diff --git a/.editorconfig b/.editorconfig index e283b2a2..f0328fd7 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,27 +1,356 @@ +# Standard properties +charset = utf-8 +end_of_line = lf +insert_final_newline = true +csharp_indent_labels = one_less_than_current +csharp_prefer_simple_using_statement = true:suggestion +csharp_prefer_braces = true:silent +csharp_style_prefer_method_group_conversion = 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 +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_inlined_variable_declaration = true:suggestion +csharp_style_prefer_top_level_statements = true:silent [*] -charset=utf-8 -end_of_line=lf -trim_trailing_whitespace=true -insert_final_newline=false -indent_style=space -indent_size=4 - # Microsoft .NET properties -csharp_new_line_before_members_in_object_initializers=false +csharp_indent_braces=false +csharp_indent_switch_labels=true +csharp_new_line_before_catch=true +csharp_new_line_before_else=true +csharp_new_line_before_finally=true +csharp_new_line_before_members_in_object_initializers=true +csharp_new_line_before_open_brace=all +csharp_new_line_between_query_expression_clauses=true csharp_preferred_modifier_order=public, private, protected, internal, new, abstract, virtual, sealed, override, static, readonly, extern, unsafe, volatile, async:suggestion -csharp_prefer_braces=true:none +csharp_preserve_single_line_blocks=true csharp_space_after_cast=false -csharp_space_after_keywords_in_control_flow_statements=false -csharp_space_between_method_call_parameter_list_parentheses=true -csharp_space_between_method_declaration_parameter_list_parentheses=true -csharp_space_between_parentheses=control_flow_statements,expressions,type_casts +csharp_space_after_colon_in_inheritance_clause=true +csharp_space_after_comma=true +csharp_space_after_dot=false +csharp_space_after_keywords_in_control_flow_statements=true +csharp_space_after_semicolon_in_for_statement=true +csharp_space_around_binary_operators=before_and_after +csharp_space_before_colon_in_inheritance_clause=true +csharp_space_before_comma=false +csharp_space_before_dot=false +csharp_space_before_open_square_brackets=false +csharp_space_before_semicolon_in_for_statement=false +csharp_space_between_empty_square_brackets=false +csharp_space_between_method_call_empty_parameter_list_parentheses=false +csharp_space_between_method_call_name_and_opening_parenthesis=false +csharp_space_between_method_call_parameter_list_parentheses=false +csharp_space_between_method_declaration_empty_parameter_list_parentheses=false +csharp_space_between_method_declaration_name_and_open_parenthesis=false +csharp_space_between_method_declaration_parameter_list_parentheses=false +csharp_space_between_parentheses=false +csharp_space_between_square_brackets=false +csharp_style_namespace_declarations= file_scoped: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_style_parentheses_in_arithmetic_binary_operators=never_if_unnecessary:none -dotnet_style_parentheses_in_other_binary_operators=never_if_unnecessary:none -dotnet_style_parentheses_in_relational_binary_operators=never_if_unnecessary:none +csharp_using_directive_placement= outside_namespace:silent +dotnet_diagnostic.bc40000.severity=warning +dotnet_diagnostic.bc400005.severity=warning +dotnet_diagnostic.bc40008.severity=warning +dotnet_diagnostic.bc40056.severity=warning +dotnet_diagnostic.bc42016.severity=warning +dotnet_diagnostic.bc42024.severity=warning +dotnet_diagnostic.bc42025.severity=warning +dotnet_diagnostic.bc42104.severity=warning +dotnet_diagnostic.bc42105.severity=warning +dotnet_diagnostic.bc42106.severity=warning +dotnet_diagnostic.bc42107.severity=warning +dotnet_diagnostic.bc42304.severity=warning +dotnet_diagnostic.bc42309.severity=warning +dotnet_diagnostic.bc42322.severity=warning +dotnet_diagnostic.bc42349.severity=warning +dotnet_diagnostic.bc42353.severity=warning +dotnet_diagnostic.bc42354.severity=warning +dotnet_diagnostic.bc42355.severity=warning +dotnet_diagnostic.bc42356.severity=warning +dotnet_diagnostic.bc42358.severity=warning +dotnet_diagnostic.bc42504.severity=warning +dotnet_diagnostic.bc42505.severity=warning +dotnet_diagnostic.cs0067.severity=warning +dotnet_diagnostic.cs0078.severity=warning +dotnet_diagnostic.cs0108.severity=warning +dotnet_diagnostic.cs0109.severity=warning +dotnet_diagnostic.cs0114.severity=warning +dotnet_diagnostic.cs0162.severity=warning +dotnet_diagnostic.cs0164.severity=warning +dotnet_diagnostic.cs0168.severity=warning +dotnet_diagnostic.cs0169.severity=warning +dotnet_diagnostic.cs0183.severity=warning +dotnet_diagnostic.cs0184.severity=warning +dotnet_diagnostic.cs0197.severity=warning +dotnet_diagnostic.cs0219.severity=warning +dotnet_diagnostic.cs0252.severity=warning +dotnet_diagnostic.cs0253.severity=warning +dotnet_diagnostic.cs0414.severity=warning +dotnet_diagnostic.cs0420.severity=warning +dotnet_diagnostic.cs0465.severity=warning +dotnet_diagnostic.cs0469.severity=warning +dotnet_diagnostic.cs0612.severity=warning +dotnet_diagnostic.cs0618.severity=warning +dotnet_diagnostic.cs0628.severity=warning +dotnet_diagnostic.cs0642.severity=warning +dotnet_diagnostic.cs0649.severity=warning +dotnet_diagnostic.cs0652.severity=warning +dotnet_diagnostic.cs0657.severity=warning +dotnet_diagnostic.cs0658.severity=warning +dotnet_diagnostic.cs0659.severity=warning +dotnet_diagnostic.cs0660.severity=warning +dotnet_diagnostic.cs0661.severity=warning +dotnet_diagnostic.cs0665.severity=warning +dotnet_diagnostic.cs0672.severity=warning +dotnet_diagnostic.cs0675.severity=warning +dotnet_diagnostic.cs0693.severity=warning +dotnet_diagnostic.cs1030.severity=warning +dotnet_diagnostic.cs1058.severity=warning +dotnet_diagnostic.cs1066.severity=warning +dotnet_diagnostic.cs1522.severity=warning +dotnet_diagnostic.cs1570.severity=warning +dotnet_diagnostic.cs1571.severity=warning +dotnet_diagnostic.cs1572.severity=warning +dotnet_diagnostic.cs1573.severity=warning +dotnet_diagnostic.cs1574.severity=warning +dotnet_diagnostic.cs1580.severity=warning +dotnet_diagnostic.cs1581.severity=warning +dotnet_diagnostic.cs1584.severity=warning +dotnet_diagnostic.cs1587.severity=warning +dotnet_diagnostic.cs1589.severity=warning +dotnet_diagnostic.cs1590.severity=warning +dotnet_diagnostic.cs1591.severity=warning +dotnet_diagnostic.cs1592.severity=warning +dotnet_diagnostic.cs1710.severity=warning +dotnet_diagnostic.cs1711.severity=warning +dotnet_diagnostic.cs1712.severity=warning +dotnet_diagnostic.cs1717.severity=warning +dotnet_diagnostic.cs1723.severity=warning +dotnet_diagnostic.cs1911.severity=warning +dotnet_diagnostic.cs1957.severity=warning +dotnet_diagnostic.cs1981.severity=warning +dotnet_diagnostic.cs1998.severity=warning +dotnet_diagnostic.cs4014.severity=warning +dotnet_diagnostic.cs7022.severity=warning +dotnet_diagnostic.cs7023.severity=warning +dotnet_diagnostic.cs7095.severity=warning +dotnet_diagnostic.cs8094.severity=warning +dotnet_diagnostic.cs8123.severity=warning +dotnet_diagnostic.cs8321.severity=warning +dotnet_diagnostic.cs8383.severity=warning +dotnet_diagnostic.cs8416.severity=warning +dotnet_diagnostic.cs8417.severity=warning +dotnet_diagnostic.cs8424.severity=warning +dotnet_diagnostic.cs8425.severity=warning +dotnet_diagnostic.cs8509.severity=warning +dotnet_diagnostic.cs8524.severity=warning +dotnet_diagnostic.cs8597.severity=warning +dotnet_diagnostic.cs8600.severity=warning +dotnet_diagnostic.cs8601.severity=warning +dotnet_diagnostic.cs8602.severity=warning +dotnet_diagnostic.cs8603.severity=warning +dotnet_diagnostic.cs8604.severity=warning +dotnet_diagnostic.cs8605.severity=warning +dotnet_diagnostic.cs8607.severity=warning +dotnet_diagnostic.cs8608.severity=warning +dotnet_diagnostic.cs8609.severity=warning +dotnet_diagnostic.cs8610.severity=warning +dotnet_diagnostic.cs8611.severity=warning +dotnet_diagnostic.cs8612.severity=warning +dotnet_diagnostic.cs8613.severity=warning +dotnet_diagnostic.cs8614.severity=warning +dotnet_diagnostic.cs8615.severity=warning +dotnet_diagnostic.cs8616.severity=warning +dotnet_diagnostic.cs8617.severity=warning +dotnet_diagnostic.cs8618.severity=warning +dotnet_diagnostic.cs8619.severity=warning +dotnet_diagnostic.cs8620.severity=warning +dotnet_diagnostic.cs8621.severity=warning +dotnet_diagnostic.cs8622.severity=warning +dotnet_diagnostic.cs8624.severity=warning +dotnet_diagnostic.cs8625.severity=warning +dotnet_diagnostic.cs8629.severity=warning +dotnet_diagnostic.cs8631.severity=warning +dotnet_diagnostic.cs8632.severity=none +dotnet_diagnostic.cs8633.severity=warning +dotnet_diagnostic.cs8634.severity=warning +dotnet_diagnostic.cs8643.severity=warning +dotnet_diagnostic.cs8644.severity=warning +dotnet_diagnostic.cs8645.severity=warning +dotnet_diagnostic.cs8655.severity=warning +dotnet_diagnostic.cs8656.severity=warning +dotnet_diagnostic.cs8667.severity=warning +dotnet_diagnostic.cs8669.severity=none +dotnet_diagnostic.cs8670.severity=warning +dotnet_diagnostic.cs8714.severity=warning +dotnet_diagnostic.cs8762.severity=warning +dotnet_diagnostic.cs8763.severity=warning +dotnet_diagnostic.cs8764.severity=warning +dotnet_diagnostic.cs8765.severity=warning +dotnet_diagnostic.cs8766.severity=warning +dotnet_diagnostic.cs8767.severity=warning +dotnet_diagnostic.cs8768.severity=warning +dotnet_diagnostic.cs8769.severity=warning +dotnet_diagnostic.cs8770.severity=warning +dotnet_diagnostic.cs8774.severity=warning +dotnet_diagnostic.cs8775.severity=warning +dotnet_diagnostic.cs8776.severity=warning +dotnet_diagnostic.cs8777.severity=warning +dotnet_diagnostic.cs8794.severity=warning +dotnet_diagnostic.cs8819.severity=warning +dotnet_diagnostic.cs8824.severity=warning +dotnet_diagnostic.cs8825.severity=warning +dotnet_diagnostic.cs8846.severity=warning +dotnet_diagnostic.cs8847.severity=warning +dotnet_diagnostic.cs8851.severity=warning +dotnet_diagnostic.cs8860.severity=warning +dotnet_diagnostic.cs8892.severity=warning +dotnet_diagnostic.cs8907.severity=warning +dotnet_diagnostic.cs8947.severity=warning +dotnet_diagnostic.cs8960.severity=warning +dotnet_diagnostic.cs8961.severity=warning +dotnet_diagnostic.cs8962.severity=warning +dotnet_diagnostic.cs8963.severity=warning +dotnet_diagnostic.cs8965.severity=warning +dotnet_diagnostic.cs8966.severity=warning +dotnet_diagnostic.cs8971.severity=warning +dotnet_diagnostic.wme006.severity=warning +dotnet_naming_rule.constants_rule.import_to_resharper=as_predefined +dotnet_naming_rule.constants_rule.severity = warning +dotnet_naming_rule.constants_rule.style = upper_camel_case_style +dotnet_naming_rule.constants_rule.symbols=constants_symbols +dotnet_naming_rule.event_rule.import_to_resharper=as_predefined +dotnet_naming_rule.event_rule.severity = warning +dotnet_naming_rule.event_rule.style = upper_camel_case_style +dotnet_naming_rule.event_rule.symbols=event_symbols +dotnet_naming_rule.interfaces_rule.import_to_resharper=as_predefined +dotnet_naming_rule.interfaces_rule.severity = warning +dotnet_naming_rule.interfaces_rule.style = i_upper_camel_case_style +dotnet_naming_rule.interfaces_rule.symbols=interfaces_symbols +dotnet_naming_rule.locals_rule.import_to_resharper=as_predefined +dotnet_naming_rule.locals_rule.severity = warning +dotnet_naming_rule.locals_rule.style = lower_camel_case_style_1 +dotnet_naming_rule.locals_rule.symbols=locals_symbols +dotnet_naming_rule.local_constants_rule.import_to_resharper=as_predefined +dotnet_naming_rule.local_constants_rule.severity = warning +dotnet_naming_rule.local_constants_rule.style = lower_camel_case_style_1 +dotnet_naming_rule.local_constants_rule.symbols=local_constants_symbols +dotnet_naming_rule.local_functions_rule.import_to_resharper=as_predefined +dotnet_naming_rule.local_functions_rule.severity = warning +dotnet_naming_rule.local_functions_rule.style = upper_camel_case_style +dotnet_naming_rule.local_functions_rule.symbols=local_functions_symbols +dotnet_naming_rule.method_rule.import_to_resharper=as_predefined +dotnet_naming_rule.method_rule.severity = warning +dotnet_naming_rule.method_rule.style = upper_camel_case_style +dotnet_naming_rule.method_rule.symbols=method_symbols +dotnet_naming_rule.parameters_rule.import_to_resharper=as_predefined +dotnet_naming_rule.parameters_rule.severity = warning +dotnet_naming_rule.parameters_rule.style = lower_camel_case_style_1 +dotnet_naming_rule.parameters_rule.symbols=parameters_symbols +dotnet_naming_rule.private_constants_rule.import_to_resharper=as_predefined +dotnet_naming_rule.private_constants_rule.severity = warning +dotnet_naming_rule.private_constants_rule.style = upper_camel_case_style +dotnet_naming_rule.private_constants_rule.symbols=private_constants_symbols +dotnet_naming_rule.private_instance_fields_rule.import_to_resharper=as_predefined +dotnet_naming_rule.private_instance_fields_rule.severity = warning +dotnet_naming_rule.private_instance_fields_rule.style = lower_camel_case_style +dotnet_naming_rule.private_instance_fields_rule.symbols=private_instance_fields_symbols +dotnet_naming_rule.private_static_fields_rule.import_to_resharper=as_predefined +dotnet_naming_rule.private_static_fields_rule.severity = warning +dotnet_naming_rule.private_static_fields_rule.style = lower_camel_case_style +dotnet_naming_rule.private_static_fields_rule.symbols=private_static_fields_symbols +dotnet_naming_rule.private_static_readonly_rule.import_to_resharper=as_predefined +dotnet_naming_rule.private_static_readonly_rule.severity = warning +dotnet_naming_rule.private_static_readonly_rule.style = upper_camel_case_style +dotnet_naming_rule.private_static_readonly_rule.symbols=private_static_readonly_symbols +dotnet_naming_rule.property_rule.import_to_resharper=as_predefined +dotnet_naming_rule.property_rule.severity = warning +dotnet_naming_rule.property_rule.style = upper_camel_case_style +dotnet_naming_rule.property_rule.symbols=property_symbols +dotnet_naming_rule.public_fields_rule.import_to_resharper=as_predefined +dotnet_naming_rule.public_fields_rule.severity = warning +dotnet_naming_rule.public_fields_rule.style = upper_camel_case_style +dotnet_naming_rule.public_fields_rule.symbols=public_fields_symbols +dotnet_naming_rule.static_readonly_rule.import_to_resharper=as_predefined +dotnet_naming_rule.static_readonly_rule.severity = warning +dotnet_naming_rule.static_readonly_rule.style = upper_camel_case_style +dotnet_naming_rule.static_readonly_rule.symbols=static_readonly_symbols +dotnet_naming_rule.types_and_namespaces_rule.import_to_resharper=as_predefined +dotnet_naming_rule.types_and_namespaces_rule.severity = warning +dotnet_naming_rule.types_and_namespaces_rule.style = upper_camel_case_style +dotnet_naming_rule.types_and_namespaces_rule.symbols=types_and_namespaces_symbols +dotnet_naming_rule.type_parameters_rule.import_to_resharper=as_predefined +dotnet_naming_rule.type_parameters_rule.severity = warning +dotnet_naming_rule.type_parameters_rule.style = t_upper_camel_case_style +dotnet_naming_rule.type_parameters_rule.symbols=type_parameters_symbols +dotnet_naming_style.i_upper_camel_case_style.capitalization=pascal_case +dotnet_naming_style.i_upper_camel_case_style.required_prefix=I +dotnet_naming_style.lower_camel_case_style.capitalization=camel_case +dotnet_naming_style.lower_camel_case_style.required_prefix=_ +dotnet_naming_style.lower_camel_case_style_1.capitalization=camel_case +dotnet_naming_style.t_upper_camel_case_style.capitalization=pascal_case +dotnet_naming_style.t_upper_camel_case_style.required_prefix=T +dotnet_naming_style.upper_camel_case_style.capitalization=pascal_case +dotnet_naming_symbols.constants_symbols.applicable_accessibilities=public,internal,protected,protected_internal,private_protected +dotnet_naming_symbols.constants_symbols.applicable_kinds=field +dotnet_naming_symbols.constants_symbols.required_modifiers=const +dotnet_naming_symbols.event_symbols.applicable_accessibilities=* +dotnet_naming_symbols.event_symbols.applicable_kinds=event +dotnet_naming_symbols.interfaces_symbols.applicable_accessibilities=* +dotnet_naming_symbols.interfaces_symbols.applicable_kinds=interface +dotnet_naming_symbols.locals_symbols.applicable_accessibilities=* +dotnet_naming_symbols.locals_symbols.applicable_kinds=local +dotnet_naming_symbols.local_constants_symbols.applicable_accessibilities=* +dotnet_naming_symbols.local_constants_symbols.applicable_kinds=local +dotnet_naming_symbols.local_constants_symbols.required_modifiers=const +dotnet_naming_symbols.local_functions_symbols.applicable_accessibilities=* +dotnet_naming_symbols.local_functions_symbols.applicable_kinds=local_function +dotnet_naming_symbols.method_symbols.applicable_accessibilities=* +dotnet_naming_symbols.method_symbols.applicable_kinds=method +dotnet_naming_symbols.parameters_symbols.applicable_accessibilities=* +dotnet_naming_symbols.parameters_symbols.applicable_kinds=parameter +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_naming_symbols.private_instance_fields_symbols.applicable_accessibilities=private +dotnet_naming_symbols.private_instance_fields_symbols.applicable_kinds=field +dotnet_naming_symbols.private_static_fields_symbols.applicable_accessibilities=private +dotnet_naming_symbols.private_static_fields_symbols.applicable_kinds=field +dotnet_naming_symbols.private_static_fields_symbols.required_modifiers=static +dotnet_naming_symbols.private_static_readonly_symbols.applicable_accessibilities=private +dotnet_naming_symbols.private_static_readonly_symbols.applicable_kinds=field +dotnet_naming_symbols.private_static_readonly_symbols.required_modifiers=static,readonly +dotnet_naming_symbols.property_symbols.applicable_accessibilities=* +dotnet_naming_symbols.property_symbols.applicable_kinds=property +dotnet_naming_symbols.public_fields_symbols.applicable_accessibilities=public,internal,protected,protected_internal,private_protected +dotnet_naming_symbols.public_fields_symbols.applicable_kinds=field +dotnet_naming_symbols.static_readonly_symbols.applicable_accessibilities=public,internal,protected,protected_internal,private_protected +dotnet_naming_symbols.static_readonly_symbols.applicable_kinds=field +dotnet_naming_symbols.static_readonly_symbols.required_modifiers=static,readonly +dotnet_naming_symbols.types_and_namespaces_symbols.applicable_accessibilities=* +dotnet_naming_symbols.types_and_namespaces_symbols.applicable_kinds=namespace,class,struct,enum,delegate +dotnet_naming_symbols.type_parameters_symbols.applicable_accessibilities=* +dotnet_naming_symbols.type_parameters_symbols.applicable_kinds=type_parameter +dotnet_separate_import_directive_groups=false +dotnet_sort_system_directives_first=true +dotnet_style_parentheses_in_arithmetic_binary_operators=never_if_unnecessary:suggestion +dotnet_style_parentheses_in_other_binary_operators=never_if_unnecessary:suggestion +dotnet_style_parentheses_in_relational_binary_operators=never_if_unnecessary:suggestion dotnet_style_predefined_type_for_locals_parameters_members=true:suggestion dotnet_style_predefined_type_for_member_access=true:suggestion dotnet_style_qualification_for_event=false:suggestion @@ -29,57 +358,3280 @@ 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 +file_header_template= # ReSharper properties +resharper_accessor_owner_body=expression_body +resharper_alignment_tab_fill_style=use_spaces +resharper_align_first_arg_by_paren=false +resharper_align_linq_query=false +resharper_align_multiline_argument=true +resharper_align_multiline_array_and_object_initializer=false +resharper_align_multiline_array_initializer=true resharper_align_multiline_binary_expressions_chain=false -resharper_align_multiline_calls_chain=false -resharper_autodetect_indent_settings=true +resharper_align_multiline_binary_patterns=false +resharper_align_multiline_ctor_init=true +resharper_align_multiline_expression_braces=false +resharper_align_multiline_implements_list=true +resharper_align_multiline_property_pattern=false +resharper_align_multiline_statement_conditions=true +resharper_align_multiline_switch_expression=false +resharper_align_multiline_type_argument=true +resharper_align_multiline_type_parameter=true +resharper_align_multline_type_parameter_constrains=true +resharper_align_multline_type_parameter_list=false +resharper_align_tuple_components=false +resharper_align_union_type_usage=true +resharper_allow_alias=true +resharper_allow_comment_after_lbrace=false +resharper_allow_far_alignment=false +resharper_always_use_end_of_line_brace_style=false +resharper_apply_auto_detected_rules=false +resharper_apply_on_completion=false +resharper_arguments_anonymous_function=positional +resharper_arguments_literal=positional +resharper_arguments_named=positional +resharper_arguments_other=positional +resharper_arguments_skip_single=false +resharper_arguments_string_literal=positional +resharper_attribute_style=do_not_touch +resharper_autodetect_indent_settings=false +resharper_blank_lines_after_block_statements=1 +resharper_blank_lines_after_case=0 +resharper_blank_lines_after_control_transfer_statements=1 +resharper_blank_lines_after_file_scoped_namespace_directive=1 +resharper_blank_lines_after_imports=1 +resharper_blank_lines_after_multiline_statements=0 +resharper_blank_lines_after_options=1 +resharper_blank_lines_after_start_comment=1 +resharper_blank_lines_after_using_list=1 +resharper_blank_lines_around_accessor=0 +resharper_blank_lines_around_auto_property=1 +resharper_blank_lines_around_block_case_section=0 +resharper_blank_lines_around_class_definition=1 +resharper_blank_lines_around_field=1 +resharper_blank_lines_around_function_declaration=0 +resharper_blank_lines_around_function_definition=1 +resharper_blank_lines_around_global_attribute=0 +resharper_blank_lines_around_invocable=1 +resharper_blank_lines_around_local_method=1 +resharper_blank_lines_around_multiline_case_section=0 +resharper_blank_lines_around_namespace=1 +resharper_blank_lines_around_other_declaration=0 +resharper_blank_lines_around_property=1 +resharper_blank_lines_around_razor_functions=1 +resharper_blank_lines_around_razor_helpers=1 +resharper_blank_lines_around_razor_sections=1 +resharper_blank_lines_around_region=1 +resharper_blank_lines_around_single_line_accessor=0 +resharper_blank_lines_around_single_line_auto_property=0 +resharper_blank_lines_around_single_line_field=0 +resharper_blank_lines_around_single_line_function_definition=0 +resharper_blank_lines_around_single_line_invocable=0 +resharper_blank_lines_around_single_line_local_method=0 +resharper_blank_lines_around_single_line_property=0 +resharper_blank_lines_around_single_line_type=0 +resharper_blank_lines_around_type=1 +resharper_blank_lines_before_block_statements=0 +resharper_blank_lines_before_case=0 +resharper_blank_lines_before_control_transfer_statements=0 +resharper_blank_lines_before_multiline_statements=0 +resharper_blank_lines_before_single_line_comment=0 +resharper_blank_lines_inside_namespace=0 +resharper_blank_lines_inside_region=1 +resharper_blank_lines_inside_type=0 +resharper_blank_line_after_pi=true +resharper_braces_for_dowhile=required +resharper_braces_for_fixed=required +resharper_braces_for_for=required_for_multiline +resharper_braces_for_foreach=required_for_multiline +resharper_braces_for_ifelse=not_required_for_both +resharper_braces_for_lock=required +resharper_braces_for_using=required +resharper_braces_for_while=required_for_multiline resharper_braces_redundant=true +resharper_break_template_declaration=line_break +resharper_can_use_global_alias=true +resharper_configure_await_analysis_mode=disabled resharper_constructor_or_destructor_body=expression_body +resharper_continuous_indent_multiplier=1 +resharper_continuous_line_indent=single +resharper_cpp_align_multiline_argument=true +resharper_cpp_align_multiline_calls_chain=true +resharper_cpp_align_multiline_extends_list=true +resharper_cpp_align_multiline_for_stmt=true +resharper_cpp_align_multiline_parameter=true +resharper_cpp_align_multiple_declaration=true +resharper_cpp_align_ternary=align_not_nested +resharper_cpp_anonymous_method_declaration_braces=next_line +resharper_cpp_case_block_braces=next_line_shifted_2 +resharper_cpp_empty_block_style=multiline +resharper_cpp_indent_switch_labels=false +resharper_cpp_insert_final_newline=false +resharper_cpp_int_align_comments=false +resharper_cpp_invocable_declaration_braces=next_line +resharper_cpp_max_line_length=120 +resharper_cpp_new_line_before_catch=true +resharper_cpp_new_line_before_else=true +resharper_cpp_new_line_before_while=true +resharper_cpp_other_braces=next_line +resharper_cpp_space_around_binary_operator=true +resharper_cpp_type_declaration_braces=next_line +resharper_cpp_wrap_arguments_style=wrap_if_long +resharper_cpp_wrap_lines=true +resharper_cpp_wrap_parameters_style=wrap_if_long +resharper_csharp_align_multiline_argument=false +resharper_csharp_align_multiline_calls_chain=false +resharper_csharp_align_multiline_expression=false +resharper_csharp_align_multiline_extends_list=false +resharper_csharp_align_multiline_for_stmt=false +resharper_csharp_align_multiline_parameter=false +resharper_csharp_align_multiple_declaration=true resharper_csharp_empty_block_style=together +resharper_csharp_insert_final_newline=true +resharper_csharp_int_align_comments=true resharper_csharp_max_line_length=144 -resharper_csharp_space_within_array_access_brackets=true -resharper_enforce_line_ending_style=true +resharper_csharp_naming_rule.enum_member=AaBb +resharper_csharp_naming_rule.method_property_event=AaBb +resharper_csharp_naming_rule.other=AaBb +resharper_csharp_new_line_before_while=false +resharper_csharp_prefer_qualified_reference=false +resharper_csharp_space_after_unary_operator=false +resharper_csharp_wrap_arguments_style=wrap_if_long +resharper_csharp_wrap_before_binary_opsign=true +resharper_csharp_wrap_for_stmt_header_style=wrap_if_long +resharper_csharp_wrap_lines=true +resharper_csharp_wrap_parameters_style=wrap_if_long +resharper_css_brace_style=end_of_line +resharper_css_insert_final_newline=false +resharper_css_keep_blank_lines_between_declarations=1 +resharper_css_max_line_length=120 +resharper_css_wrap_lines=true +resharper_cxxcli_property_declaration_braces=next_line +resharper_declarations_style=separate_lines +resharper_default_exception_variable_name=e +resharper_default_value_when_type_evident=default_literal +resharper_default_value_when_type_not_evident=default_literal +resharper_delete_quotes_from_solid_values=false +resharper_disable_blank_line_changes=false +resharper_disable_formatter=false +resharper_disable_indenter=false +resharper_disable_int_align=false +resharper_disable_line_break_changes=false +resharper_disable_line_break_removal=false +resharper_disable_space_changes=false +resharper_disable_space_changes_before_trailing_comment=false +resharper_dont_remove_extra_blank_lines=false +resharper_enable_wrapping=false +resharper_enforce_line_ending_style=false +resharper_event_handler_pattern_long=$object$On$event$ +resharper_event_handler_pattern_short=On$event$ +resharper_expression_braces=inside +resharper_expression_pars=inside +resharper_extra_spaces=remove_all +resharper_force_attribute_style=separate +resharper_force_chop_compound_do_expression=false +resharper_force_chop_compound_if_expression=false +resharper_force_chop_compound_while_expression=false +resharper_force_control_statements_braces=do_not_change +resharper_force_linebreaks_inside_complex_literals=true +resharper_force_variable_declarations_on_new_line=false +resharper_format_leading_spaces_decl=false +resharper_free_block_braces=next_line +resharper_function_declaration_return_type_style=do_not_change +resharper_function_definition_return_type_style=do_not_change +resharper_generator_mode=false +resharper_html_attribute_indent=align_by_first_attribute +resharper_html_insert_final_newline=false +resharper_html_linebreak_before_elements=body,div,p,form,h1,h2,h3 +resharper_html_max_blank_lines_between_tags=2 +resharper_html_max_line_length=120 +resharper_html_pi_attribute_style=on_single_line +resharper_html_space_before_self_closing=false +resharper_html_wrap_lines=true +resharper_ignore_space_preservation=false +resharper_include_prefix_comment_in_indent=false +resharper_indent_access_specifiers_from_class=false +resharper_indent_aligned_ternary=true +resharper_indent_anonymous_method_block=false +resharper_indent_braces_inside_statement_conditions=true +resharper_indent_case_from_select=true +resharper_indent_child_elements=OneIndent +resharper_indent_class_members_from_access_specifiers=false +resharper_indent_comment=true +resharper_indent_inside_namespace=true +resharper_indent_invocation_pars=inside +resharper_indent_left_par_inside_expression=false +resharper_indent_method_decl_pars=inside +resharper_indent_nested_fixed_stmt=false +resharper_indent_nested_foreach_stmt=true +resharper_indent_nested_for_stmt=true +resharper_indent_nested_lock_stmt=false +resharper_indent_nested_usings_stmt=false +resharper_indent_nested_while_stmt=true +resharper_indent_pars=inside +resharper_indent_preprocessor_directives=none +resharper_indent_preprocessor_if=no_indent +resharper_indent_preprocessor_other=no_indent +resharper_indent_preprocessor_region=usual_indent +resharper_indent_statement_pars=inside +resharper_indent_text=OneIndent +resharper_indent_typearg_angles=inside +resharper_indent_typeparam_angles=inside +resharper_indent_type_constraints=true +resharper_indent_wrapped_function_names=false +resharper_instance_members_qualify_declared_in=this_class, base_class +resharper_int_align=true resharper_int_align_assignments=true -resharper_int_align_comments=true +resharper_int_align_binary_expressions=false +resharper_int_align_declaration_names=false +resharper_int_align_eq=false resharper_int_align_fields=true -resharper_int_align_invocations=false +resharper_int_align_fix_in_adjacent=true +resharper_int_align_invocations=true +resharper_int_align_methods=true resharper_int_align_nested_ternary=true -resharper_int_align_properties=false +resharper_int_align_parameters=false +resharper_int_align_properties=true +resharper_int_align_property_patterns=true resharper_int_align_switch_expressions=true resharper_int_align_switch_sections=true resharper_int_align_variables=true +resharper_js_align_multiline_parameter=false +resharper_js_align_multiple_declaration=false +resharper_js_align_ternary=none +resharper_js_brace_style=end_of_line +resharper_js_empty_block_style=multiline +resharper_js_indent_switch_labels=false +resharper_js_insert_final_newline=false +resharper_js_keep_blank_lines_between_declarations=2 +resharper_js_max_line_length=120 +resharper_js_new_line_before_catch=false +resharper_js_new_line_before_else=false +resharper_js_new_line_before_finally=false +resharper_js_new_line_before_while=false +resharper_js_space_around_binary_operator=true +resharper_js_wrap_arguments_style=chop_if_long +resharper_js_wrap_before_binary_opsign=false +resharper_js_wrap_for_stmt_header_style=chop_if_long +resharper_js_wrap_lines=true +resharper_js_wrap_parameters_style=chop_if_long +resharper_keep_blank_lines_in_code=2 +resharper_keep_blank_lines_in_declarations=2 +resharper_keep_existing_attribute_arrangement=false +resharper_keep_existing_declaration_block_arrangement=false +resharper_keep_existing_declaration_parens_arrangement=true +resharper_keep_existing_embedded_arrangement=false +resharper_keep_existing_embedded_block_arrangement=false +resharper_keep_existing_enum_arrangement=false +resharper_keep_existing_expr_member_arrangement=false +resharper_keep_existing_initializer_arrangement=false +resharper_keep_existing_invocation_parens_arrangement=true +resharper_keep_existing_property_patterns_arrangement=true +resharper_keep_existing_switch_expression_arrangement=false +resharper_keep_nontrivial_alias=true +resharper_keep_user_linebreaks=true +resharper_keep_user_wrapping=true +resharper_linebreaks_around_razor_statements=true +resharper_linebreaks_inside_tags_for_elements_longer_than=2147483647 +resharper_linebreaks_inside_tags_for_elements_with_child_elements=true +resharper_linebreaks_inside_tags_for_multiline_elements=true +resharper_linebreak_before_all_elements=false +resharper_linebreak_before_multiline_elements=true +resharper_linebreak_before_singleline_elements=false +resharper_line_break_after_colon_in_member_initializer_lists=do_not_change +resharper_line_break_after_comma_in_member_initializer_lists=false +resharper_line_break_before_comma_in_member_initializer_lists=false +resharper_line_break_before_requires_clause=do_not_change +resharper_linkage_specification_braces=end_of_line +resharper_linkage_specification_indentation=none resharper_local_function_body=expression_body +resharper_macro_block_begin= +resharper_macro_block_end= +resharper_max_array_initializer_elements_on_line=10000 +resharper_max_attribute_length_for_same_line=38 +resharper_max_enum_members_on_line=1 +resharper_max_formal_parameters_on_line=10000 +resharper_max_initializer_elements_on_line=1 +resharper_max_invocation_arguments_on_line=10000 +resharper_media_query_style=same_line +resharper_member_initializer_list_style=do_not_change resharper_method_or_operator_body=expression_body +resharper_min_blank_lines_after_imports=0 +resharper_min_blank_lines_around_fields=0 +resharper_min_blank_lines_around_functions=1 +resharper_min_blank_lines_around_types=1 +resharper_min_blank_lines_between_declarations=1 +resharper_namespace_declaration_braces=next_line +resharper_namespace_indentation=all +resharper_nested_ternary_style=autodetect +resharper_new_line_before_enumerators=true +resharper_normalize_tag_names=false +resharper_no_indent_inside_elements=html,body,thead,tbody,tfoot +resharper_no_indent_inside_if_element_longer_than=200 +resharper_object_creation_when_type_evident=target_typed +resharper_object_creation_when_type_not_evident=explicitly_typed +resharper_old_engine=false +resharper_options_braces_pointy=false +resharper_outdent_binary_ops=true +resharper_outdent_binary_pattern_ops=false +resharper_outdent_commas=false +resharper_outdent_dots=false +resharper_outdent_namespace_member=false +resharper_outdent_statement_labels=false +resharper_outdent_ternary_ops=false +resharper_parentheses_non_obvious_operations=none, bitwise, bitwise_inclusive_or, bitwise_exclusive_or, shift, bitwise_and +resharper_parentheses_redundancy_style=remove_if_not_clarifies_precedence +resharper_parentheses_same_type_operations=false +resharper_pi_attributes_indent=align_by_first_attribute resharper_place_attribute_on_same_line=false +resharper_place_class_decorator_on_the_same_line=false +resharper_place_comments_at_first_column=false +resharper_place_constructor_initializer_on_same_line=false +resharper_place_each_decorator_on_new_line=false +resharper_place_event_attribute_on_same_line=false +resharper_place_expr_accessor_on_single_line=true +resharper_place_expr_method_on_single_line=false +resharper_place_expr_property_on_single_line=false +resharper_place_field_decorator_on_the_same_line=false +resharper_place_linq_into_on_new_line=true +resharper_place_method_decorator_on_the_same_line=false +resharper_place_namespace_definitions_on_same_line=false +resharper_place_property_attribute_on_same_line=false +resharper_place_property_decorator_on_the_same_line=false +resharper_place_simple_case_statement_on_same_line=if_owner_is_single_line +resharper_place_simple_embedded_statement_on_same_line=false +resharper_place_simple_enum_on_single_line=true +resharper_place_simple_initializer_on_single_line=true +resharper_place_simple_property_pattern_on_single_line=true +resharper_place_simple_switch_expression_on_single_line=true +resharper_place_template_args_on_new_line=false +resharper_place_type_constraints_on_same_line=true +resharper_prefer_explicit_discard_declaration=false +resharper_prefer_separate_deconstructed_variables_declaration=false +resharper_preserve_spaces_inside_tags=pre,textarea +resharper_properties_style=separate_lines_for_nonsingle +resharper_protobuf_brace_style=end_of_line +resharper_protobuf_empty_block_style=together_same_line +resharper_protobuf_insert_final_newline=false +resharper_protobuf_max_line_length=120 +resharper_protobuf_wrap_lines=true +resharper_qualified_using_at_nested_scope=false +resharper_quote_style=doublequoted +resharper_razor_prefer_qualified_reference=true +resharper_remove_blank_lines_near_braces=false +resharper_remove_blank_lines_near_braces_in_code=true +resharper_remove_blank_lines_near_braces_in_declarations=true +resharper_remove_this_qualifier=true +resharper_requires_expression_braces=next_line +resharper_resx_attribute_indent=single_indent +resharper_resx_insert_final_newline=false +resharper_resx_linebreak_before_elements= +resharper_resx_max_blank_lines_between_tags=0 +resharper_resx_max_line_length=2147483647 +resharper_resx_pi_attribute_style=do_not_touch +resharper_resx_space_before_self_closing=false +resharper_resx_wrap_lines=false +resharper_resx_wrap_tags_and_pi=false +resharper_resx_wrap_text=false +resharper_selector_style=same_line +resharper_show_autodetect_configure_formatting_tip=true +resharper_simple_blocks=do_not_change +resharper_simple_block_style=do_not_change +resharper_simple_case_statement_style=do_not_change +resharper_simple_embedded_statement_style=do_not_change +resharper_single_statement_function_style=do_not_change +resharper_sort_attributes=false +resharper_sort_class_selectors=false +resharper_sort_usings=true +resharper_sort_usings_lowercase_first=false +resharper_spaces_around_eq_in_attribute=false +resharper_spaces_around_eq_in_pi_attribute=false +resharper_spaces_inside_tags=false +resharper_space_after_arrow=true +resharper_space_after_attributes=true +resharper_space_after_attribute_target_colon=true resharper_space_after_cast=false -resharper_space_within_checked_parentheses=true -resharper_space_within_default_parentheses=true -resharper_space_within_nameof_parentheses=true +resharper_space_after_colon=true +resharper_space_after_colon_in_case=true +resharper_space_after_colon_in_inheritance_clause=true +resharper_space_after_colon_in_type_annotation=true +resharper_space_after_comma=true +resharper_space_after_for_colon=true +resharper_space_after_function_comma=true +resharper_space_after_keywords_in_control_flow_statements=true +resharper_space_after_last_attribute=false +resharper_space_after_last_pi_attribute=false +resharper_space_after_media_colon=true +resharper_space_after_media_comma=true +resharper_space_after_operator_keyword=true +resharper_space_after_property_colon=true +resharper_space_after_property_semicolon=true +resharper_space_after_ptr_in_data_member=true +resharper_space_after_ptr_in_data_members=false +resharper_space_after_ptr_in_method=true +resharper_space_after_ref_in_data_member=true +resharper_space_after_ref_in_data_members=false +resharper_space_after_ref_in_method=true +resharper_space_after_selector_comma=true +resharper_space_after_semicolon_in_for_statement=true +resharper_space_after_separator=false +resharper_space_after_ternary_colon=true +resharper_space_after_ternary_quest=true +resharper_space_after_triple_slash=true +resharper_space_after_type_parameter_constraint_colon=true +resharper_space_around_additive_op=true +resharper_space_around_alias_eq=true +resharper_space_around_assignment_op=true +resharper_space_around_assignment_operator=true +resharper_space_around_attribute_match_operator=false +resharper_space_around_deref_in_trailing_return_type=true +resharper_space_around_lambda_arrow=true +resharper_space_around_member_access_operator=false +resharper_space_around_operator=true +resharper_space_around_pipe_or_amper_in_type_usage=true +resharper_space_around_relational_op=true +resharper_space_around_selector_operator=true +resharper_space_around_shift_op=true +resharper_space_around_stmt_colon=true +resharper_space_around_ternary_operator=true +resharper_space_before_array_rank_parentheses=false +resharper_space_before_arrow=true +resharper_space_before_attribute_target_colon=false +resharper_space_before_checked_parentheses=false +resharper_space_before_colon=false +resharper_space_before_colon_in_case=false +resharper_space_before_colon_in_inheritance_clause=true +resharper_space_before_colon_in_type_annotation=false +resharper_space_before_comma=false +resharper_space_before_default_parentheses=false +resharper_space_before_empty_invocation_parentheses=false +resharper_space_before_empty_method_parentheses=false +resharper_space_before_for_colon=true +resharper_space_before_function_comma=false +resharper_space_before_initializer_braces=false +resharper_space_before_invocation_parentheses=false +resharper_space_before_label_colon=false +resharper_space_before_lambda_parentheses=false +resharper_space_before_media_colon=false +resharper_space_before_media_comma=false +resharper_space_before_method_parentheses=false +resharper_space_before_nameof_parentheses=false +resharper_space_before_new_parentheses=false +resharper_space_before_nullable_mark=false +resharper_space_before_open_square_brackets=false +resharper_space_before_pointer_asterik_declaration=false +resharper_space_before_property_colon=false +resharper_space_before_property_semicolon=false +resharper_space_before_ptr_in_abstract_decl=false +resharper_space_before_ptr_in_data_member=false +resharper_space_before_ptr_in_data_members=true +resharper_space_before_ptr_in_method=false +resharper_space_before_ref_in_abstract_decl=false +resharper_space_before_ref_in_data_member=false +resharper_space_before_ref_in_data_members=true +resharper_space_before_ref_in_method=false +resharper_space_before_selector_comma=false +resharper_space_before_semicolon=false +resharper_space_before_semicolon_in_for_statement=false +resharper_space_before_separator=false +resharper_space_before_singleline_accessorholder=true +resharper_space_before_sizeof_parentheses=false +resharper_space_before_template_args=false +resharper_space_before_template_params=true +resharper_space_before_ternary_colon=true +resharper_space_before_ternary_quest=true +resharper_space_before_trailing_comment=true +resharper_space_before_typeof_parentheses=false +resharper_space_before_type_argument_angle=false +resharper_space_before_type_parameters_brackets=false +resharper_space_before_type_parameter_angle=false +resharper_space_before_type_parameter_constraint_colon=true +resharper_space_before_type_parameter_parentheses=true +resharper_space_between_accessors_in_singleline_property=true +resharper_space_between_attribute_sections=true +resharper_space_between_closing_angle_brackets_in_template_args=false +resharper_space_between_empty_square_brackets=false +resharper_space_between_keyword_and_expression=true +resharper_space_between_keyword_and_type=true +resharper_space_between_method_call_empty_parameter_list_parentheses=false +resharper_space_between_method_call_name_and_opening_parenthesis=false +resharper_space_between_method_call_parameter_list_parentheses=false +resharper_space_between_method_declaration_empty_parameter_list_parentheses=false +resharper_space_between_method_declaration_name_and_open_parenthesis=false +resharper_space_between_method_declaration_parameter_list_parentheses=false +resharper_space_between_parentheses_of_control_flow_statements=false +resharper_space_between_square_brackets=false +resharper_space_between_typecast_parentheses=false +resharper_space_colon_after=true +resharper_space_colon_before=false +resharper_space_comma=true +resharper_space_equals=true +resharper_space_inside_braces=true +resharper_space_in_singleline_accessorholder=true +resharper_space_in_singleline_anonymous_method=true +resharper_space_in_singleline_method=true +resharper_space_near_postfix_and_prefix_op=false +resharper_space_within_array_initialization_braces=false +resharper_space_within_array_rank_empty_parentheses=false +resharper_space_within_array_rank_parentheses=false +resharper_space_within_attribute_angles=false +resharper_space_within_attribute_match_brackets=false +resharper_space_within_checked_parentheses=false +resharper_space_within_default_parentheses=false +resharper_space_within_empty_braces=true +resharper_space_within_empty_initializer_braces=false +resharper_space_within_empty_invocation_parentheses=false +resharper_space_within_empty_method_parentheses=false +resharper_space_within_empty_object_literal_braces=false +resharper_space_within_empty_template_params=false +resharper_space_within_expression_parentheses=false +resharper_space_within_function_parentheses=false +resharper_space_within_import_braces=true +resharper_space_within_initializer_braces=false +resharper_space_within_invocation_parentheses=false +resharper_space_within_media_block=true +resharper_space_within_media_parentheses=false +resharper_space_within_method_parentheses=false +resharper_space_within_nameof_parentheses=false +resharper_space_within_new_parentheses=false +resharper_space_within_object_literal_braces=true +resharper_space_within_parentheses=false +resharper_space_within_property_block=true resharper_space_within_single_line_array_initializer_braces=true -resharper_space_within_sizeof_parentheses=true -resharper_space_within_typeof_parentheses=true -resharper_space_within_type_argument_angles=true -resharper_space_within_type_parameter_angles=true +resharper_space_within_sizeof_parentheses=false +resharper_space_within_template_args=false +resharper_space_within_template_argument=false +resharper_space_within_template_params=false +resharper_space_within_tuple_parentheses=false +resharper_space_within_typeof_parentheses=false +resharper_space_within_type_argument_angles=false +resharper_space_within_type_parameters_brackets=false +resharper_space_within_type_parameter_angles=false +resharper_space_within_type_parameter_parentheses=false +resharper_special_else_if_treatment=true +resharper_static_members_qualify_members=none +resharper_static_members_qualify_with=declared_type +resharper_stick_comment=true +resharper_support_vs_event_naming_pattern=true +resharper_termination_style=ensure_semicolon +resharper_toplevel_function_declaration_return_type_style=do_not_change +resharper_toplevel_function_definition_return_type_style=do_not_change +resharper_trailing_comma_in_multiline_lists=true +resharper_trailing_comma_in_singleline_lists=false +resharper_types_braces=end_of_line +resharper_use_continuous_indent_inside_initializer_braces=true +resharper_use_continuous_indent_inside_parens=true +resharper_use_continuous_line_indent_in_expression_braces=false +resharper_use_continuous_line_indent_in_method_pars=false +resharper_use_heuristics_for_body_style=true +resharper_use_indents_from_main_language_in_file=true +resharper_use_indent_from_previous_element=true resharper_use_indent_from_vs=false -resharper_wrap_lines=true +resharper_use_roslyn_logic_for_evident_types=false +resharper_vb_align_multiline_argument=true +resharper_vb_align_multiline_expression=true +resharper_vb_align_multiline_parameter=true +resharper_vb_align_multiple_declaration=true +resharper_vb_insert_final_newline=false +resharper_vb_max_line_length=120 +resharper_vb_place_field_attribute_on_same_line=true +resharper_vb_place_method_attribute_on_same_line=false +resharper_vb_place_type_attribute_on_same_line=false +resharper_vb_prefer_qualified_reference=false +resharper_vb_space_after_unary_operator=true +resharper_vb_space_around_multiplicative_op=false +resharper_vb_wrap_arguments_style=wrap_if_long +resharper_vb_wrap_before_binary_opsign=false +resharper_vb_wrap_lines=true +resharper_vb_wrap_parameters_style=wrap_if_long +resharper_wrap_after_binary_opsign=true +resharper_wrap_after_declaration_lpar=false +resharper_wrap_after_dot=false +resharper_wrap_after_dot_in_method_calls=false +resharper_wrap_after_expression_lbrace=true +resharper_wrap_after_invocation_lpar=false +resharper_wrap_around_elements=true +resharper_wrap_array_initializer_style=chop_always +resharper_wrap_array_literals=chop_if_long +resharper_wrap_base_clause_style=wrap_if_long +resharper_wrap_before_arrow_with_expressions=true +resharper_wrap_before_binary_pattern_op=true +resharper_wrap_before_colon=false +resharper_wrap_before_comma=false +resharper_wrap_before_comma_in_base_clause=false +resharper_wrap_before_declaration_lpar=false +resharper_wrap_before_declaration_rpar=false +resharper_wrap_before_dot=true +resharper_wrap_before_eq=false +resharper_wrap_before_expression_rbrace=true +resharper_wrap_before_extends_colon=false +resharper_wrap_before_first_type_parameter_constraint=false +resharper_wrap_before_invocation_lpar=false +resharper_wrap_before_invocation_rpar=false +resharper_wrap_before_linq_expression=false +resharper_wrap_before_ternary_opsigns=true +resharper_wrap_before_type_parameter_langle=false +resharper_wrap_braced_init_list_style=wrap_if_long +resharper_wrap_chained_binary_expressions=chop_if_long +resharper_wrap_chained_binary_patterns=wrap_if_long +resharper_wrap_chained_method_calls=wrap_if_long +resharper_wrap_ctor_initializer_style=wrap_if_long +resharper_wrap_enumeration_style=chop_if_long +resharper_wrap_enum_declaration=chop_always +resharper_wrap_enum_style=do_not_change +resharper_wrap_extends_list_style=wrap_if_long +resharper_wrap_imports=chop_if_long +resharper_wrap_multiple_declaration_style=chop_if_long +resharper_wrap_multiple_type_parameter_constraints_style=chop_if_long +resharper_wrap_object_literals=chop_if_long +resharper_wrap_property_pattern=chop_if_long +resharper_wrap_switch_expression=chop_always +resharper_wrap_ternary_expr_style=chop_if_long +resharper_wrap_union_type_usage=chop_if_long +resharper_wrap_verbatim_interpolated_strings=no_wrap +resharper_xmldoc_attribute_indent=single_indent +resharper_xmldoc_insert_final_newline=false +resharper_xmldoc_linebreak_before_elements=summary,remarks,example,returns,param,typeparam,value,para +resharper_xmldoc_max_blank_lines_between_tags=0 +resharper_xmldoc_max_line_length=120 +resharper_xmldoc_pi_attribute_style=do_not_touch +resharper_xmldoc_space_before_self_closing=true +resharper_xmldoc_wrap_lines=true +resharper_xmldoc_wrap_tags_and_pi=true +resharper_xmldoc_wrap_text=true +resharper_xml_attribute_indent=align_by_first_attribute +resharper_xml_insert_final_newline=false +resharper_xml_linebreak_before_elements= +resharper_xml_max_blank_lines_between_tags=2 +resharper_xml_max_line_length=120 +resharper_xml_pi_attribute_style=do_not_touch +resharper_xml_space_before_self_closing=true +resharper_xml_wrap_lines=true +resharper_xml_wrap_tags_and_pi=true +resharper_xml_wrap_text=false # ReSharper inspection severities +resharper_abstract_class_constructor_can_be_made_protected_highlighting=hint +resharper_access_rights_in_text_highlighting=warning +resharper_access_to_disposed_closure_highlighting=warning +resharper_access_to_for_each_variable_in_closure_highlighting=warning +resharper_access_to_modified_closure_highlighting=warning +resharper_access_to_static_member_via_derived_type_highlighting=warning +resharper_address_of_marshal_by_ref_object_highlighting=warning +resharper_amd_dependency_path_problem_highlighting=none +resharper_amd_external_module_highlighting=suggestion +resharper_angular_html_banana_highlighting=warning +resharper_annotate_can_be_null_parameter_highlighting=none +resharper_annotate_can_be_null_type_member_highlighting=none +resharper_annotate_not_null_parameter_highlighting=none +resharper_annotate_not_null_type_member_highlighting=none +resharper_annotation_conflict_in_hierarchy_highlighting=warning +resharper_annotation_redundancy_at_value_type_highlighting=warning +resharper_annotation_redundancy_in_hierarchy_highlighting=warning +resharper_arguments_style_anonymous_function_highlighting=hint +resharper_arguments_style_literal_highlighting=hint +resharper_arguments_style_named_expression_highlighting=hint +resharper_arguments_style_other_highlighting=hint +resharper_arguments_style_string_literal_highlighting=hint +resharper_arrange_accessor_owner_body_highlighting=suggestion +resharper_arrange_attributes_highlighting=none +resharper_arrange_constructor_or_destructor_body_highlighting=hint +resharper_arrange_default_value_when_type_evident_highlighting=suggestion +resharper_arrange_default_value_when_type_not_evident_highlighting=hint +resharper_arrange_local_function_body_highlighting=hint +resharper_arrange_method_or_operator_body_highlighting=hint +resharper_arrange_missing_parentheses_highlighting=hint +resharper_arrange_namespace_body_highlighting=hint +resharper_arrange_object_creation_when_type_evident_highlighting=suggestion +resharper_arrange_object_creation_when_type_not_evident_highlighting=hint resharper_arrange_redundant_parentheses_highlighting=hint +resharper_arrange_static_member_qualifier_highlighting=hint resharper_arrange_this_qualifier_highlighting=hint +resharper_arrange_trailing_comma_in_multiline_lists_highlighting=hint +resharper_arrange_trailing_comma_in_singleline_lists_highlighting=hint resharper_arrange_type_member_modifiers_highlighting=hint resharper_arrange_type_modifiers_highlighting=hint +resharper_arrange_var_keywords_in_deconstructing_declaration_highlighting=suggestion +resharper_asp_content_placeholder_not_resolved_highlighting=error +resharper_asp_custom_page_parser_filter_type_highlighting=warning +resharper_asp_dead_code_highlighting=warning +resharper_asp_entity_highlighting=warning +resharper_asp_image_highlighting=warning +resharper_asp_invalid_control_type_highlighting=error +resharper_asp_not_resolved_highlighting=error +resharper_asp_ods_method_reference_resolve_error_highlighting=error +resharper_asp_resolve_warning_highlighting=warning +resharper_asp_skin_not_resolved_highlighting=error +resharper_asp_tag_attribute_with_optional_value_highlighting=warning +resharper_asp_theme_not_resolved_highlighting=error +resharper_asp_unused_register_directive_highlighting_highlighting=warning +resharper_asp_warning_highlighting=warning +resharper_assigned_value_is_never_used_highlighting=warning +resharper_assigned_value_wont_be_assigned_to_corresponding_field_highlighting=warning +resharper_assignment_in_conditional_expression_highlighting=warning +resharper_assignment_in_condition_expression_highlighting=warning +resharper_assignment_is_fully_discarded_highlighting=warning +resharper_assign_null_to_not_null_attribute_highlighting=warning +resharper_assign_to_constant_highlighting=error +resharper_assign_to_implicit_global_in_function_scope_highlighting=warning +resharper_asxx_path_error_highlighting=warning +resharper_async_iterator_invocation_without_await_foreach_highlighting=warning +resharper_async_void_lambda_highlighting=warning +resharper_async_void_method_highlighting=none +resharper_auto_property_can_be_made_get_only_global_highlighting=suggestion +resharper_auto_property_can_be_made_get_only_local_highlighting=suggestion +resharper_bad_attribute_brackets_spaces_highlighting=none +resharper_bad_braces_spaces_highlighting=none +resharper_bad_child_statement_indent_highlighting=warning +resharper_bad_colon_spaces_highlighting=none +resharper_bad_comma_spaces_highlighting=none +resharper_bad_control_braces_indent_highlighting=suggestion +resharper_bad_control_braces_line_breaks_highlighting=none +resharper_bad_declaration_braces_indent_highlighting=none +resharper_bad_declaration_braces_line_breaks_highlighting=none +resharper_bad_empty_braces_line_breaks_highlighting=none +resharper_bad_expression_braces_indent_highlighting=none +resharper_bad_expression_braces_line_breaks_highlighting=none +resharper_bad_generic_brackets_spaces_highlighting=none +resharper_bad_indent_highlighting=none +resharper_bad_linq_line_breaks_highlighting=none +resharper_bad_list_line_breaks_highlighting=none +resharper_bad_member_access_spaces_highlighting=none +resharper_bad_namespace_braces_indent_highlighting=none +resharper_bad_parens_line_breaks_highlighting=none +resharper_bad_parens_spaces_highlighting=none +resharper_bad_preprocessor_indent_highlighting=none +resharper_bad_semicolon_spaces_highlighting=none +resharper_bad_spaces_after_keyword_highlighting=none +resharper_bad_square_brackets_spaces_highlighting=none +resharper_bad_switch_braces_indent_highlighting=none +resharper_bad_symbol_spaces_highlighting=none +resharper_base_member_has_params_highlighting=warning +resharper_base_method_call_with_default_parameter_highlighting=warning +resharper_base_object_equals_is_object_equals_highlighting=warning +resharper_base_object_get_hash_code_call_in_get_hash_code_highlighting=warning +resharper_bitwise_operator_on_enum_without_flags_highlighting=warning +resharper_block_scope_redeclaration_highlighting=error resharper_built_in_type_reference_style_for_member_access_highlighting=hint resharper_built_in_type_reference_style_highlighting=hint +resharper_by_ref_argument_is_volatile_field_highlighting=warning +resharper_caller_callee_using_error_highlighting=error +resharper_caller_callee_using_highlighting=warning +resharper_cannot_apply_equality_operator_to_type_highlighting=warning +resharper_center_tag_is_obsolete_highlighting=warning +resharper_check_for_reference_equality_instead_1_highlighting=suggestion +resharper_check_for_reference_equality_instead_2_highlighting=suggestion +resharper_check_for_reference_equality_instead_3_highlighting=suggestion +resharper_check_for_reference_equality_instead_4_highlighting=suggestion +resharper_check_namespace_highlighting=warning +resharper_class_cannot_be_instantiated_highlighting=warning +resharper_class_can_be_sealed_global_highlighting=none +resharper_class_can_be_sealed_local_highlighting=none +resharper_class_highlighting=suggestion +resharper_class_never_instantiated_global_highlighting=suggestion +resharper_class_never_instantiated_local_highlighting=suggestion +resharper_class_with_virtual_members_never_inherited_global_highlighting=suggestion +resharper_class_with_virtual_members_never_inherited_local_highlighting=suggestion +resharper_clear_attribute_is_obsolete_all_highlighting=warning +resharper_clear_attribute_is_obsolete_highlighting=warning +resharper_closure_on_modified_variable_highlighting=warning +resharper_coerced_equals_using_highlighting=warning +resharper_coerced_equals_using_with_null_undefined_highlighting=none +resharper_collection_never_queried_global_highlighting=warning +resharper_collection_never_queried_local_highlighting=warning +resharper_collection_never_updated_global_highlighting=warning +resharper_collection_never_updated_local_highlighting=warning +resharper_comma_not_valid_here_highlighting=error +resharper_comment_typo_highlighting=suggestion +resharper_common_js_external_module_highlighting=suggestion +resharper_compare_non_constrained_generic_with_null_highlighting=none +resharper_compare_of_floats_by_equality_operator_highlighting=none +resharper_conditional_ternary_equal_branch_highlighting=warning +resharper_condition_is_always_const_highlighting=warning +resharper_condition_is_always_true_or_false_highlighting=warning +resharper_confusing_char_as_integer_in_constructor_highlighting=warning +resharper_constant_conditional_access_qualifier_highlighting=warning +resharper_constant_null_coalescing_condition_highlighting=warning +resharper_constructor_call_not_used_highlighting=warning +resharper_constructor_initializer_loop_highlighting=warning +resharper_container_annotation_redundancy_highlighting=warning +resharper_context_value_is_provided_highlighting=none +resharper_contract_annotation_not_parsed_highlighting=warning +resharper_convert_closure_to_method_group_highlighting=suggestion +resharper_convert_conditional_ternary_expression_to_switch_expression_highlighting=hint +resharper_convert_if_do_to_while_highlighting=suggestion +resharper_convert_if_statement_to_conditional_ternary_expression_highlighting=suggestion +resharper_convert_if_statement_to_null_coalescing_assignment_highlighting=suggestion +resharper_convert_if_statement_to_null_coalescing_expression_highlighting=suggestion +resharper_convert_if_statement_to_return_statement_highlighting=hint +resharper_convert_if_statement_to_switch_expression_highlighting=hint +resharper_convert_if_statement_to_switch_statement_highlighting=hint +resharper_convert_if_to_or_expression_highlighting=suggestion +resharper_convert_nullable_to_short_form_highlighting=suggestion +resharper_convert_switch_statement_to_switch_expression_highlighting=hint +resharper_convert_to_auto_property_highlighting=suggestion +resharper_convert_to_auto_property_when_possible_highlighting=hint +resharper_convert_to_auto_property_with_private_setter_highlighting=hint +resharper_convert_to_compound_assignment_highlighting=hint +resharper_convert_to_constant_global_highlighting=hint +resharper_convert_to_constant_local_highlighting=hint +resharper_convert_to_lambda_expression_highlighting=suggestion +resharper_convert_to_lambda_expression_when_possible_highlighting=none +resharper_convert_to_local_function_highlighting=suggestion +resharper_convert_to_null_coalescing_compound_assignment_highlighting=suggestion +resharper_convert_to_primary_constructor_highlighting=suggestion +resharper_convert_to_static_class_highlighting=suggestion +resharper_convert_to_using_declaration_highlighting=suggestion +resharper_convert_to_vb_auto_property_highlighting=suggestion +resharper_convert_to_vb_auto_property_when_possible_highlighting=hint +resharper_convert_to_vb_auto_property_with_private_setter_highlighting=hint +resharper_convert_type_check_pattern_to_null_check_highlighting=warning +resharper_convert_type_check_to_null_check_highlighting=warning +resharper_co_variant_array_conversion_highlighting=warning +resharper_cpp_abstract_class_without_specifier_highlighting=warning +resharper_cpp_abstract_final_class_highlighting=warning +resharper_cpp_abstract_virtual_function_call_in_ctor_highlighting=error +resharper_cpp_access_specifier_with_no_declarations_highlighting=suggestion +resharper_cpp_assigned_value_is_never_used_highlighting=warning +resharper_cpp_awaiter_type_is_not_class_highlighting=warning +resharper_cpp_bad_angle_brackets_spaces_highlighting=none +resharper_cpp_bad_braces_spaces_highlighting=none +resharper_cpp_bad_child_statement_indent_highlighting=none +resharper_cpp_bad_colon_spaces_highlighting=none +resharper_cpp_bad_comma_spaces_highlighting=none +resharper_cpp_bad_control_braces_indent_highlighting=none +resharper_cpp_bad_control_braces_line_breaks_highlighting=none +resharper_cpp_bad_declaration_braces_indent_highlighting=none +resharper_cpp_bad_declaration_braces_line_breaks_highlighting=none +resharper_cpp_bad_empty_braces_line_breaks_highlighting=none +resharper_cpp_bad_expression_braces_indent_highlighting=none +resharper_cpp_bad_expression_braces_line_breaks_highlighting=none +resharper_cpp_bad_indent_highlighting=none +resharper_cpp_bad_list_line_breaks_highlighting=none +resharper_cpp_bad_member_access_spaces_highlighting=none +resharper_cpp_bad_namespace_braces_indent_highlighting=none +resharper_cpp_bad_parens_line_breaks_highlighting=none +resharper_cpp_bad_parens_spaces_highlighting=none +resharper_cpp_bad_semicolon_spaces_highlighting=none +resharper_cpp_bad_spaces_after_keyword_highlighting=none +resharper_cpp_bad_square_brackets_spaces_highlighting=none +resharper_cpp_bad_switch_braces_indent_highlighting=none +resharper_cpp_bad_symbol_spaces_highlighting=none +resharper_cpp_boolean_increment_expression_highlighting=warning +resharper_cpp_boost_format_bad_code_highlighting=warning +resharper_cpp_boost_format_legacy_code_highlighting=suggestion +resharper_cpp_boost_format_mixed_args_highlighting=error +resharper_cpp_boost_format_too_few_args_highlighting=error +resharper_cpp_boost_format_too_many_args_highlighting=warning +resharper_cpp_clang_tidy_abseil_duration_addition_highlighting=none +resharper_cpp_clang_tidy_abseil_duration_comparison_highlighting=none +resharper_cpp_clang_tidy_abseil_duration_conversion_cast_highlighting=none +resharper_cpp_clang_tidy_abseil_duration_division_highlighting=none +resharper_cpp_clang_tidy_abseil_duration_factory_float_highlighting=none +resharper_cpp_clang_tidy_abseil_duration_factory_scale_highlighting=none +resharper_cpp_clang_tidy_abseil_duration_subtraction_highlighting=none +resharper_cpp_clang_tidy_abseil_duration_unnecessary_conversion_highlighting=none +resharper_cpp_clang_tidy_abseil_faster_strsplit_delimiter_highlighting=none +resharper_cpp_clang_tidy_abseil_no_internal_dependencies_highlighting=none +resharper_cpp_clang_tidy_abseil_no_namespace_highlighting=none +resharper_cpp_clang_tidy_abseil_redundant_strcat_calls_highlighting=none +resharper_cpp_clang_tidy_abseil_string_find_startswith_highlighting=none +resharper_cpp_clang_tidy_abseil_string_find_str_contains_highlighting=none +resharper_cpp_clang_tidy_abseil_str_cat_append_highlighting=none +resharper_cpp_clang_tidy_abseil_time_comparison_highlighting=none +resharper_cpp_clang_tidy_abseil_time_subtraction_highlighting=none +resharper_cpp_clang_tidy_abseil_upgrade_duration_conversions_highlighting=none +resharper_cpp_clang_tidy_altera_id_dependent_backward_branch_highlighting=none +resharper_cpp_clang_tidy_altera_kernel_name_restriction_highlighting=none +resharper_cpp_clang_tidy_altera_single_work_item_barrier_highlighting=none +resharper_cpp_clang_tidy_altera_struct_pack_align_highlighting=none +resharper_cpp_clang_tidy_altera_unroll_loops_highlighting=none +resharper_cpp_clang_tidy_android_cloexec_accept4_highlighting=none +resharper_cpp_clang_tidy_android_cloexec_accept_highlighting=none +resharper_cpp_clang_tidy_android_cloexec_creat_highlighting=none +resharper_cpp_clang_tidy_android_cloexec_dup_highlighting=none +resharper_cpp_clang_tidy_android_cloexec_epoll_create1_highlighting=none +resharper_cpp_clang_tidy_android_cloexec_epoll_create_highlighting=none +resharper_cpp_clang_tidy_android_cloexec_fopen_highlighting=none +resharper_cpp_clang_tidy_android_cloexec_inotify_init1_highlighting=none +resharper_cpp_clang_tidy_android_cloexec_inotify_init_highlighting=none +resharper_cpp_clang_tidy_android_cloexec_memfd_create_highlighting=none +resharper_cpp_clang_tidy_android_cloexec_open_highlighting=none +resharper_cpp_clang_tidy_android_cloexec_pipe2_highlighting=none +resharper_cpp_clang_tidy_android_cloexec_pipe_highlighting=none +resharper_cpp_clang_tidy_android_cloexec_socket_highlighting=none +resharper_cpp_clang_tidy_android_comparison_in_temp_failure_retry_highlighting=none +resharper_cpp_clang_tidy_boost_use_to_string_highlighting=suggestion +resharper_cpp_clang_tidy_bugprone_argument_comment_highlighting=suggestion +resharper_cpp_clang_tidy_bugprone_assert_side_effect_highlighting=warning +resharper_cpp_clang_tidy_bugprone_bad_signal_to_kill_thread_highlighting=warning +resharper_cpp_clang_tidy_bugprone_bool_pointer_implicit_conversion_highlighting=none +resharper_cpp_clang_tidy_bugprone_branch_clone_highlighting=warning +resharper_cpp_clang_tidy_bugprone_copy_constructor_init_highlighting=warning +resharper_cpp_clang_tidy_bugprone_dangling_handle_highlighting=warning +resharper_cpp_clang_tidy_bugprone_dynamic_static_initializers_highlighting=warning +resharper_cpp_clang_tidy_bugprone_easily_swappable_parameters_highlighting=none +resharper_cpp_clang_tidy_bugprone_exception_escape_highlighting=none +resharper_cpp_clang_tidy_bugprone_fold_init_type_highlighting=warning +resharper_cpp_clang_tidy_bugprone_forwarding_reference_overload_highlighting=warning +resharper_cpp_clang_tidy_bugprone_forward_declaration_namespace_highlighting=warning +resharper_cpp_clang_tidy_bugprone_implicit_widening_of_multiplication_result_highlighting=warning +resharper_cpp_clang_tidy_bugprone_inaccurate_erase_highlighting=warning +resharper_cpp_clang_tidy_bugprone_incorrect_roundings_highlighting=warning +resharper_cpp_clang_tidy_bugprone_infinite_loop_highlighting=warning +resharper_cpp_clang_tidy_bugprone_integer_division_highlighting=warning +resharper_cpp_clang_tidy_bugprone_lambda_function_name_highlighting=warning +resharper_cpp_clang_tidy_bugprone_macro_parentheses_highlighting=warning +resharper_cpp_clang_tidy_bugprone_macro_repeated_side_effects_highlighting=warning +resharper_cpp_clang_tidy_bugprone_misplaced_operator_in_strlen_in_alloc_highlighting=warning +resharper_cpp_clang_tidy_bugprone_misplaced_pointer_arithmetic_in_alloc_highlighting=warning +resharper_cpp_clang_tidy_bugprone_misplaced_widening_cast_highlighting=warning +resharper_cpp_clang_tidy_bugprone_move_forwarding_reference_highlighting=warning +resharper_cpp_clang_tidy_bugprone_multiple_statement_macro_highlighting=warning +resharper_cpp_clang_tidy_bugprone_narrowing_conversions_highlighting=warning +resharper_cpp_clang_tidy_bugprone_not_null_terminated_result_highlighting=warning +resharper_cpp_clang_tidy_bugprone_no_escape_highlighting=warning +resharper_cpp_clang_tidy_bugprone_parent_virtual_call_highlighting=warning +resharper_cpp_clang_tidy_bugprone_posix_return_highlighting=warning +resharper_cpp_clang_tidy_bugprone_redundant_branch_condition_highlighting=warning +resharper_cpp_clang_tidy_bugprone_reserved_identifier_highlighting=warning +resharper_cpp_clang_tidy_bugprone_signal_handler_highlighting=warning +resharper_cpp_clang_tidy_bugprone_signed_char_misuse_highlighting=warning +resharper_cpp_clang_tidy_bugprone_sizeof_container_highlighting=warning +resharper_cpp_clang_tidy_bugprone_sizeof_expression_highlighting=warning +resharper_cpp_clang_tidy_bugprone_spuriously_wake_up_functions_highlighting=warning +resharper_cpp_clang_tidy_bugprone_string_constructor_highlighting=warning +resharper_cpp_clang_tidy_bugprone_string_integer_assignment_highlighting=warning +resharper_cpp_clang_tidy_bugprone_string_literal_with_embedded_nul_highlighting=warning +resharper_cpp_clang_tidy_bugprone_suspicious_enum_usage_highlighting=warning +resharper_cpp_clang_tidy_bugprone_suspicious_include_highlighting=warning +resharper_cpp_clang_tidy_bugprone_suspicious_memset_usage_highlighting=warning +resharper_cpp_clang_tidy_bugprone_suspicious_missing_comma_highlighting=warning +resharper_cpp_clang_tidy_bugprone_suspicious_semicolon_highlighting=warning +resharper_cpp_clang_tidy_bugprone_suspicious_string_compare_highlighting=warning +resharper_cpp_clang_tidy_bugprone_swapped_arguments_highlighting=warning +resharper_cpp_clang_tidy_bugprone_terminating_continue_highlighting=warning +resharper_cpp_clang_tidy_bugprone_throw_keyword_missing_highlighting=warning +resharper_cpp_clang_tidy_bugprone_too_small_loop_variable_highlighting=warning +resharper_cpp_clang_tidy_bugprone_undefined_memory_manipulation_highlighting=warning +resharper_cpp_clang_tidy_bugprone_undelegated_constructor_highlighting=warning +resharper_cpp_clang_tidy_bugprone_unhandled_exception_at_new_highlighting=none +resharper_cpp_clang_tidy_bugprone_unhandled_self_assignment_highlighting=warning +resharper_cpp_clang_tidy_bugprone_unused_raii_highlighting=warning +resharper_cpp_clang_tidy_bugprone_unused_return_value_highlighting=warning +resharper_cpp_clang_tidy_bugprone_use_after_move_highlighting=warning +resharper_cpp_clang_tidy_bugprone_virtual_near_miss_highlighting=suggestion +resharper_cpp_clang_tidy_cert_con36_c_highlighting=none +resharper_cpp_clang_tidy_cert_con54_cpp_highlighting=none +resharper_cpp_clang_tidy_cert_dcl03_c_highlighting=none +resharper_cpp_clang_tidy_cert_dcl16_c_highlighting=none +resharper_cpp_clang_tidy_cert_dcl21_cpp_highlighting=none +resharper_cpp_clang_tidy_cert_dcl37_c_highlighting=none +resharper_cpp_clang_tidy_cert_dcl50_cpp_highlighting=none +resharper_cpp_clang_tidy_cert_dcl51_cpp_highlighting=none +resharper_cpp_clang_tidy_cert_dcl54_cpp_highlighting=none +resharper_cpp_clang_tidy_cert_dcl58_cpp_highlighting=warning +resharper_cpp_clang_tidy_cert_dcl59_cpp_highlighting=none +resharper_cpp_clang_tidy_cert_env33_c_highlighting=none +resharper_cpp_clang_tidy_cert_err09_cpp_highlighting=none +resharper_cpp_clang_tidy_cert_err34_c_highlighting=suggestion +resharper_cpp_clang_tidy_cert_err52_cpp_highlighting=none +resharper_cpp_clang_tidy_cert_err58_cpp_highlighting=none +resharper_cpp_clang_tidy_cert_err60_cpp_highlighting=warning +resharper_cpp_clang_tidy_cert_err61_cpp_highlighting=none +resharper_cpp_clang_tidy_cert_fio38_c_highlighting=none +resharper_cpp_clang_tidy_cert_flp30_c_highlighting=warning +resharper_cpp_clang_tidy_cert_mem57_cpp_highlighting=warning +resharper_cpp_clang_tidy_cert_msc30_c_highlighting=none +resharper_cpp_clang_tidy_cert_msc32_c_highlighting=none +resharper_cpp_clang_tidy_cert_msc50_cpp_highlighting=none +resharper_cpp_clang_tidy_cert_msc51_cpp_highlighting=warning +resharper_cpp_clang_tidy_cert_oop11_cpp_highlighting=none +resharper_cpp_clang_tidy_cert_oop54_cpp_highlighting=none +resharper_cpp_clang_tidy_cert_oop57_cpp_highlighting=warning +resharper_cpp_clang_tidy_cert_oop58_cpp_highlighting=warning +resharper_cpp_clang_tidy_cert_pos44_c_highlighting=none +resharper_cpp_clang_tidy_cert_pos47_c_highlighting=none +resharper_cpp_clang_tidy_cert_sig30_c_highlighting=none +resharper_cpp_clang_tidy_cert_str34_c_highlighting=none +resharper_cpp_clang_tidy_clang_analyzer_api_modeling_google_g_test_highlighting=none +resharper_cpp_clang_tidy_clang_analyzer_api_modeling_llvm_cast_value_highlighting=none +resharper_cpp_clang_tidy_clang_analyzer_api_modeling_llvm_return_value_highlighting=none +resharper_cpp_clang_tidy_clang_analyzer_api_modeling_std_c_library_functions_highlighting=none +resharper_cpp_clang_tidy_clang_analyzer_api_modeling_trust_nonnull_highlighting=none +resharper_cpp_clang_tidy_clang_analyzer_core_builtin_builtin_functions_highlighting=none +resharper_cpp_clang_tidy_clang_analyzer_core_builtin_no_return_functions_highlighting=none +resharper_cpp_clang_tidy_clang_analyzer_core_call_and_message_highlighting=none +resharper_cpp_clang_tidy_clang_analyzer_core_call_and_message_modeling_highlighting=none +resharper_cpp_clang_tidy_clang_analyzer_core_divide_zero_highlighting=none +resharper_cpp_clang_tidy_clang_analyzer_core_dynamic_type_propagation_highlighting=none +resharper_cpp_clang_tidy_clang_analyzer_core_nonnil_string_constants_highlighting=none +resharper_cpp_clang_tidy_clang_analyzer_core_non_null_param_checker_highlighting=none +resharper_cpp_clang_tidy_clang_analyzer_core_null_dereference_highlighting=none +resharper_cpp_clang_tidy_clang_analyzer_core_stack_address_escape_highlighting=none +resharper_cpp_clang_tidy_clang_analyzer_core_stack_addr_escape_base_highlighting=none +resharper_cpp_clang_tidy_clang_analyzer_core_undefined_binary_operator_result_highlighting=none +resharper_cpp_clang_tidy_clang_analyzer_core_uninitialized_array_subscript_highlighting=none +resharper_cpp_clang_tidy_clang_analyzer_core_uninitialized_assign_highlighting=none +resharper_cpp_clang_tidy_clang_analyzer_core_uninitialized_branch_highlighting=none +resharper_cpp_clang_tidy_clang_analyzer_core_uninitialized_captured_block_variable_highlighting=none +resharper_cpp_clang_tidy_clang_analyzer_core_uninitialized_undef_return_highlighting=none +resharper_cpp_clang_tidy_clang_analyzer_core_vla_size_highlighting=none +resharper_cpp_clang_tidy_clang_analyzer_cplusplus_inner_pointer_highlighting=none +resharper_cpp_clang_tidy_clang_analyzer_cplusplus_move_highlighting=none +resharper_cpp_clang_tidy_clang_analyzer_cplusplus_new_delete_highlighting=none +resharper_cpp_clang_tidy_clang_analyzer_cplusplus_new_delete_leaks_highlighting=none +resharper_cpp_clang_tidy_clang_analyzer_cplusplus_placement_new_highlighting=none +resharper_cpp_clang_tidy_clang_analyzer_cplusplus_pure_virtual_call_highlighting=none +resharper_cpp_clang_tidy_clang_analyzer_cplusplus_self_assignment_highlighting=none +resharper_cpp_clang_tidy_clang_analyzer_cplusplus_smart_ptr_modeling_highlighting=none +resharper_cpp_clang_tidy_clang_analyzer_cplusplus_virtual_call_modeling_highlighting=none +resharper_cpp_clang_tidy_clang_analyzer_deadcode_dead_stores_highlighting=none +resharper_cpp_clang_tidy_clang_analyzer_fuchsia_handle_checker_highlighting=none +resharper_cpp_clang_tidy_clang_analyzer_nullability_nullability_base_highlighting=none +resharper_cpp_clang_tidy_clang_analyzer_nullability_nullable_dereferenced_highlighting=none +resharper_cpp_clang_tidy_clang_analyzer_nullability_nullable_passed_to_nonnull_highlighting=none +resharper_cpp_clang_tidy_clang_analyzer_nullability_nullable_returned_from_nonnull_highlighting=none +resharper_cpp_clang_tidy_clang_analyzer_nullability_null_passed_to_nonnull_highlighting=none +resharper_cpp_clang_tidy_clang_analyzer_nullability_null_returned_from_nonnull_highlighting=none +resharper_cpp_clang_tidy_clang_analyzer_optin_cplusplus_uninitialized_object_highlighting=none +resharper_cpp_clang_tidy_clang_analyzer_optin_cplusplus_virtual_call_highlighting=none +resharper_cpp_clang_tidy_clang_analyzer_optin_mpi_mpi_checker_highlighting=none +resharper_cpp_clang_tidy_clang_analyzer_optin_osx_cocoa_localizability_empty_localization_context_checker_highlighting=none +resharper_cpp_clang_tidy_clang_analyzer_optin_osx_cocoa_localizability_non_localized_string_checker_highlighting=none +resharper_cpp_clang_tidy_clang_analyzer_optin_osx_os_object_c_style_cast_highlighting=none +resharper_cpp_clang_tidy_clang_analyzer_optin_performance_gcd_antipattern_highlighting=none +resharper_cpp_clang_tidy_clang_analyzer_optin_performance_padding_highlighting=none +resharper_cpp_clang_tidy_clang_analyzer_optin_portability_unix_api_highlighting=none +resharper_cpp_clang_tidy_clang_analyzer_osx_api_highlighting=none +resharper_cpp_clang_tidy_clang_analyzer_osx_cocoa_at_sync_highlighting=none +resharper_cpp_clang_tidy_clang_analyzer_osx_cocoa_autorelease_write_highlighting=none +resharper_cpp_clang_tidy_clang_analyzer_osx_cocoa_class_release_highlighting=none +resharper_cpp_clang_tidy_clang_analyzer_osx_cocoa_dealloc_highlighting=none +resharper_cpp_clang_tidy_clang_analyzer_osx_cocoa_incompatible_method_types_highlighting=none +resharper_cpp_clang_tidy_clang_analyzer_osx_cocoa_loops_highlighting=none +resharper_cpp_clang_tidy_clang_analyzer_osx_cocoa_missing_super_call_highlighting=none +resharper_cpp_clang_tidy_clang_analyzer_osx_cocoa_nil_arg_highlighting=none +resharper_cpp_clang_tidy_clang_analyzer_osx_cocoa_non_nil_return_value_highlighting=none +resharper_cpp_clang_tidy_clang_analyzer_osx_cocoa_ns_autorelease_pool_highlighting=none +resharper_cpp_clang_tidy_clang_analyzer_osx_cocoa_ns_error_highlighting=none +resharper_cpp_clang_tidy_clang_analyzer_osx_cocoa_obj_c_generics_highlighting=none +resharper_cpp_clang_tidy_clang_analyzer_osx_cocoa_retain_count_base_highlighting=none +resharper_cpp_clang_tidy_clang_analyzer_osx_cocoa_retain_count_highlighting=none +resharper_cpp_clang_tidy_clang_analyzer_osx_cocoa_run_loop_autorelease_leak_highlighting=none +resharper_cpp_clang_tidy_clang_analyzer_osx_cocoa_self_init_highlighting=none +resharper_cpp_clang_tidy_clang_analyzer_osx_cocoa_super_dealloc_highlighting=none +resharper_cpp_clang_tidy_clang_analyzer_osx_cocoa_unused_ivars_highlighting=none +resharper_cpp_clang_tidy_clang_analyzer_osx_cocoa_variadic_method_types_highlighting=none +resharper_cpp_clang_tidy_clang_analyzer_osx_core_foundation_cf_error_highlighting=none +resharper_cpp_clang_tidy_clang_analyzer_osx_core_foundation_cf_number_highlighting=none +resharper_cpp_clang_tidy_clang_analyzer_osx_core_foundation_cf_retain_release_highlighting=none +resharper_cpp_clang_tidy_clang_analyzer_osx_core_foundation_containers_out_of_bounds_highlighting=none +resharper_cpp_clang_tidy_clang_analyzer_osx_core_foundation_containers_pointer_sized_values_highlighting=none +resharper_cpp_clang_tidy_clang_analyzer_osx_mig_highlighting=none +resharper_cpp_clang_tidy_clang_analyzer_osx_ns_or_cf_error_deref_checker_highlighting=none +resharper_cpp_clang_tidy_clang_analyzer_osx_number_object_conversion_highlighting=none +resharper_cpp_clang_tidy_clang_analyzer_osx_obj_c_property_highlighting=none +resharper_cpp_clang_tidy_clang_analyzer_osx_os_object_retain_count_highlighting=none +resharper_cpp_clang_tidy_clang_analyzer_osx_sec_keychain_api_highlighting=none +resharper_cpp_clang_tidy_clang_analyzer_security_float_loop_counter_highlighting=none +resharper_cpp_clang_tidy_clang_analyzer_security_insecure_api_bcmp_highlighting=none +resharper_cpp_clang_tidy_clang_analyzer_security_insecure_api_bcopy_highlighting=none +resharper_cpp_clang_tidy_clang_analyzer_security_insecure_api_bzero_highlighting=none +resharper_cpp_clang_tidy_clang_analyzer_security_insecure_api_decode_value_of_obj_c_type_highlighting=none +resharper_cpp_clang_tidy_clang_analyzer_security_insecure_api_deprecated_or_unsafe_buffer_handling_highlighting=none +resharper_cpp_clang_tidy_clang_analyzer_security_insecure_api_getpw_highlighting=none +resharper_cpp_clang_tidy_clang_analyzer_security_insecure_api_gets_highlighting=none +resharper_cpp_clang_tidy_clang_analyzer_security_insecure_api_mkstemp_highlighting=none +resharper_cpp_clang_tidy_clang_analyzer_security_insecure_api_mktemp_highlighting=none +resharper_cpp_clang_tidy_clang_analyzer_security_insecure_api_rand_highlighting=none +resharper_cpp_clang_tidy_clang_analyzer_security_insecure_api_security_syntax_checker_highlighting=none +resharper_cpp_clang_tidy_clang_analyzer_security_insecure_api_strcpy_highlighting=none +resharper_cpp_clang_tidy_clang_analyzer_security_insecure_api_unchecked_return_highlighting=none +resharper_cpp_clang_tidy_clang_analyzer_security_insecure_api_vfork_highlighting=none +resharper_cpp_clang_tidy_clang_analyzer_unix_api_highlighting=none +resharper_cpp_clang_tidy_clang_analyzer_unix_cstring_bad_size_arg_highlighting=none +resharper_cpp_clang_tidy_clang_analyzer_unix_cstring_c_string_modeling_highlighting=none +resharper_cpp_clang_tidy_clang_analyzer_unix_cstring_null_arg_highlighting=none +resharper_cpp_clang_tidy_clang_analyzer_unix_dynamic_memory_modeling_highlighting=none +resharper_cpp_clang_tidy_clang_analyzer_unix_malloc_highlighting=none +resharper_cpp_clang_tidy_clang_analyzer_unix_malloc_sizeof_highlighting=none +resharper_cpp_clang_tidy_clang_analyzer_unix_mismatched_deallocator_highlighting=none +resharper_cpp_clang_tidy_clang_analyzer_unix_vfork_highlighting=none +resharper_cpp_clang_tidy_clang_analyzer_valist_copy_to_self_highlighting=none +resharper_cpp_clang_tidy_clang_analyzer_valist_uninitialized_highlighting=none +resharper_cpp_clang_tidy_clang_analyzer_valist_unterminated_highlighting=none +resharper_cpp_clang_tidy_clang_analyzer_valist_valist_base_highlighting=none +resharper_cpp_clang_tidy_clang_analyzer_webkit_no_uncounted_member_checker_highlighting=none +resharper_cpp_clang_tidy_clang_analyzer_webkit_ref_cntbl_base_virtual_dtor_highlighting=none +resharper_cpp_clang_tidy_clang_analyzer_webkit_uncounted_lambda_captures_checker_highlighting=none +resharper_cpp_clang_tidy_clang_diagnostic_absolute_value_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_abstract_final_class_highlighting=none +resharper_cpp_clang_tidy_clang_diagnostic_abstract_vbase_init_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_address_of_packed_member_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_address_of_temporary_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_aix_compat_highlighting=none +resharper_cpp_clang_tidy_clang_diagnostic_align_mismatch_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_alloca_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_alloca_with_align_alignof_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_ambiguous_delete_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_ambiguous_ellipsis_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_ambiguous_macro_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_ambiguous_member_template_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_ambiguous_reversed_operator_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_analyzer_incompatible_plugin_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_anonymous_pack_parens_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_anon_enum_enum_conversion_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_arc_bridge_casts_disallowed_in_nonarc_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_arc_maybe_repeated_use_of_weak_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_arc_non_pod_memaccess_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_arc_perform_selector_leaks_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_arc_repeated_use_of_weak_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_arc_retain_cycles_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_arc_unsafe_retained_assign_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_argument_outside_range_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_array_bounds_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_array_bounds_pointer_arithmetic_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_asm_operand_widths_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_assign_enum_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_assume_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_atimport_in_framework_header_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_atomic_alignment_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_atomic_implicit_seq_cst_highlighting=suggestion +resharper_cpp_clang_tidy_clang_diagnostic_atomic_memory_ordering_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_atomic_property_with_user_defined_accessor_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_attribute_packed_for_bitfield_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_at_protocol_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_auto_disable_vptr_sanitizer_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_auto_import_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_auto_storage_class_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_auto_var_id_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_availability_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_avr_rtlib_linking_quirks_highlighting=none +resharper_cpp_clang_tidy_clang_diagnostic_backslash_newline_escape_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_bad_function_cast_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_binding_in_condition_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_bind_to_temporary_copy_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_bitfield_constant_conversion_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_bitfield_enum_conversion_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_bitfield_width_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_bitwise_conditional_parentheses_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_bitwise_op_parentheses_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_block_capture_autoreleasing_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_bool_conversion_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_bool_operation_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_braced_scalar_init_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_bridge_cast_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_builtin_assume_aligned_alignment_highlighting=none +resharper_cpp_clang_tidy_clang_diagnostic_builtin_macro_redefined_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_builtin_memcpy_chk_size_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_builtin_requires_header_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_c11_extensions_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_c2x_extensions_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_c99_compat_highlighting=none +resharper_cpp_clang_tidy_clang_diagnostic_c99_designator_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_c99_extensions_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_called_once_parameter_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_call_to_pure_virtual_from_ctor_dtor_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_cast_align_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_cast_calling_convention_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_cast_function_type_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_cast_of_sel_type_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_cast_qual_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_cast_qual_unrelated_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_cf_string_literal_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_char_subscripts_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_clang_cl_pch_highlighting=none +resharper_cpp_clang_tidy_clang_diagnostic_class_conversion_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_class_varargs_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_cmse_union_leak_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_comma_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_comment_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_compare_distinct_pointer_types_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_completion_handler_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_complex_component_init_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_compound_token_split_by_macro_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_compound_token_split_by_space_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_compound_token_split_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_concepts_ts_compat_highlighting=none +resharper_cpp_clang_tidy_clang_diagnostic_conditional_type_mismatch_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_conditional_uninitialized_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_config_macros_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_constant_conversion_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_constant_evaluated_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_constant_logical_operand_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_constexpr_not_const_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_consumed_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_conversion_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_coroutine_missing_unhandled_exception_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_covered_switch_default_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_cpp11_compat_deprecated_writable_strings_highlighting=none +resharper_cpp_clang_tidy_clang_diagnostic_cpp11_compat_highlighting=none +resharper_cpp_clang_tidy_clang_diagnostic_cpp11_compat_pedantic_highlighting=none +resharper_cpp_clang_tidy_clang_diagnostic_cpp11_compat_reserved_user_defined_literal_highlighting=none +resharper_cpp_clang_tidy_clang_diagnostic_cpp11_extensions_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_cpp11_extra_semi_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_cpp11_inline_namespace_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_cpp11_long_long_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_cpp11_narrowing_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_cpp14_binary_literal_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_cpp14_compat_highlighting=none +resharper_cpp_clang_tidy_clang_diagnostic_cpp14_compat_pedantic_highlighting=none +resharper_cpp_clang_tidy_clang_diagnostic_cpp14_extensions_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_cpp17_compat_highlighting=none +resharper_cpp_clang_tidy_clang_diagnostic_cpp17_compat_mangling_highlighting=none +resharper_cpp_clang_tidy_clang_diagnostic_cpp17_compat_pedantic_highlighting=none +resharper_cpp_clang_tidy_clang_diagnostic_cpp17_extensions_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_cpp20_compat_highlighting=none +resharper_cpp_clang_tidy_clang_diagnostic_cpp20_compat_pedantic_highlighting=none +resharper_cpp_clang_tidy_clang_diagnostic_cpp20_designator_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_cpp20_extensions_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_cpp2a_compat_pedantic_highlighting=none +resharper_cpp_clang_tidy_clang_diagnostic_cpp2a_extensions_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_cpp2b_extensions_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_cpp98_compat_bind_to_temporary_copy_highlighting=none +resharper_cpp_clang_tidy_clang_diagnostic_cpp98_compat_extra_semi_highlighting=none +resharper_cpp_clang_tidy_clang_diagnostic_cpp98_compat_highlighting=none +resharper_cpp_clang_tidy_clang_diagnostic_cpp98_compat_local_type_template_args_highlighting=none +resharper_cpp_clang_tidy_clang_diagnostic_cpp98_compat_pedantic_highlighting=none +resharper_cpp_clang_tidy_clang_diagnostic_cpp98_compat_unnamed_type_template_args_highlighting=none +resharper_cpp_clang_tidy_clang_diagnostic_cpp98_cpp11_compat_binary_literal_highlighting=none +resharper_cpp_clang_tidy_clang_diagnostic_cpp98_cpp11_compat_highlighting=none +resharper_cpp_clang_tidy_clang_diagnostic_cpp98_cpp11_compat_pedantic_highlighting=none +resharper_cpp_clang_tidy_clang_diagnostic_cpp98_cpp11_cpp14_compat_highlighting=none +resharper_cpp_clang_tidy_clang_diagnostic_cpp98_cpp11_cpp14_compat_pedantic_highlighting=none +resharper_cpp_clang_tidy_clang_diagnostic_cpp98_cpp11_cpp14_cpp17_compat_highlighting=none +resharper_cpp_clang_tidy_clang_diagnostic_cpp98_cpp11_cpp14_cpp17_compat_pedantic_highlighting=none +resharper_cpp_clang_tidy_clang_diagnostic_cpp_compat_highlighting=none +resharper_cpp_clang_tidy_clang_diagnostic_cstring_format_directive_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_ctad_maybe_unsupported_highlighting=none +resharper_cpp_clang_tidy_clang_diagnostic_ctu_highlighting=none +resharper_cpp_clang_tidy_clang_diagnostic_cuda_compat_highlighting=none +resharper_cpp_clang_tidy_clang_diagnostic_custom_atomic_properties_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_cxx_attribute_extension_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_dangling_else_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_dangling_field_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_dangling_gsl_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_dangling_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_dangling_initializer_list_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_darwin_sdk_settings_highlighting=none +resharper_cpp_clang_tidy_clang_diagnostic_date_time_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_dealloc_in_category_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_debug_compression_unavailable_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_declaration_after_statement_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_defaulted_function_deleted_highlighting=none +resharper_cpp_clang_tidy_clang_diagnostic_delegating_ctor_cycles_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_delete_abstract_non_virtual_dtor_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_delete_incomplete_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_delete_non_abstract_non_virtual_dtor_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_delete_non_virtual_dtor_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_deprecated_altivec_src_compat_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_deprecated_anon_enum_enum_conversion_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_deprecated_array_compare_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_deprecated_attributes_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_deprecated_comma_subscript_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_deprecated_copy_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_deprecated_copy_with_dtor_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_deprecated_copy_with_user_provided_copy_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_deprecated_copy_with_user_provided_dtor_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_deprecated_declarations_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_deprecated_dynamic_exception_spec_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_deprecated_enum_compare_conditional_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_deprecated_enum_compare_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_deprecated_enum_enum_conversion_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_deprecated_enum_float_conversion_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_deprecated_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_deprecated_implementations_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_deprecated_increment_bool_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_deprecated_objc_isa_usage_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_deprecated_objc_pointer_introspection_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_deprecated_objc_pointer_introspection_perform_selector_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_deprecated_register_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_deprecated_this_capture_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_deprecated_volatile_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_direct_ivar_access_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_disabled_macro_expansion_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_distributed_object_modifiers_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_division_by_zero_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_dllexport_explicit_instantiation_decl_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_dllimport_static_field_def_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_dll_attribute_on_redeclaration_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_documentation_deprecated_sync_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_documentation_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_documentation_html_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_documentation_pedantic_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_documentation_unknown_command_highlighting=none +resharper_cpp_clang_tidy_clang_diagnostic_dollar_in_identifier_extension_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_double_promotion_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_dtor_name_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_dtor_typedef_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_duplicate_decl_specifier_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_duplicate_enum_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_duplicate_method_arg_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_duplicate_method_match_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_duplicate_protocol_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_dynamic_class_memaccess_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_dynamic_exception_spec_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_embedded_directive_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_empty_body_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_empty_decomposition_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_empty_init_stmt_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_empty_translation_unit_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_encode_type_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_enum_compare_conditional_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_enum_compare_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_enum_compare_switch_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_enum_conversion_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_enum_enum_conversion_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_enum_float_conversion_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_enum_too_large_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_error_highlighting=none +resharper_cpp_clang_tidy_clang_diagnostic_exceptions_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_excess_initializers_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_exit_time_destructors_highlighting=none +resharper_cpp_clang_tidy_clang_diagnostic_expansion_to_defined_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_explicit_initialize_call_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_explicit_ownership_type_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_export_unnamed_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_export_using_directive_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_extern_c_compat_highlighting=none +resharper_cpp_clang_tidy_clang_diagnostic_extern_initializer_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_extra_qualification_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_extra_semi_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_extra_semi_stmt_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_extra_tokens_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_final_dtor_non_final_class_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_fixed_enum_extension_highlighting=none +resharper_cpp_clang_tidy_clang_diagnostic_fixed_point_overflow_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_flag_enum_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_flexible_array_extensions_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_float_conversion_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_float_equal_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_float_overflow_conversion_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_float_zero_conversion_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_format_extra_args_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_format_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_format_insufficient_args_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_format_invalid_specifier_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_format_nonliteral_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_format_non_iso_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_format_pedantic_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_format_security_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_format_type_confusion_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_format_zero_length_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_fortify_source_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_for_loop_analysis_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_four_char_constants_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_framework_include_private_from_public_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_frame_address_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_frame_larger_than_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_free_nonheap_object_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_function_def_in_objc_container_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_function_multiversion_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_gcc_compat_highlighting=none +resharper_cpp_clang_tidy_clang_diagnostic_global_constructors_highlighting=none +resharper_cpp_clang_tidy_clang_diagnostic_global_isel_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_gnu_alignof_expression_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_gnu_anonymous_struct_highlighting=none +resharper_cpp_clang_tidy_clang_diagnostic_gnu_array_member_paren_init_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_gnu_auto_type_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_gnu_binary_literal_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_gnu_case_range_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_gnu_complex_integer_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_gnu_compound_literal_initializer_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_gnu_conditional_omitted_operand_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_gnu_designator_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_gnu_empty_initializer_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_gnu_empty_struct_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_gnu_flexible_array_initializer_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_gnu_flexible_array_union_member_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_gnu_folding_constant_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_gnu_imaginary_constant_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_gnu_include_next_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_gnu_inline_cpp_without_extern_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_gnu_label_as_value_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_gnu_redeclared_enum_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_gnu_statement_expression_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_gnu_static_float_init_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_gnu_string_literal_operator_template_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_gnu_union_cast_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_gnu_variable_sized_type_not_at_end_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_gnu_zero_variadic_macro_arguments_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_header_guard_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_header_hygiene_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_hip_only_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_idiomatic_parentheses_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_ignored_attributes_highlighting=none +resharper_cpp_clang_tidy_clang_diagnostic_ignored_availability_without_sdk_settings_highlighting=none +resharper_cpp_clang_tidy_clang_diagnostic_ignored_optimization_argument_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_ignored_pragmas_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_ignored_pragma_intrinsic_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_ignored_pragma_optimize_highlighting=none +resharper_cpp_clang_tidy_clang_diagnostic_ignored_qualifiers_highlighting=suggestion +resharper_cpp_clang_tidy_clang_diagnostic_implicitly_unsigned_literal_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_implicit_atomic_properties_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_implicit_const_int_float_conversion_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_implicit_conversion_floating_point_to_bool_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_implicit_exception_spec_mismatch_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_implicit_fallthrough_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_implicit_fallthrough_per_function_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_implicit_fixed_point_conversion_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_implicit_float_conversion_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_implicit_function_declaration_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_implicit_int_conversion_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_implicit_int_float_conversion_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_implicit_int_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_implicit_retain_self_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_import_preprocessor_directive_pedantic_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_inaccessible_base_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_include_next_absolute_path_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_include_next_outside_header_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_incompatible_exception_spec_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_incompatible_function_pointer_types_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_incompatible_library_redeclaration_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_incompatible_ms_struct_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_incompatible_pointer_types_discards_qualifiers_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_incompatible_pointer_types_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_incompatible_property_type_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_incompatible_sysroot_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_incomplete_framework_module_declaration_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_incomplete_implementation_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_incomplete_module_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_incomplete_setjmp_declaration_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_incomplete_umbrella_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_inconsistent_dllimport_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_inconsistent_missing_destructor_override_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_inconsistent_missing_override_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_increment_bool_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_independent_class_attribute_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_infinite_recursion_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_initializer_overrides_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_injected_class_name_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_inline_asm_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_inline_namespace_reopened_noninline_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_inline_new_delete_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_instantiation_after_specialization_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_integer_overflow_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_interrupt_service_routine_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_int_conversion_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_int_in_bool_context_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_int_to_pointer_cast_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_int_to_void_pointer_cast_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_invalid_constexpr_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_invalid_iboutlet_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_invalid_initializer_from_system_header_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_invalid_ios_deployment_target_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_invalid_noreturn_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_invalid_no_builtin_names_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_invalid_offsetof_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_invalid_or_nonexistent_directory_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_invalid_partial_specialization_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_invalid_pp_token_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_invalid_source_encoding_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_invalid_token_paste_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_jump_seh_finally_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_keyword_compat_highlighting=none +resharper_cpp_clang_tidy_clang_diagnostic_keyword_macro_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_knr_promoted_parameter_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_language_extension_token_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_large_by_value_copy_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_literal_conversion_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_literal_range_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_local_type_template_args_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_logical_not_parentheses_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_logical_op_parentheses_highlighting=none +resharper_cpp_clang_tidy_clang_diagnostic_long_long_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_macro_redefined_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_main_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_main_return_type_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_malformed_warning_check_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_many_braces_around_scalar_init_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_max_tokens_highlighting=none +resharper_cpp_clang_tidy_clang_diagnostic_max_unsigned_zero_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_memset_transposed_args_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_memsize_comparison_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_method_signatures_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_microsoft_abstract_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_microsoft_anon_tag_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_microsoft_cast_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_microsoft_charize_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_microsoft_comment_paste_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_microsoft_const_init_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_microsoft_cpp_macro_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_microsoft_default_arg_redefinition_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_microsoft_drectve_section_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_microsoft_end_of_file_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_microsoft_enum_forward_reference_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_microsoft_enum_value_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_microsoft_exception_spec_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_microsoft_exists_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_microsoft_explicit_constructor_call_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_microsoft_extra_qualification_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_microsoft_fixed_enum_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_microsoft_flexible_array_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_microsoft_goto_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_microsoft_inaccessible_base_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_microsoft_include_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_microsoft_mutable_reference_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_microsoft_pure_definition_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_microsoft_redeclare_static_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_microsoft_sealed_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_microsoft_static_assert_highlighting=none +resharper_cpp_clang_tidy_clang_diagnostic_microsoft_template_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_microsoft_template_shadow_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_microsoft_union_member_reference_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_microsoft_unqualified_friend_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_microsoft_using_decl_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_microsoft_void_pseudo_dtor_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_misleading_indentation_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_mismatched_new_delete_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_mismatched_parameter_types_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_mismatched_return_types_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_mismatched_tags_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_missing_braces_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_missing_constinit_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_missing_declarations_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_missing_exception_spec_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_missing_field_initializers_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_missing_method_return_type_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_missing_noescape_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_missing_noreturn_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_missing_prototypes_highlighting=none +resharper_cpp_clang_tidy_clang_diagnostic_missing_prototype_for_cc_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_missing_selector_name_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_missing_sysroot_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_missing_variable_declarations_highlighting=none +resharper_cpp_clang_tidy_clang_diagnostic_misspelled_assumption_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_modules_ambiguous_internal_linkage_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_modules_import_nested_redundant_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_module_conflict_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_module_file_config_mismatch_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_module_file_extension_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_module_import_in_extern_c_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_msvc_not_found_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_multichar_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_multiple_move_vbase_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_nested_anon_types_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_newline_eof_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_new_returns_null_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_noderef_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_nonnull_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_nonportable_include_path_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_nonportable_system_include_path_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_nonportable_vector_initialization_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_nontrivial_memaccess_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_non_c_typedef_for_linkage_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_non_literal_null_conversion_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_non_modular_include_in_framework_module_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_non_modular_include_in_module_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_non_pod_varargs_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_non_power_of_two_alignment_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_non_virtual_dtor_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_nsconsumed_mismatch_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_nsreturns_mismatch_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_ns_object_attribute_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_nullability_completeness_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_nullability_completeness_on_arrays_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_nullability_declspec_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_nullability_extension_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_nullability_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_nullability_inferred_on_nested_type_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_nullable_to_nonnull_conversion_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_null_arithmetic_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_null_character_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_null_conversion_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_null_dereference_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_null_pointer_arithmetic_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_null_pointer_subtraction_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_odr_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_old_style_cast_highlighting=none +resharper_cpp_clang_tidy_clang_diagnostic_opencl_unsupported_rgba_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_openmp51_extensions_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_openmp_clauses_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_openmp_loop_form_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_openmp_mapping_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_openmp_target_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_option_ignored_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_ordered_compare_function_pointers_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_out_of_line_declaration_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_out_of_scope_function_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_overlength_strings_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_overloaded_shift_op_parentheses_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_overloaded_virtual_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_override_init_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_override_module_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_overriding_method_mismatch_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_overriding_t_option_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_over_aligned_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_packed_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_padded_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_parentheses_equality_highlighting=none +resharper_cpp_clang_tidy_clang_diagnostic_parentheses_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_pass_failed_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_pch_date_time_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_pedantic_core_features_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_pedantic_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_pessimizing_move_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_pointer_arith_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_pointer_bool_conversion_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_pointer_compare_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_pointer_integer_compare_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_pointer_sign_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_pointer_to_enum_cast_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_pointer_to_int_cast_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_pointer_type_mismatch_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_poison_system_directories_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_potentially_evaluated_expression_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_pragmas_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_pragma_clang_attribute_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_pragma_messages_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_pragma_once_outside_header_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_pragma_pack_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_pragma_pack_suspicious_include_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_pragma_system_header_outside_header_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_predefined_identifier_outside_function_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_pre_c2x_compat_highlighting=none +resharper_cpp_clang_tidy_clang_diagnostic_pre_c2x_compat_pedantic_highlighting=none +resharper_cpp_clang_tidy_clang_diagnostic_pre_cpp14_compat_highlighting=none +resharper_cpp_clang_tidy_clang_diagnostic_pre_cpp14_compat_pedantic_highlighting=none +resharper_cpp_clang_tidy_clang_diagnostic_pre_cpp17_compat_highlighting=none +resharper_cpp_clang_tidy_clang_diagnostic_pre_cpp17_compat_pedantic_highlighting=none +resharper_cpp_clang_tidy_clang_diagnostic_pre_cpp20_compat_highlighting=none +resharper_cpp_clang_tidy_clang_diagnostic_pre_cpp20_compat_pedantic_highlighting=none +resharper_cpp_clang_tidy_clang_diagnostic_pre_cpp2b_compat_highlighting=none +resharper_cpp_clang_tidy_clang_diagnostic_pre_cpp2b_compat_pedantic_highlighting=none +resharper_cpp_clang_tidy_clang_diagnostic_pre_openmp51_compat_highlighting=none +resharper_cpp_clang_tidy_clang_diagnostic_private_extern_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_private_header_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_private_module_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_profile_instr_missing_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_profile_instr_out_of_date_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_profile_instr_unprofiled_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_property_access_dot_syntax_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_property_attribute_mismatch_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_protocol_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_protocol_property_synthesis_ambiguity_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_psabi_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_qualified_void_return_type_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_quoted_include_in_framework_header_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_range_loop_analysis_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_range_loop_bind_reference_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_range_loop_construct_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_readonly_iboutlet_property_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_receiver_expr_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_receiver_forward_class_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_redeclared_class_member_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_redundant_move_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_redundant_parens_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_register_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_reinterpret_base_class_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_reorder_ctor_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_reorder_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_reorder_init_list_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_requires_expression_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_requires_super_attribute_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_reserved_identifier_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_reserved_id_macro_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_reserved_macro_identifier_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_reserved_user_defined_literal_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_retained_language_linkage_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_return_stack_address_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_return_std_move_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_return_type_c_linkage_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_return_type_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_rewrite_not_bool_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_section_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_selector_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_selector_type_mismatch_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_self_assign_field_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_self_assign_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_self_assign_overloaded_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_self_move_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_semicolon_before_method_body_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_sentinel_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_serialized_diagnostics_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_shadow_field_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_shadow_field_in_constructor_highlighting=none +resharper_cpp_clang_tidy_clang_diagnostic_shadow_field_in_constructor_modified_highlighting=none +resharper_cpp_clang_tidy_clang_diagnostic_shadow_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_shadow_ivar_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_shadow_uncaptured_local_highlighting=none +resharper_cpp_clang_tidy_clang_diagnostic_shift_count_negative_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_shift_count_overflow_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_shift_negative_value_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_shift_op_parentheses_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_shift_overflow_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_shift_sign_overflow_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_shorten64_to32_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_signed_enum_bitfield_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_signed_unsigned_wchar_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_sign_compare_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_sign_conversion_highlighting=none +resharper_cpp_clang_tidy_clang_diagnostic_sizeof_array_argument_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_sizeof_array_decay_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_sizeof_array_div_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_sizeof_pointer_div_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_sizeof_pointer_memaccess_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_slash_u_filename_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_slh_asm_goto_highlighting=none +resharper_cpp_clang_tidy_clang_diagnostic_sometimes_uninitialized_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_source_uses_openmp_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_spir_compat_highlighting=none +resharper_cpp_clang_tidy_clang_diagnostic_static_float_init_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_static_inline_explicit_instantiation_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_static_in_inline_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_static_local_in_inline_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_static_self_init_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_stdlibcxx_not_found_highlighting=none +resharper_cpp_clang_tidy_clang_diagnostic_strict_prototypes_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_strict_selector_match_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_string_compare_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_string_concatenation_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_string_conversion_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_string_plus_char_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_string_plus_int_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_strlcpy_strlcat_size_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_strncat_size_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_suggest_destructor_override_highlighting=none +resharper_cpp_clang_tidy_clang_diagnostic_suggest_override_highlighting=none +resharper_cpp_clang_tidy_clang_diagnostic_super_class_method_mismatch_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_suspicious_bzero_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_switch_bool_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_switch_enum_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_switch_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_sync_fetch_and_nand_semantics_changed_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_tautological_bitwise_compare_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_tautological_compare_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_tautological_constant_compare_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_tautological_constant_in_range_compare_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_tautological_constant_out_of_range_compare_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_tautological_objc_bool_compare_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_tautological_overlap_compare_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_tautological_pointer_compare_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_tautological_type_limit_compare_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_tautological_undefined_compare_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_tautological_unsigned_char_zero_compare_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_tautological_unsigned_enum_zero_compare_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_tautological_unsigned_zero_compare_highlighting=none +resharper_cpp_clang_tidy_clang_diagnostic_tautological_value_range_compare_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_tentative_definition_incomplete_type_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_thread_safety_analysis_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_thread_safety_attributes_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_thread_safety_beta_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_thread_safety_negative_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_thread_safety_precise_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_thread_safety_reference_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_thread_safety_verbose_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_trigraphs_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_typedef_redefinition_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_typename_missing_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_type_safety_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_unable_to_open_stats_file_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_unavailable_declarations_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_undeclared_selector_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_undefined_bool_conversion_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_undefined_func_template_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_undefined_inline_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_undefined_internal_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_undefined_internal_type_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_undefined_reinterpret_cast_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_undefined_var_template_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_undef_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_undef_prefix_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_underaligned_exception_object_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_unevaluated_expression_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_unguarded_availability_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_unguarded_availability_new_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_unicode_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_unicode_homoglyph_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_unicode_whitespace_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_unicode_zero_width_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_uninitialized_const_reference_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_uninitialized_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_unknown_argument_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_unknown_attributes_highlighting=none +resharper_cpp_clang_tidy_clang_diagnostic_unknown_cuda_version_highlighting=none +resharper_cpp_clang_tidy_clang_diagnostic_unknown_escape_sequence_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_unknown_pragmas_highlighting=none +resharper_cpp_clang_tidy_clang_diagnostic_unknown_sanitizers_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_unknown_warning_option_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_unnamed_type_template_args_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_unneeded_internal_declaration_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_unneeded_member_function_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_unreachable_code_break_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_unreachable_code_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_unreachable_code_loop_increment_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_unreachable_code_return_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_unsequenced_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_unsupported_abs_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_unsupported_availability_guard_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_unsupported_cb_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_unsupported_dll_base_class_template_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_unsupported_friend_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_unsupported_gpopt_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_unsupported_nan_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_unsupported_target_opt_highlighting=none +resharper_cpp_clang_tidy_clang_diagnostic_unsupported_visibility_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_unusable_partial_specialization_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_unused_but_set_parameter_highlighting=none +resharper_cpp_clang_tidy_clang_diagnostic_unused_but_set_variable_highlighting=none +resharper_cpp_clang_tidy_clang_diagnostic_unused_comparison_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_unused_const_variable_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_unused_exception_parameter_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_unused_function_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_unused_getter_return_value_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_unused_label_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_unused_lambda_capture_highlighting=none +resharper_cpp_clang_tidy_clang_diagnostic_unused_local_typedef_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_unused_macros_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_unused_member_function_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_unused_parameter_highlighting=none +resharper_cpp_clang_tidy_clang_diagnostic_unused_private_field_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_unused_property_ivar_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_unused_result_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_unused_template_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_unused_value_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_unused_variable_highlighting=none +resharper_cpp_clang_tidy_clang_diagnostic_unused_volatile_lvalue_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_used_but_marked_unused_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_user_defined_literals_highlighting=none +resharper_cpp_clang_tidy_clang_diagnostic_user_defined_warnings_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_varargs_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_variadic_macros_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_vector_conversion_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_vec_elem_size_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_vexing_parse_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_visibility_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_vla_extension_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_vla_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_void_pointer_to_enum_cast_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_void_pointer_to_int_cast_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_void_ptr_dereference_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_warnings_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_wasm_exception_spec_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_weak_template_vtables_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_weak_vtables_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_writable_strings_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_xor_used_as_pow_highlighting=warning +resharper_cpp_clang_tidy_clang_diagnostic_zero_as_null_pointer_constant_highlighting=none +resharper_cpp_clang_tidy_clang_diagnostic_zero_length_array_highlighting=warning +resharper_cpp_clang_tidy_concurrency_mt_unsafe_highlighting=warning +resharper_cpp_clang_tidy_concurrency_thread_canceltype_asynchronous_highlighting=warning +resharper_cpp_clang_tidy_cppcoreguidelines_avoid_c_arrays_highlighting=none +resharper_cpp_clang_tidy_cppcoreguidelines_avoid_goto_highlighting=warning +resharper_cpp_clang_tidy_cppcoreguidelines_avoid_magic_numbers_highlighting=none +resharper_cpp_clang_tidy_cppcoreguidelines_avoid_non_const_global_variables_highlighting=none +resharper_cpp_clang_tidy_cppcoreguidelines_c_copy_assignment_signature_highlighting=none +resharper_cpp_clang_tidy_cppcoreguidelines_explicit_virtual_functions_highlighting=none +resharper_cpp_clang_tidy_cppcoreguidelines_init_variables_highlighting=none +resharper_cpp_clang_tidy_cppcoreguidelines_interfaces_global_init_highlighting=warning +resharper_cpp_clang_tidy_cppcoreguidelines_macro_usage_highlighting=none +resharper_cpp_clang_tidy_cppcoreguidelines_narrowing_conversions_highlighting=warning +resharper_cpp_clang_tidy_cppcoreguidelines_non_private_member_variables_in_classes_highlighting=none +resharper_cpp_clang_tidy_cppcoreguidelines_no_malloc_highlighting=none +resharper_cpp_clang_tidy_cppcoreguidelines_owning_memory_highlighting=none +resharper_cpp_clang_tidy_cppcoreguidelines_prefer_member_initializer_highlighting=suggestion +resharper_cpp_clang_tidy_cppcoreguidelines_pro_bounds_array_to_pointer_decay_highlighting=none +resharper_cpp_clang_tidy_cppcoreguidelines_pro_bounds_constant_array_index_highlighting=none +resharper_cpp_clang_tidy_cppcoreguidelines_pro_bounds_pointer_arithmetic_highlighting=none +resharper_cpp_clang_tidy_cppcoreguidelines_pro_type_const_cast_highlighting=none +resharper_cpp_clang_tidy_cppcoreguidelines_pro_type_cstyle_cast_highlighting=none +resharper_cpp_clang_tidy_cppcoreguidelines_pro_type_member_init_highlighting=warning +resharper_cpp_clang_tidy_cppcoreguidelines_pro_type_reinterpret_cast_highlighting=none +resharper_cpp_clang_tidy_cppcoreguidelines_pro_type_static_cast_downcast_highlighting=suggestion +resharper_cpp_clang_tidy_cppcoreguidelines_pro_type_union_access_highlighting=none +resharper_cpp_clang_tidy_cppcoreguidelines_pro_type_vararg_highlighting=none +resharper_cpp_clang_tidy_cppcoreguidelines_slicing_highlighting=none +resharper_cpp_clang_tidy_cppcoreguidelines_special_member_functions_highlighting=suggestion +resharper_cpp_clang_tidy_darwin_avoid_spinlock_highlighting=none +resharper_cpp_clang_tidy_darwin_dispatch_once_nonstatic_highlighting=none +resharper_cpp_clang_tidy_fuchsia_default_arguments_calls_highlighting=none +resharper_cpp_clang_tidy_fuchsia_default_arguments_declarations_highlighting=none +resharper_cpp_clang_tidy_fuchsia_header_anon_namespaces_highlighting=none +resharper_cpp_clang_tidy_fuchsia_multiple_inheritance_highlighting=none +resharper_cpp_clang_tidy_fuchsia_overloaded_operator_highlighting=none +resharper_cpp_clang_tidy_fuchsia_statically_constructed_objects_highlighting=none +resharper_cpp_clang_tidy_fuchsia_trailing_return_highlighting=none +resharper_cpp_clang_tidy_fuchsia_virtual_inheritance_highlighting=none +resharper_cpp_clang_tidy_google_build_explicit_make_pair_highlighting=none +resharper_cpp_clang_tidy_google_build_namespaces_highlighting=none +resharper_cpp_clang_tidy_google_build_using_namespace_highlighting=none +resharper_cpp_clang_tidy_google_default_arguments_highlighting=none +resharper_cpp_clang_tidy_google_explicit_constructor_highlighting=none +resharper_cpp_clang_tidy_google_global_names_in_headers_highlighting=none +resharper_cpp_clang_tidy_google_objc_avoid_nsobject_new_highlighting=none +resharper_cpp_clang_tidy_google_objc_avoid_throwing_exception_highlighting=none +resharper_cpp_clang_tidy_google_objc_function_naming_highlighting=none +resharper_cpp_clang_tidy_google_objc_global_variable_declaration_highlighting=none +resharper_cpp_clang_tidy_google_readability_avoid_underscore_in_googletest_name_highlighting=none +resharper_cpp_clang_tidy_google_readability_braces_around_statements_highlighting=none +resharper_cpp_clang_tidy_google_readability_casting_highlighting=none +resharper_cpp_clang_tidy_google_readability_function_size_highlighting=none +resharper_cpp_clang_tidy_google_readability_namespace_comments_highlighting=none +resharper_cpp_clang_tidy_google_readability_todo_highlighting=none +resharper_cpp_clang_tidy_google_runtime_int_highlighting=none +resharper_cpp_clang_tidy_google_runtime_operator_highlighting=warning +resharper_cpp_clang_tidy_google_upgrade_googletest_case_highlighting=suggestion +resharper_cpp_clang_tidy_hicpp_avoid_c_arrays_highlighting=none +resharper_cpp_clang_tidy_hicpp_avoid_goto_highlighting=warning +resharper_cpp_clang_tidy_hicpp_braces_around_statements_highlighting=none +resharper_cpp_clang_tidy_hicpp_deprecated_headers_highlighting=none +resharper_cpp_clang_tidy_hicpp_exception_baseclass_highlighting=suggestion +resharper_cpp_clang_tidy_hicpp_explicit_conversions_highlighting=none +resharper_cpp_clang_tidy_hicpp_function_size_highlighting=none +resharper_cpp_clang_tidy_hicpp_invalid_access_moved_highlighting=none +resharper_cpp_clang_tidy_hicpp_member_init_highlighting=none +resharper_cpp_clang_tidy_hicpp_move_const_arg_highlighting=none +resharper_cpp_clang_tidy_hicpp_multiway_paths_covered_highlighting=warning +resharper_cpp_clang_tidy_hicpp_named_parameter_highlighting=none +resharper_cpp_clang_tidy_hicpp_new_delete_operators_highlighting=none +resharper_cpp_clang_tidy_hicpp_noexcept_move_highlighting=none +resharper_cpp_clang_tidy_hicpp_no_array_decay_highlighting=none +resharper_cpp_clang_tidy_hicpp_no_assembler_highlighting=none +resharper_cpp_clang_tidy_hicpp_no_malloc_highlighting=none +resharper_cpp_clang_tidy_hicpp_signed_bitwise_highlighting=none +resharper_cpp_clang_tidy_hicpp_special_member_functions_highlighting=none +resharper_cpp_clang_tidy_hicpp_static_assert_highlighting=none +resharper_cpp_clang_tidy_hicpp_undelegated_constructor_highlighting=none +resharper_cpp_clang_tidy_hicpp_uppercase_literal_suffix_highlighting=none +resharper_cpp_clang_tidy_hicpp_use_auto_highlighting=none +resharper_cpp_clang_tidy_hicpp_use_emplace_highlighting=none +resharper_cpp_clang_tidy_hicpp_use_equals_default_highlighting=none +resharper_cpp_clang_tidy_hicpp_use_equals_delete_highlighting=none +resharper_cpp_clang_tidy_hicpp_use_noexcept_highlighting=none +resharper_cpp_clang_tidy_hicpp_use_nullptr_highlighting=none +resharper_cpp_clang_tidy_hicpp_use_override_highlighting=none +resharper_cpp_clang_tidy_hicpp_vararg_highlighting=none +resharper_cpp_clang_tidy_highlighting_highlighting=suggestion +resharper_cpp_clang_tidy_linuxkernel_must_check_errs_highlighting=warning +resharper_cpp_clang_tidy_llvmlibc_callee_namespace_highlighting=none +resharper_cpp_clang_tidy_llvmlibc_implementation_in_namespace_highlighting=none +resharper_cpp_clang_tidy_llvmlibc_restrict_system_libc_headers_highlighting=none +resharper_cpp_clang_tidy_llvm_else_after_return_highlighting=none +resharper_cpp_clang_tidy_llvm_header_guard_highlighting=none +resharper_cpp_clang_tidy_llvm_include_order_highlighting=none +resharper_cpp_clang_tidy_llvm_namespace_comment_highlighting=none +resharper_cpp_clang_tidy_llvm_prefer_isa_or_dyn_cast_in_conditionals_highlighting=none +resharper_cpp_clang_tidy_llvm_prefer_register_over_unsigned_highlighting=suggestion +resharper_cpp_clang_tidy_llvm_qualified_auto_highlighting=none +resharper_cpp_clang_tidy_llvm_twine_local_highlighting=none +resharper_cpp_clang_tidy_misc_definitions_in_headers_highlighting=none +resharper_cpp_clang_tidy_misc_misplaced_const_highlighting=warning +resharper_cpp_clang_tidy_misc_new_delete_overloads_highlighting=warning +resharper_cpp_clang_tidy_misc_non_copyable_objects_highlighting=warning +resharper_cpp_clang_tidy_misc_non_private_member_variables_in_classes_highlighting=none +resharper_cpp_clang_tidy_misc_no_recursion_highlighting=none +resharper_cpp_clang_tidy_misc_redundant_expression_highlighting=warning +resharper_cpp_clang_tidy_misc_static_assert_highlighting=suggestion +resharper_cpp_clang_tidy_misc_throw_by_value_catch_by_reference_highlighting=warning +resharper_cpp_clang_tidy_misc_unconventional_assign_operator_highlighting=warning +resharper_cpp_clang_tidy_misc_uniqueptr_reset_release_highlighting=suggestion +resharper_cpp_clang_tidy_misc_unused_alias_decls_highlighting=suggestion +resharper_cpp_clang_tidy_misc_unused_parameters_highlighting=none +resharper_cpp_clang_tidy_misc_unused_using_decls_highlighting=suggestion +resharper_cpp_clang_tidy_modernize_avoid_bind_highlighting=suggestion +resharper_cpp_clang_tidy_modernize_avoid_c_arrays_highlighting=none +resharper_cpp_clang_tidy_modernize_concat_nested_namespaces_highlighting=none +resharper_cpp_clang_tidy_modernize_deprecated_headers_highlighting=suggestion +resharper_cpp_clang_tidy_modernize_deprecated_ios_base_aliases_highlighting=warning +resharper_cpp_clang_tidy_modernize_loop_convert_highlighting=suggestion +resharper_cpp_clang_tidy_modernize_make_shared_highlighting=none +resharper_cpp_clang_tidy_modernize_make_unique_highlighting=none +resharper_cpp_clang_tidy_modernize_pass_by_value_highlighting=suggestion +resharper_cpp_clang_tidy_modernize_raw_string_literal_highlighting=suggestion +resharper_cpp_clang_tidy_modernize_redundant_void_arg_highlighting=none +resharper_cpp_clang_tidy_modernize_replace_auto_ptr_highlighting=suggestion +resharper_cpp_clang_tidy_modernize_replace_disallow_copy_and_assign_macro_highlighting=suggestion +resharper_cpp_clang_tidy_modernize_replace_random_shuffle_highlighting=suggestion +resharper_cpp_clang_tidy_modernize_return_braced_init_list_highlighting=suggestion +resharper_cpp_clang_tidy_modernize_shrink_to_fit_highlighting=suggestion +resharper_cpp_clang_tidy_modernize_unary_static_assert_highlighting=suggestion +resharper_cpp_clang_tidy_modernize_use_auto_highlighting=suggestion +resharper_cpp_clang_tidy_modernize_use_bool_literals_highlighting=suggestion +resharper_cpp_clang_tidy_modernize_use_default_member_init_highlighting=none +resharper_cpp_clang_tidy_modernize_use_emplace_highlighting=suggestion +resharper_cpp_clang_tidy_modernize_use_equals_default_highlighting=suggestion +resharper_cpp_clang_tidy_modernize_use_equals_delete_highlighting=suggestion +resharper_cpp_clang_tidy_modernize_use_nodiscard_highlighting=suggestion +resharper_cpp_clang_tidy_modernize_use_noexcept_highlighting=suggestion +resharper_cpp_clang_tidy_modernize_use_nullptr_highlighting=none +resharper_cpp_clang_tidy_modernize_use_override_highlighting=none +resharper_cpp_clang_tidy_modernize_use_trailing_return_type_highlighting=none +resharper_cpp_clang_tidy_modernize_use_transparent_functors_highlighting=suggestion +resharper_cpp_clang_tidy_modernize_use_uncaught_exceptions_highlighting=warning +resharper_cpp_clang_tidy_modernize_use_using_highlighting=none +resharper_cpp_clang_tidy_mpi_buffer_deref_highlighting=warning +resharper_cpp_clang_tidy_mpi_type_mismatch_highlighting=warning +resharper_cpp_clang_tidy_objc_avoid_nserror_init_highlighting=warning +resharper_cpp_clang_tidy_objc_dealloc_in_category_highlighting=warning +resharper_cpp_clang_tidy_objc_forbidden_subclassing_highlighting=warning +resharper_cpp_clang_tidy_objc_missing_hash_highlighting=warning +resharper_cpp_clang_tidy_objc_nsinvocation_argument_lifetime_highlighting=warning +resharper_cpp_clang_tidy_objc_property_declaration_highlighting=warning +resharper_cpp_clang_tidy_objc_super_self_highlighting=warning +resharper_cpp_clang_tidy_openmp_exception_escape_highlighting=warning +resharper_cpp_clang_tidy_openmp_use_default_none_highlighting=warning +resharper_cpp_clang_tidy_performance_faster_string_find_highlighting=suggestion +resharper_cpp_clang_tidy_performance_for_range_copy_highlighting=suggestion +resharper_cpp_clang_tidy_performance_implicit_conversion_in_loop_highlighting=suggestion +resharper_cpp_clang_tidy_performance_inefficient_algorithm_highlighting=suggestion +resharper_cpp_clang_tidy_performance_inefficient_string_concatenation_highlighting=suggestion +resharper_cpp_clang_tidy_performance_inefficient_vector_operation_highlighting=suggestion +resharper_cpp_clang_tidy_performance_move_constructor_init_highlighting=warning +resharper_cpp_clang_tidy_performance_move_const_arg_highlighting=suggestion +resharper_cpp_clang_tidy_performance_noexcept_move_constructor_highlighting=none +resharper_cpp_clang_tidy_performance_no_automatic_move_highlighting=warning +resharper_cpp_clang_tidy_performance_no_int_to_ptr_highlighting=warning +resharper_cpp_clang_tidy_performance_trivially_destructible_highlighting=suggestion +resharper_cpp_clang_tidy_performance_type_promotion_in_math_fn_highlighting=suggestion +resharper_cpp_clang_tidy_performance_unnecessary_copy_initialization_highlighting=suggestion +resharper_cpp_clang_tidy_performance_unnecessary_value_param_highlighting=suggestion +resharper_cpp_clang_tidy_portability_restrict_system_includes_highlighting=none +resharper_cpp_clang_tidy_portability_simd_intrinsics_highlighting=none +resharper_cpp_clang_tidy_readability_avoid_const_params_in_decls_highlighting=none +resharper_cpp_clang_tidy_readability_braces_around_statements_highlighting=none +resharper_cpp_clang_tidy_readability_const_return_type_highlighting=none +resharper_cpp_clang_tidy_readability_container_size_empty_highlighting=suggestion +resharper_cpp_clang_tidy_readability_convert_member_functions_to_static_highlighting=none +resharper_cpp_clang_tidy_readability_delete_null_pointer_highlighting=suggestion +resharper_cpp_clang_tidy_readability_else_after_return_highlighting=none +resharper_cpp_clang_tidy_readability_function_cognitive_complexity_highlighting=none +resharper_cpp_clang_tidy_readability_function_size_highlighting=none +resharper_cpp_clang_tidy_readability_identifier_naming_highlighting=none +resharper_cpp_clang_tidy_readability_implicit_bool_conversion_highlighting=none +resharper_cpp_clang_tidy_readability_inconsistent_declaration_parameter_name_highlighting=suggestion +resharper_cpp_clang_tidy_readability_isolate_declaration_highlighting=none +resharper_cpp_clang_tidy_readability_magic_numbers_highlighting=none +resharper_cpp_clang_tidy_readability_make_member_function_const_highlighting=none +resharper_cpp_clang_tidy_readability_misleading_indentation_highlighting=none +resharper_cpp_clang_tidy_readability_misplaced_array_index_highlighting=suggestion +resharper_cpp_clang_tidy_readability_named_parameter_highlighting=none +resharper_cpp_clang_tidy_readability_non_const_parameter_highlighting=none +resharper_cpp_clang_tidy_readability_qualified_auto_highlighting=none +resharper_cpp_clang_tidy_readability_redundant_access_specifiers_highlighting=none +resharper_cpp_clang_tidy_readability_redundant_control_flow_highlighting=none +resharper_cpp_clang_tidy_readability_redundant_declaration_highlighting=suggestion +resharper_cpp_clang_tidy_readability_redundant_function_ptr_dereference_highlighting=suggestion +resharper_cpp_clang_tidy_readability_redundant_member_init_highlighting=none +resharper_cpp_clang_tidy_readability_redundant_preprocessor_highlighting=warning +resharper_cpp_clang_tidy_readability_redundant_smartptr_get_highlighting=suggestion +resharper_cpp_clang_tidy_readability_redundant_string_cstr_highlighting=suggestion +resharper_cpp_clang_tidy_readability_redundant_string_init_highlighting=suggestion +resharper_cpp_clang_tidy_readability_simplify_boolean_expr_highlighting=none +resharper_cpp_clang_tidy_readability_simplify_subscript_expr_highlighting=warning +resharper_cpp_clang_tidy_readability_static_accessed_through_instance_highlighting=suggestion +resharper_cpp_clang_tidy_readability_static_definition_in_anonymous_namespace_highlighting=none +resharper_cpp_clang_tidy_readability_string_compare_highlighting=warning +resharper_cpp_clang_tidy_readability_suspicious_call_argument_highlighting=warning +resharper_cpp_clang_tidy_readability_uniqueptr_delete_release_highlighting=suggestion +resharper_cpp_clang_tidy_readability_uppercase_literal_suffix_highlighting=none +resharper_cpp_clang_tidy_readability_use_anyofallof_highlighting=suggestion +resharper_cpp_clang_tidy_zircon_temporary_objects_highlighting=none +resharper_cpp_class_can_be_final_highlighting=hint +resharper_cpp_class_disallow_lazy_merging_highlighting=warning +resharper_cpp_class_is_incomplete_highlighting=warning +resharper_cpp_class_needs_constructor_because_of_uninitialized_member_highlighting=warning +resharper_cpp_class_never_used_highlighting=warning +resharper_cpp_compile_time_constant_can_be_replaced_with_boolean_constant_highlighting=suggestion +resharper_cpp_const_parameter_in_declaration_highlighting=suggestion +resharper_cpp_const_value_function_return_type_highlighting=suggestion +resharper_cpp_coroutine_call_resolve_error_highlighting=warning +resharper_cpp_cv_qualifier_can_not_be_applied_to_reference_highlighting=warning +resharper_cpp_c_style_cast_highlighting=suggestion +resharper_cpp_declaration_hides_local_highlighting=warning +resharper_cpp_declaration_hides_uncaptured_local_highlighting=hint +resharper_cpp_declaration_specifier_without_declarators_highlighting=warning +resharper_cpp_declarator_disambiguated_as_function_highlighting=warning +resharper_cpp_declarator_never_used_highlighting=warning +resharper_cpp_declarator_used_before_initialization_highlighting=error +resharper_cpp_defaulted_special_member_function_is_implicitly_deleted_highlighting=warning +resharper_cpp_default_case_not_handled_in_switch_statement_highlighting=warning +resharper_cpp_default_initialization_with_no_user_constructor_highlighting=warning +resharper_cpp_default_is_used_as_identifier_highlighting=warning +resharper_cpp_deleting_void_pointer_highlighting=warning +resharper_cpp_dependent_template_without_template_keyword_highlighting=warning +resharper_cpp_dependent_type_without_typename_keyword_highlighting=warning +resharper_cpp_deprecated_entity_highlighting=warning +resharper_cpp_deprecated_register_storage_class_specifier_highlighting=warning +resharper_cpp_dereference_operator_limit_exceeded_highlighting=warning +resharper_cpp_discarded_postfix_operator_result_highlighting=suggestion +resharper_cpp_doxygen_syntax_error_highlighting=warning +resharper_cpp_doxygen_undocumented_parameter_highlighting=suggestion +resharper_cpp_doxygen_unresolved_reference_highlighting=warning +resharper_cpp_empty_declaration_highlighting=warning +resharper_cpp_enforce_cv_qualifiers_order_highlighting=none +resharper_cpp_enforce_cv_qualifiers_placement_highlighting=none +resharper_cpp_enforce_do_statement_braces_highlighting=none +resharper_cpp_enforce_for_statement_braces_highlighting=none +resharper_cpp_enforce_function_declaration_style_highlighting=none +resharper_cpp_enforce_if_statement_braces_highlighting=none +resharper_cpp_enforce_nested_namespaces_style_highlighting=hint +resharper_cpp_enforce_overriding_destructor_style_highlighting=suggestion +resharper_cpp_enforce_overriding_function_style_highlighting=suggestion +resharper_cpp_enforce_type_alias_code_style_highlighting=none +resharper_cpp_enforce_while_statement_braces_highlighting=none +resharper_cpp_entity_assigned_but_no_read_highlighting=warning +resharper_cpp_entity_used_only_in_unevaluated_context_highlighting=warning +resharper_cpp_enumerator_never_used_highlighting=warning +resharper_cpp_equal_operands_in_binary_expression_highlighting=warning +resharper_cpp_explicit_specialization_in_non_namespace_scope_highlighting=warning +resharper_cpp_expression_without_side_effects_highlighting=warning +resharper_cpp_final_function_in_final_class_highlighting=suggestion +resharper_cpp_final_non_overriding_virtual_function_highlighting=suggestion +resharper_cpp_for_loop_can_be_replaced_with_while_highlighting=suggestion +resharper_cpp_functional_style_cast_highlighting=suggestion +resharper_cpp_function_doesnt_return_value_highlighting=warning +resharper_cpp_function_is_not_implemented_highlighting=warning +resharper_cpp_header_has_been_already_included_highlighting=hint +resharper_cpp_hidden_function_highlighting=warning +resharper_cpp_hiding_function_highlighting=warning +resharper_cpp_identical_operands_in_binary_expression_highlighting=warning +resharper_cpp_if_can_be_replaced_by_constexpr_if_highlighting=suggestion +resharper_cpp_implicit_default_constructor_not_available_highlighting=warning +resharper_cpp_incompatible_pointer_conversion_highlighting=warning +resharper_cpp_incomplete_switch_statement_highlighting=warning +resharper_cpp_inconsistent_naming_highlighting=hint +resharper_cpp_incorrect_blank_lines_near_braces_highlighting=none +resharper_cpp_initialized_value_is_always_rewritten_highlighting=warning +resharper_cpp_integral_to_pointer_conversion_highlighting=warning +resharper_cpp_invalid_line_continuation_highlighting=warning +resharper_cpp_join_declaration_and_assignment_highlighting=suggestion +resharper_cpp_lambda_capture_never_used_highlighting=warning +resharper_cpp_local_variable_may_be_const_highlighting=suggestion +resharper_cpp_local_variable_might_not_be_initialized_highlighting=warning +resharper_cpp_local_variable_with_non_trivial_dtor_is_never_used_highlighting=none +resharper_cpp_long_float_highlighting=warning +resharper_cpp_member_function_may_be_const_highlighting=suggestion +resharper_cpp_member_function_may_be_static_highlighting=suggestion +resharper_cpp_member_initializers_order_highlighting=suggestion +resharper_cpp_mismatched_class_tags_highlighting=warning +resharper_cpp_missing_blank_lines_highlighting=none +resharper_cpp_missing_include_guard_highlighting=warning +resharper_cpp_missing_indent_highlighting=none +resharper_cpp_missing_keyword_throw_highlighting=warning +resharper_cpp_missing_linebreak_highlighting=none +resharper_cpp_missing_space_highlighting=none +resharper_cpp_ms_ext_address_of_class_r_value_highlighting=warning +resharper_cpp_ms_ext_binding_r_value_to_lvalue_reference_highlighting=warning +resharper_cpp_ms_ext_copy_elision_in_copy_init_declarator_highlighting=warning +resharper_cpp_ms_ext_double_user_conversion_in_copy_init_highlighting=warning +resharper_cpp_ms_ext_not_initialized_static_const_local_var_highlighting=warning +resharper_cpp_ms_ext_reinterpret_cast_from_nullptr_highlighting=warning +resharper_cpp_multiple_spaces_highlighting=none +resharper_cpp_must_be_public_virtual_to_implement_interface_highlighting=warning +resharper_cpp_mutable_specifier_on_reference_member_highlighting=warning +resharper_cpp_nodiscard_function_without_return_value_highlighting=warning +resharper_cpp_non_exception_safe_resource_acquisition_highlighting=hint +resharper_cpp_non_explicit_conversion_operator_highlighting=hint +resharper_cpp_non_explicit_converting_constructor_highlighting=hint +resharper_cpp_non_inline_function_definition_in_header_file_highlighting=warning +resharper_cpp_non_inline_variable_definition_in_header_file_highlighting=warning +resharper_cpp_not_all_paths_return_value_highlighting=warning +resharper_cpp_no_discard_expression_highlighting=warning +resharper_cpp_object_member_might_not_be_initialized_highlighting=warning +resharper_cpp_outdent_is_off_prev_level_highlighting=none +resharper_cpp_out_parameter_must_be_written_highlighting=warning +resharper_cpp_parameter_may_be_const_highlighting=hint +resharper_cpp_parameter_may_be_const_ptr_or_ref_highlighting=suggestion +resharper_cpp_parameter_names_mismatch_highlighting=hint +resharper_cpp_parameter_never_used_highlighting=hint +resharper_cpp_parameter_value_is_reassigned_highlighting=warning +resharper_cpp_pointer_conversion_drops_qualifiers_highlighting=warning +resharper_cpp_pointer_to_integral_conversion_highlighting=warning +resharper_cpp_polymorphic_class_with_non_virtual_public_destructor_highlighting=warning +resharper_cpp_possibly_erroneous_empty_statements_highlighting=warning +resharper_cpp_possibly_uninitialized_member_highlighting=warning +resharper_cpp_possibly_unintended_object_slicing_highlighting=warning +resharper_cpp_precompiled_header_is_not_included_highlighting=error +resharper_cpp_precompiled_header_not_found_highlighting=error +resharper_cpp_printf_bad_format_highlighting=warning +resharper_cpp_printf_extra_arg_highlighting=warning +resharper_cpp_printf_missed_arg_highlighting=error +resharper_cpp_printf_risky_format_highlighting=warning +resharper_cpp_private_special_member_function_is_not_implemented_highlighting=warning +resharper_cpp_range_based_for_incompatible_reference_highlighting=warning +resharper_cpp_redefinition_of_default_argument_in_override_function_highlighting=warning +resharper_cpp_redundant_access_specifier_highlighting=hint +resharper_cpp_redundant_base_class_access_specifier_highlighting=hint +resharper_cpp_redundant_blank_lines_highlighting=none +resharper_cpp_redundant_boolean_expression_argument_highlighting=warning +resharper_cpp_redundant_cast_expression_highlighting=hint +resharper_cpp_redundant_const_specifier_highlighting=hint +resharper_cpp_redundant_control_flow_jump_highlighting=hint +resharper_cpp_redundant_elaborated_type_specifier_highlighting=hint +resharper_cpp_redundant_else_keyword_highlighting=hint +resharper_cpp_redundant_else_keyword_inside_compound_statement_highlighting=hint +resharper_cpp_redundant_empty_declaration_highlighting=hint +resharper_cpp_redundant_empty_statement_highlighting=hint +resharper_cpp_redundant_explicit_template_arguments_highlighting=hint +resharper_cpp_redundant_inline_specifier_highlighting=hint +resharper_cpp_redundant_lambda_parameter_list_highlighting=hint +resharper_cpp_redundant_linebreak_highlighting=none +resharper_cpp_redundant_member_initializer_highlighting=suggestion +resharper_cpp_redundant_namespace_definition_highlighting=suggestion +resharper_cpp_redundant_parentheses_highlighting=hint +resharper_cpp_redundant_qualifier_highlighting=hint +resharper_cpp_redundant_space_highlighting=none +resharper_cpp_redundant_static_specifier_on_member_allocation_function_highlighting=hint +resharper_cpp_redundant_template_keyword_highlighting=warning +resharper_cpp_redundant_typename_keyword_highlighting=warning +resharper_cpp_redundant_void_argument_list_highlighting=suggestion +resharper_cpp_reinterpret_cast_from_void_ptr_highlighting=suggestion +resharper_cpp_remove_redundant_braces_highlighting=none +resharper_cpp_replace_memset_with_zero_initialization_highlighting=suggestion +resharper_cpp_replace_tie_with_structured_binding_highlighting=suggestion +resharper_cpp_return_no_value_in_non_void_function_highlighting=warning +resharper_cpp_smart_pointer_vs_make_function_highlighting=suggestion +resharper_cpp_some_object_members_might_not_be_initialized_highlighting=warning +resharper_cpp_special_function_without_noexcept_specification_highlighting=warning +resharper_cpp_static_data_member_in_unnamed_struct_highlighting=warning +resharper_cpp_static_specifier_on_anonymous_namespace_member_highlighting=suggestion +resharper_cpp_string_literal_to_char_pointer_conversion_highlighting=warning +resharper_cpp_syntax_warning_highlighting=warning +resharper_cpp_tabs_and_spaces_mismatch_highlighting=none +resharper_cpp_tabs_are_disallowed_highlighting=none +resharper_cpp_tabs_outside_indent_highlighting=none +resharper_cpp_template_parameter_shadowing_highlighting=warning +resharper_cpp_this_arg_member_func_delegate_ctor_is_unsuported_by_dot_net_core_highlighting=none +resharper_cpp_throw_expression_can_be_replaced_with_rethrow_highlighting=warning +resharper_cpp_too_wide_scope_highlighting=suggestion +resharper_cpp_too_wide_scope_init_statement_highlighting=hint +resharper_cpp_type_alias_never_used_highlighting=warning +resharper_cpp_ue4_blueprint_callable_function_may_be_const_highlighting=hint +resharper_cpp_ue4_blueprint_callable_function_may_be_static_highlighting=hint +resharper_cpp_ue4_coding_standard_naming_violation_warning_highlighting=hint +resharper_cpp_ue4_coding_standard_u_class_naming_violation_error_highlighting=error +resharper_cpp_ue4_probable_memory_issues_with_u_objects_in_container_highlighting=warning +resharper_cpp_ue4_probable_memory_issues_with_u_object_highlighting=warning +resharper_cpp_ue_blueprint_callable_function_unused_highlighting=warning +resharper_cpp_ue_blueprint_implementable_event_not_implemented_highlighting=warning +resharper_cpp_ue_incorrect_engine_directory_highlighting=error +resharper_cpp_ue_non_existent_input_action_highlighting=warning +resharper_cpp_ue_non_existent_input_axis_highlighting=warning +resharper_cpp_ue_source_file_without_predefined_macros_highlighting=warning +resharper_cpp_ue_source_file_without_standard_library_highlighting=error +resharper_cpp_ue_version_file_doesnt_exist_highlighting=error +resharper_cpp_uninitialized_dependent_base_class_highlighting=warning +resharper_cpp_uninitialized_non_static_data_member_highlighting=warning +resharper_cpp_union_member_of_reference_type_highlighting=warning +resharper_cpp_unnamed_namespace_in_header_file_highlighting=warning +resharper_cpp_unnecessary_whitespace_highlighting=none +resharper_cpp_unreachable_code_highlighting=warning +resharper_cpp_unsigned_zero_comparison_highlighting=warning +resharper_cpp_unused_include_directive_highlighting=warning +resharper_cpp_user_defined_literal_suffix_does_not_start_with_underscore_highlighting=warning +resharper_cpp_use_algorithm_with_count_highlighting=suggestion +resharper_cpp_use_associative_contains_highlighting=suggestion +resharper_cpp_use_auto_for_numeric_highlighting=hint +resharper_cpp_use_auto_highlighting=hint +resharper_cpp_use_elements_view_highlighting=suggestion +resharper_cpp_use_erase_algorithm_highlighting=suggestion +resharper_cpp_use_familiar_template_syntax_for_generic_lambdas_highlighting=suggestion +resharper_cpp_use_range_algorithm_highlighting=suggestion +resharper_cpp_use_std_size_highlighting=suggestion +resharper_cpp_use_structured_binding_highlighting=hint +resharper_cpp_use_type_trait_alias_highlighting=suggestion +resharper_cpp_using_result_of_assignment_as_condition_highlighting=warning +resharper_cpp_u_function_macro_call_has_no_effect_highlighting=warning +resharper_cpp_u_property_macro_call_has_no_effect_highlighting=warning +resharper_cpp_variable_can_be_made_constexpr_highlighting=suggestion +resharper_cpp_virtual_function_call_inside_ctor_highlighting=warning +resharper_cpp_virtual_function_in_final_class_highlighting=warning +resharper_cpp_volatile_parameter_in_declaration_highlighting=suggestion +resharper_cpp_wrong_includes_order_highlighting=hint +resharper_cpp_wrong_indent_size_highlighting=none +resharper_cpp_wrong_slashes_in_include_directive_highlighting=hint +resharper_cpp_zero_constant_can_be_replaced_with_nullptr_highlighting=suggestion +resharper_cpp_zero_valued_expression_used_as_null_pointer_highlighting=warning +resharper_create_specialized_overload_highlighting=hint +resharper_css_browser_compatibility_highlighting=warning +resharper_css_caniuse_feature_requires_prefix_highlighting=hint +resharper_css_caniuse_unsupported_feature_highlighting=hint +resharper_css_not_resolved_highlighting=error +resharper_css_obsolete_highlighting=hint +resharper_css_property_does_not_override_vendor_property_highlighting=warning +resharper_cyclic_reference_comment_highlighting=none +resharper_c_declaration_with_implicit_int_type_highlighting=warning +resharper_c_sharp_build_cs_invalid_module_name_highlighting=warning +resharper_c_sharp_missing_plugin_dependency_highlighting=warning +resharper_declaration_hides_highlighting=hint +resharper_declaration_is_empty_highlighting=warning +resharper_declaration_visibility_error_highlighting=error +resharper_default_value_attribute_for_optional_parameter_highlighting=warning +resharper_deleting_non_qualified_reference_highlighting=error +resharper_dl_tag_contains_non_dt_or_dd_elements_highlighting=hint +resharper_double_colons_expected_highlighting=error +resharper_double_colons_preferred_highlighting=suggestion +resharper_double_negation_in_pattern_highlighting=suggestion +resharper_double_negation_of_boolean_highlighting=warning +resharper_double_negation_operator_highlighting=suggestion +resharper_duplicate_identifier_error_highlighting=error +resharper_duplicate_reference_comment_highlighting=warning +resharper_duplicate_resource_highlighting=warning +resharper_duplicating_local_declaration_highlighting=warning +resharper_duplicating_parameter_declaration_error_highlighting=error +resharper_duplicating_property_declaration_error_highlighting=error +resharper_duplicating_property_declaration_highlighting=warning +resharper_duplicating_switch_label_highlighting=warning +resharper_dynamic_shift_right_op_is_not_int_highlighting=warning +resharper_elided_trailing_element_highlighting=warning +resharper_empty_constructor_highlighting=warning +resharper_empty_destructor_highlighting=warning +resharper_empty_embedded_statement_highlighting=warning +resharper_empty_for_statement_highlighting=warning +resharper_empty_general_catch_clause_highlighting=warning +resharper_empty_namespace_highlighting=warning +resharper_empty_object_property_declaration_highlighting=error +resharper_empty_return_value_for_type_annotated_function_highlighting=warning +resharper_empty_statement_highlighting=warning +resharper_empty_title_tag_highlighting=hint +resharper_enforce_do_while_statement_braces_highlighting=none +resharper_enforce_fixed_statement_braces_highlighting=none +resharper_enforce_foreach_statement_braces_highlighting=none +resharper_enforce_for_statement_braces_highlighting=none +resharper_enforce_if_statement_braces_highlighting=none +resharper_enforce_lock_statement_braces_highlighting=none +resharper_enforce_using_statement_braces_highlighting=none +resharper_enforce_while_statement_braces_highlighting=none +resharper_entity_name_captured_only_global_highlighting=warning +resharper_entity_name_captured_only_local_highlighting=warning +resharper_enumerable_sum_in_explicit_unchecked_context_highlighting=warning +resharper_enum_underlying_type_is_int_highlighting=warning +resharper_equal_expression_comparison_highlighting=warning +resharper_error_in_xml_doc_reference_highlighting=error +resharper_es6_feature_highlighting=error +resharper_es7_feature_highlighting=error +resharper_eval_arguments_name_error_highlighting=error +resharper_event_never_invoked_global_highlighting=suggestion +resharper_event_never_subscribed_to_global_highlighting=suggestion +resharper_event_never_subscribed_to_local_highlighting=suggestion +resharper_event_unsubscription_via_anonymous_delegate_highlighting=warning +resharper_experimental_feature_highlighting=error +resharper_explicit_caller_info_argument_highlighting=warning +resharper_expression_is_always_const_highlighting=warning +resharper_expression_is_always_null_highlighting=warning +resharper_field_can_be_made_read_only_global_highlighting=suggestion +resharper_field_can_be_made_read_only_local_highlighting=suggestion +resharper_field_hides_interface_property_with_default_implementation_highlighting=warning +resharper_foreach_can_be_converted_to_query_using_another_get_enumerator_highlighting=hint +resharper_foreach_can_be_partly_converted_to_query_using_another_get_enumerator_highlighting=hint +resharper_format_string_placeholders_mismatch_highlighting=warning +resharper_format_string_problem_highlighting=warning +resharper_for_can_be_converted_to_foreach_highlighting=suggestion +resharper_for_statement_condition_is_true_highlighting=warning +resharper_functions_used_before_declared_highlighting=none +resharper_function_complexity_overflow_highlighting=none +resharper_function_never_returns_highlighting=warning +resharper_function_parameter_named_arguments_highlighting=warning +resharper_function_recursive_on_all_paths_highlighting=warning +resharper_function_used_out_of_scope_highlighting=warning +resharper_gc_suppress_finalize_for_type_without_destructor_highlighting=warning +resharper_generic_enumerator_not_disposed_highlighting=warning +resharper_heuristically_unreachable_code_highlighting=warning +resharper_heuristic_unreachable_code_highlighting=warning +resharper_hex_color_value_with_alpha_highlighting=error +resharper_html_attributes_quotes_highlighting=hint +resharper_html_attribute_not_resolved_highlighting=warning +resharper_html_attribute_value_not_resolved_highlighting=warning +resharper_html_dead_code_highlighting=warning +resharper_html_event_not_resolved_highlighting=warning +resharper_html_id_duplication_highlighting=warning +resharper_html_id_not_resolved_highlighting=warning +resharper_html_obsolete_highlighting=warning +resharper_html_path_error_highlighting=warning +resharper_html_tag_not_closed_highlighting=error +resharper_html_tag_not_resolved_highlighting=warning +resharper_html_tag_should_be_self_closed_highlighting=warning +resharper_html_tag_should_not_be_self_closed_highlighting=warning +resharper_html_warning_highlighting=warning +resharper_identifier_typo_highlighting=suggestion +resharper_implicit_any_error_highlighting=error +resharper_implicit_any_type_warning_highlighting=warning +resharper_import_keyword_not_with_invocation_highlighting=error +resharper_inactive_preprocessor_branch_highlighting=warning +resharper_inconsistently_synchronized_field_highlighting=warning +resharper_inconsistent_function_returns_highlighting=warning +resharper_inconsistent_naming_highlighting=warning +resharper_inconsistent_order_of_locks_highlighting=warning +resharper_incorrect_blank_lines_near_braces_highlighting=none +resharper_incorrect_operand_in_type_of_comparison_highlighting=warning +resharper_incorrect_triple_slash_location_highlighting=warning +resharper_indexing_by_invalid_range_highlighting=warning +resharper_inheritdoc_consider_usage_highlighting=none +resharper_inheritdoc_invalid_usage_highlighting=warning +resharper_inline_out_variable_declaration_highlighting=suggestion +resharper_inline_temporary_variable_highlighting=hint +resharper_internal_module_highlighting=suggestion +resharper_internal_or_private_member_not_documented_highlighting=none +resharper_interpolated_string_expression_is_not_i_formattable_highlighting=warning +resharper_introduce_optional_parameters_global_highlighting=suggestion +resharper_introduce_optional_parameters_local_highlighting=suggestion +resharper_introduce_variable_to_apply_guard_highlighting=hint +resharper_int_division_by_zero_highlighting=warning +resharper_int_variable_overflow_highlighting=warning +resharper_int_variable_overflow_in_checked_context_highlighting=warning +resharper_int_variable_overflow_in_unchecked_context_highlighting=warning +resharper_invalid_attribute_value_highlighting=warning +resharper_invalid_json_syntax_highlighting=error +resharper_invalid_task_element_highlighting=none +resharper_invalid_value_highlighting=error +resharper_invalid_value_type_highlighting=warning +resharper_invalid_xml_doc_comment_highlighting=warning +resharper_invert_condition_1_highlighting=hint +resharper_invert_if_highlighting=hint +resharper_invocation_is_skipped_highlighting=hint +resharper_invocation_of_non_function_highlighting=warning +resharper_invoked_expression_maybe_non_function_highlighting=warning +resharper_invoke_as_extension_method_highlighting=suggestion +resharper_is_expression_always_false_highlighting=warning +resharper_is_expression_always_true_highlighting=warning +resharper_iterator_method_result_is_ignored_highlighting=warning +resharper_iterator_never_returns_highlighting=warning +resharper_join_declaration_and_initializer_highlighting=suggestion +resharper_join_declaration_and_initializer_js_highlighting=suggestion +resharper_join_null_check_with_usage_highlighting=suggestion +resharper_join_null_check_with_usage_when_possible_highlighting=none +resharper_json_validation_failed_highlighting=error +resharper_js_path_not_found_highlighting=error +resharper_js_unreachable_code_highlighting=warning +resharper_jump_must_be_in_loop_highlighting=warning +resharper_label_or_semicolon_expected_highlighting=error +resharper_lambda_expression_can_be_made_static_highlighting=none +resharper_lambda_expression_must_be_static_highlighting=suggestion +resharper_lambda_highlighting=suggestion +resharper_lambda_should_not_capture_context_highlighting=warning +resharper_less_specific_overload_than_main_signature_highlighting=warning +resharper_lexical_declaration_needs_block_highlighting=error +resharper_localizable_element_highlighting=warning +resharper_local_function_can_be_made_static_highlighting=none +resharper_local_function_hides_method_highlighting=warning +resharper_local_function_redefined_later_highlighting=warning +resharper_local_variable_hides_member_highlighting=warning +resharper_long_literal_ending_lower_l_highlighting=warning +resharper_loop_can_be_converted_to_query_highlighting=hint +resharper_loop_can_be_partly_converted_to_query_highlighting=none +resharper_loop_variable_is_never_changed_inside_loop_highlighting=warning +resharper_l_value_is_expected_highlighting=error +resharper_markup_attribute_typo_highlighting=suggestion +resharper_markup_text_typo_highlighting=suggestion +resharper_math_abs_method_is_redundant_highlighting=warning +resharper_math_clamp_min_greater_than_max_highlighting=warning +resharper_meaningless_default_parameter_value_highlighting=warning +resharper_member_can_be_internal_highlighting=none +resharper_member_can_be_made_static_global_highlighting=hint +resharper_member_can_be_made_static_local_highlighting=hint +resharper_member_can_be_private_global_highlighting=suggestion +resharper_member_can_be_private_local_highlighting=suggestion +resharper_member_can_be_protected_global_highlighting=suggestion +resharper_member_can_be_protected_local_highlighting=suggestion +resharper_member_hides_interface_member_with_default_implementation_highlighting=warning +resharper_member_hides_static_from_outer_class_highlighting=warning +resharper_member_initializer_value_ignored_highlighting=warning +resharper_merge_and_pattern_highlighting=suggestion +resharper_merge_cast_with_type_check_highlighting=suggestion +resharper_merge_conditional_expression_highlighting=suggestion +resharper_merge_conditional_expression_when_possible_highlighting=none +resharper_merge_into_logical_pattern_highlighting=hint +resharper_merge_into_negated_pattern_highlighting=hint +resharper_merge_into_pattern_highlighting=suggestion +resharper_merge_nested_property_patterns_highlighting=suggestion +resharper_merge_sequential_checks_highlighting=hint +resharper_merge_sequential_checks_when_possible_highlighting=none +resharper_method_has_async_overload_highlighting=suggestion +resharper_method_has_async_overload_with_cancellation_highlighting=suggestion +resharper_method_overload_with_optional_parameter_highlighting=warning +resharper_method_safe_this_highlighting=suggestion +resharper_method_supports_cancellation_highlighting=suggestion +resharper_missing_alt_attribute_in_img_tag_highlighting=hint +resharper_missing_attribute_highlighting=warning +resharper_missing_blank_lines_highlighting=none +resharper_missing_body_tag_highlighting=warning +resharper_missing_has_own_property_in_foreach_highlighting=warning +resharper_missing_head_and_body_tags_highlighting=warning +resharper_missing_head_tag_highlighting=warning +resharper_missing_indent_highlighting=none +resharper_missing_linebreak_highlighting=none +resharper_missing_space_highlighting=none +resharper_missing_title_tag_highlighting=hint +resharper_misuse_of_owner_function_this_highlighting=warning +resharper_more_specific_foreach_variable_type_available_highlighting=suggestion +resharper_more_specific_signature_after_less_specific_highlighting=warning +resharper_move_to_existing_positional_deconstruction_pattern_highlighting=hint +resharper_multiple_declarations_in_foreach_highlighting=error +resharper_multiple_nullable_attributes_usage_highlighting=warning +resharper_multiple_order_by_highlighting=warning +resharper_multiple_output_tags_highlighting=warning +resharper_multiple_resolve_candidates_in_text_highlighting=warning +resharper_multiple_spaces_highlighting=none +resharper_multiple_statements_on_one_line_highlighting=none +resharper_multiple_type_members_on_one_line_highlighting=none +resharper_must_use_return_value_highlighting=warning +resharper_mvc_action_not_resolved_highlighting=error +resharper_mvc_area_not_resolved_highlighting=error +resharper_mvc_controller_not_resolved_highlighting=error +resharper_mvc_invalid_model_type_highlighting=error +resharper_mvc_masterpage_not_resolved_highlighting=error +resharper_mvc_partial_view_not_resolved_highlighting=error +resharper_mvc_template_not_resolved_highlighting=error +resharper_mvc_view_component_not_resolved_highlighting=error +resharper_mvc_view_component_view_not_resolved_highlighting=error +resharper_mvc_view_not_resolved_highlighting=error +resharper_native_type_prototype_extending_highlighting=warning +resharper_native_type_prototype_overwriting_highlighting=warning +resharper_negation_of_relational_pattern_highlighting=suggestion +resharper_negative_equality_expression_highlighting=suggestion +resharper_negative_index_highlighting=warning +resharper_nested_string_interpolation_highlighting=suggestion +resharper_non_assigned_constant_highlighting=error +resharper_non_atomic_compound_operator_highlighting=warning +resharper_non_constant_equality_expression_has_constant_result_highlighting=warning +resharper_non_parsable_element_highlighting=warning +resharper_non_readonly_member_in_get_hash_code_highlighting=warning +resharper_non_volatile_field_in_double_check_locking_highlighting=warning +resharper_not_accessed_field_global_highlighting=suggestion +resharper_not_accessed_field_local_highlighting=warning +resharper_not_accessed_positional_property_global_highlighting=warning +resharper_not_accessed_positional_property_local_highlighting=warning +resharper_not_accessed_variable_highlighting=warning +resharper_not_all_paths_return_value_highlighting=warning +resharper_not_assigned_out_parameter_highlighting=warning +resharper_not_declared_in_parent_culture_highlighting=warning +resharper_not_null_member_is_not_initialized_highlighting=warning +resharper_not_observable_annotation_redundancy_highlighting=warning +resharper_not_overridden_in_specific_culture_highlighting=warning +resharper_not_resolved_highlighting=warning +resharper_not_resolved_in_text_highlighting=warning +resharper_nullable_warning_suppression_is_used_highlighting=none +resharper_n_unit_async_method_must_be_task_highlighting=warning +resharper_n_unit_attribute_produces_too_many_tests_highlighting=none +resharper_n_unit_auto_fixture_incorrect_argument_type_highlighting=warning +resharper_n_unit_auto_fixture_missed_test_attribute_highlighting=warning +resharper_n_unit_auto_fixture_missed_test_or_test_fixture_attribute_highlighting=warning +resharper_n_unit_auto_fixture_redundant_argument_in_inline_auto_data_attribute_highlighting=warning +resharper_n_unit_duplicate_values_highlighting=warning +resharper_n_unit_ignored_parameter_attribute_highlighting=warning +resharper_n_unit_implicit_unspecified_null_values_highlighting=warning +resharper_n_unit_incorrect_argument_type_highlighting=warning +resharper_n_unit_incorrect_expected_result_type_highlighting=warning +resharper_n_unit_incorrect_range_bounds_highlighting=warning +resharper_n_unit_method_with_parameters_and_test_attribute_highlighting=warning +resharper_n_unit_missing_arguments_in_test_case_attribute_highlighting=warning +resharper_n_unit_non_public_method_with_test_attribute_highlighting=warning +resharper_n_unit_no_values_provided_highlighting=warning +resharper_n_unit_parameter_type_is_not_compatible_with_attribute_highlighting=warning +resharper_n_unit_range_attribute_bounds_are_out_of_range_highlighting=warning +resharper_n_unit_range_step_sign_mismatch_highlighting=warning +resharper_n_unit_range_step_value_must_not_be_zero_highlighting=warning +resharper_n_unit_range_to_value_is_not_reachable_highlighting=warning +resharper_n_unit_redundant_argument_instead_of_expected_result_highlighting=warning +resharper_n_unit_redundant_argument_in_test_case_attribute_highlighting=warning +resharper_n_unit_redundant_expected_result_in_test_case_attribute_highlighting=warning +resharper_n_unit_test_case_attribute_requires_expected_result_highlighting=warning +resharper_n_unit_test_case_result_property_duplicates_expected_result_highlighting=warning +resharper_n_unit_test_case_result_property_is_obsolete_highlighting=warning +resharper_n_unit_test_case_source_cannot_be_resolved_highlighting=warning +resharper_n_unit_test_case_source_must_be_field_property_method_highlighting=warning +resharper_n_unit_test_case_source_must_be_static_highlighting=warning +resharper_n_unit_test_case_source_should_implement_i_enumerable_highlighting=warning +resharper_object_creation_as_statement_highlighting=warning +resharper_object_destructuring_without_parentheses_highlighting=error +resharper_object_literals_are_not_comma_free_highlighting=error +resharper_obsolete_element_error_highlighting=error +resharper_obsolete_element_highlighting=warning +resharper_octal_literals_not_allowed_error_highlighting=error +resharper_ol_tag_contains_non_li_elements_highlighting=hint +resharper_one_way_operation_contract_with_return_type_highlighting=warning +resharper_operation_contract_without_service_contract_highlighting=warning +resharper_operator_is_can_be_used_highlighting=warning +resharper_optional_parameter_hierarchy_mismatch_highlighting=warning +resharper_optional_parameter_ref_out_highlighting=warning +resharper_other_tags_inside_script1_highlighting=error +resharper_other_tags_inside_script2_highlighting=error +resharper_other_tags_inside_unclosed_script_highlighting=error +resharper_outdent_is_off_prev_level_highlighting=none +resharper_output_tag_required_highlighting=warning +resharper_out_parameter_value_is_always_discarded_global_highlighting=suggestion +resharper_out_parameter_value_is_always_discarded_local_highlighting=warning +resharper_overload_signature_inferring_highlighting=hint +resharper_overridden_with_empty_value_highlighting=warning +resharper_overridden_with_same_value_highlighting=suggestion +resharper_parameter_doesnt_make_any_sense_highlighting=warning +resharper_parameter_hides_member_highlighting=warning +resharper_parameter_only_used_for_precondition_check_global_highlighting=suggestion +resharper_parameter_only_used_for_precondition_check_local_highlighting=warning +resharper_parameter_type_can_be_enumerable_global_highlighting=hint +resharper_parameter_type_can_be_enumerable_local_highlighting=hint +resharper_parameter_value_is_not_used_highlighting=warning +resharper_partial_method_parameter_name_mismatch_highlighting=warning +resharper_partial_method_with_single_part_highlighting=warning +resharper_partial_type_with_single_part_highlighting=warning +resharper_pass_string_interpolation_highlighting=hint +resharper_path_not_resolved_highlighting=error +resharper_pattern_always_matches_highlighting=warning +resharper_pattern_is_always_true_or_false_highlighting=warning +resharper_pattern_never_matches_highlighting=warning +resharper_polymorphic_field_like_event_invocation_highlighting=warning +resharper_possible_infinite_inheritance_highlighting=warning +resharper_possible_intended_rethrow_highlighting=warning +resharper_possible_interface_member_ambiguity_highlighting=warning +resharper_possible_invalid_cast_exception_highlighting=warning +resharper_possible_invalid_cast_exception_in_foreach_loop_highlighting=warning +resharper_possible_invalid_operation_exception_highlighting=warning +resharper_possible_loss_of_fraction_highlighting=warning +resharper_possible_mistaken_argument_highlighting=warning +resharper_possible_mistaken_call_to_get_type_1_highlighting=warning +resharper_possible_mistaken_call_to_get_type_2_highlighting=warning +resharper_possible_multiple_enumeration_highlighting=warning +resharper_possible_multiple_write_access_in_double_check_locking_highlighting=warning +resharper_possible_null_reference_exception_highlighting=warning +resharper_possible_struct_member_modification_of_non_variable_struct_highlighting=warning +resharper_possible_unintended_linear_search_in_set_highlighting=warning +resharper_possible_unintended_queryable_as_enumerable_highlighting=suggestion +resharper_possible_unintended_reference_comparison_highlighting=warning +resharper_possible_write_to_me_highlighting=warning +resharper_possibly_impure_method_call_on_readonly_variable_highlighting=warning +resharper_possibly_incorrectly_broken_statement_highlighting=warning +resharper_possibly_missing_indexer_initializer_comma_highlighting=warning +resharper_possibly_mistaken_use_of_interpolated_string_insert_highlighting=warning +resharper_possibly_mistaken_use_of_params_method_highlighting=warning +resharper_possibly_unassigned_property_highlighting=hint +resharper_private_field_can_be_converted_to_local_variable_highlighting=warning +resharper_private_variable_can_be_made_readonly_highlighting=hint +resharper_property_can_be_made_init_only_global_highlighting=suggestion +resharper_property_can_be_made_init_only_local_highlighting=suggestion +resharper_property_getter_cannot_have_parameters_highlighting=error +resharper_property_not_resolved_highlighting=error +resharper_property_setter_must_have_single_parameter_highlighting=error +resharper_public_constructor_in_abstract_class_highlighting=suggestion +resharper_pure_attribute_on_void_method_highlighting=warning +resharper_qualified_expression_is_null_highlighting=warning +resharper_qualified_expression_maybe_null_highlighting=warning +resharper_razor_layout_not_resolved_highlighting=error +resharper_razor_section_not_resolved_highlighting=error +resharper_read_access_in_double_check_locking_highlighting=warning +resharper_redundant_abstract_modifier_highlighting=warning +resharper_redundant_always_match_subpattern_highlighting=suggestion +resharper_redundant_anonymous_type_property_name_highlighting=warning +resharper_redundant_argument_default_value_highlighting=warning +resharper_redundant_array_creation_expression_highlighting=hint +resharper_redundant_array_lower_bound_specification_highlighting=warning +resharper_redundant_assignment_highlighting=warning +resharper_redundant_attribute_parentheses_highlighting=hint +resharper_redundant_attribute_usage_property_highlighting=suggestion +resharper_redundant_base_constructor_call_highlighting=warning resharper_redundant_base_qualifier_highlighting=warning +resharper_redundant_blank_lines_highlighting=none +resharper_redundant_block_highlighting=warning +resharper_redundant_bool_compare_highlighting=warning +resharper_redundant_case_label_highlighting=warning +resharper_redundant_cast_highlighting=warning +resharper_redundant_catch_clause_highlighting=warning +resharper_redundant_check_before_assignment_highlighting=warning +resharper_redundant_collection_initializer_element_braces_highlighting=hint +resharper_redundant_comparison_with_boolean_highlighting=warning +resharper_redundant_configure_await_highlighting=suggestion +resharper_redundant_css_hack_highlighting=warning +resharper_redundant_declaration_semicolon_highlighting=hint +resharper_redundant_default_member_initializer_highlighting=warning +resharper_redundant_delegate_creation_highlighting=warning +resharper_redundant_disable_warning_comment_highlighting=warning +resharper_redundant_discard_designation_highlighting=suggestion +resharper_redundant_else_block_highlighting=warning +resharper_redundant_empty_case_else_highlighting=warning +resharper_redundant_empty_constructor_highlighting=warning +resharper_redundant_empty_finally_block_highlighting=warning +resharper_redundant_empty_object_creation_argument_list_highlighting=hint +resharper_redundant_empty_object_or_collection_initializer_highlighting=warning +resharper_redundant_empty_switch_section_highlighting=warning +resharper_redundant_enumerable_cast_call_highlighting=warning +resharper_redundant_enum_case_label_for_default_section_highlighting=none +resharper_redundant_explicit_array_creation_highlighting=warning +resharper_redundant_explicit_array_size_highlighting=warning +resharper_redundant_explicit_nullable_creation_highlighting=warning +resharper_redundant_explicit_params_array_creation_highlighting=suggestion +resharper_redundant_explicit_positional_property_declaration_highlighting=warning +resharper_redundant_explicit_tuple_component_name_highlighting=warning +resharper_redundant_extends_list_entry_highlighting=warning +resharper_redundant_fixed_pointer_declaration_highlighting=suggestion +resharper_redundant_highlighting=warning +resharper_redundant_if_else_block_highlighting=hint +resharper_redundant_if_statement_then_keyword_highlighting=none +resharper_redundant_immediate_delegate_invocation_highlighting=suggestion +resharper_redundant_intermediate_variable_highlighting=hint +resharper_redundant_is_before_relational_pattern_highlighting=suggestion +resharper_redundant_iterator_keyword_highlighting=warning +resharper_redundant_jump_statement_highlighting=warning +resharper_redundant_lambda_parameter_type_highlighting=warning +resharper_redundant_lambda_signature_parentheses_highlighting=hint +resharper_redundant_linebreak_highlighting=none +resharper_redundant_local_class_name_highlighting=hint +resharper_redundant_local_function_name_highlighting=hint +resharper_redundant_logical_conditional_expression_operand_highlighting=warning +resharper_redundant_me_qualifier_highlighting=warning +resharper_redundant_my_base_qualifier_highlighting=warning +resharper_redundant_my_class_qualifier_highlighting=warning +resharper_redundant_name_qualifier_highlighting=warning +resharper_redundant_not_null_constraint_highlighting=warning +resharper_redundant_nullable_annotation_on_reference_type_constraint_highlighting=warning +resharper_redundant_nullable_annotation_on_type_constraint_has_non_nullable_base_type_highlighting=warning +resharper_redundant_nullable_annotation_on_type_constraint_has_non_nullable_type_kind_highlighting=warning +resharper_redundant_nullable_flow_attribute_highlighting=warning +resharper_redundant_nullable_type_mark_highlighting=warning +resharper_redundant_nullness_attribute_with_nullable_reference_types_highlighting=warning +resharper_redundant_overflow_checking_context_highlighting=warning +resharper_redundant_overload_global_highlighting=suggestion +resharper_redundant_overload_local_highlighting=suggestion +resharper_redundant_overridden_member_highlighting=warning +resharper_redundant_params_highlighting=warning +resharper_redundant_parentheses_highlighting=none +resharper_redundant_parent_type_declaration_highlighting=warning +resharper_redundant_pattern_parentheses_highlighting=hint +resharper_redundant_property_parentheses_highlighting=hint +resharper_redundant_property_pattern_clause_highlighting=suggestion +resharper_redundant_qualifier_highlighting=warning +resharper_redundant_query_order_by_ascending_keyword_highlighting=hint +resharper_redundant_range_bound_highlighting=suggestion +resharper_redundant_readonly_modifier_highlighting=suggestion +resharper_redundant_record_body_highlighting=warning +resharper_redundant_record_class_keyword_highlighting=warning +resharper_redundant_setter_value_parameter_declaration_highlighting=hint +resharper_redundant_space_highlighting=none +resharper_redundant_string_format_call_highlighting=warning +resharper_redundant_string_interpolation_highlighting=suggestion +resharper_redundant_string_to_char_array_call_highlighting=warning +resharper_redundant_string_type_highlighting=suggestion +resharper_redundant_suppress_nullable_warning_expression_highlighting=warning +resharper_redundant_ternary_expression_highlighting=warning +resharper_redundant_to_string_call_for_value_type_highlighting=hint +resharper_redundant_to_string_call_highlighting=warning +resharper_redundant_type_arguments_of_method_highlighting=warning +resharper_redundant_type_cast_highlighting=warning +resharper_redundant_type_cast_structural_highlighting=warning +resharper_redundant_type_check_in_pattern_highlighting=warning +resharper_redundant_units_highlighting=warning +resharper_redundant_unsafe_context_highlighting=warning +resharper_redundant_using_directive_global_highlighting=warning +resharper_redundant_using_directive_highlighting=warning +resharper_redundant_variable_type_specification_highlighting=hint +resharper_redundant_verbatim_prefix_highlighting=suggestion +resharper_redundant_verbatim_string_prefix_highlighting=suggestion +resharper_redundant_with_expression_highlighting=suggestion +resharper_reference_equals_with_value_type_highlighting=warning +resharper_reg_exp_inspections_highlighting=warning +resharper_remove_constructor_invocation_highlighting=none +resharper_remove_redundant_braces_highlighting=none +resharper_remove_redundant_or_statement_false_highlighting=suggestion +resharper_remove_redundant_or_statement_true_highlighting=suggestion +resharper_remove_to_list_1_highlighting=suggestion +resharper_remove_to_list_2_highlighting=suggestion +resharper_replace_auto_property_with_computed_property_highlighting=hint +resharper_replace_indicing_with_array_destructuring_highlighting=hint +resharper_replace_indicing_with_short_hand_properties_after_destructuring_highlighting=hint +resharper_replace_object_pattern_with_var_pattern_highlighting=suggestion +resharper_replace_slice_with_range_indexer_highlighting=hint +resharper_replace_substring_with_range_indexer_highlighting=hint +resharper_replace_undefined_checking_series_with_object_destructuring_highlighting=hint +resharper_replace_with_destructuring_swap_highlighting=hint +resharper_replace_with_first_or_default_1_highlighting=suggestion +resharper_replace_with_first_or_default_2_highlighting=suggestion +resharper_replace_with_first_or_default_3_highlighting=suggestion +resharper_replace_with_first_or_default_4_highlighting=suggestion +resharper_replace_with_last_or_default_1_highlighting=suggestion +resharper_replace_with_last_or_default_2_highlighting=suggestion +resharper_replace_with_last_or_default_3_highlighting=suggestion +resharper_replace_with_last_or_default_4_highlighting=suggestion +resharper_replace_with_of_type_1_highlighting=suggestion +resharper_replace_with_of_type_2_highlighting=suggestion +resharper_replace_with_of_type_3_highlighting=suggestion +resharper_replace_with_of_type_any_1_highlighting=suggestion +resharper_replace_with_of_type_any_2_highlighting=suggestion +resharper_replace_with_of_type_count_1_highlighting=suggestion +resharper_replace_with_of_type_count_2_highlighting=suggestion +resharper_replace_with_of_type_first_1_highlighting=suggestion +resharper_replace_with_of_type_first_2_highlighting=suggestion +resharper_replace_with_of_type_first_or_default_1_highlighting=suggestion +resharper_replace_with_of_type_first_or_default_2_highlighting=suggestion +resharper_replace_with_of_type_last_1_highlighting=suggestion +resharper_replace_with_of_type_last_2_highlighting=suggestion +resharper_replace_with_of_type_last_or_default_1_highlighting=suggestion +resharper_replace_with_of_type_last_or_default_2_highlighting=suggestion +resharper_replace_with_of_type_long_count_highlighting=suggestion +resharper_replace_with_of_type_single_1_highlighting=suggestion +resharper_replace_with_of_type_single_2_highlighting=suggestion +resharper_replace_with_of_type_single_or_default_1_highlighting=suggestion +resharper_replace_with_of_type_single_or_default_2_highlighting=suggestion +resharper_replace_with_of_type_where_highlighting=suggestion +resharper_replace_with_simple_assignment_false_highlighting=suggestion +resharper_replace_with_simple_assignment_true_highlighting=suggestion +resharper_replace_with_single_assignment_false_highlighting=suggestion +resharper_replace_with_single_assignment_true_highlighting=suggestion +resharper_replace_with_single_call_to_any_highlighting=suggestion +resharper_replace_with_single_call_to_count_highlighting=suggestion +resharper_replace_with_single_call_to_first_highlighting=suggestion +resharper_replace_with_single_call_to_first_or_default_highlighting=suggestion +resharper_replace_with_single_call_to_last_highlighting=suggestion +resharper_replace_with_single_call_to_last_or_default_highlighting=suggestion +resharper_replace_with_single_call_to_single_highlighting=suggestion +resharper_replace_with_single_call_to_single_or_default_highlighting=suggestion +resharper_replace_with_single_or_default_1_highlighting=suggestion +resharper_replace_with_single_or_default_2_highlighting=suggestion +resharper_replace_with_single_or_default_3_highlighting=suggestion +resharper_replace_with_single_or_default_4_highlighting=suggestion +resharper_replace_with_string_is_null_or_empty_highlighting=suggestion +resharper_required_base_types_conflict_highlighting=warning +resharper_required_base_types_direct_conflict_highlighting=warning +resharper_required_base_types_is_not_inherited_highlighting=warning +resharper_requires_fallback_color_highlighting=warning +resharper_resource_item_not_resolved_highlighting=error +resharper_resource_not_resolved_highlighting=error +resharper_resx_not_resolved_highlighting=warning +resharper_return_from_global_scopet_with_value_highlighting=warning +resharper_return_type_can_be_enumerable_global_highlighting=hint +resharper_return_type_can_be_enumerable_local_highlighting=hint +resharper_return_type_can_be_not_nullable_highlighting=warning +resharper_return_value_of_pure_method_is_not_used_highlighting=warning +resharper_route_templates_action_route_prefix_can_be_extracted_to_controller_route_highlighting=hint +resharper_route_templates_ambiguous_matching_constraint_constructor_highlighting=warning +resharper_route_templates_ambiguous_route_match_highlighting=warning +resharper_route_templates_constraint_argument_cannot_be_converted_highlighting=warning +resharper_route_templates_controller_route_parameter_is_not_passed_to_methods_highlighting=hint +resharper_route_templates_duplicated_parameter_highlighting=warning +resharper_route_templates_matching_constraint_constructor_not_resolved_highlighting=warning +resharper_route_templates_method_missing_route_parameters_highlighting=hint +resharper_route_templates_optional_parameter_can_be_preceded_only_by_single_period_highlighting=warning +resharper_route_templates_optional_parameter_must_be_at_the_end_of_segment_highlighting=warning +resharper_route_templates_parameter_constraint_can_be_specified_highlighting=hint +resharper_route_templates_parameter_type_and_constraints_mismatch_highlighting=warning +resharper_route_templates_parameter_type_can_be_made_stricter_highlighting=suggestion +resharper_route_templates_route_parameter_constraint_not_resolved_highlighting=warning +resharper_route_templates_route_parameter_is_not_passed_to_method_highlighting=hint +resharper_route_templates_route_token_not_resolved_highlighting=warning +resharper_route_templates_symbol_not_resolved_highlighting=warning +resharper_route_templates_syntax_error_highlighting=warning +resharper_safe_cast_is_used_as_type_check_highlighting=suggestion +resharper_same_imports_with_different_name_highlighting=warning +resharper_same_variable_assignment_highlighting=warning +resharper_script_tag_has_both_src_and_content_attributes_highlighting=error +resharper_script_tag_with_content_before_includes_highlighting=hint +resharper_sealed_member_in_sealed_class_highlighting=warning +resharper_separate_control_transfer_statement_highlighting=none +resharper_service_contract_without_operations_highlighting=warning +resharper_shift_expression_real_shift_count_is_zero_highlighting=warning +resharper_shift_expression_result_equals_zero_highlighting=warning +resharper_shift_expression_right_operand_not_equal_real_count_highlighting=warning +resharper_shift_expression_zero_left_operand_highlighting=warning +resharper_similar_anonymous_type_nearby_highlighting=hint +resharper_similar_expressions_comparison_highlighting=warning +resharper_simplify_conditional_operator_highlighting=suggestion +resharper_simplify_conditional_ternary_expression_highlighting=suggestion +resharper_simplify_i_if_highlighting=suggestion +resharper_simplify_linq_expression_use_all_highlighting=suggestion +resharper_simplify_linq_expression_use_any_highlighting=suggestion +resharper_simplify_string_interpolation_highlighting=suggestion +resharper_specify_a_culture_in_string_conversion_explicitly_highlighting=warning +resharper_specify_string_comparison_highlighting=hint +resharper_specify_variable_type_explicitly_highlighting=hint +resharper_spin_lock_in_readonly_field_highlighting=warning +resharper_stack_alloc_inside_loop_highlighting=warning +resharper_statement_termination_highlighting=warning +resharper_static_member_initializer_referes_to_member_below_highlighting=warning +resharper_static_member_in_generic_type_highlighting=none +resharper_static_problem_in_text_highlighting=warning +resharper_string_compare_is_culture_specific_1_highlighting=warning +resharper_string_compare_is_culture_specific_2_highlighting=warning +resharper_string_compare_is_culture_specific_3_highlighting=warning +resharper_string_compare_is_culture_specific_4_highlighting=warning +resharper_string_compare_is_culture_specific_5_highlighting=warning +resharper_string_compare_is_culture_specific_6_highlighting=warning +resharper_string_compare_to_is_culture_specific_highlighting=warning +resharper_string_concatenation_to_template_string_highlighting=hint +resharper_string_ends_with_is_culture_specific_highlighting=none +resharper_string_index_of_is_culture_specific_1_highlighting=warning +resharper_string_index_of_is_culture_specific_2_highlighting=warning +resharper_string_index_of_is_culture_specific_3_highlighting=warning +resharper_string_last_index_of_is_culture_specific_1_highlighting=warning +resharper_string_last_index_of_is_culture_specific_2_highlighting=warning +resharper_string_last_index_of_is_culture_specific_3_highlighting=warning +resharper_string_literal_as_interpolation_argument_highlighting=suggestion +resharper_string_literal_typo_highlighting=suggestion +resharper_string_literal_wrong_quotes_highlighting=hint +resharper_string_starts_with_is_culture_specific_highlighting=none +resharper_structured_message_template_problem_highlighting=warning +resharper_struct_can_be_made_read_only_highlighting=suggestion +resharper_struct_member_can_be_made_read_only_highlighting=none +resharper_suggest_base_type_for_parameter_highlighting=hint +resharper_suggest_base_type_for_parameter_in_constructor_highlighting=hint +resharper_suggest_discard_declaration_var_style_highlighting=hint resharper_suggest_var_or_type_built_in_types_highlighting=hint +resharper_suggest_var_or_type_deconstruction_declarations_highlighting=hint resharper_suggest_var_or_type_elsewhere_highlighting=hint resharper_suggest_var_or_type_simple_types_highlighting=hint -resharper_web_config_module_not_resolved_highlighting=warning -resharper_web_config_type_not_resolved_highlighting=warning -resharper_web_config_wrong_module_highlighting=warning +resharper_super_call_highlighting=suggestion +resharper_super_call_prohibits_this_highlighting=error +resharper_suppress_nullable_warning_expression_as_inverted_is_expression_highlighting=warning +resharper_suspicious_instanceof_check_highlighting=warning +resharper_suspicious_lambda_block_highlighting=warning +resharper_suspicious_lock_over_synchronization_primitive_highlighting=warning +resharper_suspicious_math_sign_method_highlighting=warning +resharper_suspicious_parameter_name_in_argument_null_exception_highlighting=warning +resharper_suspicious_this_usage_highlighting=warning +resharper_suspicious_typeof_check_highlighting=warning +resharper_suspicious_type_conversion_global_highlighting=warning +resharper_swap_via_deconstruction_highlighting=suggestion +resharper_switch_expression_handles_some_known_enum_values_with_exception_in_default_highlighting=hint +resharper_switch_statement_for_enum_misses_default_section_highlighting=hint +resharper_switch_statement_handles_some_known_enum_values_with_default_highlighting=hint +resharper_switch_statement_missing_some_enum_cases_no_default_highlighting=none +resharper_symbol_from_not_copied_locally_reference_used_warning_highlighting=warning +resharper_syntax_is_not_allowed_highlighting=warning +resharper_tabs_and_spaces_mismatch_highlighting=none +resharper_tabs_are_disallowed_highlighting=none +resharper_tabs_outside_indent_highlighting=none +resharper_tail_recursive_call_highlighting=hint +resharper_tasks_not_loaded_highlighting=warning +resharper_ternary_can_be_replaced_by_its_condition_highlighting=warning +resharper_this_in_global_context_highlighting=warning +resharper_thread_static_at_instance_field_highlighting=warning +resharper_thread_static_field_has_initializer_highlighting=warning +resharper_throw_must_be_followed_by_expression_highlighting=error +resharper_too_wide_local_variable_scope_highlighting=suggestion +resharper_try_cast_always_succeeds_highlighting=suggestion +resharper_try_statements_can_be_merged_highlighting=hint +resharper_ts_not_resolved_highlighting=error +resharper_ts_resolved_from_inaccessible_module_highlighting=error +resharper_type_guard_doesnt_affect_anything_highlighting=warning +resharper_type_guard_produces_never_type_highlighting=warning +resharper_type_parameter_can_be_variant_highlighting=suggestion +resharper_type_parameter_hides_type_param_from_outer_scope_highlighting=warning +resharper_ul_tag_contains_non_li_elements_highlighting=hint +resharper_unassigned_field_global_highlighting=suggestion +resharper_unassigned_field_local_highlighting=warning +resharper_unassigned_get_only_auto_property_highlighting=warning +resharper_unassigned_readonly_field_highlighting=warning +resharper_unclosed_script_highlighting=error +resharper_undeclared_global_variable_using_highlighting=warning +resharper_unexpected_value_highlighting=error +resharper_unknown_css_class_highlighting=warning +resharper_unknown_css_variable_highlighting=warning +resharper_unknown_css_vendor_extension_highlighting=hint +resharper_unknown_item_group_highlighting=warning +resharper_unknown_metadata_highlighting=warning +resharper_unknown_output_parameter_highlighting=warning +resharper_unknown_property_highlighting=warning +resharper_unknown_target_highlighting=warning +resharper_unknown_task_attribute_highlighting=warning +resharper_unknown_task_highlighting=warning +resharper_unnecessary_whitespace_highlighting=none +resharper_unreachable_switch_arm_due_to_integer_analysis_highlighting=warning +resharper_unreachable_switch_case_due_to_integer_analysis_highlighting=warning +resharper_unreal_header_tool_error_highlighting=error +resharper_unreal_header_tool_parser_error_highlighting=error +resharper_unreal_header_tool_warning_highlighting=warning +resharper_unsafe_comma_in_object_properties_list_highlighting=warning +resharper_unsupported_required_base_type_highlighting=warning +resharper_unused_anonymous_method_signature_highlighting=warning +resharper_unused_auto_property_accessor_global_highlighting=warning +resharper_unused_auto_property_accessor_local_highlighting=warning +resharper_unused_import_clause_highlighting=warning +resharper_unused_inherited_parameter_highlighting=hint +resharper_unused_locals_highlighting=warning +resharper_unused_local_function_highlighting=warning +resharper_unused_local_function_parameter_highlighting=warning +resharper_unused_local_function_return_value_highlighting=warning +resharper_unused_local_import_highlighting=warning +resharper_unused_member_global_highlighting=suggestion +resharper_unused_member_hierarchy_global_highlighting=suggestion +resharper_unused_member_hierarchy_local_highlighting=warning +resharper_unused_member_in_super_global_highlighting=suggestion +resharper_unused_member_in_super_local_highlighting=warning +resharper_unused_member_local_highlighting=warning +resharper_unused_method_return_value_global_highlighting=suggestion +resharper_unused_method_return_value_local_highlighting=warning +resharper_unused_parameter_global_highlighting=suggestion +resharper_unused_parameter_highlighting=warning +resharper_unused_parameter_in_partial_method_highlighting=warning +resharper_unused_parameter_local_highlighting=warning +resharper_unused_property_highlighting=warning +resharper_unused_tuple_component_in_return_value_highlighting=warning +resharper_unused_type_global_highlighting=suggestion +resharper_unused_type_local_highlighting=warning +resharper_unused_type_parameter_highlighting=warning +resharper_unused_variable_highlighting=warning +resharper_usage_of_definitely_unassigned_value_highlighting=warning +resharper_usage_of_possibly_unassigned_value_highlighting=warning +resharper_useless_binary_operation_highlighting=warning +resharper_useless_comparison_to_integral_constant_highlighting=warning +resharper_use_array_creation_expression_1_highlighting=suggestion +resharper_use_array_creation_expression_2_highlighting=suggestion +resharper_use_array_empty_method_highlighting=suggestion +resharper_use_as_instead_of_type_cast_highlighting=hint +resharper_use_await_using_highlighting=suggestion +resharper_use_cancellation_token_for_i_async_enumerable_highlighting=suggestion +resharper_use_collection_count_property_highlighting=suggestion +resharper_use_configure_await_false_for_async_disposable_highlighting=none +resharper_use_configure_await_false_highlighting=suggestion +resharper_use_deconstruction_highlighting=hint +resharper_use_deconstruction_on_parameter_highlighting=hint +resharper_use_empty_types_field_highlighting=suggestion +resharper_use_event_args_empty_field_highlighting=suggestion +resharper_use_format_specifier_in_format_string_highlighting=suggestion +resharper_use_implicitly_typed_variable_evident_highlighting=hint +resharper_use_implicitly_typed_variable_highlighting=none +resharper_use_implicit_by_val_modifier_highlighting=hint +resharper_use_indexed_property_highlighting=suggestion +resharper_use_index_from_end_expression_highlighting=suggestion +resharper_use_is_operator_1_highlighting=suggestion +resharper_use_is_operator_2_highlighting=suggestion +resharper_use_method_any_0_highlighting=suggestion +resharper_use_method_any_1_highlighting=suggestion +resharper_use_method_any_2_highlighting=suggestion +resharper_use_method_any_3_highlighting=suggestion +resharper_use_method_any_4_highlighting=suggestion +resharper_use_method_is_instance_of_type_highlighting=suggestion +resharper_use_nameof_expression_for_part_of_the_string_highlighting=none +resharper_use_nameof_expression_highlighting=suggestion +resharper_use_name_of_instead_of_type_of_highlighting=suggestion +resharper_use_negated_pattern_in_is_expression_highlighting=hint +resharper_use_negated_pattern_matching_highlighting=hint +resharper_use_nullable_annotation_instead_of_attribute_highlighting=suggestion +resharper_use_nullable_attributes_supported_by_compiler_highlighting=suggestion +resharper_use_nullable_reference_types_annotation_syntax_highlighting=warning +resharper_use_null_propagation_highlighting=hint +resharper_use_null_propagation_when_possible_highlighting=none +resharper_use_object_or_collection_initializer_highlighting=suggestion +resharper_use_of_implicit_global_in_function_scope_highlighting=warning +resharper_use_of_possibly_unassigned_property_highlighting=warning +resharper_use_pattern_matching_highlighting=suggestion +resharper_use_positional_deconstruction_pattern_highlighting=none +resharper_use_string_interpolation_highlighting=suggestion +resharper_use_switch_case_pattern_variable_highlighting=suggestion +resharper_use_throw_if_null_method_highlighting=none +resharper_use_verbatim_string_highlighting=hint +resharper_using_of_reserved_word_error_highlighting=error +resharper_using_of_reserved_word_highlighting=warning +resharper_value_parameter_not_used_highlighting=warning +resharper_value_range_attribute_violation_highlighting=warning +resharper_value_should_have_units_highlighting=error +resharper_variable_can_be_made_const_highlighting=hint +resharper_variable_can_be_made_let_highlighting=hint +resharper_variable_can_be_moved_to_inner_block_highlighting=hint +resharper_variable_can_be_not_nullable_highlighting=warning +resharper_variable_hides_outer_variable_highlighting=warning +resharper_variable_used_before_declared_highlighting=warning +resharper_variable_used_in_inner_scope_before_declared_highlighting=warning +resharper_variable_used_out_of_scope_highlighting=warning +resharper_vb_check_for_reference_equality_instead_1_highlighting=suggestion +resharper_vb_check_for_reference_equality_instead_2_highlighting=suggestion +resharper_vb_possible_mistaken_argument_highlighting=warning +resharper_vb_possible_mistaken_call_to_get_type_1_highlighting=warning +resharper_vb_possible_mistaken_call_to_get_type_2_highlighting=warning +resharper_vb_remove_to_list_1_highlighting=suggestion +resharper_vb_remove_to_list_2_highlighting=suggestion +resharper_vb_replace_with_first_or_default_highlighting=suggestion +resharper_vb_replace_with_last_or_default_highlighting=suggestion +resharper_vb_replace_with_of_type_1_highlighting=suggestion +resharper_vb_replace_with_of_type_2_highlighting=suggestion +resharper_vb_replace_with_of_type_any_1_highlighting=suggestion +resharper_vb_replace_with_of_type_any_2_highlighting=suggestion +resharper_vb_replace_with_of_type_count_1_highlighting=suggestion +resharper_vb_replace_with_of_type_count_2_highlighting=suggestion +resharper_vb_replace_with_of_type_first_1_highlighting=suggestion +resharper_vb_replace_with_of_type_first_2_highlighting=suggestion +resharper_vb_replace_with_of_type_first_or_default_1_highlighting=suggestion +resharper_vb_replace_with_of_type_first_or_default_2_highlighting=suggestion +resharper_vb_replace_with_of_type_last_1_highlighting=suggestion +resharper_vb_replace_with_of_type_last_2_highlighting=suggestion +resharper_vb_replace_with_of_type_last_or_default_1_highlighting=suggestion +resharper_vb_replace_with_of_type_last_or_default_2_highlighting=suggestion +resharper_vb_replace_with_of_type_single_1_highlighting=suggestion +resharper_vb_replace_with_of_type_single_2_highlighting=suggestion +resharper_vb_replace_with_of_type_single_or_default_1_highlighting=suggestion +resharper_vb_replace_with_of_type_single_or_default_2_highlighting=suggestion +resharper_vb_replace_with_of_type_where_highlighting=suggestion +resharper_vb_replace_with_single_assignment_1_highlighting=suggestion +resharper_vb_replace_with_single_assignment_2_highlighting=suggestion +resharper_vb_replace_with_single_call_to_any_highlighting=suggestion +resharper_vb_replace_with_single_call_to_count_highlighting=suggestion +resharper_vb_replace_with_single_call_to_first_highlighting=suggestion +resharper_vb_replace_with_single_call_to_first_or_default_highlighting=suggestion +resharper_vb_replace_with_single_call_to_last_highlighting=suggestion +resharper_vb_replace_with_single_call_to_last_or_default_highlighting=suggestion +resharper_vb_replace_with_single_call_to_single_highlighting=suggestion +resharper_vb_replace_with_single_call_to_single_or_default_highlighting=suggestion +resharper_vb_replace_with_single_or_default_highlighting=suggestion +resharper_vb_simplify_linq_expression_10_highlighting=hint +resharper_vb_simplify_linq_expression_1_highlighting=suggestion +resharper_vb_simplify_linq_expression_2_highlighting=suggestion +resharper_vb_simplify_linq_expression_3_highlighting=suggestion +resharper_vb_simplify_linq_expression_4_highlighting=suggestion +resharper_vb_simplify_linq_expression_5_highlighting=suggestion +resharper_vb_simplify_linq_expression_6_highlighting=suggestion +resharper_vb_simplify_linq_expression_7_highlighting=hint +resharper_vb_simplify_linq_expression_8_highlighting=hint +resharper_vb_simplify_linq_expression_9_highlighting=hint +resharper_vb_string_compare_is_culture_specific_1_highlighting=warning +resharper_vb_string_compare_is_culture_specific_2_highlighting=warning +resharper_vb_string_compare_is_culture_specific_3_highlighting=warning +resharper_vb_string_compare_is_culture_specific_4_highlighting=warning +resharper_vb_string_compare_is_culture_specific_5_highlighting=warning +resharper_vb_string_compare_is_culture_specific_6_highlighting=warning +resharper_vb_string_compare_to_is_culture_specific_highlighting=warning +resharper_vb_string_ends_with_is_culture_specific_highlighting=none +resharper_vb_string_index_of_is_culture_specific_1_highlighting=warning +resharper_vb_string_index_of_is_culture_specific_2_highlighting=warning +resharper_vb_string_index_of_is_culture_specific_3_highlighting=warning +resharper_vb_string_last_index_of_is_culture_specific_1_highlighting=warning +resharper_vb_string_last_index_of_is_culture_specific_2_highlighting=warning +resharper_vb_string_last_index_of_is_culture_specific_3_highlighting=warning +resharper_vb_string_starts_with_is_culture_specific_highlighting=none +resharper_vb_unreachable_code_highlighting=warning +resharper_vb_use_array_creation_expression_1_highlighting=suggestion +resharper_vb_use_array_creation_expression_2_highlighting=suggestion +resharper_vb_use_first_instead_highlighting=warning +resharper_vb_use_method_any_1_highlighting=suggestion +resharper_vb_use_method_any_2_highlighting=suggestion +resharper_vb_use_method_any_3_highlighting=suggestion +resharper_vb_use_method_any_4_highlighting=suggestion +resharper_vb_use_method_any_5_highlighting=suggestion +resharper_vb_use_method_is_instance_of_type_highlighting=suggestion +resharper_vb_use_type_of_is_operator_1_highlighting=suggestion +resharper_vb_use_type_of_is_operator_2_highlighting=suggestion +resharper_virtual_member_call_in_constructor_highlighting=warning +resharper_virtual_member_never_overridden_global_highlighting=suggestion +resharper_virtual_member_never_overridden_local_highlighting=suggestion +resharper_void_method_with_must_use_return_value_attribute_highlighting=warning +resharper_web_config_module_not_resolved_highlighting=error +resharper_web_config_module_qualification_resolve_highlighting=warning +resharper_web_config_redundant_add_namespace_tag_highlighting=warning +resharper_web_config_redundant_location_tag_highlighting=warning +resharper_web_config_tag_prefix_redundand_highlighting=warning +resharper_web_config_type_not_resolved_highlighting=error +resharper_web_config_unused_add_tag_highlighting=warning +resharper_web_config_unused_element_due_to_config_source_attribute_highlighting=warning +resharper_web_config_unused_remove_or_clear_tag_highlighting=warning +resharper_web_config_web_config_path_warning_highlighting=warning +resharper_web_config_wrong_module_highlighting=error +resharper_web_ignored_path_highlighting=none +resharper_web_mapped_path_highlighting=hint +resharper_with_expression_instead_of_initializer_highlighting=suggestion +resharper_with_statement_using_error_highlighting=error +resharper_wrong_expression_statement_highlighting=warning +resharper_wrong_indent_size_highlighting=none +resharper_wrong_metadata_use_highlighting=none +resharper_wrong_public_modifier_specification_highlighting=hint +resharper_wrong_require_relative_path_highlighting=hint +resharper_xaml_assign_null_to_not_null_attribute_highlighting=warning +resharper_xaml_avalonia_wrong_binding_mode_for_stream_binding_operator_highlighting=warning +resharper_xaml_binding_without_context_not_resolved_highlighting=hint +resharper_xaml_binding_with_context_not_resolved_highlighting=warning +resharper_xaml_compiled_binding_missing_data_type_error_highlighting_highlighting=error +resharper_xaml_constructor_warning_highlighting=warning +resharper_xaml_decimal_parsing_is_culture_dependent_highlighting=warning +resharper_xaml_dependency_property_resolve_error_highlighting=warning +resharper_xaml_duplicate_style_setter_highlighting=warning +resharper_xaml_dynamic_resource_error_highlighting=error +resharper_xaml_element_name_reference_not_resolved_highlighting=error +resharper_xaml_empty_grid_length_definition_highlighting=error +resharper_xaml_grid_definitions_can_be_converted_to_attribute_highlighting=hint +resharper_xaml_ignored_path_highlighting_highlighting=none +resharper_xaml_index_out_of_grid_definition_highlighting=warning +resharper_xaml_invalid_member_type_highlighting=error +resharper_xaml_invalid_resource_target_type_highlighting=error +resharper_xaml_invalid_resource_type_highlighting=error +resharper_xaml_invalid_type_highlighting=error +resharper_xaml_language_level_highlighting=error +resharper_xaml_mapped_path_highlighting_highlighting=hint +resharper_xaml_method_arguments_will_be_ignored_highlighting=warning +resharper_xaml_missing_grid_index_highlighting=warning +resharper_xaml_overloads_collision_highlighting=warning +resharper_xaml_parent_is_out_of_current_component_tree_highlighting=warning +resharper_xaml_path_error_highlighting=warning +resharper_xaml_possible_null_reference_exception_highlighting=suggestion +resharper_xaml_redundant_attached_property_highlighting=warning +resharper_xaml_redundant_binding_mode_attribute_highlighting=warning +resharper_xaml_redundant_collection_property_highlighting=warning +resharper_xaml_redundant_freeze_attribute_highlighting=warning +resharper_xaml_redundant_grid_definitions_highlighting=warning +resharper_xaml_redundant_grid_span_highlighting=warning +resharper_xaml_redundant_modifiers_attribute_highlighting=warning +resharper_xaml_redundant_namespace_alias_highlighting=warning +resharper_xaml_redundant_name_attribute_highlighting=warning +resharper_xaml_redundant_property_type_qualifier_highlighting=warning +resharper_xaml_redundant_resource_highlighting=warning +resharper_xaml_redundant_styled_value_highlighting=warning +resharper_xaml_redundant_update_source_trigger_attribute_highlighting=warning +resharper_xaml_redundant_xamarin_forms_class_declaration_highlighting=warning +resharper_xaml_resource_file_path_case_mismatch_highlighting=warning +resharper_xaml_routed_event_resolve_error_highlighting=warning +resharper_xaml_static_resource_not_resolved_highlighting=warning +resharper_xaml_style_class_not_found_highlighting=warning +resharper_xaml_style_invalid_target_type_highlighting=error +resharper_xaml_unexpected_text_token_highlighting=error +resharper_xaml_xaml_duplicate_device_family_type_view_highlighting_highlighting=error +resharper_xaml_xaml_mismatched_device_family_view_clr_name_highlighting_highlighting=warning +resharper_xaml_xaml_relative_source_default_mode_warning_highlighting_highlighting=warning +resharper_xaml_xaml_unknown_device_family_type_highlighting_highlighting=warning +resharper_xaml_xaml_xamarin_forms_data_type_and_binding_context_type_mismatched_highlighting_highlighting=warning +resharper_xaml_x_key_attribute_disallowed_highlighting=error +resharper_xml_doc_comment_syntax_problem_highlighting=warning +resharper_xunit_xunit_test_with_console_output_highlighting=warning +csharp_style_prefer_implicitly_typed_lambda_expression = true:suggestion +csharp_style_expression_bodied_methods = true:silent +csharp_style_prefer_tuple_swap = true:suggestion +csharp_style_prefer_unbound_generic_type_in_nameof = true:suggestion +csharp_style_prefer_utf8_string_literals = true:suggestion +csharp_style_inlined_variable_declaration = true:suggestion +csharp_style_expression_bodied_constructors = true:silent +csharp_style_expression_bodied_operators = true:silent +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 +csharp_style_expression_bodied_properties = true:silent -[*.{appxmanifest,asax,ascx,aspx,build,cg,cginc,compute,cs,cshtml,dtd,hlsl,hlsli,hlslinc,master,nuspec,razor,resw,resx,shader,skin,usf,ush,vb,xaml,xamlx,xoml,xsd}] +[*.{cshtml,htm,html,proto,razor}] +indent_style=tab +indent_size=tab +tab_width=4 + +[*.{asax,ascx,aspx,axaml,c,c++,cc,cginc,compute,cp,cpp,cs,css,cu,cuh,cxx,h,hh,hlsl,hlsli,hlslinc,hpp,hxx,inc,inl,ino,ipp,js,jsx,master,mpp,mq4,mq5,mqh,paml,skin,tpp,ts,tsx,usf,ush,vb,xaml,xamlx,xoml}] indent_style=space indent_size=4 tab_width=4 + +[ "*.proto" ] +indent_style=tab +indent_size=tab +tab_width=4 + +[*.{asax,ascx,aspx,axaml,cs,cshtml,css,htm,html,js,jsx,master,paml,razor,skin,ts,tsx,vb,xaml,xamlx,xoml}] +indent_style=space +indent_size=4 +tab_width=4 + +[*.{appxmanifest,axml,build,config,csproj,dbml,discomap,dtd,json,jsproj,lsproj,njsproj,nuspec,proj,props,resjson,resw,resx,StyleCop,targets,tasks,vbproj,xml,xsd}] +indent_style=space +indent_size=2 +tab_width=2 + +[*.{appxmanifest,asax,ascx,aspx,axaml,axml,build,c,c++,cc,cginc,compute,config,cp,cpp,cs,cshtml,csproj,css,cu,cuh,cxx,dbml,discomap,dtd,h,hh,hlsl,hlsli,hlslinc,hpp,htm,html,hxx,inc,inl,ino,ipp,js,json,jsproj,jsx,lsproj,master,mpp,mq4,mq5,mqh,njsproj,nuspec,paml,proj,props,proto,razor,resjson,resw,resx,skin,StyleCop,targets,tasks,tpp,ts,tsx,usf,ush,vb,vbproj,xaml,xamlx,xml,xoml,xsd}] +indent_style=space +indent_size= 4 +tab_width= 4 +dotnet_style_operator_placement_when_wrapping = beginning_of_line +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 +insert_final_newline = true diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 54e31343..1a61439e 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -10,11 +10,15 @@ jobs: build: runs-on: windows-latest steps: - - uses: actions/checkout@v2 - - name: Setup .NET - uses: actions/setup-dotnet@v1 + - uses: actions/checkout@v5 with: - dotnet-version: 5.0.100 + submodules: recursive + - name: Setup .NET + uses: actions/setup-dotnet@v5 + with: + dotnet-version: | + 10.x.x + 9.x.x - name: Restore dependencies run: dotnet restore - name: Download Dalamud @@ -25,9 +29,9 @@ jobs: run: | dotnet build --no-restore --configuration Release --nologo - name: Archive - run: Compress-Archive -Path Penumbra/bin/Release/net5.0-windows/* -DestinationPath Penumbra.zip + run: Compress-Archive -Path Penumbra/bin/Release/* -DestinationPath Penumbra.zip - name: Upload a Build Artifact - uses: actions/upload-artifact@v2.2.1 + uses: actions/upload-artifact@v4 with: path: | - ./Penumbra/bin/Release/net5.0-windows/* + ./Penumbra/bin/Release/* diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 763348f6..c72b4800 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2,18 +2,22 @@ name: Create Release on: push: - tags: - - '*' + tags-ignore: + - testing_* jobs: build: runs-on: windows-latest steps: - - uses: actions/checkout@v2 - - name: Setup .NET - uses: actions/setup-dotnet@v1 + - uses: actions/checkout@v5 with: - dotnet-version: 5.0.100 + submodules: recursive + - name: Setup .NET + uses: actions/setup-dotnet@v5 + with: + dotnet-version: | + 10.x.x + 9.x.x - name: Restore dependencies run: dotnet restore - name: Download Dalamud @@ -22,22 +26,23 @@ jobs: Expand-Archive -Force latest.zip "$env:AppData\XIVLauncher\addon\Hooks\dev" - name: Build run: | - $ver = '${{ github.ref }}' -replace 'refs/tags/','' - invoke-expression 'dotnet build --no-restore --configuration Release --nologo -p:Version=$ver -p:FileVersion=$ver' - - name: write version into json + $ver = '${{ github.ref_name }}' + invoke-expression 'dotnet build --no-restore --configuration Release --nologo -p:Version=$ver -p:FileVersion=$ver -p:AssemblyVersion=$ver' + - name: write version into jsons run: | - $ver = '${{ github.ref }}' -replace 'refs/tags/','' - $path = './Penumbra/bin/Release/net5.0-windows/Penumbra.json' - $content = get-content -path $path - $content = $content -replace '1.0.0.0',$ver + $ver = '${{ github.ref_name }}' + $path = './Penumbra/bin/Release/Penumbra.json' + $json = Get-Content -Raw $path | ConvertFrom-Json + $json.AssemblyVersion = $ver + $content = $json | ConvertTo-Json set-content -Path $path -Value $content - name: Archive - run: Compress-Archive -Path Penumbra/bin/Release/net5.0-windows/* -DestinationPath Penumbra.zip + run: Compress-Archive -Path Penumbra/bin/Release/* -DestinationPath Penumbra.zip - name: Upload a Build Artifact - uses: actions/upload-artifact@v2.2.1 + uses: actions/upload-artifact@v4 with: path: | - ./Penumbra/bin/Release/net5.0-windows/* + ./Penumbra/bin/Release/* - name: Create Release id: create_release uses: actions/create-release@v1 @@ -61,20 +66,24 @@ jobs: - name: Write out repo.json run: | - $ver = '${{ github.ref }}' -replace 'refs/tags/','' - $path = './base_repo.json' - $new_path = './repo.json' - $content = get-content -path $path - $content = $content -replace '1.0.0.0',$ver - set-content -Path $new_path -Value $content + $ver = '${{ github.ref_name }}' + $path = './repo.json' + $json = Get-Content -Raw $path | ConvertFrom-Json + $json[0].AssemblyVersion = $ver + $json[0].TestingAssemblyVersion = $ver + $json[0].DownloadLinkInstall = $json.DownloadLinkInstall -replace '[^/]+/Penumbra.zip',"$ver/Penumbra.zip" + $json[0].DownloadLinkTesting = $json.DownloadLinkTesting -replace '[^/]+/Penumbra.zip',"$ver/Penumbra.zip" + $json[0].DownloadLinkUpdate = $json.DownloadLinkUpdate -replace '[^/]+/Penumbra.zip',"$ver/Penumbra.zip" + $content = $json | ConvertTo-Json -AsArray + set-content -Path $path -Value $content - name: Commit repo.json run: | git config --global user.name "Actions User" git config --global user.email "actions@github.com" - - git fetch origin master && git checkout master + git fetch origin master + git branch -f master ${{ github.sha }} + git checkout master git add repo.json - git commit -m "[CI] Updating repo.json for ${{ github.ref }}" || true - - git push origin master || true \ No newline at end of file + git commit -m "[CI] Updating repo.json for ${{ github.ref_name }}" || true + git push origin master diff --git a/.github/workflows/test_release.yml b/.github/workflows/test_release.yml new file mode 100644 index 00000000..c6b4e459 --- /dev/null +++ b/.github/workflows/test_release.yml @@ -0,0 +1,87 @@ +name: Create Test Release + +on: + push: + tags: + - testing_* + +jobs: + build: + runs-on: windows-latest + steps: + - uses: actions/checkout@v5 + with: + submodules: recursive + - name: Setup .NET + uses: actions/setup-dotnet@v5 + with: + dotnet-version: | + 10.x.x + 9.x.x + - name: Restore dependencies + run: dotnet restore + - name: Download Dalamud + run: | + Invoke-WebRequest -Uri https://goatcorp.github.io/dalamud-distrib/stg/latest.zip -OutFile latest.zip + Expand-Archive -Force latest.zip "$env:AppData\XIVLauncher\addon\Hooks\dev" + - name: Build + run: | + $ver = '${{ github.ref_name }}' -replace 'testing_' + invoke-expression 'dotnet build --no-restore --configuration Debug --nologo -p:Version=$ver -p:FileVersion=$ver -p:AssemblyVersion=$ver' + - name: write version into json + run: | + $ver = '${{ github.ref_name }}' -replace 'testing_' + $path = './Penumbra/bin/Debug/Penumbra.json' + $json = Get-Content -Raw $path | ConvertFrom-Json + $json.AssemblyVersion = $ver + $content = $json | ConvertTo-Json + set-content -Path $path -Value $content + - name: Archive + run: Compress-Archive -Path Penumbra/bin/Debug/* -DestinationPath Penumbra.zip + - name: Upload a Build Artifact + uses: actions/upload-artifact@v4 + with: + path: | + ./Penumbra/bin/Debug/* + - name: Create Release + id: create_release + uses: actions/create-release@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + tag_name: ${{ github.ref }} + release_name: Penumbra ${{ github.ref }} + draft: false + prerelease: false + - name: Upload Release Asset + id: upload-release-asset + uses: actions/upload-release-asset@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + upload_url: ${{ steps.create_release.outputs.upload_url }} # This pulls from the CREATE RELEASE step above, referencing it's ID to get its outputs object, which include a `upload_url`. See this blog post for more info: https://jasonet.co/posts/new-features-of-github-actions/#passing-data-to-future-steps + asset_path: ./Penumbra.zip + asset_name: Penumbra.zip + asset_content_type: application/zip + + - name: Write out repo.json + run: | + $verT = '${{ github.ref_name }}' + $ver = $verT -replace 'testing_' + $path = './repo.json' + $json = Get-Content -Raw $path | ConvertFrom-Json + $json[0].TestingAssemblyVersion = $ver + $json[0].DownloadLinkTesting = $json.DownloadLinkTesting -replace '[^/]+/Penumbra.zip',"$verT/Penumbra.zip" + $content = $json | ConvertTo-Json -AsArray + set-content -Path $path -Value $content + + - name: Commit repo.json + run: | + git config --global user.name "Actions User" + git config --global user.email "actions@github.com" + git fetch origin master + git branch -f master ${{ github.sha }} + git checkout master + git add repo.json + git commit -m "[CI] Updating repo.json for ${{ github.ref_name }}" || true + git push origin master diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 00000000..ea1199ad --- /dev/null +++ b/.gitmodules @@ -0,0 +1,16 @@ +[submodule "OtterGui"] + path = OtterGui + url = https://github.com/Ottermandias/OtterGui.git + branch = main +[submodule "Penumbra.Api"] + path = Penumbra.Api + url = https://github.com/Ottermandias/Penumbra.Api.git + branch = main +[submodule "Penumbra.String"] + path = Penumbra.String + url = https://github.com/Ottermandias/Penumbra.String.git + branch = main +[submodule "Penumbra.GameData"] + path = Penumbra.GameData + url = https://github.com/Ottermandias/Penumbra.GameData.git + branch = main diff --git a/OtterGui b/OtterGui new file mode 160000 index 00000000..ff1e6543 --- /dev/null +++ b/OtterGui @@ -0,0 +1 @@ +Subproject commit ff1e6543845e3b8c53a5f8b240bc38faffb1b3bf diff --git a/Penumbra.Api b/Penumbra.Api new file mode 160000 index 00000000..52a3216a --- /dev/null +++ b/Penumbra.Api @@ -0,0 +1 @@ +Subproject commit 52a3216a525592205198303df2844435e382cf87 diff --git a/Penumbra.CrashHandler/Buffers/AnimationInvocationBuffer.cs b/Penumbra.CrashHandler/Buffers/AnimationInvocationBuffer.cs new file mode 100644 index 00000000..292be2ff --- /dev/null +++ b/Penumbra.CrashHandler/Buffers/AnimationInvocationBuffer.cs @@ -0,0 +1,123 @@ +using System; +using System.Collections.Generic; +using System.Text.Json.Nodes; + +namespace Penumbra.CrashHandler.Buffers; + +/// The types of currently hooked and relevant animation loading functions. +public enum AnimationInvocationType : int +{ + PapLoad, + ActionLoad, + ScheduleClipUpdate, + LoadTimelineResources, + LoadCharacterVfx, + LoadCharacterSound, + ApricotSoundPlay, + LoadAreaVfx, + CharacterBaseLoadAnimation, +} + +/// The full crash entry for an invoked vfx function. +public record struct VfxFuncInvokedEntry( + double Age, + DateTimeOffset Timestamp, + int ThreadId, + string InvocationType, + string CharacterName, + string CharacterAddress, + Guid CollectionId) : ICrashDataEntry; + +/// Only expose the write interface for the buffer. +public interface IAnimationInvocationBufferWriter +{ + /// Write a line into the buffer with the given data. + /// The address of the related character, if known. + /// The name of the related character, anonymized or relying on index if unavailable, if known. + /// The GUID of the associated collection. + /// The type of VFX func called. + public void WriteLine(nint characterAddress, ReadOnlySpan characterName, Guid collectionId, AnimationInvocationType type); +} + +internal sealed class AnimationInvocationBuffer : MemoryMappedBuffer, IAnimationInvocationBufferWriter, IBufferReader +{ + private const int _version = 1; + private const int _lineCount = 64; + private const int _lineCapacity = 128; + private const string _name = "Penumbra.AnimationInvocation"; + + public void WriteLine(nint characterAddress, ReadOnlySpan characterName, Guid collectionId, AnimationInvocationType type) + { + var accessor = GetCurrentLineLocking(); + lock (accessor) + { + accessor.Write(0, DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()); + accessor.Write(8, Environment.CurrentManagedThreadId); + accessor.Write(12, (int)type); + accessor.Write(16, characterAddress); + var span = GetSpan(accessor, 24, 16); + collectionId.TryWriteBytes(span); + accessor.SafeMemoryMappedViewHandle.ReleasePointer(); + span = GetSpan(accessor, 40); + WriteSpan(characterName, span); + accessor.SafeMemoryMappedViewHandle.ReleasePointer(); + } + } + + public uint TotalCount + => TotalWrittenLines; + + public IEnumerable GetLines(DateTimeOffset crashTime) + { + var lineCount = (int)CurrentLineCount; + for (var i = lineCount - 1; i >= 0; --i) + { + var line = GetLine(i); + var timestamp = DateTimeOffset.FromUnixTimeMilliseconds(BitConverter.ToInt64(line)); + var thread = BitConverter.ToInt32(line[8..]); + var type = (AnimationInvocationType)BitConverter.ToInt32(line[12..]); + var address = BitConverter.ToUInt64(line[16..]); + var collectionId = new Guid(line[24..40]); + var characterName = ReadString(line[40..]); + yield return new JsonObject() + { + [nameof(VfxFuncInvokedEntry.Age)] = (crashTime - timestamp).TotalSeconds, + [nameof(VfxFuncInvokedEntry.Timestamp)] = timestamp, + [nameof(VfxFuncInvokedEntry.ThreadId)] = thread, + [nameof(VfxFuncInvokedEntry.InvocationType)] = ToName(type), + [nameof(VfxFuncInvokedEntry.CharacterName)] = characterName, + [nameof(VfxFuncInvokedEntry.CharacterAddress)] = address.ToString("X"), + [nameof(VfxFuncInvokedEntry.CollectionId)] = collectionId, + }; + } + } + + public static IBufferReader CreateReader(int pid) + => new AnimationInvocationBuffer(false, pid); + + public static IAnimationInvocationBufferWriter CreateWriter(int pid) + => new AnimationInvocationBuffer(pid); + + private AnimationInvocationBuffer(bool writer, int pid) + : base($"{_name}_{pid}_{_version}", _version) + { } + + private AnimationInvocationBuffer(int pid) + : base($"{_name}_{pid}_{_version}", _version, _lineCount, _lineCapacity) + { } + + private static string ToName(AnimationInvocationType type) + => type switch + { + AnimationInvocationType.PapLoad => "PAP Load", + AnimationInvocationType.ActionLoad => "Action Load", + AnimationInvocationType.ScheduleClipUpdate => "Schedule Clip Update", + AnimationInvocationType.LoadTimelineResources => "Load Timeline Resources", + AnimationInvocationType.LoadCharacterVfx => "Load Character VFX", + AnimationInvocationType.LoadCharacterSound => "Load Character Sound", + AnimationInvocationType.ApricotSoundPlay => "Apricot Sound Play", + AnimationInvocationType.LoadAreaVfx => "Load Area VFX", + AnimationInvocationType.CharacterBaseLoadAnimation => "Load Animation (CharacterBase)", + _ => $"Unknown ({(int)type})", + }; +} diff --git a/Penumbra.CrashHandler/Buffers/CharacterBaseBuffer.cs b/Penumbra.CrashHandler/Buffers/CharacterBaseBuffer.cs new file mode 100644 index 00000000..89fea29d --- /dev/null +++ b/Penumbra.CrashHandler/Buffers/CharacterBaseBuffer.cs @@ -0,0 +1,89 @@ +using System; +using System.Collections.Generic; +using System.Text.Json.Nodes; + +namespace Penumbra.CrashHandler.Buffers; + +/// Only expose the write interface for the buffer. +public interface ICharacterBaseBufferWriter +{ + /// Write a line into the buffer with the given data. + /// The address of the related character, if known. + /// The name of the related character, anonymized or relying on index if unavailable, if known. + /// The GUID of the associated collection. + public void WriteLine(nint characterAddress, ReadOnlySpan characterName, Guid collectionId); +} + +/// The full crash entry for a loaded character base. +public record struct CharacterLoadedEntry( + double Age, + DateTimeOffset Timestamp, + int ThreadId, + string CharacterName, + string CharacterAddress, + Guid CollectionId) : ICrashDataEntry; + +internal sealed class CharacterBaseBuffer : MemoryMappedBuffer, ICharacterBaseBufferWriter, IBufferReader +{ + private const int _version = 1; + private const int _lineCount = 10; + private const int _lineCapacity = 128; + private const string _name = "Penumbra.CharacterBase"; + + public void WriteLine(nint characterAddress, ReadOnlySpan characterName, Guid collectionId) + { + var accessor = GetCurrentLineLocking(); + lock (accessor) + { + accessor.Write(0, DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()); + accessor.Write(8, Environment.CurrentManagedThreadId); + accessor.Write(12, characterAddress); + var span = GetSpan(accessor, 20, 16); + collectionId.TryWriteBytes(span); + accessor.SafeMemoryMappedViewHandle.ReleasePointer(); + span = GetSpan(accessor, 36); + WriteSpan(characterName, span); + accessor.SafeMemoryMappedViewHandle.ReleasePointer(); + } + } + + public IEnumerable GetLines(DateTimeOffset crashTime) + { + var lineCount = (int)CurrentLineCount; + for (var i = lineCount - 1; i >= 0; --i) + { + var line = GetLine(i); + var timestamp = DateTimeOffset.FromUnixTimeMilliseconds(BitConverter.ToInt64(line)); + var thread = BitConverter.ToInt32(line[8..]); + var address = BitConverter.ToUInt64(line[12..]); + var collectionId = new Guid(line[20..36]); + var characterName = ReadString(line[36..]); + yield return new JsonObject + { + [nameof(CharacterLoadedEntry.Age)] = (crashTime - timestamp).TotalSeconds, + [nameof(CharacterLoadedEntry.Timestamp)] = timestamp, + [nameof(CharacterLoadedEntry.ThreadId)] = thread, + [nameof(CharacterLoadedEntry.CharacterName)] = characterName, + [nameof(CharacterLoadedEntry.CharacterAddress)] = address.ToString("X"), + [nameof(CharacterLoadedEntry.CollectionId)] = collectionId, + }; + } + } + + public uint TotalCount + => TotalWrittenLines; + + public static IBufferReader CreateReader(int pid) + => new CharacterBaseBuffer(false, pid); + + public static ICharacterBaseBufferWriter CreateWriter(int pid) + => new CharacterBaseBuffer(pid); + + private CharacterBaseBuffer(bool writer, int pid) + : base($"{_name}_{pid}_{_version}", _version) + { } + + private CharacterBaseBuffer(int pid) + : base($"{_name}_{pid}_{_version}", _version, _lineCount, _lineCapacity) + { } +} diff --git a/Penumbra.CrashHandler/Buffers/MemoryMappedBuffer.cs b/Penumbra.CrashHandler/Buffers/MemoryMappedBuffer.cs new file mode 100644 index 00000000..e2ffcebe --- /dev/null +++ b/Penumbra.CrashHandler/Buffers/MemoryMappedBuffer.cs @@ -0,0 +1,220 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.IO.MemoryMappedFiles; +using System.Linq; +using System.Numerics; +using System.Text; + +namespace Penumbra.CrashHandler.Buffers; + +public class MemoryMappedBuffer : IDisposable +{ + private const int MinHeaderLength = 4 + 4 + 4 + 4 + 4 + 4 + 4 + 4; + + private readonly MemoryMappedFile _file; + private readonly MemoryMappedViewAccessor _header; + private readonly MemoryMappedViewAccessor[] _lines = []; + + public readonly int Version; + public readonly uint LineCount; + public readonly uint LineCapacity; + private readonly uint _lineMask; + private bool _disposed; + + protected uint CurrentLineCount + { + get => _header.ReadUInt32(16); + set => _header.Write(16, value); + } + + protected uint CurrentLinePosition + { + get => _header.ReadUInt32(20); + set => _header.Write(20, value); + } + + public uint TotalWrittenLines + { + get => _header.ReadUInt32(24); + protected set => _header.Write(24, value); + } + + public MemoryMappedBuffer(string mapName, int version, uint lineCount, uint lineCapacity) + { + Version = version; + LineCount = BitOperations.RoundUpToPowerOf2(Math.Clamp(lineCount, 2, int.MaxValue >> 3)); + LineCapacity = BitOperations.RoundUpToPowerOf2(Math.Clamp(lineCapacity, 2, int.MaxValue >> 3)); + _lineMask = LineCount - 1; + var fileName = Encoding.UTF8.GetBytes(mapName); + var headerLength = (uint)(4 + 4 + 4 + 4 + 4 + 4 + 4 + fileName.Length + 1); + headerLength = (headerLength & 0b111) > 0 ? (headerLength & ~0b111u) + 0b1000 : headerLength; + var capacity = LineCount * LineCapacity + headerLength; + _file = MemoryMappedFile.CreateNew(mapName, capacity, MemoryMappedFileAccess.ReadWrite, MemoryMappedFileOptions.None, + HandleInheritability.Inheritable); + _header = _file.CreateViewAccessor(0, headerLength); + _header.Write(0, headerLength); + _header.Write(4, Version); + _header.Write(8, LineCount); + _header.Write(12, LineCapacity); + _header.WriteArray(28, fileName, 0, fileName.Length); + _header.Write(fileName.Length + 28, (byte)0); + _lines = Enumerable.Range(0, (int)LineCount).Select(i + => _file.CreateViewAccessor(headerLength + i * LineCapacity, LineCapacity, MemoryMappedFileAccess.ReadWrite)) + .ToArray(); + } + + public MemoryMappedBuffer(string mapName, int? expectedVersion = null, uint? expectedMinLineCount = null, + uint? expectedMinLineCapacity = null) + { + _lines = []; + _file = MemoryMappedFile.OpenExisting(mapName, MemoryMappedFileRights.ReadWrite, HandleInheritability.Inheritable); + using var headerLine = _file.CreateViewAccessor(0, 4, MemoryMappedFileAccess.Read); + var headerLength = headerLine.ReadUInt32(0); + if (headerLength < MinHeaderLength) + Throw($"Map {mapName} did not contain a valid header."); + + _header = _file.CreateViewAccessor(0, headerLength, MemoryMappedFileAccess.ReadWrite); + Version = _header.ReadInt32(4); + LineCount = _header.ReadUInt32(8); + LineCapacity = _header.ReadUInt32(12); + _lineMask = LineCount - 1; + if (expectedVersion.HasValue && expectedVersion.Value != Version) + Throw($"Map {mapName} has version {Version} instead of {expectedVersion.Value}."); + + if (LineCount < expectedMinLineCount) + Throw($"Map {mapName} has line count {LineCount} but line count >= {expectedMinLineCount.Value} is required."); + + if (LineCapacity < expectedMinLineCapacity) + Throw($"Map {mapName} has line capacity {LineCapacity} but line capacity >= {expectedMinLineCapacity.Value} is required."); + + var name = ReadString(GetSpan(_header, 28)); + if (name != mapName) + Throw($"Map {mapName} does not contain its map name at the expected location."); + + _lines = Enumerable.Range(0, (int)LineCount).Select(i + => _file.CreateViewAccessor(headerLength + i * LineCapacity, LineCapacity, MemoryMappedFileAccess.ReadWrite)) + .ToArray(); + + [DoesNotReturn] + void Throw(string text) + { + _file.Dispose(); + _header?.Dispose(); + _disposed = true; + throw new Exception(text); + } + } + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + _disposed = true; + } + + protected static string ReadString(Span span) + { + if (span.IsEmpty) + throw new Exception("String from empty span requested."); + + var termination = span.IndexOf((byte)0); + if (termination < 0) + throw new Exception("String in span is not terminated."); + + return Encoding.UTF8.GetString(span[..termination]); + } + + protected static int WriteString(string text, Span span) + { + var bytes = Encoding.UTF8.GetBytes(text); + var source = (Span)bytes; + var length = source.Length + 1; + if (length > span.Length) + source = source[..(span.Length - 1)]; + source.CopyTo(span); + span[bytes.Length] = 0; + return source.Length + 1; + } + + protected static int WriteSpan(ReadOnlySpan input, Span span) + { + var length = input.Length + 1; + if (length > span.Length) + input = input[..(span.Length - 1)]; + + input.CopyTo(span); + span[input.Length] = 0; + return input.Length + 1; + } + + protected Span GetLine(int i) + { + if (i < 0 || i > LineCount) + return null; + + lock (_header) + { + var lineIdx = (CurrentLinePosition + i) & _lineMask; + if (lineIdx > CurrentLineCount) + return null; + + return GetSpan(_lines[lineIdx]); + } + } + + + protected MemoryMappedViewAccessor GetCurrentLineLocking() + { + MemoryMappedViewAccessor view; + lock (_header) + { + var currentLineCount = CurrentLineCount; + if (currentLineCount == LineCount) + { + var currentLinePos = CurrentLinePosition; + view = _lines[currentLinePos]!; + CurrentLinePosition = (currentLinePos + 1) & _lineMask; + } + else + { + view = _lines[currentLineCount]; + ++CurrentLineCount; + } + + ++TotalWrittenLines; + _header.Flush(); + } + + return view; + } + + protected static Span GetSpan(MemoryMappedViewAccessor accessor, int offset = 0) + => GetSpan(accessor, offset, (int)accessor.Capacity - offset); + + protected static unsafe Span GetSpan(MemoryMappedViewAccessor accessor, int offset, int size) + { + byte* ptr = null; + accessor.SafeMemoryMappedViewHandle.AcquirePointer(ref ptr); + size = Math.Min(size, (int)accessor.Capacity - offset); + if (size < 0) + return []; + + var span = new Span(ptr + offset + accessor.PointerOffset, size); + return span; + } + + protected void Dispose(bool disposing) + { + if (_disposed) + return; + + _header?.Dispose(); + foreach (var line in _lines) + line?.Dispose(); + _file?.Dispose(); + } + + ~MemoryMappedBuffer() + => Dispose(false); +} diff --git a/Penumbra.CrashHandler/Buffers/ModdedFileBuffer.cs b/Penumbra.CrashHandler/Buffers/ModdedFileBuffer.cs new file mode 100644 index 00000000..e4ee66d0 --- /dev/null +++ b/Penumbra.CrashHandler/Buffers/ModdedFileBuffer.cs @@ -0,0 +1,105 @@ +using System; +using System.Collections.Generic; +using System.Text.Json.Nodes; + +namespace Penumbra.CrashHandler.Buffers; + +/// Only expose the write interface for the buffer. +public interface IModdedFileBufferWriter +{ + /// Write a line into the buffer with the given data. + /// The address of the related character, if known. + /// The name of the related character, anonymized or relying on index if unavailable, if known. + /// The GUID of the associated collection. + /// The file name as requested by the game. + /// The actual modded file name loaded. + public void WriteLine(nint characterAddress, ReadOnlySpan characterName, Guid collectionId, ReadOnlySpan requestedFileName, + ReadOnlySpan actualFileName); +} + +/// The full crash entry for a loaded modded file. +public record struct ModdedFileLoadedEntry( + double Age, + DateTimeOffset Timestamp, + int ThreadId, + string CharacterName, + string CharacterAddress, + Guid CollectionId, + string RequestedFileName, + string ActualFileName) : ICrashDataEntry; + +internal sealed class ModdedFileBuffer : MemoryMappedBuffer, IModdedFileBufferWriter, IBufferReader +{ + private const int _version = 1; + private const int _lineCount = 128; + private const int _lineCapacity = 1024; + private const string _name = "Penumbra.ModdedFile"; + + public void WriteLine(nint characterAddress, ReadOnlySpan characterName, Guid collectionId, ReadOnlySpan requestedFileName, + ReadOnlySpan actualFileName) + { + var accessor = GetCurrentLineLocking(); + lock (accessor) + { + accessor.Write(0, DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()); + accessor.Write(8, Environment.CurrentManagedThreadId); + accessor.Write(12, characterAddress); + var span = GetSpan(accessor, 20, 16); + collectionId.TryWriteBytes(span); + accessor.SafeMemoryMappedViewHandle.ReleasePointer(); + span = GetSpan(accessor, 36, 80); + WriteSpan(characterName, span); + accessor.SafeMemoryMappedViewHandle.ReleasePointer(); + span = GetSpan(accessor, 116, 260); + WriteSpan(requestedFileName, span); + accessor.SafeMemoryMappedViewHandle.ReleasePointer(); + span = GetSpan(accessor, 376); + WriteSpan(actualFileName, span); + accessor.SafeMemoryMappedViewHandle.ReleasePointer(); + } + } + + public uint TotalCount + => TotalWrittenLines; + + public IEnumerable GetLines(DateTimeOffset crashTime) + { + var lineCount = (int)CurrentLineCount; + for (var i = lineCount - 1; i >= 0; --i) + { + var line = GetLine(i); + var timestamp = DateTimeOffset.FromUnixTimeMilliseconds(BitConverter.ToInt64(line)); + var thread = BitConverter.ToInt32(line[8..]); + var address = BitConverter.ToUInt64(line[12..]); + var collectionId = new Guid(line[20..36]); + var characterName = ReadString(line[36..]); + var requestedFileName = ReadString(line[116..]); + var actualFileName = ReadString(line[376..]); + yield return new JsonObject() + { + [nameof(ModdedFileLoadedEntry.Age)] = (crashTime - timestamp).TotalSeconds, + [nameof(ModdedFileLoadedEntry.Timestamp)] = timestamp, + [nameof(ModdedFileLoadedEntry.ThreadId)] = thread, + [nameof(ModdedFileLoadedEntry.CharacterName)] = characterName, + [nameof(ModdedFileLoadedEntry.CharacterAddress)] = address.ToString("X"), + [nameof(ModdedFileLoadedEntry.CollectionId)] = collectionId, + [nameof(ModdedFileLoadedEntry.RequestedFileName)] = requestedFileName, + [nameof(ModdedFileLoadedEntry.ActualFileName)] = actualFileName, + }; + } + } + + public static IBufferReader CreateReader(int pid) + => new ModdedFileBuffer(false, pid); + + public static IModdedFileBufferWriter CreateWriter(int pid) + => new ModdedFileBuffer(pid); + + private ModdedFileBuffer(bool writer, int pid) + : base($"{_name}_{pid}_{_version}", _version) + { } + + private ModdedFileBuffer(int pid) + : base($"{_name}_{pid}_{_version}", _version, _lineCount, _lineCapacity) + { } +} diff --git a/Penumbra.CrashHandler/CrashData.cs b/Penumbra.CrashHandler/CrashData.cs new file mode 100644 index 00000000..55460548 --- /dev/null +++ b/Penumbra.CrashHandler/CrashData.cs @@ -0,0 +1,70 @@ +using System; +using System.Collections.Generic; +using Penumbra.CrashHandler.Buffers; + +namespace Penumbra.CrashHandler; + +/// A base entry for crash data. +public interface ICrashDataEntry +{ + /// The timestamp of the event. + DateTimeOffset Timestamp { get; } + + /// The thread invoking the event. + int ThreadId { get; } + + /// The age of the event compared to the crash. (Redundantly with the timestamp) + double Age { get; } +} + +/// A full set of crash data. +public class CrashData +{ + /// The mode this data was obtained - manually or from a crash. + public string Mode { get; set; } = "Unknown"; + + /// The time this crash data was generated. + public DateTimeOffset CrashTime { get; set; } = DateTimeOffset.UnixEpoch; + + /// Penumbra's Version when this crash data was created. + public string Version { get; set; } = string.Empty; + + /// The Game's Version when this crash data was created. + public string GameVersion { get; set; } = string.Empty; + + /// The FFXIV process ID when this data was generated. + public int ProcessId { get; set; } = 0; + + /// The FFXIV Exit Code (if any) when this data was generated. + public int ExitCode { get; set; } = 0; + + /// The total amount of characters loaded during this session. + public int TotalCharactersLoaded { get; set; } = 0; + + /// The total amount of modded files loaded during this session. + public int TotalModdedFilesLoaded { get; set; } = 0; + + /// The total amount of vfx functions invoked during this session. + public int TotalVFXFuncsInvoked { get; set; } = 0; + + /// The last character loaded before this crash data was generated. + public CharacterLoadedEntry? LastCharacterLoaded + => LastCharactersLoaded.Count == 0 ? default : LastCharactersLoaded[0]; + + /// The last modded file loaded before this crash data was generated. + public ModdedFileLoadedEntry? LastModdedFileLoaded + => LastModdedFilesLoaded.Count == 0 ? default : LastModdedFilesLoaded[0]; + + /// The last vfx function invoked before this crash data was generated. + public VfxFuncInvokedEntry? LastVfxFuncInvoked + => LastVFXFuncsInvoked.Count == 0 ? default : LastVFXFuncsInvoked[0]; + + /// A collection of the last few characters loaded before this crash data was generated. + public List LastCharactersLoaded { get; set; } = []; + + /// A collection of the last few modded files loaded before this crash data was generated. + public List LastModdedFilesLoaded { get; set; } = []; + + /// A collection of the last few vfx functions invoked before this crash data was generated. + public List LastVFXFuncsInvoked { get; set; } = []; +} diff --git a/Penumbra.CrashHandler/GameEventLogReader.cs b/Penumbra.CrashHandler/GameEventLogReader.cs new file mode 100644 index 00000000..8a7f53f8 --- /dev/null +++ b/Penumbra.CrashHandler/GameEventLogReader.cs @@ -0,0 +1,58 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json.Nodes; +using Penumbra.CrashHandler.Buffers; + +namespace Penumbra.CrashHandler; + +public interface IBufferReader +{ + public uint TotalCount { get; } + public IEnumerable GetLines(DateTimeOffset crashTime); +} + +public sealed class GameEventLogReader(int pid) : IDisposable +{ + public readonly (IBufferReader Reader, string TypeSingular, string TypePlural)[] Readers = + [ + (CharacterBaseBuffer.CreateReader(pid), "CharacterLoaded", "CharactersLoaded"), + (ModdedFileBuffer.CreateReader(pid), "ModdedFileLoaded", "ModdedFilesLoaded"), + (AnimationInvocationBuffer.CreateReader(pid), "VFXFuncInvoked", "VFXFuncsInvoked"), + ]; + + public void Dispose() + { + foreach (var (reader, _, _) in Readers) + (reader as IDisposable)?.Dispose(); + } + + + public JsonObject Dump(string mode, int processId, int exitCode, string version, string gameVersion) + { + var crashTime = DateTimeOffset.UtcNow; + var obj = new JsonObject + { + [nameof(CrashData.Mode)] = mode, + [nameof(CrashData.CrashTime)] = DateTimeOffset.UtcNow, + [nameof(CrashData.ProcessId)] = processId, + [nameof(CrashData.ExitCode)] = exitCode, + [nameof(CrashData.Version)] = version, + [nameof(CrashData.GameVersion)] = gameVersion, + }; + + foreach (var (reader, singular, _) in Readers) + obj["Last" + singular] = reader.GetLines(crashTime).FirstOrDefault(); + + foreach (var (reader, _, plural) in Readers) + { + obj["Total" + plural] = reader.TotalCount; + var array = new JsonArray(); + foreach (var file in reader.GetLines(crashTime)) + array.Add(file); + obj["Last" + plural] = array; + } + + return obj; + } +} diff --git a/Penumbra.CrashHandler/GameEventLogWriter.cs b/Penumbra.CrashHandler/GameEventLogWriter.cs new file mode 100644 index 00000000..915c59a2 --- /dev/null +++ b/Penumbra.CrashHandler/GameEventLogWriter.cs @@ -0,0 +1,18 @@ +using System; +using Penumbra.CrashHandler.Buffers; + +namespace Penumbra.CrashHandler; + +public sealed class GameEventLogWriter(int pid) : IDisposable +{ + public readonly ICharacterBaseBufferWriter CharacterBase = CharacterBaseBuffer.CreateWriter(pid); + public readonly IModdedFileBufferWriter FileLoaded = ModdedFileBuffer.CreateWriter(pid); + public readonly IAnimationInvocationBufferWriter AnimationFuncInvoked = AnimationInvocationBuffer.CreateWriter(pid); + + public void Dispose() + { + (CharacterBase as IDisposable)?.Dispose(); + (FileLoaded as IDisposable)?.Dispose(); + (AnimationFuncInvoked as IDisposable)?.Dispose(); + } +} diff --git a/Penumbra.CrashHandler/Penumbra.CrashHandler.csproj b/Penumbra.CrashHandler/Penumbra.CrashHandler.csproj new file mode 100644 index 00000000..e07bb745 --- /dev/null +++ b/Penumbra.CrashHandler/Penumbra.CrashHandler.csproj @@ -0,0 +1,18 @@ + + + Exe + + + + embedded + + + + embedded + + + + false + + + diff --git a/Penumbra.CrashHandler/Program.cs b/Penumbra.CrashHandler/Program.cs new file mode 100644 index 00000000..38c176a6 --- /dev/null +++ b/Penumbra.CrashHandler/Program.cs @@ -0,0 +1,41 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.Text.Json; + +namespace Penumbra.CrashHandler; + +public class CrashHandler +{ + public static void Main(string[] args) + { + if (args.Length < 4 || !int.TryParse(args[1], out var pid)) + return; + + try + { + using var reader = new GameEventLogReader(pid); + var parent = Process.GetProcessById(pid); + using var handle = parent.SafeHandle; + parent.WaitForExit(); + int exitCode; + try + { + exitCode = parent.ExitCode; + } + catch + { + exitCode = -1; + } + + var obj = reader.Dump("Crash", pid, exitCode, args[2], args[3]); + using var fs = File.Open(args[0], FileMode.Create); + using var w = new Utf8JsonWriter(fs, new JsonWriterOptions { Indented = true }); + obj.WriteTo(w, new JsonSerializerOptions() { WriteIndented = true }); + } + catch (Exception ex) + { + File.WriteAllText(args[0], $"{DateTime.UtcNow} {pid} {ex}"); + } + } +} diff --git a/Penumbra.CrashHandler/packages.lock.json b/Penumbra.CrashHandler/packages.lock.json new file mode 100644 index 00000000..0a160ea5 --- /dev/null +++ b/Penumbra.CrashHandler/packages.lock.json @@ -0,0 +1,13 @@ +{ + "version": 1, + "dependencies": { + "net10.0-windows7.0": { + "DotNet.ReproducibleBuilds": { + "type": "Direct", + "requested": "[1.2.39, )", + "resolved": "1.2.39", + "contentHash": "fcFN01tDTIQqDuTwr1jUQK/geofiwjG5DycJQOnC72i1SsLAk1ELe+apBOuZ11UMQG8YKFZG1FgvjZPbqHyatg==" + } + } + } +} \ No newline at end of file diff --git a/Penumbra.GameData b/Penumbra.GameData new file mode 160000 index 00000000..0e973ed6 --- /dev/null +++ b/Penumbra.GameData @@ -0,0 +1 @@ +Subproject commit 0e973ed6eace6afd31cd298f8c58f76fa8d5ef60 diff --git a/Penumbra.GameData/Enums/BodySlot.cs b/Penumbra.GameData/Enums/BodySlot.cs deleted file mode 100644 index 0e1220ce..00000000 --- a/Penumbra.GameData/Enums/BodySlot.cs +++ /dev/null @@ -1,43 +0,0 @@ -using System.Collections.Generic; -using System.ComponentModel; - -namespace Penumbra.GameData.Enums -{ - public enum BodySlot : byte - { - Unknown, - Hair, - Face, - Tail, - Body, - Zear, - } - - public static class BodySlotEnumExtension - { - public static string ToSuffix( this BodySlot value ) - { - return value switch - { - BodySlot.Zear => "zear", - BodySlot.Face => "face", - BodySlot.Hair => "hair", - BodySlot.Body => "body", - BodySlot.Tail => "tail", - _ => throw new InvalidEnumArgumentException(), - }; - } - } - - public static partial class Names - { - public static readonly Dictionary< string, BodySlot > StringToBodySlot = new() - { - { BodySlot.Zear.ToSuffix(), BodySlot.Zear }, - { BodySlot.Face.ToSuffix(), BodySlot.Face }, - { BodySlot.Hair.ToSuffix(), BodySlot.Hair }, - { BodySlot.Body.ToSuffix(), BodySlot.Body }, - { BodySlot.Tail.ToSuffix(), BodySlot.Tail }, - }; - } -} \ No newline at end of file diff --git a/Penumbra.GameData/Enums/ChangedItemType.cs b/Penumbra.GameData/Enums/ChangedItemType.cs deleted file mode 100644 index fa33382d..00000000 --- a/Penumbra.GameData/Enums/ChangedItemType.cs +++ /dev/null @@ -1,40 +0,0 @@ -using System; -using Lumina.Excel.GeneratedSheets; -using Action = Lumina.Excel.GeneratedSheets.Action; - -namespace Penumbra.GameData.Enums -{ - public enum ChangedItemType - { - None, - Item, - Action, - Customization, - } - - public static class ChangedItemExtensions - { - public static (ChangedItemType, uint) ChangedItemToTypeAndId( object? item ) - { - return item switch - { - null => ( ChangedItemType.None, 0 ), - Item i => ( ChangedItemType.Item, i.RowId ), - Action a => ( ChangedItemType.Action, a.RowId ), - _ => ( ChangedItemType.Customization, 0 ), - }; - } - - public static object? GetObject( this ChangedItemType type, uint id ) - { - return type switch - { - ChangedItemType.None => null, - ChangedItemType.Item => ObjectIdentification.DataManager?.GetExcelSheet< Item >()?.GetRow( id ), - ChangedItemType.Action => ObjectIdentification.DataManager?.GetExcelSheet< Action >()?.GetRow( id ), - ChangedItemType.Customization => null, - _ => throw new ArgumentOutOfRangeException( nameof( type ), type, null ) - }; - } - } -} diff --git a/Penumbra.GameData/Enums/CustomizationType.cs b/Penumbra.GameData/Enums/CustomizationType.cs deleted file mode 100644 index 60cf23dd..00000000 --- a/Penumbra.GameData/Enums/CustomizationType.cs +++ /dev/null @@ -1,55 +0,0 @@ -using System.Collections.Generic; -using System.ComponentModel; - -namespace Penumbra.GameData.Enums -{ - public enum CustomizationType : byte - { - Unknown, - Body, - Tail, - Face, - Iris, - Accessory, - Hair, - Zear, - DecalFace, - DecalEquip, - Skin, - Etc, - } - - public static class CustomizationTypeEnumExtension - { - public static string ToSuffix( this CustomizationType value ) - { - return value switch - { - CustomizationType.Body => "top", - CustomizationType.Face => "fac", - CustomizationType.Iris => "iri", - CustomizationType.Accessory => "acc", - CustomizationType.Hair => "hir", - CustomizationType.Tail => "til", - CustomizationType.Zear => "zer", - CustomizationType.Etc => "etc", - _ => throw new InvalidEnumArgumentException(), - }; - } - } - - public static partial class Names - { - public static readonly Dictionary< string, CustomizationType > SuffixToCustomizationType = new() - { - { CustomizationType.Body.ToSuffix(), CustomizationType.Body }, - { CustomizationType.Face.ToSuffix(), CustomizationType.Face }, - { CustomizationType.Iris.ToSuffix(), CustomizationType.Iris }, - { CustomizationType.Accessory.ToSuffix(), CustomizationType.Accessory }, - { CustomizationType.Hair.ToSuffix(), CustomizationType.Hair }, - { CustomizationType.Tail.ToSuffix(), CustomizationType.Tail }, - { CustomizationType.Zear.ToSuffix(), CustomizationType.Zear }, - { CustomizationType.Etc.ToSuffix(), CustomizationType.Etc }, - }; - } -} \ No newline at end of file diff --git a/Penumbra.GameData/Enums/EquipSlot.cs b/Penumbra.GameData/Enums/EquipSlot.cs deleted file mode 100644 index 061787e6..00000000 --- a/Penumbra.GameData/Enums/EquipSlot.cs +++ /dev/null @@ -1,125 +0,0 @@ -using System.Collections.Generic; -using System.ComponentModel; - -namespace Penumbra.GameData.Enums -{ - public enum EquipSlot : byte - { - Unknown = 0, - MainHand = 1, - OffHand = 2, - Head = 3, - Body = 4, - Hands = 5, - Belt = 6, - Legs = 7, - Feet = 8, - Ears = 9, - Neck = 10, - Wrists = 11, - RFinger = 12, - BothHand = 13, - LFinger = 14, // Not officially existing, means "weapon could be equipped in either hand" for the game. - HeadBody = 15, - BodyHandsLegsFeet = 16, - SoulCrystal = 17, - LegsFeet = 18, - FullBody = 19, - BodyHands = 20, - BodyLegsFeet = 21, - All = 22, // Not officially existing - } - - public static class EquipSlotEnumExtension - { - public static string ToSuffix( this EquipSlot value ) - { - return value switch - { - EquipSlot.Head => "met", - EquipSlot.Hands => "glv", - EquipSlot.Legs => "dwn", - EquipSlot.Feet => "sho", - EquipSlot.Body => "top", - EquipSlot.Ears => "ear", - EquipSlot.Neck => "nek", - EquipSlot.RFinger => "rir", - EquipSlot.LFinger => "ril", - EquipSlot.Wrists => "wrs", - _ => throw new InvalidEnumArgumentException(), - }; - } - - public static EquipSlot ToSlot( this EquipSlot value ) - { - return value switch - { - EquipSlot.MainHand => EquipSlot.MainHand, - EquipSlot.OffHand => EquipSlot.OffHand, - EquipSlot.Head => EquipSlot.Head, - EquipSlot.Body => EquipSlot.Body, - EquipSlot.Hands => EquipSlot.Hands, - EquipSlot.Belt => EquipSlot.Belt, - EquipSlot.Legs => EquipSlot.Legs, - EquipSlot.Feet => EquipSlot.Feet, - EquipSlot.Ears => EquipSlot.Ears, - EquipSlot.Neck => EquipSlot.Neck, - EquipSlot.Wrists => EquipSlot.Wrists, - EquipSlot.RFinger => EquipSlot.RFinger, - EquipSlot.BothHand => EquipSlot.MainHand, - EquipSlot.LFinger => EquipSlot.RFinger, - EquipSlot.HeadBody => EquipSlot.Body, - EquipSlot.BodyHandsLegsFeet => EquipSlot.Body, - EquipSlot.SoulCrystal => EquipSlot.SoulCrystal, - EquipSlot.LegsFeet => EquipSlot.Legs, - EquipSlot.FullBody => EquipSlot.Body, - EquipSlot.BodyHands => EquipSlot.Body, - EquipSlot.BodyLegsFeet => EquipSlot.Body, - _ => throw new InvalidEnumArgumentException(), - }; - } - - public static bool IsEquipment( this EquipSlot value ) - { - return value switch - { - EquipSlot.Head => true, - EquipSlot.Hands => true, - EquipSlot.Legs => true, - EquipSlot.Feet => true, - EquipSlot.Body => true, - _ => false, - }; - } - - public static bool IsAccessory( this EquipSlot value ) - { - return value switch - { - EquipSlot.Ears => true, - EquipSlot.Neck => true, - EquipSlot.RFinger => true, - EquipSlot.LFinger => true, - EquipSlot.Wrists => true, - _ => false, - }; - } - } - - public static partial class Names - { - public static readonly Dictionary< string, EquipSlot > SuffixToEquipSlot = new() - { - { EquipSlot.Head.ToSuffix(), EquipSlot.Head }, - { EquipSlot.Hands.ToSuffix(), EquipSlot.Hands }, - { EquipSlot.Legs.ToSuffix(), EquipSlot.Legs }, - { EquipSlot.Feet.ToSuffix(), EquipSlot.Feet }, - { EquipSlot.Body.ToSuffix(), EquipSlot.Body }, - { EquipSlot.Ears.ToSuffix(), EquipSlot.Ears }, - { EquipSlot.Neck.ToSuffix(), EquipSlot.Neck }, - { EquipSlot.RFinger.ToSuffix(), EquipSlot.RFinger }, - { EquipSlot.LFinger.ToSuffix(), EquipSlot.LFinger }, - { EquipSlot.Wrists.ToSuffix(), EquipSlot.Wrists }, - }; - } -} \ No newline at end of file diff --git a/Penumbra.GameData/Enums/FileType.cs b/Penumbra.GameData/Enums/FileType.cs deleted file mode 100644 index e326ef17..00000000 --- a/Penumbra.GameData/Enums/FileType.cs +++ /dev/null @@ -1,45 +0,0 @@ -using System.Collections.Generic; - -namespace Penumbra.GameData.Enums -{ - public enum FileType : byte - { - Unknown, - Sound, - Imc, - Vfx, - Animation, - Pap, - MetaInfo, - Material, - Texture, - Model, - Shader, - Font, - Environment, - } - - public static partial class Names - { - public static readonly Dictionary< string, FileType > ExtensionToFileType = new() - { - { ".mdl", FileType.Model }, - { ".tex", FileType.Texture }, - { ".mtrl", FileType.Material }, - { ".atex", FileType.Animation }, - { ".avfx", FileType.Vfx }, - { ".scd", FileType.Sound }, - { ".imc", FileType.Imc }, - { ".pap", FileType.Pap }, - { ".eqp", FileType.MetaInfo }, - { ".eqdp", FileType.MetaInfo }, - { ".est", FileType.MetaInfo }, - { ".exd", FileType.MetaInfo }, - { ".exh", FileType.MetaInfo }, - { ".shpk", FileType.Shader }, - { ".shcd", FileType.Shader }, - { ".fdt", FileType.Font }, - { ".envb", FileType.Environment }, - }; - } -} \ No newline at end of file diff --git a/Penumbra.GameData/Enums/MouseButton.cs b/Penumbra.GameData/Enums/MouseButton.cs deleted file mode 100644 index 99948d7c..00000000 --- a/Penumbra.GameData/Enums/MouseButton.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace Penumbra.GameData.Enums -{ - public enum MouseButton - { - None, - Left, - Right, - Middle, - } -} \ No newline at end of file diff --git a/Penumbra.GameData/Enums/ObjectType.cs b/Penumbra.GameData/Enums/ObjectType.cs deleted file mode 100644 index 8b62e42b..00000000 --- a/Penumbra.GameData/Enums/ObjectType.cs +++ /dev/null @@ -1,21 +0,0 @@ -namespace Penumbra.GameData.Enums -{ - public enum ObjectType : byte - { - Unknown, - Vfx, - DemiHuman, - Accessory, - World, - Housing, - Monster, - Icon, - LoadingScreen, - Map, - Interface, - Equipment, - Character, - Weapon, - Font, - } -} \ No newline at end of file diff --git a/Penumbra.GameData/Enums/Race.cs b/Penumbra.GameData/Enums/Race.cs deleted file mode 100644 index 8221a625..00000000 --- a/Penumbra.GameData/Enums/Race.cs +++ /dev/null @@ -1,453 +0,0 @@ -using System; -using System.Collections.Generic; -using System.ComponentModel; - -namespace Penumbra.GameData.Enums -{ - public enum Race : byte - { - Unknown, - Hyur, - Elezen, - Lalafell, - Miqote, - Roegadyn, - AuRa, - Hrothgar, - Viera, - } - - public enum Gender : byte - { - Unknown, - Male, - Female, - MaleNpc, - FemaleNpc, - } - - public enum ModelRace : byte - { - Unknown, - Midlander, - Highlander, - Elezen, - Lalafell, - Miqote, - Roegadyn, - AuRa, - Hrothgar, - Viera, - } - - public enum SubRace : byte - { - Unknown, - Midlander, - Highlander, - Wildwood, - Duskwight, - Plainsfolk, - Dunesfolk, - SeekerOfTheSun, - KeeperOfTheMoon, - Seawolf, - Hellsguard, - Raen, - Xaela, - Helion, - Lost, - Rava, - Veena, - } - - // The combined gender-race-npc numerical code as used by the game. - public enum GenderRace : ushort - { - Unknown = 0, - MidlanderMale = 0101, - MidlanderMaleNpc = 0104, - MidlanderFemale = 0201, - MidlanderFemaleNpc = 0204, - HighlanderMale = 0301, - HighlanderMaleNpc = 0304, - HighlanderFemale = 0401, - HighlanderFemaleNpc = 0404, - ElezenMale = 0501, - ElezenMaleNpc = 0504, - ElezenFemale = 0601, - ElezenFemaleNpc = 0604, - MiqoteMale = 0701, - MiqoteMaleNpc = 0704, - MiqoteFemale = 0801, - MiqoteFemaleNpc = 0804, - RoegadynMale = 0901, - RoegadynMaleNpc = 0904, - RoegadynFemale = 1001, - RoegadynFemaleNpc = 1004, - LalafellMale = 1101, - LalafellMaleNpc = 1104, - LalafellFemale = 1201, - LalafellFemaleNpc = 1204, - AuRaMale = 1301, - AuRaMaleNpc = 1304, - AuRaFemale = 1401, - AuRaFemaleNpc = 1404, - HrothgarMale = 1501, - HrothgarMaleNpc = 1504, - VieraFemale = 1801, - VieraFemaleNpc = 1804, - UnknownMaleNpc = 9104, - UnknownFemaleNpc = 9204, - } - - public static class RaceEnumExtensions - { - public static int ToRspIndex( this SubRace subRace ) - { - return subRace switch - { - SubRace.Midlander => 0, - SubRace.Highlander => 1, - SubRace.Wildwood => 10, - SubRace.Duskwight => 11, - SubRace.Plainsfolk => 20, - SubRace.Dunesfolk => 21, - SubRace.SeekerOfTheSun => 30, - SubRace.KeeperOfTheMoon => 31, - SubRace.Seawolf => 40, - SubRace.Hellsguard => 41, - SubRace.Raen => 50, - SubRace.Xaela => 51, - SubRace.Helion => 60, - SubRace.Lost => 61, - SubRace.Rava => 70, - SubRace.Veena => 71, - _ => throw new ArgumentOutOfRangeException( nameof( subRace ), subRace, null ), - }; - } - - public static Race ToRace( this ModelRace race ) - { - return race switch - { - ModelRace.Unknown => Race.Unknown, - ModelRace.Midlander => Race.Hyur, - ModelRace.Highlander => Race.Hyur, - ModelRace.Elezen => Race.Elezen, - ModelRace.Lalafell => Race.Lalafell, - ModelRace.Miqote => Race.Miqote, - ModelRace.Roegadyn => Race.Roegadyn, - ModelRace.AuRa => Race.AuRa, - ModelRace.Hrothgar => Race.Hrothgar, - ModelRace.Viera => Race.Viera, - _ => throw new ArgumentOutOfRangeException( nameof( race ), race, null ), - }; - } - - public static Race ToRace( this SubRace subRace ) - { - return subRace switch - { - SubRace.Unknown => Race.Unknown, - SubRace.Midlander => Race.Hyur, - SubRace.Highlander => Race.Hyur, - SubRace.Wildwood => Race.Elezen, - SubRace.Duskwight => Race.Elezen, - SubRace.Plainsfolk => Race.Lalafell, - SubRace.Dunesfolk => Race.Lalafell, - SubRace.SeekerOfTheSun => Race.Miqote, - SubRace.KeeperOfTheMoon => Race.Miqote, - SubRace.Seawolf => Race.Roegadyn, - SubRace.Hellsguard => Race.Roegadyn, - SubRace.Raen => Race.AuRa, - SubRace.Xaela => Race.AuRa, - SubRace.Helion => Race.Hrothgar, - SubRace.Lost => Race.Hrothgar, - SubRace.Rava => Race.Viera, - SubRace.Veena => Race.Viera, - _ => throw new ArgumentOutOfRangeException( nameof( subRace ), subRace, null ), - }; - } - - public static string ToName( this ModelRace modelRace ) - { - return modelRace switch - { - ModelRace.Midlander => SubRace.Midlander.ToName(), - ModelRace.Highlander => SubRace.Highlander.ToName(), - ModelRace.Elezen => Race.Elezen.ToName(), - ModelRace.Lalafell => Race.Lalafell.ToName(), - ModelRace.Miqote => Race.Miqote.ToName(), - ModelRace.Roegadyn => Race.Roegadyn.ToName(), - ModelRace.AuRa => Race.AuRa.ToName(), - ModelRace.Hrothgar => Race.Hrothgar.ToName(), - ModelRace.Viera => Race.Viera.ToName(), - _ => throw new ArgumentOutOfRangeException( nameof( modelRace ), modelRace, null ), - }; - } - - public static string ToName( this Race race ) - { - return race switch - { - Race.Hyur => "Hyur", - Race.Elezen => "Elezen", - Race.Lalafell => "Lalafell", - Race.Miqote => "Miqo'te", - Race.Roegadyn => "Roegadyn", - Race.AuRa => "Au Ra", - Race.Hrothgar => "Hrothgar", - Race.Viera => "Viera", - _ => throw new ArgumentOutOfRangeException( nameof( race ), race, null ), - }; - } - - public static string ToName( this Gender gender ) - { - return gender switch - { - Gender.Male => "Male", - Gender.Female => "Female", - Gender.MaleNpc => "Male (NPC)", - Gender.FemaleNpc => "Female (NPC)", - _ => throw new InvalidEnumArgumentException(), - }; - } - - public static string ToName( this SubRace subRace ) - { - return subRace switch - { - SubRace.Midlander => "Midlander", - SubRace.Highlander => "Highlander", - SubRace.Wildwood => "Wildwood", - SubRace.Duskwight => "Duskwright", - SubRace.Plainsfolk => "Plainsfolk", - SubRace.Dunesfolk => "Dunesfolk", - SubRace.SeekerOfTheSun => "Seeker Of The Sun", - SubRace.KeeperOfTheMoon => "Keeper Of The Moon", - SubRace.Seawolf => "Seawolf", - SubRace.Hellsguard => "Hellsguard", - SubRace.Raen => "Raen", - SubRace.Xaela => "Xaela", - SubRace.Helion => "Hellion", - SubRace.Lost => "Lost", - SubRace.Rava => "Rava", - SubRace.Veena => "Veena", - _ => throw new InvalidEnumArgumentException(), - }; - } - - public static bool FitsRace( this SubRace subRace, Race race ) - => subRace.ToRace() == race; - - public static byte ToByte( this Gender gender, ModelRace modelRace ) - => ( byte )( ( int )gender | ( ( int )modelRace << 3 ) ); - - public static byte ToByte( this ModelRace modelRace, Gender gender ) - => gender.ToByte( modelRace ); - - public static byte ToByte( this GenderRace value ) - { - var (gender, race) = value.Split(); - return gender.ToByte( race ); - } - - public static (Gender, ModelRace) Split( this GenderRace value ) - { - return value switch - { - GenderRace.Unknown => ( Gender.Unknown, ModelRace.Unknown ), - GenderRace.MidlanderMale => ( Gender.Male, ModelRace.Midlander ), - GenderRace.MidlanderMaleNpc => ( Gender.MaleNpc, ModelRace.Midlander ), - GenderRace.MidlanderFemale => ( Gender.Female, ModelRace.Midlander ), - GenderRace.MidlanderFemaleNpc => ( Gender.FemaleNpc, ModelRace.Midlander ), - GenderRace.HighlanderMale => ( Gender.Male, ModelRace.Highlander ), - GenderRace.HighlanderMaleNpc => ( Gender.MaleNpc, ModelRace.Highlander ), - GenderRace.HighlanderFemale => ( Gender.Female, ModelRace.Highlander ), - GenderRace.HighlanderFemaleNpc => ( Gender.FemaleNpc, ModelRace.Highlander ), - GenderRace.ElezenMale => ( Gender.Male, ModelRace.Elezen ), - GenderRace.ElezenMaleNpc => ( Gender.MaleNpc, ModelRace.Elezen ), - GenderRace.ElezenFemale => ( Gender.Female, ModelRace.Elezen ), - GenderRace.ElezenFemaleNpc => ( Gender.FemaleNpc, ModelRace.Elezen ), - GenderRace.LalafellMale => ( Gender.Male, ModelRace.Lalafell ), - GenderRace.LalafellMaleNpc => ( Gender.MaleNpc, ModelRace.Lalafell ), - GenderRace.LalafellFemale => ( Gender.Female, ModelRace.Lalafell ), - GenderRace.LalafellFemaleNpc => ( Gender.FemaleNpc, ModelRace.Lalafell ), - GenderRace.MiqoteMale => ( Gender.Male, ModelRace.Miqote ), - GenderRace.MiqoteMaleNpc => ( Gender.MaleNpc, ModelRace.Miqote ), - GenderRace.MiqoteFemale => ( Gender.Female, ModelRace.Miqote ), - GenderRace.MiqoteFemaleNpc => ( Gender.FemaleNpc, ModelRace.Miqote ), - GenderRace.RoegadynMale => ( Gender.Male, ModelRace.Roegadyn ), - GenderRace.RoegadynMaleNpc => ( Gender.MaleNpc, ModelRace.Roegadyn ), - GenderRace.RoegadynFemale => ( Gender.Female, ModelRace.Roegadyn ), - GenderRace.RoegadynFemaleNpc => ( Gender.FemaleNpc, ModelRace.Roegadyn ), - GenderRace.AuRaMale => ( Gender.Male, ModelRace.AuRa ), - GenderRace.AuRaMaleNpc => ( Gender.MaleNpc, ModelRace.AuRa ), - GenderRace.AuRaFemale => ( Gender.Female, ModelRace.AuRa ), - GenderRace.AuRaFemaleNpc => ( Gender.FemaleNpc, ModelRace.AuRa ), - GenderRace.HrothgarMale => ( Gender.Male, ModelRace.Hrothgar ), - GenderRace.HrothgarMaleNpc => ( Gender.MaleNpc, ModelRace.Hrothgar ), - GenderRace.VieraFemale => ( Gender.Female, ModelRace.Viera ), - GenderRace.VieraFemaleNpc => ( Gender.FemaleNpc, ModelRace.Viera ), - GenderRace.UnknownMaleNpc => ( Gender.MaleNpc, ModelRace.Unknown ), - GenderRace.UnknownFemaleNpc => ( Gender.FemaleNpc, ModelRace.Unknown ), - _ => throw new InvalidEnumArgumentException(), - }; - } - - public static bool IsValid( this GenderRace value ) - => value != GenderRace.Unknown && Enum.IsDefined( typeof( GenderRace ), value ); - - public static string ToRaceCode( this GenderRace value ) - { - return value switch - { - GenderRace.MidlanderMale => "0101", - GenderRace.MidlanderMaleNpc => "0104", - GenderRace.MidlanderFemale => "0201", - GenderRace.MidlanderFemaleNpc => "0204", - GenderRace.HighlanderMale => "0301", - GenderRace.HighlanderMaleNpc => "0304", - GenderRace.HighlanderFemale => "0401", - GenderRace.HighlanderFemaleNpc => "0404", - GenderRace.ElezenMale => "0501", - GenderRace.ElezenMaleNpc => "0504", - GenderRace.ElezenFemale => "0601", - GenderRace.ElezenFemaleNpc => "0604", - GenderRace.MiqoteMale => "0701", - GenderRace.MiqoteMaleNpc => "0704", - GenderRace.MiqoteFemale => "0801", - GenderRace.MiqoteFemaleNpc => "0804", - GenderRace.RoegadynMale => "0901", - GenderRace.RoegadynMaleNpc => "0904", - GenderRace.RoegadynFemale => "1001", - GenderRace.RoegadynFemaleNpc => "1004", - GenderRace.LalafellMale => "1101", - GenderRace.LalafellMaleNpc => "1104", - GenderRace.LalafellFemale => "1201", - GenderRace.LalafellFemaleNpc => "1204", - GenderRace.AuRaMale => "1301", - GenderRace.AuRaMaleNpc => "1304", - GenderRace.AuRaFemale => "1401", - GenderRace.AuRaFemaleNpc => "1404", - GenderRace.HrothgarMale => "1501", - GenderRace.HrothgarMaleNpc => "1504", - GenderRace.VieraFemale => "1801", - GenderRace.VieraFemaleNpc => "1804", - GenderRace.UnknownMaleNpc => "9104", - GenderRace.UnknownFemaleNpc => "9204", - _ => throw new InvalidEnumArgumentException(), - }; - } - } - - public static partial class Names - { - public static GenderRace GenderRaceFromCode( string code ) - { - return code switch - { - "0101" => GenderRace.MidlanderMale, - "0104" => GenderRace.MidlanderMaleNpc, - "0201" => GenderRace.MidlanderFemale, - "0204" => GenderRace.MidlanderFemaleNpc, - "0301" => GenderRace.HighlanderMale, - "0304" => GenderRace.HighlanderMaleNpc, - "0401" => GenderRace.HighlanderFemale, - "0404" => GenderRace.HighlanderFemaleNpc, - "0501" => GenderRace.ElezenMale, - "0504" => GenderRace.ElezenMaleNpc, - "0601" => GenderRace.ElezenFemale, - "0604" => GenderRace.ElezenFemaleNpc, - "0701" => GenderRace.MiqoteMale, - "0704" => GenderRace.MiqoteMaleNpc, - "0801" => GenderRace.MiqoteFemale, - "0804" => GenderRace.MiqoteFemaleNpc, - "0901" => GenderRace.RoegadynMale, - "0904" => GenderRace.RoegadynMaleNpc, - "1001" => GenderRace.RoegadynFemale, - "1004" => GenderRace.RoegadynFemaleNpc, - "1101" => GenderRace.LalafellMale, - "1104" => GenderRace.LalafellMaleNpc, - "1201" => GenderRace.LalafellFemale, - "1204" => GenderRace.LalafellFemaleNpc, - "1301" => GenderRace.AuRaMale, - "1304" => GenderRace.AuRaMaleNpc, - "1401" => GenderRace.AuRaFemale, - "1404" => GenderRace.AuRaFemaleNpc, - "1501" => GenderRace.HrothgarMale, - "1504" => GenderRace.HrothgarMaleNpc, - "1801" => GenderRace.VieraFemale, - "1804" => GenderRace.VieraFemaleNpc, - "9104" => GenderRace.UnknownMaleNpc, - "9204" => GenderRace.UnknownFemaleNpc, - _ => throw new KeyNotFoundException(), - }; - } - - public static GenderRace GenderRaceFromByte( byte value ) - { - var gender = ( Gender )( value & 0b111 ); - var race = ( ModelRace )( value >> 3 ); - return CombinedRace( gender, race ); - } - - public static GenderRace CombinedRace( Gender gender, ModelRace modelRace ) - { - return gender switch - { - Gender.Male => modelRace switch - { - ModelRace.Midlander => GenderRace.MidlanderMale, - ModelRace.Highlander => GenderRace.HighlanderMale, - ModelRace.Elezen => GenderRace.ElezenMale, - ModelRace.Lalafell => GenderRace.LalafellMale, - ModelRace.Miqote => GenderRace.MiqoteMale, - ModelRace.Roegadyn => GenderRace.RoegadynMale, - ModelRace.AuRa => GenderRace.AuRaMale, - ModelRace.Hrothgar => GenderRace.HrothgarMale, - _ => GenderRace.Unknown, - }, - Gender.MaleNpc => modelRace switch - { - ModelRace.Midlander => GenderRace.MidlanderMaleNpc, - ModelRace.Highlander => GenderRace.HighlanderMaleNpc, - ModelRace.Elezen => GenderRace.ElezenMaleNpc, - ModelRace.Lalafell => GenderRace.LalafellMaleNpc, - ModelRace.Miqote => GenderRace.MiqoteMaleNpc, - ModelRace.Roegadyn => GenderRace.RoegadynMaleNpc, - ModelRace.AuRa => GenderRace.AuRaMaleNpc, - ModelRace.Hrothgar => GenderRace.HrothgarMaleNpc, - _ => GenderRace.Unknown, - }, - Gender.Female => modelRace switch - { - ModelRace.Midlander => GenderRace.MidlanderFemale, - ModelRace.Highlander => GenderRace.HighlanderFemale, - ModelRace.Elezen => GenderRace.ElezenFemale, - ModelRace.Lalafell => GenderRace.LalafellFemale, - ModelRace.Miqote => GenderRace.MiqoteFemale, - ModelRace.Roegadyn => GenderRace.RoegadynFemale, - ModelRace.AuRa => GenderRace.AuRaFemale, - ModelRace.Viera => GenderRace.VieraFemale, - _ => GenderRace.Unknown, - }, - Gender.FemaleNpc => modelRace switch - { - ModelRace.Midlander => GenderRace.MidlanderFemaleNpc, - ModelRace.Highlander => GenderRace.HighlanderFemaleNpc, - ModelRace.Elezen => GenderRace.ElezenFemaleNpc, - ModelRace.Lalafell => GenderRace.LalafellFemaleNpc, - ModelRace.Miqote => GenderRace.MiqoteFemaleNpc, - ModelRace.Roegadyn => GenderRace.RoegadynFemaleNpc, - ModelRace.AuRa => GenderRace.AuRaFemaleNpc, - ModelRace.Viera => GenderRace.VieraFemaleNpc, - _ => GenderRace.Unknown, - }, - _ => GenderRace.Unknown, - }; - } - } -} \ No newline at end of file diff --git a/Penumbra.GameData/Enums/RedrawType.cs b/Penumbra.GameData/Enums/RedrawType.cs deleted file mode 100644 index c3668504..00000000 --- a/Penumbra.GameData/Enums/RedrawType.cs +++ /dev/null @@ -1,14 +0,0 @@ -namespace Penumbra.GameData.Enums -{ - public enum RedrawType - { - WithoutSettings, - WithSettings, - OnlyWithSettings, - Unload, - RedrawWithoutSettings, - RedrawWithSettings, - AfterGPoseWithSettings, - AfterGPoseWithoutSettings, - } -} \ No newline at end of file diff --git a/Penumbra.GameData/Enums/RspAttribute.cs b/Penumbra.GameData/Enums/RspAttribute.cs deleted file mode 100644 index 07d03b67..00000000 --- a/Penumbra.GameData/Enums/RspAttribute.cs +++ /dev/null @@ -1,92 +0,0 @@ -using System.ComponentModel; - -namespace Penumbra.GameData.Enums -{ - public enum RspAttribute : byte - { - MaleMinSize, - MaleMaxSize, - MaleMinTail, - MaleMaxTail, - FemaleMinSize, - FemaleMaxSize, - FemaleMinTail, - FemaleMaxTail, - BustMinX, - BustMinY, - BustMinZ, - BustMaxX, - BustMaxY, - BustMaxZ, - NumAttributes, - } - - public static class RspAttributeExtensions - { - public static Gender ToGender( this RspAttribute attribute ) - { - return attribute switch - { - RspAttribute.MaleMinSize => Gender.Male, - RspAttribute.MaleMaxSize => Gender.Male, - RspAttribute.MaleMinTail => Gender.Male, - RspAttribute.MaleMaxTail => Gender.Male, - RspAttribute.FemaleMinSize => Gender.Female, - RspAttribute.FemaleMaxSize => Gender.Female, - RspAttribute.FemaleMinTail => Gender.Female, - RspAttribute.FemaleMaxTail => Gender.Female, - RspAttribute.BustMinX => Gender.Female, - RspAttribute.BustMinY => Gender.Female, - RspAttribute.BustMinZ => Gender.Female, - RspAttribute.BustMaxX => Gender.Female, - RspAttribute.BustMaxY => Gender.Female, - RspAttribute.BustMaxZ => Gender.Female, - _ => Gender.Unknown, - }; - } - - public static string ToUngenderedString( this RspAttribute attribute ) - { - return attribute switch - { - RspAttribute.MaleMinSize => "MinSize", - RspAttribute.MaleMaxSize => "MaxSize", - RspAttribute.MaleMinTail => "MinTail", - RspAttribute.MaleMaxTail => "MaxTail", - RspAttribute.FemaleMinSize => "MinSize", - RspAttribute.FemaleMaxSize => "MaxSize", - RspAttribute.FemaleMinTail => "MinTail", - RspAttribute.FemaleMaxTail => "MaxTail", - RspAttribute.BustMinX => "BustMinX", - RspAttribute.BustMinY => "BustMinY", - RspAttribute.BustMinZ => "BustMinZ", - RspAttribute.BustMaxX => "BustMaxX", - RspAttribute.BustMaxY => "BustMaxY", - RspAttribute.BustMaxZ => "BustMaxZ", - _ => "", - }; - } - - public static string ToFullString( this RspAttribute attribute ) - { - return attribute switch - { - RspAttribute.MaleMinSize => "Male Minimum Size", - RspAttribute.MaleMaxSize => "Male Maximum Size", - RspAttribute.FemaleMinSize => "Female Minimum Size", - RspAttribute.FemaleMaxSize => "Female Maximum Size", - RspAttribute.BustMinX => "Bust Minimum X-Axis", - RspAttribute.BustMaxX => "Bust Maximum X-Axis", - RspAttribute.BustMinY => "Bust Minimum Y-Axis", - RspAttribute.BustMaxY => "Bust Maximum Y-Axis", - RspAttribute.BustMinZ => "Bust Minimum Z-Axis", - RspAttribute.BustMaxZ => "Bust Maximum Z-Axis", - RspAttribute.MaleMinTail => "Male Minimum Tail Length", - RspAttribute.MaleMaxTail => "Male Maximum Tail Length", - RspAttribute.FemaleMinTail => "Female Minimum Tail Length", - RspAttribute.FemaleMaxTail => "Female Maximum Tail Length", - _ => throw new InvalidEnumArgumentException(), - }; - } - } -} \ No newline at end of file diff --git a/Penumbra.GameData/GameData.cs b/Penumbra.GameData/GameData.cs deleted file mode 100644 index cb294ca4..00000000 --- a/Penumbra.GameData/GameData.cs +++ /dev/null @@ -1,51 +0,0 @@ -using System; -using System.Collections.Generic; -using Dalamud; -using Dalamud.Data; -using Lumina.Excel.GeneratedSheets; -using Penumbra.GameData.Enums; -using Penumbra.GameData.Structs; -using Penumbra.GameData.Util; - -namespace Penumbra.GameData -{ - public static class GameData - { - internal static ObjectIdentification? Identification; - internal static readonly GamePathParser GamePathParser = new(); - - public static IObjectIdentifier GetIdentifier( DataManager dataManager, ClientLanguage clientLanguage ) - { - Identification ??= new ObjectIdentification( dataManager, clientLanguage ); - return Identification; - } - - public static IObjectIdentifier GetIdentifier() - { - if( Identification == null ) - { - throw new Exception( "Object Identification was not initialized." ); - } - - return Identification; - } - - public static IGamePathParser GetGamePathParser() - => GamePathParser; - } - - public interface IObjectIdentifier - { - public void Identify( IDictionary< string, object? > set, GamePath path ); - - public Dictionary< string, object? > Identify( GamePath path ); - public Item? Identify( SetId setId, WeaponType weaponType, ushort variant, EquipSlot slot ); - } - - public interface IGamePathParser - { - public ObjectType PathToObjectType( GamePath path ); - public GameObjectInfo GetFileInfo( GamePath path ); - public string VfxToKey( GamePath path ); - } -} \ No newline at end of file diff --git a/Penumbra.GameData/GamePathParser.cs b/Penumbra.GameData/GamePathParser.cs deleted file mode 100644 index 8afa3d1b..00000000 --- a/Penumbra.GameData/GamePathParser.cs +++ /dev/null @@ -1,332 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Text; -using System.Text.RegularExpressions; -using Dalamud.Logging; -using Penumbra.GameData.Enums; -using Penumbra.GameData.Structs; -using Penumbra.GameData.Util; - -namespace Penumbra.GameData -{ - internal class GamePathParser : IGamePathParser - { - private const string CharacterFolder = "chara"; - private const string EquipmentFolder = "equipment"; - private const string PlayerFolder = "human"; - private const string WeaponFolder = "weapon"; - private const string AccessoryFolder = "accessory"; - private const string DemiHumanFolder = "demihuman"; - private const string MonsterFolder = "monster"; - private const string CommonFolder = "common"; - private const string UiFolder = "ui"; - private const string IconFolder = "icon"; - private const string LoadingFolder = "loadingimage"; - private const string MapFolder = "map"; - private const string InterfaceFolder = "uld"; - private const string FontFolder = "font"; - private const string HousingFolder = "hou"; - private const string VfxFolder = "vfx"; - private const string WorldFolder1 = "bgcommon"; - private const string WorldFolder2 = "bg"; - - // @formatter:off - private readonly Dictionary> _regexes = new() - { { FileType.Font, new Dictionary< ObjectType, Regex[] >(){ { ObjectType.Font, new Regex[]{ new(@"common/font/(?'fontname'.*)_(?'id'\d\d)(_lobby)?\.fdt") } } } } - , { FileType.Texture, new Dictionary< ObjectType, Regex[] >() - { { ObjectType.Icon, new Regex[]{ new(@"ui/icon/(?'group'\d*)(/(?'lang'[a-z]{2}))?(/(?'hq'hq))?/(?'id'\d*)\.tex") } } - , { ObjectType.Map, new Regex[]{ new(@"ui/map/(?'id'[a-z0-9]{4})/(?'variant'\d{2})/\k'id'\k'variant'(?'suffix'[a-z])?(_[a-z])?\.tex") } } - , { ObjectType.Weapon, new Regex[]{ new(@"chara/weapon/w(?'id'\d{4})/obj/body/b(?'weapon'\d{4})/texture/v(?'variant'\d{2})_w\k'id'b\k'weapon'(_[a-z])?_[a-z]\.tex") } } - , { ObjectType.Monster, new Regex[]{ new(@"chara/monster/m(?'monster'\d{4})/obj/body/b(?'id'\d{4})/texture/v(?'variant'\d{2})_m\k'monster'b\k'id'(_[a-z])?_[a-z]\.tex") } } - , { ObjectType.Equipment, new Regex[]{ new(@"chara/equipment/e(?'id'\d{4})/texture/v(?'variant'\d{2})_c(?'race'\d{4})e\k'id'_(?'slot'[a-z]{3})(_[a-z])?_[a-z]\.tex") } } - , { ObjectType.DemiHuman, new Regex[]{ new(@"chara/demihuman/d(?'id'\d{4})/obj/equipment/e(?'equip'\d{4})/texture/v(?'variant'\d{2})_d\k'id'e\k'equip'_(?'slot'[a-z]{3})(_[a-z])?_[a-z]\.tex") } } - , { ObjectType.Accessory, new Regex[]{ new(@"chara/accessory/a(?'id'\d{4})/texture/v(?'variant'\d{2})_c(?'race'\d{4})a\k'id'_(?'slot'[a-z]{3})_[a-z]\.tex") } } - , { ObjectType.Character, new Regex[]{ new(@"chara/human/c(?'race'\d{4})/obj/(?'type'[a-z]+)/(?'typeabr'[a-z])(?'id'\d{4})/texture/(?'minus'(--)?)(v(?'variant'\d{2})_)?c\k'race'\k'typeabr'\k'id'(_(?'slot'[a-z]{3}))?(_[a-z])?_[a-z]\.tex") - , new(@"chara/human/c(?'race'\d{4})/obj/(?'type'[a-z]+)/(?'typeabr'[a-z])(?'id'\d{4})/texture") - , new(@"chara/common/texture/skin(?'skin'.*)\.tex") - , new(@"chara/common/texture/decal_(?'location'[a-z]+)/[-_]?decal_(?'id'\d+).tex") } } } } - , { FileType.Model, new Dictionary< ObjectType, Regex[] >() - { { ObjectType.Weapon, new Regex[]{ new(@"chara/weapon/w(?'id'\d{4})/obj/body/b(?'weapon'\d{4})/model/w\k'id'b\k'weapon'\.mdl") } } - , { ObjectType.Monster, new Regex[]{ new(@"chara/monster/m(?'monster'\d{4})/obj/body/b(?'id'\d{4})/model/m\k'monster'b\k'id'\.mdl") } } - , { ObjectType.Equipment, new Regex[]{ new(@"chara/equipment/e(?'id'\d{4})/model/c(?'race'\d{4})e\k'id'_(?'slot'[a-z]{3})\.mdl") } } - , { ObjectType.DemiHuman, new Regex[]{ new(@"chara/demihuman/d(?'id'\d{4})/obj/equipment/e(?'equip'\d{4})/model/d\k'id'e\k'equip'_(?'slot'[a-z]{3})\.mdl") } } - , { ObjectType.Accessory, new Regex[]{ new(@"chara/accessory/a(?'id'\d{4})/model/c(?'race'\d{4})a\k'id'_(?'slot'[a-z]{3})\.mdl") } } - , { ObjectType.Character, new Regex[]{ new(@"chara/human/c(?'race'\d{4})/obj/(?'type'[a-z]+)/(?'typeabr'[a-z])(?'id'\d{4})/model/c\k'race'\k'typeabr'\k'id'_(?'slot'[a-z]{3})\.mdl") } } } } - , { FileType.Material, new Dictionary< ObjectType, Regex[] >() - { { ObjectType.Weapon, new Regex[]{ new(@"chara/weapon/w(?'id'\d{4})/obj/body/b(?'weapon'\d{4})/material/v(?'variant'\d{4})/mt_w\k'id'b\k'weapon'_[a-z]\.mtrl") } } - , { ObjectType.Monster, new Regex[]{ new(@"chara/monster/m(?'monster'\d{4})/obj/body/b(?'id'\d{4})/material/v(?'variant'\d{4})/mt_m\k'monster'b\k'id'_[a-z]\.mtrl") } } - , { ObjectType.Equipment, new Regex[]{ new(@"chara/equipment/e(?'id'\d{4})/material/v(?'variant'\d{4})/mt_c(?'race'\d{4})e\k'id'_(?'slot'[a-z]{3})_[a-z]\.mtrl") } } - , { ObjectType.DemiHuman, new Regex[]{ new(@"chara/demihuman/d(?'id'\d{4})/obj/equipment/e(?'equip'\d{4})/material/v(?'variant'\d{4})/mt_d\k'id'e\k'equip'_(?'slot'[a-z]{3})_[a-z]\.mtrl") } } - , { ObjectType.Accessory, new Regex[]{ new(@"chara/accessory/a(?'id'\d{4})/material/v(?'variant'\d{4})/mt_c(?'race'\d{4})a\k'id'_(?'slot'[a-z]{3})_[a-z]\.mtrl") } } - , { ObjectType.Character, new Regex[]{ new(@"chara/human/c(?'race'\d{4})/obj/(?'type'[a-z]+)/(?'typeabr'[a-z])(?'id'\d{4})/material/v(?'variant'\d{4})/mt_c\k'race'\k'typeabr'\k'id'(_(?'slot'[a-z]{3}))?_[a-z]\.mtrl") } } } } - , { FileType.Imc, new Dictionary< ObjectType, Regex[] >() - { { ObjectType.Weapon, new Regex[]{ new(@"chara/weapon/w(?'id'\d{4})/obj/body/b(?'weapon'\d{4})/b\k'weapon'\.imc") } } - , { ObjectType.Monster, new Regex[]{ new(@"chara/monster/m(?'monster'\d{4})/obj/body/b(?'id'\d{4})/b\k'id'\.imc") } } - , { ObjectType.Equipment, new Regex[]{ new(@"chara/equipment/e(?'id'\d{4})/e\k'id'\.imc") } } - , { ObjectType.DemiHuman, new Regex[]{ new(@"chara/demihuman/d(?'id'\d{4})/obj/equipment/e(?'equip'\d{4})/e\k'equip'\.imc") } } - , { ObjectType.Accessory, new Regex[]{ new(@"chara/accessory/a(?'id'\d{4})/a\k'id'\.imc") } } } }, - }; - // @formatter:on - - public ObjectType PathToObjectType( GamePath path ) - { - if( path.Empty ) - { - return ObjectType.Unknown; - } - - string p = path; - var folders = p.Split( '/' ); - if( folders.Length < 2 ) - { - return ObjectType.Unknown; - } - - return folders[ 0 ] switch - { - CharacterFolder => folders[ 1 ] switch - { - EquipmentFolder => ObjectType.Equipment, - AccessoryFolder => ObjectType.Accessory, - WeaponFolder => ObjectType.Weapon, - PlayerFolder => ObjectType.Character, - DemiHumanFolder => ObjectType.DemiHuman, - MonsterFolder => ObjectType.Monster, - CommonFolder => ObjectType.Character, - _ => ObjectType.Unknown, - }, - UiFolder => folders[ 1 ] switch - { - IconFolder => ObjectType.Icon, - LoadingFolder => ObjectType.LoadingScreen, - MapFolder => ObjectType.Map, - InterfaceFolder => ObjectType.Interface, - _ => ObjectType.Unknown, - }, - CommonFolder => folders[ 1 ] switch - { - FontFolder => ObjectType.Font, - _ => ObjectType.Unknown, - }, - HousingFolder => ObjectType.Housing, - WorldFolder1 => folders[ 1 ] switch - { - HousingFolder => ObjectType.Housing, - _ => ObjectType.World, - }, - WorldFolder2 => ObjectType.World, - VfxFolder => ObjectType.Vfx, - _ => ObjectType.Unknown, - }; - } - - private (FileType, ObjectType, Match?) ParseGamePath( GamePath path ) - { - if( !Names.ExtensionToFileType.TryGetValue( Extension( path ), out var fileType ) ) - { - fileType = FileType.Unknown; - } - - var objectType = PathToObjectType( path ); - - if( !_regexes.TryGetValue( fileType, out var objectDict ) ) - { - return ( fileType, objectType, null ); - } - - if( !objectDict.TryGetValue( objectType, out var regexes ) ) - { - return ( fileType, objectType, null ); - } - - foreach( var regex in regexes ) - { - var match = regex.Match( path ); - if( match.Success ) - { - return ( fileType, objectType, match ); - } - } - - return ( fileType, objectType, null ); - } - - private static string Extension( string filename ) - { - var extIdx = filename.LastIndexOf( '.' ); - return extIdx < 0 ? "" : filename.Substring( extIdx ); - } - - private static GameObjectInfo HandleEquipment( FileType fileType, GroupCollection groups ) - { - var setId = ushort.Parse( groups[ "id" ].Value ); - if( fileType == FileType.Imc ) - { - return GameObjectInfo.Equipment( fileType, setId ); - } - - var gr = Names.GenderRaceFromCode( groups[ "race" ].Value ); - var slot = Names.SuffixToEquipSlot[ groups[ "slot" ].Value ]; - if( fileType == FileType.Model ) - { - return GameObjectInfo.Equipment( fileType, setId, gr, slot ); - } - - var variant = byte.Parse( groups[ "variant" ].Value ); - return GameObjectInfo.Equipment( fileType, setId, gr, slot, variant ); - } - - private static GameObjectInfo HandleWeapon( FileType fileType, GroupCollection groups ) - { - var weaponId = ushort.Parse( groups[ "weapon" ].Value ); - var setId = ushort.Parse( groups[ "id" ].Value ); - if( fileType == FileType.Imc || fileType == FileType.Model ) - { - return GameObjectInfo.Weapon( fileType, setId, weaponId ); - } - - var variant = byte.Parse( groups[ "variant" ].Value ); - return GameObjectInfo.Weapon( fileType, setId, weaponId, variant ); - } - - private static GameObjectInfo HandleMonster( FileType fileType, GroupCollection groups ) - { - var monsterId = ushort.Parse( groups[ "monster" ].Value ); - var bodyId = ushort.Parse( groups[ "id" ].Value ); - if( fileType == FileType.Imc || fileType == FileType.Model ) - { - return GameObjectInfo.Monster( fileType, monsterId, bodyId ); - } - - var variant = byte.Parse( groups[ "variant" ].Value ); - return GameObjectInfo.Monster( fileType, monsterId, bodyId, variant ); - } - - private static GameObjectInfo HandleDemiHuman( FileType fileType, GroupCollection groups ) - { - var demiHumanId = ushort.Parse( groups[ "id" ].Value ); - var equipId = ushort.Parse( groups[ "equip" ].Value ); - if( fileType == FileType.Imc ) - { - return GameObjectInfo.DemiHuman( fileType, demiHumanId, equipId ); - } - - var slot = Names.SuffixToEquipSlot[ groups[ "slot" ].Value ]; - if( fileType == FileType.Model ) - { - return GameObjectInfo.DemiHuman( fileType, demiHumanId, equipId, slot ); - } - - var variant = byte.Parse( groups[ "variant" ].Value ); - return GameObjectInfo.DemiHuman( fileType, demiHumanId, equipId, slot, variant ); - } - - private static GameObjectInfo HandleCustomization( FileType fileType, GroupCollection groups ) - { - if( groups[ "skin" ].Success ) - { - return GameObjectInfo.Customization( fileType, CustomizationType.Skin ); - } - - var id = ushort.Parse( groups[ "id" ].Value ); - if( groups[ "location" ].Success ) - { - var tmpType = groups[ "location" ].Value == "face" ? CustomizationType.DecalFace - : groups[ "location" ].Value == "equip" ? CustomizationType.DecalEquip : CustomizationType.Unknown; - return GameObjectInfo.Customization( fileType, tmpType, id ); - } - - var gr = Names.GenderRaceFromCode( groups[ "race" ].Value ); - var bodySlot = Names.StringToBodySlot[ groups[ "type" ].Value ]; - var type = groups[ "slot" ].Success - ? Names.SuffixToCustomizationType[ groups[ "slot" ].Value ] - : CustomizationType.Skin; - if( fileType == FileType.Material ) - { - var variant = byte.Parse( groups[ "variant" ].Value ); - return GameObjectInfo.Customization( fileType, type, id, gr, bodySlot, variant ); - } - - return GameObjectInfo.Customization( fileType, type, id, gr, bodySlot ); - } - - private static GameObjectInfo HandleIcon( FileType fileType, GroupCollection groups ) - { - var hq = groups[ "hq" ].Success; - var id = uint.Parse( groups[ "id" ].Value ); - if( !groups[ "lang" ].Success ) - { - return GameObjectInfo.Icon( fileType, id, hq ); - } - - var language = groups[ "lang" ].Value switch - { - "en" => Dalamud.ClientLanguage.English, - "ja" => Dalamud.ClientLanguage.Japanese, - "de" => Dalamud.ClientLanguage.German, - "fr" => Dalamud.ClientLanguage.French, - _ => Dalamud.ClientLanguage.English, - }; - return GameObjectInfo.Icon( fileType, id, hq, language ); - } - - private static GameObjectInfo HandleMap( FileType fileType, GroupCollection groups ) - { - var map = Encoding.ASCII.GetBytes( groups[ "id" ].Value ); - var variant = byte.Parse( groups[ "variant" ].Value ); - if( groups[ "suffix" ].Success ) - { - var suffix = Encoding.ASCII.GetBytes( groups[ "suffix" ].Value )[ 0 ]; - return GameObjectInfo.Map( fileType, map[ 0 ], map[ 1 ], map[ 2 ], map[ 3 ], variant, suffix ); - } - - return GameObjectInfo.Map( fileType, map[ 0 ], map[ 1 ], map[ 2 ], map[ 3 ], variant ); - } - - public GameObjectInfo GetFileInfo( GamePath path ) - { - var (fileType, objectType, match) = ParseGamePath( path ); - if( match == null || !match.Success ) - { - return new GameObjectInfo { FileType = fileType, ObjectType = objectType }; - } - - try - { - var groups = match.Groups; - switch( objectType ) - { - case ObjectType.Accessory: return HandleEquipment( fileType, groups ); - case ObjectType.Equipment: return HandleEquipment( fileType, groups ); - case ObjectType.Weapon: return HandleWeapon( fileType, groups ); - case ObjectType.Map: return HandleMap( fileType, groups ); - case ObjectType.Monster: return HandleMonster( fileType, groups ); - case ObjectType.DemiHuman: return HandleDemiHuman( fileType, groups ); - case ObjectType.Character: return HandleCustomization( fileType, groups ); - case ObjectType.Icon: return HandleIcon( fileType, groups ); - } - } - catch( Exception e ) - { - PluginLog.Error( $"Could not parse {path}:\n{e}" ); - } - - return new GameObjectInfo { FileType = fileType, ObjectType = objectType }; - } - - private readonly Regex _vfxRegexTmb = new( @"chara/action/(?'key'[^\s]+?)\.tmb" ); - private readonly Regex _vfxRegexPap = new( @"chara/human/c0101/animation/a0001/[^\s]+?/(?'key'[^\s]+?)\.pap" ); - - public string VfxToKey( GamePath path ) - { - var match = _vfxRegexTmb.Match( path ); - if( match.Success ) - { - return match.Groups[ "key" ].Value.ToLowerInvariant(); - } - - match = _vfxRegexPap.Match( path ); - return match.Success ? match.Groups[ "key" ].Value.ToLowerInvariant() : string.Empty; - } - } -} \ No newline at end of file diff --git a/Penumbra.GameData/ObjectIdentification.cs b/Penumbra.GameData/ObjectIdentification.cs deleted file mode 100644 index 93e69b1d..00000000 --- a/Penumbra.GameData/ObjectIdentification.cs +++ /dev/null @@ -1,325 +0,0 @@ -using System.Collections.Generic; -using System.ComponentModel; -using System.Linq; -using Dalamud; -using Dalamud.Data; -using Lumina.Excel.GeneratedSheets; -using Penumbra.GameData.Enums; -using Penumbra.GameData.Structs; -using Penumbra.GameData.Util; -using Action = Lumina.Excel.GeneratedSheets.Action; - -namespace Penumbra.GameData -{ - internal class ObjectIdentification : IObjectIdentifier - { - public static DataManager? DataManager = null!; - private readonly List< (ulong, HashSet< Item >) > _weapons; - private readonly List< (ulong, HashSet< Item >) > _equipment; - private readonly Dictionary< string, HashSet< Action > > _actions; - - private static bool Add( IDictionary< ulong, HashSet< Item > > dict, ulong key, Item item ) - { - if( dict.TryGetValue( key, out var list ) ) - { - return list.Add( item ); - } - - dict[ key ] = new HashSet< Item > { item }; - return true; - } - - private static ulong EquipmentKey( Item i ) - { - var model = ( ulong )( ( Lumina.Data.Parsing.Quad )i.ModelMain ).A; - var variant = ( ulong )( ( Lumina.Data.Parsing.Quad )i.ModelMain ).B; - var slot = ( ulong )( ( EquipSlot )i.EquipSlotCategory.Row ).ToSlot(); - return ( model << 32 ) | ( slot << 16 ) | variant; - } - - private static ulong WeaponKey( Item i, bool offhand ) - { - var quad = offhand ? ( Lumina.Data.Parsing.Quad )i.ModelSub : ( Lumina.Data.Parsing.Quad )i.ModelMain; - var model = ( ulong )quad.A; - var type = ( ulong )quad.B; - var variant = ( ulong )quad.C; - - return ( model << 32 ) | ( type << 16 ) | variant; - } - - private void AddAction( string key, Action action ) - { - if( key.Length == 0 ) - { - return; - } - - key = key.ToLowerInvariant(); - if( _actions.TryGetValue( key, out var actions ) ) - { - actions.Add( action ); - } - else - { - _actions[ key ] = new HashSet< Action > { action }; - } - } - - public ObjectIdentification( DataManager dataManager, ClientLanguage clientLanguage ) - { - DataManager = dataManager; - var items = dataManager.GetExcelSheet< Item >( clientLanguage )!; - SortedList< ulong, HashSet< Item > > weapons = new(); - SortedList< ulong, HashSet< Item > > equipment = new(); - foreach( var item in items ) - { - switch( ( EquipSlot )item.EquipSlotCategory.Row ) - { - case EquipSlot.MainHand: - case EquipSlot.OffHand: - case EquipSlot.BothHand: - if( item.ModelMain != 0 ) - { - Add( weapons, WeaponKey( item, false ), item ); - } - - if( item.ModelSub != 0 ) - { - Add( weapons, WeaponKey( item, true ), item ); - } - - break; - // Accessories - case EquipSlot.RFinger: - case EquipSlot.Wrists: - case EquipSlot.Ears: - case EquipSlot.Neck: - Add( equipment, EquipmentKey( item ), item ); - break; - // Equipment - case EquipSlot.Head: - case EquipSlot.Body: - case EquipSlot.Hands: - case EquipSlot.Legs: - case EquipSlot.Feet: - case EquipSlot.BodyHands: - case EquipSlot.BodyHandsLegsFeet: - case EquipSlot.BodyLegsFeet: - case EquipSlot.FullBody: - case EquipSlot.HeadBody: - case EquipSlot.LegsFeet: - Add( equipment, EquipmentKey( item ), item ); - break; - default: continue; - } - } - - _actions = new Dictionary< string, HashSet< Action > >(); - foreach( var action in dataManager.GetExcelSheet< Action >( clientLanguage )! - .Where( a => a.Name.ToString().Any() ) ) - { - var startKey = action.AnimationStart?.Value?.Name?.Value?.Key.ToString() ?? string.Empty; - var endKey = action.AnimationEnd?.Value?.Key.ToString() ?? string.Empty; - var hitKey = action.ActionTimelineHit?.Value?.Key.ToString() ?? string.Empty; - AddAction( startKey, action ); - AddAction( endKey, action ); - AddAction( hitKey, action ); - } - - _weapons = weapons.Select( kvp => ( kvp.Key, kvp.Value ) ).ToList(); - _equipment = equipment.Select( kvp => ( kvp.Key, kvp.Value ) ).ToList(); - } - - private class Comparer : IComparer< (ulong, HashSet< Item >) > - { - public int Compare( (ulong, HashSet< Item >) x, (ulong, HashSet< Item >) y ) - => x.Item1.CompareTo( y.Item1 ); - } - - private static (int, int) FindIndexRange( List< (ulong, HashSet< Item >) > list, ulong key, ulong mask ) - { - var maskedKey = key & mask; - var idx = list.BinarySearch( 0, list.Count, ( key, null! ), new Comparer() ); - if( idx < 0 ) - { - if( ~idx == list.Count || maskedKey != ( list[ ~idx ].Item1 & mask ) ) - { - return ( -1, -1 ); - } - - idx = ~idx; - } - - var endIdx = idx + 1; - while( endIdx < list.Count && maskedKey == ( list[ endIdx ].Item1 & mask ) ) - { - ++endIdx; - } - - return ( idx, endIdx ); - } - - private void FindEquipment( IDictionary< string, object? > set, GameObjectInfo info ) - { - var key = ( ulong )info.PrimaryId << 32; - var mask = 0xFFFF00000000ul; - if( info.EquipSlot != EquipSlot.Unknown ) - { - key |= ( ulong )info.EquipSlot.ToSlot() << 16; - mask |= 0xFFFF0000; - } - - if( info.Variant != 0 ) - { - key |= info.Variant; - mask |= 0xFFFF; - } - - var (start, end) = FindIndexRange( _equipment, key, mask ); - if( start == -1 ) - { - return; - } - - for( ; start < end; ++start ) - { - foreach( var item in _equipment[ start ].Item2 ) - { - set[ item.Name.ToString() ] = item; - } - } - } - - private void FindWeapon( IDictionary< string, object? > set, GameObjectInfo info ) - { - var key = ( ulong )info.PrimaryId << 32; - var mask = 0xFFFF00000000ul; - if( info.SecondaryId != 0 ) - { - key |= ( ulong )info.SecondaryId << 16; - mask |= 0xFFFF0000; - } - - if( info.Variant != 0 ) - { - key |= info.Variant; - mask |= 0xFFFF; - } - - var (start, end) = FindIndexRange( _weapons, key, mask ); - if( start == -1 ) - { - return; - } - - for( ; start < end; ++start ) - { - foreach( var item in _weapons[ start ].Item2 ) - { - set[ item.Name.ToString() ] = item; - } - } - } - - - private void IdentifyParsed( IDictionary< string, object? > set, GameObjectInfo info ) - { - switch( info.ObjectType ) - { - case ObjectType.Unknown: - case ObjectType.LoadingScreen: - case ObjectType.Map: - case ObjectType.Interface: - case ObjectType.Vfx: - case ObjectType.World: - case ObjectType.Housing: - case ObjectType.DemiHuman: - case ObjectType.Monster: - case ObjectType.Icon: - case ObjectType.Font: - // Don't do anything for these cases. - break; - case ObjectType.Accessory: - case ObjectType.Equipment: - FindEquipment( set, info ); - break; - case ObjectType.Weapon: - FindWeapon( set, info ); - break; - case ObjectType.Character: - var (gender, race) = info.GenderRace.Split(); - var raceString = race != ModelRace.Unknown ? race.ToName() + " " : ""; - var genderString = gender != Gender.Unknown ? gender.ToName() + " " : "Player "; - if( info.CustomizationType == CustomizationType.Skin ) - { - set[ $"Customization: {raceString}{genderString}Skin Textures" ] = null; - } - else - { - var customizationString = - $"Customization: {race} {gender} {info.BodySlot} ({info.CustomizationType}) {info.PrimaryId}"; - set[ customizationString ] = null; - } - - break; - - default: throw new InvalidEnumArgumentException(); - } - } - - private void IdentifyVfx( IDictionary< string, object? > set, GamePath path ) - { - var key = GameData.GamePathParser.VfxToKey( path ); - if( key.Length == 0 || !_actions.TryGetValue( key, out var actions ) ) - { - return; - } - - foreach( var action in actions ) - { - set[ $"Action: {action.Name}" ] = action; - } - } - - public void Identify( IDictionary< string, object? > set, GamePath path ) - { - if( ( ( string )path ).EndsWith( ".pap" ) || ( ( string )path ).EndsWith( ".tmb" ) ) - { - IdentifyVfx( set, path ); - } - else - { - var info = GameData.GamePathParser.GetFileInfo( path ); - IdentifyParsed( set, info ); - } - } - - public Dictionary< string, object? > Identify( GamePath path ) - { - Dictionary< string, object? > ret = new(); - Identify( ret, path ); - return ret; - } - - public Item? Identify( SetId setId, WeaponType weaponType, ushort variant, EquipSlot slot ) - { - switch( slot ) - { - case EquipSlot.MainHand: - case EquipSlot.OffHand: - { - var (begin, _) = FindIndexRange( _weapons, ( ( ulong )setId << 32 ) | ( ( ulong )weaponType << 16 ) | variant, - 0xFFFFFFFFFFFF ); - return begin >= 0 ? _weapons[ begin ].Item2.FirstOrDefault() : null; - } - default: - { - var (begin, _) = FindIndexRange( _equipment, - ( ( ulong )setId << 32 ) | ( ( ulong )slot.ToSlot() << 16 ) | variant, - 0xFFFFFFFFFFFF ); - return begin >= 0 ? _equipment[ begin ].Item2.FirstOrDefault() : null; - } - } - } - } -} \ No newline at end of file diff --git a/Penumbra.GameData/Penumbra.GameData.csproj b/Penumbra.GameData/Penumbra.GameData.csproj deleted file mode 100644 index c7dbc908..00000000 --- a/Penumbra.GameData/Penumbra.GameData.csproj +++ /dev/null @@ -1,48 +0,0 @@ - - - net5.0-windows - preview - x64 - Penumbra.GameData - absolute gangstas - Penumbra - Copyright © 2020 - 1.0.0.0 - 1.0.0.0 - bin\$(Configuration)\ - true - enable - - - - full - DEBUG;TRACE - - - - pdbonly - - - - $(MSBuildWarningsAsMessages);MSB3277 - - - - - $(AppData)\XIVLauncher\addon\Hooks\dev\Dalamud.dll - False - - - $(AppData)\XIVLauncher\addon\Hooks\dev\Lumina.dll - False - - - $(AppData)\XIVLauncher\addon\Hooks\dev\Lumina.Excel.dll - False - - - - - - - diff --git a/Penumbra.GameData/Structs/CharacterArmor.cs b/Penumbra.GameData/Structs/CharacterArmor.cs deleted file mode 100644 index c61ac7ab..00000000 --- a/Penumbra.GameData/Structs/CharacterArmor.cs +++ /dev/null @@ -1,15 +0,0 @@ -using System.Runtime.InteropServices; - -namespace Penumbra.GameData.Structs -{ - [StructLayout( LayoutKind.Sequential, Pack = 1 )] - public readonly struct CharacterArmor - { - public readonly SetId Set; - public readonly byte Variant; - public readonly StainId Stain; - - public override string ToString() - => $"{Set},{Variant},{Stain}"; - } -} \ No newline at end of file diff --git a/Penumbra.GameData/Structs/CharacterEquipment.cs b/Penumbra.GameData/Structs/CharacterEquipment.cs deleted file mode 100644 index 84d244ae..00000000 --- a/Penumbra.GameData/Structs/CharacterEquipment.cs +++ /dev/null @@ -1,149 +0,0 @@ -using System; -using System.Runtime.InteropServices; -using Dalamud.Game.ClientState.Objects.Types; - -// Read the customization data regarding weapons and displayable equipment from an actor struct. -// Stores the data in a 56 bytes, i.e. 7 longs for easier comparison. -namespace Penumbra.GameData.Structs -{ - [StructLayout( LayoutKind.Sequential, Pack = 1 )] - public class CharacterEquipment - { - public const int MainWeaponOffset = 0x0F08; - public const int OffWeaponOffset = 0x0F70; - public const int EquipmentOffset = 0x1040; - public const int EquipmentSlots = 10; - public const int WeaponSlots = 2; - - public CharacterWeapon MainHand; - public CharacterWeapon OffHand; - public CharacterArmor Head; - public CharacterArmor Body; - public CharacterArmor Hands; - public CharacterArmor Legs; - public CharacterArmor Feet; - public CharacterArmor Ears; - public CharacterArmor Neck; - public CharacterArmor Wrists; - public CharacterArmor RFinger; - public CharacterArmor LFinger; - public ushort IsSet; // Also fills struct size to 56, a multiple of 8. - - public CharacterEquipment() - => Clear(); - - public CharacterEquipment( Character actor ) - : this( actor.Address ) - { } - - public override string ToString() - => IsSet == 0 - ? "(Not Set)" - : $"({MainHand}) | ({OffHand}) | ({Head}) | ({Body}) | ({Hands}) | ({Legs}) | " - + $"({Feet}) | ({Ears}) | ({Neck}) | ({Wrists}) | ({LFinger}) | ({RFinger})"; - - public bool Equal( Character rhs ) - => CompareData( new CharacterEquipment( rhs ) ); - - public bool Equal( CharacterEquipment rhs ) - => CompareData( rhs ); - - public bool CompareAndUpdate( Character rhs ) - => CompareAndOverwrite( new CharacterEquipment( rhs ) ); - - public bool CompareAndUpdate( CharacterEquipment rhs ) - => CompareAndOverwrite( rhs ); - - private unsafe CharacterEquipment( IntPtr actorAddress ) - { - IsSet = 1; - var actorPtr = ( byte* )actorAddress.ToPointer(); - fixed( CharacterWeapon* main = &MainHand, off = &OffHand ) - { - Buffer.MemoryCopy( actorPtr + MainWeaponOffset, main, sizeof( CharacterWeapon ), sizeof( CharacterWeapon ) ); - Buffer.MemoryCopy( actorPtr + OffWeaponOffset, off, sizeof( CharacterWeapon ), sizeof( CharacterWeapon ) ); - } - - fixed( CharacterArmor* equipment = &Head ) - { - Buffer.MemoryCopy( actorPtr + EquipmentOffset, equipment, EquipmentSlots * sizeof( CharacterArmor ), - EquipmentSlots * sizeof( CharacterArmor ) ); - } - } - - public unsafe void Clear() - { - fixed( CharacterWeapon* main = &MainHand ) - { - var structSizeEights = ( 2 + EquipmentSlots * sizeof( CharacterArmor ) + WeaponSlots * sizeof( CharacterWeapon ) ) / 8; - for( ulong* ptr = ( ulong* )main, end = ptr + structSizeEights; ptr != end; ++ptr ) - { - *ptr = 0; - } - } - } - - private unsafe bool CompareAndOverwrite( CharacterEquipment rhs ) - { - var structSizeEights = ( 2 + EquipmentSlots * sizeof( CharacterArmor ) + WeaponSlots * sizeof( CharacterWeapon ) ) / 8; - var ret = true; - fixed( CharacterWeapon* data1 = &MainHand, data2 = &rhs.MainHand ) - { - var ptr1 = ( ulong* )data1; - var ptr2 = ( ulong* )data2; - for( var end = ptr1 + structSizeEights; ptr1 != end; ++ptr1, ++ptr2 ) - { - if( *ptr1 != *ptr2 ) - { - *ptr1 = *ptr2; - ret = false; - } - } - } - - return ret; - } - - private unsafe bool CompareData( CharacterEquipment rhs ) - { - var structSizeEights = ( 2 + EquipmentSlots * sizeof( CharacterArmor ) + WeaponSlots * sizeof( CharacterWeapon ) ) / 8; - fixed( CharacterWeapon* data1 = &MainHand, data2 = &rhs.MainHand ) - { - var ptr1 = ( ulong* )data1; - var ptr2 = ( ulong* )data2; - for( var end = ptr1 + structSizeEights; ptr1 != end; ++ptr1, ++ptr2 ) - { - if( *ptr1 != *ptr2 ) - { - return false; - } - } - } - - return true; - } - - public unsafe void WriteBytes( byte[] array, int offset = 0 ) - { - fixed( CharacterWeapon* data = &MainHand ) - { - Marshal.Copy( new IntPtr( data ), array, offset, 56 ); - } - } - - public byte[] ToBytes() - { - var ret = new byte[56]; - WriteBytes( ret ); - return ret; - } - - public unsafe void FromBytes( byte[] array, int offset = 0 ) - { - fixed( CharacterWeapon* data = &MainHand ) - { - Marshal.Copy( array, offset, new IntPtr( data ), 56 ); - } - } - } -} \ No newline at end of file diff --git a/Penumbra.GameData/Structs/CharacterWeapon.cs b/Penumbra.GameData/Structs/CharacterWeapon.cs deleted file mode 100644 index 5a742073..00000000 --- a/Penumbra.GameData/Structs/CharacterWeapon.cs +++ /dev/null @@ -1,16 +0,0 @@ -using System.Runtime.InteropServices; - -namespace Penumbra.GameData.Structs -{ - [StructLayout( LayoutKind.Sequential, Pack = 1 )] - public readonly struct CharacterWeapon - { - public readonly SetId Set; - public readonly WeaponType Type; - public readonly ushort Variant; - public readonly StainId Stain; - - public override string ToString() - => $"{Set},{Type},{Variant},{Stain}"; - } -} \ No newline at end of file diff --git a/Penumbra.GameData/Structs/EqdpEntry.cs b/Penumbra.GameData/Structs/EqdpEntry.cs deleted file mode 100644 index 763809ae..00000000 --- a/Penumbra.GameData/Structs/EqdpEntry.cs +++ /dev/null @@ -1,107 +0,0 @@ -using System; -using System.ComponentModel; -using Penumbra.GameData.Enums; - -namespace Penumbra.GameData.Structs -{ - [Flags] - public enum EqdpEntry : ushort - { - Invalid = 0, - Head1 = 0b0000000001, - Head2 = 0b0000000010, - HeadMask = 0b0000000011, - - Body1 = 0b0000000100, - Body2 = 0b0000001000, - BodyMask = 0b0000001100, - - Hands1 = 0b0000010000, - Hands2 = 0b0000100000, - HandsMask = 0b0000110000, - - Legs1 = 0b0001000000, - Legs2 = 0b0010000000, - LegsMask = 0b0011000000, - - Feet1 = 0b0100000000, - Feet2 = 0b1000000000, - FeetMask = 0b1100000000, - - Ears1 = 0b0000000001, - Ears2 = 0b0000000010, - EarsMask = 0b0000000011, - - Neck1 = 0b0000000100, - Neck2 = 0b0000001000, - NeckMask = 0b0000001100, - - Wrists1 = 0b0000010000, - Wrists2 = 0b0000100000, - WristsMask = 0b0000110000, - - RingR1 = 0b0001000000, - RingR2 = 0b0010000000, - RingRMask = 0b0011000000, - - RingL1 = 0b0100000000, - RingL2 = 0b1000000000, - RingLMask = 0b1100000000, - } - - public static class Eqdp - { - public static int Offset( EquipSlot slot ) - { - return slot switch - { - EquipSlot.Head => 0, - EquipSlot.Body => 2, - EquipSlot.Hands => 4, - EquipSlot.Legs => 6, - EquipSlot.Feet => 8, - EquipSlot.Ears => 0, - EquipSlot.Neck => 2, - EquipSlot.Wrists => 4, - EquipSlot.RFinger => 6, - EquipSlot.LFinger => 8, - _ => throw new InvalidEnumArgumentException(), - }; - } - - public static EqdpEntry FromSlotAndBits( EquipSlot slot, bool bit1, bool bit2 ) - { - EqdpEntry ret = 0; - var offset = Offset( slot ); - if( bit1 ) - { - ret |= ( EqdpEntry )( 1 << offset ); - } - - if( bit2 ) - { - ret |= ( EqdpEntry )( 1 << ( offset + 1 ) ); - } - - return ret; - } - - public static EqdpEntry Mask( EquipSlot slot ) - { - return slot switch - { - EquipSlot.Head => EqdpEntry.HeadMask, - EquipSlot.Body => EqdpEntry.BodyMask, - EquipSlot.Hands => EqdpEntry.HandsMask, - EquipSlot.Legs => EqdpEntry.LegsMask, - EquipSlot.Feet => EqdpEntry.FeetMask, - EquipSlot.Ears => EqdpEntry.EarsMask, - EquipSlot.Neck => EqdpEntry.NeckMask, - EquipSlot.Wrists => EqdpEntry.WristsMask, - EquipSlot.RFinger => EqdpEntry.RingRMask, - EquipSlot.LFinger => EqdpEntry.RingLMask, - _ => 0, - }; - } - } -} \ No newline at end of file diff --git a/Penumbra.GameData/Structs/EqpEntry.cs b/Penumbra.GameData/Structs/EqpEntry.cs deleted file mode 100644 index 2e4c0d86..00000000 --- a/Penumbra.GameData/Structs/EqpEntry.cs +++ /dev/null @@ -1,308 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.ComponentModel; -using Penumbra.GameData.Enums; - -namespace Penumbra.GameData.Structs -{ - [Flags] - public enum EqpEntry : ulong - { - BodyEnabled = 0x00_01ul, - BodyHideWaist = 0x00_02ul, - _2 = 0x00_04ul, - BodyHideGlovesS = 0x00_08ul, - _4 = 0x00_10ul, - BodyHideGlovesM = 0x00_20ul, - BodyHideGlovesL = 0x00_40ul, - BodyHideGorget = 0x00_80ul, - BodyShowLeg = 0x01_00ul, - BodyShowHand = 0x02_00ul, - BodyShowHead = 0x04_00ul, - BodyShowNecklace = 0x08_00ul, - BodyShowBracelet = 0x10_00ul, - BodyShowTail = 0x20_00ul, - _14 = 0x40_00ul, - _15 = 0x80_00ul, - BodyMask = 0xFF_FFul, - - LegsEnabled = 0x01ul << 16, - LegsHideKneePads = 0x02ul << 16, - LegsHideBootsS = 0x04ul << 16, - LegsHideBootsM = 0x08ul << 16, - _20 = 0x10ul << 16, - LegsShowFoot = 0x20ul << 16, - LegsShowTail = 0x40ul << 16, - _23 = 0x80ul << 16, - LegsMask = 0xFFul << 16, - - HandsEnabled = 0x01ul << 24, - HandsHideElbow = 0x02ul << 24, - HandsHideForearm = 0x04ul << 24, - _27 = 0x08ul << 24, - HandShowBracelet = 0x10ul << 24, - HandShowRingL = 0x20ul << 24, - HandShowRingR = 0x40ul << 24, - _31 = 0x80ul << 24, - HandsMask = 0xFFul << 24, - - FeetEnabled = 0x01ul << 32, - FeetHideKnee = 0x02ul << 32, - FeetHideCalf = 0x04ul << 32, - FeetHideAnkle = 0x08ul << 32, - _36 = 0x10ul << 32, - _37 = 0x20ul << 32, - _38 = 0x40ul << 32, - _39 = 0x80ul << 32, - FeetMask = 0xFFul << 32, - - HeadEnabled = 0x00_00_01ul << 40, - HeadHideScalp = 0x00_00_02ul << 40, - HeadHideHair = 0x00_00_04ul << 40, - HeadShowHairOverride = 0x00_00_08ul << 40, - HeadHideNeck = 0x00_00_10ul << 40, - HeadShowNecklace = 0x00_00_20ul << 40, - _46 = 0x00_00_40ul << 40, - HeadShowEarrings = 0x00_00_80ul << 40, - HeadShowEarringsHuman = 0x00_01_00ul << 40, - HeadShowEarringsAura = 0x00_02_00ul << 40, - HeadShowEarHuman = 0x00_04_00ul << 40, - HeadShowEarMiqote = 0x00_08_00ul << 40, - HeadShowEarAuRa = 0x00_10_00ul << 40, - HeadShowEarViera = 0x00_20_00ul << 40, - _54 = 0x00_40_00ul << 40, - _55 = 0x00_80_00ul << 40, - HeadShowHrothgarHat = 0x01_00_00ul << 40, - HeadShowVieraHat = 0x02_00_00ul << 40, - _58 = 0x04_00_00ul << 40, - _59 = 0x08_00_00ul << 40, - _60 = 0x10_00_00ul << 40, - _61 = 0x20_00_00ul << 40, - _62 = 0x40_00_00ul << 40, - _63 = 0x80_00_00ul << 40, - HeadMask = 0xFF_FF_FFul << 40, - } - - public static class Eqp - { - public static (int, int) BytesAndOffset( EquipSlot slot ) - { - return slot switch - { - EquipSlot.Body => ( 2, 0 ), - EquipSlot.Legs => ( 1, 2 ), - EquipSlot.Hands => ( 1, 3 ), - EquipSlot.Feet => ( 1, 4 ), - EquipSlot.Head => ( 3, 5 ), - _ => throw new InvalidEnumArgumentException(), - }; - } - - public static EqpEntry FromSlotAndBytes( EquipSlot slot, byte[] value ) - { - EqpEntry ret = 0; - var (bytes, offset) = BytesAndOffset( slot ); - if( bytes != value.Length ) - { - throw new ArgumentException(); - } - - for( var i = 0; i < bytes; ++i ) - { - ret |= ( EqpEntry )( ( ulong )value[ i ] << ( ( offset + i ) * 8 ) ); - } - - return ret; - } - - public static EqpEntry Mask( EquipSlot slot ) - { - return slot switch - { - EquipSlot.Body => EqpEntry.BodyMask, - EquipSlot.Head => EqpEntry.HeadMask, - EquipSlot.Legs => EqpEntry.LegsMask, - EquipSlot.Feet => EqpEntry.FeetMask, - EquipSlot.Hands => EqpEntry.HandsMask, - _ => 0, - }; - } - - public static EquipSlot ToEquipSlot( this EqpEntry entry ) - { - return entry switch - { - EqpEntry.BodyEnabled => EquipSlot.Body, - EqpEntry.BodyHideWaist => EquipSlot.Body, - EqpEntry._2 => EquipSlot.Body, - EqpEntry.BodyHideGlovesS => EquipSlot.Body, - EqpEntry._4 => EquipSlot.Body, - EqpEntry.BodyHideGlovesM => EquipSlot.Body, - EqpEntry.BodyHideGlovesL => EquipSlot.Body, - EqpEntry.BodyHideGorget => EquipSlot.Body, - EqpEntry.BodyShowLeg => EquipSlot.Body, - EqpEntry.BodyShowHand => EquipSlot.Body, - EqpEntry.BodyShowHead => EquipSlot.Body, - EqpEntry.BodyShowNecklace => EquipSlot.Body, - EqpEntry.BodyShowBracelet => EquipSlot.Body, - EqpEntry.BodyShowTail => EquipSlot.Body, - EqpEntry._14 => EquipSlot.Body, - EqpEntry._15 => EquipSlot.Body, - - EqpEntry.LegsEnabled => EquipSlot.Legs, - EqpEntry.LegsHideKneePads => EquipSlot.Legs, - EqpEntry.LegsHideBootsS => EquipSlot.Legs, - EqpEntry.LegsHideBootsM => EquipSlot.Legs, - EqpEntry._20 => EquipSlot.Legs, - EqpEntry.LegsShowFoot => EquipSlot.Legs, - EqpEntry.LegsShowTail => EquipSlot.Legs, - EqpEntry._23 => EquipSlot.Legs, - - EqpEntry.HandsEnabled => EquipSlot.Hands, - EqpEntry.HandsHideElbow => EquipSlot.Hands, - EqpEntry.HandsHideForearm => EquipSlot.Hands, - EqpEntry._27 => EquipSlot.Hands, - EqpEntry.HandShowBracelet => EquipSlot.Hands, - EqpEntry.HandShowRingL => EquipSlot.Hands, - EqpEntry.HandShowRingR => EquipSlot.Hands, - EqpEntry._31 => EquipSlot.Hands, - - EqpEntry.FeetEnabled => EquipSlot.Feet, - EqpEntry.FeetHideKnee => EquipSlot.Feet, - EqpEntry.FeetHideCalf => EquipSlot.Feet, - EqpEntry.FeetHideAnkle => EquipSlot.Feet, - EqpEntry._36 => EquipSlot.Feet, - EqpEntry._37 => EquipSlot.Feet, - EqpEntry._38 => EquipSlot.Feet, - EqpEntry._39 => EquipSlot.Feet, - - EqpEntry.HeadEnabled => EquipSlot.Head, - EqpEntry.HeadHideScalp => EquipSlot.Head, - EqpEntry.HeadHideHair => EquipSlot.Head, - EqpEntry.HeadShowHairOverride => EquipSlot.Head, - EqpEntry.HeadHideNeck => EquipSlot.Head, - EqpEntry.HeadShowNecklace => EquipSlot.Head, - EqpEntry._46 => EquipSlot.Head, - EqpEntry.HeadShowEarrings => EquipSlot.Head, - EqpEntry.HeadShowEarringsHuman => EquipSlot.Head, - EqpEntry.HeadShowEarringsAura => EquipSlot.Head, - EqpEntry.HeadShowEarHuman => EquipSlot.Head, - EqpEntry.HeadShowEarMiqote => EquipSlot.Head, - EqpEntry.HeadShowEarAuRa => EquipSlot.Head, - EqpEntry.HeadShowEarViera => EquipSlot.Head, - EqpEntry._54 => EquipSlot.Head, - EqpEntry._55 => EquipSlot.Head, - EqpEntry.HeadShowHrothgarHat => EquipSlot.Head, - EqpEntry.HeadShowVieraHat => EquipSlot.Head, - EqpEntry._58 => EquipSlot.Head, - EqpEntry._59 => EquipSlot.Head, - EqpEntry._60 => EquipSlot.Head, - EqpEntry._61 => EquipSlot.Head, - EqpEntry._62 => EquipSlot.Head, - EqpEntry._63 => EquipSlot.Head, - - _ => EquipSlot.Unknown, - }; - } - - public static string ToLocalName( this EqpEntry entry ) - { - return entry switch - { - EqpEntry.BodyEnabled => "Enabled", - EqpEntry.BodyHideWaist => "Hide Waist", - EqpEntry._2 => "Unknown 2", - EqpEntry.BodyHideGlovesS => "Hide Small Gloves", - EqpEntry._4 => "Unknown 4", - EqpEntry.BodyHideGlovesM => "Hide Medium Gloves", - EqpEntry.BodyHideGlovesL => "Hide Large Gloves", - EqpEntry.BodyHideGorget => "Hide Gorget", - EqpEntry.BodyShowLeg => "Show Legs", - EqpEntry.BodyShowHand => "Show Hands", - EqpEntry.BodyShowHead => "Show Head", - EqpEntry.BodyShowNecklace => "Show Necklace", - EqpEntry.BodyShowBracelet => "Show Bracelet", - EqpEntry.BodyShowTail => "Show Tail", - EqpEntry._14 => "Unknown 14", - EqpEntry._15 => "Unknown 15", - - EqpEntry.LegsEnabled => "Enabled", - EqpEntry.LegsHideKneePads => "Hide Knee Pads", - EqpEntry.LegsHideBootsS => "Hide Small Boots", - EqpEntry.LegsHideBootsM => "Hide Medium Boots", - EqpEntry._20 => "Unknown 20", - EqpEntry.LegsShowFoot => "Show Foot", - EqpEntry.LegsShowTail => "Show Tail", - EqpEntry._23 => "Unknown 23", - - EqpEntry.HandsEnabled => "Enabled", - EqpEntry.HandsHideElbow => "Hide Elbow", - EqpEntry.HandsHideForearm => "Hide Forearm", - EqpEntry._27 => "Unknown 27", - EqpEntry.HandShowBracelet => "Show Bracelet", - EqpEntry.HandShowRingL => "Show Left Ring", - EqpEntry.HandShowRingR => "Show Right Ring", - EqpEntry._31 => "Unknown 31", - - EqpEntry.FeetEnabled => "Enabled", - EqpEntry.FeetHideKnee => "Hide Knees", - EqpEntry.FeetHideCalf => "Hide Calves", - EqpEntry.FeetHideAnkle => "Hide Ankles", - EqpEntry._36 => "Unknown 36", - EqpEntry._37 => "Unknown 37", - EqpEntry._38 => "Unknown 38", - EqpEntry._39 => "Unknown 39", - - EqpEntry.HeadEnabled => "Enabled", - EqpEntry.HeadHideScalp => "Hide Scalp", - EqpEntry.HeadHideHair => "Hide Hair", - EqpEntry.HeadShowHairOverride => "Show Hair Override", - EqpEntry.HeadHideNeck => "Hide Neck", - EqpEntry.HeadShowNecklace => "Show Necklace", - EqpEntry._46 => "Unknown 46", - EqpEntry.HeadShowEarrings => "Show Earrings", - EqpEntry.HeadShowEarringsHuman => "Show Earrings (Human)", - EqpEntry.HeadShowEarringsAura => "Show Earrings (Au Ra)", - EqpEntry.HeadShowEarHuman => "Show Ears (Human)", - EqpEntry.HeadShowEarMiqote => "Show Ears (Miqo'te)", - EqpEntry.HeadShowEarAuRa => "Show Ears (Au Ra)", - EqpEntry.HeadShowEarViera => "Show Ears (Viera)", - EqpEntry._54 => "Unknown 54", - EqpEntry._55 => "Unknown 55", - EqpEntry.HeadShowHrothgarHat => "Show on Hrothgar", - EqpEntry.HeadShowVieraHat => "Show on Viera", - EqpEntry._58 => "Unknown 58", - EqpEntry._59 => "Unknown 59", - EqpEntry._60 => "Unknown 60", - EqpEntry._61 => "Unknown 61", - EqpEntry._62 => "Unknown 62", - EqpEntry._63 => "Unknown 63", - - _ => throw new InvalidEnumArgumentException(), - }; - } - - private static EqpEntry[] GetEntriesForSlot( EquipSlot slot ) - { - return ( ( EqpEntry[] )Enum.GetValues( typeof( EqpEntry ) ) ) - .Where( e => e.ToEquipSlot() == slot ) - .ToArray(); - } - - public static readonly EqpEntry[] EqpAttributesBody = GetEntriesForSlot( EquipSlot.Body ); - public static readonly EqpEntry[] EqpAttributesLegs = GetEntriesForSlot( EquipSlot.Legs ); - public static readonly EqpEntry[] EqpAttributesHands = GetEntriesForSlot( EquipSlot.Hands ); - public static readonly EqpEntry[] EqpAttributesFeet = GetEntriesForSlot( EquipSlot.Feet ); - public static readonly EqpEntry[] EqpAttributesHead = GetEntriesForSlot( EquipSlot.Head ); - - public static IReadOnlyDictionary< EquipSlot, EqpEntry[] > EqpAttributes = new Dictionary< EquipSlot, EqpEntry[] >() - { - [ EquipSlot.Body ] = EqpAttributesBody, - [ EquipSlot.Legs ] = EqpAttributesLegs, - [ EquipSlot.Hands ] = EqpAttributesHands, - [ EquipSlot.Feet ] = EqpAttributesFeet, - [ EquipSlot.Head ] = EqpAttributesHead, - }; - } -} \ No newline at end of file diff --git a/Penumbra.GameData/Structs/GameObjectInfo.cs b/Penumbra.GameData/Structs/GameObjectInfo.cs deleted file mode 100644 index 80b6c792..00000000 --- a/Penumbra.GameData/Structs/GameObjectInfo.cs +++ /dev/null @@ -1,160 +0,0 @@ -using System; -using System.Runtime.InteropServices; -using Dalamud; -using Penumbra.GameData.Enums; - -namespace Penumbra.GameData.Structs -{ - [StructLayout( LayoutKind.Explicit )] - public struct GameObjectInfo : IComparable - { - public static GameObjectInfo Equipment( FileType type, ushort setId, GenderRace gr = GenderRace.Unknown - , EquipSlot slot = EquipSlot.Unknown, byte variant = 0 ) - => new() - { - FileType = type, - ObjectType = slot.IsAccessory() ? ObjectType.Accessory : ObjectType.Equipment, - PrimaryId = setId, - GenderRace = gr, - Variant = variant, - EquipSlot = slot, - }; - - public static GameObjectInfo Weapon( FileType type, ushort setId, ushort weaponId, byte variant = 0 ) - => new() - { - FileType = type, - ObjectType = ObjectType.Weapon, - PrimaryId = setId, - SecondaryId = weaponId, - Variant = variant, - }; - - public static GameObjectInfo Customization( FileType type, CustomizationType customizationType, ushort id = 0 - , GenderRace gr = GenderRace.Unknown, BodySlot bodySlot = BodySlot.Unknown, byte variant = 0 ) - => new() - { - FileType = type, - ObjectType = ObjectType.Character, - PrimaryId = id, - GenderRace = gr, - BodySlot = bodySlot, - Variant = variant, - CustomizationType = customizationType, - }; - - public static GameObjectInfo Monster( FileType type, ushort monsterId, ushort bodyId, byte variant = 0 ) - => new() - { - FileType = type, - ObjectType = ObjectType.Monster, - PrimaryId = monsterId, - SecondaryId = bodyId, - Variant = variant, - }; - - public static GameObjectInfo DemiHuman( FileType type, ushort demiHumanId, ushort bodyId, EquipSlot slot = EquipSlot.Unknown, - byte variant = 0 - ) - => new() - { - FileType = type, - ObjectType = ObjectType.DemiHuman, - PrimaryId = demiHumanId, - SecondaryId = bodyId, - Variant = variant, - EquipSlot = slot, - }; - - public static GameObjectInfo Map( FileType type, byte c1, byte c2, byte c3, byte c4, byte variant, byte suffix = 0 ) - => new() - { - FileType = type, - ObjectType = ObjectType.Map, - MapC1 = c1, - MapC2 = c2, - MapC3 = c3, - MapC4 = c4, - MapSuffix = suffix, - Variant = variant, - }; - - public static GameObjectInfo Icon( FileType type, uint iconId, bool hq, ClientLanguage lang = ClientLanguage.English ) - => new() - { - FileType = type, - ObjectType = ObjectType.Map, - IconId = iconId, - IconHq = hq, - Language = lang, - }; - - - [FieldOffset( 0 )] - public readonly ulong Identifier; - - [FieldOffset( 0 )] - public FileType FileType; - - [FieldOffset( 1 )] - public ObjectType ObjectType; - - - [FieldOffset( 2 )] - public ushort PrimaryId; // Equipment, Weapon, Customization, Monster, DemiHuman - - [FieldOffset( 2 )] - public uint IconId; // Icon - - [FieldOffset( 2 )] - public byte MapC1; // Map - - [FieldOffset( 3 )] - public byte MapC2; // Map - - [FieldOffset( 4 )] - public ushort SecondaryId; // Weapon, Monster, Demihuman - - [FieldOffset( 4 )] - public byte MapC3; // Map - - [FieldOffset( 4 )] - private byte _genderRaceByte; // Equipment, Customization - - public GenderRace GenderRace - { - get => Names.GenderRaceFromByte( _genderRaceByte ); - set => _genderRaceByte = value.ToByte(); - } - - [FieldOffset( 5 )] - public BodySlot BodySlot; // Customization - - [FieldOffset( 5 )] - public byte MapC4; // Map - - [FieldOffset( 6 )] - public byte Variant; // Equipment, Weapon, Customization, Map, Monster, Demihuman - - [FieldOffset( 6 )] - public bool IconHq; // Icon - - [FieldOffset( 7 )] - public EquipSlot EquipSlot; // Equipment, Demihuman - - [FieldOffset( 7 )] - public CustomizationType CustomizationType; // Customization - - [FieldOffset( 7 )] - public ClientLanguage Language; // Icon - - [FieldOffset( 7 )] - public byte MapSuffix; - - public override int GetHashCode() - => Identifier.GetHashCode(); - - public int CompareTo( object? r ) - => Identifier.CompareTo( r ); - } -} \ No newline at end of file diff --git a/Penumbra.GameData/Structs/GmpEntry.cs b/Penumbra.GameData/Structs/GmpEntry.cs deleted file mode 100644 index be9a7b58..00000000 --- a/Penumbra.GameData/Structs/GmpEntry.cs +++ /dev/null @@ -1,92 +0,0 @@ -using System.IO; - -namespace Penumbra.GameData.Structs -{ - public struct GmpEntry - { - public bool Enabled - { - get => ( Value & 1 ) == 1; - set - { - if( value ) - { - Value |= 1ul; - } - else - { - Value &= ~1ul; - } - } - } - - public bool Animated - { - get => ( Value & 2 ) == 2; - set - { - if( value ) - { - Value |= 2ul; - } - else - { - Value &= ~2ul; - } - } - } - - public ushort RotationA - { - get => ( ushort )( ( Value >> 2 ) & 0x3FF ); - set => Value = ( Value & ~0xFFCul ) | ( ( value & 0x3FFul ) << 2 ); - } - - public ushort RotationB - { - get => ( ushort )( ( Value >> 12 ) & 0x3FF ); - set => Value = ( Value & ~0x3FF000ul ) | ( ( value & 0x3FFul ) << 12 ); - } - - public ushort RotationC - { - get => ( ushort )( ( Value >> 22 ) & 0x3FF ); - set => Value = ( Value & ~0xFFC00000ul ) | ( ( value & 0x3FFul ) << 22 ); - } - - public byte UnknownA - { - get => ( byte )( ( Value >> 32 ) & 0x0F ); - set => Value = ( Value & ~0x0F00000000ul ) | ( ( value & 0x0Ful ) << 32 ); - } - - public byte UnknownB - { - get => ( byte )( ( Value >> 36 ) & 0x0F ); - set => Value = ( Value & ~0xF000000000ul ) | ( ( value & 0x0Ful ) << 36 ); - } - - public byte UnknownTotal - { - get => ( byte )( ( Value >> 32 ) & 0xFF ); - set => Value = ( Value & ~0xFF00000000ul ) | ( ( value & 0xFFul ) << 32 ); - } - - public ulong Value { get; set; } - - public static GmpEntry FromTexToolsMeta( byte[] data ) - { - GmpEntry ret = new(); - using var reader = new BinaryReader( new MemoryStream( data ) ); - ret.Value = reader.ReadUInt32(); - ret.UnknownTotal = data[ 4 ]; - return ret; - } - - public static implicit operator ulong( GmpEntry entry ) - => entry.Value; - - public static explicit operator GmpEntry( ulong entry ) - => new() { Value = entry }; - } -} \ No newline at end of file diff --git a/Penumbra.GameData/Structs/RspEntry.cs b/Penumbra.GameData/Structs/RspEntry.cs deleted file mode 100644 index 80f02144..00000000 --- a/Penumbra.GameData/Structs/RspEntry.cs +++ /dev/null @@ -1,58 +0,0 @@ -using System; -using System.ComponentModel; -using System.IO; -using System.Runtime.InteropServices; -using Penumbra.GameData.Enums; - -namespace Penumbra.GameData.Structs -{ - [StructLayout( LayoutKind.Sequential, Pack = 1 )] - public readonly struct RspEntry - { - public const int ByteSize = ( int )RspAttribute.NumAttributes * 4; - - private readonly float[] Attributes; - - public RspEntry( RspEntry copy ) - => Attributes = ( float[] )copy.Attributes.Clone(); - - public RspEntry( byte[] bytes, int offset ) - { - if( offset < 0 || offset + ByteSize > bytes.Length ) - { - throw new ArgumentOutOfRangeException(); - } - - Attributes = new float[( int )RspAttribute.NumAttributes]; - using MemoryStream s = new( bytes ) { Position = offset }; - using BinaryReader br = new( s ); - for( var i = 0; i < ( int )RspAttribute.NumAttributes; ++i ) - { - Attributes[ i ] = br.ReadSingle(); - } - } - - private static int ToIndex( RspAttribute attribute ) - => attribute < RspAttribute.NumAttributes && attribute >= 0 - ? ( int )attribute - : throw new InvalidEnumArgumentException(); - - public float this[ RspAttribute attribute ] - { - get => Attributes[ ToIndex( attribute ) ]; - set => Attributes[ ToIndex( attribute ) ] = value; - } - - public byte[] ToBytes() - { - using var s = new MemoryStream( ByteSize ); - using var bw = new BinaryWriter( s ); - foreach( var attribute in Attributes ) - { - bw.Write( attribute ); - } - - return s.ToArray(); - } - } -} \ No newline at end of file diff --git a/Penumbra.GameData/Structs/SetId.cs b/Penumbra.GameData/Structs/SetId.cs deleted file mode 100644 index a2483538..00000000 --- a/Penumbra.GameData/Structs/SetId.cs +++ /dev/null @@ -1,24 +0,0 @@ -using System; - -namespace Penumbra.GameData.Structs -{ - public readonly struct SetId : IComparable< SetId > - { - public readonly ushort Value; - - public SetId( ushort value ) - => Value = value; - - public static implicit operator SetId( ushort id ) - => new( id ); - - public static explicit operator ushort( SetId id ) - => id.Value; - - public override string ToString() - => Value.ToString(); - - public int CompareTo( SetId other ) - => Value.CompareTo( other.Value ); - } -} \ No newline at end of file diff --git a/Penumbra.GameData/Structs/StainId.cs b/Penumbra.GameData/Structs/StainId.cs deleted file mode 100644 index 74a479a1..00000000 --- a/Penumbra.GameData/Structs/StainId.cs +++ /dev/null @@ -1,30 +0,0 @@ -using System; - -namespace Penumbra.GameData.Structs -{ - public readonly struct StainId : IEquatable< StainId > - { - public readonly byte Value; - - public StainId( byte value ) - => Value = value; - - public static implicit operator StainId( byte id ) - => new( id ); - - public static explicit operator byte( StainId id ) - => id.Value; - - public override string ToString() - => Value.ToString(); - - public bool Equals( StainId other ) - => Value == other.Value; - - public override bool Equals( object? obj ) - => obj is StainId other && Equals( other ); - - public override int GetHashCode() - => Value.GetHashCode(); - } -} \ No newline at end of file diff --git a/Penumbra.GameData/Structs/WeaponType.cs b/Penumbra.GameData/Structs/WeaponType.cs deleted file mode 100644 index a5fa6107..00000000 --- a/Penumbra.GameData/Structs/WeaponType.cs +++ /dev/null @@ -1,30 +0,0 @@ -using System; - -namespace Penumbra.GameData.Structs -{ - public readonly struct WeaponType : IEquatable< WeaponType > - { - public readonly ushort Value; - - public WeaponType( ushort value ) - => Value = value; - - public static implicit operator WeaponType( ushort id ) - => new( id ); - - public static explicit operator ushort( WeaponType id ) - => id.Value; - - public override string ToString() - => Value.ToString(); - - public bool Equals( WeaponType other ) - => Value == other.Value; - - public override bool Equals( object? obj ) - => obj is WeaponType other && Equals( other ); - - public override int GetHashCode() - => Value.GetHashCode(); - } -} \ No newline at end of file diff --git a/Penumbra.GameData/Util/GamePath.cs b/Penumbra.GameData/Util/GamePath.cs deleted file mode 100644 index b602d8d6..00000000 --- a/Penumbra.GameData/Util/GamePath.cs +++ /dev/null @@ -1,105 +0,0 @@ -using System; -using System.IO; -using Newtonsoft.Json; -using Newtonsoft.Json.Linq; - -namespace Penumbra.GameData.Util -{ - public readonly struct GamePath : IComparable - { - public const int MaxGamePathLength = 256; - - private readonly string _path; - - private GamePath( string path, bool _ ) - => _path = path; - - public GamePath( string? path ) - { - if( path != null && path.Length < MaxGamePathLength ) - { - _path = Lower( Trim( ReplaceSlash( path ) ) ); - } - else - { - _path = ""; - } - } - - public GamePath( FileInfo file, DirectoryInfo baseDir ) - => _path = CheckPre( file, baseDir ) ? Lower( Trim( ReplaceSlash( Substring( file, baseDir ) ) ) ) : ""; - - private static bool CheckPre( FileInfo file, DirectoryInfo baseDir ) - => file.FullName.StartsWith( baseDir.FullName ) && file.FullName.Length < MaxGamePathLength; - - private static string Substring( FileInfo file, DirectoryInfo baseDir ) - => file.FullName.Substring( baseDir.FullName.Length ); - - private static string ReplaceSlash( string path ) - => path.Replace( '\\', '/' ); - - private static string Trim( string path ) - => path.TrimStart( '/' ); - - private static string Lower( string path ) - => path.ToLowerInvariant(); - - public static GamePath GenerateUnchecked( string path ) - => new( path, true ); - - public static GamePath GenerateUncheckedLower( string path ) - => new( Lower( path ), true ); - - public static implicit operator string( GamePath gamePath ) - => gamePath._path; - - public static explicit operator GamePath( string gamePath ) - => new( gamePath ); - - public bool Empty - => _path.Length == 0; - - public string Filename() - { - var idx = _path.LastIndexOf( "/", StringComparison.Ordinal ); - return idx == -1 ? _path : idx == _path.Length - 1 ? "" : _path.Substring( idx + 1 ); - } - - public int CompareTo( object? rhs ) - { - return rhs switch - { - string path => string.Compare( _path, path, StringComparison.InvariantCulture ), - GamePath path => string.Compare( _path, path._path, StringComparison.InvariantCulture ), - _ => -1, - }; - } - - public override string ToString() - => _path; - } - - public class GamePathConverter : JsonConverter - { - public override bool CanConvert( Type objectType ) - => objectType == typeof( GamePath ); - - public override object ReadJson( JsonReader reader, Type objectType, object? existingValue, JsonSerializer serializer ) - { - var token = JToken.Load( reader ); - return token.ToObject< GamePath >(); - } - - public override bool CanWrite - => true; - - public override void WriteJson( JsonWriter writer, object? value, JsonSerializer serializer ) - { - if( value != null ) - { - var v = ( GamePath )value; - serializer.Serialize( writer, v.ToString() ); - } - } - } -} \ No newline at end of file diff --git a/Penumbra.PlayerWatch/CharacterFactory.cs b/Penumbra.PlayerWatch/CharacterFactory.cs deleted file mode 100644 index 806efafe..00000000 --- a/Penumbra.PlayerWatch/CharacterFactory.cs +++ /dev/null @@ -1,62 +0,0 @@ -using System; -using System.Reflection; -using Dalamud.Game.ClientState.Objects.Enums; -using Dalamud.Game.ClientState.Objects.SubKinds; -using Dalamud.Game.ClientState.Objects.Types; - -namespace Penumbra.PlayerWatch -{ - public static class CharacterFactory - { - private static ConstructorInfo? _characterConstructor = null; - - private static void Initialize() - { - _characterConstructor ??= typeof( Character ).GetConstructor( BindingFlags.NonPublic | BindingFlags.Instance, null, new[] - { - typeof( IntPtr ), - }, null )!; - } - - private static Character Character( IntPtr address ) - { - Initialize(); - return ( Character )_characterConstructor?.Invoke( new object[] - { - address, - } )!; - } - - public static Character? Convert( GameObject? actor ) - { - if( actor == null ) - { - return null; - } - - return actor switch - { - PlayerCharacter p => p, - BattleChara b => b, - _ => actor.ObjectKind switch - { - ObjectKind.BattleNpc => Character( actor.Address ), - ObjectKind.Companion => Character( actor.Address ), - ObjectKind.EventNpc => Character( actor.Address ), - _ => null, - }, - }; - } - } - - public static class GameObjectExtensions - { - private const int ModelTypeOffset = 0x01B4; - - public static unsafe int ModelType( this GameObject actor ) - => *( int* )( actor.Address + ModelTypeOffset ); - - public static unsafe void SetModelType( this GameObject actor, int value ) - => *( int* )( actor.Address + ModelTypeOffset ) = value; - } -} \ No newline at end of file diff --git a/Penumbra.PlayerWatch/IPlayerWatcher.cs b/Penumbra.PlayerWatch/IPlayerWatcher.cs deleted file mode 100644 index d6e077c5..00000000 --- a/Penumbra.PlayerWatch/IPlayerWatcher.cs +++ /dev/null @@ -1,31 +0,0 @@ -using System; -using System.Collections.Generic; -using Dalamud.Game.ClientState.Objects.Types; -using Penumbra.GameData.Structs; - -namespace Penumbra.PlayerWatch -{ - public delegate void PlayerChange( Character actor ); - - public interface IPlayerWatcherBase : IDisposable - { - public int Version { get; } - public bool Valid { get; } - } - - public interface IPlayerWatcher : IPlayerWatcherBase - { - public event PlayerChange? PlayerChanged; - public bool Active { get; } - - public void Enable(); - public void Disable(); - public void SetStatus( bool enabled ); - - public void AddPlayerToWatch( string playerName ); - public void RemovePlayerFromWatch( string playerName ); - public CharacterEquipment UpdatePlayerWithoutEvent( Character actor ); - - public IEnumerable< (string, CharacterEquipment) > WatchedPlayers(); - } -} \ No newline at end of file diff --git a/Penumbra.PlayerWatch/Penumbra.PlayerWatch.csproj b/Penumbra.PlayerWatch/Penumbra.PlayerWatch.csproj deleted file mode 100644 index f3449ad3..00000000 --- a/Penumbra.PlayerWatch/Penumbra.PlayerWatch.csproj +++ /dev/null @@ -1,40 +0,0 @@ - - - net5.0-windows - preview - x64 - Penumbra.PlayerWatch - absolute gangstas - Penumbra - Copyright © 2020 - 1.0.0.0 - 1.0.0.0 - bin\$(Configuration)\ - true - enable - - - - full - DEBUG;TRACE - - - - pdbonly - - - - $(MSBuildWarningsAsMessages);MSB3277 - - - - - $(AppData)\XIVLauncher\addon\Hooks\dev\Dalamud.dll - False - - - - - - - \ No newline at end of file diff --git a/Penumbra.PlayerWatch/PlayerWatchBase.cs b/Penumbra.PlayerWatch/PlayerWatchBase.cs deleted file mode 100644 index 6c203151..00000000 --- a/Penumbra.PlayerWatch/PlayerWatchBase.cs +++ /dev/null @@ -1,277 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using Dalamud.Game; -using Dalamud.Game.ClientState; -using Dalamud.Game.ClientState.Objects; -using Dalamud.Game.ClientState.Objects.Enums; -using Dalamud.Game.ClientState.Objects.Types; -using Dalamud.Logging; -using Penumbra.GameData.Structs; - -namespace Penumbra.PlayerWatch -{ - internal class PlayerWatchBase : IDisposable - { - public const int GPosePlayerIdx = 201; - public const int GPoseTableEnd = GPosePlayerIdx + 48; - private const int ObjectsPerFrame = 32; - - private readonly Framework _framework; - private readonly ClientState _clientState; - private readonly ObjectTable _objects; - internal readonly HashSet< PlayerWatcher > RegisteredWatchers = new(); - internal readonly Dictionary< string, (CharacterEquipment, HashSet< PlayerWatcher >) > Equip = new(); - private int _frameTicker; - private bool _inGPose; - private bool _enabled; - private bool _cancel; - - internal PlayerWatchBase( Framework framework, ClientState clientState, ObjectTable objects ) - { - _framework = framework; - _clientState = clientState; - _objects = objects; - } - - internal void RegisterWatcher( PlayerWatcher watcher ) - { - RegisteredWatchers.Add( watcher ); - if( watcher.Active ) - { - EnablePlayerWatch(); - } - } - - internal void UnregisterWatcher( PlayerWatcher watcher ) - { - if( RegisteredWatchers.Remove( watcher ) ) - { - foreach( var items in Equip.Values ) - { - items.Item2.Remove( watcher ); - } - } - - CheckActiveStatus(); - } - - internal void CheckActiveStatus() - { - if( RegisteredWatchers.Any( w => w.Active ) ) - { - EnablePlayerWatch(); - } - else - { - DisablePlayerWatch(); - } - } - - internal CharacterEquipment UpdatePlayerWithoutEvent( Character actor ) - { - var equipment = new CharacterEquipment( actor ); - if( Equip.ContainsKey( actor.Name.ToString() ) ) - { - Equip[ actor.Name.ToString() ] = ( equipment, Equip[ actor.Name.ToString() ].Item2 ); - } - - return equipment; - } - - internal void AddPlayerToWatch( string playerName, PlayerWatcher watcher ) - { - if( Equip.TryGetValue( playerName, out var items ) ) - { - items.Item2.Add( watcher ); - } - else - { - Equip[ playerName ] = ( new CharacterEquipment(), new HashSet< PlayerWatcher > { watcher } ); - } - } - - public void RemovePlayerFromWatch( string playerName, PlayerWatcher watcher ) - { - if( Equip.TryGetValue( playerName, out var items ) ) - { - items.Item2.Remove( watcher ); - if( items.Item2.Count == 0 ) - { - Equip.Remove( playerName ); - } - } - } - - internal void EnablePlayerWatch() - { - if( !_enabled ) - { - _enabled = true; - _framework.Update += OnFrameworkUpdate; - _clientState.TerritoryChanged += OnTerritoryChange; - _clientState.Logout += OnLogout; - } - } - - internal void DisablePlayerWatch() - { - if( _enabled ) - { - _enabled = false; - _framework.Update -= OnFrameworkUpdate; - _clientState.TerritoryChanged -= OnTerritoryChange; - _clientState.Logout -= OnLogout; - } - } - - public void Dispose() - => DisablePlayerWatch(); - - private void OnTerritoryChange( object? _1, ushort _2 ) - => Clear(); - - private void OnLogout( object? _1, object? _2 ) - => Clear(); - - internal void Clear() - { - PluginLog.Debug( "Clearing PlayerWatcher Store." ); - _cancel = true; - foreach( var kvp in Equip ) - { - kvp.Value.Item1.Clear(); - } - - _frameTicker = 0; - } - - private static void TriggerEvents( IEnumerable< PlayerWatcher > watchers, Character player ) - { - PluginLog.Debug( "Triggering events for {PlayerName} at {Address}.", player.Name, player.Address ); - foreach( var watcher in watchers.Where( w => w.Active ) ) - { - watcher.Trigger( player ); - } - } - - internal void TriggerGPose() - { - for( var i = GPosePlayerIdx; i < GPoseTableEnd; ++i ) - { - var player = _objects[ i ]; - if( player == null ) - { - return; - } - - if( Equip.TryGetValue( player.Name.ToString(), out var watcher ) ) - { - TriggerEvents( watcher.Item2, ( Character )player ); - } - } - } - - private Character? CheckGPoseObject( GameObject player ) - { - if( !_inGPose ) - { - return CharacterFactory.Convert( player ); - } - - for( var i = GPosePlayerIdx; i < GPoseTableEnd; ++i ) - { - var a = _objects[ i ]; - if( a == null ) - { - return CharacterFactory.Convert( player); - } - - if( a.Name == player.Name ) - { - return CharacterFactory.Convert( a ); - } - } - - return CharacterFactory.Convert(player)!; - } - - private bool TryGetPlayer( GameObject gameObject, out (CharacterEquipment, HashSet< PlayerWatcher >) equip ) - { - equip = default; - var name = gameObject.Name.ToString(); - return name.Length != 0 && Equip.TryGetValue( name, out equip ); - } - - private static bool InvalidObjectKind( ObjectKind kind ) - { - return kind switch - { - ObjectKind.BattleNpc => false, - ObjectKind.EventNpc => false, - ObjectKind.Player => false, - _ => true, - }; - } - - private GameObject? GetNextObject() - { - if( _frameTicker == GPosePlayerIdx - 1 ) - _frameTicker = GPoseTableEnd; - else if( _frameTicker == _objects.Length - 1 ) - _frameTicker = 0; - else - ++_frameTicker; - - return _objects[ _frameTicker ]; - } - - private void OnFrameworkUpdate( object framework ) - { - var newInGPose = _objects[ GPosePlayerIdx ] != null; - - if( newInGPose != _inGPose ) - { - if( newInGPose ) - { - TriggerGPose(); - } - else - { - Clear(); - } - - _inGPose = newInGPose; - } - - for( var i = 0; i < ObjectsPerFrame; ++i ) - { - var actor = GetNextObject(); - if( actor == null - || InvalidObjectKind(actor.ObjectKind) - || !TryGetPlayer( actor, out var equip ) ) - { - continue; - } - - var character = CheckGPoseObject( actor ); - if( _cancel ) - { - _cancel = false; - return; - } - - if( character == null || character.ModelType() != 0 ) - { - continue; - } - - PluginLog.Verbose( "Comparing Gear for {PlayerName} at {Address}...", character.Name, character.Address ); - if( !equip.Item1.CompareAndUpdate( character ) ) - { - TriggerEvents( equip.Item2, character ); - } - } - } - } -} \ No newline at end of file diff --git a/Penumbra.PlayerWatch/PlayerWatcher.cs b/Penumbra.PlayerWatch/PlayerWatcher.cs deleted file mode 100644 index 817d368a..00000000 --- a/Penumbra.PlayerWatch/PlayerWatcher.cs +++ /dev/null @@ -1,103 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using Dalamud.Game; -using Dalamud.Game.ClientState; -using Dalamud.Game.ClientState.Objects; -using Dalamud.Game.ClientState.Objects.Types; -using Penumbra.GameData.Structs; - -namespace Penumbra.PlayerWatch -{ - public class PlayerWatcher : IPlayerWatcher - { - public int Version { get; } = 2; - - private static PlayerWatchBase? _playerWatch; - - public event PlayerChange? PlayerChanged; - - public bool Active { get; set; } = true; - - public bool Valid - => _playerWatch != null; - - internal PlayerWatcher( Framework framework, ClientState clientState, ObjectTable objects ) - { - _playerWatch ??= new PlayerWatchBase( framework, clientState, objects ); - _playerWatch.RegisterWatcher( this ); - } - - public void Enable() - => SetStatus( true ); - - public void Disable() - => SetStatus( false ); - - public void SetStatus( bool enabled ) - { - Active = enabled && Valid; - _playerWatch?.CheckActiveStatus(); - } - - internal void Trigger( Character actor ) - => PlayerChanged?.Invoke( actor ); - - public void Dispose() - { - if( _playerWatch == null ) - { - return; - } - - Active = false; - PlayerChanged = null; - _playerWatch.UnregisterWatcher( this ); - if( _playerWatch.RegisteredWatchers.Count == 0 ) - { - _playerWatch.Dispose(); - _playerWatch = null; - } - } - - private void CheckValidity() - { - if( !Valid ) - { - throw new Exception( $"PlayerWatch was already disposed." ); - } - } - - public void AddPlayerToWatch( string name ) - { - CheckValidity(); - _playerWatch!.AddPlayerToWatch( name, this ); - } - - public void RemovePlayerFromWatch( string playerName ) - { - CheckValidity(); - _playerWatch!.RemovePlayerFromWatch( playerName, this ); - } - - public CharacterEquipment UpdatePlayerWithoutEvent( Character actor ) - { - CheckValidity(); - return _playerWatch!.UpdatePlayerWithoutEvent( actor ); - } - - public IEnumerable< (string, CharacterEquipment) > WatchedPlayers() - { - CheckValidity(); - return _playerWatch!.Equip - .Where( kvp => kvp.Value.Item2.Contains( this ) ) - .Select( kvp => ( kvp.Key, kvp.Value.Item1 ) ); - } - } - - public static class PlayerWatchFactory - { - public static IPlayerWatcher Create( Framework framework, ClientState clientState, ObjectTable objects ) - => new PlayerWatcher( framework, clientState, objects ); - } -} \ No newline at end of file diff --git a/Penumbra.String b/Penumbra.String new file mode 160000 index 00000000..9bd016fb --- /dev/null +++ b/Penumbra.String @@ -0,0 +1 @@ +Subproject commit 9bd016fbef5fb2de467dd42165267fdd93cd9592 diff --git a/Penumbra.sln b/Penumbra.sln index 58df4bf5..fbcd6080 100644 --- a/Penumbra.sln +++ b/Penumbra.sln @@ -1,41 +1,98 @@  Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 16 -VisualStudioVersion = 16.0.29709.97 +# Visual Studio Version 17 +VisualStudioVersion = 17.2.32210.308 MinimumVisualStudioVersion = 10.0.40219.1 Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Penumbra", "Penumbra\Penumbra.csproj", "{13C812E9-0D42-4B95-8646-40EEBF30636F}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{F89C9EAE-25C8-43BE-8108-5921E5A93502}" ProjectSection(SolutionItems) = preProject .editorconfig = .editorconfig + .github\workflows\build.yml = .github\workflows\build.yml + Penumbra\Penumbra.json = Penumbra\Penumbra.json + .github\workflows\release.yml = .github\workflows\release.yml + repo.json = repo.json + .github\workflows\test_release.yml = .github\workflows\test_release.yml EndProjectSection EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Penumbra.GameData", "Penumbra.GameData\Penumbra.GameData.csproj", "{EE551E87-FDB3-4612-B500-DC870C07C605}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Penumbra.PlayerWatch", "Penumbra.PlayerWatch\Penumbra.PlayerWatch.csproj", "{01685BD8-8847-4B49-BF90-1683B4C76B0E}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "OtterGui", "OtterGui\OtterGui.csproj", "{87750518-1A20-40B4-9FC1-22F906EFB290}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Penumbra.Api", "Penumbra.Api\Penumbra.Api.csproj", "{1FE4D8DF-B56A-464F-B39E-CDC0ED4167D4}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Penumbra.String", "Penumbra.String\Penumbra.String.csproj", "{5549BAFD-6357-4B1A-800C-75AC36E5B76D}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Penumbra.CrashHandler", "Penumbra.CrashHandler\Penumbra.CrashHandler.csproj", "{EE834491-A98F-4395-BE0D-6861AE5AD953}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Schemas", "Schemas", "{BFEA7504-1210-4F79-A7FE-BF03B6567E33}" + ProjectSection(SolutionItems) = preProject + schemas\default_mod.json = schemas\default_mod.json + schemas\group.json = schemas\group.json + schemas\local_mod_data-v3.json = schemas\local_mod_data-v3.json + schemas\mod_meta-v3.json = schemas\mod_meta-v3.json + EndProjectSection +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "structs", "structs", "{B03F276A-0572-4F62-AF86-EF62F6B80463}" + ProjectSection(SolutionItems) = preProject + schemas\structs\container.json = schemas\structs\container.json + schemas\structs\group_combining.json = schemas\structs\group_combining.json + schemas\structs\group_imc.json = schemas\structs\group_imc.json + schemas\structs\group_multi.json = schemas\structs\group_multi.json + schemas\structs\group_single.json = schemas\structs\group_single.json + schemas\structs\manipulation.json = schemas\structs\manipulation.json + schemas\structs\meta_atch.json = schemas\structs\meta_atch.json + schemas\structs\meta_atr.json = schemas\structs\meta_atr.json + schemas\structs\meta_enums.json = schemas\structs\meta_enums.json + schemas\structs\meta_eqdp.json = schemas\structs\meta_eqdp.json + schemas\structs\meta_eqp.json = schemas\structs\meta_eqp.json + schemas\structs\meta_est.json = schemas\structs\meta_est.json + schemas\structs\meta_geqp.json = schemas\structs\meta_geqp.json + schemas\structs\meta_gmp.json = schemas\structs\meta_gmp.json + schemas\structs\meta_imc.json = schemas\structs\meta_imc.json + schemas\structs\meta_rsp.json = schemas\structs\meta_rsp.json + schemas\structs\meta_shp.json = schemas\structs\meta_shp.json + schemas\structs\option.json = schemas\structs\option.json + EndProjectSection EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution - Debug|Any CPU = Debug|Any CPU - Release|Any CPU = Release|Any CPU + Debug|x64 = Debug|x64 + Release|x64 = Release|x64 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution - {13C812E9-0D42-4B95-8646-40EEBF30636F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {13C812E9-0D42-4B95-8646-40EEBF30636F}.Debug|Any CPU.Build.0 = Debug|Any CPU - {13C812E9-0D42-4B95-8646-40EEBF30636F}.Release|Any CPU.ActiveCfg = Release|Any CPU - {13C812E9-0D42-4B95-8646-40EEBF30636F}.Release|Any CPU.Build.0 = Release|Any CPU - {EE551E87-FDB3-4612-B500-DC870C07C605}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {EE551E87-FDB3-4612-B500-DC870C07C605}.Debug|Any CPU.Build.0 = Debug|Any CPU - {EE551E87-FDB3-4612-B500-DC870C07C605}.Release|Any CPU.ActiveCfg = Release|Any CPU - {EE551E87-FDB3-4612-B500-DC870C07C605}.Release|Any CPU.Build.0 = Release|Any CPU - {01685BD8-8847-4B49-BF90-1683B4C76B0E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {01685BD8-8847-4B49-BF90-1683B4C76B0E}.Debug|Any CPU.Build.0 = Debug|Any CPU - {01685BD8-8847-4B49-BF90-1683B4C76B0E}.Release|Any CPU.ActiveCfg = Release|Any CPU - {01685BD8-8847-4B49-BF90-1683B4C76B0E}.Release|Any CPU.Build.0 = Release|Any CPU + {13C812E9-0D42-4B95-8646-40EEBF30636F}.Debug|x64.ActiveCfg = Debug|x64 + {13C812E9-0D42-4B95-8646-40EEBF30636F}.Debug|x64.Build.0 = Debug|x64 + {13C812E9-0D42-4B95-8646-40EEBF30636F}.Release|x64.ActiveCfg = Release|x64 + {13C812E9-0D42-4B95-8646-40EEBF30636F}.Release|x64.Build.0 = Release|x64 + {EE551E87-FDB3-4612-B500-DC870C07C605}.Debug|x64.ActiveCfg = Debug|x64 + {EE551E87-FDB3-4612-B500-DC870C07C605}.Debug|x64.Build.0 = Debug|x64 + {EE551E87-FDB3-4612-B500-DC870C07C605}.Release|x64.ActiveCfg = Release|x64 + {EE551E87-FDB3-4612-B500-DC870C07C605}.Release|x64.Build.0 = Release|x64 + {87750518-1A20-40B4-9FC1-22F906EFB290}.Debug|x64.ActiveCfg = Debug|x64 + {87750518-1A20-40B4-9FC1-22F906EFB290}.Debug|x64.Build.0 = Debug|x64 + {87750518-1A20-40B4-9FC1-22F906EFB290}.Release|x64.ActiveCfg = Release|x64 + {87750518-1A20-40B4-9FC1-22F906EFB290}.Release|x64.Build.0 = Release|x64 + {1FE4D8DF-B56A-464F-B39E-CDC0ED4167D4}.Debug|x64.ActiveCfg = Debug|x64 + {1FE4D8DF-B56A-464F-B39E-CDC0ED4167D4}.Debug|x64.Build.0 = Debug|x64 + {1FE4D8DF-B56A-464F-B39E-CDC0ED4167D4}.Release|x64.ActiveCfg = Release|x64 + {1FE4D8DF-B56A-464F-B39E-CDC0ED4167D4}.Release|x64.Build.0 = Release|x64 + {5549BAFD-6357-4B1A-800C-75AC36E5B76D}.Debug|x64.ActiveCfg = Debug|x64 + {5549BAFD-6357-4B1A-800C-75AC36E5B76D}.Debug|x64.Build.0 = Debug|x64 + {5549BAFD-6357-4B1A-800C-75AC36E5B76D}.Release|x64.ActiveCfg = Release|x64 + {5549BAFD-6357-4B1A-800C-75AC36E5B76D}.Release|x64.Build.0 = Release|x64 + {EE834491-A98F-4395-BE0D-6861AE5AD953}.Debug|x64.ActiveCfg = Debug|x64 + {EE834491-A98F-4395-BE0D-6861AE5AD953}.Debug|x64.Build.0 = Debug|x64 + {EE834491-A98F-4395-BE0D-6861AE5AD953}.Release|x64.ActiveCfg = Release|x64 + {EE834491-A98F-4395-BE0D-6861AE5AD953}.Release|x64.Build.0 = Release|x64 EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {BFEA7504-1210-4F79-A7FE-BF03B6567E33} = {F89C9EAE-25C8-43BE-8108-5921E5A93502} + {B03F276A-0572-4F62-AF86-EF62F6B80463} = {BFEA7504-1210-4F79-A7FE-BF03B6567E33} + EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {B17E85B1-5F60-4440-9F9A-3DDE877E8CDF} EndGlobalSection diff --git a/Penumbra/API/ModsController.cs b/Penumbra/API/ModsController.cs deleted file mode 100644 index 277f4a3a..00000000 --- a/Penumbra/API/ModsController.cs +++ /dev/null @@ -1,49 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using EmbedIO; -using EmbedIO.Routing; -using EmbedIO.WebApi; -using Penumbra.Mods; -using Penumbra.Util; - -namespace Penumbra.Api -{ - public class ModsController : WebApiController - { - private readonly Penumbra _penumbra; - - public ModsController( Penumbra penumbra ) - => _penumbra = penumbra; - - [Route( HttpVerbs.Get, "/mods" )] - public object? GetMods() - { - var modManager = Service< ModManager >.Get(); - return modManager.Collections.CurrentCollection.Cache?.AvailableMods.Values.Select( x => new - { - x.Settings.Enabled, - x.Settings.Priority, - x.Data.BasePath.Name, - x.Data.Meta, - BasePath = x.Data.BasePath.FullName, - Files = x.Data.Resources.ModFiles.Select( fi => fi.FullName ), - } ) - ?? null; - } - - [Route( HttpVerbs.Post, "/mods" )] - public object CreateMod() - => new { }; - - [Route( HttpVerbs.Get, "/files" )] - public object GetFiles() - { - var modManager = Service< ModManager >.Get(); - return modManager.Collections.CurrentCollection.Cache?.ResolvedFiles.ToDictionary( - o => ( string )o.Key, - o => o.Value.FullName - ) - ?? new Dictionary< string, string >(); - } - } -} \ No newline at end of file diff --git a/Penumbra/Api/Api/ApiHelpers.cs b/Penumbra/Api/Api/ApiHelpers.cs new file mode 100644 index 00000000..92a30bce --- /dev/null +++ b/Penumbra/Api/Api/ApiHelpers.cs @@ -0,0 +1,78 @@ +using OtterGui.Log; +using OtterGui.Services; +using Penumbra.Api.Enums; +using Penumbra.Collections; +using Penumbra.Collections.Manager; +using Penumbra.GameData.Actors; +using Penumbra.GameData.Interop; +using Penumbra.Interop.PathResolving; + +namespace Penumbra.Api.Api; + +public class ApiHelpers( + CollectionManager collectionManager, + ObjectManager objects, + CollectionResolver collectionResolver, + ActorManager actors) : IApiService +{ + /// Return the associated identifier for an object given by its index. + [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] + internal ActorIdentifier AssociatedIdentifier(int gameObjectIdx) + { + if (gameObjectIdx < 0 || gameObjectIdx >= objects.TotalCount) + return ActorIdentifier.Invalid; + + var ptr = objects[gameObjectIdx]; + return actors.FromObject(ptr, out _, false, true, true); + } + + /// + /// Return the collection associated to a current game object. If it does not exist, return the default collection. + /// If the index is invalid, returns false and the default collection. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] + internal unsafe bool AssociatedCollection(int gameObjectIdx, out ModCollection collection) + { + collection = collectionManager.Active.Default; + if (gameObjectIdx < 0 || gameObjectIdx >= objects.TotalCount) + return false; + + var ptr = objects[gameObjectIdx]; + var data = collectionResolver.IdentifyCollection(ptr.AsObject, false); + if (data.Valid) + collection = data.ModCollection; + + return true; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] + internal static PenumbraApiEc Return(PenumbraApiEc ec, LazyString args, [CallerMemberName] string name = "Unknown") + { + if (ec is PenumbraApiEc.Success or PenumbraApiEc.NothingChanged) + Penumbra.Log.Verbose($"[{name}] Called with {args}, returned {ec}."); + else + Penumbra.Log.Debug($"[{name}] Called with {args}, returned {ec}."); + return ec; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] + internal static LazyString Args(params object[] arguments) + { + if (arguments.Length == 0) + return new LazyString(() => "no arguments"); + + return new LazyString(() => + { + var sb = new StringBuilder(); + for (var i = 0; i < arguments.Length / 2; ++i) + { + sb.Append(arguments[2 * i]); + sb.Append(" = "); + sb.Append(arguments[2 * i + 1]); + sb.Append(", "); + } + + return sb.ToString(0, sb.Length - 2); + }); + } +} diff --git a/Penumbra/Api/Api/CollectionApi.cs b/Penumbra/Api/Api/CollectionApi.cs new file mode 100644 index 00000000..c40feb12 --- /dev/null +++ b/Penumbra/Api/Api/CollectionApi.cs @@ -0,0 +1,177 @@ +using OtterGui.Services; +using Penumbra.Api.Enums; +using Penumbra.Collections; +using Penumbra.Collections.Manager; +using Penumbra.Mods; + +namespace Penumbra.Api.Api; + +public class CollectionApi(CollectionManager collections, ApiHelpers helpers) : IPenumbraApiCollection, IApiService +{ + public Dictionary GetCollections() + => collections.Storage.ToDictionary(c => c.Identity.Id, c => c.Identity.Name); + + public List<(Guid Id, string Name)> GetCollectionsByIdentifier(string identifier) + { + if (identifier.Length == 0) + return []; + + var list = new List<(Guid Id, string Name)>(4); + if (Guid.TryParse(identifier, out var guid) && collections.Storage.ById(guid, out var collection) && collection != ModCollection.Empty) + list.Add((collection.Identity.Id, collection.Identity.Name)); + else if (identifier.Length >= 8) + list.AddRange(collections.Storage.Where(c => c.Identity.Identifier.StartsWith(identifier, StringComparison.OrdinalIgnoreCase)) + .Select(c => (c.Identity.Id, c.Identity.Name))); + + list.AddRange(collections.Storage + .Where(c => string.Equals(c.Identity.Name, identifier, StringComparison.OrdinalIgnoreCase) + && !list.Contains((c.Identity.Id, c.Identity.Name))) + .Select(c => (c.Identity.Id, c.Identity.Name))); + return list; + } + + public Func CheckCurrentChangedItemFunc() + { + var weakRef = new WeakReference(collections); + return s => + { + if (!weakRef.TryGetTarget(out var c)) + throw new ObjectDisposedException("The underlying collection storage of this IPC container was disposed."); + + if (!c.Active.Current.ChangedItems.TryGetValue(s, out var d)) + return []; + + return d.Item1.Select(m => (m is Mod mod ? mod.Identifier : string.Empty, m.Name.Text)).ToArray(); + }; + } + + public Dictionary GetChangedItemsForCollection(Guid collectionId) + { + try + { + if (!collections.Storage.ById(collectionId, out var collection)) + collection = ModCollection.Empty; + + if (collection.HasCache) + return collection.ChangedItems.ToDictionary(kvp => kvp.Key, kvp => kvp.Value.Item2?.ToInternalObject()); + + Penumbra.Log.Warning($"Collection {collectionId} does not exist or is not loaded."); + return []; + } + catch (Exception e) + { + Penumbra.Log.Error($"Could not obtain Changed Items for {collectionId}:\n{e}"); + throw; + } + } + + public (Guid Id, string Name)? GetCollection(ApiCollectionType type) + { + if (!Enum.IsDefined(type)) + return null; + + var collection = collections.Active.ByType((CollectionType)type); + return collection == null ? null : (collection.Identity.Id, collection.Identity.Name); + } + + internal (Guid Id, string Name)? GetCollection(byte type) + => GetCollection((ApiCollectionType)type); + + public (bool ObjectValid, bool IndividualSet, (Guid Id, string Name) EffectiveCollection) GetCollectionForObject(int gameObjectIdx) + { + var id = helpers.AssociatedIdentifier(gameObjectIdx); + if (!id.IsValid) + return (false, false, (collections.Active.Default.Identity.Id, collections.Active.Default.Identity.Name)); + + if (collections.Active.Individuals.TryGetValue(id, out var collection)) + return (true, true, (collection.Identity.Id, collection.Identity.Name)); + + helpers.AssociatedCollection(gameObjectIdx, out collection); + return (true, false, (collection.Identity.Id, collection.Identity.Name)); + } + + public Guid[] GetCollectionByName(string name) + => collections.Storage.Where(c => string.Equals(name, c.Identity.Name, StringComparison.OrdinalIgnoreCase)).Select(c => c.Identity.Id) + .ToArray(); + + public (PenumbraApiEc, (Guid Id, string Name)? OldCollection) SetCollection(ApiCollectionType type, Guid? collectionId, + bool allowCreateNew, bool allowDelete) + { + if (!Enum.IsDefined(type)) + return (PenumbraApiEc.InvalidArgument, null); + + var oldCollection = collections.Active.ByType((CollectionType)type); + var old = oldCollection != null ? (oldCollection.Identity.Id, oldCollection.Identity.Name) : new ValueTuple?(); + if (collectionId == null) + { + if (old == null) + return (PenumbraApiEc.NothingChanged, old); + + if (!allowDelete || type is ApiCollectionType.Current or ApiCollectionType.Default or ApiCollectionType.Interface) + return (PenumbraApiEc.AssignmentDeletionDisallowed, old); + + collections.Active.RemoveSpecialCollection((CollectionType)type); + return (PenumbraApiEc.Success, old); + } + + if (!collections.Storage.ById(collectionId.Value, out var collection)) + return (PenumbraApiEc.CollectionMissing, old); + + if (old == null) + { + if (!allowCreateNew) + return (PenumbraApiEc.AssignmentCreationDisallowed, old); + + collections.Active.CreateSpecialCollection((CollectionType)type); + } + else if (old.Value.Item1 == collection.Identity.Id) + { + return (PenumbraApiEc.NothingChanged, old); + } + + collections.Active.SetCollection(collection, (CollectionType)type); + return (PenumbraApiEc.Success, old); + } + + public (PenumbraApiEc, (Guid Id, string Name)? OldCollection) SetCollectionForObject(int gameObjectIdx, Guid? collectionId, + bool allowCreateNew, bool allowDelete) + { + var id = helpers.AssociatedIdentifier(gameObjectIdx); + if (!id.IsValid) + return (PenumbraApiEc.InvalidIdentifier, (collections.Active.Default.Identity.Id, collections.Active.Default.Identity.Name)); + + var oldCollection = collections.Active.Individuals.TryGetValue(id, out var c) ? c : null; + var old = oldCollection != null ? (oldCollection.Identity.Id, oldCollection.Identity.Name) : new ValueTuple?(); + if (collectionId == null) + { + if (old == null) + return (PenumbraApiEc.NothingChanged, old); + + if (!allowDelete) + return (PenumbraApiEc.AssignmentDeletionDisallowed, old); + + var idx = collections.Active.Individuals.Index(id); + collections.Active.RemoveIndividualCollection(idx); + return (PenumbraApiEc.Success, old); + } + + if (!collections.Storage.ById(collectionId.Value, out var collection)) + return (PenumbraApiEc.CollectionMissing, old); + + if (old == null) + { + if (!allowCreateNew) + return (PenumbraApiEc.AssignmentCreationDisallowed, old); + + var ids = collections.Active.Individuals.GetGroup(id); + collections.Active.CreateIndividualCollection(ids); + } + else if (old.Value.Item1 == collection.Identity.Id) + { + return (PenumbraApiEc.NothingChanged, old); + } + + collections.Active.SetCollection(collection, CollectionType.Individual, collections.Active.Individuals.Index(id)); + return (PenumbraApiEc.Success, old); + } +} diff --git a/Penumbra/Api/Api/EditingApi.cs b/Penumbra/Api/Api/EditingApi.cs new file mode 100644 index 00000000..5a1fc347 --- /dev/null +++ b/Penumbra/Api/Api/EditingApi.cs @@ -0,0 +1,54 @@ +using OtterGui.Services; +using Penumbra.Import.Textures; +using TextureType = Penumbra.Api.Enums.TextureType; + +namespace Penumbra.Api.Api; + +public class EditingApi(TextureManager textureManager) : IPenumbraApiEditing, IApiService +{ + public Task ConvertTextureFile(string inputFile, string outputFile, TextureType textureType, bool mipMaps) + => textureType switch + { + TextureType.Png => textureManager.SavePng(inputFile, outputFile), + TextureType.Targa => textureManager.SaveTga(inputFile, outputFile), + TextureType.AsIsTex => textureManager.SaveAs(CombinedTexture.TextureSaveType.AsIs, mipMaps, true, inputFile, outputFile), + TextureType.AsIsDds => textureManager.SaveAs(CombinedTexture.TextureSaveType.AsIs, mipMaps, false, inputFile, outputFile), + TextureType.RgbaTex => textureManager.SaveAs(CombinedTexture.TextureSaveType.Bitmap, mipMaps, true, inputFile, outputFile), + TextureType.RgbaDds => textureManager.SaveAs(CombinedTexture.TextureSaveType.Bitmap, mipMaps, false, inputFile, outputFile), + TextureType.Bc3Tex => textureManager.SaveAs(CombinedTexture.TextureSaveType.BC3, mipMaps, true, inputFile, outputFile), + TextureType.Bc3Dds => textureManager.SaveAs(CombinedTexture.TextureSaveType.BC3, mipMaps, false, inputFile, outputFile), + TextureType.Bc7Tex => textureManager.SaveAs(CombinedTexture.TextureSaveType.BC7, mipMaps, true, inputFile, outputFile), + TextureType.Bc7Dds => textureManager.SaveAs(CombinedTexture.TextureSaveType.BC7, mipMaps, false, inputFile, outputFile), + TextureType.Bc1Tex => textureManager.SaveAs(CombinedTexture.TextureSaveType.BC1, mipMaps, true, inputFile, outputFile), + TextureType.Bc1Dds => textureManager.SaveAs(CombinedTexture.TextureSaveType.BC1, mipMaps, false, inputFile, outputFile), + TextureType.Bc4Tex => textureManager.SaveAs(CombinedTexture.TextureSaveType.BC4, mipMaps, true, inputFile, outputFile), + TextureType.Bc4Dds => textureManager.SaveAs(CombinedTexture.TextureSaveType.BC4, mipMaps, false, inputFile, outputFile), + TextureType.Bc5Tex => textureManager.SaveAs(CombinedTexture.TextureSaveType.BC5, mipMaps, true, inputFile, outputFile), + TextureType.Bc5Dds => textureManager.SaveAs(CombinedTexture.TextureSaveType.BC5, mipMaps, false, inputFile, outputFile), + _ => Task.FromException(new Exception($"Invalid input value {textureType}.")), + }; + + // @formatter:off + public Task ConvertTextureData(byte[] rgbaData, int width, string outputFile, TextureType textureType, bool mipMaps) + => textureType switch + { + TextureType.Png => textureManager.SavePng(new BaseImage(), outputFile, rgbaData, width, rgbaData.Length / 4 / width), + TextureType.Targa => textureManager.SaveTga(new BaseImage(), outputFile, rgbaData, width, rgbaData.Length / 4 / width), + TextureType.AsIsTex => textureManager.SaveAs(CombinedTexture.TextureSaveType.AsIs, mipMaps, true, new BaseImage(), outputFile, rgbaData, width, rgbaData.Length / 4 / width), + TextureType.AsIsDds => textureManager.SaveAs(CombinedTexture.TextureSaveType.AsIs, mipMaps, false, new BaseImage(), outputFile, rgbaData, width, rgbaData.Length / 4 / width), + TextureType.RgbaTex => textureManager.SaveAs(CombinedTexture.TextureSaveType.Bitmap, mipMaps, true, new BaseImage(), outputFile, rgbaData, width, rgbaData.Length / 4 / width), + TextureType.RgbaDds => textureManager.SaveAs(CombinedTexture.TextureSaveType.Bitmap, mipMaps, false, new BaseImage(), outputFile, rgbaData, width, rgbaData.Length / 4 / width), + TextureType.Bc3Tex => textureManager.SaveAs(CombinedTexture.TextureSaveType.BC3, mipMaps, true, new BaseImage(), outputFile, rgbaData, width, rgbaData.Length / 4 / width), + TextureType.Bc3Dds => textureManager.SaveAs(CombinedTexture.TextureSaveType.BC3, mipMaps, false, new BaseImage(), outputFile, rgbaData, width, rgbaData.Length / 4 / width), + TextureType.Bc7Tex => textureManager.SaveAs(CombinedTexture.TextureSaveType.BC7, mipMaps, true, new BaseImage(), outputFile, rgbaData, width, rgbaData.Length / 4 / width), + TextureType.Bc7Dds => textureManager.SaveAs(CombinedTexture.TextureSaveType.BC7, mipMaps, false, new BaseImage(), outputFile, rgbaData, width, rgbaData.Length / 4 / width), + TextureType.Bc1Tex => textureManager.SaveAs(CombinedTexture.TextureSaveType.BC1, mipMaps, true, new BaseImage(), outputFile, rgbaData, width, rgbaData.Length / 4 / width), + TextureType.Bc1Dds => textureManager.SaveAs(CombinedTexture.TextureSaveType.BC1, mipMaps, false, new BaseImage(), outputFile, rgbaData, width, rgbaData.Length / 4 / width), + TextureType.Bc4Tex => textureManager.SaveAs(CombinedTexture.TextureSaveType.BC4, mipMaps, true, new BaseImage(), outputFile, rgbaData, width, rgbaData.Length / 4 / width), + TextureType.Bc4Dds => textureManager.SaveAs(CombinedTexture.TextureSaveType.BC4, mipMaps, false, new BaseImage(), outputFile, rgbaData, width, rgbaData.Length / 4 / width), + TextureType.Bc5Tex => textureManager.SaveAs(CombinedTexture.TextureSaveType.BC5, mipMaps, true, new BaseImage(), outputFile, rgbaData, width, rgbaData.Length / 4 / width), + TextureType.Bc5Dds => textureManager.SaveAs(CombinedTexture.TextureSaveType.BC5, mipMaps, false, new BaseImage(), outputFile, rgbaData, width, rgbaData.Length / 4 / width), + _ => Task.FromException(new Exception($"Invalid input value {textureType}.")), + }; + // @formatter:on +} diff --git a/Penumbra/Api/Api/GameStateApi.cs b/Penumbra/Api/Api/GameStateApi.cs new file mode 100644 index 00000000..74cde3a0 --- /dev/null +++ b/Penumbra/Api/Api/GameStateApi.cs @@ -0,0 +1,123 @@ +using FFXIVClientStructs.FFXIV.Client.Graphics.Scene; +using OtterGui.Services; +using Penumbra.Api.Enums; +using Penumbra.Collections; +using Penumbra.Interop.Hooks.ResourceLoading; +using Penumbra.Interop.PathResolving; +using Penumbra.Interop.Structs; +using Penumbra.Services; +using Penumbra.String.Classes; + +namespace Penumbra.Api.Api; + +public class GameStateApi : IPenumbraApiGameState, IApiService, IDisposable +{ + private readonly CommunicatorService _communicator; + private readonly CollectionResolver _collectionResolver; + private readonly DrawObjectState _drawObjectState; + private readonly CutsceneService _cutsceneService; + private readonly ResourceLoader _resourceLoader; + + public unsafe GameStateApi(CommunicatorService communicator, CollectionResolver collectionResolver, CutsceneService cutsceneService, + ResourceLoader resourceLoader, DrawObjectState drawObjectState) + { + _communicator = communicator; + _collectionResolver = collectionResolver; + _cutsceneService = cutsceneService; + _resourceLoader = resourceLoader; + _drawObjectState = drawObjectState; + _resourceLoader.ResourceLoaded += OnResourceLoaded; + _resourceLoader.PapRequested += OnPapRequested; + _communicator.CreatedCharacterBase.Subscribe(OnCreatedCharacterBase, Communication.CreatedCharacterBase.Priority.Api); + } + + public unsafe void Dispose() + { + _resourceLoader.ResourceLoaded -= OnResourceLoaded; + _resourceLoader.PapRequested -= OnPapRequested; + _communicator.CreatedCharacterBase.Unsubscribe(OnCreatedCharacterBase); + } + + public event CreatedCharacterBaseDelegate? CreatedCharacterBase; + public event GameObjectResourceResolvedDelegate? GameObjectResourceResolved; + + public event CreatingCharacterBaseDelegate? CreatingCharacterBase + { + add + { + if (value == null) + return; + + _communicator.CreatingCharacterBase.Subscribe(new Action(value), + Communication.CreatingCharacterBase.Priority.Api); + } + remove + { + if (value == null) + return; + + _communicator.CreatingCharacterBase.Unsubscribe(new Action(value)); + } + } + + public unsafe (nint GameObject, (Guid Id, string Name) Collection) GetDrawObjectInfo(nint drawObject) + { + var data = _collectionResolver.IdentifyCollection((DrawObject*)drawObject, true); + return (data.AssociatedGameObject, (Id: data.ModCollection.Identity.Id, Name: data.ModCollection.Identity.Name)); + } + + public int GetCutsceneParentIndex(int actorIdx) + => _cutsceneService.GetParentIndex(actorIdx); + + public Func GetCutsceneParentIndexFunc() + { + var weakRef = new WeakReference(_cutsceneService); + return idx => + { + if (!weakRef.TryGetTarget(out var c)) + throw new ObjectDisposedException("The underlying cutscene state storage of this IPC container was disposed."); + + return c.GetParentIndex(idx); + }; + } + + public Func GetGameObjectFromDrawObjectFunc() + { + var weakRef = new WeakReference(_drawObjectState); + return model => + { + if (!weakRef.TryGetTarget(out var c)) + throw new ObjectDisposedException("The underlying draw object state storage of this IPC container was disposed."); + + return c.TryGetValue(model, out var data) ? data.Item1.Address : nint.Zero; + }; + } + + public PenumbraApiEc SetCutsceneParentIndex(int copyIdx, int newParentIdx) + => _cutsceneService.SetParentIndex(copyIdx, newParentIdx) + ? PenumbraApiEc.Success + : PenumbraApiEc.InvalidArgument; + + private unsafe void OnResourceLoaded(ResourceHandle* handle, Utf8GamePath originalPath, FullPath? manipulatedPath, ResolveData resolveData) + { + if (resolveData.AssociatedGameObject != nint.Zero && GameObjectResourceResolved != null) + { + var original = originalPath.ToString(); + GameObjectResourceResolved.Invoke(resolveData.AssociatedGameObject, original, + manipulatedPath?.ToString() ?? original); + } + } + + private void OnPapRequested(Utf8GamePath originalPath, FullPath? manipulatedPath, ResolveData resolveData) + { + if (resolveData.AssociatedGameObject != nint.Zero && GameObjectResourceResolved != null) + { + var original = originalPath.ToString(); + GameObjectResourceResolved.Invoke(resolveData.AssociatedGameObject, original, + manipulatedPath?.ToString() ?? original); + } + } + + private void OnCreatedCharacterBase(nint gameObject, ModCollection collection, nint drawObject) + => CreatedCharacterBase?.Invoke(gameObject, collection.Identity.Id, drawObject); +} diff --git a/Penumbra/Api/Api/IdentityChecker.cs b/Penumbra/Api/Api/IdentityChecker.cs new file mode 100644 index 00000000..e090053e --- /dev/null +++ b/Penumbra/Api/Api/IdentityChecker.cs @@ -0,0 +1,7 @@ +namespace Penumbra.Api.Api; + +public static class IdentityChecker +{ + public static bool Check(string identity) + => true; +} diff --git a/Penumbra/Api/Api/MetaApi.cs b/Penumbra/Api/Api/MetaApi.cs new file mode 100644 index 00000000..5cffc811 --- /dev/null +++ b/Penumbra/Api/Api/MetaApi.cs @@ -0,0 +1,544 @@ +using Dalamud.Plugin.Services; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using OtterGui; +using OtterGui.Services; +using Penumbra.Collections; +using Penumbra.Collections.Cache; +using Penumbra.GameData.Files.AtchStructs; +using Penumbra.GameData.Files.Utility; +using Penumbra.GameData.Structs; +using Penumbra.Interop.PathResolving; +using Penumbra.Meta.Manipulations; + +namespace Penumbra.Api.Api; + +public class MetaApi(IFramework framework, CollectionResolver collectionResolver, ApiHelpers helpers) + : IPenumbraApiMeta, IApiService +{ + public string GetPlayerMetaManipulations() + { + var collection = collectionResolver.PlayerCollection(); + return CompressMetaManipulations(collection); + } + + public string GetMetaManipulations(int gameObjectIdx) + { + helpers.AssociatedCollection(gameObjectIdx, out var collection); + return CompressMetaManipulations(collection); + } + + public Task GetPlayerMetaManipulationsAsync() + { + return Task.Run(async () => + { + var playerCollection = await framework.RunOnFrameworkThread(collectionResolver.PlayerCollection).ConfigureAwait(false); + return CompressMetaManipulations(playerCollection); + }); + } + + public Task GetMetaManipulationsAsync(int gameObjectIdx) + { + return Task.Run(async () => + { + var playerCollection = await framework.RunOnFrameworkThread(() => + { + helpers.AssociatedCollection(gameObjectIdx, out var collection); + return collection; + }).ConfigureAwait(false); + return CompressMetaManipulations(playerCollection); + }); + } + + internal static string CompressMetaManipulations(ModCollection collection) + => CompressMetaManipulationsV1(collection); + + private static string CompressMetaManipulationsV0(ModCollection collection) + { + var array = new JArray(); + if (collection.MetaCache is { } cache) + { + MetaDictionary.SerializeTo(array, cache.GlobalEqp.Select(kvp => kvp.Key)); + MetaDictionary.SerializeTo(array, cache.Imc.Select(kvp => new KeyValuePair(kvp.Key, kvp.Value.Entry))); + MetaDictionary.SerializeTo(array, cache.Eqp.Select(kvp => new KeyValuePair(kvp.Key, kvp.Value.Entry))); + MetaDictionary.SerializeTo(array, cache.Eqdp.Select(kvp => new KeyValuePair(kvp.Key, kvp.Value.Entry))); + MetaDictionary.SerializeTo(array, cache.Est.Select(kvp => new KeyValuePair(kvp.Key, kvp.Value.Entry))); + MetaDictionary.SerializeTo(array, cache.Rsp.Select(kvp => new KeyValuePair(kvp.Key, kvp.Value.Entry))); + MetaDictionary.SerializeTo(array, cache.Gmp.Select(kvp => new KeyValuePair(kvp.Key, kvp.Value.Entry))); + MetaDictionary.SerializeTo(array, cache.Atch.Select(kvp => new KeyValuePair(kvp.Key, kvp.Value.Entry))); + MetaDictionary.SerializeTo(array, cache.Shp.Select(kvp => new KeyValuePair(kvp.Key, kvp.Value.Entry))); + MetaDictionary.SerializeTo(array, cache.Atr.Select(kvp => new KeyValuePair(kvp.Key, kvp.Value.Entry))); + } + + return Functions.ToCompressedBase64(array, 0); + } + + private static unsafe string CompressMetaManipulationsV1(ModCollection? collection) + { + using var ms = new MemoryStream(); + ms.Capacity = 1024; + using (var zipStream = new GZipStream(ms, CompressionMode.Compress, true)) + { + zipStream.Write((byte)1); + zipStream.Write("META0001"u8); + if (collection?.MetaCache is not { } cache) + { + zipStream.Write(0); + zipStream.Write(0); + zipStream.Write(0); + zipStream.Write(0); + zipStream.Write(0); + zipStream.Write(0); + zipStream.Write(0); + } + else + { + WriteCache(zipStream, cache.Imc); + WriteCache(zipStream, cache.Eqp); + WriteCache(zipStream, cache.Eqdp); + WriteCache(zipStream, cache.Est); + WriteCache(zipStream, cache.Rsp); + WriteCache(zipStream, cache.Gmp); + cache.GlobalEqp.EnterReadLock(); + + try + { + zipStream.Write(cache.GlobalEqp.Count); + foreach (var (globalEqp, _) in cache.GlobalEqp) + zipStream.Write(new ReadOnlySpan(&globalEqp, sizeof(GlobalEqpManipulation))); + } + finally + { + cache.GlobalEqp.ExitReadLock(); + } + + WriteCache(zipStream, cache.Atch); + WriteCache(zipStream, cache.Shp); + WriteCache(zipStream, cache.Atr); + } + } + + ms.Flush(); + ms.Position = 0; + var data = ms.GetBuffer().AsSpan(0, (int)ms.Length); + return Convert.ToBase64String(data); + + void WriteCache(Stream stream, MetaCacheBase metaCache) + where TKey : unmanaged, IMetaIdentifier + where TValue : unmanaged + { + metaCache.EnterReadLock(); + try + { + stream.Write(metaCache.Count); + foreach (var (identifier, (_, value)) in metaCache) + { + stream.Write(identifier); + stream.Write(value); + } + } + finally + { + metaCache.ExitReadLock(); + } + } + } + + public const uint ImcKey = ((uint)'I' << 24) | ((uint)'M' << 16) | ((uint)'C' << 8); + public const uint EqpKey = ((uint)'E' << 24) | ((uint)'Q' << 16) | ((uint)'P' << 8); + public const uint EqdpKey = ((uint)'E' << 24) | ((uint)'Q' << 16) | ((uint)'D' << 8) | 'P'; + public const uint EstKey = ((uint)'E' << 24) | ((uint)'S' << 16) | ((uint)'T' << 8); + public const uint RspKey = ((uint)'R' << 24) | ((uint)'S' << 16) | ((uint)'P' << 8); + public const uint GmpKey = ((uint)'G' << 24) | ((uint)'M' << 16) | ((uint)'P' << 8); + public const uint GeqpKey = ((uint)'G' << 24) | ((uint)'E' << 16) | ((uint)'Q' << 8) | 'P'; + public const uint AtchKey = ((uint)'A' << 24) | ((uint)'T' << 16) | ((uint)'C' << 8) | 'H'; + public const uint ShpKey = ((uint)'S' << 24) | ((uint)'H' << 16) | ((uint)'P' << 8); + public const uint AtrKey = ((uint)'A' << 24) | ((uint)'T' << 16) | ((uint)'R' << 8); + + private static unsafe string CompressMetaManipulationsV2(ModCollection? collection) + { + using var ms = new MemoryStream(); + ms.Capacity = 1024; + using (var zipStream = new GZipStream(ms, CompressionMode.Compress, true)) + { + zipStream.Write((byte)2); + zipStream.Write("META0002"u8); + if (collection?.MetaCache is { } cache) + { + WriteCache(zipStream, cache.Imc, ImcKey); + WriteCache(zipStream, cache.Eqp, EqpKey); + WriteCache(zipStream, cache.Eqdp, EqdpKey); + WriteCache(zipStream, cache.Est, EstKey); + WriteCache(zipStream, cache.Rsp, RspKey); + WriteCache(zipStream, cache.Gmp, GmpKey); + cache.GlobalEqp.EnterReadLock(); + + try + { + if (cache.GlobalEqp.Count > 0) + { + zipStream.Write(GeqpKey); + zipStream.Write(cache.GlobalEqp.Count); + foreach (var (globalEqp, _) in cache.GlobalEqp) + zipStream.Write(new ReadOnlySpan(&globalEqp, sizeof(GlobalEqpManipulation))); + } + } + finally + { + cache.GlobalEqp.ExitReadLock(); + } + + WriteCache(zipStream, cache.Atch, AtchKey); + WriteCache(zipStream, cache.Shp, ShpKey); + WriteCache(zipStream, cache.Atr, AtrKey); + } + } + + ms.Flush(); + ms.Position = 0; + var data = ms.GetBuffer().AsSpan(0, (int)ms.Length); + return Convert.ToBase64String(data); + + void WriteCache(Stream stream, MetaCacheBase metaCache, uint label) + where TKey : unmanaged, IMetaIdentifier + where TValue : unmanaged + { + metaCache.EnterReadLock(); + try + { + if (metaCache.Count <= 0) + return; + + stream.Write(label); + stream.Write(metaCache.Count); + foreach (var (identifier, (_, value)) in metaCache) + { + stream.Write(identifier); + stream.Write(value); + } + } + finally + { + metaCache.ExitReadLock(); + } + } + } + + /// + /// Convert manipulations from a transmitted base64 string to actual manipulations. + /// The empty string is treated as an empty set. + /// Only returns true if all conversions are successful and distinct. + /// + internal static bool ConvertManips(string manipString, [NotNullWhen(true)] out MetaDictionary? manips, out byte version) + { + if (manipString.Length == 0) + { + manips = new MetaDictionary(); + version = byte.MaxValue; + return true; + } + + try + { + var bytes = Convert.FromBase64String(manipString); + using var compressedStream = new MemoryStream(bytes); + using var zipStream = new GZipStream(compressedStream, CompressionMode.Decompress); + using var resultStream = new MemoryStream(); + zipStream.CopyTo(resultStream); + resultStream.Flush(); + resultStream.Position = 0; + var data = resultStream.GetBuffer().AsSpan(0, (int)resultStream.Length); + version = data[0]; + data = data[1..]; + switch (version) + { + case 0: return ConvertManipsV0(data, out manips); + case 1: return ConvertManipsV1(data, out manips); + case 2: return ConvertManipsV2(data, out manips); + default: + Penumbra.Log.Debug($"Invalid version for manipulations: {version}."); + manips = null; + return false; + } + } + catch (Exception ex) + { + Penumbra.Log.Debug($"Error decompressing manipulations:\n{ex}"); + manips = null; + version = byte.MaxValue; + return false; + } + } + + private static bool ConvertManipsV2(ReadOnlySpan data, [NotNullWhen(true)] out MetaDictionary? manips) + { + if (!data.StartsWith("META0002"u8)) + { + Penumbra.Log.Debug("Invalid manipulations of version 2, does not start with valid prefix."); + manips = null; + return false; + } + + manips = new MetaDictionary(); + var r = new SpanBinaryReader(data[8..]); + while (r.Remaining > 4) + { + var prefix = r.ReadUInt32(); + var count = r.Remaining > 4 ? r.ReadInt32() : 0; + if (count is 0) + continue; + + switch (prefix) + { + case ImcKey: + for (var i = 0; i < count; ++i) + { + var identifier = r.Read(); + var value = r.Read(); + if (!identifier.Validate() || !manips.TryAdd(identifier, value)) + return false; + } + + break; + case EqpKey: + for (var i = 0; i < count; ++i) + { + var identifier = r.Read(); + var value = r.Read(); + if (!identifier.Validate() || !manips.TryAdd(identifier, value)) + return false; + } + + break; + case EqdpKey: + for (var i = 0; i < count; ++i) + { + var identifier = r.Read(); + var value = r.Read(); + if (!identifier.Validate() || !manips.TryAdd(identifier, value)) + return false; + } + + break; + case EstKey: + for (var i = 0; i < count; ++i) + { + var identifier = r.Read(); + var value = r.Read(); + if (!identifier.Validate() || !manips.TryAdd(identifier, value)) + return false; + } + + break; + case RspKey: + for (var i = 0; i < count; ++i) + { + var identifier = r.Read(); + var value = r.Read(); + if (!identifier.Validate() || !manips.TryAdd(identifier, value)) + return false; + } + + break; + case GmpKey: + for (var i = 0; i < count; ++i) + { + var identifier = r.Read(); + var value = r.Read(); + if (!identifier.Validate() || !manips.TryAdd(identifier, value)) + return false; + } + + break; + case GeqpKey: + for (var i = 0; i < count; ++i) + { + var identifier = r.Read(); + if (!identifier.Validate() || !manips.TryAdd(identifier)) + return false; + } + + break; + case AtchKey: + for (var i = 0; i < count; ++i) + { + var identifier = r.Read(); + var value = r.Read(); + if (!identifier.Validate() || !manips.TryAdd(identifier, value)) + return false; + } + + break; + case ShpKey: + for (var i = 0; i < count; ++i) + { + var identifier = r.Read(); + var value = r.Read(); + if (!identifier.Validate() || !manips.TryAdd(identifier, value)) + return false; + } + + break; + case AtrKey: + for (var i = 0; i < count; ++i) + { + var identifier = r.Read(); + var value = r.Read(); + if (!identifier.Validate() || !manips.TryAdd(identifier, value)) + return false; + } + + break; + } + } + + return true; + } + + private static bool ConvertManipsV1(ReadOnlySpan data, [NotNullWhen(true)] out MetaDictionary? manips) + { + if (!data.StartsWith("META0001"u8)) + { + Penumbra.Log.Debug($"Invalid manipulations of version 1, does not start with valid prefix."); + manips = null; + return false; + } + + manips = new MetaDictionary(); + var r = new SpanBinaryReader(data[8..]); + var imcCount = r.ReadInt32(); + for (var i = 0; i < imcCount; ++i) + { + var identifier = r.Read(); + var value = r.Read(); + if (!identifier.Validate() || !manips.TryAdd(identifier, value)) + return false; + } + + var eqpCount = r.ReadInt32(); + for (var i = 0; i < eqpCount; ++i) + { + var identifier = r.Read(); + var value = r.Read(); + if (!identifier.Validate() || !manips.TryAdd(identifier, value)) + return false; + } + + var eqdpCount = r.ReadInt32(); + for (var i = 0; i < eqdpCount; ++i) + { + var identifier = r.Read(); + var value = r.Read(); + if (!identifier.Validate() || !manips.TryAdd(identifier, value)) + return false; + } + + var estCount = r.ReadInt32(); + for (var i = 0; i < estCount; ++i) + { + var identifier = r.Read(); + var value = r.Read(); + if (!identifier.Validate() || !manips.TryAdd(identifier, value)) + return false; + } + + var rspCount = r.ReadInt32(); + for (var i = 0; i < rspCount; ++i) + { + var identifier = r.Read(); + var value = r.Read(); + if (!identifier.Validate() || !manips.TryAdd(identifier, value)) + return false; + } + + var gmpCount = r.ReadInt32(); + for (var i = 0; i < gmpCount; ++i) + { + var identifier = r.Read(); + var value = r.Read(); + if (!identifier.Validate() || !manips.TryAdd(identifier, value)) + return false; + } + + var globalEqpCount = r.ReadInt32(); + for (var i = 0; i < globalEqpCount; ++i) + { + var manip = r.Read(); + if (!manip.Validate() || !manips.TryAdd(manip)) + return false; + } + + // Atch was added after there were already some V1 around, so check for size here. + if (r.Position < r.Count) + { + var atchCount = r.ReadInt32(); + for (var i = 0; i < atchCount; ++i) + { + var identifier = r.Read(); + var value = r.Read(); + if (!identifier.Validate() || !manips.TryAdd(identifier, value)) + return false; + } + + // Shp and Atr was added later + if (r.Position < r.Count) + { + var shpCount = r.ReadInt32(); + for (var i = 0; i < shpCount; ++i) + { + var identifier = r.Read(); + var value = r.Read(); + if (!identifier.Validate() || !manips.TryAdd(identifier, value)) + return false; + } + + var atrCount = r.ReadInt32(); + for (var i = 0; i < atrCount; ++i) + { + var identifier = r.Read(); + var value = r.Read(); + if (!identifier.Validate() || !manips.TryAdd(identifier, value)) + return false; + } + } + } + + return true; + } + + private static bool ConvertManipsV0(ReadOnlySpan data, [NotNullWhen(true)] out MetaDictionary? manips) + { + var json = Encoding.UTF8.GetString(data); + manips = JsonConvert.DeserializeObject(json); + return manips != null; + } + + internal void TestMetaManipulations() + { + var collection = collectionResolver.PlayerCollection(); + var dict = new MetaDictionary(collection.MetaCache); + var count = dict.Count; + + var watch = Stopwatch.StartNew(); + var v0 = CompressMetaManipulationsV0(collection); + var v0Time = watch.ElapsedMilliseconds; + + watch.Restart(); + var v1 = CompressMetaManipulationsV1(collection); + var v1Time = watch.ElapsedMilliseconds; + + watch.Restart(); + var v1Success = ConvertManips(v1, out var v1Roundtrip, out _); + var v1RoundtripTime = watch.ElapsedMilliseconds; + + watch.Restart(); + var v0Success = ConvertManips(v0, out var v0Roundtrip, out _); + var v0RoundtripTime = watch.ElapsedMilliseconds; + + Penumbra.Log.Information($"Version | Count | Time | Length | Success | ReCount | ReTime | Equal"); + Penumbra.Log.Information( + $"0 | {count} | {v0Time} | {v0.Length} | {v0Success} | {v0Roundtrip?.Count} | {v0RoundtripTime} | {v0Roundtrip?.Equals(dict)}"); + Penumbra.Log.Information( + $"1 | {count} | {v1Time} | {v1.Length} | {v1Success} | {v1Roundtrip?.Count} | {v1RoundtripTime} | {v0Roundtrip?.Equals(dict)}"); + } +} diff --git a/Penumbra/Api/Api/ModSettingsApi.cs b/Penumbra/Api/Api/ModSettingsApi.cs new file mode 100644 index 00000000..d49c2904 --- /dev/null +++ b/Penumbra/Api/Api/ModSettingsApi.cs @@ -0,0 +1,362 @@ +using OtterGui.Extensions; +using OtterGui.Services; +using Penumbra.Api.Enums; +using Penumbra.Api.Helpers; +using Penumbra.Collections; +using Penumbra.Collections.Manager; +using Penumbra.Communication; +using Penumbra.Interop.PathResolving; +using Penumbra.Mods; +using Penumbra.Mods.Editor; +using Penumbra.Mods.Groups; +using Penumbra.Mods.Manager; +using Penumbra.Mods.Manager.OptionEditor; +using Penumbra.Mods.Settings; +using Penumbra.Mods.SubMods; +using Penumbra.Services; + +namespace Penumbra.Api.Api; + +public class ModSettingsApi : IPenumbraApiModSettings, IApiService, IDisposable +{ + private readonly CollectionResolver _collectionResolver; + private readonly ModManager _modManager; + private readonly CollectionManager _collectionManager; + private readonly CollectionEditor _collectionEditor; + private readonly CommunicatorService _communicator; + + public ModSettingsApi(CollectionResolver collectionResolver, + ModManager modManager, + CollectionManager collectionManager, + CollectionEditor collectionEditor, + CommunicatorService communicator) + { + _collectionResolver = collectionResolver; + _modManager = modManager; + _collectionManager = collectionManager; + _collectionEditor = collectionEditor; + _communicator = communicator; + _communicator.ModPathChanged.Subscribe(OnModPathChange, ModPathChanged.Priority.ApiModSettings); + _communicator.ModSettingChanged.Subscribe(OnModSettingChange, Communication.ModSettingChanged.Priority.Api); + _communicator.ModOptionChanged.Subscribe(OnModOptionEdited, ModOptionChanged.Priority.Api); + _communicator.ModFileChanged.Subscribe(OnModFileChanged, ModFileChanged.Priority.Api); + } + + public void Dispose() + { + _communicator.ModPathChanged.Unsubscribe(OnModPathChange); + _communicator.ModSettingChanged.Unsubscribe(OnModSettingChange); + _communicator.ModOptionChanged.Unsubscribe(OnModOptionEdited); + _communicator.ModFileChanged.Unsubscribe(OnModFileChanged); + } + + public event ModSettingChangedDelegate? ModSettingChanged; + + public AvailableModSettings? GetAvailableModSettings(string modDirectory, string modName) + { + if (!_modManager.TryGetMod(modDirectory, modName, out var mod)) + return null; + + var dict = new Dictionary(mod.Groups.Count); + foreach (var g in mod.Groups) + dict.Add(g.Name, (g.Options.Select(o => o.Name).ToArray(), (int)g.Type)); + return new AvailableModSettings(dict); + } + + public (PenumbraApiEc, (bool, int, Dictionary>, bool)?) GetCurrentModSettings(Guid collectionId, string modDirectory, + string modName, bool ignoreInheritance) + { + var ret = GetCurrentModSettingsWithTemp(collectionId, modDirectory, modName, ignoreInheritance, true, 0); + if (ret.Item2 is null) + return (ret.Item1, null); + + return (ret.Item1, (ret.Item2.Value.Item1, ret.Item2.Value.Item2, ret.Item2.Value.Item3, ret.Item2.Value.Item4)); + } + + public PenumbraApiEc GetSettingsInAllCollections(string modDirectory, string modName, + out Dictionary>, bool, bool)> settings, + bool ignoreTemporaryCollections = false) + { + settings = []; + if (!_modManager.TryGetMod(modDirectory, modName, out var mod)) + return PenumbraApiEc.ModMissing; + + var collections = ignoreTemporaryCollections + ? _collectionManager.Storage.Where(c => c != ModCollection.Empty) + : _collectionManager.Storage.Where(c => c != ModCollection.Empty).Concat(_collectionManager.Temp.Values); + settings = []; + foreach (var collection in collections) + { + if (GetCurrentSettings(collection, mod, false, false, 0) is { } s) + settings.Add(collection.Identity.Id, s); + } + + return PenumbraApiEc.Success; + } + + public (PenumbraApiEc, (bool, int, Dictionary>, bool, bool)?) GetCurrentModSettingsWithTemp(Guid collectionId, + string modDirectory, string modName, bool ignoreInheritance, bool ignoreTemporary, int key) + { + if (!_modManager.TryGetMod(modDirectory, modName, out var mod)) + return (PenumbraApiEc.ModMissing, null); + + if (!_collectionManager.Storage.ById(collectionId, out var collection)) + return (PenumbraApiEc.CollectionMissing, null); + + if (collection.Identity.Id == Guid.Empty) + return (PenumbraApiEc.Success, null); + + if (GetCurrentSettings(collection, mod, ignoreInheritance, ignoreTemporary, key) is { } settings) + return (PenumbraApiEc.Success, settings); + + return (PenumbraApiEc.Success, null); + } + + public (PenumbraApiEc, Dictionary>, bool, bool)>?) GetAllModSettings(Guid collectionId, + bool ignoreInheritance, bool ignoreTemporary, int key) + { + if (!_collectionManager.Storage.ById(collectionId, out var collection)) + return (PenumbraApiEc.CollectionMissing, null); + + if (collection.Identity.Id == Guid.Empty) + return (PenumbraApiEc.Success, []); + + var ret = new Dictionary>, bool, bool)>(_modManager.Count); + foreach (var mod in _modManager) + { + if (GetCurrentSettings(collection, mod, ignoreInheritance, ignoreTemporary, key) is { } settings) + ret[mod.Identifier] = settings; + } + + return (PenumbraApiEc.Success, ret); + } + + public PenumbraApiEc TryInheritMod(Guid collectionId, string modDirectory, string modName, bool inherit) + { + var args = ApiHelpers.Args("CollectionId", collectionId, "ModDirectory", modDirectory, "ModName", modName, "Inherit", + inherit.ToString()); + + if (collectionId == Guid.Empty) + return ApiHelpers.Return(PenumbraApiEc.InvalidArgument, args); + + if (!_collectionManager.Storage.ById(collectionId, out var collection)) + return ApiHelpers.Return(PenumbraApiEc.CollectionMissing, args); + + if (!_modManager.TryGetMod(modDirectory, modName, out var mod)) + return ApiHelpers.Return(PenumbraApiEc.ModMissing, args); + + var ret = _collectionEditor.SetModInheritance(collection, mod, inherit) + ? PenumbraApiEc.Success + : PenumbraApiEc.NothingChanged; + return ApiHelpers.Return(ret, args); + } + + public PenumbraApiEc TrySetMod(Guid collectionId, string modDirectory, string modName, bool enabled) + { + var args = ApiHelpers.Args("CollectionId", collectionId, "ModDirectory", modDirectory, "ModName", modName, "Enabled", enabled); + if (!_collectionManager.Storage.ById(collectionId, out var collection)) + return ApiHelpers.Return(PenumbraApiEc.CollectionMissing, args); + + if (!_modManager.TryGetMod(modDirectory, modName, out var mod)) + return ApiHelpers.Return(PenumbraApiEc.ModMissing, args); + + var ret = _collectionEditor.SetModState(collection, mod, enabled) + ? PenumbraApiEc.Success + : PenumbraApiEc.NothingChanged; + return ApiHelpers.Return(ret, args); + } + + public PenumbraApiEc TrySetModPriority(Guid collectionId, string modDirectory, string modName, int priority) + { + var args = ApiHelpers.Args("CollectionId", collectionId, "ModDirectory", modDirectory, "ModName", modName, "Priority", priority); + + if (!_collectionManager.Storage.ById(collectionId, out var collection)) + return ApiHelpers.Return(PenumbraApiEc.CollectionMissing, args); + + if (!_modManager.TryGetMod(modDirectory, modName, out var mod)) + return ApiHelpers.Return(PenumbraApiEc.ModMissing, args); + + var ret = _collectionEditor.SetModPriority(collection, mod, new ModPriority(priority)) + ? PenumbraApiEc.Success + : PenumbraApiEc.NothingChanged; + return ApiHelpers.Return(ret, args); + } + + public PenumbraApiEc TrySetModSetting(Guid collectionId, string modDirectory, string modName, string optionGroupName, string optionName) + { + var args = ApiHelpers.Args("CollectionId", collectionId, "ModDirectory", modDirectory, "ModName", modName, "OptionGroupName", + optionGroupName, "OptionName", optionName); + + if (!_collectionManager.Storage.ById(collectionId, out var collection)) + return ApiHelpers.Return(PenumbraApiEc.CollectionMissing, args); + + if (!_modManager.TryGetMod(modDirectory, modName, out var mod)) + return ApiHelpers.Return(PenumbraApiEc.ModMissing, args); + + var groupIdx = mod.Groups.IndexOf(g => g.Name == optionGroupName); + if (groupIdx < 0) + return ApiHelpers.Return(PenumbraApiEc.OptionGroupMissing, args); + + var optionIdx = mod.Groups[groupIdx].Options.IndexOf(o => o.Name == optionName); + if (optionIdx < 0) + return ApiHelpers.Return(PenumbraApiEc.OptionMissing, args); + + var setting = mod.Groups[groupIdx].Behaviour switch + { + GroupDrawBehaviour.MultiSelection => Setting.Multi(optionIdx), + GroupDrawBehaviour.SingleSelection => Setting.Single(optionIdx), + _ => Setting.Zero, + }; + var ret = _collectionEditor.SetModSetting(collection, mod, groupIdx, setting) + ? PenumbraApiEc.Success + : PenumbraApiEc.NothingChanged; + return ApiHelpers.Return(ret, args); + } + + public PenumbraApiEc TrySetModSettings(Guid collectionId, string modDirectory, string modName, string optionGroupName, + IReadOnlyList optionNames) + { + var args = ApiHelpers.Args("CollectionId", collectionId, "ModDirectory", modDirectory, "ModName", modName, "OptionGroupName", + optionGroupName, "#optionNames", optionNames.Count); + + if (!_collectionManager.Storage.ById(collectionId, out var collection)) + return ApiHelpers.Return(PenumbraApiEc.CollectionMissing, args); + + if (!_modManager.TryGetMod(modDirectory, modName, out var mod)) + return ApiHelpers.Return(PenumbraApiEc.ModMissing, args); + + var settingSuccess = ConvertModSetting(mod, optionGroupName, optionNames, out var groupIdx, out var setting); + if (settingSuccess is not PenumbraApiEc.Success) + return ApiHelpers.Return(settingSuccess, args); + + var ret = _collectionEditor.SetModSetting(collection, mod, groupIdx, setting) + ? PenumbraApiEc.Success + : PenumbraApiEc.NothingChanged; + return ApiHelpers.Return(ret, args); + } + + public PenumbraApiEc CopyModSettings(Guid? collectionId, string modDirectoryFrom, string modDirectoryTo) + { + var args = ApiHelpers.Args("CollectionId", collectionId.HasValue ? collectionId.Value.ToString() : "NULL", + "From", modDirectoryFrom, "To", modDirectoryTo); + var sourceMod = _modManager.FirstOrDefault(m => string.Equals(m.ModPath.Name, modDirectoryFrom, StringComparison.OrdinalIgnoreCase)); + var targetMod = _modManager.FirstOrDefault(m => string.Equals(m.ModPath.Name, modDirectoryTo, StringComparison.OrdinalIgnoreCase)); + if (collectionId == null) + foreach (var collection in _collectionManager.Storage) + _collectionEditor.CopyModSettings(collection, sourceMod, modDirectoryFrom, targetMod, modDirectoryTo); + else if (_collectionManager.Storage.ById(collectionId.Value, out var collection)) + _collectionEditor.CopyModSettings(collection, sourceMod, modDirectoryFrom, targetMod, modDirectoryTo); + else + return ApiHelpers.Return(PenumbraApiEc.CollectionMissing, args); + + return ApiHelpers.Return(PenumbraApiEc.Success, args); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] + private (bool, int, Dictionary>, bool, bool)? GetCurrentSettings(ModCollection collection, Mod mod, + bool ignoreInheritance, bool ignoreTemporary, int key) + { + var settings = collection.Settings.Settings[mod.Index]; + if (!ignoreTemporary && settings.TempSettings is { } tempSettings && (tempSettings.Lock <= 0 || tempSettings.Lock == key)) + { + if (!tempSettings.ForceInherit) + return (tempSettings.Enabled, tempSettings.Priority.Value, tempSettings.ConvertToShareable(mod).Settings, + false, true); + if (!ignoreInheritance && collection.GetActualSettings(mod.Index).Settings is { } actualSettingsTemp) + return (actualSettingsTemp.Enabled, actualSettingsTemp.Priority.Value, + actualSettingsTemp.ConvertToShareable(mod).Settings, true, true); + } + + if (settings.Settings is { } ownSettings) + return (ownSettings.Enabled, ownSettings.Priority.Value, ownSettings.ConvertToShareable(mod).Settings, false, + false); + if (!ignoreInheritance && collection.GetInheritedSettings(mod.Index).Settings is { } actualSettings) + return (actualSettings.Enabled, actualSettings.Priority.Value, + actualSettings.ConvertToShareable(mod).Settings, true, false); + + return null; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] + private void TriggerSettingEdited(Mod mod) + { + var collection = _collectionResolver.PlayerCollection(); + var (settings, parent) = collection.GetActualSettings(mod.Index); + if (settings is { Enabled: true }) + ModSettingChanged?.Invoke(ModSettingChange.Edited, collection.Identity.Id, mod.Identifier, parent != collection); + } + + private void OnModPathChange(ModPathChangeType type, Mod mod, DirectoryInfo? _1, DirectoryInfo? _2) + { + if (type == ModPathChangeType.Reloaded) + TriggerSettingEdited(mod); + } + + private void OnModSettingChange(ModCollection collection, ModSettingChange type, Mod? mod, Setting _1, int _2, bool inherited) + => ModSettingChanged?.Invoke(type, collection.Identity.Id, mod?.ModPath.Name ?? string.Empty, inherited); + + private void OnModOptionEdited(ModOptionChangeType type, Mod mod, IModGroup? group, IModOption? option, IModDataContainer? container, + int moveIndex) + { + switch (type) + { + case ModOptionChangeType.GroupDeleted: + case ModOptionChangeType.GroupMoved: + case ModOptionChangeType.GroupTypeChanged: + case ModOptionChangeType.PriorityChanged: + case ModOptionChangeType.OptionDeleted: + case ModOptionChangeType.OptionMoved: + case ModOptionChangeType.OptionFilesChanged: + case ModOptionChangeType.OptionFilesAdded: + case ModOptionChangeType.OptionSwapsChanged: + case ModOptionChangeType.OptionMetaChanged: + TriggerSettingEdited(mod); + break; + } + } + + private void OnModFileChanged(Mod mod, FileRegistry file) + { + if (file.CurrentUsage == 0) + return; + + TriggerSettingEdited(mod); + } + + public static PenumbraApiEc ConvertModSetting(Mod mod, string groupName, IReadOnlyList optionNames, out int groupIndex, + out Setting setting) + { + groupIndex = mod.Groups.IndexOf(g => g.Name.Equals(groupName, StringComparison.OrdinalIgnoreCase)); + setting = Setting.Zero; + if (groupIndex < 0) + return PenumbraApiEc.OptionGroupMissing; + + switch (mod.Groups[groupIndex]) + { + case { Behaviour: GroupDrawBehaviour.SingleSelection } single: + { + var optionIdx = optionNames.Count == 0 ? -1 : single.Options.IndexOf(o => o.Name == optionNames[^1]); + if (optionIdx < 0) + return PenumbraApiEc.OptionMissing; + + setting = Setting.Single(optionIdx); + break; + } + case { Behaviour: GroupDrawBehaviour.MultiSelection } multi: + { + foreach (var name in optionNames) + { + var optionIdx = multi.Options.IndexOf(o => o.Name == name); + if (optionIdx < 0) + return PenumbraApiEc.OptionMissing; + + setting |= Setting.Multi(optionIdx); + } + + break; + } + } + + return PenumbraApiEc.Success; + } +} diff --git a/Penumbra/Api/Api/ModsApi.cs b/Penumbra/Api/Api/ModsApi.cs new file mode 100644 index 00000000..1f4f1cf4 --- /dev/null +++ b/Penumbra/Api/Api/ModsApi.cs @@ -0,0 +1,165 @@ +using Newtonsoft.Json.Linq; +using OtterGui.Compression; +using OtterGui.Services; +using Penumbra.Api.Enums; +using Penumbra.Communication; +using Penumbra.Mods; +using Penumbra.Mods.Manager; +using Penumbra.Services; + +namespace Penumbra.Api.Api; + +public class ModsApi : IPenumbraApiMods, IApiService, IDisposable +{ + private readonly CommunicatorService _communicator; + private readonly ModManager _modManager; + private readonly ModImportManager _modImportManager; + private readonly Configuration _config; + private readonly ModFileSystem _modFileSystem; + private readonly MigrationManager _migrationManager; + + public ModsApi(ModManager modManager, ModImportManager modImportManager, Configuration config, ModFileSystem modFileSystem, + CommunicatorService communicator, MigrationManager migrationManager) + { + _modManager = modManager; + _modImportManager = modImportManager; + _config = config; + _modFileSystem = modFileSystem; + _communicator = communicator; + _migrationManager = migrationManager; + _communicator.ModPathChanged.Subscribe(OnModPathChanged, ModPathChanged.Priority.ApiMods); + } + + private void OnModPathChanged(ModPathChangeType type, Mod mod, DirectoryInfo? oldDirectory, DirectoryInfo? newDirectory) + { + switch (type) + { + case ModPathChangeType.Deleted when oldDirectory != null: ModDeleted?.Invoke(oldDirectory.Name); break; + case ModPathChangeType.Added when newDirectory != null: ModAdded?.Invoke(newDirectory.Name); break; + case ModPathChangeType.Moved when newDirectory != null && oldDirectory != null: + ModMoved?.Invoke(oldDirectory.Name, newDirectory.Name); + break; + } + } + + public void Dispose() + { + _communicator.ModPathChanged.Unsubscribe(OnModPathChanged); + } + + public Dictionary GetModList() + => _modManager.ToDictionary(m => m.ModPath.Name, m => m.Name.Text); + + public PenumbraApiEc InstallMod(string modFilePackagePath) + { + if (!File.Exists(modFilePackagePath)) + return ApiHelpers.Return(PenumbraApiEc.FileMissing, ApiHelpers.Args("ModFilePackagePath", modFilePackagePath)); + + _modImportManager.AddUnpack(modFilePackagePath); + return ApiHelpers.Return(PenumbraApiEc.Success, ApiHelpers.Args("ModFilePackagePath", modFilePackagePath)); + } + + public PenumbraApiEc ReloadMod(string modDirectory, string modName) + { + if (!_modManager.TryGetMod(modDirectory, modName, out var mod)) + return ApiHelpers.Return(PenumbraApiEc.ModMissing, ApiHelpers.Args("ModDirectory", modDirectory, "ModName", modName)); + + _modManager.ReloadMod(mod); + return ApiHelpers.Return(PenumbraApiEc.Success, ApiHelpers.Args("ModDirectory", modDirectory, "ModName", modName)); + } + + public PenumbraApiEc AddMod(string modDirectory) + { + var args = ApiHelpers.Args("ModDirectory", modDirectory); + + var dir = new DirectoryInfo(Path.Join(_modManager.BasePath.FullName, Path.GetFileName(modDirectory))); + if (!dir.Exists) + return ApiHelpers.Return(PenumbraApiEc.FileMissing, args); + + if (dir.Parent == null + || Path.TrimEndingDirectorySeparator(Path.GetFullPath(_modManager.BasePath.FullName)) + != Path.TrimEndingDirectorySeparator(Path.GetFullPath(dir.Parent.FullName))) + return ApiHelpers.Return(PenumbraApiEc.InvalidArgument, args); + + _modManager.AddMod(dir, true); + if (_config.MigrateImportedModelsToV6) + { + _migrationManager.MigrateMdlDirectory(dir.FullName, false); + _migrationManager.Await(); + } + + if (_config.UseFileSystemCompression) + new FileCompactor(Penumbra.Log).StartMassCompact(dir.EnumerateFiles("*.*", SearchOption.AllDirectories), + CompressionAlgorithm.Xpress8K, false); + + return ApiHelpers.Return(PenumbraApiEc.Success, args); + } + + public PenumbraApiEc DeleteMod(string modDirectory, string modName) + { + if (!_modManager.TryGetMod(modDirectory, modName, out var mod)) + return ApiHelpers.Return(PenumbraApiEc.NothingChanged, ApiHelpers.Args("ModDirectory", modDirectory, "ModName", modName)); + + _modManager.DeleteMod(mod); + return ApiHelpers.Return(PenumbraApiEc.Success, ApiHelpers.Args("ModDirectory", modDirectory, "ModName", modName)); + } + + public event Action? ModDeleted; + public event Action? ModAdded; + public event Action? ModMoved; + + public event Action? CreatingPcp + { + add => _communicator.PcpCreation.Subscribe(value!, PcpCreation.Priority.ModsApi); + remove => _communicator.PcpCreation.Unsubscribe(value!); + } + + public event Action? ParsingPcp + { + add => _communicator.PcpParsing.Subscribe(value!, PcpParsing.Priority.ModsApi); + remove => _communicator.PcpParsing.Unsubscribe(value!); + } + + public (PenumbraApiEc, string, bool, bool) GetModPath(string modDirectory, string modName) + { + if (!_modManager.TryGetMod(modDirectory, modName, out var mod) + || !_modFileSystem.TryGetValue(mod, out var leaf)) + return (PenumbraApiEc.ModMissing, string.Empty, false, false); + + var fullPath = leaf.FullName(); + var isDefault = ModFileSystem.ModHasDefaultPath(mod, fullPath); + var isNameDefault = isDefault || ModFileSystem.ModHasDefaultPath(mod, leaf.Name); + return (PenumbraApiEc.Success, fullPath, !isDefault, !isNameDefault); + } + + public PenumbraApiEc SetModPath(string modDirectory, string modName, string newPath) + { + if (newPath.Length == 0) + return PenumbraApiEc.InvalidArgument; + + if (!_modManager.TryGetMod(modDirectory, modName, out var mod) + || !_modFileSystem.TryGetValue(mod, out var leaf)) + return PenumbraApiEc.ModMissing; + + try + { + _modFileSystem.RenameAndMove(leaf, newPath); + return PenumbraApiEc.Success; + } + catch + { + return PenumbraApiEc.PathRenameFailed; + } + } + + public Dictionary GetChangedItems(string modDirectory, string modName) + => _modManager.TryGetMod(modDirectory, modName, out var mod) + ? mod.ChangedItems.ToDictionary(kvp => kvp.Key, kvp => kvp.Value?.ToInternalObject()) + : []; + + public IReadOnlyDictionary> GetChangedItemAdapterDictionary() + => new ModChangedItemAdapter(new WeakReference(_modManager)); + + public IReadOnlyList<(string ModDirectory, IReadOnlyDictionary ChangedItems)> GetChangedItemAdapterList() + => new ModChangedItemAdapter(new WeakReference(_modManager)); +} diff --git a/Penumbra/Api/Api/PenumbraApi.cs b/Penumbra/Api/Api/PenumbraApi.cs new file mode 100644 index 00000000..c4026c72 --- /dev/null +++ b/Penumbra/Api/Api/PenumbraApi.cs @@ -0,0 +1,43 @@ +using OtterGui.Services; + +namespace Penumbra.Api.Api; + +public class PenumbraApi( + CollectionApi collection, + EditingApi editing, + GameStateApi gameState, + MetaApi meta, + ModsApi mods, + ModSettingsApi modSettings, + PluginStateApi pluginState, + RedrawApi redraw, + ResolveApi resolve, + ResourceTreeApi resourceTree, + TemporaryApi temporary, + UiApi ui) : IDisposable, IApiService, IPenumbraApi +{ + public const int BreakingVersion = 5; + public const int FeatureVersion = 13; + + public void Dispose() + { + Valid = false; + } + + public (int Breaking, int Feature) ApiVersion + => (BreakingVersion, FeatureVersion); + + public bool Valid { get; private set; } = true; + public IPenumbraApiCollection Collection { get; } = collection; + public IPenumbraApiEditing Editing { get; } = editing; + public IPenumbraApiGameState GameState { get; } = gameState; + public IPenumbraApiMeta Meta { get; } = meta; + public IPenumbraApiMods Mods { get; } = mods; + public IPenumbraApiModSettings ModSettings { get; } = modSettings; + public IPenumbraApiPluginState PluginState { get; } = pluginState; + public IPenumbraApiRedraw Redraw { get; } = redraw; + public IPenumbraApiResolve Resolve { get; } = resolve; + public IPenumbraApiResourceTree ResourceTree { get; } = resourceTree; + public IPenumbraApiTemporary Temporary { get; } = temporary; + public IPenumbraApiUi Ui { get; } = ui; +} diff --git a/Penumbra/Api/Api/PluginStateApi.cs b/Penumbra/Api/Api/PluginStateApi.cs new file mode 100644 index 00000000..f74553d1 --- /dev/null +++ b/Penumbra/Api/Api/PluginStateApi.cs @@ -0,0 +1,38 @@ +using System.Collections.Frozen; +using Newtonsoft.Json; +using OtterGui.Services; +using Penumbra.Communication; +using Penumbra.Mods; +using Penumbra.Services; + +namespace Penumbra.Api.Api; + +public class PluginStateApi(Configuration config, CommunicatorService communicator) : IPenumbraApiPluginState, IApiService +{ + public string GetModDirectory() + => config.ModDirectory; + + public string GetConfiguration() + => JsonConvert.SerializeObject(config, Formatting.Indented); + + public event Action? ModDirectoryChanged + { + add => communicator.ModDirectoryChanged.Subscribe(value!, Communication.ModDirectoryChanged.Priority.Api); + remove => communicator.ModDirectoryChanged.Unsubscribe(value!); + } + + public bool GetEnabledState() + => config.EnableMods; + + public event Action? EnabledChange + { + add => communicator.EnabledChanged.Subscribe(value!, EnabledChanged.Priority.Api); + remove => communicator.EnabledChanged.Unsubscribe(value!); + } + + public FrozenSet SupportedFeatures + => FeatureChecker.SupportedFeatures.ToFrozenSet(); + + public string[] CheckSupportedFeatures(IEnumerable requiredFeatures) + => requiredFeatures.Where(f => !FeatureChecker.Supported(f)).ToArray(); +} diff --git a/Penumbra/Api/Api/RedrawApi.cs b/Penumbra/Api/Api/RedrawApi.cs new file mode 100644 index 00000000..08f1f9df --- /dev/null +++ b/Penumbra/Api/Api/RedrawApi.cs @@ -0,0 +1,57 @@ +using Dalamud.Game.ClientState.Objects.Types; +using Dalamud.Plugin.Services; +using OtterGui.Services; +using Penumbra.Api.Enums; +using Penumbra.Collections; +using Penumbra.Collections.Manager; +using Penumbra.GameData.Interop; +using Penumbra.Interop.Services; + +namespace Penumbra.Api.Api; + +public class RedrawApi(RedrawService redrawService, IFramework framework, CollectionManager collections, ObjectManager objects, ApiHelpers helpers) : IPenumbraApiRedraw, IApiService +{ + public void RedrawObject(int gameObjectIndex, RedrawType setting) + { + framework.RunOnFrameworkThread(() => redrawService.RedrawObject(gameObjectIndex, setting)); + } + + public void RedrawObject(string name, RedrawType setting) + { + framework.RunOnFrameworkThread(() => redrawService.RedrawObject(name, setting)); + } + + public void RedrawObject(IGameObject? gameObject, RedrawType setting) + { + framework.RunOnFrameworkThread(() => redrawService.RedrawObject(gameObject, setting)); + } + + public void RedrawAll(RedrawType setting) + { + framework.RunOnFrameworkThread(() => redrawService.RedrawAll(setting)); + } + + public void RedrawCollectionMembers(Guid collectionId, RedrawType setting) + { + + if (!collections.Storage.ById(collectionId, out var collection)) + collection = ModCollection.Empty; + framework.RunOnFrameworkThread(() => + { + foreach (var actor in objects.Objects) + { + helpers.AssociatedCollection(actor.ObjectIndex, out var modCollection); + if (collection == modCollection) + { + redrawService.RedrawObject(actor.ObjectIndex, setting); + } + } + }); + } + + public event GameObjectRedrawnDelegate? GameObjectRedrawn + { + add => redrawService.GameObjectRedrawn += value; + remove => redrawService.GameObjectRedrawn -= value; + } +} diff --git a/Penumbra/Api/Api/ResolveApi.cs b/Penumbra/Api/Api/ResolveApi.cs new file mode 100644 index 00000000..00a0c86f --- /dev/null +++ b/Penumbra/Api/Api/ResolveApi.cs @@ -0,0 +1,135 @@ +using Dalamud.Plugin.Services; +using OtterGui.Services; +using Penumbra.Api.Enums; +using Penumbra.Collections; +using Penumbra.Collections.Manager; +using Penumbra.Interop.PathResolving; +using Penumbra.Mods.Manager; +using Penumbra.String.Classes; + +namespace Penumbra.Api.Api; + +public class ResolveApi( + ModManager modManager, + CollectionManager collectionManager, + Configuration config, + CollectionResolver collectionResolver, + ApiHelpers helpers, + IFramework framework) : IPenumbraApiResolve, IApiService +{ + public string ResolveDefaultPath(string gamePath) + => ResolvePath(gamePath, modManager, collectionManager.Active.Default); + + public string ResolveInterfacePath(string gamePath) + => ResolvePath(gamePath, modManager, collectionManager.Active.Interface); + + public string ResolveGameObjectPath(string gamePath, int gameObjectIdx) + { + helpers.AssociatedCollection(gameObjectIdx, out var collection); + return ResolvePath(gamePath, modManager, collection); + } + + public string ResolvePlayerPath(string gamePath) + => ResolvePath(gamePath, modManager, collectionResolver.PlayerCollection()); + + public string[] ReverseResolveGameObjectPath(string moddedPath, int gameObjectIdx) + { + if (!config.EnableMods) + return [moddedPath]; + + helpers.AssociatedCollection(gameObjectIdx, out var collection); + var ret = collection.ReverseResolvePath(new FullPath(moddedPath)); + return ret.Select(r => r.ToString()).ToArray(); + } + + public PenumbraApiEc ResolvePath(Guid collectionId, string gamePath, out string resolvedPath) + { + resolvedPath = gamePath; + if (!collectionManager.Storage.ById(collectionId, out var collection)) + return PenumbraApiEc.CollectionMissing; + + if (!collection.HasCache) + return PenumbraApiEc.CollectionInactive; + + resolvedPath = ResolvePath(gamePath, modManager, collection); + return PenumbraApiEc.Success; + } + + public string[] ReverseResolvePlayerPath(string moddedPath) + { + if (!config.EnableMods) + return [moddedPath]; + + var ret = collectionResolver.PlayerCollection().ReverseResolvePath(new FullPath(moddedPath)); + return ret.Select(r => r.ToString()).ToArray(); + } + + public (string[], string[][]) ResolvePlayerPaths(string[] forward, string[] reverse) + { + if (!config.EnableMods) + return (forward, reverse.Select(p => new[] + { + p, + }).ToArray()); + + var playerCollection = collectionResolver.PlayerCollection(); + var resolved = forward.Select(p => ResolvePath(p, modManager, playerCollection)).ToArray(); + var reverseResolved = playerCollection.ReverseResolvePaths(reverse); + return (resolved, reverseResolved.Select(a => a.Select(p => p.ToString()).ToArray()).ToArray()); + } + + public PenumbraApiEc ResolvePaths(Guid collectionId, string[] forward, string[] reverse, out string[] resolvedForward, + out string[][] resolvedReverse) + { + resolvedForward = forward; + resolvedReverse = []; + if (!config.EnableMods) + return PenumbraApiEc.Success; + + if (!collectionManager.Storage.ById(collectionId, out var collection)) + return PenumbraApiEc.CollectionMissing; + + if (!collection.HasCache) + return PenumbraApiEc.CollectionInactive; + + resolvedForward = forward.Select(p => ResolvePath(p, modManager, collection)).ToArray(); + var reverseResolved = collection.ReverseResolvePaths(reverse); + resolvedReverse = reverseResolved.Select(a => a.Select(p => p.ToString()).ToArray()).ToArray(); + return PenumbraApiEc.Success; + } + + public async Task<(string[], string[][])> ResolvePlayerPathsAsync(string[] forward, string[] reverse) + { + if (!config.EnableMods) + return (forward, reverse.Select(p => new[] + { + p, + }).ToArray()); + + return await Task.Run(async () => + { + var playerCollection = await framework.RunOnFrameworkThread(collectionResolver.PlayerCollection).ConfigureAwait(false); + var forwardTask = Task.Run(() => + { + var forwardRet = new string[forward.Length]; + Parallel.For(0, forward.Length, idx => forwardRet[idx] = ResolvePath(forward[idx], modManager, playerCollection)); + return forwardRet; + }).ConfigureAwait(false); + var reverseTask = Task.Run(() => playerCollection.ReverseResolvePaths(reverse)).ConfigureAwait(false); + var reverseResolved = (await reverseTask).Select(a => a.Select(p => p.ToString()).ToArray()).ToArray(); + return (await forwardTask, reverseResolved); + }).ConfigureAwait(false); + } + + /// Resolve a path given by string for a specific collection. + [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] + private string ResolvePath(string path, ModManager _, ModCollection collection) + { + if (!config.EnableMods) + return path; + + var gamePath = Utf8GamePath.FromString(path, out var p) ? p : Utf8GamePath.Empty; + var ret = collection.ResolvePath(gamePath); + return ret?.ToString() ?? path; + } +} diff --git a/Penumbra/Api/Api/ResourceTreeApi.cs b/Penumbra/Api/Api/ResourceTreeApi.cs new file mode 100644 index 00000000..dcec99bf --- /dev/null +++ b/Penumbra/Api/Api/ResourceTreeApi.cs @@ -0,0 +1,63 @@ +using Dalamud.Game.ClientState.Objects.Types; +using Newtonsoft.Json.Linq; +using OtterGui.Services; +using Penumbra.Api.Enums; +using Penumbra.Api.Helpers; +using Penumbra.GameData.Interop; +using Penumbra.Interop.ResourceTree; + +namespace Penumbra.Api.Api; + +public class ResourceTreeApi(ResourceTreeFactory resourceTreeFactory, ObjectManager objects) : IPenumbraApiResourceTree, IApiService +{ + public Dictionary>?[] GetGameObjectResourcePaths(params ushort[] gameObjects) + { + var characters = gameObjects.Select(index => objects.GetDalamudObject((int)index)).OfType(); + var resourceTrees = resourceTreeFactory.FromCharacters(characters, 0); + var pathDictionaries = ResourceTreeApiHelper.GetResourcePathDictionaries(resourceTrees); + + return Array.ConvertAll(gameObjects, obj => pathDictionaries.GetValueOrDefault(obj)); + } + + public Dictionary>> GetPlayerResourcePaths() + { + var resourceTrees = resourceTreeFactory.FromObjectTable(ResourceTreeFactory.Flags.LocalPlayerRelatedOnly); + return ResourceTreeApiHelper.GetResourcePathDictionaries(resourceTrees); + } + + public GameResourceDict?[] GetGameObjectResourcesOfType(ResourceType type, bool withUiData, + params ushort[] gameObjects) + { + var characters = gameObjects.Select(index => objects.GetDalamudObject((int)index)).OfType(); + var resourceTrees = resourceTreeFactory.FromCharacters(characters, withUiData ? ResourceTreeFactory.Flags.WithUiData : 0); + var resDictionaries = ResourceTreeApiHelper.GetResourcesOfType(resourceTrees, type); + + return Array.ConvertAll(gameObjects, obj => resDictionaries.GetValueOrDefault(obj)); + } + + public Dictionary GetPlayerResourcesOfType(ResourceType type, + bool withUiData) + { + var resourceTrees = resourceTreeFactory.FromObjectTable(ResourceTreeFactory.Flags.LocalPlayerRelatedOnly + | (withUiData ? ResourceTreeFactory.Flags.WithUiData : 0)); + return ResourceTreeApiHelper.GetResourcesOfType(resourceTrees, type); + } + + public JObject?[] GetGameObjectResourceTrees(bool withUiData, params ushort[] gameObjects) + { + var characters = gameObjects.Select(index => objects.GetDalamudObject((int)index)).OfType(); + var resourceTrees = resourceTreeFactory.FromCharacters(characters, withUiData ? ResourceTreeFactory.Flags.WithUiData : 0); + var resDictionary = ResourceTreeApiHelper.EncapsulateResourceTrees(resourceTrees); + + return Array.ConvertAll(gameObjects, obj => resDictionary.GetValueOrDefault(obj)); + } + + public Dictionary GetPlayerResourceTrees(bool withUiData) + { + var resourceTrees = resourceTreeFactory.FromObjectTable(ResourceTreeFactory.Flags.LocalPlayerRelatedOnly + | (withUiData ? ResourceTreeFactory.Flags.WithUiData : 0)); + var resDictionary = ResourceTreeApiHelper.EncapsulateResourceTrees(resourceTrees); + + return resDictionary; + } +} diff --git a/Penumbra/Api/Api/TemporaryApi.cs b/Penumbra/Api/Api/TemporaryApi.cs new file mode 100644 index 00000000..7567acd3 --- /dev/null +++ b/Penumbra/Api/Api/TemporaryApi.cs @@ -0,0 +1,338 @@ +using OtterGui.Log; +using OtterGui.Services; +using Penumbra.Api.Enums; +using Penumbra.Collections; +using Penumbra.Collections.Manager; +using Penumbra.GameData.Actors; +using Penumbra.GameData.Interop; +using Penumbra.Mods.Manager; +using Penumbra.Mods.Settings; +using Penumbra.String.Classes; + +namespace Penumbra.Api.Api; + +public class TemporaryApi( + TempCollectionManager tempCollections, + ObjectManager objects, + ActorManager actors, + CollectionManager collectionManager, + TempModManager tempMods, + ApiHelpers apiHelpers, + ModManager modManager) : IPenumbraApiTemporary, IApiService +{ + public (PenumbraApiEc, Guid) CreateTemporaryCollection(string identity, string name) + { + if (!IdentityChecker.Check(identity)) + return (PenumbraApiEc.InvalidCredentials, Guid.Empty); + + var collection = tempCollections.CreateTemporaryCollection(name); + if (collection == Guid.Empty) + return (PenumbraApiEc.UnknownError, collection); + return (PenumbraApiEc.Success, collection); + } + + public PenumbraApiEc DeleteTemporaryCollection(Guid collectionId) + => tempCollections.RemoveTemporaryCollection(collectionId) + ? PenumbraApiEc.Success + : PenumbraApiEc.CollectionMissing; + + public PenumbraApiEc AssignTemporaryCollection(Guid collectionId, int actorIndex, bool forceAssignment) + { + var args = ApiHelpers.Args("CollectionId", collectionId, "ActorIndex", actorIndex, "Forced", forceAssignment); + if (actorIndex < 0 || actorIndex >= objects.TotalCount) + return ApiHelpers.Return(PenumbraApiEc.InvalidArgument, args); + + var identifier = actors.FromObject(objects[actorIndex], out _, false, false, true); + if (!identifier.IsValid) + return ApiHelpers.Return(PenumbraApiEc.InvalidArgument, args); + + if (!tempCollections.CollectionById(collectionId, out var collection)) + return ApiHelpers.Return(PenumbraApiEc.CollectionMissing, args); + + if (forceAssignment) + { + if (tempCollections.Collections.ContainsKey(identifier) && !tempCollections.Collections.Delete(identifier)) + return ApiHelpers.Return(PenumbraApiEc.AssignmentDeletionFailed, args); + } + else if (tempCollections.Collections.ContainsKey(identifier) + || collectionManager.Active.Individuals.ContainsKey(identifier)) + { + return ApiHelpers.Return(PenumbraApiEc.CharacterCollectionExists, args); + } + + var group = tempCollections.Collections.GetGroup(identifier); + var ret = tempCollections.AddIdentifier(collection, group) + ? PenumbraApiEc.Success + : PenumbraApiEc.UnknownError; + return ApiHelpers.Return(ret, args); + } + + public PenumbraApiEc AddTemporaryModAll(string tag, Dictionary paths, string manipString, int priority) + { + var args = ApiHelpers.Args("Tag", tag, "#Paths", paths.Count, "ManipString", manipString, "Priority", priority); + if (!ConvertPaths(paths, out var p)) + return ApiHelpers.Return(PenumbraApiEc.InvalidGamePath, args); + + if (!MetaApi.ConvertManips(manipString, out var m, out _)) + return ApiHelpers.Return(PenumbraApiEc.InvalidManipulation, args); + + var ret = tempMods.Register(tag, null, p, m, new ModPriority(priority)) switch + { + RedirectResult.Success => PenumbraApiEc.Success, + _ => PenumbraApiEc.UnknownError, + }; + return ApiHelpers.Return(ret, args); + } + + public PenumbraApiEc AddTemporaryMod(string tag, Guid collectionId, Dictionary paths, string manipString, int priority) + { + var args = ApiHelpers.Args("Tag", tag, "CollectionId", collectionId, "#Paths", paths.Count, "ManipString", + manipString, "Priority", priority); + + if (collectionId == Guid.Empty) + return ApiHelpers.Return(PenumbraApiEc.InvalidArgument, args); + + if (!tempCollections.CollectionById(collectionId, out var collection) + && !collectionManager.Storage.ById(collectionId, out collection)) + return ApiHelpers.Return(PenumbraApiEc.CollectionMissing, args); + + if (!ConvertPaths(paths, out var p)) + return ApiHelpers.Return(PenumbraApiEc.InvalidGamePath, args); + + if (!MetaApi.ConvertManips(manipString, out var m, out _)) + return ApiHelpers.Return(PenumbraApiEc.InvalidManipulation, args); + + var ret = tempMods.Register(tag, collection, p, m, new ModPriority(priority)) switch + { + RedirectResult.Success => PenumbraApiEc.Success, + _ => PenumbraApiEc.UnknownError, + }; + return ApiHelpers.Return(ret, args); + } + + public PenumbraApiEc RemoveTemporaryModAll(string tag, int priority) + { + var ret = tempMods.Unregister(tag, null, new ModPriority(priority)) switch + { + RedirectResult.Success => PenumbraApiEc.Success, + RedirectResult.NotRegistered => PenumbraApiEc.NothingChanged, + _ => PenumbraApiEc.UnknownError, + }; + return ApiHelpers.Return(ret, ApiHelpers.Args("Tag", tag, "Priority", priority)); + } + + public PenumbraApiEc RemoveTemporaryMod(string tag, Guid collectionId, int priority) + { + var args = ApiHelpers.Args("Tag", tag, "CollectionId", collectionId, "Priority", priority); + + if (!tempCollections.CollectionById(collectionId, out var collection) + && !collectionManager.Storage.ById(collectionId, out collection)) + return ApiHelpers.Return(PenumbraApiEc.CollectionMissing, args); + + var ret = tempMods.Unregister(tag, collection, new ModPriority(priority)) switch + { + RedirectResult.Success => PenumbraApiEc.Success, + RedirectResult.NotRegistered => PenumbraApiEc.NothingChanged, + _ => PenumbraApiEc.UnknownError, + }; + return ApiHelpers.Return(ret, args); + } + + public (PenumbraApiEc, (bool, bool, int, Dictionary>)?, string) QueryTemporaryModSettings(Guid collectionId, + string modDirectory, string modName, int key) + { + var args = ApiHelpers.Args("CollectionId", collectionId, "ModDirectory", modDirectory, "ModName", modName); + if (!collectionManager.Storage.ById(collectionId, out var collection)) + return (ApiHelpers.Return(PenumbraApiEc.CollectionMissing, args), null, string.Empty); + + return QueryTemporaryModSettings(args, collection, modDirectory, modName, key); + } + + public (PenumbraApiEc ErrorCode, (bool, bool, int, Dictionary>)? Settings, string Source) + QueryTemporaryModSettingsPlayer(int objectIndex, + string modDirectory, string modName, int key) + { + var args = ApiHelpers.Args("ObjectIndex", objectIndex, "ModDirectory", modDirectory, "ModName", modName); + if (!apiHelpers.AssociatedCollection(objectIndex, out var collection)) + return (ApiHelpers.Return(PenumbraApiEc.InvalidArgument, args), null, string.Empty); + + return QueryTemporaryModSettings(args, collection, modDirectory, modName, key); + } + + private (PenumbraApiEc ErrorCode, (bool, bool, int, Dictionary>)? Settings, string Source) QueryTemporaryModSettings( + in LazyString args, ModCollection collection, string modDirectory, string modName, int key) + { + if (!modManager.TryGetMod(modDirectory, modName, out var mod)) + return (ApiHelpers.Return(PenumbraApiEc.ModMissing, args), null, string.Empty); + + if (collection.Identity.Index <= 0) + return (ApiHelpers.Return(PenumbraApiEc.Success, args), null, string.Empty); + + var settings = collection.GetTempSettings(mod.Index); + if (settings == null) + return (ApiHelpers.Return(PenumbraApiEc.Success, args), null, string.Empty); + + if (settings.Lock > 0 && settings.Lock != key) + return (ApiHelpers.Return(PenumbraApiEc.TemporarySettingDisallowed, args), null, settings.Source); + + return (ApiHelpers.Return(PenumbraApiEc.Success, args), + (settings.ForceInherit, settings.Enabled, settings.Priority.Value, settings.ConvertToShareable(mod).Settings), settings.Source); + } + + + public PenumbraApiEc SetTemporaryModSettings(Guid collectionId, string modDirectory, string modName, bool inherit, bool enabled, + int priority, + IReadOnlyDictionary> options, string source, int key) + { + var args = ApiHelpers.Args("CollectionId", collectionId, "ModDirectory", modDirectory, "ModName", modName, "Inherit", inherit, + "Enabled", enabled, + "Priority", priority, "Options", options, "Source", source, "Key", key); + if (!collectionManager.Storage.ById(collectionId, out var collection)) + return ApiHelpers.Return(PenumbraApiEc.CollectionMissing, args); + + return SetTemporaryModSettings(args, collection, modDirectory, modName, inherit, enabled, priority, options, source, key); + } + + public PenumbraApiEc SetTemporaryModSettingsPlayer(int objectIndex, string modDirectory, string modName, bool inherit, bool enabled, + int priority, + IReadOnlyDictionary> options, string source, int key) + { + var args = ApiHelpers.Args("ObjectIndex", objectIndex, "ModDirectory", modDirectory, "ModName", modName, "Inherit", inherit, "Enabled", + enabled, + "Priority", priority, "Options", options, "Source", source, "Key", key); + if (!apiHelpers.AssociatedCollection(objectIndex, out var collection)) + return ApiHelpers.Return(PenumbraApiEc.InvalidArgument, args); + + return SetTemporaryModSettings(args, collection, modDirectory, modName, inherit, enabled, priority, options, source, key); + } + + private PenumbraApiEc SetTemporaryModSettings(in LazyString args, ModCollection collection, string modDirectory, string modName, + bool inherit, bool enabled, int priority, IReadOnlyDictionary> options, string source, int key) + { + if (collection.Identity.Index <= 0) + return ApiHelpers.Return(PenumbraApiEc.TemporarySettingImpossible, args); + + if (!modManager.TryGetMod(modDirectory, modName, out var mod)) + return ApiHelpers.Return(PenumbraApiEc.ModMissing, args); + + if (!collectionManager.Editor.CanSetTemporarySettings(collection, mod, key)) + if (collection.GetTempSettings(mod.Index) is { Lock: > 0 } oldSettings && oldSettings.Lock != key) + return ApiHelpers.Return(PenumbraApiEc.TemporarySettingDisallowed, args); + + var newSettings = new TemporaryModSettings() + { + ForceInherit = inherit, + Enabled = enabled, + Priority = new ModPriority(priority), + Lock = key, + Source = source, + Settings = SettingList.Default(mod), + }; + + + foreach (var (groupName, optionNames) in options) + { + var ec = ModSettingsApi.ConvertModSetting(mod, groupName, optionNames, out var groupIdx, out var setting); + if (ec != PenumbraApiEc.Success) + return ApiHelpers.Return(ec, args); + + newSettings.Settings[groupIdx] = setting; + } + + if (collectionManager.Editor.SetTemporarySettings(collection, mod, newSettings, key)) + return ApiHelpers.Return(PenumbraApiEc.Success, args); + + // This should not happen since all error cases had been checked before. + return ApiHelpers.Return(PenumbraApiEc.UnknownError, args); + } + + public PenumbraApiEc RemoveTemporaryModSettings(Guid collectionId, string modDirectory, string modName, int key) + { + var args = ApiHelpers.Args("CollectionId", collectionId, "ModDirectory", modDirectory, "ModName", modName, "Key", key); + if (!collectionManager.Storage.ById(collectionId, out var collection)) + return ApiHelpers.Return(PenumbraApiEc.CollectionMissing, args); + + return RemoveTemporaryModSettings(args, collection, modDirectory, modName, key); + } + + public PenumbraApiEc RemoveTemporaryModSettingsPlayer(int objectIndex, string modDirectory, string modName, int key) + { + var args = ApiHelpers.Args("ObjectIndex", objectIndex, "ModDirectory", modDirectory, "ModName", modName, "Key", key); + if (!apiHelpers.AssociatedCollection(objectIndex, out var collection)) + return ApiHelpers.Return(PenumbraApiEc.InvalidArgument, args); + + return RemoveTemporaryModSettings(args, collection, modDirectory, modName, key); + } + + private PenumbraApiEc RemoveTemporaryModSettings(in LazyString args, ModCollection collection, string modDirectory, string modName, int key) + { + if (collection.Identity.Index <= 0) + return ApiHelpers.Return(PenumbraApiEc.NothingChanged, args); + + if (!modManager.TryGetMod(modDirectory, modName, out var mod)) + return ApiHelpers.Return(PenumbraApiEc.ModMissing, args); + + if (collection.GetTempSettings(mod.Index) is null) + return ApiHelpers.Return(PenumbraApiEc.NothingChanged, args); + + if (!collectionManager.Editor.SetTemporarySettings(collection, mod, null, key)) + return ApiHelpers.Return(PenumbraApiEc.TemporarySettingDisallowed, args); + + return ApiHelpers.Return(PenumbraApiEc.Success, args); + } + + public PenumbraApiEc RemoveAllTemporaryModSettings(Guid collectionId, int key) + { + var args = ApiHelpers.Args("CollectionId", collectionId, "Key", key); + if (!collectionManager.Storage.ById(collectionId, out var collection)) + return ApiHelpers.Return(PenumbraApiEc.CollectionMissing, args); + + return RemoveAllTemporaryModSettings(args, collection, key); + } + + public PenumbraApiEc RemoveAllTemporaryModSettingsPlayer(int objectIndex, int key) + { + var args = ApiHelpers.Args("ObjectIndex", objectIndex, "Key", key); + if (!apiHelpers.AssociatedCollection(objectIndex, out var collection)) + return ApiHelpers.Return(PenumbraApiEc.InvalidArgument, args); + + return RemoveAllTemporaryModSettings(args, collection, key); + } + + private PenumbraApiEc RemoveAllTemporaryModSettings(in LazyString args, ModCollection collection, int key) + { + if (collection.Identity.Index <= 0) + return ApiHelpers.Return(PenumbraApiEc.NothingChanged, args); + + var numRemoved = collectionManager.Editor.ClearTemporarySettings(collection, key); + return ApiHelpers.Return(numRemoved > 0 ? PenumbraApiEc.Success : PenumbraApiEc.NothingChanged, args); + } + + + /// + /// Convert a dictionary of strings to a dictionary of game paths to full paths. + /// Only returns true if all paths can successfully be converted and added. + /// + private static bool ConvertPaths(IReadOnlyDictionary redirections, + [NotNullWhen(true)] out Dictionary? paths) + { + paths = new Dictionary(redirections.Count); + foreach (var (gString, fString) in redirections) + { + if (!Utf8GamePath.FromString(gString, out var path)) + { + paths = null; + return false; + } + + var fullPath = new FullPath(fString); + if (!paths.TryAdd(path, fullPath)) + { + paths = null; + return false; + } + } + + return true; + } +} diff --git a/Penumbra/Api/Api/UiApi.cs b/Penumbra/Api/Api/UiApi.cs new file mode 100644 index 00000000..6fb116f3 --- /dev/null +++ b/Penumbra/Api/Api/UiApi.cs @@ -0,0 +1,113 @@ +using OtterGui.Services; +using Penumbra.Api.Enums; +using Penumbra.Communication; +using Penumbra.GameData.Data; +using Penumbra.Mods.Manager; +using Penumbra.Services; +using Penumbra.UI; +using Penumbra.UI.Integration; +using Penumbra.UI.Tabs; + +namespace Penumbra.Api.Api; + +public class UiApi : IPenumbraApiUi, IApiService, IDisposable +{ + private readonly CommunicatorService _communicator; + private readonly ConfigWindow _configWindow; + private readonly ModManager _modManager; + private readonly IntegrationSettingsRegistry _integrationSettings; + + public UiApi(CommunicatorService communicator, ConfigWindow configWindow, ModManager modManager, IntegrationSettingsRegistry integrationSettings) + { + _communicator = communicator; + _configWindow = configWindow; + _modManager = modManager; + _integrationSettings = integrationSettings; + _communicator.ChangedItemHover.Subscribe(OnChangedItemHover, ChangedItemHover.Priority.Default); + _communicator.ChangedItemClick.Subscribe(OnChangedItemClick, ChangedItemClick.Priority.Default); + } + + public void Dispose() + { + _communicator.ChangedItemHover.Unsubscribe(OnChangedItemHover); + _communicator.ChangedItemClick.Unsubscribe(OnChangedItemClick); + } + + public event Action? ChangedItemTooltip; + + public event Action? ChangedItemClicked; + + public event Action? PreSettingsTabBarDraw + { + add => _communicator.PreSettingsTabBarDraw.Subscribe(value!, Communication.PreSettingsTabBarDraw.Priority.Default); + remove => _communicator.PreSettingsTabBarDraw.Unsubscribe(value!); + } + + public event Action? PreSettingsPanelDraw + { + add => _communicator.PreSettingsPanelDraw.Subscribe(value!, Communication.PreSettingsPanelDraw.Priority.Default); + remove => _communicator.PreSettingsPanelDraw.Unsubscribe(value!); + } + + public event Action? PostEnabledDraw + { + add => _communicator.PostEnabledDraw.Subscribe(value!, Communication.PostEnabledDraw.Priority.Default); + remove => _communicator.PostEnabledDraw.Unsubscribe(value!); + } + + public event Action? PostSettingsPanelDraw + { + add => _communicator.PostSettingsPanelDraw.Subscribe(value!, Communication.PostSettingsPanelDraw.Priority.Default); + remove => _communicator.PostSettingsPanelDraw.Unsubscribe(value!); + } + + public PenumbraApiEc OpenMainWindow(TabType tab, string modDirectory, string modName) + { + _configWindow.IsOpen = true; + if (!Enum.IsDefined(tab)) + return PenumbraApiEc.InvalidArgument; + + if (tab == TabType.Mods && (modDirectory.Length > 0 || modName.Length > 0)) + { + if (_modManager.TryGetMod(modDirectory, modName, out var mod)) + _communicator.SelectTab.Invoke(tab, mod); + else + return PenumbraApiEc.ModMissing; + } + else if (tab != TabType.None) + { + _communicator.SelectTab.Invoke(tab, null); + } + + return PenumbraApiEc.Success; + } + + public void CloseMainWindow() + => _configWindow.IsOpen = false; + + private void OnChangedItemClick(MouseButton button, IIdentifiedObjectData data) + { + if (ChangedItemClicked == null) + return; + + var (type, id) = data.ToApiObject(); + ChangedItemClicked.Invoke(button, type, id); + } + + private void OnChangedItemHover(IIdentifiedObjectData data) + { + if (ChangedItemTooltip == null) + return; + + var (type, id) = data.ToApiObject(); + ChangedItemTooltip.Invoke(type, id); + } + + public PenumbraApiEc RegisterSettingsSection(Action draw) + => _integrationSettings.RegisterSection(draw); + + public PenumbraApiEc UnregisterSettingsSection(Action draw) + => _integrationSettings.UnregisterSection(draw) + ? PenumbraApiEc.Success + : PenumbraApiEc.NothingChanged; +} diff --git a/Penumbra/Api/DalamudSubstitutionProvider.cs b/Penumbra/Api/DalamudSubstitutionProvider.cs new file mode 100644 index 00000000..e10dc461 --- /dev/null +++ b/Penumbra/Api/DalamudSubstitutionProvider.cs @@ -0,0 +1,161 @@ +using Dalamud.Interface; +using Dalamud.Plugin.Services; +using OtterGui.Services; +using Penumbra.Collections; +using Penumbra.Collections.Manager; +using Penumbra.Communication; +using Penumbra.Mods.Editor; +using Penumbra.Services; +using Penumbra.String.Classes; + +namespace Penumbra.Api; + +public class DalamudSubstitutionProvider : IDisposable, IApiService +{ + private readonly ITextureSubstitutionProvider _substitution; + private readonly IUiBuilder _uiBuilder; + private readonly ActiveCollectionData _activeCollectionData; + private readonly Configuration _config; + private readonly CommunicatorService _communicator; + + public bool Enabled + => _config.UseDalamudUiTextureRedirection; + + public DalamudSubstitutionProvider(ITextureSubstitutionProvider substitution, ActiveCollectionData activeCollectionData, + Configuration config, CommunicatorService communicator, IUiBuilder ui) + { + _substitution = substitution; + _uiBuilder = ui; + _activeCollectionData = activeCollectionData; + _config = config; + _communicator = communicator; + if (Enabled) + Subscribe(); + } + + public void Set(bool value) + { + if (value) + Enable(); + else + Disable(); + } + + public void ResetSubstitutions(IEnumerable paths) + { + if (!_uiBuilder.UiPrepared) + return; + + var transformed = paths + .Where(p => (p.Path.StartsWith("ui/"u8) || p.Path.StartsWith("common/font/"u8)) && p.Path.EndsWith(".tex"u8)) + .Select(p => p.ToString()); + _substitution.InvalidatePaths(transformed); + } + + public void Enable() + { + if (Enabled) + return; + + _config.UseDalamudUiTextureRedirection = true; + _config.Save(); + Subscribe(); + } + + public void Disable() + { + if (!Enabled) + return; + + Unsubscribe(); + _config.UseDalamudUiTextureRedirection = false; + _config.Save(); + } + + public void Dispose() + => Unsubscribe(); + + private void OnCollectionChange(CollectionType type, ModCollection? oldCollection, ModCollection? newCollection, string _) + { + if (type is not CollectionType.Interface) + return; + + var enumerable = oldCollection?.ResolvedFiles.Keys ?? Array.Empty().AsEnumerable(); + enumerable = enumerable.Concat(newCollection?.ResolvedFiles.Keys ?? Array.Empty().AsEnumerable()); + ResetSubstitutions(enumerable); + } + + private void OnResolvedFileChange(ModCollection collection, ResolvedFileChanged.Type type, Utf8GamePath key, FullPath _1, FullPath _2, + IMod? _3) + { + if (_activeCollectionData.Interface != collection) + return; + + switch (type) + { + case ResolvedFileChanged.Type.Added: + case ResolvedFileChanged.Type.Removed: + case ResolvedFileChanged.Type.Replaced: + ResetSubstitutions([key]); + break; + case ResolvedFileChanged.Type.FullRecomputeStart: + case ResolvedFileChanged.Type.FullRecomputeFinished: + ResetSubstitutions(collection.ResolvedFiles.Keys); + break; + } + } + + private void OnEnabledChange(bool state) + { + if (state) + OnCollectionChange(CollectionType.Interface, null, _activeCollectionData.Interface, string.Empty); + else + OnCollectionChange(CollectionType.Interface, _activeCollectionData.Interface, null, string.Empty); + } + + private void Substitute(string path, ref string? replacementPath) + { + // Do not replace when not enabled. + if (!_config.EnableMods) + return; + + // Let other plugins prioritize replacement paths. + if (replacementPath != null) + return; + + // Only replace interface textures. + if (!path.StartsWith("ui/") && !path.StartsWith("common/font/")) + return; + + try + { + if (!Utf8GamePath.FromString(path, out var utf8Path)) + return; + + var resolved = _activeCollectionData.Interface.ResolvePath(utf8Path); + replacementPath = resolved?.FullName; + } + catch + { + // ignored + } + } + + private void Subscribe() + { + _substitution.InterceptTexDataLoad += Substitute; + _communicator.CollectionChange.Subscribe(OnCollectionChange, CollectionChange.Priority.DalamudSubstitutionProvider); + _communicator.ResolvedFileChanged.Subscribe(OnResolvedFileChange, ResolvedFileChanged.Priority.DalamudSubstitutionProvider); + _communicator.EnabledChanged.Subscribe(OnEnabledChange, EnabledChanged.Priority.DalamudSubstitutionProvider); + OnCollectionChange(CollectionType.Interface, null, _activeCollectionData.Interface, string.Empty); + } + + private void Unsubscribe() + { + _substitution.InterceptTexDataLoad -= Substitute; + _communicator.CollectionChange.Unsubscribe(OnCollectionChange); + _communicator.ResolvedFileChanged.Unsubscribe(OnResolvedFileChange); + _communicator.EnabledChanged.Unsubscribe(OnEnabledChange); + OnCollectionChange(CollectionType.Interface, _activeCollectionData.Interface, null, string.Empty); + } +} diff --git a/Penumbra/Api/HttpApi.cs b/Penumbra/Api/HttpApi.cs new file mode 100644 index 00000000..79348a88 --- /dev/null +++ b/Penumbra/Api/HttpApi.cs @@ -0,0 +1,203 @@ +using Dalamud.Plugin.Services; +using EmbedIO; +using EmbedIO.Routing; +using EmbedIO.WebApi; +using OtterGui.Services; +using Penumbra.Api.Api; +using Penumbra.Api.Enums; +using Penumbra.Mods.Settings; + +namespace Penumbra.Api; + +public class HttpApi : IDisposable, IApiService +{ + private partial class Controller : WebApiController + { + // @formatter:off + [Route( HttpVerbs.Get, "/moddirectory" )] public partial string GetModDirectory(); + [Route( HttpVerbs.Get, "/mods" )] public partial object? GetMods(); + [Route( HttpVerbs.Post, "/redraw" )] public partial Task Redraw(); + [Route( HttpVerbs.Post, "/redrawAll" )] public partial Task RedrawAll(); + [Route( HttpVerbs.Post, "/reloadmod" )] public partial Task ReloadMod(); + [Route( HttpVerbs.Post, "/installmod" )] public partial Task InstallMod(); + [Route( HttpVerbs.Post, "/openwindow" )] public partial void OpenWindow(); + [Route( HttpVerbs.Post, "/focusmod" )] public partial Task FocusMod(); + [Route( HttpVerbs.Post, "/setmodsettings")] public partial Task SetModSettings(); + // @formatter:on + } + + public const string Prefix = "http://localhost:42069/"; + + private readonly IPenumbraApi _api; + private readonly IFramework _framework; + private WebServer? _server; + + public HttpApi(Configuration config, IPenumbraApi api, IFramework framework) + { + _api = api; + _framework = framework; + if (config.EnableHttpApi) + CreateWebServer(); + } + + public bool Enabled + => _server != null; + + public void CreateWebServer() + { + ShutdownWebServer(); + + _server = new WebServer(o => o + .WithUrlPrefix(Prefix) + .WithMode(HttpListenerMode.EmbedIO)) + .WithCors(Prefix) + .WithWebApi("/api", m => m.WithController(() => new Controller(_api, _framework))); + + _server.StateChanged += (_, e) => Penumbra.Log.Information($"WebServer New State - {e.NewState}"); + _server.RunAsync(); + } + + public void ShutdownWebServer() + { + _server?.Dispose(); + _server = null; + } + + public void Dispose() + => ShutdownWebServer(); + + private partial class Controller(IPenumbraApi api, IFramework framework) + { + public partial string GetModDirectory() + { + Penumbra.Log.Debug($"[HTTP] {nameof(GetModDirectory)} triggered."); + return api.PluginState.GetModDirectory(); + } + + public partial object? GetMods() + { + Penumbra.Log.Debug($"[HTTP] {nameof(GetMods)} triggered."); + return api.Mods.GetModList(); + } + + public async partial Task Redraw() + { + var data = await HttpContext.GetRequestDataAsync().ConfigureAwait(false); + Penumbra.Log.Debug($"[HTTP] [{Environment.CurrentManagedThreadId}] {nameof(Redraw)} triggered with {data}."); + await framework.RunOnFrameworkThread(() => + { + if (data.ObjectTableIndex >= 0) + api.Redraw.RedrawObject(data.ObjectTableIndex, data.Type); + else + api.Redraw.RedrawAll(data.Type); + }).ConfigureAwait(false); + } + + public async partial Task RedrawAll() + { + Penumbra.Log.Debug($"[HTTP] {nameof(RedrawAll)} triggered."); + await framework.RunOnFrameworkThread(() => { api.Redraw.RedrawAll(RedrawType.Redraw); }).ConfigureAwait(false); + } + + public async partial Task ReloadMod() + { + var data = await HttpContext.GetRequestDataAsync().ConfigureAwait(false); + Penumbra.Log.Debug($"[HTTP] {nameof(ReloadMod)} triggered with {data}."); + // Add the mod if it is not already loaded and if the directory name is given. + // AddMod returns Success if the mod is already loaded. + if (data.Path.Length != 0) + api.Mods.AddMod(data.Path); + + // Reload the mod by path or name, which will also remove no-longer existing mods. + api.Mods.ReloadMod(data.Path, data.Name); + } + + public async partial Task InstallMod() + { + var data = await HttpContext.GetRequestDataAsync().ConfigureAwait(false); + Penumbra.Log.Debug($"[HTTP] {nameof(InstallMod)} triggered with {data}."); + if (data.Path.Length != 0) + api.Mods.InstallMod(data.Path); + } + + public partial void OpenWindow() + { + Penumbra.Log.Debug($"[HTTP] {nameof(OpenWindow)} triggered."); + api.Ui.OpenMainWindow(TabType.Mods, string.Empty, string.Empty); + } + + public async partial Task FocusMod() + { + var data = await HttpContext.GetRequestDataAsync().ConfigureAwait(false); + Penumbra.Log.Debug($"[HTTP] {nameof(FocusMod)} triggered."); + if (data.Path.Length != 0) + api.Ui.OpenMainWindow(TabType.Mods, data.Path, data.Name); + } + + public async partial Task SetModSettings() + { + var data = await HttpContext.GetRequestDataAsync().ConfigureAwait(false); + Penumbra.Log.Debug($"[HTTP] {nameof(SetModSettings)} triggered."); + await framework.RunOnFrameworkThread(() => + { + var collection = data.CollectionId ?? api.Collection.GetCollection(ApiCollectionType.Current)!.Value.Id; + if (data.Inherit.HasValue) + { + api.ModSettings.TryInheritMod(collection, data.ModPath, data.ModName, data.Inherit.Value); + if (data.Inherit.Value) + return; + } + + if (data.State.HasValue) + api.ModSettings.TrySetMod(collection, data.ModPath, data.ModName, data.State.Value); + if (data.Priority.HasValue) + api.ModSettings.TrySetModPriority(collection, data.ModPath, data.ModName, data.Priority.Value); + foreach (var (group, settings) in data.Settings ?? []) + api.ModSettings.TrySetModSettings(collection, data.ModPath, data.ModName, group, settings); + } + ).ConfigureAwait(false); + } + + private record ModReloadData(string Path, string Name) + { + public ModReloadData() + : this(string.Empty, string.Empty) + { } + } + + private record ModFocusData(string Path, string Name) + { + public ModFocusData() + : this(string.Empty, string.Empty) + { } + } + + private record ModInstallData(string Path) + { + public ModInstallData() + : this(string.Empty) + { } + } + + private record RedrawData(string Name, RedrawType Type, int ObjectTableIndex) + { + public RedrawData() + : this(string.Empty, RedrawType.Redraw, -1) + { } + } + + private record SetModSettingsData( + Guid? CollectionId, + string ModPath, + string ModName, + bool? Inherit, + bool? State, + int? Priority, + Dictionary>? Settings) + { + public SetModSettingsData() + : this(null, string.Empty, string.Empty, null, null, null, null) + {} + } + } +} diff --git a/Penumbra/Api/IPenumbraApi.cs b/Penumbra/Api/IPenumbraApi.cs deleted file mode 100644 index 92d9ef3d..00000000 --- a/Penumbra/Api/IPenumbraApi.cs +++ /dev/null @@ -1,47 +0,0 @@ -using Dalamud.Game.ClientState.Objects.Types; -using Lumina.Data; -using Penumbra.GameData.Enums; - -namespace Penumbra.Api -{ - public interface IPenumbraApiBase - { - public int ApiVersion { get; } - public bool Valid { get; } - } - - public delegate void ChangedItemHover( object? item ); - public delegate void ChangedItemClick( MouseButton button, object? item ); - - public interface IPenumbraApi : IPenumbraApiBase - { - // Triggered when the user hovers over a listed changed object in a mod tab. - // Can be used to append tooltips. - public event ChangedItemHover? ChangedItemTooltip; - // Triggered when the user clicks a listed changed object in a mod tab. - public event ChangedItemClick? ChangedItemClicked; - - // Queue redrawing of all actors of the given name with the given RedrawType. - public void RedrawObject( string name, RedrawType setting ); - - // Queue redrawing of the specific actor with the given RedrawType. Should only be used when the actor is sure to be valid. - public void RedrawObject( GameObject gameObject, RedrawType setting ); - - // Queue redrawing of all currently available actors with the given RedrawType. - public void RedrawAll( RedrawType setting ); - - // Resolve a given gamePath via Penumbra using the Default and Forced collections. - // Returns the given gamePath if penumbra would not manipulate it. - public string ResolvePath(string gamePath); - - // Resolve a given gamePath via Penumbra using the character collection for the given name (if it exists) and the Forced collections. - // Returns the given gamePath if penumbra would not manipulate it. - public string ResolvePath( string gamePath, string characterName ); - - // Try to load a given gamePath with the resolved path from Penumbra. - public T? GetFile< T >( string gamePath ) where T : FileResource; - - // Try to load a given gamePath with the resolved path from Penumbra. - public T? GetFile( string gamePath, string characterName ) where T : FileResource; - } -} \ No newline at end of file diff --git a/Penumbra/Api/IpcLaunchingProvider.cs b/Penumbra/Api/IpcLaunchingProvider.cs new file mode 100644 index 00000000..ff851003 --- /dev/null +++ b/Penumbra/Api/IpcLaunchingProvider.cs @@ -0,0 +1,28 @@ +using Dalamud.Plugin; +using OtterGui.Log; +using OtterGui.Services; +using Penumbra.Api.Api; +using Serilog.Events; + +namespace Penumbra.Api; + +public sealed class IpcLaunchingProvider : IApiService +{ + public IpcLaunchingProvider(IDalamudPluginInterface pi, Logger log) + { + try + { + using var subscriber = log.MainLogger.IsEnabled(LogEventLevel.Debug) + ? IpcSubscribers.Launching.Subscriber(pi, + (major, minor) => log.Debug($"[IPC] Invoked Penumbra.Launching IPC with API Version {major}.{minor}.")) + : null; + + using var provider = IpcSubscribers.Launching.Provider(pi); + provider.Invoke(PenumbraApi.BreakingVersion, PenumbraApi.FeatureVersion); + } + catch (Exception ex) + { + log.Error($"[IPC] Could not invoke Penumbra.Launching IPC:\n{ex}"); + } + } +} diff --git a/Penumbra/Api/IpcProviders.cs b/Penumbra/Api/IpcProviders.cs new file mode 100644 index 00000000..7cbe29f6 --- /dev/null +++ b/Penumbra/Api/IpcProviders.cs @@ -0,0 +1,161 @@ +using Dalamud.Plugin; +using OtterGui.Services; +using Penumbra.Api.Api; +using Penumbra.Api.Helpers; +using Penumbra.Communication; +using CharacterUtility = Penumbra.Interop.Services.CharacterUtility; + +namespace Penumbra.Api; + +public sealed class IpcProviders : IDisposable, IApiService +{ + private readonly List _providers; + + private readonly EventProvider _disposedProvider; + private readonly EventProvider _initializedProvider; + private readonly CharacterUtility _characterUtility; + + public IpcProviders(IDalamudPluginInterface pi, IPenumbraApi api, CharacterUtility characterUtility) + { + _characterUtility = characterUtility; + _disposedProvider = IpcSubscribers.Disposed.Provider(pi); + _initializedProvider = IpcSubscribers.Initialized.Provider(pi); + _providers = + [ + IpcSubscribers.GetCollections.Provider(pi, api.Collection), + IpcSubscribers.GetCollectionsByIdentifier.Provider(pi, api.Collection), + IpcSubscribers.GetChangedItemsForCollection.Provider(pi, api.Collection), + IpcSubscribers.GetCollection.Provider(pi, api.Collection), + IpcSubscribers.GetCollectionForObject.Provider(pi, api.Collection), + IpcSubscribers.SetCollection.Provider(pi, api.Collection), + IpcSubscribers.SetCollectionForObject.Provider(pi, api.Collection), + IpcSubscribers.CheckCurrentChangedItemFunc.Provider(pi, api.Collection), + + IpcSubscribers.ConvertTextureFile.Provider(pi, api.Editing), + IpcSubscribers.ConvertTextureData.Provider(pi, api.Editing), + + IpcSubscribers.GetDrawObjectInfo.Provider(pi, api.GameState), + IpcSubscribers.GetCutsceneParentIndex.Provider(pi, api.GameState), + IpcSubscribers.SetCutsceneParentIndex.Provider(pi, api.GameState), + IpcSubscribers.CreatingCharacterBase.Provider(pi, api.GameState), + IpcSubscribers.CreatedCharacterBase.Provider(pi, api.GameState), + IpcSubscribers.GameObjectResourcePathResolved.Provider(pi, api.GameState), + IpcSubscribers.GetCutsceneParentIndexFunc.Provider(pi, api.GameState), + IpcSubscribers.GetGameObjectFromDrawObjectFunc.Provider(pi, api.GameState), + + IpcSubscribers.GetPlayerMetaManipulations.Provider(pi, api.Meta), + IpcSubscribers.GetMetaManipulations.Provider(pi, api.Meta), + + IpcSubscribers.GetModList.Provider(pi, api.Mods), + IpcSubscribers.InstallMod.Provider(pi, api.Mods), + IpcSubscribers.ReloadMod.Provider(pi, api.Mods), + IpcSubscribers.AddMod.Provider(pi, api.Mods), + IpcSubscribers.DeleteMod.Provider(pi, api.Mods), + IpcSubscribers.ModDeleted.Provider(pi, api.Mods), + IpcSubscribers.ModAdded.Provider(pi, api.Mods), + IpcSubscribers.ModMoved.Provider(pi, api.Mods), + IpcSubscribers.CreatingPcp.Provider(pi, api.Mods), + IpcSubscribers.ParsingPcp.Provider(pi, api.Mods), + IpcSubscribers.GetModPath.Provider(pi, api.Mods), + IpcSubscribers.SetModPath.Provider(pi, api.Mods), + IpcSubscribers.GetChangedItems.Provider(pi, api.Mods), + IpcSubscribers.GetChangedItemAdapterDictionary.Provider(pi, api.Mods), + IpcSubscribers.GetChangedItemAdapterList.Provider(pi, api.Mods), + + IpcSubscribers.GetAvailableModSettings.Provider(pi, api.ModSettings), + IpcSubscribers.GetCurrentModSettings.Provider(pi, api.ModSettings), + IpcSubscribers.GetCurrentModSettingsWithTemp.Provider(pi, api.ModSettings), + IpcSubscribers.GetAllModSettings.Provider(pi, api.ModSettings), + IpcSubscribers.GetSettingsInAllCollections.Provider(pi, api.ModSettings), + IpcSubscribers.TryInheritMod.Provider(pi, api.ModSettings), + IpcSubscribers.TrySetMod.Provider(pi, api.ModSettings), + IpcSubscribers.TrySetModPriority.Provider(pi, api.ModSettings), + IpcSubscribers.TrySetModSetting.Provider(pi, api.ModSettings), + IpcSubscribers.TrySetModSettings.Provider(pi, api.ModSettings), + IpcSubscribers.ModSettingChanged.Provider(pi, api.ModSettings), + IpcSubscribers.CopyModSettings.Provider(pi, api.ModSettings), + + IpcSubscribers.ApiVersion.Provider(pi, api), + new FuncProvider<(int Major, int Minor)>(pi, "Penumbra.ApiVersions", () => api.ApiVersion), // backward compatibility + new FuncProvider(pi, "Penumbra.ApiVersion", () => api.ApiVersion.Breaking), // backward compatibility + IpcSubscribers.GetModDirectory.Provider(pi, api.PluginState), + IpcSubscribers.GetConfiguration.Provider(pi, api.PluginState), + IpcSubscribers.ModDirectoryChanged.Provider(pi, api.PluginState), + IpcSubscribers.GetEnabledState.Provider(pi, api.PluginState), + IpcSubscribers.EnabledChange.Provider(pi, api.PluginState), + IpcSubscribers.SupportedFeatures.Provider(pi, api.PluginState), + IpcSubscribers.CheckSupportedFeatures.Provider(pi, api.PluginState), + + IpcSubscribers.RedrawObject.Provider(pi, api.Redraw), + IpcSubscribers.RedrawAll.Provider(pi, api.Redraw), + IpcSubscribers.GameObjectRedrawn.Provider(pi, api.Redraw), + IpcSubscribers.RedrawCollectionMembers.Provider(pi, api.Redraw), + + IpcSubscribers.ResolveDefaultPath.Provider(pi, api.Resolve), + IpcSubscribers.ResolveInterfacePath.Provider(pi, api.Resolve), + IpcSubscribers.ResolveGameObjectPath.Provider(pi, api.Resolve), + IpcSubscribers.ResolvePlayerPath.Provider(pi, api.Resolve), + IpcSubscribers.ReverseResolveGameObjectPath.Provider(pi, api.Resolve), + IpcSubscribers.ReverseResolvePlayerPath.Provider(pi, api.Resolve), + IpcSubscribers.ResolvePlayerPaths.Provider(pi, api.Resolve), + IpcSubscribers.ResolvePlayerPathsAsync.Provider(pi, api.Resolve), + IpcSubscribers.ResolvePath.Provider(pi, api.Resolve), + IpcSubscribers.ResolvePaths.Provider(pi, api.Resolve), + + IpcSubscribers.GetGameObjectResourcePaths.Provider(pi, api.ResourceTree), + IpcSubscribers.GetPlayerResourcePaths.Provider(pi, api.ResourceTree), + IpcSubscribers.GetGameObjectResourcesOfType.Provider(pi, api.ResourceTree), + IpcSubscribers.GetPlayerResourcesOfType.Provider(pi, api.ResourceTree), + IpcSubscribers.GetGameObjectResourceTrees.Provider(pi, api.ResourceTree), + IpcSubscribers.GetPlayerResourceTrees.Provider(pi, api.ResourceTree), + + IpcSubscribers.CreateTemporaryCollection.Provider(pi, api.Temporary), + IpcSubscribers.DeleteTemporaryCollection.Provider(pi, api.Temporary), + IpcSubscribers.AssignTemporaryCollection.Provider(pi, api.Temporary), + IpcSubscribers.AddTemporaryModAll.Provider(pi, api.Temporary), + IpcSubscribers.AddTemporaryMod.Provider(pi, api.Temporary), + IpcSubscribers.RemoveTemporaryModAll.Provider(pi, api.Temporary), + IpcSubscribers.RemoveTemporaryMod.Provider(pi, api.Temporary), + IpcSubscribers.SetTemporaryModSettings.Provider(pi, api.Temporary), + IpcSubscribers.SetTemporaryModSettingsPlayer.Provider(pi, api.Temporary), + IpcSubscribers.RemoveTemporaryModSettings.Provider(pi, api.Temporary), + IpcSubscribers.RemoveTemporaryModSettingsPlayer.Provider(pi, api.Temporary), + IpcSubscribers.RemoveAllTemporaryModSettings.Provider(pi, api.Temporary), + IpcSubscribers.RemoveAllTemporaryModSettingsPlayer.Provider(pi, api.Temporary), + IpcSubscribers.QueryTemporaryModSettings.Provider(pi, api.Temporary), + IpcSubscribers.QueryTemporaryModSettingsPlayer.Provider(pi, api.Temporary), + + IpcSubscribers.ChangedItemTooltip.Provider(pi, api.Ui), + IpcSubscribers.ChangedItemClicked.Provider(pi, api.Ui), + IpcSubscribers.PreSettingsTabBarDraw.Provider(pi, api.Ui), + IpcSubscribers.PreSettingsDraw.Provider(pi, api.Ui), + IpcSubscribers.PostEnabledDraw.Provider(pi, api.Ui), + IpcSubscribers.PostSettingsDraw.Provider(pi, api.Ui), + IpcSubscribers.OpenMainWindow.Provider(pi, api.Ui), + IpcSubscribers.CloseMainWindow.Provider(pi, api.Ui), + IpcSubscribers.RegisterSettingsSection.Provider(pi, api.Ui), + IpcSubscribers.UnregisterSettingsSection.Provider(pi, api.Ui), + ]; + if (_characterUtility.Ready) + _initializedProvider.Invoke(); + else + _characterUtility.LoadingFinished.Subscribe(OnCharacterUtilityReady, CharacterUtilityFinished.Priority.IpcProvider); + } + + private void OnCharacterUtilityReady() + { + _initializedProvider.Invoke(); + _characterUtility.LoadingFinished.Unsubscribe(OnCharacterUtilityReady); + } + + public void Dispose() + { + _characterUtility.LoadingFinished.Unsubscribe(OnCharacterUtilityReady); + foreach (var provider in _providers) + provider.Dispose(); + _providers.Clear(); + _initializedProvider.Dispose(); + _disposedProvider.Invoke(); + _disposedProvider.Dispose(); + } +} diff --git a/Penumbra/Api/IpcTester/CollectionsIpcTester.cs b/Penumbra/Api/IpcTester/CollectionsIpcTester.cs new file mode 100644 index 00000000..f033b7c3 --- /dev/null +++ b/Penumbra/Api/IpcTester/CollectionsIpcTester.cs @@ -0,0 +1,189 @@ +using Dalamud.Bindings.ImGui; +using Dalamud.Interface; +using Dalamud.Interface.Utility; +using Dalamud.Plugin; +using OtterGui; +using OtterGui.Raii; +using OtterGui.Services; +using Penumbra.Api.Enums; +using Penumbra.Api.IpcSubscribers; +using Penumbra.Collections.Manager; +using Penumbra.GameData.Data; +using ImGuiClip = OtterGui.ImGuiClip; + +namespace Penumbra.Api.IpcTester; + +public class CollectionsIpcTester(IDalamudPluginInterface pi) : IUiService +{ + private int _objectIdx; + private string _collectionIdString = string.Empty; + private Guid? _collectionId; + private bool _allowCreation = true; + private bool _allowDeletion = true; + private ApiCollectionType _type = ApiCollectionType.Yourself; + + private Dictionary _collections = []; + private (string, ChangedItemType, uint)[] _changedItems = []; + private PenumbraApiEc _returnCode = PenumbraApiEc.Success; + private (Guid Id, string Name)? _oldCollection; + + public void Draw() + { + using var _ = ImRaii.TreeNode("Collections"); + if (!_) + return; + + ImGuiUtil.GenericEnumCombo("Collection Type", 200, _type, out _type, t => ((CollectionType)t).ToName()); + ImGui.InputInt("Object Index##Collections", ref _objectIdx, 0, 0); + ImGuiUtil.GuidInput("Collection Id##Collections", "Collection Identifier...", string.Empty, ref _collectionId, ref _collectionIdString); + ImGui.Checkbox("Allow Assignment Creation", ref _allowCreation); + ImGui.SameLine(); + ImGui.Checkbox("Allow Assignment Deletion", ref _allowDeletion); + + using var table = ImRaii.Table(string.Empty, 4, ImGuiTableFlags.SizingFixedFit); + if (!table) + return; + + IpcTester.DrawIntro("Last Return Code", _returnCode.ToString()); + if (_oldCollection != null) + ImGui.TextUnformatted(!_oldCollection.HasValue ? "Created" : _oldCollection.ToString()); + + IpcTester.DrawIntro(GetCollectionsByIdentifier.Label, "Collection Identifier"); + var collectionList = new GetCollectionsByIdentifier(pi).Invoke(_collectionIdString); + if (collectionList.Count == 0) + { + DrawCollection(null); + } + else + { + DrawCollection(collectionList[0]); + foreach (var pair in collectionList.Skip(1)) + { + ImGui.TableNextRow(); + ImGui.TableNextColumn(); + ImGui.TableNextColumn(); + ImGui.TableNextColumn(); + DrawCollection(pair); + } + } + + IpcTester.DrawIntro(GetCollection.Label, "Current Collection"); + DrawCollection(new GetCollection(pi).Invoke(ApiCollectionType.Current)); + + IpcTester.DrawIntro(GetCollection.Label, "Default Collection"); + DrawCollection(new GetCollection(pi).Invoke(ApiCollectionType.Default)); + + IpcTester.DrawIntro(GetCollection.Label, "Interface Collection"); + DrawCollection(new GetCollection(pi).Invoke(ApiCollectionType.Interface)); + + IpcTester.DrawIntro(GetCollection.Label, "Special Collection"); + DrawCollection(new GetCollection(pi).Invoke(_type)); + + IpcTester.DrawIntro(GetCollections.Label, "Collections"); + DrawCollectionPopup(); + if (ImGui.Button("Get##Collections")) + { + _collections = new GetCollections(pi).Invoke(); + ImGui.OpenPopup("Collections"); + } + + IpcTester.DrawIntro(GetCollectionForObject.Label, "Get Object Collection"); + var (valid, individual, effectiveCollection) = new GetCollectionForObject(pi).Invoke(_objectIdx); + DrawCollection(effectiveCollection); + ImGui.SameLine(); + ImGui.TextUnformatted($"({(valid ? "Valid" : "Invalid")} Object{(individual ? ", Individual Assignment)" : ")")}"); + + IpcTester.DrawIntro(SetCollection.Label, "Set Special Collection"); + if (ImGui.Button("Set##SpecialCollection")) + (_returnCode, _oldCollection) = + new SetCollection(pi).Invoke(_type, _collectionId.GetValueOrDefault(Guid.Empty), _allowCreation, _allowDeletion); + ImGui.TableNextColumn(); + if (ImGui.Button("Remove##SpecialCollection")) + (_returnCode, _oldCollection) = new SetCollection(pi).Invoke(_type, null, _allowCreation, _allowDeletion); + + IpcTester.DrawIntro(SetCollectionForObject.Label, "Set Object Collection"); + if (ImGui.Button("Set##ObjectCollection")) + (_returnCode, _oldCollection) = new SetCollectionForObject(pi).Invoke(_objectIdx, _collectionId.GetValueOrDefault(Guid.Empty), + _allowCreation, _allowDeletion); + ImGui.TableNextColumn(); + if (ImGui.Button("Remove##ObjectCollection")) + (_returnCode, _oldCollection) = new SetCollectionForObject(pi).Invoke(_objectIdx, null, _allowCreation, _allowDeletion); + + IpcTester.DrawIntro(GetChangedItemsForCollection.Label, "Changed Item List"); + DrawChangedItemPopup(); + if (ImGui.Button("Get##ChangedItems")) + { + var items = new GetChangedItemsForCollection(pi).Invoke(_collectionId.GetValueOrDefault(Guid.Empty)); + _changedItems = items.Select(kvp => + { + var (type, id) = kvp.Value.ToApiObject(); + return (kvp.Key, type, id); + }).ToArray(); + ImGui.OpenPopup("Changed Item List"); + } + IpcTester.DrawIntro(RedrawCollectionMembers.Label, "Redraw Collection Members"); + if (ImGui.Button("Redraw##ObjectCollection")) + new RedrawCollectionMembers(pi).Invoke(collectionList[0].Id, RedrawType.Redraw); + + } + + private void DrawChangedItemPopup() + { + ImGui.SetNextWindowSize(ImGuiHelpers.ScaledVector2(500, 500)); + using var p = ImRaii.Popup("Changed Item List"); + if (!p) + return; + + using (var table = ImRaii.Table("##ChangedItems", 3, ImGuiTableFlags.SizingFixedFit)) + { + if (table) + ImGuiClip.ClippedDraw(_changedItems, t => + { + ImGuiUtil.DrawTableColumn(t.Item1); + ImGuiUtil.DrawTableColumn(t.Item2.ToString()); + ImGuiUtil.DrawTableColumn(t.Item3.ToString()); + }, ImGui.GetTextLineHeightWithSpacing()); + } + + if (ImGui.Button("Close", -Vector2.UnitX) || !ImGui.IsWindowFocused()) + ImGui.CloseCurrentPopup(); + } + + private void DrawCollectionPopup() + { + ImGui.SetNextWindowSize(ImGuiHelpers.ScaledVector2(500, 500)); + using var p = ImRaii.Popup("Collections"); + if (!p) + return; + + using (var t = ImRaii.Table("collections", 2, ImGuiTableFlags.SizingFixedFit)) + { + if (t) + foreach (var collection in _collections) + { + ImGui.TableNextColumn(); + DrawCollection((collection.Key, collection.Value)); + } + } + + if (ImGui.Button("Close", -Vector2.UnitX) || !ImGui.IsWindowFocused()) + ImGui.CloseCurrentPopup(); + } + + private static void DrawCollection((Guid Id, string Name)? collection) + { + if (collection == null) + { + ImGui.TextUnformatted(""); + ImGui.TableNextColumn(); + return; + } + + ImGui.TextUnformatted(collection.Value.Name); + ImGui.TableNextColumn(); + using (ImRaii.PushFont(UiBuilder.MonoFont)) + { + ImGuiUtil.CopyOnClickSelectable(collection.Value.Id.ToString()); + } + } +} diff --git a/Penumbra/Api/IpcTester/EditingIpcTester.cs b/Penumbra/Api/IpcTester/EditingIpcTester.cs new file mode 100644 index 00000000..d754cf90 --- /dev/null +++ b/Penumbra/Api/IpcTester/EditingIpcTester.cs @@ -0,0 +1,70 @@ +using Dalamud.Bindings.ImGui; +using Dalamud.Plugin; +using OtterGui; +using OtterGui.Raii; +using OtterGui.Services; +using Penumbra.Api.Enums; +using Penumbra.Api.IpcSubscribers; + +namespace Penumbra.Api.IpcTester; + +public class EditingIpcTester(IDalamudPluginInterface pi) : IUiService +{ + private string _inputPath = string.Empty; + private string _inputPath2 = string.Empty; + private string _outputPath = string.Empty; + private string _outputPath2 = string.Empty; + + private TextureType _typeSelector; + private bool _mipMaps = true; + + private Task? _task1; + private Task? _task2; + + public void Draw() + { + using var _ = ImRaii.TreeNode("Editing"); + if (!_) + return; + + ImGui.InputTextWithHint("##inputPath", "Input Texture Path...", ref _inputPath, 256); + ImGui.InputTextWithHint("##outputPath", "Output Texture Path...", ref _outputPath, 256); + ImGui.InputTextWithHint("##inputPath2", "Input Texture Path 2...", ref _inputPath2, 256); + ImGui.InputTextWithHint("##outputPath2", "Output Texture Path 2...", ref _outputPath2, 256); + TypeCombo(); + ImGui.Checkbox("Add MipMaps", ref _mipMaps); + + using var table = ImRaii.Table("...", 3, ImGuiTableFlags.SizingFixedFit); + if (!table) + return; + + IpcTester.DrawIntro(ConvertTextureFile.Label, (string)"Convert Texture 1"); + if (ImGuiUtil.DrawDisabledButton("Save 1", Vector2.Zero, string.Empty, _task1 is { IsCompleted: false })) + _task1 = new ConvertTextureFile(pi).Invoke(_inputPath, _outputPath, _typeSelector, _mipMaps); + ImGui.SameLine(); + ImGui.TextUnformatted(_task1 == null ? "Not Initiated" : _task1.Status.ToString()); + if (ImGui.IsItemHovered() && _task1?.Status == TaskStatus.Faulted) + ImGui.SetTooltip(_task1.Exception?.ToString()); + + IpcTester.DrawIntro(ConvertTextureFile.Label, (string)"Convert Texture 2"); + if (ImGuiUtil.DrawDisabledButton("Save 2", Vector2.Zero, string.Empty, _task2 is { IsCompleted: false })) + _task2 = new ConvertTextureFile(pi).Invoke(_inputPath2, _outputPath2, _typeSelector, _mipMaps); + ImGui.SameLine(); + ImGui.TextUnformatted(_task2 == null ? "Not Initiated" : _task2.Status.ToString()); + if (ImGui.IsItemHovered() && _task2?.Status == TaskStatus.Faulted) + ImGui.SetTooltip(_task2.Exception?.ToString()); + } + + private void TypeCombo() + { + using var combo = ImRaii.Combo("Convert To", _typeSelector.ToString()); + if (!combo) + return; + + foreach (var value in Enum.GetValues()) + { + if (ImGui.Selectable(value.ToString(), _typeSelector == value)) + _typeSelector = value; + } + } +} diff --git a/Penumbra/Api/IpcTester/GameStateIpcTester.cs b/Penumbra/Api/IpcTester/GameStateIpcTester.cs new file mode 100644 index 00000000..38a09714 --- /dev/null +++ b/Penumbra/Api/IpcTester/GameStateIpcTester.cs @@ -0,0 +1,139 @@ +using Dalamud.Bindings.ImGui; +using Dalamud.Interface; +using Dalamud.Plugin; +using OtterGui.Raii; +using OtterGui.Services; +using Penumbra.Api.Enums; +using Penumbra.Api.Helpers; +using Penumbra.Api.IpcSubscribers; +using Penumbra.String; + +namespace Penumbra.Api.IpcTester; + +public class GameStateIpcTester : IUiService, IDisposable +{ + private readonly IDalamudPluginInterface _pi; + public readonly EventSubscriber CharacterBaseCreating; + public readonly EventSubscriber CharacterBaseCreated; + public readonly EventSubscriber GameObjectResourcePathResolved; + + private string _lastCreatedGameObjectName = string.Empty; + private nint _lastCreatedDrawObject = nint.Zero; + private DateTimeOffset _lastCreatedGameObjectTime = DateTimeOffset.MaxValue; + private string _lastResolvedGamePath = string.Empty; + private string _lastResolvedFullPath = string.Empty; + private string _lastResolvedObject = string.Empty; + private DateTimeOffset _lastResolvedGamePathTime = DateTimeOffset.MaxValue; + private string _currentDrawObjectString = string.Empty; + private nint _currentDrawObject = nint.Zero; + private int _currentCutsceneActor; + private int _currentCutsceneParent; + private PenumbraApiEc _cutsceneError = PenumbraApiEc.Success; + + public GameStateIpcTester(IDalamudPluginInterface pi) + { + _pi = pi; + CharacterBaseCreating = IpcSubscribers.CreatingCharacterBase.Subscriber(pi, UpdateLastCreated); + CharacterBaseCreated = IpcSubscribers.CreatedCharacterBase.Subscriber(pi, UpdateLastCreated2); + GameObjectResourcePathResolved = IpcSubscribers.GameObjectResourcePathResolved.Subscriber(pi, UpdateGameObjectResourcePath); + CharacterBaseCreating.Disable(); + CharacterBaseCreated.Disable(); + GameObjectResourcePathResolved.Disable(); + } + + public void Dispose() + { + CharacterBaseCreating.Dispose(); + CharacterBaseCreated.Dispose(); + GameObjectResourcePathResolved.Dispose(); + } + + public void Draw() + { + using var _ = ImRaii.TreeNode("Game State"); + if (!_) + return; + + if (ImGui.InputTextWithHint("##drawObject", "Draw Object Address..", ref _currentDrawObjectString, 16, + ImGuiInputTextFlags.CharsHexadecimal)) + _currentDrawObject = nint.TryParse(_currentDrawObjectString, NumberStyles.HexNumber, CultureInfo.InvariantCulture, + out var tmp) + ? tmp + : nint.Zero; + + ImGui.InputInt("Cutscene Actor", ref _currentCutsceneActor, 0); + ImGui.InputInt("Cutscene Parent", ref _currentCutsceneParent, 0); + if (_cutsceneError is not PenumbraApiEc.Success) + { + ImGui.SameLine(); + ImGui.TextUnformatted("Invalid Argument on last Call"); + } + + using var table = ImRaii.Table(string.Empty, 3, ImGuiTableFlags.SizingFixedFit); + if (!table) + return; + + IpcTester.DrawIntro(GetDrawObjectInfo.Label, "Draw Object Info"); + if (_currentDrawObject == nint.Zero) + { + ImGui.TextUnformatted("Invalid"); + } + else + { + var (ptr, (collectionId, collectionName)) = new GetDrawObjectInfo(_pi).Invoke(_currentDrawObject); + ImGui.TextUnformatted(ptr == nint.Zero ? $"No Actor Associated, {collectionName}" : $"{ptr:X}, {collectionName}"); + ImGui.SameLine(); + using (ImRaii.PushFont(UiBuilder.MonoFont)) + { + ImGui.TextUnformatted(collectionId.ToString()); + } + } + + IpcTester.DrawIntro(GetCutsceneParentIndex.Label, "Cutscene Parent"); + ImGui.TextUnformatted(new GetCutsceneParentIndex(_pi).Invoke(_currentCutsceneActor).ToString()); + + IpcTester.DrawIntro(SetCutsceneParentIndex.Label, "Cutscene Parent"); + if (ImGui.Button("Set Parent")) + _cutsceneError = new SetCutsceneParentIndex(_pi) + .Invoke(_currentCutsceneActor, _currentCutsceneParent); + + IpcTester.DrawIntro(CreatingCharacterBase.Label, "Last Drawobject created"); + if (_lastCreatedGameObjectTime < DateTimeOffset.Now) + ImGui.TextUnformatted(_lastCreatedDrawObject != nint.Zero + ? $"0x{_lastCreatedDrawObject:X} for <{_lastCreatedGameObjectName}> at {_lastCreatedGameObjectTime}" + : $"NULL for <{_lastCreatedGameObjectName}> at {_lastCreatedGameObjectTime}"); + + IpcTester.DrawIntro(IpcSubscribers.GameObjectResourcePathResolved.Label, "Last GamePath resolved"); + if (_lastResolvedGamePathTime < DateTimeOffset.Now) + ImGui.TextUnformatted( + $"{_lastResolvedGamePath} -> {_lastResolvedFullPath} for <{_lastResolvedObject}> at {_lastResolvedGamePathTime}"); + } + + private void UpdateLastCreated(nint gameObject, Guid _, nint _2, nint _3, nint _4) + { + _lastCreatedGameObjectName = GetObjectName(gameObject); + _lastCreatedGameObjectTime = DateTimeOffset.Now; + _lastCreatedDrawObject = nint.Zero; + } + + private void UpdateLastCreated2(nint gameObject, Guid _, nint drawObject) + { + _lastCreatedGameObjectName = GetObjectName(gameObject); + _lastCreatedGameObjectTime = DateTimeOffset.Now; + _lastCreatedDrawObject = drawObject; + } + + private void UpdateGameObjectResourcePath(nint gameObject, string gamePath, string fullPath) + { + _lastResolvedObject = GetObjectName(gameObject); + _lastResolvedGamePath = gamePath; + _lastResolvedFullPath = fullPath; + _lastResolvedGamePathTime = DateTimeOffset.Now; + } + + private static unsafe string GetObjectName(nint gameObject) + { + var obj = (FFXIVClientStructs.FFXIV.Client.Game.Object.GameObject*)gameObject; + return obj != null && obj->Name[0] != 0 ? new ByteString(obj->Name).ToString() : "Unknown"; + } +} diff --git a/Penumbra/Api/IpcTester/IpcTester.cs b/Penumbra/Api/IpcTester/IpcTester.cs new file mode 100644 index 00000000..b03d7e03 --- /dev/null +++ b/Penumbra/Api/IpcTester/IpcTester.cs @@ -0,0 +1,133 @@ +using Dalamud.Plugin.Services; +using FFXIVClientStructs.FFXIV.Client.System.Framework; +using Dalamud.Bindings.ImGui; +using OtterGui.Services; +using Penumbra.Api.Api; + +namespace Penumbra.Api.IpcTester; + +public class IpcTester( + IpcProviders ipcProviders, + IPenumbraApi api, + PluginStateIpcTester pluginStateIpcTester, + UiIpcTester uiIpcTester, + RedrawingIpcTester redrawingIpcTester, + GameStateIpcTester gameStateIpcTester, + ResolveIpcTester resolveIpcTester, + CollectionsIpcTester collectionsIpcTester, + MetaIpcTester metaIpcTester, + ModsIpcTester modsIpcTester, + ModSettingsIpcTester modSettingsIpcTester, + EditingIpcTester editingIpcTester, + TemporaryIpcTester temporaryIpcTester, + ResourceTreeIpcTester resourceTreeIpcTester, + IFramework framework) : IUiService +{ + private readonly IpcProviders _ipcProviders = ipcProviders; + private DateTime _lastUpdate; + private bool _subscribed = false; + + public void Draw() + { + try + { + _lastUpdate = framework.LastUpdateUTC.AddSeconds(1); + Subscribe(); + + ImGui.TextUnformatted($"API Version: {api.ApiVersion.Breaking}.{api.ApiVersion.Feature:D4}"); + collectionsIpcTester.Draw(); + editingIpcTester.Draw(); + gameStateIpcTester.Draw(); + metaIpcTester.Draw(); + modSettingsIpcTester.Draw(); + modsIpcTester.Draw(); + pluginStateIpcTester.Draw(); + redrawingIpcTester.Draw(); + resolveIpcTester.Draw(); + resourceTreeIpcTester.Draw(); + uiIpcTester.Draw(); + temporaryIpcTester.Draw(); + temporaryIpcTester.DrawCollections(); + temporaryIpcTester.DrawMods(); + } + catch (Exception e) + { + Penumbra.Log.Error($"Error during IPC Tests:\n{e}"); + } + } + + internal static void DrawIntro(string label, string info) + { + ImGui.TableNextRow(); + ImGui.TableNextColumn(); + ImGui.TextUnformatted(label); + ImGui.TableNextColumn(); + ImGui.TextUnformatted(info); + ImGui.TableNextColumn(); + } + + private void Subscribe() + { + if (_subscribed) + return; + + Penumbra.Log.Debug("[IPCTester] Subscribed to IPC events for IPC tester."); + gameStateIpcTester.GameObjectResourcePathResolved.Enable(); + gameStateIpcTester.CharacterBaseCreated.Enable(); + gameStateIpcTester.CharacterBaseCreating.Enable(); + modSettingsIpcTester.SettingChanged.Enable(); + modsIpcTester.DeleteSubscriber.Enable(); + modsIpcTester.AddSubscriber.Enable(); + modsIpcTester.MoveSubscriber.Enable(); + pluginStateIpcTester.ModDirectoryChanged.Enable(); + pluginStateIpcTester.Initialized.Enable(); + pluginStateIpcTester.Disposed.Enable(); + pluginStateIpcTester.EnabledChange.Enable(); + redrawingIpcTester.Redrawn.Enable(); + uiIpcTester.PreSettingsTabBar.Enable(); + uiIpcTester.PreSettingsPanel.Enable(); + uiIpcTester.PostEnabled.Enable(); + uiIpcTester.PostSettingsPanelDraw.Enable(); + uiIpcTester.ChangedItemTooltip.Enable(); + uiIpcTester.ChangedItemClicked.Enable(); + + framework.Update += CheckUnsubscribe; + _subscribed = true; + } + + private void CheckUnsubscribe(IFramework framework1) + { + if (_lastUpdate > framework.LastUpdateUTC) + return; + + Unsubscribe(); + framework.Update -= CheckUnsubscribe; + } + + private void Unsubscribe() + { + if (!_subscribed) + return; + + Penumbra.Log.Debug("[IPCTester] Unsubscribed from IPC events for IPC tester."); + _subscribed = false; + gameStateIpcTester.GameObjectResourcePathResolved.Disable(); + gameStateIpcTester.CharacterBaseCreated.Disable(); + gameStateIpcTester.CharacterBaseCreating.Disable(); + modSettingsIpcTester.SettingChanged.Disable(); + modsIpcTester.DeleteSubscriber.Disable(); + modsIpcTester.AddSubscriber.Disable(); + modsIpcTester.MoveSubscriber.Disable(); + pluginStateIpcTester.ModDirectoryChanged.Disable(); + pluginStateIpcTester.Initialized.Disable(); + pluginStateIpcTester.Disposed.Disable(); + pluginStateIpcTester.EnabledChange.Disable(); + redrawingIpcTester.Redrawn.Disable(); + uiIpcTester.PreSettingsTabBar.Disable(); + uiIpcTester.PreSettingsPanel.Disable(); + uiIpcTester.PostEnabled.Disable(); + uiIpcTester.PostSettingsPanelDraw.Disable(); + uiIpcTester.ChangedItemTooltip.Disable(); + uiIpcTester.ChangedItemClicked.Disable(); + } +} diff --git a/Penumbra/Api/IpcTester/MetaIpcTester.cs b/Penumbra/Api/IpcTester/MetaIpcTester.cs new file mode 100644 index 00000000..bee1981c --- /dev/null +++ b/Penumbra/Api/IpcTester/MetaIpcTester.cs @@ -0,0 +1,52 @@ +using Dalamud.Bindings.ImGui; +using Dalamud.Plugin; +using OtterGui.Raii; +using OtterGui.Services; +using OtterGui.Text; +using Penumbra.Api.Api; +using Penumbra.Api.IpcSubscribers; +using Penumbra.Meta.Manipulations; + +namespace Penumbra.Api.IpcTester; + +public class MetaIpcTester(IDalamudPluginInterface pi) : IUiService +{ + private int _gameObjectIndex; + private string _metaBase64 = string.Empty; + private MetaDictionary _metaDict = new(); + private byte _parsedVersion = byte.MaxValue; + + public void Draw() + { + using var _ = ImRaii.TreeNode("Meta"); + if (!_) + return; + + ImGui.InputInt("##metaIdx", ref _gameObjectIndex, 0, 0); + if (ImUtf8.InputText("##metaText"u8, ref _metaBase64, "Base64 Metadata..."u8)) + if (!MetaApi.ConvertManips(_metaBase64, out _metaDict!, out _parsedVersion)) + _metaDict ??= new MetaDictionary(); + + + using var table = ImRaii.Table(string.Empty, 3, ImGuiTableFlags.SizingFixedFit); + if (!table) + return; + + IpcTester.DrawIntro(GetPlayerMetaManipulations.Label, "Player Meta Manipulations"); + if (ImGui.Button("Copy to Clipboard##Player")) + { + var base64 = new GetPlayerMetaManipulations(pi).Invoke(); + ImGui.SetClipboardText(base64); + } + + IpcTester.DrawIntro(GetMetaManipulations.Label, "Game Object Manipulations"); + if (ImGui.Button("Copy to Clipboard##GameObject")) + { + var base64 = new GetMetaManipulations(pi).Invoke(_gameObjectIndex); + ImGui.SetClipboardText(base64); + } + + IpcTester.DrawIntro(string.Empty, "Parsed Data"); + ImUtf8.Text($"Version: {_parsedVersion}, Count: {_metaDict.Count}"); + } +} diff --git a/Penumbra/Api/IpcTester/ModSettingsIpcTester.cs b/Penumbra/Api/IpcTester/ModSettingsIpcTester.cs new file mode 100644 index 00000000..152efa45 --- /dev/null +++ b/Penumbra/Api/IpcTester/ModSettingsIpcTester.cs @@ -0,0 +1,224 @@ +using Dalamud.Bindings.ImGui; +using Dalamud.Plugin; +using OtterGui; +using OtterGui.Raii; +using OtterGui.Services; +using OtterGui.Text; +using Penumbra.Api.Enums; +using Penumbra.Api.Helpers; +using Penumbra.Api.IpcSubscribers; +using Penumbra.UI; + +namespace Penumbra.Api.IpcTester; + +public class ModSettingsIpcTester : IUiService, IDisposable +{ + private readonly IDalamudPluginInterface _pi; + public readonly EventSubscriber SettingChanged; + + private PenumbraApiEc _lastSettingsError = PenumbraApiEc.Success; + private ModSettingChange _lastSettingChangeType; + private Guid _lastSettingChangeCollection = Guid.Empty; + private string _lastSettingChangeMod = string.Empty; + private bool _lastSettingChangeInherited; + private DateTimeOffset _lastSettingChange; + + private string _settingsModDirectory = string.Empty; + private string _settingsModName = string.Empty; + private Guid? _settingsCollection; + private string _settingsCollectionName = string.Empty; + private bool _settingsIgnoreInheritance; + private bool _settingsIgnoreTemporary; + private int _settingsKey; + private bool _settingsInherit; + private bool _settingsTemporary; + private bool _settingsEnabled; + private int _settingsPriority; + private IReadOnlyDictionary? _availableSettings; + private Dictionary>? _currentSettings; + private Dictionary>, bool, bool)>? _allSettings; + + public ModSettingsIpcTester(IDalamudPluginInterface pi) + { + _pi = pi; + SettingChanged = ModSettingChanged.Subscriber(pi, UpdateLastModSetting); + SettingChanged.Disable(); + } + + public void Dispose() + { + SettingChanged.Dispose(); + } + + public void Draw() + { + using var _ = ImRaii.TreeNode("Mod Settings"); + if (!_) + return; + + ImGui.InputTextWithHint("##settingsDir", "Mod Directory Name...", ref _settingsModDirectory, 100); + ImGui.InputTextWithHint("##settingsName", "Mod Name...", ref _settingsModName, 100); + ImGuiUtil.GuidInput("##settingsCollection", "Collection...", string.Empty, ref _settingsCollection, ref _settingsCollectionName); + ImUtf8.Checkbox("Ignore Inheritance"u8, ref _settingsIgnoreInheritance); + ImUtf8.Checkbox("Ignore Temporary"u8, ref _settingsIgnoreTemporary); + ImUtf8.InputScalar("Key"u8, ref _settingsKey); + var collection = _settingsCollection.GetValueOrDefault(Guid.Empty); + + using var table = ImRaii.Table(string.Empty, 3, ImGuiTableFlags.SizingFixedFit); + if (!table) + return; + + IpcTester.DrawIntro("Last Error", _lastSettingsError.ToString()); + + IpcTester.DrawIntro(ModSettingChanged.Label, "Last Mod Setting Changed"); + ImGui.TextUnformatted(_lastSettingChangeMod.Length > 0 + ? $"{_lastSettingChangeType} of {_lastSettingChangeMod} in {_lastSettingChangeCollection}{(_lastSettingChangeInherited ? " (Inherited)" : string.Empty)} at {_lastSettingChange}" + : "None"); + + IpcTester.DrawIntro(GetAvailableModSettings.Label, "Get Available Settings"); + if (ImGui.Button("Get##Available")) + { + _availableSettings = new GetAvailableModSettings(_pi).Invoke(_settingsModDirectory, _settingsModName); + _lastSettingsError = _availableSettings == null ? PenumbraApiEc.ModMissing : PenumbraApiEc.Success; + } + + IpcTester.DrawIntro(GetCurrentModSettings.Label, "Get Current Settings"); + if (ImGui.Button("Get##Current")) + { + var ret = new GetCurrentModSettings(_pi) + .Invoke(collection, _settingsModDirectory, _settingsModName, _settingsIgnoreInheritance); + _lastSettingsError = ret.Item1; + if (ret.Item1 == PenumbraApiEc.Success) + { + _settingsEnabled = ret.Item2?.Item1 ?? false; + _settingsInherit = ret.Item2?.Item4 ?? true; + _settingsTemporary = false; + _settingsPriority = ret.Item2?.Item2 ?? 0; + _currentSettings = ret.Item2?.Item3; + } + else + { + _currentSettings = null; + } + } + + IpcTester.DrawIntro(GetCurrentModSettingsWithTemp.Label, "Get Current Settings With Temp"); + if (ImGui.Button("Get##CurrentTemp")) + { + var ret = new GetCurrentModSettingsWithTemp(_pi) + .Invoke(collection, _settingsModDirectory, _settingsModName, _settingsIgnoreInheritance, _settingsIgnoreTemporary, _settingsKey); + _lastSettingsError = ret.Item1; + if (ret.Item1 == PenumbraApiEc.Success) + { + _settingsEnabled = ret.Item2?.Item1 ?? false; + _settingsInherit = ret.Item2?.Item4 ?? true; + _settingsTemporary = ret.Item2?.Item5 ?? false; + _settingsPriority = ret.Item2?.Item2 ?? 0; + _currentSettings = ret.Item2?.Item3; + } + else + { + _currentSettings = null; + } + } + + IpcTester.DrawIntro(GetAllModSettings.Label, "Get All Mod Settings"); + if (ImGui.Button("Get##All")) + { + var ret = new GetAllModSettings(_pi).Invoke(collection, _settingsIgnoreInheritance, _settingsIgnoreTemporary, _settingsKey); + _lastSettingsError = ret.Item1; + _allSettings = ret.Item2; + } + + if (_allSettings != null) + { + ImGui.SameLine(); + ImUtf8.Text($"{_allSettings.Count} Mods"); + } + + IpcTester.DrawIntro(TryInheritMod.Label, "Inherit Mod"); + ImGui.Checkbox("##inherit", ref _settingsInherit); + ImGui.SameLine(); + if (ImGui.Button("Set##Inherit")) + _lastSettingsError = new TryInheritMod(_pi) + .Invoke(collection, _settingsModDirectory, _settingsInherit, _settingsModName); + + IpcTester.DrawIntro(TrySetMod.Label, "Set Enabled"); + ImGui.Checkbox("##enabled", ref _settingsEnabled); + ImGui.SameLine(); + if (ImGui.Button("Set##Enabled")) + _lastSettingsError = new TrySetMod(_pi) + .Invoke(collection, _settingsModDirectory, _settingsEnabled, _settingsModName); + + IpcTester.DrawIntro(TrySetModPriority.Label, "Set Priority"); + ImGui.SetNextItemWidth(200 * UiHelpers.Scale); + ImGui.DragInt("##Priority", ref _settingsPriority); + ImGui.SameLine(); + if (ImGui.Button("Set##Priority")) + _lastSettingsError = new TrySetModPriority(_pi) + .Invoke(collection, _settingsModDirectory, _settingsPriority, _settingsModName); + + IpcTester.DrawIntro(CopyModSettings.Label, "Copy Mod Settings"); + if (ImGui.Button("Copy Settings")) + _lastSettingsError = new CopyModSettings(_pi) + .Invoke(_settingsCollection, _settingsModDirectory, _settingsModName); + + ImGuiUtil.HoverTooltip("Copy settings from Mod Directory Name to Mod Name (as directory) in collection."); + + IpcTester.DrawIntro(TrySetModSetting.Label, "Set Setting(s)"); + if (_availableSettings == null) + return; + + foreach (var (group, (list, type)) in _availableSettings) + { + using var id = ImRaii.PushId(group); + var preview = list.Length > 0 ? list[0] : string.Empty; + if (_currentSettings != null && _currentSettings.TryGetValue(group, out var current) && current.Count > 0) + { + preview = current[0]; + } + else + { + current = []; + if (_currentSettings != null) + _currentSettings[group] = current; + } + + ImGui.SetNextItemWidth(200 * UiHelpers.Scale); + using (var c = ImRaii.Combo("##group", preview)) + { + if (c) + foreach (var s in list) + { + var contained = current.Contains(s); + if (ImGui.Checkbox(s, ref contained)) + { + if (contained) + current.Add(s); + else + current.Remove(s); + } + } + } + + ImGui.SameLine(); + if (ImGui.Button("Set##setting")) + _lastSettingsError = type == GroupType.Single + ? new TrySetModSetting(_pi).Invoke(collection, _settingsModDirectory, group, current.Count > 0 ? current[0] : string.Empty, + _settingsModName) + : new TrySetModSettings(_pi).Invoke(collection, _settingsModDirectory, group, current.ToArray(), _settingsModName); + + ImGui.SameLine(); + ImGui.TextUnformatted(group); + } + } + + private void UpdateLastModSetting(ModSettingChange type, Guid collection, string mod, bool inherited) + { + _lastSettingChangeType = type; + _lastSettingChangeCollection = collection; + _lastSettingChangeMod = mod; + _lastSettingChangeInherited = inherited; + _lastSettingChange = DateTimeOffset.Now; + } +} diff --git a/Penumbra/Api/IpcTester/ModsIpcTester.cs b/Penumbra/Api/IpcTester/ModsIpcTester.cs new file mode 100644 index 00000000..9ea53366 --- /dev/null +++ b/Penumbra/Api/IpcTester/ModsIpcTester.cs @@ -0,0 +1,184 @@ +using Dalamud.Bindings.ImGui; +using Dalamud.Interface.Utility; +using Dalamud.Plugin; +using OtterGui.Raii; +using OtterGui.Services; +using OtterGui.Text; +using Penumbra.Api.Enums; +using Penumbra.Api.Helpers; +using Penumbra.Api.IpcSubscribers; + +namespace Penumbra.Api.IpcTester; + +public class ModsIpcTester : IUiService, IDisposable +{ + private readonly IDalamudPluginInterface _pi; + + private string _modDirectory = string.Empty; + private string _modName = string.Empty; + private string _pathInput = string.Empty; + private string _newInstallPath = string.Empty; + private PenumbraApiEc _lastReloadEc; + private PenumbraApiEc _lastAddEc; + private PenumbraApiEc _lastDeleteEc; + private PenumbraApiEc _lastSetPathEc; + private PenumbraApiEc _lastInstallEc; + private Dictionary _mods = []; + private Dictionary _changedItems = []; + + public readonly EventSubscriber DeleteSubscriber; + public readonly EventSubscriber AddSubscriber; + public readonly EventSubscriber MoveSubscriber; + + private DateTimeOffset _lastDeletedModTime = DateTimeOffset.UnixEpoch; + private string _lastDeletedMod = string.Empty; + private DateTimeOffset _lastAddedModTime = DateTimeOffset.UnixEpoch; + private string _lastAddedMod = string.Empty; + private DateTimeOffset _lastMovedModTime = DateTimeOffset.UnixEpoch; + private string _lastMovedModFrom = string.Empty; + private string _lastMovedModTo = string.Empty; + + public ModsIpcTester(IDalamudPluginInterface pi) + { + _pi = pi; + DeleteSubscriber = ModDeleted.Subscriber(pi, s => + { + _lastDeletedModTime = DateTimeOffset.UtcNow; + _lastDeletedMod = s; + }); + AddSubscriber = ModAdded.Subscriber(pi, s => + { + _lastAddedModTime = DateTimeOffset.UtcNow; + _lastAddedMod = s; + }); + MoveSubscriber = ModMoved.Subscriber(pi, (s1, s2) => + { + _lastMovedModTime = DateTimeOffset.UtcNow; + _lastMovedModFrom = s1; + _lastMovedModTo = s2; + }); + DeleteSubscriber.Disable(); + AddSubscriber.Disable(); + MoveSubscriber.Disable(); + } + + public void Dispose() + { + DeleteSubscriber.Dispose(); + DeleteSubscriber.Disable(); + AddSubscriber.Dispose(); + AddSubscriber.Disable(); + MoveSubscriber.Dispose(); + MoveSubscriber.Disable(); + } + + public void Draw() + { + using var _ = ImRaii.TreeNode("Mods"); + if (!_) + return; + + ImGui.InputTextWithHint("##install", "Install File Path...", ref _newInstallPath, 100); + ImGui.InputTextWithHint("##modDir", "Mod Directory Name...", ref _modDirectory, 100); + ImGui.InputTextWithHint("##modName", "Mod Name...", ref _modName, 100); + ImGui.InputTextWithHint("##path", "New Path...", ref _pathInput, 100); + using var table = ImRaii.Table(string.Empty, 3, ImGuiTableFlags.SizingFixedFit); + if (!table) + return; + + IpcTester.DrawIntro(GetModList.Label, "Mods"); + DrawModsPopup(); + if (ImGui.Button("Get##Mods")) + { + _mods = new GetModList(_pi).Invoke(); + ImGui.OpenPopup("Mods"); + } + + IpcTester.DrawIntro(ReloadMod.Label, "Reload Mod"); + if (ImGui.Button("Reload")) + _lastReloadEc = new ReloadMod(_pi).Invoke(_modDirectory, _modName); + + ImGui.SameLine(); + ImGui.TextUnformatted(_lastReloadEc.ToString()); + + IpcTester.DrawIntro(InstallMod.Label, "Install Mod"); + if (ImGui.Button("Install")) + _lastInstallEc = new InstallMod(_pi).Invoke(_newInstallPath); + + ImGui.SameLine(); + ImGui.TextUnformatted(_lastInstallEc.ToString()); + + IpcTester.DrawIntro(AddMod.Label, "Add Mod"); + if (ImGui.Button("Add")) + _lastAddEc = new AddMod(_pi).Invoke(_modDirectory); + + ImGui.SameLine(); + ImGui.TextUnformatted(_lastAddEc.ToString()); + + IpcTester.DrawIntro(DeleteMod.Label, "Delete Mod"); + if (ImGui.Button("Delete")) + _lastDeleteEc = new DeleteMod(_pi).Invoke(_modDirectory, _modName); + + ImGui.SameLine(); + ImGui.TextUnformatted(_lastDeleteEc.ToString()); + + IpcTester.DrawIntro(GetChangedItems.Label, "Get Changed Items"); + DrawChangedItemsPopup(); + if (ImUtf8.Button("Get##ChangedItems"u8)) + { + _changedItems = new GetChangedItems(_pi).Invoke(_modDirectory, _modName); + ImUtf8.OpenPopup("ChangedItems"u8); + } + + IpcTester.DrawIntro(GetModPath.Label, "Current Path"); + var (ec, path, def, nameDef) = new GetModPath(_pi).Invoke(_modDirectory, _modName); + ImGui.TextUnformatted($"{path} ({(def ? "Custom" : "Default")} Path, {(nameDef ? "Custom" : "Default")} Name) [{ec}]"); + + IpcTester.DrawIntro(SetModPath.Label, "Set Path"); + if (ImGui.Button("Set")) + _lastSetPathEc = new SetModPath(_pi).Invoke(_modDirectory, _pathInput, _modName); + + ImGui.SameLine(); + ImGui.TextUnformatted(_lastSetPathEc.ToString()); + + IpcTester.DrawIntro(ModDeleted.Label, "Last Mod Deleted"); + if (_lastDeletedModTime > DateTimeOffset.UnixEpoch) + ImGui.TextUnformatted($"{_lastDeletedMod} at {_lastDeletedModTime}"); + + IpcTester.DrawIntro(ModAdded.Label, "Last Mod Added"); + if (_lastAddedModTime > DateTimeOffset.UnixEpoch) + ImGui.TextUnformatted($"{_lastAddedMod} at {_lastAddedModTime}"); + + IpcTester.DrawIntro(ModMoved.Label, "Last Mod Moved"); + if (_lastMovedModTime > DateTimeOffset.UnixEpoch) + ImGui.TextUnformatted($"{_lastMovedModFrom} -> {_lastMovedModTo} at {_lastMovedModTime}"); + } + + private void DrawModsPopup() + { + ImGui.SetNextWindowSize(ImGuiHelpers.ScaledVector2(500, 500)); + using var p = ImRaii.Popup("Mods"); + if (!p) + return; + + foreach (var (modDir, modName) in _mods) + ImGui.TextUnformatted($"{modDir}: {modName}"); + + if (ImGui.Button("Close", -Vector2.UnitX) || !ImGui.IsWindowFocused()) + ImGui.CloseCurrentPopup(); + } + + private void DrawChangedItemsPopup() + { + ImGui.SetNextWindowSize(ImGuiHelpers.ScaledVector2(500, 500)); + using var p = ImUtf8.Popup("ChangedItems"u8); + if (!p) + return; + + foreach (var (name, data) in _changedItems) + ImUtf8.Text($"{name}: {data}"); + + if (ImUtf8.Button("Close"u8, -Vector2.UnitX) || !ImGui.IsWindowFocused()) + ImGui.CloseCurrentPopup(); + } +} diff --git a/Penumbra/Api/IpcTester/PluginStateIpcTester.cs b/Penumbra/Api/IpcTester/PluginStateIpcTester.cs new file mode 100644 index 00000000..073305d0 --- /dev/null +++ b/Penumbra/Api/IpcTester/PluginStateIpcTester.cs @@ -0,0 +1,147 @@ +using Dalamud.Interface; +using Dalamud.Interface.Utility; +using Dalamud.Plugin; +using Dalamud.Bindings.ImGui; +using OtterGui; +using OtterGui.Raii; +using OtterGui.Services; +using OtterGui.Text; +using Penumbra.Api.Helpers; +using Penumbra.Api.IpcSubscribers; + +namespace Penumbra.Api.IpcTester; + +public class PluginStateIpcTester : IUiService, IDisposable +{ + private readonly IDalamudPluginInterface _pi; + public readonly EventSubscriber ModDirectoryChanged; + public readonly EventSubscriber Initialized; + public readonly EventSubscriber Disposed; + public readonly EventSubscriber EnabledChange; + + private string _currentConfiguration = string.Empty; + private string _lastModDirectory = string.Empty; + private bool _lastModDirectoryValid; + private DateTimeOffset _lastModDirectoryTime = DateTimeOffset.MinValue; + + private readonly List _initializedList = []; + private readonly List _disposedList = []; + + private string _requiredFeatureString = string.Empty; + private string[] _requiredFeatures = []; + + private DateTimeOffset _lastEnabledChange = DateTimeOffset.UnixEpoch; + private bool? _lastEnabledValue; + + public PluginStateIpcTester(IDalamudPluginInterface pi) + { + _pi = pi; + ModDirectoryChanged = IpcSubscribers.ModDirectoryChanged.Subscriber(pi, UpdateModDirectoryChanged); + Initialized = IpcSubscribers.Initialized.Subscriber(pi, AddInitialized); + Disposed = IpcSubscribers.Disposed.Subscriber(pi, AddDisposed); + EnabledChange = IpcSubscribers.EnabledChange.Subscriber(pi, SetLastEnabled); + ModDirectoryChanged.Disable(); + EnabledChange.Disable(); + } + + public void Dispose() + { + ModDirectoryChanged.Dispose(); + Initialized.Dispose(); + Disposed.Dispose(); + EnabledChange.Dispose(); + } + + + public void Draw() + { + using var _ = ImRaii.TreeNode("Plugin State"); + if (!_) + return; + + if (ImUtf8.InputText("Required Features"u8, ref _requiredFeatureString)) + _requiredFeatures = _requiredFeatureString.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries); + using var table = ImRaii.Table(string.Empty, 3, ImGuiTableFlags.SizingFixedFit); + if (!table) + return; + + DrawList(IpcSubscribers.Initialized.Label, "Last Initialized", _initializedList); + DrawList(IpcSubscribers.Disposed.Label, "Last Disposed", _disposedList); + + IpcTester.DrawIntro(ApiVersion.Label, "Current Version"); + var (breaking, features) = new ApiVersion(_pi).Invoke(); + ImGui.TextUnformatted($"{breaking}.{features:D4}"); + + IpcTester.DrawIntro(GetEnabledState.Label, "Current State"); + ImGui.TextUnformatted($"{new GetEnabledState(_pi).Invoke()}"); + + IpcTester.DrawIntro(IpcSubscribers.EnabledChange.Label, "Last Change"); + ImGui.TextUnformatted(_lastEnabledValue is { } v ? $"{_lastEnabledChange} (to {v})" : "Never"); + + IpcTester.DrawIntro(SupportedFeatures.Label, "Supported Features"); + ImUtf8.Text(string.Join(", ", new SupportedFeatures(_pi).Invoke())); + + IpcTester.DrawIntro(CheckSupportedFeatures.Label, "Missing Features"); + ImUtf8.Text(string.Join(", ", new CheckSupportedFeatures(_pi).Invoke(_requiredFeatures))); + + DrawConfigPopup(); + IpcTester.DrawIntro(GetConfiguration.Label, "Configuration"); + if (ImGui.Button("Get")) + { + _currentConfiguration = new GetConfiguration(_pi).Invoke(); + ImGui.OpenPopup("Config Popup"); + } + + IpcTester.DrawIntro(GetModDirectory.Label, "Current Mod Directory"); + ImGui.TextUnformatted(new GetModDirectory(_pi).Invoke()); + + IpcTester.DrawIntro(IpcSubscribers.ModDirectoryChanged.Label, "Last Mod Directory Change"); + ImGui.TextUnformatted(_lastModDirectoryTime > DateTimeOffset.MinValue + ? $"{_lastModDirectory} ({(_lastModDirectoryValid ? "Valid" : "Invalid")}) at {_lastModDirectoryTime}" + : "None"); + + void DrawList(string label, string text, List list) + { + IpcTester.DrawIntro(label, text); + if (list.Count == 0) + { + ImGui.TextUnformatted("Never"); + } + else + { + ImGui.TextUnformatted(list[^1].LocalDateTime.ToString(CultureInfo.CurrentCulture)); + if (list.Count > 1 && ImGui.IsItemHovered()) + ImGui.SetTooltip(string.Join("\n", + list.SkipLast(1).Select(t => t.LocalDateTime.ToString(CultureInfo.CurrentCulture)))); + } + } + } + + private void DrawConfigPopup() + { + ImGui.SetNextWindowSize(ImGuiHelpers.ScaledVector2(500, 500)); + using var popup = ImRaii.Popup("Config Popup"); + if (!popup) + return; + + using (ImRaii.PushFont(UiBuilder.MonoFont)) + { + ImGuiUtil.TextWrapped(_currentConfiguration); + } + + if (ImGui.Button("Close", -Vector2.UnitX) || !ImGui.IsWindowFocused()) + ImGui.CloseCurrentPopup(); + } + + private void UpdateModDirectoryChanged(string path, bool valid) + => (_lastModDirectory, _lastModDirectoryValid, _lastModDirectoryTime) = (path, valid, DateTimeOffset.Now); + + private void AddInitialized() + => _initializedList.Add(DateTimeOffset.UtcNow); + + private void AddDisposed() + => _disposedList.Add(DateTimeOffset.UtcNow); + + private void SetLastEnabled(bool val) + => (_lastEnabledChange, _lastEnabledValue) = (DateTimeOffset.Now, val); +} diff --git a/Penumbra/Api/IpcTester/RedrawingIpcTester.cs b/Penumbra/Api/IpcTester/RedrawingIpcTester.cs new file mode 100644 index 00000000..6b853ed2 --- /dev/null +++ b/Penumbra/Api/IpcTester/RedrawingIpcTester.cs @@ -0,0 +1,73 @@ +using Dalamud.Plugin; +using Dalamud.Plugin.Services; +using Dalamud.Bindings.ImGui; +using OtterGui.Raii; +using OtterGui.Services; +using Penumbra.Api.Enums; +using Penumbra.Api.Helpers; +using Penumbra.Api.IpcSubscribers; +using Penumbra.GameData.Interop; +using Penumbra.UI; + +namespace Penumbra.Api.IpcTester; + +public class RedrawingIpcTester : IUiService, IDisposable +{ + private readonly IDalamudPluginInterface _pi; + private readonly ObjectManager _objects; + public readonly EventSubscriber Redrawn; + + private int _redrawIndex; + private string _lastRedrawnString = "None"; + + public RedrawingIpcTester(IDalamudPluginInterface pi, ObjectManager objects) + { + _pi = pi; + _objects = objects; + Redrawn = GameObjectRedrawn.Subscriber(_pi, SetLastRedrawn); + Redrawn.Disable(); + } + + public void Dispose() + { + Redrawn.Dispose(); + } + + public void Draw() + { + using var _ = ImRaii.TreeNode("Redrawing"); + if (!_) + return; + + using var table = ImRaii.Table(string.Empty, 3, ImGuiTableFlags.SizingFixedFit); + if (!table) + return; + + IpcTester.DrawIntro(RedrawObject.Label, "Redraw by Index"); + var tmp = _redrawIndex; + ImGui.SetNextItemWidth(100 * UiHelpers.Scale); + if (ImGui.DragInt("##redrawIndex", ref tmp, 0.1f, 0, _objects.TotalCount)) + _redrawIndex = Math.Clamp(tmp, 0, _objects.TotalCount); + ImGui.SameLine(); + if (ImGui.Button("Redraw##Index")) + new RedrawObject(_pi).Invoke(_redrawIndex); + + IpcTester.DrawIntro(RedrawAll.Label, "Redraw All"); + if (ImGui.Button("Redraw##All")) + new RedrawAll(_pi).Invoke(); + + IpcTester.DrawIntro(GameObjectRedrawn.Label, "Last Redrawn Object:"); + ImGui.TextUnformatted(_lastRedrawnString); + } + + private void SetLastRedrawn(nint address, int index) + { + if (index < 0 + || index > _objects.TotalCount + || address == nint.Zero + || _objects[index].Address != address) + _lastRedrawnString = "Invalid"; + + _lastRedrawnString = $"{_objects[index].Utf8Name} (0x{address:X}, {index})"; + } +} diff --git a/Penumbra/Api/IpcTester/ResolveIpcTester.cs b/Penumbra/Api/IpcTester/ResolveIpcTester.cs new file mode 100644 index 00000000..9fc5bfc7 --- /dev/null +++ b/Penumbra/Api/IpcTester/ResolveIpcTester.cs @@ -0,0 +1,114 @@ +using Dalamud.Plugin; +using Dalamud.Bindings.ImGui; +using OtterGui.Raii; +using OtterGui.Services; +using Penumbra.Api.IpcSubscribers; +using Penumbra.String.Classes; + +namespace Penumbra.Api.IpcTester; + +public class ResolveIpcTester(IDalamudPluginInterface pi) : IUiService +{ + private string _currentResolvePath = string.Empty; + private string _currentReversePath = string.Empty; + private int _currentReverseIdx; + private Task<(string[], string[][])> _task = Task.FromResult<(string[], string[][])>(([], [])); + + public void Draw() + { + using var tree = ImRaii.TreeNode("Resolving"); + if (!tree) + return; + + ImGui.InputTextWithHint("##resolvePath", "Resolve this game path...", ref _currentResolvePath, Utf8GamePath.MaxGamePathLength); + ImGui.InputTextWithHint("##resolveInversePath", "Reverse-resolve this path...", ref _currentReversePath, + Utf8GamePath.MaxGamePathLength); + ImGui.InputInt("##resolveIdx", ref _currentReverseIdx, 0, 0); + using var table = ImRaii.Table(string.Empty, 3, ImGuiTableFlags.SizingFixedFit); + if (!table) + return; + + IpcTester.DrawIntro(ResolveDefaultPath.Label, "Default Collection Resolve"); + if (_currentResolvePath.Length != 0) + ImGui.TextUnformatted(new ResolveDefaultPath(pi).Invoke(_currentResolvePath)); + + IpcTester.DrawIntro(ResolveInterfacePath.Label, "Interface Collection Resolve"); + if (_currentResolvePath.Length != 0) + ImGui.TextUnformatted(new ResolveInterfacePath(pi).Invoke(_currentResolvePath)); + + IpcTester.DrawIntro(ResolvePlayerPath.Label, "Player Collection Resolve"); + if (_currentResolvePath.Length != 0) + ImGui.TextUnformatted(new ResolvePlayerPath(pi).Invoke(_currentResolvePath)); + + IpcTester.DrawIntro(ResolveGameObjectPath.Label, "Game Object Collection Resolve"); + if (_currentResolvePath.Length != 0) + ImGui.TextUnformatted(new ResolveGameObjectPath(pi).Invoke(_currentResolvePath, _currentReverseIdx)); + + IpcTester.DrawIntro(ReverseResolvePlayerPath.Label, "Reversed Game Paths (Player)"); + if (_currentReversePath.Length > 0) + { + var list = new ReverseResolvePlayerPath(pi).Invoke(_currentReversePath); + if (list.Length > 0) + { + ImGui.TextUnformatted(list[0]); + if (list.Length > 1 && ImGui.IsItemHovered()) + ImGui.SetTooltip(string.Join("\n", list.Skip(1))); + } + } + + IpcTester.DrawIntro(ReverseResolveGameObjectPath.Label, "Reversed Game Paths (Game Object)"); + if (_currentReversePath.Length > 0) + { + var list = new ReverseResolveGameObjectPath(pi).Invoke(_currentReversePath, _currentReverseIdx); + if (list.Length > 0) + { + ImGui.TextUnformatted(list[0]); + if (list.Length > 1 && ImGui.IsItemHovered()) + ImGui.SetTooltip(string.Join("\n", list.Skip(1))); + } + } + + var forwardArray = _currentResolvePath.Length > 0 + ? [_currentResolvePath] + : Array.Empty(); + var reverseArray = _currentReversePath.Length > 0 + ? [_currentReversePath] + : Array.Empty(); + + IpcTester.DrawIntro(ResolvePlayerPaths.Label, "Resolved Paths (Player)"); + if (forwardArray.Length > 0 || reverseArray.Length > 0) + { + var ret = new ResolvePlayerPaths(pi).Invoke(forwardArray, reverseArray); + ImGui.TextUnformatted(ConvertText(ret)); + } + + IpcTester.DrawIntro(ResolvePlayerPathsAsync.Label, "Resolved Paths Async (Player)"); + if (ImGui.Button("Start")) + _task = new ResolvePlayerPathsAsync(pi).Invoke(forwardArray, reverseArray); + var hovered = ImGui.IsItemHovered(); + ImGui.SameLine(); + ImGui.AlignTextToFramePadding(); + ImGui.TextUnformatted(_task.Status.ToString()); + if ((hovered || ImGui.IsItemHovered()) && _task.IsCompletedSuccessfully) + ImGui.SetTooltip(ConvertText(_task.Result)); + return; + + static string ConvertText((string[], string[][]) data) + { + var text = string.Empty; + if (data.Item1.Length > 0) + { + if (data.Item2.Length > 0) + text = $"Forward: {data.Item1[0]} | Reverse: {string.Join("; ", data.Item2[0])}."; + else + text = $"Forward: {data.Item1[0]}."; + } + else if (data.Item2.Length > 0) + { + text = $"Reverse: {string.Join("; ", data.Item2[0])}."; + } + + return text; + } + } +} diff --git a/Penumbra/Api/IpcTester/ResourceTreeIpcTester.cs b/Penumbra/Api/IpcTester/ResourceTreeIpcTester.cs new file mode 100644 index 00000000..e6c8d52e --- /dev/null +++ b/Penumbra/Api/IpcTester/ResourceTreeIpcTester.cs @@ -0,0 +1,350 @@ +using Dalamud.Bindings.ImGui; +using Dalamud.Game.ClientState.Objects.Enums; +using Dalamud.Interface; +using Dalamud.Interface.Utility; +using Dalamud.Plugin; +using OtterGui; +using OtterGui.Extensions; +using OtterGui.Raii; +using OtterGui.Services; +using Penumbra.Api.Enums; +using Penumbra.Api.Helpers; +using Penumbra.Api.IpcSubscribers; +using Penumbra.GameData.Enums; +using Penumbra.GameData.Interop; +using Penumbra.GameData.Structs; + +namespace Penumbra.Api.IpcTester; + +public class ResourceTreeIpcTester(IDalamudPluginInterface pi, ObjectManager objects) : IUiService +{ + private readonly Stopwatch _stopwatch = new(); + + private string _gameObjectIndices = "0"; + private ResourceType _type = ResourceType.Mtrl; + private bool _withUiData; + + private (string, Dictionary>?)[]? _lastGameObjectResourcePaths; + private (string, Dictionary>?)[]? _lastPlayerResourcePaths; + private (string, IReadOnlyDictionary?)[]? _lastGameObjectResourcesOfType; + private (string, IReadOnlyDictionary?)[]? _lastPlayerResourcesOfType; + private (string, ResourceTreeDto?)[]? _lastGameObjectResourceTrees; + private (string, ResourceTreeDto)[]? _lastPlayerResourceTrees; + private TimeSpan _lastCallDuration; + + public void Draw() + { + using var _ = ImRaii.TreeNode("Resource Tree"); + if (!_) + return; + + ImGui.InputText("GameObject indices", ref _gameObjectIndices, 511); + ImGuiUtil.GenericEnumCombo("Resource type", ImGui.CalcItemWidth(), _type, out _type, Enum.GetValues()); + ImGui.Checkbox("Also get names and icons", ref _withUiData); + + using var table = ImRaii.Table(string.Empty, 3, ImGuiTableFlags.SizingFixedFit); + if (!table) + return; + + IpcTester.DrawIntro(GetGameObjectResourcePaths.Label, "Get GameObject resource paths"); + if (ImGui.Button("Get##GameObjectResourcePaths")) + { + var gameObjects = GetSelectedGameObjects(); + var subscriber = new GetGameObjectResourcePaths(pi); + _stopwatch.Restart(); + var resourcePaths = subscriber.Invoke(gameObjects); + + _lastCallDuration = _stopwatch.Elapsed; + _lastGameObjectResourcePaths = gameObjects + .Select(i => GameObjectToString(i)) + .Zip(resourcePaths) + .ToArray(); + + ImGui.OpenPopup(nameof(GetGameObjectResourcePaths)); + } + + IpcTester.DrawIntro(GetPlayerResourcePaths.Label, "Get local player resource paths"); + if (ImGui.Button("Get##PlayerResourcePaths")) + { + var subscriber = new GetPlayerResourcePaths(pi); + _stopwatch.Restart(); + var resourcePaths = subscriber.Invoke(); + + _lastCallDuration = _stopwatch.Elapsed; + _lastPlayerResourcePaths = resourcePaths + .Select(pair => (GameObjectToString(pair.Key), pair.Value)) + .ToArray()!; + + ImGui.OpenPopup(nameof(GetPlayerResourcePaths)); + } + + IpcTester.DrawIntro(GetGameObjectResourcesOfType.Label, "Get GameObject resources of type"); + if (ImGui.Button("Get##GameObjectResourcesOfType")) + { + var gameObjects = GetSelectedGameObjects(); + var subscriber = new GetGameObjectResourcesOfType(pi); + _stopwatch.Restart(); + var resourcesOfType = subscriber.Invoke(_type, _withUiData, gameObjects); + + _lastCallDuration = _stopwatch.Elapsed; + _lastGameObjectResourcesOfType = gameObjects + .Select(i => GameObjectToString(i)) + .Zip(resourcesOfType) + .ToArray(); + + ImGui.OpenPopup(nameof(GetGameObjectResourcesOfType)); + } + + IpcTester.DrawIntro(GetPlayerResourcesOfType.Label, "Get local player resources of type"); + if (ImGui.Button("Get##PlayerResourcesOfType")) + { + var subscriber = new GetPlayerResourcesOfType(pi); + _stopwatch.Restart(); + var resourcesOfType = subscriber.Invoke(_type, _withUiData); + + _lastCallDuration = _stopwatch.Elapsed; + _lastPlayerResourcesOfType = resourcesOfType + .Select(pair => (GameObjectToString(pair.Key), (IReadOnlyDictionary?)pair.Value)) + .ToArray(); + + ImGui.OpenPopup(nameof(GetPlayerResourcesOfType)); + } + + IpcTester.DrawIntro(GetGameObjectResourceTrees.Label, "Get GameObject resource trees"); + if (ImGui.Button("Get##GameObjectResourceTrees")) + { + var gameObjects = GetSelectedGameObjects(); + var subscriber = new GetGameObjectResourceTrees(pi); + _stopwatch.Restart(); + var trees = subscriber.Invoke(_withUiData, gameObjects); + + _lastCallDuration = _stopwatch.Elapsed; + _lastGameObjectResourceTrees = gameObjects + .Select(i => GameObjectToString(i)) + .Zip(trees) + .ToArray(); + + ImGui.OpenPopup(nameof(GetGameObjectResourceTrees)); + } + + IpcTester.DrawIntro(GetPlayerResourceTrees.Label, "Get local player resource trees"); + if (ImGui.Button("Get##PlayerResourceTrees")) + { + var subscriber = new GetPlayerResourceTrees(pi); + _stopwatch.Restart(); + var trees = subscriber.Invoke(_withUiData); + + _lastCallDuration = _stopwatch.Elapsed; + _lastPlayerResourceTrees = trees + .Select(pair => (GameObjectToString(pair.Key), pair.Value)) + .ToArray(); + + ImGui.OpenPopup(nameof(GetPlayerResourceTrees)); + } + + DrawPopup(nameof(GetGameObjectResourcePaths), ref _lastGameObjectResourcePaths, DrawResourcePaths, + _lastCallDuration); + DrawPopup(nameof(GetPlayerResourcePaths), ref _lastPlayerResourcePaths!, DrawResourcePaths, _lastCallDuration); + + DrawPopup(nameof(GetGameObjectResourcesOfType), ref _lastGameObjectResourcesOfType, DrawResourcesOfType, + _lastCallDuration); + DrawPopup(nameof(GetPlayerResourcesOfType), ref _lastPlayerResourcesOfType, DrawResourcesOfType, + _lastCallDuration); + + DrawPopup(nameof(GetGameObjectResourceTrees), ref _lastGameObjectResourceTrees, DrawResourceTrees, + _lastCallDuration); + DrawPopup(nameof(GetPlayerResourceTrees), ref _lastPlayerResourceTrees, DrawResourceTrees!, _lastCallDuration); + } + + private static void DrawPopup(string popupId, ref T? result, Action drawResult, TimeSpan duration) where T : class + { + ImGui.SetNextWindowSize(ImGuiHelpers.ScaledVector2(1000, 500)); + using var popup = ImRaii.Popup(popupId); + if (!popup) + { + result = null; + return; + } + + if (result == null) + { + ImGui.CloseCurrentPopup(); + return; + } + + drawResult(result); + + ImGui.TextUnformatted($"Invoked in {duration.TotalMilliseconds} ms"); + + if (ImGui.Button("Close", -Vector2.UnitX) || !ImGui.IsWindowFocused()) + { + result = null; + ImGui.CloseCurrentPopup(); + } + } + + private static void DrawWithHeaders((string, T?)[] result, Action drawItem) where T : class + { + var firstSeen = new Dictionary(); + foreach (var (label, item) in result) + { + if (item == null) + { + ImRaii.TreeNode($"{label}: null", ImGuiTreeNodeFlags.Leaf).Dispose(); + continue; + } + + if (firstSeen.TryGetValue(item, out var firstLabel)) + { + ImRaii.TreeNode($"{label}: same as {firstLabel}", ImGuiTreeNodeFlags.Leaf).Dispose(); + continue; + } + + firstSeen.Add(item, label); + + using var header = ImRaii.TreeNode(label); + if (!header) + continue; + + drawItem(item); + } + } + + private static void DrawResourcePaths((string, Dictionary>?)[] result) + { + DrawWithHeaders(result, paths => + { + using var table = ImRaii.Table(string.Empty, 2, ImGuiTableFlags.SizingFixedFit); + if (!table) + return; + + ImGui.TableSetupColumn("Actual Path", ImGuiTableColumnFlags.WidthStretch, 0.6f); + ImGui.TableSetupColumn("Game Paths", ImGuiTableColumnFlags.WidthStretch, 0.4f); + ImGui.TableHeadersRow(); + + foreach (var (actualPath, gamePaths) in paths) + { + ImGui.TableNextColumn(); + ImGui.TextUnformatted(actualPath); + ImGui.TableNextColumn(); + foreach (var gamePath in gamePaths) + ImGui.TextUnformatted(gamePath); + } + }); + } + + private void DrawResourcesOfType((string, IReadOnlyDictionary?)[] result) + { + DrawWithHeaders(result, resources => + { + using var table = ImRaii.Table(string.Empty, _withUiData ? 3 : 2, ImGuiTableFlags.SizingFixedFit); + if (!table) + return; + + ImGui.TableSetupColumn("Resource Handle", ImGuiTableColumnFlags.WidthStretch, 0.15f); + ImGui.TableSetupColumn("Actual Path", ImGuiTableColumnFlags.WidthStretch, _withUiData ? 0.55f : 0.85f); + if (_withUiData) + ImGui.TableSetupColumn("Icon & Name", ImGuiTableColumnFlags.WidthStretch, 0.3f); + ImGui.TableHeadersRow(); + + foreach (var (resourceHandle, (actualPath, name, icon)) in resources) + { + ImGui.TableNextColumn(); + TextUnformattedMono($"0x{resourceHandle:X}"); + ImGui.TableNextColumn(); + ImGui.TextUnformatted(actualPath); + if (_withUiData) + { + ImGui.TableNextColumn(); + TextUnformattedMono(icon.ToString()); + ImGui.SameLine(); + ImGui.TextUnformatted(name); + } + } + }); + } + + private void DrawResourceTrees((string, ResourceTreeDto?)[] result) + { + DrawWithHeaders(result, tree => + { + ImGui.TextUnformatted($"Name: {tree.Name}\nRaceCode: {(GenderRace)tree.RaceCode}"); + + using var table = ImRaii.Table(string.Empty, _withUiData ? 7 : 5, ImGuiTableFlags.SizingFixedFit | ImGuiTableFlags.Resizable); + if (!table) + return; + + if (_withUiData) + { + ImGui.TableSetupColumn("Name", ImGuiTableColumnFlags.WidthStretch, 0.5f); + ImGui.TableSetupColumn("Type", ImGuiTableColumnFlags.WidthStretch, 0.1f); + ImGui.TableSetupColumn("Icon", ImGuiTableColumnFlags.WidthStretch, 0.15f); + } + else + { + ImGui.TableSetupColumn("Type", ImGuiTableColumnFlags.WidthStretch, 0.5f); + } + + ImGui.TableSetupColumn("Game Path", ImGuiTableColumnFlags.WidthStretch, 0.5f); + ImGui.TableSetupColumn("Actual Path", ImGuiTableColumnFlags.WidthStretch, 0.5f); + ImGui.TableSetupColumn("Object Address", ImGuiTableColumnFlags.WidthStretch, 0.2f); + ImGui.TableSetupColumn("Resource Handle", ImGuiTableColumnFlags.WidthStretch, 0.2f); + ImGui.TableHeadersRow(); + + void DrawNode(ResourceNodeDto node) + { + ImGui.TableNextRow(); + ImGui.TableNextColumn(); + var hasChildren = node.Children.Any(); + using var treeNode = ImRaii.TreeNode( + $"{(_withUiData ? node.Name ?? "Unknown" : node.Type)}##{node.ObjectAddress:X8}", + hasChildren + ? ImGuiTreeNodeFlags.SpanFullWidth + : ImGuiTreeNodeFlags.SpanFullWidth | ImGuiTreeNodeFlags.Leaf | ImGuiTreeNodeFlags.NoTreePushOnOpen); + if (_withUiData) + { + ImGui.TableNextColumn(); + TextUnformattedMono(node.Type.ToString()); + ImGui.TableNextColumn(); + TextUnformattedMono(node.Icon.ToString()); + } + + ImGui.TableNextColumn(); + ImGui.TextUnformatted(node.GamePath ?? "Unknown"); + ImGui.TableNextColumn(); + ImGui.TextUnformatted(node.ActualPath); + ImGui.TableNextColumn(); + TextUnformattedMono($"0x{node.ObjectAddress:X8}"); + ImGui.TableNextColumn(); + TextUnformattedMono($"0x{node.ResourceHandle:X8}"); + + if (treeNode) + foreach (var child in node.Children) + DrawNode(child); + } + + foreach (var node in tree.Nodes) + DrawNode(node); + }); + } + + private static void TextUnformattedMono(string text) + { + using var _ = ImRaii.PushFont(UiBuilder.MonoFont); + ImGui.TextUnformatted(text); + } + + private ushort[] GetSelectedGameObjects() + => _gameObjectIndices.Split(',') + .SelectWhere(index => (ushort.TryParse(index.Trim(), out var i), i)) + .ToArray(); + + private unsafe string GameObjectToString(ObjectIndex gameObjectIndex) + { + var gameObject = objects[gameObjectIndex]; + + return gameObject.Valid + ? $"[{gameObjectIndex}] {gameObject.Utf8Name} ({(ObjectKind)gameObject.AsObject->ObjectKind})" + : $"[{gameObjectIndex}] null"; + } +} diff --git a/Penumbra/Api/IpcTester/TemporaryIpcTester.cs b/Penumbra/Api/IpcTester/TemporaryIpcTester.cs new file mode 100644 index 00000000..d46c5728 --- /dev/null +++ b/Penumbra/Api/IpcTester/TemporaryIpcTester.cs @@ -0,0 +1,319 @@ +using Dalamud.Interface; +using Dalamud.Plugin; +using Dalamud.Bindings.ImGui; +using OtterGui; +using OtterGui.Extensions; +using OtterGui.Raii; +using OtterGui.Services; +using OtterGui.Text; +using Penumbra.Api.Api; +using Penumbra.Api.Enums; +using Penumbra.Api.IpcSubscribers; +using Penumbra.Collections.Manager; +using Penumbra.Mods; +using Penumbra.Mods.Manager; +using Penumbra.Services; + +namespace Penumbra.Api.IpcTester; + +public class TemporaryIpcTester( + IDalamudPluginInterface pi, + ModManager modManager, + CollectionManager collections, + TempModManager tempMods, + TempCollectionManager tempCollections, + SaveService saveService, + Configuration config) + : IUiService +{ + public Guid LastCreatedCollectionId = Guid.Empty; + + private readonly bool _debug = Assembly.GetAssembly(typeof(TemporaryIpcTester))?.GetName().Version?.Major >= 9; + + private Guid? _tempGuid; + private string _tempCollectionName = string.Empty; + private string _tempCollectionGuidName = string.Empty; + private string _tempModName = string.Empty; + private string _modDirectory = string.Empty; + private string _tempGamePath = "test/game/path.mtrl"; + private string _tempFilePath = "test/success.mtrl"; + private string _tempManipulation = string.Empty; + private string _identity = string.Empty; + private PenumbraApiEc _lastTempError; + private int _tempActorIndex; + private bool _forceOverwrite; + + public void Draw() + { + using var _ = ImRaii.TreeNode("Temporary"); + if (!_) + return; + + ImGui.InputTextWithHint("##identity", "Identity...", ref _identity, 128); + ImGui.InputTextWithHint("##tempCollection", "Collection Name...", ref _tempCollectionName, 128); + ImGuiUtil.GuidInput("##guid", "Collection GUID...", string.Empty, ref _tempGuid, ref _tempCollectionGuidName); + ImGui.InputInt("##tempActorIndex", ref _tempActorIndex, 0, 0); + ImGui.InputTextWithHint("##tempMod", "Temporary Mod Name...", ref _tempModName, 32); + ImGui.InputTextWithHint("##mod", "Existing Mod Name...", ref _modDirectory, 256); + ImGui.InputTextWithHint("##tempGame", "Game Path...", ref _tempGamePath, 256); + ImGui.InputTextWithHint("##tempFile", "File Path...", ref _tempFilePath, 256); + ImUtf8.InputText("##tempManip"u8, ref _tempManipulation, "Manipulation Base64 String..."u8); + ImGui.Checkbox("Force Character Collection Overwrite", ref _forceOverwrite); + + using var table = ImRaii.Table(string.Empty, 3, ImGuiTableFlags.SizingFixedFit); + if (!table) + return; + + IpcTester.DrawIntro("Last Error", _lastTempError.ToString()); + ImGuiUtil.DrawTableColumn("Last Created Collection"); + ImGui.TableNextColumn(); + using (ImRaii.PushFont(UiBuilder.MonoFont)) + { + ImGuiUtil.CopyOnClickSelectable(LastCreatedCollectionId.ToString()); + } + + IpcTester.DrawIntro(CreateTemporaryCollection.Label, "Create Temporary Collection"); + if (ImGui.Button("Create##Collection")) + { + _lastTempError = new CreateTemporaryCollection(pi).Invoke(_identity, _tempCollectionName, out LastCreatedCollectionId); + if (_tempGuid == null) + { + _tempGuid = LastCreatedCollectionId; + _tempCollectionGuidName = LastCreatedCollectionId.ToString(); + } + } + + var guid = _tempGuid.GetValueOrDefault(Guid.Empty); + + IpcTester.DrawIntro(DeleteTemporaryCollection.Label, "Delete Temporary Collection"); + if (ImGui.Button("Delete##Collection")) + _lastTempError = new DeleteTemporaryCollection(pi).Invoke(guid); + ImGui.SameLine(); + if (ImGui.Button("Delete Last##Collection")) + _lastTempError = new DeleteTemporaryCollection(pi).Invoke(LastCreatedCollectionId); + + IpcTester.DrawIntro(AssignTemporaryCollection.Label, "Assign Temporary Collection"); + if (ImGui.Button("Assign##NamedCollection")) + _lastTempError = new AssignTemporaryCollection(pi).Invoke(guid, _tempActorIndex, _forceOverwrite); + + IpcTester.DrawIntro(AddTemporaryMod.Label, "Add Temporary Mod to specific Collection"); + if (ImGui.Button("Add##Mod")) + _lastTempError = new AddTemporaryMod(pi).Invoke(_tempModName, guid, + new Dictionary { { _tempGamePath, _tempFilePath } }, + _tempManipulation.Length > 0 ? _tempManipulation : string.Empty, int.MaxValue); + + IpcTester.DrawIntro(CreateTemporaryCollection.Label, "Copy Existing Collection"); + if (ImGuiUtil.DrawDisabledButton("Copy##Collection", Vector2.Zero, + "Copies the effective list from the collection named in Temporary Mod Name...", + !collections.Storage.ByName(_tempModName, out var copyCollection)) + && copyCollection is { HasCache: true }) + { + var files = copyCollection.ResolvedFiles.ToDictionary(kvp => kvp.Key.ToString(), kvp => kvp.Value.Path.ToString()); + var manips = MetaApi.CompressMetaManipulations(copyCollection); + _lastTempError = new AddTemporaryMod(pi).Invoke(_tempModName, guid, files, manips, 999); + } + + IpcTester.DrawIntro(AddTemporaryModAll.Label, "Add Temporary Mod to all Collections"); + if (ImGui.Button("Add##All")) + _lastTempError = new AddTemporaryModAll(pi).Invoke(_tempModName, + new Dictionary { { _tempGamePath, _tempFilePath } }, + _tempManipulation.Length > 0 ? _tempManipulation : string.Empty, int.MaxValue); + + IpcTester.DrawIntro(RemoveTemporaryMod.Label, "Remove Temporary Mod from specific Collection"); + if (ImGui.Button("Remove##Mod")) + _lastTempError = new RemoveTemporaryMod(pi).Invoke(_tempModName, guid, int.MaxValue); + + IpcTester.DrawIntro(RemoveTemporaryModAll.Label, "Remove Temporary Mod from all Collections"); + if (ImGui.Button("Remove##ModAll")) + _lastTempError = new RemoveTemporaryModAll(pi).Invoke(_tempModName, int.MaxValue); + + IpcTester.DrawIntro(SetTemporaryModSettings.Label, "Set Temporary Mod Settings (to default) in specific Collection"); + if (ImUtf8.Button("Set##SetTemporary"u8)) + _lastTempError = new SetTemporaryModSettings(pi).Invoke(guid, _modDirectory, false, true, 1337, + new Dictionary>(), + "IPC Tester", 1337); + + IpcTester.DrawIntro(SetTemporaryModSettingsPlayer.Label, "Set Temporary Mod Settings (to default) in game object collection"); + if (ImUtf8.Button("Set##SetTemporaryPlayer"u8)) + _lastTempError = new SetTemporaryModSettingsPlayer(pi).Invoke(_tempActorIndex, _modDirectory, false, true, 1337, + new Dictionary>(), + "IPC Tester", 1337); + + IpcTester.DrawIntro(RemoveTemporaryModSettings.Label, "Remove Temporary Mod Settings from specific Collection"); + if (ImUtf8.Button("Remove##RemoveTemporary"u8)) + _lastTempError = new RemoveTemporaryModSettings(pi).Invoke(guid, _modDirectory, 1337); + ImGui.SameLine(); + if (ImUtf8.Button("Remove (Wrong Key)##RemoveTemporary"u8)) + _lastTempError = new RemoveTemporaryModSettings(pi).Invoke(guid, _modDirectory, 1338); + + IpcTester.DrawIntro(RemoveTemporaryModSettingsPlayer.Label, "Remove Temporary Mod Settings from game object Collection"); + if (ImUtf8.Button("Remove##RemoveTemporaryPlayer"u8)) + _lastTempError = new RemoveTemporaryModSettingsPlayer(pi).Invoke(_tempActorIndex, _modDirectory, 1337); + ImGui.SameLine(); + if (ImUtf8.Button("Remove (Wrong Key)##RemoveTemporaryPlayer"u8)) + _lastTempError = new RemoveTemporaryModSettingsPlayer(pi).Invoke(_tempActorIndex, _modDirectory, 1338); + + IpcTester.DrawIntro(RemoveAllTemporaryModSettings.Label, "Remove All Temporary Mod Settings from specific Collection"); + if (ImUtf8.Button("Remove##RemoveAllTemporary"u8)) + _lastTempError = new RemoveAllTemporaryModSettings(pi).Invoke(guid, 1337); + ImGui.SameLine(); + if (ImUtf8.Button("Remove (Wrong Key)##RemoveAllTemporary"u8)) + _lastTempError = new RemoveAllTemporaryModSettings(pi).Invoke(guid, 1338); + + IpcTester.DrawIntro(RemoveAllTemporaryModSettingsPlayer.Label, "Remove All Temporary Mod Settings from game object Collection"); + if (ImUtf8.Button("Remove##RemoveAllTemporaryPlayer"u8)) + _lastTempError = new RemoveAllTemporaryModSettingsPlayer(pi).Invoke(_tempActorIndex, 1337); + ImGui.SameLine(); + if (ImUtf8.Button("Remove (Wrong Key)##RemoveAllTemporaryPlayer"u8)) + _lastTempError = new RemoveAllTemporaryModSettingsPlayer(pi).Invoke(_tempActorIndex, 1338); + + IpcTester.DrawIntro(QueryTemporaryModSettings.Label, "Query Temporary Mod Settings from specific Collection"); + ImUtf8.Button("Query##QueryTemporaryModSettings"u8); + if (ImGui.IsItemHovered()) + { + _lastTempError = new QueryTemporaryModSettings(pi).Invoke(guid, _modDirectory, out var settings, out var source, 1337); + DrawTooltip(settings, source); + } + + ImGui.SameLine(); + ImUtf8.Button("Query (Wrong Key)##RemoveAllTemporary"u8); + if (ImGui.IsItemHovered()) + { + _lastTempError = new QueryTemporaryModSettings(pi).Invoke(guid, _modDirectory, out var settings, out var source, 1338); + DrawTooltip(settings, source); + } + + IpcTester.DrawIntro(QueryTemporaryModSettingsPlayer.Label, "Query Temporary Mod Settings from game object Collection"); + ImUtf8.Button("Query##QueryTemporaryModSettingsPlayer"u8); + if (ImGui.IsItemHovered()) + { + _lastTempError = + new QueryTemporaryModSettingsPlayer(pi).Invoke(_tempActorIndex, _modDirectory, out var settings, out var source, 1337); + DrawTooltip(settings, source); + } + + ImGui.SameLine(); + ImUtf8.Button("Query (Wrong Key)##RemoveAllTemporaryPlayer"u8); + if (ImGui.IsItemHovered()) + { + _lastTempError = + new QueryTemporaryModSettingsPlayer(pi).Invoke(_tempActorIndex, _modDirectory, out var settings, out var source, 1338); + DrawTooltip(settings, source); + } + + void DrawTooltip((bool ForceInherit, bool Enabled, int Priority, Dictionary> Settings)? settings, string source) + { + using var tt = ImUtf8.Tooltip(); + ImUtf8.Text($"Query returned {_lastTempError}"); + if (settings != null) + ImUtf8.Text($"Settings created by {(source.Length == 0 ? "Unknown Source" : source)}:"); + else + ImUtf8.Text(source.Length > 0 ? $"Locked by {source}." : "No settings exist."); + ImGui.Separator(); + if (settings == null) + { + + return; + } + + using (ImUtf8.Group()) + { + ImUtf8.Text("Force Inherit"u8); + ImUtf8.Text("Enabled"u8); + ImUtf8.Text("Priority"u8); + foreach (var group in settings.Value.Settings.Keys) + ImUtf8.Text(group); + } + + ImGui.SameLine(); + using (ImUtf8.Group()) + { + ImUtf8.Text($"{settings.Value.ForceInherit}"); + ImUtf8.Text($"{settings.Value.Enabled}"); + ImUtf8.Text($"{settings.Value.Priority}"); + foreach (var group in settings.Value.Settings.Values) + ImUtf8.Text(string.Join("; ", group)); + } + } + } + + public void DrawCollections() + { + using var collTree = ImUtf8.TreeNode("Temporary Collections##TempCollections"u8); + if (!collTree) + return; + + using var table = ImUtf8.Table("##collTree"u8, 6, ImGuiTableFlags.SizingFixedFit); + if (!table) + return; + + foreach (var (collection, idx) in tempCollections.Values.WithIndex()) + { + using var id = ImRaii.PushId(idx); + ImGui.TableNextColumn(); + var character = tempCollections.Collections.Where(p => p.Collection == collection).Select(p => p.DisplayName) + .FirstOrDefault() + ?? "Unknown"; + if (_debug && ImUtf8.Button("Save##Collection"u8)) + TemporaryMod.SaveTempCollection(config, saveService, modManager, collection, character); + + using (ImRaii.PushFont(UiBuilder.MonoFont)) + { + ImGui.TableNextColumn(); + ImGuiUtil.CopyOnClickSelectable(collection.Identity.Identifier); + } + + ImGuiUtil.DrawTableColumn(collection.Identity.Name); + ImGuiUtil.DrawTableColumn(collection.ResolvedFiles.Count.ToString()); + ImGuiUtil.DrawTableColumn(collection.MetaCache?.Count.ToString() ?? "0"); + ImGuiUtil.DrawTableColumn(string.Join(", ", + tempCollections.Collections.Where(p => p.Collection == collection).Select(c => c.DisplayName))); + } + } + + public void DrawMods() + { + using var modTree = ImRaii.TreeNode("Temporary Mods##TempMods"); + if (!modTree) + return; + + using var table = ImRaii.Table("##modTree", 5, ImGuiTableFlags.SizingFixedFit); + + void PrintList(string collectionName, IReadOnlyList list) + { + foreach (var mod in list) + { + ImGui.TableNextColumn(); + ImGui.TextUnformatted(mod.Name.Text); + ImGui.TableNextColumn(); + ImGui.TextUnformatted(mod.Priority.ToString()); + ImGui.TableNextColumn(); + ImGui.TextUnformatted(collectionName); + ImGui.TableNextColumn(); + ImGui.TextUnformatted(mod.Default.Files.Count.ToString()); + if (ImGui.IsItemHovered()) + { + using var tt = ImRaii.Tooltip(); + foreach (var (path, file) in mod.Default.Files) + ImGui.TextUnformatted($"{path} -> {file}"); + } + + ImGui.TableNextColumn(); + ImGui.TextUnformatted(mod.TotalManipulations.ToString()); + if (ImGui.IsItemHovered()) + { + using var tt = ImRaii.Tooltip(); + foreach (var identifier in mod.Default.Manipulations.Identifiers) + ImGui.TextUnformatted(identifier.ToString()); + } + } + } + + if (table) + { + PrintList("All", tempMods.ModsForAllCollections); + foreach (var (collection, list) in tempMods.Mods) + PrintList(collection.Identity.Name, list); + } + } +} diff --git a/Penumbra/Api/IpcTester/UiIpcTester.cs b/Penumbra/Api/IpcTester/UiIpcTester.cs new file mode 100644 index 00000000..852339c9 --- /dev/null +++ b/Penumbra/Api/IpcTester/UiIpcTester.cs @@ -0,0 +1,133 @@ +using Dalamud.Plugin; +using Dalamud.Bindings.ImGui; +using OtterGui.Raii; +using OtterGui.Services; +using Penumbra.Api.Enums; +using Penumbra.Api.Helpers; +using Penumbra.Api.IpcSubscribers; + +namespace Penumbra.Api.IpcTester; + +public class UiIpcTester : IUiService, IDisposable +{ + private readonly IDalamudPluginInterface _pi; + public readonly EventSubscriber PreSettingsTabBar; + public readonly EventSubscriber PreSettingsPanel; + public readonly EventSubscriber PostEnabled; + public readonly EventSubscriber PostSettingsPanelDraw; + public readonly EventSubscriber ChangedItemTooltip; + public readonly EventSubscriber ChangedItemClicked; + + private string _lastDrawnMod = string.Empty; + private DateTimeOffset _lastDrawnModTime = DateTimeOffset.MinValue; + private bool _subscribedToTooltip; + private bool _subscribedToClick; + private string _lastClicked = string.Empty; + private string _lastHovered = string.Empty; + private TabType _selectTab = TabType.None; + private string _modName = string.Empty; + private PenumbraApiEc _ec = PenumbraApiEc.Success; + + public UiIpcTester(IDalamudPluginInterface pi) + { + _pi = pi; + PreSettingsTabBar = IpcSubscribers.PreSettingsTabBarDraw.Subscriber(pi, UpdateLastDrawnMod); + PreSettingsPanel = IpcSubscribers.PreSettingsDraw.Subscriber(pi, UpdateLastDrawnMod); + PostEnabled = IpcSubscribers.PostEnabledDraw.Subscriber(pi, UpdateLastDrawnMod); + PostSettingsPanelDraw = IpcSubscribers.PostSettingsDraw.Subscriber(pi, UpdateLastDrawnMod); + ChangedItemTooltip = IpcSubscribers.ChangedItemTooltip.Subscriber(pi, AddedTooltip); + ChangedItemClicked = IpcSubscribers.ChangedItemClicked.Subscriber(pi, AddedClick); + PreSettingsTabBar.Disable(); + PreSettingsPanel.Disable(); + PostEnabled.Disable(); + PostSettingsPanelDraw.Disable(); + ChangedItemTooltip.Disable(); + ChangedItemClicked.Disable(); + } + + public void Dispose() + { + PreSettingsTabBar.Dispose(); + PreSettingsPanel.Dispose(); + PostEnabled.Dispose(); + PostSettingsPanelDraw.Dispose(); + ChangedItemTooltip.Dispose(); + ChangedItemClicked.Dispose(); + } + + public void Draw() + { + using var _ = ImRaii.TreeNode("UI"); + if (!_) + return; + + using (var combo = ImRaii.Combo("Tab to Open at", _selectTab.ToString())) + { + if (combo) + foreach (var val in Enum.GetValues()) + { + if (ImGui.Selectable(val.ToString(), _selectTab == val)) + _selectTab = val; + } + } + + ImGui.InputTextWithHint("##openMod", "Mod to Open at...", ref _modName, 256); + using var table = ImRaii.Table(string.Empty, 3, ImGuiTableFlags.SizingFixedFit); + if (!table) + return; + + IpcTester.DrawIntro(IpcSubscribers.PostSettingsDraw.Label, "Last Drawn Mod"); + ImGui.TextUnformatted(_lastDrawnMod.Length > 0 ? $"{_lastDrawnMod} at {_lastDrawnModTime}" : "None"); + + IpcTester.DrawIntro(IpcSubscribers.ChangedItemTooltip.Label, "Add Tooltip"); + if (ImGui.Checkbox("##tooltip", ref _subscribedToTooltip)) + { + if (_subscribedToTooltip) + ChangedItemTooltip.Enable(); + else + ChangedItemTooltip.Disable(); + } + + ImGui.SameLine(); + ImGui.TextUnformatted(_lastHovered); + + IpcTester.DrawIntro(IpcSubscribers.ChangedItemClicked.Label, "Subscribe Click"); + if (ImGui.Checkbox("##click", ref _subscribedToClick)) + { + if (_subscribedToClick) + ChangedItemClicked.Enable(); + else + ChangedItemClicked.Disable(); + } + + ImGui.SameLine(); + ImGui.TextUnformatted(_lastClicked); + IpcTester.DrawIntro(OpenMainWindow.Label, "Open Mod Window"); + if (ImGui.Button("Open##window")) + _ec = new OpenMainWindow(_pi).Invoke(_selectTab, _modName, _modName); + + ImGui.SameLine(); + ImGui.TextUnformatted(_ec.ToString()); + + IpcTester.DrawIntro(CloseMainWindow.Label, "Close Mod Window"); + if (ImGui.Button("Close##window")) + new CloseMainWindow(_pi).Invoke(); + } + + private void UpdateLastDrawnMod(string name) + => (_lastDrawnMod, _lastDrawnModTime) = (name, DateTimeOffset.Now); + + private void UpdateLastDrawnMod(string name, float _1, float _2) + => (_lastDrawnMod, _lastDrawnModTime) = (name, DateTimeOffset.Now); + + private void AddedTooltip(ChangedItemType type, uint id) + { + _lastHovered = $"{type} {id} at {DateTime.UtcNow.ToLocalTime().ToString(CultureInfo.CurrentCulture)}"; + ImGui.TextUnformatted("IPC Test Successful"); + } + + private void AddedClick(MouseButton button, ChangedItemType type, uint id) + { + _lastClicked = $"{button}-click on {type} {id} at {DateTime.UtcNow.ToLocalTime().ToString(CultureInfo.CurrentCulture)}"; + } +} diff --git a/Penumbra/Api/ModChangedItemAdapter.cs b/Penumbra/Api/ModChangedItemAdapter.cs new file mode 100644 index 00000000..8d2d473c --- /dev/null +++ b/Penumbra/Api/ModChangedItemAdapter.cs @@ -0,0 +1,103 @@ +using Penumbra.GameData.Data; +using Penumbra.Mods.Manager; + +namespace Penumbra.Api; + +public sealed class ModChangedItemAdapter(WeakReference storage) + : IReadOnlyDictionary>, + IReadOnlyList<(string ModDirectory, IReadOnlyDictionary ChangedItems)> +{ + IEnumerator<(string ModDirectory, IReadOnlyDictionary ChangedItems)> + IEnumerable<(string ModDirectory, IReadOnlyDictionary ChangedItems)>.GetEnumerator() + => Storage.Select(m => (m.Identifier, (IReadOnlyDictionary)new ChangedItemDictionaryAdapter(m.ChangedItems))) + .GetEnumerator(); + + public IEnumerator>> GetEnumerator() + => Storage.Select(m => new KeyValuePair>(m.Identifier, + new ChangedItemDictionaryAdapter(m.ChangedItems))) + .GetEnumerator(); + + IEnumerator IEnumerable.GetEnumerator() + => GetEnumerator(); + + public int Count + => Storage.Count; + + public bool ContainsKey(string key) + => Storage.TryGetMod(key, string.Empty, out _); + + public bool TryGetValue(string key, [NotNullWhen(true)] out IReadOnlyDictionary? value) + { + if (Storage.TryGetMod(key, string.Empty, out var mod)) + { + value = new ChangedItemDictionaryAdapter(mod.ChangedItems); + return true; + } + + value = null; + return false; + } + + public IReadOnlyDictionary this[string key] + => TryGetValue(key, out var v) ? v : throw new KeyNotFoundException(); + + (string ModDirectory, IReadOnlyDictionary ChangedItems) + IReadOnlyList<(string ModDirectory, IReadOnlyDictionary ChangedItems)>.this[int index] + { + get + { + var m = Storage[index]; + return (m.Identifier, new ChangedItemDictionaryAdapter(m.ChangedItems)); + } + } + + public IEnumerable Keys + => Storage.Select(m => m.Identifier); + + public IEnumerable> Values + => Storage.Select(m => new ChangedItemDictionaryAdapter(m.ChangedItems)); + + private ModStorage Storage + { + [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] + get => storage.TryGetTarget(out var t) + ? t + : throw new ObjectDisposedException("The underlying mod storage of this IPC container was disposed."); + } + + private sealed class ChangedItemDictionaryAdapter(SortedList data) : IReadOnlyDictionary + { + public IEnumerator> GetEnumerator() + => data.Select(d => new KeyValuePair(d.Key, d.Value?.ToInternalObject())).GetEnumerator(); + + IEnumerator IEnumerable.GetEnumerator() + => GetEnumerator(); + + public int Count + => data.Count; + + public bool ContainsKey(string key) + => data.ContainsKey(key); + + public bool TryGetValue(string key, out object? value) + { + if (data.TryGetValue(key, out var v)) + { + value = v?.ToInternalObject(); + return true; + } + + value = null; + return false; + } + + public object? this[string key] + => data[key]?.ToInternalObject(); + + public IEnumerable Keys + => data.Keys; + + public IEnumerable Values + => data.Values.Select(v => v?.ToInternalObject()); + } +} diff --git a/Penumbra/Api/PenumbraApi.cs b/Penumbra/Api/PenumbraApi.cs deleted file mode 100644 index 51360940..00000000 --- a/Penumbra/Api/PenumbraApi.cs +++ /dev/null @@ -1,135 +0,0 @@ -using System; -using System.IO; -using System.Reflection; -using Dalamud.Game.ClientState.Objects.Types; -using Dalamud.Logging; -using Lumina.Data; -using Penumbra.GameData.Enums; -using Penumbra.GameData.Util; -using Penumbra.Mods; -using Penumbra.Util; - -namespace Penumbra.Api -{ - public class PenumbraApi : IDisposable, IPenumbraApi - { - public int ApiVersion { get; } = 3; - private Penumbra? _penumbra; - private Lumina.GameData? _lumina; - - public bool Valid - => _penumbra != null; - - public PenumbraApi( Penumbra penumbra ) - { - _penumbra = penumbra; - _lumina = ( Lumina.GameData? )Dalamud.GameData.GetType() - .GetField( "gameData", BindingFlags.Instance | BindingFlags.NonPublic ) - ?.GetValue( Dalamud.GameData ); - } - - public void Dispose() - { - _penumbra = null; - _lumina = null; - } - - public event ChangedItemClick? ChangedItemClicked; - public event ChangedItemHover? ChangedItemTooltip; - - internal bool HasTooltip - => ChangedItemTooltip != null; - - internal void InvokeTooltip( object? it ) - => ChangedItemTooltip?.Invoke( it ); - - internal void InvokeClick( MouseButton button, object? it ) - => ChangedItemClicked?.Invoke( button, it ); - - - private void CheckInitialized() - { - if( !Valid ) - { - throw new Exception( "PluginShare is not initialized." ); - } - } - - public void RedrawObject( string name, RedrawType setting ) - { - CheckInitialized(); - - _penumbra!.ObjectReloader.RedrawObject( name, setting ); - } - - public void RedrawObject( GameObject? gameObject, RedrawType setting ) - { - CheckInitialized(); - - _penumbra!.ObjectReloader.RedrawObject( gameObject, setting ); - } - - public void RedrawAll( RedrawType setting ) - { - CheckInitialized(); - - _penumbra!.ObjectReloader.RedrawAll( setting ); - } - - private static string ResolvePath( string path, ModManager manager, ModCollection collection ) - { - if( !Penumbra.Config.IsEnabled ) - { - return path; - } - - var gamePath = new GamePath( path ); - var ret = collection.Cache?.ResolveSwappedOrReplacementPath( gamePath ); - ret ??= manager.Collections.ForcedCollection.Cache?.ResolveSwappedOrReplacementPath( gamePath ); - ret ??= path; - return ret; - } - - public string ResolvePath( string path ) - { - CheckInitialized(); - var modManager = Service< ModManager >.Get(); - return ResolvePath( path, modManager, modManager.Collections.DefaultCollection ); - } - - public string ResolvePath( string path, string characterName ) - { - CheckInitialized(); - var modManager = Service< ModManager >.Get(); - return ResolvePath( path, modManager, - modManager.Collections.CharacterCollection.TryGetValue( characterName, out var collection ) - ? collection - : ModCollection.Empty ); - } - - private T? GetFileIntern< T >( string resolvedPath ) where T : FileResource - { - CheckInitialized(); - try - { - if( Path.IsPathRooted( resolvedPath ) ) - { - return _lumina?.GetFileFromDisk< T >( resolvedPath ); - } - - return Dalamud.GameData.GetFile< T >( resolvedPath ); - } - catch( Exception e ) - { - PluginLog.Warning( $"Could not load file {resolvedPath}:\n{e}" ); - return null; - } - } - - public T? GetFile< T >( string gamePath ) where T : FileResource - => GetFileIntern< T >( ResolvePath( gamePath ) ); - - public T? GetFile< T >( string gamePath, string characterName ) where T : FileResource - => GetFileIntern< T >( ResolvePath( gamePath, characterName ) ); - } -} \ No newline at end of file diff --git a/Penumbra/Api/PenumbraIpc.cs b/Penumbra/Api/PenumbraIpc.cs deleted file mode 100644 index fba72015..00000000 --- a/Penumbra/Api/PenumbraIpc.cs +++ /dev/null @@ -1,154 +0,0 @@ -using System; -using Dalamud.Game.ClientState.Objects.Types; -using Dalamud.Logging; -using Dalamud.Plugin; -using Dalamud.Plugin.Ipc; -using Penumbra.GameData.Enums; - -namespace Penumbra.Api -{ - public class PenumbraIpc : IDisposable - { - public const string LabelProviderApiVersion = "Penumbra.ApiVersion"; - public const string LabelProviderRedrawName = "Penumbra.RedrawObjectByName"; - public const string LabelProviderRedrawObject = "Penumbra.RedrawObject"; - public const string LabelProviderRedrawAll = "Penumbra.RedrawAll"; - public const string LabelProviderResolveDefault = "Penumbra.ResolveDefaultPath"; - public const string LabelProviderResolveCharacter = "Penumbra.ResolveCharacterPath"; - - public const string LabelProviderChangedItemTooltip = "Penumbra.ChangedItemTooltip"; - public const string LabelProviderChangedItemClick = "Penumbra.ChangedItemClick"; - - internal ICallGateProvider< int >? ProviderApiVersion; - internal ICallGateProvider< string, int, object >? ProviderRedrawName; - internal ICallGateProvider< GameObject, int, object >? ProviderRedrawObject; - internal ICallGateProvider< int, object >? ProviderRedrawAll; - internal ICallGateProvider< string, string >? ProviderResolveDefault; - internal ICallGateProvider< string, string, string >? ProviderResolveCharacter; - internal ICallGateProvider< ChangedItemType, uint, object >? ProviderChangedItemTooltip; - internal ICallGateProvider< MouseButton, ChangedItemType, uint, object >? ProviderChangedItemClick; - - internal readonly IPenumbraApi Api; - - private static RedrawType CheckRedrawType( int value ) - { - var type = ( RedrawType )value; - if( Enum.IsDefined( type ) ) - { - return type; - } - - throw new Exception( "The integer provided for a Redraw Function was not a valid RedrawType." ); - } - - private void OnClick( MouseButton click, object? item ) - { - var (type, id) = ChangedItemExtensions.ChangedItemToTypeAndId( item ); - ProviderChangedItemClick?.SendMessage( click, type, id ); - } - - private void OnTooltip( object? item ) - { - var (type, id) = ChangedItemExtensions.ChangedItemToTypeAndId( item ); - ProviderChangedItemTooltip?.SendMessage( type, id ); - } - - - public PenumbraIpc( DalamudPluginInterface pi, IPenumbraApi api ) - { - Api = api; - - try - { - ProviderApiVersion = pi.GetIpcProvider< int >( LabelProviderApiVersion ); - ProviderApiVersion.RegisterFunc( () => api.ApiVersion ); - } - catch( Exception e ) - { - PluginLog.Error( $"Error registering IPC provider for {LabelProviderApiVersion}:\n{e}" ); - } - - try - { - ProviderRedrawName = pi.GetIpcProvider< string, int, object >( LabelProviderRedrawName ); - ProviderRedrawName.RegisterAction( ( s, i ) => api.RedrawObject( s, CheckRedrawType( i ) ) ); - } - catch( Exception e ) - { - PluginLog.Error( $"Error registering IPC provider for {LabelProviderRedrawName}:\n{e}" ); - } - - try - { - ProviderRedrawObject = pi.GetIpcProvider< GameObject, int, object >( LabelProviderRedrawObject ); - ProviderRedrawObject.RegisterAction( ( o, i ) => api.RedrawObject( o, CheckRedrawType( i ) ) ); - } - catch( Exception e ) - { - PluginLog.Error( $"Error registering IPC provider for {LabelProviderRedrawObject}:\n{e}" ); - } - - try - { - ProviderRedrawAll = pi.GetIpcProvider< int, object >( LabelProviderRedrawAll ); - ProviderRedrawAll.RegisterAction( i => api.RedrawAll( CheckRedrawType( i ) ) ); - } - catch( Exception e ) - { - PluginLog.Error( $"Error registering IPC provider for {LabelProviderRedrawAll}:\n{e}" ); - } - - try - { - ProviderResolveDefault = pi.GetIpcProvider< string, string >( LabelProviderResolveDefault ); - ProviderResolveDefault.RegisterFunc( api.ResolvePath ); - } - catch( Exception e ) - { - PluginLog.Error( $"Error registering IPC provider for {LabelProviderResolveDefault}:\n{e}" ); - } - - try - { - ProviderResolveCharacter = pi.GetIpcProvider< string, string, string >( LabelProviderResolveCharacter ); - ProviderResolveCharacter.RegisterFunc( api.ResolvePath ); - } - catch( Exception e ) - { - PluginLog.Error( $"Error registering IPC provider for {LabelProviderResolveCharacter}:\n{e}" ); - } - - try - { - ProviderChangedItemTooltip = pi.GetIpcProvider< ChangedItemType, uint, object >( LabelProviderChangedItemTooltip ); - api.ChangedItemTooltip += OnTooltip; - } - catch( Exception e ) - { - PluginLog.Error( $"Error registering IPC provider for {LabelProviderChangedItemTooltip}:\n{e}" ); - } - - try - { - ProviderChangedItemClick = pi.GetIpcProvider< MouseButton, ChangedItemType, uint, object >( LabelProviderChangedItemClick ); - api.ChangedItemClicked += OnClick; - } - catch( Exception e ) - { - PluginLog.Error( $"Error registering IPC provider for {LabelProviderChangedItemClick}:\n{e}" ); - } - } - - public void Dispose() - { - ProviderApiVersion?.UnregisterFunc(); - ProviderRedrawName?.UnregisterAction(); - ProviderRedrawObject?.UnregisterAction(); - ProviderRedrawAll?.UnregisterAction(); - ProviderResolveDefault?.UnregisterFunc(); - ProviderResolveCharacter?.UnregisterFunc(); - Api.ChangedItemClicked -= OnClick; - Api.ChangedItemTooltip -= OnTooltip; - } - } -} \ No newline at end of file diff --git a/Penumbra/Api/TempModManager.cs b/Penumbra/Api/TempModManager.cs new file mode 100644 index 00000000..b3c6066a --- /dev/null +++ b/Penumbra/Api/TempModManager.cs @@ -0,0 +1,163 @@ +using OtterGui.Services; +using Penumbra.Api.Enums; +using Penumbra.Collections; +using Penumbra.Meta.Manipulations; +using Penumbra.Mods; +using Penumbra.Services; +using Penumbra.String.Classes; +using Penumbra.Collections.Manager; +using Penumbra.Communication; +using Penumbra.Mods.Settings; + +namespace Penumbra.Api; + +public enum RedirectResult +{ + Success = 0, + IdenticalFileRegistered = 1, + NotRegistered = 2, + FilteredGamePath = 3, +} + +public class TempModManager : IDisposable, IService +{ + private readonly CommunicatorService _communicator; + + private readonly Dictionary> _mods = []; + private readonly List _modsForAllCollections = []; + + public TempModManager(CommunicatorService communicator) + { + _communicator = communicator; + _communicator.CollectionChange.Subscribe(OnCollectionChange, CollectionChange.Priority.TempModManager); + } + + public void Dispose() + { + _communicator.CollectionChange.Unsubscribe(OnCollectionChange); + } + + public IReadOnlyDictionary> Mods + => _mods; + + public IReadOnlyList ModsForAllCollections + => _modsForAllCollections; + + public RedirectResult Register(string tag, ModCollection? collection, Dictionary dict, + MetaDictionary manips, ModPriority priority) + { + var mod = GetOrCreateMod(tag, collection, priority, out var created); + Penumbra.Log.Verbose($"{(created ? "Created" : "Changed")} temporary Mod {mod.Name}."); + mod.SetAll(dict, manips); + ApplyModChange(mod, collection, created, false); + return RedirectResult.Success; + } + + public RedirectResult Unregister(string tag, ModCollection? collection, ModPriority? priority) + { + Penumbra.Log.Verbose($"Removing temporary mod with tag {tag}..."); + var list = collection == null ? _modsForAllCollections : _mods.GetValueOrDefault(collection); + if (list == null) + return RedirectResult.NotRegistered; + + var removed = list.RemoveAll(m => + { + if (m.Name != tag || priority != null && m.Priority != priority.Value) + return false; + + ApplyModChange(m, collection, false, true); + return true; + }); + + if (removed == 0) + return RedirectResult.NotRegistered; + + if (list.Count == 0 && collection != null) + _mods.Remove(collection); + + return RedirectResult.Success; + } + + // Apply any new changes to the temporary mod. + private void ApplyModChange(TemporaryMod mod, ModCollection? collection, bool created, bool removed) + { + if (collection != null) + { + if (removed) + { + Penumbra.Log.Verbose($"Removing temporary Mod {mod.Name} from {collection.Identity.AnonymizedName}."); + collection.Remove(mod); + _communicator.ModSettingChanged.Invoke(collection, ModSettingChange.TemporaryMod, null, Setting.False, 0, false); + } + else + { + Penumbra.Log.Verbose($"Adding {(created ? "new " : string.Empty)}temporary Mod {mod.Name} to {collection.Identity.AnonymizedName}."); + collection.Apply(mod, created); + _communicator.ModSettingChanged.Invoke(collection, ModSettingChange.TemporaryMod, null, Setting.True, 0, false); + } + } + else + { + Penumbra.Log.Verbose($"Triggering global mod change for {(created ? "new " : string.Empty)}temporary Mod {mod.Name}."); + _communicator.TemporaryGlobalModChange.Invoke(mod, created, removed); + } + } + + /// + /// Apply a mod change to a set of collections. + /// + public static void OnGlobalModChange(IEnumerable collections, TemporaryMod mod, bool created, bool removed) + { + if (removed) + foreach (var c in collections) + c.Remove(mod); + else + foreach (var c in collections) + c.Apply(mod, created); + } + + // Find or create a mod with the given tag as name and the given priority, for the given collection (or all collections). + // Returns the found or created mod and whether it was newly created. + private TemporaryMod GetOrCreateMod(string tag, ModCollection? collection, ModPriority priority, out bool created) + { + List list; + if (collection == null) + { + list = _modsForAllCollections; + } + else if (_mods.TryGetValue(collection, out var l)) + { + list = l; + } + else + { + list = []; + _mods.Add(collection, list); + } + + var mod = list.Find(m => m.Priority == priority && m.Name == tag); + if (mod == null) + { + mod = new TemporaryMod + { + Name = tag, + Priority = priority, + }; + list.Add(mod); + created = true; + } + else + { + created = false; + } + + return mod; + } + + private void OnCollectionChange(CollectionType collectionType, ModCollection? oldCollection, ModCollection? newCollection, + string _) + { + if (collectionType is CollectionType.Temporary or CollectionType.Inactive && newCollection == null && oldCollection != null) + _mods.Remove(oldCollection); + } +} diff --git a/Penumbra/ChangedItemMode.cs b/Penumbra/ChangedItemMode.cs new file mode 100644 index 00000000..ddb79ee0 --- /dev/null +++ b/Penumbra/ChangedItemMode.cs @@ -0,0 +1,57 @@ +using Dalamud.Bindings.ImGui; +using OtterGui.Text; + +namespace Penumbra; + +public enum ChangedItemMode +{ + GroupedCollapsed, + GroupedExpanded, + Alphabetical, +} + +public static class ChangedItemModeExtensions +{ + public static ReadOnlySpan ToName(this ChangedItemMode mode) + => mode switch + { + ChangedItemMode.GroupedCollapsed => "Grouped (Collapsed)"u8, + ChangedItemMode.GroupedExpanded => "Grouped (Expanded)"u8, + ChangedItemMode.Alphabetical => "Alphabetical"u8, + _ => "Error"u8, + }; + + public static ReadOnlySpan ToTooltip(this ChangedItemMode mode) + => mode switch + { + ChangedItemMode.GroupedCollapsed => + "Display items as groups by their model and slot. Collapse those groups to a single item by default. Prefers items with more changes affecting them or configured items as the main item."u8, + ChangedItemMode.GroupedExpanded => + "Display items as groups by their model and slot. Expand those groups showing all items by default. Prefers items with more changes affecting them or configured items as the main item."u8, + ChangedItemMode.Alphabetical => "Display all changed items in a single list sorted alphabetically."u8, + _ => ""u8, + }; + + public static bool DrawCombo(ReadOnlySpan label, ChangedItemMode value, float width, Action setter) + { + ImGui.SetNextItemWidth(width); + using var combo = ImUtf8.Combo(label, value.ToName()); + if (!combo) + return false; + + var ret = false; + foreach (var newValue in Enum.GetValues()) + { + var selected = ImUtf8.Selectable(newValue.ToName(), newValue == value); + if (selected) + { + ret = true; + setter(newValue); + } + + ImUtf8.HoverTooltip(newValue.ToTooltip()); + } + + return ret; + } +} diff --git a/Penumbra/Collections/Cache/AtchCache.cs b/Penumbra/Collections/Cache/AtchCache.cs new file mode 100644 index 00000000..10990553 --- /dev/null +++ b/Penumbra/Collections/Cache/AtchCache.cs @@ -0,0 +1,121 @@ +using Penumbra.GameData.Enums; +using Penumbra.GameData.Files; +using Penumbra.GameData.Files.AtchStructs; +using Penumbra.Meta; +using Penumbra.Meta.Manipulations; + +namespace Penumbra.Collections.Cache; + +public sealed class AtchCache(MetaFileManager manager, ModCollection collection) : MetaCacheBase(manager, collection) +{ + private readonly Dictionary)> _atchFiles = []; + + public bool HasFile(GenderRace gr) + => _atchFiles.ContainsKey(gr); + + public bool GetFile(GenderRace gr, [NotNullWhen(true)] out AtchFile? file) + { + if (!_atchFiles.TryGetValue(gr, out var p)) + { + file = null; + return false; + } + + file = p.Item1; + return true; + } + + public void Reset() + { + foreach (var (_, (_, set)) in _atchFiles) + set.Clear(); + + _atchFiles.Clear(); + Clear(); + } + + protected override void ApplyModInternal(AtchIdentifier identifier, AtchEntry entry) + { + Collection.Counters.IncrementAtch(); + ApplyFile(identifier, entry); + } + + private void ApplyFile(AtchIdentifier identifier, AtchEntry entry) + { + try + { + if (!_atchFiles.TryGetValue(identifier.GenderRace, out var pair)) + { + if (!Manager.AtchManager.AtchFileBase.TryGetValue(identifier.GenderRace, out var baseFile)) + throw new Exception($"Invalid Atch File for {identifier.GenderRace.ToName()} requested."); + + pair = (baseFile.Clone(), []); + } + + + if (!Apply(pair.Item1, identifier, entry)) + return; + + pair.Item2.Add(identifier); + _atchFiles[identifier.GenderRace] = pair; + } + catch (Exception e) + { + Penumbra.Log.Error($"Could not apply ATCH Manipulation {identifier}:\n{e}"); + } + } + + protected override void RevertModInternal(AtchIdentifier identifier) + { + Collection.Counters.IncrementAtch(); + if (!_atchFiles.TryGetValue(identifier.GenderRace, out var pair)) + return; + + if (!pair.Item2.Remove(identifier)) + return; + + if (pair.Item2.Count == 0) + { + _atchFiles.Remove(identifier.GenderRace); + return; + } + + var def = GetDefault(Manager, identifier); + if (def == null) + throw new Exception($"Reverting an .atch mod had no default value for the identifier to revert to."); + + Apply(pair.Item1, identifier, def.Value); + } + + public static AtchEntry? GetDefault(MetaFileManager manager, AtchIdentifier identifier) + { + if (!manager.AtchManager.AtchFileBase.TryGetValue(identifier.GenderRace, out var baseFile)) + return null; + + if (baseFile.Points.FirstOrDefault(p => p.Type == identifier.Type) is not { } point) + return null; + + if (point.Entries.Length <= identifier.EntryIndex) + return null; + + return point.Entries[identifier.EntryIndex]; + } + + public static bool Apply(AtchFile file, AtchIdentifier identifier, in AtchEntry entry) + { + if (file.Points.FirstOrDefault(p => p.Type == identifier.Type) is not { } point) + return false; + + if (point.Entries.Length <= identifier.EntryIndex) + return false; + + point.Entries[identifier.EntryIndex] = entry; + return true; + } + + protected override void Dispose(bool _) + { + Clear(); + _atchFiles.Clear(); + } +} diff --git a/Penumbra/Collections/Cache/AtrCache.cs b/Penumbra/Collections/Cache/AtrCache.cs new file mode 100644 index 00000000..b017da32 --- /dev/null +++ b/Penumbra/Collections/Cache/AtrCache.cs @@ -0,0 +1,65 @@ +using Penumbra.GameData.Enums; +using Penumbra.GameData.Structs; +using Penumbra.Meta; +using Penumbra.Meta.Manipulations; + +namespace Penumbra.Collections.Cache; + +public sealed class AtrCache(MetaFileManager manager, ModCollection collection) : MetaCacheBase(manager, collection) +{ + public bool ShouldBeDisabled(in ShapeAttributeString attribute, HumanSlot slot, PrimaryId id, GenderRace genderRace) + => DisabledCount > 0 && _atrData.TryGetValue(attribute, out var value) && value.CheckEntry(slot, id, genderRace) is false; + + public int EnabledCount { get; private set; } + public int DisabledCount { get; private set; } + + + internal IReadOnlyDictionary Data + => _atrData; + + private readonly Dictionary _atrData = []; + + public void Reset() + { + Clear(); + _atrData.Clear(); + DisabledCount = 0; + EnabledCount = 0; + } + + protected override void Dispose(bool _) + => Reset(); + + protected override void ApplyModInternal(AtrIdentifier identifier, AtrEntry entry) + { + if (!_atrData.TryGetValue(identifier.Attribute, out var value)) + { + value = []; + _atrData.Add(identifier.Attribute, value); + } + + if (value.TrySet(identifier.Slot, identifier.Id, identifier.GenderRaceCondition, entry.Value, out _)) + { + if (entry.Value) + ++EnabledCount; + else + ++DisabledCount; + } + } + + protected override void RevertModInternal(AtrIdentifier identifier) + { + if (!_atrData.TryGetValue(identifier.Attribute, out var value)) + return; + + if (value.TrySet(identifier.Slot, identifier.Id, identifier.GenderRaceCondition, null, out var which)) + { + if (which) + --EnabledCount; + else + --DisabledCount; + if (value.IsEmpty) + _atrData.Remove(identifier.Attribute); + } + } +} diff --git a/Penumbra/Collections/Cache/CollectionCache.cs b/Penumbra/Collections/Cache/CollectionCache.cs new file mode 100644 index 00000000..8294624b --- /dev/null +++ b/Penumbra/Collections/Cache/CollectionCache.cs @@ -0,0 +1,547 @@ +using Dalamud.Interface.ImGuiNotification; +using OtterGui.Classes; +using Penumbra.Meta.Manipulations; +using Penumbra.Mods; +using Penumbra.Communication; +using Penumbra.Mods.Editor; +using Penumbra.String.Classes; +using Penumbra.Util; +using Penumbra.GameData.Data; +using OtterGui.Extensions; + +namespace Penumbra.Collections.Cache; + +public record struct ModPath(IMod Mod, FullPath Path); +public record ModConflicts(IMod Mod2, List Conflicts, bool HasPriority, bool Solved); + +/// +/// The Cache contains all required temporary data to use a collection. +/// It will only be setup if a collection gets activated in any way. +/// +public sealed class CollectionCache : IDisposable +{ + private readonly CollectionCacheManager _manager; + private readonly ModCollection _collection; + public readonly CollectionModData ModData = new(); + private readonly SortedList, IIdentifiedObjectData)> _changedItems = []; + public readonly ConcurrentDictionary ResolvedFiles = new(); + public readonly CustomResourceCache CustomResources; + public readonly MetaCache Meta; + public readonly Dictionary> ConflictDict = []; + + public int Calculating = -1; + + public string AnonymizedName + => _collection.Identity.AnonymizedName; + + public IEnumerable> AllConflicts + => ConflictDict.Values; + + public SingleArray Conflicts(IMod mod) + => ConflictDict.TryGetValue(mod, out var c) ? c : new SingleArray(); + + private int _changedItemsSaveCounter = -1; + + // Obtain currently changed items. Computes them if they haven't been computed before. + public IReadOnlyDictionary, IIdentifiedObjectData)> ChangedItems + { + get + { + SetChangedItems(); + return _changedItems; + } + } + + // The cache reacts through events on its collection changing. + public CollectionCache(CollectionCacheManager manager, ModCollection collection) + { + _manager = manager; + _collection = collection; + Meta = new MetaCache(manager.MetaFileManager, _collection); + CustomResources = new CustomResourceCache(manager.ResourceLoader); + } + + public void Dispose() + { + Meta.Dispose(); + CustomResources.Dispose(); + GC.SuppressFinalize(this); + } + + ~CollectionCache() + => Dispose(); + + // Resolve a given game path according to this collection. + public FullPath? ResolvePath(Utf8GamePath gameResourcePath) + { + if (!ResolvedFiles.TryGetValue(gameResourcePath, out var candidate)) + return null; + + if (candidate.Path.InternalName.Length > Utf8GamePath.MaxGamePathLength + || candidate.Path is { IsRooted: true, Exists: false }) + return null; + + return candidate.Path; + } + + // For a given full path, find all game paths that currently use this file. + public IEnumerable ReverseResolvePath(FullPath localFilePath) + { + var needle = localFilePath.FullName.ToLower(); + if (localFilePath.IsRooted) + needle = needle.Replace('/', '\\'); + + var iterator = ResolvedFiles + .Where(f => string.Equals(f.Value.Path.FullName, needle, StringComparison.OrdinalIgnoreCase)) + .Select(kvp => kvp.Key); + + // For files that are not rooted, try to add themselves. + if (!localFilePath.IsRooted && Utf8GamePath.FromString(localFilePath.FullName, out var utf8)) + iterator = iterator.Prepend(utf8); + + return iterator; + } + + // Reverse resolve multiple paths at once for efficiency. + public HashSet[] ReverseResolvePaths(IReadOnlyCollection fullPaths) + { + if (fullPaths.Count == 0) + return []; + + var ret = new HashSet[fullPaths.Count]; + var dict = new Dictionary(fullPaths.Count); + foreach (var (path, idx) in fullPaths.WithIndex()) + { + dict[new FullPath(path)] = idx; + ret[idx] = !Path.IsPathRooted(path) && Utf8GamePath.FromString(path, out var utf8) + ? [utf8] + : []; + } + + foreach (var (game, full) in ResolvedFiles) + { + if (dict.TryGetValue(full.Path, out var idx)) + ret[idx].Add(game); + } + + return ret; + } + + public void ReloadMod(IMod mod, bool addMetaChanges) + => _manager.AddChange(ChangeData.ModReload(this, mod, addMetaChanges)); + + public void AddMod(IMod mod, bool addMetaChanges) + => _manager.AddChange(ChangeData.ModAddition(this, mod, addMetaChanges)); + + public void RemoveMod(IMod mod, bool addMetaChanges) + => _manager.AddChange(ChangeData.ModRemoval(this, mod, addMetaChanges)); + + /// Force a file to be resolved to a specific path regardless of conflicts. + internal void ForceFileSync(Utf8GamePath path, FullPath fullPath) + { + if (!CheckFullPath(path, fullPath)) + return; + + if (ResolvedFiles.Remove(path, out var modPath)) + { + ModData.RemovePath(modPath.Mod, path); + if (fullPath.FullName.Length > 0) + { + ResolvedFiles.TryAdd(path, new ModPath(Mod.ForcedFiles, fullPath)); + CustomResources.Invalidate(path); + InvokeResolvedFileChange(_collection, ResolvedFileChanged.Type.Replaced, path, fullPath, modPath.Path, + Mod.ForcedFiles); + } + else + { + CustomResources.Invalidate(path); + InvokeResolvedFileChange(_collection, ResolvedFileChanged.Type.Removed, path, FullPath.Empty, modPath.Path, null); + } + } + else if (fullPath.FullName.Length > 0) + { + ResolvedFiles.TryAdd(path, new ModPath(Mod.ForcedFiles, fullPath)); + CustomResources.Invalidate(path); + InvokeResolvedFileChange(_collection, ResolvedFileChanged.Type.Added, path, fullPath, FullPath.Empty, Mod.ForcedFiles); + } + } + + private void ReloadModSync(IMod mod, bool addMetaChanges) + { + RemoveModSync(mod, addMetaChanges); + AddModSync(mod, addMetaChanges); + } + + internal void RemoveModSync(IMod mod, bool addMetaChanges) + { + var conflicts = Conflicts(mod); + var (paths, manipulations) = ModData.RemoveMod(mod); + + if (addMetaChanges) + _collection.Counters.IncrementChange(); + + foreach (var path in paths) + { + if (ResolvedFiles.Remove(path, out var mp)) + { + CustomResources.Invalidate(path); + if (mp.Mod != mod) + Penumbra.Log.Warning( + $"Invalid mod state, removing {mod.Name} and associated file {path} returned current mod {mp.Mod.Name}."); + else + _manager.ResolvedFileChanged.Invoke(_collection, ResolvedFileChanged.Type.Removed, path, FullPath.Empty, mp.Path, mp.Mod); + } + } + + foreach (var manipulation in manipulations) + { + if (Meta.RevertMod(manipulation, out var mp) && mp != mod) + Penumbra.Log.Warning( + $"Invalid mod state, removing {mod.Name} and associated manipulation {manipulation} returned current mod {mp.Name}."); + } + + ConflictDict.Remove(mod); + foreach (var conflict in conflicts) + { + if (conflict.HasPriority) + { + ReloadModSync(conflict.Mod2, false); + } + else + { + var newConflicts = Conflicts(conflict.Mod2).Remove(c => c.Mod2 == mod); + if (newConflicts.Count > 0) + ConflictDict[conflict.Mod2] = newConflicts; + else + ConflictDict.Remove(conflict.Mod2); + } + } + + if (addMetaChanges) + _manager.MetaFileManager.ApplyDefaultFiles(_collection); + } + + + /// Add all files and possibly manipulations of a given mod according to its settings in this collection. + internal void AddModSync(IMod mod, bool addMetaChanges) + { + var files = GetFiles(mod); + foreach (var (path, file) in files.FileRedirections) + AddFile(path, file, mod); + + if (files.Manipulations.Count > 0) + { + foreach (var (identifier, entry) in files.Manipulations.Eqp) + AddManipulation(mod, identifier, entry); + foreach (var (identifier, entry) in files.Manipulations.Eqdp) + AddManipulation(mod, identifier, entry); + foreach (var (identifier, entry) in files.Manipulations.Est) + AddManipulation(mod, identifier, entry); + foreach (var (identifier, entry) in files.Manipulations.Gmp) + AddManipulation(mod, identifier, entry); + foreach (var (identifier, entry) in files.Manipulations.Rsp) + AddManipulation(mod, identifier, entry); + foreach (var (identifier, entry) in files.Manipulations.Imc) + AddManipulation(mod, identifier, entry); + foreach (var (identifier, entry) in files.Manipulations.Atch) + AddManipulation(mod, identifier, entry); + foreach (var (identifier, entry) in files.Manipulations.Shp) + AddManipulation(mod, identifier, entry); + foreach (var (identifier, entry) in files.Manipulations.Atr) + AddManipulation(mod, identifier, entry); + foreach (var identifier in files.Manipulations.GlobalEqp) + AddManipulation(mod, identifier, null!); + } + + if (addMetaChanges) + { + _collection.Counters.IncrementChange(); + _manager.MetaFileManager.ApplyDefaultFiles(_collection); + } + } + + private AppliedModData GetFiles(IMod mod) + { + if (mod.Index < 0) + return mod.GetData(); + + var settings = _collection.GetActualSettings(mod.Index).Settings; + return settings is not { Enabled: true } + ? AppliedModData.Empty + : mod.GetData(settings); + } + + /// Invoke only if not in a full recalculation. + private void InvokeResolvedFileChange(ModCollection collection, ResolvedFileChanged.Type type, Utf8GamePath key, FullPath value, + FullPath old, IMod? mod) + { + if (Calculating == -1) + _manager.ResolvedFileChanged.Invoke(collection, type, key, value, old, mod); + } + + private static bool IsRedirectionSupported(Utf8GamePath path, IMod mod) + { + var ext = path.Extension().AsciiToLower().ToString(); + switch (ext) + { + case ".atch" or ".eqp" or ".eqdp" or ".est" or ".gmp" or ".cmp" or ".imc": + Penumbra.Messager.NotificationMessage( + $"Redirection of {ext} files for {mod.Name} is unsupported. This probably means that the mod is outdated and may not work correctly.\n\nPlease tell the mod creator to use the corresponding meta manipulations instead.", + NotificationType.Warning); + return false; + case ".lvb" or ".lgb" or ".sgb": + Penumbra.Messager.NotificationMessage($"Redirection of {ext} files for {mod.Name} is unsupported as this breaks the game.\n\nThis mod will probably not work correctly.", + NotificationType.Warning); + return false; + default: return true; + } + } + + // Add a specific file redirection, handling potential conflicts. + // For different mods, higher mod priority takes precedence before option group priority, + // which takes precedence before option priority, which takes precedence before ordering. + // Inside the same mod, conflicts are not recorded. + private void AddFile(Utf8GamePath path, FullPath file, IMod mod) + { + if (!CheckFullPath(path, file)) + return; + + if (!IsRedirectionSupported(path, mod)) + return; + + try + { + if (ResolvedFiles.TryAdd(path, new ModPath(mod, file))) + { + ModData.AddPath(mod, path); + CustomResources.Invalidate(path); + InvokeResolvedFileChange(_collection, ResolvedFileChanged.Type.Added, path, file, FullPath.Empty, mod); + return; + } + + var modPath = ResolvedFiles[path]; + // Lower prioritized option in the same mod. + if (mod == modPath.Mod) + return; + + if (AddConflict(path, mod, modPath.Mod)) + { + ModData.RemovePath(modPath.Mod, path); + ResolvedFiles[path] = new ModPath(mod, file); + ModData.AddPath(mod, path); + CustomResources.Invalidate(path); + InvokeResolvedFileChange(_collection, ResolvedFileChanged.Type.Replaced, path, file, modPath.Path, mod); + } + } + catch (Exception ex) + { + Penumbra.Log.Error( + $"[{Environment.CurrentManagedThreadId}] Error adding redirection {file} -> {path} for mod {mod.Name} to collection cache {AnonymizedName}:\n{ex}"); + } + } + + + // Remove all empty conflict sets for a given mod with the given conflicts. + // If transitive is true, also removes the corresponding version of the other mod. + private void RemoveEmptyConflicts(IMod mod, SingleArray oldConflicts, bool transitive) + { + var changedConflicts = oldConflicts.Remove(c => + { + if (c.Conflicts.Count == 0) + { + if (transitive) + RemoveEmptyConflicts(c.Mod2, Conflicts(c.Mod2), false); + + return true; + } + + return false; + }); + if (changedConflicts.Count == 0) + ConflictDict.Remove(mod); + else + ConflictDict[mod] = changedConflicts; + } + + // Add a new conflict between the added mod and the existing mod. + // Update all other existing conflicts between the existing mod and other mods if necessary. + // Returns if the added mod takes priority before the existing mod. + private bool AddConflict(object data, IMod addedMod, IMod existingMod) + { + var addedPriority = addedMod.Index >= 0 ? _collection.GetActualSettings(addedMod.Index).Settings!.Priority : addedMod.Priority; + var existingPriority = + existingMod.Index >= 0 ? _collection.GetActualSettings(existingMod.Index).Settings!.Priority : existingMod.Priority; + + if (existingPriority < addedPriority) + { + var tmpConflicts = Conflicts(existingMod); + foreach (var conflict in tmpConflicts) + { + if (data is Utf8GamePath path && conflict.Conflicts.RemoveAll(p => p is Utf8GamePath x && x.Equals(path)) > 0 + || data is IMetaIdentifier meta && conflict.Conflicts.RemoveAll(m => m.Equals(meta)) > 0) + AddConflict(data, addedMod, conflict.Mod2); + } + + RemoveEmptyConflicts(existingMod, tmpConflicts, true); + } + + var addedConflicts = Conflicts(addedMod); + var existingConflicts = Conflicts(existingMod); + if (addedConflicts.FindFirst(c => c.Mod2 == existingMod, out var oldConflicts)) + { + // Only need to change one list since both conflict lists refer to the same list. + oldConflicts.Conflicts.Add(data); + } + else + { + // Add the same conflict list to both conflict directions. + var conflictList = new List { data }; + ConflictDict[addedMod] = addedConflicts.Append(new ModConflicts(existingMod, conflictList, existingPriority < addedPriority, + existingPriority != addedPriority)); + ConflictDict[existingMod] = existingConflicts.Append(new ModConflicts(addedMod, conflictList, + existingPriority >= addedPriority, + existingPriority != addedPriority)); + } + + return existingPriority < addedPriority; + } + + // Add a specific manipulation, handling potential conflicts. + // For different mods, higher mod priority takes precedence before option group priority, + // which takes precedence before option priority, which takes precedence before ordering. + // Inside the same mod, conflicts are not recorded. + private void AddManipulation(IMod mod, IMetaIdentifier identifier, object entry) + { + if (!Meta.TryGetMod(identifier, out var existingMod)) + { + Meta.ApplyMod(mod, identifier, entry); + ModData.AddManip(mod, identifier); + return; + } + + // Lower prioritized option in the same mod. + if (mod == existingMod) + return; + + if (AddConflict(identifier, mod, existingMod)) + { + ModData.RemoveManip(existingMod, identifier); + Meta.ApplyMod(mod, identifier, entry); + ModData.AddManip(mod, identifier); + } + } + + + // Identify and record all manipulated objects for this entire collection. + private void SetChangedItems() + { + if (_changedItemsSaveCounter == _collection.Counters.Change) + return; + + try + { + _changedItemsSaveCounter = _collection.Counters.Change; + _changedItems.Clear(); + // Skip IMCs because they would result in far too many false-positive items, + // since they are per set instead of per item-slot/item/variant. + var identifier = _manager.MetaFileManager.Identifier; + var items = new SortedList(512); + + void AddItems(IMod mod) + { + foreach (var (name, obj) in items) + { + if (!_changedItems.TryGetValue(name, out var data)) + _changedItems.Add(name, (new SingleArray(mod), obj)); + else if (!data.Item1.Contains(mod)) + _changedItems[name] = (data.Item1.Append(mod), + obj is IdentifiedCounter x && data.Item2 is IdentifiedCounter y ? x + y : obj); + else if (obj is IdentifiedCounter x && data.Item2 is IdentifiedCounter y) + _changedItems[name] = (data.Item1, x + y); + } + + items.Clear(); + } + + foreach (var (resolved, modPath) in ResolvedFiles.Where(file => !file.Key.Path.EndsWith("imc"u8))) + { + identifier.Identify(items, resolved.ToString()); + AddItems(modPath.Mod); + } + + foreach (var (manip, mod) in Meta.IdentifierSources) + { + manip.AddChangedItems(identifier, items); + AddItems(mod); + } + + if (_manager.Config.HideMachinistOffhandFromChangedItems) + _changedItems.RemoveMachinistOffhands(); + } + catch (Exception e) + { + Penumbra.Log.Error($"Unknown Error:\n{e}"); + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool CheckFullPath(Utf8GamePath path, FullPath fullPath) + { + if (fullPath.InternalName.Length < Utf8GamePath.MaxGamePathLength) + return true; + + Penumbra.Log.Error($"The redirected path is too long to add the redirection\n\t{path}\n\t--> {fullPath}"); + return false; + } + + public readonly record struct ChangeData + { + public readonly CollectionCache Cache; + public readonly Utf8GamePath Path; + public readonly FullPath FullPath; + public readonly IMod Mod; + public readonly byte Type; + public readonly bool AddMetaChanges; + + private ChangeData(CollectionCache cache, Utf8GamePath p, FullPath fp, IMod m, byte t, bool a) + { + Cache = cache; + Path = p; + FullPath = fp; + Mod = m; + Type = t; + AddMetaChanges = a; + } + + public static ChangeData ModRemoval(CollectionCache cache, IMod mod, bool addMetaChanges) + => new(cache, Utf8GamePath.Empty, FullPath.Empty, mod, 0, addMetaChanges); + + public static ChangeData ModAddition(CollectionCache cache, IMod mod, bool addMetaChanges) + => new(cache, Utf8GamePath.Empty, FullPath.Empty, mod, 1, addMetaChanges); + + public static ChangeData ModReload(CollectionCache cache, IMod mod, bool addMetaChanges) + => new(cache, Utf8GamePath.Empty, FullPath.Empty, mod, 2, addMetaChanges); + + public static ChangeData ForcedFile(CollectionCache cache, Utf8GamePath p, FullPath fp) + => new(cache, p, fp, Mods.Mod.ForcedFiles, 3, false); + + public void Apply() + { + switch (Type) + { + case 0: + Cache.RemoveModSync(Mod, AddMetaChanges); + break; + case 1: + Cache.AddModSync(Mod, AddMetaChanges); + break; + case 2: + Cache.ReloadModSync(Mod, AddMetaChanges); + break; + case 3: + Cache.ForceFileSync(Path, FullPath); + break; + } + } + } +} diff --git a/Penumbra/Collections/Cache/CollectionCacheManager.cs b/Penumbra/Collections/Cache/CollectionCacheManager.cs new file mode 100644 index 00000000..ec48e608 --- /dev/null +++ b/Penumbra/Collections/Cache/CollectionCacheManager.cs @@ -0,0 +1,411 @@ +using Dalamud.Plugin.Services; +using OtterGui.Classes; +using OtterGui.Services; +using Penumbra.Api; +using Penumbra.Api.Enums; +using Penumbra.Collections.Manager; +using Penumbra.Communication; +using Penumbra.Interop.Hooks.ResourceLoading; +using Penumbra.Meta; +using Penumbra.Mods; +using Penumbra.Mods.Groups; +using Penumbra.Mods.Manager; +using Penumbra.Mods.Manager.OptionEditor; +using Penumbra.Mods.Settings; +using Penumbra.Mods.SubMods; +using Penumbra.Services; +using Penumbra.String.Classes; + +namespace Penumbra.Collections.Cache; + +public class CollectionCacheManager : IDisposable, IService +{ + private readonly FrameworkManager _framework; + private readonly CommunicatorService _communicator; + private readonly TempModManager _tempMods; + private readonly ModStorage _modStorage; + private readonly CollectionStorage _storage; + private readonly ActiveCollections _active; + internal readonly Configuration Config; + internal readonly ResolvedFileChanged ResolvedFileChanged; + internal readonly MetaFileManager MetaFileManager; + internal readonly ResourceLoader ResourceLoader; + + private readonly ConcurrentQueue _changeQueue = new(); + + private int _count; + + public int Count + => _count; + + public IEnumerable Active + => _storage.Where(c => c.HasCache); + + public CollectionCacheManager(FrameworkManager framework, CommunicatorService communicator, TempModManager tempMods, ModStorage modStorage, + MetaFileManager metaFileManager, ActiveCollections active, CollectionStorage storage, ResourceLoader resourceLoader, + Configuration config) + { + _framework = framework; + _communicator = communicator; + _tempMods = tempMods; + _modStorage = modStorage; + MetaFileManager = metaFileManager; + _active = active; + _storage = storage; + ResourceLoader = resourceLoader; + Config = config; + ResolvedFileChanged = _communicator.ResolvedFileChanged; + + if (!_active.Individuals.IsLoaded) + _active.Individuals.Loaded += CreateNecessaryCaches; + _framework.Framework.Update += OnFramework; + _communicator.CollectionChange.Subscribe(OnCollectionChange, CollectionChange.Priority.CollectionCacheManager); + _communicator.ModPathChanged.Subscribe(OnModChangeAddition, ModPathChanged.Priority.CollectionCacheManagerAddition); + _communicator.ModPathChanged.Subscribe(OnModChangeRemoval, ModPathChanged.Priority.CollectionCacheManagerRemoval); + _communicator.TemporaryGlobalModChange.Subscribe(OnGlobalModChange, TemporaryGlobalModChange.Priority.CollectionCacheManager); + _communicator.ModOptionChanged.Subscribe(OnModOptionChange, ModOptionChanged.Priority.CollectionCacheManager); + _communicator.ModSettingChanged.Subscribe(OnModSettingChange, ModSettingChanged.Priority.CollectionCacheManager); + _communicator.CollectionInheritanceChanged.Subscribe(OnCollectionInheritanceChange, + CollectionInheritanceChanged.Priority.CollectionCacheManager); + _communicator.ModDiscoveryStarted.Subscribe(OnModDiscoveryStarted, ModDiscoveryStarted.Priority.CollectionCacheManager); + _communicator.ModDiscoveryFinished.Subscribe(OnModDiscoveryFinished, ModDiscoveryFinished.Priority.CollectionCacheManager); + + if (!MetaFileManager.CharacterUtility.Ready) + MetaFileManager.CharacterUtility.LoadingFinished.Subscribe(IncrementCounters, CharacterUtilityFinished.Priority.CollectionCacheManager); + } + + public void Dispose() + { + _communicator.CollectionChange.Unsubscribe(OnCollectionChange); + _communicator.ModPathChanged.Unsubscribe(OnModChangeAddition); + _communicator.ModPathChanged.Unsubscribe(OnModChangeRemoval); + _communicator.TemporaryGlobalModChange.Unsubscribe(OnGlobalModChange); + _communicator.ModOptionChanged.Unsubscribe(OnModOptionChange); + _communicator.ModSettingChanged.Unsubscribe(OnModSettingChange); + _communicator.CollectionInheritanceChanged.Unsubscribe(OnCollectionInheritanceChange); + MetaFileManager.CharacterUtility.LoadingFinished.Unsubscribe(IncrementCounters); + + foreach (var collection in _storage) + { + collection._cache?.Dispose(); + collection._cache = null; + } + } + + public void AddChange(CollectionCache.ChangeData data) + { + if (data.Cache.Calculating == -1) + { + if (_framework.Framework.IsInFrameworkUpdateThread) + data.Apply(); + else + _changeQueue.Enqueue(data); + } + else if (data.Cache.Calculating == Environment.CurrentManagedThreadId) + { + data.Apply(); + } + else + { + _changeQueue.Enqueue(data); + } + } + + /// Only creates a new cache, does not update an existing one. + public bool CreateCache(ModCollection collection) + { + if (collection.Identity.Index == ModCollection.Empty.Identity.Index) + return false; + + if (collection._cache != null) + return false; + + collection._cache = new CollectionCache(this, collection); + if (collection.Identity.Index > 0) + Interlocked.Increment(ref _count); + Penumbra.Log.Verbose($"Created new cache for collection {collection.Identity.AnonymizedName}."); + return true; + } + + /// + /// Update the effective file list for the given cache. + /// Does not create caches. + /// + public void CalculateEffectiveFileList(ModCollection collection) + => _framework.RegisterImportant(nameof(CalculateEffectiveFileList) + collection.Identity.Identifier, + () => CalculateEffectiveFileListInternal(collection)); + + private void CalculateEffectiveFileListInternal(ModCollection collection) + { + // Skip the empty collection. + if (collection.Identity.Index == 0) + return; + + Penumbra.Log.Debug($"[{Environment.CurrentManagedThreadId}] Recalculating effective file list for {collection.Identity.AnonymizedName}"); + if (!collection.HasCache) + { + Penumbra.Log.Error( + $"[{Environment.CurrentManagedThreadId}] Recalculating effective file list for {collection.Identity.AnonymizedName} failed, no cache exists."); + } + else if (collection._cache!.Calculating != -1) + { + Penumbra.Log.Error( + $"[{Environment.CurrentManagedThreadId}] Recalculating effective file list for {collection.Identity.AnonymizedName} failed, already in calculation on [{collection._cache!.Calculating}]."); + } + else + { + FullRecalculation(collection); + + Penumbra.Log.Debug( + $"[{Environment.CurrentManagedThreadId}] Recalculation of effective file list for {collection.Identity.AnonymizedName} finished."); + } + } + + private void FullRecalculation(ModCollection collection) + { + var cache = collection._cache; + if (cache is not { Calculating: -1 }) + return; + + cache.Calculating = Environment.CurrentManagedThreadId; + try + { + ResolvedFileChanged.Invoke(collection, ResolvedFileChanged.Type.FullRecomputeStart, Utf8GamePath.Empty, FullPath.Empty, + FullPath.Empty, null); + cache.ResolvedFiles.Clear(); + cache.Meta.Reset(); + cache.ConflictDict.Clear(); + + // Add all forced redirects. + foreach (var tempMod in _tempMods.ModsForAllCollections + .Concat(_tempMods.Mods.TryGetValue(collection, out var list) + ? list + : Array.Empty())) + cache.AddModSync(tempMod, false); + + foreach (var mod in _modStorage) + cache.AddModSync(mod, false); + + collection.Counters.IncrementChange(); + + MetaFileManager.ApplyDefaultFiles(collection); + ResolvedFileChanged.Invoke(collection, ResolvedFileChanged.Type.FullRecomputeFinished, Utf8GamePath.Empty, FullPath.Empty, + FullPath.Empty, + null); + } + finally + { + cache.Calculating = -1; + } + } + + private void OnCollectionChange(CollectionType type, ModCollection? old, ModCollection? newCollection, string displayName) + { + if (type is CollectionType.Temporary) + { + if (newCollection != null && CreateCache(newCollection)) + CalculateEffectiveFileList(newCollection); + + if (old != null) + ClearCache(old); + } + else + { + RemoveCache(old); + if (type is not CollectionType.Inactive && newCollection != null && newCollection.Identity.Index != 0 && CreateCache(newCollection)) + CalculateEffectiveFileList(newCollection); + + if (type is CollectionType.Default) + if (newCollection != null) + MetaFileManager.ApplyDefaultFiles(newCollection); + else + MetaFileManager.CharacterUtility.ResetAll(); + } + } + + + private void OnModChangeRemoval(ModPathChangeType type, Mod mod, DirectoryInfo? oldModPath, DirectoryInfo? newModPath) + { + switch (type) + { + case ModPathChangeType.Deleted: + case ModPathChangeType.StartingReload: + foreach (var collection in _storage.Where(c => c.HasCache && c.GetActualSettings(mod.Index).Settings?.Enabled == true)) + collection._cache!.RemoveMod(mod, true); + break; + case ModPathChangeType.Moved: + foreach (var collection in _storage.Where(c => c.HasCache && c.GetActualSettings(mod.Index).Settings?.Enabled == true)) + collection._cache!.ReloadMod(mod, true); + break; + } + } + + private void OnModChangeAddition(ModPathChangeType type, Mod mod, DirectoryInfo? oldModPath, DirectoryInfo? newModPath) + { + if (type is not (ModPathChangeType.Added or ModPathChangeType.Reloaded)) + return; + + foreach (var collection in _storage.Where(c => c.HasCache && c.GetActualSettings(mod.Index).Settings?.Enabled == true)) + collection._cache!.AddMod(mod, true); + } + + /// Apply a mod change to all collections with a cache. + private void OnGlobalModChange(TemporaryMod mod, bool created, bool removed) + => TempModManager.OnGlobalModChange(_storage.Where(c => c.HasCache), mod, created, removed); + + /// Remove a cache from a collection if it is active. + private void RemoveCache(ModCollection? collection) + { + if (collection != null + && collection.Identity.Index > ModCollection.Empty.Identity.Index + && collection.Identity.Index != _active.Default.Identity.Index + && collection.Identity.Index != _active.Interface.Identity.Index + && collection.Identity.Index != _active.Current.Identity.Index + && _active.SpecialAssignments.All(c => c.Value.Identity.Index != collection.Identity.Index) + && _active.Individuals.All(c => c.Collection.Identity.Index != collection.Identity.Index)) + ClearCache(collection); + } + + /// Prepare Changes by removing mods from caches with collections or add or reload mods. + private void OnModOptionChange(ModOptionChangeType type, Mod mod, IModGroup? group, IModOption? option, IModDataContainer? container, + int movedToIdx) + { + if (type is ModOptionChangeType.PrepareChange) + { + foreach (var collection in _storage.Where(collection => collection.HasCache && collection.GetActualSettings(mod.Index).Settings is { Enabled: true })) + collection._cache!.RemoveMod(mod, false); + + return; + } + + type.HandlingInfo(out _, out var recomputeList, out var justAdd); + + if (!recomputeList) + return; + + foreach (var collection in _storage.Where(collection => collection.HasCache && collection.GetActualSettings(mod.Index).Settings is { Enabled: true })) + { + if (justAdd) + collection._cache!.AddMod(mod, true); + else + collection._cache!.ReloadMod(mod, true); + } + } + + /// Increment the counter to ensure new files are loaded after applying meta changes. + private void IncrementCounters() + { + foreach (var collection in _storage.Where(c => c.HasCache)) + collection.Counters.IncrementChange(); + MetaFileManager.CharacterUtility.LoadingFinished.Unsubscribe(IncrementCounters); + } + + private void OnModSettingChange(ModCollection collection, ModSettingChange type, Mod? mod, Setting oldValue, int groupIdx, bool _) + { + if (!collection.HasCache) + return; + + var cache = collection._cache!; + switch (type) + { + case ModSettingChange.Inheritance: + cache.ReloadMod(mod!, true); + break; + case ModSettingChange.EnableState: + if (oldValue == Setting.False) + cache.AddMod(mod!, true); + else if (oldValue == Setting.True) + cache.RemoveMod(mod!, true); + else if (collection.GetActualSettings(mod!.Index).Settings?.Enabled == true) + cache.ReloadMod(mod!, true); + else + cache.RemoveMod(mod!, true); + + break; + case ModSettingChange.Priority: + if (cache.Conflicts(mod!).Count > 0) + cache.ReloadMod(mod!, true); + + break; + case ModSettingChange.Setting: + if (collection.GetActualSettings(mod!.Index).Settings?.Enabled == true) + cache.ReloadMod(mod, true); + + break; + case ModSettingChange.TemporarySetting: + cache.ReloadMod(mod!, true); + break; + case ModSettingChange.MultiInheritance: + case ModSettingChange.MultiEnableState: + FullRecalculation(collection); + break; + case ModSettingChange.TemporaryMod: + case ModSettingChange.Edited: + // handled otherwise + break; + } + } + + /// + /// Inheritance changes are too big to check for relevance, + /// just recompute everything. + /// + private void OnCollectionInheritanceChange(ModCollection collection, bool _) + => FullRecalculation(collection); + + /// Clear the current cache of a collection. + private void ClearCache(ModCollection collection) + { + if (!collection.HasCache) + return; + + collection._cache!.Dispose(); + collection._cache = null; + if (collection.Identity.Index > 0) + Interlocked.Decrement(ref _count); + Penumbra.Log.Verbose($"Cleared cache of collection {collection.Identity.AnonymizedName}."); + } + + /// + /// Cache handling. Usually recreate caches on the next framework tick, + /// but at launch create all of them at once. + /// + public void CreateNecessaryCaches() + { + ModCollection[] caches; + // Lock to make sure no race conditions during CreateCache happen. + lock (this) + { + caches = _active.SpecialAssignments.Select(p => p.Value) + .Concat(_active.Individuals.Select(p => p.Collection)) + .Prepend(_active.Current) + .Prepend(_active.Default) + .Prepend(_active.Interface) + .Where(CreateCache).ToArray(); + } + + Parallel.ForEach(caches, CalculateEffectiveFileListInternal); + } + + private void OnModDiscoveryStarted() + { + foreach (var collection in Active) + { + collection._cache!.ResolvedFiles.Clear(); + collection._cache!.Meta.Reset(); + collection._cache!.ConflictDict.Clear(); + } + } + + private void OnModDiscoveryFinished() + => Parallel.ForEach(Active, CalculateEffectiveFileListInternal); + + /// + /// Update forced files only on framework. + /// + private void OnFramework(IFramework _) + { + while (_changeQueue.TryDequeue(out var changeData)) + changeData.Apply(); + } +} diff --git a/Penumbra/Collections/Cache/CollectionModData.cs b/Penumbra/Collections/Cache/CollectionModData.cs new file mode 100644 index 00000000..295191d2 --- /dev/null +++ b/Penumbra/Collections/Cache/CollectionModData.cs @@ -0,0 +1,62 @@ +using Penumbra.Meta.Manipulations; +using Penumbra.Mods.Editor; +using Penumbra.String.Classes; + +namespace Penumbra.Collections.Cache; + +/// +/// Contains associations between a mod and the paths and meta manipulations affected by that mod. +/// +public class CollectionModData +{ + private readonly Dictionary, HashSet)> _data = new(); + + public IEnumerable<(IMod, IReadOnlySet, IReadOnlySet)> Data + => _data.Select(kvp => (kvp.Key, (IReadOnlySet)kvp.Value.Item1, (IReadOnlySet)kvp.Value.Item2)); + + public (IReadOnlyCollection Paths, IReadOnlyCollection Manipulations) RemoveMod(IMod mod) + { + if (_data.Remove(mod, out var data)) + return data; + + return ([], []); + } + + public void AddPath(IMod mod, Utf8GamePath path) + { + if (_data.TryGetValue(mod, out var data)) + { + data.Item1.Add(path); + } + else + { + data = ([path], []); + _data.Add(mod, data); + } + } + + public void AddManip(IMod mod, IMetaIdentifier manipulation) + { + if (_data.TryGetValue(mod, out var data)) + { + data.Item2.Add(manipulation); + } + else + { + data = ([], [manipulation]); + _data.Add(mod, data); + } + } + + public void RemovePath(IMod mod, Utf8GamePath path) + { + if (_data.TryGetValue(mod, out var data) && data.Item1.Remove(path) && data.Item1.Count == 0 && data.Item2.Count == 0) + _data.Remove(mod); + } + + public void RemoveManip(IMod mod, IMetaIdentifier manip) + { + if (_data.TryGetValue(mod, out var data) && data.Item2.Remove(manip) && data.Item1.Count == 0 && data.Item2.Count == 0) + _data.Remove(mod); + } +} diff --git a/Penumbra/Collections/Cache/CustomResourceCache.cs b/Penumbra/Collections/Cache/CustomResourceCache.cs new file mode 100644 index 00000000..e63f8637 --- /dev/null +++ b/Penumbra/Collections/Cache/CustomResourceCache.cs @@ -0,0 +1,49 @@ +using FFXIVClientStructs.FFXIV.Client.System.Resource; +using Penumbra.Api.Enums; +using Penumbra.Interop.Hooks.ResourceLoading; +using Penumbra.Interop.SafeHandles; +using Penumbra.String.Classes; + +namespace Penumbra.Collections.Cache; + +/// A cache for resources owned by a collection. +public sealed class CustomResourceCache(ResourceLoader loader) + : ConcurrentDictionary, IDisposable +{ + /// Invalidate an existing resource by clearing it from the cache and disposing it. + public void Invalidate(Utf8GamePath path) + { + if (TryRemove(path, out var handle)) + handle.Dispose(); + } + + public void Dispose() + { + foreach (var handle in Values) + handle.Dispose(); + Clear(); + } + + /// Get the requested resource either from the cached resource, or load a new one if it does not exist. + public SafeResourceHandle Get(ResourceCategory category, ResourceType type, Utf8GamePath path, ResolveData data) + { + if (TryGetClonedValue(path, out var handle)) + return handle; + + handle = loader.LoadResolvedSafeResource(category, type, path.Path, data); + var clone = handle.Clone(); + if (!TryAdd(path, clone)) + clone.Dispose(); + return handle; + } + + /// Get a cloned cached resource if it exists. + private bool TryGetClonedValue(Utf8GamePath path, [NotNullWhen(true)] out SafeResourceHandle? handle) + { + if (!TryGetValue(path, out handle)) + return false; + + handle = handle.Clone(); + return true; + } +} diff --git a/Penumbra/Collections/Cache/EqdpCache.cs b/Penumbra/Collections/Cache/EqdpCache.cs new file mode 100644 index 00000000..5e0626cf --- /dev/null +++ b/Penumbra/Collections/Cache/EqdpCache.cs @@ -0,0 +1,54 @@ +using Penumbra.GameData.Enums; +using Penumbra.GameData.Structs; +using Penumbra.Meta; +using Penumbra.Meta.Manipulations; + +namespace Penumbra.Collections.Cache; + +public sealed class EqdpCache(MetaFileManager manager, ModCollection collection) : MetaCacheBase(manager, collection) +{ + private readonly Dictionary<(PrimaryId Id, GenderRace GenderRace, bool Accessory), (EqdpEntry Entry, EqdpEntry InverseMask)> _fullEntries = + []; + + public EqdpEntry ApplyFullEntry(PrimaryId id, GenderRace genderRace, bool accessory, EqdpEntry originalEntry) + => _fullEntries.TryGetValue((id, genderRace, accessory), out var pair) + ? (originalEntry & pair.InverseMask) | pair.Entry + : originalEntry; + + public void Reset() + { + Clear(); + _fullEntries.Clear(); + } + + protected override void ApplyModInternal(EqdpIdentifier identifier, EqdpEntry entry) + { + var tuple = (identifier.SetId, identifier.GenderRace, identifier.Slot.IsAccessory()); + var mask = Eqdp.Mask(identifier.Slot); + var inverseMask = ~mask; + if (_fullEntries.TryGetValue(tuple, out var pair)) + pair = ((pair.Entry & inverseMask) | (entry & mask), pair.InverseMask & inverseMask); + else + pair = (entry & mask, inverseMask); + _fullEntries[tuple] = pair; + } + + protected override void RevertModInternal(EqdpIdentifier identifier) + { + var tuple = (identifier.SetId, identifier.GenderRace, identifier.Slot.IsAccessory()); + + if (!_fullEntries.Remove(tuple, out var pair)) + return; + + var mask = Eqdp.Mask(identifier.Slot); + var newMask = pair.InverseMask | mask; + if (newMask is not EqdpEntry.FullMask) + _fullEntries[tuple] = (pair.Entry & ~mask, newMask); + } + + protected override void Dispose(bool _) + { + Clear(); + _fullEntries.Clear(); + } +} diff --git a/Penumbra/Collections/Cache/EqpCache.cs b/Penumbra/Collections/Cache/EqpCache.cs new file mode 100644 index 00000000..da1a1d44 --- /dev/null +++ b/Penumbra/Collections/Cache/EqpCache.cs @@ -0,0 +1,66 @@ +using Penumbra.GameData.Enums; +using Penumbra.GameData.Structs; +using Penumbra.Meta; +using Penumbra.Meta.Files; +using Penumbra.Meta.Manipulations; + +namespace Penumbra.Collections.Cache; + +public sealed class EqpCache(MetaFileManager manager, ModCollection collection) : MetaCacheBase(manager, collection) +{ + public unsafe EqpEntry GetValues(CharacterArmor* armor) + { + var bodyEntry = GetSingleValue(armor[1].Set, EquipSlot.Body); + var headEntry = bodyEntry.HasFlag(EqpEntry.BodyShowHead) + ? GetSingleValue(armor[0].Set, EquipSlot.Head) + : GetSingleValue(armor[1].Set, EquipSlot.Head); + var handEntry = bodyEntry.HasFlag(EqpEntry.BodyShowHand) + ? GetSingleValue(armor[2].Set, EquipSlot.Hands) + : GetSingleValue(armor[1].Set, EquipSlot.Hands); + var (legsEntry, legsId) = bodyEntry.HasFlag(EqpEntry.BodyShowLeg) + ? (GetSingleValue(armor[3].Set, EquipSlot.Legs), 3) + : (GetSingleValue(armor[1].Set, EquipSlot.Legs), 1); + var footEntry = legsEntry.HasFlag(EqpEntry.LegsShowFoot) + ? GetSingleValue(armor[4].Set, EquipSlot.Feet) + : GetSingleValue(armor[legsId].Set, EquipSlot.Feet); + + var combined = bodyEntry | headEntry | handEntry | legsEntry | footEntry; + return PostProcessFeet(PostProcessHands(combined)); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] + private EqpEntry GetSingleValue(PrimaryId id, EquipSlot slot) + => TryGetValue(new EqpIdentifier(id, slot), out var pair) ? pair.Entry : ExpandedEqpFile.GetDefault(Manager, id) & Eqp.Mask(slot); + + public void Reset() + => Clear(); + + protected override void Dispose(bool _) + => Clear(); + + [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] + private static EqpEntry PostProcessHands(EqpEntry entry) + { + if (!entry.HasFlag(EqpEntry.HandsHideForearm)) + return entry; + + var testFlag = entry.HasFlag(EqpEntry.HandsHideElbow) + ? entry.HasFlag(EqpEntry.BodyHideGlovesL) + : entry.HasFlag(EqpEntry.BodyHideGlovesM); + return testFlag + ? (entry | EqpEntry.BodyHideGloveCuffs) & ~EqpEntry.BodyHideGlovesS + : entry & ~(EqpEntry.BodyHideGloveCuffs | EqpEntry.BodyHideGlovesS); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] + private static EqpEntry PostProcessFeet(EqpEntry entry) + { + if (!entry.HasFlag(EqpEntry.FeetHideCalf)) + return entry; + + if (entry.HasFlag(EqpEntry.FeetHideKnee) || !entry.HasFlag(EqpEntry._20)) + return entry & ~(EqpEntry.LegsHideBootsS | EqpEntry.LegsHideBootsM); + + return (entry | EqpEntry.LegsHideBootsM) & ~EqpEntry.LegsHideBootsS; + } +} diff --git a/Penumbra/Collections/Cache/EstCache.cs b/Penumbra/Collections/Cache/EstCache.cs new file mode 100644 index 00000000..aff8beef --- /dev/null +++ b/Penumbra/Collections/Cache/EstCache.cs @@ -0,0 +1,19 @@ +using Penumbra.Meta; +using Penumbra.Meta.Files; +using Penumbra.Meta.Manipulations; + +namespace Penumbra.Collections.Cache; + +public sealed class EstCache(MetaFileManager manager, ModCollection collection) : MetaCacheBase(manager, collection) +{ + public EstEntry GetEstEntry(EstIdentifier identifier) + => TryGetValue(identifier, out var entry) + ? entry.Entry + : EstFile.GetDefault(Manager, identifier); + + public void Reset() + => Clear(); + + protected override void Dispose(bool _) + => Clear(); +} diff --git a/Penumbra/Collections/Cache/GlobalEqpCache.cs b/Penumbra/Collections/Cache/GlobalEqpCache.cs new file mode 100644 index 00000000..7d2fbf64 --- /dev/null +++ b/Penumbra/Collections/Cache/GlobalEqpCache.cs @@ -0,0 +1,122 @@ +using OtterGui.Classes; +using OtterGui.Services; +using Penumbra.GameData.Structs; +using Penumbra.Meta.Manipulations; +using Penumbra.Mods.Editor; + +namespace Penumbra.Collections.Cache; + +public class GlobalEqpCache : ReadWriteDictionary, IService +{ + private readonly HashSet _doNotHideEarrings = []; + private readonly HashSet _doNotHideNecklace = []; + private readonly HashSet _doNotHideBracelets = []; + private readonly HashSet _doNotHideRingL = []; + private readonly HashSet _doNotHideRingR = []; + private bool _doNotHideVieraHats; + private bool _doNotHideHrothgarHats; + private bool _hideAuRaHorns; + private bool _hideVieraEars; + private bool _hideMiqoteEars; + + public new void Clear() + { + base.Clear(); + _doNotHideEarrings.Clear(); + _doNotHideNecklace.Clear(); + _doNotHideBracelets.Clear(); + _doNotHideRingL.Clear(); + _doNotHideRingR.Clear(); + _doNotHideHrothgarHats = false; + _doNotHideVieraHats = false; + _hideAuRaHorns = false; + _hideVieraEars = false; + _hideMiqoteEars = false; + } + + public unsafe EqpEntry Apply(EqpEntry original, CharacterArmor* armor) + { + if (Count == 0) + return original; + + if (_doNotHideVieraHats) + original |= EqpEntry.HeadShowVieraHat; + + if (_doNotHideHrothgarHats) + original |= EqpEntry.HeadShowHrothgarHat; + + if (_hideAuRaHorns) + original &= ~EqpEntry.HeadShowEarAuRa; + + if (_hideVieraEars) + original &= ~EqpEntry.HeadShowEarViera; + + if (_hideMiqoteEars) + original &= ~EqpEntry.HeadShowEarMiqote; + + if (_doNotHideEarrings.Contains(armor[5].Set)) + original |= EqpEntry.HeadShowEarringsHyurRoe + | EqpEntry.HeadShowEarringsLalaElezen + | EqpEntry.HeadShowEarringsMiqoHrothViera + | EqpEntry.HeadShowEarringsAura; + + if (_doNotHideNecklace.Contains(armor[6].Set)) + original |= EqpEntry.BodyShowNecklace | EqpEntry.HeadShowNecklace; + + if (_doNotHideBracelets.Contains(armor[7].Set)) + original |= EqpEntry.BodyShowBracelet | EqpEntry.HandShowBracelet; + + if (_doNotHideRingR.Contains(armor[8].Set)) + original |= EqpEntry.HandShowRingR; + + if (_doNotHideRingL.Contains(armor[9].Set)) + original |= EqpEntry.HandShowRingL; + + return original; + } + + public bool ApplyMod(IMod mod, GlobalEqpManipulation manipulation) + { + if (Remove(manipulation, out var oldMod) && oldMod == mod) + return false; + + this[manipulation] = mod; + _ = manipulation.Type switch + { + GlobalEqpType.DoNotHideEarrings => _doNotHideEarrings.Add(manipulation.Condition), + GlobalEqpType.DoNotHideNecklace => _doNotHideNecklace.Add(manipulation.Condition), + GlobalEqpType.DoNotHideBracelets => _doNotHideBracelets.Add(manipulation.Condition), + GlobalEqpType.DoNotHideRingR => _doNotHideRingR.Add(manipulation.Condition), + GlobalEqpType.DoNotHideRingL => _doNotHideRingL.Add(manipulation.Condition), + GlobalEqpType.DoNotHideHrothgarHats => !_doNotHideHrothgarHats && (_doNotHideHrothgarHats = true), + GlobalEqpType.DoNotHideVieraHats => !_doNotHideVieraHats && (_doNotHideVieraHats = true), + GlobalEqpType.HideHorns => !_hideAuRaHorns && (_hideAuRaHorns = true), + GlobalEqpType.HideMiqoteEars => !_hideMiqoteEars && (_hideMiqoteEars = true), + GlobalEqpType.HideVieraEars => !_hideVieraEars && (_hideVieraEars = true), + _ => false, + }; + return true; + } + + public bool RevertMod(GlobalEqpManipulation manipulation, [NotNullWhen(true)] out IMod? mod) + { + if (!Remove(manipulation, out mod)) + return false; + + _ = manipulation.Type switch + { + GlobalEqpType.DoNotHideEarrings => _doNotHideEarrings.Remove(manipulation.Condition), + GlobalEqpType.DoNotHideNecklace => _doNotHideNecklace.Remove(manipulation.Condition), + GlobalEqpType.DoNotHideBracelets => _doNotHideBracelets.Remove(manipulation.Condition), + GlobalEqpType.DoNotHideRingR => _doNotHideRingR.Remove(manipulation.Condition), + GlobalEqpType.DoNotHideRingL => _doNotHideRingL.Remove(manipulation.Condition), + GlobalEqpType.DoNotHideHrothgarHats => _doNotHideHrothgarHats && !(_doNotHideHrothgarHats = false), + GlobalEqpType.DoNotHideVieraHats => _doNotHideVieraHats && !(_doNotHideVieraHats = false), + GlobalEqpType.HideHorns => _hideAuRaHorns && (_hideAuRaHorns = false), + GlobalEqpType.HideMiqoteEars => _hideMiqoteEars && (_hideMiqoteEars = false), + GlobalEqpType.HideVieraEars => _hideVieraEars && (_hideVieraEars = false), + _ => false, + }; + return true; + } +} diff --git a/Penumbra/Collections/Cache/GmpCache.cs b/Penumbra/Collections/Cache/GmpCache.cs new file mode 100644 index 00000000..9170b871 --- /dev/null +++ b/Penumbra/Collections/Cache/GmpCache.cs @@ -0,0 +1,14 @@ +using Penumbra.GameData.Structs; +using Penumbra.Meta; +using Penumbra.Meta.Manipulations; + +namespace Penumbra.Collections.Cache; + +public sealed class GmpCache(MetaFileManager manager, ModCollection collection) : MetaCacheBase(manager, collection) +{ + public void Reset() + => Clear(); + + protected override void Dispose(bool _) + => Clear(); +} diff --git a/Penumbra/Collections/Cache/ImcCache.cs b/Penumbra/Collections/Cache/ImcCache.cs new file mode 100644 index 00000000..461ffccc --- /dev/null +++ b/Penumbra/Collections/Cache/ImcCache.cs @@ -0,0 +1,102 @@ +using Penumbra.GameData.Structs; +using Penumbra.Meta; +using Penumbra.Meta.Files; +using Penumbra.Meta.Manipulations; +using Penumbra.String; + +namespace Penumbra.Collections.Cache; + +public sealed class ImcCache(MetaFileManager manager, ModCollection collection) : MetaCacheBase(manager, collection) +{ + private readonly Dictionary)> _imcFiles = []; + + public bool HasFile(CiByteString path) + => _imcFiles.ContainsKey(path); + + public bool GetFile(CiByteString path, [NotNullWhen(true)] out ImcFile? file) + { + if (!_imcFiles.TryGetValue(path, out var p)) + { + file = null; + return false; + } + + file = p.Item1; + return true; + } + + public void Reset() + { + foreach (var (_, (file, set)) in _imcFiles) + { + file.Reset(); + set.Clear(); + } + + _imcFiles.Clear(); + Clear(); + } + + protected override void ApplyModInternal(ImcIdentifier identifier, ImcEntry entry) + { + Collection.Counters.IncrementImc(); + ApplyFile(identifier, entry); + } + + private void ApplyFile(ImcIdentifier identifier, ImcEntry entry) + { + var path = identifier.GamePath().Path; + try + { + if (!_imcFiles.TryGetValue(path, out var pair)) + pair = (new ImcFile(Manager, identifier), []); + + if (!Apply(pair.Item1, identifier, entry)) + return; + + pair.Item2.Add(identifier); + _imcFiles[path] = pair; + } + catch (ImcException e) + { + Manager.ValidityChecker.ImcExceptions.Add(e); + Penumbra.Log.Error(e.ToString()); + } + catch (Exception e) + { + Penumbra.Log.Error($"Could not apply IMC Manipulation {identifier}:\n{e}"); + } + } + + protected override void RevertModInternal(ImcIdentifier identifier) + { + Collection.Counters.IncrementImc(); + var path = identifier.GamePath().Path; + if (!_imcFiles.TryGetValue(path, out var pair)) + return; + + if (!pair.Item2.Remove(identifier)) + return; + + if (pair.Item2.Count == 0) + { + _imcFiles.Remove(path); + pair.Item1.Dispose(); + return; + } + + var def = ImcFile.GetDefault(Manager, pair.Item1.Path, identifier.EquipSlot, identifier.Variant, out _); + Apply(pair.Item1, identifier, def); + } + + public static bool Apply(ImcFile file, ImcIdentifier identifier, ImcEntry entry) + => file.SetEntry(ImcFile.PartIndex(identifier.EquipSlot), identifier.Variant.Id, entry); + + protected override void Dispose(bool _) + { + foreach (var (_, (file, _)) in _imcFiles) + file.Dispose(); + Clear(); + _imcFiles.Clear(); + } +} diff --git a/Penumbra/Collections/Cache/MetaCache.cs b/Penumbra/Collections/Cache/MetaCache.cs new file mode 100644 index 00000000..011cdd23 --- /dev/null +++ b/Penumbra/Collections/Cache/MetaCache.cs @@ -0,0 +1,137 @@ +using Penumbra.GameData.Enums; +using Penumbra.GameData.Files.AtchStructs; +using Penumbra.GameData.Structs; +using Penumbra.Meta; +using Penumbra.Meta.Manipulations; +using Penumbra.Mods.Editor; + +namespace Penumbra.Collections.Cache; + +public class MetaCache(MetaFileManager manager, ModCollection collection) +{ + public readonly EqpCache Eqp = new(manager, collection); + public readonly EqdpCache Eqdp = new(manager, collection); + public readonly EstCache Est = new(manager, collection); + public readonly GmpCache Gmp = new(manager, collection); + public readonly RspCache Rsp = new(manager, collection); + public readonly ImcCache Imc = new(manager, collection); + public readonly AtchCache Atch = new(manager, collection); + public readonly ShpCache Shp = new(manager, collection); + public readonly AtrCache Atr = new(manager, collection); + public readonly GlobalEqpCache GlobalEqp = new(); + public bool IsDisposed { get; private set; } + + public int Count + => Eqp.Count + Eqdp.Count + Est.Count + Gmp.Count + Rsp.Count + Imc.Count + Atch.Count + Shp.Count + Atr.Count + GlobalEqp.Count; + + public IEnumerable<(IMetaIdentifier, IMod)> IdentifierSources + => Eqp.Select(kvp => ((IMetaIdentifier)kvp.Key, kvp.Value.Source)) + .Concat(Eqdp.Select(kvp => ((IMetaIdentifier)kvp.Key, kvp.Value.Source))) + .Concat(Est.Select(kvp => ((IMetaIdentifier)kvp.Key, kvp.Value.Source))) + .Concat(Gmp.Select(kvp => ((IMetaIdentifier)kvp.Key, kvp.Value.Source))) + .Concat(Rsp.Select(kvp => ((IMetaIdentifier)kvp.Key, kvp.Value.Source))) + .Concat(Imc.Select(kvp => ((IMetaIdentifier)kvp.Key, kvp.Value.Source))) + .Concat(Atch.Select(kvp => ((IMetaIdentifier)kvp.Key, kvp.Value.Source))) + .Concat(Shp.Select(kvp => ((IMetaIdentifier)kvp.Key, kvp.Value.Source))) + .Concat(Atr.Select(kvp => ((IMetaIdentifier)kvp.Key, kvp.Value.Source))) + .Concat(GlobalEqp.Select(kvp => ((IMetaIdentifier)kvp.Key, kvp.Value))); + + public void Reset() + { + Eqp.Reset(); + Eqdp.Reset(); + Est.Reset(); + Gmp.Reset(); + Rsp.Reset(); + Imc.Reset(); + Atch.Reset(); + Shp.Reset(); + Atr.Reset(); + GlobalEqp.Clear(); + } + + public void Dispose() + { + if (IsDisposed) + return; + + IsDisposed = true; + Eqp.Dispose(); + Eqdp.Dispose(); + Est.Dispose(); + Gmp.Dispose(); + Rsp.Dispose(); + Imc.Dispose(); + Atch.Dispose(); + Shp.Dispose(); + Atr.Dispose(); + } + + public bool TryGetMod(IMetaIdentifier identifier, [NotNullWhen(true)] out IMod? mod) + { + mod = null; + return identifier switch + { + EqdpIdentifier i => Eqdp.TryGetValue(i, out var p) && Convert(p, out mod), + EqpIdentifier i => Eqp.TryGetValue(i, out var p) && Convert(p, out mod), + EstIdentifier i => Est.TryGetValue(i, out var p) && Convert(p, out mod), + GmpIdentifier i => Gmp.TryGetValue(i, out var p) && Convert(p, out mod), + ImcIdentifier i => Imc.TryGetValue(i, out var p) && Convert(p, out mod), + RspIdentifier i => Rsp.TryGetValue(i, out var p) && Convert(p, out mod), + AtchIdentifier i => Atch.TryGetValue(i, out var p) && Convert(p, out mod), + ShpIdentifier i => Shp.TryGetValue(i, out var p) && Convert(p, out mod), + AtrIdentifier i => Atr.TryGetValue(i, out var p) && Convert(p, out mod), + GlobalEqpManipulation i => GlobalEqp.TryGetValue(i, out mod), + _ => false, + }; + + static bool Convert((IMod, T) pair, out IMod mod) + { + mod = pair.Item1; + return true; + } + } + + public bool RevertMod(IMetaIdentifier identifier, [NotNullWhen(true)] out IMod? mod) + => identifier switch + { + EqdpIdentifier i => Eqdp.RevertMod(i, out mod), + EqpIdentifier i => Eqp.RevertMod(i, out mod), + EstIdentifier i => Est.RevertMod(i, out mod), + GmpIdentifier i => Gmp.RevertMod(i, out mod), + ImcIdentifier i => Imc.RevertMod(i, out mod), + RspIdentifier i => Rsp.RevertMod(i, out mod), + AtchIdentifier i => Atch.RevertMod(i, out mod), + ShpIdentifier i => Shp.RevertMod(i, out mod), + AtrIdentifier i => Atr.RevertMod(i, out mod), + GlobalEqpManipulation i => GlobalEqp.RevertMod(i, out mod), + _ => (mod = null) != null, + }; + + public bool ApplyMod(IMod mod, IMetaIdentifier identifier, object entry) + => identifier switch + { + EqdpIdentifier i when entry is EqdpEntry e => Eqdp.ApplyMod(mod, i, e), + EqdpIdentifier i when entry is EqdpEntryInternal e => Eqdp.ApplyMod(mod, i, e.ToEntry(i.Slot)), + EqpIdentifier i when entry is EqpEntry e => Eqp.ApplyMod(mod, i, e), + EqpIdentifier i when entry is EqpEntryInternal e => Eqp.ApplyMod(mod, i, e.ToEntry(i.Slot)), + EstIdentifier i when entry is EstEntry e => Est.ApplyMod(mod, i, e), + GmpIdentifier i when entry is GmpEntry e => Gmp.ApplyMod(mod, i, e), + ImcIdentifier i when entry is ImcEntry e => Imc.ApplyMod(mod, i, e), + RspIdentifier i when entry is RspEntry e => Rsp.ApplyMod(mod, i, e), + AtchIdentifier i when entry is AtchEntry e => Atch.ApplyMod(mod, i, e), + ShpIdentifier i when entry is ShpEntry e => Shp.ApplyMod(mod, i, e), + AtrIdentifier i when entry is AtrEntry e => Atr.ApplyMod(mod, i, e), + GlobalEqpManipulation i => GlobalEqp.ApplyMod(mod, i), + _ => false, + }; + + ~MetaCache() + => Dispose(); + + internal EqdpEntry GetEqdpEntry(GenderRace race, bool accessory, PrimaryId primaryId) + => Eqdp.ApplyFullEntry(primaryId, race, accessory, Meta.Files.ExpandedEqdpFile.GetDefault(manager, race, accessory, primaryId)); + + internal EstEntry GetEstEntry(EstType type, GenderRace genderRace, PrimaryId primaryId) + => Est.GetEstEntry(new EstIdentifier(primaryId, type, genderRace)); +} diff --git a/Penumbra/Collections/Cache/MetaCacheBase.cs b/Penumbra/Collections/Cache/MetaCacheBase.cs new file mode 100644 index 00000000..98a87e3f --- /dev/null +++ b/Penumbra/Collections/Cache/MetaCacheBase.cs @@ -0,0 +1,47 @@ +using OtterGui.Classes; +using Penumbra.Meta; +using Penumbra.Meta.Manipulations; +using Penumbra.Mods.Editor; + +namespace Penumbra.Collections.Cache; + +public abstract class MetaCacheBase(MetaFileManager manager, ModCollection collection) + : ReadWriteDictionary + where TIdentifier : unmanaged, IMetaIdentifier + where TEntry : unmanaged +{ + protected readonly MetaFileManager Manager = manager; + protected readonly ModCollection Collection = collection; + + public bool ApplyMod(IMod source, TIdentifier identifier, TEntry entry) + { + if (TryGetValue(identifier, out var pair) && pair.Source == source && EqualityComparer.Default.Equals(pair.Entry, entry)) + return false; + + this[identifier] = (source, entry); + + ApplyModInternal(identifier, entry); + return true; + } + + public bool RevertMod(TIdentifier identifier, [NotNullWhen(true)] out IMod? mod) + { + if (!Remove(identifier, out var pair)) + { + mod = null; + return false; + } + + mod = pair.Source; + + RevertModInternal(identifier); + return true; + } + + + protected virtual void ApplyModInternal(TIdentifier identifier, TEntry entry) + { } + + protected virtual void RevertModInternal(TIdentifier identifier) + { } +} diff --git a/Penumbra/Collections/Cache/RspCache.cs b/Penumbra/Collections/Cache/RspCache.cs new file mode 100644 index 00000000..064b1f44 --- /dev/null +++ b/Penumbra/Collections/Cache/RspCache.cs @@ -0,0 +1,13 @@ +using Penumbra.Meta; +using Penumbra.Meta.Manipulations; + +namespace Penumbra.Collections.Cache; + +public sealed class RspCache(MetaFileManager manager, ModCollection collection) : MetaCacheBase(manager, collection) +{ + public void Reset() + => Clear(); + + protected override void Dispose(bool _) + => Clear(); +} diff --git a/Penumbra/Collections/Cache/ShapeAttributeHashSet.cs b/Penumbra/Collections/Cache/ShapeAttributeHashSet.cs new file mode 100644 index 00000000..4c61bdd2 --- /dev/null +++ b/Penumbra/Collections/Cache/ShapeAttributeHashSet.cs @@ -0,0 +1,181 @@ +using System.Collections.Frozen; +using OtterGui.Extensions; +using Penumbra.GameData.Enums; +using Penumbra.GameData.Structs; +using Penumbra.Meta; + +namespace Penumbra.Collections.Cache; + +public sealed class ShapeAttributeHashSet : Dictionary<(HumanSlot Slot, PrimaryId Id), ulong> +{ + public static readonly IReadOnlyList GenderRaceValues = + [ + GenderRace.Unknown, GenderRace.MidlanderMale, GenderRace.MidlanderFemale, GenderRace.HighlanderMale, GenderRace.HighlanderFemale, + GenderRace.ElezenMale, GenderRace.ElezenFemale, GenderRace.MiqoteMale, GenderRace.MiqoteFemale, GenderRace.RoegadynMale, + GenderRace.RoegadynFemale, GenderRace.LalafellMale, GenderRace.LalafellFemale, GenderRace.AuRaMale, GenderRace.AuRaFemale, + GenderRace.HrothgarMale, GenderRace.HrothgarFemale, GenderRace.VieraMale, GenderRace.VieraFemale, + ]; + + public static readonly FrozenDictionary GenderRaceIndices = + GenderRaceValues.WithIndex().ToFrozenDictionary(p => p.Value, p => p.Index); + + private readonly BitArray _allIds = new(2 * (ShapeAttributeManager.ModelSlotSize + 1) * GenderRaceValues.Count); + + public bool? this[HumanSlot slot] + => AllCheck(ToIndex(slot, 0)); + + public bool? this[GenderRace genderRace] + => ToIndex(HumanSlot.Unknown, genderRace, out var index) ? AllCheck(index) : null; + + public bool? this[HumanSlot slot, GenderRace genderRace] + => ToIndex(slot, genderRace, out var index) ? AllCheck(index) : null; + + public bool? All + => Convert(_allIds[2 * AllIndex], _allIds[2 * AllIndex + 1]); + + [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] + private bool? AllCheck(int idx) + => Convert(_allIds[idx], _allIds[idx + 1]); + + [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] + private static int ToIndex(HumanSlot slot, int genderRaceIndex) + => 2 * (slot is HumanSlot.Unknown ? genderRaceIndex + AllIndex : genderRaceIndex + (int)slot * GenderRaceValues.Count); + + [MethodImpl(MethodImplOptions.AggressiveOptimization)] + public bool? CheckEntry(HumanSlot slot, PrimaryId id, GenderRace genderRace) + { + if (!GenderRaceIndices.TryGetValue(genderRace, out var index)) + return null; + + // Check for specific ID. + if (TryGetValue((slot, id), out var flags)) + { + // Check completely specified entry. + if (Convert(flags, 2 * index) is { } specified) + return specified; + + // Check any gender / race. + if (Convert(flags, 0) is { } anyGr) + return anyGr; + } + + // Check for specified gender / race and slot, but no ID. + if (AllCheck(ToIndex(slot, index)) is { } noIdButGr) + return noIdButGr; + + // Check for specified gender / race but no slot or ID. + if (AllCheck(ToIndex(HumanSlot.Unknown, index)) is { } noSlotButGr) + return noSlotButGr; + + // Check for specified slot but no gender / race or ID. + if (AllCheck(ToIndex(slot, 0)) is { } noGrButSlot) + return noGrButSlot; + + return All; + } + + public bool TrySet(HumanSlot slot, PrimaryId? id, GenderRace genderRace, bool? value, out bool which) + { + which = false; + if (!GenderRaceIndices.TryGetValue(genderRace, out var index)) + return false; + + if (!id.HasValue) + { + var slotIndex = ToIndex(slot, index); + var ret = false; + if (value is true) + { + if (!_allIds[slotIndex]) + ret = true; + _allIds[slotIndex] = true; + _allIds[slotIndex + 1] = false; + } + else if (value is false) + { + if (!_allIds[slotIndex + 1]) + ret = true; + _allIds[slotIndex] = false; + _allIds[slotIndex + 1] = true; + } + else + { + if (_allIds[slotIndex]) + { + which = true; + ret = true; + } + else if (_allIds[slotIndex + 1]) + { + which = false; + ret = true; + } + + _allIds[slotIndex] = false; + _allIds[slotIndex + 1] = false; + } + + return ret; + } + + if (TryGetValue((slot, id.Value), out var flags)) + { + index *= 2; + var newFlags = value switch + { + true => (flags | (1ul << index)) & ~(1ul << (index + 1)), + false => (flags & ~(1ul << index)) | (1ul << (index + 1)), + _ => flags & ~(1ul << index) & ~(1ul << (index + 1)), + }; + if (newFlags == flags) + return false; + + this[(slot, id.Value)] = newFlags; + which = (flags & (1ul << index)) is not 0; + return true; + } + + if (value is null) + return false; + + this[(slot, id.Value)] = 1ul << (2 * index + (value.Value ? 0 : 1)); + return true; + } + + public new void Clear() + { + base.Clear(); + _allIds.SetAll(false); + } + + public bool IsEmpty + => !_allIds.HasAnySet() && Count is 0; + + private static readonly int AllIndex = ShapeAttributeManager.ModelSlotSize * GenderRaceValues.Count; + + [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] + private static bool ToIndex(HumanSlot slot, GenderRace genderRace, out int index) + { + if (!GenderRaceIndices.TryGetValue(genderRace, out index)) + return false; + + index = ToIndex(slot, index); + return true; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] + private static bool? Convert(bool trueValue, bool falseValue) + => trueValue ? true : falseValue ? false : null; + + [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] + private static bool? Convert(ulong mask, int idx) + { + mask >>= idx; + return (mask & 3) switch + { + 1 => true, + 2 => false, + _ => null, + }; + } +} diff --git a/Penumbra/Collections/Cache/ShpCache.cs b/Penumbra/Collections/Cache/ShpCache.cs new file mode 100644 index 00000000..d8c3a036 --- /dev/null +++ b/Penumbra/Collections/Cache/ShpCache.cs @@ -0,0 +1,106 @@ +using Penumbra.GameData.Enums; +using Penumbra.GameData.Structs; +using Penumbra.Meta; +using Penumbra.Meta.Manipulations; + +namespace Penumbra.Collections.Cache; + +public sealed class ShpCache(MetaFileManager manager, ModCollection collection) : MetaCacheBase(manager, collection) +{ + public bool ShouldBeEnabled(in ShapeAttributeString shape, HumanSlot slot, PrimaryId id, GenderRace genderRace) + => EnabledCount > 0 && _shpData.TryGetValue(shape, out var value) && value.CheckEntry(slot, id, genderRace) is true; + + public bool ShouldBeDisabled(in ShapeAttributeString shape, HumanSlot slot, PrimaryId id, GenderRace genderRace) + => DisabledCount > 0 && _shpData.TryGetValue(shape, out var value) && value.CheckEntry(slot, id, genderRace) is false; + + internal IReadOnlyDictionary State(ShapeConnectorCondition connector) + => connector switch + { + ShapeConnectorCondition.None => _shpData, + ShapeConnectorCondition.Wrists => _wristConnectors, + ShapeConnectorCondition.Waist => _waistConnectors, + ShapeConnectorCondition.Ankles => _ankleConnectors, + _ => [], + }; + + public int EnabledCount { get; private set; } + public int DisabledCount { get; private set; } + + private readonly Dictionary _shpData = []; + private readonly Dictionary _wristConnectors = []; + private readonly Dictionary _waistConnectors = []; + private readonly Dictionary _ankleConnectors = []; + + public void Reset() + { + Clear(); + _shpData.Clear(); + _wristConnectors.Clear(); + _waistConnectors.Clear(); + _ankleConnectors.Clear(); + EnabledCount = 0; + DisabledCount = 0; + } + + protected override void Dispose(bool _) + => Reset(); + + protected override void ApplyModInternal(ShpIdentifier identifier, ShpEntry entry) + { + switch (identifier.ConnectorCondition) + { + case ShapeConnectorCondition.None: Func(_shpData); break; + case ShapeConnectorCondition.Wrists: Func(_wristConnectors); break; + case ShapeConnectorCondition.Waist: Func(_waistConnectors); break; + case ShapeConnectorCondition.Ankles: Func(_ankleConnectors); break; + } + + return; + + void Func(Dictionary dict) + { + if (!dict.TryGetValue(identifier.Shape, out var value)) + { + value = []; + dict.Add(identifier.Shape, value); + } + + if (value.TrySet(identifier.Slot, identifier.Id, identifier.GenderRaceCondition, entry.Value, out _)) + { + if (entry.Value) + ++EnabledCount; + else + ++DisabledCount; + } + } + } + + protected override void RevertModInternal(ShpIdentifier identifier) + { + switch (identifier.ConnectorCondition) + { + case ShapeConnectorCondition.None: Func(_shpData); break; + case ShapeConnectorCondition.Wrists: Func(_wristConnectors); break; + case ShapeConnectorCondition.Waist: Func(_waistConnectors); break; + case ShapeConnectorCondition.Ankles: Func(_ankleConnectors); break; + } + + return; + + void Func(Dictionary dict) + { + if (!dict.TryGetValue(identifier.Shape, out var value)) + return; + + if (value.TrySet(identifier.Slot, identifier.Id, identifier.GenderRaceCondition, null, out var which)) + { + if (which) + --EnabledCount; + else + --DisabledCount; + if (value.IsEmpty) + dict.Remove(identifier.Shape); + } + } + } +} diff --git a/Penumbra/Collections/CollectionAutoSelector.cs b/Penumbra/Collections/CollectionAutoSelector.cs new file mode 100644 index 00000000..f6e6bf72 --- /dev/null +++ b/Penumbra/Collections/CollectionAutoSelector.cs @@ -0,0 +1,82 @@ +using Dalamud.Plugin.Services; +using OtterGui.Services; +using Penumbra.Collections.Manager; +using Penumbra.GameData.Interop; +using Penumbra.Interop.PathResolving; + +namespace Penumbra.Collections; + +public sealed class CollectionAutoSelector : IService, IDisposable +{ + private readonly Configuration _config; + private readonly ActiveCollections _collections; + private readonly IClientState _clientState; + private readonly CollectionResolver _resolver; + private readonly ObjectManager _objects; + + public CollectionAutoSelector(Configuration config, ActiveCollections collections, IClientState clientState, CollectionResolver resolver, + ObjectManager objects) + { + _config = config; + _collections = collections; + _clientState = clientState; + _resolver = resolver; + _objects = objects; + + if (_config.AutoSelectCollection) + Attach(); + } + + public bool Disposed { get; private set; } + + public void SetAutomaticSelection(bool value) + { + _config.AutoSelectCollection = value; + if (value) + Attach(); + else + Detach(); + } + + private void Attach() + { + if (Disposed) + return; + + _clientState.Login += OnLogin; + Select(); + } + + private void OnLogin() + => Select(); + + private void Detach() + => _clientState.Login -= OnLogin; + + private void Select() + { + if (!_objects[0].IsCharacter) + return; + + var collection = _resolver.PlayerCollection(); + if (collection.Identity.Id == Guid.Empty) + { + Penumbra.Log.Debug($"Not setting current collection because character has no mods assigned."); + } + else + { + Penumbra.Log.Debug($"Setting current collection to {collection.Identity.Identifier} through automatic collection selection."); + _collections.SetCollection(collection, CollectionType.Current); + } + } + + + public void Dispose() + { + if (Disposed) + return; + + Disposed = true; + Detach(); + } +} diff --git a/Penumbra/Collections/CollectionCounters.cs b/Penumbra/Collections/CollectionCounters.cs new file mode 100644 index 00000000..6ca0d0a0 --- /dev/null +++ b/Penumbra/Collections/CollectionCounters.cs @@ -0,0 +1,28 @@ +namespace Penumbra.Collections; + +public struct CollectionCounters(int changeCounter) +{ + /// Count the number of changes of the effective file list. + public int Change { get; private set; } = changeCounter; + + /// Count the number of IMC-relevant changes of the effective file list. + public int Imc { get; private set; } + + /// Count the number of ATCH-relevant changes of the effective file list. + public int Atch { get; private set; } + + /// Increment the number of changes in the effective file list. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public int IncrementChange() + => ++Change; + + /// Increment the number of IMC-relevant changes in the effective file list. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public int IncrementImc() + => ++Imc; + + /// Increment the number of ATCH-relevant changes in the effective file list. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public int IncrementAtch() + => ++Atch; +} diff --git a/Penumbra/Collections/Manager/ActiveCollectionMigration.cs b/Penumbra/Collections/Manager/ActiveCollectionMigration.cs new file mode 100644 index 00000000..b4af0998 --- /dev/null +++ b/Penumbra/Collections/Manager/ActiveCollectionMigration.cs @@ -0,0 +1,63 @@ +using Dalamud.Interface.ImGuiNotification; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using OtterGui.Classes; +using Penumbra.Services; + +namespace Penumbra.Collections.Manager; + +public static class ActiveCollectionMigration +{ + /// Migrate ungendered collections to Male and Female for 0.5.9.0. + public static void MigrateUngenderedCollections(FilenameService fileNames) + { + if (!ActiveCollections.Load(fileNames, out var jObject)) + return; + + foreach (var (type, _, _) in CollectionTypeExtensions.Special.Where(t => t.Item2.StartsWith("Male "))) + { + var oldName = type.ToString()[4..]; + var value = jObject[oldName]; + if (value == null) + continue; + + jObject.Remove(oldName); + jObject.Add("Male" + oldName, value); + jObject.Add("Female" + oldName, value); + } + + using var stream = File.Open(fileNames.ActiveCollectionsFile, FileMode.Truncate); + using var writer = new StreamWriter(stream); + using var j = new JsonTextWriter(writer); + j.Formatting = Formatting.Indented; + jObject.WriteTo(j); + } + + /// Migrate individual collections to Identifiers for 0.6.0. + public static bool MigrateIndividualCollections(CollectionStorage storage, IndividualCollections individuals, JObject jObject) + { + var version = jObject[nameof(Version)]?.Value() ?? 0; + if (version > 0) + return false; + + // Load character collections. If a player name comes up multiple times, the last one is applied. + var characters = jObject["Characters"]?.ToObject>() ?? new Dictionary(); + var dict = new Dictionary(characters.Count); + foreach (var (player, collectionName) in characters) + { + if (!storage.ByName(collectionName, out var collection)) + { + Penumbra.Messager.NotificationMessage( + $"Last choice of <{player}>'s Collection {collectionName} is not available, reset to {ModCollection.Empty.Identity.Name}.", NotificationType.Warning); + dict.Add(player, ModCollection.Empty); + } + else + { + dict.Add(player, collection); + } + } + + individuals.Migrate0To1(dict); + return true; + } +} diff --git a/Penumbra/Collections/Manager/ActiveCollections.cs b/Penumbra/Collections/Manager/ActiveCollections.cs new file mode 100644 index 00000000..ffec7fd2 --- /dev/null +++ b/Penumbra/Collections/Manager/ActiveCollections.cs @@ -0,0 +1,659 @@ +using Dalamud.Interface.ImGuiNotification; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using OtterGui.Classes; +using OtterGui.Extensions; +using OtterGui.Services; +using Penumbra.Communication; +using Penumbra.GameData.Actors; +using Penumbra.GameData.Enums; +using Penumbra.Services; +using Penumbra.UI; + +namespace Penumbra.Collections.Manager; + +public class ActiveCollectionData : IService +{ + public ModCollection Current { get; internal set; } = ModCollection.Empty; + public ModCollection Default { get; internal set; } = ModCollection.Empty; + public ModCollection Interface { get; internal set; } = ModCollection.Empty; + + public readonly ModCollection?[] SpecialCollections = new ModCollection?[Enum.GetValues().Length - 3]; +} + +public class ActiveCollections : ISavable, IDisposable, IService +{ + public const int Version = 2; + + private readonly CollectionStorage _storage; + private readonly CommunicatorService _communicator; + private readonly SaveService _saveService; + private readonly ActiveCollectionData _data; + private readonly ActorManager _actors; + + public ActiveCollections(Configuration config, CollectionStorage storage, ActorManager actors, CommunicatorService communicator, + SaveService saveService, ActiveCollectionData data) + { + _storage = storage; + _actors = actors; + _communicator = communicator; + _saveService = saveService; + _data = data; + Current = storage.DefaultNamed; + Default = storage.DefaultNamed; + Interface = storage.DefaultNamed; + Individuals = new IndividualCollections(actors, config, false); + _communicator.CollectionChange.Subscribe(OnCollectionChange, CollectionChange.Priority.ActiveCollections); + LoadCollections(); + UpdateCurrentCollectionInUse(); + Individuals.Loaded += UpdateCurrentCollectionInUse; + } + + public void Dispose() + => _communicator.CollectionChange.Unsubscribe(OnCollectionChange); + + /// The collection currently selected for changing settings. + public ModCollection Current + { + get => _data.Current; + private set => _data.Current = value; + } + + /// Whether the currently selected collection is used either directly via assignment or via inheritance. + public bool CurrentCollectionInUse { get; private set; } + + /// The collection used for general file redirections and all characters not specifically named. + public ModCollection Default + { + get => _data.Default; + private set => _data.Default = value; + } + + /// The collection used for all files categorized as UI files. + public ModCollection Interface + { + get => _data.Interface; + private set => _data.Interface = value; + } + + /// The list of individual assignments. + public readonly IndividualCollections Individuals; + + /// Get the collection assigned to an individual or Default if unassigned. + public ModCollection Individual(ActorIdentifier identifier) + => Individuals.TryGetCollection(identifier, out var c) ? c : Default; + + /// The list of group assignments. + private ModCollection?[] SpecialCollections + => _data.SpecialCollections; + + /// Return all actually assigned group assignments. + public IEnumerable> SpecialAssignments + { + get + { + for (var i = 0; i < SpecialCollections.Length; ++i) + { + var collection = SpecialCollections[i]; + if (collection != null) + yield return new KeyValuePair((CollectionType)i, collection); + } + } + } + + /// + public ModCollection? ByType(CollectionType type) + => ByType(type, ActorIdentifier.Invalid); + + /// Return the configured collection for the given type or null. + public ModCollection? ByType(CollectionType type, ActorIdentifier identifier) + { + if (type.IsSpecial()) + return SpecialCollections[(int)type]; + + return type switch + { + CollectionType.Default => Default, + CollectionType.Interface => Interface, + CollectionType.Current => Current, + CollectionType.Individual => identifier.IsValid && Individuals.TryGetValue(identifier, out var c) ? c : null, + _ => null, + }; + } + + /// Create a special collection if it does not exist and set it to the current default. + public bool CreateSpecialCollection(CollectionType collectionType) + { + if (!collectionType.IsSpecial() || SpecialCollections[(int)collectionType] != null) + return false; + + SpecialCollections[(int)collectionType] = Default; + _communicator.CollectionChange.Invoke(collectionType, null, Default, string.Empty); + return true; + } + + /// Remove a special collection if it exists + public void RemoveSpecialCollection(CollectionType collectionType) + { + if (!collectionType.IsSpecial()) + return; + + var old = SpecialCollections[(int)collectionType]; + if (old == null) + return; + + SpecialCollections[(int)collectionType] = null; + _communicator.CollectionChange.Invoke(collectionType, old, null, string.Empty); + } + + /// Create an individual collection if possible. + public void CreateIndividualCollection(params ActorIdentifier[] identifiers) + { + if (Individuals.Add(identifiers, Default)) + _communicator.CollectionChange.Invoke(CollectionType.Individual, null, Default, Individuals.Last().DisplayName); + } + + /// Remove an individual collection if it exists. + public void RemoveIndividualCollection(int individualIndex) + { + if (individualIndex < 0 || individualIndex >= Individuals.Count) + return; + + var (name, old) = Individuals[individualIndex]; + if (Individuals.Delete(individualIndex)) + _communicator.CollectionChange.Invoke(CollectionType.Individual, old, null, name); + } + + /// Move an individual collection from one index to another. + public void MoveIndividualCollection(int from, int to) + { + if (Individuals.Move(from, to)) + _saveService.DelaySave(this); + } + + /// Set and create an active collection, can be used to set Default, Current, Interface, Special, or Individual collections. + public void SetCollection(ModCollection? collection, CollectionType collectionType, ActorIdentifier[] identifiers) + { + if (collectionType is CollectionType.Individual && identifiers.Length > 0 && identifiers[0].IsValid) + { + var idx = Individuals.Index(identifiers[0]); + if (idx >= 0) + { + if (collection == null) + RemoveIndividualCollection(idx); + else + SetCollection(collection, collectionType, idx); + } + else if (collection != null) + { + CreateIndividualCollection(identifiers); + SetCollection(collection, CollectionType.Individual, Individuals.Count - 1); + } + } + else + { + if (collection == null) + { + RemoveSpecialCollection(collectionType); + } + else + { + CreateSpecialCollection(collectionType); + SetCollection(collection, collectionType); + } + } + } + + /// Set an active collection, can be used to set Default, Current, Interface, Special, or Individual collections. + public void SetCollection(ModCollection collection, CollectionType collectionType, int individualIndex = -1) + { + var oldCollection = collectionType switch + { + CollectionType.Default => Default, + CollectionType.Interface => Interface, + CollectionType.Current => Current, + CollectionType.Individual when individualIndex >= 0 && individualIndex < Individuals.Count => Individuals[individualIndex] + .Collection, + CollectionType.Individual => null, + _ when collectionType.IsSpecial() => SpecialCollections[(int)collectionType] ?? Default, + _ => null, + }; + + if (oldCollection == null || collection == oldCollection || collection.Identity.Index >= _storage.Count) + return; + + switch (collectionType) + { + case CollectionType.Default: + Default = collection; + break; + case CollectionType.Interface: + Interface = collection; + break; + case CollectionType.Current: + Current = collection; + break; + case CollectionType.Individual: + if (!Individuals.ChangeCollection(individualIndex, collection)) + return; + + break; + default: + SpecialCollections[(int)collectionType] = collection; + break; + } + + UpdateCurrentCollectionInUse(); + _communicator.CollectionChange.Invoke(collectionType, oldCollection, collection, + collectionType == CollectionType.Individual ? Individuals[individualIndex].DisplayName : string.Empty); + } + + public string ToFilename(FilenameService fileNames) + => fileNames.ActiveCollectionsFile; + + public string TypeName + => "Active Collections"; + + public string LogName(string _) + => "to file"; + + public void Save(StreamWriter writer) + { + var jObj = new JObject + { + { nameof(Version), Version }, + { nameof(Default), Default.Identity.Id }, + { nameof(Interface), Interface.Identity.Id }, + { nameof(Current), Current.Identity.Id }, + }; + foreach (var (type, collection) in SpecialCollections.WithIndex().Where(p => p.Value != null) + .Select(p => ((CollectionType)p.Index, p.Value!))) + jObj.Add(type.ToString(), collection.Identity.Id); + + jObj.Add(nameof(Individuals), Individuals.ToJObject()); + using var j = new JsonTextWriter(writer); + j.Formatting = Formatting.Indented; + jObj.WriteTo(j); + } + + private void UpdateCurrentCollectionInUse() + => CurrentCollectionInUse = SpecialCollections + .OfType() + .Prepend(Interface) + .Prepend(Default) + .Concat(Individuals.Assignments.Select(kvp => kvp.Collection)) + .SelectMany(c => c.Inheritance.FlatHierarchy).Contains(Current); + + /// Save if any of the active collections is changed and set new collections to Current. + private void OnCollectionChange(CollectionType collectionType, ModCollection? oldCollection, ModCollection? newCollection, string _3) + { + if (collectionType is CollectionType.Inactive) + { + if (newCollection != null) + { + SetCollection(newCollection, CollectionType.Current); + } + else if (oldCollection != null) + { + if (oldCollection == Default) + SetCollection(ModCollection.Empty, CollectionType.Default); + if (oldCollection == Interface) + SetCollection(ModCollection.Empty, CollectionType.Interface); + if (oldCollection == Current) + SetCollection(Default.Identity.Index > ModCollection.Empty.Identity.Index ? Default : _storage.DefaultNamed, CollectionType.Current); + + for (var i = 0; i < SpecialCollections.Length; ++i) + { + if (oldCollection == SpecialCollections[i]) + SetCollection(ModCollection.Empty, (CollectionType)i); + } + + for (var i = 0; i < Individuals.Count; ++i) + { + if (oldCollection == Individuals[i].Collection) + SetCollection(ModCollection.Empty, CollectionType.Individual, i); + } + } + } + else if (collectionType is not CollectionType.Temporary) + { + _saveService.DelaySave(this); + } + } + + private bool LoadCollectionsV1(JObject jObject) + { + var configChanged = false; + // Load the default collection. If the name does not exist take the empty collection. + var defaultName = jObject[nameof(Default)]?.ToObject() ?? ModCollection.Empty.Identity.Name; + if (!_storage.ByName(defaultName, out var defaultCollection)) + { + Penumbra.Messager.NotificationMessage( + $"Last choice of {TutorialService.DefaultCollection} {defaultName} is not available, reset to {ModCollection.Empty.Identity.Name}.", + NotificationType.Warning); + Default = ModCollection.Empty; + configChanged = true; + } + else + { + Default = defaultCollection; + } + + // Load the interface collection. If no string is set, use the name of whatever was set as Default. + var interfaceName = jObject[nameof(Interface)]?.ToObject() ?? Default.Identity.Name; + if (!_storage.ByName(interfaceName, out var interfaceCollection)) + { + Penumbra.Messager.NotificationMessage( + $"Last choice of {TutorialService.InterfaceCollection} {interfaceName} is not available, reset to {ModCollection.Empty.Identity.Name}.", + NotificationType.Warning); + Interface = ModCollection.Empty; + configChanged = true; + } + else + { + Interface = interfaceCollection; + } + + // Load the current collection. + var currentName = jObject[nameof(Current)]?.ToObject() ?? Default.Identity.Name; + if (!_storage.ByName(currentName, out var currentCollection)) + { + Penumbra.Messager.NotificationMessage( + $"Last choice of {TutorialService.SelectedCollection} {currentName} is not available, reset to {ModCollectionIdentity.DefaultCollectionName}.", + NotificationType.Warning); + Current = _storage.DefaultNamed; + configChanged = true; + } + else + { + Current = currentCollection; + } + + // Load special collections. + foreach (var (type, name, _) in CollectionTypeExtensions.Special) + { + var typeName = jObject[type.ToString()]?.ToObject(); + if (typeName != null) + { + if (!_storage.ByName(typeName, out var typeCollection)) + { + Penumbra.Messager.NotificationMessage($"Last choice of {name} Collection {typeName} is not available, removed.", + NotificationType.Warning); + configChanged = true; + } + else + { + SpecialCollections[(int)type] = typeCollection; + } + } + } + + Penumbra.Log.Debug("[Collections] Loaded non-individual collection assignments."); + + configChanged |= ActiveCollectionMigration.MigrateIndividualCollections(_storage, Individuals, jObject); + configChanged |= Individuals.ReadJObject(_saveService, this, jObject[nameof(Individuals)] as JArray, _storage, 1); + + return configChanged; + } + + private bool LoadCollectionsV2(JObject jObject) + { + var configChanged = false; + // Load the default collection. If the guid does not exist take the empty collection. + var defaultId = jObject[nameof(Default)]?.ToObject() ?? Guid.Empty; + if (!_storage.ById(defaultId, out var defaultCollection)) + { + Penumbra.Messager.NotificationMessage( + $"Last choice of {TutorialService.DefaultCollection} {defaultId} is not available, reset to {ModCollection.Empty.Identity.Name}.", + NotificationType.Warning); + Default = ModCollection.Empty; + configChanged = true; + } + else + { + Default = defaultCollection; + } + + // Load the interface collection. If no string is set, use the name of whatever was set as Default. + var interfaceId = jObject[nameof(Interface)]?.ToObject() ?? Default.Identity.Id; + if (!_storage.ById(interfaceId, out var interfaceCollection)) + { + Penumbra.Messager.NotificationMessage( + $"Last choice of {TutorialService.InterfaceCollection} {interfaceId} is not available, reset to {ModCollection.Empty.Identity.Name}.", + NotificationType.Warning); + Interface = ModCollection.Empty; + configChanged = true; + } + else + { + Interface = interfaceCollection; + } + + // Load the current collection. + var currentId = jObject[nameof(Current)]?.ToObject() ?? _storage.DefaultNamed.Identity.Id; + if (!_storage.ById(currentId, out var currentCollection)) + { + Penumbra.Messager.NotificationMessage( + $"Last choice of {TutorialService.SelectedCollection} {currentId} is not available, reset to {ModCollectionIdentity.DefaultCollectionName}.", + NotificationType.Warning); + Current = _storage.DefaultNamed; + configChanged = true; + } + else + { + Current = currentCollection; + } + + // Load special collections. + foreach (var (type, name, _) in CollectionTypeExtensions.Special) + { + var typeId = jObject[type.ToString()]?.ToObject(); + if (typeId == null) + continue; + + if (!_storage.ById(typeId.Value, out var typeCollection)) + { + Penumbra.Messager.NotificationMessage($"Last choice of {name} Collection {typeId.Value} is not available, removed.", + NotificationType.Warning); + configChanged = true; + } + else + { + SpecialCollections[(int)type] = typeCollection; + } + } + + Penumbra.Log.Debug("[Collections] Loaded non-individual collection assignments."); + + configChanged |= ActiveCollectionMigration.MigrateIndividualCollections(_storage, Individuals, jObject); + configChanged |= Individuals.ReadJObject(_saveService, this, jObject[nameof(Individuals)] as JArray, _storage, 2); + + return configChanged; + } + + private bool LoadCollectionsNew() + { + Current = _storage.DefaultNamed; + Default = _storage.DefaultNamed; + Interface = _storage.DefaultNamed; + return true; + } + + /// + /// Load default, current, special, and character collections from config. + /// If a collection does not exist anymore, reset it to an appropriate default. + /// + private void LoadCollections() + { + Penumbra.Log.Debug("[Collections] Reading collection assignments..."); + var configChanged = !Load(_saveService.FileNames, out var jObject); + var version = jObject["Version"]?.ToObject() ?? 0; + var changed = false; + switch (version) + { + case 1: + changed = LoadCollectionsV1(jObject); + break; + case 2: + changed = LoadCollectionsV2(jObject); + break; + case 0 when configChanged: + changed = LoadCollectionsNew(); + break; + case 0: + Penumbra.Messager.NotificationMessage("Active Collections File has unknown version and will be reset.", + NotificationType.Warning); + changed = LoadCollectionsNew(); + break; + } + + if (changed) + _saveService.ImmediateSaveSync(this); + } + + /// + /// Read the active collection file into a jObject. + /// Returns true if this is successful, false if the file does not exist or it is unsuccessful. + /// + public static bool Load(FilenameService fileNames, out JObject ret) + { + var file = fileNames.ActiveCollectionsFile; + var jObj = BackupService.GetJObjectForFile(fileNames, file); + if (jObj == null) + { + ret = []; + return false; + } + + ret = jObj; + return true; + } + + public string RedundancyCheck(CollectionType type, ActorIdentifier id) + { + var checkAssignment = ByType(type, id); + if (checkAssignment == null) + return string.Empty; + + switch (type) + { + case CollectionType.Yourself: + var yourself = ByType(CollectionType.Yourself); + if (yourself == null) + return string.Empty; + + var racial = false; + foreach (var race in Enum.GetValues().Skip(1)) + { + var m = ByType(CollectionTypeExtensions.FromParts(race, Gender.Male, false)); + if (m != null && m != yourself) + return string.Empty; + + var f = ByType(CollectionTypeExtensions.FromParts(race, Gender.Female, false)); + if (f != null && f != yourself) + return string.Empty; + + racial |= m != null || f != null; + } + + var racialString = racial ? " and Racial Assignments" : string.Empty; + var @base = ByType(CollectionType.Default); + var male = ByType(CollectionType.MalePlayerCharacter); + var female = ByType(CollectionType.FemalePlayerCharacter); + if (male == yourself && female == yourself) + return + $"Assignment is redundant due to overwriting Male Players and Female Players{racialString} with an identical collection.\nYou can remove it."; + + if (male == null) + { + if (female == null && @base == yourself) + return + $"Assignment is redundant due to overwriting Base{racialString} with an identical collection.\nYou can remove it."; + if (female == yourself && @base == yourself) + return + $"Assignment is redundant due to overwriting Base and Female Players{racialString} with an identical collection.\nYou can remove it."; + } + else if (male == yourself && female == null && @base == yourself) + { + return + $"Assignment is redundant due to overwriting Base and Male Players{racialString} with an identical collection.\nYou can remove it."; + } + + break; + // Check individual assignments. We can only be sure of redundancy for world-overlap or ownership overlap. + case CollectionType.Individual: + switch (id.Type) + { + case IdentifierType.Player when id.HomeWorld != ushort.MaxValue: + { + var global = ByType(CollectionType.Individual, _actors.CreatePlayer(id.PlayerName, ushort.MaxValue)); + return (global != null ? global.Identity.Index : null) == checkAssignment.Identity.Index + ? "Assignment is redundant due to an identical Any-World assignment existing.\nYou can remove it." + : string.Empty; + } + case IdentifierType.Owned: + if (id.HomeWorld != ushort.MaxValue) + { + var global = ByType(CollectionType.Individual, + _actors.CreateOwned(id.PlayerName, ushort.MaxValue, id.Kind, id.DataId)); + if ((global != null ? global.Identity.Index : null) == checkAssignment.Identity.Index) + return "Assignment is redundant due to an identical Any-World assignment existing.\nYou can remove it."; + } + + var unowned = ByType(CollectionType.Individual, _actors.CreateNpc(id.Kind, id.DataId)); + return (unowned != null ? unowned.Identity.Index : null) == checkAssignment.Identity.Index + ? "Assignment is redundant due to an identical unowned NPC assignment existing.\nYou can remove it." + : string.Empty; + } + + break; + // Children and Elderly are redundant if they are identical to both Male NPCs and Female NPCs, or if they are unassigned to Default. + case CollectionType.NonPlayerChild: + case CollectionType.NonPlayerElderly: + var maleNpc = ByType(CollectionType.MaleNonPlayerCharacter); + var femaleNpc = ByType(CollectionType.FemaleNonPlayerCharacter); + var collection1 = CollectionType.MaleNonPlayerCharacter; + var collection2 = CollectionType.FemaleNonPlayerCharacter; + if (maleNpc == null) + { + maleNpc = Default; + if (maleNpc.Identity.Index != checkAssignment.Identity.Index) + return string.Empty; + + collection1 = CollectionType.Default; + } + + if (femaleNpc == null) + { + femaleNpc = Default; + if (femaleNpc.Identity.Index != checkAssignment.Identity.Index) + return string.Empty; + + collection2 = CollectionType.Default; + } + + return collection1 == collection2 + ? $"Assignment is currently redundant due to overwriting {collection1.ToName()} with an identical collection.\nYou can remove them." + : $"Assignment is currently redundant due to overwriting {collection1.ToName()} and {collection2.ToName()} with an identical collection.\nYou can remove them."; + + // For other assignments, check the inheritance order, unassigned means fall-through, + // assigned needs identical assignments to be redundant. + default: + var group = type.InheritanceOrder(); + foreach (var parentType in group) + { + var assignment = ByType(parentType); + if (assignment == null) + continue; + + if (assignment.Identity.Index == checkAssignment.Identity.Index) + return + $"Assignment is currently redundant due to overwriting {parentType.ToName()} with an identical collection.\nYou can remove it."; + } + + break; + } + + return string.Empty; + } +} diff --git a/Penumbra/Collections/Manager/CollectionEditor.cs b/Penumbra/Collections/Manager/CollectionEditor.cs new file mode 100644 index 00000000..f62eea3f --- /dev/null +++ b/Penumbra/Collections/Manager/CollectionEditor.cs @@ -0,0 +1,239 @@ +using OtterGui.Extensions; +using OtterGui.Services; +using Penumbra.Api.Enums; +using Penumbra.Mods; +using Penumbra.Mods.Manager; +using Penumbra.Mods.Settings; +using Penumbra.Services; + +namespace Penumbra.Collections.Manager; + +public class CollectionEditor(SaveService saveService, CommunicatorService communicator, ModStorage modStorage) : IService +{ + /// Enable or disable the mod inheritance of mod idx. + public bool SetModInheritance(ModCollection collection, Mod mod, bool inherit) + { + if (!FixInheritance(collection, mod, inherit)) + return false; + + InvokeChange(collection, ModSettingChange.Inheritance, mod, inherit ? Setting.False : Setting.True, 0); + return true; + } + + /// + /// Set the enabled state mod idx to newValue if it differs from the current enabled state. + /// If the mod is currently inherited, stop the inheritance. + /// + public bool SetModState(ModCollection collection, Mod mod, bool newValue) + { + var oldValue = collection.GetInheritedSettings(mod.Index).Settings?.Enabled ?? false; + if (newValue == oldValue) + return false; + + var inheritance = FixInheritance(collection, mod, false); + collection.GetOwnSettings(mod.Index)!.Enabled = newValue; + InvokeChange(collection, ModSettingChange.EnableState, mod, inheritance ? Setting.Indefinite : newValue ? Setting.False : Setting.True, + 0); + return true; + } + + /// Enable or disable the mod inheritance of every mod in mods. + public void SetMultipleModInheritances(ModCollection collection, IEnumerable mods, bool inherit) + { + if (!mods.Aggregate(false, (current, mod) => current | FixInheritance(collection, mod, inherit))) + return; + + InvokeChange(collection, ModSettingChange.MultiInheritance, null, Setting.Indefinite, 0); + } + + /// + /// Set the enabled state of every mod in mods to the new value. + /// If the mod is currently inherited, stop the inheritance. + /// + public void SetMultipleModStates(ModCollection collection, IEnumerable mods, bool newValue) + { + var changes = false; + foreach (var mod in mods) + { + var oldValue = collection.GetOwnSettings(mod.Index)?.Enabled; + if (newValue == oldValue) + continue; + + FixInheritance(collection, mod, false); + collection.GetOwnSettings(mod.Index)!.Enabled = newValue; + changes = true; + } + + if (!changes) + return; + + InvokeChange(collection, ModSettingChange.MultiEnableState, null, Setting.Indefinite, 0); + } + + /// + /// Set the priority of mod idx to newValue if it differs from the current priority. + /// If the mod is currently inherited, stop the inheritance. + /// + public bool SetModPriority(ModCollection collection, Mod mod, ModPriority newValue) + { + var oldValue = collection.GetInheritedSettings(mod.Index).Settings?.Priority ?? ModPriority.Default; + if (newValue == oldValue) + return false; + + var inheritance = FixInheritance(collection, mod, false); + collection.GetOwnSettings(mod.Index)!.Priority = newValue; + InvokeChange(collection, ModSettingChange.Priority, mod, inheritance ? Setting.Indefinite : oldValue.AsSetting, 0); + return true; + } + + /// + /// Set a given setting group settingName of mod idx to newValue if it differs from the current value and fix it if necessary. + /// If the mod is currently inherited, stop the inheritance. + /// + public bool SetModSetting(ModCollection collection, Mod mod, int groupIdx, Setting newValue) + { + var settings = collection.GetInheritedSettings(mod.Index).Settings?.Settings; + var oldValue = settings?[groupIdx] ?? mod.Groups[groupIdx].DefaultSettings; + if (oldValue == newValue) + return false; + + var inheritance = FixInheritance(collection, mod, false); + collection.GetOwnSettings(mod.Index)!.SetValue(mod, groupIdx, newValue); + InvokeChange(collection, ModSettingChange.Setting, mod, inheritance ? Setting.Indefinite : oldValue, groupIdx); + return true; + } + + public bool SetTemporarySettings(ModCollection collection, Mod mod, TemporaryModSettings? settings, int key = 0) + { + key = settings?.Lock ?? key; + if (!CanSetTemporarySettings(collection, mod, key)) + return false; + + collection.Settings.SetTemporary(mod.Index, settings); + InvokeChange(collection, ModSettingChange.TemporarySetting, mod, Setting.Indefinite, 0); + return true; + } + + public int ClearTemporarySettings(ModCollection collection, int key = 0) + { + var numRemoved = 0; + for (var i = 0; i < collection.Settings.Count; ++i) + { + if (collection.GetTempSettings(i) is { } tempSettings + && tempSettings.Lock == key + && SetTemporarySettings(collection, modStorage[i], null, key)) + ++numRemoved; + } + + return numRemoved; + } + + public bool CanSetTemporarySettings(ModCollection collection, Mod mod, int key) + { + var old = collection.GetTempSettings(mod.Index); + return old is not { Lock: > 0 } || old.Lock == key; + } + + /// Copy the settings of an existing (sourceMod != null) or stored (sourceName) mod to another mod, if they exist. + public bool CopyModSettings(ModCollection collection, Mod? sourceMod, string sourceName, Mod? targetMod, string targetName) + { + if (targetName.Length == 0 && targetMod == null || sourceName.Length == 0) + return false; + + // If the source mod exists, convert its settings to saved settings or null if its inheriting. + // If it does not exist, check unused settings. + // If it does not exist and has no unused settings, also use null. + ModSettings.SavedSettings? savedSettings = sourceMod != null + ? collection.GetOwnSettings(sourceMod.Index) is { } ownSettings + ? new ModSettings.SavedSettings(ownSettings, sourceMod) + : null + : collection.Settings.Unused.TryGetValue(sourceName, out var s) + ? s + : null; + + if (targetMod != null) + { + if (savedSettings != null) + { + // The target mod exists and the source settings are not inheriting, convert and fix the settings and copy them. + // This triggers multiple events. + savedSettings.Value.ToSettings(targetMod, out var settings); + SetModState(collection, targetMod, settings.Enabled); + SetModPriority(collection, targetMod, settings.Priority); + foreach (var (value, index) in settings.Settings.WithIndex()) + SetModSetting(collection, targetMod, index, value); + } + else + { + // The target mod exists, but the source is inheriting, set the target to inheriting. + // This triggers events. + SetModInheritance(collection, targetMod, true); + } + } + else + { + // The target mod does not exist. + // Either copy the unused source settings directly if they are not inheriting, + // or remove any unused settings for the target if they are inheriting. + if (savedSettings != null) + { + ((Dictionary)collection.Settings.Unused)[targetName] = savedSettings.Value; + saveService.QueueSave(new ModCollectionSave(modStorage, collection)); + } + else if (((Dictionary)collection.Settings.Unused).Remove(targetName)) + { + saveService.QueueSave(new ModCollectionSave(modStorage, collection)); + } + } + + return true; + } + + /// + /// Set inheritance of a mod without saving, + /// to be used as an intermediary. + /// + private static bool FixInheritance(ModCollection collection, Mod mod, bool inherit) + { + var settings = collection.GetOwnSettings(mod.Index); + if (inherit == (settings == null)) + return false; + + var settings1 = inherit ? null : collection.GetInheritedSettings(mod.Index).Settings?.DeepCopy() ?? ModSettings.DefaultSettings(mod); + collection.Settings.Set(mod.Index, settings1); + return true; + } + + /// Queue saves and trigger changes for any non-inherited change in a collection, then trigger changes for all inheritors. + [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] + private void InvokeChange(ModCollection changedCollection, ModSettingChange type, Mod? mod, Setting oldValue, int groupIdx) + { + if (type is not ModSettingChange.TemporarySetting) + saveService.QueueSave(new ModCollectionSave(modStorage, changedCollection)); + communicator.ModSettingChanged.Invoke(changedCollection, type, mod, oldValue, groupIdx, false); + if (type is not ModSettingChange.TemporarySetting) + RecurseInheritors(changedCollection, type, mod, oldValue, groupIdx); + } + + /// Trigger changes in all inherited collections. + [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] + private void RecurseInheritors(ModCollection directParent, ModSettingChange type, Mod? mod, Setting oldValue, int groupIdx) + { + foreach (var directInheritor in directParent.Inheritance.DirectlyInheritedBy) + { + switch (type) + { + case ModSettingChange.MultiInheritance: + case ModSettingChange.MultiEnableState: + communicator.ModSettingChanged.Invoke(directInheritor, type, null, oldValue, groupIdx, true); + break; + default: + if (directInheritor.GetOwnSettings(mod!.Index) == null) + communicator.ModSettingChanged.Invoke(directInheritor, type, mod, oldValue, groupIdx, true); + break; + } + + RecurseInheritors(directInheritor, type, mod, oldValue, groupIdx); + } + } +} diff --git a/Penumbra/Collections/Manager/CollectionManager.cs b/Penumbra/Collections/Manager/CollectionManager.cs new file mode 100644 index 00000000..85f5b957 --- /dev/null +++ b/Penumbra/Collections/Manager/CollectionManager.cs @@ -0,0 +1,20 @@ +using OtterGui.Services; +using Penumbra.Collections.Cache; + +namespace Penumbra.Collections.Manager; + +public class CollectionManager( + CollectionStorage storage, + ActiveCollections active, + InheritanceManager inheritances, + CollectionCacheManager caches, + TempCollectionManager temp, + CollectionEditor editor) : IService +{ + public readonly CollectionStorage Storage = storage; + public readonly ActiveCollections Active = active; + public readonly InheritanceManager Inheritances = inheritances; + public readonly CollectionCacheManager Caches = caches; + public readonly TempCollectionManager Temp = temp; + public readonly CollectionEditor Editor = editor; +} diff --git a/Penumbra/Collections/Manager/CollectionStorage.cs b/Penumbra/Collections/Manager/CollectionStorage.cs new file mode 100644 index 00000000..531b6333 --- /dev/null +++ b/Penumbra/Collections/Manager/CollectionStorage.cs @@ -0,0 +1,384 @@ +using Dalamud.Interface.ImGuiNotification; +using OtterGui.Classes; +using OtterGui.Extensions; +using OtterGui.Services; +using Penumbra.Communication; +using Penumbra.Mods; +using Penumbra.Mods.Editor; +using Penumbra.Mods.Groups; +using Penumbra.Mods.Manager; +using Penumbra.Mods.Manager.OptionEditor; +using Penumbra.Mods.Settings; +using Penumbra.Mods.SubMods; +using Penumbra.Services; + +namespace Penumbra.Collections.Manager; + +/// A contiguously incrementing ID managed by the CollectionCreator. +public readonly record struct LocalCollectionId(int Id) : IAdditionOperators +{ + public static readonly LocalCollectionId Zero = new(0); + + public static LocalCollectionId operator +(LocalCollectionId left, int right) + => new(left.Id + right); +} + +public class CollectionStorage : IReadOnlyList, IDisposable, IService +{ + private readonly CommunicatorService _communicator; + private readonly SaveService _saveService; + private readonly ModStorage _modStorage; + + public ModCollection Create(string name, int index, ModCollection? duplicate) + { + var newCollection = duplicate?.Duplicate(name, CurrentCollectionId, index) + ?? ModCollection.CreateEmpty(name, CurrentCollectionId, index, _modStorage.Count); + _collectionsByLocal[CurrentCollectionId] = newCollection; + CurrentCollectionId += 1; + return newCollection; + } + + public ModCollection CreateFromData(Guid id, string name, int version, Dictionary allSettings, + IReadOnlyList inheritances) + { + var newCollection = ModCollection.CreateFromData(_saveService, _modStorage, + new ModCollectionIdentity(id, CurrentCollectionId, name, Count), version, allSettings, inheritances); + _collectionsByLocal[CurrentCollectionId] = newCollection; + CurrentCollectionId += 1; + return newCollection; + } + + public ModCollection CreateTemporary(string name, int index, int globalChangeCounter) + { + var newCollection = ModCollection.CreateTemporary(name, CurrentCollectionId, index, globalChangeCounter); + _collectionsByLocal[CurrentCollectionId] = newCollection; + CurrentCollectionId += 1; + return newCollection; + } + + public void Delete(ModCollection collection) + => _collectionsByLocal.Remove(collection.Identity.LocalId); + + /// The empty collection is always available at Index 0. + private readonly List _collections = + [ + ModCollection.Empty, + ]; + + /// A list of all collections ever created still existing by their local id. + private readonly Dictionary + _collectionsByLocal = new() { [LocalCollectionId.Zero] = ModCollection.Empty }; + + + public readonly ModCollection DefaultNamed; + + /// Incremented by 1 because the empty collection gets Zero. + public LocalCollectionId CurrentCollectionId { get; private set; } = LocalCollectionId.Zero + 1; + + /// Default enumeration skips the empty collection. + public IEnumerator GetEnumerator() + => _collections.Skip(1).GetEnumerator(); + + IEnumerator IEnumerable.GetEnumerator() + => GetEnumerator(); + + public int Count + => _collections.Count; + + public ModCollection this[int index] + => _collections[index]; + + /// Find a collection by its name. If the name is empty or None, the empty collection is returned. + public bool ByName(string name, [NotNullWhen(true)] out ModCollection? collection) + { + if (name.Length != 0) + return _collections.FindFirst(c => string.Equals(c.Identity.Name, name, StringComparison.OrdinalIgnoreCase), out collection); + + collection = ModCollection.Empty; + return true; + } + + /// Find a collection by its id. If the GUID is empty, the empty collection is returned. + public bool ById(Guid id, [NotNullWhen(true)] out ModCollection? collection) + { + if (id != Guid.Empty) + return _collections.FindFirst(c => c.Identity.Id == id, out collection); + + collection = ModCollection.Empty; + return true; + } + + /// Find a collection by an identifier, which is interpreted as a GUID first and if it does not correspond to one, as a name. + public bool ByIdentifier(string identifier, [NotNullWhen(true)] out ModCollection? collection) + { + if (Guid.TryParse(identifier, out var guid)) + return ById(guid, out collection); + + return ByName(identifier, out collection); + } + + /// Find a collection by its local ID if it still exists, otherwise returns the empty collection. + public ModCollection ByLocalId(LocalCollectionId localId) + => _collectionsByLocal.TryGetValue(localId, out var coll) ? coll : ModCollection.Empty; + + public CollectionStorage(CommunicatorService communicator, SaveService saveService, ModStorage modStorage) + { + _communicator = communicator; + _saveService = saveService; + _modStorage = modStorage; + _communicator.ModDiscoveryStarted.Subscribe(OnModDiscoveryStarted, ModDiscoveryStarted.Priority.CollectionStorage); + _communicator.ModDiscoveryFinished.Subscribe(OnModDiscoveryFinished, ModDiscoveryFinished.Priority.CollectionStorage); + _communicator.ModPathChanged.Subscribe(OnModPathChange, ModPathChanged.Priority.CollectionStorage); + _communicator.ModOptionChanged.Subscribe(OnModOptionChange, ModOptionChanged.Priority.CollectionStorage); + _communicator.ModFileChanged.Subscribe(OnModFileChanged, ModFileChanged.Priority.CollectionStorage); + ReadCollections(out DefaultNamed); + } + + public void Dispose() + { + _communicator.ModDiscoveryStarted.Unsubscribe(OnModDiscoveryStarted); + _communicator.ModDiscoveryFinished.Unsubscribe(OnModDiscoveryFinished); + _communicator.ModPathChanged.Unsubscribe(OnModPathChange); + _communicator.ModOptionChanged.Unsubscribe(OnModOptionChange); + _communicator.ModFileChanged.Unsubscribe(OnModFileChanged); + } + + /// + /// Add a new collection of the given name. + /// If duplicate is not-null, the new collection will be a duplicate of it. + /// If the name of the collection would result in an already existing filename, skip it. + /// Returns true if the collection was successfully created and fires a Inactive event. + /// Also sets the current collection to the new collection afterwards. + /// + public bool AddCollection(string name, ModCollection? duplicate) + { + if (name.Length == 0) + return false; + + var newCollection = Create(name, _collections.Count, duplicate); + _collections.Add(newCollection); + _saveService.ImmediateSave(new ModCollectionSave(_modStorage, newCollection)); + Penumbra.Messager.NotificationMessage($"Created new collection {newCollection.Identity.AnonymizedName}.", NotificationType.Success, false); + _communicator.CollectionChange.Invoke(CollectionType.Inactive, null, newCollection, string.Empty); + return true; + } + + /// + /// Remove the given collection if it exists and is neither the empty nor the default-named collection. + /// + public bool RemoveCollection(ModCollection collection) + { + if (collection.Identity.Index <= ModCollection.Empty.Identity.Index || collection.Identity.Index >= _collections.Count) + { + Penumbra.Messager.NotificationMessage("Can not remove the empty collection.", NotificationType.Error, false); + return false; + } + + if (collection.Identity.Index == DefaultNamed.Identity.Index) + { + Penumbra.Messager.NotificationMessage("Can not remove the default collection.", NotificationType.Error, false); + return false; + } + + Delete(collection); + _saveService.ImmediateDelete(new ModCollectionSave(_modStorage, collection)); + _collections.RemoveAt(collection.Identity.Index); + // Update indices. + for (var i = collection.Identity.Index; i < Count; ++i) + _collections[i].Identity.Index = i; + _collectionsByLocal.Remove(collection.Identity.LocalId); + + Penumbra.Messager.NotificationMessage($"Deleted collection {collection.Identity.AnonymizedName}.", NotificationType.Success, false); + _communicator.CollectionChange.Invoke(CollectionType.Inactive, collection, null, string.Empty); + return true; + } + + /// Remove all settings for not currently-installed mods from the given collection. + public int CleanUnavailableSettings(ModCollection collection) + { + var count = collection.Settings.Unused.Count; + if (count > 0) + { + ((Dictionary)collection.Settings.Unused).Clear(); + _saveService.QueueSave(new ModCollectionSave(_modStorage, collection)); + } + + return count; + } + + /// Remove a specific setting for not currently-installed mods from the given collection. + public void CleanUnavailableSetting(ModCollection collection, string? setting) + { + if (setting != null && ((Dictionary)collection.Settings.Unused).Remove(setting)) + _saveService.QueueSave(new ModCollectionSave(_modStorage, collection)); + } + + /// + /// Read all collection files in the Collection Directory. + /// Ensure that the default named collection exists, and apply inheritances afterward. + /// Duplicate collection files are not deleted, just not added here. + /// + private void ReadCollections(out ModCollection defaultNamedCollection) + { + Penumbra.Log.Debug("[Collections] Reading saved collections..."); + foreach (var file in _saveService.FileNames.CollectionFiles) + { + if (!ModCollectionSave.LoadFromFile(file, out var id, out var name, out var version, out var settings, out var inheritance)) + continue; + + if (id == Guid.Empty) + { + Penumbra.Messager.NotificationMessage("Collection without ID found.", NotificationType.Warning); + continue; + } + + if (ById(id, out _)) + { + Penumbra.Messager.NotificationMessage($"Duplicate collection found: {id} already exists. Import skipped.", + NotificationType.Warning); + continue; + } + + var collection = CreateFromData(id, name, version, settings, inheritance); + var correctName = _saveService.FileNames.CollectionFile(collection); + if (file.FullName != correctName) + try + { + if (version >= 2) + { + try + { + File.Move(file.FullName, correctName, false); + Penumbra.Messager.NotificationMessage( + $"Collection {file.Name} does not correspond to {collection.Identity.Identifier}, renamed.", + NotificationType.Warning); + } + catch (Exception ex) + { + Penumbra.Messager.NotificationMessage( + $"Collection {file.Name} does not correspond to {collection.Identity.Identifier}, rename failed:\n{ex}", + NotificationType.Warning); + } + } + else + { + _saveService.ImmediateSaveSync(new ModCollectionSave(_modStorage, collection)); + try + { + File.Move(file.FullName, file.FullName + ".bak", true); + Penumbra.Log.Information($"Migrated collection {name} to Guid {id} with backup of old file."); + } + catch (Exception ex) + { + Penumbra.Log.Information($"Migrated collection {name} to Guid {id}, rename of old file failed:\n{ex}"); + } + } + } + catch (Exception e) + { + Penumbra.Messager.NotificationMessage(e, + $"Collection {file.Name} does not correspond to {collection.Identity.Identifier}, but could not rename.", + NotificationType.Error); + } + + _collections.Add(collection); + } + + defaultNamedCollection = SetDefaultNamedCollection(); + Penumbra.Log.Debug($"[Collections] Found {Count} saved collections."); + } + + /// + /// Add the collection with the default name if it does not exist. + /// It should always be ensured that it exists, otherwise it will be created. + /// This can also not be deleted, so there are always at least the empty and a collection with default name. + /// + private ModCollection SetDefaultNamedCollection() + { + if (ByName(ModCollectionIdentity.DefaultCollectionName, out var collection)) + return collection; + + if (AddCollection(ModCollectionIdentity.DefaultCollectionName, null)) + return _collections[^1]; + + Penumbra.Messager.NotificationMessage( + $"Unknown problem creating a collection with the name {ModCollectionIdentity.DefaultCollectionName}, which is required to exist.", + NotificationType.Error); + return Count > 1 ? _collections[1] : _collections[0]; + } + + /// Move all settings in all collections to unused settings. + private void OnModDiscoveryStarted() + { + foreach (var collection in this) + collection.Settings.PrepareModDiscovery(_modStorage); + } + + /// Restore all settings in all collections to mods. + private void OnModDiscoveryFinished() + { + // Re-apply all mod settings. + foreach (var collection in this) + collection.Settings.ApplyModSettings(collection, _saveService, _modStorage); + } + + /// Add or remove a mod from all collections, or re-save all collections where the mod has settings. + private void OnModPathChange(ModPathChangeType type, Mod mod, DirectoryInfo? oldDirectory, + DirectoryInfo? newDirectory) + { + switch (type) + { + case ModPathChangeType.Added: + foreach (var collection in this) + collection.Settings.AddMod(mod); + break; + case ModPathChangeType.Deleted: + foreach (var collection in this) + collection.Settings.RemoveMod(mod); + break; + case ModPathChangeType.Moved: + foreach (var collection in this.Where(collection => collection.GetOwnSettings(mod.Index) != null)) + _saveService.QueueSave(new ModCollectionSave(_modStorage, collection)); + break; + case ModPathChangeType.Reloaded: + foreach (var collection in this) + { + if (collection.GetOwnSettings(mod.Index)?.Settings.FixAll(mod) ?? false) + _saveService.QueueSave(new ModCollectionSave(_modStorage, collection)); + collection.Settings.SetTemporary(mod.Index, null); + } + + break; + } + } + + /// Save all collections where the mod has settings and the change requires saving. + private void OnModOptionChange(ModOptionChangeType type, Mod mod, IModGroup? group, IModOption? option, IModDataContainer? container, + int movedToIdx) + { + type.HandlingInfo(out var requiresSaving, out _, out _); + if (!requiresSaving) + return; + + foreach (var collection in this) + { + if (collection.GetOwnSettings(mod.Index)?.HandleChanges(type, mod, group, option, movedToIdx) ?? false) + _saveService.QueueSave(new ModCollectionSave(_modStorage, collection)); + collection.Settings.SetTemporary(mod.Index, null); + } + } + + /// Update change counters when changing files. + private void OnModFileChanged(Mod mod, FileRegistry file) + { + if (file.CurrentUsage == 0) + return; + + foreach (var collection in this) + { + var (settings, _) = collection.GetActualSettings(mod.Index); + if (settings is { Enabled: true }) + collection.Counters.IncrementChange(); + } + } +} diff --git a/Penumbra/Collections/Manager/CollectionType.cs b/Penumbra/Collections/Manager/CollectionType.cs new file mode 100644 index 00000000..c25413b8 --- /dev/null +++ b/Penumbra/Collections/Manager/CollectionType.cs @@ -0,0 +1,442 @@ +using Penumbra.GameData.Enums; + +namespace Penumbra.Collections.Manager; + +public enum CollectionType : byte +{ + // Special Collections + Yourself = Api.Enums.ApiCollectionType.Yourself, + + MalePlayerCharacter = Api.Enums.ApiCollectionType.MalePlayerCharacter, + FemalePlayerCharacter = Api.Enums.ApiCollectionType.FemalePlayerCharacter, + MaleNonPlayerCharacter = Api.Enums.ApiCollectionType.MaleNonPlayerCharacter, + FemaleNonPlayerCharacter = Api.Enums.ApiCollectionType.FemaleNonPlayerCharacter, + NonPlayerChild = Api.Enums.ApiCollectionType.NonPlayerChild, + NonPlayerElderly = Api.Enums.ApiCollectionType.NonPlayerElderly, + + MaleMidlander = Api.Enums.ApiCollectionType.MaleMidlander, + FemaleMidlander = Api.Enums.ApiCollectionType.FemaleMidlander, + MaleHighlander = Api.Enums.ApiCollectionType.MaleHighlander, + FemaleHighlander = Api.Enums.ApiCollectionType.FemaleHighlander, + + MaleWildwood = Api.Enums.ApiCollectionType.MaleWildwood, + FemaleWildwood = Api.Enums.ApiCollectionType.FemaleWildwood, + MaleDuskwight = Api.Enums.ApiCollectionType.MaleDuskwight, + FemaleDuskwight = Api.Enums.ApiCollectionType.FemaleDuskwight, + + MalePlainsfolk = Api.Enums.ApiCollectionType.MalePlainsfolk, + FemalePlainsfolk = Api.Enums.ApiCollectionType.FemalePlainsfolk, + MaleDunesfolk = Api.Enums.ApiCollectionType.MaleDunesfolk, + FemaleDunesfolk = Api.Enums.ApiCollectionType.FemaleDunesfolk, + + MaleSeekerOfTheSun = Api.Enums.ApiCollectionType.MaleSeekerOfTheSun, + FemaleSeekerOfTheSun = Api.Enums.ApiCollectionType.FemaleSeekerOfTheSun, + MaleKeeperOfTheMoon = Api.Enums.ApiCollectionType.MaleKeeperOfTheMoon, + FemaleKeeperOfTheMoon = Api.Enums.ApiCollectionType.FemaleKeeperOfTheMoon, + + MaleSeawolf = Api.Enums.ApiCollectionType.MaleSeawolf, + FemaleSeawolf = Api.Enums.ApiCollectionType.FemaleSeawolf, + MaleHellsguard = Api.Enums.ApiCollectionType.MaleHellsguard, + FemaleHellsguard = Api.Enums.ApiCollectionType.FemaleHellsguard, + + MaleRaen = Api.Enums.ApiCollectionType.MaleRaen, + FemaleRaen = Api.Enums.ApiCollectionType.FemaleRaen, + MaleXaela = Api.Enums.ApiCollectionType.MaleXaela, + FemaleXaela = Api.Enums.ApiCollectionType.FemaleXaela, + + MaleHelion = Api.Enums.ApiCollectionType.MaleHelion, + FemaleHelion = Api.Enums.ApiCollectionType.FemaleHelion, + MaleLost = Api.Enums.ApiCollectionType.MaleLost, + FemaleLost = Api.Enums.ApiCollectionType.FemaleLost, + + MaleRava = Api.Enums.ApiCollectionType.MaleRava, + FemaleRava = Api.Enums.ApiCollectionType.FemaleRava, + MaleVeena = Api.Enums.ApiCollectionType.MaleVeena, + FemaleVeena = Api.Enums.ApiCollectionType.FemaleVeena, + + MaleMidlanderNpc = Api.Enums.ApiCollectionType.MaleMidlanderNpc, + FemaleMidlanderNpc = Api.Enums.ApiCollectionType.FemaleMidlanderNpc, + MaleHighlanderNpc = Api.Enums.ApiCollectionType.MaleHighlanderNpc, + FemaleHighlanderNpc = Api.Enums.ApiCollectionType.FemaleHighlanderNpc, + + MaleWildwoodNpc = Api.Enums.ApiCollectionType.MaleWildwoodNpc, + FemaleWildwoodNpc = Api.Enums.ApiCollectionType.FemaleWildwoodNpc, + MaleDuskwightNpc = Api.Enums.ApiCollectionType.MaleDuskwightNpc, + FemaleDuskwightNpc = Api.Enums.ApiCollectionType.FemaleDuskwightNpc, + + MalePlainsfolkNpc = Api.Enums.ApiCollectionType.MalePlainsfolkNpc, + FemalePlainsfolkNpc = Api.Enums.ApiCollectionType.FemalePlainsfolkNpc, + MaleDunesfolkNpc = Api.Enums.ApiCollectionType.MaleDunesfolkNpc, + FemaleDunesfolkNpc = Api.Enums.ApiCollectionType.FemaleDunesfolkNpc, + + MaleSeekerOfTheSunNpc = Api.Enums.ApiCollectionType.MaleSeekerOfTheSunNpc, + FemaleSeekerOfTheSunNpc = Api.Enums.ApiCollectionType.FemaleSeekerOfTheSunNpc, + MaleKeeperOfTheMoonNpc = Api.Enums.ApiCollectionType.MaleKeeperOfTheMoonNpc, + FemaleKeeperOfTheMoonNpc = Api.Enums.ApiCollectionType.FemaleKeeperOfTheMoonNpc, + + MaleSeawolfNpc = Api.Enums.ApiCollectionType.MaleSeawolfNpc, + FemaleSeawolfNpc = Api.Enums.ApiCollectionType.FemaleSeawolfNpc, + MaleHellsguardNpc = Api.Enums.ApiCollectionType.MaleHellsguardNpc, + FemaleHellsguardNpc = Api.Enums.ApiCollectionType.FemaleHellsguardNpc, + + MaleRaenNpc = Api.Enums.ApiCollectionType.MaleRaenNpc, + FemaleRaenNpc = Api.Enums.ApiCollectionType.FemaleRaenNpc, + MaleXaelaNpc = Api.Enums.ApiCollectionType.MaleXaelaNpc, + FemaleXaelaNpc = Api.Enums.ApiCollectionType.FemaleXaelaNpc, + + MaleHelionNpc = Api.Enums.ApiCollectionType.MaleHelionNpc, + FemaleHelionNpc = Api.Enums.ApiCollectionType.FemaleHelionNpc, + MaleLostNpc = Api.Enums.ApiCollectionType.MaleLostNpc, + FemaleLostNpc = Api.Enums.ApiCollectionType.FemaleLostNpc, + + MaleRavaNpc = Api.Enums.ApiCollectionType.MaleRavaNpc, + FemaleRavaNpc = Api.Enums.ApiCollectionType.FemaleRavaNpc, + MaleVeenaNpc = Api.Enums.ApiCollectionType.MaleVeenaNpc, + FemaleVeenaNpc = Api.Enums.ApiCollectionType.FemaleVeenaNpc, + + Default = Api.Enums.ApiCollectionType.Default, // The default collection was changed + Interface = Api.Enums.ApiCollectionType.Interface, // The ui collection was changed + Current = Api.Enums.ApiCollectionType.Current, // The current collection was changed + Individual, // An individual collection was changed + Inactive, // A collection was added or removed + Temporary, // A temporary collections was set or deleted via IPC +} + +public static class CollectionTypeExtensions +{ + public static bool IsSpecial(this CollectionType collectionType) + => collectionType < CollectionType.Default; + + public static bool CanBeRemoved(this CollectionType collectionType) + => collectionType.IsSpecial() || collectionType is CollectionType.Individual; + + public static readonly (CollectionType, string, string)[] Special = Enum.GetValues() + .Where(IsSpecial) + .Select(s => (s, s.ToName(), s.ToDescription())) + .ToArray(); + + public static CollectionType FromParts(Gender gender, bool npc) + { + gender = gender switch + { + Gender.MaleNpc => Gender.Male, + Gender.FemaleNpc => Gender.Female, + _ => gender, + }; + + return (gender, npc) switch + { + (Gender.Male, false) => CollectionType.MalePlayerCharacter, + (Gender.Female, false) => CollectionType.FemalePlayerCharacter, + (Gender.Male, true) => CollectionType.MaleNonPlayerCharacter, + (Gender.Female, true) => CollectionType.FemaleNonPlayerCharacter, + _ => CollectionType.Inactive, + }; + } + + // @formatter:off + private static readonly IReadOnlyList DefaultList = new[] { CollectionType.Default }; + private static readonly IReadOnlyList MalePlayerList = new[] { CollectionType.MalePlayerCharacter, CollectionType.Default }; + private static readonly IReadOnlyList FemalePlayerList = new[] { CollectionType.FemalePlayerCharacter, CollectionType.Default }; + private static readonly IReadOnlyList MaleNpcList = new[] { CollectionType.MaleNonPlayerCharacter, CollectionType.Default }; + private static readonly IReadOnlyList FemaleNpcList = new[] { CollectionType.FemaleNonPlayerCharacter, CollectionType.Default }; + // @formatter:on + + /// A list of definite redundancy possibilities. + public static IReadOnlyList InheritanceOrder(this CollectionType collectionType) + => collectionType switch + { + CollectionType.Yourself => DefaultList, + CollectionType.MalePlayerCharacter => DefaultList, + CollectionType.FemalePlayerCharacter => DefaultList, + CollectionType.MaleNonPlayerCharacter => DefaultList, + CollectionType.FemaleNonPlayerCharacter => DefaultList, + CollectionType.MaleMidlander => MalePlayerList, + CollectionType.FemaleMidlander => FemalePlayerList, + CollectionType.MaleHighlander => MalePlayerList, + CollectionType.FemaleHighlander => FemalePlayerList, + CollectionType.MaleWildwood => MalePlayerList, + CollectionType.FemaleWildwood => FemalePlayerList, + CollectionType.MaleDuskwight => MalePlayerList, + CollectionType.FemaleDuskwight => FemalePlayerList, + CollectionType.MalePlainsfolk => MalePlayerList, + CollectionType.FemalePlainsfolk => FemalePlayerList, + CollectionType.MaleDunesfolk => MalePlayerList, + CollectionType.FemaleDunesfolk => FemalePlayerList, + CollectionType.MaleSeekerOfTheSun => MalePlayerList, + CollectionType.FemaleSeekerOfTheSun => FemalePlayerList, + CollectionType.MaleKeeperOfTheMoon => MalePlayerList, + CollectionType.FemaleKeeperOfTheMoon => FemalePlayerList, + CollectionType.MaleSeawolf => MalePlayerList, + CollectionType.FemaleSeawolf => FemalePlayerList, + CollectionType.MaleHellsguard => MalePlayerList, + CollectionType.FemaleHellsguard => FemalePlayerList, + CollectionType.MaleRaen => MalePlayerList, + CollectionType.FemaleRaen => FemalePlayerList, + CollectionType.MaleXaela => MalePlayerList, + CollectionType.FemaleXaela => FemalePlayerList, + CollectionType.MaleHelion => MalePlayerList, + CollectionType.FemaleHelion => FemalePlayerList, + CollectionType.MaleLost => MalePlayerList, + CollectionType.FemaleLost => FemalePlayerList, + CollectionType.MaleRava => MalePlayerList, + CollectionType.FemaleRava => FemalePlayerList, + CollectionType.MaleVeena => MalePlayerList, + CollectionType.FemaleVeena => FemalePlayerList, + CollectionType.MaleMidlanderNpc => MaleNpcList, + CollectionType.FemaleMidlanderNpc => FemaleNpcList, + CollectionType.MaleHighlanderNpc => MaleNpcList, + CollectionType.FemaleHighlanderNpc => FemaleNpcList, + CollectionType.MaleWildwoodNpc => MaleNpcList, + CollectionType.FemaleWildwoodNpc => FemaleNpcList, + CollectionType.MaleDuskwightNpc => MaleNpcList, + CollectionType.FemaleDuskwightNpc => FemaleNpcList, + CollectionType.MalePlainsfolkNpc => MaleNpcList, + CollectionType.FemalePlainsfolkNpc => FemaleNpcList, + CollectionType.MaleDunesfolkNpc => MaleNpcList, + CollectionType.FemaleDunesfolkNpc => FemaleNpcList, + CollectionType.MaleSeekerOfTheSunNpc => MaleNpcList, + CollectionType.FemaleSeekerOfTheSunNpc => FemaleNpcList, + CollectionType.MaleKeeperOfTheMoonNpc => MaleNpcList, + CollectionType.FemaleKeeperOfTheMoonNpc => FemaleNpcList, + CollectionType.MaleSeawolfNpc => MaleNpcList, + CollectionType.FemaleSeawolfNpc => FemaleNpcList, + CollectionType.MaleHellsguardNpc => MaleNpcList, + CollectionType.FemaleHellsguardNpc => FemaleNpcList, + CollectionType.MaleRaenNpc => MaleNpcList, + CollectionType.FemaleRaenNpc => FemaleNpcList, + CollectionType.MaleXaelaNpc => MaleNpcList, + CollectionType.FemaleXaelaNpc => FemaleNpcList, + CollectionType.MaleHelionNpc => MaleNpcList, + CollectionType.FemaleHelionNpc => FemaleNpcList, + CollectionType.MaleLostNpc => MaleNpcList, + CollectionType.FemaleLostNpc => FemaleNpcList, + CollectionType.MaleRavaNpc => MaleNpcList, + CollectionType.FemaleRavaNpc => FemaleNpcList, + CollectionType.MaleVeenaNpc => MaleNpcList, + CollectionType.FemaleVeenaNpc => FemaleNpcList, + CollectionType.Individual => DefaultList, + _ => Array.Empty(), + }; + + public static CollectionType FromParts(SubRace race, Gender gender, bool npc) + { + gender = gender switch + { + Gender.MaleNpc => Gender.Male, + Gender.FemaleNpc => Gender.Female, + _ => gender, + }; + + return (race, gender, npc) switch + { + (SubRace.Midlander, Gender.Male, false) => CollectionType.MaleMidlander, + (SubRace.Highlander, Gender.Male, false) => CollectionType.MaleHighlander, + (SubRace.Wildwood, Gender.Male, false) => CollectionType.MaleWildwood, + (SubRace.Duskwight, Gender.Male, false) => CollectionType.MaleDuskwight, + (SubRace.Plainsfolk, Gender.Male, false) => CollectionType.MalePlainsfolk, + (SubRace.Dunesfolk, Gender.Male, false) => CollectionType.MaleDunesfolk, + (SubRace.SeekerOfTheSun, Gender.Male, false) => CollectionType.MaleSeekerOfTheSun, + (SubRace.KeeperOfTheMoon, Gender.Male, false) => CollectionType.MaleKeeperOfTheMoon, + (SubRace.Seawolf, Gender.Male, false) => CollectionType.MaleSeawolf, + (SubRace.Hellsguard, Gender.Male, false) => CollectionType.MaleHellsguard, + (SubRace.Raen, Gender.Male, false) => CollectionType.MaleRaen, + (SubRace.Xaela, Gender.Male, false) => CollectionType.MaleXaela, + (SubRace.Helion, Gender.Male, false) => CollectionType.MaleHelion, + (SubRace.Lost, Gender.Male, false) => CollectionType.MaleLost, + (SubRace.Rava, Gender.Male, false) => CollectionType.MaleRava, + (SubRace.Veena, Gender.Male, false) => CollectionType.MaleVeena, + + (SubRace.Midlander, Gender.Female, false) => CollectionType.FemaleMidlander, + (SubRace.Highlander, Gender.Female, false) => CollectionType.FemaleHighlander, + (SubRace.Wildwood, Gender.Female, false) => CollectionType.FemaleWildwood, + (SubRace.Duskwight, Gender.Female, false) => CollectionType.FemaleDuskwight, + (SubRace.Plainsfolk, Gender.Female, false) => CollectionType.FemalePlainsfolk, + (SubRace.Dunesfolk, Gender.Female, false) => CollectionType.FemaleDunesfolk, + (SubRace.SeekerOfTheSun, Gender.Female, false) => CollectionType.FemaleSeekerOfTheSun, + (SubRace.KeeperOfTheMoon, Gender.Female, false) => CollectionType.FemaleKeeperOfTheMoon, + (SubRace.Seawolf, Gender.Female, false) => CollectionType.FemaleSeawolf, + (SubRace.Hellsguard, Gender.Female, false) => CollectionType.FemaleHellsguard, + (SubRace.Raen, Gender.Female, false) => CollectionType.FemaleRaen, + (SubRace.Xaela, Gender.Female, false) => CollectionType.FemaleXaela, + (SubRace.Helion, Gender.Female, false) => CollectionType.FemaleHelion, + (SubRace.Lost, Gender.Female, false) => CollectionType.FemaleLost, + (SubRace.Rava, Gender.Female, false) => CollectionType.FemaleRava, + (SubRace.Veena, Gender.Female, false) => CollectionType.FemaleVeena, + + (SubRace.Midlander, Gender.Male, true) => CollectionType.MaleMidlanderNpc, + (SubRace.Highlander, Gender.Male, true) => CollectionType.MaleHighlanderNpc, + (SubRace.Wildwood, Gender.Male, true) => CollectionType.MaleWildwoodNpc, + (SubRace.Duskwight, Gender.Male, true) => CollectionType.MaleDuskwightNpc, + (SubRace.Plainsfolk, Gender.Male, true) => CollectionType.MalePlainsfolkNpc, + (SubRace.Dunesfolk, Gender.Male, true) => CollectionType.MaleDunesfolkNpc, + (SubRace.SeekerOfTheSun, Gender.Male, true) => CollectionType.MaleSeekerOfTheSunNpc, + (SubRace.KeeperOfTheMoon, Gender.Male, true) => CollectionType.MaleKeeperOfTheMoonNpc, + (SubRace.Seawolf, Gender.Male, true) => CollectionType.MaleSeawolfNpc, + (SubRace.Hellsguard, Gender.Male, true) => CollectionType.MaleHellsguardNpc, + (SubRace.Raen, Gender.Male, true) => CollectionType.MaleRaenNpc, + (SubRace.Xaela, Gender.Male, true) => CollectionType.MaleXaelaNpc, + (SubRace.Helion, Gender.Male, true) => CollectionType.MaleHelionNpc, + (SubRace.Lost, Gender.Male, true) => CollectionType.MaleLostNpc, + (SubRace.Rava, Gender.Male, true) => CollectionType.MaleRavaNpc, + (SubRace.Veena, Gender.Male, true) => CollectionType.MaleVeenaNpc, + + (SubRace.Midlander, Gender.Female, true) => CollectionType.FemaleMidlanderNpc, + (SubRace.Highlander, Gender.Female, true) => CollectionType.FemaleHighlanderNpc, + (SubRace.Wildwood, Gender.Female, true) => CollectionType.FemaleWildwoodNpc, + (SubRace.Duskwight, Gender.Female, true) => CollectionType.FemaleDuskwightNpc, + (SubRace.Plainsfolk, Gender.Female, true) => CollectionType.FemalePlainsfolkNpc, + (SubRace.Dunesfolk, Gender.Female, true) => CollectionType.FemaleDunesfolkNpc, + (SubRace.SeekerOfTheSun, Gender.Female, true) => CollectionType.FemaleSeekerOfTheSunNpc, + (SubRace.KeeperOfTheMoon, Gender.Female, true) => CollectionType.FemaleKeeperOfTheMoonNpc, + (SubRace.Seawolf, Gender.Female, true) => CollectionType.FemaleSeawolfNpc, + (SubRace.Hellsguard, Gender.Female, true) => CollectionType.FemaleHellsguardNpc, + (SubRace.Raen, Gender.Female, true) => CollectionType.FemaleRaenNpc, + (SubRace.Xaela, Gender.Female, true) => CollectionType.FemaleXaelaNpc, + (SubRace.Helion, Gender.Female, true) => CollectionType.FemaleHelionNpc, + (SubRace.Lost, Gender.Female, true) => CollectionType.FemaleLostNpc, + (SubRace.Rava, Gender.Female, true) => CollectionType.FemaleRavaNpc, + (SubRace.Veena, Gender.Female, true) => CollectionType.FemaleVeenaNpc, + _ => CollectionType.Inactive, + }; + } + + public static bool TryParse(string text, out CollectionType type) + { + if (Enum.TryParse(text, true, out type)) + return type is not CollectionType.Inactive and not CollectionType.Temporary; + + if (string.Equals(text, "character", StringComparison.OrdinalIgnoreCase)) + { + type = CollectionType.Individual; + return true; + } + + if (string.Equals(text, "base", StringComparison.OrdinalIgnoreCase)) + { + type = CollectionType.Default; + return true; + } + + if (string.Equals(text, "ui", StringComparison.OrdinalIgnoreCase)) + { + type = CollectionType.Interface; + return true; + } + + if (string.Equals(text, "selected", StringComparison.OrdinalIgnoreCase)) + { + type = CollectionType.Current; + return true; + } + + foreach (var t in Enum.GetValues()) + { + if (t is CollectionType.Inactive or CollectionType.Temporary) + continue; + + if (string.Equals(text, t.ToName(), StringComparison.OrdinalIgnoreCase)) + { + type = t; + return true; + } + } + + return false; + } + + public static string ToName(this CollectionType collectionType) + => collectionType switch + { + CollectionType.Yourself => "Your Character", + CollectionType.NonPlayerChild => "Non-Player Children", + CollectionType.NonPlayerElderly => "Non-Player Elderly", + CollectionType.MalePlayerCharacter => "Male Player Characters", + CollectionType.MaleNonPlayerCharacter => "Male Non-Player Characters", + CollectionType.MaleMidlander => $"Male {SubRace.Midlander.ToName()}", + CollectionType.MaleHighlander => $"Male {SubRace.Highlander.ToName()}", + CollectionType.MaleWildwood => $"Male {SubRace.Wildwood.ToName()}", + CollectionType.MaleDuskwight => $"Male {SubRace.Duskwight.ToName()}", + CollectionType.MalePlainsfolk => $"Male {SubRace.Plainsfolk.ToName()}", + CollectionType.MaleDunesfolk => $"Male {SubRace.Dunesfolk.ToName()}", + CollectionType.MaleSeekerOfTheSun => $"Male {SubRace.SeekerOfTheSun.ToName()}", + CollectionType.MaleKeeperOfTheMoon => $"Male {SubRace.KeeperOfTheMoon.ToName()}", + CollectionType.MaleSeawolf => $"Male {SubRace.Seawolf.ToName()}", + CollectionType.MaleHellsguard => $"Male {SubRace.Hellsguard.ToName()}", + CollectionType.MaleRaen => $"Male {SubRace.Raen.ToName()}", + CollectionType.MaleXaela => $"Male {SubRace.Xaela.ToName()}", + CollectionType.MaleHelion => $"Male {SubRace.Helion.ToName()}", + CollectionType.MaleLost => $"Male {SubRace.Lost.ToName()}", + CollectionType.MaleRava => $"Male {SubRace.Rava.ToName()}", + CollectionType.MaleVeena => $"Male {SubRace.Veena.ToName()}", + CollectionType.MaleMidlanderNpc => $"Male {SubRace.Midlander.ToName()} (NPC)", + CollectionType.MaleHighlanderNpc => $"Male {SubRace.Highlander.ToName()} (NPC)", + CollectionType.MaleWildwoodNpc => $"Male {SubRace.Wildwood.ToName()} (NPC)", + CollectionType.MaleDuskwightNpc => $"Male {SubRace.Duskwight.ToName()} (NPC)", + CollectionType.MalePlainsfolkNpc => $"Male {SubRace.Plainsfolk.ToName()} (NPC)", + CollectionType.MaleDunesfolkNpc => $"Male {SubRace.Dunesfolk.ToName()} (NPC)", + CollectionType.MaleSeekerOfTheSunNpc => $"Male {SubRace.SeekerOfTheSun.ToName()} (NPC)", + CollectionType.MaleKeeperOfTheMoonNpc => $"Male {SubRace.KeeperOfTheMoon.ToName()} (NPC)", + CollectionType.MaleSeawolfNpc => $"Male {SubRace.Seawolf.ToName()} (NPC)", + CollectionType.MaleHellsguardNpc => $"Male {SubRace.Hellsguard.ToName()} (NPC)", + CollectionType.MaleRaenNpc => $"Male {SubRace.Raen.ToName()} (NPC)", + CollectionType.MaleXaelaNpc => $"Male {SubRace.Xaela.ToName()} (NPC)", + CollectionType.MaleHelionNpc => $"Male {SubRace.Helion.ToName()} (NPC)", + CollectionType.MaleLostNpc => $"Male {SubRace.Lost.ToName()} (NPC)", + CollectionType.MaleRavaNpc => $"Male {SubRace.Rava.ToName()} (NPC)", + CollectionType.MaleVeenaNpc => $"Male {SubRace.Veena.ToName()} (NPC)", + CollectionType.FemalePlayerCharacter => "Female Player Characters", + CollectionType.FemaleNonPlayerCharacter => "Female Non-Player Characters", + CollectionType.FemaleMidlander => $"Female {SubRace.Midlander.ToName()}", + CollectionType.FemaleHighlander => $"Female {SubRace.Highlander.ToName()}", + CollectionType.FemaleWildwood => $"Female {SubRace.Wildwood.ToName()}", + CollectionType.FemaleDuskwight => $"Female {SubRace.Duskwight.ToName()}", + CollectionType.FemalePlainsfolk => $"Female {SubRace.Plainsfolk.ToName()}", + CollectionType.FemaleDunesfolk => $"Female {SubRace.Dunesfolk.ToName()}", + CollectionType.FemaleSeekerOfTheSun => $"Female {SubRace.SeekerOfTheSun.ToName()}", + CollectionType.FemaleKeeperOfTheMoon => $"Female {SubRace.KeeperOfTheMoon.ToName()}", + CollectionType.FemaleSeawolf => $"Female {SubRace.Seawolf.ToName()}", + CollectionType.FemaleHellsguard => $"Female {SubRace.Hellsguard.ToName()}", + CollectionType.FemaleRaen => $"Female {SubRace.Raen.ToName()}", + CollectionType.FemaleXaela => $"Female {SubRace.Xaela.ToName()}", + CollectionType.FemaleHelion => $"Female {SubRace.Helion.ToName()}", + CollectionType.FemaleLost => $"Female {SubRace.Lost.ToName()}", + CollectionType.FemaleRava => $"Female {SubRace.Rava.ToName()}", + CollectionType.FemaleVeena => $"Female {SubRace.Veena.ToName()}", + CollectionType.FemaleMidlanderNpc => $"Female {SubRace.Midlander.ToName()} (NPC)", + CollectionType.FemaleHighlanderNpc => $"Female {SubRace.Highlander.ToName()} (NPC)", + CollectionType.FemaleWildwoodNpc => $"Female {SubRace.Wildwood.ToName()} (NPC)", + CollectionType.FemaleDuskwightNpc => $"Female {SubRace.Duskwight.ToName()} (NPC)", + CollectionType.FemalePlainsfolkNpc => $"Female {SubRace.Plainsfolk.ToName()} (NPC)", + CollectionType.FemaleDunesfolkNpc => $"Female {SubRace.Dunesfolk.ToName()} (NPC)", + CollectionType.FemaleSeekerOfTheSunNpc => $"Female {SubRace.SeekerOfTheSun.ToName()} (NPC)", + CollectionType.FemaleKeeperOfTheMoonNpc => $"Female {SubRace.KeeperOfTheMoon.ToName()} (NPC)", + CollectionType.FemaleSeawolfNpc => $"Female {SubRace.Seawolf.ToName()} (NPC)", + CollectionType.FemaleHellsguardNpc => $"Female {SubRace.Hellsguard.ToName()} (NPC)", + CollectionType.FemaleRaenNpc => $"Female {SubRace.Raen.ToName()} (NPC)", + CollectionType.FemaleXaelaNpc => $"Female {SubRace.Xaela.ToName()} (NPC)", + CollectionType.FemaleHelionNpc => $"Female {SubRace.Helion.ToName()} (NPC)", + CollectionType.FemaleLostNpc => $"Female {SubRace.Lost.ToName()} (NPC)", + CollectionType.FemaleRavaNpc => $"Female {SubRace.Rava.ToName()} (NPC)", + CollectionType.FemaleVeenaNpc => $"Female {SubRace.Veena.ToName()} (NPC)", + CollectionType.Inactive => "Collection", + CollectionType.Default => "Base", + CollectionType.Interface => "Interface", + CollectionType.Individual => "Individual", + CollectionType.Current => "Current", + _ => string.Empty, + }; + + public static string ToDescription(this CollectionType collectionType) + => collectionType switch + { + CollectionType.Default => "World, Music, Furniture, baseline for characters and monsters not specialized.", + CollectionType.Interface => "User Interface, Icons, Maps, Styles.", + CollectionType.Yourself => "Your characters, regardless of name, race or gender. Applies in the login screen.", + CollectionType.MalePlayerCharacter => "Baseline for male player characters.", + CollectionType.FemalePlayerCharacter => "Baseline for female player characters.", + CollectionType.MaleNonPlayerCharacter => "Baseline for humanoid male non-player characters.", + CollectionType.FemaleNonPlayerCharacter => "Baseline for humanoid female non-player characters.", + _ => string.Empty, + }; +} diff --git a/Penumbra/Collections/Manager/IndividualCollections.Access.cs b/Penumbra/Collections/Manager/IndividualCollections.Access.cs new file mode 100644 index 00000000..d0a70630 --- /dev/null +++ b/Penumbra/Collections/Manager/IndividualCollections.Access.cs @@ -0,0 +1,154 @@ +using Dalamud.Game.ClientState.Objects.Enums; +using Dalamud.Game.ClientState.Objects.Types; +using Penumbra.GameData.Actors; +using Penumbra.GameData.Enums; +using Penumbra.String; + +namespace Penumbra.Collections.Manager; + +public sealed partial class IndividualCollections : IReadOnlyList<(string DisplayName, ModCollection Collection)> +{ + public IEnumerator<(string DisplayName, ModCollection Collection)> GetEnumerator() + => _assignments.Select(t => (t.DisplayName, t.Collection)).GetEnumerator(); + + IEnumerator IEnumerable.GetEnumerator() + => GetEnumerator(); + + public int Count + => _assignments.Count; + + public (string DisplayName, ModCollection Collection) this[int index] + => (_assignments[index].DisplayName, _assignments[index].Collection); + + public bool TryGetCollection(ActorIdentifier identifier, [NotNullWhen(true)] out ModCollection? collection) + { + if (Count == 0) + { + collection = null; + return false; + } + + switch (identifier.Type) + { + case IdentifierType.Player: return CheckWorlds(identifier, out collection); + case IdentifierType.Retainer: + { + if (_individuals.TryGetValue(identifier, out collection)) + return true; + + if (identifier.Retainer is not ActorIdentifier.RetainerType.Mannequin && _config.UseOwnerNameForCharacterCollection) + return CheckWorlds(_actors.GetCurrentPlayer(), out collection); + + break; + } + case IdentifierType.Owned: + { + if (CheckWorlds(identifier, out collection!)) + return true; + + // Handle generic NPC + var npcIdentifier = _actors.CreateIndividualUnchecked(IdentifierType.Npc, ByteString.Empty, + ushort.MaxValue, identifier.Kind, identifier.DataId); + if (npcIdentifier.IsValid && _individuals.TryGetValue(npcIdentifier, out collection)) + return true; + + // Handle Ownership. + if (!_config.UseOwnerNameForCharacterCollection) + return false; + + identifier = _actors.CreateIndividualUnchecked(IdentifierType.Player, identifier.PlayerName, + identifier.HomeWorld.Id, ObjectKind.None, uint.MaxValue); + return CheckWorlds(identifier, out collection); + } + case IdentifierType.Npc: return _individuals.TryGetValue(identifier, out collection); + case IdentifierType.Special: return CheckWorlds(ConvertSpecialIdentifier(identifier).Item1, out collection); + } + + collection = null; + return false; + } + + public enum SpecialResult + { + PartyBanner, + PvPBanner, + Mahjong, + CharacterScreen, + FittingRoom, + DyePreview, + Portrait, + Inspect, + Card, + Glamour, + Invalid, + } + + public (ActorIdentifier, SpecialResult) ConvertSpecialIdentifier(ActorIdentifier identifier) + { + if (identifier.Type != IdentifierType.Special) + return (identifier, SpecialResult.Invalid); + + if (_actors.ResolvePartyBannerPlayer(identifier.Special, out var id)) + return _config.UseCharacterCollectionsInCards ? (id, SpecialResult.PartyBanner) : (identifier, SpecialResult.Invalid); + + if (_actors.ResolvePvPBannerPlayer(identifier.Special, out id)) + return _config.UseCharacterCollectionsInCards ? (id, SpecialResult.PvPBanner) : (identifier, SpecialResult.Invalid); + + if (_actors.ResolveMahjongPlayer(identifier.Special, out id)) + return _config.UseCharacterCollectionsInCards ? (id, SpecialResult.Mahjong) : (identifier, SpecialResult.Invalid); + + switch (identifier.Special) + { + case ScreenActor.CharacterScreen when _config.UseCharacterCollectionInMainWindow: + return (_actors.GetCurrentPlayer(), SpecialResult.CharacterScreen); + case ScreenActor.FittingRoom when _config.UseCharacterCollectionInTryOn: + return (_actors.GetCurrentPlayer(), SpecialResult.FittingRoom); + case ScreenActor.DyePreview when _config.UseCharacterCollectionInTryOn: + return (_actors.GetCurrentPlayer(), SpecialResult.DyePreview); + case ScreenActor.Portrait when _config.UseCharacterCollectionsInCards: + return (_actors.GetCurrentPlayer(), SpecialResult.Portrait); + case ScreenActor.ExamineScreen: + { + identifier = _actors.GetInspectPlayer(); + if (identifier.IsValid) + return (_config.UseCharacterCollectionInInspect ? identifier : ActorIdentifier.Invalid, SpecialResult.Inspect); + + identifier = _actors.GetCardPlayer(); + if (identifier.IsValid) + return (_config.UseCharacterCollectionInInspect ? identifier : ActorIdentifier.Invalid, SpecialResult.Card); + + return _config.UseCharacterCollectionInTryOn + ? (_actors.GetGlamourPlayer(), SpecialResult.Glamour) + : (identifier, SpecialResult.Invalid); + } + default: return (identifier, SpecialResult.Invalid); + } + } + + public bool TryGetCollection(IGameObject? gameObject, out ModCollection? collection) + => TryGetCollection(_actors.FromObject(gameObject, true, false, false), out collection); + + public unsafe bool TryGetCollection(FFXIVClientStructs.FFXIV.Client.Game.Object.GameObject* gameObject, out ModCollection? collection) + => TryGetCollection(_actors.FromObject(gameObject, out _, true, false, false), out collection); + + private bool CheckWorlds(ActorIdentifier identifier, out ModCollection? collection) + { + if (!identifier.IsValid) + { + collection = null; + return false; + } + + if (_individuals.TryGetValue(identifier, out collection)) + return true; + + identifier = _actors.CreateIndividualUnchecked(identifier.Type, identifier.PlayerName, ushort.MaxValue, + identifier.Kind, + identifier.DataId); + if (identifier.IsValid && _individuals.TryGetValue(identifier, out collection)) + return true; + + collection = null; + return false; + } +} diff --git a/Penumbra/Collections/Manager/IndividualCollections.Files.cs b/Penumbra/Collections/Manager/IndividualCollections.Files.cs new file mode 100644 index 00000000..60e9fc5f --- /dev/null +++ b/Penumbra/Collections/Manager/IndividualCollections.Files.cs @@ -0,0 +1,219 @@ +using Dalamud.Game.ClientState.Objects.Enums; +using Dalamud.Interface.ImGuiNotification; +using Newtonsoft.Json.Linq; +using OtterGui.Classes; +using Penumbra.GameData.Actors; +using Penumbra.GameData.DataContainers.Bases; +using Penumbra.GameData.Structs; +using Penumbra.Services; +using Penumbra.String; + +namespace Penumbra.Collections.Manager; + +public partial class IndividualCollections +{ + public JArray ToJObject() + { + var ret = new JArray(); + foreach (var (name, identifiers, collection) in Assignments) + { + var tmp = identifiers[0].ToJson(); + tmp.Add("Collection", collection.Identity.Id); + tmp.Add("Display", name); + ret.Add(tmp); + } + + return ret; + } + + public bool ReadJObject(SaveService saver, ActiveCollections parent, JArray? obj, CollectionStorage storage, int version) + { + if (_actors.Awaiter.IsCompletedSuccessfully) + { + var ret = version switch + { + 1 => ReadJObjectInternalV1(obj, storage), + 2 => ReadJObjectInternalV2(obj, storage), + _ => true, + }; + return ret; + } + + Penumbra.Log.Debug("[Collections] Delayed reading individual assignments until actor service is ready..."); + _actors.Awaiter.ContinueWith(_ => + { + if (version switch + { + 1 => ReadJObjectInternalV1(obj, storage), + 2 => ReadJObjectInternalV2(obj, storage), + _ => true, + }) + saver.ImmediateSave(parent); + IsLoaded = true; + Loaded.Invoke(); + }, TaskScheduler.Default); + return false; + } + + private bool ReadJObjectInternalV1(JArray? obj, CollectionStorage storage) + { + Penumbra.Log.Debug("[Collections] Reading individual assignments..."); + if (obj == null) + { + Penumbra.Log.Debug($"[Collections] Finished reading {Count} individual assignments..."); + return true; + } + + foreach (var data in obj) + { + try + { + var identifier = _actors.FromJson(data as JObject); + var group = GetGroup(identifier); + if (group.Length == 0 || group.Any(i => !i.IsValid)) + { + Penumbra.Messager.NotificationMessage("Could not load an unknown individual collection, removed.", + NotificationType.Error); + continue; + } + + var collectionName = data["Collection"]?.ToObject() ?? string.Empty; + if (collectionName.Length == 0 || !storage.ByName(collectionName, out var collection)) + { + Penumbra.Messager.NotificationMessage( + $"Could not load the collection \"{collectionName}\" as individual collection for {identifier}, set to None.", + NotificationType.Warning); + continue; + } + + if (!Add(group, collection)) + { + Penumbra.Messager.NotificationMessage($"Could not add an individual collection for {identifier}, removed.", + NotificationType.Warning); + } + } + catch (Exception e) + { + Penumbra.Messager.NotificationMessage(e, $"Could not load an unknown individual collection, removed.", NotificationType.Error); + } + } + + Penumbra.Log.Debug($"Finished reading {Count} individual assignments..."); + + return true; + } + + private bool ReadJObjectInternalV2(JArray? obj, CollectionStorage storage) + { + Penumbra.Log.Debug("[Collections] Reading individual assignments..."); + if (obj == null) + { + Penumbra.Log.Debug($"[Collections] Finished reading {Count} individual assignments..."); + return true; + } + + var changes = false; + foreach (var data in obj) + { + try + { + var identifier = _actors.FromJson(data as JObject); + var group = GetGroup(identifier); + if (group.Length == 0 || group.Any(i => !i.IsValid)) + { + changes = true; + Penumbra.Messager.NotificationMessage("Could not load an unknown individual collection, removed assignment.", + NotificationType.Error); + continue; + } + + var collectionId = data["Collection"]?.ToObject(); + if (!collectionId.HasValue || !storage.ById(collectionId.Value, out var collection)) + { + changes = true; + Penumbra.Messager.NotificationMessage( + $"Could not load the collection {collectionId} as individual collection for {identifier}, removed assignment.", + NotificationType.Warning); + continue; + } + + if (!Add(group, collection)) + { + changes = true; + Penumbra.Messager.NotificationMessage($"Could not add an individual collection for {identifier}, removed assignment.", + NotificationType.Warning); + } + } + catch (Exception e) + { + changes = true; + Penumbra.Messager.NotificationMessage(e, $"Could not load an unknown individual collection, removed assignment.", NotificationType.Error); + } + } + + Penumbra.Log.Debug($"Finished reading {Count} individual assignments..."); + + return changes; + } + + internal void Migrate0To1(Dictionary old) + { + foreach (var (name, collection) in old) + { + var kind = ObjectKind.None; + var lowerName = name.ToLowerInvariant(); + // Prefer matching NPC names, fewer false positives than preferring players. + if (FindDataId(lowerName, _actors.Data.Companions, out var dataId)) + kind = ObjectKind.Companion; + else if (FindDataId(lowerName, _actors.Data.Mounts, out dataId)) + kind = ObjectKind.MountType; + else if (FindDataId(lowerName, _actors.Data.BNpcs, out dataId)) + kind = ObjectKind.BattleNpc; + else if (FindDataId(lowerName, _actors.Data.ENpcs, out dataId)) + kind = ObjectKind.EventNpc; + + var identifier = _actors.CreateNpc(kind, dataId); + if (identifier.IsValid) + { + // If the name corresponds to a valid npc, add it as a group. If this fails, notify users. + var group = GetGroup(identifier); + var ids = string.Join(", ", group.Select(i => i.DataId.ToString())); + if (Add($"{_actors.Data.ToName(kind, dataId)} ({kind.ToName()})", group, collection)) + Penumbra.Log.Information($"Migrated {name} ({kind.ToName()}) to NPC Identifiers [{ids}]."); + else + Penumbra.Messager.NotificationMessage( + $"Could not migrate {name} ({collection.Identity.AnonymizedName}) which was assumed to be a {kind.ToName()} with IDs [{ids}], please look through your individual collections.", + NotificationType.Error); + } + // If it is not a valid NPC name, check if it can be a player name. + else if (ActorIdentifierFactory.VerifyPlayerName(name)) + { + identifier = _actors.CreatePlayer(ByteString.FromStringUnsafe(name, false), ushort.MaxValue); + var shortName = string.Join(" ", name.Split().Select(n => $"{n[0]}.")); + // Try to migrate the player name without logging full names. + if (Add($"{name} ({_actors.Data.ToWorldName(identifier.HomeWorld)})", [identifier], collection)) + Penumbra.Log.Information($"Migrated {shortName} ({collection.Identity.AnonymizedName}) to Player Identifier."); + else + Penumbra.Messager.NotificationMessage( + $"Could not migrate {shortName} ({collection.Identity.AnonymizedName}), please look through your individual collections.", + NotificationType.Error); + } + else + { + Penumbra.Messager.NotificationMessage( + $"Could not migrate {name} ({collection.Identity.AnonymizedName}), which can not be a player name nor is it a known NPC name, please look through your individual collections.", + NotificationType.Error); + } + } + + return; + + static bool FindDataId(string name, NameDictionary data, out NpcId dataId) + { + var kvp = data.FirstOrDefault(kvp => kvp.Value.Equals(name, StringComparison.OrdinalIgnoreCase), + new KeyValuePair(uint.MaxValue, string.Empty)); + dataId = kvp.Key; + return kvp.Value.Length > 0; + } + } +} diff --git a/Penumbra/Collections/Manager/IndividualCollections.cs b/Penumbra/Collections/Manager/IndividualCollections.cs new file mode 100644 index 00000000..67ab0b21 --- /dev/null +++ b/Penumbra/Collections/Manager/IndividualCollections.cs @@ -0,0 +1,236 @@ +using Dalamud.Game.ClientState.Objects.Enums; +using OtterGui.Filesystem; +using Penumbra.GameData.Actors; +using Penumbra.GameData.DataContainers.Bases; +using Penumbra.GameData.Enums; +using Penumbra.GameData.Structs; +using Penumbra.String; + +namespace Penumbra.Collections.Manager; + +public sealed partial class IndividualCollections +{ + public record struct IndividualAssignment(string DisplayName, IReadOnlyList Identifiers, ModCollection Collection); + + private readonly Configuration _config; + private readonly ActorManager _actors; + private readonly Dictionary _individuals = []; + private readonly List _assignments = []; + + public event Action Loaded; + public bool IsLoaded { get; private set; } + + public IReadOnlyList Assignments + => _assignments; + + public IndividualCollections(ActorManager actors, Configuration config, bool temporary) + { + _config = config; + _actors = actors; + IsLoaded = temporary; + Loaded += () => Penumbra.Log.Information($"{_assignments.Count} Individual Assignments loaded after delay."); + } + + public enum AddResult + { + Valid, + AlreadySet, + Invalid, + } + + public bool TryGetValue(ActorIdentifier identifier, [NotNullWhen(true)] out ModCollection? collection) + { + lock (_individuals) + { + return _individuals.TryGetValue(identifier, out collection); + } + } + + public bool ContainsKey(ActorIdentifier identifier) + { + lock (_individuals) + { + return _individuals.ContainsKey(identifier); + } + } + + public AddResult CanAdd(params ActorIdentifier[] identifiers) + { + if (identifiers.Length == 0) + return AddResult.Invalid; + + if (identifiers.Any(i => !i.IsValid)) + return AddResult.Invalid; + + bool set; + lock (_individuals) + { + set = identifiers.Any(_individuals.ContainsKey); + } + + return set ? AddResult.AlreadySet : AddResult.Valid; + } + + public AddResult CanAdd(IdentifierType type, string name, WorldId homeWorld, ObjectKind kind, IEnumerable dataIds, + out ActorIdentifier[] identifiers) + { + identifiers = []; + + switch (type) + { + case IdentifierType.Player: + if (!ByteString.FromString(name, out var playerName)) + return AddResult.Invalid; + + identifiers = [_actors.CreatePlayer(playerName, homeWorld)]; + break; + case IdentifierType.Retainer: + if (!ByteString.FromString(name, out var retainerName)) + return AddResult.Invalid; + + identifiers = [_actors.CreateRetainer(retainerName, ActorIdentifier.RetainerType.Both)]; + break; + case IdentifierType.Owned: + if (!ByteString.FromString(name, out var ownerName)) + return AddResult.Invalid; + + identifiers = dataIds.Select(id => _actors.CreateOwned(ownerName, homeWorld, kind, id)).ToArray(); + break; + case IdentifierType.Npc: + identifiers = dataIds + .Select(id => _actors.CreateIndividual(IdentifierType.Npc, ByteString.Empty, ushort.MaxValue, kind, id)).ToArray(); + break; + } + + return CanAdd(identifiers); + } + + public ActorIdentifier[] GetGroup(ActorIdentifier identifier) + { + if (!identifier.IsValid) + return []; + + return identifier.Type switch + { + IdentifierType.Player => [identifier.CreatePermanent()], + IdentifierType.Special => [identifier], + IdentifierType.Retainer => [identifier.CreatePermanent()], + IdentifierType.Owned => CreateNpcs(_actors, identifier.CreatePermanent()), + IdentifierType.Npc => CreateNpcs(_actors, identifier), + _ => [], + }; + + static ActorIdentifier[] CreateNpcs(ActorManager manager, ActorIdentifier identifier) + { + var name = manager.Data.ToName(identifier.Kind, identifier.DataId); + NameDictionary table = identifier.Kind switch + { + ObjectKind.BattleNpc => manager.Data.BNpcs, + ObjectKind.EventNpc => manager.Data.ENpcs, + ObjectKind.Companion => manager.Data.Companions, + ObjectKind.MountType => manager.Data.Mounts, + ObjectKind.Ornament => manager.Data.Ornaments, + _ => throw new NotImplementedException(), + }; + return table.Where(kvp => kvp.Value == name) + .Select(kvp => manager.CreateIndividualUnchecked(identifier.Type, identifier.PlayerName, identifier.HomeWorld.Id, + identifier.Kind, kvp.Key)).ToArray(); + } + } + + internal bool Add(ActorIdentifier[] identifiers, ModCollection collection) + { + if (identifiers.Length == 0 || !identifiers[0].IsValid) + return false; + + var name = DisplayString(identifiers[0]); + return Add(name, identifiers, collection); + } + + private bool Add(string displayName, ActorIdentifier[] identifiers, ModCollection collection) + { + if (CanAdd(identifiers) != AddResult.Valid + || displayName.Length == 0 + || _assignments.Any(a => a.DisplayName.Equals(displayName, StringComparison.OrdinalIgnoreCase))) + return false; + + for (var i = 0; i < identifiers.Length; ++i) + { + identifiers[i] = identifiers[i].CreatePermanent(); + lock (_individuals) + { + _individuals.Add(identifiers[i], collection); + } + } + + _assignments.Add(new IndividualAssignment(displayName, identifiers, collection)); + + return true; + } + + internal bool ChangeCollection(ActorIdentifier identifier, ModCollection newCollection) + => ChangeCollection(DisplayString(identifier), newCollection); + + internal bool ChangeCollection(string displayName, ModCollection newCollection) + => ChangeCollection(_assignments.FindIndex(t => t.DisplayName.Equals(displayName, StringComparison.OrdinalIgnoreCase)), newCollection); + + internal bool ChangeCollection(int displayIndex, ModCollection newCollection) + { + if (displayIndex < 0 || displayIndex >= _assignments.Count || _assignments[displayIndex].Collection == newCollection) + return false; + + _assignments[displayIndex] = _assignments[displayIndex] with { Collection = newCollection }; + lock (_individuals) + { + foreach (var identifier in _assignments[displayIndex].Identifiers) + _individuals[identifier] = newCollection; + } + + return true; + } + + internal bool Delete(ActorIdentifier identifier) + => Delete(Index(identifier)); + + internal bool Delete(string displayName) + => Delete(Index(displayName)); + + internal bool Delete(int displayIndex) + { + if (displayIndex < 0 || displayIndex >= _assignments.Count) + return false; + + var (name, identifiers, _) = _assignments[displayIndex]; + _assignments.RemoveAt(displayIndex); + lock (_individuals) + { + foreach (var identifier in identifiers) + _individuals.Remove(identifier); + } + + return true; + } + + internal bool Move(int from, int to) + => _assignments.Move(from, to); + + internal int Index(string displayName) + => _assignments.FindIndex(t => t.DisplayName.Equals(displayName, StringComparison.OrdinalIgnoreCase)); + + internal int Index(ActorIdentifier identifier) + => identifier.IsValid ? Index(DisplayString(identifier)) : -1; + + private string DisplayString(ActorIdentifier identifier) + { + return identifier.Type switch + { + IdentifierType.Player => $"{identifier.PlayerName} ({_actors.Data.ToWorldName(identifier.HomeWorld)})", + IdentifierType.Retainer => $"{identifier.PlayerName} (Retainer)", + IdentifierType.Owned => + $"{identifier.PlayerName} ({_actors.Data.ToWorldName(identifier.HomeWorld)})'s {_actors.Data.ToName(identifier.Kind, identifier.DataId)}", + IdentifierType.Npc => + $"{_actors.Data.ToName(identifier.Kind, identifier.DataId)} ({identifier.Kind.ToName()})", + _ => string.Empty, + }; + } +} diff --git a/Penumbra/Collections/Manager/InheritanceManager.cs b/Penumbra/Collections/Manager/InheritanceManager.cs new file mode 100644 index 00000000..34582677 --- /dev/null +++ b/Penumbra/Collections/Manager/InheritanceManager.cs @@ -0,0 +1,197 @@ +using Dalamud.Interface.ImGuiNotification; +using OtterGui.Classes; +using OtterGui.Extensions; +using OtterGui.Services; +using Penumbra.Communication; +using Penumbra.Mods.Manager; +using Penumbra.Services; + +namespace Penumbra.Collections.Manager; + +/// +/// ModCollections can inherit from an arbitrary number of other collections. +/// This is transitive, so a collection A inheriting from B also inherits from everything B inherits. +/// Circular dependencies are resolved by distinctness. +/// +public class InheritanceManager : IDisposable, IService +{ + public enum ValidInheritance + { + Valid, + + /// Can not inherit from self + Self, + + /// Can not inherit from the empty collection + Empty, + + /// Already inherited from + Contained, + + /// Inheritance would lead to a circle. + Circle, + } + + private readonly CollectionStorage _storage; + private readonly CommunicatorService _communicator; + private readonly SaveService _saveService; + private readonly ModStorage _modStorage; + + public InheritanceManager(CollectionStorage storage, SaveService saveService, CommunicatorService communicator, ModStorage modStorage) + { + _storage = storage; + _saveService = saveService; + _communicator = communicator; + _modStorage = modStorage; + + ApplyInheritances(); + _communicator.CollectionChange.Subscribe(OnCollectionChange, CollectionChange.Priority.InheritanceManager); + } + + public void Dispose() + { + _communicator.CollectionChange.Unsubscribe(OnCollectionChange); + } + + /// Check whether a collection can be inherited from. + public static ValidInheritance CheckValidInheritance(ModCollection potentialInheritor, ModCollection? potentialParent) + { + if (potentialParent == null || ReferenceEquals(potentialParent, ModCollection.Empty)) + return ValidInheritance.Empty; + + if (ReferenceEquals(potentialParent, potentialInheritor)) + return ValidInheritance.Self; + + if (potentialInheritor.Inheritance.DirectlyInheritsFrom.Contains(potentialParent)) + return ValidInheritance.Contained; + + if (potentialParent.Inheritance.FlatHierarchy.Any(c => ReferenceEquals(c, potentialInheritor))) + return ValidInheritance.Circle; + + return ValidInheritance.Valid; + } + + /// + /// Add a new collection to the inheritance list. + /// We do not check if this collection would be visited before, + /// only that it is unique in the list itself. + /// + public bool AddInheritance(ModCollection inheritor, ModCollection parent) + => AddInheritance(inheritor, parent, true); + + /// Remove an existing inheritance from a collection. + public void RemoveInheritance(ModCollection inheritor, int idx) + { + var parent = inheritor.Inheritance.RemoveInheritanceAt(inheritor, idx); + _saveService.QueueSave(new ModCollectionSave(_modStorage, inheritor)); + _communicator.CollectionInheritanceChanged.Invoke(inheritor, false); + RecurseInheritanceChanges(inheritor, true); + Penumbra.Log.Debug($"Removed {parent.Identity.AnonymizedName} from {inheritor.Identity.AnonymizedName} inheritances."); + } + + /// Order in the inheritance list is relevant. + public void MoveInheritance(ModCollection inheritor, int from, int to) + { + if (!inheritor.Inheritance.MoveInheritance(inheritor, from, to)) + return; + + _saveService.QueueSave(new ModCollectionSave(_modStorage, inheritor)); + _communicator.CollectionInheritanceChanged.Invoke(inheritor, false); + RecurseInheritanceChanges(inheritor, true); + Penumbra.Log.Debug($"Moved {inheritor.Identity.AnonymizedName}s inheritance {from} to {to}."); + } + + /// + private bool AddInheritance(ModCollection inheritor, ModCollection parent, bool invokeEvent) + { + if (CheckValidInheritance(inheritor, parent) != ValidInheritance.Valid) + return false; + + inheritor.Inheritance.AddInheritance(inheritor, parent); + if (invokeEvent) + { + _saveService.QueueSave(new ModCollectionSave(_modStorage, inheritor)); + _communicator.CollectionInheritanceChanged.Invoke(inheritor, false); + } + + RecurseInheritanceChanges(inheritor, invokeEvent); + + Penumbra.Log.Debug($"Added {parent.Identity.AnonymizedName} to {inheritor.Identity.AnonymizedName} inheritances."); + return true; + } + + /// + /// Inheritances can not be setup before all collections are read, + /// so this happens after reading the collections in the constructor, consuming the stored strings. + /// + private void ApplyInheritances() + { + foreach (var collection in _storage) + { + if (collection.Inheritance.ConsumeNames() is not { } byName) + continue; + + var changes = false; + foreach (var subCollectionName in byName) + { + if (Guid.TryParse(subCollectionName, out var guid) && _storage.ById(guid, out var subCollection)) + { + if (AddInheritance(collection, subCollection, false)) + continue; + + changes = true; + Penumbra.Messager.NotificationMessage( + $"{collection.Identity.Name} can not inherit from {subCollection.Identity.Name}, removed.", + NotificationType.Warning); + } + else if (_storage.ByName(subCollectionName, out subCollection)) + { + changes = true; + Penumbra.Log.Information($"Migrating inheritance for {collection.Identity.AnonymizedName} from name to GUID."); + if (AddInheritance(collection, subCollection, false)) + continue; + + Penumbra.Messager.NotificationMessage( + $"{collection.Identity.Name} can not inherit from {subCollection.Identity.Name}, removed.", + NotificationType.Warning); + } + else + { + Penumbra.Messager.NotificationMessage( + $"Inherited collection {subCollectionName} for {collection.Identity.AnonymizedName} does not exist, it was removed.", + NotificationType.Warning); + changes = true; + } + } + + if (changes) + _saveService.ImmediateSave(new ModCollectionSave(_modStorage, collection)); + } + } + + private void OnCollectionChange(CollectionType collectionType, ModCollection? old, ModCollection? newCollection, string _3) + { + if (collectionType is not CollectionType.Inactive || old == null) + return; + + foreach (var c in _storage) + { + var inheritedIdx = c.Inheritance.DirectlyInheritsFrom.IndexOf(old); + if (inheritedIdx >= 0) + RemoveInheritance(c, inheritedIdx); + + c.Inheritance.RemoveChild(old); + } + } + + private void RecurseInheritanceChanges(ModCollection newInheritor, bool invokeEvent) + { + foreach (var inheritor in newInheritor.Inheritance.DirectlyInheritedBy) + { + ModCollectionInheritance.UpdateFlattenedInheritance(inheritor); + RecurseInheritanceChanges(inheritor, invokeEvent); + if (invokeEvent) + _communicator.CollectionInheritanceChanged.Invoke(inheritor, true); + } + } +} diff --git a/Penumbra/Collections/Manager/ModCollectionMigration.cs b/Penumbra/Collections/Manager/ModCollectionMigration.cs new file mode 100644 index 00000000..7db375f7 --- /dev/null +++ b/Penumbra/Collections/Manager/ModCollectionMigration.cs @@ -0,0 +1,46 @@ +using Penumbra.Mods.Manager; +using Penumbra.Mods.Settings; +using Penumbra.Services; + +namespace Penumbra.Collections.Manager; + +/// Migration to convert ModCollections from older versions to newer. +internal static class ModCollectionMigration +{ + /// Migrate a mod collection to the current version. + public static void Migrate(SaveService saver, ModStorage mods, int version, ModCollection collection) + { + var changes = MigrateV0ToV1(collection, ref version); + if (changes) + saver.ImmediateSaveSync(new ModCollectionSave(mods, collection)); + } + + /// Migrate a mod collection from Version 0 to Version 1, which introduced support for inheritance. + private static bool MigrateV0ToV1(ModCollection collection, ref int version) + { + if (version > 0) + return false; + + version = 1; + + // Remove all completely defaulted settings from active and inactive mods. + for (var i = 0; i < collection.Settings.Count; ++i) + { + if (SettingIsDefaultV0(collection.GetOwnSettings(i))) + collection.Settings.SetAll(i, FullModSettings.Empty); + } + + foreach (var (key, _) in collection.Settings.Unused.Where(kvp => SettingIsDefaultV0(kvp.Value)).ToList()) + collection.Settings.RemoveUnused(key); + + return true; + } + + /// We treat every completely defaulted setting as inheritance-ready. + private static bool SettingIsDefaultV0(ModSettings.SavedSettings setting) + => setting is { Enabled: true, Priority.IsDefault: true } && setting.Settings.Values.All(s => s == Setting.Zero); + + /// + private static bool SettingIsDefaultV0(ModSettings? setting) + => setting is { Enabled: true, Priority.IsDefault: true } && setting.Settings.All(s => s == Setting.Zero); +} diff --git a/Penumbra/Collections/Manager/TempCollectionManager.cs b/Penumbra/Collections/Manager/TempCollectionManager.cs new file mode 100644 index 00000000..9476e38c --- /dev/null +++ b/Penumbra/Collections/Manager/TempCollectionManager.cs @@ -0,0 +1,132 @@ +using OtterGui.Extensions; +using OtterGui.Services; +using Penumbra.Api; +using Penumbra.Communication; +using Penumbra.GameData.Actors; +using Penumbra.Mods; +using Penumbra.Services; +using Penumbra.String; + +namespace Penumbra.Collections.Manager; + +public class TempCollectionManager : IDisposable, IService +{ + public int GlobalChangeCounter { get; private set; } + public readonly IndividualCollections Collections; + + private readonly CommunicatorService _communicator; + private readonly CollectionStorage _storage; + private readonly ActorManager _actors; + private readonly Dictionary _customCollections = []; + + public TempCollectionManager(Configuration config, CommunicatorService communicator, ActorManager actors, CollectionStorage storage) + { + _communicator = communicator; + _actors = actors; + _storage = storage; + Collections = new IndividualCollections(actors, config, true); + + _communicator.TemporaryGlobalModChange.Subscribe(OnGlobalModChange, TemporaryGlobalModChange.Priority.TempCollectionManager); + } + + public void Dispose() + { + _communicator.TemporaryGlobalModChange.Unsubscribe(OnGlobalModChange); + } + + private void OnGlobalModChange(TemporaryMod mod, bool created, bool removed) + => TempModManager.OnGlobalModChange(_customCollections.Values, mod, created, removed); + + public int Count + => _customCollections.Count; + + public IEnumerable Values + => _customCollections.Values; + + public bool CollectionByName(string name, [NotNullWhen(true)] out ModCollection? collection) + => _customCollections.Values.FindFirst(c => string.Equals(name, c.Identity.Name, StringComparison.OrdinalIgnoreCase), out collection); + + public bool CollectionById(Guid id, [NotNullWhen(true)] out ModCollection? collection) + => _customCollections.TryGetValue(id, out collection); + + public Guid CreateTemporaryCollection(string name) + { + if (GlobalChangeCounter == int.MaxValue) + GlobalChangeCounter = 0; + var collection = _storage.CreateTemporary(name, ~Count, GlobalChangeCounter++); + Penumbra.Log.Debug($"Creating temporary collection {collection.Identity.Name} with {collection.Identity.Id}."); + if (_customCollections.TryAdd(collection.Identity.Id, collection)) + { + // Temporary collection created. + _communicator.CollectionChange.Invoke(CollectionType.Temporary, null, collection, string.Empty); + return collection.Identity.Id; + } + + return Guid.Empty; + } + + public bool RemoveTemporaryCollection(Guid collectionId) + { + if (!_customCollections.Remove(collectionId, out var collection)) + { + Penumbra.Log.Debug($"Tried to delete temporary collection {collectionId}, but did not exist."); + return false; + } + + _storage.Delete(collection); + Penumbra.Log.Debug($"Deleted temporary collection {collection.Identity.Id}."); + GlobalChangeCounter += Math.Max(collection.Counters.Change + 1 - GlobalChangeCounter, 0); + for (var i = 0; i < Collections.Count; ++i) + { + if (Collections[i].Collection != collection) + continue; + + // Temporary collection assignment removed. + _communicator.CollectionChange.Invoke(CollectionType.Temporary, collection, null, Collections[i].DisplayName); + Penumbra.Log.Verbose($"Unassigned temporary collection {collection.Identity.Id} from {Collections[i].DisplayName}."); + Collections.Delete(i--); + } + + return true; + } + + public bool AddIdentifier(ModCollection collection, params ActorIdentifier[] identifiers) + { + if (!Collections.Add(identifiers, collection)) + return false; + + // Temporary collection assignment added. + Penumbra.Log.Verbose($"Assigned temporary collection {collection.Identity.AnonymizedName} to {Collections.Last().DisplayName}."); + _communicator.CollectionChange.Invoke(CollectionType.Temporary, null, collection, Collections.Last().DisplayName); + return true; + } + + public bool AddIdentifier(Guid collectionId, params ActorIdentifier[] identifiers) + { + if (!_customCollections.TryGetValue(collectionId, out var collection)) + return false; + + return AddIdentifier(collection, identifiers); + } + + public bool AddIdentifier(Guid collectionId, string characterName, ushort worldId = ushort.MaxValue) + { + if (!ByteString.FromString(characterName, out var byteString)) + return false; + + var identifier = _actors.CreatePlayer(byteString, worldId); + if (!identifier.IsValid) + return false; + + return AddIdentifier(collectionId, identifier); + } + + internal bool RemoveByCharacterName(string characterName, ushort worldId = ushort.MaxValue) + { + if (!ByteString.FromString(characterName, out var byteString)) + return false; + + var identifier = _actors.CreatePlayer(byteString, worldId); + return Collections.TryGetValue(identifier, out var collection) && RemoveTemporaryCollection(collection.Identity.Id); + } +} diff --git a/Penumbra/Collections/ModCollection.Cache.Access.cs b/Penumbra/Collections/ModCollection.Cache.Access.cs new file mode 100644 index 00000000..716b153e --- /dev/null +++ b/Penumbra/Collections/ModCollection.Cache.Access.cs @@ -0,0 +1,57 @@ +using OtterGui.Classes; +using Penumbra.Mods; +using Penumbra.String.Classes; +using Penumbra.Collections.Cache; +using Penumbra.GameData.Data; +using Penumbra.Mods.Editor; + +namespace Penumbra.Collections; + +public partial class ModCollection +{ + // Only active collections need to have a cache. + internal CollectionCache? _cache; + + public bool HasCache + => _cache != null; + + + // Handle temporary mods for this collection. + public void Apply(TemporaryMod tempMod, bool created) + { + if (created) + _cache?.AddMod(tempMod, tempMod.TotalManipulations > 0); + else + _cache?.ReloadMod(tempMod, tempMod.TotalManipulations > 0); + } + + public void Remove(TemporaryMod tempMod) + { + _cache?.RemoveMod(tempMod, tempMod.TotalManipulations > 0); + } + + public IEnumerable ReverseResolvePath(FullPath path) + => _cache?.ReverseResolvePath(path) ?? Array.Empty(); + + public HashSet[] ReverseResolvePaths(IReadOnlyCollection paths) + => _cache?.ReverseResolvePaths(paths) ?? paths.Select(_ => new HashSet()).ToArray(); + + public FullPath? ResolvePath(Utf8GamePath path) + => _cache?.ResolvePath(path); + + // Obtain data from the cache. + internal MetaCache? MetaCache + => _cache?.Meta; + + internal IReadOnlyDictionary ResolvedFiles + => _cache?.ResolvedFiles ?? new ConcurrentDictionary(); + + internal IReadOnlyDictionary, IIdentifiedObjectData)> ChangedItems + => _cache?.ChangedItems ?? new Dictionary, IIdentifiedObjectData)>(); + + internal IEnumerable> AllConflicts + => _cache?.AllConflicts ?? Array.Empty>(); + + internal SingleArray Conflicts(Mod mod) + => _cache?.Conflicts(mod) ?? new SingleArray(); +} diff --git a/Penumbra/Collections/ModCollection.cs b/Penumbra/Collections/ModCollection.cs new file mode 100644 index 00000000..69f82458 --- /dev/null +++ b/Penumbra/Collections/ModCollection.cs @@ -0,0 +1,140 @@ +using Penumbra.Mods.Manager; +using Penumbra.Collections.Manager; +using Penumbra.Mods.Settings; +using Penumbra.Services; + +namespace Penumbra.Collections; + +/// +/// A ModCollection is a named set of ModSettings to all the users' installed mods. +/// Settings to mods that are not installed anymore are kept as long as no call to CleanUnavailableSettings is made. +/// Invariants: +/// - Index is the collections index in the ModCollection.Manager +/// - Settings has the same size as ModManager.Mods. +/// - any change in settings or inheritance of the collection causes a Save. +/// +public partial class ModCollection +{ + public const int CurrentVersion = 2; + + /// + /// Create the always available Empty Collection that will always sit at index 0, + /// can not be deleted and does never create a cache. + /// + public static readonly ModCollection Empty = new(ModCollectionIdentity.Empty, 0, CurrentVersion, new ModSettingProvider(), + new ModCollectionInheritance()); + + public ModCollectionIdentity Identity; + + public override string ToString() + => Identity.ToString(); + + public readonly ModSettingProvider Settings; + public ModCollectionInheritance Inheritance; + public CollectionCounters Counters; + + + public ModSettings? GetOwnSettings(Index idx) + { + if (Identity.Index <= 0) + return ModSettings.Empty; + + return Settings.Settings[idx].Settings; + } + + public TemporaryModSettings? GetTempSettings(Index idx) + { + if (Identity.Index <= 0) + return null; + + return Settings.Settings[idx].TempSettings; + } + + public (ModSettings? Settings, ModCollection Collection) GetInheritedSettings(Index idx) + { + if (Identity.Index <= 0) + return (ModSettings.Empty, this); + + foreach (var collection in Inheritance.FlatHierarchy) + { + var settings = collection.Settings.Settings[idx].Settings; + if (settings != null) + return (settings, collection); + } + + return (null, this); + } + + public (ModSettings? Settings, ModCollection Collection) GetActualSettings(Index idx) + { + if (Identity.Index <= 0) + return (ModSettings.Empty, this); + + // Check temp settings. + var ownTempSettings = Settings.Settings[idx].Resolve(); + if (ownTempSettings != null) + return (ownTempSettings, this); + + // Ignore temp settings for inherited collections. + foreach (var collection in Inheritance.FlatHierarchy.Skip(1)) + { + var settings = collection.Settings.Settings[idx].Settings; + if (settings != null) + return (settings, collection); + } + + return (null, this); + } + + /// Evaluates all settings along the whole inheritance tree. + public IEnumerable ActualSettings + => Enumerable.Range(0, Settings.Count).Select(i => GetActualSettings(i).Settings); + + /// + /// Constructor for duplication. Deep copies all settings and parent collections and adds the new collection to their children lists. + /// + public ModCollection Duplicate(string name, LocalCollectionId localId, int index) + { + Debug.Assert(index > 0, "Collection duplicated with non-positive index."); + return new ModCollection(ModCollectionIdentity.New(name, localId, index), 0, CurrentVersion, Settings.Clone(), Inheritance.Clone()); + } + + /// Constructor for reading from files. + public static ModCollection CreateFromData(SaveService saver, ModStorage mods, ModCollectionIdentity identity, int version, + Dictionary allSettings, IReadOnlyList inheritances) + { + Debug.Assert(identity.Index > 0, "Collection read with non-positive index."); + var ret = new ModCollection(identity, 0, version, new ModSettingProvider(allSettings), new ModCollectionInheritance(inheritances)); + ret.Settings.ApplyModSettings(ret, saver, mods); + ModCollectionMigration.Migrate(saver, mods, version, ret); + return ret; + } + + /// Constructor for temporary collections. + public static ModCollection CreateTemporary(string name, LocalCollectionId localId, int index, int changeCounter) + { + Debug.Assert(index < 0, "Temporary collection created with non-negative index."); + var ret = new ModCollection(ModCollectionIdentity.New(name, localId, index), changeCounter, CurrentVersion, new ModSettingProvider(), + new ModCollectionInheritance()); + return ret; + } + + /// Constructor for empty collections. + public static ModCollection CreateEmpty(string name, LocalCollectionId localId, int index, int modCount) + { + Debug.Assert(index >= 0, "Empty collection created with negative index."); + return new ModCollection(ModCollectionIdentity.New(name, localId, index), 0, CurrentVersion, ModSettingProvider.Empty(modCount), + new ModCollectionInheritance()); + } + + private ModCollection(ModCollectionIdentity identity, int changeCounter, int version, ModSettingProvider settings, + ModCollectionInheritance inheritance) + { + Identity = identity; + Counters = new CollectionCounters(changeCounter); + Settings = settings; + Inheritance = inheritance; + ModCollectionInheritance.UpdateChildren(this); + ModCollectionInheritance.UpdateFlattenedInheritance(this); + } +} diff --git a/Penumbra/Collections/ModCollectionIdentity.cs b/Penumbra/Collections/ModCollectionIdentity.cs new file mode 100644 index 00000000..7050450c --- /dev/null +++ b/Penumbra/Collections/ModCollectionIdentity.cs @@ -0,0 +1,43 @@ +using OtterGui; +using OtterGui.Extensions; +using Penumbra.Collections.Manager; + +namespace Penumbra.Collections; + +public struct ModCollectionIdentity(Guid id, LocalCollectionId localId) +{ + public const string DefaultCollectionName = "Default"; + public const string EmptyCollectionName = "None"; + + public static readonly ModCollectionIdentity Empty = new(Guid.Empty, LocalCollectionId.Zero, EmptyCollectionName, 0); + + public string Name { get; set; } = string.Empty; + public Guid Id { get; } = id; + public LocalCollectionId LocalId { get; } = localId; + + /// The index of the collection is set and kept up-to-date by the CollectionManager. + public int Index { get; internal set; } + + public string Identifier + => Id.ToString(); + + public string ShortIdentifier + => Id.ShortGuid(); + + /// Get the short identifier of a collection unless it is a well-known collection name. + public string AnonymizedName + => Id == Guid.Empty ? EmptyCollectionName : Name == DefaultCollectionName ? Name : ShortIdentifier; + + public override string ToString() + => Name.Length > 0 ? Name : ShortIdentifier; + + public ModCollectionIdentity(Guid id, LocalCollectionId localId, string name, int index) + : this(id, localId) + { + Name = name; + Index = index; + } + + public static ModCollectionIdentity New(string name, LocalCollectionId id, int index) + => new(Guid.NewGuid(), id, name, index); +} diff --git a/Penumbra/Collections/ModCollectionInheritance.cs b/Penumbra/Collections/ModCollectionInheritance.cs new file mode 100644 index 00000000..151ed7db --- /dev/null +++ b/Penumbra/Collections/ModCollectionInheritance.cs @@ -0,0 +1,92 @@ +using OtterGui.Filesystem; + +namespace Penumbra.Collections; + +public struct ModCollectionInheritance +{ + public IReadOnlyList? InheritanceByName { get; private set; } + private readonly List _directlyInheritsFrom = []; + private readonly List _directlyInheritedBy = []; + private readonly List _flatHierarchy = []; + + public ModCollectionInheritance() + { } + + private ModCollectionInheritance(List inheritsFrom) + => _directlyInheritsFrom = [.. inheritsFrom]; + + public ModCollectionInheritance(IReadOnlyList byName) + => InheritanceByName = byName; + + public ModCollectionInheritance Clone() + => new(_directlyInheritsFrom); + + public IEnumerable Identifiers + => InheritanceByName ?? _directlyInheritsFrom.Select(c => c.Identity.Identifier); + + public IReadOnlyList? ConsumeNames() + { + var ret = InheritanceByName; + InheritanceByName = null; + return ret; + } + + public static void UpdateChildren(ModCollection parent) + { + foreach (var inheritance in parent.Inheritance.DirectlyInheritsFrom) + inheritance.Inheritance._directlyInheritedBy.Add(parent); + } + + public void AddInheritance(ModCollection inheritor, ModCollection newParent) + { + _directlyInheritsFrom.Add(newParent); + newParent.Inheritance._directlyInheritedBy.Add(inheritor); + UpdateFlattenedInheritance(inheritor); + } + + public ModCollection RemoveInheritanceAt(ModCollection inheritor, int idx) + { + var parent = DirectlyInheritsFrom[idx]; + _directlyInheritsFrom.RemoveAt(idx); + parent.Inheritance._directlyInheritedBy.Remove(parent); + UpdateFlattenedInheritance(inheritor); + return parent; + } + + public bool MoveInheritance(ModCollection inheritor, int from, int to) + { + if (!_directlyInheritsFrom.Move(from, to)) + return false; + + UpdateFlattenedInheritance(inheritor); + return true; + } + + public void RemoveChild(ModCollection child) + => _directlyInheritedBy.Remove(child); + + /// Contains all direct parent collections this collection inherits settings from. + public readonly IReadOnlyList DirectlyInheritsFrom + => _directlyInheritsFrom; + + /// Contains all direct child collections that inherit from this collection. + public readonly IReadOnlyList DirectlyInheritedBy + => _directlyInheritedBy; + + /// + /// Iterate over all collections inherited from in depth-first order. + /// Skip already visited collections to avoid circular dependencies. + /// + public readonly IReadOnlyList FlatHierarchy + => _flatHierarchy; + + public static void UpdateFlattenedInheritance(ModCollection parent) + { + parent.Inheritance._flatHierarchy.Clear(); + parent.Inheritance._flatHierarchy.AddRange(InheritedCollections(parent).Distinct()); + } + + /// All inherited collections in application order without filtering for duplicates. + private static IEnumerable InheritedCollections(ModCollection parent) + => parent.Inheritance.DirectlyInheritsFrom.SelectMany(InheritedCollections).Prepend(parent); +} diff --git a/Penumbra/Collections/ModCollectionSave.cs b/Penumbra/Collections/ModCollectionSave.cs new file mode 100644 index 00000000..4c41a28c --- /dev/null +++ b/Penumbra/Collections/ModCollectionSave.cs @@ -0,0 +1,98 @@ +using Newtonsoft.Json.Linq; +using Penumbra.Services; +using Newtonsoft.Json; +using Penumbra.Mods.Manager; +using Penumbra.Mods.Settings; + +namespace Penumbra.Collections; + +/// +/// Handle saving and loading a collection. +/// +internal readonly struct ModCollectionSave(ModStorage modStorage, ModCollection modCollection) : ISavable +{ + public string ToFilename(FilenameService fileNames) + => fileNames.CollectionFile(modCollection); + + public string LogName(string _) + => modCollection.Identity.AnonymizedName; + + public string TypeName + => "Collection"; + + public void Save(StreamWriter writer) + { + using var j = new JsonTextWriter(writer); + j.Formatting = Formatting.Indented; + var x = JsonSerializer.Create(new JsonSerializerSettings { Formatting = Formatting.Indented }); + j.WriteStartObject(); + j.WritePropertyName("Version"); + j.WriteValue(ModCollection.CurrentVersion); + j.WritePropertyName(nameof(ModCollectionIdentity.Id)); + j.WriteValue(modCollection.Identity.Identifier); + j.WritePropertyName(nameof(ModCollectionIdentity.Name)); + j.WriteValue(modCollection.Identity.Name); + j.WritePropertyName("Settings"); + + // Write all used and unused settings by mod directory name. + j.WriteStartObject(); + var list = new List<(string, ModSettings.SavedSettings)>(modCollection.Settings.Count + modCollection.Settings.Unused.Count); + for (var i = 0; i < modCollection.Settings.Count; ++i) + { + var settings = modCollection.GetOwnSettings(i); + if (settings != null) + list.Add((modStorage[i].ModPath.Name, new ModSettings.SavedSettings(settings, modStorage[i]))); + } + + list.AddRange(modCollection.Settings.Unused.Select(kvp => (kvp.Key, kvp.Value))); + list.Sort((a, b) => string.Compare(a.Item1, b.Item1, StringComparison.OrdinalIgnoreCase)); + + foreach (var (modDir, settings) in list) + { + j.WritePropertyName(modDir); + x.Serialize(j, settings); + } + + j.WriteEndObject(); + + // Inherit by collection name. + j.WritePropertyName("Inheritance"); + x.Serialize(j, modCollection.Inheritance.Identifiers); + j.WriteEndObject(); + } + + public static bool LoadFromFile(FileInfo file, out Guid id, out string name, out int version, out Dictionary settings, + out IReadOnlyList inheritance) + { + settings = []; + inheritance = []; + if (!file.Exists) + { + Penumbra.Log.Error("Could not read collection because file does not exist."); + name = string.Empty; + id = Guid.Empty; + version = 0; + return false; + } + + try + { + var obj = JObject.Parse(File.ReadAllText(file.FullName)); + version = obj["Version"]?.ToObject() ?? 0; + name = obj[nameof(ModCollectionIdentity.Name)]?.ToObject() ?? string.Empty; + id = obj[nameof(ModCollectionIdentity.Id)]?.ToObject() ?? (version == 1 ? Guid.NewGuid() : Guid.Empty); + // Custom deserialization that is converted with the constructor. + settings = obj["Settings"]?.ToObject>() ?? settings; + inheritance = obj["Inheritance"]?.ToObject>() ?? inheritance; + return true; + } + catch (Exception e) + { + name = string.Empty; + version = 0; + id = Guid.Empty; + Penumbra.Log.Error($"Could not read collection information from file:\n{e}"); + return false; + } + } +} diff --git a/Penumbra/Collections/ModSettingProvider.cs b/Penumbra/Collections/ModSettingProvider.cs new file mode 100644 index 00000000..3bf2f949 --- /dev/null +++ b/Penumbra/Collections/ModSettingProvider.cs @@ -0,0 +1,98 @@ +using Penumbra.Mods; +using Penumbra.Mods.Manager; +using Penumbra.Mods.Settings; +using Penumbra.Services; + +namespace Penumbra.Collections; + +public readonly struct ModSettingProvider +{ + private ModSettingProvider(IEnumerable settings, Dictionary unusedSettings) + { + _settings = settings.Select(s => s.DeepCopy()).ToList(); + _unused = unusedSettings.ToDictionary(kvp => kvp.Key, kvp => kvp.Value.DeepCopy()); + } + + public ModSettingProvider() + { } + + public static ModSettingProvider Empty(int count) + => new(Enumerable.Repeat(FullModSettings.Empty, count), []); + + public ModSettingProvider(Dictionary allSettings) + => _unused = allSettings; + + private readonly List _settings = []; + + /// Settings for deleted mods will be kept via the mods identifier (directory name). + private readonly Dictionary _unused = []; + + public int Count + => _settings.Count; + + public bool RemoveUnused(string key) + => _unused.Remove(key); + + internal void Set(Index index, ModSettings? settings) + => _settings[index] = _settings[index] with { Settings = settings }; + + internal void SetTemporary(Index index, TemporaryModSettings? settings) + => _settings[index] = _settings[index] with { TempSettings = settings }; + + internal void SetAll(Index index, FullModSettings settings) + => _settings[index] = settings; + + public IReadOnlyList Settings + => _settings; + + public IReadOnlyDictionary Unused + => _unused; + + public ModSettingProvider Clone() + => new(_settings, _unused); + + /// Add settings for a new appended mod, by checking if the mod had settings from a previous deletion. + internal bool AddMod(Mod mod) + { + if (_unused.Remove(mod.ModPath.Name, out var save)) + { + var ret = save.ToSettings(mod, out var settings); + _settings.Add(new FullModSettings(settings)); + return ret; + } + + _settings.Add(FullModSettings.Empty); + return false; + } + + /// Move settings from the current mod list to the unused mod settings. + internal void RemoveMod(Mod mod) + { + var settings = _settings[mod.Index]; + if (settings.Settings != null) + _unused[mod.ModPath.Name] = new ModSettings.SavedSettings(settings.Settings, mod); + + _settings.RemoveAt(mod.Index); + } + + /// Move all settings to unused settings for rediscovery. + internal void PrepareModDiscovery(ModStorage mods) + { + foreach (var (mod, setting) in mods.Zip(_settings).Where(s => s.Second.Settings != null)) + _unused[mod.ModPath.Name] = new ModSettings.SavedSettings(setting.Settings!, mod); + + _settings.Clear(); + } + + /// + /// Apply all mod settings from unused settings to the current set of mods. + /// Also fixes invalid settings. + /// + internal void ApplyModSettings(ModCollection parent, SaveService saver, ModStorage mods) + { + _settings.Capacity = Math.Max(_settings.Capacity, mods.Count); + var settings = this; + if (mods.Aggregate(false, (current, mod) => current | settings.AddMod(mod))) + saver.ImmediateSave(new ModCollectionSave(mods, parent)); + } +} diff --git a/Penumbra/Collections/ResolveData.cs b/Penumbra/Collections/ResolveData.cs new file mode 100644 index 00000000..bda877ff --- /dev/null +++ b/Penumbra/Collections/ResolveData.cs @@ -0,0 +1,39 @@ +namespace Penumbra.Collections; + +public readonly struct ResolveData(ModCollection collection, nint gameObject) +{ + public static readonly ResolveData Invalid = new(); + + private readonly ModCollection? _modCollection = collection; + + public ModCollection ModCollection + => _modCollection ?? ModCollection.Empty; + + public readonly nint AssociatedGameObject = gameObject; + + public bool Valid + => _modCollection != null; + + public ResolveData() + : this(null!, nint.Zero) + { } + + public ResolveData(ModCollection collection) + : this(collection, nint.Zero) + { } + + public override string ToString() + => ModCollection.Identity.Name; +} + +public static class ResolveDataExtensions +{ + public static ResolveData ToResolveData(this ModCollection collection) + => new(collection); + + public static ResolveData ToResolveData(this ModCollection collection, nint ptr) + => new(collection, ptr); + + public static unsafe ResolveData ToResolveData(this ModCollection collection, void* ptr) + => new(collection, (nint)ptr); +} diff --git a/Penumbra/CommandHandler.cs b/Penumbra/CommandHandler.cs new file mode 100644 index 00000000..b5d307ef --- /dev/null +++ b/Penumbra/CommandHandler.cs @@ -0,0 +1,694 @@ +using Dalamud.Game.Command; +using Dalamud.Game.Text.SeStringHandling; +using Dalamud.Plugin.Services; +using Dalamud.Bindings.ImGui; +using OtterGui.Classes; +using OtterGui.Services; +using Penumbra.Api.Api; +using Penumbra.Api.Enums; +using Penumbra.Collections; +using Penumbra.Collections.Manager; +using Penumbra.GameData.Actors; +using Penumbra.Interop.Services; +using Penumbra.Mods; +using Penumbra.Mods.Manager; +using Penumbra.UI; +using Penumbra.UI.Knowledge; + +namespace Penumbra; + +public class CommandHandler : IDisposable, IApiService +{ + private const string CommandName = "/penumbra"; + + private readonly ICommandManager _commandManager; + private readonly RedrawService _redrawService; + private readonly IChatGui _chat; + private readonly Configuration _config; + private readonly ConfigWindow _configWindow; + private readonly ActorManager _actors; + private readonly ModManager _modManager; + private readonly CollectionManager _collectionManager; + private readonly Penumbra _penumbra; + private readonly CollectionEditor _collectionEditor; + private readonly KnowledgeWindow _knowledgeWindow; + + public CommandHandler(IFramework framework, ICommandManager commandManager, IChatGui chat, RedrawService redrawService, + Configuration config, ConfigWindow configWindow, ModManager modManager, CollectionManager collectionManager, ActorManager actors, + Penumbra penumbra, + CollectionEditor collectionEditor, KnowledgeWindow knowledgeWindow) + { + _commandManager = commandManager; + _redrawService = redrawService; + _config = config; + _configWindow = configWindow; + _modManager = modManager; + _collectionManager = collectionManager; + _actors = actors; + _chat = chat; + _penumbra = penumbra; + _collectionEditor = collectionEditor; + _knowledgeWindow = knowledgeWindow; + framework.RunOnFrameworkThread(() => + { + if (_commandManager.Commands.ContainsKey(CommandName)) + _commandManager.RemoveHandler(CommandName); + _commandManager.AddHandler(CommandName, new CommandInfo(OnCommand) + { + HelpMessage = "Without arguments, toggles the main window. Use /penumbra help to get further command help.", + ShowInHelp = true, + }); + Penumbra.Log.Information($"Registered {CommandName} with Dalamud."); + }); + } + + public void Dispose() + => _commandManager.RemoveHandler(CommandName); + + private void OnCommand(string command, string arguments) + { + if (arguments.Length == 0) + arguments = "window"; + + var argumentList = arguments.Split(' ', 2); + arguments = argumentList.Length == 2 ? argumentList[1] : string.Empty; + + _ = argumentList[0].ToLowerInvariant() switch + { + "window" => ToggleWindow(arguments), + "enable" => SetPenumbraState(arguments, true), + "disable" => SetPenumbraState(arguments, false), + "toggle" => SetPenumbraState(arguments, null), + "reload" => Reload(arguments), + "redraw" => Redraw(arguments), + "lockui" => SetUiLockState(arguments), + "size" => SetUiMinimumSize(arguments), + "debug" => SetDebug(arguments), + "collection" => SetCollection(arguments), + "mod" => SetMod(arguments), + "bulktag" => SetTag(arguments), + "clearsettings" => ClearSettings(arguments), + "knowledge" => HandleKnowledge(arguments), + _ => PrintHelp(argumentList[0]), + }; + } + + private bool PrintHelp(string arguments) + { + if (!string.Equals(arguments, "help", StringComparison.OrdinalIgnoreCase) && arguments != "?") + _chat.Print(new SeStringBuilder().AddText("The given argument ").AddRed(arguments, true) + .AddText(" is not valid. Valid arguments are:").BuiltString); + else + _chat.Print("Valid arguments for /penumbra are:"); + + _chat.Print(new SeStringBuilder().AddCommand("window", + "Toggle the Penumbra main config window. Can be used with [on|off] to force specific state. Also used when no argument is provided.") + .BuiltString); + _chat.Print(new SeStringBuilder() + .AddCommand("enable", "Enable modding and force a redraw of all game objects if it was previously disabled.").BuiltString); + _chat.Print(new SeStringBuilder() + .AddCommand("disable", "Disable modding and force a redraw of all game objects if it was previously enabled.").BuiltString); + _chat.Print(new SeStringBuilder().AddCommand("toggle", "Toggle modding and force a redraw of all game objects.") + .BuiltString); + _chat.Print(new SeStringBuilder().AddCommand("reload", "Rediscover the mod directory and reload all mods.").BuiltString); + _chat.Print(new SeStringBuilder() + .AddCommand("redraw", "Redraw all game objects. Specify a placeholder or a name to redraw specific objects.").BuiltString); + _chat.Print(new SeStringBuilder() + .AddCommand("lockui", "Toggle the locked state of the main Penumbra window. Can be used with [on|off] to force specific state.") + .BuiltString); + _chat.Print(new SeStringBuilder().AddCommand("size", "Reset the minimum config window size to its default values.").BuiltString); + _chat.Print(new SeStringBuilder() + .AddCommand("debug", "Toggle debug mode for Penumbra. Can be used with [on|off] to force specific state.").BuiltString); + _chat.Print(new SeStringBuilder() + .AddCommand("collection", "Change your active collection setup. Use without further parameters for more detailed help.") + .BuiltString); + _chat.Print(new SeStringBuilder() + .AddCommand("mod", "Change a specific mods settings. Use without further parameters for more detailed help.").BuiltString); + _chat.Print(new SeStringBuilder() + .AddCommand("bulktag", "Change multiple mods settings based on their tags. Use without further parameters for more detailed help.") + .BuiltString); + _chat.Print(new SeStringBuilder() + .AddCommand("clearsettings", + "Clear all temporary settings applied manually through Penumbra in the current or all collections. Use with 'all' parameter for all.") + .BuiltString); + return true; + } + + private bool ClearSettings(string arguments) + { + if (arguments.Trim().ToLowerInvariant() is "all") + foreach (var collection in _collectionManager.Storage) + _collectionEditor.ClearTemporarySettings(collection); + else + _collectionEditor.ClearTemporarySettings(_collectionManager.Active.Current); + + return true; + } + + private bool ToggleWindow(string arguments) + { + var value = ParseTrueFalseToggle(arguments) ?? !_configWindow.IsOpen; + if (value == _configWindow.IsOpen) + return false; + + _configWindow.Toggle(); + return true; + } + + private bool Reload(string _) + { + _modManager.DiscoverMods(); + Print($"Reloaded Penumbra mods. You have {_modManager.Count} mods."); + return true; + } + + private bool Redraw(string arguments) + { + if (arguments.Length > 0) + _redrawService.RedrawObject(arguments, RedrawType.Redraw); + else + _redrawService.RedrawAll(RedrawType.Redraw); + + return true; + } + + private bool SetDebug(string arguments) + { + var value = ParseTrueFalseToggle(arguments) ?? !_config.DebugMode; + if (value == _config.DebugMode) + return false; + + Print(value ? "Debug mode enabled." : "Debug mode disabled."); + + _config.DebugMode = value; + _config.Save(); + return true; + } + + private bool SetPenumbraState(string _, bool? newValue) + { + var value = newValue ?? !_config.EnableMods; + + if (value == _config.EnableMods) + { + Print(value + ? "Your mods are already enabled. To disable your mods, please run the following command instead: /penumbra disable" + : "Your mods are already disabled. To enable your mods, please run the following command instead: /penumbra enable"); + return false; + } + + Print(value + ? "Your mods have been enabled." + : "Your mods have been disabled."); + return _penumbra.SetEnabled(value); + } + + private bool SetUiLockState(string arguments) + { + var value = ParseTrueFalseToggle(arguments) ?? !_config.Ephemeral.FixMainWindow; + if (value == _config.Ephemeral.FixMainWindow) + return false; + + if (value) + { + Print("Penumbra UI locked in place."); + _configWindow.Flags |= ImGuiWindowFlags.NoMove | ImGuiWindowFlags.NoResize; + } + else + { + Print("Penumbra UI unlocked."); + _configWindow.Flags &= ~(ImGuiWindowFlags.NoMove | ImGuiWindowFlags.NoResize); + } + + _config.Ephemeral.FixMainWindow = value; + _config.Ephemeral.Save(); + return true; + } + + private bool SetUiMinimumSize(string _) + { + if (_config.MinimumSize.X == Configuration.Constants.MinimumSizeX && _config.MinimumSize.Y == Configuration.Constants.MinimumSizeY) + return false; + + _config.MinimumSize.X = Configuration.Constants.MinimumSizeX; + _config.MinimumSize.Y = Configuration.Constants.MinimumSizeY; + _config.Save(); + return true; + } + + private bool SetCollection(string arguments) + { + if (arguments.Length == 0) + { + _chat.Print(new SeStringBuilder().AddText("Use with /penumbra collection ").AddBlue("[Collection Type]") + .AddText(" | ").AddYellow("[Collection Name]") + .AddText(" | ").AddGreen("").BuiltString); + _chat.Print(new SeStringBuilder().AddText(" 》 Valid Collection Types are ").AddBlue("Base").AddText(", ") + .AddBlue("Ui").AddText(", ") + .AddBlue("Selected").AddText(", ") + .AddBlue("Individual").AddText(", and all those selectable in Character Groups.").BuiltString); + _chat.Print(new SeStringBuilder().AddText(" 》 Valid Collection Names are ").AddYellow("None") + .AddText(", all collections you have created by their full names, and ").AddYellow("Delete") + .AddText(" to remove assignments (not valid for all types).") + .BuiltString); + _chat.Print(new SeStringBuilder().AddText(" 》 If the type is ").AddBlue("Individual") + .AddText(" you need to specify an individual with an identifier of the form:").BuiltString); + _chat.Print(new SeStringBuilder().AddText(" 》》》 ").AddGreen("").AddText(" or ").AddGreen("") + .AddText(" or ").AddGreen("") + .AddText(" or ").AddGreen("") + .AddText(" as placeholders for your character, your target, your mouseover or your focus, if they exist.").BuiltString); + _chat.Print(new SeStringBuilder().AddText(" 》》》 ").AddGreen("p").AddText(" | ") + .AddWhite("[Player Name]@") + .AddText(", if no @ is provided, Any World is used.").BuiltString); + _chat.Print(new SeStringBuilder().AddText(" 》》》 ").AddGreen("r").AddText(" | ").AddWhite("[Retainer Name]") + .BuiltString); + _chat.Print(new SeStringBuilder().AddText(" 》》》 ").AddGreen("n").AddText(" | ").AddPurple("[NPC Type]") + .AddText(" : ") + .AddRed("[NPC Name]").AddText(", where NPC Type can be ").AddInitialPurple("Mount").AddInitialPurple("Companion") + .AddInitialPurple("Accessory") + .AddInitialPurple("Event NPC").AddText("or ") + .AddInitialPurple("Battle NPC", false).AddText(".").BuiltString); + _chat.Print(new SeStringBuilder().AddText(" 》》》 ").AddGreen("o").AddText(" | ").AddPurple("[NPC Type]") + .AddText(" : ") + .AddRed("[NPC Name]").AddText(" | ").AddWhite("[Player Name]@").AddText(".").BuiltString); + return true; + } + + var split = arguments.Split('|', 3, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + var typeName = split[0]; + + if (!CollectionTypeExtensions.TryParse(typeName, out var type)) + { + _chat.Print(new SeStringBuilder().AddText("The argument ").AddRed(typeName, true) + .AddText(" is not a valid collection type.").BuiltString); + return false; + } + + if (split.Length == 1) + { + _chat.Print("There was no collection name provided."); + return false; + } + + if (!GetModCollection(split[1], out var collection)) + return false; + + var identifiers = Array.Empty(); + if (type is CollectionType.Individual) + { + if (split.Length == 2) + { + _chat.Print( + "Setting an individual collection requires a collection name and an identifier, but no identifier was provided."); + return false; + } + + try + { + if (_redrawService.GetName(split[2].ToLowerInvariant(), out var obj)) + { + var identifier = _actors.FromObject(obj, false, true, true); + if (!identifier.IsValid) + { + _chat.Print(new SeStringBuilder().AddText("The placeholder ").AddGreen(split[2]) + .AddText(" did not resolve to a game object with a valid identifier.").BuiltString); + return false; + } + + identifiers = new[] + { + identifier, + }; + } + else + { + identifiers = _actors.FromUserString(split[2], false); + } + } + catch (ActorIdentifierFactory.IdentifierParseError e) + { + _chat.Print(new SeStringBuilder().AddText("The argument ").AddRed(split[2], true) + .AddText($" could not be converted to an identifier. {e.Message}") + .BuiltString); + return false; + } + } + + var anySuccess = false; + foreach (var identifier in identifiers.Distinct().DefaultIfEmpty(ActorIdentifier.Invalid)) + { + var oldCollection = _collectionManager.Active.ByType(type, identifier); + if (collection == oldCollection) + { + _chat.Print(collection == null + ? $"The {type.ToName()} Collection{(identifier.IsValid ? $" for {identifier}" : string.Empty)} is already unassigned" + : $"{collection.Identity.Name} already is the {type.ToName()} Collection{(identifier.IsValid ? $" for {identifier}." : ".")}"); + continue; + } + + var individualIndex = _collectionManager.Active.Individuals.Index(identifier); + + if (oldCollection == null) + { + if (type.IsSpecial()) + { + _collectionManager.Active.CreateSpecialCollection(type); + } + else if (identifier.IsValid) + { + var identifierGroup = _collectionManager.Active.Individuals.GetGroup(identifier); + individualIndex = _collectionManager.Active.Individuals.Count; + _collectionManager.Active.CreateIndividualCollection(identifierGroup); + } + } + else if (collection == null) + { + if (type.IsSpecial()) + { + _collectionManager.Active.RemoveSpecialCollection(type); + } + else if (individualIndex >= 0) + { + _collectionManager.Active.RemoveIndividualCollection(individualIndex); + } + else + { + _chat.Print( + $"Can not remove the {type.ToName()} Collection assignment {(identifier.IsValid ? $" for {identifier}." : ".")}"); + continue; + } + + Print( + $"Removed {oldCollection.Identity.Name} as {type.ToName()} Collection assignment {(identifier.IsValid ? $" for {identifier}." : ".")}"); + anySuccess = true; + continue; + } + + _collectionManager.Active.SetCollection(collection!, type, individualIndex); + Print($"Assigned {collection!.Identity.Name} as {type.ToName()} Collection{(identifier.IsValid ? $" for {identifier}." : ".")}"); + } + + return anySuccess; + } + + private bool SetMod(string arguments) + { + if (arguments.Length == 0) + { + var seString = new SeStringBuilder() + .AddText("Use with /penumbra mod ").AddBlue("[enable|disable|inherit|toggle|").AddGreen("setting").AddBlue("]").AddText(" ") + .AddYellow("[Collection Name]") + .AddText(" | ") + .AddPurple("[Mod Name or Mod Directory Name]") + .AddGreen(" <| [Option Group Name] | [Option1;Option2;...]>"); + _chat.Print(seString.BuiltString); + return true; + } + + var split = arguments.Split(' ', 2, StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries); + var nameSplit = split.Length != 2 + ? [] + : split[1].Split('|', 2, StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries); + if (nameSplit.Length != 2) + { + _chat.Print("Not enough arguments provided."); + return false; + } + + var state = ConvertToSettingState(split[0]); + if (state == -1) + { + _chat.Print(new SeStringBuilder().AddRed(split[0], true).AddText(" is not a valid type of setting.").BuiltString); + return false; + } + + if (!GetModCollection(nameSplit[0], out var collection) || collection == ModCollection.Empty) + return false; + + var groupName = string.Empty; + var optionNames = Array.Empty(); + if (state is 4) + { + var split2 = nameSplit[1].Split('|', 3, StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries); + if (split2.Length < 2) + { + _chat.Print( + "Not enough arguments for changing settings provided. Please add a group name and a list of setting names - which can be empty for multi options."); + return false; + } + + nameSplit[1] = split2[0]; + groupName = split2[1]; + if (split2.Length == 3) + optionNames = split2[2].Split(';', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries); + } + + if (!_modManager.TryGetMod(nameSplit[1], nameSplit[1], out var mod)) + { + _chat.Print(new SeStringBuilder().AddText("The mod ").AddRed(nameSplit[1], true).AddText(" does not exist.") + .BuiltString); + return false; + } + + if (state < 4) + { + if (HandleModState(state, collection!, mod)) + return true; + + _chat.Print(new SeStringBuilder().AddText("Mod ").AddPurple(mod.Name, true) + .AddText("already had the desired state in collection ") + .AddYellow(collection!.Identity.Name, true).AddText(".").BuiltString); + return false; + } + + switch (ModSettingsApi.ConvertModSetting(mod, groupName, optionNames, out var groupIndex, out var setting)) + { + case PenumbraApiEc.OptionGroupMissing: + _chat.Print(new SeStringBuilder().AddText("The mod ").AddRed(nameSplit[1], true).AddText(" has no group ") + .AddGreen(groupName, true).AddText(".").BuiltString); + return false; + case PenumbraApiEc.OptionMissing: + _chat.Print(new SeStringBuilder().AddText("Not all set options in the mod ").AddRed(nameSplit[1], true) + .AddText(" could be found in group ").AddGreen(groupName, true).AddText(".").BuiltString); + return false; + case PenumbraApiEc.Success: + _collectionEditor.SetModSetting(collection!, mod, groupIndex, setting); + Print(() => new SeStringBuilder().AddText("Changed settings of group ").AddGreen(groupName, true).AddText(" in mod ") + .AddPurple(mod.Name, true).AddText(" in collection ") + .AddYellow(collection!.Identity.Name, true).AddText(".").BuiltString); + return true; + } + + return false; + } + + private enum TagType + { + Local, + Mod, + Both, + } + + private bool SetTag(string arguments) + { + if (arguments.Length == 0) + { + var seString = new SeStringBuilder() + .AddText("Use with /penumbra bulktag ").AddBlue("[enable|disable|toggle|inherit]").AddText(" ").AddYellow("[Collection Name]") + .AddText(" | ") + .AddPurple("[Tag]"); + _chat.Print(seString.BuiltString); + var tagString = new SeStringBuilder() + .AddText(" 》 ") + .AddPurple("[Tag]") + .AddText(" is only Local tags by default, but can be prefixed with '") + .AddWhite("b:") + .AddText("' for both types of tags or '") + .AddWhite("m:") + .AddText("' for only Mod tags."); + _chat.Print(tagString.BuiltString); + return true; + } + + var split = arguments.Split(' ', 2, StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries); + var nameSplit = split.Length != 2 + ? Array.Empty() + : split[1].Split('|', 2, StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries); + if (nameSplit.Length != 2) + { + _chat.Print("Not enough arguments provided."); + return false; + } + + var state = ConvertToSettingState(split[0]); + + if (state == -1) + { + _chat.Print(new SeStringBuilder().AddRed(split[0], true).AddText(" is not a valid type of setting.").BuiltString); + return false; + } + + if (!GetModCollection(nameSplit[0], out var collection) || collection == ModCollection.Empty) + return false; + + var tagType = nameSplit[1].Length < 3 || nameSplit[1][1] != ':' + ? TagType.Local + : nameSplit[1][0] switch + { + 'b' => TagType.Both, + 'm' => TagType.Mod, + _ => TagType.Local, + }; + var tag = tagType is TagType.Local ? nameSplit[1] : nameSplit[1][2..]; + + var mods = tagType switch + { + TagType.Local => _modManager.Where(m => m.LocalTags.Contains(tag, StringComparer.OrdinalIgnoreCase)).ToList(), + TagType.Mod => _modManager.Where(m => m.ModTags.Contains(tag, StringComparer.OrdinalIgnoreCase)).ToList(), + _ => _modManager.Where(m => m.LocalTags.Concat(m.ModTags).Contains(tag, StringComparer.OrdinalIgnoreCase)).ToList(), + }; + + if (mods.Count == 0) + { + _chat.Print(new SeStringBuilder().AddText("The tag ").AddRed(tag, true).AddText(" does not match any mods.") + .BuiltString); + return false; + } + + var changes = false; + foreach (var mod in mods) + changes |= HandleModState(state, collection!, mod); + + if (!changes) + Print(() => new SeStringBuilder().AddText("No mod states were changed in collection ").AddYellow(collection!.Identity.Name, true) + .AddText(".").BuiltString); + + return true; + } + + private bool GetModCollection(string collectionName, out ModCollection? collection) + { + var lowerName = collectionName.ToLowerInvariant(); + if (lowerName == "delete") + { + collection = null; + return true; + } + + collection = string.Equals(lowerName, ModCollection.Empty.Identity.Name, StringComparison.OrdinalIgnoreCase) + ? ModCollection.Empty + : _collectionManager.Storage.ByIdentifier(lowerName, out var c) + ? c + : null; + if (collection != null) + return true; + + _chat.Print(new SeStringBuilder().AddText("The collection ").AddRed(collectionName, true).AddText(" does not exist.") + .BuiltString); + return false; + } + + private static bool? ParseTrueFalseToggle(string value) + => value.ToLowerInvariant() switch + { + "0" => false, + "false" => false, + "off" => false, + "disable" => false, + "disabled" => false, + + "1" => true, + "true" => true, + "on" => true, + "enable" => true, + "enabled" => true, + + _ => null, + }; + + private static int ConvertToSettingState(string text) + => text.ToLowerInvariant() switch + { + "enable" => 0, + "enabled" => 0, + "disable" => 1, + "disabled" => 1, + "toggle" => 2, + "inherit" => 3, + "inherited" => 3, + "setting" => 4, + "settings" => 4, + _ => -1, + }; + + private bool HandleModState(int settingState, ModCollection collection, Mod mod) + { + var settings = collection.GetOwnSettings(mod.Index); + switch (settingState) + { + case 0: + if (!_collectionEditor.SetModState(collection, mod, true)) + return false; + + Print(() => new SeStringBuilder().AddText("Enabled mod ").AddPurple(mod.Name, true).AddText(" in collection ") + .AddYellow(collection.Identity.Name, true) + .AddText(".").BuiltString); + return true; + + case 1: + if (!_collectionEditor.SetModState(collection, mod, false)) + return false; + + Print(() => new SeStringBuilder().AddText("Disabled mod ").AddPurple(mod.Name, true).AddText(" in collection ") + .AddYellow(collection.Identity.Name, true) + .AddText(".").BuiltString); + return true; + + case 2: + var setting = !(settings?.Enabled ?? false); + if (!_collectionEditor.SetModState(collection, mod, setting)) + return false; + + Print(() => new SeStringBuilder().AddText(setting ? "Enabled mod " : "Disabled mod ").AddPurple(mod.Name, true) + .AddText(" in collection ") + .AddYellow(collection.Identity.Name, true) + .AddText(".").BuiltString); + return true; + + case 3: + if (!_collectionEditor.SetModInheritance(collection, mod, true)) + return false; + + Print(() => new SeStringBuilder().AddText("Set mod ").AddPurple(mod.Name, true).AddText(" in collection ") + .AddYellow(collection.Identity.Name, true) + .AddText(" to inherit.").BuiltString); + return true; + } + + return false; + } + + private void Print(string text) + { + if (_config.PrintSuccessfulCommandsToChat) + _chat.Print(text); + } + + private void Print(DefaultInterpolatedStringHandler text) + { + if (_config.PrintSuccessfulCommandsToChat) + _chat.Print(text.ToStringAndClear()); + } + + private void Print(Func text) + { + if (_config.PrintSuccessfulCommandsToChat) + _chat.Print(text()); + } + + private bool HandleKnowledge(string arguments) + { + _knowledgeWindow.Toggle(); + return true; + } +} diff --git a/Penumbra/Communication/ChangedItemClick.cs b/Penumbra/Communication/ChangedItemClick.cs new file mode 100644 index 00000000..2d27f36a --- /dev/null +++ b/Penumbra/Communication/ChangedItemClick.cs @@ -0,0 +1,25 @@ +using OtterGui.Classes; +using Penumbra.Api.Api; +using Penumbra.Api.Enums; +using Penumbra.GameData.Data; + +namespace Penumbra.Communication; + +/// +/// Triggered when a Changed Item in Penumbra is clicked. +/// +/// Parameter is the clicked mouse button. +/// Parameter is the clicked object data if any. +/// +/// +public sealed class ChangedItemClick() : EventWrapper(nameof(ChangedItemClick)) +{ + public enum Priority + { + /// + Default = 0, + + /// + Link = 1, + } +} diff --git a/Penumbra/Communication/ChangedItemHover.cs b/Penumbra/Communication/ChangedItemHover.cs new file mode 100644 index 00000000..92d770f7 --- /dev/null +++ b/Penumbra/Communication/ChangedItemHover.cs @@ -0,0 +1,26 @@ +using OtterGui.Classes; +using Penumbra.Api.Api; +using Penumbra.GameData.Data; + +namespace Penumbra.Communication; + +/// +/// Triggered when a Changed Item in Penumbra is hovered. +/// +/// Parameter is the hovered object data if any. +/// +/// +public sealed class ChangedItemHover() : EventWrapper(nameof(ChangedItemHover)) +{ + public enum Priority + { + /// + Default = 0, + + /// + Link = 1, + } + + public bool HasTooltip + => HasSubscribers; +} diff --git a/Penumbra/Communication/CharacterUtilityFinished.cs b/Penumbra/Communication/CharacterUtilityFinished.cs new file mode 100644 index 00000000..fbeeb8a7 --- /dev/null +++ b/Penumbra/Communication/CharacterUtilityFinished.cs @@ -0,0 +1,23 @@ +using OtterGui.Classes; +using Penumbra.Api; +using Penumbra.Interop.Services; + +namespace Penumbra.Communication; + +/// +/// Triggered when the Character Utility becomes ready. +/// +public sealed class CharacterUtilityFinished() : EventWrapper(nameof(CharacterUtilityFinished)) +{ + public enum Priority + { + /// + OnFinishedLoading = int.MaxValue, + + /// + IpcProvider = int.MinValue, + + /// + CollectionCacheManager = 0, + } +} diff --git a/Penumbra/Communication/CollectionChange.cs b/Penumbra/Communication/CollectionChange.cs new file mode 100644 index 00000000..2788177d --- /dev/null +++ b/Penumbra/Communication/CollectionChange.cs @@ -0,0 +1,53 @@ +using OtterGui.Classes; +using Penumbra.Collections; +using Penumbra.Collections.Manager; + +namespace Penumbra.Communication; + +/// +/// Triggered whenever collection setup is changed. +/// +/// Parameter is the type of the changed collection. (Inactive or Temporary for additions or deletions) +/// Parameter is the old collection, or null on additions. +/// Parameter is the new collection, or null on deletions. +/// Parameter is the display name for Individual collections or an empty string otherwise. +/// +public sealed class CollectionChange() + : EventWrapper(nameof(CollectionChange)) +{ + public enum Priority + { + /// + DalamudSubstitutionProvider = -3, + + /// + CollectionCacheManager = -2, + + /// + ActiveCollections = -1, + + /// + TempModManager = 0, + + /// + InheritanceManager = 0, + + /// + IdentifiedCollectionCache = 0, + + /// + ItemSwapTab = 0, + + /// + CollectionSelector = 0, + + /// + IndividualAssignmentUi = 0, + + /// + ModFileSystemSelector = 0, + + /// + ModSelection = 10, + } +} diff --git a/Penumbra/Communication/CollectionInheritanceChanged.cs b/Penumbra/Communication/CollectionInheritanceChanged.cs new file mode 100644 index 00000000..30af2b20 --- /dev/null +++ b/Penumbra/Communication/CollectionInheritanceChanged.cs @@ -0,0 +1,30 @@ +using OtterGui.Classes; +using Penumbra.Collections; + +namespace Penumbra.Communication; + +/// +/// Triggered whenever a collections inheritances change. +/// +/// Parameter is the collection whose ancestors were changed. +/// Parameter is whether the change was itself inherited, i.e. if it happened in a direct parent (false) or a more removed ancestor (true). +/// +/// +public sealed class CollectionInheritanceChanged() + : EventWrapper(nameof(CollectionInheritanceChanged)) +{ + public enum Priority + { + /// + CollectionCacheManager = 0, + + /// + ItemSwapTab = 0, + + /// + ModFileSystemSelector = 0, + + /// + ModSelection = 10, + } +} diff --git a/Penumbra/Communication/CreatedCharacterBase.cs b/Penumbra/Communication/CreatedCharacterBase.cs new file mode 100644 index 00000000..8992f9fc --- /dev/null +++ b/Penumbra/Communication/CreatedCharacterBase.cs @@ -0,0 +1,21 @@ +using OtterGui.Classes; +using Penumbra.Api; +using Penumbra.Api.Api; +using Penumbra.Collections; + +namespace Penumbra.Communication; + +/// +/// Parameter is the game object for which a draw object is created. +/// Parameter is the applied collection. +/// Parameter is the created draw object. +/// +public sealed class CreatedCharacterBase() + : EventWrapper(nameof(CreatedCharacterBase)) +{ + public enum Priority + { + /// + Api = int.MinValue, + } +} diff --git a/Penumbra/Communication/CreatingCharacterBase.cs b/Penumbra/Communication/CreatingCharacterBase.cs new file mode 100644 index 00000000..51d55868 --- /dev/null +++ b/Penumbra/Communication/CreatingCharacterBase.cs @@ -0,0 +1,27 @@ +using OtterGui.Classes; +using Penumbra.Api.Api; +using Penumbra.Services; + +namespace Penumbra.Communication; + +/// +/// Triggered whenever a character base draw object is being created by the game. +/// +/// Parameter is the game object for which a draw object is created. +/// Parameter is the name of the applied collection. +/// Parameter is a pointer to the model id (an uint). +/// Parameter is a pointer to the customize array. +/// Parameter is a pointer to the equip data array. +/// +public sealed class CreatingCharacterBase() + : EventWrapper(nameof(CreatingCharacterBase)) +{ + public enum Priority + { + /// + Api = 0, + + /// + CrashHandler = 0, + } +} diff --git a/Penumbra/Communication/EnabledChanged.cs b/Penumbra/Communication/EnabledChanged.cs new file mode 100644 index 00000000..846b1a58 --- /dev/null +++ b/Penumbra/Communication/EnabledChanged.cs @@ -0,0 +1,23 @@ +using OtterGui.Classes; +using Penumbra.Api; +using Penumbra.Api.IpcSubscribers; + +namespace Penumbra.Communication; + +/// +/// Triggered when the general Enabled state of Penumbra is changed. +/// +/// Parameter is whether Penumbra is now Enabled (true) or Disabled (false). +/// +/// +public sealed class EnabledChanged() : EventWrapper(nameof(EnabledChanged)) +{ + public enum Priority + { + /// + Api = int.MinValue, + + /// + DalamudSubstitutionProvider = 0, + } +} diff --git a/Penumbra/Communication/ModDataChanged.cs b/Penumbra/Communication/ModDataChanged.cs new file mode 100644 index 00000000..ffa43d43 --- /dev/null +++ b/Penumbra/Communication/ModDataChanged.cs @@ -0,0 +1,30 @@ +using OtterGui.Classes; +using Penumbra.Mods; +using Penumbra.Mods.Manager; + +namespace Penumbra.Communication; + +/// +/// Triggered whenever mod meta data or local data is changed. +/// +/// Parameter is the type of data change for the mod, which can be multiple flags. +/// Parameter is the changed mod. +/// Parameter is the old name of the mod in case of a name change, and null otherwise. +/// +public sealed class ModDataChanged() : EventWrapper(nameof(ModDataChanged)) +{ + public enum Priority + { + /// + ModFileSystemSelector = -10, + + /// + ModCacheManager = 0, + + /// + ModFileSystem = 0, + + /// + ModPanelHeader = 0, + } +} diff --git a/Penumbra/Communication/ModDirectoryChanged.cs b/Penumbra/Communication/ModDirectoryChanged.cs new file mode 100644 index 00000000..9c64573f --- /dev/null +++ b/Penumbra/Communication/ModDirectoryChanged.cs @@ -0,0 +1,23 @@ +using OtterGui.Classes; +using Penumbra.Api.Api; + +namespace Penumbra.Communication; + +/// +/// Triggered whenever the mod root directory changes. +/// +/// Parameter is the full path of the new directory. +/// Parameter is whether the new directory is valid. +/// +/// +public sealed class ModDirectoryChanged() : EventWrapper(nameof(ModDirectoryChanged)) +{ + public enum Priority + { + /// + Api = 0, + + /// + FileDialogService = 0, + } +} diff --git a/Penumbra/Communication/ModDiscoveryFinished.cs b/Penumbra/Communication/ModDiscoveryFinished.cs new file mode 100644 index 00000000..759ea42e --- /dev/null +++ b/Penumbra/Communication/ModDiscoveryFinished.cs @@ -0,0 +1,25 @@ +using OtterGui.Classes; + +namespace Penumbra.Communication; + +/// Triggered whenever a new mod discovery has finished. +public sealed class ModDiscoveryFinished() : EventWrapper(nameof(ModDiscoveryFinished)) +{ + public enum Priority + { + /// + ModFileSystemSelector = -200, + + /// + CollectionCacheManager = -100, + + /// + CollectionStorage = 0, + + /// + ModCacheManager = 0, + + /// + ModFileSystem = 0, + } +} diff --git a/Penumbra/Communication/ModDiscoveryStarted.cs b/Penumbra/Communication/ModDiscoveryStarted.cs new file mode 100644 index 00000000..5cafd1ea --- /dev/null +++ b/Penumbra/Communication/ModDiscoveryStarted.cs @@ -0,0 +1,19 @@ +using OtterGui.Classes; + +namespace Penumbra.Communication; + +/// Triggered whenever mods are prepared to be rediscovered. +public sealed class ModDiscoveryStarted() : EventWrapper(nameof(ModDiscoveryStarted)) +{ + public enum Priority + { + /// + CollectionCacheManager = 0, + + /// + CollectionStorage = 0, + + /// + ModFileSystemSelector = 200, + } +} diff --git a/Penumbra/Communication/ModFileChanged.cs b/Penumbra/Communication/ModFileChanged.cs new file mode 100644 index 00000000..8cda48e9 --- /dev/null +++ b/Penumbra/Communication/ModFileChanged.cs @@ -0,0 +1,29 @@ +using OtterGui.Classes; +using Penumbra.Api; +using Penumbra.Api.Api; +using Penumbra.Mods; +using Penumbra.Mods.Editor; + +namespace Penumbra.Communication; + +/// +/// Triggered whenever an existing file in a mod is overwritten by Penumbra. +/// +/// Parameter is the changed mod. +/// Parameter file registry of the changed file. +/// +public sealed class ModFileChanged() + : EventWrapper(nameof(ModFileChanged)) +{ + public enum Priority + { + /// + Api = int.MinValue, + + /// + RedrawService = -50, + + /// + CollectionStorage = 0, + } +} diff --git a/Penumbra/Communication/ModOptionChanged.cs b/Penumbra/Communication/ModOptionChanged.cs new file mode 100644 index 00000000..67f2c0c3 --- /dev/null +++ b/Penumbra/Communication/ModOptionChanged.cs @@ -0,0 +1,42 @@ +using OtterGui.Classes; +using Penumbra.Api.Api; +using Penumbra.Mods; +using Penumbra.Mods.Groups; +using Penumbra.Mods.Manager; +using Penumbra.Mods.Manager.OptionEditor; +using Penumbra.Mods.SubMods; +using static Penumbra.Communication.ModOptionChanged; + +namespace Penumbra.Communication; + +/// +/// Triggered whenever an option of a mod is changed inside the mod. +/// +/// Parameter is the type option change. +/// Parameter is the changed mod. +/// Parameter is the changed group inside the mod. +/// Parameter is the changed option inside the group or null if it does not concern a specific option. +/// Parameter is the changed data container inside the group or null if it does not concern a specific data container. +/// Parameter is the index of the group or option moved or deleted from. +/// +public sealed class ModOptionChanged() + : EventWrapper(nameof(ModOptionChanged)) +{ + public enum Priority + { + /// + Api = int.MinValue, + + /// + CollectionCacheManager = -100, + + /// + ModCacheManager = 0, + + /// + ItemSwapTab = 0, + + /// + CollectionStorage = 100, + } +} diff --git a/Penumbra/Communication/ModPathChanged.cs b/Penumbra/Communication/ModPathChanged.cs new file mode 100644 index 00000000..efe59482 --- /dev/null +++ b/Penumbra/Communication/ModPathChanged.cs @@ -0,0 +1,63 @@ +using OtterGui.Classes; +using Penumbra.Api; +using Penumbra.Api.Api; +using Penumbra.Mods; +using Penumbra.Mods.Manager; +using Penumbra.Services; + +namespace Penumbra.Communication; + +/// +/// Triggered whenever a mod is added, deleted, moved or reloaded. +/// +/// Parameter is the type of change. +/// Parameter is the changed mod. +/// Parameter is the old directory on deletion, move or reload and null on addition. +/// Parameter is the new directory on addition, move or reload and null on deletion. +/// +/// +public sealed class ModPathChanged() + : EventWrapper(nameof(ModPathChanged)) +{ + public enum Priority + { + /// + PcpService = int.MinValue, + + /// + ApiMods = int.MinValue + 1, + + /// + ApiModSettings = int.MinValue + 1, + + /// + EphemeralConfig = -500, + + /// + CollectionCacheManagerAddition = -100, + + /// + ModCacheManager = 0, + + /// + ModExportManager = 0, + + /// + ModFileSystem = 0, + + /// + ModManager = 0, + + /// + ModMerger = 0, + + /// + ModEditWindow = 0, + + /// + CollectionStorage = 10, + + /// + CollectionCacheManagerRemoval = 100, + } +} diff --git a/Penumbra/Communication/ModSettingChanged.cs b/Penumbra/Communication/ModSettingChanged.cs new file mode 100644 index 00000000..d4bf00be --- /dev/null +++ b/Penumbra/Communication/ModSettingChanged.cs @@ -0,0 +1,41 @@ +using OtterGui.Classes; +using Penumbra.Api.Api; +using Penumbra.Api.Enums; +using Penumbra.Collections; +using Penumbra.Mods; +using Penumbra.Mods.Settings; + +namespace Penumbra.Communication; + +/// +/// Triggered whenever a mod setting is changed. +/// +/// Parameter is the collection in which the setting was changed. +/// Parameter is the type of change. +/// Parameter is the mod the setting was changed for, unless it was a multi-change. +/// Parameter is the old value of the setting before the change as Setting. +/// Parameter is the index of the changed group if the change type is Setting. +/// Parameter is whether the change was inherited from another collection. +/// +/// +public sealed class ModSettingChanged() + : EventWrapper(nameof(ModSettingChanged)) +{ + public enum Priority + { + /// + Api = int.MinValue, + + /// + CollectionCacheManager = 0, + + /// + ItemSwapTab = 0, + + /// + ModFileSystemSelector = 0, + + /// + ModSelection = 10, + } +} diff --git a/Penumbra/Communication/MtrlLoaded.cs b/Penumbra/Communication/MtrlLoaded.cs new file mode 100644 index 00000000..224438e5 --- /dev/null +++ b/Penumbra/Communication/MtrlLoaded.cs @@ -0,0 +1,16 @@ +using OtterGui.Classes; + +namespace Penumbra.Communication; + +/// +/// Parameter is the material resource handle for which the shader package has been loaded. +/// Parameter is the associated game object. +/// +public sealed class MtrlLoaded() : EventWrapper(nameof(MtrlLoaded)) +{ + public enum Priority + { + /// + ShaderReplacementFixer = 0, + } +} diff --git a/Penumbra/Communication/PcpCreation.cs b/Penumbra/Communication/PcpCreation.cs new file mode 100644 index 00000000..ca0cfcf6 --- /dev/null +++ b/Penumbra/Communication/PcpCreation.cs @@ -0,0 +1,21 @@ +using Newtonsoft.Json.Linq; +using OtterGui.Classes; + +namespace Penumbra.Communication; + +/// +/// Triggered when the character.json file for a .pcp file is written. +/// +/// Parameter is the JObject that gets written to file. +/// Parameter is the object index of the game object this is written for. +/// Parameter is the full path to the directory being set up for the PCP creation. +/// +/// +public sealed class PcpCreation() : EventWrapper(nameof(PcpCreation)) +{ + public enum Priority + { + /// + ModsApi = int.MinValue, + } +} diff --git a/Penumbra/Communication/PcpParsing.cs b/Penumbra/Communication/PcpParsing.cs new file mode 100644 index 00000000..95b78951 --- /dev/null +++ b/Penumbra/Communication/PcpParsing.cs @@ -0,0 +1,21 @@ +using Newtonsoft.Json.Linq; +using OtterGui.Classes; + +namespace Penumbra.Communication; + +/// +/// Triggered when the character.json file for a .pcp file is parsed and applied. +/// +/// Parameter is parsed JObject that contains the data. +/// Parameter is the identifier of the created mod. +/// Parameter is the GUID of the created collection. +/// +/// +public sealed class PcpParsing() : EventWrapper(nameof(PcpParsing)) +{ + public enum Priority + { + /// + ModsApi = int.MinValue, + } +} diff --git a/Penumbra/Communication/PostEnabledDraw.cs b/Penumbra/Communication/PostEnabledDraw.cs new file mode 100644 index 00000000..e21f0183 --- /dev/null +++ b/Penumbra/Communication/PostEnabledDraw.cs @@ -0,0 +1,19 @@ +using OtterGui.Classes; +using Penumbra.Api.Api; + +namespace Penumbra.Communication; + +/// +/// Triggered after the Enabled Checkbox line in settings is drawn, but before options are drawn. +/// +/// Parameter is the identifier (directory name) of the currently selected mod. +/// +/// +public sealed class PostEnabledDraw() : EventWrapper(nameof(PostEnabledDraw)) +{ + public enum Priority + { + /// + Default = 0, + } +} diff --git a/Penumbra/Communication/PostSettingsPanelDraw.cs b/Penumbra/Communication/PostSettingsPanelDraw.cs new file mode 100644 index 00000000..525ac73e --- /dev/null +++ b/Penumbra/Communication/PostSettingsPanelDraw.cs @@ -0,0 +1,19 @@ +using OtterGui.Classes; +using Penumbra.Api.Api; + +namespace Penumbra.Communication; + +/// +/// Triggered after the settings panel is drawn. +/// +/// Parameter is the identifier (directory name) of the currently selected mod. +/// +/// +public sealed class PostSettingsPanelDraw() : EventWrapper(nameof(PostSettingsPanelDraw)) +{ + public enum Priority + { + /// + Default = 0, + } +} diff --git a/Penumbra/Communication/PreSettingsPanelDraw.cs b/Penumbra/Communication/PreSettingsPanelDraw.cs new file mode 100644 index 00000000..33f6b4e1 --- /dev/null +++ b/Penumbra/Communication/PreSettingsPanelDraw.cs @@ -0,0 +1,19 @@ +using OtterGui.Classes; +using Penumbra.Api.Api; + +namespace Penumbra.Communication; + +/// +/// Triggered before the settings panel is drawn. +/// +/// Parameter is the identifier (directory name) of the currently selected mod. +/// +/// +public sealed class PreSettingsPanelDraw() : EventWrapper(nameof(PreSettingsPanelDraw)) +{ + public enum Priority + { + /// + Default = 0, + } +} diff --git a/Penumbra/Communication/PreSettingsTabBarDraw.cs b/Penumbra/Communication/PreSettingsTabBarDraw.cs new file mode 100644 index 00000000..e1d67297 --- /dev/null +++ b/Penumbra/Communication/PreSettingsTabBarDraw.cs @@ -0,0 +1,22 @@ +using OtterGui.Classes; +using Penumbra.Api.Api; +using Penumbra.Api.IpcSubscribers; + +namespace Penumbra.Communication; + +/// +/// Triggered before the settings tab bar for a mod is drawn, after the title group is drawn. +/// +/// Parameter is the identifier (directory name) of the currently selected mod. +/// is the total width of the header group. +/// is the width of the title box. +/// +/// +public sealed class PreSettingsTabBarDraw() : EventWrapper(nameof(PreSettingsTabBarDraw)) +{ + public enum Priority + { + /// + Default = 0, + } +} diff --git a/Penumbra/Communication/ResolvedFileChanged.cs b/Penumbra/Communication/ResolvedFileChanged.cs new file mode 100644 index 00000000..0c91a18b --- /dev/null +++ b/Penumbra/Communication/ResolvedFileChanged.cs @@ -0,0 +1,39 @@ +using OtterGui.Classes; +using Penumbra.Collections; +using Penumbra.Mods.Editor; +using Penumbra.String.Classes; + +namespace Penumbra.Communication; + +/// +/// Triggered whenever a redirection in a mod collection cache is manipulated. +/// +/// Parameter is collection with a changed cache. +/// Parameter is the type of change. +/// Parameter is the game path to be redirected or empty for FullRecompute. +/// Parameter is the new redirection path or empty for Removed or FullRecompute +/// Parameter is the old redirection path for Replaced, or empty. +/// Parameter is the mod responsible for the new redirection if any. +/// +public sealed class ResolvedFileChanged() + : EventWrapper( + nameof(ResolvedFileChanged)) +{ + public enum Type + { + Added, + Removed, + Replaced, + FullRecomputeStart, + FullRecomputeFinished, + } + + public enum Priority + { + /// + DalamudSubstitutionProvider = 0, + + /// + SchedulerResourceManagementService = 0, + } +} diff --git a/Penumbra/Communication/SelectTab.cs b/Penumbra/Communication/SelectTab.cs new file mode 100644 index 00000000..cb7e2e56 --- /dev/null +++ b/Penumbra/Communication/SelectTab.cs @@ -0,0 +1,21 @@ +using OtterGui.Classes; +using Penumbra.Api.Enums; +using Penumbra.Mods; + +namespace Penumbra.Communication; + +/// +/// Trigger to select a tab and mod in the Config Window. +/// +/// Parameter is the selected tab. +/// Parameter is the selected mod, if any. +/// +/// +public sealed class SelectTab() : EventWrapper(nameof(SelectTab)) +{ + public enum Priority + { + /// + ConfigTabBar = 0, + } +} diff --git a/Penumbra/Communication/TemporaryGlobalModChange.cs b/Penumbra/Communication/TemporaryGlobalModChange.cs new file mode 100644 index 00000000..6edf26d7 --- /dev/null +++ b/Penumbra/Communication/TemporaryGlobalModChange.cs @@ -0,0 +1,24 @@ +using OtterGui.Classes; +using Penumbra.Mods; + +namespace Penumbra.Communication; + +/// +/// Triggered whenever a temporary mod for all collections is changed. +/// +/// Parameter added, deleted or edited temporary mod. +/// Parameter is whether the mod was newly created. +/// Parameter is whether the mod was deleted. +/// +public sealed class TemporaryGlobalModChange() + : EventWrapper(nameof(TemporaryGlobalModChange)) +{ + public enum Priority + { + /// + CollectionCacheManager = 0, + + /// + TempCollectionManager = 0, + } +} diff --git a/Penumbra/Configuration.cs b/Penumbra/Configuration.cs index 1a9fb2b4..2991230e 100644 --- a/Penumbra/Configuration.cs +++ b/Penumbra/Configuration.cs @@ -1,66 +1,270 @@ -using System; -using System.Collections.Generic; using Dalamud.Configuration; -using Dalamud.Logging; +using Dalamud.Interface.ImGuiNotification; +using Newtonsoft.Json; +using OtterGui.Classes; +using OtterGui.Extensions; +using OtterGui.Filesystem; +using OtterGui.Services; +using OtterGui.Widgets; +using Penumbra.Import.Structs; +using Penumbra.Interop.Services; +using Penumbra.Mods; +using Penumbra.Mods.Manager; +using Penumbra.Services; +using Penumbra.UI.Classes; +using Penumbra.UI.ModsTab; +using Penumbra.UI.ResourceWatcher; +using ErrorEventArgs = Newtonsoft.Json.Serialization.ErrorEventArgs; -namespace Penumbra +namespace Penumbra; + +public record PcpSettings { - [Serializable] - public class Configuration : IPluginConfiguration + public bool CreateCollection { get; set; } = true; + public bool AssignCollection { get; set; } = true; + public bool AllowIpc { get; set; } = true; + public bool DisableHandling { get; set; } = false; + public string FolderName { get; set; } = "PCP"; +} + +[Serializable] +public class Configuration : IPluginConfiguration, ISavable, IService +{ + [JsonIgnore] + private readonly SaveService _saveService; + + [JsonIgnore] + public readonly EphemeralConfig Ephemeral; + + public int Version { get; set; } = Constants.CurrentVersion; + + public ChangeLogDisplayType ChangeLogDisplayType { get; set; } = ChangeLogDisplayType.New; + + public event Action? ModsEnabled; + + [JsonIgnore] + private bool _enableMods = true; + + public bool EnableMods { - private const int CurrentVersion = 1; + get => _enableMods; + set => SetField(ref _enableMods, value, ModsEnabled); + } - public int Version { get; set; } = CurrentVersion; + public string ModDirectory { get; set; } = string.Empty; + public string ExportDirectory { get; set; } = string.Empty; + public string WatchDirectory { get; set; } = string.Empty; - public bool IsEnabled { get; set; } = true; + public bool? UseCrashHandler { get; set; } = null; + public bool OpenWindowAtStart { get; set; } = false; + public bool HideUiInGPose { get; set; } = false; + public bool HideUiInCutscenes { get; set; } = true; + public bool HideUiWhenUiHidden { get; set; } = false; + public bool UseDalamudUiTextureRedirection { get; set; } = true; - public bool ScaleModSelector { get; set; } = false; - public bool ShowAdvanced { get; set; } + public bool AutoSelectCollection { get; set; } = false; - public bool DisableFileSystemNotifications { get; set; } + public bool ShowModsInLobby { get; set; } = true; + public bool UseCharacterCollectionInMainWindow { get; set; } = true; + public bool UseCharacterCollectionsInCards { get; set; } = true; + public bool UseCharacterCollectionInInspect { get; set; } = true; + public bool UseCharacterCollectionInTryOn { get; set; } = true; + public bool UseOwnerNameForCharacterCollection { get; set; } = true; + public bool UseNoModsInInspect { get; set; } = false; + public bool HideChangedItemFilters { get; set; } = false; + public bool ReplaceNonAsciiOnImport { get; set; } = false; + public bool HidePrioritiesInSelector { get; set; } = false; + public bool HideRedrawBar { get; set; } = false; + public bool HideMachinistOffhandFromChangedItems { get; set; } = true; + public bool DefaultTemporaryMode { get; set; } = false; + public bool EnableDirectoryWatch { get; set; } = false; + public bool EnableAutomaticModImport { get; set; } = false; + public bool EnableCustomShapes { get; set; } = true; + public PcpSettings PcpSettings = new(); + public RenameField ShowRename { get; set; } = RenameField.BothDataPrio; + public ChangedItemMode ChangedItemDisplay { get; set; } = ChangedItemMode.GroupedCollapsed; + public int OptionGroupCollapsibleMin { get; set; } = 5; - public bool EnableHttpApi { get; set; } - public bool EnablePlayerWatch { get; set; } = false; - public int WaitFrames { get; set; } = 30; + public Vector2 MinimumSize = new(Constants.MinimumSizeX, Constants.MinimumSizeY); - public string ModDirectory { get; set; } = string.Empty; - public string TempDirectory { get; set; } = string.Empty; +#if DEBUG + public bool DebugMode { get; set; } = true; +#else + public bool DebugMode { get; set; } = false; +#endif + public int MaxResourceWatcherRecords { get; set; } = ResourceWatcher.DefaultMaxEntries; - public string CurrentCollection { get; set; } = "Default"; - public string DefaultCollection { get; set; } = "Default"; - public string ForcedCollection { get; set; } = ""; + [JsonConverter(typeof(SortModeConverter))] + [JsonProperty(Order = int.MaxValue)] + public ISortMode SortMode = ISortMode.FoldersFirst; - public bool SortFoldersFirst { get; set; } = false; + public bool OpenFoldersByDefault { get; set; } = false; + public int SingleGroupRadioMax { get; set; } = 2; + public string DefaultImportFolder { get; set; } = string.Empty; + public string QuickMoveFolder1 { get; set; } = string.Empty; + public string QuickMoveFolder2 { get; set; } = string.Empty; + public string QuickMoveFolder3 { get; set; } = string.Empty; + public DoubleModifier DeleteModModifier { get; set; } = new(ModifierHotkey.Control, ModifierHotkey.Shift); + public DoubleModifier IncognitoModifier { get; set; } = new(ModifierHotkey.Control); + public bool PrintSuccessfulCommandsToChat { get; set; } = true; + public bool AutoDeduplicateOnImport { get; set; } = true; + public bool AutoReduplicateUiOnImport { get; set; } = true; + public bool UseFileSystemCompression { get; set; } = true; + public bool EnableHttpApi { get; set; } = true; - public Dictionary< string, string > CharacterCollections { get; set; } = new(); - public Dictionary< string, string > ModSortOrder { get; set; } = new(); + public bool MigrateImportedModelsToV6 { get; set; } = true; + public bool MigrateImportedMaterialsToLegacy { get; set; } = true; - public bool InvertModListOrder { internal get; set; } + public string DefaultModImportPath { get; set; } = string.Empty; + public bool AlwaysOpenDefaultImport { get; set; } = false; + public bool KeepDefaultMetaChanges { get; set; } = false; + public string DefaultModAuthor { get; set; } = DefaultTexToolsData.Author; + public bool EditRawTileTransforms { get; set; } = false; + public bool HdrRenderTargets { get; set; } = true; - public static Configuration Load() + public Dictionary Colors { get; set; } + = Enum.GetValues().ToDictionary(c => c, c => c.Data().DefaultColor); + + /// + /// Load the current configuration. + /// Includes adding new colors and migrating from old versions. + /// + public Configuration(CharacterUtility utility, ConfigMigrationService migrator, SaveService saveService, EphemeralConfig ephemeral) + { + _saveService = saveService; + Ephemeral = ephemeral; + Load(utility, migrator); + } + + public void Load(CharacterUtility utility, ConfigMigrationService migrator) + { + static void HandleDeserializationError(object? sender, ErrorEventArgs errorArgs) { - var configuration = Dalamud.PluginInterface.GetPluginConfig() as Configuration ?? new Configuration(); - if( configuration.Version == CurrentVersion ) - { - return configuration; - } - - MigrateConfiguration.Version0To1( configuration ); - configuration.Save(); - - return configuration; + Penumbra.Log.Error( + $"Error parsing Configuration at {errorArgs.ErrorContext.Path}, using default or migrating:\n{errorArgs.ErrorContext.Error}"); + errorArgs.ErrorContext.Handled = true; } - public void Save() - { + if (File.Exists(_saveService.FileNames.ConfigFile)) try { - Dalamud.PluginInterface.SavePluginConfig( this ); + var text = File.ReadAllText(_saveService.FileNames.ConfigFile); + JsonConvert.PopulateObject(text, this, new JsonSerializerSettings + { + Error = HandleDeserializationError, + }); } - catch( Exception e ) + catch (Exception ex) { - PluginLog.Error( $"Could not save plugin configuration:\n{e}" ); + Penumbra.Messager.NotificationMessage(ex, + "Error reading Configuration, reverting to default.\nYou may be able to restore your configuration using the rolling backups in the XIVLauncher/backups/Penumbra directory.", + "Error reading Configuration", NotificationType.Error); } + + migrator.Migrate(utility, this); + } + + /// Save the current configuration. + public void Save() + => _saveService.QueueSave(this); + + /// Contains some default values or boundaries for config values. + public static class Constants + { + public const int CurrentVersion = 9; + public const float MaxAbsoluteSize = 600; + public const int DefaultAbsoluteSize = 250; + public const float MinAbsoluteSize = 50; + public const int MaxScaledSize = 80; + public const int DefaultScaledSize = 20; + public const int MinScaledSize = 5; + public const int MinimumSizeX = 900; + public const int MinimumSizeY = 675; + + public static readonly ISortMode[] ValidSortModes = + { + ISortMode.FoldersFirst, + ISortMode.Lexicographical, + new ModFileSystem.ImportDate(), + new ModFileSystem.InverseImportDate(), + ISortMode.InverseFoldersFirst, + ISortMode.InverseLexicographical, + ISortMode.FoldersLast, + ISortMode.InverseFoldersLast, + ISortMode.InternalOrder, + ISortMode.InverseInternalOrder, + }; + } + + /// Convert SortMode Types to their name. + private class SortModeConverter : JsonConverter> + { + public override void WriteJson(JsonWriter writer, ISortMode? value, JsonSerializer serializer) + { + value ??= ISortMode.FoldersFirst; + serializer.Serialize(writer, value.GetType().Name); + } + + public override ISortMode ReadJson(JsonReader reader, Type objectType, ISortMode? existingValue, + bool hasExistingValue, + JsonSerializer serializer) + { + var name = serializer.Deserialize(reader); + if (name == null || !Constants.ValidSortModes.FindFirst(s => s.GetType().Name == name, out var mode)) + return existingValue ?? ISortMode.FoldersFirst; + + return mode; } } -} \ No newline at end of file + + 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); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] + private static bool SetField(ref T field, T value, Action? @event, [CallerMemberName] string? propertyName = null) + { + if (EqualityComparer.Default.Equals(value)) + return false; + + var oldValue = field; + field = value; + try + { + @event?.Invoke(oldValue, field); + } + catch (Exception ex) + { + Penumbra.Log.Error($"Error in subscribers updating configuration field {propertyName} from {oldValue} to {field}:\n{ex}"); + throw; + } + + return true; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] + private static bool SetField(ref T field, T value, Action? @event, [CallerMemberName] string? propertyName = null) + { + if (EqualityComparer.Default.Equals(value)) + return false; + + field = value; + try + { + @event?.Invoke(field); + } + catch (Exception ex) + { + Penumbra.Log.Error($"Error in subscribers updating configuration field {propertyName} to {field}:\n{ex}"); + throw; + } + + return true; + } +} diff --git a/Penumbra/Dalamud.cs b/Penumbra/Dalamud.cs deleted file mode 100644 index eea4d05a..00000000 --- a/Penumbra/Dalamud.cs +++ /dev/null @@ -1,32 +0,0 @@ -using Dalamud.Data; -using Dalamud.Game; -using Dalamud.Game.ClientState; -using Dalamud.Game.ClientState.Conditions; -using Dalamud.Game.ClientState.Objects; -using Dalamud.Game.Command; -using Dalamud.Game.Gui; -using Dalamud.IoC; -using Dalamud.Plugin; -// ReSharper disable AutoPropertyCanBeMadeGetOnly.Local - -namespace Penumbra -{ - public class Dalamud - { - public static void Initialize(DalamudPluginInterface pluginInterface) - => pluginInterface.Create(); - - // @formatter:off - [PluginService][RequiredVersion("1.0")] public static DalamudPluginInterface PluginInterface { get; private set; } = null!; - [PluginService][RequiredVersion("1.0")] public static CommandManager Commands { get; private set; } = null!; - [PluginService][RequiredVersion("1.0")] public static SigScanner SigScanner { get; private set; } = null!; - [PluginService][RequiredVersion("1.0")] public static DataManager GameData { get; private set; } = null!; - [PluginService][RequiredVersion("1.0")] public static ClientState ClientState { get; private set; } = null!; - [PluginService][RequiredVersion("1.0")] public static ChatGui Chat { get; private set; } = null!; - [PluginService][RequiredVersion("1.0")] public static Framework Framework { get; private set; } = null!; - [PluginService][RequiredVersion("1.0")] public static Condition Conditions { get; private set; } = null!; - [PluginService][RequiredVersion("1.0")] public static TargetManager Targets { get; private set; } = null!; - [PluginService][RequiredVersion("1.0")] public static ObjectTable Objects { get; private set; } = null!; - // @formatter:on - } -} diff --git a/Penumbra/DebugConfiguration.cs b/Penumbra/DebugConfiguration.cs new file mode 100644 index 00000000..3f9e8207 --- /dev/null +++ b/Penumbra/DebugConfiguration.cs @@ -0,0 +1,7 @@ +namespace Penumbra; + +public class DebugConfiguration +{ + public static bool WriteImcBytesToLog = false; + public static bool UseSkinMaterialProcessing = true; +} diff --git a/Penumbra/Enums/ResourceTypeFlag.cs b/Penumbra/Enums/ResourceTypeFlag.cs new file mode 100644 index 00000000..920e9780 --- /dev/null +++ b/Penumbra/Enums/ResourceTypeFlag.cs @@ -0,0 +1,274 @@ +using FFXIVClientStructs.FFXIV.Client.System.Resource; +using Penumbra.Api.Enums; +using Penumbra.String; +using Penumbra.String.Functions; + +namespace Penumbra.Enums; + +[Flags] +public enum ResourceTypeFlag : ulong +{ + Aet = 0x0000_0000_0000_0001, + Amb = 0x0000_0000_0000_0002, + Atch = 0x0000_0000_0000_0004, + Atex = 0x0000_0000_0000_0008, + Avfx = 0x0000_0000_0000_0010, + Awt = 0x0000_0000_0000_0020, + Cmp = 0x0000_0000_0000_0040, + Dic = 0x0000_0000_0000_0080, + Eid = 0x0000_0000_0000_0100, + Envb = 0x0000_0000_0000_0200, + Eqdp = 0x0000_0000_0000_0400, + Eqp = 0x0000_0000_0000_0800, + Essb = 0x0000_0000_0000_1000, + Est = 0x0000_0000_0000_2000, + Evp = 0x0000_0000_0000_4000, + Exd = 0x0000_0000_0000_8000, + Exh = 0x0000_0000_0001_0000, + Exl = 0x0000_0000_0002_0000, + Fdt = 0x0000_0000_0004_0000, + Gfd = 0x0000_0000_0008_0000, + Ggd = 0x0000_0000_0010_0000, + Gmp = 0x0000_0000_0020_0000, + Gzd = 0x0000_0000_0040_0000, + Imc = 0x0000_0000_0080_0000, + Lcb = 0x0000_0000_0100_0000, + Lgb = 0x0000_0000_0200_0000, + Luab = 0x0000_0000_0400_0000, + Lvb = 0x0000_0000_0800_0000, + Mdl = 0x0000_0000_1000_0000, + Mlt = 0x0000_0000_2000_0000, + Mtrl = 0x0000_0000_4000_0000, + Obsb = 0x0000_0000_8000_0000, + Pap = 0x0000_0001_0000_0000, + Pbd = 0x0000_0002_0000_0000, + Pcb = 0x0000_0004_0000_0000, + Phyb = 0x0000_0008_0000_0000, + Plt = 0x0000_0010_0000_0000, + Scd = 0x0000_0020_0000_0000, + Sgb = 0x0000_0040_0000_0000, + Shcd = 0x0000_0080_0000_0000, + Shpk = 0x0000_0100_0000_0000, + Sklb = 0x0000_0200_0000_0000, + Skp = 0x0000_0400_0000_0000, + Stm = 0x0000_0800_0000_0000, + Svb = 0x0000_1000_0000_0000, + Tera = 0x0000_2000_0000_0000, + Tex = 0x0000_4000_0000_0000, + Tmb = 0x0000_8000_0000_0000, + Ugd = 0x0001_0000_0000_0000, + Uld = 0x0002_0000_0000_0000, + Waoe = 0x0004_0000_0000_0000, + Wtd = 0x0008_0000_0000_0000, + Bklb = 0x0010_0000_0000_0000, + Cutb = 0x0020_0000_0000_0000, + Eanb = 0x0040_0000_0000_0000, + Eslb = 0x0080_0000_0000_0000, + Fpeb = 0x0100_0000_0000_0000, + Kdb = 0x0200_0000_0000_0000, + Kdlb = 0x0400_0000_0000_0000, +} + +[Flags] +public enum ResourceCategoryFlag : ushort +{ + Common = 0x0001, + BgCommon = 0x0002, + Bg = 0x0004, + Cut = 0x0008, + Chara = 0x0010, + Shader = 0x0020, + Ui = 0x0040, + Sound = 0x0080, + Vfx = 0x0100, + UiScript = 0x0200, + Exd = 0x0400, + GameScript = 0x0800, + Music = 0x1000, + SqpackTest = 0x2000, +} + +public static class ResourceExtensions +{ + public static readonly ResourceTypeFlag AllResourceTypes = Enum.GetValues().Aggregate((v, f) => v | f); + public static readonly ResourceCategoryFlag AllResourceCategories = Enum.GetValues().Aggregate((v, f) => v | f); + + public static ResourceTypeFlag ToFlag(this ResourceType type) + => type switch + { + ResourceType.Aet => ResourceTypeFlag.Aet, + ResourceType.Amb => ResourceTypeFlag.Amb, + ResourceType.Atch => ResourceTypeFlag.Atch, + ResourceType.Atex => ResourceTypeFlag.Atex, + ResourceType.Avfx => ResourceTypeFlag.Avfx, + ResourceType.Awt => ResourceTypeFlag.Awt, + ResourceType.Cmp => ResourceTypeFlag.Cmp, + ResourceType.Dic => ResourceTypeFlag.Dic, + ResourceType.Eid => ResourceTypeFlag.Eid, + ResourceType.Envb => ResourceTypeFlag.Envb, + ResourceType.Eqdp => ResourceTypeFlag.Eqdp, + ResourceType.Eqp => ResourceTypeFlag.Eqp, + ResourceType.Essb => ResourceTypeFlag.Essb, + ResourceType.Est => ResourceTypeFlag.Est, + ResourceType.Evp => ResourceTypeFlag.Evp, + ResourceType.Exd => ResourceTypeFlag.Exd, + ResourceType.Exh => ResourceTypeFlag.Exh, + ResourceType.Exl => ResourceTypeFlag.Exl, + ResourceType.Fdt => ResourceTypeFlag.Fdt, + ResourceType.Gfd => ResourceTypeFlag.Gfd, + ResourceType.Ggd => ResourceTypeFlag.Ggd, + ResourceType.Gmp => ResourceTypeFlag.Gmp, + ResourceType.Gzd => ResourceTypeFlag.Gzd, + ResourceType.Imc => ResourceTypeFlag.Imc, + ResourceType.Lcb => ResourceTypeFlag.Lcb, + ResourceType.Lgb => ResourceTypeFlag.Lgb, + ResourceType.Luab => ResourceTypeFlag.Luab, + ResourceType.Lvb => ResourceTypeFlag.Lvb, + ResourceType.Mdl => ResourceTypeFlag.Mdl, + ResourceType.Mlt => ResourceTypeFlag.Mlt, + ResourceType.Mtrl => ResourceTypeFlag.Mtrl, + ResourceType.Obsb => ResourceTypeFlag.Obsb, + ResourceType.Pap => ResourceTypeFlag.Pap, + ResourceType.Pbd => ResourceTypeFlag.Pbd, + ResourceType.Pcb => ResourceTypeFlag.Pcb, + ResourceType.Phyb => ResourceTypeFlag.Phyb, + ResourceType.Plt => ResourceTypeFlag.Plt, + ResourceType.Scd => ResourceTypeFlag.Scd, + ResourceType.Sgb => ResourceTypeFlag.Sgb, + ResourceType.Shcd => ResourceTypeFlag.Shcd, + ResourceType.Shpk => ResourceTypeFlag.Shpk, + ResourceType.Sklb => ResourceTypeFlag.Sklb, + ResourceType.Skp => ResourceTypeFlag.Skp, + ResourceType.Stm => ResourceTypeFlag.Stm, + ResourceType.Svb => ResourceTypeFlag.Svb, + ResourceType.Tera => ResourceTypeFlag.Tera, + ResourceType.Tex => ResourceTypeFlag.Tex, + ResourceType.Tmb => ResourceTypeFlag.Tmb, + ResourceType.Ugd => ResourceTypeFlag.Ugd, + ResourceType.Uld => ResourceTypeFlag.Uld, + ResourceType.Waoe => ResourceTypeFlag.Waoe, + ResourceType.Wtd => ResourceTypeFlag.Wtd, + ResourceType.Bklb => ResourceTypeFlag.Bklb, + ResourceType.Cutb => ResourceTypeFlag.Cutb, + ResourceType.Eanb => ResourceTypeFlag.Eanb, + ResourceType.Eslb => ResourceTypeFlag.Eslb, + ResourceType.Fpeb => ResourceTypeFlag.Fpeb, + ResourceType.Kdb => ResourceTypeFlag.Kdb , + ResourceType.Kdlb => ResourceTypeFlag.Kdlb, + _ => 0, + }; + + public static bool FitsFlag(this ResourceType type, ResourceTypeFlag flags) + => (type.ToFlag() & flags) != 0; + + public static ResourceCategoryFlag ToFlag(this ResourceCategory type) + => (ResourceCategory)((uint) type & 0x00FFFFFF) switch + { + ResourceCategory.Common => ResourceCategoryFlag.Common, + ResourceCategory.BgCommon => ResourceCategoryFlag.BgCommon, + ResourceCategory.Bg => ResourceCategoryFlag.Bg, + ResourceCategory.Cut => ResourceCategoryFlag.Cut, + ResourceCategory.Chara => ResourceCategoryFlag.Chara, + ResourceCategory.Shader => ResourceCategoryFlag.Shader, + ResourceCategory.Ui => ResourceCategoryFlag.Ui, + ResourceCategory.Sound => ResourceCategoryFlag.Sound, + ResourceCategory.Vfx => ResourceCategoryFlag.Vfx, + ResourceCategory.UiScript => ResourceCategoryFlag.UiScript, + ResourceCategory.Exd => ResourceCategoryFlag.Exd, + ResourceCategory.GameScript => ResourceCategoryFlag.GameScript, + ResourceCategory.Music => ResourceCategoryFlag.Music, + ResourceCategory.SqpackTest => ResourceCategoryFlag.SqpackTest, + _ => 0, + }; + + public static bool FitsFlag(this ResourceCategory type, ResourceCategoryFlag flags) + => (type.ToFlag() & flags) != 0; + + public static ResourceType FromBytes(byte a1, byte a2, byte a3) + => (ResourceType)(((uint)ByteStringFunctions.AsciiToLower(a1) << 16) + | ((uint)ByteStringFunctions.AsciiToLower(a2) << 8) + | ByteStringFunctions.AsciiToLower(a3)); + + public static ResourceType FromBytes(byte a1, byte a2, byte a3, byte a4) + => (ResourceType)(((uint)ByteStringFunctions.AsciiToLower(a1) << 24) + | ((uint)ByteStringFunctions.AsciiToLower(a2) << 16) + | ((uint)ByteStringFunctions.AsciiToLower(a3) << 8) + | ByteStringFunctions.AsciiToLower(a4)); + + public static ResourceType FromBytes(char a1, char a2, char a3) + => FromBytes((byte)a1, (byte)a2, (byte)a3); + + public static ResourceType FromBytes(char a1, char a2, char a3, char a4) + => FromBytes((byte)a1, (byte)a2, (byte)a3, (byte)a4); + + public static ResourceType Type(string path) + { + var ext = Path.GetExtension(path.AsSpan()); + ext = ext.Length == 0 ? path.AsSpan() : ext[1..]; + + return ext.Length switch + { + 0 => 0, + 1 => (ResourceType)ext[^1], + 2 => FromBytes('\0', ext[^2], ext[^1]), + 3 => FromBytes(ext[^3], ext[^2], ext[^1]), + _ => FromBytes(ext[^4], ext[^3], ext[^2], ext[^1]), + }; + } + + public static ResourceType Type(CiByteString path) + { + var extIdx = path.LastIndexOf((byte)'.'); + var ext = extIdx == -1 ? path : extIdx == path.Length - 1 ? CiByteString.Empty : path.Substring(extIdx + 1); + + return ext.Length switch + { + 0 => 0, + 1 => (ResourceType)ext[^1], + 2 => FromBytes(0, ext[^2], ext[^1]), + 3 => FromBytes(ext[^3], ext[^2], ext[^1]), + _ => FromBytes(ext[^4], ext[^3], ext[^2], ext[^1]), + }; + } + + public static ResourceCategory Category(CiByteString path) + { + if (path.Length < 3) + return ResourceCategory.Debug; + + return ByteStringFunctions.AsciiToUpper(path[0]) switch + { + (byte)'C' => ByteStringFunctions.AsciiToUpper(path[1]) switch + { + (byte)'O' => ResourceCategory.Common, + (byte)'U' => ResourceCategory.Cut, + (byte)'H' => ResourceCategory.Chara, + _ => ResourceCategory.Debug, + }, + (byte)'B' => ByteStringFunctions.AsciiToUpper(path[2]) switch + { + (byte)'C' => ResourceCategory.BgCommon, + (byte)'/' => ResourceCategory.Bg, + _ => ResourceCategory.Debug, + }, + (byte)'S' => ByteStringFunctions.AsciiToUpper(path[1]) switch + { + (byte)'H' => ResourceCategory.Shader, + (byte)'O' => ResourceCategory.Sound, + (byte)'Q' => ResourceCategory.SqpackTest, + _ => ResourceCategory.Debug, + }, + (byte)'U' => ByteStringFunctions.AsciiToUpper(path[2]) switch + { + (byte)'/' => ResourceCategory.Ui, + (byte)'S' => ResourceCategory.UiScript, + _ => ResourceCategory.Debug, + }, + (byte)'V' => ResourceCategory.Vfx, + (byte)'E' => ResourceCategory.Exd, + (byte)'G' => ResourceCategory.GameScript, + (byte)'M' => ResourceCategory.Music, + _ => ResourceCategory.Debug, + }; + } +} diff --git a/Penumbra/EphemeralConfig.cs b/Penumbra/EphemeralConfig.cs new file mode 100644 index 00000000..ecb0218f --- /dev/null +++ b/Penumbra/EphemeralConfig.cs @@ -0,0 +1,119 @@ +using Dalamud.Interface.ImGuiNotification; +using Newtonsoft.Json; +using OtterGui.Classes; +using OtterGui.FileSystem.Selector; +using OtterGui.Services; +using Penumbra.Api.Enums; +using Penumbra.Communication; +using Penumbra.Enums; +using Penumbra.Mods; +using Penumbra.Mods.Manager; +using Penumbra.Services; +using Penumbra.UI; +using Penumbra.UI.ResourceWatcher; +using Penumbra.UI.Tabs; +using ErrorEventArgs = Newtonsoft.Json.Serialization.ErrorEventArgs; + +namespace Penumbra; + +public class EphemeralConfig : ISavable, IDisposable, IService +{ + [JsonIgnore] + private readonly SaveService _saveService; + + [JsonIgnore] + private readonly ModPathChanged _modPathChanged; + + public float CurrentModSelectorWidth { get; set; } = 200f; + public float ModSelectorMinimumScale { get; set; } = 0.1f; + public float ModSelectorMaximumScale { get; set; } = 0.5f; + + public int Version { get; set; } = Configuration.Constants.CurrentVersion; + public int LastSeenVersion { get; set; } = PenumbraChangelog.LastChangelogVersion; + public bool DebugSeparateWindow { get; set; } = false; + public int TutorialStep { get; set; } = 0; + public bool EnableResourceLogging { get; set; } = false; + public string ResourceLoggingFilter { get; set; } = string.Empty; + public bool EnableResourceWatcher { get; set; } = false; + public bool OnlyAddMatchingResources { get; set; } = true; + public ResourceTypeFlag ResourceWatcherResourceTypes { get; set; } = ResourceExtensions.AllResourceTypes; + public ResourceCategoryFlag ResourceWatcherResourceCategories { get; set; } = ResourceExtensions.AllResourceCategories; + public RecordType ResourceWatcherRecordTypes { get; set; } = ResourceWatcher.AllRecords; + public CollectionsTab.PanelMode CollectionPanel { get; set; } = CollectionsTab.PanelMode.SimpleAssignment; + public TabType SelectedTab { get; set; } = TabType.Settings; + public ChangedItemIconFlag ChangedItemFilter { get; set; } = ChangedItemFlagExtensions.DefaultFlags; + public bool FixMainWindow { get; set; } = false; + public string LastModPath { get; set; } = string.Empty; + public bool AdvancedEditingOpen { get; set; } = false; + public bool ForceRedrawOnFileChange { get; set; } = false; + public bool IncognitoMode { get; set; } = false; + + /// + /// Load the current configuration. + /// Includes adding new colors and migrating from old versions. + /// + public EphemeralConfig(SaveService saveService, ModPathChanged modPathChanged) + { + _saveService = saveService; + _modPathChanged = modPathChanged; + Load(); + _modPathChanged.Subscribe(OnModPathChanged, ModPathChanged.Priority.EphemeralConfig); + } + + public void Dispose() + => _modPathChanged.Unsubscribe(OnModPathChanged); + + private void Load() + { + static void HandleDeserializationError(object? sender, ErrorEventArgs errorArgs) + { + Penumbra.Log.Error( + $"Error parsing ephemeral Configuration at {errorArgs.ErrorContext.Path}, using default or migrating:\n{errorArgs.ErrorContext.Error}"); + errorArgs.ErrorContext.Handled = true; + } + + if (!File.Exists(_saveService.FileNames.EphemeralConfigFile)) + return; + + try + { + var text = File.ReadAllText(_saveService.FileNames.EphemeralConfigFile); + JsonConvert.PopulateObject(text, this, new JsonSerializerSettings + { + Error = HandleDeserializationError, + }); + } + catch (Exception ex) + { + Penumbra.Messager.NotificationMessage(ex, + "Error reading ephemeral Configuration, reverting to default.", + "Error reading ephemeral Configuration", NotificationType.Error); + } + } + + /// Save the current configuration. + public void Save() + => _saveService.DelaySave(this, TimeSpan.FromSeconds(5)); + + + public string ToFilename(FilenameService fileNames) + => fileNames.EphemeralConfigFile; + + public void Save(StreamWriter writer) + { + using var jWriter = new JsonTextWriter(writer); + jWriter.Formatting = Formatting.Indented; + var serializer = new JsonSerializer { Formatting = Formatting.Indented }; + serializer.Serialize(jWriter, this); + } + + /// Overwrite the last saved mod path if it changes. + private void OnModPathChanged(ModPathChangeType type, Mod mod, DirectoryInfo? old, DirectoryInfo? _) + { + if (type is not ModPathChangeType.Moved || !string.Equals(old?.Name, LastModPath, StringComparison.OrdinalIgnoreCase)) + return; + + LastModPath = mod.Identifier; + Save(); + } +} diff --git a/Penumbra/Extensions/FuckedExtensions.cs b/Penumbra/Extensions/FuckedExtensions.cs deleted file mode 100644 index f9f41413..00000000 --- a/Penumbra/Extensions/FuckedExtensions.cs +++ /dev/null @@ -1,81 +0,0 @@ -using System; -using System.Reflection; -using System.Reflection.Emit; - -namespace Penumbra.Extensions -{ - public static class FuckedExtensions - { - private delegate ref TFieldType RefGet< in TObject, TFieldType >( TObject obj ); - - /// - /// Create a delegate which will return a zero-copy reference to a given field in a manner that's fucked tiers of quick and - /// fucked tiers of stupid, but hey, why not? - /// - /// - /// The only thing that this can't do is inline, this always ends up as a call instruction because we're generating code at - /// runtime and need to jump to it. That said, this is still super quick and provides a convenient and type safe shim around - /// a primitive type - /// - /// You can use the resultant to access a ref to a field on an object without invoking any - /// unsafe code too. - /// - /// The name of the field to grab a reference to - /// The object that holds the field - /// The type of the underlying field - /// A delegate that will return a reference to a particular field - zero copy - /// - private static RefGet< TObject, TField > CreateRefGetter< TObject, TField >( string fieldName ) - where TField : unmanaged - { - const BindingFlags flags = BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Instance; - - var fieldInfo = typeof( TObject ).GetField( fieldName, flags ); - if( fieldInfo == null ) - { - throw new MissingFieldException( typeof( TObject ).Name, fieldName ); - } - - var dm = new DynamicMethod( - $"__refget_{typeof( TObject ).Name}_{fieldInfo.Name}", - typeof( TField ).MakeByRefType(), - new[] { typeof( TObject ) }, - typeof( TObject ), - true - ); - - var il = dm.GetILGenerator(); - - il.Emit( OpCodes.Ldarg_0 ); - il.Emit( OpCodes.Ldflda, fieldInfo ); - il.Emit( OpCodes.Ret ); - - return ( RefGet< TObject, TField > )dm.CreateDelegate( typeof( RefGet< TObject, TField > ) ); - } - - private static readonly RefGet< string, byte > StringRefGet = CreateRefGetter< string, byte >( "_firstChar" ); - - public static unsafe IntPtr UnsafePtr( this string str ) - { - // nb: you can do it without __makeref but the code becomes way shittier because the way of getting the ptr - // is more fucked up so it's easier to just abuse __makeref - // but you can just use the StringRefGet func to get a `ref byte` too, though you'll probs want a better delegate so it's - // actually usable, lol - var fieldRef = __makeref( StringRefGet( str ) ); - - return *( IntPtr* )&fieldRef; - } - - public static unsafe int UnsafeLength( this string str ) - { - var fieldRef = __makeref( StringRefGet( str ) ); - - // c# strings are utf16 so we just multiply len by 2 to get the total byte count + 2 for null terminator (:D) - // very simple and intuitive - - // this also maps to a defined structure, so you can just move the pointer backwards to read from the native string struct - // see: https://github.com/dotnet/coreclr/blob/master/src/vm/object.h#L897-L909 - return *( int* )( *( IntPtr* )&fieldRef - 4 ) * 2 + 2; - } - } -} \ No newline at end of file diff --git a/Penumbra/GlobalUsings.cs b/Penumbra/GlobalUsings.cs new file mode 100644 index 00000000..51ba9ce5 --- /dev/null +++ b/Penumbra/GlobalUsings.cs @@ -0,0 +1,21 @@ +// Global using directives + +global using System; +global using System.Collections; +global using System.Collections.Concurrent; +global using System.Collections.Generic; +global using System.Diagnostics; +global using System.Diagnostics.CodeAnalysis; +global using System.Globalization; +global using System.IO; +global using System.IO.Compression; +global using System.Linq; +global using System.Numerics; +global using System.Reflection; +global using System.Runtime.CompilerServices; +global using System.Runtime.InteropServices; +global using System.Security.Cryptography; +global using System.Text; +global using System.Text.RegularExpressions; +global using System.Threading; +global using System.Threading.Tasks; diff --git a/Penumbra/Import/Models/Export/Config.cs b/Penumbra/Import/Models/Export/Config.cs new file mode 100644 index 00000000..58329a1d --- /dev/null +++ b/Penumbra/Import/Models/Export/Config.cs @@ -0,0 +1,6 @@ +namespace Penumbra.Import.Models.Export; + +public struct ExportConfig +{ + public bool GenerateMissingBones; +} diff --git a/Penumbra/Import/Models/Export/MaterialExporter.cs b/Penumbra/Import/Models/Export/MaterialExporter.cs new file mode 100644 index 00000000..0d91534e --- /dev/null +++ b/Penumbra/Import/Models/Export/MaterialExporter.cs @@ -0,0 +1,441 @@ +using Lumina.Data.Parsing; +using Penumbra.GameData.Files; +using Penumbra.GameData.Files.MaterialStructs; +using Penumbra.UI.AdvancedWindow.Materials; +using SharpGLTF.Materials; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.Advanced; +using SixLabors.ImageSharp.Formats.Png; +using SixLabors.ImageSharp.Memory; +using SixLabors.ImageSharp.PixelFormats; +using SixLabors.ImageSharp.Processing; + +namespace Penumbra.Import.Models.Export; + +using ImageSharpConfiguration = SixLabors.ImageSharp.Configuration; + +public class MaterialExporter +{ + public struct Material + { + public MtrlFile Mtrl; + + public Dictionary> Textures; + // variant? + } + + /// Dependency-less material configuration, for use when no material data can be resolved. + public static readonly MaterialBuilder Unknown = new MaterialBuilder("UNKNOWN") + .WithMetallicRoughnessShader() + .WithDoubleSide(true) + .WithBaseColor(Vector4.One); + + /// Build a glTF material from a hydrated XIV model, with the provided name. + public static MaterialBuilder Export(Material material, string name, IoNotifier notifier) + { + Penumbra.Log.Debug($"Exporting material \"{name}\"."); + return material.Mtrl.ShaderPackage.Name switch + { + // NOTE: this isn't particularly precise to game behavior (it has some fade around high opacity), but good enough for now. + "character.shpk" => BuildCharacter(material, name).WithAlpha(AlphaMode.MASK, 0.5f), + "characterlegacy.shpk" => BuildCharacter(material, name).WithAlpha(AlphaMode.MASK, 0.5f), + "characterglass.shpk" => BuildCharacter(material, name).WithAlpha(AlphaMode.BLEND), + "charactertattoo.shpk" => BuildCharacterTattoo(material, name), + "hair.shpk" => BuildHair(material, name), + "iris.shpk" => BuildIris(material, name), + "skin.shpk" => BuildSkin(material, name), + _ => BuildFallback(material, name, notifier), + }; + } + + /// Build a material following the semantics of character.shpk. + private static MaterialBuilder BuildCharacter(Material material, string name) + { + // Build the textures from the color table. + var table = new ColorTable(material.Mtrl.Table!); + var indexTexture = material.Textures[(TextureUsage)1449103320]; + var indexOperation = new ProcessCharacterIndexOperation(indexTexture, table); + ParallelRowIterator.IterateRows(ImageSharpConfiguration.Default, indexTexture.Bounds, in indexOperation); + + var normalTexture = material.Textures[TextureUsage.SamplerNormal]; + var normalOperation = new ProcessCharacterNormalOperation(normalTexture); + ParallelRowIterator.IterateRows(ImageSharpConfiguration.Default, normalTexture.Bounds, in normalOperation); + + // Merge in opacity from the normal. + var baseColor = indexOperation.BaseColor; + MultiplyOperation.Execute(baseColor, normalOperation.BaseColorOpacity); + + // Check if a full diffuse is provided, and merge in if available. + if (material.Textures.TryGetValue(TextureUsage.SamplerDiffuse, out var diffuse)) + { + MultiplyOperation.Execute(diffuse, indexOperation.BaseColor); + baseColor = diffuse; + } + + var specular = indexOperation.Specular; + if (material.Textures.TryGetValue(TextureUsage.SamplerSpecular, out var specularTexture)) + { + MultiplyOperation.Execute(specularTexture, indexOperation.Specular); + specular = specularTexture; + } + + // Pull further information from the mask. + if (material.Textures.TryGetValue(TextureUsage.SamplerMask, out var maskTexture)) + { + var maskOperation = new ProcessCharacterMaskOperation(maskTexture); + ParallelRowIterator.IterateRows(ImageSharpConfiguration.Default, maskTexture.Bounds, in maskOperation); + + // TODO: consider using the occusion gltf material property. + MultiplyOperation.Execute(baseColor, maskOperation.Occlusion); + + // Similar to base color's alpha, this is a pretty wasteful operation for a single channel. + MultiplyOperation.Execute(specular, maskOperation.SpecularFactor); + } + + // Specular extension puts colour on RGB and factor on A. We're already packing like that, so we can reuse the texture. + var specularImage = BuildImage(specular, name, "specular"); + + return BuildSharedBase(material, name) + .WithBaseColor(BuildImage(baseColor, name, "basecolor")) + .WithNormal(BuildImage(normalOperation.Normal, name, "normal")) + .WithEmissive(BuildImage(indexOperation.Emissive, name, "emissive"), Vector3.One, 1) + .WithSpecularFactor(specularImage, 1) + .WithSpecularColor(specularImage); + } + + private readonly struct ProcessCharacterIndexOperation(Image index, ColorTable table) : IRowOperation + { + public Image BaseColor { get; } = new(index.Width, index.Height); + public Image Specular { get; } = new(index.Width, index.Height); + public Image Emissive { get; } = new(index.Width, index.Height); + + private Buffer2D IndexBuffer + => index.Frames.RootFrame.PixelBuffer; + + private Buffer2D BaseColorBuffer + => BaseColor.Frames.RootFrame.PixelBuffer; + + private Buffer2D SpecularBuffer + => Specular.Frames.RootFrame.PixelBuffer; + + private Buffer2D EmissiveBuffer + => Emissive.Frames.RootFrame.PixelBuffer; + + public void Invoke(int y) + { + var indexSpan = IndexBuffer.DangerousGetRowSpan(y); + var baseColorSpan = BaseColorBuffer.DangerousGetRowSpan(y); + var specularSpan = SpecularBuffer.DangerousGetRowSpan(y); + var emissiveSpan = EmissiveBuffer.DangerousGetRowSpan(y); + + for (var x = 0; x < indexSpan.Length; x++) + { + ref var indexPixel = ref indexSpan[x]; + + // Calculate and fetch the color table rows being used for this pixel. + var tablePair = (int) Math.Round(indexPixel.R / 17f); + var rowBlend = 1.0f - indexPixel.G / 255f; + + var prevRow = table[tablePair * 2]; + var nextRow = table[Math.Min(tablePair * 2 + 1, ColorTable.NumRows)]; + + // Lerp between table row values to fetch final pixel values for each subtexture. + var lerpedDiffuse = Vector3.Lerp((Vector3)prevRow.DiffuseColor, (Vector3)nextRow.DiffuseColor, rowBlend); + baseColorSpan[x].FromVector4(new Vector4(MtrlTab.PseudoSqrtRgb(lerpedDiffuse), 1)); + + var lerpedSpecularColor = Vector3.Lerp((Vector3)prevRow.SpecularColor, (Vector3)nextRow.SpecularColor, rowBlend); + specularSpan[x].FromVector4(new Vector4(MtrlTab.PseudoSqrtRgb(lerpedSpecularColor), 1)); + + var lerpedEmissive = Vector3.Lerp((Vector3)prevRow.EmissiveColor, (Vector3)nextRow.EmissiveColor, rowBlend); + emissiveSpan[x].FromVector4(new Vector4(MtrlTab.PseudoSqrtRgb(lerpedEmissive), 1)); + } + } + } + + private readonly struct ProcessCharacterNormalOperation(Image normal) : IRowOperation + { + // TODO: Consider omitting the alpha channel here. + public Image Normal { get; } = normal.Clone(); + // TODO: We only really need the alpha here, however using A8 will result in the multiply later zeroing out the RGB channels. + public Image BaseColorOpacity { get; } = new(normal.Width, normal.Height); + + private Buffer2D NormalBuffer + => Normal.Frames.RootFrame.PixelBuffer; + + private Buffer2D BaseColorOpacityBuffer + => BaseColorOpacity.Frames.RootFrame.PixelBuffer; + + public void Invoke(int y) + { + var normalSpan = NormalBuffer.DangerousGetRowSpan(y); + var baseColorOpacitySpan = BaseColorOpacityBuffer.DangerousGetRowSpan(y); + + for (var x = 0; x < normalSpan.Length; x++) + { + ref var normalPixel = ref normalSpan[x]; + + baseColorOpacitySpan[x].FromVector4(Vector4.One); + baseColorOpacitySpan[x].A = normalPixel.B; + + normalPixel.B = byte.MaxValue; + normalPixel.A = byte.MaxValue; + } + } + } + + private readonly struct ProcessCharacterMaskOperation(Image mask) : IRowOperation + { + public Image Occlusion { get; } = new(mask.Width, mask.Height); + public Image SpecularFactor { get; } = new(mask.Width, mask.Height); + + private Buffer2D MaskBuffer + => mask.Frames.RootFrame.PixelBuffer; + + private Buffer2D OcclusionBuffer + => Occlusion.Frames.RootFrame.PixelBuffer; + + private Buffer2D SpecularFactorBuffer + => SpecularFactor.Frames.RootFrame.PixelBuffer; + + public void Invoke(int y) + { + var maskSpan = MaskBuffer.DangerousGetRowSpan(y); + var occlusionSpan = OcclusionBuffer.DangerousGetRowSpan(y); + var specularFactorSpan = SpecularFactorBuffer.DangerousGetRowSpan(y); + + for (var x = 0; x < maskSpan.Length; x++) + { + ref var maskPixel = ref maskSpan[x]; + + occlusionSpan[x].FromL8(new L8(maskPixel.B)); + + specularFactorSpan[x].FromVector4(Vector4.One); + specularFactorSpan[x].A = maskPixel.R; + } + } + } + + private readonly struct MultiplyOperation + { + public static void Execute(Image target, Image multiplier) + where TPixel1 : unmanaged, IPixel + where TPixel2 : unmanaged, IPixel + { + // Ensure the images are the same size + var (small, large) = target.Width < multiplier.Width && target.Height < multiplier.Height + ? ((Image)target, (Image)multiplier) + : (multiplier, target); + small.Mutate(context => context.Resize(large.Width, large.Height)); + + var operation = new MultiplyOperation(target, multiplier); + ParallelRowIterator.IterateRows(ImageSharpConfiguration.Default, target.Bounds, in operation); + } + } + + private readonly struct MultiplyOperation(Image target, Image multiplier) : IRowOperation + where TPixel1 : unmanaged, IPixel + where TPixel2 : unmanaged, IPixel + { + public void Invoke(int y) + { + var targetSpan = target.Frames.RootFrame.PixelBuffer.DangerousGetRowSpan(y); + var multiplierSpan = multiplier.Frames.RootFrame.PixelBuffer.DangerousGetRowSpan(y); + + for (var x = 0; x < targetSpan.Length; x++) + targetSpan[x].FromVector4(targetSpan[x].ToVector4() * multiplierSpan[x].ToVector4()); + } + } + + private static readonly Vector4 DefaultTattooColor = new Vector4(38, 112, 102, 255) / new Vector4(255); + + private static MaterialBuilder BuildCharacterTattoo(Material material, string name) + { + var normal = material.Textures[TextureUsage.SamplerNormal]; + var baseColor = new Image(normal.Width, normal.Height); + + normal.ProcessPixelRows(baseColor, (normalAccessor, baseColorAccessor) => + { + for (var y = 0; y < normalAccessor.Height; y++) + { + var normalSpan = normalAccessor.GetRowSpan(y); + var baseColorSpan = baseColorAccessor.GetRowSpan(y); + + for (var x = 0; x < normalSpan.Length; x++) + { + baseColorSpan[x].FromVector4(DefaultTattooColor); + baseColorSpan[x].A = normalSpan[x].A; + + normalSpan[x].B = byte.MaxValue; + normalSpan[x].A = byte.MaxValue; + } + } + }); + + return BuildSharedBase(material, name) + .WithBaseColor(BuildImage(baseColor, name, "basecolor")) + .WithNormal(BuildImage(normal, name, "normal")) + .WithAlpha(AlphaMode.BLEND); + } + + // TODO: These are hardcoded colours - I'm not keen on supporting highly customizable exports, but there's possibly some more sensible values to use here. + private static readonly Vector4 DefaultHairColor = new Vector4(130, 64, 13, 255) / new Vector4(255); + private static readonly Vector4 DefaultHighlightColor = new Vector4(77, 126, 240, 255) / new Vector4(255); + + /// Build a material following the semantics of hair.shpk. + private static MaterialBuilder BuildHair(Material material, string name) + { + // Trust me bro. + const uint categoryHairType = 0x24826489; + const uint valueFace = 0x6E5B8F10; + + var isFace = material.Mtrl.ShaderPackage.ShaderKeys + .Any(key => key is { Key: categoryHairType, Value: valueFace }); + + var normal = material.Textures[TextureUsage.SamplerNormal]; + var mask = material.Textures[TextureUsage.SamplerMask]; + + mask.Mutate(context => context.Resize(normal.Width, normal.Height)); + + var baseColor = new Image(normal.Width, normal.Height); + normal.ProcessPixelRows(mask, baseColor, (normalAccessor, maskAccessor, baseColorAccessor) => + { + for (var y = 0; y < normalAccessor.Height; y++) + { + var normalSpan = normalAccessor.GetRowSpan(y); + var maskSpan = maskAccessor.GetRowSpan(y); + var baseColorSpan = baseColorAccessor.GetRowSpan(y); + + for (var x = 0; x < normalSpan.Length; x++) + { + var color = Vector4.Lerp(DefaultHairColor, DefaultHighlightColor, normalSpan[x].B / 255f); + baseColorSpan[x].FromVector4(color * new Vector4(maskSpan[x].A / 255f)); + baseColorSpan[x].A = normalSpan[x].A; + + normalSpan[x].B = byte.MaxValue; + normalSpan[x].A = byte.MaxValue; + } + } + }); + + return BuildSharedBase(material, name) + .WithBaseColor(BuildImage(baseColor, name, "basecolor")) + .WithNormal(BuildImage(normal, name, "normal")) + .WithAlpha(isFace ? AlphaMode.BLEND : AlphaMode.MASK, 0.5f); + } + + private static readonly Vector4 DefaultEyeColor = new Vector4(21, 176, 172, 255) / new Vector4(255); + + /// Build a material following the semantics of iris.shpk. + // NOTE: This is largely the same as the hair material, but is also missing a few features that would cause it to diverge. Keeping separate for now. + private static MaterialBuilder BuildIris(Material material, string name) + { + var normal = material.Textures[TextureUsage.SamplerNormal]; + var mask = material.Textures[TextureUsage.SamplerMask]; + var baseColor = material.Textures[TextureUsage.SamplerDiffuse]; + + mask.Mutate(context => context.Resize(baseColor.Width, baseColor.Height)); + + baseColor.ProcessPixelRows(mask, (baseColorAccessor, maskAccessor) => + { + for (var y = 0; y < baseColor.Height; y++) + { + var baseColorSpan = baseColorAccessor.GetRowSpan(y); + var maskSpan = maskAccessor.GetRowSpan(y); + + for (var x = 0; x < baseColorSpan.Length; x++) + { + var eyeColor = Vector4.Lerp(Vector4.One, DefaultEyeColor, maskSpan[x].B / 255f); + baseColorSpan[x].FromVector4(baseColorSpan[x].ToVector4() * eyeColor); + } + } + }); + + return BuildSharedBase(material, name) + .WithBaseColor(BuildImage(baseColor, name, "basecolor")) + .WithNormal(BuildImage(normal, name, "normal")); + } + + /// Build a material following the semantics of skin.shpk. + private static MaterialBuilder BuildSkin(Material material, string name) + { + // Trust me bro. + const uint categorySkinType = 0x380CAED0; + const uint valueFace = 0xF5673524; + + // Face is the default for the skin shader, so a lack of skin type category is also correct. + var isFace = !material.Mtrl.ShaderPackage.ShaderKeys + .Any(key => key.Key == categorySkinType && key.Value != valueFace); + + // TODO: There's more nuance to skin than this, but this should be enough for a baseline reference. + // TODO: Specular? + var diffuse = material.Textures[TextureUsage.SamplerDiffuse]; + var normal = material.Textures[TextureUsage.SamplerNormal]; + + // The normal also stores the skin color influence (.b) and wetness mask (.a) - remove. + normal.ProcessPixelRows(normalAccessor => + { + for (var y = 0; y < normalAccessor.Height; y++) + { + var normalSpan = normalAccessor.GetRowSpan(y); + + for (var x = 0; x < normalSpan.Length; x++) + { + normalSpan[x].B = byte.MaxValue; + normalSpan[x].A = byte.MaxValue; + } + } + }); + + return BuildSharedBase(material, name) + .WithBaseColor(BuildImage(diffuse, name, "basecolor")) + .WithNormal(BuildImage(normal, name, "normal")) + .WithAlpha(isFace ? AlphaMode.MASK : AlphaMode.OPAQUE, 0.5f); + } + + /// Build a material from a source with unknown semantics. + /// Will make a loose effort to fetch common / simple textures. + private static MaterialBuilder BuildFallback(Material material, string name, IoNotifier notifier) + { + notifier.Warning($"Unhandled shader package: {material.Mtrl.ShaderPackage.Name}"); + + var materialBuilder = BuildSharedBase(material, name) + .WithMetallicRoughnessShader() + .WithBaseColor(Vector4.One); + + if (material.Textures.TryGetValue(TextureUsage.SamplerDiffuse, out var diffuse)) + materialBuilder.WithBaseColor(BuildImage(diffuse, name, "basecolor")); + + if (material.Textures.TryGetValue(TextureUsage.SamplerNormal, out var normal)) + materialBuilder.WithNormal(BuildImage(normal, name, "normal")); + + return materialBuilder; + } + + /// Build a material pre-configured with settings common to all XIV materials/shaders. + private static MaterialBuilder BuildSharedBase(Material material, string name) + { + // TODO: Move this and potentially the other known stuff into MtrlFile? + const uint backfaceMask = 0x1; + var showBackfaces = (material.Mtrl.ShaderPackage.Flags & backfaceMask) == 0; + + return new MaterialBuilder(name) + .WithDoubleSide(showBackfaces); + } + + /// Convert an ImageSharp Image into an ImageBuilder for use with SharpGLTF. + private static ImageBuilder BuildImage(Image image, string materialName, string suffix) + { + var name = materialName.Replace("/", "").Replace(".mtrl", "") + $"_{suffix}"; + + byte[] textureBytes; + using (var memoryStream = new MemoryStream()) + { + image.Save(memoryStream, PngFormat.Instance); + textureBytes = memoryStream.ToArray(); + } + + var imageBuilder = ImageBuilder.From(textureBytes, name); + imageBuilder.AlternateWriteFileName = $"{name}.*"; + return imageBuilder; + } +} diff --git a/Penumbra/Import/Models/Export/MeshExporter.cs b/Penumbra/Import/Models/Export/MeshExporter.cs new file mode 100644 index 00000000..6ea2b284 --- /dev/null +++ b/Penumbra/Import/Models/Export/MeshExporter.cs @@ -0,0 +1,650 @@ +using System.Collections.Immutable; +using System.Text.Json; +using System.Text.Json.Nodes; +using Lumina.Extensions; +using OtterGui.Extensions; +using Penumbra.GameData.Files; +using Penumbra.GameData.Files.ModelStructs; +using SharpGLTF.Geometry; +using SharpGLTF.Geometry.VertexTypes; +using SharpGLTF.Materials; +using SharpGLTF.Scenes; + +namespace Penumbra.Import.Models.Export; + +public class MeshExporter +{ + public class Mesh(IEnumerable meshes, GltfSkeleton? skeleton) + { + public void AddToScene(SceneBuilder scene) + { + foreach (var data in meshes) + { + var instance = skeleton != null + ? scene.AddSkinnedMesh(data.Mesh, Matrix4x4.Identity, [.. skeleton.Value.Joints]) + : scene.AddRigidMesh(data.Mesh, Matrix4x4.Identity); + + var node = new JsonObject(); + foreach (var attribute in data.Attributes) + node[attribute] = true; + + instance.WithExtras(node); + } + } + } + + public struct MeshData + { + public IMeshBuilder Mesh; + public string[] Attributes; + } + + public static Mesh Export(in ExportConfig config, MdlFile mdl, byte lod, ushort meshIndex, MaterialBuilder[] materials, + GltfSkeleton? skeleton, + IoNotifier notifier) + { + var self = new MeshExporter(config, mdl, lod, meshIndex, materials, skeleton, notifier); + return new Mesh(self.BuildMeshes(), skeleton); + } + + private const byte MaximumMeshBufferStreams = 3; + + private readonly ExportConfig _config; + private readonly IoNotifier _notifier; + + private readonly MdlFile _mdl; + private readonly byte _lod; + private readonly ushort _meshIndex; + + private MeshStruct XivMesh + => _mdl.Meshes[_meshIndex]; + + private readonly MaterialBuilder _material; + + private readonly Dictionary? _boneIndexMap; + + private readonly Type _geometryType; + private readonly Type _materialType; + private readonly Type _skinningType; + + // TODO: This signature is getting out of control. + private MeshExporter(in ExportConfig config, MdlFile mdl, byte lod, ushort meshIndex, MaterialBuilder[] materials, + GltfSkeleton? skeleton, IoNotifier notifier) + { + _config = config; + _notifier = notifier; + _mdl = mdl; + _lod = lod; + _meshIndex = meshIndex; + + _material = materials[XivMesh.MaterialIndex]; + + if (skeleton != null) + _boneIndexMap = BuildBoneIndexMap(skeleton.Value); + + var usages = _mdl.VertexDeclarations[_meshIndex].VertexElements + .GroupBy(ele => (MdlFile.VertexUsage)ele.Usage, ele => ele) + .ToImmutableDictionary( + g => g.Key, + g => g.OrderBy(ele => ele.UsageIndex) // OrderBy UsageIndex is probably unnecessary as they're probably already be in order + .Select(ele => (MdlFile.VertexType)ele.Type) + .ToList() + ); + + _geometryType = GetGeometryType(usages); + _materialType = GetMaterialType(usages); + _skinningType = GetSkinningType(usages); + + // If there's skinning usages but no bone mapping, there's probably something wrong with the data. + if (_skinningType != typeof(VertexEmpty) && _boneIndexMap == null) + _notifier.Warning($"Skinned vertex usages but no bone information was provided."); + + Penumbra.Log.Debug( + $"Mesh {meshIndex} using vertex types geometry: {_geometryType.Name}, material: {_materialType.Name}, skinning: {_skinningType.Name}"); + } + + /// Build a mapping between indices in this mesh's bone table (if any), and the glTF joint indices provided. + private Dictionary? BuildBoneIndexMap(GltfSkeleton skeleton) + { + // A BoneTableIndex of 255 means that this mesh is not skinned. + if (XivMesh.BoneTableIndex == 255) + return null; + + var xivBoneTable = _mdl.BoneTables[XivMesh.BoneTableIndex]; + + var indexMap = new Dictionary(); + // #TODO @ackwell maybe fix for V6 Models, I think this works fine. + + foreach (var (xivBoneIndex, tableIndex) in xivBoneTable.BoneIndex.Take((int)xivBoneTable.BoneCount).WithIndex()) + { + var boneName = _mdl.Bones[xivBoneIndex]; + if (!skeleton.Names.TryGetValue(boneName, out var gltfBoneIndex)) + { + if (!_config.GenerateMissingBones) + throw _notifier.Exception( + $@"Armature does not contain bone ""{boneName}"". + Ensure all dependencies are enabled in the current collection, and EST entries (if required) are configured. + If this is a known issue with this model and you would like to export anyway, enable the ""Generate missing bones"" option." + ); + + (_, gltfBoneIndex) = skeleton.GenerateBone(boneName); + _notifier.Warning( + $"Generated missing bone \"{boneName}\". Vertices weighted to this bone will not move with the rest of the armature."); + } + + indexMap.Add((ushort)tableIndex, gltfBoneIndex); + } + + return indexMap; + } + + /// Build glTF meshes for this XIV mesh. + private MeshData[] BuildMeshes() + { + var indices = BuildIndices(); + var vertices = BuildVertices(); + + // NOTE: Index indices are specified relative to the LOD's 0, but we're reading chunks for each mesh, so we're specifying the index base relative to the mesh's base. + if (XivMesh.SubMeshCount == 0) + return [BuildMesh($"mesh {_meshIndex}", indices, vertices, 0, (int)XivMesh.IndexCount, 0)]; + + return _mdl.SubMeshes + .Skip(XivMesh.SubMeshIndex) + .Take(XivMesh.SubMeshCount) + .WithIndex() + .Select(subMesh => BuildMesh($"mesh {_meshIndex}.{subMesh.Index}", indices, vertices, + (int)(subMesh.Value.IndexOffset - XivMesh.StartIndex), (int)subMesh.Value.IndexCount, + subMesh.Value.AttributeIndexMask)) + .ToArray(); + } + + /// Build a mesh from the provided indices and vertices. A subset of the full indices may be built by providing an index base and count. + private MeshData BuildMesh( + string name, + IReadOnlyList indices, + IReadOnlyList vertices, + int indexBase, + int indexCount, + uint attributeMask + ) + { + var meshBuilderType = typeof(MeshBuilder<,,,>).MakeGenericType( + typeof(MaterialBuilder), + _geometryType, + _materialType, + _skinningType + ); + var meshBuilder = (IMeshBuilder)Activator.CreateInstance(meshBuilderType, name)!; + + var primitiveBuilder = meshBuilder.UsePrimitive(_material); + + // Store a list of the glTF indices. The list index will be equivalent to the xiv (submesh) index. + var gltfIndices = new List(); + + // All XIV meshes use triangle lists. + for (var indexOffset = 0; indexOffset < indexCount; indexOffset += 3) + { + var (a, b, c) = primitiveBuilder.AddTriangle( + vertices[indices[indexBase + indexOffset + 0]], + vertices[indices[indexBase + indexOffset + 1]], + vertices[indices[indexBase + indexOffset + 2]] + ); + gltfIndices.AddRange([a, b, c]); + } + + var primitiveVertices = meshBuilder.Primitives.First().Vertices; + var shapeNames = new List(); + + foreach (var shape in _mdl.Shapes) + { + // Filter down to shape values for the current mesh that sit within the bounds of the current submesh. + var shapeValues = _mdl.ShapeMeshes + .Skip(shape.ShapeMeshStartIndex[_lod]) + .Take(shape.ShapeMeshCount[_lod]) + .Where(shapeMesh => shapeMesh.MeshIndexOffset == XivMesh.StartIndex) + .SelectMany(shapeMesh => + _mdl.ShapeValues + .Skip((int)shapeMesh.ShapeValueOffset) + .Take((int)shapeMesh.ShapeValueCount) + ) + .Where(shapeValue => + shapeValue.BaseIndicesIndex >= indexBase + && shapeValue.BaseIndicesIndex < indexBase + indexCount + ) + .ToList(); + + if (shapeValues.Count == 0) + continue; + + var morphBuilder = meshBuilder.UseMorphTarget(shapeNames.Count); + shapeNames.Add(shape.ShapeName); + + foreach (var (shapeValue, shapeValueIndex) in shapeValues.WithIndex()) + { + var gltfIndex = gltfIndices[shapeValue.BaseIndicesIndex - indexBase]; + + if (gltfIndex == -1) + { + _notifier.Warning($"{name}: Shape {shape.ShapeName} mapping {shapeValueIndex} targets a degenerate triangle, ignoring."); + continue; + } + + morphBuilder.SetVertex( + primitiveVertices[gltfIndex].GetGeometry(), + vertices[shapeValue.ReplacingVertexIndex].GetGeometry() + ); + } + } + + // Named morph targets aren't part of the specification, however `MESH.extras.targetNames` + // is a commonly-accepted means of providing the data. + meshBuilder.Extras = new JsonObject { ["targetNames"] = JsonSerializer.SerializeToNode(shapeNames) }; + + string[] attributes = []; + var maxAttribute = 31 - BitOperations.LeadingZeroCount(attributeMask); + if (maxAttribute < _mdl.Attributes.Length) + attributes = Enumerable.Range(0, 32) + .Where(index => ((attributeMask >> index) & 1) == 1) + .Select(index => _mdl.Attributes[index]) + .ToArray(); + else + _notifier.Warning("Invalid attribute data, ignoring."); + + return new MeshData + { + Mesh = meshBuilder, + Attributes = attributes, + }; + } + + /// Read in the indices for this mesh. + private IReadOnlyList BuildIndices() + { + var reader = new BinaryReader(new MemoryStream(_mdl.RemainingData)); + reader.Seek(_mdl.IndexOffset[_lod] + XivMesh.StartIndex * sizeof(ushort)); + return reader.ReadStructuresAsArray((int)XivMesh.IndexCount); + } + + /// Build glTF-compatible vertex data for all vertices in this mesh. + private IReadOnlyList BuildVertices() + { + var vertexBuilderType = typeof(VertexBuilder<,,>) + .MakeGenericType(_geometryType, _materialType, _skinningType); + + // NOTE: This assumes that buffer streams are tightly packed, which has proven safe across tested files. If this assumption is broken, seeks will need to be moved into the vertex element loop. + var streams = new BinaryReader[MaximumMeshBufferStreams]; + for (var streamIndex = 0; streamIndex < MaximumMeshBufferStreams; streamIndex++) + { + streams[streamIndex] = new BinaryReader(new MemoryStream(_mdl.RemainingData)); + streams[streamIndex].Seek(_mdl.VertexOffset[_lod] + XivMesh.VertexBufferOffset(streamIndex)); + } + + var sortedElements = _mdl.VertexDeclarations[_meshIndex].VertexElements + .OrderBy(element => element.Offset) + .ToList(); + var vertices = new List(); + + var attributes = new Dictionary>(); + for (var vertexIndex = 0; vertexIndex < XivMesh.VertexCount; vertexIndex++) + { + attributes.Clear(); + attributes = sortedElements + .GroupBy(element => element.Usage) + .ToDictionary( + x => (MdlFile.VertexUsage)x.Key, + x => x.OrderBy(ele => ele.UsageIndex) // Once again, OrderBy UsageIndex is probably unnecessary + .Select(ele => ReadVertexAttribute((MdlFile.VertexType)ele.Type, streams[ele.Stream])) + .ToList() + ); + + + var vertexGeometry = BuildVertexGeometry(attributes); + var vertexMaterial = BuildVertexMaterial(attributes); + var vertexSkinning = BuildVertexSkinning(attributes); + + var vertexBuilder = (IVertexBuilder)Activator.CreateInstance(vertexBuilderType, vertexGeometry, vertexMaterial, vertexSkinning)!; + vertices.Add(vertexBuilder); + } + + return vertices; + } + + /// Read a vertex attribute of the specified type from a vertex buffer stream. + private object ReadVertexAttribute(MdlFile.VertexType type, BinaryReader reader) + { + return type switch + { + MdlFile.VertexType.Single2 => new Vector2(reader.ReadSingle(), reader.ReadSingle()), + MdlFile.VertexType.Single3 => new Vector3(reader.ReadSingle(), reader.ReadSingle(), reader.ReadSingle()), + MdlFile.VertexType.Single4 => new Vector4(reader.ReadSingle(), reader.ReadSingle(), reader.ReadSingle(), reader.ReadSingle()), + MdlFile.VertexType.UByte4 => reader.ReadBytes(4), + MdlFile.VertexType.NByte4 => new Vector4(reader.ReadByte() / 255f, reader.ReadByte() / 255f, reader.ReadByte() / 255f, reader.ReadByte() / 255f), + MdlFile.VertexType.Half2 => new Vector2((float)reader.ReadHalf(), (float)reader.ReadHalf()), + MdlFile.VertexType.Half4 => new Vector4((float)reader.ReadHalf(), (float)reader.ReadHalf(), (float)reader.ReadHalf(), (float)reader.ReadHalf()), + MdlFile.VertexType.UShort4 => reader.ReadBytes(8), + var other => throw _notifier.Exception($"Unhandled vertex type {other}"), + }; + } + + /// Get the vertex geometry type for this mesh's vertex usages. + private Type GetGeometryType(IReadOnlyDictionary> usages) + { + if (!usages.ContainsKey(MdlFile.VertexUsage.Position)) + throw _notifier.Exception("Mesh does not contain position vertex elements."); + + if (!usages.ContainsKey(MdlFile.VertexUsage.Normal)) + return typeof(VertexPosition); + + if (!usages.ContainsKey(MdlFile.VertexUsage.Tangent1)) + return typeof(VertexPositionNormal); + + return typeof(VertexPositionNormalTangent); + } + + /// Build a geometry vertex from a vertex's attributes. + private IVertexGeometry BuildVertexGeometry(IReadOnlyDictionary> attributes) + { + if (_geometryType == typeof(VertexPosition)) + return new VertexPosition( + ToVector3(GetFirstSafe(attributes, MdlFile.VertexUsage.Position)) + ); + + if (_geometryType == typeof(VertexPositionNormal)) + return new VertexPositionNormal( + ToVector3(GetFirstSafe(attributes, MdlFile.VertexUsage.Position)), + ToVector3(GetFirstSafe(attributes, MdlFile.VertexUsage.Normal)) + ); + + if (_geometryType == typeof(VertexPositionNormalTangent)) + { + // (Bi)tangents are universally stored as ByteFloat4, which uses 0..1 to represent the full -1..1 range. + // TODO: While this assumption is safe, it would be sensible to actually check. + var bitangent = ToVector4(GetFirstSafe(attributes, MdlFile.VertexUsage.Tangent1)) * 2 - Vector4.One; + + return new VertexPositionNormalTangent( + ToVector3(GetFirstSafe(attributes, MdlFile.VertexUsage.Position)), + ToVector3(GetFirstSafe(attributes, MdlFile.VertexUsage.Normal)), + bitangent.SanitizeTangent() + ); + } + + throw _notifier.Exception($"Unknown geometry type {_geometryType}."); + } + + /// Get the vertex material type for this mesh's vertex usages. + private Type GetMaterialType(IReadOnlyDictionary> usages) + { + var uvCount = 0; + if (usages.TryGetValue(MdlFile.VertexUsage.UV, out var list)) + { + foreach (var type in list) + { + uvCount += type switch + { + MdlFile.VertexType.Half2 => 1, + MdlFile.VertexType.Half4 => 2, + MdlFile.VertexType.Single2 => 1, + MdlFile.VertexType.Single4 => 2, + _ => throw _notifier.Exception($"Unexpected UV vertex type {type}."), + }; + } + } + + usages.TryGetValue(MdlFile.VertexUsage.Color, out var colours); + var nColors = colours?.Count ?? 0; + + var materialUsages = ( + uvCount, + nColors + ); + + return materialUsages switch + { + (3, 2) => typeof(VertexTexture3Color2Ffxiv), + (3, 1) => typeof(VertexTexture3ColorFfxiv), + (3, 0) => typeof(VertexTexture3), + (2, 2) => typeof(VertexTexture2Color2Ffxiv), + (2, 1) => typeof(VertexTexture2ColorFfxiv), + (2, 0) => typeof(VertexTexture2), + (1, 2) => typeof(VertexTexture1Color2Ffxiv), + (1, 1) => typeof(VertexTexture1ColorFfxiv), + (1, 0) => typeof(VertexTexture1), + (0, 2) => typeof(VertexColor2Ffxiv), + (0, 1) => typeof(VertexColorFfxiv), + (0, 0) => typeof(VertexEmpty), + + _ => throw _notifier.Exception($"Unhandled UV/color count of {uvCount}/{nColors} encountered."), + }; + } + + /// Build a material vertex from a vertex's attributes. + private IVertexMaterial BuildVertexMaterial(IReadOnlyDictionary> attributes) + { + if (_materialType == typeof(VertexEmpty)) + return new VertexEmpty(); + + if (_materialType == typeof(VertexColorFfxiv)) + return new VertexColorFfxiv(ToVector4(GetFirstSafe(attributes, MdlFile.VertexUsage.Color))); + + if (_materialType == typeof(VertexColor2Ffxiv)) + { + var (color0, color1) = GetBothSafe(attributes, MdlFile.VertexUsage.Color); + return new VertexColor2Ffxiv(ToVector4(color0), ToVector4(color1)); + } + + if (_materialType == typeof(VertexTexture1)) + return new VertexTexture1(ToVector2(GetFirstSafe(attributes, MdlFile.VertexUsage.UV))); + + if (_materialType == typeof(VertexTexture1ColorFfxiv)) + return new VertexTexture1ColorFfxiv( + ToVector2(GetFirstSafe(attributes, MdlFile.VertexUsage.UV)), + ToVector4(GetFirstSafe(attributes, MdlFile.VertexUsage.Color)) + ); + + if (_materialType == typeof(VertexTexture1Color2Ffxiv)) + { + var (color0, color1) = GetBothSafe(attributes, MdlFile.VertexUsage.Color); + return new VertexTexture1Color2Ffxiv( + ToVector2(GetFirstSafe(attributes, MdlFile.VertexUsage.UV)), + ToVector4(color0), + ToVector4(color1) + ); + } + + // XIV packs two UVs into a single vec4 attribute. + + if (_materialType == typeof(VertexTexture2)) + { + var uv = ToVector4(GetFirstSafe(attributes, MdlFile.VertexUsage.UV)); + return new VertexTexture2( + new Vector2(uv.X, uv.Y), + new Vector2(uv.Z, uv.W) + ); + } + + if (_materialType == typeof(VertexTexture2ColorFfxiv)) + { + var uv = ToVector4(GetFirstSafe(attributes, MdlFile.VertexUsage.UV)); + return new VertexTexture2ColorFfxiv( + new Vector2(uv.X, uv.Y), + new Vector2(uv.Z, uv.W), + ToVector4(GetFirstSafe(attributes, MdlFile.VertexUsage.Color)) + ); + } + + if (_materialType == typeof(VertexTexture2Color2Ffxiv)) + { + var uv = ToVector4(GetFirstSafe(attributes, MdlFile.VertexUsage.UV)); + var (color0, color1) = GetBothSafe(attributes, MdlFile.VertexUsage.Color); + + return new VertexTexture2Color2Ffxiv( + new Vector2(uv.X, uv.Y), + new Vector2(uv.Z, uv.W), + ToVector4(color0), + ToVector4(color1) + ); + } + + if (_materialType == typeof(VertexTexture3)) + { + // Not 100% sure about this + var uv0 = ToVector4(attributes[MdlFile.VertexUsage.UV][0]); + var uv1 = ToVector4(attributes[MdlFile.VertexUsage.UV][1]); + return new VertexTexture3( + new Vector2(uv0.X, uv0.Y), + new Vector2(uv0.Z, uv0.W), + new Vector2(uv1.X, uv1.Y) + ); + } + + if (_materialType == typeof(VertexTexture3ColorFfxiv)) + { + var uv0 = ToVector4(attributes[MdlFile.VertexUsage.UV][0]); + var uv1 = ToVector4(attributes[MdlFile.VertexUsage.UV][1]); + return new VertexTexture3ColorFfxiv( + new Vector2(uv0.X, uv0.Y), + new Vector2(uv0.Z, uv0.W), + new Vector2(uv1.X, uv1.Y), + ToVector4(GetFirstSafe(attributes, MdlFile.VertexUsage.Color)) + ); + } + + if (_materialType == typeof(VertexTexture3Color2Ffxiv)) + { + var uv0 = ToVector4(attributes[MdlFile.VertexUsage.UV][0]); + var uv1 = ToVector4(attributes[MdlFile.VertexUsage.UV][1]); + var (color0, color1) = GetBothSafe(attributes, MdlFile.VertexUsage.Color); + + return new VertexTexture3Color2Ffxiv( + new Vector2(uv0.X, uv0.Y), + new Vector2(uv0.Z, uv0.W), + new Vector2(uv1.X, uv1.Y), + ToVector4(color0), + ToVector4(color1) + ); + } + + throw _notifier.Exception($"Unknown material type {_skinningType}"); + } + + /// Get the vertex skinning type for this mesh's vertex usages. + private Type GetSkinningType(IReadOnlyDictionary> usages) + { + if (usages.ContainsKey(MdlFile.VertexUsage.BlendWeights) && usages.ContainsKey(MdlFile.VertexUsage.BlendIndices)) + { + return GetFirstSafe(usages, MdlFile.VertexUsage.BlendWeights) == MdlFile.VertexType.UShort4 + ? typeof(VertexJoints8) + : typeof(VertexJoints4); + } + + return typeof(VertexEmpty); + } + + /// Build a skinning vertex from a vertex's attributes. + private IVertexSkinning BuildVertexSkinning(IReadOnlyDictionary> attributes) + { + if (_skinningType == typeof(VertexEmpty)) + return new VertexEmpty(); + + if (_skinningType == typeof(VertexJoints4) || _skinningType == typeof(VertexJoints8)) + { + if (_boneIndexMap == null) + throw _notifier.Exception("Tried to build skinned vertex but no bone mappings are available."); + + var indiciesData = GetFirstSafe(attributes, MdlFile.VertexUsage.BlendIndices); + var weightsData = GetFirstSafe(attributes, MdlFile.VertexUsage.BlendWeights); + var indices = ToByteArray(indiciesData); + var weights = ToFloatArray(weightsData); + + var bindings = Enumerable.Range(0, indices.Length) + .Select(bindingIndex => + { + // NOTE: I've not seen any files that throw this error that aren't completely broken. + var xivBoneIndex = indices[bindingIndex]; + if (!_boneIndexMap.TryGetValue(xivBoneIndex, out var jointIndex)) + throw _notifier.Exception($"Vertex contains weight for unknown bone index {xivBoneIndex}."); + + return (jointIndex, weights[bindingIndex]); + }) + .ToArray(); + + return bindings.Length switch + { + 4 => new VertexJoints4(bindings), + 8 => new VertexJoints8(bindings), + _ => throw _notifier.Exception($"Invalid number of bone bindings {bindings.Length}.") + }; + } + + throw _notifier.Exception($"Unknown skinning type {_skinningType}"); + } + + /// Check that the list has length 1 for any case where this is expected and return the one entry. + [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] + private T GetFirstSafe(IReadOnlyDictionary> attributes, MdlFile.VertexUsage usage) + { + var list = attributes[usage]; + if (list.Count != 1) + throw _notifier.Exception($"Multiple usage indices encountered for {usage}."); + + return list[0]; + } + + /// Check that the list has length 2 for any case where this is expected and return both entries. + [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] + private (T First, T Second) GetBothSafe(IReadOnlyDictionary> attributes, MdlFile.VertexUsage usage) + { + var list = attributes[usage]; + if (list.Count != 2) + throw _notifier.Exception($"{list.Count} usage indices encountered for {usage}, but expected 2."); + + return (list[0], list[1]); + } + + /// Convert a vertex attribute value to a Vector2. Supported inputs are Vector2, Vector3, and Vector4. + private static Vector2 ToVector2(object data) + => data switch + { + Vector2 v2 => v2, + Vector3 v3 => new Vector2(v3.X, v3.Y), + Vector4 v4 => new Vector2(v4.X, v4.Y), + _ => throw new ArgumentOutOfRangeException($"Invalid Vector2 input {data}"), + }; + + /// Convert a vertex attribute value to a Vector3. Supported inputs are Vector2, Vector3, and Vector4. + private static Vector3 ToVector3(object data) + => data switch + { + Vector2 v2 => new Vector3(v2.X, v2.Y, 0), + Vector3 v3 => v3, + Vector4 v4 => new Vector3(v4.X, v4.Y, v4.Z), + _ => throw new ArgumentOutOfRangeException($"Invalid Vector3 input {data}"), + }; + + /// Convert a vertex attribute value to a Vector4. Supported inputs are Vector2, Vector3, and Vector4. + private static Vector4 ToVector4(object data) + => data switch + { + Vector2 v2 => new Vector4(v2.X, v2.Y, 0, 0), + Vector3 v3 => new Vector4(v3.X, v3.Y, v3.Z, 1), + Vector4 v4 => v4, + _ => throw new ArgumentOutOfRangeException($"Invalid Vector3 input {data}"), + }; + + /// Convert a vertex attribute value to a byte array. + private static byte[] ToByteArray(object data) + => data switch + { + byte[] value => value, + _ => throw new ArgumentOutOfRangeException($"Invalid byte[] input {data}"), + }; + + private static float[] ToFloatArray(object data) + => data switch + { + byte[] value => value.Select(x => x / 255f).ToArray(), + Vector4 v4 => new[] { v4.X, v4.Y, v4.Z, v4.W }, + _ => throw new ArgumentOutOfRangeException($"Invalid float[] input {data}"), + }; +} + diff --git a/Penumbra/Import/Models/Export/ModelExporter.cs b/Penumbra/Import/Models/Export/ModelExporter.cs new file mode 100644 index 00000000..55997ef8 --- /dev/null +++ b/Penumbra/Import/Models/Export/ModelExporter.cs @@ -0,0 +1,112 @@ +using Penumbra.GameData.Files; +using SharpGLTF.Materials; +using SharpGLTF.Scenes; +using SharpGLTF.Transforms; + +namespace Penumbra.Import.Models.Export; + +public class ModelExporter +{ + public class Model(List meshes, GltfSkeleton? skeleton) + { + public void AddToScene(SceneBuilder scene) + { + // If there's a skeleton, the root node should be added before we add any potentially skinned meshes. + var skeletonRoot = skeleton?.Root; + if (skeletonRoot != null) + scene.AddNode(skeletonRoot); + + // Add all the meshes to the scene. + foreach (var mesh in meshes) + mesh.AddToScene(scene); + } + } + + /// Export a model in preparation for usage in a glTF file. If provided, skeleton will be used to skin the resulting meshes where appropriate. + public static Model Export(in ExportConfig config, MdlFile mdl, IEnumerable xivSkeletons, Dictionary rawMaterials, IoNotifier notifier) + { + var gltfSkeleton = ConvertSkeleton(xivSkeletons); + var materials = ConvertMaterials(mdl, rawMaterials, notifier); + var meshes = ConvertMeshes(config, mdl, materials, gltfSkeleton, notifier); + return new Model(meshes, gltfSkeleton); + } + + /// Convert a .mdl to a mesh (group) per LoD. + private static List ConvertMeshes(in ExportConfig config, MdlFile mdl, MaterialBuilder[] materials, GltfSkeleton? skeleton, IoNotifier notifier) + { + var meshes = new List(); + + for (byte lodIndex = 0; lodIndex < mdl.LodCount; lodIndex++) + { + var lod = mdl.Lods[lodIndex]; + + // TODO: consider other types of mesh? + for (ushort meshOffset = 0; meshOffset < lod.MeshCount; meshOffset++) + { + var meshIndex = (ushort)(lod.MeshIndex + meshOffset); + var mesh = MeshExporter.Export(config, mdl, lodIndex, meshIndex, materials, skeleton, notifier.WithContext($"Mesh {meshIndex}")); + meshes.Add(mesh); + } + } + + return meshes; + } + + /// Build materials for each of the material slots in the .mdl. + private static MaterialBuilder[] ConvertMaterials(MdlFile mdl, Dictionary rawMaterials, IoNotifier notifier) + => mdl.Materials + .Select(name => + { + if (rawMaterials.TryGetValue(name, out var rawMaterial)) + return MaterialExporter.Export(rawMaterial, name, notifier.WithContext($"Material {name}")); + + notifier.Warning($"Material \"{name}\" missing, using blank fallback."); + return MaterialExporter.Unknown; + }) + .ToArray(); + + /// Convert XIV skeleton data into a glTF-compatible node tree, with mappings. + private static GltfSkeleton? ConvertSkeleton(IEnumerable skeletons) + { + NodeBuilder? root = null; + var names = new Dictionary(); + var joints = new List(); + + // Flatten out the bones across all the received skeletons, but retain a reference to the parent skeleton for lookups. + var iterator = skeletons.SelectMany(skeleton => skeleton.Bones.Select(bone => (skeleton, bone))); + foreach (var (skeleton, bone) in iterator) + { + if (names.ContainsKey(bone.Name)) + continue; + + var node = new NodeBuilder(bone.Name); + names[bone.Name] = joints.Count; + joints.Add(node); + + node.SetLocalTransform(new AffineTransform( + bone.Transform.Scale, + bone.Transform.Rotation, + bone.Transform.Translation + ), false); + + if (bone.ParentIndex == -1) + { + root = node; + continue; + } + + var parent = joints[names[skeleton.Bones[bone.ParentIndex].Name]]; + parent.AddNode(node); + } + + if (root == null) + return null; + + return new GltfSkeleton + { + Root = root, + Joints = joints, + Names = names, + }; + } +} diff --git a/Penumbra/Import/Models/Export/Skeleton.cs b/Penumbra/Import/Models/Export/Skeleton.cs new file mode 100644 index 00000000..ca72a1f8 --- /dev/null +++ b/Penumbra/Import/Models/Export/Skeleton.cs @@ -0,0 +1,45 @@ +using SharpGLTF.Scenes; + +namespace Penumbra.Import.Models.Export; + +/// Representation of a skeleton within XIV. +public class XivSkeleton(XivSkeleton.Bone[] bones) +{ + public Bone[] Bones = bones; + + public struct Bone + { + public string Name; + public int ParentIndex; + public Transform Transform; + } + + public struct Transform { + public Vector3 Scale; + public Quaternion Rotation; + public Vector3 Translation; + } +} + +/// Representation of a glTF-compatible skeleton. +public struct GltfSkeleton +{ + /// Root node of the skeleton. + public NodeBuilder Root; + + /// Flattened list of skeleton nodes. + public List Joints; + + /// Mapping of bone names to their index within the joints array. + public Dictionary Names; + + public (NodeBuilder, int) GenerateBone(string name) + { + var node = new NodeBuilder(name); + var index = Joints.Count; + Names[name] = index; + Joints.Add(node); + Root.AddNode(node); + return (node, index); + } +} diff --git a/Penumbra/Import/Models/Export/VertexFragment.cs b/Penumbra/Import/Models/Export/VertexFragment.cs new file mode 100644 index 00000000..463c59fc --- /dev/null +++ b/Penumbra/Import/Models/Export/VertexFragment.cs @@ -0,0 +1,819 @@ +using SharpGLTF.Geometry.VertexTypes; +using SharpGLTF.Memory; +using SharpGLTF.Schema2; + +namespace Penumbra.Import.Models.Export; + +/* +Yeah, look, I tried to make this file less garbage. It's a little difficult. +Realistically, it will need to stick around until transforms/mutations are built +and there's reason to overhaul the export pipeline. +*/ + +public struct VertexColorFfxiv(Vector4 ffxivColor) : IVertexCustom +{ + public IEnumerable> GetEncodingAttributes() + { + // NOTE: We only realistically require UNSIGNED_BYTE for this, however Blender 3.6 errors on that (fixed in 4.0). + yield return new KeyValuePair("_FFXIV_COLOR", + new AttributeFormat(DimensionType.VEC4, EncodingType.UNSIGNED_SHORT, true)); + } + + public Vector4 FfxivColor = ffxivColor; + + public int MaxColors + => 0; + + public int MaxTextCoords + => 0; + + private static readonly string[] CustomNames = ["_FFXIV_COLOR"]; + + public IEnumerable CustomAttributes + => CustomNames; + + public void Add(in VertexMaterialDelta delta) + { } + + public VertexMaterialDelta Subtract(IVertexMaterial baseValue) + => new(Vector4.Zero, Vector4.Zero, Vector2.Zero, Vector2.Zero); + + public Vector2 GetTexCoord(int index) + => throw new ArgumentOutOfRangeException(nameof(index)); + + public void SetTexCoord(int setIndex, Vector2 coord) + { } + + public bool TryGetCustomAttribute(string attributeName, out object? value) + { + switch (attributeName) + { + case "_FFXIV_COLOR": + value = FfxivColor; + return true; + + default: + value = null; + return false; + } + } + + public void SetCustomAttribute(string attributeName, object value) + { + if (attributeName == "_FFXIV_COLOR" && value is Vector4 valueVector4) + FfxivColor = valueVector4; + } + + public Vector4 GetColor(int index) + => throw new ArgumentOutOfRangeException(nameof(index)); + + public void SetColor(int setIndex, Vector4 color) + { } + + public void Validate() + { + var components = new[] + { + FfxivColor.X, + FfxivColor.Y, + FfxivColor.Z, + FfxivColor.W, + }; + if (components.Any(component => component < 0 || component > 1)) + throw new ArgumentOutOfRangeException(nameof(FfxivColor)); + } +} + +public struct VertexColor2Ffxiv(Vector4 ffxivColor0, Vector4 ffxivColor1) : IVertexCustom +{ + public IEnumerable> GetEncodingAttributes() + { + yield return new KeyValuePair("_FFXIV_COLOR_0", + new AttributeFormat(DimensionType.VEC4, EncodingType.UNSIGNED_SHORT, true)); + yield return new KeyValuePair("_FFXIV_COLOR_1", + new AttributeFormat(DimensionType.VEC4, EncodingType.UNSIGNED_SHORT, true)); + } + + public Vector4 FfxivColor0 = ffxivColor0; + public Vector4 FfxivColor1 = ffxivColor1; + + public int MaxColors + => 0; + + public int MaxTextCoords + => 0; + + private static readonly string[] CustomNames = ["_FFXIV_COLOR_0", "_FFXIV_COLOR_1"]; + + public IEnumerable CustomAttributes + => CustomNames; + + public void Add(in VertexMaterialDelta delta) + { } + + public VertexMaterialDelta Subtract(IVertexMaterial baseValue) + => new(Vector4.Zero, Vector4.Zero, Vector2.Zero, Vector2.Zero); + + public Vector2 GetTexCoord(int index) + => throw new ArgumentOutOfRangeException(nameof(index)); + + public void SetTexCoord(int setIndex, Vector2 coord) + { } + + public bool TryGetCustomAttribute(string attributeName, out object? value) + { + switch (attributeName) + { + case "_FFXIV_COLOR_0": + value = FfxivColor0; + return true; + + case "_FFXIV_COLOR_1": + value = FfxivColor1; + return true; + + default: + value = null; + return false; + } + } + + public void SetCustomAttribute(string attributeName, object value) + { + switch (attributeName) + { + case "_FFXIV_COLOR_0" when value is Vector4 valueVector4: + FfxivColor0 = valueVector4; + break; + case "_FFXIV_COLOR_1" when value is Vector4 valueVector4: + FfxivColor1 = valueVector4; + break; + } + } + + public Vector4 GetColor(int index) + => throw new ArgumentOutOfRangeException(nameof(index)); + + public void SetColor(int setIndex, Vector4 color) + { } + + public void Validate() + { + var components = new[] + { + FfxivColor0.X, + FfxivColor0.Y, + FfxivColor0.Z, + FfxivColor0.W, + }; + if (components.Any(component => component is < 0 or > 1)) + throw new ArgumentOutOfRangeException(nameof(FfxivColor0)); + components = + [ + FfxivColor1.X, + FfxivColor1.Y, + FfxivColor1.Z, + FfxivColor1.W, + ]; + if (components.Any(component => component is < 0 or > 1)) + throw new ArgumentOutOfRangeException(nameof(FfxivColor1)); + } +} + + +public struct VertexTexture1ColorFfxiv(Vector2 texCoord0, Vector4 ffxivColor) : IVertexCustom +{ + public IEnumerable> GetEncodingAttributes() + { + yield return new KeyValuePair("TEXCOORD_0", + new AttributeFormat(DimensionType.VEC2, EncodingType.FLOAT, false)); + yield return new KeyValuePair("_FFXIV_COLOR", + new AttributeFormat(DimensionType.VEC4, EncodingType.UNSIGNED_SHORT, true)); + } + + public Vector2 TexCoord0 = texCoord0; + + public Vector4 FfxivColor = ffxivColor; + + public int MaxColors + => 0; + + public int MaxTextCoords + => 1; + + private static readonly string[] CustomNames = ["_FFXIV_COLOR"]; + + public IEnumerable CustomAttributes + => CustomNames; + + public void Add(in VertexMaterialDelta delta) + { + TexCoord0 += delta.TexCoord0Delta; + } + + public VertexMaterialDelta Subtract(IVertexMaterial baseValue) + => new(Vector4.Zero, Vector4.Zero, TexCoord0 - baseValue.GetTexCoord(0), Vector2.Zero); + + public Vector2 GetTexCoord(int index) + => index switch + { + 0 => TexCoord0, + _ => throw new ArgumentOutOfRangeException(nameof(index)), + }; + + public void SetTexCoord(int setIndex, Vector2 coord) + { + if (setIndex == 0) + TexCoord0 = coord; + if (setIndex >= 1) + throw new ArgumentOutOfRangeException(nameof(setIndex)); + } + + public bool TryGetCustomAttribute(string attributeName, out object? value) + { + switch (attributeName) + { + case "_FFXIV_COLOR": + value = FfxivColor; + return true; + + default: + value = null; + return false; + } + } + + public void SetCustomAttribute(string attributeName, object value) + { + if (attributeName == "_FFXIV_COLOR" && value is Vector4 valueVector4) + FfxivColor = valueVector4; + } + + public Vector4 GetColor(int index) + => throw new ArgumentOutOfRangeException(nameof(index)); + + public void SetColor(int setIndex, Vector4 color) + { } + + public void Validate() + { + var components = new[] + { + FfxivColor.X, + FfxivColor.Y, + FfxivColor.Z, + FfxivColor.W, + }; + if (components.Any(component => component < 0 || component > 1)) + throw new ArgumentOutOfRangeException(nameof(FfxivColor)); + } +} + +public struct VertexTexture1Color2Ffxiv(Vector2 texCoord0, Vector4 ffxivColor0, Vector4 ffxivColor1) : IVertexCustom +{ + public IEnumerable> GetEncodingAttributes() + { + yield return new KeyValuePair("TEXCOORD_0", + new AttributeFormat(DimensionType.VEC2, EncodingType.FLOAT, false)); + yield return new KeyValuePair("_FFXIV_COLOR_0", + new AttributeFormat(DimensionType.VEC4, EncodingType.UNSIGNED_SHORT, true)); + yield return new KeyValuePair("_FFXIV_COLOR_1", + new AttributeFormat(DimensionType.VEC4, EncodingType.UNSIGNED_SHORT, true)); + } + + public Vector2 TexCoord0 = texCoord0; + + public Vector4 FfxivColor0 = ffxivColor0; + public Vector4 FfxivColor1 = ffxivColor1; + + public int MaxColors + => 0; + + public int MaxTextCoords + => 1; + + private static readonly string[] CustomNames = ["_FFXIV_COLOR_0", "_FFXIV_COLOR_1"]; + + public IEnumerable CustomAttributes + => CustomNames; + + public void Add(in VertexMaterialDelta delta) + { + TexCoord0 += delta.TexCoord0Delta; + } + + public VertexMaterialDelta Subtract(IVertexMaterial baseValue) + => new(Vector4.Zero, Vector4.Zero, TexCoord0 - baseValue.GetTexCoord(0), Vector2.Zero); + + public Vector2 GetTexCoord(int index) + => index switch + { + 0 => TexCoord0, + _ => throw new ArgumentOutOfRangeException(nameof(index)), + }; + + public void SetTexCoord(int setIndex, Vector2 coord) + { + if (setIndex == 0) + TexCoord0 = coord; + if (setIndex >= 1) + throw new ArgumentOutOfRangeException(nameof(setIndex)); + } + + public bool TryGetCustomAttribute(string attributeName, out object? value) + { + switch (attributeName) + { + case "_FFXIV_COLOR_0": + value = FfxivColor0; + return true; + + case "_FFXIV_COLOR_1": + value = FfxivColor1; + return true; + + default: + value = null; + return false; + } + } + + public void SetCustomAttribute(string attributeName, object value) + { + switch (attributeName) + { + case "_FFXIV_COLOR_0" when value is Vector4 valueVector4: + FfxivColor0 = valueVector4; + break; + case "_FFXIV_COLOR_1" when value is Vector4 valueVector4: + FfxivColor1 = valueVector4; + break; + } + } + + public Vector4 GetColor(int index) + => throw new ArgumentOutOfRangeException(nameof(index)); + + public void SetColor(int setIndex, Vector4 color) + { } + + public void Validate() + { + var components = new[] + { + FfxivColor0.X, + FfxivColor0.Y, + FfxivColor0.Z, + FfxivColor0.W, + }; + if (components.Any(component => component is < 0 or > 1)) + throw new ArgumentOutOfRangeException(nameof(FfxivColor0)); + components = + [ + FfxivColor1.X, + FfxivColor1.Y, + FfxivColor1.Z, + FfxivColor1.W, + ]; + if (components.Any(component => component is < 0 or > 1)) + throw new ArgumentOutOfRangeException(nameof(FfxivColor1)); + } +} + + +public struct VertexTexture2ColorFfxiv(Vector2 texCoord0, Vector2 texCoord1, Vector4 ffxivColor) : IVertexCustom +{ + public IEnumerable> GetEncodingAttributes() + { + yield return new KeyValuePair("TEXCOORD_0", + new AttributeFormat(DimensionType.VEC2, EncodingType.FLOAT, false)); + yield return new KeyValuePair("TEXCOORD_1", + new AttributeFormat(DimensionType.VEC2, EncodingType.FLOAT, false)); + yield return new KeyValuePair("_FFXIV_COLOR", + new AttributeFormat(DimensionType.VEC4, EncodingType.UNSIGNED_SHORT, true)); + } + + public Vector2 TexCoord0 = texCoord0; + public Vector2 TexCoord1 = texCoord1; + public Vector4 FfxivColor = ffxivColor; + + public int MaxColors + => 0; + + public int MaxTextCoords + => 2; + + private static readonly string[] CustomNames = ["_FFXIV_COLOR"]; + + public IEnumerable CustomAttributes + => CustomNames; + + public void Add(in VertexMaterialDelta delta) + { + TexCoord0 += delta.TexCoord0Delta; + TexCoord1 += delta.TexCoord1Delta; + } + + public VertexMaterialDelta Subtract(IVertexMaterial baseValue) + => new(Vector4.Zero, Vector4.Zero, TexCoord0 - baseValue.GetTexCoord(0), TexCoord1 - baseValue.GetTexCoord(1)); + + public Vector2 GetTexCoord(int index) + => index switch + { + 0 => TexCoord0, + 1 => TexCoord1, + _ => throw new ArgumentOutOfRangeException(nameof(index)), + }; + + public void SetTexCoord(int setIndex, Vector2 coord) + { + if (setIndex == 0) + TexCoord0 = coord; + if (setIndex == 1) + TexCoord1 = coord; + if (setIndex >= 2) + throw new ArgumentOutOfRangeException(nameof(setIndex)); + } + + public bool TryGetCustomAttribute(string attributeName, out object? value) + { + switch (attributeName) + { + case "_FFXIV_COLOR": + value = FfxivColor; + return true; + + default: + value = null; + return false; + } + } + + public void SetCustomAttribute(string attributeName, object value) + { + if (attributeName == "_FFXIV_COLOR" && value is Vector4 valueVector4) + FfxivColor = valueVector4; + } + + public Vector4 GetColor(int index) + => throw new ArgumentOutOfRangeException(nameof(index)); + + public void SetColor(int setIndex, Vector4 color) + { } + + public void Validate() + { + var components = new[] + { + FfxivColor.X, + FfxivColor.Y, + FfxivColor.Z, + FfxivColor.W, + }; + if (components.Any(component => component < 0 || component > 1)) + throw new ArgumentOutOfRangeException(nameof(FfxivColor)); + } +} + +public struct VertexTexture2Color2Ffxiv(Vector2 texCoord0, Vector2 texCoord1, Vector4 ffxivColor0, Vector4 ffxivColor1) : IVertexCustom +{ + public IEnumerable> GetEncodingAttributes() + { + yield return new KeyValuePair("TEXCOORD_0", + new AttributeFormat(DimensionType.VEC2, EncodingType.FLOAT, false)); + yield return new KeyValuePair("TEXCOORD_1", + new AttributeFormat(DimensionType.VEC2, EncodingType.FLOAT, false)); + yield return new KeyValuePair("_FFXIV_COLOR_0", + new AttributeFormat(DimensionType.VEC4, EncodingType.UNSIGNED_SHORT, true)); + yield return new KeyValuePair("_FFXIV_COLOR_1", + new AttributeFormat(DimensionType.VEC4, EncodingType.UNSIGNED_SHORT, true)); + } + + public Vector2 TexCoord0 = texCoord0; + public Vector2 TexCoord1 = texCoord1; + public Vector4 FfxivColor0 = ffxivColor0; + public Vector4 FfxivColor1 = ffxivColor1; + + public int MaxColors + => 0; + + public int MaxTextCoords + => 2; + + private static readonly string[] CustomNames = ["_FFXIV_COLOR_0", "_FFXIV_COLOR_1"]; + + public IEnumerable CustomAttributes + => CustomNames; + + public void Add(in VertexMaterialDelta delta) + { + TexCoord0 += delta.TexCoord0Delta; + TexCoord1 += delta.TexCoord1Delta; + } + + public VertexMaterialDelta Subtract(IVertexMaterial baseValue) + => new(Vector4.Zero, Vector4.Zero, TexCoord0 - baseValue.GetTexCoord(0), TexCoord1 - baseValue.GetTexCoord(1)); + + public Vector2 GetTexCoord(int index) + => index switch + { + 0 => TexCoord0, + 1 => TexCoord1, + _ => throw new ArgumentOutOfRangeException(nameof(index)), + }; + + public void SetTexCoord(int setIndex, Vector2 coord) + { + if (setIndex == 0) + TexCoord0 = coord; + if (setIndex == 1) + TexCoord1 = coord; + if (setIndex >= 2) + throw new ArgumentOutOfRangeException(nameof(setIndex)); + } + + public bool TryGetCustomAttribute(string attributeName, out object? value) + { + switch (attributeName) + { + case "_FFXIV_COLOR_0": + value = FfxivColor0; + return true; + + case "_FFXIV_COLOR_1": + value = FfxivColor1; + return true; + + default: + value = null; + return false; + } + } + + public void SetCustomAttribute(string attributeName, object value) + { + switch (attributeName) + { + case "_FFXIV_COLOR_0" when value is Vector4 valueVector4: + FfxivColor0 = valueVector4; + break; + case "_FFXIV_COLOR_1" when value is Vector4 valueVector4: + FfxivColor1 = valueVector4; + break; + } + } + + public Vector4 GetColor(int index) + => throw new ArgumentOutOfRangeException(nameof(index)); + + public void SetColor(int setIndex, Vector4 color) + { } + + public void Validate() + { + var components = new[] + { + FfxivColor0.X, + FfxivColor0.Y, + FfxivColor0.Z, + FfxivColor0.W, + }; + if (components.Any(component => component is < 0 or > 1)) + throw new ArgumentOutOfRangeException(nameof(FfxivColor0)); + components = + [ + FfxivColor1.X, + FfxivColor1.Y, + FfxivColor1.Z, + FfxivColor1.W, + ]; + if (components.Any(component => component is < 0 or > 1)) + throw new ArgumentOutOfRangeException(nameof(FfxivColor1)); + } + +} + +public struct VertexTexture3ColorFfxiv(Vector2 texCoord0, Vector2 texCoord1, Vector2 texCoord2, Vector4 ffxivColor) + : IVertexCustom +{ + public IEnumerable> GetEncodingAttributes() + { + yield return new KeyValuePair("TEXCOORD_0", + new AttributeFormat(DimensionType.VEC2, EncodingType.FLOAT, false)); + yield return new KeyValuePair("TEXCOORD_1", + new AttributeFormat(DimensionType.VEC2, EncodingType.FLOAT, false)); + yield return new KeyValuePair("TEXCOORD_2", + new AttributeFormat(DimensionType.VEC2, EncodingType.FLOAT, false)); + yield return new KeyValuePair("_FFXIV_COLOR", + new AttributeFormat(DimensionType.VEC4, EncodingType.UNSIGNED_SHORT, true)); + } + + public Vector2 TexCoord0 = texCoord0; + public Vector2 TexCoord1 = texCoord1; + public Vector2 TexCoord2 = texCoord2; + public Vector4 FfxivColor = ffxivColor; + + public int MaxColors + => 0; + + public int MaxTextCoords + => 3; + + private static readonly string[] CustomNames = ["_FFXIV_COLOR"]; + + public IEnumerable CustomAttributes + => CustomNames; + + public void Add(in VertexMaterialDelta delta) + { + TexCoord0 += delta.TexCoord0Delta; + TexCoord1 += delta.TexCoord1Delta; + TexCoord2 += delta.TexCoord2Delta; + } + + public VertexMaterialDelta Subtract(IVertexMaterial baseValue) + => new(Vector4.Zero, Vector4.Zero, TexCoord0 - baseValue.GetTexCoord(0), TexCoord1 - baseValue.GetTexCoord(1)); + + public Vector2 GetTexCoord(int index) + => index switch + { + 0 => TexCoord0, + 1 => TexCoord1, + 2 => TexCoord2, + _ => throw new ArgumentOutOfRangeException(nameof(index)), + }; + + public void SetTexCoord(int setIndex, Vector2 coord) + { + if (setIndex == 0) + TexCoord0 = coord; + if (setIndex == 1) + TexCoord1 = coord; + if (setIndex == 2) + TexCoord2 = coord; + if (setIndex >= 3) + throw new ArgumentOutOfRangeException(nameof(setIndex)); + } + + public bool TryGetCustomAttribute(string attributeName, out object? value) + { + switch (attributeName) + { + case "_FFXIV_COLOR": + value = FfxivColor; + return true; + + default: + value = null; + return false; + } + } + + public void SetCustomAttribute(string attributeName, object value) + { + if (attributeName == "_FFXIV_COLOR" && value is Vector4 valueVector4) + FfxivColor = valueVector4; + } + + public Vector4 GetColor(int index) + => throw new ArgumentOutOfRangeException(nameof(index)); + + public void SetColor(int setIndex, Vector4 color) + { } + + public void Validate() + { + var components = new[] + { + FfxivColor.X, + FfxivColor.Y, + FfxivColor.Z, + FfxivColor.W, + }; + if (components.Any(component => component is < 0f or > 1f)) + throw new ArgumentOutOfRangeException(nameof(FfxivColor)); + } +} + +public struct VertexTexture3Color2Ffxiv(Vector2 texCoord0, Vector2 texCoord1, Vector2 texCoord2, Vector4 ffxivColor0, Vector4 ffxivColor1) + : IVertexCustom +{ + public IEnumerable> GetEncodingAttributes() + { + yield return new KeyValuePair("TEXCOORD_0", + new AttributeFormat(DimensionType.VEC2, EncodingType.FLOAT, false)); + yield return new KeyValuePair("TEXCOORD_1", + new AttributeFormat(DimensionType.VEC2, EncodingType.FLOAT, false)); + yield return new KeyValuePair("_FFXIV_COLOR_0", + new AttributeFormat(DimensionType.VEC4, EncodingType.UNSIGNED_SHORT, true)); + yield return new KeyValuePair("_FFXIV_COLOR_1", + new AttributeFormat(DimensionType.VEC4, EncodingType.UNSIGNED_SHORT, true)); + } + + public Vector2 TexCoord0 = texCoord0; + public Vector2 TexCoord1 = texCoord1; + public Vector2 TexCoord2 = texCoord2; + public Vector4 FfxivColor0 = ffxivColor0; + public Vector4 FfxivColor1 = ffxivColor1; + + public int MaxColors + => 0; + + public int MaxTextCoords + => 3; + + private static readonly string[] CustomNames = ["_FFXIV_COLOR_0", "_FFXIV_COLOR_1"]; + + public IEnumerable CustomAttributes + => CustomNames; + + public void Add(in VertexMaterialDelta delta) + { + TexCoord0 += delta.TexCoord0Delta; + TexCoord1 += delta.TexCoord1Delta; + TexCoord2 += delta.TexCoord2Delta; + } + + public VertexMaterialDelta Subtract(IVertexMaterial baseValue) + => new(Vector4.Zero, Vector4.Zero, TexCoord0 - baseValue.GetTexCoord(0), TexCoord1 - baseValue.GetTexCoord(1)); + + public Vector2 GetTexCoord(int index) + => index switch + { + 0 => TexCoord0, + 1 => TexCoord1, + 2 => TexCoord2, + _ => throw new ArgumentOutOfRangeException(nameof(index)), + }; + + public void SetTexCoord(int setIndex, Vector2 coord) + { + if (setIndex == 0) + TexCoord0 = coord; + if (setIndex == 1) + TexCoord1 = coord; + if (setIndex == 2) + TexCoord2 = coord; + if (setIndex >= 3) + throw new ArgumentOutOfRangeException(nameof(setIndex)); + } + + public bool TryGetCustomAttribute(string attributeName, out object? value) + { + switch (attributeName) + { + case "_FFXIV_COLOR_0": + value = FfxivColor0; + return true; + + case "_FFXIV_COLOR_1": + value = FfxivColor1; + return true; + + default: + value = null; + return false; + } + } + + public void SetCustomAttribute(string attributeName, object value) + { + switch (attributeName) + { + case "_FFXIV_COLOR_0" when value is Vector4 valueVector4: + FfxivColor0 = valueVector4; + break; + case "_FFXIV_COLOR_1" when value is Vector4 valueVector4: + FfxivColor1 = valueVector4; + break; + } + } + + public Vector4 GetColor(int index) + => throw new ArgumentOutOfRangeException(nameof(index)); + + public void SetColor(int setIndex, Vector4 color) + { } + + public void Validate() + { + var components = new[] + { + FfxivColor0.X, + FfxivColor0.Y, + FfxivColor0.Z, + FfxivColor0.W, + }; + if (components.Any(component => component is < 0 or > 1)) + throw new ArgumentOutOfRangeException(nameof(FfxivColor0)); + components = + [ + FfxivColor1.X, + FfxivColor1.Y, + FfxivColor1.Z, + FfxivColor1.W, + ]; + if (components.Any(component => component is < 0 or > 1)) + throw new ArgumentOutOfRangeException(nameof(FfxivColor1)); + } +} diff --git a/Penumbra/Import/Models/HavokConverter.cs b/Penumbra/Import/Models/HavokConverter.cs new file mode 100644 index 00000000..e3797083 --- /dev/null +++ b/Penumbra/Import/Models/HavokConverter.cs @@ -0,0 +1,137 @@ +using FFXIVClientStructs.Havok.Common.Base.System.IO.OStream; +using FFXIVClientStructs.Havok.Common.Base.Types; +using FFXIVClientStructs.Havok.Common.Serialize.Resource; +using FFXIVClientStructs.Havok.Common.Serialize.Util; + +namespace Penumbra.Import.Models; + +public static unsafe class HavokConverter +{ + /// Creates a temporary file and returns its path. + private static string CreateTempFile() + { + var stream = File.Create(Path.GetTempFileName()); + stream.Close(); + return stream.Name; + } + + /// Converts a .hkx file to a .xml file. + /// A byte array representing the .hkx file. + public static string HkxToXml(byte[] hkx) + { + const hkSerializeUtil.SaveOptionBits options = hkSerializeUtil.SaveOptionBits.SerializeIgnoredMembers + | hkSerializeUtil.SaveOptionBits.TextFormat + | hkSerializeUtil.SaveOptionBits.WriteAttributes; + + var tempHkx = CreateTempFile(); + File.WriteAllBytes(tempHkx, hkx); + + var resource = Read(tempHkx); + File.Delete(tempHkx); + + if (resource == null) + throw new Exception("Failed to read havok file."); + + var file = Write(resource, options); + file.Close(); + + var bytes = File.ReadAllText(file.Name); + File.Delete(file.Name); + + return bytes; + } + + /// Converts an .xml file to a .hkx file. + /// A string representing the .xml file. + public static byte[] XmlToHkx(string xml) + { + const hkSerializeUtil.SaveOptionBits options = hkSerializeUtil.SaveOptionBits.SerializeIgnoredMembers + | hkSerializeUtil.SaveOptionBits.WriteAttributes; + + var tempXml = CreateTempFile(); + File.WriteAllText(tempXml, xml); + + var resource = Read(tempXml); + File.Delete(tempXml); + + if (resource == null) + throw new Exception("Failed to read havok file."); + + var file = Write(resource, options); + file.Close(); + + var bytes = File.ReadAllBytes(file.Name); + File.Delete(file.Name); + + return bytes; + } + + /// + /// Parses a serialized file into an hkResource*. + /// The type is guessed automatically by Havok. + /// This pointer might be null - you should check for that. + /// + /// Path to a file on the filesystem. + private static hkResource* Read(string filePath) + { + var path = Encoding.UTF8.GetBytes(filePath); + var builtinTypeRegistry = hkBuiltinTypeRegistry.Instance(); + + var loadOptions = stackalloc hkSerializeUtil.LoadOptions[1]; + loadOptions->Flags = new hkFlags { Storage = (int)hkSerializeUtil.LoadOptionBits.Default }; + loadOptions->ClassNameRegistry = builtinTypeRegistry->GetClassNameRegistry(); + loadOptions->TypeInfoRegistry = builtinTypeRegistry->GetTypeInfoRegistry(); + + // TODO: probably can use LoadFromBuffer for this. + return hkSerializeUtil.LoadFromFile(path, null, loadOptions); + } + + /// Serializes an hkResource* to a temporary file. + /// A pointer to the hkResource, opened through Read(). + /// Flags representing how to serialize the file. + private static FileStream Write( + hkResource* resource, + hkSerializeUtil.SaveOptionBits optionBits + ) + { + var tempFile = CreateTempFile(); + var path = Encoding.UTF8.GetBytes(tempFile); + var oStream = new hkOstream(); + oStream.Ctor(path); + + var result = stackalloc hkResult[1]; + + var saveOptions = new hkSerializeUtil.SaveOptions() + { + Flags = new hkFlags { Storage = (int)optionBits }, + }; + + var builtinTypeRegistry = hkBuiltinTypeRegistry.Instance(); + var classNameRegistry = builtinTypeRegistry->GetClassNameRegistry(); + var typeInfoRegistry = builtinTypeRegistry->GetTypeInfoRegistry(); + + try + { + const string name = "hkRootLevelContainer"; + + var resourcePtr = (hkRootLevelContainer*)resource->GetContentsPointer(name, typeInfoRegistry); + if (resourcePtr == null) + throw new Exception("Failed to retrieve havok root level container resource."); + + var hkRootLevelContainerClass = classNameRegistry->GetClassByName(name); + if (hkRootLevelContainerClass == null) + throw new Exception("Failed to retrieve havok root level container type."); + + hkSerializeUtil.Save(result, resourcePtr, hkRootLevelContainerClass, oStream.StreamWriter.ptr, saveOptions); + } + finally + { + oStream.Dtor(); + } + + if (result->Result == hkResult.hkResultEnum.Failure) + throw new Exception("Failed to serialize havok file."); + + return new FileStream(tempFile, FileMode.Open); + } +} diff --git a/Penumbra/Import/Models/Import/BoundingBox.cs b/Penumbra/Import/Models/Import/BoundingBox.cs new file mode 100644 index 00000000..b6d670ae --- /dev/null +++ b/Penumbra/Import/Models/Import/BoundingBox.cs @@ -0,0 +1,33 @@ +using Lumina.Data.Parsing; + +namespace Penumbra.Import.Models.Import; + +/// Mutable representation of the bounding box surrounding a collection of vertices. +public class BoundingBox +{ + private Vector3 _minimum = new(float.PositiveInfinity, float.PositiveInfinity, float.PositiveInfinity); + private Vector3 _maximum = new(float.NegativeInfinity, float.NegativeInfinity, float.NegativeInfinity); + + /// Use the specified position to update this bounding box, expanding it if necessary. + public void Merge(Vector3 position) + { + _minimum = Vector3.Min(_minimum, position); + _maximum = Vector3.Max(_maximum, position); + } + + /// Merge the provided bounding box into this one, expanding it if necessary. + /// + public void Merge(BoundingBox other) + { + _minimum = Vector3.Min(_minimum, other._minimum); + _maximum = Vector3.Max(_maximum, other._maximum); + } + + /// Convert this bounding box to the struct format used in .mdl data structures. + public MdlStructs.BoundingBoxStruct ToStruct() + => new() + { + Min = [_minimum.X, _minimum.Y, _minimum.Z, 1], + Max = [_maximum.X, _maximum.Y, _maximum.Z, 1], + }; +} diff --git a/Penumbra/Import/Models/Import/MeshImporter.cs b/Penumbra/Import/Models/Import/MeshImporter.cs new file mode 100644 index 00000000..16fe2ca0 --- /dev/null +++ b/Penumbra/Import/Models/Import/MeshImporter.cs @@ -0,0 +1,249 @@ +using Lumina.Data.Parsing; +using OtterGui.Extensions; +using Penumbra.GameData.Files.ModelStructs; +using SharpGLTF.Schema2; + +namespace Penumbra.Import.Models.Import; + +public class MeshImporter(IEnumerable nodes, IoNotifier notifier) +{ + public struct Mesh + { + public MeshStruct MeshStruct; + public List SubMeshStructs; + + public string? Material; + + public MdlStructs.VertexDeclarationStruct VertexDeclaration; + public IEnumerable VertexBuffer; + + public List Indices; + + public List? Bones; + + public BoundingBox BoundingBox; + + public List MetaAttributes; + + public List ShapeKeys; + } + + public struct MeshShapeKey + { + public string Name; + public MdlStructs.ShapeMeshStruct ShapeMesh; + public List ShapeValues; + } + + public static Mesh Import(IEnumerable nodes, IoNotifier notifier) + { + var importer = new MeshImporter(nodes, notifier); + return importer.Create(); + } + + private readonly List _subMeshes = []; + + private string? _material; + + private MdlStructs.VertexDeclarationStruct? _vertexDeclaration; + private byte[]? _strides; + private ushort _vertexCount; + private readonly List[] _streams = [[], [], []]; + + private readonly List _indices = []; + + private List? _bones; + + private readonly BoundingBox _boundingBox = new(); + + private readonly List _metaAttributes = []; + + private readonly Dictionary> _shapeValues = []; + + private Mesh Create() + { + foreach (var node in nodes) + BuildSubMeshForNode(node); + + ArgumentNullException.ThrowIfNull(_strides); + ArgumentNullException.ThrowIfNull(_vertexDeclaration); + + return new Mesh + { + MeshStruct = new MeshStruct + { + VertexBufferOffset1 = 0, + VertexBufferOffset2 = (uint)_streams[0].Count, + VertexBufferOffset3 = (uint)(_streams[0].Count + _streams[1].Count), + VertexBufferStride1 = _strides[0], + VertexBufferStride2 = _strides[1], + VertexBufferStride3 = _strides[2], + VertexCount = _vertexCount, + VertexStreamCount = (byte)_vertexDeclaration.Value.VertexElements + .Select(element => element.Stream + 1) + .Max(), + StartIndex = 0, + IndexCount = (uint)_indices.Count, + + MaterialIndex = 0, + SubMeshIndex = 0, + SubMeshCount = (ushort)_subMeshes.Count, + BoneTableIndex = 0, + }, + SubMeshStructs = _subMeshes, + Material = _material, + VertexDeclaration = _vertexDeclaration.Value, + VertexBuffer = _streams[0].Concat(_streams[1]).Concat(_streams[2]), + Indices = _indices, + Bones = _bones, + BoundingBox = _boundingBox, + MetaAttributes = _metaAttributes, + ShapeKeys = _shapeValues + .Select(pair => new MeshShapeKey() + { + Name = pair.Key, + ShapeMesh = new MdlStructs.ShapeMeshStruct() + { + MeshIndexOffset = 0, + ShapeValueOffset = 0, + ShapeValueCount = (uint)pair.Value.Count, + }, + ShapeValues = pair.Value, + }) + .ToList(), + }; + } + + private void BuildSubMeshForNode(Node node) + { + // Record some offsets we'll be using later, before they get mutated with sub-mesh values. + var vertexOffset = _vertexCount; + var indexOffset = _indices.Count; + + var subMeshName = node.Name ?? node.Mesh.Name; + + var subNotifier = notifier.WithContext($"Sub-mesh {subMeshName}"); + var nodeBoneMap = CreateNodeBoneMap(node, subNotifier); + var subMesh = SubMeshImporter.Import(node, nodeBoneMap, subNotifier); + + _material ??= subMesh.Material; + if (subMesh.Material != null && _material != subMesh.Material) + notifier.Warning( + $"Meshes may only reference one material. Sub-mesh {subMeshName} material \"{subMesh.Material}\" has been ignored."); + + // Check that vertex declarations match - we need to combine the buffers, so a mismatch would take a whole load of resolution. + if (_vertexDeclaration == null) + _vertexDeclaration = subMesh.VertexDeclaration; + else + Utility.EnsureVertexDeclarationMatch(_vertexDeclaration.Value, subMesh.VertexDeclaration, notifier); + + // Given that strides are derived from declarations, a lack of mismatch in declarations means the strides are fine. + // TODO: I mean, given that strides are derivable, might be worth dropping strides from the sub mesh return structure and computing when needed. + _strides ??= subMesh.Strides; + + // Merge the sub-mesh streams into the main mesh stream bodies. + _vertexCount += subMesh.VertexCount; + + foreach (var (stream, subStream) in _streams.Zip(subMesh.Streams)) + stream.AddRange(subStream); + + // As we're appending vertex data to the buffers, we need to update indices to point into that later block. + _indices.AddRange(subMesh.Indices.Select(index => (ushort)(index + vertexOffset))); + + // Merge the sub-mesh's shape values into the mesh's. + foreach (var (name, subMeshShapeValues) in subMesh.ShapeValues) + { + if (!_shapeValues.TryGetValue(name, out var meshShapeValues)) + { + meshShapeValues = []; + _shapeValues.Add(name, meshShapeValues); + } + + meshShapeValues.AddRange(subMeshShapeValues.Select(value => value with + { + BaseIndicesIndex = (ushort)(value.BaseIndicesIndex + indexOffset), + ReplacingVertexIndex = (ushort)(value.ReplacingVertexIndex + vertexOffset), + })); + } + + _boundingBox.Merge(subMesh.BoundingBox); + + // And finally, merge in the sub-mesh struct itself. + _subMeshes.Add(subMesh.SubMeshStruct with + { + IndexOffset = (uint)(subMesh.SubMeshStruct.IndexOffset + indexOffset), + AttributeIndexMask = Utility.GetMergedAttributeMask( + subMesh.SubMeshStruct.AttributeIndexMask, subMesh.MetaAttributes, _metaAttributes), + }); + } + + private Dictionary? CreateNodeBoneMap(Node node, IoNotifier notifier) + { + // Unskinned assets can skip this all of this. + if (node.Skin == null) + return null; + + // Build an array of joint names, preserving the joint index from the skin. + // Any unnamed joints we'll be coalescing on a fallback bone name - though this is realistically unlikely to occur. + var jointNames = Enumerable.Range(0, node.Skin.JointsCount) + .Select(index => node.Skin.GetJoint(index).Joint.Name ?? "unnamed_joint") + .ToArray(); + + var usedJoints = new HashSet(); + + foreach (var (primitive, primitiveIndex) in node.Mesh.Primitives.WithIndex()) + { + // Per glTF specification, an asset with a skin MUST contain skinning attributes on its meshes. + var joints0Accessor = primitive.GetVertexAccessor("JOINTS_0")?.AsVector4Array(); + var weights0Accessor = primitive.GetVertexAccessor("WEIGHTS_0")?.AsVector4Array(); + var joints1Accessor = primitive.GetVertexAccessor("JOINTS_1")?.AsVector4Array(); + var weights1Accessor = primitive.GetVertexAccessor("WEIGHTS_1")?.AsVector4Array(); + + if (joints0Accessor == null || weights0Accessor == null) + throw notifier.Exception($"Primitive {primitiveIndex} is skinned but does not contain skinning vertex attributes."); + + // Build a set of joints that are referenced by this mesh. + for (var i = 0; i < joints0Accessor.Count; i++) + { + var joints0 = joints0Accessor[i]; + var weights0 = weights0Accessor[i]; + var joints1 = joints1Accessor?[i]; + var weights1 = weights1Accessor?[i]; + for (var index = 0; index < 4; index++) + { + // If a joint has absolutely no weight, we omit the bone entirely. + if (weights0[index] != 0) + { + usedJoints.Add((ushort)joints0[index]); + } + + + if (joints1 != null && weights1 != null && weights1.Value[index] != 0) + { + usedJoints.Add((ushort)joints1.Value[index]); + } + } + } + } + + // Only initialise the bones list if we're actually going to put something in it. + _bones ??= []; + + // Build a dictionary of node-specific joint indices mapped to mesh-wide bone indices. + var nodeBoneMap = new Dictionary(); + foreach (var usedJoint in usedJoints) + { + var jointName = jointNames[usedJoint]; + var boneIndex = _bones.IndexOf(jointName); + if (boneIndex == -1) + { + boneIndex = _bones.Count; + _bones.Add(jointName); + } + + nodeBoneMap.Add(usedJoint, (ushort)boneIndex); + } + + return nodeBoneMap; + } +} diff --git a/Penumbra/Import/Models/Import/ModelImporter.cs b/Penumbra/Import/Models/Import/ModelImporter.cs new file mode 100644 index 00000000..f4eefccc --- /dev/null +++ b/Penumbra/Import/Models/Import/ModelImporter.cs @@ -0,0 +1,254 @@ +using Lumina.Data.Parsing; +using OtterGui.Extensions; +using Penumbra.GameData.Files; +using Penumbra.GameData.Files.ModelStructs; +using SharpGLTF.Schema2; + +namespace Penumbra.Import.Models.Import; + +public partial class ModelImporter(ModelRoot model, IoNotifier notifier) +{ + public const int BoneLimit = 128; + public const int MaterialLimit = 10; + + public static MdlFile Import(ModelRoot model, IoNotifier notifier) + { + var importer = new ModelImporter(model, notifier); + return importer.Create(); + } + + // NOTE: This is intended to match TexTool's grouping regex, ".*[_ ^]([0-9]+)[\\.\\-]?([0-9]+)?$" + [GeneratedRegex(@"[_ ^](?'Mesh'[0-9]+)[.-]?(?'SubMesh'[0-9]+)?$", + RegexOptions.Compiled | RegexOptions.NonBacktracking | RegexOptions.ExplicitCapture)] + private static partial Regex MeshNameGroupingRegex(); + + private readonly List _meshes = []; + private readonly List _subMeshes = []; + + private readonly List _materials = []; + + private readonly List _vertexDeclarations = []; + private readonly List _vertexBuffer = []; + + private readonly List _indices = []; + + private readonly List _bones = []; + private readonly List _boneTables = []; + + private readonly BoundingBox _boundingBox = new(); + + private readonly List _metaAttributes = []; + + private readonly Dictionary> _shapeMeshes = []; + private readonly List _shapeValues = []; + + private MdlFile Create() + { + // Group and build out meshes in this model. + foreach (var (subMeshNodes, index) in GroupedMeshNodes().WithIndex()) + BuildMeshForGroup(subMeshNodes, index); + + // Now that all the meshes have been built, we can build some of the model-wide metadata. + var materials = _materials.Count > 0 ? _materials : ["/NO_MATERIAL"]; + + var shapes = new List(); + var shapeMeshes = new List(); + foreach (var (keyName, keyMeshes) in _shapeMeshes) + { + shapes.Add(new MdlFile.Shape() + { + ShapeName = keyName, + // NOTE: these values are per-LoD. + ShapeMeshStartIndex = [(ushort)shapeMeshes.Count, 0, 0], + ShapeMeshCount = [(ushort)keyMeshes.Count, 0, 0], + }); + shapeMeshes.AddRange(keyMeshes); + } + + var indexBuffer = _indices.SelectMany(BitConverter.GetBytes).ToArray(); + + // And finally, the MdlFile itself. + return new MdlFile + { + VertexOffset = [0, 0, 0], + VertexBufferSize = [(uint)_vertexBuffer.Count, 0, 0], + IndexOffset = [(uint)_vertexBuffer.Count, 0, 0], + IndexBufferSize = [(uint)indexBuffer.Length, 0, 0], + VertexDeclarations = [.. _vertexDeclarations], + Meshes = [.. _meshes], + SubMeshes = [.. _subMeshes], + BoneTables = [.. _boneTables], + Bones = [.. _bones], + // TODO: Game doesn't seem to rely on this, but would be good to populate. + SubMeshBoneMap = [], + Attributes = [.. _metaAttributes], + Shapes = [.. shapes], + ShapeMeshes = [.. shapeMeshes], + ShapeValues = [.. _shapeValues], + LodCount = 1, + Lods = + [ + new MdlStructs.LodStruct + { + MeshIndex = 0, + MeshCount = (ushort)_meshes.Count, + ModelLodRange = 0, + TextureLodRange = 0, + VertexDataOffset = 0, + VertexBufferSize = (uint)_vertexBuffer.Count, + IndexDataOffset = (uint)_vertexBuffer.Count, + IndexBufferSize = (uint)indexBuffer.Length, + }, + ], + Materials = [.. materials], + BoundingBoxes = _boundingBox.ToStruct(), + + // TODO: Would be good to calculate all of this up the tree. + Radius = 1, + BoneBoundingBoxes = Enumerable.Repeat(MdlFile.EmptyBoundingBox, _bones.Count).ToArray(), + RemainingData = [.._vertexBuffer, ..indexBuffer], + Valid = true, + }; + } + + /// Returns an iterator over sorted, grouped mesh nodes. + private IEnumerable> GroupedMeshNodes() + => model.LogicalNodes + .Where(node => node.Mesh != null) + .Select(node => + { + var name = node.Name ?? node.Mesh.Name ?? "NOMATCH"; + var match = MeshNameGroupingRegex().Match(name); + return (node, match); + }) + .Where(pair => pair.match.Success) + .OrderBy(pair => + { + var subMeshGroup = pair.match.Groups["SubMesh"]; + return subMeshGroup.Success ? int.Parse(subMeshGroup.Value) : 0; + }) + .GroupBy( + pair => int.Parse(pair.match.Groups["Mesh"].Value), + pair => pair.node + ) + .OrderBy(group => group.Key); + + private void BuildMeshForGroup(IEnumerable subMeshNodes, int index) + { + // Record some offsets we'll be using later, before they get mutated with mesh values. + var subMeshOffset = _subMeshes.Count; + var vertexOffset = _vertexBuffer.Count; + var indexOffset = _indices.Count; + + var mesh = MeshImporter.Import(subMeshNodes, notifier.WithContext($"Mesh {index}")); + var meshStartIndex = (uint)(mesh.MeshStruct.StartIndex + indexOffset); + + var materialIndex = mesh.Material != null + ? GetMaterialIndex(mesh.Material) + : (ushort)0; + + // If no bone table is used for a mesh, the index is set to 255. + var boneTableIndex = mesh.Bones != null + ? BuildBoneTable(mesh.Bones) + : (ushort)255; + + _meshes.Add(mesh.MeshStruct with + { + MaterialIndex = materialIndex, + SubMeshIndex = (ushort)(mesh.MeshStruct.SubMeshIndex + subMeshOffset), + BoneTableIndex = boneTableIndex, + StartIndex = meshStartIndex, + VertexBufferOffset1 = (uint)(mesh.MeshStruct.VertexBufferOffset1 + vertexOffset), + VertexBufferOffset2 = (uint)(mesh.MeshStruct.VertexBufferOffset2 + vertexOffset), + VertexBufferOffset3 = (uint)(mesh.MeshStruct.VertexBufferOffset3 + vertexOffset), + }); + + _boundingBox.Merge(mesh.BoundingBox); + + _subMeshes.AddRange(mesh.SubMeshStructs.Select(m => m with + { + AttributeIndexMask = Utility.GetMergedAttributeMask( + m.AttributeIndexMask, mesh.MetaAttributes, _metaAttributes), + IndexOffset = (uint)(m.IndexOffset + indexOffset), + })); + + _vertexDeclarations.Add(mesh.VertexDeclaration); + _vertexBuffer.AddRange(mesh.VertexBuffer); + + _indices.AddRange(mesh.Indices); + + foreach (var meshShapeKey in mesh.ShapeKeys) + { + if (!_shapeMeshes.TryGetValue(meshShapeKey.Name, out var shapeMeshes)) + { + shapeMeshes = []; + _shapeMeshes.Add(meshShapeKey.Name, shapeMeshes); + } + + shapeMeshes.Add(meshShapeKey.ShapeMesh with + { + MeshIndexOffset = meshStartIndex, + ShapeValueOffset = (uint)_shapeValues.Count, + }); + + _shapeValues.AddRange(meshShapeKey.ShapeValues); + } + + // The number of shape values in a model is bounded by the count + // value, which is stored as a u16. + // While technically there are similar bounds on other shape struct + // arrays, values is practically guaranteed to be the highest of the + // group, so a failure on any of them will be a failure on it. + if (_shapeValues.Count > ushort.MaxValue) + throw notifier.Exception( + $"Importing this file would require more than the maximum of {ushort.MaxValue} shape values.\nTry removing or applying shape keys that do not need to be changed at runtime in-game."); + } + + private ushort GetMaterialIndex(string materialName) + { + // If we already have this material, grab the current index. + var index = _materials.IndexOf(materialName); + if (index >= 0) + return (ushort)index; + + // TODO: permit, with a warning to reduce, and validation in MdlTab. + var count = _materials.Count; + if (count >= MaterialLimit) + return 0; + + _materials.Add(materialName); + return (ushort)count; + } + + // #TODO @ackwell fix for V6 Models + private ushort BuildBoneTable(List boneNames) + { + var boneIndices = new List(); + foreach (var boneName in boneNames) + { + var boneIndex = _bones.IndexOf(boneName); + if (boneIndex == -1) + { + boneIndex = _bones.Count; + _bones.Add(boneName); + } + + boneIndices.Add((ushort)boneIndex); + } + + if (boneIndices.Count > BoneLimit) + throw notifier.Exception($"XIV does not support meshes weighted to a total of more than {BoneLimit} bones."); + + var boneIndicesArray = new ushort[BoneLimit]; + boneIndices.CopyTo(boneIndicesArray); + + var boneTableIndex = _boneTables.Count; + _boneTables.Add(new BoneTableStruct() + { + BoneIndex = boneIndicesArray, + BoneCount = (byte)boneIndices.Count, + }); + + return (ushort)boneTableIndex; + } +} diff --git a/Penumbra/Import/Models/Import/PrimitiveImporter.cs b/Penumbra/Import/Models/Import/PrimitiveImporter.cs new file mode 100644 index 00000000..57c7929f --- /dev/null +++ b/Penumbra/Import/Models/Import/PrimitiveImporter.cs @@ -0,0 +1,211 @@ +using Lumina.Data.Parsing; +using OtterGui.Extensions; +using SharpGLTF.Schema2; + +namespace Penumbra.Import.Models.Import; + +public class PrimitiveImporter +{ + public struct Primitive + { + public string? Material; + + public MdlStructs.VertexDeclarationStruct VertexDeclaration; + + public ushort VertexCount; + public byte[] Strides; + public List[] Streams; + + public ushort[] Indices; + + public BoundingBox BoundingBox; + + public List> ShapeValues; + } + + public static Primitive Import(MeshPrimitive primitive, IDictionary? nodeBoneMap, IoNotifier notifier) + { + var importer = new PrimitiveImporter(primitive, nodeBoneMap, notifier); + return importer.Create(); + } + + private readonly IoNotifier _notifier; + + private readonly MeshPrimitive _primitive; + private readonly IDictionary? _nodeBoneMap; + + private ushort[]? _indices; + + private List? _vertexAttributes; + + private ushort _vertexCount; + private byte[] _strides = [0, 0, 0]; + private readonly List[] _streams = [[], [], []]; + + private readonly BoundingBox _boundingBox = new(); + + private List>? _shapeValues; + + private PrimitiveImporter(MeshPrimitive primitive, IDictionary? nodeBoneMap, IoNotifier notifier) + { + _notifier = notifier; + _primitive = primitive; + _nodeBoneMap = nodeBoneMap; + } + + private Primitive Create() + { + // TODO: This structure is verging on a little silly. Reconsider. + BuildIndices(); + BuildVertexAttributes(); + BuildVertices(); + BuildBoundingBox(); + + ArgumentNullException.ThrowIfNull(_vertexAttributes); + ArgumentNullException.ThrowIfNull(_indices); + ArgumentNullException.ThrowIfNull(_shapeValues); + + var material = _primitive.Material?.Name; + if (material == "") + material = null; + + return new Primitive + { + Material = material, + VertexDeclaration = new MdlStructs.VertexDeclarationStruct + { + VertexElements = _vertexAttributes.Select(attribute => attribute.Element).ToArray(), + }, + VertexCount = _vertexCount, + Strides = _strides, + Streams = _streams, + Indices = _indices, + BoundingBox = _boundingBox, + ShapeValues = _shapeValues, + }; + } + + private void BuildIndices() + { + // TODO: glTF supports a bunch of primitive types, ref. Schema2.PrimitiveType. All this code is currently assuming that it's using plain triangles (4). It should probably be generalised to other formats - I _suspect_ we should be able to get away with evaluating the indices to triangles with GetTriangleIndices, but will need investigation. + _indices = _primitive.GetIndices().Select(idx => (ushort)idx).ToArray(); + } + + private void BuildVertexAttributes() + { + // Tangent calculation requires indices if missing. + ArgumentNullException.ThrowIfNull(_indices); + + var accessors = _primitive.VertexAccessors; + + var morphAccessors = Enumerable.Range(0, _primitive.MorphTargetsCount) + .Select(index => _primitive.GetMorphTargetAccessors(index)).ToList(); + + // Try to build all the attributes the mesh might use. + // The order here is chosen to match a typical model's element order. + var rawAttributes = new[] + { + VertexAttribute.Position(accessors, morphAccessors, _notifier), + VertexAttribute.BlendWeight(accessors, _notifier), + VertexAttribute.BlendIndex(accessors, _nodeBoneMap, _notifier), + VertexAttribute.Normal(accessors, morphAccessors), + VertexAttribute.Tangent1(accessors, morphAccessors, _indices, _notifier), + VertexAttribute.Color(accessors), + VertexAttribute.Uv(accessors), + }; + + var attributes = new List(); + var offsets = new byte[] + { + 0, + 0, + 0, + }; + foreach (var attribute in rawAttributes) + { + if (attribute == null) + continue; + + attributes.Add(attribute.WithOffset(offsets[attribute.Stream])); + offsets[attribute.Stream] += attribute.Size; + } + + _vertexAttributes = attributes; + // After building the attributes, the resulting next offsets are our stream strides. + _strides = offsets; + } + + private void BuildVertices() + { + ArgumentNullException.ThrowIfNull(_vertexAttributes); + + // Lists of vertex indices that are effected by each morph target for this primitive. + var morphModifiedVertices = Enumerable.Range(0, _primitive.MorphTargetsCount) + .Select(_ => new List()) + .ToArray(); + + // We can safely assume that POSITION exists by this point - and if, by some bizarre chance, it doesn't, failing out is sane. + _vertexCount = (ushort)_primitive.VertexAccessors["POSITION"].Count; + + for (var vertexIndex = 0; vertexIndex < _vertexCount; vertexIndex++) + { + // Write out vertex data to streams for each attribute. + foreach (var attribute in _vertexAttributes) + _streams[attribute.Stream].AddRange(attribute.Build(vertexIndex)); + + // Record which morph targets have values for this vertex, if any. + var index = vertexIndex; + var changedMorphs = morphModifiedVertices + .WithIndex() + .Where(pair => _vertexAttributes.Any(attribute => attribute.HasMorph(pair.Index, index))) + .Select(pair => pair.Value); + foreach (var modifiedVertices in changedMorphs) + modifiedVertices.Add(vertexIndex); + } + + BuildShapeValues(morphModifiedVertices); + } + + private void BuildShapeValues(IEnumerable> morphModifiedVertices) + { + ArgumentNullException.ThrowIfNull(_indices); + ArgumentNullException.ThrowIfNull(_vertexAttributes); + + var morphShapeValues = new List>(); + + foreach (var (modifiedVertices, morphIndex) in morphModifiedVertices.WithIndex()) + { + // For a given mesh, each shape key contains a list of shape value mappings. + var shapeValues = new List(); + + foreach (var vertexIndex in modifiedVertices) + { + // Write out the morphed vertex to the vertex streams. + foreach (var attribute in _vertexAttributes) + _streams[attribute.Stream].AddRange(attribute.BuildMorph(morphIndex, vertexIndex)); + + // Find any indices that target this vertex index and create a mapping. + var targetingIndices = _indices.WithIndex() + .SelectWhere(pair => (pair.Value == vertexIndex, pair.Index)); + shapeValues.AddRange(targetingIndices.Select(targetingIndex => new MdlStructs.ShapeValueStruct + { + BaseIndicesIndex = (ushort)targetingIndex, + ReplacingVertexIndex = _vertexCount, + })); + + _vertexCount++; + } + + morphShapeValues.Add(shapeValues); + } + + _shapeValues = morphShapeValues; + } + + private void BuildBoundingBox() + { + var positions = _primitive.VertexAccessors["POSITION"].AsVector3Array(); + foreach (var position in positions) + _boundingBox.Merge(position); + } +} diff --git a/Penumbra/Import/Models/Import/SubMeshImporter.cs b/Penumbra/Import/Models/Import/SubMeshImporter.cs new file mode 100644 index 00000000..6aa46fb6 --- /dev/null +++ b/Penumbra/Import/Models/Import/SubMeshImporter.cs @@ -0,0 +1,189 @@ +using System.Text.Json; +using Lumina.Data.Parsing; +using OtterGui.Extensions; +using SharpGLTF.Schema2; + +namespace Penumbra.Import.Models.Import; + +public class SubMeshImporter +{ + public struct SubMesh + { + public MdlStructs.SubmeshStruct SubMeshStruct; + + public string? Material; + + public MdlStructs.VertexDeclarationStruct VertexDeclaration; + + public ushort VertexCount; + public byte[] Strides; + public List[] Streams; + + public List Indices; + + public BoundingBox BoundingBox; + + public string[] MetaAttributes; + + public Dictionary> ShapeValues; + } + + public static SubMesh Import(Node node, IDictionary? nodeBoneMap, IoNotifier notifier) + { + var importer = new SubMeshImporter(node, nodeBoneMap, notifier); + return importer.Create(); + } + + private readonly IoNotifier _notifier; + + private readonly Node _node; + private readonly IDictionary? _nodeBoneMap; + + private string? _material; + + private MdlStructs.VertexDeclarationStruct? _vertexDeclaration; + private ushort _vertexCount; + private byte[]? _strides; + private readonly List[] _streams = [[], [], []]; + + private readonly List _indices = []; + + private readonly BoundingBox _boundingBox = new(); + + private readonly List? _morphNames; + private readonly Dictionary> _shapeValues = []; + + private SubMeshImporter(Node node, IDictionary? nodeBoneMap, IoNotifier notifier) + { + _notifier = notifier; + _node = node; + _nodeBoneMap = nodeBoneMap; + + try + { + _morphNames = node.Mesh.Extras["targetNames"].Deserialize>(); + } + catch + { + _morphNames = null; + } + } + + private SubMesh Create() + { + // Build all the data we'll need. + foreach (var (primitive, index) in _node.Mesh.Primitives.WithIndex()) + BuildPrimitive(primitive, index); + + ArgumentNullException.ThrowIfNull(_indices); + ArgumentNullException.ThrowIfNull(_vertexDeclaration); + ArgumentNullException.ThrowIfNull(_strides); + ArgumentNullException.ThrowIfNull(_shapeValues); + + var metaAttributes = BuildMetaAttributes(); + + // At this level, we assume that attributes are wholly controlled by this sub-mesh. + var attributeMask = metaAttributes.Length switch + { + < 32 => (1u << metaAttributes.Length) - 1, + 32 => uint.MaxValue, + > 32 => throw _notifier.Exception("Models may utilise a maximum of 32 attributes."), + }; + + return new SubMesh() + { + SubMeshStruct = new MdlStructs.SubmeshStruct() + { + IndexOffset = 0, + IndexCount = (uint)_indices.Count, + AttributeIndexMask = attributeMask, + + // TODO: Flesh these out. Game doesn't seem to rely on them existing, though. + BoneStartIndex = 0, + BoneCount = 0, + }, + Material = _material, + VertexDeclaration = _vertexDeclaration.Value, + VertexCount = _vertexCount, + Strides = _strides, + Streams = _streams, + Indices = _indices, + BoundingBox = _boundingBox, + MetaAttributes = metaAttributes, + ShapeValues = _shapeValues, + }; + } + + private void BuildPrimitive(MeshPrimitive meshPrimitive, int index) + { + var vertexOffset = _vertexCount; + var indexOffset = _indices.Count; + + var primitive = PrimitiveImporter.Import(meshPrimitive, _nodeBoneMap, _notifier.WithContext($"Primitive {index}")); + + // Material + _material ??= primitive.Material; + if (primitive.Material != null && _material != primitive.Material) + _notifier.Warning($"Meshes may only reference one material. Primitive {index} material \"{primitive.Material}\" has been ignored."); + + // Vertex metadata + if (_vertexDeclaration == null) + _vertexDeclaration = primitive.VertexDeclaration; + else + Utility.EnsureVertexDeclarationMatch(_vertexDeclaration.Value, primitive.VertexDeclaration, _notifier); + + _strides ??= primitive.Strides; + + // Vertices + _vertexCount += primitive.VertexCount; + + foreach (var (stream, primitiveStream) in _streams.Zip(primitive.Streams)) + stream.AddRange(primitiveStream); + + // Indices + _indices.AddRange(primitive.Indices.Select(i => (ushort)(i + vertexOffset))); + + // Shape values + foreach (var (primitiveShapeValues, morphIndex) in primitive.ShapeValues.WithIndex()) + { + // Per glTF spec, all primitives MUST have the same number of morph targets in the same order. + // As such, this lookup should be safe - a failure here is a broken glTF file. + var name = _morphNames != null ? _morphNames[morphIndex] : $"unnamed_shape_{morphIndex}"; + + if (!_shapeValues.TryGetValue(name, out var subMeshShapeValues)) + { + subMeshShapeValues = []; + _shapeValues.Add(name, subMeshShapeValues); + } + + subMeshShapeValues.AddRange(primitiveShapeValues.Select(value => value with + { + BaseIndicesIndex = (ushort)(value.BaseIndicesIndex + indexOffset), + ReplacingVertexIndex = (ushort)(value.ReplacingVertexIndex + vertexOffset), + })); + } + + // Bounds + _boundingBox.Merge(primitive.BoundingBox); + } + + private string[] BuildMetaAttributes() + { + Dictionary? nodeExtras; + try + { + nodeExtras = _node.Extras.Deserialize>(); + } + catch + { + nodeExtras = null; + } + + // We consider any "extras" key with a boolean value set to `true` to be an attribute. + return nodeExtras? + .Where(pair => pair.Value.ValueKind == JsonValueKind.True) + .Select(pair => pair.Key) + .ToArray() + ?? []; + } +} diff --git a/Penumbra/Import/Models/Import/Utility.cs b/Penumbra/Import/Models/Import/Utility.cs new file mode 100644 index 00000000..21655563 --- /dev/null +++ b/Penumbra/Import/Models/Import/Utility.cs @@ -0,0 +1,73 @@ +using Lumina.Data.Parsing; +using Penumbra.GameData.Files; + +namespace Penumbra.Import.Models.Import; + +public static class Utility +{ + /// Merge attributes into an existing attribute array, providing an updated sub mesh mask. + /// Old sub mesh attribute mask. + /// Old attribute array that should be merged. + /// New attribute array. Will be mutated. + /// New sub mesh attribute mask, updated to match the merged attribute array. + public static uint GetMergedAttributeMask(uint oldMask, IList oldAttributes, List newAttributes) + { + var metaAttributes = Enumerable.Range(0, 32) + .Where(index => ((oldMask >> index) & 1) == 1) + .Select(index => oldAttributes[index]); + + var newMask = 0u; + + foreach (var metaAttribute in metaAttributes) + { + var attributeIndex = newAttributes.IndexOf(metaAttribute); + if (attributeIndex == -1) + { + if (newAttributes.Count >= 32) + throw new Exception("Models may utilise a maximum of 32 attributes."); + + newAttributes.Add(metaAttribute); + attributeIndex = newAttributes.Count - 1; + } + + newMask |= 1u << attributeIndex; + } + + return newMask; + } + + /// Ensures that the two vertex declarations provided are equal, throwing if not. + public static void EnsureVertexDeclarationMatch(MdlStructs.VertexDeclarationStruct current, MdlStructs.VertexDeclarationStruct @new, + IoNotifier notifier) + { + if (VertexDeclarationMismatch(current, @new)) + throw notifier.Exception( + $""" + All sub-meshes of a mesh must have equivalent vertex declarations. + Current: {FormatVertexDeclaration(current)} + New: {FormatVertexDeclaration(@new)} + """ + ); + } + + private static string FormatVertexDeclaration(MdlStructs.VertexDeclarationStruct vertexDeclaration) + => string.Join(", ", + vertexDeclaration.VertexElements.Select(element => $"{(MdlFile.VertexUsage)element.Usage} ({(MdlFile.VertexType)element.Type}@{element.Stream}:{element.Offset})")); + + private static bool VertexDeclarationMismatch(MdlStructs.VertexDeclarationStruct a, MdlStructs.VertexDeclarationStruct b) + { + var elA = a.VertexElements; + var elB = b.VertexElements; + + if (elA.Length != elB.Length) + return true; + + // NOTE: This assumes that elements will always be in the same order. Under the current implementation, that's guaranteed. + return elA.Zip(elB).Any(pair => + pair.First.Usage != pair.Second.Usage + || pair.First.Type != pair.Second.Type + || pair.First.Offset != pair.Second.Offset + || pair.First.Stream != pair.Second.Stream + ); + } +} diff --git a/Penumbra/Import/Models/Import/VertexAttribute.cs b/Penumbra/Import/Models/Import/VertexAttribute.cs new file mode 100644 index 00000000..155fa833 --- /dev/null +++ b/Penumbra/Import/Models/Import/VertexAttribute.cs @@ -0,0 +1,533 @@ +using Lumina.Data.Parsing; +using Penumbra.GameData.Files; +using SharpGLTF.Schema2; + +namespace Penumbra.Import.Models.Import; + +using BuildFn = Func; +using HasMorphFn = Func; +using BuildMorphFn = Func; +using Accessors = IReadOnlyDictionary; + +public class VertexAttribute +{ + /// XIV vertex element metadata structure. + public readonly MdlStructs.VertexElement Element; + + /// Build a byte array containing this vertex attribute's data for the specified vertex index. + public readonly BuildFn Build; + + /// Check if the specified morph target index contains a morph for the specified vertex index. + public readonly HasMorphFn HasMorph; + + /// Build a byte array containing this vertex attribute's data, as modified by the specified morph target, for the specified vertex index. + public readonly BuildMorphFn BuildMorph; + + public byte Stream + => Element.Stream; + + /// Size in bytes of a single vertex's attribute value. + public byte Size + => (MdlFile.VertexType)Element.Type switch + { + MdlFile.VertexType.Single1 => 4, + MdlFile.VertexType.Single2 => 8, + MdlFile.VertexType.Single3 => 12, + MdlFile.VertexType.Single4 => 16, + MdlFile.VertexType.UByte4 => 4, + MdlFile.VertexType.Short2 => 4, + MdlFile.VertexType.Short4 => 8, + MdlFile.VertexType.NByte4 => 4, + MdlFile.VertexType.Half2 => 4, + MdlFile.VertexType.Half4 => 8, + MdlFile.VertexType.UShort4 => 8, + + _ => throw new Exception($"Unhandled vertex type {(MdlFile.VertexType)Element.Type}"), + }; + + private VertexAttribute( + MdlStructs.VertexElement element, + BuildFn write, + HasMorphFn? hasMorph = null, + BuildMorphFn? buildMorph = null + ) + { + Element = element; + Build = write; + HasMorph = hasMorph ?? DefaultHasMorph; + BuildMorph = buildMorph ?? DefaultBuildMorph; + } + + public VertexAttribute WithOffset(byte offset) + => new( + Element with { Offset = offset }, + Build, + HasMorph, + BuildMorph + ); + + /// We assume that attributes don't have morph data unless explicitly configured. + private static bool DefaultHasMorph(int morphIndex, int vertexIndex) + => false; + + /// + /// XIV stores shapes as full vertex replacements, so all attributes need to output something for a morph. + /// As a fallback, we're just building the normal vertex data for the index. + /// > + private byte[] DefaultBuildMorph(int morphIndex, int vertexIndex) + => Build(vertexIndex); + + public static VertexAttribute Position(Accessors accessors, IEnumerable morphAccessors, IoNotifier notifier) + { + if (!accessors.TryGetValue("POSITION", out var accessor)) + throw notifier.Exception("Meshes must contain a POSITION attribute."); + + var element = new MdlStructs.VertexElement() + { + Stream = 0, + Type = (byte)MdlFile.VertexType.Single3, + Usage = (byte)MdlFile.VertexUsage.Position, + }; + + var values = accessor.AsVector3Array(); + + var morphValues = morphAccessors + .Select(a => a.GetValueOrDefault("POSITION")?.AsVector3Array()) + .ToArray(); + + return new VertexAttribute( + element, + index => BuildSingle3(values[index]), + (morphIndex, vertexIndex) => + { + var deltas = morphValues[morphIndex]; + if (deltas == null) + return false; + + var delta = deltas[vertexIndex]; + return delta != Vector3.Zero; + }, + (morphIndex, vertexIndex) => + { + var value = values[vertexIndex]; + + var delta = morphValues[morphIndex]?[vertexIndex]; + if (delta != null) + value += delta.Value; + + return BuildSingle3(value); + } + ); + } + + public static VertexAttribute? BlendWeight(Accessors accessors, IoNotifier notifier) + { + if (!accessors.TryGetValue("WEIGHTS_0", out var weights0Accessor)) + return null; + + if (!accessors.ContainsKey("JOINTS_0")) + throw notifier.Exception("Mesh contained WEIGHTS_0 attribute but no corresponding JOINTS_0 attribute."); + + if (accessors.TryGetValue("WEIGHTS_1", out var weights1Accessor)) + { + if (!accessors.ContainsKey("JOINTS_1")) + throw notifier.Exception("Mesh contained WEIGHTS_1 attribute but no corresponding JOINTS_1 attribute."); + } + + var element = new MdlStructs.VertexElement() + { + Stream = 0, + Type = (byte)MdlFile.VertexType.UShort4, + Usage = (byte)MdlFile.VertexUsage.BlendWeights, + }; + + var weights0 = weights0Accessor.AsVector4Array(); + var weights1 = weights1Accessor?.AsVector4Array(); + + return new VertexAttribute( + element, + index => BuildBlendWeights(weights0[index], weights1?[index] ?? Vector4.Zero) + ); + } + + private static byte[] BuildBlendWeights(Vector4 v1, Vector4 v2) + { + var originalData = BuildUshort4(v1, v2); + var byteValues = new byte[originalData.Length]; + for (var i = 0; i < originalData.Length; i++) + { + byteValues[i] = (byte)Math.Round(originalData[i] * 255f); + } + + // Blend weights are _very_ sensitive to float imprecision - a vertex sum being off + // by one, such as 256, is enough to cause a visible defect. To avoid this, we tweak + // the converted values to have the expected sum, preferencing values with minimal differences. + var adjustment = 255 - byteValues.Sum(value => value); + while (adjustment != 0) + { + var closestIndex = Enumerable.Range(0, byteValues.Length) + .Where(i => adjustment switch + { + < 0 => byteValues[i] > 0, + > 0 => byteValues[i] < 255, + _ => true, + }) + .Select(index => (index, delta: Math.Abs(originalData[index] - (byteValues[index] * (1f / 255f))))) + .MinBy(x => x.delta) + .index; + byteValues[closestIndex] += (byte)Math.CopySign(1, adjustment); + adjustment = 255 - byteValues.Sum(value => value); + } + + return byteValues; + } + + public static VertexAttribute? BlendIndex(Accessors accessors, IDictionary? boneMap, IoNotifier notifier) + { + if (!accessors.TryGetValue("JOINTS_0", out var joints0Accessor)) + return null; + + if (!accessors.TryGetValue("WEIGHTS_0", out var weights0Accessor)) + throw notifier.Exception("Mesh contained JOINTS_0 attribute but no corresponding WEIGHTS_0 attribute."); + + if (boneMap == null) + throw notifier.Exception("Mesh contained JOINTS_0 attribute but no bone mapping was created."); + + var joints0 = joints0Accessor.AsVector4Array(); + var weights0 = weights0Accessor.AsVector4Array(); + accessors.TryGetValue("JOINTS_1", out var joints1Accessor); + accessors.TryGetValue("WEIGHTS_1", out var weights1Accessor); + var element = new MdlStructs.VertexElement + { + Stream = 0, + Type = (byte)MdlFile.VertexType.UShort4, + Usage = (byte)MdlFile.VertexUsage.BlendIndices, + }; + + var joints1 = joints1Accessor?.AsVector4Array(); + var weights1 = weights1Accessor?.AsVector4Array(); + + return new VertexAttribute( + element, + index => + { + var gltfIndices0 = joints0[index]; + var gltfWeights0 = weights0[index]; + var gltfIndices1 = joints1?[index]; + var gltfWeights1 = weights1?[index]; + var v0 = new Vector4( + gltfWeights0.X == 0 ? 0 : boneMap[(ushort)gltfIndices0.X], + gltfWeights0.Y == 0 ? 0 : boneMap[(ushort)gltfIndices0.Y], + gltfWeights0.Z == 0 ? 0 : boneMap[(ushort)gltfIndices0.Z], + gltfWeights0.W == 0 ? 0 : boneMap[(ushort)gltfIndices0.W] + ); + + var v1 = Vector4.Zero; + if (gltfIndices1 != null && gltfWeights1 != null) + { + v1 = new Vector4( + gltfWeights1.Value.X == 0 ? 0 : boneMap[(ushort)gltfIndices1.Value.X], + gltfWeights1.Value.Y == 0 ? 0 : boneMap[(ushort)gltfIndices1.Value.Y], + gltfWeights1.Value.Z == 0 ? 0 : boneMap[(ushort)gltfIndices1.Value.Z], + gltfWeights1.Value.W == 0 ? 0 : boneMap[(ushort)gltfIndices1.Value.W] + ); + } + + var byteValues = BuildUshort4(v0, v1); + + return byteValues.Select(x => (byte)x).ToArray(); + } + ); + } + + public static VertexAttribute? Normal(Accessors accessors, IEnumerable morphAccessors) + { + if (!accessors.TryGetValue("NORMAL", out var accessor)) + return null; + + var element = new MdlStructs.VertexElement() + { + Stream = 1, + Type = (byte)MdlFile.VertexType.Single3, + Usage = (byte)MdlFile.VertexUsage.Normal, + }; + + var values = accessor.AsVector3Array(); + + var morphValues = morphAccessors + .Select(a => a.GetValueOrDefault("NORMAL")?.AsVector3Array()) + .ToArray(); + + return new VertexAttribute( + element, + index => BuildSingle3(values[index]), + buildMorph: (morphIndex, vertexIndex) => + { + var value = values[vertexIndex]; + + var delta = morphValues[morphIndex]?[vertexIndex]; + if (delta != null) + value += delta.Value; + + return BuildSingle3(value); + } + ); + } + + public static VertexAttribute? Uv(Accessors accessors) + { + if (!accessors.TryGetValue("TEXCOORD_0", out var accessor1)) + return null; + + // We're omitting type here, and filling it in on return, as there's two different types we might use. + var element = new MdlStructs.VertexElement() + { + Stream = 1, + Usage = (byte)MdlFile.VertexUsage.UV, + }; + + var values1 = accessor1.AsVector2Array(); + + // There's only one TEXCOORD, output UV coordinates as vec2s. + if (!accessors.TryGetValue("TEXCOORD_1", out var accessor2)) + return new VertexAttribute( + element with { Type = (byte)MdlFile.VertexType.Single2 }, + index => BuildSingle2(values1[index]) + ); + + var values2 = accessor2.AsVector2Array(); + + // Two TEXCOORDs are available, repack them into xiv's vec4 [0X, 0Y, 1X, 1Y] format. + return new VertexAttribute( + element with { Type = (byte)MdlFile.VertexType.Single4 }, + index => + { + var value1 = values1[index]; + var value2 = values2[index]; + return BuildSingle4(new Vector4(value1.X, value1.Y, value2.X, value2.Y)); + } + ); + } + + public static VertexAttribute? Tangent1(Accessors accessors, IEnumerable morphAccessors, ushort[] indices, IoNotifier notifier) + { + if (!accessors.TryGetValue("NORMAL", out var normalAccessor)) + { + notifier.Warning("Normals are required to facilitate import or calculation of tangents."); + return null; + } + + var normals = normalAccessor.AsVector3Array(); + var tangents = accessors.TryGetValue("TANGENT", out var accessor) + ? accessor.AsVector4Array().ToArray() + : CalculateTangents(accessors, indices, normals, notifier); + + if (tangents == null) + { + notifier.Warning("No tangents available for sub-mesh. This could lead to incorrect lighting, or mismatched vertex attributes."); + return null; + } + + var element = new MdlStructs.VertexElement + { + Stream = 1, + Type = (byte)MdlFile.VertexType.NByte4, + Usage = (byte)MdlFile.VertexUsage.Tangent1, + }; + + // Per glTF specification, TANGENT morph values are stored as vec3, with the W component always considered to be 0. + var morphValues = morphAccessors + .Select(a => (Tangent: a.GetValueOrDefault("TANGENT")?.AsVector3Array(), + Normal: a.GetValueOrDefault("NORMAL")?.AsVector3Array())) + .ToList(); + + return new VertexAttribute( + element, + index => BuildBitangent(tangents[index], normals[index]), + buildMorph: (morphIndex, vertexIndex) => + { + var tangent = tangents[vertexIndex]; + var tangentDelta = morphValues[morphIndex].Tangent?[vertexIndex]; + if (tangentDelta != null) + tangent += new Vector4(tangentDelta.Value, 0); + + var normal = normals[vertexIndex]; + var normalDelta = morphValues[morphIndex].Normal?[vertexIndex]; + if (normalDelta != null) + normal += normalDelta.Value; + + return BuildBitangent(tangent, normal); + } + ); + } + + /// Build a byte array representing bitangent data computed from the provided tangent and normal. + /// XIV primarily stores bitangents, rather than tangents as with most other software, so we calculate on import. + private static byte[] BuildBitangent(Vector4 tangent, Vector3 normal) + { + var handedness = tangent.W; + var tangent3 = new Vector3(tangent.X, tangent.Y, tangent.Z); + var bitangent = Vector3.Normalize(Vector3.Cross(normal, tangent3)); + bitangent *= handedness; + + // Byte floats encode 0..1, and bitangents are stored as -1..1. Convert. + bitangent = (bitangent + Vector3.One) / 2; + return BuildNByte4(new Vector4(bitangent, handedness)); + } + + /// Attempt to calculate tangent values based on other pre-existing data. + private static Vector4[]? CalculateTangents(Accessors accessors, ushort[] indices, IList normals, IoNotifier notifier) + { + // To calculate tangents, we will also need access to uv data. + if (!accessors.TryGetValue("TEXCOORD_0", out var uvAccessor)) + return null; + + var positions = accessors["POSITION"].AsVector3Array(); + var uvs = uvAccessor.AsVector2Array(); + + notifier.Warning( + "Calculating tangents, this may result in degraded light interaction. For best results, ensure tangents are caculated or retained during export from 3D modelling tools."); + + var vertexCount = positions.Count; + + // https://github.com/TexTools/xivModdingFramework/blob/master/xivModdingFramework/Models/Helpers/ModelModifiers.cs#L1569 + // https://gamedev.stackexchange.com/a/68617 + // https://marti.works/posts/post-calculating-tangents-for-your-mesh/post/ + var tangents = new Vector3[vertexCount]; + var bitangents = new Vector3[vertexCount]; + + // Iterate over triangles, calculating tangents relative to the UVs. + for (var index = 0; index < indices.Length; index += 3) + { + // Collect information for this triangle. + var vertexIndex1 = indices[index]; + var vertexIndex2 = indices[index + 1]; + var vertexIndex3 = indices[index + 2]; + + var position1 = positions[vertexIndex1]; + var position2 = positions[vertexIndex2]; + var position3 = positions[vertexIndex3]; + + var texCoord1 = uvs[vertexIndex1]; + var texCoord2 = uvs[vertexIndex2]; + var texCoord3 = uvs[vertexIndex3]; + + // Calculate deltas for the position XYZ, and texcoord UV. + var edge1 = position2 - position1; + var edge2 = position3 - position1; + + var uv1 = texCoord2 - texCoord1; + var uv2 = texCoord3 - texCoord1; + + // Solve. + var r = 1.0f / (uv1.X * uv2.Y - uv1.Y * uv2.X); + var tangent = new Vector3( + (edge1.X * uv2.Y - edge2.X * uv1.Y) * r, + (edge1.Y * uv2.Y - edge2.Y * uv1.Y) * r, + (edge1.Z * uv2.Y - edge2.Z * uv1.Y) * r + ); + var bitangent = new Vector3( + (edge1.X * uv2.X - edge2.X * uv1.X) * r, + (edge1.Y * uv2.X - edge2.Y * uv1.X) * r, + (edge1.Z * uv2.X - edge2.Z * uv1.X) * r + ); + + // Update vertex values. + tangents[vertexIndex1] += tangent; + tangents[vertexIndex2] += tangent; + tangents[vertexIndex3] += tangent; + + bitangents[vertexIndex1] += bitangent; + bitangents[vertexIndex2] += bitangent; + bitangents[vertexIndex3] += bitangent; + } + + // All the triangles have been calculated, normalise the results for each vertex. + var result = new Vector4[vertexCount]; + for (var vertexIndex = 0; vertexIndex < vertexCount; vertexIndex++) + { + var n = normals[vertexIndex]; + var t = tangents[vertexIndex]; + var b = bitangents[vertexIndex]; + + // Gram-Schmidt orthogonalize and calculate handedness. + var tangent = Vector3.Normalize(t - n * Vector3.Dot(n, t)); + var handedness = Vector3.Dot(Vector3.Cross(t, b), n) > 0 ? 1 : -1; + + result[vertexIndex] = new Vector4(tangent, handedness); + } + + return result; + } + + public static VertexAttribute Color(Accessors accessors) + { + // Try to retrieve the custom color attribute we use for export, falling back to the glTF standard name. + if (!accessors.TryGetValue("_FFXIV_COLOR", out var accessor)) + accessors.TryGetValue("COLOR_0", out accessor); + + var element = new MdlStructs.VertexElement() + { + Stream = 1, + Type = (byte)MdlFile.VertexType.NByte4, + Usage = (byte)MdlFile.VertexUsage.Color, + }; + + // Some shaders rely on the presence of vertex colors to render - fall back to a pure white value if it's missing. + var values = accessor?.AsVector4Array(); + + return new VertexAttribute( + element, + index => BuildNByte4(values?[index] ?? Vector4.One) + ); + } + + private static byte[] BuildSingle2(Vector2 input) + => + [ + ..BitConverter.GetBytes(input.X), + ..BitConverter.GetBytes(input.Y), + ]; + + private static byte[] BuildSingle3(Vector3 input) + => + [ + ..BitConverter.GetBytes(input.X), + ..BitConverter.GetBytes(input.Y), + ..BitConverter.GetBytes(input.Z), + ]; + + private static byte[] BuildSingle4(Vector4 input) + => + [ + ..BitConverter.GetBytes(input.X), + ..BitConverter.GetBytes(input.Y), + ..BitConverter.GetBytes(input.Z), + ..BitConverter.GetBytes(input.W), + ]; + + private static byte[] BuildUByte4(Vector4 input) + => + [ + (byte)input.X, + (byte)input.Y, + (byte)input.Z, + (byte)input.W, + ]; + + private static byte[] BuildNByte4(Vector4 input) + => + [ + (byte)Math.Round(input.X * 255f), + (byte)Math.Round(input.Y * 255f), + (byte)Math.Round(input.Z * 255f), + (byte)Math.Round(input.W * 255f), + ]; + + private static float[] BuildUshort4(Vector4 v0, Vector4 v1) => + new[] + { + v0.X, v0.Y, v0.Z, v0.W, + v1.X, v1.Y, v1.Z, v1.W, + }; +} diff --git a/Penumbra/Import/Models/IoNotifier.cs b/Penumbra/Import/Models/IoNotifier.cs new file mode 100644 index 00000000..56ef7103 --- /dev/null +++ b/Penumbra/Import/Models/IoNotifier.cs @@ -0,0 +1,40 @@ +using OtterGui.Log; + +namespace Penumbra.Import.Models; + +public record class IoNotifier +{ + private readonly List _messages = []; + private string _context = ""; + + /// Create a new notifier with the specified context appended to any other context already present. + public IoNotifier WithContext(string context) + => this with { _context = $"{_context}{context}: "}; + + /// Send a warning with any current context to notification channels. + public void Warning(string content) + => SendMessage(content, Logger.LogLevel.Warning); + + /// Get the current warnings for this notifier. + /// This does not currently filter to notifications with the current notifier's context - it will return all IO notifications from all notifiers. + public IEnumerable GetWarnings() + => _messages; + + /// Create an exception with any current context. + [StackTraceHidden] + public Exception Exception(string message) + => Exception(message); + + /// Create an exception of the provided type with any current context. + [StackTraceHidden] + public TException Exception(string message) + where TException : Exception, new() + => (TException)Activator.CreateInstance(typeof(TException), $"{_context}{message}")!; + + private void SendMessage(string message, Logger.LogLevel type) + { + var fullText = $"{_context}{message}"; + Penumbra.Log.Message(type, fullText); + _messages.Add(fullText); + } +} diff --git a/Penumbra/Import/Models/ModelExtensions.cs b/Penumbra/Import/Models/ModelExtensions.cs new file mode 100644 index 00000000..2edb3ca4 --- /dev/null +++ b/Penumbra/Import/Models/ModelExtensions.cs @@ -0,0 +1,69 @@ +namespace Penumbra.Import.Models; + +public static class ModelExtensions +{ + // https://github.com/vpenades/SharpGLTF/blob/2073cf3cd671f8ecca9667f9a8c7f04ed865d3ac/src/Shared/_Extensions.cs#L158 + private const float UnitLengthThresholdVec3 = 0.00674f; + private const float UnitLengthThresholdVec4 = 0.00769f; + + internal static bool _IsFinite(this float value) + { + return float.IsFinite(value); + } + + internal static bool _IsFinite(this Vector2 v) + { + return v.X._IsFinite() && v.Y._IsFinite(); + } + + internal static bool _IsFinite(this Vector3 v) + { + return v.X._IsFinite() && v.Y._IsFinite() && v.Z._IsFinite(); + } + + internal static bool _IsFinite(this in Vector4 v) + { + return v.X._IsFinite() && v.Y._IsFinite() && v.Z._IsFinite() && v.W._IsFinite(); + } + + internal static Boolean IsNormalized(this Vector3 normal) + { + if (!normal._IsFinite()) return false; + + return Math.Abs(normal.Length() - 1) <= UnitLengthThresholdVec3; + } + + internal static void ValidateNormal(this Vector3 normal, string msg) + { + if (!normal._IsFinite()) throw new NotFiniteNumberException($"{msg} is invalid."); + + if (!normal.IsNormalized()) throw new ArithmeticException($"{msg} is not unit length."); + } + + internal static void ValidateTangent(this Vector4 tangent, string msg) + { + if (tangent.W != 1 && tangent.W != -1) throw new ArithmeticException(msg); + + new Vector3(tangent.X, tangent.Y, tangent.Z).ValidateNormal(msg); + } + + internal static Vector3 SanitizeNormal(this Vector3 normal) + { + if (normal == Vector3.Zero) return Vector3.UnitX; + return normal.IsNormalized() ? normal : Vector3.Normalize(normal); + } + + internal static bool IsValidTangent(this Vector4 tangent) + { + if (tangent.W != 1 && tangent.W != -1) return false; + + return new Vector3(tangent.X, tangent.Y, tangent.Z).IsNormalized(); + } + + internal static Vector4 SanitizeTangent(this Vector4 tangent) + { + var n = new Vector3(tangent.X, tangent.Y, tangent.Z).SanitizeNormal(); + var s = float.IsNaN(tangent.W) ? 1 : tangent.W; + return new Vector4(n, s > 0 ? 1 : -1); + } +} diff --git a/Penumbra/Import/Models/ModelManager.cs b/Penumbra/Import/Models/ModelManager.cs new file mode 100644 index 00000000..6818ad64 --- /dev/null +++ b/Penumbra/Import/Models/ModelManager.cs @@ -0,0 +1,325 @@ +using Dalamud.Plugin.Services; +using Lumina.Data.Parsing; +using OtterGui.Extensions; +using OtterGui.Services; +using OtterGui.Tasks; +using Penumbra.Collections.Manager; +using Penumbra.GameData; +using Penumbra.GameData.Data; +using Penumbra.GameData.Enums; +using Penumbra.GameData.Files; +using Penumbra.GameData.Structs; +using Penumbra.Import.Models.Export; +using Penumbra.Import.Models.Import; +using Penumbra.Import.Textures; +using Penumbra.Meta; +using Penumbra.Meta.Files; +using Penumbra.Meta.Manipulations; +using SharpGLTF.Scenes; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.PixelFormats; + +namespace Penumbra.Import.Models; + +using Schema2 = SharpGLTF.Schema2; +using LuminaMaterial = Lumina.Models.Materials.Material; + +public sealed class ModelManager(IFramework framework, MetaFileManager metaFileManager, ActiveCollections collections, GamePathParser parser) + : SingleTaskQueue, IDisposable, IService +{ + private readonly IFramework _framework = framework; + + private readonly ConcurrentDictionary _tasks = new(); + + private bool _disposed; + + public void Dispose() + { + _disposed = true; + foreach (var (_, cancel) in _tasks.Values.ToArray()) + cancel.Cancel(); + _tasks.Clear(); + } + + public Task ExportToGltf(in ExportConfig config, MdlFile mdl, IEnumerable sklbPaths, Func read, + string outputPath) + => EnqueueWithResult( + new ExportToGltfAction(this, config, mdl, sklbPaths, read, outputPath), + action => action.Notifier + ); + + public Task<(MdlFile?, IoNotifier)> ImportGltf(string inputPath) + => EnqueueWithResult( + new ImportGltfAction(inputPath), + action => (action.Out, action.Notifier) + ); + + /// Try to find the .sklb paths for a .mdl file. + /// .mdl file to look up the skeletons for. + /// Modified extra skeleton template parameters. + public string[] ResolveSklbsForMdl(string mdlPath, KeyValuePair[] estManipulations) + { + var info = parser.GetFileInfo(mdlPath); + if (info.FileType is not FileType.Model) + return []; + + var baseSkeleton = GamePaths.Sklb.Customization(info.GenderRace, "base", 1); + + return info.ObjectType switch + { + ObjectType.Equipment when info.EquipSlot.ToSlot() is EquipSlot.Body + => [baseSkeleton, ..ResolveEstSkeleton(EstType.Body, info, estManipulations)], + ObjectType.Equipment when info.EquipSlot.ToSlot() is EquipSlot.Head + => [baseSkeleton, ..ResolveEstSkeleton(EstType.Head, info, estManipulations)], + ObjectType.Equipment => [baseSkeleton], + ObjectType.Accessory => [baseSkeleton], + ObjectType.Character when info.BodySlot is BodySlot.Body or BodySlot.Tail => [baseSkeleton], + ObjectType.Character when info.BodySlot is BodySlot.Hair + => [baseSkeleton, ..ResolveEstSkeleton(EstType.Hair, info, estManipulations)], + ObjectType.Character when info.BodySlot is BodySlot.Face or BodySlot.Ear + => [baseSkeleton, ..ResolveEstSkeleton(EstType.Face, info, estManipulations)], + ObjectType.Character => throw new Exception($"Currently unsupported human model type \"{info.BodySlot}\"."), + ObjectType.DemiHuman => [GamePaths.Sklb.DemiHuman(info.PrimaryId)], + ObjectType.Monster => [GamePaths.Sklb.Monster(info.PrimaryId)], + ObjectType.Weapon => [GamePaths.Sklb.Weapon(info.PrimaryId)], + _ => [], + }; + } + + private string[] ResolveEstSkeleton(EstType type, GameObjectInfo info, KeyValuePair[] estManipulations) + { + // Try to find an EST entry from the manipulations provided. + var modEst = estManipulations + .FirstOrNull( + est => est.Key.GenderRace == info.GenderRace + && est.Key.Slot == type + && est.Key.SetId == info.PrimaryId + ); + + // Try to use an entry from provided manipulations, falling back to the current collection. + var targetId = modEst?.Value + ?? collections.Current.MetaCache?.GetEstEntry(type, info.GenderRace, info.PrimaryId) + ?? EstFile.GetDefault(metaFileManager, type, info.GenderRace, info.PrimaryId); + + // If there's no entries, we can assume that there's no additional skeleton. + if (targetId == EstEntry.Zero) + return []; + + return [GamePaths.Sklb.Customization(info.GenderRace, type.ToName(), targetId.AsId)]; + } + + /// Try to resolve the absolute path to a .mtrl from the potentially-partial path provided by a model. + private string? ResolveMtrlPath(string rawPath, IoNotifier notifier) + { + // TODO: this should probably be chosen in the export settings + var variantId = 1; + + // Get standardised paths + var absolutePath = rawPath.StartsWith('/') + ? LuminaMaterial.ResolveRelativeMaterialPath(rawPath, variantId) + : rawPath; + var relativePath = rawPath.StartsWith('/') + ? rawPath + : '/' + Path.GetFileName(rawPath); + + if (absolutePath == null) + { + notifier.Warning($"Material path \"{rawPath}\" could not be resolved."); + return null; + } + + var info = parser.GetFileInfo(absolutePath); + if (info.FileType is not FileType.Material) + { + notifier.Warning($"Material path {rawPath} does not conform to material conventions."); + return null; + } + + var resolvedPath = info.ObjectType switch + { + ObjectType.Character => GamePaths.Mtrl.Customization( + info.GenderRace, info.BodySlot, info.PrimaryId, relativePath, out _, out _, info.Variant), + _ => absolutePath, + }; + + Penumbra.Log.Debug($"Resolved material {rawPath} to {resolvedPath}"); + + return resolvedPath; + } + + private Task Enqueue(IAction action) + { + if (_disposed) + return Task.FromException(new ObjectDisposedException(nameof(ModelManager))); + + Task task; + lock (_tasks) + { + task = _tasks.GetOrAdd(action, a => + { + var token = new CancellationTokenSource(); + var t = Enqueue(a, token.Token); + t.ContinueWith(_ => + { + lock (_tasks) + { + return _tasks.TryRemove(a, out var unused); + } + }, CancellationToken.None, TaskContinuationOptions.None, TaskScheduler.Default); + return (t, token); + }).Item1; + } + + return task; + } + + private Task EnqueueWithResult(TAction action, Func process) + where TAction : IAction + => Enqueue(action).ContinueWith(task => + { + if (task is { IsFaulted: true, Exception: not null }) + throw task.Exception; + + return process(action); + }, TaskScheduler.Default); + + private class ExportToGltfAction( + ModelManager manager, + ExportConfig config, + MdlFile mdl, + IEnumerable sklbPaths, + Func read, + string outputPath) + : IAction + { + public readonly IoNotifier Notifier = new(); + + public void Execute(CancellationToken cancel) + { + Penumbra.Log.Debug($"[GLTF Export] Exporting model to {outputPath}..."); + + Penumbra.Log.Debug("[GLTF Export] Reading skeletons..."); + var xivSkeletons = BuildSkeletons(cancel); + + Penumbra.Log.Debug("[GLTF Export] Reading materials..."); + var materials = mdl.Materials + .Select(path => (path, material: BuildMaterial(path, Notifier, cancel))) + .Where(pair => pair.material != null) + .ToDictionary(pair => pair.path, pair => pair.material!.Value); + + Penumbra.Log.Debug("[GLTF Export] Converting model..."); + var model = ModelExporter.Export(config, mdl, xivSkeletons, materials, Notifier); + + Penumbra.Log.Debug("[GLTF Export] Building scene..."); + var scene = new SceneBuilder(); + model.AddToScene(scene); + + Penumbra.Log.Debug("[GLTF Export] Saving..."); + var gltfModel = scene.ToGltf2(); + gltfModel.Save(outputPath); + Penumbra.Log.Debug("[GLTF Export] Done."); + } + + /// Attempt to read out the pertinent information from the sklb file paths provided. + private IEnumerable BuildSkeletons(CancellationToken cancel) + { + // We're intentionally filtering failed reads here - the failure will + // be picked up, if relevant, when the model tries to create mappings + // for a bone in the failed sklb. + var havokTasks = sklbPaths + .Select(read) + .Where(bytes => bytes != null) + .Select(bytes => new SklbFile(bytes!)) + .WithIndex() + .Select(CreateHavokTask) + .ToArray(); + + // Result waits automatically. + return havokTasks.Select(task => SkeletonConverter.FromXml(task.Result)); + + // The havok methods we're relying on for this conversion are a bit + // finicky at the best of times, and can outright cause a CTD if they + // get upset. Running each conversion on its own tick seems to make + // this consistently non-crashy across my testing. + Task CreateHavokTask((SklbFile Sklb, int Index) pair) + => manager._framework.RunOnTick( + () => HavokConverter.HkxToXml(pair.Sklb.Skeleton), + delayTicks: pair.Index, cancellationToken: cancel); + } + + /// Read a .mtrl and populate its textures. + private MaterialExporter.Material? BuildMaterial(string relativePath, IoNotifier notifier, CancellationToken cancel) + { + var path = manager.ResolveMtrlPath(relativePath, notifier); + if (path == null) + return null; + + var bytes = read(path); + if (bytes == null) + return null; + + var mtrl = new MtrlFile(bytes); + + return new MaterialExporter.Material + { + Mtrl = mtrl, + Textures = mtrl.ShaderPackage.Samplers.ToDictionary( + sampler => (TextureUsage)sampler.SamplerId, + sampler => ConvertImage(mtrl.Textures[sampler.TextureIndex], cancel) + ), + }; + } + + /// Read a texture referenced by a .mtrl and convert it into an ImageSharp image. + private Image ConvertImage(MtrlFile.Texture texture, CancellationToken cancel) + { + // Work out the texture's path - the DX11 material flag controls a file name prefix. + GamePaths.Tex.HandleDx11Path(texture, out var texturePath); + var bytes = read(texturePath); + if (bytes == null) + return CreateDummyImage(); + + using var textureData = new MemoryStream(bytes); + var image = TexFileParser.Parse(textureData); + var pngImage = TextureManager.ConvertToPng(image, cancel).AsPng; + return pngImage ?? throw new Exception("Failed to convert texture to png."); + } + + private static Image CreateDummyImage() + { + var image = new Image(1, 1); + image[0, 0] = Color.White; + return image; + } + + public bool Equals(IAction? other) + { + if (other is not ExportToGltfAction rhs) + return false; + + // TODO: compare configuration and such + return true; + } + } + + private partial class ImportGltfAction(string inputPath) : IAction + { + public MdlFile? Out; + public readonly IoNotifier Notifier = new(); + + public void Execute(CancellationToken cancel) + { + var model = Schema2.ModelRoot.Load(inputPath); + + Out = ModelImporter.Import(model, Notifier); + } + + public bool Equals(IAction? other) + { + if (other is not ImportGltfAction rhs) + return false; + + return true; + } + } +} diff --git a/Penumbra/Import/Models/SkeletonConverter.cs b/Penumbra/Import/Models/SkeletonConverter.cs new file mode 100644 index 00000000..e180662d --- /dev/null +++ b/Penumbra/Import/Models/SkeletonConverter.cs @@ -0,0 +1,137 @@ +using System.Xml; +using OtterGui.Extensions; +using Penumbra.Import.Models.Export; + +namespace Penumbra.Import.Models; + +public static class SkeletonConverter +{ + /// Parse XIV skeleton data from a havok XML tagfile. + /// Havok XML tagfile containing skeleton data. + public static XivSkeleton FromXml(string xml) + { + var document = new XmlDocument(); + document.LoadXml(xml); + + var mainSkeletonId = GetMainSkeletonId(document); + + var skeletonNode = document.SelectSingleNode($"/hktagfile/object[@type='hkaSkeleton'][@id='{mainSkeletonId}']") + ?? throw new InvalidDataException($"Failed to find skeleton with id {mainSkeletonId}."); + var referencePose = ReadReferencePose(skeletonNode); + var parentIndices = ReadParentIndices(skeletonNode); + var boneNames = ReadBoneNames(skeletonNode); + + if (boneNames.Length != parentIndices.Length || boneNames.Length != referencePose.Length) + throw new InvalidDataException( + $"Mismatch in bone value array lengths: names({boneNames.Length}) parents({parentIndices.Length}) pose({referencePose.Length})"); + + var bones = referencePose + .Zip(parentIndices, boneNames) + .Select(values => + { + var (transform, parentIndex, name) = values; + return new XivSkeleton.Bone() + { + Transform = transform, + ParentIndex = parentIndex, + Name = name, + }; + }) + .ToArray(); + + return new XivSkeleton(bones); + } + + /// Get the main skeleton ID for a given skeleton document. + /// XML skeleton document. + private static string GetMainSkeletonId(XmlNode node) + { + var animationSkeletons = node + .SelectSingleNode("/hktagfile/object[@type='hkaAnimationContainer']/array[@name='skeletons']")? + .ChildNodes; + + if (animationSkeletons?.Count != 1) + throw new Exception($"Assumption broken: Expected 1 hkaAnimationContainer skeleton, got {animationSkeletons?.Count ?? 0}."); + + return animationSkeletons[0]!.InnerText; + } + + /// Read the reference pose transforms for a skeleton. + /// XML node for the skeleton. + private static XivSkeleton.Transform[] ReadReferencePose(XmlNode node) + { + return ReadArray( + CheckExists(node.SelectSingleNode("array[@name='referencePose']")), + n => + { + var raw = ReadVec12(n); + return new XivSkeleton.Transform() + { + Translation = new Vector3(raw[0], raw[1], raw[2]), + Rotation = new Quaternion(raw[4], raw[5], raw[6], raw[7]), + Scale = new Vector3(raw[8], raw[9], raw[10]), + }; + } + ); + } + + /// Read a 12-item vector from a tagfile. + /// Havok Vec12 XML node. + private static float[] ReadVec12(XmlNode node) + { + var array = node.ChildNodes + .Cast() + .Where(n => n.NodeType != XmlNodeType.Comment) + .Select(n => + { + var text = n.InnerText.AsSpan().Trim()[1..]; + return BitConverter.Int32BitsToSingle(int.Parse(text, NumberStyles.HexNumber)); + }) + .ToArray(); + + if (array.Length != 12) + throw new InvalidDataException($"Unexpected Vector12 length ({array.Length})."); + + return array; + } + + /// Read the bone parent relations for a skeleton. + /// XML node for the skeleton. + private static int[] ReadParentIndices(XmlNode node) + // todo: would be neat to genericise array between bare and children + => CheckExists(node.SelectSingleNode("array[@name='parentIndices']")) + .InnerText + .Split((char[]) [' ', '\n'], StringSplitOptions.RemoveEmptyEntries) + .Select(int.Parse) + .ToArray(); + + /// Read the names of bones in a skeleton. + /// XML node for the skeleton. + private static string[] ReadBoneNames(XmlNode node) + => ReadArray( + CheckExists(node.SelectSingleNode("array[@name='bones']")), + n => CheckExists(n.SelectSingleNode("string[@name='name']")).InnerText + ); + + /// Read an XML tagfile array, converting it via the provided conversion function. + /// Tagfile XML array node. + /// Function to convert array item nodes to required data types. + private static T[] ReadArray(XmlNode node, Func convert) + { + var element = (XmlElement)node; + var size = int.Parse(element.GetAttribute("size")); + var array = new T[size]; + + foreach (var (childNode, index) in element.ChildNodes.Cast().WithIndex()) + array[index] = convert(childNode); + + return array; + } + + /// Check if the argument is null, returning a non-nullable value if it exists, and throwing if not. + private static T CheckExists(T? value) + { + ArgumentNullException.ThrowIfNull(value); + return value; + } +} diff --git a/Penumbra/Import/Structs/ImporterState.cs b/Penumbra/Import/Structs/ImporterState.cs new file mode 100644 index 00000000..8c0ddb4e --- /dev/null +++ b/Penumbra/Import/Structs/ImporterState.cs @@ -0,0 +1,10 @@ +namespace Penumbra.Import.Structs; + +public enum ImporterState +{ + None, + WritingPackToDisk, + ExtractingModFiles, + DeduplicatingFiles, + Done, +} diff --git a/Penumbra/Import/Structs/MetaFileInfo.cs b/Penumbra/Import/Structs/MetaFileInfo.cs new file mode 100644 index 00000000..693c77b1 --- /dev/null +++ b/Penumbra/Import/Structs/MetaFileInfo.cs @@ -0,0 +1,104 @@ +using Penumbra.GameData.Enums; +using Penumbra.GameData; +using Penumbra.GameData.Data; + +namespace Penumbra.Import.Structs; + +/// +/// Obtain information what type of object is manipulated +/// by the given .meta file from TexTools, using its name. +/// +public partial struct MetaFileInfo +{ + // These are the valid regexes for .meta files that we are able to support at the moment. + [GeneratedRegex(@"bgcommon/hou/(?'Type1'[a-z]*)/general/(?'Id1'\d{4})/\k'Id1'\.meta", + RegexOptions.Compiled | RegexOptions.ExplicitCapture)] + private static partial Regex HousingMeta(); + + [GeneratedRegex( + @"chara/(?'Type1'[a-z]*)/(?'Pre1'[a-z])(?'Id1'\d{4})(/obj/(?'Type2'[a-z]*)/(?'Pre2'[a-z])(?'Id2'\d{4}))?/\k'Pre1'\k'Id1'(\k'Pre2'\k'Id2')?(_(?'Slot'[a-z]{3}))?\.meta", + RegexOptions.Compiled | RegexOptions.ExplicitCapture)] + private static partial Regex CharaMeta(); + + public readonly ObjectType PrimaryType; + public readonly BodySlot SecondaryType; + public readonly ushort PrimaryId; + public readonly ushort SecondaryId; + public readonly EquipSlot EquipSlot = EquipSlot.Unknown; + public readonly CustomizationType CustomizationType = CustomizationType.Unknown; + + private static bool ValidType(ObjectType type) + => type switch + { + ObjectType.Accessory => true, + ObjectType.Character => true, + ObjectType.Equipment => true, + ObjectType.DemiHuman => true, + ObjectType.Housing => true, + ObjectType.Monster => true, + ObjectType.Weapon => true, + ObjectType.Icon => false, + ObjectType.Font => false, + ObjectType.Interface => false, + ObjectType.LoadingScreen => false, + ObjectType.Map => false, + ObjectType.Vfx => false, + ObjectType.Unknown => false, + ObjectType.World => false, + _ => false, + }; + + public MetaFileInfo(GamePathParser parser, string fileName) + { + // Set the primary type from the gamePath start. + PrimaryType = parser.PathToObjectType(fileName); + PrimaryId = 0; + SecondaryType = BodySlot.Unknown; + SecondaryId = 0; + // Not all types of objects can have valid meta data manipulation. + if (!ValidType(PrimaryType)) + { + PrimaryType = ObjectType.Unknown; + return; + } + + // Housing files have a separate regex that just contains the primary id. + if (PrimaryType == ObjectType.Housing) + { + var housingMatch = HousingMeta().Match(fileName); + if (housingMatch.Success) + PrimaryId = ushort.Parse(housingMatch.Groups["Id1"].Value); + + return; + } + + // Non-housing is in chara/. + var match = CharaMeta().Match(fileName); + if (!match.Success) + return; + + // The primary ID has to be available for every object. + PrimaryId = ushort.Parse(match.Groups["Id1"].Value); + + // Depending on slot, we can set equip slot or customization type. + if (match.Groups["Slot"].Success) + switch (PrimaryType) + { + case ObjectType.Equipment: + case ObjectType.Accessory: + if (Names.SuffixToEquipSlot.TryGetValue(match.Groups["Slot"].Value, out var tmpSlot)) + EquipSlot = tmpSlot; + + break; + case ObjectType.Character: + if (Names.SuffixToCustomizationType.TryGetValue(match.Groups["Slot"].Value, out var tmpCustom)) + CustomizationType = tmpCustom; + + break; + } + + // Secondary type and secondary id are for weapons and demihumans. + if (match.Groups["Type2"].Success && Names.StringToBodySlot.TryGetValue(match.Groups["Type2"].Value, out SecondaryType)) + SecondaryId = ushort.Parse(match.Groups["Id2"].Value); + } +} diff --git a/Penumbra/Import/Structs/StreamDisposer.cs b/Penumbra/Import/Structs/StreamDisposer.cs new file mode 100644 index 00000000..c38362ce --- /dev/null +++ b/Penumbra/Import/Structs/StreamDisposer.cs @@ -0,0 +1,20 @@ +using Penumbra.Util; + +namespace Penumbra.Import.Structs; + +// Create an automatically disposing SqPack stream. +public class StreamDisposer : PenumbraSqPackStream +{ + private readonly FileStream _fileStream; + + public StreamDisposer(FileStream stream) + : base(stream) + => _fileStream = stream; + + protected override void Dispose(bool _) + { + var filePath = _fileStream.Name; + _fileStream.Dispose(); + File.Delete(filePath); + } +} diff --git a/Penumbra/Import/Structs/TexToolsStructs.cs b/Penumbra/Import/Structs/TexToolsStructs.cs new file mode 100644 index 00000000..f5b5ef4a --- /dev/null +++ b/Penumbra/Import/Structs/TexToolsStructs.cs @@ -0,0 +1,76 @@ +using Penumbra.Api.Enums; + +namespace Penumbra.Import.Structs; + +internal static class DefaultTexToolsData +{ + public const string Name = "New Mod"; + public const string Author = "Unknown"; + public const string Description = "Mod imported from TexTools mod pack."; + public const string DefaultOption = "Default"; +} + +[Serializable] +public class SimpleMod +{ + public string Name = string.Empty; + public string Category = string.Empty; + public string FullPath = string.Empty; + public string DatFile = string.Empty; + public long ModOffset = 0; + public long ModSize = 0; + public object? ModPackEntry = null; +} + +[Serializable] +public class ModPackPage +{ + public int PageIndex = 0; + public ModGroup[] ModGroups = []; +} + +[Serializable] +public class ModGroup +{ + public string GroupName = string.Empty; + public GroupType SelectionType = GroupType.Single; + public OptionList[] OptionList = []; + public string Description = string.Empty; +} + +[Serializable] +public class OptionList +{ + public string Name = string.Empty; + public string Description = string.Empty; + public string ImagePath = string.Empty; + public SimpleMod[] ModsJsons = []; + public string GroupName = string.Empty; + public GroupType SelectionType = GroupType.Single; + public bool IsChecked = false; +} + +[Serializable] +public class ExtendedModPack +{ + public string PackVersion = string.Empty; + public string Name = DefaultTexToolsData.Name; + public string Author = DefaultTexToolsData.Author; + public string Version = string.Empty; + public string Description = DefaultTexToolsData.Description; + public string Url = string.Empty; + public ModPackPage[] ModPackPages = []; + public SimpleMod[] SimpleModsList = []; +} + +[Serializable] +public class SimpleModPack +{ + public string TtmpVersion = string.Empty; + public string Name = DefaultTexToolsData.Name; + public string Author = DefaultTexToolsData.Author; + public string Version = string.Empty; + public string Description = DefaultTexToolsData.Description; + public string Url = string.Empty; + public SimpleMod[] SimpleModsList = []; +} diff --git a/Penumbra/Import/TexToolsImport.cs b/Penumbra/Import/TexToolsImport.cs new file mode 100644 index 00000000..8e4fea41 --- /dev/null +++ b/Penumbra/Import/TexToolsImport.cs @@ -0,0 +1,196 @@ +using Newtonsoft.Json; +using OtterGui.Compression; +using Penumbra.Import.Structs; +using Penumbra.Mods.Editor; +using Penumbra.Mods.Manager; +using Penumbra.Services; +using FileMode = System.IO.FileMode; +using ZipArchive = SharpCompress.Archives.Zip.ZipArchive; +using ZipArchiveEntry = SharpCompress.Archives.Zip.ZipArchiveEntry; + +namespace Penumbra.Import; + +public partial class TexToolsImporter : IDisposable +{ + private const string TempFileName = "textools-import"; + private static readonly JsonSerializerSettings JsonSettings = new() { NullValueHandling = NullValueHandling.Ignore }; + + private readonly DirectoryInfo _baseDirectory; + private readonly string _tmpFile; + + private readonly IEnumerable _modPackFiles; + private readonly int _modPackCount; + private FileStream? _tmpFileStream; + private StreamDisposer? _streamDisposer; + private readonly CancellationTokenSource _cancellation = new(); + private readonly CancellationToken _token; + + public ImporterState State { get; private set; } + public readonly List<(FileInfo File, DirectoryInfo? Mod, Exception? Error)> ExtractedMods; + + private readonly Configuration _config; + private readonly ModEditor _editor; + private readonly ModManager _modManager; + private readonly FileCompactor _compactor; + private readonly MigrationManager _migrationManager; + + public TexToolsImporter(int count, IEnumerable modPackFiles, Action handler, + Configuration config, ModEditor editor, ModManager modManager, FileCompactor compactor, MigrationManager migrationManager) + { + _baseDirectory = modManager.BasePath; + _tmpFile = Path.Combine(_baseDirectory.FullName, TempFileName); + _modPackFiles = modPackFiles; + _config = config; + _editor = editor; + _modManager = modManager; + _compactor = compactor; + _migrationManager = migrationManager; + _modPackCount = count; + ExtractedMods = new List<(FileInfo, DirectoryInfo?, Exception?)>(count); + _token = _cancellation.Token; + Task.Run(ImportFiles, _token) + .ContinueWith(_ => CloseStreams(), TaskScheduler.Default) + .ContinueWith(_ => + { + foreach (var (file, dir, error) in ExtractedMods) + handler(file, dir, error); + }, TaskScheduler.Default); + } + + private void CloseStreams() + { + _tmpFileStream?.Dispose(); + _tmpFileStream = null; + ResetStreamDisposer(); + } + + public void Dispose() + { + _cancellation.Cancel(true); + if (State != ImporterState.WritingPackToDisk) + { + _tmpFileStream?.Dispose(); + _tmpFileStream = null; + } + + if (State != ImporterState.ExtractingModFiles) + ResetStreamDisposer(); + } + + private void ImportFiles() + { + State = ImporterState.None; + _currentModPackIdx = 0; + foreach (var file in _modPackFiles) + { + _currentModDirectory = null; + if (_token.IsCancellationRequested) + { + ExtractedMods.Add((file, null, new TaskCanceledException("Task canceled by user."))); + continue; + } + + try + { + var directory = VerifyVersionAndImport(file); + ExtractedMods.Add((file, directory, null)); + if (_config.AutoDeduplicateOnImport) + { + State = ImporterState.DeduplicatingFiles; + _editor.Duplicates.DeduplicateMod(directory); + } + } + catch (Exception e) + { + ExtractedMods.Add((file, _currentModDirectory, e)); + _currentNumOptions = 0; + _currentOptionIdx = 0; + _currentFileIdx = 0; + _currentNumFiles = 0; + } + + ++_currentModPackIdx; + } + + State = ImporterState.Done; + } + + // Rudimentary analysis of a TTMP file by extension and version. + // Puts out warnings if extension does not correspond to data. + private DirectoryInfo VerifyVersionAndImport(FileInfo modPackFile) + { + if (modPackFile.Extension.ToLowerInvariant() is ".pmp" or ".pcp" or ".zip" or ".7z" or ".rar") + return HandleRegularArchive(modPackFile); + + using var zfs = modPackFile.OpenRead(); + using var extractedModPack = ZipArchive.Open(zfs); + + var mpl = FindZipEntry(extractedModPack, "TTMPL.mpl"); + if (mpl == null) + throw new FileNotFoundException("ZIP does not contain a TTMPL.mpl file."); + + var modRaw = GetStringFromZipEntry(mpl, Encoding.UTF8); + + // At least a better validation than going by the extension. + if (modRaw.Contains("\"TTMPVersion\":")) + { + if (modPackFile.Extension != ".ttmp2") + Penumbra.Log.Warning($"File {modPackFile.FullName} seems to be a V2 TTMP, but has the wrong extension."); + + return ImportV2ModPack(modPackFile, extractedModPack, modRaw); + } + + if (modPackFile.Extension != ".ttmp") + Penumbra.Log.Warning($"File {modPackFile.FullName} seems to be a V1 TTMP, but has the wrong extension."); + + return ImportV1ModPack(modPackFile, extractedModPack, modRaw); + } + + // You can in no way rely on any file paths in TTMPs so we need to just do this, sorry + private static ZipArchiveEntry? FindZipEntry(ZipArchive file, string fileName) + => file.Entries.FirstOrDefault(e => e is { IsDirectory: false, Key: not null } && e.Key.Contains(fileName)); + + private static string GetStringFromZipEntry(ZipArchiveEntry entry, Encoding encoding) + { + using var ms = new MemoryStream(); + using var s = entry.OpenEntryStream(); + s.CopyTo(ms); + return encoding.GetString(ms.ToArray()); + } + + private void WriteZipEntryToTempFile(Stream s) + { + _tmpFileStream?.Dispose(); // should not happen + _tmpFileStream = new FileStream(_tmpFile, FileMode.Create); + if (_token.IsCancellationRequested) + return; + + s.CopyTo(_tmpFileStream); + _tmpFileStream.Dispose(); + _tmpFileStream = null; + } + + private StreamDisposer GetSqPackStreamStream(ZipArchive file, string entryName) + { + State = ImporterState.WritingPackToDisk; + + // write shitty zip garbage to disk + var entry = FindZipEntry(file, entryName); + if (entry == null) + throw new FileNotFoundException($"ZIP does not contain a file named {entryName}."); + + using var s = entry.OpenEntryStream(); + + WriteZipEntryToTempFile(s); + + _streamDisposer?.Dispose(); // Should not happen. + var fs = new FileStream(_tmpFile, FileMode.Open); + return new StreamDisposer(fs); + } + + private void ResetStreamDisposer() + { + _streamDisposer?.Dispose(); + _streamDisposer = null; + } +} diff --git a/Penumbra/Import/TexToolsImporter.Archives.cs b/Penumbra/Import/TexToolsImporter.Archives.cs new file mode 100644 index 00000000..a80730bf --- /dev/null +++ b/Penumbra/Import/TexToolsImporter.Archives.cs @@ -0,0 +1,191 @@ +using Dalamud.Utility; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using OtterGui.Filesystem; +using Penumbra.Import.Structs; +using Penumbra.Mods; +using Penumbra.Services; +using SharpCompress.Archives; +using SharpCompress.Archives.Rar; +using SharpCompress.Archives.SevenZip; +using SharpCompress.Common; +using SharpCompress.Readers; +using ZipArchive = SharpCompress.Archives.Zip.ZipArchive; + +namespace Penumbra.Import; + +public partial class TexToolsImporter +{ + private static readonly ExtractionOptions _extractionOptions = new() + { + ExtractFullPath = true, + Overwrite = true, + }; + + /// + /// Extract regular compressed archives that are folders containing penumbra-formatted mods. + /// The mod has to either contain a meta.json at top level, or one folder deep. + /// If the meta.json is one folder deep, all other files have to be in the same folder. + /// The extracted folder gets its name either from that one top-level folder or from the mod name. + /// All data is extracted without manipulation of the files or metadata. + /// + private DirectoryInfo HandleRegularArchive(FileInfo modPackFile) + { + using var zfs = modPackFile.OpenRead(); + using var archive = ArchiveFactory.Open(zfs); + + var baseName = FindArchiveModMeta(archive, out var leadDir); + var name = string.Empty; + _currentOptionIdx = 0; + _currentNumOptions = 1; + _currentModName = modPackFile.Name; + _currentGroupName = string.Empty; + _currentOptionName = DefaultTexToolsData.Name; + _currentNumFiles = + archive switch + { + RarArchive r => r.Entries.Count, + ZipArchive z => z.Entries.Count, + SevenZipArchive s => s.Entries.Count, + _ => archive.Entries.Count(), + }; + Penumbra.Log.Information($" -> Importing {archive.Type} Archive."); + + _currentModDirectory = ModCreator.CreateModFolder(_baseDirectory, Path.GetRandomFileName(), _config.ReplaceNonAsciiOnImport, true); + + + State = ImporterState.ExtractingModFiles; + _currentFileIdx = 0; + var reader = archive.ExtractAllEntries(); + + while (reader.MoveToNextEntry()) + { + _token.ThrowIfCancellationRequested(); + + if (reader.Entry.IsDirectory) + { + --_currentNumFiles; + continue; + } + + Penumbra.Log.Information($" -> Extracting {reader.Entry.Key}"); + // Check that the mod has a valid name in the meta.json file. + if (Path.GetFileName(reader.Entry.Key) == "meta.json") + { + using var s = new MemoryStream(); + using var e = reader.OpenEntryStream(); + e.CopyTo(s); + s.Seek(0, SeekOrigin.Begin); + using var t = new StreamReader(s); + using var j = new JsonTextReader(t); + var obj = JObject.Load(j); + name = obj[nameof(Mod.Name)]?.Value()?.RemoveInvalidPathSymbols() ?? string.Empty; + if (name.Length == 0) + throw new Exception("Invalid mod archive: mod meta has no name."); + + using var f = File.OpenWrite(Path.Combine(_currentModDirectory.FullName, reader.Entry.Key!)); + s.Seek(0, SeekOrigin.Begin); + s.WriteTo(f); + } + else + { + HandleFileMigrationsAndWrite(reader); + } + + ++_currentFileIdx; + } + + _token.ThrowIfCancellationRequested(); + var oldName = _currentModDirectory.FullName; + + // Try renaming the folder three times because sometimes we get AccessDenied here for some unknown reason. + const int numTries = 3; + for (var i = 1;; ++i) + { + // Use either the top-level directory as the mods base name, or the (fixed for path) name in the json. + try + { + if (leadDir) + { + _currentModDirectory = ModCreator.CreateModFolder(_baseDirectory, baseName, _config.ReplaceNonAsciiOnImport, false); + Directory.Move(Path.Combine(oldName, baseName), _currentModDirectory.FullName); + Directory.Delete(oldName); + } + else + { + _currentModDirectory = ModCreator.CreateModFolder(_baseDirectory, name, _config.ReplaceNonAsciiOnImport, false); + Directory.Move(oldName, _currentModDirectory.FullName); + } + } + catch (IOException io) + { + if (i == numTries) + throw; + + Penumbra.Log.Warning($"Error when renaming the extracted mod, try {i}/{numTries}: {io.Message}."); + continue; + } + + break; + } + + _currentModDirectory.Refresh(); + _modManager.Creator.SplitMultiGroups(_currentModDirectory); + _editor.ModNormalizer.NormalizeUi(_currentModDirectory); + + return _currentModDirectory; + } + + + private void HandleFileMigrationsAndWrite(IReader reader) + { + switch (Path.GetExtension(reader.Entry.Key)) + { + case ".mdl": + _migrationManager.MigrateMdlDuringExtraction(reader, _currentModDirectory!.FullName, _extractionOptions); + break; + case ".mtrl": + _migrationManager.MigrateMtrlDuringExtraction(reader, _currentModDirectory!.FullName, _extractionOptions); + break; + case ".tex": + _migrationManager.FixMipMaps(reader, _currentModDirectory!.FullName, _extractionOptions); + break; + default: + reader.WriteEntryToDirectory(_currentModDirectory!.FullName, _extractionOptions); + break; + } + } + + // Search the archive for the meta.json file which needs to exist. + private static string FindArchiveModMeta(IArchive archive, out bool leadDir) + { + var entry = archive.Entries.FirstOrDefault(e => !e.IsDirectory && Path.GetFileName(e.Key) == "meta.json"); + // None found. + if (entry == null) + throw new Exception("Invalid mod archive: No meta.json contained."); + + var ret = string.Empty; + leadDir = false; + + // If the file is not at top-level. + if (entry.Key != "meta.json") + { + leadDir = true; + var directory = Path.GetDirectoryName(entry.Key); + // Should not happen. + if (directory.IsNullOrEmpty()) + throw new Exception("Invalid mod archive: Unknown error fetching meta.json."); + + ret = directory; + // Check that all other files are also contained in the top-level directory. + if (ret.IndexOfAny(['/', '\\']) >= 0 + || !archive.Entries.All(e + => e.Key != null && e.Key.StartsWith(ret) && (e.Key.Length == ret.Length || e.Key[ret.Length] is '/' or '\\'))) + throw new Exception( + "Invalid mod archive: meta.json in wrong location. It needs to be either at root or one directory deep, in which all other files must be nested too."); + } + + + return ret; + } +} diff --git a/Penumbra/Import/TexToolsImporter.Gui.cs b/Penumbra/Import/TexToolsImporter.Gui.cs new file mode 100644 index 00000000..5cb99d72 --- /dev/null +++ b/Penumbra/Import/TexToolsImporter.Gui.cs @@ -0,0 +1,106 @@ +using Dalamud.Bindings.ImGui; +using OtterGui; +using OtterGui.Raii; +using Penumbra.Import.Structs; +using Penumbra.UI.Classes; + +namespace Penumbra.Import; + +public partial class TexToolsImporter +{ + // Progress Data + private int _currentModPackIdx; + private int _currentOptionIdx; + private int _currentFileIdx; + + private int _currentNumOptions; + private int _currentNumFiles; + private string _currentModName = string.Empty; + private string _currentGroupName = string.Empty; + private string _currentOptionName = string.Empty; + private string _currentFileName = string.Empty; + + public bool DrawProgressInfo(Vector2 size) + { + if (_modPackCount == 0) + { + ImGuiUtil.Center("Nothing to extract."); + return true; + } + + if (_modPackCount == _currentModPackIdx) + { + DrawEndState(); + return true; + } + + ImGui.NewLine(); + var percentage = (float)_currentModPackIdx / _modPackCount; + ImGui.ProgressBar(percentage, size, $"Mod {_currentModPackIdx + 1} / {_modPackCount}"); + ImGui.NewLine(); + ImGui.TextUnformatted(State == ImporterState.DeduplicatingFiles + ? $"Deduplicating {_currentModName}..." + : $"Extracting {_currentModName}..."); + + if (_currentNumOptions > 1) + { + ImGui.NewLine(); + ImGui.NewLine(); + if (_currentOptionIdx >= _currentNumOptions) + ImGui.ProgressBar(1f, size, $"Extracted {_currentNumOptions} Options"); + else + ImGui.ProgressBar(_currentOptionIdx / (float)_currentNumOptions, size, + $"Extracting Option {_currentOptionIdx + 1} / {_currentNumOptions}..."); + + ImGui.NewLine(); + if (State != ImporterState.DeduplicatingFiles) + ImGui.TextUnformatted( + $"Extracting Option {(_currentGroupName.Length == 0 ? string.Empty : $"{_currentGroupName} - ")}{_currentOptionName}..."); + } + + ImGui.NewLine(); + ImGui.NewLine(); + if (_currentFileIdx >= _currentNumFiles) + ImGui.ProgressBar(1f, size, $"Extracted {_currentNumFiles} Files"); + else + ImGui.ProgressBar(_currentFileIdx / (float)_currentNumFiles, size, $"Extracting File {_currentFileIdx + 1} / {_currentNumFiles}..."); + + ImGui.NewLine(); + if (State != ImporterState.DeduplicatingFiles) + ImGui.TextUnformatted($"Extracting File {_currentFileName}..."); + return false; + } + + + private void DrawEndState() + { + var success = ExtractedMods.Count(t => t.Error == null); + + ImGui.TextUnformatted($"Successfully extracted {success} / {ExtractedMods.Count} files."); + ImGui.NewLine(); + using var table = ImRaii.Table("##files", 2); + if (!table) + return; + + foreach (var (file, dir, ex) in ExtractedMods) + { + ImGui.TableNextColumn(); + ImGui.TextUnformatted(file.Name); + ImGui.TableNextColumn(); + if (ex == null) + { + using var color = ImRaii.PushColor(ImGuiCol.Text, ColorId.FolderExpanded.Value()); + ImGui.TextUnformatted(dir?.FullName[(_baseDirectory.FullName.Length + 1)..] ?? "Unknown Directory"); + } + else + { + using var color = ImRaii.PushColor(ImGuiCol.Text, ColorId.ConflictingMod.Value()); + ImGui.TextUnformatted(ex.Message); + ImGuiUtil.HoverTooltip(ex.ToString()); + } + } + } + + public bool DrawCancelButton(Vector2 size) + => ImGuiUtil.DrawDisabledButton("Cancel", size, string.Empty, _token.IsCancellationRequested); +} diff --git a/Penumbra/Import/TexToolsImporter.ModPack.cs b/Penumbra/Import/TexToolsImporter.ModPack.cs new file mode 100644 index 00000000..fd9e50c0 --- /dev/null +++ b/Penumbra/Import/TexToolsImporter.ModPack.cs @@ -0,0 +1,268 @@ +using Newtonsoft.Json; +using OtterGui.Extensions; +using Penumbra.Api.Enums; +using Penumbra.Import.Structs; +using Penumbra.Mods; +using Penumbra.Mods.Groups; +using Penumbra.Mods.Settings; +using Penumbra.Mods.SubMods; +using Penumbra.Util; +using ZipArchive = SharpCompress.Archives.Zip.ZipArchive; + +namespace Penumbra.Import; + +public partial class TexToolsImporter +{ + private DirectoryInfo? _currentModDirectory; + + // Version 1 mod packs are a simple collection of files without much information. + private DirectoryInfo ImportV1ModPack(FileInfo modPackFile, ZipArchive extractedModPack, string modRaw) + { + _currentOptionIdx = 0; + _currentNumOptions = 1; + _currentModName = modPackFile.Name.Length > 0 ? modPackFile.Name : DefaultTexToolsData.Name; + _currentGroupName = string.Empty; + _currentOptionName = DefaultTexToolsData.DefaultOption; + + Penumbra.Log.Information(" -> Importing V1 ModPack"); + + var modListRaw = modRaw.Split( + new[] + { + "\r\n", + "\r", + "\n", + }, + StringSplitOptions.RemoveEmptyEntries + ); + + var modList = modListRaw.Select(m => JsonConvert.DeserializeObject(m, JsonSettings)!).ToList(); + + _currentModDirectory = ModCreator.CreateModFolder(_baseDirectory, Path.GetFileNameWithoutExtension(modPackFile.Name), + _config.ReplaceNonAsciiOnImport, true); + // Create a new ModMeta from the TTMP mod list info + _modManager.DataEditor.CreateMeta(_currentModDirectory, _currentModName, DefaultTexToolsData.Author, DefaultTexToolsData.Description, + null, null); + + // Open the mod data file from the mod pack as a SqPackStream + _streamDisposer = GetSqPackStreamStream(extractedModPack, "TTMPD.mpd"); + ExtractSimpleModList(_currentModDirectory, modList); + _modManager.Creator.CreateDefaultFiles(_currentModDirectory); + ResetStreamDisposer(); + return _currentModDirectory; + } + + // Version 2 mod packs can either be simple or extended, import accordingly. + private DirectoryInfo ImportV2ModPack(FileInfo _, ZipArchive extractedModPack, string modRaw) + { + var modList = JsonConvert.DeserializeObject(modRaw, JsonSettings)!; + + if (modList.TtmpVersion.EndsWith("s")) + return ImportSimpleV2ModPack(extractedModPack, modList); + + if (modList.TtmpVersion.EndsWith("w")) + return ImportExtendedV2ModPack(extractedModPack, modRaw); + + try + { + Penumbra.Log.Warning($"Unknown TTMPVersion <{modList.TtmpVersion}> given, trying to export as simple mod pack."); + return ImportSimpleV2ModPack(extractedModPack, modList); + } + catch (Exception e1) + { + Penumbra.Log.Warning($"Exporting as simple mod pack failed with following error, retrying as extended mod pack:\n{e1}"); + try + { + return ImportExtendedV2ModPack(extractedModPack, modRaw); + } + catch (Exception e2) + { + throw new IOException("Exporting as extended mod pack failed, too. Version unsupported or file defect.", e2); + } + } + } + + // Simple V2 mod packs are basically the same as V1 mod packs. + private DirectoryInfo ImportSimpleV2ModPack(ZipArchive extractedModPack, SimpleModPack modList) + { + _currentOptionIdx = 0; + _currentNumOptions = 1; + _currentModName = modList.Name; + _currentGroupName = string.Empty; + _currentOptionName = DefaultTexToolsData.DefaultOption; + Penumbra.Log.Information(" -> Importing Simple V2 ModPack"); + + _currentModDirectory = ModCreator.CreateModFolder(_baseDirectory, _currentModName, _config.ReplaceNonAsciiOnImport, true); + _modManager.DataEditor.CreateMeta(_currentModDirectory, _currentModName, modList.Author, string.IsNullOrEmpty(modList.Description) + ? "Mod imported from TexTools mod pack" + : modList.Description, modList.Version, modList.Url); + + // Open the mod data file from the mod pack as a SqPackStream + _streamDisposer = GetSqPackStreamStream(extractedModPack, "TTMPD.mpd"); + ExtractSimpleModList(_currentModDirectory, modList.SimpleModsList); + _modManager.Creator.CreateDefaultFiles(_currentModDirectory); + ResetStreamDisposer(); + return _currentModDirectory; + } + + // Obtain the number of relevant options to extract. + private static int GetOptionCount(ExtendedModPack pack) + => (pack.SimpleModsList.Length > 0 ? 1 : 0) + + pack.ModPackPages + .Sum(page => page.ModGroups + .Where(g => g.GroupName.Length > 0 && g.OptionList.Length > 0) + .Sum(group => group.OptionList + .Count(o => o.Name.Length > 0 && o.ModsJsons.Length > 0) + + (group.OptionList.Any(o => o.Name.Length > 0 && o.ModsJsons.Length == 0) ? 1 : 0))); + + private static string GetGroupName(string groupName, ISet names) + { + var baseName = groupName; + var i = 2; + while (!names.Add(groupName)) + groupName = $"{baseName} ({i++})"; + + return groupName; + } + + // Extended V2 mod packs contain multiple options that need to be handled separately. + private DirectoryInfo ImportExtendedV2ModPack(ZipArchive extractedModPack, string modRaw) + { + _currentOptionIdx = 0; + Penumbra.Log.Information(" -> Importing Extended V2 ModPack"); + + var modList = JsonConvert.DeserializeObject(modRaw, JsonSettings)!; + _currentNumOptions = GetOptionCount(modList); + _currentModName = modList.Name; + + _currentModDirectory = ModCreator.CreateModFolder(_baseDirectory, _currentModName, _config.ReplaceNonAsciiOnImport, true); + _modManager.DataEditor.CreateMeta(_currentModDirectory, _currentModName, modList.Author, modList.Description, modList.Version, + modList.Url); + + if (_currentNumOptions == 0) + return _currentModDirectory; + + // Open the mod data file from the mod pack as a SqPackStream + _streamDisposer = GetSqPackStreamStream(extractedModPack, "TTMPD.mpd"); + + // It can contain a simple list, still. + if (modList.SimpleModsList.Length > 0) + { + _currentGroupName = string.Empty; + _currentOptionName = "Default"; + ExtractSimpleModList(_currentModDirectory, modList.SimpleModsList); + ++_currentOptionIdx; + } + + // Iterate through all pages + var options = new List(); + var groupPriority = ModPriority.Default; + var groupNames = new HashSet(); + foreach (var page in modList.ModPackPages) + { + foreach (var group in page.ModGroups.Where(group => group.GroupName.Length > 0 && group.OptionList.Length > 0)) + { + var allOptions = group.OptionList.Where(option => option.Name.Length > 0 && option.ModsJsons.Length > 0).ToList(); + var (numGroups, maxOptions) = group.SelectionType == GroupType.Single + ? (1, allOptions.Count) + : (1 + allOptions.Count / IModGroup.MaxMultiOptions, IModGroup.MaxMultiOptions); + _currentGroupName = GetGroupName(group.GroupName, groupNames); + + var optionIdx = 0; + for (var groupId = 0; groupId < numGroups; ++groupId) + { + var name = numGroups == 1 ? _currentGroupName : $"{_currentGroupName}, Part {groupId + 1}"; + options.Clear(); + var groupFolder = ModCreator.NewSubFolderName(_currentModDirectory, name, _config.ReplaceNonAsciiOnImport) + ?? new DirectoryInfo(Path.Combine(_currentModDirectory.FullName, + numGroups == 1 ? $"Group {groupPriority + 1}" : $"Group {groupPriority + 1}, Part {groupId + 1}")); + + Setting? defaultSettings = group.SelectionType == GroupType.Multi ? Setting.Zero : null; + for (var i = 0; i + optionIdx < allOptions.Count && i < maxOptions; ++i) + { + var option = allOptions[i + optionIdx]; + _token.ThrowIfCancellationRequested(); + _currentOptionName = option.Name; + var optionFolder = ModCreator.NewSubFolderName(groupFolder, option.Name, _config.ReplaceNonAsciiOnImport) + ?? new DirectoryInfo(Path.Combine(groupFolder.FullName, $"Option {i + optionIdx + 1}")); + ExtractSimpleModList(optionFolder, option.ModsJsons); + options.Add(_modManager.Creator.CreateSubMod(_currentModDirectory, optionFolder, option, new ModPriority(i))); + if (option.IsChecked) + defaultSettings = group.SelectionType == GroupType.Multi + ? defaultSettings!.Value | Setting.Multi(i) + : Setting.Single(i); + + ++_currentOptionIdx; + } + + optionIdx += maxOptions; + + // Handle empty options for single select groups without creating a folder for them. + // We only want one of those at most. + if (group.SelectionType == GroupType.Single) + { + var idx = group.OptionList.IndexOf(o => o.Name.Length > 0 && o.ModsJsons.Length == 0); + if (idx >= 0) + { + var option = group.OptionList[idx]; + _currentOptionName = option.Name; + options.Insert(idx, MultiSubMod.WithoutGroup(option.Name, option.Description, ModPriority.Default)); + if (option.IsChecked) + defaultSettings = Setting.Single(idx); + ++_currentOptionIdx; + } + } + + _modManager.Creator.CreateOptionGroup(_currentModDirectory, group.SelectionType, name, groupPriority, groupPriority.Value, + defaultSettings ?? Setting.Zero, group.Description, options); + groupPriority += 1; + } + } + } + + ResetStreamDisposer(); + _modManager.Creator.CreateDefaultFiles(_currentModDirectory); + return _currentModDirectory; + } + + private void ExtractSimpleModList(DirectoryInfo outDirectory, ICollection mods) + { + State = ImporterState.ExtractingModFiles; + + _currentFileIdx = 0; + _currentNumFiles = mods.Count(m => m.FullPath.Length > 0); + + // Extract each SimpleMod into the new mod folder + foreach (var simpleMod in mods.Where(m => m.FullPath.Length > 0)) + { + ExtractMod(outDirectory, simpleMod); + ++_currentFileIdx; + } + } + + private void ExtractMod(DirectoryInfo outDirectory, SimpleMod mod) + { + if (_streamDisposer is not PenumbraSqPackStream stream) + return; + + Penumbra.Log.Information($" -> Extracting {mod.FullPath} at {mod.ModOffset:X}"); + + _token.ThrowIfCancellationRequested(); + var data = stream.ReadFile(mod.ModOffset); + + _currentFileName = mod.FullPath; + var extractedFile = new FileInfo(Path.Combine(outDirectory.FullName, mod.FullPath)); + + extractedFile.Directory?.Create(); + + data.Data = Path.GetExtension(extractedFile.FullName) switch + { + ".mdl" => _migrationManager.MigrateTtmpModel(extractedFile.FullName, data.Data), + ".mtrl" => _migrationManager.MigrateTtmpMaterial(extractedFile.FullName, data.Data), + ".tex" => _migrationManager.FixTtmpMipMaps(extractedFile.FullName, data.Data), + _ => data.Data, + }; + + _compactor.WriteAllBytesAsync(extractedFile.FullName, data.Data, _token).Wait(_token); + } +} diff --git a/Penumbra/Import/TexToolsMeta.Deserialization.cs b/Penumbra/Import/TexToolsMeta.Deserialization.cs new file mode 100644 index 00000000..7861a95b --- /dev/null +++ b/Penumbra/Import/TexToolsMeta.Deserialization.cs @@ -0,0 +1,117 @@ +using Lumina.Extensions; +using Penumbra.GameData.Enums; +using Penumbra.GameData.Structs; +using Penumbra.Import.Structs; +using Penumbra.Meta.Manipulations; + +namespace Penumbra.Import; + +public partial class TexToolsMeta +{ + // Deserialize and check Eqp Entries and add them to the list if they are non-default. + private void DeserializeEqpEntry(MetaFileInfo metaFileInfo, byte[]? data) + { + // Eqp can only be valid for equipment. + var mask = Eqp.Mask(metaFileInfo.EquipSlot); + if (data == null || mask == 0) + return; + + var identifier = new EqpIdentifier(metaFileInfo.PrimaryId, metaFileInfo.EquipSlot); + var value = Eqp.FromSlotAndBytes(metaFileInfo.EquipSlot, data) & mask; + MetaManipulations.TryAdd(identifier, value); + } + + // Deserialize and check Eqdp Entries and add them to the list if they are non-default. + private void DeserializeEqdpEntries(MetaFileInfo metaFileInfo, byte[]? data) + { + if (data == null) + return; + + var num = data.Length / 5; + using var reader = new BinaryReader(new MemoryStream(data)); + for (var i = 0; i < num; ++i) + { + // Use the SE gender/race code. + var gr = (GenderRace)reader.ReadUInt32(); + var byteValue = reader.ReadByte(); + if (!gr.IsValid() || !metaFileInfo.EquipSlot.IsEquipment() && !metaFileInfo.EquipSlot.IsAccessory()) + continue; + + var identifier = new EqdpIdentifier(metaFileInfo.PrimaryId, metaFileInfo.EquipSlot, gr); + var mask = Eqdp.Mask(metaFileInfo.EquipSlot); + var value = Eqdp.FromSlotAndBits(metaFileInfo.EquipSlot, (byteValue & 1) == 1, (byteValue & 2) == 2) & mask; + MetaManipulations.TryAdd(identifier, value); + } + } + + // Deserialize and check Gmp Entries and add them to the list if they are non-default. + private void DeserializeGmpEntry(MetaFileInfo metaFileInfo, byte[]? data) + { + if (data == null) + return; + + var value = GmpEntry.FromTexToolsMeta(data.AsSpan(0, 5)); + var identifier = new GmpIdentifier(metaFileInfo.PrimaryId); + MetaManipulations.TryAdd(identifier, value); + } + + // Deserialize and check Est Entries and add them to the list if they are non-default. + private void DeserializeEstEntries(MetaFileInfo metaFileInfo, byte[]? data) + { + if (data == null) + return; + + var num = data.Length / 6; + using var reader = new BinaryReader(new MemoryStream(data)); + for (var i = 0; i < num; ++i) + { + var gr = (GenderRace)reader.ReadUInt16(); + var id = (PrimaryId)reader.ReadUInt16(); + var value = new EstEntry(reader.ReadUInt16()); + var type = (metaFileInfo.SecondaryType, metaFileInfo.EquipSlot) switch + { + (BodySlot.Face, _) => EstType.Face, + (BodySlot.Hair, _) => EstType.Hair, + (_, EquipSlot.Head) => EstType.Head, + (_, EquipSlot.Body) => EstType.Body, + _ => (EstType)0, + }; + if (!gr.IsValid() || type == 0) + continue; + + var identifier = new EstIdentifier(id, type, gr); + MetaManipulations.TryAdd(identifier, value); + } + } + + // Deserialize and check IMC Entries and add them to the list if they are non-default. + // This requires requesting a file from Lumina, which may fail due to TexTools corruption or just not existing. + // TexTools creates IMC files for off-hand weapon models which may not exist in the game files. + private void DeserializeImcEntries(MetaFileInfo metaFileInfo, byte[]? data) + { + if (data == null) + return; + + var num = data.Length / 6; + using var reader = new BinaryReader(new MemoryStream(data)); + var values = reader.ReadStructures(num); + ushort i = 0; + try + { + var identifier = new ImcIdentifier(metaFileInfo.PrimaryId, 0, metaFileInfo.PrimaryType, metaFileInfo.SecondaryId, + metaFileInfo.EquipSlot, metaFileInfo.SecondaryType); + foreach (var value in values) + { + identifier = identifier with { Variant = (Variant)i }; + MetaManipulations.TryAdd(identifier, value); + ++i; + } + } + catch (Exception e) + { + Penumbra.Log.Warning( + $"Could not compute IMC manipulation for {metaFileInfo.PrimaryType} {metaFileInfo.PrimaryId}. This is in all likelihood due to TexTools corrupting your index files.\n" + + $"If the following error looks like Lumina is having trouble to read an IMC file, please do a do-over in TexTools:\n{e}"); + } + } +} diff --git a/Penumbra/Import/TexToolsMeta.Export.cs b/Penumbra/Import/TexToolsMeta.Export.cs new file mode 100644 index 00000000..9cce60e3 --- /dev/null +++ b/Penumbra/Import/TexToolsMeta.Export.cs @@ -0,0 +1,336 @@ +using Penumbra.Collections.Cache; +using Penumbra.GameData.Enums; +using Penumbra.GameData.Structs; +using Penumbra.Meta; +using Penumbra.Meta.Files; +using Penumbra.Meta.Manipulations; + +namespace Penumbra.Import; + +public partial class TexToolsMeta +{ + public static void WriteTexToolsMeta(MetaFileManager manager, MetaDictionary manipulations, DirectoryInfo basePath) + { + var files = ConvertToTexTools(manager, manipulations); + + foreach (var (file, data) in files) + { + var path = Path.Combine(basePath.FullName, file); + try + { + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + manager.Compactor.WriteAllBytes(path, data); + } + catch (Exception e) + { + Penumbra.Log.Error($"Could not write meta file {path}:\n{e}"); + } + } + } + + public static Dictionary ConvertToTexTools(MetaFileManager manager, MetaDictionary manips) + { + var ret = new Dictionary(); + foreach (var group in manips.Rsp.GroupBy(ManipToPath)) + { + if (group.Key.Length == 0) + continue; + + var bytes = WriteRgspFile(manager, group); + if (bytes.Length == 0) + continue; + + ret.Add(group.Key, bytes); + } + + foreach (var (file, dict) in SplitByFile(manips)) + { + var bytes = WriteMetaFile(manager, file, dict); + if (bytes.Length == 0) + continue; + + ret.Add(file, bytes); + } + + return ret; + } + + private static Dictionary SplitByFile(MetaDictionary manips) + { + var ret = new Dictionary(); + foreach (var (identifier, key) in manips.Imc) + GetDict(ManipToPath(identifier)).TryAdd(identifier, key); + + foreach (var (identifier, key) in manips.Eqp) + GetDict(ManipToPath(identifier)).TryAdd(identifier, key); + + foreach (var (identifier, key) in manips.Eqdp) + GetDict(ManipToPath(identifier)).TryAdd(identifier, key); + + foreach (var (identifier, key) in manips.Est) + GetDict(ManipToPath(identifier)).TryAdd(identifier, key); + + foreach (var (identifier, key) in manips.Gmp) + GetDict(ManipToPath(identifier)).TryAdd(identifier, key); + + ret.Remove(string.Empty); + + return ret; + + MetaDictionary GetDict(string path) + { + if (!ret.TryGetValue(path, out var dict)) + { + dict = new MetaDictionary(); + ret.Add(path, dict); + } + + return dict; + } + } + + private static byte[] WriteRgspFile(MetaFileManager manager, IEnumerable> manips) + { + var list = manips.GroupBy(m => m.Key.Attribute).ToDictionary(g => g.Key, g => g.Last()); + using var m = new MemoryStream(45); + using var b = new BinaryWriter(m); + // Version + b.Write(byte.MaxValue); + b.Write((ushort)2); + + var race = list.First().Value.Key.SubRace; + var gender = list.First().Value.Key.Attribute.ToGender(); + b.Write((byte)(race - 1)); // offset by one due to Unknown + b.Write((byte)(gender - 1)); // offset by one due to Unknown + + if (gender == Gender.Male) + { + Add(RspAttribute.MaleMinSize, RspAttribute.MaleMaxSize, RspAttribute.MaleMinTail, RspAttribute.MaleMaxTail); + } + else + { + Add(RspAttribute.FemaleMinSize, RspAttribute.FemaleMaxSize, RspAttribute.FemaleMinTail, RspAttribute.FemaleMaxTail); + Add(RspAttribute.BustMinX, RspAttribute.BustMinY, RspAttribute.BustMinZ, RspAttribute.BustMaxX, RspAttribute.BustMaxY, + RspAttribute.BustMaxZ); + } + + return m.GetBuffer(); + + void Add(params RspAttribute[] attributes) + { + foreach (var attribute in attributes) + { + var value = list.TryGetValue(attribute, out var tmp) ? tmp.Value : CmpFile.GetDefault(manager, race, attribute); + b.Write(value.Value); + } + } + } + + private static byte[] WriteMetaFile(MetaFileManager manager, string path, MetaDictionary manips) + { + var headerCount = (manips.Imc.Count > 0 ? 1 : 0) + + (manips.Eqp.Count > 0 ? 1 : 0) + + (manips.Eqdp.Count > 0 ? 1 : 0) + + (manips.Est.Count > 0 ? 1 : 0) + + (manips.Gmp.Count > 0 ? 1 : 0); + using var m = new MemoryStream(); + using var b = new BinaryWriter(m); + + // Header + // Current TT Metadata version. + b.Write(2u); + + // Null-terminated ASCII path. + var utf8Path = Encoding.ASCII.GetBytes(path); + b.Write(utf8Path); + b.Write((byte)0); + + // Number of Headers + b.Write((uint)headerCount); + // Current TT Size of Headers + b.Write((uint)12); + + // Start of Header Entries for some reason, which is absolutely useless. + var headerStart = b.BaseStream.Position + 4; + b.Write((uint)headerStart); + + var offset = (uint)(b.BaseStream.Position + 12 * manips.Count); + offset += WriteData(manager, b, offset, manips.Imc); + offset += WriteData(b, offset, manips.Eqdp); + offset += WriteData(b, offset, manips.Eqp); + offset += WriteData(b, offset, manips.Est); + offset += WriteData(b, offset, manips.Gmp); + + return m.ToArray(); + } + + private static uint WriteData(MetaFileManager manager, BinaryWriter b, uint offset, IReadOnlyDictionary manips) + { + if (manips.Count == 0) + return 0; + + b.Write((uint)MetaManipulationType.Imc); + b.Write(offset); + + var oldPos = b.BaseStream.Position; + b.Seek((int)offset, SeekOrigin.Begin); + + var refIdentifier = manips.First().Key; + var baseFile = new ImcFile(manager, refIdentifier); + foreach (var (identifier, entry) in manips) + ImcCache.Apply(baseFile, identifier, entry); + + var partIdx = refIdentifier.ObjectType is ObjectType.Equipment or ObjectType.Accessory + ? ImcFile.PartIndex(refIdentifier.EquipSlot) + : 0; + + for (var i = 0; i <= baseFile.Count; ++i) + { + var entry = baseFile.GetEntry(partIdx, (Variant)i); + b.Write(entry.MaterialId); + b.Write(entry.DecalId); + b.Write(entry.AttributeAndSound); + b.Write(entry.VfxId); + b.Write(entry.MaterialAnimationId); + } + + var size = b.BaseStream.Position - offset; + b.Seek((int)oldPos, SeekOrigin.Begin); + return (uint)size; + } + + private static uint WriteData(BinaryWriter b, uint offset, IReadOnlyDictionary manips) + { + if (manips.Count == 0) + return 0; + + b.Write((uint)MetaManipulationType.Eqdp); + b.Write(offset); + + var oldPos = b.BaseStream.Position; + b.Seek((int)offset, SeekOrigin.Begin); + + foreach (var (identifier, entry) in manips) + { + b.Write((uint)identifier.GenderRace); + b.Write(entry.AsByte); + } + + var size = b.BaseStream.Position - offset; + b.Seek((int)oldPos, SeekOrigin.Begin); + return (uint)size; + } + + private static uint WriteData(BinaryWriter b, uint offset, + IReadOnlyDictionary manips) + { + if (manips.Count == 0) + return 0; + + b.Write((uint)MetaManipulationType.Imc); + b.Write(offset); + + var oldPos = b.BaseStream.Position; + b.Seek((int)offset, SeekOrigin.Begin); + + foreach (var (identifier, entry) in manips) + { + var numBytes = Eqp.BytesAndOffset(identifier.Slot).Item1; + for (var i = 0; i < numBytes; ++i) + b.Write((byte)(entry.Value >> (8 * i))); + } + + var size = b.BaseStream.Position - offset; + b.Seek((int)oldPos, SeekOrigin.Begin); + return (uint)size; + } + + private static uint WriteData(BinaryWriter b, uint offset, IReadOnlyDictionary manips) + { + if (manips.Count == 0) + return 0; + + b.Write((uint)MetaManipulationType.Imc); + b.Write(offset); + + var oldPos = b.BaseStream.Position; + b.Seek((int)offset, SeekOrigin.Begin); + + foreach (var (identifier, entry) in manips) + { + b.Write((ushort)identifier.GenderRace); + b.Write(identifier.SetId.Id); + b.Write(entry.Value); + } + + var size = b.BaseStream.Position - offset; + b.Seek((int)oldPos, SeekOrigin.Begin); + return (uint)size; + } + + private static uint WriteData(BinaryWriter b, uint offset, IReadOnlyDictionary manips) + { + if (manips.Count == 0) + return 0; + + b.Write((uint)MetaManipulationType.Imc); + b.Write(offset); + + var oldPos = b.BaseStream.Position; + b.Seek((int)offset, SeekOrigin.Begin); + + foreach (var entry in manips.Values) + { + b.Write((uint)entry.Value); + b.Write(entry.UnknownTotal); + } + + var size = b.BaseStream.Position - offset; + b.Seek((int)oldPos, SeekOrigin.Begin); + return (uint)size; + } + + private static string ManipToPath(ImcIdentifier manip) + { + var path = manip.GamePath().ToString(); + var replacement = manip.ObjectType switch + { + ObjectType.Accessory => $"_{manip.EquipSlot.ToSuffix()}.meta", + ObjectType.Equipment => $"_{manip.EquipSlot.ToSuffix()}.meta", + ObjectType.Character => $"_{manip.BodySlot.ToSuffix()}.meta", + _ => ".meta", + }; + + return path.Replace(".imc", replacement); + } + + private static string ManipToPath(EqdpIdentifier manip) + => manip.Slot.IsAccessory() + ? $"chara/accessory/a{manip.SetId.Id:D4}/a{manip.SetId.Id:D4}_{manip.Slot.ToSuffix()}.meta" + : $"chara/equipment/e{manip.SetId.Id:D4}/e{manip.SetId.Id:D4}_{manip.Slot.ToSuffix()}.meta"; + + private static string ManipToPath(EqpIdentifier manip) + => manip.Slot.IsAccessory() + ? $"chara/accessory/a{manip.SetId.Id:D4}/a{manip.SetId.Id:D4}_{manip.Slot.ToSuffix()}.meta" + : $"chara/equipment/e{manip.SetId.Id:D4}/e{manip.SetId.Id:D4}_{manip.Slot.ToSuffix()}.meta"; + + private static string ManipToPath(EstIdentifier manip) + { + var raceCode = Names.CombinedRace(manip.Gender, manip.Race).ToRaceCode(); + return manip.Slot switch + { + EstType.Hair => $"chara/human/c{raceCode}/obj/hair/h{manip.SetId.Id:D4}/c{raceCode}h{manip.SetId.Id:D4}_hir.meta", + EstType.Face => $"chara/human/c{raceCode}/obj/face/h{manip.SetId.Id:D4}/c{raceCode}f{manip.SetId.Id:D4}_fac.meta", + EstType.Body => $"chara/equipment/e{manip.SetId.Id:D4}/e{manip.SetId.Id:D4}_{EquipSlot.Body.ToSuffix()}.meta", + EstType.Head => $"chara/equipment/e{manip.SetId.Id:D4}/e{manip.SetId.Id:D4}_{EquipSlot.Head.ToSuffix()}.meta", + _ => throw new ArgumentOutOfRangeException(), + }; + } + + private static string ManipToPath(GmpIdentifier manip) + => $"chara/equipment/e{manip.SetId.Id:D4}/e{manip.SetId.Id:D4}_{EquipSlot.Head.ToSuffix()}.meta"; + + + private static string ManipToPath(KeyValuePair manip) + => $"chara/xls/charamake/rgsp/{(int)manip.Key.SubRace - 1}-{(int)manip.Key.Attribute.ToGender() - 1}.rgsp"; +} diff --git a/Penumbra/Import/TexToolsMeta.Rgsp.cs b/Penumbra/Import/TexToolsMeta.Rgsp.cs new file mode 100644 index 00000000..77c70e6c --- /dev/null +++ b/Penumbra/Import/TexToolsMeta.Rgsp.cs @@ -0,0 +1,75 @@ +using Penumbra.GameData.Enums; +using Penumbra.Meta; +using Penumbra.Meta.Manipulations; + +namespace Penumbra.Import; + +public partial class TexToolsMeta +{ + // Parse a single rgsp file. + public static TexToolsMeta FromRgspFile(MetaFileManager manager, string filePath, byte[] data) + { + if (data.Length != 45 && data.Length != 42) + { + Penumbra.Log.Error("Error while parsing .rgsp file:\n\tInvalid number of bytes."); + return Invalid; + } + + using var s = new MemoryStream(data); + using var br = new BinaryReader(s); + // The first value is a flag that signifies version. + // If it is byte.max, the following two bytes are the version, + // otherwise it is version 1 and signifies the sub race instead. + var flag = br.ReadByte(); + var version = flag != 255 ? (uint)1 : br.ReadUInt16(); + + var ret = new TexToolsMeta(manager, filePath, version); + + // SubRace is offset by one due to Unknown. + var subRace = (SubRace)(version == 1 ? flag + 1 : br.ReadByte() + 1); + if (!Enum.IsDefined(typeof(SubRace), subRace) || subRace == SubRace.Unknown) + { + Penumbra.Log.Error($"Error while parsing .rgsp file:\n\t{subRace} is not a valid SubRace."); + return Invalid; + } + + // Next byte is Gender. 1 is Female, 0 is Male. + var gender = br.ReadByte(); + if (gender != 1 && gender != 0) + { + Penumbra.Log.Error($"Error while parsing .rgsp file:\n\t{gender} is neither Male nor Female."); + return Invalid; + } + + if (gender == 1) + { + Add(RspAttribute.FemaleMinSize, br.ReadSingle()); + Add(RspAttribute.FemaleMaxSize, br.ReadSingle()); + Add(RspAttribute.FemaleMinTail, br.ReadSingle()); + Add(RspAttribute.FemaleMaxTail, br.ReadSingle()); + + Add(RspAttribute.BustMinX, br.ReadSingle()); + Add(RspAttribute.BustMinY, br.ReadSingle()); + Add(RspAttribute.BustMinZ, br.ReadSingle()); + Add(RspAttribute.BustMaxX, br.ReadSingle()); + Add(RspAttribute.BustMaxY, br.ReadSingle()); + Add(RspAttribute.BustMaxZ, br.ReadSingle()); + } + else + { + Add(RspAttribute.MaleMinSize, br.ReadSingle()); + Add(RspAttribute.MaleMaxSize, br.ReadSingle()); + Add(RspAttribute.MaleMinTail, br.ReadSingle()); + Add(RspAttribute.MaleMaxTail, br.ReadSingle()); + } + + return ret; + + // Add the given values to the manipulations if they are not default. + void Add(RspAttribute attribute, float value) + { + var identifier = new RspIdentifier(subRace, attribute); + ret.MetaManipulations.TryAdd(identifier, new RspEntry(value)); + } + } +} diff --git a/Penumbra/Import/TexToolsMeta.cs b/Penumbra/Import/TexToolsMeta.cs new file mode 100644 index 00000000..f98eddbe --- /dev/null +++ b/Penumbra/Import/TexToolsMeta.cs @@ -0,0 +1,91 @@ +using Penumbra.GameData.Data; +using Penumbra.Import.Structs; +using Penumbra.Meta; +using Penumbra.Meta.Manipulations; + +namespace Penumbra.Import; + +/// TexTools provices custom generated *.meta files for its modpacks, that contain changes to +/// - imc files +/// - eqp files +/// - gmp files +/// - est files +/// - eqdp files +/// made by the mod. The filename determines to what the changes are applied, and the binary file itself contains changes. +/// We parse every *.meta file in a mod and combine all actual changes that do not keep data on default values and that can be applied to the game in a .json. +/// TexTools may also generate files that contain non-existing changes, e.g. *.imc files for weapon offhands, which will be ignored. +/// TexTools also provides .rgsp files, that contain changes to the racial scaling parameters in the human.cmp file. +public partial class TexToolsMeta +{ + /// An empty TexToolsMeta. + public static readonly TexToolsMeta Invalid = new(null!, string.Empty, 0); + + // The info class determines the files or table locations the changes need to apply to from the filename. + public readonly uint Version; + public readonly string FilePath; + public readonly MetaDictionary MetaManipulations = new(); + + + public TexToolsMeta(GamePathParser parser, byte[] data) + { + try + { + using var reader = new BinaryReader(new MemoryStream(data)); + Version = reader.ReadUInt32(); + FilePath = ReadNullTerminated(reader); + var metaInfo = new MetaFileInfo(parser, FilePath); + var numHeaders = reader.ReadUInt32(); + var headerSize = reader.ReadUInt32(); + var headerStart = reader.ReadUInt32(); + reader.BaseStream.Seek(headerStart, SeekOrigin.Begin); + + List<(MetaManipulationType type, uint offset, int size)> entries = []; + for (var i = 0; i < numHeaders; ++i) + { + var currentOffset = reader.BaseStream.Position; + var type = (MetaManipulationType)reader.ReadUInt32(); + var offset = reader.ReadUInt32(); + var size = reader.ReadInt32(); + entries.Add((type, offset, size)); + reader.BaseStream.Seek(currentOffset + headerSize, SeekOrigin.Begin); + } + + byte[]? ReadEntry(MetaManipulationType type) + { + var idx = entries.FindIndex(t => t.type == type); + if (idx < 0) + return null; + + reader.BaseStream.Seek(entries[idx].offset, SeekOrigin.Begin); + return reader.ReadBytes(entries[idx].size); + } + + DeserializeEqpEntry(metaInfo, ReadEntry(MetaManipulationType.Eqp)); + DeserializeGmpEntry(metaInfo, ReadEntry(MetaManipulationType.Gmp)); + DeserializeEqdpEntries(metaInfo, ReadEntry(MetaManipulationType.Eqdp)); + DeserializeEstEntries(metaInfo, ReadEntry(MetaManipulationType.Est)); + DeserializeImcEntries(metaInfo, ReadEntry(MetaManipulationType.Imc)); + } + catch (Exception e) + { + FilePath = ""; + Penumbra.Log.Error($"Error while parsing .meta file:\n{e}"); + } + } + + private TexToolsMeta(MetaFileManager metaFileManager, string filePath, uint version) + { + FilePath = filePath; + Version = version; + } + + // Read a null terminated string from a binary reader. + private static string ReadNullTerminated(BinaryReader reader) + { + var builder = new StringBuilder(); + for (var c = reader.ReadChar(); c != 0; c = reader.ReadChar()) + builder.Append(c); + + return builder.ToString(); + } +} diff --git a/Penumbra/Import/Textures/BaseImage.cs b/Penumbra/Import/Textures/BaseImage.cs new file mode 100644 index 00000000..eba2d8ba --- /dev/null +++ b/Penumbra/Import/Textures/BaseImage.cs @@ -0,0 +1,109 @@ +using Lumina.Data.Files; +using OtterTex; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.PixelFormats; + +namespace Penumbra.Import.Textures; + +public readonly struct BaseImage : IDisposable +{ + public readonly object? Image; + + public BaseImage(ScratchImage scratch) + => Image = scratch; + + public BaseImage(Image image) + => Image = image; + + public static implicit operator BaseImage(ScratchImage scratch) + => new(scratch); + + public static implicit operator BaseImage(Image img) + => new(img); + + public ScratchImage? AsDds + => Image as ScratchImage; + + public Image? AsPng + => Image as Image; + + public TexFile? AsTex + => Image as TexFile; + + public TextureType Type + => Image switch + { + null => TextureType.Unknown, + ScratchImage => TextureType.Dds, + Image => TextureType.Png, + _ => TextureType.Unknown, + }; + + public void Dispose() + => (Image as IDisposable)?.Dispose(); + + /// Obtain RGBA pixel data for the given image (not including any mip maps.) + public (byte[] Rgba, int Width, int Height) GetPixelData() + { + switch (Image) + { + case null: return (Array.Empty(), 0, 0); + case ScratchImage scratch: + { + var rgba = scratch.GetRGBA(out var f).ThrowIfError(f); + return (rgba.Pixels[..(f.Meta.Width * f.Meta.Height * (f.Meta.Format.BitsPerPixel() / 8))].ToArray(), f.Meta.Width, + f.Meta.Height); + } + case Image img: + { + var ret = new byte[img.Height * img.Width * 4]; + img.CopyPixelDataTo(ret); + return (ret, img.Width, img.Height); + } + default: return (Array.Empty(), 0, 0); + } + } + + public (int Width, int Height) Dimensions + => Image switch + { + null => (0, 0), + ScratchImage scratch => (scratch.Meta.Width, scratch.Meta.Height), + Image img => (img.Width, img.Height), + _ => (0, 0), + }; + + public int Width + => Dimensions.Width; + + public int Height + => Dimensions.Height; + + public Vector2 ImageSize + { + get + { + var (width, height) = Dimensions; + return new Vector2(width, height); + } + } + + public DXGIFormat Format + => Image switch + { + null => DXGIFormat.Unknown, + ScratchImage s => s.Meta.Format, + TexFile t => t.Header.Format.ToDXGI(), + Image => DXGIFormat.B8G8R8X8UNorm, + _ => DXGIFormat.Unknown, + }; + + public int MipMaps + => Image switch + { + null => 0, + ScratchImage s => s.Meta.MipLevels, + TexFile t => t.Header.MipCount, + _ => 1, + }; +} diff --git a/Penumbra/Import/Textures/CombinedTexture.Manipulation.cs b/Penumbra/Import/Textures/CombinedTexture.Manipulation.cs new file mode 100644 index 00000000..7a7e5888 --- /dev/null +++ b/Penumbra/Import/Textures/CombinedTexture.Manipulation.cs @@ -0,0 +1,403 @@ +using Dalamud.Bindings.ImGui; +using OtterGui.Raii; +using OtterGui; +using SixLabors.ImageSharp.PixelFormats; +using Dalamud.Interface.Utility; +using Penumbra.UI; + +namespace Penumbra.Import.Textures; + +public partial class CombinedTexture +{ + private Matrix4x4 _multiplierLeft = Matrix4x4.Identity; + private Vector4 _constantLeft = Vector4.Zero; + private Matrix4x4 _multiplierRight = Matrix4x4.Identity; + private Vector4 _constantRight = Vector4.Zero; + private int _offsetX; + private int _offsetY; + private CombineOp _combineOp = CombineOp.Over; + private ResizeOp _resizeOp = ResizeOp.None; + private Channels _copyChannels = Channels.Red | Channels.Green | Channels.Blue | Channels.Alpha; + + private RgbaPixelData _leftPixels = RgbaPixelData.Empty; + private RgbaPixelData _rightPixels = RgbaPixelData.Empty; + + private const float OneThird = 1.0f / 3.0f; + private const float RWeight = 0.2126f; + private const float GWeight = 0.7152f; + private const float BWeight = 0.0722f; + + // @formatter:off + private static readonly IReadOnlyList<(string Label, Matrix4x4 Multiplier, Vector4 Constant)> PredefinedColorTransforms = + new[] + { + ("No Transform (Identity)", Matrix4x4.Identity, Vector4.Zero ), + ("Grayscale (Average)", new Matrix4x4(OneThird, OneThird, OneThird, 0.0f, OneThird, OneThird, OneThird, 0.0f, OneThird, OneThird, OneThird, 0.0f, 0.0f, 0.0f, 0.0f, 1.0f), Vector4.Zero ), + ("Grayscale (Weighted)", new Matrix4x4(RWeight, RWeight, RWeight, 0.0f, GWeight, GWeight, GWeight, 0.0f, BWeight, BWeight, BWeight, 0.0f, 0.0f, 0.0f, 0.0f, 1.0f), Vector4.Zero ), + ("Grayscale (Average) to Alpha", new Matrix4x4(OneThird, OneThird, OneThird, OneThird, OneThird, OneThird, OneThird, OneThird, OneThird, OneThird, OneThird, OneThird, 0.0f, 0.0f, 0.0f, 0.0f), Vector4.Zero ), + ("Grayscale (Weighted) to Alpha", new Matrix4x4(RWeight, RWeight, RWeight, RWeight, GWeight, GWeight, GWeight, GWeight, BWeight, BWeight, BWeight, BWeight, 0.0f, 0.0f, 0.0f, 0.0f), Vector4.Zero ), + ("Make Opaque (Drop Alpha)", new Matrix4x4(1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f), Vector4.UnitW ), + ("Extract Red", new Matrix4x4(1.0f, 1.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f), Vector4.UnitW ), + ("Extract Green", new Matrix4x4(0.0f, 0.0f, 0.0f, 0.0f, 1.0f, 1.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f), Vector4.UnitW ), + ("Extract Blue", new Matrix4x4(0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 1.0f, 1.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f), Vector4.UnitW ), + ("Extract Alpha", new Matrix4x4(0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 1.0f, 1.0f, 1.0f, 0.0f), Vector4.UnitW ), + }; + // @formatter:on + + private Vector4 DataLeft(int offset) + => CappedVector(_leftPixels.PixelData, offset, _multiplierLeft, _constantLeft); + + private Vector4 DataRight(int offset) + => CappedVector(_rightPixels.PixelData, offset, _multiplierRight, _constantRight); + + private Vector4 DataRight(int x, int y) + { + x += _offsetX; + y += _offsetY; + if (x < 0 || x >= _rightPixels.Width || y < 0 || y >= _rightPixels.Height) + return Vector4.Zero; + + var offset = (y * _rightPixels.Width + x) * 4; + return CappedVector(_rightPixels.PixelData, offset, _multiplierRight, _constantRight); + } + + private void AddPixelsMultiplied(int y, ParallelLoopState _) + { + for (var x = 0; x < _leftPixels.Width; ++x) + { + var offset = (_leftPixels.Width * y + x) * 4; + var left = DataLeft(offset); + var right = DataRight(x, y); + var alpha = right.W + left.W * (1 - right.W); + var rgba = alpha == 0 + ? new Rgba32() + : new Rgba32(((right * right.W + left * left.W * (1 - right.W)) / alpha) with { W = alpha }); + _centerStorage.RgbaPixels[offset] = rgba.R; + _centerStorage.RgbaPixels[offset + 1] = rgba.G; + _centerStorage.RgbaPixels[offset + 2] = rgba.B; + _centerStorage.RgbaPixels[offset + 3] = rgba.A; + } + } + + private void ReverseAddPixelsMultiplied(int y, ParallelLoopState _) + { + for (var x = 0; x < _leftPixels.Width; ++x) + { + var offset = (_leftPixels.Width * y + x) * 4; + var left = DataLeft(offset); + var right = DataRight(x, y); + var alpha = left.W + right.W * (1 - left.W); + var rgba = alpha == 0 + ? new Rgba32() + : new Rgba32(((left * left.W + right * right.W * (1 - left.W)) / alpha) with { W = alpha }); + _centerStorage.RgbaPixels[offset] = rgba.R; + _centerStorage.RgbaPixels[offset + 1] = rgba.G; + _centerStorage.RgbaPixels[offset + 2] = rgba.B; + _centerStorage.RgbaPixels[offset + 3] = rgba.A; + } + } + + private void ChannelMergePixelsMultiplied(int y, ParallelLoopState _) + { + var channels = _copyChannels; + for (var x = 0; x < _leftPixels.Width; ++x) + { + var offset = (_leftPixels.Width * y + x) * 4; + var left = DataLeft(offset); + var right = DataRight(x, y); + var rgba = new Rgba32((channels & Channels.Red) != 0 ? right.X : left.X, + (channels & Channels.Green) != 0 ? right.Y : left.Y, + (channels & Channels.Blue) != 0 ? right.Z : left.Z, + (channels & Channels.Alpha) != 0 ? right.W : left.W); + _centerStorage.RgbaPixels[offset] = rgba.R; + _centerStorage.RgbaPixels[offset + 1] = rgba.G; + _centerStorage.RgbaPixels[offset + 2] = rgba.B; + _centerStorage.RgbaPixels[offset + 3] = rgba.A; + } + } + + private void MultiplyPixelsLeft(int y, ParallelLoopState _) + { + for (var x = 0; x < _leftPixels.Width; ++x) + { + var offset = (_leftPixels.Width * y + x) * 4; + var left = DataLeft(offset); + var rgba = new Rgba32(left); + _centerStorage.RgbaPixels[offset] = rgba.R; + _centerStorage.RgbaPixels[offset + 1] = rgba.G; + _centerStorage.RgbaPixels[offset + 2] = rgba.B; + _centerStorage.RgbaPixels[offset + 3] = rgba.A; + } + } + + private void MultiplyPixelsRight(int y, ParallelLoopState _) + { + for (var x = 0; x < _rightPixels.Width; ++x) + { + var offset = (_rightPixels.Width * y + x) * 4; + var right = DataRight(offset); + var rgba = new Rgba32(right); + _centerStorage.RgbaPixels[offset] = rgba.R; + _centerStorage.RgbaPixels[offset + 1] = rgba.G; + _centerStorage.RgbaPixels[offset + 2] = rgba.B; + _centerStorage.RgbaPixels[offset + 3] = rgba.A; + } + } + + private (int Width, int Height) CombineImage() + { + var combineOp = GetActualCombineOp(); + var resizeOp = GetActualResizeOp(_resizeOp, combineOp); + + var left = resizeOp != ResizeOp.RightOnly ? RgbaPixelData.FromTexture(_left) : RgbaPixelData.Empty; + var right = resizeOp != ResizeOp.LeftOnly ? RgbaPixelData.FromTexture(_right) : RgbaPixelData.Empty; + + var targetSize = resizeOp switch + { + ResizeOp.RightOnly => right.Size, + ResizeOp.ToRight => right.Size, + _ => left.Size, + }; + + try + { + _centerStorage.RgbaPixels = RgbaPixelData.NewPixelData(targetSize); + _centerStorage.Type = TextureType.Bitmap; + + _leftPixels = resizeOp switch + { + ResizeOp.RightOnly => RgbaPixelData.Empty, + _ => left.Resize(targetSize), + }; + _rightPixels = resizeOp switch + { + ResizeOp.LeftOnly => RgbaPixelData.Empty, + ResizeOp.None => right, + _ => right.Resize(targetSize), + }; + + Parallel.For(0, targetSize.Height, combineOp switch + { + CombineOp.Over => AddPixelsMultiplied, + CombineOp.Under => ReverseAddPixelsMultiplied, + CombineOp.LeftMultiply => MultiplyPixelsLeft, + CombineOp.RightMultiply => MultiplyPixelsRight, + CombineOp.CopyChannels => ChannelMergePixelsMultiplied, + _ => throw new InvalidOperationException($"Cannot combine images with operation {combineOp}"), + }); + } + finally + { + _leftPixels = RgbaPixelData.Empty; + _rightPixels = RgbaPixelData.Empty; + } + + return targetSize; + } + + private static Vector4 CappedVector(IReadOnlyList bytes, int offset, Matrix4x4 transform, Vector4 constant) + { + if (bytes.Count == 0) + return Vector4.Zero; + + var rgba = new Rgba32(bytes[offset], bytes[offset + 1], bytes[offset + 2], bytes[offset + 3]); + var transformed = Vector4.Transform(rgba.ToVector4(), transform) + constant; + + transformed.X = Math.Clamp(transformed.X, 0, 1); + transformed.Y = Math.Clamp(transformed.Y, 0, 1); + transformed.Z = Math.Clamp(transformed.Z, 0, 1); + transformed.W = Math.Clamp(transformed.W, 0, 1); + return transformed; + } + + private static bool DragFloat(string label, float width, ref float value) + { + var tmp = value; + ImGui.TableNextColumn(); + ImGui.SetNextItemWidth(width); + if (ImGui.DragFloat(label, ref tmp, 0.001f, -1f, 1f)) + value = tmp; + + return ImGui.IsItemDeactivatedAfterEdit(); + } + + public void DrawMatrixInputLeft(float width) + { + var ret = DrawMatrixInput(ref _multiplierLeft, ref _constantLeft, width); + ret |= DrawMatrixTools(ref _multiplierLeft, ref _constantLeft); + if (ret) + Update(); + } + + public void DrawMatrixInputRight(float width) + { + var ret = DrawMatrixInput(ref _multiplierRight, ref _constantRight, width); + ret |= DrawMatrixTools(ref _multiplierRight, ref _constantRight); + + ImGui.SetNextItemWidth(75.0f * UiHelpers.Scale); + ImGui.DragInt("##XOffset", ref _offsetX, 0.5f); + ret |= ImGui.IsItemDeactivatedAfterEdit(); + ImGui.SameLine(); + ImGui.SetNextItemWidth(75.0f * UiHelpers.Scale); + ImGui.DragInt("Offsets##YOffset", ref _offsetY, 0.5f); + ret |= ImGui.IsItemDeactivatedAfterEdit(); + + ImGui.SetNextItemWidth(200.0f * UiHelpers.Scale); + using (var c = ImRaii.Combo("Combine Operation", CombineOpLabels[(int)_combineOp])) + { + if (c) + foreach (var op in Enum.GetValues()) + { + if ((int)op < 0) // Negative codes are for internal use only. + continue; + + if (ImGui.Selectable(CombineOpLabels[(int)op], op == _combineOp)) + { + _combineOp = op; + ret = true; + } + + ImGuiUtil.SelectableHelpMarker(CombineOpTooltips[(int)op]); + } + } + + var resizeOp = GetActualResizeOp(_resizeOp, _combineOp); + using (var dis = ImRaii.Disabled((int)resizeOp < 0)) + { + ret |= ImGuiUtil.GenericEnumCombo("Resizing Mode", 200.0f * UiHelpers.Scale, _resizeOp, out _resizeOp, + Enum.GetValues().Where(op => (int)op >= 0), op => ResizeOpLabels[(int)op]); + } + + using (var dis = ImRaii.Disabled(_combineOp != CombineOp.CopyChannels)) + { + ImGui.TextUnformatted("Copy"); + foreach (var channel in Enum.GetValues()) + { + ImGui.SameLine(); + var copy = (_copyChannels & channel) != 0; + if (ImGui.Checkbox(channel.ToString(), ref copy)) + { + _copyChannels = copy ? _copyChannels | channel : _copyChannels & ~channel; + ret = true; + } + } + } + + if (ret) + Update(); + } + + private static bool DrawMatrixInput(ref Matrix4x4 multiplier, ref Vector4 constant, float width) + { + using var table = ImRaii.Table(string.Empty, 5, ImGuiTableFlags.BordersInner | ImGuiTableFlags.SizingFixedFit); + if (!table) + return false; + + var changes = false; + + ImGui.TableNextColumn(); + ImGui.TableNextColumn(); + ImGuiUtil.Center("R"); + ImGui.TableNextColumn(); + ImGuiUtil.Center("G"); + ImGui.TableNextColumn(); + ImGuiUtil.Center("B"); + ImGui.TableNextColumn(); + ImGuiUtil.Center("A"); + + var inputWidth = width / 6; + ImGui.TableNextColumn(); + ImGui.AlignTextToFramePadding(); + ImGui.Text("R "); + changes |= DragFloat("##RR", inputWidth, ref multiplier.M11); + changes |= DragFloat("##RG", inputWidth, ref multiplier.M12); + changes |= DragFloat("##RB", inputWidth, ref multiplier.M13); + changes |= DragFloat("##RA", inputWidth, ref multiplier.M14); + + ImGui.TableNextColumn(); + ImGui.AlignTextToFramePadding(); + ImGui.Text("G "); + changes |= DragFloat("##GR", inputWidth, ref multiplier.M21); + changes |= DragFloat("##GG", inputWidth, ref multiplier.M22); + changes |= DragFloat("##GB", inputWidth, ref multiplier.M23); + changes |= DragFloat("##GA", inputWidth, ref multiplier.M24); + + ImGui.TableNextColumn(); + ImGui.AlignTextToFramePadding(); + ImGui.Text("B "); + changes |= DragFloat("##BR", inputWidth, ref multiplier.M31); + changes |= DragFloat("##BG", inputWidth, ref multiplier.M32); + changes |= DragFloat("##BB", inputWidth, ref multiplier.M33); + changes |= DragFloat("##BA", inputWidth, ref multiplier.M34); + + ImGui.TableNextColumn(); + ImGui.AlignTextToFramePadding(); + ImGui.Text("A "); + changes |= DragFloat("##AR", inputWidth, ref multiplier.M41); + changes |= DragFloat("##AG", inputWidth, ref multiplier.M42); + changes |= DragFloat("##AB", inputWidth, ref multiplier.M43); + changes |= DragFloat("##AA", inputWidth, ref multiplier.M44); + + ImGui.TableNextColumn(); + ImGui.AlignTextToFramePadding(); + ImGui.Text("1 "); + changes |= DragFloat("##1R", inputWidth, ref constant.X); + changes |= DragFloat("##1G", inputWidth, ref constant.Y); + changes |= DragFloat("##1B", inputWidth, ref constant.Z); + changes |= DragFloat("##1A", inputWidth, ref constant.W); + + return changes; + } + + private static bool DrawMatrixTools(ref Matrix4x4 multiplier, ref Vector4 constant) + { + var changes = PresetCombo(ref multiplier, ref constant); + ImGui.SameLine(); + ImGui.Dummy(ImGuiHelpers.ScaledVector2(20, 0)); + ImGui.SameLine(); + ImGui.TextUnformatted("Invert"); + ImGui.SameLine(); + + Channels channels = 0; + if (ImGui.Button("Colors")) + channels |= Channels.Red | Channels.Green | Channels.Blue; + ImGui.SameLine(); + if (ImGui.Button("R")) + channels |= Channels.Red; + + ImGui.SameLine(); + if (ImGui.Button("G")) + channels |= Channels.Green; + + ImGui.SameLine(); + if (ImGui.Button("B")) + channels |= Channels.Blue; + + ImGui.SameLine(); + if (ImGui.Button("A")) + channels |= Channels.Alpha; + + changes |= InvertChannels(channels, ref multiplier, ref constant); + return changes; + } + + private static bool PresetCombo(ref Matrix4x4 multiplier, ref Vector4 constant) + { + using var combo = ImRaii.Combo("Presets", string.Empty, ImGuiComboFlags.NoPreview); + if (!combo) + return false; + + var ret = false; + foreach (var (label, preMultiplier, preConstant) in PredefinedColorTransforms) + { + if (!ImGui.Selectable(label, multiplier == preMultiplier && constant == preConstant)) + continue; + + multiplier = preMultiplier; + constant = preConstant; + ret = true; + } + + return ret; + } +} diff --git a/Penumbra/Import/Textures/CombinedTexture.Operations.cs b/Penumbra/Import/Textures/CombinedTexture.Operations.cs new file mode 100644 index 00000000..8494f12b --- /dev/null +++ b/Penumbra/Import/Textures/CombinedTexture.Operations.cs @@ -0,0 +1,146 @@ +namespace Penumbra.Import.Textures; + +public partial class CombinedTexture +{ + private enum CombineOp + { + LeftMultiply = -4, + LeftCopy = -3, + RightCopy = -2, + Invalid = -1, + Over = 0, + Under = 1, + RightMultiply = 2, + CopyChannels = 3, + } + + private enum ResizeOp + { + LeftOnly = -2, + RightOnly = -1, + None = 0, + ToLeft = 1, + ToRight = 2, + } + + [Flags] + private enum Channels : byte + { + Red = 1, + Green = 2, + Blue = 4, + Alpha = 8, + } + + private static readonly IReadOnlyList CombineOpLabels = new[] + { + "Overlay over Input", + "Input over Overlay", + "Replace Input", + "Copy Channels", + }; + + private static readonly IReadOnlyList CombineOpTooltips = new[] + { + "Standard composition.\nApply the overlay over the input.", + "Standard composition, reversed.\nApply the input over the overlay ; can be used to fix some wrong imports.", + "Completely replace the input with the overlay.\nCan be used to select the destination file as input and the source file as overlay.", + "Replace some input channels with those from the overlay.\nUseful for Multi maps.", + }; + + private static readonly IReadOnlyList ResizeOpLabels = new string[] + { + "No Resizing", + "Adjust Overlay to Input", + "Adjust Input to Overlay", + }; + + private static ResizeOp GetActualResizeOp(ResizeOp resizeOp, CombineOp combineOp) + => combineOp switch + { + CombineOp.LeftCopy => ResizeOp.LeftOnly, + CombineOp.LeftMultiply => ResizeOp.LeftOnly, + CombineOp.RightCopy => ResizeOp.RightOnly, + CombineOp.RightMultiply => ResizeOp.RightOnly, + CombineOp.Over => resizeOp, + CombineOp.Under => resizeOp, + CombineOp.CopyChannels => resizeOp, + _ => throw new ArgumentException($"Invalid combine operation {combineOp}"), + }; + + private CombineOp GetActualCombineOp() + { + var combineOp = (_left.IsLoaded, _right.IsLoaded) switch + { + (true, true) => _combineOp, + (true, false) => CombineOp.LeftMultiply, + (false, true) => CombineOp.RightMultiply, + (false, false) => CombineOp.Invalid, + }; + + if (combineOp == CombineOp.CopyChannels) + { + if (_copyChannels == 0) + combineOp = CombineOp.LeftMultiply; + else if (_copyChannels == (Channels.Red | Channels.Green | Channels.Blue | Channels.Alpha)) + combineOp = CombineOp.RightMultiply; + } + + return combineOp switch + { + CombineOp.LeftMultiply when _multiplierLeft.IsIdentity && _constantLeft == Vector4.Zero => CombineOp.LeftCopy, + CombineOp.RightMultiply when _multiplierRight.IsIdentity && _constantRight == Vector4.Zero => CombineOp.RightCopy, + _ => combineOp, + }; + } + + + private static bool InvertChannels(Channels channels, ref Matrix4x4 multiplier, ref Vector4 constant) + { + if (channels.HasFlag(Channels.Red)) + InvertRed(ref multiplier, ref constant); + if (channels.HasFlag(Channels.Green)) + InvertGreen(ref multiplier, ref constant); + if (channels.HasFlag(Channels.Blue)) + InvertBlue(ref multiplier, ref constant); + if (channels.HasFlag(Channels.Alpha)) + InvertAlpha(ref multiplier, ref constant); + return channels != 0; + } + + private static void InvertRed(ref Matrix4x4 multiplier, ref Vector4 constant) + { + multiplier.M11 = -multiplier.M11; + multiplier.M21 = -multiplier.M21; + multiplier.M31 = -multiplier.M31; + multiplier.M41 = -multiplier.M41; + constant.X = 1.0f - constant.X; + } + + private static void InvertGreen(ref Matrix4x4 multiplier, ref Vector4 constant) + { + multiplier.M12 = -multiplier.M12; + multiplier.M22 = -multiplier.M22; + multiplier.M32 = -multiplier.M32; + multiplier.M42 = -multiplier.M42; + constant.Y = 1.0f - constant.Y; + } + + private static void InvertBlue(ref Matrix4x4 multiplier, ref Vector4 constant) + { + multiplier.M13 = -multiplier.M13; + multiplier.M23 = -multiplier.M23; + multiplier.M33 = -multiplier.M33; + multiplier.M43 = -multiplier.M43; + constant.Z = 1.0f - constant.Z; + } + + private static void InvertAlpha(ref Matrix4x4 multiplier, ref Vector4 constant) + { + multiplier.M14 = -multiplier.M14; + multiplier.M24 = -multiplier.M24; + multiplier.M34 = -multiplier.M34; + multiplier.M44 = -multiplier.M44; + constant.W = 1.0f - constant.W; + } +} diff --git a/Penumbra/Import/Textures/CombinedTexture.cs b/Penumbra/Import/Textures/CombinedTexture.cs new file mode 100644 index 00000000..f5f921be --- /dev/null +++ b/Penumbra/Import/Textures/CombinedTexture.cs @@ -0,0 +1,164 @@ +namespace Penumbra.Import.Textures; + +public partial class CombinedTexture : IDisposable +{ + public enum TextureSaveType + { + AsIs, + Bitmap, + BC1, + BC3, + BC4, + BC5, + BC7, + } + + private enum Mode + { + Empty, + LeftCopy, + RightCopy, + Custom, + } + + private readonly Texture _left; + private readonly Texture _right; + + private Texture? _current; + private Mode _mode = Mode.Empty; + + private readonly Texture _centerStorage = new(); + + public Task SaveTask { get; private set; } = Task.CompletedTask; + + public bool IsLoaded + => _mode != Mode.Empty; + + public bool IsLeftCopy + => _mode == Mode.LeftCopy; + + public void Draw(TextureManager textures, Vector2 size) + { + if (_mode == Mode.Custom && !_centerStorage.IsLoaded) + { + var (width, height) = CombineImage(); + _centerStorage.TextureWrap = textures.LoadTextureWrap(_centerStorage.RgbaPixels, width, height); + } + + if (_current != null) + TextureDrawer.Draw(_current, size); + } + + + public void SaveAsPng(TextureManager textures, string path) + { + if (!IsLoaded || _current == null) + return; + + SaveTask = textures.SavePng(_current.BaseImage, path, _current.RgbaPixels, _current.TextureWrap!.Width, _current.TextureWrap!.Height); + } + + public void SaveAsTarga(TextureManager textures, string path) + { + if (!IsLoaded || _current == null) + return; + + SaveTask = textures.SaveTga(_current.BaseImage, path, _current.RgbaPixels, _current.TextureWrap!.Width, _current.TextureWrap!.Height); + } + + private void SaveAs(TextureManager textures, string path, TextureSaveType type, bool mipMaps, bool writeTex) + { + if (!IsLoaded || _current == null) + return; + + SaveTask = textures.SaveAs(type, mipMaps, writeTex, _current.BaseImage, path, _current.RgbaPixels, _current.TextureWrap!.Width, + _current.TextureWrap!.Height); + } + + public void SaveAs(TextureType? texType, TextureManager textures, string path, TextureSaveType type, bool mipMaps) + { + var finalTexType = texType + ?? Path.GetExtension(path).ToLowerInvariant() switch + { + ".tex" => TextureType.Tex, + ".dds" => TextureType.Dds, + ".png" => TextureType.Png, + ".tga" => TextureType.Targa, + _ => TextureType.Unknown, + }; + + switch (finalTexType) + { + case TextureType.Tex: + SaveAsTex(textures, path, type, mipMaps); + break; + case TextureType.Dds: + SaveAsDds(textures, path, type, mipMaps); + break; + case TextureType.Png: + SaveAsPng(textures, path); + break; + case TextureType.Targa: + SaveAsTarga(textures, path); + break; + default: + throw new ArgumentException( + $"Cannot save texture as TextureType {finalTexType} with extension {Path.GetExtension(path).ToLowerInvariant()}"); + } + } + + public void SaveAsTex(TextureManager textures, string path, TextureSaveType type, bool mipMaps) + => SaveAs(textures, path, type, mipMaps, true); + + public void SaveAsDds(TextureManager textures, string path, TextureSaveType type, bool mipMaps) + => SaveAs(textures, path, type, mipMaps, false); + + + public CombinedTexture(Texture left, Texture right) + { + _left = left; + _right = right; + _left.Loaded += OnLoaded; + _right.Loaded += OnLoaded; + OnLoaded(false); + } + + public void Dispose() + { + Clean(); + _left.Loaded -= OnLoaded; + _right.Loaded -= OnLoaded; + } + + private void OnLoaded(bool _) + => Update(); + + public void Update() + { + Clean(); + switch (GetActualCombineOp()) + { + case CombineOp.Invalid: break; + case CombineOp.LeftCopy: + _mode = Mode.LeftCopy; + _current = _left; + break; + case CombineOp.RightCopy: + _mode = Mode.RightCopy; + _current = _right; + break; + default: + _mode = Mode.Custom; + _current = _centerStorage; + break; + } + } + + private void Clean() + { + _centerStorage.Dispose(); + _current = null; + SaveTask = Task.CompletedTask; + _mode = Mode.Empty; + } +} diff --git a/Penumbra/Import/Textures/RgbaPixelData.cs b/Penumbra/Import/Textures/RgbaPixelData.cs new file mode 100644 index 00000000..7c28b72b --- /dev/null +++ b/Penumbra/Import/Textures/RgbaPixelData.cs @@ -0,0 +1,41 @@ +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.PixelFormats; +using SixLabors.ImageSharp.Processing; + +namespace Penumbra.Import.Textures; + +public readonly record struct RgbaPixelData(int Width, int Height, byte[] PixelData) +{ + public static readonly RgbaPixelData Empty = new(0, 0, Array.Empty()); + + public (int Width, int Height) Size + => (Width, Height); + + public RgbaPixelData((int Width, int Height) size, byte[] pixelData) + : this(size.Width, size.Height, pixelData) + { } + + public Image ToImage() + => Image.LoadPixelData(PixelData, Width, Height); + + public RgbaPixelData Resize((int Width, int Height) size) + { + if (Width == size.Width && Height == size.Height) + return this; + + var result = new RgbaPixelData(size, NewPixelData(size)); + using (var image = ToImage()) + { + image.Mutate(ctx => ctx.Resize(size.Width, size.Height, KnownResamplers.Lanczos3)); + image.CopyPixelDataTo(result.PixelData); + } + + return result; + } + + public static byte[] NewPixelData((int Width, int Height) size) + => new byte[size.Width * size.Height * 4]; + + public static RgbaPixelData FromTexture(Texture texture) + => new(texture.TextureWrap!.Width, texture.TextureWrap!.Height, texture.RgbaPixels); +} diff --git a/Penumbra/Import/Textures/TexFileParser.cs b/Penumbra/Import/Textures/TexFileParser.cs new file mode 100644 index 00000000..04bbf5d8 --- /dev/null +++ b/Penumbra/Import/Textures/TexFileParser.cs @@ -0,0 +1,287 @@ +using Lumina.Data.Files; +using Lumina.Extensions; +using OtterTex; + +namespace Penumbra.Import.Textures; + +public static class TexFileParser +{ + public static ScratchImage Parse(Stream data) + { + using var r = new BinaryReader(data); + var header = r.ReadStructure(); + + var meta = header.ToTexMeta(); + if (meta.Format == DXGIFormat.Unknown) + throw new Exception($"Could not convert format {header.Format} to DXGI Format."); + + if (meta.Dimension == TexDimension.Unknown) + throw new Exception($"Could not obtain dimensionality from {header.Type}."); + + meta.MipLevels = CountMipLevels(data, in meta, in header); + if (meta.MipLevels == 0) + throw new Exception("Could not load file. Image is corrupted and does not contain enough data for its size."); + + var scratch = ScratchImage.Initialize(meta); + + CopyData(scratch, r); + + return scratch; + } + + private static unsafe int CountMipLevels(Stream data, in TexMeta meta, in TexFile.TexHeader header) + { + var width = meta.Width; + var height = meta.Height; + var bits = meta.Format.BitsPerPixel(); + + var lastOffset = 0L; + var lastSize = 80L; + var minSize = meta.Format.IsCompressed() ? 4 : 1; + for (var i = 0; i < 13; ++i) + { + var offset = header.OffsetToSurface[i]; + if (offset == 0) + return i; + + var Size = width * height * bits / 8; + if (offset + Size > data.Length) + return i; + + var diff = offset - lastOffset; + if (diff != lastSize) + return i; + + width = Math.Max(width / 2, minSize); + height = Math.Max(height / 2, minSize); + lastOffset = offset; + lastSize = Size; + } + + return 13; + } + + public static unsafe void FixMipOffsets(long size, ref TexFile.TexHeader header, out long newSize) + { + var width = (uint)header.Width; + var height = (uint)header.Height; + var format = header.Format.ToDXGI(); + var bits = format.BitsPerPixel(); + var totalSize = 80u; + size -= totalSize; + var minSize = format.IsCompressed() ? 4u : 1u; + for (var i = 0; i < 13; ++i) + { + var requiredSize = (uint)((long)width * height * bits / 8); + if (requiredSize > size) + { + newSize = totalSize; + if (header.MipCount != i) + { + Penumbra.Log.Debug( + $"-- Mip Map Count in TEX header was {header.MipCount}, but file only contains data for {i} Mip Maps, fixed."); + FixLodOffsets(ref header, i); + } + + return; + } + + if (header.OffsetToSurface[i] != totalSize) + { + Penumbra.Log.Debug( + $"-- Mip Map Offset {i + 1} in TEX header was {header.OffsetToSurface[i]} but should be {totalSize}, fixed."); + header.OffsetToSurface[i] = totalSize; + } + + if (width == minSize && height == minSize) + { + ++i; + newSize = totalSize + requiredSize; + if (header.MipCount != i) + { + Penumbra.Log.Debug($"-- Reduced number of Mip Maps from {header.MipCount} to {i} due to minimum size constraints."); + FixLodOffsets(ref header, i); + } + + return; + } + + totalSize += requiredSize; + size -= requiredSize; + width = Math.Max(width / 2, minSize); + height = Math.Max(height / 2, minSize); + } + + newSize = totalSize; + if (header.MipCount != 13) + { + Penumbra.Log.Debug($"-- Mip Map Count in TEX header was {header.MipCount}, but maximum is 13, fixed."); + FixLodOffsets(ref header, 13); + } + + void FixLodOffsets(ref TexFile.TexHeader header, int index) + { + header.MipCount = index; + if (header.LodOffset[2] >= header.MipCount) + header.LodOffset[2] = (byte)(header.MipCount - 1); + if (header.LodOffset[1] >= header.MipCount) + header.LodOffset[1] = header.MipCount > 2 ? (byte)(header.MipCount - 2) : (byte)(header.MipCount - 1); + for (++index; index < 13; ++index) + header.OffsetToSurface[index] = 0; + } + } + + private static unsafe void CopyData(ScratchImage image, BinaryReader r) + { + fixed (byte* ptr = image.Pixels) + { + var span = new Span(ptr, image.Pixels.Length); + var readBytes = r.Read(span); + if (readBytes < image.Pixels.Length) + throw new Exception($"Invalid data length {readBytes} < {image.Pixels.Length}."); + } + } + + public static void Write(this TexFile.TexHeader header, BinaryWriter w) + { + w.Write((uint)header.Type); + w.Write((uint)header.Format); + w.Write(header.Width); + w.Write(header.Height); + w.Write(header.Depth); + w.Write((byte)(header.MipCount | (header.MipUnknownFlag ? 0x80 : 0))); + w.Write(header.ArraySize); + unsafe + { + w.Write(header.LodOffset[0]); + w.Write(header.LodOffset[1]); + w.Write(header.LodOffset[2]); + for (var i = 0; i < 13; ++i) + w.Write(header.OffsetToSurface[i]); + } + } + + public static TexFile.TexHeader ToTexHeader(this ScratchImage scratch) + { + var meta = scratch.Meta; + var ret = new TexFile.TexHeader() + { + Height = (ushort)meta.Height, + Width = (ushort)meta.Width, + Depth = (ushort)Math.Max(meta.Depth, 1), + MipCount = (byte)Math.Min(meta.MipLevels, 13), + Format = meta.Format.ToTexFormat(), + Type = meta.Dimension switch + { + _ when meta.IsCubeMap => TexFile.Attribute.TextureTypeCube, + TexDimension.Tex1D => TexFile.Attribute.TextureType1D, + TexDimension.Tex2D => TexFile.Attribute.TextureType2D, + TexDimension.Tex3D => TexFile.Attribute.TextureType3D, + _ => 0, + }, + }; + + ret.FillSurfaceOffsets(scratch); + + return ret; + } + + private static unsafe void FillSurfaceOffsets(this ref TexFile.TexHeader header, ScratchImage scratch) + { + var idx = 0; + fixed (byte* ptr = scratch.Pixels) + { + foreach (var image in scratch.Images) + { + var offset = (byte*)image.Pixels - ptr; + header.OffsetToSurface[idx++] = (uint)(80 + offset); + } + } + + for (; idx < 13; ++idx) + header.OffsetToSurface[idx] = 0; + + header.LodOffset[0] = 0; + header.LodOffset[1] = 1; + header.LodOffset[2] = 2; + } + + + public static TexMeta ToTexMeta(this TexFile.TexHeader header) + => new() + { + Height = header.Height, + Width = header.Width, + Depth = Math.Max(header.Depth, (ushort)1), + MipLevels = header.MipCount, + ArraySize = 1, + Format = header.Format.ToDXGI(), + Dimension = header.Type.ToDimension(), + MiscFlags = header.Type.HasFlag(TexFile.Attribute.TextureTypeCube) ? D3DResourceMiscFlags.TextureCube : 0, + MiscFlags2 = 0, + }; + + private static TexDimension ToDimension(this TexFile.Attribute attribute) + => (attribute & TexFile.Attribute.TextureTypeMask) switch + { + TexFile.Attribute.TextureType1D => TexDimension.Tex1D, + TexFile.Attribute.TextureType2D => TexDimension.Tex2D, + TexFile.Attribute.TextureType3D => TexDimension.Tex3D, + _ => TexDimension.Unknown, + }; + + public static TexFile.TextureFormat ToTexFormat(this DXGIFormat format) + => format switch + { + DXGIFormat.R8UNorm => TexFile.TextureFormat.L8, + DXGIFormat.A8UNorm => TexFile.TextureFormat.A8, + DXGIFormat.B4G4R4A4UNorm => TexFile.TextureFormat.B4G4R4A4, + DXGIFormat.B5G5R5A1UNorm => TexFile.TextureFormat.B5G5R5A1, + DXGIFormat.B8G8R8A8UNorm => TexFile.TextureFormat.B8G8R8A8, + DXGIFormat.B8G8R8X8UNorm => TexFile.TextureFormat.B8G8R8X8, + DXGIFormat.R32Float => TexFile.TextureFormat.R32F, + DXGIFormat.R16G16Float => TexFile.TextureFormat.R16G16F, + DXGIFormat.R32G32Float => TexFile.TextureFormat.R32G32F, + DXGIFormat.R16G16B16A16Float => TexFile.TextureFormat.R16G16B16A16F, + DXGIFormat.R32G32B32A32Float => TexFile.TextureFormat.R32G32B32A32F, + DXGIFormat.BC1UNorm => TexFile.TextureFormat.BC1, + DXGIFormat.BC2UNorm => TexFile.TextureFormat.BC2, + DXGIFormat.BC3UNorm => TexFile.TextureFormat.BC3, + DXGIFormat.BC4UNorm => (TexFile.TextureFormat)0x6120, // TODO: upstream to Lumina + DXGIFormat.BC5UNorm => TexFile.TextureFormat.BC5, + DXGIFormat.BC6HSF16 => (TexFile.TextureFormat)0x6330, // TODO: upstream to Lumina + DXGIFormat.BC7UNorm => TexFile.TextureFormat.BC7, + DXGIFormat.R16G16B16A16Typeless => TexFile.TextureFormat.D16, + DXGIFormat.R24G8Typeless => TexFile.TextureFormat.D24S8, + DXGIFormat.R16Typeless => TexFile.TextureFormat.Shadow16, + _ => TexFile.TextureFormat.Unknown, + }; + + public static DXGIFormat ToDXGI(this TexFile.TextureFormat format) + => format switch + { + TexFile.TextureFormat.L8 => DXGIFormat.R8UNorm, + TexFile.TextureFormat.A8 => DXGIFormat.A8UNorm, + TexFile.TextureFormat.B4G4R4A4 => DXGIFormat.B4G4R4A4UNorm, + TexFile.TextureFormat.B5G5R5A1 => DXGIFormat.B5G5R5A1UNorm, + TexFile.TextureFormat.B8G8R8A8 => DXGIFormat.B8G8R8A8UNorm, + TexFile.TextureFormat.B8G8R8X8 => DXGIFormat.B8G8R8X8UNorm, + TexFile.TextureFormat.R32F => DXGIFormat.R32Float, + TexFile.TextureFormat.R16G16F => DXGIFormat.R16G16Float, + TexFile.TextureFormat.R32G32F => DXGIFormat.R32G32Float, + TexFile.TextureFormat.R16G16B16A16F => DXGIFormat.R16G16B16A16Float, + TexFile.TextureFormat.R32G32B32A32F => DXGIFormat.R32G32B32A32Float, + TexFile.TextureFormat.BC1 => DXGIFormat.BC1UNorm, + TexFile.TextureFormat.BC2 => DXGIFormat.BC2UNorm, + TexFile.TextureFormat.BC3 => DXGIFormat.BC3UNorm, + (TexFile.TextureFormat)0x6120 => DXGIFormat.BC4UNorm, // TODO: upstream to Lumina + TexFile.TextureFormat.BC5 => DXGIFormat.BC5UNorm, + (TexFile.TextureFormat)0x6330 => DXGIFormat.BC6HSF16, // TODO: upstream to Lumina + TexFile.TextureFormat.BC7 => DXGIFormat.BC7UNorm, + TexFile.TextureFormat.D16 => DXGIFormat.R16G16B16A16Typeless, + TexFile.TextureFormat.D24S8 => DXGIFormat.R24G8Typeless, + TexFile.TextureFormat.Shadow16 => DXGIFormat.R16Typeless, + TexFile.TextureFormat.Shadow24 => DXGIFormat.R24G8Typeless, + _ => DXGIFormat.Unknown, + }; +} diff --git a/Penumbra/Import/Textures/Texture.cs b/Penumbra/Import/Textures/Texture.cs new file mode 100644 index 00000000..ae0aabd9 --- /dev/null +++ b/Penumbra/Import/Textures/Texture.cs @@ -0,0 +1,112 @@ +using Dalamud.Interface.Textures.TextureWraps; +using OtterTex; + +namespace Penumbra.Import.Textures; + +public enum TextureType +{ + Unknown, + Dds, + Tex, + Png, + Bitmap, + Targa, +} + +internal static class TextureTypeExtensions +{ + public static TextureType ReduceToBehaviour(this TextureType type) + => type switch + { + TextureType.Dds => TextureType.Dds, + TextureType.Tex => TextureType.Tex, + TextureType.Png => TextureType.Png, + TextureType.Bitmap => TextureType.Png, + TextureType.Targa => TextureType.Png, + _ => TextureType.Unknown, + }; +} + +public sealed class Texture : IDisposable +{ + // Path to the file we tried to load. + public string Path = string.Empty; + + // Path for changing paths. + internal string? TmpPath; + + // If the load failed, an exception is stored. + public Exception? LoadError; + + // The pixels of the main image in RGBA order. + // Empty if LoadError != null or Path is empty. + public byte[] RgbaPixels = Array.Empty(); + + // The ImGui wrapper to load the image. + // null if LoadError != null or Path is empty. + public IDalamudTextureWrap? TextureWrap; + + // The base image in whatever format it has. + public BaseImage BaseImage; + + // Original File Type. + public TextureType Type = TextureType.Unknown; + + // Whether the file is successfully loaded and drawable. + public bool IsLoaded + => TextureWrap != null; + + public DXGIFormat Format + => BaseImage.Format; + + public int MipMaps + => BaseImage.MipMaps; + + private void Clean() + { + RgbaPixels = Array.Empty(); + TextureWrap?.Dispose(); + TextureWrap = null; + BaseImage.Dispose(); + BaseImage = new BaseImage(); + Type = TextureType.Unknown; + Loaded?.Invoke(false); + } + + public void Dispose() + => Clean(); + + public event Action? Loaded; + + public void Load(TextureManager textures, string path) + { + TmpPath = null; + if (path == Path) + return; + + Path = path; + Clean(); + if (path.Length == 0) + return; + + try + { + (BaseImage, Type) = textures.Load(path); + (RgbaPixels, _, _) = BaseImage.GetPixelData(); + TextureWrap = textures.LoadTextureWrap(BaseImage, RgbaPixels); + Loaded?.Invoke(true); + } + catch (Exception e) + { + LoadError = e; + Clean(); + } + } + + public void Reload(TextureManager textures) + { + var path = Path; + Path = string.Empty; + Load(textures, path); + } +} diff --git a/Penumbra/Import/Textures/TextureDrawer.cs b/Penumbra/Import/Textures/TextureDrawer.cs new file mode 100644 index 00000000..14203dff --- /dev/null +++ b/Penumbra/Import/Textures/TextureDrawer.cs @@ -0,0 +1,166 @@ +using Dalamud.Bindings.ImGui; +using Dalamud.Interface; +using Lumina.Data.Files; +using OtterGui; +using OtterGui.Raii; +using OtterGui.Widgets; +using OtterTex; +using Penumbra.Mods.Editor; +using Penumbra.String.Classes; +using Penumbra.UI; +using Penumbra.UI.Classes; + +namespace Penumbra.Import.Textures; + +public static class TextureDrawer +{ + public static void Draw(Texture texture, Vector2 size) + { + if (texture.TextureWrap != null) + { + size = texture.TextureWrap.Size.Contain(size); + + ImGui.Image(texture.TextureWrap.Handle, size); + DrawData(texture); + } + else if (texture.LoadError != null) + { + const string link = "https://aka.ms/vcredist"; + ImGui.TextUnformatted("Could not load file:"); + + if (texture.LoadError is DllNotFoundException) + { + ImGuiUtil.TextColored(Colors.RegexWarningBorder, + "A texture handling dependency could not be found. Try installing a current Microsoft VC Redistributable."); + if (ImGui.Button("Microsoft VC Redistributables")) + Dalamud.Utility.Util.OpenLink(link); + ImGuiUtil.HoverTooltip($"Open {link} in your browser."); + } + + ImGuiUtil.TextColored(Colors.RegexWarningBorder, texture.LoadError.ToString()); + } + } + + public static void PathInputBox(TextureManager textures, Texture current, ref string? tmpPath, string label, string hint, string tooltip, + string startPath, FileDialogService fileDialog, string defaultModImportPath) + { + tmpPath ??= current.Path; + using var spacing = ImRaii.PushStyle(ImGuiStyleVar.ItemSpacing, + new Vector2(UiHelpers.ScaleX3, ImGui.GetStyle().ItemSpacing.Y)); + ImGui.SetNextItemWidth(-2 * ImGui.GetFrameHeight() - 7 * UiHelpers.Scale); + ImGui.InputTextWithHint(label, hint, ref tmpPath, Utf8GamePath.MaxGamePathLength); + if (ImGui.IsItemDeactivatedAfterEdit()) + current.Load(textures, tmpPath); + + ImGuiUtil.HoverTooltip(tooltip); + ImGui.SameLine(); + if (ImGuiUtil.DrawDisabledButton(FontAwesomeIcon.Folder.ToIconString(), new Vector2(ImGui.GetFrameHeight()), string.Empty, false, + true)) + { + if (defaultModImportPath.Length > 0) + startPath = defaultModImportPath; + + void UpdatePath(bool success, List paths) + { + if (success && paths.Count > 0) + current.Load(textures, paths[0]); + } + + fileDialog.OpenFilePicker("Open Image...", "Textures{.png,.dds,.tex,.tga}", UpdatePath, 1, startPath, false); + } + + ImGui.SameLine(); + if (ImGuiUtil.DrawDisabledButton(FontAwesomeIcon.Recycle.ToIconString(), new Vector2(ImGui.GetFrameHeight()), + "Reload the currently selected path.", false, + true)) + current.Reload(textures); + } + + private static void DrawData(Texture texture) + { + using var table = ImRaii.Table("##data", 2, ImGuiTableFlags.SizingFixedFit); + ImGuiUtil.DrawTableColumn("Width"); + ImGuiUtil.DrawTableColumn(texture.TextureWrap!.Width.ToString()); + ImGuiUtil.DrawTableColumn("Height"); + ImGuiUtil.DrawTableColumn(texture.TextureWrap!.Height.ToString()); + ImGuiUtil.DrawTableColumn("File Type"); + ImGuiUtil.DrawTableColumn(texture.Type.ToString()); + ImGuiUtil.DrawTableColumn("Bitmap Size"); + ImGuiUtil.DrawTableColumn($"{Functions.HumanReadableSize(texture.RgbaPixels.Length)} ({texture.RgbaPixels.Length} Bytes)"); + switch (texture.BaseImage.Image) + { + case ScratchImage s: + ImGuiUtil.DrawTableColumn("Format"); + ImGuiUtil.DrawTableColumn(s.Meta.Format.ToString()); + ImGuiUtil.DrawTableColumn("Mip Levels"); + ImGuiUtil.DrawTableColumn(s.Meta.MipLevels.ToString()); + ImGuiUtil.DrawTableColumn("Data Size"); + ImGuiUtil.DrawTableColumn($"{Functions.HumanReadableSize(s.Pixels.Length)} ({s.Pixels.Length} Bytes)"); + ImGuiUtil.DrawTableColumn("Number of Images"); + ImGuiUtil.DrawTableColumn(s.Images.Length.ToString()); + break; + case TexFile t: + ImGuiUtil.DrawTableColumn("Format"); + ImGuiUtil.DrawTableColumn(t.Header.Format.ToString()); + ImGuiUtil.DrawTableColumn("Mip Levels"); + ImGuiUtil.DrawTableColumn(t.Header.MipCount.ToString()); + ImGuiUtil.DrawTableColumn("Data Size"); + ImGuiUtil.DrawTableColumn($"{Functions.HumanReadableSize(t.ImageData.Length)} ({t.ImageData.Length} Bytes)"); + break; + } + } + + public sealed class PathSelectCombo(TextureManager textures, ModEditor editor, Func> getPlayerResources) + : FilterComboCache<(string Path, bool Game, bool IsOnPlayer)>(() => CreateFiles(textures, editor, getPlayerResources), + MouseWheelType.None, Penumbra.Log) + { + private int _skipPrefix = 0; + + protected override string ToString((string Path, bool Game, bool IsOnPlayer) obj) + => obj.Path; + + protected override bool DrawSelectable(int globalIdx, bool selected) + { + var (path, game, isOnPlayer) = Items[globalIdx]; + bool ret; + using (var color = ImRaii.PushColor(ImGuiCol.Text, ColorId.FolderExpanded.Value(), game)) + { + color.Push(ImGuiCol.Text, ColorId.HandledConflictMod.Value(), isOnPlayer); + var equals = string.Equals(CurrentSelection.Path, path, StringComparison.OrdinalIgnoreCase); + var p = game ? $"--> {path}" : path[_skipPrefix..]; + ret = ImGui.Selectable(p, selected) && !equals; + } + + ImGuiUtil.HoverTooltip(game + ? "This is a game path and refers to an unmanipulated file from your game data." + : "This is a path to a modded file on your file system."); + return ret; + } + + private static IReadOnlyList<(string Path, bool Game, bool IsOnPlayer)> CreateFiles(TextureManager textures, ModEditor editor, + Func> getPlayerResources) + { + var playerResources = getPlayerResources(); + + return editor.Files.Tex.SelectMany(f => f.SubModUsage.Select(p => (p.Item2.ToString(), true)) + .Prepend((f.File.FullName, false))) + .Where(p => p.Item2 ? textures.GameFileExists(p.Item1) : File.Exists(p.Item1)) + .Select(p => (p.Item1, p.Item2, playerResources.Contains(p.Item1))) + .ToList(); + } + + public bool Draw(string label, string tooltip, string current, int skipPrefix, out string newPath) + { + _skipPrefix = skipPrefix; + var startPath = current.Length > 0 ? current : "Choose a modded texture from this mod here..."; + if (!Draw(label, startPath, tooltip, -0.0001f, ImGui.GetTextLineHeightWithSpacing())) + { + newPath = current; + return false; + } + + newPath = CurrentSelection.Item1; + return true; + } + } +} diff --git a/Penumbra/Import/Textures/TextureManager.cs b/Penumbra/Import/Textures/TextureManager.cs new file mode 100644 index 00000000..177722ec --- /dev/null +++ b/Penumbra/Import/Textures/TextureManager.cs @@ -0,0 +1,583 @@ +using Dalamud.Interface; +using Dalamud.Interface.Textures; +using Dalamud.Interface.Textures.TextureWraps; +using Dalamud.Plugin.Services; +using Lumina.Data.Files; +using OtterGui.Log; +using OtterGui.Services; +using OtterGui.Tasks; +using OtterTex; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.Formats.Png; +using SixLabors.ImageSharp.Formats.Tga; +using SixLabors.ImageSharp.PixelFormats; +using TerraFX.Interop.DirectX; +using TerraFX.Interop.Windows; +using Image = SixLabors.ImageSharp.Image; + +namespace Penumbra.Import.Textures; + +public sealed class TextureManager(IDataManager gameData, Logger logger, ITextureProvider textureProvider, IUiBuilder uiBuilder) + : SingleTaskQueue, IDisposable, IService +{ + private readonly Logger _logger = logger; + + private readonly ConcurrentDictionary _tasks = new(); + private bool _disposed; + + public IReadOnlyDictionary Tasks + => _tasks; + + public void Dispose() + { + _disposed = true; + foreach (var (_, cancel) in _tasks.Values.ToArray()) + cancel.Cancel(); + _tasks.Clear(); + } + + public Task SavePng(string input, string output) + => Enqueue(new SaveImageSharpAction(this, input, output, TextureType.Png)); + + public Task SavePng(BaseImage image, string path, byte[]? rgba = null, int width = 0, int height = 0) + => Enqueue(new SaveImageSharpAction(this, image, path, TextureType.Png, rgba, width, height)); + + public Task SaveTga(string input, string output) + => Enqueue(new SaveImageSharpAction(this, input, output, TextureType.Targa)); + + public Task SaveTga(BaseImage image, string path, byte[]? rgba = null, int width = 0, int height = 0) + => Enqueue(new SaveImageSharpAction(this, image, path, TextureType.Targa, rgba, width, height)); + + + public Task SaveAs(CombinedTexture.TextureSaveType type, bool mipMaps, bool asTex, string input, string output) + => Enqueue(new SaveAsAction(this, type, mipMaps, asTex, input, output)); + + public Task SaveAs(CombinedTexture.TextureSaveType type, bool mipMaps, bool asTex, BaseImage image, string path, byte[]? rgba = null, + int width = 0, int height = 0) + => Enqueue(new SaveAsAction(this, type, mipMaps, asTex, image, path, rgba, width, height)); + + private Task Enqueue(IAction action) + { + if (_disposed) + return Task.FromException(new ObjectDisposedException(nameof(TextureManager))); + + Task t; + lock (_tasks) + { + t = _tasks.GetOrAdd(action, a => + { + var token = new CancellationTokenSource(); + var task = Enqueue(a, token.Token); + task.ContinueWith(_ => _tasks.TryRemove(a, out var unused), CancellationToken.None, TaskContinuationOptions.None, + TaskScheduler.Default); + return (task, token); + }).Item1; + } + + return t; + } + + private class SaveImageSharpAction : IAction + { + private readonly TextureManager _textures; + private readonly string _outputPath; + private readonly ImageInputData _input; + private readonly TextureType _type; + + public SaveImageSharpAction(TextureManager textures, string input, string output, TextureType type) + { + _textures = textures; + _input = new ImageInputData(input); + _outputPath = output; + _type = type; + if (_type.ReduceToBehaviour() is not TextureType.Png) + throw new ArgumentOutOfRangeException(nameof(type), type, $"Can not save as {type} with ImageSharp."); + } + + public SaveImageSharpAction(TextureManager textures, BaseImage image, string path, TextureType type, byte[]? rgba = null, int width = 0, + int height = 0) + { + _textures = textures; + _input = new ImageInputData(image, rgba, width, height); + _outputPath = path; + _type = type; + if (_type.ReduceToBehaviour() is not TextureType.Png) + throw new ArgumentOutOfRangeException(nameof(type), type, $"Can not save as {type} with ImageSharp."); + } + + public void Execute(CancellationToken cancel) + { + _textures._logger.Information($"[{nameof(TextureManager)}] Saving {_input} as {_type} to {_outputPath}..."); + var (image, rgba, width, height) = _input.GetData(_textures); + cancel.ThrowIfCancellationRequested(); + Image? data = null; + if (image.Type is TextureType.Unknown) + { + if (rgba != null && width > 0 && height > 0) + data = ConvertToPng(rgba, width, height).AsPng!; + } + else + { + data = ConvertToPng(image, cancel, rgba).AsPng!; + } + + cancel.ThrowIfCancellationRequested(); + switch (_type) + { + case TextureType.Png: + data?.SaveAsync(_outputPath, new PngEncoder { CompressionLevel = PngCompressionLevel.NoCompression }, cancel) + .Wait(cancel); + return; + case TextureType.Targa: + data?.SaveAsync(_outputPath, new TgaEncoder + { + Compression = TgaCompression.None, + BitsPerPixel = TgaBitsPerPixel.Pixel32, + }, cancel).Wait(cancel); + return; + } + } + + public override string ToString() + => $"{_input} to {_outputPath} PNG"; + + public bool Equals(IAction? other) + { + if (other is not SaveImageSharpAction rhs) + return false; + + return string.Equals(_outputPath, rhs._outputPath, StringComparison.OrdinalIgnoreCase) && _input.Equals(rhs._input); + } + + public override int GetHashCode() + => HashCode.Combine(_outputPath.ToLowerInvariant(), _input); + } + + private class SaveAsAction : IAction + { + private readonly TextureManager _textures; + private readonly string _outputPath; + private readonly ImageInputData _input; + private readonly CombinedTexture.TextureSaveType _type; + private readonly bool _mipMaps; + private readonly bool _asTex; + + public SaveAsAction(TextureManager textures, CombinedTexture.TextureSaveType type, bool mipMaps, bool asTex, string input, + string output) + { + _textures = textures; + _input = new ImageInputData(input); + _outputPath = output; + _type = type; + _mipMaps = mipMaps; + _asTex = asTex; + } + + public SaveAsAction(TextureManager textures, CombinedTexture.TextureSaveType type, bool mipMaps, bool asTex, BaseImage image, + string path, byte[]? rgba = null, int width = 0, int height = 0) + { + _textures = textures; + _input = new ImageInputData(image, rgba, width, height); + _outputPath = path; + _type = type; + _mipMaps = mipMaps; + _asTex = asTex; + } + + public void Execute(CancellationToken cancel) + { + _textures._logger.Information( + $"[{nameof(TextureManager)}] Saving {_input} as {_type} {(_asTex ? ".tex" : ".dds")} file{(_mipMaps ? " with mip maps" : string.Empty)} to {_outputPath}..."); + var (image, rgba, width, height) = _input.GetData(_textures); + if (image.Type is TextureType.Unknown) + { + if (rgba != null && width > 0 && height > 0) + image = ConvertToDds(rgba, width, height); + else + return; + } + + var imageTypeBehaviour = image.Type.ReduceToBehaviour(); + var dds = _type switch + { + CombinedTexture.TextureSaveType.AsIs when imageTypeBehaviour is TextureType.Png => ConvertToRgbaDds(image, _mipMaps, cancel, + rgba, width, height), + CombinedTexture.TextureSaveType.AsIs when imageTypeBehaviour is TextureType.Dds => AddMipMaps(image.AsDds!, _mipMaps), + CombinedTexture.TextureSaveType.Bitmap => ConvertToRgbaDds(image, _mipMaps, cancel, rgba, width, height), + CombinedTexture.TextureSaveType.BC1 => _textures.ConvertToCompressedDds(image, _mipMaps, DXGIFormat.BC1UNorm, cancel, rgba, + width, height), + CombinedTexture.TextureSaveType.BC3 => _textures.ConvertToCompressedDds(image, _mipMaps, DXGIFormat.BC3UNorm, cancel, rgba, + width, height), + CombinedTexture.TextureSaveType.BC4 => _textures.ConvertToCompressedDds(image, _mipMaps, DXGIFormat.BC4UNorm, cancel, rgba, + width, height), + CombinedTexture.TextureSaveType.BC5 => _textures.ConvertToCompressedDds(image, _mipMaps, DXGIFormat.BC5UNorm, cancel, rgba, + width, height), + CombinedTexture.TextureSaveType.BC7 => _textures.ConvertToCompressedDds(image, _mipMaps, DXGIFormat.BC7UNorm, cancel, rgba, + width, height), + _ => throw new Exception("Wrong save type."), + }; + + cancel.ThrowIfCancellationRequested(); + if (_asTex) + SaveTex(_outputPath, dds.AsDds!); + else + dds.AsDds!.SaveDDS(_outputPath); + } + + public override string ToString() + => $"{_input} to {_outputPath} {_type} {(_asTex ? "TEX" : "DDS")}{(_mipMaps ? " with MipMaps" : string.Empty)}"; + + public bool Equals(IAction? other) + { + if (other is not SaveAsAction rhs) + return false; + + return _type == rhs._type + && _mipMaps == rhs._mipMaps + && _asTex == rhs._asTex + && string.Equals(_outputPath, rhs._outputPath, StringComparison.OrdinalIgnoreCase) + && _input.Equals(rhs._input); + } + + public override int GetHashCode() + => HashCode.Combine(_outputPath.ToLowerInvariant(), _type, _mipMaps, _asTex, _input); + } + + /// Load a texture wrap for a given image. + public IDalamudTextureWrap LoadTextureWrap(BaseImage image, byte[]? rgba = null, int width = 0, int height = 0) + { + (rgba, width, height) = GetData(image, rgba, width, height); + return LoadTextureWrap(rgba, width, height); + } + + /// Load a texture wrap for a given image. + public IDalamudTextureWrap LoadTextureWrap(byte[] rgba, int width, int height) + => textureProvider.CreateFromRaw(RawImageSpecification.Rgba32(width, height), rgba, "Penumbra.Texture"); + + /// Load any supported file from game data or drive depending on extension and if the path is rooted. + public (BaseImage, TextureType) Load(string path) + => Path.GetExtension(path).ToLowerInvariant() switch + { + ".dds" => (LoadDds(path), TextureType.Dds), + ".png" => (LoadImageSharp(path), TextureType.Png), + ".tga" => (LoadImageSharp(path), TextureType.Targa), + ".bmp" => (LoadImageSharp(path), TextureType.Bitmap), + ".tex" => (LoadTex(path), TextureType.Tex), + _ => throw new Exception($"Extension {Path.GetExtension(path)} unknown."), + }; + + /// Load a .tex file from game data or drive depending on if the path is rooted. + public BaseImage LoadTex(string path) + { + using var stream = OpenTexStream(path); + return TexFileParser.Parse(stream); + } + + /// Load a .dds file from drive using OtterTex. + public BaseImage LoadDds(string path) + => ScratchImage.LoadDDS(path); + + /// Load a supported file type from drive using ImageSharp. + public BaseImage LoadImageSharp(string path) + { + using var stream = File.OpenRead(path); + return Image.Load(stream); + } + + /// Convert an existing image to ImageSharp. Does not create a deep copy of an existing ImageSharp file and just returns the existing one. + public static BaseImage ConvertToPng(BaseImage input, CancellationToken cancel, byte[]? rgba = null, int width = 0, int height = 0) + { + switch (input.Type.ReduceToBehaviour()) + { + case TextureType.Png: return input; + case TextureType.Dds: + { + (rgba, width, height) = GetData(input, rgba, width, height); + cancel.ThrowIfCancellationRequested(); + return ConvertToPng(rgba, width, height); + } + default: return new BaseImage(); + } + } + + /// Convert an existing image to a RGBA32 .dds. Does not create a deep copy of an existing RGBA32 dds and just returns the existing one. + public static BaseImage ConvertToRgbaDds(BaseImage input, bool mipMaps, CancellationToken cancel, byte[]? rgba = null, int width = 0, + int height = 0) + { + switch (input.Type.ReduceToBehaviour()) + { + case TextureType.Png: + { + (rgba, width, height) = GetData(input, rgba, width, height); + cancel.ThrowIfCancellationRequested(); + var dds = ConvertToDds(rgba, width, height).AsDds!; + cancel.ThrowIfCancellationRequested(); + return AddMipMaps(dds, mipMaps); + } + case TextureType.Dds: + { + var scratch = input.AsDds!; + if (rgba == null) + return CreateUncompressed(scratch, mipMaps, cancel); + + (rgba, width, height) = GetData(input, rgba, width, height); + cancel.ThrowIfCancellationRequested(); + var dds = ConvertToDds(rgba, width, height).AsDds!; + cancel.ThrowIfCancellationRequested(); + return AddMipMaps(dds, mipMaps); + } + default: return new BaseImage(); + } + } + + /// Convert an existing image to a block compressed .dds. Does not create a deep copy of an existing dds of the correct format and just returns the existing one. + public BaseImage ConvertToCompressedDds(BaseImage input, bool mipMaps, DXGIFormat format, CancellationToken cancel, byte[]? rgba = null, + int width = 0, int height = 0) + { + switch (input.Type.ReduceToBehaviour()) + { + case TextureType.Png: + { + (rgba, width, height) = GetData(input, rgba, width, height); + cancel.ThrowIfCancellationRequested(); + var dds = ConvertToDds(rgba, width, height).AsDds!; + cancel.ThrowIfCancellationRequested(); + return CreateCompressed(dds, mipMaps, format, cancel); + } + case TextureType.Dds: + { + var scratch = input.AsDds!; + return CreateCompressed(scratch, mipMaps, format, cancel); + } + default: return new BaseImage(); + } + } + + public static BaseImage ConvertToPng(byte[] rgba, int width, int height) + => Image.LoadPixelData(rgba, width, height); + + public static BaseImage ConvertToDds(byte[] rgba, int width, int height) + { + var scratch = ScratchImage.FromRGBA(rgba, width, height, out var i).ThrowIfError(i); + return scratch.Convert(DXGIFormat.B8G8R8A8UNorm); + } + + public bool GameFileExists(string path) + => gameData.FileExists(path); + + /// Add up to 13 mip maps to the input if mip maps is true, otherwise return input. + public static ScratchImage AddMipMaps(ScratchImage input, bool mipMaps) + { + var numMips = mipMaps ? Math.Min(13, 1 + BitOperations.Log2((uint)Math.Max(input.Meta.Width, input.Meta.Height))) : 1; + if (numMips == input.Meta.MipLevels) + return input; + + var flags = (Dalamud.Utility.Util.IsWine() ? FilterFlags.ForceNonWIC : 0) | FilterFlags.SeparateAlpha; + var ec = input.GenerateMipMaps(out var ret, numMips, flags); + if (ec != ErrorCode.Ok) + throw new Exception( + $"Could not create the requested {numMips} mip maps (input has {input.Meta.MipLevels}) with flags [{flags}], maybe retry with the top-right checkbox unchecked:\n{ec}"); + + return ret; + } + + /// Create an uncompressed .dds (optionally with mip maps) from the input. Returns input (+ mipmaps) if it is already uncompressed. + public static ScratchImage CreateUncompressed(ScratchImage input, bool mipMaps, CancellationToken cancel) + { + if (input.Meta.Format == DXGIFormat.B8G8R8A8UNorm) + return AddMipMaps(input, mipMaps); + + input = input.Meta.Format.IsCompressed() + ? input.Decompress(DXGIFormat.B8G8R8A8UNorm) + : input.Convert(DXGIFormat.B8G8R8A8UNorm); + cancel.ThrowIfCancellationRequested(); + return AddMipMaps(input, mipMaps); + } + + /// Create a BC3 or BC7 block-compressed .dds from the input (optionally with mipmaps). Returns input (+ mipmaps) if it is already the correct format. + public unsafe ScratchImage CreateCompressed(ScratchImage input, bool mipMaps, DXGIFormat format, CancellationToken cancel) + { + if (input.Meta.Format == format) + return input; + + if (input.Meta.Format.IsCompressed()) + { + input = input.Decompress(DXGIFormat.B8G8R8A8UNorm); + cancel.ThrowIfCancellationRequested(); + } + + input = AddMipMaps(input, mipMaps); + cancel.ThrowIfCancellationRequested(); + // See https://github.com/microsoft/DirectXTex/wiki/Compress#parameters for the format condition. + if (format is DXGIFormat.BC6HUF16 or DXGIFormat.BC6HSF16 or DXGIFormat.BC7UNorm or DXGIFormat.BC7UNormSRGB) + { + ref var device = ref *(ID3D11Device*)uiBuilder.DeviceHandle; + IDXGIDevice* dxgiDevice; + Marshal.ThrowExceptionForHR(device.QueryInterface(TerraFX.Interop.Windows.Windows.__uuidof(), (void**)&dxgiDevice)); + + try + { + IDXGIAdapter* adapter = null; + Marshal.ThrowExceptionForHR(dxgiDevice->GetAdapter(&adapter)); + try + { + dxgiDevice->Release(); + dxgiDevice = null; + + ID3D11Device* deviceClone = null; + ID3D11DeviceContext* contextClone = null; + var featureLevel = device.GetFeatureLevel(); + Marshal.ThrowExceptionForHR(DirectX.D3D11CreateDevice( + adapter, + D3D_DRIVER_TYPE.D3D_DRIVER_TYPE_UNKNOWN, + HMODULE.NULL, + device.GetCreationFlags(), + &featureLevel, + 1, + D3D11.D3D11_SDK_VERSION, + &deviceClone, + null, + &contextClone)); + try + { + adapter->Release(); + adapter = null; + return input.Compress((nint)deviceClone, format, CompressFlags.Parallel); + } + finally + { + if (contextClone is not null) + contextClone->Release(); + if (deviceClone is not null) + deviceClone->Release(); + } + } + finally + { + if (adapter is not null) + adapter->Release(); + } + } + finally + { + if (dxgiDevice is not null) + dxgiDevice->Release(); + } + } + + return input.Compress(format, CompressFlags.BC7Quick | CompressFlags.Parallel); + } + + + /// Load a tex file either from game data if the path is not rooted, or from drive if it is rooted. + private Stream OpenTexStream(string path) + { + if (Path.IsPathRooted(path)) + return File.OpenRead(path); + + var file = gameData.GetFile(path); + return file != null ? new MemoryStream(file.Data) : throw new Exception($"Unable to obtain \"{path}\" from game files."); + } + + /// Obtain the checked rgba data, width and height for an image. + private static (byte[], int, int) GetData(BaseImage input, byte[]? rgba, int width, int height) + { + if (rgba == null) + return input.GetPixelData(); + + if (width == 0 || height == 0) + (width, height) = input.Dimensions; + return width * height * 4 != rgba.Length + ? input.GetPixelData() + : (rgba, width, height); + } + + /// Save a .dds file as .tex file with appropriately changed header. + public static void SaveTex(string path, ScratchImage input) + { + var header = input.ToTexHeader(); + if (header.Format == TexFile.TextureFormat.Unknown) + throw new Exception($"Could not save tex file with format {input.Meta.Format}, not convertible to a valid .tex format."); + + using var stream = File.Open(path, File.Exists(path) ? FileMode.Truncate : FileMode.CreateNew); + using var w = new BinaryWriter(stream); + header.Write(w); + w.Write(input.Pixels); + // Necessary due to the GC being allowed to collect after the last invocation of an object, + // thus invalidating the ReadOnlySpan. + GC.KeepAlive(input); + } + + private readonly struct ImageInputData : IEquatable + { + private readonly string? _inputPath; + + private readonly BaseImage _image; + private readonly byte[]? _rgba; + private readonly int _width; + private readonly int _height; + + public ImageInputData(string inputPath) + { + _inputPath = inputPath; + _image = new BaseImage(); + _rgba = null; + _width = 0; + _height = 0; + } + + public ImageInputData(BaseImage image, byte[]? rgba = null, int width = 0, int height = 0) + { + _inputPath = null; + _image = image.Width == 0 || image.Height == 0 ? new BaseImage() : image; + _rgba = rgba?.ToArray(); + _width = width; + _height = height; + } + + public (BaseImage Image, byte[]? Rgba, int Width, int Height) GetData(TextureManager textures) + { + if (_inputPath == null) + return (_image, _rgba, _width, _height); + + if (!File.Exists(_inputPath)) + throw new FileNotFoundException($"Input texture file {_inputPath} not Found.", _inputPath); + + var (image, _) = textures.Load(_inputPath); + return (image, null, 0, 0); + } + + public bool Equals(ImageInputData rhs) + { + if (_inputPath != null) + return string.Equals(_inputPath, rhs._inputPath, StringComparison.OrdinalIgnoreCase); + + if (rhs._inputPath != null) + return false; + + if (_image.Image != null) + return ReferenceEquals(_image.Image, rhs._image.Image); + + return _width == rhs._width && _height == rhs._height && _rgba != null && rhs._rgba != null && _rgba.SequenceEqual(rhs._rgba); + } + + public override string ToString() + => _inputPath + ?? _image.Type switch + { + TextureType.Unknown => $"Custom {_width} x {_height} RGBA Image", + TextureType.Dds => $"Custom {_width} x {_height} {_image.Format} Image", + TextureType.Tex => $"Custom {_width} x {_height} {_image.Format} Image", + TextureType.Png => $"Custom {_width} x {_height} .png Image", + TextureType.Targa => $"Custom {_width} x {_height} .tga Image", + TextureType.Bitmap => $"Custom {_width} x {_height} RGBA Image", + _ => "Unknown Image", + }; + + public override int GetHashCode() + => _inputPath != null ? _inputPath.ToLowerInvariant().GetHashCode() : HashCode.Combine(_width, _height); + + public override bool Equals(object? obj) + => obj is ImageInputData o && Equals(o); + } +} diff --git a/Penumbra/Importer/ImporterState.cs b/Penumbra/Importer/ImporterState.cs deleted file mode 100644 index 608976fc..00000000 --- a/Penumbra/Importer/ImporterState.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace Penumbra.Importer -{ - public enum ImporterState - { - None, - WritingPackToDisk, - ExtractingModFiles, - Done, - } -} \ No newline at end of file diff --git a/Penumbra/Importer/MagicTempFileStreamManagerAndDeleter.cs b/Penumbra/Importer/MagicTempFileStreamManagerAndDeleter.cs deleted file mode 100644 index 10be9f15..00000000 --- a/Penumbra/Importer/MagicTempFileStreamManagerAndDeleter.cs +++ /dev/null @@ -1,25 +0,0 @@ -using System; -using System.IO; -using Penumbra.Util; - -namespace Penumbra.Importer -{ - public class MagicTempFileStreamManagerAndDeleter : PenumbraSqPackStream, IDisposable - { - private readonly FileStream _fileStream; - - public MagicTempFileStreamManagerAndDeleter( FileStream stream ) - : base( stream ) - => _fileStream = stream; - - public new void Dispose() - { - var filePath = _fileStream.Name; - - base.Dispose(); - _fileStream.Dispose(); - - File.Delete( filePath ); - } - } -} \ No newline at end of file diff --git a/Penumbra/Importer/Models/ExtendedModPack.cs b/Penumbra/Importer/Models/ExtendedModPack.cs deleted file mode 100644 index 91bb5d01..00000000 --- a/Penumbra/Importer/Models/ExtendedModPack.cs +++ /dev/null @@ -1,40 +0,0 @@ -using System.Collections.Generic; -using Penumbra.Structs; - -namespace Penumbra.Importer.Models -{ - internal class OptionList - { - public string? Name { get; set; } - public string? Description { get; set; } - public string? ImagePath { get; set; } - public List< SimpleMod >? ModsJsons { get; set; } - public string? GroupName { get; set; } - public SelectType SelectionType { get; set; } - public bool IsChecked { get; set; } - } - - internal class ModGroup - { - public string? GroupName { get; set; } - public SelectType SelectionType { get; set; } - public List< OptionList >? OptionList { get; set; } - } - - internal class ModPackPage - { - public int PageIndex { get; set; } - public List< ModGroup >? ModGroups { get; set; } - } - - internal class ExtendedModPack - { - public string? TTMPVersion { get; set; } - public string? Name { get; set; } - public string? Author { get; set; } - public string? Version { get; set; } - public string? Description { get; set; } - public List< ModPackPage >? ModPackPages { get; set; } - public List< SimpleMod >? SimpleModsList { get; set; } - } -} \ No newline at end of file diff --git a/Penumbra/Importer/Models/SimpleModPack.cs b/Penumbra/Importer/Models/SimpleModPack.cs deleted file mode 100644 index 1b3f9e4e..00000000 --- a/Penumbra/Importer/Models/SimpleModPack.cs +++ /dev/null @@ -1,25 +0,0 @@ -using System.Collections.Generic; - -namespace Penumbra.Importer.Models -{ - internal class SimpleModPack - { - public string? TTMPVersion { get; set; } - public string? Name { get; set; } - public string? Author { get; set; } - public string? Version { get; set; } - public string? Description { get; set; } - public List< SimpleMod >? SimpleModsList { get; set; } - } - - internal class SimpleMod - { - public string? Name { get; set; } - public string? Category { get; set; } - public string? FullPath { get; set; } - public long ModOffset { get; set; } - public long ModSize { get; set; } - public string? DatFile { get; set; } - public object? ModPackEntry { get; set; } - } -} \ No newline at end of file diff --git a/Penumbra/Importer/TexToolsImport.cs b/Penumbra/Importer/TexToolsImport.cs deleted file mode 100644 index 0a724f6f..00000000 --- a/Penumbra/Importer/TexToolsImport.cs +++ /dev/null @@ -1,415 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Text; -using Dalamud.Logging; -using Dalamud.Plugin; -using ICSharpCode.SharpZipLib.Zip; -using Newtonsoft.Json; -using Penumbra.GameData.Util; -using Penumbra.Importer.Models; -using Penumbra.Mod; -using Penumbra.Structs; -using Penumbra.Util; -using FileMode = System.IO.FileMode; - -namespace Penumbra.Importer -{ - internal class TexToolsImport - { - private readonly DirectoryInfo _outDirectory; - - private const string TempFileName = "textools-import"; - private readonly string _resolvedTempFilePath; - - public DirectoryInfo? ExtractedDirectory { get; private set; } - - public ImporterState State { get; private set; } - - public long TotalProgress { get; private set; } - public long CurrentProgress { get; private set; } - - public float Progress - { - get - { - if( CurrentProgress != 0 ) - { - // ReSharper disable twice RedundantCast - return ( float )CurrentProgress / ( float )TotalProgress; - } - - return 0; - } - } - - public string? CurrentModPack { get; private set; } - - public TexToolsImport( DirectoryInfo outDirectory ) - { - _outDirectory = outDirectory; - _resolvedTempFilePath = Path.Combine( _outDirectory.FullName, TempFileName ); - } - - private static DirectoryInfo NewOptionDirectory( DirectoryInfo baseDir, string optionName ) - => new( Path.Combine( baseDir.FullName, optionName.ReplaceBadXivSymbols() ) ); - - public void ImportModPack( FileInfo modPackFile ) - { - CurrentModPack = modPackFile.Name; - - VerifyVersionAndImport( modPackFile ); - - State = ImporterState.Done; - } - - private void WriteZipEntryToTempFile( Stream s ) - { - var fs = new FileStream( _resolvedTempFilePath, FileMode.Create ); - s.CopyTo( fs ); - fs.Close(); - } - - // You can in no way rely on any file paths in TTMPs so we need to just do this, sorry - private static ZipEntry? FindZipEntry( ZipFile file, string fileName ) - { - for( var i = 0; i < file.Count; i++ ) - { - var entry = file[ i ]; - - if( entry.Name.Contains( fileName ) ) - { - return entry; - } - } - - return null; - } - - private PenumbraSqPackStream GetMagicSqPackDeleterStream( ZipFile file, string entryName ) - { - State = ImporterState.WritingPackToDisk; - - // write shitty zip garbage to disk - var entry = FindZipEntry( file, entryName ); - if( entry == null ) - { - throw new FileNotFoundException( $"ZIP does not contain a file named {entryName}." ); - } - - using var s = file.GetInputStream( entry ); - - WriteZipEntryToTempFile( s ); - - var fs = new FileStream( _resolvedTempFilePath, FileMode.Open ); - return new MagicTempFileStreamManagerAndDeleter( fs ); - } - - private void VerifyVersionAndImport( FileInfo modPackFile ) - { - using var zfs = modPackFile.OpenRead(); - using var extractedModPack = new ZipFile( zfs ); - - var mpl = FindZipEntry( extractedModPack, "TTMPL.mpl" ); - if( mpl == null ) - { - throw new FileNotFoundException( "ZIP does not contain a TTMPL.mpl file." ); - } - - var modRaw = GetStringFromZipEntry( extractedModPack, mpl, Encoding.UTF8 ); - - // At least a better validation than going by the extension. - if( modRaw.Contains( "\"TTMPVersion\":" ) ) - { - if( modPackFile.Extension != ".ttmp2" ) - { - PluginLog.Warning( $"File {modPackFile.FullName} seems to be a V2 TTMP, but has the wrong extension." ); - } - - ImportV2ModPack( modPackFile, extractedModPack, modRaw ); - } - else - { - if( modPackFile.Extension != ".ttmp" ) - { - PluginLog.Warning( $"File {modPackFile.FullName} seems to be a V1 TTMP, but has the wrong extension." ); - } - - ImportV1ModPack( modPackFile, extractedModPack, modRaw ); - } - } - - private void ImportV1ModPack( FileInfo modPackFile, ZipFile extractedModPack, string modRaw ) - { - PluginLog.Log( " -> Importing V1 ModPack" ); - - var modListRaw = modRaw.Split( - new[] { "\r\n", "\r", "\n" }, - StringSplitOptions.None - ); - - var modList = modListRaw.Select( JsonConvert.DeserializeObject< SimpleMod > ); - - // Create a new ModMeta from the TTMP modlist info - var modMeta = new ModMeta - { - Author = "Unknown", - Name = modPackFile.Name, - Description = "Mod imported from TexTools mod pack", - }; - - // Open the mod data file from the modpack as a SqPackStream - using var modData = GetMagicSqPackDeleterStream( extractedModPack, "TTMPD.mpd" ); - - ExtractedDirectory = CreateModFolder( _outDirectory, Path.GetFileNameWithoutExtension( modPackFile.Name ) ); - - File.WriteAllText( - Path.Combine( ExtractedDirectory.FullName, "meta.json" ), - JsonConvert.SerializeObject( modMeta ) - ); - - ExtractSimpleModList( ExtractedDirectory, modList, modData ); - } - - private void ImportV2ModPack( FileInfo modPackFile, ZipFile extractedModPack, string modRaw ) - { - var modList = JsonConvert.DeserializeObject< SimpleModPack >( modRaw ); - - if( modList?.TTMPVersion == null ) - { - PluginLog.Error( "Could not extract V2 Modpack. No version given." ); - return; - } - - if( modList.TTMPVersion.EndsWith( "s" ) ) - { - ImportSimpleV2ModPack( extractedModPack, modList ); - return; - } - - if( modList.TTMPVersion.EndsWith( "w" ) ) - { - ImportExtendedV2ModPack( extractedModPack, modRaw ); - } - } - - public static DirectoryInfo CreateModFolder( DirectoryInfo outDirectory, string modListName ) - { - var name = Path.GetFileName( modListName ); - if( !name.Any() ) - { - name = "_"; - } - - var newModFolderBase = NewOptionDirectory( outDirectory, name ); - var newModFolder = newModFolderBase; - var i = 2; - while( newModFolder.Exists && i < 12 ) - { - newModFolder = new DirectoryInfo( newModFolderBase.FullName + $" ({i++})" ); - } - - if( newModFolder.Exists ) - { - throw new IOException( "Could not create mod folder: too many folders of the same name exist." ); - } - - newModFolder.Create(); - return newModFolder; - } - - private void ImportSimpleV2ModPack( ZipFile extractedModPack, SimpleModPack modList ) - { - PluginLog.Log( " -> Importing Simple V2 ModPack" ); - - // Create a new ModMeta from the TTMP modlist info - var modMeta = new ModMeta - { - Author = modList.Author ?? "Unknown", - Name = modList.Name ?? "New Mod", - Description = string.IsNullOrEmpty( modList.Description ) - ? "Mod imported from TexTools mod pack" - : modList.Description!, - }; - - // Open the mod data file from the modpack as a SqPackStream - using var modData = GetMagicSqPackDeleterStream( extractedModPack, "TTMPD.mpd" ); - - ExtractedDirectory = CreateModFolder( _outDirectory, modList.Name ?? "New Mod" ); - - File.WriteAllText( Path.Combine( ExtractedDirectory.FullName, "meta.json" ), - JsonConvert.SerializeObject( modMeta ) ); - - ExtractSimpleModList( ExtractedDirectory, modList.SimpleModsList ?? Enumerable.Empty< SimpleMod >(), modData ); - } - - private void ImportExtendedV2ModPack( ZipFile extractedModPack, string modRaw ) - { - PluginLog.Log( " -> Importing Extended V2 ModPack" ); - - var modList = JsonConvert.DeserializeObject< ExtendedModPack >( modRaw ); - - // Create a new ModMeta from the TTMP modlist info - var modMeta = new ModMeta - { - Author = modList.Author ?? "Unknown", - Name = modList.Name ?? "New Mod", - Description = string.IsNullOrEmpty( modList.Description ) - ? "Mod imported from TexTools mod pack" - : modList.Description ?? "", - Version = modList.Version ?? "", - }; - - // Open the mod data file from the modpack as a SqPackStream - using var modData = GetMagicSqPackDeleterStream( extractedModPack, "TTMPD.mpd" ); - - ExtractedDirectory = CreateModFolder( _outDirectory, modList.Name ?? "New Mod" ); - - if( modList.SimpleModsList != null ) - { - ExtractSimpleModList( ExtractedDirectory, modList.SimpleModsList, modData ); - } - - if( modList.ModPackPages == null ) - { - return; - } - - // Iterate through all pages - foreach( var page in modList.ModPackPages ) - { - if( page.ModGroups == null ) - { - continue; - } - - foreach( var group in page.ModGroups.Where( group => group.GroupName != null && group.OptionList != null ) ) - { - var groupFolder = NewOptionDirectory( ExtractedDirectory, group.GroupName! ); - if( groupFolder.Exists ) - { - groupFolder = new DirectoryInfo( groupFolder.FullName + $" ({page.PageIndex})" ); - group.GroupName += $" ({page.PageIndex})"; - } - - foreach( var option in group.OptionList!.Where( option => option.Name != null && option.ModsJsons != null ) ) - { - var optionFolder = NewOptionDirectory( groupFolder, option.Name! ); - ExtractSimpleModList( optionFolder, option.ModsJsons!, modData ); - } - - AddMeta( ExtractedDirectory, groupFolder, group, modMeta ); - } - } - - File.WriteAllText( - Path.Combine( ExtractedDirectory.FullName, "meta.json" ), - JsonConvert.SerializeObject( modMeta, Formatting.Indented ) - ); - } - - private static void AddMeta( DirectoryInfo baseFolder, DirectoryInfo groupFolder, ModGroup group, ModMeta meta ) - { - var inf = new OptionGroup - { - SelectionType = group.SelectionType, - GroupName = group.GroupName!, - Options = new List< Option >(), - }; - foreach( var opt in group.OptionList! ) - { - var option = new Option - { - OptionName = opt.Name!, - OptionDesc = string.IsNullOrEmpty( opt.Description ) ? "" : opt.Description!, - OptionFiles = new Dictionary< RelPath, HashSet< GamePath > >(), - }; - var optDir = NewOptionDirectory( groupFolder, opt.Name! ); - if( optDir.Exists ) - { - foreach( var file in optDir.EnumerateFiles( "*.*", SearchOption.AllDirectories ) ) - { - option.AddFile( new RelPath( file, baseFolder ), new GamePath( file, optDir ) ); - } - } - - inf.Options.Add( option ); - } - - meta.Groups.Add( inf.GroupName, inf ); - } - - private void ImportMetaModPack( FileInfo file ) - { - throw new NotImplementedException(); - } - - private void ExtractSimpleModList( DirectoryInfo outDirectory, IEnumerable< SimpleMod > mods, PenumbraSqPackStream dataStream ) - { - State = ImporterState.ExtractingModFiles; - - // haha allocation go brr - var wtf = mods.ToList(); - - TotalProgress += wtf.LongCount(); - - // Extract each SimpleMod into the new mod folder - foreach( var simpleMod in wtf.Where( m => m != null ) ) - { - ExtractMod( outDirectory, simpleMod, dataStream ); - CurrentProgress++; - } - } - - private void ExtractMod( DirectoryInfo outDirectory, SimpleMod mod, PenumbraSqPackStream dataStream ) - { - PluginLog.Log( " -> Extracting {0} at {1}", mod.FullPath!, mod.ModOffset.ToString( "X" ) ); - - try - { - var data = dataStream.ReadFile< PenumbraSqPackStream.PenumbraFileResource >( mod.ModOffset ); - - var extractedFile = new FileInfo( Path.Combine( outDirectory.FullName, mod.FullPath! ) ); - extractedFile.Directory?.Create(); - - if( extractedFile.FullName.EndsWith( "mdl" ) ) - { - ProcessMdl( data.Data ); - } - - File.WriteAllBytes( extractedFile.FullName, data.Data ); - } - catch( Exception ex ) - { - PluginLog.LogError( ex, "Could not extract mod." ); - } - } - - private void ProcessMdl( byte[] mdl ) - { - // Model file header LOD num - mdl[ 64 ] = 1; - - // Model header LOD num - var stackSize = BitConverter.ToUInt32( mdl, 4 ); - var runtimeBegin = stackSize + 0x44; - var stringsLengthOffset = runtimeBegin + 4; - var stringsLength = BitConverter.ToUInt32( mdl, ( int )stringsLengthOffset ); - var modelHeaderStart = stringsLengthOffset + stringsLength + 4; - var modelHeaderLodOffset = 22; - mdl[ modelHeaderStart + modelHeaderLodOffset ] = 1; - } - - private static Stream GetStreamFromZipEntry( ZipFile file, ZipEntry entry ) - => file.GetInputStream( entry ); - - private static string GetStringFromZipEntry( ZipFile file, ZipEntry entry, Encoding encoding ) - { - using var ms = new MemoryStream(); - using var s = GetStreamFromZipEntry( file, entry ); - s.CopyTo( ms ); - return encoding.GetString( ms.ToArray() ); - } - } -} \ No newline at end of file diff --git a/Penumbra/Importer/TexToolsMeta.cs b/Penumbra/Importer/TexToolsMeta.cs deleted file mode 100644 index 18b5e5a8..00000000 --- a/Penumbra/Importer/TexToolsMeta.cs +++ /dev/null @@ -1,382 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Text.RegularExpressions; -using Dalamud.Logging; -using Lumina.Data.Files; -using Penumbra.GameData.Enums; -using Penumbra.GameData.Structs; -using Penumbra.GameData.Util; -using Penumbra.Meta; -using Penumbra.Meta.Files; -using Penumbra.Util; - -namespace Penumbra.Importer -{ - // TexTools provices custom generated *.meta files for its modpacks, that contain changes to - // - imc files - // - eqp files - // - gmp files - // - est files - // - eqdp files - // made by the mod. The filename determines to what the changes are applied, and the binary file itself contains changes. - // We parse every *.meta file in a mod and combine all actual changes that do not keep data on default values and that can be applied to the game in a .json. - // TexTools may also generate files that contain non-existing changes, e.g. *.imc files for weapon offhands, which will be ignored. - public class TexToolsMeta - { - // The info class determines the files or table locations the changes need to apply to from the filename. - public class Info - { - private const string Pt = @"(?'PrimaryType'[a-z]*)"; // language=regex - private const string Pp = @"(?'PrimaryPrefix'[a-z])"; // language=regex - private const string Pi = @"(?'PrimaryId'\d{4})"; // language=regex - private const string Pir = @"\k'PrimaryId'"; // language=regex - private const string St = @"(?'SecondaryType'[a-z]*)"; // language=regex - private const string Sp = @"(?'SecondaryPrefix'[a-z])"; // language=regex - private const string Si = @"(?'SecondaryId'\d{4})"; // language=regex - private const string File = @"\k'PrimaryPrefix'\k'PrimaryId'(\k'SecondaryPrefix'\k'SecondaryId')?"; // language=regex - private const string Slot = @"(_(?'Slot'[a-z]{3}))?"; // language=regex - private const string Ext = @"\.meta"; - - // These are the valid regexes for .meta files that we are able to support at the moment. - private static readonly Regex HousingMeta = new( $"bgcommon/hou/{Pt}/general/{Pi}/{Pir}{Ext}" ); - private static readonly Regex CharaMeta = new( $"chara/{Pt}/{Pp}{Pi}(/obj/{St}/{Sp}{Si})?/{File}{Slot}{Ext}" ); - - public readonly ObjectType PrimaryType; - public readonly BodySlot SecondaryType; - public readonly ushort PrimaryId; - public readonly ushort SecondaryId; - public readonly EquipSlot EquipSlot = EquipSlot.Unknown; - public readonly CustomizationType CustomizationType = CustomizationType.Unknown; - - private static bool ValidType( ObjectType type ) - { - return type switch - { - ObjectType.Accessory => true, - ObjectType.Character => true, - ObjectType.Equipment => true, - ObjectType.DemiHuman => true, - ObjectType.Housing => true, - ObjectType.Monster => true, - ObjectType.Weapon => true, - ObjectType.Icon => false, - ObjectType.Font => false, - ObjectType.Interface => false, - ObjectType.LoadingScreen => false, - ObjectType.Map => false, - ObjectType.Vfx => false, - ObjectType.Unknown => false, - ObjectType.World => false, - _ => false, - }; - } - - public Info( string fileName ) - : this( new GamePath( fileName ) ) - { } - - public Info( GamePath fileName ) - { - PrimaryType = GameData.GameData.GetGamePathParser().PathToObjectType( fileName ); - PrimaryId = 0; - SecondaryType = BodySlot.Unknown; - SecondaryId = 0; - if( !ValidType( PrimaryType ) ) - { - PrimaryType = ObjectType.Unknown; - return; - } - - if( PrimaryType == ObjectType.Housing ) - { - var housingMatch = HousingMeta.Match( fileName ); - if( housingMatch.Success ) - { - PrimaryId = ushort.Parse( housingMatch.Groups[ "PrimaryId" ].Value ); - } - - return; - } - - var match = CharaMeta.Match( fileName ); - if( !match.Success ) - { - return; - } - - PrimaryId = ushort.Parse( match.Groups[ "PrimaryId" ].Value ); - if( match.Groups[ "Slot" ].Success ) - { - switch( PrimaryType ) - { - case ObjectType.Equipment: - case ObjectType.Accessory: - if( Names.SuffixToEquipSlot.TryGetValue( match.Groups[ "Slot" ].Value, out var tmpSlot ) ) - { - EquipSlot = tmpSlot; - } - - break; - case ObjectType.Character: - if( Names.SuffixToCustomizationType.TryGetValue( match.Groups[ "Slot" ].Value, out var tmpCustom ) ) - { - CustomizationType = tmpCustom; - } - - break; - } - } - - if( match.Groups[ "SecondaryType" ].Success - && Names.StringToBodySlot.TryGetValue( match.Groups[ "SecondaryType" ].Value, out SecondaryType ) ) - { - SecondaryId = ushort.Parse( match.Groups[ "SecondaryId" ].Value ); - } - } - } - - public readonly uint Version; - public readonly string FilePath; - public readonly List< MetaManipulation > Manipulations = new(); - - private static string ReadNullTerminated( BinaryReader reader ) - { - var builder = new System.Text.StringBuilder(); - for( var c = reader.ReadChar(); c != 0; c = reader.ReadChar() ) - { - builder.Append( c ); - } - - return builder.ToString(); - } - - private void AddIfNotDefault( MetaManipulation manipulation ) - { - try - { - if( !Service< MetaDefaults >.Get().CheckAgainstDefault( manipulation ) ) - { - Manipulations.Add( manipulation ); - } - } - catch( Exception e ) - { - PluginLog.Debug( "Skipped {Type}-manipulation:\n{e:l}", manipulation.Type, e ); - } - } - - private void DeserializeEqpEntry( Info info, byte[]? data ) - { - if( data == null || !info.EquipSlot.IsEquipment() ) - { - return; - } - - try - { - var value = Eqp.FromSlotAndBytes( info.EquipSlot, data ); - - AddIfNotDefault( MetaManipulation.Eqp( info.EquipSlot, info.PrimaryId, value ) ); - } - catch( ArgumentException ) - { } - } - - private void DeserializeEqdpEntries( Info info, byte[]? data ) - { - if( data == null ) - { - return; - } - - var num = data.Length / 5; - using var reader = new BinaryReader( new MemoryStream( data ) ); - for( var i = 0; i < num; ++i ) - { - var gr = ( GenderRace )reader.ReadUInt32(); - var byteValue = reader.ReadByte(); - if( !gr.IsValid() || !info.EquipSlot.IsEquipment() && !info.EquipSlot.IsAccessory() ) - { - continue; - } - - var value = Eqdp.FromSlotAndBits( info.EquipSlot, ( byteValue & 1 ) == 1, ( byteValue & 2 ) == 2 ); - AddIfNotDefault( MetaManipulation.Eqdp( info.EquipSlot, gr, info.PrimaryId, value ) ); - } - } - - private void DeserializeGmpEntry( Info info, byte[]? data ) - { - if( data == null ) - { - return; - } - - using var reader = new BinaryReader( new MemoryStream( data ) ); - var value = ( GmpEntry )reader.ReadUInt32(); - value.UnknownTotal = reader.ReadByte(); - AddIfNotDefault( MetaManipulation.Gmp( info.PrimaryId, value ) ); - } - - private void DeserializeEstEntries( Info info, byte[]? data ) - { - if( data == null ) - { - return; - } - - var num = data.Length / 6; - using var reader = new BinaryReader( new MemoryStream( data ) ); - for( var i = 0; i < num; ++i ) - { - var gr = ( GenderRace )reader.ReadUInt16(); - var id = reader.ReadUInt16(); - var value = reader.ReadUInt16(); - if( !gr.IsValid() - || info.PrimaryType == ObjectType.Character && info.SecondaryType != BodySlot.Face && info.SecondaryType != BodySlot.Hair - || info.PrimaryType == ObjectType.Equipment && info.EquipSlot != EquipSlot.Head && info.EquipSlot != EquipSlot.Body ) - { - continue; - } - - AddIfNotDefault( MetaManipulation.Est( info.PrimaryType, info.EquipSlot, gr, info.SecondaryType, id, value ) ); - } - } - - private void DeserializeImcEntries( Info info, byte[]? data ) - { - if( data == null ) - { - return; - } - - var num = data.Length / 6; - using var reader = new BinaryReader( new MemoryStream( data ) ); - for( var i = 0; i < num; ++i ) - { - var value = ImcFile.ImageChangeData.Read( reader ); - if( info.PrimaryType == ObjectType.Equipment || info.PrimaryType == ObjectType.Accessory ) - { - AddIfNotDefault( MetaManipulation.Imc( info.EquipSlot, info.PrimaryId, ( ushort )i, value ) ); - } - else - { - AddIfNotDefault( MetaManipulation.Imc( info.PrimaryType, info.SecondaryType, info.PrimaryId - , info.SecondaryId, ( ushort )i, value ) ); - } - } - } - - public TexToolsMeta( byte[] data ) - { - try - { - using var reader = new BinaryReader( new MemoryStream( data ) ); - Version = reader.ReadUInt32(); - FilePath = ReadNullTerminated( reader ); - var metaInfo = new Info( FilePath ); - var numHeaders = reader.ReadUInt32(); - var headerSize = reader.ReadUInt32(); - var headerStart = reader.ReadUInt32(); - reader.BaseStream.Seek( headerStart, SeekOrigin.Begin ); - - List< (MetaType type, uint offset, int size) > entries = new(); - for( var i = 0; i < numHeaders; ++i ) - { - var currentOffset = reader.BaseStream.Position; - var type = ( MetaType )reader.ReadUInt32(); - var offset = reader.ReadUInt32(); - var size = reader.ReadInt32(); - entries.Add( ( type, offset, size ) ); - reader.BaseStream.Seek( currentOffset + headerSize, SeekOrigin.Begin ); - } - - byte[]? ReadEntry( MetaType type ) - { - var idx = entries.FindIndex( t => t.type == type ); - if( idx < 0 ) - { - return null; - } - - reader.BaseStream.Seek( entries[ idx ].offset, SeekOrigin.Begin ); - return reader.ReadBytes( entries[ idx ].size ); - } - - DeserializeEqpEntry( metaInfo, ReadEntry( MetaType.Eqp ) ); - DeserializeGmpEntry( metaInfo, ReadEntry( MetaType.Gmp ) ); - DeserializeEqdpEntries( metaInfo, ReadEntry( MetaType.Eqdp ) ); - DeserializeEstEntries( metaInfo, ReadEntry( MetaType.Est ) ); - DeserializeImcEntries( metaInfo, ReadEntry( MetaType.Imc ) ); - } - catch( Exception e ) - { - FilePath = ""; - PluginLog.Error( $"Error while parsing .meta file:\n{e}" ); - } - } - - private TexToolsMeta( string filePath, uint version ) - { - FilePath = filePath; - Version = version; - } - - public static TexToolsMeta Invalid = new( string.Empty, 0 ); - - public static TexToolsMeta FromRgspFile( string filePath, byte[] data ) - { - if( data.Length != 45 && data.Length != 42 ) - { - PluginLog.Error( "Error while parsing .rgsp file:\n\tInvalid number of bytes." ); - return Invalid; - } - - using var s = new MemoryStream( data ); - using var br = new BinaryReader( s ); - var flag = br.ReadByte(); - var version = flag != 255 ? ( uint )1 : br.ReadUInt16(); - - var ret = new TexToolsMeta( filePath, version ); - - var subRace = ( SubRace )( version == 1 ? flag + 1 : br.ReadByte() + 1 ); - if( !Enum.IsDefined( typeof( SubRace ), subRace ) || subRace == SubRace.Unknown ) - { - PluginLog.Error( $"Error while parsing .rgsp file:\n\t{subRace} is not a valid SubRace." ); - return Invalid; - } - - var gender = br.ReadByte(); - if( gender != 1 && gender != 0 ) - { - PluginLog.Error( $"Error while parsing .rgsp file:\n\t{gender} is neither Male nor Female." ); - return Invalid; - } - - if( gender == 1 ) - { - ret.AddIfNotDefault( MetaManipulation.Rsp( subRace, RspAttribute.FemaleMinSize, br.ReadSingle() ) ); - ret.AddIfNotDefault( MetaManipulation.Rsp( subRace, RspAttribute.FemaleMaxSize, br.ReadSingle() ) ); - ret.AddIfNotDefault( MetaManipulation.Rsp( subRace, RspAttribute.FemaleMinTail, br.ReadSingle() ) ); - ret.AddIfNotDefault( MetaManipulation.Rsp( subRace, RspAttribute.FemaleMaxTail, br.ReadSingle() ) ); - - ret.AddIfNotDefault( MetaManipulation.Rsp( subRace, RspAttribute.BustMinX, br.ReadSingle() ) ); - ret.AddIfNotDefault( MetaManipulation.Rsp( subRace, RspAttribute.BustMinY, br.ReadSingle() ) ); - ret.AddIfNotDefault( MetaManipulation.Rsp( subRace, RspAttribute.BustMinZ, br.ReadSingle() ) ); - ret.AddIfNotDefault( MetaManipulation.Rsp( subRace, RspAttribute.BustMaxX, br.ReadSingle() ) ); - ret.AddIfNotDefault( MetaManipulation.Rsp( subRace, RspAttribute.BustMaxY, br.ReadSingle() ) ); - ret.AddIfNotDefault( MetaManipulation.Rsp( subRace, RspAttribute.BustMaxZ, br.ReadSingle() ) ); - } - else - { - ret.AddIfNotDefault( MetaManipulation.Rsp( subRace, RspAttribute.MaleMinSize, br.ReadSingle() ) ); - ret.AddIfNotDefault( MetaManipulation.Rsp( subRace, RspAttribute.MaleMaxSize, br.ReadSingle() ) ); - ret.AddIfNotDefault( MetaManipulation.Rsp( subRace, RspAttribute.MaleMinTail, br.ReadSingle() ) ); - ret.AddIfNotDefault( MetaManipulation.Rsp( subRace, RspAttribute.MaleMaxTail, br.ReadSingle() ) ); - } - - return ret; - } - } -} \ No newline at end of file diff --git a/Penumbra/Interop/CharacterBaseVTables.cs b/Penumbra/Interop/CharacterBaseVTables.cs new file mode 100644 index 00000000..40b9a588 --- /dev/null +++ b/Penumbra/Interop/CharacterBaseVTables.cs @@ -0,0 +1,24 @@ +using Dalamud.Plugin.Services; +using Dalamud.Utility.Signatures; +using OtterGui.Services; +using Penumbra.GameData; + +namespace Penumbra.Interop; + +public sealed unsafe class CharacterBaseVTables : IService +{ + [Signature(Sigs.HumanVTable, ScanType = ScanType.StaticAddress)] + public readonly nint* HumanVTable = null!; + + [Signature(Sigs.WeaponVTable, ScanType = ScanType.StaticAddress)] + public readonly nint* WeaponVTable = null!; + + [Signature(Sigs.DemiHumanVTable, ScanType = ScanType.StaticAddress)] + public readonly nint* DemiHumanVTable = null!; + + [Signature(Sigs.MonsterVTable, ScanType = ScanType.StaticAddress)] + public readonly nint* MonsterVTable = null!; + + public CharacterBaseVTables(IGameInteropProvider interop) + => interop.InitializeFromAttributes(this); +} diff --git a/Penumbra/Interop/CloudApi.cs b/Penumbra/Interop/CloudApi.cs new file mode 100644 index 00000000..603d4c9f --- /dev/null +++ b/Penumbra/Interop/CloudApi.cs @@ -0,0 +1,47 @@ +namespace Penumbra.Interop; + +public static unsafe partial class CloudApi +{ + private const int CfSyncRootInfoBasic = 0; + + /// Determines whether a file or directory is cloud-synced using OneDrive or other providers that use the Cloud API. + /// Can be expensive. Callers should cache the result when relevant. + public static bool IsCloudSynced(string path) + { + var buffer = stackalloc long[1]; + int hr; + uint length; + try + { + hr = CfGetSyncRootInfoByPath(path, CfSyncRootInfoBasic, buffer, sizeof(long), out length); + } + catch (DllNotFoundException) + { + Penumbra.Log.Debug($"{nameof(CfGetSyncRootInfoByPath)} threw DllNotFoundException"); + return false; + } + catch (EntryPointNotFoundException) + { + Penumbra.Log.Debug($"{nameof(CfGetSyncRootInfoByPath)} threw EntryPointNotFoundException"); + return false; + } + + Penumbra.Log.Debug($"{nameof(CfGetSyncRootInfoByPath)} returned HRESULT 0x{hr:X8}"); + if (hr < 0) + return false; + + if (length != sizeof(long)) + { + Penumbra.Log.Debug($"Expected {nameof(CfGetSyncRootInfoByPath)} to return {sizeof(long)} bytes, got {length} bytes"); + return false; + } + + Penumbra.Log.Debug($"{nameof(CfGetSyncRootInfoByPath)} returned {{ SyncRootFileId = 0x{*buffer:X16} }}"); + + return true; + } + + [LibraryImport("cldapi.dll", StringMarshalling = StringMarshalling.Utf16)] + private static partial int CfGetSyncRootInfoByPath(string filePath, int infoClass, void* infoBuffer, uint infoBufferLength, + out uint returnedLength); +} diff --git a/Penumbra/Interop/GameState.cs b/Penumbra/Interop/GameState.cs new file mode 100644 index 00000000..b5171244 --- /dev/null +++ b/Penumbra/Interop/GameState.cs @@ -0,0 +1,135 @@ +using FFXIVClientStructs.FFXIV.Client.Game.Object; +using OtterGui.Services; +using Penumbra.Api.Enums; +using Penumbra.Collections; +using Penumbra.Interop.PathResolving; +using Penumbra.String.Classes; + +namespace Penumbra.Interop; + +public class GameState : IService +{ + #region Last Game Object + + private readonly ThreadLocal> _lastGameObject = new(() => new Queue()); + public readonly ThreadLocal CharacterAssociated = new(() => false); + + public nint LastGameObject + => _lastGameObject.IsValueCreated && _lastGameObject.Value!.Count > 0 ? _lastGameObject.Value.Peek() : nint.Zero; + + [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] + public unsafe void QueueGameObject(GameObject* gameObject) + => QueueGameObject((nint)gameObject); + + [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] + public void QueueGameObject(nint gameObject) + => _lastGameObject.Value!.Enqueue(gameObject); + + [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] + public void DequeueGameObject() + => _lastGameObject.Value!.TryDequeue(out _); + + #endregion + + #region Animation Data + + private readonly ThreadLocal _animationLoadData = new(() => ResolveData.Invalid, true); + + public ResolveData AnimationData + => _animationLoadData.Value; + + [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] + public ResolveData SetAnimationData(ResolveData data) + { + var old = _animationLoadData.Value; + _animationLoadData.Value = data; + return old; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] + public void RestoreAnimationData(ResolveData old) + => _animationLoadData.Value = old; + + public readonly ThreadLocal InLoadActionTmb = new(() => false); + public readonly ThreadLocal SkipTmbCache = new(() => false); + + #endregion + + #region Sound Data + + private readonly ThreadLocal _characterSoundData = new(() => ResolveData.Invalid, true); + + [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] + public ResolveData SetSoundData(ResolveData data) + { + var old = _characterSoundData.Value; + _characterSoundData.Value = data; + return old; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] + public void RestoreSoundData(ResolveData old) + => _characterSoundData.Value = old; + + #endregion + + #region Subfiles + + public readonly ThreadLocal MtrlData = new(() => ResolveData.Invalid); + public readonly ThreadLocal AvfxData = new(() => ResolveData.Invalid); + + public readonly ConcurrentDictionary SubFileCollection = new(); + + public ResolveData LoadSubFileHelper(nint resourceHandle) + { + if (resourceHandle == nint.Zero) + return ResolveData.Invalid; + + return SubFileCollection.TryGetValue(resourceHandle, out var c) ? c : ResolveData.Invalid; + } + + #endregion + + /// Return the correct resolve data from the stored data. + public unsafe bool HandleFiles(CollectionResolver resolver, ResourceType type, Utf8GamePath _, out ResolveData resolveData) + { + switch (type) + { + case ResourceType.Scd: + if (_characterSoundData is { IsValueCreated: true, Value.Valid: true }) + { + resolveData = _characterSoundData.Value; + return true; + } + + if (_animationLoadData is { IsValueCreated: true, Value.Valid: true }) + { + resolveData = _animationLoadData.Value; + return true; + } + + break; + case ResourceType.Tmb: + case ResourceType.Pap: + case ResourceType.Avfx: + case ResourceType.Atex: + if (_animationLoadData is { IsValueCreated: true, Value.Valid: true }) + { + resolveData = _animationLoadData.Value; + return true; + } + + break; + } + + var lastObj = LastGameObject; + if (lastObj != nint.Zero) + { + resolveData = resolver.IdentifyCollection((GameObject*)lastObj, true); + return true; + } + + resolveData = ResolveData.Invalid; + return false; + } +} diff --git a/Penumbra/Interop/Hooks/Animation/ApricotListenerSoundPlay.cs b/Penumbra/Interop/Hooks/Animation/ApricotListenerSoundPlay.cs new file mode 100644 index 00000000..8838971c --- /dev/null +++ b/Penumbra/Interop/Hooks/Animation/ApricotListenerSoundPlay.cs @@ -0,0 +1,77 @@ +using FFXIVClientStructs.FFXIV.Client.Game.Object; +using FFXIVClientStructs.FFXIV.Client.Graphics.Scene; +using OtterGui.Services; +using Penumbra.Collections; +using Penumbra.CrashHandler.Buffers; +using Penumbra.GameData; +using Penumbra.Interop.PathResolving; +using Penumbra.Services; + +namespace Penumbra.Interop.Hooks.Animation; + +/// Called for some sound effects caused by animations or VFX. +/// Actual function got inlined. +public sealed unsafe class ApricotListenerSoundPlayCaller : FastHook +{ + private readonly GameState _state; + private readonly CollectionResolver _collectionResolver; + private readonly CrashHandlerService _crashHandler; + + public ApricotListenerSoundPlayCaller(HookManager hooks, GameState state, CollectionResolver collectionResolver, + CrashHandlerService crashHandler) + { + _state = state; + _collectionResolver = collectionResolver; + _crashHandler = crashHandler; + Task = hooks.CreateHook("Apricot Listener Sound Play Caller", Sigs.ApricotListenerSoundPlayCaller, Detour, + !HookOverrides.Instance.Animation.ApricotListenerSoundPlayCaller); + } + + public delegate nint Delegate(nint a1, nint a2, float a3); + + [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] + private nint Detour(nint a1, nint unused, float timeOffset) + { + // Short-circuiting and sanity checks done by game. + var playTime = a1 == nint.Zero ? -1 : *(float*)(a1 + VolatileOffsets.ApricotListenerSoundPlayCaller.PlayTimeOffset); + if (playTime < 0) + return Task.Result.Original(a1, unused, timeOffset); + + var someIntermediate = *(nint*)(a1 + VolatileOffsets.ApricotListenerSoundPlayCaller.SomeIntermediate); + var flags = someIntermediate == nint.Zero + ? (ushort)0 + : *(ushort*)(someIntermediate + VolatileOffsets.ApricotListenerSoundPlayCaller.Flags); + if (((flags >> VolatileOffsets.ApricotListenerSoundPlayCaller.BitShift) & 1) == 0) + return Task.Result.Original(a1, unused, timeOffset); + + Penumbra.Log.Excessive( + $"[Apricot Listener Sound Play Caller] Invoked on 0x{a1:X} with {unused}, {timeOffset}."); + // Fetch the IInstanceListenner (sixth argument to inlined call of SoundPlay) + var apricotIInstanceListenner = *(nint*)(someIntermediate + VolatileOffsets.ApricotListenerSoundPlayCaller.IInstanceListenner); + if (apricotIInstanceListenner == nint.Zero) + return Task.Result.Original(a1, unused, timeOffset); + + // In some cases we can obtain the associated caster via vfunc 1. + var newData = ResolveData.Invalid; + var gameObject = (*(delegate* unmanaged**)apricotIInstanceListenner)[VolatileOffsets.ApricotListenerSoundPlayCaller.CasterVFunc](apricotIInstanceListenner); + if (gameObject != null) + { + newData = _collectionResolver.IdentifyCollection(gameObject, true); + } + else + { + // for VfxListenner we can obtain the associated draw object as its first member, + // if the object has different type, drawObject will contain other values or garbage, + // but only be used in a dictionary pointer lookup, so this does not hurt. + var drawObject = ((DrawObject**)apricotIInstanceListenner)[1]; + if (drawObject != null) + newData = _collectionResolver.IdentifyCollection(drawObject, true); + } + + _crashHandler.LogAnimation(newData.AssociatedGameObject, newData.ModCollection, AnimationInvocationType.ApricotSoundPlay); + var last = _state.SetAnimationData(newData); + var ret = Task.Result.Original(a1, unused, timeOffset); + _state.RestoreAnimationData(last); + return ret; + } +} diff --git a/Penumbra/Interop/Hooks/Animation/CharacterBaseLoadAnimation.cs b/Penumbra/Interop/Hooks/Animation/CharacterBaseLoadAnimation.cs new file mode 100644 index 00000000..22609afc --- /dev/null +++ b/Penumbra/Interop/Hooks/Animation/CharacterBaseLoadAnimation.cs @@ -0,0 +1,48 @@ +using FFXIVClientStructs.FFXIV.Client.Game.Object; +using FFXIVClientStructs.FFXIV.Client.Graphics.Scene; +using OtterGui.Services; +using Penumbra.CrashHandler.Buffers; +using Penumbra.GameData; +using Penumbra.Interop.PathResolving; +using Penumbra.Services; + +namespace Penumbra.Interop.Hooks.Animation; + +/// +/// Probably used when the base idle animation gets loaded. +/// Make it aware of the correct collection to load the correct pap files. +/// +public sealed unsafe class CharacterBaseLoadAnimation : FastHook +{ + private readonly GameState _state; + private readonly CollectionResolver _collectionResolver; + private readonly DrawObjectState _drawObjectState; + private readonly CrashHandlerService _crashHandler; + + public CharacterBaseLoadAnimation(HookManager hooks, GameState state, CollectionResolver collectionResolver, + DrawObjectState drawObjectState, CrashHandlerService crashHandler) + { + _state = state; + _collectionResolver = collectionResolver; + _drawObjectState = drawObjectState; + _crashHandler = crashHandler; + Task = hooks.CreateHook("CharacterBase Load Animation", Sigs.CharacterBaseLoadAnimation, Detour, + !HookOverrides.Instance.Animation.CharacterBaseLoadAnimation); + } + + public delegate void Delegate(DrawObject* drawBase); + + [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] + private void Detour(DrawObject* drawObject) + { + var lastObj = _state.LastGameObject; + if (lastObj == nint.Zero && _drawObjectState.TryGetValue((nint)drawObject, out var p)) + lastObj = p.Item1; + var data = _collectionResolver.IdentifyCollection((GameObject*)lastObj, true); + var last = _state.SetAnimationData(data); + _crashHandler.LogAnimation(data.AssociatedGameObject, data.ModCollection, AnimationInvocationType.CharacterBaseLoadAnimation); + Penumbra.Log.Excessive($"[CharacterBase Load Animation] Invoked on {(nint)drawObject:X}"); + Task.Result.Original(drawObject); + _state.RestoreAnimationData(last); + } +} diff --git a/Penumbra/Interop/Hooks/Animation/Dismount.cs b/Penumbra/Interop/Hooks/Animation/Dismount.cs new file mode 100644 index 00000000..17151083 --- /dev/null +++ b/Penumbra/Interop/Hooks/Animation/Dismount.cs @@ -0,0 +1,45 @@ +using FFXIVClientStructs.FFXIV.Client.Game.Character; +using FFXIVClientStructs.FFXIV.Client.Game.Object; +using OtterGui.Services; +using Penumbra.GameData; +using Penumbra.Interop.PathResolving; + +namespace Penumbra.Interop.Hooks.Animation; + +/// Called for some animations when dismounting. +public sealed unsafe class Dismount : FastHook +{ + private readonly GameState _state; + private readonly CollectionResolver _collectionResolver; + + public Dismount(HookManager hooks, GameState state, CollectionResolver collectionResolver) + { + _state = state; + _collectionResolver = collectionResolver; + Task = hooks.CreateHook("Dismount", Sigs.Dismount, Detour, !HookOverrides.Instance.Animation.Dismount); + } + + public delegate void Delegate(MountContainer* a1, nint a2); + + [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] + private void Detour(MountContainer* a1, nint a2) + { + Penumbra.Log.Excessive($"[Dismount] Invoked on 0x{(nint)a1:X} with {a2:X}."); + if (a1 == null) + { + Task.Result.Original(a1, a2); + return; + } + + var gameObject = a1->OwnerObject; + if (gameObject == null) + { + Task.Result.Original(a1, a2); + return; + } + + var last = _state.SetAnimationData(_collectionResolver.IdentifyCollection((GameObject*) gameObject, true)); + Task.Result.Original(a1, a2); + _state.RestoreAnimationData(last); + } +} diff --git a/Penumbra/Interop/Hooks/Animation/GetCachedScheduleResource.cs b/Penumbra/Interop/Hooks/Animation/GetCachedScheduleResource.cs new file mode 100644 index 00000000..6ce1f899 --- /dev/null +++ b/Penumbra/Interop/Hooks/Animation/GetCachedScheduleResource.cs @@ -0,0 +1,53 @@ +using FFXIVClientStructs.FFXIV.Client.System.Scheduler.Resource; +using JetBrains.Annotations; +using OtterGui.Services; +using Penumbra.GameData; +using Penumbra.Interop.Structs; +using Penumbra.String; + +namespace Penumbra.Interop.Hooks.Animation; + +/// Load a cached TMB resource from SchedulerResourceManagement. +public sealed unsafe class GetCachedScheduleResource : FastHook +{ + private readonly GameState _state; + + public GetCachedScheduleResource(HookManager hooks, GameState state) + { + _state = state; + Task = hooks.CreateHook("Get Cached Schedule Resource", Sigs.GetCachedScheduleResource, Detour, + !HookOverrides.Instance.Animation.GetCachedScheduleResource); + } + + public delegate SchedulerResource* Delegate(SchedulerResourceManagement* a, ScheduleResourceLoadData* b, byte useMap); + + [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] + private SchedulerResource* Detour(SchedulerResourceManagement* a, ScheduleResourceLoadData* b, byte c) + { + if (_state.SkipTmbCache.Value) + { + Penumbra.Log.Verbose( + $"[GetCachedScheduleResource] Called with 0x{(ulong)a:X}, {b->Id}, {new CiByteString(b->Path, MetaDataComputation.None)}, {c} from LoadActionTmb with forced skipping of cache, returning NULL."); + return null; + } + + var ret = Task.Result.Original(a, b, c); + Penumbra.Log.Excessive( + $"[GetCachedScheduleResource] Called with 0x{(ulong)a:X}, {b->Id}, {new CiByteString(b->Path, MetaDataComputation.None)}, {c}, returning 0x{(ulong)ret:X} ({(ret != null && Resource(ret) != null ? Resource(ret)->FileName().ToString() : "No Path")})."); + return ret; + } + + public struct ScheduleResourceLoadData + { + [UsedImplicitly] + public byte* Path; + + [UsedImplicitly] + public uint Id; + } + + + // #TODO: remove when fixed in CS. + public static ResourceHandle* Resource(SchedulerResource* r) + => ((ResourceHandle**)r)[3]; +} diff --git a/Penumbra/Interop/Hooks/Animation/LoadActionTmb.cs b/Penumbra/Interop/Hooks/Animation/LoadActionTmb.cs new file mode 100644 index 00000000..457465d2 --- /dev/null +++ b/Penumbra/Interop/Hooks/Animation/LoadActionTmb.cs @@ -0,0 +1,55 @@ +using FFXIVClientStructs.FFXIV.Client.System.Scheduler.Resource; +using OtterGui.Services; +using Penumbra.GameData; +using Penumbra.Interop.Services; +using Penumbra.String; + +namespace Penumbra.Interop.Hooks.Animation; + +/// Load a Action TMB. +public sealed unsafe class LoadActionTmb : FastHook +{ + private readonly GameState _state; + private readonly SchedulerResourceManagementService _scheduler; + + public LoadActionTmb(HookManager hooks, GameState state, SchedulerResourceManagementService scheduler) + { + _state = state; + _scheduler = scheduler; + Task = hooks.CreateHook("Load Action TMB", Sigs.LoadActionTmb, Detour, !HookOverrides.Instance.Animation.LoadActionTmb); + } + + public delegate SchedulerResource* Delegate(SchedulerResourceManagement* scheduler, + GetCachedScheduleResource.ScheduleResourceLoadData* loadData, nint b, byte c, byte d, byte e); + + + [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] + private SchedulerResource* Detour(SchedulerResourceManagement* scheduler, GetCachedScheduleResource.ScheduleResourceLoadData* loadData, + nint b, byte c, byte d, byte e) + { + _state.InLoadActionTmb.Value = true; + SchedulerResource* ret; + if (ShouldSkipCache(loadData)) + { + _state.SkipTmbCache.Value = true; + ret = Task.Result.Original(scheduler, loadData, b, c, d, 1); + Penumbra.Log.Verbose( + $"[LoadActionTMB] Called with 0x{(ulong)scheduler:X}, {loadData->Id}, {new CiByteString(loadData->Path, MetaDataComputation.None)}, 0x{b:X}, {c}, {d}, {e}, forced no-cache use, returned 0x{(ulong)ret:X} ({(ret != null && GetCachedScheduleResource.Resource(ret) != null ? GetCachedScheduleResource.Resource(ret)->FileName().ToString() : "No Path")})."); + _state.SkipTmbCache.Value = false; + } + else + { + ret = Task.Result.Original(scheduler, loadData, b, c, d, e); + Penumbra.Log.Excessive( + $"[LoadActionTMB] Called with 0x{(ulong)scheduler:X}, {loadData->Id}, {new CiByteString(loadData->Path)}, 0x{b:X}, {c}, {d}, {e}, returned 0x{(ulong)ret:X} ({(ret != null && GetCachedScheduleResource.Resource(ret) != null ? GetCachedScheduleResource.Resource(ret)->FileName().ToString() : "No Path")})."); + } + + _state.InLoadActionTmb.Value = false; + + return ret; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] + private bool ShouldSkipCache(GetCachedScheduleResource.ScheduleResourceLoadData* loadData) + => _scheduler.Contains(loadData->Id); +} diff --git a/Penumbra/Interop/Hooks/Animation/LoadAreaVfx.cs b/Penumbra/Interop/Hooks/Animation/LoadAreaVfx.cs new file mode 100644 index 00000000..29afd4ea --- /dev/null +++ b/Penumbra/Interop/Hooks/Animation/LoadAreaVfx.cs @@ -0,0 +1,43 @@ +using FFXIVClientStructs.FFXIV.Client.Game.Object; +using OtterGui.Services; +using Penumbra.Collections; +using Penumbra.CrashHandler.Buffers; +using Penumbra.GameData; +using Penumbra.Interop.PathResolving; +using Penumbra.Services; + +namespace Penumbra.Interop.Hooks.Animation; + +/// Load a ground-based area VFX. +public sealed unsafe class LoadAreaVfx : FastHook +{ + private readonly GameState _state; + private readonly CollectionResolver _collectionResolver; + private readonly CrashHandlerService _crashHandler; + + public LoadAreaVfx(HookManager hooks, GameState state, CollectionResolver collectionResolver, CrashHandlerService crashHandler) + { + _state = state; + _collectionResolver = collectionResolver; + _crashHandler = crashHandler; + Task = hooks.CreateHook("Load Area VFX", Sigs.LoadAreaVfx, Detour, !HookOverrides.Instance.Animation.LoadAreaVfx); + } + + public delegate nint Delegate(uint vfxId, float* pos, GameObject* caster, float unk1, float unk2, byte unk3); + + [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] + private nint Detour(uint vfxId, float* pos, GameObject* caster, float unk1, float unk2, byte unk3) + { + var newData = caster != null + ? _collectionResolver.IdentifyCollection(caster, true) + : ResolveData.Invalid; + + var last = _state.SetAnimationData(newData); + _crashHandler.LogAnimation(newData.AssociatedGameObject, newData.ModCollection, AnimationInvocationType.LoadAreaVfx); + var ret = Task.Result.Original(vfxId, pos, caster, unk1, unk2, unk3); + Penumbra.Log.Excessive( + $"[Load Area VFX] Invoked with {vfxId}, [{pos[0]} {pos[1]} {pos[2]}], 0x{(nint)caster:X}, {unk1}, {unk2}, {unk3} -> 0x{ret:X}."); + _state.RestoreAnimationData(last); + return ret; + } +} diff --git a/Penumbra/Interop/Hooks/Animation/LoadCharacterSound.cs b/Penumbra/Interop/Hooks/Animation/LoadCharacterSound.cs new file mode 100644 index 00000000..91b70ede --- /dev/null +++ b/Penumbra/Interop/Hooks/Animation/LoadCharacterSound.cs @@ -0,0 +1,41 @@ +using FFXIVClientStructs.FFXIV.Client.Game.Character; +using FFXIVClientStructs.FFXIV.Client.Game.Object; +using OtterGui.Services; +using Penumbra.CrashHandler.Buffers; +using Penumbra.Interop.PathResolving; +using Penumbra.Services; + +namespace Penumbra.Interop.Hooks.Animation; + +/// Characters load some of their voice lines or whatever with this function. +public sealed unsafe class LoadCharacterSound : FastHook +{ + private readonly GameState _state; + private readonly CollectionResolver _collectionResolver; + private readonly CrashHandlerService _crashHandler; + + public LoadCharacterSound(HookManager hooks, GameState state, CollectionResolver collectionResolver, CrashHandlerService crashHandler) + { + _state = state; + _collectionResolver = collectionResolver; + _crashHandler = crashHandler; + Task = hooks.CreateHook("Load Character Sound", (nint)VfxContainer.MemberFunctionPointers.LoadCharacterSound, Detour, + !HookOverrides.Instance.Animation.LoadCharacterSound); + } + + public delegate nint Delegate(VfxContainer* container, int unk1, int unk2, nint unk3, ulong unk4, int unk5, int unk6, ulong unk7); + + [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] + private nint Detour(VfxContainer* container, int unk1, int unk2, nint unk3, ulong unk4, int unk5, int unk6, ulong unk7) + { + var character = (GameObject*)container->OwnerObject; + var newData = _collectionResolver.IdentifyCollection(character, true); + var last = _state.SetSoundData(newData); + _crashHandler.LogAnimation(newData.AssociatedGameObject, newData.ModCollection, AnimationInvocationType.LoadCharacterSound); + var ret = Task.Result.Original(container, unk1, unk2, unk3, unk4, unk5, unk6, unk7); + Penumbra.Log.Excessive( + $"[Load Character Sound] Invoked with {(nint)container:X} {unk1} {unk2} {unk3} {unk4} {unk5} {unk6} {unk7} -> {ret}."); + _state.RestoreSoundData(last); + return ret; + } +} diff --git a/Penumbra/Interop/Hooks/Animation/LoadCharacterVfx.cs b/Penumbra/Interop/Hooks/Animation/LoadCharacterVfx.cs new file mode 100644 index 00000000..9a57ca12 --- /dev/null +++ b/Penumbra/Interop/Hooks/Animation/LoadCharacterVfx.cs @@ -0,0 +1,69 @@ +using FFXIVClientStructs.FFXIV.Client.Game.Object; +using OtterGui.Services; +using Penumbra.Collections; +using Penumbra.CrashHandler.Buffers; +using Penumbra.GameData; +using Penumbra.GameData.Interop; +using Penumbra.Interop.PathResolving; +using Penumbra.Interop.Structs; +using Penumbra.Services; +using Penumbra.String; + +namespace Penumbra.Interop.Hooks.Animation; + +/// Load a VFX specifically for a character. +public sealed unsafe class LoadCharacterVfx : FastHook +{ + private readonly GameState _state; + private readonly CollectionResolver _collectionResolver; + private readonly ObjectManager _objects; + private readonly CrashHandlerService _crashHandler; + + public LoadCharacterVfx(HookManager hooks, GameState state, CollectionResolver collectionResolver, ObjectManager objects, + CrashHandlerService crashHandler) + { + _state = state; + _collectionResolver = collectionResolver; + _objects = objects; + _crashHandler = crashHandler; + Task = hooks.CreateHook("Load Character VFX", Sigs.LoadCharacterVfx, Detour, !HookOverrides.Instance.Animation.LoadCharacterVfx); + } + + public delegate nint Delegate(byte* vfxPath, VfxParams* vfxParams, byte unk1, byte unk2, float unk3, int unk4); + + [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] + private nint Detour(byte* vfxPath, VfxParams* vfxParams, byte unk1, byte unk2, float unk3, int unk4) + { + var newData = ResolveData.Invalid; + if (vfxParams != null && vfxParams->GameObjectId != unchecked((uint)-1)) + { + var obj = vfxParams->GameObjectType switch + { + 0 => _objects.ById(vfxParams->GameObjectId), + 2 => _objects[(int)vfxParams->GameObjectId], + 4 => GetOwnedObject(vfxParams->GameObjectId), + _ => Actor.Null, + }; + newData = obj.Valid + ? _collectionResolver.IdentifyCollection((GameObject*)obj.Address, true) + : ResolveData.Invalid; + } + + var last = _state.SetAnimationData(newData); + _crashHandler.LogAnimation(newData.AssociatedGameObject, newData.ModCollection, AnimationInvocationType.LoadCharacterVfx); + var ret = Task.Result.Original(vfxPath, vfxParams, unk1, unk2, unk3, unk4); + Penumbra.Log.Excessive( + $"[Load Character VFX] Invoked with {new ByteString(vfxPath)}, 0x{vfxParams->GameObjectId:X}, {vfxParams->TargetCount}, {unk1}, {unk2}, {unk3}, {unk4} -> 0x{ret:X}."); + _state.RestoreAnimationData(last); + return ret; + } + + /// Search an object by its id, then get its minion/mount/ornament. + private Actor GetOwnedObject(uint id) + { + var owner = _objects.ById(id); + return !owner.Valid + ? Actor.Null + : _objects[owner.Index.Index + 1]; + } +} diff --git a/Penumbra/Interop/Hooks/Animation/LoadTimelineResources.cs b/Penumbra/Interop/Hooks/Animation/LoadTimelineResources.cs new file mode 100644 index 00000000..cdd82b95 --- /dev/null +++ b/Penumbra/Interop/Hooks/Animation/LoadTimelineResources.cs @@ -0,0 +1,81 @@ +using Dalamud.Game.ClientState.Conditions; +using Dalamud.Plugin.Services; +using FFXIVClientStructs.FFXIV.Client.Game.Object; +using FFXIVClientStructs.FFXIV.Client.System.Scheduler.Base; +using OtterGui.Services; +using Penumbra.Collections; +using Penumbra.GameData; +using Penumbra.GameData.Interop; +using Penumbra.Interop.PathResolving; +using Penumbra.Services; + +namespace Penumbra.Interop.Hooks.Animation; + +/// +/// The timeline object loads the requested .tmb and .pap files. The .tmb files load the respective .avfx files. +/// We can obtain the associated game object from the timelines 28'th vfunc and use that to apply the correct collection. +/// +public sealed unsafe class LoadTimelineResources : FastHook +{ + private readonly GameState _state; + private readonly CollectionResolver _collectionResolver; + private readonly ICondition _conditions; + private readonly ObjectManager _objects; + private readonly CrashHandlerService _crashHandler; + + public LoadTimelineResources(HookManager hooks, GameState state, CollectionResolver collectionResolver, ICondition conditions, + ObjectManager objects, CrashHandlerService crashHandler) + { + _state = state; + _collectionResolver = collectionResolver; + _conditions = conditions; + _objects = objects; + _crashHandler = crashHandler; + Task = hooks.CreateHook("Load Timeline Resources", Sigs.LoadTimelineResources, Detour, !HookOverrides.Instance.Animation.LoadTimelineResources); + } + + public delegate ulong Delegate(SchedulerTimeline* timeline); + + [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] + private ulong Detour(SchedulerTimeline* timeline) + { + Penumbra.Log.Excessive($"[Load Timeline Resources] Invoked on {(nint)timeline:X}."); + // Do not check timeline loading in cutscenes. + if (_conditions[ConditionFlag.OccupiedInCutSceneEvent] || _conditions[ConditionFlag.WatchingCutscene78]) + return Task.Result.Original(timeline); + + var newData = GetDataFromTimeline(_objects, _collectionResolver, timeline); + var last = _state.SetAnimationData(newData); + +#if false + // This is called far too often and spams the log too much. + _crashHandler.LogAnimation(newData.AssociatedGameObject, newData.ModCollection, AnimationInvocationType.LoadTimelineResources); +#endif + var ret = Task.Result.Original(timeline); + _state.RestoreAnimationData(last); + return ret; + } + + /// Use timelines vfuncs to obtain the associated game object. + public static ResolveData GetDataFromTimeline(ObjectManager objects, CollectionResolver resolver, SchedulerTimeline* timeline) + { + try + { + if (timeline != null) + { + var idx = timeline->GetOwningGameObjectIndex(); + if (idx >= 0 && idx < objects.TotalCount) + { + var obj = objects[idx]; + return obj.Valid ? resolver.IdentifyCollection(obj.AsObject, true) : ResolveData.Invalid; + } + } + } + catch (Exception e) + { + Penumbra.Log.Error($"Error getting timeline data for 0x{(nint)timeline:X}:\n{e}"); + } + + return ResolveData.Invalid; + } +} diff --git a/Penumbra/Interop/Hooks/Animation/PlayFootstep.cs b/Penumbra/Interop/Hooks/Animation/PlayFootstep.cs new file mode 100644 index 00000000..858357c8 --- /dev/null +++ b/Penumbra/Interop/Hooks/Animation/PlayFootstep.cs @@ -0,0 +1,30 @@ +using FFXIVClientStructs.FFXIV.Client.Game.Object; +using OtterGui.Services; +using Penumbra.GameData; +using Penumbra.Interop.PathResolving; + +namespace Penumbra.Interop.Hooks.Animation; + +public sealed unsafe class PlayFootstep : FastHook +{ + private readonly GameState _state; + private readonly CollectionResolver _collectionResolver; + + public PlayFootstep(HookManager hooks, GameState state, CollectionResolver collectionResolver) + { + _state = state; + _collectionResolver = collectionResolver; + Task = hooks.CreateHook("Play Footstep", Sigs.FootStepSound, Detour, !HookOverrides.Instance.Animation.PlayFootstep); + } + + public delegate void Delegate(GameObject* gameObject, int id, int unk); + + [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] + private void Detour(GameObject* gameObject, int id, int unk) + { + Penumbra.Log.Excessive($"[Play Footstep] Invoked on 0x{(nint)gameObject:X} with {id}, {unk}."); + var last = _state.SetAnimationData(_collectionResolver.IdentifyCollection(gameObject, true)); + Task.Result.Original(gameObject, id, unk); + _state.RestoreAnimationData(last); + } +} diff --git a/Penumbra/Interop/Hooks/Animation/ScheduleClipUpdate.cs b/Penumbra/Interop/Hooks/Animation/ScheduleClipUpdate.cs new file mode 100644 index 00000000..dfbc615a --- /dev/null +++ b/Penumbra/Interop/Hooks/Animation/ScheduleClipUpdate.cs @@ -0,0 +1,41 @@ +using OtterGui.Services; +using Penumbra.CrashHandler.Buffers; +using Penumbra.GameData; +using Penumbra.GameData.Interop; +using Penumbra.Interop.PathResolving; +using Penumbra.Interop.Structs; +using Penumbra.Services; + +namespace Penumbra.Interop.Hooks.Animation; + +/// Called when some action timelines update. +public sealed unsafe class ScheduleClipUpdate : FastHook +{ + private readonly GameState _state; + private readonly CollectionResolver _collectionResolver; + private readonly ObjectManager _objects; + private readonly CrashHandlerService _crashHandler; + + public ScheduleClipUpdate(HookManager hooks, GameState state, CollectionResolver collectionResolver, ObjectManager objects, + CrashHandlerService crashHandler) + { + _state = state; + _collectionResolver = collectionResolver; + _objects = objects; + _crashHandler = crashHandler; + Task = hooks.CreateHook("Schedule Clip Update", Sigs.ScheduleClipUpdate, Detour, !HookOverrides.Instance.Animation.ScheduleClipUpdate); + } + + public delegate void Delegate(ClipScheduler* x); + + [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] + private void Detour(ClipScheduler* clipScheduler) + { + Penumbra.Log.Excessive($"[Schedule Clip Update] Invoked on {(nint)clipScheduler:X}."); + var newData = LoadTimelineResources.GetDataFromTimeline(_objects, _collectionResolver, clipScheduler->SchedulerTimeline); + var last = _state.SetAnimationData(newData); + _crashHandler.LogAnimation(newData.AssociatedGameObject, newData.ModCollection, AnimationInvocationType.ScheduleClipUpdate); + Task.Result.Original(clipScheduler); + _state.RestoreAnimationData(last); + } +} diff --git a/Penumbra/Interop/Hooks/Animation/SomeActionLoad.cs b/Penumbra/Interop/Hooks/Animation/SomeActionLoad.cs new file mode 100644 index 00000000..e1751261 --- /dev/null +++ b/Penumbra/Interop/Hooks/Animation/SomeActionLoad.cs @@ -0,0 +1,38 @@ +using FFXIVClientStructs.FFXIV.Client.Game.Character; +using FFXIVClientStructs.FFXIV.Client.Game.Object; +using OtterGui.Services; +using Penumbra.CrashHandler.Buffers; +using Penumbra.GameData; +using Penumbra.Interop.PathResolving; +using Penumbra.Services; + +namespace Penumbra.Interop.Hooks.Animation; + +/// Seems to load character actions when zoning or changing class, maybe. +public sealed unsafe class SomeActionLoad : FastHook +{ + private readonly GameState _state; + private readonly CollectionResolver _collectionResolver; + private readonly CrashHandlerService _crashHandler; + + public SomeActionLoad(HookManager hooks, GameState state, CollectionResolver collectionResolver, CrashHandlerService crashHandler) + { + _state = state; + _collectionResolver = collectionResolver; + _crashHandler = crashHandler; + Task = hooks.CreateHook("Some Action Load", Sigs.LoadSomeAction, Detour, !HookOverrides.Instance.Animation.SomeActionLoad); + } + + public delegate void Delegate(TimelineContainer* timelineManager); + + [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] + private void Detour(TimelineContainer* timelineManager) + { + var newData = _collectionResolver.IdentifyCollection((GameObject*)timelineManager->OwnerObject, true); + var last = _state.SetAnimationData(newData); + Penumbra.Log.Excessive($"[Some Action Load] Invoked on 0x{(nint)timelineManager:X}."); + _crashHandler.LogAnimation(newData.AssociatedGameObject, newData.ModCollection, AnimationInvocationType.ActionLoad); + Task.Result.Original(timelineManager); + _state.RestoreAnimationData(last); + } +} diff --git a/Penumbra/Interop/Hooks/Animation/SomeMountAnimation.cs b/Penumbra/Interop/Hooks/Animation/SomeMountAnimation.cs new file mode 100644 index 00000000..75f1240a --- /dev/null +++ b/Penumbra/Interop/Hooks/Animation/SomeMountAnimation.cs @@ -0,0 +1,31 @@ +using FFXIVClientStructs.FFXIV.Client.Graphics.Scene; +using OtterGui.Services; +using Penumbra.GameData; +using Penumbra.Interop.PathResolving; + +namespace Penumbra.Interop.Hooks.Animation; + +/// Called for some animations when mounted or mounting. +public sealed unsafe class SomeMountAnimation : FastHook +{ + private readonly GameState _state; + private readonly CollectionResolver _collectionResolver; + + public SomeMountAnimation(HookManager hooks, GameState state, CollectionResolver collectionResolver) + { + _state = state; + _collectionResolver = collectionResolver; + Task = hooks.CreateHook("Some Mount Animation", Sigs.UnkMountAnimation, Detour, !HookOverrides.Instance.Animation.SomeMountAnimation); + } + + public delegate void Delegate(DrawObject* drawObject, uint unk1, byte unk2, uint unk3); + + [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] + private void Detour(DrawObject* drawObject, uint unk1, byte unk2, uint unk3) + { + Penumbra.Log.Excessive($"[Some Mount Animation] Invoked on {(nint)drawObject:X} with {unk1}, {unk2}, {unk3}."); + var last = _state.SetAnimationData(_collectionResolver.IdentifyCollection(drawObject, true)); + Task.Result.Original(drawObject, unk1, unk2, unk3); + _state.RestoreAnimationData(last); + } +} diff --git a/Penumbra/Interop/Hooks/Animation/SomePapLoad.cs b/Penumbra/Interop/Hooks/Animation/SomePapLoad.cs new file mode 100644 index 00000000..f19e4ce2 --- /dev/null +++ b/Penumbra/Interop/Hooks/Animation/SomePapLoad.cs @@ -0,0 +1,51 @@ +using OtterGui.Services; +using Penumbra.CrashHandler.Buffers; +using Penumbra.GameData; +using Penumbra.GameData.Interop; +using Penumbra.Interop.PathResolving; +using Penumbra.Services; + +namespace Penumbra.Interop.Hooks.Animation; + +/// Unknown what exactly this is, but it seems to load a bunch of paps. +public sealed unsafe class SomePapLoad : FastHook +{ + private readonly GameState _state; + private readonly CollectionResolver _collectionResolver; + private readonly ObjectManager _objects; + private readonly CrashHandlerService _crashHandler; + + public SomePapLoad(HookManager hooks, GameState state, CollectionResolver collectionResolver, ObjectManager objects, + CrashHandlerService crashHandler) + { + _state = state; + _collectionResolver = collectionResolver; + _objects = objects; + _crashHandler = crashHandler; + Task = hooks.CreateHook("Some PAP Load", Sigs.LoadSomePap, Detour, !HookOverrides.Instance.Animation.SomePapLoad); + } + + public delegate void Delegate(nint a1, int a2, nint a3, int a4); + + [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] + private void Detour(nint a1, int a2, nint a3, int a4) + { + Penumbra.Log.Excessive($"[Some PAP Load] Invoked on 0x{a1:X} with {a2}, {a3}, {a4}."); + var timelinePtr = a1 + VolatileOffsets.AnimationState.TimeLinePtr; + if (timelinePtr != nint.Zero) + { + var actorIdx = (int)(*(*(ulong**)timelinePtr + 1) >> 3); + if (actorIdx >= 0 && actorIdx < _objects.TotalCount) + { + var newData = _collectionResolver.IdentifyCollection(_objects[actorIdx].AsObject, true); + var last = _state.SetAnimationData(newData); + _crashHandler.LogAnimation(newData.AssociatedGameObject, newData.ModCollection, AnimationInvocationType.PapLoad); + Task.Result.Original(a1, a2, a3, a4); + _state.RestoreAnimationData(last); + return; + } + } + + Task.Result.Original(a1, a2, a3, a4); + } +} diff --git a/Penumbra/Interop/Hooks/Animation/SomeParasolAnimation.cs b/Penumbra/Interop/Hooks/Animation/SomeParasolAnimation.cs new file mode 100644 index 00000000..9df8d4eb --- /dev/null +++ b/Penumbra/Interop/Hooks/Animation/SomeParasolAnimation.cs @@ -0,0 +1,31 @@ +using FFXIVClientStructs.FFXIV.Client.Graphics.Scene; +using OtterGui.Services; +using Penumbra.GameData; +using Penumbra.Interop.PathResolving; + +namespace Penumbra.Interop.Hooks.Animation; + +/// Called for some animations when using a Parasol. +public sealed unsafe class SomeParasolAnimation : FastHook +{ + private readonly GameState _state; + private readonly CollectionResolver _collectionResolver; + + public SomeParasolAnimation(HookManager hooks, GameState state, CollectionResolver collectionResolver) + { + _state = state; + _collectionResolver = collectionResolver; + Task = hooks.CreateHook("Some Parasol Animation", Sigs.UnkParasolAnimation, Detour, !HookOverrides.Instance.Animation.SomeParasolAnimation); + } + + public delegate void Delegate(DrawObject* drawObject, int unk1); + + [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] + private void Detour(DrawObject* drawObject, int unk1) + { + Penumbra.Log.Excessive($"[Some Mount Animation] Invoked on {(nint)drawObject:X} with {unk1}."); + var last = _state.SetAnimationData(_collectionResolver.IdentifyCollection(drawObject, true)); + Task.Result.Original(drawObject, unk1); + _state.RestoreAnimationData(last); + } +} diff --git a/Penumbra/Interop/Hooks/DebugHook.cs b/Penumbra/Interop/Hooks/DebugHook.cs new file mode 100644 index 00000000..fe9754f9 --- /dev/null +++ b/Penumbra/Interop/Hooks/DebugHook.cs @@ -0,0 +1,44 @@ +using Dalamud.Hooking; +using OtterGui.Services; +using Penumbra.Interop.Structs; + +namespace Penumbra.Interop.Hooks; + +#if DEBUG +public sealed unsafe class DebugHook : IHookService +{ + public const string Signature = ""; + + public DebugHook(HookManager hooks) + { + if (Signature.Length > 0) + _task = hooks.CreateHook("Debug Hook", Signature, Detour, true); + } + + private readonly Task>? _task; + + public nint Address + => _task?.Result.Address ?? nint.Zero; + + public void Enable() + => _task?.Result.Enable(); + + public void Disable() + => _task?.Result.Disable(); + + public Task Awaiter + => _task ?? Task.CompletedTask; + + public bool Finished + => _task?.IsCompletedSuccessfully ?? true; + + private delegate nint Delegate(ResourceHandle* a, int b, int c); + + private nint Detour(ResourceHandle* a, int b, int c) + { + var ret = _task!.Result.Original(a, b, c); + Penumbra.Log.Information($"[Debug Hook] Results with 0x{(nint)a:X}, {b}, {c} -> 0x{ret:X}."); + return ret; + } +} +#endif diff --git a/Penumbra/Interop/Hooks/HookSettings.cs b/Penumbra/Interop/Hooks/HookSettings.cs new file mode 100644 index 00000000..bcff25d2 --- /dev/null +++ b/Penumbra/Interop/Hooks/HookSettings.cs @@ -0,0 +1,155 @@ +using Dalamud.Plugin; +using Newtonsoft.Json; + +namespace Penumbra.Interop.Hooks; + +public class HookOverrides +{ + [JsonIgnore] + public bool IsCustomLoaded { get; private set; } + + public static HookOverrides Instance = new(); + + public AnimationHooks Animation; + public MetaHooks Meta; + public ObjectHooks Objects; + public PostProcessingHooks PostProcessing; + public ResourceLoadingHooks ResourceLoading; + public ResourceHooks Resources; + + public HookOverrides Clone() + => new() + { + Animation = Animation, + Meta = Meta, + Objects = Objects, + PostProcessing = PostProcessing, + ResourceLoading = ResourceLoading, + Resources = Resources, + }; + + public struct AnimationHooks + { + public bool ApricotListenerSoundPlayCaller; + public bool CharacterBaseLoadAnimation; + public bool Dismount; + public bool LoadAreaVfx; + public bool LoadCharacterSound; + public bool LoadCharacterVfx; + public bool LoadTimelineResources; + public bool PlayFootstep; + public bool ScheduleClipUpdate; + public bool SomeActionLoad; + public bool SomeMountAnimation; + public bool SomePapLoad; + public bool SomeParasolAnimation; + public bool GetCachedScheduleResource; + public bool LoadActionTmb; + } + + public struct MetaHooks + { + public bool CalculateHeight; + public bool ChangeCustomize; + public bool EqdpAccessoryHook; + public bool EqdpEquipHook; + public bool EqpHook; + public bool EstHook; + public bool GmpHook; + public bool ModelLoadComplete; + public bool RspBustHook; + public bool RspHeightHook; + public bool RspSetupCharacter; + public bool RspTailHook; + public bool SetupVisor; + public bool UpdateModel; + public bool UpdateRender; + public bool AtchCaller1; + public bool AtchCaller2; + } + + public struct ObjectHooks + { + public bool CharacterBaseDestructor; + public bool CharacterDestructor; + public bool CopyCharacter; + public bool CreateCharacterBase; + public bool EnableDraw; + public bool WeaponReload; + public bool SetupPlayerNpc; + public bool ConstructCutsceneCharacter; + } + + public struct PostProcessingHooks + { + public bool HumanSetupScaling; + public bool HumanCreateDeformer; + public bool HumanOnRenderMaterial; + public bool ModelRendererOnRenderMaterial; + public bool ModelRendererUnkFunc; + public bool PrepareColorTable; + public bool RenderTargetManagerInitialize; + } + + public struct ResourceLoadingHooks + { + public bool CreateFileWHook; + public bool PapHooks; + public bool ReadSqPack; + public bool IncRef; + public bool DecRef; + public bool GetResourceSync; + public bool GetResourceAsync; + public bool UpdateResourceState; + public bool CheckFileState; + public bool TexResourceHandleOnLoad; + public bool LoadMdlFileExtern; + public bool SoundOnLoad; + } + + public struct ResourceHooks + { + public bool ApricotResourceLoad; + public bool LoadMtrl; + public bool LoadMtrlTex; + public bool ResolvePathHooks; + public bool ResourceHandleDestructor; + } + + public const string FileName = "HookOverrides.json"; + + public static HookOverrides LoadFile(IDalamudPluginInterface pi) + { + var path = Path.Combine(pi.GetPluginConfigDirectory(), FileName); + if (!File.Exists(path)) + return new HookOverrides(); + + try + { + var text = File.ReadAllText(path); + var ret = JsonConvert.DeserializeObject(text)!; + ret.IsCustomLoaded = true; + Penumbra.Log.Warning("A hook override file was loaded, some hooks may be disabled and Penumbra might not be working as expected."); + return ret; + } + catch (Exception ex) + { + Penumbra.Log.Error($"A hook override file was found at {path}, but could not be loaded:\n{ex}"); + return new HookOverrides(); + } + } + + public void Write(IDalamudPluginInterface pi) + { + var path = Path.Combine(pi.GetPluginConfigDirectory(), FileName); + try + { + var text = JsonConvert.SerializeObject(this, Formatting.Indented); + File.WriteAllText(path, text); + } + catch (Exception ex) + { + Penumbra.Log.Error($"Could not write hook override file to {path}:\n{ex}"); + } + } +} diff --git a/Penumbra/Interop/Hooks/Meta/AtchCallerHook1.cs b/Penumbra/Interop/Hooks/Meta/AtchCallerHook1.cs new file mode 100644 index 00000000..2a3d7468 --- /dev/null +++ b/Penumbra/Interop/Hooks/Meta/AtchCallerHook1.cs @@ -0,0 +1,38 @@ +using FFXIVClientStructs.FFXIV.Client.Game.Character; +using OtterGui.Services; +using Penumbra.GameData; +using Penumbra.GameData.Interop; +using Penumbra.Interop.PathResolving; + +namespace Penumbra.Interop.Hooks.Meta; + +public unsafe class AtchCallerHook1 : FastHook, IDisposable +{ + public delegate void Delegate(DrawObjectData* data, uint slot, nint unk, Model playerModel); + + private readonly CollectionResolver _collectionResolver; + private readonly MetaState _metaState; + + public AtchCallerHook1(HookManager hooks, MetaState metaState, CollectionResolver collectionResolver) + { + _metaState = metaState; + _collectionResolver = collectionResolver; + Task = hooks.CreateHook("AtchCaller1", Sigs.AtchCaller1, Detour, + metaState.Config.EnableMods && !HookOverrides.Instance.Meta.AtchCaller1); + if (!HookOverrides.Instance.Meta.AtchCaller1) + _metaState.Config.ModsEnabled += Toggle; + } + + private void Detour(DrawObjectData* data, uint slot, nint unk, Model playerModel) + { + var collection = playerModel.Valid ? _collectionResolver.IdentifyCollection(playerModel.AsDrawObject, true) : _collectionResolver.DefaultCollection; + _metaState.AtchCollection.Push(collection); + Task.Result.Original(data, slot, unk, playerModel); + _metaState.AtchCollection.Pop(); + Penumbra.Log.Excessive( + $"[AtchCaller1] Invoked on 0x{(ulong)data:X} with {slot}, {unk:X}, 0x{playerModel.Address:X}, identified to {collection.ModCollection.Identity.AnonymizedName}."); + } + + public void Dispose() + => _metaState.Config.ModsEnabled -= Toggle; +} diff --git a/Penumbra/Interop/Hooks/Meta/AtchCallerHook2.cs b/Penumbra/Interop/Hooks/Meta/AtchCallerHook2.cs new file mode 100644 index 00000000..af38ce50 --- /dev/null +++ b/Penumbra/Interop/Hooks/Meta/AtchCallerHook2.cs @@ -0,0 +1,38 @@ +using FFXIVClientStructs.FFXIV.Client.Game.Character; +using OtterGui.Services; +using Penumbra.GameData; +using Penumbra.GameData.Interop; +using Penumbra.Interop.PathResolving; + +namespace Penumbra.Interop.Hooks.Meta; + +public unsafe class AtchCallerHook2 : FastHook, IDisposable +{ + public delegate void Delegate(DrawObjectData* data, uint slot, nint unk, Model playerModel, uint unk2); + + private readonly CollectionResolver _collectionResolver; + private readonly MetaState _metaState; + + public AtchCallerHook2(HookManager hooks, MetaState metaState, CollectionResolver collectionResolver) + { + _metaState = metaState; + _collectionResolver = collectionResolver; + Task = hooks.CreateHook("AtchCaller2", Sigs.AtchCaller2, Detour, + metaState.Config.EnableMods && !HookOverrides.Instance.Meta.AtchCaller2); + if (!HookOverrides.Instance.Meta.AtchCaller2) + _metaState.Config.ModsEnabled += Toggle; + } + + private void Detour(DrawObjectData* data, uint slot, nint unk, Model playerModel, uint unk2) + { + var collection = _collectionResolver.IdentifyCollection(playerModel.AsDrawObject, true); + _metaState.AtchCollection.Push(collection); + Task.Result.Original(data, slot, unk, playerModel, unk2); + _metaState.AtchCollection.Pop(); + Penumbra.Log.Excessive( + $"[AtchCaller2] Invoked on 0x{(ulong)data:X} with {slot}, {unk:X}, 0x{playerModel.Address:X}, {unk2}, identified to {collection.ModCollection.Identity.AnonymizedName}."); + } + + public void Dispose() + => _metaState.Config.ModsEnabled -= Toggle; +} diff --git a/Penumbra/Interop/Hooks/Meta/CalculateHeight.cs b/Penumbra/Interop/Hooks/Meta/CalculateHeight.cs new file mode 100644 index 00000000..3dac17bd --- /dev/null +++ b/Penumbra/Interop/Hooks/Meta/CalculateHeight.cs @@ -0,0 +1,33 @@ +using FFXIVClientStructs.FFXIV.Client.Game.Character; +using FFXIVClientStructs.FFXIV.Client.Game.Object; +using OtterGui.Services; +using Penumbra.Interop.PathResolving; + +namespace Penumbra.Interop.Hooks.Meta; + +public sealed unsafe class CalculateHeight : FastHook +{ + private readonly CollectionResolver _collectionResolver; + private readonly MetaState _metaState; + + public CalculateHeight(HookManager hooks, CollectionResolver collectionResolver, MetaState metaState) + { + _collectionResolver = collectionResolver; + _metaState = metaState; + Task = hooks.CreateHook("Calculate Height", (nint)ModelContainer.MemberFunctionPointers.CalculateHeight, Detour, + !HookOverrides.Instance.Meta.CalculateHeight); + } + + public delegate float Delegate(ModelContainer* character); + + [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] + private float Detour(ModelContainer* container) + { + var collection = _collectionResolver.IdentifyCollection((GameObject*)container->OwnerObject, true); + _metaState.RspCollection.Push(collection); + var ret = Task.Result.Original.Invoke(container); + Penumbra.Log.Excessive($"[Calculate Height] Invoked on {(nint)container:X} -> {ret}."); + _metaState.RspCollection.Pop(); + return ret; + } +} diff --git a/Penumbra/Interop/Hooks/Meta/ChangeCustomize.cs b/Penumbra/Interop/Hooks/Meta/ChangeCustomize.cs new file mode 100644 index 00000000..523ae610 --- /dev/null +++ b/Penumbra/Interop/Hooks/Meta/ChangeCustomize.cs @@ -0,0 +1,38 @@ +using FFXIVClientStructs.FFXIV.Client.Graphics.Scene; +using OtterGui.Services; +using Penumbra.Collections; +using Penumbra.GameData; +using Penumbra.GameData.Structs; +using Penumbra.Interop.PathResolving; + +namespace Penumbra.Interop.Hooks.Meta; + +public sealed unsafe class ChangeCustomize : FastHook +{ + private readonly CollectionResolver _collectionResolver; + private readonly MetaState _metaState; + + public ChangeCustomize(HookManager hooks, CollectionResolver collectionResolver, MetaState metaState) + { + _collectionResolver = collectionResolver; + _metaState = metaState; + Task = hooks.CreateHook("Change Customize", Sigs.UpdateDrawData, Detour, !HookOverrides.Instance.Meta.ChangeCustomize); + } + + public delegate bool Delegate(Human* human, CustomizeArray* data, byte skipEquipment); + + [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] + private bool Detour(Human* human, CustomizeArray* data, byte skipEquipment) + { + var collection = _collectionResolver.IdentifyCollection((DrawObject*)human, true); + _metaState.CustomizeChangeCollection = collection; + _metaState.RspCollection.Push(collection); + using var decal1 = _metaState.ResolveDecal(_metaState.CustomizeChangeCollection, true); + using var decal2 = _metaState.ResolveDecal(_metaState.CustomizeChangeCollection, false); + var ret = Task.Result.Original.Invoke(human, data, skipEquipment); + Penumbra.Log.Excessive($"[Change Customize] Invoked on {(nint)human:X} with {(nint)data:X}, {skipEquipment} -> {ret}."); + _metaState.CustomizeChangeCollection = ResolveData.Invalid; + _metaState.RspCollection.Pop(); + return ret; + } +} diff --git a/Penumbra/Interop/Hooks/Meta/EqdpAccessoryHook.cs b/Penumbra/Interop/Hooks/Meta/EqdpAccessoryHook.cs new file mode 100644 index 00000000..43328600 --- /dev/null +++ b/Penumbra/Interop/Hooks/Meta/EqdpAccessoryHook.cs @@ -0,0 +1,37 @@ +using FFXIVClientStructs.FFXIV.Client.Graphics.Scene; +using OtterGui.Services; +using Penumbra.GameData; +using Penumbra.GameData.Enums; +using Penumbra.GameData.Structs; +using Penumbra.Interop.PathResolving; + +namespace Penumbra.Interop.Hooks.Meta; + +public unsafe class EqdpAccessoryHook : FastHook, IDisposable +{ + public delegate void Delegate(CharacterUtility* utility, EqdpEntry* entry, uint id, uint raceCode); + + private readonly MetaState _metaState; + + public EqdpAccessoryHook(HookManager hooks, MetaState metaState) + { + _metaState = metaState; + Task = hooks.CreateHook("GetEqdpAccessoryEntry", Sigs.GetEqdpAccessoryEntry, Detour, + metaState.Config.EnableMods && !HookOverrides.Instance.Meta.EqdpAccessoryHook); + if (!HookOverrides.Instance.Meta.EqdpAccessoryHook) + _metaState.Config.ModsEnabled += Toggle; + } + + private void Detour(CharacterUtility* utility, EqdpEntry* entry, uint setId, uint raceCode) + { + Task.Result.Original(utility, entry, setId, raceCode); + if (_metaState.EqdpCollection.TryPeek(out var collection) + && collection is { Valid: true, ModCollection.MetaCache: { } cache }) + *entry = cache.Eqdp.ApplyFullEntry(new PrimaryId((ushort)setId), (GenderRace)raceCode, true, *entry); + Penumbra.Log.Excessive( + $"[GetEqdpAccessoryEntry] Invoked on 0x{(ulong)utility:X} with {setId}, {(GenderRace)raceCode}, returned {(ushort)*entry:B10}."); + } + + public void Dispose() + => _metaState.Config.ModsEnabled -= Toggle; +} diff --git a/Penumbra/Interop/Hooks/Meta/EqdpEquipHook.cs b/Penumbra/Interop/Hooks/Meta/EqdpEquipHook.cs new file mode 100644 index 00000000..fa0d5a29 --- /dev/null +++ b/Penumbra/Interop/Hooks/Meta/EqdpEquipHook.cs @@ -0,0 +1,36 @@ +using FFXIVClientStructs.FFXIV.Client.Graphics.Scene; +using OtterGui.Services; +using Penumbra.GameData; +using Penumbra.GameData.Enums; +using Penumbra.GameData.Structs; +using Penumbra.Interop.PathResolving; + +namespace Penumbra.Interop.Hooks.Meta; + +public unsafe class EqdpEquipHook : FastHook, IDisposable +{ + public delegate void Delegate(CharacterUtility* utility, EqdpEntry* entry, uint id, uint raceCode); + + private readonly MetaState _metaState; + + public EqdpEquipHook(HookManager hooks, MetaState metaState) + { + _metaState = metaState; + Task = hooks.CreateHook("GetEqdpEquipEntry", Sigs.GetEqdpEquipEntry, Detour, metaState.Config.EnableMods && !HookOverrides.Instance.Meta.EqdpEquipHook); + if (!HookOverrides.Instance.Meta.EqdpEquipHook) + _metaState.Config.ModsEnabled += Toggle; + } + + private void Detour(CharacterUtility* utility, EqdpEntry* entry, uint setId, uint raceCode) + { + Task.Result.Original(utility, entry, setId, raceCode); + if (_metaState.EqdpCollection.TryPeek(out var collection) + && collection is { Valid: true, ModCollection.MetaCache: { } cache }) + *entry = cache.Eqdp.ApplyFullEntry(new PrimaryId((ushort)setId), (GenderRace)raceCode, false, *entry); + Penumbra.Log.Excessive( + $"[GetEqdpEquipEntry] Invoked on 0x{(ulong)utility:X} with {setId}, {(GenderRace)raceCode}, returned {(ushort)*entry:B10}."); + } + + public void Dispose() + => _metaState.Config.ModsEnabled -= Toggle; +} diff --git a/Penumbra/Interop/Hooks/Meta/EqpHook.cs b/Penumbra/Interop/Hooks/Meta/EqpHook.cs new file mode 100644 index 00000000..f35b922b --- /dev/null +++ b/Penumbra/Interop/Hooks/Meta/EqpHook.cs @@ -0,0 +1,41 @@ +using FFXIVClientStructs.FFXIV.Client.Graphics.Scene; +using OtterGui.Services; +using Penumbra.GameData; +using Penumbra.GameData.Structs; +using Penumbra.Interop.PathResolving; + +namespace Penumbra.Interop.Hooks.Meta; + +public unsafe class EqpHook : FastHook, IDisposable +{ + public delegate void Delegate(CharacterUtility* utility, EqpEntry* flags, CharacterArmor* armor); + + private readonly MetaState _metaState; + + public EqpHook(HookManager hooks, MetaState metaState) + { + _metaState = metaState; + Task = hooks.CreateHook("GetEqpFlags", Sigs.GetEqpEntry, Detour, + metaState.Config.EnableMods && !HookOverrides.Instance.Meta.EqpHook); + if (!HookOverrides.Instance.Meta.EqpHook) + _metaState.Config.ModsEnabled += Toggle; + } + + private void Detour(CharacterUtility* utility, EqpEntry* flags, CharacterArmor* armor) + { + if (_metaState.EqpCollection.TryPeek(out var collection) && collection is { Valid: true, ModCollection.MetaCache: { } cache }) + { + *flags = cache.Eqp.GetValues(armor); + *flags = cache.GlobalEqp.Apply(*flags, armor); + } + else + { + Task.Result.Original(utility, flags, armor); + } + + Penumbra.Log.Excessive($"[GetEqpFlags] Invoked on 0x{(nint)utility:X} with 0x{(ulong)armor:X}, returned 0x{(ulong)*flags:X16}."); + } + + public void Dispose() + => _metaState.Config.ModsEnabled -= Toggle; +} diff --git a/Penumbra/Interop/Hooks/Meta/EstHook.cs b/Penumbra/Interop/Hooks/Meta/EstHook.cs new file mode 100644 index 00000000..8284eb69 --- /dev/null +++ b/Penumbra/Interop/Hooks/Meta/EstHook.cs @@ -0,0 +1,63 @@ +using OtterGui.Services; +using Penumbra.GameData; +using Penumbra.GameData.Enums; +using Penumbra.GameData.Structs; +using Penumbra.Interop.PathResolving; +using Penumbra.Interop.Structs; +using Penumbra.Meta.Manipulations; +using CharacterUtility = Penumbra.Interop.Services.CharacterUtility; + +namespace Penumbra.Interop.Hooks.Meta; + +public unsafe class EstHook : FastHook, IDisposable +{ + public delegate EstEntry Delegate(ResourceHandle* estResource, uint id, uint genderRace); + + private readonly CharacterUtility _characterUtility; + private readonly MetaState _metaState; + + public EstHook(HookManager hooks, MetaState metaState, CharacterUtility characterUtility) + { + _metaState = metaState; + _characterUtility = characterUtility; + Task = hooks.CreateHook("FindEstEntry", Sigs.FindEstEntry, Detour, + metaState.Config.EnableMods && !HookOverrides.Instance.Meta.EstHook); + if (!HookOverrides.Instance.Meta.EstHook) + _metaState.Config.ModsEnabled += Toggle; + } + + private EstEntry Detour(ResourceHandle* estResource, uint genderRace, uint id) + { + EstEntry ret; + if (_metaState.EstCollection.TryPeek(out var collection) + && collection is { Valid: true, ModCollection.MetaCache: { } cache } + && cache.Est.TryGetValue(Convert(estResource, genderRace, id), out var entry)) + ret = entry.Entry; + else + ret = Task.Result.Original(estResource, genderRace, id); + + Penumbra.Log.Excessive($"[FindEstEntry] Invoked with 0x{(nint)estResource:X}, {genderRace}, {id}, returned {ret.Value}."); + return ret; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private EstIdentifier Convert(ResourceHandle* estResource, uint genderRace, uint id) + { + var i = new PrimaryId((ushort)id); + var gr = (GenderRace)genderRace; + + if (estResource == _characterUtility.Address->BodyEstResource) + return new EstIdentifier(i, EstType.Body, gr); + if (estResource == _characterUtility.Address->HairEstResource) + return new EstIdentifier(i, EstType.Hair, gr); + if (estResource == _characterUtility.Address->FaceEstResource) + return new EstIdentifier(i, EstType.Face, gr); + if (estResource == _characterUtility.Address->HeadEstResource) + return new EstIdentifier(i, EstType.Head, gr); + + return new EstIdentifier(i, 0, gr); + } + + public void Dispose() + => _metaState.Config.ModsEnabled -= Toggle; +} diff --git a/Penumbra/Interop/Hooks/Meta/GmpHook.cs b/Penumbra/Interop/Hooks/Meta/GmpHook.cs new file mode 100644 index 00000000..d656ebdb --- /dev/null +++ b/Penumbra/Interop/Hooks/Meta/GmpHook.cs @@ -0,0 +1,43 @@ +using FFXIVClientStructs.FFXIV.Client.Graphics.Scene; +using Lumina.Data.Parsing.Uld; +using OtterGui.Services; +using Penumbra.GameData; +using Penumbra.Interop.PathResolving; +using Penumbra.Meta.Files; +using Penumbra.Meta.Manipulations; + +namespace Penumbra.Interop.Hooks.Meta; + +public unsafe class GmpHook : FastHook, IDisposable +{ + public delegate ulong Delegate(CharacterUtility* characterUtility, ulong* outputEntry, ushort setId); + + private readonly MetaState _metaState; + + public GmpHook(HookManager hooks, MetaState metaState) + { + _metaState = metaState; + Task = hooks.CreateHook("GetGmpEntry", Sigs.GetGmpEntry, Detour, + metaState.Config.EnableMods && !HookOverrides.Instance.Meta.GmpHook); + if (!HookOverrides.Instance.Meta.GmpHook) + _metaState.Config.ModsEnabled += Toggle; + } + + private ulong Detour(CharacterUtility* characterUtility, ulong* outputEntry, ushort setId) + { + ulong ret; + if (_metaState.GmpCollection.TryPeek(out var collection) + && collection.Collection is { Valid: true, ModCollection.MetaCache: { } cache } + && cache.Gmp.TryGetValue(new GmpIdentifier(collection.Id), out var entry)) + ret = (*outputEntry) = entry.Entry.Enabled ? entry.Entry.Value : 0ul; + else + ret = Task.Result.Original(characterUtility, outputEntry, setId); + + Penumbra.Log.Excessive( + $"[GetGmpFlags] Invoked on 0x{(ulong)characterUtility:X} for {setId} with 0x{(ulong)outputEntry:X} (={*outputEntry:X}), returned {ret:X10}."); + return ret; + } + + public void Dispose() + => _metaState.Config.ModsEnabled -= Toggle; +} diff --git a/Penumbra/Interop/Hooks/Meta/ModelLoadComplete.cs b/Penumbra/Interop/Hooks/Meta/ModelLoadComplete.cs new file mode 100644 index 00000000..4b9b05b1 --- /dev/null +++ b/Penumbra/Interop/Hooks/Meta/ModelLoadComplete.cs @@ -0,0 +1,32 @@ +using FFXIVClientStructs.FFXIV.Client.Graphics.Scene; +using OtterGui.Services; +using Penumbra.Interop.PathResolving; + +namespace Penumbra.Interop.Hooks.Meta; + +public sealed unsafe class ModelLoadComplete : FastHook +{ + private readonly CollectionResolver _collectionResolver; + private readonly MetaState _metaState; + + public ModelLoadComplete(HookManager hooks, CollectionResolver collectionResolver, MetaState metaState, CharacterBaseVTables vtables) + { + _collectionResolver = collectionResolver; + _metaState = metaState; + Task = hooks.CreateHook("Model Load Complete", vtables.HumanVTable[59], Detour, !HookOverrides.Instance.Meta.ModelLoadComplete); + } + + public delegate void Delegate(DrawObject* drawObject); + + [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] + private void Detour(DrawObject* drawObject) + { + Penumbra.Log.Excessive($"[Model Load Complete] Invoked on {(nint)drawObject:X}."); + var collection = _collectionResolver.IdentifyCollection(drawObject, true); + _metaState.EqpCollection.Push(collection); + _metaState.EqdpCollection.Push(collection); + Task.Result.Original(drawObject); + _metaState.EqpCollection.Pop(); + _metaState.EqdpCollection.Pop(); + } +} diff --git a/Penumbra/Interop/Hooks/Meta/RspBustHook.cs b/Penumbra/Interop/Hooks/Meta/RspBustHook.cs new file mode 100644 index 00000000..c49556bf --- /dev/null +++ b/Penumbra/Interop/Hooks/Meta/RspBustHook.cs @@ -0,0 +1,73 @@ +using OtterGui.Services; +using Penumbra.GameData; +using Penumbra.GameData.Enums; +using Penumbra.Interop.PathResolving; +using Penumbra.Meta; +using Penumbra.Meta.Files; +using Penumbra.Meta.Manipulations; + +namespace Penumbra.Interop.Hooks.Meta; + +public unsafe class RspBustHook : FastHook, IDisposable +{ + public delegate float* Delegate(nint cmpResource, float* storage, SubRace race, byte gender, byte bodyType, byte bustSize); + + private readonly MetaState _metaState; + private readonly MetaFileManager _metaFileManager; + + public RspBustHook(HookManager hooks, MetaState metaState, MetaFileManager metaFileManager) + { + _metaState = metaState; + _metaFileManager = metaFileManager; + Task = hooks.CreateHook("GetRspBust", Sigs.GetRspBust, Detour, + metaState.Config.EnableMods && !HookOverrides.Instance.Meta.RspBustHook); + if (!HookOverrides.Instance.Meta.RspBustHook) + _metaState.Config.ModsEnabled += Toggle; + } + + private float* Detour(nint cmpResource, float* storage, SubRace clan, byte gender, byte bodyType, byte bustSize) + { + if (gender == 0) + { + storage[0] = 1f; + storage[1] = 1f; + storage[2] = 1f; + return storage; + } + + var ret = storage; + if (bodyType < 2 + && _metaState.RspCollection.TryPeek(out var collection) + && collection is { Valid: true, ModCollection.MetaCache: { } cache }) + { + var bustScale = bustSize / 100f; + var ptr = CmpFile.GetDefaults(_metaFileManager, clan, RspAttribute.BustMinX); + storage[0] = GetValue(0, RspAttribute.BustMinX, RspAttribute.BustMaxX); + storage[1] = GetValue(1, RspAttribute.BustMinY, RspAttribute.BustMaxY); + storage[2] = GetValue(2, RspAttribute.BustMinZ, RspAttribute.BustMaxZ); + + [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] + float GetValue(int dimension, RspAttribute min, RspAttribute max) + { + var minValue = cache.Rsp.TryGetValue(new RspIdentifier(clan, min), out var minEntry) + ? minEntry.Entry.Value + : (ptr + dimension)->Value; + var maxValue = cache.Rsp.TryGetValue(new RspIdentifier(clan, max), out var maxEntry) + ? maxEntry.Entry.Value + : (ptr + 3 + dimension)->Value; + return (maxValue - minValue) * bustScale + minValue; + } + } + else + { + ret = Task.Result.Original(cmpResource, storage, clan, gender, bodyType, bustSize); + } + + Penumbra.Log.Excessive( + $"[GetRspBust] Invoked on 0x{cmpResource:X} with {clan}, {(Gender)(gender + 1)}, {bodyType}, {bustSize}, returned {storage[0]}, {storage[1]}, {storage[2]}."); + return ret; + } + + public void Dispose() + => _metaState.Config.ModsEnabled -= Toggle; +} diff --git a/Penumbra/Interop/Hooks/Meta/RspHeightHook.cs b/Penumbra/Interop/Hooks/Meta/RspHeightHook.cs new file mode 100644 index 00000000..49180d6e --- /dev/null +++ b/Penumbra/Interop/Hooks/Meta/RspHeightHook.cs @@ -0,0 +1,83 @@ +using OtterGui.Services; +using Penumbra.GameData; +using Penumbra.GameData.Enums; +using Penumbra.Interop.PathResolving; +using Penumbra.Meta; +using Penumbra.Meta.Files; +using Penumbra.Meta.Manipulations; + +namespace Penumbra.Interop.Hooks.Meta; + +public class RspHeightHook : FastHook, IDisposable +{ + public delegate float Delegate(nint cmpResource, SubRace clan, byte gender, byte bodyType, byte height); + + private readonly MetaState _metaState; + private readonly MetaFileManager _metaFileManager; + + public RspHeightHook(HookManager hooks, MetaState metaState, MetaFileManager metaFileManager) + { + _metaState = metaState; + _metaFileManager = metaFileManager; + Task = hooks.CreateHook("GetRspHeight", Sigs.GetRspHeight, Detour, + metaState.Config.EnableMods && !HookOverrides.Instance.Meta.RspHeightHook); + if (!HookOverrides.Instance.Meta.RspHeightHook) + _metaState.Config.ModsEnabled += Toggle; + } + + private unsafe float Detour(nint cmpResource, SubRace clan, byte gender, byte bodyType, byte height) + { + float scale; + if (bodyType < 2 + && _metaState.RspCollection.TryPeek(out var collection) + && collection is { Valid: true, ModCollection.MetaCache: { } cache }) + { + // Special cases. + if (height == 0xFF) + return 1.0f; + + if (height > 100) + height = 0; + + var (minIdent, maxIdent) = gender == 0 + ? (new RspIdentifier(clan, RspAttribute.MaleMinSize), new RspIdentifier(clan, RspAttribute.MaleMaxSize)) + : (new RspIdentifier(clan, RspAttribute.FemaleMinSize), new RspIdentifier(clan, RspAttribute.FemaleMaxSize)); + + float minEntry, maxEntry; + if (cache.Rsp.TryGetValue(minIdent, out var min)) + { + minEntry = min.Entry.Value; + maxEntry = cache.Rsp.TryGetValue(maxIdent, out var max) + ? max.Entry.Value + : CmpFile.GetDefault(_metaFileManager, minIdent.SubRace, maxIdent.Attribute).Value; + } + else + { + var ptr = CmpFile.GetDefaults(_metaFileManager, minIdent.SubRace, minIdent.Attribute); + if (cache.Rsp.TryGetValue(maxIdent, out var max)) + { + minEntry = ptr->Value; + maxEntry = max.Entry.Value; + } + else + { + minEntry = ptr[0].Value; + maxEntry = ptr[1].Value; + } + } + + scale = (maxEntry - minEntry) * height / 100f + minEntry; + } + else + { + scale = Task.Result.Original(cmpResource, clan, gender, bodyType, height); + } + + Penumbra.Log.Excessive( + $"[GetRspHeight] Invoked on 0x{cmpResource:X} with {clan}, {(Gender)(gender + 1)}, {bodyType}, {height}, returned {scale}."); + return scale; + } + + public void Dispose() + => _metaState.Config.ModsEnabled -= Toggle; +} diff --git a/Penumbra/Interop/Hooks/Meta/RspSetupCharacter.cs b/Penumbra/Interop/Hooks/Meta/RspSetupCharacter.cs new file mode 100644 index 00000000..952a2e29 --- /dev/null +++ b/Penumbra/Interop/Hooks/Meta/RspSetupCharacter.cs @@ -0,0 +1,39 @@ +using FFXIVClientStructs.FFXIV.Client.Graphics.Scene; +using OtterGui.Services; +using Penumbra.Collections; +using Penumbra.GameData; +using Penumbra.Interop.PathResolving; + +namespace Penumbra.Interop.Hooks.Meta; + +public sealed unsafe class RspSetupCharacter : FastHook +{ + private readonly CollectionResolver _collectionResolver; + private readonly MetaState _metaState; + + public RspSetupCharacter(HookManager hooks, CollectionResolver collectionResolver, MetaState metaState) + { + _collectionResolver = collectionResolver; + _metaState = metaState; + Task = hooks.CreateHook("RSP Setup Character", Sigs.RspSetupCharacter, Detour, !HookOverrides.Instance.Meta.RspSetupCharacter); + } + + public delegate void Delegate(DrawObject* drawObject, nint unk2, float unk3, nint unk4, byte unk5); + + [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] + private void Detour(DrawObject* drawObject, nint unk2, float unk3, nint unk4, byte unk5) + { + Penumbra.Log.Excessive($"[RSP Setup Character] Invoked on {(nint)drawObject:X} with {unk2}, {unk3}, {unk4}, {unk5}."); + // Skip if we are coming from ChangeCustomize. + if (_metaState.CustomizeChangeCollection.Valid) + { + Task.Result.Original.Invoke(drawObject, unk2, unk3, unk4, unk5); + return; + } + + var collection = _collectionResolver.IdentifyCollection(drawObject, true); + _metaState.RspCollection.Push(collection); + Task.Result.Original.Invoke(drawObject, unk2, unk3, unk4, unk5); + _metaState.RspCollection.Pop(); + } +} diff --git a/Penumbra/Interop/Hooks/Meta/RspTailHook.cs b/Penumbra/Interop/Hooks/Meta/RspTailHook.cs new file mode 100644 index 00000000..b434efa6 --- /dev/null +++ b/Penumbra/Interop/Hooks/Meta/RspTailHook.cs @@ -0,0 +1,77 @@ +using OtterGui.Services; +using Penumbra.GameData; +using Penumbra.GameData.Enums; +using Penumbra.Interop.PathResolving; +using Penumbra.Meta; +using Penumbra.Meta.Files; +using Penumbra.Meta.Manipulations; + +namespace Penumbra.Interop.Hooks.Meta; + +public class RspTailHook : FastHook, IDisposable +{ + public delegate float Delegate(nint cmpResource, Race clan, byte gender, byte isSecondSubRace, byte bodyType, byte height); + + private readonly MetaState _metaState; + private readonly MetaFileManager _metaFileManager; + + public RspTailHook(HookManager hooks, MetaState metaState, MetaFileManager metaFileManager) + { + _metaState = metaState; + _metaFileManager = metaFileManager; + Task = hooks.CreateHook("GetRspTail", Sigs.GetRspTail, Detour, + metaState.Config.EnableMods && !HookOverrides.Instance.Meta.RspTailHook); + if (!HookOverrides.Instance.Meta.RspTailHook) + _metaState.Config.ModsEnabled += Toggle; + } + + private unsafe float Detour(nint cmpResource, Race race, byte gender, byte isSecondSubRace, byte bodyType, byte tailLength) + { + float scale; + if (bodyType < 2 + && _metaState.RspCollection.TryPeek(out var collection) + && collection is { Valid: true, ModCollection.MetaCache: { } cache }) + { + var clan = (SubRace)(((int)race - 1) * 2 + 1 + isSecondSubRace); + var (minIdent, maxIdent) = gender == 0 + ? (new RspIdentifier(clan, RspAttribute.MaleMinTail), new RspIdentifier(clan, RspAttribute.MaleMaxTail)) + : (new RspIdentifier(clan, RspAttribute.FemaleMinTail), new RspIdentifier(clan, RspAttribute.FemaleMaxTail)); + + float minEntry, maxEntry; + if (cache.Rsp.TryGetValue(minIdent, out var min)) + { + minEntry = min.Entry.Value; + maxEntry = cache.Rsp.TryGetValue(maxIdent, out var max) + ? max.Entry.Value + : CmpFile.GetDefault(_metaFileManager, minIdent.SubRace, maxIdent.Attribute).Value; + } + else + { + var ptr = CmpFile.GetDefaults(_metaFileManager, minIdent.SubRace, minIdent.Attribute); + if (cache.Rsp.TryGetValue(maxIdent, out var max)) + { + minEntry = ptr->Value; + maxEntry = max.Entry.Value; + } + else + { + minEntry = ptr[0].Value; + maxEntry = ptr[1].Value; + } + } + + scale = (maxEntry - minEntry) * tailLength / 100f + minEntry; + } + else + { + scale = Task.Result.Original(cmpResource, race, gender, isSecondSubRace, bodyType, tailLength); + } + + Penumbra.Log.Excessive( + $"[GetRspTail] Invoked on 0x{cmpResource:X} with {race}, {(Gender)(gender + 1)}, {isSecondSubRace == 1}, {bodyType}, {tailLength}, returned {scale}."); + return scale; + } + + public void Dispose() + => _metaState.Config.ModsEnabled -= Toggle; +} diff --git a/Penumbra/Interop/Hooks/Meta/SetupVisor.cs b/Penumbra/Interop/Hooks/Meta/SetupVisor.cs new file mode 100644 index 00000000..063a9462 --- /dev/null +++ b/Penumbra/Interop/Hooks/Meta/SetupVisor.cs @@ -0,0 +1,37 @@ +using FFXIVClientStructs.FFXIV.Client.Graphics.Scene; +using OtterGui.Services; +using Penumbra.Collections; +using Penumbra.GameData; +using Penumbra.Interop.PathResolving; + +namespace Penumbra.Interop.Hooks.Meta; + +/// +/// GMP. This gets called every time when changing visor state, and it accesses the gmp file itself, +/// but it only applies a changed gmp file after a redraw for some reason. +/// +public sealed unsafe class SetupVisor : FastHook +{ + private readonly CollectionResolver _collectionResolver; + private readonly MetaState _metaState; + + public SetupVisor(HookManager hooks, CollectionResolver collectionResolver, MetaState metaState) + { + _collectionResolver = collectionResolver; + _metaState = metaState; + Task = hooks.CreateHook("Setup Visor", Sigs.SetupVisor, Detour, !HookOverrides.Instance.Meta.SetupVisor); + } + + public delegate byte Delegate(DrawObject* drawObject, ushort modelId, byte visorState); + + [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] + private byte Detour(DrawObject* drawObject, ushort modelId, byte visorState) + { + var collection = _collectionResolver.IdentifyCollection(drawObject, true); + _metaState.GmpCollection.Push((collection, modelId)); + var ret = Task.Result.Original.Invoke(drawObject, modelId, visorState); + Penumbra.Log.Excessive($"[Setup Visor] Invoked on {(nint)drawObject:X} with {modelId}, {visorState} -> {ret}."); + _metaState.GmpCollection.Pop(); + return ret; + } +} diff --git a/Penumbra/Interop/Hooks/Meta/UpdateModel.cs b/Penumbra/Interop/Hooks/Meta/UpdateModel.cs new file mode 100644 index 00000000..9189ce3b --- /dev/null +++ b/Penumbra/Interop/Hooks/Meta/UpdateModel.cs @@ -0,0 +1,39 @@ +using FFXIVClientStructs.FFXIV.Client.Graphics.Scene; +using OtterGui.Services; +using Penumbra.Collections; +using Penumbra.GameData; +using Penumbra.Interop.PathResolving; + +namespace Penumbra.Interop.Hooks.Meta; + +public sealed unsafe class UpdateModel : FastHook +{ + private readonly CollectionResolver _collectionResolver; + private readonly MetaState _metaState; + + public UpdateModel(HookManager hooks, CollectionResolver collectionResolver, MetaState metaState) + { + _collectionResolver = collectionResolver; + _metaState = metaState; + Task = hooks.CreateHook("Update Model", Sigs.UpdateModel, Detour, !HookOverrides.Instance.Meta.UpdateModel); + } + + public delegate void Delegate(DrawObject* drawObject); + + [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] + private void Detour(DrawObject* drawObject) + { + // Shortcut because this is called all the time. + // Same thing is checked at the beginning of the original function. + if (*(int*)((nint)drawObject + VolatileOffsets.UpdateModel.ShortCircuit) == 0) + return; + + Penumbra.Log.Excessive($"[Update Model] Invoked on {(nint)drawObject:X}."); + var collection = _collectionResolver.IdentifyCollection(drawObject, true); + _metaState.EqpCollection.Push(collection); + _metaState.EqdpCollection.Push(collection); + Task.Result.Original(drawObject); + _metaState.EqpCollection.Pop(); + _metaState.EqdpCollection.Pop(); + } +} diff --git a/Penumbra/Interop/Hooks/Meta/UpdateRender.cs b/Penumbra/Interop/Hooks/Meta/UpdateRender.cs new file mode 100644 index 00000000..ef0068b6 --- /dev/null +++ b/Penumbra/Interop/Hooks/Meta/UpdateRender.cs @@ -0,0 +1,31 @@ +using FFXIVClientStructs.FFXIV.Client.Graphics.Scene; +using OtterGui.Services; +using Penumbra.Interop.PathResolving; + +namespace Penumbra.Interop.Hooks.Meta; + +/// The actual function is inlined, so we need to hook its only callsite: Human.UpdateRender instead. +public sealed unsafe class UpdateRender : FastHook +{ + private readonly CollectionResolver _collectionResolver; + private readonly MetaState _metaState; + + public UpdateRender(HookManager hooks, CollectionResolver collectionResolver, MetaState metaState, CharacterBaseVTables vTables) + { + _collectionResolver = collectionResolver; + _metaState = metaState; + Task = hooks.CreateHook("Human.UpdateRender", vTables.HumanVTable[4], Detour, !HookOverrides.Instance.Meta.UpdateRender); + } + + public delegate void Delegate(DrawObject* drawObject); + + [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] + private void Detour(DrawObject* drawObject) + { + Penumbra.Log.Excessive($"[Human.UpdateRender] Invoked on {(nint)drawObject:X}."); + var collection = _collectionResolver.IdentifyCollection(drawObject, true); + _metaState.EqpCollection.Push(collection); + Task.Result.Original(drawObject); + _metaState.EqpCollection.Pop(); + } +} diff --git a/Penumbra/Interop/Hooks/Objects/CharacterBaseDestructor.cs b/Penumbra/Interop/Hooks/Objects/CharacterBaseDestructor.cs new file mode 100644 index 00000000..2d8e60b2 --- /dev/null +++ b/Penumbra/Interop/Hooks/Objects/CharacterBaseDestructor.cs @@ -0,0 +1,48 @@ +using Dalamud.Hooking; +using FFXIVClientStructs.FFXIV.Client.Graphics.Scene; +using OtterGui.Classes; +using OtterGui.Services; + +namespace Penumbra.Interop.Hooks.Objects; + +public sealed unsafe class CharacterBaseDestructor : EventWrapperPtr, IHookService +{ + public enum Priority + { + /// + DrawObjectState = 0, + + /// + MtrlTab = -1000, + } + + public CharacterBaseDestructor(HookManager hooks) + : base("Destroy CharacterBase") + => _task = hooks.CreateHook(Name, Address, Detour, !HookOverrides.Instance.Objects.CharacterBaseDestructor); + + private readonly Task> _task; + + public nint Address + => (nint)CharacterBase.MemberFunctionPointers.Destroy; + + public void Enable() + => _task.Result.Enable(); + + public void Disable() + => _task.Result.Disable(); + + public Task Awaiter + => _task; + + public bool Finished + => _task.IsCompletedSuccessfully; + + private delegate nint Delegate(CharacterBase* characterBase); + + private nint Detour(CharacterBase* characterBase) + { + Penumbra.Log.Excessive($"[{Name}] Triggered with 0x{(nint)characterBase:X}."); + Invoke(characterBase); + return _task.Result.Original(characterBase); + } +} diff --git a/Penumbra/Interop/Hooks/Objects/CharacterDestructor.cs b/Penumbra/Interop/Hooks/Objects/CharacterDestructor.cs new file mode 100644 index 00000000..b0452dc1 --- /dev/null +++ b/Penumbra/Interop/Hooks/Objects/CharacterDestructor.cs @@ -0,0 +1,52 @@ +using Dalamud.Hooking; +using FFXIVClientStructs.FFXIV.Client.Game.Character; +using OtterGui.Classes; +using OtterGui.Services; +using Penumbra.GameData; + +namespace Penumbra.Interop.Hooks.Objects; + +public sealed unsafe class CharacterDestructor : EventWrapperPtr, IHookService +{ + public enum Priority + { + /// + CutsceneService = 0, + + /// + IdentifiedCollectionCache = 0, + + /// + DrawObjectState = 0, + } + + public CharacterDestructor(HookManager hooks) + : base("Character Destructor") + => _task = hooks.CreateHook(Name, Sigs.CharacterDestructor, Detour, !HookOverrides.Instance.Objects.CharacterDestructor); + + private readonly Task> _task; + + public nint Address + => _task.Result.Address; + + public void Enable() + => _task.Result.Enable(); + + public void Disable() + => _task.Result.Disable(); + + public Task Awaiter + => _task; + + public bool Finished + => _task.IsCompletedSuccessfully; + + private delegate void Delegate(Character* character); + + private void Detour(Character* character) + { + Penumbra.Log.Excessive($"[{Name}] Triggered with 0x{(nint)character:X}."); + Invoke(character); + _task.Result.Original(character); + } +} diff --git a/Penumbra/Interop/Hooks/Objects/ConstructCutsceneCharacter.cs b/Penumbra/Interop/Hooks/Objects/ConstructCutsceneCharacter.cs new file mode 100644 index 00000000..5fa3de32 --- /dev/null +++ b/Penumbra/Interop/Hooks/Objects/ConstructCutsceneCharacter.cs @@ -0,0 +1,70 @@ +using Dalamud.Hooking; +using FFXIVClientStructs.FFXIV.Client.Game.Character; +using OtterGui.Classes; +using OtterGui.Services; +using Penumbra.GameData; +using Penumbra.GameData.Enums; +using Penumbra.GameData.Interop; + +namespace Penumbra.Interop.Hooks.Objects; + +public sealed unsafe class ConstructCutsceneCharacter : EventWrapperPtr, IHookService +{ + private readonly GameState _gameState; + private readonly ObjectManager _objects; + + public enum Priority + { + /// + CutsceneService = 0, + } + + public ConstructCutsceneCharacter(GameState gameState, HookManager hooks, ObjectManager objects) + : base("ConstructCutsceneCharacter") + { + _gameState = gameState; + _objects = objects; + _task = hooks.CreateHook(Name, Sigs.ConstructCutsceneCharacter, Detour, !HookOverrides.Instance.Objects.ConstructCutsceneCharacter); + } + + private readonly Task> _task; + + public delegate int Delegate(SetupPlayerNpc.SchedulerStruct* scheduler); + + public int Detour(SetupPlayerNpc.SchedulerStruct* scheduler) + { + // This is the function that actually creates the new game object + // and fills it into the object table at a free index etc. + var ret = _task.Result.Original(scheduler); + // Check for the copy state from SetupPlayerNpc. + if (_gameState.CharacterAssociated.Value) + { + // If the newly created character exists, invoke the event. + var character = _objects[ret + (int)ScreenActor.CutsceneStart].AsCharacter; + if (character != null) + { + Invoke(character); + Penumbra.Log.Verbose( + $"[{Name}] Created indirect copy of player character at 0x{(nint)character}, index {character->ObjectIndex}."); + } + _gameState.CharacterAssociated.Value = false; + } + + return ret; + } + + public IntPtr Address + => _task.Result.Address; + + public void Enable() + => _task.Result.Enable(); + + public void Disable() + => _task.Result.Disable(); + + public Task Awaiter + => _task; + + public bool Finished + => _task.IsCompletedSuccessfully; +} diff --git a/Penumbra/Interop/Hooks/Objects/CopyCharacter.cs b/Penumbra/Interop/Hooks/Objects/CopyCharacter.cs new file mode 100644 index 00000000..bc18a7ad --- /dev/null +++ b/Penumbra/Interop/Hooks/Objects/CopyCharacter.cs @@ -0,0 +1,46 @@ +using Dalamud.Hooking; +using FFXIVClientStructs.FFXIV.Client.Game.Character; +using OtterGui.Classes; +using OtterGui.Services; + +namespace Penumbra.Interop.Hooks.Objects; + +public sealed unsafe class CopyCharacter : EventWrapperPtr, IHookService +{ + public enum Priority + { + /// + CutsceneService = 0, + } + + public CopyCharacter(HookManager hooks) + : base("Copy Character") + => _task = hooks.CreateHook(Name, Address, Detour, !HookOverrides.Instance.Objects.CopyCharacter); + + private readonly Task> _task; + + public nint Address + => (nint)CharacterSetupContainer.MemberFunctionPointers.CopyFromCharacter; + + public void Enable() + => _task.Result.Enable(); + + public void Disable() + => _task.Result.Disable(); + + public Task Awaiter + => _task; + + public bool Finished + => _task.IsCompletedSuccessfully; + + private delegate ulong Delegate(CharacterSetupContainer* target, Character* source, uint unk); + + private ulong Detour(CharacterSetupContainer* target, Character* source, uint unk) + { + var character = target->OwnerObject; + Penumbra.Log.Verbose($"[{Name}] Triggered with target: 0x{(nint)target:X}, source : 0x{(nint)source:X} unk: {unk}."); + Invoke(character, source); + return _task.Result.Original(target, source, unk); + } +} diff --git a/Penumbra/Interop/Hooks/Objects/CreateCharacterBase.cs b/Penumbra/Interop/Hooks/Objects/CreateCharacterBase.cs new file mode 100644 index 00000000..e29876ac --- /dev/null +++ b/Penumbra/Interop/Hooks/Objects/CreateCharacterBase.cs @@ -0,0 +1,74 @@ +using Dalamud.Hooking; +using FFXIVClientStructs.FFXIV.Client.Graphics.Scene; +using OtterGui.Classes; +using OtterGui.Services; +using Penumbra.GameData.Structs; + +namespace Penumbra.Interop.Hooks.Objects; + +public sealed unsafe class CreateCharacterBase : EventWrapperPtr, IHookService +{ + public enum Priority + { + /// + MetaState = 0, + } + + public CreateCharacterBase(HookManager hooks) + : base("Create CharacterBase") + => _task = hooks.CreateHook(Name, Address, Detour, !HookOverrides.Instance.Objects.CreateCharacterBase); + + private readonly Task> _task; + + public nint Address + => (nint)CharacterBase.MemberFunctionPointers.Create; + + public void Enable() + => _task.Result.Enable(); + + public void Disable() + => _task.Result.Disable(); + + public Task Awaiter + => _task; + + public bool Finished + => _task.IsCompletedSuccessfully; + + private delegate CharacterBase* Delegate(ModelCharaId model, CustomizeArray* customize, CharacterArmor* equipment, byte unk); + + private CharacterBase* Detour(ModelCharaId model, CustomizeArray* customize, CharacterArmor* equipment, byte unk) + { + Penumbra.Log.Verbose($"[{Name}] Triggered with model: {model.Id}, customize: 0x{(nint)customize:X}, equipment: 0x{(nint)equipment:X}, unk: {unk}."); + Invoke(&model, customize, equipment); + var ret = _task.Result.Original(model, customize, equipment, unk); + _postEvent.Invoke(model, customize, equipment, ret); + return ret; + } + + public void Subscribe(ActionPtr234 subscriber, PostEvent.Priority priority) + => _postEvent.Subscribe(subscriber, priority); + + public void Unsubscribe(ActionPtr234 subscriber) + => _postEvent.Unsubscribe(subscriber); + + + private readonly PostEvent _postEvent = new("Created CharacterBase"); + + protected override void Dispose(bool disposing) + { + _postEvent.Dispose(); + } + + public class PostEvent(string name) : EventWrapperPtr234(name) + { + public enum Priority + { + /// + DrawObjectState = 0, + + /// + MetaState = 0, + } + } +} diff --git a/Penumbra/Interop/Hooks/Objects/EnableDraw.cs b/Penumbra/Interop/Hooks/Objects/EnableDraw.cs new file mode 100644 index 00000000..979cb87c --- /dev/null +++ b/Penumbra/Interop/Hooks/Objects/EnableDraw.cs @@ -0,0 +1,48 @@ +using Dalamud.Hooking; +using FFXIVClientStructs.FFXIV.Client.Game.Object; +using OtterGui.Services; +using Penumbra.GameData; + +namespace Penumbra.Interop.Hooks.Objects; + +/// +/// EnableDraw is what creates DrawObjects for gameObjects, +/// so we always keep track of the current GameObject to be able to link it to the DrawObject. +/// +public sealed unsafe class EnableDraw : IHookService +{ + private readonly Task> _task; + private readonly GameState _state; + + public EnableDraw(HookManager hooks, GameState state) + { + _state = state; + _task = hooks.CreateHook("Enable Draw", Sigs.EnableDraw, Detour, !HookOverrides.Instance.Objects.EnableDraw); + } + + private delegate void Delegate(GameObject* gameObject); + + [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] + private void Detour(GameObject* gameObject) + { + _state.QueueGameObject(gameObject); + Penumbra.Log.Excessive($"[Enable Draw] Invoked on 0x{(nint)gameObject:X} at {gameObject->ObjectIndex}."); + _task.Result.Original.Invoke(gameObject); + _state.DequeueGameObject(); + } + + public Task Awaiter + => _task; + + public bool Finished + => _task.IsCompletedSuccessfully; + + public nint Address + => _task.Result.Address; + + public void Enable() + => _task.Result.Enable(); + + public void Disable() + => _task.Result.Disable(); +} diff --git a/Penumbra/Interop/Hooks/Objects/SetupPlayerNpc.cs b/Penumbra/Interop/Hooks/Objects/SetupPlayerNpc.cs new file mode 100644 index 00000000..8f1226c3 --- /dev/null +++ b/Penumbra/Interop/Hooks/Objects/SetupPlayerNpc.cs @@ -0,0 +1,55 @@ +using FFXIVClientStructs.FFXIV.Client.Game.Character; +using OtterGui.Services; +using Penumbra.GameData; + +namespace Penumbra.Interop.Hooks.Objects; + +public sealed unsafe class SetupPlayerNpc : FastHook +{ + private readonly GameState _gameState; + + public SetupPlayerNpc(GameState gameState, HookManager hooks) + { + _gameState = gameState; + Task = hooks.CreateHook("SetupPlayerNPC", Sigs.SetupPlayerNpc, Detour, + !HookOverrides.Instance.Objects.SetupPlayerNpc); + } + + public delegate SchedulerStruct* Delegate(byte* npcType, nint unk, NpcSetupData* setupData); + + public SchedulerStruct* Detour(byte* npcType, nint unk, NpcSetupData* setupData) + { + // This function actually seems to generate all NPC. + + // If an ENPC is being created, check the creation parameters. + // If CopyPlayerCustomize is true, the event NPC gets a timeline that copies its customize and glasses from the local player. + // Keep track of this, so we can associate the actor to be created for this with the player character, see ConstructCutsceneCharacter. + if (setupData->CopyPlayerCustomize && npcType != null && *npcType is 8) + _gameState.CharacterAssociated.Value = true; + + var ret = Task.Result.Original.Invoke(npcType, unk, setupData); + Penumbra.Log.Excessive( + $"[Setup Player NPC] Invoked for type {*npcType} with 0x{unk:X} and Copy Player Customize: {setupData->CopyPlayerCustomize}."); + return ret; + } + + [StructLayout(LayoutKind.Explicit)] + public struct NpcSetupData + { + [FieldOffset(0x0B)] + private byte _copyPlayerCustomize; + + public bool CopyPlayerCustomize + { + get => _copyPlayerCustomize != 0; + set => _copyPlayerCustomize = value ? (byte)1 : (byte)0; + } + } + + [StructLayout(LayoutKind.Explicit)] + public struct SchedulerStruct + { + public static Character* GetCharacter(SchedulerStruct* s) + => ((delegate* unmanaged**)s)[0][19](s); + } +} diff --git a/Penumbra/Interop/Hooks/Objects/WeaponReload.cs b/Penumbra/Interop/Hooks/Objects/WeaponReload.cs new file mode 100644 index 00000000..4231b027 --- /dev/null +++ b/Penumbra/Interop/Hooks/Objects/WeaponReload.cs @@ -0,0 +1,71 @@ +using Dalamud.Hooking; +using FFXIVClientStructs.FFXIV.Client.Game.Character; +using OtterGui.Classes; +using OtterGui.Services; +using Penumbra.GameData.Structs; + +namespace Penumbra.Interop.Hooks.Objects; + +public sealed unsafe class WeaponReload : EventWrapperPtr, IHookService +{ + public enum Priority + { + /// + DrawObjectState = 0, + } + + public WeaponReload(HookManager hooks) + : base("Reload Weapon") + => _task = hooks.CreateHook(Name, Address, Detour, !HookOverrides.Instance.Objects.WeaponReload); + + private readonly Task> _task; + + public nint Address + => (nint)DrawDataContainer.MemberFunctionPointers.LoadWeapon; + + public void Enable() + => _task.Result.Enable(); + + public void Disable() + => _task.Result.Disable(); + + public Task Awaiter + => _task; + + public bool Finished + => _task.IsCompletedSuccessfully; + + private delegate void Delegate(DrawDataContainer* drawData, uint slot, ulong weapon, byte d, byte e, byte f, byte g, byte h); + + private void Detour(DrawDataContainer* drawData, uint slot, ulong weapon, byte d, byte e, byte f, byte g, byte h) + { + var gameObject = drawData->OwnerObject; + Penumbra.Log.Verbose($"[{Name}] Triggered with drawData: 0x{(nint)drawData:X}, {slot}, {weapon}, {d}, {e}, {f}, {g}, {h}."); + Invoke(drawData, gameObject, (CharacterWeapon*)(&weapon)); + _task.Result.Original(drawData, slot, weapon, d, e, f, g, h); + _postEvent.Invoke(drawData, gameObject); + } + + public void Subscribe(ActionPtr subscriber, PostEvent.Priority priority) + => _postEvent.Subscribe(subscriber, priority); + + public void Unsubscribe(ActionPtr subscriber) + => _postEvent.Unsubscribe(subscriber); + + + private readonly PostEvent _postEvent = new("Created CharacterBase"); + + protected override void Dispose(bool disposing) + { + _postEvent.Dispose(); + } + + public class PostEvent(string name) : EventWrapperPtr(name) + { + public enum Priority + { + /// + DrawObjectState = 0, + } + } +} diff --git a/Penumbra/Interop/Hooks/PostProcessing/AttributeHook.cs b/Penumbra/Interop/Hooks/PostProcessing/AttributeHook.cs new file mode 100644 index 00000000..00e5851f --- /dev/null +++ b/Penumbra/Interop/Hooks/PostProcessing/AttributeHook.cs @@ -0,0 +1,85 @@ +using Dalamud.Hooking; +using FFXIVClientStructs.FFXIV.Client.Graphics.Scene; +using OtterGui.Classes; +using OtterGui.Services; +using Penumbra.Collections; +using Penumbra.GameData; +using Penumbra.GameData.Interop; +using Penumbra.Interop.PathResolving; +using Penumbra.Meta; + +namespace Penumbra.Interop.Hooks.PostProcessing; + +/// +/// Triggered whenever a model recomputes its attribute masks. +/// +/// Parameter is the game object that recomputed its attributes. +/// Parameter is the draw object on which the recomputation was called. +/// Parameter is the collection associated with the game object. +/// Parameter is the slot that was recomputed. If this is Unknown, it is a general new update call. +/// +public sealed unsafe class AttributeHook : EventWrapper, IHookService +{ + public enum Priority + { + /// + ShapeAttributeManager = 0, + } + + private readonly CollectionResolver _resolver; + private readonly Configuration _config; + + public AttributeHook(HookManager hooks, Configuration config, CollectionResolver resolver) + : base("Update Model Attributes") + { + _config = config; + _resolver = resolver; + _task = hooks.CreateHook(Name, Sigs.UpdateAttributes, Detour, config.EnableCustomShapes); + } + + private readonly Task> _task; + + public nint Address + => _task.Result.Address; + + public void Enable() + => SetState(true); + + public void Disable() + => SetState(false); + + public void SetState(bool enabled) + { + if (_config.EnableCustomShapes == enabled) + return; + + _config.EnableCustomShapes = enabled; + _config.Save(); + if (enabled) + _task.Result.Enable(); + else + _task.Result.Disable(); + } + + + public Task Awaiter + => _task; + + public bool Finished + => _task.IsCompletedSuccessfully; + + private delegate void Delegate(Human* human); + + private void Detour(Human* human) + { + _task.Result.Original(human); + var resolveData = _resolver.IdentifyCollection((DrawObject*)human, true); + var identifiedActor = resolveData.AssociatedGameObject; + var identifiedCollection = resolveData.ModCollection; + Penumbra.Log.Excessive($"[{Name}] Invoked on 0x{(ulong)human:X} (0x{identifiedActor:X})."); + Invoke(identifiedActor, human, identifiedCollection); + } + + protected override void Dispose(bool disposing) + => _task.Result.Dispose(); +} diff --git a/Penumbra/Interop/Hooks/PostProcessing/HumanSetupScalingHook.cs b/Penumbra/Interop/Hooks/PostProcessing/HumanSetupScalingHook.cs new file mode 100644 index 00000000..870229d6 --- /dev/null +++ b/Penumbra/Interop/Hooks/PostProcessing/HumanSetupScalingHook.cs @@ -0,0 +1,54 @@ +using FFXIVClientStructs.FFXIV.Client.Graphics.Scene; +using OtterGui.Services; + +namespace Penumbra.Interop.Hooks.PostProcessing; + +// TODO: "SetupScaling" does not seem to only set up scaling -> find a better name? +public unsafe class HumanSetupScalingHook : FastHook +{ + private const int ReplacementCapacity = 2; + + public event EventDelegate? SetupReplacements; + + public HumanSetupScalingHook(HookManager hooks, CharacterBaseVTables vTables) + => Task = hooks.CreateHook("Human.SetupScaling", vTables.HumanVTable[58], Detour, + !HookOverrides.Instance.PostProcessing.HumanSetupScaling); + + private void Detour(CharacterBase* drawObject, uint slotIndex) + { + Span replacements = stackalloc Replacement[ReplacementCapacity]; + var numReplacements = 0; + IDisposable? pbdDisposable = null; + object? shpkLock = null; + var releaseLock = false; + + try + { + SetupReplacements?.Invoke(drawObject, slotIndex, replacements, ref numReplacements, ref pbdDisposable, ref shpkLock); + if (shpkLock != null) + { + Monitor.Enter(shpkLock); + releaseLock = true; + } + + for (var i = 0; i < numReplacements; ++i) + *(nint*)replacements[i].AddressToReplace = replacements[i].ValueToSet; + Task.Result.Original(drawObject, slotIndex); + } + finally + { + for (var i = numReplacements; i-- > 0;) + *(nint*)replacements[i].AddressToReplace = replacements[i].ValueToRestore; + if (releaseLock) + Monitor.Exit(shpkLock!); + pbdDisposable?.Dispose(); + } + } + + public delegate void Delegate(CharacterBase* drawObject, uint slotIndex); + + public delegate void EventDelegate(CharacterBase* drawObject, uint slotIndex, Span replacements, ref int numReplacements, + ref IDisposable? pbdDisposable, ref object? shpkLock); + + public readonly record struct Replacement(nint AddressToReplace, nint ValueToSet, nint ValueToRestore); +} diff --git a/Penumbra/Interop/Hooks/PostProcessing/PreBoneDeformerReplacer.cs b/Penumbra/Interop/Hooks/PostProcessing/PreBoneDeformerReplacer.cs new file mode 100644 index 00000000..51af5813 --- /dev/null +++ b/Penumbra/Interop/Hooks/PostProcessing/PreBoneDeformerReplacer.cs @@ -0,0 +1,100 @@ +using Dalamud.Hooking; +using Dalamud.Plugin.Services; +using FFXIVClientStructs.FFXIV.Client.Graphics.Scene; +using FFXIVClientStructs.FFXIV.Client.System.Resource; +using OtterGui.Services; +using Penumbra.Api.Enums; +using Penumbra.Interop.Hooks.ResourceLoading; +using Penumbra.Interop.PathResolving; +using Penumbra.Interop.SafeHandles; +using Penumbra.String; +using Penumbra.String.Classes; +using CharacterUtility = Penumbra.Interop.Services.CharacterUtility; + +namespace Penumbra.Interop.Hooks.PostProcessing; + +public sealed unsafe class PreBoneDeformerReplacer : IDisposable, IRequiredService +{ + public static readonly Utf8GamePath PreBoneDeformerPath = + Utf8GamePath.FromSpan("chara/xls/boneDeformer/human.pbd"u8, MetaDataComputation.All, out var p) ? p : Utf8GamePath.Empty; + + // Approximate name guess. + private delegate void* CharacterBaseCreateDeformerDelegate(CharacterBase* drawObject, uint slotIndex); + + private readonly Hook _humanCreateDeformerHook; + + private readonly CharacterUtility _utility; + private readonly CollectionResolver _collectionResolver; + private readonly ResourceLoader _resourceLoader; + private readonly IFramework _framework; + private readonly HumanSetupScalingHook _humanSetupScalingHook; + + public PreBoneDeformerReplacer(CharacterUtility utility, CollectionResolver collectionResolver, ResourceLoader resourceLoader, + HookManager hooks, IFramework framework, CharacterBaseVTables vTables, HumanSetupScalingHook humanSetupScalingHook) + { + _utility = utility; + _collectionResolver = collectionResolver; + _resourceLoader = resourceLoader; + _framework = framework; + _humanSetupScalingHook = humanSetupScalingHook; + _humanSetupScalingHook.SetupReplacements += SetupHssReplacements; + _humanCreateDeformerHook = hooks.CreateHook("HumanCreateDeformer", vTables.HumanVTable[101], + CreateDeformer, !HookOverrides.Instance.PostProcessing.HumanCreateDeformer).Result; + } + + public void Dispose() + { + _humanCreateDeformerHook.Dispose(); + _humanSetupScalingHook.SetupReplacements -= SetupHssReplacements; + } + + private SafeResourceHandle GetPreBoneDeformerForCharacter(CharacterBase* drawObject) + { + var resolveData = _collectionResolver.IdentifyCollection(&drawObject->DrawObject, true); + if (resolveData.ModCollection._cache is not { } cache) + return _resourceLoader.LoadResolvedSafeResource(ResourceCategory.Chara, ResourceType.Pbd, PreBoneDeformerPath.Path, resolveData); + + return cache.CustomResources.Get(ResourceCategory.Chara, ResourceType.Pbd, PreBoneDeformerPath, resolveData); + } + + private void SetupHssReplacements(CharacterBase* drawObject, uint slotIndex, Span replacements, + ref int numReplacements, ref IDisposable? pbdDisposable, ref object? shpkLock) + { + if (!_framework.IsInFrameworkUpdateThread) + Penumbra.Log.Warning( + $"{nameof(PreBoneDeformerReplacer)}.{nameof(SetupHssReplacements)}(0x{(nint)drawObject:X}, {slotIndex}) called out of framework thread"); + + var preBoneDeformer = GetPreBoneDeformerForCharacter(drawObject); + try + { + pbdDisposable = preBoneDeformer; + replacements[numReplacements++] = new HumanSetupScalingHook.Replacement((nint)(&_utility.Address->HumanPbdResource), + (nint)preBoneDeformer.ResourceHandle, + _utility.DefaultHumanPbdResource); + } + catch + { + preBoneDeformer.Dispose(); + throw; + } + } + + private void* CreateDeformer(CharacterBase* drawObject, uint slotIndex) + { + if (!_framework.IsInFrameworkUpdateThread) + Penumbra.Log.Warning( + $"{nameof(PreBoneDeformerReplacer)}.{nameof(CreateDeformer)}(0x{(nint)drawObject:X}, {slotIndex}) called out of framework thread"); + + using var preBoneDeformer = GetPreBoneDeformerForCharacter(drawObject); + try + { + if (!preBoneDeformer.IsInvalid) + _utility.Address->HumanPbdResource = (Structs.ResourceHandle*)preBoneDeformer.ResourceHandle; + return _humanCreateDeformerHook.Original(drawObject, slotIndex); + } + finally + { + _utility.Address->HumanPbdResource = (Structs.ResourceHandle*)_utility.DefaultHumanPbdResource; + } + } +} diff --git a/Penumbra/Interop/Hooks/PostProcessing/RenderTargetHdrEnabler.cs b/Penumbra/Interop/Hooks/PostProcessing/RenderTargetHdrEnabler.cs new file mode 100644 index 00000000..653d9c1a --- /dev/null +++ b/Penumbra/Interop/Hooks/PostProcessing/RenderTargetHdrEnabler.cs @@ -0,0 +1,170 @@ +using System.Collections.Immutable; +using Dalamud.Hooking; +using Dalamud.Plugin; +using Dalamud.Plugin.Services; +using Dalamud.Utility.Signatures; +using FFXIVClientStructs.FFXIV.Client.Graphics.Kernel; +using FFXIVClientStructs.FFXIV.Client.Graphics.Render; +using OtterGui.Services; +using Penumbra.GameData; +using Penumbra.Interop.Hooks.ResourceLoading; +using Penumbra.Services; + +namespace Penumbra.Interop.Hooks.PostProcessing; + +public unsafe class RenderTargetHdrEnabler : IService, IDisposable +{ + /// This array must be sorted by CreationOrder ascending. + private static readonly ImmutableArray ForcedTextureConfigs = + [ + new ForcedTextureConfig(9, TextureFormat.R16G16B16A16_FLOAT, "Opaque Diffuse GBuffer"), + new ForcedTextureConfig(10, TextureFormat.R16G16B16A16_FLOAT, "Semitransparent Diffuse GBuffer"), + ]; + + private static readonly IComparer ForcedTextureConfigComparer + = Comparer.Create((lhs, rhs) => lhs.CreationOrder.CompareTo(rhs.CreationOrder)); + + private readonly Configuration _config; + private readonly Tuple _share; + private readonly ThreadLocal _textureIndices = new(() => new TextureIndices(-1, -1)); + + private readonly ThreadLocal?> _textures = new(() => null); + + public TextureReportRecord[]? TextureReport { get; private set; } + + private readonly Hook? _renderTargetManagerInitialize; + private readonly Hook? _createTexture2D; + + public RenderTargetHdrEnabler(IGameInteropProvider interop, Configuration config, IDalamudPluginInterface pi, + DalamudConfigService dalamudConfig, PeSigScanner peScanner) + { + _config = config; + if (peScanner.TryScanText(Sigs.RenderTargetManagerInitialize, out var initializeAddress) + && peScanner.TryScanText(Sigs.DeviceCreateTexture2D, out var createAddress)) + { + _renderTargetManagerInitialize = + interop.HookFromAddress(initializeAddress, RenderTargetManagerInitializeDetour); + _createTexture2D = interop.HookFromAddress(createAddress, CreateTexture2DDetour); + + if (config.HdrRenderTargets && !HookOverrides.Instance.PostProcessing.RenderTargetManagerInitialize) + _renderTargetManagerInitialize.Enable(); + } + + _share = pi.GetOrCreateData("Penumbra.RenderTargetHDR.V1", () => + { + bool? waitForPlugins = dalamudConfig.GetDalamudConfig(DalamudConfigService.WaitingForPluginsOption, out bool s) ? s : null; + return new Tuple(waitForPlugins, config.HdrRenderTargets, + !HookOverrides.Instance.PostProcessing.RenderTargetManagerInitialize, [0], [false]); + }); + ++_share.Item4[0]; + } + + public bool? FirstLaunchWaitForPluginsState + => _share.Item1; + + public bool FirstLaunchHdrState + => _share.Item2; + + public bool FirstLaunchHdrHookOverrideState + => _share.Item3; + + public int PenumbraReloadCount + => _share.Item4[0]; + + public bool HdrEnabledSuccess + => _share.Item5[0]; + + ~RenderTargetHdrEnabler() + => Dispose(false); + + + public static ForcedTextureConfig? GetForcedTextureConfig(int creationOrder) + { + var i = ForcedTextureConfigs.BinarySearch(new ForcedTextureConfig(creationOrder, 0, string.Empty), ForcedTextureConfigComparer); + return i >= 0 ? ForcedTextureConfigs[i] : null; + } + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + private void Dispose(bool _) + { + _createTexture2D?.Dispose(); + _renderTargetManagerInitialize?.Dispose(); + } + + private nint RenderTargetManagerInitializeDetour(RenderTargetManager* @this) + { + _createTexture2D!.Enable(); + _share.Item5[0] = true; + _textureIndices.Value = new TextureIndices(0, 0); + _textures.Value = _config.DebugMode ? [] : null; + try + { + return _renderTargetManagerInitialize!.Original(@this); + } + finally + { + if (_textures.Value != null) + { + TextureReport = CreateTextureReport(@this, _textures.Value); + _textures.Value = null; + } + + _textureIndices.Value = new TextureIndices(-1, -1); + _createTexture2D.Disable(); + } + } + + private Texture* CreateTexture2DDetour( + Device* @this, int* size, byte mipLevel, uint textureFormat, uint flags, uint unk) + { + var originalTextureFormat = textureFormat; + var indices = _textureIndices.IsValueCreated ? _textureIndices.Value : new TextureIndices(-1, -1); + if (indices.ConfigIndex >= 0 + && indices.ConfigIndex < ForcedTextureConfigs.Length + && ForcedTextureConfigs[indices.ConfigIndex].CreationOrder == indices.CreationOrder) + { + var config = ForcedTextureConfigs[indices.ConfigIndex++]; + textureFormat = (uint)config.ForcedTextureFormat; + } + + if (indices.CreationOrder >= 0) + { + ++indices.CreationOrder; + _textureIndices.Value = indices; + } + + var texture = _createTexture2D!.Original(@this, size, mipLevel, textureFormat, flags, unk); + if (_textures.IsValueCreated) + _textures.Value?.Add((nint)texture, (indices.CreationOrder - 1, originalTextureFormat)); + return texture; + } + + private static TextureReportRecord[] CreateTextureReport(RenderTargetManager* renderTargetManager, + Dictionary textures) + { + var rtmTextures = new Span(renderTargetManager, sizeof(RenderTargetManager) / sizeof(nint)); + var report = new List(); + for (var i = 0; i < rtmTextures.Length; ++i) + { + if (textures.TryGetValue(rtmTextures[i], out var texture)) + report.Add(new TextureReportRecord(i * sizeof(nint), texture.TextureIndex, (TextureFormat)texture.TextureFormat)); + } + + return report.ToArray(); + } + + private delegate nint RenderTargetManagerInitializeFunc(RenderTargetManager* @this); + + private delegate Texture* CreateTexture2DFunc(Device* @this, int* size, byte mipLevel, uint textureFormat, uint flags, uint unk); + + private record struct TextureIndices(int CreationOrder, int ConfigIndex); + + public readonly record struct ForcedTextureConfig(int CreationOrder, TextureFormat ForcedTextureFormat, string Comment); + + public readonly record struct TextureReportRecord(nint Offset, int CreationOrder, TextureFormat OriginalTextureFormat); +} diff --git a/Penumbra/Interop/Hooks/PostProcessing/ShaderReplacementFixer.cs b/Penumbra/Interop/Hooks/PostProcessing/ShaderReplacementFixer.cs new file mode 100644 index 00000000..b9c21556 --- /dev/null +++ b/Penumbra/Interop/Hooks/PostProcessing/ShaderReplacementFixer.cs @@ -0,0 +1,565 @@ +using Dalamud.Hooking; +using FFXIVClientStructs.FFXIV.Client.Graphics.Kernel; +using FFXIVClientStructs.FFXIV.Client.Graphics.Render; +using FFXIVClientStructs.FFXIV.Client.Graphics.Scene; +using FFXIVClientStructs.FFXIV.Client.System.Resource.Handle; +using OtterGui.Classes; +using OtterGui.Services; +using Penumbra.Communication; +using Penumbra.GameData; +using Penumbra.GameData.Files.MaterialStructs; +using Penumbra.Interop.Hooks.Resources; +using Penumbra.Interop.Structs; +using Penumbra.Services; +using Penumbra.String.Classes; +using CharacterUtility = Penumbra.Interop.Services.CharacterUtility; +using CSModelRenderer = FFXIVClientStructs.FFXIV.Client.Graphics.Render.ModelRenderer; +using ModelRenderer = Penumbra.Interop.Services.ModelRenderer; + +namespace Penumbra.Interop.Hooks.PostProcessing; + +public sealed unsafe class ShaderReplacementFixer : IDisposable, IRequiredService +{ + public static ReadOnlySpan SkinShpkName + => "skin.shpk"u8; + + public static ReadOnlySpan CharacterStockingsShpkName + => "characterstockings.shpk"u8; + + public static ReadOnlySpan CharacterLegacyShpkName + => "characterlegacy.shpk"u8; + + public static ReadOnlySpan IrisShpkName + => "iris.shpk"u8; + + public static ReadOnlySpan CharacterGlassShpkName + => "characterglass.shpk"u8; + + public static ReadOnlySpan CharacterTransparencyShpkName + => "charactertransparency.shpk"u8; + + public static ReadOnlySpan CharacterTattooShpkName + => "charactertattoo.shpk"u8; + + public static ReadOnlySpan CharacterOcclusionShpkName + => "characterocclusion.shpk"u8; + + public static ReadOnlySpan HairMaskShpkName + => "hairmask.shpk"u8; + + private delegate nint CharacterBaseOnRenderMaterialDelegate(CharacterBase* drawObject, CSModelRenderer.OnRenderMaterialParams* param); + + private delegate nint ModelRendererOnRenderMaterialDelegate(CSModelRenderer* modelRenderer, ushort* outFlags, + CSModelRenderer.OnRenderModelParams* param, Material* material, uint materialIndex); + + private delegate void ModelRendererUnkFuncDelegate(CSModelRenderer* modelRenderer, ModelRendererStructs.UnkPayload* unkPayload, uint unk2, + uint unk3, uint unk4, uint unk5); + + private readonly Hook _humanOnRenderMaterialHook; + + private readonly Hook _modelRendererOnRenderMaterialHook; + + private readonly Hook _modelRendererUnkFuncHook; + + private readonly Hook _prepareColorTableHook; + + private readonly ResourceHandleDestructor _resourceHandleDestructor; + private readonly CommunicatorService _communicator; + private readonly HumanSetupScalingHook _humanSetupScalingHook; + + private readonly ModdedShaderPackageState _skinState; + private readonly ModdedShaderPackageState _characterStockingsState; + private readonly ModdedShaderPackageState _characterLegacyState; + private readonly ModdedShaderPackageState _irisState; + private readonly ModdedShaderPackageState _characterGlassState; + private readonly ModdedShaderPackageState _characterTransparencyState; + private readonly ModdedShaderPackageState _characterTattooState; + private readonly ModdedShaderPackageState _characterOcclusionState; + private readonly ModdedShaderPackageState _hairMaskState; + + public bool Enabled { get; internal set; } = true; + + public uint ModdedSkinShpkCount + => _skinState.MaterialCount; + + public uint ModdedCharacterStockingsShpkCount + => _characterStockingsState.MaterialCount; + + public uint ModdedCharacterLegacyShpkCount + => _characterLegacyState.MaterialCount; + + public uint ModdedIrisShpkCount + => _irisState.MaterialCount; + + public uint ModdedCharacterGlassShpkCount + => _characterGlassState.MaterialCount; + + public uint ModdedCharacterTransparencyShpkCount + => _characterTransparencyState.MaterialCount; + + public uint ModdedCharacterTattooShpkCount + => _characterTattooState.MaterialCount; + + public uint ModdedCharacterOcclusionShpkCount + => _characterOcclusionState.MaterialCount; + + public uint ModdedHairMaskShpkCount + => _hairMaskState.MaterialCount; + + public ShaderReplacementFixer(ResourceHandleDestructor resourceHandleDestructor, CharacterUtility utility, ModelRenderer modelRenderer, + CommunicatorService communicator, HookManager hooks, CharacterBaseVTables vTables, HumanSetupScalingHook humanSetupScalingHook) + { + _resourceHandleDestructor = resourceHandleDestructor; + _communicator = communicator; + _humanSetupScalingHook = humanSetupScalingHook; + + _skinState = new ModdedShaderPackageState( + () => (ShaderPackageResourceHandle**)&utility.Address->SkinShpkResource, + () => (ShaderPackageResourceHandle*)utility.DefaultSkinShpkResource); + _characterStockingsState = new ModdedShaderPackageState( + () => (ShaderPackageResourceHandle**)&utility.Address->CharacterStockingsShpkResource, + () => (ShaderPackageResourceHandle*)utility.DefaultCharacterStockingsShpkResource); + _characterLegacyState = new ModdedShaderPackageState( + () => (ShaderPackageResourceHandle**)&utility.Address->CharacterLegacyShpkResource, + () => (ShaderPackageResourceHandle*)utility.DefaultCharacterLegacyShpkResource); + _irisState = new ModdedShaderPackageState(() => modelRenderer.IrisShaderPackage, () => modelRenderer.DefaultIrisShaderPackage); + _characterGlassState = new ModdedShaderPackageState(() => modelRenderer.CharacterGlassShaderPackage, + () => modelRenderer.DefaultCharacterGlassShaderPackage); + _characterTransparencyState = new ModdedShaderPackageState(() => modelRenderer.CharacterTransparencyShaderPackage, + () => modelRenderer.DefaultCharacterTransparencyShaderPackage); + _characterTattooState = new ModdedShaderPackageState(() => modelRenderer.CharacterTattooShaderPackage, + () => modelRenderer.DefaultCharacterTattooShaderPackage); + _characterOcclusionState = new ModdedShaderPackageState(() => modelRenderer.CharacterOcclusionShaderPackage, + () => modelRenderer.DefaultCharacterOcclusionShaderPackage); + _hairMaskState = + new ModdedShaderPackageState(() => modelRenderer.HairMaskShaderPackage, () => modelRenderer.DefaultHairMaskShaderPackage); + + _humanSetupScalingHook.SetupReplacements += SetupHssReplacements; + _humanOnRenderMaterialHook = hooks.CreateHook("Human.OnRenderMaterial", vTables.HumanVTable[64], + OnRenderHumanMaterial, !HookOverrides.Instance.PostProcessing.HumanOnRenderMaterial).Result; + _modelRendererOnRenderMaterialHook = hooks.CreateHook("ModelRenderer.OnRenderMaterial", + Sigs.ModelRendererOnRenderMaterial, ModelRendererOnRenderMaterialDetour, + !HookOverrides.Instance.PostProcessing.ModelRendererOnRenderMaterial).Result; + _modelRendererUnkFuncHook = hooks.CreateHook("ModelRenderer.UnkFunc", + Sigs.ModelRendererUnkFunc, ModelRendererUnkFuncDetour, + !HookOverrides.Instance.PostProcessing.ModelRendererUnkFunc).Result; + _prepareColorTableHook = hooks.CreateHook( + "MaterialResourceHandle.PrepareColorTable", + Sigs.PrepareColorSet, PrepareColorTableDetour, + !HookOverrides.Instance.PostProcessing.PrepareColorTable).Result; + + _communicator.MtrlLoaded.Subscribe(OnMtrlLoaded, MtrlLoaded.Priority.ShaderReplacementFixer); + _resourceHandleDestructor.Subscribe(OnResourceHandleDestructor, ResourceHandleDestructor.Priority.ShaderReplacementFixer); + } + + public void Dispose() + { + _prepareColorTableHook.Dispose(); + _modelRendererUnkFuncHook.Dispose(); + _modelRendererOnRenderMaterialHook.Dispose(); + _humanOnRenderMaterialHook.Dispose(); + _humanSetupScalingHook.SetupReplacements -= SetupHssReplacements; + + _communicator.MtrlLoaded.Unsubscribe(OnMtrlLoaded); + _resourceHandleDestructor.Unsubscribe(OnResourceHandleDestructor); + + _hairMaskState.ClearMaterials(); + _characterOcclusionState.ClearMaterials(); + _characterTattooState.ClearMaterials(); + _characterTransparencyState.ClearMaterials(); + _characterGlassState.ClearMaterials(); + _irisState.ClearMaterials(); + _characterLegacyState.ClearMaterials(); + _characterStockingsState.ClearMaterials(); + _skinState.ClearMaterials(); + } + + public (ulong Skin, ulong CharacterStockings, ulong CharacterLegacy, ulong Iris, ulong CharacterGlass, ulong CharacterTransparency, ulong + CharacterTattoo, ulong CharacterOcclusion, ulong HairMask) GetAndResetSlowPathCallDeltas() + => (_skinState.GetAndResetSlowPathCallDelta(), + _characterStockingsState.GetAndResetSlowPathCallDelta(), + _characterLegacyState.GetAndResetSlowPathCallDelta(), + _irisState.GetAndResetSlowPathCallDelta(), + _characterGlassState.GetAndResetSlowPathCallDelta(), + _characterTransparencyState.GetAndResetSlowPathCallDelta(), + _characterTattooState.GetAndResetSlowPathCallDelta(), + _characterOcclusionState.GetAndResetSlowPathCallDelta(), + _hairMaskState.GetAndResetSlowPathCallDelta()); + + private void OnMtrlLoaded(nint mtrlResourceHandle, nint gameObject) + { + var mtrl = (MaterialResourceHandle*)mtrlResourceHandle; + var shpk = mtrl->ShaderPackageResourceHandle; + if (shpk == null) + return; + + var shpkName = mtrl->ShpkName.AsSpan(); + var shpkState = GetStateForHumanSetup(shpkName) + ?? GetStateForHumanRender(shpkName) + ?? GetStateForModelRendererRender(shpkName) + ?? GetStateForModelRendererUnk(shpkName) ?? GetStateForColorTable(shpkName); + + if (shpkState != null && shpk != shpkState.DefaultShaderPackage) + shpkState.TryAddMaterial(mtrlResourceHandle); + } + + private void OnResourceHandleDestructor(Structs.ResourceHandle* handle) + { + _skinState.TryRemoveMaterial(handle); + _characterStockingsState.TryRemoveMaterial(handle); + _characterLegacyState.TryRemoveMaterial(handle); + _irisState.TryRemoveMaterial(handle); + _characterGlassState.TryRemoveMaterial(handle); + _characterTransparencyState.TryRemoveMaterial(handle); + _characterTattooState.TryRemoveMaterial(handle); + _characterOcclusionState.TryRemoveMaterial(handle); + _hairMaskState.TryRemoveMaterial(handle); + } + + private ModdedShaderPackageState? GetStateForHumanSetup(MaterialResourceHandle* mtrlResource) + => mtrlResource == null ? null : GetStateForHumanSetup(mtrlResource->ShpkName.AsSpan()); + + private ModdedShaderPackageState? GetStateForHumanSetup(ReadOnlySpan shpkName) + => CharacterStockingsShpkName.SequenceEqual(shpkName) ? _characterStockingsState : null; + + [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] + private uint GetTotalMaterialCountForHumanSetup() + => _characterStockingsState.MaterialCount; + + private ModdedShaderPackageState? GetStateForHumanRender(MaterialResourceHandle* mtrlResource) + => mtrlResource == null ? null : GetStateForHumanRender(mtrlResource->ShpkName.AsSpan()); + + private ModdedShaderPackageState? GetStateForHumanRender(ReadOnlySpan shpkName) + => SkinShpkName.SequenceEqual(shpkName) ? _skinState : null; + + [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] + private uint GetTotalMaterialCountForHumanRender() + => _skinState.MaterialCount; + + private ModdedShaderPackageState? GetStateForModelRendererRender(MaterialResourceHandle* mtrlResource) + => mtrlResource == null ? null : GetStateForModelRendererRender(mtrlResource->ShpkName.AsSpan()); + + private ModdedShaderPackageState? GetStateForModelRendererRender(ReadOnlySpan shpkName) + { + if (CharacterGlassShpkName.SequenceEqual(shpkName)) + return _characterGlassState; + + if (CharacterTransparencyShpkName.SequenceEqual(shpkName)) + return _characterTransparencyState; + + if (CharacterTattooShpkName.SequenceEqual(shpkName)) + return _characterTattooState; + + if (HairMaskShpkName.SequenceEqual(shpkName)) + return _hairMaskState; + + return null; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] + private uint GetTotalMaterialCountForModelRendererRender() + => _characterGlassState.MaterialCount + + _characterTransparencyState.MaterialCount + + _characterTattooState.MaterialCount + + _hairMaskState.MaterialCount; + + private ModdedShaderPackageState? GetStateForModelRendererUnk(MaterialResourceHandle* mtrlResource) + => mtrlResource == null ? null : GetStateForModelRendererUnk(mtrlResource->ShpkName.AsSpan()); + + private ModdedShaderPackageState? GetStateForModelRendererUnk(ReadOnlySpan shpkName) + { + if (IrisShpkName.SequenceEqual(shpkName)) + return _irisState; + + if (CharacterOcclusionShpkName.SequenceEqual(shpkName)) + return _characterOcclusionState; + + if (CharacterStockingsShpkName.SequenceEqual(shpkName)) + return _characterStockingsState; + + return null; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] + private uint GetTotalMaterialCountForModelRendererUnk() + => _irisState.MaterialCount + + _characterOcclusionState.MaterialCount + + _characterStockingsState.MaterialCount; + + private ModdedShaderPackageState? GetStateForColorTable(ReadOnlySpan shpkName) + => CharacterLegacyShpkName.SequenceEqual(shpkName) ? _characterLegacyState : null; + + [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] + private uint GetTotalMaterialCountForColorTable() + => _characterLegacyState.MaterialCount; + + private void SetupHssReplacements(CharacterBase* drawObject, uint slotIndex, Span replacements, + ref int numReplacements, ref IDisposable? pbdDisposable, ref object? shpkLock) + { + // If we don't have any on-screen instances of modded characterstockings.shpk, we don't need the slow path at all. + if (!Enabled || GetTotalMaterialCountForHumanSetup() == 0) + return; + + var model = drawObject->Models[slotIndex]; + if (model == null) + return; + + MaterialResourceHandle* mtrlResource = null; + ModdedShaderPackageState? shpkState = null; + foreach (var material in model->MaterialsSpan) + { + if (material.Value == null) + continue; + + mtrlResource = material.Value->MaterialResourceHandle; + shpkState = GetStateForHumanSetup(mtrlResource); + // Despite this function being called with what designates a model (and therefore potentially many materials), + // we currently don't need to handle more than one modded ShPk. + if (shpkState != null) + break; + } + + if (shpkState == null || shpkState.MaterialCount == 0) + return; + + shpkState.IncrementSlowPathCallDelta(); + + // This is less performance-critical than the others, as this is called by the game only on draw object creation and slot update. + // There are still thread safety concerns as it might be called in other threads by plugins. + shpkLock = shpkState; + replacements[numReplacements++] = new HumanSetupScalingHook.Replacement((nint)shpkState.ShaderPackageReference, + (nint)mtrlResource->ShaderPackageResourceHandle, + (nint)shpkState.DefaultShaderPackage); + } + + private nint OnRenderHumanMaterial(CharacterBase* human, CSModelRenderer.OnRenderMaterialParams* param) + { + // If we don't have any on-screen instances of modded skin.shpk, we don't need the slow path at all. + if (!Enabled || GetTotalMaterialCountForHumanRender() == 0) + return _humanOnRenderMaterialHook.Original(human, param); + + var material = param->Model->Materials[param->MaterialIndex]; + var mtrlResource = material->MaterialResourceHandle; + var shpkState = GetStateForHumanRender(mtrlResource); + if (shpkState == null || shpkState.MaterialCount == 0) + return _humanOnRenderMaterialHook.Original(human, param); + + shpkState.IncrementSlowPathCallDelta(); + + // Performance considerations: + // - This function is called from several threads simultaneously, hence the need for synchronization in the swapping path ; + // - Function is called each frame for each material on screen, after culling, i.e. up to thousands of times a frame in crowded areas ; + // - Swapping path is taken up to hundreds of times a frame. + // At the time of writing, the lock doesn't seem to have a noticeable impact in either frame rate or CPU usage, but the swapping path shall still be avoided as much as possible. + lock (shpkState) + { + var shpkReference = shpkState.ShaderPackageReference; + try + { + *shpkReference = mtrlResource->ShaderPackageResourceHandle; + return _humanOnRenderMaterialHook.Original(human, param); + } + finally + { + *shpkReference = shpkState.DefaultShaderPackage; + } + } + } + + private nint ModelRendererOnRenderMaterialDetour(CSModelRenderer* modelRenderer, ushort* outFlags, + CSModelRenderer.OnRenderModelParams* param, Material* material, uint materialIndex) + { + // If we don't have any on-screen instances of modded characterglass.shpk or others, we don't need the slow path at all. + if (!Enabled || GetTotalMaterialCountForModelRendererRender() == 0) + return _modelRendererOnRenderMaterialHook.Original(modelRenderer, outFlags, param, material, materialIndex); + + var mtrlResource = material->MaterialResourceHandle; + var shpkState = GetStateForModelRendererRender(mtrlResource); + if (shpkState == null || shpkState.MaterialCount == 0) + return _modelRendererOnRenderMaterialHook.Original(modelRenderer, outFlags, param, material, materialIndex); + + shpkState.IncrementSlowPathCallDelta(); + + // Same performance considerations as OnRenderHumanMaterial. + lock (shpkState) + { + var shpkReference = shpkState.ShaderPackageReference; + try + { + *shpkReference = mtrlResource->ShaderPackageResourceHandle; + return _modelRendererOnRenderMaterialHook.Original(modelRenderer, outFlags, param, material, materialIndex); + } + finally + { + *shpkReference = shpkState.DefaultShaderPackage; + } + } + } + + private void ModelRendererUnkFuncDetour(CSModelRenderer* modelRenderer, ModelRendererStructs.UnkPayload* unkPayload, uint unk2, uint unk3, + uint unk4, uint unk5) + { + if (!Enabled || GetTotalMaterialCountForModelRendererUnk() == 0) + { + _modelRendererUnkFuncHook.Original(modelRenderer, unkPayload, unk2, unk3, unk4, unk5); + return; + } + + var mtrlResource = GetMaterialResourceHandle(unkPayload); + var shpkState = GetStateForModelRendererUnk(mtrlResource); + if (shpkState == null || shpkState.MaterialCount == 0) + { + _modelRendererUnkFuncHook.Original(modelRenderer, unkPayload, unk2, unk3, unk4, unk5); + return; + } + + shpkState.IncrementSlowPathCallDelta(); + + // Same performance considerations as OnRenderHumanMaterial. + lock (shpkState) + { + var shpkReference = shpkState.ShaderPackageReference; + try + { + *shpkReference = mtrlResource->ShaderPackageResourceHandle; + _modelRendererUnkFuncHook.Original(modelRenderer, unkPayload, unk2, unk3, unk4, unk5); + } + finally + { + *shpkReference = shpkState.DefaultShaderPackage; + } + } + } + + private static MaterialResourceHandle* GetMaterialResourceHandle(ModelRendererStructs.UnkPayload* unkPayload) + { + // TODO ClientStructs-ify + var unkPointer = *(nint*)((nint)unkPayload->ModelResourceHandle + 0xE8) + unkPayload->UnkIndex * 0x24; + var materialIndex = *(ushort*)(unkPointer + 8); + var material = unkPayload->Params->Model->Materials[materialIndex]; + if (material == null) + return null; + + var mtrlResource = material->MaterialResourceHandle; + if (mtrlResource == null) + return null; + + if (mtrlResource->ShaderPackageResourceHandle == null) + { + Penumbra.Log.Warning("ShaderReplacementFixer found a MaterialResourceHandle with no shader package"); + return null; + } + + if (mtrlResource->ShaderPackageResourceHandle->ShaderPackage != unkPayload->ShaderWrapper->ShaderPackage) + { + Penumbra.Log.Warning( + $"ShaderReplacementFixer found a MaterialResourceHandle (0x{(nint)mtrlResource:X}) with an inconsistent shader package (got 0x{(nint)mtrlResource->ShaderPackageResourceHandle->ShaderPackage:X}, expected 0x{(nint)unkPayload->ShaderWrapper->ShaderPackage:X})"); + return null; + } + + return mtrlResource; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] + private static int GetDataSetExpectedSize(uint dataFlags) + => (dataFlags & 4) != 0 + ? ColorTable.Size + ((dataFlags & 8) != 0 ? ColorDyeTable.Size : 0) + : 0; + + private Texture* PrepareColorTableDetour(MaterialResourceHandle* thisPtr, byte stain0Id, byte stain1Id) + { + if (thisPtr->DataSetSize < GetDataSetExpectedSize(thisPtr->DataFlags) && Utf8GamePath.IsRooted(thisPtr->FileName.AsSpan())) + Penumbra.Log.Warning( + $"Material at {thisPtr->FileName} has data set of size {thisPtr->DataSetSize} bytes, but should have at least {GetDataSetExpectedSize(thisPtr->DataFlags)} bytes. This may cause crashes due to access violations."); + + // If we don't have any on-screen instances of modded characterlegacy.shpk, we don't need the slow path at all. + if (!Enabled || GetTotalMaterialCountForColorTable() == 0) + return _prepareColorTableHook.Original(thisPtr, stain0Id, stain1Id); + + var material = thisPtr->Material; + if (material == null) + return _prepareColorTableHook.Original(thisPtr, stain0Id, stain1Id); + + var shpkState = GetStateForColorTable(thisPtr->ShpkName.AsSpan()); + if (shpkState == null || shpkState.MaterialCount == 0) + return _prepareColorTableHook.Original(thisPtr, stain0Id, stain1Id); + + shpkState.IncrementSlowPathCallDelta(); + + // Same performance considerations as HumanSetupScalingDetour. + lock (shpkState) + { + var shpkReference = shpkState.ShaderPackageReference; + try + { + *shpkReference = thisPtr->ShaderPackageResourceHandle; + return _prepareColorTableHook.Original(thisPtr, stain0Id, stain1Id); + } + finally + { + *shpkReference = shpkState.DefaultShaderPackage; + } + } + } + + private sealed class ModdedShaderPackageState(ShaderPackageReferenceGetter referenceGetter, DefaultShaderPackageGetter defaultGetter) + { + // MaterialResourceHandle set + private readonly ConcurrentSet _materials = new(); + + // ConcurrentDictionary.Count uses a lock in its current implementation. + private uint _materialCount; + private ulong _slowPathCallDelta; + + public uint MaterialCount + { + [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] + get => _materialCount; + } + + public ShaderPackageResourceHandle** ShaderPackageReference + { + [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] + get => referenceGetter(); + } + + public ShaderPackageResourceHandle* DefaultShaderPackage + { + [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] + get => defaultGetter(); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] + public void TryAddMaterial(nint mtrlResourceHandle) + { + if (_materials.TryAdd(mtrlResourceHandle)) + Interlocked.Increment(ref _materialCount); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] + public void TryRemoveMaterial(Structs.ResourceHandle* handle) + { + if (_materials.TryRemove((nint)handle)) + Interlocked.Decrement(ref _materialCount); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] + public void ClearMaterials() + { + _materials.Clear(); + _materialCount = 0; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] + public void IncrementSlowPathCallDelta() + => Interlocked.Increment(ref _slowPathCallDelta); + + [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] + public ulong GetAndResetSlowPathCallDelta() + => Interlocked.Exchange(ref _slowPathCallDelta, 0); + } + + private delegate ShaderPackageResourceHandle* DefaultShaderPackageGetter(); + + private delegate ShaderPackageResourceHandle** ShaderPackageReferenceGetter(); +} diff --git a/Penumbra/Interop/Hooks/ResourceLoading/CreateFileWHook.cs b/Penumbra/Interop/Hooks/ResourceLoading/CreateFileWHook.cs new file mode 100644 index 00000000..a9a5f41d --- /dev/null +++ b/Penumbra/Interop/Hooks/ResourceLoading/CreateFileWHook.cs @@ -0,0 +1,176 @@ +using Dalamud.Hooking; +using Dalamud.Plugin.Services; +using OtterGui.Services; +using Penumbra.String; +using Penumbra.String.Classes; +using Penumbra.String.Functions; + +namespace Penumbra.Interop.Hooks.ResourceLoading; + +/// +/// To allow XIV to load files of arbitrary path length, +/// we use the fixed size buffers of their formats to only store pointers to the actual path instead. +/// Then we translate the stored pointer to the path in CreateFileW, if the prefix matches. +/// +public unsafe class CreateFileWHook : IDisposable, IRequiredService +{ + public const int Size = 28; + + public CreateFileWHook(IGameInteropProvider interop) + { + _createFileWHook = interop.HookFromImport(null, "KERNEL32.dll", "CreateFileW", 0, CreateFileWDetour); + if (!HookOverrides.Instance.ResourceLoading.CreateFileWHook) + _createFileWHook.Enable(); + } + + /// + /// Write the data read specifically in the CreateFileW hook to a buffer array. + /// + /// The buffer the data is written to. + /// The pointer to the UTF8 string containing the path. + /// The length of the path in bytes. + public static void WritePtr(char* buffer, byte* address, int length) + { + // Set the prefix, which is not valid for any actual path. + buffer[0] = Prefix; + + var ptr = (byte*)buffer; + var v = (ulong)address; + var l = (uint)length; + + // Since the game calls wstrcpy without a length, we need to ensure + // that there is no wchar_t (i.e. 2 bytes) of 0-values before the end. + // Fill everything with 0xFF and use every second byte. + MemoryUtility.MemSet(ptr + 2, 0xFF, 23); + + // Write the byte pointer. + ptr[2] = (byte)(v >> 0); + ptr[4] = (byte)(v >> 8); + ptr[6] = (byte)(v >> 16); + ptr[8] = (byte)(v >> 24); + ptr[10] = (byte)(v >> 32); + ptr[12] = (byte)(v >> 40); + ptr[14] = (byte)(v >> 48); + ptr[16] = (byte)(v >> 56); + + // Write the length. + ptr[18] = (byte)(l >> 0); + ptr[20] = (byte)(l >> 8); + ptr[22] = (byte)(l >> 16); + ptr[24] = (byte)(l >> 24); + + ptr[Size - 2] = 0; + ptr[Size - 1] = 0; + } + + public void Dispose() + { + _createFileWHook.Disable(); + _createFileWHook.Dispose(); + foreach (var ptr in _fileNameStorage.Values) + Marshal.FreeHGlobal(ptr); + } + + /// Long paths in windows need to start with "\\?\", so we keep this static in the pointers. + private static nint SetupStorage() + { + var ptr = (char*)Marshal.AllocHGlobal(2 * BufferSize); + ptr[0] = '\\'; + ptr[1] = '\\'; + ptr[2] = '?'; + ptr[3] = '\\'; + ptr[4] = '\0'; + return (nint)ptr; + } + + // The prefix is not valid for any actual path, so should never run into false-positives. + private const char Prefix = (char)((byte)'P' | (('?' & 0x00FF) << 8)); + private const int BufferSize = Utf8GamePath.MaxGamePathLength; + + private delegate nint CreateFileWDelegate(char* fileName, uint access, uint shareMode, nint security, uint creation, uint flags, + nint template); + + private readonly Hook _createFileWHook; + + /// Some storage to skip repeated allocations. + private readonly ThreadLocal _fileNameStorage = new(SetupStorage, true); + + private nint CreateFileWDetour(char* fileName, uint access, uint shareMode, nint security, uint creation, uint flags, nint template) + { + // Translate data if prefix fits. + if (CheckPtr(fileName, out var name)) + { + // Use static storage. + var ptr = WriteFileName(name); + Penumbra.Log.Excessive($"[ResourceHooks] Calling CreateFileWDetour with {CiByteString.FromSpanUnsafe(name, false)}."); + return _createFileWHook.OriginalDisposeSafe(ptr, access, shareMode, security, creation, flags, template); + } + + return _createFileWHook.OriginalDisposeSafe(fileName, access, shareMode, security, creation, flags, template); + } + + + /// Write the UTF8-encoded byte string as UTF16 into the static buffers, + /// replacing any forward-slashes with back-slashes and adding a terminating null-wchar_t. + private char* WriteFileName(ReadOnlySpan actualName) + { + var span = new Span((char*)_fileNameStorage.Value + 4, BufferSize - 4); + var written = Encoding.UTF8.GetChars(actualName, span); + for (var i = 0; i < written; ++i) + { + if (span[i] == '/') + span[i] = '\\'; + } + + span[written] = '\0'; + + return (char*)_fileNameStorage.Value; + } + + private static bool CheckPtr(char* buffer, out ReadOnlySpan fileName) + { + if (buffer[0] is not Prefix) + { + fileName = ReadOnlySpan.Empty; + return false; + } + + var ptr = (byte*)buffer; + + // Read the byte pointer. + var address = 0ul; + address |= (ulong)ptr[2] << 0; + address |= (ulong)ptr[4] << 8; + address |= (ulong)ptr[6] << 16; + address |= (ulong)ptr[8] << 24; + address |= (ulong)ptr[10] << 32; + address |= (ulong)ptr[12] << 40; + address |= (ulong)ptr[14] << 48; + address |= (ulong)ptr[16] << 56; + + // Read the length. + var length = 0u; + length |= (uint)ptr[18] << 0; + length |= (uint)ptr[20] << 8; + length |= (uint)ptr[22] << 16; + length |= (uint)ptr[24] << 24; + + fileName = new ReadOnlySpan((void*)address, (int)length); + return true; + } + + // ***** Old method ***** + + //[DllImport( "kernel32.dll" )] + //private static extern nint LoadLibrary( string dllName ); + // + //[DllImport( "kernel32.dll" )] + //private static extern nint GetProcAddress( nint hModule, string procName ); + // + //public CreateFileWHookOld() + //{ + // var userApi = LoadLibrary( "kernel32.dll" ); + // var createFileAddress = GetProcAddress( userApi, "CreateFileW" ); + // _createFileWHook = Hook.FromAddress( createFileAddress, CreateFileWDetour ); + //} +} diff --git a/Penumbra/Interop/Hooks/ResourceLoading/FileReadService.cs b/Penumbra/Interop/Hooks/ResourceLoading/FileReadService.cs new file mode 100644 index 00000000..d8801b81 --- /dev/null +++ b/Penumbra/Interop/Hooks/ResourceLoading/FileReadService.cs @@ -0,0 +1,89 @@ +using Dalamud.Hooking; +using Dalamud.Plugin.Services; +using Dalamud.Utility.Signatures; +using OtterGui.Services; +using Penumbra.GameData; +using Penumbra.Interop.Structs; +using Penumbra.Util; + +namespace Penumbra.Interop.Hooks.ResourceLoading; + +public unsafe class FileReadService : IDisposable, IRequiredService +{ + public FileReadService(PerformanceTracker performance, ResourceManagerService resourceManager, IGameInteropProvider interop) + { + _resourceManager = resourceManager; + _performance = performance; + interop.InitializeFromAttributes(this); + if (!HookOverrides.Instance.ResourceLoading.ReadSqPack) + _readSqPackHook.Enable(); + } + + /// Invoked when a file is supposed to be read from SqPack. + /// The file descriptor containing what file to read. + /// The games priority. Should not generally be changed. + /// Whether the file needs to be loaded synchronously. Should not generally be changed. + /// The return value. If this is set, original will not be called. + public delegate void ReadSqPackDelegate(SeFileDescriptor* fileDescriptor, ref int priority, ref bool isSync, ref byte? returnValue); + + /// + /// + /// Subscribers should be exception-safe. + /// + public event ReadSqPackDelegate? ReadSqPack; + + /// + /// Use the games ReadFile function to read a file from the hard drive instead of an SqPack. + /// + /// The file to load. + /// The games priority. + /// Whether the file needs to be loaded synchronously. + /// Unknown, not directly success/failure. + public byte ReadFile(SeFileDescriptor* fileDescriptor, int priority, bool isSync) + => _readFile.Invoke(GetResourceManager(), fileDescriptor, priority, isSync); + + public byte ReadDefaultSqPack(SeFileDescriptor* fileDescriptor, int priority, bool isSync) + => _readSqPackHook.Original(GetResourceManager(), fileDescriptor, priority, isSync); + + public void Dispose() + { + _readSqPackHook.Dispose(); + } + + private readonly PerformanceTracker _performance; + private readonly ResourceManagerService _resourceManager; + + private delegate byte ReadSqPackPrototype(nint resourceManager, SeFileDescriptor* pFileDesc, int priority, bool isSync); + + [Signature(Sigs.ReadSqPack, DetourName = nameof(ReadSqPackDetour))] + private readonly Hook _readSqPackHook = null!; + + private byte ReadSqPackDetour(nint resourceManager, SeFileDescriptor* fileDescriptor, int priority, bool isSync) + { + using var performance = _performance.Measure(PerformanceType.ReadSqPack); + byte? ret = null; + _lastFileThreadResourceManager.Value = resourceManager; + ReadSqPack?.Invoke(fileDescriptor, ref priority, ref isSync, ref ret); + _lastFileThreadResourceManager.Value = nint.Zero; + return ret ?? _readSqPackHook.Original(resourceManager, fileDescriptor, priority, isSync); + } + + + private delegate byte ReadFileDelegate(nint resourceManager, SeFileDescriptor* fileDescriptor, int priority, + bool isSync); + + /// We need to use the ReadFile function to load local, uncompressed files instead of loading them from the SqPacks. + [Signature(Sigs.ReadFile)] + private readonly ReadFileDelegate _readFile = null!; + + private readonly ThreadLocal _lastFileThreadResourceManager = new(true); + + /// + /// Usually files are loaded using the resource manager as a first pointer, but it seems some rare cases are using something else. + /// So we keep track of them per thread and use them. + /// + private nint GetResourceManager() + => !_lastFileThreadResourceManager.IsValueCreated || _lastFileThreadResourceManager.Value == nint.Zero + ? (nint)_resourceManager.ResourceManager + : _lastFileThreadResourceManager.Value; +} diff --git a/Penumbra/Interop/Hooks/ResourceLoading/MappedCodeReader.cs b/Penumbra/Interop/Hooks/ResourceLoading/MappedCodeReader.cs new file mode 100644 index 00000000..de0014d2 --- /dev/null +++ b/Penumbra/Interop/Hooks/ResourceLoading/MappedCodeReader.cs @@ -0,0 +1,14 @@ +using Iced.Intel; + +namespace Penumbra.Interop.Hooks.ResourceLoading; + +public class MappedCodeReader(UnmanagedMemoryAccessor data, long offset) : CodeReader +{ + public override int ReadByte() + { + if (offset >= data.Capacity) + return -1; + + return data.ReadByte(offset++); + } +} diff --git a/Penumbra/Interop/Hooks/ResourceLoading/PapHandler.cs b/Penumbra/Interop/Hooks/ResourceLoading/PapHandler.cs new file mode 100644 index 00000000..35ee86dc --- /dev/null +++ b/Penumbra/Interop/Hooks/ResourceLoading/PapHandler.cs @@ -0,0 +1,33 @@ +using Penumbra.GameData; + +namespace Penumbra.Interop.Hooks.ResourceLoading; + +public sealed class PapHandler(PeSigScanner sigScanner, PapRewriter.PapResourceHandlerPrototype papResourceHandler) : IDisposable +{ + private readonly PapRewriter _papRewriter = new(sigScanner, papResourceHandler); + + public void Enable() + { + if (HookOverrides.Instance.ResourceLoading.PapHooks) + return; + + ReadOnlySpan<(string Sig, string Name)> signatures = + [ + (Sigs.LoadAlwaysResidentMotionPacks, nameof(Sigs.LoadAlwaysResidentMotionPacks)), + (Sigs.LoadWeaponDependentResidentMotionPacks, nameof(Sigs.LoadWeaponDependentResidentMotionPacks)), + (Sigs.LoadInitialResidentMotionPacks, nameof(Sigs.LoadInitialResidentMotionPacks)), + (Sigs.LoadMotionPacks, nameof(Sigs.LoadMotionPacks)), + (Sigs.LoadMotionPacks2, nameof(Sigs.LoadMotionPacks2)), + (Sigs.LoadMigratoryMotionPack, nameof(Sigs.LoadMigratoryMotionPack)), + ]; + + var stopwatch = Stopwatch.StartNew(); + foreach (var (sig, name) in signatures) + _papRewriter.Rewrite(sig, name); + Penumbra.Log.Debug( + $"[PapHandler] Rewrote {signatures.Length} .pap functions for inlined GetResourceAsync in {stopwatch.ElapsedMilliseconds} ms."); + } + + public void Dispose() + => _papRewriter.Dispose(); +} diff --git a/Penumbra/Interop/Hooks/ResourceLoading/PapRewriter.cs b/Penumbra/Interop/Hooks/ResourceLoading/PapRewriter.cs new file mode 100644 index 00000000..ff794d81 --- /dev/null +++ b/Penumbra/Interop/Hooks/ResourceLoading/PapRewriter.cs @@ -0,0 +1,215 @@ +using System.Text.Unicode; +using Dalamud.Hooking; +using Iced.Intel; +using static Iced.Intel.AssemblerRegisters; +using OtterGui.Extensions; +using Penumbra.String.Classes; +using Swan; + +namespace Penumbra.Interop.Hooks.ResourceLoading; + +public sealed class PapRewriter(PeSigScanner sigScanner, PapRewriter.PapResourceHandlerPrototype papResourceHandler) : IDisposable +{ + public unsafe delegate int PapResourceHandlerPrototype(void* self, byte* path, int length); + + private readonly Dictionary _hooks = []; + private readonly Dictionary<(nint, Register, ulong), nint> _nativeAllocPaths = []; + private readonly List _nativeAllocCaves = []; + + public void Rewrite(string sig, string name) + { + if (!sigScanner.TryScanText(sig, out var address)) + throw new Exception($"Signature for {name} [{sig}] could not be found."); + + var funcInstructions = sigScanner.GetFunctionInstructions(address).ToArray(); + var hookPoints = ScanPapHookPoints(funcInstructions).ToList(); + + foreach (var hookPoint in hookPoints) + { + var stackAccesses = ScanStackAccesses(funcInstructions, hookPoint).ToList(); + var stringAllocation = NativeAllocPath( + address, hookPoint.MemoryBase, hookPoint.MemoryDisplacement64, + Utf8GamePath.MaxGamePathLength + ); + WriteToAlloc(stringAllocation, Utf8GamePath.MaxGamePathLength, name); + + // We'll need to grab our true hook point; the location where we can change the path at our leisure. + // This is going to be the first call instruction after our 'hookPoint', so, we'll find that. + // Pretty scuffed, this might need a refactoring at some point. + // We're doing it by skipping to our hookPoint's address in the list of instructions inside the function; then getting next CALL + var skipIndex = funcInstructions.IndexOf(instr => instr.IP == hookPoint.IP) + 1; + var detourPoint = funcInstructions.Skip(skipIndex) + .First(instr => instr.Mnemonic == Mnemonic.Call); + + // We'll also remove all the 'hookPoints' from 'stackAccesses'. + // We're handling the char *path redirection here, so we don't want this to hit the later code + foreach (var hp in hookPoints) + stackAccesses.RemoveAll(instr => instr.IP == hp.IP); + + var detourPointer = Marshal.GetFunctionPointerForDelegate(papResourceHandler); + var targetRegister = GetRegister64(hookPoint.Op0Register); + var hookAddress = new IntPtr((long)detourPoint.IP); + + var caveAllocation = NativeAllocCave(16); + var assembler = new Assembler(64); + assembler.mov(targetRegister, stringAllocation); // Move our char *path into the relevant register (rdx) + + // After this asm stub, we have a call to Crc32(); since r9 is a volatile, unused register, we can use it ourselves + // We're essentially storing the original 2 arguments ('this', 'path'), in case they get mangled in our call + // We technically don't need to save rdx ('path'), since it'll be stringLoc, but eh + assembler.mov(r9, caveAllocation); + assembler.mov(__qword_ptr[r9], rcx); + assembler.mov(__qword_ptr[r9 + 8], rdx); + + // We can use 'rax' here too since it's also volatile, and it'll be overwritten by Crc32()'s return anyway + assembler.mov(rax, detourPointer); + assembler.call(rax); + + // Do the reverse process and retrieve the stored stuff + assembler.mov(r9, caveAllocation); + assembler.mov(rcx, __qword_ptr[r9]); + assembler.mov(rdx, __qword_ptr[r9 + 8]); + + // Plop 'rax' (our return value, the path size) into r8, so it's the third argument for the subsequent Crc32() call + assembler.mov(r8, rax); + var hook = new AsmHook(hookAddress, AssembleToBytes(assembler), $"{name}.PapRedirection"); + + _hooks.Add(hookAddress, hook); + hook.Enable(); + + // Now we're adjusting every single reference to the stack allocated 'path' to our substantially bigger 'stringLoc' + UpdatePathAddresses(stackAccesses, stringAllocation, name); + } + } + + private void UpdatePathAddresses(IEnumerable stackAccesses, nint stringAllocation, string name) + { + foreach (var (stackAccess, index) in stackAccesses.WithIndex()) + { + var hookAddress = new IntPtr((long)stackAccess.IP + stackAccess.Length); + + // Hook already exists, means there's reuse of the same stack address across 2 GetResourceAsync; just skip + if (_hooks.ContainsKey(hookAddress)) + continue; + + var targetRegister = GetRegister64(stackAccess.Op0Register); + var assembler = new Assembler(64); + assembler.mov(targetRegister, stringAllocation); + var hook = new AsmHook(hookAddress, AssembleToBytes(assembler), $"{name}.PapStackAccess[{index}]"); + + _hooks.Add(hookAddress, hook); + hook.Enable(); + } + } + + private static AssemblerRegister64 GetRegister64(Register reg) + => reg switch + { + Register.RAX => rax, + Register.RCX => rcx, + Register.RDX => rdx, + Register.RBX => rbx, + Register.RSP => rsp, + Register.RBP => rbp, + Register.RSI => rsi, + Register.RDI => rdi, + Register.R8 => r8, + Register.R9 => r9, + Register.R10 => r10, + Register.R11 => r11, + Register.R12 => r12, + Register.R13 => r13, + Register.R14 => r14, + Register.R15 => r15, + _ => throw new ArgumentOutOfRangeException(nameof(reg), reg, "Unsupported register."), + }; + + private static byte[] AssembleToBytes(Assembler assembler) + { + using var stream = new MemoryStream(); + var writer = new StreamCodeWriter(stream); + assembler.Assemble(writer, 0); + return stream.ToArray(); + } + + private static IEnumerable ScanStackAccesses(IEnumerable instructions, Instruction hookPoint) + { + return instructions.Where(instr => + instr.Code == hookPoint.Code + && instr.Op0Kind == hookPoint.Op0Kind + && instr.Op1Kind == hookPoint.Op1Kind + && instr.MemoryBase == hookPoint.MemoryBase + && instr.MemoryDisplacement64 == hookPoint.MemoryDisplacement64) + .GroupBy(instr => instr.IP) + .Select(grp => grp.First()); + } + + // This is utterly fucked and hardcoded, but, again, it works + // Might be a neat idea for a more versatile kind of signature though + private static IEnumerable ScanPapHookPoints(Instruction[] funcInstructions) + { + for (var i = 0; i < funcInstructions.Length - 8; i++) + { + if (funcInstructions.AsSpan(i, 8) is + [ + { Code : Code.Lea_r64_m }, + { Code : Code.Lea_r64_m }, + { Mnemonic: Mnemonic.Call }, + { Code : Code.Lea_r64_m }, + { Mnemonic: Mnemonic.Call }, + { Code : Code.Lea_r64_m }, + .., + ] + ) + yield return funcInstructions[i]; + } + } + + private unsafe nint NativeAllocCave(nuint size) + { + var caveLoc = (nint)NativeMemory.Alloc(size); + _nativeAllocCaves.Add(caveLoc); + + return caveLoc; + } + + // This is a bit conked but, if we identify a path by: + // 1) The function it belongs to (starting address, 'funcAddress') + // 2) The stack register (not strictly necessary - should always be rbp - but abundance of caution, so I don't hit myself in the future) + // 3) The displacement on the stack + // Then we ensure we have a unique identifier for the specific variable location of that specific function + // This is useful because sometimes the stack address is reused within the same function for different GetResourceAsync calls + private unsafe nint NativeAllocPath(nint funcAddress, Register stackRegister, ulong stackDisplacement, nuint size) + => _nativeAllocPaths.GetOrAdd((funcAddress, stackRegister, stackDisplacement), _ => (nint)NativeMemory.Alloc(size)); + + private static unsafe void NativeFree(nint mem) + => NativeMemory.Free((void*)mem); + + public void Dispose() + { + foreach (var hook in _hooks.Values) + { + hook.Disable(); + hook.Dispose(); + } + + _hooks.Clear(); + + foreach (var mem in _nativeAllocCaves) + NativeFree(mem); + + _nativeAllocCaves.Clear(); + + foreach (var mem in _nativeAllocPaths.Values) + NativeFree(mem); + + _nativeAllocPaths.Clear(); + } + + [Conditional("DEBUG")] + private static unsafe void WriteToAlloc(nint alloc, int size, string name) + { + var span = new Span((void*)alloc, size); + Utf8.TryWrite(span, $"Penumbra.{name}\0", out _); + } +} diff --git a/Penumbra/Interop/Hooks/ResourceLoading/PeSigScanner.cs b/Penumbra/Interop/Hooks/ResourceLoading/PeSigScanner.cs new file mode 100644 index 00000000..620f3160 --- /dev/null +++ b/Penumbra/Interop/Hooks/ResourceLoading/PeSigScanner.cs @@ -0,0 +1,188 @@ +using System.IO.MemoryMappedFiles; +using Iced.Intel; +using OtterGui.Services; +using PeNet; +using Decoder = Iced.Intel.Decoder; + +namespace Penumbra.Interop.Hooks.ResourceLoading; + +// A good chunk of this was blatantly stolen from Dalamud's SigScanner 'cause Winter could not be faffed, Winter will definitely not rewrite it later +public unsafe class PeSigScanner : IDisposable, IService +{ + private readonly MemoryMappedFile _file; + private readonly MemoryMappedViewAccessor _textSection; + + private readonly nint _moduleBaseAddress; + private readonly uint _textSectionVirtualAddress; + + public PeSigScanner() + { + var mainModule = Process.GetCurrentProcess().MainModule!; + var fileName = mainModule.FileName; + _moduleBaseAddress = mainModule.BaseAddress; + + if (fileName == null) + throw new Exception("Unable to obtain main module path. This should not happen."); + + _file = MemoryMappedFile.CreateFromFile(fileName, FileMode.Open, null, 0, MemoryMappedFileAccess.Read); + + using var fileStream = _file.CreateViewStream(0, 0, MemoryMappedFileAccess.Read); + var pe = new PeFile(fileStream); + + var textSection = pe.ImageSectionHeaders!.First(header => header.Name == ".text"); + + var textSectionStart = textSection.PointerToRawData; + var textSectionSize = textSection.SizeOfRawData; + _textSectionVirtualAddress = textSection.VirtualAddress; + + _textSection = _file.CreateViewAccessor(textSectionStart, textSectionSize, MemoryMappedFileAccess.Read); + } + + + private nint ScanText(string signature) + { + var scanRet = Scan(_textSection, signature); + if (*(byte*)scanRet is 0xE8 or 0xE9) + scanRet = ReadJmpCallSig(scanRet); + + return scanRet; + } + + private static nint ReadJmpCallSig(nint sigLocation) + { + var jumpOffset = *(int*)(sigLocation + 1); + return sigLocation + 5 + jumpOffset; + } + + public bool TryScanText(string signature, out nint result) + { + try + { + result = ScanText(signature); + return true; + } + catch (KeyNotFoundException) + { + result = nint.Zero; + return false; + } + } + + private nint Scan(MemoryMappedViewAccessor section, string signature) + { + var (needle, mask) = ParseSignature(signature); + + var index = IndexOf(section, needle, mask); + if (index < 0) + throw new KeyNotFoundException($"Can't find a signature of {signature}"); + + return (nint)(_moduleBaseAddress + index - section.PointerOffset + _textSectionVirtualAddress); + } + + private static (byte[] Needle, bool[] Mask) ParseSignature(string signature) + { + signature = signature.Replace(" ", string.Empty); + if (signature.Length % 2 != 0) + throw new ArgumentException("Signature without whitespaces must be divisible by two.", nameof(signature)); + + var needleLength = signature.Length / 2; + var needle = new byte[needleLength]; + var mask = new bool[needleLength]; + for (var i = 0; i < needleLength; i++) + { + var hexString = signature.Substring(i * 2, 2); + if (hexString is "??" or "**") + { + needle[i] = 0; + mask[i] = true; + continue; + } + + needle[i] = byte.Parse(hexString, NumberStyles.AllowHexSpecifier); + mask[i] = false; + } + + return (needle, mask); + } + + private static int IndexOf(MemoryMappedViewAccessor section, byte[] needle, bool[] mask) + { + if (needle.Length > section.Capacity) + return -1; + + var badShift = BuildBadCharTable(needle, mask); + var last = needle.Length - 1; + var offset = 0; + var maxOffset = section.Capacity - needle.Length; + + byte* buffer = null; + section.SafeMemoryMappedViewHandle.AcquirePointer(ref buffer); + try + { + while (offset <= maxOffset) + { + int position; + for (position = last; needle[position] == *(buffer + position + offset) || mask[position]; position--) + { + if (position == 0) + return offset; + } + + offset += badShift[*(buffer + offset + last)]; + } + } + finally + { + section.SafeMemoryMappedViewHandle.ReleasePointer(); + } + + return -1; + } + + + private static int[] BuildBadCharTable(byte[] needle, bool[] mask) + { + int idx; + var last = needle.Length - 1; + var badShift = new int[256]; + for (idx = last; idx > 0 && !mask[idx]; --idx) + { } + + var diff = last - idx; + if (diff == 0) + diff = 1; + + for (idx = 0; idx <= 255; ++idx) + badShift[idx] = diff; + for (idx = last - diff; idx < last; ++idx) + badShift[needle[idx]] = last - idx; + return badShift; + } + + // Detects function termination; this is done in a really stupid way that will possibly break if looked at wrong, but it'll work for now + // If this shits itself, go bother Winter to implement proper CFG + basic block detection + public IEnumerable GetFunctionInstructions(nint address) + { + var fileOffset = address - _textSectionVirtualAddress - _moduleBaseAddress; + + var codeReader = new MappedCodeReader(_textSection, fileOffset); + var decoder = Decoder.Create(64, codeReader, (ulong)address.ToInt64()); + + do + { + decoder.Decode(out var instr); + + // Yes, this is catastrophically bad, but it works for some cases okay + if (instr.Mnemonic == Mnemonic.Int3) + break; + + yield return instr; + } while (true); + } + + public void Dispose() + { + _textSection.Dispose(); + _file.Dispose(); + } +} diff --git a/Penumbra/Interop/Hooks/ResourceLoading/ResourceLoader.cs b/Penumbra/Interop/Hooks/ResourceLoading/ResourceLoader.cs new file mode 100644 index 00000000..6ddcbfda --- /dev/null +++ b/Penumbra/Interop/Hooks/ResourceLoading/ResourceLoader.cs @@ -0,0 +1,382 @@ +using FFXIVClientStructs.FFXIV.Client.System.Resource; +using OtterGui.Services; +using Penumbra.Api.Enums; +using Penumbra.Collections; +using Penumbra.Interop.Hooks.Resources; +using Penumbra.Interop.PathResolving; +using Penumbra.Interop.SafeHandles; +using Penumbra.Interop.Structs; +using Penumbra.String; +using Penumbra.String.Classes; +using FileMode = Penumbra.Interop.Structs.FileMode; + +namespace Penumbra.Interop.Hooks.ResourceLoading; + +public unsafe class ResourceLoader : IDisposable, IService +{ + private readonly ResourceService _resources; + private readonly FileReadService _fileReadService; + private readonly RsfService _rsfService; + private readonly PapHandler _papHandler; + private readonly Configuration _config; + private readonly ResourceHandleDestructor _destructor; + + private readonly ConcurrentDictionary _ongoingLoads = []; + + private readonly ThreadLocal _resolvedData = new(() => ResolveData.Invalid); + public event Action? PapRequested; + + public IReadOnlyDictionary OngoingLoads + => _ongoingLoads; + + public ResourceLoader(ResourceService resources, FileReadService fileReadService, RsfService rsfService, Configuration config, PeSigScanner sigScanner, + ResourceHandleDestructor destructor) + { + _resources = resources; + _fileReadService = fileReadService; + _rsfService = rsfService; + _config = config; + _destructor = destructor; + ResetResolvePath(); + + _resources.ResourceRequested += ResourceHandler; + _resources.ResourceStateUpdating += ResourceStateUpdatingHandler; + _resources.ResourceStateUpdated += ResourceStateUpdatedHandler; + _resources.ResourceHandleIncRef += IncRefProtection; + _resources.ResourceHandleDecRef += DecRefProtection; + _fileReadService.ReadSqPack += ReadSqPackDetour; + _destructor.Subscribe(ResourceDestructorHandler, ResourceHandleDestructor.Priority.ResourceLoader); + + _papHandler = new PapHandler(sigScanner, PapResourceHandler); + _papHandler.Enable(); + } + + private int PapResourceHandler(void* self, byte* path, int length) + { + if (!_config.EnableMods || !Utf8GamePath.FromPointer(path, MetaDataComputation.CiCrc32, out var gamePath)) + return length; + + var resolvedData = _resolvedData.Value; + var (resolvedPath, data) = _incMode.Value + ? (null, ResolveData.Invalid) + : resolvedData.Valid + ? (resolvedData.ModCollection.ResolvePath(gamePath), resolvedData) + : ResolvePath(gamePath, ResourceCategory.Chara, ResourceType.Pap); + + + if (!resolvedPath.HasValue) + { + PapRequested?.Invoke(gamePath, null, data); + return length; + } + + PapRequested?.Invoke(gamePath, resolvedPath.Value, data); + NativeMemory.Copy(resolvedPath.Value.InternalName.Path, path, (nuint)resolvedPath.Value.InternalName.Length); + path[resolvedPath.Value.InternalName.Length] = 0; + return resolvedPath.Value.InternalName.Length; + } + + /// Load a resource for a given path and a specific collection. + public ResourceHandle* LoadResolvedResource(ResourceCategory category, ResourceType type, CiByteString path, ResolveData resolveData) + { + var previous = _resolvedData.Value; + _resolvedData.Value = resolveData; + try + { + return _resources.GetResource(category, type, path); + } + finally + { + _resolvedData.Value = previous; + } + } + + /// Load a resource for a given path and a specific collection. + public SafeResourceHandle LoadResolvedSafeResource(ResourceCategory category, ResourceType type, CiByteString path, ResolveData resolveData) + { + var previous = _resolvedData.Value; + _resolvedData.Value = resolveData; + try + { + return _resources.GetSafeResource(category, type, path); + } + finally + { + _resolvedData.Value = previous; + } + } + + /// The function to use to resolve a given path. + public Func ResolvePath = null!; + + /// Reset the ResolvePath function to always return null. + public void ResetResolvePath() + => ResolvePath = (_, _, _) => (null, ResolveData.Invalid); + + public delegate void ResourceLoadedDelegate(ResourceHandle* handle, Utf8GamePath originalPath, FullPath? manipulatedPath, + ResolveData resolveData); + + /// + /// Event fired whenever a resource is returned. + /// If the path was manipulated by penumbra, manipulatedPath will be the file path of the loaded resource. + /// resolveData is additional data returned by the current ResolvePath function which can contain the collection and associated game object. + /// + public event ResourceLoadedDelegate? ResourceLoaded; + + public delegate void FileLoadedDelegate(ResourceHandle* resource, CiByteString path, bool returnValue, bool custom, + ReadOnlySpan additionalData); + + /// + /// Event fired whenever a resource is newly loaded. + /// ReturnValue indicates the return value of the loading function (which does not imply that the resource was actually successfully loaded) + /// custom is true if the file was loaded from local files instead of the default SqPacks. + /// AdditionalData is either empty or the part of the path inside the leading pipes. + /// + public event FileLoadedDelegate? FileLoaded; + + public delegate void ResourceCompleteDelegate(ResourceHandle* resource, CiByteString path, Utf8GamePath originalPath, + ReadOnlySpan additionalData, bool isAsync); + + /// + /// Event fired just before a resource finishes loading. + /// must be checked to know whether the load was successful or not. + /// AdditionalData is either empty or the part of the path inside the leading pipes. + /// + public event ResourceCompleteDelegate? BeforeResourceComplete; + + /// + /// Event fired when a resource has finished loading. + /// must be checked to know whether the load was successful or not. + /// AdditionalData is either empty or the part of the path inside the leading pipes. + /// + public event ResourceCompleteDelegate? ResourceComplete; + + public void Dispose() + { + _resources.ResourceRequested -= ResourceHandler; + _resources.ResourceStateUpdating -= ResourceStateUpdatingHandler; + _resources.ResourceStateUpdated -= ResourceStateUpdatedHandler; + _resources.ResourceHandleIncRef -= IncRefProtection; + _resources.ResourceHandleDecRef -= DecRefProtection; + _fileReadService.ReadSqPack -= ReadSqPackDetour; + _destructor.Unsubscribe(ResourceDestructorHandler); + _papHandler.Dispose(); + } + + private void ResourceHandler(ref ResourceCategory category, ref ResourceType type, ref int hash, ref Utf8GamePath path, + Utf8GamePath original, GetResourceParameters* parameters, ref bool sync, ref ResourceHandle* returnValue) + { + if (!_config.EnableMods || returnValue != null) + return; + + CompareHash(ComputeHash(path.Path, parameters), hash, path); + + // If no replacements are being made, we still want to be able to trigger the event. + var resolvedData = _resolvedData.Value; + var (resolvedPath, data) = _incMode.Value + ? (null, ResolveData.Invalid) + : resolvedData.Valid + ? (resolvedData.ModCollection.ResolvePath(path), resolvedData) + : ResolvePath(path, category, type); + + if (resolvedPath == null || !Utf8GamePath.FromByteString(resolvedPath.Value.InternalName, out var p)) + { + returnValue = _resources.GetOriginalResource(sync, category, type, hash, path.Path, original, parameters); + TrackResourceLoad(returnValue, original); + ResourceLoaded?.Invoke(returnValue, path, resolvedPath, data); + return; + } + + _rsfService.AddCrc(type, resolvedPath); + // Replace the hash and path with the correct one for the replacement. + hash = ComputeHash(resolvedPath.Value.InternalName, parameters); + var oldPath = path; + path = p; + returnValue = _resources.GetOriginalResource(sync, category, type, hash, path.Path, original, parameters); + TrackResourceLoad(returnValue, original); + ResourceLoaded?.Invoke(returnValue, oldPath, resolvedPath.Value, data); + } + + private void TrackResourceLoad(ResourceHandle* handle, Utf8GamePath original) + { + if (handle->UnkState == 2 && handle->LoadState >= LoadState.Success) + return; + + _ongoingLoads.TryAdd((nint)handle, original.Clone()); + } + + private void ResourceStateUpdatedHandler(ResourceHandle* handle, Utf8GamePath syncOriginal, (byte, LoadState) previousState, ref uint returnValue) + { + if (handle->UnkState != 2 || handle->LoadState < LoadState.Success || previousState is { Item1: 2, Item2: >= LoadState.Success }) + return; + + if (!_ongoingLoads.TryRemove((nint)handle, out var asyncOriginal)) + asyncOriginal = Utf8GamePath.Empty; + + var path = handle->CsHandle.FileName; + if (!syncOriginal.IsEmpty && !asyncOriginal.IsEmpty && !syncOriginal.Equals(asyncOriginal)) + Penumbra.Log.Warning($"[ResourceLoader] Resource original paths inconsistency: 0x{(nint)handle:X}, of path {path}, sync original {syncOriginal}, async original {asyncOriginal}."); + var original = !asyncOriginal.IsEmpty ? asyncOriginal : syncOriginal; + + Penumbra.Log.Excessive($"[ResourceLoader] Resource is complete: 0x{(nint)handle:X}, of path {path}, original {original}, state {previousState.Item1}:{previousState.Item2} -> {handle->UnkState}:{handle->LoadState}, sync: {asyncOriginal.IsEmpty}"); + if (PathDataHandler.Split(path.AsSpan(), out var actualPath, out var additionalData)) + ResourceComplete?.Invoke(handle, new CiByteString(actualPath), original, additionalData, !asyncOriginal.IsEmpty); + else + ResourceComplete?.Invoke(handle, path.AsByteString(), original, [], !asyncOriginal.IsEmpty); + } + + private void ResourceStateUpdatingHandler(ResourceHandle* handle, Utf8GamePath syncOriginal) + { + if (handle->UnkState != 1 || handle->LoadState != LoadState.Success) + return; + + if (!_ongoingLoads.TryGetValue((nint)handle, out var asyncOriginal)) + asyncOriginal = Utf8GamePath.Empty; + + var path = handle->CsHandle.FileName; + var original = asyncOriginal.IsEmpty ? syncOriginal : asyncOriginal; + + Penumbra.Log.Excessive($"[ResourceLoader] Resource is about to be complete: 0x{(nint)handle:X}, of path {path}, original {original}"); + if (PathDataHandler.Split(path.AsSpan(), out var actualPath, out var additionalData)) + BeforeResourceComplete?.Invoke(handle, new CiByteString(actualPath), original, additionalData, !asyncOriginal.IsEmpty); + else + BeforeResourceComplete?.Invoke(handle, path.AsByteString(), original, [], !asyncOriginal.IsEmpty); + } + + private void ReadSqPackDetour(SeFileDescriptor* fileDescriptor, ref int priority, ref bool isSync, ref byte? returnValue) + { + if (fileDescriptor->ResourceHandle == null) + { + Penumbra.Log.Verbose( + $"[ResourceLoader] Failure to load file from SqPack: invalid File Descriptor: {Marshal.PtrToStringUni((nint)(&fileDescriptor->Utf16FileName))}"); + return; + } + + if (!fileDescriptor->ResourceHandle->GamePath(out var gamePath) || gamePath.Length == 0) + { + Penumbra.Log.Error("[ResourceLoader] Failure to load file from SqPack: invalid path specified."); + return; + } + + // Paths starting with a '|' are handled separately to allow for special treatment. + // They are expected to also have a closing '|'. + if (!PathDataHandler.Split(gamePath.Path.Span, out var actualPath, out var data)) + { + returnValue = DefaultLoadResource(gamePath.Path, fileDescriptor, priority, isSync, []); + return; + } + + var path = CiByteString.FromSpanUnsafe(actualPath, gamePath.Path.IsNullTerminated, gamePath.Path.IsAsciiLowerCase, + gamePath.Path.IsAscii); + fileDescriptor->ResourceHandle->FileNameData = path.Path; + fileDescriptor->ResourceHandle->FileNameLength = path.Length; + returnValue = DefaultLoadResource(path, fileDescriptor, priority, isSync, data); + // Return original resource handle path so that they can be loaded separately. + fileDescriptor->ResourceHandle->FileNameData = gamePath.Path.Path; + fileDescriptor->ResourceHandle->FileNameLength = gamePath.Path.Length; + } + + + /// Load a resource by its path. If it is rooted, it will be loaded from the drive, otherwise from the SqPack. + private byte DefaultLoadResource(CiByteString gamePath, SeFileDescriptor* fileDescriptor, int priority, + bool isSync, ReadOnlySpan additionalData) + { + if (Utf8GamePath.IsRooted(gamePath)) + { + // Specify that we are loading unpacked files from the drive. + // We need to obtain the actual file path in UTF16 (Windows-Unicode) on two locations, + // but we write a pointer to the given string instead and use the CreateFileW hook to handle it, + // because otherwise we are limited to 260 characters. + fileDescriptor->FileMode = FileMode.LoadUnpackedResource; + + // Ensure that the file descriptor has its wchar_t array on aligned boundary even if it has to be odd. + var fd = stackalloc char[0x11 + 0x0B + 14]; + fileDescriptor->FileDescriptor = (byte*)fd + 1; + CreateFileWHook.WritePtr(fd + 0x11, gamePath.Path, gamePath.Length); + CreateFileWHook.WritePtr(&fileDescriptor->Utf16FileName, gamePath.Path, gamePath.Length); + + // Use the SE ReadFile function. + var ret = _fileReadService.ReadFile(fileDescriptor, priority, isSync); + FileLoaded?.Invoke(fileDescriptor->ResourceHandle, gamePath, ret != 0, true, additionalData); + return ret; + } + else + { + var ret = _fileReadService.ReadDefaultSqPack(fileDescriptor, priority, isSync); + FileLoaded?.Invoke(fileDescriptor->ResourceHandle, gamePath, ret != 0, false, additionalData); + return ret; + } + } + + /// + /// A resource with ref count 0 that gets incremented goes through GetResourceAsync again. + /// This means, that if the path determined from that is different than the resources path, + /// a different resource gets loaded or incremented, while the IncRef'd resource stays at 0. + /// This causes some problems and is hopefully prevented with this. + /// + private readonly ThreadLocal _incMode = new(() => false, true); + + /// + private void IncRefProtection(ResourceHandle* handle, ref nint? returnValue) + { + if (handle->RefCount != 0) + return; + + _incMode.Value = true; + returnValue = _resources.IncRef(handle); + _incMode.Value = false; + } + + /// + /// Catch weird errors with invalid decrements of the reference count. + /// + private static void DecRefProtection(ResourceHandle* handle, ref byte? returnValue) + { + if (handle->RefCount != 0) + return; + + try + { + Penumbra.Log.Error( + $"[ResourceLoader] Caught decrease of Reference Counter for {handle->FileName()} at 0x{(ulong)handle} below 0."); + } + catch + { + // ignored + } + + returnValue = 1; + } + + private void ResourceDestructorHandler(ResourceHandle* handle) + { + _ongoingLoads.TryRemove((nint)handle, out _); + } + + /// Compute the CRC32 hash for a given path together with potential resource parameters. + private static int ComputeHash(CiByteString path, GetResourceParameters* pGetResParams) + { + if (pGetResParams == null || !pGetResParams->IsPartialRead) + return path.Crc32; + + // When the game requests file only partially, crc32 includes that information, in format of: + // path/to/file.ext.hex_offset.hex_size + // ex) music/ex4/BGM_EX4_System_Title.scd.381adc.30000 + return CiByteString.Join( + (byte)'.', + path, + CiByteString.FromString(pGetResParams->SegmentOffset.ToString("x"), out var s1, MetaDataComputation.None) ? s1 : CiByteString.Empty, + CiByteString.FromString(pGetResParams->SegmentLength.ToString("x"), out var s2, MetaDataComputation.None) ? s2 : CiByteString.Empty + ).Crc32; + } + + /// + /// In Debug build, compare the hashes the game computes with those Penumbra computes to notice potential changes in the CRC32 algorithm or resource parameters. + /// + [Conditional("DEBUG")] + private static void CompareHash(int local, int game, Utf8GamePath path) + { + if (local != game) + Penumbra.Log.Warning($"[ResourceLoader] Hash function appears to have changed. Computed {local:X8} vs Game {game:X8} for {path}."); + } +} diff --git a/Penumbra/Interop/Hooks/ResourceLoading/ResourceManagerService.cs b/Penumbra/Interop/Hooks/ResourceLoading/ResourceManagerService.cs new file mode 100644 index 00000000..1bff80ba --- /dev/null +++ b/Penumbra/Interop/Hooks/ResourceLoading/ResourceManagerService.cs @@ -0,0 +1,99 @@ +using Dalamud.Plugin.Services; +using Dalamud.Utility.Signatures; +using FFXIVClientStructs.FFXIV.Client.System.Resource; +using FFXIVClientStructs.FFXIV.Client.System.Resource.Handle; +using FFXIVClientStructs.Interop; +using FFXIVClientStructs.STD; +using OtterGui.Services; +using Penumbra.Api.Enums; +using Penumbra.GameData; + +namespace Penumbra.Interop.Hooks.ResourceLoading; + +public unsafe class ResourceManagerService : IRequiredService +{ + public ResourceManagerService(IGameInteropProvider interop) + => interop.InitializeFromAttributes(this); + + /// The SE Resource Manager as pointer. + public ResourceManager* ResourceManager + => *ResourceManagerAddress; + + /// Find a resource in the resource manager by its category, extension and crc-hash. + public ResourceHandle* FindResource(ResourceCategory cat, ResourceType ext, uint crc32) + { + ref var manager = ref *ResourceManager; + var catIdx = (uint)cat >> 0x18; + cat = (ResourceCategory)(ushort)cat; + ref var category = ref manager.ResourceGraph->Containers[(int)cat]; + var extMap = FindInMap(category.CategoryMaps[(int)catIdx].Value, (uint)ext); + if (extMap == null) + return null; + + var ret = FindInMap(extMap->Value, crc32); + return ret == null ? null : ret->Value; + } + + public delegate void ExtMapAction(ResourceCategory category, StdMap>>>* graph, int idx); + public delegate void ResourceMapAction(uint ext, StdMap>* graph); + public delegate void ResourceAction(uint crc32, ResourceHandle* graph); + + /// Iterate through the entire graph calling an action on every ExtMap. + public void IterateGraphs(ExtMapAction action) + { + ref var manager = ref *ResourceManager; + foreach (var resourceType in Enum.GetValues().SkipLast(1)) + { + ref var graph = ref manager.ResourceGraph->Containers[(int)resourceType]; + for (var i = 0; i < 20; ++i) + { + var map = graph.CategoryMaps[i]; + if (map.Value != null) + action(resourceType, map, i); + } + } + } + + /// Iterate through a specific ExtMap calling an action on every resource map. + public void IterateExtMap(StdMap>>>* map, ResourceMapAction action) + => IterateMap(map, (ext, m) => action(ext, m.Value)); + + /// Iterate through a specific resource map calling an action on every resource. + public void IterateResourceMap(StdMap>* map, ResourceAction action) + => IterateMap(map, (crc, r) => action(crc, r.Value)); + + /// Iterate through the entire graph calling an action on every resource. + public void IterateResources(ResourceAction action) + { + IterateGraphs((_, extMap, _) + => IterateExtMap(extMap, (_, resourceMap) + => IterateResourceMap(resourceMap, action))); + } + + /// A static pointer to the SE Resource Manager. + [Signature(Sigs.ResourceManager, ScanType = ScanType.StaticAddress)] + internal readonly ResourceManager** ResourceManagerAddress = null; + + // Find a key in a StdMap. + private static TValue* FindInMap(StdMap* map, in TKey key) + where TKey : unmanaged, IComparable + where TValue : unmanaged + { + if (map == null) + return null; + + return map->TryGetValuePointer(key, out var val) ? val : null; + } + + // Iterate in tree-order through a map, applying action to each KeyValuePair. + private static void IterateMap(StdMap* map, Action action) + where TKey : unmanaged + where TValue : unmanaged + { + if (map == null) + return; + + foreach (var (key, value) in *map) + action(key, value); + } +} diff --git a/Penumbra/Interop/Hooks/ResourceLoading/ResourceService.cs b/Penumbra/Interop/Hooks/ResourceLoading/ResourceService.cs new file mode 100644 index 00000000..1a40accc --- /dev/null +++ b/Penumbra/Interop/Hooks/ResourceLoading/ResourceService.cs @@ -0,0 +1,269 @@ +using Dalamud.Hooking; +using Dalamud.Interface.Windowing; +using Dalamud.Plugin.Services; +using Dalamud.Utility.Signatures; +using FFXIVClientStructs.FFXIV.Client.System.Resource; +using OtterGui.Services; +using Penumbra.Api.Enums; +using Penumbra.GameData; +using Penumbra.Interop.SafeHandles; +using Penumbra.Interop.Structs; +using Penumbra.String; +using Penumbra.String.Classes; +using Penumbra.Util; +using CSResourceHandle = FFXIVClientStructs.FFXIV.Client.System.Resource.Handle.ResourceHandle; + +namespace Penumbra.Interop.Hooks.ResourceLoading; + +public unsafe class ResourceService : IDisposable, IRequiredService +{ + private readonly PerformanceTracker _performance; + private readonly ResourceManagerService _resourceManager; + + private readonly ThreadLocal _currentGetResourcePath = new(() => Utf8GamePath.Empty); + + public ResourceService(PerformanceTracker performance, ResourceManagerService resourceManager, IGameInteropProvider interop) + { + _performance = performance; + _resourceManager = resourceManager; + interop.InitializeFromAttributes(this); + _incRefHook = interop.HookFromAddress( + (nint)CSResourceHandle.MemberFunctionPointers.IncRef, + ResourceHandleIncRefDetour); + _decRefHook = interop.HookFromAddress( + (nint)CSResourceHandle.MemberFunctionPointers.DecRef, + ResourceHandleDecRefDetour); + if (!HookOverrides.Instance.ResourceLoading.GetResourceSync) + _getResourceSyncHook.Enable(); + if (!HookOverrides.Instance.ResourceLoading.GetResourceAsync) + _getResourceAsyncHook.Enable(); + if (!HookOverrides.Instance.ResourceLoading.UpdateResourceState) + _updateResourceStateHook.Enable(); + if (!HookOverrides.Instance.ResourceLoading.IncRef) + _incRefHook.Enable(); + if (!HookOverrides.Instance.ResourceLoading.DecRef) + _decRefHook.Enable(); + } + + public ResourceHandle* GetResource(ResourceCategory category, ResourceType type, CiByteString path) + { + var hash = path.Crc32; + return GetResourceHandler(true, (ResourceManager*)_resourceManager.ResourceManagerAddress, + &category, &type, &hash, path.Path, null, 0, 0, 0); + } + + public SafeResourceHandle GetSafeResource(ResourceCategory category, ResourceType type, CiByteString path) + => new((CSResourceHandle*)GetResource(category, type, path), false); + + public void Dispose() + { + _getResourceSyncHook.Dispose(); + _getResourceAsyncHook.Dispose(); + _updateResourceStateHook.Dispose(); + _incRefHook.Dispose(); + _decRefHook.Dispose(); + _currentGetResourcePath.Dispose(); + } + + #region GetResource + + /// Called before a resource is requested. + /// The resource category. Should not generally be changed. + /// The resource type. Should not generally be changed. + /// The resource hash. Should generally fit to the path. + /// The path of the requested resource. + /// Mainly used for SCD streaming, can be null. + /// Whether to request the resource synchronously or asynchronously. + /// The returned resource handle. If this is not null, calling original will be skipped. + public delegate void GetResourcePreDelegate(ref ResourceCategory category, ref ResourceType type, ref int hash, ref Utf8GamePath path, + Utf8GamePath original, GetResourceParameters* parameters, ref bool sync, ref ResourceHandle* returnValue); + + /// + /// Subscribers should be exception-safe. + public event GetResourcePreDelegate? ResourceRequested; + + private delegate ResourceHandle* GetResourceSyncPrototype(ResourceManager* resourceManager, ResourceCategory* pCategoryId, + ResourceType* pResourceType, int* pResourceHash, byte* pPath, GetResourceParameters* pGetResParams, nint unk7, uint unk8); + + private delegate ResourceHandle* GetResourceAsyncPrototype(ResourceManager* resourceManager, ResourceCategory* pCategoryId, + ResourceType* pResourceType, int* pResourceHash, byte* pPath, GetResourceParameters* pGetResParams, byte isUnknown, nint unk8, + uint unk9); + + [Signature(Sigs.GetResourceSync, DetourName = nameof(GetResourceSyncDetour))] + private readonly Hook _getResourceSyncHook = null!; + + [Signature(Sigs.GetResourceAsync, DetourName = nameof(GetResourceAsyncDetour))] + private readonly Hook _getResourceAsyncHook = null!; + + private ResourceHandle* GetResourceSyncDetour(ResourceManager* resourceManager, ResourceCategory* categoryId, ResourceType* resourceType, + int* resourceHash, byte* path, GetResourceParameters* pGetResParams, nint unk8, uint unk9) + => GetResourceHandler(true, resourceManager, categoryId, resourceType, resourceHash, path, pGetResParams, 0, unk8, unk9); + + private ResourceHandle* GetResourceAsyncDetour(ResourceManager* resourceManager, ResourceCategory* categoryId, ResourceType* resourceType, + int* resourceHash, byte* path, GetResourceParameters* pGetResParams, byte isUnk, nint unk8, uint unk9) + => GetResourceHandler(false, resourceManager, categoryId, resourceType, resourceHash, path, pGetResParams, isUnk, unk8, unk9); + + /// + /// Resources can be obtained synchronously and asynchronously. We need to change behaviour in both cases. + /// Both work basically the same, so we can reduce the main work to one function used by both hooks. + /// + private ResourceHandle* GetResourceHandler(bool isSync, ResourceManager* resourceManager, ResourceCategory* categoryId, + ResourceType* resourceType, int* resourceHash, byte* path, GetResourceParameters* pGetResParams, byte isUnk, nint unk8, uint unk9) + { + using var performance = _performance.Measure(PerformanceType.GetResourceHandler); + if (!Utf8GamePath.FromPointer(path, MetaDataComputation.CiCrc32, out var gamePath)) + { + Penumbra.Log.Error("[ResourceService] Could not create GamePath from resource path."); + return isSync + ? _getResourceSyncHook.Original(resourceManager, categoryId, resourceType, resourceHash, path, pGetResParams, unk8, unk9) + : _getResourceAsyncHook.Original(resourceManager, categoryId, resourceType, resourceHash, path, pGetResParams, isUnk, unk8, + unk9); + } + + if (gamePath.IsEmpty) + { + Penumbra.Log.Error($"[ResourceService] Empty resource path requested with category {*categoryId}, type {*resourceType}, hash {*resourceHash}."); + return null; + } + + var original = gamePath; + ResourceHandle* returnValue = null; + ResourceRequested?.Invoke(ref *categoryId, ref *resourceType, ref *resourceHash, ref gamePath, original, pGetResParams, ref isSync, + ref returnValue); + if (returnValue != null) + return returnValue; + + return GetOriginalResource(isSync, *categoryId, *resourceType, *resourceHash, gamePath.Path, original, pGetResParams, isUnk, unk8, + unk9); + } + + /// Call the original GetResource function. + public ResourceHandle* GetOriginalResource(bool sync, ResourceCategory categoryId, ResourceType type, int hash, CiByteString path, + Utf8GamePath original, + GetResourceParameters* resourceParameters = null, byte unk = 0, nint unk8 = 0, uint unk9 = 0) + { + var previous = _currentGetResourcePath.Value; + try + { + _currentGetResourcePath.Value = original; + return sync + ? _getResourceSyncHook.OriginalDisposeSafe(_resourceManager.ResourceManager, &categoryId, &type, &hash, path.Path, + resourceParameters, unk8, unk9) + : _getResourceAsyncHook.OriginalDisposeSafe(_resourceManager.ResourceManager, &categoryId, &type, &hash, path.Path, + resourceParameters, unk, unk8, unk9); + } + finally + { + _currentGetResourcePath.Value = previous; + } + } + + #endregion + + private delegate nint ResourceHandlePrototype(ResourceHandle* handle); + + #region UpdateResourceState + + /// Invoked before a resource state is updated. + /// The resource handle. + /// The original game path of the resource, if loaded synchronously. + public delegate void ResourceStateUpdatingDelegate(ResourceHandle* handle, Utf8GamePath syncOriginal); + + /// Invoked after a resource state is updated. + /// The resource handle. + /// The original game path of the resource, if loaded synchronously. + /// The previous state of the resource. + /// The return value to use. + public delegate void ResourceStateUpdatedDelegate(ResourceHandle* handle, Utf8GamePath syncOriginal, + (byte UnkState, LoadState LoadState) previousState, ref uint returnValue); + + /// + /// + /// Subscribers should be exception-safe. + /// + public event ResourceStateUpdatingDelegate? ResourceStateUpdating; + + /// + /// + /// Subscribers should be exception-safe. + /// + public event ResourceStateUpdatedDelegate? ResourceStateUpdated; + + private delegate uint UpdateResourceStatePrototype(ResourceHandle* handle, byte offFileThread); + + [Signature(Sigs.UpdateResourceState, DetourName = nameof(UpdateResourceStateDetour))] + private readonly Hook _updateResourceStateHook = null!; + + private uint UpdateResourceStateDetour(ResourceHandle* handle, byte offFileThread) + { + var previousState = (handle->UnkState, handle->LoadState); + var syncOriginal = _currentGetResourcePath.IsValueCreated ? _currentGetResourcePath.Value : Utf8GamePath.Empty; + ResourceStateUpdating?.Invoke(handle, syncOriginal); + var ret = _updateResourceStateHook.OriginalDisposeSafe(handle, offFileThread); + ResourceStateUpdated?.Invoke(handle, syncOriginal, previousState, ref ret); + return ret; + } + + #endregion + + #region IncRef + + /// Invoked before a resource handle reference count is incremented. + /// The resource handle. + /// The return value to use, setting this value will skip calling original. + public delegate void ResourceHandleIncRefDelegate(ResourceHandle* handle, ref nint? returnValue); + + /// + /// + /// Subscribers should be exception-safe. + /// + public event ResourceHandleIncRefDelegate? ResourceHandleIncRef; + + /// + /// Call the game function that increases the reference counter of a resource handle. + /// + public nint IncRef(ResourceHandle* handle) + => _incRefHook.OriginalDisposeSafe(handle); + + private readonly Hook _incRefHook; + + private nint ResourceHandleIncRefDetour(ResourceHandle* handle) + { + nint? ret = null; + ResourceHandleIncRef?.Invoke(handle, ref ret); + return ret ?? _incRefHook.OriginalDisposeSafe(handle); + } + + #endregion + + #region DecRef + + /// Invoked before a resource handle reference count is decremented. + /// The resource handle. + /// The return value to use, setting this value will skip calling original. + public delegate void ResourceHandleDecRefDelegate(ResourceHandle* handle, ref byte? returnValue); + + /// + /// + /// Subscribers should be exception-safe. + /// + public event ResourceHandleDecRefDelegate? ResourceHandleDecRef; + + /// + /// Call the original game function that decreases the reference counter of a resource handle. + /// + public byte DecRef(ResourceHandle* handle) + => _decRefHook.OriginalDisposeSafe(handle); + + private delegate byte ResourceHandleDecRefPrototype(ResourceHandle* handle); + private readonly Hook _decRefHook; + + private byte ResourceHandleDecRefDetour(ResourceHandle* handle) + { + byte? ret = null; + ResourceHandleDecRef?.Invoke(handle, ref ret); + return ret ?? _decRefHook.OriginalDisposeSafe(handle); + } + + #endregion +} diff --git a/Penumbra/Interop/Hooks/ResourceLoading/RsfService.cs b/Penumbra/Interop/Hooks/ResourceLoading/RsfService.cs new file mode 100644 index 00000000..e7f06f91 --- /dev/null +++ b/Penumbra/Interop/Hooks/ResourceLoading/RsfService.cs @@ -0,0 +1,203 @@ +using Dalamud.Hooking; +using Dalamud.Plugin.Services; +using Dalamud.Utility.Signatures; +using OtterGui.Services; +using Penumbra.Api.Enums; +using Penumbra.GameData; +using Penumbra.Interop.Structs; +using Penumbra.String.Classes; +using ResourceHandle = FFXIVClientStructs.FFXIV.Client.System.Resource.Handle.ResourceHandle; +using TextureResourceHandle = Penumbra.Interop.Structs.TextureResourceHandle; + +namespace Penumbra.Interop.Hooks.ResourceLoading; + +public unsafe class RsfService : IDisposable, IRequiredService +{ + /// + /// We need to be able to obtain the requested LoD level. + /// This replicates the LoD behavior of a textures OnLoad function. + /// + private readonly struct LodService + { + public LodService(IGameInteropProvider interop) + => interop.InitializeFromAttributes(this); + + [Signature(Sigs.LodConfig)] + private readonly nint _lodConfig = nint.Zero; + + public byte GetLod(TextureResourceHandle* handle) + { + if (handle->ChangeLod) + { + var config = *(byte*)_lodConfig + 0xE; + if (config == byte.MaxValue) + return 2; + } + + return 0; + } + } + + /// Custom ulong flag to signal our files as opposed to SE files. + public static readonly nint CustomFileFlag = new(0xDEADBEEF); + + private readonly LodService _lodService; + + public RsfService(IGameInteropProvider interop) + { + interop.InitializeFromAttributes(this); + _lodService = new LodService(interop); + if (!HookOverrides.Instance.ResourceLoading.CheckFileState) + _checkFileStateHook.Enable(); + if (!HookOverrides.Instance.ResourceLoading.LoadMdlFileExtern) + _loadMdlFileExternHook.Enable(); + if (!HookOverrides.Instance.ResourceLoading.TexResourceHandleOnLoad) + _textureOnLoadHook.Enable(); + if (!HookOverrides.Instance.ResourceLoading.SoundOnLoad) + _soundOnLoadHook.Enable(); + } + + /// Add CRC64 if the given file is a model or texture file and has an associated path. + public void AddCrc(ResourceType type, FullPath? path) + { + _ = type switch + { + ResourceType.Mdl when path.HasValue => _customFileCrc.TryAdd(path.Value.Crc64, ResourceType.Mdl), + ResourceType.Tex when path.HasValue => _customFileCrc.TryAdd(path.Value.Crc64, ResourceType.Tex), + ResourceType.Scd when path.HasValue => _customFileCrc.TryAdd(path.Value.Crc64, ResourceType.Scd), + _ => false, + }; + } + + public void Dispose() + { + _checkFileStateHook.Dispose(); + _loadMdlFileExternHook.Dispose(); + _textureOnLoadHook.Dispose(); + _soundOnLoadHook.Dispose(); + } + + /// + /// We need to keep a list of all CRC64 hash values of our replaced Mdl and Tex files, + /// i.e. CRC32 of filename in the lower bytes, CRC32 of parent path in the upper bytes. + /// + private readonly Dictionary _customFileCrc = []; + + public IReadOnlyDictionary CustomCache + => _customFileCrc; + + private delegate nint CheckFileStatePrototype(nint unk1, ulong crc64); + + [Signature(Sigs.CheckFileState, DetourName = nameof(CheckFileStateDetour))] + private readonly Hook _checkFileStateHook = null!; + + private readonly ThreadLocal _texReturnData = new(() => default); + private readonly ThreadLocal _scdReturnData = new(() => default); + + private delegate void UpdateCategoryDelegate(TextureResourceHandle* resourceHandle); + + [Signature(Sigs.TexHandleUpdateCategory)] + private readonly UpdateCategoryDelegate _updateCategory = null!; + + private delegate byte SoundOnLoadDelegate(ResourceHandle* handle, SeFileDescriptor* descriptor, byte unk); + + [Signature(Sigs.LoadScdFileLocal)] + private readonly delegate* unmanaged _loadScdFileLocal = null!; + + [Signature(Sigs.SoundOnLoad, DetourName = nameof(OnScdLoadDetour))] + private readonly Hook _soundOnLoadHook = null!; + + [Signature(Sigs.RsfServiceAddress, ScanType = ScanType.StaticAddress)] + private readonly nint* _rsfService = null; + + private byte OnScdLoadDetour(ResourceHandle* handle, SeFileDescriptor* descriptor, byte unk) + { + byte ret; + if (*_rsfService == nint.Zero) + { + Penumbra.Log.Debug( + $"Resource load of {handle->FileName} before FFXIV RSF-service was instantiated, workaround by setting pointer."); + *_rsfService = 1; + ret = _soundOnLoadHook.Original(handle, descriptor, unk); + *_rsfService = nint.Zero; + } + else + { + ret = _soundOnLoadHook.Original(handle, descriptor, unk); + } + + if (!_scdReturnData.Value) + return ret; + + // Function failed on a replaced scd, call local. + _scdReturnData.Value = false; + ret = _loadScdFileLocal(handle, descriptor, unk); + return ret; + } + + /// + /// The function that checks a files CRC64 to determine whether it is 'protected'. + /// We use it to check against our stored CRC64s and if it corresponds, we return the custom flag for models. + /// Since Dawntrail inlined the RSF function for textures, we can not use the flag method here. + /// Instead, we signal the caller that this will fail and let it call the local function after intentionally failing. + /// + private nint CheckFileStateDetour(nint ptr, ulong crc64) + { + if (_customFileCrc.TryGetValue(crc64, out var type)) + switch (type) + { + case ResourceType.Mdl: return CustomFileFlag; + case ResourceType.Tex: + _texReturnData.Value = true; + return nint.Zero; + case ResourceType.Scd: + _scdReturnData.Value = true; + return nint.Zero; + } + + var ret = _checkFileStateHook.Original(ptr, crc64); + Penumbra.Log.Excessive($"[CheckFileState] Called on 0x{ptr:X} with CRC {crc64:X16}, returned 0x{ret:X}."); + return ret; + } + + private delegate byte LoadTexFileLocalDelegate(TextureResourceHandle* handle, int unk1, SeFileDescriptor* unk2, bool unk3); + + /// We use the local functions for our own files in the extern hook. + [Signature(Sigs.LoadTexFileLocal)] + private readonly LoadTexFileLocalDelegate _loadTexFileLocal = null!; + + private delegate byte LoadMdlFileLocalPrototype(ResourceHandle* handle, nint unk1, bool unk2); + + /// We use the local functions for our own files in the extern hook. + [Signature(Sigs.LoadMdlFileLocal)] + private readonly LoadMdlFileLocalPrototype _loadMdlFileLocal = null!; + + private delegate byte TexResourceHandleOnLoadPrototype(TextureResourceHandle* handle, SeFileDescriptor* descriptor, byte unk2); + + [Signature(Sigs.TexHandleOnLoad, DetourName = nameof(OnTexLoadDetour))] + private readonly Hook _textureOnLoadHook = null!; + + private byte OnTexLoadDetour(TextureResourceHandle* handle, SeFileDescriptor* descriptor, byte unk2) + { + var ret = _textureOnLoadHook.Original(handle, descriptor, unk2); + if (!_texReturnData.Value) + return ret; + + // Function failed on a replaced texture, call local. + _texReturnData.Value = false; + ret = _loadTexFileLocal(handle, _lodService.GetLod(handle), descriptor, unk2 != 0); + _updateCategory(handle); + return ret; + } + + private delegate byte LoadMdlFileExternPrototype(ResourceHandle* handle, nint unk1, bool unk2, nint unk3); + + [Signature(Sigs.LoadMdlFileExtern, DetourName = nameof(LoadMdlFileExternDetour))] + private readonly Hook _loadMdlFileExternHook = null!; + + /// We hook the extern functions to just return the local one if given the custom flag as last argument. + private byte LoadMdlFileExternDetour(ResourceHandle* resourceHandle, nint unk1, bool unk2, nint ptr) + => ptr.Equals(CustomFileFlag) + ? _loadMdlFileLocal.Invoke(resourceHandle, unk1, unk2) + : _loadMdlFileExternHook.Original(resourceHandle, unk1, unk2, ptr); +} diff --git a/Penumbra/Interop/Hooks/Resources/ApricotResourceLoad.cs b/Penumbra/Interop/Hooks/Resources/ApricotResourceLoad.cs new file mode 100644 index 00000000..40860b0b --- /dev/null +++ b/Penumbra/Interop/Hooks/Resources/ApricotResourceLoad.cs @@ -0,0 +1,29 @@ +using FFXIVClientStructs.FFXIV.Client.System.Resource.Handle; +using OtterGui.Services; +using Penumbra.GameData; + +namespace Penumbra.Interop.Hooks.Resources; + +public sealed unsafe class ApricotResourceLoad : FastHook +{ + private readonly GameState _gameState; + + public ApricotResourceLoad(HookManager hooks, GameState gameState) + { + _gameState = gameState; + Task = hooks.CreateHook("Load Apricot Resource", Sigs.ApricotResourceLoad, Detour, + !HookOverrides.Instance.Resources.ApricotResourceLoad); + } + + public delegate byte Delegate(ResourceHandle* handle, nint unk1, byte unk2); + + [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] + private byte Detour(ResourceHandle* handle, nint unk1, byte unk2) + { + var last = _gameState.AvfxData.Value; + _gameState.AvfxData.Value = _gameState.LoadSubFileHelper((nint)handle); + var ret = Task.Result.Original(handle, unk1, unk2); + _gameState.AvfxData.Value = last; + return ret; + } +} diff --git a/Penumbra/Interop/Hooks/Resources/LoadMtrl.cs b/Penumbra/Interop/Hooks/Resources/LoadMtrl.cs new file mode 100644 index 00000000..f56177e4 --- /dev/null +++ b/Penumbra/Interop/Hooks/Resources/LoadMtrl.cs @@ -0,0 +1,32 @@ +using FFXIVClientStructs.FFXIV.Client.System.Resource.Handle; +using OtterGui.Services; +using Penumbra.GameData; +using Penumbra.Services; + +namespace Penumbra.Interop.Hooks.Resources; + +public sealed unsafe class LoadMtrl : FastHook +{ + private readonly GameState _gameState; + private readonly CommunicatorService _communicator; + + public LoadMtrl(HookManager hooks, GameState gameState, CommunicatorService communicator) + { + _gameState = gameState; + _communicator = communicator; + Task = hooks.CreateHook("Load Material", Sigs.LoadMtrl, Detour, !HookOverrides.Instance.Resources.LoadMtrl); + } + + public delegate byte Delegate(MaterialResourceHandle* mtrlResourceHandle, void* unk1, byte unk2); + + private byte Detour(MaterialResourceHandle* handle, void* unk1, byte unk2) + { + var last = _gameState.MtrlData.Value; + var mtrlData = _gameState.LoadSubFileHelper((nint)handle); + _gameState.MtrlData.Value = mtrlData; + var ret = Task.Result.Original(handle, unk1, unk2); + _gameState.MtrlData.Value = last; + _communicator.MtrlLoaded.Invoke((nint)handle, mtrlData.AssociatedGameObject); + return ret; + } +} diff --git a/Penumbra/Interop/Hooks/Resources/LoadMtrlTex.cs b/Penumbra/Interop/Hooks/Resources/LoadMtrlTex.cs new file mode 100644 index 00000000..1866e859 --- /dev/null +++ b/Penumbra/Interop/Hooks/Resources/LoadMtrlTex.cs @@ -0,0 +1,29 @@ +using FFXIVClientStructs.FFXIV.Client.System.Resource.Handle; +using OtterGui.Services; +using Penumbra.GameData; + +namespace Penumbra.Interop.Hooks.Resources; + +// TODO check if this is still needed, as our hooked function is called by LoadMtrl's hooked function +public sealed unsafe class LoadMtrlTex : FastHook +{ + private readonly GameState _gameState; + + public LoadMtrlTex(HookManager hooks, GameState gameState) + { + _gameState = gameState; + Task = hooks.CreateHook("Load Material Textures", Sigs.LoadMtrlTex, Detour, !HookOverrides.Instance.Resources.LoadMtrlTex); + } + + public delegate byte Delegate(MaterialResourceHandle* mtrlResourceHandle); + + [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] + private byte Detour(MaterialResourceHandle* handle) + { + var last = _gameState.MtrlData.Value; + _gameState.MtrlData.Value = _gameState.LoadSubFileHelper((nint)handle); + var ret = Task.Result.Original(handle); + _gameState.MtrlData.Value = last; + return ret; + } +} diff --git a/Penumbra/Interop/Hooks/Resources/ResolvePathHooks.cs b/Penumbra/Interop/Hooks/Resources/ResolvePathHooks.cs new file mode 100644 index 00000000..8a52acd2 --- /dev/null +++ b/Penumbra/Interop/Hooks/Resources/ResolvePathHooks.cs @@ -0,0 +1,38 @@ +using OtterGui.Services; +using Penumbra.Interop.PathResolving; + +namespace Penumbra.Interop.Hooks.Resources; + +public sealed unsafe class ResolvePathHooks(HookManager hooks, CharacterBaseVTables vTables, PathState pathState) : IDisposable, IRequiredService +{ + // @formatter:off + private readonly ResolvePathHooksBase _human = new("Human", hooks, pathState, vTables.HumanVTable, ResolvePathHooksBase.Type.Human); + private readonly ResolvePathHooksBase _weapon = new("Weapon", hooks, pathState, vTables.WeaponVTable, ResolvePathHooksBase.Type.Other); + private readonly ResolvePathHooksBase _demiHuman = new("DemiHuman", hooks, pathState, vTables.DemiHumanVTable, ResolvePathHooksBase.Type.Other); + private readonly ResolvePathHooksBase _monster = new("Monster", hooks, pathState, vTables.MonsterVTable, ResolvePathHooksBase.Type.Other); + // @formatter:on + + public void Enable() + { + _human.Enable(); + _weapon.Enable(); + _demiHuman.Enable(); + _monster.Enable(); + } + + public void Disable() + { + _human.Disable(); + _weapon.Disable(); + _demiHuman.Disable(); + _monster.Disable(); + } + + public void Dispose() + { + _human.Dispose(); + _weapon.Dispose(); + _demiHuman.Dispose(); + _monster.Dispose(); + } +} diff --git a/Penumbra/Interop/Hooks/Resources/ResolvePathHooksBase.cs b/Penumbra/Interop/Hooks/Resources/ResolvePathHooksBase.cs new file mode 100644 index 00000000..db39889e --- /dev/null +++ b/Penumbra/Interop/Hooks/Resources/ResolvePathHooksBase.cs @@ -0,0 +1,361 @@ +using System.Text.Unicode; +using Dalamud.Hooking; +using FFXIVClientStructs.FFXIV.Client.Graphics.Scene; +using OtterGui.Services; +using Penumbra.Collections; +using Penumbra.GameData.Enums; +using Penumbra.GameData.Structs; +using Penumbra.Interop.PathResolving; +using Penumbra.Interop.Processing; +using static FFXIVClientStructs.FFXIV.Client.Game.Character.ActionEffectHandler; + +namespace Penumbra.Interop.Hooks.Resources; + +public sealed unsafe class ResolvePathHooksBase : IDisposable +{ + public enum Type + { + Human, + Other, + } + + private delegate nint MPapResolveDelegate(nint drawObject, nint pathBuffer, nint pathBufferSize, uint slotIndex, uint sId); + private delegate nint NamedResolveDelegate(nint drawObject, nint pathBuffer, nint pathBufferSize, uint slotIndex, nint name); + private delegate nint PerSlotResolveDelegate(nint drawObject, nint pathBuffer, nint pathBufferSize, uint slotIndex); + private delegate nint SingleResolveDelegate(nint drawObject, nint pathBuffer, nint pathBufferSize); + private delegate nint SkeletonVFuncDelegate(nint drawObject, int estType, nint unk); + + private delegate nint TmbResolveDelegate(nint drawObject, nint pathBuffer, nint pathBufferSize, nint timelineName); + + // Kept separate from NamedResolveDelegate because the 5th parameter has out semantics here, instead of in. + private delegate nint VfxResolveDelegate(nint drawObject, nint pathBuffer, nint pathBufferSize, uint slotIndex, nint unkOutParam); + + private readonly Hook _resolveDecalPathHook; + private readonly Hook _resolveEidPathHook; + private readonly Hook _resolveImcPathHook; + private readonly Hook _resolveMPapPathHook; + private readonly Hook _resolveMdlPathHook; + private readonly Hook _resolveMtrlPathHook; + private readonly Hook _resolveSkinMtrlPathHook; + private readonly Hook _resolvePapPathHook; + private readonly Hook _resolveKdbPathHook; + private readonly Hook _resolvePhybPathHook; + private readonly Hook _resolveBnmbPathHook; + private readonly Hook _resolveSklbPathHook; + private readonly Hook _resolveSkpPathHook; + private readonly Hook _resolveTmbPathHook; + private readonly Hook _resolveVfxPathHook; + private readonly Hook? _vFunc81Hook; + private readonly Hook? _vFunc83Hook; + + private readonly PathState _parent; + + public ResolvePathHooksBase(string name, HookManager hooks, PathState parent, nint* vTable, Type type) + { + _parent = parent; + // @formatter:off + _resolveSklbPathHook = Create($"{name}.{nameof(ResolveSklb)}", hooks, vTable[76], type, ResolveSklb, ResolveSklbHuman); + _resolveMdlPathHook = Create($"{name}.{nameof(ResolveMdl)}", hooks, vTable[77], type, ResolveMdl, ResolveMdlHuman); + _resolveSkpPathHook = Create($"{name}.{nameof(ResolveSkp)}", hooks, vTable[78], type, ResolveSkp, ResolveSkpHuman); + _resolvePhybPathHook = Create($"{name}.{nameof(ResolvePhyb)}", hooks, vTable[79], type, ResolvePhyb, ResolvePhybHuman); + _resolveKdbPathHook = Create($"{name}.{nameof(ResolveKdb)}", hooks, vTable[80], type, ResolveKdb, ResolveKdbHuman); + _vFunc81Hook = Create( $"{name}.{nameof(VFunc81)}", hooks, vTable[81], type, null, VFunc81); + _resolveBnmbPathHook = Create($"{name}.{nameof(ResolveBnmb)}", hooks, vTable[82], type, ResolveBnmb, ResolveBnmbHuman); + _vFunc83Hook = Create( $"{name}.{nameof(VFunc83)}", hooks, vTable[83], type, null, VFunc83); + _resolvePapPathHook = Create( $"{name}.{nameof(ResolvePap)}", hooks, vTable[84], type, ResolvePap, ResolvePapHuman); + _resolveTmbPathHook = Create( $"{name}.{nameof(ResolveTmb)}", hooks, vTable[85], ResolveTmb); + _resolveMPapPathHook = Create( $"{name}.{nameof(ResolveMPap)}", hooks, vTable[87], ResolveMPap); + _resolveImcPathHook = Create($"{name}.{nameof(ResolveImc)}", hooks, vTable[89], ResolveImc); + _resolveMtrlPathHook = Create( $"{name}.{nameof(ResolveMtrl)}", hooks, vTable[90], ResolveMtrl); + _resolveSkinMtrlPathHook = Create($"{name}.{nameof(ResolveSkinMtrl)}", hooks, vTable[91], ResolveSkinMtrl); + _resolveDecalPathHook = Create($"{name}.{nameof(ResolveDecal)}", hooks, vTable[92], ResolveDecal); + _resolveVfxPathHook = Create( $"{name}.{nameof(ResolveVfx)}", hooks, vTable[93], type, ResolveVfx, ResolveVfxHuman); + _resolveEidPathHook = Create( $"{name}.{nameof(ResolveEid)}", hooks, vTable[94], ResolveEid); + + + // @formatter:on + if (!HookOverrides.Instance.Resources.ResolvePathHooks) + Enable(); + } + + public void Enable() + { + _resolveDecalPathHook.Enable(); + _resolveEidPathHook.Enable(); + _resolveImcPathHook.Enable(); + _resolveMPapPathHook.Enable(); + _resolveMdlPathHook.Enable(); + _resolveMtrlPathHook.Enable(); + _resolveSkinMtrlPathHook.Enable(); + _resolvePapPathHook.Enable(); + _resolveKdbPathHook.Enable(); + _resolvePhybPathHook.Enable(); + _resolveBnmbPathHook.Enable(); + _resolveSklbPathHook.Enable(); + _resolveSkpPathHook.Enable(); + _resolveTmbPathHook.Enable(); + _resolveVfxPathHook.Enable(); + _vFunc81Hook?.Enable(); + _vFunc83Hook?.Enable(); + } + + public void Disable() + { + _resolveDecalPathHook.Disable(); + _resolveEidPathHook.Disable(); + _resolveImcPathHook.Disable(); + _resolveMPapPathHook.Disable(); + _resolveMdlPathHook.Disable(); + _resolveMtrlPathHook.Disable(); + _resolveSkinMtrlPathHook.Disable(); + _resolvePapPathHook.Disable(); + _resolveKdbPathHook.Disable(); + _resolvePhybPathHook.Disable(); + _resolveBnmbPathHook.Disable(); + _resolveSklbPathHook.Disable(); + _resolveSkpPathHook.Disable(); + _resolveTmbPathHook.Disable(); + _resolveVfxPathHook.Disable(); + _vFunc81Hook?.Disable(); + _vFunc83Hook?.Disable(); + } + + public void Dispose() + { + _resolveDecalPathHook.Dispose(); + _resolveEidPathHook.Dispose(); + _resolveImcPathHook.Dispose(); + _resolveMPapPathHook.Dispose(); + _resolveMdlPathHook.Dispose(); + _resolveMtrlPathHook.Dispose(); + _resolveSkinMtrlPathHook.Dispose(); + _resolvePapPathHook.Dispose(); + _resolveKdbPathHook.Dispose(); + _resolvePhybPathHook.Dispose(); + _resolveBnmbPathHook.Dispose(); + _resolveSklbPathHook.Dispose(); + _resolveSkpPathHook.Dispose(); + _resolveTmbPathHook.Dispose(); + _resolveVfxPathHook.Dispose(); + _vFunc81Hook?.Dispose(); + _vFunc83Hook?.Dispose(); + } + + private nint ResolveDecal(nint drawObject, nint pathBuffer, nint pathBufferSize, uint slotIndex) + => ResolvePath(drawObject, _resolveDecalPathHook.Original(drawObject, pathBuffer, pathBufferSize, slotIndex)); + + private nint ResolveEid(nint drawObject, nint pathBuffer, nint pathBufferSize) + => ResolvePath(drawObject, _resolveEidPathHook.Original(drawObject, pathBuffer, pathBufferSize)); + + private nint ResolveImc(nint drawObject, nint pathBuffer, nint pathBufferSize, uint slotIndex) + => ResolvePath(drawObject, _resolveImcPathHook.Original(drawObject, pathBuffer, pathBufferSize, slotIndex)); + + private nint ResolveMPap(nint drawObject, nint pathBuffer, nint pathBufferSize, uint slotIndex, uint unkSId) + => ResolvePath(drawObject, _resolveMPapPathHook.Original(drawObject, pathBuffer, pathBufferSize, slotIndex, unkSId)); + + private nint ResolveMdl(nint drawObject, nint pathBuffer, nint pathBufferSize, uint slotIndex) + => ResolvePath(drawObject, _resolveMdlPathHook.Original(drawObject, pathBuffer, pathBufferSize, slotIndex)); + + private nint ResolveMtrl(nint drawObject, nint pathBuffer, nint pathBufferSize, uint slotIndex, nint mtrlFileName) + => ResolvePath(drawObject, _resolveMtrlPathHook.Original(drawObject, pathBuffer, pathBufferSize, slotIndex, mtrlFileName)); + + private nint ResolveSkinMtrl(nint drawObject, nint pathBuffer, nint pathBufferSize, uint slotIndex) + { + var finalPathBuffer = _resolveSkinMtrlPathHook.Original(drawObject, pathBuffer, pathBufferSize, slotIndex); + if (DebugConfiguration.UseSkinMaterialProcessing && finalPathBuffer != nint.Zero && finalPathBuffer == pathBuffer) + SkinMtrlPathEarlyProcessing.Process(new Span((void*)pathBuffer, (int)pathBufferSize), (CharacterBase*)drawObject, slotIndex); + + return ResolvePath(drawObject, finalPathBuffer); + } + + private nint ResolvePap(nint drawObject, nint pathBuffer, nint pathBufferSize, uint unkAnimationIndex, nint animationName) + => ResolvePath(drawObject, _resolvePapPathHook.Original(drawObject, pathBuffer, pathBufferSize, unkAnimationIndex, animationName)); + + private nint ResolveKdb(nint drawObject, nint pathBuffer, nint pathBufferSize, uint partialSkeletonIndex) + => ResolvePath(drawObject, _resolveKdbPathHook.Original(drawObject, pathBuffer, pathBufferSize, partialSkeletonIndex)); + + private nint ResolvePhyb(nint drawObject, nint pathBuffer, nint pathBufferSize, uint partialSkeletonIndex) + => ResolvePath(drawObject, _resolvePhybPathHook.Original(drawObject, pathBuffer, pathBufferSize, partialSkeletonIndex)); + + private nint ResolveBnmb(nint drawObject, nint pathBuffer, nint pathBufferSize, uint partialSkeletonIndex) + => ResolvePath(drawObject, _resolveBnmbPathHook.Original(drawObject, pathBuffer, pathBufferSize, partialSkeletonIndex)); + + private nint ResolveSklb(nint drawObject, nint pathBuffer, nint pathBufferSize, uint partialSkeletonIndex) + => ResolvePath(drawObject, _resolveSklbPathHook.Original(drawObject, pathBuffer, pathBufferSize, partialSkeletonIndex)); + + private nint ResolveSkp(nint drawObject, nint pathBuffer, nint pathBufferSize, uint partialSkeletonIndex) + => ResolvePath(drawObject, _resolveSkpPathHook.Original(drawObject, pathBuffer, pathBufferSize, partialSkeletonIndex)); + + private nint ResolveTmb(nint drawObject, nint pathBuffer, nint pathBufferSize, nint timelineName) + => ResolvePath(drawObject, _resolveTmbPathHook.Original(drawObject, pathBuffer, pathBufferSize, timelineName)); + + private nint ResolveVfx(nint drawObject, nint pathBuffer, nint pathBufferSize, uint slotIndex, nint unkOutParam) + => ResolvePath(drawObject, _resolveVfxPathHook.Original(drawObject, pathBuffer, pathBufferSize, slotIndex, unkOutParam)); + + + private nint ResolveMdlHuman(nint drawObject, nint pathBuffer, nint pathBufferSize, uint slotIndex) + { + var collection = _parent.CollectionResolver.IdentifyCollection((DrawObject*)drawObject, true); + if (slotIndex < 10) + _parent.MetaState.EqdpCollection.Push(collection); + + var ret = ResolvePath(collection, _resolveMdlPathHook.Original(drawObject, pathBuffer, pathBufferSize, slotIndex)); + if (slotIndex < 10) + _parent.MetaState.EqdpCollection.Pop(); + + return ret; + } + + private nint ResolvePapHuman(nint drawObject, nint pathBuffer, nint pathBufferSize, uint unkAnimationIndex, nint animationName) + { + var collection = _parent.CollectionResolver.IdentifyCollection((DrawObject*)drawObject, true); + _parent.MetaState.EstCollection.Push(collection); + var ret = ResolvePath(collection, + _resolvePapPathHook.Original(drawObject, pathBuffer, pathBufferSize, unkAnimationIndex, animationName)); + _parent.MetaState.EstCollection.Pop(); + return ret; + } + + private nint ResolveKdbHuman(nint drawObject, nint pathBuffer, nint pathBufferSize, uint partialSkeletonIndex) + { + var collection = _parent.CollectionResolver.IdentifyCollection((DrawObject*)drawObject, true); + _parent.MetaState.EstCollection.Push(collection); + var ret = ResolvePath(collection, _resolveKdbPathHook.Original(drawObject, pathBuffer, pathBufferSize, partialSkeletonIndex)); + _parent.MetaState.EstCollection.Pop(); + return ret; + } + + private nint ResolvePhybHuman(nint drawObject, nint pathBuffer, nint pathBufferSize, uint partialSkeletonIndex) + { + var collection = _parent.CollectionResolver.IdentifyCollection((DrawObject*)drawObject, true); + _parent.MetaState.EstCollection.Push(collection); + var ret = ResolvePath(collection, _resolvePhybPathHook.Original(drawObject, pathBuffer, pathBufferSize, partialSkeletonIndex)); + _parent.MetaState.EstCollection.Pop(); + return ret; + } + + private nint ResolveBnmbHuman(nint drawObject, nint pathBuffer, nint pathBufferSize, uint partialSkeletonIndex) + { + var collection = _parent.CollectionResolver.IdentifyCollection((DrawObject*)drawObject, true); + _parent.MetaState.EstCollection.Push(collection); + var ret = ResolvePath(collection, _resolveBnmbPathHook.Original(drawObject, pathBuffer, pathBufferSize, partialSkeletonIndex)); + _parent.MetaState.EstCollection.Pop(); + return ret; + } + + private nint ResolveSklbHuman(nint drawObject, nint pathBuffer, nint pathBufferSize, uint partialSkeletonIndex) + { + var collection = _parent.CollectionResolver.IdentifyCollection((DrawObject*)drawObject, true); + _parent.MetaState.EstCollection.Push(collection); + var ret = ResolvePath(collection, _resolveSklbPathHook.Original(drawObject, pathBuffer, pathBufferSize, partialSkeletonIndex)); + _parent.MetaState.EstCollection.Pop(); + return ret; + } + + private nint ResolveSkpHuman(nint drawObject, nint pathBuffer, nint pathBufferSize, uint partialSkeletonIndex) + { + var collection = _parent.CollectionResolver.IdentifyCollection((DrawObject*)drawObject, true); + _parent.MetaState.EstCollection.Push(collection); + var ret = ResolvePath(collection, _resolveSkpPathHook.Original(drawObject, pathBuffer, pathBufferSize, partialSkeletonIndex)); + _parent.MetaState.EstCollection.Pop(); + return ret; + } + + private nint ResolveVfxHuman(nint drawObject, nint pathBuffer, nint pathBufferSize, uint slotIndex, nint unkOutParam) + { + switch (slotIndex) + { + case <= 4: return ResolveVfx(drawObject, pathBuffer, pathBufferSize, slotIndex, unkOutParam); + case <= 10: + { + // Enable vfxs for accessories + var changedEquipData = (ChangedEquipData*)((Human*)drawObject)->ChangedEquipData; + if (changedEquipData == null) + return ResolveVfx(drawObject, pathBuffer, pathBufferSize, slotIndex, unkOutParam); + + ref var slot = ref changedEquipData[slotIndex]; + + if (slot.Model == 0 || slot.Variant == 0 || slot.VfxId == 0) + return ResolveVfx(drawObject, pathBuffer, pathBufferSize, slotIndex, unkOutParam); + + if (!Utf8.TryWrite(new Span((void*)pathBuffer, (int)pathBufferSize), + $"chara/accessory/a{slot.Model.Id:D4}/vfx/eff/va{slot.VfxId:D4}.avfx\0", + out _)) + return ResolveVfx(drawObject, pathBuffer, pathBufferSize, slotIndex, unkOutParam); + + *(ulong*)unkOutParam = 4; + return ResolvePath(drawObject, pathBuffer); + } + case 16: + { + // Enable vfxs for glasses + var changedEquipData = (ChangedEquipData*)((Human*)drawObject)->ChangedEquipData; + if (changedEquipData == null) + return ResolveVfx(drawObject, pathBuffer, pathBufferSize, slotIndex, unkOutParam); + + ref var slot = ref changedEquipData[slotIndex - 6]; + + if (slot.BonusModel == 0 || slot.BonusVariant == 0 || slot.VfxId == 0) + return ResolveVfx(drawObject, pathBuffer, pathBufferSize, slotIndex, unkOutParam); + if (!Utf8.TryWrite(new Span((void*)pathBuffer, (int)pathBufferSize), + $"chara/equipment/e{slot.BonusModel.Id:D4}/vfx/eff/ve{slot.VfxId:D4}.avfx\0", + out _)) + return ResolveVfx(drawObject, pathBuffer, pathBufferSize, slotIndex, unkOutParam); + + *(ulong*)unkOutParam = 4; + return ResolvePath(drawObject, pathBuffer); + } + default: return ResolveVfx(drawObject, pathBuffer, pathBufferSize, slotIndex, unkOutParam); + } + } + + private nint VFunc81(nint drawObject, int estType, nint unk) + { + var collection = _parent.CollectionResolver.IdentifyCollection((DrawObject*)drawObject, true); + _parent.MetaState.EstCollection.Push(collection); + var ret = _vFunc81Hook!.Original(drawObject, estType, unk); + _parent.MetaState.EstCollection.Pop(); + return ret; + } + + private nint VFunc83(nint drawObject, int estType, nint unk) + { + var collection = _parent.CollectionResolver.IdentifyCollection((DrawObject*)drawObject, true); + _parent.MetaState.EstCollection.Push(collection); + var ret = _vFunc83Hook!.Original(drawObject, estType, unk); + _parent.MetaState.EstCollection.Pop(); + return ret; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] + [return: NotNullIfNotNull(nameof(other))] + private static Hook? Create(string name, HookManager hooks, nint address, Type type, T? other, T human) where T : Delegate + { + var del = type switch + { + Type.Human => human, + _ => other, + }; + if (del == null) + return null; + + return hooks.CreateHook(name, address, del).Result; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] + private static Hook Create(string name, HookManager hooks, nint address, T del) where T : Delegate + => hooks.CreateHook(name, address, del).Result; + + + // Implementation + [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] + private nint ResolvePath(nint drawObject, nint path) + { + var data = _parent.CollectionResolver.IdentifyCollection((DrawObject*)drawObject, true); + return ResolvePath(data, path); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] + private nint ResolvePath(ResolveData data, nint path) + => _parent.ResolvePath(data, path); +} diff --git a/Penumbra/Interop/Hooks/Resources/ResourceHandleDestructor.cs b/Penumbra/Interop/Hooks/Resources/ResourceHandleDestructor.cs new file mode 100644 index 00000000..0e04029b --- /dev/null +++ b/Penumbra/Interop/Hooks/Resources/ResourceHandleDestructor.cs @@ -0,0 +1,57 @@ +using Dalamud.Hooking; +using OtterGui.Classes; +using OtterGui.Services; +using Penumbra.GameData; +using Penumbra.Interop.Structs; +using Penumbra.UI.ResourceWatcher; + +namespace Penumbra.Interop.Hooks.Resources; + +public sealed unsafe class ResourceHandleDestructor : EventWrapperPtr, IHookService +{ + public enum Priority + { + /// + SubfileHelper, + + /// + ShaderReplacementFixer, + + /// + ResourceLoader, + + /// + ResourceWatcher, + } + + public ResourceHandleDestructor(HookManager hooks) + : base("Destroy ResourceHandle") + => _task = hooks.CreateHook(Name, Sigs.ResourceHandleDestructor, Detour, + !HookOverrides.Instance.Resources.ResourceHandleDestructor); + + private readonly Task> _task; + + public nint Address + => _task.Result.Address; + + public void Enable() + => _task.Result.Enable(); + + public void Disable() + => _task.Result.Disable(); + + public Task Awaiter + => _task; + + public bool Finished + => _task.IsCompletedSuccessfully; + + private delegate nint Delegate(ResourceHandle* resourceHandle); + + private nint Detour(ResourceHandle* resourceHandle) + { + Penumbra.Log.Excessive($"[{Name}] Triggered with 0x{(nint)resourceHandle:X}."); + Invoke(resourceHandle); + return _task.Result.Original(resourceHandle); + } +} diff --git a/Penumbra/Interop/MaterialPreview/LiveColorTablePreviewer.cs b/Penumbra/Interop/MaterialPreview/LiveColorTablePreviewer.cs new file mode 100644 index 00000000..0415fc9d --- /dev/null +++ b/Penumbra/Interop/MaterialPreview/LiveColorTablePreviewer.cs @@ -0,0 +1,117 @@ +using Dalamud.Plugin.Services; +using FFXIVClientStructs.FFXIV.Client.Graphics.Kernel; +using FFXIVClientStructs.FFXIV.Client.Graphics.Scene; +using Penumbra.GameData.Interop; +using Penumbra.Interop.SafeHandles; + +namespace Penumbra.Interop.MaterialPreview; + +public sealed unsafe class LiveColorTablePreviewer : LiveMaterialPreviewerBase +{ + private readonly IFramework _framework; + + private readonly Texture** _colorTableTexture; + private readonly SafeTextureHandle _originalColorTableTexture; + + private bool _updatePending; + + public int Width { get; } + public int Height { get; } + + public Half[] ColorTable { get; } + + public LiveColorTablePreviewer(ObjectManager objects, IFramework framework, MaterialInfo materialInfo) + : base(objects, materialInfo) + { + _framework = framework; + + var mtrlHandle = Material->MaterialResourceHandle; + if (mtrlHandle == null) + throw new InvalidOperationException("Material doesn't have a resource handle"); + + var colorSetTextures = DrawObject->ColorTableTextures; + if (colorSetTextures == null) + throw new InvalidOperationException("Draw object doesn't have color table textures"); + + _colorTableTexture = colorSetTextures + (MaterialInfo.ModelSlot * CharacterBase.MaterialsPerSlot + MaterialInfo.MaterialSlot); + + + _originalColorTableTexture = new SafeTextureHandle(*_colorTableTexture, true); + if (_originalColorTableTexture.Texture == null) + throw new InvalidOperationException("Material doesn't have a color table"); + + Width = (int)_originalColorTableTexture.Texture->ActualWidth; + Height = (int)_originalColorTableTexture.Texture->ActualHeight; + ColorTable = new Half[Width * Height * 4]; + _updatePending = true; + + framework.Update += OnFrameworkUpdate; + } + + public Span GetColorRow(int i) + => ColorTable.AsSpan().Slice(Width * 4 * i, Width * 4); + + protected override void Clear(bool disposing, bool reset) + { + _framework.Update -= OnFrameworkUpdate; + + base.Clear(disposing, reset); + + if (reset) + _originalColorTableTexture.Exchange(ref *(nint*)_colorTableTexture); + + _originalColorTableTexture.Dispose(); + } + + public void ScheduleUpdate() + { + _updatePending = true; + } + + [SkipLocalsInit] + private void OnFrameworkUpdate(IFramework _) + { + if (!_updatePending) + return; + + _updatePending = false; + + if (!CheckValidity()) + return; + + var textureSize = stackalloc int[2]; + textureSize[0] = Width; + textureSize[1] = Height; + + using var texture = + new SafeTextureHandle( + Device.Instance()->CreateTexture2D(textureSize, 1, TextureFormat.R16G16B16A16_FLOAT, + TextureFlags.TextureNoSwizzle | TextureFlags.Immutable | TextureFlags.Managed, 7), false); + if (texture.IsInvalid) + return; + + bool success; + lock (ColorTable) + { + fixed (Half* colorTable = ColorTable) + { + success = texture.Texture->InitializeContents(colorTable); + } + } + + if (success) + texture.Exchange(ref *(nint*)_colorTableTexture); + } + + protected override bool IsStillValid() + { + if (!base.IsStillValid()) + return false; + + var colorSetTextures = DrawObject->ColorTableTextures; + if (colorSetTextures == null) + return false; + + return _colorTableTexture == colorSetTextures + (MaterialInfo.ModelSlot * CharacterBase.MaterialsPerSlot + MaterialInfo.MaterialSlot); + } +} diff --git a/Penumbra/Interop/MaterialPreview/LiveMaterialPreviewer.cs b/Penumbra/Interop/MaterialPreview/LiveMaterialPreviewer.cs new file mode 100644 index 00000000..60762ac7 --- /dev/null +++ b/Penumbra/Interop/MaterialPreview/LiveMaterialPreviewer.cs @@ -0,0 +1,136 @@ +using FFXIVClientStructs.FFXIV.Client.Graphics.Kernel; +using Penumbra.GameData.Interop; + +namespace Penumbra.Interop.MaterialPreview; + +public sealed unsafe class LiveMaterialPreviewer : LiveMaterialPreviewerBase +{ + private readonly ShaderPackage* _shaderPackage; + + private readonly uint _originalShPkFlags; + private readonly byte[] _originalMaterialParameter; + private readonly uint[] _originalSamplerFlags; + + public LiveMaterialPreviewer(ObjectManager objects, MaterialInfo materialInfo) + : base(objects, materialInfo) + { + var mtrlHandle = Material->MaterialResourceHandle; + if (mtrlHandle == null) + throw new InvalidOperationException("Material doesn't have a resource handle"); + + var shpkHandle = mtrlHandle->ShaderPackageResourceHandle; + if (shpkHandle == null) + throw new InvalidOperationException("Material doesn't have a ShPk resource handle"); + + _shaderPackage = shpkHandle->ShaderPackage; + if (_shaderPackage == null) + throw new InvalidOperationException("Material doesn't have a shader package"); + + _originalShPkFlags = Material->ShaderFlags; + + _originalMaterialParameter = Material->MaterialParameterCBuffer->TryGetBuffer().ToArray(); + + _originalSamplerFlags = new uint[Material->TextureCount]; + for (var i = 0; i < _originalSamplerFlags.Length; ++i) + _originalSamplerFlags[i] = Material->Textures[i].SamplerFlags; + } + + protected override void Clear(bool disposing, bool reset) + { + base.Clear(disposing, reset); + + if (!reset) + return; + + Material->ShaderFlags = _originalShPkFlags; + var materialParameter = Material->MaterialParameterCBuffer->TryGetBuffer(); + if (!materialParameter.IsEmpty) + _originalMaterialParameter.AsSpan().CopyTo(materialParameter); + + for (var i = 0; i < _originalSamplerFlags.Length; ++i) + Material->Textures[i].SamplerFlags = _originalSamplerFlags[i]; + } + + public void SetShaderPackageFlags(uint shPkFlags) + { + if (!CheckValidity()) + return; + + Material->ShaderFlags = shPkFlags; + } + + public void SetMaterialParameter(uint parameterCrc, Index offset, ReadOnlySpan value) + { + if (!CheckValidity()) + return; + + var constantBuffer = Material->MaterialParameterCBuffer; + if (constantBuffer == null) + return; + + var buffer = constantBuffer->TryGetBuffer(); + if (buffer.IsEmpty) + return; + + for (var i = 0; i < _shaderPackage->MaterialElementCount; ++i) + { + ref var parameter = ref _shaderPackage->MaterialElementsSpan[i]; + if (parameter.CRC != parameterCrc) + continue; + + if (parameter.Offset + parameter.Size > buffer.Length) + return; + + value.TryCopyTo(buffer.Slice(parameter.Offset, parameter.Size)[offset..]); + return; + } + } + + public void SetSamplerFlags(uint samplerCrc, uint samplerFlags) + { + if (!CheckValidity()) + return; + + var id = 0u; + var found = false; + + var samplers = _shaderPackage->Samplers; + for (var i = 0; i < _shaderPackage->SamplerCount; ++i) + { + if (samplers[i].CRC != samplerCrc) + continue; + + id = samplers[i].Id; + found = true; + break; + } + + if (!found) + return; + + for (var i = 0; i < Material->TextureCount; ++i) + { + if (Material->Textures[i].Id != id) + continue; + + Material->Textures[i].SamplerFlags = (samplerFlags & 0xFFFFFDFF) | 0x000001C0; + break; + } + } + + protected override bool IsStillValid() + { + if (!base.IsStillValid()) + return false; + + var mtrlHandle = Material->MaterialResourceHandle; + if (mtrlHandle == null) + return false; + + var shpkHandle = mtrlHandle->ShaderPackageResourceHandle; + if (shpkHandle == null) + return false; + + return _shaderPackage == shpkHandle->ShaderPackage; + } +} diff --git a/Penumbra/Interop/MaterialPreview/LiveMaterialPreviewerBase.cs b/Penumbra/Interop/MaterialPreview/LiveMaterialPreviewerBase.cs new file mode 100644 index 00000000..f176990e --- /dev/null +++ b/Penumbra/Interop/MaterialPreview/LiveMaterialPreviewerBase.cs @@ -0,0 +1,67 @@ +using Dalamud.Plugin.Services; +using FFXIVClientStructs.FFXIV.Client.Graphics.Render; +using FFXIVClientStructs.FFXIV.Client.Graphics.Scene; +using Penumbra.GameData.Interop; + +namespace Penumbra.Interop.MaterialPreview; + +public abstract unsafe class LiveMaterialPreviewerBase : IDisposable +{ + private readonly ObjectManager _objects; + + public readonly MaterialInfo MaterialInfo; + public readonly CharacterBase* DrawObject; + protected readonly Material* Material; + + protected bool Valid; + + public LiveMaterialPreviewerBase(ObjectManager objects, MaterialInfo materialInfo) + { + _objects = objects; + + MaterialInfo = materialInfo; + var gameObject = MaterialInfo.GetCharacter(objects); + if (gameObject == nint.Zero) + throw new InvalidOperationException("Cannot retrieve game object."); + + DrawObject = (CharacterBase*)MaterialInfo.GetDrawObject(gameObject); + if (DrawObject == null) + throw new InvalidOperationException("Cannot retrieve draw object."); + + Material = MaterialInfo.GetDrawObjectMaterial(DrawObject); + if (Material == null) + throw new InvalidOperationException("Cannot retrieve material."); + + Valid = true; + } + + public void Dispose() + { + if (Valid) + Clear(true, IsStillValid()); + } + + public bool CheckValidity() + { + if (Valid && !IsStillValid()) + Clear(false, false); + return Valid; + } + + protected virtual void Clear(bool disposing, bool reset) + { + Valid = false; + } + + protected virtual bool IsStillValid() + { + var gameObject = MaterialInfo.GetCharacter(_objects); + if (gameObject == nint.Zero) + return false; + + if ((nint)DrawObject != MaterialInfo.GetDrawObject(gameObject)) + return false; + + return Material == MaterialInfo.GetDrawObjectMaterial(DrawObject); + } +} diff --git a/Penumbra/Interop/MaterialPreview/MaterialInfo.cs b/Penumbra/Interop/MaterialPreview/MaterialInfo.cs new file mode 100644 index 00000000..a9fb46ff --- /dev/null +++ b/Penumbra/Interop/MaterialPreview/MaterialInfo.cs @@ -0,0 +1,114 @@ +using FFXIVClientStructs.FFXIV.Client.Game.Character; +using FFXIVClientStructs.FFXIV.Client.Graphics.Render; +using FFXIVClientStructs.FFXIV.Client.Graphics.Scene; +using Penumbra.GameData.Interop; +using Penumbra.GameData.Structs; +using Penumbra.Interop.PathResolving; +using Penumbra.String; +using static Penumbra.Interop.Structs.StructExtensions; +using Model = Penumbra.GameData.Interop.Model; + +namespace Penumbra.Interop.MaterialPreview; + +public enum DrawObjectType +{ + Character, + Mainhand, + Offhand, + Vfx, +}; + +public readonly record struct MaterialInfo(ObjectIndex ObjectIndex, DrawObjectType Type, int ModelSlot, int MaterialSlot) +{ + public Actor GetCharacter(ObjectManager objects) + => objects[ObjectIndex]; + + public nint GetDrawObject(Actor address) + => GetDrawObject(Type, address); + + public unsafe Material* GetDrawObjectMaterial(ObjectManager objects) + => GetDrawObjectMaterial((CharacterBase*)GetDrawObject(GetCharacter(objects))); + + public unsafe Material* GetDrawObjectMaterial(CharacterBase* drawObject) + { + if (drawObject == null) + return null; + + if (ModelSlot < 0 || ModelSlot >= drawObject->SlotCount) + return null; + + var model = drawObject->Models[ModelSlot]; + if (model == null) + return null; + + if (MaterialSlot < 0 || MaterialSlot >= model->MaterialCount) + return null; + + return model->Materials[MaterialSlot]; + } + + public static unsafe List FindMaterials(IEnumerable gameObjects, string materialPath) + { + var needle = CiByteString.FromString(materialPath.Replace('\\', '/'), out var m, + MetaDataComputation.CiCrc32 | MetaDataComputation.Crc32) + ? m + : CiByteString.Empty; + + var result = new List(Enum.GetValues().Length); + foreach (var objectPtr in gameObjects) + { + var gameObject = (Character*)objectPtr; + if (gameObject == null) + continue; + + var index = (ObjectIndex)gameObject->GameObject.ObjectIndex; + + foreach (var type in Enum.GetValues()) + { + var drawObject = GetDrawObject(type, objectPtr); + if (!drawObject.Valid) + continue; + + for (var i = 0; i < drawObject.AsCharacterBase->SlotCount; ++i) + { + var model = drawObject.AsCharacterBase->Models[i]; + if (model == null) + continue; + + for (var j = 0; j < model->MaterialCount; ++j) + { + var material = model->Materials[j]; + if (material == null) + continue; + + var mtrlHandle = material->MaterialResourceHandle; + if (mtrlHandle == null) + continue; + + PathDataHandler.Split(mtrlHandle->FileName.AsSpan(), out var path, out _); + var fileName = CiByteString.FromSpanUnsafe(path, true); + if (fileName == needle) + result.Add(new MaterialInfo(index, type, i, j)); + } + } + } + } + + return result; + } + + private static unsafe Model GetDrawObject(DrawObjectType type, Actor address) + { + if (!address.Valid) + return Model.Null; + + return type switch + { + DrawObjectType.Character => address.Model, + DrawObjectType.Mainhand => address.AsCharacter->DrawData.Weapon(DrawDataContainer.WeaponSlot.MainHand).DrawObject, + DrawObjectType.Offhand => address.AsCharacter->DrawData.Weapon(DrawDataContainer.WeaponSlot.OffHand).DrawObject, + DrawObjectType.Vfx => address.AsCharacter->DrawData.Weapon(DrawDataContainer.WeaponSlot.Unk).DrawObject, + _ => Model.Null, + }; + } +} diff --git a/Penumbra/Interop/MusicManager.cs b/Penumbra/Interop/MusicManager.cs deleted file mode 100644 index 45d0c8c4..00000000 --- a/Penumbra/Interop/MusicManager.cs +++ /dev/null @@ -1,46 +0,0 @@ -using System; -using Dalamud.Logging; - -namespace Penumbra.Interop -{ - // Use this to disable streaming of specific soundfiles, - // which will allow replacement of .scd files. - public unsafe class MusicManager - { - private readonly IntPtr _musicManager; - - public MusicManager( ) - { - var framework = Dalamud.Framework.Address.BaseAddress; - - // the wildcard is basically the framework offset we want (lol) - // .text:000000000009051A 48 8B 8E 18 2A 00 00 mov rcx, [rsi+2A18h] - // .text:0000000000090521 39 78 20 cmp [rax+20h], edi - // .text:0000000000090524 0F 94 C2 setz dl - // .text:0000000000090527 45 33 C0 xor r8d, r8d - // .text:000000000009052A E8 41 1C 15 00 call musicInit - var musicInitCallLocation = Dalamud.SigScanner.ScanText( "48 8B 8E ?? ?? ?? ?? 39 78 20 0F 94 C2 45 33 C0" ); - var musicManagerOffset = *( int* )( musicInitCallLocation + 3 ); - PluginLog.Debug( "Found MusicInitCall location at 0x{Location:X16}. Framework offset for MusicManager is 0x{Offset:X8}", - musicInitCallLocation.ToInt64(), musicManagerOffset ); - _musicManager = *( IntPtr* )( framework + musicManagerOffset ); - PluginLog.Debug( "MusicManager found at 0x{Location:X16}", _musicManager ); - } - - public bool StreamingEnabled - { - get => *( bool* )( _musicManager + 50 ); - private set - { - PluginLog.Debug( value ? "Music streaming enabled." : "Music streaming disabled." ); - *( bool* )( _musicManager + 50 ) = value; - } - } - - public void EnableStreaming() - => StreamingEnabled = true; - - public void DisableStreaming() - => StreamingEnabled = false; - } -} \ No newline at end of file diff --git a/Penumbra/Interop/ObjectReloader.cs b/Penumbra/Interop/ObjectReloader.cs deleted file mode 100644 index b8000f9e..00000000 --- a/Penumbra/Interop/ObjectReloader.cs +++ /dev/null @@ -1,445 +0,0 @@ -using System; -using System.Collections.Generic; -using System.ComponentModel; -using System.Linq; -using System.Runtime.InteropServices; -using System.Threading.Tasks; -using Dalamud.Game.ClientState.Conditions; -using Dalamud.Game.ClientState.Objects.Types; -using Penumbra.GameData.Enums; -using Penumbra.Mods; - -namespace Penumbra.Interop -{ - public class ObjectReloader : IDisposable - { - private delegate void ManipulateDraw( IntPtr actor ); - - [Flags] - public enum LoadingFlags : int - { - Invisibility = 0x00_00_00_02, - IsLoading = 0x00_00_08_00, - SomeNpcFlag = 0x00_00_01_00, - MaybeCulled = 0x00_00_04_00, - MaybeHiddenMinion = 0x00_00_80_00, - MaybeHiddenSummon = 0x00_80_00_00, - } - - private const int RenderModeOffset = 0x0104; - private const int UnloadAllRedrawDelay = 250; - private const uint NpcObjectId = unchecked( ( uint )-536870912 ); - public const int GPosePlayerIdx = 201; - public const int GPoseEndIdx = GPosePlayerIdx + 48; - - private readonly ModManager _mods; - private readonly Queue< (uint actorId, string name, RedrawType s) > _actorIds = new(); - - internal int DefaultWaitFrames; - - private int _waitFrames; - private int _currentFrame; - private bool _changedSettings; - private uint _currentObjectId = uint.MaxValue; - private LoadingFlags _currentObjectStartState = 0; - private RedrawType _currentRedrawType = RedrawType.Unload; - private string? _currentObjectName; - private bool _wasTarget; - private bool _inGPose; - - public static IntPtr RenderPtr( GameObject actor ) - => actor.Address + RenderModeOffset; - - public ObjectReloader( ModManager mods, int defaultWaitFrames ) - { - _mods = mods; - DefaultWaitFrames = defaultWaitFrames; - } - - private void ChangeSettings() - { - if( _currentObjectName != null && _mods.Collections.CharacterCollection.TryGetValue( _currentObjectName, out var collection ) ) - { - _changedSettings = true; - _mods.Collections.SetActiveCollection( collection, _currentObjectName ); - } - } - - private void RestoreSettings() - { - _mods.Collections.ResetActiveCollection(); - _changedSettings = false; - } - - private unsafe void WriteInvisible( GameObject actor, int actorIdx ) - { - var renderPtr = RenderPtr( actor ); - if( renderPtr == IntPtr.Zero ) - { - return; - } - - _currentObjectStartState = *( LoadingFlags* )renderPtr; - *( LoadingFlags* )renderPtr |= LoadingFlags.Invisibility; - - if( _inGPose ) - { - var ptr = ( void*** )actor.Address; - var disableDraw = Marshal.GetDelegateForFunctionPointer< ManipulateDraw >( new IntPtr( ptr[ 0 ][ 17 ] ) ); - disableDraw( actor.Address ); - } - } - - private unsafe bool StillLoading( IntPtr renderPtr ) - { - const LoadingFlags stillLoadingFlags = LoadingFlags.SomeNpcFlag - | LoadingFlags.MaybeCulled - | LoadingFlags.MaybeHiddenMinion - | LoadingFlags.MaybeHiddenSummon; - - if( renderPtr != IntPtr.Zero ) - { - var loadingFlags = *( LoadingFlags* )renderPtr; - if( loadingFlags == _currentObjectStartState ) - { - return false; - } - - return !( loadingFlags == 0 || ( loadingFlags & stillLoadingFlags ) != 0 ); - } - - return false; - } - - private unsafe void WriteVisible( GameObject actor, int actorIdx ) - { - var renderPtr = RenderPtr( actor ); - *( LoadingFlags* )renderPtr &= ~LoadingFlags.Invisibility; - - if( _inGPose ) - { - var ptr = ( void*** )actor.Address; - var enableDraw = Marshal.GetDelegateForFunctionPointer< ManipulateDraw >( new IntPtr( ptr[ 0 ][ 16 ] ) ); - enableDraw( actor.Address ); - } - } - - private bool CheckObject( GameObject actor ) - { - if( _currentObjectId != actor.ObjectId ) - { - return false; - } - - if( _currentObjectId != NpcObjectId ) - { - return true; - } - - return _currentObjectName == actor.Name.ToString(); - } - - private bool CheckObjectGPose( GameObject actor ) - => actor.ObjectId == NpcObjectId && _currentObjectName == actor.Name.ToString(); - - private (GameObject?, int) FindCurrentObject() - { - if( _inGPose ) - { - for( var i = GPosePlayerIdx; i < GPoseEndIdx; ++i ) - { - var actor = Dalamud.Objects[ i ]; - if( actor == null ) - { - break; - } - - if( CheckObjectGPose( actor ) ) - { - return ( actor, i ); - } - } - } - - for( var i = 0; i < Dalamud.Objects.Length; ++i ) - { - if( i == GPosePlayerIdx ) - { - i = GPoseEndIdx; - } - - var actor = Dalamud.Objects[ i ]; - if( actor != null && CheckObject( actor ) ) - { - return ( actor, i ); - } - } - - return ( null, -1 ); - } - - private void PopObject() - { - if( _actorIds.Count > 0 ) - { - var (id, name, s) = _actorIds.Dequeue(); - _currentObjectName = name; - _currentObjectId = id; - _currentRedrawType = s; - var (actor, _) = FindCurrentObject(); - if( actor == null ) - { - return; - } - - _wasTarget = actor.Address == Dalamud.Targets.Target?.Address; - - ++_currentFrame; - } - else - { - Dalamud.Framework.Update -= OnUpdateEvent; - } - } - - private void ApplySettingsOrRedraw() - { - var (actor, idx) = FindCurrentObject(); - if( actor == null ) - { - _currentFrame = 0; - return; - } - - switch( _currentRedrawType ) - { - case RedrawType.Unload: - WriteInvisible( actor, idx ); - _currentFrame = 0; - break; - case RedrawType.RedrawWithSettings: - ChangeSettings(); - ++_currentFrame; - break; - case RedrawType.RedrawWithoutSettings: - WriteVisible( actor, idx ); - _currentFrame = 0; - break; - case RedrawType.WithoutSettings: - WriteInvisible( actor, idx ); - ++_currentFrame; - break; - case RedrawType.WithSettings: - ChangeSettings(); - WriteInvisible( actor, idx ); - ++_currentFrame; - break; - case RedrawType.OnlyWithSettings: - ChangeSettings(); - if( !_changedSettings ) - { - return; - } - - WriteInvisible( actor, idx ); - ++_currentFrame; - break; - case RedrawType.AfterGPoseWithSettings: - case RedrawType.AfterGPoseWithoutSettings: - if( _inGPose ) - { - _actorIds.Enqueue( ( _currentObjectId, _currentObjectName!, _currentRedrawType ) ); - _currentFrame = 0; - } - else - { - _currentRedrawType = _currentRedrawType == RedrawType.AfterGPoseWithSettings - ? RedrawType.WithSettings - : RedrawType.WithoutSettings; - } - - break; - default: throw new InvalidEnumArgumentException(); - } - } - - private void StartRedrawAndWait() - { - var (actor, idx) = FindCurrentObject(); - if( actor == null ) - { - RevertSettings(); - return; - } - - WriteVisible( actor, idx ); - _currentFrame = _changedSettings || _wasTarget ? _currentFrame + 1 : 0; - } - - private void RevertSettings() - { - var (actor, _) = FindCurrentObject(); - if( actor != null ) - { - if( !StillLoading( RenderPtr( actor ) ) ) - { - RestoreSettings(); - if( _wasTarget && Dalamud.Targets.Target == null ) - { - Dalamud.Targets.SetTarget( actor ); - } - - _currentFrame = 0; - } - } - else - { - _currentFrame = 0; - } - } - - private void OnUpdateEvent( object framework ) - { - if( Dalamud.Conditions[ ConditionFlag.BetweenAreas51 ] - || Dalamud.Conditions[ ConditionFlag.BetweenAreas ] - || Dalamud.Conditions[ ConditionFlag.OccupiedInCutSceneEvent ] ) - { - _waitFrames = DefaultWaitFrames; - return; - } - - if( _waitFrames > 0 ) - { - --_waitFrames; - return; - } - - _inGPose = Dalamud.Objects[ GPosePlayerIdx ] != null; - - switch( _currentFrame ) - { - case 0: - PopObject(); - break; - case 1: - ApplySettingsOrRedraw(); - break; - case 2: - StartRedrawAndWait(); - break; - case 3: - RevertSettings(); - break; - default: - _currentFrame = 0; - break; - } - } - - private void RedrawObjectIntern( uint objectId, string actorName, RedrawType settings ) - { - if( _actorIds.Contains( ( objectId, actorName, settings ) ) ) - { - return; - } - - _actorIds.Enqueue( ( objectId, actorName, settings ) ); - if( _actorIds.Count == 1 ) - { - Dalamud.Framework.Update += OnUpdateEvent; - } - } - - public void RedrawObject( GameObject? actor, RedrawType settings = RedrawType.WithSettings ) - { - if( actor != null ) - { - RedrawObjectIntern( actor.ObjectId, actor.Name.ToString(), settings ); - } - } - - private GameObject? GetLocalPlayer() - { - var gPosePlayer = Dalamud.Objects[ GPosePlayerIdx ]; - return gPosePlayer ?? Dalamud.Objects[ 0 ]; - } - - private GameObject? GetName( string name ) - { - var lowerName = name.ToLowerInvariant(); - return lowerName switch - { - "" => null, - "" => GetLocalPlayer(), - "self" => GetLocalPlayer(), - "" => Dalamud.Targets.Target, - "target" => Dalamud.Targets.Target, - "" => Dalamud.Targets.FocusTarget, - "focus" => Dalamud.Targets.FocusTarget, - "" => Dalamud.Targets.MouseOverTarget, - "mouseover" => Dalamud.Targets.MouseOverTarget, - _ => Dalamud.Objects.FirstOrDefault( - a => string.Equals( a.Name.ToString(), lowerName, StringComparison.InvariantCultureIgnoreCase ) ), - }; - } - - public void RedrawObject( string name, RedrawType settings = RedrawType.WithSettings ) - => RedrawObject( GetName( name ), settings ); - - public void RedrawAll( RedrawType settings = RedrawType.WithSettings ) - { - Clear(); - foreach( var actor in Dalamud.Objects ) - { - RedrawObject( actor, settings ); - } - } - - private void UnloadAll() - { - Clear(); - foreach( var (actor, index) in Dalamud.Objects.Select( ( a, i ) => ( a, i ) ) ) - { - WriteInvisible( actor, index ); - } - } - - private void RedrawAllWithoutSettings() - { - Clear(); - foreach( var (actor, index) in Dalamud.Objects.Select( ( a, i ) => ( a, i ) ) ) - { - WriteVisible( actor, index ); - } - } - - public async void UnloadAtOnceRedrawWithSettings() - { - Clear(); - UnloadAll(); - await Task.Delay( UnloadAllRedrawDelay ); - RedrawAll( RedrawType.RedrawWithSettings ); - } - - public async void UnloadAtOnceRedrawWithoutSettings() - { - Clear(); - UnloadAll(); - await Task.Delay( UnloadAllRedrawDelay ); - RedrawAllWithoutSettings(); - } - - public void Clear() - { - RestoreSettings(); - _currentFrame = 0; - } - - public void Dispose() - { - RevertSettings(); - _actorIds.Clear(); - Dalamud.Framework.Update -= OnUpdateEvent; - } - } -} \ No newline at end of file diff --git a/Penumbra/Interop/PathResolving/CollectionResolver.cs b/Penumbra/Interop/PathResolving/CollectionResolver.cs new file mode 100644 index 00000000..136393d4 --- /dev/null +++ b/Penumbra/Interop/PathResolving/CollectionResolver.cs @@ -0,0 +1,290 @@ +using Dalamud.Plugin.Services; +using FFXIVClientStructs.FFXIV.Client.Graphics.Scene; +using FFXIVClientStructs.FFXIV.Client.UI.Agent; +using OtterGui.Extensions; +using OtterGui.Services; +using Penumbra.Collections; +using Penumbra.Collections.Manager; +using Penumbra.GameData.Actors; +using Penumbra.GameData.DataContainers; +using Penumbra.GameData.Enums; +using Penumbra.GameData.Interop; +using Penumbra.GameData.Structs; +using Penumbra.String; +using Penumbra.Util; +using Character = FFXIVClientStructs.FFXIV.Client.Game.Character.Character; +using GameObject = FFXIVClientStructs.FFXIV.Client.Game.Object.GameObject; +using ObjectKind = Dalamud.Game.ClientState.Objects.Enums.ObjectKind; + +namespace Penumbra.Interop.PathResolving; + +public sealed unsafe class CollectionResolver( + PerformanceTracker performance, + IdentifiedCollectionCache cache, + IClientState clientState, + ObjectManager objects, + IGameGui gameGui, + ActorManager actors, + CutsceneService cutscenes, + Configuration config, + CollectionManager collectionManager, + TempCollectionManager tempCollections, + DrawObjectState drawObjectState, + HumanModelList humanModels) + : IService +{ + /// + /// Get the collection applying to the current player character + /// or the 'Yourself' or 'Default' collection if no player exists. + /// + public ModCollection PlayerCollection() + { + using var performance1 = performance.Measure(PerformanceType.IdentifyCollection); + var gameObject = objects[0]; + if (!gameObject.Valid) + return collectionManager.Active.ByType(CollectionType.Yourself) + ?? collectionManager.Active.Default; + + var player = actors.GetCurrentPlayer(); + var _ = false; + return CollectionByIdentifier(player) + ?? CheckYourself(player, gameObject) + ?? CollectionByAttributes(gameObject, ref _) + ?? collectionManager.Active.Default; + } + + /// Identify the correct collection for a game object. + public ResolveData IdentifyCollection(GameObject* gameObject, bool useCache) + { + using var t = performance.Measure(PerformanceType.IdentifyCollection); + + if (gameObject == null) + return collectionManager.Active.Default.ToResolveData(); + + try + { + // Login screen reuses the same actors and can not be cached. + if (LoginScreen(gameObject, out var data)) + return data; + + if (useCache && cache.TryGetValue(gameObject, out data)) + return data; + + if (Aesthetician(gameObject, out data)) + return data; + + return DefaultState(gameObject); + } + catch (Exception ex) + { + Penumbra.Log.Error($"Error identifying collection:\n{ex}"); + return collectionManager.Active.Default.ToResolveData(gameObject); + } + } + + /// Identify the correct collection for the last created game object. + public ResolveData IdentifyLastGameObjectCollection(bool useCache) + => IdentifyCollection((GameObject*)drawObjectState.LastGameObject, useCache); + + /// Identify the correct collection for a draw object. + public ResolveData IdentifyCollection(DrawObject* drawObject, bool useCache) + { + if (drawObject is null) + return DefaultCollection; + + Actor obj = drawObjectState.TryGetValue(drawObject, out var gameObject) + ? gameObject.Item1 + : drawObjectState.LastGameObject; + return IdentifyCollection(obj.AsObject, useCache); + } + + /// Get the default collection. + public ResolveData DefaultCollection + => collectionManager.Active.Default.ToResolveData(); + + /// Return whether the given ModelChara id refers to a human-type model. + public bool IsModelHuman(uint modelCharaId) + => humanModels.IsHuman(modelCharaId); + + /// + /// Used if on the Login screen. + /// + private bool LoginScreen(GameObject* gameObject, out ResolveData ret) + { + // Also check for empty names because sometimes named other characters + // might be loaded before being officially logged in. + if (clientState.IsLoggedIn || gameObject->Name[0] != '\0') + { + ret = ResolveData.Invalid; + return false; + } + + if (!config.ShowModsInLobby) + { + ret = ModCollection.Empty.ToResolveData(gameObject); + return true; + } + + var notYetReady = false; + var lobby = AgentLobby.Instance(); + var characterList = CharaSelectCharacterList.Instance(); + if (lobby != null && characterList != null) + { + // The lobby uses the first 8 cutscene actors. + var idx = gameObject->ObjectIndex - ObjectIndex.CutsceneStart.Index; + if (characterList->CharacterMapping.FindFirst(m => m.ClientObjectIndex == idx, out var mapping) + && lobby->LobbyData.CharaSelectEntries.FindFirst(e => e.Value->ContentId == mapping.ContentId, out var charaEntry)) + { + var item = charaEntry.Value; + var identifier = actors.CreatePlayer(new ByteString(item->Name), item->HomeWorldId); + Penumbra.Log.Excessive( + $"Identified {identifier.Incognito(null)} in cutscene for actor {idx + 200} at 0x{(ulong)gameObject:X} of race {(gameObject->IsCharacter() ? ((Character*)gameObject)->DrawData.CustomizeData.Race.ToString() : "Unknown")}."); + if (identifier.IsValid && CollectionByIdentifier(identifier) is { } coll) + { + // Do not add this to caches because game objects are reused for different draw objects. + ret = coll.ToResolveData(gameObject); + return true; + } + } + } + + var collection = collectionManager.Active.ByType(CollectionType.Yourself) + ?? CollectionByAttributes(gameObject, ref notYetReady) + ?? collectionManager.Active.Default; + ret = collection.ToResolveData(gameObject); + return true; + } + + /// Used if at the aesthetician. The relevant actor is yourself, so use player collection when possible. + private bool Aesthetician(GameObject* gameObject, out ResolveData ret) + { + if (gameGui.GetAddonByName("ScreenLog") != nint.Zero) + { + ret = ResolveData.Invalid; + return false; + } + + if (!config.ShowModsInLobby) + { + ret = ModCollection.Empty.ToResolveData(gameObject); + return true; + } + + var player = actors.GetCurrentPlayer(); + var notYetReady = false; + var collection = (player.IsValid ? CollectionByIdentifier(player) : null) + ?? collectionManager.Active.ByType(CollectionType.Yourself) + ?? CollectionByAttributes(gameObject, ref notYetReady) + ?? collectionManager.Active.Default; + ret = notYetReady ? collection.ToResolveData(gameObject) : cache.Set(collection, ActorIdentifier.Invalid, gameObject); + return true; + } + + /// + /// Used when no special state is active. + /// Use individual identifiers first, then Yourself, then group attributes, then ownership settings and last base. + /// + private ResolveData DefaultState(GameObject* gameObject) + { + var identifier = actors.FromObject(gameObject, out var owner, true, false, false); + if (identifier.Type is IdentifierType.Special) + { + (identifier, var type) = collectionManager.Active.Individuals.ConvertSpecialIdentifier(identifier); + if (config.UseNoModsInInspect && type == IndividualCollections.SpecialResult.Inspect) + return cache.Set(ModCollection.Empty, identifier, gameObject); + } + + var notYetReady = false; + var collection = CollectionByIdentifier(identifier) + ?? CheckYourself(identifier, gameObject) + ?? CollectionByAttributes(gameObject, ref notYetReady) + ?? CheckOwnedCollection(identifier, owner, ref notYetReady) + ?? collectionManager.Active.Default; + + return notYetReady ? collection.ToResolveData(gameObject) : cache.Set(collection, identifier, gameObject); + } + + /// Check both temporary and permanent character collections. Temporary first. + private ModCollection? CollectionByIdentifier(ActorIdentifier identifier) + { + if (tempCollections.Collections.TryGetCollection(identifier, out var collection)) + return collection; + + // Always inherit ownership for temporary collections. + if (identifier.Type is IdentifierType.Owned) + { + var playerIdentifier = actors.CreateIndividualUnchecked(IdentifierType.Player, identifier.PlayerName, + identifier.HomeWorld.Id, ObjectKind.None, uint.MaxValue); + if (tempCollections.Collections.TryGetCollection(playerIdentifier, out collection)) + return collection; + } + + if (collectionManager.Active.Individuals.TryGetCollection(identifier, out collection)) + return collection; + + return null; + } + + /// Check for the Yourself collection. + private ModCollection? CheckYourself(ActorIdentifier identifier, Actor actor) + { + if (actor.Index == 0 + || cutscenes.GetParentIndex(actor.Index.Index) == 0 + || identifier.Equals(actors.GetCurrentPlayer())) + return collectionManager.Active.ByType(CollectionType.Yourself); + + return null; + } + + /// Check special collections given the actor. Returns notYetReady if the customize array is not filled. + private ModCollection? CollectionByAttributes(Actor actor, ref bool notYetReady) + { + if (!actor.IsCharacter) + { + Penumbra.Log.Excessive($"Actor to be identified was not yet a Character."); + notYetReady = true; + return null; + } + + // Only handle human models. + if (!IsModelHuman((uint)actor.AsCharacter->ModelContainer.ModelCharaId)) + return null; + + if (actor.Customize->Data[0] == 0) + { + notYetReady = true; + return null; + } + + var bodyType = actor.Customize->Data[2]; + var collection = bodyType switch + { + 3 => collectionManager.Active.ByType(CollectionType.NonPlayerElderly), + 4 => collectionManager.Active.ByType(CollectionType.NonPlayerChild), + _ => null, + }; + if (collection != null) + return collection; + + var race = (SubRace)actor.Customize->Data[4]; + var gender = (Gender)(actor.Customize->Data[1] + 1); + var isNpc = !actor.IsPlayer; + + var type = CollectionTypeExtensions.FromParts(race, gender, isNpc); + collection = collectionManager.Active.ByType(type); + collection ??= collectionManager.Active.ByType(CollectionTypeExtensions.FromParts(gender, isNpc)); + return collection; + } + + /// Get the collection applying to the owner if it is available. + private ModCollection? CheckOwnedCollection(ActorIdentifier identifier, Actor owner, ref bool notYetReady) + { + if (identifier.Type != IdentifierType.Owned || !config.UseOwnerNameForCharacterCollection || !owner.Valid) + return null; + + var id = actors.CreateIndividualUnchecked(IdentifierType.Player, identifier.PlayerName, identifier.HomeWorld.Id, + ObjectKind.None, + uint.MaxValue); + return CheckYourself(id, owner) ?? CollectionByAttributes(owner, ref notYetReady); + } +} diff --git a/Penumbra/Interop/PathResolving/CutsceneService.cs b/Penumbra/Interop/PathResolving/CutsceneService.cs new file mode 100644 index 00000000..97e64f84 --- /dev/null +++ b/Penumbra/Interop/PathResolving/CutsceneService.cs @@ -0,0 +1,181 @@ +using Dalamud.Game.ClientState.Objects.Types; +using Dalamud.Plugin.Services; +using FFXIVClientStructs.FFXIV.Client.Game.Character; +using OtterGui.Services; +using Penumbra.GameData.Enums; +using Penumbra.GameData.Interop; +using Penumbra.Interop.Hooks.Objects; +using Penumbra.String; + +namespace Penumbra.Interop.PathResolving; + +public sealed class CutsceneService : IRequiredService, IDisposable +{ + public const int CutsceneStartIdx = (int)ScreenActor.CutsceneStart; + public const int CutsceneEndIdx = (int)ScreenActor.CutsceneEnd; + public const int CutsceneSlots = CutsceneEndIdx - CutsceneStartIdx; + + private readonly ObjectManager _objects; + private readonly CopyCharacter _copyCharacter; + private readonly CharacterDestructor _characterDestructor; + private readonly ConstructCutsceneCharacter _constructCutsceneCharacter; + private readonly short[] _copiedCharacters = Enumerable.Repeat((short)-1, CutsceneSlots).ToArray(); + + public IEnumerable> Actors + => Enumerable.Range(CutsceneStartIdx, CutsceneSlots) + .Where(i => _objects[i].Valid) + .Select(i => KeyValuePair.Create(i, this[i] ?? _objects.GetDalamudObject(i)!)); + + public unsafe CutsceneService(ObjectManager objects, CopyCharacter copyCharacter, CharacterDestructor characterDestructor, + ConstructCutsceneCharacter constructCutsceneCharacter, IClientState clientState) + { + _objects = objects; + _copyCharacter = copyCharacter; + _characterDestructor = characterDestructor; + _constructCutsceneCharacter = constructCutsceneCharacter; + _copyCharacter.Subscribe(OnCharacterCopy, CopyCharacter.Priority.CutsceneService); + _characterDestructor.Subscribe(OnCharacterDestructor, CharacterDestructor.Priority.CutsceneService); + _constructCutsceneCharacter.Subscribe(OnSetupPlayerNpc, ConstructCutsceneCharacter.Priority.CutsceneService); + if (clientState.IsGPosing) + RecoverGPoseActors(); + } + + + /// + /// 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. + /// + private IGameObject? this[int idx] + { + get + { + Debug.Assert(idx is >= CutsceneStartIdx and < CutsceneEndIdx); + idx = _copiedCharacters[idx - CutsceneStartIdx]; + return idx < 0 ? null : _objects.GetDalamudObject(idx); + } + } + + /// Return the currently set index of a parent or -1 if none is set or the index is invalid. + public int GetParentIndex(int idx) + => GetParentIndex((ushort)idx); + + public bool SetParentIndex(int copyIdx, int parentIdx) + { + if (copyIdx is < CutsceneStartIdx or >= CutsceneEndIdx) + return false; + + if (parentIdx is < -1 or >= CutsceneEndIdx) + return false; + + if (!_objects[copyIdx].Valid) + return false; + + if (parentIdx != -1 && !_objects[parentIdx].Valid) + return false; + + _copiedCharacters[copyIdx - CutsceneStartIdx] = (short)parentIdx; + _objects.InvokeRequiredUpdates(); + return true; + } + + public short GetParentIndex(ushort idx) + { + if (idx is >= CutsceneStartIdx and < CutsceneEndIdx) + return _copiedCharacters[idx - CutsceneStartIdx]; + + return -1; + } + + public unsafe void Dispose() + { + _copyCharacter.Unsubscribe(OnCharacterCopy); + _characterDestructor.Unsubscribe(OnCharacterDestructor); + _constructCutsceneCharacter.Unsubscribe(OnSetupPlayerNpc); + } + + private unsafe void OnCharacterDestructor(Character* character) + { + if (character->GameObject.ObjectIndex < CutsceneStartIdx) + { + // Remove all associations for now non-existing actor. + for (var i = 0; i < _copiedCharacters.Length; ++i) + { + if (_copiedCharacters[i] == character->GameObject.ObjectIndex) + { + // A hack to deal with GPose actors leaving and thus losing the link, we just set the home world instead. + // I do not think this breaks anything? + var address = _objects[i + CutsceneStartIdx]; + if (address.IsPlayer) + address.AsCharacter->HomeWorld = character->HomeWorld; + + _copiedCharacters[i] = -1; + } + } + } + else if (character->GameObject.ObjectIndex < CutsceneEndIdx) + { + 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); + } + + private unsafe void OnSetupPlayerNpc(Character* npc) + { + if (npc == null || npc->ObjectIndex is < CutsceneStartIdx or >= CutsceneEndIdx) + return; + + var idx = npc->GameObject.ObjectIndex - CutsceneStartIdx; + _copiedCharacters[idx] = 0; + } + + /// Try to recover GPose actors on reloads into a running game. + /// This is not 100% accurate due to world IDs, minions etc., but will be mostly sane. + private void RecoverGPoseActors() + { + Dictionary? actors = null; + + for (var i = CutsceneStartIdx; i < CutsceneEndIdx; ++i) + { + if (!TryGetName(i, out var name)) + continue; + + if ((actors ??= CreateActors()).TryGetValue(name, out var idx)) + _copiedCharacters[i - CutsceneStartIdx] = idx; + } + + return; + + bool TryGetName(int idx, out ByteString name) + { + name = ByteString.Empty; + var address = _objects[idx]; + if (!address.Valid) + return false; + + name = address.Utf8Name; + return !name.IsEmpty; + } + + Dictionary CreateActors() + { + var ret = new Dictionary(); + for (short i = 0; i < CutsceneStartIdx; ++i) + { + if (TryGetName(i, out var name)) + ret.TryAdd(name, i); + } + + return ret; + } + } +} diff --git a/Penumbra/Interop/PathResolving/DrawObjectState.cs b/Penumbra/Interop/PathResolving/DrawObjectState.cs new file mode 100644 index 00000000..6f3e457c --- /dev/null +++ b/Penumbra/Interop/PathResolving/DrawObjectState.cs @@ -0,0 +1,176 @@ +using Dalamud.Plugin.Services; +using FFXIVClientStructs.FFXIV.Client.Game.Character; +using FFXIVClientStructs.FFXIV.Client.Graphics.Scene; +using OtterGui.Services; +using Penumbra.GameData.Interop; +using Object = FFXIVClientStructs.FFXIV.Client.Graphics.Scene.Object; +using Penumbra.GameData.Structs; +using Penumbra.Interop.Hooks.Objects; + +namespace Penumbra.Interop.PathResolving; + +public sealed class DrawObjectState : IDisposable, IReadOnlyDictionary, IService +{ + private readonly ObjectManager _objects; + private readonly CreateCharacterBase _createCharacterBase; + private readonly WeaponReload _weaponReload; + private readonly CharacterBaseDestructor _characterBaseDestructor; + private readonly CharacterDestructor _characterDestructor; + private readonly GameState _gameState; + + private readonly Dictionary _drawObjectToGameObject = []; + + public nint LastGameObject + => _gameState.LastGameObject; + + public unsafe DrawObjectState(ObjectManager objects, CreateCharacterBase createCharacterBase, WeaponReload weaponReload, + CharacterBaseDestructor characterBaseDestructor, GameState gameState, IFramework framework, CharacterDestructor characterDestructor) + { + _objects = objects; + _createCharacterBase = createCharacterBase; + _weaponReload = weaponReload; + _characterBaseDestructor = characterBaseDestructor; + _gameState = gameState; + _characterDestructor = characterDestructor; + framework.RunOnFrameworkThread(InitializeDrawObjects); + + _weaponReload.Subscribe(OnWeaponReloading, WeaponReload.Priority.DrawObjectState); + _weaponReload.Subscribe(OnWeaponReloaded, WeaponReload.PostEvent.Priority.DrawObjectState); + _createCharacterBase.Subscribe(OnCharacterBaseCreated, CreateCharacterBase.PostEvent.Priority.DrawObjectState); + _characterBaseDestructor.Subscribe(OnCharacterBaseDestructor, CharacterBaseDestructor.Priority.DrawObjectState); + _characterDestructor.Subscribe(OnCharacterDestructor, CharacterDestructor.Priority.DrawObjectState); + } + + public bool ContainsKey(Model key) + => _drawObjectToGameObject.ContainsKey(key); + + public IEnumerator> GetEnumerator() + => _drawObjectToGameObject.GetEnumerator(); + + IEnumerator IEnumerable.GetEnumerator() + => GetEnumerator(); + + public int Count + => _drawObjectToGameObject.Count; + + public bool TryGetValue(Model drawObject, out (Actor, ObjectIndex, bool) gameObject) + { + if (!_drawObjectToGameObject.TryGetValue(drawObject, out gameObject)) + return false; + + var currentObject = _objects[gameObject.Item2]; + if (currentObject != gameObject.Item1) + { + Penumbra.Log.Warning($"[DrawObjectState] Stored association {drawObject} -> {gameObject.Item1} has index {gameObject.Item2}, which resolves to {currentObject}."); + return false; + } + + return true; + } + + public (Actor, ObjectIndex, bool) this[Model key] + => _drawObjectToGameObject[key]; + + public IEnumerable Keys + => _drawObjectToGameObject.Keys; + + public IEnumerable<(Actor, ObjectIndex, bool)> Values + => _drawObjectToGameObject.Values; + + public unsafe void Dispose() + { + _weaponReload.Unsubscribe(OnWeaponReloading); + _weaponReload.Unsubscribe(OnWeaponReloaded); + _createCharacterBase.Unsubscribe(OnCharacterBaseCreated); + _characterBaseDestructor.Unsubscribe(OnCharacterBaseDestructor); + _characterDestructor.Unsubscribe(OnCharacterDestructor); + } + + /// + /// Seems like sometimes the draw object of a game object is destroyed in frames after the original game object is already destroyed. + /// So protect against outdated game object pointers in the dictionary. + /// + private unsafe void OnCharacterDestructor(Character* a) + { + if (a is null) + return; + + var character = (nint)a; + var delete = stackalloc nint[5]; + var current = 0; + foreach (var (drawObject, (gameObject, _, _)) in _drawObjectToGameObject) + { + if (gameObject != character) + continue; + + delete[current++] = drawObject; + if (current is 4) + break; + } + + for (var ptr = delete; *ptr != nint.Zero; ++ptr) + { + _drawObjectToGameObject.Remove(*ptr, out var pair); + Penumbra.Log.Excessive( + $"[DrawObjectState] Removed draw object 0x{*ptr:X} -> 0x{(nint)a:X} (actual: 0x{pair.GameObject.Address:X}, {pair.IsChild})."); + } + } + + private unsafe void OnWeaponReloading(DrawDataContainer* _, Character* character, CharacterWeapon* _2) + => _gameState.QueueGameObject((nint)character); + + private unsafe void OnWeaponReloaded(DrawDataContainer* _, Character* character) + { + _gameState.DequeueGameObject(); + IterateDrawObjectTree((Object*)character->GameObject.DrawObject, (nint)character, false, false); + } + + private unsafe void OnCharacterBaseDestructor(CharacterBase* characterBase) + => _drawObjectToGameObject.Remove((nint)characterBase); + + private unsafe void OnCharacterBaseCreated(ModelCharaId modelCharaId, CustomizeArray* customize, CharacterArmor* equipment, + CharacterBase* drawObject) + { + Actor gameObject = LastGameObject; + if (gameObject.Valid) + _drawObjectToGameObject[(nint)drawObject] = (gameObject, gameObject.Index, false); + } + + /// + /// Find all current DrawObjects used in the GameObject table. + /// We do not iterate the Dalamud table because it does not work when not logged in. + /// + private unsafe void InitializeDrawObjects() + { + foreach (var actor in _objects) + { + if (actor is { IsCharacter: true, Model.Valid: true }) + IterateDrawObjectTree((Object*)actor.Model.Address, actor, false, false); + } + } + + private unsafe void IterateDrawObjectTree(Object* drawObject, Actor gameObject, bool isChild, bool iterate) + { + if (drawObject == null) + return; + + _drawObjectToGameObject[drawObject] = (gameObject, gameObject.Index, isChild); + IterateDrawObjectTree(drawObject->ChildObject, gameObject, true, true); + if (!iterate) + return; + + var nextSibling = drawObject->NextSiblingObject; + while (nextSibling != null && nextSibling != drawObject) + { + IterateDrawObjectTree(nextSibling, gameObject, true, false); + nextSibling = nextSibling->NextSiblingObject; + } + + var prevSibling = drawObject->PreviousSiblingObject; + while (prevSibling != null && prevSibling != drawObject) + { + IterateDrawObjectTree(prevSibling, gameObject, true, false); + prevSibling = prevSibling->PreviousSiblingObject; + } + } +} diff --git a/Penumbra/Interop/PathResolving/IdentifiedCollectionCache.cs b/Penumbra/Interop/PathResolving/IdentifiedCollectionCache.cs new file mode 100644 index 00000000..eeff7eee --- /dev/null +++ b/Penumbra/Interop/PathResolving/IdentifiedCollectionCache.cs @@ -0,0 +1,95 @@ +using Dalamud.Plugin.Services; +using FFXIVClientStructs.FFXIV.Client.Game.Character; +using FFXIVClientStructs.FFXIV.Client.Game.Object; +using OtterGui.Services; +using Penumbra.Collections; +using Penumbra.Collections.Manager; +using Penumbra.Communication; +using Penumbra.GameData.Actors; +using Penumbra.Interop.Hooks.Objects; +using Penumbra.Services; + +namespace Penumbra.Interop.PathResolving; + +public unsafe class IdentifiedCollectionCache : IDisposable, IEnumerable<(nint Address, ActorIdentifier Identifier, ModCollection Collection)>, + IService +{ + private readonly CommunicatorService _communicator; + private readonly CharacterDestructor _characterDestructor; + private readonly IClientState _clientState; + private readonly Dictionary _cache = new(317); + private bool _dirty; + + public IdentifiedCollectionCache(IClientState clientState, CommunicatorService communicator, CharacterDestructor characterDestructor) + { + _clientState = clientState; + _communicator = communicator; + _characterDestructor = characterDestructor; + + _communicator.CollectionChange.Subscribe(CollectionChangeClear, CollectionChange.Priority.IdentifiedCollectionCache); + _clientState.TerritoryChanged += TerritoryClear; + _characterDestructor.Subscribe(OnCharacterDestructor, CharacterDestructor.Priority.IdentifiedCollectionCache); + } + + public ResolveData Set(ModCollection collection, ActorIdentifier identifier, GameObject* data) + { + if (_dirty) + { + _dirty = false; + _cache.Clear(); + } + + _cache[(nint)data] = (identifier, collection); + return collection.ToResolveData(data); + } + + public bool TryGetValue(GameObject* gameObject, out ResolveData resolve) + { + if (_dirty) + { + _dirty = false; + _cache.Clear(); + } + else if (_cache.TryGetValue((nint)gameObject, out var p)) + { + resolve = p.Item2.ToResolveData(gameObject); + return true; + } + + resolve = default; + return false; + } + + public void Dispose() + { + _communicator.CollectionChange.Unsubscribe(CollectionChangeClear); + _clientState.TerritoryChanged -= TerritoryClear; + _characterDestructor.Unsubscribe(OnCharacterDestructor); + } + + public IEnumerator<(nint Address, ActorIdentifier Identifier, ModCollection Collection)> GetEnumerator() + { + foreach (var (address, (identifier, collection)) in _cache) + { + if (_dirty) + yield break; + + yield return (address, identifier, collection); + } + } + + IEnumerator IEnumerable.GetEnumerator() + => GetEnumerator(); + + private void CollectionChangeClear(CollectionType type, ModCollection? _1, ModCollection? _2, string _3) + { + if (type is not (CollectionType.Current or CollectionType.Interface or CollectionType.Inactive)) + _dirty = _cache.Count > 0; + } + + private void TerritoryClear(ushort _2) + => _dirty = _cache.Count > 0; + + private void OnCharacterDestructor(Character* character) + => _cache.Remove((nint)character); +} diff --git a/Penumbra/Interop/PathResolving/MetaState.cs b/Penumbra/Interop/PathResolving/MetaState.cs new file mode 100644 index 00000000..eeae77cc --- /dev/null +++ b/Penumbra/Interop/PathResolving/MetaState.cs @@ -0,0 +1,127 @@ +using FFXIVClientStructs.FFXIV.Client.Graphics.Scene; +using OtterGui.Classes; +using OtterGui.Services; +using Penumbra.Collections; +using Penumbra.Api.Enums; +using Penumbra.GameData.Structs; +using Penumbra.Interop.Services; +using Penumbra.Services; +using Penumbra.String.Classes; +using CharacterUtility = Penumbra.Interop.Services.CharacterUtility; +using Penumbra.Interop.Hooks.Objects; +using Penumbra.Interop.Hooks.ResourceLoading; + +namespace Penumbra.Interop.PathResolving; + +// State: 6.35 +// GetSlotEqpData seems to be the only function using the EQP table. +// It is only called by CheckSlotsForUnload (called by UpdateModels), +// SetupModelAttributes (called by UpdateModels and OnModelLoadComplete) +// and an unnamed function called by UpdateRender. +// It seems to be enough to change the EQP entries for UpdateModels. + +// GetEqdpDataFor[Adults|Children|Other] seem to be the only functions using the EQDP tables. +// They are called by ResolveMdlPath, UpdateModels and SetupConnectorModelAttributes, +// which is called by SetupModelAttributes, which is called by OnModelLoadComplete and UpdateModels. +// It seems to be enough to change EQDP on UpdateModels and ResolveMDLPath. + +// EST entries seem to be obtained by "44 8B C9 83 EA ?? 74", which is only called by +// ResolveSKLBPath, ResolveSKPPath, ResolvePHYBPath and indirectly by ResolvePAPPath. + +// RSP height entries seem to be obtained by "E8 ?? ?? ?? ?? 48 8B 8E ?? ?? ?? ?? 44 8B CF" +// RSP tail entries seem to be obtained by "E8 ?? ?? ?? ?? 0F 28 F0 48 8B 05" +// RSP bust size entries seem to be obtained by "E8 ?? ?? ?? ?? F2 0F 10 44 24 ?? 8B 44 24 ?? F2 0F 11 45 ?? 89 45 ?? 83 FF" +// they all are called by many functions, but the most relevant seem to be Human.SetupFromCharacterData, which is only called by CharacterBase.Create, +// ChangeCustomize and RspSetupCharacter, which is hooked here, as well as Character.CalculateHeight. + +// GMP Entries seem to be only used by "48 8B ?? 53 55 57 48 83 ?? ?? 48 8B", which is SetupVisor. +public sealed unsafe class MetaState : IDisposable, IService +{ + public readonly Configuration Config; + private readonly CommunicatorService _communicator; + private readonly CollectionResolver _collectionResolver; + private readonly ResourceLoader _resources; + private readonly CharacterUtility _characterUtility; + private readonly CreateCharacterBase _createCharacterBase; + + public ResolveData CustomizeChangeCollection = ResolveData.Invalid; + public readonly Stack EqpCollection = []; + public readonly Stack EqdpCollection = []; + public readonly Stack EstCollection = []; + public readonly Stack RspCollection = []; + public readonly Stack AtchCollection = []; + + public readonly Stack<(ResolveData Collection, PrimaryId Id)> GmpCollection = []; + + + private ResolveData _lastCreatedCollection = ResolveData.Invalid; + private DisposableContainer _characterBaseCreateMetaChanges = DisposableContainer.Empty; + + public MetaState(CommunicatorService communicator, CollectionResolver collectionResolver, + ResourceLoader resources, CreateCharacterBase createCharacterBase, CharacterUtility characterUtility, Configuration config) + { + _communicator = communicator; + _collectionResolver = collectionResolver; + _resources = resources; + _createCharacterBase = createCharacterBase; + _characterUtility = characterUtility; + Config = config; + _createCharacterBase.Subscribe(OnCreatingCharacterBase, CreateCharacterBase.Priority.MetaState); + _createCharacterBase.Subscribe(OnCharacterBaseCreated, CreateCharacterBase.PostEvent.Priority.MetaState); + } + + public bool HandleDecalFile(ResourceType type, Utf8GamePath gamePath, out ResolveData resolveData) + { + if (type == ResourceType.Tex + && (_lastCreatedCollection.Valid || CustomizeChangeCollection.Valid) + && gamePath.Path.Substring("chara/common/texture/".Length).StartsWith("decal"u8)) + { + resolveData = _lastCreatedCollection.Valid ? _lastCreatedCollection : CustomizeChangeCollection; + return true; + } + + resolveData = ResolveData.Invalid; + return false; + } + + public DecalReverter ResolveDecal(ResolveData resolve, bool which) + => new(Config, _characterUtility, _resources, resolve, which); + + public void Dispose() + { + _createCharacterBase.Unsubscribe(OnCreatingCharacterBase); + _createCharacterBase.Unsubscribe(OnCharacterBaseCreated); + } + + private void OnCreatingCharacterBase(ModelCharaId* modelCharaId, CustomizeArray* customize, CharacterArmor* equipData) + { + _lastCreatedCollection = _collectionResolver.IdentifyLastGameObjectCollection(true); + if (_lastCreatedCollection.Valid && _lastCreatedCollection.AssociatedGameObject != nint.Zero) + _communicator.CreatingCharacterBase.Invoke(_lastCreatedCollection.AssociatedGameObject, + _lastCreatedCollection.ModCollection.Identity.Id, (nint)modelCharaId, (nint)customize, (nint)equipData); + + var decal = new DecalReverter(Config, _characterUtility, _resources, _lastCreatedCollection, + UsesDecal(*(uint*)modelCharaId, (nint)customize)); + RspCollection.Push(_lastCreatedCollection); + _characterBaseCreateMetaChanges.Dispose(); // Should always be empty. + _characterBaseCreateMetaChanges = new DisposableContainer(decal); + } + + private void OnCharacterBaseCreated(ModelCharaId _1, CustomizeArray* _2, CharacterArmor* _3, CharacterBase* drawObject) + { + _characterBaseCreateMetaChanges.Dispose(); + _characterBaseCreateMetaChanges = DisposableContainer.Empty; + if (_lastCreatedCollection.Valid && _lastCreatedCollection.AssociatedGameObject != nint.Zero && drawObject != null) + _communicator.CreatedCharacterBase.Invoke(_lastCreatedCollection.AssociatedGameObject, + _lastCreatedCollection.ModCollection, (nint)drawObject); + RspCollection.Pop(); + _lastCreatedCollection = ResolveData.Invalid; + } + + /// + /// Check the customize array for the FaceCustomization byte and the last bit of that. + /// Also check for humans. + /// + private static bool UsesDecal(uint modelId, nint customizeData) + => modelId == 0 && ((byte*)customizeData)[12] > 0x7F; +} diff --git a/Penumbra/Interop/PathResolving/PathDataHandler.cs b/Penumbra/Interop/PathResolving/PathDataHandler.cs new file mode 100644 index 00000000..e0c235a2 --- /dev/null +++ b/Penumbra/Interop/PathResolving/PathDataHandler.cs @@ -0,0 +1,167 @@ +using Penumbra.Collections; +using Penumbra.Collections.Manager; +using Penumbra.String; +using Penumbra.String.Classes; + +namespace Penumbra.Interop.PathResolving; + +public static class PathDataHandler +{ + public static readonly ushort Discriminator = (ushort)(Environment.TickCount >> 12); + private static readonly string DiscriminatorString = $"{Discriminator:X4}"; + private const int MinimumLength = 8; + + /// Additional Data encoded in a path. + /// The local ID of the collection. + /// The change counter of that collection when this file was loaded. + /// The CRC32 of the originally requested path, only used for materials. + /// A discriminator to differ between multiple loads of Penumbra. + public readonly record struct AdditionalPathData( + LocalCollectionId Collection, + int ChangeCounter, + int OriginalPathCrc32, + ushort Discriminator) + { + public static readonly AdditionalPathData Invalid = new(LocalCollectionId.Zero, 0, 0, PathDataHandler.Discriminator); + + /// Any collection but the empty collection can appear. In particular, they can be negative for temporary collections. + public bool Valid + => Collection.Id != 0; + } + + /// Create the encoding path for an IMC file. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static FullPath CreateImc(CiByteString path, ModCollection collection) + => new($"|{collection.Identity.LocalId.Id}_{collection.Counters.Imc}_{DiscriminatorString}|{path}"); + + /// Create the encoding path for a TMB file. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static FullPath CreateTmb(CiByteString path, ModCollection collection) + => CreateBase(path, collection); + + /// Create the encoding path for an AVFX file. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static FullPath CreateAvfx(CiByteString path, ModCollection collection) + => CreateBase(path, collection); + + /// Create the encoding path for an ATCH file. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static FullPath CreateAtch(CiByteString path, ModCollection collection) + => new($"|{collection.Identity.LocalId.Id}_{collection.Counters.Atch}_{DiscriminatorString}|{path}"); + + /// Create the encoding path for a MTRL file. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static FullPath CreateMtrl(CiByteString path, ModCollection collection, Utf8GamePath originalPath) + => new($"|{collection.Identity.LocalId.Id}_{collection.Counters.Change}_{originalPath.Path.Crc32:X8}_{DiscriminatorString}|{path}"); + + /// The base function shared by most file types. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static FullPath CreateBase(CiByteString path, ModCollection collection) + => new($"|{collection.Identity.LocalId.Id}_{collection.Counters.Change}_{DiscriminatorString}|{path}"); + + /// Read an additional data blurb and parse it into usable data for all file types but Materials. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool Read(ReadOnlySpan additionalData, out AdditionalPathData data) + => ReadBase(additionalData, out data, out _); + + /// Read an additional data blurb and parse it into usable data for Materials. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool ReadMtrl(ReadOnlySpan additionalData, out AdditionalPathData data) + { + if (!ReadBase(additionalData, out data, out var remaining)) + return false; + + if (!int.TryParse(remaining, out var crc32)) + return false; + + data = data with { OriginalPathCrc32 = crc32 }; + return true; + } + + /// Parse the common attributes of an additional data blurb and return remaining data if there is any. + private static bool ReadBase(ReadOnlySpan additionalData, out AdditionalPathData data, out ReadOnlySpan remainingData) + { + data = AdditionalPathData.Invalid; + remainingData = []; + + // At least (\d_\d_\x\x\x\x) + if (additionalData.Length < MinimumLength) + return false; + + // Fetch discriminator, constant length. + var discriminatorSpan = additionalData[^4..]; + if (!ushort.TryParse(discriminatorSpan, NumberStyles.HexNumber, CultureInfo.CurrentCulture, out var discriminator)) + return false; + + additionalData = additionalData[..^5]; + var collectionSplit = additionalData.IndexOf((byte)'_'); + if (collectionSplit == -1) + return false; + + var collectionSpan = additionalData[..collectionSplit]; + additionalData = additionalData[(collectionSplit + 1)..]; + + if (!int.TryParse(collectionSpan, out var id)) + return false; + + var changeCounterSpan = additionalData; + var changeCounterSplit = additionalData.IndexOf((byte)'_'); + if (changeCounterSplit != -1) + { + changeCounterSpan = additionalData[..changeCounterSplit]; + remainingData = additionalData[(changeCounterSplit + 1)..]; + } + + if (!int.TryParse(changeCounterSpan, out var changeCounter)) + return false; + + data = new AdditionalPathData(new LocalCollectionId(id), changeCounter, 0, discriminator); + return true; + } + + /// Split a given span into the actual path and the additional data blurb. Returns true if a blurb exists. + public static bool Split(ReadOnlySpan text, out ReadOnlySpan path, out ReadOnlySpan data) + { + if (text.IsEmpty || text[0] is not (byte)'|') + { + path = text; + data = []; + return false; + } + + var endIdx = text[1..].IndexOf((byte)'|'); + if (endIdx++ < 0) + { + path = text; + data = []; + return false; + } + + data = text.Slice(1, endIdx - 1); + path = ++endIdx == text.Length ? [] : text[endIdx..]; + return true; + } + + /// + public static bool Split(ReadOnlySpan text, out ReadOnlySpan path, out ReadOnlySpan data) + { + if (text.Length == 0 || text[0] is not '|') + { + path = text; + data = []; + return false; + } + + var endIdx = text[1..].IndexOf('|'); + if (endIdx++ < 0) + { + path = text; + data = []; + return false; + } + + data = text.Slice(1, endIdx - 1); + path = ++endIdx >= text.Length ? [] : text[endIdx..]; + return true; + } +} diff --git a/Penumbra/Interop/PathResolving/PathResolver.cs b/Penumbra/Interop/PathResolving/PathResolver.cs new file mode 100644 index 00000000..ec421304 --- /dev/null +++ b/Penumbra/Interop/PathResolving/PathResolver.cs @@ -0,0 +1,149 @@ +using FFXIVClientStructs.FFXIV.Client.System.Resource; +using OtterGui.Services; +using Penumbra.Api.Enums; +using Penumbra.Collections; +using Penumbra.Collections.Manager; +using Penumbra.Interop.Hooks.ResourceLoading; +using Penumbra.Interop.Processing; +using Penumbra.String.Classes; +using Penumbra.Util; + +namespace Penumbra.Interop.PathResolving; + +public class PathResolver : IDisposable, IService +{ + private readonly PerformanceTracker _performance; + private readonly Configuration _config; + private readonly CollectionManager _collectionManager; + private readonly ResourceLoader _loader; + + private readonly SubfileHelper _subfileHelper; + private readonly PathState _pathState; + private readonly MetaState _metaState; + private readonly GameState _gameState; + private readonly CollectionResolver _collectionResolver; + private readonly GamePathPreProcessService _preprocessor; + + public PathResolver(PerformanceTracker performance, Configuration config, CollectionManager collectionManager, ResourceLoader loader, + SubfileHelper subfileHelper, PathState pathState, MetaState metaState, CollectionResolver collectionResolver, GameState gameState, + GamePathPreProcessService preprocessor) + { + _performance = performance; + _config = config; + _collectionManager = collectionManager; + _subfileHelper = subfileHelper; + _pathState = pathState; + _metaState = metaState; + _gameState = gameState; + _preprocessor = preprocessor; + _collectionResolver = collectionResolver; + _loader = loader; + _loader.ResolvePath = ResolvePath; + } + + /// Try to resolve the given game path to the replaced path. + public (FullPath?, ResolveData) ResolvePath(Utf8GamePath path, ResourceCategory category, ResourceType resourceType) + { + // Check if mods are enabled or if we are in a inc-ref at 0 reference count situation. + if (!_config.EnableMods) + return (null, ResolveData.Invalid); + + return resourceType switch + { + // Do not allow manipulating layers to prevent very obvious cheating and softlocks. + ResourceType.Lvb or ResourceType.Lgb or ResourceType.Sgb => (null, ResolveData.Invalid), + // Prevent .atch loading to prevent crashes on outdated .atch files. + ResourceType.Atch => ResolveAtch(path), + // These are manipulated through Meta Edits instead. + ResourceType.Eqp or ResourceType.Eqdp or ResourceType.Est or ResourceType.Gmp or ResourceType.Cmp => (null, ResolveData.Invalid), + + _ => category switch + { + // Only Interface collection. + ResourceCategory.Ui => ResolveUi(path), + // Never allow changing scripts. + ResourceCategory.UiScript => (null, ResolveData.Invalid), + ResourceCategory.GameScript => (null, ResolveData.Invalid), + // Use actual resolving. + ResourceCategory.Chara => Resolve(path, resourceType), + ResourceCategory.Shader => ResolveShader(path, resourceType), + ResourceCategory.Vfx => Resolve(path, resourceType), + ResourceCategory.Sound => Resolve(path, resourceType), + // EXD Modding in general should probably be prohibited but is currently used for fan translations. + // We prevent WebURL specifically because it technically allows launching arbitrary programs / to execute arbitrary code. + ResourceCategory.Exd => path.Path.StartsWith("exd/weburl"u8) ? (null, ResolveData.Invalid) : DefaultResolver(path), + // None of these files are ever associated with specific characters, + // always use the default resolver for now, + // except that common/font is conceptually more UI. + ResourceCategory.Common => path.Path.StartsWith("common/font"u8) ? ResolveUi(path) : DefaultResolver(path), + ResourceCategory.BgCommon => DefaultResolver(path), + ResourceCategory.Bg => DefaultResolver(path), + ResourceCategory.Cut => DefaultResolver(path), + ResourceCategory.Music => DefaultResolver(path), + _ => DefaultResolver(path), + } + }; + } + + /// Replacing the characterstockings.shpk or the characterocclusion.shpk files currently causes crashes, so we just entirely prevent that. + private (FullPath?, ResolveData) ResolveShader(Utf8GamePath gamePath, ResourceType type) + { + if (type is not ResourceType.Shpk) + return Resolve(gamePath, type); + + if (gamePath.Path.EndsWith("occlusion.shpk"u8) + || gamePath.Path.EndsWith("stockings.shpk"u8)) + return (null, ResolveData.Invalid); + + return Resolve(gamePath, type); + } + + public (FullPath?, ResolveData) Resolve(Utf8GamePath gamePath, ResourceType type) + { + using var performance = _performance.Measure(PerformanceType.CharacterResolver); + // Check if the path was marked for a specific collection, + // or if it is a file loaded by a material, and if we are currently in a material load, + // or if it is a face decal path and the current mod collection is set. + // If not use the default collection. + // We can remove paths after they have actually been loaded. + // A potential next request will add the path anew. + var nonDefault = _subfileHelper.HandleSubFiles(type, out var resolveData) + || _pathState.Consume(gamePath.Path, out resolveData) + || _gameState.HandleFiles(_collectionResolver, type, gamePath, out resolveData) + || _metaState.HandleDecalFile(type, gamePath, out resolveData); + if (!nonDefault || !resolveData.Valid) + resolveData = _collectionManager.Active.Default.ToResolveData(); + + // Resolve using character/default collection first, otherwise forced, as usual. + var resolved = resolveData.ModCollection.ResolvePath(gamePath); + + // Since mtrl files load their files separately, we need to add the new, resolved path + // so that the functions loading tex and shpk can find that path and use its collection. + // We also need to handle defaulted materials against a non-default collection. + var path = resolved == null ? gamePath.Path : resolved.Value.InternalName; + return _preprocessor.PreProcess(resolveData, path, nonDefault, type, resolved, gamePath); + } + + public void Dispose() + { + _loader.ResetResolvePath(); + } + + /// Use the default method of path replacement. + private (FullPath?, ResolveData) DefaultResolver(Utf8GamePath path) + { + var resolved = _collectionManager.Active.Default.ResolvePath(path); + return (resolved, _collectionManager.Active.Default.ToResolveData()); + } + + /// Resolve a path from the interface collection. + private (FullPath?, ResolveData) ResolveUi(Utf8GamePath path) + => (_collectionManager.Active.Interface.ResolvePath(path), + _collectionManager.Active.Interface.ToResolveData()); + + public (FullPath?, ResolveData) ResolveAtch(Utf8GamePath gamePath) + { + _metaState.AtchCollection.TryPeek(out var resolveData); + return _preprocessor.PreProcess(resolveData, gamePath.Path, false, ResourceType.Atch, null, gamePath); + } +} diff --git a/Penumbra/Interop/PathResolving/PathState.cs b/Penumbra/Interop/PathResolving/PathState.cs new file mode 100644 index 00000000..60a61408 --- /dev/null +++ b/Penumbra/Interop/PathResolving/PathState.cs @@ -0,0 +1,92 @@ +using OtterGui.Services; +using Penumbra.Collections; +using Penumbra.Interop.Services; +using Penumbra.String; + +namespace Penumbra.Interop.PathResolving; + +public sealed class PathState(CollectionResolver collectionResolver, MetaState metaState, CharacterUtility characterUtility) + : IDisposable, IService +{ + public readonly CollectionResolver CollectionResolver = collectionResolver; + public readonly MetaState MetaState = metaState; + public readonly CharacterUtility CharacterUtility = characterUtility; + + private readonly ThreadLocal _resolveData = new(() => ResolveData.Invalid, true); + private readonly ThreadLocal _internalResolve = new(() => 0, false); + + public IList CurrentData + => _resolveData.Values; + + public bool InInternalResolve + => _internalResolve.Value != 0u; + + + public void Dispose() + { + _resolveData.Dispose(); + _internalResolve.Dispose(); + } + + public bool Consume(CiByteString _, out ResolveData collection) + { + if (_resolveData.IsValueCreated) + { + collection = _resolveData.Value; + _resolveData.Value = ResolveData.Invalid; + return collection.Valid; + } + + collection = ResolveData.Invalid; + return false; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] + public nint ResolvePath(nint gameObject, ModCollection collection, nint path) + { + if (path == nint.Zero) + return path; + + if (!InInternalResolve) + _resolveData.Value = collection.ToResolveData(gameObject); + return path; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] + public nint ResolvePath(ResolveData data, nint path) + { + if (path == nint.Zero) + return path; + + if (!InInternalResolve) + _resolveData.Value = data; + return path; + } + + /// + /// Temporarily disables metadata mod application and resolve data capture on the current thread. + /// Must be called to prevent race conditions between Penumbra's internal path resolution (for example for Resource Trees) and the game's path resolution. + /// Please note that this will make path resolution cases that depend on metadata incorrect. + /// + /// A struct that will undo this operation when disposed. Best used with: using (var _ = pathState.EnterInternalResolve()) { ... } + [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] + public InternalResolveRaii EnterInternalResolve() + => new(this); + + public readonly ref struct InternalResolveRaii + { + private readonly ThreadLocal _internalResolve; + + public InternalResolveRaii(PathState parent) + { + _internalResolve = parent._internalResolve; + ++_internalResolve.Value; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] + public void Dispose() + { + --_internalResolve.Value; + } + } +} diff --git a/Penumbra/Interop/PathResolving/SubfileHelper.cs b/Penumbra/Interop/PathResolving/SubfileHelper.cs new file mode 100644 index 00000000..836cf731 --- /dev/null +++ b/Penumbra/Interop/PathResolving/SubfileHelper.cs @@ -0,0 +1,91 @@ +using OtterGui.Services; +using Penumbra.Api.Enums; +using Penumbra.Collections; +using Penumbra.Interop.Hooks.ResourceLoading; +using Penumbra.Interop.Hooks.Resources; +using Penumbra.Interop.Structs; +using Penumbra.String.Classes; + +namespace Penumbra.Interop.PathResolving; + +/// +/// Materials and avfx do contain their own paths to textures and shader packages or atex respectively. +/// Those are loaded synchronously. +/// Thus, we need to ensure the correct files are loaded when a material is loaded. +/// +public sealed unsafe class SubfileHelper : IDisposable, IReadOnlyCollection>, IService +{ + private readonly GameState _gameState; + private readonly ResourceLoader _loader; + private readonly ResourceHandleDestructor _resourceHandleDestructor; + + public SubfileHelper(GameState gameState, ResourceLoader loader, ResourceHandleDestructor resourceHandleDestructor) + { + _gameState = gameState; + _loader = loader; + _resourceHandleDestructor = resourceHandleDestructor; + + _loader.ResourceLoaded += SubfileContainerRequested; + _resourceHandleDestructor.Subscribe(ResourceDestroyed, ResourceHandleDestructor.Priority.SubfileHelper); + } + + + public IEnumerator> GetEnumerator() + => _gameState.SubFileCollection.GetEnumerator(); + + IEnumerator IEnumerable.GetEnumerator() + => GetEnumerator(); + + public int Count + => _gameState.SubFileCollection.Count; + + public ResolveData MtrlData + => _gameState.MtrlData.IsValueCreated ? _gameState.MtrlData.Value : ResolveData.Invalid; + + public ResolveData AvfxData + => _gameState.AvfxData.IsValueCreated ? _gameState.AvfxData.Value : ResolveData.Invalid; + + /// + /// Check specifically for shpk and tex files whether we are currently in a material load, + /// and for scd and atex files whether we are in an avfx load. + public bool HandleSubFiles(ResourceType type, out ResolveData collection) + { + switch (type) + { + case ResourceType.Tex when _gameState.MtrlData.Value.Valid: + case ResourceType.Shpk when _gameState.MtrlData.Value.Valid: + collection = _gameState.MtrlData.Value; + return true; + case ResourceType.Scd when _gameState.AvfxData.Value.Valid: + case ResourceType.Atex when _gameState.AvfxData.Value.Valid: + collection = _gameState.AvfxData.Value; + return true; + } + + collection = ResolveData.Invalid; + return false; + } + + public void Dispose() + { + _loader.ResourceLoaded -= SubfileContainerRequested; + _resourceHandleDestructor.Unsubscribe(ResourceDestroyed); + } + + private void SubfileContainerRequested(ResourceHandle* handle, Utf8GamePath originalPath, FullPath? manipulatedPath, + ResolveData resolveData) + { + switch (handle->FileType) + { + case ResourceType.Mtrl: + case ResourceType.Avfx: + if (handle->FileSize == 0) + _gameState.SubFileCollection[(nint)handle] = resolveData; + + break; + } + } + + private void ResourceDestroyed(ResourceHandle* handle) + => _gameState.SubFileCollection.TryRemove((nint)handle, out _); +} diff --git a/Penumbra/Interop/ProcessThreadApi.cs b/Penumbra/Interop/ProcessThreadApi.cs new file mode 100644 index 00000000..5ee213d9 --- /dev/null +++ b/Penumbra/Interop/ProcessThreadApi.cs @@ -0,0 +1,7 @@ +namespace Penumbra.Interop; + +public static partial class ProcessThreadApi +{ + [LibraryImport("kernel32.dll")] + public static partial uint GetCurrentThreadId(); +} diff --git a/Penumbra/Interop/Processing/AtchFilePostProcessor.cs b/Penumbra/Interop/Processing/AtchFilePostProcessor.cs new file mode 100644 index 00000000..e4fab022 --- /dev/null +++ b/Penumbra/Interop/Processing/AtchFilePostProcessor.cs @@ -0,0 +1,43 @@ +using Penumbra.Api.Enums; +using Penumbra.Collections.Manager; +using Penumbra.GameData.Enums; +using Penumbra.Interop.PathResolving; +using Penumbra.Interop.Structs; +using Penumbra.Meta.Files; +using Penumbra.String; + +namespace Penumbra.Interop.Processing; + +public sealed class AtchFilePostProcessor(CollectionStorage collections, XivFileAllocator allocator) + : IFilePostProcessor +{ + private readonly IFileAllocator _allocator = allocator; + + public ResourceType Type + => ResourceType.Atch; + + public unsafe void PostProcess(ResourceHandle* resource, CiByteString originalGamePath, ReadOnlySpan additionalData) + { + if (!PathDataHandler.Read(additionalData, out var data) || data.Discriminator != PathDataHandler.Discriminator) + return; + + var collection = collections.ByLocalId(data.Collection); + if (collection.MetaCache is not { } cache) + return; + + if (!AtchPathPreProcessor.TryGetAtchGenderRace(originalGamePath, out var gr)) + return; + + if (!collection.MetaCache.Atch.GetFile(gr, out var file)) + return; + + using var bytes = file.Write(); + var length = (int)bytes.Position; + var alloc = _allocator.Allocate(length, 1); + bytes.GetBuffer().AsSpan(0, length).CopyTo(new Span(alloc, length)); + var (oldData, oldLength) = resource->GetData(); + _allocator.Release((void*)oldData, oldLength); + resource->SetData((nint)alloc, length); + Penumbra.Log.Information($"Post-Processed {originalGamePath} on resource 0x{(nint)resource:X} with {collection} for {gr.ToName()}."); + } +} diff --git a/Penumbra/Interop/Processing/AtchPathPreProcessor.cs b/Penumbra/Interop/Processing/AtchPathPreProcessor.cs new file mode 100644 index 00000000..428826bc --- /dev/null +++ b/Penumbra/Interop/Processing/AtchPathPreProcessor.cs @@ -0,0 +1,44 @@ +using Penumbra.Api.Enums; +using Penumbra.Collections; +using Penumbra.GameData.Enums; +using Penumbra.Interop.PathResolving; +using Penumbra.String; +using Penumbra.String.Classes; + +namespace Penumbra.Interop.Processing; + +public sealed class AtchPathPreProcessor : IPathPreProcessor +{ + public ResourceType Type + => ResourceType.Atch; + + public FullPath? PreProcess(ResolveData resolveData, CiByteString path, Utf8GamePath _, bool nonDefault, FullPath? resolved) + { + if (!resolveData.Valid) + return resolved; + + if (!TryGetAtchGenderRace(path, out var gr)) + return resolved; + + Penumbra.Log.Excessive($"Pre-Processed {path} with {resolveData.ModCollection} for {gr.ToName()}."); + if (resolveData.ModCollection.MetaCache?.Atch.GetFile(gr, out var file) == true) + return PathDataHandler.CreateAtch(path, resolveData.ModCollection); + + return resolved; + } + + public static bool TryGetAtchGenderRace(CiByteString originalGamePath, out GenderRace genderRace) + { + if (originalGamePath[^6] != '1' + || originalGamePath[^7] != '0' + || !ushort.TryParse(originalGamePath.Span[^9..^7], out var grInt) + || grInt > 18) + { + genderRace = GenderRace.Unknown; + return false; + } + + genderRace = (GenderRace)(grInt * 100 + 1); + return true; + } +} diff --git a/Penumbra/Interop/Processing/AvfxPathPreProcessor.cs b/Penumbra/Interop/Processing/AvfxPathPreProcessor.cs new file mode 100644 index 00000000..2194354a --- /dev/null +++ b/Penumbra/Interop/Processing/AvfxPathPreProcessor.cs @@ -0,0 +1,16 @@ +using Penumbra.Api.Enums; +using Penumbra.Collections; +using Penumbra.Interop.PathResolving; +using Penumbra.String; +using Penumbra.String.Classes; + +namespace Penumbra.Interop.Processing; + +public sealed class AvfxPathPreProcessor : IPathPreProcessor +{ + public ResourceType Type + => ResourceType.Avfx; + + public FullPath? PreProcess(ResolveData resolveData, CiByteString path, Utf8GamePath _, bool nonDefault, FullPath? resolved) + => nonDefault ? PathDataHandler.CreateAvfx(path, resolveData.ModCollection) : resolved; +} diff --git a/Penumbra/Interop/Processing/FilePostProcessService.cs b/Penumbra/Interop/Processing/FilePostProcessService.cs new file mode 100644 index 00000000..71340178 --- /dev/null +++ b/Penumbra/Interop/Processing/FilePostProcessService.cs @@ -0,0 +1,40 @@ +using System.Collections.Frozen; +using OtterGui.Services; +using Penumbra.Api.Enums; +using Penumbra.Interop.Hooks.ResourceLoading; +using Penumbra.Interop.Structs; +using Penumbra.String; +using Penumbra.String.Classes; + +namespace Penumbra.Interop.Processing; + +public interface IFilePostProcessor : IService +{ + public ResourceType Type { get; } + public unsafe void PostProcess(ResourceHandle* resource, CiByteString originalGamePath, ReadOnlySpan additionalData); +} + +public unsafe class FilePostProcessService : IRequiredService, IDisposable +{ + private readonly ResourceLoader _resourceLoader; + private readonly FrozenDictionary _processors; + + public FilePostProcessService(ResourceLoader resourceLoader, ServiceManager services) + { + _resourceLoader = resourceLoader; + _processors = services.GetServicesImplementing().ToFrozenDictionary(s => s.Type, s => s); + _resourceLoader.BeforeResourceComplete += OnBeforeResourceComplete; + } + + public void Dispose() + { + _resourceLoader.BeforeResourceComplete -= OnBeforeResourceComplete; + } + + private void OnBeforeResourceComplete(ResourceHandle* resource, CiByteString path, Utf8GamePath original, + ReadOnlySpan additionalData, bool isAsync) + { + if (_processors.TryGetValue(resource->FileType, out var processor)) + processor.PostProcess(resource, original.Path, additionalData); + } +} diff --git a/Penumbra/Interop/Processing/GamePathPreProcessService.cs b/Penumbra/Interop/Processing/GamePathPreProcessService.cs new file mode 100644 index 00000000..875eb254 --- /dev/null +++ b/Penumbra/Interop/Processing/GamePathPreProcessService.cs @@ -0,0 +1,36 @@ +using System.Collections.Frozen; +using OtterGui.Services; +using Penumbra.Api.Enums; +using Penumbra.Collections; +using Penumbra.String; +using Penumbra.String.Classes; + +namespace Penumbra.Interop.Processing; + +public interface IPathPreProcessor : IService +{ + public ResourceType Type { get; } + + public FullPath? PreProcess(ResolveData resolveData, CiByteString path, Utf8GamePath originalGamePath, bool nonDefault, FullPath? resolved); +} + +public class GamePathPreProcessService : IService +{ + private readonly FrozenDictionary _processors; + + public GamePathPreProcessService(ServiceManager services) + { + _processors = services.GetServicesImplementing().ToFrozenDictionary(s => s.Type, s => s); + } + + + public (FullPath? Path, ResolveData Data) PreProcess(ResolveData resolveData, CiByteString path, bool nonDefault, ResourceType type, + FullPath? resolved, Utf8GamePath originalPath) + { + if (!_processors.TryGetValue(type, out var processor)) + return (resolved, resolveData); + + resolved = processor.PreProcess(resolveData, path, originalPath, nonDefault, resolved); + return (resolved, resolveData); + } +} diff --git a/Penumbra/Interop/Processing/ImcFilePostProcessor.cs b/Penumbra/Interop/Processing/ImcFilePostProcessor.cs new file mode 100644 index 00000000..949baaa3 --- /dev/null +++ b/Penumbra/Interop/Processing/ImcFilePostProcessor.cs @@ -0,0 +1,30 @@ +using Penumbra.Api.Enums; +using Penumbra.Collections.Manager; +using Penumbra.Interop.PathResolving; +using Penumbra.Interop.Structs; +using Penumbra.String; + +namespace Penumbra.Interop.Processing; + +public sealed class ImcFilePostProcessor(CollectionStorage collections) : IFilePostProcessor +{ + public ResourceType Type + => ResourceType.Imc; + + public unsafe void PostProcess(ResourceHandle* resource, CiByteString originalGamePath, ReadOnlySpan additionalData) + { + if (!PathDataHandler.Read(additionalData, out var data) || data.Discriminator != PathDataHandler.Discriminator) + return; + + var collection = collections.ByLocalId(data.Collection); + if (collection.MetaCache is not { } cache) + return; + + if (!cache.Imc.GetFile(originalGamePath, out var file)) + return; + + file.Replace(resource); + Penumbra.Log.Verbose( + $"[ResourceLoader] Loaded {originalGamePath} from file and replaced with IMC from collection {collection.Identity.AnonymizedName}."); + } +} diff --git a/Penumbra/Interop/Processing/ImcPathPreProcessor.cs b/Penumbra/Interop/Processing/ImcPathPreProcessor.cs new file mode 100644 index 00000000..7030dd8d --- /dev/null +++ b/Penumbra/Interop/Processing/ImcPathPreProcessor.cs @@ -0,0 +1,18 @@ +using Penumbra.Api.Enums; +using Penumbra.Collections; +using Penumbra.Interop.PathResolving; +using Penumbra.String; +using Penumbra.String.Classes; + +namespace Penumbra.Interop.Processing; + +public sealed class ImcPathPreProcessor : IPathPreProcessor +{ + public ResourceType Type + => ResourceType.Imc; + + public FullPath? PreProcess(ResolveData resolveData, CiByteString path, Utf8GamePath originalGamePath, bool _, FullPath? resolved) + => resolveData.ModCollection.MetaCache?.Imc.HasFile(originalGamePath.Path) ?? false + ? PathDataHandler.CreateImc(path, resolveData.ModCollection) + : resolved; +} diff --git a/Penumbra/Interop/Processing/MaterialFilePostProcessor.cs b/Penumbra/Interop/Processing/MaterialFilePostProcessor.cs new file mode 100644 index 00000000..26956845 --- /dev/null +++ b/Penumbra/Interop/Processing/MaterialFilePostProcessor.cs @@ -0,0 +1,18 @@ +using Penumbra.Api.Enums; +using Penumbra.Interop.PathResolving; +using Penumbra.Interop.Structs; +using Penumbra.String; + +namespace Penumbra.Interop.Processing; + +public sealed class MaterialFilePostProcessor //: IFilePostProcessor +{ + public ResourceType Type + => ResourceType.Mtrl; + + public unsafe void PostProcess(ResourceHandle* resource, CiByteString originalGamePath, ReadOnlySpan additionalData) + { + if (!PathDataHandler.ReadMtrl(additionalData, out var data)) + return; + } +} diff --git a/Penumbra/Interop/Processing/MtrlPathPreProcessor.cs b/Penumbra/Interop/Processing/MtrlPathPreProcessor.cs new file mode 100644 index 00000000..603781ed --- /dev/null +++ b/Penumbra/Interop/Processing/MtrlPathPreProcessor.cs @@ -0,0 +1,16 @@ +using Penumbra.Api.Enums; +using Penumbra.Collections; +using Penumbra.Interop.PathResolving; +using Penumbra.String; +using Penumbra.String.Classes; + +namespace Penumbra.Interop.Processing; + +public sealed class MtrlPathPreProcessor : IPathPreProcessor +{ + public ResourceType Type + => ResourceType.Mtrl; + + public FullPath? PreProcess(ResolveData resolveData, CiByteString path, Utf8GamePath originalGamePath, bool nonDefault, FullPath? resolved) + => nonDefault ? PathDataHandler.CreateMtrl(path, resolveData.ModCollection, originalGamePath) : resolved; +} diff --git a/Penumbra/Interop/Processing/PbdFilePostProcessor.cs b/Penumbra/Interop/Processing/PbdFilePostProcessor.cs new file mode 100644 index 00000000..674500cd --- /dev/null +++ b/Penumbra/Interop/Processing/PbdFilePostProcessor.cs @@ -0,0 +1,119 @@ +using Dalamud.Game; +using Dalamud.Plugin.Services; +using Penumbra.Api.Enums; +using Penumbra.GameData; +using Penumbra.GameData.Data; +using Penumbra.Interop.Structs; +using Penumbra.Meta.Files; +using Penumbra.String; + +namespace Penumbra.Interop.Processing; + +public sealed class PbdFilePostProcessor : IFilePostProcessor +{ + private readonly IFileAllocator _allocator; + private byte[] _epbdData; + private unsafe delegate* unmanaged _loadEpbdData; + + public ResourceType Type + => ResourceType.Pbd; + + public unsafe PbdFilePostProcessor(IDataManager dataManager, XivFileAllocator allocator, ISigScanner scanner) + { + _allocator = allocator; + _epbdData = SetEpbdData(dataManager); + _loadEpbdData = (delegate* unmanaged)scanner.ScanText(Sigs.LoadEpbdData); + } + + public unsafe void PostProcess(ResourceHandle* resource, CiByteString originalGamePath, ReadOnlySpan additionalData) + { + if (_epbdData.Length is 0) + return; + + if (resource->LoadState is not LoadState.Success) + { + Penumbra.Log.Warning($"[ResourceLoader] Requested PBD at {resource->FileName()} failed load ({resource->LoadState})."); + return; + } + + var (data, length) = resource->GetData(); + if (length is 0 || data == nint.Zero) + { + Penumbra.Log.Warning($"[ResourceLoader] Requested PBD at {resource->FileName()} succeeded load but has no data."); + return; + } + + var span = new ReadOnlySpan((void*)data, (int)resource->FileSize); + var reader = new PackReader(span); + if (reader.HasData) + { + Penumbra.Log.Excessive($"[ResourceLoader] Successfully loaded PBD at {resource->FileName()} with EPBD data."); + return; + } + + var newData = AppendData(span); + fixed (byte* ptr = newData) + { + // Set the appended data and the actual file size, then re-load the EPBD data via game function call. + if (resource->SetData((nint)ptr, newData.Length)) + { + resource->FileSize = (uint)newData.Length; + resource->CsHandle.FileSize2 = (uint)newData.Length; + resource->CsHandle.FileSize3 = (uint)newData.Length; + _loadEpbdData(resource); + // Free original data. + _allocator.Release((void*)data, length); + Penumbra.Log.Debug($"[ResourceLoader] Loaded {resource->FileName()} from file and appended default EPBD data."); + } + else + { + Penumbra.Log.Warning( + $"[ResourceLoader] Failed to append EPBD data to custom PBD at {resource->FileName()}."); + } + } + } + + /// Combine the given data with the default PBD data using the game's file allocator. + private unsafe ReadOnlySpan AppendData(ReadOnlySpan data) + { + // offset has to be set, otherwise not called. + var newLength = data.Length + _epbdData.Length; + var memory = _allocator.Allocate(newLength); + var span = new Span(memory, newLength); + data.CopyTo(span); + _epbdData.CopyTo(span[data.Length..]); + return span; + } + + /// Fetch the default EPBD data from the .pbd file of the game's installation. + private static byte[] SetEpbdData(IDataManager dataManager) + { + try + { + var file = dataManager.GetFile(GamePaths.Pbd.Path); + if (file is null || file.Data.Length is 0) + { + Penumbra.Log.Warning("Default PBD file has no data."); + return []; + } + + ReadOnlySpan span = file.Data; + var reader = new PackReader(span); + if (!reader.HasData) + { + Penumbra.Log.Warning("Default PBD file has no EPBD section."); + return []; + } + + var offset = span.Length - (int)reader.PackLength; + var ret = span[offset..]; + Penumbra.Log.Verbose($"Default PBD file has EPBD section of length {ret.Length} at offset {offset}."); + return ret.ToArray(); + } + catch (Exception ex) + { + Penumbra.Log.Error($"Unknown error getting default EPBD data:\n{ex}"); + return []; + } + } +} diff --git a/Penumbra/Interop/Processing/ShpkPathPreProcessor.cs b/Penumbra/Interop/Processing/ShpkPathPreProcessor.cs new file mode 100644 index 00000000..ddd59121 --- /dev/null +++ b/Penumbra/Interop/Processing/ShpkPathPreProcessor.cs @@ -0,0 +1,89 @@ +using System.IO.MemoryMappedFiles; +using FFXIVClientStructs.FFXIV.Client.System.Resource; +using Penumbra.Api.Enums; +using Penumbra.Collections; +using Penumbra.GameData.Files; +using Penumbra.GameData.Files.Utility; +using Penumbra.Interop.Hooks.ResourceLoading; +using Penumbra.Mods.Manager; +using Penumbra.Services; +using Penumbra.String; +using Penumbra.String.Classes; + +namespace Penumbra.Interop.Processing; + +/// +/// Path pre-processor for shader packages that reverts redirects to known invalid files, as bad ShPks can crash the game. +/// +public sealed class ShpkPathPreProcessor(ResourceManagerService resourceManager, MessageService messager, ModManager modManager) + : IPathPreProcessor +{ + public ResourceType Type + => ResourceType.Shpk; + + public unsafe FullPath? PreProcess(ResolveData resolveData, CiByteString path, Utf8GamePath originalGamePath, bool nonDefault, + FullPath? resolved) + { + messager.CleanTaggedMessages(false); + + if (!resolved.HasValue) + return null; + + // Skip the sanity check for game files. We are not considering the case where the user has modified game file: it's at their own risk. + var resolvedPath = resolved.Value; + if (!resolvedPath.IsRooted) + return resolvedPath; + + // If the ShPk is already loaded, it means that it already passed the sanity check. + var existingResource = + resourceManager.FindResource(ResourceCategory.Shader, ResourceType.Shpk, unchecked((uint)resolvedPath.InternalName.Crc32)); + if (existingResource != null) + return resolvedPath; + + var checkResult = SanityCheck(resolvedPath.FullName); + if (checkResult == SanityCheckResult.Success) + return resolvedPath; + + messager.PrintFileWarning(modManager, resolvedPath.FullName, originalGamePath, WarningMessageComplement(checkResult)); + + return null; + } + + internal static SanityCheckResult SanityCheck(string path) + { + try + { + using var file = MmioMemoryManager.CreateFromFile(path, access: MemoryMappedFileAccess.Read); + var bytes = file.GetSpan(); + + return ShpkFile.FastIsObsolete(bytes) + ? SanityCheckResult.Obsolete + : SanityCheckResult.Success; + } + catch (FileNotFoundException) + { + return SanityCheckResult.NotFound; + } + catch (IOException) + { + return SanityCheckResult.IoError; + } + } + + private static string WarningMessageComplement(SanityCheckResult result) + => result switch + { + SanityCheckResult.IoError => "Cannot read the modded file.", + SanityCheckResult.NotFound => "The modded file does not exist.", + SanityCheckResult.Obsolete => "This mod is not compatible with Dawntrail post patch 7.2. Get an updated version, if possible, or disable it.", + _ => string.Empty, + }; + + internal enum SanityCheckResult + { + Success, + IoError, + NotFound, + Obsolete, + } +} diff --git a/Penumbra/Interop/Processing/SkinMtrlPathEarlyProcessing.cs b/Penumbra/Interop/Processing/SkinMtrlPathEarlyProcessing.cs new file mode 100644 index 00000000..bd066d83 --- /dev/null +++ b/Penumbra/Interop/Processing/SkinMtrlPathEarlyProcessing.cs @@ -0,0 +1,63 @@ +using FFXIVClientStructs.FFXIV.Client.Graphics.Scene; +using FFXIVClientStructs.FFXIV.Client.System.Resource.Handle; + +namespace Penumbra.Interop.Processing; + +public static unsafe class SkinMtrlPathEarlyProcessing +{ + public static void Process(Span path, CharacterBase* character, uint slotIndex) + { + var end = path.IndexOf(MaterialExtension()); + if (end < 0) + return; + + var suffixPos = path[..end].LastIndexOf((byte)'_'); + if (suffixPos < 0) + return; + + var handle = GetModelResourceHandle(character, slotIndex); + if (handle == null) + return; + + var skinSuffix = GetSkinSuffix(handle); + if (skinSuffix.IsEmpty || skinSuffix.Length > path.Length - suffixPos - 7) + return; + + ++suffixPos; + skinSuffix.CopyTo(path[suffixPos..]); + suffixPos += skinSuffix.Length; + MaterialExtension().CopyTo(path[suffixPos..]); + return; + + static ReadOnlySpan MaterialExtension() + => ".mtrl\0"u8; + } + + private static ModelResourceHandle* GetModelResourceHandle(CharacterBase* character, uint slotIndex) + { + if (character is null) + return null; + + if (character->PerSlotStagingArea is not null) + { + var handle = character->PerSlotStagingArea[slotIndex].ModelResourceHandle; + if (handle != null) + return handle; + } + + var model = character->Models[slotIndex]; + return model is null ? null : model->ModelResourceHandle; + } + + private static ReadOnlySpan GetSkinSuffix(ModelResourceHandle* handle) + { + foreach (var (attribute, _) in handle->Attributes) + { + var attributeSpan = attribute.AsSpan(); + if (attributeSpan.Length > 12 && attributeSpan[..11].SequenceEqual("skin_suffix"u8) && attributeSpan[11] is (byte)'=' or (byte)'_') + return attributeSpan[12..]; + } + + return []; + } +} diff --git a/Penumbra/Interop/Processing/TmbPathPreProcessor.cs b/Penumbra/Interop/Processing/TmbPathPreProcessor.cs new file mode 100644 index 00000000..0a7aa16f --- /dev/null +++ b/Penumbra/Interop/Processing/TmbPathPreProcessor.cs @@ -0,0 +1,16 @@ +using Penumbra.Api.Enums; +using Penumbra.Collections; +using Penumbra.Interop.PathResolving; +using Penumbra.String; +using Penumbra.String.Classes; + +namespace Penumbra.Interop.Processing; + +public sealed class TmbPathPreProcessor : IPathPreProcessor +{ + public ResourceType Type + => ResourceType.Tmb; + + public FullPath? PreProcess(ResolveData resolveData, CiByteString path, Utf8GamePath _, bool nonDefault, FullPath? resolved) + => nonDefault ? PathDataHandler.CreateTmb(path, resolveData.ModCollection) : resolved; +} diff --git a/Penumbra/Interop/ResidentResources.cs b/Penumbra/Interop/ResidentResources.cs deleted file mode 100644 index 4a2cc2b9..00000000 --- a/Penumbra/Interop/ResidentResources.cs +++ /dev/null @@ -1,125 +0,0 @@ -using System; -using System.Runtime.InteropServices; -using Dalamud.Logging; -using Penumbra.Structs; -using Penumbra.Util; -using ResourceHandle = FFXIVClientStructs.FFXIV.Client.System.Resource.Handle.ResourceHandle; - -namespace Penumbra.Interop -{ - public class ResidentResources - { - private const int NumResources = 85; - - [UnmanagedFunctionPointer( CallingConvention.ThisCall )] - public unsafe delegate void* LoadPlayerResourcesPrototype( IntPtr pResourceManager ); - - [UnmanagedFunctionPointer( CallingConvention.ThisCall )] - public unsafe delegate void* UnloadPlayerResourcesPrototype( IntPtr pResourceManager ); - - [UnmanagedFunctionPointer( CallingConvention.ThisCall )] - public unsafe delegate void* LoadCharacterResourcesPrototype( CharacterUtility* pCharacterResourceManager ); - - [UnmanagedFunctionPointer( CallingConvention.ThisCall )] - public unsafe delegate void* UnloadCharacterResourcePrototype( IntPtr resource ); - - - public LoadPlayerResourcesPrototype LoadPlayerResources { get; } - public UnloadPlayerResourcesPrototype UnloadPlayerResources { get; } - public LoadCharacterResourcesPrototype LoadDataFiles { get; } - public UnloadCharacterResourcePrototype UnloadCharacterResource { get; } - - // Object addresses - private readonly IntPtr _residentResourceManagerAddress; - - public IntPtr ResidentResourceManager - => Marshal.ReadIntPtr( _residentResourceManagerAddress ); - - private readonly IntPtr _characterUtilityAddress; - - public unsafe CharacterUtility* CharacterUtility - => ( CharacterUtility* )Marshal.ReadIntPtr( _characterUtilityAddress ).ToPointer(); - - public ResidentResources() - { - var module = Dalamud.SigScanner.Module.BaseAddress.ToInt64(); - var loadPlayerResourcesAddress = - Dalamud.SigScanner.ScanText( - "E8 ?? ?? ?? ?? 48 8B 05 ?? ?? ?? ?? BA ?? ?? ?? ?? 41 B8 ?? ?? ?? ?? 48 8B 48 30 48 8B 01 FF 50 10 48 85 C0 74 0A " ); - GeneralUtil.PrintDebugAddress( "LoadPlayerResources", loadPlayerResourcesAddress ); - - var unloadPlayerResourcesAddress = - Dalamud.SigScanner.ScanText( - "41 55 48 81 EC ?? ?? ?? ?? 48 8B 05 ?? ?? ?? ?? 48 33 C4 48 89 84 24 ?? ?? ?? ?? 4C 8B E9 48 83 C1 08" ); - GeneralUtil.PrintDebugAddress( "UnloadPlayerResources", unloadPlayerResourcesAddress ); - - var loadDataFilesAddress = Dalamud.SigScanner.ScanText( "E8 ?? ?? ?? 00 48 8D 8E ?? ?? 00 00 E8 ?? ?? ?? 00 33 D2" ); - GeneralUtil.PrintDebugAddress( "LoadDataFiles", loadDataFilesAddress ); - - var unloadCharacterResourceAddress = - Dalamud.SigScanner.ScanText( "E8 ?? ?? ?? FF 4C 89 37 48 83 C7 08 48 83 ED 01 75 ?? 48 8B CB" ); - GeneralUtil.PrintDebugAddress( "UnloadCharacterResource", unloadCharacterResourceAddress ); - - _residentResourceManagerAddress = Dalamud.SigScanner.GetStaticAddressFromSig( "0F 44 FE 48 8B 0D ?? ?? ?? ?? 48 85 C9 74 05" ); - GeneralUtil.PrintDebugAddress( "ResidentResourceManager", _residentResourceManagerAddress ); - - _characterUtilityAddress = - Dalamud.SigScanner.GetStaticAddressFromSig( "48 8B 0D ?? ?? ?? ?? E8 ?? ?? ?? 00 48 8D 8E ?? ?? 00 00 E8 ?? ?? ?? 00 33 D2" ); - GeneralUtil.PrintDebugAddress( "CharacterUtility", _characterUtilityAddress ); - - LoadPlayerResources = Marshal.GetDelegateForFunctionPointer< LoadPlayerResourcesPrototype >( loadPlayerResourcesAddress ); - UnloadPlayerResources = Marshal.GetDelegateForFunctionPointer< UnloadPlayerResourcesPrototype >( unloadPlayerResourcesAddress ); - LoadDataFiles = Marshal.GetDelegateForFunctionPointer< LoadCharacterResourcesPrototype >( loadDataFilesAddress ); - UnloadCharacterResource = - Marshal.GetDelegateForFunctionPointer< UnloadCharacterResourcePrototype >( unloadCharacterResourceAddress ); - } - - // Forces the reload of a specific set of 85 files, notably containing the eqp, eqdp, gmp and est tables, by filename. - public unsafe void ReloadPlayerResources() - { - ReloadCharacterResources(); - - UnloadPlayerResources( ResidentResourceManager ); - LoadPlayerResources( ResidentResourceManager ); - } - - public unsafe string ResourceToPath( byte* resource ) - => Marshal.PtrToStringAnsi( new IntPtr( *( char** )( resource + 9 * 8 ) ) )!; - - private unsafe void ReloadCharacterResources() - { - var oldResources = new IntPtr[NumResources]; - var resources = new IntPtr( &CharacterUtility->Resources ); - var pResources = ( void** )resources.ToPointer(); - - Marshal.Copy( resources, oldResources, 0, NumResources ); - - LoadDataFiles( CharacterUtility ); - - for( var i = 0; i < NumResources; i++ ) - { - var handle = ( ResourceHandle* )oldResources[ i ]; - if( oldResources[ i ].ToPointer() == pResources[ i ] ) - { - PluginLog.Debug( $"Unchanged resource: {ResourceToPath( ( byte* )oldResources[ i ].ToPointer() )}" ); - ( ( ResourceHandle* )oldResources[ i ] )->DecRef(); - continue; - } - - PluginLog.Debug( "Freeing " - + $"{ResourceToPath( ( byte* )oldResources[ i ].ToPointer() )}, replaced with " - + $"{ResourceToPath( ( byte* )pResources[ i ] )}" ); - - UnloadCharacterResource( oldResources[ i ] ); - - // Temporary fix against crashes? - if( handle->RefCount <= 0 ) - { - handle->RefCount = 1; - handle->IncRef(); - handle->RefCount = 1; - } - } - } - } -} diff --git a/Penumbra/Interop/ResourceLoader.cs b/Penumbra/Interop/ResourceLoader.cs deleted file mode 100644 index 8d4c5937..00000000 --- a/Penumbra/Interop/ResourceLoader.cs +++ /dev/null @@ -1,272 +0,0 @@ -using System; -using System.IO; -using System.Runtime.InteropServices; -using System.Text; -using System.Text.RegularExpressions; -using Dalamud.Hooking; -using Dalamud.Logging; -using Penumbra.GameData.Util; -using Penumbra.Mods; -using Penumbra.Structs; -using Penumbra.Util; -using FileMode = Penumbra.Structs.FileMode; - -namespace Penumbra.Interop -{ - public class ResourceLoader : IDisposable - { - public Penumbra Penumbra { get; set; } - - public bool IsEnabled { get; set; } - - public Crc32 Crc32 { get; } - - - // Delegate prototypes - [UnmanagedFunctionPointer( CallingConvention.ThisCall )] - public unsafe delegate byte ReadFilePrototype( IntPtr pFileHandler, SeFileDescriptor* pFileDesc, int priority, bool isSync ); - - [UnmanagedFunctionPointer( CallingConvention.ThisCall )] - public unsafe delegate byte ReadSqpackPrototype( IntPtr pFileHandler, SeFileDescriptor* pFileDesc, int priority, bool isSync ); - - [UnmanagedFunctionPointer( CallingConvention.ThisCall )] - public unsafe delegate void* GetResourceSyncPrototype( IntPtr pFileManager, uint* pCategoryId, char* pResourceType - , uint* pResourceHash, char* pPath, void* pUnknown ); - - [UnmanagedFunctionPointer( CallingConvention.ThisCall )] - public unsafe delegate void* GetResourceAsyncPrototype( IntPtr pFileManager, uint* pCategoryId, char* pResourceType - , uint* pResourceHash, char* pPath, void* pUnknown, bool isUnknown ); - - // Hooks - public Hook< GetResourceSyncPrototype >? GetResourceSyncHook { get; private set; } - public Hook< GetResourceAsyncPrototype >? GetResourceAsyncHook { get; private set; } - public Hook< ReadSqpackPrototype >? ReadSqpackHook { get; private set; } - - // Unmanaged functions - public ReadFilePrototype? ReadFile { get; private set; } - - - public bool LogAllFiles = false; - public Regex? LogFileFilter = null; - - - public ResourceLoader( Penumbra penumbra ) - { - Penumbra = penumbra; - Crc32 = new Crc32(); - } - - public unsafe void Init() - { - var readFileAddress = - Dalamud.SigScanner.ScanText( "E8 ?? ?? ?? ?? 84 C0 0F 84 ?? 00 00 00 4C 8B C3 BA 05" ); - GeneralUtil.PrintDebugAddress( "ReadFile", readFileAddress ); - - var readSqpackAddress = - Dalamud.SigScanner.ScanText( "E8 ?? ?? ?? ?? EB 05 E8 ?? ?? ?? ?? 84 C0 0F 84 ?? 00 00 00 4C 8B C3" ); - GeneralUtil.PrintDebugAddress( "ReadSqPack", readSqpackAddress ); - - var getResourceSyncAddress = - Dalamud.SigScanner.ScanText( "E8 ?? ?? 00 00 48 8D 8F ?? ?? 00 00 48 89 87 ?? ?? 00 00" ); - GeneralUtil.PrintDebugAddress( "GetResourceSync", getResourceSyncAddress ); - - var getResourceAsyncAddress = - Dalamud.SigScanner.ScanText( "E8 ?? ?? ?? 00 48 8B D8 EB ?? F0 FF 83 ?? ?? 00 00" ); - GeneralUtil.PrintDebugAddress( "GetResourceAsync", getResourceAsyncAddress ); - - - ReadSqpackHook = new Hook< ReadSqpackPrototype >( readSqpackAddress, ReadSqpackHandler ); - GetResourceSyncHook = new Hook< GetResourceSyncPrototype >( getResourceSyncAddress, GetResourceSyncHandler ); - GetResourceAsyncHook = new Hook< GetResourceAsyncPrototype >( getResourceAsyncAddress, GetResourceAsyncHandler ); - - ReadFile = Marshal.GetDelegateForFunctionPointer< ReadFilePrototype >( readFileAddress ); - } - - - private unsafe void* GetResourceSyncHandler( - IntPtr pFileManager, - uint* pCategoryId, - char* pResourceType, - uint* pResourceHash, - char* pPath, - void* pUnknown - ) - => GetResourceHandler( true, pFileManager, pCategoryId, pResourceType, pResourceHash, pPath, pUnknown, false ); - - private unsafe void* GetResourceAsyncHandler( - IntPtr pFileManager, - uint* pCategoryId, - char* pResourceType, - uint* pResourceHash, - char* pPath, - void* pUnknown, - bool isUnknown - ) - => GetResourceHandler( false, pFileManager, pCategoryId, pResourceType, pResourceHash, pPath, pUnknown, isUnknown ); - - private unsafe void* CallOriginalHandler( - bool isSync, - IntPtr pFileManager, - uint* pCategoryId, - char* pResourceType, - uint* pResourceHash, - char* pPath, - void* pUnknown, - bool isUnknown - ) - { - if( isSync ) - { - if( GetResourceSyncHook == null ) - { - PluginLog.Error( "[GetResourceHandler] GetResourceSync is null." ); - return null; - } - - return GetResourceSyncHook.Original( pFileManager, pCategoryId, pResourceType, pResourceHash, pPath, pUnknown ); - } - - if( GetResourceAsyncHook == null ) - { - PluginLog.Error( "[GetResourceHandler] GetResourceAsync is null." ); - return null; - } - - return GetResourceAsyncHook.Original( pFileManager, pCategoryId, pResourceType, pResourceHash, pPath, pUnknown, isUnknown ); - } - - private unsafe void* GetResourceHandler( - bool isSync, - IntPtr pFileManager, - uint* pCategoryId, - char* pResourceType, - uint* pResourceHash, - char* pPath, - void* pUnknown, - bool isUnknown - ) - { - string file; - var modManager = Service< ModManager >.Get(); - - if( !Penumbra.Config.IsEnabled || modManager == null ) - { - if( LogAllFiles ) - { - file = Marshal.PtrToStringAnsi( new IntPtr( pPath ) )!; - if( LogFileFilter == null || LogFileFilter.IsMatch( file ) ) - { - PluginLog.Information( "[GetResourceHandler] {0}", file ); - } - } - - return CallOriginalHandler( isSync, pFileManager, pCategoryId, pResourceType, pResourceHash, pPath, pUnknown, isUnknown ); - } - - file = Marshal.PtrToStringAnsi( new IntPtr( pPath ) )!; - var gameFsPath = GamePath.GenerateUncheckedLower( file ); - var replacementPath = modManager.ResolveSwappedOrReplacementPath( gameFsPath ); - if( LogAllFiles && ( LogFileFilter == null || LogFileFilter.IsMatch( file ) ) ) - { - PluginLog.Information( "[GetResourceHandler] {0}", file ); - } - - // path must be < 260 because statically defined array length :( - if( replacementPath == null ) - { - return CallOriginalHandler( isSync, pFileManager, pCategoryId, pResourceType, pResourceHash, pPath, pUnknown, isUnknown ); - } - - var path = Encoding.ASCII.GetBytes( replacementPath ); - - var bPath = stackalloc byte[path.Length + 1]; - Marshal.Copy( path, 0, new IntPtr( bPath ), path.Length ); - pPath = ( char* )bPath; - - Crc32.Init(); - Crc32.Update( path ); - *pResourceHash = Crc32.Checksum; - - PluginLog.Verbose( "[GetResourceHandler] resolved {GamePath} to {NewPath}", gameFsPath, replacementPath ); - - return CallOriginalHandler( isSync, pFileManager, pCategoryId, pResourceType, pResourceHash, pPath, pUnknown, isUnknown ); - } - - - private unsafe byte ReadSqpackHandler( IntPtr pFileHandler, SeFileDescriptor* pFileDesc, int priority, bool isSync ) - { - if( ReadFile == null || pFileDesc == null || pFileDesc->ResourceHandle == null ) - { - PluginLog.Error( "THIS SHOULD NOT HAPPEN" ); - return ReadSqpackHook?.Original( pFileHandler, pFileDesc, priority, isSync ) ?? 0; - } - - var gameFsPath = Marshal.PtrToStringAnsi( new IntPtr( pFileDesc->ResourceHandle->FileName() ) ); - - var isRooted = Path.IsPathRooted( gameFsPath ); - - if( gameFsPath == null || gameFsPath.Length >= 260 || !isRooted ) - { - return ReadSqpackHook?.Original( pFileHandler, pFileDesc, priority, isSync ) ?? 0; - } - - PluginLog.Debug( "loading modded file: {GameFsPath}", gameFsPath ); - - pFileDesc->FileMode = FileMode.LoadUnpackedResource; - - // note: must be utf16 - var utfPath = Encoding.Unicode.GetBytes( gameFsPath ); - - Marshal.Copy( utfPath, 0, new IntPtr( &pFileDesc->UtfFileName ), utfPath.Length ); - - var fd = stackalloc byte[0x20 + utfPath.Length + 0x16]; - Marshal.Copy( utfPath, 0, new IntPtr( fd + 0x21 ), utfPath.Length ); - - pFileDesc->FileDescriptor = fd; - - return ReadFile( pFileHandler, pFileDesc, priority, isSync ); - } - - public void Enable() - { - if( IsEnabled ) - { - return; - } - - if( ReadSqpackHook == null || GetResourceSyncHook == null || GetResourceAsyncHook == null ) - { - PluginLog.Error( "[GetResourceHandler] Could not activate hooks because at least one was not set." ); - return; - } - - ReadSqpackHook.Enable(); - GetResourceSyncHook.Enable(); - GetResourceAsyncHook.Enable(); - - IsEnabled = true; - } - - public void Disable() - { - if( !IsEnabled ) - { - return; - } - - ReadSqpackHook?.Disable(); - GetResourceSyncHook?.Disable(); - GetResourceAsyncHook?.Disable(); - - IsEnabled = false; - } - - public void Dispose() - { - Disable(); - ReadSqpackHook?.Dispose(); - GetResourceSyncHook?.Dispose(); - GetResourceAsyncHook?.Dispose(); - } - } -} \ No newline at end of file diff --git a/Penumbra/Interop/ResourceTree/ResolveContext.PathResolution.cs b/Penumbra/Interop/ResourceTree/ResolveContext.PathResolution.cs new file mode 100644 index 00000000..c204f141 --- /dev/null +++ b/Penumbra/Interop/ResourceTree/ResolveContext.PathResolution.cs @@ -0,0 +1,388 @@ +using FFXIVClientStructs.FFXIV.Client.Graphics.Scene; +using FFXIVClientStructs.FFXIV.Client.System.Resource.Handle; +using OtterGui.Text.HelperObjects; +using Penumbra.GameData.Data; +using Penumbra.GameData.Enums; +using Penumbra.GameData.Structs; +using Penumbra.Meta.Files; +using Penumbra.Meta.Manipulations; +using Penumbra.String; +using Penumbra.String.Classes; +using static Penumbra.Interop.Structs.StructExtensions; +using CharaBase = FFXIVClientStructs.FFXIV.Client.Graphics.Scene.CharacterBase; +using ModelType = FFXIVClientStructs.FFXIV.Client.Graphics.Scene.CharacterBase.ModelType; + +namespace Penumbra.Interop.ResourceTree; + +internal partial record ResolveContext +{ + private static bool IsEquipmentOrAccessorySlot(uint slotIndex) + => slotIndex is < 10 or 16 or 17; + + private static bool IsEquipmentSlot(uint slotIndex) + => slotIndex is < 5 or 16 or 17; + + private unsafe Variant Variant + => ModelType switch + { + ModelType.Monster => (byte)((Monster*)CharacterBase)->Variant, + _ => Equipment.Variant, + }; + + private Utf8GamePath ResolveModelPath() + { + // Correctness: + // Resolving a model path through the game's code can use EQDP metadata for human equipment models. + return ModelType switch + { + ModelType.Human when IsEquipmentOrAccessorySlot(SlotIndex) => ResolveEquipmentModelPath(), + _ => ResolveModelPathNative(), + }; + } + + private Utf8GamePath ResolveEquipmentModelPath() + { + var path = IsEquipmentSlot(SlotIndex) + ? GamePaths.Mdl.Equipment(Equipment.Set, ResolveModelRaceCode(), SlotIndex.ToEquipSlot()) + : GamePaths.Mdl.Accessory(Equipment.Set, ResolveModelRaceCode(), SlotIndex.ToEquipSlot()); + return Utf8GamePath.FromString(path, out var gamePath) ? gamePath : Utf8GamePath.Empty; + } + + private GenderRace ResolveModelRaceCode() + => ResolveEqdpRaceCode(SlotIndex, Equipment.Set); + + private unsafe GenderRace ResolveEqdpRaceCode(uint slotIndex, PrimaryId primaryId) + { + if (!IsEquipmentOrAccessorySlot(slotIndex) || ModelType != ModelType.Human) + return GenderRace.MidlanderMale; + + var characterRaceCode = (GenderRace)((Human*)CharacterBase)->RaceSexId; + if (characterRaceCode == GenderRace.MidlanderMale) + return GenderRace.MidlanderMale; + + var accessory = !IsEquipmentSlot(slotIndex); + if ((ushort)characterRaceCode % 10 != 1 && accessory) + return GenderRace.MidlanderMale; + + var metaCache = Global.Collection.MetaCache; + var entry = metaCache?.GetEqdpEntry(characterRaceCode, accessory, primaryId) + ?? ExpandedEqdpFile.GetDefault(Global.MetaFileManager, characterRaceCode, accessory, primaryId); + var slot = slotIndex.ToEquipSlot(); + if (entry.ToBits(slot).Item2) + return characterRaceCode; + + var fallbackRaceCode = characterRaceCode.Fallback(); + if (fallbackRaceCode == GenderRace.MidlanderMale) + return GenderRace.MidlanderMale; + + entry = metaCache?.GetEqdpEntry(fallbackRaceCode, accessory, primaryId) + ?? ExpandedEqdpFile.GetDefault(Global.MetaFileManager, fallbackRaceCode, accessory, primaryId); + if (entry.ToBits(slot).Item2) + return fallbackRaceCode; + + return GenderRace.MidlanderMale; + } + + private unsafe Utf8GamePath ResolveModelPathNative() + { + var path = CharacterBase->ResolveMdlPathAsByteString(SlotIndex); + return Utf8GamePath.FromByteString(path, out var gamePath) ? gamePath : Utf8GamePath.Empty; + } + + private unsafe Utf8GamePath ResolveMaterialPath(Utf8GamePath modelPath, ResourceHandle* imc, byte* mtrlFileName) + { + // Safety and correctness: + // Resolving a material path through the game's code can dereference null pointers for materials that involve IMC metadata. + return ModelType switch + { + ModelType.Human when IsEquipmentOrAccessorySlot(SlotIndex) && mtrlFileName[8] != (byte)'b' + => ResolveEquipmentMaterialPath(modelPath, imc, mtrlFileName), + ModelType.DemiHuman => ResolveEquipmentMaterialPath(modelPath, imc, mtrlFileName), + ModelType.Weapon => ResolveWeaponMaterialPath(modelPath, imc, mtrlFileName), + ModelType.Monster => ResolveEquipmentMaterialPath(modelPath, imc, mtrlFileName), + _ => ResolveMaterialPathNative(mtrlFileName), + }; + } + + [SkipLocalsInit] + private unsafe Utf8GamePath ResolveEquipmentMaterialPath(Utf8GamePath modelPath, ResourceHandle* imc, byte* mtrlFileName) + { + var variant = ResolveImcData(imc).MaterialId; + var fileName = MemoryMarshal.CreateReadOnlySpanFromNullTerminated(mtrlFileName); + + Span pathBuffer = stackalloc byte[CharaBase.PathBufferSize]; + pathBuffer = AssembleMaterialPath(pathBuffer, modelPath.Path.Span, variant, fileName); + + return Utf8GamePath.FromSpan(pathBuffer, MetaDataComputation.None, out var path) ? path.Clone() : Utf8GamePath.Empty; + } + + [SkipLocalsInit] + private unsafe Utf8GamePath ResolveWeaponMaterialPath(Utf8GamePath modelPath, ResourceHandle* imc, byte* mtrlFileName) + { + var setIdHigh = Equipment.Set.Id / 100; + // All MCH (20??) weapons' materials C are one and the same + if (setIdHigh is 20 && mtrlFileName[14] == (byte)'c') + return Utf8GamePath.FromString(GamePaths.Mtrl.Weapon(2001, 1, 1, "c"), out var path) ? path : Utf8GamePath.Empty; + + // Some offhands share materials with the corresponding mainhand + if (ItemData.AdaptOffhandImc(Equipment.Set, out var mirroredSetId)) + { + var variant = ResolveImcData(imc).MaterialId; + var fileName = MemoryMarshal.CreateReadOnlySpanFromNullTerminated(mtrlFileName); + + Span mirroredFileName = stackalloc byte[32]; + mirroredFileName = mirroredFileName[..fileName.Length]; + fileName.CopyTo(mirroredFileName); + WriteZeroPaddedNumber(mirroredFileName[4..8], mirroredSetId.Id); + + Span pathBuffer = stackalloc byte[CharaBase.PathBufferSize]; + pathBuffer = AssembleMaterialPath(pathBuffer, modelPath.Path.Span, variant, mirroredFileName); + + var weaponPosition = pathBuffer.IndexOf("/weapon/w"u8); + if (weaponPosition >= 0) + WriteZeroPaddedNumber(pathBuffer[(weaponPosition + 9)..(weaponPosition + 13)], mirroredSetId.Id); + + return Utf8GamePath.FromSpan(pathBuffer, MetaDataComputation.None, out var path) ? path.Clone() : Utf8GamePath.Empty; + } + + return ResolveEquipmentMaterialPath(modelPath, imc, mtrlFileName); + } + + private unsafe ImcEntry ResolveImcData(ResourceHandle* imc) + { + var imcFileData = imc->GetDataSpan(); + if (imcFileData.IsEmpty) + { + Penumbra.Log.Warning($"IMC resource handle with path {imc->FileName.AsByteString()} doesn't have a valid data span"); + return default; + } + + return ImcFile.GetEntry(imcFileData, SlotIndex.ToEquipSlot(), Variant, out _); + } + + private static Span AssembleMaterialPath(Span materialPathBuffer, ReadOnlySpan modelPath, byte variant, + ReadOnlySpan mtrlFileName) + { + var modelPosition = modelPath.IndexOf("/model/"u8); + if (modelPosition < 0) + return []; + + var baseDirectory = modelPath[..modelPosition]; + + var writer = new SpanTextWriter(materialPathBuffer); + writer.Append(baseDirectory); + writer.Append("/material/v"u8); + WriteZeroPaddedNumber(ref writer, 4, variant); + writer.Append((byte)'/'); + writer.Append(mtrlFileName); + writer.EnsureNullTerminated(); + + return materialPathBuffer[..writer.Position]; + } + + private static void WriteZeroPaddedNumber(ref SpanTextWriter writer, int width, ushort number) + { + WriteZeroPaddedNumber(writer.GetRemainingSpan()[..width], number); + writer.Advance(width); + } + + private static void WriteZeroPaddedNumber(Span destination, ushort number) + { + for (var i = destination.Length; i-- > 0;) + { + destination[i] = (byte)('0' + number % 10); + number /= 10; + } + } + + private unsafe Utf8GamePath ResolveMaterialPathNative(byte* mtrlFileName) + { + CiByteString? path; + try + { + path = CharacterBase->ResolveMtrlPathAsByteString(SlotIndex, mtrlFileName); + } + catch (AccessViolationException) + { + Penumbra.Log.Error( + $"Access violation during attempt to resolve material path\nDraw object: {(nint)CharacterBase:X} (of type {ModelType})\nSlot index: {SlotIndex}\nMaterial file name: {(nint)mtrlFileName:X} ({new string((sbyte*)mtrlFileName)})"); + return Utf8GamePath.Empty; + } + + return Utf8GamePath.FromByteString(path, out var gamePath) ? gamePath : Utf8GamePath.Empty; + } + + private Utf8GamePath ResolveSkeletonPath(uint partialSkeletonIndex) + { + // Correctness and Safety: + // Resolving a skeleton path through the game's code can use EST metadata for human skeletons. + // Additionally, it can dereference null pointers for human equipment skeletons. + return ModelType switch + { + ModelType.Human => ResolveHumanSkeletonPath(partialSkeletonIndex), + _ => ResolveSkeletonPathNative(partialSkeletonIndex), + }; + } + + private Utf8GamePath ResolveHumanSkeletonPath(uint partialSkeletonIndex) + { + var (raceCode, slot, set) = ResolveHumanSkeletonData(partialSkeletonIndex); + if (set == 0) + return Utf8GamePath.Empty; + + var path = GamePaths.Sklb.Customization(raceCode, slot, set); + return Utf8GamePath.FromString(path, out var gamePath) ? gamePath : Utf8GamePath.Empty; + } + + private unsafe (GenderRace RaceCode, string Slot, PrimaryId Set) ResolveHumanSkeletonData(uint partialSkeletonIndex) + { + var human = (Human*)CharacterBase; + var characterRaceCode = (GenderRace)human->RaceSexId; + switch (partialSkeletonIndex) + { + case 0: return (characterRaceCode, "base", 1); + case 1: + var faceId = human->FaceId; + var tribe = human->Customize[(int)Dalamud.Game.ClientState.Objects.Enums.CustomizeIndex.Tribe]; + var modelType = human->Customize[(int)Dalamud.Game.ClientState.Objects.Enums.CustomizeIndex.ModelType]; + if (faceId < 201) + faceId -= tribe switch + { + 0xB when modelType is 4 => 100, + 0xE | 0xF => 100, + _ => 0, + }; + return ResolveHumanExtraSkeletonData(characterRaceCode, EstType.Face, faceId); + case 2: return ResolveHumanExtraSkeletonData(characterRaceCode, EstType.Hair, human->HairId); + case 3: return ResolveHumanEquipmentSkeletonData(EquipSlot.Head, EstType.Head); + case 4: return ResolveHumanEquipmentSkeletonData(EquipSlot.Body, EstType.Body); + default: return (0, string.Empty, 0); + } + } + + private unsafe (GenderRace RaceCode, string Slot, PrimaryId Set) ResolveHumanEquipmentSkeletonData(EquipSlot slot, EstType type) + { + var human = (Human*)CharacterBase; + var equipment = ((CharacterArmor*)&human->Head)[slot.ToIndex()]; + return ResolveHumanExtraSkeletonData(ResolveEqdpRaceCode(slot.ToIndex(), equipment.Set), type, equipment.Set); + } + + private (GenderRace RaceCode, string Slot, PrimaryId Set) ResolveHumanExtraSkeletonData(GenderRace raceCode, EstType type, + PrimaryId primary) + { + var metaCache = Global.Collection.MetaCache; + var skeletonSet = metaCache?.GetEstEntry(type, raceCode, primary) + ?? EstFile.GetDefault(Global.MetaFileManager, type, raceCode, primary); + return (raceCode, type.ToName(), skeletonSet.AsId); + } + + private unsafe Utf8GamePath ResolveSkeletonPathNative(uint partialSkeletonIndex) + { + var path = CharacterBase->ResolveSklbPathAsByteString(partialSkeletonIndex); + return Utf8GamePath.FromByteString(path, out var gamePath) ? gamePath : Utf8GamePath.Empty; + } + + private Utf8GamePath ResolveSkeletonParameterPath(uint partialSkeletonIndex) + { + // Correctness and Safety: + // Resolving a skeleton parameter path through the game's code can use EST metadata for human skeletons. + // Additionally, it can dereference null pointers for human equipment skeletons. + return ModelType switch + { + ModelType.Human => ResolveHumanSkeletonParameterPath(partialSkeletonIndex), + _ => ResolveSkeletonParameterPathNative(partialSkeletonIndex), + }; + } + + private Utf8GamePath ResolveHumanSkeletonParameterPath(uint partialSkeletonIndex) + { + var (raceCode, slot, set) = ResolveHumanSkeletonData(partialSkeletonIndex); + if (set.Id is 0) + return Utf8GamePath.Empty; + + var path = GamePaths.Skp.Customization(raceCode, slot, set); + return Utf8GamePath.FromString(path, out var gamePath) ? gamePath : Utf8GamePath.Empty; + } + + private unsafe Utf8GamePath ResolveSkeletonParameterPathNative(uint partialSkeletonIndex) + { + var path = CharacterBase->ResolveSkpPathAsByteString(partialSkeletonIndex); + return Utf8GamePath.FromByteString(path, out var gamePath) ? gamePath : Utf8GamePath.Empty; + } + + private Utf8GamePath ResolvePhysicsModulePath(uint partialSkeletonIndex) + { + // Correctness and Safety: + // Resolving a physics module path through the game's code can use EST metadata for human skeletons. + // Additionally, it can dereference null pointers for human equipment skeletons. + return ModelType switch + { + ModelType.Human => ResolveHumanPhysicsModulePath(partialSkeletonIndex), + _ => ResolvePhysicsModulePathNative(partialSkeletonIndex), + }; + } + + private Utf8GamePath ResolveHumanPhysicsModulePath(uint partialSkeletonIndex) + { + var (raceCode, slot, set) = ResolveHumanSkeletonData(partialSkeletonIndex); + if (set.Id is 0) + return Utf8GamePath.Empty; + + var path = GamePaths.Phyb.Customization(raceCode, slot, set); + return Utf8GamePath.FromString(path, out var gamePath) ? gamePath : Utf8GamePath.Empty; + } + + private unsafe Utf8GamePath ResolvePhysicsModulePathNative(uint partialSkeletonIndex) + { + var path = CharacterBase->ResolvePhybPathAsByteString(partialSkeletonIndex); + return Utf8GamePath.FromByteString(path, out var gamePath) ? gamePath : Utf8GamePath.Empty; + } + + private Utf8GamePath ResolveKineDriverModulePath(uint partialSkeletonIndex) + { + // Correctness and Safety: + // Resolving a KineDriver module path through the game's code can use EST metadata for human skeletons. + // Additionally, it can dereference null pointers for human equipment skeletons. + return ModelType switch + { + ModelType.Human => ResolveHumanKineDriverModulePath(partialSkeletonIndex), + _ => ResolveKineDriverModulePathNative(partialSkeletonIndex), + }; + } + + private Utf8GamePath ResolveHumanKineDriverModulePath(uint partialSkeletonIndex) + { + var (raceCode, slot, set) = ResolveHumanSkeletonData(partialSkeletonIndex); + if (set.Id is 0) + return Utf8GamePath.Empty; + + var path = GamePaths.Kdb.Customization(raceCode, slot, set); + return Utf8GamePath.FromString(path, out var gamePath) ? gamePath : Utf8GamePath.Empty; + } + + private unsafe Utf8GamePath ResolveKineDriverModulePathNative(uint partialSkeletonIndex) + { + var path = CharacterBase->ResolveKdbPathAsByteString(partialSkeletonIndex); + return Utf8GamePath.FromByteString(path, out var gamePath) ? gamePath : Utf8GamePath.Empty; + } + + private unsafe Utf8GamePath ResolveMaterialAnimationPath(ResourceHandle* imc) + { + var animation = ResolveImcData(imc).MaterialAnimationId; + if (animation is 0) + return Utf8GamePath.Empty; + + var path = CharacterBase->ResolveMaterialPapPathAsByteString(SlotIndex, animation); + return Utf8GamePath.FromByteString(path, out var gamePath) ? gamePath : Utf8GamePath.Empty; + } + + private unsafe Utf8GamePath ResolveDecalPath(ResourceHandle* imc) + { + var decal = ResolveImcData(imc).DecalId; + if (decal is 0) + return Utf8GamePath.Empty; + + var path = GamePaths.Tex.EquipDecal(decal); + return Utf8GamePath.FromString(path, out var gamePath) ? gamePath : Utf8GamePath.Empty; + } +} diff --git a/Penumbra/Interop/ResourceTree/ResolveContext.cs b/Penumbra/Interop/ResourceTree/ResolveContext.cs new file mode 100644 index 00000000..501bbc56 --- /dev/null +++ b/Penumbra/Interop/ResourceTree/ResolveContext.cs @@ -0,0 +1,498 @@ +using FFXIVClientStructs.FFXIV.Client.Graphics.Kernel; +using FFXIVClientStructs.FFXIV.Client.Graphics.Render; +using FFXIVClientStructs.FFXIV.Client.System.Resource.Handle; +using FFXIVClientStructs.Interop; +using OtterGui.Extensions; +using OtterGui.Text.HelperObjects; +using Penumbra.Api.Enums; +using Penumbra.Collections; +using Penumbra.GameData.Data; +using Penumbra.GameData.Enums; +using Penumbra.GameData.Structs; +using Penumbra.Interop.Hooks.PostProcessing; +using Penumbra.Interop.PathResolving; +using Penumbra.Meta; +using Penumbra.String; +using Penumbra.String.Classes; +using Penumbra.UI; +using static Penumbra.Interop.Structs.StructExtensions; +using CharaBase = FFXIVClientStructs.FFXIV.Client.Graphics.Scene.CharacterBase; + +namespace Penumbra.Interop.ResourceTree; + +internal record GlobalResolveContext( + MetaFileManager MetaFileManager, + ObjectIdentification Identifier, + ModCollection Collection, + TreeBuildCache TreeBuildCache, + bool WithUiData) +{ + public readonly Dictionary<(Utf8GamePath, nint), ResourceNode> Nodes = new(128); + + public unsafe ResolveContext CreateContext(CharaBase* characterBase, uint slotIndex = 0xFFFFFFFFu, + FullEquipType slot = FullEquipType.Unknown, CharacterArmor equipment = default, SecondaryId secondaryId = default) + => new(this, characterBase, slotIndex, slot, equipment, secondaryId); +} + +internal unsafe partial record ResolveContext( + GlobalResolveContext Global, + Pointer CharacterBasePointer, + uint SlotIndex, + FullEquipType Slot, + CharacterArmor Equipment, + SecondaryId SecondaryId) +{ + public CharaBase* CharacterBase + => CharacterBasePointer.Value; + + private static readonly CiByteString ShpkPrefix = CiByteString.FromSpanUnsafe("shader/sm5/shpk"u8, true, true, true); + + private CharaBase.ModelType ModelType + => CharacterBase->GetModelType(); + + private ResourceNode? CreateNodeFromShpk(ShaderPackageResourceHandle* resourceHandle, CiByteString gamePath) + { + if (resourceHandle is null) + return null; + if (gamePath.IsEmpty) + return null; + if (!Utf8GamePath.FromByteString(CiByteString.Join((byte)'/', ShpkPrefix, gamePath), out var path)) + return null; + + return GetOrCreateNode(ResourceType.Shpk, (nint)resourceHandle->ShaderPackage, (ResourceHandle*)resourceHandle, path); + } + + [SkipLocalsInit] + private ResourceNode? CreateNodeFromTex(TextureResourceHandle* resourceHandle, CiByteString gamePath, bool dx11) + { + if (resourceHandle is null) + return null; + + Utf8GamePath path; + if (dx11) + { + var lastDirectorySeparator = gamePath.LastIndexOf((byte)'/'); + if (lastDirectorySeparator == -1 || lastDirectorySeparator > gamePath.Length - 3) + return null; + + Span prefixed = stackalloc byte[CharaBase.PathBufferSize]; + + var writer = new SpanTextWriter(prefixed); + writer.Append(gamePath.Span[..(lastDirectorySeparator + 1)]); + writer.Append((byte)'-'); + writer.Append((byte)'-'); + writer.Append(gamePath.Span[(lastDirectorySeparator + 1)..]); + writer.EnsureNullTerminated(); + + if (!Utf8GamePath.FromSpan(prefixed[..(gamePath.Length + 2)], MetaDataComputation.None, out var tmp)) + return null; + + path = tmp.Clone(); + } + else + { + // Make sure the game path is owned, otherwise stale trees could cause crashes (access violations) or other memory safety issues. + if (!gamePath.IsOwned) + gamePath = gamePath.Clone(); + + if (!Utf8GamePath.FromByteString(gamePath, out path)) + return null; + } + + return GetOrCreateNode(ResourceType.Tex, (nint)resourceHandle->Texture, &resourceHandle->ResourceHandle, path); + } + + private ResourceNode GetOrCreateNode(ResourceType type, nint objectAddress, ResourceHandle* resourceHandle, + Utf8GamePath gamePath) + { + if (resourceHandle is null) + throw new ArgumentNullException(nameof(resourceHandle)); + + if (Global.Nodes.TryGetValue((gamePath, (nint)resourceHandle), out var cached)) + return cached; + + return CreateNode(type, objectAddress, resourceHandle, gamePath); + } + + private ResourceNode CreateNode(ResourceType type, nint objectAddress, ResourceHandle* resourceHandle, + Utf8GamePath gamePath, bool autoAdd = true) + { + if (resourceHandle is null) + throw new ArgumentNullException(nameof(resourceHandle)); + + var fileName = (ReadOnlySpan)resourceHandle->FileName.AsSpan(); + var additionalData = CiByteString.Empty; + if (PathDataHandler.Split(fileName, out fileName, out var data)) + additionalData = CiByteString.FromSpanUnsafe(data, false).Clone(); + + var fullPath = Utf8GamePath.FromSpan(fileName, MetaDataComputation.None, out var p) ? new FullPath(p.Clone()) : FullPath.Empty; + + var node = new ResourceNode(type, objectAddress, (nint)resourceHandle, GetResourceHandleLength(resourceHandle), this) + { + GamePath = gamePath, + FullPath = fullPath, + AdditionalData = additionalData, + }; + if (autoAdd) + Global.Nodes.Add((gamePath, (nint)resourceHandle), node); + + return node; + } + + public ResourceNode? CreateNodeFromEid(ResourceHandle* eid) + { + if (eid is null) + return null; + + if (!Utf8GamePath.FromByteString(CharacterBase->ResolveEidPathAsByteString(), out var path)) + return null; + + return GetOrCreateNode(ResourceType.Eid, 0, eid, path); + } + + public ResourceNode? CreateNodeFromImc(ResourceHandle* imc) + { + if (imc is null) + return null; + + if (!Utf8GamePath.FromByteString(CharacterBase->ResolveImcPathAsByteString(SlotIndex), out var path)) + return null; + + return GetOrCreateNode(ResourceType.Imc, 0, imc, path); + } + + public ResourceNode? CreateNodeFromPbd(ResourceHandle* pbd) + { + if (pbd is null) + return null; + + return GetOrCreateNode(ResourceType.Pbd, 0, pbd, PreBoneDeformerReplacer.PreBoneDeformerPath); + } + + public ResourceNode? CreateNodeFromTex(TextureResourceHandle* tex, string gamePath) + { + if (tex is null) + return null; + + if (!Utf8GamePath.FromString(gamePath, out var path)) + return null; + + return GetOrCreateNode(ResourceType.Tex, (nint)tex->Texture, &tex->ResourceHandle, path); + } + + public ResourceNode? CreateNodeFromTex(TextureResourceHandle* tex, Utf8GamePath gamePath) + { + if (tex is null) + return null; + + return GetOrCreateNode(ResourceType.Tex, (nint)tex->Texture, &tex->ResourceHandle, gamePath); + } + + public ResourceNode? CreateNodeFromModel(Model* mdl, ResourceHandle* imc, TextureResourceHandle* decalHandle, + MaterialResourceHandle* skinMtrlHandle, ResourceHandle* mpapHandle) + { + if (mdl is null || mdl->ModelResourceHandle is null) + return null; + + var mdlResource = mdl->ModelResourceHandle; + + var path = ResolveModelPath(); + + if (Global.Nodes.TryGetValue((path, (nint)mdlResource), out var cached)) + return cached; + + var node = CreateNode(ResourceType.Mdl, (nint)mdl, &mdlResource->ResourceHandle, path, false); + + for (var i = 0; i < mdl->MaterialCount; i++) + { + var mtrl = mdl->Materials[i]; + if (mtrl is null) + continue; + + var mtrlFileName = mdlResource->GetMaterialFileNameBySlot((uint)i); + var mtrlNode = CreateNodeFromMaterial(mtrl, ResolveMaterialPath(path, imc, mtrlFileName)); + if (mtrlNode is not null) + { + if (Global.WithUiData) + mtrlNode.FallbackName = $"Material #{i}"; + node.Children.Add(mtrlNode); + } + } + + if (skinMtrlHandle is not null + && Utf8GamePath.FromByteString(CharacterBase->ResolveSkinMtrlPathAsByteString(SlotIndex), out var skinMtrlPath) + && CreateNodeFromMaterial(skinMtrlHandle->Material, skinMtrlPath) is + { } skinMaaterialNode) + node.Children.Add(skinMaaterialNode); + + if (CreateNodeFromDecal(decalHandle, imc) is { } decalNode) + node.Children.Add(decalNode); + + if (CreateNodeFromMaterialPap(mpapHandle, imc) is { } mpapNode) + node.Children.Add(mpapNode); + + Global.Nodes.Add((path, (nint)mdl->ModelResourceHandle), node); + + return node; + } + + private ResourceNode? CreateNodeFromMaterial(Material* mtrl, Utf8GamePath path) + { + if (mtrl is null || mtrl->MaterialResourceHandle is null) + return null; + + var resource = mtrl->MaterialResourceHandle; + if (Global.Nodes.TryGetValue((path, (nint)resource), out var cached)) + return cached; + + var node = CreateNode(ResourceType.Mtrl, (nint)mtrl, (ResourceHandle*)resource, path, false); + var shpkNode = CreateNodeFromShpk(resource->ShaderPackageResourceHandle, new CiByteString(resource->ShpkName.Value)); + if (shpkNode is not null) + { + if (Global.WithUiData) + shpkNode.Name = "Shader Package"; + node.Children.Add(shpkNode); + } + + var shpkNames = Global.WithUiData && shpkNode is not null ? Global.TreeBuildCache.ReadShaderPackageNames(shpkNode.FullPath) : null; + var shpk = Global.WithUiData && shpkNode is not null ? (ShaderPackage*)shpkNode.ObjectAddress : null; + + var alreadyProcessedSamplerIds = new HashSet(); + for (var i = 0; i < resource->TextureCount; i++) + { + var texNode = CreateNodeFromTex(resource->Textures[i].TextureResourceHandle, new CiByteString(resource->TexturePath(i).Value), + resource->Textures[i].IsDX11); + if (texNode == null) + continue; + + if (Global.WithUiData) + { + string? name = null; + if (shpk is not null) + { + var index = GetTextureIndex(mtrl, resource->Textures[i].Flags, alreadyProcessedSamplerIds); + var samplerId = index != 0x001F + ? mtrl->Textures[index].Id + : GetTextureSamplerId(mtrl, resource->Textures[i].TextureResourceHandle, alreadyProcessedSamplerIds); + if (samplerId.HasValue) + { + alreadyProcessedSamplerIds.Add(samplerId.Value); + var textureCrc = GetTextureCrcById(shpk, samplerId.Value); + if (textureCrc.HasValue) + { + if (shpkNames != null && shpkNames.TryGetValue(textureCrc.Value, out var samplerName)) + name = samplerName.Value; + else + name = $"Texture 0x{textureCrc.Value:X8}"; + } + } + } + + texNode = texNode.Clone(); + texNode.Name = name ?? $"Texture #{i}"; + } + + node.Children.Add(texNode); + } + + Global.Nodes.Add((path, (nint)resource), node); + + return node; + + static uint? GetTextureCrcById(ShaderPackage* shpk, uint id) + => shpk->TexturesSpan.FindFirst(t => t.Id == id, out var t) + ? t.CRC + : null; + + static uint? GetTextureSamplerId(Material* mtrl, TextureResourceHandle* handle, HashSet alreadyVisitedSamplerIds) + => mtrl->TexturesSpan.FindFirst(p => p.Texture == handle && !alreadyVisitedSamplerIds.Contains(p.Id), out var p) + ? p.Id + : null; + + static ushort GetTextureIndex(Material* mtrl, ushort texFlags, HashSet alreadyVisitedSamplerIds) + { + if ((texFlags & 0x001F) != 0x001F && !alreadyVisitedSamplerIds.Contains(mtrl->Textures[texFlags & 0x001F].Id)) + return (ushort)(texFlags & 0x001F); + if ((texFlags & 0x03E0) != 0x03E0 && !alreadyVisitedSamplerIds.Contains(mtrl->Textures[(texFlags >> 5) & 0x001F].Id)) + return (ushort)((texFlags >> 5) & 0x001F); + if ((texFlags & 0x7C00) != 0x7C00 && !alreadyVisitedSamplerIds.Contains(mtrl->Textures[(texFlags >> 10) & 0x001F].Id)) + return (ushort)((texFlags >> 10) & 0x001F); + + return 0x001F; + } + } + + public ResourceNode? CreateNodeFromDecal(TextureResourceHandle* decalHandle, ResourceHandle* imc) + { + if (decalHandle is null) + return null; + + var path = ResolveDecalPath(imc); + if (path.IsEmpty) + return null; + + var node = CreateNodeFromTex(decalHandle, path)!; + if (Global.WithUiData) + node.FallbackName = "Decal"; + + return node; + } + + public ResourceNode? CreateNodeFromMaterialPap(ResourceHandle* mpapHandle, ResourceHandle* imc) + { + if (mpapHandle is null) + return null; + + var path = ResolveMaterialAnimationPath(imc); + if (path.IsEmpty) + return null; + + if (Global.Nodes.TryGetValue((path, (nint)mpapHandle), out var cached)) + return cached; + + var node = CreateNode(ResourceType.Pap, 0, mpapHandle, path); + if (Global.WithUiData) + node.FallbackName = "Material Animation"; + + return node; + } + + public ResourceNode? CreateNodeFromMaterialSklb(SkeletonResourceHandle* sklbHandle) + { + if (sklbHandle is null) + return null; + + if (Global.Nodes.TryGetValue((GamePaths.Sklb.MaterialAnimationSkeletonUtf8, (nint)sklbHandle), out var cached)) + return cached; + + var node = CreateNode(ResourceType.Sklb, 0, (ResourceHandle*)sklbHandle, GamePaths.Sklb.MaterialAnimationSkeletonUtf8); + node.ForceInternal = true; + + return node; + } + + public ResourceNode? CreateNodeFromPartialSkeleton(PartialSkeleton* sklb, ResourceHandle* phybHandle, ResourceHandle* kdbHandle, + uint partialSkeletonIndex) + { + if (sklb is null || sklb->SkeletonResourceHandle is null) + return null; + + var path = ResolveSkeletonPath(partialSkeletonIndex); + + if (Global.Nodes.TryGetValue((path, (nint)sklb->SkeletonResourceHandle), out var cached)) + return cached; + + var node = CreateNode(ResourceType.Sklb, (nint)sklb, (ResourceHandle*)sklb->SkeletonResourceHandle, path, false); + if (CreateParameterNodeFromPartialSkeleton(sklb, partialSkeletonIndex) is { } skpNode) + node.Children.Add(skpNode); + if (CreateNodeFromPhyb(phybHandle, partialSkeletonIndex) is { } phybNode) + node.Children.Add(phybNode); + if (CreateNodeFromKdb(kdbHandle, partialSkeletonIndex) is { } kdbNode) + node.Children.Add(kdbNode); + Global.Nodes.Add((path, (nint)sklb->SkeletonResourceHandle), node); + + return node; + } + + private ResourceNode? CreateParameterNodeFromPartialSkeleton(PartialSkeleton* sklb, uint partialSkeletonIndex) + { + if (sklb is null || sklb->SkeletonParameterResourceHandle is null) + return null; + + var path = ResolveSkeletonParameterPath(partialSkeletonIndex); + + if (Global.Nodes.TryGetValue((path, (nint)sklb->SkeletonParameterResourceHandle), out var cached)) + return cached; + + var node = CreateNode(ResourceType.Skp, (nint)sklb, (ResourceHandle*)sklb->SkeletonParameterResourceHandle, path, false); + if (Global.WithUiData) + node.FallbackName = "Skeleton Parameters"; + Global.Nodes.Add((path, (nint)sklb->SkeletonParameterResourceHandle), node); + + return node; + } + + private ResourceNode? CreateNodeFromPhyb(ResourceHandle* phybHandle, uint partialSkeletonIndex) + { + if (phybHandle is null) + return null; + + var path = ResolvePhysicsModulePath(partialSkeletonIndex); + + if (Global.Nodes.TryGetValue((path, (nint)phybHandle), out var cached)) + return cached; + + var node = CreateNode(ResourceType.Phyb, 0, phybHandle, path, false); + if (Global.WithUiData) + node.FallbackName = "Physics Module"; + Global.Nodes.Add((path, (nint)phybHandle), node); + + return node; + } + + private ResourceNode? CreateNodeFromKdb(ResourceHandle* kdbHandle, uint partialSkeletonIndex) + { + if (kdbHandle is null) + return null; + + var path = ResolveKineDriverModulePath(partialSkeletonIndex); + + if (Global.Nodes.TryGetValue((path, (nint)kdbHandle), out var cached)) + return cached; + + var node = CreateNode(ResourceType.Kdb, 0, kdbHandle, path, false); + if (Global.WithUiData) + node.FallbackName = "KineDriver Module"; + Global.Nodes.Add((path, (nint)kdbHandle), node); + + return node; + } + + internal ResourceNode.UiData GuessModelUiData(Utf8GamePath gamePath) + { + var path = gamePath.Path.Split((byte)'/'); + // Weapons intentionally left out. + var isEquipment = path.Count >= 2 + && path[0].Span.SequenceEqual("chara"u8) + && (path[1].Span.SequenceEqual("accessory"u8) || path[1].Span.SequenceEqual("equipment"u8)); + if (isEquipment) + foreach (var item in Global.Identifier.Identify(Equipment.Set, 0, Equipment.Variant, Slot.ToSlot())) + { + var name = item.Name; + if (Slot is FullEquipType.Finger) + name = SlotIndex switch + { + 8 => "R: " + name, + 9 => "L: " + name, + _ => name, + }; + return new ResourceNode.UiData(name, item.Type.GetCategoryIcon().ToFlag()); + } + + var dataFromPath = GuessUiDataFromPath(gamePath); + if (dataFromPath.Name is not null) + return dataFromPath; + + return isEquipment + ? new ResourceNode.UiData(Slot.ToName(), Slot.GetCategoryIcon().ToFlag()) + : new ResourceNode.UiData(null, ChangedItemIconFlag.Unknown); + } + + internal ResourceNode.UiData GuessUiDataFromPath(Utf8GamePath gamePath) + { + const string customization = "Customization: "; + foreach (var obj in Global.Identifier.Identify(gamePath.ToString())) + { + var name = obj.Key; + if (name.StartsWith(customization)) + name = name.AsSpan(14).Trim().ToString(); + if (name is not "Unknown") + return new ResourceNode.UiData(name, obj.Value.GetIcon().ToFlag()); + } + + return new ResourceNode.UiData(null, ChangedItemIconFlag.Unknown); + } + + private static ulong GetResourceHandleLength(ResourceHandle* handle) + => handle is null ? 0ul : handle->GetLength(); +} diff --git a/Penumbra/Interop/ResourceTree/ResourceNode.cs b/Penumbra/Interop/ResourceTree/ResourceNode.cs new file mode 100644 index 00000000..08dee818 --- /dev/null +++ b/Penumbra/Interop/ResourceTree/ResourceNode.cs @@ -0,0 +1,123 @@ +using Penumbra.Api.Enums; +using Penumbra.Mods; +using Penumbra.String; +using Penumbra.String.Classes; +using Penumbra.UI; + +namespace Penumbra.Interop.ResourceTree; + +public class ResourceNode : ICloneable +{ + public string? Name; + public string? FallbackName; + public ChangedItemIconFlag IconFlag; + public readonly ResourceType Type; + public readonly nint ObjectAddress; + public readonly nint ResourceHandle; + public Utf8GamePath[] PossibleGamePaths; + public FullPath FullPath; + public PathStatus FullPathStatus; + public bool ForceInternal; + public bool ForceProtected; + public string? ModName; + public readonly WeakReference Mod = new(null!); + public string? ModRelativePath; + public CiByteString AdditionalData; + public readonly ulong Length; + public readonly List Children; + internal ResolveContext? ResolveContext; + + public Utf8GamePath GamePath + { + get => PossibleGamePaths.Length == 1 ? PossibleGamePaths[0] : Utf8GamePath.Empty; + set + { + if (value.IsEmpty) + PossibleGamePaths = []; + else + PossibleGamePaths = [value]; + } + } + + /// Whether to treat the file as internal (hide from user unless debug mode is on). + public bool Internal + => ForceInternal || Type is ResourceType.Eid or ResourceType.Imc; + + /// Whether to treat the file as protected (require holding the Mod Deletion Modifier to make a quick import). + public bool Protected + => ForceProtected + || Internal + || Type is ResourceType.Shpk or ResourceType.Sklb or ResourceType.Skp or ResourceType.Phyb or ResourceType.Kdb or ResourceType.Pbd; + + internal ResourceNode(ResourceType type, nint objectAddress, nint resourceHandle, ulong length, ResolveContext? resolveContext) + { + Type = type; + ObjectAddress = objectAddress; + ResourceHandle = resourceHandle; + PossibleGamePaths = []; + AdditionalData = CiByteString.Empty; + Length = length; + Children = new List(); + ResolveContext = resolveContext; + } + + private ResourceNode(ResourceNode other) + { + Name = other.Name; + FallbackName = other.FallbackName; + IconFlag = other.IconFlag; + Type = other.Type; + ObjectAddress = other.ObjectAddress; + ResourceHandle = other.ResourceHandle; + PossibleGamePaths = other.PossibleGamePaths; + FullPath = other.FullPath; + FullPathStatus = other.FullPathStatus; + ModName = other.ModName; + Mod = other.Mod; + ModRelativePath = other.ModRelativePath; + AdditionalData = other.AdditionalData; + ForceInternal = other.ForceInternal; + ForceProtected = other.ForceProtected; + Length = other.Length; + Children = other.Children; + ResolveContext = other.ResolveContext; + } + + public ResourceNode Clone() + => new(this); + + object ICloneable.Clone() + => Clone(); + + public void ProcessPostfix(Action action, ResourceNode? parent) + { + foreach (var child in Children) + child.ProcessPostfix(action, this); + action(this, parent); + } + + public void SetUiData(UiData uiData) + { + Name = uiData.Name; + IconFlag = uiData.IconFlag; + } + + public void PrependName(string prefix) + { + if (Name != null) + Name = prefix + Name; + } + + public readonly record struct UiData(string? Name, ChangedItemIconFlag IconFlag) + { + public UiData PrependName(string prefix) + => Name == null ? this : this with { Name = prefix + Name }; + } + + public enum PathStatus : byte + { + Valid, + NonExistent, + External, + } +} diff --git a/Penumbra/Interop/ResourceTree/ResourceTree.cs b/Penumbra/Interop/ResourceTree/ResourceTree.cs new file mode 100644 index 00000000..1ebfe53d --- /dev/null +++ b/Penumbra/Interop/ResourceTree/ResourceTree.cs @@ -0,0 +1,285 @@ +using FFXIVClientStructs.FFXIV.Client.Game.Character; +using FFXIVClientStructs.FFXIV.Client.Graphics.Physics; +using FFXIVClientStructs.FFXIV.Client.Graphics.Render; +using FFXIVClientStructs.FFXIV.Client.Graphics.Scene; +using FFXIVClientStructs.FFXIV.Client.System.Resource.Handle; +using FFXIVClientStructs.Interop; +using Penumbra.GameData.Data; +using Penumbra.GameData.Enums; +using Penumbra.GameData.Structs; +using Penumbra.Interop.Hooks.PostProcessing; +using Penumbra.UI; +using CustomizeData = FFXIVClientStructs.FFXIV.Client.Game.Character.CustomizeData; +using CustomizeIndex = Dalamud.Game.ClientState.Objects.Enums.CustomizeIndex; +using ModelType = FFXIVClientStructs.FFXIV.Client.Graphics.Scene.CharacterBase.ModelType; +using ResourceHandle = FFXIVClientStructs.FFXIV.Client.System.Resource.Handle.ResourceHandle; + +namespace Penumbra.Interop.ResourceTree; + +public class ResourceTree( + string name, + string anonymizedName, + int gameObjectIndex, + nint gameObjectAddress, + nint drawObjectAddress, + bool localPlayerRelated, + bool playerRelated, + bool networked, + string collectionName, + string anonymizedCollectionName) +{ + public readonly string Name = name; + public readonly string AnonymizedName = anonymizedName; + public readonly int GameObjectIndex = gameObjectIndex; + public readonly nint GameObjectAddress = gameObjectAddress; + public readonly nint DrawObjectAddress = drawObjectAddress; + public readonly bool LocalPlayerRelated = localPlayerRelated; + public readonly bool PlayerRelated = playerRelated; + public readonly bool Networked = networked; + public readonly string CollectionName = collectionName; + public readonly string AnonymizedCollectionName = anonymizedCollectionName; + public readonly List Nodes = []; + public readonly HashSet FlatNodes = []; + + public int ModelId; + public CustomizeData CustomizeData; + public GenderRace RaceCode; + + public void ProcessPostfix(Action action) + { + foreach (var node in Nodes) + node.ProcessPostfix(action, null); + } + + internal unsafe void LoadResources(GlobalResolveContext globalContext) + { + var character = (Character*)GameObjectAddress; + var model = (CharacterBase*)DrawObjectAddress; + var modelType = model->GetModelType(); + var human = modelType == ModelType.Human ? (Human*)model : null; + var equipment = modelType switch + { + ModelType.Human => new ReadOnlySpan(&human->Head, 12), + ModelType.DemiHuman => new ReadOnlySpan( + Unsafe.AsPointer(ref character->DrawData.EquipmentModelIds[0]), 10), + _ => [], + }; + ModelId = character->ModelContainer.ModelCharaId; + CustomizeData = character->DrawData.CustomizeData; + RaceCode = human is not null ? (GenderRace)human->RaceSexId : GenderRace.Unknown; + + var genericContext = globalContext.CreateContext(model); + + var mpapArrayPtr = model->MaterialAnimationPacks; + var mpapArray = mpapArrayPtr is not null ? new ReadOnlySpan>(mpapArrayPtr, model->SlotCount) : []; + var skinMtrlArray = modelType switch + { + ModelType.Human => ((Human*) model)->SlotSkinMaterials, + _ => [], + }; + var decalArray = modelType switch + { + ModelType.Human => human->SlotDecals, + ModelType.DemiHuman => ((Demihuman*)model)->SlotDecals, + ModelType.Weapon => [((Weapon*)model)->Decal], + ModelType.Monster => [((Monster*)model)->Decal], + _ => [], + }; + + for (var i = 0u; i < model->SlotCount; ++i) + { + var slotContext = modelType switch + { + ModelType.Human => i switch + { + < 10 => globalContext.CreateContext(model, i, i.ToEquipSlot().ToEquipType(), equipment[(int)i]), + 16 => globalContext.CreateContext(model, i, FullEquipType.Glasses, equipment[10]), + 17 => globalContext.CreateContext(model, i, FullEquipType.Unknown, equipment[11]), + _ => globalContext.CreateContext(model, i), + }, + _ => i < equipment.Length + ? globalContext.CreateContext(model, i, i.ToEquipSlot().ToEquipType(), equipment[(int)i]) + : globalContext.CreateContext(model, i), + }; + + var imc = (ResourceHandle*)model->IMCArray[i]; + if (slotContext.CreateNodeFromImc(imc) is { } imcNode) + { + if (globalContext.WithUiData) + imcNode.FallbackName = $"IMC #{i}"; + Nodes.Add(imcNode); + } + + var mdl = model->Models[i]; + if (slotContext.CreateNodeFromModel(mdl, imc, i < decalArray.Length ? decalArray[(int)i].Value : null, + i < skinMtrlArray.Length ? skinMtrlArray[(int)i].Value : null, i < mpapArray.Length ? mpapArray[(int)i].Value : null) is + { } mdlNode) + { + if (globalContext.WithUiData) + mdlNode.FallbackName = $"Model #{i}"; + Nodes.Add(mdlNode); + } + } + + AddSkeleton(Nodes, genericContext, model); + AddMaterialAnimationSkeleton(Nodes, genericContext, model->MaterialAnimationSkeleton); + + AddWeapons(globalContext, model); + + if (human is not null) + AddHumanResources(globalContext, human); + } + + private unsafe void AddWeapons(GlobalResolveContext globalContext, CharacterBase* model) + { + var weaponIndex = 0; + var weaponNodes = new List(); + foreach (var baseSubObject in model->DrawObject.Object.ChildObjects) + { + if (baseSubObject->GetObjectType() is not FFXIVClientStructs.FFXIV.Client.Graphics.Scene.ObjectType.CharacterBase) + continue; + + var subObject = (CharacterBase*)baseSubObject; + + if (subObject->GetModelType() is not ModelType.Weapon) + continue; + + var weapon = (Weapon*)subObject; + + // This way to tell apart MainHand and OffHand is not always accurate, but seems good enough for what we're doing with it. + var slot = weaponIndex > 0 ? FullEquipType.UnknownOffhand : FullEquipType.UnknownMainhand; + var equipment = new CharacterArmor(weapon->ModelSetId, (byte)weapon->Variant, new StainIds(weapon->Stain0, weapon->Stain1)); + var weaponType = weapon->SecondaryId; + + var genericContext = globalContext.CreateContext(subObject, 0xFFFFFFFFu, slot, equipment, weaponType); + + var mpapArrayPtr = subObject->MaterialAnimationPacks; + var mpapArray = mpapArrayPtr is not null ? new ReadOnlySpan>(mpapArrayPtr, subObject->SlotCount) : []; + + for (var i = 0; i < subObject->SlotCount; ++i) + { + var slotContext = globalContext.CreateContext(subObject, (uint)i, slot, equipment, weaponType); + + var imc = (ResourceHandle*)subObject->IMCArray[i]; + if (slotContext.CreateNodeFromImc(imc) is { } imcNode) + { + if (globalContext.WithUiData) + imcNode.FallbackName = $"Weapon #{weaponIndex}, IMC #{i}"; + weaponNodes.Add(imcNode); + } + + var mdl = subObject->Models[i]; + if (slotContext.CreateNodeFromModel(mdl, imc, weapon->Decal, null, i < mpapArray.Length ? mpapArray[i].Value : null) is + { } mdlNode) + { + if (globalContext.WithUiData) + mdlNode.FallbackName = $"Weapon #{weaponIndex}, Model #{i}"; + weaponNodes.Add(mdlNode); + } + } + + AddSkeleton(weaponNodes, genericContext, subObject, $"Weapon #{weaponIndex}, "); + AddMaterialAnimationSkeleton(weaponNodes, genericContext, subObject->MaterialAnimationSkeleton, + $"Weapon #{weaponIndex}, "); + + ++weaponIndex; + } + + Nodes.InsertRange(0, weaponNodes); + } + + private unsafe void AddHumanResources(GlobalResolveContext globalContext, Human* human) + { + var genericContext = globalContext.CreateContext(&human->CharacterBase); + + var cache = globalContext.Collection._cache; + if (cache is not null + && cache.CustomResources.TryGetValue(PreBoneDeformerReplacer.PreBoneDeformerPath, out var pbdHandle) + && genericContext.CreateNodeFromPbd(pbdHandle.ResourceHandle) is { } pbdNode) + { + if (globalContext.WithUiData) + { + pbdNode = pbdNode.Clone(); + pbdNode.FallbackName = "Racial Deformer"; + pbdNode.IconFlag = ChangedItemIconFlag.Customization; + } + + Nodes.Add(pbdNode); + } + + var decalId = (byte)(human->Customize[(int)CustomizeIndex.Facepaint] & 0x7F); + var decalPath = decalId is not 0 + ? GamePaths.Tex.FaceDecal(decalId) + : GamePaths.Tex.Transparent; + if (genericContext.CreateNodeFromTex(human->Decal, decalPath) is { } decalNode) + { + if (globalContext.WithUiData) + { + decalNode = decalNode.Clone(); + decalNode.FallbackName = "Face Decal"; + decalNode.IconFlag = ChangedItemIconFlag.Customization; + } + + Nodes.Add(decalNode); + } + + var hasLegacyDecal = (human->Customize[(int)CustomizeIndex.FaceFeatures] & 0x80) != 0; + var legacyDecalPath = hasLegacyDecal + ? GamePaths.Tex.LegacyDecal + : GamePaths.Tex.Transparent; + if (genericContext.CreateNodeFromTex(human->LegacyBodyDecal, legacyDecalPath) is { } legacyDecalNode) + { + legacyDecalNode.ForceProtected = !hasLegacyDecal; + if (globalContext.WithUiData) + { + legacyDecalNode = legacyDecalNode.Clone(); + legacyDecalNode.FallbackName = "Legacy Body Decal"; + legacyDecalNode.IconFlag = ChangedItemIconFlag.Customization; + } + + Nodes.Add(legacyDecalNode); + } + } + + private unsafe void AddSkeleton(List nodes, ResolveContext context, CharacterBase* model, string prefix = "") + => AddSkeleton(nodes, context, model->EID, model->Skeleton, model->BonePhysicsModule, model->BoneKineDriverModule, prefix); + + private unsafe void AddSkeleton(List nodes, ResolveContext context, void* eid, Skeleton* skeleton, BonePhysicsModule* physics, + BoneKineDriverModule* kineDriver, string prefix = "") + { + var eidNode = context.CreateNodeFromEid((ResourceHandle*)eid); + if (eidNode != null) + { + if (context.Global.WithUiData) + eidNode.FallbackName = $"{prefix}EID"; + Nodes.Add(eidNode); + } + + if (skeleton == null) + return; + + for (var i = 0; i < skeleton->PartialSkeletonCount; ++i) + { + var phybHandle = physics != null ? physics->BonePhysicsResourceHandles[i] : null; + var kdbHandle = kineDriver != null ? kineDriver->PartialSkeletonEntries[i].KineDriverResourceHandle : null; + if (context.CreateNodeFromPartialSkeleton(&skeleton->PartialSkeletons[i], phybHandle, kdbHandle, (uint)i) is { } sklbNode) + { + if (context.Global.WithUiData) + sklbNode.FallbackName = $"{prefix}Skeleton #{i}"; + nodes.Add(sklbNode); + } + } + } + + private unsafe void AddMaterialAnimationSkeleton(List nodes, ResolveContext context, SkeletonResourceHandle* sklbHandle, + string prefix = "") + { + var sklbNode = context.CreateNodeFromMaterialSklb(sklbHandle); + if (sklbNode is null) + return; + + if (context.Global.WithUiData) + sklbNode.FallbackName = $"{prefix}Material Animation Skeleton"; + nodes.Add(sklbNode); + } +} diff --git a/Penumbra/Interop/ResourceTree/ResourceTreeApiHelper.cs b/Penumbra/Interop/ResourceTree/ResourceTreeApiHelper.cs new file mode 100644 index 00000000..48690e98 --- /dev/null +++ b/Penumbra/Interop/ResourceTree/ResourceTreeApiHelper.cs @@ -0,0 +1,123 @@ +using Dalamud.Game.ClientState.Objects.Types; +using Newtonsoft.Json.Linq; +using Penumbra.Api.Enums; +using Penumbra.Api.Helpers; +using Penumbra.String.Classes; +using Penumbra.UI; + +namespace Penumbra.Interop.ResourceTree; + +internal static class ResourceTreeApiHelper +{ + public static Dictionary>> GetResourcePathDictionaries( + IEnumerable<(ICharacter, ResourceTree)> resourceTrees) + { + var pathDictionaries = new Dictionary>>(4); + + foreach (var (gameObject, resourceTree) in resourceTrees) + { + if (pathDictionaries.ContainsKey(gameObject.ObjectIndex)) + continue; + + var pathDictionary = new Dictionary>(); + pathDictionaries.Add(gameObject.ObjectIndex, pathDictionary); + + CollectResourcePaths(pathDictionary, resourceTree); + } + + return pathDictionaries; + } + + private static void CollectResourcePaths(Dictionary> pathDictionary, ResourceTree resourceTree) + { + foreach (var node in resourceTree.FlatNodes) + { + if (node.PossibleGamePaths.Length == 0) + continue; + + var fullPath = node.FullPath.ToPath(); + if (!pathDictionary.TryGetValue(fullPath, out var gamePaths)) + { + gamePaths = []; + pathDictionary.Add(fullPath, gamePaths); + } + + foreach (var gamePath in node.PossibleGamePaths) + gamePaths.Add(gamePath.ToString()); + } + } + + public static Dictionary GetResourcesOfType(IEnumerable<(ICharacter, ResourceTree)> resourceTrees, + ResourceType type) + { + var resDictionaries = new Dictionary(4); + foreach (var (gameObject, resourceTree) in resourceTrees) + { + if (resDictionaries.ContainsKey(gameObject.ObjectIndex)) + continue; + + var resDictionary = new Dictionary(); + resDictionaries.Add(gameObject.ObjectIndex, new GameResourceDict(resDictionary)); + + foreach (var node in resourceTree.FlatNodes) + { + if (node.Type != type) + continue; + if (resDictionary.ContainsKey(node.ResourceHandle)) + continue; + + var fullPath = node.FullPath.ToPath(); + resDictionary.Add(node.ResourceHandle, (fullPath, node.Name ?? string.Empty, (uint)node.IconFlag.ToApiIcon())); + } + } + + return resDictionaries; + } + + public static Dictionary EncapsulateResourceTrees(IEnumerable<(ICharacter, ResourceTree)> resourceTrees) + { + var resDictionary = new Dictionary(4); + foreach (var (gameObject, resourceTree) in resourceTrees) + { + if (resDictionary.ContainsKey(gameObject.ObjectIndex)) + continue; + + resDictionary.Add(gameObject.ObjectIndex, GetIpcTree(resourceTree)); + } + + return resDictionary; + + static JObject GetIpcTree(ResourceTree tree) + { + var ret = new JObject + { + [nameof(ResourceTreeDto.Name)] = tree.Name, + [nameof(ResourceTreeDto.RaceCode)] = (ushort)tree.RaceCode, + }; + var children = new JArray(); + foreach (var child in tree.Nodes) + children.Add(GetIpcNode(child)); + ret[nameof(ResourceTreeDto.Nodes)] = children; + return ret; + } + + static JObject GetIpcNode(ResourceNode node) + { + var ret = new JObject + { + [nameof(ResourceNodeDto.Type)] = new JValue(node.Type), + [nameof(ResourceNodeDto.Icon)] = new JValue(node.IconFlag.ToApiIcon()), + [nameof(ResourceNodeDto.Name)] = node.Name, + [nameof(ResourceNodeDto.GamePath)] = node.GamePath.Equals(Utf8GamePath.Empty) ? null : node.GamePath.ToString(), + [nameof(ResourceNodeDto.ActualPath)] = node.FullPath.ToString(), + [nameof(ResourceNodeDto.ObjectAddress)] = node.ObjectAddress, + [nameof(ResourceNodeDto.ResourceHandle)] = node.ResourceHandle, + }; + var children = new JArray(); + foreach (var child in node.Children) + children.Add(GetIpcNode(child)); + ret[nameof(ResourceNodeDto.Children)] = children; + return ret; + } + } +} diff --git a/Penumbra/Interop/ResourceTree/ResourceTreeFactory.cs b/Penumbra/Interop/ResourceTree/ResourceTreeFactory.cs new file mode 100644 index 00000000..49194c3a --- /dev/null +++ b/Penumbra/Interop/ResourceTree/ResourceTreeFactory.cs @@ -0,0 +1,230 @@ +using Dalamud.Game.ClientState.Objects.Types; +using Dalamud.Plugin.Services; +using FFXIVClientStructs.FFXIV.Client.Game.Object; +using OtterGui.Services; +using Penumbra.Api.Enums; +using Penumbra.GameData.Actors; +using Penumbra.GameData.Data; +using Penumbra.GameData.Enums; +using Penumbra.GameData.Interop; +using Penumbra.Interop.PathResolving; +using Penumbra.Meta; +using Penumbra.Mods.Manager; +using Penumbra.String.Classes; + +namespace Penumbra.Interop.ResourceTree; + +public class ResourceTreeFactory( + IDataManager gameData, + ObjectManager objects, + MetaFileManager metaFileManager, + CollectionResolver resolver, + ObjectIdentification objectIdentifier, + Configuration config, + ActorManager actors, + PathState pathState, + IFramework framework, + ModManager modManager) : IService +{ + private static readonly string ParentDirectoryPrefix = $"..{Path.DirectorySeparatorChar}"; + + private TreeBuildCache CreateTreeBuildCache() + => new(framework.IsInFrameworkUpdateThread ? objects : null, gameData, actors); + + private TreeBuildCache CreateTreeBuildCache(Flags flags) + => !framework.IsInFrameworkUpdateThread && flags.HasFlag(Flags.PopulateObjectTableData) + ? framework.RunOnFrameworkThread(CreateTreeBuildCache).Result + : CreateTreeBuildCache(); + + public IEnumerable GetLocalPlayerRelatedCharacters() + => framework.RunOnFrameworkThread(() => + { + var cache = CreateTreeBuildCache(); + return cache.GetLocalPlayerRelatedCharacters(); + }).Result; + + public IEnumerable<(ICharacter Character, ResourceTree ResourceTree)> FromObjectTable( + Flags flags) + { + var (cache, characters) = framework.RunOnFrameworkThread(() => + { + var cache = CreateTreeBuildCache(); + var characters = ((flags & Flags.LocalPlayerRelatedOnly) != 0 ? cache.GetLocalPlayerRelatedCharacters() : cache.GetCharacters()).ToArray(); + return (cache, characters); + }).Result; + + foreach (var character in characters) + { + var tree = FromCharacter(character, cache, flags); + if (tree != null) + yield return (character, tree); + } + } + + public IEnumerable<(ICharacter Character, ResourceTree ResourceTree)> FromCharacters( + IEnumerable characters, Flags flags) + { + var cache = CreateTreeBuildCache(flags); + foreach (var character in characters) + { + var tree = FromCharacter(character, cache, flags); + if (tree != null) + yield return (character, tree); + } + } + + public ResourceTree? FromCharacter(ICharacter character, Flags flags) + => FromCharacter(character, CreateTreeBuildCache(flags), flags); + + private unsafe ResourceTree? FromCharacter(ICharacter character, TreeBuildCache cache, Flags flags) + { + if (!character.IsValid()) + return null; + + var gameObjStruct = (GameObject*)character.Address; + var drawObjStruct = gameObjStruct->GetDrawObject(); + if (drawObjStruct == null) + return null; + + var collectionResolveData = resolver.IdentifyCollection(gameObjStruct, true); + if (!collectionResolveData.Valid) + return null; + + var localPlayerRelated = cache.IsLocalPlayerRelated(character); + var (name, anonymizedName, related) = GetCharacterName((GameObject*)character.Address); + var networked = character.EntityId != 0xE0000000; + var tree = new ResourceTree(name, anonymizedName, character.ObjectIndex, (nint)gameObjStruct, (nint)drawObjStruct, localPlayerRelated, related, + networked, collectionResolveData.ModCollection.Identity.Name, collectionResolveData.ModCollection.Identity.AnonymizedName); + var globalContext = new GlobalResolveContext(metaFileManager, objectIdentifier, collectionResolveData.ModCollection, + cache, (flags & Flags.WithUiData) != 0); + using (var _ = pathState.EnterInternalResolve()) + { + tree.LoadResources(globalContext); + } + + tree.FlatNodes.UnionWith(globalContext.Nodes.Values); + tree.ProcessPostfix((node, _) => tree.FlatNodes.Add(node)); + + // This is currently unneeded as we can resolve all paths by querying the draw object: + // ResolveGamePaths(tree, collectionResolveData.ModCollection); + if (globalContext.WithUiData) + { + ResolveUiData(tree); + ResolveModData(tree); + } + FilterFullPaths(tree, (flags & Flags.RedactExternalPaths) != 0 ? config.ModDirectory : null); + Cleanup(tree); + + return tree; + } + + private static void ResolveUiData(ResourceTree tree) + { + foreach (var node in tree.FlatNodes) + { + if (node.Name != null || node.PossibleGamePaths.Length == 0) + continue; + + var gamePath = node.PossibleGamePaths[0]; + node.SetUiData(node.Type switch + { + ResourceType.Imc => node.ResolveContext!.GuessModelUiData(gamePath).PrependName("IMC: "), + ResourceType.Mdl => node.ResolveContext!.GuessModelUiData(gamePath), + _ => node.ResolveContext!.GuessUiDataFromPath(gamePath), + }); + } + + tree.ProcessPostfix((node, parent) => + { + if (node.Name == parent?.Name) + node.Name = null; + }); + } + + private void ResolveModData(ResourceTree tree) + { + foreach (var node in tree.FlatNodes) + { + if (node.FullPath.IsRooted && modManager.TryIdentifyPath(node.FullPath.FullName, out var mod, out var relativePath)) + { + node.ModName = mod.Name; + node.Mod.SetTarget(mod); + node.ModRelativePath = relativePath; + } + } + } + + private static void FilterFullPaths(ResourceTree tree, string? onlyWithinPath) + { + foreach (var node in tree.FlatNodes) + { + node.FullPathStatus = GetPathStatus(node.FullPath, onlyWithinPath); + if (node.FullPathStatus != ResourceNode.PathStatus.Valid) + node.FullPath = FullPath.Empty; + } + + return; + + static ResourceNode.PathStatus GetPathStatus(FullPath fullPath, string? onlyWithinPath) + { + if (!fullPath.IsRooted) + return ResourceNode.PathStatus.Valid; + + if (onlyWithinPath != null) + { + var relPath = Path.GetRelativePath(onlyWithinPath, fullPath.FullName); + if (relPath == ".." || relPath.StartsWith(ParentDirectoryPrefix) || Path.IsPathRooted(relPath)) + return ResourceNode.PathStatus.External; + } + + return fullPath.Exists + ? ResourceNode.PathStatus.Valid + : ResourceNode.PathStatus.NonExistent; + } + } + + private static void Cleanup(ResourceTree tree) + { + foreach (var node in tree.FlatNodes) + { + node.Name ??= node.FallbackName; + + node.FallbackName = null; + node.ResolveContext = null; + } + } + + private unsafe (string Name, string AnonymizedName, bool PlayerRelated) GetCharacterName(GameObject* character) + { + var identifier = actors.FromObject(character, out var owner, true, false, false); + var identifierStr = identifier.ToString(); + return (identifierStr, identifier.Incognito(identifierStr), IsPlayerRelated(identifier, owner)); + } + + private unsafe bool IsPlayerRelated(GameObject* character) + { + if (character is null) + return false; + + var identifier = actors.FromObject(character, out var owner, true, false, false); + return IsPlayerRelated(identifier, owner); + } + + private unsafe bool IsPlayerRelated(ActorIdentifier identifier, Actor owner) + => identifier.Type switch + { + IdentifierType.Player => true, + IdentifierType.Owned => IsPlayerRelated(owner.AsObject), + _ => false, + }; + + [Flags] + public enum Flags + { + RedactExternalPaths = 1, + WithUiData = 2, + LocalPlayerRelatedOnly = 4, + WithOwnership = 8, + PopulateObjectTableData = 16, + } +} diff --git a/Penumbra/Interop/ResourceTree/TreeBuildCache.cs b/Penumbra/Interop/ResourceTree/TreeBuildCache.cs new file mode 100644 index 00000000..c0114412 --- /dev/null +++ b/Penumbra/Interop/ResourceTree/TreeBuildCache.cs @@ -0,0 +1,111 @@ +using System.IO.MemoryMappedFiles; +using Dalamud.Game.ClientState.Objects.Types; +using Dalamud.Plugin.Services; +using Penumbra.GameData.Actors; +using Penumbra.GameData.Enums; +using Penumbra.GameData.Files; +using Penumbra.GameData.Files.ShaderStructs; +using Penumbra.GameData.Files.Utility; +using Penumbra.GameData.Interop; +using Penumbra.GameData.Structs; +using Penumbra.String.Classes; + +namespace Penumbra.Interop.ResourceTree; + +internal readonly struct TreeBuildCache(ObjectManager? objects, IDataManager dataManager, ActorManager actors) +{ + private readonly Dictionary?> _shaderPackageNames = []; + + private readonly IGameObject? _player = objects?.GetDalamudObject(0); + + public unsafe bool IsLocalPlayerRelated(ICharacter character) + { + if (_player is null) + return false; + + var gameObject = (FFXIVClientStructs.FFXIV.Client.Game.Object.GameObject*)character.Address; + var parent = actors.ToCutsceneParent(gameObject->ObjectIndex); + var actualIndex = parent >= 0 ? (ushort)parent : gameObject->ObjectIndex; + return actualIndex switch + { + < 2 => true, + < (int)ScreenActor.CutsceneStart => gameObject->OwnerId == _player.EntityId, + _ => false, + }; + } + + public IEnumerable GetCharacters() + => objects is not null ? objects.Objects.OfType() : []; + + public IEnumerable GetLocalPlayerRelatedCharacters() + { + if (_player is null) + yield break; + + yield return (ICharacter)_player; + + var minion = objects!.GetDalamudObject(1); + if (minion is not null) + yield return (ICharacter)minion; + + var playerId = _player.EntityId; + for (var i = 2; i < ObjectIndex.CutsceneStart.Index; i += 2) + { + if (objects.GetDalamudObject(i) is ICharacter owned && owned.OwnerId == playerId) + yield return owned; + } + + for (var i = ObjectIndex.CutsceneStart.Index; i < ObjectIndex.CharacterScreen.Index; ++i) + { + var character = objects.GetDalamudObject((int) i) as ICharacter; + if (character == null) + continue; + + var parent = actors.ToCutsceneParent(i); + if (parent < 0) + continue; + + if (parent is 0 or 1 || objects.GetDalamudObject(parent)?.OwnerId == playerId) + yield return character; + } + } + + /// Try to read a shpk file from the given path and cache it on success. + public IReadOnlyDictionary? ReadShaderPackageNames(FullPath path) + => ReadFile(dataManager, path, _shaderPackageNames, bytes => ShpkFile.FastExtractNames(bytes.Span)); + + private static T? ReadFile(IDataManager dataManager, FullPath path, Dictionary cache, Func, T> parseFile) + where T : class + { + if (path.FullName.Length == 0) + return null; + + if (cache.TryGetValue(path, out var cached)) + return cached; + + var pathStr = path.ToPath(); + T? parsed; + try + { + if (path.IsRooted) + { + using var mmFile = MmioMemoryManager.CreateFromFile(pathStr, access: MemoryMappedFileAccess.Read); + parsed = parseFile(mmFile.Memory); + } + else + { + var bytes = dataManager.GetFile(pathStr)?.Data; + parsed = bytes != null ? parseFile(bytes) : null; + } + } + catch (Exception e) + { + Penumbra.Log.Error($"Could not read file {pathStr}:\n{e}"); + parsed = null; + } + + cache.Add(path, parsed); + + return parsed; + } +} diff --git a/Penumbra/Interop/SafeHandles/SafeResourceHandle.cs b/Penumbra/Interop/SafeHandles/SafeResourceHandle.cs new file mode 100644 index 00000000..a5e73867 --- /dev/null +++ b/Penumbra/Interop/SafeHandles/SafeResourceHandle.cs @@ -0,0 +1,41 @@ +using FFXIVClientStructs.FFXIV.Client.System.Resource.Handle; + +namespace Penumbra.Interop.SafeHandles; + +public unsafe class SafeResourceHandle : SafeHandle, ICloneable +{ + public ResourceHandle* ResourceHandle + => (ResourceHandle*)handle; + + public override bool IsInvalid + => handle == 0; + + public SafeResourceHandle(ResourceHandle* handle, bool incRef, bool ownsHandle = true) + : base(0, ownsHandle) + { + if (incRef && !ownsHandle) + throw new ArgumentException("Non-owning SafeResourceHandle with IncRef is unsupported"); + + if (incRef && handle != null) + handle->IncRef(); + SetHandle((nint)handle); + } + + public SafeResourceHandle Clone() + => new(ResourceHandle, true); + + object ICloneable.Clone() + => Clone(); + + public static SafeResourceHandle CreateInvalid() + => new(null, false); + + protected override bool ReleaseHandle() + { + var handle = Interlocked.Exchange(ref this.handle, 0); + if (handle != 0) + ((ResourceHandle*)handle)->DecRef(); + + return true; + } +} diff --git a/Penumbra/Interop/SafeHandles/SafeTextureHandle.cs b/Penumbra/Interop/SafeHandles/SafeTextureHandle.cs new file mode 100644 index 00000000..fd020804 --- /dev/null +++ b/Penumbra/Interop/SafeHandles/SafeTextureHandle.cs @@ -0,0 +1,49 @@ +using FFXIVClientStructs.FFXIV.Client.Graphics.Kernel; + +namespace Penumbra.Interop.SafeHandles; + +public unsafe class SafeTextureHandle : SafeHandle +{ + public Texture* Texture + => (Texture*)handle; + + public override bool IsInvalid + => handle == 0; + + public SafeTextureHandle(Texture* handle, bool incRef, bool ownsHandle = true) + : base(0, ownsHandle) + { + if (incRef && !ownsHandle) + throw new ArgumentException("Non-owning SafeTextureHandle with IncRef is unsupported"); + + if (incRef && handle != null) + handle->IncRef(); + SetHandle((nint)handle); + } + + public void Exchange(ref nint ppTexture) + { + lock (this) + { + handle = Interlocked.Exchange(ref ppTexture, handle); + } + } + + public static SafeTextureHandle CreateInvalid() + => new(null, false); + + protected override bool ReleaseHandle() + { + nint handle; + lock (this) + { + handle = this.handle; + this.handle = 0; + } + + if (handle != 0) + ((Texture*)handle)->DecRef(); + + return true; + } +} diff --git a/Penumbra/Interop/Services/CharacterUtility.cs b/Penumbra/Interop/Services/CharacterUtility.cs new file mode 100644 index 00000000..0add9d46 --- /dev/null +++ b/Penumbra/Interop/Services/CharacterUtility.cs @@ -0,0 +1,152 @@ +using Dalamud.Plugin.Services; +using Dalamud.Utility.Signatures; +using OtterGui.Services; +using Penumbra.Communication; +using Penumbra.GameData; +using Penumbra.Interop.Structs; + +namespace Penumbra.Interop.Services; + +public unsafe class CharacterUtility : IDisposable, IRequiredService +{ + public record struct InternalIndex(int Value); + + /// A static pointer to the CharacterUtility address. + [Signature(Sigs.CharacterUtility, ScanType = ScanType.StaticAddress)] + private readonly CharacterUtilityData** _characterUtilityAddress = null; + + /// Only required for migration anymore. + public delegate void LoadResources(CharacterUtilityData* address); + + [Signature(Sigs.LoadCharacterResources)] + public readonly LoadResources LoadCharacterResourcesFunc = null!; + + public void LoadCharacterResources() + => LoadCharacterResourcesFunc.Invoke(Address); + + public CharacterUtilityData* Address + => *_characterUtilityAddress; + + public bool Ready { get; private set; } + + public readonly CharacterUtilityFinished LoadingFinished = new(); + + public nint DefaultHumanPbdResource { get; private set; } + public nint DefaultTransparentResource { get; private set; } + public nint DefaultDecalResource { get; private set; } + public nint DefaultSkinShpkResource { get; private set; } + public nint DefaultCharacterStockingsShpkResource { get; private set; } + public nint DefaultCharacterLegacyShpkResource { get; private set; } + + /// + /// The relevant indices depend on which meta manipulations we allow for. + /// The defines are set in the project configuration. + /// + public static readonly MetaIndex[] + RelevantIndices = Enum.GetValues(); + + public static readonly InternalIndex[] ReverseIndices + = Enumerable.Range(0, CharacterUtilityData.TotalNumResources) + .Select(i => new InternalIndex(Array.IndexOf(RelevantIndices, (MetaIndex)i))) + .ToArray(); + + private readonly MetaList[] _lists; + + public (nint Address, int Size) DefaultResource(InternalIndex idx) + => _lists[idx.Value].DefaultResource; + + private readonly IFramework _framework; + + public CharacterUtility(IFramework framework, IGameInteropProvider interop) + { + interop.InitializeFromAttributes(this); + _lists = Enumerable.Range(0, RelevantIndices.Length) + .Select(idx => new MetaList(new InternalIndex(idx))) + .ToArray(); + _framework = framework; + LoadingFinished.Subscribe(() => Penumbra.Log.Debug("Loading of CharacterUtility finished."), CharacterUtilityFinished.Priority.OnFinishedLoading); + LoadDefaultResources(null!); + if (!Ready) + _framework.Update += LoadDefaultResources; + } + + /// We store the default data of the resources, so we can always restore them. + private void LoadDefaultResources(object _) + { + if (Address == null) + return; + + var anyMissing = false; + for (var i = 0; i < RelevantIndices.Length; ++i) + { + var list = _lists[i]; + if (list.Ready) + continue; + + var resource = Address->Resource(RelevantIndices[i]); + var (data, length) = resource->GetData(); + list.SetDefaultResource(data, length); + anyMissing |= !_lists[i].Ready; + } + + if (DefaultHumanPbdResource == nint.Zero) + { + DefaultHumanPbdResource = (nint)Address->HumanPbdResource; + anyMissing |= DefaultHumanPbdResource == nint.Zero; + } + + if (DefaultTransparentResource == nint.Zero) + { + DefaultTransparentResource = (nint)Address->TransparentTexResource; + anyMissing |= DefaultTransparentResource == nint.Zero; + } + + if (DefaultDecalResource == nint.Zero) + { + DefaultDecalResource = (nint)Address->DecalTexResource; + anyMissing |= DefaultDecalResource == nint.Zero; + } + + if (DefaultSkinShpkResource == nint.Zero) + { + DefaultSkinShpkResource = (nint)Address->SkinShpkResource; + anyMissing |= DefaultSkinShpkResource == nint.Zero; + } + + if (DefaultCharacterStockingsShpkResource == nint.Zero) + { + DefaultCharacterStockingsShpkResource = (nint)Address->CharacterStockingsShpkResource; + anyMissing |= DefaultCharacterStockingsShpkResource == nint.Zero; + } + + if (DefaultCharacterLegacyShpkResource == nint.Zero) + { + DefaultCharacterLegacyShpkResource = (nint)Address->CharacterLegacyShpkResource; + anyMissing |= DefaultCharacterLegacyShpkResource == nint.Zero; + } + + if (anyMissing) + return; + + Ready = true; + _framework.Update -= LoadDefaultResources; + LoadingFinished.Invoke(); + } + + /// Return all relevant resources to the default resource. + public void ResetAll() + { + if (!Ready) + return; + + Address->HumanPbdResource = (ResourceHandle*)DefaultHumanPbdResource; + Address->TransparentTexResource = (TextureResourceHandle*)DefaultTransparentResource; + Address->DecalTexResource = (TextureResourceHandle*)DefaultDecalResource; + Address->SkinShpkResource = (ResourceHandle*)DefaultSkinShpkResource; + Address->CharacterStockingsShpkResource = (ResourceHandle*)DefaultCharacterStockingsShpkResource; + Address->CharacterLegacyShpkResource = (ResourceHandle*)DefaultCharacterLegacyShpkResource; + } + + public void Dispose() + => ResetAll(); +} diff --git a/Penumbra/Interop/Services/DecalReverter.cs b/Penumbra/Interop/Services/DecalReverter.cs new file mode 100644 index 00000000..3d5d7845 --- /dev/null +++ b/Penumbra/Interop/Services/DecalReverter.cs @@ -0,0 +1,62 @@ +using FFXIVClientStructs.FFXIV.Client.System.Resource; +using Penumbra.Api.Enums; +using Penumbra.Collections; +using Penumbra.Interop.Hooks.ResourceLoading; +using Penumbra.String; +using Penumbra.String.Classes; + +namespace Penumbra.Interop.Services; + +public sealed unsafe class DecalReverter : IDisposable +{ + public static readonly Utf8GamePath DecalPath = + Utf8GamePath.FromSpan("chara/common/texture/decal_equip/_stigma.tex"u8, MetaDataComputation.All, out var p) ? p : Utf8GamePath.Empty; + + public static readonly Utf8GamePath TransparentPath = + Utf8GamePath.FromSpan("chara/common/texture/transparent.tex"u8, MetaDataComputation.All, out var p) ? p : Utf8GamePath.Empty; + + private readonly CharacterUtility _utility; + private readonly Structs.TextureResourceHandle* _decal; + private readonly Structs.TextureResourceHandle* _transparent; + + public DecalReverter(Configuration config, CharacterUtility utility, ResourceLoader resources, ResolveData resolveData, bool doDecal) + { + _utility = utility; + var ptr = _utility.Address; + _decal = null; + _transparent = null; + if (!config.EnableMods) + return; + + if (doDecal) + { + var decalHandle = resources.LoadResolvedResource(ResourceCategory.Chara, ResourceType.Tex, DecalPath.Path, resolveData); + _decal = (Structs.TextureResourceHandle*)decalHandle; + if (_decal != null) + ptr->DecalTexResource = _decal; + } + else + { + var transparentHandle = resources.LoadResolvedResource(ResourceCategory.Chara, ResourceType.Tex, TransparentPath.Path, resolveData); + _transparent = (Structs.TextureResourceHandle*)transparentHandle; + if (_transparent != null) + ptr->TransparentTexResource = _transparent; + } + } + + public void Dispose() + { + var ptr = _utility.Address; + if (_decal != null) + { + ptr->DecalTexResource = (Structs.TextureResourceHandle*)_utility.DefaultDecalResource; + --_decal->Handle.RefCount; + } + + if (_transparent != null) + { + ptr->TransparentTexResource = (Structs.TextureResourceHandle*)_utility.DefaultTransparentResource; + --_transparent->Handle.RefCount; + } + } +} diff --git a/Penumbra/Interop/Services/FontReloader.cs b/Penumbra/Interop/Services/FontReloader.cs new file mode 100644 index 00000000..3a2c7022 --- /dev/null +++ b/Penumbra/Interop/Services/FontReloader.cs @@ -0,0 +1,49 @@ +using Dalamud.Plugin.Services; +using FFXIVClientStructs.FFXIV.Client.System.Framework; +using FFXIVClientStructs.FFXIV.Component.GUI; +using OtterGui.Services; +using Penumbra.GameData; + +namespace Penumbra.Interop.Services; + +/// +/// Handle font reloading via game functions. +/// May cause a interface flicker while reloading. +/// +public unsafe class FontReloader : IService +{ + public bool Valid + => _reloadFontsFunc != null; + + public void Reload() + { + if (Valid) + _reloadFontsFunc(_atkModule, false, true); + else + Penumbra.Log.Error("Could not reload fonts, function could not be found."); + } + + private AtkModule* _atkModule = null!; + private delegate* unmanaged _reloadFontsFunc = null!; + + public FontReloader(IFramework dFramework) + { + dFramework.RunOnFrameworkThread(() => + { + var framework = Framework.Instance(); + if (framework == null) + return; + + var uiModule = framework->GetUIModule(); + if (uiModule == null) + return; + + var atkModule = uiModule->GetRaptureAtkModule(); + if (atkModule == null) + return; + + _atkModule = &atkModule->AtkModule; + _reloadFontsFunc = ((delegate* unmanaged*)_atkModule->VirtualTable)[VolatileOffsets.FontReloader.ReloadFontsVFunc]; + }); + } +} diff --git a/Penumbra/Interop/Services/MetaList.cs b/Penumbra/Interop/Services/MetaList.cs new file mode 100644 index 00000000..839c289e --- /dev/null +++ b/Penumbra/Interop/Services/MetaList.cs @@ -0,0 +1,26 @@ +using Penumbra.Interop.Structs; + +namespace Penumbra.Interop.Services; + +public class MetaList(CharacterUtility.InternalIndex index) +{ + public readonly CharacterUtility.InternalIndex Index = index; + public readonly MetaIndex GlobalMetaIndex = CharacterUtility.RelevantIndices[index.Value]; + + private nint _defaultResourceData = nint.Zero; + private int _defaultResourceSize; + public bool Ready { get; private set; } + + public void SetDefaultResource(nint data, int size) + { + if (Ready) + return; + + _defaultResourceData = data; + _defaultResourceSize = size; + Ready = _defaultResourceData != nint.Zero && size != 0; + } + + public (nint Address, int Size) DefaultResource + => (_defaultResourceData, _defaultResourceSize); +} diff --git a/Penumbra/Interop/Services/ModelRenderer.cs b/Penumbra/Interop/Services/ModelRenderer.cs new file mode 100644 index 00000000..5e2cd1fb --- /dev/null +++ b/Penumbra/Interop/Services/ModelRenderer.cs @@ -0,0 +1,151 @@ +using Dalamud.Plugin.Services; +using FFXIVClientStructs.FFXIV.Client.Graphics.Render; +using FFXIVClientStructs.FFXIV.Client.System.Resource.Handle; +using OtterGui.Services; +using ModelRendererData = FFXIVClientStructs.FFXIV.Client.Graphics.Render.ModelRenderer; + +namespace Penumbra.Interop.Services; + +public unsafe class ModelRenderer : IDisposable, IRequiredService +{ + public bool Ready { get; private set; } + + public ModelRendererData* Address + => Manager.Instance() switch + { + null => null, + var renderManager => &renderManager->ModelRenderer, + }; + + public ShaderPackageResourceHandle** IrisShaderPackage + => Address switch + { + null => null, + var data => &data->IrisShaderPackage, + }; + + public ShaderPackageResourceHandle** CharacterGlassShaderPackage + => Address switch + { + null => null, + var data => &data->CharacterGlassShaderPackage, + }; + + public ShaderPackageResourceHandle** CharacterTransparencyShaderPackage + => Address switch + { + null => null, + var data => &data->CharacterTransparencyShaderPackage, + }; + + public ShaderPackageResourceHandle** CharacterTattooShaderPackage + => Address switch + { + null => null, + var data => &data->CharacterTattooShaderPackage, + }; + + public ShaderPackageResourceHandle** CharacterOcclusionShaderPackage + => Address switch + { + null => null, + var data => &data->CharacterOcclusionShaderPackage, + }; + + public ShaderPackageResourceHandle** HairMaskShaderPackage + => Address switch + { + null => null, + var data => &data->HairMaskShaderPackage, + }; + + public ShaderPackageResourceHandle* DefaultIrisShaderPackage { get; private set; } + + public ShaderPackageResourceHandle* DefaultCharacterGlassShaderPackage { get; private set; } + + public ShaderPackageResourceHandle* DefaultCharacterTransparencyShaderPackage { get; private set; } + + public ShaderPackageResourceHandle* DefaultCharacterTattooShaderPackage { get; private set; } + + public ShaderPackageResourceHandle* DefaultCharacterOcclusionShaderPackage { get; private set; } + + public ShaderPackageResourceHandle* DefaultHairMaskShaderPackage { get; private set; } + + private readonly IFramework _framework; + + public ModelRenderer(IFramework framework) + { + _framework = framework; + LoadDefaultResources(null!); + if (!Ready) + _framework.Update += LoadDefaultResources; + } + + /// We store the default data of the resources so we can always restore them. + private void LoadDefaultResources(object _) + { + if (Manager.Instance() == null) + return; + + var anyMissing = false; + + if (DefaultIrisShaderPackage == null) + { + DefaultIrisShaderPackage = *IrisShaderPackage; + anyMissing |= DefaultIrisShaderPackage == null; + } + + if (DefaultCharacterGlassShaderPackage == null) + { + DefaultCharacterGlassShaderPackage = *CharacterGlassShaderPackage; + anyMissing |= DefaultCharacterGlassShaderPackage == null; + } + + if (DefaultCharacterTransparencyShaderPackage == null) + { + DefaultCharacterTransparencyShaderPackage = *CharacterTransparencyShaderPackage; + anyMissing |= DefaultCharacterTransparencyShaderPackage == null; + } + + if (DefaultCharacterTattooShaderPackage == null) + { + DefaultCharacterTattooShaderPackage = *CharacterTattooShaderPackage; + anyMissing |= DefaultCharacterTattooShaderPackage == null; + } + + if (DefaultCharacterOcclusionShaderPackage == null) + { + DefaultCharacterOcclusionShaderPackage = *CharacterOcclusionShaderPackage; + anyMissing |= DefaultCharacterOcclusionShaderPackage == null; + } + + if (DefaultHairMaskShaderPackage == null) + { + DefaultHairMaskShaderPackage = *HairMaskShaderPackage; + anyMissing |= DefaultHairMaskShaderPackage == null; + } + + if (anyMissing) + return; + + Ready = true; + _framework.Update -= LoadDefaultResources; + } + + /// Return all relevant resources to the default resource. + public void ResetAll() + { + if (!Ready) + return; + + *HairMaskShaderPackage = DefaultHairMaskShaderPackage; + *CharacterOcclusionShaderPackage = DefaultCharacterOcclusionShaderPackage; + *CharacterTattooShaderPackage = DefaultCharacterTattooShaderPackage; + *CharacterTransparencyShaderPackage = DefaultCharacterTransparencyShaderPackage; + *CharacterGlassShaderPackage = DefaultCharacterGlassShaderPackage; + *IrisShaderPackage = DefaultIrisShaderPackage; + } + + public void Dispose() + => ResetAll(); +} diff --git a/Penumbra/Interop/Services/RedrawService.cs b/Penumbra/Interop/Services/RedrawService.cs new file mode 100644 index 00000000..2d741277 --- /dev/null +++ b/Penumbra/Interop/Services/RedrawService.cs @@ -0,0 +1,441 @@ +using Dalamud.Game.ClientState.Conditions; +using Dalamud.Game.ClientState.Objects; +using Dalamud.Game.ClientState.Objects.Enums; +using Dalamud.Game.ClientState.Objects.SubKinds; +using Dalamud.Game.ClientState.Objects.Types; +using Dalamud.Plugin.Services; +using FFXIVClientStructs.FFXIV.Client.Game; +using FFXIVClientStructs.FFXIV.Client.Game.Character; +using OtterGui.Services; +using Penumbra.Api; +using Penumbra.Api.Enums; +using Penumbra.Communication; +using Penumbra.GameData.Enums; +using Penumbra.GameData.Interop; +using Penumbra.Interop.Structs; +using Penumbra.Mods; +using Penumbra.Mods.Editor; +using Penumbra.Services; +using Character = FFXIVClientStructs.FFXIV.Client.Game.Character.Character; + +namespace Penumbra.Interop.Services; + +public unsafe partial class RedrawService : IService +{ + public const int GPosePlayerIdx = 201; + public const int GPoseSlots = 42; + public const int GPoseEndIdx = GPosePlayerIdx + GPoseSlots; + + private readonly string?[] _gPoseNames = new string?[GPoseSlots]; + private int _gPoseNameCounter; + + internal IReadOnlyList GPoseNames + => _gPoseNames; + + internal bool InGPose + => _clientState.IsGPosing; + + // VFuncs that disable and enable draw, used only for GPose actors. + private static void DisableDraw(IGameObject actor) + => ((delegate* unmanaged**)actor.Address)[0][VolatileOffsets.RedrawService.DisableDrawVFunc](actor.Address); + + private static void EnableDraw(IGameObject actor) + => ((delegate* unmanaged**)actor.Address)[0][VolatileOffsets.RedrawService.EnableDrawVFunc](actor.Address); + + // Check whether we currently are in GPose. + // Also clear the name list. + private void SetGPose() + => _gPoseNameCounter = 0; + + private static bool IsGPoseActor(int idx) + => idx is >= GPosePlayerIdx and < GPoseEndIdx; + + // Return whether an object has to be replaced by a GPose object. + // If the object does not exist, is already a GPose actor + // or no actor of the same name is found in the GPose actor list, + // obj will be the object itself (or null) and false will be returned. + // If we are in GPose and a game object with the same name as the original actor is found, + // this will be in obj and true will be returned. + private bool FindCorrectActor(int idx, out IGameObject? obj) + { + obj = _objects.GetDalamudObject(idx); + if (!InGPose || obj == null || IsGPoseActor(idx)) + return false; + + var name = obj.Name.ToString(); + for (var i = 0; i < _gPoseNameCounter; ++i) + { + var gPoseName = _gPoseNames[i]; + if (gPoseName == null) + break; + + if (name == gPoseName) + { + obj = _objects.GetDalamudObject(GPosePlayerIdx + i); + return true; + } + } + + for (; _gPoseNameCounter < GPoseSlots; ++_gPoseNameCounter) + { + var gPoseName = _objects.GetDalamudObject(GPosePlayerIdx + _gPoseNameCounter)?.Name.ToString(); + _gPoseNames[_gPoseNameCounter] = gPoseName; + if (gPoseName == null) + break; + + if (name == gPoseName) + { + obj = _objects.GetDalamudObject(GPosePlayerIdx + _gPoseNameCounter); + return true; + } + } + + return false; + } + + // Do not ever redraw any of the five UI Window actors. + private static bool BadRedrawIndices(IGameObject? actor, out int tableIndex) + { + if (actor == null) + { + tableIndex = -1; + return true; + } + + tableIndex = ObjectTableIndex(actor); + return tableIndex is >= (int)ScreenActor.CharacterScreen and <= (int)ScreenActor.Card8; + } +} + +public sealed unsafe partial class RedrawService : IDisposable +{ + private const int FurnitureIdx = 1337; + + private readonly IFramework _framework; + private readonly ObjectManager _objects; + private readonly ITargetManager _targets; + private readonly ICondition _conditions; + private readonly IClientState _clientState; + private readonly Configuration _config; + private readonly CommunicatorService _communicator; + + private readonly List _queue = new(100); + private readonly List _afterGPoseQueue = new(GPoseSlots); + private int _target = -1; + + internal IReadOnlyList Queue + => _queue; + + internal IReadOnlyList AfterGPoseQueue + => _afterGPoseQueue; + + internal int Target + => _target; + + public event GameObjectRedrawnDelegate? GameObjectRedrawn; + + public RedrawService(IFramework framework, ObjectManager objects, ITargetManager targets, ICondition conditions, IClientState clientState, + Configuration config, CommunicatorService communicator) + { + _framework = framework; + _objects = objects; + _targets = targets; + _conditions = conditions; + _clientState = clientState; + _config = config; + _communicator = communicator; + _framework.Update += OnUpdateEvent; + _communicator.ModFileChanged.Subscribe(OnModFileChanged, ModFileChanged.Priority.RedrawService); + } + + public void Dispose() + { + _framework.Update -= OnUpdateEvent; + _communicator.ModFileChanged.Unsubscribe(OnModFileChanged); + } + + public static DrawState* ActorDrawState(IGameObject actor) + => (DrawState*)(&((FFXIVClientStructs.FFXIV.Client.Game.Object.GameObject*)actor.Address)->RenderFlags); + + private static int ObjectTableIndex(IGameObject actor) + => ((FFXIVClientStructs.FFXIV.Client.Game.Object.GameObject*)actor.Address)->ObjectIndex; + + private void WriteInvisible(IGameObject? actor) + { + if (BadRedrawIndices(actor, out var tableIndex)) + return; + + *ActorDrawState(actor!) |= DrawState.Invisibility; + + var gPose = IsGPoseActor(tableIndex); + if (gPose) + DisableDraw(actor!); + + if (actor is IPlayerCharacter + && _objects.GetDalamudObject(tableIndex + 1) is { ObjectKind: ObjectKind.MountType or ObjectKind.Ornament } mountOrOrnament) + { + *ActorDrawState(mountOrOrnament) |= DrawState.Invisibility; + if (gPose) + DisableDraw(mountOrOrnament); + } + } + + private void WriteVisible(IGameObject? actor) + { + if (BadRedrawIndices(actor, out var tableIndex)) + return; + + *ActorDrawState(actor!) &= ~DrawState.Invisibility; + + var gPose = IsGPoseActor(tableIndex); + if (gPose) + EnableDraw(actor!); + + if (actor is IPlayerCharacter + && _objects.GetDalamudObject(tableIndex + 1) is { ObjectKind: ObjectKind.MountType or ObjectKind.Ornament } mountOrOrnament) + { + *ActorDrawState(mountOrOrnament) &= ~DrawState.Invisibility; + if (gPose) + EnableDraw(mountOrOrnament); + } + + GameObjectRedrawn?.Invoke(actor!.Address, tableIndex); + } + + private void ReloadActor(IGameObject? actor) + { + if (BadRedrawIndices(actor, out var tableIndex)) + return; + + if (actor!.Address == _targets.Target?.Address) + _target = tableIndex; + + _queue.Add(~tableIndex); + } + + private void ReloadActorAfterGPose(IGameObject? actor) + { + if (_objects[GPosePlayerIdx].Valid) + { + ReloadActor(actor); + return; + } + + if (actor != null) + { + WriteInvisible(actor); + _afterGPoseQueue.Add(~ObjectTableIndex(actor)); + } + } + + private void HandleTarget() + { + if (_target < 0) + return; + + var actor = _objects.GetDalamudObject(_target); + if (actor == null || _targets.Target != null) + return; + + _targets.Target = actor; + _target = -1; + } + + private void HandleRedraw() + { + if (_queue.Count == 0) + return; + + var numKept = 0; + for (var i = 0; i < _queue.Count; ++i) + { + var idx = _queue[i]; + if (idx == ~FurnitureIdx) + { + DisableFurniture(); + continue; + } + + if (FindCorrectActor(idx < 0 ? ~idx : idx, out var obj)) + _afterGPoseQueue.Add(idx < 0 ? idx : ~idx); + + if (obj == null) + continue; + + if (idx < 0) + { + if (DelayRedraw(obj)) + { + _queue[numKept++] = ~ObjectTableIndex(obj); + } + else + { + WriteInvisible(obj); + _queue[numKept++] = ObjectTableIndex(obj); + } + } + else + { + WriteVisible(obj); + } + } + + _queue.RemoveRange(numKept, _queue.Count - numKept); + } + + private static uint GetCurrentAnimationId(IGameObject obj) + { + var gameObj = (FFXIVClientStructs.FFXIV.Client.Game.Object.GameObject*)obj.Address; + if (gameObj == null || !gameObj->IsCharacter()) + return 0; + + var chara = (Character*)gameObj; + var ptr = (byte*)&chara->Timeline + 0xF0; + return *(uint*)ptr; + } + + private static bool DelayRedraw(IGameObject obj) + => ((Character*)obj.Address)->Mode switch + { + (CharacterModes)6 => // fishing + GetCurrentAnimationId(obj) switch + { + 278 => true, // line out. + 283 => true, // reeling in + 284 => true, // reeling in + 287 => true, // reeling in 2 + 3149 => true, // line out sitting, + 3155 => true, // reeling in sitting, + 3159 => true, // reeling in sitting 2, + _ => false, + }, + _ => false, + }; + + private void HandleAfterGPose() + { + if (_afterGPoseQueue.Count == 0 || InGPose) + return; + + var numKept = 0; + for (var i = 0; i < _afterGPoseQueue.Count; ++i) + { + var idx = _afterGPoseQueue[i]; + if (idx < 0) + { + var newIdx = ~idx; + WriteInvisible(_objects.GetDalamudObject(newIdx)); + _afterGPoseQueue[numKept++] = newIdx; + } + else + { + WriteVisible(_objects.GetDalamudObject(idx)); + } + } + + _afterGPoseQueue.RemoveRange(numKept, _afterGPoseQueue.Count - numKept); + } + + private void OnUpdateEvent(object framework) + { + if (_conditions[ConditionFlag.BetweenAreas51] + || _conditions[ConditionFlag.BetweenAreas] + || _conditions[ConditionFlag.OccupiedInCutSceneEvent]) + return; + + SetGPose(); + HandleRedraw(); + HandleAfterGPose(); + HandleTarget(); + } + + public void RedrawObject(IGameObject? actor, RedrawType settings) + { + switch (settings) + { + case RedrawType.Redraw: ReloadActor(actor); break; + case RedrawType.AfterGPose: ReloadActorAfterGPose(actor); break; + default: throw new ArgumentOutOfRangeException(nameof(settings), settings, null); + } + } + + private IGameObject? GetLocalPlayer() + => InGPose ? _objects.GetDalamudObject(GPosePlayerIdx) ?? _objects.GetDalamudObject(0) : _objects.GetDalamudObject(0); + + public bool GetName(string lowerName, out IGameObject? actor) + { + (actor, var ret) = lowerName switch + { + "" => (null, true), + "" => (GetLocalPlayer(), true), + "self" => (GetLocalPlayer(), true), + "" => (_targets.Target, true), + "target" => (_targets.Target, true), + "" => (_targets.FocusTarget, true), + "focus" => (_targets.FocusTarget, true), + "" => (_targets.MouseOverTarget, true), + "mouseover" => (_targets.MouseOverTarget, true), + _ => (null, false), + }; + if (!ret && lowerName.Length > 1 && lowerName[0] == '#' && ushort.TryParse(lowerName[1..], out var objectIndex)) + { + ret = true; + actor = _objects.GetDalamudObject((int)objectIndex); + } + + return ret; + } + + public void RedrawObject(int tableIndex, RedrawType settings) + { + if (tableIndex >= 0 && tableIndex < _objects.TotalCount) + RedrawObject(_objects.GetDalamudObject(tableIndex), settings); + } + + public void RedrawObject(string name, RedrawType settings) + { + var lowerName = name.ToLowerInvariant().Trim(); + if (lowerName == "furniture") + _queue.Add(~FurnitureIdx); + else if (GetName(lowerName, out var target)) + RedrawObject(target, settings); + else + foreach (var actor in _objects.Objects.Where(a => a.Name.ToString().ToLowerInvariant() == lowerName)) + RedrawObject(actor, settings); + } + + public void RedrawAll(RedrawType settings) + { + foreach (var actor in _objects.Objects) + RedrawObject(actor, settings); + } + + private void DisableFurniture() + { + var housingManager = HousingManager.Instance(); + if (housingManager == null) + return; + + var currentTerritory = (IndoorTerritory*)housingManager->CurrentTerritory; + if (currentTerritory == null || currentTerritory->GetTerritoryType() is not HousingTerritoryType.Indoor) + return; + + + foreach (ref var f in currentTerritory->FurnitureManager.FurnitureMemory) + { + var gameObject = f.Index >= 0 ? currentTerritory->FurnitureManager.ObjectManager.ObjectArray.Objects[f.Index].Value : null; + if (gameObject == null) + continue; + + gameObject->DisableDraw(); + } + } + + private void OnModFileChanged(Mod _1, FileRegistry _2) + { + if (!_config.Ephemeral.ForceRedrawOnFileChange) + return; + + RedrawObject(0, RedrawType.Redraw); + } +} diff --git a/Penumbra/Interop/Services/ResidentResourceManager.cs b/Penumbra/Interop/Services/ResidentResourceManager.cs new file mode 100644 index 00000000..4f430aa1 --- /dev/null +++ b/Penumbra/Interop/Services/ResidentResourceManager.cs @@ -0,0 +1,39 @@ +using Dalamud.Plugin.Services; +using Dalamud.Utility.Signatures; +using OtterGui.Services; +using Penumbra.GameData; + +namespace Penumbra.Interop.Services; + +public unsafe class ResidentResourceManager : IService +{ + // A static pointer to the resident resource manager address. + [Signature(Sigs.ResidentResourceManager, ScanType = ScanType.StaticAddress)] + private readonly Structs.ResidentResourceManager** _residentResourceManagerAddress = null; + + // Some attach and physics files are stored in the resident resource manager, and we need to manually trigger a reload of them to get them to apply. + public delegate void* ResidentResourceDelegate(void* residentResourceManager); + + [Signature(Sigs.LoadPlayerResources)] + public readonly ResidentResourceDelegate LoadPlayerResources = null!; + + [Signature(Sigs.UnloadPlayerResources)] + public readonly ResidentResourceDelegate UnloadPlayerResources = null!; + + public Structs.ResidentResourceManager* Address + => *_residentResourceManagerAddress; + + public ResidentResourceManager(IGameInteropProvider interop) + => interop.InitializeFromAttributes(this); + + // Reload certain player resources by force. + public void Reload() + { + if (Address != null && Address->NumResources > 0) + { + Penumbra.Log.Debug("Reload of resident resources triggered."); + UnloadPlayerResources.Invoke(Address); + LoadPlayerResources.Invoke(Address); + } + } +} diff --git a/Penumbra/Interop/Services/SchedulerResourceManagementService.cs b/Penumbra/Interop/Services/SchedulerResourceManagementService.cs new file mode 100644 index 00000000..b7f57a44 --- /dev/null +++ b/Penumbra/Interop/Services/SchedulerResourceManagementService.cs @@ -0,0 +1,92 @@ +using System.Collections.Frozen; +using Dalamud.Plugin.Services; +using Dalamud.Utility.Signatures; +using FFXIVClientStructs.FFXIV.Client.System.Scheduler.Resource; +using Lumina.Excel.Sheets; +using OtterGui.Services; +using Penumbra.Collections; +using Penumbra.Communication; +using Penumbra.GameData; +using Penumbra.Mods.Editor; +using Penumbra.Services; +using Penumbra.String; +using Penumbra.String.Classes; + +namespace Penumbra.Interop.Services; + +public unsafe class SchedulerResourceManagementService : IService, IDisposable +{ + private static readonly CiByteString TmbExtension = new(".tmb"u8, MetaDataComputation.All); + private static readonly CiByteString FolderPrefix = new("chara/action/"u8, MetaDataComputation.All); + + private readonly CommunicatorService _communicator; + private readonly FrozenDictionary _actionTmbs; + + private readonly ConcurrentDictionary _listedTmbIds = []; + + public bool Contains(uint tmbId) + => _listedTmbIds.ContainsKey(tmbId); + + public IReadOnlyDictionary ListedTmbs + => _listedTmbIds; + + public IReadOnlyDictionary ActionTmbs + => _actionTmbs; + + public SchedulerResourceManagementService(IGameInteropProvider interop, CommunicatorService communicator, IDataManager dataManager) + { + _communicator = communicator; + _actionTmbs = CreateActionTmbs(dataManager); + _communicator.ResolvedFileChanged.Subscribe(OnResolvedFileChange, ResolvedFileChanged.Priority.SchedulerResourceManagementService); + interop.InitializeFromAttributes(this); + } + + private void OnResolvedFileChange(ModCollection collection, ResolvedFileChanged.Type type, Utf8GamePath gamePath, FullPath oldPath, + FullPath newPath, IMod? mod) + { + switch (type) + { + case ResolvedFileChanged.Type.Added: + CheckFile(gamePath); + return; + case ResolvedFileChanged.Type.FullRecomputeFinished: + foreach (var path in collection.ResolvedFiles.Keys) + CheckFile(path); + return; + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] + private void CheckFile(Utf8GamePath gamePath) + { + if (!gamePath.Extension().Equals(TmbExtension)) + return; + + if (!gamePath.Path.StartsWith(FolderPrefix)) + return; + + var tmb = gamePath.Path.Substring(FolderPrefix.Length, gamePath.Length - FolderPrefix.Length - TmbExtension.Length).Clone(); + if (_actionTmbs.TryGetValue(tmb, out var rowId)) + _listedTmbIds[rowId] = tmb; + else + Penumbra.Log.Verbose($"Action TMB {gamePath} encountered with no corresponding row ID."); + } + + [Signature(Sigs.SchedulerResourceManagementInstance, ScanType = ScanType.StaticAddress)] + public readonly SchedulerResourceManagement** Address = null; + + public SchedulerResourceManagement* Scheduler + => *Address; + + public void Dispose() + { + _listedTmbIds.Clear(); + _communicator.ResolvedFileChanged.Unsubscribe(OnResolvedFileChange); + } + + private static FrozenDictionary CreateActionTmbs(IDataManager dataManager) + { + var sheet = dataManager.GetExcelSheet(); + return sheet.Where(row => !row.Key.IsEmpty).DistinctBy(row => row.Key).ToFrozenDictionary(row => new CiByteString(row.Key, MetaDataComputation.All).Clone(), row => row.RowId); + } +} diff --git a/Penumbra/Interop/Services/TextureArraySlicer.cs b/Penumbra/Interop/Services/TextureArraySlicer.cs new file mode 100644 index 00000000..a3db4d04 --- /dev/null +++ b/Penumbra/Interop/Services/TextureArraySlicer.cs @@ -0,0 +1,147 @@ +using Dalamud.Bindings.ImGui; +using FFXIVClientStructs.FFXIV.Client.Graphics.Kernel; +using OtterGui.Services; +using TerraFX.Interop.DirectX; + +namespace Penumbra.Interop.Services; + +/// +/// Creates ImGui handles over slices of array textures, and manages their lifetime. +/// +public sealed unsafe class TextureArraySlicer : IUiService, IDisposable +{ + private const uint InitialTimeToLive = 2; + + private readonly Dictionary<(nint XivTexture, byte SliceIndex), SliceState> _activeSlices = []; + private readonly HashSet<(nint XivTexture, byte SliceIndex)> _expiredKeys = []; + + /// Caching this across frames will cause a crash to desktop. + public ImTextureID GetImGuiHandle(Texture* texture, byte sliceIndex) + { + if (texture is null) + throw new ArgumentNullException(nameof(texture)); + if (sliceIndex >= texture->ArraySize) + throw new ArgumentOutOfRangeException(nameof(sliceIndex), + $"Slice index ({sliceIndex}) is greater than or equal to the texture array size ({texture->ArraySize})"); + + if (_activeSlices.TryGetValue(((nint)texture, sliceIndex), out var state)) + { + state.Refresh(); + return new ImTextureID((nint)state.ShaderResourceView); + } + + ref var srv = ref *(ID3D11ShaderResourceView*)(nint)texture->D3D11ShaderResourceView; + srv.AddRef(); + try + { + D3D11_SHADER_RESOURCE_VIEW_DESC description; + srv.GetDesc(&description); + switch (description.ViewDimension) + { + case D3D_SRV_DIMENSION.D3D11_SRV_DIMENSION_TEXTURE1D: + case D3D_SRV_DIMENSION.D3D11_SRV_DIMENSION_TEXTURE2D: + case D3D_SRV_DIMENSION.D3D11_SRV_DIMENSION_TEXTURE2DMS: + case D3D_SRV_DIMENSION.D3D11_SRV_DIMENSION_TEXTURE3D: + case D3D_SRV_DIMENSION.D3D11_SRV_DIMENSION_TEXTURECUBE: + // This function treats these as single-slice arrays. + // As per the range check above, the only valid slice (i. e. 0) has been requested, therefore there is nothing to do. + break; + case D3D_SRV_DIMENSION.D3D11_SRV_DIMENSION_TEXTURE1DARRAY: + description.Texture1DArray.FirstArraySlice = sliceIndex; + description.Texture1DArray.ArraySize = 1; + break; + case D3D_SRV_DIMENSION.D3D11_SRV_DIMENSION_TEXTURE2DARRAY: + description.Texture2DArray.FirstArraySlice = sliceIndex; + description.Texture2DArray.ArraySize = 1; + break; + case D3D_SRV_DIMENSION.D3D11_SRV_DIMENSION_TEXTURE2DMSARRAY: + description.Texture2DMSArray.FirstArraySlice = sliceIndex; + description.Texture2DMSArray.ArraySize = 1; + break; + case D3D_SRV_DIMENSION.D3D11_SRV_DIMENSION_TEXTURECUBEARRAY: + description.TextureCubeArray.First2DArrayFace = sliceIndex * 6u; + description.TextureCubeArray.NumCubes = 1; + break; + default: + throw new NotSupportedException($"{nameof(TextureArraySlicer)} does not support dimension {description.ViewDimension}"); + } + + ID3D11Device* device = null; + srv.GetDevice(&device); + ID3D11Resource* resource = null; + srv.GetResource(&resource); + try + { + ID3D11ShaderResourceView* slicedSrv = null; + Marshal.ThrowExceptionForHR(device->CreateShaderResourceView(resource, &description, &slicedSrv)); + state = new SliceState(slicedSrv); + _activeSlices.Add(((nint)texture, sliceIndex), state); + return new ImTextureID((nint)state.ShaderResourceView); + } + finally + { + if (resource is not null) + resource->Release(); + if (device is not null) + device->Release(); + } + } + finally + { + srv.Release(); + } + } + + public void Tick() + { + try + { + foreach (var (key, slice) in _activeSlices) + { + if (!slice.Tick()) + _expiredKeys.Add(key); + } + + foreach (var key in _expiredKeys) + _activeSlices.Remove(key); + } + finally + { + _expiredKeys.Clear(); + } + } + + public void Dispose() + { + foreach (var slice in _activeSlices.Values) + slice.Dispose(); + } + + private sealed class SliceState(ID3D11ShaderResourceView* shaderResourceView) : IDisposable + { + public readonly ID3D11ShaderResourceView* ShaderResourceView = shaderResourceView; + + private uint _timeToLive = InitialTimeToLive; + + public void Refresh() + { + _timeToLive = InitialTimeToLive; + } + + public bool Tick() + { + if (unchecked(_timeToLive--) > 0) + return true; + + if (ShaderResourceView is not null) + ShaderResourceView->Release(); + return false; + } + + public void Dispose() + { + if (ShaderResourceView is not null) + ShaderResourceView->Release(); + } + } +} diff --git a/Penumbra/Interop/Structs/CharacterUtilityData.cs b/Penumbra/Interop/Structs/CharacterUtilityData.cs new file mode 100644 index 00000000..8543466d --- /dev/null +++ b/Penumbra/Interop/Structs/CharacterUtilityData.cs @@ -0,0 +1,132 @@ +using Penumbra.GameData.Enums; + +namespace Penumbra.Interop.Structs; + +[StructLayout(LayoutKind.Explicit)] +public unsafe struct CharacterUtilityData +{ + public const int IndexHumanPbd = 63; + public const int IndexTransparentTex = 79; + public const int IndexDecalTex = 80; + public const int IndexTileOrbArrayTex = 81; + public const int IndexTileNormArrayTex = 82; + public const int IndexSkinShpk = 83; + public const int IndexCharacterStockingsShpk = 84; + public const int IndexCharacterLegacyShpk = 85; + public const int IndexGudStm = 94; + public const int IndexLegacyStm = 95; + public const int IndexSphereDArrayTex = 96; + + public static readonly MetaIndex[] EqdpIndices = Enum.GetNames() + .Zip(Enum.GetValues()) + .Where(n => n.First.StartsWith("Eqdp")) + .Select(n => n.Second).ToArray(); + + public const int TotalNumResources = 114; + + /// Obtain the index for the eqdp file corresponding to the given race code and accessory. + public static MetaIndex EqdpIdx(GenderRace raceCode, bool accessory) + => +(int)raceCode switch + { + 0101 => accessory ? MetaIndex.Eqdp0101Acc : MetaIndex.Eqdp0101, + 0201 => accessory ? MetaIndex.Eqdp0201Acc : MetaIndex.Eqdp0201, + 0301 => accessory ? MetaIndex.Eqdp0301Acc : MetaIndex.Eqdp0301, + 0401 => accessory ? MetaIndex.Eqdp0401Acc : MetaIndex.Eqdp0401, + 0501 => accessory ? MetaIndex.Eqdp0501Acc : MetaIndex.Eqdp0501, + 0601 => accessory ? MetaIndex.Eqdp0601Acc : MetaIndex.Eqdp0601, + 0701 => accessory ? MetaIndex.Eqdp0701Acc : MetaIndex.Eqdp0701, + 0801 => accessory ? MetaIndex.Eqdp0801Acc : MetaIndex.Eqdp0801, + 0901 => accessory ? MetaIndex.Eqdp0901Acc : MetaIndex.Eqdp0901, + 1001 => accessory ? MetaIndex.Eqdp1001Acc : MetaIndex.Eqdp1001, + 1101 => accessory ? MetaIndex.Eqdp1101Acc : MetaIndex.Eqdp1101, + 1201 => accessory ? MetaIndex.Eqdp1201Acc : MetaIndex.Eqdp1201, + 1301 => accessory ? MetaIndex.Eqdp1301Acc : MetaIndex.Eqdp1301, + 1401 => accessory ? MetaIndex.Eqdp1401Acc : MetaIndex.Eqdp1401, + 1501 => accessory ? MetaIndex.Eqdp1501Acc : MetaIndex.Eqdp1501, + 1601 => accessory ? MetaIndex.Eqdp1601Acc : MetaIndex.Eqdp1601, + 1701 => accessory ? MetaIndex.Eqdp1701Acc : MetaIndex.Eqdp1701, + 1801 => accessory ? MetaIndex.Eqdp1801Acc : MetaIndex.Eqdp1801, + 0104 => accessory ? MetaIndex.Eqdp0104Acc : MetaIndex.Eqdp0104, + 0204 => accessory ? MetaIndex.Eqdp0204Acc : MetaIndex.Eqdp0204, + 0504 => accessory ? MetaIndex.Eqdp0504Acc : MetaIndex.Eqdp0504, + 0604 => accessory ? MetaIndex.Eqdp0604Acc : MetaIndex.Eqdp0604, + 0704 => accessory ? MetaIndex.Eqdp0704Acc : MetaIndex.Eqdp0704, + 0804 => accessory ? MetaIndex.Eqdp0804Acc : MetaIndex.Eqdp0804, + 1304 => accessory ? MetaIndex.Eqdp1304Acc : MetaIndex.Eqdp1304, + 1404 => accessory ? MetaIndex.Eqdp1404Acc : MetaIndex.Eqdp1404, + 9104 => accessory ? MetaIndex.Eqdp9104Acc : MetaIndex.Eqdp9104, + 9204 => accessory ? MetaIndex.Eqdp9204Acc : MetaIndex.Eqdp9204, + _ => (MetaIndex)(-1), + }; + + [FieldOffset(0)] + public void* VTable; + + [FieldOffset(8)] + public fixed ulong Resources[TotalNumResources]; + + [FieldOffset(8 + (int)MetaIndex.Eqp * 8)] + public ResourceHandle* EqpResource; + + [FieldOffset(8 + (int)MetaIndex.Gmp * 8)] + public ResourceHandle* GmpResource; + + public ResourceHandle* Resource(int idx) + => (ResourceHandle*)Resources[idx]; + + public ResourceHandle* Resource(MetaIndex idx) + => Resource((int)idx); + + public ResourceHandle* EqdpResource(GenderRace raceCode, bool accessory) + => Resource((int)EqdpIdx(raceCode, accessory)); + + [FieldOffset(8 + IndexHumanPbd * 8)] + public ResourceHandle* HumanPbdResource; + + [FieldOffset(8 + (int)MetaIndex.HumanCmp * 8)] + public ResourceHandle* HumanCmpResource; + + [FieldOffset(8 + (int)MetaIndex.FaceEst * 8)] + public ResourceHandle* FaceEstResource; + + [FieldOffset(8 + (int)MetaIndex.HairEst * 8)] + public ResourceHandle* HairEstResource; + + [FieldOffset(8 + (int)MetaIndex.BodyEst * 8)] + public ResourceHandle* BodyEstResource; + + [FieldOffset(8 + (int)MetaIndex.HeadEst * 8)] + public ResourceHandle* HeadEstResource; + + [FieldOffset(8 + IndexTransparentTex * 8)] + public TextureResourceHandle* TransparentTexResource; + + [FieldOffset(8 + IndexDecalTex * 8)] + public TextureResourceHandle* DecalTexResource; + + [FieldOffset(8 + IndexTileOrbArrayTex * 8)] + public TextureResourceHandle* TileOrbArrayTexResource; + + [FieldOffset(8 + IndexTileNormArrayTex * 8)] + public TextureResourceHandle* TileNormArrayTexResource; + + [FieldOffset(8 + IndexSkinShpk * 8)] + public ResourceHandle* SkinShpkResource; + + [FieldOffset(8 + IndexCharacterStockingsShpk * 8)] + public ResourceHandle* CharacterStockingsShpkResource; + + [FieldOffset(8 + IndexCharacterLegacyShpk * 8)] + public ResourceHandle* CharacterLegacyShpkResource; + + [FieldOffset(8 + IndexGudStm * 8)] + public ResourceHandle* GudStmResource; + + [FieldOffset(8 + IndexLegacyStm * 8)] + public ResourceHandle* LegacyStmResource; + + [FieldOffset(8 + IndexSphereDArrayTex * 8)] + public TextureResourceHandle* SphereDArrayTexResource; + + // not included resources have no known use case. +} diff --git a/Penumbra/Interop/Structs/ClipScheduler.cs b/Penumbra/Interop/Structs/ClipScheduler.cs new file mode 100644 index 00000000..8270e0f2 --- /dev/null +++ b/Penumbra/Interop/Structs/ClipScheduler.cs @@ -0,0 +1,13 @@ +using FFXIVClientStructs.FFXIV.Client.System.Scheduler.Base; + +namespace Penumbra.Interop.Structs; + +[StructLayout(LayoutKind.Explicit)] +public unsafe struct ClipScheduler +{ + [FieldOffset(0)] + public nint* VTable; + + [FieldOffset(0x38)] + public SchedulerTimeline* SchedulerTimeline; +} diff --git a/Penumbra/Interop/Structs/DrawState.cs b/Penumbra/Interop/Structs/DrawState.cs new file mode 100644 index 00000000..200e7952 --- /dev/null +++ b/Penumbra/Interop/Structs/DrawState.cs @@ -0,0 +1,12 @@ +namespace Penumbra.Interop.Structs; + +[Flags] +public enum DrawState : uint +{ + Invisibility = 0x00_00_00_02, + IsLoading = 0x00_00_08_00, + SomeNpcFlag = 0x00_00_01_00, + MaybeCulled = 0x00_00_04_00, + MaybeHiddenMinion = 0x00_00_80_00, + MaybeHiddenSummon = 0x00_80_00_00, +} diff --git a/Penumbra/Interop/Structs/FileMode.cs b/Penumbra/Interop/Structs/FileMode.cs new file mode 100644 index 00000000..4e48b3c1 --- /dev/null +++ b/Penumbra/Interop/Structs/FileMode.cs @@ -0,0 +1,11 @@ +namespace Penumbra.Interop.Structs; + +public enum FileMode : byte +{ + LoadUnpackedResource = 0, + LoadFileResource = 1, // The config files in MyGames use this. + + // Probably debug options only. + LoadIndexResource = 0xA, // load index/index2 + LoadSqPackResource = 0xB, +} diff --git a/Penumbra/Interop/Structs/GetResourceParameters.cs b/Penumbra/Interop/Structs/GetResourceParameters.cs new file mode 100644 index 00000000..ef665b36 --- /dev/null +++ b/Penumbra/Interop/Structs/GetResourceParameters.cs @@ -0,0 +1,14 @@ +namespace Penumbra.Interop.Structs; + +[StructLayout(LayoutKind.Explicit)] +public struct GetResourceParameters +{ + [FieldOffset(16)] + public uint SegmentOffset; + + [FieldOffset(20)] + public uint SegmentLength; + + public bool IsPartialRead + => SegmentLength != 0; +} diff --git a/Penumbra/Interop/Structs/MetaIndex.cs b/Penumbra/Interop/Structs/MetaIndex.cs new file mode 100644 index 00000000..2ec5fce4 --- /dev/null +++ b/Penumbra/Interop/Structs/MetaIndex.cs @@ -0,0 +1,73 @@ +namespace Penumbra.Interop.Structs; + +/// Indices for the different meta files contained in CharacterUtility. +public enum MetaIndex : int +{ + Eqp = 0, + Evp = 1, + Gmp = 2, + + Eqdp0101 = 3, + Eqdp0201, + Eqdp0301, + Eqdp0401, + Eqdp0501, + Eqdp0601, + Eqdp0701, + Eqdp0801, + Eqdp0901, + Eqdp1001, + Eqdp1101, + Eqdp1201, + Eqdp1301, + Eqdp1401, + Eqdp1501, + Eqdp1601, + Eqdp1701, + Eqdp1801, + Eqdp0104, + Eqdp0204, + Eqdp0504, + Eqdp0604, + Eqdp0704, + Eqdp0804, + Eqdp1304, + Eqdp1404, + Eqdp9104, + Eqdp9204, + + Eqdp0101Acc, + Eqdp0201Acc, + Eqdp0301Acc, + Eqdp0401Acc, + Eqdp0501Acc, + Eqdp0601Acc, + Eqdp0701Acc, + Eqdp0801Acc, + Eqdp0901Acc, + Eqdp1001Acc, + Eqdp1101Acc, + Eqdp1201Acc, + Eqdp1301Acc, + Eqdp1401Acc, + Eqdp1501Acc, + Eqdp1601Acc, + Eqdp1701Acc, + Eqdp1801Acc, + Eqdp0104Acc, + Eqdp0204Acc, + Eqdp0504Acc, + Eqdp0604Acc, + Eqdp0704Acc, + Eqdp0804Acc, + Eqdp1304Acc, + Eqdp1404Acc, + Eqdp9104Acc, + Eqdp9204Acc, + + HumanCmp = 71, + FaceEst, + HairEst, + HeadEst, + BodyEst, +} diff --git a/Penumbra/Interop/Structs/ModelRendererStructs.cs b/Penumbra/Interop/Structs/ModelRendererStructs.cs new file mode 100644 index 00000000..551a32e3 --- /dev/null +++ b/Penumbra/Interop/Structs/ModelRendererStructs.cs @@ -0,0 +1,35 @@ +using FFXIVClientStructs.FFXIV.Client.Graphics.Kernel; +using FFXIVClientStructs.FFXIV.Client.Graphics.Render; +using FFXIVClientStructs.FFXIV.Client.System.Resource.Handle; + +namespace Penumbra.Interop.Structs; + +public static unsafe class ModelRendererStructs +{ + [StructLayout(LayoutKind.Explicit, Size = 0x28)] + public struct UnkShaderWrapper + { + [FieldOffset(0)] + public void* Vtbl; + + [FieldOffset(8)] + public ShaderPackage* ShaderPackage; + } + + // Unknown size, this is allocated on FUN_1404446c0's stack (E8 ?? ?? ?? ?? FF C3 41 3B DE 72 ?? 48 C7 85) + [StructLayout(LayoutKind.Explicit)] + public struct UnkPayload + { + [FieldOffset(0)] + public ModelRenderer.OnRenderModelParams* Params; + + [FieldOffset(8)] + public ModelResourceHandle* ModelResourceHandle; + + [FieldOffset(0x10)] + public UnkShaderWrapper* ShaderWrapper; + + [FieldOffset(0x1C)] + public ushort UnkIndex; + } +} diff --git a/Penumbra/Interop/Structs/RenderModel.cs b/Penumbra/Interop/Structs/RenderModel.cs new file mode 100644 index 00000000..86b09e8d --- /dev/null +++ b/Penumbra/Interop/Structs/RenderModel.cs @@ -0,0 +1,43 @@ +using FFXIVClientStructs.FFXIV.Client.Graphics.Render; + +namespace Penumbra.Interop.Structs; + +[StructLayout(LayoutKind.Explicit)] +public unsafe struct RenderModel +{ + [FieldOffset(0)] + public Model Model; + + [FieldOffset(0x18)] + public RenderModel* PreviousModel; + + [FieldOffset(0x20)] + public RenderModel* NextModel; + + [FieldOffset(0x30)] + public ResourceHandle* ResourceHandle; + + [FieldOffset(0x40)] + public Skeleton* Skeleton; + + [FieldOffset(0x58)] + public void** BoneList; + + [FieldOffset(0x60)] + public int BoneListCount; + + [FieldOffset(0x70)] + private void* UnkDXBuffer1; + + [FieldOffset(0x78)] + private void* UnkDXBuffer2; + + [FieldOffset(0x80)] + private void* UnkDXBuffer3; + + [FieldOffset(0x98)] + public void** Materials; + + [FieldOffset(0xA0)] + public int MaterialCount; +} diff --git a/Penumbra/Interop/Structs/ResidentResourceManager.cs b/Penumbra/Interop/Structs/ResidentResourceManager.cs new file mode 100644 index 00000000..131f2884 --- /dev/null +++ b/Penumbra/Interop/Structs/ResidentResourceManager.cs @@ -0,0 +1,17 @@ +namespace Penumbra.Interop.Structs; + +[StructLayout(LayoutKind.Explicit)] +public unsafe struct ResidentResourceManager +{ + [FieldOffset(0x00)] + public void** VTable; + + [FieldOffset(0x08)] + public void** ResourceListVTable; + + [FieldOffset(0x14)] + public uint NumResources; + + [FieldOffset(0x18)] + public ResourceHandle** ResourceList; +} diff --git a/Penumbra/Interop/Structs/ResourceHandle.cs b/Penumbra/Interop/Structs/ResourceHandle.cs new file mode 100644 index 00000000..1558c035 --- /dev/null +++ b/Penumbra/Interop/Structs/ResourceHandle.cs @@ -0,0 +1,120 @@ +using FFXIVClientStructs.FFXIV.Client.System.Resource; +using Penumbra.Api.Enums; +using Penumbra.String; +using Penumbra.String.Classes; +using CsHandle = FFXIVClientStructs.FFXIV.Client.System.Resource.Handle; + +namespace Penumbra.Interop.Structs; + +[StructLayout(LayoutKind.Explicit)] +public unsafe struct TextureResourceHandle +{ + [FieldOffset(0x0)] + public ResourceHandle Handle; + + [FieldOffset(0x0)] + public CsHandle.TextureResourceHandle CsHandle; + + [FieldOffset(0x104)] + public byte SomeLodFlag; + + public bool ChangeLod + => (SomeLodFlag & 1) != 0; +} + +public enum LoadState : byte +{ + Constructing = 0x00, + Constructed = 0x01, + Async2 = 0x02, + AsyncRequested = 0x03, + Async4 = 0x04, + AsyncLoading = 0x05, + Async6 = 0x06, + Success = 0x07, + Unknown8 = 0x08, + Failure = 0x09, + FailedSubResource = 0x0A, + FailureB = 0x0B, + FailureC = 0x0C, + FailureD = 0x0D, + None = 0xFF, +} + +[StructLayout(LayoutKind.Explicit)] +public unsafe struct ResourceHandle +{ + [StructLayout(LayoutKind.Explicit)] + public struct DataIndirection + { + [FieldOffset(0x00)] + public void** VTable; + + [FieldOffset(0x10)] + public byte* DataPtr; + + [FieldOffset(0x28)] + public ulong DataLength; + } + + public readonly CiByteString FileName() + => CsHandle.FileName.AsByteString(); + + public readonly bool GamePath(out Utf8GamePath path) + => Utf8GamePath.FromSpan(CsHandle.FileName.AsSpan(), MetaDataComputation.All, out path); + + [FieldOffset(0x00)] + public CsHandle.ResourceHandle CsHandle; + + [FieldOffset(0x00)] + public void** VTable; + + [FieldOffset(0x08)] + public ResourceCategory Category; + + [FieldOffset(0x0C)] + public ResourceType FileType; + + [FieldOffset(0x28)] + public uint FileSize; + + [FieldOffset(0x48)] + public byte* FileNameData; + + [FieldOffset(0x58)] + public int FileNameLength; + + [FieldOffset(0xA8)] + public byte UnkState; + + [FieldOffset(0xA9)] + public LoadState LoadState; + + [FieldOffset(0xAC)] + public uint RefCount; + + + // Only use these if you know what you are doing. + // Those are actually only sure to be accessible for DefaultResourceHandles. + [FieldOffset(0xB0)] + public DataIndirection* Data; + + [FieldOffset(0xB8)] + public uint DataLength; + + public (nint Data, int Length) GetData() + => Data != null + ? ((nint)Data->DataPtr, (int)Data->DataLength) + : (nint.Zero, 0); + + public bool SetData(nint data, int length) + { + if (Data == null) + return false; + + Data->DataPtr = length != 0 ? (byte*)data : null; + Data->DataLength = (ulong)length; + DataLength = (uint)length; + return true; + } +} diff --git a/Penumbra/Interop/Structs/SeFileDescriptor.cs b/Penumbra/Interop/Structs/SeFileDescriptor.cs new file mode 100644 index 00000000..02ab4dc8 --- /dev/null +++ b/Penumbra/Interop/Structs/SeFileDescriptor.cs @@ -0,0 +1,34 @@ +using Dalamud.Memory; +using Penumbra.String.Functions; + +namespace Penumbra.Interop.Structs; + +[StructLayout(LayoutKind.Explicit)] +public unsafe struct SeFileDescriptor +{ + [FieldOffset(0x00)] + public FileMode FileMode; + + [FieldOffset(0x30)] + public void* FileDescriptor; + + [FieldOffset(0x50)] + public ResourceHandle* ResourceHandle; + + [FieldOffset(0x70)] + public char Utf16FileName; + + public FFXIVClientStructs.FFXIV.Client.System.Resource.Handle.ResourceHandle* CsResourceHandele + => (FFXIVClientStructs.FFXIV.Client.System.Resource.Handle.ResourceHandle*)ResourceHandle; + + public string FileName + { + get + { + fixed (char* ptr = &Utf16FileName) + { + return MemoryMarshal.CreateReadOnlySpanFromNullTerminated(ptr).ToString(); + } + } + } +} diff --git a/Penumbra/Interop/Structs/StructExtensions.cs b/Penumbra/Interop/Structs/StructExtensions.cs new file mode 100644 index 00000000..7349f6cc --- /dev/null +++ b/Penumbra/Interop/Structs/StructExtensions.cs @@ -0,0 +1,78 @@ +using FFXIVClientStructs.FFXIV.Client.Graphics.Scene; +using FFXIVClientStructs.STD; +using InteropGenerator.Runtime; +using Penumbra.String; + +namespace Penumbra.Interop.Structs; + +internal static class StructExtensions +{ + public static CiByteString AsByteString(in this StdString str) + => CiByteString.FromSpanUnsafe(str.AsSpan(), true); + + public static CiByteString ResolveEidPathAsByteString(ref this CharacterBase character) + { + Span pathBuffer = stackalloc byte[CharacterBase.PathBufferSize]; + return ToOwnedByteString(character.ResolveEidPath(pathBuffer)); + } + + public static CiByteString ResolveImcPathAsByteString(ref this CharacterBase character, uint slotIndex) + { + Span pathBuffer = stackalloc byte[CharacterBase.PathBufferSize]; + return ToOwnedByteString(character.ResolveImcPath(pathBuffer, slotIndex)); + } + + public static CiByteString ResolveMdlPathAsByteString(ref this CharacterBase character, uint slotIndex) + { + Span pathBuffer = stackalloc byte[CharacterBase.PathBufferSize]; + return ToOwnedByteString(character.ResolveMdlPath(pathBuffer, slotIndex)); + } + + public static unsafe CiByteString ResolveMtrlPathAsByteString(ref this CharacterBase character, uint slotIndex, byte* mtrlFileName) + { + var pathBuffer = stackalloc byte[CharacterBase.PathBufferSize]; + return ToOwnedByteString(character.ResolveMtrlPath(pathBuffer, CharacterBase.PathBufferSize, slotIndex, mtrlFileName)); + } + + public static unsafe CiByteString ResolveSkinMtrlPathAsByteString(ref this CharacterBase character, uint slotIndex) + { + var pathBuffer = stackalloc byte[CharacterBase.PathBufferSize]; + return ToOwnedByteString(character.ResolveSkinMtrlPath(pathBuffer, CharacterBase.PathBufferSize, slotIndex)); + } + + public static CiByteString ResolveMaterialPapPathAsByteString(ref this CharacterBase character, uint slotIndex, uint unkSId) + { + Span pathBuffer = stackalloc byte[CharacterBase.PathBufferSize]; + return ToOwnedByteString(character.ResolveMaterialPapPath(pathBuffer, slotIndex, unkSId)); + } + + public static CiByteString ResolveSklbPathAsByteString(ref this CharacterBase character, uint partialSkeletonIndex) + { + Span pathBuffer = stackalloc byte[CharacterBase.PathBufferSize]; + return ToOwnedByteString(character.ResolveSklbPath(pathBuffer, partialSkeletonIndex)); + } + + public static CiByteString ResolveSkpPathAsByteString(ref this CharacterBase character, uint partialSkeletonIndex) + { + Span pathBuffer = stackalloc byte[CharacterBase.PathBufferSize]; + return ToOwnedByteString(character.ResolveSkpPath(pathBuffer, partialSkeletonIndex)); + } + + public static CiByteString ResolvePhybPathAsByteString(ref this CharacterBase character, uint partialSkeletonIndex) + { + Span pathBuffer = stackalloc byte[CharacterBase.PathBufferSize]; + return ToOwnedByteString(character.ResolvePhybPath(pathBuffer, partialSkeletonIndex)); + } + + public static unsafe CiByteString ResolveKdbPathAsByteString(ref this CharacterBase character, uint partialSkeletonIndex) + { + var pathBuffer = stackalloc byte[CharacterBase.PathBufferSize]; + return ToOwnedByteString(character.ResolveKdbPath(pathBuffer, CharacterBase.PathBufferSize, partialSkeletonIndex)); + } + + private static unsafe CiByteString ToOwnedByteString(CStringPointer str) + => str.HasValue ? new CiByteString(str.Value).Clone() : CiByteString.Empty; + + private static CiByteString ToOwnedByteString(ReadOnlySpan str) + => str.Length == 0 ? CiByteString.Empty : CiByteString.FromSpanUnsafe(str, true).Clone(); +} diff --git a/Penumbra/Interop/Structs/VfxParams.cs b/Penumbra/Interop/Structs/VfxParams.cs new file mode 100644 index 00000000..c3ae1751 --- /dev/null +++ b/Penumbra/Interop/Structs/VfxParams.cs @@ -0,0 +1,17 @@ +namespace Penumbra.Interop.Structs; + +[StructLayout(LayoutKind.Explicit)] +public unsafe struct VfxParams +{ + [FieldOffset(0x118)] + public uint GameObjectId; + + [FieldOffset(0x11C)] + public byte GameObjectType; + + [FieldOffset(0xD0)] + public ushort TargetCount; + + [FieldOffset(0x120)] + public fixed ulong Target[16]; +} diff --git a/Penumbra/Interop/VolatileOffsets.cs b/Penumbra/Interop/VolatileOffsets.cs new file mode 100644 index 00000000..85008aae --- /dev/null +++ b/Penumbra/Interop/VolatileOffsets.cs @@ -0,0 +1,35 @@ +namespace Penumbra.Interop; + +public static class VolatileOffsets +{ + public static class ApricotListenerSoundPlayCaller + { + public const int PlayTimeOffset = 0x254; + public const int SomeIntermediate = 0x1F8; + public const int Flags = 0x4A8; + public const int IInstanceListenner = 0x270; + public const int BitShift = 13; + public const int CasterVFunc = 1; + } + + public static class AnimationState + { + public const int TimeLinePtr = 0x50; + } + + public static class UpdateModel + { + public const int ShortCircuit = 0xA3C; + } + + public static class FontReloader + { + public const int ReloadFontsVFunc = 43; + } + + public static class RedrawService + { + public const int EnableDrawVFunc = 12; + public const int DisableDrawVFunc = 13; + } +} diff --git a/Penumbra/Meta/AtchManager.cs b/Penumbra/Meta/AtchManager.cs new file mode 100644 index 00000000..68f2f815 --- /dev/null +++ b/Penumbra/Meta/AtchManager.cs @@ -0,0 +1,26 @@ +using System.Collections.Frozen; +using Dalamud.Plugin.Services; +using OtterGui.Services; +using Penumbra.GameData.Enums; +using Penumbra.GameData.Files; + +namespace Penumbra.Interop.Hooks.Meta; + +public sealed unsafe class AtchManager : IService +{ + private static readonly IReadOnlyList GenderRaces = + [ + GenderRace.MidlanderMale, GenderRace.MidlanderFemale, GenderRace.HighlanderMale, GenderRace.HighlanderFemale, GenderRace.ElezenMale, + GenderRace.ElezenFemale, GenderRace.MiqoteMale, GenderRace.MiqoteFemale, GenderRace.RoegadynMale, GenderRace.RoegadynFemale, + GenderRace.LalafellMale, GenderRace.LalafellFemale, GenderRace.AuRaMale, GenderRace.AuRaFemale, GenderRace.HrothgarMale, + GenderRace.HrothgarFemale, GenderRace.VieraMale, GenderRace.VieraFemale, + ]; + + public readonly IReadOnlyDictionary AtchFileBase; + + public AtchManager(IDataManager manager) + { + AtchFileBase = GenderRaces.ToFrozenDictionary(gr => gr, + gr => new AtchFile(manager.GetFile($"chara/xls/attachOffset/c{gr.ToRaceCode()}.atch")!.DataSpan)); + } +} diff --git a/Penumbra/Meta/EntryExtensions.cs b/Penumbra/Meta/EntryExtensions.cs deleted file mode 100644 index 293d135e..00000000 --- a/Penumbra/Meta/EntryExtensions.cs +++ /dev/null @@ -1,60 +0,0 @@ -using Penumbra.GameData.Enums; -using Penumbra.GameData.Structs; - -namespace Penumbra.Meta -{ - public static class EqdpEntryExtensions - { - public static bool Apply( this ref EqdpEntry entry, MetaManipulation manipulation ) - { - if( manipulation.Type != MetaType.Eqdp ) - { - return false; - } - - var mask = Eqdp.Mask( manipulation.EqdpIdentifier.Slot ); - var result = ( entry & ~mask ) | manipulation.EqdpValue; - var ret = result == entry; - entry = result; - return ret; - } - - public static EqdpEntry Reduce( this EqdpEntry entry, EquipSlot slot ) - => entry & Eqdp.Mask( slot ); - } - - - public static class EqpEntryExtensions - { - public static bool Apply( this ref EqpEntry entry, MetaManipulation manipulation ) - { - if( manipulation.Type != MetaType.Eqp ) - { - return false; - } - - var mask = Eqp.Mask( manipulation.EqpIdentifier.Slot ); - var result = ( entry & ~mask ) | manipulation.EqpValue; - var ret = result != entry; - entry = result; - return ret; - } - - public static EqpEntry Reduce( this EqpEntry entry, EquipSlot slot ) - => entry & Eqp.Mask( slot ); - } - - public static class GmpEntryExtension - { - public static GmpEntry Apply( this ref GmpEntry entry, MetaManipulation manipulation ) - { - if( manipulation.Type != MetaType.Gmp ) - { - return entry; - } - - entry.Value = manipulation.GmpValue.Value; - return entry; - } - } -} \ No newline at end of file diff --git a/Penumbra/Meta/Files/CmpFile.cs b/Penumbra/Meta/Files/CmpFile.cs index d02a3486..5028a3de 100644 --- a/Penumbra/Meta/Files/CmpFile.cs +++ b/Penumbra/Meta/Files/CmpFile.cs @@ -1,73 +1,79 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; using Penumbra.GameData.Enums; using Penumbra.GameData.Structs; +using Penumbra.Interop.Structs; +using Penumbra.Interop.Services; +using Penumbra.Meta.Manipulations; +using Penumbra.String.Functions; -namespace Penumbra.Meta.Files +namespace Penumbra.Meta.Files; + +/// +/// The human.cmp file contains many character-relevant parameters like color sets. +/// We only support manipulating the racial scaling parameters at the moment. +/// +public sealed unsafe class CmpFile : MetaBaseFile { - public class CmpFile + public static readonly CharacterUtility.InternalIndex InternalIndex = + CharacterUtility.ReverseIndices[(int)MetaIndex.HumanCmp]; + + private const int RacialScalingStart = 0x2A800; + + public RspEntry this[SubRace subRace, RspAttribute attribute] { - private const int RacialScalingStart = 0x2A800; - - private readonly byte[] _byteData = new byte[RacialScalingStart]; - private readonly RspEntry[] _rspEntries; - - public CmpFile( byte[] bytes ) - { - if( bytes.Length < RacialScalingStart ) - { - throw new ArgumentOutOfRangeException(); - } - - Array.Copy( bytes, _byteData, RacialScalingStart ); - var rspEntryNum = ( bytes.Length - RacialScalingStart ) / RspEntry.ByteSize; - var tmp = new List< RspEntry >( rspEntryNum ); - for( var i = 0; i < rspEntryNum; ++i ) - { - tmp.Add( new RspEntry( bytes, RacialScalingStart + i * RspEntry.ByteSize ) ); - } - - _rspEntries = tmp.ToArray(); - } - - public RspEntry this[ SubRace subRace ] - => _rspEntries[ subRace.ToRspIndex() ]; - - public bool Set( SubRace subRace, RspAttribute attribute, float value ) - { - var entry = _rspEntries[ subRace.ToRspIndex() ]; - var oldValue = entry[ attribute ]; - if( oldValue == value ) - { - return false; - } - - entry[ attribute ] = value; - return true; - } - - public byte[] WriteBytes() - { - using var s = new MemoryStream( RacialScalingStart + _rspEntries.Length * RspEntry.ByteSize ); - s.Write( _byteData, 0, _byteData.Length ); - foreach( var entry in _rspEntries ) - { - var bytes = entry.ToBytes(); - s.Write( bytes, 0, bytes.Length ); - } - - return s.ToArray(); - } - - private CmpFile( byte[] data, RspEntry[] entries ) - { - _byteData = data.ToArray(); - _rspEntries = entries.Select( e => new RspEntry( e ) ).ToArray(); - } - - public CmpFile Clone() - => new( _byteData, _rspEntries ); + get => *(RspEntry*)(Data + RacialScalingStart + ToRspIndex(subRace) * RspData.ByteSize + (int)attribute * 4); + set => *(RspEntry*)(Data + RacialScalingStart + ToRspIndex(subRace) * RspData.ByteSize + (int)attribute * 4) = value; } -} \ No newline at end of file + + public override void Reset() + => MemoryUtility.MemCpyUnchecked(Data, (byte*)DefaultData.Data, DefaultData.Length); + + public void Reset(IEnumerable<(SubRace, RspAttribute)> entries) + { + foreach (var (r, a) in entries) + this[r, a] = GetDefault(Manager, r, a); + } + + public CmpFile(MetaFileManager manager) + : base(manager, manager.MarshalAllocator, MetaIndex.HumanCmp) + { + AllocateData(DefaultData.Length); + Reset(); + } + + public static RspEntry GetDefault(MetaFileManager manager, SubRace subRace, RspAttribute attribute) + { + var data = (byte*)manager.CharacterUtility.DefaultResource(InternalIndex).Address; + return *(RspEntry*)(data + RacialScalingStart + ToRspIndex(subRace) * RspData.ByteSize + (int)attribute * 4); + } + + public static RspEntry* GetDefaults(MetaFileManager manager, SubRace subRace, RspAttribute attribute) + { + { + var data = (byte*)manager.CharacterUtility.DefaultResource(InternalIndex).Address; + return (RspEntry*)(data + RacialScalingStart + ToRspIndex(subRace) * RspData.ByteSize + (int)attribute * 4); + } + } + + private static int ToRspIndex(SubRace subRace) + => subRace switch + { + SubRace.Midlander => 0, + SubRace.Highlander => 1, + SubRace.Wildwood => 10, + SubRace.Duskwight => 11, + SubRace.Plainsfolk => 20, + SubRace.Dunesfolk => 21, + SubRace.SeekerOfTheSun => 30, + SubRace.KeeperOfTheMoon => 31, + SubRace.Seawolf => 40, + SubRace.Hellsguard => 41, + SubRace.Raen => 50, + SubRace.Xaela => 51, + SubRace.Helion => 60, + SubRace.Lost => 61, + SubRace.Rava => 70, + SubRace.Veena => 71, + SubRace.Unknown => 0, + _ => throw new ArgumentOutOfRangeException(nameof(subRace), subRace, null), + }; +} diff --git a/Penumbra/Meta/Files/EqdpFile.cs b/Penumbra/Meta/Files/EqdpFile.cs index fa373a4f..34b4f25b 100644 --- a/Penumbra/Meta/Files/EqdpFile.cs +++ b/Penumbra/Meta/Files/EqdpFile.cs @@ -1,214 +1,133 @@ -using System; -using System.IO; -using System.Linq; -using Lumina.Data; +using Penumbra.GameData.Enums; using Penumbra.GameData.Structs; +using Penumbra.Interop.Services; +using Penumbra.Interop.Structs; +using Penumbra.Meta.Manipulations; +using Penumbra.String.Functions; -namespace Penumbra.Meta.Files +namespace Penumbra.Meta.Files; + +/// +/// EQDP file structure: +/// [Identifier][BlockSize:ushort][BlockCount:ushort] +/// BlockCount x [BlockHeader:ushort] +/// Containing offsets for blocks, ushort.Max means collapsed. +/// Offsets are based on the end of the header, so 0 means IdentifierSize + 4 + BlockCount x 2. +/// ExpandedBlockCount x [Entry] +/// Expanded Eqdp File just expands all blocks for easy read and write access to single entries and to keep the same memory for it. +/// +public sealed unsafe class ExpandedEqdpFile : MetaBaseFile { - // EQDP file structure: - // [Identifier][BlockSize:ushort][BlockCount:ushort] - // BlockCount x [BlockHeader:ushort] - // Containing offsets for blocks, ushort.Max means collapsed. - // Offsets are based on the end of the header, so 0 means IdentifierSize + 4 + BlockCount x 2. - // ExpandedBlockCount x [Entry] - public class EqdpFile + private const ushort BlockHeaderSize = 2; + private const ushort PreambleSize = 4; + private const ushort CollapsedBlock = ushort.MaxValue; + private const ushort IdentifierSize = 2; + private const ushort EqdpEntrySize = 2; + private const int FileAlignment = 1 << 9; + + public readonly int DataOffset; + + public ushort Identifier + => *(ushort*)Data; + + public ushort BlockSize + => *(ushort*)(Data + 2); + + public ushort BlockCount + => *(ushort*)(Data + 4); + + public int Count + => (Length - DataOffset) / EqdpEntrySize; + + public EqdpEntry this[PrimaryId id] { - private const ushort BlockHeaderSize = 2; - private const ushort PreambleSize = 4; - private const ushort CollapsedBlock = ushort.MaxValue; - private const ushort IdentifierSize = 2; - private const ushort EqdpEntrySize = 2; - private const int FileAlignment = 1 << 9; - - private EqdpFile( EqdpFile clone ) + get { - Identifier = clone.Identifier; - BlockSize = clone.BlockSize; - TotalBlockCount = clone.TotalBlockCount; - ExpandedBlockCount = clone.ExpandedBlockCount; - Blocks = new EqdpEntry[clone.TotalBlockCount][]; - for( var i = 0; i < TotalBlockCount; ++i ) - { - if( clone.Blocks[ i ] != null ) - { - Blocks[ i ] = ( EqdpEntry[] )clone.Blocks[ i ]!.Clone(); - } - } + if (id.Id >= Count) + throw new IndexOutOfRangeException(); + + return (EqdpEntry)(*(ushort*)(Data + DataOffset + EqdpEntrySize * id.Id)); } - - public ref EqdpEntry this[ ushort setId ] - => ref GetTrueEntry( setId ); - - - public EqdpFile Clone() - => new( this ); - - private ushort Identifier { get; } - private ushort BlockSize { get; } - private ushort TotalBlockCount { get; } - private ushort ExpandedBlockCount { get; set; } - private EqdpEntry[]?[] Blocks { get; } - - private int BlockIdx( ushort id ) - => ( ushort )( id / BlockSize ); - - private int SubIdx( ushort id ) - => ( ushort )( id % BlockSize ); - - private bool ExpandBlock( int idx ) + set { - if( idx < TotalBlockCount && Blocks[ idx ] == null ) - { - Blocks[ idx ] = new EqdpEntry[BlockSize]; - ++ExpandedBlockCount; - return true; - } + if (id.Id >= Count) + throw new IndexOutOfRangeException(); - return false; - } - - private bool CollapseBlock( int idx ) - { - if( idx >= TotalBlockCount || Blocks[ idx ] == null ) - { - return false; - } - - Blocks[ idx ] = null; - --ExpandedBlockCount; - return true; - } - - public bool SetEntry( ushort idx, EqdpEntry entry ) - { - var block = BlockIdx( idx ); - if( block >= TotalBlockCount ) - { - return false; - } - - if( entry != 0 ) - { - ExpandBlock( block ); - if( Blocks[ block ]![ SubIdx( idx ) ] != entry ) - { - Blocks[ block ]![ SubIdx( idx ) ] = entry; - return true; - } - } - else - { - var array = Blocks[ block ]; - if( array != null ) - { - array[ SubIdx( idx ) ] = entry; - if( array.All( e => e == 0 ) ) - { - CollapseBlock( block ); - } - - return true; - } - } - - return false; - } - - public EqdpEntry GetEntry( ushort idx ) - { - var block = BlockIdx( idx ); - var array = block < Blocks.Length ? Blocks[ block ] : null; - return array?[ SubIdx( idx ) ] ?? 0; - } - - private ref EqdpEntry GetTrueEntry( ushort idx ) - { - var block = BlockIdx( idx ); - if( block >= TotalBlockCount ) - { - throw new ArgumentOutOfRangeException(); - } - - ExpandBlock( block ); - var array = Blocks[ block ]!; - return ref array[ SubIdx( idx ) ]; - } - - private void WriteHeaders( BinaryWriter bw ) - { - ushort offset = 0; - foreach( var block in Blocks ) - { - if( block == null ) - { - bw.Write( CollapsedBlock ); - continue; - } - - bw.Write( offset ); - offset += BlockSize; - } - } - - private static void WritePadding( BinaryWriter bw, int paddingSize ) - { - var buffer = new byte[paddingSize]; - bw.Write( buffer, 0, paddingSize ); - } - - private void WriteBlocks( BinaryWriter bw ) - { - foreach( var entry in Blocks.Where( block => block != null ) - .SelectMany( block => block! ) ) - { - bw.Write( ( ushort )entry ); - } - } - - public byte[] WriteBytes() - { - var dataSize = PreambleSize + IdentifierSize + BlockHeaderSize * TotalBlockCount + ExpandedBlockCount * BlockSize * EqdpEntrySize; - var paddingSize = FileAlignment - ( dataSize & ( FileAlignment - 1 ) ); - using var mem = - new MemoryStream( dataSize + paddingSize ); - using var bw = new BinaryWriter( mem ); - bw.Write( Identifier ); - bw.Write( BlockSize ); - bw.Write( TotalBlockCount ); - - WriteHeaders( bw ); - WriteBlocks( bw ); - WritePadding( bw, paddingSize ); - - return mem.ToArray(); - } - - public EqdpFile( FileResource file ) - { - file.Reader.BaseStream.Seek( 0, SeekOrigin.Begin ); - - Identifier = file.Reader.ReadUInt16(); - BlockSize = file.Reader.ReadUInt16(); - TotalBlockCount = file.Reader.ReadUInt16(); - Blocks = new EqdpEntry[TotalBlockCount][]; - ExpandedBlockCount = 0; - for( var i = 0; i < TotalBlockCount; ++i ) - { - var offset = file.Reader.ReadUInt16(); - if( offset != CollapsedBlock ) - { - ExpandBlock( ( ushort )i ); - } - } - - foreach( var array in Blocks.Where( array => array != null ) ) - { - for( var i = 0; i < BlockSize; ++i ) - { - array![ i ] = ( EqdpEntry )file.Reader.ReadUInt16(); - } - } + *(ushort*)(Data + DataOffset + EqdpEntrySize * id.Id) = (ushort)value; } } -} \ No newline at end of file + + public override void Reset() + { + var def = (byte*)DefaultData.Data; + MemoryUtility.MemCpyUnchecked(Data, def, IdentifierSize + PreambleSize); + + var controlPtr = (ushort*)(def + IdentifierSize + PreambleSize); + var dataBasePtr = controlPtr + BlockCount; + var myDataPtr = (ushort*)(Data + IdentifierSize + PreambleSize + 2 * BlockCount); + var myControlPtr = (ushort*)(Data + IdentifierSize + PreambleSize); + for (var i = 0; i < BlockCount; ++i) + { + if (controlPtr[i] == CollapsedBlock) + MemoryUtility.MemSet(myDataPtr, 0, BlockSize * EqdpEntrySize); + else + MemoryUtility.MemCpyUnchecked(myDataPtr, dataBasePtr + controlPtr[i], BlockSize * EqdpEntrySize); + + myControlPtr[i] = (ushort)(i * BlockSize); + myDataPtr += BlockSize; + } + + MemoryUtility.MemSet(myDataPtr, 0, Length - (int)((byte*)myDataPtr - Data)); + } + + public void Reset(IEnumerable entries) + { + foreach (var entry in entries) + this[entry] = GetDefault(entry); + } + + public ExpandedEqdpFile(MetaFileManager manager, GenderRace raceCode, bool accessory) + : base(manager, manager.MarshalAllocator, CharacterUtilityData.EqdpIdx(raceCode, accessory)) + { + var def = (byte*)DefaultData.Data; + var blockSize = *(ushort*)(def + IdentifierSize); + var totalBlockCount = *(ushort*)(def + IdentifierSize + 2); + var totalBlockSize = blockSize * EqdpEntrySize; + + DataOffset = IdentifierSize + PreambleSize + totalBlockCount * BlockHeaderSize; + + var fullLength = DataOffset + totalBlockCount * totalBlockSize; + fullLength += (FileAlignment - (fullLength & (FileAlignment - 1))) & (FileAlignment - 1); + AllocateData(fullLength); + Reset(); + } + + public EqdpEntry GetDefault(PrimaryId primaryId) + => GetDefault(Manager, Index, primaryId); + + public static EqdpEntry GetDefault(MetaFileManager manager, CharacterUtility.InternalIndex idx, PrimaryId primaryId) + => GetDefault((byte*)manager.CharacterUtility.DefaultResource(idx).Address, primaryId); + + public static EqdpEntry GetDefault(byte* data, PrimaryId primaryId) + { + var blockSize = *(ushort*)(data + IdentifierSize); + var totalBlockCount = *(ushort*)(data + IdentifierSize + 2); + + var blockIdx = primaryId.Id / blockSize; + if (blockIdx >= totalBlockCount) + return 0; + + var block = ((ushort*)(data + IdentifierSize + PreambleSize))[blockIdx]; + if (block == CollapsedBlock) + return 0; + + var blockData = (ushort*)(data + IdentifierSize + PreambleSize + totalBlockCount * 2 + block * 2); + return (EqdpEntry)(*(blockData + primaryId.Id % blockSize)); + } + + public static EqdpEntry GetDefault(MetaFileManager manager, GenderRace raceCode, bool accessory, PrimaryId primaryId) + => GetDefault(manager, CharacterUtility.ReverseIndices[(int)CharacterUtilityData.EqdpIdx(raceCode, accessory)], primaryId); + + public static EqdpEntry GetDefault(MetaFileManager manager, EqdpIdentifier identifier) + => GetDefault(manager, CharacterUtility.ReverseIndices[(int)identifier.FileIndex()], identifier.SetId); +} diff --git a/Penumbra/Meta/Files/EqpFile.cs b/Penumbra/Meta/Files/EqpFile.cs deleted file mode 100644 index 7de89fdb..00000000 --- a/Penumbra/Meta/Files/EqpFile.cs +++ /dev/null @@ -1,216 +0,0 @@ -using System; -using System.IO; -using System.Linq; -using Lumina.Data; -using Penumbra.GameData.Structs; - -namespace Penumbra.Meta.Files -{ - // EQP Structure: - // 64 x [Block collapsed or not bit] - // 159 x [EquipmentParameter:ulong] - // (CountSetBits(Block Collapsed or not) - 1) x 160 x [EquipmentParameter:ulong] - // Item 0 does not exist and is sent to Item 1 instead. - public sealed class EqpFile : EqpGmpBase - { - private readonly EqpEntry[]?[] _entries = new EqpEntry[TotalBlockCount][]; - - protected override ulong ControlBlock - { - get => ( ulong )_entries[ 0 ]![ 0 ]; - set => _entries[ 0 ]![ 0 ] = ( EqpEntry )value; - } - - private EqpFile( EqpFile clone ) - { - ExpandedBlockCount = clone.ExpandedBlockCount; - _entries = clone.Clone( clone._entries ); - } - - public byte[] WriteBytes() - => WriteBytes( _entries, e => ( ulong )e ); - - public EqpFile Clone() - => new( this ); - - public EqpFile( FileResource file ) - => ReadFile( _entries, file, I => ( EqpEntry )I ); - - public EqpEntry GetEntry( ushort setId ) - => GetEntry( _entries, setId, ( EqpEntry )0 ); - - public bool SetEntry( ushort setId, EqpEntry entry ) - => SetEntry( _entries, setId, entry, e => e == 0, ( e1, e2 ) => e1 == e2 ); - - public ref EqpEntry this[ ushort setId ] - => ref GetTrueEntry( _entries, setId ); - } - - public class EqpGmpBase - { - protected const ushort ParameterSize = 8; - protected const ushort BlockSize = 160; - protected const ushort TotalBlockCount = 64; - - protected int ExpandedBlockCount { get; set; } - - private static int BlockIdx( ushort idx ) - => idx / BlockSize; - - private static int SubIdx( ushort idx ) - => idx % BlockSize; - - protected virtual ulong ControlBlock { get; set; } - - protected T[]?[] Clone< T >( T[]?[] clone ) - { - var ret = new T[TotalBlockCount][]; - for( var i = 0; i < TotalBlockCount; ++i ) - { - if( clone[ i ] != null ) - { - ret[ i ] = ( T[] )clone[ i ]!.Clone(); - } - } - - return ret; - } - - protected EqpGmpBase() - { } - - protected bool ExpandBlock< T >( T[]?[] blocks, int idx ) - { - if( idx >= TotalBlockCount || blocks[ idx ] != null ) - { - return false; - } - - blocks[ idx ] = new T[BlockSize]; - ++ExpandedBlockCount; - ControlBlock |= 1ul << idx; - return true; - } - - protected bool CollapseBlock< T >( T[]?[] blocks, int idx ) - { - if( idx >= TotalBlockCount || blocks[ idx ] == null ) - { - return false; - } - - blocks[ idx ] = null; - --ExpandedBlockCount; - ControlBlock &= ~( 1ul << idx ); - return true; - } - - protected T GetEntry< T >( T[]?[] blocks, ushort idx, T defaultEntry ) - { - // Skip the zeroth item. - idx = idx == 0 ? ( ushort )1 : idx; - var block = BlockIdx( idx ); - var array = block < blocks.Length ? blocks[ block ] : null; - if( array == null ) - { - return defaultEntry; - } - - return array[ SubIdx( idx ) ]; - } - - protected ref T GetTrueEntry< T >( T[]?[] blocks, ushort idx ) - { - // Skip the zeroth item. - idx = idx == 0 ? ( ushort )1 : idx; - var block = BlockIdx( idx ); - if( block >= TotalBlockCount ) - { - throw new ArgumentOutOfRangeException(); - } - - ExpandBlock( blocks, block ); - var array = blocks[ block ]!; - return ref array[ SubIdx( idx ) ]; - } - - protected byte[] WriteBytes< T >( T[]?[] blocks, Func< T, ulong > transform ) - { - var dataSize = ExpandedBlockCount * BlockSize * ParameterSize; - using var mem = new MemoryStream( dataSize ); - using var bw = new BinaryWriter( mem ); - - foreach( var parameter in blocks.Where( array => array != null ) - .SelectMany( array => array! ) ) - { - bw.Write( transform( parameter ) ); - } - - return mem.ToArray(); - } - - protected void ReadFile< T >( T[]?[] blocks, FileResource file, Func< ulong, T > convert ) - { - file.Reader.BaseStream.Seek( 0, SeekOrigin.Begin ); - var blockBits = file.Reader.ReadUInt64(); - // reset to 0 and just put the bitmask in the first block - // item 0 is not accessible and it simplifies printing. - file.Reader.BaseStream.Seek( 0, SeekOrigin.Begin ); - - ExpandedBlockCount = 0; - for( var i = 0; i < TotalBlockCount; ++i ) - { - var flag = 1ul << i; - if( ( blockBits & flag ) != flag ) - { - continue; - } - - ++ExpandedBlockCount; - - var tmp = new T[BlockSize]; - for( var j = 0; j < BlockSize; ++j ) - { - tmp[ j ] = convert( file.Reader.ReadUInt64() ); - } - - blocks[ i ] = tmp; - } - } - - protected bool SetEntry< T >( T[]?[] blocks, ushort idx, T entry, Func< T, bool > isDefault, Func< T, T, bool > isEqual ) - { - var block = BlockIdx( idx ); - if( block >= TotalBlockCount ) - { - return false; - } - - if( !isDefault( entry ) ) - { - ExpandBlock( blocks, block ); - if( !isEqual( entry, blocks[ block ]![ SubIdx( idx ) ] ) ) - { - blocks[ block ]![ SubIdx( idx ) ] = entry; - return true; - } - } - else - { - var array = blocks[ block ]; - if( array != null ) - { - array[ SubIdx( idx ) ] = entry; - if( array.All( e => e!.Equals( 0ul ) ) ) - { - CollapseBlock( blocks, block ); - } - - return true; - } - } - - return false; - } - } -} \ No newline at end of file diff --git a/Penumbra/Meta/Files/EqpGmpFile.cs b/Penumbra/Meta/Files/EqpGmpFile.cs new file mode 100644 index 00000000..a7540f4b --- /dev/null +++ b/Penumbra/Meta/Files/EqpGmpFile.cs @@ -0,0 +1,177 @@ +using Penumbra.GameData.Structs; +using Penumbra.Interop.Services; +using Penumbra.Interop.Structs; +using Penumbra.Meta.Manipulations; +using Penumbra.String.Functions; + +namespace Penumbra.Meta.Files; + +/// +/// EQP/GMP Structure: +/// 64 x [Block collapsed or not bit] +/// 159 x [EquipmentParameter:ulong] +/// (CountSetBits(Block Collapsed or not) - 1) x 160 x [EquipmentParameter:ulong] +/// Item 0 does not exist and is sent to Item 1 instead. +/// +public unsafe class ExpandedEqpGmpBase : MetaBaseFile +{ + public const int BlockSize = 160; + public const int NumBlocks = 64; + public const int EntrySize = 8; + public const int MaxSize = BlockSize * NumBlocks * EntrySize; + + public const int Count = BlockSize * NumBlocks; + + public ulong ControlBlock + => *(ulong*)Data; + + protected ulong GetInternal(PrimaryId idx) + { + return idx.Id switch + { + >= Count => throw new IndexOutOfRangeException(), + <= 1 => *((ulong*)Data + 1), + _ => *((ulong*)Data + idx.Id), + }; + } + + protected void SetInternal(PrimaryId idx, ulong value) + { + idx = idx.Id switch + { + >= Count => throw new IndexOutOfRangeException(), + <= 0 => 1, + _ => idx, + }; + + *((ulong*)Data + idx.Id) = value; + } + + protected virtual void SetEmptyBlock(int idx) + { + MemoryUtility.MemSet(Data + idx * BlockSize * EntrySize, 0, BlockSize * EntrySize); + } + + public sealed override void Reset() + { + var ptr = (byte*)DefaultData.Data; + var controlBlock = *(ulong*)ptr; + var expandedBlocks = 0; + for (var i = 0; i < NumBlocks; ++i) + { + var collapsed = ((controlBlock >> i) & 1) == 0; + if (!collapsed) + { + MemoryUtility.MemCpyUnchecked(Data + i * BlockSize * EntrySize, ptr + expandedBlocks * BlockSize * EntrySize, + BlockSize * EntrySize); + expandedBlocks++; + } + else + { + SetEmptyBlock(i); + } + } + + *(ulong*)Data = ulong.MaxValue; + } + + public ExpandedEqpGmpBase(MetaFileManager manager, bool gmp) + : base(manager, manager.MarshalAllocator, gmp ? MetaIndex.Gmp : MetaIndex.Eqp) + { + AllocateData(MaxSize); + Reset(); + } + + protected static ulong GetDefaultInternal(MetaFileManager manager, CharacterUtility.InternalIndex fileIndex, PrimaryId primaryId, ulong def) + { + var data = (byte*)manager.CharacterUtility.DefaultResource(fileIndex).Address; + if (primaryId == 0) + primaryId = 1; + + var blockIdx = primaryId.Id / BlockSize; + if (blockIdx >= NumBlocks) + return def; + + var control = *(ulong*)data; + var blockBit = 1ul << blockIdx; + if ((control & blockBit) == 0) + return def; + + var count = BitOperations.PopCount(control & (blockBit - 1)); + var idx = primaryId.Id % BlockSize; + var ptr = (ulong*)data + BlockSize * count + idx; + return *ptr; + } +} + +public sealed class ExpandedEqpFile(MetaFileManager manager) : ExpandedEqpGmpBase(manager, false), IEnumerable +{ + public static readonly CharacterUtility.InternalIndex InternalIndex = + CharacterUtility.ReverseIndices[(int)MetaIndex.Eqp]; + + public EqpEntry this[PrimaryId idx] + { + get => (EqpEntry)GetInternal(idx); + set => SetInternal(idx, (ulong)value); + } + + + public static EqpEntry GetDefault(MetaFileManager manager, PrimaryId primaryIdx) + => (EqpEntry)GetDefaultInternal(manager, InternalIndex, primaryIdx, (ulong)Eqp.DefaultEntry); + + protected override unsafe void SetEmptyBlock(int idx) + { + var blockPtr = (ulong*)(Data + idx * BlockSize * EntrySize); + var endPtr = blockPtr + BlockSize; + for (var ptr = blockPtr; ptr < endPtr; ++ptr) + *ptr = (ulong)Eqp.DefaultEntry; + } + + public void Reset(IEnumerable entries) + { + foreach (var entry in entries) + this[entry] = GetDefault(Manager, entry); + } + + public IEnumerator GetEnumerator() + { + for (ushort idx = 1; idx < Count; ++idx) + yield return this[idx]; + } + + IEnumerator IEnumerable.GetEnumerator() + => GetEnumerator(); +} + +public sealed class ExpandedGmpFile(MetaFileManager manager) : ExpandedEqpGmpBase(manager, true), IEnumerable +{ + public static readonly CharacterUtility.InternalIndex InternalIndex = + CharacterUtility.ReverseIndices[(int)MetaIndex.Gmp]; + + public GmpEntry this[PrimaryId idx] + { + get => new() { Value = GetInternal(idx) }; + set => SetInternal(idx, value.Value); + } + + public static GmpEntry GetDefault(MetaFileManager manager, PrimaryId primaryIdx) + => new() { Value = GetDefaultInternal(manager, InternalIndex, primaryIdx, GmpEntry.Default.Value) }; + + public static GmpEntry GetDefault(MetaFileManager manager, GmpIdentifier identifier) + => new() { Value = GetDefaultInternal(manager, InternalIndex, identifier.SetId, GmpEntry.Default.Value) }; + + public void Reset(IEnumerable entries) + { + foreach (var entry in entries) + this[entry] = GetDefault(Manager, entry); + } + + public IEnumerator GetEnumerator() + { + for (ushort idx = 1; idx < Count; ++idx) + yield return this[idx]; + } + + IEnumerator IEnumerable.GetEnumerator() + => GetEnumerator(); +} diff --git a/Penumbra/Meta/Files/EstFile.cs b/Penumbra/Meta/Files/EstFile.cs index fc00fed8..ba38d6d9 100644 --- a/Penumbra/Meta/Files/EstFile.cs +++ b/Penumbra/Meta/Files/EstFile.cs @@ -1,157 +1,190 @@ -using System.Collections.Generic; -using System.IO; -using System.Linq; -using Lumina.Data; using Penumbra.GameData.Enums; +using Penumbra.GameData.Structs; +using Penumbra.Interop.Services; +using Penumbra.Interop.Structs; +using Penumbra.Meta.Manipulations; +using Penumbra.String.Functions; -namespace Penumbra.Meta.Files +namespace Penumbra.Meta.Files; + +/// +/// EST Structure: +/// 1x [NumEntries : UInt32] +/// Apparently entries need to be sorted. +/// #NumEntries x [SetId : UInt16] [RaceId : UInt16] +/// #NumEntries x [SkeletonId : UInt16] +/// +public sealed unsafe class EstFile : MetaBaseFile { - // EST Structure: - // 1x [NumEntries : UInt32] - // #NumEntries x [SetId : UInt16] [RaceId : UInt16] - // #NumEntries x [SkeletonId : UInt16] - public class EstFile + private const ushort EntryDescSize = 4; + private const ushort EntrySize = 2; + private const int IncreaseSize = 512; + + public int Count + => *(int*)Data; + + private int Size + => 4 + Count * (EntryDescSize + EntrySize); + + public enum EstEntryChange { - private const ushort EntryDescSize = 4; - private const ushort EntrySize = 2; + Unchanged, + Changed, + Added, + Removed, + } - private readonly Dictionary< GenderRace, Dictionary< ushort, ushort > > _entries = new(); - private uint NumEntries { get; set; } - - private EstFile( EstFile clone ) + public EstEntry this[GenderRace genderRace, PrimaryId setId] + { + get { - NumEntries = clone.NumEntries; - _entries = new Dictionary< GenderRace, Dictionary< ushort, ushort > >( clone._entries.Count ); - foreach( var kvp in clone._entries ) - { - var dict = kvp.Value.ToDictionary( k => k.Key, k => k.Value ); - _entries.Add( kvp.Key, dict ); - } + var (idx, exists) = FindEntry(genderRace, setId); + if (!exists) + return EstEntry.Zero; + + return *(EstEntry*)(Data + EntryDescSize * (Count + 1) + EntrySize * idx); + } + set => SetEntry(genderRace, setId, value); + } + + private void InsertEntry(int idx, GenderRace genderRace, PrimaryId setId, EstEntry skeletonId) + { + if (Length < Size + EntryDescSize + EntrySize) + ResizeResources(Length + IncreaseSize); + + var control = (Info*)(Data + 4); + var entries = (EstEntry*)(control + Count); + + for (var i = Count - 1; i >= idx; --i) + entries[i + 3] = entries[i]; + + entries[idx + 2] = skeletonId; + + for (var i = idx - 1; i >= 0; --i) + entries[i + 2] = entries[i]; + + for (var i = Count - 1; i >= idx; --i) + control[i + 1] = control[i]; + + control[idx] = new Info(genderRace, setId); + + *(int*)Data = Count + 1; + } + + private void RemoveEntry(int idx) + { + var control = (Info*)(Data + 4); + var entries = (ushort*)(control + Count); + + for (var i = idx; i < Count; ++i) + control[i] = control[i + 1]; + + for (var i = 0; i < idx; ++i) + entries[i - 2] = entries[i]; + + for (var i = idx; i < Count - 1; ++i) + entries[i - 2] = entries[i + 1]; + + entries[Count - 3] = 0; + entries[Count - 2] = 0; + entries[Count - 1] = 0; + *(int*)Data = Count - 1; + } + + [StructLayout(LayoutKind.Sequential, Size = 4)] + private struct Info : IComparable + { + public readonly PrimaryId SetId; + public readonly GenderRace GenderRace; + + public Info(GenderRace gr, PrimaryId setId) + { + GenderRace = gr; + SetId = setId; } - public EstFile Clone() - => new( this ); - - private bool DeleteEntry( GenderRace gr, ushort setId ) + public int CompareTo(Info other) { - if( !_entries.TryGetValue( gr, out var setDict ) ) - { - return false; - } - - if( !setDict.ContainsKey( setId ) ) - { - return false; - } - - setDict.Remove( setId ); - if( setDict.Count == 0 ) - { - _entries.Remove( gr ); - } - - --NumEntries; - return true; - } - - private (bool, bool) AddEntry( GenderRace gr, ushort setId, ushort entry ) - { - if( !_entries.TryGetValue( gr, out var setDict ) ) - { - _entries[ gr ] = new Dictionary< ushort, ushort >(); - setDict = _entries[ gr ]; - } - - if( setDict.TryGetValue( setId, out var oldEntry ) ) - { - if( oldEntry == entry ) - { - return ( false, false ); - } - - setDict[ setId ] = entry; - return ( false, true ); - } - - setDict[ setId ] = entry; - return ( true, true ); - } - - public bool SetEntry( GenderRace gr, ushort setId, ushort entry ) - { - if( entry == 0 ) - { - return DeleteEntry( gr, setId ); - } - - var (addedNew, changed) = AddEntry( gr, setId, entry ); - if( !addedNew ) - { - return changed; - } - - ++NumEntries; - return true; - } - - public ushort GetEntry( GenderRace gr, ushort setId ) - { - if( !_entries.TryGetValue( gr, out var setDict ) ) - { - return 0; - } - - return !setDict.TryGetValue( setId, out var entry ) ? ( ushort )0 : entry; - } - - public byte[] WriteBytes() - { - using MemoryStream mem = new( ( int )( 4 + ( EntryDescSize + EntrySize ) * NumEntries ) ); - using BinaryWriter bw = new( mem ); - - bw.Write( NumEntries ); - foreach( var kvp1 in _entries ) - { - foreach( var kvp2 in kvp1.Value ) - { - bw.Write( kvp2.Key ); - bw.Write( ( ushort )kvp1.Key ); - } - } - - foreach( var kvp2 in _entries.SelectMany( kvp1 => kvp1.Value ) ) - { - bw.Write( kvp2.Value ); - } - - return mem.ToArray(); - } - - - public EstFile( FileResource file ) - { - file.Reader.BaseStream.Seek( 0, SeekOrigin.Begin ); - NumEntries = file.Reader.ReadUInt32(); - - var currentEntryDescOffset = 4; - var currentEntryOffset = 4 + EntryDescSize * NumEntries; - for( var i = 0; i < NumEntries; ++i ) - { - file.Reader.BaseStream.Seek( currentEntryDescOffset, SeekOrigin.Begin ); - currentEntryDescOffset += EntryDescSize; - var setId = file.Reader.ReadUInt16(); - var raceId = ( GenderRace )file.Reader.ReadUInt16(); - if( !raceId.IsValid() ) - { - continue; - } - - file.Reader.BaseStream.Seek( currentEntryOffset, SeekOrigin.Begin ); - currentEntryOffset += EntrySize; - var entry = file.Reader.ReadUInt16(); - - AddEntry( raceId, setId, entry ); - } + var genderRaceComparison = GenderRace.CompareTo(other.GenderRace); + return genderRaceComparison != 0 ? genderRaceComparison : SetId.Id.CompareTo(other.SetId.Id); } } -} \ No newline at end of file + + private static (int, bool) FindEntry(ReadOnlySpan data, GenderRace genderRace, PrimaryId setId) + { + var idx = data.BinarySearch(new Info(genderRace, setId)); + return idx < 0 ? (~idx, false) : (idx, true); + } + + private (int, bool) FindEntry(GenderRace genderRace, PrimaryId setId) + { + var span = new ReadOnlySpan(Data + 4, Count); + return FindEntry(span, genderRace, setId); + } + + public EstEntryChange SetEntry(GenderRace genderRace, PrimaryId setId, EstEntry skeletonId) + { + var (idx, exists) = FindEntry(genderRace, setId); + if (exists) + { + var value = *(EstEntry*)(Data + 4 * (Count + 1) + 2 * idx); + if (value == skeletonId) + return EstEntryChange.Unchanged; + + if (skeletonId == EstEntry.Zero) + { + RemoveEntry(idx); + return EstEntryChange.Removed; + } + + *(EstEntry*)(Data + 4 * (Count + 1) + 2 * idx) = skeletonId; + return EstEntryChange.Changed; + } + + if (skeletonId == EstEntry.Zero) + return EstEntryChange.Unchanged; + + InsertEntry(idx, genderRace, setId, skeletonId); + return EstEntryChange.Added; + } + + public override void Reset() + { + var (d, length) = DefaultData; + var data = (byte*)d; + MemoryUtility.MemCpyUnchecked(Data, data, length); + MemoryUtility.MemSet(Data + length, 0, Length - length); + } + + public EstFile(MetaFileManager manager, EstType estType) + : base(manager, manager.MarshalAllocator, (MetaIndex)estType) + { + var length = DefaultData.Length; + AllocateData(length + IncreaseSize); + Reset(); + } + + public EstEntry GetDefault(GenderRace genderRace, PrimaryId setId) + => GetDefault(Manager, Index, genderRace, setId); + + public static EstEntry GetDefault(MetaFileManager manager, CharacterUtility.InternalIndex index, GenderRace genderRace, PrimaryId primaryId) + { + var data = (byte*)manager.CharacterUtility.DefaultResource(index).Address; + var count = *(int*)data; + var span = new ReadOnlySpan(data + 4, count); + var (idx, found) = FindEntry(span, genderRace, primaryId.Id); + if (!found) + return EstEntry.Zero; + + return *(EstEntry*)(data + 4 + count * EntryDescSize + idx * EntrySize); + } + + public static EstEntry GetDefault(MetaFileManager manager, MetaIndex metaIndex, GenderRace genderRace, PrimaryId primaryId) + => GetDefault(manager, CharacterUtility.ReverseIndices[(int)metaIndex], genderRace, primaryId); + + public static EstEntry GetDefault(MetaFileManager manager, EstType estType, GenderRace genderRace, PrimaryId primaryId) + => GetDefault(manager, (MetaIndex)estType, genderRace, primaryId); + + public static EstEntry GetDefault(MetaFileManager manager, EstIdentifier identifier) + => GetDefault(manager, identifier.FileIndex(), identifier.GenderRace, identifier.SetId); +} diff --git a/Penumbra/Meta/Files/EvpFile.cs b/Penumbra/Meta/Files/EvpFile.cs new file mode 100644 index 00000000..6ab1591c --- /dev/null +++ b/Penumbra/Meta/Files/EvpFile.cs @@ -0,0 +1,60 @@ +using Penumbra.Interop.Structs; + +namespace Penumbra.Meta.Files; + +/// +/// EVP file structure: +/// [Identifier:3 bytes, EVP] +/// [NumModels:ushort] +/// NumModels x [ModelId:ushort] +/// Containing the relevant model IDs. Seems to be sorted. +/// NumModels x [DataArray]:512 Byte] +/// Containing Flags in each byte, 0x01 set for Body, 0x02 set for Helmet. +/// Each flag corresponds to a mount row from the Mounts table and determines whether the mount disables the effect. +/// +public unsafe class EvpFile(MetaFileManager manager) : MetaBaseFile(manager, manager.MarshalAllocator, (MetaIndex)1) +{ + public const int FlagArraySize = 512; + + [Flags] + public enum EvpFlag : byte + { + None = 0x00, + Body = 0x01, + Head = 0x02, + Both = Body | Head, + } + + public int NumModels + => Data[3]; + + public ReadOnlySpan ModelSetIds + => new(Data + 4, NumModels); + + public ushort ModelSetId(int idx) + => idx >= 0 && idx < NumModels ? ((ushort*)(Data + 4))[idx] : ushort.MaxValue; + + public ReadOnlySpan Flags(int idx) + => new(Data + 4 + idx * FlagArraySize, FlagArraySize); + + public EvpFlag Flag(ushort modelSet, int arrayIndex) + { + if (arrayIndex is >= FlagArraySize or < 0) + return EvpFlag.None; + + var ids = ModelSetIds; + for (var i = 0; i < ids.Length; ++i) + { + var model = ids[i]; + if (model < modelSet) + continue; + + if (model > modelSet) + break; + + return Flags(i)[arrayIndex]; + } + + return EvpFlag.None; + } +} diff --git a/Penumbra/Meta/Files/GmpFile.cs b/Penumbra/Meta/Files/GmpFile.cs deleted file mode 100644 index 35603500..00000000 --- a/Penumbra/Meta/Files/GmpFile.cs +++ /dev/null @@ -1,42 +0,0 @@ -using Lumina.Data; -using Penumbra.GameData.Structs; - -namespace Penumbra.Meta.Files -{ - // GmpFiles use the same structure as Eqp Files. - // Entries are also one ulong. - public sealed class GmpFile : EqpGmpBase - { - private readonly GmpEntry[]?[] _entries = new GmpEntry[TotalBlockCount][]; - - protected override ulong ControlBlock - { - get => _entries[ 0 ]![ 0 ]; - set => _entries[ 0 ]![ 0 ] = ( GmpEntry )value; - } - - private GmpFile( GmpFile clone ) - { - ExpandedBlockCount = clone.ExpandedBlockCount; - _entries = clone.Clone( clone._entries ); - } - - public byte[] WriteBytes() - => WriteBytes( _entries, e => ( ulong )e ); - - public GmpFile Clone() - => new( this ); - - public GmpFile( FileResource file ) - => ReadFile( _entries, file, i => ( GmpEntry )i ); - - public GmpEntry GetEntry( ushort setId ) - => GetEntry( _entries, setId, ( GmpEntry )0 ); - - public bool SetEntry( ushort setId, GmpEntry entry ) - => SetEntry( _entries, setId, entry, e => e == 0, ( e1, e2 ) => e1 == e2 ); - - public ref GmpEntry this[ ushort setId ] - => ref GetTrueEntry( _entries, setId ); - } -} \ No newline at end of file diff --git a/Penumbra/Meta/Files/ImcExtensions.cs b/Penumbra/Meta/Files/ImcExtensions.cs deleted file mode 100644 index 3b97f751..00000000 --- a/Penumbra/Meta/Files/ImcExtensions.cs +++ /dev/null @@ -1,151 +0,0 @@ -using System; -using System.ComponentModel; -using System.IO; -using System.Linq; -using Lumina.Data.Files; -using Penumbra.GameData.Enums; - -namespace Penumbra.Meta.Files -{ - public class InvalidImcVariantException : ArgumentOutOfRangeException - { - public InvalidImcVariantException() - : base( "Trying to manipulate invalid variant." ) - { } - } - - // Imc files are already supported in Lumina, but changing the provided data is not supported. - // We use reflection and extension methods to support changing the data of a given Imc file. - public static class ImcExtensions - { - public static ulong ToInteger( this ImcFile.ImageChangeData imc ) - { - ulong ret = imc.MaterialId; - ret |= ( ulong )imc.DecalId << 8; - ret |= ( ulong )imc.AttributeMask << 16; - ret |= ( ulong )imc.SoundId << 16; - ret |= ( ulong )imc.VfxId << 32; - ret |= ( ulong )imc.ActualMaterialAnimationId() << 40; - return ret; - } - - public static byte ActualMaterialAnimationId( this ImcFile.ImageChangeData imc ) - { - var tmp = imc.GetType().GetField( "_MaterialAnimationIdMask", - System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance ); - return ( byte )( tmp?.GetValue( imc ) ?? 0 ); - } - - public static ImcFile.ImageChangeData FromValues( byte materialId, byte decalId, ushort attributeMask, byte soundId, byte vfxId, - byte materialAnimationId ) - { - var ret = new ImcFile.ImageChangeData() - { - DecalId = decalId, - MaterialId = materialId, - VfxId = vfxId, - }; - ret.GetType().GetField( "_AttributeAndSound", - System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance )! - .SetValue( ret, ( ushort )( ( attributeMask & 0x3FF ) | ( soundId << 10 ) ) ); - ret.GetType().GetField( "_AttributeAndSound", - System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance )!.SetValue( ret, materialAnimationId ); - return ret; - } - - public static bool Equal( this ImcFile.ImageChangeData lhs, ImcFile.ImageChangeData rhs ) - => lhs.MaterialId == rhs.MaterialId - && lhs.DecalId == rhs.DecalId - && lhs.AttributeMask == rhs.AttributeMask - && lhs.SoundId == rhs.SoundId - && lhs.VfxId == rhs.VfxId - && lhs.MaterialAnimationId == rhs.MaterialAnimationId; - - private static void WriteBytes( this ImcFile.ImageChangeData variant, BinaryWriter bw ) - { - bw.Write( variant.MaterialId ); - bw.Write( variant.DecalId ); - bw.Write( ( ushort )( variant.AttributeMask | variant.SoundId ) ); - bw.Write( variant.VfxId ); - bw.Write( variant.ActualMaterialAnimationId() ); - } - - public static byte[] WriteBytes( this ImcFile file ) - { - var parts = file.PartMask == 31 ? 5 : 1; - var dataSize = 4 + 6 * parts * ( 1 + file.Count ); - using var mem = new MemoryStream( dataSize ); - using var bw = new BinaryWriter( mem ); - - bw.Write( file.Count ); - bw.Write( file.PartMask ); - for( var i = 0; i < parts; ++i ) - { - file.GetDefaultVariant( i ).WriteBytes( bw ); - } - - for( var i = 0; i < file.Count; ++i ) - { - for( var j = 0; j < parts; ++j ) - { - file.GetVariant( j, i ).WriteBytes( bw ); - } - } - - return mem.ToArray(); - } - - public static ref ImcFile.ImageChangeData GetValue( this ImcFile file, MetaManipulation manipulation ) - { - var parts = file.GetParts(); - var imc = manipulation.ImcIdentifier; - var idx = 0; - if( imc.ObjectType == ObjectType.Equipment || imc.ObjectType == ObjectType.Accessory ) - { - idx = imc.EquipSlot switch - { - EquipSlot.Head => 0, - EquipSlot.Ears => 0, - EquipSlot.Body => 1, - EquipSlot.Neck => 1, - EquipSlot.Hands => 2, - EquipSlot.Wrists => 2, - EquipSlot.Legs => 3, - EquipSlot.RFinger => 3, - EquipSlot.Feet => 4, - EquipSlot.LFinger => 4, - _ => throw new InvalidEnumArgumentException(), - }; - } - - if( imc.Variant == 0 ) - { - return ref parts[ idx ].DefaultVariant; - } - - if( imc.Variant > parts[ idx ].Variants.Length ) - { - throw new InvalidImcVariantException(); - } - - return ref parts[ idx ].Variants[ imc.Variant - 1 ]; - } - - public static ImcFile Clone( this ImcFile file ) - { - var ret = new ImcFile - { - Count = file.Count, - PartMask = file.PartMask, - }; - var parts = file.GetParts().Select( p => new ImcFile.ImageChangeParts() - { - DefaultVariant = p.DefaultVariant, - Variants = ( ImcFile.ImageChangeData[] )p.Variants.Clone(), - } ).ToArray(); - var prop = ret.GetType().GetField( "Parts", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance ); - prop!.SetValue( ret, parts ); - return ret; - } - } -} \ No newline at end of file diff --git a/Penumbra/Meta/Files/ImcFile.cs b/Penumbra/Meta/Files/ImcFile.cs new file mode 100644 index 00000000..b8db66dd --- /dev/null +++ b/Penumbra/Meta/Files/ImcFile.cs @@ -0,0 +1,245 @@ +using OtterGui.Extensions; +using Penumbra.GameData; +using Penumbra.GameData.Enums; +using Penumbra.GameData.Structs; +using Penumbra.Interop.Structs; +using Penumbra.Meta.Manipulations; +using Penumbra.String.Classes; +using Penumbra.String.Functions; + +namespace Penumbra.Meta.Files; + +public class ImcException(ImcIdentifier identifier, Utf8GamePath path) : Exception +{ + public readonly ImcIdentifier Identifier = identifier; + public readonly string GamePath = path.ToString(); + + public override string Message + => "Could not obtain default Imc File.\n" + + " Either the default file does not exist (possibly for offhand files from TexTools) or the installation is corrupted.\n" + + $" Game Path: {GamePath}\n" + + $" Manipulation: {Identifier}"; +} + +public unsafe class ImcFile : MetaBaseFile +{ + private const int PreambleSize = 4; + + public int ActualLength + => NumParts * sizeof(ImcEntry) * (Count + 1) + PreambleSize; + + public int Count + => CountInternal(Data); + + public readonly Utf8GamePath Path; + public readonly int NumParts; + + public ReadOnlySpan Span + => new((ImcEntry*)(Data + PreambleSize), (Length - PreambleSize) / sizeof(ImcEntry)); + + private static int CountInternal(byte* data) + => *(ushort*)data; + + private static ushort PartMask(byte* data) + => *(ushort*)(data + 2); + + private static ImcEntry* VariantPtr(byte* data, int partIdx, Variant variantIdx) + { + var flag = 1 << partIdx; + if ((PartMask(data) & flag) == 0 || variantIdx.Id > CountInternal(data)) + return null; + + var numParts = BitOperations.PopCount(PartMask(data)); + var ptr = (ImcEntry*)(data + PreambleSize); + ptr += variantIdx.Id * numParts + partIdx; + return ptr; + } + + public ImcEntry GetEntry(int partIdx, Variant variantIdx) + { + var ptr = VariantPtr(Data, partIdx, variantIdx); + return ptr == null ? new ImcEntry() : *ptr; + } + + public ImcEntry GetEntry(EquipSlot slot, Variant variantIdx) + => GetEntry(PartIndex(slot), variantIdx); + + public ImcEntry GetEntry(int partIdx, Variant variantIdx, out bool exists) + { + var ptr = VariantPtr(Data, partIdx, variantIdx); + exists = ptr != null; + return exists ? *ptr : new ImcEntry(); + } + + public static int PartIndex(EquipSlot slot) + => slot switch + { + EquipSlot.Head => 0, + EquipSlot.Ears => 0, + EquipSlot.Body => 1, + EquipSlot.Neck => 1, + EquipSlot.Hands => 2, + EquipSlot.Wrists => 2, + EquipSlot.Legs => 3, + EquipSlot.RFinger => 3, + EquipSlot.Feet => 4, + EquipSlot.LFinger => 4, + _ => 0, + }; + + public bool EnsureVariantCount(int numVariants) + { + if (numVariants <= Count) + return true; + + var oldCount = Count; + *(ushort*)Data = (ushort)numVariants; + if (ActualLength > Length) + { + var newLength = (((ActualLength - 1) >> 7) + 1) << 7; + Penumbra.Log.Verbose($"Resized IMC {Path} from {Length} to {newLength}."); + ResizeResources(newLength); + } + + var defaultPtr = (ImcEntry*)(Data + PreambleSize); + for (var i = oldCount + 1; i < numVariants + 1; ++i) + MemoryUtility.MemCpyUnchecked(defaultPtr + i * NumParts, defaultPtr, NumParts * sizeof(ImcEntry)); + + Penumbra.Log.Verbose($"Expanded IMC {Path} from {oldCount} to {numVariants} variants."); + return true; + } + + public bool SetEntry(int partIdx, Variant variantIdx, ImcEntry entry) + { + if (partIdx >= NumParts) + return false; + + EnsureVariantCount(variantIdx.Id); + + var variantPtr = VariantPtr(Data, partIdx, variantIdx); + if (variantPtr == null) + { + Penumbra.Log.Error("Error during expansion of imc file."); + return false; + } + + if (variantPtr->Equals(entry)) + return false; + + *variantPtr = entry; + return true; + } + + + public override void Reset() + { + var file = Manager.GameData.GetFile(Path.ToString()); + fixed (byte* ptr = file!.Data) + { + MemoryUtility.MemCpyUnchecked(Data, ptr, file.Data.Length); + MemoryUtility.MemSet(Data + file.Data.Length, 0, Length - file.Data.Length); + } + } + + public ImcFile(MetaFileManager manager, ImcIdentifier identifier) + : this(manager, manager.MarshalAllocator, identifier) + { } + + public ImcFile(MetaFileManager manager, IFileAllocator alloc, ImcIdentifier identifier) + : base(manager, alloc, 0) + { + var path = identifier.GamePathString(); + Path = Utf8GamePath.FromString(path, out var p) ? p : Utf8GamePath.Empty; + var file = manager.GameData.GetFile(path); + if (file == null) + throw new ImcException(identifier, Path); + + fixed (byte* ptr = file.Data) + { + NumParts = BitOperations.PopCount(*(ushort*)(ptr + 2)); + AllocateData(file.Data.Length); + MemoryUtility.MemCpyUnchecked(Data, ptr, file.Data.Length); + } + } + + public static ImcEntry GetDefault(MetaFileManager manager, Utf8GamePath path, EquipSlot slot, Variant variantIdx, out bool exists) + => GetDefault(manager, path.ToString(), slot, variantIdx, out exists); + + public static ImcEntry GetDefault(MetaFileManager manager, string path, EquipSlot slot, Variant variantIdx, out bool exists) + { + var file = manager.GameData.GetFile(path); + exists = false; + if (file == null) + throw new Exception(); + + return GetEntry(file.Data, slot, variantIdx, out exists); + } + + public static ImcEntry GetEntry(ReadOnlySpan imcFileData, EquipSlot slot, Variant variantIdx, out bool exists) + { + fixed (byte* ptr = imcFileData) + { + var entry = VariantPtr(ptr, PartIndex(slot), variantIdx); + if (entry == null) + { + exists = false; + return new ImcEntry(); + } + + exists = true; + return *entry; + } + } + + public void Replace(ResourceHandle* resource) + { + var (data, length) = resource->GetData(); + var actualLength = ActualLength; + + if (DebugConfiguration.WriteImcBytesToLog) + { + Penumbra.Log.Information($"Default IMC file -> Modified IMC File for {Path}, current handle state {resource->LoadState}:"); + Penumbra.Log.Information(new Span((void*)data, length).WriteHexBytes()); + Penumbra.Log.Information(new Span(Data, actualLength).WriteHexBytes()); + Penumbra.Log.Information(new Span(Data, actualLength).WriteHexByteDiff(new Span((void*)data, length))); + } + + if (length >= actualLength) + { + MemoryUtility.MemCpyUnchecked((byte*)data, Data, actualLength); + if (length > actualLength) + MemoryUtility.MemSet((byte*)(data + actualLength), 0, length - actualLength); + if (DebugConfiguration.WriteImcBytesToLog) + { + Penumbra.Log.Information( + $"Copied {actualLength} bytes from local IMC file into {length} available bytes.{(length > actualLength ? $" Filled remaining {length - actualLength} bytes with 0." : string.Empty)}"); + Penumbra.Log.Information("Result IMC Resource Data:"); + Penumbra.Log.Information(new Span((void*)data, length).WriteHexBytes()); + } + + return; + } + + var paddedLength = actualLength.PadToMultiple(128); + var newData = Manager.XivFileAllocator.Allocate(paddedLength, 8); + if (newData == null) + { + Penumbra.Log.Error($"Could not replace loaded IMC data at 0x{(ulong)resource:X}, allocation failed."); + return; + } + + MemoryUtility.MemCpyUnchecked(newData, Data, actualLength); + if (paddedLength > actualLength) + MemoryUtility.MemSet(newData + actualLength, 0, paddedLength - actualLength); + if (DebugConfiguration.WriteImcBytesToLog) + { + Penumbra.Log.Information( + $"Allocated {paddedLength} bytes for IMC file, copied {actualLength} bytes from local IMC file. {(length > actualLength ? $" Filled remaining {length - actualLength} bytes with 0." : string.Empty)}"); + Penumbra.Log.Information("Result IMC Resource Data:"); + Penumbra.Log.Information(new Span(newData, paddedLength).WriteHexBytes()); + } + + Manager.XivFileAllocator.Release((void*)data, length); + resource->SetData((nint)newData, paddedLength); + } +} diff --git a/Penumbra/Meta/Files/MetaBaseFile.cs b/Penumbra/Meta/Files/MetaBaseFile.cs new file mode 100644 index 00000000..d04e1bdf --- /dev/null +++ b/Penumbra/Meta/Files/MetaBaseFile.cs @@ -0,0 +1,164 @@ +using Dalamud.Memory; +using Dalamud.Plugin.Services; +using Dalamud.Utility.Signatures; +using FFXIVClientStructs.FFXIV.Client.System.Memory; +using OtterGui.Services; +using Penumbra.GameData; +using Penumbra.Interop.Structs; +using Penumbra.String.Functions; +using CharacterUtility = Penumbra.Interop.Services.CharacterUtility; + +namespace Penumbra.Meta.Files; + +public unsafe interface IFileAllocator +{ + public T* Allocate(int length, int alignment = 1) where T : unmanaged; + public void Release(ref T* pointer, int length) where T : unmanaged; + + public void Release(void* pointer, int length) + { + var tmp = (byte*)pointer; + Release(ref tmp, length); + } + + public byte* Allocate(int length, int alignment = 1) + => Allocate(length, alignment); +} + +public sealed class MarshalAllocator : IFileAllocator +{ + public unsafe T* Allocate(int length, int alignment = 1) where T : unmanaged + { + var ret = (T*)Marshal.AllocHGlobal(length * sizeof(T)); + Penumbra.Log.Verbose($"Allocating {length * sizeof(T)} bytes via Marshal Allocator to 0x{(nint)ret:X}."); + return ret; + } + + public unsafe void Release(ref T* pointer, int length) where T : unmanaged + { + Marshal.FreeHGlobal((nint)pointer); + Penumbra.Log.Verbose($"Freeing {length * sizeof(T)} bytes from 0x{(nint)pointer:X} via Marshal Allocator."); + pointer = null; + } +} + +public sealed unsafe class XivFileAllocator : IFileAllocator, IService +{ + /// + /// Allocate in the games space for file storage. + /// We only need this if using any meta file. + /// + [Signature(Sigs.GetFileSpace)] + private readonly nint _getFileSpaceAddress = nint.Zero; + + public XivFileAllocator(IGameInteropProvider provider) + => provider.InitializeFromAttributes(this); + + public IMemorySpace* GetFileSpace() + => ((delegate* unmanaged)_getFileSpaceAddress)(); + + public T* Allocate(int length, int alignment = 1) where T : unmanaged + { + var ret = (T*)GetFileSpace()->Malloc((ulong)(length * sizeof(T)), (ulong)alignment); + Penumbra.Log.Verbose($"Allocating {length * sizeof(T)} bytes via FFXIV File Allocator to 0x{(nint)ret:X}."); + return ret; + } + + public void Release(ref T* pointer, int length) where T : unmanaged + { + + IMemorySpace.Free(pointer, (ulong)(length * sizeof(T))); + Penumbra.Log.Verbose($"Freeing {length * sizeof(T)} bytes from 0x{(nint)pointer:X} via FFXIV File Allocator."); + pointer = null; + } +} + +public sealed unsafe class XivDefaultAllocator : IFileAllocator, IService +{ + public T* Allocate(int length, int alignment = 1) where T : unmanaged + { + var ret = (T*)IMemorySpace.GetDefaultSpace()->Malloc((ulong)(length * sizeof(T)), (ulong)alignment); + Penumbra.Log.Verbose($"Allocating {length * sizeof(T)} bytes via FFXIV Default Allocator to 0x{(nint)ret:X}."); + return ret; + } + + public void Release(ref T* pointer, int length) where T : unmanaged + { + + IMemorySpace.Free(pointer, (ulong)(length * sizeof(T))); + Penumbra.Log.Verbose($"Freeing {length * sizeof(T)} bytes from 0x{(nint)pointer:X} via FFXIV Default Allocator."); + pointer = null; + } +} + +public unsafe class MetaBaseFile(MetaFileManager manager, IFileAllocator alloc, MetaIndex idx) : IDisposable +{ + protected readonly MetaFileManager Manager = manager; + protected readonly IFileAllocator Allocator = alloc; + + public byte* Data { get; private set; } + public int Length { get; private set; } + public CharacterUtility.InternalIndex Index { get; } = CharacterUtility.ReverseIndices[(int)idx]; + + protected (nint Data, int Length) DefaultData + => Manager.CharacterUtility.DefaultResource(Index); + + /// Reset to default values. + public virtual void Reset() + { } + + /// Obtain memory. + protected void AllocateData(int length) + { + Length = length; + Data = Allocator.Allocate(length); + if (length > 0) + GC.AddMemoryPressure(length); + } + + /// Free memory. + protected void ReleaseUnmanagedResources() + { + Allocator.Release(Data, Length); + if (Length > 0) + GC.RemoveMemoryPressure(Length); + + Length = 0; + Data = null; + } + + /// Resize memory while retaining data. + protected void ResizeResources(int newLength) + { + if (newLength == Length) + return; + + var data = Allocator.Allocate(newLength); + if (newLength > Length) + { + MemoryUtility.MemCpyUnchecked(data, Data, Length); + MemoryUtility.MemSet(data + Length, 0, newLength - Length); + } + else + { + MemoryUtility.MemCpyUnchecked(data, Data, newLength); + } + + ReleaseUnmanagedResources(); + GC.AddMemoryPressure(newLength); + Data = data; + Length = newLength; + } + + /// Manually free memory. + public void Dispose() + { + ReleaseUnmanagedResources(); + GC.SuppressFinalize(this); + } + + ~MetaBaseFile() + { + ReleaseUnmanagedResources(); + } +} diff --git a/Penumbra/Meta/Files/MetaDefaults.cs b/Penumbra/Meta/Files/MetaDefaults.cs deleted file mode 100644 index 5aa828ee..00000000 --- a/Penumbra/Meta/Files/MetaDefaults.cs +++ /dev/null @@ -1,176 +0,0 @@ -using System; -using System.Collections.Generic; -using Dalamud.Logging; -using Dalamud.Plugin; -using Lumina.Data; -using Lumina.Data.Files; -using Penumbra.GameData.Enums; -using Penumbra.GameData.Util; - -namespace Penumbra.Meta.Files -{ - // This class manages the default meta files obtained via lumina from the game files themselves. - // On first call, the default version of any supported file will be cached and can be returned without reparsing. - public class MetaDefaults - { - private readonly Dictionary< GamePath, object > _defaultFiles = new(); - - private object CreateNewFile( string path ) - { - if( path.EndsWith( ".imc" ) ) - { - return GetImcFile( path ); - } - - var rawFile = FetchFile( path ); - if( path.EndsWith( ".eqp" ) ) - { - return new EqpFile( rawFile ); - } - - if( path.EndsWith( ".gmp" ) ) - { - return new GmpFile( rawFile ); - } - - if( path.EndsWith( ".eqdp" ) ) - { - return new EqdpFile( rawFile ); - } - - if( path.EndsWith( ".est" ) ) - { - return new EstFile( rawFile ); - } - - if( path.EndsWith( ".cmp" ) ) - { - return new CmpFile( rawFile.Data ); - } - - throw new NotImplementedException(); - } - - private T? GetDefaultFile< T >( GamePath path, string error = "" ) where T : class - { - try - { - if( _defaultFiles.TryGetValue( path, out var file ) ) - { - return ( T )file; - } - - var newFile = CreateNewFile( path ); - _defaultFiles.Add( path, newFile ); - return ( T )_defaultFiles[ path ]; - } - catch( Exception e ) - { - PluginLog.Error( $"{error}{e}" ); - return null; - } - } - - private EqdpFile? GetDefaultEqdpFile( EquipSlot slot, GenderRace gr ) - => GetDefaultFile< EqdpFile >( MetaFileNames.Eqdp( slot, gr ), - $"Could not obtain Eqdp file for {slot} {gr}:\n" ); - - private GmpFile? GetDefaultGmpFile() - => GetDefaultFile< GmpFile >( MetaFileNames.Gmp(), "Could not obtain Gmp file:\n" ); - - private EqpFile? GetDefaultEqpFile() - => GetDefaultFile< EqpFile >( MetaFileNames.Eqp(), "Could not obtain Eqp file:\n" ); - - private EstFile? GetDefaultEstFile( ObjectType type, EquipSlot equip, BodySlot body ) - => GetDefaultFile< EstFile >( MetaFileNames.Est( type, equip, body ), $"Could not obtain Est file for {type} {equip} {body}:\n" ); - - private ImcFile? GetDefaultImcFile( ObjectType type, ushort primarySetId, ushort secondarySetId = 0 ) - => GetDefaultFile< ImcFile >( MetaFileNames.Imc( type, primarySetId, secondarySetId ), - $"Could not obtain Imc file for {type}, {primarySetId} {secondarySetId}:\n" ); - - private CmpFile? GetDefaultCmpFile() - => GetDefaultFile< CmpFile >( MetaFileNames.Cmp(), "Could not obtain Cmp file:\n" ); - - public EqdpFile? GetNewEqdpFile( EquipSlot slot, GenderRace gr ) - => GetDefaultEqdpFile( slot, gr )?.Clone(); - - public GmpFile? GetNewGmpFile() - => GetDefaultGmpFile()?.Clone(); - - public EqpFile? GetNewEqpFile() - => GetDefaultEqpFile()?.Clone(); - - public EstFile? GetNewEstFile( ObjectType type, EquipSlot equip, BodySlot body ) - => GetDefaultEstFile( type, equip, body )?.Clone(); - - public ImcFile? GetNewImcFile( ObjectType type, ushort primarySetId, ushort secondarySetId = 0 ) - => GetDefaultImcFile( type, primarySetId, secondarySetId )?.Clone(); - - public CmpFile? GetNewCmpFile() - => GetDefaultCmpFile()?.Clone(); - - private static ImcFile GetImcFile( string path ) - => Dalamud.GameData.GetFile< ImcFile >( path )!; - - private static FileResource FetchFile( string name ) - => Dalamud.GameData.GetFile( name )!; - - // Check that a given meta manipulation is an actual change to the default value. We don't need to keep changes to default. - public bool CheckAgainstDefault( MetaManipulation m ) - { - return m.Type switch - { - MetaType.Imc => GetDefaultImcFile( m.ImcIdentifier.ObjectType, m.ImcIdentifier.PrimaryId, m.ImcIdentifier.SecondaryId ) - ?.GetValue( m ).Equal( m.ImcValue ) - ?? true, - MetaType.Gmp => GetDefaultGmpFile()?.GetEntry( m.GmpIdentifier.SetId ) - == m.GmpValue, - MetaType.Eqp => GetDefaultEqpFile()?.GetEntry( m.EqpIdentifier.SetId ) - .Reduce( m.EqpIdentifier.Slot ) - == m.EqpValue, - MetaType.Eqdp => GetDefaultEqdpFile( m.EqdpIdentifier.Slot, m.EqdpIdentifier.GenderRace )?.GetEntry( m.EqdpIdentifier.SetId ) - .Reduce( m.EqdpIdentifier.Slot ) - == m.EqdpValue, - MetaType.Est => GetDefaultEstFile( m.EstIdentifier.ObjectType, m.EstIdentifier.EquipSlot, m.EstIdentifier.BodySlot ) - ?.GetEntry( m.EstIdentifier.GenderRace, m.EstIdentifier.PrimaryId ) - == m.EstValue, - MetaType.Rsp => GetDefaultCmpFile()?[ m.RspIdentifier.SubRace ][ m.RspIdentifier.Attribute ] - == m.RspValue, - _ => throw new NotImplementedException(), - }; - } - - public object? GetDefaultValue( MetaManipulation m ) - { - return m.Type switch - { - MetaType.Imc => GetDefaultImcFile( m.ImcIdentifier.ObjectType, m.ImcIdentifier.PrimaryId, m.ImcIdentifier.SecondaryId ) - ?.GetValue( m ), - MetaType.Gmp => GetDefaultGmpFile()?.GetEntry( m.GmpIdentifier.SetId ), - MetaType.Eqp => GetDefaultEqpFile()?.GetEntry( m.EqpIdentifier.SetId ) - .Reduce( m.EqpIdentifier.Slot ), - MetaType.Eqdp => GetDefaultEqdpFile( m.EqdpIdentifier.Slot, m.EqdpIdentifier.GenderRace )?.GetEntry( m.EqdpIdentifier.SetId ) - .Reduce( m.EqdpIdentifier.Slot ), - MetaType.Est => GetDefaultEstFile( m.EstIdentifier.ObjectType, m.EstIdentifier.EquipSlot, m.EstIdentifier.BodySlot ) - ?.GetEntry( m.EstIdentifier.GenderRace, m.EstIdentifier.PrimaryId ), - MetaType.Rsp => GetDefaultCmpFile()?[ m.RspIdentifier.SubRace ][ m.RspIdentifier.Attribute ], - _ => throw new NotImplementedException(), - }; - } - - // Create a deep copy of a default file as a new file. - public object? CreateNewFile( MetaManipulation m ) - { - return m.Type switch - { - MetaType.Imc => GetNewImcFile( m.ImcIdentifier.ObjectType, m.ImcIdentifier.PrimaryId, m.ImcIdentifier.SecondaryId ), - MetaType.Gmp => GetNewGmpFile(), - MetaType.Eqp => GetNewEqpFile(), - MetaType.Eqdp => GetNewEqdpFile( m.EqdpIdentifier.Slot, m.EqdpIdentifier.GenderRace ), - MetaType.Est => GetNewEstFile( m.EstIdentifier.ObjectType, m.EstIdentifier.EquipSlot, m.EstIdentifier.BodySlot ), - MetaType.Rsp => GetNewCmpFile(), - _ => throw new NotImplementedException(), - }; - } - } -} \ No newline at end of file diff --git a/Penumbra/Meta/Files/MetaFilenames.cs b/Penumbra/Meta/Files/MetaFilenames.cs deleted file mode 100644 index 06360afe..00000000 --- a/Penumbra/Meta/Files/MetaFilenames.cs +++ /dev/null @@ -1,83 +0,0 @@ -using System; -using Penumbra.GameData.Enums; -using Penumbra.GameData.Util; - -namespace Penumbra.Meta.Files -{ - // Contains all filenames for meta changes depending on their parameters. - public static class MetaFileNames - { - public static GamePath Eqp() - => GamePath.GenerateUnchecked( "chara/xls/equipmentparameter/equipmentparameter.eqp" ); - - public static GamePath Gmp() - => GamePath.GenerateUnchecked( "chara/xls/equipmentparameter/gimmickparameter.gmp" ); - - public static GamePath Est( ObjectType type, EquipSlot equip, BodySlot slot ) - { - return type switch - { - ObjectType.Equipment => equip switch - { - EquipSlot.Body => GamePath.GenerateUnchecked( "chara/xls/charadb/extra_top.est" ), - EquipSlot.Head => GamePath.GenerateUnchecked( "chara/xls/charadb/extra_met.est" ), - _ => throw new NotImplementedException(), - }, - ObjectType.Character => slot switch - { - BodySlot.Hair => GamePath.GenerateUnchecked( "chara/xls/charadb/hairskeletontemplate.est" ), - BodySlot.Face => GamePath.GenerateUnchecked( "chara/xls/charadb/faceskeletontemplate.est" ), - _ => throw new NotImplementedException(), - }, - _ => throw new NotImplementedException(), - }; - } - - public static GamePath Imc( ObjectType type, ushort primaryId, ushort secondaryId ) - { - return type switch - { - ObjectType.Accessory => GamePath.GenerateUnchecked( $"chara/accessory/a{primaryId:D4}/a{primaryId:D4}.imc" ), - ObjectType.Equipment => GamePath.GenerateUnchecked( $"chara/equipment/e{primaryId:D4}/e{primaryId:D4}.imc" ), - ObjectType.DemiHuman => GamePath.GenerateUnchecked( - $"chara/demihuman/d{primaryId:D4}/obj/equipment/e{secondaryId:D4}/e{secondaryId:D4}.imc" ), - ObjectType.Monster => GamePath.GenerateUnchecked( - $"chara/monster/m{primaryId:D4}/obj/body/b{secondaryId:D4}/b{secondaryId:D4}.imc" ), - ObjectType.Weapon => GamePath.GenerateUnchecked( - $"chara/weapon/w{primaryId:D4}/obj/body/b{secondaryId:D4}/b{secondaryId:D4}.imc" ), - _ => throw new NotImplementedException(), - }; - } - - public static GamePath Eqdp( ObjectType type, GenderRace gr ) - { - return type switch - { - ObjectType.Accessory => GamePath.GenerateUnchecked( $"chara/xls/charadb/accessorydeformerparameter/c{gr.ToRaceCode()}.eqdp" ), - ObjectType.Equipment => GamePath.GenerateUnchecked( $"chara/xls/charadb/equipmentdeformerparameter/c{gr.ToRaceCode()}.eqdp" ), - _ => throw new NotImplementedException(), - }; - } - - public static GamePath Eqdp( EquipSlot slot, GenderRace gr ) - { - return slot switch - { - EquipSlot.Head => Eqdp( ObjectType.Equipment, gr ), - EquipSlot.Body => Eqdp( ObjectType.Equipment, gr ), - EquipSlot.Feet => Eqdp( ObjectType.Equipment, gr ), - EquipSlot.Hands => Eqdp( ObjectType.Equipment, gr ), - EquipSlot.Legs => Eqdp( ObjectType.Equipment, gr ), - EquipSlot.Neck => Eqdp( ObjectType.Accessory, gr ), - EquipSlot.Ears => Eqdp( ObjectType.Accessory, gr ), - EquipSlot.Wrists => Eqdp( ObjectType.Accessory, gr ), - EquipSlot.LFinger => Eqdp( ObjectType.Accessory, gr ), - EquipSlot.RFinger => Eqdp( ObjectType.Accessory, gr ), - _ => throw new NotImplementedException(), - }; - } - - public static GamePath Cmp() - => GamePath.GenerateUnchecked( "chara/xls/charamake/human.cmp" ); - } -} \ No newline at end of file diff --git a/Penumbra/Meta/Identifier.cs b/Penumbra/Meta/Identifier.cs deleted file mode 100644 index 896a5a8b..00000000 --- a/Penumbra/Meta/Identifier.cs +++ /dev/null @@ -1,172 +0,0 @@ -using System.Runtime.InteropServices; -using Penumbra.GameData.Enums; - -// A struct for each type of meta change that contains all relevant information, -// to uniquely identify the corresponding file and location for the change. -// The first byte is guaranteed to be the MetaType enum for each case. -namespace Penumbra.Meta -{ - public enum MetaType : byte - { - Unknown = 0, - Imc = 1, - Eqdp = 2, - Eqp = 3, - Est = 4, - Gmp = 5, - Rsp = 6, - }; - - [StructLayout( LayoutKind.Explicit )] - public struct EqpIdentifier - { - [FieldOffset( 0 )] - public ulong Value; - - [FieldOffset( 0 )] - public MetaType Type; - - [FieldOffset( 1 )] - public EquipSlot Slot; - - [FieldOffset( 2 )] - public ushort SetId; - - public override string ToString() - => $"Eqp - {SetId} - {Slot}"; - } - - [StructLayout( LayoutKind.Explicit )] - public struct EqdpIdentifier - { - [FieldOffset( 0 )] - public ulong Value; - - [FieldOffset( 0 )] - public MetaType Type; - - [FieldOffset( 1 )] - public EquipSlot Slot; - - [FieldOffset( 2 )] - public GenderRace GenderRace; - - [FieldOffset( 4 )] - public ushort SetId; - - public override string ToString() - => $"Eqdp - {SetId} - {Slot} - {GenderRace.Split().Item2.ToName()} {GenderRace.Split().Item1.ToName()}"; - } - - [StructLayout( LayoutKind.Explicit )] - public struct GmpIdentifier - { - [FieldOffset( 0 )] - public ulong Value; - - [FieldOffset( 0 )] - public MetaType Type; - - [FieldOffset( 1 )] - public ushort SetId; - - public override string ToString() - => $"Gmp - {SetId}"; - } - - [StructLayout( LayoutKind.Explicit )] - public struct EstIdentifier - { - [FieldOffset( 0 )] - public ulong Value; - - [FieldOffset( 0 )] - public MetaType Type; - - [FieldOffset( 1 )] - public ObjectType ObjectType; - - [FieldOffset( 2 )] - public EquipSlot EquipSlot; - - [FieldOffset( 3 )] - public BodySlot BodySlot; - - [FieldOffset( 4 )] - public GenderRace GenderRace; - - [FieldOffset( 6 )] - public ushort PrimaryId; - - public override string ToString() - => ObjectType == ObjectType.Equipment - ? $"Est - {PrimaryId} - {EquipSlot} - {GenderRace.Split().Item2.ToName()} {GenderRace.Split().Item1.ToName()}" - : $"Est - {PrimaryId} - {BodySlot} - {GenderRace.Split().Item2.ToName()} {GenderRace.Split().Item1.ToName()}"; - } - - [StructLayout( LayoutKind.Explicit )] - public struct ImcIdentifier - { - [FieldOffset( 0 )] - public ulong Value; - - [FieldOffset( 0 )] - public MetaType Type; - - [FieldOffset( 1 )] - public byte _objectAndBody; - - public ObjectType ObjectType - { - get => ( ObjectType )( _objectAndBody & 0b00011111 ); - set => _objectAndBody = ( byte )( ( _objectAndBody & 0b11100000 ) | ( byte )value ); - } - - public BodySlot BodySlot - { - get => ( BodySlot )( _objectAndBody >> 5 ); - set => _objectAndBody = ( byte )( ( _objectAndBody & 0b00011111 ) | ( ( byte )value << 5 ) ); - } - - [FieldOffset( 2 )] - public ushort PrimaryId; - - [FieldOffset( 4 )] - public ushort Variant; - - [FieldOffset( 6 )] - public ushort SecondaryId; - - [FieldOffset( 6 )] - public EquipSlot EquipSlot; - - public override string ToString() - { - return ObjectType switch - { - ObjectType.Accessory => $"Imc - {PrimaryId} - {EquipSlot} - {Variant}", - ObjectType.Equipment => $"Imc - {PrimaryId} - {EquipSlot} - {Variant}", - _ => $"Imc - {PrimaryId} - {ObjectType} - {SecondaryId} - {BodySlot} - {Variant}", - }; - } - } - - [StructLayout( LayoutKind.Explicit )] - public struct RspIdentifier - { - [FieldOffset( 0 )] - public ulong Value; - - [FieldOffset( 0 )] - public MetaType Type; - - [FieldOffset( 1 )] - public SubRace SubRace; - - [FieldOffset( 2 )] - public RspAttribute Attribute; - - public override string ToString() - => $"Rsp - {SubRace.ToName()} - {Attribute.ToFullString()}"; - } -} \ No newline at end of file diff --git a/Penumbra/Meta/ImcChecker.cs b/Penumbra/Meta/ImcChecker.cs new file mode 100644 index 00000000..a415c9b0 --- /dev/null +++ b/Penumbra/Meta/ImcChecker.cs @@ -0,0 +1,69 @@ +using Penumbra.GameData.Structs; +using Penumbra.Meta.Files; +using Penumbra.Meta.Manipulations; + +namespace Penumbra.Meta; + +public class ImcChecker +{ + private static readonly Dictionary VariantCounts = []; + private static MetaFileManager? _dataManager; + private static readonly ConcurrentDictionary GlobalCachedDefaultEntries = []; + + public static int GetVariantCount(ImcIdentifier identifier) + { + lock (VariantCounts) + { + if (VariantCounts.TryGetValue(identifier, out var count)) + return count; + + count = GetFile(identifier)?.Count ?? 0; + VariantCounts[identifier] = count; + + return count; + } + } + + public readonly record struct CachedEntry(ImcEntry Entry, bool FileExists, bool VariantExists); + + public ImcChecker(MetaFileManager metaFileManager) + => _dataManager = metaFileManager; + + public static CachedEntry GetDefaultEntry(ImcIdentifier identifier, bool storeCache) + { + if (GlobalCachedDefaultEntries.TryGetValue(identifier, out var entry)) + return entry; + + if (_dataManager == null) + return new CachedEntry(default, false, false); + + try + { + var e = ImcFile.GetDefault(_dataManager, identifier.GamePath(), identifier.EquipSlot, identifier.Variant, out var entryExists); + entry = new CachedEntry(e, true, entryExists); + } + catch (Exception) + { + entry = new CachedEntry(default, false, false); + } + + if (storeCache) + GlobalCachedDefaultEntries.TryAdd(identifier, entry); + return entry; + } + + private static ImcFile? GetFile(ImcIdentifier identifier) + { + if (_dataManager == null) + return null; + + try + { + return new ImcFile(_dataManager, identifier); + } + catch + { + return null; + } + } +} diff --git a/Penumbra/Meta/Manipulations/AtchIdentifier.cs b/Penumbra/Meta/Manipulations/AtchIdentifier.cs new file mode 100644 index 00000000..c248c48b --- /dev/null +++ b/Penumbra/Meta/Manipulations/AtchIdentifier.cs @@ -0,0 +1,77 @@ +using Newtonsoft.Json.Linq; +using Penumbra.GameData.Data; +using Penumbra.GameData.Enums; +using Penumbra.GameData.Files.AtchStructs; +using Penumbra.Interop.Structs; + +namespace Penumbra.Meta.Manipulations; + +public readonly record struct AtchIdentifier(AtchType Type, GenderRace GenderRace, ushort EntryIndex) + : IComparable, IMetaIdentifier +{ + public Gender Gender + => GenderRace.Split().Item1; + + public ModelRace Race + => GenderRace.Split().Item2; + + public int CompareTo(AtchIdentifier other) + { + var typeComparison = Type.CompareTo(other.Type); + if (typeComparison != 0) + return typeComparison; + + var genderRaceComparison = GenderRace.CompareTo(other.GenderRace); + if (genderRaceComparison != 0) + return genderRaceComparison; + + return EntryIndex.CompareTo(other.EntryIndex); + } + + public override string ToString() + => $"Atch - {Type.ToAbbreviation()} - {GenderRace.ToName()} - {EntryIndex}"; + + public void AddChangedItems(ObjectIdentification identifier, IDictionary changedItems) + { + // Nothing specific + } + + public MetaIndex FileIndex() + => (MetaIndex)(-1); + + public bool Validate() + { + var race = (int)GenderRace / 100; + var remainder = (int)GenderRace - 100 * race; + if (remainder != 1) + return false; + + return race is >= 0 and <= 18; + } + + public JObject AddToJson(JObject jObj) + { + var (gender, race) = GenderRace.Split(); + jObj["Gender"] = gender.ToString(); + jObj["Race"] = race.ToString(); + jObj["Type"] = Type.ToAbbreviation(); + jObj["Index"] = EntryIndex; + return jObj; + } + + public static AtchIdentifier? FromJson(JObject jObj) + { + var gender = jObj["Gender"]?.ToObject() ?? Gender.Unknown; + var race = jObj["Race"]?.ToObject() ?? ModelRace.Unknown; + var type = AtchExtensions.FromString(jObj["Type"]?.ToObject() ?? string.Empty); + var entryIndex = jObj["Index"]?.ToObject() ?? ushort.MaxValue; + if (entryIndex == ushort.MaxValue || type is AtchType.Unknown) + return null; + + var ret = new AtchIdentifier(type, Names.CombinedRace(gender, race), entryIndex); + return ret.Validate() ? ret : null; + } + + MetaManipulationType IMetaIdentifier.Type + => MetaManipulationType.Atch; +} diff --git a/Penumbra/Meta/Manipulations/AtrIdentifier.cs b/Penumbra/Meta/Manipulations/AtrIdentifier.cs new file mode 100644 index 00000000..ca65f6aa --- /dev/null +++ b/Penumbra/Meta/Manipulations/AtrIdentifier.cs @@ -0,0 +1,145 @@ +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using Penumbra.Collections.Cache; +using Penumbra.GameData.Data; +using Penumbra.GameData.Enums; +using Penumbra.GameData.Structs; +using Penumbra.Interop.Structs; +using Penumbra.Meta.Files; + +namespace Penumbra.Meta.Manipulations; + +public readonly record struct AtrIdentifier(HumanSlot Slot, PrimaryId? Id, ShapeAttributeString Attribute, GenderRace GenderRaceCondition) + : IComparable, IMetaIdentifier +{ + public int CompareTo(AtrIdentifier other) + { + var slotComparison = Slot.CompareTo(other.Slot); + if (slotComparison is not 0) + return slotComparison; + + if (Id.HasValue) + { + if (other.Id.HasValue) + { + var idComparison = Id.Value.Id.CompareTo(other.Id.Value.Id); + if (idComparison is not 0) + return idComparison; + } + else + { + return -1; + } + } + else if (other.Id.HasValue) + { + return 1; + } + + var genderRaceComparison = GenderRaceCondition.CompareTo(other.GenderRaceCondition); + if (genderRaceComparison is not 0) + return genderRaceComparison; + + return Attribute.CompareTo(other.Attribute); + } + + + public override string ToString() + { + var sb = new StringBuilder(64); + sb.Append("Shp - ") + .Append(Attribute); + if (Slot is HumanSlot.Unknown) + { + sb.Append(" - All Slots & IDs"); + } + else + { + sb.Append(" - ") + .Append(Slot.ToName()) + .Append(" - "); + if (Id.HasValue) + sb.Append(Id.Value.Id); + else + sb.Append("All IDs"); + } + + if (GenderRaceCondition is not GenderRace.Unknown) + sb.Append(" - ").Append(GenderRaceCondition.ToRaceCode()); + + return sb.ToString(); + } + + public void AddChangedItems(ObjectIdentification identifier, IDictionary changedItems) + { + // Nothing for now since it depends entirely on the shape key. + } + + public MetaIndex FileIndex() + => (MetaIndex)(-1); + + public bool Validate() + { + if (!Enum.IsDefined(Slot) || Slot is HumanSlot.UnkBonus) + return false; + + if (!ShapeAttributeHashSet.GenderRaceIndices.ContainsKey(GenderRaceCondition)) + return false; + + if (Slot is HumanSlot.Unknown && Id is not null) + return false; + + if (Slot.ToSpecificEnum() is BodySlot && Id is { Id: > byte.MaxValue }) + return false; + + if (Id is { Id: > ExpandedEqpGmpBase.Count - 1 }) + return false; + + return Attribute.ValidateCustomAttributeString(); + } + + public JObject AddToJson(JObject jObj) + { + if (Slot is not HumanSlot.Unknown) + jObj["Slot"] = Slot.ToString(); + if (Id.HasValue) + jObj["Id"] = Id.Value.Id.ToString(); + jObj["Attribute"] = Attribute.ToString(); + if (GenderRaceCondition is not GenderRace.Unknown) + jObj["GenderRaceCondition"] = (uint)GenderRaceCondition; + return jObj; + } + + public static AtrIdentifier? FromJson(JObject jObj) + { + var attribute = jObj["Attribute"]?.ToObject(); + if (attribute is null || !ShapeAttributeString.TryRead(attribute, out var attributeString)) + return null; + + var slot = jObj["Slot"]?.ToObject() ?? HumanSlot.Unknown; + var id = jObj["Id"]?.ToObject(); + var genderRaceCondition = jObj["GenderRaceCondition"]?.ToObject() ?? 0; + var identifier = new AtrIdentifier(slot, id, attributeString, genderRaceCondition); + return identifier.Validate() ? identifier : null; + } + + public MetaManipulationType Type + => MetaManipulationType.Atr; +} + +[JsonConverter(typeof(Converter))] +public readonly record struct AtrEntry(bool Value) +{ + public static readonly AtrEntry True = new(true); + public static readonly AtrEntry False = new(false); + + private class Converter : JsonConverter + { + public override void WriteJson(JsonWriter writer, AtrEntry value, JsonSerializer serializer) + => serializer.Serialize(writer, value.Value); + + public override AtrEntry ReadJson(JsonReader reader, Type objectType, AtrEntry existingValue, bool hasExistingValue, + JsonSerializer serializer) + => new(serializer.Deserialize(reader)); + } +} diff --git a/Penumbra/Meta/Manipulations/Eqdp.cs b/Penumbra/Meta/Manipulations/Eqdp.cs new file mode 100644 index 00000000..c8423b92 --- /dev/null +++ b/Penumbra/Meta/Manipulations/Eqdp.cs @@ -0,0 +1,95 @@ +using Newtonsoft.Json.Linq; +using Penumbra.GameData.Data; +using Penumbra.GameData.Enums; +using Penumbra.GameData.Structs; +using Penumbra.Interop.Structs; + +namespace Penumbra.Meta.Manipulations; + +public readonly record struct EqdpIdentifier(PrimaryId SetId, EquipSlot Slot, GenderRace GenderRace) + : IMetaIdentifier, IComparable +{ + public ModelRace Race + => GenderRace.Split().Item2; + + public Gender Gender + => GenderRace.Split().Item1; + + public void AddChangedItems(ObjectIdentification identifier, IDictionary changedItems) + => identifier.Identify(changedItems, GamePaths.Mdl.Equipment(SetId, GenderRace, Slot)); + + public MetaIndex FileIndex() + => CharacterUtilityData.EqdpIdx(GenderRace, Slot.IsAccessory()); + + public override string ToString() + => $"Eqdp - {SetId} - {Slot.ToName()} - {GenderRace.ToName()}"; + + public bool Validate() + { + var mask = Eqdp.Mask(Slot); + if (mask == 0) + return false; + + if (FileIndex() == (MetaIndex)(-1)) + return false; + + // No check for set id. + return true; + } + + public int CompareTo(EqdpIdentifier other) + { + var gr = GenderRace.CompareTo(other.GenderRace); + if (gr != 0) + return gr; + + var set = SetId.Id.CompareTo(other.SetId.Id); + if (set != 0) + return set; + + return Slot.CompareTo(other.Slot); + } + + public static EqdpIdentifier? FromJson(JObject jObj) + { + var gender = jObj["Gender"]?.ToObject() ?? Gender.Unknown; + var race = jObj["Race"]?.ToObject() ?? ModelRace.Unknown; + var setId = new PrimaryId(jObj["SetId"]?.ToObject() ?? 0); + var slot = jObj["Slot"]?.ToObject() ?? EquipSlot.Unknown; + var ret = new EqdpIdentifier(setId, slot, Names.CombinedRace(gender, race)); + return ret.Validate() ? ret : null; + } + + public JObject AddToJson(JObject jObj) + { + var (gender, race) = GenderRace.Split(); + jObj["Gender"] = gender.ToString(); + jObj["Race"] = race.ToString(); + jObj["SetId"] = SetId.Id.ToString(); + jObj["Slot"] = Slot.ToString(); + return jObj; + } + + public MetaManipulationType Type + => MetaManipulationType.Eqdp; +} + +public readonly record struct EqdpEntryInternal(bool Material, bool Model) +{ + public byte AsByte + => (byte)(Material ? Model ? 3 : 1 : Model ? 2 : 0); + + private EqdpEntryInternal((bool, bool) val) + : this(val.Item1, val.Item2) + { } + + public EqdpEntryInternal(EqdpEntry entry, EquipSlot slot) + : this(entry.ToBits(slot)) + { } + + public EqdpEntry ToEntry(EquipSlot slot) + => Eqdp.FromSlotAndBits(slot, Material, Model); + + public override string ToString() + => $"Material: {Material}, Model: {Model}"; +} diff --git a/Penumbra/Meta/Manipulations/Eqp.cs b/Penumbra/Meta/Manipulations/Eqp.cs new file mode 100644 index 00000000..154aca40 --- /dev/null +++ b/Penumbra/Meta/Manipulations/Eqp.cs @@ -0,0 +1,78 @@ +using Newtonsoft.Json.Linq; +using Penumbra.GameData.Data; +using Penumbra.GameData.Enums; +using Penumbra.GameData.Structs; +using Penumbra.Interop.Structs; + +namespace Penumbra.Meta.Manipulations; + +public readonly record struct EqpIdentifier(PrimaryId SetId, EquipSlot Slot) : IMetaIdentifier, IComparable +{ + public void AddChangedItems(ObjectIdentification identifier, IDictionary changedItems) + => identifier.Identify(changedItems, GamePaths.Mdl.Equipment(SetId, GenderRace.MidlanderMale, Slot)); + + public MetaIndex FileIndex() + => MetaIndex.Eqp; + + public override string ToString() + => $"Eqp - {SetId} - {Slot}"; + + public bool Validate() + { + var mask = Eqp.Mask(Slot); + if (mask == 0) + return false; + + // No check for set id. + return true; + } + + public int CompareTo(EqpIdentifier other) + { + var set = SetId.Id.CompareTo(other.SetId.Id); + if (set != 0) + return set; + + return Slot.CompareTo(other.Slot); + } + + public static EqpIdentifier? FromJson(JObject jObj) + { + var setId = new PrimaryId(jObj["SetId"]?.ToObject() ?? 0); + var slot = jObj["Slot"]?.ToObject() ?? EquipSlot.Unknown; + var ret = new EqpIdentifier(setId, slot); + return ret.Validate() ? ret : null; + } + + public JObject AddToJson(JObject jObj) + { + jObj["SetId"] = SetId.Id.ToString(); + jObj["Slot"] = Slot.ToString(); + return jObj; + } + + public MetaManipulationType Type + => MetaManipulationType.Eqp; +} + +public readonly record struct EqpEntryInternal(uint Value) +{ + public EqpEntryInternal(EqpEntry entry, EquipSlot slot) + : this(GetValue(entry, slot)) + { } + + public EqpEntry ToEntry(EquipSlot slot) + { + var (offset, mask) = Eqp.OffsetAndMask(slot); + return (EqpEntry)((ulong)Value << offset) & mask; + } + + private static uint GetValue(EqpEntry entry, EquipSlot slot) + { + var (offset, mask) = Eqp.OffsetAndMask(slot); + return (uint)((ulong)(entry & mask) >> offset); + } + + public override string ToString() + => Value.ToString("X8"); +} diff --git a/Penumbra/Meta/Manipulations/Est.cs b/Penumbra/Meta/Manipulations/Est.cs new file mode 100644 index 00000000..46a275a5 --- /dev/null +++ b/Penumbra/Meta/Manipulations/Est.cs @@ -0,0 +1,134 @@ +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using Penumbra.GameData.Data; +using Penumbra.GameData.Enums; +using Penumbra.GameData.Structs; +using Penumbra.Interop.Structs; + +namespace Penumbra.Meta.Manipulations; + +public enum EstType : byte +{ + Hair = MetaIndex.HairEst, + Face = MetaIndex.FaceEst, + Body = MetaIndex.BodyEst, + Head = MetaIndex.HeadEst, +} + +public readonly record struct EstIdentifier(PrimaryId SetId, EstType Slot, GenderRace GenderRace) + : IMetaIdentifier, IComparable +{ + public ModelRace Race + => GenderRace.Split().Item2; + + public Gender Gender + => GenderRace.Split().Item1; + + public void AddChangedItems(ObjectIdentification identifier, IDictionary changedItems) + { + switch (Slot) + { + case EstType.Hair: + { + var (gender, race) = GenderRace.Split(); + var id = (CustomizeValue)SetId.Id; + changedItems.UpdateCountOrSet( + $"Customization: {race.ToName()} {gender.ToName()} Hair {SetId}", () => IdentifiedCustomization.Hair(race, gender, id)); + break; + } + case EstType.Face: + { + var (gender, race) = GenderRace.Split(); + var id = (CustomizeValue)SetId.Id; + changedItems.UpdateCountOrSet( + $"Customization: {race.ToName()} {gender.ToName()} Face {SetId}", + () => IdentifiedCustomization.Face(race, gender, id)); + break; + } + case EstType.Body: identifier.Identify(changedItems, GamePaths.Mdl.Equipment(SetId, GenderRace, EquipSlot.Body)); break; + case EstType.Head: identifier.Identify(changedItems, GamePaths.Mdl.Equipment(SetId, GenderRace, EquipSlot.Head)); break; + } + } + + public MetaIndex FileIndex() + => (MetaIndex)Slot; + + public override string ToString() + => $"Est - {SetId} - {Slot} - {GenderRace.ToName()}"; + + public bool Validate() + { + if (!Enum.IsDefined(Slot)) + return false; + if (GenderRace is GenderRace.Unknown || !Enum.IsDefined(GenderRace)) + return false; + + // No known check for set id. + return true; + } + + public int CompareTo(EstIdentifier other) + { + var gr = GenderRace.CompareTo(other.GenderRace); + if (gr != 0) + return gr; + + var id = SetId.Id.CompareTo(other.SetId.Id); + return id != 0 ? id : Slot.CompareTo(other.Slot); + } + + public static EstIdentifier? FromJson(JObject jObj) + { + var gender = jObj["Gender"]?.ToObject() ?? Gender.Unknown; + var race = jObj["Race"]?.ToObject() ?? ModelRace.Unknown; + var setId = new PrimaryId(jObj["SetId"]?.ToObject() ?? 0); + var slot = jObj["Slot"]?.ToObject() ?? 0; + var ret = new EstIdentifier(setId, slot, Names.CombinedRace(gender, race)); + return ret.Validate() ? ret : null; + } + + public JObject AddToJson(JObject jObj) + { + var (gender, race) = GenderRace.Split(); + jObj["Gender"] = gender.ToString(); + jObj["Race"] = race.ToString(); + jObj["SetId"] = SetId.Id.ToString(); + jObj["Slot"] = Slot.ToString(); + return jObj; + } + + public MetaManipulationType Type + => MetaManipulationType.Est; +} + +[JsonConverter(typeof(Converter))] +public readonly record struct EstEntry(ushort Value) +{ + public static readonly EstEntry Zero = new(0); + + public PrimaryId AsId + => new(Value); + + private class Converter : JsonConverter + { + public override void WriteJson(JsonWriter writer, EstEntry value, JsonSerializer serializer) + => serializer.Serialize(writer, value.Value); + + public override EstEntry ReadJson(JsonReader reader, Type objectType, EstEntry existingValue, bool hasExistingValue, + JsonSerializer serializer) + => new(serializer.Deserialize(reader)); + } +} + +public static class EstTypeExtension +{ + public static string ToName(this EstType type) + => type switch + { + EstType.Hair => "hair", + EstType.Face => "face", + EstType.Body => "top", + EstType.Head => "met", + _ => "unk", + }; +} diff --git a/Penumbra/Meta/Manipulations/GlobalEqpManipulation.cs b/Penumbra/Meta/Manipulations/GlobalEqpManipulation.cs new file mode 100644 index 00000000..33399a36 --- /dev/null +++ b/Penumbra/Meta/Manipulations/GlobalEqpManipulation.cs @@ -0,0 +1,105 @@ +using Newtonsoft.Json.Linq; +using Penumbra.GameData.Data; +using Penumbra.GameData.Enums; +using Penumbra.GameData.Structs; +using Penumbra.Interop.Structs; + +namespace Penumbra.Meta.Manipulations; + +public readonly struct GlobalEqpManipulation : IMetaIdentifier +{ + public GlobalEqpType Type { get; init; } + public PrimaryId Condition { get; init; } + + public bool Validate() + { + if (!Enum.IsDefined(Type)) + return false; + + if (Type.HasCondition()) + return Condition.Id is not 0; + + return Condition.Id is 0; + } + + public JObject AddToJson(JObject jObj) + { + jObj[nameof(Type)] = Type.ToString(); + jObj[nameof(Condition)] = Condition.Id; + return jObj; + } + + public static GlobalEqpManipulation? FromJson(JObject? jObj) + { + if (jObj == null) + return null; + + var type = jObj[nameof(Type)]?.ToObject() ?? (GlobalEqpType)100; + var condition = jObj[nameof(Condition)]?.ToObject() ?? 0; + var ret = new GlobalEqpManipulation + { + Type = type, + Condition = condition, + }; + return ret.Validate() ? ret : null; + } + + + public bool Equals(GlobalEqpManipulation other) + => Type == other.Type + && Condition.Equals(other.Condition); + + public int CompareTo(GlobalEqpManipulation other) + { + var typeComp = Type.CompareTo(other); + return typeComp != 0 ? typeComp : Condition.Id.CompareTo(other.Condition.Id); + } + + public override bool Equals(object? obj) + => obj is GlobalEqpManipulation other && Equals(other); + + public override int GetHashCode() + => HashCode.Combine((int)Type, Condition); + + public static bool operator ==(GlobalEqpManipulation left, GlobalEqpManipulation right) + => left.Equals(right); + + public static bool operator !=(GlobalEqpManipulation left, GlobalEqpManipulation right) + => !left.Equals(right); + + public override string ToString() + => $"Global EQP - {Type}{(Condition != 0 ? $" - {Condition.Id}" : string.Empty)}"; + + public void AddChangedItems(ObjectIdentification identifier, IDictionary changedItems) + { + var path = Type switch + { + GlobalEqpType.DoNotHideEarrings => GamePaths.Mdl.Accessory(Condition, GenderRace.MidlanderMale, EquipSlot.Ears), + GlobalEqpType.DoNotHideNecklace => GamePaths.Mdl.Accessory(Condition, GenderRace.MidlanderMale, EquipSlot.Neck), + GlobalEqpType.DoNotHideBracelets => GamePaths.Mdl.Accessory(Condition, GenderRace.MidlanderMale, EquipSlot.Wrists), + GlobalEqpType.DoNotHideRingR => GamePaths.Mdl.Accessory(Condition, GenderRace.MidlanderMale, EquipSlot.RFinger), + GlobalEqpType.DoNotHideRingL => GamePaths.Mdl.Accessory(Condition, GenderRace.MidlanderMale, EquipSlot.LFinger), + GlobalEqpType.DoNotHideHrothgarHats => string.Empty, + GlobalEqpType.DoNotHideVieraHats => string.Empty, + _ => string.Empty, + }; + if (path.Length > 0) + identifier.Identify(changedItems, path); + else if (Type is GlobalEqpType.DoNotHideVieraHats) + changedItems.UpdateCountOrSet("All Hats for Viera", () => new IdentifiedName()); + else if (Type is GlobalEqpType.DoNotHideHrothgarHats) + changedItems.UpdateCountOrSet("All Hats for Hrothgar", () => new IdentifiedName()); + else if (Type is GlobalEqpType.HideHorns) + changedItems.UpdateCountOrSet("All Au Ra Horns", () => new IdentifiedName()); + else if (Type is GlobalEqpType.HideVieraEars) + changedItems.UpdateCountOrSet("All Viera Ears", () => new IdentifiedName()); + else if (Type is GlobalEqpType.HideMiqoteEars) + changedItems.UpdateCountOrSet("All Miqo'te Ears", () => new IdentifiedName()); + } + + public MetaIndex FileIndex() + => MetaIndex.Eqp; + + MetaManipulationType IMetaIdentifier.Type + => MetaManipulationType.GlobalEqp; +} diff --git a/Penumbra/Meta/Manipulations/GlobalEqpType.cs b/Penumbra/Meta/Manipulations/GlobalEqpType.cs new file mode 100644 index 00000000..29bfe825 --- /dev/null +++ b/Penumbra/Meta/Manipulations/GlobalEqpType.cs @@ -0,0 +1,77 @@ +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; + +namespace Penumbra.Meta.Manipulations; + +[JsonConverter(typeof(StringEnumConverter))] +public enum GlobalEqpType +{ + DoNotHideEarrings, + DoNotHideNecklace, + DoNotHideBracelets, + DoNotHideRingR, + DoNotHideRingL, + DoNotHideHrothgarHats, + DoNotHideVieraHats, + HideHorns, + HideVieraEars, + HideMiqoteEars, +} + +public static class GlobalEqpExtensions +{ + public static bool HasCondition(this GlobalEqpType type) + => type switch + { + GlobalEqpType.DoNotHideEarrings => true, + GlobalEqpType.DoNotHideNecklace => true, + GlobalEqpType.DoNotHideBracelets => true, + GlobalEqpType.DoNotHideRingR => true, + GlobalEqpType.DoNotHideRingL => true, + GlobalEqpType.DoNotHideHrothgarHats => false, + GlobalEqpType.DoNotHideVieraHats => false, + GlobalEqpType.HideHorns => false, + GlobalEqpType.HideVieraEars => false, + GlobalEqpType.HideMiqoteEars => false, + _ => false, + }; + + + public static ReadOnlySpan ToName(this GlobalEqpType type) + => type switch + { + GlobalEqpType.DoNotHideEarrings => "Always Show Earrings"u8, + GlobalEqpType.DoNotHideNecklace => "Always Show Necklaces"u8, + GlobalEqpType.DoNotHideBracelets => "Always Show Bracelets"u8, + GlobalEqpType.DoNotHideRingR => "Always Show Rings (Right Finger)"u8, + GlobalEqpType.DoNotHideRingL => "Always Show Rings (Left Finger)"u8, + GlobalEqpType.DoNotHideHrothgarHats => "Always Show Hats for Hrothgar"u8, + GlobalEqpType.DoNotHideVieraHats => "Always Show Hats for Viera"u8, + GlobalEqpType.HideHorns => "Always Hide Horns (Au Ra)"u8, + GlobalEqpType.HideVieraEars => "Always Hide Ears (Viera)"u8, + GlobalEqpType.HideMiqoteEars => "Always Hide Ears (Miqo'te)"u8, + _ => "\0"u8, + }; + + public static ReadOnlySpan ToDescription(this GlobalEqpType type) + => type switch + { + GlobalEqpType.DoNotHideEarrings => "Prevents the game from hiding earrings through other models when a specific earring is worn."u8, + GlobalEqpType.DoNotHideNecklace => + "Prevents the game from hiding necklaces through other models when a specific necklace is worn."u8, + GlobalEqpType.DoNotHideBracelets => + "Prevents the game from hiding bracelets through other models when a specific bracelet is worn."u8, + GlobalEqpType.DoNotHideRingR => + "Prevents the game from hiding rings worn on the right finger through other models when a specific ring is worn on the right finger."u8, + GlobalEqpType.DoNotHideRingL => + "Prevents the game from hiding rings worn on the left finger through other models when a specific ring is worn on the left finger."u8, + GlobalEqpType.DoNotHideHrothgarHats => + "Prevents the game from hiding any hats for Hrothgar that are normally flagged to not display on them."u8, + GlobalEqpType.DoNotHideVieraHats => + "Prevents the game from hiding any hats for Viera that are normally flagged to not display on them."u8, + GlobalEqpType.HideHorns => "Forces the game to hide Au Ra horns regardless of headwear."u8, + GlobalEqpType.HideVieraEars => "Forces the game to hide Viera ears regardless of headwear."u8, + GlobalEqpType.HideMiqoteEars => "Forces the game to hide Miqo'te ears regardless of headwear."u8, + _ => "\0"u8, + }; +} diff --git a/Penumbra/Meta/Manipulations/Gmp.cs b/Penumbra/Meta/Manipulations/Gmp.cs new file mode 100644 index 00000000..5bc81f26 --- /dev/null +++ b/Penumbra/Meta/Manipulations/Gmp.cs @@ -0,0 +1,42 @@ +using Newtonsoft.Json.Linq; +using Penumbra.GameData.Data; +using Penumbra.GameData.Enums; +using Penumbra.GameData.Structs; +using Penumbra.Interop.Structs; + +namespace Penumbra.Meta.Manipulations; + +public readonly record struct GmpIdentifier(PrimaryId SetId) : IMetaIdentifier, IComparable +{ + public void AddChangedItems(ObjectIdentification identifier, IDictionary changedItems) + => identifier.Identify(changedItems, GamePaths.Mdl.Equipment(SetId, GenderRace.MidlanderMale, EquipSlot.Head)); + + public MetaIndex FileIndex() + => MetaIndex.Gmp; + + public override string ToString() + => $"Gmp - {SetId}"; + + public bool Validate() + // No known conditions. + => true; + + public int CompareTo(GmpIdentifier other) + => SetId.Id.CompareTo(other.SetId.Id); + + public static GmpIdentifier? FromJson(JObject jObj) + { + var setId = new PrimaryId(jObj["SetId"]?.ToObject() ?? 0); + var ret = new GmpIdentifier(setId); + return ret.Validate() ? ret : null; + } + + public JObject AddToJson(JObject jObj) + { + jObj["SetId"] = SetId.Id.ToString(); + return jObj; + } + + public MetaManipulationType Type + => MetaManipulationType.Gmp; +} diff --git a/Penumbra/Meta/Manipulations/IMetaIdentifier.cs b/Penumbra/Meta/Manipulations/IMetaIdentifier.cs new file mode 100644 index 00000000..922825c3 --- /dev/null +++ b/Penumbra/Meta/Manipulations/IMetaIdentifier.cs @@ -0,0 +1,35 @@ +using Newtonsoft.Json.Linq; +using Penumbra.GameData.Data; +using Penumbra.Interop.Structs; + +namespace Penumbra.Meta.Manipulations; + +public enum MetaManipulationType : byte +{ + Unknown = 0, + Imc = 1, + Eqdp = 2, + Eqp = 3, + Est = 4, + Gmp = 5, + Rsp = 6, + GlobalEqp = 7, + Atch = 8, + Shp = 9, + Atr = 10, +} + +public interface IMetaIdentifier +{ + public void AddChangedItems(ObjectIdentification identifier, IDictionary changedItems); + + public MetaIndex FileIndex(); + + public bool Validate(); + + public JObject AddToJson(JObject jObj); + + public MetaManipulationType Type { get; } + + public string ToString(); +} diff --git a/Penumbra/Meta/Manipulations/Imc.cs b/Penumbra/Meta/Manipulations/Imc.cs new file mode 100644 index 00000000..fa726708 --- /dev/null +++ b/Penumbra/Meta/Manipulations/Imc.cs @@ -0,0 +1,194 @@ +using Newtonsoft.Json.Linq; +using Penumbra.GameData.Data; +using Penumbra.GameData.Enums; +using Penumbra.GameData.Structs; +using Penumbra.Interop.Structs; +using Penumbra.String.Classes; + +namespace Penumbra.Meta.Manipulations; + +public readonly record struct ImcIdentifier( + PrimaryId PrimaryId, + Variant Variant, + ObjectType ObjectType, + SecondaryId SecondaryId, + EquipSlot EquipSlot, + BodySlot BodySlot) : IMetaIdentifier, IComparable +{ + public static readonly ImcIdentifier Default = new(EquipSlot.Body, 1, (Variant)1); + + public ImcIdentifier(EquipSlot slot, PrimaryId primaryId, ushort variant) + : this(primaryId, (Variant)Math.Clamp(variant, (ushort)0, byte.MaxValue), + slot.IsAccessory() ? ObjectType.Accessory : ObjectType.Equipment, 0, slot, + variant > byte.MaxValue ? BodySlot.Body : BodySlot.Unknown) + { } + + public ImcIdentifier(EquipSlot slot, PrimaryId primaryId, Variant variant) + : this(primaryId, variant, slot.IsAccessory() ? ObjectType.Accessory : ObjectType.Equipment, 0, slot, BodySlot.Unknown) + { } + + public void AddChangedItems(ObjectIdentification identifier, IDictionary changedItems) + => AddChangedItems(identifier, changedItems, false); + + public void AddChangedItems(ObjectIdentification identifier, IDictionary changedItems, bool allVariants) + { + var path = ObjectType switch + { + ObjectType.Equipment when allVariants => GamePaths.Mdl.Equipment(PrimaryId, GenderRace.MidlanderMale, EquipSlot), + ObjectType.Equipment => GamePaths.Mtrl.Equipment(PrimaryId, GenderRace.MidlanderMale, EquipSlot, Variant, "a"), + ObjectType.Accessory when allVariants => GamePaths.Mdl.Accessory(PrimaryId, GenderRace.MidlanderMale, EquipSlot), + ObjectType.Accessory => GamePaths.Mtrl.Accessory(PrimaryId, GenderRace.MidlanderMale, EquipSlot, Variant, "a"), + ObjectType.Weapon => GamePaths.Mtrl.Weapon(PrimaryId, SecondaryId.Id, Variant, "a"), + ObjectType.DemiHuman => GamePaths.Mtrl.DemiHuman(PrimaryId, SecondaryId.Id, EquipSlot, Variant, + "a"), + ObjectType.Monster => GamePaths.Mtrl.Monster(PrimaryId, SecondaryId.Id, Variant, "a"), + _ => string.Empty, + }; + if (path.Length == 0) + return; + + identifier.Identify(changedItems, path); + } + + public string GamePathString() + => GamePaths.Imc.Path(ObjectType, PrimaryId, SecondaryId); + + public Utf8GamePath GamePath() + => Utf8GamePath.FromString(GamePathString(), out var p) ? p : Utf8GamePath.Empty; + + public MetaIndex FileIndex() + => (MetaIndex)(-1); + + public override string ToString() + => ObjectType switch + { + ObjectType.Equipment or ObjectType.Accessory => $"Imc - {PrimaryId} - {EquipSlot.ToName()} - {Variant}", + ObjectType.DemiHuman => $"Imc - {PrimaryId} - DemiHuman - {SecondaryId} - {EquipSlot.ToName()} - {Variant}", + _ => $"Imc - {PrimaryId} - {ObjectType.ToName()} - {SecondaryId} - {BodySlot} - {Variant}", + }; + + + public bool Validate() + { + switch (ObjectType) + { + case ObjectType.Accessory: + case ObjectType.Equipment: + if (BodySlot is not BodySlot.Unknown) + return false; + if (!EquipSlot.IsEquipment() && !EquipSlot.IsAccessory()) + return false; + if (SecondaryId != 0) + return false; + + break; + case ObjectType.DemiHuman: + if (BodySlot is not BodySlot.Unknown) + return false; + if (!EquipSlot.IsEquipment() && !EquipSlot.IsAccessory()) + return false; + + break; + default: + if (!Enum.IsDefined(BodySlot)) + return false; + if (EquipSlot is not EquipSlot.Unknown) + return false; + if (!Enum.IsDefined(ObjectType)) + return false; + if (ItemData.AdaptOffhandImc(PrimaryId, out _)) + return false; + + break; + } + + return true; + } + + public int CompareTo(ImcIdentifier other) + { + var o = ObjectType.CompareTo(other.ObjectType); + if (o != 0) + return o; + + var i = PrimaryId.Id.CompareTo(other.PrimaryId.Id); + if (i != 0) + return i; + + if (ObjectType is ObjectType.Equipment or ObjectType.Accessory) + { + var e = EquipSlot.CompareTo(other.EquipSlot); + return e != 0 ? e : Variant.Id.CompareTo(other.Variant.Id); + } + + if (ObjectType is ObjectType.DemiHuman) + { + var e = EquipSlot.CompareTo(other.EquipSlot); + if (e != 0) + return e; + } + + var s = SecondaryId.Id.CompareTo(other.SecondaryId.Id); + if (s != 0) + return s; + + var b = BodySlot.CompareTo(other.BodySlot); + return b != 0 ? b : Variant.Id.CompareTo(other.Variant.Id); + } + + public static ImcIdentifier? FromJson(JObject? jObj) + { + if (jObj == null) + return null; + + var objectType = jObj["ObjectType"]?.ToObject() ?? ObjectType.Unknown; + var primaryId = new PrimaryId(jObj["PrimaryId"]?.ToObject() ?? 0); + var variant = jObj["Variant"]?.ToObject() ?? 0; + if (variant > byte.MaxValue) + return null; + + ImcIdentifier ret; + switch (objectType) + { + case ObjectType.Equipment: + case ObjectType.Accessory: + { + var slot = jObj["EquipSlot"]?.ToObject() ?? EquipSlot.Unknown; + ret = new ImcIdentifier(slot, primaryId, variant); + break; + } + case ObjectType.DemiHuman: + { + var secondaryId = new SecondaryId(jObj["SecondaryId"]?.ToObject() ?? 0); + var slot = jObj["EquipSlot"]?.ToObject() ?? EquipSlot.Unknown; + ret = new ImcIdentifier(primaryId, (Variant)variant, objectType, secondaryId, slot, BodySlot.Unknown); + break; + } + + case ObjectType.Monster: + case ObjectType.Weapon: + { + var secondaryId = new SecondaryId(jObj["SecondaryId"]?.ToObject() ?? 0); + ret = new ImcIdentifier(primaryId, (Variant)variant, objectType, secondaryId, EquipSlot.Unknown, BodySlot.Body); + break; + } + default: return null; + } + + return ret.Validate() ? ret : null; + } + + public JObject AddToJson(JObject jObj) + { + jObj["ObjectType"] = ObjectType.ToString(); + jObj["PrimaryId"] = PrimaryId.Id; + jObj["SecondaryId"] = SecondaryId.Id; + jObj["Variant"] = Variant.Id; + jObj["EquipSlot"] = EquipSlot.ToString(); + jObj["BodySlot"] = BodySlot.ToString(); + return jObj; + } + + public MetaManipulationType Type + => MetaManipulationType.Imc; +} diff --git a/Penumbra/Meta/Manipulations/MetaDictionary.cs b/Penumbra/Meta/Manipulations/MetaDictionary.cs new file mode 100644 index 00000000..8b448ec6 --- /dev/null +++ b/Penumbra/Meta/Manipulations/MetaDictionary.cs @@ -0,0 +1,1119 @@ +using System.Collections.Frozen; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using Penumbra.Collections.Cache; +using Penumbra.GameData.Enums; +using Penumbra.GameData.Files.AtchStructs; +using Penumbra.GameData.Interop; +using Penumbra.GameData.Structs; +using Penumbra.Util; +using ImcEntry = Penumbra.GameData.Structs.ImcEntry; + +namespace Penumbra.Meta.Manipulations; + +[JsonConverter(typeof(Converter))] +public class MetaDictionary +{ + private class Wrapper : HashSet + { + public readonly Dictionary Imc = []; + public readonly Dictionary Eqp = []; + public readonly Dictionary Eqdp = []; + public readonly Dictionary Est = []; + public readonly Dictionary Rsp = []; + public readonly Dictionary Gmp = []; + public readonly Dictionary Atch = []; + public readonly Dictionary Shp = []; + public readonly Dictionary Atr = []; + + public Wrapper() + { } + + public Wrapper(MetaCache cache) + { + Imc = cache.Imc.ToDictionary(kvp => kvp.Key, kvp => kvp.Value.Entry); + Eqp = cache.Eqp.ToDictionary(kvp => kvp.Key, kvp => new EqpEntryInternal(kvp.Value.Entry, kvp.Key.Slot)); + Eqdp = cache.Eqdp.ToDictionary(kvp => kvp.Key, kvp => new EqdpEntryInternal(kvp.Value.Entry, kvp.Key.Slot)); + Est = cache.Est.ToDictionary(kvp => kvp.Key, kvp => kvp.Value.Entry); + Gmp = cache.Gmp.ToDictionary(kvp => kvp.Key, kvp => kvp.Value.Entry); + Rsp = cache.Rsp.ToDictionary(kvp => kvp.Key, kvp => kvp.Value.Entry); + Atch = cache.Atch.ToDictionary(kvp => kvp.Key, kvp => kvp.Value.Entry); + Shp = cache.Shp.ToDictionary(kvp => kvp.Key, kvp => kvp.Value.Entry); + Atr = cache.Atr.ToDictionary(kvp => kvp.Key, kvp => kvp.Value.Entry); + foreach (var geqp in cache.GlobalEqp.Keys) + Add(geqp); + } + + public static unsafe Wrapper Filtered(MetaCache cache, Actor actor) + { + if (!actor.IsCharacter) + return new Wrapper(cache); + + var model = actor.Model; + if (!model.IsHuman) + return new Wrapper(cache); + + var headId = model.GetModelId(HumanSlot.Head); + var bodyId = model.GetModelId(HumanSlot.Body); + var equipIdSet = ((IEnumerable) + [ + headId, + bodyId, + model.GetModelId(HumanSlot.Hands), + model.GetModelId(HumanSlot.Legs), + model.GetModelId(HumanSlot.Feet), + ]).ToFrozenSet(); + var earsId = model.GetModelId(HumanSlot.Ears); + var neckId = model.GetModelId(HumanSlot.Neck); + var wristId = model.GetModelId(HumanSlot.Wrists); + var rFingerId = model.GetModelId(HumanSlot.RFinger); + var lFingerId = model.GetModelId(HumanSlot.LFinger); + + var wrapper = new Wrapper(); + // Check for all relevant primary IDs due to slot overlap. + foreach (var (eqp, value) in cache.Eqp) + { + if (eqp.Slot.IsEquipment()) + { + if (equipIdSet.Contains(eqp.SetId)) + wrapper.Eqp.Add(eqp, new EqpEntryInternal(value.Entry, eqp.Slot)); + } + else + { + switch (eqp.Slot) + { + case EquipSlot.Ears when eqp.SetId == earsId: + case EquipSlot.Neck when eqp.SetId == neckId: + case EquipSlot.Wrists when eqp.SetId == wristId: + case EquipSlot.RFinger when eqp.SetId == rFingerId: + case EquipSlot.LFinger when eqp.SetId == lFingerId: + wrapper.Eqp.Add(eqp, new EqpEntryInternal(value.Entry, eqp.Slot)); + break; + } + } + } + + // Check also for body IDs due to body occupying head. + foreach (var (gmp, value) in cache.Gmp) + { + if (gmp.SetId == headId || gmp.SetId == bodyId) + wrapper.Gmp.Add(gmp, value.Entry); + } + + // Check for all races due to inheritance and all slots due to overlap. + foreach (var (eqdp, value) in cache.Eqdp) + { + if (eqdp.Slot.IsEquipment()) + { + if (equipIdSet.Contains(eqdp.SetId)) + wrapper.Eqdp.Add(eqdp, new EqdpEntryInternal(value.Entry, eqdp.Slot)); + } + else + { + switch (eqdp.Slot) + { + case EquipSlot.Ears when eqdp.SetId == earsId: + case EquipSlot.Neck when eqdp.SetId == neckId: + case EquipSlot.Wrists when eqdp.SetId == wristId: + case EquipSlot.RFinger when eqdp.SetId == rFingerId: + case EquipSlot.LFinger when eqdp.SetId == lFingerId: + wrapper.Eqdp.Add(eqdp, new EqdpEntryInternal(value.Entry, eqdp.Slot)); + break; + } + } + } + + var genderRace = (GenderRace)model.AsHuman->RaceSexId; + var hairId = model.GetModelId(HumanSlot.Hair); + var faceId = model.GetModelId(HumanSlot.Face); + // We do not need to care for racial inheritance for ESTs. + foreach (var (est, value) in cache.Est) + { + switch (est.Slot) + { + case EstType.Hair when est.SetId == hairId && est.GenderRace == genderRace: + case EstType.Face when est.SetId == faceId && est.GenderRace == genderRace: + case EstType.Body when est.SetId == bodyId && est.GenderRace == genderRace: + case EstType.Head when (est.SetId == headId || est.SetId == bodyId) && est.GenderRace == genderRace: + wrapper.Est.Add(est, value.Entry); + break; + } + } + + foreach (var (geqp, _) in cache.GlobalEqp) + { + switch (geqp.Type) + { + case GlobalEqpType.DoNotHideEarrings when geqp.Condition != earsId: + case GlobalEqpType.DoNotHideNecklace when geqp.Condition != neckId: + case GlobalEqpType.DoNotHideBracelets when geqp.Condition != wristId: + case GlobalEqpType.DoNotHideRingR when geqp.Condition != rFingerId: + case GlobalEqpType.DoNotHideRingL when geqp.Condition != lFingerId: + continue; + default: wrapper.Add(geqp); break; + } + } + + var (_, _, main, off) = model.GetWeapons(actor); + foreach (var (imc, value) in cache.Imc) + { + switch (imc.ObjectType) + { + case ObjectType.Equipment when equipIdSet.Contains(imc.PrimaryId): wrapper.Imc.Add(imc, value.Entry); break; + + case ObjectType.Weapon: + if (imc.PrimaryId == main.Skeleton && imc.SecondaryId == main.Weapon) + wrapper.Imc.Add(imc, value.Entry); + else if (imc.PrimaryId == off.Skeleton && imc.SecondaryId == off.Weapon) + wrapper.Imc.Add(imc, value.Entry); + break; + case ObjectType.Accessory: + switch (imc.EquipSlot) + { + case EquipSlot.Ears when imc.PrimaryId == earsId: + case EquipSlot.Neck when imc.PrimaryId == neckId: + case EquipSlot.Wrists when imc.PrimaryId == wristId: + case EquipSlot.RFinger when imc.PrimaryId == rFingerId: + case EquipSlot.LFinger when imc.PrimaryId == lFingerId: + wrapper.Imc.Add(imc, value.Entry); + break; + } + + break; + } + } + + var subRace = (SubRace)model.AsHuman->Customize[4]; + foreach (var (rsp, value) in cache.Rsp) + { + if (rsp.SubRace == subRace) + wrapper.Rsp.Add(rsp, value.Entry); + } + + // Keep all atch, atr and shp. + wrapper.Atch.EnsureCapacity(cache.Atch.Count); + wrapper.Shp.EnsureCapacity(cache.Shp.Count); + wrapper.Atr.EnsureCapacity(cache.Atr.Count); + foreach (var (atch, value) in cache.Atch) + wrapper.Atch.Add(atch, value.Entry); + foreach (var (shp, value) in cache.Shp) + wrapper.Shp.Add(shp, value.Entry); + foreach (var (atr, value) in cache.Atr) + wrapper.Atr.Add(atr, value.Entry); + return wrapper; + } + } + + private Wrapper? _data; + + public IReadOnlyDictionary Imc + => _data?.Imc ?? []; + + public IReadOnlyDictionary Eqp + => _data?.Eqp ?? []; + + public IReadOnlyDictionary Eqdp + => _data?.Eqdp ?? []; + + public IReadOnlyDictionary Est + => _data?.Est ?? []; + + public IReadOnlyDictionary Gmp + => _data?.Gmp ?? []; + + public IReadOnlyDictionary Rsp + => _data?.Rsp ?? []; + + public IReadOnlyDictionary Atch + => _data?.Atch ?? []; + + public IReadOnlyDictionary Shp + => _data?.Shp ?? []; + + public IReadOnlyDictionary Atr + => _data?.Atr ?? []; + + public IReadOnlySet GlobalEqp + => _data ?? []; + + public int Count { get; private set; } + + public int GetCount(MetaManipulationType type) + => _data is null + ? 0 + : type switch + { + MetaManipulationType.Imc => _data.Imc.Count, + MetaManipulationType.Eqdp => _data.Eqdp.Count, + MetaManipulationType.Eqp => _data.Eqp.Count, + MetaManipulationType.Est => _data.Est.Count, + MetaManipulationType.Gmp => _data.Gmp.Count, + MetaManipulationType.Rsp => _data.Rsp.Count, + MetaManipulationType.Atch => _data.Atch.Count, + MetaManipulationType.Shp => _data.Shp.Count, + MetaManipulationType.Atr => _data.Atr.Count, + MetaManipulationType.GlobalEqp => _data.Count, + _ => 0, + }; + + public bool Contains(IMetaIdentifier identifier) + => _data is not null + && identifier switch + { + EqdpIdentifier i => _data.Eqdp.ContainsKey(i), + EqpIdentifier i => _data.Eqp.ContainsKey(i), + EstIdentifier i => _data.Est.ContainsKey(i), + GlobalEqpManipulation i => _data.Contains(i), + GmpIdentifier i => _data.Gmp.ContainsKey(i), + ImcIdentifier i => _data.Imc.ContainsKey(i), + AtchIdentifier i => _data.Atch.ContainsKey(i), + ShpIdentifier i => _data.Shp.ContainsKey(i), + AtrIdentifier i => _data.Atr.ContainsKey(i), + RspIdentifier i => _data.Rsp.ContainsKey(i), + _ => false, + }; + + public void Clear() + { + _data = null; + Count = 0; + } + + public void ClearForDefault() + { + if (_data is null) + return; + + if (_data.Count is 0 && Shp.Count is 0 && Atr.Count is 0) + { + _data = null; + Count = 0; + return; + } + + Count = GlobalEqp.Count + Shp.Count + Atr.Count; + _data!.Imc.Clear(); + _data!.Eqp.Clear(); + _data!.Eqdp.Clear(); + _data!.Est.Clear(); + _data!.Rsp.Clear(); + _data!.Gmp.Clear(); + _data!.Atch.Clear(); + } + + public bool Equals(MetaDictionary other) + { + if (Count != other.Count) + return false; + + if (_data is null) + return true; + + return _data.Imc.SetEquals(other._data!.Imc) + && _data.Eqp.SetEquals(other._data!.Eqp) + && _data.Eqdp.SetEquals(other._data!.Eqdp) + && _data.Est.SetEquals(other._data!.Est) + && _data.Rsp.SetEquals(other._data!.Rsp) + && _data.Gmp.SetEquals(other._data!.Gmp) + && _data.Atch.SetEquals(other._data!.Atch) + && _data.Shp.SetEquals(other._data!.Shp) + && _data.Atr.SetEquals(other._data!.Atr) + && _data.SetEquals(other._data!); + } + + public IEnumerable Identifiers + => _data is null + ? [] + : _data.Imc.Keys.Cast() + .Concat(_data!.Eqdp.Keys.Cast()) + .Concat(_data!.Eqp.Keys.Cast()) + .Concat(_data!.Est.Keys.Cast()) + .Concat(_data!.Gmp.Keys.Cast()) + .Concat(_data!.Rsp.Keys.Cast()) + .Concat(_data!.Atch.Keys.Cast()) + .Concat(_data!.Shp.Keys.Cast()) + .Concat(_data!.Atr.Keys.Cast()) + .Concat(_data!.Cast()); + + #region TryAdd + + public bool TryAdd(ImcIdentifier identifier, ImcEntry entry) + { + _data ??= []; + if (!_data!.Imc.TryAdd(identifier, entry)) + return false; + + ++Count; + return true; + } + + public bool TryAdd(EqpIdentifier identifier, EqpEntryInternal entry) + { + _data ??= []; + if (!_data!.Eqp.TryAdd(identifier, entry)) + return false; + + ++Count; + return true; + } + + public bool TryAdd(EqpIdentifier identifier, EqpEntry entry) + => TryAdd(identifier, new EqpEntryInternal(entry, identifier.Slot)); + + public bool TryAdd(EqdpIdentifier identifier, EqdpEntryInternal entry) + { + _data ??= []; + if (!_data!.Eqdp.TryAdd(identifier, entry)) + return false; + + ++Count; + return true; + } + + public bool TryAdd(EqdpIdentifier identifier, EqdpEntry entry) + => TryAdd(identifier, new EqdpEntryInternal(entry, identifier.Slot)); + + public bool TryAdd(EstIdentifier identifier, EstEntry entry) + { + _data ??= []; + if (!_data!.Est.TryAdd(identifier, entry)) + return false; + + ++Count; + return true; + } + + public bool TryAdd(GmpIdentifier identifier, GmpEntry entry) + { + _data ??= []; + if (!_data!.Gmp.TryAdd(identifier, entry)) + return false; + + ++Count; + return true; + } + + public bool TryAdd(RspIdentifier identifier, RspEntry entry) + { + _data ??= []; + if (!_data!.Rsp.TryAdd(identifier, entry)) + return false; + + ++Count; + return true; + } + + public bool TryAdd(AtchIdentifier identifier, in AtchEntry entry) + { + _data ??= []; + if (!_data!.Atch.TryAdd(identifier, entry)) + return false; + + ++Count; + return true; + } + + public bool TryAdd(ShpIdentifier identifier, in ShpEntry entry) + { + _data ??= []; + if (!_data!.Shp.TryAdd(identifier, entry)) + return false; + + ++Count; + return true; + } + + public bool TryAdd(AtrIdentifier identifier, in AtrEntry entry) + { + _data ??= []; + if (!_data!.Atr.TryAdd(identifier, entry)) + return false; + + ++Count; + return true; + } + + public bool TryAdd(GlobalEqpManipulation identifier) + { + _data ??= []; + if (!_data.Add(identifier)) + return false; + + ++Count; + return true; + } + + #endregion + + #region Update + + public bool Update(ImcIdentifier identifier, ImcEntry entry) + { + if (_data is null || !_data.Imc.ContainsKey(identifier)) + return false; + + _data.Imc[identifier] = entry; + return true; + } + + public bool Update(EqpIdentifier identifier, EqpEntryInternal entry) + { + if (_data is null || !_data.Eqp.ContainsKey(identifier)) + return false; + + _data.Eqp[identifier] = entry; + return true; + } + + public bool Update(EqpIdentifier identifier, EqpEntry entry) + => Update(identifier, new EqpEntryInternal(entry, identifier.Slot)); + + public bool Update(EqdpIdentifier identifier, EqdpEntryInternal entry) + { + if (_data is null || !_data.Eqdp.ContainsKey(identifier)) + return false; + + _data.Eqdp[identifier] = entry; + return true; + } + + public bool Update(EqdpIdentifier identifier, EqdpEntry entry) + => Update(identifier, new EqdpEntryInternal(entry, identifier.Slot)); + + public bool Update(EstIdentifier identifier, EstEntry entry) + { + if (_data is null || !_data.Est.ContainsKey(identifier)) + return false; + + _data.Est[identifier] = entry; + return true; + } + + public bool Update(GmpIdentifier identifier, GmpEntry entry) + { + if (_data is null || !_data.Gmp.ContainsKey(identifier)) + return false; + + _data.Gmp[identifier] = entry; + return true; + } + + public bool Update(RspIdentifier identifier, RspEntry entry) + { + if (_data is null || !_data.Rsp.ContainsKey(identifier)) + return false; + + _data.Rsp[identifier] = entry; + return true; + } + + public bool Update(AtchIdentifier identifier, in AtchEntry entry) + { + if (_data is null || !_data.Atch.ContainsKey(identifier)) + return false; + + _data.Atch[identifier] = entry; + return true; + } + + public bool Update(ShpIdentifier identifier, in ShpEntry entry) + { + if (_data is null || !_data.Shp.ContainsKey(identifier)) + return false; + + _data.Shp[identifier] = entry; + return true; + } + + public bool Update(AtrIdentifier identifier, in AtrEntry entry) + { + if (_data is null || !_data.Atr.ContainsKey(identifier)) + return false; + + _data.Atr[identifier] = entry; + return true; + } + + #endregion + + #region TryGetValue + + public bool TryGetValue(EstIdentifier identifier, out EstEntry value) + => _data?.Est.TryGetValue(identifier, out value) ?? SetDefault(out value); + + public bool TryGetValue(EqpIdentifier identifier, out EqpEntryInternal value) + => _data?.Eqp.TryGetValue(identifier, out value) ?? SetDefault(out value); + + public bool TryGetValue(EqdpIdentifier identifier, out EqdpEntryInternal value) + => _data?.Eqdp.TryGetValue(identifier, out value) ?? SetDefault(out value); + + public bool TryGetValue(GmpIdentifier identifier, out GmpEntry value) + => _data?.Gmp.TryGetValue(identifier, out value) ?? SetDefault(out value); + + public bool TryGetValue(RspIdentifier identifier, out RspEntry value) + => _data?.Rsp.TryGetValue(identifier, out value) ?? SetDefault(out value); + + public bool TryGetValue(ImcIdentifier identifier, out ImcEntry value) + => _data?.Imc.TryGetValue(identifier, out value) ?? SetDefault(out value); + + public bool TryGetValue(AtchIdentifier identifier, out AtchEntry value) + => _data?.Atch.TryGetValue(identifier, out value) ?? SetDefault(out value); + + public bool TryGetValue(ShpIdentifier identifier, out ShpEntry value) + => _data?.Shp.TryGetValue(identifier, out value) ?? SetDefault(out value); + + public bool TryGetValue(AtrIdentifier identifier, out AtrEntry value) + => _data?.Atr.TryGetValue(identifier, out value) ?? SetDefault(out value); + + [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] + private static bool SetDefault(out T? value) + { + value = default; + return false; + } + + #endregion + + public bool Remove(IMetaIdentifier identifier) + { + if (_data is null) + return false; + + var ret = identifier switch + { + EqdpIdentifier i => _data.Eqdp.Remove(i), + EqpIdentifier i => _data.Eqp.Remove(i), + EstIdentifier i => _data.Est.Remove(i), + GlobalEqpManipulation i => _data.Remove(i), + GmpIdentifier i => _data.Gmp.Remove(i), + ImcIdentifier i => _data.Imc.Remove(i), + RspIdentifier i => _data.Rsp.Remove(i), + AtchIdentifier i => _data.Atch.Remove(i), + ShpIdentifier i => _data.Shp.Remove(i), + AtrIdentifier i => _data.Atr.Remove(i), + _ => false, + }; + if (ret && --Count is 0) + _data = null; + + return ret; + } + + #region Merging + + public void UnionWith(MetaDictionary manips) + { + if (manips.Count is 0) + return; + + _data ??= []; + foreach (var (identifier, entry) in manips._data!.Imc) + TryAdd(identifier, entry); + + foreach (var (identifier, entry) in manips._data!.Eqp) + TryAdd(identifier, entry); + + foreach (var (identifier, entry) in manips._data!.Eqdp) + TryAdd(identifier, entry); + + foreach (var (identifier, entry) in manips._data!.Gmp) + TryAdd(identifier, entry); + + foreach (var (identifier, entry) in manips._data!.Rsp) + TryAdd(identifier, entry); + + foreach (var (identifier, entry) in manips._data!.Est) + TryAdd(identifier, entry); + + foreach (var (identifier, entry) in manips._data!.Atch) + TryAdd(identifier, entry); + + foreach (var (identifier, entry) in manips._data!.Shp) + TryAdd(identifier, entry); + + foreach (var (identifier, entry) in manips._data!.Atr) + TryAdd(identifier, entry); + + foreach (var identifier in manips._data!) + TryAdd(identifier); + } + + /// Try to merge all manipulations from manips into this, and return the first failure, if any. + public bool MergeForced(MetaDictionary manips, out IMetaIdentifier? failedIdentifier) + { + if (manips.Count is 0) + { + failedIdentifier = null; + return true; + } + + _data ??= []; + foreach (var (identifier, _) in manips._data!.Imc.Where(kvp => !TryAdd(kvp.Key, kvp.Value))) + { + failedIdentifier = identifier; + return false; + } + + foreach (var (identifier, _) in manips._data!.Eqp.Where(kvp => !TryAdd(kvp.Key, kvp.Value))) + { + failedIdentifier = identifier; + return false; + } + + foreach (var (identifier, _) in manips._data!.Eqdp.Where(kvp => !TryAdd(kvp.Key, kvp.Value))) + { + failedIdentifier = identifier; + return false; + } + + foreach (var (identifier, _) in manips._data!.Gmp.Where(kvp => !TryAdd(kvp.Key, kvp.Value))) + { + failedIdentifier = identifier; + return false; + } + + foreach (var (identifier, _) in manips._data!.Rsp.Where(kvp => !TryAdd(kvp.Key, kvp.Value))) + { + failedIdentifier = identifier; + return false; + } + + foreach (var (identifier, _) in manips._data!.Est.Where(kvp => !TryAdd(kvp.Key, kvp.Value))) + { + failedIdentifier = identifier; + return false; + } + + foreach (var (identifier, _) in manips._data!.Atch.Where(kvp => !TryAdd(kvp.Key, kvp.Value))) + { + failedIdentifier = identifier; + return false; + } + + foreach (var (identifier, _) in manips._data!.Shp.Where(kvp => !TryAdd(kvp.Key, kvp.Value))) + { + failedIdentifier = identifier; + return false; + } + + foreach (var (identifier, _) in manips._data!.Atr.Where(kvp => !TryAdd(kvp.Key, kvp.Value))) + { + failedIdentifier = identifier; + return false; + } + + foreach (var identifier in manips._data!.Where(identifier => !TryAdd(identifier))) + { + failedIdentifier = identifier; + return false; + } + + failedIdentifier = null; + return true; + } + + public void SetTo(MetaDictionary other) + { + if (other.Count is 0) + { + _data = null; + Count = 0; + return; + } + + _data ??= []; + _data!.Imc.SetTo(other._data!.Imc); + _data!.Eqp.SetTo(other._data!.Eqp); + _data!.Eqdp.SetTo(other._data!.Eqdp); + _data!.Est.SetTo(other._data!.Est); + _data!.Rsp.SetTo(other._data!.Rsp); + _data!.Gmp.SetTo(other._data!.Gmp); + _data!.Atch.SetTo(other._data!.Atch); + _data!.Shp.SetTo(other._data!.Shp); + _data!.Atr.SetTo(other._data!.Atr); + _data!.SetTo(other._data!); + Count = other.Count; + } + + public void UpdateTo(MetaDictionary other) + { + if (other.Count is 0) + return; + + _data ??= []; + _data!.Imc.UpdateTo(other._data!.Imc); + _data!.Eqp.UpdateTo(other._data!.Eqp); + _data!.Eqdp.UpdateTo(other._data!.Eqdp); + _data!.Est.UpdateTo(other._data!.Est); + _data!.Rsp.UpdateTo(other._data!.Rsp); + _data!.Gmp.UpdateTo(other._data!.Gmp); + _data!.Atch.UpdateTo(other._data!.Atch); + _data!.Shp.UpdateTo(other._data!.Shp); + _data!.Atr.UpdateTo(other._data!.Atr); + _data!.UnionWith(other._data!); + Count = _data!.Imc.Count + + _data!.Eqp.Count + + _data!.Eqdp.Count + + _data!.Est.Count + + _data!.Rsp.Count + + _data!.Gmp.Count + + _data!.Atch.Count + + _data!.Shp.Count + + _data!.Atr.Count + + _data!.Count; + } + + #endregion + + public MetaDictionary Clone() + { + var ret = new MetaDictionary(); + ret.SetTo(this); + return ret; + } + + public static JObject Serialize(EqpIdentifier identifier, EqpEntryInternal entry) + => Serialize(identifier, entry.ToEntry(identifier.Slot)); + + public static JObject Serialize(EqpIdentifier identifier, EqpEntry entry) + => new() + { + ["Type"] = MetaManipulationType.Eqp.ToString(), + ["Manipulation"] = identifier.AddToJson(new JObject + { + ["Entry"] = (ulong)entry, + }), + }; + + public static JObject Serialize(EqdpIdentifier identifier, EqdpEntryInternal entry) + => Serialize(identifier, entry.ToEntry(identifier.Slot)); + + public static JObject Serialize(EqdpIdentifier identifier, EqdpEntry entry) + => new() + { + ["Type"] = MetaManipulationType.Eqdp.ToString(), + ["Manipulation"] = identifier.AddToJson(new JObject + { + ["Entry"] = (ushort)entry, + }), + }; + + public static JObject Serialize(EstIdentifier identifier, EstEntry entry) + => new() + { + ["Type"] = MetaManipulationType.Est.ToString(), + ["Manipulation"] = identifier.AddToJson(new JObject + { + ["Entry"] = entry.Value, + }), + }; + + public static JObject Serialize(GmpIdentifier identifier, GmpEntry entry) + => new() + { + ["Type"] = MetaManipulationType.Gmp.ToString(), + ["Manipulation"] = identifier.AddToJson(new JObject + { + ["Entry"] = JObject.FromObject(entry), + }), + }; + + public static JObject Serialize(ImcIdentifier identifier, ImcEntry entry) + => new() + { + ["Type"] = MetaManipulationType.Imc.ToString(), + ["Manipulation"] = identifier.AddToJson(new JObject + { + ["Entry"] = JObject.FromObject(entry), + }), + }; + + public static JObject Serialize(RspIdentifier identifier, RspEntry entry) + => new() + { + ["Type"] = MetaManipulationType.Rsp.ToString(), + ["Manipulation"] = identifier.AddToJson(new JObject + { + ["Entry"] = entry.Value, + }), + }; + + public static JObject Serialize(AtchIdentifier identifier, AtchEntry entry) + => new() + { + ["Type"] = MetaManipulationType.Atch.ToString(), + ["Manipulation"] = identifier.AddToJson(new JObject + { + ["Entry"] = entry.ToJson(), + }), + }; + + public static JObject Serialize(ShpIdentifier identifier, ShpEntry entry) + => new() + { + ["Type"] = MetaManipulationType.Shp.ToString(), + ["Manipulation"] = identifier.AddToJson(new JObject + { + ["Entry"] = entry.Value, + }), + }; + + public static JObject Serialize(AtrIdentifier identifier, AtrEntry entry) + => new() + { + ["Type"] = MetaManipulationType.Atr.ToString(), + ["Manipulation"] = identifier.AddToJson(new JObject + { + ["Entry"] = entry.Value, + }), + }; + + public static JObject Serialize(GlobalEqpManipulation identifier) + => new() + { + ["Type"] = MetaManipulationType.GlobalEqp.ToString(), + ["Manipulation"] = identifier.AddToJson(new JObject()), + }; + + public static JObject? Serialize(TIdentifier identifier, TEntry entry) + where TIdentifier : unmanaged, IMetaIdentifier + where TEntry : unmanaged + { + if (typeof(TIdentifier) == typeof(EqpIdentifier) && typeof(TEntry) == typeof(EqpEntryInternal)) + return Serialize(Unsafe.As(ref identifier), Unsafe.As(ref entry)); + if (typeof(TIdentifier) == typeof(EqpIdentifier) && typeof(TEntry) == typeof(EqpEntry)) + return Serialize(Unsafe.As(ref identifier), Unsafe.As(ref entry)); + if (typeof(TIdentifier) == typeof(EqdpIdentifier) && typeof(TEntry) == typeof(EqdpEntryInternal)) + return Serialize(Unsafe.As(ref identifier), Unsafe.As(ref entry)); + if (typeof(TIdentifier) == typeof(EqdpIdentifier) && typeof(TEntry) == typeof(EqdpEntry)) + return Serialize(Unsafe.As(ref identifier), Unsafe.As(ref entry)); + if (typeof(TIdentifier) == typeof(EstIdentifier) && typeof(TEntry) == typeof(EstEntry)) + return Serialize(Unsafe.As(ref identifier), Unsafe.As(ref entry)); + if (typeof(TIdentifier) == typeof(GmpIdentifier) && typeof(TEntry) == typeof(GmpEntry)) + return Serialize(Unsafe.As(ref identifier), Unsafe.As(ref entry)); + if (typeof(TIdentifier) == typeof(RspIdentifier) && typeof(TEntry) == typeof(RspEntry)) + return Serialize(Unsafe.As(ref identifier), Unsafe.As(ref entry)); + if (typeof(TIdentifier) == typeof(ImcIdentifier) && typeof(TEntry) == typeof(ImcEntry)) + return Serialize(Unsafe.As(ref identifier), Unsafe.As(ref entry)); + if (typeof(TIdentifier) == typeof(AtchIdentifier) && typeof(TEntry) == typeof(AtchEntry)) + return Serialize(Unsafe.As(ref identifier), Unsafe.As(ref entry)); + if (typeof(TIdentifier) == typeof(ShpIdentifier) && typeof(TEntry) == typeof(ShpEntry)) + return Serialize(Unsafe.As(ref identifier), Unsafe.As(ref entry)); + if (typeof(TIdentifier) == typeof(AtrIdentifier) && typeof(TEntry) == typeof(AtrEntry)) + return Serialize(Unsafe.As(ref identifier), Unsafe.As(ref entry)); + if (typeof(TIdentifier) == typeof(GlobalEqpManipulation)) + return Serialize(Unsafe.As(ref identifier)); + + return null; + } + + public static JArray SerializeTo(JArray array, IEnumerable> manipulations) + where TIdentifier : unmanaged, IMetaIdentifier + where TEntry : unmanaged + { + foreach (var (identifier, entry) in manipulations) + { + if (Serialize(identifier, entry) is { } jObj) + array.Add(jObj); + } + + return array; + } + + public static JArray SerializeTo(JArray array, IEnumerable manipulations) + { + foreach (var manip in manipulations) + array.Add(Serialize(manip)); + + return array; + } + + private class Converter : JsonConverter + { + public override void WriteJson(JsonWriter writer, MetaDictionary? value, JsonSerializer serializer) + { + if (value is null) + { + writer.WriteNull(); + return; + } + + var array = new JArray(); + if (value._data is not null) + { + SerializeTo(array, value._data!.Imc); + SerializeTo(array, value._data!.Eqp); + SerializeTo(array, value._data!.Eqdp); + SerializeTo(array, value._data!.Est); + SerializeTo(array, value._data!.Rsp); + SerializeTo(array, value._data!.Gmp); + SerializeTo(array, value._data!.Atch); + SerializeTo(array, value._data!.Shp); + SerializeTo(array, value._data!.Atr); + SerializeTo(array, value._data!); + } + + array.WriteTo(writer); + } + + public override MetaDictionary ReadJson(JsonReader reader, Type objectType, MetaDictionary? existingValue, bool hasExistingValue, + JsonSerializer serializer) + { + var dict = existingValue ?? new MetaDictionary(); + dict.Clear(); + var jObj = JArray.Load(reader); + foreach (var item in jObj) + { + var type = item["Type"]?.ToObject() ?? MetaManipulationType.Unknown; + if (type is MetaManipulationType.Unknown) + { + Penumbra.Log.Warning($"Invalid Meta Manipulation Type {type} encountered."); + continue; + } + + if (item["Manipulation"] is not JObject manip) + { + Penumbra.Log.Warning($"Manipulation of type {type} does not contain manipulation data."); + continue; + } + + switch (type) + { + case MetaManipulationType.Imc: + { + var identifier = ImcIdentifier.FromJson(manip); + var entry = manip["Entry"]?.ToObject(); + if (identifier.HasValue && entry.HasValue) + dict.TryAdd(identifier.Value, entry.Value); + else + Penumbra.Log.Warning("Invalid IMC Manipulation encountered."); + break; + } + case MetaManipulationType.Eqdp: + { + var identifier = EqdpIdentifier.FromJson(manip); + var entry = (EqdpEntry?)manip["Entry"]?.ToObject(); + if (identifier.HasValue && entry.HasValue) + dict.TryAdd(identifier.Value, entry.Value); + else + Penumbra.Log.Warning("Invalid EQDP Manipulation encountered."); + break; + } + case MetaManipulationType.Eqp: + { + var identifier = EqpIdentifier.FromJson(manip); + var entry = (EqpEntry?)manip["Entry"]?.ToObject(); + if (identifier.HasValue && entry.HasValue) + dict.TryAdd(identifier.Value, entry.Value); + else + Penumbra.Log.Warning("Invalid EQP Manipulation encountered."); + break; + } + case MetaManipulationType.Est: + { + var identifier = EstIdentifier.FromJson(manip); + var entry = manip["Entry"]?.ToObject(); + if (identifier.HasValue && entry.HasValue) + dict.TryAdd(identifier.Value, entry.Value); + else + Penumbra.Log.Warning("Invalid EST Manipulation encountered."); + break; + } + case MetaManipulationType.Gmp: + { + var identifier = GmpIdentifier.FromJson(manip); + var entry = manip["Entry"]?.ToObject(); + if (identifier.HasValue && entry.HasValue) + dict.TryAdd(identifier.Value, entry.Value); + else + Penumbra.Log.Warning("Invalid GMP Manipulation encountered."); + break; + } + case MetaManipulationType.Rsp: + { + var identifier = RspIdentifier.FromJson(manip); + var entry = manip["Entry"]?.ToObject(); + if (identifier.HasValue && entry.HasValue) + dict.TryAdd(identifier.Value, entry.Value); + else + Penumbra.Log.Warning("Invalid RSP Manipulation encountered."); + break; + } + case MetaManipulationType.Atch: + { + var identifier = AtchIdentifier.FromJson(manip); + var entry = AtchEntry.FromJson(manip["Entry"] as JObject); + if (identifier.HasValue && entry.HasValue) + dict.TryAdd(identifier.Value, entry.Value); + else + Penumbra.Log.Warning("Invalid ATCH Manipulation encountered."); + break; + } + case MetaManipulationType.Shp: + { + var identifier = ShpIdentifier.FromJson(manip); + var entry = new ShpEntry(manip["Entry"]?.Value() ?? true); + if (identifier.HasValue) + dict.TryAdd(identifier.Value, entry); + else + Penumbra.Log.Warning("Invalid SHP Manipulation encountered."); + break; + } + case MetaManipulationType.Atr: + { + var identifier = AtrIdentifier.FromJson(manip); + var entry = new AtrEntry(manip["Entry"]?.Value() ?? true); + if (identifier.HasValue) + dict.TryAdd(identifier.Value, entry); + else + Penumbra.Log.Warning("Invalid ATR Manipulation encountered."); + break; + } + case MetaManipulationType.GlobalEqp: + { + var identifier = GlobalEqpManipulation.FromJson(manip); + if (identifier.HasValue) + dict.TryAdd(identifier.Value); + else + Penumbra.Log.Warning("Invalid Global EQP Manipulation encountered."); + break; + } + } + } + + return dict; + } + } + + public MetaDictionary() + { } + + public MetaDictionary(MetaCache? cache) + { + if (cache is null) + return; + + _data = new Wrapper(cache); + Count = cache.Count; + } + + public MetaDictionary(MetaCache? cache, Actor actor) + { + if (cache is null) + return; + + _data = Wrapper.Filtered(cache, actor); + Count = _data.Count + + _data.Eqp.Count + + _data.Eqdp.Count + + _data.Est.Count + + _data.Gmp.Count + + _data.Imc.Count + + _data.Rsp.Count + + _data.Atch.Count + + _data.Atr.Count + + _data.Shp.Count; + if (Count is 0) + _data = null; + } +} diff --git a/Penumbra/Meta/Manipulations/Rsp.cs b/Penumbra/Meta/Manipulations/Rsp.cs new file mode 100644 index 00000000..5f91a37c --- /dev/null +++ b/Penumbra/Meta/Manipulations/Rsp.cs @@ -0,0 +1,79 @@ +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using Penumbra.GameData.Data; +using Penumbra.GameData.Enums; +using Penumbra.Interop.Structs; + +namespace Penumbra.Meta.Manipulations; + +public readonly record struct RspIdentifier(SubRace SubRace, RspAttribute Attribute) : IMetaIdentifier +{ + public void AddChangedItems(ObjectIdentification identifier, IDictionary changedItems) + => changedItems.UpdateCountOrSet($"{SubRace.ToName()} {Attribute.ToFullString()}", () => new IdentifiedName()); + + public MetaIndex FileIndex() + => MetaIndex.HumanCmp; + + public bool Validate() + => SubRace is not SubRace.Unknown + && Enum.IsDefined(SubRace) + && Attribute is not RspAttribute.NumAttributes + && Enum.IsDefined(Attribute); + + public JObject AddToJson(JObject jObj) + { + jObj["SubRace"] = SubRace.ToString(); + jObj["Attribute"] = Attribute.ToString(); + return jObj; + } + + public static RspIdentifier? FromJson(JObject? jObj) + { + if (jObj == null) + return null; + + var subRace = jObj["SubRace"]?.ToObject() ?? SubRace.Unknown; + var attribute = jObj["Attribute"]?.ToObject() ?? RspAttribute.NumAttributes; + var ret = new RspIdentifier(subRace, attribute); + return ret.Validate() ? ret : null; + } + + public MetaManipulationType Type + => MetaManipulationType.Rsp; + + public override string ToString() + => $"RSP - {SubRace.ToName()} - {Attribute.ToFullString()}"; +} + +[JsonConverter(typeof(Converter))] +public readonly record struct RspEntry(float Value) : IComparisonOperators +{ + public const float MinValue = 0.01f; + public const float MaxValue = 512f; + public static readonly RspEntry One = new(1f); + + public bool Validate() + => Value is >= MinValue and <= MaxValue; + + private class Converter : JsonConverter + { + public override void WriteJson(JsonWriter writer, RspEntry value, JsonSerializer serializer) + => serializer.Serialize(writer, value.Value); + + public override RspEntry ReadJson(JsonReader reader, Type objectType, RspEntry existingValue, bool hasExistingValue, + JsonSerializer serializer) + => new(serializer.Deserialize(reader)); + } + + public static bool operator >(RspEntry left, RspEntry right) + => left.Value > right.Value; + + public static bool operator >=(RspEntry left, RspEntry right) + => left.Value >= right.Value; + + public static bool operator <(RspEntry left, RspEntry right) + => left.Value < right.Value; + + public static bool operator <=(RspEntry left, RspEntry right) + => left.Value <= right.Value; +} diff --git a/Penumbra/Meta/Manipulations/ShpIdentifier.cs b/Penumbra/Meta/Manipulations/ShpIdentifier.cs new file mode 100644 index 00000000..0a5b71b7 --- /dev/null +++ b/Penumbra/Meta/Manipulations/ShpIdentifier.cs @@ -0,0 +1,187 @@ +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; +using Newtonsoft.Json.Linq; +using Penumbra.Collections.Cache; +using Penumbra.GameData.Data; +using Penumbra.GameData.Enums; +using Penumbra.GameData.Structs; +using Penumbra.Interop.Structs; +using Penumbra.Meta.Files; + +namespace Penumbra.Meta.Manipulations; + +[JsonConverter(typeof(StringEnumConverter))] +public enum ShapeConnectorCondition : byte +{ + None = 0, + Wrists = 1, + Waist = 2, + Ankles = 3, +} + +public readonly record struct ShpIdentifier( + HumanSlot Slot, + PrimaryId? Id, + ShapeAttributeString Shape, + ShapeConnectorCondition ConnectorCondition, + GenderRace GenderRaceCondition) + : IComparable, IMetaIdentifier +{ + public int CompareTo(ShpIdentifier other) + { + var slotComparison = Slot.CompareTo(other.Slot); + if (slotComparison is not 0) + return slotComparison; + + if (Id.HasValue) + { + if (other.Id.HasValue) + { + var idComparison = Id.Value.Id.CompareTo(other.Id.Value.Id); + if (idComparison is not 0) + return idComparison; + } + else + { + return -1; + } + } + else if (other.Id.HasValue) + { + return 1; + } + + var conditionComparison = ConnectorCondition.CompareTo(other.ConnectorCondition); + if (conditionComparison is not 0) + return conditionComparison; + + var genderRaceComparison = GenderRaceCondition.CompareTo(other.GenderRaceCondition); + if (genderRaceComparison is not 0) + return genderRaceComparison; + + return Shape.CompareTo(other.Shape); + } + + + public override string ToString() + { + var sb = new StringBuilder(64); + sb.Append("Shp - ") + .Append(Shape); + if (Slot is HumanSlot.Unknown) + { + sb.Append(" - All Slots & IDs"); + } + else + { + sb.Append(" - ") + .Append(Slot.ToName()) + .Append(" - "); + if (Id.HasValue) + sb.Append(Id.Value.Id); + else + sb.Append("All IDs"); + } + + switch (ConnectorCondition) + { + case ShapeConnectorCondition.Wrists: sb.Append(" - Wrist Connector"); break; + case ShapeConnectorCondition.Waist: sb.Append(" - Waist Connector"); break; + case ShapeConnectorCondition.Ankles: sb.Append(" - Ankle Connector"); break; + } + + if (GenderRaceCondition is not GenderRace.Unknown) + sb.Append(" - ").Append(GenderRaceCondition.ToRaceCode()); + + return sb.ToString(); + } + + public void AddChangedItems(ObjectIdentification identifier, IDictionary changedItems) + { + // Nothing for now since it depends entirely on the shape key. + } + + public MetaIndex FileIndex() + => (MetaIndex)(-1); + + public bool Validate() + { + if (!Enum.IsDefined(Slot) || Slot is HumanSlot.UnkBonus) + return false; + + if (!ShapeAttributeHashSet.GenderRaceIndices.ContainsKey(GenderRaceCondition)) + return false; + + if (!Enum.IsDefined(ConnectorCondition)) + return false; + + if (Slot is HumanSlot.Unknown && Id is not null) + return false; + + if (Slot.ToSpecificEnum() is BodySlot && Id is { Id: > byte.MaxValue }) + return false; + + if (Id is { Id: > ExpandedEqpGmpBase.Count - 1 }) + return false; + + if (!Shape.ValidateCustomShapeString()) + return false; + + return ConnectorCondition switch + { + ShapeConnectorCondition.None => true, + ShapeConnectorCondition.Wrists => Slot is HumanSlot.Body or HumanSlot.Hands or HumanSlot.Unknown, + ShapeConnectorCondition.Waist => Slot is HumanSlot.Body or HumanSlot.Legs or HumanSlot.Unknown, + ShapeConnectorCondition.Ankles => Slot is HumanSlot.Legs or HumanSlot.Feet or HumanSlot.Unknown, + _ => false, + }; + } + + public JObject AddToJson(JObject jObj) + { + if (Slot is not HumanSlot.Unknown) + jObj["Slot"] = Slot.ToString(); + if (Id.HasValue) + jObj["Id"] = Id.Value.Id.ToString(); + jObj["Shape"] = Shape.ToString(); + if (ConnectorCondition is not ShapeConnectorCondition.None) + jObj["ConnectorCondition"] = ConnectorCondition.ToString(); + if (GenderRaceCondition is not GenderRace.Unknown) + jObj["GenderRaceCondition"] = (uint)GenderRaceCondition; + return jObj; + } + + public static ShpIdentifier? FromJson(JObject jObj) + { + var shape = jObj["Shape"]?.ToObject(); + if (shape is null || !ShapeAttributeString.TryRead(shape, out var shapeString)) + return null; + + var slot = jObj["Slot"]?.ToObject() ?? HumanSlot.Unknown; + var id = jObj["Id"]?.ToObject(); + var connectorCondition = jObj["ConnectorCondition"]?.ToObject() ?? ShapeConnectorCondition.None; + var genderRaceCondition = jObj["GenderRaceCondition"]?.ToObject() ?? 0; + var identifier = new ShpIdentifier(slot, id, shapeString, connectorCondition, genderRaceCondition); + return identifier.Validate() ? identifier : null; + } + + public MetaManipulationType Type + => MetaManipulationType.Shp; +} + +[JsonConverter(typeof(Converter))] +public readonly record struct ShpEntry(bool Value) +{ + public static readonly ShpEntry True = new(true); + public static readonly ShpEntry False = new(false); + + private class Converter : JsonConverter + { + public override void WriteJson(JsonWriter writer, ShpEntry value, JsonSerializer serializer) + => serializer.Serialize(writer, value.Value); + + public override ShpEntry ReadJson(JsonReader reader, Type objectType, ShpEntry existingValue, bool hasExistingValue, + JsonSerializer serializer) + => new(serializer.Deserialize(reader)); + } +} diff --git a/Penumbra/Meta/MetaCollection.cs b/Penumbra/Meta/MetaCollection.cs deleted file mode 100644 index 3e47ff47..00000000 --- a/Penumbra/Meta/MetaCollection.cs +++ /dev/null @@ -1,234 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using Dalamud.Logging; -using Newtonsoft.Json; -using Penumbra.Importer; -using Penumbra.Meta.Files; -using Penumbra.Mod; -using Penumbra.Structs; -using Penumbra.Util; - -namespace Penumbra.Meta -{ - // Corresponds meta manipulations of any kind with the settings for a mod. - // DefaultData contains all manipulations that are active regardless of option groups. - // GroupData contains a mapping of Group -> { Options -> {Manipulations} }. - public class MetaCollection - { - public List< MetaManipulation > DefaultData = new(); - public Dictionary< string, Dictionary< string, List< MetaManipulation > > > GroupData = new(); - - - // Store total number of manipulations for some ease of access. - [JsonIgnore] - internal int Count; - - - // Return an enumeration of all active meta manipulations for a given mod with given settings. - public IEnumerable< MetaManipulation > GetManipulationsForConfig( ModSettings settings, ModMeta modMeta ) - { - if( Count == DefaultData.Count ) - { - return DefaultData; - } - - IEnumerable< MetaManipulation > ret = DefaultData; - - foreach( var group in modMeta.Groups ) - { - if( !GroupData.TryGetValue( group.Key, out var metas ) || !settings.Settings.TryGetValue( group.Key, out var setting ) ) - { - continue; - } - - if( group.Value.SelectionType == SelectType.Single ) - { - var settingName = group.Value.Options[ setting ].OptionName; - if( metas.TryGetValue( settingName, out var meta ) ) - { - ret = ret.Concat( meta ); - } - } - else - { - for( var i = 0; i < group.Value.Options.Count; ++i ) - { - var flag = 1 << i; - if( ( setting & flag ) == 0 ) - { - continue; - } - - var settingName = group.Value.Options[ i ].OptionName; - if( metas.TryGetValue( settingName, out var meta ) ) - { - ret = ret.Concat( meta ); - } - } - } - } - - return ret; - } - - // Check that the collection is still basically valid, - // i.e. keep it sorted, and verify that the options stored by name are all still part of the mod, - // and that the contained manipulations are still valid and non-default manipulations. - public bool Validate( ModMeta modMeta ) - { - var defaultFiles = Service< MetaDefaults >.Get(); - SortLists(); - foreach( var group in GroupData ) - { - if( !modMeta.Groups.TryGetValue( group.Key, out var options ) ) - { - return false; - } - - foreach( var option in group.Value ) - { - if( options.Options.All( o => o.OptionName != option.Key ) ) - { - return false; - } - - if( option.Value.Any( manip => defaultFiles.CheckAgainstDefault( manip ) ) ) - { - return false; - } - } - } - - return DefaultData.All( manip => !defaultFiles.CheckAgainstDefault( manip ) ); - } - - // Re-sort all manipulations. - private void SortLists() - { - DefaultData.Sort(); - foreach( var list in GroupData.Values.SelectMany( g => g.Values ) ) - { - list.Sort(); - } - } - - // Add a parsed TexTools .meta file to a given option group and option. If group is the empty string, add it to default. - // Creates the option group and the option if necessary. - private void AddMeta( string group, string option, TexToolsMeta meta ) - { - if( meta.Manipulations.Count == 0 ) - { - return; - } - - if( group.Length == 0 ) - { - DefaultData.AddRange( meta.Manipulations ); - } - else if( option.Length == 0 ) - { } - else if( !GroupData.TryGetValue( group, out var options ) ) - { - GroupData.Add( group, new Dictionary< string, List< MetaManipulation > >() { { option, meta.Manipulations.ToList() } } ); - } - else if( !options.TryGetValue( option, out var list ) ) - { - options.Add( option, meta.Manipulations.ToList() ); - } - else - { - list.AddRange( meta.Manipulations ); - } - - Count += meta.Manipulations.Count; - } - - // Update the whole meta collection by reading all TexTools .meta files in a mod directory anew, - // combining them with the given ModMeta. - public void Update( IEnumerable< FileInfo > files, DirectoryInfo basePath, ModMeta modMeta ) - { - DefaultData.Clear(); - GroupData.Clear(); - Count = 0; - foreach( var file in files ) - { - TexToolsMeta metaData = file.Extension.ToLowerInvariant() switch - { - ".meta" => new TexToolsMeta( File.ReadAllBytes( file.FullName ) ), - ".rgsp" => TexToolsMeta.FromRgspFile( file.FullName, File.ReadAllBytes( file.FullName ) ), - _ => TexToolsMeta.Invalid, - }; - - if( metaData.FilePath == string.Empty || metaData.Manipulations.Count == 0 ) - { - continue; - } - - var path = new RelPath( file, basePath ); - var foundAny = false; - foreach( var group in modMeta.Groups ) - { - foreach( var option in group.Value.Options.Where( o => o.OptionFiles.ContainsKey( path ) ) ) - { - foundAny = true; - AddMeta( group.Key, option.OptionName, metaData ); - } - } - - if( !foundAny ) - { - AddMeta( string.Empty, string.Empty, metaData ); - } - } - - SortLists(); - } - - public static FileInfo FileName( DirectoryInfo basePath ) - => new( Path.Combine( basePath.FullName, "metadata_manipulations.json" ) ); - - public void SaveToFile( FileInfo file ) - { - try - { - var text = JsonConvert.SerializeObject( this, Formatting.Indented ); - File.WriteAllText( file.FullName, text ); - } - catch( Exception e ) - { - PluginLog.Error( $"Could not write metadata manipulations file to {file.FullName}:\n{e}" ); - } - } - - public static MetaCollection? LoadFromFile( FileInfo file ) - { - if( !file.Exists ) - { - return null; - } - - try - { - var text = File.ReadAllText( file.FullName ); - - var collection = JsonConvert.DeserializeObject< MetaCollection >( text, - new JsonSerializerSettings { NullValueHandling = NullValueHandling.Ignore } ); - - if( collection != null ) - { - collection.Count = collection.DefaultData.Count - + collection.GroupData.Values.SelectMany( kvp => kvp.Values ).Sum( l => l.Count ); - } - - return collection; - } - catch( Exception e ) - { - PluginLog.Error( $"Could not load mod metadata manipulations from {file.FullName}:\n{e}" ); - return null; - } - } - } -} \ No newline at end of file diff --git a/Penumbra/Meta/MetaFileManager.cs b/Penumbra/Meta/MetaFileManager.cs new file mode 100644 index 00000000..6130a48f --- /dev/null +++ b/Penumbra/Meta/MetaFileManager.cs @@ -0,0 +1,92 @@ +using Dalamud.Plugin.Services; +using OtterGui.Compression; +using OtterGui.Services; +using Penumbra.Collections; +using Penumbra.Collections.Manager; +using Penumbra.GameData.Data; +using Penumbra.Import; +using Penumbra.Interop.Hooks.Meta; +using Penumbra.Interop.Services; +using Penumbra.Meta.Files; +using Penumbra.Mods; +using Penumbra.Mods.Groups; +using Penumbra.Services; +using ResidentResourceManager = Penumbra.Interop.Services.ResidentResourceManager; + +namespace Penumbra.Meta; + +public class MetaFileManager : IService +{ + internal readonly Configuration Config; + internal readonly CharacterUtility CharacterUtility; + internal readonly ResidentResourceManager ResidentResources; + internal readonly IDataManager GameData; + internal readonly ActiveCollectionData ActiveCollections; + internal readonly ValidityChecker ValidityChecker; + internal readonly ObjectIdentification Identifier; + internal readonly FileCompactor Compactor; + internal readonly ImcChecker ImcChecker; + internal readonly AtchManager AtchManager; + internal readonly IFileAllocator MarshalAllocator = new MarshalAllocator(); + internal readonly IFileAllocator XivFileAllocator; + internal readonly IFileAllocator XivDefaultAllocator; + + + public MetaFileManager(CharacterUtility characterUtility, ResidentResourceManager residentResources, IDataManager gameData, + ActiveCollectionData activeCollections, Configuration config, ValidityChecker validityChecker, ObjectIdentification identifier, + FileCompactor compactor, IGameInteropProvider interop, AtchManager atchManager) + { + CharacterUtility = characterUtility; + ResidentResources = residentResources; + GameData = gameData; + ActiveCollections = activeCollections; + Config = config; + ValidityChecker = validityChecker; + Identifier = identifier; + Compactor = compactor; + AtchManager = atchManager; + ImcChecker = new ImcChecker(this); + XivFileAllocator = new XivFileAllocator(interop); + XivDefaultAllocator = new XivDefaultAllocator(); + interop.InitializeFromAttributes(this); + } + + public void WriteAllTexToolsMeta(Mod mod) + { + try + { + TexToolsMeta.WriteTexToolsMeta(this, mod.Default.Manipulations, mod.ModPath); + foreach (var group in mod.Groups) + { + if (group is not ITexToolsGroup texToolsGroup) + continue; + + var dir = ModCreator.NewOptionDirectory(mod.ModPath, group.Name, Config.ReplaceNonAsciiOnImport); + if (!dir.Exists) + dir.Create(); + + + foreach (var option in texToolsGroup.OptionData) + { + var optionDir = ModCreator.NewOptionDirectory(dir, option.Name, Config.ReplaceNonAsciiOnImport); + if (!optionDir.Exists) + optionDir.Create(); + + TexToolsMeta.WriteTexToolsMeta(this, option.Manipulations, optionDir); + } + } + } + catch (Exception e) + { + Penumbra.Log.Error($"Error writing TexToolsMeta:\n{e}"); + } + } + + public void ApplyDefaultFiles(ModCollection? collection) + { + if (ActiveCollections.Default != collection || !CharacterUtility.Ready || !Config.EnableMods) + return; + + ResidentResources.Reload(); + } +} diff --git a/Penumbra/Meta/MetaManager.cs b/Penumbra/Meta/MetaManager.cs deleted file mode 100644 index f0d2cad9..00000000 --- a/Penumbra/Meta/MetaManager.cs +++ /dev/null @@ -1,186 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using Dalamud.Logging; -using Lumina.Data.Files; -using Penumbra.GameData.Util; -using Penumbra.Interop; -using Penumbra.Meta.Files; -using Penumbra.Util; - -namespace Penumbra.Meta -{ - public class MetaManager : IDisposable - { - internal class FileInformation - { - public readonly object Data; - public bool Changed; - public FileInfo? CurrentFile; - - public FileInformation( object data ) - => Data = data; - - public void Write( DirectoryInfo dir, GamePath originalPath ) - { - var data = Data switch - { - EqdpFile eqdp => eqdp.WriteBytes(), - EqpFile eqp => eqp.WriteBytes(), - GmpFile gmp => gmp.WriteBytes(), - EstFile est => est.WriteBytes(), - ImcFile imc => imc.WriteBytes(), - CmpFile cmp => cmp.WriteBytes(), - _ => throw new NotImplementedException(), - }; - DisposeFile( CurrentFile ); - CurrentFile = TempFile.WriteNew( dir, data, $"_{originalPath.Filename()}" ); - Changed = false; - } - } - - public const string TmpDirectory = "penumbrametatmp"; - - private readonly MetaDefaults _default; - private readonly DirectoryInfo _dir; - private readonly ResidentResources _resourceManagement; - private readonly Dictionary< GamePath, FileInfo > _resolvedFiles; - - private readonly Dictionary< MetaManipulation, Mod.Mod > _currentManipulations = new(); - private readonly Dictionary< GamePath, FileInformation > _currentFiles = new(); - - public IEnumerable< (MetaManipulation, Mod.Mod) > Manipulations - => _currentManipulations.Select( kvp => ( kvp.Key, kvp.Value ) ); - - public IEnumerable< (GamePath, FileInfo) > Files - => _currentFiles.Where( kvp => kvp.Value.CurrentFile != null ) - .Select( kvp => ( kvp.Key, kvp.Value.CurrentFile! ) ); - - public int Count - => _currentManipulations.Count; - - public bool TryGetValue( MetaManipulation manip, out Mod.Mod mod ) - => _currentManipulations.TryGetValue( manip, out mod! ); - - private static void DisposeFile( FileInfo? file ) - { - file?.Refresh(); - if( !( file?.Exists ?? false ) ) - { - return; - } - - try - { - file.Delete(); - } - catch( Exception e ) - { - PluginLog.Error( $"Could not delete temporary file \"{file.FullName}\":\n{e}" ); - } - } - - public void Reset( bool reload = true ) - { - foreach( var file in _currentFiles ) - { - _resolvedFiles.Remove( file.Key ); - DisposeFile( file.Value.CurrentFile ); - } - - _currentManipulations.Clear(); - _currentFiles.Clear(); - ClearDirectory(); - if( reload ) - { - _resourceManagement.ReloadPlayerResources(); - } - } - - public void Dispose() - => Reset(); - - private static void ClearDirectory( DirectoryInfo modDir ) - { - modDir.Refresh(); - if( modDir.Exists ) - { - try - { - Directory.Delete( modDir.FullName, true ); - } - catch( Exception e ) - { - PluginLog.Error( $"Could not clear temporary metafile directory \"{modDir.FullName}\":\n{e}" ); - } - } - } - - private void ClearDirectory() - => ClearDirectory( _dir ); - - public MetaManager( string name, Dictionary< GamePath, FileInfo > resolvedFiles, DirectoryInfo tempDir ) - { - _resolvedFiles = resolvedFiles; - _default = Service< MetaDefaults >.Get(); - _resourceManagement = Service< ResidentResources >.Get(); - _dir = new DirectoryInfo( Path.Combine( tempDir.FullName, name.ReplaceBadXivSymbols() ) ); - ClearDirectory(); - } - - public void WriteNewFiles() - { - if( _currentFiles.Any() ) - { - Directory.CreateDirectory( _dir.FullName ); - } - - foreach( var kvp in _currentFiles.Where( kvp => kvp.Value.Changed ) ) - { - kvp.Value.Write( _dir, kvp.Key ); - _resolvedFiles[ kvp.Key ] = kvp.Value.CurrentFile!; - } - } - - public bool ApplyMod( MetaManipulation m, Mod.Mod mod ) - { - if( _currentManipulations.ContainsKey( m ) ) - { - return false; - } - - _currentManipulations.Add( m, mod ); - var gamePath = m.CorrespondingFilename(); - try - { - if( !_currentFiles.TryGetValue( gamePath, out var file ) ) - { - file = new FileInformation( _default.CreateNewFile( m ) ?? throw new IOException() ) - { - Changed = true, - CurrentFile = null, - }; - _currentFiles[ gamePath ] = file; - } - - file.Changed |= m.Type switch - { - MetaType.Eqp => m.Apply( ( EqpFile )file.Data ), - MetaType.Eqdp => m.Apply( ( EqdpFile )file.Data ), - MetaType.Gmp => m.Apply( ( GmpFile )file.Data ), - MetaType.Est => m.Apply( ( EstFile )file.Data ), - MetaType.Imc => m.Apply( ( ImcFile )file.Data ), - MetaType.Rsp => m.Apply( ( CmpFile )file.Data ), - _ => throw new NotImplementedException(), - }; - return true; - } - catch( Exception e ) - { - PluginLog.Error( $"Could not obtain default file for manipulation {m.CorrespondingFilename()}:\n{e}" ); - return false; - } - } - } -} \ No newline at end of file diff --git a/Penumbra/Meta/MetaManipulation.cs b/Penumbra/Meta/MetaManipulation.cs deleted file mode 100644 index 326b8519..00000000 --- a/Penumbra/Meta/MetaManipulation.cs +++ /dev/null @@ -1,259 +0,0 @@ -using System; -using System.ComponentModel; -using System.IO; -using System.Runtime.InteropServices; -using Newtonsoft.Json; -using Penumbra.GameData.Enums; -using Penumbra.GameData.Structs; -using Penumbra.GameData.Util; -using Penumbra.Meta.Files; -using Swan; -using ImcFile = Lumina.Data.Files.ImcFile; - -namespace Penumbra.Meta -{ - // Write a single meta manipulation as a Base64string of the 16 bytes defining it. - public class MetaManipulationConverter : JsonConverter< MetaManipulation > - { - public override void WriteJson( JsonWriter writer, MetaManipulation manip, JsonSerializer serializer ) - { - var s = Convert.ToBase64String( manip.ToBytes() ); - writer.WriteValue( s ); - } - - public override MetaManipulation ReadJson( JsonReader reader, Type objectType, MetaManipulation existingValue, bool hasExistingValue, - JsonSerializer serializer ) - - { - if( reader.TokenType != JsonToken.String ) - { - throw new JsonReaderException(); - } - - var bytes = Convert.FromBase64String( ( string )reader.Value! ); - using MemoryStream m = new( bytes ); - using BinaryReader br = new( m ); - var i = br.ReadUInt64(); - var v = br.ReadUInt64(); - return new MetaManipulation( i, v ); - } - } - - // A MetaManipulation is a union of a type of Identifier (first 8 bytes, cf. Identifier.cs) - // and the appropriate Value to change the meta entry to (the other 8 bytes). - // Its comparison for sorting and hashes depends only on the identifier. - // The first byte is guaranteed to be a MetaType enum value in any case, so Type can always be read. - [StructLayout( LayoutKind.Explicit )] - [JsonConverter( typeof( MetaManipulationConverter ) )] - public struct MetaManipulation : IComparable - { - public static MetaManipulation Eqp( EquipSlot equipSlot, ushort setId, EqpEntry value ) - => new() - { - EqpIdentifier = new EqpIdentifier() - { - Type = MetaType.Eqp, - Slot = equipSlot, - SetId = setId, - }, - EqpValue = value, - }; - - public static MetaManipulation Eqdp( EquipSlot equipSlot, GenderRace gr, ushort setId, EqdpEntry value ) - => new() - { - EqdpIdentifier = new EqdpIdentifier() - { - Type = MetaType.Eqdp, - Slot = equipSlot, - GenderRace = gr, - SetId = setId, - }, - EqdpValue = value, - }; - - public static MetaManipulation Gmp( ushort setId, GmpEntry value ) - => new() - { - GmpIdentifier = new GmpIdentifier() - { - Type = MetaType.Gmp, - SetId = setId, - }, - GmpValue = value, - }; - - public static MetaManipulation Est( ObjectType type, EquipSlot equipSlot, GenderRace gr, BodySlot bodySlot, ushort setId, - ushort value ) - => new() - { - EstIdentifier = new EstIdentifier() - { - Type = MetaType.Est, - ObjectType = type, - GenderRace = gr, - EquipSlot = equipSlot, - BodySlot = bodySlot, - PrimaryId = setId, - }, - EstValue = value, - }; - - public static MetaManipulation Imc( ObjectType type, BodySlot secondaryType, ushort primaryId, ushort secondaryId - , ushort idx, ImcFile.ImageChangeData value ) - => new() - { - ImcIdentifier = new ImcIdentifier() - { - Type = MetaType.Imc, - ObjectType = type, - BodySlot = secondaryType, - PrimaryId = primaryId, - SecondaryId = secondaryId, - Variant = idx, - }, - ImcValue = value, - }; - - public static MetaManipulation Imc( EquipSlot slot, ushort primaryId, ushort idx, ImcFile.ImageChangeData value ) - => new() - { - ImcIdentifier = new ImcIdentifier() - { - Type = MetaType.Imc, - ObjectType = slot.IsAccessory() ? ObjectType.Accessory : ObjectType.Equipment, - EquipSlot = slot, - PrimaryId = primaryId, - Variant = idx, - }, - ImcValue = value, - }; - - public static MetaManipulation Rsp( SubRace subRace, RspAttribute attribute, float value ) - => new() - { - RspIdentifier = new RspIdentifier() - { - Type = MetaType.Rsp, - SubRace = subRace, - Attribute = attribute, - }, - RspValue = value, - }; - - internal MetaManipulation( ulong identifier, ulong value ) - : this() - { - Identifier = identifier; - Value = value; - } - - [FieldOffset( 0 )] - public readonly ulong Identifier; - - [FieldOffset( 8 )] - public readonly ulong Value; - - [FieldOffset( 0 )] - public MetaType Type; - - [FieldOffset( 0 )] - public EqpIdentifier EqpIdentifier; - - [FieldOffset( 0 )] - public GmpIdentifier GmpIdentifier; - - [FieldOffset( 0 )] - public EqdpIdentifier EqdpIdentifier; - - [FieldOffset( 0 )] - public EstIdentifier EstIdentifier; - - [FieldOffset( 0 )] - public ImcIdentifier ImcIdentifier; - - [FieldOffset( 0 )] - public RspIdentifier RspIdentifier; - - - [FieldOffset( 8 )] - public EqpEntry EqpValue; - - [FieldOffset( 8 )] - public GmpEntry GmpValue; - - [FieldOffset( 8 )] - public EqdpEntry EqdpValue; - - [FieldOffset( 8 )] - public ushort EstValue; - - [FieldOffset( 8 )] - public ImcFile.ImageChangeData ImcValue; // 6 bytes. - - [FieldOffset( 8 )] - public float RspValue; - - public override int GetHashCode() - => Identifier.GetHashCode(); - - public int CompareTo( object? rhs ) - => Identifier.CompareTo( rhs is MetaManipulation m ? m.Identifier : null ); - - public GamePath CorrespondingFilename() - { - return Type switch - { - MetaType.Eqp => MetaFileNames.Eqp(), - MetaType.Eqdp => MetaFileNames.Eqdp( EqdpIdentifier.Slot, EqdpIdentifier.GenderRace ), - MetaType.Est => MetaFileNames.Est( EstIdentifier.ObjectType, EstIdentifier.EquipSlot, EstIdentifier.BodySlot ), - MetaType.Gmp => MetaFileNames.Gmp(), - MetaType.Imc => MetaFileNames.Imc( ImcIdentifier.ObjectType, ImcIdentifier.PrimaryId, ImcIdentifier.SecondaryId ), - MetaType.Rsp => MetaFileNames.Cmp(), - _ => throw new InvalidEnumArgumentException(), - }; - } - - // No error checking. - public bool Apply( EqpFile file ) - => file[ EqpIdentifier.SetId ].Apply( this ); - - public bool Apply( EqdpFile file ) - => file[ EqdpIdentifier.SetId ].Apply( this ); - - public bool Apply( GmpFile file ) - => file.SetEntry( GmpIdentifier.SetId, GmpValue ); - - public bool Apply( EstFile file ) - => file.SetEntry( EstIdentifier.GenderRace, EstIdentifier.PrimaryId, EstValue ); - - public bool Apply( ImcFile file ) - { - ref var value = ref file.GetValue( this ); - if( ImcValue.Equal( value ) ) - { - return false; - } - - value = ImcValue; - return true; - } - - public bool Apply( CmpFile file ) - => file.Set( RspIdentifier.SubRace, RspIdentifier.Attribute, RspValue ); - - public string IdentifierString() - { - return Type switch - { - MetaType.Eqp => EqpIdentifier.ToString(), - MetaType.Eqdp => EqdpIdentifier.ToString(), - MetaType.Est => EstIdentifier.ToString(), - MetaType.Gmp => GmpIdentifier.ToString(), - MetaType.Imc => ImcIdentifier.ToString(), - MetaType.Rsp => RspIdentifier.ToString(), - _ => throw new InvalidEnumArgumentException(), - }; - } - } -} \ No newline at end of file diff --git a/Penumbra/Meta/ShapeAttributeManager.cs b/Penumbra/Meta/ShapeAttributeManager.cs new file mode 100644 index 00000000..a7f71ac7 --- /dev/null +++ b/Penumbra/Meta/ShapeAttributeManager.cs @@ -0,0 +1,227 @@ +using OtterGui.Services; +using Penumbra.Collections; +using Penumbra.Collections.Cache; +using Penumbra.GameData.Enums; +using Penumbra.GameData.Interop; +using Penumbra.GameData.Structs; +using Penumbra.Interop.Hooks.PostProcessing; +using Penumbra.Meta.Manipulations; + +namespace Penumbra.Meta; + +public unsafe class ShapeAttributeManager : IRequiredService, IDisposable +{ + public const int NumSlots = 14; + public const int ModelSlotSize = 18; + private readonly AttributeHook _attributeHook; + + public static ReadOnlySpan UsedModels + => + [ + HumanSlot.Head, HumanSlot.Body, HumanSlot.Hands, HumanSlot.Legs, HumanSlot.Feet, HumanSlot.Ears, HumanSlot.Neck, HumanSlot.Wrists, + HumanSlot.RFinger, HumanSlot.LFinger, HumanSlot.Glasses, HumanSlot.Hair, HumanSlot.Face, HumanSlot.Ear, + ]; + + public ShapeAttributeManager(AttributeHook attributeHook) + { + _attributeHook = attributeHook; + _attributeHook.Subscribe(OnAttributeComputed, AttributeHook.Priority.ShapeAttributeManager); + } + + private readonly Dictionary[] _temporaryShapes = + Enumerable.Range(0, NumSlots).Select(_ => new Dictionary()).ToArray(); + + private readonly PrimaryId[] _ids = new PrimaryId[ModelSlotSize]; + + private HumanSlot _modelIndex; + private int _slotIndex; + private GenderRace _genderRace; + + private FFXIVClientStructs.FFXIV.Client.Graphics.Render.Model* _model; + + public void Dispose() + => _attributeHook.Unsubscribe(OnAttributeComputed); + + private void OnAttributeComputed(Actor actor, Model model, ModCollection collection) + { + if (!collection.HasCache) + return; + + _genderRace = (GenderRace)model.AsHuman->RaceSexId; + for (_slotIndex = 0; _slotIndex < NumSlots; ++_slotIndex) + { + _modelIndex = UsedModels[_slotIndex]; + _model = model.AsHuman->Models[_modelIndex.ToIndex()]; + if (_model is null || _model->ModelResourceHandle is null) + continue; + + _ids[(int)_modelIndex] = model.GetModelId(_modelIndex); + CheckShapes(collection.MetaCache!.Shp); + CheckAttributes(collection.MetaCache!.Atr); + if (_modelIndex is <= HumanSlot.LFinger and >= HumanSlot.Ears) + AccessoryImcCheck(model); + } + + UpdateDefaultMasks(model, collection.MetaCache!.Shp); + } + + private void AccessoryImcCheck(Model model) + { + var imcMask = (ushort)(0x03FF & *(ushort*)(model.Address + 0xAAC + 6 * (int)_modelIndex)); + + Span attr = + [ + (byte)'a', + (byte)'t', + (byte)'r', + (byte)'_', + AccessoryByte(_modelIndex), + (byte)'v', + (byte)'_', + (byte)'a', + 0, + ]; + for (var i = 1; i < 10; ++i) + { + var flag = (ushort)(1 << i); + if ((imcMask & flag) is not 0) + continue; + + attr[^2] = (byte)('a' + i); + + foreach (var (attribute, index) in _model->ModelResourceHandle->Attributes) + { + if (!EqualAttribute(attr, attribute.Value)) + continue; + + _model->EnabledAttributeIndexMask &= ~(1u << index); + break; + } + } + } + + [MethodImpl(MethodImplOptions.AggressiveOptimization | MethodImplOptions.AggressiveInlining)] + private static bool EqualAttribute(Span needle, byte* haystack) + { + foreach (var character in needle) + { + if (*haystack++ != character) + return false; + } + + return true; + } + + private static byte AccessoryByte(HumanSlot slot) + => slot switch + { + HumanSlot.Head => (byte)'m', + HumanSlot.Ears => (byte)'e', + HumanSlot.Neck => (byte)'n', + HumanSlot.Wrists => (byte)'w', + HumanSlot.RFinger => (byte)'r', + HumanSlot.LFinger => (byte)'r', + _ => 0, + }; + + private void CheckAttributes(AtrCache attributeCache) + { + if (attributeCache.DisabledCount is 0) + return; + + ref var attributes = ref _model->ModelResourceHandle->Attributes; + foreach (var (attribute, index) in attributes.Where(kvp => ShapeAttributeString.ValidateCustomAttributeString(kvp.Key.Value))) + { + if (ShapeAttributeString.TryRead(attribute.Value, out var attributeString)) + { + // Mask out custom attributes if they are disabled. Attributes are enabled by default. + if (attributeCache.ShouldBeDisabled(attributeString, _modelIndex, _ids[_modelIndex.ToIndex()], _genderRace)) + _model->EnabledAttributeIndexMask &= ~(1u << index); + } + else + { + Penumbra.Log.Warning($"Trying to read a attribute string that is too long: {attribute}."); + } + } + } + + private void CheckShapes(ShpCache shapeCache) + { + _temporaryShapes[_slotIndex].Clear(); + ref var shapes = ref _model->ModelResourceHandle->Shapes; + foreach (var (shape, index) in shapes.Where(kvp => ShapeAttributeString.ValidateCustomShapeString(kvp.Key.Value))) + { + if (ShapeAttributeString.TryRead(shape.Value, out var shapeString)) + { + _temporaryShapes[_slotIndex].TryAdd(shapeString, index); + // Add custom shapes if they are enabled. Shapes are disabled by default. + if (shapeCache.ShouldBeEnabled(shapeString, _modelIndex, _ids[_modelIndex.ToIndex()], _genderRace)) + _model->EnabledShapeKeyIndexMask |= 1u << index; + } + else + { + Penumbra.Log.Warning($"Trying to read a shape string that is too long: {shape}."); + } + } + } + + private void UpdateDefaultMasks(Model human, ShpCache cache) + { + var genderRace = (GenderRace)human.AsHuman->RaceSexId; + foreach (var (shape, topIndex) in _temporaryShapes[1]) + { + if (shape.IsWrist() + && _temporaryShapes[2].TryGetValue(shape, out var handIndex) + && !cache.ShouldBeDisabled(shape, HumanSlot.Body, _ids[1], genderRace) + && !cache.ShouldBeDisabled(shape, HumanSlot.Hands, _ids[2], genderRace) + && human.AsHuman->Models[1] is not null + && human.AsHuman->Models[2] is not null) + { + human.AsHuman->Models[1]->EnabledShapeKeyIndexMask |= 1u << topIndex; + human.AsHuman->Models[2]->EnabledShapeKeyIndexMask |= 1u << handIndex; + CheckCondition(cache.State(ShapeConnectorCondition.Wrists), genderRace, HumanSlot.Body, HumanSlot.Hands, 1, 2); + } + + if (shape.IsWaist() + && _temporaryShapes[3].TryGetValue(shape, out var legIndex) + && !cache.ShouldBeDisabled(shape, HumanSlot.Body, _ids[1], genderRace) + && !cache.ShouldBeDisabled(shape, HumanSlot.Legs, _ids[3], genderRace) + && human.AsHuman->Models[1] is not null + && human.AsHuman->Models[3] is not null) + { + human.AsHuman->Models[1]->EnabledShapeKeyIndexMask |= 1u << topIndex; + human.AsHuman->Models[3]->EnabledShapeKeyIndexMask |= 1u << legIndex; + CheckCondition(cache.State(ShapeConnectorCondition.Waist), genderRace, HumanSlot.Body, HumanSlot.Legs, 1, 3); + } + } + + foreach (var (shape, bottomIndex) in _temporaryShapes[3]) + { + if (shape.IsAnkle() + && _temporaryShapes[4].TryGetValue(shape, out var footIndex) + && !cache.ShouldBeDisabled(shape, HumanSlot.Legs, _ids[3], genderRace) + && !cache.ShouldBeDisabled(shape, HumanSlot.Feet, _ids[4], genderRace) + && human.AsHuman->Models[3] is not null + && human.AsHuman->Models[4] is not null) + { + human.AsHuman->Models[3]->EnabledShapeKeyIndexMask |= 1u << bottomIndex; + human.AsHuman->Models[4]->EnabledShapeKeyIndexMask |= 1u << footIndex; + CheckCondition(cache.State(ShapeConnectorCondition.Ankles), genderRace, HumanSlot.Legs, HumanSlot.Feet, 3, 4); + } + } + + return; + + void CheckCondition(IReadOnlyDictionary dict, GenderRace genderRace, HumanSlot slot1, + HumanSlot slot2, int idx1, int idx2) + { + foreach (var (shape, set) in dict) + { + if (set.CheckEntry(slot1, _ids[idx1], genderRace) is true && _temporaryShapes[idx1].TryGetValue(shape, out var index1)) + human.AsHuman->Models[idx1]->EnabledShapeKeyIndexMask |= 1u << index1; + if (set.CheckEntry(slot2, _ids[idx2], genderRace) is true && _temporaryShapes[idx2].TryGetValue(shape, out var index2)) + human.AsHuman->Models[idx2]->EnabledShapeKeyIndexMask |= 1u << index2; + } + } + } +} diff --git a/Penumbra/Meta/ShapeAttributeString.cs b/Penumbra/Meta/ShapeAttributeString.cs new file mode 100644 index 00000000..55e3f021 --- /dev/null +++ b/Penumbra/Meta/ShapeAttributeString.cs @@ -0,0 +1,203 @@ +using Lumina.Misc; +using Newtonsoft.Json; +using Penumbra.GameData.Files.PhybStructs; +using Penumbra.String.Functions; + +namespace Penumbra.Meta; + +[JsonConverter(typeof(Converter))] +public struct ShapeAttributeString : IEquatable, IComparable +{ + public const int MaxLength = 30; + + public static readonly ShapeAttributeString Empty = new(); + + private FixedString32 _buffer; + + public int Count + => _buffer[31]; + + public int Length + => _buffer[31]; + + public override string ToString() + => Encoding.UTF8.GetString(_buffer[..Length]); + + public byte this[int index] + => _buffer[index]; + + public unsafe ReadOnlySpan AsSpan + { + get + { + fixed (void* ptr = &this) + { + return new ReadOnlySpan(ptr, Length); + } + } + } + + public static unsafe bool ValidateCustomShapeString(byte* shape) + { + // "shpx_*" + if (shape is null) + return false; + + if (*shape++ is not (byte)'s' + || *shape++ is not (byte)'h' + || *shape++ is not (byte)'p' + || *shape++ is not (byte)'x' + || *shape++ is not (byte)'_' + || *shape is 0) + return false; + + return true; + } + + public bool ValidateCustomShapeString() + { + // "shpx_*" + if (Length < 6) + return false; + + if (_buffer[0] is not (byte)'s' + || _buffer[1] is not (byte)'h' + || _buffer[2] is not (byte)'p' + || _buffer[3] is not (byte)'x' + || _buffer[4] is not (byte)'_') + return false; + + return true; + } + + public static unsafe bool ValidateCustomAttributeString(byte* shape) + { + // "atrx_*" + if (shape is null) + return false; + + if (*shape++ is not (byte)'a' + || *shape++ is not (byte)'t' + || *shape++ is not (byte)'r' + || *shape++ is not (byte)'x' + || *shape++ is not (byte)'_' + || *shape is 0) + return false; + + return true; + } + + public bool ValidateCustomAttributeString() + { + // "atrx_*" + if (Length < 6) + return false; + + if (_buffer[0] is not (byte)'a' + || _buffer[1] is not (byte)'t' + || _buffer[2] is not (byte)'r' + || _buffer[3] is not (byte)'x' + || _buffer[4] is not (byte)'_') + return false; + + return true; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] + public bool IsAnkle() + => CheckCenter('a', 'n'); + + [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] + public bool IsWaist() + => CheckCenter('w', 'a'); + + [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] + public bool IsWrist() + => CheckCenter('w', 'r'); + + [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] + private bool CheckCenter(char first, char second) + => Length > 8 && _buffer[5] == first && _buffer[6] == second && _buffer[7] is (byte)'_'; + + public bool Equals(ShapeAttributeString other) + => Length == other.Length && _buffer[..Length].SequenceEqual(other._buffer[..Length]); + + public override bool Equals(object? obj) + => obj is ShapeAttributeString other && Equals(other); + + public override int GetHashCode() + => (int)Crc32.Get(_buffer[..Length]); + + public static bool operator ==(ShapeAttributeString left, ShapeAttributeString right) + => left.Equals(right); + + public static bool operator !=(ShapeAttributeString left, ShapeAttributeString right) + => !left.Equals(right); + + public static unsafe bool TryRead(byte* pointer, out ShapeAttributeString ret) + { + var span = MemoryMarshal.CreateReadOnlySpanFromNullTerminated(pointer); + return TryRead(span, out ret); + } + + public unsafe int CompareTo(ShapeAttributeString other) + { + fixed (void* lhs = &this) + { + return ByteStringFunctions.Compare((byte*)lhs, Length, (byte*)&other, other.Length); + } + } + + public static bool TryRead(ReadOnlySpan utf8, out ShapeAttributeString ret) + { + if (utf8.Length is 0 or > MaxLength) + { + ret = Empty; + return false; + } + + ret = Empty; + utf8.CopyTo(ret._buffer); + ret._buffer[utf8.Length] = 0; + ret._buffer[31] = (byte)utf8.Length; + return true; + } + + public static bool TryRead(ReadOnlySpan utf16, out ShapeAttributeString ret) + { + ret = Empty; + if (!Encoding.UTF8.TryGetBytes(utf16, ret._buffer[..MaxLength], out var written)) + return false; + + ret._buffer[written] = 0; + ret._buffer[31] = (byte)written; + return true; + } + + public void ForceLength(byte length) + { + if (length > MaxLength) + length = MaxLength; + _buffer[length] = 0; + _buffer[31] = length; + } + + private sealed class Converter : JsonConverter + { + public override void WriteJson(JsonWriter writer, ShapeAttributeString value, JsonSerializer serializer) + { + writer.WriteValue(value.ToString()); + } + + public override ShapeAttributeString ReadJson(JsonReader reader, Type objectType, ShapeAttributeString existingValue, + bool hasExistingValue, + JsonSerializer serializer) + { + var value = serializer.Deserialize(reader); + if (!TryRead(value, out existingValue)) + throw new JsonReaderException($"Could not parse {value} into ShapeAttributeString."); + + return existingValue; + } + } +} diff --git a/Penumbra/MigrateConfiguration.cs b/Penumbra/MigrateConfiguration.cs deleted file mode 100644 index 71b3646c..00000000 --- a/Penumbra/MigrateConfiguration.cs +++ /dev/null @@ -1,86 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using Dalamud.Logging; -using Dalamud.Plugin; -using Newtonsoft.Json.Linq; -using Penumbra.Mod; -using Penumbra.Mods; -using Penumbra.Util; - -namespace Penumbra -{ - public static class MigrateConfiguration - { - public static void Version0To1( Configuration config ) - { - if( config.Version != 0 ) - { - return; - } - - config.ModDirectory = config.CurrentCollection; - config.CurrentCollection = "Default"; - config.DefaultCollection = "Default"; - config.Version = 1; - ResettleCollectionJson( config ); - } - - private static void ResettleCollectionJson( Configuration config ) - { - var collectionJson = new FileInfo( Path.Combine( config.ModDirectory, "collection.json" ) ); - if( !collectionJson.Exists ) - { - return; - } - - var defaultCollection = new ModCollection(); - var defaultCollectionFile = defaultCollection.FileName(); - if( defaultCollectionFile.Exists ) - { - return; - } - - try - { - var text = File.ReadAllText( collectionJson.FullName ); - var data = JArray.Parse( text ); - - var maxPriority = 0; - foreach( var setting in data.Cast< JObject >() ) - { - var modName = ( string )setting[ "FolderName" ]!; - var enabled = ( bool )setting[ "Enabled" ]!; - var priority = ( int )setting[ "Priority" ]!; - var settings = setting[ "Settings" ]!.ToObject< Dictionary< string, int > >() - ?? setting[ "Conf" ]!.ToObject< Dictionary< string, int > >(); - - var save = new ModSettings() - { - Enabled = enabled, - Priority = priority, - Settings = settings!, - }; - defaultCollection.Settings.Add( modName, save ); - maxPriority = Math.Max( maxPriority, priority ); - } - - if( !config.InvertModListOrder ) - { - foreach( var setting in defaultCollection.Settings.Values ) - { - setting.Priority = maxPriority - setting.Priority; - } - } - - defaultCollection.Save(); - } - catch( Exception e ) - { - PluginLog.Error( $"Could not migrate the old collection file to new collection files:\n{e}" ); - throw; - } - } - } -} \ No newline at end of file diff --git a/Penumbra/Mod/Mod.cs b/Penumbra/Mod/Mod.cs deleted file mode 100644 index 820c79c5..00000000 --- a/Penumbra/Mod/Mod.cs +++ /dev/null @@ -1,35 +0,0 @@ -using System.Collections.Generic; -using System.IO; -using Penumbra.GameData.Util; -using Penumbra.Util; - -namespace Penumbra.Mod -{ - // A complete Mod containing settings (i.e. dependent on a collection) - // and the resulting cache. - public class Mod - { - public ModSettings Settings { get; } - public ModData Data { get; } - public ModCache Cache { get; } - - public Mod( ModSettings settings, ModData data ) - { - Settings = settings; - Data = data; - Cache = new ModCache(); - } - - public bool FixSettings() - => Settings.FixInvalidSettings( Data.Meta ); - - public HashSet< GamePath > GetFiles( FileInfo file ) - { - var relPath = new RelPath( file, Data.BasePath ); - return ModFunctions.GetFilesForConfig( relPath, Settings, Data.Meta ); - } - - public override string ToString() - => Data.Meta.Name; - } -} \ No newline at end of file diff --git a/Penumbra/Mod/ModCache.cs b/Penumbra/Mod/ModCache.cs deleted file mode 100644 index accb1272..00000000 --- a/Penumbra/Mod/ModCache.cs +++ /dev/null @@ -1,58 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using Penumbra.GameData.Util; -using Penumbra.Meta; - -namespace Penumbra.Mod -{ - // The ModCache contains volatile information dependent on all current settings in a collection. - public class ModCache - { - public Dictionary< Mod, (List< GamePath > Files, List< MetaManipulation > Manipulations) > Conflicts { get; private set; } = new(); - - public void AddConflict( Mod precedingMod, GamePath gamePath ) - { - if( Conflicts.TryGetValue( precedingMod, out var conflicts ) && !conflicts.Files.Contains( gamePath ) ) - { - conflicts.Files.Add( gamePath ); - } - else - { - Conflicts[ precedingMod ] = ( new List< GamePath > { gamePath }, new List< MetaManipulation >() ); - } - } - - public void AddConflict( Mod precedingMod, MetaManipulation manipulation ) - { - if( Conflicts.TryGetValue( precedingMod, out var conflicts ) && !conflicts.Manipulations.Contains( manipulation ) ) - { - conflicts.Manipulations.Add( manipulation ); - } - else - { - Conflicts[ precedingMod ] = ( new List< GamePath >(), new List< MetaManipulation > { manipulation } ); - } - } - - public void ClearConflicts() - => Conflicts.Clear(); - - public void ClearFileConflicts() - { - Conflicts = Conflicts.Where( kvp => kvp.Value.Manipulations.Count > 0 ).ToDictionary( kvp => kvp.Key, kvp => - { - kvp.Value.Files.Clear(); - return kvp.Value; - } ); - } - - public void ClearMetaConflicts() - { - Conflicts = Conflicts.Where( kvp => kvp.Value.Files.Count > 0 ).ToDictionary( kvp => kvp.Key, kvp => - { - kvp.Value.Manipulations.Clear(); - return kvp.Value; - } ); - } - } -} \ No newline at end of file diff --git a/Penumbra/Mod/ModCleanup.cs b/Penumbra/Mod/ModCleanup.cs deleted file mode 100644 index fca6acfd..00000000 --- a/Penumbra/Mod/ModCleanup.cs +++ /dev/null @@ -1,482 +0,0 @@ -using System; -using System.Collections; -using System.Collections.Generic; -using System.ComponentModel; -using System.IO; -using System.Linq; -using System.Security.Cryptography; -using Dalamud.Logging; -using Penumbra.GameData.Util; -using Penumbra.Importer; -using Penumbra.Mods; -using Penumbra.Structs; -using Penumbra.Util; - -namespace Penumbra.Mod -{ - public class ModCleanup - { - private const string Duplicates = "Duplicates"; - private const string Required = "Required"; - - - private readonly DirectoryInfo _baseDir; - private readonly ModMeta _mod; - private SHA256? _hasher; - - private readonly Dictionary< long, List< FileInfo > > _filesBySize = new(); - - private SHA256 Sha() - { - _hasher ??= SHA256.Create(); - return _hasher; - } - - private ModCleanup( DirectoryInfo baseDir, ModMeta mod ) - { - _baseDir = baseDir; - _mod = mod; - BuildDict(); - } - - private void BuildDict() - { - foreach( var file in _baseDir.EnumerateFiles( "*.*", SearchOption.AllDirectories ) ) - { - var fileLength = file.Length; - if( _filesBySize.TryGetValue( fileLength, out var files ) ) - { - files.Add( file ); - } - else - { - _filesBySize[ fileLength ] = new List< FileInfo >() { file }; - } - } - } - - private static DirectoryInfo CreateNewModDir( ModData mod, string optionGroup, string option ) - { - var newName = $"{mod.BasePath.Name}_{optionGroup}_{option}"; - var newDir = TexToolsImport.CreateModFolder( new DirectoryInfo( Penumbra.Config!.ModDirectory ), newName ); - return newDir; - } - - private static ModData CreateNewMod( DirectoryInfo newDir, string newSortOrder ) - { - var manager = Service< ModManager >.Get(); - manager.AddMod( newDir ); - var newMod = manager.Mods[ newDir.Name ]; - newMod.Move( newSortOrder ); - newMod.ComputeChangedItems(); - ModFileSystem.InvokeChange(); - return newMod; - } - - private static ModMeta CreateNewMeta( DirectoryInfo newDir, ModData mod, string name, string optionGroup, string option ) - { - var newMeta = new ModMeta - { - Author = mod.Meta.Author, - Name = name, - Description = $"Split from {mod.Meta.Name} Group {optionGroup} Option {option}.", - }; - var metaFile = new FileInfo( Path.Combine( newDir.FullName, "meta.json" ) ); - newMeta.SaveToFile( metaFile ); - return newMeta; - } - - private static void CreateModSplit( HashSet< string > unseenPaths, ModData mod, OptionGroup group, Option option ) - { - try - { - var newDir = CreateNewModDir( mod, group.GroupName!, option.OptionName ); - var newName = group.SelectionType == SelectType.Multi ? $"{group.GroupName} - {option.OptionName}" : option.OptionName; - var newMeta = CreateNewMeta( newDir, mod, newName, group.GroupName!, option.OptionName ); - foreach( var (fileName, paths) in option.OptionFiles ) - { - var oldPath = Path.Combine( mod.BasePath.FullName, fileName ); - unseenPaths.Remove( oldPath ); - if( File.Exists( oldPath ) ) - { - foreach( var path in paths ) - { - var newPath = Path.Combine( newDir.FullName, path ); - Directory.CreateDirectory( Path.GetDirectoryName( newPath )! ); - File.Copy( oldPath, newPath, true ); - } - } - } - - var newSortOrder = group.SelectionType == SelectType.Single - ? $"{mod.SortOrder.ParentFolder.FullName}/{mod.Meta.Name}/{group.GroupName}/{option.OptionName}" - : $"{mod.SortOrder.ParentFolder.FullName}/{mod.Meta.Name}/{group.GroupName} - {option.OptionName}"; - CreateNewMod( newDir, newSortOrder ); - } - catch( Exception e ) - { - PluginLog.Error( $"Could not split Mod:\n{e}" ); - } - } - - public static void SplitMod( ModData mod ) - { - if( !mod.Meta.Groups.Any() ) - { - return; - } - - var unseenPaths = mod.Resources.ModFiles.Select( f => f.FullName ).ToHashSet(); - foreach( var group in mod.Meta.Groups.Values ) - { - foreach( var option in group.Options ) - { - CreateModSplit( unseenPaths, mod, group, option ); - } - } - - if( !unseenPaths.Any() ) - { - return; - } - - var defaultGroup = new OptionGroup() - { - GroupName = "Default", - SelectionType = SelectType.Multi, - }; - var defaultOption = new Option() - { - OptionName = "Files", - OptionFiles = unseenPaths.ToDictionary( p => new RelPath( new FileInfo( p ), mod.BasePath ), - p => new HashSet< GamePath >() { new( new FileInfo( p ), mod.BasePath ) } ), - }; - CreateModSplit( unseenPaths, mod, defaultGroup, defaultOption ); - } - - private static Option FindOrCreateDuplicates( ModMeta meta ) - { - static Option RequiredOption() - => new() - { - OptionName = Required, - OptionDesc = "", - OptionFiles = new Dictionary< RelPath, HashSet< GamePath > >(), - }; - - if( meta.Groups.TryGetValue( Duplicates, out var duplicates ) ) - { - var idx = duplicates.Options.FindIndex( o => o.OptionName == Required ); - if( idx >= 0 ) - { - return duplicates.Options[ idx ]; - } - - duplicates.Options.Add( RequiredOption() ); - return duplicates.Options.Last(); - } - - meta.Groups.Add( Duplicates, new OptionGroup - { - GroupName = Duplicates, - SelectionType = SelectType.Single, - Options = new List< Option > { RequiredOption() }, - } ); - - return meta.Groups[ Duplicates ].Options.First(); - } - - public static void Deduplicate( DirectoryInfo baseDir, ModMeta mod ) - { - var dedup = new ModCleanup( baseDir, mod ); - foreach( var pair in dedup._filesBySize.Where( pair => pair.Value.Count >= 2 ) ) - { - if( pair.Value.Count == 2 ) - { - if( CompareFilesDirectly( pair.Value[ 0 ], pair.Value[ 1 ] ) ) - { - dedup.ReplaceFile( pair.Value[ 0 ], pair.Value[ 1 ] ); - } - } - else - { - var deleted = Enumerable.Repeat( false, pair.Value.Count ).ToArray(); - var hashes = pair.Value.Select( dedup.ComputeHash ).ToArray(); - - for( var i = 0; i < pair.Value.Count; ++i ) - { - if( deleted[ i ] ) - { - continue; - } - - for( var j = i + 1; j < pair.Value.Count; ++j ) - { - if( deleted[ j ] || !CompareHashes( hashes[ i ], hashes[ j ] ) ) - { - continue; - } - - dedup.ReplaceFile( pair.Value[ i ], pair.Value[ j ] ); - deleted[ j ] = true; - } - } - } - } - - CleanUpDuplicates( mod ); - ClearEmptySubDirectories( dedup._baseDir ); - } - - private void ReplaceFile( FileInfo f1, FileInfo f2 ) - { - RelPath relName1 = new( f1, _baseDir ); - RelPath relName2 = new( f2, _baseDir ); - - var inOption1 = false; - var inOption2 = false; - foreach( var option in _mod.Groups.SelectMany( g => g.Value.Options ) ) - { - if( option.OptionFiles.ContainsKey( relName1 ) ) - { - inOption1 = true; - } - - if( !option.OptionFiles.TryGetValue( relName2, out var values ) ) - { - continue; - } - - inOption2 = true; - - foreach( var value in values ) - { - option.AddFile( relName1, value ); - } - - option.OptionFiles.Remove( relName2 ); - } - - if( !inOption1 || !inOption2 ) - { - var duplicates = FindOrCreateDuplicates( _mod ); - if( !inOption1 ) - { - duplicates.AddFile( relName1, relName2.ToGamePath() ); - } - - if( !inOption2 ) - { - duplicates.AddFile( relName1, relName1.ToGamePath() ); - } - } - - PluginLog.Information( $"File {relName1} and {relName2} are identical. Deleting the second." ); - f2.Delete(); - } - - public static bool CompareFilesDirectly( FileInfo f1, FileInfo f2 ) - => File.ReadAllBytes( f1.FullName ).SequenceEqual( File.ReadAllBytes( f2.FullName ) ); - - public static bool CompareHashes( byte[] f1, byte[] f2 ) - => StructuralComparisons.StructuralEqualityComparer.Equals( f1, f2 ); - - public byte[] ComputeHash( FileInfo f ) - { - var stream = File.OpenRead( f.FullName ); - var ret = Sha().ComputeHash( stream ); - stream.Dispose(); - return ret; - } - - // Does not delete the base directory itself even if it is completely empty at the end. - public static void ClearEmptySubDirectories( DirectoryInfo baseDir ) - { - foreach( var subDir in baseDir.GetDirectories() ) - { - ClearEmptySubDirectories( subDir ); - if( subDir.GetFiles().Length == 0 && subDir.GetDirectories().Length == 0 ) - { - subDir.Delete(); - } - } - } - - private static bool FileIsInAnyGroup( ModMeta meta, RelPath relPath, bool exceptDuplicates = false ) - { - var groupEnumerator = exceptDuplicates - ? meta.Groups.Values.Where( g => g.GroupName != Duplicates ) - : meta.Groups.Values; - return groupEnumerator.SelectMany( group => group.Options ) - .Any( option => option.OptionFiles.ContainsKey( relPath ) ); - } - - private static void CleanUpDuplicates( ModMeta meta ) - { - if( !meta.Groups.TryGetValue( Duplicates, out var info ) ) - { - return; - } - - var requiredIdx = info.Options.FindIndex( o => o.OptionName == Required ); - if( requiredIdx >= 0 ) - { - var required = info.Options[ requiredIdx ]; - foreach( var kvp in required.OptionFiles.ToArray() ) - { - if( kvp.Value.Count > 1 || FileIsInAnyGroup( meta, kvp.Key, true ) ) - { - continue; - } - - if( kvp.Value.Count == 0 || kvp.Value.First().CompareTo( kvp.Key.ToGamePath() ) == 0 ) - { - required.OptionFiles.Remove( kvp.Key ); - } - } - - if( required.OptionFiles.Count == 0 ) - { - info.Options.RemoveAt( requiredIdx ); - } - } - - if( info.Options.Count == 0 ) - { - meta.Groups.Remove( Duplicates ); - } - } - - public enum GroupType - { - Both = 0, - Single = 1, - Multi = 2, - }; - - private static void RemoveFromGroups( ModMeta meta, RelPath relPath, GamePath gamePath, GroupType type = GroupType.Both, - bool skipDuplicates = true ) - { - if( meta.Groups.Count == 0 ) - { - return; - } - - var enumerator = type switch - { - GroupType.Both => meta.Groups.Values, - GroupType.Single => meta.Groups.Values.Where( g => g.SelectionType == SelectType.Single ), - GroupType.Multi => meta.Groups.Values.Where( g => g.SelectionType == SelectType.Multi ), - _ => throw new InvalidEnumArgumentException( "Invalid Enum in RemoveFromGroups" ), - }; - foreach( var group in enumerator ) - { - var optionEnum = skipDuplicates - ? group.Options.Where( o => group.GroupName != Duplicates || o.OptionName != Required ) - : group.Options; - foreach( var option in optionEnum ) - { - if( option.OptionFiles.TryGetValue( relPath, out var gamePaths ) && gamePaths.Remove( gamePath ) && gamePaths.Count == 0 ) - { - option.OptionFiles.Remove( relPath ); - } - } - } - } - - public static bool MoveFile( ModMeta meta, string basePath, RelPath oldRelPath, RelPath newRelPath ) - { - if( oldRelPath == newRelPath ) - { - return true; - } - - try - { - var newFullPath = Path.Combine( basePath, newRelPath ); - new FileInfo( newFullPath ).Directory!.Create(); - File.Move( Path.Combine( basePath, oldRelPath ), newFullPath ); - } - catch( Exception e ) - { - PluginLog.Error( $"Could not move file from {oldRelPath} to {newRelPath}:\n{e}" ); - return false; - } - - foreach( var option in meta.Groups.Values.SelectMany( group => group.Options ) ) - { - if( option.OptionFiles.TryGetValue( oldRelPath, out var gamePaths ) ) - { - option.OptionFiles.Add( newRelPath, gamePaths ); - option.OptionFiles.Remove( oldRelPath ); - } - } - - return true; - } - - - private static void RemoveUselessGroups( ModMeta meta ) - { - meta.Groups = meta.Groups.Where( kvp => kvp.Value.Options.Any( o => o.OptionFiles.Count > 0 ) ) - .ToDictionary( kvp => kvp.Key, kvp => kvp.Value ); - } - - // Goes through all Single-Select options and checks if file links are in each of them. - // If they are, it moves those files to the root folder and removes them from the groups (and puts them to duplicates, if necessary). - public static void Normalize( DirectoryInfo baseDir, ModMeta meta ) - { - foreach( var group in meta.Groups.Values.Where( g => g.SelectionType == SelectType.Single && g.GroupName != Duplicates ) ) - { - var firstOption = true; - HashSet< (RelPath, GamePath) > groupList = new(); - foreach( var option in group.Options ) - { - HashSet< (RelPath, GamePath) > optionList = new(); - foreach( var (file, gamePaths) in option.OptionFiles.Select( p => ( p.Key, p.Value ) ) ) - { - optionList.UnionWith( gamePaths.Select( p => ( file, p ) ) ); - } - - if( firstOption ) - { - groupList = optionList; - } - else - { - groupList.IntersectWith( optionList ); - } - - firstOption = false; - } - - var newPath = new Dictionary< RelPath, GamePath >(); - foreach( var (path, gamePath) in groupList ) - { - var relPath = new RelPath( gamePath ); - if( newPath.TryGetValue( path, out var usedGamePath ) ) - { - var required = FindOrCreateDuplicates( meta ); - var usedRelPath = new RelPath( usedGamePath ); - required.AddFile( usedRelPath, gamePath ); - required.AddFile( usedRelPath, usedGamePath ); - RemoveFromGroups( meta, relPath, gamePath, GroupType.Single ); - } - else if( MoveFile( meta, baseDir.FullName, path, relPath ) ) - { - newPath[ path ] = gamePath; - if( FileIsInAnyGroup( meta, relPath ) ) - { - FindOrCreateDuplicates( meta ).AddFile( relPath, gamePath ); - } - - RemoveFromGroups( meta, relPath, gamePath, GroupType.Single ); - } - } - } - - RemoveUselessGroups( meta ); - ClearEmptySubDirectories( baseDir ); - } - } -} \ No newline at end of file diff --git a/Penumbra/Mod/ModData.cs b/Penumbra/Mod/ModData.cs deleted file mode 100644 index 603f254e..00000000 --- a/Penumbra/Mod/ModData.cs +++ /dev/null @@ -1,136 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using Dalamud.Logging; -using Penumbra.Mods; -using Penumbra.Util; - -namespace Penumbra.Mod -{ - public struct SortOrder : IComparable< SortOrder > - { - public ModFolder ParentFolder { get; set; } - - private string _sortOrderName; - - public string SortOrderName - { - get => _sortOrderName; - set => _sortOrderName = value.Replace( '/', '\\' ); - } - - public string SortOrderPath - => ParentFolder.FullName; - - public string FullName - { - get - { - var path = SortOrderPath; - return path.Any() ? $"{path}/{SortOrderName}" : SortOrderName; - } - } - - - public SortOrder( ModFolder parentFolder, string name ) - { - ParentFolder = parentFolder; - _sortOrderName = name.Replace( '/', '\\' ); - } - - public string FullPath - => SortOrderPath.Any() ? $"{SortOrderPath}/{SortOrderName}" : SortOrderName; - - public int CompareTo( SortOrder other ) - => string.Compare( FullPath, other.FullPath, StringComparison.InvariantCultureIgnoreCase ); - } - - // ModData contains all permanent information about a mod, - // and is independent of collections or settings. - // It only changes when the user actively changes the mod or their filesystem. - public class ModData - { - public DirectoryInfo BasePath; - public ModMeta Meta; - public ModResources Resources; - - public SortOrder SortOrder; - - public SortedList< string, object? > ChangedItems { get; } = new(); - public string LowerChangedItemsString { get; private set; } = string.Empty; - public FileInfo MetaFile { get; set; } - - private ModData( ModFolder parentFolder, DirectoryInfo basePath, ModMeta meta, ModResources resources ) - { - BasePath = basePath; - Meta = meta; - Resources = resources; - MetaFile = MetaFileInfo( basePath ); - SortOrder = new SortOrder( parentFolder, Meta.Name ); - SortOrder.ParentFolder.AddMod( this ); - - ComputeChangedItems(); - } - - public void ComputeChangedItems() - { - var identifier = GameData.GameData.GetIdentifier(); - ChangedItems.Clear(); - foreach( var file in Resources.ModFiles.Select( f => new RelPath( f, BasePath ) ) ) - { - foreach( var path in ModFunctions.GetAllFiles( file, Meta ) ) - { - identifier.Identify( ChangedItems, path ); - } - } - - foreach( var path in Meta.FileSwaps.Keys ) - { - identifier.Identify( ChangedItems, path ); - } - - LowerChangedItemsString = string.Join( "\0", ChangedItems.Keys.Select( k => k.ToLowerInvariant() ) ); - } - - public static FileInfo MetaFileInfo( DirectoryInfo basePath ) - => new( Path.Combine( basePath.FullName, "meta.json" ) ); - - public static ModData? LoadMod( ModFolder parentFolder, DirectoryInfo basePath ) - { - basePath.Refresh(); - if( !basePath.Exists ) - { - PluginLog.Error( $"Supplied mod directory {basePath} does not exist." ); - return null; - } - - var metaFile = MetaFileInfo( basePath ); - if( !metaFile.Exists ) - { - PluginLog.Debug( "No mod meta found for {ModLocation}.", basePath.Name ); - return null; - } - - var meta = ModMeta.LoadFromFile( metaFile ); - if( meta == null ) - { - return null; - } - - var data = new ModResources(); - if( data.RefreshModFiles( basePath ).HasFlag( ResourceChange.Meta ) ) - { - data.SetManipulations( meta, basePath ); - } - - return new ModData( parentFolder, basePath, meta, data ); - } - - public void SaveMeta() - => Meta.SaveToFile( MetaFile ); - - public override string ToString() - => SortOrder.FullPath; - } -} \ No newline at end of file diff --git a/Penumbra/Mod/ModFunctions.cs b/Penumbra/Mod/ModFunctions.cs deleted file mode 100644 index 8f7f6e1c..00000000 --- a/Penumbra/Mod/ModFunctions.cs +++ /dev/null @@ -1,103 +0,0 @@ -using System.Collections.Generic; -using System.IO; -using System.Linq; -using Penumbra.GameData.Util; -using Penumbra.Structs; -using Penumbra.Util; - -namespace Penumbra.Mod -{ - // Functions that do not really depend on only one component of a mod. - public static class ModFunctions - { - public static bool CleanUpCollection( Dictionary< string, ModSettings > settings, IEnumerable< DirectoryInfo > modPaths ) - { - var hashes = modPaths.Select( p => p.Name ).ToHashSet(); - var missingMods = settings.Keys.Where( k => !hashes.Contains( k ) ).ToArray(); - var anyChanges = false; - foreach( var toRemove in missingMods ) - { - anyChanges |= settings.Remove( toRemove ); - } - - return anyChanges; - } - - public static HashSet< GamePath > GetFilesForConfig( RelPath relPath, ModSettings settings, ModMeta meta ) - { - var doNotAdd = false; - var files = new HashSet< GamePath >(); - foreach( var group in meta.Groups.Values.Where( g => g.Options.Count > 0 ) ) - { - doNotAdd |= group.ApplyGroupFiles( relPath, settings.Settings[ group.GroupName ], files ); - } - - if( !doNotAdd ) - { - files.Add( new GamePath( relPath ) ); - } - - return files; - } - - public static HashSet< GamePath > GetAllFiles( RelPath relPath, ModMeta meta ) - { - var ret = new HashSet< GamePath >(); - foreach( var option in meta.Groups.Values.SelectMany( g => g.Options ) ) - { - if( option.OptionFiles.TryGetValue( relPath, out var files ) ) - { - ret.UnionWith( files ); - } - } - - if( ret.Count == 0 ) - { - ret.Add( relPath.ToGamePath() ); - } - - return ret; - } - - public static ModSettings ConvertNamedSettings( NamedModSettings namedSettings, ModMeta meta ) - { - ModSettings ret = new() - { - Priority = namedSettings.Priority, - Settings = namedSettings.Settings.Keys.ToDictionary( k => k, _ => 0 ), - }; - - foreach( var kvp in namedSettings.Settings ) - { - if( !meta.Groups.TryGetValue( kvp.Key, out var info ) ) - { - continue; - } - - if( info.SelectionType == SelectType.Single ) - { - if( namedSettings.Settings[ kvp.Key ].Count == 0 ) - { - ret.Settings[ kvp.Key ] = 0; - } - else - { - var idx = info.Options.FindIndex( o => o.OptionName == namedSettings.Settings[ kvp.Key ].Last() ); - ret.Settings[ kvp.Key ] = idx < 0 ? 0 : idx; - } - } - else - { - foreach( var idx in namedSettings.Settings[ kvp.Key ] - .Select( option => info.Options.FindIndex( o => o.OptionName == option ) ) - .Where( idx => idx >= 0 ) ) - { - ret.Settings[ kvp.Key ] |= 1 << idx; - } - } - } - - return ret; - } - } -} \ No newline at end of file diff --git a/Penumbra/Mod/ModMeta.cs b/Penumbra/Mod/ModMeta.cs deleted file mode 100644 index 946d11f8..00000000 --- a/Penumbra/Mod/ModMeta.cs +++ /dev/null @@ -1,137 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using Dalamud.Logging; -using Newtonsoft.Json; -using Penumbra.GameData.Util; -using Penumbra.Structs; - -namespace Penumbra.Mod -{ - // Contains descriptive data about the mod as well as possible settings and fileswaps. - public class ModMeta - { - public uint FileVersion { get; set; } - - public string Name - { - get => _name; - set - { - _name = value; - LowerName = value.ToLowerInvariant(); - } - } - - private string _name = "Mod"; - - [JsonIgnore] - public string LowerName { get; private set; } = "mod"; - - private string _author = ""; - - public string Author - { - get => _author; - set - { - _author = value; - LowerAuthor = value.ToLowerInvariant(); - } - } - - [JsonIgnore] - public string LowerAuthor { get; private set; } = ""; - - public string Description { get; set; } = ""; - public string Version { get; set; } = ""; - public string Website { get; set; } = ""; - - [JsonProperty( ItemConverterType = typeof( GamePathConverter ) )] - public Dictionary< GamePath, GamePath > FileSwaps { get; set; } = new(); - - public Dictionary< string, OptionGroup > Groups { get; set; } = new(); - - [JsonIgnore] - private int FileHash { get; set; } - - [JsonIgnore] - public bool HasGroupsWithConfig { get; private set; } - - public bool RefreshFromFile( FileInfo filePath ) - { - var newMeta = LoadFromFile( filePath ); - if( newMeta == null ) - { - return true; - } - - if( newMeta.FileHash == FileHash ) - { - return false; - } - - FileVersion = newMeta.FileVersion; - Name = newMeta.Name; - Author = newMeta.Author; - Description = newMeta.Description; - Version = newMeta.Version; - Website = newMeta.Website; - FileSwaps = newMeta.FileSwaps; - Groups = newMeta.Groups; - FileHash = newMeta.FileHash; - HasGroupsWithConfig = newMeta.HasGroupsWithConfig; - return true; - } - - public static ModMeta? LoadFromFile( FileInfo filePath ) - { - try - { - var text = File.ReadAllText( filePath.FullName ); - - var meta = JsonConvert.DeserializeObject< ModMeta >( text, - new JsonSerializerSettings { NullValueHandling = NullValueHandling.Ignore } ); - if( meta != null ) - { - meta.FileHash = text.GetHashCode(); - meta.RefreshHasGroupsWithConfig(); - } - - return meta; - } - catch( Exception e ) - { - PluginLog.Error( $"Could not load mod meta:\n{e}" ); - return null; - } - } - - public bool RefreshHasGroupsWithConfig() - { - var oldValue = HasGroupsWithConfig; - HasGroupsWithConfig = Groups.Values.Any( g => g.Options.Count > 1 || g.SelectionType == SelectType.Multi && g.Options.Count == 1 ); - return oldValue != HasGroupsWithConfig; - } - - - public void SaveToFile( FileInfo filePath ) - { - try - { - var text = JsonConvert.SerializeObject( this, Formatting.Indented ); - var newHash = text.GetHashCode(); - if( newHash != FileHash ) - { - File.WriteAllText( filePath.FullName, text ); - FileHash = newHash; - } - } - catch( Exception e ) - { - PluginLog.Error( $"Could not write meta file for mod {Name} to {filePath.FullName}:\n{e}" ); - } - } - } -} \ No newline at end of file diff --git a/Penumbra/Mod/ModResources.cs b/Penumbra/Mod/ModResources.cs deleted file mode 100644 index 859a92a5..00000000 --- a/Penumbra/Mod/ModResources.cs +++ /dev/null @@ -1,88 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using Penumbra.Meta; - -namespace Penumbra.Mod -{ - [Flags] - public enum ResourceChange - { - None = 0, - Files = 1, - Meta = 2, - } - - // Contains static mod data that should only change on filesystem changes. - public class ModResources - { - public List< FileInfo > ModFiles { get; private set; } = new(); - public List< FileInfo > MetaFiles { get; private set; } = new(); - - public MetaCollection MetaManipulations { get; private set; } = new(); - - - private void ForceManipulationsUpdate( ModMeta meta, DirectoryInfo basePath ) - { - MetaManipulations.Update( MetaFiles, basePath, meta ); - MetaManipulations.SaveToFile( MetaCollection.FileName( basePath ) ); - } - - public void SetManipulations( ModMeta meta, DirectoryInfo basePath, bool validate = true ) - { - var newManipulations = MetaCollection.LoadFromFile( MetaCollection.FileName( basePath ) ); - if( newManipulations == null ) - { - ForceManipulationsUpdate( meta, basePath ); - } - else - { - MetaManipulations = newManipulations; - if( validate && !MetaManipulations.Validate( meta ) ) - { - ForceManipulationsUpdate( meta, basePath ); - } - } - } - - // Update the current set of files used by the mod, - // returns true if anything changed. - public ResourceChange RefreshModFiles( DirectoryInfo basePath ) - { - List< FileInfo > tmpFiles = new( ModFiles.Count ); - List< FileInfo > tmpMetas = new( MetaFiles.Count ); - // we don't care about any _files_ in the root dir, but any folders should be a game folder/file combo - foreach( var file in basePath.EnumerateDirectories() - .SelectMany( dir => dir.EnumerateFiles( "*.*", SearchOption.AllDirectories ) ) - .OrderBy( f => f.FullName ) ) - { - switch( file.Extension.ToLowerInvariant() ) - { - case ".meta": - case ".rgsp": - tmpMetas.Add( file ); - break; - default: - tmpFiles.Add( file ); - break; - } - } - - ResourceChange changes = 0; - if( !tmpFiles.Select( f => f.FullName ).SequenceEqual( ModFiles.Select( f => f.FullName ) ) ) - { - ModFiles = tmpFiles; - changes |= ResourceChange.Files; - } - - if( !tmpMetas.Select( f => f.FullName ).SequenceEqual( MetaFiles.Select( f => f.FullName ) ) ) - { - MetaFiles = tmpMetas; - changes |= ResourceChange.Meta; - } - - return changes; - } - } -} \ No newline at end of file diff --git a/Penumbra/Mod/ModSettings.cs b/Penumbra/Mod/ModSettings.cs deleted file mode 100644 index d24b6c0b..00000000 --- a/Penumbra/Mod/ModSettings.cs +++ /dev/null @@ -1,75 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using Penumbra.Structs; - -namespace Penumbra.Mod -{ - // Contains the settings for a given mod. - public class ModSettings - { - public bool Enabled { get; set; } - public int Priority { get; set; } - public Dictionary< string, int > Settings { get; set; } = new(); - - // For backwards compatibility - private Dictionary< string, int > Conf - { - set => Settings = value; - } - - public ModSettings DeepCopy() - { - var settings = new ModSettings - { - Enabled = Enabled, - Priority = Priority, - Settings = Settings.ToDictionary( kvp => kvp.Key, kvp => kvp.Value ), - }; - return settings; - } - - public static ModSettings DefaultSettings( ModMeta meta ) - { - return new() - { - Enabled = false, - Priority = 0, - Settings = meta.Groups.ToDictionary( kvp => kvp.Key, _ => 0 ), - }; - } - - public bool FixSpecificSetting( string name, ModMeta meta ) - { - if( !meta.Groups.TryGetValue( name, out var group ) ) - { - return Settings.Remove( name ); - } - - if( Settings.TryGetValue( name, out var oldSetting ) ) - { - Settings[ name ] = group.SelectionType switch - { - SelectType.Single => Math.Min( Math.Max( oldSetting, 0 ), group.Options.Count - 1 ), - SelectType.Multi => Math.Min( Math.Max( oldSetting, 0 ), ( 1 << group.Options.Count ) - 1 ), - _ => Settings[ group.GroupName ], - }; - return oldSetting != Settings[ group.GroupName ]; - } - - Settings[ name ] = 0; - return true; - } - - public bool FixInvalidSettings( ModMeta meta ) - { - if( meta.Groups.Count == 0 ) - { - return false; - } - - return Settings.Keys.ToArray().Union( meta.Groups.Keys ) - .Aggregate( false, ( current, name ) => current | FixSpecificSetting( name, meta ) ); - } - } -} \ No newline at end of file diff --git a/Penumbra/Mod/NamedModSettings.cs b/Penumbra/Mod/NamedModSettings.cs deleted file mode 100644 index 9e812ef5..00000000 --- a/Penumbra/Mod/NamedModSettings.cs +++ /dev/null @@ -1,48 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using Penumbra.Structs; - -namespace Penumbra.Mod -{ - // Contains settings with the option selections stored by names instead of index. - // This is meant to make them possibly more portable when we support importing collections from other users. - // Enabled does not exist, because disabled mods would not be exported in this way. - public class NamedModSettings - { - public int Priority { get; set; } - public Dictionary< string, HashSet< string > > Settings { get; set; } = new(); - - public void AddFromModSetting( ModSettings s, ModMeta meta ) - { - Priority = s.Priority; - Settings = s.Settings.Keys.ToDictionary( k => k, _ => new HashSet< string >() ); - - foreach( var kvp in Settings ) - { - if( !meta.Groups.TryGetValue( kvp.Key, out var info ) ) - { - continue; - } - - var setting = s.Settings[ kvp.Key ]; - if( info.SelectionType == SelectType.Single ) - { - var name = setting < info.Options.Count - ? info.Options[ setting ].OptionName - : info.Options[ 0 ].OptionName; - kvp.Value.Add( name ); - } - else - { - for( var i = 0; i < info.Options.Count; ++i ) - { - if( ( ( setting >> i ) & 1 ) != 0 ) - { - kvp.Value.Add( info.Options[ i ].OptionName ); - } - } - } - } - } - } -} \ No newline at end of file diff --git a/Penumbra/Mods/CollectionManager.cs b/Penumbra/Mods/CollectionManager.cs deleted file mode 100644 index 9e617746..00000000 --- a/Penumbra/Mods/CollectionManager.cs +++ /dev/null @@ -1,400 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using Dalamud.Logging; -using Penumbra.Interop; -using Penumbra.Mod; -using Penumbra.Util; - -namespace Penumbra.Mods -{ - // Contains all collections and respective functions, as well as the collection settings. - public class CollectionManager - { - private readonly ModManager _manager; - - public string CollectionChangedTo { get; private set; } = string.Empty; - public Dictionary< string, ModCollection > Collections { get; } = new(); - - public ModCollection CurrentCollection { get; private set; } = null!; - public ModCollection DefaultCollection { get; private set; } = null!; - public ModCollection ForcedCollection { get; private set; } = ModCollection.Empty; - public Dictionary< string, ModCollection > CharacterCollection { get; } = new(); - - public ModCollection ActiveCollection { get; private set; } - - public CollectionManager( ModManager manager ) - { - _manager = manager; - - ReadCollections(); - LoadConfigCollections( Penumbra.Config ); - ActiveCollection = DefaultCollection; - } - - public bool SetActiveCollection( ModCollection newActive, string name ) - { - CollectionChangedTo = name; - if( newActive == ActiveCollection ) - { - return false; - } - - if( ActiveCollection.Cache?.MetaManipulations.Count > 0 || newActive.Cache?.MetaManipulations.Count > 0 ) - { - var resourceManager = Service< ResidentResources >.Get(); - resourceManager.ReloadPlayerResources(); - } - - ActiveCollection = newActive; - return true; - } - - public bool ResetActiveCollection() - => SetActiveCollection( DefaultCollection, string.Empty ); - - public void RecreateCaches() - { - if( !_manager.TempWritable ) - { - PluginLog.Error( "No temporary directory available." ); - return; - } - - foreach( var collection in Collections.Values.Where( c => c.Cache != null ) ) - { - collection.CreateCache( _manager.TempPath, _manager.StructuredMods.AllMods( _manager.Config.SortFoldersFirst ) ); - } - } - - public void RemoveModFromCaches( DirectoryInfo modDir ) - { - foreach( var collection in Collections.Values ) - { - collection.Cache?.RemoveMod( modDir ); - } - } - - internal void UpdateCollections( ModData mod, bool metaChanges, ResourceChange fileChanges, bool nameChange, bool reloadMeta ) - { - foreach( var collection in Collections.Values ) - { - if( metaChanges ) - { - collection.UpdateSetting( mod ); - } - - if( fileChanges.HasFlag( ResourceChange.Files ) - && collection.Settings.TryGetValue( mod.BasePath.Name, out var settings ) - && settings.Enabled ) - { - collection.Cache?.CalculateEffectiveFileList(); - } - - if( reloadMeta ) - { - collection.Cache?.UpdateMetaManipulations(); - } - } - - if( reloadMeta && ActiveCollection.Settings.TryGetValue( mod.BasePath.Name, out var config ) && config.Enabled ) - { - Service< ResidentResources >.Get().ReloadPlayerResources(); - } - } - - public bool AddCollection( string name, Dictionary< string, ModSettings > settings ) - { - var nameFixed = name.RemoveInvalidPathSymbols().ToLowerInvariant(); - if( nameFixed == string.Empty || Collections.Values.Any( c => c.Name.RemoveInvalidPathSymbols().ToLowerInvariant() == nameFixed ) ) - { - PluginLog.Warning( $"The new collection {name} would lead to the same path as one that already exists." ); - return false; - } - - var newCollection = new ModCollection( name, settings ); - Collections.Add( name, newCollection ); - newCollection.Save(); - SetCurrentCollection( newCollection ); - return true; - } - - public bool RemoveCollection( string name ) - { - if( name == ModCollection.DefaultCollection ) - { - PluginLog.Error( "Can not remove the default collection." ); - return false; - } - - if( Collections.TryGetValue( name, out var collection ) ) - { - if( CurrentCollection == collection ) - { - SetCurrentCollection( Collections[ ModCollection.DefaultCollection ] ); - } - - if( ForcedCollection == collection ) - { - SetForcedCollection( ModCollection.Empty ); - } - - if( DefaultCollection == collection ) - { - SetDefaultCollection( ModCollection.Empty ); - } - - foreach( var kvp in CharacterCollection.ToArray() ) - { - if( kvp.Value == collection ) - { - SetCharacterCollection( kvp.Key, ModCollection.Empty ); - } - } - - collection.Delete(); - Collections.Remove( name ); - return true; - } - - return false; - } - - private void AddCache( ModCollection collection ) - { - if( !_manager.TempWritable ) - { - PluginLog.Error( "No tmp directory available." ); - return; - } - - if( collection.Cache == null && collection.Name != string.Empty ) - { - collection.CreateCache( _manager.TempPath, _manager.StructuredMods.AllMods( _manager.Config.SortFoldersFirst ) ); - } - } - - private void RemoveCache( ModCollection collection ) - { - if( collection.Name != ForcedCollection.Name - && collection.Name != CurrentCollection.Name - && collection.Name != DefaultCollection.Name - && CharacterCollection.All( kvp => kvp.Value.Name != collection.Name ) ) - { - collection.ClearCache(); - } - } - - private void SetCollection( ModCollection newCollection, ModCollection oldCollection, Action< ModCollection > setter, - Action< string > configSetter ) - { - if( newCollection.Name == oldCollection.Name ) - { - return; - } - - AddCache( newCollection ); - - setter( newCollection ); - RemoveCache( oldCollection ); - configSetter( newCollection.Name ); - Penumbra.Config.Save(); - } - - public void SetDefaultCollection( ModCollection newCollection ) - => SetCollection( newCollection, DefaultCollection, c => - { - if( !CollectionChangedTo.Any() ) - { - ActiveCollection = c; - var resourceManager = Service< ResidentResources >.Get(); - resourceManager.ReloadPlayerResources(); - } - - DefaultCollection = c; - }, s => Penumbra.Config.DefaultCollection = s ); - - public void SetForcedCollection( ModCollection newCollection ) - => SetCollection( newCollection, ForcedCollection, c => ForcedCollection = c, s => Penumbra.Config.ForcedCollection = s ); - - public void SetCurrentCollection( ModCollection newCollection ) - => SetCollection( newCollection, CurrentCollection, c => CurrentCollection = c, s => Penumbra.Config.CurrentCollection = s ); - - public void SetCharacterCollection( string characterName, ModCollection newCollection ) - => SetCollection( newCollection, - CharacterCollection.TryGetValue( characterName, out var oldCollection ) ? oldCollection : ModCollection.Empty, - c => - { - if( CollectionChangedTo == characterName && CharacterCollection.TryGetValue( characterName, out var collection ) ) - { - ActiveCollection = c; - var resourceManager = Service< ResidentResources >.Get(); - resourceManager.ReloadPlayerResources(); - } - - CharacterCollection[ characterName ] = c; - }, s => Penumbra.Config.CharacterCollections[ characterName ] = s ); - - public bool CreateCharacterCollection( string characterName ) - { - if( !CharacterCollection.ContainsKey( characterName ) ) - { - CharacterCollection[ characterName ] = ModCollection.Empty; - Penumbra.Config.CharacterCollections[ characterName ] = string.Empty; - Penumbra.Config.Save(); - Penumbra.PlayerWatcher.AddPlayerToWatch( characterName ); - return true; - } - - return false; - } - - public void RemoveCharacterCollection( string characterName ) - { - if( CharacterCollection.TryGetValue( characterName, out var collection ) ) - { - RemoveCache( collection ); - CharacterCollection.Remove( characterName ); - Penumbra.PlayerWatcher.RemovePlayerFromWatch( characterName ); - } - - if( Penumbra.Config.CharacterCollections.Remove( characterName ) ) - { - Penumbra.Config.Save(); - } - } - - private bool LoadCurrentCollection( Configuration config ) - { - if( Collections.TryGetValue( config.CurrentCollection, out var currentCollection ) ) - { - CurrentCollection = currentCollection; - AddCache( CurrentCollection ); - return false; - } - - PluginLog.Error( $"Last choice of CurrentCollection {config.CurrentCollection} is not available, reset to Default." ); - CurrentCollection = Collections[ ModCollection.DefaultCollection ]; - config.CurrentCollection = ModCollection.DefaultCollection; - return true; - } - - private bool LoadForcedCollection( Configuration config ) - { - if( config.ForcedCollection == string.Empty ) - { - ForcedCollection = ModCollection.Empty; - return false; - } - - if( Collections.TryGetValue( config.ForcedCollection, out var forcedCollection ) ) - { - ForcedCollection = forcedCollection; - AddCache( ForcedCollection ); - return false; - } - - PluginLog.Error( $"Last choice of ForcedCollection {config.ForcedCollection} is not available, reset to None." ); - ForcedCollection = ModCollection.Empty; - config.ForcedCollection = string.Empty; - return true; - } - - private bool LoadDefaultCollection( Configuration config ) - { - if( config.DefaultCollection == string.Empty ) - { - DefaultCollection = ModCollection.Empty; - return false; - } - - if( Collections.TryGetValue( config.DefaultCollection, out var defaultCollection ) ) - { - DefaultCollection = defaultCollection; - AddCache( DefaultCollection ); - return false; - } - - PluginLog.Error( $"Last choice of DefaultCollection {config.DefaultCollection} is not available, reset to None." ); - DefaultCollection = ModCollection.Empty; - config.DefaultCollection = string.Empty; - return true; - } - - private bool LoadCharacterCollections( Configuration config ) - { - var configChanged = false; - foreach( var kvp in config.CharacterCollections.ToArray() ) - { - Penumbra.PlayerWatcher.AddPlayerToWatch( kvp.Key ); - if( kvp.Value == string.Empty ) - { - CharacterCollection.Add( kvp.Key, ModCollection.Empty ); - } - else if( Collections.TryGetValue( kvp.Value, out var charCollection ) ) - { - AddCache( charCollection ); - CharacterCollection.Add( kvp.Key, charCollection ); - } - else - { - PluginLog.Error( $"Last choice of <{kvp.Key}>'s Collection {kvp.Value} is not available, reset to None." ); - CharacterCollection.Add( kvp.Key, ModCollection.Empty ); - config.CharacterCollections[ kvp.Key ] = string.Empty; - configChanged = true; - } - } - - return configChanged; - } - - private void LoadConfigCollections( Configuration config ) - { - var configChanged = LoadCurrentCollection( config ); - configChanged |= LoadDefaultCollection( config ); - configChanged |= LoadForcedCollection( config ); - configChanged |= LoadCharacterCollections( config ); - - if( configChanged ) - { - config.Save(); - } - } - - private void ReadCollections() - { - var collectionDir = ModCollection.CollectionDir(); - if( collectionDir.Exists ) - { - foreach( var file in collectionDir.EnumerateFiles( "*.json" ) ) - { - var collection = ModCollection.LoadFromFile( file ); - if( collection != null ) - { - if( file.Name != $"{collection.Name.RemoveInvalidPathSymbols()}.json" ) - { - PluginLog.Warning( $"Collection {file.Name} does not correspond to {collection.Name}." ); - } - - if( Collections.ContainsKey( collection.Name ) ) - { - PluginLog.Warning( $"Duplicate collection found: {collection.Name} already exists." ); - } - else - { - Collections.Add( collection.Name, collection ); - } - } - } - } - - if( !Collections.ContainsKey( ModCollection.DefaultCollection ) ) - { - var defaultCollection = new ModCollection(); - defaultCollection.Save(); - Collections.Add( defaultCollection.Name, defaultCollection ); - } - } - } -} \ No newline at end of file diff --git a/Penumbra/Mods/Editor/DuplicateManager.cs b/Penumbra/Mods/Editor/DuplicateManager.cs new file mode 100644 index 00000000..56a19766 --- /dev/null +++ b/Penumbra/Mods/Editor/DuplicateManager.cs @@ -0,0 +1,251 @@ +using OtterGui.Classes; +using OtterGui.Services; +using Penumbra.Mods.Groups; +using Penumbra.Mods.Manager; +using Penumbra.Mods.SubMods; +using Penumbra.Services; +using Penumbra.String.Classes; + +namespace Penumbra.Mods.Editor; + +public class DuplicateManager(ModManager modManager, SaveService saveService, Configuration config) : IService +{ + private readonly SHA256 _hasher = SHA256.Create(); + private readonly List<(FullPath[] Paths, long Size, byte[] Hash)> _duplicates = []; + + public IReadOnlyList<(FullPath[] Paths, long Size, byte[] Hash)> Duplicates + => _duplicates; + + public long SavedSpace { get; private set; } + public Task Worker { get; private set; } = Task.CompletedTask; + + private CancellationTokenSource _cancellationTokenSource = new(); + + public void StartDuplicateCheck(IEnumerable files) + { + if (!Worker.IsCompleted) + return; + + var filesTmp = files.OrderByDescending(f => f.FileSize).ToArray(); + _cancellationTokenSource = new CancellationTokenSource(); + Worker = Task.Run(() => CheckDuplicates(filesTmp, _cancellationTokenSource.Token), _cancellationTokenSource.Token); + } + + public void DeleteDuplicates(ModFileCollection files, Mod mod, IModDataContainer option, bool useModManager) + { + if (!Worker.IsCompleted || _duplicates.Count == 0) + return; + + foreach (var (set, _, _) in _duplicates) + { + if (set.Length < 2) + continue; + + var remaining = set[0]; + foreach (var duplicate in set.Skip(1)) + HandleDuplicate(mod, duplicate, remaining, useModManager); + } + + _duplicates.Clear(); + DeleteEmptyDirectories(mod.ModPath); + files.UpdateAll(mod, option); + } + + public void Clear() + { + _cancellationTokenSource.Cancel(); + Worker = Task.CompletedTask; + _duplicates.Clear(); + SavedSpace = 0; + } + + private void HandleDuplicate(Mod mod, FullPath duplicate, FullPath remaining, bool useModManager) + { + ModEditor.ApplyToAllContainers(mod, HandleSubMod); + + try + { + File.Delete(duplicate.FullName); + } + catch (Exception e) + { + Penumbra.Log.Error($"[DeleteDuplicates] Could not delete duplicate {duplicate.FullName} of {remaining.FullName}:\n{e}"); + } + + return; + + void HandleSubMod(IModDataContainer subMod) + { + var changes = false; + var dict = subMod.Files.ToDictionary(kvp => kvp.Key, + kvp => ChangeDuplicatePath(mod, kvp.Value, duplicate, remaining, kvp.Key, ref changes)); + if (!changes) + return; + + if (useModManager) + { + modManager.OptionEditor.SetFiles(subMod, dict, SaveType.ImmediateSync); + } + else + { + subMod.Files = dict; + saveService.ImmediateSaveSync(new ModSaveGroup(mod.ModPath, subMod, config.ReplaceNonAsciiOnImport)); + } + } + } + + private static FullPath ChangeDuplicatePath(Mod mod, FullPath value, FullPath from, FullPath to, Utf8GamePath key, ref bool changes) + { + if (!value.Equals(from)) + return value; + + changes = true; + Penumbra.Log.Debug($"[DeleteDuplicates] Changing {key} for {mod.Name}\n : {from}\n -> {to}"); + return to; + } + + private void CheckDuplicates(IReadOnlyList files, CancellationToken token) + { + _duplicates.Clear(); + SavedSpace = 0; + var list = new List(); + var lastSize = -1L; + foreach (var file in files) + { + // Skip any UI Files because deduplication causes weird crashes for those. + if (file.SubModUsage.Any(f => f.Item2.Path.StartsWith("ui/"u8))) + continue; + + token.ThrowIfCancellationRequested(); + + if (file.FileSize == lastSize) + { + list.Add(file.File); + continue; + } + + if (list.Count >= 2) + CheckMultiDuplicates(list, lastSize, token); + + lastSize = file.FileSize; + + list.Clear(); + list.Add(file.File); + } + + if (list.Count >= 2) + CheckMultiDuplicates(list, lastSize, token); + + _duplicates.Sort((a, b) => a.Size != b.Size ? b.Size.CompareTo(a.Size) : a.Paths[0].CompareTo(b.Paths[0])); + } + + private void CheckMultiDuplicates(IReadOnlyList list, long size, CancellationToken token) + { + var hashes = list.Select(f => (f, ComputeHash(f))).ToList(); + while (hashes.Count > 0) + { + token.ThrowIfCancellationRequested(); + + var set = new HashSet { hashes[0].Item1 }; + var hash = hashes[0]; + for (var j = 1; j < hashes.Count; ++j) + { + token.ThrowIfCancellationRequested(); + + if (CompareHashes(hash.Item2, hashes[j].Item2) && CompareFilesDirectly(hashes[0].Item1, hashes[j].Item1)) + set.Add(hashes[j].Item1); + } + + hashes.RemoveAll(p => set.Contains(p.Item1)); + if (set.Count > 1) + { + _duplicates.Add((set.OrderBy(f => f.FullName.Length).ToArray(), size, hash.Item2)); + SavedSpace += (set.Count - 1) * size; + } + } + } + + /// Check if two files are identical on a binary level. Returns true if they are identical. + [SkipLocalsInit] + public static unsafe bool CompareFilesDirectly(FullPath f1, FullPath f2) + { + const int size = 256; + if (!f1.Exists || !f2.Exists) + return false; + + using var s1 = File.OpenRead(f1.FullName); + using var s2 = File.OpenRead(f2.FullName); + Span span1 = stackalloc byte[size]; + Span span2 = stackalloc byte[size]; + + while (true) + { + var bytes1 = s1.Read(span1); + var bytes2 = s2.Read(span2); + if (bytes1 != bytes2) + return false; + + if (!span1[..bytes1].SequenceEqual(span2[..bytes2])) + return false; + + if (bytes1 < size) + return true; + } + } + + /// + /// Recursively delete all empty directories starting from the given directory. + /// Deletes inner directories first, so that a tree of empty directories is actually deleted. + /// + private static void DeleteEmptyDirectories(DirectoryInfo baseDir) + { + try + { + if (!baseDir.Exists) + return; + + foreach (var dir in baseDir.EnumerateDirectories("*", SearchOption.TopDirectoryOnly)) + DeleteEmptyDirectories(dir); + + baseDir.Refresh(); + if (!baseDir.EnumerateFileSystemInfos().Any()) + Directory.Delete(baseDir.FullName, false); + } + catch (Exception e) + { + Penumbra.Log.Error($"Could not delete empty directories in {baseDir.FullName}:\n{e}"); + } + } + + /// Deduplicate a mod simply by its directory without any confirmation or waiting time. + internal void DeduplicateMod(DirectoryInfo modDirectory, bool useModManager = false) + { + try + { + if (!useModManager || !modManager.TryGetMod(modDirectory.Name, string.Empty, out var mod)) + { + mod = new Mod(modDirectory); + modManager.Creator.ReloadMod(mod, true, true, out _); + } + + Clear(); + var files = new ModFileCollection(); + files.UpdateAll(mod, mod.Default); + CheckDuplicates([.. files.Available.OrderByDescending(f => f.FileSize)], CancellationToken.None); + DeleteDuplicates(files, mod, mod.Default, useModManager); + } + catch (Exception e) + { + Penumbra.Log.Warning($"Could not deduplicate mod {modDirectory.Name}:\n{e}"); + } + } + + private static bool CompareHashes(byte[] f1, byte[] f2) + => StructuralComparisons.StructuralEqualityComparer.Equals(f1, f2); + + private byte[] ComputeHash(FullPath f) + { + using var stream = File.OpenRead(f.FullName); + return _hasher.ComputeHash(stream); + } +} diff --git a/Penumbra/Mods/Editor/FileRegistry.cs b/Penumbra/Mods/Editor/FileRegistry.cs new file mode 100644 index 00000000..a484c8c2 --- /dev/null +++ b/Penumbra/Mods/Editor/FileRegistry.cs @@ -0,0 +1,56 @@ +using Penumbra.Mods.SubMods; +using Penumbra.String.Classes; + +namespace Penumbra.Mods.Editor; + +public class FileRegistry : IEquatable +{ + public readonly List<(IModDataContainer, Utf8GamePath)> SubModUsage = []; + public FullPath File { get; private init; } + public Utf8RelPath RelPath { get; private init; } + public long FileSize { get; private init; } + public int CurrentUsage; + public bool IsOnPlayer; + + public static bool FromFile(DirectoryInfo modPath, FileInfo file, [NotNullWhen(true)] out FileRegistry? registry) + { + var fullPath = new FullPath(file.FullName); + if (!fullPath.ToRelPath(modPath, out var relPath)) + { + registry = null; + return false; + } + + registry = new FileRegistry + { + File = fullPath, + RelPath = relPath, + FileSize = file.Length, + CurrentUsage = 0, + IsOnPlayer = false, + }; + return true; + } + + public bool Equals(FileRegistry? other) + { + if (other is null) + return false; + + return ReferenceEquals(this, other) || File.Equals(other.File); + } + + public override bool Equals(object? obj) + { + if (obj is null) + return false; + + if (ReferenceEquals(this, obj)) + return true; + + return obj.GetType() == GetType() && Equals((FileRegistry)obj); + } + + public override int GetHashCode() + => File.GetHashCode(); +} diff --git a/Penumbra/Mods/Editor/IMod.cs b/Penumbra/Mods/Editor/IMod.cs new file mode 100644 index 00000000..3da38829 --- /dev/null +++ b/Penumbra/Mods/Editor/IMod.cs @@ -0,0 +1,29 @@ +using OtterGui.Classes; +using Penumbra.Meta.Manipulations; +using Penumbra.Mods.Groups; +using Penumbra.Mods.Settings; +using Penumbra.String.Classes; + +namespace Penumbra.Mods.Editor; + +public record struct AppliedModData( + Dictionary FileRedirections, + MetaDictionary Manipulations) +{ + public static readonly AppliedModData Empty = new([], new MetaDictionary()); +} + +public interface IMod +{ + LowerString Name { get; } + + public int Index { get; } + public ModPriority Priority { get; } + + public IReadOnlyList Groups { get; } + + public AppliedModData GetData(ModSettings? settings = null); + + // Cache + public int TotalManipulations { get; } +} diff --git a/Penumbra/Mods/Editor/MdlMaterialEditor.cs b/Penumbra/Mods/Editor/MdlMaterialEditor.cs new file mode 100644 index 00000000..da580794 --- /dev/null +++ b/Penumbra/Mods/Editor/MdlMaterialEditor.cs @@ -0,0 +1,88 @@ +using OtterGui.Compression; +using OtterGui.Extensions; +using OtterGui.Services; +using Penumbra.GameData.Enums; +using Penumbra.GameData.Files; + +namespace Penumbra.Mods.Editor; + +public partial class MdlMaterialEditor(ModFileCollection files) : IService +{ + [GeneratedRegex(@"/mt_c(?'RaceCode'\d{4})b0001_(?'Suffix'.*?)\.mtrl", RegexOptions.ExplicitCapture | RegexOptions.NonBacktracking)] + private static partial Regex MaterialRegex(); + + private readonly List _modelFiles = []; + + public IReadOnlyList ModelFiles + => _modelFiles; + + public void SaveAllModels(FileCompactor compactor) + { + foreach (var info in _modelFiles) + info.Save(compactor); + } + + public void RestoreAllModels() + { + foreach (var info in _modelFiles) + info.Restore(); + } + + public void Clear() + { + _modelFiles.Clear(); + } + + /// + /// Go through the currently loaded files and replace all appropriate suffices. + /// Does nothing if toSuffix is invalid. + /// If raceCode is Unknown, apply to all raceCodes. + /// If fromSuffix is empty, apply to all suffices. + /// + public void ReplaceAllMaterials(string toSuffix, string fromSuffix = "", GenderRace raceCode = GenderRace.Unknown) + { + if (!ValidString(toSuffix)) + return; + + foreach (var info in _modelFiles) + { + for (var i = 0; i < info.Count; ++i) + { + var (_, def) = info[i]; + var match = MaterialRegex().Match(def); + if (match.Success + && (raceCode == GenderRace.Unknown || raceCode.ToRaceCode() == match.Groups["RaceCode"].Value) + && (fromSuffix.Length == 0 || fromSuffix == match.Groups["Suffix"].Value)) + info.SetMaterial($"/mt_c{match.Groups["RaceCode"].Value}b0001_{toSuffix}.mtrl", i); + } + } + } + + /// Non-ASCII encoding is not supported. + public static bool ValidString(string to) + => to.Length != 0 + && to.Length < 16 + && Encoding.UTF8.GetByteCount(to) == to.Length; + + /// Find all model files in the mod that contain skin materials. + public void ScanModels(Mod mod) + { + _modelFiles.Clear(); + foreach (var file in files.Mdl) + { + try + { + var bytes = File.ReadAllBytes(file.File.FullName); + var mdlFile = new MdlFile(bytes); + var materials = mdlFile.Materials.WithIndex().Where(p => MaterialRegex().IsMatch((string)p.Item1)) + .Select(p => p.Item2).ToArray(); + if (materials.Length > 0) + _modelFiles.Add(new ModelMaterialInfo(file.File, mdlFile, materials)); + } + catch (Exception e) + { + Penumbra.Log.Error($"Unexpected error scanning {mod.Name}'s {file.File.FullName} for materials:\n{e}"); + } + } + } +} diff --git a/Penumbra/Mods/Editor/ModBackup.cs b/Penumbra/Mods/Editor/ModBackup.cs new file mode 100644 index 00000000..994ca0b5 --- /dev/null +++ b/Penumbra/Mods/Editor/ModBackup.cs @@ -0,0 +1,139 @@ +using OtterGui.Tasks; +using Penumbra.Mods.Manager; + +namespace Penumbra.Mods.Editor; + +/// Utility to create and apply a zipped backup of a mod. +public class ModBackup +{ + /// Set when reading Config and migrating from v4 to v5. + public static bool MigrateModBackups = false; + + public static bool CreatingBackup { get; private set; } + + private readonly Mod _mod; + public readonly string Name; + public readonly bool Exists; + + public ModBackup(ModExportManager modExportManager, Mod mod) + { + _mod = mod; + Name = Path.Combine(modExportManager.ExportDirectory.FullName, _mod.ModPath.Name) + ".pmp"; + Exists = File.Exists(Name); + } + + /// Migrate file extensions. + public static void MigrateZipToPmp(IEnumerable modStorage) + { + foreach (var mod in modStorage) + { + var pmpName = mod.ModPath + ".pmp"; + var zipName = mod.ModPath + ".zip"; + if (!File.Exists(zipName)) + continue; + + try + { + if (!File.Exists(pmpName)) + File.Move(zipName, pmpName); + else + File.Delete(zipName); + + Penumbra.Log.Information($"Migrated mod export from {zipName} to {pmpName}."); + } + catch (Exception e) + { + Penumbra.Log.Warning($"Could not migrate mod export of {mod.ModPath} from .pmp to .zip:\n{e}"); + } + } + } + + /// + /// Move and/or rename an exported mod. + /// This object is unusable afterwards. + /// + public void Move(string? newBasePath = null, string? newName = null) + { + if (CreatingBackup || !Exists) + return; + + try + { + newBasePath ??= Path.GetDirectoryName(Name) ?? string.Empty; + newName = newName == null ? Path.GetFileName(Name) : newName + ".pmp"; + var newPath = Path.Combine(newBasePath, newName); + File.Move(Name, newPath); + } + catch (Exception e) + { + Penumbra.Log.Warning($"Could not move mod export file {Name}:\n{e}"); + } + } + + /// Create a backup zip without blocking the main thread. + public async void CreateAsync() + { + if (CreatingBackup) + return; + + CreatingBackup = true; + await AsyncTask.Run(Create); + CreatingBackup = false; + } + + /// Create a backup. Overwrites pre-existing backups. + private void Create() + { + try + { + Delete(); + ZipFile.CreateFromDirectory(_mod.ModPath.FullName, Name, CompressionLevel.Optimal, false); + Penumbra.Log.Debug($"Created export file {Name} from {_mod.ModPath.FullName}."); + } + catch (Exception e) + { + Penumbra.Log.Error($"Could not export mod {_mod.Name} to \"{Name}\":\n{e}"); + } + } + + /// Delete a pre-existing backup. + public void Delete() + { + if (!Exists) + return; + + try + { + File.Delete(Name); + Penumbra.Log.Debug($"Deleted export file {Name}."); + } + catch (Exception e) + { + Penumbra.Log.Error($"Could not delete file \"{Name}\":\n{e}"); + } + } + + /// + /// Restore a mod from a pre-existing backup. Does not check if the mod contained in the backup is even similar. + /// Does an automatic reload after extraction. + /// + public void Restore(ModManager modManager) + { + try + { + if (Directory.Exists(_mod.ModPath.FullName)) + { + Directory.Delete(_mod.ModPath.FullName, true); + Penumbra.Log.Debug($"Deleted mod folder {_mod.ModPath.FullName}."); + } + + ZipFile.ExtractToDirectory(Name, _mod.ModPath.FullName); + Penumbra.Log.Debug($"Extracted exported file {Name} to {_mod.ModPath.FullName}."); + modManager.ReloadMod(_mod); + } + catch (Exception e) + { + Penumbra.Log.Error($"Could not restore {_mod.Name} from export \"{Name}\":\n{e}"); + } + } +} diff --git a/Penumbra/Mods/Editor/ModEditor.cs b/Penumbra/Mods/Editor/ModEditor.cs new file mode 100644 index 00000000..19ca7022 --- /dev/null +++ b/Penumbra/Mods/Editor/ModEditor.cs @@ -0,0 +1,154 @@ +using OtterGui.Compression; +using OtterGui.Services; +using Penumbra.Mods.Groups; +using Penumbra.Mods.SubMods; + +namespace Penumbra.Mods.Editor; + +public class ModEditor( + ModNormalizer modNormalizer, + ModMetaEditor metaEditor, + ModFileCollection files, + ModFileEditor fileEditor, + DuplicateManager duplicates, + ModSwapEditor swapEditor, + MdlMaterialEditor mdlMaterialEditor, + FileCompactor compactor) + : IDisposable, IService +{ + public readonly ModNormalizer ModNormalizer = modNormalizer; + public readonly ModMetaEditor MetaEditor = metaEditor; + public readonly ModFileEditor FileEditor = fileEditor; + public readonly DuplicateManager Duplicates = duplicates; + public readonly ModFileCollection Files = files; + public readonly ModSwapEditor SwapEditor = swapEditor; + public readonly MdlMaterialEditor MdlMaterialEditor = mdlMaterialEditor; + public readonly FileCompactor Compactor = compactor; + + + public bool IsLoading + { + get + { + lock (_lock) + { + return _loadingMod is { IsCompleted: false }; + } + } + } + + private readonly object _lock = new(); + private Task? _loadingMod; + + public Mod? Mod { get; private set; } + public int GroupIdx { get; private set; } + public int DataIdx { get; private set; } + + public IModGroup? Group { get; private set; } + public IModDataContainer? Option { get; private set; } + + public async Task LoadMod(Mod mod, int groupIdx, int dataIdx) + { + await AppendTask(() => + { + Mod = mod; + LoadOption(groupIdx, dataIdx, true); + Files.UpdateAll(mod, Option!); + SwapEditor.Revert(Option!); + MetaEditor.Load(Mod!, Option!); + Duplicates.Clear(); + MdlMaterialEditor.ScanModels(Mod!); + }); + } + + private Task AppendTask(Action run) + { + lock (_lock) + { + if (_loadingMod == null || _loadingMod.IsCompleted) + return _loadingMod = Task.Run(run); + + return _loadingMod = _loadingMod.ContinueWith(_ => run()); + } + } + + public async Task LoadOption(int groupIdx, int dataIdx) + { + await AppendTask(() => + { + LoadOption(groupIdx, dataIdx, true); + SwapEditor.Revert(Option!); + Files.UpdatePaths(Mod!, Option!); + MetaEditor.Load(Mod!, Option!); + FileEditor.Clear(); + Duplicates.Clear(); + }); + } + + /// Load the correct option by indices for the currently loaded mod if possible, unload if not. + private void LoadOption(int groupIdx, int dataIdx, bool message) + { + if (Mod != null && Mod.Groups.Count > groupIdx) + { + if (groupIdx == -1 && dataIdx == 0) + { + Group = null; + Option = Mod.Default; + GroupIdx = groupIdx; + DataIdx = dataIdx; + return; + } + + if (groupIdx >= 0) + { + Group = Mod.Groups[groupIdx]; + if (dataIdx >= 0 && dataIdx < Group.DataContainers.Count) + { + Option = Group.DataContainers[dataIdx]; + GroupIdx = groupIdx; + DataIdx = dataIdx; + return; + } + } + } + + Group = null; + Option = Mod?.Default; + GroupIdx = -1; + DataIdx = 0; + if (message) + Penumbra.Log.Error($"Loading invalid option {groupIdx} {dataIdx} for Mod {Mod?.Name ?? "Unknown"}."); + } + + public void Clear() + { + Duplicates.Clear(); + FileEditor.Clear(); + Files.Clear(); + MetaEditor.Clear(); + Mod = null; + LoadOption(0, 0, false); + } + + public void Dispose() + => Clear(); + + /// Apply a option action to all available option in a mod, including the default option. + public static void ApplyToAllContainers(Mod mod, Action action) + { + action(mod.Default); + foreach (var container in mod.Groups.SelectMany(g => g.DataContainers)) + action(container); + } + + // Does not delete the base directory itself even if it is completely empty at the end. + public static void ClearEmptySubDirectories(DirectoryInfo baseDir) + { + foreach (var subDir in baseDir.GetDirectories()) + { + ClearEmptySubDirectories(subDir); + if (subDir.GetFiles().Length == 0 && subDir.GetDirectories().Length == 0) + subDir.Delete(); + } + } +} diff --git a/Penumbra/Mods/Editor/ModFileCollection.cs b/Penumbra/Mods/Editor/ModFileCollection.cs new file mode 100644 index 00000000..15bd179e --- /dev/null +++ b/Penumbra/Mods/Editor/ModFileCollection.cs @@ -0,0 +1,210 @@ +using OtterGui; +using OtterGui.Extensions; +using OtterGui.Services; +using Penumbra.Mods.SubMods; +using Penumbra.String.Classes; + +namespace Penumbra.Mods.Editor; + +public class ModFileCollection : IDisposable, IService +{ + private readonly List _available = []; + private readonly List _mtrl = []; + private readonly List _mdl = []; + private readonly List _tex = []; + private readonly List _shpk = []; + private readonly List _pbd = []; + private readonly List _atch = []; + + private readonly SortedSet _missing = []; + private readonly HashSet _usedPaths = []; + + public IReadOnlySet Missing + => Ready ? _missing : []; + + public IReadOnlySet UsedPaths + => Ready ? _usedPaths : []; + + public IReadOnlyList Available + => Ready ? _available : []; + + public IReadOnlyList Mtrl + => Ready ? _mtrl : []; + + public IReadOnlyList Mdl + => Ready ? _mdl : []; + + public IReadOnlyList Tex + => Ready ? _tex : []; + + public IReadOnlyList Shpk + => Ready ? _shpk : []; + + public IReadOnlyList Pbd + => Ready ? _pbd : []; + + public IReadOnlyList Atch + => Ready ? _atch : []; + + public bool Ready { get; private set; } = true; + + public void UpdateAll(Mod mod, IModDataContainer option) + { + UpdateFiles(mod, CancellationToken.None); + UpdatePaths(mod, option, false, CancellationToken.None); + } + + public void UpdatePaths(Mod mod, IModDataContainer option) + => UpdatePaths(mod, option, true, CancellationToken.None); + + public void Clear() + { + ClearFiles(); + ClearPaths(false, CancellationToken.None); + } + + public void Dispose() + => Clear(); + + public void ClearMissingFiles() + => _missing.Clear(); + + public void RemoveUsedPath(IModDataContainer option, FileRegistry? file, Utf8GamePath gamePath) + { + _usedPaths.Remove(gamePath); + if (file != null) + { + --file.CurrentUsage; + file.SubModUsage.RemoveAll(p => p.Item1 == option && p.Item2.Equals(gamePath)); + } + } + + public void RemoveUsedPath(IModDataContainer option, FullPath file, Utf8GamePath gamePath) + => RemoveUsedPath(option, _available.FirstOrDefault(f => f.File.Equals(file)), gamePath); + + public void AddUsedPath(IModDataContainer option, FileRegistry? file, Utf8GamePath gamePath) + { + _usedPaths.Add(gamePath); + if (file == null) + return; + + ++file.CurrentUsage; + file.SubModUsage.Add((option, gamePath)); + } + + public void AddUsedPath(IModDataContainer option, FullPath file, Utf8GamePath gamePath) + => AddUsedPath(option, _available.FirstOrDefault(f => f.File.Equals(file)), gamePath); + + public void ChangeUsedPath(FileRegistry file, int pathIdx, Utf8GamePath gamePath) + { + var oldPath = file.SubModUsage[pathIdx]; + _usedPaths.Remove(oldPath.Item2); + if (!gamePath.IsEmpty) + { + file.SubModUsage[pathIdx] = (oldPath.Item1, gamePath); + _usedPaths.Add(gamePath); + } + else + { + --file.CurrentUsage; + file.SubModUsage.RemoveAt(pathIdx); + } + } + + private void UpdateFiles(Mod mod, CancellationToken tok) + { + tok.ThrowIfCancellationRequested(); + ClearFiles(); + + foreach (var file in mod.ModPath.EnumerateDirectories().Where(d => !d.IsHidden()).SelectMany(FileExtensions.EnumerateNonHiddenFiles)) + { + tok.ThrowIfCancellationRequested(); + if (!FileRegistry.FromFile(mod.ModPath, file, out var registry)) + continue; + + _available.Add(registry); + switch (Path.GetExtension(registry.File.FullName).ToLowerInvariant()) + { + case ".mtrl": + _mtrl.Add(registry); + break; + case ".mdl": + _mdl.Add(registry); + break; + case ".tex": + _tex.Add(registry); + break; + case ".shpk": + _shpk.Add(registry); + break; + case ".pbd": + _pbd.Add(registry); + break; + case ".atch": + _atch.Add(registry); + break; + } + } + } + + private void ClearFiles() + { + _available.Clear(); + _mtrl.Clear(); + _mdl.Clear(); + _tex.Clear(); + _shpk.Clear(); + _pbd.Clear(); + _atch.Clear(); + } + + private void ClearPaths(bool clearRegistries, CancellationToken tok) + { + if (clearRegistries) + foreach (var reg in _available) + { + tok.ThrowIfCancellationRequested(); + reg.CurrentUsage = 0; + reg.SubModUsage.Clear(); + } + + _missing.Clear(); + _usedPaths.Clear(); + } + + private void UpdatePaths(Mod mod, IModDataContainer option, bool clearRegistries, CancellationToken tok) + { + tok.ThrowIfCancellationRequested(); + ClearPaths(clearRegistries, tok); + + tok.ThrowIfCancellationRequested(); + + foreach (var subMod in mod.AllDataContainers) + { + foreach (var (gamePath, file) in subMod.Files) + { + tok.ThrowIfCancellationRequested(); + if (!file.Exists) + { + _missing.Add(file); + if (subMod == option) + _usedPaths.Add(gamePath); + } + else + { + var registry = _available.Find(x => x.File.Equals(file)); + if (registry == null) + continue; + + if (subMod == option) + { + ++registry.CurrentUsage; + _usedPaths.Add(gamePath); + } + + registry.SubModUsage.Add((subMod, gamePath)); + } + } + } + } +} diff --git a/Penumbra/Mods/Editor/ModFileEditor.cs b/Penumbra/Mods/Editor/ModFileEditor.cs new file mode 100644 index 00000000..3b765215 --- /dev/null +++ b/Penumbra/Mods/Editor/ModFileEditor.cs @@ -0,0 +1,170 @@ +using OtterGui.Services; +using Penumbra.Mods.Manager; +using Penumbra.Mods.SubMods; +using Penumbra.Services; +using Penumbra.String.Classes; + +namespace Penumbra.Mods.Editor; + +public class ModFileEditor(ModFileCollection files, ModManager modManager, CommunicatorService communicator) : IService +{ + public bool Changes { get; private set; } + + public void Clear() + { + Changes = false; + } + + public int Apply(Mod mod, IModDataContainer option) + { + var dict = new Dictionary(); + var num = 0; + foreach (var file in files.Available) + { + foreach (var path in file.SubModUsage.Where(p => p.Item1 == option)) + num += dict.TryAdd(path.Item2, file.File) ? 0 : 1; + } + + modManager.OptionEditor.SetFiles(option, dict); + files.UpdatePaths(mod, option); + Changes = false; + return num; + } + + public void Revert(Mod mod, IModDataContainer option) + { + files.UpdateAll(mod, option); + Changes = false; + } + + /// Remove all path redirections where the pointed-to file does not exist. + public void RemoveMissingPaths(Mod mod, IModDataContainer option) + { + void HandleSubMod(IModDataContainer subMod) + { + var newDict = subMod.Files.Where(kvp => CheckAgainstMissing(mod, subMod, kvp.Value, kvp.Key, subMod == option)) + .ToDictionary(kvp => kvp.Key, kvp => kvp.Value); + if (newDict.Count != subMod.Files.Count) + modManager.OptionEditor.SetFiles(subMod, newDict); + } + + ModEditor.ApplyToAllContainers(mod, HandleSubMod); + files.ClearMissingFiles(); + } + + /// Return whether the given path is already used in the current option. + public bool CanAddGamePath(Utf8GamePath path) + => !files.UsedPaths.Contains(path); + + /// + /// Try to set a given path for a given file. + /// Returns false if this is not possible. + /// If path is empty, it will be deleted instead. + /// If pathIdx is equal to the total number of paths, path will be added, otherwise replaced. + /// + public bool SetGamePath(IModDataContainer option, int fileIdx, int pathIdx, Utf8GamePath path) + { + if (!CanAddGamePath(path) || fileIdx < 0 || fileIdx > files.Available.Count) + return false; + + var registry = files.Available[fileIdx]; + if (pathIdx > registry.SubModUsage.Count) + return false; + + if ((pathIdx == -1 || pathIdx == registry.SubModUsage.Count) && !path.IsEmpty) + files.AddUsedPath(option, registry, path); + else + files.ChangeUsedPath(registry, pathIdx, path); + + Changes = true; + + return true; + } + + /// + /// Transform a set of files to the appropriate game paths with the given number of folders skipped, + /// and add them to the given option. + /// + public int AddPathsToSelected(IModDataContainer option, IEnumerable files1, int skipFolders = 0) + { + var failed = 0; + foreach (var file in files1) + { + var gamePath = file.RelPath.ToGamePath(skipFolders); + if (gamePath.IsEmpty) + { + ++failed; + continue; + } + + if (CanAddGamePath(gamePath)) + { + files.AddUsedPath(option, file, gamePath); + Changes = true; + } + else + { + ++failed; + } + } + + return failed; + } + + /// Remove all paths in the current option from the given files. + public void RemovePathsFromSelected(IModDataContainer option, IEnumerable files1) + { + foreach (var file in files1) + { + for (var i = 0; i < file.SubModUsage.Count; ++i) + { + var (opt, path) = file.SubModUsage[i]; + if (option != opt) + continue; + + files.RemoveUsedPath(option, file, path); + Changes = true; + --i; + } + } + } + + /// Delete all given files from your filesystem + public void DeleteFiles(Mod mod, IModDataContainer option, IEnumerable files1) + { + var deletions = 0; + foreach (var file in files1) + { + try + { + File.Delete(file.File.FullName); + communicator.ModFileChanged.Invoke(mod, file); + Penumbra.Log.Debug($"[DeleteFiles] Deleted {file.File.FullName} from {mod.Name}."); + ++deletions; + } + catch (Exception e) + { + Penumbra.Log.Error($"[DeleteFiles] Could not delete {file.File.FullName} from {mod.Name}:\n{e}"); + } + } + + if (deletions <= 0) + return; + + modManager.Creator.ReloadMod(mod, false, false, out _); + files.UpdateAll(mod, option); + } + + + private bool CheckAgainstMissing(Mod mod, IModDataContainer option, FullPath file, Utf8GamePath key, bool removeUsed) + { + if (!files.Missing.Contains(file)) + return true; + + if (removeUsed) + files.RemoveUsedPath(option, file, key); + + Penumbra.Log.Debug($"[RemoveMissingPaths] Removing {key} -> {file} from {mod.Name}."); + return false; + } +} diff --git a/Penumbra/Mods/Editor/ModMerger.cs b/Penumbra/Mods/Editor/ModMerger.cs new file mode 100644 index 00000000..eb270e13 --- /dev/null +++ b/Penumbra/Mods/Editor/ModMerger.cs @@ -0,0 +1,513 @@ +using Dalamud.Interface.ImGuiNotification; +using Dalamud.Utility; +using OtterGui; +using OtterGui.Classes; +using OtterGui.Extensions; +using OtterGui.Services; +using Penumbra.Api.Enums; +using Penumbra.Communication; +using Penumbra.Mods.Groups; +using Penumbra.Mods.Manager; +using Penumbra.Mods.Manager.OptionEditor; +using Penumbra.Mods.SubMods; +using Penumbra.Services; +using Penumbra.String.Classes; + +namespace Penumbra.Mods.Editor; + +public class ModMerger : IDisposable, IService +{ + private readonly Configuration _config; + private readonly CommunicatorService _communicator; + private readonly ModGroupEditor _editor; + private readonly ModSelection _selection; + private readonly DuplicateManager _duplicates; + private readonly ModManager _mods; + private readonly ModCreator _creator; + + public Mod? MergeFromMod + => _selection.Mod; + + public Mod? MergeToMod; + public string OptionGroupName = "Merges"; + public string OptionName = string.Empty; + + private readonly Dictionary _fileToFile = []; + private readonly HashSet _createdDirectories = []; + private readonly HashSet _createdGroups = []; + private readonly HashSet _createdOptions = []; + + public readonly HashSet SelectedOptions = []; + + public readonly IReadOnlyList Warnings = new List(); + public Exception? Error { get; private set; } + + public ModMerger(ModManager mods, ModGroupEditor editor, ModSelection selection, DuplicateManager duplicates, + CommunicatorService communicator, ModCreator creator, Configuration config) + { + _editor = editor; + _selection = selection; + _duplicates = duplicates; + _communicator = communicator; + _creator = creator; + _config = config; + _mods = mods; + _selection.Subscribe(OnSelectionChange, ModSelection.Priority.ModMerger); + _communicator.ModPathChanged.Subscribe(OnModPathChange, ModPathChanged.Priority.ModMerger); + } + + public void Dispose() + { + _selection.Unsubscribe(OnSelectionChange); + _communicator.ModPathChanged.Unsubscribe(OnModPathChange); + } + + public IEnumerable ModsWithoutCurrent + => _mods.Where(m => m != MergeFromMod); + + public bool CanMerge + => MergeToMod != null && MergeToMod != MergeFromMod; + + public void Merge() + { + if (MergeFromMod == null || MergeToMod == null || MergeFromMod == MergeToMod) + return; + + try + { + Error = null; + DataCleanup(); + if (MergeFromMod.HasOptions) + MergeWithOptions(); + else + MergeIntoOption(OptionGroupName, OptionName); + + _duplicates.DeduplicateMod(MergeToMod.ModPath, true); + } + catch (Exception ex) + { + Error = ex; + Penumbra.Messager.NotificationMessage(ex, $"Could not merge {MergeFromMod!.Name} into {MergeToMod!.Name}, cleaning up changes.", + NotificationType.Error, false); + FailureCleanup(); + DataCleanup(); + } + } + + private void MergeWithOptions() + { + MergeIntoOption([MergeFromMod!.Default], MergeToMod!.Default, false); + + foreach (var originalGroup in MergeFromMod!.Groups) + { + switch (originalGroup.Type) + { + case GroupType.Single: + case GroupType.Multi: + { + var (group, groupIdx, groupCreated) = _editor.FindOrAddModGroup(MergeToMod!, originalGroup.Type, originalGroup.Name); + if (group is null) + throw new Exception( + $"The merged group {originalGroup.Name} already existed, but had a different type than the original group of type {originalGroup.Type}."); + + if (groupCreated) + { + _createdGroups.Add(groupIdx); + group.Description = originalGroup.Description; + group.Image = originalGroup.Image; + group.DefaultSettings = originalGroup.DefaultSettings; + group.Page = originalGroup.Page; + group.Priority = originalGroup.Priority; + } + + foreach (var originalOption in originalGroup.Options) + { + var (option, _, optionCreated) = _editor.FindOrAddOption(group, originalOption.Name); + if (optionCreated) + { + _createdOptions.Add(option!); + MergeIntoOption([(IModDataContainer)originalOption], (IModDataContainer)option!, false); + option!.Description = originalOption.Description; + if (option is MultiSubMod multiOption) + multiOption.Priority = ((MultiSubMod)originalOption).Priority; + } + else + { + throw new Exception( + $"Could not merge {MergeFromMod!.Name} into {MergeToMod!.Name}: The option {option!.FullName} already existed."); + } + } + + break; + } + + case GroupType.Imc when originalGroup is ImcModGroup imc: + { + var group = _editor.ImcEditor.AddModGroup(MergeToMod!, imc.Name, imc.Identifier, imc.DefaultEntry); + if (group is null) + throw new Exception( + $"The merged group {originalGroup.Name} already existed, but groups of type {originalGroup.Type} can not be merged."); + + group.AllVariants = imc.AllVariants; + group.OnlyAttributes = imc.OnlyAttributes; + group.Description = imc.Description; + group.Image = imc.Image; + group.DefaultSettings = imc.DefaultSettings; + group.Page = imc.Page; + group.Priority = imc.Priority; + foreach (var originalOption in imc.OptionData) + { + if (originalOption.IsDisableSubMod) + { + _editor.ImcEditor.ChangeCanBeDisabled(group, true); + var disable = group.OptionData.First(s => s.IsDisableSubMod); + disable.Description = originalOption.Description; + disable.Name = originalOption.Name; + continue; + } + + var newOption = _editor.ImcEditor.AddOption(group, originalOption.Name); + if (newOption is null) + throw new Exception( + $"Could not merge {MergeFromMod!.Name} into {MergeToMod!.Name}: Unknown error when creating IMC option {originalOption.FullName}."); + + newOption.Description = originalOption.Description; + newOption.AttributeMask = originalOption.AttributeMask; + } + + break; + } + case GroupType.Combining when originalGroup is CombiningModGroup combining: + { + var group = _editor.CombiningEditor.AddModGroup(MergeToMod!, combining.Name); + if (group is null) + throw new Exception( + $"The merged group {originalGroup.Name} already existed, but groups of type {originalGroup.Type} can not be merged."); + + group.Description = combining.Description; + group.Image = combining.Image; + group.DefaultSettings = combining.DefaultSettings; + group.Page = combining.Page; + group.Priority = combining.Priority; + foreach (var originalOption in combining.OptionData) + { + var option = _editor.CombiningEditor.AddOption(group, originalOption.Name); + if (option is null) + throw new Exception( + $"Could not merge {MergeFromMod!.Name} into {MergeToMod!.Name}: Unknown error when creating combining option {originalOption.FullName}."); + + option.Description = originalOption.Description; + } + + if (group.Data.Count != combining.Data.Count) + throw new Exception( + $"Could not merge {MergeFromMod!.Name} into {MergeToMod!.Name}: Unknown error caused data container counts in combining group {originalGroup.Name} to differ."); + + foreach (var (originalContainer, container) in combining.Data.Zip(group.Data)) + { + container.Name = originalContainer.Name; + MergeIntoOption([originalContainer], container, false); + } + + + break; + } + } + } + + CopyFiles(MergeToMod!.ModPath); + } + + private void MergeIntoOption(string groupName, string optionName) + { + if (groupName.Length == 0 && optionName.Length == 0) + { + CopyFiles(MergeToMod!.ModPath); + MergeIntoOption(MergeFromMod!.AllDataContainers.Reverse(), MergeToMod!.Default, true); + } + else if (groupName.Length * optionName.Length == 0) + { + return; + } + + var (group, groupIdx, groupCreated) = _editor.FindOrAddModGroup(MergeToMod!, GroupType.Multi, groupName, SaveType.None); + if (groupCreated) + _createdGroups.Add(groupIdx); + var (option, _, optionCreated) = _editor.FindOrAddOption(group!, optionName, SaveType.None); + if (optionCreated) + _createdOptions.Add(option!); + var dir = ModCreator.NewOptionDirectory(MergeToMod!.ModPath, groupName, _config.ReplaceNonAsciiOnImport); + if (!dir.Exists) + _createdDirectories.Add(dir.FullName); + dir = ModCreator.NewOptionDirectory(dir, optionName, _config.ReplaceNonAsciiOnImport); + if (!dir.Exists) + _createdDirectories.Add(dir.FullName); + CopyFiles(dir); + MergeIntoOption(MergeFromMod!.AllDataContainers.Reverse(), (IModDataContainer)option!, true); + } + + private void MergeIntoOption(IEnumerable mergeOptions, IModDataContainer option, bool fromFileToFile) + { + var redirections = option.Files.ToDictionary(kvp => kvp.Key, kvp => kvp.Value); + var swaps = option.FileSwaps.ToDictionary(kvp => kvp.Key, kvp => kvp.Value); + var manips = option.Manipulations.Clone(); + + foreach (var originalOption in mergeOptions) + { + if (!manips.MergeForced(originalOption.Manipulations, out var failed)) + throw new Exception( + $"Could not add meta manipulation {failed} from {originalOption.GetFullName()} to {option.GetFullName()} because another manipulation of the same data already exists in this option."); + + foreach (var (swapA, swapB) in originalOption.FileSwaps) + { + if (!swaps.TryAdd(swapA, swapB)) + throw new Exception( + $"Could not add file swap {swapB} -> {swapA} from {originalOption.GetFullName()} to {option.GetFullName()} because another swap of the key already exists."); + } + + foreach (var (gamePath, path) in originalOption.Files) + { + if (!GetFullPath(path, out var newFile)) + throw new Exception( + $"Could not add file redirection {path} -> {gamePath} from {originalOption.GetFullName()} to {option.GetFullName()} because the file does not exist in the new mod."); + if (!redirections.TryAdd(gamePath, newFile)) + throw new Exception( + $"Could not add file redirection {path} -> {gamePath} from {originalOption.GetFullName()} to {option.GetFullName()} because a redirection for the game path already exists."); + } + } + + _editor.SetFiles(option, redirections, SaveType.None); + _editor.SetFileSwaps(option, swaps, SaveType.None); + _editor.SetManipulations(option, manips, SaveType.None); + _editor.ForceSave(option, SaveType.ImmediateSync); + return; + + bool GetFullPath(FullPath input, out FullPath ret) + { + if (fromFileToFile) + { + if (!_fileToFile.TryGetValue(input.FullName.ToLowerInvariant(), out var s)) + { + ret = input; + return false; + } + + ret = new FullPath(s); + return true; + } + + if (!Utf8RelPath.FromFile(input, MergeFromMod!.ModPath, out var relPath)) + throw new Exception($"Could not create relative path from {input} and {MergeFromMod!.ModPath}."); + + ret = new FullPath(MergeToMod!.ModPath, relPath); + return true; + } + } + + private void CopyFiles(DirectoryInfo directory) + { + directory = Directory.CreateDirectory(directory.FullName); + foreach (var file in MergeFromMod!.ModPath.EnumerateDirectories() + .Where(d => !d.IsHidden()) + .SelectMany(FileExtensions.EnumerateNonHiddenFiles)) + { + var path = Path.GetRelativePath(MergeFromMod.ModPath.FullName, file.FullName); + path = Path.Combine(directory.FullName, path); + var finalDir = Path.GetDirectoryName(path)!; + var dir = finalDir; + while (!dir.IsNullOrEmpty()) + { + if (!Directory.Exists(dir)) + _createdDirectories.Add(dir); + else + break; + + dir = Path.GetDirectoryName(dir); + } + + Directory.CreateDirectory(finalDir); + file.CopyTo(path); + Penumbra.Log.Verbose($"[Merger] Copied file {file.FullName} to {path}."); + _fileToFile.Add(file.FullName.ToLowerInvariant(), path); + } + } + + public void SplitIntoMod(string modName) + { + var mods = SelectedOptions.ToList(); + if (mods.Count == 0) + return; + + ((List)Warnings).Clear(); + Error = null; + DirectoryInfo? dir = null; + Mod? result = null; + try + { + dir = _creator.CreateEmptyMod(_mods.BasePath, modName, $"Split off from {mods[0].Mod.Name}."); + if (dir == null) + throw new Exception($"Could not split off mods, unable to create new mod with name {modName}."); + + _mods.AddMod(dir, false); + result = _mods[^1]; + if (mods.Count == 1) + { + var files = CopySubModFiles(mods[0], dir); + _editor.SetFiles(result.Default, files, SaveType.None); + _editor.SetFileSwaps(result.Default, mods[0].FileSwaps, SaveType.None); + _editor.SetManipulations(result.Default, mods[0].Manipulations, SaveType.None); + _editor.ForceSave(result.Default); + } + else + { + foreach (var originalOption in mods) + { + if (originalOption.Group is not { } originalGroup) + { + var files = CopySubModFiles(mods[0], dir); + _editor.SetFiles(result.Default, files); + _editor.SetFileSwaps(result.Default, mods[0].FileSwaps); + _editor.SetManipulations(result.Default, mods[0].Manipulations); + _editor.ForceSave(result.Default); + } + else + { + var (group, _, _) = _editor.FindOrAddModGroup(result, originalGroup.Type, originalGroup.Name); + var (option, _, _) = _editor.FindOrAddOption(group!, originalOption.GetName()); + var folder = Path.Combine(dir.FullName, group!.Name, option!.Name); + var files = CopySubModFiles(originalOption, new DirectoryInfo(folder)); + _editor.SetFiles((IModDataContainer)option, files, SaveType.None); + _editor.SetFileSwaps((IModDataContainer)option, originalOption.FileSwaps, SaveType.None); + _editor.SetManipulations((IModDataContainer)option, originalOption.Manipulations, SaveType.None); + _editor.ForceSave((IModDataContainer)option); + } + } + } + } + catch (Exception e) + { + Error = e; + if (result != null) + _mods.DeleteMod(result); + else if (dir != null) + try + { + Directory.Delete(dir.FullName); + } + catch (Exception ex) + { + Penumbra.Log.Error($"Could not clean up after failure to split options into new mod {modName}:\n{ex}"); + } + } + } + + private static Dictionary CopySubModFiles(IModDataContainer option, DirectoryInfo newMod) + { + var ret = new Dictionary(option.Files.Count); + var parentPath = ((Mod)option.Mod).ModPath.FullName; + foreach (var (path, file) in option.Files) + { + var target = Path.GetRelativePath(parentPath, file.FullName); + target = Path.Combine(newMod.FullName, target); + var targetPath = new FullPath(target); + Directory.CreateDirectory(Path.GetDirectoryName(target)!); + // Copy throws if the file exists, which we want. + // This copies if the target does not exist, throws if it exists and is different, or does nothing if it exists and is identical. + if (!File.Exists(target) || !DuplicateManager.CompareFilesDirectly(targetPath, file)) + File.Copy(file.FullName, target); + Penumbra.Log.Verbose($"[Splitter] Copied file {file.FullName} to {target}."); + ret.Add(path, targetPath); + } + + return ret; + } + + private void DataCleanup() + { + _fileToFile.Clear(); + _createdDirectories.Clear(); + _createdGroups.Clear(); + _createdOptions.Clear(); + } + + private void FailureCleanup() + { + foreach (var option in _createdOptions) + { + _editor.DeleteOption(option); + Penumbra.Log.Verbose($"[Merger] Removed option {option.FullName}."); + } + + foreach (var group in _createdGroups) + { + var groupName = MergeToMod!.Groups[group]; + _editor.DeleteModGroup(groupName); + Penumbra.Log.Verbose($"[Merger] Removed option group {groupName.Name}."); + } + + foreach (var dir in _createdDirectories) + { + if (!Directory.Exists(dir)) + continue; + + try + { + Directory.Delete(dir, true); + Penumbra.Log.Verbose($"[Merger] Deleted {dir}."); + } + catch (Exception ex) + { + Penumbra.Log.Error( + $"Could not clean up after failing to merge {MergeFromMod!.Name} into {MergeToMod!.Name}, unable to delete {dir}:\n{ex}"); + } + } + + foreach (var (_, file) in _fileToFile) + { + if (!File.Exists(file)) + continue; + + try + { + File.Delete(file); + Penumbra.Log.Verbose($"[Merger] Deleted {file}."); + } + catch (Exception ex) + { + Penumbra.Log.Error( + $"Could not clean up after failing to merge {MergeFromMod!.Name} into {MergeToMod!.Name}, unable to delete {file}:\n{ex}"); + } + } + } + + private void OnSelectionChange(Mod? oldSelection, Mod? newSelection) + { + if (OptionGroupName == "Merges" && OptionName.Length == 0 || OptionName == oldSelection?.Name.Text) + OptionName = newSelection?.Name.Text ?? string.Empty; + + if (MergeToMod == newSelection) + MergeToMod = null; + + SelectedOptions.Clear(); + } + + private void OnModPathChange(ModPathChangeType type, Mod mod, DirectoryInfo? _1, DirectoryInfo? _2) + { + switch (type) + { + case ModPathChangeType.Deleted: + { + if (mod == MergeFromMod) + SelectedOptions.Clear(); + + if (mod == MergeToMod) + MergeToMod = null; + break; + } + case ModPathChangeType.StartingReload: + SelectedOptions.Clear(); + MergeToMod = null; + break; + } + } +} diff --git a/Penumbra/Mods/Editor/ModMetaEditor.cs b/Penumbra/Mods/Editor/ModMetaEditor.cs new file mode 100644 index 00000000..b4db457d --- /dev/null +++ b/Penumbra/Mods/Editor/ModMetaEditor.cs @@ -0,0 +1,342 @@ +using System.Collections.Frozen; +using OtterGui.Classes; +using OtterGui.Services; +using Penumbra.Collections.Cache; +using Penumbra.Meta; +using Penumbra.Meta.Files; +using Penumbra.Meta.Manipulations; +using Penumbra.Mods.Groups; +using Penumbra.Mods.Manager.OptionEditor; +using Penumbra.Mods.SubMods; +using Penumbra.Services; +using static Penumbra.GameData.Files.ShpkFile; + +namespace Penumbra.Mods.Editor; + +public class ModMetaEditor( + ModGroupEditor groupEditor, + MetaFileManager metaFileManager) : MetaDictionary, IService +{ + public sealed class OtherOptionData : HashSet + { + public int TotalCount; + + public void Add(string name, int count) + { + if (count > 0) + Add(name); + TotalCount += count; + } + + public new void Clear() + { + TotalCount = 0; + base.Clear(); + } + } + + public readonly FrozenDictionary OtherData = + Enum.GetValues().ToFrozenDictionary(t => t, _ => new OtherOptionData()); + + public bool Changes { get; set; } + + public new void Clear() + { + Changes = Count > 0; + base.Clear(); + } + + public void Load(Mod mod, IModDataContainer currentOption) + { + foreach (var type in Enum.GetValues()) + OtherData[type].Clear(); + + foreach (var option in mod.AllDataContainers) + { + if (option == currentOption) + continue; + + var name = option.GetFullName(); + OtherData[MetaManipulationType.Imc].Add(name, option.Manipulations.GetCount(MetaManipulationType.Imc)); + OtherData[MetaManipulationType.Eqp].Add(name, option.Manipulations.GetCount(MetaManipulationType.Eqp)); + OtherData[MetaManipulationType.Eqdp].Add(name, option.Manipulations.GetCount(MetaManipulationType.Eqdp)); + OtherData[MetaManipulationType.Gmp].Add(name, option.Manipulations.GetCount(MetaManipulationType.Gmp)); + OtherData[MetaManipulationType.Est].Add(name, option.Manipulations.GetCount(MetaManipulationType.Est)); + OtherData[MetaManipulationType.Rsp].Add(name, option.Manipulations.GetCount(MetaManipulationType.Rsp)); + OtherData[MetaManipulationType.Atch].Add(name, option.Manipulations.GetCount(MetaManipulationType.Atch)); + OtherData[MetaManipulationType.Shp].Add(name, option.Manipulations.GetCount(MetaManipulationType.Shp)); + OtherData[MetaManipulationType.Atr].Add(name, option.Manipulations.GetCount(MetaManipulationType.Atr)); + OtherData[MetaManipulationType.GlobalEqp].Add(name, option.Manipulations.GetCount(MetaManipulationType.GlobalEqp)); + } + + Clear(); + UnionWith(currentOption.Manipulations); + Changes = false; + } + + public static bool DeleteDefaultValues(Mod mod, MetaFileManager metaFileManager, SaveService? saveService, bool deleteAll = false) + { + if (deleteAll) + { + var changes = false; + foreach (var container in mod.AllDataContainers) + { + if (!DeleteDefaultValues(metaFileManager, container.Manipulations)) + continue; + + saveService?.ImmediateSaveSync(new ModSaveGroup(container, metaFileManager.Config.ReplaceNonAsciiOnImport)); + changes = true; + } + + return changes; + } + + var defaultEntries = new MultiDictionary(); + var actualEntries = new HashSet(); + if (!FilterDefaultValues(mod.AllDataContainers, metaFileManager, defaultEntries, actualEntries)) + return false; + + var groups = new HashSet(); + DefaultSubMod? defaultMod = null; + foreach (var (defaultIdentifier, containers) in defaultEntries.Grouped) + { + if (!deleteAll && actualEntries.Contains(defaultIdentifier)) + continue; + + foreach (var container in containers) + { + if (!container.Manipulations.Remove(defaultIdentifier)) + continue; + + Penumbra.Log.Verbose($"Deleted default-valued meta-entry {defaultIdentifier}."); + if (container.Group is { } group) + groups.Add(group); + else if (container is DefaultSubMod d) + defaultMod = d; + } + } + + if (saveService is not null) + { + if (defaultMod is not null) + saveService.ImmediateSaveSync(new ModSaveGroup(defaultMod, metaFileManager.Config.ReplaceNonAsciiOnImport)); + foreach (var group in groups) + saveService.ImmediateSaveSync(new ModSaveGroup(group, metaFileManager.Config.ReplaceNonAsciiOnImport)); + } + + return defaultMod is not null || groups.Count > 0; + } + + public void DeleteDefaultValues() + => Changes = DeleteDefaultValues(metaFileManager, this); + + public void Apply(IModDataContainer container) + { + if (!Changes) + return; + + groupEditor.SetManipulations(container, this); + Changes = false; + } + + private static bool FilterDefaultValues(IEnumerable containers, MetaFileManager metaFileManager, + MultiDictionary defaultEntries, HashSet actualEntries) + { + if (!metaFileManager.CharacterUtility.Ready) + { + Penumbra.Log.Warning("Trying to filter default meta values before CharacterUtility was ready, skipped."); + return false; + } + + foreach (var container in containers) + { + foreach (var (key, value) in container.Manipulations.Imc) + { + var defaultEntry = ImcChecker.GetDefaultEntry(key, false); + if (defaultEntry.Entry.Equals(value)) + defaultEntries.TryAdd(key, container); + else + actualEntries.Add(key); + } + + foreach (var (key, value) in container.Manipulations.Eqp) + { + var defaultEntry = new EqpEntryInternal(ExpandedEqpFile.GetDefault(metaFileManager, key.SetId), key.Slot); + if (defaultEntry.Equals(value)) + defaultEntries.TryAdd(key, container); + else + actualEntries.Add(key); + } + + foreach (var (key, value) in container.Manipulations.Eqdp) + { + var defaultEntry = new EqdpEntryInternal(ExpandedEqdpFile.GetDefault(metaFileManager, key), key.Slot); + if (defaultEntry.Equals(value)) + defaultEntries.TryAdd(key, container); + else + actualEntries.Add(key); + } + + foreach (var (key, value) in container.Manipulations.Est) + { + var defaultEntry = EstFile.GetDefault(metaFileManager, key); + if (defaultEntry.Equals(value)) + defaultEntries.TryAdd(key, container); + else + actualEntries.Add(key); + } + + foreach (var (key, value) in container.Manipulations.Gmp) + { + var defaultEntry = ExpandedGmpFile.GetDefault(metaFileManager, key); + if (defaultEntry.Equals(value)) + defaultEntries.TryAdd(key, container); + else + actualEntries.Add(key); + } + + foreach (var (key, value) in container.Manipulations.Rsp) + { + var defaultEntry = CmpFile.GetDefault(metaFileManager, key.SubRace, key.Attribute); + if (defaultEntry.Equals(value)) + defaultEntries.TryAdd(key, container); + else + actualEntries.Add(key); + } + + foreach (var (key, value) in container.Manipulations.Atch) + { + var defaultEntry = AtchCache.GetDefault(metaFileManager, key); + if (defaultEntry.Equals(value)) + defaultEntries.TryAdd(key, container); + else + actualEntries.Add(key); + } + } + + return true; + } + + private static bool DeleteDefaultValues(MetaFileManager metaFileManager, MetaDictionary dict) + { + if (!metaFileManager.CharacterUtility.Ready) + { + Penumbra.Log.Warning("Trying to delete default meta values before CharacterUtility was ready, skipped."); + return false; + } + + var clone = dict.Clone(); + dict.ClearForDefault(); + + var count = 0; + foreach (var value in clone.GlobalEqp) + dict.TryAdd(value); + + foreach (var (key, value) in clone.Imc) + { + var defaultEntry = ImcChecker.GetDefaultEntry(key, false); + if (!defaultEntry.Entry.Equals(value)) + { + dict.TryAdd(key, value); + } + else + { + Penumbra.Log.Verbose($"Deleted default-valued meta-entry {key}."); + ++count; + } + } + + foreach (var (key, value) in clone.Eqp) + { + var defaultEntry = new EqpEntryInternal(ExpandedEqpFile.GetDefault(metaFileManager, key.SetId), key.Slot); + if (!defaultEntry.Equals(value)) + { + dict.TryAdd(key, value); + } + else + { + Penumbra.Log.Verbose($"Deleted default-valued meta-entry {key}."); + ++count; + } + } + + foreach (var (key, value) in clone.Eqdp) + { + var defaultEntry = new EqdpEntryInternal(ExpandedEqdpFile.GetDefault(metaFileManager, key), key.Slot); + if (!defaultEntry.Equals(value)) + { + dict.TryAdd(key, value); + } + else + { + Penumbra.Log.Verbose($"Deleted default-valued meta-entry {key}."); + ++count; + } + } + + foreach (var (key, value) in clone.Est) + { + var defaultEntry = EstFile.GetDefault(metaFileManager, key); + if (!defaultEntry.Equals(value)) + { + dict.TryAdd(key, value); + } + else + { + Penumbra.Log.Verbose($"Deleted default-valued meta-entry {key}."); + ++count; + } + } + + foreach (var (key, value) in clone.Gmp) + { + var defaultEntry = ExpandedGmpFile.GetDefault(metaFileManager, key); + if (!defaultEntry.Equals(value)) + { + dict.TryAdd(key, value); + } + else + { + Penumbra.Log.Verbose($"Deleted default-valued meta-entry {key}."); + ++count; + } + } + + foreach (var (key, value) in clone.Rsp) + { + var defaultEntry = CmpFile.GetDefault(metaFileManager, key.SubRace, key.Attribute); + if (!defaultEntry.Equals(value)) + { + dict.TryAdd(key, value); + } + else + { + Penumbra.Log.Verbose($"Deleted default-valued meta-entry {key}."); + ++count; + } + } + + foreach (var (key, value) in clone.Atch) + { + var defaultEntry = AtchCache.GetDefault(metaFileManager, key); + if (!defaultEntry.HasValue) + continue; + + if (!defaultEntry.Value.Equals(value)) + { + dict.TryAdd(key, value); + } + else + { + Penumbra.Log.Verbose($"Deleted default-valued meta-entry {key}."); + ++count; + } + } + + if (count == 0) + return false; + + Penumbra.Log.Debug($"Deleted {count} default-valued meta-entries from a mod option."); + return true; + } +} diff --git a/Penumbra/Mods/Editor/ModNormalizer.cs b/Penumbra/Mods/Editor/ModNormalizer.cs new file mode 100644 index 00000000..df1528f6 --- /dev/null +++ b/Penumbra/Mods/Editor/ModNormalizer.cs @@ -0,0 +1,386 @@ +using Dalamud.Interface.ImGuiNotification; +using OtterGui.Classes; +using OtterGui.Extensions; +using OtterGui.Services; +using OtterGui.Tasks; +using Penumbra.Mods.Groups; +using Penumbra.Mods.Manager; +using Penumbra.Mods.SubMods; +using Penumbra.Services; +using Penumbra.String.Classes; + +namespace Penumbra.Mods.Editor; + +public class ModNormalizer(ModManager modManager, Configuration config, SaveService saveService) : IService +{ + private readonly List>> _redirections = []; + + public Mod Mod { get; private set; } = null!; + private string _normalizationDirName = null!; + private string _oldDirName = null!; + + public int Step { get; private set; } + public int TotalSteps { get; private set; } + public Task Worker { get; private set; } = Task.CompletedTask; + + + public bool Running + => !Worker.IsCompleted; + + public void Normalize(Mod mod) + { + if (Step < TotalSteps) + return; + + Mod = mod; + _normalizationDirName = Path.Combine(Mod.ModPath.FullName, "TmpNormalization"); + _oldDirName = Path.Combine(Mod.ModPath.FullName, "TmpNormalizationOld"); + Step = 0; + TotalSteps = mod.TotalFileCount + 5; + + Worker = TrackedTask.Run(NormalizeSync); + } + + public void NormalizeUi(DirectoryInfo modDirectory) + { + if (!config.AutoReduplicateUiOnImport) + return; + + if (modManager.Creator.LoadMod(modDirectory, false, false) is not { } mod) + return; + + Dictionary> paths = []; + Dictionary containers = []; + foreach (var container in mod.AllDataContainers) + { + foreach (var (gamePath, path) in container.Files) + { + if (!gamePath.Path.StartsWith("ui/"u8)) + continue; + + if (!paths.TryGetValue(path, out var list)) + { + list = []; + paths.Add(path, list); + } + + list.Add((container, gamePath)); + containers.TryAdd(container, string.Empty); + } + } + + foreach (var container in containers.Keys.ToList()) + { + if (container.Group == null) + containers[container] = mod.ModPath.FullName; + else + { + var groupDir = ModCreator.NewOptionDirectory(mod.ModPath, container.Group.Name, config.ReplaceNonAsciiOnImport); + var optionDir = ModCreator.NewOptionDirectory(groupDir, container.GetDirectoryName(), config.ReplaceNonAsciiOnImport); + containers[container] = optionDir.FullName; + } + } + + var anyChanges = 0; + var modRootLength = mod.ModPath.FullName.Length + 1; + foreach (var (file, gamePaths) in paths) + { + if (gamePaths.Count < 2) + continue; + + var keptPath = false; + foreach (var (container, gamePath) in gamePaths) + { + var directory = containers[container]; + var relPath = new Utf8RelPath(gamePath).ToString(); + var newFilePath = Path.Combine(directory, relPath); + if (newFilePath == file.FullName) + { + Penumbra.Log.Verbose($"[UIReduplication] Kept {file.FullName[modRootLength..]} because new path was identical."); + keptPath = true; + continue; + } + + try + { + Directory.CreateDirectory(Path.GetDirectoryName(newFilePath)!); + File.Copy(file.FullName, newFilePath, false); + Penumbra.Log.Verbose($"[UIReduplication] Copied {file.FullName[modRootLength..]} to {newFilePath[modRootLength..]}."); + container.Files[gamePath] = new FullPath(newFilePath); + ++anyChanges; + } + catch (Exception ex) + { + Penumbra.Log.Error( + $"[UIReduplication] Failed to copy {file.FullName[modRootLength..]} to {newFilePath[modRootLength..]}:\n{ex}"); + } + } + + if (keptPath) + continue; + + try + { + File.Delete(file.FullName); + Penumbra.Log.Verbose($"[UIReduplication] Deleted {file.FullName[modRootLength..]} because no new path matched."); + } + catch (Exception ex) + { + Penumbra.Log.Error($"[UIReduplication] Failed to delete {file.FullName[modRootLength..]}:\n{ex}"); + } + } + + if (anyChanges == 0) + return; + + saveService.Save(SaveType.ImmediateSync, new ModSaveGroup(mod.Default, config.ReplaceNonAsciiOnImport)); + saveService.SaveAllOptionGroups(mod, false, config.ReplaceNonAsciiOnImport); + Penumbra.Log.Information($"[UIReduplication] Saved groups after {anyChanges} changes."); + } + + private void NormalizeSync() + { + try + { + Penumbra.Log.Debug($"[Normalization] Starting Normalization of {Mod.ModPath.Name}..."); + if (!CheckDirectories()) + { + return; + } + + Penumbra.Log.Debug("[Normalization] Copying files to temporary directory structure..."); + if (!CopyNewFiles()) + { + return; + } + + Penumbra.Log.Debug("[Normalization] Moving old files out of the way..."); + if (!MoveOldFiles()) + { + return; + } + + Penumbra.Log.Debug("[Normalization] Moving new directory structure in place..."); + if (!MoveNewFiles()) + { + return; + } + + Penumbra.Log.Debug("[Normalization] Applying new redirections..."); + ApplyRedirections(); + } + catch (Exception e) + { + Penumbra.Messager.NotificationMessage(e, $"Could not normalize mod {Mod.Name}.", NotificationType.Error, false); + } + finally + { + Penumbra.Log.Debug("[Normalization] Cleaning up remaining directories..."); + Cleanup(); + } + } + + private bool CheckDirectories() + { + if (Directory.Exists(_normalizationDirName)) + { + Penumbra.Messager.NotificationMessage($"Could not normalize mod {Mod.Name}:\n" + + "The directory TmpNormalization may not already exist when normalizing a mod.", NotificationType.Error, false); + return false; + } + + if (Directory.Exists(_oldDirName)) + { + Penumbra.Messager.NotificationMessage($"Could not normalize mod {Mod.Name}:\n" + + "The directory TmpNormalizationOld may not already exist when normalizing a mod.", NotificationType.Error, false); + return false; + } + + ++Step; + return true; + } + + private void Cleanup() + { + if (Directory.Exists(_normalizationDirName)) + { + try + { + Directory.Delete(_normalizationDirName, true); + } + catch + { + // ignored + } + } + + if (Directory.Exists(_oldDirName)) + { + try + { + foreach (var dir in new DirectoryInfo(_oldDirName).EnumerateDirectories()) + { + dir.MoveTo(Path.Combine(Mod.ModPath.FullName, dir.Name)); + } + + Directory.Delete(_oldDirName, true); + } + catch + { + // ignored + } + } + + Step = TotalSteps; + } + + private bool CopyNewFiles() + { + // We copy all files to a temporary folder to ensure that we can revert the operation on failure. + try + { + var directory = Directory.CreateDirectory(_normalizationDirName); + for (var i = _redirections.Count; i < Mod.Groups.Count + 1; ++i) + _redirections.Add([]); + + if (_redirections[0].Count == 0) + _redirections[0].Add(new Dictionary(Mod.Default.Files.Count)); + else + { + _redirections[0][0].Clear(); + _redirections[0][0].EnsureCapacity(Mod.Default.Files.Count); + } + + // Normalize the default option. + var newDict = _redirections[0][0]; + foreach (var (gamePath, fullPath) in Mod.Default.Files) + { + var relPath = new Utf8RelPath(gamePath).ToString(); + var newFullPath = Path.Combine(directory.FullName, relPath); + var redirectPath = new FullPath(Path.Combine(Mod.ModPath.FullName, relPath)); + Directory.CreateDirectory(Path.GetDirectoryName(newFullPath)!); + File.Copy(fullPath.FullName, newFullPath, true); + newDict.Add(gamePath, redirectPath); + ++Step; + } + + // Normalize all other options. + foreach (var (group, groupIdx) in Mod.Groups.WithIndex()) + { + var groupDir = ModCreator.CreateModFolder(directory, group.Name, config.ReplaceNonAsciiOnImport, true); + _redirections[groupIdx + 1].EnsureCapacity(group.DataContainers.Count); + for (var i = _redirections[groupIdx + 1].Count; i < group.DataContainers.Count; ++i) + _redirections[groupIdx + 1].Add([]); + foreach (var (data, dataIdx) in group.DataContainers.WithIndex()) + HandleSubMod(groupDir, data, _redirections[groupIdx + 1][dataIdx]); + } + + return true; + } + catch (Exception e) + { + Penumbra.Messager.NotificationMessage(e, $"Could not normalize mod {Mod.Name}.", NotificationType.Error, false); + } + + return false; + + void HandleSubMod(DirectoryInfo groupDir, IModDataContainer option, Dictionary newDict) + { + var name = option.GetDirectoryName(); + var optionDir = ModCreator.CreateModFolder(groupDir, name, config.ReplaceNonAsciiOnImport, true); + + newDict.Clear(); + newDict.EnsureCapacity(option.Files.Count); + foreach (var (gamePath, fullPath) in option.Files) + { + var relPath = new Utf8RelPath(gamePath).ToString(); + var newFullPath = Path.Combine(optionDir.FullName, relPath); + var redirectPath = new FullPath(Path.Combine(Mod.ModPath.FullName, groupDir.Name, optionDir.Name, relPath)); + Directory.CreateDirectory(Path.GetDirectoryName(newFullPath)!); + File.Copy(fullPath.FullName, newFullPath, true); + newDict.Add(gamePath, redirectPath); + ++Step; + } + } + } + + private bool MoveOldFiles() + { + try + { + // Clean old directories and files. + var oldDirectory = Directory.CreateDirectory(_oldDirName); + foreach (var dir in Mod.ModPath.EnumerateDirectories()) + { + if (dir.FullName.Equals(_oldDirName, StringComparison.OrdinalIgnoreCase) + || dir.FullName.Equals(_normalizationDirName, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + dir.MoveTo(Path.Combine(oldDirectory.FullName, dir.Name)); + } + + ++Step; + return true; + } + catch (Exception e) + { + Penumbra.Messager.NotificationMessage(e, $"Could not move old files out of the way while normalizing mod {Mod.Name}.", + NotificationType.Error, false); + } + + return false; + } + + private bool MoveNewFiles() + { + try + { + var mainDir = new DirectoryInfo(_normalizationDirName); + foreach (var dir in mainDir.EnumerateDirectories()) + { + dir.MoveTo(Path.Combine(Mod.ModPath.FullName, dir.Name)); + } + + mainDir.Delete(); + Directory.Delete(_oldDirName, true); + ++Step; + return true; + } + catch (Exception e) + { + Penumbra.Messager.NotificationMessage(e, $"Could not move new files into the mod while normalizing mod {Mod.Name}.", + NotificationType.Error, false); + foreach (var dir in Mod.ModPath.EnumerateDirectories()) + { + if (dir.FullName.Equals(_oldDirName, StringComparison.OrdinalIgnoreCase) + || dir.FullName.Equals(_normalizationDirName, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + try + { + dir.Delete(true); + } + catch + { + // ignored + } + } + } + + return false; + } + + private void ApplyRedirections() + { + modManager.OptionEditor.SetFiles(Mod.Default, _redirections[0][0]); + foreach (var (group, groupIdx) in Mod.Groups.WithIndex()) + foreach (var (container, containerIdx) in group.DataContainers.WithIndex()) + modManager.OptionEditor.SetFiles(container, _redirections[groupIdx + 1][containerIdx]); + + ++Step; + } +} diff --git a/Penumbra/Mods/Editor/ModSwapEditor.cs b/Penumbra/Mods/Editor/ModSwapEditor.cs new file mode 100644 index 00000000..1a8ff2eb --- /dev/null +++ b/Penumbra/Mods/Editor/ModSwapEditor.cs @@ -0,0 +1,48 @@ +using OtterGui.Services; +using Penumbra.Mods.Manager; +using Penumbra.Mods.SubMods; +using Penumbra.String.Classes; +using Penumbra.Util; + +public class ModSwapEditor(ModManager modManager) : IService +{ + private readonly Dictionary _swaps = []; + + public IReadOnlyDictionary Swaps + => _swaps; + + public void Revert(IModDataContainer option) + { + _swaps.SetTo(option.FileSwaps); + Changes = false; + } + + public void Apply(IModDataContainer container) + { + if (!Changes) + return; + + modManager.OptionEditor.SetFileSwaps(container, _swaps); + Changes = false; + } + + public bool Changes { get; private set; } + + public void Remove(Utf8GamePath path) + => Changes |= _swaps.Remove(path); + + public void Add(Utf8GamePath path, FullPath file) + => Changes |= _swaps.TryAdd(path, file); + + public void Change(Utf8GamePath path, Utf8GamePath newPath) + { + if (_swaps.Remove(path, out var file)) + Add(newPath, file); + } + + public void Change(Utf8GamePath path, FullPath file) + { + _swaps[path] = file; + Changes = true; + } +} diff --git a/Penumbra/Mods/Editor/ModelMaterialInfo.cs b/Penumbra/Mods/Editor/ModelMaterialInfo.cs new file mode 100644 index 00000000..fe46048f --- /dev/null +++ b/Penumbra/Mods/Editor/ModelMaterialInfo.cs @@ -0,0 +1,80 @@ +using OtterGui.Compression; +using OtterGui.Extensions; +using Penumbra.GameData.Files; +using Penumbra.String.Classes; + +namespace Penumbra.Mods.Editor; + +/// A class that collects information about skin materials in a model file and handle changes on them. +public class ModelMaterialInfo +{ + public readonly FullPath Path; + public readonly MdlFile File; + private readonly string[] _currentMaterials; + private readonly IReadOnlyList _materialIndices; + public bool Changed { get; private set; } + + public IReadOnlyList CurrentMaterials + => _currentMaterials; + + private IEnumerable DefaultMaterials + => _materialIndices.Select(i => File.Materials[i]); + + public (string Current, string Default) this[int idx] + => (_currentMaterials[idx], File.Materials[_materialIndices[idx]]); + + public int Count + => _materialIndices.Count; + + // Set the skin material to a new value and flag changes appropriately. + public void SetMaterial(string value, int materialIdx) + { + var mat = File.Materials[_materialIndices[materialIdx]]; + _currentMaterials[materialIdx] = value; + if (mat != value) + Changed = true; + else + Changed = !_currentMaterials.SequenceEqual(DefaultMaterials); + } + + // Save a changed .mdl file. + public void Save(FileCompactor compactor) + { + if (!Changed) + return; + + foreach (var (idx, i) in _materialIndices.WithIndex()) + File.Materials[idx] = _currentMaterials[i]; + + try + { + compactor.WriteAllBytes(Path.FullName, File.Write()); + Changed = false; + } + catch (Exception e) + { + Restore(); + Penumbra.Log.Error($"Could not write manipulated .mdl file {Path.FullName}:\n{e}"); + } + } + + // Revert all current changes. + public void Restore() + { + if (!Changed) + return; + + foreach (var (idx, i) in _materialIndices.WithIndex()) + _currentMaterials[i] = File.Materials[idx]; + + Changed = false; + } + + public ModelMaterialInfo(FullPath path, MdlFile file, IReadOnlyList indices) + { + Path = path; + File = file; + _materialIndices = indices; + _currentMaterials = DefaultMaterials.ToArray(); + } +} diff --git a/Penumbra/Mods/FeatureChecker.cs b/Penumbra/Mods/FeatureChecker.cs new file mode 100644 index 00000000..10874fc9 --- /dev/null +++ b/Penumbra/Mods/FeatureChecker.cs @@ -0,0 +1,95 @@ +using System.Collections.Frozen; +using Dalamud.Interface.ImGuiNotification; +using Dalamud.Interface.Utility.Raii; +using Dalamud.Bindings.ImGui; +using OtterGui.Text; +using Penumbra.Mods.Manager; +using Penumbra.UI.Classes; +using Notification = OtterGui.Classes.Notification; + +namespace Penumbra.Mods; + +public static class FeatureChecker +{ + /// Manually setup supported features to exclude None and Invalid and not make something supported too early. + private static readonly FrozenDictionary SupportedFlags = new[] + { + FeatureFlags.Atch, + FeatureFlags.Shp, + FeatureFlags.Atr, + }.ToFrozenDictionary(f => f.ToString(), f => f); + + public static IReadOnlyCollection SupportedFeatures + => SupportedFlags.Keys; + + public static FeatureFlags ParseFlags(string modDirectory, string modName, IEnumerable features) + { + var featureFlags = FeatureFlags.None; + HashSet missingFeatures = []; + foreach (var feature in features) + { + if (SupportedFlags.TryGetValue(feature, out var featureFlag)) + featureFlags |= featureFlag; + else + missingFeatures.Add(feature); + } + + if (missingFeatures.Count > 0) + { + Penumbra.Messager.AddMessage(new Notification( + $"Please update Penumbra to use the mod {modName}{(modDirectory != modName ? $" at {modDirectory}" : string.Empty)}!\n\nLoading failed because it requires the unsupported feature{(missingFeatures.Count > 1 ? $"s\n\n\t[{string.Join("], [", missingFeatures)}]." : $" [{missingFeatures.First()}].")}", + NotificationType.Warning)); + return FeatureFlags.Invalid; + } + + return featureFlags; + } + + public static bool Supported(string features) + => SupportedFlags.ContainsKey(features); + + public static void DrawFeatureFlagInput(ModDataEditor editor, Mod mod, float width) + { + const int numButtons = 5; + var innerSpacing = ImGui.GetStyle().ItemInnerSpacing; + var size = new Vector2((width - (numButtons - 1) * innerSpacing.X) / numButtons, 0); + var buttonColor = ImGui.GetColorU32(ImGuiCol.FrameBg); + var textColor = ImGui.GetColorU32(ImGuiCol.TextDisabled); + using var style = ImRaii.PushStyle(ImGuiStyleVar.ItemSpacing, innerSpacing) + .Push(ImGuiStyleVar.FrameBorderSize, 0); + using (var color = ImRaii.PushColor(ImGuiCol.Border, ColorId.FolderLine.Value()) + .Push(ImGuiCol.Button, buttonColor) + .Push(ImGuiCol.Text, textColor)) + { + foreach (var flag in SupportedFlags.Values) + { + if (mod.RequiredFeatures.HasFlag(flag)) + { + style.Push(ImGuiStyleVar.FrameBorderSize, ImUtf8.GlobalScale); + color.Pop(2); + if (ImUtf8.Button($"{flag}", size)) + editor.ChangeRequiredFeatures(mod, mod.RequiredFeatures & ~flag); + color.Push(ImGuiCol.Button, buttonColor) + .Push(ImGuiCol.Text, textColor); + style.Pop(); + } + else if (ImUtf8.Button($"{flag}", size)) + { + editor.ChangeRequiredFeatures(mod, mod.RequiredFeatures | flag); + } + + ImGui.SameLine(); + } + } + + if (ImUtf8.ButtonEx("Compute"u8, "Compute the required features automatically from the used features."u8, size)) + editor.ChangeRequiredFeatures(mod, mod.ComputeRequiredFeatures()); + + ImGui.SameLine(); + if (ImUtf8.ButtonEx("Clear"u8, "Clear all required features."u8, size)) + editor.ChangeRequiredFeatures(mod, FeatureFlags.None); + + ImGui.SameLine(); + ImUtf8.Text("Required Features"u8); + } +} diff --git a/Penumbra/Mods/Groups/CombiningModGroup.cs b/Penumbra/Mods/Groups/CombiningModGroup.cs new file mode 100644 index 00000000..d3f14101 --- /dev/null +++ b/Penumbra/Mods/Groups/CombiningModGroup.cs @@ -0,0 +1,197 @@ +using Dalamud.Interface.ImGuiNotification; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using OtterGui; +using OtterGui.Classes; +using OtterGui.Extensions; +using Penumbra.Api.Enums; +using Penumbra.GameData.Data; +using Penumbra.Meta.Manipulations; +using Penumbra.Mods.Settings; +using Penumbra.Mods.SubMods; +using Penumbra.String.Classes; +using Penumbra.UI.ModsTab.Groups; +using Penumbra.Util; + +namespace Penumbra.Mods.Groups; + +/// Groups that allow all available options to be selected at once. +public sealed class CombiningModGroup : IModGroup +{ + public GroupType Type + => GroupType.Combining; + + public GroupDrawBehaviour Behaviour + => GroupDrawBehaviour.MultiSelection; + + public Mod Mod { get; } + public string Name { get; set; } = "Group"; + public string Description { get; set; } = string.Empty; + public string Image { get; set; } = string.Empty; + public ModPriority Priority { get; set; } + public int Page { get; set; } + public Setting DefaultSettings { get; set; } + public readonly List OptionData = []; + public List Data { get; private set; } + + /// Groups that allow all available options to be selected at once. + public CombiningModGroup(Mod mod) + { + Mod = mod; + Data = [new CombinedDataContainer(this)]; + } + + IReadOnlyList IModGroup.Options + => OptionData; + + public IReadOnlyList DataContainers + => Data; + + public bool IsOption + => OptionData.Count > 0; + + public FullPath? FindBestMatch(Utf8GamePath gamePath) + { + foreach (var path in Data.SelectWhere(o + => (o.Files.TryGetValue(gamePath, out var file) || o.FileSwaps.TryGetValue(gamePath, out file), file))) + return path; + + return null; + } + + public IModOption? AddOption(string name, string description = "") + { + var groupIdx = Mod.Groups.IndexOf(this); + if (groupIdx < 0) + return null; + + var subMod = new CombiningSubMod(this) + { + Name = name, + Description = description, + }; + return OptionData.AddNewWithPowerSet(Data, subMod, () => new CombinedDataContainer(this), IModGroup.MaxCombiningOptions) + ? subMod + : null; + } + + public static CombiningModGroup? Load(Mod mod, JObject json) + { + var ret = new CombiningModGroup(mod, true); + if (!ModSaveGroup.ReadJsonBase(json, ret)) + return null; + + var options = json["Options"]; + if (options != null) + foreach (var child in options.Children()) + { + if (ret.OptionData.Count == IModGroup.MaxCombiningOptions) + { + Penumbra.Messager.NotificationMessage( + $"Combining Group {ret.Name} in {mod.Name} has more than {IModGroup.MaxCombiningOptions} options, ignoring excessive options.", + NotificationType.Warning); + break; + } + + var subMod = new CombiningSubMod(ret, child); + ret.OptionData.Add(subMod); + } + + var requiredContainers = 1 << ret.OptionData.Count; + var containers = json["Containers"]; + if (containers != null) + foreach (var child in containers.Children()) + { + if (requiredContainers <= ret.Data.Count) + { + Penumbra.Messager.NotificationMessage( + $"Combining Group {ret.Name} in {mod.Name} has more data containers than it can support with {ret.OptionData.Count} options, ignoring excessive containers.", + NotificationType.Warning); + break; + } + + var container = new CombinedDataContainer(ret, child); + ret.Data.Add(container); + } + + if (requiredContainers > ret.Data.Count) + { + Penumbra.Messager.NotificationMessage( + $"Combining Group {ret.Name} in {mod.Name} has not enough data containers for its {ret.OptionData.Count} options, filling with empty containers.", + NotificationType.Warning); + ret.Data.EnsureCapacity(requiredContainers); + ret.Data.AddRange(Enumerable.Repeat(0, requiredContainers - ret.Data.Count).Select(_ => new CombinedDataContainer(ret))); + } + + ret.DefaultSettings = ret.FixSetting(ret.DefaultSettings); + + return ret; + } + + public int GetIndex() + => ModGroup.GetIndex(this); + + public IModGroupEditDrawer EditDrawer(ModGroupEditDrawer editDrawer) + => new CombiningModGroupEditDrawer(editDrawer, this); + + public void AddData(Setting setting, Dictionary redirections, MetaDictionary manipulations) + => Data[setting.AsIndex].AddDataTo(redirections, manipulations); + + public void AddChangedItems(ObjectIdentification identifier, IDictionary changedItems) + { + foreach (var container in DataContainers) + identifier.AddChangedItems(container, changedItems); + } + + public void WriteJson(JsonTextWriter jWriter, JsonSerializer serializer, DirectoryInfo? basePath = null) + { + ModSaveGroup.WriteJsonBase(jWriter, this); + jWriter.WritePropertyName("Options"); + jWriter.WriteStartArray(); + foreach (var option in OptionData) + { + jWriter.WriteStartObject(); + SubMod.WriteModOption(jWriter, option); + jWriter.WriteEndObject(); + } + + jWriter.WriteEndArray(); + + jWriter.WritePropertyName("Containers"); + jWriter.WriteStartArray(); + foreach (var container in Data) + { + jWriter.WriteStartObject(); + if (container.Name.Length > 0) + { + jWriter.WritePropertyName("Name"); + jWriter.WriteValue(container.Name); + } + + SubMod.WriteModContainer(jWriter, serializer, container, basePath ?? Mod.ModPath); + jWriter.WriteEndObject(); + } + + jWriter.WriteEndArray(); + } + + public (int Redirections, int Swaps, int Manips) GetCounts() + => ModGroup.GetCountsBase(this); + + public Setting FixSetting(Setting setting) + => new(Math.Min(setting.Value, (ulong)(Data.Count - 1))); + + /// Create a group without a mod only for saving it in the creator. + internal static CombiningModGroup WithoutMod(string name) + => new(null!) + { + Name = name, + }; + + /// For loading when no empty container should be created. + private CombiningModGroup(Mod mod, bool _) + { + Mod = mod; + Data = []; + } +} diff --git a/Penumbra/Mods/Groups/ComplexModGroup.cs b/Penumbra/Mods/Groups/ComplexModGroup.cs new file mode 100644 index 00000000..435bc253 --- /dev/null +++ b/Penumbra/Mods/Groups/ComplexModGroup.cs @@ -0,0 +1,180 @@ +using Dalamud.Interface.ImGuiNotification; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using OtterGui.Classes; +using OtterGui.Extensions; +using Penumbra.Api.Enums; +using Penumbra.GameData.Data; +using Penumbra.Meta.Manipulations; +using Penumbra.Mods.Settings; +using Penumbra.Mods.SubMods; +using Penumbra.String.Classes; +using Penumbra.UI.ModsTab.Groups; +using Penumbra.Util; + +namespace Penumbra.Mods.Groups; + +public sealed class ComplexModGroup(Mod mod) : IModGroup +{ + public Mod Mod { get; } = mod; + public string Name { get; set; } = "Option"; + public string Description { get; set; } = string.Empty; + public string Image { get; set; } = string.Empty; + + public GroupType Type + => GroupType.Complex; + + public GroupDrawBehaviour Behaviour + => GroupDrawBehaviour.Complex; + + public ModPriority Priority { get; set; } + public int Page { get; set; } + public Setting DefaultSettings { get; set; } + + public readonly List Options = []; + public readonly List Containers = []; + + + public FullPath? FindBestMatch(Utf8GamePath gamePath) + => throw new NotImplementedException(); + + public IModOption? AddOption(string name, string description = "") + => throw new NotImplementedException(); + + IReadOnlyList IModGroup.Options + => Options; + + IReadOnlyList IModGroup.DataContainers + => Containers; + + public bool IsOption + => Options.Count > 0; + + public int GetIndex() + => ModGroup.GetIndex(this); + + public IModGroupEditDrawer EditDrawer(ModGroupEditDrawer editDrawer) + => throw new NotImplementedException(); + + public void AddData(Setting setting, Dictionary redirections, MetaDictionary manipulations) + { + foreach (var container in Containers.Where(c => c.Association.IsEnabled(setting))) + SubMod.AddContainerTo(container, redirections, manipulations); + } + + public void AddChangedItems(ObjectIdentification identifier, IDictionary changedItems) + { + foreach (var container in Containers) + identifier.AddChangedItems(container, changedItems); + } + + public Setting FixSetting(Setting setting) + => new(setting.Value & ((1ul << Options.Count) - 1)); + + public void WriteJson(JsonTextWriter jWriter, JsonSerializer serializer, DirectoryInfo? basePath = null) + { + ModSaveGroup.WriteJsonBase(jWriter, this); + jWriter.WritePropertyName("Options"); + jWriter.WriteStartArray(); + foreach (var option in Options) + { + jWriter.WriteStartObject(); + SubMod.WriteModOption(jWriter, option); + if (!option.Conditions.IsZero) + { + jWriter.WritePropertyName("ConditionMask"); + jWriter.WriteValue(option.Conditions.Mask.Value); + jWriter.WritePropertyName("ConditionValue"); + jWriter.WriteValue(option.Conditions.Value.Value); + } + + if (option.Indentation > 0) + { + jWriter.WritePropertyName("Indentation"); + jWriter.WriteValue(option.Indentation); + } + + if (option.SubGroupLabel.Length > 0) + { + jWriter.WritePropertyName("SubGroup"); + jWriter.WriteValue(option.SubGroupLabel); + } + + jWriter.WriteEndObject(); + } + + jWriter.WriteEndArray(); + + jWriter.WritePropertyName("Containers"); + jWriter.WriteStartArray(); + foreach (var container in Containers) + { + jWriter.WriteStartObject(); + if (container.Name.Length > 0) + { + jWriter.WritePropertyName("Name"); + jWriter.WriteValue(container.Name); + } + + if (!container.Association.IsZero) + { + jWriter.WritePropertyName("AssociationMask"); + jWriter.WriteValue(container.Association.Mask.Value); + + jWriter.WritePropertyName("AssociationValue"); + jWriter.WriteValue(container.Association.Value.Value); + } + + SubMod.WriteModContainer(jWriter, serializer, container, basePath ?? Mod.ModPath); + jWriter.WriteEndObject(); + } + + jWriter.WriteEndArray(); + } + + public (int Redirections, int Swaps, int Manips) GetCounts() + => ModGroup.GetCountsBase(this); + + public static ComplexModGroup? Load(Mod mod, JObject json) + { + var ret = new ComplexModGroup(mod); + if (!ModSaveGroup.ReadJsonBase(json, ret)) + return null; + + var options = json["Options"]; + if (options != null) + foreach (var child in options.Children()) + { + if (ret.Options.Count == IModGroup.MaxComplexOptions) + { + Penumbra.Messager.NotificationMessage( + $"Complex Group {ret.Name} in {mod.Name} has more than {IModGroup.MaxComplexOptions} options, ignoring excessive options.", + NotificationType.Warning); + break; + } + + var subMod = new ComplexSubMod(ret, child); + ret.Options.Add(subMod); + } + + // Fix up conditions: No condition on itself. + foreach (var (option, index) in ret.Options.WithIndex()) + { + option.Conditions = option.Conditions.Limit(ret.Options.Count); + option.Conditions = new MaskedSetting(option.Conditions.Mask.SetBit(index, false), option.Conditions.Value); + } + + var containers = json["Containers"]; + if (containers != null) + foreach (var child in containers.Children()) + { + var container = new ComplexDataContainer(ret, child); + container.Association = container.Association.Limit(ret.Options.Count); + ret.Containers.Add(container); + } + + ret.DefaultSettings = ret.FixSetting(ret.DefaultSettings); + + return ret; + } +} diff --git a/Penumbra/Mods/Groups/IModGroup.cs b/Penumbra/Mods/Groups/IModGroup.cs new file mode 100644 index 00000000..98f62862 --- /dev/null +++ b/Penumbra/Mods/Groups/IModGroup.cs @@ -0,0 +1,66 @@ +using Newtonsoft.Json; +using Penumbra.Api.Enums; +using Penumbra.GameData.Data; +using Penumbra.Meta.Manipulations; +using Penumbra.Mods.Settings; +using Penumbra.Mods.SubMods; +using Penumbra.String.Classes; +using Penumbra.UI.ModsTab.Groups; + +namespace Penumbra.Mods.Groups; + +public interface ITexToolsGroup +{ + public IReadOnlyList OptionData { get; } +} + +public enum GroupDrawBehaviour +{ + SingleSelection, + MultiSelection, + Complex, +} + +public interface IModGroup +{ + public const int MaxMultiOptions = 32; + public const int MaxComplexOptions = MaxMultiOptions; + public const int MaxCombiningOptions = 8; + + public Mod Mod { get; } + public string Name { get; set; } + public string Description { get; set; } + + /// Unused in Penumbra but for better TexTools interop. + public string Image { get; set; } + + public GroupType Type { get; } + public GroupDrawBehaviour Behaviour { get; } + public ModPriority Priority { get; set; } + + /// Unused in Penumbra but for better TexTools interop. + public int Page { get; set; } + + public Setting DefaultSettings { get; set; } + + public FullPath? FindBestMatch(Utf8GamePath gamePath); + public IModOption? AddOption(string name, string description = ""); + + public IReadOnlyList Options { get; } + public IReadOnlyList DataContainers { get; } + public bool IsOption { get; } + + public int GetIndex(); + + public IModGroupEditDrawer EditDrawer(ModGroupEditDrawer editDrawer); + + public void AddData(Setting setting, Dictionary redirections, MetaDictionary manipulations); + public void AddChangedItems(ObjectIdentification identifier, IDictionary changedItems); + + /// Ensure that a value is valid for a group. + public Setting FixSetting(Setting setting); + + public void WriteJson(JsonTextWriter jWriter, JsonSerializer serializer, DirectoryInfo? basePath = null); + + public (int Redirections, int Swaps, int Manips) GetCounts(); +} diff --git a/Penumbra/Mods/Groups/ImcModGroup.cs b/Penumbra/Mods/Groups/ImcModGroup.cs new file mode 100644 index 00000000..34174f7f --- /dev/null +++ b/Penumbra/Mods/Groups/ImcModGroup.cs @@ -0,0 +1,258 @@ +using Dalamud.Interface.ImGuiNotification; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using OtterGui.Classes; +using OtterGui.Extensions; +using Penumbra.Api.Enums; +using Penumbra.GameData.Data; +using Penumbra.GameData.Structs; +using Penumbra.Meta; +using Penumbra.Meta.Manipulations; +using Penumbra.Mods.Settings; +using Penumbra.Mods.SubMods; +using Penumbra.String.Classes; +using Penumbra.UI.ModsTab.Groups; + +namespace Penumbra.Mods.Groups; + +public class ImcModGroup(Mod mod) : IModGroup +{ + public Mod Mod { get; } = mod; + public string Name { get; set; } = "Option"; + public string Description { get; set; } = string.Empty; + public string Image { get; set; } = string.Empty; + + public GroupType Type + => GroupType.Imc; + + public GroupDrawBehaviour Behaviour + => GroupDrawBehaviour.MultiSelection; + + public ModPriority Priority { get; set; } = ModPriority.Default; + public int Page { get; set; } + public Setting DefaultSettings { get; set; } = Setting.Zero; + + public ImcIdentifier Identifier; + public ImcEntry DefaultEntry; + public bool AllVariants; + public bool OnlyAttributes; + + + public FullPath? FindBestMatch(Utf8GamePath gamePath) + => null; + + private bool _canBeDisabled; + + public bool CanBeDisabled + { + get => _canBeDisabled; + set + { + _canBeDisabled = value; + if (!value) + { + OptionData.RemoveAll(m => m.IsDisableSubMod); + DefaultSettings = FixSetting(DefaultSettings); + } + else + { + if (!OptionData.Any(m => m.IsDisableSubMod)) + OptionData.Add(ImcSubMod.DisableSubMod(this)); + } + } + } + + public bool DefaultDisabled + => IsDisabled(DefaultSettings); + + public IModOption? AddOption(string name, string description = "") + { + var groupIdx = Mod.Groups.IndexOf(this); + if (groupIdx < 0) + return null; + + var subMod = new ImcSubMod(this) + { + Name = name, + Description = description, + AttributeMask = 0, + }; + OptionData.Add(subMod); + return subMod; + } + + public readonly List OptionData = []; + + public IReadOnlyList Options + => OptionData; + + public IReadOnlyList DataContainers + => []; + + public bool IsOption + => OptionData.Count > 0; + + public int GetIndex() + => ModGroup.GetIndex(this); + + public IModGroupEditDrawer EditDrawer(ModGroupEditDrawer editDrawer) + => new ImcModGroupEditDrawer(editDrawer, this); + + private ImcEntry GetEntry(Variant variant, ushort mask) + { + if (!OnlyAttributes) + return DefaultEntry with { AttributeMask = mask }; + + var defaultEntry = ImcChecker.GetDefaultEntry(Identifier with { Variant = variant }, true); + if (defaultEntry.VariantExists) + return defaultEntry.Entry with { AttributeMask = mask }; + + return DefaultEntry with { AttributeMask = mask }; + } + + public void AddData(Setting setting, Dictionary redirections, MetaDictionary manipulations) + { + if (IsDisabled(setting)) + return; + + var mask = GetCurrentMask(setting); + if (AllVariants) + { + var count = ImcChecker.GetVariantCount(Identifier); + if (count == 0) + manipulations.TryAdd(Identifier, GetEntry(Identifier.Variant, mask)); + else + for (var i = 0; i <= count; ++i) + manipulations.TryAdd(Identifier with { Variant = (Variant)i }, GetEntry((Variant)i, mask)); + } + else + { + manipulations.TryAdd(Identifier, GetEntry(Identifier.Variant, mask)); + } + } + + public void AddChangedItems(ObjectIdentification identifier, IDictionary changedItems) + => Identifier.AddChangedItems(identifier, changedItems, AllVariants); + + public Setting FixSetting(Setting setting) + => new(setting.Value & ((1ul << OptionData.Count) - 1)); + + public void WriteJson(JsonTextWriter jWriter, JsonSerializer serializer, DirectoryInfo? basePath = null) + { + ModSaveGroup.WriteJsonBase(jWriter, this); + var jObj = Identifier.AddToJson(new JObject()); + jWriter.WritePropertyName(nameof(Identifier)); + jObj.WriteTo(jWriter); + jWriter.WritePropertyName(nameof(DefaultEntry)); + serializer.Serialize(jWriter, DefaultEntry); + jWriter.WritePropertyName(nameof(AllVariants)); + jWriter.WriteValue(AllVariants); + jWriter.WritePropertyName(nameof(OnlyAttributes)); + jWriter.WriteValue(OnlyAttributes); + jWriter.WritePropertyName("Options"); + jWriter.WriteStartArray(); + foreach (var option in OptionData) + { + jWriter.WriteStartObject(); + SubMod.WriteModOption(jWriter, option); + if (option.IsDisableSubMod) + { + jWriter.WritePropertyName(nameof(option.IsDisableSubMod)); + jWriter.WriteValue(true); + } + else + { + jWriter.WritePropertyName(nameof(option.AttributeMask)); + jWriter.WriteValue(option.AttributeMask); + } + + jWriter.WriteEndObject(); + } + + jWriter.WriteEndArray(); + } + + public (int Redirections, int Swaps, int Manips) GetCounts() + => (0, 0, 1); + + public static ImcModGroup? Load(Mod mod, JObject json) + { + var options = json["Options"]; + var identifier = ImcIdentifier.FromJson(json[nameof(Identifier)] as JObject); + var ret = new ImcModGroup(mod) + { + DefaultEntry = json[nameof(DefaultEntry)]?.ToObject() ?? new ImcEntry(), + AllVariants = json[nameof(AllVariants)]?.ToObject() ?? false, + OnlyAttributes = json[nameof(OnlyAttributes)]?.ToObject() ?? false, + }; + if (!ModSaveGroup.ReadJsonBase(json, ret)) + return null; + + if (!identifier.HasValue || ret.DefaultEntry.MaterialId == 0) + { + Penumbra.Messager.NotificationMessage($"Could not add IMC group {ret.Name} because the associated IMC Entry is invalid.", + NotificationType.Warning); + return null; + } + + var rollingMask = 0ul; + if (options != null) + foreach (var child in options.Children()) + { + var subMod = new ImcSubMod(ret, child); + + if (subMod.IsDisableSubMod) + ret._canBeDisabled = true; + + if (subMod.IsDisableSubMod && ret.OptionData.FirstOrDefault(m => m.IsDisableSubMod) is { } disable) + { + Penumbra.Messager.NotificationMessage( + $"Could not add IMC option {subMod.Name} to {ret.Name} because it already contains {disable.Name} as disable option.", + NotificationType.Warning); + } + else if ((subMod.AttributeMask & rollingMask) != 0) + { + Penumbra.Messager.NotificationMessage( + $"Could not add IMC option {subMod.Name} to {ret.Name} because it contains attributes already in use.", + NotificationType.Warning); + } + else + { + rollingMask |= subMod.AttributeMask; + ret.OptionData.Add(subMod); + } + } + + ret.Identifier = identifier.Value; + ret.DefaultSettings = ret.FixSetting(ret.DefaultSettings); + return ret; + } + + private bool IsDisabled(Setting setting) + { + if (!CanBeDisabled) + return false; + + var idx = OptionData.IndexOf(m => m.IsDisableSubMod); + if (idx >= 0) + return setting.HasFlag(idx); + + Penumbra.Log.Warning("A IMC Group should be able to be disabled, but does not contain a disable option."); + return false; + } + + private ushort GetCurrentMask(Setting setting) + { + var mask = DefaultEntry.AttributeMask; + for (var i = 0; i < OptionData.Count; ++i) + { + if (!setting.HasFlag(i)) + continue; + + var option = OptionData[i]; + mask ^= option.AttributeMask; + } + + return mask; + } +} diff --git a/Penumbra/Mods/Groups/ModGroup.cs b/Penumbra/Mods/Groups/ModGroup.cs new file mode 100644 index 00000000..8b55a035 --- /dev/null +++ b/Penumbra/Mods/Groups/ModGroup.cs @@ -0,0 +1,57 @@ +using Penumbra.Api.Enums; +using Penumbra.Mods.Settings; + +namespace Penumbra.Mods.Groups; + +public static class ModGroup +{ + /// Create a new mod group based on the given type. + public static IModGroup Create(Mod mod, GroupType type, string name) + { + var maxPriority = mod.Groups.Count == 0 ? ModPriority.Default : mod.Groups.Max(o => o.Priority) + 1; + return type switch + { + GroupType.Single => new SingleModGroup(mod) + { + Name = name, + Priority = maxPriority, + }, + GroupType.Multi => new MultiModGroup(mod) + { + Name = name, + Priority = maxPriority, + }, + GroupType.Imc => new ImcModGroup(mod) + { + Name = name, + Priority = maxPriority, + }, + _ => throw new ArgumentOutOfRangeException(nameof(type), type, null), + }; + } + + + public static (int Redirections, int Swaps, int Manips) GetCountsBase(IModGroup group) + { + var redirectionCount = 0; + var swapCount = 0; + var manipCount = 0; + foreach (var option in group.DataContainers) + { + redirectionCount += option.Files.Count; + swapCount += option.FileSwaps.Count; + manipCount += option.Manipulations.Count; + } + + return (redirectionCount, swapCount, manipCount); + } + + public static int GetIndex(IModGroup group) + { + var groupIndex = group.Mod.Groups.IndexOf(group); + if (groupIndex < 0) + throw new Exception($"Mod {group.Mod.Name} from Group {group.Name} does not contain this group."); + + return groupIndex; + } +} diff --git a/Penumbra/Mods/Groups/ModSaveGroup.cs b/Penumbra/Mods/Groups/ModSaveGroup.cs new file mode 100644 index 00000000..bda70b54 --- /dev/null +++ b/Penumbra/Mods/Groups/ModSaveGroup.cs @@ -0,0 +1,117 @@ +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using Penumbra.GameData.Files.ShaderStructs; +using Penumbra.Mods.Settings; +using Penumbra.Mods.SubMods; +using Penumbra.Services; + +namespace Penumbra.Mods.Groups; + +public readonly struct ModSaveGroup : ISavable +{ + public const int CurrentVersion = 0; + + private readonly DirectoryInfo _basePath; + private readonly IModGroup? _group; + private readonly int _groupIdx; + private readonly DefaultSubMod? _defaultMod; + private readonly bool _onlyAscii; + + private ModSaveGroup(DirectoryInfo basePath, IModGroup group, int groupIndex, bool onlyAscii) + { + _basePath = basePath; + _group = group; + _groupIdx = groupIndex; + _onlyAscii = onlyAscii; + } + + public static ModSaveGroup WithoutMod(DirectoryInfo basePath, IModGroup group, int groupIndex, bool onlyAscii) + => new(basePath, group, groupIndex, onlyAscii); + + public ModSaveGroup(IModGroup group, bool onlyAscii) + : this(group.Mod.ModPath, group, group.GetIndex(), onlyAscii) + { } + + public ModSaveGroup(DirectoryInfo basePath, DefaultSubMod @default, bool onlyAscii) + { + _basePath = basePath; + _groupIdx = -1; + _defaultMod = @default; + _onlyAscii = onlyAscii; + } + + public ModSaveGroup(DirectoryInfo basePath, IModDataContainer container, bool onlyAscii) + { + _basePath = basePath; + _defaultMod = container as DefaultSubMod; + _onlyAscii = onlyAscii; + if (_defaultMod != null) + { + _groupIdx = -1; + _group = null; + } + else + { + _group = container.Group!; + _groupIdx = _group.GetIndex(); + } + } + + public ModSaveGroup(IModDataContainer container, bool onlyAscii) + { + _basePath = (container.Mod as Mod)?.ModPath + ?? throw new Exception("Invalid save group from default data container without base path."); // Should not happen. + _defaultMod = container as DefaultSubMod; + _onlyAscii = onlyAscii; + _group = container.Group; + _groupIdx = _group?.GetIndex() ?? -1; + } + + public string ToFilename(FilenameService fileNames) + => fileNames.OptionGroupFile(_basePath.FullName, _groupIdx, _group?.Name ?? string.Empty, _onlyAscii); + + public void Save(StreamWriter writer) + { + using var j = new JsonTextWriter(writer); + j.Formatting = Formatting.Indented; + var serializer = new JsonSerializer { Formatting = Formatting.Indented }; + j.WriteStartObject(); + j.WritePropertyName("Version"); + j.WriteValue(CurrentVersion); + if (_groupIdx >= 0) + _group!.WriteJson(j, serializer, _basePath); + else + SubMod.WriteModContainer(j, serializer, _defaultMod!, _basePath); + j.WriteEndObject(); + } + + public static void WriteJsonBase(JsonTextWriter jWriter, IModGroup group) + { + jWriter.WritePropertyName(nameof(group.Name)); + jWriter.WriteValue(group.Name); + jWriter.WritePropertyName(nameof(group.Description)); + jWriter.WriteValue(group.Description); + jWriter.WritePropertyName(nameof(group.Image)); + jWriter.WriteValue(group.Image); + jWriter.WritePropertyName(nameof(group.Page)); + jWriter.WriteValue(group.Page); + jWriter.WritePropertyName(nameof(group.Priority)); + jWriter.WriteValue(group.Priority.Value); + jWriter.WritePropertyName(nameof(group.Type)); + jWriter.WriteValue(group.Type.ToString()); + jWriter.WritePropertyName(nameof(group.DefaultSettings)); + jWriter.WriteValue(group.DefaultSettings.Value); + } + + public static bool ReadJsonBase(JObject json, IModGroup group) + { + group.Name = json[nameof(IModGroup.Name)]?.ToObject() ?? string.Empty; + group.Description = json[nameof(IModGroup.Description)]?.ToObject() ?? string.Empty; + group.Image = json[nameof(IModGroup.Image)]?.ToObject() ?? string.Empty; + group.Page = json[nameof(IModGroup.Page)]?.ToObject() ?? 0; + group.Priority = json[nameof(IModGroup.Priority)]?.ToObject() ?? ModPriority.Default; + group.DefaultSettings = json[nameof(IModGroup.DefaultSettings)]?.ToObject() ?? Setting.Zero; + + return group.Name.Length > 0; + } +} diff --git a/Penumbra/Mods/Groups/MultiModGroup.cs b/Penumbra/Mods/Groups/MultiModGroup.cs new file mode 100644 index 00000000..558ee6be --- /dev/null +++ b/Penumbra/Mods/Groups/MultiModGroup.cs @@ -0,0 +1,164 @@ +using Dalamud.Interface.ImGuiNotification; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using OtterGui.Classes; +using OtterGui.Extensions; +using Penumbra.Api.Enums; +using Penumbra.GameData.Data; +using Penumbra.Meta.Manipulations; +using Penumbra.Mods.Settings; +using Penumbra.Mods.SubMods; +using Penumbra.String.Classes; +using Penumbra.UI.ModsTab.Groups; +using Penumbra.Util; + +namespace Penumbra.Mods.Groups; + +/// Groups that allow all available options to be selected at once. +public sealed class MultiModGroup(Mod mod) : IModGroup, ITexToolsGroup +{ + public GroupType Type + => GroupType.Multi; + + public GroupDrawBehaviour Behaviour + => GroupDrawBehaviour.MultiSelection; + + public Mod Mod { get; } = mod; + public string Name { get; set; } = "Group"; + public string Description { get; set; } = string.Empty; + public string Image { get; set; } = string.Empty; + public ModPriority Priority { get; set; } + public int Page { get; set; } + public Setting DefaultSettings { get; set; } + public readonly List OptionData = []; + + public IReadOnlyList Options + => OptionData; + + public IReadOnlyList DataContainers + => OptionData; + + public bool IsOption + => OptionData.Count > 0; + + public FullPath? FindBestMatch(Utf8GamePath gamePath) + { + foreach (var path in OptionData.OrderByDescending(o => o.Priority) + .SelectWhere(o => (o.Files.TryGetValue(gamePath, out var file) || o.FileSwaps.TryGetValue(gamePath, out file), file))) + return path; + + return null; + } + + public IModOption? AddOption(string name, string description = "") + { + var groupIdx = Mod.Groups.IndexOf(this); + if (groupIdx < 0) + return null; + + var subMod = new MultiSubMod(this) + { + Name = name, + Description = description, + }; + OptionData.Add(subMod); + return subMod; + } + + public static MultiModGroup? Load(Mod mod, JObject json) + { + var ret = new MultiModGroup(mod); + if (!ModSaveGroup.ReadJsonBase(json, ret)) + return null; + + var options = json["Options"]; + if (options != null) + foreach (var child in options.Children()) + { + if (ret.OptionData.Count == IModGroup.MaxMultiOptions) + { + Penumbra.Messager.NotificationMessage( + $"Multi Group {ret.Name} in {mod.Name} has more than {IModGroup.MaxMultiOptions} options, ignoring excessive options.", + NotificationType.Warning); + break; + } + + var subMod = new MultiSubMod(ret, child); + ret.OptionData.Add(subMod); + } + + ret.DefaultSettings = ret.FixSetting(ret.DefaultSettings); + + return ret; + } + + public SingleModGroup ConvertToSingle() + { + var single = new SingleModGroup(Mod) + { + Name = Name, + Description = Description, + Priority = Priority, + Image = Image, + Page = Page, + DefaultSettings = DefaultSettings.TurnMulti(OptionData.Count), + }; + single.OptionData.AddRange(OptionData.Select(o => o.ConvertToSingle(single))); + return single; + } + + public int GetIndex() + => ModGroup.GetIndex(this); + + public IModGroupEditDrawer EditDrawer(ModGroupEditDrawer editDrawer) + => new MultiModGroupEditDrawer(editDrawer, this); + + public void AddData(Setting setting, Dictionary redirections, MetaDictionary manipulations) + { + foreach (var (option, index) in OptionData.WithIndex().OrderByDescending(o => o.Value.Priority)) + { + if (setting.HasFlag(index)) + option.AddDataTo(redirections, manipulations); + } + } + + public void AddChangedItems(ObjectIdentification identifier, IDictionary changedItems) + { + foreach (var container in DataContainers) + identifier.AddChangedItems(container, changedItems); + } + + public void WriteJson(JsonTextWriter jWriter, JsonSerializer serializer, DirectoryInfo? basePath = null) + { + ModSaveGroup.WriteJsonBase(jWriter, this); + jWriter.WritePropertyName("Options"); + jWriter.WriteStartArray(); + foreach (var option in OptionData) + { + jWriter.WriteStartObject(); + SubMod.WriteModOption(jWriter, option); + jWriter.WritePropertyName(nameof(option.Priority)); + jWriter.WriteValue(option.Priority.Value); + SubMod.WriteModContainer(jWriter, serializer, option, basePath ?? Mod.ModPath); + jWriter.WriteEndObject(); + } + + jWriter.WriteEndArray(); + } + + public (int Redirections, int Swaps, int Manips) GetCounts() + => ModGroup.GetCountsBase(this); + + public Setting FixSetting(Setting setting) + => new(setting.Value & ((1ul << OptionData.Count) - 1)); + + /// Create a group without a mod only for saving it in the creator. + internal static MultiModGroup WithoutMod(string name) + => new(null!) + { + Name = name, + }; + + IReadOnlyList ITexToolsGroup.OptionData + => OptionData; +} diff --git a/Penumbra/Mods/Groups/SingleModGroup.cs b/Penumbra/Mods/Groups/SingleModGroup.cs new file mode 100644 index 00000000..f376c1c9 --- /dev/null +++ b/Penumbra/Mods/Groups/SingleModGroup.cs @@ -0,0 +1,147 @@ +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using OtterGui.Extensions; +using Penumbra.Api.Enums; +using Penumbra.GameData.Data; +using Penumbra.Meta.Manipulations; +using Penumbra.Mods.Settings; +using Penumbra.Mods.SubMods; +using Penumbra.String.Classes; +using Penumbra.UI.ModsTab.Groups; +using Penumbra.Util; + +namespace Penumbra.Mods.Groups; + +/// Groups that allow only one of their available options to be selected. +public sealed class SingleModGroup(Mod mod) : IModGroup, ITexToolsGroup +{ + public GroupType Type + => GroupType.Single; + + public GroupDrawBehaviour Behaviour + => GroupDrawBehaviour.SingleSelection; + + public Mod Mod { get; } = mod; + public string Name { get; set; } = "Option"; + public string Description { get; set; } = string.Empty; + public string Image { get; set; } = string.Empty; + public ModPriority Priority { get; set; } + public int Page { get; set; } + public Setting DefaultSettings { get; set; } + + public readonly List OptionData = []; + + public FullPath? FindBestMatch(Utf8GamePath gamePath) + { + foreach (var path in OptionData + .SelectWhere(m => (m.Files.TryGetValue(gamePath, out var file) || m.FileSwaps.TryGetValue(gamePath, out file), file))) + return path; + + return null; + } + + public IModOption AddOption(string name, string description = "") + { + var subMod = new SingleSubMod(this) + { + Name = name, + Description = description, + }; + OptionData.Add(subMod); + return subMod; + } + + public IReadOnlyList Options + => OptionData; + + public IReadOnlyList DataContainers + => OptionData; + + public bool IsOption + => OptionData.Count > 1; + + public static SingleModGroup? Load(Mod mod, JObject json) + { + var options = json["Options"]; + var ret = new SingleModGroup(mod); + if (!ModSaveGroup.ReadJsonBase(json, ret)) + return null; + + if (options != null) + foreach (var child in options.Children()) + { + var subMod = new SingleSubMod(ret, child); + ret.OptionData.Add(subMod); + } + + ret.DefaultSettings = ret.FixSetting(ret.DefaultSettings); + return ret; + } + + public MultiModGroup ConvertToMulti() + { + var multi = new MultiModGroup(Mod) + { + Name = Name, + Description = Description, + Priority = Priority, + Image = Image, + Page = Page, + DefaultSettings = Setting.Multi((int)DefaultSettings.Value), + }; + multi.OptionData.AddRange(OptionData.Select((o, i) => o.ConvertToMulti(multi, new ModPriority(i)))); + return multi; + } + + public int GetIndex() + => ModGroup.GetIndex(this); + + public IModGroupEditDrawer EditDrawer(ModGroupEditDrawer editDrawer) + => new SingleModGroupEditDrawer(editDrawer, this); + + public void AddData(Setting setting, Dictionary redirections, MetaDictionary manipulations) + { + if (OptionData.Count == 0) + return; + + OptionData[setting.AsIndex].AddDataTo(redirections, manipulations); + } + + public void AddChangedItems(ObjectIdentification identifier, IDictionary changedItems) + { + foreach (var container in DataContainers) + identifier.AddChangedItems(container, changedItems); + } + + public Setting FixSetting(Setting setting) + => OptionData.Count == 0 ? Setting.Zero : new Setting(Math.Min(setting.Value, (ulong)(OptionData.Count - 1))); + + public (int Redirections, int Swaps, int Manips) GetCounts() + => ModGroup.GetCountsBase(this); + + public void WriteJson(JsonTextWriter jWriter, JsonSerializer serializer, DirectoryInfo? basePath = null) + { + ModSaveGroup.WriteJsonBase(jWriter, this); + jWriter.WritePropertyName("Options"); + jWriter.WriteStartArray(); + foreach (var option in OptionData) + { + jWriter.WriteStartObject(); + SubMod.WriteModOption(jWriter, option); + SubMod.WriteModContainer(jWriter, serializer, option, basePath ?? Mod.ModPath); + jWriter.WriteEndObject(); + } + + jWriter.WriteEndArray(); + } + + /// Create a group without a mod only for saving it in the creator. + internal static SingleModGroup CreateForSaving(string name) + => new(null!) + { + Name = name, + }; + + IReadOnlyList ITexToolsGroup.OptionData + => OptionData; +} diff --git a/Penumbra/Mods/ItemSwap/CustomizationSwap.cs b/Penumbra/Mods/ItemSwap/CustomizationSwap.cs new file mode 100644 index 00000000..c5406f66 --- /dev/null +++ b/Penumbra/Mods/ItemSwap/CustomizationSwap.cs @@ -0,0 +1,104 @@ +using Penumbra.Api.Enums; +using Penumbra.GameData.Data; +using Penumbra.GameData.Enums; +using Penumbra.GameData.Files; +using Penumbra.GameData.Structs; +using Penumbra.Meta; +using Penumbra.String.Classes; + +namespace Penumbra.Mods.ItemSwap; + +public static class CustomizationSwap +{ + /// The .mdl file for customizations is unique per racecode, slot and id, thus the .mdl redirection itself is independent of the mode. + public static FileSwap CreateMdl(MetaFileManager manager, Func redirections, BodySlot slot, GenderRace race, + PrimaryId idFrom, PrimaryId idTo) + { + if (idFrom.Id > byte.MaxValue) + throw new Exception($"The Customization ID {idFrom} is too large for {slot}."); + + var mdlPathFrom = GamePaths.Mdl.Customization(race, slot, idFrom, slot.ToCustomizationType()); + var mdlPathTo = GamePaths.Mdl.Customization(race, slot, idTo, slot.ToCustomizationType()); + + var mdl = FileSwap.CreateSwap(manager, ResourceType.Mdl, redirections, mdlPathFrom, mdlPathTo); + var range = slot == BodySlot.Tail + && race is GenderRace.HrothgarMale or GenderRace.HrothgarFemale or GenderRace.HrothgarMaleNpc or GenderRace.HrothgarMaleNpc + ? 5 + : 1; + + foreach (ref var materialFileName in mdl.AsMdl()!.Materials.AsSpan()) + { + var name = materialFileName; + foreach (var variant in Enumerable.Range(1, range)) + { + name = materialFileName; + var mtrl = CreateMtrl(manager, redirections, slot, race, idFrom, idTo, (byte)variant, ref name, ref mdl.DataWasChanged); + mdl.ChildSwaps.Add(mtrl); + } + + materialFileName = name; + } + + return mdl; + } + + public static FileSwap CreateMtrl(MetaFileManager manager, Func redirections, BodySlot slot, GenderRace race, + PrimaryId idFrom, PrimaryId idTo, byte variant, + ref string fileName, ref bool dataWasChanged) + { + variant = slot is BodySlot.Face or BodySlot.Ear ? Variant.None.Id : variant; + var mtrlFromPath = GamePaths.Mtrl.Customization(race, slot, idFrom, fileName, out var gameRaceFrom, out var gameSetIdFrom, variant); + var mtrlToPath = GamePaths.Mtrl.Customization(race, slot, idTo, fileName, out var gameRaceTo, out var gameSetIdTo, variant); + + var newFileName = fileName; + newFileName = ItemSwap.ReplaceRace(newFileName, gameRaceTo, race, gameRaceTo != race); + newFileName = ItemSwap.ReplaceBody(newFileName, slot, idTo, idFrom, idFrom != idTo); + newFileName = ItemSwap.AddSuffix(newFileName, ".mtrl", $"_c{race.ToRaceCode()}", + gameRaceFrom != race || MaterialHandling.IsSpecialCase(race, idFrom)); + newFileName = ItemSwap.AddSuffix(newFileName, ".mtrl", $"_{slot.ToAbbreviation()}{idFrom.Id:D4}", gameSetIdFrom != idFrom); + + var actualMtrlFromPath = mtrlFromPath; + if (newFileName != fileName) + { + actualMtrlFromPath = GamePaths.Mtrl.Customization(race, slot, idFrom, newFileName, out _, out _, variant); + fileName = newFileName; + dataWasChanged = true; + } + + var mtrl = FileSwap.CreateSwap(manager, ResourceType.Mtrl, redirections, actualMtrlFromPath, mtrlToPath, actualMtrlFromPath); + var shpk = CreateShader(manager, redirections, ref mtrl.AsMtrl()!.ShaderPackage.Name, ref mtrl.DataWasChanged); + mtrl.ChildSwaps.Add(shpk); + + foreach (ref var texture in mtrl.AsMtrl()!.Textures.AsSpan()) + { + var tex = CreateTex(manager, redirections, slot, race, idFrom, ref texture, ref mtrl.DataWasChanged); + mtrl.ChildSwaps.Add(tex); + } + + return mtrl; + } + + public static FileSwap CreateTex(MetaFileManager manager, Func redirections, BodySlot slot, GenderRace race, + PrimaryId idFrom, ref MtrlFile.Texture texture, ref bool dataWasChanged) + { + var addedDashes = GamePaths.Tex.HandleDx11Path(texture, out var path); + var newPath = ItemSwap.ReplaceAnyRace(path, race); + newPath = ItemSwap.ReplaceAnyBody(newPath, slot, idFrom); + newPath = ItemSwap.AddSuffix(newPath, ".tex", $"_{Path.GetFileName(texture.Path).GetStableHashCode():x8}", true); + if (newPath != path) + { + texture.Path = addedDashes ? newPath.Replace("--", string.Empty) : newPath; + dataWasChanged = true; + } + + return FileSwap.CreateSwap(manager, ResourceType.Tex, redirections, newPath, path, path); + } + + + public static FileSwap CreateShader(MetaFileManager manager, Func redirections, ref string shaderName, + ref bool dataWasChanged) + { + var path = $"shader/sm5/shpk/{shaderName}"; + return FileSwap.CreateSwap(manager, ResourceType.Shpk, redirections, path, path); + } +} diff --git a/Penumbra/Mods/ItemSwap/EquipmentSwap.cs b/Penumbra/Mods/ItemSwap/EquipmentSwap.cs new file mode 100644 index 00000000..216b5841 --- /dev/null +++ b/Penumbra/Mods/ItemSwap/EquipmentSwap.cs @@ -0,0 +1,513 @@ +using Penumbra.Api.Enums; +using Penumbra.GameData.Data; +using Penumbra.GameData.Enums; +using Penumbra.GameData.Files; +using Penumbra.GameData.Structs; +using Penumbra.Interop.Structs; +using Penumbra.Meta; +using Penumbra.Meta.Files; +using Penumbra.Meta.Manipulations; +using Penumbra.String.Classes; + +namespace Penumbra.Mods.ItemSwap; + +public static class EquipmentSwap +{ + private static EquipSlot[] ConvertSlots(EquipSlot slot, bool rFinger, bool lFinger) + { + if (slot != EquipSlot.RFinger) + return [slot]; + + return rFinger + ? lFinger + ? [EquipSlot.RFinger, EquipSlot.LFinger] + : [EquipSlot.RFinger] + : lFinger + ? [EquipSlot.LFinger] + : []; + } + + public static HashSet CreateTypeSwap(MetaFileManager manager, ObjectIdentification identifier, List swaps, + Func redirections, MetaDictionary manips, + EquipSlot slotFrom, EquipItem itemFrom, EquipSlot slotTo, EquipItem itemTo) + { + LookupItem(itemFrom, out var actualSlotFrom, out var idFrom, out var variantFrom); + LookupItem(itemTo, out var actualSlotTo, out var idTo, out var variantTo); + if (actualSlotFrom != slotFrom.ToSlot() || actualSlotTo != slotTo.ToSlot()) + throw new ItemSwap.InvalidItemTypeException(); + + var (imcFileFrom, variants, affectedItems) = GetVariants(manager, identifier, slotFrom, idFrom, idTo, variantFrom); + var imcIdentifierTo = new ImcIdentifier(slotTo, idTo, variantTo); + var imcFileTo = new ImcFile(manager, imcIdentifierTo); + var imcEntry = manips.TryGetValue(imcIdentifierTo, out var entry) + ? entry + : imcFileTo.GetEntry(imcIdentifierTo.EquipSlot, imcIdentifierTo.Variant); + var mtrlVariantTo = imcEntry.MaterialId; + var skipFemale = false; + var skipMale = false; + foreach (var gr in Enum.GetValues()) + { + switch (gr.Split().Item1) + { + case Gender.Male when skipMale: continue; + case Gender.Female when skipFemale: continue; + case Gender.MaleNpc when skipMale: continue; + case Gender.FemaleNpc when skipFemale: continue; + } + + if (CharacterUtilityData.EqdpIdx(gr, true) < 0) + continue; + + try + { + var eqdp = CreateEqdp(manager, redirections, manips, slotFrom, slotTo, gr, idFrom, idTo, mtrlVariantTo); + if (eqdp != null) + swaps.Add(eqdp); + } + catch (ItemSwap.MissingFileException e) + { + switch (gr) + { + case GenderRace.MidlanderMale when e.Type == ResourceType.Mdl: + skipMale = true; + continue; + case GenderRace.MidlanderFemale when e.Type == ResourceType.Mdl: + skipFemale = true; + continue; + default: throw; + } + } + } + + foreach (var variant in variants) + { + var imc = CreateImc(manager, redirections, manips, slotFrom, slotTo, idFrom, idTo, variant, variantTo, imcFileFrom, imcFileTo); + swaps.Add(imc); + } + + return affectedItems; + } + + public static HashSet CreateItemSwap(MetaFileManager manager, ObjectIdentification identifier, List swaps, + Func redirections, MetaDictionary manips, EquipItem itemFrom, + EquipItem itemTo, bool rFinger = true, bool lFinger = true) + { + // Check actual ids, variants and slots. We only support using the same slot. + LookupItem(itemFrom, out var slotFrom, out var idFrom, out var variantFrom); + LookupItem(itemTo, out var slotTo, out var idTo, out var variantTo); + if (slotFrom != slotTo) + throw new ItemSwap.InvalidItemTypeException(); + + HashSet affectedItems = []; + var eqp = CreateEqp(manager, manips, slotFrom, idFrom, idTo); + if (eqp != null) + { + swaps.Add(eqp); + // Add items affected through multi-slot EQP edits. + foreach (var child in eqp.ChildSwaps.SelectMany(c => c.WithChildren()).OfType>()) + { + affectedItems.UnionWith(identifier + .Identify(GamePaths.Mdl.Equipment(idFrom, GenderRace.MidlanderMale, child.SwapFromIdentifier.Slot)) + .Select(kvp => kvp.Value).OfType().Select(i => i.Item)); + } + } + + var gmp = CreateGmp(manager, manips, slotFrom, idFrom, idTo); + if (gmp != null) + swaps.Add(gmp); + + foreach (var slot in ConvertSlots(slotFrom, rFinger, lFinger)) + { + var (imcFileFrom, variants, affectedItemsLocal) = GetVariants(manager, identifier, slot, idFrom, idTo, variantFrom); + affectedItems.UnionWith(affectedItemsLocal); + var imcIdentifierTo = new ImcIdentifier(slotTo, idTo, variantTo); + var imcFileTo = new ImcFile(manager, imcIdentifierTo); + var imcEntry = manips.TryGetValue(imcIdentifierTo, out var entry) + ? entry + : imcFileTo.GetEntry(imcIdentifierTo.EquipSlot, imcIdentifierTo.Variant); + var mtrlVariantTo = imcEntry.MaterialId; + + var isAccessory = slot.IsAccessory(); + var estType = slot switch + { + EquipSlot.Head => EstType.Head, + EquipSlot.Body => EstType.Body, + _ => (EstType)0, + }; + + var skipFemale = false; + var skipMale = false; + foreach (var gr in Enum.GetValues()) + { + switch (gr.Split().Item1) + { + case Gender.Male when skipMale: continue; + case Gender.Female when skipFemale: continue; + case Gender.MaleNpc when skipMale: continue; + case Gender.FemaleNpc when skipFemale: continue; + } + + if (CharacterUtilityData.EqdpIdx(gr, isAccessory) < 0) + continue; + + + try + { + var eqdp = CreateEqdp(manager, redirections, manips, slot, gr, idFrom, idTo, mtrlVariantTo); + if (eqdp != null) + swaps.Add(eqdp); + + var ownMdl = eqdp?.SwapToModdedEntry.Model ?? false; + var est = ItemSwap.CreateEst(manager, redirections, manips, estType, gr, idFrom, idTo, ownMdl); + if (est != null) + swaps.Add(est); + } + catch (ItemSwap.MissingFileException e) + { + switch (gr) + { + case GenderRace.MidlanderMale when e.Type == ResourceType.Mdl: + skipMale = true; + continue; + case GenderRace.MidlanderFemale when e.Type == ResourceType.Mdl: + skipFemale = true; + continue; + default: throw; + } + } + } + + foreach (var variant in variants) + { + var imc = CreateImc(manager, redirections, manips, slot, idFrom, idTo, variant, variantTo, imcFileFrom, imcFileTo); + swaps.Add(imc); + } + } + + return affectedItems; + } + + public static MetaSwap? CreateEqdp(MetaFileManager manager, Func redirections, + MetaDictionary manips, EquipSlot slot, GenderRace gr, PrimaryId idFrom, PrimaryId idTo, byte mtrlTo) + => CreateEqdp(manager, redirections, manips, slot, slot, gr, idFrom, idTo, mtrlTo); + + public static MetaSwap? CreateEqdp(MetaFileManager manager, Func redirections, + MetaDictionary manips, EquipSlot slotFrom, EquipSlot slotTo, GenderRace gr, PrimaryId idFrom, + PrimaryId idTo, byte mtrlTo) + { + var eqdpFromIdentifier = new EqdpIdentifier(idFrom, slotFrom, gr); + var eqdpToIdentifier = new EqdpIdentifier(idTo, slotTo, gr); + var eqdpFromDefault = new EqdpEntryInternal(ExpandedEqdpFile.GetDefault(manager, eqdpFromIdentifier), slotFrom); + var eqdpToDefault = new EqdpEntryInternal(ExpandedEqdpFile.GetDefault(manager, eqdpToIdentifier), slotTo); + var meta = new MetaSwap(i => manips.TryGetValue(i, out var e) ? e : null, eqdpFromIdentifier, + eqdpFromDefault, eqdpToIdentifier, + eqdpToDefault); + var (ownMtrl, ownMdl) = meta.SwapToModdedEntry; + if (ownMdl) + { + var mdl = CreateMdl(manager, redirections, slotFrom, slotTo, gr, idFrom, idTo, mtrlTo); + meta.ChildSwaps.Add(mdl); + } + else if (!ownMtrl && meta.SwapAppliedIsDefault) + { + meta = null; + } + + return meta; + } + + public static FileSwap CreateMdl(MetaFileManager manager, Func redirections, EquipSlot slot, GenderRace gr, + PrimaryId idFrom, PrimaryId idTo, byte mtrlTo) + => CreateMdl(manager, redirections, slot, slot, gr, idFrom, idTo, mtrlTo); + + public static FileSwap CreateMdl(MetaFileManager manager, Func redirections, EquipSlot slotFrom, EquipSlot slotTo, + GenderRace gr, PrimaryId idFrom, PrimaryId idTo, byte mtrlTo) + { + var mdlPathFrom = GamePaths.Mdl.Gear(idFrom, gr, slotFrom); + var mdlPathTo = GamePaths.Mdl.Gear(idTo, gr, slotTo); + var mdl = FileSwap.CreateSwap(manager, ResourceType.Mdl, redirections, mdlPathFrom, mdlPathTo); + + foreach (ref var fileName in mdl.AsMdl()!.Materials.AsSpan()) + { + var mtrl = CreateMtrl(manager, redirections, slotFrom, slotTo, idFrom, idTo, mtrlTo, ref fileName, ref mdl.DataWasChanged); + if (mtrl != null) + mdl.ChildSwaps.Add(mtrl); + } + + FixAttributes(mdl, slotFrom, slotTo); + + return mdl; + } + + private static void FixAttributes(FileSwap swap, EquipSlot slotFrom, EquipSlot slotTo) + { + if (slotFrom == slotTo) + return; + + var needle = slotTo switch + { + EquipSlot.Head => "atr_mv_", + EquipSlot.Ears => "atr_ev_", + EquipSlot.Neck => "atr_nv_", + EquipSlot.Wrists => "atr_wv_", + EquipSlot.RFinger or EquipSlot.LFinger => "atr_rv_", + _ => string.Empty, + }; + + var replacement = slotFrom switch + { + EquipSlot.Head => 'm', + EquipSlot.Ears => 'e', + EquipSlot.Neck => 'n', + EquipSlot.Wrists => 'w', + EquipSlot.RFinger or EquipSlot.LFinger => 'r', + _ => 'm', + }; + + var attributes = swap.AsMdl()!.Attributes; + for (var i = 0; i < attributes.Length; ++i) + { + if (FixAttribute(ref attributes[i], needle, replacement)) + swap.DataWasChanged = true; + } + } + + private static unsafe bool FixAttribute(ref string attribute, string from, char to) + { + if (!attribute.StartsWith(from) || attribute.Length != from.Length + 1 || attribute[^1] is < 'a' or > 'j') + return false; + + Span stack = stackalloc char[attribute.Length]; + attribute.CopyTo(stack); + stack[4] = to; + attribute = new string(stack); + return true; + } + + private static void LookupItem(EquipItem i, out EquipSlot slot, out PrimaryId modelId, out Variant variant) + { + slot = i.Type.ToSlot(); + if (!slot.IsEquipmentPiece()) + throw new ItemSwap.InvalidItemTypeException(); + + modelId = i.PrimaryId; + variant = i.Variant; + } + + private static (ImcFile, Variant[], HashSet) GetVariants(MetaFileManager manager, ObjectIdentification identifier, + EquipSlot slotFrom, + PrimaryId idFrom, PrimaryId idTo, Variant variantFrom) + { + var ident = new ImcIdentifier(slotFrom, idFrom, variantFrom); + var imc = new ImcFile(manager, ident); + HashSet items; + Variant[] variants; + if (idFrom == idTo) + { + items = identifier.Identify(idFrom, 0, variantFrom, slotFrom).ToHashSet(); + variants = [variantFrom]; + } + else + { + items = identifier.Identify(GamePaths.Mdl.Gear(idFrom, GenderRace.MidlanderMale, slotFrom)) + .Select(kvp => kvp.Value).OfType().Select(i => i.Item) + .ToHashSet(); + variants = Enumerable.Range(0, imc.Count + 1).Select(i => (Variant)i).ToArray(); + } + + return (imc, variants, items); + } + + public static MetaSwap? CreateGmp(MetaFileManager manager, MetaDictionary manips, + EquipSlot slot, PrimaryId idFrom, PrimaryId idTo) + { + if (slot is not EquipSlot.Head) + return null; + + var manipFromIdentifier = new GmpIdentifier(idFrom); + var manipToIdentifier = new GmpIdentifier(idTo); + var manipFromDefault = ExpandedGmpFile.GetDefault(manager, manipFromIdentifier); + var manipToDefault = ExpandedGmpFile.GetDefault(manager, manipToIdentifier); + return new MetaSwap(i => manips.TryGetValue(i, out var e) ? e : null, manipFromIdentifier, manipFromDefault, + manipToIdentifier, manipToDefault); + } + + public static MetaSwap CreateImc(MetaFileManager manager, Func redirections, + MetaDictionary manips, EquipSlot slot, PrimaryId idFrom, PrimaryId idTo, Variant variantFrom, Variant variantTo, + ImcFile imcFileFrom, ImcFile imcFileTo) + => CreateImc(manager, redirections, manips, slot, slot, idFrom, idTo, variantFrom, variantTo, imcFileFrom, imcFileTo); + + public static MetaSwap CreateImc(MetaFileManager manager, Func redirections, + MetaDictionary manips, EquipSlot slotFrom, EquipSlot slotTo, PrimaryId idFrom, PrimaryId idTo, + Variant variantFrom, Variant variantTo, ImcFile imcFileFrom, ImcFile imcFileTo) + { + var manipFromIdentifier = new ImcIdentifier(slotFrom, idFrom, variantFrom); + var manipToIdentifier = new ImcIdentifier(slotTo, idTo, variantTo); + var manipFromDefault = imcFileFrom.GetEntry(ImcFile.PartIndex(slotFrom), variantFrom); + var manipToDefault = imcFileTo.GetEntry(ImcFile.PartIndex(slotTo), variantTo); + var imc = new MetaSwap(i => manips.TryGetValue(i, out var e) ? e : null, manipFromIdentifier, manipFromDefault, + manipToIdentifier, manipToDefault); + + var decal = CreateDecal(manager, redirections, imc.SwapToModdedEntry.DecalId); + if (decal != null) + imc.ChildSwaps.Add(decal); + + var avfx = CreateAvfx(manager, redirections, slotFrom, slotTo, idFrom, idTo, imc.SwapToModdedEntry.VfxId); + if (avfx != null) + imc.ChildSwaps.Add(avfx); + + // IMC also controls sound, Example: Dodore Doublet, but unknown what it does? + // IMC also controls some material animation, Example: The Howling Spirit and The Wailing Spirit, but unknown what it does. + return imc; + } + + // Example: Crimson Standard Bracelet + public static FileSwap? CreateDecal(MetaFileManager manager, Func redirections, byte decalId) + { + if (decalId == 0) + return null; + + var decalPath = GamePaths.Tex.EquipDecal(decalId); + return FileSwap.CreateSwap(manager, ResourceType.Tex, redirections, decalPath, decalPath); + } + + + // Example: Abyssos Helm / Body + public static FileSwap? CreateAvfx(MetaFileManager manager, Func redirections, EquipSlot slotFrom, EquipSlot slotTo, + PrimaryId idFrom, PrimaryId idTo, + byte vfxId) + { + if (vfxId == 0) + return null; + + var vfxPathFrom = GamePaths.Avfx.Path(slotFrom, idFrom, vfxId); + vfxPathFrom = ItemSwap.ReplaceType(vfxPathFrom, slotFrom, slotTo, idFrom); + var vfxPathTo = GamePaths.Avfx.Path(slotTo, idTo, vfxId); + var avfx = FileSwap.CreateSwap(manager, ResourceType.Avfx, redirections, vfxPathFrom, vfxPathTo); + + foreach (ref var filePath in avfx.AsAvfx()!.Textures.AsSpan()) + { + var atex = CreateAtex(manager, redirections, slotFrom, slotTo, idFrom, ref filePath, ref avfx.DataWasChanged); + avfx.ChildSwaps.Add(atex); + } + + return avfx; + } + + public static MetaSwap? CreateEqp(MetaFileManager manager, MetaDictionary manips, EquipSlot slot, + PrimaryId idFrom, PrimaryId idTo) + { + if (slot.IsAccessory()) + return null; + + var manipFromIdentifier = new EqpIdentifier(idFrom, slot); + var manipToIdentifier = new EqpIdentifier(idTo, slot); + var manipFromDefault = new EqpEntryInternal(ExpandedEqpFile.GetDefault(manager, idFrom), slot); + var manipToDefault = new EqpEntryInternal(ExpandedEqpFile.GetDefault(manager, idTo), slot); + var swap = new MetaSwap(i => manips.TryGetValue(i, out var e) ? e : null, manipFromIdentifier, + manipFromDefault, manipToIdentifier, manipToDefault); + var entry = swap.SwapToModdedEntry.ToEntry(slot); + // Add additional EQP entries if the swapped item is a multi-slot item, + // because those take the EQP entries of their other model-set slots when used. + switch (slot) + { + case EquipSlot.Body: + if (!entry.HasFlag(EqpEntry.BodyShowLeg) + && CreateEqp(manager, manips, EquipSlot.Legs, idFrom, idTo) is { } legChild) + swap.ChildSwaps.Add(legChild); + if (!entry.HasFlag(EqpEntry.BodyShowHead) + && CreateEqp(manager, manips, EquipSlot.Head, idFrom, idTo) is { } headChild) + swap.ChildSwaps.Add(headChild); + if (!entry.HasFlag(EqpEntry.BodyShowHand) + && CreateEqp(manager, manips, EquipSlot.Hands, idFrom, idTo) is { } handChild) + swap.ChildSwaps.Add(handChild); + break; + case EquipSlot.Legs: + if (!entry.HasFlag(EqpEntry.LegsShowFoot) + && CreateEqp(manager, manips, EquipSlot.Feet, idFrom, idTo) is { } footChild) + swap.ChildSwaps.Add(footChild); + break; + } + + return swap; + } + + public static FileSwap? CreateMtrl(MetaFileManager manager, Func redirections, EquipSlot slot, PrimaryId idFrom, + PrimaryId idTo, byte variantTo, ref string fileName, + ref bool dataWasChanged) + => CreateMtrl(manager, redirections, slot, slot, idFrom, idTo, variantTo, ref fileName, ref dataWasChanged); + + public static FileSwap? CreateMtrl(MetaFileManager manager, Func redirections, EquipSlot slotFrom, EquipSlot slotTo, + PrimaryId idFrom, PrimaryId idTo, byte variantTo, ref string fileName, + ref bool dataWasChanged) + { + var prefix = slotTo.IsAccessory() ? 'a' : 'e'; + if (!fileName.Contains($"{prefix}{idTo.Id:D4}")) + return null; + + var folderTo = GamePaths.Mtrl.GearFolder(slotTo, idTo, variantTo); + var pathTo = $"{folderTo}{fileName}"; + + var folderFrom = GamePaths.Mtrl.GearFolder(slotFrom, idFrom, variantTo); + var newFileName = ItemSwap.ReplaceId(fileName, prefix, idTo, idFrom); + newFileName = ItemSwap.ReplaceSlot(newFileName, slotTo, slotFrom, slotTo != slotFrom); + var pathFrom = $"{folderFrom}{newFileName}"; + + if (newFileName != fileName) + { + fileName = newFileName; + dataWasChanged = true; + } + + var mtrl = FileSwap.CreateSwap(manager, ResourceType.Mtrl, redirections, pathFrom, pathTo); + var shpk = CreateShader(manager, redirections, ref mtrl.AsMtrl()!.ShaderPackage.Name, ref mtrl.DataWasChanged); + mtrl.ChildSwaps.Add(shpk); + + foreach (ref var texture in mtrl.AsMtrl()!.Textures.AsSpan()) + { + var tex = CreateTex(manager, redirections, prefix, slotFrom, slotTo, idFrom, idTo, ref texture, ref mtrl.DataWasChanged); + mtrl.ChildSwaps.Add(tex); + } + + return mtrl; + } + + public static FileSwap CreateTex(MetaFileManager manager, Func redirections, char prefix, PrimaryId idFrom, + PrimaryId idTo, ref MtrlFile.Texture texture, ref bool dataWasChanged) + => CreateTex(manager, redirections, prefix, EquipSlot.Unknown, EquipSlot.Unknown, idFrom, idTo, ref texture, ref dataWasChanged); + + public static FileSwap CreateTex(MetaFileManager manager, Func redirections, char prefix, EquipSlot slotFrom, + EquipSlot slotTo, PrimaryId idFrom, PrimaryId idTo, ref MtrlFile.Texture texture, ref bool dataWasChanged) + { + var addedDashes = GamePaths.Tex.HandleDx11Path(texture, out var path); + var newPath = ItemSwap.ReplaceAnyId(path, prefix, idFrom); + newPath = ItemSwap.ReplaceSlot(newPath, slotTo, slotFrom, slotTo != slotFrom); + newPath = ItemSwap.ReplaceType(newPath, slotFrom, slotTo, idFrom); + newPath = ItemSwap.AddSuffix(newPath, ".tex", $"_{Path.GetFileName(texture.Path).GetStableHashCode():x8}"); + if (newPath != path) + { + texture.Path = addedDashes ? newPath.Replace("--", string.Empty) : newPath; + dataWasChanged = true; + } + + return FileSwap.CreateSwap(manager, ResourceType.Tex, redirections, newPath, path, path); + } + + public static FileSwap CreateShader(MetaFileManager manager, Func redirections, ref string shaderName, + ref bool dataWasChanged) + { + var path = GamePaths.Shader(shaderName); + return FileSwap.CreateSwap(manager, ResourceType.Shpk, redirections, path, path); + } + + public static FileSwap CreateAtex(MetaFileManager manager, Func redirections, EquipSlot slotFrom, EquipSlot slotTo, + PrimaryId idFrom, ref string filePath, ref bool dataWasChanged) + { + var oldPath = filePath; + filePath = ItemSwap.AddSuffix(filePath, ".atex", $"_{Path.GetFileName(filePath).GetStableHashCode():x8}"); + filePath = ItemSwap.ReplaceType(filePath, slotFrom, slotTo, idFrom); + dataWasChanged = true; + + return FileSwap.CreateSwap(manager, ResourceType.Atex, redirections, filePath, oldPath, oldPath); + } +} diff --git a/Penumbra/Mods/ItemSwap/ItemSwap.cs b/Penumbra/Mods/ItemSwap/ItemSwap.cs new file mode 100644 index 00000000..0049fa12 --- /dev/null +++ b/Penumbra/Mods/ItemSwap/ItemSwap.cs @@ -0,0 +1,241 @@ +using Penumbra.Api.Enums; +using Penumbra.GameData.Data; +using Penumbra.GameData.Enums; +using Penumbra.GameData.Files; +using Penumbra.GameData.Structs; +using Penumbra.Meta; +using Penumbra.Meta.Files; +using Penumbra.Meta.Manipulations; +using Penumbra.String.Classes; + +namespace Penumbra.Mods.ItemSwap; + +public static class ItemSwap +{ + public class InvalidItemTypeException : Exception + { } + + public class MissingFileException(ResourceType type, object path) : Exception($"Could not load {type} File Data for \"{path}\".") + { + public readonly ResourceType Type = type; + } + + private static bool LoadFile(MetaFileManager manager, FullPath path, out byte[] data) + { + if (path.FullName.Length > 0) + try + { + if (path.IsRooted) + { + data = File.ReadAllBytes(path.FullName); + return true; + } + + var file = manager.GameData.GetFile(path.InternalName.ToString()); + if (file != null) + { + data = file.Data; + return true; + } + } + catch (Exception e) + { + Penumbra.Log.Debug($"Could not load file {path}:\n{e}"); + } + + data = []; + return false; + } + + public class GenericFile : IWritable + { + public readonly byte[] Data; + public bool Valid { get; } + + public GenericFile(MetaFileManager manager, FullPath path) + => Valid = LoadFile(manager, path, out Data); + + public byte[] Write() + => Data; + + public static readonly GenericFile Invalid = new(null!, FullPath.Empty); + } + + public static bool LoadFile(MetaFileManager manager, FullPath path, [NotNullWhen(true)] out GenericFile? file) + { + file = new GenericFile(manager, path); + if (file.Valid) + return true; + + file = null; + return false; + } + + public static bool LoadMdl(MetaFileManager manager, FullPath path, [NotNullWhen(true)] out MdlFile? file) + { + try + { + if (LoadFile(manager, path, out byte[] data)) + { + file = new MdlFile(data); + return true; + } + } + catch (Exception e) + { + Penumbra.Log.Debug($"Could not parse file {path} to Mdl:\n{e}"); + } + + file = null; + return false; + } + + public static bool LoadMtrl(MetaFileManager manager, FullPath path, [NotNullWhen(true)] out MtrlFile? file) + { + try + { + if (LoadFile(manager, path, out byte[] data)) + { + file = new MtrlFile(data); + return true; + } + } + catch (Exception e) + { + Penumbra.Log.Debug($"Could not parse file {path} to Mtrl:\n{e}"); + } + + file = null; + return false; + } + + public static bool LoadAvfx(MetaFileManager manager, FullPath path, [NotNullWhen(true)] out AvfxFile? file) + { + try + { + if (LoadFile(manager, path, out byte[] data)) + { + file = new AvfxFile(data); + return true; + } + } + catch (Exception e) + { + Penumbra.Log.Debug($"Could not parse file {path} to Avfx:\n{e}"); + } + + file = null; + return false; + } + + + public static FileSwap CreatePhyb(MetaFileManager manager, Func redirections, EstType type, + GenderRace race, EstEntry estEntry) + { + var phybPath = GamePaths.Phyb.Customization(race, type.ToName(), estEntry.AsId); + return FileSwap.CreateSwap(manager, ResourceType.Phyb, redirections, phybPath, phybPath); + } + + public static FileSwap CreateSklb(MetaFileManager manager, Func redirections, EstType type, + GenderRace race, EstEntry estEntry) + { + var sklbPath = GamePaths.Sklb.Customization(race, type.ToName(), estEntry.AsId); + return FileSwap.CreateSwap(manager, ResourceType.Sklb, redirections, sklbPath, sklbPath); + } + + public static MetaSwap? CreateEst(MetaFileManager manager, Func redirections, + MetaDictionary manips, EstType type, GenderRace genderRace, PrimaryId idFrom, PrimaryId idTo, bool ownMdl) + { + if (type == 0) + return null; + + var manipFromIdentifier = new EstIdentifier(idFrom, type, genderRace); + var manipToIdentifier = new EstIdentifier(idTo, type, genderRace); + var manipFromDefault = EstFile.GetDefault(manager, manipFromIdentifier); + var manipToDefault = EstFile.GetDefault(manager, manipToIdentifier); + var est = new MetaSwap(i => manips.TryGetValue(i, out var e) ? e : null, manipFromIdentifier, manipFromDefault, + manipToIdentifier, manipToDefault); + + if (ownMdl && est.SwapToModdedEntry.Value >= 2) + { + var phyb = CreatePhyb(manager, redirections, type, genderRace, est.SwapToModdedEntry); + est.ChildSwaps.Add(phyb); + var sklb = CreateSklb(manager, redirections, type, genderRace, est.SwapToModdedEntry); + est.ChildSwaps.Add(sklb); + } + else if (est.SwapAppliedIsDefault) + { + return null; + } + + return est; + } + + public static int GetStableHashCode(this string str) + { + unchecked + { + var hash1 = 5381; + var hash2 = hash1; + + for (var i = 0; i < str.Length && str[i] != '\0'; i += 2) + { + hash1 = ((hash1 << 5) + hash1) ^ str[i]; + if (i == str.Length - 1 || str[i + 1] == '\0') + break; + + hash2 = ((hash2 << 5) + hash2) ^ str[i + 1]; + } + + return hash1 + hash2 * 1566083941; + } + } + + public static string ReplaceAnyId(string path, char idType, PrimaryId id, bool condition = true) + => condition + ? Regex.Replace(path, $"{idType}\\d{{4}}", $"{idType}{id.Id:D4}") + : path; + + public static string ReplaceAnyRace(string path, GenderRace to, bool condition = true) + => ReplaceAnyId(path, 'c', (ushort)to, condition); + + public static string ReplaceAnyBody(string path, BodySlot slot, PrimaryId to, bool condition = true) + => ReplaceAnyId(path, slot.ToAbbreviation(), to, condition); + + public static string ReplaceId(string path, char type, PrimaryId idFrom, PrimaryId idTo, bool condition = true) + => condition + ? path.Replace($"{type}{idFrom.Id:D4}", $"{type}{idTo.Id:D4}") + : path; + + public static string ReplaceSlot(string path, EquipSlot from, EquipSlot to, bool condition = true) + => condition + ? path.Replace($"_{from.ToSuffix()}_", $"_{to.ToSuffix()}_") + : path; + + public static string ReplaceType(string path, EquipSlot from, EquipSlot to, PrimaryId idFrom) + { + var isAccessoryFrom = from.IsAccessory(); + if (isAccessoryFrom == to.IsAccessory()) + return path; + + if (isAccessoryFrom) + { + path = path.Replace("accessory/a", "equipment/e"); + return path.Replace($"a{idFrom.Id:D4}", $"e{idFrom.Id:D4}"); + } + + path = path.Replace("equipment/e", "accessory/a"); + return path.Replace($"e{idFrom.Id:D4}", $"a{idFrom.Id:D4}"); + } + + public static string ReplaceRace(string path, GenderRace from, GenderRace to, bool condition = true) + => ReplaceId(path, 'c', (ushort)from, (ushort)to, condition); + + public static string ReplaceBody(string path, BodySlot slot, PrimaryId idFrom, PrimaryId idTo, bool condition = true) + => ReplaceId(path, slot.ToAbbreviation(), idFrom, idTo, condition); + + public static string AddSuffix(string path, string ext, string suffix, bool condition = true) + => condition + ? path.Replace(ext, suffix + ext) + : path; +} diff --git a/Penumbra/Mods/ItemSwap/ItemSwapContainer.cs b/Penumbra/Mods/ItemSwap/ItemSwapContainer.cs new file mode 100644 index 00000000..a9d5e0d6 --- /dev/null +++ b/Penumbra/Mods/ItemSwap/ItemSwapContainer.cs @@ -0,0 +1,173 @@ +using OtterGui.Classes; +using Penumbra.Collections; +using Penumbra.GameData.Data; +using Penumbra.GameData.Enums; +using Penumbra.GameData.Structs; +using Penumbra.Meta.Manipulations; +using Penumbra.String.Classes; +using Penumbra.Meta; +using Penumbra.Mods.Editor; +using Penumbra.Mods.Manager; +using Penumbra.Mods.Settings; +using Penumbra.Mods.SubMods; + +namespace Penumbra.Mods.ItemSwap; + +public class ItemSwapContainer +{ + private readonly MetaFileManager _manager; + private readonly ObjectIdentification _identifier; + + private AppliedModData _appliedModData = AppliedModData.Empty; + + public IReadOnlyDictionary ModRedirections + => _appliedModData.FileRedirections; + + public MetaDictionary ModManipulations + => _appliedModData.Manipulations; + + public readonly List Swaps = []; + + public bool Loaded { get; private set; } + + public void Clear() + { + Swaps.Clear(); + Loaded = false; + } + + public enum WriteType + { + UseSwaps, + NoSwaps, + } + + public bool WriteMod(ModManager manager, Mod mod, IModDataContainer container, WriteType writeType = WriteType.NoSwaps, + DirectoryInfo? directory = null) + { + var convertedManips = new MetaDictionary(); + var convertedFiles = new Dictionary(Swaps.Count); + var convertedSwaps = new Dictionary(Swaps.Count); + directory ??= mod.ModPath; + try + { + foreach (var swap in Swaps.SelectMany(s => s.WithChildren())) + { + if (swap is FileSwap file) + { + // Skip, nothing to do + if (file.SwapToModdedEqualsOriginal) + continue; + + if (writeType == WriteType.UseSwaps && file.SwapToModdedExistsInGame && !file.DataWasChanged) + { + convertedSwaps.TryAdd(file.SwapFromRequestPath, file.SwapToModded); + } + else + { + var path = file.GetNewPath(directory.FullName); + var bytes = file.FileData.Write(); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + _manager.Compactor.WriteAllBytes(path, bytes); + convertedFiles.TryAdd(file.SwapFromRequestPath, new FullPath(path)); + } + } + else if (swap is IMetaSwap { SwapAppliedIsDefault: false }) + { + // @formatter:off + _ = swap switch + { + MetaSwap meta => convertedManips.TryAdd(meta.SwapFromIdentifier, meta.SwapToModdedEntry), + MetaSwap meta => convertedManips.TryAdd(meta.SwapFromIdentifier, meta.SwapToModdedEntry), + MetaSwap meta => convertedManips.TryAdd(meta.SwapFromIdentifier, meta.SwapToModdedEntry), + MetaSwapmeta => convertedManips.TryAdd(meta.SwapFromIdentifier, meta.SwapToModdedEntry), + MetaSwapmeta => convertedManips.TryAdd(meta.SwapFromIdentifier, meta.SwapToModdedEntry), + _ => false, + }; + // @formatter:on + } + } + + manager.OptionEditor.SetFiles(container, convertedFiles, SaveType.None); + manager.OptionEditor.SetFileSwaps(container, convertedSwaps, SaveType.None); + manager.OptionEditor.SetManipulations(container, convertedManips, SaveType.None); + manager.OptionEditor.ForceSave(container, SaveType.ImmediateSync); + return true; + } + catch (Exception e) + { + Penumbra.Log.Error($"Could not write FileSwapContainer to {mod.ModPath}:\n{e}"); + return false; + } + } + + public void LoadMod(Mod? mod, ModSettings? settings) + { + Clear(); + if (mod == null || mod.Index < 0) + _appliedModData = AppliedModData.Empty; + else + _appliedModData = ModSettings.GetResolveData(mod, settings); + } + + public ItemSwapContainer(MetaFileManager manager, ObjectIdentification identifier) + { + _manager = manager; + _identifier = identifier; + LoadMod(null, null); + } + + private Func PathResolver(ModCollection? collection) + => collection != null + ? p => collection.ResolvePath(p) ?? new FullPath(p) + : p => ModRedirections.TryGetValue(p, out var path) ? path : new FullPath(p); + + private MetaDictionary MetaResolver(ModCollection? collection) + => collection?.MetaCache is { } cache + ? new MetaDictionary(cache) + : _appliedModData.Manipulations; + + public HashSet LoadEquipment(EquipItem from, EquipItem to, ModCollection? collection = null, bool useRightRing = true, + bool useLeftRing = true) + { + Swaps.Clear(); + Loaded = false; + var ret = EquipmentSwap.CreateItemSwap(_manager, _identifier, Swaps, PathResolver(collection), MetaResolver(collection), + from, to, useRightRing, useLeftRing); + Loaded = true; + return ret; + } + + public HashSet LoadTypeSwap(EquipSlot slotFrom, EquipItem from, EquipSlot slotTo, EquipItem to, ModCollection? collection = null) + { + Swaps.Clear(); + Loaded = false; + var ret = EquipmentSwap.CreateTypeSwap(_manager, _identifier, Swaps, PathResolver(collection), MetaResolver(collection), + slotFrom, from, slotTo, to); + Loaded = true; + return ret; + } + + public bool LoadCustomization(MetaFileManager manager, BodySlot slot, GenderRace race, PrimaryId from, PrimaryId to, + ModCollection? collection = null) + { + var pathResolver = PathResolver(collection); + var mdl = CustomizationSwap.CreateMdl(manager, pathResolver, slot, race, from, to); + var type = slot switch + { + BodySlot.Hair => EstType.Hair, + BodySlot.Face => EstType.Face, + _ => (EstType)0, + }; + + var estResolver = MetaResolver(collection); + var est = ItemSwap.CreateEst(manager, pathResolver, estResolver, type, race, from, to, true); + + Swaps.Add(mdl); + if (est != null) + Swaps.Add(est); + + Loaded = true; + return true; + } +} diff --git a/Penumbra/Mods/ItemSwap/Swaps.cs b/Penumbra/Mods/ItemSwap/Swaps.cs new file mode 100644 index 00000000..36c54203 --- /dev/null +++ b/Penumbra/Mods/ItemSwap/Swaps.cs @@ -0,0 +1,184 @@ +using Penumbra.Api.Enums; +using Penumbra.GameData.Files; +using Penumbra.Meta.Manipulations; +using Penumbra.String.Classes; +using Penumbra.Meta; +using static Penumbra.Mods.ItemSwap.ItemSwap; + +namespace Penumbra.Mods.ItemSwap; + +public class Swap +{ + /// Any further swaps belonging specifically to this tree of changes. + public readonly List ChildSwaps = []; + + public IEnumerable WithChildren() + => ChildSwaps.SelectMany(c => c.WithChildren()).Prepend(this); +} + +public interface IMetaSwap +{ + public IMetaIdentifier SwapFromIdentifier { get; } + public IMetaIdentifier SwapToIdentifier { get; } + + public object SwapFromDefaultEntry { get; } + public object SwapToDefaultEntry { get; } + public object SwapToModdedEntry { get; } + + public bool SwapToIsDefault { get; } + public bool SwapAppliedIsDefault { get; } +} + +public sealed class MetaSwap : Swap, IMetaSwap + where TIdentifier : unmanaged, IMetaIdentifier + where TEntry : unmanaged, IEquatable +{ + public TIdentifier SwapFromIdentifier; + public TIdentifier SwapToIdentifier; + + /// The default value of a specific meta manipulation that needs to be redirected. + public TEntry SwapFromDefaultEntry; + + /// The default value of the same Meta entry of the redirected item. + public TEntry SwapToDefaultEntry; + + /// The modded value of the same Meta entry of the redirected item, or the same as SwapToDefault if unmodded. + public TEntry SwapToModdedEntry; + + /// Whether SwapToModdedEntry equals SwapToDefaultEntry. + public bool SwapToIsDefault { get; } + + /// Whether the applied meta manipulation does not change anything against the default. + public bool SwapAppliedIsDefault { get; } + + /// + /// Create a new MetaSwap from the original meta identifier and the target meta identifier. + /// + /// A function that obtains a modded meta entry if it exists. + /// The original meta identifier. + /// The default value for the original meta identifier. + /// The target meta identifier. + /// The default value for the target meta identifier. + public MetaSwap(Func manipulations, TIdentifier manipFromIdentifier, TEntry manipFromEntry, + TIdentifier manipToIdentifier, TEntry manipToEntry) + { + SwapFromIdentifier = manipFromIdentifier; + SwapToIdentifier = manipToIdentifier; + SwapFromDefaultEntry = manipFromEntry; + SwapToDefaultEntry = manipToEntry; + + SwapToModdedEntry = manipulations(SwapToIdentifier) ?? SwapToDefaultEntry; + SwapToIsDefault = SwapToModdedEntry.Equals(SwapToDefaultEntry); + SwapAppliedIsDefault = SwapToModdedEntry.Equals(SwapFromDefaultEntry); + } + + IMetaIdentifier IMetaSwap.SwapFromIdentifier + => SwapFromIdentifier; + + IMetaIdentifier IMetaSwap.SwapToIdentifier + => SwapToIdentifier; + + object IMetaSwap.SwapFromDefaultEntry + => SwapFromDefaultEntry; + + object IMetaSwap.SwapToDefaultEntry + => SwapToDefaultEntry; + + object IMetaSwap.SwapToModdedEntry + => SwapToModdedEntry; +} + +public sealed class FileSwap : Swap +{ + /// The file type, used for bookkeeping. + public ResourceType Type; + + /// The binary or parsed data of the file at SwapToModded. + public IWritable FileData = GenericFile.Invalid; + + /// The path that would be requested without manipulated parent files. + public string SwapFromPreChangePath = string.Empty; + + /// The Path that needs to be redirected. + public Utf8GamePath SwapFromRequestPath; + + /// The path that the game should request instead, if no mods are involved. + public Utf8GamePath SwapToRequestPath; + + /// The path to the actual file that should be loaded. This can be the same as SwapToRequestPath or a file on the drive. + public FullPath SwapToModded; + + /// Whether the target file is an actual game file. + public bool SwapToModdedExistsInGame; + + /// Whether the target file could be read either from the game or the drive. + public bool SwapToModdedExists + => FileData.Valid; + + /// Whether SwapToModded is a path to a game file that equals SwapFromGamePath. + public bool SwapToModdedEqualsOriginal; + + /// Whether the data in FileData was manipulated from the original file. + public bool DataWasChanged; + + /// Whether SwapFromPreChangePath equals SwapFromRequest. + public bool SwapFromChanged; + + public string GetNewPath(string newMod) + => Path.Combine(newMod, new Utf8RelPath(SwapFromRequestPath).ToString()); + + public MdlFile? AsMdl() + => FileData as MdlFile; + + public MtrlFile? AsMtrl() + => FileData as MtrlFile; + + public AvfxFile? AsAvfx() + => FileData as AvfxFile; + + /// + /// Create a full swap container for a specific file type using a modded redirection set, the actually requested path and the game file it should load instead after the swap. + /// + /// The file type. Mdl and Mtrl have special file loading treatment. + /// A function either returning the path after mod application. + /// The path the game is going to request when loading the file. + /// The unmodded path to the file the game is supposed to load instead. + /// A full swap container with the actual file in memory. + /// True if everything could be read correctly, false otherwise. + public static FileSwap CreateSwap(MetaFileManager manager, ResourceType type, Func redirections, + string swapFromRequest, string swapToRequest, string? swapFromPreChange = null) + { + var swap = new FileSwap + { + Type = type, + FileData = GenericFile.Invalid, + DataWasChanged = false, + SwapFromPreChangePath = swapFromPreChange ?? swapFromRequest, + SwapFromChanged = swapFromPreChange != swapFromRequest, + SwapFromRequestPath = Utf8GamePath.Empty, + SwapToRequestPath = Utf8GamePath.Empty, + SwapToModded = FullPath.Empty, + }; + + if (swapFromRequest.Length == 0 + || swapToRequest.Length == 0 + || !Utf8GamePath.FromString(swapToRequest, out swap.SwapToRequestPath) + || !Utf8GamePath.FromString(swapFromRequest, out swap.SwapFromRequestPath)) + throw new Exception($"Could not create UTF8 String for \"{swapFromRequest}\" or \"{swapToRequest}\"."); + + swap.SwapToModded = redirections(swap.SwapToRequestPath); + swap.SwapToModdedExistsInGame = + !swap.SwapToModded.IsRooted && manager.GameData.FileExists(swap.SwapToModded.InternalName.ToString()); + swap.SwapToModdedEqualsOriginal = !swap.SwapToModded.IsRooted && swap.SwapToModded.InternalName.Equals(swap.SwapFromRequestPath.Path); + + swap.FileData = type switch + { + ResourceType.Mdl => LoadMdl(manager, swap.SwapToModded, out var f) ? f : throw new MissingFileException(type, swap.SwapToModded), + ResourceType.Mtrl => LoadMtrl(manager, swap.SwapToModded, out var f) ? f : throw new MissingFileException(type, swap.SwapToModded), + ResourceType.Avfx => LoadAvfx(manager, swap.SwapToModded, out var f) ? f : throw new MissingFileException(type, swap.SwapToModded), + _ => LoadFile(manager, swap.SwapToModded, out var f) ? f : throw new MissingFileException(type, swap.SwapToModded), + }; + + return swap; + } +} diff --git a/Penumbra/Mods/Manager/ModCacheManager.cs b/Penumbra/Mods/Manager/ModCacheManager.cs new file mode 100644 index 00000000..130c8fcb --- /dev/null +++ b/Penumbra/Mods/Manager/ModCacheManager.cs @@ -0,0 +1,173 @@ +using OtterGui.Services; +using Penumbra.Communication; +using Penumbra.GameData.Data; +using Penumbra.Mods.Groups; +using Penumbra.Mods.Manager.OptionEditor; +using Penumbra.Mods.SubMods; +using Penumbra.Services; +using Penumbra.Util; + +namespace Penumbra.Mods.Manager; + +public class ModCacheManager : IDisposable, IService +{ + private readonly Configuration _config; + private readonly CommunicatorService _communicator; + private readonly ObjectIdentification _identifier; + private readonly ModStorage _modManager; + private bool _updatingItems; + + public ModCacheManager(CommunicatorService communicator, ObjectIdentification identifier, ModStorage modStorage, Configuration config) + { + _communicator = communicator; + _identifier = identifier; + _modManager = modStorage; + _config = config; + + _communicator.ModOptionChanged.Subscribe(OnModOptionChange, ModOptionChanged.Priority.ModCacheManager); + _communicator.ModPathChanged.Subscribe(OnModPathChange, ModPathChanged.Priority.ModCacheManager); + _communicator.ModDataChanged.Subscribe(OnModDataChange, ModDataChanged.Priority.ModCacheManager); + _communicator.ModDiscoveryFinished.Subscribe(OnModDiscoveryFinished, ModDiscoveryFinished.Priority.ModCacheManager); + identifier.Awaiter.ContinueWith(_ => OnIdentifierCreation(), TaskScheduler.Default); + OnModDiscoveryFinished(); + } + + public void Dispose() + { + _communicator.ModOptionChanged.Unsubscribe(OnModOptionChange); + _communicator.ModPathChanged.Unsubscribe(OnModPathChange); + _communicator.ModDataChanged.Unsubscribe(OnModDataChange); + _communicator.ModDiscoveryFinished.Unsubscribe(OnModDiscoveryFinished); + } + + private void OnModOptionChange(ModOptionChangeType type, Mod mod, IModGroup? group, IModOption? option, IModDataContainer? container, + int fromIdx) + { + switch (type) + { + case ModOptionChangeType.GroupAdded: + case ModOptionChangeType.GroupDeleted: + case ModOptionChangeType.OptionAdded: + case ModOptionChangeType.OptionDeleted: + UpdateChangedItems(mod); + UpdateCounts(mod); + break; + case ModOptionChangeType.GroupTypeChanged: + UpdateHasOptions(mod); + break; + case ModOptionChangeType.OptionFilesChanged: + case ModOptionChangeType.OptionFilesAdded: + UpdateChangedItems(mod); + UpdateFileCount(mod); + break; + case ModOptionChangeType.OptionSwapsChanged: + UpdateChangedItems(mod); + UpdateSwapCount(mod); + break; + case ModOptionChangeType.OptionMetaChanged: + UpdateChangedItems(mod); + UpdateMetaCount(mod); + break; + } + } + + private void OnModPathChange(ModPathChangeType type, Mod mod, DirectoryInfo? old, DirectoryInfo? @new) + { + switch (type) + { + case ModPathChangeType.Added: + case ModPathChangeType.Reloaded: + RefreshWithChangedItems(mod); + break; + } + } + + private static void OnModDataChange(ModDataChangeType type, Mod mod, string? _) + { + if ((type & (ModDataChangeType.LocalTags | ModDataChangeType.ModTags)) != 0) + UpdateTags(mod); + } + + private void OnModDiscoveryFinished() + { + if (!_identifier.Awaiter.IsCompletedSuccessfully || _updatingItems) + { + Parallel.ForEach(_modManager, RefreshWithoutChangedItems); + } + else + { + _updatingItems = true; + Parallel.ForEach(_modManager, RefreshWithChangedItems); + _updatingItems = false; + } + } + + private void OnIdentifierCreation() + { + if (_updatingItems) + return; + + _updatingItems = true; + Parallel.ForEach(_modManager, UpdateChangedItems); + _updatingItems = false; + } + + private static void UpdateFileCount(Mod mod) + => mod.TotalFileCount = mod.AllDataContainers.Sum(s => s.Files.Count); + + private static void UpdateSwapCount(Mod mod) + => mod.TotalSwapCount = mod.AllDataContainers.Sum(s => s.FileSwaps.Count); + + private static void UpdateMetaCount(Mod mod) + => mod.TotalManipulations = mod.AllDataContainers.Sum(s => s.Manipulations.Count); + + private static void UpdateHasOptions(Mod mod) + => mod.HasOptions = mod.Groups.Any(o => o.IsOption); + + private static void UpdateTags(Mod mod) + => mod.AllTagsLower = string.Join('\0', mod.ModTags.Concat(mod.LocalTags).Select(s => s.ToLowerInvariant())); + + private void UpdateChangedItems(Mod mod) + { + mod.ChangedItems.Clear(); + + _identifier.AddChangedItems(mod.Default, mod.ChangedItems); + foreach (var group in mod.Groups) + group.AddChangedItems(_identifier, mod.ChangedItems); + + if (_config.HideMachinistOffhandFromChangedItems) + mod.ChangedItems.RemoveMachinistOffhands(); + + mod.LowerChangedItemsString = string.Join("\0", mod.ChangedItems.Keys.Select(k => k.ToLowerInvariant())); + ++mod.LastChangedItemsUpdate; + } + + private static void UpdateCounts(Mod mod) + { + mod.TotalFileCount = mod.Default.Files.Count; + mod.TotalSwapCount = mod.Default.FileSwaps.Count; + mod.TotalManipulations = mod.Default.Manipulations.Count; + mod.HasOptions = false; + foreach (var group in mod.Groups) + { + mod.HasOptions |= group.IsOption; + var (files, swaps, manips) = group.GetCounts(); + mod.TotalFileCount += files; + mod.TotalSwapCount += swaps; + mod.TotalManipulations += manips; + } + } + + private void RefreshWithChangedItems(Mod mod) + { + UpdateTags(mod); + UpdateCounts(mod); + UpdateChangedItems(mod); + } + + private void RefreshWithoutChangedItems(Mod mod) + { + UpdateTags(mod); + UpdateCounts(mod); + } +} diff --git a/Penumbra/Mods/Manager/ModDataEditor.cs b/Penumbra/Mods/Manager/ModDataEditor.cs new file mode 100644 index 00000000..ffa73b76 --- /dev/null +++ b/Penumbra/Mods/Manager/ModDataEditor.cs @@ -0,0 +1,314 @@ +using Dalamud.Utility; +using OtterGui.Classes; +using OtterGui.Services; +using Penumbra.GameData.Data; +using Penumbra.GameData.Structs; +using Penumbra.Services; + +namespace Penumbra.Mods.Manager; + +[Flags] +public enum ModDataChangeType : ushort +{ + None = 0x0000, + Name = 0x0001, + Author = 0x0002, + Description = 0x0004, + Version = 0x0008, + Website = 0x0010, + Deletion = 0x0020, + Migration = 0x0040, + ModTags = 0x0080, + ImportDate = 0x0100, + Favorite = 0x0200, + LocalTags = 0x0400, + Note = 0x0800, + Image = 0x1000, + DefaultChangedItems = 0x2000, + PreferredChangedItems = 0x4000, + RequiredFeatures = 0x8000, +} + +public class ModDataEditor(SaveService saveService, CommunicatorService communicatorService, ItemData itemData) : IService +{ + public SaveService SaveService + => saveService; + + /// Create the file containing the meta information about a mod from scratch. + public void CreateMeta(DirectoryInfo directory, string? name, string? author, string? description, string? version, + string? website, params string[] tags) + { + var mod = new Mod(directory); + mod.Name = name.IsNullOrEmpty() ? mod.Name : new LowerString(name); + mod.Author = author != null ? new LowerString(author) : mod.Author; + mod.Description = description ?? mod.Description; + mod.Version = version ?? mod.Version; + mod.Website = website ?? mod.Website; + mod.ModTags = tags; + saveService.ImmediateSaveSync(new ModMeta(mod)); + } + + public void ChangeModName(Mod mod, string newName) + { + if (mod.Name.Text == newName) + return; + + var oldName = mod.Name; + mod.Name = newName; + saveService.QueueSave(new ModMeta(mod)); + communicatorService.ModDataChanged.Invoke(ModDataChangeType.Name, mod, oldName.Text); + } + + public void ChangeModAuthor(Mod mod, string newAuthor) + { + if (mod.Author == newAuthor) + return; + + mod.Author = newAuthor; + saveService.QueueSave(new ModMeta(mod)); + communicatorService.ModDataChanged.Invoke(ModDataChangeType.Author, mod, null); + } + + public void ChangeModDescription(Mod mod, string newDescription) + { + if (mod.Description == newDescription) + return; + + mod.Description = newDescription; + saveService.QueueSave(new ModMeta(mod)); + communicatorService.ModDataChanged.Invoke(ModDataChangeType.Description, mod, null); + } + + public void ChangeModVersion(Mod mod, string newVersion) + { + if (mod.Version == newVersion) + return; + + mod.Version = newVersion; + saveService.QueueSave(new ModMeta(mod)); + communicatorService.ModDataChanged.Invoke(ModDataChangeType.Version, mod, null); + } + + public void ChangeModWebsite(Mod mod, string newWebsite) + { + if (mod.Website == newWebsite) + return; + + mod.Website = newWebsite; + saveService.QueueSave(new ModMeta(mod)); + communicatorService.ModDataChanged.Invoke(ModDataChangeType.Website, mod, null); + } + + public void ChangeRequiredFeatures(Mod mod, FeatureFlags flags) + { + if (mod.RequiredFeatures == flags) + return; + + mod.RequiredFeatures = flags; + saveService.QueueSave(new ModMeta(mod)); + communicatorService.ModDataChanged.Invoke(ModDataChangeType.RequiredFeatures, mod, null); + } + + public void ChangeModTag(Mod mod, int tagIdx, string newTag) + => ChangeTag(mod, tagIdx, newTag, false); + + public void ChangeLocalTag(Mod mod, int tagIdx, string newTag) + => ChangeTag(mod, tagIdx, newTag, true); + + public void ChangeModFavorite(Mod mod, bool state) + { + if (mod.Favorite == state) + return; + + mod.Favorite = state; + saveService.QueueSave(new ModLocalData(mod)); + communicatorService.ModDataChanged.Invoke(ModDataChangeType.Favorite, mod, null); + } + + public void ResetModImportDate(Mod mod) + { + var newDate = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + if (mod.ImportDate == newDate) + return; + + mod.ImportDate = newDate; + saveService.QueueSave(new ModLocalData(mod)); + communicatorService.ModDataChanged.Invoke(ModDataChangeType.ImportDate, mod, null); + } + + public void ChangeModNote(Mod mod, string newNote) + { + if (mod.Note == newNote) + return; + + mod.Note = newNote; + saveService.QueueSave(new ModLocalData(mod)); + communicatorService.ModDataChanged.Invoke(ModDataChangeType.Favorite, mod, null); + } + + private void ChangeTag(Mod mod, int tagIdx, string newTag, bool local) + { + var which = local ? mod.LocalTags : mod.ModTags; + if (tagIdx < 0 || tagIdx > which.Count) + return; + + ModDataChangeType flags; + if (tagIdx == which.Count) + { + flags = ModLocalData.UpdateTags(mod, local ? null : which.Append(newTag), local ? which.Append(newTag) : null); + } + else + { + var tmp = which.ToArray(); + tmp[tagIdx] = newTag; + flags = ModLocalData.UpdateTags(mod, local ? null : tmp, local ? tmp : null); + } + + if (flags.HasFlag(ModDataChangeType.ModTags)) + saveService.QueueSave(new ModMeta(mod)); + + if (flags.HasFlag(ModDataChangeType.LocalTags)) + saveService.QueueSave(new ModLocalData(mod)); + + if (flags != 0) + communicatorService.ModDataChanged.Invoke(flags, mod, null); + } + + public void MoveDataFile(DirectoryInfo oldMod, DirectoryInfo newMod) + { + var oldFile = saveService.FileNames.LocalDataFile(oldMod.Name); + var newFile = saveService.FileNames.LocalDataFile(newMod.Name); + if (!File.Exists(oldFile)) + return; + + try + { + File.Move(oldFile, newFile, true); + } + catch (Exception e) + { + Penumbra.Log.Error($"Could not move local data file {oldFile} to {newFile}:\n{e}"); + } + } + + public void AddPreferredItem(Mod mod, CustomItemId id, bool toDefault, bool cleanExisting) + { + if (CleanExisting(mod.PreferredChangedItems)) + { + ++mod.LastChangedItemsUpdate; + saveService.QueueSave(new ModLocalData(mod)); + communicatorService.ModDataChanged.Invoke(ModDataChangeType.PreferredChangedItems, mod, null); + } + + if (toDefault && CleanExisting(mod.DefaultPreferredItems)) + { + saveService.QueueSave(new ModMeta(mod)); + communicatorService.ModDataChanged.Invoke(ModDataChangeType.DefaultChangedItems, mod, null); + } + + bool CleanExisting(HashSet items) + { + if (!items.Add(id)) + return false; + + if (!cleanExisting) + return true; + + var it1Exists = itemData.Primary.TryGetValue(id, out var it1); + var it2Exists = itemData.Secondary.TryGetValue(id, out var it2); + var it3Exists = itemData.Tertiary.TryGetValue(id, out var it3); + + foreach (var item in items.ToArray()) + { + if (item == id) + continue; + + if (it1Exists + && itemData.Primary.TryGetValue(item, out var oldItem1) + && oldItem1.PrimaryId == it1.PrimaryId + && oldItem1.Type == it1.Type) + items.Remove(item); + + else if (it2Exists + && itemData.Primary.TryGetValue(item, out var oldItem2) + && oldItem2.PrimaryId == it2.PrimaryId + && oldItem2.Type == it2.Type) + items.Remove(item); + + else if (it3Exists + && itemData.Primary.TryGetValue(item, out var oldItem3) + && oldItem3.PrimaryId == it3.PrimaryId + && oldItem3.Type == it3.Type) + items.Remove(item); + } + + return true; + } + } + + public void RemovePreferredItem(Mod mod, CustomItemId id, bool fromDefault) + { + if (!fromDefault && mod.PreferredChangedItems.Remove(id)) + { + ++mod.LastChangedItemsUpdate; + saveService.QueueSave(new ModLocalData(mod)); + communicatorService.ModDataChanged.Invoke(ModDataChangeType.PreferredChangedItems, mod, null); + } + + if (fromDefault && mod.DefaultPreferredItems.Remove(id)) + { + saveService.QueueSave(new ModMeta(mod)); + communicatorService.ModDataChanged.Invoke(ModDataChangeType.DefaultChangedItems, mod, null); + } + } + + public void ClearInvalidPreferredItems(Mod mod) + { + var currentChangedItems = mod.ChangedItems.Values.OfType().Select(i => i.Item.Id).Distinct().ToHashSet(); + var newSet = new HashSet(mod.PreferredChangedItems.Count); + + if (CheckItems(mod.PreferredChangedItems)) + { + mod.PreferredChangedItems = newSet; + ++mod.LastChangedItemsUpdate; + saveService.QueueSave(new ModLocalData(mod)); + communicatorService.ModDataChanged.Invoke(ModDataChangeType.PreferredChangedItems, mod, null); + } + + newSet = new HashSet(mod.DefaultPreferredItems.Count); + if (CheckItems(mod.DefaultPreferredItems)) + { + mod.DefaultPreferredItems = newSet; + saveService.QueueSave(new ModMeta(mod)); + communicatorService.ModDataChanged.Invoke(ModDataChangeType.DefaultChangedItems, mod, null); + } + + return; + + bool CheckItems(HashSet set) + { + var changes = false; + foreach (var item in set) + { + if (currentChangedItems.Contains(item)) + newSet.Add(item); + else + changes = true; + } + + return changes; + } + } + + public void ResetPreferredItems(Mod mod) + { + if (mod.PreferredChangedItems.SetEquals(mod.DefaultPreferredItems)) + return; + + mod.PreferredChangedItems.Clear(); + mod.PreferredChangedItems.UnionWith(mod.DefaultPreferredItems); + ++mod.LastChangedItemsUpdate; + saveService.QueueSave(new ModLocalData(mod)); + communicatorService.ModDataChanged.Invoke(ModDataChangeType.PreferredChangedItems, mod, null); + } +} diff --git a/Penumbra/Mods/Manager/ModExportManager.cs b/Penumbra/Mods/Manager/ModExportManager.cs new file mode 100644 index 00000000..38b9c0fd --- /dev/null +++ b/Penumbra/Mods/Manager/ModExportManager.cs @@ -0,0 +1,93 @@ +using OtterGui.Services; +using Penumbra.Communication; +using Penumbra.Mods.Editor; +using Penumbra.Services; + +namespace Penumbra.Mods.Manager; + +public class ModExportManager : IDisposable, IService +{ + private readonly Configuration _config; + private readonly CommunicatorService _communicator; + private readonly ModManager _modManager; + + private DirectoryInfo? _exportDirectory; + + public DirectoryInfo ExportDirectory + => _exportDirectory ?? _modManager.BasePath; + + public ModExportManager(Configuration config, CommunicatorService communicator, ModManager modManager) + { + _config = config; + _communicator = communicator; + _modManager = modManager; + UpdateExportDirectory(_config.ExportDirectory, false); + _communicator.ModPathChanged.Subscribe(OnModPathChange, ModPathChanged.Priority.ModExportManager); + } + + /// + public void UpdateExportDirectory(string newDirectory) + => UpdateExportDirectory(newDirectory, true); + + /// + /// Update the export directory to a new directory. Can also reset it to null with empty input. + /// If the directory is changed, all existing backups will be moved to the new one. + /// + /// The new directory name. + /// Can be used to stop saving for the initial setting + private void UpdateExportDirectory(string newDirectory, bool change) + { + if (newDirectory.Length == 0) + { + if (_exportDirectory == null) + return; + + _exportDirectory = null; + _config.ExportDirectory = string.Empty; + _config.Save(); + return; + } + + var dir = new DirectoryInfo(newDirectory); + if (dir.FullName.Equals(_exportDirectory?.FullName, StringComparison.OrdinalIgnoreCase)) + return; + + if (!dir.Exists) + try + { + Directory.CreateDirectory(dir.FullName); + } + catch (Exception e) + { + Penumbra.Log.Error($"Could not create Export Directory:\n{e}"); + return; + } + + if (change) + foreach (var mod in _modManager) + new ModBackup(this, mod).Move(dir.FullName); + + _exportDirectory = dir; + + if (!change) + return; + + _config.ExportDirectory = dir.FullName; + _config.Save(); + } + + public void Dispose() + => _communicator.ModPathChanged.Unsubscribe(OnModPathChange); + + /// Automatically migrate the backup file to the new name if any exists. + private void OnModPathChange(ModPathChangeType type, Mod mod, DirectoryInfo? oldDirectory, + DirectoryInfo? newDirectory) + { + if (type is not ModPathChangeType.Moved || oldDirectory == null || newDirectory == null) + return; + + mod.ModPath = oldDirectory; + new ModBackup(this, mod).Move(null, newDirectory.Name); + mod.ModPath = newDirectory; + } +} diff --git a/Penumbra/Mods/Manager/ModFileSystem.cs b/Penumbra/Mods/Manager/ModFileSystem.cs new file mode 100644 index 00000000..20a78995 --- /dev/null +++ b/Penumbra/Mods/Manager/ModFileSystem.cs @@ -0,0 +1,158 @@ +using Dalamud.Interface.ImGuiNotification; +using OtterGui.Classes; +using OtterGui.Filesystem; +using OtterGui.Services; +using Penumbra.Communication; +using Penumbra.Services; + +namespace Penumbra.Mods.Manager; + +public sealed class ModFileSystem : FileSystem, IDisposable, ISavable, IService +{ + private readonly ModManager _modManager; + private readonly CommunicatorService _communicator; + private readonly SaveService _saveService; + private readonly Configuration _config; + + // Create a new ModFileSystem from the currently loaded mods and the current sort order file. + public ModFileSystem(ModManager modManager, CommunicatorService communicator, SaveService saveService, Configuration config) + { + _modManager = modManager; + _communicator = communicator; + _saveService = saveService; + _config = config; + Reload(); + Changed += OnChange; + _communicator.ModDiscoveryFinished.Subscribe(Reload, ModDiscoveryFinished.Priority.ModFileSystem); + _communicator.ModDataChanged.Subscribe(OnModDataChange, ModDataChanged.Priority.ModFileSystem); + _communicator.ModPathChanged.Subscribe(OnModPathChange, ModPathChanged.Priority.ModFileSystem); + } + + public void Dispose() + { + _communicator.ModPathChanged.Unsubscribe(OnModPathChange); + _communicator.ModDiscoveryFinished.Unsubscribe(Reload); + _communicator.ModDataChanged.Unsubscribe(OnModDataChange); + } + + public struct ImportDate : ISortMode + { + public ReadOnlySpan Name + => "Import Date (Older First)"u8; + + public ReadOnlySpan Description + => "In each folder, sort all subfolders lexicographically, then sort all leaves using their import date."u8; + + public IEnumerable GetChildren(Folder f) + => f.GetSubFolders().Cast().Concat(f.GetLeaves().OrderBy(l => l.Value.ImportDate)); + } + + public struct InverseImportDate : ISortMode + { + public ReadOnlySpan Name + => "Import Date (Newer First)"u8; + + public ReadOnlySpan Description + => "In each folder, sort all subfolders lexicographically, then sort all leaves using their inverse import date."u8; + + public IEnumerable GetChildren(Folder f) + => f.GetSubFolders().Cast().Concat(f.GetLeaves().OrderByDescending(l => l.Value.ImportDate)); + } + + // Reload the whole filesystem from currently loaded mods and the current sort order file. + // Used on construction and on mod rediscoveries. + private void Reload() + { + var jObj = BackupService.GetJObjectForFile(_saveService.FileNames, _saveService.FileNames.FilesystemFile); + if (Load(jObj, _modManager, ModToIdentifier, ModToName)) + _saveService.ImmediateSave(this); + + Penumbra.Log.Debug("Reloaded mod filesystem."); + } + + // Save the filesystem on every filesystem change except full reloading. + private void OnChange(FileSystemChangeType type, IPath _1, IPath? _2, IPath? _3) + { + if (type != FileSystemChangeType.Reload) + _saveService.DelaySave(this); + } + + // Update sort order when defaulted mod names change. + private void OnModDataChange(ModDataChangeType type, Mod mod, string? oldName) + { + if (!type.HasFlag(ModDataChangeType.Name) || oldName == null || !TryGetValue(mod, out var leaf)) + return; + + var old = oldName.FixName(); + if (old == leaf.Name || leaf.Name.IsDuplicateName(out var baseName, out _) && baseName == old) + RenameWithDuplicates(leaf, mod.Name.Text); + } + + // Update the filesystem if a mod has been added or removed. + // Save it, if the mod directory has been moved, since this will change the save format. + private void OnModPathChange(ModPathChangeType type, Mod mod, DirectoryInfo? oldPath, DirectoryInfo? newPath) + { + switch (type) + { + case ModPathChangeType.Added: + var parent = Root; + if (_config.DefaultImportFolder.Length != 0) + try + { + parent = FindOrCreateAllFolders(_config.DefaultImportFolder); + } + catch (Exception e) + { + Penumbra.Messager.NotificationMessage(e, + $"Could not move newly imported mod {mod.Name} to default import folder {_config.DefaultImportFolder}.", + NotificationType.Warning); + } + + CreateDuplicateLeaf(parent, mod.Name.Text, mod); + break; + case ModPathChangeType.Deleted: + if (TryGetValue(mod, out var leaf)) + Delete(leaf); + + break; + case ModPathChangeType.Moved: + _saveService.DelaySave(this); + break; + case ModPathChangeType.Reloaded: + // Nothing + break; + } + } + + // Used for saving and loading. + private static string ModToIdentifier(Mod mod) + => mod.ModPath.Name; + + private static string ModToName(Mod mod) + => mod.Name.Text.FixName(); + + // Return whether a mod has a custom path or is just a numbered default path. + public static bool ModHasDefaultPath(Mod mod, string fullPath) + { + var regex = new Regex($@"^{Regex.Escape(ModToName(mod))}( \(\d+\))?$"); + return regex.IsMatch(fullPath); + } + + private static (string, bool) SaveMod(Mod mod, string fullPath) + // Only save pairs with non-default paths. + => ModHasDefaultPath(mod, fullPath) + ? (string.Empty, false) + : (ModToIdentifier(mod), true); + + public string ToFilename(FilenameService fileNames) + => fileNames.FilesystemFile; + + public void Save(StreamWriter writer) + => SaveToFile(writer, SaveMod, true); + + public string TypeName + => "Mod File System"; + + public string LogName(string _) + => "to file"; +} diff --git a/Penumbra/Mods/Manager/ModImportManager.cs b/Penumbra/Mods/Manager/ModImportManager.cs new file mode 100644 index 00000000..bb282262 --- /dev/null +++ b/Penumbra/Mods/Manager/ModImportManager.cs @@ -0,0 +1,120 @@ +using Dalamud.Interface.ImGuiNotification; +using OtterGui.Classes; +using OtterGui.Services; +using Penumbra.Import; +using Penumbra.Mods.Editor; +using Penumbra.Services; + +namespace Penumbra.Mods.Manager; + +public class ModImportManager(ModManager modManager, Configuration config, ModEditor modEditor, MigrationManager migrationManager) : IDisposable, IService +{ + private readonly ConcurrentQueue _modsToUnpack = new(); + + /// Mods need to be added thread-safely outside of iteration. + private readonly ConcurrentQueue _modsToAdd = new(); + + private TexToolsImporter? _import; + + + internal IEnumerable ModBatches + => _modsToUnpack; + + internal IEnumerable AddableMods + => _modsToAdd; + + + public void TryUnpacking() + { + if (Importing || !_modsToUnpack.TryDequeue(out var newMods)) + return; + + var files = newMods.Where(s => + { + if (File.Exists(s)) + return true; + + Penumbra.Messager.NotificationMessage($"Failed to import queued mod at {s}, the file does not exist.", NotificationType.Warning, + false); + return false; + }).Select(s => new FileInfo(s)).ToArray(); + + Penumbra.Log.Debug($"Unpacking mods: {string.Join("\n\t", files.Select(f => f.FullName))}."); + if (files.Length == 0) + return; + + _import = new TexToolsImporter(files.Length, files, AddNewMod, config, modEditor, modManager, modEditor.Compactor, migrationManager); + } + + public bool Importing + => _import != null; + + public bool IsImporting([NotNullWhen(true)] out TexToolsImporter? importer) + { + importer = _import; + return _import != null; + } + + public void AddUnpack(IEnumerable paths) + => AddUnpack(paths.ToArray()); + + public void AddUnpack(params string[] paths) + { + Penumbra.Log.Debug($"Adding mods to install: {string.Join("\n\t", paths)}"); + _modsToUnpack.Enqueue(paths); + } + + public void ClearImport() + { + _import?.Dispose(); + _import = null; + } + + public bool AddUnpackedMod([NotNullWhen(true)] out Mod? mod) + { + if (!_modsToAdd.TryDequeue(out var directory)) + { + mod = null; + return false; + } + + modManager.AddMod(directory, true); + mod = modManager.LastOrDefault(); + return mod != null && mod.ModPath == directory; + } + + public void Dispose() + { + ClearImport(); + _modsToAdd.Clear(); + _modsToUnpack.Clear(); + } + + /// + /// Clean up invalid directory if necessary. + /// Add successfully extracted mods. + /// + private void AddNewMod(FileInfo file, DirectoryInfo? dir, Exception? error) + { + if (error != null) + { + if (dir != null && Directory.Exists(dir.FullName)) + try + { + Directory.Delete(dir.FullName, true); + } + catch (Exception e) + { + Penumbra.Log.Error($"Error cleaning up failed mod extraction of {file.FullName} to {dir.FullName}:\n{e}"); + } + + if (error is not OperationCanceledException) + Penumbra.Log.Error($"Error extracting {file.FullName}, mod skipped:\n{error}"); + } + else if (dir != null) + { + Penumbra.Log.Debug($"Adding newly installed mod to queue: {dir.FullName}"); + _modsToAdd.Enqueue(dir); + } + } +} diff --git a/Penumbra/Mods/Manager/ModManager.cs b/Penumbra/Mods/Manager/ModManager.cs new file mode 100644 index 00000000..77385bbd --- /dev/null +++ b/Penumbra/Mods/Manager/ModManager.cs @@ -0,0 +1,372 @@ +using OtterGui.Services; +using Penumbra.Communication; +using Penumbra.Interop; +using Penumbra.Mods.Editor; +using Penumbra.Mods.Manager.OptionEditor; +using Penumbra.Services; + +namespace Penumbra.Mods.Manager; + +/// Describes the state of a potential move-target for a mod. +public enum NewDirectoryState +{ + NonExisting, + ExistsEmpty, + ExistsNonEmpty, + ExistsAsFile, + ContainsInvalidSymbols, + Identical, + Empty, +} + +/// Describes the state of a changed mod event. +public enum ModPathChangeType +{ + Added, + Deleted, + Moved, + Reloaded, + StartingReload, +} + +public sealed class ModManager : ModStorage, IDisposable, IService +{ + private readonly Configuration _config; + private readonly CommunicatorService _communicator; + + public readonly ModCreator Creator; + public readonly ModDataEditor DataEditor; + public readonly ModGroupEditor OptionEditor; + + public DirectoryInfo BasePath { get; private set; } = null!; + public bool Valid { get; private set; } + + public ModManager(Configuration config, CommunicatorService communicator, ModDataEditor dataEditor, ModGroupEditor optionEditor, + ModCreator creator) + { + _config = config; + _communicator = communicator; + DataEditor = dataEditor; + OptionEditor = optionEditor; + Creator = creator; + SetBaseDirectory(config.ModDirectory, true, out _); + _communicator.ModPathChanged.Subscribe(OnModPathChange, ModPathChanged.Priority.ModManager); + DiscoverMods(); + } + + /// Change the mod base directory and discover available mods. + public void DiscoverMods(string newDir, out string resultNewDir) + { + SetBaseDirectory(newDir, false, out resultNewDir); + DiscoverMods(); + } + + /// + /// Discover mods without changing the root directory. + /// + public void DiscoverMods() + { + _communicator.ModDiscoveryStarted.Invoke(); + ClearNewMods(); + Mods.Clear(); + BasePath.Refresh(); + + if (Valid && BasePath.Exists) + ScanMods(); + + _communicator.ModDiscoveryFinished.Invoke(); + Penumbra.Log.Information($"Rediscovered {Mods.Count} mods."); + + if (ModBackup.MigrateModBackups) + ModBackup.MigrateZipToPmp(this); + } + + /// Load a new mod and add it to the manager if successful. + public void AddMod(DirectoryInfo modFolder, bool deleteDefaultMeta) + { + if (this.Any(m => m.ModPath.Name == modFolder.Name)) + return; + + Creator.SplitMultiGroups(modFolder); + var mod = Creator.LoadMod(modFolder, true, deleteDefaultMeta); + if (mod == null) + return; + + mod.Index = Count; + Mods.Add(mod); + _communicator.ModPathChanged.Invoke(ModPathChangeType.Added, mod, null, mod.ModPath); + Penumbra.Log.Debug($"Added new mod {mod.Name} from {modFolder.FullName}."); + } + + /// + /// Delete a mod. The event is invoked before the mod is removed from the list. + /// Deletes from filesystem as well as from internal data. + /// Updates indices of later mods. + /// + public void DeleteMod(Mod mod) + { + if (Directory.Exists(mod.ModPath.FullName)) + try + { + Directory.Delete(mod.ModPath.FullName, true); + Penumbra.Log.Debug($"Deleted directory {mod.ModPath.FullName} for {mod.Name}."); + } + catch (Exception e) + { + Penumbra.Log.Error($"Could not delete the mod {mod.ModPath.Name}:\n{e}"); + } + + RemoveMod(mod); + } + + /// + /// Remove a loaded mod. The event is invoked before the mod is removed from the list. + /// Does not delete the mod from the filesystem. + /// Updates indices of later mods. + /// + public void RemoveMod(Mod mod) + { + _communicator.ModPathChanged.Invoke(ModPathChangeType.Deleted, mod, mod.ModPath, null); + foreach (var remainingMod in Mods.Skip(mod.Index + 1)) + --remainingMod.Index; + Mods.RemoveAt(mod.Index); + Penumbra.Log.Debug($"Removed loaded mod {mod.Name} from list."); + } + + /// + /// Reload a mod without changing its base directory. + /// If the base directory does not exist anymore, the mod will be deleted. + /// + public void ReloadMod(Mod mod) + { + var oldName = mod.Name; + + _communicator.ModPathChanged.Invoke(ModPathChangeType.StartingReload, mod, mod.ModPath, mod.ModPath); + if (!Creator.ReloadMod(mod, true, false, out var metaChange)) + { + if (mod.RequiredFeatures is not FeatureFlags.Invalid) + Penumbra.Log.Warning(mod.Name.Length == 0 + ? $"Reloading mod {oldName} has failed, new name is empty. Removing from loaded mods instead." + : $"Reloading mod {oldName} failed, {mod.ModPath.FullName} does not exist anymore or it has invalid data. Removing from loaded mods instead."); + RemoveMod(mod); + return; + } + + _communicator.ModPathChanged.Invoke(ModPathChangeType.Reloaded, mod, mod.ModPath, mod.ModPath); + if (metaChange != ModDataChangeType.None) + _communicator.ModDataChanged.Invoke(metaChange, mod, oldName); + } + + + /// + /// Rename/Move a mod directory. + /// Updates all collection settings and sort order settings. + /// + public void MoveModDirectory(Mod mod, string newName) + { + var oldName = mod.Name; + var oldDirectory = mod.ModPath; + + switch (NewDirectoryValid(oldDirectory.Name, newName, out var dir)) + { + case NewDirectoryState.NonExisting: + // Nothing to do + break; + case NewDirectoryState.ExistsEmpty: + try + { + Directory.Delete(dir!.FullName); + } + catch (Exception e) + { + Penumbra.Log.Error($"Could not delete empty directory {dir!.FullName} to move {mod.Name} to it:\n{e}"); + return; + } + + break; + // Should be caught beforehand. + case NewDirectoryState.ExistsNonEmpty: + case NewDirectoryState.ExistsAsFile: + case NewDirectoryState.ContainsInvalidSymbols: + // Nothing to do at all. + case NewDirectoryState.Identical: + default: + return; + } + + try + { + Directory.Move(oldDirectory.FullName, dir!.FullName); + } + catch (Exception e) + { + Penumbra.Log.Error($"Could not move {mod.Name} from {oldDirectory.Name} to {dir!.Name}:\n{e}"); + return; + } + + DataEditor.MoveDataFile(oldDirectory, dir); + + dir.Refresh(); + mod.ModPath = dir; + if (!Creator.ReloadMod(mod, false, false, out var metaChange)) + { + Penumbra.Log.Error($"Error reloading moved mod {mod.Name}."); + return; + } + + _communicator.ModPathChanged.Invoke(ModPathChangeType.Moved, mod, oldDirectory, dir); + if (metaChange != ModDataChangeType.None) + _communicator.ModDataChanged.Invoke(metaChange, mod, oldName); + } + + /// Return the state of the new potential name of a directory. + public NewDirectoryState NewDirectoryValid(string oldName, string newName, out DirectoryInfo? directory) + { + directory = null; + if (newName.Length == 0) + return NewDirectoryState.Empty; + + if (oldName == newName) + return NewDirectoryState.Identical; + + var fixedNewName = ModCreator.ReplaceBadXivSymbols(newName, _config.ReplaceNonAsciiOnImport); + if (fixedNewName != newName) + return NewDirectoryState.ContainsInvalidSymbols; + + directory = new DirectoryInfo(Path.Combine(BasePath.FullName, fixedNewName)); + if (File.Exists(directory.FullName)) + return NewDirectoryState.ExistsAsFile; + + if (!Directory.Exists(directory.FullName)) + return NewDirectoryState.NonExisting; + + if (directory.EnumerateFileSystemInfos().Any()) + return NewDirectoryState.ExistsNonEmpty; + + return NewDirectoryState.ExistsEmpty; + } + + + /// Add new mods to NewMods and remove deleted mods from NewMods. + private void OnModPathChange(ModPathChangeType type, Mod mod, DirectoryInfo? oldDirectory, + DirectoryInfo? newDirectory) + { + switch (type) + { + case ModPathChangeType.Added: SetNew(mod); break; + case ModPathChangeType.Deleted: SetKnown(mod); break; + case ModPathChangeType.Moved: + if (oldDirectory != null && newDirectory != null) + DataEditor.MoveDataFile(oldDirectory, newDirectory); + + break; + } + } + + public void Dispose() + => _communicator.ModPathChanged.Unsubscribe(OnModPathChange); + + /// + /// Set the mod base directory. + /// If it's not the first time, check if it is the same directory as before. + /// Also checks if the directory is available and tries to create it if it is not. + /// + private void SetBaseDirectory(string newPath, bool firstTime, out string resultNewDir) + { + resultNewDir = newPath; + if (!firstTime && string.Equals(newPath, _config.ModDirectory, StringComparison.Ordinal)) + return; + + if (newPath.Length == 0) + { + Valid = false; + BasePath = new DirectoryInfo("."); + if (_config.ModDirectory != BasePath.FullName) + TriggerModDirectoryChange(string.Empty, false); + } + else + { + var newDir = new DirectoryInfo(Path.TrimEndingDirectorySeparator(newPath)); + if (!newDir.Exists) + try + { + Directory.CreateDirectory(newDir.FullName); + newDir.Refresh(); + } + catch (Exception e) + { + Penumbra.Log.Error($"Could not create specified mod directory {newDir.FullName}:\n{e}"); + } + + BasePath = newDir; + Valid = Directory.Exists(newDir.FullName); + resultNewDir = BasePath.FullName; + if (!firstTime && _config.ModDirectory != BasePath.FullName) + TriggerModDirectoryChange(BasePath.FullName, Valid); + } + + if (CloudApi.IsCloudSynced(BasePath.FullName)) + Penumbra.Log.Warning($"Mod base directory {BasePath.FullName} is cloud-synced. This may cause issues."); + } + + private void TriggerModDirectoryChange(string newPath, bool valid) + { + _config.ModDirectory = newPath; + _config.Save(); + Penumbra.Log.Information($"Set new mod base directory from {_config.ModDirectory} to {newPath}."); + _communicator.ModDirectoryChanged.Invoke(newPath, valid); + } + + + /// + /// Iterate through available mods with multiple threads and queue their loads, + /// then add the mods from the queue. + /// + private void ScanMods() + { + try + { + var options = new ParallelOptions() + { + MaxDegreeOfParallelism = Math.Max(1, Environment.ProcessorCount / 2), + }; + var queue = new ConcurrentQueue(); + Parallel.ForEach(BasePath.EnumerateDirectories(), options, dir => + { + var mod = Creator.LoadMod(dir, false, false); + if (mod != null) + queue.Enqueue(mod); + }); + + foreach (var mod in queue) + { + mod.Index = Count; + Mods.Add(mod); + } + } + catch (Exception ex) + { + Valid = false; + _communicator.ModDirectoryChanged.Invoke(BasePath.FullName, false); + Penumbra.Log.Error($"Could not scan for mods:\n{ex}"); + } + } + + public bool TryIdentifyPath(string path, [NotNullWhen(true)] out Mod? mod, [NotNullWhen(true)] out string? relativePath) + { + var relPath = Path.GetRelativePath(BasePath.FullName, path); + if (relPath != "." && (relPath.StartsWith('.') || Path.IsPathRooted(relPath))) + { + mod = null; + relativePath = null; + return false; + } + + var modDirectorySeparator = relPath.IndexOfAny([Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar]); + + var modDirectory = modDirectorySeparator < 0 ? relPath : relPath[..modDirectorySeparator]; + relativePath = modDirectorySeparator < 0 ? string.Empty : relPath[(modDirectorySeparator + 1)..]; + + return TryGetMod(modDirectory, "\0", out mod); + } +} diff --git a/Penumbra/Mods/Manager/ModMigration.cs b/Penumbra/Mods/Manager/ModMigration.cs new file mode 100644 index 00000000..f3b25f1a --- /dev/null +++ b/Penumbra/Mods/Manager/ModMigration.cs @@ -0,0 +1,263 @@ +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using OtterGui.Extensions; +using Penumbra.Api.Enums; +using Penumbra.Mods.Editor; +using Penumbra.Mods.Groups; +using Penumbra.Mods.Settings; +using Penumbra.Mods.SubMods; +using Penumbra.Services; +using Penumbra.String.Classes; + +namespace Penumbra.Mods.Manager; + +public static partial class ModMigration +{ + [GeneratedRegex(@"group_\d{3}_", RegexOptions.Compiled | RegexOptions.NonBacktracking | RegexOptions.ExplicitCapture)] + private static partial Regex GroupRegex(); + + [GeneratedRegex("^group_", RegexOptions.Compiled)] + private static partial Regex GroupStartRegex(); + + public static bool Migrate(ModCreator creator, SaveService saveService, Mod mod, JObject json, ref uint fileVersion) + => MigrateV0ToV1(creator, saveService, mod, json, ref fileVersion) + || MigrateV1ToV2(saveService, mod, ref fileVersion) + || MigrateV2ToV3(mod, ref fileVersion); + + private static bool MigrateV2ToV3(Mod _, ref uint fileVersion) + { + if (fileVersion > 2) + return false; + + // Remove import time. + fileVersion = 3; + return true; + } + + private static bool MigrateV1ToV2(SaveService saveService, Mod mod, ref uint fileVersion) + { + if (fileVersion > 1) + return false; + + if (!saveService.FileNames.GetOptionGroupFiles(mod).All(g => GroupRegex().IsMatch(g.Name))) + foreach (var (group, index) in saveService.FileNames.GetOptionGroupFiles(mod).WithIndex().ToArray()) + { + var newName = GroupStartRegex().Replace(group.Name, $"group_{index + 1:D3}_"); + try + { + if (newName != group.Name) + group.MoveTo(Path.Combine(group.DirectoryName ?? string.Empty, newName), false); + } + catch (Exception e) + { + Penumbra.Log.Error($"Could not rename group file {group.Name} to {newName} during migration:\n{e}"); + } + } + + fileVersion = 2; + + return true; + } + + private static bool MigrateV0ToV1(ModCreator creator, SaveService saveService, Mod mod, JObject json, ref uint fileVersion) + { + if (fileVersion > 0) + return false; + + var swaps = json["FileSwaps"]?.ToObject>() ?? []; + var groups = json["Groups"]?.ToObject>() ?? []; + var priority = new ModPriority(1); + var seenMetaFiles = new HashSet(); + foreach (var group in groups.Values) + ConvertGroup(creator, mod, group, ref priority, seenMetaFiles); + + foreach (var unusedFile in mod.FindUnusedFiles().Where(f => !seenMetaFiles.Contains(f))) + { + if (unusedFile.ToGamePath(mod.ModPath, out var gamePath) + && !mod.Default.Files.TryAdd(gamePath, unusedFile)) + Penumbra.Log.Error($"Could not add {gamePath} because it already points to {mod.Default.Files[gamePath]}."); + } + + mod.Default.FileSwaps.Clear(); + mod.Default.FileSwaps.EnsureCapacity(swaps.Count); + foreach (var (gamePath, swapPath) in swaps) + mod.Default.FileSwaps.Add(gamePath, swapPath); + + creator.IncorporateAllMetaChanges(mod, true, true); + saveService.SaveAllOptionGroups(mod, false, creator.Config.ReplaceNonAsciiOnImport); + + // Delete meta files. + foreach (var file in seenMetaFiles.Where(f => f.Exists)) + { + try + { + File.Delete(file.FullName); + } + catch (Exception e) + { + Penumbra.Log.Warning($"Could not delete meta file {file.FullName} during migration:\n{e}"); + } + } + + // Delete old meta files. + var oldMetaFile = Path.Combine(mod.ModPath.FullName, "metadata_manipulations.json"); + if (File.Exists(oldMetaFile)) + try + { + File.Delete(oldMetaFile); + } + catch (Exception e) + { + Penumbra.Log.Warning($"Could not delete old meta file {oldMetaFile} during migration:\n{e}"); + } + + fileVersion = 1; + saveService.ImmediateSave(new ModSaveGroup(mod.ModPath, mod.Default, creator.Config.ReplaceNonAsciiOnImport)); + + return true; + } + + private static void ConvertGroup(ModCreator creator, Mod mod, OptionGroupV0 group, ref ModPriority priority, + HashSet seenMetaFiles) + { + if (group.Options.Count == 0) + return; + + switch (group.SelectionType) + { + case GroupType.Multi: + + var optionPriority = ModPriority.Default; + var newMultiGroup = new MultiModGroup(mod) + { + Name = group.GroupName, + Priority = priority++, + Description = string.Empty, + }; + mod.Groups.Add(newMultiGroup); + foreach (var option in group.Options) + newMultiGroup.OptionData.Add(SubModFromOption(creator, mod, newMultiGroup, option, optionPriority++, seenMetaFiles)); + + break; + case GroupType.Single: + if (group.Options.Count == 1) + { + AddFilesToSubMod(mod.Default, mod.ModPath, group.Options[0], seenMetaFiles); + return; + } + + var newSingleGroup = new SingleModGroup(mod) + { + Name = group.GroupName, + Priority = priority++, + Description = string.Empty, + }; + mod.Groups.Add(newSingleGroup); + foreach (var option in group.Options) + newSingleGroup.OptionData.Add(SubModFromOption(creator, mod, newSingleGroup, option, seenMetaFiles)); + + break; + } + } + + private static void AddFilesToSubMod(IModDataContainer mod, DirectoryInfo basePath, OptionV0 option, HashSet seenMetaFiles) + { + foreach (var (relPath, gamePaths) in option.OptionFiles) + { + var fullPath = new FullPath(basePath, relPath); + foreach (var gamePath in gamePaths) + mod.Files.TryAdd(gamePath, fullPath); + + if (fullPath.Extension is ".meta" or ".rgsp") + seenMetaFiles.Add(fullPath); + } + } + + private static SingleSubMod SubModFromOption(ModCreator creator, Mod mod, SingleModGroup group, OptionV0 option, + HashSet seenMetaFiles) + { + var subMod = new SingleSubMod(group) + { + Name = option.OptionName, + Description = option.OptionDesc, + }; + AddFilesToSubMod(subMod, mod.ModPath, option, seenMetaFiles); + creator.IncorporateMetaChanges(subMod, mod.ModPath, false); + return subMod; + } + + private static MultiSubMod SubModFromOption(ModCreator creator, Mod mod, MultiModGroup group, OptionV0 option, + ModPriority priority, HashSet seenMetaFiles) + { + var subMod = new MultiSubMod(group) + { + Name = option.OptionName, + Description = option.OptionDesc, + Priority = priority, + }; + AddFilesToSubMod(subMod, mod.ModPath, option, seenMetaFiles); + creator.IncorporateMetaChanges(subMod, mod.ModPath, false); + return subMod; + } + + private struct OptionV0 + { + public string OptionName = string.Empty; + public string OptionDesc = string.Empty; + + [JsonProperty(ItemConverterType = typeof(SingleOrArrayConverter))] + public Dictionary> OptionFiles = new(); + + public OptionV0() + { } + } + + private struct OptionGroupV0 + { + public string GroupName = string.Empty; + + [JsonConverter(typeof(Newtonsoft.Json.Converters.StringEnumConverter))] + public GroupType SelectionType = GroupType.Single; + + public List Options = []; + + public OptionGroupV0() + { } + } + + // Not used anymore, but required for migration. + private class SingleOrArrayConverter : JsonConverter + { + public override bool CanConvert(Type objectType) + => objectType == typeof(HashSet); + + public override object ReadJson(JsonReader reader, Type objectType, object? existingValue, JsonSerializer serializer) + { + var token = JToken.Load(reader); + + if (token.Type == JTokenType.Array) + return token.ToObject>() ?? []; + + var tmp = token.ToObject(); + return tmp != null + ? new HashSet { tmp } + : []; + } + + public override bool CanWrite + => true; + + public override void WriteJson(JsonWriter writer, object? value, JsonSerializer serializer) + { + writer.WriteStartArray(); + if (value != null) + { + var v = (HashSet)value; + foreach (var val in v) + serializer.Serialize(writer, val?.ToString()); + } + + writer.WriteEndArray(); + } + } +} diff --git a/Penumbra/Mods/Manager/ModStorage.cs b/Penumbra/Mods/Manager/ModStorage.cs new file mode 100644 index 00000000..acb2c1ab --- /dev/null +++ b/Penumbra/Mods/Manager/ModStorage.cs @@ -0,0 +1,76 @@ +using OtterGui.Classes; +using OtterGui.Widgets; + +namespace Penumbra.Mods.Manager; + +public class ModCombo(Func> generator) : FilterComboCache(generator, MouseWheelType.None, Penumbra.Log) +{ + protected override bool IsVisible(int globalIndex, LowerString filter) + => Items[globalIndex].Name.Contains(filter); + + protected override string ToString(Mod obj) + => obj.Name.Text; +} + +public class ModStorage : IReadOnlyList +{ + /// The actual list of mods. + protected readonly List Mods = []; + + public int Count + => Mods.Count; + + public Mod this[int idx] + => Mods[idx]; + + public Mod this[Index idx] + => Mods[idx]; + + public IEnumerator GetEnumerator() + => Mods.GetEnumerator(); + + IEnumerator IEnumerable.GetEnumerator() + => GetEnumerator(); + + /// + /// Try to obtain a mod by its directory name (unique identifier, preferred), + /// or the first mod of the given name if no directory fits. + /// + public bool TryGetMod(string identifier, string modName, [NotNullWhen(true)] out Mod? mod) + { + mod = null; + foreach (var m in Mods) + { + if (string.Equals(m.Identifier, identifier, StringComparison.OrdinalIgnoreCase)) + { + mod = m; + return true; + } + + if (m.Name == modName) + mod ??= m; + } + + return mod != null; + } + + /// + /// An easily accessible set of new mods. + /// Mods are added when they are created or imported. + /// Mods are removed when they are deleted or when they are toggled in any collection. + /// Also gets cleared on mod rediscovery. + /// + private readonly HashSet _newMods = new(); + + public bool IsNew(Mod mod) + => _newMods.Contains(mod); + + public void SetNew(Mod mod) + => _newMods.Add(mod); + + public void SetKnown(Mod mod) + => _newMods.Remove(mod); + + public void ClearNewMods() + => _newMods.Clear(); +} diff --git a/Penumbra/Mods/Manager/OptionEditor/CombiningModGroupEditor.cs b/Penumbra/Mods/Manager/OptionEditor/CombiningModGroupEditor.cs new file mode 100644 index 00000000..5acf5eb5 --- /dev/null +++ b/Penumbra/Mods/Manager/OptionEditor/CombiningModGroupEditor.cs @@ -0,0 +1,49 @@ +using OtterGui; +using OtterGui.Classes; +using OtterGui.Extensions; +using OtterGui.Services; +using Penumbra.Mods.Groups; +using Penumbra.Mods.Settings; +using Penumbra.Mods.SubMods; +using Penumbra.Services; + +namespace Penumbra.Mods.Manager.OptionEditor; + +public sealed class CombiningModGroupEditor(CommunicatorService communicator, SaveService saveService, Configuration config) + : ModOptionEditor(communicator, saveService, config), IService +{ + protected override CombiningModGroup CreateGroup(Mod mod, string newName, ModPriority priority, SaveType saveType = SaveType.ImmediateSync) + => new(mod) + { + Name = newName, + Priority = priority, + }; + + protected override CombiningSubMod? CloneOption(CombiningModGroup group, IModOption option) + => throw new NotImplementedException(); + + protected override void RemoveOption(CombiningModGroup group, int optionIndex) + { + if (group.OptionData.RemoveWithPowerSet(group.Data, optionIndex)) + group.DefaultSettings.RemoveBit(optionIndex); + } + + protected override bool MoveOption(CombiningModGroup group, int optionIdxFrom, int optionIdxTo) + { + if (!group.OptionData.MoveWithPowerSet(group.Data, ref optionIdxFrom, ref optionIdxTo)) + return false; + + group.DefaultSettings.MoveBit(optionIdxFrom, optionIdxTo); + return true; + } + + public void SetDisplayName(CombinedDataContainer container, string name, SaveType saveType = SaveType.Queue) + { + if (container.Name == name) + return; + + container.Name = name; + SaveService.Save(saveType, new ModSaveGroup(container.Group, Config.ReplaceNonAsciiOnImport)); + Communicator.ModOptionChanged.Invoke(ModOptionChangeType.DisplayChange, container.Group.Mod, container.Group, null, null, -1); + } +} diff --git a/Penumbra/Mods/Manager/OptionEditor/ImcAttributeCache.cs b/Penumbra/Mods/Manager/OptionEditor/ImcAttributeCache.cs new file mode 100644 index 00000000..12ed4c60 --- /dev/null +++ b/Penumbra/Mods/Manager/OptionEditor/ImcAttributeCache.cs @@ -0,0 +1,105 @@ +using OtterGui.Extensions; +using Penumbra.GameData.Structs; +using Penumbra.Mods.Groups; +using Penumbra.Mods.SubMods; + +namespace Penumbra.Mods.Manager.OptionEditor; + +public unsafe ref struct ImcAttributeCache +{ + private fixed bool _canChange[ImcEntry.NumAttributes]; + private fixed byte _option[ImcEntry.NumAttributes]; + + /// Obtain the earliest unset flag, or 0 if none are unset. + public readonly ushort LowestUnsetMask; + + public ImcAttributeCache(ImcModGroup group) + { + for (var i = 0; i < ImcEntry.NumAttributes; ++i) + { + _canChange[i] = true; + _option[i] = byte.MaxValue; + + var flag = (ushort)(1 << i); + foreach (var (option, idx) in group.OptionData.WithIndex()) + { + if ((option.AttributeMask & flag) == 0) + continue; + + _canChange[i] = option.AttributeMask != flag; + _option[i] = (byte)idx; + break; + } + + if (_option[i] == byte.MaxValue && LowestUnsetMask is 0) + LowestUnsetMask = flag; + } + } + + + /// Checks whether an attribute flag can be set by anything, i.e. if it might be the only flag for an option and thus could not be removed from that option. + public readonly bool CanChange(int idx) + => _canChange[idx]; + + /// Set a default attribute flag to a value if possible, remove it from its prior option if necessary, and return if anything changed. + public readonly bool Set(ImcModGroup group, int idx, bool value) + { + var flag = 1 << idx; + var oldMask = group.DefaultEntry.AttributeMask; + if (!value) + { + var newMask = (ushort)(oldMask & ~flag); + if (oldMask == newMask) + return false; + + group.DefaultEntry = group.DefaultEntry with { AttributeMask = newMask }; + return true; + } + + var mask = (ushort)(oldMask | flag); + if (oldMask == mask) + return false; + + group.DefaultEntry = group.DefaultEntry with { AttributeMask = mask }; + return true; + } + + /// Set an attribute flag to a value if possible, remove it from its prior option or the default entry if necessary, and return if anything changed. + public readonly bool Set(ImcSubMod option, int idx, bool value, bool turnOffDefault = false) + { + if (!_canChange[idx]) + return false; + + var flag = 1 << idx; + var oldMask = option.AttributeMask; + if (!value) + { + var newMask = (ushort)(oldMask & ~flag); + if (oldMask == newMask) + return false; + + option.AttributeMask = newMask; + return true; + } + + var mask = (ushort)(oldMask | flag); + if (oldMask == mask) + return false; + + option.AttributeMask = mask; + if (_option[idx] <= ImcEntry.NumAttributes) + { + var oldOption = option.Group.OptionData[_option[idx]]; + oldOption.AttributeMask = (ushort)(oldOption.AttributeMask & ~flag); + } + else if (turnOffDefault && _option[idx] is byte.MaxValue - 1) + { + option.Group.DefaultEntry = option.Group.DefaultEntry with + { + AttributeMask = (ushort)(option.Group.DefaultEntry.AttributeMask & ~flag), + }; + } + + return true; + } +} diff --git a/Penumbra/Mods/Manager/OptionEditor/ImcModGroupEditor.cs b/Penumbra/Mods/Manager/OptionEditor/ImcModGroupEditor.cs new file mode 100644 index 00000000..f8760625 --- /dev/null +++ b/Penumbra/Mods/Manager/OptionEditor/ImcModGroupEditor.cs @@ -0,0 +1,147 @@ +using OtterGui.Classes; +using OtterGui.Filesystem; +using OtterGui.Services; +using Penumbra.GameData.Structs; +using Penumbra.Meta.Manipulations; +using Penumbra.Mods.Groups; +using Penumbra.Mods.Settings; +using Penumbra.Mods.SubMods; +using Penumbra.Services; + +namespace Penumbra.Mods.Manager.OptionEditor; + +public sealed class ImcModGroupEditor(CommunicatorService communicator, SaveService saveService, Configuration config) + : ModOptionEditor(communicator, saveService, config), IService +{ + /// Add a new, empty imc group with the given manipulation data. + public ImcModGroup? AddModGroup(Mod mod, string newName, ImcIdentifier identifier, ImcEntry defaultEntry, + SaveType saveType = SaveType.ImmediateSync) + { + if (!ModGroupEditor.VerifyFileName(mod, null, newName, true)) + return null; + + var maxPriority = mod.Groups.Count == 0 ? ModPriority.Default : mod.Groups.Max(o => o.Priority) + 1; + var group = CreateGroup(mod, newName, identifier, defaultEntry, maxPriority); + mod.Groups.Add(group); + SaveService.Save(saveType, new ModSaveGroup(group, Config.ReplaceNonAsciiOnImport)); + Communicator.ModOptionChanged.Invoke(ModOptionChangeType.GroupAdded, mod, group, null, null, -1); + return group; + } + + public ImcSubMod? AddOption(ImcModGroup group, in ImcAttributeCache cache, string name, string description = "", + SaveType saveType = SaveType.Queue) + { + if (cache.LowestUnsetMask == 0) + return null; + + var subMod = new ImcSubMod(group) + { + Name = name, + Description = description, + AttributeMask = cache.LowestUnsetMask, + }; + group.OptionData.Add(subMod); + SaveService.Save(saveType, new ModSaveGroup(group, Config.ReplaceNonAsciiOnImport)); + Communicator.ModOptionChanged.Invoke(ModOptionChangeType.OptionAdded, group.Mod, group, subMod, null, -1); + return subMod; + } + + // Hide this method. + private new ImcSubMod? AddOption(ImcModGroup group, string name, SaveType saveType) + => null; + + public void ChangeDefaultAttribute(ImcModGroup group, in ImcAttributeCache cache, int idx, bool value, SaveType saveType = SaveType.Queue) + { + if (!cache.Set(group, idx, value)) + return; + + SaveService.Save(saveType, new ModSaveGroup(group, Config.ReplaceNonAsciiOnImport)); + Communicator.ModOptionChanged.Invoke(ModOptionChangeType.OptionMetaChanged, group.Mod, group, null, null, -1); + } + + public void ChangeDefaultEntry(ImcModGroup group, in ImcEntry newEntry, SaveType saveType = SaveType.Queue) + { + var entry = newEntry with { AttributeMask = group.DefaultEntry.AttributeMask }; + if (entry.MaterialId == 0 || group.DefaultEntry.Equals(entry)) + return; + + group.DefaultEntry = entry; + SaveService.Save(saveType, new ModSaveGroup(group, Config.ReplaceNonAsciiOnImport)); + Communicator.ModOptionChanged.Invoke(ModOptionChangeType.OptionMetaChanged, group.Mod, group, null, null, -1); + } + + public void ChangeOptionAttribute(ImcSubMod option, in ImcAttributeCache cache, int idx, bool value, SaveType saveType = SaveType.Queue) + { + if (!cache.Set(option, idx, value)) + return; + + SaveService.Save(saveType, new ModSaveGroup(option.Group, Config.ReplaceNonAsciiOnImport)); + Communicator.ModOptionChanged.Invoke(ModOptionChangeType.OptionMetaChanged, option.Mod, option.Group, option, null, -1); + } + + public void ChangeAllVariants(ImcModGroup group, bool allVariants, SaveType saveType = SaveType.Queue) + { + if (group.AllVariants == allVariants) + return; + + group.AllVariants = allVariants; + SaveService.Save(saveType, new ModSaveGroup(group, Config.ReplaceNonAsciiOnImport)); + Communicator.ModOptionChanged.Invoke(ModOptionChangeType.OptionMetaChanged, group.Mod, group, null, null, -1); + } + + public void ChangeOnlyAttributes(ImcModGroup group, bool onlyAttributes, SaveType saveType = SaveType.Queue) + { + if (group.OnlyAttributes == onlyAttributes) + return; + + group.OnlyAttributes = onlyAttributes; + SaveService.Save(saveType, new ModSaveGroup(group, Config.ReplaceNonAsciiOnImport)); + Communicator.ModOptionChanged.Invoke(ModOptionChangeType.OptionMetaChanged, group.Mod, group, null, null, -1); + } + + public void ChangeCanBeDisabled(ImcModGroup group, bool canBeDisabled, SaveType saveType = SaveType.Queue) + { + if (group.CanBeDisabled == canBeDisabled) + return; + + group.CanBeDisabled = canBeDisabled; + SaveService.Save(saveType, new ModSaveGroup(group, Config.ReplaceNonAsciiOnImport)); + Communicator.ModOptionChanged.Invoke(ModOptionChangeType.OptionMetaChanged, group.Mod, group, null, null, -1); + } + + protected override ImcModGroup CreateGroup(Mod mod, string newName, ModPriority priority, SaveType saveType = SaveType.ImmediateSync) + => new(mod) + { + Name = newName, + Priority = priority, + }; + + + private static ImcModGroup CreateGroup(Mod mod, string newName, ImcIdentifier identifier, ImcEntry defaultEntry, ModPriority priority, + SaveType saveType = SaveType.ImmediateSync) + => new(mod) + { + Name = newName, + Priority = priority, + Identifier = identifier, + DefaultEntry = defaultEntry, + }; + + protected override ImcSubMod? CloneOption(ImcModGroup group, IModOption option) + => null; + + protected override void RemoveOption(ImcModGroup group, int optionIndex) + { + group.OptionData.RemoveAt(optionIndex); + group.DefaultSettings = group.FixSetting(group.DefaultSettings); + } + + protected override bool MoveOption(ImcModGroup group, int optionIdxFrom, int optionIdxTo) + { + if (!group.OptionData.Move(ref optionIdxFrom, ref optionIdxTo)) + return false; + + group.DefaultSettings = group.DefaultSettings.MoveBit(optionIdxFrom, optionIdxTo); + return true; + } +} diff --git a/Penumbra/Mods/Manager/OptionEditor/ModGroupEditor.cs b/Penumbra/Mods/Manager/OptionEditor/ModGroupEditor.cs new file mode 100644 index 00000000..1c077c58 --- /dev/null +++ b/Penumbra/Mods/Manager/OptionEditor/ModGroupEditor.cs @@ -0,0 +1,304 @@ +using Dalamud.Interface.ImGuiNotification; +using OtterGui.Classes; +using OtterGui.Filesystem; +using OtterGui.Services; +using Penumbra.Api.Enums; +using Penumbra.Meta.Manipulations; +using Penumbra.Mods.Groups; +using Penumbra.Mods.Settings; +using Penumbra.Mods.SubMods; +using Penumbra.Services; +using Penumbra.String.Classes; +using Penumbra.Util; + +namespace Penumbra.Mods.Manager.OptionEditor; + +public enum ModOptionChangeType +{ + GroupRenamed, + GroupAdded, + GroupDeleted, + GroupMoved, + GroupTypeChanged, + PriorityChanged, + OptionAdded, + OptionDeleted, + OptionMoved, + OptionFilesChanged, + OptionFilesAdded, + OptionSwapsChanged, + OptionMetaChanged, + DisplayChange, + PrepareChange, + DefaultOptionChanged, +} + +public class ModGroupEditor( + SingleModGroupEditor singleEditor, + MultiModGroupEditor multiEditor, + ImcModGroupEditor imcEditor, + CombiningModGroupEditor combiningEditor, + CommunicatorService communicator, + SaveService saveService, + Configuration config) : IService +{ + public SingleModGroupEditor SingleEditor + => singleEditor; + + public MultiModGroupEditor MultiEditor + => multiEditor; + + public ImcModGroupEditor ImcEditor + => imcEditor; + + public CombiningModGroupEditor CombiningEditor + => combiningEditor; + + /// Change the settings stored as default options in a mod. + public void ChangeModGroupDefaultOption(IModGroup group, Setting defaultOption) + { + if (group.DefaultSettings == defaultOption) + return; + + group.DefaultSettings = defaultOption; + saveService.QueueSave(new ModSaveGroup(group, config.ReplaceNonAsciiOnImport)); + communicator.ModOptionChanged.Invoke(ModOptionChangeType.DefaultOptionChanged, group.Mod, group, null, null, -1); + } + + /// Rename an option group if possible. + public void RenameModGroup(IModGroup group, string newName) + { + var oldName = group.Name; + if (oldName == newName || !VerifyFileName(group.Mod, group, newName, true)) + return; + + saveService.ImmediateDelete(new ModSaveGroup(group, config.ReplaceNonAsciiOnImport)); + group.Name = newName; + saveService.ImmediateSave(new ModSaveGroup(group, config.ReplaceNonAsciiOnImport)); + communicator.ModOptionChanged.Invoke(ModOptionChangeType.GroupRenamed, group.Mod, group, null, null, -1); + } + + /// Delete a given option group. Fires an event to prepare before actually deleting. + public void DeleteModGroup(IModGroup group) + { + var mod = group.Mod; + var idx = group.GetIndex(); + communicator.ModOptionChanged.Invoke(ModOptionChangeType.PrepareChange, mod, group, null, null, -1); + mod.Groups.RemoveAt(idx); + saveService.SaveAllOptionGroups(mod, false, config.ReplaceNonAsciiOnImport); + communicator.ModOptionChanged.Invoke(ModOptionChangeType.GroupDeleted, mod, null, null, null, idx); + } + + /// Move the index of a given option group. + public void MoveModGroup(IModGroup group, int groupIdxTo) + { + var mod = group.Mod; + var idxFrom = group.GetIndex(); + if (!mod.Groups.Move(ref idxFrom, ref groupIdxTo)) + return; + + saveService.SaveAllOptionGroups(mod, false, config.ReplaceNonAsciiOnImport); + communicator.ModOptionChanged.Invoke(ModOptionChangeType.GroupMoved, mod, group, null, null, idxFrom); + } + + /// Change the internal priority of the given option group. + public void ChangeGroupPriority(IModGroup group, ModPriority newPriority) + { + if (group.Priority == newPriority) + return; + + group.Priority = newPriority; + saveService.QueueSave(new ModSaveGroup(group, config.ReplaceNonAsciiOnImport)); + communicator.ModOptionChanged.Invoke(ModOptionChangeType.PriorityChanged, group.Mod, group, null, null, -1); + } + + /// Change the description of the given option group. + public void ChangeGroupDescription(IModGroup group, string newDescription) + { + if (group.Description == newDescription) + return; + + group.Description = newDescription; + saveService.QueueSave(new ModSaveGroup(group, config.ReplaceNonAsciiOnImport)); + communicator.ModOptionChanged.Invoke(ModOptionChangeType.DisplayChange, group.Mod, group, null, null, -1); + } + + /// Rename the given option. + public void RenameOption(IModOption option, string newName) + { + if (option.Name == newName) + return; + + option.Name = newName; + saveService.QueueSave(new ModSaveGroup(option.Group, config.ReplaceNonAsciiOnImport)); + communicator.ModOptionChanged.Invoke(ModOptionChangeType.DisplayChange, option.Mod, option.Group, option, null, -1); + } + + /// Change the description of the given option. + public void ChangeOptionDescription(IModOption option, string newDescription) + { + if (option.Description == newDescription) + return; + + option.Description = newDescription; + saveService.QueueSave(new ModSaveGroup(option.Group, config.ReplaceNonAsciiOnImport)); + communicator.ModOptionChanged.Invoke(ModOptionChangeType.DisplayChange, option.Mod, option.Group, option, null, -1); + } + + /// Set the meta manipulations for a given option. Replaces existing manipulations. + public void SetManipulations(IModDataContainer subMod, MetaDictionary manipulations, SaveType saveType = SaveType.Queue) + { + if (subMod.Manipulations.Equals(manipulations)) + return; + + communicator.ModOptionChanged.Invoke(ModOptionChangeType.PrepareChange, (Mod)subMod.Mod, subMod.Group, null, subMod, -1); + subMod.Manipulations.SetTo(manipulations); + saveService.Save(saveType, new ModSaveGroup(subMod, config.ReplaceNonAsciiOnImport)); + communicator.ModOptionChanged.Invoke(ModOptionChangeType.OptionMetaChanged, (Mod)subMod.Mod, subMod.Group, null, subMod, -1); + } + + /// Set the file redirections for a given option. Replaces existing redirections. + public void SetFiles(IModDataContainer subMod, IReadOnlyDictionary replacements, SaveType saveType = SaveType.Queue) + { + if (subMod.Files.SetEquals(replacements)) + return; + + communicator.ModOptionChanged.Invoke(ModOptionChangeType.PrepareChange, (Mod)subMod.Mod, subMod.Group, null, subMod, -1); + subMod.Files.SetTo(replacements); + saveService.Save(saveType, new ModSaveGroup(subMod, config.ReplaceNonAsciiOnImport)); + communicator.ModOptionChanged.Invoke(ModOptionChangeType.OptionFilesChanged, (Mod)subMod.Mod, subMod.Group, null, subMod, -1); + } + + /// Forces a file save of the given container's group. + public void ForceSave(IModDataContainer subMod, SaveType saveType = SaveType.Queue) + => saveService.Save(saveType, new ModSaveGroup(subMod, config.ReplaceNonAsciiOnImport)); + + /// Add additional file redirections to a given option, keeping already existing ones. Only fires an event if anything is actually added. + public void AddFiles(IModDataContainer subMod, IReadOnlyDictionary additions) + { + var oldCount = subMod.Files.Count; + subMod.Files.AddFrom(additions); + if (oldCount != subMod.Files.Count) + { + saveService.QueueSave(new ModSaveGroup(subMod, config.ReplaceNonAsciiOnImport)); + communicator.ModOptionChanged.Invoke(ModOptionChangeType.OptionFilesAdded, (Mod)subMod.Mod, subMod.Group, null, subMod, -1); + } + } + + /// Set the file swaps for a given option. Replaces existing swaps. + public void SetFileSwaps(IModDataContainer subMod, IReadOnlyDictionary swaps, SaveType saveType = SaveType.Queue) + { + if (subMod.FileSwaps.SetEquals(swaps)) + return; + + communicator.ModOptionChanged.Invoke(ModOptionChangeType.PrepareChange, (Mod)subMod.Mod, subMod.Group, null, subMod, -1); + subMod.FileSwaps.SetTo(swaps); + saveService.Save(saveType, new ModSaveGroup(subMod, config.ReplaceNonAsciiOnImport)); + communicator.ModOptionChanged.Invoke(ModOptionChangeType.OptionSwapsChanged, (Mod)subMod.Mod, subMod.Group, null, subMod, -1); + } + + /// Verify that a new option group name is unique in this mod. + public static bool VerifyFileName(Mod mod, IModGroup? group, string newName, bool message) + { + var path = newName.RemoveInvalidPathSymbols(); + if (path.Length != 0 + && !mod.Groups.Any(o => !ReferenceEquals(o, group) + && string.Equals(o.Name.RemoveInvalidPathSymbols(), path, StringComparison.OrdinalIgnoreCase))) + return true; + + if (message) + Penumbra.Messager.NotificationMessage( + $"Could not name option {newName} because option with same filename {path} already exists.", + NotificationType.Warning, false); + + return false; + } + + public void DeleteOption(IModOption option) + { + switch (option) + { + case SingleSubMod s: + SingleEditor.DeleteOption(s); + return; + case MultiSubMod m: + MultiEditor.DeleteOption(m); + return; + case ImcSubMod i: + ImcEditor.DeleteOption(i); + return; + case CombiningSubMod c: + CombiningEditor.DeleteOption(c); + return; + } + } + + public IModOption? AddOption(IModGroup group, IModOption option) + => group switch + { + SingleModGroup s => SingleEditor.AddOption(s, option), + MultiModGroup m => MultiEditor.AddOption(m, option), + ImcModGroup i => ImcEditor.AddOption(i, option), + CombiningModGroup c => CombiningEditor.AddOption(c, option), + _ => null, + }; + + public IModOption? AddOption(IModGroup group, string newName) + => group switch + { + SingleModGroup s => SingleEditor.AddOption(s, newName), + MultiModGroup m => MultiEditor.AddOption(m, newName), + ImcModGroup i => ImcEditor.AddOption(i, newName), + CombiningModGroup c => CombiningEditor.AddOption(c, newName), + _ => null, + }; + + public IModGroup? AddModGroup(Mod mod, GroupType type, string newName, SaveType saveType = SaveType.ImmediateSync) + => type switch + { + GroupType.Single => SingleEditor.AddModGroup(mod, newName, saveType), + GroupType.Multi => MultiEditor.AddModGroup(mod, newName, saveType), + GroupType.Imc => ImcEditor.AddModGroup(mod, newName, default, default, saveType), + GroupType.Combining => CombiningEditor.AddModGroup(mod, newName, saveType), + _ => null, + }; + + public (IModGroup?, int, bool) FindOrAddModGroup(Mod mod, GroupType type, string name, SaveType saveType = SaveType.ImmediateSync) + => type switch + { + GroupType.Single => SingleEditor.FindOrAddModGroup(mod, name, saveType), + GroupType.Multi => MultiEditor.FindOrAddModGroup(mod, name, saveType), + GroupType.Imc => ImcEditor.FindOrAddModGroup(mod, name, saveType), + GroupType.Combining => CombiningEditor.FindOrAddModGroup(mod, name, saveType), + _ => (null, -1, false), + }; + + public (IModOption?, int, bool) FindOrAddOption(IModGroup group, string name, SaveType saveType = SaveType.ImmediateSync) + => group switch + { + SingleModGroup s => SingleEditor.FindOrAddOption(s, name, saveType), + MultiModGroup m => MultiEditor.FindOrAddOption(m, name, saveType), + ImcModGroup i => ImcEditor.FindOrAddOption(i, name, saveType), + CombiningModGroup c => CombiningEditor.FindOrAddOption(c, name, saveType), + _ => (null, -1, false), + }; + + public void MoveOption(IModOption option, int toIdx) + { + switch (option) + { + case SingleSubMod s: + SingleEditor.MoveOption(s, toIdx); + return; + case MultiSubMod m: + MultiEditor.MoveOption(m, toIdx); + return; + case ImcSubMod i: + ImcEditor.MoveOption(i, toIdx); + return; + case CombiningSubMod c: + CombiningEditor.MoveOption(c, toIdx); + return; + } + } +} diff --git a/Penumbra/Mods/Manager/OptionEditor/ModOptionEditor.cs b/Penumbra/Mods/Manager/OptionEditor/ModOptionEditor.cs new file mode 100644 index 00000000..d9d672e3 --- /dev/null +++ b/Penumbra/Mods/Manager/OptionEditor/ModOptionEditor.cs @@ -0,0 +1,153 @@ +using OtterGui.Classes; +using OtterGui.Extensions; +using Penumbra.Mods.Groups; +using Penumbra.Mods.Settings; +using Penumbra.Mods.SubMods; +using Penumbra.Services; + +namespace Penumbra.Mods.Manager.OptionEditor; + +public abstract class ModOptionEditor( + CommunicatorService communicator, + SaveService saveService, + Configuration config) + where TGroup : class, IModGroup + where TOption : class, IModOption +{ + protected readonly CommunicatorService Communicator = communicator; + protected readonly SaveService SaveService = saveService; + protected readonly Configuration Config = config; + + /// Add a new, empty option group of the given type and name. + public TGroup? AddModGroup(Mod mod, string newName, SaveType saveType = SaveType.ImmediateSync) + { + if (!ModGroupEditor.VerifyFileName(mod, null, newName, true)) + return null; + + var maxPriority = mod.Groups.Count == 0 ? ModPriority.Default : mod.Groups.Max(o => o.Priority) + 1; + var group = CreateGroup(mod, newName, maxPriority); + mod.Groups.Add(group); + SaveService.Save(saveType, new ModSaveGroup(group, Config.ReplaceNonAsciiOnImport)); + Communicator.ModOptionChanged.Invoke(ModOptionChangeType.GroupAdded, mod, group, null, null, -1); + return group; + } + + /// Add a new mod, empty option group of the given type and name if it does not exist already. + public (TGroup, int, bool) FindOrAddModGroup(Mod mod, string newName, SaveType saveType = SaveType.ImmediateSync) + { + var idx = mod.Groups.IndexOf(g => g.Name == newName); + if (idx >= 0) + { + var existingGroup = mod.Groups[idx] as TGroup + ?? throw new Exception($"Mod group with name {newName} exists, but is of the wrong type."); + return (existingGroup, idx, false); + } + + idx = mod.Groups.Count; + if (AddModGroup(mod, newName, saveType) is not { } group) + throw new Exception($"Could not create new mod group with name {newName}."); + + return (group, idx, true); + } + + /// Add a new empty option of the given name for the given group. + public TOption? AddOption(TGroup group, string newName, SaveType saveType = SaveType.Queue) + { + if (group.AddOption(newName) is not TOption option) + return null; + + SaveService.Save(saveType, new ModSaveGroup(group, Config.ReplaceNonAsciiOnImport)); + Communicator.ModOptionChanged.Invoke(ModOptionChangeType.OptionAdded, group.Mod, group, option, null, -1); + return option; + } + + /// Add a new empty option of the given name for the given group if it does not exist already. + public (TOption, int, bool) FindOrAddOption(TGroup group, string newName, SaveType saveType = SaveType.Queue) + { + var idx = group.Options.IndexOf(o => o.Name == newName); + if (idx >= 0) + { + var existingOption = group.Options[idx] as TOption + ?? throw new Exception($"Mod option with name {newName} exists, but is of the wrong type."); // Should never happen. + return (existingOption, idx, false); + } + + if (AddOption(group, newName, saveType) is not { } option) + throw new Exception($"Could not create new option with name {newName} in {group.Name}."); + + return (option, idx, true); + } + + /// Add an existing option to a given group. + public TOption? AddOption(TGroup group, IModOption option) + { + if (CloneOption(group, option) is not { } clonedOption) + return null; + + SaveService.QueueSave(new ModSaveGroup(group, Config.ReplaceNonAsciiOnImport)); + Communicator.ModOptionChanged.Invoke(ModOptionChangeType.OptionAdded, group.Mod, group, clonedOption, null, -1); + return clonedOption; + } + + /// Delete the given option from the given group. + public void DeleteOption(TOption option) + { + var mod = option.Mod; + var group = option.Group; + var optionIdx = option.GetIndex(); + Communicator.ModOptionChanged.Invoke(ModOptionChangeType.PrepareChange, mod, group, option, null, -1); + RemoveOption((TGroup)group, optionIdx); + SaveService.QueueSave(new ModSaveGroup(group, Config.ReplaceNonAsciiOnImport)); + Communicator.ModOptionChanged.Invoke(ModOptionChangeType.OptionDeleted, mod, group, null, null, optionIdx); + } + + /// Move an option inside the given option group. + public void MoveOption(TOption option, int optionIdxTo) + { + var idx = option.GetIndex(); + var group = (TGroup)option.Group; + if (!MoveOption(group, idx, optionIdxTo)) + return; + + SaveService.QueueSave(new ModSaveGroup(group, Config.ReplaceNonAsciiOnImport)); + Communicator.ModOptionChanged.Invoke(ModOptionChangeType.OptionMoved, group.Mod, group, option, null, idx); + } + + protected abstract TGroup CreateGroup(Mod mod, string newName, ModPriority priority, SaveType saveType = SaveType.ImmediateSync); + protected abstract TOption? CloneOption(TGroup group, IModOption option); + protected abstract void RemoveOption(TGroup group, int optionIndex); + protected abstract bool MoveOption(TGroup group, int optionIdxFrom, int optionIdxTo); +} + +public static class ModOptionChangeTypeExtension +{ + /// + /// Give information for each type of change. + /// If requiresSaving, collections need to be re-saved after this change. + /// If requiresReloading, caches need to be manipulated after this change. + /// If wasPrepared, caches have already removed the mod beforehand, then need add it again when this event is fired. + /// Otherwise, caches need to reload the mod itself. + /// + public static void HandlingInfo(this ModOptionChangeType type, out bool requiresSaving, out bool requiresReloading, out bool wasPrepared) + { + (requiresSaving, requiresReloading, wasPrepared) = type switch + { + ModOptionChangeType.GroupRenamed => (true, false, false), + ModOptionChangeType.GroupAdded => (true, false, false), + ModOptionChangeType.GroupDeleted => (true, true, false), + ModOptionChangeType.GroupMoved => (true, false, false), + ModOptionChangeType.GroupTypeChanged => (true, true, true), + ModOptionChangeType.PriorityChanged => (true, true, true), + ModOptionChangeType.OptionAdded => (true, true, true), + ModOptionChangeType.OptionDeleted => (true, true, false), + ModOptionChangeType.OptionMoved => (true, false, false), + ModOptionChangeType.OptionFilesChanged => (false, true, false), + ModOptionChangeType.OptionFilesAdded => (false, true, true), + ModOptionChangeType.OptionSwapsChanged => (false, true, false), + ModOptionChangeType.OptionMetaChanged => (false, true, false), + ModOptionChangeType.DisplayChange => (false, false, false), + ModOptionChangeType.DefaultOptionChanged => (true, false, false), + _ => (false, false, false), + }; + } +} diff --git a/Penumbra/Mods/Manager/OptionEditor/MultiModGroupEditor.cs b/Penumbra/Mods/Manager/OptionEditor/MultiModGroupEditor.cs new file mode 100644 index 00000000..2446ae80 --- /dev/null +++ b/Penumbra/Mods/Manager/OptionEditor/MultiModGroupEditor.cs @@ -0,0 +1,84 @@ +using OtterGui.Classes; +using OtterGui.Filesystem; +using OtterGui.Services; +using Penumbra.Mods.Groups; +using Penumbra.Mods.Settings; +using Penumbra.Mods.SubMods; +using Penumbra.Services; + +namespace Penumbra.Mods.Manager.OptionEditor; + +public sealed class MultiModGroupEditor(CommunicatorService communicator, SaveService saveService, Configuration config) + : ModOptionEditor(communicator, saveService, config), IService +{ + public void ChangeToSingle(MultiModGroup group) + { + var idx = group.GetIndex(); + var singleGroup = group.ConvertToSingle(); + group.Mod.Groups[idx] = singleGroup; + SaveService.QueueSave(new ModSaveGroup(singleGroup, Config.ReplaceNonAsciiOnImport)); + Communicator.ModOptionChanged.Invoke(ModOptionChangeType.GroupTypeChanged, singleGroup.Mod, singleGroup, null, null, -1); + } + + /// Change the internal priority of the given option. + public void ChangeOptionPriority(MultiSubMod option, ModPriority newPriority) + { + if (option.Priority == newPriority) + return; + + option.Priority = newPriority; + SaveService.QueueSave(new ModSaveGroup(option.Group, Config.ReplaceNonAsciiOnImport)); + Communicator.ModOptionChanged.Invoke(ModOptionChangeType.PriorityChanged, option.Mod, option.Group, option, null, -1); + } + + protected override MultiModGroup CreateGroup(Mod mod, string newName, ModPriority priority, SaveType saveType = SaveType.ImmediateSync) + => new(mod) + { + Name = newName, + Priority = priority, + }; + + protected override MultiSubMod? CloneOption(MultiModGroup group, IModOption option) + { + if (group.OptionData.Count >= IModGroup.MaxMultiOptions) + { + Penumbra.Log.Error( + $"Could not add option {option.Name} to {group.Name} for mod {group.Mod.Name}, " + + $"since only up to {IModGroup.MaxMultiOptions} options are supported in one group."); + return null; + } + + var newOption = new MultiSubMod(group) + { + Name = option.Name, + Description = option.Description, + }; + + if (option is IModDataContainer data) + { + SubMod.Clone(data, newOption); + if (option is MultiSubMod m) + newOption.Priority = m.Priority; + else + newOption.Priority = new ModPriority(group.OptionData.Max(o => o.Priority.Value) + 1); + } + + group.OptionData.Add(newOption); + return newOption; + } + + protected override void RemoveOption(MultiModGroup group, int optionIndex) + { + group.OptionData.RemoveAt(optionIndex); + group.DefaultSettings = group.DefaultSettings.RemoveBit(optionIndex); + } + + protected override bool MoveOption(MultiModGroup group, int optionIdxFrom, int optionIdxTo) + { + if (!group.OptionData.Move(ref optionIdxFrom, ref optionIdxTo)) + return false; + + group.DefaultSettings = group.DefaultSettings.MoveBit(optionIdxFrom, optionIdxTo); + return true; + } +} diff --git a/Penumbra/Mods/Manager/OptionEditor/SingleModGroupEditor.cs b/Penumbra/Mods/Manager/OptionEditor/SingleModGroupEditor.cs new file mode 100644 index 00000000..5fd785cf --- /dev/null +++ b/Penumbra/Mods/Manager/OptionEditor/SingleModGroupEditor.cs @@ -0,0 +1,57 @@ +using OtterGui.Classes; +using OtterGui.Filesystem; +using OtterGui.Services; +using Penumbra.Mods.Groups; +using Penumbra.Mods.Settings; +using Penumbra.Mods.SubMods; +using Penumbra.Services; + +namespace Penumbra.Mods.Manager.OptionEditor; + +public sealed class SingleModGroupEditor(CommunicatorService communicator, SaveService saveService, Configuration config) + : ModOptionEditor(communicator, saveService, config), IService +{ + public void ChangeToMulti(SingleModGroup group) + { + var idx = group.GetIndex(); + var multiGroup = group.ConvertToMulti(); + group.Mod.Groups[idx] = multiGroup; + SaveService.QueueSave(new ModSaveGroup(multiGroup, Config.ReplaceNonAsciiOnImport)); + Communicator.ModOptionChanged.Invoke(ModOptionChangeType.GroupTypeChanged, multiGroup.Mod, multiGroup, null, null, -1); + } + + protected override SingleModGroup CreateGroup(Mod mod, string newName, ModPriority priority, SaveType saveType = SaveType.ImmediateSync) + => new(mod) + { + Name = newName, + Priority = priority, + }; + + protected override SingleSubMod CloneOption(SingleModGroup group, IModOption option) + { + var newOption = new SingleSubMod(group) + { + Name = option.Name, + Description = option.Description, + }; + if (option is IModDataContainer data) + SubMod.Clone(data, newOption); + group.OptionData.Add(newOption); + return newOption; + } + + protected override void RemoveOption(SingleModGroup group, int optionIndex) + { + group.OptionData.RemoveAt(optionIndex); + group.DefaultSettings = group.DefaultSettings.RemoveSingle(optionIndex); + } + + protected override bool MoveOption(SingleModGroup group, int optionIdxFrom, int optionIdxTo) + { + if (!group.OptionData.Move(ref optionIdxFrom, ref optionIdxTo)) + return false; + + group.DefaultSettings = group.DefaultSettings.MoveSingle(optionIdxFrom, optionIdxTo); + return true; + } +} diff --git a/Penumbra/Mods/Mod.cs b/Penumbra/Mods/Mod.cs new file mode 100644 index 00000000..e262e8f1 --- /dev/null +++ b/Penumbra/Mods/Mod.cs @@ -0,0 +1,144 @@ +using OtterGui.Classes; +using OtterGui.Extensions; +using Penumbra.GameData.Data; +using Penumbra.GameData.Structs; +using Penumbra.Meta.Manipulations; +using Penumbra.Mods.Editor; +using Penumbra.Mods.Groups; +using Penumbra.Mods.Settings; +using Penumbra.Mods.SubMods; +using Penumbra.String.Classes; + +namespace Penumbra.Mods; + +[Flags] +public enum FeatureFlags : ulong +{ + None = 0, + Atch = 1ul << 0, + Shp = 1ul << 1, + Atr = 1ul << 2, + Invalid = 1ul << 62, +} + +public sealed class Mod : IMod +{ + public static readonly TemporaryMod ForcedFiles = new() + { + Name = "Forced Files", + Index = -1, + Priority = ModPriority.MaxValue, + }; + + // Main Data + public DirectoryInfo ModPath { get; internal set; } + + public string Identifier + => Index >= 0 ? ModPath.Name : Name; + + public int Index { get; internal set; } = -1; + + public bool IsTemporary + => Index < 0; + + /// Unused if Index is less than 0 but used for special temporary mods. + public ModPriority Priority + => ModPriority.Default; + + IReadOnlyList IMod.Groups + => Groups; + + internal Mod(DirectoryInfo modPath) + { + ModPath = modPath; + Default = new DefaultSubMod(this); + } + + public override string ToString() + => Name.Text; + + // Meta Data + public LowerString Name { get; internal set; } = "New Mod"; + public LowerString Author { get; internal set; } = LowerString.Empty; + public string Description { get; internal set; } = string.Empty; + public string Version { get; internal set; } = string.Empty; + public string Website { get; internal set; } = string.Empty; + public string Image { get; internal set; } = string.Empty; + public IReadOnlyList ModTags { get; internal set; } = []; + public HashSet DefaultPreferredItems { get; internal set; } = []; + public FeatureFlags RequiredFeatures { get; internal set; } = 0; + + + // Local Data + public long ImportDate { get; internal set; } = DateTimeOffset.UnixEpoch.ToUnixTimeMilliseconds(); + public IReadOnlyList LocalTags { get; internal set; } = []; + public string Note { get; internal set; } = string.Empty; + public HashSet PreferredChangedItems { get; internal set; } = []; + public bool Favorite { get; internal set; } = false; + + // Options + public readonly DefaultSubMod Default; + public readonly List Groups = []; + + /// Compute the required feature flags for this mod. + public FeatureFlags ComputeRequiredFeatures() + { + var flags = FeatureFlags.None; + foreach (var option in AllDataContainers) + { + if (option.Manipulations.Atch.Count > 0) + flags |= FeatureFlags.Atch; + if (option.Manipulations.Atr.Count > 0) + flags |= FeatureFlags.Atr; + if (option.Manipulations.Shp.Count > 0) + flags |= FeatureFlags.Shp; + } + + return flags; + } + + public AppliedModData GetData(ModSettings? settings = null) + { + if (settings is not { Enabled: true }) + return AppliedModData.Empty; + + var dictRedirections = new Dictionary(TotalFileCount); + var setManips = new MetaDictionary(); + foreach (var (group, groupIndex) in Groups.WithIndex().Reverse().OrderByDescending(g => g.Value.Priority)) + { + var config = settings.Settings[groupIndex]; + group.AddData(config, dictRedirections, setManips); + } + + Default.AddTo(dictRedirections, setManips); + return new AppliedModData(dictRedirections, setManips); + } + + public IEnumerable AllDataContainers + => Groups.SelectMany(o => o.DataContainers).Prepend(Default); + + public List FindUnusedFiles() + { + var modFiles = AllDataContainers.SelectMany(o => o.Files) + .Select(p => p.Value) + .ToHashSet(); + return ModPath.EnumerateDirectories() + .Where(d => !d.IsHidden()) + .SelectMany(FileExtensions.EnumerateNonHiddenFiles) + .Select(f => new FullPath(f)) + .Where(f => !modFiles.Contains(f)) + .ToList(); + } + + // Cache + public readonly SortedList ChangedItems = new(); + + public string LowerChangedItemsString { get; internal set; } = string.Empty; + public string AllTagsLower { get; internal set; } = string.Empty; + + public int TotalFileCount { get; internal set; } + public int TotalSwapCount { get; internal set; } + public int TotalManipulations { get; internal set; } + public ushort LastChangedItemsUpdate { get; internal set; } + public bool HasOptions { get; internal set; } +} diff --git a/Penumbra/Mods/ModCollection.cs b/Penumbra/Mods/ModCollection.cs deleted file mode 100644 index 8b23612d..00000000 --- a/Penumbra/Mods/ModCollection.cs +++ /dev/null @@ -1,250 +0,0 @@ -using Dalamud.Plugin; -using Newtonsoft.Json; -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using Dalamud.Logging; -using Penumbra.GameData.Util; -using Penumbra.Interop; -using Penumbra.Mod; -using Penumbra.Util; - -namespace Penumbra.Mods -{ - // A ModCollection is a named set of ModSettings to all of the users' installed mods. - // It is meant to be local only, and thus should always contain settings for every mod, not just the enabled ones. - // Settings to mods that are not installed anymore are kept as long as no call to CleanUnavailableSettings is made. - // Active ModCollections build a cache of currently relevant data. - public class ModCollection - { - public const string DefaultCollection = "Default"; - - public string Name { get; set; } - - public Dictionary< string, ModSettings > Settings { get; } - - public ModCollection() - { - Name = DefaultCollection; - Settings = new Dictionary< string, ModSettings >(); - } - - public ModCollection( string name, Dictionary< string, ModSettings > settings ) - { - Name = name; - Settings = settings.ToDictionary( kvp => kvp.Key, kvp => kvp.Value.DeepCopy() ); - } - - public Mod.Mod GetMod( ModData mod ) - { - if( Cache != null && Cache.AvailableMods.TryGetValue( mod.BasePath.Name, out var ret ) ) - { - return ret; - } - - if( Settings.TryGetValue( mod.BasePath.Name, out var settings ) ) - { - return new Mod.Mod( settings, mod ); - } - - var newSettings = ModSettings.DefaultSettings( mod.Meta ); - Settings.Add( mod.BasePath.Name, newSettings ); - Save(); - return new Mod.Mod( newSettings, mod ); - } - - private bool CleanUnavailableSettings( Dictionary< string, ModData > data ) - { - var removeList = Settings.Where( settingKvp => !data.ContainsKey( settingKvp.Key ) ).ToArray(); - - foreach( var s in removeList ) - { - Settings.Remove( s.Key ); - } - - return removeList.Length > 0; - } - - public void CreateCache( DirectoryInfo modDirectory, IEnumerable< ModData > data ) - { - Cache = new ModCollectionCache( Name, modDirectory ); - var changedSettings = false; - foreach( var mod in data ) - { - if( Settings.TryGetValue( mod.BasePath.Name, out var settings ) ) - { - Cache.AddMod( settings, mod, false ); - } - else - { - changedSettings = true; - var newSettings = ModSettings.DefaultSettings( mod.Meta ); - Settings.Add( mod.BasePath.Name, newSettings ); - Cache.AddMod( newSettings, mod, false ); - } - } - - if( changedSettings ) - { - Save(); - } - - CalculateEffectiveFileList( modDirectory, true, false ); - } - - public void ClearCache() - => Cache = null; - - public void UpdateSetting( ModData mod ) - { - if( !Settings.TryGetValue( mod.BasePath.Name, out var settings ) ) - { - return; - } - - if( settings.FixInvalidSettings( mod.Meta ) ) - { - Save(); - } - } - - public void UpdateSettings( bool forceSave ) - { - if( Cache == null ) - { - return; - } - - var changes = false; - foreach( var mod in Cache.AvailableMods.Values ) - { - changes |= mod.FixSettings(); - } - - if( forceSave || changes ) - { - Save(); - } - } - - public void CalculateEffectiveFileList( DirectoryInfo modDir, bool withMetaManipulations, bool activeCollection ) - { - PluginLog.Debug( "Recalculating effective file list for {CollectionName} [{WithMetaManipulations}] [{IsActiveCollection}]", Name, - withMetaManipulations, activeCollection ); - Cache ??= new ModCollectionCache( Name, modDir ); - UpdateSettings( false ); - Cache.CalculateEffectiveFileList(); - if( withMetaManipulations ) - { - Cache.UpdateMetaManipulations(); - if( activeCollection ) - { - Service< ResidentResources >.Get().ReloadPlayerResources(); - } - } - } - - - [JsonIgnore] - public ModCollectionCache? Cache { get; private set; } - - public static ModCollection? LoadFromFile( FileInfo file ) - { - if( !file.Exists ) - { - PluginLog.Error( $"Could not read collection because {file.FullName} does not exist." ); - return null; - } - - try - { - var collection = JsonConvert.DeserializeObject< ModCollection >( File.ReadAllText( file.FullName ) ); - return collection; - } - catch( Exception e ) - { - PluginLog.Error( $"Could not read collection information from {file.FullName}:\n{e}" ); - } - - return null; - } - - private void SaveToFile( FileInfo file ) - { - try - { - File.WriteAllText( file.FullName, JsonConvert.SerializeObject( this, Formatting.Indented ) ); - } - catch( Exception e ) - { - PluginLog.Error( $"Could not write collection {Name} to {file.FullName}:\n{e}" ); - } - } - - public static DirectoryInfo CollectionDir() - => new( Path.Combine( Dalamud.PluginInterface.GetPluginConfigDirectory(), "collections" ) ); - - private static FileInfo FileName( DirectoryInfo collectionDir, string name ) - => new( Path.Combine( collectionDir.FullName, $"{name.RemoveInvalidPathSymbols()}.json" ) ); - - public FileInfo FileName() - => new( Path.Combine( Dalamud.PluginInterface.GetPluginConfigDirectory(), - $"{Name.RemoveInvalidPathSymbols()}.json" ) ); - - public void Save() - { - try - { - var dir = CollectionDir(); - dir.Create(); - var file = FileName( dir, Name ); - SaveToFile( file ); - } - catch( Exception e ) - { - PluginLog.Error( $"Could not save collection {Name}:\n{e}" ); - } - } - - public static ModCollection? Load( string name ) - { - var file = FileName( CollectionDir(), name ); - return file.Exists ? LoadFromFile( file ) : null; - } - - public void Delete() - { - var file = FileName( CollectionDir(), Name ); - if( file.Exists ) - { - try - { - file.Delete(); - } - catch( Exception e ) - { - PluginLog.Error( $"Could not delete collection file {file} for {Name}:\n{e}" ); - } - } - } - - public void AddMod( ModData data ) - { - if( Cache == null ) - { - return; - } - - Cache.AddMod( Settings.TryGetValue( data.BasePath.Name, out var settings ) - ? settings - : ModSettings.DefaultSettings( data.Meta ), - data ); - } - - public string? ResolveSwappedOrReplacementPath( GamePath gameResourcePath ) - => Cache?.ResolveSwappedOrReplacementPath( gameResourcePath ); - - public static readonly ModCollection Empty = new() { Name = "" }; - } -} \ No newline at end of file diff --git a/Penumbra/Mods/ModCollectionCache.cs b/Penumbra/Mods/ModCollectionCache.cs deleted file mode 100644 index 9914553a..00000000 --- a/Penumbra/Mods/ModCollectionCache.cs +++ /dev/null @@ -1,322 +0,0 @@ -using System.Collections; -using System.Collections.Generic; -using System.ComponentModel; -using System.Diagnostics; -using System.IO; -using System.Linq; -using System.Runtime.InteropServices; -using Dalamud.Logging; -using Penumbra.GameData.Util; -using Penumbra.Meta; -using Penumbra.Mod; -using Penumbra.Structs; -using Penumbra.Util; - -namespace Penumbra.Mods -{ - // The ModCollectionCache contains all required temporary data to use a collection. - // It will only be setup if a collection gets activated in any way. - public class ModCollectionCache - { - // Shared caches to avoid allocations. - private static readonly BitArray FileSeen = new( 256 ); - private static readonly Dictionary< GamePath, Mod.Mod > RegisteredFiles = new( 256 ); - - public readonly Dictionary< string, Mod.Mod > AvailableMods = new(); - - public readonly Dictionary< GamePath, FileInfo > ResolvedFiles = new(); - public readonly Dictionary< GamePath, GamePath > SwappedFiles = new(); - public readonly HashSet< FileInfo > MissingFiles = new(); - public readonly MetaManager MetaManipulations; - - public ModCollectionCache( string collectionName, DirectoryInfo tempDir ) - => MetaManipulations = new MetaManager( collectionName, ResolvedFiles, tempDir ); - - private static void ResetFileSeen( int size ) - { - if( size < FileSeen.Length ) - { - FileSeen.Length = size; - FileSeen.SetAll( false ); - } - else - { - FileSeen.SetAll( false ); - FileSeen.Length = size; - } - } - - public void CalculateEffectiveFileList() - { - ResolvedFiles.Clear(); - SwappedFiles.Clear(); - MissingFiles.Clear(); - RegisteredFiles.Clear(); - - foreach( var mod in AvailableMods.Values - .Where( m => m.Settings.Enabled ) - .OrderByDescending( m => m.Settings.Priority ) ) - { - mod.Cache.ClearFileConflicts(); - AddFiles( mod ); - AddSwaps( mod ); - } - - AddMetaFiles(); - } - - - private void AddFiles( Mod.Mod mod ) - { - ResetFileSeen( mod.Data.Resources.ModFiles.Count ); - // Iterate in reverse so that later groups take precedence before earlier ones. - foreach( var group in mod.Data.Meta.Groups.Values.Reverse() ) - { - switch( group.SelectionType ) - { - case SelectType.Single: - AddFilesForSingle( group, mod ); - break; - case SelectType.Multi: - AddFilesForMulti( group, mod ); - break; - default: throw new InvalidEnumArgumentException(); - } - } - - AddRemainingFiles( mod ); - } - - private void AddFile( Mod.Mod mod, GamePath gamePath, FileInfo file ) - { - if( !RegisteredFiles.TryGetValue( gamePath, out var oldMod ) ) - { - RegisteredFiles.Add( gamePath, mod ); - ResolvedFiles[ gamePath ] = file; - } - else - { - mod.Cache.AddConflict( oldMod, gamePath ); - } - } - - private void AddMissingFile( FileInfo file ) - { - switch( file.Extension.ToLowerInvariant() ) - { - case ".meta": - case ".rgsp": - return; - default: - MissingFiles.Add( file ); - return; - } - } - - private void AddPathsForOption( Option option, Mod.Mod mod, bool enabled ) - { - foreach( var (file, paths) in option.OptionFiles ) - { - var fullPath = Path.Combine( mod.Data.BasePath.FullName, file ); - var idx = mod.Data.Resources.ModFiles.IndexOf( f => f.FullName == fullPath ); - if( idx < 0 ) - { - AddMissingFile( new FileInfo( fullPath ) ); - continue; - } - - var registeredFile = mod.Data.Resources.ModFiles[ idx ]; - registeredFile.Refresh(); - if( !registeredFile.Exists ) - { - AddMissingFile( registeredFile ); - continue; - } - - FileSeen.Set( idx, true ); - if( enabled ) - { - foreach( var path in paths ) - { - AddFile( mod, path, registeredFile ); - } - } - } - } - - private void AddFilesForSingle( OptionGroup singleGroup, Mod.Mod mod ) - { - Debug.Assert( singleGroup.SelectionType == SelectType.Single ); - - if( !mod.Settings.Settings.TryGetValue( singleGroup.GroupName, out var setting ) ) - { - setting = 0; - } - - for( var i = 0; i < singleGroup.Options.Count; ++i ) - { - AddPathsForOption( singleGroup.Options[ i ], mod, setting == i ); - } - } - - private void AddFilesForMulti( OptionGroup multiGroup, Mod.Mod mod ) - { - Debug.Assert( multiGroup.SelectionType == SelectType.Multi ); - - if( !mod.Settings.Settings.TryGetValue( multiGroup.GroupName, out var setting ) ) - { - return; - } - - // Also iterate options in reverse so that later options take precedence before earlier ones. - for( var i = multiGroup.Options.Count - 1; i >= 0; --i ) - { - AddPathsForOption( multiGroup.Options[ i ], mod, ( setting & ( 1 << i ) ) != 0 ); - } - } - - private void AddRemainingFiles( Mod.Mod mod ) - { - for( var i = 0; i < mod.Data.Resources.ModFiles.Count; ++i ) - { - if( FileSeen.Get( i ) ) - { - continue; - } - - var file = mod.Data.Resources.ModFiles[ i ]; - file.Refresh(); - if( file.Exists ) - { - AddFile( mod, new GamePath( file, mod.Data.BasePath ), file ); - } - else - { - MissingFiles.Add( file ); - } - } - } - - private void AddMetaFiles() - { - foreach( var (gamePath, file) in MetaManipulations.Files ) - { - if( RegisteredFiles.TryGetValue( gamePath, out var mod ) ) - { - PluginLog.Warning( - $"The meta manipulation file {gamePath} was already completely replaced by {mod.Data.Meta.Name}. This is probably a mistake. Using the custom file {file.FullName}." ); - } - - ResolvedFiles[ gamePath ] = file; - } - } - - private void AddSwaps( Mod.Mod mod ) - { - foreach( var swap in mod.Data.Meta.FileSwaps ) - { - if( !RegisteredFiles.TryGetValue( swap.Key, out var oldMod ) ) - { - RegisteredFiles.Add( swap.Key, mod ); - SwappedFiles.Add( swap.Key, swap.Value ); - } - else - { - mod.Cache.AddConflict( oldMod, swap.Key ); - } - } - } - - private void AddManipulations( Mod.Mod mod ) - { - foreach( var manip in mod.Data.Resources.MetaManipulations.GetManipulationsForConfig( mod.Settings, mod.Data.Meta ) ) - { - if( MetaManipulations.TryGetValue( manip, out var precedingMod ) ) - { - mod.Cache.AddConflict( precedingMod, manip ); - } - else - { - MetaManipulations.ApplyMod( manip, mod ); - } - } - } - - public void UpdateMetaManipulations() - { - MetaManipulations.Reset( false ); - - foreach( var mod in AvailableMods.Values.Where( m => m.Settings.Enabled && m.Data.Resources.MetaManipulations.Count > 0 ) ) - { - mod.Cache.ClearMetaConflicts(); - AddManipulations( mod ); - } - - MetaManipulations.WriteNewFiles(); - } - - public void RemoveMod( DirectoryInfo basePath ) - { - if( AvailableMods.TryGetValue( basePath.Name, out var mod ) ) - { - AvailableMods.Remove( basePath.Name ); - if( mod.Settings.Enabled ) - { - CalculateEffectiveFileList(); - if( mod.Data.Resources.MetaManipulations.Count > 0 ) - { - UpdateMetaManipulations(); - } - } - } - } - - private class PriorityComparer : IComparer< Mod.Mod > - { - public int Compare( Mod.Mod? x, Mod.Mod? y ) - => ( x?.Settings.Priority ?? 0 ).CompareTo( y?.Settings.Priority ?? 0 ); - } - - private static readonly PriorityComparer Comparer = new(); - - public void AddMod( ModSettings settings, ModData data, bool updateFileList = true ) - { - if( !AvailableMods.TryGetValue( data.BasePath.Name, out var existingMod ) ) - { - var newMod = new Mod.Mod( settings, data ); - AvailableMods[ data.BasePath.Name ] = newMod; - - if( updateFileList && settings.Enabled ) - { - CalculateEffectiveFileList(); - if( data.Resources.MetaManipulations.Count > 0 ) - { - UpdateMetaManipulations(); - } - } - } - } - - public FileInfo? GetCandidateForGameFile( GamePath gameResourcePath ) - { - if( !ResolvedFiles.TryGetValue( gameResourcePath, out var candidate ) ) - { - return null; - } - - candidate.Refresh(); - if( candidate.FullName.Length >= 260 || !candidate.Exists ) - { - return null; - } - - return candidate; - } - - public GamePath? GetSwappedFilePath( GamePath gameResourcePath ) - => SwappedFiles.TryGetValue( gameResourcePath, out var swappedPath ) ? swappedPath : null; - - public string? ResolveSwappedOrReplacementPath( GamePath gameResourcePath ) - => GetCandidateForGameFile( gameResourcePath )?.FullName.Replace( '\\', '/' ) ?? GetSwappedFilePath( gameResourcePath ) ?? null; - } -} \ No newline at end of file diff --git a/Penumbra/Mods/ModCreator.cs b/Penumbra/Mods/ModCreator.cs new file mode 100644 index 00000000..3a7bd105 --- /dev/null +++ b/Penumbra/Mods/ModCreator.cs @@ -0,0 +1,474 @@ +using Dalamud.Interface.ImGuiNotification; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using OtterGui; +using OtterGui.Classes; +using OtterGui.Extensions; +using OtterGui.Filesystem; +using OtterGui.Services; +using Penumbra.Api.Enums; +using Penumbra.GameData.Data; +using Penumbra.Import; +using Penumbra.Import.Structs; +using Penumbra.Meta; +using Penumbra.Mods.Editor; +using Penumbra.Mods.Groups; +using Penumbra.Mods.Manager; +using Penumbra.Mods.Settings; +using Penumbra.Mods.SubMods; +using Penumbra.Services; +using Penumbra.String.Classes; + +namespace Penumbra.Mods; + +public partial class ModCreator( + SaveService saveService, + Configuration config, + ModDataEditor dataEditor, + MetaFileManager metaFileManager, + GamePathParser gamePathParser) : IService +{ + public const FeatureFlags SupportedFeatures = FeatureFlags.Atch | FeatureFlags.Shp | FeatureFlags.Atr; + public readonly Configuration Config = config; + + /// Creates directory and files necessary for a new mod without adding it to the manager. + public DirectoryInfo? CreateEmptyMod(DirectoryInfo basePath, string newName, string description = "", string? author = null, params string[] tags) + { + try + { + var newDir = CreateModFolder(basePath, newName, Config.ReplaceNonAsciiOnImport, true); + dataEditor.CreateMeta(newDir, newName, author ?? Config.DefaultModAuthor, description, "1.0", string.Empty, tags); + CreateDefaultFiles(newDir); + return newDir; + } + catch (Exception e) + { + Penumbra.Messager.NotificationMessage(e, $"Could not create directory for new Mod {newName}.", NotificationType.Error, false); + return null; + } + } + + /// Load a mod by its directory. + public Mod? LoadMod(DirectoryInfo modPath, bool incorporateMetaChanges, bool deleteDefaultMetaChanges) + { + modPath.Refresh(); + if (!modPath.Exists) + { + Penumbra.Log.Error($"Supplied mod directory {modPath} does not exist."); + return null; + } + + var mod = new Mod(modPath); + if (ReloadMod(mod, incorporateMetaChanges, deleteDefaultMetaChanges, out _)) + return mod; + + // Can not be base path not existing because that is checked before. + Penumbra.Log.Warning($"Mod at {modPath} without name is not supported."); + return null; + } + + /// Reload a mod from its mod path. + public bool ReloadMod(Mod mod, bool incorporateMetaChanges, bool deleteDefaultMetaChanges, out ModDataChangeType modDataChange) + { + modDataChange = ModDataChangeType.Deletion; + if (!Directory.Exists(mod.ModPath.FullName)) + return false; + + modDataChange = ModMeta.Load(dataEditor, this, mod); + if (modDataChange.HasFlag(ModDataChangeType.Deletion) || mod.Name.Length == 0 || mod.RequiredFeatures is FeatureFlags.Invalid) + return false; + + modDataChange |= ModLocalData.Load(dataEditor, mod); + LoadDefaultOption(mod); + LoadAllGroups(mod); + if (incorporateMetaChanges) + IncorporateAllMetaChanges(mod, true, deleteDefaultMetaChanges); + else if (deleteDefaultMetaChanges) + ModMetaEditor.DeleteDefaultValues(mod, metaFileManager, saveService, false); + + return true; + } + + /// Load all option groups for a given mod. + public void LoadAllGroups(Mod mod) + { + mod.Groups.Clear(); + var changes = false; + foreach (var file in saveService.FileNames.GetOptionGroupFiles(mod)) + { + var group = LoadModGroup(mod, file); + if (group != null && mod.Groups.All(g => g.Name != group.Name)) + { + changes = changes + || saveService.FileNames.OptionGroupFile(mod.ModPath.FullName, mod.Groups.Count, group.Name, true) + != Path.Combine(file.DirectoryName!, ReplaceBadXivSymbols(file.Name, true)); + mod.Groups.Add(group); + } + else + { + changes = true; + } + } + + if (changes) + saveService.SaveAllOptionGroups(mod, true, Config.ReplaceNonAsciiOnImport); + } + + /// Load the default option for a given mod. + public void LoadDefaultOption(Mod mod) + { + var defaultFile = saveService.FileNames.OptionGroupFile(mod, -1, Config.ReplaceNonAsciiOnImport); + try + { + var jObject = File.Exists(defaultFile) ? JObject.Parse(File.ReadAllText(defaultFile)) : new JObject(); + SubMod.LoadDataContainer(jObject, mod.Default, mod.ModPath); + } + catch (Exception e) + { + Penumbra.Log.Error($"Could not parse default file for {mod.Name}:\n{e}"); + } + } + + /// + /// Create and return a new directory based on the given directory and name, that is
+ /// - Not Empty.
+ /// - Unique, by appending (digit) for duplicates.
+ /// - Containing no symbols invalid for FFXIV or windows paths.
+ ///
+ public static DirectoryInfo CreateModFolder(DirectoryInfo outDirectory, string modListName, bool onlyAscii, bool create) + { + var name = modListName; + if (name.Length == 0) + name = "_"; + + var newModFolderBase = NewOptionDirectory(outDirectory, name, onlyAscii); + var newModFolder = newModFolderBase.FullName.ObtainUniqueFile(); + if (newModFolder.Length == 0) + throw new IOException("Could not create mod folder: too many folders of the same name exist."); + + if (create) + Directory.CreateDirectory(newModFolder); + + return new DirectoryInfo(newModFolder); + } + + /// + /// Convert all .meta and .rgsp files to their respective meta changes and add them to their options. + /// Deletes the source files if delete is true. + /// + public void IncorporateAllMetaChanges(Mod mod, bool delete, bool removeDefaultValues) + { + var changes = false; + List deleteList = []; + foreach (var subMod in mod.AllDataContainers) + { + var (localChanges, localDeleteList) = IncorporateMetaChanges(subMod, mod.ModPath, false); + changes |= localChanges; + if (delete) + deleteList.AddRange(localDeleteList); + } + + DeleteDeleteList(deleteList, delete); + if (removeDefaultValues && !Config.KeepDefaultMetaChanges) + changes |= ModMetaEditor.DeleteDefaultValues(mod, metaFileManager, null, false); + + if (!changes) + return; + + saveService.SaveAllOptionGroups(mod, false, Config.ReplaceNonAsciiOnImport); + saveService.ImmediateSaveSync(new ModSaveGroup(mod.ModPath, mod.Default, Config.ReplaceNonAsciiOnImport)); + } + + + /// + /// If .meta or .rgsp files are encountered, parse them and incorporate their meta changes into the mod. + /// If delete is true, the files are deleted afterwards. + /// + public (bool Changes, List DeleteList) IncorporateMetaChanges(IModDataContainer option, DirectoryInfo basePath, bool delete) + { + var deleteList = new List(); + var oldSize = option.Manipulations.Count; + var deleteString = delete ? "with deletion." : "without deletion."; + foreach (var (key, file) in option.Files.ToList()) + { + var ext1 = key.Extension().AsciiToLower().ToString(); + var ext2 = file.Extension.ToLowerInvariant(); + try + { + if (ext1 == ".meta" || ext2 == ".meta") + { + option.Files.Remove(key); + if (!file.Exists) + continue; + + var meta = new TexToolsMeta(gamePathParser, File.ReadAllBytes(file.FullName)); + Penumbra.Log.Verbose( + $"Incorporating {file} as Metadata file of {meta.MetaManipulations.Count} manipulations {deleteString}"); + deleteList.Add(file.FullName); + option.Manipulations.UnionWith(meta.MetaManipulations); + } + else if (ext1 == ".rgsp" || ext2 == ".rgsp") + { + option.Files.Remove(key); + if (!file.Exists) + continue; + + var rgsp = TexToolsMeta.FromRgspFile(metaFileManager, file.FullName, File.ReadAllBytes(file.FullName)); + Penumbra.Log.Verbose( + $"Incorporating {file} as racial scaling file of {rgsp.MetaManipulations.Count} manipulations {deleteString}"); + deleteList.Add(file.FullName); + + option.Manipulations.UnionWith(rgsp.MetaManipulations); + } + } + catch (Exception e) + { + Penumbra.Log.Error($"Could not incorporate meta changes in mod {basePath} from file {file.FullName}:\n{e}"); + } + } + + DeleteDeleteList(deleteList, delete); + var changes = oldSize < option.Manipulations.Count; + return (changes, deleteList); + } + + /// + /// Create the name for a group or option subfolder based on its parent folder and given name. + /// subFolderName should never be empty, and the result is unique and contains no invalid symbols. + /// + public static DirectoryInfo? NewSubFolderName(DirectoryInfo parentFolder, string subFolderName, bool onlyAscii) + { + var newModFolderBase = NewOptionDirectory(parentFolder, subFolderName, onlyAscii); + var newModFolder = newModFolderBase.FullName.ObtainUniqueFile(); + return newModFolder.Length == 0 ? null : new DirectoryInfo(newModFolder); + } + + /// Create a file for an option group from given data. + public void CreateOptionGroup(DirectoryInfo baseFolder, GroupType type, string name, + ModPriority priority, int index, Setting defaultSettings, string desc, IEnumerable subMods) + { + switch (type) + { + case GroupType.Multi: + { + var group = MultiModGroup.WithoutMod(name); + group.Description = desc; + group.Priority = priority; + group.DefaultSettings = defaultSettings; + group.OptionData.AddRange(subMods.Select(s => s.Clone(group))); + saveService.ImmediateSaveSync(ModSaveGroup.WithoutMod(baseFolder, group, index, Config.ReplaceNonAsciiOnImport)); + break; + } + case GroupType.Single: + { + var group = SingleModGroup.CreateForSaving(name); + group.Description = desc; + group.Priority = priority; + group.DefaultSettings = defaultSettings; + group.OptionData.AddRange(subMods.Select(s => s.ConvertToSingle(group))); + saveService.ImmediateSaveSync(ModSaveGroup.WithoutMod(baseFolder, group, index, Config.ReplaceNonAsciiOnImport)); + break; + } + } + } + + /// Create the data for a given sub mod from its data and the folder it is based on. + public MultiSubMod CreateSubMod(DirectoryInfo baseFolder, DirectoryInfo optionFolder, OptionList option, ModPriority priority) + { + var list = optionFolder.EnumerateNonHiddenFiles() + .Select(f => (Utf8GamePath.FromFile(f, optionFolder, out var gamePath), gamePath, new FullPath(f))) + .Where(t => t.Item1); + + var mod = MultiSubMod.WithoutGroup(option.Name, option.Description, priority); + foreach (var (_, gamePath, file) in list) + mod.Files.TryAdd(gamePath, file); + + IncorporateMetaChanges(mod, baseFolder, true); + + return mod; + } + + /// + /// Create the default data file from all unused files that were not handled before + /// and are used in sub mods. + /// + internal void CreateDefaultFiles(DirectoryInfo directory) + { + var mod = new Mod(directory); + ReloadMod(mod, false, false, out _); + foreach (var file in mod.FindUnusedFiles()) + { + if (Utf8GamePath.FromFile(new FileInfo(file.FullName), directory, out var gamePath)) + mod.Default.Files.TryAdd(gamePath, file); + } + + IncorporateMetaChanges(mod.Default, directory, true); + saveService.ImmediateSaveSync(new ModSaveGroup(mod.ModPath, mod.Default, Config.ReplaceNonAsciiOnImport)); + } + + /// Return the name of a new valid directory based on the base directory and the given name. + public static DirectoryInfo NewOptionDirectory(DirectoryInfo baseDir, string optionName, bool onlyAscii) + { + var option = ReplaceBadXivSymbols(optionName, onlyAscii); + return new DirectoryInfo(Path.Combine(baseDir.FullName, option.Length > 0 ? option : "_")); + } + + /// Normalize for nicer names, and remove invalid symbols or invalid paths. + public static string ReplaceBadXivSymbols(string s, bool onlyAscii, string replacement = "_") + { + switch (s) + { + case ".": return replacement; + case "..": return replacement + replacement; + } + + StringBuilder sb = new(s.Length); + foreach (var c in s.Normalize(NormalizationForm.FormKC)) + { + if (c.IsInvalidInPath()) + sb.Append(replacement); + else if (onlyAscii && c.IsInvalidAscii()) + sb.Append(replacement); + else + sb.Append(c); + } + + return sb.ToString().Trim(); + } + + public void SplitMultiGroups(DirectoryInfo baseDir) + { + var mod = new Mod(baseDir); + + var files = saveService.FileNames.GetOptionGroupFiles(mod).ToList(); + var idx = 0; + var reorder = false; + foreach (var groupFile in files) + { + ++idx; + try + { + if (reorder) + { + var newName = $"{baseDir.FullName}\\group_{idx:D3}{groupFile.Name[9..]}"; + Penumbra.Log.Debug($"Moving {groupFile.Name} to {Path.GetFileName(newName)} due to reordering after multi group split."); + groupFile.MoveTo(newName, false); + } + } + catch (Exception ex) + { + throw new Exception("Could not reorder group file after splitting multi group on .pmp import.", ex); + } + + try + { + var json = JObject.Parse(File.ReadAllText(groupFile.FullName)); + if (json[nameof(IModGroup.Type)]?.ToObject() is not GroupType.Multi) + continue; + + var name = json[nameof(IModGroup.Name)]?.ToObject() ?? string.Empty; + if (name.Length == 0) + continue; + + + var options = json["Options"]?.Children().ToList(); + if (options is not { Count: > IModGroup.MaxMultiOptions }) + continue; + + Penumbra.Log.Information($"Splitting multi group {name} in {mod.Name} due to {options.Count} being too many options."); + var clone = json.DeepClone(); + reorder = true; + foreach (var o in options.Skip(IModGroup.MaxMultiOptions)) + o.Remove(); + + var newOptions = clone["Options"]!.Children().ToList(); + foreach (var o in newOptions.Take(IModGroup.MaxMultiOptions)) + o.Remove(); + + var match = DuplicateNumber().Match(name); + var startNumber = match.Success ? int.Parse(match.Groups[0].Value) : 1; + name = match.Success ? name[..4] : name; + var oldName = $"{name}, Part {startNumber}"; + var oldPath = $"{baseDir.FullName}\\group_{idx:D3}_{oldName.RemoveInvalidPathSymbols().ToLowerInvariant()}.json"; + var newName = $"{name}, Part {startNumber + 1}"; + var newPath = $"{baseDir.FullName}\\group_{++idx:D3}_{newName.RemoveInvalidPathSymbols().ToLowerInvariant()}.json"; + json[nameof(IModGroup.Name)] = oldName; + clone[nameof(IModGroup.Name)] = newName; + + clone[nameof(IModGroup.DefaultSettings)] = 0u; + + Penumbra.Log.Debug($"Writing the first {IModGroup.MaxMultiOptions} options to {Path.GetFileName(oldPath)} after split."); + using (var oldFile = File.CreateText(oldPath)) + { + using var j = new JsonTextWriter(oldFile); + j.Formatting = Formatting.Indented; + json.WriteTo(j); + } + + Penumbra.Log.Debug( + $"Writing the remaining {options.Count - IModGroup.MaxMultiOptions} options to {Path.GetFileName(newPath)} after split."); + using (var newFile = File.CreateText(newPath)) + { + using var j = new JsonTextWriter(newFile); + j.Formatting = Formatting.Indented; + clone.WriteTo(j); + } + + Penumbra.Log.Debug( + $"Deleting the old group file at {groupFile.Name} after splitting it into {Path.GetFileName(oldPath)} and {Path.GetFileName(newPath)}."); + groupFile.Delete(); + } + catch (Exception ex) + { + throw new Exception($"Could not split multi group file {groupFile.Name} on .pmp import.", ex); + } + } + } + + [GeneratedRegex(@", Part (\d+)$", RegexOptions.NonBacktracking)] + private static partial Regex DuplicateNumber(); + + + /// Load an option group for a specific mod by its file and index. + private static IModGroup? LoadModGroup(Mod mod, FileInfo file) + { + if (!File.Exists(file.FullName)) + return null; + + try + { + var json = JObject.Parse(File.ReadAllText(file.FullName)); + switch (json[nameof(Type)]?.ToObject() ?? GroupType.Single) + { + case GroupType.Multi: return MultiModGroup.Load(mod, json); + case GroupType.Single: return SingleModGroup.Load(mod, json); + case GroupType.Imc: return ImcModGroup.Load(mod, json); + case GroupType.Combining: return CombiningModGroup.Load(mod, json); + } + } + catch (Exception e) + { + Penumbra.Log.Error($"Could not read mod group from {file.FullName}:\n{e}"); + } + + return null; + } + + internal static void DeleteDeleteList(IEnumerable deleteList, bool delete) + { + if (!delete) + return; + + foreach (var file in deleteList) + { + try + { + File.Delete(file); + } + catch (Exception e) + { + Penumbra.Log.Error($"Could not delete incorporated meta file {file}:\n{e}"); + } + } + } +} diff --git a/Penumbra/Mods/ModFileSystem.cs b/Penumbra/Mods/ModFileSystem.cs deleted file mode 100644 index 0a0dc54f..00000000 --- a/Penumbra/Mods/ModFileSystem.cs +++ /dev/null @@ -1,260 +0,0 @@ -using System; -using System.Linq; -using Penumbra.Mod; -using Penumbra.Util; - -namespace Penumbra.Mods -{ - public delegate void OnModFileSystemChange(); - - public static partial class ModFileSystem - { - // The root folder that should be used as the base for all structured mods. - public static ModFolder Root = ModFolder.CreateRoot(); - - // Gets invoked every time the file system changes. - public static event OnModFileSystemChange? ModFileSystemChanged; - - internal static void InvokeChange() - => ModFileSystemChanged?.Invoke(); - - // Find a specific mod folder by its path from Root. - // Returns true if the folder was found, and false if not. - // The out parameter will contain the furthest existing folder. - public static bool Find( string path, out ModFolder folder ) - { - var split = path.Split( new[] { '/' }, StringSplitOptions.RemoveEmptyEntries ); - folder = Root; - foreach( var part in split ) - { - if( !folder.FindSubFolder( part, out folder ) ) - { - return false; - } - } - - return true; - } - - // Rename the SortOrderName of a single mod. Slashes are replaced by Backslashes. - // Saves and returns true if anything changed. - public static bool Rename( this ModData mod, string newName ) - { - if( RenameNoSave( mod, newName ) ) - { - SaveMod( mod ); - return true; - } - - return false; - } - - // Rename the target folder, merging it and its subfolders if the new name already exists. - // Saves all mods manipulated thus, and returns true if anything changed. - public static bool Rename( this ModFolder target, string newName ) - { - if( RenameNoSave( target, newName ) ) - { - SaveModChildren( target ); - return true; - } - - return false; - } - - // Move a single mod to the target folder. - // Returns true and saves if anything changed. - public static bool Move( this ModData mod, ModFolder target ) - { - if( MoveNoSave( mod, target ) ) - { - SaveMod( mod ); - return true; - } - - return false; - } - - // Move a mod to the filesystem location specified by sortOrder and rename its SortOrderName. - // Creates all necessary Subfolders. - public static void Move( this ModData mod, string sortOrder ) - { - var split = sortOrder.Split( new[] { '/' }, StringSplitOptions.RemoveEmptyEntries ); - var folder = Root; - for( var i = 0; i < split.Length - 1; ++i ) - { - folder = folder.FindOrCreateSubFolder( split[ i ] ).Item1; - } - - if( MoveNoSave( mod, folder ) | RenameNoSave( mod, split.Last() ) ) - { - SaveMod( mod ); - } - } - - // Moves folder to target. - // If an identically named subfolder of target already exists, merges instead. - // Root is not movable. - public static bool Move( this ModFolder folder, ModFolder target ) - { - if( MoveNoSave( folder, target ) ) - { - SaveModChildren( target ); - return true; - } - - return false; - } - - // Merge source with target, moving all direct mod children of source to target, - // and moving all subfolders of source to target, or merging them with targets subfolders if they exist. - // Returns true and saves if anything changed. - public static bool Merge( this ModFolder source, ModFolder target ) - { - if( MergeNoSave( source, target ) ) - { - SaveModChildren( target ); - return true; - } - - return false; - } - } - - // Internal stuff. - public static partial class ModFileSystem - { - // Reset all sort orders for all descendants of the given folder. - // Assumes that it is not called on Root, and thus does not remove unnecessary SortOrder entries. - private static void SaveModChildren( ModFolder target ) - { - foreach( var mod in target.AllMods( true ) ) - { - Penumbra.Config.ModSortOrder[ mod.BasePath.Name ] = mod.SortOrder.FullName; - } - - Penumbra.Config.Save(); - InvokeChange(); - } - - // Sets and saves the sort order of a single mod, removing the entry if it is unnecessary. - private static void SaveMod( ModData mod ) - { - if( ReferenceEquals( mod.SortOrder.ParentFolder, Root ) - && string.Equals( mod.SortOrder.SortOrderName, mod.Meta.Name.Replace( '/', '\\' ), StringComparison.InvariantCultureIgnoreCase ) ) - { - Penumbra.Config.ModSortOrder.Remove( mod.BasePath.Name ); - } - else - { - Penumbra.Config.ModSortOrder[ mod.BasePath.Name ] = mod.SortOrder.FullName; - } - - Penumbra.Config.Save(); - InvokeChange(); - } - - private static bool RenameNoSave( this ModFolder target, string newName ) - { - if( ReferenceEquals( target, Root ) ) - { - throw new InvalidOperationException( "Can not rename root." ); - } - - newName = newName.Replace( '/', '\\' ); - if( target.Name == newName ) - { - return false; - } - - if( target.Parent!.FindSubFolder( newName, out var preExisting ) ) - { - MergeNoSave( target, preExisting ); - } - else - { - var parent = target.Parent; - parent.RemoveFolderIgnoreEmpty( target ); - target.Name = newName; - parent.FindOrAddSubFolder( target ); - } - - return true; - } - - private static bool RenameNoSave( ModData mod, string newName ) - { - newName = newName.Replace( '/', '\\' ); - if( mod.SortOrder.SortOrderName == newName ) - { - return false; - } - - mod.SortOrder.ParentFolder.RemoveModIgnoreEmpty( mod ); - mod.SortOrder = new SortOrder( mod.SortOrder.ParentFolder, newName ); - mod.SortOrder.ParentFolder.AddMod( mod ); - return true; - } - - private static bool MoveNoSave( ModData mod, ModFolder target ) - { - var oldParent = mod.SortOrder.ParentFolder; - if( ReferenceEquals( target, oldParent ) ) - { - return false; - } - - oldParent.RemoveMod( mod ); - mod.SortOrder = new SortOrder( target, mod.SortOrder.SortOrderName ); - target.AddMod( mod ); - return true; - } - - private static bool MergeNoSave( ModFolder source, ModFolder target ) - { - if( ReferenceEquals( source, target ) ) - { - return false; - } - - var any = false; - while( source.SubFolders.Count > 0 ) - { - any |= MoveNoSave( source.SubFolders.First(), target ); - } - - while( source.Mods.Count > 0 ) - { - any |= MoveNoSave( source.Mods.First(), target ); - } - - source.Parent?.RemoveSubFolder( source ); - - return any || source.Parent != null; - } - - private static bool MoveNoSave( ModFolder folder, ModFolder target ) - { - // Moving a folder into itself is not permitted. - if( ReferenceEquals( folder, target ) ) - { - return false; - } - - if( ReferenceEquals( target, folder.Parent! ) ) - { - return false; - } - - folder.Parent!.RemoveSubFolder( folder ); - var subFolderIdx = target.FindOrAddSubFolder( folder ); - if( subFolderIdx > 0 ) - { - var main = target.SubFolders[ subFolderIdx ]; - MergeNoSave( folder, main ); - } - - return true; - } - } -} \ No newline at end of file diff --git a/Penumbra/Mods/ModFolder.cs b/Penumbra/Mods/ModFolder.cs deleted file mode 100644 index 4b4b6422..00000000 --- a/Penumbra/Mods/ModFolder.cs +++ /dev/null @@ -1,243 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using Penumbra.Mod; - -namespace Penumbra.Mods -{ - public partial class ModFolder - { - public ModFolder? Parent; - - public string FullName - { - get - { - var parentPath = Parent?.FullName ?? string.Empty; - return parentPath.Any() ? $"{parentPath}/{Name}" : Name; - } - } - - private string _name = string.Empty; - - public string Name - { - get => _name; - set => _name = value.Replace( '/', '\\' ); - } - - public List< ModFolder > SubFolders { get; } = new(); - public List< ModData > Mods { get; } = new(); - - public ModFolder( ModFolder parent, string name ) - { - Parent = parent; - Name = name; - } - - public override string ToString() - => FullName; - - public int TotalDescendantMods() - => Mods.Count + SubFolders.Sum( f => f.TotalDescendantMods() ); - - public int TotalDescendantFolders() - => SubFolders.Sum( f => f.TotalDescendantFolders() ); - - // Return all descendant mods in the specified order. - public IEnumerable< ModData > AllMods( bool foldersFirst ) - { - if( foldersFirst ) - { - return SubFolders.SelectMany( f => f.AllMods( foldersFirst ) ).Concat( Mods ); - } - - return GetSortedEnumerator().SelectMany( f => - { - if( f is ModFolder folder ) - { - return folder.AllMods( false ); - } - - return new[] { ( ModData )f }; - } ); - } - - // Return all descendant subfolders. - public IEnumerable< ModFolder > AllFolders() - => SubFolders.SelectMany( f => f.AllFolders() ).Prepend( this ); - - // Iterate through all descendants in the specified order, returning subfolders as well as mods. - public IEnumerable< object > GetItems( bool foldersFirst ) - => foldersFirst ? SubFolders.Cast< object >().Concat( Mods ) : GetSortedEnumerator(); - - // Find a subfolder by name. Returns true and sets folder to it if it exists. - public bool FindSubFolder( string name, out ModFolder folder ) - { - var subFolder = new ModFolder( this, name ); - var idx = SubFolders.BinarySearch( subFolder, FolderComparer ); - folder = idx >= 0 ? SubFolders[ idx ] : this; - return idx >= 0; - } - - // Checks if an equivalent subfolder as folder already exists and returns its index. - // If it does not exist, inserts folder as a subfolder and returns the new index. - // Also sets this as folders parent. - public int FindOrAddSubFolder( ModFolder folder ) - { - var idx = SubFolders.BinarySearch( folder, FolderComparer ); - if( idx >= 0 ) - { - return idx; - } - - idx = ~idx; - SubFolders.Insert( idx, folder ); - folder.Parent = this; - return idx; - } - - // Checks if a subfolder with the given name already exists and returns it and its index. - // If it does not exists, creates and inserts it and returns the new subfolder and its index. - public (ModFolder, int) FindOrCreateSubFolder( string name ) - { - var subFolder = new ModFolder( this, name ); - var idx = FindOrAddSubFolder( subFolder ); - return ( SubFolders[ idx ], idx ); - } - - // Remove folder as a subfolder if it exists. - // If this folder is empty afterwards, remove it from its parent. - public void RemoveSubFolder( ModFolder folder ) - { - RemoveFolderIgnoreEmpty( folder ); - CheckEmpty(); - } - - // Add the given mod as a child, if it is not already a child. - // Returns the index of the found or inserted mod. - public int AddMod( ModData mod ) - { - var idx = Mods.BinarySearch( mod, ModComparer ); - if( idx >= 0 ) - { - return idx; - } - - idx = ~idx; - Mods.Insert( idx, mod ); - - return idx; - } - - // Remove mod as a child if it exists. - // If this folder is empty afterwards, remove it from its parent. - public void RemoveMod( ModData mod ) - { - RemoveModIgnoreEmpty( mod ); - CheckEmpty(); - } - } - - // Internals - public partial class ModFolder - { - // Create a Root folder without parent. - internal static ModFolder CreateRoot() - => new( null!, string.Empty ); - - internal class ModFolderComparer : IComparer< ModFolder > - { - // Compare only the direct folder names since this is only used inside an enumeration of subfolders of one folder. - public int Compare( ModFolder? x, ModFolder? y ) - => ReferenceEquals( x, y ) - ? 0 - : string.Compare( x?.Name ?? string.Empty, y?.Name ?? string.Empty, StringComparison.InvariantCultureIgnoreCase ); - } - - internal class ModDataComparer : IComparer< ModData > - { - // Compare only the direct SortOrderNames since this is only used inside an enumeration of direct mod children of one folder. - // Since mod SortOrderNames do not have to be unique inside a folder, also compare their BasePaths (and thus their identity) if necessary. - public int Compare( ModData? x, ModData? y ) - { - if( ReferenceEquals( x, y ) ) - { - return 0; - } - - var cmp = string.Compare( x?.SortOrder.SortOrderName, y?.SortOrder.SortOrderName, StringComparison.InvariantCultureIgnoreCase ); - if( cmp != 0 ) - { - return cmp; - } - - return string.Compare( x?.BasePath.Name, y?.BasePath.Name, StringComparison.InvariantCulture ); - } - } - - private static readonly ModFolderComparer FolderComparer = new(); - private static readonly ModDataComparer ModComparer = new(); - - // Get an enumerator for actually sorted objects instead of folder-first objects. - private IEnumerable< object > GetSortedEnumerator() - { - var modIdx = 0; - foreach( var folder in SubFolders ) - { - var folderString = folder.Name; - for( ; modIdx < Mods.Count; ++modIdx ) - { - var mod = Mods[ modIdx ]; - var modString = mod.SortOrder.SortOrderName; - if( string.Compare( folderString, modString, StringComparison.InvariantCultureIgnoreCase ) > 0 ) - { - yield return mod; - } - else - { - break; - } - } - - yield return folder; - } - - for( ; modIdx < Mods.Count; ++modIdx ) - { - yield return Mods[ modIdx ]; - } - } - - private void CheckEmpty() - { - if( Mods.Count == 0 && SubFolders.Count == 0 ) - { - Parent?.RemoveSubFolder( this ); - } - } - - // Remove a subfolder but do not remove this folder from its parent if it is empty afterwards. - internal void RemoveFolderIgnoreEmpty( ModFolder folder ) - { - var idx = SubFolders.BinarySearch( folder, FolderComparer ); - if( idx < 0 ) - { - return; - } - - SubFolders[ idx ].Parent = null; - SubFolders.RemoveAt( idx ); - } - - // Remove a mod, but do not remove this folder from its parent if it is empty afterwards. - internal void RemoveModIgnoreEmpty( ModData mod ) - { - var idx = Mods.BinarySearch( mod, ModComparer ); - if( idx >= 0 ) - { - Mods.RemoveAt( idx ); - } - } - } -} \ No newline at end of file diff --git a/Penumbra/Mods/ModLocalData.cs b/Penumbra/Mods/ModLocalData.cs new file mode 100644 index 00000000..cc20fad6 --- /dev/null +++ b/Penumbra/Mods/ModLocalData.cs @@ -0,0 +1,131 @@ +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using Penumbra.GameData.Structs; +using Penumbra.Mods.Manager; +using Penumbra.Services; + +namespace Penumbra.Mods; + +public readonly struct ModLocalData(Mod mod) : ISavable +{ + public const int FileVersion = 3; + + public string ToFilename(FilenameService fileNames) + => fileNames.LocalDataFile(mod); + + public void Save(StreamWriter writer) + { + var jObject = new JObject + { + { nameof(FileVersion), JToken.FromObject(FileVersion) }, + { nameof(Mod.ImportDate), JToken.FromObject(mod.ImportDate) }, + { nameof(Mod.LocalTags), JToken.FromObject(mod.LocalTags) }, + { nameof(Mod.Note), JToken.FromObject(mod.Note) }, + { nameof(Mod.Favorite), JToken.FromObject(mod.Favorite) }, + { nameof(Mod.PreferredChangedItems), JToken.FromObject(mod.PreferredChangedItems) }, + }; + using var jWriter = new JsonTextWriter(writer); + jWriter.Formatting = Formatting.Indented; + jObject.WriteTo(jWriter); + } + + public static ModDataChangeType Load(ModDataEditor editor, Mod mod) + { + var dataFile = editor.SaveService.FileNames.LocalDataFile(mod); + + var importDate = 0L; + var localTags = Enumerable.Empty(); + var favorite = false; + var note = string.Empty; + + HashSet preferredChangedItems = []; + + var save = true; + if (File.Exists(dataFile)) + try + { + var text = File.ReadAllText(dataFile); + var json = JObject.Parse(text); + + importDate = json[nameof(Mod.ImportDate)]?.Value() ?? importDate; + favorite = json[nameof(Mod.Favorite)]?.Value() ?? favorite; + note = json[nameof(Mod.Note)]?.Value() ?? note; + localTags = (json[nameof(Mod.LocalTags)] as JArray)?.Values().OfType() ?? localTags; + preferredChangedItems = (json[nameof(Mod.PreferredChangedItems)] as JArray)?.Values().Select(i => (CustomItemId) i).ToHashSet() ?? mod.DefaultPreferredItems; + save = false; + } + catch (Exception e) + { + Penumbra.Log.Error($"Could not load local mod data:\n{e}"); + } + else + { + preferredChangedItems = mod.DefaultPreferredItems; + } + + if (importDate == 0) + importDate = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + + ModDataChangeType changes = 0; + if (mod.ImportDate != importDate) + { + mod.ImportDate = importDate; + changes |= ModDataChangeType.ImportDate; + } + + changes |= UpdateTags(mod, null, localTags); + + if (mod.Favorite != favorite) + { + mod.Favorite = favorite; + changes |= ModDataChangeType.Favorite; + } + + if (mod.Note != note) + { + mod.Note = note; + changes |= ModDataChangeType.Note; + } + + if (!preferredChangedItems.SetEquals(mod.PreferredChangedItems)) + { + mod.PreferredChangedItems = preferredChangedItems; + changes |= ModDataChangeType.PreferredChangedItems; + } + + if (save) + editor.SaveService.QueueSave(new ModLocalData(mod)); + + return changes; + } + + internal static ModDataChangeType UpdateTags(Mod mod, IEnumerable? newModTags, IEnumerable? newLocalTags) + { + if (newModTags == null && newLocalTags == null) + return 0; + + ModDataChangeType type = 0; + if (newModTags != null) + { + var modTags = newModTags.Where(t => t.Length > 0).Distinct().ToArray(); + if (!modTags.SequenceEqual(mod.ModTags)) + { + newLocalTags ??= mod.LocalTags; + mod.ModTags = modTags; + type |= ModDataChangeType.ModTags; + } + } + + if (newLocalTags != null) + { + var localTags = newLocalTags!.Where(t => t.Length > 0 && !mod.ModTags.Contains(t)).Distinct().ToArray(); + if (!localTags.SequenceEqual(mod.LocalTags)) + { + mod.LocalTags = localTags; + type |= ModDataChangeType.LocalTags; + } + } + + return type; + } +} diff --git a/Penumbra/Mods/ModManager.cs b/Penumbra/Mods/ModManager.cs deleted file mode 100644 index 4a42d7b6..00000000 --- a/Penumbra/Mods/ModManager.cs +++ /dev/null @@ -1,398 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using Dalamud.Logging; -using Penumbra.GameData.Util; -using Penumbra.Meta; -using Penumbra.Mod; - -namespace Penumbra.Mods -{ - // The ModManager handles the basic mods installed to the mod directory. - // It also contains the CollectionManager that handles all collections. - public class ModManager - { - public DirectoryInfo BasePath { get; private set; } = null!; - public DirectoryInfo TempPath { get; private set; } = null!; - - public Dictionary< string, ModData > Mods { get; } = new(); - public ModFolder StructuredMods { get; } = ModFileSystem.Root; - - public CollectionManager Collections { get; } - - public bool Valid { get; private set; } - public bool TempWritable { get; private set; } - - public Configuration Config - => Penumbra.Config; - - public void DiscoverMods( string newDir ) - { - SetBaseDirectory( newDir, false ); - DiscoverMods(); - } - - private void ClearOldTmpDir() - { - if( !TempWritable ) - { - return; - } - - TempPath.Refresh(); - if( TempPath.Exists ) - { - try - { - TempPath.Delete( true ); - } - catch( Exception e ) - { - PluginLog.Error( $"Could not delete temporary directory {TempPath.FullName}:\n{e}" ); - } - } - } - - private static bool CheckTmpDir( string newPath, out DirectoryInfo tmpDir ) - { - tmpDir = new DirectoryInfo( Path.Combine( newPath, MetaManager.TmpDirectory ) ); - try - { - if( tmpDir.Exists ) - { - tmpDir.Delete( true ); - tmpDir.Refresh(); - } - - Directory.CreateDirectory( tmpDir.FullName ); - tmpDir.Refresh(); - return true; - } - catch( Exception e ) - { - PluginLog.Error( $"Could not create temporary directory {tmpDir.FullName}:\n{e}" ); - return false; - } - } - - private void SetBaseDirectory( string newPath, bool firstTime ) - { - if( !firstTime && string.Equals( newPath, Config.ModDirectory, StringComparison.InvariantCultureIgnoreCase ) ) - { - return; - } - - if( !newPath.Any() ) - { - Valid = false; - BasePath = new DirectoryInfo( "." ); - } - else - { - var newDir = new DirectoryInfo( newPath ); - if( !newDir.Exists ) - { - try - { - Directory.CreateDirectory( newDir.FullName ); - newDir.Refresh(); - } - catch( Exception e ) - { - PluginLog.Error( $"Could not create specified mod directory {newDir.FullName}:\n{e}" ); - } - } - - BasePath = newDir; - Valid = true; - if( Config.ModDirectory != BasePath.FullName ) - { - Config.ModDirectory = BasePath.FullName; - Config.Save(); - } - - if( !Config.TempDirectory.Any() ) - { - if( CheckTmpDir( BasePath.FullName, out var newTmpDir ) ) - { - if( !firstTime ) - { - ClearOldTmpDir(); - } - - TempPath = newTmpDir; - TempWritable = true; - } - else - { - TempWritable = false; - } - } - } - } - - private void SetTempDirectory( string newPath, bool firstTime ) - { - if( !Valid || !firstTime && string.Equals( newPath, Config.TempDirectory, StringComparison.InvariantCultureIgnoreCase ) ) - { - return; - } - - if( !newPath.Any() && CheckTmpDir( BasePath.FullName, out var newTmpDir ) - || newPath.Any() && CheckTmpDir( newPath, out newTmpDir ) ) - { - if( !firstTime ) - { - ClearOldTmpDir(); - } - - TempPath = newTmpDir; - TempWritable = true; - var newName = newPath.Any() ? TempPath.Parent!.FullName : string.Empty; - if( Config.TempDirectory != newName ) - { - Config.TempDirectory = newName; - Config.Save(); - } - - if( !firstTime ) - { - Collections.RecreateCaches(); - } - } - else - { - TempWritable = false; - } - } - - public void SetTempDirectory( string newPath ) - => SetTempDirectory( newPath, false ); - - public ModManager() - { - SetBaseDirectory( Config.ModDirectory, true ); - SetTempDirectory( Config.TempDirectory, true ); - Collections = new CollectionManager( this ); - } - - private bool SetSortOrderPath( ModData mod, string path ) - { - mod.Move( path ); - var fixedPath = mod.SortOrder.FullPath; - if( !fixedPath.Any() || string.Equals( fixedPath, mod.Meta.Name, StringComparison.InvariantCultureIgnoreCase ) ) - { - Config.ModSortOrder.Remove( mod.BasePath.Name ); - return true; - } - - if( path != fixedPath ) - { - Config.ModSortOrder[ mod.BasePath.Name ] = fixedPath; - return true; - } - - return false; - } - - private void SetModStructure( bool removeOldPaths = false ) - { - var changes = false; - - foreach( var kvp in Config.ModSortOrder.ToArray() ) - { - if( kvp.Value.Any() && Mods.TryGetValue( kvp.Key, out var mod ) ) - { - changes |= SetSortOrderPath( mod, kvp.Value ); - } - else if( removeOldPaths ) - { - changes = true; - Config.ModSortOrder.Remove( kvp.Key ); - } - } - - if( changes ) - { - Config.Save(); - } - } - - public void DiscoverMods() - { - Mods.Clear(); - BasePath.Refresh(); - - StructuredMods.SubFolders.Clear(); - StructuredMods.Mods.Clear(); - if( Valid && BasePath.Exists ) - { - foreach( var modFolder in BasePath.EnumerateDirectories() ) - { - var mod = ModData.LoadMod( StructuredMods, modFolder ); - if( mod == null ) - { - continue; - } - - Mods.Add( modFolder.Name, mod ); - } - - SetModStructure(); - } - - Collections.RecreateCaches(); - } - - public void DeleteMod( DirectoryInfo modFolder ) - { - modFolder.Refresh(); - if( modFolder.Exists ) - { - try - { - Directory.Delete( modFolder.FullName, true ); - } - catch( Exception e ) - { - PluginLog.Error( $"Could not delete the mod {modFolder.Name}:\n{e}" ); - } - - if( Mods.TryGetValue( modFolder.Name, out var mod ) ) - { - mod.SortOrder.ParentFolder.RemoveMod( mod ); - Mods.Remove( modFolder.Name ); - Collections.RemoveModFromCaches( modFolder ); - } - } - } - - public bool AddMod( DirectoryInfo modFolder ) - { - var mod = ModData.LoadMod( StructuredMods, modFolder ); - if( mod == null ) - { - return false; - } - - if( Config.ModSortOrder.TryGetValue( mod.BasePath.Name, out var sortOrder ) ) - { - if( SetSortOrderPath( mod, sortOrder ) ) - { - Config.Save(); - } - } - - if( Mods.ContainsKey( modFolder.Name ) ) - { - return false; - } - - Mods.Add( modFolder.Name, mod ); - foreach( var collection in Collections.Collections.Values ) - { - collection.AddMod( mod ); - } - - return true; - } - - public bool UpdateMod( ModData mod, bool reloadMeta = false, bool recomputeMeta = false ) - { - var oldName = mod.Meta.Name; - var metaChanges = mod.Meta.RefreshFromFile( mod.MetaFile ); - var fileChanges = mod.Resources.RefreshModFiles( mod.BasePath ); - - if( !recomputeMeta && !reloadMeta && !metaChanges && fileChanges == 0 ) - { - return false; - } - - if( metaChanges || fileChanges.HasFlag( ResourceChange.Files ) ) - { - mod.ComputeChangedItems(); - if( Config.ModSortOrder.TryGetValue( mod.BasePath.Name, out var sortOrder ) ) - { - mod.Move( sortOrder ); - var path = mod.SortOrder.FullPath; - if( path != sortOrder ) - { - Config.ModSortOrder[ mod.BasePath.Name ] = path; - Config.Save(); - } - } - else - { - mod.SortOrder = new SortOrder( StructuredMods, mod.Meta.Name ); - } - } - - var nameChange = !string.Equals( oldName, mod.Meta.Name, StringComparison.InvariantCulture ); - - recomputeMeta |= fileChanges.HasFlag( ResourceChange.Meta ); - if( recomputeMeta ) - { - mod.Resources.MetaManipulations.Update( mod.Resources.MetaFiles, mod.BasePath, mod.Meta ); - mod.Resources.MetaManipulations.SaveToFile( MetaCollection.FileName( mod.BasePath ) ); - } - - Collections.UpdateCollections( mod, metaChanges, fileChanges, nameChange, reloadMeta ); - - return true; - } - - public string? ResolveSwappedOrReplacementPath( GamePath gameResourcePath ) - { - var ret = Collections.ActiveCollection.ResolveSwappedOrReplacementPath( gameResourcePath ); - ret ??= Collections.ForcedCollection.ResolveSwappedOrReplacementPath( gameResourcePath ); - return ret; - } - - // private void FileSystemWatcherOnChanged( object sender, FileSystemEventArgs e ) - // { - // #if DEBUG - // PluginLog.Verbose( "file changed: {FullPath}", e.FullPath ); - // #endif - // - // if( _plugin.ImportInProgress ) - // { - // return; - // } - // - // if( _plugin.Configuration.DisableFileSystemNotifications ) - // { - // return; - // } - // - // var file = e.FullPath; - // - // if( !ResolvedFiles.Any( x => x.Value.FullName == file ) ) - // { - // return; - // } - // - // PluginLog.Log( "a loaded file has been modified - file: {FullPath}", file ); - // _plugin.GameUtils.ReloadPlayerResources(); - // } - // - // private void FileSystemPasta() - // { - // haha spaghet - // _fileSystemWatcher?.Dispose(); - // _fileSystemWatcher = new FileSystemWatcher( _basePath.FullName ) - // { - // NotifyFilter = NotifyFilters.LastWrite | - // NotifyFilters.FileName | - // NotifyFilters.DirectoryName, - // IncludeSubdirectories = true, - // EnableRaisingEvents = true - // }; - // - // _fileSystemWatcher.Changed += FileSystemWatcherOnChanged; - // _fileSystemWatcher.Created += FileSystemWatcherOnChanged; - // _fileSystemWatcher.Deleted += FileSystemWatcherOnChanged; - // _fileSystemWatcher.Renamed += FileSystemWatcherOnChanged; - // } - } -} \ No newline at end of file diff --git a/Penumbra/Mods/ModManagerEditExtensions.cs b/Penumbra/Mods/ModManagerEditExtensions.cs deleted file mode 100644 index 6f510f8a..00000000 --- a/Penumbra/Mods/ModManagerEditExtensions.cs +++ /dev/null @@ -1,219 +0,0 @@ -using System; -using System.Collections.Generic; -using System.ComponentModel; -using System.IO; -using System.Linq; -using Dalamud.Logging; -using Dalamud.Plugin; -using Penumbra.Mod; -using Penumbra.Structs; - -namespace Penumbra.Mods -{ - // Extracted to keep the main file a bit more clean. - // Contains all change functions on a specific mod that also require corresponding changes to collections. - public static class ModManagerEditExtensions - { - public static bool RenameMod( this ModManager manager, string newName, ModData mod ) - { - if( newName.Length == 0 || string.Equals( newName, mod.Meta.Name, StringComparison.InvariantCulture ) ) - { - return false; - } - - mod.Meta.Name = newName; - mod.SaveMeta(); - - return true; - } - - public static bool ChangeSortOrder( this ModManager manager, ModData mod, string newSortOrder ) - { - if( string.Equals(mod.SortOrder.FullPath, newSortOrder, StringComparison.InvariantCultureIgnoreCase ) ) - { - return false; - } - - var inRoot = new SortOrder( manager.StructuredMods, mod.Meta.Name ); - if( newSortOrder == string.Empty || newSortOrder == inRoot.SortOrderName ) - { - mod.SortOrder = inRoot; - manager.Config.ModSortOrder.Remove( mod.BasePath.Name ); - } - else - { - mod.Move( newSortOrder ); - manager.Config.ModSortOrder[ mod.BasePath.Name ] = mod.SortOrder.FullPath; - } - - manager.Config.Save(); - - return true; - } - - public static bool RenameModFolder( this ModManager manager, ModData mod, DirectoryInfo newDir, bool move = true ) - { - if( move ) - { - newDir.Refresh(); - if( newDir.Exists ) - { - return false; - } - - var oldDir = new DirectoryInfo( mod.BasePath.FullName ); - try - { - oldDir.MoveTo( newDir.FullName ); - } - catch( Exception e ) - { - PluginLog.Error( $"Error while renaming directory {oldDir.FullName} to {newDir.FullName}:\n{e}" ); - return false; - } - } - - manager.Mods.Remove( mod.BasePath.Name ); - manager.Mods[ newDir.Name ] = mod; - - var oldBasePath = mod.BasePath; - mod.BasePath = newDir; - mod.MetaFile = ModData.MetaFileInfo( newDir ); - manager.UpdateMod( mod ); - - if( manager.Config.ModSortOrder.ContainsKey( oldBasePath.Name ) ) - { - manager.Config.ModSortOrder[ newDir.Name ] = manager.Config.ModSortOrder[ oldBasePath.Name ]; - manager.Config.ModSortOrder.Remove( oldBasePath.Name ); - manager.Config.Save(); - } - - foreach( var collection in manager.Collections.Collections.Values ) - { - if( collection.Settings.TryGetValue( oldBasePath.Name, out var settings ) ) - { - collection.Settings[ newDir.Name ] = settings; - collection.Settings.Remove( oldBasePath.Name ); - collection.Save(); - } - - if( collection.Cache != null ) - { - collection.Cache.RemoveMod( newDir ); - collection.AddMod( mod ); - } - } - - return true; - } - - public static bool ChangeModGroup( this ModManager manager, string oldGroupName, string newGroupName, ModData mod, - SelectType type = SelectType.Single ) - { - if( newGroupName == oldGroupName || mod.Meta.Groups.ContainsKey( newGroupName ) ) - { - return false; - } - - if( mod.Meta.Groups.TryGetValue( oldGroupName, out var oldGroup ) ) - { - if( newGroupName.Length > 0 ) - { - mod.Meta.Groups[ newGroupName ] = new OptionGroup() - { - GroupName = newGroupName, - SelectionType = oldGroup.SelectionType, - Options = oldGroup.Options, - }; - } - - mod.Meta.Groups.Remove( oldGroupName ); - } - else - { - if( newGroupName.Length == 0 ) - { - return false; - } - - mod.Meta.Groups[ newGroupName ] = new OptionGroup() - { - GroupName = newGroupName, - SelectionType = type, - Options = new List< Option >(), - }; - } - - mod.SaveMeta(); - - foreach( var collection in manager.Collections.Collections.Values ) - { - if( !collection.Settings.TryGetValue( mod.BasePath.Name, out var settings ) ) - { - continue; - } - - if( newGroupName.Length > 0 ) - { - settings.Settings[ newGroupName ] = settings.Settings.TryGetValue( oldGroupName, out var value ) ? value : 0; - } - - settings.Settings.Remove( oldGroupName ); - collection.Save(); - } - - return true; - } - - public static bool RemoveModOption( this ModManager manager, int optionIdx, OptionGroup group, ModData mod ) - { - if( optionIdx < 0 || optionIdx >= group.Options.Count ) - { - return false; - } - - group.Options.RemoveAt( optionIdx ); - mod.SaveMeta(); - - static int MoveMultiSetting( int oldSetting, int idx ) - { - var bitmaskFront = ( 1 << idx ) - 1; - var bitmaskBack = ~( bitmaskFront | ( 1 << idx ) ); - return ( oldSetting & bitmaskFront ) | ( ( oldSetting & bitmaskBack ) >> 1 ); - } - - foreach( var collection in manager.Collections.Collections.Values ) - { - if( !collection.Settings.TryGetValue( mod.BasePath.Name, out var settings ) ) - { - continue; - } - - if( !settings.Settings.TryGetValue( group.GroupName, out var setting ) ) - { - setting = 0; - } - - var newSetting = group.SelectionType switch - { - SelectType.Single => setting >= optionIdx ? setting - 1 : setting, - SelectType.Multi => MoveMultiSetting( setting, optionIdx ), - _ => throw new InvalidEnumArgumentException(), - }; - - if( newSetting != setting ) - { - settings.Settings[ group.GroupName ] = newSetting; - collection.Save(); - if( collection.Cache != null && settings.Enabled ) - { - collection.CalculateEffectiveFileList( manager.TempPath, mod.Resources.MetaManipulations.Count > 0, - collection == manager.Collections.ActiveCollection ); - } - } - } - - return true; - } - } -} \ No newline at end of file diff --git a/Penumbra/Mods/ModMeta.cs b/Penumbra/Mods/ModMeta.cs new file mode 100644 index 00000000..b52eecf4 --- /dev/null +++ b/Penumbra/Mods/ModMeta.cs @@ -0,0 +1,145 @@ +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using Penumbra.GameData.Structs; +using Penumbra.Mods.Manager; +using Penumbra.Services; + +namespace Penumbra.Mods; + +public readonly struct ModMeta(Mod mod) : ISavable +{ + public const uint FileVersion = 3; + + public string ToFilename(FilenameService fileNames) + => fileNames.ModMetaPath(mod); + + public void Save(StreamWriter writer) + { + var jObject = new JObject + { + { nameof(FileVersion), JToken.FromObject(FileVersion) }, + { nameof(Mod.Name), JToken.FromObject(mod.Name) }, + { nameof(Mod.Author), JToken.FromObject(mod.Author) }, + { nameof(Mod.Description), JToken.FromObject(mod.Description) }, + { nameof(Mod.Image), JToken.FromObject(mod.Image) }, + { nameof(Mod.Version), JToken.FromObject(mod.Version) }, + { nameof(Mod.Website), JToken.FromObject(mod.Website) }, + { nameof(Mod.ModTags), JToken.FromObject(mod.ModTags) }, + { nameof(Mod.DefaultPreferredItems), JToken.FromObject(mod.DefaultPreferredItems) }, + }; + if (mod.RequiredFeatures is not FeatureFlags.None) + { + var features = mod.RequiredFeatures; + var array = new JArray(); + foreach (var flag in Enum.GetValues()) + { + if ((features & flag) is not FeatureFlags.None) + array.Add(flag.ToString()); + } + + jObject[nameof(Mod.RequiredFeatures)] = array; + } + + using var jWriter = new JsonTextWriter(writer); + jWriter.Formatting = Formatting.Indented; + jObject.WriteTo(jWriter); + } + + public static ModDataChangeType Load(ModDataEditor editor, ModCreator creator, Mod mod) + { + var metaFile = editor.SaveService.FileNames.ModMetaPath(mod); + if (!File.Exists(metaFile)) + { + Penumbra.Log.Debug($"No mod meta found for {mod.ModPath.Name}."); + return ModDataChangeType.Deletion; + } + + try + { + var text = File.ReadAllText(metaFile); + var json = JObject.Parse(text); + + var newFileVersion = json[nameof(FileVersion)]?.Value() ?? 0; + + // Empty name gets checked after loading and is not allowed. + var newName = json[nameof(Mod.Name)]?.Value() ?? string.Empty; + + var newAuthor = json[nameof(Mod.Author)]?.Value() ?? string.Empty; + var newDescription = json[nameof(Mod.Description)]?.Value() ?? string.Empty; + var newImage = json[nameof(Mod.Image)]?.Value() ?? string.Empty; + var newVersion = json[nameof(Mod.Version)]?.Value() ?? string.Empty; + var newWebsite = json[nameof(Mod.Website)]?.Value() ?? string.Empty; + var modTags = (json[nameof(Mod.ModTags)] as JArray)?.Values().OfType(); + var defaultItems = (json[nameof(Mod.DefaultPreferredItems)] as JArray)?.Values().Select(i => (CustomItemId)i).ToHashSet() + ?? []; + var requiredFeatureArray = (json[nameof(Mod.RequiredFeatures)] as JArray)?.Values() ?? []; + var requiredFeatures = FeatureChecker.ParseFlags(mod.ModPath.Name, newName.Length > 0 ? newName : mod.Name.Length > 0 ? mod.Name : "Unknown", requiredFeatureArray!); + + ModDataChangeType changes = 0; + if (mod.Name != newName) + { + changes |= ModDataChangeType.Name; + mod.Name = newName; + } + + if (mod.Author != newAuthor) + { + changes |= ModDataChangeType.Author; + mod.Author = newAuthor; + } + + if (mod.Description != newDescription) + { + changes |= ModDataChangeType.Description; + mod.Description = newDescription; + } + + if (mod.Image != newImage) + { + changes |= ModDataChangeType.Image; + mod.Image = newImage; + } + + if (mod.Version != newVersion) + { + changes |= ModDataChangeType.Version; + mod.Version = newVersion; + } + + if (mod.Website != newWebsite) + { + changes |= ModDataChangeType.Website; + mod.Website = newWebsite; + } + + if (!mod.DefaultPreferredItems.SetEquals(defaultItems)) + { + changes |= ModDataChangeType.DefaultChangedItems; + mod.DefaultPreferredItems = defaultItems; + } + + if (newFileVersion != FileVersion) + if (ModMigration.Migrate(creator, editor.SaveService, mod, json, ref newFileVersion)) + { + changes |= ModDataChangeType.Migration; + editor.SaveService.ImmediateSave(new ModMeta(mod)); + } + + // Required features get checked during parsing, in which case the new required features signal invalid. + if (requiredFeatures != mod.RequiredFeatures) + { + changes |= ModDataChangeType.RequiredFeatures; + mod.RequiredFeatures = requiredFeatures; + } + + changes |= ModLocalData.UpdateTags(mod, modTags, null); + + return changes; + } + catch (Exception e) + { + Penumbra.Log.Error($"Could not load mod meta for {metaFile}:\n{e}"); + return ModDataChangeType.Deletion; + } + } +} diff --git a/Penumbra/Mods/ModSelection.cs b/Penumbra/Mods/ModSelection.cs new file mode 100644 index 00000000..b728bd00 --- /dev/null +++ b/Penumbra/Mods/ModSelection.cs @@ -0,0 +1,108 @@ +using OtterGui.Classes; +using Penumbra.Api.Enums; +using Penumbra.Collections; +using Penumbra.Collections.Manager; +using Penumbra.Communication; +using Penumbra.Mods.Manager; +using Penumbra.Mods.Settings; +using Penumbra.Services; + +namespace Penumbra.Mods; + +/// +/// Triggered whenever the selected mod changes +/// +/// Parameter is the old selected mod. +/// Parameter is the new selected mod +/// +/// +public class ModSelection : EventWrapper +{ + private readonly ActiveCollections _collections; + private readonly EphemeralConfig _config; + private readonly CommunicatorService _communicator; + + public ModSelection(CommunicatorService communicator, ModManager mods, ActiveCollections collections, EphemeralConfig config) + : base(nameof(ModSelection)) + { + _communicator = communicator; + _collections = collections; + _config = config; + if (_config.LastModPath.Length > 0) + SelectMod(mods.FirstOrDefault(m => string.Equals(m.Identifier, config.LastModPath, StringComparison.OrdinalIgnoreCase))); + + _communicator.CollectionChange.Subscribe(OnCollectionChange, CollectionChange.Priority.ModSelection); + _communicator.CollectionInheritanceChanged.Subscribe(OnInheritanceChange, CollectionInheritanceChanged.Priority.ModSelection); + _communicator.ModSettingChanged.Subscribe(OnSettingChange, ModSettingChanged.Priority.ModSelection); + } + + public ModSettings Settings { get; private set; } = ModSettings.Empty; + public ModCollection Collection { get; private set; } = ModCollection.Empty; + public Mod? Mod { get; private set; } + public ModSettings? OwnSettings { get; private set; } + public TemporaryModSettings? TemporarySettings { get; private set; } + + public void SelectMod(Mod? mod) + { + if (mod == Mod) + return; + + var oldMod = Mod; + Mod = mod; + OnCollectionChange(CollectionType.Current, null, _collections.Current, string.Empty); + Invoke(oldMod, Mod); + _config.LastModPath = mod?.ModPath.Name ?? string.Empty; + _config.Save(); + } + + protected override void Dispose(bool _) + { + _communicator.CollectionChange.Unsubscribe(OnCollectionChange); + _communicator.CollectionInheritanceChanged.Unsubscribe(OnInheritanceChange); + _communicator.ModSettingChanged.Unsubscribe(OnSettingChange); + } + + private void OnCollectionChange(CollectionType type, ModCollection? oldCollection, ModCollection? newCollection, string _2) + { + if (type is CollectionType.Current && oldCollection != newCollection) + UpdateSettings(); + } + + private void OnSettingChange(ModCollection collection, ModSettingChange _1, Mod? mod, Setting _2, int _3, bool _4) + { + if (collection == _collections.Current && mod == Mod) + UpdateSettings(); + } + + private void OnInheritanceChange(ModCollection collection, bool arg2) + { + if (collection == _collections.Current) + UpdateSettings(); + } + + private void UpdateSettings() + { + if (Mod == null) + { + Settings = ModSettings.Empty; + Collection = ModCollection.Empty; + OwnSettings = null; + } + else + { + (var settings, Collection) = _collections.Current.GetActualSettings(Mod.Index); + OwnSettings = _collections.Current.GetOwnSettings(Mod.Index); + TemporarySettings = _collections.Current.GetTempSettings(Mod.Index); + Settings = settings ?? ModSettings.Empty; + } + } + + public enum Priority + { + /// + ModPanel = 0, + + /// + ModMerger = 0, + } +} diff --git a/Penumbra/Mods/Settings/FullModSettings.cs b/Penumbra/Mods/Settings/FullModSettings.cs new file mode 100644 index 00000000..904b56bd --- /dev/null +++ b/Penumbra/Mods/Settings/FullModSettings.cs @@ -0,0 +1,19 @@ +namespace Penumbra.Mods.Settings; + +public readonly record struct FullModSettings(ModSettings? Settings = null, TemporaryModSettings? TempSettings = null) +{ + public static readonly FullModSettings Empty = new(); + + public ModSettings? Resolve() + { + if (TempSettings == null) + return Settings; + if (TempSettings.ForceInherit) + return null; + + return TempSettings; + } + + public FullModSettings DeepCopy() + => new(Settings?.DeepCopy()); +} diff --git a/Penumbra/Mods/Settings/ModPriority.cs b/Penumbra/Mods/Settings/ModPriority.cs new file mode 100644 index 00000000..cf234c00 --- /dev/null +++ b/Penumbra/Mods/Settings/ModPriority.cs @@ -0,0 +1,75 @@ +using Newtonsoft.Json; + +namespace Penumbra.Mods.Settings; + +[JsonConverter(typeof(Converter))] +public readonly record struct ModPriority(int Value) : + IComparisonOperators, + IAdditionOperators, + IAdditionOperators, + ISubtractionOperators, + ISubtractionOperators, + IIncrementOperators, + IComparable +{ + public static readonly ModPriority Default = new(0); + public static readonly ModPriority MaxValue = new(int.MaxValue); + + public bool IsDefault + => Value == Default.Value; + + public Setting AsSetting + => new((uint)Value); + + public ModPriority Max(ModPriority other) + => this < other ? other : this; + + public override string ToString() + => Value.ToString(); + + private class Converter : JsonConverter + { + public override void WriteJson(JsonWriter writer, ModPriority value, JsonSerializer serializer) + => serializer.Serialize(writer, value.Value); + + public override ModPriority ReadJson(JsonReader reader, Type objectType, ModPriority existingValue, bool hasExistingValue, + JsonSerializer serializer) + => new(serializer.Deserialize(reader)); + } + + public static bool operator >(ModPriority left, ModPriority right) + => left.Value > right.Value; + + public static bool operator >=(ModPriority left, ModPriority right) + => left.Value >= right.Value; + + public static bool operator <(ModPriority left, ModPriority right) + => left.Value < right.Value; + + public static bool operator <=(ModPriority left, ModPriority right) + => left.Value <= right.Value; + + public static ModPriority operator +(ModPriority left, ModPriority right) + => new(left.Value + right.Value); + + public static ModPriority operator +(ModPriority left, int right) + => new(left.Value + right); + + public static ModPriority operator -(ModPriority left, ModPriority right) + => new(left.Value - right.Value); + + public static ModPriority operator -(ModPriority left, int right) + => new(left.Value - right); + + public static ModPriority operator ++(ModPriority value) + => new(value.Value + 1); + + public int CompareTo(ModPriority other) + => Value.CompareTo(other.Value); + + public const int HiddenMin = -84037; + public const int HiddenMax = HiddenMin + 1000; + + public bool IsHidden + => Value is > HiddenMin and < HiddenMax; +} diff --git a/Penumbra/Mods/Settings/ModSettings.cs b/Penumbra/Mods/Settings/ModSettings.cs new file mode 100644 index 00000000..bbdd6bfa --- /dev/null +++ b/Penumbra/Mods/Settings/ModSettings.cs @@ -0,0 +1,208 @@ +using OtterGui.Extensions; +using OtterGui.Filesystem; +using Penumbra.Api.Enums; +using Penumbra.Mods.Editor; +using Penumbra.Mods.Groups; +using Penumbra.Mods.Manager.OptionEditor; +using Penumbra.Mods.SubMods; + +namespace Penumbra.Mods.Settings; + +/// Contains the settings for a given mod. +public class ModSettings +{ + public static readonly ModSettings Empty = new(true); + + public SettingList Settings { get; internal init; } = []; + public ModPriority Priority { get; set; } + public bool Enabled { get; set; } + public bool IsEmpty { get; protected init; } + + public ModSettings() + { } + + protected ModSettings(bool empty) + => IsEmpty = empty; + + // Create an independent copy of the current settings. + public ModSettings DeepCopy() + => new() + { + Enabled = Enabled, + Priority = Priority, + Settings = Settings.Clone(), + }; + + // Create default settings for a given mod. + public static ModSettings DefaultSettings(Mod mod) + => new() + { + Enabled = false, + Priority = ModPriority.Default, + Settings = SettingList.Default(mod), + }; + + // Return everything required to resolve things for a single mod with given settings (which can be null, in which case the default is used. + public static AppliedModData GetResolveData(Mod mod, ModSettings? settings) + { + if (settings == null) + settings = DefaultSettings(mod); + else + settings.Settings.FixSize(mod); + + return mod.GetData(settings); + } + + // Automatically react to changes in a mods available options. + public bool HandleChanges(ModOptionChangeType type, Mod mod, IModGroup? group, IModOption? option, int fromIdx) + { + switch (type) + { + case ModOptionChangeType.GroupRenamed: return true; + case ModOptionChangeType.GroupAdded: + // Add new empty setting for new mod. + Settings.Insert(group!.GetIndex(), group.DefaultSettings); + return true; + case ModOptionChangeType.GroupDeleted: + // Remove setting for deleted mod. + Settings.RemoveAt(fromIdx); + return true; + case ModOptionChangeType.GroupTypeChanged: + { + // Fix settings for a changed group type. + // Single -> Multi: set single as enabled, rest as disabled + // Multi -> Single: set the first enabled option or 0. + var idx = group!.GetIndex(); + var config = Settings[idx]; + Settings[idx] = group.Type switch + { + GroupType.Single => config.TurnMulti(group.Options.Count), + GroupType.Multi => Setting.Multi((int)config.Value), + _ => config, + }; + return config != Settings[idx]; + } + case ModOptionChangeType.OptionDeleted: + { + // Single -> select the previous option if any. + // Multi -> excise the corresponding bit. + var groupIdx = group!.GetIndex(); + var config = Settings[groupIdx]; + Settings[groupIdx] = group!.Type switch + { + GroupType.Single => config.RemoveSingle(fromIdx), + GroupType.Multi => config.RemoveBit(fromIdx), + GroupType.Imc => config.RemoveBit(fromIdx), + _ => config, + }; + return config != Settings[groupIdx]; + } + case ModOptionChangeType.GroupMoved: + // Move the group the same way. + return Settings.Move(fromIdx, group!.GetIndex()); + case ModOptionChangeType.OptionMoved: + { + // Single -> select the moved option if it was currently selected + // Multi -> move the corresponding bit + var groupIdx = group!.GetIndex(); + var toIdx = option!.GetIndex(); + var config = Settings[groupIdx]; + Settings[groupIdx] = group!.Type switch + { + GroupType.Single => config.MoveSingle(fromIdx, toIdx), + GroupType.Multi => config.MoveBit(fromIdx, toIdx), + GroupType.Imc => config.MoveBit(fromIdx, toIdx), + _ => config, + }; + return config != Settings[groupIdx]; + } + default: return false; + } + } + + /// Set a setting. Ensures that there are enough settings and fixes the setting beforehand. + public void SetValue(Mod mod, int groupIdx, Setting newValue) + { + Settings.FixSize(mod); + var group = mod.Groups[groupIdx]; + Settings[groupIdx] = group.FixSetting(newValue); + } + + // A simple struct conversion to easily save settings by name instead of value. + public struct SavedSettings + { + public Dictionary Settings; + public ModPriority Priority; + public bool Enabled; + + public SavedSettings DeepCopy() + => this with { Settings = Settings.ToDictionary(kvp => kvp.Key, kvp => kvp.Value) }; + + public SavedSettings(ModSettings settings, Mod mod) + { + Priority = settings.Priority; + Enabled = settings.Enabled; + Settings = new Dictionary(mod.Groups.Count); + settings.Settings.FixSize(mod); + + foreach (var (group, setting) in mod.Groups.Zip(settings.Settings)) + Settings.Add(group.Name, setting); + } + + // Convert and fix. + public readonly bool ToSettings(Mod mod, out ModSettings settings) + { + var list = new SettingList(mod.Groups.Count); + var changes = Settings.Count != mod.Groups.Count; + foreach (var group in mod.Groups) + { + if (Settings.TryGetValue(group.Name, out var config)) + { + var actualConfig = group.FixSetting(config); + list.Add(actualConfig); + if (actualConfig != config) + changes = true; + } + else + { + list.Add(group.DefaultSettings); + changes = true; + } + } + + settings = new ModSettings + { + Enabled = Enabled, + Priority = Priority, + Settings = list, + }; + + return changes; + } + } + + // Return the settings for a given mod in a shareable format, using the names of groups and options instead of indices. + // Does not repair settings but ignores settings not fitting to the given mod. + public (bool Enabled, ModPriority Priority, Dictionary> Settings) ConvertToShareable(Mod mod) + { + var dict = new Dictionary>(Settings.Count); + foreach (var (setting, idx) in Settings.WithIndex()) + { + if (idx >= mod.Groups.Count) + break; + + switch (mod.Groups[idx]) + { + case { Behaviour: GroupDrawBehaviour.SingleSelection } single when setting.Value < (ulong)single.Options.Count: + dict.Add(single.Name, [single.Options[setting.AsIndex].Name]); + break; + case { Behaviour: GroupDrawBehaviour.MultiSelection } multi: + var list = multi.Options.WithIndex().Where(p => setting.HasFlag(p.Index)).Select(p => p.Value.Name).ToList(); + dict.Add(multi.Name, list); + break; + } + } + + return (Enabled, Priority, dict); + } +} diff --git a/Penumbra/Mods/Settings/Setting.cs b/Penumbra/Mods/Settings/Setting.cs new file mode 100644 index 00000000..e8ad103c --- /dev/null +++ b/Penumbra/Mods/Settings/Setting.cs @@ -0,0 +1,108 @@ +using Newtonsoft.Json; +using OtterGui; + +namespace Penumbra.Mods.Settings; + +[JsonConverter(typeof(Converter))] +public readonly record struct Setting(ulong Value) +{ + public static readonly Setting Zero = new(0); + public static readonly Setting True = new(1); + public static readonly Setting False = new(0); + public static readonly Setting Indefinite = new(ulong.MaxValue); + + public static Setting Multi(int idx) + => new(1ul << idx); + + public static Setting Single(int idx) + => new(Math.Max(0ul, (ulong)idx)); + + public static Setting operator |(Setting lhs, Setting rhs) + => new(lhs.Value | rhs.Value); + + public int AsIndex + => (int)Math.Clamp(Value, 0ul, int.MaxValue); + + public bool HasFlag(int idx) + => idx >= 0 && (Value & (1ul << idx)) != 0; + + public Setting MoveBit(int idx1, int idx2) + => new(Functions.MoveBit(Value, idx1, idx2)); + + public Setting RemoveBit(int idx) + => new(Functions.RemoveBit(Value, idx)); + + public Setting SetBit(int idx, bool value) + => new(value ? Value | (1ul << idx) : Value & ~(1ul << idx)); + + public static Setting AllBits(int count) + => new((1ul << Math.Clamp(count, 0, 63)) - 1); + + public Setting TurnMulti(int count) + => new(Math.Max((ulong)Math.Min(count - 1, BitOperations.TrailingZeroCount(Value)), 0)); + + public Setting RemoveSingle(int singleIdx) + { + var settingIndex = AsIndex; + if (settingIndex >= singleIdx) + return settingIndex > 1 ? Single(settingIndex - 1) : Zero; + + return this; + } + + public Setting MoveSingle(int singleIdxFrom, int singleIdxTo) + { + var currentIndex = AsIndex; + if (currentIndex == singleIdxFrom) + return Single(singleIdxTo); + + if (singleIdxFrom < singleIdxTo) + { + if (currentIndex > singleIdxFrom && currentIndex <= singleIdxTo) + return Single(currentIndex - 1); + } + else if (currentIndex < singleIdxFrom && currentIndex >= singleIdxTo) + { + return Single(currentIndex + 1); + } + + return this; + } + + public ModPriority AsPriority + => new((int)(Value & 0xFFFFFFFF)); + + public static Setting FromBool(bool value) + => value ? True : False; + + public bool AsBool + => Value != 0; + + private class Converter : JsonConverter + { + public override void WriteJson(JsonWriter writer, Setting value, JsonSerializer serializer) + => serializer.Serialize(writer, value.Value); + + public override Setting ReadJson(JsonReader reader, Type objectType, Setting existingValue, bool hasExistingValue, + JsonSerializer serializer) + { + try + { + return new Setting(serializer.Deserialize(reader)); + } + catch (Exception e) + { + Penumbra.Log.Warning($"Could not deserialize setting {reader.Value} to unsigned long:\n{e}"); + try + { + return new Setting((ulong)serializer.Deserialize(reader)); + } + catch + { + Penumbra.Log.Warning($"Could not deserialize setting {reader.Value} to long:\n{e}"); + return Zero; + } + } + } + } +} diff --git a/Penumbra/Mods/Settings/SettingList.cs b/Penumbra/Mods/Settings/SettingList.cs new file mode 100644 index 00000000..67b1b947 --- /dev/null +++ b/Penumbra/Mods/Settings/SettingList.cs @@ -0,0 +1,57 @@ +namespace Penumbra.Mods.Settings; + +public class SettingList : List +{ + public SettingList() + { } + + public SettingList(int capacity) + : base(capacity) + { } + + public SettingList(IEnumerable settings) + => AddRange(settings); + + public SettingList Clone() + => new(this); + + public static SettingList Default(Mod mod) + => new(mod.Groups.Select(g => g.DefaultSettings)); + + [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] + public bool FixSize(Mod mod) + { + var diff = Count - mod.Groups.Count; + + switch (diff) + { + case 0: return false; + case > 0: + RemoveRange(mod.Groups.Count, diff); + return true; + default: + EnsureCapacity(mod.Groups.Count); + for (var i = Count; i < mod.Groups.Count; ++i) + Add(mod.Groups[i].DefaultSettings); + return true; + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] + public bool FixAll(Mod mod) + { + var ret = false; + for (var i = 0; i < Count; ++i) + { + var oldValue = this[i]; + var newValue = mod.Groups[i].FixSetting(oldValue); + if (newValue == oldValue) + continue; + + ret = true; + this[i] = newValue; + } + + return FixSize(mod) | ret; + } +} diff --git a/Penumbra/Mods/Settings/TemporaryModSettings.cs b/Penumbra/Mods/Settings/TemporaryModSettings.cs new file mode 100644 index 00000000..d3e36ef6 --- /dev/null +++ b/Penumbra/Mods/Settings/TemporaryModSettings.cs @@ -0,0 +1,54 @@ +namespace Penumbra.Mods.Settings; + +public sealed class TemporaryModSettings : ModSettings +{ + public new static readonly TemporaryModSettings Empty = new(true); + + public const string OwnSource = "yourself"; + public string Source = string.Empty; + public int Lock; + public bool ForceInherit; + + // Create default settings for a given mod. + public static TemporaryModSettings DefaultSettings(Mod mod, string source, bool enabled = false, int key = 0) + => new() + { + Enabled = enabled, + Source = source, + Lock = key, + Priority = ModPriority.Default, + Settings = SettingList.Default(mod), + }; + + public TemporaryModSettings() + { } + + private TemporaryModSettings(bool empty) + : base(empty) + { } + + public TemporaryModSettings(Mod mod, ModSettings? clone, string source = OwnSource, int key = 0) + { + Source = source; + Lock = key; + ForceInherit = clone == null; + if (clone is { IsEmpty: false }) + { + Enabled = clone.Enabled; + Priority = clone.Priority; + Settings = clone.Settings.Clone(); + } + else + { + Enabled = false; + Priority = ModPriority.Default; + Settings = SettingList.Default(mod); + } + } +} + +public static class ModSettingsExtensions +{ + public static bool IsTemporary(this ModSettings? settings) + => settings is TemporaryModSettings; +} diff --git a/Penumbra/Mods/SubMods/CombinedDataContainer.cs b/Penumbra/Mods/SubMods/CombinedDataContainer.cs new file mode 100644 index 00000000..bfca2afd --- /dev/null +++ b/Penumbra/Mods/SubMods/CombinedDataContainer.cs @@ -0,0 +1,91 @@ +using Newtonsoft.Json.Linq; +using OtterGui.Extensions; +using Penumbra.Meta.Manipulations; +using Penumbra.Mods.Editor; +using Penumbra.Mods.Groups; +using Penumbra.String.Classes; + +namespace Penumbra.Mods.SubMods; + +public class CombinedDataContainer(IModGroup group) : IModDataContainer +{ + public IMod Mod + => Group.Mod; + + public IModGroup Group { get; } = group; + + public string Name { get; set; } = string.Empty; + public Dictionary Files { get; set; } = []; + public Dictionary FileSwaps { get; set; } = []; + public MetaDictionary Manipulations { get; set; } = new(); + + public void AddDataTo(Dictionary redirections, MetaDictionary manipulations) + => SubMod.AddContainerTo(this, redirections, manipulations); + + public string GetName() + { + if (Name.Length > 0) + return Name; + + var index = GetDataIndex(); + if (index == 0) + return "None"; + + var sb = new StringBuilder(128); + for (var i = 0; i < IModGroup.MaxCombiningOptions; ++i) + { + if ((index & 1) != 0) + { + sb.Append(Group.Options[i].Name); + sb.Append(' ').Append('+').Append(' '); + } + + index >>= 1; + if (index == 0) + break; + } + + return sb.ToString(0, sb.Length - 3); + } + + public unsafe string GetDirectoryName() + { + if (Name.Length > 0) + return Name; + + var index = GetDataIndex(); + if (index == 0) + return "None"; + + var text = stackalloc char[IModGroup.MaxCombiningOptions].Slice(0, Group.Options.Count); + for (var i = 0; i < Group.Options.Count; ++i) + { + text[Group.Options.Count - 1 - i] = (index & 1) is 0 ? '0' : '1'; + index >>= 1; + } + + return new string(text); + } + + public string GetFullName() + => $"{Group.Name}: {GetName()}"; + + public (int GroupIndex, int DataIndex) GetDataIndices() + => (Group.GetIndex(), GetDataIndex()); + + private int GetDataIndex() + { + var dataIndex = Group.DataContainers.IndexOf(this); + if (dataIndex < 0) + throw new Exception($"Group {Group.Name} from SubMod {Name} does not contain this SubMod."); + + return dataIndex; + } + + public CombinedDataContainer(CombiningModGroup group, JToken token) + : this(group) + { + SubMod.LoadDataContainer(token, this, group.Mod.ModPath); + Name = token["Name"]?.ToObject() ?? string.Empty; + } +} diff --git a/Penumbra/Mods/SubMods/CombiningSubMod.cs b/Penumbra/Mods/SubMods/CombiningSubMod.cs new file mode 100644 index 00000000..6eb5de9d --- /dev/null +++ b/Penumbra/Mods/SubMods/CombiningSubMod.cs @@ -0,0 +1,25 @@ +using Newtonsoft.Json.Linq; +using Penumbra.Mods.Groups; + +namespace Penumbra.Mods.SubMods; + +public class CombiningSubMod(IModGroup group) : IModOption +{ + public IModGroup Group { get; } = group; + + public Mod Mod + => Group.Mod; + + public string Name { get; set; } = "Option"; + public string Description { get; set; } = string.Empty; + + public string FullName + => $"{Group.Name}: {Name}"; + + public int GetIndex() + => SubMod.GetIndex(this); + + public CombiningSubMod(CombiningModGroup group, JToken json) + : this(group) + => SubMod.LoadOptionData(json, this); +} diff --git a/Penumbra/Mods/SubMods/ComplexDataContainer.cs b/Penumbra/Mods/SubMods/ComplexDataContainer.cs new file mode 100644 index 00000000..0f0fdef8 --- /dev/null +++ b/Penumbra/Mods/SubMods/ComplexDataContainer.cs @@ -0,0 +1,46 @@ +using Newtonsoft.Json.Linq; +using OtterGui.Extensions; +using Penumbra.Meta.Manipulations; +using Penumbra.Mods.Editor; +using Penumbra.Mods.Groups; +using Penumbra.String.Classes; + +namespace Penumbra.Mods.SubMods; + +public sealed class ComplexDataContainer(ComplexModGroup group) : IModDataContainer +{ + public IMod Mod + => Group.Mod; + + public IModGroup Group { get; } = group; + + public Dictionary Files { get; set; } = []; + public Dictionary FileSwaps { get; set; } = []; + public MetaDictionary Manipulations { get; set; } = new(); + + public MaskedSetting Association = MaskedSetting.Zero; + + public string Name { get; set; } = string.Empty; + + public string GetName() + => Name.Length > 0 ? Name : $"Container {Group.DataContainers.IndexOf(this)}"; + + public string GetDirectoryName() + => Name.Length > 0 ? Name : $"{Group.DataContainers.IndexOf(this)}"; + + public string GetFullName() + => $"{Group.Name}: {GetName()}"; + + public (int GroupIndex, int DataIndex) GetDataIndices() + => (Group.GetIndex(), Group.DataContainers.IndexOf(this)); + + public ComplexDataContainer(ComplexModGroup group, JToken json) + : this(group) + { + SubMod.LoadDataContainer(json, this, group.Mod.ModPath); + var mask = json["AssociationMask"]?.ToObject() ?? 0; + var value = json["AssociationMask"]?.ToObject() ?? 0; + Association = new MaskedSetting(mask, value); + Name = json["Name"]?.ToObject() ?? string.Empty; + } +} diff --git a/Penumbra/Mods/SubMods/ComplexSubMod.cs b/Penumbra/Mods/SubMods/ComplexSubMod.cs new file mode 100644 index 00000000..7c189170 --- /dev/null +++ b/Penumbra/Mods/SubMods/ComplexSubMod.cs @@ -0,0 +1,37 @@ +using Newtonsoft.Json.Linq; +using OtterGui.Extensions; +using Penumbra.Mods.Groups; + +namespace Penumbra.Mods.SubMods; + +public sealed class ComplexSubMod(ComplexModGroup group) : IModOption +{ + public Mod Mod + => group.Mod; + + public IModGroup Group { get; } = group; + public string Name { get; set; } = "Option"; + + public string FullName + => $"{Group.Name}: {Name}"; + + public MaskedSetting Conditions = MaskedSetting.Zero; + public int Indentation = 0; + public string SubGroupLabel = string.Empty; + + public string Description { get; set; } = string.Empty; + + public int GetIndex() + => Group.Options.IndexOf(this); + + public ComplexSubMod(ComplexModGroup group, JToken json) + : this(group) + { + SubMod.LoadOptionData(json, this); + var mask = json["ConditionMask"]?.ToObject() ?? 0; + var value = json["ConditionMask"]?.ToObject() ?? 0; + Conditions = new MaskedSetting(mask, value); + Indentation = json["Indentation"]?.ToObject() ?? 0; + SubGroupLabel = json["SubGroup"]?.ToObject() ?? string.Empty; + } +} diff --git a/Penumbra/Mods/SubMods/DefaultSubMod.cs b/Penumbra/Mods/SubMods/DefaultSubMod.cs new file mode 100644 index 00000000..3282f518 --- /dev/null +++ b/Penumbra/Mods/SubMods/DefaultSubMod.cs @@ -0,0 +1,38 @@ +using Penumbra.Meta.Manipulations; +using Penumbra.Mods.Editor; +using Penumbra.Mods.Groups; +using Penumbra.String.Classes; + +namespace Penumbra.Mods.SubMods; + +public class DefaultSubMod(IMod mod) : IModDataContainer +{ + public const string FullName = "Default Option"; + + internal readonly IMod Mod = mod; + + public Dictionary Files { get; set; } = []; + public Dictionary FileSwaps { get; set; } = []; + public MetaDictionary Manipulations { get; set; } = new(); + + IMod IModDataContainer.Mod + => Mod; + + IModGroup? IModDataContainer.Group + => null; + + public void AddTo(Dictionary redirections, MetaDictionary manipulations) + => SubMod.AddContainerTo(this, redirections, manipulations); + + public string GetName() + => FullName; + + public string GetDirectoryName() + => GetName(); + + public string GetFullName() + => FullName; + + public (int GroupIndex, int DataIndex) GetDataIndices() + => (-1, 0); +} diff --git a/Penumbra/Mods/SubMods/IModDataContainer.cs b/Penumbra/Mods/SubMods/IModDataContainer.cs new file mode 100644 index 00000000..92ccf7e1 --- /dev/null +++ b/Penumbra/Mods/SubMods/IModDataContainer.cs @@ -0,0 +1,21 @@ +using Penumbra.Meta.Manipulations; +using Penumbra.Mods.Editor; +using Penumbra.Mods.Groups; +using Penumbra.String.Classes; + +namespace Penumbra.Mods.SubMods; + +public interface IModDataContainer +{ + public IMod Mod { get; } + public IModGroup? Group { get; } + + public Dictionary Files { get; set; } + public Dictionary FileSwaps { get; set; } + public MetaDictionary Manipulations { get; set; } + + public string GetName(); + public string GetDirectoryName(); + public string GetFullName(); + public (int GroupIndex, int DataIndex) GetDataIndices(); +} diff --git a/Penumbra/Mods/SubMods/IModOption.cs b/Penumbra/Mods/SubMods/IModOption.cs new file mode 100644 index 00000000..ecfcf91a --- /dev/null +++ b/Penumbra/Mods/SubMods/IModOption.cs @@ -0,0 +1,15 @@ +using Penumbra.Mods.Groups; + +namespace Penumbra.Mods.SubMods; + +public interface IModOption +{ + public Mod Mod { get; } + public IModGroup Group { get; } + + public string Name { get; set; } + public string FullName { get; } + public string Description { get; set; } + + public int GetIndex(); +} diff --git a/Penumbra/Mods/SubMods/ImcSubMod.cs b/Penumbra/Mods/SubMods/ImcSubMod.cs new file mode 100644 index 00000000..c5c8f002 --- /dev/null +++ b/Penumbra/Mods/SubMods/ImcSubMod.cs @@ -0,0 +1,48 @@ +using Newtonsoft.Json.Linq; +using Penumbra.GameData.Structs; +using Penumbra.Mods.Groups; + +namespace Penumbra.Mods.SubMods; + +public class ImcSubMod(ImcModGroup group) : IModOption +{ + public readonly ImcModGroup Group = group; + + public ImcSubMod(ImcModGroup group, JToken json) + : this(group) + { + SubMod.LoadOptionData(json, this); + AttributeMask = (ushort)((json[nameof(AttributeMask)]?.ToObject() ?? 0) & ImcEntry.AttributesMask); + IsDisableSubMod = json[nameof(IsDisableSubMod)]?.ToObject() ?? false; + } + + public static ImcSubMod DisableSubMod(ImcModGroup group) + => new(group) + { + Name = "Disable", + AttributeMask = 0, + IsDisableSubMod = true, + }; + + public Mod Mod + => Group.Mod; + + public ushort AttributeMask; + public bool IsDisableSubMod { get; private init; } + + Mod IModOption.Mod + => Mod; + + IModGroup IModOption.Group + => Group; + + public string Name { get; set; } = "Part"; + + public string FullName + => $"{Group.Name}: {Name}"; + + public string Description { get; set; } = string.Empty; + + public int GetIndex() + => SubMod.GetIndex(this); +} diff --git a/Penumbra/Mods/SubMods/MaskedSetting.cs b/Penumbra/Mods/SubMods/MaskedSetting.cs new file mode 100644 index 00000000..75bb46c2 --- /dev/null +++ b/Penumbra/Mods/SubMods/MaskedSetting.cs @@ -0,0 +1,27 @@ +using Penumbra.Mods.Groups; +using Penumbra.Mods.Settings; + +namespace Penumbra.Mods.SubMods; + +public readonly struct MaskedSetting(Setting mask, Setting value) +{ + public const int MaxSettings = IModGroup.MaxMultiOptions; + public static readonly MaskedSetting Zero = new(Setting.Zero, Setting.Zero); + public static readonly MaskedSetting FullMask = new(Setting.AllBits(IModGroup.MaxComplexOptions), Setting.Zero); + + public readonly Setting Mask = mask; + public readonly Setting Value = new(value.Value & mask.Value); + + public MaskedSetting(ulong mask, ulong value) + : this(new Setting(mask), new Setting(value)) + { } + + public MaskedSetting Limit(int numOptions) + => new(Mask.Value & Setting.AllBits(numOptions).Value, Value.Value); + + public bool IsZero + => Mask.Value is 0; + + public bool IsEnabled(Setting input) + => (input.Value & Mask.Value) == Value.Value; +} diff --git a/Penumbra/Mods/SubMods/MultiSubMod.cs b/Penumbra/Mods/SubMods/MultiSubMod.cs new file mode 100644 index 00000000..c01dcce9 --- /dev/null +++ b/Penumbra/Mods/SubMods/MultiSubMod.cs @@ -0,0 +1,50 @@ +using Newtonsoft.Json.Linq; +using Penumbra.Mods.Groups; +using Penumbra.Mods.Settings; + +namespace Penumbra.Mods.SubMods; + +public class MultiSubMod(MultiModGroup group) : OptionSubMod(group) +{ + public ModPriority Priority { get; set; } = ModPriority.Default; + + public MultiSubMod(MultiModGroup group, JToken json) + : this(group) + { + SubMod.LoadOptionData(json, this); + SubMod.LoadDataContainer(json, this, group.Mod.ModPath); + Priority = json[nameof(IModGroup.Priority)]?.ToObject() ?? ModPriority.Default; + } + + public MultiSubMod Clone(MultiModGroup group) + { + var ret = new MultiSubMod(group) + { + Name = Name, + Description = Description, + Priority = Priority, + }; + SubMod.Clone(this, ret); + + return ret; + } + + public SingleSubMod ConvertToSingle(SingleModGroup group) + { + var ret = new SingleSubMod(group) + { + Name = Name, + Description = Description, + }; + SubMod.Clone(this, ret); + return ret; + } + + public static MultiSubMod WithoutGroup(string name, string description, ModPriority priority) + => new(null!) + { + Name = name, + Description = description, + Priority = priority, + }; +} diff --git a/Penumbra/Mods/SubMods/OptionSubMod.cs b/Penumbra/Mods/SubMods/OptionSubMod.cs new file mode 100644 index 00000000..aa3fed8f --- /dev/null +++ b/Penumbra/Mods/SubMods/OptionSubMod.cs @@ -0,0 +1,71 @@ +using OtterGui.Extensions; +using Penumbra.Meta.Manipulations; +using Penumbra.Mods.Editor; +using Penumbra.Mods.Groups; +using Penumbra.String.Classes; + +namespace Penumbra.Mods.SubMods; + +public abstract class OptionSubMod(IModGroup group) : IModOption, IModDataContainer +{ + protected readonly IModGroup Group = group; + + public Mod Mod + => Group.Mod; + + public string Name { get; set; } = "Option"; + public string Description { get; set; } = string.Empty; + + public string FullName + => $"{Group.Name}: {Name}"; + + Mod IModOption.Mod + => Mod; + + IMod IModDataContainer.Mod + => Mod; + + IModGroup IModDataContainer.Group + => Group; + + IModGroup IModOption.Group + => Group; + + public Dictionary Files { get; set; } = []; + public Dictionary FileSwaps { get; set; } = []; + public MetaDictionary Manipulations { get; set; } = new(); + + public void AddDataTo(Dictionary redirections, MetaDictionary manipulations) + => SubMod.AddContainerTo(this, redirections, manipulations); + + public string GetName() + => Name; + + public string GetDirectoryName() + => GetName(); + + public string GetFullName() + => FullName; + + public (int GroupIndex, int DataIndex) GetDataIndices() + => (Group.GetIndex(), GetDataIndex()); + + public int GetIndex() + => SubMod.GetIndex(this); + + private int GetDataIndex() + { + var dataIndex = Group.DataContainers.IndexOf(this); + if (dataIndex < 0) + throw new Exception($"Group {Group.Name} from SubMod {Name} does not contain this SubMod."); + + return dataIndex; + } +} + +public abstract class OptionSubMod(T group) : OptionSubMod(group) + where T : IModGroup +{ + public new T Group + => (T)base.Group; +} diff --git a/Penumbra/Mods/SubMods/SingleSubMod.cs b/Penumbra/Mods/SubMods/SingleSubMod.cs new file mode 100644 index 00000000..675f37bc --- /dev/null +++ b/Penumbra/Mods/SubMods/SingleSubMod.cs @@ -0,0 +1,40 @@ +using Newtonsoft.Json.Linq; +using Penumbra.Mods.Groups; +using Penumbra.Mods.Settings; + +namespace Penumbra.Mods.SubMods; + +public class SingleSubMod(SingleModGroup singleGroup) : OptionSubMod(singleGroup) +{ + public SingleSubMod(SingleModGroup singleGroup, JToken json) + : this(singleGroup) + { + SubMod.LoadOptionData(json, this); + SubMod.LoadDataContainer(json, this, singleGroup.Mod.ModPath); + } + + public SingleSubMod Clone(SingleModGroup group) + { + var ret = new SingleSubMod(group) + { + Name = Name, + Description = Description, + }; + SubMod.Clone(this, ret); + + return ret; + } + + public MultiSubMod ConvertToMulti(MultiModGroup group, ModPriority priority) + { + var ret = new MultiSubMod(group) + { + Name = Name, + Description = Description, + Priority = priority, + }; + SubMod.Clone(this, ret); + + return ret; + } +} diff --git a/Penumbra/Mods/SubMods/SubMod.cs b/Penumbra/Mods/SubMods/SubMod.cs new file mode 100644 index 00000000..a7a2ee61 --- /dev/null +++ b/Penumbra/Mods/SubMods/SubMod.cs @@ -0,0 +1,130 @@ +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using OtterGui.Extensions; +using Penumbra.Meta.Manipulations; +using Penumbra.String.Classes; + +namespace Penumbra.Mods.SubMods; + +public static class SubMod +{ + [MethodImpl(MethodImplOptions.AggressiveOptimization | MethodImplOptions.AggressiveInlining)] + public static int GetIndex(IModOption option) + { + var dataIndex = option.Group.Options.IndexOf(option); + if (dataIndex < 0) + throw new Exception($"Group {option.Group.Name} from option {option.Name} does not contain this option."); + + return dataIndex; + } + + /// Add all unique meta manipulations, file redirections and then file swaps from a ModDataContainer to the given sets. Skip any keys that are already contained. + [MethodImpl(MethodImplOptions.AggressiveOptimization | MethodImplOptions.AggressiveInlining)] + public static void AddContainerTo(IModDataContainer container, Dictionary redirections, + MetaDictionary manipulations) + { + foreach (var (path, file) in container.Files) + redirections.TryAdd(path, file); + + foreach (var (path, file) in container.FileSwaps) + redirections.TryAdd(path, file); + manipulations.UnionWith(container.Manipulations); + } + + /// Replace all data of with the data of . + [MethodImpl(MethodImplOptions.AggressiveOptimization | MethodImplOptions.AggressiveInlining)] + public static void Clone(IModDataContainer from, IModDataContainer to) + { + to.Files = new Dictionary(from.Files); + to.FileSwaps = new Dictionary(from.FileSwaps); + to.Manipulations = from.Manipulations.Clone(); + } + + /// Load all file redirections, file swaps and meta manipulations from a JToken of that option into a data container. + [MethodImpl(MethodImplOptions.AggressiveOptimization | MethodImplOptions.AggressiveInlining)] + public static void LoadDataContainer(JToken json, IModDataContainer data, DirectoryInfo basePath) + { + data.Files.Clear(); + data.FileSwaps.Clear(); + data.Manipulations.Clear(); + + var files = (JObject?)json[nameof(data.Files)]; + if (files != null) + foreach (var property in files.Properties()) + { + if (Utf8GamePath.FromString(property.Name, out var p)) + data.Files.TryAdd(p, new FullPath(basePath, property.Value.ToObject())); + } + + var swaps = (JObject?)json[nameof(data.FileSwaps)]; + if (swaps != null) + foreach (var property in swaps.Properties()) + { + if (Utf8GamePath.FromString(property.Name, out var p)) + data.FileSwaps.TryAdd(p, new FullPath(property.Value.ToObject()!)); + } + + var manips = json[nameof(data.Manipulations)]?.ToObject(); + if (manips != null) + data.Manipulations.UnionWith(manips); + } + + /// Load the relevant data for a selectable option from a JToken of that option. + [MethodImpl(MethodImplOptions.AggressiveOptimization | MethodImplOptions.AggressiveInlining)] + public static void LoadOptionData(JToken json, IModOption option) + { + option.Name = json[nameof(option.Name)]?.ToObject() ?? string.Empty; + option.Description = json[nameof(option.Description)]?.ToObject() ?? string.Empty; + } + + /// Write file redirections, file swaps and meta manipulations from a data container on a JsonWriter. + [MethodImpl(MethodImplOptions.AggressiveOptimization | MethodImplOptions.AggressiveInlining)] + public static void WriteModContainer(JsonWriter j, JsonSerializer serializer, IModDataContainer data, DirectoryInfo basePath) + { + // #TODO: remove comments when TexTools updated. + //if (data.Files.Count > 0) + //{ + j.WritePropertyName(nameof(data.Files)); + j.WriteStartObject(); + foreach (var (gamePath, file) in data.Files) + { + if (file.ToRelPath(basePath, out var relPath)) + { + j.WritePropertyName(gamePath.ToString()); + j.WriteValue(relPath.ToString()); + } + } + + j.WriteEndObject(); + //} + + //if (data.FileSwaps.Count > 0) + //{ + j.WritePropertyName(nameof(data.FileSwaps)); + j.WriteStartObject(); + foreach (var (gamePath, file) in data.FileSwaps) + { + j.WritePropertyName(gamePath.ToString()); + j.WriteValue(file.ToString()); + } + + j.WriteEndObject(); + //} + + //if (data.Manipulations.Count > 0) + //{ + j.WritePropertyName(nameof(data.Manipulations)); + serializer.Serialize(j, data.Manipulations); + //} + } + + /// Write the data for a selectable mod option on a JsonWriter. + [MethodImpl(MethodImplOptions.AggressiveOptimization | MethodImplOptions.AggressiveInlining)] + public static void WriteModOption(JsonWriter j, IModOption option) + { + j.WritePropertyName(nameof(option.Name)); + j.WriteValue(option.Name); + j.WritePropertyName(nameof(option.Description)); + j.WriteValue(option.Description); + } +} diff --git a/Penumbra/Mods/TemporaryMod.cs b/Penumbra/Mods/TemporaryMod.cs new file mode 100644 index 00000000..8fdd09c5 --- /dev/null +++ b/Penumbra/Mods/TemporaryMod.cs @@ -0,0 +1,120 @@ +using OtterGui.Classes; +using Penumbra.Collections; +using Penumbra.Interop.PathResolving; +using Penumbra.Meta.Manipulations; +using Penumbra.Mods.Editor; +using Penumbra.Mods.Groups; +using Penumbra.Mods.Manager; +using Penumbra.Mods.Settings; +using Penumbra.Mods.SubMods; +using Penumbra.Services; +using Penumbra.String.Classes; + +namespace Penumbra.Mods; + +public class TemporaryMod : IMod +{ + public LowerString Name { get; init; } = LowerString.Empty; + public int Index { get; init; } = -2; + public ModPriority Priority { get; init; } = ModPriority.MaxValue; + + public int TotalManipulations + => Default.Manipulations.Count; + + public readonly DefaultSubMod Default; + + public AppliedModData GetData(ModSettings? settings = null) + { + Dictionary dict; + if (Default.FileSwaps.Count == 0) + { + dict = Default.Files; + } + else if (Default.Files.Count == 0) + { + dict = Default.FileSwaps; + } + else + { + // Need to ensure uniqueness. + dict = new Dictionary(Default.Files.Count + Default.FileSwaps.Count); + foreach (var (gamePath, file) in Default.Files.Concat(Default.FileSwaps)) + dict.TryAdd(gamePath, file); + } + + return new AppliedModData(dict, Default.Manipulations); + } + + public IReadOnlyList Groups + => Array.Empty(); + + public TemporaryMod() + => Default = new DefaultSubMod(this); + + public void SetAll(Dictionary dict, MetaDictionary manips) + { + Default.Files = dict; + Default.Manipulations = manips; + } + + public static void SaveTempCollection(Configuration config, SaveService saveService, ModManager modManager, ModCollection collection, + string? character = null) + { + DirectoryInfo? dir = null; + try + { + dir = ModCreator.CreateModFolder(modManager.BasePath, collection.Identity.Name, config.ReplaceNonAsciiOnImport, true); + var fileDir = Directory.CreateDirectory(Path.Combine(dir.FullName, "files")); + modManager.DataEditor.CreateMeta(dir, collection.Identity.Name, character ?? config.DefaultModAuthor, + $"Mod generated from temporary collection {collection.Identity.Id} for {character ?? "Unknown Character"} with name {collection.Identity.Name}.", + null, null); + var mod = new Mod(dir); + var defaultMod = mod.Default; + foreach (var (gamePath, fullPath) in collection.ResolvedFiles) + { + if (gamePath.Path.EndsWith(".imc"u8)) + { + continue; + } + + var targetPath = fullPath.Path.FullName; + if (PathDataHandler.Split(fullPath.Path.FullName, out var actualPath, out _)) + targetPath = actualPath.ToString(); + + if (Path.IsPathRooted(targetPath)) + { + var target = Path.Combine(fileDir.FullName, Path.GetFileName(targetPath)); + File.Copy(targetPath, target, true); + defaultMod.Files[gamePath] = new FullPath(target); + } + else + { + defaultMod.FileSwaps[gamePath] = new FullPath(targetPath); + } + } + + var manips = new MetaDictionary(collection.MetaCache); + defaultMod.Manipulations.UnionWith(manips); + + saveService.ImmediateSaveSync(new ModSaveGroup(dir, defaultMod, config.ReplaceNonAsciiOnImport)); + modManager.AddMod(dir, false); + Penumbra.Log.Information( + $"Successfully generated mod {mod.Name} at {mod.ModPath.FullName} for collection {collection.Identity.Identifier}."); + } + catch (Exception e) + { + Penumbra.Log.Error($"Could not save temporary collection {collection.Identity.Identifier} to permanent Mod:\n{e}"); + if (dir != null && Directory.Exists(dir.FullName)) + { + try + { + Directory.Delete(dir.FullName, true); + } + catch + { + // ignored + } + } + } + } +} diff --git a/Penumbra/Penumbra.cs b/Penumbra/Penumbra.cs index 6ac949d6..d433a0fb 100644 --- a/Penumbra/Penumbra.cs +++ b/Penumbra/Penumbra.cs @@ -1,191 +1,320 @@ -using Dalamud.Game.Command; -using Dalamud.Logging; using Dalamud.Plugin; -using EmbedIO; -using EmbedIO.WebApi; -using ImGuiNET; -using Lumina.Excel.GeneratedSheets; +using Dalamud.Bindings.ImGui; +using Dalamud.Game; +using OtterGui; +using OtterGui.Log; +using OtterGui.Services; using Penumbra.Api; -using Penumbra.GameData.Enums; -using Penumbra.Interop; -using Penumbra.Meta.Files; -using Penumbra.Mods; -using Penumbra.PlayerWatch; +using Penumbra.Api.Enums; +using Penumbra.Collections; +using Penumbra.Collections.Cache; +using Penumbra.Interop.PathResolving; +using Penumbra.Services; +using Penumbra.Interop.Services; +using Penumbra.Mods.Manager; +using Penumbra.Collections.Manager; +using Penumbra.UI.Tabs; +using ChangedItemClick = Penumbra.Communication.ChangedItemClick; +using ChangedItemHover = Penumbra.Communication.ChangedItemHover; +using OtterGui.Tasks; using Penumbra.UI; -using Penumbra.Util; +using ResidentResourceManager = Penumbra.Interop.Services.ResidentResourceManager; +using Dalamud.Plugin.Services; +using Lumina.Excel.Sheets; +using Penumbra.GameData.Data; +using Penumbra.Interop; +using Penumbra.Interop.Hooks; +using Penumbra.Interop.Hooks.PostProcessing; +using Penumbra.Interop.Hooks.ResourceLoading; -namespace Penumbra +namespace Penumbra; + +public class Penumbra : IDalamudPlugin { - public class Penumbra : IDalamudPlugin + public string Name + => "Penumbra"; + + public static readonly Logger Log = new(); + public static MessageService Messager { get; private set; } = null!; + public static DynamisIpc Dynamis { get; private set; } = null!; + + private readonly ValidityChecker _validityChecker; + private readonly ResidentResourceManager _residentResources; + private readonly TempModManager _tempMods; + private readonly TempCollectionManager _tempCollections; + private readonly ModManager _modManager; + private readonly CollectionManager _collectionManager; + private readonly Configuration _config; + private readonly CharacterUtility _characterUtility; + private readonly RedrawService _redrawService; + private readonly CommunicatorService _communicatorService; + private readonly IDataManager _gameData; + private PenumbraWindowSystem? _windowSystem; + private bool _disposed; + + private readonly ServiceManager _services; + + public Penumbra(IDalamudPluginInterface pluginInterface) { - public string Name { get; } = "Penumbra"; - public string PluginDebugTitleStr { get; } = "Penumbra - Debug Build"; - - private const string CommandName = "/penumbra"; - - public static Configuration Config { get; private set; } = null!; - public static IPlayerWatcher PlayerWatcher { get; private set; } = null!; - - public ResourceLoader ResourceLoader { get; } - public SettingsInterface SettingsInterface { get; } - public MusicManager MusicManager { get; } - public ObjectReloader ObjectReloader { get; } - - public PenumbraApi Api { get; } - public PenumbraIpc Ipc { get; } - - private WebServer? _webServer; - - public Penumbra( DalamudPluginInterface pluginInterface ) + try { - FFXIVClientStructs.Resolver.Initialize(); - Dalamud.Initialize( pluginInterface ); - GameData.GameData.GetIdentifier( Dalamud.GameData, Dalamud.ClientState.ClientLanguage ); - Config = Configuration.Load(); + HookOverrides.Instance = HookOverrides.LoadFile(pluginInterface); + _services = StaticServiceManager.CreateProvider(this, pluginInterface, Log); + // Invoke the IPC Penumbra.Launching method before any hooks or other services are created. + _services.GetService(); + Messager = _services.GetService(); + Dynamis = _services.GetService(); + _validityChecker = _services.GetService(); + _services.EnsureRequiredServices(); - MusicManager = new MusicManager(); - MusicManager.DisableStreaming(); + var startup = _services.GetService() + .GetDalamudConfig(DalamudConfigService.WaitingForPluginsOption, out bool s) + ? s.ToString() + : "Unknown"; + Log.Information( + $"Loading Penumbra Version {_validityChecker.Version}, Commit #{_validityChecker.CommitHash} with Waiting For Plugins: {startup}..."); + _services.GetService(); // Initialize because not required anywhere else. + _config = _services.GetService(); + _characterUtility = _services.GetService(); + _tempMods = _services.GetService(); + _residentResources = _services.GetService(); + _services.GetService(); // Initialize because not required anywhere else. + _modManager = _services.GetService(); + _collectionManager = _services.GetService(); + _tempCollections = _services.GetService(); + _redrawService = _services.GetService(); + _communicatorService = _services.GetService(); + _gameData = _services.GetService(); + _services.GetService(); // Initialize because not required anywhere else. + _services.GetService(); // Initialize because not required anywhere else. + _collectionManager.Caches.CreateNecessaryCaches(); + _services.GetService(); - var gameUtils = Service< ResidentResources >.Set(); - PlayerWatcher = PlayerWatchFactory.Create( Dalamud.Framework, Dalamud.ClientState, Dalamud.Objects ); - Service< MetaDefaults >.Set(); - var modManager = Service< ModManager >.Set(); + _services.GetService(); // Initialize before Interface. - modManager.DiscoverMods(); + foreach (var service in _services.GetServicesImplementing()) + service.Awaiter.Wait(); - ObjectReloader = new ObjectReloader( modManager, Config.WaitFrames ); + SetupInterface(); + SetupApi(); - ResourceLoader = new ResourceLoader( this ); + _validityChecker.LogExceptions(); + Log.Information( + $"Penumbra Version {_validityChecker.Version}, Commit #{_validityChecker.CommitHash} successfully Loaded from {pluginInterface.SourceRepository}."); + OtterTex.NativeDll.Initialize(pluginInterface.AssemblyLocation.DirectoryName); + Log.Information($"Loading native OtterTex assembly from {OtterTex.NativeDll.Directory}."); - Dalamud.Commands.AddHandler( CommandName, new CommandInfo( OnCommand ) - { - HelpMessage = "/penumbra - toggle ui\n/penumbra reload - reload mod file lists & discover any new mods", - } ); - - ResourceLoader.Init(); - ResourceLoader.Enable(); - - gameUtils.ReloadPlayerResources(); - - SettingsInterface = new SettingsInterface( this ); - - if( Config.EnableHttpApi ) - { - CreateWebServer(); - } - - if( !Config.EnablePlayerWatch || !Config.IsEnabled ) - { - PlayerWatcher.Disable(); - } - - PlayerWatcher.PlayerChanged += p => - { - PluginLog.Debug( "Triggered Redraw of {Player}.", p.Name ); - ObjectReloader.RedrawObject( p, RedrawType.OnlyWithSettings ); - }; - - Api = new PenumbraApi( this ); - SubscribeItemLinks(); - Ipc = new PenumbraIpc( pluginInterface, Api ); + if (_characterUtility.Ready) + _residentResources.Reload(); } - - private void SubscribeItemLinks() + catch (Exception ex) { - Api.ChangedItemTooltip += it => - { - if( it is Item ) - { - ImGui.Text( "Left Click to create an item link in chat." ); - } - }; - Api.ChangedItemClicked += ( button, it ) => - { - if( button == MouseButton.Left && it is Item item ) - { - ChatUtil.LinkItem( item ); - } - }; - } - - public void CreateWebServer() - { - const string prefix = "http://localhost:42069/"; - - ShutdownWebServer(); - - _webServer = new WebServer( o => o - .WithUrlPrefix( prefix ) - .WithMode( HttpListenerMode.EmbedIO ) ) - .WithCors( prefix ) - .WithWebApi( "/api", m => m - .WithController( () => new ModsController( this ) ) ); - - _webServer.StateChanged += ( s, e ) => PluginLog.Information( $"WebServer New State - {e.NewState}" ); - - _webServer.RunAsync(); - } - - public void ShutdownWebServer() - { - _webServer?.Dispose(); - _webServer = null; - } - - public void Dispose() - { - Ipc.Dispose(); - Api.Dispose(); - SettingsInterface.Dispose(); - ObjectReloader.Dispose(); - PlayerWatcher.Dispose(); - - Dalamud.Commands.RemoveHandler( CommandName ); - - ResourceLoader.Dispose(); - - ShutdownWebServer(); - } - - private void OnCommand( string command, string rawArgs ) - { - var args = rawArgs.Split( new[] { ' ' }, 2 ); - if( args.Length > 0 && args[ 0 ].Length > 0 ) - { - switch( args[ 0 ] ) - { - case "reload": - { - Service< ModManager >.Get().DiscoverMods(); - Dalamud.Chat.Print( - $"Reloaded Penumbra mods. You have {Service< ModManager >.Get()?.Mods.Count} mods." - ); - break; - } - case "redraw": - { - if( args.Length > 1 ) - { - ObjectReloader.RedrawObject( args[ 1 ] ); - } - else - { - ObjectReloader.RedrawAll(); - } - - break; - } - case "debug": - { - SettingsInterface.MakeDebugTabVisible(); - break; - } - } - - return; - } - - SettingsInterface.FlipVisibility(); + Log.Error($"Error constructing Penumbra, Disposing again:\n{ex}"); + Dispose(); + throw; } } + + private void SetupApi() + { + _services.GetService(); + var itemSheet = _services.GetService().GetExcelSheet(); + _communicatorService.ChangedItemHover.Subscribe(it => + { + if (it is IdentifiedItem { Item.Id.IsItem: true }) + ImGui.TextUnformatted("Left Click to create an item link in chat."); + }, ChangedItemHover.Priority.Link); + + _communicatorService.ChangedItemClick.Subscribe((button, it) => + { + if (button == MouseButton.Left && it is IdentifiedItem item && itemSheet.GetRow(item.Item.ItemId.Id) is { } i) + Messager.LinkItem(i); + }, ChangedItemClick.Priority.Link); + } + + private void SetupInterface() + { + AsyncTask.Run(() => + { + var system = _services.GetService(); + system.Window.Setup(this, _services.GetService()); + _services.GetService(); + if (!_disposed) + _windowSystem = system; + else + system.Dispose(); + } + ); + } + + public bool SetEnabled(bool enabled) + { + if (enabled == _config.EnableMods) + return false; + + _config.EnableMods = enabled; + if (enabled) + { + if (_characterUtility.Ready) + { + _residentResources.Reload(); + _redrawService.RedrawAll(RedrawType.Redraw); + } + } + else + { + if (_characterUtility.Ready) + { + _residentResources.Reload(); + _redrawService.RedrawAll(RedrawType.Redraw); + } + } + + _config.Save(); + _communicatorService.EnabledChanged.Invoke(enabled); + + return true; + } + + public void ForceChangelogOpen() + => _windowSystem?.ForceChangelogOpen(); + + public void Dispose() + { + if (_disposed) + return; + + _services?.Dispose(); + _disposed = true; + } + + private void GatherRelevantPlugins(StringBuilder sb) + { + ReadOnlySpan relevantPlugins = + [ + "Glamourer", "MareSynchronos", "CustomizePlus", "SimpleHeels", "VfxEditor", "heliosphere-plugin", "Ktisis", "Brio", "DynamicBridge", + "IllusioVitae", "Aetherment", "LoporritSync", "GagSpeak", "ProjectGagSpeak", "RoleplayingVoiceDalamud", "AQuestReborn", + ]; + var plugins = _services.GetService().InstalledPlugins + .GroupBy(p => p.InternalName) + .ToDictionary(g => g.Key, g => + { + var item = g.OrderByDescending(p => p.IsLoaded).ThenByDescending(p => p.Version).First(); + return (item.IsLoaded, item.Version, item.Name); + }); + foreach (var plugin in relevantPlugins) + { + if (plugins.TryGetValue(plugin, out var data)) + sb.Append($"> **`{data.Name + ':',-29}`** {data.Version}{(data.IsLoaded ? string.Empty : " (Disabled)")}\n"); + } + } + + public string GatherSupportInformation() + { + var sb = new StringBuilder(10240); + var exists = _config.ModDirectory.Length > 0 && Directory.Exists(_config.ModDirectory); + var cloudSynced = exists && CloudApi.IsCloudSynced(_config.ModDirectory); + var hdrEnabler = _services.GetService(); + var drive = exists ? new DriveInfo(new DirectoryInfo(_config.ModDirectory).Root.FullName) : null; + sb.AppendLine("**Settings**"); + sb.Append($"> **`Plugin Version: `** {_validityChecker.Version}\n"); + sb.Append($"> **`Commit Hash: `** {_validityChecker.CommitHash}\n"); + sb.Append($"> **`Enable Mods: `** {_config.EnableMods}\n"); + sb.Append($"> **`Enable HTTP API: `** {_config.EnableHttpApi}\n"); + sb.Append($"> **`Operating System: `** {(Dalamud.Utility.Util.IsWine() ? "Mac/Linux (Wine)" : "Windows")}\n"); + if (Dalamud.Utility.Util.IsWine()) + sb.Append($"> **`Locale Environment Variables:`** {CollectLocaleEnvironmentVariables()}\n"); + sb.Append( + $"> **`Root Directory: `** `{_config.ModDirectory}`, {(exists ? "Exists" : "Not Existing")}{(cloudSynced ? ", Cloud-Synced" : "")}\n"); + sb.Append( + $"> **`Free Drive Space: `** {(drive != null ? Functions.HumanReadableSize(drive.AvailableFreeSpace) : "Unknown")}\n"); + sb.Append($"> **`Game Data Files: `** {(_gameData.HasModifiedGameDataFiles ? "Modified" : "Pristine")}\n"); + sb.Append($"> **`Auto-Deduplication: `** {_config.AutoDeduplicateOnImport}\n"); + sb.Append($"> **`Auto-UI-Reduplication: `** {_config.AutoReduplicateUiOnImport}\n"); + sb.Append($"> **`Debug Mode: `** {_config.DebugMode}\n"); + sb.Append($"> **`Penumbra Reloads: `** {hdrEnabler.PenumbraReloadCount}\n"); + sb.Append( + $"> **`HDR Enabled (from Start): `** {_config.HdrRenderTargets} ({hdrEnabler is { FirstLaunchHdrState: true, FirstLaunchHdrHookOverrideState: true }}){(hdrEnabler.HdrEnabledSuccess ? ", Detour Called" : ", **NEVER CALLED**")}\n"); + sb.Append($"> **`Custom Shapes Enabled: `** {_config.EnableCustomShapes}\n"); + sb.Append($"> **`Hook Overrides: `** {HookOverrides.Instance.IsCustomLoaded}\n"); + sb.Append( + $"> **`Synchronous Load (Dalamud): `** {(_services.GetService().GetDalamudConfig(DalamudConfigService.WaitingForPluginsOption, out bool v) ? v.ToString() : "Unknown")} (first Start: {hdrEnabler.FirstLaunchWaitForPluginsState?.ToString() ?? "Unknown"})\n"); + sb.Append( + $"> **`Logging: `** Log: {_config.Ephemeral.EnableResourceLogging}, Watcher: {_config.Ephemeral.EnableResourceWatcher} ({_config.MaxResourceWatcherRecords})\n"); + sb.Append($"> **`Use Ownership: `** {_config.UseOwnerNameForCharacterCollection}\n"); + GatherRelevantPlugins(sb); + sb.AppendLine("**Mods**"); + sb.Append($"> **`Installed Mods: `** {_modManager.Count}\n"); + sb.Append($"> **`Mods with Config: `** {_modManager.Count(m => m.HasOptions)}\n"); + sb.Append( + $"> **`Mods with File Redirections: `** {_modManager.Count(m => m.TotalFileCount > 0)}, Total: {_modManager.Sum(m => m.TotalFileCount)}\n"); + sb.Append( + $"> **`Mods with FileSwaps: `** {_modManager.Count(m => m.TotalSwapCount > 0)}, Total: {_modManager.Sum(m => m.TotalSwapCount)}\n"); + sb.Append( + $"> **`Mods with Meta Manipulations:`** {_modManager.Count(m => m.TotalManipulations > 0)}, Total {_modManager.Sum(m => m.TotalManipulations)}\n"); + sb.Append($"> **`IMC Exceptions Thrown: `** {_validityChecker.ImcExceptions.Count}\n"); + sb.Append( + $"> **`#Temp Mods: `** {_tempMods.Mods.Sum(kvp => kvp.Value.Count) + _tempMods.ModsForAllCollections.Count}\n"); + + void PrintCollection(ModCollection c, CollectionCache _) + => sb.Append( + $"> **`Collection {c.Identity.AnonymizedName + ':',-18}`** Inheritances: `{c.Inheritance.DirectlyInheritsFrom.Count,3}`, Enabled Mods: `{c.ActualSettings.Count(s => s is { Enabled: true }),4}`, Conflicts: `{c.AllConflicts.SelectMany(x => x).Sum(x => x is { HasPriority: true, Solved: true } ? x.Conflicts.Count : 0),5}/{c.AllConflicts.SelectMany(x => x).Sum(x => x.HasPriority ? x.Conflicts.Count : 0),5}`\n"); + + sb.AppendLine("**Collections**"); + sb.Append($"> **`#Collections: `** {_collectionManager.Storage.Count - 1}\n"); + sb.Append($"> **`#Temp Collections: `** {_tempCollections.Count}\n"); + sb.Append($"> **`Active Collections: `** {_collectionManager.Caches.Count}\n"); + sb.Append($"> **`Base Collection: `** {_collectionManager.Active.Default.Identity.AnonymizedName}\n"); + sb.Append($"> **`Interface Collection: `** {_collectionManager.Active.Interface.Identity.AnonymizedName}\n"); + sb.Append($"> **`Selected Collection: `** {_collectionManager.Active.Current.Identity.AnonymizedName}\n"); + foreach (var (type, name, _) in CollectionTypeExtensions.Special) + { + var collection = _collectionManager.Active.ByType(type); + if (collection != null) + sb.Append($"> **`{name,-29}`** {collection.Identity.AnonymizedName}\n"); + } + + foreach (var (name, id, collection) in _collectionManager.Active.Individuals.Assignments) + sb.Append($"> **`{id[0].Incognito(name) + ':',-29}`** {collection.Identity.AnonymizedName}\n"); + + foreach (var collection in _collectionManager.Caches.Active) + PrintCollection(collection, collection._cache!); + + return sb.ToString(); + } + + private static string CollectLocaleEnvironmentVariables() + { + var variableNames = new List(); + var variables = new Dictionary(StringComparer.Ordinal); + foreach (DictionaryEntry variable in Environment.GetEnvironmentVariables()) + { + var key = (string)variable.Key; + if (key.Equals("LANG", StringComparison.Ordinal) || key.StartsWith("LC_", StringComparison.Ordinal)) + { + variableNames.Add(key); + variables.Add(key, (string?)variable.Value ?? string.Empty); + } + } + + variableNames.Sort(); + + var pos = variableNames.IndexOf("LC_ALL"); + if (pos > 0) // If it's == 0, we're going to do a no-op. + { + variableNames.RemoveAt(pos); + variableNames.Insert(0, "LC_ALL"); + } + + pos = variableNames.IndexOf("LANG"); + if (pos >= 0 && pos < variableNames.Count - 1) + { + variableNames.RemoveAt(pos); + variableNames.Add("LANG"); + } + + return variableNames.Count == 0 + ? "None" + : string.Join(", ", variableNames.Select(name => $"`{name}={variables[name]}`")); + } } diff --git a/Penumbra/Penumbra.csproj b/Penumbra/Penumbra.csproj index 7b6e54b8..f9e33219 100644 --- a/Penumbra/Penumbra.csproj +++ b/Penumbra/Penumbra.csproj @@ -1,75 +1,77 @@ - + - net5.0-windows - preview - x64 Penumbra absolute gangstas Penumbra - Copyright © 2020 - 1.0.0.0 - 1.0.0.0 + Copyright © 2025 + 9.0.0.1 + 9.0.0.1 bin\$(Configuration)\ - true - enable - true - true - - full - DEBUG;TRACE + + PROFILING; + false - - pdbonly - - - - $(MSBuildWarningsAsMessages);MSB3277 - - - - $(AppData)\XIVLauncher\addon\Hooks\dev\Dalamud.dll + + + + + + + + PreserveNewest + + + PreserveNewest + DirectXTexC.dll + + + PreserveNewest + + + + + + $(DalamudLibPath)Iced.dll False - - $(AppData)\XIVLauncher\addon\Hooks\dev\ImGui.NET.dll + + $(DalamudLibPath)TerraFX.Interop.Windows.dll False - - $(AppData)\XIVLauncher\addon\Hooks\dev\ImGuiScene.dll - False - - - $(AppData)\XIVLauncher\addon\Hooks\dev\Lumina.dll - False - - - $(AppData)\XIVLauncher\addon\Hooks\dev\Lumina.Excel.dll - False - - - $(AppData)\XIVLauncher\addon\Hooks\dev\FFXIVClientStructs.dll - False + + lib\OtterTex.dll - - - - - - - - + + + + + + - - Always - + + + + + + + + + + + + + + $(GitCommitHash) + + diff --git a/Penumbra/Penumbra.json b/Penumbra/Penumbra.json index ec60e72c..975c5bb3 100644 --- a/Penumbra/Penumbra.json +++ b/Penumbra/Penumbra.json @@ -1,13 +1,16 @@ { - "Author": "Adam", + "Author": "Ottermandias, Nylfae, Adam, Wintermute", "Name": "Penumbra", + "Punchline": "Runtime mod loader and manager.", "Description": "Runtime mod loader and manager.", "InternalName": "Penumbra", - "AssemblyVersion": "1.0.0.0", + "AssemblyVersion": "9.0.0.1", "RepoUrl": "https://github.com/xivdev/Penumbra", "ApplicableVersion": "any", "Tags": [ "modding" ], - "DalamudApiLevel": 69420, + "DalamudApiLevel": 14, "LoadPriority": 69420, + "LoadRequiredState": 2, + "LoadSync": true, "IconUrl": "https://raw.githubusercontent.com/xivdev/Penumbra/master/images/icon.png" -} \ No newline at end of file +} diff --git a/Penumbra/Services/BackupService.cs b/Penumbra/Services/BackupService.cs new file mode 100644 index 00000000..88b99de1 --- /dev/null +++ b/Penumbra/Services/BackupService.cs @@ -0,0 +1,67 @@ +using Newtonsoft.Json.Linq; +using OtterGui.Classes; +using OtterGui.Log; +using OtterGui.Services; + +namespace Penumbra.Services; + +public class BackupService : IAsyncService +{ + private readonly Logger _logger; + private readonly DirectoryInfo _configDirectory; + private readonly IReadOnlyList _fileNames; + + /// + public Task Awaiter { get; } + + /// + public bool Finished + => Awaiter.IsCompletedSuccessfully; + + /// Start a backup process on the collected files. + public BackupService(Logger logger, FilenameService fileNames) + { + _logger = logger; + _fileNames = PenumbraFiles(fileNames); + _configDirectory = new DirectoryInfo(fileNames.ConfigDirectory); + Awaiter = Task.Run(() => Backup.CreateAutomaticBackup(logger, new DirectoryInfo(fileNames.ConfigDirectory), _fileNames)); + } + + /// Create a permanent backup with a given name for migrations. + public void CreateMigrationBackup(string name) + => Backup.CreatePermanentBackup(_logger, _configDirectory, _fileNames, name); + + /// Collect all relevant files for penumbra configuration. + private static IReadOnlyList PenumbraFiles(FilenameService fileNames) + { + var list = fileNames.CollectionFiles.ToList(); + list.AddRange(fileNames.LocalDataFiles); + list.Add(new FileInfo(fileNames.ConfigFile)); + list.Add(new FileInfo(fileNames.FilesystemFile)); + list.Add(new FileInfo(fileNames.ActiveCollectionsFile)); + list.Add(new FileInfo(fileNames.PredefinedTagFile)); + return list; + } + + /// Try to parse a file to JObject and check backups if this does not succeed. + public static JObject? GetJObjectForFile(FilenameService fileNames, string fileName) + { + JObject? ret = null; + if (!File.Exists(fileName)) + return ret; + + try + { + var text = File.ReadAllText(fileName); + ret = JObject.Parse(text); + } + catch (Exception ex) + { + Penumbra.Log.Error($"Failed to load {fileName}, trying to restore from backup:\n{ex}"); + Backup.TryGetFile(new DirectoryInfo(fileNames.ConfigDirectory), fileName, out ret, out var messages, JObject.Parse); + Penumbra.Messager.NotificationMessage(messages); + } + + return ret; + } +} diff --git a/Penumbra/Services/CleanupService.cs b/Penumbra/Services/CleanupService.cs new file mode 100644 index 00000000..bf76f5f0 --- /dev/null +++ b/Penumbra/Services/CleanupService.cs @@ -0,0 +1,165 @@ +using OtterGui.Services; +using Penumbra.Collections.Manager; +using Penumbra.Mods.Manager; + +namespace Penumbra.Services; + +public class CleanupService(SaveService saveService, ModManager mods, CollectionManager collections) : IService +{ + private CancellationTokenSource _cancel = new(); + private Task? _task; + + public double Progress { get; private set; } + + public bool IsRunning + => _task is { IsCompleted: false }; + + public void Cancel() + => _cancel.Cancel(); + + public void CleanUnusedLocalData() + { + if (IsRunning) + return; + + var usedFiles = mods.Select(saveService.FileNames.LocalDataFile).ToHashSet(); + Progress = 0; + var deleted = 0; + _cancel = new CancellationTokenSource(); + _task = Task.Run(() => + { + var localFiles = saveService.FileNames.LocalDataFiles.ToList(); + var step = 0.9 / localFiles.Count; + Progress = 0.1; + foreach (var file in localFiles) + { + if (_cancel.IsCancellationRequested) + break; + + try + { + if (!file.Exists || usedFiles.Contains(file.FullName)) + continue; + + file.Delete(); + Penumbra.Log.Debug($"[CleanupService] Deleted unused local data file {file.Name}."); + ++deleted; + } + catch (Exception ex) + { + Penumbra.Log.Error($"[CleanupService] Failed to delete unused local data file {file.Name}:\n{ex}"); + } + + Progress += step; + } + + Penumbra.Log.Information($"[CleanupService] Deleted {deleted} unused local data files."); + Progress = 1; + }); + } + + public void CleanBackupFiles() + { + if (IsRunning) + return; + + Progress = 0; + var deleted = 0; + _cancel = new CancellationTokenSource(); + _task = Task.Run(() => + { + var configFiles = Directory.EnumerateFiles(saveService.FileNames.ConfigDirectory, "*.json.bak", SearchOption.AllDirectories) + .ToList(); + Progress = 0.1; + if (_cancel.IsCancellationRequested) + return; + + var groupFiles = mods.BasePath.EnumerateFiles("group_*.json.bak", SearchOption.AllDirectories).ToList(); + Progress = 0.5; + var step = 0.4 / (groupFiles.Count + configFiles.Count); + foreach (var file in groupFiles) + { + if (_cancel.IsCancellationRequested) + break; + + try + { + if (!file.Exists) + continue; + + file.Delete(); + ++deleted; + Penumbra.Log.Debug($"[CleanupService] Deleted group backup file {file.FullName}."); + } + catch (Exception ex) + { + Penumbra.Log.Error($"[CleanupService] Failed to delete group backup file {file.FullName}:\n{ex}"); + } + + Progress += step; + } + + Penumbra.Log.Information($"[CleanupService] Deleted {deleted} group backup files."); + + deleted = 0; + foreach (var file in configFiles) + { + if (_cancel.IsCancellationRequested) + break; + + try + { + if (!File.Exists(file)) + continue; + + File.Delete(file); + ++deleted; + Penumbra.Log.Debug($"[CleanupService] Deleted config backup file {file}."); + } + catch (Exception ex) + { + Penumbra.Log.Error($"[CleanupService] Failed to delete config backup file {file}:\n{ex}"); + } + + Progress += step; + } + + Penumbra.Log.Information($"[CleanupService] Deleted {deleted} config backup files."); + Progress = 1; + }); + } + + public void CleanupAllUnusedSettings() + { + if (IsRunning) + return; + + Progress = 0; + var totalRemoved = 0; + var diffCollections = 0; + _cancel = new CancellationTokenSource(); + _task = Task.Run(() => + { + var step = 1.0 / collections.Storage.Count; + foreach (var collection in collections.Storage) + { + if (_cancel.IsCancellationRequested) + break; + + var count = collections.Storage.CleanUnavailableSettings(collection); + if (count > 0) + { + Penumbra.Log.Debug( + $"[CleanupService] Removed {count} unused settings from collection {collection.Identity.AnonymizedName}."); + totalRemoved += count; + ++diffCollections; + } + + Progress += step; + } + + Penumbra.Log.Information($"[CleanupService] Removed {totalRemoved} unused settings from {diffCollections} separate collections."); + Progress = 1; + }); + } +} diff --git a/Penumbra/Services/CommunicatorService.cs b/Penumbra/Services/CommunicatorService.cs new file mode 100644 index 00000000..35f15e9e --- /dev/null +++ b/Penumbra/Services/CommunicatorService.cs @@ -0,0 +1,117 @@ +using OtterGui.Classes; +using OtterGui.Log; +using OtterGui.Services; +using Penumbra.Communication; + +namespace Penumbra.Services; + +public class CommunicatorService : IDisposable, IService +{ + public CommunicatorService(Logger logger) + { + EventWrapperBase.ChangeLogger(logger); + } + + /// + public readonly CollectionChange CollectionChange = new(); + + /// + public readonly TemporaryGlobalModChange TemporaryGlobalModChange = new(); + + /// + public readonly CreatingCharacterBase CreatingCharacterBase = new(); + + /// + public readonly CreatedCharacterBase CreatedCharacterBase = new(); + + /// + public readonly MtrlLoaded MtrlLoaded = new(); + + /// + public readonly ModDataChanged ModDataChanged = new(); + + /// + public readonly ModOptionChanged ModOptionChanged = new(); + + /// + public readonly ModDiscoveryStarted ModDiscoveryStarted = new(); + + /// + public readonly ModDiscoveryFinished ModDiscoveryFinished = new(); + + /// + public readonly ModDirectoryChanged ModDirectoryChanged = new(); + + /// + public readonly ModFileChanged ModFileChanged = new(); + + /// + public readonly ModPathChanged ModPathChanged = new(); + + /// + public readonly ModSettingChanged ModSettingChanged = new(); + + /// + public readonly CollectionInheritanceChanged CollectionInheritanceChanged = new(); + + /// + public readonly EnabledChanged EnabledChanged = new(); + + /// + public readonly PreSettingsTabBarDraw PreSettingsTabBarDraw = new(); + + /// + public readonly PreSettingsPanelDraw PreSettingsPanelDraw = new(); + + /// + public readonly PostEnabledDraw PostEnabledDraw = new(); + + /// + public readonly PostSettingsPanelDraw PostSettingsPanelDraw = new(); + + /// + public readonly ChangedItemHover ChangedItemHover = new(); + + /// + public readonly ChangedItemClick ChangedItemClick = new(); + + /// + public readonly SelectTab SelectTab = new(); + + /// + public readonly ResolvedFileChanged ResolvedFileChanged = new(); + + /// + public readonly PcpCreation PcpCreation = new(); + + /// + public readonly PcpParsing PcpParsing = new(); + + public void Dispose() + { + CollectionChange.Dispose(); + TemporaryGlobalModChange.Dispose(); + CreatingCharacterBase.Dispose(); + CreatedCharacterBase.Dispose(); + MtrlLoaded.Dispose(); + ModDataChanged.Dispose(); + ModOptionChanged.Dispose(); + ModDiscoveryStarted.Dispose(); + ModDiscoveryFinished.Dispose(); + ModDirectoryChanged.Dispose(); + ModPathChanged.Dispose(); + ModSettingChanged.Dispose(); + CollectionInheritanceChanged.Dispose(); + EnabledChanged.Dispose(); + PreSettingsTabBarDraw.Dispose(); + PreSettingsPanelDraw.Dispose(); + PostEnabledDraw.Dispose(); + PostSettingsPanelDraw.Dispose(); + ChangedItemHover.Dispose(); + ChangedItemClick.Dispose(); + SelectTab.Dispose(); + ResolvedFileChanged.Dispose(); + PcpCreation.Dispose(); + PcpParsing.Dispose(); + } +} diff --git a/Penumbra/Services/ConfigMigrationService.cs b/Penumbra/Services/ConfigMigrationService.cs new file mode 100644 index 00000000..9fe8c420 --- /dev/null +++ b/Penumbra/Services/ConfigMigrationService.cs @@ -0,0 +1,419 @@ +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using OtterGui.Filesystem; +using OtterGui.Services; +using Penumbra.Api.Enums; +using Penumbra.Collections; +using Penumbra.Collections.Manager; +using Penumbra.Enums; +using Penumbra.Interop.Services; +using Penumbra.Mods; +using Penumbra.Mods.Editor; +using Penumbra.Mods.Manager; +using Penumbra.Mods.Settings; +using Penumbra.UI; +using Penumbra.UI.Classes; +using Penumbra.UI.ResourceWatcher; +using Penumbra.UI.Tabs; + +namespace Penumbra.Services; + +/// +/// Contains everything to migrate from older versions of the config to the current, +/// including deprecated fields. +/// +public class ConfigMigrationService(SaveService saveService, BackupService backupService) : IService +{ + private Configuration _config = null!; + private JObject _data = null!; + + public string CurrentCollection = ModCollectionIdentity.DefaultCollectionName; + public string DefaultCollection = ModCollectionIdentity.DefaultCollectionName; + public string ForcedCollection = string.Empty; + public Dictionary CharacterCollections = []; + public Dictionary ModSortOrder = []; + public bool InvertModListOrder; + public bool SortFoldersFirst; + public SortModeV3 SortMode = SortModeV3.FoldersFirst; + + /// Add missing colors to the dictionary if necessary. + private static void AddColors(Configuration config, bool forceSave) + { + var save = false; + foreach (var color in Enum.GetValues()) + save |= config.Colors.TryAdd(color, color.Data().DefaultColor); + + if (save || forceSave) + config.Save(); + + Colors.SetColors(config); + } + + public void Migrate(CharacterUtility utility, Configuration config) + { + _config = config; + // Do this on every migration from now on for a while + // because it stayed alive for a bunch of people for some reason. + DeleteMetaTmp(); + + if (config.Version >= Configuration.Constants.CurrentVersion || !File.Exists(saveService.FileNames.ConfigFile)) + { + AddColors(config, false); + return; + } + + _data = JObject.Parse(File.ReadAllText(saveService.FileNames.ConfigFile)); + CreateBackup(); + + Version0To1(); + Version1To2(utility); + Version2To3(); + Version3To4(); + Version4To5(); + Version5To6(); + Version6To7(); + Version7To8(); + Version8To9(); + AddColors(config, true); + } + + // Migrate to ephemeral config. + private void Version8To9() + { + if (_config.Version != 8) + return; + + backupService.CreateMigrationBackup("pre_collection_identifiers"); + _config.Version = 9; + _config.Ephemeral.Version = 9; + _config.Save(); + _config.Ephemeral.Save(); + } + + // Migrate to ephemeral config. + private void Version7To8() + { + if (_config.Version != 7) + return; + + _config.Version = 8; + _config.Ephemeral.Version = 8; + + _config.Ephemeral.LastSeenVersion = _data["LastSeenVersion"]?.ToObject() ?? _config.Ephemeral.LastSeenVersion; + _config.Ephemeral.DebugSeparateWindow = _data["DebugSeparateWindow"]?.ToObject() ?? _config.Ephemeral.DebugSeparateWindow; + _config.Ephemeral.TutorialStep = _data["TutorialStep"]?.ToObject() ?? _config.Ephemeral.TutorialStep; + _config.Ephemeral.EnableResourceLogging = _data["EnableResourceLogging"]?.ToObject() ?? _config.Ephemeral.EnableResourceLogging; + _config.Ephemeral.ResourceLoggingFilter = _data["ResourceLoggingFilter"]?.ToObject() ?? _config.Ephemeral.ResourceLoggingFilter; + _config.Ephemeral.EnableResourceWatcher = _data["EnableResourceWatcher"]?.ToObject() ?? _config.Ephemeral.EnableResourceWatcher; + _config.Ephemeral.OnlyAddMatchingResources = + _data["OnlyAddMatchingResources"]?.ToObject() ?? _config.Ephemeral.OnlyAddMatchingResources; + _config.Ephemeral.ResourceWatcherResourceTypes = _data["ResourceWatcherResourceTypes"]?.ToObject() + ?? _config.Ephemeral.ResourceWatcherResourceTypes; + _config.Ephemeral.ResourceWatcherResourceCategories = _data["ResourceWatcherResourceCategories"]?.ToObject() + ?? _config.Ephemeral.ResourceWatcherResourceCategories; + _config.Ephemeral.ResourceWatcherRecordTypes = + _data["ResourceWatcherRecordTypes"]?.ToObject() ?? _config.Ephemeral.ResourceWatcherRecordTypes; + _config.Ephemeral.CollectionPanel = _data["CollectionPanel"]?.ToObject() ?? _config.Ephemeral.CollectionPanel; + _config.Ephemeral.SelectedTab = _data["SelectedTab"]?.ToObject() ?? _config.Ephemeral.SelectedTab; + _config.Ephemeral.ChangedItemFilter = _data["ChangedItemFilter"]?.ToObject() + ?? _config.Ephemeral.ChangedItemFilter; + _config.Ephemeral.FixMainWindow = _data["FixMainWindow"]?.ToObject() ?? _config.Ephemeral.FixMainWindow; + _config.Ephemeral.Save(); + } + + // Gendered special collections were added. + private void Version6To7() + { + if (_config.Version != 6) + return; + + ActiveCollectionMigration.MigrateUngenderedCollections(saveService.FileNames); + _config.Version = 7; + } + + + // A new tutorial step was inserted in the middle. + // The UI collection and a new tutorial for it was added. + // The migration for the UI collection itself happens in the ActiveCollections file. + private void Version5To6() + { + if (_config.Version != 5) + return; + + if (_config.Ephemeral.TutorialStep == 25) + _config.Ephemeral.TutorialStep = 27; + + _config.Version = 6; + } + + // Mod backup extension was changed from .zip to .pmp. + // Actual migration takes place in ModManager. + private void Version4To5() + { + if (_config.Version != 4) + return; + + ModBackup.MigrateModBackups = true; + _config.Version = 5; + } + + // SortMode was changed from an enum to a type. + private void Version3To4() + { + if (_config.Version != 3) + return; + + SortMode = _data[nameof(SortMode)]?.ToObject() ?? SortMode; + _config.SortMode = SortMode switch + { + SortModeV3.FoldersFirst => ISortMode.FoldersFirst, + SortModeV3.Lexicographical => ISortMode.Lexicographical, + SortModeV3.InverseFoldersFirst => ISortMode.InverseFoldersFirst, + SortModeV3.InverseLexicographical => ISortMode.InverseLexicographical, + SortModeV3.FoldersLast => ISortMode.FoldersLast, + SortModeV3.InverseFoldersLast => ISortMode.InverseFoldersLast, + SortModeV3.InternalOrder => ISortMode.InternalOrder, + SortModeV3.InternalOrderInverse => ISortMode.InverseInternalOrder, + _ => ISortMode.FoldersFirst, + }; + _config.Version = 4; + } + + // SortFoldersFirst was changed from a bool to the enum SortMode. + private void Version2To3() + { + if (_config.Version != 2) + return; + + SortFoldersFirst = _data[nameof(SortFoldersFirst)]?.ToObject() ?? false; + SortMode = SortFoldersFirst ? SortModeV3.FoldersFirst : SortModeV3.Lexicographical; + _config.Version = 3; + } + + // The forced collection was removed due to general inheritance. + // Sort Order was moved to a separate file and may contain empty folders. + // Active collections in general were moved to their own file. + // Delete the penumbrametatmp folder if it exists. + private void Version1To2(CharacterUtility utility) + { + if (_config.Version != 1) + return; + + // Ensure the right meta files are loaded. + DeleteMetaTmp(); + if (utility.Ready) + utility.LoadCharacterResources(); + ResettleSortOrder(); + ResettleCollectionSettings(); + ResettleForcedCollection(); + _config.Version = 2; + } + + private void DeleteMetaTmp() + { + var path = Path.Combine(_config.ModDirectory, "penumbrametatmp"); + if (!Directory.Exists(path)) + return; + + try + { + Directory.Delete(path, true); + } + catch (Exception e) + { + Penumbra.Log.Error($"Could not delete the outdated penumbrametatmp folder:\n{e}"); + } + } + + private void ResettleForcedCollection() + { + ForcedCollection = _data[nameof(ForcedCollection)]?.ToObject() ?? ForcedCollection; + if (ForcedCollection.Length <= 0) + return; + + // Add the previous forced collection to all current collections except itself as an inheritance. + foreach (var collection in saveService.FileNames.CollectionFiles) + { + try + { + var jObject = JObject.Parse(File.ReadAllText(collection.FullName)); + if (jObject["Name"]?.ToObject() == ForcedCollection) + continue; + + jObject[nameof(ModCollectionInheritance.DirectlyInheritsFrom)] = JToken.FromObject(new List { ForcedCollection }); + File.WriteAllText(collection.FullName, jObject.ToString()); + } + catch (Exception e) + { + Penumbra.Log.Error( + $"Could not transfer forced collection {ForcedCollection} to inheritance of collection {collection}:\n{e}"); + } + } + } + + // Move the current sort order to its own file. + private void ResettleSortOrder() + { + ModSortOrder = _data[nameof(ModSortOrder)]?.ToObject>() ?? ModSortOrder; + var file = saveService.FileNames.FilesystemFile; + using var stream = File.Open(file, File.Exists(file) ? FileMode.Truncate : FileMode.CreateNew); + using var writer = new StreamWriter(stream); + using var j = new JsonTextWriter(writer); + j.Formatting = Formatting.Indented; + j.WriteStartObject(); + j.WritePropertyName("Data"); + j.WriteStartObject(); + foreach (var (mod, path) in ModSortOrder.Where(kvp => Directory.Exists(Path.Combine(_config.ModDirectory, kvp.Key)))) + { + j.WritePropertyName(mod, true); + j.WriteValue(path); + } + + j.WriteEndObject(); + j.WritePropertyName("EmptyFolders"); + j.WriteStartArray(); + j.WriteEndArray(); + j.WriteEndObject(); + } + + // Move the active collections to their own file. + private void ResettleCollectionSettings() + { + CurrentCollection = _data[nameof(CurrentCollection)]?.ToObject() ?? CurrentCollection; + DefaultCollection = _data[nameof(DefaultCollection)]?.ToObject() ?? DefaultCollection; + CharacterCollections = _data[nameof(CharacterCollections)]?.ToObject>() ?? CharacterCollections; + SaveActiveCollectionsV0(DefaultCollection, CurrentCollection, DefaultCollection, + CharacterCollections.Select(kvp => (kvp.Key, kvp.Value)), Array.Empty<(CollectionType, string)>()); + } + + // Outdated saving using the Characters list. + private void SaveActiveCollectionsV0(string def, string ui, string current, IEnumerable<(string, string)> characters, + IEnumerable<(CollectionType, string)> special) + { + var file = saveService.FileNames.ActiveCollectionsFile; + try + { + using var stream = File.Open(file, File.Exists(file) ? FileMode.Truncate : FileMode.CreateNew); + using var writer = new StreamWriter(stream); + using var j = new JsonTextWriter(writer); + j.Formatting = Formatting.Indented; + j.WriteStartObject(); + j.WritePropertyName(nameof(ActiveCollectionData.Default)); + j.WriteValue(def); + j.WritePropertyName(nameof(ActiveCollectionData.Interface)); + j.WriteValue(ui); + j.WritePropertyName(nameof(ActiveCollectionData.Current)); + j.WriteValue(current); + foreach (var (type, collection) in special) + { + j.WritePropertyName(type.ToString()); + j.WriteValue(collection); + } + + j.WritePropertyName("Characters"); + j.WriteStartObject(); + foreach (var (character, collection) in characters) + { + j.WritePropertyName(character, true); + j.WriteValue(collection); + } + + j.WriteEndObject(); + j.WriteEndObject(); + Penumbra.Log.Verbose("Active Collections saved."); + } + catch (Exception e) + { + Penumbra.Log.Error($"Could not save active collections to file {file}:\n{e}"); + } + } + + // Collections were introduced and the previous CurrentCollection got put into ModDirectory. + private void Version0To1() + { + if (_config.Version != 0) + return; + + _config.ModDirectory = _data[nameof(CurrentCollection)]?.ToObject() ?? string.Empty; + _config.Version = 1; + ResettleCollectionJson(); + } + + /// Move the previous mod configurations to a new default collection file. + private void ResettleCollectionJson() + { + var collectionJson = new FileInfo(Path.Combine(_config.ModDirectory, "collection.json")); + if (!collectionJson.Exists) + return; + + var defaultCollectionFile = new FileInfo(saveService.FileNames.CollectionFile(ModCollectionIdentity.DefaultCollectionName)); + if (defaultCollectionFile.Exists) + return; + + try + { + var text = File.ReadAllText(collectionJson.FullName); + var data = JArray.Parse(text); + + var maxPriority = ModPriority.Default; + var dict = new Dictionary(); + foreach (var setting in data.Cast()) + { + var modName = setting["FolderName"]?.ToObject()!; + var enabled = setting["Enabled"]?.ToObject() ?? false; + var priority = setting["Priority"]?.ToObject() ?? ModPriority.Default; + var settings = setting["Settings"]!.ToObject>() + ?? setting["Conf"]!.ToObject>(); + + dict[modName] = new ModSettings.SavedSettings() + { + Enabled = enabled, + Priority = priority, + Settings = settings!, + }; + maxPriority = maxPriority.Max(priority); + } + + InvertModListOrder = _data[nameof(InvertModListOrder)]?.ToObject() ?? InvertModListOrder; + if (!InvertModListOrder) + dict = dict.ToDictionary(kvp => kvp.Key, kvp => kvp.Value with { Priority = maxPriority - kvp.Value.Priority }); + + var emptyStorage = new ModStorage(); + // Only used for saving and immediately discarded, so the local collection id here is irrelevant. + var collection = ModCollection.CreateFromData(saveService, emptyStorage, ModCollectionIdentity.New(ModCollectionIdentity.DefaultCollectionName, LocalCollectionId.Zero, 1), 0, dict, []); + saveService.ImmediateSaveSync(new ModCollectionSave(emptyStorage, collection)); + } + catch (Exception e) + { + Penumbra.Log.Error($"Could not migrate the old collection file to new collection files:\n{e}"); + throw; + } + } + + // Create a backup of the configuration file specifically. + private void CreateBackup() + { + var name = saveService.FileNames.ConfigFile; + var bakName = name + ".bak"; + try + { + File.Copy(name, bakName, true); + } + catch (Exception e) + { + Penumbra.Log.Error($"Could not create backup copy of config at {bakName}:\n{e}"); + } + } + + public enum SortModeV3 : byte + { + FoldersFirst = 0x00, + Lexicographical = 0x01, + InverseFoldersFirst = 0x02, + InverseLexicographical = 0x03, + FoldersLast = 0x04, + InverseFoldersLast = 0x05, + InternalOrder = 0x06, + InternalOrderInverse = 0x07, + } +} diff --git a/Penumbra/Services/CrashHandlerService.cs b/Penumbra/Services/CrashHandlerService.cs new file mode 100644 index 00000000..4814795c --- /dev/null +++ b/Penumbra/Services/CrashHandlerService.cs @@ -0,0 +1,342 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using FFXIVClientStructs.FFXIV.Client.Game.Object; +using OtterGui.Services; +using Penumbra.Collections; +using Penumbra.Communication; +using Penumbra.CrashHandler; +using Penumbra.CrashHandler.Buffers; +using Penumbra.GameData.Actors; +using Penumbra.Interop.Hooks.ResourceLoading; +using Penumbra.Interop.PathResolving; +using Penumbra.Interop.Structs; +using Penumbra.String; +using Penumbra.String.Classes; +using FileMode = System.IO.FileMode; + +namespace Penumbra.Services; + +public sealed class CrashHandlerService : IDisposable, IService +{ + private readonly FilenameService _files; + private readonly CommunicatorService _communicator; + private readonly ActorManager _actors; + private readonly ResourceLoader _resourceLoader; + private readonly Configuration _config; + private readonly ValidityChecker _validityChecker; + + private string _tempExecutableDirectory = string.Empty; + + public CrashHandlerService(FilenameService files, CommunicatorService communicator, ActorManager actors, ResourceLoader resourceLoader, + Configuration config, ValidityChecker validityChecker) + { + _files = files; + _communicator = communicator; + _actors = actors; + _resourceLoader = resourceLoader; + _config = config; + _validityChecker = validityChecker; + + if (!(_config.UseCrashHandler ?? false)) + return; + + OpenEventWriter(); + LaunchCrashHandler(); + if (_eventWriter != null) + Subscribe(); + } + + public void Dispose() + { + CloseEventWriter(); + _eventWriter?.Dispose(); + if (_child != null) + { + _child.Kill(); + Penumbra.Log.Debug($"Killed crash handler child process {_child.Id}."); + } + + Unsubscribe(); + CleanExecutables(); + } + + private Process? _child; + private GameEventLogWriter? _eventWriter; + + public string CopiedExe = string.Empty; + + public string OriginalExe + => _files.CrashHandlerExe; + + public string LogPath + => _files.LogFileName; + + public int ChildProcessId + => _child?.Id ?? -1; + + public int ProcessId + => Environment.ProcessId; + + public bool IsRunning + => _eventWriter != null && _child is { HasExited: false }; + + public int ChildExitCode + => IsRunning ? 0 : _child?.ExitCode ?? 0; + + public void Enable() + { + if (_config.UseCrashHandler ?? false) + return; + + _config.UseCrashHandler = true; + _config.Save(); + OpenEventWriter(); + LaunchCrashHandler(); + if (_eventWriter != null) + Subscribe(); + } + + public void Disable() + { + if (!(_config.UseCrashHandler ?? false)) + return; + + _config.UseCrashHandler = false; + _config.Save(); + CloseEventWriter(); + CloseCrashHandler(); + Unsubscribe(); + } + + public JsonObject? Load(string fileName) + { + if (!File.Exists(fileName)) + return null; + + try + { + var data = File.ReadAllText(fileName); + return JsonNode.Parse(data) as JsonObject; + } + catch (Exception ex) + { + Penumbra.Log.Error($"Could not parse crash dump at {fileName}:\n{ex}"); + return null; + } + } + + public void CloseCrashHandler() + { + if (_child == null) + return; + + try + { + if (_child.HasExited) + return; + + _child.Kill(); + Penumbra.Log.Debug($"Closed Crash Handler at {CopiedExe}."); + } + catch (Exception ex) + { + _child = null; + Penumbra.Log.Debug($"Closed not close Crash Handler at {CopiedExe}:\n{ex}."); + } + } + + public void LaunchCrashHandler() + { + try + { + CloseCrashHandler(); + CopiedExe = CopyExecutables(); + var info = new ProcessStartInfo() + { + CreateNoWindow = true, + FileName = CopiedExe, + }; + info.ArgumentList.Add(_files.LogFileName); + info.ArgumentList.Add(Environment.ProcessId.ToString()); + info.ArgumentList.Add($"{_validityChecker.Version} ({_validityChecker.CommitHash})"); + info.ArgumentList.Add(_validityChecker.GameVersion); + _child = Process.Start(info); + if (_child == null) + throw new Exception("Child Process could not be created."); + + Penumbra.Log.Information($"Opened Crash Handler at {CopiedExe}, PID {_child.Id}."); + } + catch (Exception ex) + { + Penumbra.Log.Error($"Could not launch crash handler process:\n{ex}"); + CloseCrashHandler(); + _child = null; + } + } + + public JsonObject? Dump() + { + if (_eventWriter == null) + return null; + + try + { + using var reader = new GameEventLogReader(Environment.ProcessId); + JsonObject jObj; + lock (_eventWriter) + { + jObj = reader.Dump("Manual Dump", Environment.ProcessId, 0, $"{_validityChecker.Version} ({_validityChecker.CommitHash})", + _validityChecker.GameVersion); + } + + var logFile = _files.LogFileName; + using var s = File.Open(logFile, FileMode.Create); + using var jw = new Utf8JsonWriter(s, new JsonWriterOptions() { Indented = true }); + jObj.WriteTo(jw); + Penumbra.Log.Information($"Dumped crash handler memory to {logFile}."); + return jObj; + } + catch (Exception ex) + { + Penumbra.Log.Error($"Error dumping crash handler memory to file:\n{ex}"); + return null; + } + } + + private void CleanExecutables() + { + var parent = Path.GetDirectoryName(_files.CrashHandlerExe)!; + foreach (var dir in Directory.EnumerateDirectories(parent, "temp_*")) + { + try + { + Directory.Delete(dir, true); + } + catch (Exception ex) + { + Penumbra.Log.Verbose($"Could not delete {dir}. This is generally not an error:\n{ex}"); + } + } + } + + private string CopyExecutables() + { + CleanExecutables(); + var parent = Path.GetDirectoryName(_files.CrashHandlerExe)!; + _tempExecutableDirectory = Path.Combine(parent, $"temp_{Environment.ProcessId}"); + Directory.CreateDirectory(_tempExecutableDirectory); + foreach (var file in Directory.EnumerateFiles(parent, "Penumbra.CrashHandler.*")) + File.Copy(file, Path.Combine(_tempExecutableDirectory, Path.GetFileName(file)), true); + return Path.Combine(_tempExecutableDirectory, Path.GetFileName(_files.CrashHandlerExe)); + } + + public void LogAnimation(nint character, ModCollection collection, AnimationInvocationType type) + { + if (_eventWriter == null) + return; + + try + { + var name = GetActorName(character); + lock (_eventWriter) + { + _eventWriter?.AnimationFuncInvoked.WriteLine(character, name.Span, collection.Identity.Id, type); + } + } + catch (Exception ex) + { + Penumbra.Log.Warning($"Error logging animation function {type} to crash handler:\n{ex}"); + } + } + + private void OnCreatingCharacterBase(nint address, Guid collection, nint _1, nint _2, nint _3) + { + if (_eventWriter == null) + return; + + try + { + var name = GetActorName(address); + + lock (_eventWriter) + { + _eventWriter?.CharacterBase.WriteLine(address, name.Span, collection); + } + } + catch (Exception ex) + { + Penumbra.Log.Warning($"Error logging character creation to crash handler:\n{ex}"); + } + } + + private unsafe ByteString GetActorName(nint address) + { + var obj = (GameObject*)address; + if (obj == null) + return ByteString.FromSpanUnsafe("Unknown"u8, true, false, true); + + var id = _actors.FromObject(obj, out _, false, true, false); + return id.IsValid ? ByteString.FromStringUnsafe(id.Incognito(null), false) : + obj->Name[0] != 0 ? new ByteString(obj->Name) : ByteString.FromStringUnsafe($"Actor #{obj->ObjectIndex}", false); + } + + private unsafe void OnResourceLoaded(ResourceHandle* handle, Utf8GamePath originalPath, FullPath? manipulatedPath, ResolveData resolveData) + { + if (manipulatedPath == null || _eventWriter == null) + return; + + try + { + if (PathDataHandler.Split(manipulatedPath.Value.FullName, out var actualPath, out _) && !Path.IsPathRooted(actualPath)) + return; + + var name = GetActorName(resolveData.AssociatedGameObject); + lock (_eventWriter) + { + _eventWriter!.FileLoaded.WriteLine(resolveData.AssociatedGameObject, name.Span, resolveData.ModCollection.Identity.Id, + manipulatedPath.Value.InternalName.Span, originalPath.Path.Span); + } + } + catch (Exception ex) + { + Penumbra.Log.Warning($"Error logging resource to crash handler:\n{ex}"); + } + } + + private void CloseEventWriter() + { + if (_eventWriter == null) + return; + + _eventWriter.Dispose(); + _eventWriter = null; + Penumbra.Log.Debug("Closed Event Writer for crash handler."); + } + + private void OpenEventWriter() + { + try + { + CloseEventWriter(); + _eventWriter = new GameEventLogWriter(Environment.ProcessId); + Penumbra.Log.Debug("Opened new Event Writer for crash handler."); + } + catch (Exception ex) + { + Penumbra.Log.Error($"Could not open Event Writer:\n{ex}"); + CloseEventWriter(); + } + } + + private unsafe void Subscribe() + { + _communicator.CreatingCharacterBase.Subscribe(OnCreatingCharacterBase, CreatingCharacterBase.Priority.CrashHandler); + _resourceLoader.ResourceLoaded += OnResourceLoaded; + } + + private unsafe void Unsubscribe() + { + _communicator.CreatingCharacterBase.Unsubscribe(OnCreatingCharacterBase); + _resourceLoader.ResourceLoaded -= OnResourceLoaded; + } +} diff --git a/Penumbra/Services/DalamudConfigService.cs b/Penumbra/Services/DalamudConfigService.cs new file mode 100644 index 00000000..012a45f5 --- /dev/null +++ b/Penumbra/Services/DalamudConfigService.cs @@ -0,0 +1,108 @@ +using Dalamud.Plugin; +using OtterGui.Services; + +namespace Penumbra.Services; + +public class DalamudConfigService : IService +{ + public DalamudConfigService() + { + try + { + var serviceType = + typeof(IDalamudPluginInterface).Assembly.DefinedTypes.FirstOrDefault(t => t.Name == "Service`1" && t.IsGenericType); + var configType = typeof(IDalamudPluginInterface).Assembly.DefinedTypes.FirstOrDefault(t => t.Name == "DalamudConfiguration"); + var interfaceType = typeof(IDalamudPluginInterface).Assembly.DefinedTypes.FirstOrDefault(t => t.Name == "DalamudInterface"); + if (serviceType == null || configType == null || interfaceType == null) + return; + + var configService = serviceType.MakeGenericType(configType); + var interfaceService = serviceType.MakeGenericType(interfaceType); + var configGetter = configService.GetMethod("Get", BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic); + _interfaceGetter = interfaceService.GetMethod("Get", BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic); + if (configGetter == null || _interfaceGetter == null) + return; + + _dalamudConfig = configGetter.Invoke(null, null); + if (_dalamudConfig != null) + { + _saveDalamudConfig = _dalamudConfig.GetType() + .GetMethod("Save", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); + if (_saveDalamudConfig == null) + { + _dalamudConfig = null; + _interfaceGetter = null; + } + } + } + catch + { + _dalamudConfig = null; + _saveDalamudConfig = null; + _interfaceGetter = null; + } + } + + public const string WaitingForPluginsOption = "IsResumeGameAfterPluginLoad"; + + private readonly object? _dalamudConfig; + private readonly MethodInfo? _interfaceGetter; + private readonly MethodInfo? _saveDalamudConfig; + + public bool GetDalamudConfig(string fieldName, out T? value) + { + value = default; + try + { + if (_dalamudConfig == null) + return false; + + var getter = _dalamudConfig.GetType().GetProperty(fieldName, BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); + if (getter == null) + return false; + + var result = getter.GetValue(_dalamudConfig); + if (result is not T v) + return false; + + value = v; + return true; + } + catch (Exception e) + { + Penumbra.Log.Error($"Error while fetching Dalamud Config {fieldName}:\n{e}"); + return false; + } + } + + public bool SetDalamudConfig(string fieldName, in T? value, string? windowFieldName = null) + { + try + { + if (_dalamudConfig == null) + return false; + + var getter = _dalamudConfig.GetType().GetProperty(fieldName, BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); + if (getter == null) + return false; + + getter.SetValue(_dalamudConfig, value); + if (windowFieldName != null) + { + var inter = _interfaceGetter!.Invoke(null, null); + var settingsWindow = inter?.GetType() + .GetField("settingsWindow", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic)?.GetValue(inter); + settingsWindow?.GetType().GetField(windowFieldName, BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic) + ?.SetValue(settingsWindow, value); + } + + _saveDalamudConfig!.Invoke(_dalamudConfig, null); + return true; + } + catch (Exception e) + { + Penumbra.Log.Error($"Error while fetching Dalamud Config {fieldName}:\n{e}"); + return false; + } + } +} diff --git a/Penumbra/Services/FileWatcher.cs b/Penumbra/Services/FileWatcher.cs new file mode 100644 index 00000000..1d572f05 --- /dev/null +++ b/Penumbra/Services/FileWatcher.cs @@ -0,0 +1,209 @@ +using OtterGui.Services; +using Penumbra.Mods.Manager; + +namespace Penumbra.Services; + +public class FileWatcher : IDisposable, IService +{ + // TODO: use ConcurrentSet when it supports comparers in Luna. + private readonly ConcurrentDictionary _pending = new(StringComparer.OrdinalIgnoreCase); + private readonly ModImportManager _modImportManager; + private readonly MessageService _messageService; + private readonly Configuration _config; + + private bool _pausedConsumer; + private FileSystemWatcher? _fsw; + private CancellationTokenSource? _cts = new(); + private Task? _consumer; + + public FileWatcher(ModImportManager modImportManager, MessageService messageService, Configuration config) + { + _modImportManager = modImportManager; + _messageService = messageService; + _config = config; + + if (_config.EnableDirectoryWatch) + { + SetupFileWatcher(_config.WatchDirectory); + SetupConsumerTask(); + } + } + + public void Toggle(bool value) + { + if (_config.EnableDirectoryWatch == value) + return; + + _config.EnableDirectoryWatch = value; + _config.Save(); + if (value) + { + SetupFileWatcher(_config.WatchDirectory); + SetupConsumerTask(); + } + else + { + EndFileWatcher(); + EndConsumerTask(); + } + } + + internal void PauseConsumer(bool pause) + => _pausedConsumer = pause; + + private void EndFileWatcher() + { + if (_fsw is null) + return; + + _fsw.Dispose(); + _fsw = null; + } + + private void SetupFileWatcher(string directory) + { + EndFileWatcher(); + _fsw = new FileSystemWatcher + { + IncludeSubdirectories = false, + NotifyFilter = NotifyFilters.FileName | NotifyFilters.CreationTime, + InternalBufferSize = 32 * 1024, + }; + + // Only wake us for the exact patterns we care about + _fsw.Filters.Add("*.pmp"); + _fsw.Filters.Add("*.pcp"); + _fsw.Filters.Add("*.ttmp"); + _fsw.Filters.Add("*.ttmp2"); + + _fsw.Created += OnPath; + _fsw.Renamed += OnPath; + UpdateDirectory(directory); + } + + + private void EndConsumerTask() + { + if (_cts is not null) + { + _cts.Cancel(); + _cts = null; + } + _consumer = null; + } + + private void SetupConsumerTask() + { + EndConsumerTask(); + _cts = new CancellationTokenSource(); + _consumer = Task.Factory.StartNew( + () => ConsumerLoopAsync(_cts.Token), + _cts.Token, TaskCreationOptions.LongRunning, TaskScheduler.Default).Unwrap(); + } + + public void UpdateDirectory(string newPath) + { + if (_config.WatchDirectory != newPath) + { + _config.WatchDirectory = newPath; + _config.Save(); + } + + if (_fsw is null) + return; + + _fsw.EnableRaisingEvents = false; + if (!Directory.Exists(newPath) || newPath.Length is 0) + { + _fsw.Path = string.Empty; + } + else + { + _fsw.Path = newPath; + _fsw.EnableRaisingEvents = true; + } + } + + private void OnPath(object? sender, FileSystemEventArgs e) + => _pending.TryAdd(e.FullPath, 0); + + private async Task ConsumerLoopAsync(CancellationToken token) + { + while (true) + { + var (path, _) = _pending.FirstOrDefault(); + if (path is null || _pausedConsumer) + { + await Task.Delay(500, token).ConfigureAwait(false); + continue; + } + + try + { + await ProcessOneAsync(path, token).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + Penumbra.Log.Debug("[FileWatcher] Canceled via Token."); + } + catch (Exception ex) + { + Penumbra.Log.Warning($"[FileWatcher] Error during Processing: {ex}"); + } + finally + { + _pending.TryRemove(path, out _); + } + } + } + + private async Task ProcessOneAsync(string path, CancellationToken token) + { + // Downloads often finish via rename; file may be locked briefly. + // Wait until it exists and is readable; also require two stable size checks. + const int maxTries = 40; + long lastLen = -1; + + for (var i = 0; i < maxTries && !token.IsCancellationRequested; i++) + { + if (!File.Exists(path)) + { + await Task.Delay(100, token); + continue; + } + + try + { + var fi = new FileInfo(path); + var len = fi.Length; + if (len > 0 && len == lastLen) + { + if (_config.EnableAutomaticModImport) + _modImportManager.AddUnpack(path); + else + _messageService.AddMessage(new InstallNotification(_modImportManager, path), false); + return; + } + + lastLen = len; + } + catch (IOException) + { + Penumbra.Log.Debug($"[FileWatcher] File is still being written to."); + } + catch (UnauthorizedAccessException) + { + Penumbra.Log.Debug($"[FileWatcher] File is locked."); + } + + await Task.Delay(150, token); + } + } + + + public void Dispose() + { + EndConsumerTask(); + EndFileWatcher(); + } +} diff --git a/Penumbra/Services/FilenameService.cs b/Penumbra/Services/FilenameService.cs new file mode 100644 index 00000000..ee096109 --- /dev/null +++ b/Penumbra/Services/FilenameService.cs @@ -0,0 +1,85 @@ +using Dalamud.Plugin; +using OtterGui.Services; +using Penumbra.Collections; +using Penumbra.Mods; + +namespace Penumbra.Services; + +public class FilenameService(IDalamudPluginInterface pi) : IService +{ + public readonly string ConfigDirectory = pi.ConfigDirectory.FullName; + public readonly string CollectionDirectory = Path.Combine(pi.ConfigDirectory.FullName, "collections"); + public readonly string LocalDataDirectory = Path.Combine(pi.ConfigDirectory.FullName, "mod_data"); + public readonly string ConfigFile = pi.ConfigFile.FullName; + public readonly string EphemeralConfigFile = Path.Combine(pi.ConfigDirectory.FullName, "ephemeral_config.json"); + public readonly string FilesystemFile = Path.Combine(pi.ConfigDirectory.FullName, "sort_order.json"); + public readonly string ActiveCollectionsFile = Path.Combine(pi.ConfigDirectory.FullName, "active_collections.json"); + public readonly string PredefinedTagFile = Path.Combine(pi.ConfigDirectory.FullName, "predefined_tags.json"); + + public readonly string CrashHandlerExe = + Path.Combine(pi.AssemblyLocation.DirectoryName!, "Penumbra.CrashHandler.exe"); + + public readonly string LogFileName = + Path.Combine(Path.GetDirectoryName(Path.GetDirectoryName(pi.ConfigDirectory.FullName)!)!, "Penumbra.log"); + + /// Obtain the path of a collection file given its name. + public string CollectionFile(ModCollection collection) + => CollectionFile(collection.Identity.Identifier); + + /// Obtain the path of a collection file given its name. + public string CollectionFile(string collectionName) + => Path.Combine(CollectionDirectory, $"{collectionName}.json"); + + /// Obtain the path of the local data file given a mod directory. Returns an empty string if the mod is temporary. + public string LocalDataFile(Mod mod) + => LocalDataFile(mod.ModPath.FullName); + + /// Obtain the path of the local data file given a mod directory. + public string LocalDataFile(string modDirectory) + => Path.Combine(LocalDataDirectory, $"{Path.GetFileName(modDirectory)}.json"); + + /// Enumerate all collection files. + public IEnumerable CollectionFiles + { + get + { + var directory = new DirectoryInfo(CollectionDirectory); + return directory.Exists ? directory.EnumerateFiles("*.json") : []; + } + } + + /// Enumerate all local data files. + public IEnumerable LocalDataFiles + { + get + { + var directory = new DirectoryInfo(LocalDataDirectory); + return directory.Exists ? directory.EnumerateFiles("*.json") : []; + } + } + + /// Obtain the path of the meta file for a given mod. Returns an empty string if the mod is temporary. + public string ModMetaPath(Mod mod) + => ModMetaPath(mod.ModPath.FullName); + + /// Obtain the path of the meta file given a mod directory. + public string ModMetaPath(string modDirectory) + => Path.Combine(modDirectory, "meta.json"); + + /// Obtain the path of the file describing a given option group by its index and the mod. If the index is < 0, return the path for the default mod file. + public string OptionGroupFile(Mod mod, int index, bool onlyAscii) + => OptionGroupFile(mod.ModPath.FullName, index, index >= 0 ? mod.Groups[index].Name : string.Empty, onlyAscii); + + /// Obtain the path of the file describing a given option group by its index, name and basepath. If the index is < 0, return the path for the default mod file. + public string OptionGroupFile(string basePath, int index, string name, bool onlyAscii) + { + var fileName = index >= 0 + ? $"group_{index + 1:D3}_{ModCreator.ReplaceBadXivSymbols(name.ToLowerInvariant(), onlyAscii)}.json" + : "default_mod.json"; + return Path.Combine(basePath, fileName); + } + + /// Enumerate all group files for a given mod. + public IEnumerable GetOptionGroupFiles(Mod mod) + => mod.ModPath.EnumerateFiles("group_*.json"); +} diff --git a/Penumbra/Services/InstallNotification.cs b/Penumbra/Services/InstallNotification.cs new file mode 100644 index 00000000..e3956076 --- /dev/null +++ b/Penumbra/Services/InstallNotification.cs @@ -0,0 +1,39 @@ +using Dalamud.Bindings.ImGui; +using Dalamud.Interface.ImGuiNotification; +using Dalamud.Interface.ImGuiNotification.EventArgs; +using OtterGui.Text; +using Penumbra.Mods.Manager; + +namespace Penumbra.Services; + +public class InstallNotification(ModImportManager modImportManager, string filePath) : OtterGui.Classes.MessageService.IMessage +{ + public string Message + => "A new mod has been found!"; + + public NotificationType NotificationType + => NotificationType.Info; + + public uint NotificationDuration + => uint.MaxValue; + + public string NotificationTitle { get; } = Path.GetFileNameWithoutExtension(filePath); + + public string LogMessage + => $"A new mod has been found: {Path.GetFileName(filePath)}"; + + public void OnNotificationActions(INotificationDrawArgs args) + { + var region = ImGui.GetContentRegionAvail(); + var buttonSize = new Vector2((region.X - ImGui.GetStyle().ItemSpacing.X) / 2, 0); + if (ImUtf8.ButtonEx("Install"u8, ""u8, buttonSize)) + { + modImportManager.AddUnpack(filePath); + args.Notification.DismissNow(); + } + + ImGui.SameLine(); + if (ImUtf8.ButtonEx("Ignore"u8, ""u8, buttonSize)) + args.Notification.DismissNow(); + } +} diff --git a/Penumbra/Services/MessageService.cs b/Penumbra/Services/MessageService.cs new file mode 100644 index 00000000..70ccf47b --- /dev/null +++ b/Penumbra/Services/MessageService.cs @@ -0,0 +1,58 @@ +using Dalamud.Game.Text; +using Dalamud.Game.Text.SeStringHandling; +using Dalamud.Game.Text.SeStringHandling.Payloads; +using Dalamud.Interface; +using Dalamud.Interface.ImGuiNotification; +using Dalamud.Plugin.Services; +using Lumina.Excel.Sheets; +using OtterGui.Log; +using OtterGui.Services; +using Penumbra.GameData.Data; +using Penumbra.Mods.Manager; +using Penumbra.String.Classes; +using Notification = OtterGui.Classes.Notification; + +namespace Penumbra.Services; + +public class MessageService(Logger log, IUiBuilder builder, IChatGui chat, INotificationManager notificationManager) + : OtterGui.Classes.MessageService(log, builder, chat, notificationManager), IService +{ + public void LinkItem(in Item item) + { + // @formatter:off + var payloadList = new List + { + new UIForegroundPayload((ushort)(0x223 + item.Rarity * 2)), + new UIGlowPayload((ushort)(0x224 + item.Rarity * 2)), + new ItemPayload(item.RowId, false), + new UIForegroundPayload(500), + new UIGlowPayload(501), + new TextPayload($"{(char)SeIconChar.LinkMarker}"), + new UIForegroundPayload(0), + new UIGlowPayload(0), + new TextPayload(item.Name.ExtractTextExtended()), + new RawPayload([0x02, 0x27, 0x07, 0xCF, 0x01, 0x01, 0x01, 0xFF, 0x01, 0x03]), + new RawPayload([0x02, 0x13, 0x02, 0xEC, 0x03]), + }; + // @formatter:on + + var payload = new SeString(payloadList); + + Chat.Print(new XivChatEntry + { + Message = payload, + }); + } + + public void PrintFileWarning(ModManager modManager, string fullPath, Utf8GamePath originalGamePath, string messageComplement) + { + // Don't warn for files managed by other plugins, or files we aren't sure about. + if (!modManager.TryIdentifyPath(fullPath, out var mod, out _)) + return; + + AddTaggedMessage($"{fullPath}.{messageComplement}", + new Notification( + $"Cowardly refusing to load replacement for {originalGamePath.Filename().ToString().ToLowerInvariant()} by {mod.Name}{(messageComplement.Length > 0 ? ":\n" : ".")}{messageComplement}", + NotificationType.Warning, 10000)); + } +} diff --git a/Penumbra/Services/MigrationManager.cs b/Penumbra/Services/MigrationManager.cs new file mode 100644 index 00000000..8db62e48 --- /dev/null +++ b/Penumbra/Services/MigrationManager.cs @@ -0,0 +1,436 @@ +using Dalamud.Interface.ImGuiNotification; +using Lumina.Data.Files; +using OtterGui.Classes; +using OtterGui.Services; +using Lumina.Extensions; +using Penumbra.GameData.Files.Utility; +using Penumbra.Import.Textures; +using SharpCompress.Common; +using SharpCompress.Readers; +using MdlFile = Penumbra.GameData.Files.MdlFile; +using MtrlFile = Penumbra.GameData.Files.MtrlFile; + +namespace Penumbra.Services; + +public class MigrationManager(Configuration config) : IService +{ + public enum TaskType : byte + { + None, + MdlMigration, + MdlRestoration, + MdlCleanup, + MtrlMigration, + MtrlRestoration, + MtrlCleanup, + } + + public class MigrationData(bool hasUnchanged) + + { + public int Changed; + public int Unchanged; + public int Failed; + public bool HasData; + public readonly bool HasUnchanged = hasUnchanged; + + public int Total + => Changed + Unchanged + Failed; + + public void Init() + { + Changed = 0; + Unchanged = 0; + Failed = 0; + HasData = true; + } + } + + private Task? _currentTask; + private CancellationTokenSource? _source; + + public TaskType CurrentTask { get; private set; } + + public readonly MigrationData MdlMigration = new(true); + public readonly MigrationData MtrlMigration = new(true); + public readonly MigrationData MdlCleanup = new(false); + public readonly MigrationData MtrlCleanup = new(false); + public readonly MigrationData MdlRestoration = new(false); + public readonly MigrationData MtrlRestoration = new(false); + + + public bool IsRunning + => _currentTask is { IsCompleted: false }; + + public void CleanMdlBackups(string path) + => CleanBackups(path, "*.mdl.bak", "model", MdlCleanup, TaskType.MdlCleanup); + + public void CleanMtrlBackups(string path) + => CleanBackups(path, "*.mtrl.bak", "material", MtrlCleanup, TaskType.MtrlCleanup); + + public void Await() + => _currentTask?.Wait(); + + private void CleanBackups(string path, string extension, string fileType, MigrationData data, TaskType type) + { + if (IsRunning) + return; + + _source = new CancellationTokenSource(); + var token = _source.Token; + _currentTask = Task.Run(() => + { + CurrentTask = type; + data.Init(); + foreach (var file in Directory.EnumerateFiles(path, extension, SearchOption.AllDirectories)) + { + if (token.IsCancellationRequested) + return; + + try + { + File.Delete(file); + ++data.Changed; + Penumbra.Log.Debug($"Deleted {fileType} backup file {file}."); + } + catch (Exception ex) + { + Penumbra.Messager.NotificationMessage(ex, $"Failed to delete {fileType} backup file {file}", NotificationType.Warning); + ++data.Failed; + } + } + }, token); + } + + public void RestoreMdlBackups(string path) + => RestoreBackups(path, "*.mdl.bak", "model", MdlRestoration, TaskType.MdlRestoration); + + public void RestoreMtrlBackups(string path) + => RestoreBackups(path, "*.mtrl.bak", "material", MtrlRestoration, TaskType.MtrlRestoration); + + private void RestoreBackups(string path, string extension, string fileType, MigrationData data, TaskType type) + { + if (IsRunning) + return; + + _source = new CancellationTokenSource(); + var token = _source.Token; + _currentTask = Task.Run(() => + { + CurrentTask = type; + data.Init(); + foreach (var file in Directory.EnumerateFiles(path, extension, SearchOption.AllDirectories)) + { + if (token.IsCancellationRequested) + return; + + var target = file[..^4]; + try + { + File.Copy(file, target, true); + ++data.Changed; + Penumbra.Log.Debug($"Restored {fileType} backup file {file} to {target}."); + } + catch (Exception ex) + { + Penumbra.Messager.NotificationMessage(ex, $"Failed to restore {fileType} backup file {file} to {target}", + NotificationType.Warning); + ++data.Failed; + } + } + }, token); + } + + public void MigrateMdlDirectory(string path, bool createBackups) + => MigrateDirectory(path, createBackups, "*.mdl", "model", MdlMigration, TaskType.MdlMigration, "from V5 to V6", "V6", + (file, fileData, backups) => + { + var mdl = new MdlFile(fileData); + return MigrateModel(file, mdl, backups); + }); + + public void MigrateMtrlDirectory(string path, bool createBackups) + => MigrateDirectory(path, createBackups, "*.mtrl", "material", MtrlMigration, TaskType.MtrlMigration, "to Dawntrail", "Dawntrail", + (file, fileData, backups) => + { + var mtrl = new MtrlFile(fileData); + return MigrateMaterial(file, mtrl, backups); + } + ); + + private void MigrateDirectory(string path, bool createBackups, string extension, string fileType, MigrationData data, TaskType type, + string action, string state, Func func) + { + if (IsRunning) + return; + + _source = new CancellationTokenSource(); + var token = _source.Token; + _currentTask = Task.Run(() => + { + CurrentTask = type; + data.Init(); + foreach (var file in Directory.EnumerateFiles(path, extension, SearchOption.AllDirectories)) + { + if (token.IsCancellationRequested) + return; + + var timer = Stopwatch.StartNew(); + try + { + var fileData = File.ReadAllBytes(file); + if (func(file, fileData, createBackups)) + { + ++data.Changed; + Penumbra.Log.Debug($"Migrated {fileType} file {file} {action} in {timer.ElapsedMilliseconds} ms."); + } + else + { + ++data.Unchanged; + Penumbra.Log.Verbose($"Verified that {fileType} file {file} is already {state} in {timer.ElapsedMilliseconds} ms."); + } + } + catch (Exception ex) + { + ++data.Failed; + Penumbra.Messager.NotificationMessage(ex, + $"Failed to migrate {fileType} file {file} to {state} in {timer.ElapsedMilliseconds} ms", + NotificationType.Warning); + } + } + }, token); + } + + public void Cancel() + { + _source?.Cancel(); + _source = null; + _currentTask = null; + } + + public static bool TryMigrateSingleModel(string path, bool createBackup) + { + try + { + var data = File.ReadAllBytes(path); + var mdl = new MdlFile(data); + return MigrateModel(path, mdl, createBackup); + } + catch (Exception ex) + { + Penumbra.Messager.NotificationMessage(ex, $"Failed to migrate the model {path} to V6", NotificationType.Warning); + return false; + } + } + + public static bool TryMigrateSingleMaterial(string path, bool createBackup) + { + try + { + var data = File.ReadAllBytes(path); + var mtrl = new MtrlFile(data); + return MigrateMaterial(path, mtrl, createBackup); + } + catch (Exception ex) + { + Penumbra.Messager.NotificationMessage(ex, $"Failed to migrate the material {path} to Dawntrail", NotificationType.Warning); + return false; + } + } + + /// Writes or migrates a .mdl file during extraction from a regular archive. + public void MigrateMdlDuringExtraction(IReader reader, string directory, ExtractionOptions options) + { + if (!config.MigrateImportedModelsToV6) + { + reader.WriteEntryToDirectory(directory, options); + return; + } + + var path = Path.Combine(directory, reader.Entry.Key!); + using var s = new MemoryStream(); + using var e = reader.OpenEntryStream(); + e.CopyTo(s); + s.Position = 0; + using var b = new BinaryReader(s); + var version = b.ReadUInt32(); + if (version == MdlFile.V5) + { + var data = s.ToArray(); + var mdl = new MdlFile(data); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + MigrateModel(path, mdl, false); + Penumbra.Log.Debug($"Migrated model {reader.Entry.Key} from V5 to V6 during import."); + } + else + { + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + using var f = File.Open(path, FileMode.Create, FileAccess.Write); + s.Seek(0, SeekOrigin.Begin); + s.WriteTo(f); + } + } + + public void MigrateMtrlDuringExtraction(IReader reader, string directory, ExtractionOptions options) + { + if (!config.MigrateImportedMaterialsToLegacy || true) // TODO change when this is working + { + reader.WriteEntryToDirectory(directory, options); + return; + } + + var path = Path.Combine(directory, reader.Entry.Key); + using var s = new MemoryStream(); + using var e = reader.OpenEntryStream(); + e.CopyTo(s); + var file = new MtrlFile(s.GetBuffer()); + + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + using var f = File.Open(path, FileMode.Create, FileAccess.Write); + if (file.IsDawntrail) + { + file.MigrateToDawntrail(); + Penumbra.Log.Debug($"Migrated material {reader.Entry.Key} to Dawntrail during import."); + f.Write(file.Write()); + } + else + { + s.Seek(0, SeekOrigin.Begin); + s.WriteTo(f); + } + } + + public void FixMipMaps(IReader reader, string directory, ExtractionOptions options) + { + var path = Path.Combine(directory, reader.Entry.Key!); + using var s = new MemoryStream(); + using var e = reader.OpenEntryStream(); + e.CopyTo(s); + var length = s.Position; + s.Seek(0, SeekOrigin.Begin); + var br = new BinaryReader(s, Encoding.UTF8, true); + var header = br.ReadStructure(); + br.Dispose(); + TexFileParser.FixMipOffsets(length, ref header, out var actualSize); + + s.Seek(0, SeekOrigin.Begin); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + using var f = File.Open(path, FileMode.Create, FileAccess.Write); + f.Write(header); + f.Write(s.GetBuffer().AsSpan(80, (int)actualSize - 80)); + } + + /// Update the data of a .mdl file during TTMP extraction. Returns either the existing array or a new one. + public byte[] MigrateTtmpModel(string path, byte[] data) + { + FixLodNum(data); + if (!config.MigrateImportedModelsToV6) + return data; + + var version = BitConverter.ToUInt32(data); + if (version != 5) + return data; + + try + { + var mdl = new MdlFile(data); + if (!mdl.ConvertV5ToV6()) + return data; + + data = mdl.Write(); + Penumbra.Log.Debug($"Migrated model {path} from V5 to V6 during import."); + return data; + } + catch (Exception ex) + { + Penumbra.Log.Warning($"Failed to migrate model {path} from V5 to V6 during import:\n{ex}"); + return data; + } + } + + /// Update the data of a .mtrl file during TTMP extraction. Returns either the existing array or a new one. + public byte[] MigrateTtmpMaterial(string path, byte[] data) + { + if (!config.MigrateImportedMaterialsToLegacy || true) // TODO fix when this is working + return data; + + try + { + var mtrl = new MtrlFile(data); + if (mtrl.IsDawntrail) + return data; + + mtrl.MigrateToDawntrail(); + data = mtrl.Write(); + Penumbra.Log.Debug($"Migrated material {path} to Dawntrail during import."); + return data; + } + catch (Exception ex) + { + Penumbra.Log.Warning($"Failed to migrate material {path} to Dawntrail during import:\n{ex}"); + return data; + } + } + + public byte[] FixTtmpMipMaps(string path, byte[] data) + { + using var m = new MemoryStream(data); + var br = new BinaryReader(m, Encoding.UTF8, true); + var header = br.ReadStructure(); + br.Dispose(); + TexFileParser.FixMipOffsets(data.Length, ref header, out var actualSize); + if (actualSize == data.Length) + return data; + + var ret = new byte[actualSize]; + using var m2 = new MemoryStream(ret); + using var bw = new BinaryWriter(m2); + bw.Write(header); + bw.Write(data.AsSpan(80, (int)actualSize - 80)); + + return ret; + } + + + private static bool MigrateModel(string path, MdlFile mdl, bool createBackup) + { + if (!mdl.ConvertV5ToV6()) + return false; + + var data = mdl.Write(); + if (createBackup) + File.Copy(path, Path.ChangeExtension(path, ".mdl.bak")); + File.WriteAllBytes(path, data); + return true; + } + + private static bool MigrateMaterial(string path, MtrlFile mtrl, bool createBackup) + { + if (!mtrl.MigrateToDawntrail()) + return false; + + var data = mtrl.Write(); + + mtrl.Write(); + if (createBackup) + File.Copy(path, Path.ChangeExtension(path, ".mtrl.bak")); + File.WriteAllBytes(path, data); + return true; + } + + private static void FixLodNum(byte[] data) + { + const int modelHeaderLodOffset = 22; + + // Model file header LOD num + data[64] = 1; + + // Model header LOD num + var stackSize = BitConverter.ToUInt32(data, 4); + var runtimeBegin = stackSize + 0x44; + var stringsLengthOffset = runtimeBegin + 4; + var stringsLength = BitConverter.ToUInt32(data, (int)stringsLengthOffset); + var modelHeaderStart = stringsLengthOffset + stringsLength + 4; + data[modelHeaderStart + modelHeaderLodOffset] = 1; + } +} diff --git a/Penumbra/Services/ModMigrator.cs b/Penumbra/Services/ModMigrator.cs new file mode 100644 index 00000000..043d9631 --- /dev/null +++ b/Penumbra/Services/ModMigrator.cs @@ -0,0 +1,349 @@ +using Dalamud.Plugin.Services; +using OtterGui.Classes; +using OtterGui.Services; +using Penumbra.Api.Enums; +using Penumbra.GameData.Data; +using Penumbra.GameData.Files; +using Penumbra.GameData.Files.MaterialStructs; +using Penumbra.GameData.Structs; +using Penumbra.Import.Textures; +using Penumbra.Mods; +using Penumbra.Mods.SubMods; +using Penumbra.String.Classes; + +namespace Penumbra.Services; + +public class ModMigrator(IDataManager gameData, TextureManager textures) : IService +{ + private sealed class FileDataDict : MultiDictionary; + + private readonly Lazy _glassReferenceMaterial = new(() => + { + var bytes = gameData.GetFile("chara/equipment/e5001/material/v0001/mt_c0101e5001_met_b.mtrl"); + return new MtrlFile(bytes!.Data); + }); + + private readonly HashSet _changedMods = []; + private readonly HashSet _failedMods = []; + + private readonly FileDataDict Textures = []; + private readonly FileDataDict Models = []; + private readonly FileDataDict Materials = []; + private readonly FileDataDict FileSwaps = []; + + private readonly ConcurrentBag _messages = []; + + public void Update(IEnumerable mods) + { + CollectFiles(mods); + foreach (var (from, (to, container)) in FileSwaps) + MigrateFileSwaps(from, to, container); + foreach (var (model, list) in Models.Grouped) + MigrateModel(model, (Mod)list[0].Container.Mod); + } + + private void CollectFiles(IEnumerable mods) + { + foreach (var mod in mods) + { + foreach (var container in mod.AllDataContainers) + { + foreach (var (gamePath, file) in container.Files) + { + switch (ResourceTypeExtensions.FromExtension(gamePath.Extension().Span)) + { + case ResourceType.Tex: Textures.TryAdd(file.FullName, (gamePath.ToString(), container)); break; + case ResourceType.Mdl: Models.TryAdd(file.FullName, (gamePath.ToString(), container)); break; + case ResourceType.Mtrl: Materials.TryAdd(file.FullName, (gamePath.ToString(), container)); break; + } + } + + foreach (var (swapFrom, swapTo) in container.FileSwaps) + FileSwaps.TryAdd(swapTo.FullName, (swapFrom.ToString(), container)); + } + } + } + + public Task CreateIndexFile(string normalPath, string targetPath) + { + const int rowBlend = 17; + + return Task.Run(async () => + { + var tex = textures.LoadTex(normalPath); + var data = tex.GetPixelData(); + var rgbaData = new RgbaPixelData(data.Width, data.Height, data.Rgba); + if (!BitOperations.IsPow2(rgbaData.Height) || !BitOperations.IsPow2(rgbaData.Width)) + { + var requiredHeight = (int)BitOperations.RoundUpToPowerOf2((uint)rgbaData.Height); + var requiredWidth = (int)BitOperations.RoundUpToPowerOf2((uint)rgbaData.Width); + rgbaData = rgbaData.Resize((requiredWidth, requiredHeight)); + } + + Parallel.ForEach(Enumerable.Range(0, rgbaData.PixelData.Length / 4), idx => + { + var pixelIdx = 4 * idx; + var normal = rgbaData.PixelData[pixelIdx + 3]; + + // Copied from TT + var blendRem = normal % (2 * rowBlend); + var originalRow = normal / rowBlend; + switch (blendRem) + { + // Goes to next row, clamped to the closer row. + case > 25: + blendRem = 0; + ++originalRow; + break; + // Stays in this row, clamped to the closer row. + case > 17: blendRem = 17; break; + } + + var newBlend = (byte)(255 - MathF.Round(blendRem / 17f * 255f)); + + // Slight add here to push the color deeper into the row to ensure BC5 compression doesn't + // cause any artifacting. + var newRow = (byte)(originalRow / 2 * 17 + 4); + + rgbaData.PixelData[pixelIdx] = newRow; + rgbaData.PixelData[pixelIdx] = newBlend; + rgbaData.PixelData[pixelIdx] = 0; + rgbaData.PixelData[pixelIdx] = 255; + }); + await textures.SaveAs(CombinedTexture.TextureSaveType.BC5, true, true, new BaseImage(), targetPath, rgbaData.PixelData, + rgbaData.Width, rgbaData.Height); + }); + } + + private void MigrateModel(string filePath, Mod mod) + { + if (MigrationManager.TryMigrateSingleModel(filePath, true)) + { + _messages.Add($"Migrated model {filePath} in {mod.Name}."); + } + else + { + _messages.Add($"Failed to migrate model {filePath} in {mod.Name}"); + _failedMods.Add(mod); + } + } + + private void SetGlassReferenceValues(MtrlFile mtrl) + { + var reference = _glassReferenceMaterial.Value; + mtrl.ShaderPackage.ShaderKeys = reference.ShaderPackage.ShaderKeys.ToArray(); + mtrl.ShaderPackage.Constants = reference.ShaderPackage.Constants.ToArray(); + mtrl.AdditionalData = reference.AdditionalData.ToArray(); + mtrl.ShaderPackage.Flags &= ~(0x04u | 0x08u); + // From TT. + if (mtrl.Table is ColorTable t) + foreach (ref var row in t.AsRows()) + row.SpecularColor = new HalfColor((Half)0.8100586, (Half)0.8100586, (Half)0.8100586); + } + + private ref struct MaterialPack + { + public readonly MtrlFile File; + public readonly bool UsesMaskAsSpecular; + + private readonly Dictionary Samplers = []; + + public MaterialPack(MtrlFile file) + { + File = file; + UsesMaskAsSpecular = File.ShaderPackage.ShaderKeys.Any(x => x.Key is 0xC8BD1DEF && x.Value is 0xA02F4828 or 0x198D11CD); + Add(Samplers, TextureUsage.Normal, ShpkFile.NormalSamplerId); + Add(Samplers, TextureUsage.Index, ShpkFile.IndexSamplerId); + Add(Samplers, TextureUsage.Mask, ShpkFile.MaskSamplerId); + Add(Samplers, TextureUsage.Diffuse, ShpkFile.DiffuseSamplerId); + Add(Samplers, TextureUsage.Specular, ShpkFile.SpecularSamplerId); + return; + + void Add(Dictionary dict, TextureUsage usage, uint samplerId) + { + var idx = new SamplerIndex(file, samplerId); + if (idx.Texture >= 0) + dict.Add(usage, idx); + } + } + + public readonly record struct SamplerIndex(int Sampler, int Texture) + { + public SamplerIndex(MtrlFile file, uint samplerId) + : this(file.FindSampler(samplerId), -1) + => Texture = Sampler < 0 ? -1 : file.ShaderPackage.Samplers[Sampler].TextureIndex; + } + + public enum TextureUsage + { + Unknown, + Normal, + Index, + Mask, + Diffuse, + Specular, + } + + public static bool AdaptPath(IDataManager data, string path, TextureUsage usage, out string newPath) + { + newPath = path; + if (Path.GetExtension(newPath) is not ".tex") + return false; + + if (data.FileExists(newPath)) + return true; + + ReadOnlySpan<(string, string)> pairs = usage switch + { + TextureUsage.Unknown => + [ + ("_n.tex", "_norm.tex"), + ("_m.tex", "_mult.tex"), + ("_m.tex", "_mask.tex"), + ("_d.tex", "_base.tex"), + ], + TextureUsage.Normal => + [ + ("_n_", "_norm_"), + ("_n.tex", "_norm.tex"), + ], + TextureUsage.Mask => + [ + ("_m_", "_mult_"), + ("_m_", "_mask_"), + ("_m.tex", "_mult.tex"), + ("_m.tex", "_mask.tex"), + ], + TextureUsage.Diffuse => + [ + ("_d_", "_base_"), + ("_d.tex", "_base.tex"), + ], + TextureUsage.Index => [], + TextureUsage.Specular => [], + _ => [], + }; + foreach (var (from, to) in pairs) + { + newPath = path.Replace(from, to); + if (data.FileExists(newPath)) + return true; + } + + return false; + } + } + + private void MigrateMaterial(string filePath, IReadOnlyList<(string GamePath, IModDataContainer Container)> redirections) + { + try + { + var bytes = File.ReadAllBytes(filePath); + var mtrl = new MtrlFile(bytes); + if (!CheckUpdateNeeded(mtrl)) + return; + + // Update colorsets, flags and character shader package. + var changes = mtrl.MigrateToDawntrail(); + + if (!changes) + switch (mtrl.ShaderPackage.Name) + { + case "hair.shpk": break; + case "characterglass.shpk": + SetGlassReferenceValues(mtrl); + changes = true; + break; + } + + // Remove DX11 flags and update paths if necessary. + foreach (ref var tex in mtrl.Textures.AsSpan()) + { + if (tex.DX11) + { + changes = true; + if (GamePaths.Tex.HandleDx11Path(tex, out var newPath)) + tex.Path = newPath; + tex.DX11 = false; + } + + if (gameData.FileExists(tex.Path)) + continue; + } + + // Dyeing, from TT. + if (mtrl.DyeTable is ColorDyeTable dye) + foreach (ref var row in dye.AsRows()) + row.Template += 1000; + } + catch + { + // ignored + } + + static bool CheckUpdateNeeded(MtrlFile mtrl) + { + if (!mtrl.IsDawntrail) + return true; + + if (mtrl.ShaderPackage.Name is not "hair.shpk") + return false; + + var foundOld = 0; + foreach (var c in mtrl.ShaderPackage.Constants) + { + switch (c.Id) + { + case 0x36080AD0: foundOld |= 1; break; // == 1, from TT + case 0x992869AB: foundOld |= 2; break; // == 3 (skin) or 4 (hair) from TT + } + + if (foundOld is 3) + return true; + } + + return false; + } + } + + private void MigrateFileSwaps(string swapFrom, string swapTo, IModDataContainer container) + { + var fromExists = gameData.FileExists(swapFrom); + var toExists = gameData.FileExists(swapTo); + if (fromExists && toExists) + return; + + if (ResourceTypeExtensions.FromExtension(Path.GetExtension(swapFrom.AsSpan())) is not ResourceType.Tex + || ResourceTypeExtensions.FromExtension(Path.GetExtension(swapTo.AsSpan())) is not ResourceType.Tex) + { + _messages.Add( + $"Could not migrate file swap {swapFrom} -> {swapTo} in {container.Mod.Name}: {container.GetFullName()}. Only textures may be migrated.{(fromExists ? "\n\tSource File does not exist." : "")}{(toExists ? "\n\tTarget File does not exist." : "")}"); + return; + } + + var newSwapFrom = swapFrom; + if (!fromExists && !MaterialPack.AdaptPath(gameData, swapFrom, MaterialPack.TextureUsage.Unknown, out newSwapFrom)) + { + _messages.Add($"Could not migrate file swap {swapFrom} -> {swapTo} in {container.Mod.Name}: {container.GetFullName()}."); + return; + } + + var newSwapTo = swapTo; + if (!toExists && !MaterialPack.AdaptPath(gameData, swapTo, MaterialPack.TextureUsage.Unknown, out newSwapTo)) + { + _messages.Add($"Could not migrate file swap {swapFrom} -> {swapTo} in {container.Mod.Name}: {container.GetFullName()}."); + return; + } + + if (!Utf8GamePath.FromString(swapFrom, out var path) || !Utf8GamePath.FromString(newSwapFrom, out var newPath)) + { + _messages.Add( + $"Could not migrate file swap {swapFrom} -> {swapTo} in {container.Mod.Name}: {container.GetFullName()}. Unknown Error."); + return; + } + + container.FileSwaps.Remove(path); + container.FileSwaps.Add(newPath, new FullPath(newSwapTo)); + _changedMods.Add((Mod)container.Mod); + } +} diff --git a/Penumbra/Services/PcpService.cs b/Penumbra/Services/PcpService.cs new file mode 100644 index 00000000..17646564 --- /dev/null +++ b/Penumbra/Services/PcpService.cs @@ -0,0 +1,308 @@ +using Dalamud.Game.ClientState.Objects.Types; +using FFXIVClientStructs.FFXIV.Client.Game.Object; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using OtterGui.Classes; +using OtterGui.Services; +using Penumbra.Collections; +using Penumbra.Collections.Manager; +using Penumbra.Communication; +using Penumbra.GameData.Actors; +using Penumbra.GameData.Interop; +using Penumbra.GameData.Structs; +using Penumbra.Interop.PathResolving; +using Penumbra.Interop.ResourceTree; +using Penumbra.Meta.Manipulations; +using Penumbra.Mods; +using Penumbra.Mods.Groups; +using Penumbra.Mods.Manager; +using Penumbra.Mods.SubMods; +using Penumbra.String.Classes; + +namespace Penumbra.Services; + +public class PcpService : IApiService, IDisposable +{ + public const string Extension = ".pcp"; + + private readonly Configuration _config; + private readonly SaveService _files; + private readonly ResourceTreeFactory _treeFactory; + private readonly ObjectManager _objectManager; + private readonly ActorManager _actors; + private readonly FrameworkManager _framework; + private readonly CollectionResolver _collectionResolver; + private readonly CollectionManager _collections; + private readonly ModCreator _modCreator; + private readonly ModExportManager _modExport; + private readonly CommunicatorService _communicator; + private readonly SHA1 _sha1 = SHA1.Create(); + private readonly ModFileSystem _fileSystem; + private readonly ModManager _mods; + + public PcpService(Configuration config, + SaveService files, + ResourceTreeFactory treeFactory, + ObjectManager objectManager, + ActorManager actors, + FrameworkManager framework, + CollectionManager collections, + CollectionResolver collectionResolver, + ModCreator modCreator, + ModExportManager modExport, + CommunicatorService communicator, + ModFileSystem fileSystem, + ModManager mods) + { + _config = config; + _files = files; + _treeFactory = treeFactory; + _objectManager = objectManager; + _actors = actors; + _framework = framework; + _collectionResolver = collectionResolver; + _collections = collections; + _modCreator = modCreator; + _modExport = modExport; + _communicator = communicator; + _fileSystem = fileSystem; + _mods = mods; + + _communicator.ModPathChanged.Subscribe(OnModPathChange, ModPathChanged.Priority.PcpService); + } + + public void CleanPcpMods() + { + var mods = _mods.Where(m => m.ModTags.Contains("PCP")).ToList(); + Penumbra.Log.Information($"[PCPService] Deleting {mods.Count} mods containing the tag PCP."); + foreach (var mod in mods) + _mods.DeleteMod(mod); + } + + public void CleanPcpCollections() + { + var collections = _collections.Storage.Where(c => c.Identity.Name.StartsWith("PCP/")).ToList(); + Penumbra.Log.Information($"[PCPService] Deleting {collections.Count} collections starting with PCP/."); + foreach (var collection in collections) + _collections.Storage.RemoveCollection(collection); + } + + private void OnModPathChange(ModPathChangeType type, Mod mod, DirectoryInfo? oldDirectory, DirectoryInfo? newDirectory) + { + if (type is not ModPathChangeType.Added || _config.PcpSettings.DisableHandling || newDirectory is null) + return; + + try + { + var file = Path.Combine(newDirectory.FullName, "character.json"); + if (!File.Exists(file)) + { + // First version had collection.json, changed. + var oldFile = Path.Combine(newDirectory.FullName, "collection.json"); + if (File.Exists(oldFile)) + { + Penumbra.Log.Information("[PCPService] Renaming old PCP file from collection.json to character.json."); + File.Move(oldFile, file, true); + } + else + return; + } + + Penumbra.Log.Information($"[PCPService] Found a PCP file for {mod.Name}, applying."); + var text = File.ReadAllText(file); + var jObj = JObject.Parse(text); + var collection = ModCollection.Empty; + // Create collection. + if (_config.PcpSettings.CreateCollection) + { + var identifier = _actors.FromJson(jObj["Actor"] as JObject); + if (identifier.IsValid && jObj["Collection"]?.ToObject() is { } collectionName) + { + var name = $"PCP/{collectionName}"; + if (_collections.Storage.AddCollection(name, null)) + { + collection = _collections.Storage[^1]; + _collections.Editor.SetModState(collection, mod, true); + + // Assign collection. + if (_config.PcpSettings.AssignCollection) + { + var identifierGroup = _collections.Active.Individuals.GetGroup(identifier); + _collections.Active.SetCollection(collection, CollectionType.Individual, identifierGroup); + } + } + } + } + + // Move to folder. + if (_fileSystem.TryGetValue(mod, out var leaf)) + { + try + { + var folder = _fileSystem.FindOrCreateAllFolders(_config.PcpSettings.FolderName); + _fileSystem.Move(leaf, folder); + } + catch + { + // ignored. + } + } + + // Invoke IPC. + if (_config.PcpSettings.AllowIpc) + _communicator.PcpParsing.Invoke(jObj, mod.Identifier, collection.Identity.Id); + } + catch (Exception ex) + { + Penumbra.Log.Error($"Error reading the character.json file from {mod.Identifier}:\n{ex}"); + } + } + + public void Dispose() + => _communicator.ModPathChanged.Unsubscribe(OnModPathChange); + + public async Task<(bool, string)> CreatePcp(ObjectIndex objectIndex, string note = "", CancellationToken cancel = default) + { + try + { + Penumbra.Log.Information($"[PCPService] Creating PCP file for game object {objectIndex.Index}."); + var (identifier, tree, meta) = await _framework.Framework.RunOnFrameworkThread(() => + { + var (actor, identifier) = CheckActor(objectIndex); + cancel.ThrowIfCancellationRequested(); + unsafe + { + var collection = _collectionResolver.IdentifyCollection((GameObject*)actor.Address, true); + if (!collection.Valid || !collection.ModCollection.HasCache) + throw new Exception($"Actor {identifier} has no mods applying, nothing to do."); + + cancel.ThrowIfCancellationRequested(); + if (_treeFactory.FromCharacter(actor, 0) is not { } tree) + throw new Exception($"Unable to fetch modded resources for {identifier}."); + + var meta = new MetaDictionary(collection.ModCollection.MetaCache, actor.Address); + return (identifier.CreatePermanent(), tree, meta); + } + }); + cancel.ThrowIfCancellationRequested(); + var time = DateTime.Now; + var modDirectory = CreateMod(identifier, note, time); + await CreateDefaultMod(modDirectory, meta, tree, cancel); + await CreateCollectionInfo(modDirectory, objectIndex, identifier, note, time, cancel); + var file = ZipUp(modDirectory); + return (true, file); + } + catch (Exception ex) + { + return (false, ex.Message); + } + } + + private static string ZipUp(DirectoryInfo directory) + { + var fileName = directory.FullName + Extension; + ZipFile.CreateFromDirectory(directory.FullName, fileName, CompressionLevel.Optimal, false); + directory.Delete(true); + return fileName; + } + + private async Task CreateCollectionInfo(DirectoryInfo directory, ObjectIndex index, ActorIdentifier actor, string note, DateTime time, + CancellationToken cancel = default) + { + var jObj = new JObject + { + ["Version"] = 1, + ["Actor"] = actor.ToJson(), + ["Mod"] = directory.Name, + ["Collection"] = note.Length > 0 ? $"{actor.ToName()}: {note}" : actor.ToName(), + ["Time"] = time, + ["Note"] = note, + }; + if (note.Length > 0) + cancel.ThrowIfCancellationRequested(); + if (_config.PcpSettings.AllowIpc) + await _framework.Framework.RunOnFrameworkThread(() => _communicator.PcpCreation.Invoke(jObj, index.Index, directory.FullName)); + var filePath = Path.Combine(directory.FullName, "character.json"); + await using var file = File.Open(filePath, File.Exists(filePath) ? FileMode.Truncate : FileMode.CreateNew); + await using var stream = new StreamWriter(file); + await using var json = new JsonTextWriter(stream); + json.Formatting = Formatting.Indented; + await jObj.WriteToAsync(json, cancel); + } + + private DirectoryInfo CreateMod(ActorIdentifier actor, string note, DateTime time) + { + var directory = _modExport.ExportDirectory; + directory.Create(); + var actorName = actor.ToName(); + var authorName = _actors.GetCurrentPlayer().ToName(); + var suffix = note.Length > 0 + ? note + : time.ToString("yyyy-MM-ddTHH\\:mm", CultureInfo.InvariantCulture); + var modName = $"{actorName} - {suffix}"; + var description = $"On-Screen Data for {actorName} as snapshotted on {time}."; + return _modCreator.CreateEmptyMod(directory, modName, description, authorName, "PCP") + ?? throw new Exception($"Unable to create mod {modName} in {directory.FullName}."); + } + + private async Task CreateDefaultMod(DirectoryInfo modDirectory, MetaDictionary meta, ResourceTree tree, + CancellationToken cancel = default) + { + var subDirectory = modDirectory.CreateSubdirectory("files"); + var subMod = new DefaultSubMod(null!) + { + Manipulations = meta, + }; + + foreach (var node in tree.FlatNodes) + { + cancel.ThrowIfCancellationRequested(); + var gamePath = node.GamePath; + var fullPath = node.FullPath; + if (fullPath.IsRooted) + { + var hash = await _sha1.ComputeHashAsync(File.OpenRead(fullPath.FullName), cancel).ConfigureAwait(false); + cancel.ThrowIfCancellationRequested(); + var name = Convert.ToHexString(hash) + fullPath.Extension; + var newFile = Path.Combine(subDirectory.FullName, name); + if (!File.Exists(newFile)) + File.Copy(fullPath.FullName, newFile); + subMod.Files.TryAdd(gamePath, new FullPath(newFile)); + } + else if (gamePath.Path != fullPath.InternalName) + { + subMod.FileSwaps.TryAdd(gamePath, fullPath); + } + } + + cancel.ThrowIfCancellationRequested(); + + var saveGroup = new ModSaveGroup(modDirectory, subMod, _config.ReplaceNonAsciiOnImport); + var filePath = _files.FileNames.OptionGroupFile(modDirectory.FullName, -1, string.Empty, _config.ReplaceNonAsciiOnImport); + cancel.ThrowIfCancellationRequested(); + await using var fileStream = File.Open(filePath, File.Exists(filePath) ? FileMode.Truncate : FileMode.CreateNew); + await using var writer = new StreamWriter(fileStream); + saveGroup.Save(writer); + } + + private (ICharacter Actor, ActorIdentifier Identifier) CheckActor(ObjectIndex objectIndex) + { + var actor = _objectManager[objectIndex]; + if (!actor.Valid) + throw new Exception($"No Actor at index {objectIndex} found."); + + if (!actor.Identifier(_actors, out var identifier)) + throw new Exception($"Could not create valid identifier for actor at index {objectIndex}."); + + if (!actor.IsCharacter) + throw new Exception($"Actor {identifier} at index {objectIndex} is not a valid character."); + + if (!actor.Model.Valid) + throw new Exception($"Actor {identifier} at index {objectIndex} has no model."); + + if (_objectManager.Objects.CreateObjectReference(actor.Address) is not ICharacter character) + throw new Exception($"Actor {identifier} at index {objectIndex} could not be converted to ICharacter"); + + return (character, identifier); + } +} diff --git a/Penumbra/Services/SaveService.cs b/Penumbra/Services/SaveService.cs new file mode 100644 index 00000000..eff3295d --- /dev/null +++ b/Penumbra/Services/SaveService.cs @@ -0,0 +1,44 @@ +using OtterGui.Classes; +using OtterGui.Log; +using OtterGui.Services; +using Penumbra.Mods; +using Penumbra.Mods.Groups; + +namespace Penumbra.Services; + +/// +/// Any file type that we want to save via SaveService. +/// +public interface ISavable : ISavable +{ } + +public sealed class SaveService(Logger log, FrameworkManager framework, FilenameService fileNames, BackupService backupService) + : SaveServiceBase(log, framework, fileNames, backupService.Awaiter), IService +{ + /// Immediately delete all existing option group files for a mod and save them anew. + public void SaveAllOptionGroups(Mod mod, bool backup, bool onlyAscii) + { + foreach (var file in FileNames.GetOptionGroupFiles(mod)) + { + try + { + if (file.Exists) + if (backup) + file.MoveTo(file.FullName + ".bak", true); + else + file.Delete(); + } + catch (Exception e) + { + Log.Error($"Could not {(backup ? "move" : "delete")} outdated group file {file}:\n{e}"); + } + } + + if (mod.Groups.Count > 0) + { + foreach (var group in mod.Groups.SkipLast(1)) + ImmediateSave(new ModSaveGroup(group, onlyAscii)); + ImmediateSaveSync(new ModSaveGroup(mod.Groups[^1], onlyAscii)); + } + } +} diff --git a/Penumbra/Services/ServiceWrapper.cs b/Penumbra/Services/ServiceWrapper.cs new file mode 100644 index 00000000..e321b35c --- /dev/null +++ b/Penumbra/Services/ServiceWrapper.cs @@ -0,0 +1,91 @@ +using OtterGui.Tasks; + +namespace Penumbra.Services; + +public abstract class SyncServiceWrapper : IDisposable +{ + public string Name { get; } + public T Service { get; } + private bool _isDisposed; + + public bool Valid + => !_isDisposed; + + protected SyncServiceWrapper(string name, Func factory) + { + Name = name; + Service = factory(); + Penumbra.Log.Verbose($"[{Name}] Created."); + } + + public void Dispose() + { + if (_isDisposed) + return; + + _isDisposed = true; + if (Service is IDisposable d) + d.Dispose(); + Penumbra.Log.Verbose($"[{Name}] Disposed."); + } +} + +public abstract class AsyncServiceWrapper : 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 factory) + { + Name = name; + _task = TrackedTask.Run(() => + { + var service = factory(); + if (_isDisposed) + { + if (service is IDisposable d) + d.Dispose(); + } + else + { + Service = service; + Penumbra.Log.Verbose($"[{Name}] Created."); + _task = null; + } + }); + _task.ContinueWith((t, x) => + { + if (!_isDisposed) + FinishedCreation?.Invoke(); + }, TaskScheduler.Default); + } + + public void Dispose() + { + if (_isDisposed) + return; + + _isDisposed = true; + _task = null; + if (Service is IDisposable d) + d.Dispose(); + Penumbra.Log.Verbose($"[{Name}] Disposed."); + } +} diff --git a/Penumbra/Services/StainService.cs b/Penumbra/Services/StainService.cs new file mode 100644 index 00000000..17294aa8 --- /dev/null +++ b/Penumbra/Services/StainService.cs @@ -0,0 +1,138 @@ +using Dalamud.Bindings.ImGui; +using Dalamud.Interface; +using Dalamud.Interface.Utility.Raii; +using Dalamud.Plugin.Services; +using OtterGui.Services; +using OtterGui.Widgets; +using Penumbra.GameData.DataContainers; +using Penumbra.GameData.Files; +using Penumbra.GameData.Files.StainMapStructs; +using Penumbra.Interop.Services; +using Penumbra.Interop.Structs; +using Penumbra.UI.AdvancedWindow.Materials; + +namespace Penumbra.Services; + +public class StainService : IService +{ + public sealed class StainTemplateCombo(FilterComboColors[] stainCombos, StmFile stmFile) + : FilterComboCache(stmFile.Entries.Keys.Prepend(0), MouseWheelType.None, Penumbra.Log) + where TDyePack : unmanaged, IDyePack + { + // FIXME There might be a better way to handle that. + public int CurrentDyeChannel = 0; + + protected override float GetFilterWidth() + { + var baseSize = ImGui.CalcTextSize("0000").X + ImGui.GetStyle().ScrollbarSize + ImGui.GetStyle().ItemInnerSpacing.X; + if (stainCombos[CurrentDyeChannel].CurrentSelection.Key == 0) + return baseSize; + + return baseSize + ImGui.GetTextLineHeight() * 3 + ImGui.GetStyle().ItemInnerSpacing.X * 3; + } + + protected override string ToString(StmKeyType obj) + => $"{obj,4}"; + + protected override void DrawFilter(int currentSelected, float width) + { + using var font = ImRaii.PushFont(UiBuilder.DefaultFont); + base.DrawFilter(currentSelected, width); + } + + public override bool Draw(string label, string preview, string tooltip, ref int currentSelection, float previewWidth, float itemHeight, + ImGuiComboFlags flags = ImGuiComboFlags.None) + { + using var font = ImRaii.PushFont(UiBuilder.MonoFont); + using var style = ImRaii.PushStyle(ImGuiStyleVar.ButtonTextAlign, new Vector2(1, 0.5f)) + .Push(ImGuiStyleVar.ItemSpacing, ImGui.GetStyle().ItemSpacing with { X = ImGui.GetStyle().ItemInnerSpacing.X }); + var spaceSize = ImGui.CalcTextSize(" ").X; + var spaces = (int)(previewWidth / spaceSize) - 1; + return base.Draw(label, preview.PadLeft(spaces), tooltip, ref currentSelection, previewWidth, itemHeight, flags); + } + + protected override bool DrawSelectable(int globalIdx, bool selected) + { + var ret = base.DrawSelectable(globalIdx, selected); + var selection = stainCombos[CurrentDyeChannel].CurrentSelection.Key; + if (selection == 0 || !stmFile.TryGetValue(Items[globalIdx], selection, out var colors)) + return ret; + + ImGui.SameLine(); + var frame = new Vector2(ImGui.GetTextLineHeight()); + ImGui.ColorButton("D", new Vector4(MtrlTab.PseudoSqrtRgb((Vector3)colors.DiffuseColor), 1), 0, frame); + ImGui.SameLine(); + ImGui.ColorButton("S", new Vector4(MtrlTab.PseudoSqrtRgb((Vector3)colors.SpecularColor), 1), 0, frame); + ImGui.SameLine(); + ImGui.ColorButton("E", new Vector4(MtrlTab.PseudoSqrtRgb((Vector3)colors.EmissiveColor), 1), 0, frame); + return ret; + } + } + + public const int ChannelCount = 2; + + public readonly DictStain StainData; + public readonly FilterComboColors StainCombo1; + public readonly FilterComboColors StainCombo2; // FIXME is there a better way to handle this? + public readonly StmFile LegacyStmFile; + public readonly StmFile GudStmFile; + public readonly StainTemplateCombo LegacyTemplateCombo; + public readonly StainTemplateCombo GudTemplateCombo; + + public unsafe StainService(IDataManager dataManager, CharacterUtility characterUtility, DictStain stainData) + { + StainData = stainData; + StainCombo1 = CreateStainCombo(); + StainCombo2 = CreateStainCombo(); + + if (characterUtility.Address == null) + { + LegacyStmFile = LoadStmFile(null, dataManager); + GudStmFile = LoadStmFile(null, dataManager); + } + else + { + LegacyStmFile = LoadStmFile(characterUtility.Address->LegacyStmResource, dataManager); + GudStmFile = LoadStmFile(characterUtility.Address->GudStmResource, dataManager); + } + + + FilterComboColors[] stainCombos = [StainCombo1, StainCombo2]; + + LegacyTemplateCombo = new StainTemplateCombo(stainCombos, LegacyStmFile); + GudTemplateCombo = new StainTemplateCombo(stainCombos, GudStmFile); + } + + /// Retrieves the instance for the given channel. Indexing is zero-based. + public FilterComboColors GetStainCombo(int channel) + => channel switch + { + 0 => StainCombo1, + 1 => StainCombo2, + _ => throw new ArgumentOutOfRangeException(nameof(channel), channel, + $"Unsupported dye channel {channel} (supported values are 0 and 1)"), + }; + + /// Loads a STM file. Opportunistically attempts to re-use the file already read by the game, with Lumina fallback. + private static unsafe StmFile LoadStmFile(ResourceHandle* stmResourceHandle, IDataManager dataManager) + where TDyePack : unmanaged, IDyePack + { + if (stmResourceHandle != null) + { + var stmData = stmResourceHandle->CsHandle.GetDataSpan(); + if (stmData.Length > 0) + { + Penumbra.Log.Debug($"[StainService] Loading StmFile<{typeof(TDyePack)}> from ResourceHandle 0x{(nint)stmResourceHandle:X}"); + return new StmFile(stmData); + } + } + + Penumbra.Log.Debug($"[StainService] Loading StmFile<{typeof(TDyePack)}> from Lumina"); + return new StmFile(dataManager); + } + + private FilterComboColors CreateStainCombo() + => new(140, MouseWheelType.None, + () => StainData.Value.Prepend(new KeyValuePair(0, ("None", 0, false))).ToList(), + Penumbra.Log); +} diff --git a/Penumbra/Services/StaticServiceManager.cs b/Penumbra/Services/StaticServiceManager.cs new file mode 100644 index 00000000..be482d1d --- /dev/null +++ b/Penumbra/Services/StaticServiceManager.cs @@ -0,0 +1,68 @@ +using Dalamud.Game; +using Dalamud.Game.ClientState.Objects; +using Dalamud.Interface.DragDrop; +using Dalamud.Plugin; +using Dalamud.Plugin.Services; +using Microsoft.Extensions.DependencyInjection; +using OtterGui; +using OtterGui.Log; +using OtterGui.Services; +using Penumbra.Api.Api; +using Penumbra.GameData.Actors; +using Penumbra.GameData.Structs; +using Penumbra.Interop.PathResolving; +using Penumbra.Meta; +using Penumbra.Mods.Manager; +using IPenumbraApi = Penumbra.Api.Api.IPenumbraApi; + +namespace Penumbra.Services; + +#pragma warning disable SeStringEvaluator + +public static class StaticServiceManager +{ + public static ServiceManager CreateProvider(Penumbra penumbra, IDalamudPluginInterface pi, Logger log) + { + var services = new ServiceManager(log) + .AddDalamudServices(pi) + .AddExistingService(log) + .AddExistingService(penumbra); + services.AddIServices(typeof(EquipItem).Assembly); + services.AddIServices(typeof(Penumbra).Assembly); + services.AddIServices(typeof(ImGuiUtil).Assembly); + services.AddSingleton(p => + { + var cutsceneService = p.GetRequiredService(); + return new CutsceneResolver(cutsceneService.GetParentIndex); + }) + .AddSingleton(p => p.GetRequiredService().ImcChecker) + .AddSingleton(s => (ModStorage)s.GetRequiredService()) + .AddSingleton(x => x.GetRequiredService()); + services.CreateProvider(); + return services; + } + + private static ServiceManager AddDalamudServices(this ServiceManager services, IDalamudPluginInterface pi) + => services.AddExistingService(pi) + .AddExistingService(pi.UiBuilder) + .AddDalamudService(pi) + .AddDalamudService(pi) + .AddDalamudService(pi) + .AddDalamudService(pi) + .AddDalamudService(pi) + .AddDalamudService(pi) + .AddDalamudService(pi) + .AddDalamudService(pi) + .AddDalamudService(pi) + .AddDalamudService(pi) + .AddDalamudService(pi) + .AddDalamudService(pi) + .AddDalamudService(pi) + .AddDalamudService(pi) + .AddDalamudService(pi) + .AddDalamudService(pi) + .AddDalamudService(pi) + .AddDalamudService(pi) + .AddDalamudService(pi) + .AddDalamudService(pi); +} diff --git a/Penumbra/Services/ValidityChecker.cs b/Penumbra/Services/ValidityChecker.cs new file mode 100644 index 00000000..5feeab02 --- /dev/null +++ b/Penumbra/Services/ValidityChecker.cs @@ -0,0 +1,103 @@ +using Dalamud.Interface.ImGuiNotification; +using Dalamud.Plugin; +using FFXIVClientStructs.FFXIV.Client.System.Framework; +using OtterGui.Classes; +using OtterGui.Services; + +namespace Penumbra.Services; + +public class ValidityChecker : IService +{ + public const string Repository = "https://raw.githubusercontent.com/xivdev/Penumbra/master/repo.json"; + public const string SeaOfStars = "https://raw.githubusercontent.com/Ottermandias/SeaOfStars/main/repo.json"; + public const string RepositoryLower = "https://raw.githubusercontent.com/xivdev/penumbra/master/repo.json"; + public const string SeaOfStarsLower = "https://raw.githubusercontent.com/ottermandias/seaofstars/main/repo.json"; + + public readonly bool DevPenumbraExists; + public readonly bool IsNotInstalledPenumbra; + public readonly bool IsValidSourceRepo; + + public readonly List ImcExceptions = []; + + public readonly string Version; + public readonly string CommitHash; + + public unsafe string GameVersion + { + get + { + var framework = Framework.Instance(); + return framework == null ? string.Empty : framework->GameVersionString; + } + } + + public ValidityChecker(IDalamudPluginInterface pi) + { + DevPenumbraExists = CheckDevPluginPenumbra(pi); + IsNotInstalledPenumbra = CheckIsNotInstalled(pi); + IsValidSourceRepo = CheckSourceRepo(pi); + + var assembly = GetType().Assembly; + Version = assembly.GetName().Version?.ToString() ?? string.Empty; + CommitHash = assembly.GetCustomAttribute()?.InformationalVersion ?? "Unknown"; + } + + public void LogExceptions() + { + if (ImcExceptions.Count > 0) + Penumbra.Messager.NotificationMessage($"{ImcExceptions.Count} IMC Exceptions thrown during Penumbra load. Please repair your game files.", + NotificationType.Warning); + } + + // Because remnants of penumbra in devPlugins cause issues, we check for them to warn users to remove them. + private static bool CheckDevPluginPenumbra(IDalamudPluginInterface pi) + { +#if !DEBUG + var path = Path.Combine(pi.DalamudAssetDirectory.Parent?.FullName ?? "INVALIDPATH", "devPlugins", "Penumbra"); + var dir = new DirectoryInfo(path); + + try + { + return dir.Exists && dir.EnumerateFiles("*.dll", SearchOption.AllDirectories).Any(); + } + catch (Exception e) + { + Penumbra.Log.Error($"Could not check for dev plugin Penumbra:\n{e}"); + return true; + } +#else + return false; +#endif + } + + // Check if the loaded version of Penumbra itself is in devPlugins. + private static bool CheckIsNotInstalled(IDalamudPluginInterface pi) + { +#if !DEBUG + var checkedDirectory = pi.AssemblyLocation.Directory?.Parent?.Parent?.Name; + var ret = checkedDirectory?.Equals("installedPlugins", StringComparison.OrdinalIgnoreCase) ?? false; + if (!ret) + Penumbra.Log.Error($"Penumbra is not correctly installed. Application loaded from \"{pi.AssemblyLocation.Directory!.FullName}\"."); + + return !ret; +#else + return false; +#endif + } + + // Check if the loaded version of Penumbra is installed from a valid source repo. + private static bool CheckSourceRepo(IDalamudPluginInterface pi) + { +#if !DEBUG + return pi.SourceRepository?.Trim().ToLowerInvariant() switch + { + null => false, + RepositoryLower => true, + SeaOfStarsLower => true, + _ => false, + }; +#else + return true; +#endif + } +} diff --git a/Penumbra/Structs/CharacterUtility.cs b/Penumbra/Structs/CharacterUtility.cs deleted file mode 100644 index 2459e2d6..00000000 --- a/Penumbra/Structs/CharacterUtility.cs +++ /dev/null @@ -1,13 +0,0 @@ -using System; -using System.Runtime.InteropServices; - -namespace Penumbra.Structs -{ - [StructLayout( LayoutKind.Sequential )] - public unsafe struct CharacterUtility - { - public void* VTable; - - public IntPtr Resources; // Size: 85, I hate C# - } -} \ No newline at end of file diff --git a/Penumbra/Structs/FileMode.cs b/Penumbra/Structs/FileMode.cs deleted file mode 100644 index 13235521..00000000 --- a/Penumbra/Structs/FileMode.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace Penumbra.Structs -{ - public enum FileMode : uint - { - LoadUnpackedResource = 0, - LoadFileResource = 1, // Shit in My Games uses this - - // some shit here, the game does some jump if its < 0xA for other files for some reason but there's no impl, probs debug? - LoadIndexResource = 0xA, // load index/index2 - LoadSqPackResource = 0xB, - } -} \ No newline at end of file diff --git a/Penumbra/Structs/GroupInformation.cs b/Penumbra/Structs/GroupInformation.cs deleted file mode 100644 index f9681f11..00000000 --- a/Penumbra/Structs/GroupInformation.cs +++ /dev/null @@ -1,103 +0,0 @@ -using System.Collections.Generic; -using System.ComponentModel; -using Newtonsoft.Json; -using Penumbra.GameData.Util; -using Penumbra.Util; - -namespace Penumbra.Structs -{ - public enum SelectType - { - Single, - Multi, - } - - public struct Option - { - public string OptionName; - public string OptionDesc; - - [JsonProperty( ItemConverterType = typeof( SingleOrArrayConverter< GamePath > ) )] - public Dictionary< RelPath, HashSet< GamePath > > OptionFiles; - - public bool AddFile( RelPath filePath, GamePath gamePath ) - { - if( OptionFiles.TryGetValue( filePath, out var set ) ) - { - return set.Add( gamePath ); - } - - OptionFiles[ filePath ] = new HashSet< GamePath >() { gamePath }; - return true; - } - } - - public struct OptionGroup - { - public string GroupName; - - [JsonConverter( typeof( Newtonsoft.Json.Converters.StringEnumConverter ) )] - public SelectType SelectionType; - - public List< Option > Options; - - private bool ApplySingleGroupFiles( RelPath relPath, int selection, HashSet< GamePath > paths ) - { - // Selection contains the path, merge all GamePaths for this config. - if( Options[ selection ].OptionFiles.TryGetValue( relPath, out var groupPaths ) ) - { - paths.UnionWith( groupPaths ); - return true; - } - - // If the group contains the file in another selection, return true to skip it for default files. - for( var i = 0; i < Options.Count; ++i ) - { - if( i == selection ) - { - continue; - } - - if( Options[ i ].OptionFiles.ContainsKey( relPath ) ) - { - return true; - } - } - - return false; - } - - private bool ApplyMultiGroupFiles( RelPath relPath, int selection, HashSet< GamePath > paths ) - { - var doNotAdd = false; - for( var i = 0; i < Options.Count; ++i ) - { - if( ( selection & ( 1 << i ) ) != 0 ) - { - if( Options[ i ].OptionFiles.TryGetValue( relPath, out var groupPaths ) ) - { - paths.UnionWith( groupPaths ); - doNotAdd = true; - } - } - else if( Options[ i ].OptionFiles.ContainsKey( relPath ) ) - { - doNotAdd = true; - } - } - - return doNotAdd; - } - - // Adds all game paths from the given option that correspond to the given RelPath to paths, if any exist. - internal bool ApplyGroupFiles( RelPath relPath, int selection, HashSet< GamePath > paths ) - { - return SelectionType switch - { - SelectType.Single => ApplySingleGroupFiles( relPath, selection, paths ), - SelectType.Multi => ApplyMultiGroupFiles( relPath, selection, paths ), - _ => throw new InvalidEnumArgumentException( "Invalid option group type." ), - }; - } - } -} \ No newline at end of file diff --git a/Penumbra/Structs/ResourceHandle.cs b/Penumbra/Structs/ResourceHandle.cs deleted file mode 100644 index 3318bb99..00000000 --- a/Penumbra/Structs/ResourceHandle.cs +++ /dev/null @@ -1,29 +0,0 @@ -using System.Runtime.InteropServices; - -namespace Penumbra.Structs -{ - [StructLayout( LayoutKind.Explicit )] - public unsafe struct ResourceHandle - { - public const int SsoSize = 15; - - public byte* FileName() - { - if( FileNameLength > SsoSize ) - { - return _fileName; - } - - fixed( byte** name = &_fileName ) - { - return ( byte* )name; - } - } - - [FieldOffset( 0x48 )] - private byte* _fileName; - - [FieldOffset( 0x58 )] - public int FileNameLength; - } -} \ No newline at end of file diff --git a/Penumbra/Structs/SeFileDescriptor.cs b/Penumbra/Structs/SeFileDescriptor.cs deleted file mode 100644 index dc22b81b..00000000 --- a/Penumbra/Structs/SeFileDescriptor.cs +++ /dev/null @@ -1,21 +0,0 @@ -using System.Runtime.InteropServices; - -namespace Penumbra.Structs -{ - [StructLayout( LayoutKind.Explicit )] - public unsafe struct SeFileDescriptor - { - [FieldOffset( 0x00 )] - public FileMode FileMode; - - [FieldOffset( 0x30 )] - public void* FileDescriptor; // - - [FieldOffset( 0x50 )] - public ResourceHandle* ResourceHandle; // - - - [FieldOffset( 0x70 )] - public byte UtfFileName; // - } -} \ No newline at end of file diff --git a/Penumbra/UI/AdvancedWindow/FileEditor.cs b/Penumbra/UI/AdvancedWindow/FileEditor.cs new file mode 100644 index 00000000..424bc56f --- /dev/null +++ b/Penumbra/UI/AdvancedWindow/FileEditor.cs @@ -0,0 +1,332 @@ +using Dalamud.Interface; +using Dalamud.Interface.ImGuiNotification; +using Dalamud.Plugin.Services; +using Dalamud.Bindings.ImGui; +using OtterGui; +using OtterGui.Classes; +using OtterGui.Compression; +using OtterGui.Raii; +using OtterGui.Text; +using OtterGui.Widgets; +using Penumbra.GameData.Data; +using Penumbra.GameData.Files; +using Penumbra.Mods.Editor; +using Penumbra.Services; +using Penumbra.String.Classes; +using Penumbra.UI.Classes; + +namespace Penumbra.UI.AdvancedWindow; + +public class FileEditor( + ModEditWindow owner, + CommunicatorService communicator, + IDataManager gameData, + Configuration config, + FileCompactor compactor, + FileDialogService fileDialog, + string tabName, + string fileType, + Func> getFiles, + Func drawEdit, + Func getInitialPath, + Func parseFile) + : IDisposable + where T : class, IWritable +{ + public void Draw() + { + using var tab = ImRaii.TabItem(tabName); + if (!tab) + { + _quickImport = null; + return; + } + + ImGui.NewLine(); + DrawFileSelectCombo(); + SaveButton(); + ImGui.SameLine(); + ResetButton(); + ImGui.SameLine(); + RedrawOnSaveBox(); + ImGui.SameLine(); + DefaultInput(); + ImGui.Dummy(new Vector2(ImGui.GetTextLineHeight() / 2)); + + DrawFilePanel(); + } + + private void RedrawOnSaveBox() + { + var redraw = config.Ephemeral.ForceRedrawOnFileChange; + if (ImGui.Checkbox("Redraw on Save", ref redraw)) + { + config.Ephemeral.ForceRedrawOnFileChange = redraw; + config.Ephemeral.Save(); + } + + ImGuiUtil.HoverTooltip("Force a redraw of your player character whenever you save a file here."); + } + + public void Dispose() + { + (_currentFile as IDisposable)?.Dispose(); + _currentFile = null; + (_defaultFile as IDisposable)?.Dispose(); + _defaultFile = null; + } + + private FileRegistry? _currentPath; + private T? _currentFile; + private Exception? _currentException; + private bool _changed; + + private string _defaultPath = typeof(T) == typeof(ModEditWindow.PbdTab) ? GamePaths.Pbd.Path : string.Empty; + private bool _inInput; + private Utf8GamePath _defaultPathUtf8; + private bool _isDefaultPathUtf8Valid; + private T? _defaultFile; + private Exception? _defaultException; + + private readonly Combo _combo = new(config, getFiles); + + private ModEditWindow.QuickImportAction? _quickImport; + + private void DefaultInput() + { + using var spacing = ImRaii.PushStyle(ImGuiStyleVar.ItemSpacing, ImGui.GetStyle().ItemSpacing with { X = UiHelpers.ScaleX3 }); + ImGui.SetNextItemWidth(ImGui.GetContentRegionAvail().X - 2 * (UiHelpers.ScaleX3 + ImGui.GetFrameHeight())); + ImGui.InputTextWithHint("##defaultInput", "Input game path to compare...", ref _defaultPath, Utf8GamePath.MaxGamePathLength); + _inInput = ImGui.IsItemActive(); + if (ImGui.IsItemDeactivatedAfterEdit() && _defaultPath.Length > 0) + { + _isDefaultPathUtf8Valid = Utf8GamePath.FromString(_defaultPath, out _defaultPathUtf8); + _quickImport = null; + fileDialog.Reset(); + try + { + var file = gameData.GetFile(_defaultPath); + if (file != null) + { + _defaultException = null; + (_defaultFile as IDisposable)?.Dispose(); + _defaultFile = null; // Avoid double disposal if an exception occurs during the parsing of the new file. + _defaultFile = parseFile(file.Data, _defaultPath, false); + } + else + { + _defaultFile = null; + _defaultException = new Exception("File does not exist."); + } + } + catch (Exception e) + { + _defaultFile = null; + _defaultException = e; + } + } + + ImGui.SameLine(); + if (ImGuiUtil.DrawDisabledButton(FontAwesomeIcon.Save.ToIconString(), new Vector2(ImGui.GetFrameHeight()), "Export this file.", + _defaultFile == null, true)) + fileDialog.OpenSavePicker($"Export {_defaultPath} to...", fileType, Path.GetFileNameWithoutExtension(_defaultPath), fileType, + (success, name) => + { + if (!success) + return; + + try + { + compactor.WriteAllBytes(name, _defaultFile?.Write() ?? throw new Exception("File invalid.")); + } + catch (Exception e) + { + Penumbra.Messager.NotificationMessage(e, $"Could not export {_defaultPath}.", NotificationType.Error); + } + }, getInitialPath(), false); + + _quickImport ??= + ModEditWindow.QuickImportAction.Prepare(owner, _isDefaultPathUtf8Valid ? _defaultPathUtf8 : Utf8GamePath.Empty, _defaultFile); + ImGui.SameLine(); + if (ImGuiUtil.DrawDisabledButton(FontAwesomeIcon.FileImport.ToIconString(), new Vector2(ImGui.GetFrameHeight()), + $"Add a copy of this file to {_quickImport.OptionName}.", !_quickImport.CanExecute, true)) + { + try + { + UpdateCurrentFile(_quickImport.Execute()); + } + catch (Exception e) + { + Penumbra.Log.Error($"Could not add a copy of {_quickImport.GamePath} to {_quickImport.OptionName}:\n{e}"); + } + + _quickImport = null; + } + } + + public void Reset() + { + _currentException = null; + _currentPath = null; + (_currentFile as IDisposable)?.Dispose(); + _currentFile = null; + _changed = false; + } + + private void DrawFileSelectCombo() + { + if (_combo.Draw("##fileSelect", _currentPath?.RelPath.ToString() ?? $"Select {fileType} File...", string.Empty, + ImGui.GetContentRegionAvail().X, ImGui.GetTextLineHeight()) + && _combo.CurrentSelection != null) + UpdateCurrentFile(_combo.CurrentSelection); + } + + private void UpdateCurrentFile(FileRegistry path) + { + if (ReferenceEquals(_currentPath, path)) + return; + + _changed = false; + _currentPath = path; + _currentException = null; + try + { + var bytes = File.ReadAllBytes(_currentPath.File.FullName); + (_currentFile as IDisposable)?.Dispose(); + _currentFile = null; // Avoid double disposal if an exception occurs during the parsing of the new file. + _currentFile = parseFile(bytes, _currentPath.File.FullName, true); + } + catch (Exception e) + { + (_currentFile as IDisposable)?.Dispose(); + _currentFile = null; + _currentException = e; + } + } + + private void SaveButton() + { + var canSave = _changed && _currentFile is { Valid: true }; + if (ImGuiUtil.DrawDisabledButton("Save to File", Vector2.Zero, + $"Save the selected {fileType} file with all changes applied. This is not revertible.", !canSave)) + SaveFile(); + } + + public void SaveFile() + { + compactor.WriteAllBytes(_currentPath!.File.FullName, _currentFile!.Write()); + if (owner.Mod != null) + communicator.ModFileChanged.Invoke(owner.Mod, _currentPath); + _changed = false; + } + + private void ResetButton() + { + if (ImGuiUtil.DrawDisabledButton("Reset Changes", Vector2.Zero, + $"Reset all changes made to the {fileType} file.", !_changed)) + { + var tmp = _currentPath; + _currentPath = null; + UpdateCurrentFile(tmp!); + } + } + + private void DrawFilePanel() + { + using var child = ImRaii.Child("##filePanel", -Vector2.One, true); + if (!child) + return; + + if (_currentPath != null) + { + if (_currentFile == null) + { + ImGui.TextUnformatted($"Could not parse selected {fileType} file."); + if (_currentException != null) + { + using var tab = ImRaii.PushIndent(); + ImGuiUtil.TextWrapped(_currentException.ToString()); + } + } + else + { + using var id = ImRaii.PushId(0); + _changed |= drawEdit(_currentFile, false); + } + } + + if (!_inInput && _defaultPath.Length > 0) + { + if (_currentPath != null) + { + ImGui.NewLine(); + ImGui.NewLine(); + ImGui.TextUnformatted($"Preview of {_defaultPath}:"); + ImGui.Separator(); + } + + if (_defaultFile == null) + { + ImGui.TextUnformatted($"Could not parse provided {fileType} game file:\n"); + if (_defaultException != null) + { + using var tab = ImRaii.PushIndent(); + ImGuiUtil.TextWrapped(_defaultException.ToString()); + } + } + else + { + using var id = ImRaii.PushId(1); + drawEdit(_defaultFile, true); + } + } + } + + private class Combo : FilterComboCache + { + private readonly Configuration _config; + + public Combo(Configuration config, Func> generator) + : base(generator, MouseWheelType.None, Penumbra.Log) + => _config = config; + + protected override bool DrawSelectable(int globalIdx, bool selected) + { + var file = Items[globalIdx]; + bool ret; + using (var c = ImRaii.PushColor(ImGuiCol.Text, ColorId.HandledConflictMod.Value(), file.IsOnPlayer)) + { + ret = ImGui.Selectable(file.RelPath.ToString(), selected); + } + + if (ImGui.IsItemHovered()) + { + using var tt = ImRaii.Tooltip(); + ImGui.TextUnformatted("All Game Paths"); + ImGui.Separator(); + using var t = ImRaii.Table("##Tooltip", 2, ImGuiTableFlags.SizingFixedFit); + foreach (var (option, gamePath) in file.SubModUsage) + { + ImGui.TableNextColumn(); + ImUtf8.Text(gamePath.Path.Span); + ImGui.TableNextColumn(); + using var color = ImRaii.PushColor(ImGuiCol.Text, ColorId.ItemId.Value()); + ImGui.TextUnformatted(option.GetFullName()); + } + } + + if (file.SubModUsage.Count > 0) + { + ImGui.SameLine(); + using var color = ImRaii.PushColor(ImGuiCol.Text, ColorId.ItemId.Value()); + ImGuiUtil.RightAlign(file.SubModUsage[0].Item2.Path.ToString()); + } + + return ret; + } + + protected override bool IsVisible(int globalIndex, LowerString filter) + => filter.IsContained(Items[globalIndex].File.FullName) + || Items[globalIndex].SubModUsage.Any(f => filter.IsContained(f.Item2.ToString())); + } +} diff --git a/Penumbra/UI/AdvancedWindow/ItemSwapTab.cs b/Penumbra/UI/AdvancedWindow/ItemSwapTab.cs new file mode 100644 index 00000000..e9d76990 --- /dev/null +++ b/Penumbra/UI/AdvancedWindow/ItemSwapTab.cs @@ -0,0 +1,849 @@ +using Dalamud.Interface.ImGuiNotification; +using Dalamud.Bindings.ImGui; +using OtterGui; +using OtterGui.Classes; +using OtterGui.Raii; +using OtterGui.Services; +using OtterGui.Text; +using OtterGui.Widgets; +using Penumbra.Api.Enums; +using Penumbra.Collections; +using Penumbra.Collections.Manager; +using Penumbra.Communication; +using Penumbra.GameData.Data; +using Penumbra.GameData.Enums; +using Penumbra.GameData.Structs; +using Penumbra.Import.Structs; +using Penumbra.Meta; +using Penumbra.Mods; +using Penumbra.Mods.Editor; +using Penumbra.Mods.Groups; +using Penumbra.Mods.ItemSwap; +using Penumbra.Mods.Manager; +using Penumbra.Mods.Manager.OptionEditor; +using Penumbra.Mods.Settings; +using Penumbra.Mods.SubMods; +using Penumbra.Services; +using Penumbra.UI.Classes; +using Penumbra.UI.ModsTab; + +namespace Penumbra.UI.AdvancedWindow; + +public class ItemSwapTab : IDisposable, ITab, IUiService +{ + private readonly Configuration _config; + private readonly CommunicatorService _communicator; + private readonly CollectionManager _collectionManager; + private readonly ModManager _modManager; + private readonly MetaFileManager _metaFileManager; + + public ItemSwapTab(CommunicatorService communicator, ItemData itemService, CollectionManager collectionManager, + ModManager modManager, ModFileSystemSelector selector, ObjectIdentification identifier, MetaFileManager metaFileManager, + Configuration config) + { + _communicator = communicator; + _collectionManager = collectionManager; + _modManager = modManager; + _metaFileManager = metaFileManager; + _config = config; + _swapData = new ItemSwapContainer(metaFileManager, identifier); + + var a = collectionManager.Active; + _selectors = new Dictionary + { + // @formatter:off + [SwapType.Hat] = (new ItemSelector(a, itemService, selector, FullEquipType.Head), new ItemSelector(a, itemService, null, FullEquipType.Head), "Take this Hat", "and put it on this one" ), + [SwapType.Top] = (new ItemSelector(a, itemService, selector, FullEquipType.Body), new ItemSelector(a, itemService, null, FullEquipType.Body), "Take this Top", "and put it on this one" ), + [SwapType.Gloves] = (new ItemSelector(a, itemService, selector, FullEquipType.Hands), new ItemSelector(a, itemService, null, FullEquipType.Hands), "Take these Gloves", "and put them on these" ), + [SwapType.Pants] = (new ItemSelector(a, itemService, selector, FullEquipType.Legs), new ItemSelector(a, itemService, null, FullEquipType.Legs), "Take these Pants", "and put them on these" ), + [SwapType.Shoes] = (new ItemSelector(a, itemService, selector, FullEquipType.Feet), new ItemSelector(a, itemService, null, FullEquipType.Feet), "Take these Shoes", "and put them on these" ), + [SwapType.Earrings] = (new ItemSelector(a, itemService, selector, FullEquipType.Ears), new ItemSelector(a, itemService, null, FullEquipType.Ears), "Take these Earrings", "and put them on these" ), + [SwapType.Necklace] = (new ItemSelector(a, itemService, selector, FullEquipType.Neck), new ItemSelector(a, itemService, null, FullEquipType.Neck), "Take this Necklace", "and put it on this one" ), + [SwapType.Bracelet] = (new ItemSelector(a, itemService, selector, FullEquipType.Wrists), new ItemSelector(a, itemService, null, FullEquipType.Wrists), "Take these Bracelets", "and put them on these" ), + [SwapType.Ring] = (new ItemSelector(a, itemService, selector, FullEquipType.Finger), new ItemSelector(a, itemService, null, FullEquipType.Finger), "Take this Ring", "and put it on this one" ), + [SwapType.Glasses] = (new ItemSelector(a, itemService, selector, FullEquipType.Glasses), new ItemSelector(a, itemService, null, FullEquipType.Glasses), "Take these Glasses", "and put them on these" ), + // @formatter:on + }; + + _communicator.CollectionChange.Subscribe(OnCollectionChange, CollectionChange.Priority.ItemSwapTab); + _communicator.ModSettingChanged.Subscribe(OnSettingChange, ModSettingChanged.Priority.ItemSwapTab); + _communicator.CollectionInheritanceChanged.Subscribe(OnInheritanceChange, CollectionInheritanceChanged.Priority.ItemSwapTab); + _communicator.ModOptionChanged.Subscribe(OnModOptionChange, ModOptionChanged.Priority.ItemSwapTab); + } + + /// Update the currently selected mod or its settings. + public void UpdateMod(Mod mod, ModSettings? settings) + { + if (mod == _mod && settings == _modSettings) + return; + + var oldDefaultName = $"{_mod?.Name.Text ?? "Unknown"} (Swapped)"; + if (_newModName.Length == 0 || oldDefaultName == _newModName) + _newModName = $"{mod.Name.Text} (Swapped)"; + + _mod = mod; + _modSettings = settings; + _swapData.LoadMod(_mod, _modSettings); + UpdateOption(); + _dirty = true; + } + + public ReadOnlySpan Label + => "Item Swap"u8; + + public void DrawContent() + { + ImGui.NewLine(); + DrawHeaderLine(300 * UiHelpers.Scale); + ImGui.NewLine(); + + DrawSwapBar(); + + using var table = ImRaii.ListBox("##swaps", -Vector2.One); + if (_loadException != null) + ImGuiUtil.TextWrapped($"Could not load Customization Swap:\n{_loadException}"); + else if (_swapData.Loaded) + foreach (var swap in _swapData.Swaps) + DrawSwap(swap); + else + ImGui.TextUnformatted(NonExistentText()); + } + + public void Dispose() + { + _communicator.CollectionChange.Unsubscribe(OnCollectionChange); + _communicator.ModSettingChanged.Unsubscribe(OnSettingChange); + _communicator.CollectionInheritanceChanged.Unsubscribe(OnInheritanceChange); + _communicator.ModOptionChanged.Unsubscribe(OnModOptionChange); + } + + private enum SwapType + { + Hat, + Top, + Gloves, + Pants, + Shoes, + Earrings, + Necklace, + Bracelet, + Ring, + BetweenSlots, + Hair, + Face, + Ears, + Tail, + Weapon, + Glasses, + } + + private class ItemSelector(ActiveCollections collections, ItemData data, ModFileSystemSelector? selector, FullEquipType type) + : FilterComboCache<(EquipItem Item, bool InMod, SingleArray InCollection)>(() => + { + var list = data.ByType[type]; + var enumerable = selector?.Selected is { } mod && mod.ChangedItems.Values.Any(o => o is IdentifiedItem i && i.Item.Type == type) + ? list.Select(i => (i, mod.ChangedItems.ContainsKey(i.Name), collections.Current.ChangedItems.TryGetValue(i.Name, out var m) ? m.Item1 : new SingleArray())) + .OrderByDescending(p => p.Item2).ThenByDescending(p => p.Item3.Count) + : selector is null + ? list.Select(i => (i, false, collections.Current.ChangedItems.TryGetValue(i.Name, out var m) ? m.Item1 : new SingleArray())).OrderBy(p => p.Item3.Count) + : list.Select(i => (i, false, collections.Current.ChangedItems.TryGetValue(i.Name, out var m) ? m.Item1 : new SingleArray())).OrderByDescending(p => p.Item3.Count); + return enumerable.ToList(); + }, MouseWheelType.None, Penumbra.Log) + { + protected override bool DrawSelectable(int globalIdx, bool selected) + { + var (_, inMod, inCollection) = Items[globalIdx]; + using var color = inMod + ? ImRaii.PushColor(ImGuiCol.Text, ColorId.ResTreeLocalPlayer.Value()) + : inCollection.Count > 0 + ? ImRaii.PushColor(ImGuiCol.Text, ColorId.ResTreeNonNetworked.Value()) + : null; + var ret = base.DrawSelectable(globalIdx, selected); + if (inCollection.Count > 0) + ImUtf8.HoverTooltip(string.Join('\n', inCollection.Select(m => m.Name.Text))); + return ret; + } + + protected override string ToString((EquipItem Item, bool InMod, SingleArray InCollection) obj) + => obj.Item.Name; + } + + private readonly Dictionary _selectors; + private readonly ItemSwapContainer _swapData; + + private Mod? _mod; + private ModSettings? _modSettings; + private bool _dirty; + + private SwapType _lastTab = SwapType.Hair; + private Gender _currentGender = Gender.Male; + private ModelRace _currentRace = ModelRace.Midlander; + private int _targetId; + private int _sourceId; + private Exception? _loadException; + private BetweenSlotTypes _slotFrom = BetweenSlotTypes.Hat; + private BetweenSlotTypes _slotTo = BetweenSlotTypes.Earrings; + + private string _newModName = string.Empty; + private string _newGroupName = "Swaps"; + private string _newOptionName = string.Empty; + private IModGroup? _selectedGroup; + private bool _subModValid; + private bool _useFileSwaps = true; + private bool _useCurrentCollection; + private bool _useLeftRing = true; + private bool _useRightRing = true; + + private HashSet? _affectedItems; + + private void UpdateState() + { + if (!_dirty) + return; + + _swapData.Clear(); + _loadException = null; + _affectedItems = null; + try + { + switch (_lastTab) + { + case SwapType.Hat: + case SwapType.Top: + case SwapType.Gloves: + case SwapType.Pants: + case SwapType.Shoes: + case SwapType.Earrings: + case SwapType.Necklace: + case SwapType.Bracelet: + case SwapType.Ring: + case SwapType.Glasses: + var values = _selectors[_lastTab]; + if (values.Source.CurrentSelection.Item.Type != FullEquipType.Unknown + && values.Target.CurrentSelection.Item.Type != FullEquipType.Unknown) + _affectedItems = _swapData.LoadEquipment(values.Target.CurrentSelection.Item, values.Source.CurrentSelection.Item, + _useCurrentCollection ? _collectionManager.Active.Current : null, _useRightRing, _useLeftRing); + break; + case SwapType.BetweenSlots: + var (_, _, selectorFrom) = GetAccessorySelector(_slotFrom, true); + var (_, _, selectorTo) = GetAccessorySelector(_slotTo, false); + if (selectorFrom.CurrentSelection.Item.Valid && selectorTo.CurrentSelection.Item.Valid) + _affectedItems = _swapData.LoadTypeSwap(ToEquipSlot(_slotTo), selectorTo.CurrentSelection.Item, ToEquipSlot(_slotFrom), + selectorFrom.CurrentSelection.Item, + _useCurrentCollection ? _collectionManager.Active.Current : null); + break; + case SwapType.Hair when _targetId > 0 && _sourceId > 0: + _swapData.LoadCustomization(_metaFileManager, BodySlot.Hair, Names.CombinedRace(_currentGender, _currentRace), + (PrimaryId)_sourceId, + (PrimaryId)_targetId, + _useCurrentCollection ? _collectionManager.Active.Current : null); + break; + case SwapType.Face when _targetId > 0 && _sourceId > 0: + _swapData.LoadCustomization(_metaFileManager, BodySlot.Face, Names.CombinedRace(_currentGender, _currentRace), + (PrimaryId)_sourceId, + (PrimaryId)_targetId, + _useCurrentCollection ? _collectionManager.Active.Current : null); + break; + case SwapType.Ears when _targetId > 0 && _sourceId > 0: + _swapData.LoadCustomization(_metaFileManager, BodySlot.Ear, Names.CombinedRace(_currentGender, ModelRace.Viera), + (PrimaryId)_sourceId, + (PrimaryId)_targetId, + _useCurrentCollection ? _collectionManager.Active.Current : null); + break; + case SwapType.Tail when _targetId > 0 && _sourceId > 0: + _swapData.LoadCustomization(_metaFileManager, BodySlot.Tail, Names.CombinedRace(_currentGender, _currentRace), + (PrimaryId)_sourceId, + (PrimaryId)_targetId, + _useCurrentCollection ? _collectionManager.Active.Current : null); + break; + case SwapType.Weapon: break; + } + } + catch (Exception e) + { + Penumbra.Log.Error($"Could not get Customization Data container for {_lastTab}:\n{e}"); + _loadException = e; + _affectedItems = null; + _swapData.Clear(); + } + + _dirty = false; + } + + private static string SwapToString(Swap swap) + { + return swap switch + { + IMetaSwap meta => $"{meta.SwapFromIdentifier}: {meta.SwapFromDefaultEntry} -> {meta.SwapToModdedEntry}", + FileSwap file => + $"{file.Type}: {file.SwapFromRequestPath} -> {file.SwapToModded.FullName}{(file.DataWasChanged ? " (EDITED)" : string.Empty)}", + _ => string.Empty, + }; + } + + private string CreateDescription() + { + switch (_lastTab) + { + case SwapType.Ears: + case SwapType.Face: + case SwapType.Hair: + case SwapType.Tail: + return + $"Created by swapping {_lastTab} {_sourceId} onto {_lastTab} {_targetId} for {_currentRace.ToName()} {_currentGender.ToName()}s in {_mod!.Name}{OriginalAuthor()}"; + case SwapType.BetweenSlots: + return + $"Created by swapping {GetAccessorySelector(_slotFrom, true).Item3.CurrentSelection.Item.Name} onto {GetAccessorySelector(_slotTo, false).Item3.CurrentSelection.Item.Name} in {_mod!.Name}{OriginalAuthor()}"; + default: + return + $"Created by swapping {_selectors[_lastTab].Source.CurrentSelection.Item.Name} onto {_selectors[_lastTab].Target.CurrentSelection.Item.Name} in {_mod!.Name}{OriginalAuthor()}"; + } + } + + private string OriginalAuthor() + { + if (_mod!.Author.IsEmpty || _mod!.Author.Text is "TexTools User" or DefaultTexToolsData.Author) + return "."; + + return $" by {_mod!.Author}."; + } + + private string CreateAuthor() + { + if (_mod!.Author.IsEmpty) + return _config.DefaultModAuthor; + if (_mod!.Author.Text == _config.DefaultModAuthor) + return _config.DefaultModAuthor; + if (_mod!.Author.Text is "TexTools User" or DefaultTexToolsData.Author) + return _config.DefaultModAuthor; + if (_config.DefaultModAuthor is DefaultTexToolsData.Author) + return _mod!.Author; + + return $"{_mod!.Author} (Swap by {_config.DefaultModAuthor})"; + } + + private void UpdateOption() + { + _selectedGroup = _mod?.Groups.FirstOrDefault(g => g.Name == _newGroupName); + _subModValid = _mod != null + && _newGroupName.Length > 0 + && _newOptionName.Length > 0 + && (_selectedGroup?.Options.All(o => o.Name != _newOptionName) ?? true); + } + + private void CreateMod() + { + var newDir = _modManager.Creator.CreateEmptyMod(_modManager.BasePath, _newModName, CreateDescription(), CreateAuthor()); + if (newDir == null) + return; + + _modManager.AddMod(newDir, false); + var mod = _modManager[^1]; + if (!_swapData.WriteMod(_modManager, mod, mod.Default, + _useFileSwaps ? ItemSwapContainer.WriteType.UseSwaps : ItemSwapContainer.WriteType.NoSwaps)) + _modManager.DeleteMod(mod); + } + + private void CreateOption() + { + if (_mod == null || !_subModValid) + return; + + var groupCreated = false; + var dirCreated = false; + IModOption? createdOption = null; + DirectoryInfo? optionFolderName = null; + try + { + optionFolderName = + ModCreator.NewSubFolderName(new DirectoryInfo(Path.Combine(_mod.ModPath.FullName, _selectedGroup?.Name ?? _newGroupName)), + _newOptionName, _config.ReplaceNonAsciiOnImport); + if (optionFolderName?.Exists == true) + throw new Exception($"The folder {optionFolderName.FullName} for the option already exists."); + + if (optionFolderName != null) + { + if (_selectedGroup == null) + { + if (_modManager.OptionEditor.AddModGroup(_mod, GroupType.Multi, _newGroupName) is not { } group) + throw new Exception($"Failure creating option group."); + + _selectedGroup = group; + groupCreated = true; + } + + if (_modManager.OptionEditor.AddOption(_selectedGroup, _newOptionName) is not { } option) + throw new Exception($"Failure creating mod option."); + + createdOption = option; + optionFolderName = Directory.CreateDirectory(optionFolderName.FullName); + dirCreated = true; + // #TODO ModOption <> DataContainer + if (!_swapData.WriteMod(_modManager, _mod, (IModDataContainer)option, + _useFileSwaps ? ItemSwapContainer.WriteType.UseSwaps : ItemSwapContainer.WriteType.NoSwaps, optionFolderName)) + throw new Exception("Failure writing files for mod swap."); + } + } + catch (Exception e) + { + Penumbra.Messager.NotificationMessage(e, "Could not create new Swap Option.", NotificationType.Error, false); + try + { + if (createdOption != null) + _modManager.OptionEditor.DeleteOption(createdOption); + + if (groupCreated) + { + _modManager.OptionEditor.DeleteModGroup(_selectedGroup!); + _selectedGroup = null; + } + + if (dirCreated && optionFolderName != null) + Directory.Delete(optionFolderName.FullName, true); + } + catch + { + // ignored + } + } + + UpdateOption(); + } + + private void DrawHeaderLine(float width) + { + var newModAvailable = _loadException == null && _swapData.Loaded; + + ImGui.SetNextItemWidth(width); + if (ImGui.InputTextWithHint("##newModName", "New Mod Name...", ref _newModName, 64)) + { } + + ImGui.SameLine(); + var tt = !newModAvailable + ? "No swap is currently loaded." + : _newModName.Length == 0 + ? "Please enter a name for your mod." + : "Create a new mod of the given name containing only the swap."; + if (ImGuiUtil.DrawDisabledButton("Create New Mod", new Vector2(width / 2, 0), tt, !newModAvailable || _newModName.Length == 0)) + CreateMod(); + + ImGui.SameLine(); + ImGui.SetCursorPosX(ImGui.GetCursorPosX() + 20 * UiHelpers.Scale); + ImGui.Checkbox("Use File Swaps", ref _useFileSwaps); + ImGuiUtil.HoverTooltip("Instead of writing every single non-default file to the newly created mod or option,\n" + + "even those available from game files, use File Swaps to default game files where possible."); + + ImGui.SetNextItemWidth((width - ImGui.GetStyle().ItemSpacing.X) / 2); + if (ImGui.InputTextWithHint("##groupName", "Group Name...", ref _newGroupName, 32)) + UpdateOption(); + + ImGui.SameLine(); + ImGui.SetNextItemWidth((width - ImGui.GetStyle().ItemSpacing.X) / 2); + if (ImGui.InputTextWithHint("##optionName", "New Option Name...", ref _newOptionName, 32)) + UpdateOption(); + + ImGui.SameLine(); + tt = !_subModValid + ? "An option with that name already exists in that group, or no name is specified." + : !newModAvailable + ? "Create a new option inside this mod containing only the swap." + : "Create a new option (and possibly Multi-Group) inside the currently selected mod containing the swap."; + if (ImGuiUtil.DrawDisabledButton("Create New Option", new Vector2(width / 2, 0), tt, !newModAvailable || !_subModValid)) + CreateOption(); + + ImGui.SameLine(); + ImGui.SetCursorPosX(ImGui.GetCursorPosX() + 20 * UiHelpers.Scale); + _dirty |= ImGui.Checkbox("Use Entire Collection", ref _useCurrentCollection); + ImGuiUtil.HoverTooltip( + "Use all applied mods from the Selected Collection with their current settings and respecting the enabled state of mods and inheritance,\n" + + "instead of using only the selected mod with its current settings in the Selected collection or the default settings, ignoring the enabled state and inheritance."); + } + + private void DrawSwapBar() + { + using var bar = ImRaii.TabBar("##swapBar", ImGuiTabBarFlags.None); + + DrawEquipmentSwap(SwapType.Hat); + DrawEquipmentSwap(SwapType.Top); + DrawEquipmentSwap(SwapType.Gloves); + DrawEquipmentSwap(SwapType.Pants); + DrawEquipmentSwap(SwapType.Shoes); + DrawEquipmentSwap(SwapType.Earrings); + DrawEquipmentSwap(SwapType.Necklace); + DrawEquipmentSwap(SwapType.Bracelet); + DrawEquipmentSwap(SwapType.Ring); + DrawEquipmentSwap(SwapType.Glasses); + DrawAccessorySwap(); + DrawHairSwap(); + //DrawFaceSwap(); + DrawEarSwap(); + DrawTailSwap(); + //DrawWeaponSwap(); + } + + private ImRaii.IEndObject DrawTab(SwapType newTab) + { + var tab = ImRaii.TabItem(newTab is SwapType.BetweenSlots ? "Between Slots" : newTab.ToString()); + if (tab) + { + _dirty |= _lastTab != newTab; + _lastTab = newTab; + } + + UpdateState(); + + return tab; + } + + private void DrawAccessorySwap() + { + using var tab = DrawTab(SwapType.BetweenSlots); + if (!tab) + return; + + using var table = ImRaii.Table("##settings", 3, ImGuiTableFlags.SizingFixedFit); + ImGui.TableSetupColumn("##text", ImGuiTableColumnFlags.WidthFixed, ImGui.CalcTextSize("and put them on these").X); + + var (article1, article2, selector) = GetAccessorySelector(_slotFrom, true); + ImGui.TableNextColumn(); + ImGui.AlignTextToFramePadding(); + ImGui.TextUnformatted($"Take {article1}"); + + ImGui.TableNextColumn(); + ImGui.SetNextItemWidth(100 * UiHelpers.Scale); + using (var combo = ImRaii.Combo("##fromType", ToName(_slotFrom))) + { + if (combo) + foreach (var slot in Enum.GetValues()) + { + if (!ImGui.Selectable(ToName(slot), slot == _slotFrom) || slot == _slotFrom) + continue; + + _dirty = true; + _slotFrom = slot; + if (slot == _slotTo) + _slotTo = AvailableToTypes.First(s => slot != s); + } + } + + ImGui.TableNextColumn(); + _dirty |= selector.Draw("##itemSource", selector.CurrentSelection.Item.Name ?? string.Empty, string.Empty, + InputWidth * 2 * UiHelpers.Scale, + ImGui.GetTextLineHeightWithSpacing()); + + (article1, _, selector) = GetAccessorySelector(_slotTo, false); + ImGui.TableNextColumn(); + ImGui.AlignTextToFramePadding(); + ImGui.TextUnformatted($"and put {article2} on {article1}"); + + ImGui.TableNextColumn(); + ImGui.SetNextItemWidth(100 * UiHelpers.Scale); + using (var combo = ImRaii.Combo("##toType", ToName(_slotTo))) + { + if (combo) + foreach (var slot in AvailableToTypes.Where(t => t != _slotFrom)) + { + if (!ImGui.Selectable(ToName(slot), slot == _slotTo) || slot == _slotTo) + continue; + + _dirty = true; + _slotTo = slot; + } + } + + ImGui.TableNextColumn(); + + _dirty |= selector.Draw("##itemTarget", selector.CurrentSelection.Item.Name, string.Empty, InputWidth * 2 * UiHelpers.Scale, + ImGui.GetTextLineHeightWithSpacing()); + if (_affectedItems is not { Count: > 1 }) + return; + + ImGui.SameLine(); + ImGuiUtil.DrawTextButton($"which will also affect {_affectedItems.Count - 1} other Items.", Vector2.Zero, + Colors.PressEnterWarningBg); + if (ImGui.IsItemHovered()) + ImGui.SetTooltip(string.Join('\n', _affectedItems.Where(i => !ReferenceEquals(i.Name, selector.CurrentSelection.Item.Name)) + .Select(i => i.Name))); + } + + private (string, string, ItemSelector) GetAccessorySelector(BetweenSlotTypes slot, bool source) + { + var (type, article1, article2) = slot switch + { + BetweenSlotTypes.Hat => (SwapType.Hat, "this", "it"), + BetweenSlotTypes.Earrings => (SwapType.Earrings, "these", "them"), + BetweenSlotTypes.Necklace => (SwapType.Necklace, "this", "it"), + BetweenSlotTypes.Bracelets => (SwapType.Bracelet, "these", "them"), + BetweenSlotTypes.RightRing => (SwapType.Ring, "this", "it"), + BetweenSlotTypes.LeftRing => (SwapType.Ring, "this", "it"), + BetweenSlotTypes.Glasses => (SwapType.Glasses, "these", "them"), + _ => (SwapType.Ring, "this", "it"), + }; + var (itemSelector, target, _, _) = _selectors[type]; + return (article1, article2, source ? itemSelector : target); + } + + private void DrawEquipmentSwap(SwapType type) + { + using var tab = DrawTab(type); + if (!tab) + return; + + var (sourceSelector, targetSelector, text1, text2) = _selectors[type]; + using var table = ImRaii.Table("##settings", 2, ImGuiTableFlags.SizingFixedFit); + ImGui.TableNextColumn(); + ImGui.AlignTextToFramePadding(); + ImGui.TextUnformatted(text1); + ImGui.TableNextColumn(); + _dirty |= sourceSelector.Draw("##itemSource", sourceSelector.CurrentSelection.Item.Name, string.Empty, InputWidth * 2 * UiHelpers.Scale, + ImGui.GetTextLineHeightWithSpacing()); + + if (type == SwapType.Ring) + { + ImGui.SameLine(); + _dirty |= ImGui.Checkbox("Swap Right Ring", ref _useRightRing); + } + + ImGui.TableNextColumn(); + ImGui.AlignTextToFramePadding(); + ImGui.TextUnformatted(text2); + ImGui.TableNextColumn(); + _dirty |= targetSelector.Draw("##itemTarget", targetSelector.CurrentSelection.Item.Name, string.Empty, InputWidth * 2 * UiHelpers.Scale, + ImGui.GetTextLineHeightWithSpacing()); + if (type == SwapType.Ring) + { + ImGui.SameLine(); + _dirty |= ImGui.Checkbox("Swap Left Ring", ref _useLeftRing); + } + + if (_affectedItems is not { Count: > 1 }) + return; + + ImGui.SameLine(); + ImGuiUtil.DrawTextButton($"which will also affect {_affectedItems.Count - 1} other Items.", Vector2.Zero, + Colors.PressEnterWarningBg); + if (ImGui.IsItemHovered()) + ImGui.SetTooltip(string.Join('\n', _affectedItems.Where(i => !ReferenceEquals(i.Name, targetSelector.CurrentSelection.Item.Name)) + .Select(i => i.Name))); + } + + private void DrawHairSwap() + { + using var tab = DrawTab(SwapType.Hair); + if (!tab) + return; + + using var table = ImRaii.Table("##settings", 2, ImGuiTableFlags.SizingFixedFit); + DrawTargetIdInput("Take this Hairstyle"); + DrawSourceIdInput(); + DrawGenderInput(); + } + + private void DrawFaceSwap() + { + using var disabled = ImRaii.Disabled(); + using var tab = DrawTab(SwapType.Face); + if (!tab) + return; + + using var table = ImRaii.Table("##settings", 2, ImGuiTableFlags.SizingFixedFit); + DrawTargetIdInput("Take this Face Type"); + DrawSourceIdInput(); + DrawGenderInput(); + } + + private void DrawTailSwap() + { + using var tab = DrawTab(SwapType.Tail); + if (!tab) + return; + + using var table = ImRaii.Table("##settings", 2, ImGuiTableFlags.SizingFixedFit); + DrawTargetIdInput("Take this Tail Type"); + DrawSourceIdInput(); + DrawGenderInput("for all", 2); + } + + + private void DrawEarSwap() + { + using var tab = DrawTab(SwapType.Ears); + if (!tab) + return; + + using var table = ImRaii.Table("##settings", 2, ImGuiTableFlags.SizingFixedFit); + DrawTargetIdInput("Take this Ear Type"); + DrawSourceIdInput(); + DrawGenderInput("for all Viera", 0); + } + + private const float InputWidth = 120; + + private void DrawTargetIdInput(string text = "Take this ID") + { + ImGui.TableNextColumn(); + ImGui.AlignTextToFramePadding(); + ImGui.TextUnformatted(text); + + ImGui.TableNextColumn(); + ImGui.SetNextItemWidth(InputWidth * UiHelpers.Scale); + if (ImGui.InputInt("##targetId", ref _targetId, 0, 0)) + _targetId = Math.Clamp(_targetId, 0, byte.MaxValue); + + _dirty |= ImGui.IsItemDeactivatedAfterEdit(); + } + + private void DrawSourceIdInput(string text = "and put it on this one") + { + ImGui.TableNextColumn(); + ImGui.AlignTextToFramePadding(); + ImGui.TextUnformatted(text); + + ImGui.TableNextColumn(); + ImGui.SetNextItemWidth(InputWidth * UiHelpers.Scale); + if (ImGui.InputInt("##sourceId", ref _sourceId, 0, 0)) + _sourceId = Math.Clamp(_sourceId, 0, byte.MaxValue); + + _dirty |= ImGui.IsItemDeactivatedAfterEdit(); + } + + private void DrawGenderInput(string text = "for all", int drawRace = 1) + { + ImGui.TableNextColumn(); + ImGui.AlignTextToFramePadding(); + ImGui.TextUnformatted(text); + + ImGui.TableNextColumn(); + _dirty |= Combos.Gender("##Gender", _currentGender, out _currentGender, InputWidth); + if (drawRace == 1) + { + ImGui.SameLine(); + _dirty |= Combos.Race("##Race", _currentRace, out _currentRace, InputWidth); + } + else if (drawRace == 2) + { + ImGui.SameLine(); + if (_currentRace is not ModelRace.Miqote and not ModelRace.AuRa and not ModelRace.Hrothgar) + _currentRace = ModelRace.Miqote; + + _dirty |= ImGuiUtil.GenericEnumCombo("##Race", InputWidth, _currentRace, out _currentRace, new[] + { + ModelRace.Miqote, + ModelRace.AuRa, + ModelRace.Hrothgar, + }, + RaceEnumExtensions.ToName); + } + } + + private string NonExistentText() + => _lastTab switch + { + SwapType.Hat => "One of the selected hats does not seem to exist.", + SwapType.Top => "One of the selected tops does not seem to exist.", + SwapType.Gloves => "One of the selected pairs of gloves does not seem to exist.", + SwapType.Pants => "One of the selected pants does not seem to exist.", + SwapType.Shoes => "One of the selected pairs of shoes does not seem to exist.", + SwapType.Earrings => "One of the selected earrings does not seem to exist.", + SwapType.Necklace => "One of the selected necklaces does not seem to exist.", + SwapType.Bracelet => "One of the selected bracelets does not seem to exist.", + SwapType.Ring => "One of the selected rings does not seem to exist.", + SwapType.Glasses => "One of the selected glasses does not seem to exist.", + SwapType.Hair => "One of the selected hairstyles does not seem to exist for this gender and race combo.", + SwapType.Face => "One of the selected faces does not seem to exist for this gender and race combo.", + SwapType.Ears => "One of the selected ear types does not seem to exist for this gender and race combo.", + SwapType.Tail => "One of the selected tails does not seem to exist for this gender and race combo.", + SwapType.Weapon => "One of the selected weapons or tools does not seem to exist.", + _ => string.Empty, + }; + + private static void DrawSwap(Swap swap) + { + var flags = swap.ChildSwaps.Count == 0 ? ImGuiTreeNodeFlags.Bullet | ImGuiTreeNodeFlags.Leaf : ImGuiTreeNodeFlags.DefaultOpen; + using var tree = ImRaii.TreeNode(SwapToString(swap), flags); + if (!tree) + return; + + foreach (var child in swap.ChildSwaps) + DrawSwap(child); + } + + private void OnCollectionChange(CollectionType collectionType, ModCollection? oldCollection, + ModCollection? newCollection, string _) + { + if (collectionType is not CollectionType.Current || _mod == null || newCollection == null) + return; + + UpdateMod(_mod, _mod.Index < newCollection.Settings.Count ? newCollection.GetInheritedSettings(_mod.Index).Settings : null); + } + + private void OnSettingChange(ModCollection collection, ModSettingChange type, Mod? mod, Setting oldValue, int groupIdx, bool inherited) + { + if (collection != _collectionManager.Active.Current || mod != _mod || type is ModSettingChange.TemporarySetting) + return; + + _swapData.LoadMod(_mod, _modSettings); + _dirty = true; + } + + private void OnInheritanceChange(ModCollection collection, bool _) + { + if (collection != _collectionManager.Active.Current || _mod == null) + return; + + UpdateMod(_mod, collection.GetInheritedSettings(_mod.Index).Settings); + _swapData.LoadMod(_mod, _modSettings); + _dirty = true; + } + + private void OnModOptionChange(ModOptionChangeType type, Mod mod, IModGroup? group, IModOption? option, IModDataContainer? container, + int fromIdx) + { + if (type is ModOptionChangeType.PrepareChange or ModOptionChangeType.GroupAdded or ModOptionChangeType.OptionAdded || mod != _mod) + return; + + _swapData.LoadMod(_mod, _modSettings); + UpdateOption(); + _dirty = true; + } + + private enum BetweenSlotTypes + { + Hat, + Earrings, + Necklace, + Bracelets, + RightRing, + LeftRing, + Glasses, + } + + private static EquipSlot ToEquipSlot(BetweenSlotTypes type) + => type switch + { + BetweenSlotTypes.Hat => EquipSlot.Head, + BetweenSlotTypes.Earrings => EquipSlot.Ears, + BetweenSlotTypes.Necklace => EquipSlot.Neck, + BetweenSlotTypes.Bracelets => EquipSlot.Wrists, + BetweenSlotTypes.RightRing => EquipSlot.RFinger, + BetweenSlotTypes.LeftRing => EquipSlot.LFinger, + BetweenSlotTypes.Glasses => BonusItemFlag.Glasses.ToEquipSlot(), + _ => EquipSlot.Unknown, + }; + + private static string ToName(BetweenSlotTypes type) + => type switch + { + BetweenSlotTypes.Hat => "Hat", + BetweenSlotTypes.Earrings => "Earrings", + BetweenSlotTypes.Necklace => "Necklace", + BetweenSlotTypes.Bracelets => "Bracelets", + BetweenSlotTypes.RightRing => "Right Ring", + BetweenSlotTypes.LeftRing => "Left Ring", + BetweenSlotTypes.Glasses => "Glasses", + _ => "Unknown", + }; + + private static readonly IReadOnlyList AvailableToTypes = + Enum.GetValues().Where(s => s is not BetweenSlotTypes.Hat).ToArray(); +} diff --git a/Penumbra/UI/AdvancedWindow/Materials/ConstantEditors.cs b/Penumbra/UI/AdvancedWindow/Materials/ConstantEditors.cs new file mode 100644 index 00000000..690580df --- /dev/null +++ b/Penumbra/UI/AdvancedWindow/Materials/ConstantEditors.cs @@ -0,0 +1,71 @@ +using System.Collections.Frozen; +using OtterGui.Text.Widget.Editors; +using Penumbra.GameData.Files.ShaderStructs; + +namespace Penumbra.UI.AdvancedWindow.Materials; + +public static class ConstantEditors +{ + public static readonly IEditor DefaultFloat = Editors.DefaultFloat.AsByteEditor(); + public static readonly IEditor DefaultInt = Editors.DefaultInt.AsByteEditor(); + public static readonly IEditor DefaultIntAsFloat = Editors.DefaultInt.IntAsFloatEditor().AsByteEditor(); + public static readonly IEditor DefaultColor = ColorEditor.HighDynamicRange.Reinterpreting(); + + /// + /// Material constants known to be encoded as native s. + /// + /// A editor is nonfunctional for them, as typical values for these constants would fall into the IEEE 754 denormalized number range. + /// + private static readonly FrozenSet KnownIntConstants; + + static ConstantEditors() + { + IReadOnlyList knownIntConstants = [ + "g_ToonIndex", + "g_ToonSpecIndex", + ]; + + KnownIntConstants = knownIntConstants.ToFrozenSet(); + } + + public static IEditor DefaultFor(Name name, MaterialTemplatePickers? materialTemplatePickers = null) + { + if (materialTemplatePickers != null) + { + if (name == Names.SphereMapIndexConstantName) + return materialTemplatePickers.SphereMapIndexPicker; + else if (name == Names.TileIndexConstantName) + return materialTemplatePickers.TileIndexPicker; + } + + if (name.Value != null && name.Value.EndsWith("Color")) + return DefaultColor; + + if (KnownIntConstants.Contains(name)) + return DefaultInt; + + return DefaultFloat; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static IEditor AsByteEditor(this IEditor inner) where T : unmanaged + => inner.Reinterpreting(); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static IEditor IntAsFloatEditor(this IEditor inner) + => inner.Converting(value => int.CreateSaturating(MathF.Round(value)), value => value); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static IEditor WithExponent(this IEditor inner, T exponent) + where T : unmanaged, IPowerFunctions, IComparisonOperators + => exponent == T.MultiplicativeIdentity + ? inner + : inner.Converting(value => value < T.Zero ? -T.Pow(-value, T.MultiplicativeIdentity / exponent) : T.Pow(value, T.MultiplicativeIdentity / exponent), value => value < T.Zero ? -T.Pow(-value, exponent) : T.Pow(value, exponent)); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static IEditor WithFactorAndBias(this IEditor inner, T factor, T bias) + where T : unmanaged, IMultiplicativeIdentity, IAdditiveIdentity, IMultiplyOperators, IAdditionOperators, ISubtractionOperators, IDivisionOperators, IEqualityOperators + => factor == T.MultiplicativeIdentity && bias == T.AdditiveIdentity + ? inner + : inner.Converting(value => (value - bias) / factor, value => value * factor + bias); +} diff --git a/Penumbra/UI/AdvancedWindow/Materials/MaterialTemplatePickers.cs b/Penumbra/UI/AdvancedWindow/Materials/MaterialTemplatePickers.cs new file mode 100644 index 00000000..24a5f9c2 --- /dev/null +++ b/Penumbra/UI/AdvancedWindow/Materials/MaterialTemplatePickers.cs @@ -0,0 +1,177 @@ +using Dalamud.Interface; +using FFXIVClientStructs.Interop; +using Dalamud.Bindings.ImGui; +using OtterGui; +using OtterGui.Raii; +using OtterGui.Services; +using OtterGui.Text; +using OtterGui.Text.Widget.Editors; +using Penumbra.Interop.Services; +using Penumbra.Interop.Structs; + +namespace Penumbra.UI.AdvancedWindow.Materials; + +public sealed unsafe class MaterialTemplatePickers : IUiService +{ + private const float MaximumTextureSize = 64.0f; + + private readonly TextureArraySlicer _textureArraySlicer; + private readonly CharacterUtility _characterUtility; + + public readonly IEditor TileIndexPicker; + public readonly IEditor SphereMapIndexPicker; + + public MaterialTemplatePickers(TextureArraySlicer textureArraySlicer, CharacterUtility characterUtility) + { + _textureArraySlicer = textureArraySlicer; + _characterUtility = characterUtility; + + TileIndexPicker = new Editor(DrawTileIndexPicker).AsByteEditor(); + SphereMapIndexPicker = new Editor(DrawSphereMapIndexPicker).AsByteEditor(); + } + + public bool DrawTileIndexPicker(ReadOnlySpan label, ReadOnlySpan description, ref ushort value, bool compact) + => _characterUtility.Address != null + && DrawTextureArrayIndexPicker(label, description, ref value, compact, [ + _characterUtility.Address->TileOrbArrayTexResource, + _characterUtility.Address->TileNormArrayTexResource, + ]); + + public bool DrawSphereMapIndexPicker(ReadOnlySpan label, ReadOnlySpan description, ref ushort value, bool compact) + => _characterUtility.Address != null + && DrawTextureArrayIndexPicker(label, description, ref value, compact, [ + _characterUtility.Address->SphereDArrayTexResource, + ]); + + public bool DrawTextureArrayIndexPicker(ReadOnlySpan label, ReadOnlySpan description, ref ushort value, bool compact, ReadOnlySpan> textureRHs) + { + TextureResourceHandle* firstNonNullTextureRH = null; + foreach (var texture in textureRHs) + { + if (texture.Value != null && texture.Value->CsHandle.Texture != null) + { + firstNonNullTextureRH = texture; + break; + } + } + var firstNonNullTexture = firstNonNullTextureRH != null ? firstNonNullTextureRH->CsHandle.Texture : null; + + var textureSize = firstNonNullTexture != null ? new Vector2(firstNonNullTexture->ActualWidth, firstNonNullTexture->ActualHeight).Contain(new Vector2(MaximumTextureSize)) : Vector2.Zero; + var count = firstNonNullTexture != null ? firstNonNullTexture->ArraySize : 0; + + var ret = false; + + var framePadding = ImGui.GetStyle().FramePadding; + var itemSpacing = ImGui.GetStyle().ItemSpacing; + using (var font = ImRaii.PushFont(UiBuilder.MonoFont)) + { + var spaceSize = ImUtf8.CalcTextSize(" "u8).X; + var spaces = (int)((ImGui.CalcItemWidth() - framePadding.X * 2.0f - (compact ? 0.0f : (textureSize.X + itemSpacing.X) * textureRHs.Length)) / spaceSize); + using var padding = ImRaii.PushStyle(ImGuiStyleVar.FramePadding, framePadding + new Vector2(0.0f, Math.Max(textureSize.Y - ImGui.GetFrameHeight() + itemSpacing.Y, 0.0f) * 0.5f), !compact); + using var combo = ImUtf8.Combo(label, (value == ushort.MaxValue ? "-" : value.ToString()).PadLeft(spaces), ImGuiComboFlags.NoArrowButton | ImGuiComboFlags.HeightLarge); + if (combo.Success && firstNonNullTextureRH != null) + { + var lineHeight = Math.Max(ImGui.GetTextLineHeightWithSpacing(), framePadding.Y * 2.0f + textureSize.Y); + var itemWidth = Math.Max(ImGui.GetContentRegionAvail().X, ImUtf8.CalcTextSize("MMM"u8).X + (itemSpacing.X + textureSize.X) * textureRHs.Length + framePadding.X * 2.0f); + using var center = ImRaii.PushStyle(ImGuiStyleVar.SelectableTextAlign, new Vector2(0, 0.5f)); + using var clipper = ImUtf8.ListClipper(count, lineHeight); + while (clipper.Step()) + { + for (var i = clipper.DisplayStart; i < clipper.DisplayEnd && i < count; i++) + { + if (ImUtf8.Selectable($"{i,3}", i == value, size: new(itemWidth, lineHeight))) + { + ret = value != i; + value = (ushort)i; + } + var rectMin = ImGui.GetItemRectMin(); + var rectMax = ImGui.GetItemRectMax(); + var textureRegionStart = new Vector2( + rectMax.X - framePadding.X - textureSize.X * textureRHs.Length - itemSpacing.X * (textureRHs.Length - 1), + rectMin.Y + framePadding.Y); + var maxSize = new Vector2(textureSize.X, rectMax.Y - framePadding.Y - textureRegionStart.Y); + DrawTextureSlices(textureRegionStart, maxSize, itemSpacing.X, textureRHs, (byte)i); + } + } + } + } + if (!compact && value != ushort.MaxValue) + { + var cbRectMin = ImGui.GetItemRectMin(); + var cbRectMax = ImGui.GetItemRectMax(); + var cbTextureRegionStart = new Vector2(cbRectMax.X - framePadding.X - textureSize.X * textureRHs.Length - itemSpacing.X * (textureRHs.Length - 1), cbRectMin.Y + framePadding.Y); + var cbMaxSize = new Vector2(textureSize.X, cbRectMax.Y - framePadding.Y - cbTextureRegionStart.Y); + DrawTextureSlices(cbTextureRegionStart, cbMaxSize, itemSpacing.X, textureRHs, (byte)value); + } + if (ImGui.IsItemHovered(ImGuiHoveredFlags.AllowWhenDisabled) && (description.Length > 0 || compact && value != ushort.MaxValue)) + { + using var disabled = ImRaii.Enabled(); + using var tt = ImUtf8.Tooltip(); + if (description.Length > 0) + ImUtf8.Text(description); + if (compact && value != ushort.MaxValue) + { + ImGui.Dummy(new Vector2(textureSize.X * textureRHs.Length + itemSpacing.X * (textureRHs.Length - 1), textureSize.Y)); + var rectMin = ImGui.GetItemRectMin(); + var rectMax = ImGui.GetItemRectMax(); + DrawTextureSlices(rectMin, textureSize, itemSpacing.X, textureRHs, (byte)value); + } + } + + return ret; + } + + public void DrawTextureSlices(Vector2 regionStart, Vector2 itemSize, float itemSpacing, ReadOnlySpan> textureRHs, byte sliceIndex) + { + for (var j = 0; j < textureRHs.Length; ++j) + { + if (textureRHs[j].Value == null) + continue; + var texture = textureRHs[j].Value->CsHandle.Texture; + if (texture == null) + continue; + var handle = _textureArraySlicer.GetImGuiHandle(texture, sliceIndex); + if (handle.IsNull) + continue; + + var position = regionStart with { X = regionStart.X + (itemSize.X + itemSpacing) * j }; + var size = new Vector2(texture->ActualWidth, texture->ActualHeight).Contain(itemSize); + position += (itemSize - size) * 0.5f; + ImGui.GetWindowDrawList().AddImage(handle, position, position + size, Vector2.Zero, + new Vector2(texture->ActualWidth / (float)texture->AllocatedWidth, texture->ActualHeight / (float)texture->AllocatedHeight)); + } + } + + private delegate bool DrawEditor(ReadOnlySpan label, ReadOnlySpan description, ref ushort value, bool compact); + + private sealed class Editor(DrawEditor draw) : IEditor + { + public bool Draw(Span values, bool disabled) + { + var helper = Editors.PrepareMultiComponent(values.Length); + var ret = false; + + for (var valueIdx = 0; valueIdx < values.Length; ++valueIdx) + { + helper.SetupComponent(valueIdx); + + var value = ushort.CreateSaturating(MathF.Round(values[valueIdx])); + if (disabled) + { + using var _ = ImRaii.Disabled(); + draw(helper.Id, default, ref value, true); + } + else + { + if (draw(helper.Id, default, ref value, true)) + { + values[valueIdx] = value; + ret = true; + } + } + } + + return ret; + } + } +} diff --git a/Penumbra/UI/AdvancedWindow/Materials/MtrlTab.ColorTable.cs b/Penumbra/UI/AdvancedWindow/Materials/MtrlTab.ColorTable.cs new file mode 100644 index 00000000..fad9adeb --- /dev/null +++ b/Penumbra/UI/AdvancedWindow/Materials/MtrlTab.ColorTable.cs @@ -0,0 +1,642 @@ +using Dalamud.Interface; +using Dalamud.Bindings.ImGui; +using OtterGui; +using OtterGui.Raii; +using OtterGui.Text; +using Penumbra.GameData.Files.MaterialStructs; +using Penumbra.GameData.Files.StainMapStructs; +using Penumbra.Services; + +namespace Penumbra.UI.AdvancedWindow.Materials; + +public partial class MtrlTab +{ + private const float ColorTableScalarSize = 65.0f; + + private int _colorTableSelectedPair; + + private bool DrawColorTable(ColorTable table, ColorDyeTable? dyeTable, bool disabled) + { + DrawColorTablePairSelector(table, disabled); + return DrawColorTablePairEditor(table, dyeTable, disabled); + } + + private void DrawColorTablePairSelector(ColorTable table, bool disabled) + { + var style = ImGui.GetStyle(); + var itemSpacing = style.ItemSpacing.X; + var itemInnerSpacing = style.ItemInnerSpacing.X; + var framePadding = style.FramePadding; + var buttonWidth = (ImGui.GetContentRegionAvail().X - itemSpacing * 7.0f) * 0.125f; + var frameHeight = ImGui.GetFrameHeight(); + var highlighterSize = ImUtf8.CalcIconSize(FontAwesomeIcon.Crosshairs) + framePadding * 2.0f; + + using var font = ImRaii.PushFont(UiBuilder.MonoFont); + using var alignment = ImRaii.PushStyle(ImGuiStyleVar.ButtonTextAlign, new Vector2(0, 0.5f)); + + // This depends on the font being pushed for "proper" alignment of the pair indices in the buttons. + var spaceWidth = ImUtf8.CalcTextSize(" "u8).X; + var spacePadding = (int)MathF.Ceiling((highlighterSize.X + framePadding.X + itemInnerSpacing) / spaceWidth); + + for (var i = 0; i < ColorTable.NumRows >> 1; i += 8) + { + for (var j = 0; j < 8; ++j) + { + var pairIndex = i + j; + using (ImRaii.PushColor(ImGuiCol.Button, ImGui.GetColorU32(ImGuiCol.ButtonActive), pairIndex == _colorTableSelectedPair)) + { + if (ImUtf8.Button($"#{pairIndex + 1}".PadLeft(3 + spacePadding), + new Vector2(buttonWidth, ImGui.GetFrameHeightWithSpacing() + frameHeight))) + _colorTableSelectedPair = pairIndex; + } + + var rcMin = ImGui.GetItemRectMin() + framePadding; + var rcMax = ImGui.GetItemRectMax() - framePadding; + CtBlendRect( + rcMin with { X = rcMax.X - frameHeight * 3 - itemInnerSpacing * 2 }, + rcMax with { X = rcMax.X - (frameHeight + itemInnerSpacing) * 2 }, + ImGuiUtil.ColorConvertFloat3ToU32(PseudoSqrtRgb((Vector3)table[pairIndex << 1].DiffuseColor)), + ImGuiUtil.ColorConvertFloat3ToU32(PseudoSqrtRgb((Vector3)table[(pairIndex << 1) | 1].DiffuseColor)) + ); + CtBlendRect( + rcMin with { X = rcMax.X - frameHeight * 2 - itemInnerSpacing }, + rcMax with { X = rcMax.X - frameHeight - itemInnerSpacing }, + ImGuiUtil.ColorConvertFloat3ToU32(PseudoSqrtRgb((Vector3)table[pairIndex << 1].SpecularColor)), + ImGuiUtil.ColorConvertFloat3ToU32(PseudoSqrtRgb((Vector3)table[(pairIndex << 1) | 1].SpecularColor)) + ); + CtBlendRect( + rcMin with { X = rcMax.X - frameHeight }, rcMax, + ImGuiUtil.ColorConvertFloat3ToU32(PseudoSqrtRgb((Vector3)table[pairIndex << 1].EmissiveColor)), + ImGuiUtil.ColorConvertFloat3ToU32(PseudoSqrtRgb((Vector3)table[(pairIndex << 1) | 1].EmissiveColor)) + ); + if (j < 7) + ImGui.SameLine(); + + var cursor = ImGui.GetCursorScreenPos(); + ImGui.SetCursorScreenPos(rcMin with { Y = float.Lerp(rcMin.Y, rcMax.Y, 0.5f) - highlighterSize.Y * 0.5f }); + font.Pop(); + ColorTablePairHighlightButton(pairIndex, disabled); + font.Push(UiBuilder.MonoFont); + ImGui.SetCursorScreenPos(cursor); + } + } + } + + private bool DrawColorTablePairEditor(ColorTable table, ColorDyeTable? dyeTable, bool disabled) + { + var retA = false; + var retB = false; + var rowAIdx = _colorTableSelectedPair << 1; + var rowBIdx = rowAIdx | 1; + var dyeA = dyeTable?[_colorTableSelectedPair << 1] ?? default; + var dyeB = dyeTable?[(_colorTableSelectedPair << 1) | 1] ?? default; + var previewDyeA = _stainService.GetStainCombo(dyeA.Channel).CurrentSelection.Key; + var previewDyeB = _stainService.GetStainCombo(dyeB.Channel).CurrentSelection.Key; + var dyePackA = _stainService.GudStmFile.GetValueOrNull(dyeA.Template, previewDyeA); + var dyePackB = _stainService.GudStmFile.GetValueOrNull(dyeB.Template, previewDyeB); + using (var columns = ImUtf8.Columns(2, "ColorTable"u8)) + { + using (ImUtf8.PushId("RowHeaderA"u8)) + { + retA |= DrawRowHeader(rowAIdx, disabled); + } + columns.Next(); + using (ImUtf8.PushId("RowHeaderB"u8)) + { + retB |= DrawRowHeader(rowBIdx, disabled); + } + } + + DrawHeader(" Colors"u8); + using (var columns = ImUtf8.Columns(2, "ColorTable"u8)) + { + using var dis = ImRaii.Disabled(disabled); + using (ImUtf8.PushId("ColorsA"u8)) + { + retA |= DrawColors(table, dyeTable, dyePackA, rowAIdx); + } + + columns.Next(); + using (ImUtf8.PushId("ColorsB"u8)) + { + retB |= DrawColors(table, dyeTable, dyePackB, rowBIdx); + } + } + + DrawHeader(" Physical Parameters"u8); + using (var columns = ImUtf8.Columns(2, "ColorTable"u8)) + { + using var dis = ImRaii.Disabled(disabled); + using (ImUtf8.PushId("PbrA"u8)) + { + retA |= DrawPbr(table, dyeTable, dyePackA, rowAIdx); + } + + columns.Next(); + using (ImUtf8.PushId("PbrB"u8)) + { + retB |= DrawPbr(table, dyeTable, dyePackB, rowBIdx); + } + } + + DrawHeader(" Sheen Layer Parameters"u8); + using (var columns = ImUtf8.Columns(2, "ColorTable"u8)) + { + using var dis = ImRaii.Disabled(disabled); + using (ImUtf8.PushId("SheenA"u8)) + { + retA |= DrawSheen(table, dyeTable, dyePackA, rowAIdx); + } + + columns.Next(); + using (ImUtf8.PushId("SheenB"u8)) + { + retB |= DrawSheen(table, dyeTable, dyePackB, rowBIdx); + } + } + + DrawHeader(" Pair Blending"u8); + using (var columns = ImUtf8.Columns(2, "ColorTable"u8)) + { + using var dis = ImRaii.Disabled(disabled); + using (ImUtf8.PushId("BlendingA"u8)) + { + retA |= DrawBlending(table, dyeTable, dyePackA, rowAIdx); + } + + columns.Next(); + using (ImUtf8.PushId("BlendingB"u8)) + { + retB |= DrawBlending(table, dyeTable, dyePackB, rowBIdx); + } + } + + DrawHeader(" Material Template"u8); + using (var columns = ImUtf8.Columns(2, "ColorTable"u8)) + { + using var dis = ImRaii.Disabled(disabled); + using (ImUtf8.PushId("TemplateA"u8)) + { + retA |= DrawTemplate(table, dyeTable, dyePackA, rowAIdx); + } + + columns.Next(); + using (ImUtf8.PushId("TemplateB"u8)) + { + retB |= DrawTemplate(table, dyeTable, dyePackB, rowBIdx); + } + } + + if (dyeTable != null) + { + DrawHeader(" Dye Properties"u8); + using var columns = ImUtf8.Columns(2, "ColorTable"u8); + using var dis = ImRaii.Disabled(disabled); + using (ImUtf8.PushId("DyeA"u8)) + { + retA |= DrawDye(dyeTable, dyePackA, rowAIdx); + } + + columns.Next(); + using (ImUtf8.PushId("DyeB"u8)) + { + retB |= DrawDye(dyeTable, dyePackB, rowBIdx); + } + } + + DrawHeader(" Further Content"u8); + using (var columns = ImUtf8.Columns(2, "ColorTable"u8)) + { + using var dis = ImRaii.Disabled(disabled); + using (ImUtf8.PushId("FurtherA"u8)) + { + retA |= DrawFurther(table, dyeTable, dyePackA, rowAIdx); + } + + columns.Next(); + using (ImUtf8.PushId("FurtherB"u8)) + { + retB |= DrawFurther(table, dyeTable, dyePackB, rowBIdx); + } + } + + if (retA) + UpdateColorTableRowPreview(rowAIdx); + if (retB) + UpdateColorTableRowPreview(rowBIdx); + + return retA | retB; + } + + /// Padding styles do not seem to apply to this component. It is recommended to prepend two spaces. + private static void DrawHeader(ReadOnlySpan label) + { + var headerColor = ImGui.GetColorU32(ImGuiCol.Header); + using var _ = ImRaii.PushColor(ImGuiCol.HeaderHovered, headerColor).Push(ImGuiCol.HeaderActive, headerColor); + ImUtf8.CollapsingHeader(label, ImGuiTreeNodeFlags.Leaf); + } + + private bool DrawRowHeader(int rowIdx, bool disabled) + { + ColorTableCopyClipboardButton(rowIdx); + ImUtf8.SameLineInner(); + var ret = ColorTablePasteFromClipboardButton(rowIdx, disabled); + ImUtf8.SameLineInner(); + ColorTableRowHighlightButton(rowIdx, disabled); + + ImGui.SameLine(); + CenteredTextInRest($"Row {(rowIdx >> 1) + 1}{"AB"[rowIdx & 1]}"); + + return ret; + } + + private static bool DrawColors(ColorTable table, ColorDyeTable? dyeTable, DyePack? dyePack, int rowIdx) + { + var dyeOffset = ImGui.GetContentRegionAvail().X + + ImGui.GetStyle().ItemSpacing.X + - ImGui.GetStyle().ItemInnerSpacing.X + - ImGui.GetFrameHeight() * 2.0f; + + var ret = false; + ref var row = ref table[rowIdx]; + var dye = dyeTable?[rowIdx] ?? default; + + ret |= CtColorPicker("Diffuse Color"u8, default, row.DiffuseColor, + c => table[rowIdx].DiffuseColor = c); + if (dyeTable != null) + { + ImGui.SameLine(dyeOffset); + ret |= CtApplyStainCheckbox("##dyeDiffuseColor"u8, "Apply Diffuse Color on Dye"u8, dye.DiffuseColor, + b => dyeTable[rowIdx].DiffuseColor = b); + ImUtf8.SameLineInner(); + CtColorPicker("##dyePreviewDiffuseColor"u8, "Dye Preview for Diffuse Color"u8, dyePack?.DiffuseColor); + } + + ret |= CtColorPicker("Specular Color"u8, default, row.SpecularColor, + c => table[rowIdx].SpecularColor = c); + if (dyeTable != null) + { + ImGui.SameLine(dyeOffset); + ret |= CtApplyStainCheckbox("##dyeSpecularColor"u8, "Apply Specular Color on Dye"u8, dye.SpecularColor, + b => dyeTable[rowIdx].SpecularColor = b); + ImUtf8.SameLineInner(); + CtColorPicker("##dyePreviewSpecularColor"u8, "Dye Preview for Specular Color"u8, dyePack?.SpecularColor); + } + + ret |= CtColorPicker("Emissive Color"u8, default, row.EmissiveColor, + c => table[rowIdx].EmissiveColor = c); + if (dyeTable != null) + { + ImGui.SameLine(dyeOffset); + ret |= CtApplyStainCheckbox("##dyeEmissiveColor"u8, "Apply Emissive Color on Dye"u8, dye.EmissiveColor, + b => dyeTable[rowIdx].EmissiveColor = b); + ImUtf8.SameLineInner(); + CtColorPicker("##dyePreviewEmissiveColor"u8, "Dye Preview for Emissive Color"u8, dyePack?.EmissiveColor); + } + + return ret; + } + + private static bool DrawBlending(ColorTable table, ColorDyeTable? dyeTable, DyePack? dyePack, int rowIdx) + { + var scalarSize = ColorTableScalarSize * UiHelpers.Scale; + var dyeOffset = ImGui.GetContentRegionAvail().X + + ImGui.GetStyle().ItemSpacing.X + - ImGui.GetStyle().ItemInnerSpacing.X + - ImGui.GetFrameHeight() + - scalarSize; + + var isRowB = (rowIdx & 1) != 0; + + var ret = false; + ref var row = ref table[rowIdx]; + var dye = dyeTable?[rowIdx] ?? default; + + ImGui.SetNextItemWidth(scalarSize); + ret |= CtDragHalf(isRowB ? "Field #19"u8 : "Anisotropy Degree"u8, default, row.Anisotropy, "%.2f"u8, 0.0f, HalfMaxValue, 0.1f, + v => table[rowIdx].Anisotropy = v); + if (dyeTable != null) + { + ImGui.SameLine(dyeOffset); + ret |= CtApplyStainCheckbox("##dyeAnisotropy"u8, isRowB ? "Apply Field #19 on Dye"u8 : "Apply Anisotropy Degree on Dye"u8, + dye.Anisotropy, + b => dyeTable[rowIdx].Anisotropy = b); + ImUtf8.SameLineInner(); + ImGui.SetNextItemWidth(scalarSize); + CtDragHalf("##dyePreviewAnisotropy"u8, isRowB ? "Dye Preview for Field #19"u8 : "Dye Preview for Anisotropy Degree"u8, + dyePack?.Anisotropy, "%.2f"u8); + } + + return ret; + } + + private bool DrawTemplate(ColorTable table, ColorDyeTable? dyeTable, DyePack? dyePack, int rowIdx) + { + var scalarSize = ColorTableScalarSize * UiHelpers.Scale; + var itemSpacing = ImGui.GetStyle().ItemSpacing.X; + var dyeOffset = ImGui.GetContentRegionAvail().X - ImGui.GetStyle().ItemInnerSpacing.X - ImGui.GetFrameHeight() - scalarSize - 64.0f; + var subColWidth = CalculateSubColumnWidth(2); + + var ret = false; + ref var row = ref table[rowIdx]; + var dye = dyeTable?[rowIdx] ?? default; + + ImGui.SetNextItemWidth(scalarSize); + ret |= CtDragScalar("Shader ID"u8, default, row.ShaderId, "%d"u8, (ushort)0, (ushort)255, 0.25f, + v => table[rowIdx].ShaderId = v); + + ImGui.Dummy(new Vector2(ImGui.GetTextLineHeight() / 2)); + + ImGui.SetNextItemWidth(scalarSize + itemSpacing + 64.0f); + ret |= CtSphereMapIndexPicker("###SphereMapIndex"u8, default, row.SphereMapIndex, false, + v => table[rowIdx].SphereMapIndex = v); + ImUtf8.SameLineInner(); + ImUtf8.Text("Sphere Map"u8); + if (dyeTable != null) + { + var textRectMin = ImGui.GetItemRectMin(); + var textRectMax = ImGui.GetItemRectMax(); + ImGui.SameLine(dyeOffset); + var cursor = ImGui.GetCursorScreenPos(); + ImGui.SetCursorScreenPos(cursor with { Y = float.Lerp(textRectMin.Y, textRectMax.Y, 0.5f) - ImGui.GetFrameHeight() * 0.5f }); + ret |= CtApplyStainCheckbox("##dyeSphereMapIndex"u8, "Apply Sphere Map on Dye"u8, dye.SphereMapIndex, + b => dyeTable[rowIdx].SphereMapIndex = b); + ImUtf8.SameLineInner(); + ImGui.SetCursorScreenPos(ImGui.GetCursorScreenPos() with { Y = cursor.Y }); + ImGui.SetNextItemWidth(scalarSize + itemSpacing + 64.0f); + using var dis = ImRaii.Disabled(); + CtSphereMapIndexPicker("###SphereMapIndexDye"u8, "Dye Preview for Sphere Map"u8, dyePack?.SphereMapIndex ?? ushort.MaxValue, false, + Nop); + } + + ImGui.Dummy(new Vector2(64.0f, 0.0f)); + ImGui.SameLine(); + ImGui.SetNextItemWidth(scalarSize); + ret |= CtDragScalar("Sphere Map Intensity"u8, default, (float)row.SphereMapMask * 100.0f, "%.0f%%"u8, HalfMinValue * 100.0f, + HalfMaxValue * 100.0f, 1.0f, + v => table[rowIdx].SphereMapMask = (Half)(v * 0.01f)); + if (dyeTable != null) + { + ImGui.SameLine(dyeOffset); + ret |= CtApplyStainCheckbox("##dyeSphereMapMask"u8, "Apply Sphere Map Intensity on Dye"u8, dye.SphereMapMask, + b => dyeTable[rowIdx].SphereMapMask = b); + ImUtf8.SameLineInner(); + ImGui.SetNextItemWidth(scalarSize); + CtDragScalar("##dyeSphereMapMask"u8, "Dye Preview for Sphere Map Intensity"u8, (float?)dyePack?.SphereMapMask * 100.0f, "%.0f%%"u8); + } + + ImGui.Dummy(new Vector2(ImGui.GetTextLineHeight() / 2)); + + var leftLineHeight = 64.0f + ImGui.GetStyle().FramePadding.Y * 2.0f; + var rightLineHeight = 3.0f * ImGui.GetFrameHeight() + 2.0f * ImGui.GetStyle().ItemSpacing.Y; + var lineHeight = Math.Max(leftLineHeight, rightLineHeight); + var cursorPos = ImGui.GetCursorScreenPos(); + ImGui.SetCursorScreenPos(cursorPos + new Vector2(0.0f, (lineHeight - leftLineHeight) * 0.5f)); + ImGui.SetNextItemWidth(scalarSize + (itemSpacing + 64.0f) * 2.0f); + ret |= CtTileIndexPicker("###TileIndex"u8, default, row.TileIndex, false, + v => table[rowIdx].TileIndex = v); + ImUtf8.SameLineInner(); + ImUtf8.Text("Tile"u8); + + ImGui.SameLine(subColWidth); + ImGui.SetCursorScreenPos(ImGui.GetCursorScreenPos() with { Y = cursorPos.Y + (lineHeight - rightLineHeight) * 0.5f }); + using (ImUtf8.Child("###TileProperties"u8, + new Vector2(ImGui.GetContentRegionAvail().X, float.Lerp(rightLineHeight, lineHeight, 0.5f)))) + { + ImGui.Dummy(new Vector2(scalarSize, 0.0f)); + ImUtf8.SameLineInner(); + ImGui.SetNextItemWidth(scalarSize); + ret |= CtDragScalar("Tile Opacity"u8, default, (float)row.TileAlpha * 100.0f, "%.0f%%"u8, 0.0f, HalfMaxValue * 100.0f, 1.0f, + v => table[rowIdx].TileAlpha = (Half)(v * 0.01f)); + + ret |= CtTileTransformMatrix(row.TileTransform, scalarSize, true, + m => table[rowIdx].TileTransform = m); + ImUtf8.SameLineInner(); + ImGui.SetCursorScreenPos(ImGui.GetCursorScreenPos() + - new Vector2(0.0f, (ImGui.GetFrameHeight() + ImGui.GetStyle().ItemSpacing.Y) * 0.5f)); + ImUtf8.Text("Tile Transform"u8); + } + + return ret; + } + + private static bool DrawPbr(ColorTable table, ColorDyeTable? dyeTable, DyePack? dyePack, int rowIdx) + { + var scalarSize = ColorTableScalarSize * UiHelpers.Scale; + var subColWidth = CalculateSubColumnWidth(2) + ImGui.GetStyle().ItemSpacing.X; + var dyeOffset = subColWidth + - ImGui.GetStyle().ItemSpacing.X * 2.0f + - ImGui.GetStyle().ItemInnerSpacing.X + - ImGui.GetFrameHeight() + - scalarSize; + + var ret = false; + ref var row = ref table[rowIdx]; + var dye = dyeTable?[rowIdx] ?? default; + + ImGui.SetNextItemWidth(scalarSize); + ret |= CtDragScalar("Roughness"u8, default, (float)row.Roughness * 100.0f, "%.0f%%"u8, HalfMinValue * 100.0f, HalfMaxValue * 100.0f, + 1.0f, + v => table[rowIdx].Roughness = (Half)(v * 0.01f)); + if (dyeTable != null) + { + ImGui.SameLine(dyeOffset); + ret |= CtApplyStainCheckbox("##dyeRoughness"u8, "Apply Roughness on Dye"u8, dye.Roughness, + b => dyeTable[rowIdx].Roughness = b); + ImUtf8.SameLineInner(); + ImGui.SetNextItemWidth(scalarSize); + CtDragScalar("##dyePreviewRoughness"u8, "Dye Preview for Roughness"u8, (float?)dyePack?.Roughness * 100.0f, "%.0f%%"u8); + } + + ImGui.SameLine(subColWidth); + ImGui.SetNextItemWidth(scalarSize); + ret |= CtDragScalar("Metalness"u8, default, (float)row.Metalness * 100.0f, "%.0f%%"u8, HalfMinValue * 100.0f, HalfMaxValue * 100.0f, + 1.0f, + v => table[rowIdx].Metalness = (Half)(v * 0.01f)); + if (dyeTable != null) + { + ImGui.SameLine(subColWidth + dyeOffset); + ret |= CtApplyStainCheckbox("##dyeMetalness"u8, "Apply Metalness on Dye"u8, dye.Metalness, + b => dyeTable[rowIdx].Metalness = b); + ImUtf8.SameLineInner(); + ImGui.SetNextItemWidth(scalarSize); + CtDragScalar("##dyePreviewMetalness"u8, "Dye Preview for Metalness"u8, (float?)dyePack?.Metalness * 100.0f, "%.0f%%"u8); + } + + return ret; + } + + private static bool DrawSheen(ColorTable table, ColorDyeTable? dyeTable, DyePack? dyePack, int rowIdx) + { + var scalarSize = ColorTableScalarSize * UiHelpers.Scale; + var subColWidth = CalculateSubColumnWidth(2) + ImGui.GetStyle().ItemSpacing.X; + var dyeOffset = subColWidth + - ImGui.GetStyle().ItemSpacing.X * 2.0f + - ImGui.GetStyle().ItemInnerSpacing.X + - ImGui.GetFrameHeight() + - scalarSize; + + var ret = false; + ref var row = ref table[rowIdx]; + var dye = dyeTable?[rowIdx] ?? default; + + ImGui.SetNextItemWidth(scalarSize); + ret |= CtDragScalar("Sheen"u8, default, (float)row.SheenRate * 100.0f, "%.0f%%"u8, HalfMinValue * 100.0f, HalfMaxValue * 100.0f, 1.0f, + v => table[rowIdx].SheenRate = (Half)(v * 0.01f)); + if (dyeTable != null) + { + ImGui.SameLine(dyeOffset); + ret |= CtApplyStainCheckbox("##dyeSheenRate"u8, "Apply Sheen on Dye"u8, dye.SheenRate, + b => dyeTable[rowIdx].SheenRate = b); + ImUtf8.SameLineInner(); + ImGui.SetNextItemWidth(scalarSize); + CtDragScalar("##dyePreviewSheenRate"u8, "Dye Preview for Sheen"u8, (float?)dyePack?.SheenRate * 100.0f, "%.0f%%"u8); + } + + ImGui.SameLine(subColWidth); + ImGui.SetNextItemWidth(scalarSize); + ret |= CtDragScalar("Sheen Tint"u8, default, (float)row.SheenTintRate * 100.0f, "%.0f%%"u8, HalfMinValue * 100.0f, + HalfMaxValue * 100.0f, 1.0f, + v => table[rowIdx].SheenTintRate = (Half)(v * 0.01f)); + if (dyeTable != null) + { + ImGui.SameLine(subColWidth + dyeOffset); + ret |= CtApplyStainCheckbox("##dyeSheenTintRate"u8, "Apply Sheen Tint on Dye"u8, dye.SheenTintRate, + b => dyeTable[rowIdx].SheenTintRate = b); + ImUtf8.SameLineInner(); + ImGui.SetNextItemWidth(scalarSize); + CtDragScalar("##dyePreviewSheenTintRate"u8, "Dye Preview for Sheen Tint"u8, (float?)dyePack?.SheenTintRate * 100.0f, "%.0f%%"u8); + } + + ImGui.SetNextItemWidth(scalarSize); + ret |= CtDragScalar("Sheen Roughness"u8, default, 100.0f / (float)row.SheenAperture, "%.0f%%"u8, 100.0f / HalfMaxValue, + 100.0f / HalfEpsilon, 1.0f, + v => table[rowIdx].SheenAperture = (Half)(100.0f / v)); + if (dyeTable != null) + { + ImGui.SameLine(dyeOffset); + ret |= CtApplyStainCheckbox("##dyeSheenRoughness"u8, "Apply Sheen Roughness on Dye"u8, dye.SheenAperture, + b => dyeTable[rowIdx].SheenAperture = b); + ImUtf8.SameLineInner(); + ImGui.SetNextItemWidth(scalarSize); + CtDragScalar("##dyePreviewSheenRoughness"u8, "Dye Preview for Sheen Roughness"u8, 100.0f / (float?)dyePack?.SheenAperture, + "%.0f%%"u8); + } + + return ret; + } + + private static bool DrawFurther(ColorTable table, ColorDyeTable? dyeTable, DyePack? dyePack, int rowIdx) + { + var scalarSize = ColorTableScalarSize * UiHelpers.Scale; + var subColWidth = CalculateSubColumnWidth(2) + ImGui.GetStyle().ItemSpacing.X; + var dyeOffset = subColWidth + - ImGui.GetStyle().ItemSpacing.X * 2.0f + - ImGui.GetStyle().ItemInnerSpacing.X + - ImGui.GetFrameHeight() + - scalarSize; + + var ret = false; + ref var row = ref table[rowIdx]; + var dye = dyeTable?[rowIdx] ?? default; + + ImGui.SetNextItemWidth(scalarSize); + ret |= CtDragHalf("Field #11"u8, default, row.Scalar11, "%.2f"u8, HalfMinValue, HalfMaxValue, 0.1f, + v => table[rowIdx].Scalar11 = v); + if (dyeTable != null) + { + ImGui.SameLine(dyeOffset); + ret |= CtApplyStainCheckbox("##dyeScalar11"u8, "Apply Field #11 on Dye"u8, dye.Scalar3, + b => dyeTable[rowIdx].Scalar3 = b); + ImUtf8.SameLineInner(); + ImGui.SetNextItemWidth(scalarSize); + CtDragHalf("##dyePreviewScalar11"u8, "Dye Preview for Field #11"u8, dyePack?.Scalar3, "%.2f"u8); + } + + ImGui.Dummy(new Vector2(ImGui.GetTextLineHeight() / 2)); + + ImGui.SetNextItemWidth(scalarSize); + ret |= CtDragHalf("Field #3"u8, default, row.Scalar3, "%.2f"u8, HalfMinValue, HalfMaxValue, 0.1f, + v => table[rowIdx].Scalar3 = v); + + ImGui.SameLine(subColWidth); + ImGui.SetNextItemWidth(scalarSize); + ret |= CtDragHalf("Field #7"u8, default, row.Scalar7, "%.2f"u8, HalfMinValue, HalfMaxValue, 0.1f, + v => table[rowIdx].Scalar7 = v); + + ImGui.SetNextItemWidth(scalarSize); + ret |= CtDragHalf("Field #15"u8, default, row.Scalar15, "%.2f"u8, HalfMinValue, HalfMaxValue, 0.1f, + v => table[rowIdx].Scalar15 = v); + + ImGui.SameLine(subColWidth); + ImGui.SetNextItemWidth(scalarSize); + ret |= CtDragHalf("Field #17"u8, default, row.Scalar17, "%.2f"u8, HalfMinValue, HalfMaxValue, 0.1f, + v => table[rowIdx].Scalar17 = v); + + ImGui.SetNextItemWidth(scalarSize); + ret |= CtDragHalf("Field #20"u8, default, row.Scalar20, "%.2f"u8, HalfMinValue, HalfMaxValue, 0.1f, + v => table[rowIdx].Scalar20 = v); + + ImGui.SameLine(subColWidth); + ImGui.SetNextItemWidth(scalarSize); + ret |= CtDragHalf("Field #22"u8, default, row.Scalar22, "%.2f"u8, HalfMinValue, HalfMaxValue, 0.1f, + v => table[rowIdx].Scalar22 = v); + + ImGui.SetNextItemWidth(scalarSize); + ret |= CtDragHalf("Field #23"u8, default, row.Scalar23, "%.2f"u8, HalfMinValue, HalfMaxValue, 0.1f, + v => table[rowIdx].Scalar23 = v); + + return ret; + } + + private bool DrawDye(ColorDyeTable dyeTable, DyePack? dyePack, int rowIdx) + { + var scalarSize = ColorTableScalarSize * UiHelpers.Scale; + var applyButtonWidth = ImUtf8.CalcTextSize("Apply Preview Dye"u8).X + ImGui.GetStyle().FramePadding.X * 2.0f; + var subColWidth = CalculateSubColumnWidth(2, applyButtonWidth); + + var ret = false; + ref var dye = ref dyeTable[rowIdx]; + + ImGui.SetNextItemWidth(scalarSize); + ret |= CtDragScalar("Dye Channel"u8, default, dye.Channel + 1, "%d"u8, 1, StainService.ChannelCount, 0.1f, + value => dyeTable[rowIdx].Channel = (byte)(Math.Clamp(value, 1, StainService.ChannelCount) - 1)); + ImGui.SameLine(subColWidth); + ImGui.SetNextItemWidth(scalarSize); + _stainService.GudTemplateCombo.CurrentDyeChannel = dye.Channel; + if (_stainService.GudTemplateCombo.Draw("##dyeTemplate", dye.Template.ToString(), string.Empty, + scalarSize + ImGui.GetStyle().ScrollbarSize / 2, ImGui.GetTextLineHeightWithSpacing(), ImGuiComboFlags.NoArrowButton)) + { + dye.Template = _stainService.GudTemplateCombo.CurrentSelection.UShort; + ret = true; + } + + ImUtf8.SameLineInner(); + ImUtf8.Text("Dye Template"u8); + ImGui.SameLine(ImGui.GetContentRegionAvail().X - applyButtonWidth + ImGui.GetStyle().ItemSpacing.X); + using var dis = ImRaii.Disabled(!dyePack.HasValue); + if (ImUtf8.Button("Apply Preview Dye"u8)) + ret |= Mtrl.ApplyDyeToRow(_stainService.GudStmFile, [ + _stainService.StainCombo1.CurrentSelection.Key, + _stainService.StainCombo2.CurrentSelection.Key, + ], rowIdx); + + return ret; + } + + private static void CenteredTextInRest(string text) + => AlignedTextInRest(text, 0.5f); + + private static void AlignedTextInRest(string text, float alignment) + { + var width = ImGui.CalcTextSize(text).X; + ImGui.SetCursorScreenPos(ImGui.GetCursorScreenPos() + new Vector2((ImGui.GetContentRegionAvail().X - width) * alignment, 0.0f)); + ImGui.TextUnformatted(text); + } + + private static float CalculateSubColumnWidth(int numSubColumns, float reservedSpace = 0.0f) + { + var itemSpacing = ImGui.GetStyle().ItemSpacing.X; + return (ImGui.GetContentRegionAvail().X - reservedSpace - itemSpacing * (numSubColumns - 1)) / numSubColumns + itemSpacing; + } +} diff --git a/Penumbra/UI/AdvancedWindow/Materials/MtrlTab.CommonColorTable.cs b/Penumbra/UI/AdvancedWindow/Materials/MtrlTab.CommonColorTable.cs new file mode 100644 index 00000000..9ea9c2e0 --- /dev/null +++ b/Penumbra/UI/AdvancedWindow/Materials/MtrlTab.CommonColorTable.cs @@ -0,0 +1,602 @@ +using Dalamud.Bindings.ImGui; +using Dalamud.Interface; +using Dalamud.Interface.Utility; +using Penumbra.GameData.Files.MaterialStructs; +using Penumbra.GameData.Files; +using OtterGui.Text; +using Penumbra.GameData.Structs; +using OtterGui.Raii; +using OtterGui.Text.Widget; + +namespace Penumbra.UI.AdvancedWindow.Materials; + +public partial class MtrlTab +{ + private static readonly float HalfMinValue = (float)Half.MinValue; + private static readonly float HalfMaxValue = (float)Half.MaxValue; + private static readonly float HalfEpsilon = (float)Half.Epsilon; + + private static readonly FontAwesomeCheckbox ApplyStainCheckbox = new(FontAwesomeIcon.FillDrip); + + private static (Vector2 Scale, float Rotation, float Shear)? _pinnedTileTransform; + + private bool DrawColorTableSection(bool disabled) + { + if (!_shpkLoading && !TextureIds.Contains(ShpkFile.TableSamplerId) || Mtrl.Table == null) + return false; + + ImGui.Dummy(new Vector2(ImGui.GetTextLineHeight() / 2)); + if (!ImUtf8.CollapsingHeader("Color Table"u8, ImGuiTreeNodeFlags.DefaultOpen)) + return false; + + ColorTableCopyAllClipboardButton(); + ImGui.SameLine(); + var ret = ColorTablePasteAllClipboardButton(disabled); + if (!disabled) + { + ImGui.SameLine(); + ImUtf8.IconDummy(); + ImGui.SameLine(); + ret |= ColorTableDyeableCheckbox(); + } + + if (Mtrl.DyeTable != null) + { + ImGui.SameLine(); + ImUtf8.IconDummy(); + ImGui.SameLine(); + ret |= DrawPreviewDye(disabled); + } + + ret |= Mtrl.Table switch + { + LegacyColorTable legacyTable => DrawLegacyColorTable(legacyTable, Mtrl.DyeTable as LegacyColorDyeTable, disabled), + ColorTable table when Mtrl.ShaderPackage.Name is "characterlegacy.shpk" => DrawLegacyColorTable(table, + Mtrl.DyeTable as ColorDyeTable, disabled), + ColorTable table => DrawColorTable(table, Mtrl.DyeTable as ColorDyeTable, disabled), + _ => false, + }; + + return ret; + } + + private void ColorTableCopyAllClipboardButton() + { + if (Mtrl.Table == null) + return; + + if (!ImUtf8.Button("Export All Rows to Clipboard"u8, ImGuiHelpers.ScaledVector2(200, 0))) + return; + + try + { + var data1 = Mtrl.Table.AsBytes(); + var data2 = Mtrl.DyeTable != null ? Mtrl.DyeTable.AsBytes() : []; + + var array = new byte[data1.Length + data2.Length]; + data1.TryCopyTo(array); + data2.TryCopyTo(array.AsSpan(data1.Length)); + + var text = Convert.ToBase64String(array); + ImGui.SetClipboardText(text); + } + catch + { + // ignored + } + } + + private bool DrawPreviewDye(bool disabled) + { + var (dyeId1, (name1, dyeColor1, gloss1)) = _stainService.StainCombo1.CurrentSelection; + var (dyeId2, (name2, dyeColor2, gloss2)) = _stainService.StainCombo2.CurrentSelection; + var tt = dyeId1 == 0 && dyeId2 == 0 + ? "Select a preview dye first."u8 + : "Apply all preview values corresponding to the dye template and chosen dye where dyeing is enabled."u8; + if (ImUtf8.ButtonEx("Apply Preview Dye"u8, tt, disabled: disabled || dyeId1 == 0 && dyeId2 == 0)) + { + var ret = false; + if (Mtrl.DyeTable != null) + { + ret |= Mtrl.ApplyDye(_stainService.LegacyStmFile, [dyeId1, dyeId2]); + ret |= Mtrl.ApplyDye(_stainService.GudStmFile, [dyeId1, dyeId2]); + } + + UpdateColorTablePreview(); + + return ret; + } + + ImGui.SameLine(); + var label = dyeId1 == 0 ? "Preview Dye 1###previewDye1" : $"{name1} (Preview 1)###previewDye1"; + if (_stainService.StainCombo1.Draw(label, dyeColor1, string.Empty, true, gloss1)) + UpdateColorTablePreview(); + ImGui.SameLine(); + label = dyeId2 == 0 ? "Preview Dye 2###previewDye2" : $"{name2} (Preview 2)###previewDye2"; + if (_stainService.StainCombo2.Draw(label, dyeColor2, string.Empty, true, gloss2)) + UpdateColorTablePreview(); + return false; + } + + private bool ColorTablePasteAllClipboardButton(bool disabled) + { + if (Mtrl.Table == null) + return false; + + if (!ImUtf8.ButtonEx("Import All Rows from Clipboard"u8, ImGuiHelpers.ScaledVector2(200, 0), disabled)) + return false; + + try + { + var text = ImGui.GetClipboardText(); + var data = Convert.FromBase64String(text); + var table = Mtrl.Table.AsBytes(); + var dyeTable = Mtrl.DyeTable != null ? Mtrl.DyeTable.AsBytes() : []; + if (data.Length != table.Length && data.Length != table.Length + dyeTable.Length) + return false; + + data.AsSpan(0, table.Length).TryCopyTo(table); + data.AsSpan(table.Length).TryCopyTo(dyeTable); + + UpdateColorTablePreview(); + + return true; + } + catch + { + return false; + } + } + + [SkipLocalsInit] + private void ColorTableCopyClipboardButton(int rowIdx) + { + if (Mtrl.Table == null) + return; + + if (!ImUtf8.IconButton(FontAwesomeIcon.Clipboard, "Export this row to your clipboard."u8, + ImGui.GetFrameHeight() * Vector2.One)) + return; + + try + { + var data1 = Mtrl.Table.RowAsBytes(rowIdx); + var data2 = Mtrl.DyeTable != null ? Mtrl.DyeTable.RowAsBytes(rowIdx) : []; + + var array = new byte[data1.Length + data2.Length]; + data1.TryCopyTo(array); + data2.TryCopyTo(array.AsSpan(data1.Length)); + + var text = Convert.ToBase64String(array); + ImGui.SetClipboardText(text); + } + catch + { + // ignored + } + } + + private bool ColorTableDyeableCheckbox() + { + var dyeable = Mtrl.DyeTable != null; + var ret = ImUtf8.Checkbox("Dyeable"u8, ref dyeable); + + if (ret) + { + Mtrl.DyeTable = dyeable + ? Mtrl.Table switch + { + ColorTable => new ColorDyeTable(), + LegacyColorTable => new LegacyColorDyeTable(), + _ => null, + } + : null; + UpdateColorTablePreview(); + } + + return ret; + } + + private bool ColorTablePasteFromClipboardButton(int rowIdx, bool disabled) + { + if (Mtrl.Table == null) + return false; + + if (ImUtf8.IconButton(FontAwesomeIcon.Paste, + "Import an exported row from your clipboard onto this row.\n\nRight-Click for more options."u8, + ImGui.GetFrameHeight() * Vector2.One, disabled)) + try + { + var text = ImGui.GetClipboardText(); + var data = Convert.FromBase64String(text); + var row = Mtrl.Table.RowAsBytes(rowIdx); + var dyeRow = Mtrl.DyeTable != null ? Mtrl.DyeTable.RowAsBytes(rowIdx) : []; + if (data.Length != row.Length && data.Length != row.Length + dyeRow.Length) + return false; + + data.AsSpan(0, row.Length).TryCopyTo(row); + data.AsSpan(row.Length).TryCopyTo(dyeRow); + + UpdateColorTableRowPreview(rowIdx); + + return true; + } + catch + { + return false; + } + + return ColorTablePasteFromClipboardContext(rowIdx, disabled); + } + + private unsafe bool ColorTablePasteFromClipboardContext(int rowIdx, bool disabled) + { + if (!disabled && ImGui.IsItemClicked(ImGuiMouseButton.Right)) + ImUtf8.OpenPopup("context"u8); + + using var context = ImUtf8.Popup("context"u8); + if (!context) + return false; + + using var _ = ImRaii.Disabled(disabled); + + IColorTable.ValueTypes copy = 0; + IColorDyeTable.ValueTypes dyeCopy = 0; + if (ImUtf8.Selectable("Import Colors Only"u8)) + { + copy = IColorTable.ValueTypes.Colors; + dyeCopy = IColorDyeTable.ValueTypes.Colors; + } + + if (ImUtf8.Selectable("Import Other Values Only"u8)) + { + copy = ~IColorTable.ValueTypes.Colors; + dyeCopy = ~IColorDyeTable.ValueTypes.Colors; + } + + if (copy == 0) + return false; + + try + { + var text = ImGui.GetClipboardText(); + var data = Convert.FromBase64String(text); + var row = Mtrl.Table!.RowAsHalves(rowIdx); + var halves = new Span(Unsafe.AsPointer(ref data[0]), row.Length); + var dyeRow = Mtrl.DyeTable != null ? Mtrl.DyeTable.RowAsBytes(rowIdx) : []; + if (!Mtrl.Table.MergeSpecificValues(row, halves, copy)) + return false; + + Mtrl.DyeTable?.MergeSpecificValues(dyeRow, data.AsSpan(row.Length * 2), dyeCopy); + + UpdateColorTableRowPreview(rowIdx); + return true; + } + catch + { + return false; + } + } + + private void ColorTablePairHighlightButton(int pairIdx, bool disabled) + { + ImUtf8.IconButton(FontAwesomeIcon.Crosshairs, + "Highlight this pair of rows on your character, if possible.\n\nHighlight colors can be configured in Penumbra's settings."u8, + ImGui.GetFrameHeight() * Vector2.One, disabled || _colorTablePreviewers.Count == 0); + + if (ImGui.IsItemHovered()) + HighlightColorTablePair(pairIdx); + else if (_highlightedColorTablePair == pairIdx) + CancelColorTableHighlight(); + } + + private void ColorTableRowHighlightButton(int rowIdx, bool disabled) + { + ImUtf8.IconButton(FontAwesomeIcon.Crosshairs, + "Highlight this row on your character, if possible.\n\nHighlight colors can be configured in Penumbra's settings."u8, + ImGui.GetFrameHeight() * Vector2.One, disabled || _colorTablePreviewers.Count == 0); + + if (ImGui.IsItemHovered()) + HighlightColorTableRow(rowIdx); + else if (_highlightedColorTableRow == rowIdx) + CancelColorTableHighlight(); + } + + private static void CtBlendRect(Vector2 rcMin, Vector2 rcMax, uint topColor, uint bottomColor) + { + var style = ImGui.GetStyle(); + var frameRounding = style.FrameRounding; + var frameThickness = style.FrameBorderSize; + var borderColor = ImGui.GetColorU32(ImGuiCol.Border); + var drawList = ImGui.GetWindowDrawList(); + if (topColor == bottomColor) + { + drawList.AddRectFilled(rcMin, rcMax, topColor, frameRounding, ImDrawFlags.RoundCornersDefault); + } + else + { + drawList.AddRectFilled( + rcMin, rcMax with { Y = float.Lerp(rcMin.Y, rcMax.Y, 1.0f / 3) }, + topColor, frameRounding, ImDrawFlags.RoundCornersTopLeft | ImDrawFlags.RoundCornersTopRight); + drawList.AddRectFilledMultiColor( + rcMin with { Y = float.Lerp(rcMin.Y, rcMax.Y, 1.0f / 3) }, + rcMax with { Y = float.Lerp(rcMin.Y, rcMax.Y, 2.0f / 3) }, + topColor, topColor, bottomColor, bottomColor); + drawList.AddRectFilled( + rcMin with { Y = float.Lerp(rcMin.Y, rcMax.Y, 2.0f / 3) }, rcMax, + bottomColor, frameRounding, ImDrawFlags.RoundCornersBottomLeft | ImDrawFlags.RoundCornersBottomRight); + } + + drawList.AddRect(rcMin, rcMax, borderColor, frameRounding, ImDrawFlags.RoundCornersDefault, frameThickness); + } + + private static bool CtColorPicker(ReadOnlySpan label, ReadOnlySpan description, HalfColor current, Action setter, + ReadOnlySpan letter = default) + { + var ret = false; + var inputSqrt = PseudoSqrtRgb((Vector3)current); + var tmp = inputSqrt; + if (ImUtf8.ColorEdit(label, ref tmp, + ImGuiColorEditFlags.NoInputs + | ImGuiColorEditFlags.DisplayRgb + | ImGuiColorEditFlags.InputRgb + | ImGuiColorEditFlags.NoTooltip + | ImGuiColorEditFlags.Hdr) + && tmp != inputSqrt) + { + setter((HalfColor)PseudoSquareRgb(tmp)); + ret = true; + } + + if (letter.Length > 0 && ImGui.IsItemVisible()) + { + var textSize = ImUtf8.CalcTextSize(letter); + var center = ImGui.GetItemRectMin() + (ImGui.GetItemRectSize() - textSize) / 2; + var textColor = inputSqrt.LengthSquared() < 0.25f ? 0x80FFFFFFu : 0x80000000u; + ImGui.GetWindowDrawList().AddText(letter, center, textColor); + } + + ImUtf8.HoverTooltip(ImGuiHoveredFlags.AllowWhenDisabled, description); + + return ret; + } + + private static void CtColorPicker(ReadOnlySpan label, ReadOnlySpan description, HalfColor? current, + ReadOnlySpan letter = default) + { + if (current.HasValue) + { + CtColorPicker(label, description, current.Value, Nop, letter); + } + else + { + var tmp = Vector4.Zero; + ImUtf8.ColorEdit(label, ref tmp, + ImGuiColorEditFlags.NoInputs + | ImGuiColorEditFlags.DisplayRgb + | ImGuiColorEditFlags.InputRgb + | ImGuiColorEditFlags.NoTooltip + | ImGuiColorEditFlags.Hdr + | ImGuiColorEditFlags.AlphaPreview); + + if (letter.Length > 0 && ImGui.IsItemVisible()) + { + var textSize = ImUtf8.CalcTextSize(letter); + var center = ImGui.GetItemRectMin() + (ImGui.GetItemRectSize() - textSize) / 2; + ImGui.GetWindowDrawList().AddText(letter, center, 0x80000000u); + } + + ImUtf8.HoverTooltip(ImGuiHoveredFlags.AllowWhenDisabled, description); + } + } + + private static bool CtApplyStainCheckbox(ReadOnlySpan label, ReadOnlySpan description, bool current, Action setter) + { + var tmp = current; + var result = ApplyStainCheckbox.Draw(label, ref tmp); + ImUtf8.HoverTooltip(ImGuiHoveredFlags.AllowWhenDisabled, description); + if (!result || tmp == current) + return false; + + setter(tmp); + return true; + } + + private static bool CtDragHalf(ReadOnlySpan label, ReadOnlySpan description, Half value, ReadOnlySpan format, float min, + float max, float speed, Action setter) + { + var tmp = (float)value; + var result = ImUtf8.DragScalar(label, ref tmp, format, min, max, speed); + ImUtf8.HoverTooltip(ImGuiHoveredFlags.AllowWhenDisabled, description); + if (!result) + return false; + + var newValue = (Half)tmp; + if (newValue == value) + return false; + + setter(newValue); + return true; + } + + private static bool CtDragHalf(ReadOnlySpan label, ReadOnlySpan description, ref Half value, ReadOnlySpan format, + float min, float max, float speed) + { + var tmp = (float)value; + var result = ImUtf8.DragScalar(label, ref tmp, format, min, max, speed); + ImUtf8.HoverTooltip(ImGuiHoveredFlags.AllowWhenDisabled, description); + if (!result) + return false; + + var newValue = (Half)tmp; + if (newValue == value) + return false; + + value = newValue; + return true; + } + + private static void CtDragHalf(ReadOnlySpan label, ReadOnlySpan description, Half? value, ReadOnlySpan format) + { + using var _ = ImRaii.Disabled(); + var valueOrDefault = value ?? Half.Zero; + var floatValue = (float)valueOrDefault; + CtDragHalf(label, description, valueOrDefault, value.HasValue ? format : "-"u8, floatValue, floatValue, 0.0f, Nop); + } + + private static bool CtDragScalar(ReadOnlySpan label, ReadOnlySpan description, T value, ReadOnlySpan format, T min, + T max, float speed, Action setter) where T : unmanaged, INumber + { + var tmp = value; + var result = ImUtf8.DragScalar(label, ref tmp, format, min, max, speed); + ImUtf8.HoverTooltip(ImGuiHoveredFlags.AllowWhenDisabled, description); + if (!result || tmp == value) + return false; + + setter(tmp); + return true; + } + + private static bool CtDragScalar(ReadOnlySpan label, ReadOnlySpan description, ref T value, ReadOnlySpan format, T min, + T max, float speed) where T : unmanaged, INumber + { + var tmp = value; + var result = ImUtf8.DragScalar(label, ref tmp, format, min, max, speed); + ImUtf8.HoverTooltip(ImGuiHoveredFlags.AllowWhenDisabled, description); + if (!result || tmp == value) + return false; + + value = tmp; + return true; + } + + private static void CtDragScalar(ReadOnlySpan label, ReadOnlySpan description, T? value, ReadOnlySpan format) + where T : unmanaged, INumber + { + using var _ = ImRaii.Disabled(); + var valueOrDefault = value ?? T.Zero; + CtDragScalar(label, description, valueOrDefault, value.HasValue ? format : "-"u8, valueOrDefault, valueOrDefault, 0.0f, Nop); + } + + private bool CtTileIndexPicker(ReadOnlySpan label, ReadOnlySpan description, ushort value, bool compact, Action setter) + { + if (!_materialTemplatePickers.DrawTileIndexPicker(label, description, ref value, compact)) + return false; + + setter(value); + return true; + } + + private bool CtSphereMapIndexPicker(ReadOnlySpan label, ReadOnlySpan description, ushort value, bool compact, + Action setter) + { + if (!_materialTemplatePickers.DrawSphereMapIndexPicker(label, description, ref value, compact)) + return false; + + setter(value); + return true; + } + + private bool CtTileTransformMatrix(HalfMatrix2x2 value, float floatSize, bool twoRowLayout, Action setter) + { + var ret = false; + if (_config.EditRawTileTransforms) + { + var tmp = value; + ImGui.SetNextItemWidth(floatSize); + ret |= CtDragHalf("##TileTransformUU"u8, "Tile Repeat U"u8, ref tmp.UU, "%.2f"u8, HalfMinValue, HalfMaxValue, 0.1f); + ImUtf8.SameLineInner(); + ImGui.SetNextItemWidth(floatSize); + ret |= CtDragHalf("##TileTransformVV"u8, "Tile Repeat V"u8, ref tmp.VV, "%.2f"u8, HalfMinValue, HalfMaxValue, 0.1f); + if (!twoRowLayout) + ImUtf8.SameLineInner(); + ImGui.SetNextItemWidth(floatSize); + ret |= CtDragHalf("##TileTransformUV"u8, "Tile Skew U"u8, ref tmp.UV, "%.2f"u8, HalfMinValue, HalfMaxValue, 0.1f); + ImUtf8.SameLineInner(); + ImGui.SetNextItemWidth(floatSize); + ret |= CtDragHalf("##TileTransformVU"u8, "Tile Skew V"u8, ref tmp.VU, "%.2f"u8, HalfMinValue, HalfMaxValue, 0.1f); + if (!ret || tmp == value) + return false; + + setter(tmp); + } + else + { + value.Decompose(out var scale, out var rotation, out var shear); + rotation *= 180.0f / MathF.PI; + shear *= 180.0f / MathF.PI; + ImGui.SetNextItemWidth(floatSize); + var scaleXChanged = CtDragScalar("##TileScaleU"u8, "Tile Scale U"u8, ref scale.X, "%.2f"u8, HalfMinValue, HalfMaxValue, 0.1f); + var activated = ImGui.IsItemActivated(); + var deactivated = ImGui.IsItemDeactivated(); + ImUtf8.SameLineInner(); + ImGui.SetNextItemWidth(floatSize); + var scaleYChanged = CtDragScalar("##TileScaleV"u8, "Tile Scale V"u8, ref scale.Y, "%.2f"u8, HalfMinValue, HalfMaxValue, 0.1f); + activated |= ImGui.IsItemActivated(); + deactivated |= ImGui.IsItemDeactivated(); + if (!twoRowLayout) + ImUtf8.SameLineInner(); + ImGui.SetNextItemWidth(floatSize); + var rotationChanged = CtDragScalar("##TileRotation"u8, "Tile Rotation"u8, ref rotation, "%.0f°"u8, -180.0f, 180.0f, 1.0f); + activated |= ImGui.IsItemActivated(); + deactivated |= ImGui.IsItemDeactivated(); + ImUtf8.SameLineInner(); + ImGui.SetNextItemWidth(floatSize); + var shearChanged = CtDragScalar("##TileShear"u8, "Tile Shear"u8, ref shear, "%.0f°"u8, -90.0f, 90.0f, 1.0f); + activated |= ImGui.IsItemActivated(); + deactivated |= ImGui.IsItemDeactivated(); + if (deactivated) + _pinnedTileTransform = null; + else if (activated) + _pinnedTileTransform = (scale, rotation, shear); + ret = scaleXChanged | scaleYChanged | rotationChanged | shearChanged; + if (!ret) + return false; + + if (_pinnedTileTransform.HasValue) + { + var (pinScale, pinRotation, pinShear) = _pinnedTileTransform.Value; + if (!scaleXChanged) + scale.X = pinScale.X; + if (!scaleYChanged) + scale.Y = pinScale.Y; + if (!rotationChanged) + rotation = pinRotation; + if (!shearChanged) + shear = pinShear; + } + + var newValue = HalfMatrix2x2.Compose(scale, rotation * MathF.PI / 180.0f, shear * MathF.PI / 180.0f); + if (newValue == value) + return false; + + setter(newValue); + } + + return true; + } + + /// For use as setter of read-only fields. + private static void Nop(T _) + { } + + // Functions to deal with squared RGB values without making negatives useless. + + internal static float PseudoSquareRgb(float x) + => x < 0.0f ? -(x * x) : x * x; + + internal static Vector3 PseudoSquareRgb(Vector3 vec) + => new(PseudoSquareRgb(vec.X), PseudoSquareRgb(vec.Y), PseudoSquareRgb(vec.Z)); + + internal static Vector4 PseudoSquareRgb(Vector4 vec) + => new(PseudoSquareRgb(vec.X), PseudoSquareRgb(vec.Y), PseudoSquareRgb(vec.Z), vec.W); + + internal static float PseudoSqrtRgb(float x) + => x < 0.0f ? -MathF.Sqrt(-x) : MathF.Sqrt(x); + + public static Vector3 PseudoSqrtRgb(Vector3 vec) + => new(PseudoSqrtRgb(vec.X), PseudoSqrtRgb(vec.Y), PseudoSqrtRgb(vec.Z)); + + internal static Vector4 PseudoSqrtRgb(Vector4 vec) + => new(PseudoSqrtRgb(vec.X), PseudoSqrtRgb(vec.Y), PseudoSqrtRgb(vec.Z), vec.W); +} diff --git a/Penumbra/UI/AdvancedWindow/Materials/MtrlTab.Constants.cs b/Penumbra/UI/AdvancedWindow/Materials/MtrlTab.Constants.cs new file mode 100644 index 00000000..4ad6968b --- /dev/null +++ b/Penumbra/UI/AdvancedWindow/Materials/MtrlTab.Constants.cs @@ -0,0 +1,279 @@ +using Dalamud.Interface; +using Dalamud.Bindings.ImGui; +using OtterGui; +using OtterGui.Classes; +using OtterGui.Extensions; +using OtterGui.Raii; +using OtterGui.Text; +using OtterGui.Text.Widget.Editors; +using Penumbra.GameData.Files.ShaderStructs; +using static Penumbra.GameData.Files.ShpkFile; + +namespace Penumbra.UI.AdvancedWindow.Materials; + +public partial class MtrlTab +{ + private const float MaterialConstantSize = 250.0f; + + public readonly + List<(string Header, List<(string Label, int ConstantIndex, Range Slice, string Description, bool MonoFont, IEditor Editor)> + Constants)> Constants = new(16); + + private void UpdateConstants() + { + static List FindOrAddGroup(List<(string, List)> groups, string name) + { + foreach (var (groupName, group) in groups) + { + if (string.Equals(name, groupName, StringComparison.Ordinal)) + return group; + } + + var newGroup = new List(16); + groups.Add((name, newGroup)); + return newGroup; + } + + Constants.Clear(); + string mpPrefix; + if (_associatedShpk == null) + { + mpPrefix = MaterialParamsConstantName.Value!; + var fcGroup = FindOrAddGroup(Constants, "Further Constants"); + foreach (var (constant, index) in Mtrl.ShaderPackage.Constants.WithIndex()) + { + var values = Mtrl.GetConstantValue(constant); + for (var i = 0; i < values.Length; i += 4) + { + fcGroup.Add(($"0x{constant.Id:X8}", index, i..Math.Min(i + 4, values.Length), string.Empty, true, + ConstantEditors.DefaultFloat)); + } + } + } + else + { + mpPrefix = _associatedShpk.GetConstantById(MaterialParamsConstantId)?.Name ?? MaterialParamsConstantName.Value!; + var autoNameMaxLength = Math.Max(Names.LongestKnownNameLength, mpPrefix.Length + 8); + foreach (var shpkConstant in _associatedShpk.MaterialParams) + { + var name = Names.KnownNames.TryResolve(shpkConstant.Id); + var constant = Mtrl.GetOrAddConstant(shpkConstant.Id, _associatedShpk, out var constantIndex); + var values = Mtrl.GetConstantValue(constant); + var handledElements = new IndexSet(values.Length, false); + + var dkData = TryGetShpkDevkitData("Constants", shpkConstant.Id, true); + if (dkData != null) + foreach (var dkConstant in dkData) + { + var offset = (int)dkConstant.EffectiveByteOffset; + var length = values.Length - offset; + var constantSize = dkConstant.EffectiveByteSize; + if (constantSize.HasValue) + length = Math.Min(length, (int)constantSize.Value); + if (length <= 0) + continue; + + var editor = dkConstant.CreateEditor(_materialTemplatePickers); + if (editor != null) + FindOrAddGroup(Constants, dkConstant.Group.Length > 0 ? dkConstant.Group : "Further Constants") + .Add((dkConstant.Label, constantIndex, offset..(offset + length), dkConstant.Description, false, editor)); + handledElements.AddRange(offset, length); + } + + if (handledElements.IsFull) + continue; + + var fcGroup = FindOrAddGroup(Constants, "Further Constants"); + foreach (var (start, end) in handledElements.Ranges(complement: true)) + { + if (start == 0 && end == values.Length && end - start <= 16) + if (name.Value != null) + { + fcGroup.Add(( + $"{name.Value.PadRight(autoNameMaxLength)} (0x{shpkConstant.Id:X8})", + constantIndex, 0..values.Length, string.Empty, true, DefaultConstantEditorFor(name))); + continue; + } + + if ((shpkConstant.ByteOffset & 0x3) == 0 && (shpkConstant.ByteSize & 0x3) == 0) + { + var offset = shpkConstant.ByteOffset; + for (int i = (start & ~0xF) - (offset & 0xF), j = offset >> 4; i < end; i += 16, ++j) + { + var rangeStart = Math.Max(i, start); + var rangeEnd = Math.Min(i + 16, end); + if (rangeEnd > rangeStart) + { + var autoName = + $"{mpPrefix}[{j,2:D}]{VectorSwizzle(((offset + rangeStart) & 0xF) >> 2, ((offset + rangeEnd - 1) & 0xF) >> 2)}"; + fcGroup.Add(( + $"{autoName.PadRight(autoNameMaxLength)} (0x{shpkConstant.Id:X8})", + constantIndex, rangeStart..rangeEnd, string.Empty, true, DefaultConstantEditorFor(name))); + } + } + } + else + { + for (var i = start; i < end; i += 16) + { + fcGroup.Add(($"{"???".PadRight(autoNameMaxLength)} (0x{shpkConstant.Id:X8})", constantIndex, + i..Math.Min(i + 16, end), string.Empty, true, + DefaultConstantEditorFor(name))); + } + } + } + } + } + + Constants.RemoveAll(group => group.Constants.Count == 0); + Constants.Sort((x, y) => + { + if (string.Equals(x.Header, "Further Constants", StringComparison.Ordinal)) + return 1; + if (string.Equals(y.Header, "Further Constants", StringComparison.Ordinal)) + return -1; + + return string.Compare(x.Header, y.Header, StringComparison.Ordinal); + }); + // HACK the Replace makes w appear after xyz, for the cbuffer-location-based naming scheme, and cbuffer-location names appear after known variable names + foreach (var (_, group) in Constants) + { + group.Sort((x, y) => string.CompareOrdinal( + x.MonoFont ? x.Label.Replace("].w", "].{").Replace(mpPrefix, "}_MaterialParameter") : x.Label, + y.MonoFont ? y.Label.Replace("].w", "].{").Replace(mpPrefix, "}_MaterialParameter") : y.Label)); + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private IEditor DefaultConstantEditorFor(Name name) + => ConstantEditors.DefaultFor(name, _materialTemplatePickers); + + private bool DrawConstantsSection(bool disabled) + { + if (Constants.Count == 0) + return false; + + ImGui.Dummy(new Vector2(ImGui.GetTextLineHeight() / 2)); + if (!ImGui.CollapsingHeader("Material Constants")) + return false; + + using var _ = ImRaii.PushId("MaterialConstants"); + + var ret = false; + foreach (var (header, group) in Constants) + { + using var t = ImRaii.TreeNode(header, ImGuiTreeNodeFlags.DefaultOpen); + if (!t) + continue; + + foreach (var (label, constantIndex, slice, description, monoFont, editor) in group) + { + var constant = Mtrl.ShaderPackage.Constants[constantIndex]; + var buffer = Mtrl.GetConstantValue(constant); + if (buffer.Length > 0) + { + using var id = ImRaii.PushId($"##{constant.Id:X8}:{slice.Start}"); + ImGui.SetNextItemWidth(MaterialConstantSize * UiHelpers.Scale); + if (editor.Draw(buffer[slice], disabled)) + { + ret = true; + SetMaterialParameter(constant.Id, slice.Start, buffer[slice]); + } + + var shpkConstant = _associatedShpk?.GetMaterialParamById(constant.Id); + var defaultConstantValue = shpkConstant.HasValue ? _associatedShpk!.GetMaterialParamDefault(shpkConstant.Value) : []; + var defaultValue = IsValid(slice, defaultConstantValue.Length) ? defaultConstantValue[slice] : []; + var canReset = _associatedShpk?.MaterialParamsDefaults != null + ? defaultValue.Length > 0 && !defaultValue.SequenceEqual(buffer[slice]) + : buffer[slice].ContainsAnyExcept((byte)0); + ImUtf8.SameLineInner(); + if (ImGuiUtil.DrawDisabledButton(FontAwesomeIcon.Backspace.ToIconString(), ImGui.GetFrameHeight() * Vector2.One, + "Reset this constant to its default value.\n\nHold Ctrl to unlock.", !ImGui.GetIO().KeyCtrl || !canReset, true)) + { + ret = true; + if (defaultValue.Length > 0) + defaultValue.CopyTo(buffer[slice]); + else + buffer[slice].Clear(); + + SetMaterialParameter(constant.Id, slice.Start, buffer[slice]); + } + + ImGui.SameLine(); + using var font = ImRaii.PushFont(UiBuilder.MonoFont, monoFont); + if (description.Length > 0) + ImGuiUtil.LabeledHelpMarker(label, description); + else + ImGui.TextUnformatted(label); + } + } + } + + return ret; + } + + private static bool IsValid(Range range, int length) + { + var start = range.Start.GetOffset(length); + var end = range.End.GetOffset(length); + return start >= 0 && start <= length && end >= start && end <= length; + } + + internal static string? MaterialParamName(bool componentOnly, int offset) + { + if (offset < 0) + return null; + + return (componentOnly, offset & 0x3) switch + { + (true, 0) => "x", + (true, 1) => "y", + (true, 2) => "z", + (true, 3) => "w", + (false, 0) => $"[{offset >> 2:D2}].x", + (false, 1) => $"[{offset >> 2:D2}].y", + (false, 2) => $"[{offset >> 2:D2}].z", + (false, 3) => $"[{offset >> 2:D2}].w", + _ => null, + }; + } + + /// Returned string is 4 chars long. + private static string VectorSwizzle(int firstComponent, int lastComponent) + => (firstComponent, lastComponent) switch + { + (0, 4) => " ", + (0, 0) => ".x ", + (0, 1) => ".xy ", + (0, 2) => ".xyz", + (0, 3) => " ", + (1, 1) => ".y ", + (1, 2) => ".yz ", + (1, 3) => ".yzw", + (2, 2) => ".z ", + (2, 3) => ".zw ", + (3, 3) => ".w ", + _ => string.Empty, + }; + + internal static (string? Name, bool ComponentOnly) MaterialParamRangeName(string prefix, int valueOffset, int valueLength) + { + if (valueLength == 0 || valueOffset < 0) + return (null, false); + + var firstVector = valueOffset >> 2; + var lastVector = (valueOffset + valueLength - 1) >> 2; + var firstComponent = valueOffset & 0x3; + var lastComponent = (valueOffset + valueLength - 1) & 0x3; + if (firstVector == lastVector) + return ($"{prefix}[{firstVector}]{VectorSwizzle(firstComponent, lastComponent)}", true); + + var sb = new StringBuilder(128); + sb.Append($"{prefix}[{firstVector}]{VectorSwizzle(firstComponent, 3).TrimEnd()}"); + for (var i = firstVector + 1; i < lastVector; ++i) + sb.Append($", [{i}]"); + + sb.Append($", [{lastVector}]{VectorSwizzle(0, lastComponent)}"); + return (sb.ToString(), false); + } +} diff --git a/Penumbra/UI/AdvancedWindow/Materials/MtrlTab.Devkit.cs b/Penumbra/UI/AdvancedWindow/Materials/MtrlTab.Devkit.cs new file mode 100644 index 00000000..26fe3dcb --- /dev/null +++ b/Penumbra/UI/AdvancedWindow/Materials/MtrlTab.Devkit.cs @@ -0,0 +1,252 @@ +using JetBrains.Annotations; +using Newtonsoft.Json.Linq; +using OtterGui.Text.Widget.Editors; +using Penumbra.String.Classes; +using static Penumbra.GameData.Files.ShpkFile; + +namespace Penumbra.UI.AdvancedWindow.Materials; + +public partial class MtrlTab +{ + private JObject? TryLoadShpkDevkit(string shpkBaseName, out string devkitPathName) + { + try + { + if (!Utf8GamePath.FromString("penumbra/shpk_devkit/" + shpkBaseName + ".json", out var devkitPath)) + throw new Exception("Could not assemble ShPk dev-kit path."); + + var devkitFullPath = _edit.FindBestMatch(devkitPath); + if (!devkitFullPath.IsRooted) + throw new Exception("Could not resolve ShPk dev-kit path."); + + devkitPathName = devkitFullPath.FullName; + return JObject.Parse(File.ReadAllText(devkitFullPath.FullName)); + } + catch + { + devkitPathName = string.Empty; + return null; + } + } + + private T? TryGetShpkDevkitData(string category, uint? id, bool mayVary) where T : class + => TryGetShpkDevkitData(_associatedShpkDevkit, _loadedShpkDevkitPathName, category, id, mayVary) + ?? TryGetShpkDevkitData(_associatedBaseDevkit, _loadedBaseDevkitPathName, category, id, mayVary); + + private T? TryGetShpkDevkitData(JObject? devkit, string devkitPathName, string category, uint? id, bool mayVary) where T : class + { + if (devkit == null) + return null; + + try + { + var data = devkit[category]; + if (id.HasValue) + data = data?[id.Value.ToString()]; + + if (mayVary && (data as JObject)?["Vary"] != null) + { + var selector = BuildSelector(data!["Vary"]! + .Select(key => (uint)key) + .Select(key => Mtrl.GetShaderKey(key)?.Value ?? _associatedShpk!.GetMaterialKeyById(key)!.Value.DefaultValue)); + var index = (int)data["Selectors"]![selector.ToString()]!; + data = data["Items"]![index]; + } + + return data?.ToObject(typeof(T)) as T; + } + catch (Exception e) + { + // Some element in the JSON was undefined or invalid (wrong type, key that doesn't exist in the ShPk, index out of range, …) + Penumbra.Log.Error($"Error while traversing the ShPk dev-kit file at {devkitPathName}: {e}"); + return null; + } + } + + [UsedImplicitly] + private sealed class DevkitShaderKeyValue + { + public string Label = string.Empty; + public string Description = string.Empty; + } + + [UsedImplicitly] + private sealed class DevkitShaderKey + { + public string Label = string.Empty; + public string Description = string.Empty; + public Dictionary Values = []; + } + + [UsedImplicitly] + private sealed class DevkitSampler + { + public string Label = string.Empty; + public string Description = string.Empty; + public string DefaultTexture = string.Empty; + } + + private enum DevkitConstantType + { + Hidden = -1, + Float = 0, + + /// Integer encoded as a float. + Integer = 1, + Color = 2, + Enum = 3, + + /// Native integer. + Int32 = 4, + Int32Enum = 5, + Int8 = 6, + Int8Enum = 7, + Int16 = 8, + Int16Enum = 9, + Int64 = 10, + Int64Enum = 11, + Half = 12, + Double = 13, + TileIndex = 14, + SphereMapIndex = 15, + } + + [UsedImplicitly] + private sealed class DevkitConstantValue + { + public string Label = string.Empty; + public string Description = string.Empty; + public double Value = 0; + } + + [UsedImplicitly] + private sealed class DevkitConstant + { + public uint Offset = 0; + public uint? Length = null; + public uint? ByteOffset = null; + public uint? ByteSize = null; + public string Group = string.Empty; + public string Label = string.Empty; + public string Description = string.Empty; + public DevkitConstantType Type = DevkitConstantType.Float; + + public float? Minimum = null; + public float? Maximum = null; + public float Step = 0.0f; + public float StepFast = 0.0f; + public float? Speed = null; + public float RelativeSpeed = 0.0f; + public float Exponent = 1.0f; + public float Factor = 1.0f; + public float Bias = 0.0f; + public byte Precision = 3; + public bool Hex = false; + public bool Slider = true; + public bool Drag = true; + public string Unit = string.Empty; + + public bool SquaredRgb = false; + public bool Clamped = false; + + public DevkitConstantValue[] Values = []; + + public uint EffectiveByteOffset + => ByteOffset ?? Offset * ValueSize; + + public uint? EffectiveByteSize + => ByteSize ?? Length * ValueSize; + + public unsafe uint ValueSize + => Type switch + { + DevkitConstantType.Hidden => sizeof(byte), + DevkitConstantType.Float => sizeof(float), + DevkitConstantType.Integer => sizeof(float), + DevkitConstantType.Color => sizeof(float), + DevkitConstantType.Enum => sizeof(float), + DevkitConstantType.Int32 => sizeof(int), + DevkitConstantType.Int32Enum => sizeof(int), + DevkitConstantType.Int8 => sizeof(byte), + DevkitConstantType.Int8Enum => sizeof(byte), + DevkitConstantType.Int16 => sizeof(short), + DevkitConstantType.Int16Enum => sizeof(short), + DevkitConstantType.Int64 => sizeof(long), + DevkitConstantType.Int64Enum => sizeof(long), + DevkitConstantType.Half => (uint)sizeof(Half), + DevkitConstantType.Double => sizeof(double), + DevkitConstantType.TileIndex => sizeof(float), + DevkitConstantType.SphereMapIndex => sizeof(float), + _ => sizeof(float), + }; + + public IEditor? CreateEditor(MaterialTemplatePickers? materialTemplatePickers) + => Type switch + { + DevkitConstantType.Hidden => null, + DevkitConstantType.Float => CreateFloatEditor().AsByteEditor(), + DevkitConstantType.Integer => CreateIntegerEditor().IntAsFloatEditor().AsByteEditor(), + DevkitConstantType.Color => ColorEditor.Get(!Clamped).WithExponent(SquaredRgb ? 2.0f : 1.0f).AsByteEditor(), + DevkitConstantType.Enum => CreateEnumEditor(float.CreateSaturating).AsByteEditor(), + DevkitConstantType.Int32 => CreateIntegerEditor().AsByteEditor(), + DevkitConstantType.Int32Enum => CreateEnumEditor(ToInteger).AsByteEditor(), + DevkitConstantType.Int8 => CreateIntegerEditor(), + DevkitConstantType.Int8Enum => CreateEnumEditor(ToInteger), + DevkitConstantType.Int16 => CreateIntegerEditor().AsByteEditor(), + DevkitConstantType.Int16Enum => CreateEnumEditor(ToInteger).AsByteEditor(), + DevkitConstantType.Int64 => CreateIntegerEditor().AsByteEditor(), + DevkitConstantType.Int64Enum => CreateEnumEditor(ToInteger).AsByteEditor(), + DevkitConstantType.Half => CreateFloatEditor().AsByteEditor(), + DevkitConstantType.Double => CreateFloatEditor().AsByteEditor(), + DevkitConstantType.TileIndex => materialTemplatePickers?.TileIndexPicker ?? ConstantEditors.DefaultIntAsFloat, + DevkitConstantType.SphereMapIndex => materialTemplatePickers?.SphereMapIndexPicker ?? ConstantEditors.DefaultIntAsFloat, + _ => ConstantEditors.DefaultFloat, + }; + + private IEditor CreateIntegerEditor() + where T : unmanaged, INumber + => ((Drag || Slider) && !Hex + ? Drag + ? (IEditor)DragEditor.CreateInteger(ToInteger(Minimum), ToInteger(Maximum), Speed ?? 0.25f, RelativeSpeed, + Unit, 0) + : SliderEditor.CreateInteger(ToInteger(Minimum) ?? default, ToInteger(Maximum) ?? default, Unit, 0) + : InputEditor.CreateInteger(ToInteger(Minimum), ToInteger(Maximum), ToInteger(Step), ToInteger(StepFast), + Hex, Unit, 0)) + .WithFactorAndBias(ToInteger(Factor), ToInteger(Bias)); + + private IEditor CreateFloatEditor() + where T : unmanaged, INumber, IPowerFunctions + => (Drag || Slider + ? Drag + ? (IEditor)DragEditor.CreateFloat(ToFloat(Minimum), ToFloat(Maximum), Speed ?? 0.1f, RelativeSpeed, + Precision, Unit, 0) + : SliderEditor.CreateFloat(ToFloat(Minimum) ?? default, ToFloat(Maximum) ?? default, Precision, Unit, 0) + : InputEditor.CreateFloat(ToFloat(Minimum), ToFloat(Maximum), T.CreateSaturating(Step), + T.CreateSaturating(StepFast), Precision, Unit, 0)) + .WithExponent(T.CreateSaturating(Exponent)) + .WithFactorAndBias(T.CreateSaturating(Factor), T.CreateSaturating(Bias)); + + private EnumEditor CreateEnumEditor(Func convertValue) + where T : unmanaged, IUtf8SpanFormattable, IEqualityOperators + => new(Array.ConvertAll(Values, value => (ToUtf8(value.Label), convertValue(value.Value), ToUtf8(value.Description)))); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static T ToInteger(float value) where T : struct, INumberBase + => T.CreateSaturating(MathF.Round(value)); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static T ToInteger(double value) where T : struct, INumberBase + => T.CreateSaturating(Math.Round(value)); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static T? ToInteger(float? value) where T : struct, INumberBase + => value.HasValue ? ToInteger(value.Value) : null; + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static T? ToFloat(float? value) where T : struct, INumberBase + => value.HasValue ? T.CreateSaturating(value.Value) : null; + + private static ReadOnlyMemory ToUtf8(string value) + => Encoding.UTF8.GetBytes(value); + } +} diff --git a/Penumbra/UI/AdvancedWindow/Materials/MtrlTab.LegacyColorTable.cs b/Penumbra/UI/AdvancedWindow/Materials/MtrlTab.LegacyColorTable.cs new file mode 100644 index 00000000..e75cd633 --- /dev/null +++ b/Penumbra/UI/AdvancedWindow/Materials/MtrlTab.LegacyColorTable.cs @@ -0,0 +1,374 @@ +using Dalamud.Interface; +using Dalamud.Interface.Utility.Raii; +using Dalamud.Bindings.ImGui; +using OtterGui; +using OtterGui.Text; +using Penumbra.GameData.Files.MaterialStructs; +using Penumbra.GameData.Files.StainMapStructs; +using Penumbra.Services; + +namespace Penumbra.UI.AdvancedWindow.Materials; + +public partial class MtrlTab +{ + private const float LegacyColorTableFloatSize = 65.0f; + private const float LegacyColorTablePercentageSize = 50.0f; + private const float LegacyColorTableIntegerSize = 40.0f; + private const float LegacyColorTableByteSize = 25.0f; + + private bool DrawLegacyColorTable(LegacyColorTable table, LegacyColorDyeTable? dyeTable, bool disabled) + { + using var imTable = ImUtf8.Table("##ColorTable"u8, dyeTable != null ? 10 : 8, + ImGuiTableFlags.SizingFixedFit | ImGuiTableFlags.RowBg | ImGuiTableFlags.BordersInnerV); + if (!imTable) + return false; + + DrawLegacyColorTableHeader(dyeTable != null); + + var ret = false; + for (var i = 0; i < LegacyColorTable.NumRows; ++i) + { + if (DrawLegacyColorTableRow(table, dyeTable, i, disabled)) + { + UpdateColorTableRowPreview(i); + ret = true; + } + + ImGui.TableNextRow(); + } + + return ret; + } + + private bool DrawLegacyColorTable(ColorTable table, ColorDyeTable? dyeTable, bool disabled) + { + using var imTable = ImUtf8.Table("##ColorTable"u8, dyeTable != null ? 10 : 8, + ImGuiTableFlags.SizingFixedFit | ImGuiTableFlags.RowBg | ImGuiTableFlags.BordersInnerV); + if (!imTable) + return false; + + DrawLegacyColorTableHeader(dyeTable != null); + + var ret = false; + for (var i = 0; i < ColorTable.NumRows; ++i) + { + if (DrawLegacyColorTableRow(table, dyeTable, i, disabled)) + { + UpdateColorTableRowPreview(i); + ret = true; + } + + ImGui.TableNextRow(); + } + + return ret; + } + + private static void DrawLegacyColorTableHeader(bool hasDyeTable) + { + ImGui.TableNextColumn(); + ImUtf8.TableHeader(""u8); + ImGui.TableNextColumn(); + ImUtf8.TableHeader("Row"u8); + ImGui.TableNextColumn(); + ImUtf8.TableHeader("Diffuse"u8); + ImGui.TableNextColumn(); + ImUtf8.TableHeader("Specular"u8); + ImGui.TableNextColumn(); + ImUtf8.TableHeader("Emissive"u8); + ImGui.TableNextColumn(); + ImUtf8.TableHeader("Gloss"u8); + ImGui.TableNextColumn(); + ImUtf8.TableHeader("Tile"u8); + ImGui.TableNextColumn(); + ImUtf8.TableHeader("Repeat / Skew"u8); + if (hasDyeTable) + { + ImGui.TableNextColumn(); + ImUtf8.TableHeader("Dye"u8); + ImGui.TableNextColumn(); + ImUtf8.TableHeader("Dye Preview"u8); + } + } + + private bool DrawLegacyColorTableRow(LegacyColorTable table, LegacyColorDyeTable? dyeTable, int rowIdx, bool disabled) + { + using var id = ImRaii.PushId(rowIdx); + ref var row = ref table[rowIdx]; + var dye = dyeTable != null ? dyeTable[rowIdx] : default; + var floatSize = LegacyColorTableFloatSize * UiHelpers.Scale; + var pctSize = LegacyColorTablePercentageSize * UiHelpers.Scale; + var intSize = LegacyColorTableIntegerSize * UiHelpers.Scale; + ImGui.TableNextColumn(); + ColorTableCopyClipboardButton(rowIdx); + ImUtf8.SameLineInner(); + var ret = ColorTablePasteFromClipboardButton(rowIdx, disabled); + ImUtf8.SameLineInner(); + ColorTableRowHighlightButton(rowIdx, disabled); + + ImGui.TableNextColumn(); + using (ImRaii.PushFont(UiBuilder.MonoFont)) + { + ImUtf8.Text($"{(rowIdx >> 1) + 1,2:D}{"AB"[rowIdx & 1]}"); + } + + ImGui.TableNextColumn(); + using var dis = ImRaii.Disabled(disabled); + ret |= CtColorPicker("##Diffuse"u8, "Diffuse Color"u8, row.DiffuseColor, + c => table[rowIdx].DiffuseColor = c); + if (dyeTable != null) + { + ImUtf8.SameLineInner(); + ret |= CtApplyStainCheckbox("##dyeDiffuse"u8, "Apply Diffuse Color on Dye"u8, dye.DiffuseColor, + b => dyeTable[rowIdx].DiffuseColor = b); + } + + ImGui.TableNextColumn(); + ret |= CtColorPicker("##Specular"u8, "Specular Color"u8, row.SpecularColor, + c => table[rowIdx].SpecularColor = c); + if (dyeTable != null) + { + ImUtf8.SameLineInner(); + ret |= CtApplyStainCheckbox("##dyeSpecular"u8, "Apply Specular Color on Dye"u8, dye.SpecularColor, + b => dyeTable[rowIdx].SpecularColor = b); + } + + ImGui.SameLine(); + ImGui.SetNextItemWidth(pctSize); + ret |= CtDragScalar("##SpecularMask"u8, "Specular Strength"u8, (float)row.SpecularMask * 100.0f, "%.0f%%"u8, 0f, HalfMaxValue * 100.0f, + 1.0f, + v => table[rowIdx].SpecularMask = (Half)(v * 0.01f)); + if (dyeTable != null) + { + ImUtf8.SameLineInner(); + ret |= CtApplyStainCheckbox("##dyeSpecularMask"u8, "Apply Specular Strength on Dye"u8, dye.SpecularMask, + b => dyeTable[rowIdx].SpecularMask = b); + } + + ImGui.TableNextColumn(); + ret |= CtColorPicker("##Emissive"u8, "Emissive Color"u8, row.EmissiveColor, + c => table[rowIdx].EmissiveColor = c); + if (dyeTable != null) + { + ImUtf8.SameLineInner(); + ret |= CtApplyStainCheckbox("##dyeEmissive"u8, "Apply Emissive Color on Dye"u8, dye.EmissiveColor, + b => dyeTable[rowIdx].EmissiveColor = b); + } + + ImGui.TableNextColumn(); + ImGui.SetNextItemWidth(floatSize); + var glossStrengthMin = ImGui.GetIO().KeyCtrl ? 0.0f : HalfEpsilon; + ret |= CtDragHalf("##Shininess"u8, "Gloss Strength"u8, row.Shininess, "%.1f"u8, glossStrengthMin, HalfMaxValue, + Math.Max(0.1f, (float)row.Shininess * 0.025f), + v => table[rowIdx].Shininess = v); + + if (dyeTable != null) + { + ImUtf8.SameLineInner(); + ret |= CtApplyStainCheckbox("##dyeShininess"u8, "Apply Gloss Strength on Dye"u8, dye.Shininess, + b => dyeTable[rowIdx].Shininess = b); + } + + ImGui.TableNextColumn(); + ImGui.SetNextItemWidth(intSize); + ret |= CtTileIndexPicker("##TileIndex"u8, "Tile Index"u8, row.TileIndex, true, + value => table[rowIdx].TileIndex = value); + + ImGui.TableNextColumn(); + ret |= CtTileTransformMatrix(row.TileTransform, floatSize, false, + m => table[rowIdx].TileTransform = m); + + if (dyeTable != null) + { + ImGui.TableNextColumn(); + if (_stainService.LegacyTemplateCombo.Draw("##dyeTemplate", dye.Template.ToString(), string.Empty, intSize + + ImGui.GetStyle().ScrollbarSize / 2, ImGui.GetTextLineHeightWithSpacing(), ImGuiComboFlags.NoArrowButton)) + { + dyeTable[rowIdx].Template = _stainService.LegacyTemplateCombo.CurrentSelection.UShort; + ret = true; + } + + ImGuiUtil.HoverTooltip("Dye Template", ImGuiHoveredFlags.AllowWhenDisabled); + + ImGui.TableNextColumn(); + ret |= DrawLegacyDyePreview(rowIdx, disabled, dye, floatSize); + } + + return ret; + } + + private bool DrawLegacyColorTableRow(ColorTable table, ColorDyeTable? dyeTable, int rowIdx, bool disabled) + { + using var id = ImRaii.PushId(rowIdx); + ref var row = ref table[rowIdx]; + var dye = dyeTable?[rowIdx] ?? default; + var floatSize = LegacyColorTableFloatSize * UiHelpers.Scale; + var pctSize = LegacyColorTablePercentageSize * UiHelpers.Scale; + var intSize = LegacyColorTableIntegerSize * UiHelpers.Scale; + var byteSize = LegacyColorTableByteSize * UiHelpers.Scale; + ImGui.TableNextColumn(); + ColorTableCopyClipboardButton(rowIdx); + ImUtf8.SameLineInner(); + var ret = ColorTablePasteFromClipboardButton(rowIdx, disabled); + ImUtf8.SameLineInner(); + ColorTableRowHighlightButton(rowIdx, disabled); + + ImGui.TableNextColumn(); + using (ImRaii.PushFont(UiBuilder.MonoFont)) + { + ImUtf8.Text($"{(rowIdx >> 1) + 1,2:D}{"AB"[rowIdx & 1]}"); + } + + ImGui.TableNextColumn(); + using var dis = ImRaii.Disabled(disabled); + ret |= CtColorPicker("##Diffuse"u8, "Diffuse Color"u8, row.DiffuseColor, + c => table[rowIdx].DiffuseColor = c); + if (dyeTable != null) + { + ImUtf8.SameLineInner(); + ret |= CtApplyStainCheckbox("##dyeDiffuse"u8, "Apply Diffuse Color on Dye"u8, dye.DiffuseColor, + b => dyeTable[rowIdx].DiffuseColor = b); + } + + ImGui.TableNextColumn(); + ret |= CtColorPicker("##Specular"u8, "Specular Color"u8, row.SpecularColor, + c => table[rowIdx].SpecularColor = c); + if (dyeTable != null) + { + ImUtf8.SameLineInner(); + ret |= CtApplyStainCheckbox("##dyeSpecular"u8, "Apply Specular Color on Dye"u8, dye.SpecularColor, + b => dyeTable[rowIdx].SpecularColor = b); + } + + ImGui.SameLine(); + ImGui.SetNextItemWidth(pctSize); + ret |= CtDragScalar("##SpecularMask"u8, "Specular Strength"u8, (float)row.Scalar7 * 100.0f, "%.0f%%"u8, 0f, HalfMaxValue * 100.0f, 1.0f, + v => table[rowIdx].Scalar7 = (Half)(v * 0.01f)); + if (dyeTable != null) + { + ImUtf8.SameLineInner(); + ret |= CtApplyStainCheckbox("##dyeSpecularMask"u8, "Apply Specular Strength on Dye"u8, dye.Metalness, + b => dyeTable[rowIdx].Metalness = b); + } + + ImGui.TableNextColumn(); + ret |= CtColorPicker("##Emissive"u8, "Emissive Color"u8, row.EmissiveColor, + c => table[rowIdx].EmissiveColor = c); + if (dyeTable != null) + { + ImUtf8.SameLineInner(); + ret |= CtApplyStainCheckbox("##dyeEmissive"u8, "Apply Emissive Color on Dye"u8, dye.EmissiveColor, + b => dyeTable[rowIdx].EmissiveColor = b); + } + + ImGui.TableNextColumn(); + ImGui.SetNextItemWidth(floatSize); + var glossStrengthMin = ImGui.GetIO().KeyCtrl ? 0.0f : HalfEpsilon; + ret |= CtDragHalf("##Shininess"u8, "Gloss Strength"u8, row.Scalar3, "%.1f"u8, glossStrengthMin, HalfMaxValue, + Math.Max(0.1f, (float)row.Scalar3 * 0.025f), + v => table[rowIdx].Scalar3 = v); + + if (dyeTable != null) + { + ImUtf8.SameLineInner(); + ret |= CtApplyStainCheckbox("##dyeShininess"u8, "Apply Gloss Strength on Dye"u8, dye.Scalar3, + b => dyeTable[rowIdx].Scalar3 = b); + } + + ImGui.TableNextColumn(); + ImGui.SetNextItemWidth(intSize); + ret |= CtTileIndexPicker("##TileIndex"u8, "Tile Index"u8, row.TileIndex, true, + value => table[rowIdx].TileIndex = value); + ImUtf8.SameLineInner(); + ImGui.SetNextItemWidth(pctSize); + ret |= CtDragScalar("##TileAlpha"u8, "Tile Opacity"u8, (float)row.TileAlpha * 100.0f, "%.0f%%"u8, 0f, HalfMaxValue * 100.0f, 1.0f, + v => table[rowIdx].TileAlpha = (Half)(v * 0.01f)); + + ImGui.TableNextColumn(); + ret |= CtTileTransformMatrix(row.TileTransform, floatSize, false, + m => table[rowIdx].TileTransform = m); + + if (dyeTable != null) + { + ImGui.TableNextColumn(); + ImGui.SetNextItemWidth(byteSize); + ret |= CtDragScalar("##DyeChannel"u8, "Dye Channel"u8, dye.Channel + 1, "%hhd"u8, 1, StainService.ChannelCount, 0.25f, + value => dyeTable[rowIdx].Channel = (byte)(Math.Clamp(value, 1, StainService.ChannelCount) - 1)); + ImUtf8.SameLineInner(); + _stainService.LegacyTemplateCombo.CurrentDyeChannel = dye.Channel; + if (_stainService.LegacyTemplateCombo.Draw("##dyeTemplate", dye.Template.ToString(), string.Empty, intSize + + ImGui.GetStyle().ScrollbarSize / 2, ImGui.GetTextLineHeightWithSpacing(), ImGuiComboFlags.NoArrowButton)) + { + dyeTable[rowIdx].Template = _stainService.LegacyTemplateCombo.CurrentSelection.UShort; + ret = true; + } + + ImGuiUtil.HoverTooltip("Dye Template", ImGuiHoveredFlags.AllowWhenDisabled); + + ImGui.TableNextColumn(); + ret |= DrawLegacyDyePreview(rowIdx, disabled, dye, floatSize); + } + + return ret; + } + + private bool DrawLegacyDyePreview(int rowIdx, bool disabled, LegacyColorDyeTableRow dye, float floatSize) + { + var stain = _stainService.StainCombo1.CurrentSelection.Key; + if (stain == 0 || !_stainService.LegacyStmFile.TryGetValue(dye.Template, stain, out var values)) + return false; + + using var style = ImRaii.PushStyle(ImGuiStyleVar.ItemSpacing, ImGui.GetStyle().ItemSpacing / 2); + + var ret = ImGuiUtil.DrawDisabledButton(FontAwesomeIcon.PaintBrush.ToIconString(), new Vector2(ImGui.GetFrameHeight()), + "Apply the selected dye to this row.", disabled, true); + + ret = ret && Mtrl.ApplyDyeToRow(_stainService.LegacyStmFile, [stain], rowIdx); + + ImGui.SameLine(); + DrawLegacyDyePreview(values, floatSize); + + return ret; + } + + private bool DrawLegacyDyePreview(int rowIdx, bool disabled, ColorDyeTableRow dye, float floatSize) + { + var stain = _stainService.GetStainCombo(dye.Channel).CurrentSelection.Key; + if (stain == 0 || !_stainService.LegacyStmFile.TryGetValue(dye.Template, stain, out var values)) + return false; + + using var style = ImRaii.PushStyle(ImGuiStyleVar.ItemSpacing, ImGui.GetStyle().ItemSpacing / 2); + + var ret = ImGuiUtil.DrawDisabledButton(FontAwesomeIcon.PaintBrush.ToIconString(), new Vector2(ImGui.GetFrameHeight()), + "Apply the selected dye to this row.", disabled, true); + + ret = ret + && Mtrl.ApplyDyeToRow(_stainService.LegacyStmFile, [ + _stainService.StainCombo1.CurrentSelection.Key, + _stainService.StainCombo2.CurrentSelection.Key, + ], rowIdx); + + ImGui.SameLine(); + DrawLegacyDyePreview(values, floatSize); + + return ret; + } + + private static void DrawLegacyDyePreview(LegacyDyePack values, float floatSize) + { + CtColorPicker("##diffusePreview"u8, default, values.DiffuseColor, "D"u8); + ImUtf8.SameLineInner(); + CtColorPicker("##specularPreview"u8, default, values.SpecularColor, "S"u8); + ImUtf8.SameLineInner(); + CtColorPicker("##emissivePreview"u8, default, values.EmissiveColor, "E"u8); + ImUtf8.SameLineInner(); + using var dis = ImRaii.Disabled(); + ImGui.SetNextItemWidth(floatSize); + var shininess = (float)values.Shininess; + ImGui.DragFloat("##shininessPreview", ref shininess, 0, shininess, shininess, "%.1f G"); + ImUtf8.SameLineInner(); + ImGui.SetNextItemWidth(floatSize); + var specularMask = (float)values.SpecularMask * 100.0f; + ImGui.DragFloat("##specularMaskPreview", ref specularMask, 0, specularMask, specularMask, "%.0f%% S"); + } +} diff --git a/Penumbra/UI/AdvancedWindow/Materials/MtrlTab.LivePreview.cs b/Penumbra/UI/AdvancedWindow/Materials/MtrlTab.LivePreview.cs new file mode 100644 index 00000000..dfa3a963 --- /dev/null +++ b/Penumbra/UI/AdvancedWindow/Materials/MtrlTab.LivePreview.cs @@ -0,0 +1,301 @@ +using Dalamud.Bindings.ImGui; +using FFXIVClientStructs.FFXIV.Client.Graphics.Scene; +using OtterGui.Raii; +using OtterGui.Text; +using Penumbra.GameData.Files.MaterialStructs; +using Penumbra.GameData.Structs; +using Penumbra.Interop.MaterialPreview; +using Penumbra.Services; +using Penumbra.UI.Classes; + +namespace Penumbra.UI.AdvancedWindow.Materials; + +public partial class MtrlTab +{ + private readonly List _materialPreviewers = new(4); + private readonly List _colorTablePreviewers = new(4); + private int _highlightedColorTableRow = -1; + private int _highlightedColorTablePair = -1; + private readonly Stopwatch _highlightTime = new(); + + private void DrawMaterialLivePreviewRebind(bool disabled) + { + if (disabled) + return; + + if (ImUtf8.Button("Reload live preview"u8)) + BindToMaterialInstances(); + + if (_materialPreviewers.Count != 0 || _colorTablePreviewers.Count != 0) + return; + + ImGui.SameLine(); + using var c = ImRaii.PushColor(ImGuiCol.Text, Colors.RegexWarningBorder); + ImUtf8.Text( + "The current material has not been found on your character. Please check the Import from Screen tab for more information."u8); + } + + private unsafe void BindToMaterialInstances() + { + UnbindFromMaterialInstances(); + + var instances = MaterialInfo.FindMaterials(_resourceTreeFactory.GetLocalPlayerRelatedCharacters().Select(ch => ch.Address), + FilePath); + + var foundMaterials = new HashSet(); + foreach (var materialInfo in instances) + { + var material = materialInfo.GetDrawObjectMaterial(_objects); + if (foundMaterials.Contains((nint)material)) + continue; + + try + { + _materialPreviewers.Add(new LiveMaterialPreviewer(_objects, materialInfo)); + foundMaterials.Add((nint)material); + } + catch (InvalidOperationException) + { + // Carry on without that previewer. + } + } + + UpdateMaterialPreview(); + + if (Mtrl.Table == null) + return; + + foreach (var materialInfo in instances) + { + try + { + _colorTablePreviewers.Add(new LiveColorTablePreviewer(_objects, _framework, materialInfo)); + } + catch (InvalidOperationException) + { + // Carry on without that previewer. + } + } + + UpdateColorTablePreview(); + } + + private void UnbindFromMaterialInstances() + { + foreach (var previewer in _materialPreviewers) + previewer.Dispose(); + _materialPreviewers.Clear(); + + foreach (var previewer in _colorTablePreviewers) + previewer.Dispose(); + _colorTablePreviewers.Clear(); + } + + private unsafe void UnbindFromDrawObjectMaterialInstances(CharacterBase* characterBase) + { + for (var i = _materialPreviewers.Count; i-- > 0;) + { + var previewer = _materialPreviewers[i]; + if (previewer.DrawObject != characterBase) + continue; + + previewer.Dispose(); + _materialPreviewers.RemoveAt(i); + } + + for (var i = _colorTablePreviewers.Count; i-- > 0;) + { + var previewer = _colorTablePreviewers[i]; + if (previewer.DrawObject != characterBase) + continue; + + previewer.Dispose(); + _colorTablePreviewers.RemoveAt(i); + } + } + + private void SetShaderPackageFlags(uint shPkFlags) + { + foreach (var previewer in _materialPreviewers) + previewer.SetShaderPackageFlags(shPkFlags); + } + + private void SetMaterialParameter(uint parameterCrc, Index offset, Span value) + { + foreach (var previewer in _materialPreviewers) + previewer.SetMaterialParameter(parameterCrc, offset, value); + } + + private void SetSamplerFlags(uint samplerCrc, uint samplerFlags) + { + foreach (var previewer in _materialPreviewers) + previewer.SetSamplerFlags(samplerCrc, samplerFlags); + } + + private void UpdateMaterialPreview() + { + SetShaderPackageFlags(Mtrl.ShaderPackage.Flags); + foreach (var constant in Mtrl.ShaderPackage.Constants) + { + var values = Mtrl.GetConstantValue(constant); + if (values != []) + SetMaterialParameter(constant.Id, 0, values); + } + + foreach (var sampler in Mtrl.ShaderPackage.Samplers) + SetSamplerFlags(sampler.SamplerId, sampler.Flags); + } + + private void HighlightColorTablePair(int pairIdx) + { + var oldPairIdx = _highlightedColorTablePair; + + if (_highlightedColorTablePair != pairIdx) + { + _highlightedColorTablePair = pairIdx; + _highlightTime.Restart(); + } + + if (oldPairIdx >= 0) + { + UpdateColorTableRowPreview(oldPairIdx << 1); + UpdateColorTableRowPreview((oldPairIdx << 1) | 1); + } + + if (pairIdx >= 0) + { + UpdateColorTableRowPreview(pairIdx << 1); + UpdateColorTableRowPreview((pairIdx << 1) | 1); + } + } + + private void HighlightColorTableRow(int rowIdx) + { + var oldRowIdx = _highlightedColorTableRow; + + if (_highlightedColorTableRow != rowIdx) + { + _highlightedColorTableRow = rowIdx; + _highlightTime.Restart(); + } + + if (oldRowIdx >= 0) + UpdateColorTableRowPreview(oldRowIdx); + + if (rowIdx >= 0) + UpdateColorTableRowPreview(rowIdx); + } + + private void CancelColorTableHighlight() + { + var rowIdx = _highlightedColorTableRow; + var pairIdx = _highlightedColorTablePair; + + _highlightedColorTableRow = -1; + _highlightedColorTablePair = -1; + _highlightTime.Reset(); + + if (rowIdx >= 0) + UpdateColorTableRowPreview(rowIdx); + + if (pairIdx >= 0) + { + UpdateColorTableRowPreview(pairIdx << 1); + UpdateColorTableRowPreview((pairIdx << 1) | 1); + } + } + + private void UpdateColorTableRowPreview(int rowIdx) + { + if (_colorTablePreviewers.Count == 0) + return; + + if (Mtrl.Table == null) + return; + + var row = Mtrl.Table switch + { + LegacyColorTable legacyTable => new ColorTableRow(legacyTable[rowIdx]), + ColorTable table => table[rowIdx], + _ => throw new InvalidOperationException($"Unsupported color table type {Mtrl.Table.GetType()}"), + }; + if (Mtrl.DyeTable != null) + { + var dyeRow = Mtrl.DyeTable switch + { + LegacyColorDyeTable legacyDyeTable => new ColorDyeTableRow(legacyDyeTable[rowIdx]), + ColorDyeTable dyeTable => dyeTable[rowIdx], + _ => throw new InvalidOperationException($"Unsupported color dye table type {Mtrl.DyeTable.GetType()}"), + }; + if (dyeRow.Channel < StainService.ChannelCount) + { + StainId stainId = _stainService.GetStainCombo(dyeRow.Channel).CurrentSelection.Key; + if (_stainService.LegacyStmFile.TryGetValue(dyeRow.Template, stainId, out var legacyDyes)) + row.ApplyDye(dyeRow, legacyDyes); + if (_stainService.GudStmFile.TryGetValue(dyeRow.Template, stainId, out var gudDyes)) + row.ApplyDye(dyeRow, gudDyes); + } + } + + if (_highlightedColorTablePair << 1 == rowIdx || _highlightedColorTableRow == rowIdx) + ApplyHighlight(ref row, ColorId.InGameHighlight, (float)_highlightTime.Elapsed.TotalSeconds); + else if (((_highlightedColorTablePair << 1) | 1) == rowIdx) + ApplyHighlight(ref row, ColorId.InGameHighlight2, (float)_highlightTime.Elapsed.TotalSeconds); + + foreach (var previewer in _colorTablePreviewers) + { + row[..].CopyTo(previewer.GetColorRow(rowIdx)); + previewer.ScheduleUpdate(); + } + } + + private void UpdateColorTablePreview() + { + if (_colorTablePreviewers.Count == 0) + return; + + if (Mtrl.Table == null) + return; + + var rows = new ColorTable(Mtrl.Table); + var dyeRows = Mtrl.DyeTable != null ? ColorDyeTable.CastOrConvert(Mtrl.DyeTable) : null; + if (dyeRows != null) + { + ReadOnlySpan stainIds = + [ + _stainService.StainCombo1.CurrentSelection.Key, + _stainService.StainCombo2.CurrentSelection.Key, + ]; + rows.ApplyDye(_stainService.LegacyStmFile, stainIds, dyeRows); + rows.ApplyDye(_stainService.GudStmFile, stainIds, dyeRows); + } + + if (_highlightedColorTableRow >= 0) + ApplyHighlight(ref rows[_highlightedColorTableRow], ColorId.InGameHighlight, (float)_highlightTime.Elapsed.TotalSeconds); + + if (_highlightedColorTablePair >= 0) + { + ApplyHighlight(ref rows[_highlightedColorTablePair << 1], ColorId.InGameHighlight, (float)_highlightTime.Elapsed.TotalSeconds); + ApplyHighlight(ref rows[(_highlightedColorTablePair << 1) | 1], ColorId.InGameHighlight2, + (float)_highlightTime.Elapsed.TotalSeconds); + } + + foreach (var previewer in _colorTablePreviewers) + { + rows.AsHalves().CopyTo(previewer.ColorTable); + previewer.ScheduleUpdate(); + } + } + + private static void ApplyHighlight(ref ColorTableRow row, ColorId colorId, float time) + { + var level = (MathF.Sin(time * 2.0f * MathF.PI) + 2.0f) / 3.0f / 255.0f; + var baseColor = colorId.Value(); + var color = level * new Vector3(baseColor & 0xFF, (baseColor >> 8) & 0xFF, (baseColor >> 16) & 0xFF); + var halfColor = (HalfColor)(color * color); + + row.DiffuseColor = halfColor; + row.SpecularColor = halfColor; + row.EmissiveColor = halfColor; + } +} diff --git a/Penumbra/UI/AdvancedWindow/Materials/MtrlTab.ShaderPackage.cs b/Penumbra/UI/AdvancedWindow/Materials/MtrlTab.ShaderPackage.cs new file mode 100644 index 00000000..43040ca3 --- /dev/null +++ b/Penumbra/UI/AdvancedWindow/Materials/MtrlTab.ShaderPackage.cs @@ -0,0 +1,515 @@ +using Dalamud.Interface; +using Dalamud.Interface.ImGuiNotification; +using Dalamud.Bindings.ImGui; +using Newtonsoft.Json.Linq; +using OtterGui; +using OtterGui.Classes; +using OtterGui.Extensions; +using OtterGui.Raii; +using OtterGui.Text; +using Penumbra.GameData; +using Penumbra.GameData.Data; +using Penumbra.GameData.Files; +using Penumbra.GameData.Files.ShaderStructs; +using Penumbra.Interop.Processing; +using Penumbra.String.Classes; +using static Penumbra.GameData.Files.ShpkFile; + +namespace Penumbra.UI.AdvancedWindow.Materials; + +public partial class MtrlTab +{ + // strings path/to/the.exe | grep --fixed-strings '.shpk' | sort -u | sed -e 's#^shader/sm5/shpk/##' + // Apricot shader packages are unlisted because + // 1. they cause severe performance/memory issues when calculating the effective shader set + // 2. they probably aren't intended for use with materials anyway + private static readonly IReadOnlyList StandardShaderPackages = + [ + "3dui.shpk", + // "apricot_decal_dummy.shpk", + // "apricot_decal_ring.shpk", + // "apricot_decal.shpk", + // "apricot_fogModel.shpk", + // "apricot_gbuffer_decal_dummy.shpk", + // "apricot_gbuffer_decal_ring.shpk", + // "apricot_gbuffer_decal.shpk", + // "apricot_lightmodel.shpk", + // "apricot_model_dummy.shpk", + // "apricot_model_morph.shpk", + // "apricot_model.shpk", + // "apricot_powder_dummy.shpk", + // "apricot_powder.shpk", + // "apricot_shape_dummy.shpk", + // "apricot_shape.shpk", + "bgcolorchange.shpk", + "bg_composite.shpk", + "bgcrestchange.shpk", + "bgdecal.shpk", + "bgprop.shpk", + "bg.shpk", + "bguvscroll.shpk", + "characterglass.shpk", + "characterinc.shpk", + "characterlegacy.shpk", + "characterocclusion.shpk", + "characterreflection.shpk", + "characterscroll.shpk", + "charactershadowoffset.shpk", + "character.shpk", + "characterstockings.shpk", + "charactertattoo.shpk", + "charactertransparency.shpk", + "cloud.shpk", + "createviewposition.shpk", + "crystal.shpk", + "directionallighting.shpk", + "directionalshadow.shpk", + "furblur.shpk", + "grassdynamicwave.shpk", + "grass.shpk", + "hairmask.shpk", + "hair.shpk", + "iris.shpk", + "lightshaft.shpk", + "linelighting.shpk", + "planelighting.shpk", + "pointlighting.shpk", + "river.shpk", + "shadowmask.shpk", + "skin.shpk", + "spotlighting.shpk", + "subsurfaceblur.shpk", + "verticalfog.shpk", + "water.shpk", + "weather.shpk", + ]; + + private static readonly byte[] UnknownShadersString = "Vertex Shaders: ???\nPixel Shaders: ???"u8.ToArray(); + + private string[]? _shpkNames; + + private string _shaderHeader = "Shader###Shader"; + private FullPath _loadedShpkPath = FullPath.Empty; + private string _loadedShpkPathName = string.Empty; + private string _loadedShpkDevkitPathName = string.Empty; + private string _shaderComment = string.Empty; + private ShpkFile? _associatedShpk; + private bool _shpkLoading; + private JObject? _associatedShpkDevkit; + + private readonly string _loadedBaseDevkitPathName; + private readonly JObject? _associatedBaseDevkit; + + // Shader Key State + private readonly + List<(string Label, int Index, string Description, bool MonoFont, IReadOnlyList<(string Label, uint Value, string Description)> + Values)> _shaderKeys = new(16); + + private readonly HashSet _vertexShaders = new(16); + private readonly HashSet _pixelShaders = new(16); + private bool _shadersKnown; + private ReadOnlyMemory _shadersString = UnknownShadersString; + + private string[] GetShpkNames() + { + if (null != _shpkNames) + return _shpkNames; + + var names = new HashSet(StandardShaderPackages); + names.UnionWith(_edit.FindPathsStartingWith(ShpkPrefix).Select(path => path.ToString()[ShpkPrefixLength..])); + + _shpkNames = names.ToArray(); + Array.Sort(_shpkNames); + + return _shpkNames; + } + + private FullPath FindAssociatedShpk(out string defaultPath, out Utf8GamePath defaultGamePath) + { + defaultPath = GamePaths.Shader(Mtrl.ShaderPackage.Name); + if (!Utf8GamePath.FromString(defaultPath, out defaultGamePath)) + return FullPath.Empty; + + var path = _edit.FindBestMatch(defaultGamePath); + if (!path.IsRooted || ShpkPathPreProcessor.SanityCheck(path.FullName) == ShpkPathPreProcessor.SanityCheckResult.Success) + return path; + + return new FullPath(defaultPath); + } + + private void LoadShpk(FullPath path) + => Task.Run(() => DoLoadShpk(path)); + + private async Task DoLoadShpk(FullPath path) + { + _shadersKnown = false; + _shaderHeader = $"Shader ({Mtrl.ShaderPackage.Name})###Shader"; + _shpkLoading = true; + + try + { + var data = path.IsRooted + ? await File.ReadAllBytesAsync(path.FullName) + : _gameData.GetFile(path.InternalName.ToString())?.Data; + _loadedShpkPath = path; + _associatedShpk = data?.Length > 0 ? new ShpkFile(data) : throw new Exception("Failure to load file data."); + _loadedShpkPathName = path.ToPath(); + } + catch (Exception e) + { + _loadedShpkPath = FullPath.Empty; + _loadedShpkPathName = string.Empty; + _associatedShpk = null; + Penumbra.Messager.NotificationMessage(e, $"Could not load {_loadedShpkPath.ToPath()}.", NotificationType.Error, false); + } + finally + { + _shpkLoading = false; + } + + if (_loadedShpkPath.InternalName.IsEmpty) + { + _associatedShpkDevkit = null; + _loadedShpkDevkitPathName = string.Empty; + } + else + { + _associatedShpkDevkit = + TryLoadShpkDevkit(Path.GetFileNameWithoutExtension(Mtrl.ShaderPackage.Name), out _loadedShpkDevkitPathName); + } + + UpdateShaderKeys(); + _updateOnNextFrame = true; + } + + private void UpdateShaderKeys() + { + _shaderKeys.Clear(); + if (_associatedShpk != null) + foreach (var key in _associatedShpk.MaterialKeys) + { + var keyName = Names.KnownNames.TryResolve(key.Id); + var dkData = TryGetShpkDevkitData("ShaderKeys", key.Id, false); + var hasDkLabel = !string.IsNullOrEmpty(dkData?.Label); + + var valueSet = new HashSet(key.Values); + if (dkData != null) + valueSet.UnionWith(dkData.Values.Keys); + + var valueKnownNames = keyName.WithKnownSuffixes(); + + var mtrlKeyIndex = Mtrl.FindOrAddShaderKey(key.Id, key.DefaultValue); + var values = valueSet.Select(value => + { + var valueName = valueKnownNames.TryResolve(Names.KnownNames, value); + if (dkData != null && dkData.Values.TryGetValue(value, out var dkValue)) + return (dkValue.Label.Length > 0 ? dkValue.Label : valueName.ToString(), value, dkValue.Description); + + return (valueName.ToString(), value, string.Empty); + }).ToArray(); + Array.Sort(values, (x, y) => + { + if (x.Value == key.DefaultValue) + return -1; + if (y.Value == key.DefaultValue) + return 1; + + return string.Compare(x.Label, y.Label, StringComparison.Ordinal); + }); + _shaderKeys.Add((hasDkLabel ? dkData!.Label : keyName.ToString(), mtrlKeyIndex, dkData?.Description ?? string.Empty, + !hasDkLabel, values)); + } + else + foreach (var (key, index) in Mtrl.ShaderPackage.ShaderKeys.WithIndex()) + { + var keyName = Names.KnownNames.TryResolve(key.Key); + var valueName = keyName.WithKnownSuffixes().TryResolve(Names.KnownNames, key.Value); + _shaderKeys.Add((keyName.ToString(), index, string.Empty, true, [(valueName.ToString(), key.Value, string.Empty)])); + } + } + + private void UpdateShaders() + { + static void AddShader(HashSet globalSet, Dictionary> byPassSets, uint passId, int shaderIndex) + { + globalSet.Add(shaderIndex); + if (!byPassSets.TryGetValue(passId, out var passSet)) + { + passSet = []; + byPassSets.Add(passId, passSet); + } + + passSet.Add(shaderIndex); + } + + _vertexShaders.Clear(); + _pixelShaders.Clear(); + + var vertexShadersByPass = new Dictionary>(); + var pixelShadersByPass = new Dictionary>(); + + if (_associatedShpk == null || !_associatedShpk.IsExhaustiveNodeAnalysisFeasible()) + { + _shadersKnown = false; + } + else + { + _shadersKnown = true; + var systemKeySelectors = AllSelectors(_associatedShpk.SystemKeys).ToArray(); + var sceneKeySelectors = AllSelectors(_associatedShpk.SceneKeys).ToArray(); + var subViewKeySelectors = AllSelectors(_associatedShpk.SubViewKeys).ToArray(); + var materialKeySelector = + BuildSelector(_associatedShpk.MaterialKeys.Select(key => Mtrl.GetOrAddShaderKey(key.Id, key.DefaultValue).Value)); + + foreach (var systemKeySelector in systemKeySelectors) + { + foreach (var sceneKeySelector in sceneKeySelectors) + { + foreach (var subViewKeySelector in subViewKeySelectors) + { + var selector = BuildSelector(systemKeySelector, sceneKeySelector, materialKeySelector, subViewKeySelector); + var node = _associatedShpk.GetNodeBySelector(selector); + if (node.HasValue) + foreach (var pass in node.Value.Passes) + { + AddShader(_vertexShaders, vertexShadersByPass, pass.Id, (int)pass.VertexShader); + AddShader(_pixelShaders, pixelShadersByPass, pass.Id, (int)pass.PixelShader); + } + else + _shadersKnown = false; + } + } + } + } + + if (_shadersKnown) + { + var builder = new StringBuilder(); + foreach (var (passId, passVertexShader) in vertexShadersByPass) + { + if (builder.Length > 0) + builder.Append("\n\n"); + + var passName = Names.KnownNames.TryResolve(passId); + var shaders = passVertexShader.OrderBy(i => i).Select(i => $"#{i}"); + builder.Append($"Vertex Shaders ({passName}): {string.Join(", ", shaders)}"); + if (pixelShadersByPass.TryGetValue(passId, out var passPixelShader)) + { + shaders = passPixelShader.OrderBy(i => i).Select(i => $"#{i}"); + builder.Append($"\nPixel Shaders ({passName}): {string.Join(", ", shaders)}"); + } + } + + foreach (var (passId, passPixelShader) in pixelShadersByPass) + { + if (vertexShadersByPass.ContainsKey(passId)) + continue; + + if (builder.Length > 0) + builder.Append("\n\n"); + + var passName = Names.KnownNames.TryResolve(passId); + var shaders = passPixelShader.OrderBy(i => i).Select(i => $"#{i}"); + builder.Append($"Pixel Shaders ({passName}): {string.Join(", ", shaders)}"); + } + + _shadersString = Encoding.UTF8.GetBytes(builder.ToString()); + } + else + { + _shadersString = UnknownShadersString; + } + + _shaderComment = TryGetShpkDevkitData("Comment", null, true) ?? string.Empty; + } + + private bool DrawShaderSection(bool disabled) + { + var ret = false; + if (ImGui.CollapsingHeader(_shaderHeader)) + { + ret |= DrawPackageNameInput(disabled); + ret |= DrawShaderFlagsInput(disabled); + DrawCustomAssociations(); + ret |= DrawMaterialShaderKeys(disabled); + DrawMaterialShaders(); + } + + if (!_shpkLoading && (_associatedShpk == null || _associatedShpkDevkit == null)) + { + ImGui.Dummy(new Vector2(ImGui.GetTextLineHeight() / 2)); + + if (_associatedShpk == null) + ImUtf8.Text("Unable to find a suitable shader (.shpk) file for cross-references. Some functionality will be missing."u8, + ImGuiUtil.HalfBlendText(0x80u)); // Half red + else + ImUtf8.Text( + "No dev-kit file found for this material's shaders. Please install one for optimal editing experience, such as actual constant names instead of hexadecimal identifiers."u8, + ImGuiUtil.HalfBlendText(0x8080u)); // Half yellow + } + + return ret; + } + + private bool DrawPackageNameInput(bool disabled) + { + if (disabled) + { + ImGui.TextUnformatted("Shader Package: " + Mtrl.ShaderPackage.Name); + return false; + } + + var ret = false; + ImGui.SetNextItemWidth(UiHelpers.Scale * 250.0f); + using var c = ImRaii.Combo("Shader Package", Mtrl.ShaderPackage.Name); + if (c) + foreach (var value in GetShpkNames()) + { + if (!ImGui.Selectable(value, value == Mtrl.ShaderPackage.Name)) + continue; + + Mtrl.ShaderPackage.Name = value; + ret = true; + _associatedShpk = null; + _loadedShpkPath = FullPath.Empty; + UnpinResources(true); + LoadShpk(FindAssociatedShpk(out _, out _)); + } + + return ret; + } + + private bool DrawShaderFlagsInput(bool disabled) + { + var shpkFlags = (int)Mtrl.ShaderPackage.Flags; + ImGui.SetNextItemWidth(UiHelpers.Scale * 250.0f); + if (!ImGui.InputInt("Shader Flags", ref shpkFlags, 0, 0, + flags: ImGuiInputTextFlags.CharsHexadecimal | (disabled ? ImGuiInputTextFlags.ReadOnly : ImGuiInputTextFlags.None))) + return false; + + Mtrl.ShaderPackage.Flags = (uint)shpkFlags; + SetShaderPackageFlags((uint)shpkFlags); + return true; + } + + /// + /// Show the currently associated shpk file, if any, and the buttons to associate + /// a specific shpk from your drive, the modded shpk by path or the default shpk. + /// + private void DrawCustomAssociations() + { + const string tooltip = "Click to copy file path to clipboard."; + var text = _associatedShpk == null + ? "Associated .shpk file: None" + : $"Associated .shpk file: {_loadedShpkPathName}"; + var devkitText = _associatedShpkDevkit == null + ? "Associated dev-kit file: None" + : $"Associated dev-kit file: {_loadedShpkDevkitPathName}"; + var baseDevkitText = _associatedBaseDevkit == null + ? "Base dev-kit file: None" + : $"Base dev-kit file: {_loadedBaseDevkitPathName}"; + + ImGui.Dummy(new Vector2(ImGui.GetTextLineHeight() / 2)); + + ImUtf8.CopyOnClickSelectable(text, _loadedShpkPathName, tooltip); + ImUtf8.CopyOnClickSelectable(devkitText, _loadedShpkDevkitPathName, tooltip); + ImUtf8.CopyOnClickSelectable(baseDevkitText, _loadedBaseDevkitPathName, tooltip); + + if (ImUtf8.Button("Associate Custom .shpk File"u8)) + _fileDialog.OpenFilePicker("Associate Custom .shpk File...", ".shpk", (success, name) => + { + if (success) + LoadShpk(new FullPath(name[0])); + }, 1, _edit.Mod!.ModPath.FullName, false); + + var moddedPath = FindAssociatedShpk(out var defaultPath, out var gamePath); + ImGui.SameLine(); + if (ImUtf8.ButtonEx("Associate Default .shpk File"u8, moddedPath.ToPath(), Vector2.Zero, + moddedPath.Equals(_loadedShpkPath))) + LoadShpk(moddedPath); + + if (!gamePath.Path.Equals(moddedPath.InternalName)) + { + ImGui.SameLine(); + if (ImUtf8.ButtonEx("Associate Unmodded .shpk File", defaultPath, Vector2.Zero, + gamePath.Path.Equals(_loadedShpkPath.InternalName))) + LoadShpk(new FullPath(gamePath)); + } + + ImGui.Dummy(new Vector2(ImGui.GetTextLineHeight() / 2)); + } + + private bool DrawMaterialShaderKeys(bool disabled) + { + if (_shaderKeys.Count == 0) + return false; + + var ret = false; + foreach (var (label, index, description, monoFont, values) in _shaderKeys) + { + using var font = ImRaii.PushFont(UiBuilder.MonoFont, monoFont); + ref var key = ref Mtrl.ShaderPackage.ShaderKeys[index]; + using var id = ImUtf8.PushId((int)key.Key); + var shpkKey = _associatedShpk?.GetMaterialKeyById(key.Key); + var currentValue = key.Value; + var (currentLabel, _, currentDescription) = + values.FirstOrNull(v => v.Value == currentValue) ?? ($"0x{currentValue:X8}", currentValue, string.Empty); + if (!disabled && shpkKey.HasValue) + { + ImGui.SetNextItemWidth(UiHelpers.Scale * 250.0f); + using (var c = ImUtf8.Combo(""u8, currentLabel)) + { + if (c) + foreach (var (valueLabel, value, valueDescription) in values) + { + if (ImGui.Selectable(valueLabel, value == currentValue)) + { + key.Value = value; + ret = true; + UnpinResources(false); + Update(); + } + + if (valueDescription.Length > 0) + ImGuiUtil.SelectableHelpMarker(valueDescription); + } + } + + ImGui.SameLine(); + if (description.Length > 0) + ImGuiUtil.LabeledHelpMarker(label, description); + else + ImUtf8.Text(label); + } + else if (description.Length > 0 || currentDescription.Length > 0) + { + ImUtf8.LabeledHelpMarker($"{label}: {currentLabel}", + description + (description.Length > 0 && currentDescription.Length > 0 ? "\n\n" : string.Empty) + currentDescription); + } + else + { + ImUtf8.Text($"{label}: {currentLabel}"); + } + } + + return ret; + } + + private void DrawMaterialShaders() + { + if (_associatedShpk == null) + return; + + using (var node = ImUtf8.TreeNode("Candidate Shaders"u8)) + { + if (node) + ImUtf8.Text(_shadersString.Span); + } + + if (_shaderComment.Length > 0) + { + ImGui.Dummy(new Vector2(ImGui.GetTextLineHeight() / 2)); + ImUtf8.Text(_shaderComment); + } + } +} diff --git a/Penumbra/UI/AdvancedWindow/Materials/MtrlTab.Textures.cs b/Penumbra/UI/AdvancedWindow/Materials/MtrlTab.Textures.cs new file mode 100644 index 00000000..82ba7be4 --- /dev/null +++ b/Penumbra/UI/AdvancedWindow/Materials/MtrlTab.Textures.cs @@ -0,0 +1,300 @@ +using Dalamud.Interface; +using Dalamud.Bindings.ImGui; +using OtterGui; +using OtterGui.Extensions; +using OtterGui.Raii; +using OtterGui.Text; +using Penumbra.GameData.Files.MaterialStructs; +using Penumbra.String.Classes; +using static Penumbra.GameData.Files.MaterialStructs.SamplerFlags; +using static Penumbra.GameData.Files.ShpkFile; + +namespace Penumbra.UI.AdvancedWindow.Materials; + +public partial class MtrlTab +{ + public readonly List<(string Label, int TextureIndex, int SamplerIndex, string Description, bool MonoFont)> Textures = new(4); + + public readonly HashSet UnfoldedTextures = new(4); + public readonly HashSet TextureIds = new(16); + public readonly HashSet SamplerIds = new(16); + public float TextureLabelWidth; + private bool _samplersPinned; + + private void UpdateTextures() + { + Textures.Clear(); + TextureIds.Clear(); + SamplerIds.Clear(); + if (_associatedShpk == null) + { + TextureIds.UnionWith(Mtrl.ShaderPackage.Samplers.Select(sampler => sampler.SamplerId)); + SamplerIds.UnionWith(Mtrl.ShaderPackage.Samplers.Select(sampler => sampler.SamplerId)); + if (Mtrl.Table != null) + TextureIds.Add(TableSamplerId); + + foreach (var (sampler, index) in Mtrl.ShaderPackage.Samplers.WithIndex()) + Textures.Add(($"0x{sampler.SamplerId:X8}", sampler.TextureIndex, index, string.Empty, true)); + } + else + { + foreach (var index in _vertexShaders) + { + TextureIds.UnionWith(_associatedShpk.VertexShaders[index].Textures.Select(texture => texture.Id)); + SamplerIds.UnionWith(_associatedShpk.VertexShaders[index].Samplers.Select(sampler => sampler.Id)); + } + + foreach (var index in _pixelShaders) + { + TextureIds.UnionWith(_associatedShpk.PixelShaders[index].Textures.Select(texture => texture.Id)); + SamplerIds.UnionWith(_associatedShpk.PixelShaders[index].Samplers.Select(sampler => sampler.Id)); + } + + if (_samplersPinned || !_shadersKnown) + { + TextureIds.UnionWith(Mtrl.ShaderPackage.Samplers.Select(sampler => sampler.SamplerId)); + if (Mtrl.Table != null) + TextureIds.Add(TableSamplerId); + } + + foreach (var textureId in TextureIds) + { + var shpkTexture = _associatedShpk.GetTextureById(textureId); + if (shpkTexture is not { Slot: 2 } && (shpkTexture is not null || textureId == TableSamplerId)) + continue; + + var dkData = TryGetShpkDevkitData("Samplers", textureId, true); + var hasDkLabel = !string.IsNullOrEmpty(dkData?.Label); + + var sampler = Mtrl.GetOrAddSampler(textureId, dkData?.DefaultTexture ?? string.Empty, out var samplerIndex); + Textures.Add((hasDkLabel ? dkData!.Label : shpkTexture!.Value.Name, sampler.TextureIndex, samplerIndex, + dkData?.Description ?? string.Empty, !hasDkLabel)); + } + + if (TextureIds.Contains(TableSamplerId)) + Mtrl.Table ??= new ColorTable(); + } + + Textures.Sort((x, y) => string.CompareOrdinal(x.Label, y.Label)); + + TextureLabelWidth = 50f * UiHelpers.Scale; + + float helpWidth; + using (var _ = ImRaii.PushFont(UiBuilder.IconFont)) + { + helpWidth = ImGui.GetStyle().ItemSpacing.X + ImGui.CalcTextSize(FontAwesomeIcon.InfoCircle.ToIconString()).X; + } + + foreach (var (label, _, _, description, monoFont) in Textures) + { + if (!monoFont) + TextureLabelWidth = Math.Max(TextureLabelWidth, ImGui.CalcTextSize(label).X + (description.Length > 0 ? helpWidth : 0.0f)); + } + + using (var _ = ImRaii.PushFont(UiBuilder.MonoFont)) + { + foreach (var (label, _, _, description, monoFont) in Textures) + { + if (monoFont) + TextureLabelWidth = Math.Max(TextureLabelWidth, + ImGui.CalcTextSize(label).X + (description.Length > 0 ? helpWidth : 0.0f)); + } + } + + TextureLabelWidth = TextureLabelWidth / UiHelpers.Scale + 4; + } + + private static ReadOnlySpan TextureAddressModeTooltip(TextureAddressMode addressMode) + => addressMode switch + { + TextureAddressMode.Wrap => + "Tile the texture at every UV integer junction.\n\nFor example, for U values between 0 and 3, the texture is repeated three times."u8, + TextureAddressMode.Mirror => + "Flip the texture at every UV integer junction.\n\nFor U values between 0 and 1, for example, the texture is addressed normally; between 1 and 2, the texture is mirrored; between 2 and 3, the texture is normal again; and so on."u8, + TextureAddressMode.Clamp => + "Texture coordinates outside the range [0.0, 1.0] are set to the texture color at 0.0 or 1.0, respectively."u8, + TextureAddressMode.Border => "Texture coordinates outside the range [0.0, 1.0] are set to the border color (generally black)."u8, + _ => ""u8, + }; + + private bool DrawTextureSection(bool disabled) + { + if (Textures.Count == 0) + return false; + + ImGui.Dummy(new Vector2(ImGui.GetTextLineHeight() / 2)); + if (!ImGui.CollapsingHeader("Textures and Samplers", ImGuiTreeNodeFlags.DefaultOpen)) + return false; + + var frameHeight = ImGui.GetFrameHeight(); + var ret = false; + using var table = ImRaii.Table("##Textures", 3); + + ImGui.TableSetupColumn(string.Empty, ImGuiTableColumnFlags.WidthFixed, frameHeight); + ImGui.TableSetupColumn("Path", ImGuiTableColumnFlags.WidthStretch); + ImGui.TableSetupColumn("Name", ImGuiTableColumnFlags.WidthFixed, TextureLabelWidth * UiHelpers.Scale); + foreach (var (label, textureI, samplerI, description, monoFont) in Textures) + { + using var _ = ImRaii.PushId(samplerI); + var tmp = Mtrl.Textures[textureI].Path; + var unfolded = UnfoldedTextures.Contains(samplerI); + ImGui.TableNextColumn(); + if (ImGuiUtil.DrawDisabledButton((unfolded ? FontAwesomeIcon.CaretDown : FontAwesomeIcon.CaretRight).ToIconString(), + new Vector2(frameHeight), + "Settings for this texture and the associated sampler", false, true)) + { + unfolded = !unfolded; + if (unfolded) + UnfoldedTextures.Add(samplerI); + else + UnfoldedTextures.Remove(samplerI); + } + + ImGui.TableNextColumn(); + ImGui.SetNextItemWidth(ImGui.GetContentRegionAvail().X); + if (ImGui.InputText(string.Empty, ref tmp, Utf8GamePath.MaxGamePathLength, + disabled ? ImGuiInputTextFlags.ReadOnly : ImGuiInputTextFlags.None) + && tmp.Length > 0 + && tmp != Mtrl.Textures[textureI].Path) + { + ret = true; + Mtrl.Textures[textureI].Path = tmp; + } + + ImGui.TableNextColumn(); + using (ImRaii.PushFont(UiBuilder.MonoFont, monoFont)) + { + ImGui.AlignTextToFramePadding(); + if (description.Length > 0) + ImGuiUtil.LabeledHelpMarker(label, description); + else + ImGui.TextUnformatted(label); + } + + if (unfolded) + { + ImGui.TableNextColumn(); + ImGui.TableNextColumn(); + ret |= DrawMaterialSampler(disabled, textureI, samplerI); + ImGui.TableNextColumn(); + } + } + + return ret; + } + + private static bool ComboTextureAddressMode(ReadOnlySpan label, ref TextureAddressMode value) + { + using var c = ImUtf8.Combo(label, value.ToString()); + if (!c) + return false; + + var ret = false; + foreach (var mode in Enum.GetValues()) + { + if (ImGui.Selectable(mode.ToString(), mode == value)) + { + value = mode; + ret = true; + } + + ImUtf8.SelectableHelpMarker(TextureAddressModeTooltip(mode)); + } + + return ret; + } + + private bool DrawMaterialSampler(bool disabled, int textureIdx, int samplerIdx) + { + var ret = false; + ref var texture = ref Mtrl.Textures[textureIdx]; + ref var sampler = ref Mtrl.ShaderPackage.Samplers[samplerIdx]; + + var dx11 = texture.DX11; + if (ImUtf8.Checkbox("Prepend -- to the file name on DirectX 11"u8, ref dx11)) + { + texture.DX11 = dx11; + ret = true; + } + + if (SamplerIds.Contains(sampler.SamplerId)) + { + ref var samplerFlags = ref Wrap(ref sampler.Flags); + + ImGui.SetNextItemWidth(UiHelpers.Scale * 100.0f); + var addressMode = samplerFlags.UAddressMode; + if (ComboTextureAddressMode("##UAddressMode"u8, ref addressMode)) + { + samplerFlags.UAddressMode = addressMode; + ret = true; + SetSamplerFlags(sampler.SamplerId, sampler.Flags); + } + + ImGui.SameLine(); + ImUtf8.LabeledHelpMarker("U Address Mode"u8, + "Method to use for resolving a U texture coordinate that is outside the 0 to 1 range."); + + ImGui.SetNextItemWidth(UiHelpers.Scale * 100.0f); + addressMode = samplerFlags.VAddressMode; + if (ComboTextureAddressMode("##VAddressMode"u8, ref addressMode)) + { + samplerFlags.VAddressMode = addressMode; + ret = true; + SetSamplerFlags(sampler.SamplerId, sampler.Flags); + } + + ImGui.SameLine(); + ImUtf8.LabeledHelpMarker("V Address Mode"u8, + "Method to use for resolving a V texture coordinate that is outside the 0 to 1 range."); + + var lodBias = samplerFlags.LodBias; + ImGui.SetNextItemWidth(UiHelpers.Scale * 100.0f); + if (ImUtf8.DragScalar("##LoDBias"u8, ref lodBias, -8.0f, 7.984375f, 0.1f)) + { + samplerFlags.LodBias = lodBias; + ret = true; + SetSamplerFlags(sampler.SamplerId, sampler.Flags); + } + + ImGui.SameLine(); + ImUtf8.LabeledHelpMarker("Level of Detail Bias"u8, + "Offset from the calculated mipmap level.\n\nHigher means that the texture will start to lose detail nearer.\nLower means that the texture will keep its detail until farther."); + + var minLod = samplerFlags.MinLod; + ImGui.SetNextItemWidth(UiHelpers.Scale * 100.0f); + if (ImUtf8.DragScalar("##MinLoD"u8, ref minLod, 0, 15, 0.1f)) + { + samplerFlags.MinLod = minLod; + ret = true; + SetSamplerFlags(sampler.SamplerId, sampler.Flags); + } + + ImGui.SameLine(); + ImUtf8.LabeledHelpMarker("Minimum Level of Detail"u8, + "Most detailed mipmap level to use.\n\n0 is the full-sized texture, 1 is the half-sized texture, 2 is the quarter-sized texture, and so on.\n15 will forcibly reduce the texture to its smallest mipmap."); + } + else + { + ImUtf8.Text("This texture does not have a dedicated sampler."u8); + } + + using var t = ImUtf8.TreeNode("Advanced Settings"u8); + if (!t) + return ret; + + ImGui.SetNextItemWidth(UiHelpers.Scale * 100.0f); + if (ImUtf8.InputScalar("Texture Flags"u8, ref texture.Flags, "%04X"u8, + flags: disabled ? ImGuiInputTextFlags.ReadOnly : ImGuiInputTextFlags.None)) + ret = true; + + ImGui.SetNextItemWidth(UiHelpers.Scale * 100.0f); + if (ImUtf8.InputScalar("Sampler Flags"u8, ref sampler.Flags, "%08X"u8, + flags: ImGuiInputTextFlags.CharsHexadecimal | (disabled ? ImGuiInputTextFlags.ReadOnly : ImGuiInputTextFlags.None))) + { + ret = true; + SetSamplerFlags(sampler.SamplerId, sampler.Flags); + } + + return ret; + } +} diff --git a/Penumbra/UI/AdvancedWindow/Materials/MtrlTab.cs b/Penumbra/UI/AdvancedWindow/Materials/MtrlTab.cs new file mode 100644 index 00000000..2c7c889e --- /dev/null +++ b/Penumbra/UI/AdvancedWindow/Materials/MtrlTab.cs @@ -0,0 +1,228 @@ +using Dalamud.Interface.Components; +using Dalamud.Plugin.Services; +using Dalamud.Bindings.ImGui; +using OtterGui; +using OtterGui.Raii; +using OtterGui.Text; +using OtterGui.Widgets; +using Penumbra.GameData.Files; +using Penumbra.GameData.Files.MaterialStructs; +using Penumbra.GameData.Interop; +using Penumbra.Interop.Hooks.Objects; +using Penumbra.Interop.ResourceTree; +using Penumbra.Services; +using Penumbra.String; +using Penumbra.UI.Classes; + +namespace Penumbra.UI.AdvancedWindow.Materials; + +public sealed partial class MtrlTab : IWritable, IDisposable +{ + private const int ShpkPrefixLength = 16; + + private static readonly CiByteString ShpkPrefix = CiByteString.FromSpanUnsafe("shader/sm5/shpk/"u8, true, true, true); + + private readonly IDataManager _gameData; + private readonly IFramework _framework; + private readonly ObjectManager _objects; + private readonly CharacterBaseDestructor _characterBaseDestructor; + private readonly StainService _stainService; + private readonly ResourceTreeFactory _resourceTreeFactory; + private readonly FileDialogService _fileDialog; + private readonly MaterialTemplatePickers _materialTemplatePickers; + private readonly Configuration _config; + + private readonly ModEditWindow _edit; + public readonly MtrlFile Mtrl; + public readonly string FilePath; + public readonly bool Writable; + + private bool _updateOnNextFrame; + + public unsafe MtrlTab(IDataManager gameData, IFramework framework, ObjectManager objects, CharacterBaseDestructor characterBaseDestructor, + StainService stainService, ResourceTreeFactory resourceTreeFactory, FileDialogService fileDialog, + MaterialTemplatePickers materialTemplatePickers, + Configuration config, ModEditWindow edit, MtrlFile file, string filePath, bool writable) + { + _gameData = gameData; + _framework = framework; + _objects = objects; + _characterBaseDestructor = characterBaseDestructor; + _stainService = stainService; + _resourceTreeFactory = resourceTreeFactory; + _fileDialog = fileDialog; + _materialTemplatePickers = materialTemplatePickers; + _config = config; + + _edit = edit; + Mtrl = file; + FilePath = filePath; + Writable = writable; + _samplersPinned = true; + _associatedBaseDevkit = TryLoadShpkDevkit("_base", out _loadedBaseDevkitPathName); + Update(); + LoadShpk(FindAssociatedShpk(out _, out _)); + if (writable) + { + _characterBaseDestructor.Subscribe(UnbindFromDrawObjectMaterialInstances, CharacterBaseDestructor.Priority.MtrlTab); + BindToMaterialInstances(); + } + } + + public bool DrawVersionUpdate(bool disabled) + { + if (disabled || Mtrl.IsDawntrail) + return false; + + if (!ImUtf8.ButtonEx("Update MTRL Version to Dawntrail"u8, + "Try using this if the material can not be loaded or should use legacy shaders.\n\nThis is not revertible."u8, + new Vector2(-0.1f, 0), false, 0, Colors.PressEnterWarningBg)) + return false; + + Mtrl.MigrateToDawntrail(); + Update(); + LoadShpk(FindAssociatedShpk(out _, out _)); + return true; + } + + public bool DrawPanel(bool disabled) + { + if (_updateOnNextFrame) + { + _updateOnNextFrame = false; + Update(); + } + + DrawMaterialLivePreviewRebind(disabled); + + ImGui.Dummy(new Vector2(ImGui.GetTextLineHeight() / 2)); + var ret = DrawBackFaceAndTransparency(disabled); + + ImGui.Dummy(new Vector2(ImGui.GetTextLineHeight() / 2)); + ret |= DrawShaderSection(disabled); + + ret |= DrawTextureSection(disabled); + ret |= DrawColorTableSection(disabled); + ret |= DrawConstantsSection(disabled); + + ImGui.Dummy(new Vector2(ImGui.GetTextLineHeight() / 2)); + DrawOtherMaterialDetails(disabled); + + return !disabled && ret; + } + + private bool DrawBackFaceAndTransparency(bool disabled) + { + ref var shaderFlags = ref ShaderFlags.Wrap(ref Mtrl.ShaderPackage.Flags); + + var ret = false; + + using var dis = ImRaii.Disabled(disabled); + + var tmp = shaderFlags.EnableTransparency; + + // guardrail: the game crashes if transparency is enabled on characterstockings.shpk + var disallowTransparency = Mtrl.ShaderPackage.Name == "characterstockings.shpk"; + using (ImRaii.Disabled(disallowTransparency)) + { + if (ImUtf8.Checkbox("Enable Transparency"u8, ref tmp)) + { + shaderFlags.EnableTransparency = tmp; + ret = true; + SetShaderPackageFlags(Mtrl.ShaderPackage.Flags); + } + } + + if (disallowTransparency) + { + ImGuiComponents.HelpMarker("Enabling transparency for shader package characterstockings.shpk will crash the game."); + } + + ImGui.SameLine(200 * UiHelpers.Scale + ImGui.GetStyle().ItemSpacing.X + ImGui.GetStyle().WindowPadding.X); + tmp = shaderFlags.HideBackfaces; + if (ImUtf8.Checkbox("Hide Backfaces"u8, ref tmp)) + { + shaderFlags.HideBackfaces = tmp; + ret = true; + SetShaderPackageFlags(Mtrl.ShaderPackage.Flags); + } + + if (_shpkLoading) + { + ImGui.SameLine(400 * UiHelpers.Scale + 2 * ImGui.GetStyle().ItemSpacing.X + ImGui.GetStyle().WindowPadding.X); + + ImUtf8.Text("Loading shader (.shpk) file. Some functionality will only be available after this is done."u8, + ImGuiUtil.HalfBlendText(0x808000u)); // Half cyan + } + + return ret; + } + + private void DrawOtherMaterialDetails(bool _) + { + if (!ImUtf8.CollapsingHeader("Further Content"u8)) + return; + + using (var sets = ImUtf8.TreeNode("UV Sets"u8, ImGuiTreeNodeFlags.DefaultOpen)) + { + if (sets) + foreach (var set in Mtrl.UvSets) + ImUtf8.TreeNode($"#{set.Index:D2} - {set.Name}", ImGuiTreeNodeFlags.Leaf).Dispose(); + } + + using (var sets = ImUtf8.TreeNode("Color Sets"u8, ImGuiTreeNodeFlags.DefaultOpen)) + { + if (sets) + foreach (var set in Mtrl.ColorSets) + ImUtf8.TreeNode($"#{set.Index:D2} - {set.Name}", ImGuiTreeNodeFlags.Leaf).Dispose(); + } + + if (Mtrl.AdditionalData.Length <= 0) + return; + + using var t = ImUtf8.TreeNode($"Additional Data (Size: {Mtrl.AdditionalData.Length})###AdditionalData"); + if (t) + Widget.DrawHexViewer(Mtrl.AdditionalData); + } + + private void UnpinResources(bool all) + { + _samplersPinned = false; + + if (!all) + return; + + var keys = Mtrl.ShaderPackage.ShaderKeys; + for (var i = 0; i < keys.Length; i++) + keys[i].Pinned = false; + + var constants = Mtrl.ShaderPackage.Constants; + for (var i = 0; i < constants.Length; i++) + constants[i].Pinned = false; + } + + private void Update() + { + UpdateShaders(); + UpdateTextures(); + UpdateConstants(); + } + + public unsafe void Dispose() + { + UnbindFromMaterialInstances(); + if (Writable) + _characterBaseDestructor.Unsubscribe(UnbindFromDrawObjectMaterialInstances); + } + + public bool Valid + => Mtrl.Valid; // FIXME This should be _shadersKnown && Mtrl.Valid but the algorithm for _shadersKnown is flawed as of 7.2. + + public byte[] Write() + { + var output = Mtrl.Clone(); + output.GarbageCollect(_associatedShpk, TextureIds); + + return output.Write(); + } +} diff --git a/Penumbra/UI/AdvancedWindow/Materials/MtrlTabFactory.cs b/Penumbra/UI/AdvancedWindow/Materials/MtrlTabFactory.cs new file mode 100644 index 00000000..09db4277 --- /dev/null +++ b/Penumbra/UI/AdvancedWindow/Materials/MtrlTabFactory.cs @@ -0,0 +1,25 @@ +using Dalamud.Plugin.Services; +using OtterGui.Services; +using Penumbra.GameData.Files; +using Penumbra.GameData.Interop; +using Penumbra.Interop.Hooks.Objects; +using Penumbra.Interop.ResourceTree; +using Penumbra.Services; + +namespace Penumbra.UI.AdvancedWindow.Materials; + +public sealed class MtrlTabFactory( + IDataManager gameData, + IFramework framework, + ObjectManager objects, + CharacterBaseDestructor characterBaseDestructor, + StainService stainService, + ResourceTreeFactory resourceTreeFactory, + FileDialogService fileDialog, + MaterialTemplatePickers materialTemplatePickers, + Configuration config) : IUiService +{ + public MtrlTab Create(ModEditWindow edit, MtrlFile file, string filePath, bool writable) + => new(gameData, framework, objects, characterBaseDestructor, stainService, resourceTreeFactory, fileDialog, + materialTemplatePickers, config, edit, file, filePath, writable); +} diff --git a/Penumbra/UI/AdvancedWindow/Meta/AtchMetaDrawer.cs b/Penumbra/UI/AdvancedWindow/Meta/AtchMetaDrawer.cs new file mode 100644 index 00000000..4a74cda5 --- /dev/null +++ b/Penumbra/UI/AdvancedWindow/Meta/AtchMetaDrawer.cs @@ -0,0 +1,294 @@ +using Dalamud.Interface; +using Dalamud.Interface.ImGuiNotification; +using Dalamud.Bindings.ImGui; +using Newtonsoft.Json.Linq; +using OtterGui.Extensions; +using OtterGui.Raii; +using OtterGui.Services; +using OtterGui.Text; +using OtterGui.Widgets; +using Penumbra.Collections.Cache; +using Penumbra.GameData.Data; +using Penumbra.GameData.Enums; +using Penumbra.GameData.Files; +using Penumbra.GameData.Files.AtchStructs; +using Penumbra.Meta; +using Penumbra.Meta.Manipulations; +using Penumbra.Mods.Editor; +using Penumbra.UI.Classes; +using Notification = OtterGui.Classes.Notification; + +namespace Penumbra.UI.AdvancedWindow.Meta; + +public sealed class AtchMetaDrawer : MetaDrawer, IService +{ + public override ReadOnlySpan Label + => "Attachment Points (ATCH)###ATCH"u8; + + public override int NumColumns + => 10; + + public override float ColumnHeight + => 2 * ImUtf8.FrameHeightSpacing; + + private AtchFile? _currentBaseAtchFile; + private AtchPoint? _currentBaseAtchPoint; + private readonly AtchPointCombo _combo; + private string _fileImport = string.Empty; + + public AtchMetaDrawer(ModMetaEditor editor, MetaFileManager metaFiles) + : base(editor, metaFiles) + { + _combo = new AtchPointCombo(() => _currentBaseAtchFile?.Points.Select(p => p.Type).ToList() ?? []); + } + + private sealed class AtchPointCombo(Func> generator) + : FilterComboCache(generator, MouseWheelType.Control, Penumbra.Log) + { + protected override string ToString(AtchType obj) + => obj.ToName(); + } + + private sealed class RaceCodeException(string filePath) : Exception($"Could not identify race code from path {filePath}."); + + public void ImportFile(string filePath) + { + try + { + if (filePath.Length == 0 || !File.Exists(filePath)) + throw new FileNotFoundException(); + + var gr = Parser.ParseRaceCode(filePath); + if (gr is GenderRace.Unknown) + throw new RaceCodeException(filePath); + + var text = File.ReadAllBytes(filePath); + var file = new AtchFile(text); + foreach (var point in file.Points) + { + foreach (var (entry, index) in point.Entries.WithIndex()) + { + var identifier = new AtchIdentifier(point.Type, gr, (ushort)index); + var defaultValue = AtchCache.GetDefault(MetaFiles, identifier); + if (defaultValue == null) + continue; + + if (defaultValue.Value.Equals(entry)) + Editor.Changes |= Editor.Remove(identifier); + else + Editor.Changes |= Editor.TryAdd(identifier, entry) || Editor.Update(identifier, entry); + } + } + } + catch (RaceCodeException ex) + { + Penumbra.Messager.AddMessage(new Notification(ex, "The imported .atch file does not contain a race code (cXXXX) in its name.", + "Could not import .atch file:", + NotificationType.Warning)); + } + catch (Exception ex) + { + Penumbra.Messager.AddMessage(new Notification(ex, "Unable to import .atch file.", "Could not import .atch file:", + NotificationType.Warning)); + } + } + + + protected override void DrawNew() + { + ImGui.TableNextColumn(); + CopyToClipboardButton("Copy all current ATCH manipulations to clipboard."u8, + new Lazy(() => MetaDictionary.SerializeTo([], Editor.Atch))); + + ImGui.TableNextColumn(); + var canAdd = !Editor.Contains(Identifier); + var tt = canAdd ? "Stage this edit."u8 : "This entry is already edited."u8; + if (ImUtf8.IconButton(FontAwesomeIcon.Plus, tt, disabled: !canAdd)) + Editor.Changes |= Editor.TryAdd(Identifier, Entry); + + if (DrawIdentifierInput(ref Identifier)) + UpdateEntry(); + + var defaultEntry = AtchCache.GetDefault(MetaFiles, Identifier) ?? default; + DrawEntry(defaultEntry, ref defaultEntry, true); + } + + private void UpdateEntry() + => Entry = _currentBaseAtchPoint!.Entries[Identifier.EntryIndex]; + + protected override void Initialize() + { + _currentBaseAtchFile = MetaFiles.AtchManager.AtchFileBase[GenderRace.MidlanderMale]; + _currentBaseAtchPoint = _currentBaseAtchFile.Points.First(); + Identifier = new AtchIdentifier(_currentBaseAtchPoint.Type, GenderRace.MidlanderMale, 0); + Entry = _currentBaseAtchPoint.Entries[0]; + } + + protected override void DrawEntry(AtchIdentifier identifier, AtchEntry entry) + { + DrawMetaButtons(identifier, entry); + DrawIdentifier(identifier); + + var defaultEntry = AtchCache.GetDefault(MetaFiles, identifier) ?? default; + if (DrawEntry(defaultEntry, ref entry, false)) + Editor.Changes |= Editor.Update(identifier, entry); + } + + protected override IEnumerable<(AtchIdentifier, AtchEntry)> Enumerate() + => Editor.Atch.Select(kvp => (kvp.Key, kvp.Value)) + .OrderBy(p => p.Key.GenderRace) + .ThenBy(p => p.Key.Type) + .ThenBy(p => p.Key.EntryIndex); + + protected override int Count + => Editor.Atch.Count; + + private bool DrawIdentifierInput(ref AtchIdentifier identifier) + { + var changes = false; + ImGui.TableNextColumn(); + changes |= DrawRace(ref identifier); + ImGui.TableNextColumn(); + changes |= DrawGender(ref identifier, false); + if (changes) + UpdateFile(); + ImGui.TableNextColumn(); + if (DrawPointInput(ref identifier, _combo)) + { + _currentBaseAtchPoint = _currentBaseAtchFile?.GetPoint(identifier.Type); + changes = true; + } + + ImGui.TableNextColumn(); + changes |= DrawEntryIndexInput(ref identifier, _currentBaseAtchPoint!); + + return changes; + } + + private void UpdateFile() + { + _currentBaseAtchFile = MetaFiles.AtchManager.AtchFileBase[Identifier.GenderRace]; + _currentBaseAtchPoint = _currentBaseAtchFile.GetPoint(Identifier.Type); + if (_currentBaseAtchPoint == null) + { + _currentBaseAtchPoint = _currentBaseAtchFile.Points.First(); + Identifier = Identifier with { Type = _currentBaseAtchPoint.Type }; + } + + if (Identifier.EntryIndex >= _currentBaseAtchPoint.Entries.Length) + Identifier = Identifier with { EntryIndex = 0 }; + } + + private static void DrawIdentifier(AtchIdentifier identifier) + { + ImGui.TableNextColumn(); + ImUtf8.TextFramed(identifier.Race.ToName(), FrameColor); + ImUtf8.HoverTooltip("Model Race"u8); + + ImGui.TableNextColumn(); + DrawGender(ref identifier, true); + + ImGui.TableNextColumn(); + ImUtf8.TextFramed(identifier.Type.ToName(), FrameColor); + ImUtf8.HoverTooltip("Attachment Point Type"u8); + + ImGui.TableNextColumn(); + ImUtf8.TextFramed(identifier.EntryIndex.ToString(), FrameColor); + ImUtf8.HoverTooltip("State Entry Index"u8); + } + + private static bool DrawEntry(in AtchEntry defaultEntry, ref AtchEntry entry, bool disabled) + { + var changes = false; + using var dis = ImRaii.Disabled(disabled); + if (defaultEntry.Bone.Length == 0) + return false; + + ImGui.TableNextColumn(); + ImGui.SetNextItemWidth(200 * ImUtf8.GlobalScale); + if (ImUtf8.InputText("##BoneName"u8, entry.FullSpan, out TerminatedByteString newBone)) + { + entry.SetBoneName(newBone); + changes = true; + } + + ImUtf8.HoverTooltip(ImGuiHoveredFlags.AllowWhenDisabled, "Bone Name"u8); + + ImGui.SetNextItemWidth(200 * ImUtf8.GlobalScale); + changes |= ImUtf8.InputScalar("##AtchScale"u8, ref entry.Scale); + ImUtf8.HoverTooltip(ImGuiHoveredFlags.AllowWhenDisabled, "Scale"u8); + + ImGui.TableNextColumn(); + ImGui.SetNextItemWidth(120 * ImUtf8.GlobalScale); + changes |= ImUtf8.InputScalar("##AtchOffsetX"u8, ref entry.OffsetX); + ImUtf8.HoverTooltip(ImGuiHoveredFlags.AllowWhenDisabled, "Offset X-Coordinate"u8); + ImGui.SetNextItemWidth(120 * ImUtf8.GlobalScale); + changes |= ImUtf8.InputScalar("##AtchRotationX"u8, ref entry.RotationX); + ImUtf8.HoverTooltip(ImGuiHoveredFlags.AllowWhenDisabled, "Rotation X-Axis"u8); + + ImGui.TableNextColumn(); + ImGui.SetNextItemWidth(120 * ImUtf8.GlobalScale); + changes |= ImUtf8.InputScalar("##AtchOffsetY"u8, ref entry.OffsetY); + ImUtf8.HoverTooltip(ImGuiHoveredFlags.AllowWhenDisabled, "Offset Y-Coordinate"u8); + ImGui.SetNextItemWidth(120 * ImUtf8.GlobalScale); + changes |= ImUtf8.InputScalar("##AtchRotationY"u8, ref entry.RotationY); + ImUtf8.HoverTooltip(ImGuiHoveredFlags.AllowWhenDisabled, "Rotation Y-Axis"u8); + + ImGui.TableNextColumn(); + ImGui.SetNextItemWidth(120 * ImUtf8.GlobalScale); + changes |= ImUtf8.InputScalar("##AtchOffsetZ"u8, ref entry.OffsetZ); + ImUtf8.HoverTooltip("Offset Z-Coordinate"u8); + ImGui.SetNextItemWidth(120 * ImUtf8.GlobalScale); + changes |= ImUtf8.InputScalar("##AtchRotationZ"u8, ref entry.RotationZ); + ImUtf8.HoverTooltip(ImGuiHoveredFlags.AllowWhenDisabled, "Rotation Z-Axis"u8); + + return changes; + } + + private static bool DrawRace(ref AtchIdentifier identifier, float unscaledWidth = 100) + { + var ret = Combos.Race("##atchRace", identifier.Race, out var race, unscaledWidth); + ImUtf8.HoverTooltip("Model Race"u8); + if (ret) + identifier = identifier with { GenderRace = Names.CombinedRace(identifier.Gender, race) }; + + return ret; + } + + private static bool DrawGender(ref AtchIdentifier identifier, bool disabled) + { + var isMale = identifier.Gender is Gender.Male; + + if (!ImUtf8.IconButton(isMale ? FontAwesomeIcon.Mars : FontAwesomeIcon.Venus, "Gender"u8, buttonColor: disabled ? 0x000F0000u : 0) + || disabled) + return false; + + identifier = identifier with { GenderRace = Names.CombinedRace(isMale ? Gender.Female : Gender.Male, identifier.Race) }; + return true; + } + + private static bool DrawPointInput(ref AtchIdentifier identifier, AtchPointCombo combo) + { + if (!combo.Draw("##AtchPoint", identifier.Type.ToName(), "Attachment Point Type", 160 * ImUtf8.GlobalScale, + ImGui.GetTextLineHeightWithSpacing())) + return false; + + identifier = identifier with { Type = combo.CurrentSelection }; + return true; + } + + private static bool DrawEntryIndexInput(ref AtchIdentifier identifier, AtchPoint currentAtchPoint) + { + var index = identifier.EntryIndex; + ImGui.SetNextItemWidth(40 * ImUtf8.GlobalScale); + var ret = ImUtf8.DragScalar("##AtchEntry"u8, ref index, 0, (ushort)(currentAtchPoint.Entries.Length - 1), 0.05f, + ImGuiSliderFlags.AlwaysClamp); + ImUtf8.HoverTooltip("State Entry Index"u8); + if (!ret) + return false; + + index = Math.Clamp(index, (ushort)0, (ushort)(currentAtchPoint.Entries.Length - 1)); + identifier = identifier with { EntryIndex = index }; + return true; + } +} diff --git a/Penumbra/UI/AdvancedWindow/Meta/AtrMetaDrawer.cs b/Penumbra/UI/AdvancedWindow/Meta/AtrMetaDrawer.cs new file mode 100644 index 00000000..4b375c26 --- /dev/null +++ b/Penumbra/UI/AdvancedWindow/Meta/AtrMetaDrawer.cs @@ -0,0 +1,274 @@ +using Dalamud.Interface; +using Dalamud.Bindings.ImGui; +using Newtonsoft.Json.Linq; +using OtterGui.Raii; +using OtterGui.Services; +using OtterGui.Text; +using Penumbra.Collections.Cache; +using Penumbra.GameData.Enums; +using Penumbra.GameData.Structs; +using Penumbra.Meta; +using Penumbra.Meta.Files; +using Penumbra.Meta.Manipulations; +using Penumbra.Mods.Editor; +using Penumbra.UI.Classes; + +namespace Penumbra.UI.AdvancedWindow.Meta; + +public sealed class AtrMetaDrawer(ModMetaEditor editor, MetaFileManager metaFiles) + : MetaDrawer(editor, metaFiles), IService +{ + public override ReadOnlySpan Label + => "Attributes(ATR)###ATR"u8; + + private ShapeAttributeString _buffer = ShapeAttributeString.TryRead("atrx_"u8, out var s) ? s : ShapeAttributeString.Empty; + private bool _identifierValid; + + public override int NumColumns + => 7; + + public override float ColumnHeight + => ImUtf8.FrameHeightSpacing; + + protected override void Initialize() + { + Identifier = new AtrIdentifier(HumanSlot.Unknown, null, ShapeAttributeString.Empty, GenderRace.Unknown); + Entry = AtrEntry.True; + } + + protected override void DrawNew() + { + ImGui.TableNextColumn(); + CopyToClipboardButton("Copy all current ATR manipulations to clipboard."u8, + new Lazy(() => MetaDictionary.SerializeTo([], Editor.Atr))); + + ImGui.TableNextColumn(); + var canAdd = !Editor.Contains(Identifier) && _identifierValid; + var tt = canAdd + ? "Stage this edit."u8 + : _identifierValid + ? "This entry does not contain a valid attribute."u8 + : "This entry is already edited."u8; + if (ImUtf8.IconButton(FontAwesomeIcon.Plus, tt, disabled: !canAdd)) + Editor.Changes |= Editor.TryAdd(Identifier, AtrEntry.False); + + DrawIdentifierInput(ref Identifier); + DrawEntry(ref Entry, true); + } + + protected override void DrawEntry(AtrIdentifier identifier, AtrEntry entry) + { + DrawMetaButtons(identifier, entry); + DrawIdentifier(identifier); + + if (DrawEntry(ref entry, false)) + Editor.Changes |= Editor.Update(identifier, entry); + } + + protected override IEnumerable<(AtrIdentifier, AtrEntry)> Enumerate() + => Editor.Atr + .OrderBy(kvp => kvp.Key.Attribute) + .ThenBy(kvp => kvp.Key.Slot) + .ThenBy(kvp => kvp.Key.Id) + .Select(kvp => (kvp.Key, kvp.Value)); + + protected override int Count + => Editor.Atr.Count; + + private bool DrawIdentifierInput(ref AtrIdentifier identifier) + { + ImGui.TableNextColumn(); + var changes = DrawHumanSlot(ref identifier); + + ImGui.TableNextColumn(); + changes |= DrawGenderRaceConditionInput(ref identifier); + + ImGui.TableNextColumn(); + changes |= DrawPrimaryId(ref identifier); + + ImGui.TableNextColumn(); + changes |= DrawAttributeKeyInput(ref identifier, ref _buffer, ref _identifierValid); + return changes; + } + + private static void DrawIdentifier(AtrIdentifier identifier) + { + ImGui.TableNextColumn(); + + ImUtf8.TextFramed(ShpMetaDrawer.SlotName(identifier.Slot), FrameColor); + ImUtf8.HoverTooltip("Model Slot"u8); + + ImGui.TableNextColumn(); + if (identifier.GenderRaceCondition is not GenderRace.Unknown) + { + ImUtf8.TextFramed($"{identifier.GenderRaceCondition.ToName()} ({identifier.GenderRaceCondition.ToRaceCode()})", FrameColor); + ImUtf8.HoverTooltip("Gender & Race Code for this attribute to be set."); + } + else + { + ImUtf8.TextFramed("Any Gender & Race"u8, FrameColor); + } + + ImGui.TableNextColumn(); + if (identifier.Id.HasValue) + ImUtf8.TextFramed($"{identifier.Id.Value.Id}", FrameColor); + else + ImUtf8.TextFramed("All IDs"u8, FrameColor); + ImUtf8.HoverTooltip("Primary ID"u8); + + ImGui.TableNextColumn(); + ImUtf8.TextFramed(identifier.Attribute.AsSpan, FrameColor); + } + + private static bool DrawEntry(ref AtrEntry entry, bool disabled) + { + using var dis = ImRaii.Disabled(disabled); + ImGui.TableNextColumn(); + var value = entry.Value; + var changes = ImUtf8.Checkbox("##atrEntry"u8, ref value); + if (changes) + entry = new AtrEntry(value); + ImUtf8.HoverTooltip("Whether to enable or disable this attribute for the selected items."); + return changes; + } + + public static bool DrawPrimaryId(ref AtrIdentifier identifier, float unscaledWidth = 100) + { + var allSlots = identifier.Slot is HumanSlot.Unknown; + var all = !identifier.Id.HasValue; + var ret = false; + using (ImRaii.Disabled(allSlots)) + { + if (ImUtf8.Checkbox("##atrAll"u8, ref all)) + { + identifier = identifier with { Id = all ? null : 0 }; + ret = true; + } + } + + ImUtf8.HoverTooltip(allSlots + ? "When using all slots, you also need to use all IDs."u8 + : "Enable this attribute for all model IDs."u8); + + ImGui.SameLine(0, ImGui.GetStyle().ItemInnerSpacing.X); + if (all) + { + using var style = ImRaii.PushStyle(ImGuiStyleVar.ButtonTextAlign, new Vector2(0.05f, 0.5f)); + ImUtf8.TextFramed("All IDs"u8, ImGui.GetColorU32(ImGuiCol.FrameBg, all || allSlots ? ImGui.GetStyle().DisabledAlpha : 1f), + new Vector2(unscaledWidth, 0), ImGui.GetColorU32(ImGuiCol.TextDisabled)); + } + else + { + var max = identifier.Slot.ToSpecificEnum() is BodySlot ? byte.MaxValue : ExpandedEqpGmpBase.Count - 1; + if (IdInput("##atrPrimaryId"u8, unscaledWidth, identifier.Id.GetValueOrDefault(0).Id, out var setId, 0, max, false)) + { + identifier = identifier with { Id = setId }; + ret = true; + } + } + + ImUtf8.HoverTooltip("Primary ID - You can usually find this as the 'e####' part of an item path or similar for customizations."u8); + + return ret; + } + + public bool DrawHumanSlot(ref AtrIdentifier identifier, float unscaledWidth = 150) + { + var ret = false; + ImGui.SetNextItemWidth(unscaledWidth * ImUtf8.GlobalScale); + using (var combo = ImUtf8.Combo("##atrSlot"u8, ShpMetaDrawer.SlotName(identifier.Slot))) + { + if (combo) + foreach (var slot in ShpMetaDrawer.AvailableSlots) + { + if (!ImUtf8.Selectable(ShpMetaDrawer.SlotName(slot), slot == identifier.Slot) || slot == identifier.Slot) + continue; + + ret = true; + if (slot is HumanSlot.Unknown) + { + identifier = identifier with + { + Id = null, + Slot = slot, + }; + } + else + { + identifier = identifier with + { + Id = identifier.Id.HasValue + ? (PrimaryId)Math.Clamp(identifier.Id.Value.Id, 0, + slot.ToSpecificEnum() is BodySlot ? byte.MaxValue : ExpandedEqpGmpBase.Count - 1) + : null, + Slot = slot, + }; + ret = true; + } + } + } + + ImUtf8.HoverTooltip("Model Slot"u8); + return ret; + } + + private static bool DrawGenderRaceConditionInput(ref AtrIdentifier identifier, float unscaledWidth = 250) + { + var ret = false; + ImGui.SetNextItemWidth(unscaledWidth * ImUtf8.GlobalScale); + + using (var combo = ImUtf8.Combo("##shpGenderRace"u8, + identifier.GenderRaceCondition is GenderRace.Unknown + ? "Any Gender & Race" + : $"{identifier.GenderRaceCondition.ToName()} ({identifier.GenderRaceCondition.ToRaceCode()})")) + { + if (combo) + { + if (ImUtf8.Selectable("Any Gender & Race"u8, identifier.GenderRaceCondition is GenderRace.Unknown) + && identifier.GenderRaceCondition is not GenderRace.Unknown) + { + identifier = identifier with { GenderRaceCondition = GenderRace.Unknown }; + ret = true; + } + + foreach (var gr in ShapeAttributeHashSet.GenderRaceValues.Skip(1)) + { + if (ImUtf8.Selectable($"{gr.ToName()} ({gr.ToRaceCode()})", identifier.GenderRaceCondition == gr) + && identifier.GenderRaceCondition != gr) + { + identifier = identifier with { GenderRaceCondition = gr }; + ret = true; + } + } + } + } + + ImUtf8.HoverTooltip( + "Only activate this attribute for this gender & race code."u8); + + return ret; + } + + public static unsafe bool DrawAttributeKeyInput(ref AtrIdentifier identifier, ref ShapeAttributeString buffer, ref bool valid, + float unscaledWidth = 150) + { + var ret = false; + var ptr = Unsafe.AsPointer(ref buffer); + var span = new Span(ptr, ShapeAttributeString.MaxLength + 1); + using (new ImRaii.ColorStyle().Push(ImGuiCol.Border, Colors.RegexWarningBorder, !valid).Push(ImGuiStyleVar.FrameBorderSize, 1f, !valid)) + { + ImGui.SetNextItemWidth(unscaledWidth * ImUtf8.GlobalScale); + if (ImUtf8.InputText("##atrAttribute"u8, span, out int newLength, "Attribute..."u8)) + { + buffer.ForceLength((byte)newLength); + valid = buffer.ValidateCustomAttributeString(); + if (valid) + identifier = identifier with { Attribute = buffer }; + ret = true; + } + } + + ImUtf8.HoverTooltip("Supported attribute need to have the format `atrx_*` and a maximum length of 30 characters."u8); + return ret; + } +} diff --git a/Penumbra/UI/AdvancedWindow/Meta/EqdpMetaDrawer.cs b/Penumbra/UI/AdvancedWindow/Meta/EqdpMetaDrawer.cs new file mode 100644 index 00000000..16af5217 --- /dev/null +++ b/Penumbra/UI/AdvancedWindow/Meta/EqdpMetaDrawer.cs @@ -0,0 +1,166 @@ +using Dalamud.Interface; +using Dalamud.Interface.Utility.Raii; +using Dalamud.Bindings.ImGui; +using Newtonsoft.Json.Linq; +using OtterGui.Services; +using OtterGui.Text; +using Penumbra.GameData.Enums; +using Penumbra.Interop.Structs; +using Penumbra.Meta; +using Penumbra.Meta.Files; +using Penumbra.Meta.Manipulations; +using Penumbra.Mods.Editor; +using Penumbra.UI.Classes; + +namespace Penumbra.UI.AdvancedWindow.Meta; + +public sealed class EqdpMetaDrawer(ModMetaEditor editor, MetaFileManager metaFiles) + : MetaDrawer(editor, metaFiles), IService +{ + public override ReadOnlySpan Label + => "Racial Model Edits (EQDP)###EQDP"u8; + + public override int NumColumns + => 7; + + protected override void Initialize() + { + Identifier = new EqdpIdentifier(1, EquipSlot.Head, GenderRace.MidlanderMale); + UpdateEntry(); + } + + private void UpdateEntry() + => Entry = new EqdpEntryInternal(ExpandedEqdpFile.GetDefault(MetaFiles, Identifier), Identifier.Slot); + + protected override void DrawNew() + { + ImGui.TableNextColumn(); + CopyToClipboardButton("Copy all current EQDP manipulations to clipboard."u8, new Lazy(() => MetaDictionary.SerializeTo([], Editor.Eqdp))); + + ImGui.TableNextColumn(); + var validRaceCode = CharacterUtilityData.EqdpIdx(Identifier.GenderRace, false) >= 0; + var canAdd = validRaceCode && !Editor.Contains(Identifier); + var tt = canAdd ? "Stage this edit."u8 : + validRaceCode ? "This entry is already edited."u8 : "This combination of race and gender can not be used."u8; + if (ImUtf8.IconButton(FontAwesomeIcon.Plus, tt, disabled: !canAdd)) + Editor.Changes |= Editor.TryAdd(Identifier, Entry); + + if (DrawIdentifierInput(ref Identifier)) + UpdateEntry(); + + DrawEntry(Entry, ref Entry, true); + } + + protected override void DrawEntry(EqdpIdentifier identifier, EqdpEntryInternal entry) + { + DrawMetaButtons(identifier, entry); + DrawIdentifier(identifier); + + var defaultEntry = new EqdpEntryInternal(ExpandedEqdpFile.GetDefault(MetaFiles, identifier), identifier.Slot); + if (DrawEntry(defaultEntry, ref entry, false)) + Editor.Changes |= Editor.Update(identifier, entry); + } + + protected override IEnumerable<(EqdpIdentifier, EqdpEntryInternal)> Enumerate() + => Editor.Eqdp.OrderBy(kvp => kvp.Key.SetId.Id) + .ThenBy(kvp => kvp.Key.GenderRace) + .ThenBy(kvp => kvp.Key.Slot) + .Select(kvp => (kvp.Key, kvp.Value)); + + protected override int Count + => Editor.Eqdp.Count; + + private static bool DrawIdentifierInput(ref EqdpIdentifier identifier) + { + ImGui.TableNextColumn(); + var changes = DrawPrimaryId(ref identifier); + + ImGui.TableNextColumn(); + changes |= DrawRace(ref identifier); + + ImGui.TableNextColumn(); + changes |= DrawGender(ref identifier); + + ImGui.TableNextColumn(); + changes |= DrawEquipSlot(ref identifier); + return changes; + } + + private static void DrawIdentifier(EqdpIdentifier identifier) + { + ImGui.TableNextColumn(); + ImUtf8.TextFramed($"{identifier.SetId.Id}", FrameColor); + ImUtf8.HoverTooltip("Model Set ID"u8); + + ImGui.TableNextColumn(); + ImUtf8.TextFramed(identifier.Race.ToName(), FrameColor); + ImUtf8.HoverTooltip("Model Race"u8); + + ImGui.TableNextColumn(); + ImUtf8.TextFramed(identifier.Gender.ToName(), FrameColor); + ImUtf8.HoverTooltip("Gender"u8); + + ImGui.TableNextColumn(); + ImUtf8.TextFramed(identifier.Slot.ToName(), FrameColor); + ImUtf8.HoverTooltip("Equip Slot"u8); + } + + private static bool DrawEntry(EqdpEntryInternal defaultEntry, ref EqdpEntryInternal entry, bool disabled) + { + var changes = false; + using var dis = ImRaii.Disabled(disabled); + ImGui.TableNextColumn(); + if (Checkmark("Material##eqdp"u8, "\0"u8, entry.Material, defaultEntry.Material, out var newMaterial)) + { + entry = entry with { Material = newMaterial }; + changes = true; + } + + ImGui.SameLine(); + if (Checkmark("Model##eqdp"u8, "\0"u8, entry.Model, defaultEntry.Model, out var newModel)) + { + entry = entry with { Model = newModel }; + changes = true; + } + + return changes; + } + + public static bool DrawPrimaryId(ref EqdpIdentifier identifier, float unscaledWidth = 100) + { + var ret = IdInput("##eqdpPrimaryId"u8, unscaledWidth, identifier.SetId.Id, out var setId, 0, ExpandedEqpGmpBase.Count - 1, + identifier.SetId.Id <= 1); + ImUtf8.HoverTooltip( + "Model Set ID - You can usually find this as the 'e####' part of an item path.\nThis should generally not be left <= 1 unless you explicitly want that."u8); + if (ret) + identifier = identifier with { SetId = setId }; + return ret; + } + + public static bool DrawRace(ref EqdpIdentifier identifier, float unscaledWidth = 100) + { + var ret = Combos.Race("##eqdpRace", identifier.Race, out var race, unscaledWidth); + ImUtf8.HoverTooltip("Model Race"u8); + if (ret) + identifier = identifier with { GenderRace = Names.CombinedRace(identifier.Gender, race) }; + return ret; + } + + public static bool DrawGender(ref EqdpIdentifier identifier, float unscaledWidth = 120) + { + var ret = Combos.Gender("##eqdpGender", identifier.Gender, out var gender, unscaledWidth); + ImUtf8.HoverTooltip("Gender"u8); + if (ret) + identifier = identifier with { GenderRace = Names.CombinedRace(gender, identifier.Race) }; + return ret; + } + + public static bool DrawEquipSlot(ref EqdpIdentifier identifier, float unscaledWidth = 100) + { + var ret = Combos.EqdpEquipSlot("##eqdpSlot", identifier.Slot, out var slot, unscaledWidth); + ImUtf8.HoverTooltip("Equip Slot"u8); + if (ret) + identifier = identifier with { Slot = slot }; + return ret; + } +} diff --git a/Penumbra/UI/AdvancedWindow/Meta/EqpMetaDrawer.cs b/Penumbra/UI/AdvancedWindow/Meta/EqpMetaDrawer.cs new file mode 100644 index 00000000..77c2915a --- /dev/null +++ b/Penumbra/UI/AdvancedWindow/Meta/EqpMetaDrawer.cs @@ -0,0 +1,141 @@ +using Dalamud.Interface; +using Dalamud.Bindings.ImGui; +using Newtonsoft.Json.Linq; +using OtterGui.Raii; +using OtterGui.Services; +using OtterGui.Text; +using Penumbra.GameData.Enums; +using Penumbra.GameData.Structs; +using Penumbra.Meta; +using Penumbra.Meta.Files; +using Penumbra.Meta.Manipulations; +using Penumbra.Mods.Editor; +using Penumbra.UI.Classes; + +namespace Penumbra.UI.AdvancedWindow.Meta; + +public sealed class EqpMetaDrawer(ModMetaEditor editor, MetaFileManager metaFiles) + : MetaDrawer(editor, metaFiles), IService +{ + public override ReadOnlySpan Label + => "Equipment Parameter Edits (EQP)###EQP"u8; + + public override int NumColumns + => 5; + + protected override void Initialize() + { + Identifier = new EqpIdentifier(1, EquipSlot.Body); + UpdateEntry(); + } + + private void UpdateEntry() + => Entry = new EqpEntryInternal(ExpandedEqpFile.GetDefault(MetaFiles, Identifier.SetId), Identifier.Slot); + + protected override void DrawNew() + { + ImGui.TableNextColumn(); + CopyToClipboardButton("Copy all current EQP manipulations to clipboard."u8, new Lazy(() => MetaDictionary.SerializeTo([], Editor.Eqp))); + + ImGui.TableNextColumn(); + var canAdd = !Editor.Contains(Identifier); + var tt = canAdd ? "Stage this edit."u8 : "This entry is already edited."u8; + if (ImUtf8.IconButton(FontAwesomeIcon.Plus, tt, disabled: !canAdd)) + Editor.Changes |= Editor.TryAdd(Identifier, Entry); + + if (DrawIdentifierInput(ref Identifier)) + UpdateEntry(); + + DrawEntry(Identifier.Slot, Entry, ref Entry, true); + } + + protected override void DrawEntry(EqpIdentifier identifier, EqpEntryInternal entry) + { + DrawMetaButtons(identifier, entry); + DrawIdentifier(identifier); + + var defaultEntry = new EqpEntryInternal(ExpandedEqpFile.GetDefault(MetaFiles, identifier.SetId), identifier.Slot); + if (DrawEntry(identifier.Slot, defaultEntry, ref entry, false)) + Editor.Changes |= Editor.Update(identifier, entry); + } + + protected override IEnumerable<(EqpIdentifier, EqpEntryInternal)> Enumerate() + => Editor.Eqp + .OrderBy(kvp => kvp.Key.SetId.Id) + .ThenBy(kvp => kvp.Key.Slot) + .Select(kvp => (kvp.Key, kvp.Value)); + + protected override int Count + => Editor.Eqp.Count; + + private static bool DrawIdentifierInput(ref EqpIdentifier identifier) + { + ImGui.TableNextColumn(); + var changes = DrawPrimaryId(ref identifier); + + ImGui.TableNextColumn(); + changes |= DrawEquipSlot(ref identifier); + return changes; + } + + private static void DrawIdentifier(EqpIdentifier identifier) + { + ImGui.TableNextColumn(); + ImUtf8.TextFramed($"{identifier.SetId.Id}", FrameColor); + ImUtf8.HoverTooltip("Model Set ID"u8); + + ImGui.TableNextColumn(); + ImUtf8.TextFramed(identifier.Slot.ToName(), FrameColor); + ImUtf8.HoverTooltip("Equip Slot"u8); + } + + private static bool DrawEntry(EquipSlot slot, EqpEntryInternal defaultEntry, ref EqpEntryInternal entry, bool disabled) + { + var changes = false; + using var dis = ImRaii.Disabled(disabled); + ImGui.TableNextColumn(); + var offset = Eqp.OffsetAndMask(slot).Item1; + DrawBox(ref entry, 0); + for (var i = 1; i < Eqp.EqpAttributes[slot].Count; ++i) + { + ImUtf8.SameLineInner(); + DrawBox(ref entry, i); + } + + return changes; + + void DrawBox(ref EqpEntryInternal entry, int i) + { + using var id = ImUtf8.PushId(i); + var flag = 1u << i; + var eqpFlag = (EqpEntry)((ulong)flag << offset); + var defaultValue = (flag & defaultEntry.Value) != 0; + var value = (flag & entry.Value) != 0; + if (Checkmark("##eqp"u8, eqpFlag.ToLocalName(), value, defaultValue, out var newValue)) + { + entry = new EqpEntryInternal(newValue ? entry.Value | flag : entry.Value & ~flag); + changes = true; + } + } + } + + public static bool DrawPrimaryId(ref EqpIdentifier identifier, float unscaledWidth = 100) + { + var ret = IdInput("##eqpPrimaryId"u8, unscaledWidth, identifier.SetId.Id, out var setId, 0, ExpandedEqpGmpBase.Count - 1, + identifier.SetId.Id <= 1); + ImUtf8.HoverTooltip( + "Model Set ID - You can usually find this as the 'e####' part of an item path.\nThis should generally not be left <= 1 unless you explicitly want that."u8); + if (ret) + identifier = identifier with { SetId = setId }; + return ret; + } + + public static bool DrawEquipSlot(ref EqpIdentifier identifier, float unscaledWidth = 100) + { + var ret = Combos.EqpEquipSlot("##eqpSlot", identifier.Slot, out var slot, unscaledWidth); + ImUtf8.HoverTooltip("Equip Slot"u8); + if (ret) + identifier = identifier with { Slot = slot }; + return ret; + } +} diff --git a/Penumbra/UI/AdvancedWindow/Meta/EstMetaDrawer.cs b/Penumbra/UI/AdvancedWindow/Meta/EstMetaDrawer.cs new file mode 100644 index 00000000..84e09be5 --- /dev/null +++ b/Penumbra/UI/AdvancedWindow/Meta/EstMetaDrawer.cs @@ -0,0 +1,155 @@ +using Dalamud.Interface; +using Dalamud.Interface.Utility.Raii; +using Dalamud.Bindings.ImGui; +using Newtonsoft.Json.Linq; +using OtterGui.Services; +using OtterGui.Text; +using Penumbra.GameData.Enums; +using Penumbra.Meta; +using Penumbra.Meta.Files; +using Penumbra.Meta.Manipulations; +using Penumbra.Mods.Editor; +using Penumbra.UI.Classes; + +namespace Penumbra.UI.AdvancedWindow.Meta; + +public sealed class EstMetaDrawer(ModMetaEditor editor, MetaFileManager metaFiles) + : MetaDrawer(editor, metaFiles), IService +{ + public override ReadOnlySpan Label + => "Extra Skeleton Parameters (EST)###EST"u8; + + public override int NumColumns + => 7; + + protected override void Initialize() + { + Identifier = new EstIdentifier(1, EstType.Hair, GenderRace.MidlanderMale); + UpdateEntry(); + } + + private void UpdateEntry() + => Entry = EstFile.GetDefault(MetaFiles, Identifier.Slot, Identifier.GenderRace, Identifier.SetId); + + protected override void DrawNew() + { + ImGui.TableNextColumn(); + CopyToClipboardButton("Copy all current EST manipulations to clipboard."u8, new Lazy(() => MetaDictionary.SerializeTo([], Editor.Est))); + + ImGui.TableNextColumn(); + var canAdd = !Editor.Contains(Identifier); + var tt = canAdd ? "Stage this edit."u8 : "This entry is already edited."u8; + if (ImUtf8.IconButton(FontAwesomeIcon.Plus, tt, disabled: !canAdd)) + Editor.Changes |= Editor.TryAdd(Identifier, Entry); + + if (DrawIdentifierInput(ref Identifier)) + UpdateEntry(); + + DrawEntry(Entry, ref Entry, true); + } + + protected override void DrawEntry(EstIdentifier identifier, EstEntry entry) + { + DrawMetaButtons(identifier, entry); + DrawIdentifier(identifier); + + var defaultEntry = EstFile.GetDefault(MetaFiles, identifier.Slot, identifier.GenderRace, identifier.SetId); + if (DrawEntry(defaultEntry, ref entry, false)) + Editor.Changes |= Editor.Update(identifier, entry); + } + + protected override IEnumerable<(EstIdentifier, EstEntry)> Enumerate() + => Editor.Est + .OrderBy(kvp => kvp.Key.SetId.Id) + .ThenBy(kvp => kvp.Key.GenderRace) + .ThenBy(kvp => kvp.Key.Slot) + .Select(kvp => (kvp.Key, kvp.Value)); + + protected override int Count + => Editor.Est.Count; + + private static bool DrawIdentifierInput(ref EstIdentifier identifier) + { + ImGui.TableNextColumn(); + var changes = DrawPrimaryId(ref identifier); + + ImGui.TableNextColumn(); + changes |= DrawRace(ref identifier); + + ImGui.TableNextColumn(); + changes |= DrawGender(ref identifier); + + ImGui.TableNextColumn(); + changes |= DrawSlot(ref identifier); + + return changes; + } + + private static void DrawIdentifier(EstIdentifier identifier) + { + ImGui.TableNextColumn(); + ImUtf8.TextFramed($"{identifier.SetId.Id}", FrameColor); + ImUtf8.HoverTooltip("Model Set ID"u8); + + ImGui.TableNextColumn(); + ImUtf8.TextFramed(identifier.Race.ToName(), FrameColor); + ImUtf8.HoverTooltip("Model Race"u8); + + ImGui.TableNextColumn(); + ImUtf8.TextFramed(identifier.Gender.ToName(), FrameColor); + ImUtf8.HoverTooltip("Gender"u8); + + ImGui.TableNextColumn(); + ImUtf8.TextFramed(identifier.Slot.ToString(), FrameColor); + ImUtf8.HoverTooltip("Extra Skeleton Type"u8); + } + + private static bool DrawEntry(EstEntry defaultEntry, ref EstEntry entry, bool disabled) + { + using var dis = ImRaii.Disabled(disabled); + ImGui.TableNextColumn(); + var ret = DragInput("##estValue"u8, [], 100f * ImUtf8.GlobalScale, entry.Value, defaultEntry.Value, out var newValue, (ushort)0, + ushort.MaxValue, 0.05f, !disabled); + if (ret) + entry = new EstEntry(newValue); + return ret; + } + + public static bool DrawPrimaryId(ref EstIdentifier identifier, float unscaledWidth = 100) + { + var ret = IdInput("##estPrimaryId"u8, unscaledWidth, identifier.SetId.Id, out var setId, 0, ExpandedEqpGmpBase.Count - 1, + identifier.SetId.Id <= 1); + ImUtf8.HoverTooltip( + "Model Set ID - You can usually find this as the 'e####' part of an item path.\nThis should generally not be left <= 1 unless you explicitly want that."u8); + if (ret) + identifier = identifier with { SetId = setId }; + return ret; + } + + public static bool DrawRace(ref EstIdentifier identifier, float unscaledWidth = 100) + { + var ret = Combos.Race("##estRace", identifier.Race, out var race, unscaledWidth); + ImUtf8.HoverTooltip("Model Race"u8); + if (ret) + identifier = identifier with { GenderRace = Names.CombinedRace(identifier.Gender, race) }; + return ret; + } + + public static bool DrawGender(ref EstIdentifier identifier, float unscaledWidth = 120) + { + var ret = Combos.Gender("##estGender", identifier.Gender, out var gender, unscaledWidth); + ImUtf8.HoverTooltip("Gender"u8); + if (ret) + identifier = identifier with { GenderRace = Names.CombinedRace(gender, identifier.Race) }; + return ret; + } + + public static bool DrawSlot(ref EstIdentifier identifier, float unscaledWidth = 200) + { + var ret = Combos.EstSlot("##estSlot", identifier.Slot, out var slot, unscaledWidth); + ImUtf8.HoverTooltip("Extra Skeleton Type"u8); + if (ret) + identifier = identifier with { Slot = slot }; + return ret; + } +} diff --git a/Penumbra/UI/AdvancedWindow/Meta/GlobalEqpMetaDrawer.cs b/Penumbra/UI/AdvancedWindow/Meta/GlobalEqpMetaDrawer.cs new file mode 100644 index 00000000..b03f4aa5 --- /dev/null +++ b/Penumbra/UI/AdvancedWindow/Meta/GlobalEqpMetaDrawer.cs @@ -0,0 +1,118 @@ +using Dalamud.Interface; +using Dalamud.Bindings.ImGui; +using Newtonsoft.Json.Linq; +using OtterGui.Services; +using OtterGui.Text; +using Penumbra.Meta; +using Penumbra.Meta.Manipulations; +using Penumbra.Mods.Editor; + +namespace Penumbra.UI.AdvancedWindow.Meta; + +public sealed class GlobalEqpMetaDrawer(ModMetaEditor editor, MetaFileManager metaFiles) + : MetaDrawer(editor, metaFiles), IService +{ + public override ReadOnlySpan Label + => "Global Equipment Parameter Edits (Global EQP)###GEQP"u8; + + public override int NumColumns + => 4; + + protected override void Initialize() + { + Identifier = new GlobalEqpManipulation() + { + Condition = 1, + Type = GlobalEqpType.DoNotHideEarrings, + }; + } + + protected override void DrawNew() + { + ImGui.TableNextColumn(); + CopyToClipboardButton("Copy all current global EQP manipulations to clipboard."u8, new Lazy(() => MetaDictionary.SerializeTo([], Editor.GlobalEqp))); + + ImGui.TableNextColumn(); + var canAdd = !Editor.Contains(Identifier); + var tt = canAdd ? "Stage this edit."u8 : "This entry is already edited."u8; + if (ImUtf8.IconButton(FontAwesomeIcon.Plus, tt, disabled: !canAdd)) + Editor.Changes |= Editor.TryAdd(Identifier); + + DrawIdentifierInput(ref Identifier); + } + + protected override void DrawEntry(GlobalEqpManipulation identifier, byte _) + { + DrawMetaButtons(identifier, 0); + DrawIdentifier(identifier); + } + + protected override IEnumerable<(GlobalEqpManipulation, byte)> Enumerate() + => Editor.GlobalEqp + .OrderBy(identifier => identifier.Type) + .ThenBy(identifier => identifier.Condition.Id) + .Select(identifier => (identifier, (byte)0)); + + protected override int Count + => Editor.GlobalEqp.Count; + + private static void DrawIdentifierInput(ref GlobalEqpManipulation identifier) + { + ImGui.TableNextColumn(); + DrawType(ref identifier); + + ImGui.TableNextColumn(); + if (identifier.Type.HasCondition()) + DrawCondition(ref identifier); + else + ImUtf8.ScaledDummy(100); + } + + private static void DrawIdentifier(GlobalEqpManipulation identifier) + { + ImGui.TableNextColumn(); + ImUtf8.TextFramed(identifier.Type.ToName(), FrameColor); + ImUtf8.HoverTooltip("Global EQP Type"u8); + + ImGui.TableNextColumn(); + if (identifier.Type.HasCondition()) + { + ImUtf8.TextFramed($"{identifier.Condition.Id}", FrameColor); + ImUtf8.HoverTooltip("Conditional Model ID"u8); + } + } + + public static bool DrawType(ref GlobalEqpManipulation identifier, float unscaledWidth = 250) + { + ImGui.SetNextItemWidth(unscaledWidth * ImUtf8.GlobalScale); + using var combo = ImUtf8.Combo("##geqpType"u8, identifier.Type.ToName()); + if (!combo) + return false; + + var ret = false; + foreach (var type in Enum.GetValues()) + { + if (ImUtf8.Selectable(type.ToName(), type == identifier.Type)) + { + identifier = new GlobalEqpManipulation + { + Type = type, + Condition = type.HasCondition() ? identifier.Type.HasCondition() ? identifier.Condition : 1 : 0, + }; + ret = true; + } + + ImUtf8.HoverTooltip(type.ToDescription()); + } + + return ret; + } + + public static void DrawCondition(ref GlobalEqpManipulation identifier, float unscaledWidth = 100) + { + if (IdInput("##geqpCond"u8, unscaledWidth, identifier.Condition.Id, out var newId, 1, ushort.MaxValue, + identifier.Condition.Id <= 1)) + identifier = identifier with { Condition = newId }; + ImUtf8.HoverTooltip("The Model ID for the item that should not be hidden."u8); + } +} diff --git a/Penumbra/UI/AdvancedWindow/Meta/GmpMetaDrawer.cs b/Penumbra/UI/AdvancedWindow/Meta/GmpMetaDrawer.cs new file mode 100644 index 00000000..4053560b --- /dev/null +++ b/Penumbra/UI/AdvancedWindow/Meta/GmpMetaDrawer.cs @@ -0,0 +1,154 @@ +using Dalamud.Interface; +using Dalamud.Interface.Utility.Raii; +using Dalamud.Bindings.ImGui; +using OtterGui.Services; +using OtterGui.Text; +using Penumbra.GameData.Structs; +using Penumbra.Meta.Files; +using Penumbra.Meta; +using Penumbra.Meta.Manipulations; +using Penumbra.Mods.Editor; +using Newtonsoft.Json.Linq; + +namespace Penumbra.UI.AdvancedWindow.Meta; + +public sealed class GmpMetaDrawer(ModMetaEditor editor, MetaFileManager metaFiles) + : MetaDrawer(editor, metaFiles), IService +{ + public override ReadOnlySpan Label + => "Visor/Gimmick Edits (GMP)###GMP"u8; + + public override int NumColumns + => 7; + + protected override void Initialize() + { + Identifier = new GmpIdentifier(1); + UpdateEntry(); + } + + private void UpdateEntry() + => Entry = ExpandedGmpFile.GetDefault(MetaFiles, Identifier.SetId); + + protected override void DrawNew() + { + ImGui.TableNextColumn(); + CopyToClipboardButton("Copy all current Gmp manipulations to clipboard."u8, new Lazy(() => MetaDictionary.SerializeTo([], Editor.Gmp))); + + ImGui.TableNextColumn(); + var canAdd = !Editor.Contains(Identifier); + var tt = canAdd ? "Stage this edit."u8 : "This entry is already edited."u8; + if (ImUtf8.IconButton(FontAwesomeIcon.Plus, tt, disabled: !canAdd)) + Editor.Changes |= Editor.TryAdd(Identifier, Entry); + + if (DrawIdentifierInput(ref Identifier)) + UpdateEntry(); + + DrawEntry(Entry, ref Entry, true); + } + + protected override void DrawEntry(GmpIdentifier identifier, GmpEntry entry) + { + DrawMetaButtons(identifier, entry); + DrawIdentifier(identifier); + + var defaultEntry = ExpandedGmpFile.GetDefault(MetaFiles, identifier.SetId); + if (DrawEntry(defaultEntry, ref entry, false)) + Editor.Changes |= Editor.Update(identifier, entry); + } + + protected override IEnumerable<(GmpIdentifier, GmpEntry)> Enumerate() + => Editor.Gmp + .OrderBy(kvp => kvp.Key.SetId.Id) + .Select(kvp => (kvp.Key, kvp.Value)); + + protected override int Count + => Editor.Gmp.Count; + + private static bool DrawIdentifierInput(ref GmpIdentifier identifier) + { + ImGui.TableNextColumn(); + return DrawPrimaryId(ref identifier); + } + + private static void DrawIdentifier(GmpIdentifier identifier) + { + ImGui.TableNextColumn(); + ImUtf8.TextFramed($"{identifier.SetId.Id}", FrameColor); + ImUtf8.HoverTooltip("Model Set ID"u8); + } + + private static bool DrawEntry(GmpEntry defaultEntry, ref GmpEntry entry, bool disabled) + { + using var dis = ImRaii.Disabled(disabled); + ImGui.TableNextColumn(); + var changes = false; + if (Checkmark("##gmpEnabled"u8, "Gimmick Enabled", entry.Enabled, defaultEntry.Enabled, out var enabled)) + { + entry = entry with { Enabled = enabled }; + changes = true; + } + + ImGui.TableNextColumn(); + if (Checkmark("##gmpAnimated"u8, "Gimmick Animated", entry.Animated, defaultEntry.Animated, out var animated)) + { + entry = entry with { Animated = animated }; + changes = true; + } + + var rotationWidth = 75 * ImUtf8.GlobalScale; + ImGui.TableNextColumn(); + if (DragInput("##gmpRotationA"u8, "Rotation A in Degrees"u8, rotationWidth, entry.RotationA, defaultEntry.RotationA, out var rotationA, + (ushort)0, (ushort)360, 0.05f, !disabled)) + { + entry = entry with { RotationA = rotationA }; + changes = true; + } + + ImUtf8.SameLineInner(); + if (DragInput("##gmpRotationB"u8, "Rotation B in Degrees"u8, rotationWidth, entry.RotationB, defaultEntry.RotationB, out var rotationB, + (ushort)0, (ushort)360, 0.05f, !disabled)) + { + entry = entry with { RotationB = rotationB }; + changes = true; + } + + ImUtf8.SameLineInner(); + if (DragInput("##gmpRotationC"u8, "Rotation C in Degrees"u8, rotationWidth, entry.RotationC, defaultEntry.RotationC, out var rotationC, + (ushort)0, (ushort)360, 0.05f, !disabled)) + { + entry = entry with { RotationC = rotationC }; + changes = true; + } + + var unkWidth = 50 * ImUtf8.GlobalScale; + ImGui.TableNextColumn(); + if (DragInput("##gmpUnkA"u8, "Animation Type A?"u8, unkWidth, entry.UnknownA, defaultEntry.UnknownA, out var unknownA, + (byte)0, (byte)15, 0.01f, !disabled)) + { + entry = entry with { UnknownA = unknownA }; + changes = true; + } + + ImUtf8.SameLineInner(); + if (DragInput("##gmpUnkB"u8, "Animation Type B?"u8, unkWidth, entry.UnknownB, defaultEntry.UnknownB, out var unknownB, + (byte)0, (byte)15, 0.01f, !disabled)) + { + entry = entry with { UnknownB = unknownB }; + changes = true; + } + + return changes; + } + + public static bool DrawPrimaryId(ref GmpIdentifier identifier, float unscaledWidth = 100) + { + var ret = IdInput("##gmpPrimaryId"u8, unscaledWidth, identifier.SetId.Id, out var setId, 1, ExpandedEqpGmpBase.Count - 1, + identifier.SetId.Id <= 1); + ImUtf8.HoverTooltip( + "Model Set ID - You can usually find this as the 'e####' part of an item path.\nThis should generally not be left <= 1 unless you explicitly want that."u8); + if (ret) + identifier = new GmpIdentifier(setId); + return ret; + } +} diff --git a/Penumbra/UI/AdvancedWindow/Meta/ImcMetaDrawer.cs b/Penumbra/UI/AdvancedWindow/Meta/ImcMetaDrawer.cs new file mode 100644 index 00000000..bb87cd47 --- /dev/null +++ b/Penumbra/UI/AdvancedWindow/Meta/ImcMetaDrawer.cs @@ -0,0 +1,304 @@ +using Dalamud.Interface; +using Dalamud.Bindings.ImGui; +using Newtonsoft.Json.Linq; +using OtterGui.Raii; +using OtterGui.Services; +using OtterGui.Text; +using Penumbra.GameData.Enums; +using Penumbra.GameData.Structs; +using Penumbra.Meta; +using Penumbra.Meta.Manipulations; +using Penumbra.Mods.Editor; +using Penumbra.UI.Classes; + +namespace Penumbra.UI.AdvancedWindow.Meta; + +public sealed class ImcMetaDrawer(ModMetaEditor editor, MetaFileManager metaFiles) + : MetaDrawer(editor, metaFiles), IService +{ + public override ReadOnlySpan Label + => "Variant Edits (IMC)###IMC"u8; + + public override int NumColumns + => 10; + + private bool _fileExists; + + protected override void Initialize() + { + Identifier = ImcIdentifier.Default; + UpdateEntry(); + } + + private void UpdateEntry() + => (Entry, _fileExists, _) = ImcChecker.GetDefaultEntry(Identifier, true); + + protected override void DrawNew() + { + ImGui.TableNextColumn(); + CopyToClipboardButton("Copy all current IMC manipulations to clipboard."u8, new Lazy(() => MetaDictionary.SerializeTo([], Editor.Imc))); + ImGui.TableNextColumn(); + var canAdd = _fileExists && !Editor.Contains(Identifier); + var tt = canAdd ? "Stage this edit."u8 : !_fileExists ? "This IMC file does not exist."u8 : "This entry is already edited."u8; + if (ImUtf8.IconButton(FontAwesomeIcon.Plus, tt, disabled: !canAdd)) + Editor.Changes |= Editor.TryAdd(Identifier, Entry); + + if (DrawIdentifierInput(ref Identifier)) + UpdateEntry(); + + using var disabled = ImRaii.Disabled(); + DrawEntry(Entry, ref Entry, false); + } + + protected override void DrawEntry(ImcIdentifier identifier, ImcEntry entry) + { + DrawMetaButtons(identifier, entry); + DrawIdentifier(identifier); + + var defaultEntry = ImcChecker.GetDefaultEntry(identifier, true).Entry; + if (DrawEntry(defaultEntry, ref entry, true)) + Editor.Changes |= Editor.Update(identifier, entry); + } + + private static bool DrawIdentifierInput(ref ImcIdentifier identifier) + { + ImGui.TableNextColumn(); + var change = DrawObjectType(ref identifier); + + ImGui.TableNextColumn(); + change |= DrawPrimaryId(ref identifier); + + ImGui.TableNextColumn(); + if (identifier.ObjectType is ObjectType.Equipment or ObjectType.Accessory) + change |= DrawSlot(ref identifier); + else + change |= DrawSecondaryId(ref identifier); + + ImGui.TableNextColumn(); + change |= DrawVariant(ref identifier); + + ImGui.TableNextColumn(); + if (identifier.ObjectType is ObjectType.DemiHuman) + change |= DrawSlot(ref identifier, 70f); + else + ImUtf8.ScaledDummy(70f); + return change; + } + + private static void DrawIdentifier(ImcIdentifier identifier) + { + ImGui.TableNextColumn(); + ImUtf8.TextFramed(identifier.ObjectType.ToName(), FrameColor); + ImUtf8.HoverTooltip("Object Type"u8); + + ImGui.TableNextColumn(); + ImUtf8.TextFramed($"{identifier.PrimaryId.Id}", FrameColor); + ImUtf8.HoverTooltip("Primary ID"); + + ImGui.TableNextColumn(); + if (identifier.ObjectType is ObjectType.Equipment or ObjectType.Accessory) + { + ImUtf8.TextFramed(identifier.EquipSlot.ToName(), FrameColor); + ImUtf8.HoverTooltip("Equip Slot"u8); + } + else + { + ImUtf8.TextFramed($"{identifier.SecondaryId.Id}", FrameColor); + ImUtf8.HoverTooltip("Secondary ID"u8); + } + + ImGui.TableNextColumn(); + ImUtf8.TextFramed($"{identifier.Variant.Id}", FrameColor); + ImUtf8.HoverTooltip("Variant"u8); + + ImGui.TableNextColumn(); + if (identifier.ObjectType is ObjectType.DemiHuman) + { + ImUtf8.TextFramed(identifier.EquipSlot.ToName(), FrameColor); + ImUtf8.HoverTooltip("Equip Slot"u8); + } + } + + private static bool DrawEntry(ImcEntry defaultEntry, ref ImcEntry entry, bool addDefault) + { + ImGui.TableNextColumn(); + var change = DrawMaterialId(defaultEntry, ref entry, addDefault); + ImUtf8.SameLineInner(); + change |= DrawMaterialAnimationId(defaultEntry, ref entry, addDefault); + + ImGui.TableNextColumn(); + change |= DrawDecalId(defaultEntry, ref entry, addDefault); + ImUtf8.SameLineInner(); + change |= DrawVfxId(defaultEntry, ref entry, addDefault); + ImUtf8.SameLineInner(); + change |= DrawSoundId(defaultEntry, ref entry, addDefault); + + ImGui.TableNextColumn(); + change |= DrawAttributes(defaultEntry, ref entry); + return change; + } + + + protected override IEnumerable<(ImcIdentifier, ImcEntry)> Enumerate() + => Editor.Imc + .OrderBy(kvp => kvp.Key.ObjectType) + .ThenBy(kvp => kvp.Key.PrimaryId.Id) + .ThenBy(kvp => kvp.Key.EquipSlot) + .ThenBy(kvp => kvp.Key.BodySlot) + .ThenBy(kvp => kvp.Key.SecondaryId.Id) + .ThenBy(kvp => kvp.Key.Variant.Id) + .Select(kvp => (kvp.Key, kvp.Value)); + + protected override int Count + => Editor.Imc.Count; + + public static bool DrawObjectType(ref ImcIdentifier identifier, float width = 110) + { + var ret = Combos.ImcType("##imcType", identifier.ObjectType, out var type, width); + ImUtf8.HoverTooltip("Object Type"u8); + + if (ret) + { + var (equipSlot, secondaryId) = type switch + { + ObjectType.Equipment => (identifier.EquipSlot.IsEquipment() ? identifier.EquipSlot : EquipSlot.Head, (SecondaryId)0), + ObjectType.DemiHuman => (identifier.EquipSlot.IsEquipment() ? identifier.EquipSlot : EquipSlot.Head, + identifier.SecondaryId == 0 ? 1 : identifier.SecondaryId), + ObjectType.Accessory => (identifier.EquipSlot.IsAccessory() ? identifier.EquipSlot : EquipSlot.Ears, (SecondaryId)0), + _ => (EquipSlot.Unknown, identifier.SecondaryId == 0 ? 1 : identifier.SecondaryId), + }; + identifier = identifier with + { + ObjectType = type, + EquipSlot = equipSlot, + SecondaryId = secondaryId, + }; + } + + return ret; + } + + public static bool DrawPrimaryId(ref ImcIdentifier identifier, float unscaledWidth = 80) + { + var ret = IdInput("##imcPrimaryId"u8, unscaledWidth, identifier.PrimaryId.Id, out var newId, 0, ushort.MaxValue, + identifier.PrimaryId.Id <= 1); + ImUtf8.HoverTooltip("Primary ID - You can usually find this as the 'x####' part of an item path.\n"u8 + + "This should generally not be left <= 1 unless you explicitly want that."u8); + if (ret) + identifier = identifier with { PrimaryId = newId }; + return ret; + } + + public static bool DrawSecondaryId(ref ImcIdentifier identifier, float unscaledWidth = 100) + { + var ret = IdInput("##imcSecondaryId"u8, unscaledWidth, identifier.SecondaryId.Id, out var newId, 0, ushort.MaxValue, false); + ImUtf8.HoverTooltip("Secondary ID"u8); + if (ret) + identifier = identifier with { SecondaryId = newId }; + return ret; + } + + public static bool DrawVariant(ref ImcIdentifier identifier, float unscaledWidth = 45) + { + var ret = IdInput("##imcVariant"u8, unscaledWidth, identifier.Variant.Id, out var newId, 0, byte.MaxValue, false); + ImUtf8.HoverTooltip("Variant ID"u8); + if (ret) + identifier = identifier with { Variant = (byte)newId }; + return ret; + } + + public static bool DrawSlot(ref ImcIdentifier identifier, float unscaledWidth = 100) + { + bool ret; + EquipSlot slot; + switch (identifier.ObjectType) + { + case ObjectType.Equipment: + case ObjectType.DemiHuman: + ret = Combos.EqpEquipSlot("##slot", identifier.EquipSlot, out slot, unscaledWidth); + break; + case ObjectType.Accessory: + ret = Combos.AccessorySlot("##slot", identifier.EquipSlot, out slot, unscaledWidth); + break; + default: return false; + } + + ImUtf8.HoverTooltip("Equip Slot"u8); + if (ret) + identifier = identifier with { EquipSlot = slot }; + return ret; + } + + public static bool DrawMaterialId(ImcEntry defaultEntry, ref ImcEntry entry, bool addDefault, float unscaledWidth = 45) + { + if (!DragInput("##materialId"u8, "Material ID"u8, unscaledWidth * ImUtf8.GlobalScale, entry.MaterialId, defaultEntry.MaterialId, + out var newValue, (byte)1, byte.MaxValue, 0.01f, addDefault)) + return false; + + entry = entry with { MaterialId = newValue }; + return true; + } + + public static bool DrawMaterialAnimationId(ImcEntry defaultEntry, ref ImcEntry entry, bool addDefault, float unscaledWidth = 45) + { + if (!DragInput("##mAnimId"u8, "Material Animation ID"u8, unscaledWidth * ImUtf8.GlobalScale, entry.MaterialAnimationId, + defaultEntry.MaterialAnimationId, out var newValue, (byte)0, byte.MaxValue, 0.01f, addDefault)) + return false; + + entry = entry with { MaterialAnimationId = newValue }; + return true; + } + + public static bool DrawDecalId(ImcEntry defaultEntry, ref ImcEntry entry, bool addDefault, float unscaledWidth = 45) + { + if (!DragInput("##decalId"u8, "Decal ID"u8, unscaledWidth * ImUtf8.GlobalScale, entry.DecalId, defaultEntry.DecalId, out var newValue, + (byte)0, byte.MaxValue, 0.01f, addDefault)) + return false; + + entry = entry with { DecalId = newValue }; + return true; + } + + public static bool DrawVfxId(ImcEntry defaultEntry, ref ImcEntry entry, bool addDefault, float unscaledWidth = 45) + { + if (!DragInput("##vfxId"u8, "VFX ID"u8, unscaledWidth * ImUtf8.GlobalScale, entry.VfxId, defaultEntry.VfxId, out var newValue, (byte)0, + byte.MaxValue, 0.01f, addDefault)) + return false; + + entry = entry with { VfxId = newValue }; + return true; + } + + public static bool DrawSoundId(ImcEntry defaultEntry, ref ImcEntry entry, bool addDefault, float unscaledWidth = 45) + { + if (!DragInput("##soundId"u8, "Sound ID"u8, unscaledWidth * ImUtf8.GlobalScale, entry.SoundId, defaultEntry.SoundId, out var newValue, + (byte)0, byte.MaxValue, 0.01f, addDefault)) + return false; + + entry = entry with { SoundId = newValue }; + return true; + } + + private static bool DrawAttributes(ImcEntry defaultEntry, ref ImcEntry entry) + { + var changes = false; + for (var i = 0; i < ImcEntry.NumAttributes; ++i) + { + using var id = ImRaii.PushId(i); + var flag = 1 << i; + var value = (entry.AttributeMask & flag) != 0; + var def = (defaultEntry.AttributeMask & flag) != 0; + if (Checkmark("##attribute"u8, "ABCDEFGHIJ"u8.Slice(i, 1), value, def, out var newValue)) + { + var newMask = (ushort)(newValue ? entry.AttributeMask | flag : entry.AttributeMask & ~flag); + entry = entry with { AttributeMask = newMask }; + changes = true; + } + + if (i < ImcEntry.NumAttributes - 1) + ImUtf8.SameLineInner(); + } + + return changes; + } +} diff --git a/Penumbra/UI/AdvancedWindow/Meta/MetaDrawer.cs b/Penumbra/UI/AdvancedWindow/Meta/MetaDrawer.cs new file mode 100644 index 00000000..f608a194 --- /dev/null +++ b/Penumbra/UI/AdvancedWindow/Meta/MetaDrawer.cs @@ -0,0 +1,165 @@ +using Dalamud.Interface; +using Dalamud.Bindings.ImGui; +using Newtonsoft.Json.Linq; +using OtterGui; +using OtterGui.Raii; +using OtterGui.Text; +using Penumbra.Api.Api; +using Penumbra.Meta; +using Penumbra.Meta.Manipulations; +using Penumbra.Mods.Editor; +using Penumbra.UI.Classes; + +namespace Penumbra.UI.AdvancedWindow.Meta; + +public interface IMetaDrawer +{ + public ReadOnlySpan Label { get; } + public int NumColumns { get; } + public float ColumnHeight { get; } + public void Draw(); +} + +public abstract class MetaDrawer(ModMetaEditor editor, MetaFileManager metaFiles) : IMetaDrawer + where TIdentifier : unmanaged, IMetaIdentifier + where TEntry : unmanaged +{ + protected const uint FrameColor = 0; + + protected readonly ModMetaEditor Editor = editor; + protected readonly MetaFileManager MetaFiles = metaFiles; + protected TIdentifier Identifier; + protected TEntry Entry; + private bool _initialized; + + public void Draw() + { + if (!_initialized) + { + Initialize(); + _initialized = true; + } + + using var id = ImUtf8.PushId((int)Identifier.Type); + DrawNew(); + + var height = ColumnHeight; + var skips = ImGuiClip.GetNecessarySkipsAtPos(height, ImGui.GetCursorPosY(), Count); + if (skips < Count) + { + var remainder = ImGuiClip.ClippedTableDraw(Enumerate(), skips, DrawLine, Count); + if (remainder > 0) + ImGuiClip.DrawEndDummy(remainder, height); + } + + void DrawLine((TIdentifier Identifier, TEntry Value) pair) + => DrawEntry(pair.Identifier, pair.Value); + } + + public abstract ReadOnlySpan Label { get; } + public abstract int NumColumns { get; } + + public virtual float ColumnHeight + => ImUtf8.FrameHeightSpacing; + + protected abstract void DrawNew(); + protected abstract void Initialize(); + protected abstract void DrawEntry(TIdentifier identifier, TEntry entry); + + protected abstract IEnumerable<(TIdentifier, TEntry)> Enumerate(); + protected abstract int Count { get; } + + + /// + /// A number input for ids with an optional max id of given width. + /// Returns true if newId changed against currentId. + /// + protected static bool IdInput(ReadOnlySpan label, float unscaledWidth, ushort currentId, out ushort newId, int minId, int maxId, + bool border) + { + int tmp = currentId; + ImGui.SetNextItemWidth(unscaledWidth * ImUtf8.GlobalScale); + using var style = ImRaii.PushStyle(ImGuiStyleVar.FrameBorderSize, UiHelpers.Scale, border); + using var color = ImRaii.PushColor(ImGuiCol.Border, Colors.RegexWarningBorder, border); + if (ImUtf8.InputScalar(label, ref tmp)) + tmp = Math.Clamp(tmp, minId, maxId); + + newId = (ushort)tmp; + return newId != currentId; + } + + /// + /// A dragging int input of given width that compares against a default value, shows a tooltip and clamps against min and max. + /// Returns true if newValue changed against currentValue. + /// + protected static bool DragInput(ReadOnlySpan label, ReadOnlySpan tooltip, float width, T currentValue, T defaultValue, + out T newValue, T minValue, T maxValue, float speed, bool addDefault) where T : unmanaged, INumber + { + newValue = currentValue; + using var color = ImRaii.PushColor(ImGuiCol.FrameBg, + defaultValue > currentValue ? ColorId.DecreasedMetaValue.Value() : ColorId.IncreasedMetaValue.Value(), + defaultValue != currentValue); + ImGui.SetNextItemWidth(width); + if (ImUtf8.DragScalar(label, ref newValue, minValue, maxValue, speed)) + newValue = newValue <= minValue ? minValue : newValue >= maxValue ? maxValue : newValue; + + if (addDefault) + ImUtf8.HoverTooltip($"{tooltip}\nDefault Value: {defaultValue}"); + else + ImUtf8.HoverTooltip(ImGuiHoveredFlags.AllowWhenDisabled, tooltip); + + return newValue != currentValue; + } + + /// + /// A checkmark that compares against a default value and shows a tooltip. + /// Returns true if newValue is changed against currentValue. + /// + protected static bool Checkmark(ReadOnlySpan label, ReadOnlySpan tooltip, bool currentValue, bool defaultValue, + out bool newValue) + { + using var color = ImRaii.PushColor(ImGuiCol.FrameBg, + defaultValue ? ColorId.DecreasedMetaValue.Value() : ColorId.IncreasedMetaValue.Value(), + defaultValue != currentValue); + newValue = currentValue; + ImUtf8.Checkbox(label, ref newValue); + ImUtf8.HoverTooltip(ImGuiHoveredFlags.AllowWhenDisabled, tooltip); + return newValue != currentValue; + } + + /// + /// A checkmark that compares against a default value and shows a tooltip. + /// Returns true if newValue is changed against currentValue. + /// + protected static bool Checkmark(ReadOnlySpan label, ReadOnlySpan tooltip, bool currentValue, bool defaultValue, + out bool newValue) + { + using var color = ImRaii.PushColor(ImGuiCol.FrameBg, + defaultValue ? ColorId.DecreasedMetaValue.Value() : ColorId.IncreasedMetaValue.Value(), + defaultValue != currentValue); + newValue = currentValue; + ImUtf8.Checkbox(label, ref newValue); + ImUtf8.HoverTooltip(ImGuiHoveredFlags.AllowWhenDisabled, tooltip); + return newValue != currentValue; + } + + protected void DrawMetaButtons(TIdentifier identifier, TEntry entry) + { + ImGui.TableNextColumn(); + CopyToClipboardButton("Copy this manipulation to clipboard."u8, new Lazy(() => new JArray { MetaDictionary.Serialize(identifier, entry)! })); + + ImGui.TableNextColumn(); + if (ImUtf8.IconButton(FontAwesomeIcon.Trash, "Delete this meta manipulation."u8)) + Editor.Changes |= Editor.Remove(identifier); + } + + protected void CopyToClipboardButton(ReadOnlySpan tooltip, Lazy manipulations) + { + if (!ImUtf8.IconButton(FontAwesomeIcon.Clipboard, tooltip)) + return; + + var text = Functions.ToCompressedBase64(manipulations.Value, 0); + if (text.Length > 0) + ImGui.SetClipboardText(text); + } +} diff --git a/Penumbra/UI/AdvancedWindow/Meta/MetaDrawers.cs b/Penumbra/UI/AdvancedWindow/Meta/MetaDrawers.cs new file mode 100644 index 00000000..792611e2 --- /dev/null +++ b/Penumbra/UI/AdvancedWindow/Meta/MetaDrawers.cs @@ -0,0 +1,44 @@ +using OtterGui.Services; +using Penumbra.Meta.Manipulations; + +namespace Penumbra.UI.AdvancedWindow.Meta; + +public class MetaDrawers( + EqdpMetaDrawer eqdp, + EqpMetaDrawer eqp, + EstMetaDrawer est, + GlobalEqpMetaDrawer globalEqp, + GmpMetaDrawer gmp, + ImcMetaDrawer imc, + RspMetaDrawer rsp, + AtchMetaDrawer atch, + ShpMetaDrawer shp, + AtrMetaDrawer atr) : IService +{ + public readonly EqdpMetaDrawer Eqdp = eqdp; + public readonly EqpMetaDrawer Eqp = eqp; + public readonly EstMetaDrawer Est = est; + public readonly GmpMetaDrawer Gmp = gmp; + public readonly RspMetaDrawer Rsp = rsp; + public readonly ImcMetaDrawer Imc = imc; + public readonly GlobalEqpMetaDrawer GlobalEqp = globalEqp; + public readonly AtchMetaDrawer Atch = atch; + public readonly ShpMetaDrawer Shp = shp; + public readonly AtrMetaDrawer Atr = atr; + + public IMetaDrawer? Get(MetaManipulationType type) + => type switch + { + MetaManipulationType.Imc => Imc, + MetaManipulationType.Eqdp => Eqdp, + MetaManipulationType.Eqp => Eqp, + MetaManipulationType.Est => Est, + MetaManipulationType.Gmp => Gmp, + MetaManipulationType.Rsp => Rsp, + MetaManipulationType.Atch => Atch, + MetaManipulationType.Shp => Shp, + MetaManipulationType.Atr => Atr, + MetaManipulationType.GlobalEqp => GlobalEqp, + _ => null, + }; +} diff --git a/Penumbra/UI/AdvancedWindow/Meta/RspMetaDrawer.cs b/Penumbra/UI/AdvancedWindow/Meta/RspMetaDrawer.cs new file mode 100644 index 00000000..88abe0cb --- /dev/null +++ b/Penumbra/UI/AdvancedWindow/Meta/RspMetaDrawer.cs @@ -0,0 +1,119 @@ +using Dalamud.Interface; +using Dalamud.Interface.Utility.Raii; +using Dalamud.Bindings.ImGui; +using Newtonsoft.Json.Linq; +using OtterGui.Services; +using OtterGui.Text; +using Penumbra.GameData.Enums; +using Penumbra.Meta; +using Penumbra.Meta.Files; +using Penumbra.Meta.Manipulations; +using Penumbra.Mods.Editor; +using Penumbra.UI.Classes; + +namespace Penumbra.UI.AdvancedWindow.Meta; + +public sealed class RspMetaDrawer(ModMetaEditor editor, MetaFileManager metaFiles) + : MetaDrawer(editor, metaFiles), IService +{ + public override ReadOnlySpan Label + => "Racial Scaling Edits (RSP)###RSP"u8; + + public override int NumColumns + => 5; + + protected override void Initialize() + { + Identifier = new RspIdentifier(SubRace.Midlander, RspAttribute.MaleMinSize); + UpdateEntry(); + } + + private void UpdateEntry() + => Entry = CmpFile.GetDefault(MetaFiles, Identifier.SubRace, Identifier.Attribute); + + protected override void DrawNew() + { + ImGui.TableNextColumn(); + CopyToClipboardButton("Copy all current RSP manipulations to clipboard."u8, new Lazy(() => MetaDictionary.SerializeTo([], Editor.Rsp))); + + ImGui.TableNextColumn(); + var canAdd = !Editor.Contains(Identifier); + var tt = canAdd ? "Stage this edit."u8 : "This entry is already edited."u8; + if (ImUtf8.IconButton(FontAwesomeIcon.Plus, tt, disabled: !canAdd)) + Editor.Changes |= Editor.TryAdd(Identifier, Entry); + + if (DrawIdentifierInput(ref Identifier)) + UpdateEntry(); + + DrawEntry(Entry, ref Entry, true); + } + + protected override void DrawEntry(RspIdentifier identifier, RspEntry entry) + { + DrawMetaButtons(identifier, entry); + DrawIdentifier(identifier); + + var defaultEntry = CmpFile.GetDefault(MetaFiles, identifier.SubRace, identifier.Attribute); + if (DrawEntry(defaultEntry, ref entry, false)) + Editor.Changes |= Editor.Update(identifier, entry); + } + + protected override IEnumerable<(RspIdentifier, RspEntry)> Enumerate() + => Editor.Rsp + .OrderBy(kvp => kvp.Key.SubRace) + .ThenBy(kvp => kvp.Key.Attribute) + .Select(kvp => (kvp.Key, kvp.Value)); + + protected override int Count + => Editor.Rsp.Count; + + private static bool DrawIdentifierInput(ref RspIdentifier identifier) + { + ImGui.TableNextColumn(); + var changes = DrawSubRace(ref identifier); + + ImGui.TableNextColumn(); + changes |= DrawAttribute(ref identifier); + return changes; + } + + private static void DrawIdentifier(RspIdentifier identifier) + { + ImGui.TableNextColumn(); + ImUtf8.TextFramed(identifier.SubRace.ToName(), FrameColor); + ImUtf8.HoverTooltip("Model Set ID"u8); + + ImGui.TableNextColumn(); + ImUtf8.TextFramed(identifier.Attribute.ToFullString(), FrameColor); + ImUtf8.HoverTooltip("Equip Slot"u8); + } + + private static bool DrawEntry(RspEntry defaultEntry, ref RspEntry entry, bool disabled) + { + using var dis = ImRaii.Disabled(disabled); + ImGui.TableNextColumn(); + var ret = DragInput("##rspValue"u8, [], ImUtf8.GlobalScale * 150, entry.Value, defaultEntry.Value, out var newValue, + RspEntry.MinValue, RspEntry.MaxValue, 0.001f, !disabled); + if (ret) + entry = new RspEntry(newValue); + return ret; + } + + public static bool DrawSubRace(ref RspIdentifier identifier, float unscaledWidth = 150) + { + var ret = Combos.SubRace("##rspSubRace", identifier.SubRace, out var subRace, unscaledWidth); + ImUtf8.HoverTooltip("Racial Clan"u8); + if (ret) + identifier = identifier with { SubRace = subRace }; + return ret; + } + + public static bool DrawAttribute(ref RspIdentifier identifier, float unscaledWidth = 200) + { + var ret = Combos.RspAttribute("##rspAttribute", identifier.Attribute, out var attribute, unscaledWidth); + ImUtf8.HoverTooltip("Scaling Attribute"u8); + if (ret) + identifier = identifier with { Attribute = attribute }; + return ret; + } +} diff --git a/Penumbra/UI/AdvancedWindow/Meta/ShpMetaDrawer.cs b/Penumbra/UI/AdvancedWindow/Meta/ShpMetaDrawer.cs new file mode 100644 index 00000000..59692195 --- /dev/null +++ b/Penumbra/UI/AdvancedWindow/Meta/ShpMetaDrawer.cs @@ -0,0 +1,366 @@ +using Dalamud.Interface; +using Dalamud.Bindings.ImGui; +using Newtonsoft.Json.Linq; +using OtterGui.Raii; +using OtterGui.Services; +using OtterGui.Text; +using Penumbra.Collections.Cache; +using Penumbra.GameData.Enums; +using Penumbra.GameData.Structs; +using Penumbra.Meta; +using Penumbra.Meta.Files; +using Penumbra.Meta.Manipulations; +using Penumbra.Mods.Editor; +using Penumbra.UI.Classes; + +namespace Penumbra.UI.AdvancedWindow.Meta; + +public sealed class ShpMetaDrawer(ModMetaEditor editor, MetaFileManager metaFiles) + : MetaDrawer(editor, metaFiles), IService +{ + public override ReadOnlySpan Label + => "Shape Keys (SHP)###SHP"u8; + + private ShapeAttributeString _buffer = ShapeAttributeString.TryRead("shpx_"u8, out var s) ? s : ShapeAttributeString.Empty; + private bool _identifierValid; + + public override int NumColumns + => 8; + + public override float ColumnHeight + => ImUtf8.FrameHeightSpacing; + + protected override void Initialize() + { + Identifier = new ShpIdentifier(HumanSlot.Unknown, null, ShapeAttributeString.Empty, ShapeConnectorCondition.None, GenderRace.Unknown); + } + + protected override void DrawNew() + { + ImGui.TableNextColumn(); + CopyToClipboardButton("Copy all current SHP manipulations to clipboard."u8, + new Lazy(() => MetaDictionary.SerializeTo([], Editor.Shp))); + + ImGui.TableNextColumn(); + var canAdd = !Editor.Contains(Identifier) && _identifierValid; + var tt = canAdd + ? "Stage this edit."u8 + : _identifierValid + ? "This entry does not contain a valid shape key."u8 + : "This entry is already edited."u8; + if (ImUtf8.IconButton(FontAwesomeIcon.Plus, tt, disabled: !canAdd)) + Editor.Changes |= Editor.TryAdd(Identifier, ShpEntry.True); + + DrawIdentifierInput(ref Identifier); + DrawEntry(ref Entry, true); + } + + protected override void DrawEntry(ShpIdentifier identifier, ShpEntry entry) + { + DrawMetaButtons(identifier, entry); + DrawIdentifier(identifier); + + if (DrawEntry(ref entry, false)) + Editor.Changes |= Editor.Update(identifier, entry); + } + + protected override IEnumerable<(ShpIdentifier, ShpEntry)> Enumerate() + => Editor.Shp + .OrderBy(kvp => kvp.Key.Shape) + .ThenBy(kvp => kvp.Key.Slot) + .ThenBy(kvp => kvp.Key.Id) + .ThenBy(kvp => kvp.Key.ConnectorCondition) + .Select(kvp => (kvp.Key, kvp.Value)); + + protected override int Count + => Editor.Shp.Count; + + private bool DrawIdentifierInput(ref ShpIdentifier identifier) + { + ImGui.TableNextColumn(); + var changes = DrawHumanSlot(ref identifier); + + ImGui.TableNextColumn(); + changes |= DrawGenderRaceConditionInput(ref identifier); + + ImGui.TableNextColumn(); + changes |= DrawPrimaryId(ref identifier); + + ImGui.TableNextColumn(); + changes |= DrawShapeKeyInput(ref identifier, ref _buffer, ref _identifierValid); + + ImGui.TableNextColumn(); + changes |= DrawConnectorConditionInput(ref identifier); + return changes; + } + + private static void DrawIdentifier(ShpIdentifier identifier) + { + ImGui.TableNextColumn(); + + ImUtf8.TextFramed(SlotName(identifier.Slot), FrameColor); + ImUtf8.HoverTooltip("Model Slot"u8); + + ImGui.TableNextColumn(); + if (identifier.GenderRaceCondition is not GenderRace.Unknown) + { + ImUtf8.TextFramed($"{identifier.GenderRaceCondition.ToName()} ({identifier.GenderRaceCondition.ToRaceCode()})", FrameColor); + ImUtf8.HoverTooltip("Gender & Race Code for this shape key to be set."); + } + else + { + ImUtf8.TextFramed("Any Gender & Race"u8, FrameColor); + } + + ImGui.TableNextColumn(); + if (identifier.Id.HasValue) + ImUtf8.TextFramed($"{identifier.Id.Value.Id}", FrameColor); + else + ImUtf8.TextFramed("All IDs"u8, FrameColor); + ImUtf8.HoverTooltip("Primary ID"u8); + + ImGui.TableNextColumn(); + ImUtf8.TextFramed(identifier.Shape.AsSpan, FrameColor); + + ImGui.TableNextColumn(); + if (identifier.ConnectorCondition is not ShapeConnectorCondition.None) + { + ImUtf8.TextFramed($"{identifier.ConnectorCondition}", FrameColor); + ImUtf8.HoverTooltip("Connector condition for this shape to be activated."); + } + } + + private static bool DrawEntry(ref ShpEntry entry, bool disabled) + { + using var dis = ImRaii.Disabled(disabled); + ImGui.TableNextColumn(); + var value = entry.Value; + var changes = ImUtf8.Checkbox("##shpEntry"u8, ref value); + if (changes) + entry = new ShpEntry(value); + ImUtf8.HoverTooltip("Whether to enable or disable this shape key for the selected items."); + return changes; + } + + public static bool DrawPrimaryId(ref ShpIdentifier identifier, float unscaledWidth = 100) + { + var allSlots = identifier.Slot is HumanSlot.Unknown; + var all = !identifier.Id.HasValue; + var ret = false; + using (ImRaii.Disabled(allSlots)) + { + if (ImUtf8.Checkbox("##shpAll"u8, ref all)) + { + identifier = identifier with { Id = all ? null : 0 }; + ret = true; + } + } + + ImUtf8.HoverTooltip(allSlots ? "When using all slots, you also need to use all IDs."u8 : "Enable this shape key for all model IDs."u8); + + ImGui.SameLine(0, ImGui.GetStyle().ItemInnerSpacing.X); + if (all) + { + using var style = ImRaii.PushStyle(ImGuiStyleVar.ButtonTextAlign, new Vector2(0.05f, 0.5f)); + ImUtf8.TextFramed("All IDs"u8, ImGui.GetColorU32(ImGuiCol.FrameBg, all || allSlots ? ImGui.GetStyle().DisabledAlpha : 1f), + new Vector2(unscaledWidth, 0), ImGui.GetColorU32(ImGuiCol.TextDisabled)); + } + else + { + var max = identifier.Slot.ToSpecificEnum() is BodySlot ? byte.MaxValue : ExpandedEqpGmpBase.Count - 1; + if (IdInput("##shpPrimaryId"u8, unscaledWidth, identifier.Id.GetValueOrDefault(0).Id, out var setId, 0, max, false)) + { + identifier = identifier with { Id = setId }; + ret = true; + } + } + + ImUtf8.HoverTooltip("Primary ID - You can usually find this as the 'e####' part of an item path or similar for customizations."u8); + + return ret; + } + + public bool DrawHumanSlot(ref ShpIdentifier identifier, float unscaledWidth = 170) + { + var ret = false; + ImGui.SetNextItemWidth(unscaledWidth * ImUtf8.GlobalScale); + using (var combo = ImUtf8.Combo("##shpSlot"u8, SlotName(identifier.Slot))) + { + if (combo) + foreach (var slot in AvailableSlots) + { + if (!ImUtf8.Selectable(SlotName(slot), slot == identifier.Slot) || slot == identifier.Slot) + continue; + + ret = true; + if (slot is HumanSlot.Unknown) + { + identifier = identifier with + { + Id = null, + Slot = slot, + }; + } + else + { + identifier = identifier with + { + Id = identifier.Id.HasValue + ? (PrimaryId)Math.Clamp(identifier.Id.Value.Id, 0, + slot.ToSpecificEnum() is BodySlot ? byte.MaxValue : ExpandedEqpGmpBase.Count - 1) + : null, + Slot = slot, + ConnectorCondition = Identifier.ConnectorCondition switch + { + ShapeConnectorCondition.Wrists when slot is HumanSlot.Body or HumanSlot.Hands => ShapeConnectorCondition.Wrists, + ShapeConnectorCondition.Waist when slot is HumanSlot.Body or HumanSlot.Legs => ShapeConnectorCondition.Waist, + ShapeConnectorCondition.Ankles when slot is HumanSlot.Legs or HumanSlot.Feet => ShapeConnectorCondition.Ankles, + _ => ShapeConnectorCondition.None, + }, + }; + ret = true; + } + } + } + + ImUtf8.HoverTooltip("Model Slot"u8); + return ret; + } + + public static unsafe bool DrawShapeKeyInput(ref ShpIdentifier identifier, ref ShapeAttributeString buffer, ref bool valid, + float unscaledWidth = 200) + { + var ret = false; + var ptr = Unsafe.AsPointer(ref buffer); + var span = new Span(ptr, ShapeAttributeString.MaxLength + 1); + using (new ImRaii.ColorStyle().Push(ImGuiCol.Border, Colors.RegexWarningBorder, !valid).Push(ImGuiStyleVar.FrameBorderSize, 1f, !valid)) + { + ImGui.SetNextItemWidth(unscaledWidth * ImUtf8.GlobalScale); + if (ImUtf8.InputText("##shpShape"u8, span, out int newLength, "Shape Key..."u8)) + { + buffer.ForceLength((byte)newLength); + valid = buffer.ValidateCustomShapeString(); + if (valid) + identifier = identifier with { Shape = buffer }; + ret = true; + } + } + + ImUtf8.HoverTooltip("Supported shape keys need to have the format `shpx_*` and a maximum length of 30 characters."u8); + return ret; + } + + private static bool DrawConnectorConditionInput(ref ShpIdentifier identifier, float unscaledWidth = 80) + { + var ret = false; + ImGui.SetNextItemWidth(unscaledWidth * ImUtf8.GlobalScale); + var (showWrists, showWaist, showAnkles, disable) = identifier.Slot switch + { + HumanSlot.Unknown => (true, true, true, false), + HumanSlot.Body => (true, true, false, false), + HumanSlot.Legs => (false, true, true, false), + HumanSlot.Hands => (true, false, false, false), + HumanSlot.Feet => (false, false, true, false), + _ => (false, false, false, true), + }; + using var disabled = ImRaii.Disabled(disable); + using (var combo = ImUtf8.Combo("##shpCondition"u8, $"{identifier.ConnectorCondition}")) + { + if (combo) + { + if (ImUtf8.Selectable("None"u8, identifier.ConnectorCondition is ShapeConnectorCondition.None)) + identifier = identifier with { ConnectorCondition = ShapeConnectorCondition.None }; + + if (showWrists && ImUtf8.Selectable("Wrists"u8, identifier.ConnectorCondition is ShapeConnectorCondition.Wrists)) + identifier = identifier with { ConnectorCondition = ShapeConnectorCondition.Wrists }; + + if (showWaist && ImUtf8.Selectable("Waist"u8, identifier.ConnectorCondition is ShapeConnectorCondition.Waist)) + identifier = identifier with { ConnectorCondition = ShapeConnectorCondition.Waist }; + + if (showAnkles && ImUtf8.Selectable("Ankles"u8, identifier.ConnectorCondition is ShapeConnectorCondition.Ankles)) + identifier = identifier with { ConnectorCondition = ShapeConnectorCondition.Ankles }; + } + } + + ImUtf8.HoverTooltip( + "Only activate this shape key if any custom connector shape keys (shpx_[wr|wa|an]_*) are also enabled through matching attributes."u8); + return ret; + } + + private static bool DrawGenderRaceConditionInput(ref ShpIdentifier identifier, float unscaledWidth = 250) + { + var ret = false; + ImGui.SetNextItemWidth(unscaledWidth * ImUtf8.GlobalScale); + + using (var combo = ImUtf8.Combo("##shpGenderRace"u8, identifier.GenderRaceCondition is GenderRace.Unknown + ? "Any Gender & Race" + : $"{identifier.GenderRaceCondition.ToName()} ({identifier.GenderRaceCondition.ToRaceCode()})")) + { + if (combo) + { + if (ImUtf8.Selectable("Any Gender & Race"u8, identifier.GenderRaceCondition is GenderRace.Unknown) + && identifier.GenderRaceCondition is not GenderRace.Unknown) + { + identifier = identifier with { GenderRaceCondition = GenderRace.Unknown }; + ret = true; + } + + foreach (var gr in ShapeAttributeHashSet.GenderRaceValues.Skip(1)) + { + if (ImUtf8.Selectable($"{gr.ToName()} ({gr.ToRaceCode()})", identifier.GenderRaceCondition == gr) + && identifier.GenderRaceCondition != gr) + { + identifier = identifier with { GenderRaceCondition = gr }; + ret = true; + } + } + } + } + + ImUtf8.HoverTooltip( + "Only activate this shape key for this gender & race code."u8); + + return ret; + } + + public static ReadOnlySpan AvailableSlots + => + [ + HumanSlot.Unknown, + HumanSlot.Head, + HumanSlot.Body, + HumanSlot.Hands, + HumanSlot.Legs, + HumanSlot.Feet, + HumanSlot.Ears, + HumanSlot.Neck, + HumanSlot.Wrists, + HumanSlot.RFinger, + HumanSlot.LFinger, + HumanSlot.Glasses, + HumanSlot.Hair, + HumanSlot.Face, + HumanSlot.Ear, + ]; + + public static ReadOnlySpan SlotName(HumanSlot slot) + => slot switch + { + HumanSlot.Unknown => "All Slots"u8, + HumanSlot.Head => "Equipment: Head"u8, + HumanSlot.Body => "Equipment: Body"u8, + HumanSlot.Hands => "Equipment: Hands"u8, + HumanSlot.Legs => "Equipment: Legs"u8, + HumanSlot.Feet => "Equipment: Feet"u8, + HumanSlot.Ears => "Equipment: Ears"u8, + HumanSlot.Neck => "Equipment: Neck"u8, + HumanSlot.Wrists => "Equipment: Wrists"u8, + HumanSlot.RFinger => "Equipment: Right Finger"u8, + HumanSlot.LFinger => "Equipment: Left Finger"u8, + HumanSlot.Glasses => "Equipment: Glasses"u8, + HumanSlot.Hair => "Customization: Hair"u8, + HumanSlot.Face => "Customization: Face"u8, + HumanSlot.Ear => "Customization: Ears"u8, + _ => "Unknown"u8, + }; +} diff --git a/Penumbra/UI/AdvancedWindow/ModEditWindow.Deformers.cs b/Penumbra/UI/AdvancedWindow/ModEditWindow.Deformers.cs new file mode 100644 index 00000000..4f7ae8da --- /dev/null +++ b/Penumbra/UI/AdvancedWindow/ModEditWindow.Deformers.cs @@ -0,0 +1,355 @@ +using Dalamud.Interface; +using Dalamud.Interface.ImGuiNotification; +using Dalamud.Interface.Utility.Raii; +using Dalamud.Bindings.ImGui; +using OtterGui; +using OtterGui.Extensions; +using OtterGui.Text; +using Penumbra.GameData.Data; +using Penumbra.GameData.Enums; +using Penumbra.GameData.Files; +using Penumbra.UI.Classes; +using Notification = OtterGui.Classes.Notification; + +namespace Penumbra.UI.AdvancedWindow; + +public partial class ModEditWindow +{ + private readonly FileEditor _pbdTab; + private readonly PbdData _pbdData = new(); + + private bool DrawDeformerPanel(PbdTab tab, bool disabled) + { + _pbdData.Update(tab.File); + DrawGenderRaceSelector(tab); + ImGui.SameLine(); + DrawBoneSelector(); + ImGui.SameLine(); + return DrawBoneData(tab, disabled); + } + + private void DrawGenderRaceSelector(PbdTab tab) + { + using var group = ImUtf8.Group(); + var width = ImUtf8.CalcTextSize("Hellsguard - Female (Child)____0000"u8).X + 2 * ImGui.GetStyle().WindowPadding.X; + using (ImRaii.PushStyle(ImGuiStyleVar.FrameRounding, 0) + .Push(ImGuiStyleVar.ItemSpacing, Vector2.Zero)) + { + ImGui.SetNextItemWidth(width); + ImUtf8.InputText("##grFilter"u8, ref _pbdData.RaceCodeFilter, "Filter..."u8); + } + + using var child = ImUtf8.Child("GenderRace"u8, + new Vector2(width, ImGui.GetContentRegionMax().Y - ImGui.GetFrameHeight() - ImGui.GetStyle().WindowPadding.Y), true); + if (!child) + return; + + var metaColor = ColorId.ItemId.Value(); + foreach (var (deformer, index) in tab.File.Deformers.WithIndex()) + { + var name = deformer.GenderRace.ToName(); + var raceCode = deformer.GenderRace.ToRaceCode(); + // No clipping necessary since this are not that many objects anyway. + if (!name.Contains(_pbdData.RaceCodeFilter) && !raceCode.Contains(_pbdData.RaceCodeFilter)) + continue; + + using var id = ImUtf8.PushId(index); + using var color = ImRaii.PushColor(ImGuiCol.Text, ImGui.GetColorU32(ImGuiCol.TextDisabled), deformer.RacialDeformer.IsEmpty); + if (ImUtf8.Selectable(name, deformer.GenderRace == _pbdData.SelectedRaceCode)) + { + _pbdData.SelectedRaceCode = deformer.GenderRace; + _pbdData.SelectedDeformer = deformer.RacialDeformer; + } + + ImGui.SameLine(); + color.Push(ImGuiCol.Text, metaColor); + ImUtf8.TextRightAligned(raceCode); + } + } + + private void DrawBoneSelector() + { + using var group = ImUtf8.Group(); + var width = 200 * ImUtf8.GlobalScale; + using (ImRaii.PushStyle(ImGuiStyleVar.FrameRounding, 0) + .Push(ImGuiStyleVar.ItemSpacing, Vector2.Zero)) + { + ImGui.SetNextItemWidth(width); + ImUtf8.InputText("##boneFilter"u8, ref _pbdData.BoneFilter, "Filter..."u8); + } + + using var child = ImUtf8.Child("Bone"u8, + new Vector2(width, ImGui.GetContentRegionMax().Y - ImGui.GetFrameHeight() - ImGui.GetStyle().WindowPadding.Y), true); + if (!child) + return; + + if (_pbdData.SelectedDeformer == null) + return; + + if (_pbdData.SelectedDeformer.IsEmpty) + { + ImUtf8.Text(""u8); + } + else + { + var height = ImGui.GetTextLineHeightWithSpacing(); + var skips = ImGuiClip.GetNecessarySkips(height); + var remainder = ImGuiClip.FilteredClippedDraw(_pbdData.SelectedDeformer.DeformMatrices.Keys, skips, + b => b.Contains(_pbdData.BoneFilter), bone + => + { + if (ImUtf8.Selectable(bone, bone == _pbdData.SelectedBone)) + _pbdData.SelectedBone = bone; + }); + ImGuiClip.DrawEndDummy(remainder, height); + } + } + + private bool DrawBoneData(PbdTab tab, bool disabled) + { + using var child = ImUtf8.Child("Data"u8, + ImGui.GetContentRegionAvail() with { Y = ImGui.GetContentRegionMax().Y - ImGui.GetStyle().WindowPadding.Y }, true); + if (!child) + return false; + + if (_pbdData.SelectedBone == null) + return false; + + if (!_pbdData.SelectedDeformer!.DeformMatrices.TryGetValue(_pbdData.SelectedBone, out var matrix)) + return false; + + var width = UiBuilder.MonoFont.GetCharAdvance('0') * 12 + ImGui.GetStyle().FramePadding.X * 2; + var dummyHeight = ImGui.GetTextLineHeight() / 2; + var ret = DrawAddNewBone(tab, disabled, matrix, width); + + ImUtf8.Dummy(0, dummyHeight); + ImGui.Separator(); + ImUtf8.Dummy(0, dummyHeight); + ret |= DrawDeformerMatrix(disabled, matrix, width); + ImUtf8.Dummy(0, dummyHeight); + ret |= DrawCopyPasteButtons(disabled, matrix, width); + + + ImUtf8.Dummy(0, dummyHeight); + ImGui.Separator(); + ImUtf8.Dummy(0, dummyHeight); + ret |= DrawDecomposedData(disabled, matrix, width); + + return ret; + } + + private bool DrawAddNewBone(PbdTab tab, bool disabled, in TransformMatrix matrix, float width) + { + var ret = false; + ImUtf8.TextFrameAligned("Copy the values of the bone "u8); + ImGui.SameLine(0, 0); + using (ImRaii.PushColor(ImGuiCol.Text, ColorId.NewMod.Value())) + { + ImUtf8.TextFrameAligned(_pbdData.SelectedBone); + } + + ImGui.SameLine(0, 0); + ImUtf8.TextFrameAligned(" to a new bone of name"u8); + + var fullWidth = width * 4 + ImGui.GetStyle().ItemSpacing.X * 3; + ImGui.SetNextItemWidth(fullWidth); + ImUtf8.InputText("##newBone"u8, ref _pbdData.NewBoneName, "New Bone Name..."u8); + ImUtf8.TextFrameAligned("for all races that have a corresponding bone."u8); + ImGui.SameLine(0, fullWidth - width - ImGui.GetItemRectSize().X); + if (ImUtf8.ButtonEx("Apply"u8, ""u8, new Vector2(width, 0), + disabled || _pbdData.NewBoneName.Length == 0 || _pbdData.SelectedBone == null)) + { + foreach (var deformer in tab.File.Deformers) + { + if (!deformer.RacialDeformer.DeformMatrices.TryGetValue(_pbdData.SelectedBone!, out var existingMatrix)) + continue; + + if (!deformer.RacialDeformer.DeformMatrices.TryAdd(_pbdData.NewBoneName, existingMatrix) + && deformer.RacialDeformer.DeformMatrices.TryGetValue(_pbdData.NewBoneName, out var newBoneMatrix) + && !newBoneMatrix.Equals(existingMatrix)) + Penumbra.Messager.AddMessage(new Notification( + $"Could not add deformer matrix to {deformer.GenderRace.ToName()}, Bone {_pbdData.NewBoneName} because it already has a deformer that differs from the intended one.", + NotificationType.Warning)); + else + ret = true; + } + + _pbdData.NewBoneName = string.Empty; + } + + if (ImUtf8.ButtonEx("Copy Values to Single New Bone Entry"u8, ""u8, new Vector2(fullWidth, 0), + disabled || _pbdData.NewBoneName.Length == 0 || _pbdData.SelectedDeformer!.DeformMatrices.ContainsKey(_pbdData.NewBoneName))) + { + _pbdData.SelectedDeformer!.DeformMatrices[_pbdData.NewBoneName] = matrix; + ret = true; + _pbdData.NewBoneName = string.Empty; + } + + + return ret; + } + + private bool DrawDeformerMatrix(bool disabled, in TransformMatrix matrix, float width) + { + using var font = ImRaii.PushFont(UiBuilder.MonoFont); + using var _ = ImRaii.Disabled(disabled); + var ret = false; + for (var i = 0; i < 3; ++i) + { + for (var j = 0; j < 4; ++j) + { + using var id = ImUtf8.PushId(i * 4 + j); + ImGui.SetNextItemWidth(width); + var tmp = matrix[i, j]; + if (ImUtf8.InputScalar(""u8, ref tmp, "% 12.8f"u8)) + { + ret = true; + _pbdData.SelectedDeformer!.DeformMatrices[_pbdData.SelectedBone!] = matrix.ChangeValue(i, j, tmp); + } + + ImGui.SameLine(); + } + + ImGui.NewLine(); + } + + return ret; + } + + private bool DrawCopyPasteButtons(bool disabled, in TransformMatrix matrix, float width) + { + var size = new Vector2(width, 0); + if (ImUtf8.Button("Copy Values"u8, size)) + _pbdData.CopiedMatrix = matrix; + + ImGui.SameLine(); + + var ret = false; + if (ImUtf8.ButtonEx("Paste Values"u8, ""u8, size, disabled || !_pbdData.CopiedMatrix.HasValue)) + { + _pbdData.SelectedDeformer!.DeformMatrices[_pbdData.SelectedBone!] = _pbdData.CopiedMatrix!.Value; + ret = true; + } + + var modifier = _config.DeleteModModifier.IsActive(); + ImGui.SameLine(); + if (modifier) + { + if (ImUtf8.ButtonEx("Delete"u8, "Delete this bone entry."u8, size, disabled)) + { + ret |= _pbdData.SelectedDeformer!.DeformMatrices.Remove(_pbdData.SelectedBone!); + _pbdData.SelectedBone = null; + } + } + else + { + ImUtf8.ButtonEx("Delete"u8, $"Delete this bone entry. Hold {_config.DeleteModModifier} to delete.", size, true); + } + + return ret; + } + + private bool DrawDecomposedData(bool disabled, in TransformMatrix matrix, float width) + { + var ret = false; + + + if (!matrix.TryDecompose(out var scale, out var rotation, out var translation)) + return false; + + using (ImUtf8.Group()) + { + using var font = ImRaii.PushFont(UiBuilder.MonoFont); + using var _ = ImRaii.Disabled(disabled); + + ImGui.SetNextItemWidth(width); + ret |= ImUtf8.InputScalar("##ScaleX"u8, ref scale.X, "% 12.8f"u8); + + ImGui.SameLine(); + ImGui.SetNextItemWidth(width); + ret |= ImUtf8.InputScalar("##ScaleY"u8, ref scale.Y, "% 12.8f"u8); + + ImGui.SameLine(); + ImGui.SetNextItemWidth(width); + ret |= ImUtf8.InputScalar("##ScaleZ"u8, ref scale.Z, "% 12.8f"u8); + + + ImGui.SetNextItemWidth(width); + ret |= ImUtf8.InputScalar("##TranslationX"u8, ref translation.X, "% 12.8f"u8); + + ImGui.SameLine(); + ImGui.SetNextItemWidth(width); + ret |= ImUtf8.InputScalar("##TranslationY"u8, ref translation.Y, "% 12.8f"u8); + + ImGui.SameLine(); + ImGui.SetNextItemWidth(width); + ret |= ImUtf8.InputScalar("##TranslationZ"u8, ref translation.Z, "% 12.8f"u8); + + + ImGui.SetNextItemWidth(width); + ret |= ImUtf8.InputScalar("##RotationR"u8, ref rotation.W, "% 12.8f"u8); + + ImGui.SameLine(); + ImGui.SetNextItemWidth(width); + ret |= ImUtf8.InputScalar("##RotationI"u8, ref rotation.X, "% 12.8f"u8); + + ImGui.SameLine(); + ImGui.SetNextItemWidth(width); + ret |= ImUtf8.InputScalar("##RotationJ"u8, ref rotation.Y, "% 12.8f"u8); + ImGui.SameLine(); + ImGui.SetNextItemWidth(width); + ret |= ImUtf8.InputScalar("##RotationK"u8, ref rotation.Z, "% 12.8f"u8); + } + + ImGui.SameLine(); + using (ImUtf8.Group()) + { + ImUtf8.TextFrameAligned("Scale"u8); + ImUtf8.TextFrameAligned("Translation"u8); + ImUtf8.TextFrameAligned("Rotation (Quaternion, rijk)"u8); + } + + if (ret) + _pbdData.SelectedDeformer!.DeformMatrices[_pbdData.SelectedBone!] = TransformMatrix.Compose(scale, rotation, translation); + return ret; + } + + public class PbdTab(byte[] data, string filePath) : IWritable + { + public readonly string FilePath = filePath; + + public readonly PbdFile File = new(data); + + public bool Valid + => File.Valid; + + public byte[] Write() + => File.Write(); + } + + private class PbdData + { + public GenderRace SelectedRaceCode = GenderRace.Unknown; + public RacialDeformer? SelectedDeformer; + public string? SelectedBone; + public string NewBoneName = string.Empty; + public string BoneFilter = string.Empty; + public string RaceCodeFilter = string.Empty; + + public TransformMatrix? CopiedMatrix; + + public void Update(PbdFile file) + { + if (SelectedRaceCode is GenderRace.Unknown) + { + SelectedDeformer = null; + } + else + { + SelectedDeformer = file.Deformers.FirstOrDefault(p => p.GenderRace == SelectedRaceCode).RacialDeformer; + if (SelectedDeformer is null) + SelectedRaceCode = GenderRace.Unknown; + } + } + } +} diff --git a/Penumbra/UI/AdvancedWindow/ModEditWindow.Files.cs b/Penumbra/UI/AdvancedWindow/ModEditWindow.Files.cs new file mode 100644 index 00000000..63c99b8a --- /dev/null +++ b/Penumbra/UI/AdvancedWindow/ModEditWindow.Files.cs @@ -0,0 +1,449 @@ +using Dalamud.Interface; +using Dalamud.Bindings.ImGui; +using OtterGui; +using OtterGui.Classes; +using OtterGui.Extensions; +using OtterGui.Raii; +using OtterGui.Text; +using Penumbra.Mods.Editor; +using Penumbra.Mods.SubMods; +using Penumbra.String.Classes; +using Penumbra.UI.Classes; + +namespace Penumbra.UI.AdvancedWindow; + +public partial class ModEditWindow +{ + private readonly HashSet _selectedFiles = new(256); + private readonly HashSet _cutPaths = []; + private LowerString _fileFilter = LowerString.Empty; + private bool _showGamePaths = true; + private string _gamePathEdit = string.Empty; + private int _fileIdx = -1; + private int _pathIdx = -1; + private int _folderSkip; + private bool _overviewMode; + + private LowerString _fileOverviewFilter1 = LowerString.Empty; + private LowerString _fileOverviewFilter2 = LowerString.Empty; + private LowerString _fileOverviewFilter3 = LowerString.Empty; + + private bool CheckFilter(FileRegistry registry) + => _fileFilter.IsEmpty || registry.File.FullName.Contains(_fileFilter.Lower, StringComparison.OrdinalIgnoreCase); + + private bool CheckFilter((FileRegistry, int) p) + => CheckFilter(p.Item1); + + private void DrawFileTab() + { + using var tab = ImRaii.TabItem("File Redirections"); + if (!tab) + return; + + DrawOptionSelectHeader(); + DrawButtonHeader(); + + if (_overviewMode) + DrawFileManagementOverview(); + else + DrawFileManagementNormal(); + + using var child = ImRaii.Child("##files", -Vector2.One, true); + if (!child) + return; + + if (_overviewMode) + DrawFilesOverviewMode(); + else + DrawFilesNormalMode(); + } + + private void DrawFilesOverviewMode() + { + var height = ImGui.GetTextLineHeightWithSpacing() + 2 * ImGui.GetStyle().CellPadding.Y; + var skips = ImGuiClip.GetNecessarySkips(height); + + using var list = ImRaii.Table("##table", 3, ImGuiTableFlags.RowBg | ImGuiTableFlags.BordersInnerV, -Vector2.One); + + if (!list) + return; + + var width = ImGui.GetContentRegionAvail().X / 8; + + ImGui.TableSetupColumn("##file", ImGuiTableColumnFlags.WidthFixed, width * 3); + ImGui.TableSetupColumn("##path", ImGuiTableColumnFlags.WidthFixed, width * 3 + ImGui.GetStyle().FrameBorderSize); + ImGui.TableSetupColumn("##option", ImGuiTableColumnFlags.WidthFixed, width * 2); + + var idx = 0; + + var files = _editor.Files.Available.SelectMany(f => + { + var file = f.RelPath.ToString(); + return f.SubModUsage.Count == 0 + ? Enumerable.Repeat((file, "Unused", string.Empty, 0x40000080u), 1) + : f.SubModUsage.Select(s => (file, s.Item2.ToString(), s.Item1.GetFullName(), + _editor.Option! == s.Item1 && Mod!.HasOptions ? 0x40008000u : 0u)); + }); + + void DrawLine((string, string, string, uint) data) + { + using var id = ImRaii.PushId(idx++); + ImGui.TableNextColumn(); + if (data.Item4 != 0) + ImGui.TableSetBgColor(ImGuiTableBgTarget.CellBg, data.Item4); + + ImGuiUtil.CopyOnClickSelectable(data.Item1); + ImGui.TableNextColumn(); + if (data.Item4 != 0) + ImGui.TableSetBgColor(ImGuiTableBgTarget.CellBg, data.Item4); + + ImGuiUtil.CopyOnClickSelectable(data.Item2); + ImGui.TableNextColumn(); + if (data.Item4 != 0) + ImGui.TableSetBgColor(ImGuiTableBgTarget.CellBg, data.Item4); + + ImGuiUtil.CopyOnClickSelectable(data.Item3); + } + + bool Filter((string, string, string, uint) data) + => _fileOverviewFilter1.IsContained(data.Item1) + && _fileOverviewFilter2.IsContained(data.Item2) + && _fileOverviewFilter3.IsContained(data.Item3); + + var end = ImGuiClip.FilteredClippedDraw(files, skips, Filter, DrawLine); + ImGuiClip.DrawEndDummy(end, height); + } + + private void DrawFilesNormalMode() + { + using var list = ImRaii.Table("##table", 1); + + if (!list) + return; + + foreach (var (registry, i) in _editor.Files.Available.WithIndex().Where(CheckFilter)) + { + using var id = ImRaii.PushId(i); + ImGui.TableNextColumn(); + + DrawSelectable(registry, i); + + if (!_showGamePaths) + continue; + + using var indent = ImRaii.PushIndent(50f); + for (var j = 0; j < registry.SubModUsage.Count; ++j) + { + var (subMod, gamePath) = registry.SubModUsage[j]; + if (subMod != _editor.Option) + continue; + + PrintGamePath(i, j, registry, subMod, gamePath); + } + + PrintNewGamePath(i, registry, _editor.Option!); + } + } + + private static string DrawFileTooltip(FileRegistry registry, ColorId color) + { + var (text, groupCount) = color switch + { + ColorId.ConflictingMod => (null, 0), + ColorId.NewMod => ([registry.SubModUsage[0].Item1.GetName()], 1), + ColorId.InheritedMod => GetMulti(), + _ => (null, 0), + }; + + if (text != null && ImGui.IsItemHovered()) + { + using var tt = ImUtf8.Tooltip(); + using var c = ImRaii.DefaultColors(); + ImUtf8.Text(string.Join('\n', text)); + } + + + return (groupCount, registry.SubModUsage.Count) switch + { + (0, 0) => "(unused)", + (1, 1) => "(used 1 time)", + (1, > 1) => $"(used {registry.SubModUsage.Count} times in 1 group)", + _ => $"(used {registry.SubModUsage.Count} times over {groupCount} groups)", + }; + + (IEnumerable, int) GetMulti() + { + var groups = registry.SubModUsage.GroupBy(s => s.Item1).ToArray(); + return (groups.Select(g => g.Key.GetName()), groups.Length); + } + } + + private void DrawSelectable(FileRegistry registry, int i) + { + var selected = _selectedFiles.Contains(registry); + var color = registry.SubModUsage.Count == 0 ? ColorId.ConflictingMod : + registry.CurrentUsage == registry.SubModUsage.Count ? ColorId.NewMod : ColorId.InheritedMod; + using (ImRaii.PushColor(ImGuiCol.Text, color.Value())) + { + if (UiHelpers.Selectable(registry.RelPath.Path, selected)) + { + if (selected) + _selectedFiles.Remove(registry); + else + _selectedFiles.Add(registry); + } + + if (ImGui.IsItemClicked(ImGuiMouseButton.Right)) + ImUtf8.OpenPopup("context"u8); + + var rightText = DrawFileTooltip(registry, color); + + ImGui.SameLine(); + ImGuiUtil.RightAlign(rightText); + } + + DrawContextMenu(registry, i); + } + + private void DrawContextMenu(FileRegistry registry, int i) + { + using var context = ImUtf8.Popup("context"u8); + if (!context) + return; + + if (ImUtf8.Selectable("Copy Full File Path")) + ImUtf8.SetClipboardText(registry.File.FullName); + + using (ImRaii.Disabled(registry.CurrentUsage == 0)) + { + if (ImUtf8.Selectable("Copy Game Paths"u8)) + { + _cutPaths.Clear(); + for (var j = 0; j < registry.SubModUsage.Count; ++j) + { + if (registry.SubModUsage[j].Item1 != _editor.Option) + continue; + + _cutPaths.Add(registry.SubModUsage[j].Item2); + } + } + } + + using (ImRaii.Disabled(registry.CurrentUsage == 0)) + { + if (ImUtf8.Selectable("Cut Game Paths"u8)) + { + _cutPaths.Clear(); + for (var j = 0; j < registry.SubModUsage.Count; ++j) + { + if (registry.SubModUsage[j].Item1 != _editor.Option) + continue; + + _cutPaths.Add(registry.SubModUsage[j].Item2); + _editor.FileEditor.SetGamePath(_editor.Option, i, j--, Utf8GamePath.Empty); + } + } + } + + using (ImRaii.Disabled(_cutPaths.Count == 0)) + { + if (ImUtf8.Selectable("Paste Game Paths"u8)) + foreach (var path in _cutPaths) + _editor.FileEditor.SetGamePath(_editor.Option!, i, -1, path); + } + } + + private void PrintGamePath(int i, int j, FileRegistry registry, IModDataContainer subMod, Utf8GamePath gamePath) + { + using var id = ImRaii.PushId(j); + ImGui.TableNextColumn(); + var tmp = _fileIdx == i && _pathIdx == j ? _gamePathEdit : gamePath.ToString(); + var pos = ImGui.GetCursorPosX() - ImGui.GetFrameHeight(); + ImGui.SetNextItemWidth(-1); + if (ImGui.InputText(string.Empty, ref tmp, Utf8GamePath.MaxGamePathLength)) + { + _fileIdx = i; + _pathIdx = j; + _gamePathEdit = tmp; + } + + ImGuiUtil.HoverTooltip("Clear completely to remove the path from this mod."); + + if (ImGui.IsItemDeactivatedAfterEdit()) + { + if (Utf8GamePath.FromString(_gamePathEdit, out var path)) + _editor.FileEditor.SetGamePath(_editor.Option!, _fileIdx, _pathIdx, path); + + _fileIdx = -1; + _pathIdx = -1; + } + else if (_fileIdx == i + && _pathIdx == j + && (!Utf8GamePath.FromString(_gamePathEdit, out var path) + || !path.IsEmpty && !path.Equals(gamePath) && !_editor.FileEditor.CanAddGamePath(path))) + { + ImGui.SameLine(); + ImGui.SetCursorPosX(pos); + using var font = ImRaii.PushFont(UiBuilder.IconFont); + ImGuiUtil.TextColored(0xFF0000FF, FontAwesomeIcon.TimesCircle.ToIconString()); + } + else if (tmp.Length > 0 && Path.GetExtension(tmp) != registry.File.Extension) + { + ImGui.SameLine(); + ImGui.SetCursorPosX(pos); + using (var font = ImRaii.PushFont(UiBuilder.IconFont)) + { + ImGuiUtil.TextColored(0xFF00B0B0, FontAwesomeIcon.ExclamationCircle.ToIconString()); + } + + ImUtf8.HoverTooltip("The game path and the file do not have the same extension."u8); + } + } + + private void PrintNewGamePath(int i, FileRegistry registry, IModDataContainer subMod) + { + var tmp = _fileIdx == i && _pathIdx == -1 ? _gamePathEdit : string.Empty; + var pos = ImGui.GetCursorPosX() - ImGui.GetFrameHeight(); + ImGui.SetNextItemWidth(-1); + if (ImGui.InputTextWithHint("##new", "Add New Path...", ref tmp, Utf8GamePath.MaxGamePathLength)) + { + _fileIdx = i; + _pathIdx = -1; + _gamePathEdit = tmp; + } + + if (ImGui.IsItemDeactivatedAfterEdit()) + { + if (Utf8GamePath.FromString(_gamePathEdit, out var path) && !path.IsEmpty) + _editor.FileEditor.SetGamePath(_editor.Option!, _fileIdx, _pathIdx, path); + + _fileIdx = -1; + _pathIdx = -1; + } + else if (_fileIdx == i + && _pathIdx == -1 + && (!Utf8GamePath.FromString(_gamePathEdit, out var path) + || !path.IsEmpty && !_editor.FileEditor.CanAddGamePath(path))) + { + ImGui.SameLine(); + ImGui.SetCursorPosX(pos); + using var font = ImRaii.PushFont(UiBuilder.IconFont); + ImGuiUtil.TextColored(0xFF0000FF, FontAwesomeIcon.TimesCircle.ToIconString()); + } + else if (tmp.Length > 0 && Path.GetExtension(tmp) != registry.File.Extension) + { + ImGui.SameLine(); + ImGui.SetCursorPosX(pos); + using (var font = ImRaii.PushFont(UiBuilder.IconFont)) + { + ImGuiUtil.TextColored(0xFF00B0B0, FontAwesomeIcon.ExclamationCircle.ToIconString()); + } + + ImUtf8.HoverTooltip("The game path and the file do not have the same extension."u8); + } + } + + private void DrawButtonHeader() + { + ImGui.NewLine(); + + using var spacing = ImRaii.PushStyle(ImGuiStyleVar.ItemSpacing, new Vector2(3 * UiHelpers.Scale, 0)); + ImGui.SetNextItemWidth(30 * UiHelpers.Scale); + ImGui.DragInt("##skippedFolders", ref _folderSkip, 0.01f, 0, 10); + ImGuiUtil.HoverTooltip("Skip the first N folders when automatically constructing the game path from the file path."); + ImGui.SameLine(); + spacing.Pop(); + if (ImGui.Button("Add Paths")) + _editor.FileEditor.AddPathsToSelected(_editor.Option!, _editor.Files.Available.Where(_selectedFiles.Contains), _folderSkip); + + ImGuiUtil.HoverTooltip( + "Add the file path converted to a game path to all selected files for the current option, optionally skipping the first N folders."); + + + ImGui.SameLine(); + if (ImGui.Button("Remove Paths")) + _editor.FileEditor.RemovePathsFromSelected(_editor.Option!, _editor.Files.Available.Where(_selectedFiles.Contains)); + + ImGuiUtil.HoverTooltip("Remove all game paths associated with the selected files in the current option."); + + + ImGui.SameLine(); + var active = _config.DeleteModModifier.IsActive(); + var tt = + "Delete all selected files entirely from your filesystem, but not their file associations in the mod.\n!!!This can not be reverted!!!"; + if (_selectedFiles.Count == 0) + tt += "\n\nNo files selected."; + else if (!active) + tt += $"\n\nHold {_config.DeleteModModifier} to delete."; + + if (ImGuiUtil.DrawDisabledButton("Delete Selected Files", Vector2.Zero, tt, _selectedFiles.Count == 0 || !active)) + _editor.FileEditor.DeleteFiles(_editor.Mod!, _editor.Option!, _editor.Files.Available.Where(_selectedFiles.Contains)); + + ImGui.SameLine(); + var changes = _editor.FileEditor.Changes; + tt = changes ? "Apply the current file setup to the currently selected option." : "No changes made."; + if (ImGuiUtil.DrawDisabledButton("Apply Changes", Vector2.Zero, tt, !changes)) + { + var failedFiles = _editor.FileEditor.Apply(_editor.Mod!, _editor.Option!); + if (failedFiles > 0) + Penumbra.Log.Information($"Failed to apply {failedFiles} file redirections to {_editor.Option!.GetFullName()}."); + } + + + ImGui.SameLine(); + var label = changes ? "Revert Changes" : "Reload Files"; + var length = new Vector2(ImGui.CalcTextSize("Revert Changes").X, 0); + if (ImGui.Button(label, length)) + _editor.FileEditor.Revert(_editor.Mod!, _editor.Option!); + + ImGuiUtil.HoverTooltip("Revert all revertible changes since the last file or option reload or data refresh."); + + ImGui.SameLine(); + ImGui.Checkbox("Overview Mode", ref _overviewMode); + } + + private void DrawFileManagementNormal() + { + ImGui.SetNextItemWidth(250 * UiHelpers.Scale); + LowerString.InputWithHint("##filter", "Filter paths...", ref _fileFilter, Utf8GamePath.MaxGamePathLength); + ImGui.SameLine(); + ImGui.Checkbox("Show Game Paths", ref _showGamePaths); + ImGui.SameLine(); + if (ImGui.Button("Unselect All")) + _selectedFiles.Clear(); + + ImGui.SameLine(); + if (ImGui.Button("Select Visible")) + _selectedFiles.UnionWith(_editor.Files.Available.Where(CheckFilter)); + + ImGui.SameLine(); + if (ImGui.Button("Select Unused")) + _selectedFiles.UnionWith(_editor.Files.Available.Where(f => f.SubModUsage.Count == 0)); + + ImGui.SameLine(); + if (ImGui.Button("Select Used Here")) + _selectedFiles.UnionWith(_editor.Files.Available.Where(f => f.CurrentUsage > 0)); + + ImGui.SameLine(); + + ImGuiUtil.RightAlign($"{_selectedFiles.Count} / {_editor.Files.Available.Count} Files Selected"); + } + + private void DrawFileManagementOverview() + { + using var style = ImRaii.PushStyle(ImGuiStyleVar.FrameRounding, 0) + .Push(ImGuiStyleVar.ItemSpacing, Vector2.Zero) + .Push(ImGuiStyleVar.FrameBorderSize, ImGui.GetStyle().ChildBorderSize); + + var width = ImGui.GetContentRegionAvail().X / 8; + + ImGui.SetNextItemWidth(width * 3); + LowerString.InputWithHint("##fileFilter", "Filter file...", ref _fileOverviewFilter1, Utf8GamePath.MaxGamePathLength); + ImGui.SameLine(); + ImGui.SetNextItemWidth(width * 3); + LowerString.InputWithHint("##pathFilter", "Filter path...", ref _fileOverviewFilter2, Utf8GamePath.MaxGamePathLength); + ImGui.SameLine(); + ImGui.SetNextItemWidth(width * 2); + LowerString.InputWithHint("##optionFilter", "Filter option...", ref _fileOverviewFilter3, Utf8GamePath.MaxGamePathLength); + } +} diff --git a/Penumbra/UI/AdvancedWindow/ModEditWindow.Materials.cs b/Penumbra/UI/AdvancedWindow/ModEditWindow.Materials.cs new file mode 100644 index 00000000..3caff226 --- /dev/null +++ b/Penumbra/UI/AdvancedWindow/ModEditWindow.Materials.cs @@ -0,0 +1,77 @@ +using Dalamud.Interface; +using Dalamud.Interface.Utility; +using Dalamud.Bindings.ImGui; +using OtterGui.Extensions; +using OtterGui.Raii; +using OtterGui.Text; +using Penumbra.UI.AdvancedWindow.Materials; + +namespace Penumbra.UI.AdvancedWindow; + +public partial class ModEditWindow +{ + private readonly FileEditor _materialTab; + + private bool DrawMaterialPanel(MtrlTab tab, bool disabled) + { + if (tab.DrawVersionUpdate(disabled)) + _materialTab.SaveFile(); + + return tab.DrawPanel(disabled); + } + + private void DrawMaterialReassignmentTab() + { + if (_editor.Files.Mdl.Count == 0) + return; + + using var tab = ImUtf8.TabItem("Material Reassignment"u8); + if (!tab) + return; + + ImGui.NewLine(); + MaterialSuffix.Draw(_editor, ImGuiHelpers.ScaledVector2(175, 0)); + + ImGui.NewLine(); + using var child = ImUtf8.Child("##mdlFiles"u8, -Vector2.One, true); + if (!child) + return; + + using var table = ImUtf8.Table("##files"u8, 4, ImGuiTableFlags.RowBg | ImGuiTableFlags.SizingFixedFit, -Vector2.One); + if (!table) + return; + + foreach (var (info, idx) in _editor.MdlMaterialEditor.ModelFiles.WithIndex()) + { + using var id = ImRaii.PushId(idx); + ImGui.TableNextColumn(); + if (ImUtf8.IconButton(FontAwesomeIcon.Save, "Save the changed mdl file.\nUse at own risk!"u8, disabled: !info.Changed)) + info.Save(_editor.Compactor); + + ImGui.TableNextColumn(); + if (ImUtf8.IconButton(FontAwesomeIcon.Recycle, "Restore current changes to default."u8, disabled: !info.Changed)) + info.Restore(); + + ImGui.TableNextColumn(); + ImUtf8.Text(info.Path.InternalName.Span[(Mod!.ModPath.FullName.Length + 1)..]); + ImGui.TableNextColumn(); + ImGui.SetNextItemWidth(400 * UiHelpers.Scale); + var tmp = info.CurrentMaterials[0]; + if (ImUtf8.InputText("##0"u8, ref tmp)) + info.SetMaterial(tmp, 0); + + for (var i = 1; i < info.Count; ++i) + { + using var id2 = ImUtf8.PushId(i); + ImGui.TableNextColumn(); + ImGui.TableNextColumn(); + ImGui.TableNextColumn(); + ImGui.TableNextColumn(); + ImGui.SetNextItemWidth(400 * UiHelpers.Scale); + tmp = info.CurrentMaterials[i]; + if (ImUtf8.InputText(""u8, ref tmp)) + info.SetMaterial(tmp, i); + } + } + } +} diff --git a/Penumbra/UI/AdvancedWindow/ModEditWindow.Meta.cs b/Penumbra/UI/AdvancedWindow/ModEditWindow.Meta.cs new file mode 100644 index 00000000..06cd0763 --- /dev/null +++ b/Penumbra/UI/AdvancedWindow/ModEditWindow.Meta.cs @@ -0,0 +1,198 @@ +using Dalamud.Interface; +using Dalamud.Bindings.ImGui; +using OtterGui; +using OtterGui.Raii; +using OtterGui.Text; +using Penumbra.Api.Api; +using Penumbra.GameData.Data; +using Penumbra.GameData.Enums; +using Penumbra.Meta.Manipulations; +using Penumbra.UI.AdvancedWindow.Meta; +using Penumbra.UI.Classes; + +namespace Penumbra.UI.AdvancedWindow; + +public partial class ModEditWindow +{ + private readonly MetaDrawers _metaDrawers; + + private void DrawMetaTab() + { + using var tab = ImUtf8.TabItem("Meta Manipulations"u8); + if (!tab) + return; + + DrawOptionSelectHeader(); + + var setsEqual = !_editor.MetaEditor.Changes; + var tt = setsEqual ? "No changes staged."u8 : "Apply the currently staged changes to the option."u8; + ImGui.NewLine(); + if (ImUtf8.ButtonEx("Apply Changes"u8, tt, Vector2.Zero, setsEqual)) + _editor.MetaEditor.Apply(_editor.Option!); + + ImGui.SameLine(); + tt = setsEqual ? "No changes staged."u8 : "Revert all currently staged changes."u8; + if (ImUtf8.ButtonEx("Revert Changes"u8, tt, Vector2.Zero, setsEqual)) + _editor.MetaEditor.Load(_editor.Mod!, _editor.Option!); + + ImGui.SameLine(); + AddFromClipboardButton(); + ImGui.SameLine(); + SetFromClipboardButton(); + ImGui.SameLine(); + CopyToClipboardButton("Copy all current manipulations to clipboard.", _iconSize, _editor.MetaEditor); + ImGui.SameLine(); + if (ImUtf8.Button("Write as TexTools Files"u8)) + _metaFileManager.WriteAllTexToolsMeta(Mod!); + ImGui.SameLine(); + if (ImUtf8.ButtonEx("Remove All Default-Values"u8, "Delete any entries from all lists that set the value to its default value."u8)) + _editor.MetaEditor.DeleteDefaultValues(); + ImGui.SameLine(); + DrawAtchDragDrop(); + + using var child = ImRaii.Child("##meta", -Vector2.One, true); + if (!child) + return; + + DrawEditHeader(MetaManipulationType.Eqp); + DrawEditHeader(MetaManipulationType.Eqdp); + DrawEditHeader(MetaManipulationType.Imc); + DrawEditHeader(MetaManipulationType.Est); + DrawEditHeader(MetaManipulationType.Gmp); + DrawEditHeader(MetaManipulationType.Rsp); + DrawEditHeader(MetaManipulationType.Atch); + DrawEditHeader(MetaManipulationType.Shp); + DrawEditHeader(MetaManipulationType.Atr); + DrawEditHeader(MetaManipulationType.GlobalEqp); + } + + private void DrawAtchDragDrop() + { + _dragDropManager.CreateImGuiSource("atchDrag", f => f.Extensions.Contains(".atch"), f => + { + var gr = Parser.ParseRaceCode(f.Files.FirstOrDefault() ?? string.Empty); + if (gr is GenderRace.Unknown) + return false; + + ImUtf8.Text($"Dragging .atch for {gr.ToName()}..."); + return true; + }); + var hasAtch = _editor.Files.Atch.Count > 0; + if (ImUtf8.ButtonEx("Import .atch"u8, + _dragDropManager.IsDragging + ? ""u8 + : hasAtch + ? "Drag a .atch file containing its race code in the path here to import its values.\n\nClick to select an .atch file from the mod."u8 + : "Drag a .atch file containing its race code in the path here to import its values."u8, default, + !_dragDropManager.IsDragging && !hasAtch) + && hasAtch) + ImUtf8.OpenPopup("##atchPopup"u8); + if (_dragDropManager.CreateImGuiTarget("atchDrag", out var files, out _) && files.FirstOrDefault() is { } file) + _metaDrawers.Atch.ImportFile(file); + + using var popup = ImUtf8.Popup("##atchPopup"u8); + if (!popup) + return; + + if (!hasAtch) + { + ImGui.CloseCurrentPopup(); + return; + } + + foreach (var atchFile in _editor.Files.Atch) + { + if (ImUtf8.Selectable(atchFile.RelPath.Path.Span) && atchFile.File.Exists) + _metaDrawers.Atch.ImportFile(atchFile.File.FullName); + } + } + + private void DrawEditHeader(MetaManipulationType type) + { + var drawer = _metaDrawers.Get(type); + if (drawer == null) + return; + + var oldPos = ImGui.GetCursorPosY(); + var header = ImUtf8.CollapsingHeader($"{_editor.MetaEditor.GetCount(type)} {drawer.Label}"); + DrawOtherOptionData(type, oldPos, ImGui.GetCursorPos()); + if (!header) + return; + + DrawTable(drawer); + } + + private static void DrawTable(IMetaDrawer drawer) + { + const ImGuiTableFlags flags = ImGuiTableFlags.RowBg | ImGuiTableFlags.SizingFixedFit | ImGuiTableFlags.BordersInnerV; + using var table = ImUtf8.Table(drawer.Label, drawer.NumColumns, flags); + if (!table) + return; + + drawer.Draw(); + ImGui.NewLine(); + } + + private void DrawOtherOptionData(MetaManipulationType type, float oldPos, Vector2 newPos) + { + var otherOptionData = _editor.MetaEditor.OtherData[type]; + if (otherOptionData.TotalCount <= 0) + return; + + var text = $"{otherOptionData.TotalCount} Edits in other Options"; + var size = ImGui.CalcTextSize(text).X; + ImGui.SetCursorPos(new Vector2(ImGui.GetContentRegionAvail().X - size, oldPos + ImGui.GetStyle().FramePadding.Y)); + ImGuiUtil.TextColored(ColorId.RedundantAssignment.Value() | 0xFF000000, text); + if (ImGui.IsItemHovered()) + { + using var tt = ImUtf8.Tooltip(); + foreach (var name in otherOptionData) + ImUtf8.Text(name); + } + + ImGui.SetCursorPos(newPos); + } + + private static void CopyToClipboardButton(string tooltip, Vector2 iconSize, MetaDictionary manipulations) + { + if (!ImGuiUtil.DrawDisabledButton(FontAwesomeIcon.Clipboard.ToIconString(), iconSize, tooltip, false, true)) + return; + + var text = Functions.ToCompressedBase64(manipulations, 0); + if (text.Length > 0) + ImGui.SetClipboardText(text); + } + + private void AddFromClipboardButton() + { + if (ImUtf8.Button("Add from Clipboard"u8)) + { + var clipboard = ImGuiUtil.GetClipboardText(); + + if (MetaApi.ConvertManips(clipboard, out var manips, out _)) + { + _editor.MetaEditor.UpdateTo(manips); + _editor.MetaEditor.Changes = true; + } + } + + ImUtf8.HoverTooltip( + "Try to add meta manipulations currently stored in the clipboard to the current manipulations.\nOverwrites already existing manipulations."u8); + } + + private void SetFromClipboardButton() + { + if (ImUtf8.Button("Set from Clipboard"u8)) + { + var clipboard = ImGuiUtil.GetClipboardText(); + if (MetaApi.ConvertManips(clipboard, out var manips, out _)) + { + _editor.MetaEditor.SetTo(manips); + _editor.MetaEditor.Changes = true; + } + } + + ImUtf8.HoverTooltip( + "Try to set the current meta manipulations to the set currently stored in the clipboard.\nRemoves all other manipulations."u8); + } +} diff --git a/Penumbra/UI/AdvancedWindow/ModEditWindow.Models.MdlTab.cs b/Penumbra/UI/AdvancedWindow/ModEditWindow.Models.MdlTab.cs new file mode 100644 index 00000000..fc197bc0 --- /dev/null +++ b/Penumbra/UI/AdvancedWindow/ModEditWindow.Models.MdlTab.cs @@ -0,0 +1,384 @@ +using OtterGui.Extensions; +using Penumbra.GameData; +using Penumbra.GameData.Files; +using Penumbra.Import.Models; +using Penumbra.Import.Models.Export; +using Penumbra.Meta.Manipulations; +using Penumbra.String.Classes; + +namespace Penumbra.UI.AdvancedWindow; + +public partial class ModEditWindow +{ + private class MdlTab : IWritable + { + private readonly ModEditWindow _edit; + + public MdlFile Mdl { get; private set; } + private List?[] _attributes; + + public bool ImportKeepMaterials; + public bool ImportKeepAttributes; + + public ExportConfig ExportConfig; + + public List? GamePaths { get; private set; } + public int GamePathIndex; + + private bool _dirty; + public bool PendingIo { get; private set; } + public List IoExceptions { get; } = []; + public List IoWarnings { get; } = []; + + public MdlTab(ModEditWindow edit, byte[] bytes, string path) + { + _edit = edit; + + Initialize(new MdlFile(bytes)); + + FindGamePaths(path); + } + + [MemberNotNull(nameof(Mdl), nameof(_attributes))] + private void Initialize(MdlFile mdl) + { + Mdl = mdl; + _attributes = CreateAttributes(Mdl); + } + + /// + public bool Valid + => Mdl.Valid && Mdl.Materials.All(ValidateMaterial); + + /// + public byte[] Write() + => Mdl.Write(); + + public bool Dirty + { + get + { + var dirty = _dirty; + _dirty = false; + return dirty; + } + } + + /// Find the list of game paths that may correspond to this model. + /// Resolved path to a .mdl. + private void FindGamePaths(string path) + { + // If there's no current mod (somehow), there's nothing to resolve the model within. + var mod = _edit._editor.Mod; + if (mod == null) + return; + + if (!Path.IsPathRooted(path) && Utf8GamePath.FromString(path, out var p)) + { + GamePaths = [p]; + return; + } + + BeginIo(); + var task = Task.Run(() => + { + // TODO: Is it worth trying to order results based on option priorities for cases where more than one match is found? + // NOTE: We're using case-insensitive comparisons, as option group paths in mods are stored in lower case, but the mod editor uses paths directly from the file system, which may be mixed case. + return mod.AllDataContainers + .SelectMany(m => m.Files.Concat(m.FileSwaps)) + .Where(kv => kv.Value.FullName.Equals(path, StringComparison.OrdinalIgnoreCase)) + .Select(kv => kv.Key) + .ToList(); + }); + + task.ContinueWith(t => { GamePaths = FinalizeIo(t); }, TaskScheduler.Default); + } + + private KeyValuePair[] GetCurrentEstManipulations() + { + var mod = _edit._editor.Mod; + var option = _edit._editor.Option; + if (mod == null || option == null) + return []; + + // Filter then prepend the current option to ensure it's chosen first. + return mod.AllDataContainers + .Where(subMod => subMod != option) + .Prepend(option) + .SelectMany(subMod => subMod.Manipulations.Est) + .ToArray(); + } + + /// Export model to an interchange format. + /// Disk path to save the resulting file to. + /// .mdl game path to resolve satellite files such as skeletons relative to. + public void Export(string outputPath, Utf8GamePath mdlPath) + { + IEnumerable sklbPaths; + try + { + sklbPaths = _edit._models.ResolveSklbsForMdl(mdlPath.ToString(), GetCurrentEstManipulations()); + } + catch (Exception exception) + { + RecordIoExceptions(exception); + return; + } + + BeginIo(); + _edit._models.ExportToGltf(ExportConfig, Mdl, sklbPaths, ReadFile, outputPath) + .ContinueWith(FinalizeIo, TaskScheduler.Default); + } + + /// Import a model from an interchange format. + /// Disk path to load model data from. + public void Import(string inputPath) + { + BeginIo(); + _edit._models.ImportGltf(inputPath) + .ContinueWith(task => + { + var mdlFile = FinalizeIo(task, result => result.Item1, result => result.Item2); + if (mdlFile != null) + FinalizeImport(mdlFile); + }, TaskScheduler.Default); + } + + /// Finalise the import of a .mdl, applying any post-import transformations and state updates. + /// Model data to finalize. + private void FinalizeImport(MdlFile newMdl) + { + if (ImportKeepMaterials) + MergeMaterials(newMdl, Mdl); + + if (ImportKeepAttributes) + MergeAttributes(newMdl, Mdl); + + // Until someone works out how to actually author these, unconditionally merge element ids. + MergeElementIds(newMdl, Mdl); + + // TODO: Add flag editing. + newMdl.Flags1 = Mdl.Flags1; + newMdl.Flags2 = Mdl.Flags2; + + Initialize(newMdl); + _dirty = true; + } + + /// Merge material configuration from the source onto the target. + /// Model that will be updated. + /// Model to copy material configuration from. + private static void MergeMaterials(MdlFile target, MdlFile source) + { + target.Materials = source.Materials; + + for (var meshIndex = 0; meshIndex < target.Meshes.Length; meshIndex++) + { + target.Meshes[meshIndex].MaterialIndex = meshIndex < source.Meshes.Length + ? source.Meshes[meshIndex].MaterialIndex + : (ushort)0; + } + } + + /// Merge attribute configuration from the source onto the target. + /// Model that will be updated. > + /// Model to copy attribute configuration from. + private static void MergeAttributes(MdlFile target, MdlFile source) + { + target.Attributes = source.Attributes; + + var indexEnumerator = Enumerable.Range(0, target.Meshes.Length) + .SelectMany(mi => Enumerable.Range(0, target.Meshes[mi].SubMeshCount).Select(so => (mi, so))); + foreach (var (meshIndex, subMeshOffset) in indexEnumerator) + { + var subMeshIndex = target.Meshes[meshIndex].SubMeshIndex + subMeshOffset; + + // Preemptively reset the mask in case we need to shortcut out. + target.SubMeshes[subMeshIndex].AttributeIndexMask = 0u; + + // Rather than comparing sub-meshes directly, we're grouping by parent mesh in an attempt + // to maintain semantic connection between mesh index and sub mesh attributes. + if (meshIndex >= source.Meshes.Length) + continue; + + var sourceMesh = source.Meshes[meshIndex]; + + if (subMeshOffset >= sourceMesh.SubMeshCount) + continue; + + var sourceSubMesh = source.SubMeshes[sourceMesh.SubMeshIndex + subMeshOffset]; + + target.SubMeshes[subMeshIndex].AttributeIndexMask = sourceSubMesh.AttributeIndexMask; + } + } + + /// Merge element ids from the source onto the target. + /// Model that will be updated. > + /// Model to copy element ids from. + private static void MergeElementIds(MdlFile target, MdlFile source) + { + // This is overly simplistic, but effectively reproduces what TT did, sort of. + // TODO: Get a better idea of what these values represent. `ParentBoneName`, if it is a pointer into the bone array, does not seem to be _bounded_ by the bone array length, at least in the model. I'm guessing it _may_ be pointing into a .sklb instead? (i.e. the weapon's skeleton). EID stuff in general needs more work. + target.ElementIds = [.. source.ElementIds]; + } + + private void BeginIo() + { + PendingIo = true; + IoWarnings.Clear(); + IoExceptions.Clear(); + } + + private void FinalizeIo(Task task) + => FinalizeIo(task, _ => null, notifier => notifier); + + private TResult? FinalizeIo(Task task) + => FinalizeIo(task, result => result, null); + + private TResult? FinalizeIo(Task task, Func getResult, Func? getNotifier) + { + TResult? result = default; + RecordIoExceptions(task.Exception); + if (task is { IsCompletedSuccessfully: true, Result: not null }) + { + result = getResult(task.Result); + if (getNotifier != null) + IoWarnings.AddRange(getNotifier(task.Result).GetWarnings()); + } + + PendingIo = false; + + return result; + } + + private void RecordIoExceptions(Exception? exception) + { + switch (exception) + { + case null: break; + case AggregateException ae: + IoExceptions.AddRange(ae.Flatten().InnerExceptions); + break; + default: + IoExceptions.Add(exception); + break; + } + } + + /// Read a file from the active collection or game. + /// Game path to the file to load. + // TODO: Also look up files within the current mod regardless of mod state? + private byte[]? ReadFile(string path) + { + // TODO: if cross-collection lookups are turned off, this conversion can be skipped + if (!Utf8GamePath.FromString(path, out var utf8Path)) + throw new Exception($"Resolved path {path} could not be converted to a game path."); + + var resolvedPath = _edit._activeCollections.Current.ResolvePath(utf8Path) ?? new FullPath(utf8Path); + + // TODO: is it worth trying to use streams for these instead? I'll need to do this for mtrl/tex too, so might be a good idea. that said, the mtrl reader doesn't accept streams, so... + return resolvedPath.IsRooted + ? File.ReadAllBytes(resolvedPath.FullName) + : _edit._gameData.GetFile(resolvedPath.InternalName.ToString())?.Data; + } + + /// Validate the specified material. + /// + /// While materials can be relative (`/mt_...`) or absolute (`bg/...`), + /// they invariably must contain at least one directory seperator. + /// Missing this can lead to a crash. + /// + /// They must also be at least one character (though this is enforced + /// by containing a `/`), and end with `.mtrl`. + /// + public bool ValidateMaterial(string material) + { + return material.Contains('/') && material.EndsWith(".mtrl"); + } + + /// Remove the material given by the index. + /// Meshes using the removed material are redirected to material 0, and those after the index are corrected. + public void RemoveMaterial(int materialIndex) + { + for (var meshIndex = 0; meshIndex < Mdl.Meshes.Length; meshIndex++) + { + var newIndex = Mdl.Meshes[meshIndex].MaterialIndex; + if (newIndex == materialIndex) + newIndex = 0; + else if (newIndex > materialIndex) + --newIndex; + + Mdl.Meshes[meshIndex].MaterialIndex = newIndex; + } + + Mdl.Materials = Mdl.Materials.RemoveItems(materialIndex); + } + + /// Create a list of attributes per sub mesh. + private static List?[] CreateAttributes(MdlFile mdl) + => mdl.SubMeshes.Select(s => + { + var maxAttribute = 31 - BitOperations.LeadingZeroCount(s.AttributeIndexMask); + // TODO: Research what results in this - it seems to primarily be reproducible on bgparts, is it garbage data, or an alternative usage of the value? + return maxAttribute < mdl.Attributes.Length + ? Enumerable.Range(0, 32) + .Where(idx => ((s.AttributeIndexMask >> idx) & 1) == 1) + .Select(idx => mdl.Attributes[idx]) + .ToList() + : null; + }).ToArray(); + + /// Obtain the attributes associated with a sub mesh by its index. + public IReadOnlyList? GetSubMeshAttributes(int subMeshIndex) + => _attributes[subMeshIndex]; + + /// Remove or add attributes from a sub mesh by its index. + /// The index of the sub mesh to update. + /// If non-null, remove this attribute. + /// If non-null, add this attribute. + public void UpdateSubMeshAttribute(int subMeshIndex, string? old, string? @new) + { + var attributes = _attributes[subMeshIndex]; + if (attributes == null) + return; + + if (old != null) + attributes.Remove(old); + + if (@new != null) + attributes.Add(@new); + + PersistAttributes(); + } + + /// Apply changes to attributes to the file in memory. + private void PersistAttributes() + { + var allAttributes = new List(); + + foreach (var (attributes, subMeshIndex) in _attributes.WithIndex()) + { + if (attributes == null) + continue; + + var mask = 0u; + + foreach (var attribute in attributes) + { + var attributeIndex = allAttributes.IndexOf(attribute); + if (attributeIndex == -1) + { + allAttributes.Add(attribute); + attributeIndex = allAttributes.Count - 1; + } + + mask |= 1u << attributeIndex; + } + + Mdl.SubMeshes[subMeshIndex].AttributeIndexMask = mask; + } + + Mdl.Attributes = [.. allAttributes]; + } + } +} diff --git a/Penumbra/UI/AdvancedWindow/ModEditWindow.Models.cs b/Penumbra/UI/AdvancedWindow/ModEditWindow.Models.cs new file mode 100644 index 00000000..a7db7f25 --- /dev/null +++ b/Penumbra/UI/AdvancedWindow/ModEditWindow.Models.cs @@ -0,0 +1,735 @@ +using Dalamud.Bindings.ImGui; +using Dalamud.Interface; +using Lumina.Data.Parsing; +using OtterGui; +using OtterGui.Custom; +using OtterGui.Extensions; +using OtterGui.Raii; +using OtterGui.Text; +using OtterGui.Widgets; +using Penumbra.GameData; +using Penumbra.GameData.Files; +using Penumbra.Import.Models; +using Penumbra.Import.Models.Import; +using Penumbra.String.Classes; +using Penumbra.UI.Classes; + +namespace Penumbra.UI.AdvancedWindow; + +public partial class ModEditWindow +{ + private const int MdlMaterialMaximum = ModelImporter.MaterialLimit; + + private const string MdlImportDocumentation = + @"https://github.com/xivdev/Penumbra/wiki/Model-IO#user-content-9b49d296-23ab-410a-845b-a3be769b71ea"; + + private const string MdlExportDocumentation = + @"https://github.com/xivdev/Penumbra/wiki/Model-IO#user-content-25968400-ebe5-4861-b610-cb1556db7ec4"; + + private readonly FileEditor _modelTab; + private readonly ModelManager _models; + + private class LoadedData + { + public MdlFile LastFile = null!; + public readonly List SubMeshAttributeTags = []; + public long[] LodTriCount = []; + } + + private string _modelNewMaterial = string.Empty; + + private readonly LoadedData _main = new(); + private readonly LoadedData _preview = new(); + + private string _customPath = string.Empty; + private Utf8GamePath _customGamePath = Utf8GamePath.Empty; + + + private LoadedData UpdateFile(MdlFile file, bool force, bool disabled) + { + var data = disabled ? _preview : _main; + if (file == data.LastFile && !force) + return data; + + data.LastFile = file; + var subMeshTotal = file.Meshes.Aggregate(0, (count, mesh) => count + mesh.SubMeshCount); + if (data.SubMeshAttributeTags.Count != subMeshTotal) + { + data.SubMeshAttributeTags.Clear(); + data.SubMeshAttributeTags.AddRange( + Enumerable.Range(0, subMeshTotal).Select(_ => new TagButtons()) + ); + } + + data.LodTriCount = Enumerable.Range(0, file.Lods.Length).Select(l => GetTriangleCountForLod(file, l)).ToArray(); + return data; + } + + private bool DrawModelPanel(MdlTab tab, bool disabled) + { + var ret = tab.Dirty; + var data = UpdateFile(tab.Mdl, ret, disabled); + DrawVersionUpdate(tab, disabled); + DrawImportExport(tab, disabled); + + ret |= DrawModelMaterialDetails(tab, disabled); + + if (ImGui.CollapsingHeader($"Meshes ({data.LastFile.Meshes.Length})###meshes")) + for (var i = 0; i < data.LastFile.LodCount; ++i) + ret |= DrawModelLodDetails(tab, i, disabled); + + ret |= DrawOtherModelDetails(data); + + return !disabled && ret; + } + + private void DrawVersionUpdate(MdlTab tab, bool disabled) + { + if (disabled || tab.Mdl.Version is not MdlFile.V5) + return; + + if (!ImUtf8.ButtonEx("Update MDL Version from V5 to V6"u8, + "Try using this if the bone weights of a pre-Dawntrail model seem wrong.\n\nThis is not revertible."u8, + new Vector2(-0.1f, 0), false, 0, Colors.PressEnterWarningBg)) + return; + + tab.Mdl.ConvertV5ToV6(); + _modelTab.SaveFile(); + } + + private void DrawImportExport(MdlTab tab, bool disabled) + { + if (!ImGui.CollapsingHeader("Import / Export")) + return; + + var childSize = new Vector2((ImGui.GetContentRegionAvail().X - ImGui.GetStyle().ItemSpacing.X) / 2, 0); + + DrawImport(tab, childSize, disabled); + ImGui.SameLine(); + DrawExport(tab, childSize, disabled); + + DrawIoExceptions(tab); + DrawIoWarnings(tab); + } + + private void DrawImport(MdlTab tab, Vector2 size, bool _1) + { + using var id = ImRaii.PushId("import"); + + _dragDropManager.CreateImGuiSource("ModelDragDrop", + m => m.Extensions.Any(e => ValidModelExtensions.Contains(e.ToLowerInvariant())), m => + { + if (!GetFirstModel(m.Files, out var file)) + return false; + + ImGui.TextUnformatted($"Dragging model for editing: {Path.GetFileName(file)}"); + return true; + }); + + using (ImRaii.FramedGroup("Import", size, headerPreIcon: FontAwesomeIcon.FileImport)) + { + ImGui.Checkbox("Keep current materials", ref tab.ImportKeepMaterials); + ImGui.Checkbox("Keep current attributes", ref tab.ImportKeepAttributes); + + if (ImGuiUtil.DrawDisabledButton("Import from glTF", Vector2.Zero, "Imports a glTF file, overriding the content of this mdl.", + tab.PendingIo)) + _fileDialog.OpenFilePicker("Load model from glTF.", "glTF{.gltf,.glb}", (success, paths) => + { + if (success && paths.Count > 0) + tab.Import(paths[0]); + }, 1, Mod!.ModPath.FullName, false); + + ImGui.SameLine(); + DrawDocumentationLink(MdlImportDocumentation); + } + + if (_dragDropManager.CreateImGuiTarget("ModelDragDrop", out var files, out _) && GetFirstModel(files, out var importFile)) + tab.Import(importFile); + } + + private void DrawExport(MdlTab tab, Vector2 size, bool _) + { + using var id = ImRaii.PushId("export"); + using var frame = ImRaii.FramedGroup("Export", size, headerPreIcon: FontAwesomeIcon.FileExport); + + if (tab.GamePaths == null) + { + ImGui.TextUnformatted(tab.IoExceptions.Count == 0 ? "Resolving model game paths." : "Failed to resolve model game paths."); + + return; + } + + DrawGamePathCombo(tab); + + ImGui.Checkbox("##exportGeneratedMissingBones", ref tab.ExportConfig.GenerateMissingBones); + ImGui.SameLine(); + ImGuiUtil.LabeledHelpMarker("Generate missing bones", + "WARNING: Enabling this option can result in unusable exported meshes.\n" + + "It is primarily intended to allow exporting models weighted to bones that do not exist.\n" + + "Before enabling, ensure dependencies are enabled in the current collection, and EST metadata is correctly configured."); + + var gamePath = tab.GamePathIndex >= 0 && tab.GamePathIndex < tab.GamePaths.Count + ? tab.GamePaths[tab.GamePathIndex] + : _customGamePath; + + if (ImGuiUtil.DrawDisabledButton("Export to glTF", Vector2.Zero, "Exports this mdl file to glTF, for use in 3D authoring applications.", + tab.PendingIo || gamePath.IsEmpty)) + _fileDialog.OpenSavePicker("Save model as glTF.", ".glb", Path.GetFileNameWithoutExtension(gamePath.Filename().ToString()), + ".glb", (valid, path) => + { + if (!valid) + return; + + tab.Export(path, gamePath); + }, + Mod!.ModPath.FullName, + false + ); + + ImGui.SameLine(); + DrawDocumentationLink(MdlExportDocumentation); + } + + private static void DrawIoExceptions(MdlTab tab) + { + if (tab.IoExceptions.Count == 0) + return; + + var size = new Vector2(ImGui.GetContentRegionAvail().X, 0); + using var frame = ImRaii.FramedGroup("Exceptions", size, headerPreIcon: FontAwesomeIcon.TimesCircle, + borderColor: Colors.RegexWarningBorder); + + var spaceAvail = ImGui.GetContentRegionAvail().X - ImGui.GetStyle().ItemSpacing.X - 100; + foreach (var (exception, index) in tab.IoExceptions.WithIndex()) + { + using var id = ImRaii.PushId(index); + var message = $"{exception.GetType().Name}: {exception.Message}"; + var textSize = ImGui.CalcTextSize(message).X; + if (textSize > spaceAvail) + message = message[..(int)Math.Floor(message.Length * (spaceAvail / textSize))] + "..."; + + using var exceptionNode = ImRaii.TreeNode(message); + if (exceptionNode) + { + using var indent = ImRaii.PushIndent(); + ImGuiUtil.TextWrapped(exception.ToString()); + } + } + } + + private static void DrawIoWarnings(MdlTab tab) + { + if (tab.IoWarnings.Count == 0) + return; + + var size = new Vector2(ImGui.GetContentRegionAvail().X, 0); + using var frame = ImRaii.FramedGroup("Warnings", size, headerPreIcon: FontAwesomeIcon.ExclamationCircle, borderColor: 0xFF40FFFF); + + var spaceAvail = ImGui.GetContentRegionAvail().X - ImGui.GetStyle().ItemSpacing.X - 100; + foreach (var (warning, index) in tab.IoWarnings.WithIndex()) + { + using var id = ImRaii.PushId(index); + var textSize = ImGui.CalcTextSize(warning).X; + + if (textSize <= spaceAvail) + { + ImRaii.TreeNode(warning, ImGuiTreeNodeFlags.Leaf).Dispose(); + continue; + } + + var firstLine = warning[..(int)Math.Floor(warning.Length * (spaceAvail / textSize))] + "..."; + + using var warningNode = ImRaii.TreeNode(firstLine); + if (warningNode) + { + using var indent = ImRaii.PushIndent(); + ImGuiUtil.TextWrapped(warning); + } + } + } + + private void DrawGamePathCombo(MdlTab tab) + { + if (tab.GamePaths!.Count != 0) + { + DrawComboButton(tab); + return; + } + + ImGui.TextUnformatted("No associated game path detected. Valid game paths are currently necessary for exporting."); + if (!ImGui.InputTextWithHint("##customInput", "Enter custom game path...", ref _customPath, 256)) + return; + + if (!Utf8GamePath.FromString(_customPath, out _customGamePath)) + _customGamePath = Utf8GamePath.Empty; + } + + /// I disliked the combo with only one selection so turn it into a button in that case. + private static void DrawComboButton(MdlTab tab) + { + const string label = "Game Path"; + var preview = tab.GamePaths![tab.GamePathIndex].ToString(); + var labelWidth = ImGui.CalcTextSize(label).X + ImGui.GetStyle().ItemInnerSpacing.X; + var buttonWidth = ImGui.GetContentRegionAvail().X - labelWidth - ImGui.GetStyle().ItemSpacing.X; + if (tab.GamePaths!.Count == 1) + { + using var style = ImRaii.PushStyle(ImGuiStyleVar.ButtonTextAlign, new Vector2(0, 0.5f)); + using var color = ImRaii.PushColor(ImGuiCol.Button, ImGui.GetColorU32(ImGuiCol.FrameBg)) + .Push(ImGuiCol.ButtonHovered, ImGui.GetColorU32(ImGuiCol.FrameBgHovered)) + .Push(ImGuiCol.ButtonActive, ImGui.GetColorU32(ImGuiCol.FrameBgActive)); + using var group = ImRaii.Group(); + ImGui.Button(preview, new Vector2(buttonWidth, 0)); + ImGui.SameLine(0, ImGui.GetStyle().ItemInnerSpacing.X); + ImGui.TextUnformatted("Game Path"); + } + else + { + ImGui.SetNextItemWidth(buttonWidth); + using var combo = ImRaii.Combo("Game Path", preview); + if (combo.Success) + foreach (var (path, index) in tab.GamePaths.WithIndex()) + { + if (!ImGui.Selectable(path.ToString(), index == tab.GamePathIndex)) + continue; + + tab.GamePathIndex = index; + } + } + + if (ImGui.IsItemClicked(ImGuiMouseButton.Right)) + ImGui.SetClipboardText(preview); + ImGuiUtil.HoverTooltip("Right-Click to copy to clipboard.", ImGuiHoveredFlags.AllowWhenDisabled); + } + + private void DrawDocumentationLink(string address) + { + const string text = "Documentation →"; + + var framePadding = ImGui.GetStyle().FramePadding; + var width = ImGui.CalcTextSize(text).X + framePadding.X * 2; + + // Draw the link button. We set the background colour to transparent to mimic the look of a link. + using var color = ImRaii.PushColor(ImGuiCol.Button, 0x00000000); + CustomGui.DrawLinkButton(Penumbra.Messager, text, address, width); + + // Draw an underline for the text. + var lineStart = ImGui.GetItemRectMax(); + lineStart -= framePadding; + var lineEnd = lineStart with { X = ImGui.GetItemRectMin().X + framePadding.X }; + ImGui.GetWindowDrawList().AddLine(lineStart, lineEnd, 0xFFFFFFFF); + } + + private bool DrawModelMaterialDetails(MdlTab tab, bool disabled) + { + var invalidMaterialCount = tab.Mdl.Materials.Count(material => !tab.ValidateMaterial(material)); + + var oldPos = ImGui.GetCursorPosY(); + var header = ImGui.CollapsingHeader("Materials"); + var newPos = ImGui.GetCursorPos(); + if (invalidMaterialCount > 0) + { + var text = $"{invalidMaterialCount} invalid material{(invalidMaterialCount > 1 ? "s" : "")}"; + var size = ImGui.CalcTextSize(text).X; + ImGui.SetCursorPos(new Vector2(ImGui.GetContentRegionAvail().X - size, oldPos + ImGui.GetStyle().FramePadding.Y)); + ImGuiUtil.TextColored(0xFF0000FF, text); + ImGui.SetCursorPos(newPos); + } + + if (!header) + return false; + + using var table = ImRaii.Table(string.Empty, disabled ? 2 : 4, ImGuiTableFlags.SizingFixedFit); + if (!table) + return false; + + var ret = false; + var materials = tab.Mdl.Materials; + + ImGui.TableSetupColumn("index", ImGuiTableColumnFlags.WidthFixed, 80 * UiHelpers.Scale); + ImGui.TableSetupColumn("path", ImGuiTableColumnFlags.WidthStretch, 1); + if (!disabled) + { + ImGui.TableSetupColumn("actions", ImGuiTableColumnFlags.WidthFixed, UiHelpers.IconButtonSize.X); + ImGui.TableSetupColumn("help", ImGuiTableColumnFlags.WidthFixed, UiHelpers.IconButtonSize.X); + } + + var inputFlags = disabled ? ImGuiInputTextFlags.ReadOnly : ImGuiInputTextFlags.None; + for (var materialIndex = 0; materialIndex < materials.Length; materialIndex++) + ret |= DrawMaterialRow(tab, disabled, materials, materialIndex, inputFlags); + + if (materials.Length >= MdlMaterialMaximum || disabled) + return ret; + + ImGui.TableNextColumn(); + + ImGui.TableNextColumn(); + ImGui.SetNextItemWidth(-1); + ImGui.InputTextWithHint("##newMaterial", "Add new material...", ref _modelNewMaterial, Utf8GamePath.MaxGamePathLength, inputFlags); + var validName = tab.ValidateMaterial(_modelNewMaterial); + ImGui.TableNextColumn(); + if (ImGuiUtil.DrawDisabledButton(FontAwesomeIcon.Plus.ToIconString(), UiHelpers.IconButtonSize, string.Empty, !validName, true)) + { + ret |= true; + tab.Mdl.Materials = materials.AddItem(_modelNewMaterial); + _modelNewMaterial = string.Empty; + } + + ImGui.TableNextColumn(); + if (!validName && _modelNewMaterial.Length > 0) + DrawInvalidMaterialMarker(); + + return ret; + } + + private bool DrawMaterialRow(MdlTab tab, bool disabled, string[] materials, int materialIndex, ImGuiInputTextFlags inputFlags) + { + using var id = ImRaii.PushId(materialIndex); + var ret = false; + ImGui.TableNextColumn(); + ImGui.AlignTextToFramePadding(); + ImGui.TextUnformatted($"Material #{materialIndex + 1}"); + + var temp = materials[materialIndex]; + ImGui.TableNextColumn(); + ImGui.SetNextItemWidth(-1); + if (ImGui.InputText($"##material{materialIndex}", ref temp, Utf8GamePath.MaxGamePathLength, inputFlags) + && temp.Length > 0 + && temp != materials[materialIndex] + ) + { + materials[materialIndex] = temp; + ret = true; + } + + if (disabled) + return ret; + + ImGui.TableNextColumn(); + // Need to have at least one material. + if (materials.Length > 1) + { + var tt = "Delete this material.\nAny meshes targeting this material will be updated to use material #1."; + var modifierActive = _config.DeleteModModifier.IsActive(); + if (!modifierActive) + tt += $"\nHold {_config.DeleteModModifier} to delete."; + + if (ImGuiUtil.DrawDisabledButton(FontAwesomeIcon.Trash.ToIconString(), UiHelpers.IconButtonSize, tt, !modifierActive, true)) + { + tab.RemoveMaterial(materialIndex); + ret |= true; + } + } + + ImGui.TableNextColumn(); + // Add markers to invalid materials. + if (!tab.ValidateMaterial(temp)) + DrawInvalidMaterialMarker(); + + return ret; + } + + private static void DrawInvalidMaterialMarker() + { + using (ImRaii.PushFont(UiBuilder.IconFont)) + { + ImGuiUtil.TextColored(0xFF0000FF, FontAwesomeIcon.TimesCircle.ToIconString()); + } + + ImGuiUtil.HoverTooltip( + "Materials must be either relative (e.g. \"/filename.mtrl\")\n" + + "or absolute (e.g. \"bg/full/path/to/filename.mtrl\"),\n" + + "and must end in \".mtrl\"."); + } + + private bool DrawModelLodDetails(MdlTab tab, int lodIndex, bool disabled) + { + using var lodNode = ImRaii.TreeNode($"Level of Detail #{lodIndex + 1}", ImGuiTreeNodeFlags.DefaultOpen); + if (!lodNode) + return false; + + var lod = tab.Mdl.Lods[lodIndex]; + var ret = false; + + for (var meshOffset = 0; meshOffset < lod.MeshCount; meshOffset++) + ret |= DrawModelMeshDetails(tab, lod.MeshIndex + meshOffset, disabled); + + return ret; + } + + private bool DrawModelMeshDetails(MdlTab tab, int meshIndex, bool disabled) + { + using var meshNode = ImRaii.TreeNode($"Mesh #{meshIndex + 1}", ImGuiTreeNodeFlags.DefaultOpen); + if (!meshNode) + return false; + + using var id = ImRaii.PushId(meshIndex); + using var table = ImRaii.Table(string.Empty, 2, ImGuiTableFlags.SizingFixedFit); + if (!table) + return false; + + ImGui.TableSetupColumn("name", ImGuiTableColumnFlags.WidthFixed, 100 * UiHelpers.Scale); + ImGui.TableSetupColumn("field", ImGuiTableColumnFlags.WidthStretch, 1); + + var file = tab.Mdl; + var mesh = file.Meshes[meshIndex]; + + // Vertex elements + ImGui.TableNextColumn(); + ImGui.AlignTextToFramePadding(); + ImGui.TextUnformatted("Vertex Elements"); + + ImGui.TableNextColumn(); + DrawVertexElementDetails(file.VertexDeclarations[meshIndex].VertexElements); + + // Mesh material + ImGui.TableNextColumn(); + ImGui.AlignTextToFramePadding(); + ImGui.TextUnformatted("Material"); + + ImGui.TableNextColumn(); + var ret = DrawMaterialCombo(tab, meshIndex, disabled); + + // Sub meshes + for (var subMeshOffset = 0; subMeshOffset < mesh.SubMeshCount; subMeshOffset++) + ret |= DrawSubMeshAttributes(tab, meshIndex, subMeshOffset, disabled); + + return ret; + } + + private static void DrawVertexElementDetails(MdlStructs.VertexElement[] vertexElements) + { + using var node = ImRaii.TreeNode($"Click to expand"); + if (!node) + return; + + var flags = ImGuiTableFlags.SizingFixedFit + | ImGuiTableFlags.RowBg + | ImGuiTableFlags.Borders + | ImGuiTableFlags.NoHostExtendX; + using var table = ImRaii.Table(string.Empty, 4, flags); + if (!table) + return; + + ImGui.TableSetupColumn("Usage"); + ImGui.TableSetupColumn("Type"); + ImGui.TableSetupColumn("Stream"); + ImGui.TableSetupColumn("Offset"); + + ImGui.TableHeadersRow(); + + foreach (var element in vertexElements) + { + ImGui.TableNextColumn(); + ImGui.TextUnformatted($"{(MdlFile.VertexUsage)element.Usage}"); + ImGui.TableNextColumn(); + ImGui.TextUnformatted($"{(MdlFile.VertexType)element.Type}"); + ImGui.TableNextColumn(); + ImGui.TextUnformatted($"{element.Stream}"); + ImGui.TableNextColumn(); + ImGui.TextUnformatted($"{element.Offset}"); + } + } + + private static bool DrawMaterialCombo(MdlTab tab, int meshIndex, bool disabled) + { + var mesh = tab.Mdl.Meshes[meshIndex]; + using var _ = ImRaii.Disabled(disabled); + ImGui.SetNextItemWidth(-1); + using var materialCombo = ImRaii.Combo("##material", tab.Mdl.Materials[mesh.MaterialIndex]); + + if (!materialCombo) + return false; + + var ret = false; + foreach (var (material, materialIndex) in tab.Mdl.Materials.WithIndex()) + { + if (!ImGui.Selectable(material, mesh.MaterialIndex == materialIndex)) + continue; + + tab.Mdl.Meshes[meshIndex].MaterialIndex = (ushort)materialIndex; + ret = true; + } + + return ret; + } + + private bool DrawSubMeshAttributes(MdlTab tab, int meshIndex, int subMeshOffset, bool disabled) + { + using var _ = ImRaii.PushId(subMeshOffset); + + var mesh = tab.Mdl.Meshes[meshIndex]; + var subMeshIndex = mesh.SubMeshIndex + subMeshOffset; + + ImGui.TableNextColumn(); + ImGui.AlignTextToFramePadding(); + ImGui.TextUnformatted($"Attributes #{subMeshOffset + 1}"); + + ImGui.TableNextColumn(); + var data = disabled ? _preview : _main; + var widget = data.SubMeshAttributeTags[subMeshIndex]; + var attributes = tab.GetSubMeshAttributes(subMeshIndex); + + if (attributes == null) + { + attributes = ["invalid attribute data"]; + disabled = true; + } + + var tagIndex = widget.Draw(string.Empty, string.Empty, attributes, + out var editedAttribute, !disabled); + if (tagIndex < 0) + return false; + + var oldName = tagIndex < attributes.Count ? attributes[tagIndex] : null; + var newName = editedAttribute.Length > 0 ? editedAttribute : null; + tab.UpdateSubMeshAttribute(subMeshIndex, oldName, newName); + + return true; + } + + private bool DrawOtherModelDetails(LoadedData data) + { + using var header = ImRaii.CollapsingHeader("Further Content"); + if (!header) + return false; + + var ret = false; + using (var table = ImRaii.Table("##data", 2, ImGuiTableFlags.SizingFixedFit)) + { + if (table) + { + ImGuiUtil.DrawTableColumn("Version"); + ImGuiUtil.DrawTableColumn($"0x{data.LastFile.Version:X}"); + ImGuiUtil.DrawTableColumn("Radius"); + ImGuiUtil.DrawTableColumn(data.LastFile.Radius.ToString(CultureInfo.InvariantCulture)); + ImGuiUtil.DrawTableColumn("Model Clip Out Distance"); + ImGuiUtil.DrawTableColumn(data.LastFile.ModelClipOutDistance.ToString(CultureInfo.InvariantCulture)); + ImGuiUtil.DrawTableColumn("Shadow Clip Out Distance"); + ImGuiUtil.DrawTableColumn(data.LastFile.ShadowClipOutDistance.ToString(CultureInfo.InvariantCulture)); + ImGuiUtil.DrawTableColumn("LOD Count"); + ImGuiUtil.DrawTableColumn(data.LastFile.LodCount.ToString()); + ImGuiUtil.DrawTableColumn("Enable Index Buffer Streaming"); + ImGuiUtil.DrawTableColumn(data.LastFile.EnableIndexBufferStreaming.ToString()); + ImGuiUtil.DrawTableColumn("Enable Edge Geometry"); + ImGuiUtil.DrawTableColumn(data.LastFile.EnableEdgeGeometry.ToString()); + ImGuiUtil.DrawTableColumn("Flags 1"); + ImGuiUtil.DrawTableColumn(data.LastFile.Flags1.ToString()); + ImGuiUtil.DrawTableColumn("Flags 2"); + ImGuiUtil.DrawTableColumn(data.LastFile.Flags2.ToString()); + ImGuiUtil.DrawTableColumn("Vertex Declarations"); + ImGuiUtil.DrawTableColumn(data.LastFile.VertexDeclarations.Length.ToString()); + ImGuiUtil.DrawTableColumn("Bone Bounding Boxes"); + ImGuiUtil.DrawTableColumn(data.LastFile.BoneBoundingBoxes.Length.ToString()); + ImGuiUtil.DrawTableColumn("Bone Tables"); + ImGuiUtil.DrawTableColumn(data.LastFile.BoneTables.Length.ToString()); + ImGuiUtil.DrawTableColumn("Element IDs"); + ImGuiUtil.DrawTableColumn(data.LastFile.ElementIds.Length.ToString()); + ImGuiUtil.DrawTableColumn("Extra LoDs"); + ImGuiUtil.DrawTableColumn(data.LastFile.ExtraLods.Length.ToString()); + ImGuiUtil.DrawTableColumn("Meshes"); + ImGuiUtil.DrawTableColumn(data.LastFile.Meshes.Length.ToString()); + ImGuiUtil.DrawTableColumn("Shape Meshes"); + ImGuiUtil.DrawTableColumn(data.LastFile.ShapeMeshes.Length.ToString()); + ImGuiUtil.DrawTableColumn("LoDs"); + ImGuiUtil.DrawTableColumn(data.LastFile.Lods.Length.ToString()); + ImGuiUtil.DrawTableColumn("Vertex Declarations"); + ImGuiUtil.DrawTableColumn(data.LastFile.VertexDeclarations.Length.ToString()); + ImGuiUtil.DrawTableColumn("Stack Size"); + ImGuiUtil.DrawTableColumn(data.LastFile.StackSize.ToString()); + foreach (var (triCount, lod) in data.LodTriCount.WithIndex()) + { + ImGuiUtil.DrawTableColumn($"LOD #{lod + 1} Triangle Count"); + ImGuiUtil.DrawTableColumn(triCount.ToString()); + } + } + } + + using (var materials = ImRaii.TreeNode("Materials", ImGuiTreeNodeFlags.DefaultOpen)) + { + if (materials) + foreach (var material in data.LastFile.Materials) + ImRaii.TreeNode(material, ImGuiTreeNodeFlags.Leaf).Dispose(); + } + + using (var attributes = ImRaii.TreeNode("Attributes", ImGuiTreeNodeFlags.DefaultOpen)) + { + if (attributes) + for (var i = 0; i < data.LastFile.Attributes.Length; ++i) + { + using var id = ImUtf8.PushId(i); + ref var attribute = ref data.LastFile.Attributes[i]; + var name = attribute; + if (ImUtf8.InputText("##attribute"u8, ref name, "Attribute Name..."u8) && name.Length > 0 && name != attribute) + { + attribute = name; + ret = true; + } + } + } + + using (var bones = ImRaii.TreeNode("Bones", ImGuiTreeNodeFlags.DefaultOpen)) + { + if (bones) + for (var i = 0; i < data.LastFile.Bones.Length; ++i) + { + using var id = ImUtf8.PushId(i); + ref var bone = ref data.LastFile.Bones[i]; + var name = bone; + if (ImUtf8.InputText("##bone"u8, ref name, "Bone Name..."u8) && name.Length > 0 && name != bone) + { + bone = name; + ret = true; + } + } + } + + using (var shapes = ImRaii.TreeNode("Shapes", ImGuiTreeNodeFlags.DefaultOpen)) + { + if (shapes) + for (var i = 0; i < data.LastFile.Shapes.Length; ++i) + { + using var id = ImUtf8.PushId(i); + ref var shape = ref data.LastFile.Shapes[i]; + var name = shape.ShapeName; + if (ImUtf8.InputText("##shape"u8, ref name, "Shape Name..."u8) && name.Length > 0 && name != shape.ShapeName) + { + shape.ShapeName = name; + ret = true; + } + } + } + + if (data.LastFile.RemainingData.Length > 0) + { + using var t = ImRaii.TreeNode($"Additional Data (Size: {data.LastFile.RemainingData.Length})###AdditionalData"); + if (t) + Widget.DrawHexViewer(data.LastFile.RemainingData); + } + + return ret; + } + + private static bool GetFirstModel(IEnumerable files, [NotNullWhen(true)] out string? file) + { + file = files.FirstOrDefault(f => ValidModelExtensions.Contains(Path.GetExtension(f).ToLowerInvariant())); + return file != null; + } + + private static long GetTriangleCountForLod(MdlFile model, int lod) + { + var vertSum = 0u; + var meshIndex = model.Lods[lod].MeshIndex; + var meshCount = model.Lods[lod].MeshCount; + + for (var i = meshIndex; i < meshIndex + meshCount; i++) + vertSum += model.Meshes[i].IndexCount; + + return vertSum / 3; + } + + private static readonly string[] ValidModelExtensions = + [ + ".gltf", + ".glb", + ]; +} diff --git a/Penumbra/UI/AdvancedWindow/ModEditWindow.QuickImport.cs b/Penumbra/UI/AdvancedWindow/ModEditWindow.QuickImport.cs new file mode 100644 index 00000000..f55ae576 --- /dev/null +++ b/Penumbra/UI/AdvancedWindow/ModEditWindow.QuickImport.cs @@ -0,0 +1,198 @@ +using Dalamud.Interface; +using Dalamud.Bindings.ImGui; +using Lumina.Data; +using OtterGui.Text; +using Penumbra.Api.Enums; +using Penumbra.GameData.Files; +using Penumbra.Interop.ResourceTree; +using Penumbra.Mods; +using Penumbra.Mods.Editor; +using Penumbra.Mods.SubMods; +using Penumbra.String.Classes; + +namespace Penumbra.UI.AdvancedWindow; + +public partial class ModEditWindow +{ + private readonly FileDialogService _fileDialog; + private readonly ResourceTreeFactory _resourceTreeFactory; + private readonly ResourceTreeViewer _quickImportViewer; + private readonly Dictionary<(Utf8GamePath, IWritable?), QuickImportAction> _quickImportActions = new(); + + private HashSet GetPlayerResourcesOfType(ResourceType type) + { + var resources = ResourceTreeApiHelper + .GetResourcesOfType(_resourceTreeFactory.FromObjectTable(ResourceTreeFactory.Flags.LocalPlayerRelatedOnly), type) + .Values + .SelectMany(r => r.Values) + .Select(r => r.Item1); + + return new HashSet(resources, StringComparer.OrdinalIgnoreCase); + } + + private IReadOnlyList PopulateIsOnPlayer(IReadOnlyList files, ResourceType type) + { + var playerResources = GetPlayerResourcesOfType(type); + foreach (var file in files) + file.IsOnPlayer = playerResources.Contains(file.File.ToPath()); + + return files; + } + + private void DrawQuickImportTab() + { + using var tab = ImUtf8.TabItem("Import from Screen"u8); + if (!tab) + { + _quickImportActions.Clear(); + return; + } + + if (DrawOptionSelectHeader()) + _quickImportActions.Clear(); + _quickImportViewer.Draw(); + } + + private void OnQuickImportRefresh() + { + _quickImportActions.Clear(); + } + + private void DrawQuickImportActions(ResourceNode resourceNode, IWritable? writable, Vector2 buttonSize) + { + ImGui.SameLine(); + if (!_quickImportActions!.TryGetValue((resourceNode.GamePath, writable), out var quickImport)) + { + quickImport = QuickImportAction.Prepare(this, resourceNode.GamePath, writable); + _quickImportActions.Add((resourceNode.GamePath, writable), quickImport); + } + + var canQuickImport = quickImport.CanExecute; + var quickImportEnabled = canQuickImport && (!resourceNode.Protected || _config.DeleteModModifier.IsActive()); + if (ImUtf8.IconButton(FontAwesomeIcon.FileImport, + $"Add a copy of this file to {quickImport.OptionName}.{(canQuickImport && !quickImportEnabled ? $"\nHold {_config.DeleteModModifier} while clicking to add." : string.Empty)}", + buttonSize, + !quickImportEnabled)) + { + quickImport.Execute(); + _quickImportActions.Remove((resourceNode.GamePath, writable)); + } + } + + public class QuickImportAction + { + public const string FallbackOptionName = "the current option"; + + private readonly string _optionName; + private readonly Utf8GamePath _gamePath; + private readonly ModEditor _editor; + private readonly IWritable? _file; + private readonly string? _targetPath; + private readonly int _subDirs; + + public string OptionName + => _optionName; + + public Utf8GamePath GamePath + => _gamePath; + + public bool CanExecute + => !_gamePath.IsEmpty && _editor.Mod != null && _file != null && _targetPath != null; + + /// + /// Creates a non-executable QuickImportAction. + /// + private QuickImportAction(ModEditor editor, string optionName, Utf8GamePath gamePath) + { + _optionName = optionName; + _gamePath = gamePath; + _editor = editor; + _file = null; + _targetPath = null; + _subDirs = 0; + } + + /// + /// Creates an executable QuickImportAction. + /// + private QuickImportAction(string optionName, Utf8GamePath gamePath, ModEditor editor, IWritable file, string targetPath, int subDirs) + { + _optionName = optionName; + _gamePath = gamePath; + _editor = editor; + _file = file; + _targetPath = targetPath; + _subDirs = subDirs; + } + + public static QuickImportAction Prepare(ModEditWindow owner, Utf8GamePath gamePath, IWritable? file) + { + var editor = owner._editor; + if (editor is null) + return new QuickImportAction(owner._editor, FallbackOptionName, gamePath); + + var subMod = editor.Option!; + var optionName = subMod is IModOption o ? o.FullName : FallbackOptionName; + if (gamePath.IsEmpty || file is null || editor.FileEditor.Changes) + return new QuickImportAction(editor, optionName, gamePath); + + if (subMod.Files.ContainsKey(gamePath) || subMod.FileSwaps.ContainsKey(gamePath)) + return new QuickImportAction(editor, optionName, gamePath); + + var mod = owner.Mod; + if (mod is null) + return new QuickImportAction(editor, optionName, gamePath); + + var (preferredPath, subDirs) = GetPreferredPath(mod, subMod as IModOption, owner._config.ReplaceNonAsciiOnImport); + var targetPath = new FullPath(Path.Combine(preferredPath.FullName, gamePath.ToString())).FullName; + if (File.Exists(targetPath)) + return new QuickImportAction(editor, optionName, gamePath); + + return new QuickImportAction(optionName, gamePath, editor, file, targetPath, subDirs); + } + + public FileRegistry Execute() + { + if (!CanExecute) + throw new InvalidOperationException(); + + var directory = Path.GetDirectoryName(_targetPath); + if (directory != null) + Directory.CreateDirectory(directory); + _editor.Compactor.WriteAllBytes(_targetPath!, _file!.Write()); + _editor.FileEditor.Revert(_editor.Mod!, _editor.Option!); + var fileRegistry = _editor.Files.Available.First(file => file.File.FullName == _targetPath); + _editor.FileEditor.AddPathsToSelected(_editor.Option!, new[] + { + fileRegistry, + }, _subDirs); + _editor.FileEditor.Apply(_editor.Mod!, _editor.Option!); + + return fileRegistry; + } + + private static (DirectoryInfo, int) GetPreferredPath(Mod mod, IModOption? subMod, bool replaceNonAscii) + { + var path = mod.ModPath; + var subDirs = 0; + if (subMod is null) + return (path, subDirs); + + var name = subMod.Name; + var fullName = subMod.FullName; + if (fullName.EndsWith(": " + name)) + { + path = ModCreator.NewOptionDirectory(path, fullName[..^(name.Length + 2)], replaceNonAscii); + path = ModCreator.NewOptionDirectory(path, name, replaceNonAscii); + subDirs = 2; + } + else + { + path = ModCreator.NewOptionDirectory(path, fullName, replaceNonAscii); + subDirs = 1; + } + + return (path, subDirs); + } + } +} diff --git a/Penumbra/UI/AdvancedWindow/ModEditWindow.ShaderPackages.cs b/Penumbra/UI/AdvancedWindow/ModEditWindow.ShaderPackages.cs new file mode 100644 index 00000000..baaf4a82 --- /dev/null +++ b/Penumbra/UI/AdvancedWindow/ModEditWindow.ShaderPackages.cs @@ -0,0 +1,787 @@ +using Dalamud.Interface; +using Dalamud.Interface.ImGuiNotification; +using Dalamud.Bindings.ImGui; +using OtterGui.Raii; +using OtterGui; +using OtterGui.Classes; +using Penumbra.GameData; +using Penumbra.GameData.Files; +using Penumbra.GameData.Interop; +using Penumbra.String; +using static Penumbra.GameData.Files.ShpkFile; +using OtterGui.Widgets; +using OtterGui.Text; +using Penumbra.GameData.Structs; +using OtterGui.Extensions; + +namespace Penumbra.UI.AdvancedWindow; + +public partial class ModEditWindow +{ + private static readonly CiByteString DisassemblyLabel = CiByteString.FromSpanUnsafe("##disassembly"u8, true, true, true); + + private readonly FileEditor _shaderPackageTab; + + private static bool DrawShaderPackagePanel(ShpkTab file, bool disabled) + { + DrawShaderPackageSummary(file); + + ImGui.Dummy(new Vector2(ImGui.GetTextLineHeight() / 2)); + DrawShaderPackageFilterSection(file); + + var ret = false; + ImGui.Dummy(new Vector2(ImGui.GetTextLineHeight() / 2)); + ret |= DrawShaderPackageShaderArray(file, "Vertex Shader", file.Shpk.VertexShaders, disabled); + + ImGui.Dummy(new Vector2(ImGui.GetTextLineHeight() / 2)); + ret |= DrawShaderPackageShaderArray(file, "Pixel Shader", file.Shpk.PixelShaders, disabled); + + ImGui.Dummy(new Vector2(ImGui.GetTextLineHeight() / 2)); + ret |= DrawShaderPackageMaterialParamLayout(file, disabled); + + ImGui.Dummy(new Vector2(ImGui.GetTextLineHeight() / 2)); + ret |= DrawShaderPackageResources(file, disabled); + + ImGui.Dummy(new Vector2(ImGui.GetTextLineHeight() / 2)); + DrawShaderPackageSelection(file); + + ImGui.Dummy(new Vector2(ImGui.GetTextLineHeight() / 2)); + DrawOtherShaderPackageDetails(file); + + ret |= file.Shpk.IsChanged(); + + return !disabled && ret; + } + + private static void DrawShaderPackageSummary(ShpkTab tab) + { + if (tab.Shpk.IsLegacy) + ImUtf8.Text("This legacy shader package will not work in the current version of the game. Do not attempt to load it.", + ImGuiUtil.HalfBlendText(0x80u)); // Half red + ImUtf8.Text(tab.Header); + if (!tab.Shpk.Disassembled) + ImUtf8.Text("Your system doesn't support disassembling shaders. Some functionality will be missing.", + ImGuiUtil.HalfBlendText(0x80u)); // Half red + } + + private static void DrawShaderExportButton(ShpkTab tab, string objectName, Shader shader, int idx) + { + if (!ImUtf8.Button($"Export Shader Program Blob ({shader.Blob.Length} bytes)")) + return; + + var defaultName = objectName[0] switch + { + 'V' => $"vs{idx}", + 'P' => $"ps{idx}", + _ => throw new NotImplementedException(), + }; + + var blob = shader.Blob; + tab.FileDialog.OpenSavePicker($"Export {objectName} #{idx} Program Blob to...", tab.Extension, defaultName, tab.Extension, + (success, name) => + { + if (!success) + return; + + try + { + File.WriteAllBytes(name, blob); + } + catch (Exception e) + { + Penumbra.Messager.NotificationMessage(e, $"Could not export {defaultName}{tab.Extension} to {name}.", + NotificationType.Error, false); + return; + } + + Penumbra.Messager.NotificationMessage( + $"Shader Program Blob {defaultName}{tab.Extension} exported successfully to {Path.GetFileName(name)}.", + NotificationType.Success, false); + }, null, false); + } + + private static void DrawShaderImportButton(ShpkTab tab, string objectName, Shader[] shaders, int idx) + { + if (!ImUtf8.Button("Replace Shader Program Blob"u8)) + return; + + tab.FileDialog.OpenFilePicker($"Replace {objectName} #{idx} Program Blob...", "Shader Program Blobs{.o,.cso,.dxbc,.dxil}", + (success, name) => + { + if (!success) + return; + + try + { + shaders[idx].Blob = File.ReadAllBytes(name[0]); + } + catch (Exception e) + { + Penumbra.Messager.NotificationMessage(e, $"Could not import {name}.", NotificationType.Error, false); + return; + } + + try + { + shaders[idx].UpdateResources(tab.Shpk); + tab.Shpk.UpdateResources(); + tab.UpdateFilteredUsed(); + } + catch (Exception e) + { + tab.Shpk.SetInvalid(); + Penumbra.Messager.NotificationMessage(e, $"Failed to update resources after importing {name}.", NotificationType.Error, + false); + return; + } + + tab.Shpk.SetChanged(); + }, 1, null, false); + } + + private static unsafe void DrawRawDisassembly(Shader shader) + { + using var tree = ImUtf8.TreeNode("Raw Program Disassembly"u8); + if (!tree) + return; + + using var font = ImRaii.PushFont(UiBuilder.MonoFont); + var size = new Vector2(ImGui.GetContentRegionAvail().X, ImGui.GetTextLineHeight() * 20); + ImGuiNative.InputTextMultiline(DisassemblyLabel.Path, shader.Disassembly!.RawDisassembly.Path, + (uint)shader.Disassembly!.RawDisassembly.Length + 1, size, + ImGuiInputTextFlags.ReadOnly, null, null); + } + + private static void DrawShaderUsage(ShpkTab tab, Shader shader) + { + using (var node = ImUtf8.TreeNode("Used with Shader Keys"u8)) + { + if (node) + { + foreach (var (key, keyIdx) in shader.SystemValues!.WithIndex()) + { + ImUtf8.TreeNode( + $"Used with System Key {tab.TryResolveName(tab.Shpk.SystemKeys[keyIdx].Id)} \u2208 {{ {tab.NameSetToString(key)} }}", + ImGuiTreeNodeFlags.Leaf | ImGuiTreeNodeFlags.Bullet).Dispose(); + } + + foreach (var (key, keyIdx) in shader.SceneValues!.WithIndex()) + { + ImUtf8.TreeNode( + $"Used with Scene Key {tab.TryResolveName(tab.Shpk.SceneKeys[keyIdx].Id)} \u2208 {{ {tab.NameSetToString(key)} }}", + ImGuiTreeNodeFlags.Leaf | ImGuiTreeNodeFlags.Bullet).Dispose(); + } + + foreach (var (key, keyIdx) in shader.MaterialValues!.WithIndex()) + { + ImUtf8.TreeNode( + $"Used with Material Key {tab.TryResolveName(tab.Shpk.MaterialKeys[keyIdx].Id)} \u2208 {{ {tab.NameSetToString(key)} }}", + ImGuiTreeNodeFlags.Leaf | ImGuiTreeNodeFlags.Bullet).Dispose(); + } + + foreach (var (key, keyIdx) in shader.SubViewValues!.WithIndex()) + { + ImUtf8.TreeNode($"Used with Sub-View Key #{keyIdx} \u2208 {{ {tab.NameSetToString(key)} }}", + ImGuiTreeNodeFlags.Leaf | ImGuiTreeNodeFlags.Bullet).Dispose(); + } + } + } + + ImUtf8.TreeNode($"Used in Passes: {tab.NameSetToString(shader.Passes)}", ImGuiTreeNodeFlags.Leaf | ImGuiTreeNodeFlags.Bullet).Dispose(); + } + + private static void DrawShaderPackageFilterSection(ShpkTab tab) + { + if (!ImUtf8.CollapsingHeader(tab.FilterPopCount == tab.FilterMaximumPopCount ? "Filters###Filters"u8 : "Filters (ACTIVE)###Filters"u8)) + return; + + foreach (var (key, keyIdx) in tab.Shpk.SystemKeys.WithIndex()) + DrawShaderPackageFilterSet(tab, $"System Key {tab.TryResolveName(key.Id)}", ref tab.FilterSystemValues[keyIdx]); + + foreach (var (key, keyIdx) in tab.Shpk.SceneKeys.WithIndex()) + DrawShaderPackageFilterSet(tab, $"Scene Key {tab.TryResolveName(key.Id)}", ref tab.FilterSceneValues[keyIdx]); + + foreach (var (key, keyIdx) in tab.Shpk.MaterialKeys.WithIndex()) + DrawShaderPackageFilterSet(tab, $"Material Key {tab.TryResolveName(key.Id)}", ref tab.FilterMaterialValues[keyIdx]); + + foreach (var (_, keyIdx) in tab.Shpk.SubViewKeys.WithIndex()) + DrawShaderPackageFilterSet(tab, $"Sub-View Key #{keyIdx}", ref tab.FilterSubViewValues[keyIdx]); + + DrawShaderPackageFilterSet(tab, "Passes", ref tab.FilterPasses); + } + + private static void DrawShaderPackageFilterSet(ShpkTab tab, string label, ref SharedSet values) + { + if (values.PossibleValues == null) + { + ImUtf8.TreeNode(label, ImGuiTreeNodeFlags.Leaf | ImGuiTreeNodeFlags.Bullet).Dispose(); + return; + } + + using var node = ImUtf8.TreeNode(label); + if (!node) + return; + + foreach (var value in values.PossibleValues) + { + var contains = values.Contains(value); + if (!ImUtf8.Checkbox($"{tab.TryResolveName(value)}", ref contains)) + continue; + + if (contains) + { + if (values.AddExisting(value)) + { + ++tab.FilterPopCount; + tab.UpdateFilteredUsed(); + } + } + else + { + if (values.Remove(value)) + { + --tab.FilterPopCount; + tab.UpdateFilteredUsed(); + } + } + } + } + + private static bool DrawShaderPackageShaderArray(ShpkTab tab, string objectName, Shader[] shaders, bool disabled) + { + if (shaders.Length == 0 || !ImUtf8.CollapsingHeader($"{objectName}s")) + return false; + + var ret = false; + for (var idx = 0; idx < shaders.Length; ++idx) + { + var shader = shaders[idx]; + if (!tab.IsFilterMatch(shader)) + continue; + + using var t = ImUtf8.TreeNode($"{objectName} #{idx}"); + if (!t) + continue; + + DrawShaderExportButton(tab, objectName, shader, idx); + if (!disabled && tab.Shpk.Disassembled) + { + ImGui.SameLine(); + DrawShaderImportButton(tab, objectName, shaders, idx); + } + + ret |= DrawShaderPackageResourceArray("Constant Buffers", "slot", true, shader.Constants, false, true); + ret |= DrawShaderPackageResourceArray("Samplers", "slot", false, shader.Samplers, false, true); + if (!tab.Shpk.IsLegacy) + ret |= DrawShaderPackageResourceArray("Textures", "slot", false, shader.Textures, false, true); + ret |= DrawShaderPackageResourceArray("Unordered Access Views", "slot", true, shader.Uavs, false, true); + + if (shader.DeclaredInputs != 0) + ImUtf8.TreeNode($"Declared Inputs: {shader.DeclaredInputs}", ImGuiTreeNodeFlags.Leaf | ImGuiTreeNodeFlags.Bullet).Dispose(); + if (shader.UsedInputs != 0) + ImUtf8.TreeNode($"Used Inputs: {shader.UsedInputs}", ImGuiTreeNodeFlags.Leaf | ImGuiTreeNodeFlags.Bullet).Dispose(); + + if (shader.AdditionalHeader.Length > 8) + { + using var t2 = ImUtf8.TreeNode($"Additional Header (Size: {shader.AdditionalHeader.Length})###AdditionalHeader"); + if (t2) + Widget.DrawHexViewer(shader.AdditionalHeader); + } + + if (tab.Shpk.Disassembled) + DrawRawDisassembly(shader); + + DrawShaderUsage(tab, shader); + } + + return ret; + } + + private static bool DrawShaderPackageResource(string slotLabel, bool withSize, ref Resource resource, bool hasFilter, bool disabled) + { + var ret = false; + if (!disabled) + { + ImGui.SetNextItemWidth(UiHelpers.Scale * 150.0f); + if (ImGuiUtil.InputUInt16($"{char.ToUpper(slotLabel[0])}{slotLabel[1..].ToLower()}", ref resource.Slot, ImGuiInputTextFlags.None)) + ret = true; + } + + if (resource.Used == null) + return ret; + + var usedString = UsedComponentString(withSize, false, resource); + if (usedString.Length > 0) + { + ImUtf8.TreeNode(hasFilter ? $"Globally Used: {usedString}" : $"Used: {usedString}", + ImGuiTreeNodeFlags.Leaf | ImGuiTreeNodeFlags.Bullet).Dispose(); + if (hasFilter) + { + var filteredUsedString = UsedComponentString(withSize, true, resource); + if (filteredUsedString.Length > 0) + ImUtf8.TreeNode($"Used within Filters: {filteredUsedString}", ImGuiTreeNodeFlags.Leaf | ImGuiTreeNodeFlags.Bullet) + .Dispose(); + else + ImUtf8.TreeNode("Unused within Filters"u8, ImGuiTreeNodeFlags.Leaf | ImGuiTreeNodeFlags.Bullet).Dispose(); + } + } + else + { + ImUtf8.TreeNode(hasFilter ? "Globally Unused"u8 : "Unused"u8, ImGuiTreeNodeFlags.Leaf | ImGuiTreeNodeFlags.Bullet).Dispose(); + } + + return ret; + } + + private static bool DrawShaderPackageResourceArray(string arrayName, string slotLabel, bool withSize, Resource[] resources, bool hasFilter, + bool disabled) + { + if (resources.Length == 0) + return false; + + using var t = ImRaii.TreeNode(arrayName); + if (!t) + return false; + + var ret = false; + for (var idx = 0; idx < resources.Length; ++idx) + { + ref var buf = ref resources[idx]; + var name = $"#{idx}: {buf.Name} (ID: 0x{buf.Id:X8}), {slotLabel}: {buf.Slot}" + + (withSize ? $", size: {buf.Size} registers###{idx}: {buf.Name} (ID: 0x{buf.Id:X8})" : string.Empty); + using var font = ImRaii.PushFont(UiBuilder.MonoFont); + using var t2 = ImUtf8.TreeNode(name, !disabled || buf.Used != null ? 0 : ImGuiTreeNodeFlags.Leaf | ImGuiTreeNodeFlags.Bullet); + font.Pop(); + if (t2) + ret |= DrawShaderPackageResource(slotLabel, withSize, ref buf, hasFilter, disabled); + } + + return ret; + } + + private static bool DrawMaterialParamLayoutHeader(string label) + { + using var font = ImRaii.PushFont(UiBuilder.MonoFont); + var pos = ImGui.GetCursorScreenPos() + + new Vector2(ImGui.CalcTextSize(label).X + 3 * ImGui.GetStyle().ItemInnerSpacing.X + ImGui.GetFrameHeight(), + ImGui.GetStyle().FramePadding.Y); + + var ret = ImUtf8.CollapsingHeader(label); + ImGui.GetWindowDrawList() + .AddText(UiBuilder.DefaultFont, UiBuilder.DefaultFont.FontSize, pos, ImGui.GetColorU32(ImGuiCol.Text), "Layout"); + return ret; + } + + private static bool DrawMaterialParamLayoutBufferSize(ShpkFile file, Resource? materialParams) + { + var isSizeWellDefined = (file.MaterialParamsSize & 0xF) == 0 + && (!materialParams.HasValue || file.MaterialParamsSize == materialParams.Value.Size << 4); + if (isSizeWellDefined) + return true; + + ImUtf8.Text(materialParams.HasValue + ? $"Buffer size mismatch: {file.MaterialParamsSize} bytes ≠ {materialParams.Value.Size} registers ({materialParams.Value.Size << 4} bytes)" + : $"Buffer size mismatch: {file.MaterialParamsSize} bytes, not a multiple of 16"); + return false; + } + + private static bool DrawShaderPackageMaterialMatrix(ShpkTab tab, bool disabled) + { + ImUtf8.Text(tab.Shpk.Disassembled + ? "Parameter positions (continuations are grayed out, globally unused values are red, unused values within filters are yellow):" + : "Parameter positions (continuations are grayed out):"); + + using var table = ImRaii.Table("##MaterialParamLayout", 5, + ImGuiTableFlags.SizingFixedFit | ImGuiTableFlags.RowBg); + if (!table) + return false; + + ImGui.TableSetupColumn(string.Empty, ImGuiTableColumnFlags.WidthFixed, 40 * UiHelpers.Scale); + ImGui.TableSetupColumn("x", ImGuiTableColumnFlags.WidthFixed, 250 * UiHelpers.Scale); + ImGui.TableSetupColumn("y", ImGuiTableColumnFlags.WidthFixed, 250 * UiHelpers.Scale); + ImGui.TableSetupColumn("z", ImGuiTableColumnFlags.WidthFixed, 250 * UiHelpers.Scale); + ImGui.TableSetupColumn("w", ImGuiTableColumnFlags.WidthFixed, 250 * UiHelpers.Scale); + ImGui.TableHeadersRow(); + + var textColorStart = ImGui.GetColorU32(ImGuiCol.Text); + + var ret = false; + for (var i = 0; i < tab.Matrix.GetLength(0); ++i) + { + ImGui.TableNextColumn(); + ImGui.TableHeader($" [{i}]"); + for (var j = 0; j < 4; ++j) + { + var (name, tooltip, idx, colorType) = tab.Matrix[i, j]; + var color = textColorStart; + if (!colorType.HasFlag(ShpkTab.ColorType.Used)) + color = ImGuiUtil.HalfBlend(color, 0x80u); // Half red + else if (!colorType.HasFlag(ShpkTab.ColorType.FilteredUsed)) + color = ImGuiUtil.HalfBlend(color, 0x8080u); // Half yellow + if (colorType.HasFlag(ShpkTab.ColorType.Continuation)) + color = ImGuiUtil.HalfTransparent(color); // Half opacity + using var _ = ImRaii.PushId(i * 4 + j); + var deletable = !disabled && idx >= 0; + using (ImRaii.PushFont(UiBuilder.MonoFont, tooltip.Length > 0)) + { + using (ImRaii.PushColor(ImGuiCol.Text, color)) + { + ImGui.TableNextColumn(); + ImUtf8.Selectable(name); + if (deletable && ImGui.IsItemClicked(ImGuiMouseButton.Right) && ImGui.GetIO().KeyCtrl) + { + tab.Shpk.MaterialParams = tab.Shpk.MaterialParams.RemoveItems(idx); + ret = true; + tab.Update(); + } + } + + ImUtf8.HoverTooltip(tooltip); + } + + if (deletable) + ImUtf8.HoverTooltip("\nControl + Right-Click to remove."u8); + } + } + + return ret; + } + + private static void DrawShaderPackageMaterialDevkitExport(ShpkTab tab) + { + if (!ImUtf8.Button("Export globally unused parameters as material dev-kit file"u8)) + return; + + tab.FileDialog.OpenSavePicker("Export material dev-kit file", ".json", $"{Path.GetFileNameWithoutExtension(tab.FilePath)}.json", + ".json", DoSave, null, false); + return; + + void DoSave(bool success, string path) + { + if (!success) + return; + + try + { + File.WriteAllText(path, tab.ExportDevkit().ToString()); + } + catch (Exception e) + { + Penumbra.Messager.NotificationMessage(e, $"Could not export dev-kit for {Path.GetFileName(tab.FilePath)} to {path}.", + NotificationType.Error, false); + return; + } + + Penumbra.Messager.NotificationMessage( + $"Material dev-kit file for {Path.GetFileName(tab.FilePath)} exported successfully to {Path.GetFileName(path)}.", + NotificationType.Success, false); + } + } + + private static void DrawShaderPackageMisalignedParameters(ShpkTab tab) + { + using var t = ImUtf8.TreeNode("Misaligned / Overflowing Parameters"u8); + if (!t) + return; + + using var _ = ImRaii.PushFont(UiBuilder.MonoFont); + foreach (var name in tab.MalformedParameters) + ImUtf8.TreeNode(name, ImGuiTreeNodeFlags.Leaf | ImGuiTreeNodeFlags.Bullet).Dispose(); + } + + private static void DrawShaderPackageStartCombo(ShpkTab tab) + { + using var s = ImRaii.PushStyle(ImGuiStyleVar.ItemSpacing, ImGui.GetStyle().ItemInnerSpacing); + using (ImRaii.PushFont(UiBuilder.MonoFont)) + { + ImGui.SetNextItemWidth(UiHelpers.Scale * 400); + using var c = ImUtf8.Combo("##Start", tab.Orphans[tab.NewMaterialParamStart].Name); + if (c) + foreach (var (start, idx) in tab.Orphans.WithIndex()) + { + if (ImGui.Selectable(start.Name, idx == tab.NewMaterialParamStart)) + tab.UpdateOrphanStart(idx); + } + } + + ImGui.SameLine(); + ImUtf8.Text("Start"u8); + } + + private static void DrawShaderPackageEndCombo(ShpkTab tab) + { + using var s = ImRaii.PushStyle(ImGuiStyleVar.ItemSpacing, ImGui.GetStyle().ItemInnerSpacing); + using (var _ = ImRaii.PushFont(UiBuilder.MonoFont)) + { + ImGui.SetNextItemWidth(UiHelpers.Scale * 400); + using var c = ImUtf8.Combo("##End", tab.Orphans[tab.NewMaterialParamEnd].Name); + if (c) + { + var current = tab.Orphans[tab.NewMaterialParamStart].Index; + for (var i = tab.NewMaterialParamStart; i < tab.Orphans.Count; ++i) + { + var next = tab.Orphans[i]; + if (current++ != next.Index) + break; + + if (ImGui.Selectable(next.Name, i == tab.NewMaterialParamEnd)) + tab.NewMaterialParamEnd = i; + } + } + } + + ImGui.SameLine(); + ImUtf8.Text("End"u8); + } + + private static bool DrawShaderPackageNewParameter(ShpkTab tab) + { + if (tab.Orphans.Count == 0) + return false; + + DrawShaderPackageStartCombo(tab); + DrawShaderPackageEndCombo(tab); + + ImGui.SetNextItemWidth(UiHelpers.Scale * 400); + var newName = tab.NewMaterialParamName.Value!; + if (ImUtf8.InputText("Name", ref newName)) + tab.NewMaterialParamName = newName; + + var tooltip = tab.UsedIds.Contains(tab.NewMaterialParamName.Crc32) + ? "The ID is already in use. Please choose a different name."u8 + : ""u8; + if (!ImUtf8.ButtonEx($"Add {tab.NewMaterialParamName} (0x{tab.NewMaterialParamName.Crc32:X8})", tooltip, + new Vector2(400 * UiHelpers.Scale, ImGui.GetFrameHeight()), tooltip.Length > 0)) + return false; + + tab.Shpk.MaterialParams = tab.Shpk.MaterialParams.AddItem(new MaterialParam + { + Id = tab.NewMaterialParamName.Crc32, + ByteOffset = (ushort)(tab.Orphans[tab.NewMaterialParamStart].Index << 2), + ByteSize = (ushort)((tab.NewMaterialParamEnd - tab.NewMaterialParamStart + 1) << 2), + }); + tab.AddNameToCache(tab.NewMaterialParamName); + tab.Update(); + return true; + } + + private static bool DrawShaderPackageMaterialParamLayout(ShpkTab tab, bool disabled) + { + var ret = false; + + var materialParams = tab.Shpk.GetConstantById(MaterialParamsConstantId); + if (!DrawMaterialParamLayoutHeader(materialParams?.Name ?? "Material Parameter")) + return false; + + var sizeWellDefined = DrawMaterialParamLayoutBufferSize(tab.Shpk, materialParams); + + ret |= DrawShaderPackageMaterialMatrix(tab, disabled); + + if (tab.MalformedParameters.Count > 0) + DrawShaderPackageMisalignedParameters(tab); + else if (!disabled && sizeWellDefined) + ret |= DrawShaderPackageNewParameter(tab); + + if (tab.Shpk.Disassembled) + DrawShaderPackageMaterialDevkitExport(tab); + + return ret; + } + + private static bool DrawShaderPackageResources(ShpkTab tab, bool disabled) + { + var ret = false; + + if (!ImUtf8.CollapsingHeader("Shader Resources"u8)) + return false; + + var hasFilters = tab.FilterPopCount != tab.FilterMaximumPopCount; + ret |= DrawShaderPackageResourceArray("Constant Buffers", "type", true, tab.Shpk.Constants, hasFilters, disabled); + ret |= DrawShaderPackageResourceArray("Samplers", "type", false, tab.Shpk.Samplers, hasFilters, disabled); + if (!tab.Shpk.IsLegacy) + ret |= DrawShaderPackageResourceArray("Textures", "type", false, tab.Shpk.Textures, hasFilters, disabled); + ret |= DrawShaderPackageResourceArray("Unordered Access Views", "type", false, tab.Shpk.Uavs, hasFilters, disabled); + + return ret; + } + + private static void DrawKeyArray(ShpkTab tab, string arrayName, bool withId, IReadOnlyCollection keys) + { + if (keys.Count == 0) + return; + + using var t = ImUtf8.TreeNode(arrayName); + if (!t) + return; + + using var font = ImRaii.PushFont(UiBuilder.MonoFont); + foreach (var (key, idx) in keys.WithIndex()) + { + using var t2 = ImUtf8.TreeNode(withId ? $"#{idx}: {tab.TryResolveName(key.Id)} (0x{key.Id:X8})" : $"#{idx}"); + if (t2) + { + ImUtf8.TreeNode($"Default Value: {tab.TryResolveName(key.DefaultValue)} (0x{key.DefaultValue:X8})", + ImGuiTreeNodeFlags.Leaf | ImGuiTreeNodeFlags.Bullet).Dispose(); + ImUtf8.TreeNode($"Known Values: {tab.NameSetToString(key.Values, true)}", ImGuiTreeNodeFlags.Leaf | ImGuiTreeNodeFlags.Bullet) + .Dispose(); + } + } + } + + private static void DrawShaderPackageNodes(ShpkTab tab) + { + if (tab.Shpk.Nodes.Length <= 0) + return; + + using var t = ImUtf8.TreeNode($"Nodes ({tab.Shpk.Nodes.Length})###Nodes"); + if (!t) + return; + + using var font = ImRaii.PushFont(UiBuilder.MonoFont); + + foreach (var (node, idx) in tab.Shpk.Nodes.WithIndex()) + { + if (!tab.IsFilterMatch(node)) + continue; + + using var t2 = ImUtf8.TreeNode($"#{idx:D4}: Selector: 0x{node.Selector:X8}"); + if (!t2) + continue; + + foreach (var (key, keyIdx) in node.SystemKeys.WithIndex()) + { + ImUtf8.TreeNode( + $"System Key {tab.TryResolveName(tab.Shpk.SystemKeys[keyIdx].Id)} = {tab.TryResolveName(key)} / \u2208 {{ {tab.NameSetToString(node.SystemValues![keyIdx])} }}", + ImGuiTreeNodeFlags.Leaf | ImGuiTreeNodeFlags.Bullet).Dispose(); + } + + foreach (var (key, keyIdx) in node.SceneKeys.WithIndex()) + { + ImUtf8.TreeNode( + $"Scene Key {tab.TryResolveName(tab.Shpk.SceneKeys[keyIdx].Id)} = {tab.TryResolveName(key)} / \u2208 {{ {tab.NameSetToString(node.SceneValues![keyIdx])} }}", + ImGuiTreeNodeFlags.Leaf | ImGuiTreeNodeFlags.Bullet).Dispose(); + } + + foreach (var (key, keyIdx) in node.MaterialKeys.WithIndex()) + { + ImUtf8.TreeNode( + $"Material Key {tab.TryResolveName(tab.Shpk.MaterialKeys[keyIdx].Id)} = {tab.TryResolveName(key)} / \u2208 {{ {tab.NameSetToString(node.MaterialValues![keyIdx])} }}", + ImGuiTreeNodeFlags.Leaf | ImGuiTreeNodeFlags.Bullet).Dispose(); + } + + foreach (var (key, keyIdx) in node.SubViewKeys.WithIndex()) + { + ImUtf8.TreeNode( + $"Sub-View Key #{keyIdx} = {tab.TryResolveName(key)} / \u2208 {{ {tab.NameSetToString(node.SubViewValues![keyIdx])} }}", + ImGuiTreeNodeFlags.Leaf | ImGuiTreeNodeFlags.Bullet).Dispose(); + } + + ImUtf8.TreeNode($"Pass Indices: {string.Join(' ', node.PassIndices.Select(c => $"{c:X2}"))}", + ImGuiTreeNodeFlags.Leaf | ImGuiTreeNodeFlags.Bullet).Dispose(); + foreach (var (pass, passIdx) in node.Passes.WithIndex()) + { + ImUtf8.TreeNode( + $"Pass #{passIdx}: ID: {tab.TryResolveName(pass.Id)}, Vertex Shader #{pass.VertexShader}, Pixel Shader #{pass.PixelShader}", + ImGuiTreeNodeFlags.Leaf | ImGuiTreeNodeFlags.Bullet) + .Dispose(); + } + } + } + + private static void DrawShaderPackageSelection(ShpkTab tab) + { + if (!ImUtf8.CollapsingHeader("Shader Selection"u8)) + return; + + DrawKeyArray(tab, "System Keys", true, tab.Shpk.SystemKeys); + DrawKeyArray(tab, "Scene Keys", true, tab.Shpk.SceneKeys); + DrawKeyArray(tab, "Material Keys", true, tab.Shpk.MaterialKeys); + DrawKeyArray(tab, "Sub-View Keys", false, tab.Shpk.SubViewKeys); + + DrawShaderPackageNodes(tab); + using var t = ImUtf8.TreeNode($"Node Selectors ({tab.Shpk.NodeSelectors.Count})###NodeSelectors"); + if (t) + { + using var font = ImRaii.PushFont(UiBuilder.MonoFont); + foreach (var selector in tab.Shpk.NodeSelectors) + { + ImUtf8.TreeNode($"#{selector.Value:D4}: Selector: 0x{selector.Key:X8}", ImGuiTreeNodeFlags.Leaf | ImGuiTreeNodeFlags.Bullet) + .Dispose(); + } + } + } + + private static void DrawOtherShaderPackageDetails(ShpkTab tab) + { + if (!ImUtf8.CollapsingHeader("Further Content"u8)) + return; + + ImUtf8.TreeNode($"Version: 0x{tab.Shpk.Version:X8}", ImGuiTreeNodeFlags.Leaf | ImGuiTreeNodeFlags.Bullet).Dispose(); + + if (tab.Shpk.AdditionalData.Length > 0) + { + using var t = ImUtf8.TreeNode($"Additional Data (Size: {tab.Shpk.AdditionalData.Length})###AdditionalData"); + if (t) + Widget.DrawHexViewer(tab.Shpk.AdditionalData); + } + } + + private static string UsedComponentString(bool withSize, bool filtered, in Resource resource) + { + var used = filtered ? resource.FilteredUsed : resource.Used; + var usedDynamically = filtered ? resource.FilteredUsedDynamically : resource.UsedDynamically; + var sb = new StringBuilder(256); + if (withSize) + { + foreach (var (components, i) in (used ?? Array.Empty()).WithIndex()) + { + switch (components) + { + case 0: break; + case DisassembledShader.VectorComponents.All: + sb.Append($"[{i}], "); + break; + default: + sb.Append($"[{i}]."); + foreach (var c in components.ToString().Where(char.IsUpper)) + sb.Append(char.ToLower(c)); + + sb.Append(", "); + break; + } + } + + switch (usedDynamically ?? 0) + { + case 0: break; + case DisassembledShader.VectorComponents.All: + sb.Append("[*], "); + break; + default: + sb.Append("[*]."); + foreach (var c in usedDynamically!.Value.ToString().Where(char.IsUpper)) + sb.Append(char.ToLower(c)); + + sb.Append(", "); + break; + } + } + else + { + var components = (used is { Length: > 0 } ? used[0] : 0) | (usedDynamically ?? 0); + if ((components & DisassembledShader.VectorComponents.X) != 0) + sb.Append("Red, "); + + if ((components & DisassembledShader.VectorComponents.Y) != 0) + sb.Append("Green, "); + + if ((components & DisassembledShader.VectorComponents.Z) != 0) + sb.Append("Blue, "); + + if ((components & DisassembledShader.VectorComponents.W) != 0) + sb.Append("Alpha, "); + } + + return sb.Length == 0 ? string.Empty : sb.ToString(0, sb.Length - 2); + } +} diff --git a/Penumbra/UI/AdvancedWindow/ModEditWindow.ShpkTab.cs b/Penumbra/UI/AdvancedWindow/ModEditWindow.ShpkTab.cs new file mode 100644 index 00000000..6c2953e0 --- /dev/null +++ b/Penumbra/UI/AdvancedWindow/ModEditWindow.ShpkTab.cs @@ -0,0 +1,446 @@ +using Dalamud.Utility; +using Newtonsoft.Json.Linq; +using OtterGui.Classes; +using OtterGui.Extensions; +using Penumbra.GameData.Files; +using Penumbra.GameData.Files.ShaderStructs; +using Penumbra.GameData.Interop; +using Penumbra.GameData.Structs; +using Penumbra.UI.AdvancedWindow.Materials; + +namespace Penumbra.UI.AdvancedWindow; + +public partial class ModEditWindow +{ + private class ShpkTab : IWritable + { + public readonly ShpkFile Shpk; + public readonly string FilePath; + + public Name NewMaterialParamName = string.Empty; + public short NewMaterialParamStart; + public short NewMaterialParamEnd; + + public readonly SharedSet[] FilterSystemValues; + public readonly SharedSet[] FilterSceneValues; + public readonly SharedSet[] FilterMaterialValues; + public readonly SharedSet[] FilterSubViewValues; + public SharedSet FilterPasses; + + public readonly int FilterMaximumPopCount; + public int FilterPopCount; + + public readonly FileDialogService FileDialog; + + public readonly string Header; + public readonly string Extension; + + public ShpkTab(FileDialogService fileDialog, byte[] bytes, string filePath) + { + FileDialog = fileDialog; + try + { + Shpk = new ShpkFile(bytes, true); + } + catch (NotImplementedException) + { + Shpk = new ShpkFile(bytes, false); + } + + FilePath = filePath; + + Header = $"Shader Package for DirectX {(int)Shpk.DirectXVersion}"; + Extension = Shpk.DirectXVersion switch + { + ShpkFile.DxVersion.DirectX9 => ".cso", + ShpkFile.DxVersion.DirectX11 => ".dxbc", + _ => throw new NotImplementedException(), + }; + + FilterSystemValues = Array.ConvertAll(Shpk.SystemKeys, key => key.Values.FullSet()); + FilterSceneValues = Array.ConvertAll(Shpk.SceneKeys, key => key.Values.FullSet()); + FilterMaterialValues = Array.ConvertAll(Shpk.MaterialKeys, key => key.Values.FullSet()); + FilterSubViewValues = Array.ConvertAll(Shpk.SubViewKeys, key => key.Values.FullSet()); + FilterPasses = Shpk.Passes.FullSet(); + + FilterMaximumPopCount = FilterPasses.Count; + foreach (var key in Shpk.SystemKeys) + FilterMaximumPopCount += key.Values.Count; + foreach (var key in Shpk.SceneKeys) + FilterMaximumPopCount += key.Values.Count; + foreach (var key in Shpk.MaterialKeys) + FilterMaximumPopCount += key.Values.Count; + foreach (var key in Shpk.SubViewKeys) + FilterMaximumPopCount += key.Values.Count; + + FilterPopCount = FilterMaximumPopCount; + + UpdateNameCache(); + Shpk.UpdateFilteredUsed(IsFilterMatch); + Update(); + } + + [Flags] + public enum ColorType : byte + { + Used = 1, + FilteredUsed = 2, + Continuation = 4, + } + + public (string Name, string Tooltip, short Index, ColorType Color)[,] Matrix = null!; + public readonly List MalformedParameters = new(); + public readonly HashSet UsedIds = new(16); + public readonly List<(string Name, short Index)> Orphans = new(16); + + private readonly Dictionary _nameCache = []; + private readonly Dictionary, string> _nameSetCache = []; + private readonly Dictionary, string> _nameSetWithIdsCache = []; + + public void AddNameToCache(Name name) + { + if (name.Value != null) + _nameCache.TryAdd(name.Crc32, name); + + _nameSetCache.Clear(); + _nameSetWithIdsCache.Clear(); + } + + private void UpdateNameCache() + { + CollectResourceNames(_nameCache, Shpk.Constants); + CollectResourceNames(_nameCache, Shpk.Samplers); + CollectResourceNames(_nameCache, Shpk.Textures); + CollectResourceNames(_nameCache, Shpk.Uavs); + + CollectKeyNames(_nameCache, Shpk.SystemKeys); + CollectKeyNames(_nameCache, Shpk.SceneKeys); + CollectKeyNames(_nameCache, Shpk.MaterialKeys); + CollectKeyNames(_nameCache, Shpk.SubViewKeys); + + _nameSetCache.Clear(); + _nameSetWithIdsCache.Clear(); + return; + + static void CollectKeyNames(Dictionary nameCache, ShpkFile.Key[] keys) + { + foreach (var key in keys) + { + var keyName = nameCache.TryResolve(Names.KnownNames, key.Id); + var valueNames = keyName.WithKnownSuffixes(); + foreach (var value in key.Values) + { + var valueName = valueNames.TryResolve(value); + if (valueName.Value != null) + nameCache.TryAdd(value, valueName); + } + } + } + + static void CollectResourceNames(Dictionary nameCache, ShpkFile.Resource[] resources) + { + foreach (var resource in resources) + nameCache.TryAdd(resource.Id, resource.Name); + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public Name TryResolveName(uint crc32) + => _nameCache.TryResolve(Names.KnownNames, crc32); + + public string NameSetToString(SharedSet nameSet, bool withIds = false) + { + var cache = withIds ? _nameSetWithIdsCache : _nameSetCache; + if (cache.TryGetValue(nameSet, out var nameSetStr)) + return nameSetStr; + + if (withIds) + nameSetStr = string.Join(", ", nameSet.Select(id => $"{TryResolveName(id)} (0x{id:X8})")); + else + nameSetStr = string.Join(", ", nameSet.Select(TryResolveName)); + cache.Add(nameSet, nameSetStr); + return nameSetStr; + } + + public void UpdateFilteredUsed() + { + Shpk.UpdateFilteredUsed(IsFilterMatch); + + var materialParams = Shpk.GetConstantById(ShpkFile.MaterialParamsConstantId); + UpdateColors(materialParams); + } + + public void Update() + { + var materialParams = Shpk.GetConstantById(ShpkFile.MaterialParamsConstantId); + var numParameters = ((Shpk.MaterialParamsSize + 0xFu) & ~0xFu) >> 4; + var defaults = Shpk.MaterialParamsDefaults != null ? (ReadOnlySpan)Shpk.MaterialParamsDefaults : []; + var defaultFloats = MemoryMarshal.Cast(defaults); + Matrix = new (string Name, string Tooltip, short Index, ColorType Color)[numParameters, 4]; + + MalformedParameters.Clear(); + UsedIds.Clear(); + foreach (var (param, idx) in Shpk.MaterialParams.WithIndex()) + { + UsedIds.Add(param.Id); + var iStart = param.ByteOffset >> 4; + var jStart = (param.ByteOffset >> 2) & 3; + var iEnd = (param.ByteOffset + param.ByteSize - 1) >> 4; + var jEnd = ((param.ByteOffset + param.ByteSize - 1) >> 2) & 3; + if ((param.ByteOffset & 0x3) != 0 || (param.ByteSize & 0x3) != 0) + { + MalformedParameters.Add( + $"ID: {TryResolveName(param.Id)} (0x{param.Id:X8}), offset: 0x{param.ByteOffset:X4}, size: 0x{param.ByteSize:X4}"); + continue; + } + + if (iEnd >= numParameters) + { + MalformedParameters.Add( + $"{MtrlTab.MaterialParamRangeName(materialParams?.Name ?? string.Empty, param.ByteOffset >> 2, param.ByteSize >> 2)} ({TryResolveName(param.Id)}, 0x{param.Id:X8})"); + continue; + } + + for (var i = iStart; i <= iEnd; ++i) + { + var end = i == iEnd ? jEnd : 3; + for (var j = i == iStart ? jStart : 0; j <= end; ++j) + { + var component = (i << 2) | j; + var tt = + $"{MtrlTab.MaterialParamRangeName(materialParams?.Name ?? string.Empty, param.ByteOffset >> 2, param.ByteSize >> 2).Item1} ({TryResolveName(param.Id)}, 0x{param.Id:X8})"; + if (component < defaultFloats.Length) + tt += + $"\n\nDefault value: {defaultFloats[component]} ({defaults[component << 2]:X2} {defaults[(component << 2) | 1]:X2} {defaults[(component << 2) | 2]:X2} {defaults[(component << 2) | 3]:X2})"; + Matrix[i, j] = (TryResolveName(param.Id).ToString(), tt, (short)idx, 0); + } + } + } + + UpdateOrphans(materialParams); + UpdateColors(materialParams); + } + + public void UpdateOrphanStart(int orphanStart) + { + var oldEnd = Orphans.Count > 0 ? Orphans[NewMaterialParamEnd].Index : -1; + UpdateOrphanStart(orphanStart, oldEnd); + } + + private void UpdateOrphanStart(int orphanStart, int oldEnd) + { + var count = Math.Min(NewMaterialParamEnd - NewMaterialParamStart + orphanStart + 1, Orphans.Count); + NewMaterialParamStart = (short)orphanStart; + var current = Orphans[NewMaterialParamStart].Index; + for (var i = NewMaterialParamStart; i < count; ++i) + { + var next = Orphans[i].Index; + if (current++ != next) + { + NewMaterialParamEnd = (short)(i - 1); + return; + } + + if (next == oldEnd) + { + NewMaterialParamEnd = i; + return; + } + } + + NewMaterialParamEnd = (short)(count - 1); + } + + private void UpdateOrphans(ShpkFile.Resource? materialParams) + { + var oldStart = Orphans.Count > 0 ? Orphans[NewMaterialParamStart].Index : -1; + var oldEnd = Orphans.Count > 0 ? Orphans[NewMaterialParamEnd].Index : -1; + + Orphans.Clear(); + short newMaterialParamStart = 0; + for (var i = 0; i < Matrix.GetLength(0); ++i) + { + for (var j = 0; j < 4; ++j) + { + if (!Matrix[i, j].Name.IsNullOrEmpty()) + continue; + + Matrix[i, j] = ("(none)", string.Empty, -1, 0); + var linear = (short)(4 * i + j); + if (oldStart == linear) + newMaterialParamStart = (short)Orphans.Count; + + Orphans.Add(($"{materialParams?.Name ?? ShpkFile.MaterialParamsConstantName}{MtrlTab.MaterialParamName(false, linear)}", + linear)); + } + } + + if (Orphans.Count == 0) + return; + + UpdateOrphanStart(newMaterialParamStart, oldEnd); + } + + private void UpdateColors(ShpkFile.Resource? materialParams) + { + var lastIndex = -1; + for (var i = 0; i < Matrix.GetLength(0); ++i) + { + var usedComponents = (materialParams?.Used?[i] ?? DisassembledShader.VectorComponents.All) + | (materialParams?.UsedDynamically ?? 0); + var filteredUsedComponents = (materialParams?.FilteredUsed?[i] ?? DisassembledShader.VectorComponents.All) + | (materialParams?.FilteredUsedDynamically ?? 0); + for (var j = 0; j < 4; ++j) + { + ColorType color = 0; + if (((byte)usedComponents & (1 << j)) != 0) + color |= ColorType.Used; + if (((byte)filteredUsedComponents & (1 << j)) != 0) + color |= ColorType.FilteredUsed; + if (Matrix[i, j].Index == lastIndex || Matrix[i, j].Index < 0) + color |= ColorType.Continuation; + + lastIndex = Matrix[i, j].Index; + Matrix[i, j].Color = color; + } + } + } + + public bool IsFilterMatch(ShpkFile.Shader shader) + { + if (!FilterPasses.Overlaps(shader.Passes)) + return false; + + for (var i = 0; i < shader.SystemValues!.Length; ++i) + { + if (!FilterSystemValues[i].Overlaps(shader.SystemValues[i])) + return false; + } + + for (var i = 0; i < shader.SceneValues!.Length; ++i) + { + if (!FilterSceneValues[i].Overlaps(shader.SceneValues[i])) + return false; + } + + for (var i = 0; i < shader.MaterialValues!.Length; ++i) + { + if (!FilterMaterialValues[i].Overlaps(shader.MaterialValues[i])) + return false; + } + + for (var i = 0; i < shader.SubViewValues!.Length; ++i) + { + if (!FilterSubViewValues[i].Overlaps(shader.SubViewValues[i])) + return false; + } + + return true; + } + + public bool IsFilterMatch(ShpkFile.Node node) + { + if (!node.Passes.Any(pass => FilterPasses.Contains(pass.Id))) + return false; + + for (var i = 0; i < node.SystemValues!.Length; ++i) + { + if (!FilterSystemValues[i].Overlaps(node.SystemValues[i])) + return false; + } + + for (var i = 0; i < node.SceneValues!.Length; ++i) + { + if (!FilterSceneValues[i].Overlaps(node.SceneValues[i])) + return false; + } + + for (var i = 0; i < node.MaterialValues!.Length; ++i) + { + if (!FilterMaterialValues[i].Overlaps(node.MaterialValues[i])) + return false; + } + + for (var i = 0; i < node.SubViewValues!.Length; ++i) + { + if (!FilterSubViewValues[i].Overlaps(node.SubViewValues[i])) + return false; + } + + return true; + } + + /// + /// Generates a minimal material dev-kit file for the given shader package. + /// + /// This file currently only hides globally unused material constants. + /// + public JObject ExportDevkit() + { + var devkit = new JObject(); + + var maybeMaterialParameter = Shpk.GetConstantById(ShpkFile.MaterialParamsConstantId); + if (maybeMaterialParameter.HasValue) + { + var materialParameter = maybeMaterialParameter.Value; + var materialParameterUsage = new IndexSet(materialParameter.Size << 2, true); + + var used = materialParameter.Used ?? []; + var usedDynamically = materialParameter.UsedDynamically ?? 0; + for (var i = 0; i < used.Length; ++i) + { + for (var j = 0; j < 4; ++j) + { + if (!(used[i] | usedDynamically).HasFlag((DisassembledShader.VectorComponents)(1 << j))) + materialParameterUsage[(i << 2) | j] = false; + } + } + + var dkConstants = new JObject(); + foreach (var param in Shpk.MaterialParams) + { + // Don't handle misaligned parameters. + if ((param.ByteOffset & 0x3) != 0 || (param.ByteSize & 0x3) != 0) + continue; + + var start = param.ByteOffset >> 2; + var length = param.ByteSize >> 2; + + // If the parameter is fully used, don't include it. + if (!materialParameterUsage.Indices(start, length, true).Any()) + continue; + + var unusedSlices = new JArray(); + + if (materialParameterUsage.Indices(start, length).Any()) + foreach (var (rgStart, rgEnd) in materialParameterUsage.Ranges(start, length, true)) + { + unusedSlices.Add(new JObject + { + ["Type"] = "Hidden", + ["Offset"] = rgStart, + ["Length"] = rgEnd - rgStart, + }); + } + else + unusedSlices.Add(new JObject + { + ["Type"] = "Hidden", + }); + + dkConstants[param.Id.ToString()] = unusedSlices; + } + + devkit["Constants"] = dkConstants; + } + + return devkit; + } + + public bool Valid + => Shpk.Valid; + + public byte[] Write() + => Shpk.Write(); + } +} diff --git a/Penumbra/UI/AdvancedWindow/ModEditWindow.Textures.cs b/Penumbra/UI/AdvancedWindow/ModEditWindow.Textures.cs new file mode 100644 index 00000000..34e1e0d4 --- /dev/null +++ b/Penumbra/UI/AdvancedWindow/ModEditWindow.Textures.cs @@ -0,0 +1,355 @@ +using Dalamud.Bindings.ImGui; +using OtterGui; +using OtterGui.Extensions; +using OtterGui.Raii; +using OtterTex; +using Penumbra.Import.Textures; +using Penumbra.Mods; +using Penumbra.UI.Classes; + +namespace Penumbra.UI.AdvancedWindow; + +public partial class ModEditWindow +{ + private readonly TextureManager _textures; + + private readonly Texture _left = new(); + private readonly Texture _right = new(); + private readonly CombinedTexture _center; + private readonly TextureDrawer.PathSelectCombo _textureSelectCombo; + + private bool _overlayCollapsed = true; + private bool _addMipMaps = true; + private int _currentSaveAs; + + private static readonly (string, string)[] SaveAsStrings = + { + ("As Is", "Save the current texture with its own format without additional conversion or compression, if possible."), + ("RGBA (Uncompressed)", + "Save the current texture as an uncompressed BGRA bitmap.\nThis requires the most space but technically offers the best quality."), + ("BC1 (Simple Compression for Opaque RGB)", + "Save the current texture compressed via BC1/DXT1 compression.\nThis offers a 8:1 compression ratio and is quick with acceptable quality, but only supports RGB, without Alpha.\n\nCan be used for diffuse maps and equipment textures to save extra space."), + ("BC3 (Simple Compression for RGBA)", + "Save the current texture compressed via BC3/DXT5 compression.\nThis offers a 4:1 compression ratio and is quick with acceptable quality, and fully supports RGBA.\n\nGeneric format that can be used for most textures."), + ("BC4 (Simple Compression for Opaque Grayscale)", + "Save the current texture compressed via BC4 compression.\nThis offers a 8:1 compression ratio and has almost indistinguishable quality, but only supports Grayscale, without Alpha.\n\nCan be used for face paints and legacy marks."), + ("BC5 (Simple Compression for Opaque RG)", + "Save the current texture compressed via BC5 compression.\nThis offers a 4:1 compression ratio and has almost indistinguishable quality, but only supports RG, without B or Alpha.\n\nRecommended for index maps, unrecommended for normal maps."), + ("BC7 (Complex Compression for RGBA)", + "Save the current texture compressed via BC7 compression.\nThis offers a 4:1 compression ratio and has almost indistinguishable quality, but may take a while.\n\nGeneric format that can be used for most textures."), + }; + + private void DrawInputChild(string label, Texture tex, Vector2 size, Vector2 imageSize) + { + using (var child = ImRaii.Child(label, size, true)) + { + if (!child) + return; + + using var id = ImRaii.PushId(label); + ImGuiUtil.DrawTextButton(label, new Vector2(-1, 0), ImGui.GetColorU32(ImGuiCol.FrameBg)); + ImGui.NewLine(); + + using (var disabled = ImRaii.Disabled(!_center.SaveTask.IsCompleted)) + { + TextureDrawer.PathInputBox(_textures, tex, ref tex.TmpPath, "##input", "Import Image...", + "Can import game paths as well as your own files.", Mod!.ModPath.FullName, _fileDialog, _config.DefaultModImportPath); + if (_textureSelectCombo.Draw("##combo", + "Select the textures included in this mod on your drive or the ones they replace from the game files.", tex.Path, + Mod.ModPath.FullName.Length + 1, out var newPath) + && newPath != tex.Path) + tex.Load(_textures, newPath); + + if (tex == _left) + _center.DrawMatrixInputLeft(size.X); + else + _center.DrawMatrixInputRight(size.X); + } + + ImGui.NewLine(); + using var child2 = ImRaii.Child("image"); + if (child2) + TextureDrawer.Draw(tex, imageSize); + } + + if (_dragDropManager.CreateImGuiTarget("TextureDragDrop", out var files, out _) && GetFirstTexture(files, out var file)) + tex.Load(_textures, file); + } + + private void SaveAsCombo() + { + var (text, desc) = SaveAsStrings[_currentSaveAs]; + ImGui.SetNextItemWidth(-ImGui.GetFrameHeight() - ImGui.GetStyle().ItemSpacing.X); + using var combo = ImRaii.Combo("##format", text); + ImGuiUtil.HoverTooltip(desc); + if (!combo) + return; + + foreach (var ((newText, newDesc), idx) in SaveAsStrings.WithIndex()) + { + if (ImGui.Selectable(newText, idx == _currentSaveAs)) + _currentSaveAs = idx; + + ImGuiUtil.SelectableHelpMarker(newDesc); + } + } + + private void RedrawOnSaveBox() + { + var redraw = _config.Ephemeral.ForceRedrawOnFileChange; + if (ImGui.Checkbox("Redraw on Save", ref redraw)) + { + _config.Ephemeral.ForceRedrawOnFileChange = redraw; + _config.Ephemeral.Save(); + } + + ImGuiUtil.HoverTooltip("Force a redraw of your player character whenever you save a file here."); + } + + private void MipMapInput() + { + ImGui.Checkbox("##mipMaps", ref _addMipMaps); + ImGuiUtil.HoverTooltip( + "Add the appropriate number of MipMaps to the file."); + } + + private bool _forceTextureStartPath = true; + + private void DrawOutputChild(Vector2 size, Vector2 imageSize) + { + using var child = ImRaii.Child("Output", size, true); + if (!child) + return; + + if (_center.IsLoaded) + { + RedrawOnSaveBox(); + ImGui.SameLine(); + SaveAsCombo(); + ImGui.SameLine(); + MipMapInput(); + + var canSaveInPlace = Path.IsPathRooted(_left.Path) && _left.Type is TextureType.Tex or TextureType.Dds or TextureType.Png; + var isActive = _config.DeleteModModifier.IsActive(); + var tt = isActive + ? "This saves the texture in place. This is not revertible." + : $"This saves the texture in place. This is not revertible. Hold {_config.DeleteModModifier} to save."; + + var buttonSize2 = new Vector2((ImGui.GetContentRegionAvail().X - ImGui.GetStyle().ItemSpacing.X) / 2, 0); + var buttonSize3 = new Vector2((ImGui.GetContentRegionAvail().X - ImGui.GetStyle().ItemSpacing.X * 2) / 3, 0); + if (ImGuiUtil.DrawDisabledButton("Save in place", buttonSize2, + tt, !isActive || !canSaveInPlace || _center.IsLeftCopy && _currentSaveAs == (int)CombinedTexture.TextureSaveType.AsIs)) + { + _center.SaveAs(_left.Type, _textures, _left.Path, (CombinedTexture.TextureSaveType)_currentSaveAs, _addMipMaps); + AddChangeTask(_left.Path); + AddReloadTask(_left.Path, false); + } + + ImGui.SameLine(); + if (ImGui.Button("Save as TEX", buttonSize2)) + OpenSaveAsDialog(".tex"); + + if (ImGui.Button("Export as TGA", buttonSize3)) + OpenSaveAsDialog(".tga"); + ImGui.SameLine(); + if (ImGui.Button("Export as PNG", buttonSize3)) + OpenSaveAsDialog(".png"); + ImGui.SameLine(); + if (ImGui.Button("Export as DDS", buttonSize3)) + OpenSaveAsDialog(".dds"); + ImGui.NewLine(); + + var canConvertInPlace = canSaveInPlace && _left.Type is TextureType.Tex && _center.IsLeftCopy; + + if (ImGuiUtil.DrawDisabledButton("Convert to BC7", buttonSize3, + "This converts the texture to BC7 format in place. This is not revertible.", + !canConvertInPlace || _left.Format is DXGIFormat.BC7Typeless or DXGIFormat.BC7UNorm or DXGIFormat.BC7UNormSRGB)) + { + _center.SaveAsTex(_textures, _left.Path, CombinedTexture.TextureSaveType.BC7, _left.MipMaps > 1); + AddChangeTask(_left.Path); + AddReloadTask(_left.Path, false); + } + + ImGui.SameLine(); + if (ImGuiUtil.DrawDisabledButton("Convert to BC3", buttonSize3, + "This converts the texture to BC3 format in place. This is not revertible.", + !canConvertInPlace || _left.Format is DXGIFormat.BC3Typeless or DXGIFormat.BC3UNorm or DXGIFormat.BC3UNormSRGB)) + { + _center.SaveAsTex(_textures, _left.Path, CombinedTexture.TextureSaveType.BC3, _left.MipMaps > 1); + AddChangeTask(_left.Path); + AddReloadTask(_left.Path, false); + } + + ImGui.SameLine(); + if (ImGuiUtil.DrawDisabledButton("Convert to RGBA", buttonSize3, + "This converts the texture to RGBA format in place. This is not revertible.", + !canConvertInPlace + || _left.Format is DXGIFormat.B8G8R8A8UNorm or DXGIFormat.B8G8R8A8Typeless or DXGIFormat.B8G8R8A8UNormSRGB)) + { + _center.SaveAsTex(_textures, _left.Path, CombinedTexture.TextureSaveType.Bitmap, _left.MipMaps > 1); + AddChangeTask(_left.Path); + AddReloadTask(_left.Path, false); + } + } + + switch (_center.SaveTask.Status) + { + case TaskStatus.WaitingForActivation: + case TaskStatus.WaitingToRun: + case TaskStatus.Running: + ImGuiUtil.DrawTextButton("Computing...", -Vector2.UnitX, Colors.PressEnterWarningBg); + + break; + case TaskStatus.Canceled: + case TaskStatus.Faulted: + { + ImGui.TextUnformatted("Could not save file:"); + using var color = ImRaii.PushColor(ImGuiCol.Text, 0xFF0000FF); + ImGuiUtil.TextWrapped(_center.SaveTask.Exception?.ToString() ?? "Unknown Error"); + break; + } + default: + ImGui.Dummy(new Vector2(1, ImGui.GetFrameHeight())); + break; + } + + ImGui.NewLine(); + + using var child2 = ImRaii.Child("image"); + if (child2) + _center.Draw(_textures, imageSize); + } + + private void InvokeChange(Mod? mod, string path) + { + if (mod == null) + return; + + if (!_editor.Files.Tex.FindFirst(r => string.Equals(r.File.FullName, path, StringComparison.OrdinalIgnoreCase), + out var registry)) + return; + + _communicator.ModFileChanged.Invoke(mod, registry); + } + + private void OpenSaveAsDialog(string defaultExtension) + { + var fileName = Path.GetFileNameWithoutExtension(_left.Path.Length > 0 ? _left.Path : _right.Path); + _fileDialog.OpenSavePicker("Save Texture as TEX, DDS, PNG or TGA...", "Textures{.png,.dds,.tex,.tga},.tex,.dds,.png,.tga", fileName, + defaultExtension, + (a, b) => + { + if (a) + { + _center.SaveAs(null, _textures, b, (CombinedTexture.TextureSaveType)_currentSaveAs, _addMipMaps); + AddChangeTask(b); + if (b == _left.Path) + AddReloadTask(_left.Path, false); + else if (b == _right.Path) + AddReloadTask(_right.Path, true); + } + }, Mod!.ModPath.FullName, _forceTextureStartPath); + _forceTextureStartPath = false; + } + + private void AddChangeTask(string path) + { + _center.SaveTask.ContinueWith(t => + { + if (!t.IsCompletedSuccessfully) + return; + + _framework.RunOnFrameworkThread(() => InvokeChange(Mod, path)); + }, TaskScheduler.Default); + } + + private void AddReloadTask(string path, bool right) + { + _center.SaveTask.ContinueWith(t => + { + if (!t.IsCompletedSuccessfully) + return; + + var tex = right ? _right : _left; + + if (tex.Path != path) + return; + + _framework.RunOnFrameworkThread(() => tex.Reload(_textures)); + }, TaskScheduler.Default); + } + + private Vector2 GetChildWidth() + { + var windowWidth = ImGui.GetWindowContentRegionMax().X - ImGui.GetWindowContentRegionMin().X - ImGui.GetTextLineHeight(); + if (_overlayCollapsed) + { + var width = windowWidth - ImGui.GetStyle().FramePadding.X * 3; + return new Vector2(width / 2, -1); + } + + return new Vector2((windowWidth - ImGui.GetStyle().FramePadding.X * 5) / 3, -1); + } + + private void DrawTextureTab() + { + using var tab = ImRaii.TabItem("Textures"); + if (!tab) + return; + + try + { + _dragDropManager.CreateImGuiSource("TextureDragDrop", + m => m.Extensions.Any(e => ValidTextureExtensions.Contains(e.ToLowerInvariant())), m => + { + if (!GetFirstTexture(m.Files, out var file)) + return false; + + ImGui.TextUnformatted($"Dragging texture for editing: {Path.GetFileName(file)}"); + return true; + }); + var childWidth = GetChildWidth(); + var imageSize = new Vector2(childWidth.X - ImGui.GetStyle().FramePadding.X * 2); + DrawInputChild("Input Texture", _left, childWidth, imageSize); + ImGui.SameLine(); + DrawOutputChild(childWidth, imageSize); + if (!_overlayCollapsed) + { + ImGui.SameLine(); + DrawInputChild("Overlay Texture", _right, childWidth, imageSize); + } + + ImGui.SameLine(); + DrawOverlayCollapseButton(); + } + catch (Exception e) + { + Penumbra.Log.Error($"Unknown Error while drawing textures:\n{e}"); + } + } + + private void DrawOverlayCollapseButton() + { + var (label, tooltip) = _overlayCollapsed + ? (">", "Show a third panel in which you can import an additional texture as an overlay for the primary texture.") + : ("<", "Hide the overlay texture panel and clear the currently loaded overlay texture, if any."); + if (ImGui.Button(label, new Vector2(ImGui.GetTextLineHeight(), ImGui.GetContentRegionAvail().Y))) + _overlayCollapsed = !_overlayCollapsed; + + ImGuiUtil.HoverTooltip(tooltip); + } + + private static bool GetFirstTexture(IEnumerable files, [NotNullWhen(true)] out string? file) + { + file = files.FirstOrDefault(f => ValidTextureExtensions.Contains(Path.GetExtension(f).ToLowerInvariant())); + return file != null; + } + + private static readonly string[] ValidTextureExtensions = + { + ".png", + ".dds", + ".tex", + ".tga", + }; +} diff --git a/Penumbra/UI/AdvancedWindow/ModEditWindow.cs b/Penumbra/UI/AdvancedWindow/ModEditWindow.cs new file mode 100644 index 00000000..5a0fb849 --- /dev/null +++ b/Penumbra/UI/AdvancedWindow/ModEditWindow.cs @@ -0,0 +1,697 @@ +using Dalamud.Interface; +using Dalamud.Interface.Components; +using Dalamud.Interface.DragDrop; +using Dalamud.Interface.Utility; +using Dalamud.Interface.Windowing; +using Dalamud.Plugin.Services; +using Dalamud.Bindings.ImGui; +using OtterGui; +using OtterGui.Extensions; +using OtterGui.Log; +using OtterGui.Raii; +using OtterGui.Services; +using OtterGui.Text; +using OtterGui.Widgets; +using Penumbra.Api.Enums; +using Penumbra.Collections.Manager; +using Penumbra.Communication; +using Penumbra.GameData.Enums; +using Penumbra.GameData.Files; +using Penumbra.Import.Models; +using Penumbra.Import.Textures; +using Penumbra.Interop.ResourceTree; +using Penumbra.Meta; +using Penumbra.Mods; +using Penumbra.Mods.Editor; +using Penumbra.Mods.Manager; +using Penumbra.Mods.SubMods; +using Penumbra.Services; +using Penumbra.String; +using Penumbra.String.Classes; +using Penumbra.UI.AdvancedWindow.Materials; +using Penumbra.UI.AdvancedWindow.Meta; +using Penumbra.UI.Classes; +using Penumbra.Util; +using MdlMaterialEditor = Penumbra.Mods.Editor.MdlMaterialEditor; + +namespace Penumbra.UI.AdvancedWindow; + +public partial class ModEditWindow : Window, IDisposable, IUiService +{ + private const string WindowBaseLabel = "###SubModEdit"; + + private readonly PerformanceTracker _performance; + private readonly ModEditor _editor; + private readonly Configuration _config; + private readonly ItemSwapTab _itemSwapTab; + private readonly MetaFileManager _metaFileManager; + private readonly ActiveCollections _activeCollections; + private readonly ModMergeTab _modMergeTab; + private readonly CommunicatorService _communicator; + private readonly IDragDropManager _dragDropManager; + private readonly IDataManager _gameData; + private readonly IFramework _framework; + private readonly OptionSelectCombo _optionSelect; + + private Vector2 _iconSize = Vector2.Zero; + private bool _allowReduplicate; + + public Mod? Mod { get; private set; } + + + public bool IsLoading + { + get + { + lock (_lock) + { + return _editor.IsLoading || _loadingMod is { IsCompleted: false }; + } + } + } + + private readonly object _lock = new(); + private Task? _loadingMod; + + + private void AppendTask(Action run) + { + lock (_lock) + { + if (_loadingMod == null || _loadingMod.IsCompleted) + _loadingMod = Task.Run(run); + else + _loadingMod = _loadingMod.ContinueWith(_ => run()); + } + } + + public void ChangeMod(Mod mod) + { + if (mod == Mod) + return; + + WindowName = $"{mod.Name} (LOADING){WindowBaseLabel}"; + AppendTask(() => + { + _editor.LoadMod(mod, -1, 0).Wait(); + Mod = mod; + + SizeConstraints = new WindowSizeConstraints + { + MinimumSize = new Vector2(1240, 600), + MaximumSize = 4000 * Vector2.One, + }; + _selectedFiles.Clear(); + _modelTab.Reset(); + _materialTab.Reset(); + _shaderPackageTab.Reset(); + _itemSwapTab.UpdateMod(mod, _activeCollections.Current.GetInheritedSettings(mod.Index).Settings); + UpdateModels(); + _forceTextureStartPath = true; + }); + } + + public void ChangeOption(IModDataContainer? subMod) + { + AppendTask(() => + { + var (groupIdx, dataIdx) = subMod?.GetDataIndices() ?? (-1, 0); + _editor.LoadOption(groupIdx, dataIdx).Wait(); + }); + } + + public void UpdateModels() + { + if (Mod != null) + _editor.MdlMaterialEditor.ScanModels(Mod); + } + + public override bool DrawConditions() + => Mod != null; + + public override void PreDraw() + { + if (IsLoading) + return; + + using var performance = _performance.Measure(PerformanceType.UiAdvancedWindow); + + var sb = new StringBuilder(256); + + var redirections = 0; + var unused = 0; + var size = _editor.Files.Available.Sum(f => + { + if (f.SubModUsage.Count > 0) + redirections += f.SubModUsage.Count; + else + ++unused; + + return f.FileSize; + }); + var manipulations = 0; + var subMods = 0; + var swaps = Mod!.AllDataContainers.Sum(m => + { + ++subMods; + manipulations += m.Manipulations.Count; + return m.FileSwaps.Count; + }); + sb.Append(Mod!.Name); + if (subMods > 1) + sb.Append($" | {subMods} Options"); + + if (size > 0) + sb.Append($" | {_editor.Files.Available.Count} Files ({Functions.HumanReadableSize(size)})"); + + if (unused > 0) + sb.Append($" | {unused} Unused Files"); + + if (_editor.Files.Missing.Count > 0) + sb.Append($" | {_editor.Files.Missing.Count} Missing Files"); + + if (redirections > 0) + sb.Append($" | {redirections} Redirections"); + + if (manipulations > 0) + sb.Append($" | {manipulations} Manipulations"); + + if (swaps > 0) + sb.Append($" | {swaps} Swaps"); + + _allowReduplicate = redirections != _editor.Files.Available.Count || _editor.Files.Missing.Count > 0 || unused > 0; + sb.Append(WindowBaseLabel); + WindowName = sb.ToString(); + } + + public override void OnClose() + { + _config.Ephemeral.AdvancedEditingOpen = false; + _config.Ephemeral.Save(); + AppendTask(() => + { + _left.Dispose(); + _right.Dispose(); + _materialTab.Reset(); + _modelTab.Reset(); + _shaderPackageTab.Reset(); + }); + } + + public override void Draw() + { + using var performance = _performance.Measure(PerformanceType.UiAdvancedWindow); + + if (!_config.Ephemeral.AdvancedEditingOpen) + { + _config.Ephemeral.AdvancedEditingOpen = true; + _config.Ephemeral.Save(); + } + + if (IsLoading) + { + var radius = 100 * ImUtf8.GlobalScale; + var thickness = (int)(20 * ImUtf8.GlobalScale); + var offsetX = ImGui.GetContentRegionAvail().X / 2 - radius; + var offsetY = ImGui.GetContentRegionAvail().Y / 2 - radius; + ImGui.SetCursorPos(ImGui.GetCursorPos() + new Vector2(offsetX, offsetY)); + ImUtf8.Spinner("##spinner"u8, radius, thickness, ImGui.GetColorU32(ImGuiCol.Text)); + return; + } + + using var tabBar = ImUtf8.TabBar("##tabs"u8); + if (!tabBar) + return; + + _iconSize = new Vector2(ImGui.GetFrameHeight()); + DrawFileTab(); + DrawMetaTab(); + DrawSwapTab(); + _modMergeTab.Draw(); + DrawDuplicatesTab(); + DrawQuickImportTab(); + _modelTab.Draw(); + _materialTab.Draw(); + DrawTextureTab(); + _shaderPackageTab.Draw(); + using (var tab = ImUtf8.TabItem("Item Swap"u8)) + { + if (tab) + _itemSwapTab.DrawContent(); + } + + _pbdTab.Draw(); + + DrawMissingFilesTab(); + DrawMaterialReassignmentTab(); + } + + /// A row of three buttonSizes and a help marker that can be used for material suffix changing. + private static class MaterialSuffix + { + private static string _materialSuffixFrom = string.Empty; + private static string _materialSuffixTo = string.Empty; + private static GenderRace _raceCode = GenderRace.Unknown; + + private static string RaceCodeName(GenderRace raceCode) + { + if (raceCode == GenderRace.Unknown) + return "All Races and Genders"; + + var (gender, race) = raceCode.Split(); + return $"({raceCode.ToRaceCode()}) {race.ToName()} {gender.ToName()} "; + } + + private static void DrawRaceCodeCombo(Vector2 buttonSize) + { + ImGui.SetNextItemWidth(buttonSize.X); + using var combo = ImRaii.Combo("##RaceCode", RaceCodeName(_raceCode)); + if (!combo) + return; + + foreach (var raceCode in Enum.GetValues()) + { + if (ImGui.Selectable(RaceCodeName(raceCode), _raceCode == raceCode)) + _raceCode = raceCode; + } + } + + public static void Draw(ModEditor editor, Vector2 buttonSize) + { + DrawRaceCodeCombo(buttonSize); + ImGui.SameLine(); + ImGui.SetNextItemWidth(buttonSize.X); + ImGui.InputTextWithHint("##suffixFrom", "From...", ref _materialSuffixFrom, 32); + ImGui.SameLine(); + ImGui.SetNextItemWidth(buttonSize.X); + ImGui.InputTextWithHint("##suffixTo", "To...", ref _materialSuffixTo, 32); + ImGui.SameLine(); + var disabled = !MdlMaterialEditor.ValidString(_materialSuffixTo); + var tt = _materialSuffixTo.Length == 0 + ? "Please enter a target suffix." + : _materialSuffixFrom == _materialSuffixTo + ? "The source and target are identical." + : disabled + ? "The suffix is invalid." + : _materialSuffixFrom.Length == 0 + ? _raceCode == GenderRace.Unknown + ? "Convert all skin material suffices to the target." + : "Convert all skin material suffices for the given race code to the target." + : _raceCode == GenderRace.Unknown + ? $"Convert all skin material suffices that are currently '{_materialSuffixFrom}' to '{_materialSuffixTo}'." + : $"Convert all skin material suffices for the given race code that are currently '{_materialSuffixFrom}' to '{_materialSuffixTo}'."; + if (ImGuiUtil.DrawDisabledButton("Change Material Suffix", buttonSize, tt, disabled)) + editor.MdlMaterialEditor.ReplaceAllMaterials(_materialSuffixTo, _materialSuffixFrom, _raceCode); + + var anyChanges = editor.MdlMaterialEditor.ModelFiles.Any(m => m.Changed); + if (ImGuiUtil.DrawDisabledButton("Save All Changes", buttonSize, + anyChanges ? "Irreversibly rewrites all currently applied changes to model files." : "No changes made yet.", !anyChanges)) + editor.MdlMaterialEditor.SaveAllModels(editor.Compactor); + + ImGui.SameLine(); + if (ImGuiUtil.DrawDisabledButton("Revert All Changes", buttonSize, + anyChanges ? "Revert all currently made and unsaved changes." : "No changes made yet.", !anyChanges)) + editor.MdlMaterialEditor.RestoreAllModels(); + + ImGui.SameLine(); + ImGuiComponents.HelpMarker( + "Model files refer to the skin material they should use. This skin material is always the same, but modders have started using different suffices to differentiate between body types.\n" + + "This option allows you to switch the suffix of all model files to another. This changes the files, so you do this on your own risk.\n" + + "If you do not know what the currently used suffix of this mod is, you can leave 'From' blank and it will replace all suffices with 'To', instead of only the matching ones."); + } + } + + private void DrawMissingFilesTab() + { + if (_editor.Files.Missing.Count == 0) + return; + + using var tab = ImRaii.TabItem("Missing Files"); + if (!tab) + return; + + ImGui.NewLine(); + if (ImGui.Button("Remove Missing Files from Mod")) + _editor.FileEditor.RemoveMissingPaths(Mod!, _editor.Option!); + + using var child = ImRaii.Child("##unusedFiles", -Vector2.One, true); + if (!child) + return; + + using var table = ImRaii.Table("##missingFiles", 1, ImGuiTableFlags.RowBg, -Vector2.One); + if (!table) + return; + + foreach (var path in _editor.Files.Missing) + { + ImGui.TableNextColumn(); + ImGui.TextUnformatted(path.FullName); + } + } + + private void DrawDuplicatesTab() + { + using var tab = ImRaii.TabItem("Duplicates"); + if (!tab) + return; + + if (_editor.Duplicates.Worker.IsCompleted) + { + if (ImGuiUtil.DrawDisabledButton("Scan for Duplicates", Vector2.Zero, + "Search for identical files in this mod. This may take a while.", false)) + _editor.Duplicates.StartDuplicateCheck(_editor.Files.Available); + } + else + { + if (ImGuiUtil.DrawDisabledButton("Cancel Scanning for Duplicates", Vector2.Zero, "Cancel the current scanning operation...", false)) + _editor.Duplicates.Clear(); + } + + const string desc = + "Tries to create a unique copy of a file for every game path manipulated and put them in [Groupname]/[Optionname]/[GamePath] order.\n" + + "This will also delete all unused files and directories if it succeeds.\n" + + "Care was taken that a failure should not destroy the mod but revert to its original state, but you use this at your own risk anyway."; + + var modifier = _config.DeleteModModifier.IsActive(); + + var tt = _allowReduplicate ? desc : + modifier ? desc : desc + $"\n\nNo duplicates detected! Hold {_config.DeleteModModifier} to force normalization anyway."; + + if (_editor.ModNormalizer.Running) + { + ImGui.ProgressBar((float)_editor.ModNormalizer.Step / _editor.ModNormalizer.TotalSteps, + new Vector2(300 * UiHelpers.Scale, ImGui.GetFrameHeight()), + $"{_editor.ModNormalizer.Step} / {_editor.ModNormalizer.TotalSteps}"); + } + else if (ImGuiUtil.DrawDisabledButton("Re-Duplicate and Normalize Mod", Vector2.Zero, tt, !_allowReduplicate && !modifier)) + { + _editor.ModNormalizer.Normalize(Mod!); + _editor.ModNormalizer.Worker.ContinueWith(_ => _editor.LoadMod(Mod!, _editor.GroupIdx, _editor.DataIdx), TaskScheduler.Default); + } + + if (!_editor.Duplicates.Worker.IsCompleted) + return; + + if (_editor.Duplicates.Duplicates.Count == 0) + { + ImGui.NewLine(); + ImGui.TextUnformatted("No duplicates found."); + return; + } + + if (ImGui.Button("Delete and Redirect Duplicates")) + _editor.Duplicates.DeleteDuplicates(_editor.Files, _editor.Mod!, _editor.Option!, true); + + if (_editor.Duplicates.SavedSpace > 0) + { + ImGui.SameLine(); + ImGui.TextUnformatted($"Frees up {Functions.HumanReadableSize(_editor.Duplicates.SavedSpace)} from your hard drive."); + } + + using var child = ImRaii.Child("##duptable", -Vector2.One, true); + if (!child) + return; + + using var table = ImRaii.Table("##duplicates", 3, ImGuiTableFlags.RowBg | ImGuiTableFlags.SizingFixedFit, -Vector2.One); + if (!table) + return; + + var width = ImGui.CalcTextSize("NNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNN ").X; + ImGui.TableSetupColumn("file", ImGuiTableColumnFlags.WidthStretch); + ImGui.TableSetupColumn("size", ImGuiTableColumnFlags.WidthFixed, ImGui.CalcTextSize("NNN.NNN ").X); + ImGui.TableSetupColumn("hash", ImGuiTableColumnFlags.WidthFixed, + ImGui.GetWindowWidth() > 2 * width ? width : ImGui.CalcTextSize("NNNNNNNN... ").X); + foreach (var (set, size, hash) in _editor.Duplicates.Duplicates.Where(s => s.Paths.Length > 1)) + { + ImGui.TableNextColumn(); + using var tree = ImRaii.TreeNode(set[0].FullName[(Mod!.ModPath.FullName.Length + 1)..], + ImGuiTreeNodeFlags.NoTreePushOnOpen); + ImGui.TableNextColumn(); + ImGuiUtil.RightAlign(Functions.HumanReadableSize(size)); + ImGui.TableNextColumn(); + using (var _ = ImRaii.PushFont(UiBuilder.MonoFont)) + { + if (ImGui.GetWindowWidth() > 2 * width) + ImGuiUtil.RightAlign(string.Concat(hash.Select(b => b.ToString("X2")))); + else + ImGuiUtil.RightAlign(string.Concat(hash.Take(4).Select(b => b.ToString("X2"))) + "..."); + } + + if (!tree) + continue; + + using var indent = ImRaii.PushIndent(); + foreach (var duplicate in set.Skip(1)) + { + ImGui.TableNextColumn(); + ImGui.TableSetBgColor(ImGuiTableBgTarget.CellBg, Colors.RedTableBgTint); + using var node = ImRaii.TreeNode(duplicate.FullName[(Mod!.ModPath.FullName.Length + 1)..], ImGuiTreeNodeFlags.Leaf); + ImGui.TableNextColumn(); + ImGui.TableSetBgColor(ImGuiTableBgTarget.CellBg, Colors.RedTableBgTint); + ImGui.TableNextColumn(); + ImGui.TableSetBgColor(ImGuiTableBgTarget.CellBg, Colors.RedTableBgTint); + } + } + } + + private bool DrawOptionSelectHeader() + { + using var style = ImRaii.PushStyle(ImGuiStyleVar.ItemSpacing, Vector2.Zero).Push(ImGuiStyleVar.FrameRounding, 0); + var width = new Vector2(ImGui.GetContentRegionAvail().X / 3, 0); + var ret = false; + if (ImUtf8.ButtonEx("Default Option"u8, "Switch to the default option for the mod.\nThis resets unsaved changes."u8, width, + _editor.Option is DefaultSubMod)) + { + _editor.LoadOption(-1, 0).Wait(); + ret = true; + } + + ImGui.SameLine(); + if (ImUtf8.ButtonEx("Refresh Data"u8, "Refresh data for the current option.\nThis resets unsaved changes."u8, width)) + { + _editor.LoadMod(_editor.Mod!, _editor.GroupIdx, _editor.DataIdx).Wait(); + ret = true; + } + + ImGui.SameLine(); + if (_optionSelect.Draw(width.X)) + { + var (groupIdx, dataIdx) = _optionSelect.CurrentSelection.Index; + _editor.LoadOption(groupIdx, dataIdx).Wait(); + ret = true; + } + + return ret; + } + + private string _newSwapKey = string.Empty; + private string _newSwapValue = string.Empty; + + private void DrawSwapTab() + { + using var tab = ImRaii.TabItem("File Swaps"); + if (!tab) + return; + + DrawOptionSelectHeader(); + + var setsEqual = !_editor.SwapEditor.Changes; + var tt = setsEqual ? "No changes staged." : "Apply the currently staged changes to the option."; + ImGui.NewLine(); + if (ImGuiUtil.DrawDisabledButton("Apply Changes", Vector2.Zero, tt, setsEqual)) + _editor.SwapEditor.Apply(_editor.Option!); + + ImGui.SameLine(); + tt = setsEqual ? "No changes staged." : "Revert all currently staged changes."; + if (ImGuiUtil.DrawDisabledButton("Revert Changes", Vector2.Zero, tt, setsEqual)) + _editor.SwapEditor.Revert(_editor.Option!); + + var otherSwaps = _editor.Mod!.TotalSwapCount - _editor.Option!.FileSwaps.Count; + if (otherSwaps > 0) + { + ImGui.SameLine(); + ImGuiUtil.DrawTextButton($"There are {otherSwaps} file swaps configured in other options.", Vector2.Zero, + ColorId.RedundantAssignment.Value()); + } + + using var child = ImRaii.Child("##swaps", -Vector2.One, true); + if (!child) + return; + + using var list = ImRaii.Table("##table", 3, ImGuiTableFlags.RowBg, -Vector2.One); + if (!list) + return; + + var idx = 0; + var iconSize = ImGui.GetFrameHeight() * Vector2.One; + var pathSize = ImGui.GetContentRegionAvail().X / 2 - iconSize.X; + ImGui.TableSetupColumn("button", ImGuiTableColumnFlags.WidthFixed, iconSize.X); + ImGui.TableSetupColumn("source", ImGuiTableColumnFlags.WidthFixed, pathSize); + ImGui.TableSetupColumn("value", ImGuiTableColumnFlags.WidthFixed, pathSize); + + foreach (var (gamePath, file) in _editor.SwapEditor.Swaps.ToList()) + { + using var id = ImRaii.PushId(idx++); + ImGui.TableNextColumn(); + if (ImGuiUtil.DrawDisabledButton(FontAwesomeIcon.Trash.ToIconString(), iconSize, "Delete this swap.", false, true)) + _editor.SwapEditor.Remove(gamePath); + + ImGui.TableNextColumn(); + var tmp = file.FullName; + ImGui.SetNextItemWidth(-1); + if (ImGui.InputText("##value", ref tmp, Utf8GamePath.MaxGamePathLength) && tmp.Length > 0) + _editor.SwapEditor.Change(gamePath, new FullPath(tmp)); + + ImGui.TableNextColumn(); + tmp = gamePath.Path.ToString(); + ImGui.SetNextItemWidth(-1); + if (ImGui.InputText("##key", ref tmp, Utf8GamePath.MaxGamePathLength) + && Utf8GamePath.FromString(tmp, out var path) + && !_editor.SwapEditor.Swaps.ContainsKey(path)) + _editor.SwapEditor.Change(gamePath, path); + } + + ImGui.TableNextColumn(); + var addable = Utf8GamePath.FromString(_newSwapKey, out var newPath) + && newPath.Length > 0 + && _newSwapValue.Length > 0 + && _newSwapValue != _newSwapKey + && !_editor.SwapEditor.Swaps.ContainsKey(newPath); + if (ImGuiUtil.DrawDisabledButton(FontAwesomeIcon.Plus.ToIconString(), iconSize, "Add a new file swap to this option.", !addable, + true)) + { + _editor.SwapEditor.Add(newPath, new FullPath(_newSwapValue)); + _newSwapKey = string.Empty; + _newSwapValue = string.Empty; + } + + ImGui.TableNextColumn(); + ImGui.SetNextItemWidth(-1); + ImGui.InputTextWithHint("##swapKey", "Load this file...", ref _newSwapValue, Utf8GamePath.MaxGamePathLength); + ImGui.TableNextColumn(); + ImGui.SetNextItemWidth(-1); + ImGui.InputTextWithHint("##swapValue", "... instead of this file.", ref _newSwapKey, Utf8GamePath.MaxGamePathLength); + } + + /// + /// Find the best matching associated file for a given path. + /// + /// + /// Tries to resolve from the current collection first and chooses the currently resolved file if any exists. + /// If none exists, goes through all options in the currently selected mod (if any) in order of priority and resolves in them. + /// If no redirection is found in either of those options, returns the original path. + /// + internal FullPath FindBestMatch(Utf8GamePath path) + { + var currentFile = _activeCollections.Current.ResolvePath(path); + if (currentFile != null) + return currentFile.Value; + + if (Mod != null) + { + foreach (var option in Mod.Groups.OrderByDescending(g => g.Priority)) + { + if (option.FindBestMatch(path) is { } fullPath) + return fullPath; + } + + if (Mod.Default.Files.TryGetValue(path, out var value) || Mod.Default.FileSwaps.TryGetValue(path, out value)) + return value; + } + + return new FullPath(path); + } + + internal HashSet FindPathsStartingWith(CiByteString prefix) + { + var ret = new HashSet(); + + foreach (var path in _activeCollections.Current.ResolvedFiles.Keys) + { + if (path.Path.StartsWith(prefix)) + ret.Add(path); + } + + if (Mod != null) + foreach (var option in Mod.AllDataContainers) + { + foreach (var path in option.Files.Keys) + { + if (path.Path.StartsWith(prefix)) + ret.Add(path); + } + } + + return ret; + } + + public ModEditWindow(PerformanceTracker performance, FileDialogService fileDialog, ItemSwapTab itemSwapTab, IDataManager gameData, + Configuration config, ModEditor editor, ResourceTreeFactory resourceTreeFactory, MetaFileManager metaFileManager, + ActiveCollections activeCollections, ModMergeTab modMergeTab, + CommunicatorService communicator, TextureManager textures, ModelManager models, IDragDropManager dragDropManager, + ResourceTreeViewerFactory resourceTreeViewerFactory, IFramework framework, + MetaDrawers metaDrawers, MigrationManager migrationManager, + MtrlTabFactory mtrlTabFactory, ModSelection selection) + : base(WindowBaseLabel) + { + _performance = performance; + _itemSwapTab = itemSwapTab; + _gameData = gameData; + _config = config; + _editor = editor; + _metaFileManager = metaFileManager; + _activeCollections = activeCollections; + _modMergeTab = modMergeTab; + _communicator = communicator; + _dragDropManager = dragDropManager; + _textures = textures; + _models = models; + _fileDialog = fileDialog; + _framework = framework; + _metaDrawers = metaDrawers; + _optionSelect = new OptionSelectCombo(editor, this); + _materialTab = new FileEditor(this, _communicator, gameData, config, _editor.Compactor, _fileDialog, "Materials", ".mtrl", + () => PopulateIsOnPlayer(_editor.Files.Mtrl, ResourceType.Mtrl), DrawMaterialPanel, () => Mod?.ModPath.FullName ?? string.Empty, + (bytes, path, writable) => mtrlTabFactory.Create(this, new MtrlFile(bytes), path, writable)); + _modelTab = new FileEditor(this, _communicator, gameData, config, _editor.Compactor, _fileDialog, "Models", ".mdl", + () => PopulateIsOnPlayer(_editor.Files.Mdl, ResourceType.Mdl), DrawModelPanel, () => Mod?.ModPath.FullName ?? string.Empty, + (bytes, path, _) => new MdlTab(this, bytes, path)); + _shaderPackageTab = new FileEditor(this, _communicator, gameData, config, _editor.Compactor, _fileDialog, "Shaders", ".shpk", + () => PopulateIsOnPlayer(_editor.Files.Shpk, ResourceType.Shpk), DrawShaderPackagePanel, + () => Mod?.ModPath.FullName ?? string.Empty, + (bytes, path, _) => new ShpkTab(_fileDialog, bytes, path)); + _pbdTab = new FileEditor(this, _communicator, gameData, config, _editor.Compactor, _fileDialog, "Deformers", ".pbd", + () => _editor.Files.Pbd, DrawDeformerPanel, + () => Mod?.ModPath.FullName ?? string.Empty, + (bytes, path, _) => new PbdTab(bytes, path)); + _center = new CombinedTexture(_left, _right); + _textureSelectCombo = new TextureDrawer.PathSelectCombo(textures, editor, () => GetPlayerResourcesOfType(ResourceType.Tex)); + _resourceTreeFactory = resourceTreeFactory; + _quickImportViewer = resourceTreeViewerFactory.Create(1, OnQuickImportRefresh, DrawQuickImportActions); + _communicator.ModPathChanged.Subscribe(OnModPathChange, ModPathChanged.Priority.ModEditWindow); + IsOpen = _config is { OpenWindowAtStart: true, Ephemeral.AdvancedEditingOpen: true }; + if (IsOpen && selection.Mod != null) + ChangeMod(selection.Mod); + } + + public void Dispose() + { + _communicator.ModPathChanged.Unsubscribe(OnModPathChange); + _editor.Dispose(); + _materialTab.Dispose(); + _modelTab.Dispose(); + _shaderPackageTab.Dispose(); + _left.Dispose(); + _right.Dispose(); + _center.Dispose(); + } + + private void OnModPathChange(ModPathChangeType type, Mod mod, DirectoryInfo? _1, DirectoryInfo? _2) + { + if (type is not (ModPathChangeType.Reloaded or ModPathChangeType.Moved) || mod != Mod) + return; + + Mod = null; + ChangeMod(mod); + } +} diff --git a/Penumbra/UI/AdvancedWindow/ModMergeTab.cs b/Penumbra/UI/AdvancedWindow/ModMergeTab.cs new file mode 100644 index 00000000..bf16fa37 --- /dev/null +++ b/Penumbra/UI/AdvancedWindow/ModMergeTab.cs @@ -0,0 +1,273 @@ +using Dalamud.Interface.Utility; +using Dalamud.Bindings.ImGui; +using OtterGui; +using OtterGui.Extensions; +using OtterGui.Raii; +using OtterGui.Services; +using OtterGui.Text; +using Penumbra.Mods.Editor; +using Penumbra.Mods.Manager; +using Penumbra.Mods.SubMods; +using Penumbra.UI.Classes; + +namespace Penumbra.UI.AdvancedWindow; + +public class ModMergeTab(ModMerger modMerger) : IUiService +{ + private readonly ModCombo _modCombo = new(() => modMerger.ModsWithoutCurrent.ToList()); + private string _newModName = string.Empty; + + public void Draw() + { + if (modMerger.MergeFromMod == null) + return; + + using var tab = ImRaii.TabItem("Merge Mods"); + if (!tab) + return; + + ImGui.Dummy(Vector2.One); + var size = 550 * ImGuiHelpers.GlobalScale; + DrawMergeInto(size); + ImGui.SameLine(); + DrawMergeIntoDesc(); + + ImGui.Dummy(Vector2.One); + ImGui.Separator(); + ImGui.Dummy(Vector2.One); + + DrawSplitOff(size); + ImGui.SameLine(); + DrawSplitOffDesc(); + + + DrawError(); + DrawWarnings(); + } + + private void DrawMergeInto(float size) + { + using var bigGroup = ImRaii.Group(); + var minComboSize = 300 * ImGuiHelpers.GlobalScale; + var textSize = ImUtf8.CalcTextSize($"Merge {modMerger.MergeFromMod!.Name} into ").X; + + ImGui.AlignTextToFramePadding(); + + using (ImRaii.Group()) + { + ImUtf8.Text("Merge "u8); + ImGui.SameLine(0, 0); + if (size - textSize < minComboSize) + { + ImUtf8.Text("selected mod"u8, ColorId.FolderLine.Value()); + ImUtf8.HoverTooltip(modMerger.MergeFromMod!.Name.Text); + } + else + { + ImUtf8.Text(modMerger.MergeFromMod!.Name.Text, ColorId.FolderLine.Value()); + } + + ImGui.SameLine(0, 0); + ImUtf8.Text(" into"u8); + } + + ImGui.SameLine(); + DrawCombo(size - ImGui.GetItemRectSize().X - ImGui.GetStyle().ItemSpacing.X); + + using (ImRaii.Group()) + { + using var disabled = ImRaii.Disabled(modMerger.MergeFromMod.HasOptions); + var buttonWidth = (size - ImGui.GetStyle().ItemSpacing.X) / 2; + using var style = ImRaii.PushStyle(ImGuiStyleVar.FrameBorderSize, 1); + var group = modMerger.MergeToMod?.Groups.FirstOrDefault(g => g.Name == modMerger.OptionGroupName); + var color = group != null || modMerger.OptionGroupName.Length == 0 && modMerger.OptionName.Length == 0 + ? Colors.PressEnterWarningBg + : Colors.DiscordColor; + using var c = ImRaii.PushColor(ImGuiCol.Border, color); + ImGui.SetNextItemWidth(buttonWidth); + ImGui.InputTextWithHint("##optionGroupInput", "Target Option Group", ref modMerger.OptionGroupName, 64); + ImGuiUtil.HoverTooltip( + "The name of the new or existing option group to find or create the option in. Leave both group and option name blank for the default option.\n" + + "A red border indicates an existing option group, a blue border indicates a new one."); + ImGui.SameLine(); + + + color = color == Colors.DiscordColor + ? Colors.DiscordColor + : group == null || group.Options.Any(o => o.Name == modMerger.OptionName) + ? Colors.PressEnterWarningBg + : Colors.DiscordColor; + c.Push(ImGuiCol.Border, color); + ImGui.SetNextItemWidth(buttonWidth); + ImGui.InputTextWithHint("##optionInput", "Target Option Name", ref modMerger.OptionName, 64); + ImGuiUtil.HoverTooltip( + "The name of the new or existing option to merge this mod into. Leave both group and option name blank for the default option.\n" + + "A red border indicates an existing option, a blue border indicates a new one."); + } + + if (modMerger.MergeFromMod.HasOptions) + ImGuiUtil.HoverTooltip("You can only specify a target option if the source mod has no true options itself.", + ImGuiHoveredFlags.AllowWhenDisabled); + + if (ImGuiUtil.DrawDisabledButton("Merge", new Vector2(size, 0), + modMerger.CanMerge ? string.Empty : "Please select a target mod different from the current mod.", !modMerger.CanMerge)) + modMerger.Merge(); + } + + private void DrawMergeIntoDesc() + { + ImGuiUtil.TextWrapped(modMerger.MergeFromMod!.HasOptions + ? "The currently selected mod has options.\n\nThis means, that all of those options will be merged into the target. If merging an option is not possible due to the redirections already existing in an existing option, it will revert all changes and break." + : "The currently selected mod has no true options.\n\nThis means that you can select an existing or new option to merge all its changes into in the target mod. On failure to merge into an existing option, all changes will be reverted."); + } + + private void DrawCombo(float width) + { + _modCombo.Draw("##ModSelection", _modCombo.CurrentSelection?.Name.Text ?? "Select the target Mod...", string.Empty, width, + ImGui.GetTextLineHeight()); + modMerger.MergeToMod = _modCombo.CurrentSelection; + } + + private void DrawSplitOff(float size) + { + using var group = ImRaii.Group(); + ImGui.SetNextItemWidth(size); + ImGui.InputTextWithHint("##newModInput", "New Mod Name...", ref _newModName, 64); + ImGuiUtil.HoverTooltip("Choose a name for the newly created mod. This does not need to be unique."); + var tt = _newModName.Length == 0 + ? "Please enter a name for the newly created mod first." + : modMerger.SelectedOptions.Count == 0 + ? "Please select at least one option to split off." + : string.Empty; + var buttonText = + $"Split Off {modMerger.SelectedOptions.Count} Option{(modMerger.SelectedOptions.Count > 1 ? "s" : string.Empty)}###SplitOff"; + if (ImGuiUtil.DrawDisabledButton(buttonText, new Vector2(size, 0), tt, tt.Length > 0)) + modMerger.SplitIntoMod(_newModName); + + ImGui.Dummy(Vector2.One); + var buttonSize = new Vector2((size - 2 * ImGui.GetStyle().ItemSpacing.X) / 3, 0); + if (ImGui.Button("Select All", buttonSize)) + modMerger.SelectedOptions.UnionWith(modMerger.MergeFromMod!.AllDataContainers); + ImGui.SameLine(); + if (ImGui.Button("Unselect All", buttonSize)) + modMerger.SelectedOptions.Clear(); + ImGui.SameLine(); + if (ImGui.Button("Invert Selection", buttonSize)) + modMerger.SelectedOptions.SymmetricExceptWith(modMerger.MergeFromMod!.AllDataContainers); + DrawOptionTable(size); + } + + private void DrawSplitOffDesc() + { + ImGuiUtil.TextWrapped("Here you can create a copy or a partial copy of the currently selected mod.\n\n" + + "Select as many of the options you want to copy over, enter a new mod name and click Split Off.\n\n" + + "You can right-click option groups to select or unselect all options from that specific group, and use the three buttons above the table for quick manipulation of your selection.\n\n" + + "Only required files will be copied over to the new mod. The names of options and groups will be retained. If the Default option is not selected, the new mods default option will be empty."); + } + + private void DrawOptionTable(float size) + { + var options = modMerger.MergeFromMod!.AllDataContainers.ToList(); + var height = modMerger.Warnings.Count == 0 && modMerger.Error == null + ? ImGui.GetContentRegionAvail().Y - 3 * ImGui.GetFrameHeightWithSpacing() + : 8 * ImGui.GetFrameHeightWithSpacing(); + height = Math.Min(height, (options.Count + 1) * ImGui.GetFrameHeightWithSpacing()); + var tableSize = new Vector2(size, height); + using var table = ImRaii.Table("##options", 6, + ImGuiTableFlags.RowBg + | ImGuiTableFlags.SizingFixedFit + | ImGuiTableFlags.ScrollY + | ImGuiTableFlags.BordersOuterV + | ImGuiTableFlags.BordersOuterH, + tableSize); + if (!table) + return; + + ImGui.TableSetupColumn("##Selected", ImGuiTableColumnFlags.WidthFixed, ImGui.GetFrameHeight()); + ImGui.TableSetupColumn("Option", ImGuiTableColumnFlags.WidthStretch); + ImGui.TableSetupColumn("Option Group", ImGuiTableColumnFlags.WidthFixed, 120 * ImGuiHelpers.GlobalScale); + ImGui.TableSetupColumn("#Files", ImGuiTableColumnFlags.WidthFixed, 50 * ImGuiHelpers.GlobalScale); + ImGui.TableSetupColumn("#Swaps", ImGuiTableColumnFlags.WidthFixed, 50 * ImGuiHelpers.GlobalScale); + ImGui.TableSetupColumn("#Manips", ImGuiTableColumnFlags.WidthFixed, 50 * ImGuiHelpers.GlobalScale); + ImGui.TableHeadersRow(); + foreach (var (option, idx) in options.WithIndex()) + { + using var id = ImRaii.PushId(idx); + var selected = modMerger.SelectedOptions.Contains(option); + + ImGui.TableNextColumn(); + if (ImGui.Checkbox("##check", ref selected)) + Handle(option, selected); + + if (option.Group is not { } group) + { + ImGuiUtil.DrawTableColumn(option.GetFullName()); + ImGui.TableNextColumn(); + } + else + { + ImGuiUtil.DrawTableColumn(option.GetName()); + + ImGui.TableNextColumn(); + ImGui.Selectable(group.Name, false); + if (ImGui.BeginPopupContextItem("##groupContext")) + { + if (ImGui.MenuItem("Select All")) + // ReSharper disable once PossibleMultipleEnumeration + foreach (var opt in group.DataContainers) + Handle(opt, true); + + if (ImGui.MenuItem("Unselect All")) + // ReSharper disable once PossibleMultipleEnumeration + foreach (var opt in group.DataContainers) + Handle(opt, false); + ImGui.EndPopup(); + } + } + + ImGui.TableNextColumn(); + ImGuiUtil.RightAlign(option.Files.Count.ToString(), 3 * ImGuiHelpers.GlobalScale); + ImGui.TableNextColumn(); + ImGuiUtil.RightAlign(option.FileSwaps.Count.ToString(), 3 * ImGuiHelpers.GlobalScale); + ImGui.TableNextColumn(); + ImGuiUtil.RightAlign(option.Manipulations.Count.ToString(), 3 * ImGuiHelpers.GlobalScale); + continue; + + void Handle(IModDataContainer option2, bool selected2) + { + if (selected2) + modMerger.SelectedOptions.Add(option2); + else + modMerger.SelectedOptions.Remove(option2); + } + } + } + + private void DrawWarnings() + { + if (modMerger.Warnings.Count == 0) + return; + + ImGui.Separator(); + ImGui.Dummy(Vector2.One); + using var color = ImRaii.PushColor(ImGuiCol.Text, Colors.TutorialBorder); + foreach (var warning in modMerger.Warnings.SkipLast(1)) + { + ImGuiUtil.TextWrapped(warning); + ImGui.Separator(); + } + + ImGuiUtil.TextWrapped(modMerger.Warnings[^1]); + } + + private void DrawError() + { + if (modMerger.Error == null) + return; + + ImGui.Separator(); + ImGui.Dummy(Vector2.One); + using var color = ImRaii.PushColor(ImGuiCol.Text, Colors.RegexWarningBorder); + ImGuiUtil.TextWrapped(modMerger.Error.ToString()); + } +} diff --git a/Penumbra/UI/AdvancedWindow/OptionSelectCombo.cs b/Penumbra/UI/AdvancedWindow/OptionSelectCombo.cs new file mode 100644 index 00000000..c9996a1e --- /dev/null +++ b/Penumbra/UI/AdvancedWindow/OptionSelectCombo.cs @@ -0,0 +1,43 @@ +using Dalamud.Bindings.ImGui; +using OtterGui.Raii; +using OtterGui.Text; +using OtterGui.Widgets; +using Penumbra.Mods.Editor; +using Penumbra.UI.Classes; + +namespace Penumbra.UI.AdvancedWindow; + +public sealed class OptionSelectCombo(ModEditor editor, ModEditWindow window) + : FilterComboCache<(string FullName, (int Group, int Data) Index)>( + () => window.Mod!.AllDataContainers.Select(c => (c.GetFullName(), c.GetDataIndices())).ToList(), MouseWheelType.Control, Penumbra.Log) +{ + private ImRaii.ColorStyle _border; + + protected override void DrawCombo(string label, string preview, string tooltip, int currentSelected, float previewWidth, float itemHeight, + ImGuiComboFlags flags) + { + _border = ImRaii.PushFrameBorder(ImUtf8.GlobalScale, ColorId.FolderLine.Value()); + base.DrawCombo(label, preview, tooltip, currentSelected, previewWidth, itemHeight, flags); + _border.Dispose(); + } + + protected override void DrawFilter(int currentSelected, float width) + { + _border.Dispose(); + base.DrawFilter(currentSelected, width); + } + + public bool Draw(float width) + { + var flags = window.Mod!.AllDataContainers.Count() switch + { + 0 => ImGuiComboFlags.NoArrowButton, + > 8 => ImGuiComboFlags.HeightLargest, + _ => ImGuiComboFlags.None, + }; + return Draw("##optionSelector", editor.Option!.GetFullName(), string.Empty, width, ImGui.GetTextLineHeight(), flags); + } + + protected override bool DrawSelectable(int globalIdx, bool selected) + => ImUtf8.Selectable(Items[globalIdx].FullName, selected); +} diff --git a/Penumbra/UI/AdvancedWindow/ResourceTreeViewer.cs b/Penumbra/UI/AdvancedWindow/ResourceTreeViewer.cs new file mode 100644 index 00000000..ae450bec --- /dev/null +++ b/Penumbra/UI/AdvancedWindow/ResourceTreeViewer.cs @@ -0,0 +1,546 @@ +using Dalamud.Bindings.ImGui; +using Dalamud.Interface; +using Dalamud.Interface.Colors; +using Dalamud.Interface.ImGuiNotification; +using Dalamud.Interface.Utility; +using Dalamud.Plugin.Services; +using Lumina.Data; +using OtterGui; +using OtterGui.Classes; +using OtterGui.Compression; +using OtterGui.Extensions; +using OtterGui.Raii; +using OtterGui.Text; +using Penumbra.Api.Enums; +using Penumbra.GameData.Files; +using Penumbra.GameData.Structs; +using Penumbra.Interop.ResourceTree; +using Penumbra.Services; +using Penumbra.String; +using Penumbra.String.Classes; +using Penumbra.UI.Classes; + +namespace Penumbra.UI.AdvancedWindow; + +public class ResourceTreeViewer( + Configuration config, + ResourceTreeFactory treeFactory, + ChangedItemDrawer changedItemDrawer, + IncognitoService incognito, + int actionCapacity, + Action onRefresh, + Action drawActions, + CommunicatorService communicator, + PcpService pcpService, + IDataManager gameData, + FileDialogService fileDialog, + FileCompactor compactor) +{ + private const ResourceTreeFactory.Flags ResourceTreeFactoryFlags = + ResourceTreeFactory.Flags.WithUiData | ResourceTreeFactory.Flags.WithOwnership; + + private readonly HashSet _unfolded = []; + + private readonly Dictionary _filterCache = []; + private readonly Dictionary _writableCache = []; + + private TreeCategory _categoryFilter = AllCategories; + private ChangedItemIconFlag _typeFilter = ChangedItemFlagExtensions.AllFlags; + private string _nameFilter = string.Empty; + private string _nodeFilter = string.Empty; + private string _note = string.Empty; + + private Task? _task; + + public void Draw() + { + DrawModifiedGameFilesWarning(); + DrawControls(); + _task ??= RefreshCharacterList(); + + using var child = ImRaii.Child("##Data"); + if (!child) + return; + + if (!_task.IsCompleted) + { + ImGui.NewLine(); + ImGui.TextUnformatted("Calculating character list..."); + } + else if (_task.Exception != null) + { + ImGui.NewLine(); + using var color = ImRaii.PushColor(ImGuiCol.Text, Colors.RegexWarningBorder); + ImGui.TextUnformatted($"Error during calculation of character list:\n\n{_task.Exception}"); + } + else if (_task.IsCompletedSuccessfully) + { + var debugMode = config.DebugMode; + foreach (var (tree, index) in _task.Result.WithIndex()) + { + var category = Classify(tree); + if (!_categoryFilter.HasFlag(category) || !tree.Name.Contains(_nameFilter, StringComparison.OrdinalIgnoreCase)) + continue; + + using (var c = ImRaii.PushColor(ImGuiCol.Text, CategoryColor(category).Value())) + { + var isOpen = ImGui.CollapsingHeader($"{(incognito.IncognitoMode ? tree.AnonymizedName : tree.Name)}###{index}", + index == 0 ? ImGuiTreeNodeFlags.DefaultOpen : 0); + if (debugMode) + { + using var _ = ImRaii.PushFont(UiBuilder.MonoFont); + ImGuiUtil.HoverTooltip( + $"Object Index: {tree.GameObjectIndex}\nObject Address: 0x{tree.GameObjectAddress:X16}\nDraw Object Address: 0x{tree.DrawObjectAddress:X16}"); + } + + if (!isOpen) + continue; + } + + using var id = ImRaii.PushId(index); + + ImUtf8.TextFrameAligned($"Collection: {(incognito.IncognitoMode ? tree.AnonymizedCollectionName : tree.CollectionName)}"); + ImGui.SameLine(); + if (ImUtf8.ButtonEx("Export Character Pack"u8, + "Note that this recomputes the current data of the actor if it still exists, and does not use the cached data."u8)) + { + pcpService.CreatePcp((ObjectIndex)tree.GameObjectIndex, _note).ContinueWith(t => + { + + var (success, text) = t.Result; + + if (success) + Penumbra.Messager.NotificationMessage($"Created {text}.", NotificationType.Success, false); + else + Penumbra.Messager.NotificationMessage(text, NotificationType.Error, false); + }); + _note = string.Empty; + } + + ImUtf8.SameLineInner(); + ImGui.SetNextItemWidth(ImGui.GetContentRegionAvail().X); + ImUtf8.InputText("##note"u8, ref _note, "Export note..."u8); + + + using var table = ImRaii.Table("##ResourceTree", 4, + ImGuiTableFlags.SizingFixedFit | ImGuiTableFlags.RowBg); + if (!table) + continue; + + ImGui.TableSetupColumn(string.Empty, ImGuiTableColumnFlags.WidthStretch, 0.2f); + ImGui.TableSetupColumn("Game Path", ImGuiTableColumnFlags.WidthStretch, 0.3f); + ImGui.TableSetupColumn("Actual Path", ImGuiTableColumnFlags.WidthStretch, 0.5f); + ImGui.TableSetupColumn(string.Empty, ImGuiTableColumnFlags.WidthFixed, + actionCapacity * 3 * ImGuiHelpers.GlobalScale + (actionCapacity + 1) * ImGui.GetFrameHeight()); + ImGui.TableHeadersRow(); + + DrawNodes(tree.Nodes, 0, unchecked(tree.DrawObjectAddress * 31), 0); + } + } + } + + private void DrawModifiedGameFilesWarning() + { + if (!gameData.HasModifiedGameDataFiles) + return; + + using var style = ImRaii.PushColor(ImGuiCol.Text, ImGuiColors.DalamudOrange); + + ImUtf8.TextWrapped( + "Dalamud is reporting your FFXIV installation has modified game files. Any mods installed through TexTools will produce this message."u8); + ImUtf8.TextWrapped("Penumbra and some other plugins assume your FFXIV installation is unmodified in order to work."u8); + ImUtf8.TextWrapped( + "Data displayed here may be inaccurate because of this, which, in turn, can break functionality relying on it, such as Character Pack exports/imports, or mod synchronization functions provided by other plugins."u8); + ImUtf8.TextWrapped( + "Exit the game, open XIVLauncher, click the arrow next to Log In and select \"repair game files\" to resolve this issue. Afterwards, do not install any mods with TexTools. Your plugin configurations will remain, as will mods enabled in Penumbra."u8); + + ImGui.Separator(); + } + + private void DrawControls() + { + var yOffset = (ChangedItemDrawer.TypeFilterIconSize.Y - ImGui.GetFrameHeight()) / 2f; + ImGui.SetCursorPosY(ImGui.GetCursorPosY() + yOffset); + + if (ImGui.Button("Refresh Character List")) + _task = RefreshCharacterList(); + + var checkSpacing = ImGui.GetStyle().ItemInnerSpacing.X; + var checkPadding = 10 * ImGuiHelpers.GlobalScale + ImGui.GetStyle().ItemSpacing.X; + ImGui.SameLine(0, checkPadding); + + using (var id = ImRaii.PushId("TreeCategoryFilter")) + { + var categoryFilter = (uint)_categoryFilter; + foreach (var category in Enum.GetValues()) + { + using var c = ImRaii.PushColor(ImGuiCol.CheckMark, CategoryColor(category).Value()); + ImGui.CheckboxFlags($"##{category}", ref categoryFilter, (uint)category); + ImGuiUtil.HoverTooltip(CategoryFilterDescription(category)); + ImGui.SameLine(0.0f, checkSpacing); + } + + _categoryFilter = (TreeCategory)categoryFilter; + } + + ImGui.SameLine(0, checkPadding); + + var filterChanged = false; + ImGui.SetCursorPosY(ImGui.GetCursorPosY() - yOffset); + using (ImRaii.Child("##typeFilter", new Vector2(ImGui.GetContentRegionAvail().X, ChangedItemDrawer.TypeFilterIconSize.Y))) + { + filterChanged |= changedItemDrawer.DrawTypeFilter(ref _typeFilter); + } + + var fieldWidth = (ImGui.GetContentRegionAvail().X - checkSpacing * 2.0f - ImGui.GetFrameHeightWithSpacing()) / 2.0f; + ImGui.SetNextItemWidth(fieldWidth); + filterChanged |= ImGui.InputTextWithHint("##TreeNameFilter", "Filter by Character/Entity Name...", ref _nameFilter, 128); + ImGui.SameLine(0, checkSpacing); + ImGui.SetNextItemWidth(fieldWidth); + filterChanged |= ImGui.InputTextWithHint("##NodeFilter", "Filter by Item/Part Name or Path...", ref _nodeFilter, 128); + ImGui.SameLine(0, checkSpacing); + incognito.DrawToggle(ImGui.GetFrameHeightWithSpacing()); + + if (filterChanged) + _filterCache.Clear(); + } + + private Task RefreshCharacterList() + => Task.Run(() => + { + try + { + return treeFactory.FromObjectTable(ResourceTreeFactoryFlags) + .Select(entry => entry.ResourceTree) + .ToArray(); + } + finally + { + _filterCache.Clear(); + _writableCache.Clear(); + _unfolded.Clear(); + onRefresh(); + } + }); + + private void DrawNodes(IEnumerable resourceNodes, int level, nint pathHash, + ChangedItemIconFlag parentFilterIconFlag) + { + var debugMode = config.DebugMode; + var frameHeight = ImGui.GetFrameHeight(); + + foreach (var (resourceNode, index) in resourceNodes.WithIndex()) + { + var nodePathHash = unchecked(pathHash + resourceNode.ResourceHandle); + + var visibility = GetNodeVisibility(nodePathHash, resourceNode, parentFilterIconFlag); + if (visibility == NodeVisibility.Hidden) + continue; + + using var mutedColor = ImRaii.PushColor(ImGuiCol.Text, ImGuiUtil.HalfTransparentText(), resourceNode.Internal); + + var filterIcon = resourceNode.IconFlag != 0 ? resourceNode.IconFlag : parentFilterIconFlag; + + using var id = ImRaii.PushId(index); + ImGui.TableNextColumn(); + var unfolded = _unfolded.Contains(nodePathHash); + using (var indent = ImRaii.PushIndent(level)) + { + var hasVisibleChildren = resourceNode.Children.Any(child + => GetNodeVisibility(unchecked(nodePathHash * 31 + child.ResourceHandle), child, filterIcon) != NodeVisibility.Hidden); + var unfoldable = hasVisibleChildren && visibility != NodeVisibility.DescendentsOnly; + if (unfoldable) + { + using var font = ImRaii.PushFont(UiBuilder.IconFont); + var icon = (unfolded ? FontAwesomeIcon.CaretDown : FontAwesomeIcon.CaretRight).ToIconString(); + var offset = (ImGui.GetFrameHeight() - ImGui.CalcTextSize(icon).X) / 2; + ImGui.SetCursorPosX(ImGui.GetCursorPosX() + offset); + ImGui.TextUnformatted(icon); + ImGui.SameLine(0f, offset + ImGui.GetStyle().ItemInnerSpacing.X); + } + else + { + if (hasVisibleChildren && !unfolded) + { + _unfolded.Add(nodePathHash); + unfolded = true; + } + + ImGui.Dummy(new Vector2(ImGui.GetFrameHeight())); + ImGui.SameLine(0f, ImGui.GetStyle().ItemInnerSpacing.X); + } + + changedItemDrawer.DrawCategoryIcon(resourceNode.IconFlag); + ImGui.SameLine(0f, ImGui.GetStyle().ItemInnerSpacing.X); + ImGui.TableHeader(resourceNode.Name); + if (ImGui.IsItemClicked() && unfoldable) + { + if (unfolded) + _unfolded.Remove(nodePathHash); + else + _unfolded.Add(nodePathHash); + unfolded = !unfolded; + } + + if (debugMode) + { + using var _ = ImRaii.PushFont(UiBuilder.MonoFont); + ImGuiUtil.HoverTooltip( + $"Resource Type: {resourceNode.Type}\nObject Address: 0x{resourceNode.ObjectAddress:X16}\nResource Handle: 0x{resourceNode.ResourceHandle:X16}\nLength: 0x{resourceNode.Length:X16}"); + } + } + + ImGui.TableNextColumn(); + var hasGamePaths = resourceNode.PossibleGamePaths.Length > 0; + ImGui.Selectable(resourceNode.PossibleGamePaths.Length switch + { + 0 => "(none)", + 1 => resourceNode.GamePath.ToString(), + _ => "(multiple)", + }, false, hasGamePaths ? 0 : ImGuiSelectableFlags.Disabled, new Vector2(ImGui.GetContentRegionAvail().X, frameHeight)); + if (hasGamePaths) + { + var allPaths = string.Join('\n', resourceNode.PossibleGamePaths); + if (ImGui.IsItemClicked()) + ImGui.SetClipboardText(allPaths); + ImGuiUtil.HoverTooltip($"{allPaths}\n\nClick to copy to clipboard."); + } + + ImGui.TableNextColumn(); + if (resourceNode.FullPath.FullName.Length > 0) + { + var hasMod = resourceNode.Mod.TryGetTarget(out var mod); + if (resourceNode is { ModName: not null, ModRelativePath: not null }) + { + var modName = $"[{(hasMod ? mod!.Name : resourceNode.ModName)}]"; + var textPos = ImGui.GetCursorPosX() + ImUtf8.CalcTextSize(modName).X + ImGui.GetStyle().ItemInnerSpacing.X; + using var group = ImUtf8.Group(); + using (var color = ImRaii.PushColor(ImGuiCol.Text, (hasMod ? ColorId.NewMod : ColorId.DisabledMod).Value())) + { + ImUtf8.Selectable(modName, false, ImGuiSelectableFlags.AllowItemOverlap, + new Vector2(ImGui.GetContentRegionAvail().X, frameHeight)); + } + + ImGui.SameLine(); + ImGui.SetCursorPosX(textPos); + ImUtf8.Text(resourceNode.ModRelativePath); + } + else if (resourceNode.FullPath.IsRooted) + { + var path = resourceNode.FullPath.FullName; + var lastDirectorySeparator = path.LastIndexOf('\\'); + var secondLastDirectorySeparator = lastDirectorySeparator > 0 + ? path.LastIndexOf('\\', lastDirectorySeparator - 1) + : -1; + if (secondLastDirectorySeparator >= 0) + path = $"…{path.AsSpan(secondLastDirectorySeparator)}"; + ImGui.Selectable(path.AsSpan(), false, ImGuiSelectableFlags.AllowItemOverlap, + new Vector2(ImGui.GetContentRegionAvail().X, frameHeight)); + } + else + { + ImGui.Selectable(resourceNode.FullPath.ToPath(), false, ImGuiSelectableFlags.AllowItemOverlap, + new Vector2(ImGui.GetContentRegionAvail().X, frameHeight)); + } + + if (ImGui.IsItemClicked()) + ImGui.SetClipboardText(resourceNode.FullPath.ToPath()); + if (hasMod && ImGui.IsItemClicked(ImGuiMouseButton.Right) && ImGui.GetIO().KeyCtrl) + communicator.SelectTab.Invoke(TabType.Mods, mod); + + ImGuiUtil.HoverTooltip( + $"{resourceNode.FullPath.ToPath()}\n\nClick to copy to clipboard.{(hasMod ? "\nControl + Right-Click to jump to mod." : string.Empty)}{GetAdditionalDataSuffix(resourceNode.AdditionalData)}"); + } + else + { + ImUtf8.Selectable(GetPathStatusLabel(resourceNode.FullPathStatus), false, ImGuiSelectableFlags.Disabled, + new Vector2(ImGui.GetContentRegionAvail().X, frameHeight)); + ImGuiUtil.HoverTooltip( + $"{GetPathStatusDescription(resourceNode.FullPathStatus)}{GetAdditionalDataSuffix(resourceNode.AdditionalData)}"); + } + + mutedColor.Dispose(); + + ImGui.TableNextColumn(); + using var spacing = ImRaii.PushStyle(ImGuiStyleVar.ItemSpacing, + ImGui.GetStyle().ItemSpacing with { X = 3 * ImGuiHelpers.GlobalScale }); + DrawActions(resourceNode, new Vector2(frameHeight)); + + if (unfolded) + DrawNodes(resourceNode.Children, level + 1, unchecked(nodePathHash * 31), filterIcon); + } + + return; + + string GetAdditionalDataSuffix(CiByteString data) + => !debugMode || data.IsEmpty ? string.Empty : $"\n\nAdditional Data: {data}"; + + NodeVisibility GetNodeVisibility(nint nodePathHash, ResourceNode node, ChangedItemIconFlag parentFilterIcon) + { + if (!_filterCache.TryGetValue(nodePathHash, out var visibility)) + { + visibility = CalculateNodeVisibility(nodePathHash, node, parentFilterIcon); + _filterCache.Add(nodePathHash, visibility); + } + + return visibility; + } + + NodeVisibility CalculateNodeVisibility(nint nodePathHash, ResourceNode node, ChangedItemIconFlag parentFilterIcon) + { + if (node.Internal && !debugMode) + return NodeVisibility.Hidden; + + var filterIcon = node.IconFlag != 0 ? node.IconFlag : parentFilterIcon; + if (MatchesFilter(node, filterIcon)) + return NodeVisibility.Visible; + + foreach (var child in node.Children) + { + if (GetNodeVisibility(unchecked(nodePathHash * 31 + child.ResourceHandle), child, filterIcon) != NodeVisibility.Hidden) + return NodeVisibility.DescendentsOnly; + } + + return NodeVisibility.Hidden; + } + + bool MatchesFilter(ResourceNode node, ChangedItemIconFlag filterIcon) + { + if (!_typeFilter.HasFlag(filterIcon)) + return false; + + if (_nodeFilter.Length == 0) + return true; + + return node.Name != null && node.Name.Contains(_nodeFilter, StringComparison.OrdinalIgnoreCase) + || node.FullPath.FullName.Contains(_nodeFilter, StringComparison.OrdinalIgnoreCase) + || node.FullPath.InternalName.ToString().Contains(_nodeFilter, StringComparison.OrdinalIgnoreCase) + || Array.Exists(node.PossibleGamePaths, path => path.Path.ToString().Contains(_nodeFilter, StringComparison.OrdinalIgnoreCase)); + } + + void DrawActions(ResourceNode resourceNode, Vector2 buttonSize) + { + if (!_writableCache!.TryGetValue(resourceNode.FullPath, out var writable)) + { + var path = resourceNode.FullPath.ToPath(); + if (resourceNode.FullPath.IsRooted) + { + writable = new RawFileWritable(path); + } + else + { + var file = gameData.GetFile(path); + writable = file is null ? null : new RawGameFileWritable(file); + } + + _writableCache.Add(resourceNode.FullPath, writable); + } + + if (ImUtf8.IconButton(FontAwesomeIcon.Save, "Export this file."u8, buttonSize, + resourceNode.FullPath.FullName.Length is 0 || writable is null)) + { + var fullPathStr = resourceNode.FullPath.FullName; + var ext = resourceNode.PossibleGamePaths.Length == 1 + ? Path.GetExtension(resourceNode.GamePath.ToString()) + : Path.GetExtension(fullPathStr); + fileDialog.OpenSavePicker($"Export {Path.GetFileName(fullPathStr)} to...", ext, Path.GetFileNameWithoutExtension(fullPathStr), ext, + (success, name) => + { + if (!success) + return; + + try + { + compactor.WriteAllBytes(name, writable!.Write()); + } + catch (Exception e) + { + Penumbra.Log.Error($"Could not export {fullPathStr}:\n{e}"); + } + }, null, false); + } + + drawActions(resourceNode, writable, new Vector2(frameHeight)); + } + } + + private static ReadOnlySpan GetPathStatusLabel(ResourceNode.PathStatus status) + => status switch + { + ResourceNode.PathStatus.External => "(managed by external tools)"u8, + ResourceNode.PathStatus.NonExistent => "(not found)"u8, + _ => "(unavailable)"u8, + }; + + private static string GetPathStatusDescription(ResourceNode.PathStatus status) + => status switch + { + ResourceNode.PathStatus.External => "The actual path to this file is unavailable, because it is managed by external tools.", + ResourceNode.PathStatus.NonExistent => + "The actual path to this file is unavailable, because it seems to have been moved or deleted since it was loaded.", + _ => "The actual path to this file is unavailable.", + }; + + [Flags] + private enum TreeCategory : uint + { + LocalPlayer = 1, + Player = 2, + Networked = 4, + NonNetworked = 8, + } + + private const TreeCategory AllCategories = (TreeCategory)(((uint)TreeCategory.NonNetworked << 1) - 1); + + private static TreeCategory Classify(ResourceTree tree) + => tree.LocalPlayerRelated ? TreeCategory.LocalPlayer : + tree.PlayerRelated ? TreeCategory.Player : + tree.Networked ? TreeCategory.Networked : + TreeCategory.NonNetworked; + + private static ColorId CategoryColor(TreeCategory category) + => category switch + { + TreeCategory.LocalPlayer => ColorId.ResTreeLocalPlayer, + TreeCategory.Player => ColorId.ResTreePlayer, + TreeCategory.Networked => ColorId.ResTreeNetworked, + TreeCategory.NonNetworked => ColorId.ResTreeNonNetworked, + _ => throw new ArgumentException(), + }; + + private static string CategoryFilterDescription(TreeCategory category) + => category switch + { + TreeCategory.LocalPlayer => "Show you and what you own (mount, minion, accessory, pets and so on).", + TreeCategory.Player => "Show other players and what they own.", + TreeCategory.Networked => "Show non-player entities handled by the game server.", + TreeCategory.NonNetworked => "Show non-player entities handled locally.", + _ => throw new ArgumentException(), + }; + + [Flags] + private enum NodeVisibility : uint + { + Hidden = 0, + Visible = 1, + DescendentsOnly = 2, + } + + private record RawFileWritable(string Path) : IWritable + { + public bool Valid + => true; + + public byte[] Write() + => File.ReadAllBytes(Path); + } + + private record RawGameFileWritable(FileResource FileResource) : IWritable + { + public bool Valid + => true; + + public byte[] Write() + => FileResource.Data; + } +} diff --git a/Penumbra/UI/AdvancedWindow/ResourceTreeViewerFactory.cs b/Penumbra/UI/AdvancedWindow/ResourceTreeViewerFactory.cs new file mode 100644 index 00000000..6518ae67 --- /dev/null +++ b/Penumbra/UI/AdvancedWindow/ResourceTreeViewerFactory.cs @@ -0,0 +1,24 @@ +using Dalamud.Plugin.Services; +using OtterGui.Compression; +using OtterGui.Services; +using Penumbra.GameData.Files; +using Penumbra.Interop.ResourceTree; +using Penumbra.Services; + +namespace Penumbra.UI.AdvancedWindow; + +public class ResourceTreeViewerFactory( + Configuration config, + ResourceTreeFactory treeFactory, + ChangedItemDrawer changedItemDrawer, + IncognitoService incognito, + CommunicatorService communicator, + PcpService pcpService, + IDataManager gameData, + FileDialogService fileDialog, + FileCompactor compactor) : IService +{ + public ResourceTreeViewer Create(int actionCapacity, Action onRefresh, Action drawActions) + => new(config, treeFactory, changedItemDrawer, incognito, actionCapacity, onRefresh, drawActions, communicator, pcpService, gameData, + fileDialog, compactor); +} diff --git a/Penumbra/UI/ChangedItemDrawer.cs b/Penumbra/UI/ChangedItemDrawer.cs new file mode 100644 index 00000000..db54a8e5 --- /dev/null +++ b/Penumbra/UI/ChangedItemDrawer.cs @@ -0,0 +1,317 @@ +using Dalamud.Interface; +using Dalamud.Interface.Textures; +using Dalamud.Interface.Textures.TextureWraps; +using Dalamud.Plugin.Services; +using Dalamud.Utility; +using Dalamud.Bindings.ImGui; +using Lumina.Data.Files; +using OtterGui; +using OtterGui.Classes; +using OtterGui.Raii; +using OtterGui.Services; +using OtterGui.Text; +using Penumbra.Api.Enums; +using Penumbra.GameData.Data; +using Penumbra.Services; +using Penumbra.UI.Classes; + +namespace Penumbra.UI; + +public class ChangedItemDrawer : IDisposable, IUiService +{ + private static readonly string[] LowerNames = ChangedItemFlagExtensions.Order.Select(f => f.ToDescription().ToLowerInvariant()).ToArray(); + + public static bool TryParseIndex(ReadOnlySpan input, out ChangedItemIconFlag slot) + { + // Handle numeric cases before TryParse because numbers + // are not logical otherwise. + if (int.TryParse(input, out var idx)) + { + // We assume users will use 1-based index, but if they enter 0, just use the first. + if (idx == 0) + { + slot = ChangedItemFlagExtensions.Order[0]; + return true; + } + + // Use 1-based index. + --idx; + if (idx >= 0 && idx < ChangedItemFlagExtensions.Order.Count) + { + slot = ChangedItemFlagExtensions.Order[idx]; + return true; + } + } + + slot = 0; + return false; + } + + public static bool TryParsePartial(string lowerInput, out ChangedItemIconFlag slot) + { + if (TryParseIndex(lowerInput, out slot)) + return true; + + slot = 0; + foreach (var (item, flag) in LowerNames.Zip(ChangedItemFlagExtensions.Order)) + { + if (item.Contains(lowerInput, StringComparison.Ordinal)) + slot |= flag; + } + + return slot != 0; + } + + + private readonly Configuration _config; + private readonly CommunicatorService _communicator; + private readonly Dictionary _icons = new(16); + private float _smallestIconWidth; + + public static Vector2 TypeFilterIconSize + => new(2 * ImGui.GetTextLineHeight()); + + public ChangedItemDrawer(IUiBuilder uiBuilder, IDataManager gameData, ITextureProvider textureProvider, CommunicatorService communicator, + Configuration config) + { + uiBuilder.RunWhenUiPrepared(() => CreateEquipSlotIcons(uiBuilder, gameData, textureProvider), true); + _communicator = communicator; + _config = config; + } + + public void Dispose() + { + foreach (var wrap in _icons.Values.Distinct()) + wrap.Dispose(); + _icons.Clear(); + } + + /// Check if a changed item should be drawn based on its category. + public bool FilterChangedItem(string name, IIdentifiedObjectData data, LowerString filter) + => (_config.Ephemeral.ChangedItemFilter == ChangedItemFlagExtensions.AllFlags + || _config.Ephemeral.ChangedItemFilter.HasFlag(data.GetIcon().ToFlag())) + && (filter.IsEmpty || !data.IsFilteredOut(name, filter)); + + /// Draw the icon corresponding to the category of a changed item. + public void DrawCategoryIcon(IIdentifiedObjectData data, float height) + => DrawCategoryIcon(data.GetIcon().ToFlag(), height); + + public void DrawCategoryIcon(ChangedItemIconFlag iconFlagType) + => DrawCategoryIcon(iconFlagType, ImGui.GetFrameHeight()); + + public void DrawCategoryIcon(ChangedItemIconFlag iconFlagType, float height) + { + if (!_icons.TryGetValue(iconFlagType, out var icon)) + { + ImGui.Dummy(new Vector2(height)); + return; + } + + ImGui.Image(icon.Handle, new Vector2(height)); + if (ImGui.IsItemHovered()) + { + using var tt = ImRaii.Tooltip(); + ImGui.Image(icon.Handle, new Vector2(_smallestIconWidth)); + ImGui.SameLine(); + ImGuiUtil.DrawTextButton(iconFlagType.ToDescription(), new Vector2(0, _smallestIconWidth), 0); + } + } + + public void ChangedItemHandling(IIdentifiedObjectData data, bool leftClicked) + { + var ret = leftClicked ? MouseButton.Left : MouseButton.None; + ret = ImGui.IsItemClicked(ImGuiMouseButton.Right) ? MouseButton.Right : ret; + ret = ImGui.IsItemClicked(ImGuiMouseButton.Middle) ? MouseButton.Middle : ret; + if (ret != MouseButton.None) + _communicator.ChangedItemClick.Invoke(ret, data); + if (!ImGui.IsItemHovered()) + return; + + using var tt = ImUtf8.Tooltip(); + if (data.Count == 1) + ImUtf8.Text("This item is changed through a single effective change.\n"); + else + ImUtf8.Text($"This item is changed through {data.Count} distinct effective changes.\n"); + ImGui.SetCursorPosY(ImGui.GetCursorPosY() + 3 * ImUtf8.GlobalScale); + ImGui.Separator(); + ImGui.SetCursorPosY(ImGui.GetCursorPosY() + 3 * ImUtf8.GlobalScale); + _communicator.ChangedItemHover.Invoke(data); + } + + /// Draw the model information, right-justified. + public static void DrawModelData(IIdentifiedObjectData data, float height) + { + var additionalData = data.AdditionalData; + if (additionalData.Length == 0) + return; + + ImGui.SameLine(); + using var color = ImRaii.PushColor(ImGuiCol.Text, ColorId.ItemId.Value()); + ImGui.SetCursorPosY(ImGui.GetCursorPosY() + (height - ImGui.GetTextLineHeight()) / 2); + ImUtf8.TextRightAligned(additionalData, ImGui.GetStyle().ItemInnerSpacing.X); + } + + /// Draw the model information, right-justified. + public static void DrawModelData(ReadOnlySpan text, float height) + { + if (text.Length == 0) + return; + + ImGui.SameLine(); + using var color = ImRaii.PushColor(ImGuiCol.Text, ColorId.ItemId.Value()); + ImGui.SetCursorPosY(ImGui.GetCursorPosY() + (height - ImGui.GetTextLineHeight()) / 2); + ImUtf8.TextRightAligned(text, ImGui.GetStyle().ItemInnerSpacing.X); + } + + /// Draw a header line with the different icon types to filter them. + public void DrawTypeFilter() + { + if (_config.HideChangedItemFilters) + return; + + var typeFilter = _config.Ephemeral.ChangedItemFilter; + if (DrawTypeFilter(ref typeFilter)) + { + _config.Ephemeral.ChangedItemFilter = typeFilter; + _config.Ephemeral.Save(); + } + } + + /// Draw a header line with the different icon types to filter them. + public bool DrawTypeFilter(ref ChangedItemIconFlag typeFilter) + { + var ret = false; + using var _ = ImRaii.PushId("ChangedItemIconFilter"); + var size = TypeFilterIconSize; + using var style = ImRaii.PushStyle(ImGuiStyleVar.ItemSpacing, Vector2.Zero); + + + foreach (var iconType in ChangedItemFlagExtensions.Order) + { + ret |= DrawIcon(iconType, ref typeFilter); + ImGui.SameLine(); + } + + ImGui.SetCursorPosX(ImGui.GetContentRegionMax().X - size.X); + ImGui.Image(_icons[ChangedItemFlagExtensions.AllFlags].Handle, size, Vector2.Zero, Vector2.One, + typeFilter switch + { + 0 => new Vector4(0.6f, 0.3f, 0.3f, 1f), + ChangedItemFlagExtensions.AllFlags => new Vector4(0.75f, 0.75f, 0.75f, 1f), + _ => new Vector4(0.5f, 0.5f, 1f, 1f), + }); + if (ImGui.IsItemClicked()) + { + typeFilter = typeFilter == ChangedItemFlagExtensions.AllFlags ? 0 : ChangedItemFlagExtensions.AllFlags; + ret = true; + } + + return ret; + + bool DrawIcon(ChangedItemIconFlag type, ref ChangedItemIconFlag typeFilter) + { + var localRet = false; + var icon = _icons[type]; + var flag = typeFilter.HasFlag(type); + ImGui.Image(icon.Handle, size, Vector2.Zero, Vector2.One, flag ? Vector4.One : new Vector4(0.6f, 0.3f, 0.3f, 1f)); + if (ImGui.IsItemClicked(ImGuiMouseButton.Left)) + { + typeFilter = flag ? typeFilter & ~type : typeFilter | type; + localRet = true; + } + + using var popup = ImRaii.ContextPopupItem(type.ToString()); + if (popup) + if (ImGui.MenuItem("Enable Only This")) + { + typeFilter = type; + localRet = true; + ImGui.CloseCurrentPopup(); + } + + if (ImGui.IsItemHovered()) + { + using var tt = ImRaii.Tooltip(); + ImGui.Image(icon.Handle, new Vector2(_smallestIconWidth)); + ImGui.SameLine(); + ImGuiUtil.DrawTextButton(type.ToDescription(), new Vector2(0, _smallestIconWidth), 0); + } + + return localRet; + } + } + + /// Initialize the icons. + private bool CreateEquipSlotIcons(IUiBuilder uiBuilder, IDataManager gameData, ITextureProvider textureProvider) + { + using var equipTypeIcons = uiBuilder.LoadUld("ui/uld/ArmouryBoard.uld"); + + if (!equipTypeIcons.Valid) + return false; + + void Add(ChangedItemIconFlag icon, IDalamudTextureWrap? tex) + { + if (tex != null) + _icons.Add(icon, tex); + } + + Add(ChangedItemIconFlag.Mainhand, equipTypeIcons.LoadTexturePart("ui/uld/ArmouryBoard_hr1.tex", 0)); + Add(ChangedItemIconFlag.Head, equipTypeIcons.LoadTexturePart("ui/uld/ArmouryBoard_hr1.tex", 1)); + Add(ChangedItemIconFlag.Body, equipTypeIcons.LoadTexturePart("ui/uld/ArmouryBoard_hr1.tex", 2)); + Add(ChangedItemIconFlag.Hands, equipTypeIcons.LoadTexturePart("ui/uld/ArmouryBoard_hr1.tex", 3)); + Add(ChangedItemIconFlag.Legs, equipTypeIcons.LoadTexturePart("ui/uld/ArmouryBoard_hr1.tex", 5)); + Add(ChangedItemIconFlag.Feet, equipTypeIcons.LoadTexturePart("ui/uld/ArmouryBoard_hr1.tex", 6)); + Add(ChangedItemIconFlag.Offhand, equipTypeIcons.LoadTexturePart("ui/uld/ArmouryBoard_hr1.tex", 7)); + Add(ChangedItemIconFlag.Ears, equipTypeIcons.LoadTexturePart("ui/uld/ArmouryBoard_hr1.tex", 8)); + Add(ChangedItemIconFlag.Neck, equipTypeIcons.LoadTexturePart("ui/uld/ArmouryBoard_hr1.tex", 9)); + Add(ChangedItemIconFlag.Wrists, equipTypeIcons.LoadTexturePart("ui/uld/ArmouryBoard_hr1.tex", 10)); + Add(ChangedItemIconFlag.Finger, equipTypeIcons.LoadTexturePart("ui/uld/ArmouryBoard_hr1.tex", 11)); + Add(ChangedItemIconFlag.Monster, textureProvider.CreateFromTexFile(gameData.GetFile("ui/icon/062000/062044_hr1.tex")!)); + Add(ChangedItemIconFlag.Demihuman, textureProvider.CreateFromTexFile(gameData.GetFile("ui/icon/062000/062043_hr1.tex")!)); + Add(ChangedItemIconFlag.Customization, textureProvider.CreateFromTexFile(gameData.GetFile("ui/icon/062000/062045_hr1.tex")!)); + Add(ChangedItemIconFlag.Action, textureProvider.CreateFromTexFile(gameData.GetFile("ui/icon/062000/062001_hr1.tex")!)); + Add(ChangedItemIconFlag.Emote, LoadEmoteTexture(gameData, textureProvider)); + Add(ChangedItemIconFlag.Unknown, LoadUnknownTexture(gameData, textureProvider)); + Add(ChangedItemFlagExtensions.AllFlags, textureProvider.CreateFromTexFile(gameData.GetFile("ui/icon/114000/114052_hr1.tex")!)); + + _smallestIconWidth = _icons.Values.Min(i => i.Width); + + return true; + } + + private static IDalamudTextureWrap? LoadUnknownTexture(IDataManager gameData, ITextureProvider textureProvider) + { + var unk = gameData.GetFile("ui/uld/levelup2_hr1.tex"); + if (unk == null) + return null; + + var image = unk.GetRgbaImageData(); + var bytes = new byte[unk.Header.Height * unk.Header.Height * 4]; + var diff = 2 * (unk.Header.Height - unk.Header.Width); + for (var y = 0; y < unk.Header.Height; ++y) + image.AsSpan(4 * y * unk.Header.Width, 4 * unk.Header.Width).CopyTo(bytes.AsSpan(4 * y * unk.Header.Height + diff)); + + return textureProvider.CreateFromRaw(RawImageSpecification.Rgba32(unk.Header.Height, unk.Header.Height), bytes, "Penumbra.UnkItemIcon"); + } + + private static unsafe IDalamudTextureWrap? LoadEmoteTexture(IDataManager gameData, ITextureProvider textureProvider) + { + var emote = gameData.GetFile("ui/icon/000000/000019_hr1.tex"); + if (emote == null) + return null; + + var image2 = emote.GetRgbaImageData(); + fixed (byte* ptr = image2) + { + var color = (uint*)ptr; + for (var i = 0; i < image2.Length / 4; ++i) + { + if (color[i] == 0xFF000000) + image2[i * 4 + 3] = 0; + } + } + + return textureProvider.CreateFromRaw(RawImageSpecification.Rgba32(emote.Header.Width, emote.Header.Height), image2, + "Penumbra.EmoteItemIcon"); + } +} diff --git a/Penumbra/UI/ChangedItemIconFlag.cs b/Penumbra/UI/ChangedItemIconFlag.cs new file mode 100644 index 00000000..fc7073f2 --- /dev/null +++ b/Penumbra/UI/ChangedItemIconFlag.cs @@ -0,0 +1,122 @@ +using Penumbra.Api.Enums; +using Penumbra.GameData.Enums; + +namespace Penumbra.UI; + +[Flags] +public enum ChangedItemIconFlag : uint +{ + Head = 0x00_00_01, + Body = 0x00_00_02, + Hands = 0x00_00_04, + Legs = 0x00_00_08, + Feet = 0x00_00_10, + Ears = 0x00_00_20, + Neck = 0x00_00_40, + Wrists = 0x00_00_80, + Finger = 0x00_01_00, + Monster = 0x00_02_00, + Demihuman = 0x00_04_00, + Customization = 0x00_08_00, + Action = 0x00_10_00, + Mainhand = 0x00_20_00, + Offhand = 0x00_40_00, + Unknown = 0x00_80_00, + Emote = 0x01_00_00, +} + +public static class ChangedItemFlagExtensions +{ + public static readonly IReadOnlyList Order = + [ + ChangedItemIconFlag.Head, + ChangedItemIconFlag.Body, + ChangedItemIconFlag.Hands, + ChangedItemIconFlag.Legs, + ChangedItemIconFlag.Feet, + ChangedItemIconFlag.Ears, + ChangedItemIconFlag.Neck, + ChangedItemIconFlag.Wrists, + ChangedItemIconFlag.Finger, + ChangedItemIconFlag.Mainhand, + ChangedItemIconFlag.Offhand, + ChangedItemIconFlag.Customization, + ChangedItemIconFlag.Action, + ChangedItemIconFlag.Emote, + ChangedItemIconFlag.Monster, + ChangedItemIconFlag.Demihuman, + ChangedItemIconFlag.Unknown, + ]; + + public const ChangedItemIconFlag AllFlags = (ChangedItemIconFlag)0x01FFFF; + public static readonly int NumCategories = Order.Count; + public const ChangedItemIconFlag DefaultFlags = AllFlags & ~ChangedItemIconFlag.Offhand; + + public static string ToDescription(this ChangedItemIconFlag iconFlag) + => iconFlag switch + { + ChangedItemIconFlag.Head => EquipSlot.Head.ToName(), + ChangedItemIconFlag.Body => EquipSlot.Body.ToName(), + ChangedItemIconFlag.Hands => EquipSlot.Hands.ToName(), + ChangedItemIconFlag.Legs => EquipSlot.Legs.ToName(), + ChangedItemIconFlag.Feet => EquipSlot.Feet.ToName(), + ChangedItemIconFlag.Ears => EquipSlot.Ears.ToName(), + ChangedItemIconFlag.Neck => EquipSlot.Neck.ToName(), + ChangedItemIconFlag.Wrists => EquipSlot.Wrists.ToName(), + ChangedItemIconFlag.Finger => "Ring", + ChangedItemIconFlag.Monster => "Monster", + ChangedItemIconFlag.Demihuman => "Demi-Human", + ChangedItemIconFlag.Customization => "Customization", + ChangedItemIconFlag.Action => "Action", + ChangedItemIconFlag.Emote => "Emote", + ChangedItemIconFlag.Mainhand => "Weapon (Mainhand)", + ChangedItemIconFlag.Offhand => "Weapon (Offhand)", + _ => "Other", + }; + + public static ChangedItemIcon ToApiIcon(this ChangedItemIconFlag iconFlag) + => iconFlag switch + { + ChangedItemIconFlag.Head => ChangedItemIcon.Head, + ChangedItemIconFlag.Body => ChangedItemIcon.Body, + ChangedItemIconFlag.Hands => ChangedItemIcon.Hands, + ChangedItemIconFlag.Legs => ChangedItemIcon.Legs, + ChangedItemIconFlag.Feet => ChangedItemIcon.Feet, + ChangedItemIconFlag.Ears => ChangedItemIcon.Ears, + ChangedItemIconFlag.Neck => ChangedItemIcon.Neck, + ChangedItemIconFlag.Wrists => ChangedItemIcon.Wrists, + ChangedItemIconFlag.Finger => ChangedItemIcon.Finger, + ChangedItemIconFlag.Monster => ChangedItemIcon.Monster, + ChangedItemIconFlag.Demihuman => ChangedItemIcon.Demihuman, + ChangedItemIconFlag.Customization => ChangedItemIcon.Customization, + ChangedItemIconFlag.Action => ChangedItemIcon.Action, + ChangedItemIconFlag.Emote => ChangedItemIcon.Emote, + ChangedItemIconFlag.Mainhand => ChangedItemIcon.Mainhand, + ChangedItemIconFlag.Offhand => ChangedItemIcon.Offhand, + ChangedItemIconFlag.Unknown => ChangedItemIcon.Unknown, + _ => ChangedItemIcon.None, + }; + + public static ChangedItemIconFlag ToFlag(this ChangedItemIcon icon) + => icon switch + { + ChangedItemIcon.Unknown => ChangedItemIconFlag.Unknown, + ChangedItemIcon.Head => ChangedItemIconFlag.Head, + ChangedItemIcon.Body => ChangedItemIconFlag.Body, + ChangedItemIcon.Hands => ChangedItemIconFlag.Hands, + ChangedItemIcon.Legs => ChangedItemIconFlag.Legs, + ChangedItemIcon.Feet => ChangedItemIconFlag.Feet, + ChangedItemIcon.Ears => ChangedItemIconFlag.Ears, + ChangedItemIcon.Neck => ChangedItemIconFlag.Neck, + ChangedItemIcon.Wrists => ChangedItemIconFlag.Wrists, + ChangedItemIcon.Finger => ChangedItemIconFlag.Finger, + ChangedItemIcon.Mainhand => ChangedItemIconFlag.Mainhand, + ChangedItemIcon.Offhand => ChangedItemIconFlag.Offhand, + ChangedItemIcon.Customization => ChangedItemIconFlag.Customization, + ChangedItemIcon.Monster => ChangedItemIconFlag.Monster, + ChangedItemIcon.Demihuman => ChangedItemIconFlag.Demihuman, + ChangedItemIcon.Action => ChangedItemIconFlag.Action, + ChangedItemIcon.Emote => ChangedItemIconFlag.Emote, + _ => ChangedItemIconFlag.Unknown, + }; +} diff --git a/Penumbra/UI/Changelog.cs b/Penumbra/UI/Changelog.cs new file mode 100644 index 00000000..306dcc79 --- /dev/null +++ b/Penumbra/UI/Changelog.cs @@ -0,0 +1,1160 @@ +using OtterGui.Services; +using OtterGui.Widgets; + +namespace Penumbra.UI; + +public class PenumbraChangelog : IUiService +{ + public const int LastChangelogVersion = 0; + + private readonly Configuration _config; + public readonly Changelog Changelog; + + public PenumbraChangelog(Configuration config) + { + _config = config; + Changelog = new Changelog("Penumbra Changelog", ConfigData, Save); + + Add5_7_0(Changelog); + Add5_7_1(Changelog); + Add5_8_0(Changelog); + Add5_8_7(Changelog); + Add5_9_0(Changelog); + Add5_10_0(Changelog); + Add5_11_0(Changelog); + Add5_11_1(Changelog); + Add6_0_0(Changelog); + Add6_0_2(Changelog); + Add6_0_5(Changelog); + Add6_1_0(Changelog); + Add6_1_1(Changelog); + Add6_2_0(Changelog); + Add6_3_0(Changelog); + Add6_4_0(Changelog); + Add6_5_0(Changelog); + Add6_5_2(Changelog); + Add6_6_0(Changelog); + Add6_6_1(Changelog); + Add7_0_0(Changelog); + Add7_0_1(Changelog); + Add7_0_4(Changelog); + Add7_1_0(Changelog); + Add7_1_2(Changelog); + Add7_2_0(Changelog); + Add7_3_0(Changelog); + Add8_0_0(Changelog); + Add8_1_1(Changelog); + Add8_1_2(Changelog); + Add8_2_0(Changelog); + Add8_3_0(Changelog); + Add1_0_0_0(Changelog); + AddDummy(Changelog); + AddDummy(Changelog); + Add1_1_0_0(Changelog); + Add1_1_1_0(Changelog); + Add1_2_1_0(Changelog); + Add1_3_0_0(Changelog); + Add1_3_1_0(Changelog); + Add1_3_2_0(Changelog); + Add1_3_3_0(Changelog); + Add1_3_4_0(Changelog); + Add1_3_5_0(Changelog); + Add1_3_6_0(Changelog); + Add1_3_6_4(Changelog); + Add1_4_0_0(Changelog); + Add1_5_0_0(Changelog); + Add1_5_1_0(Changelog); + } + + #region Changelogs + + private static void Add1_5_1_0(Changelog log) + => log.NextVersion("Version 1.5.1.0") + .RegisterHighlight("Added the option to export a characters current data as a .pcp modpack in the On-Screen tab.") + .RegisterEntry("Other plugins can attach to this functionality and package and interpret their own data.", 1) + .RegisterEntry("When a .pcp modpack is installed, it can create and assign collections for the corresponding character it was created for.", 1) + .RegisterEntry("This basically provides an easier way to manually synchronize other players, but does not contain any automation.", 1) + .RegisterEntry("The settings provide some fine control about what happens when a PCP is installed, as well as buttons to cleanup any PCP-created data.", 1) + .RegisterEntry("Added a warning message when the game's integrity is corrupted to the On-Screen tab.") + .RegisterEntry("Added .kdb files to the On-Screen tab and associated functionality (thanks Ny!).") + .RegisterEntry("Updated the creation of temporary collections to require a passed identity.") + .RegisterEntry("Added the option to change the skin material suffix in models using the stockings shader by adding specific attributes (thanks Ny!).") + .RegisterEntry("Added predefined tag utility to the multi-mod selection.") + .RegisterEntry("Fixed an issue with the automatic collection selection on character login when no mods are assigned.") + .RegisterImportant( + "Fixed issue with new deformer data that makes modded deformers not containing this data work implicitly. Updates are still recommended (1.5.0.5).") + .RegisterEntry("Fixed various issues after patch (1.5.0.1 - 1.5.0.4)."); + + private static void Add1_5_0_0(Changelog log) + => log.NextVersion("Version 1.5.0.0") + .RegisterImportant("Updated for game version 7.30 and Dalamud API13, which uses a new GUI backend. Some things may not work as expected. Please let me know any issues you encounter.") + .RegisterEntry("Added support for exporting models using two vertex color schemes (thanks zeroeightysix!).") + .RegisterEntry("Possibly improved the color accuracy of the basecolor texture created when exporting models (thanks zeroeightysix!).") + .RegisterEntry("Disabled enabling transparency for materials that use the characterstockings shader due to crashes (thanks zeroeightysix!).") + .RegisterEntry("Fixed some issues with model i/o and invalid tangents (thanks PassiveModding!)") + .RegisterEntry("Changed the behavior for default directory names when using the mod normalizer with combining groups.") + .RegisterEntry("Added jumping to specific mods to the HTTP API.") + .RegisterEntry("Fixed an issue with character sound modding (1.4.0.6).") + .RegisterHighlight("Added support for IMC-toggle attributes to accessories beyond the first toggle (1.4.0.5).") + .RegisterEntry("Fixed up some slot-specific attributes and shapes in models when swapping items between slots (1.4.0.5).") + .RegisterEntry("Added handling for human skin materials to the OnScreen tab and similar functionality (thanks Ny!) (1.4.0.5).") + .RegisterEntry("The OS thread ID a resource was loaded from was added to the resource logger (1.4.0.5).") + .RegisterEntry("A button linking to my (Ottermandias') Ko-Fi and Patreon was added in the settings tab. Feel free, but not pressured, to use it! :D ") + .RegisterHighlight("Mod setting combos now support mouse-wheel scrolling with Control and have filters (1.4.0.4).") + .RegisterEntry("Using the middle mouse button to toggle designs now works correctly with temporary settings (1.4.0.4).") + .RegisterEntry("Updated some BNPC associations (1.4.0.3).") + .RegisterEntry("Fixed further issues with shapes and attributes (1.4.0.4).") + .RegisterEntry("Penumbra now handles textures with MipMap offsets broken by TexTools on import and removes unnecessary MipMaps (1.4.0.3).") + .RegisterEntry("Updated the Mod Merger for the new group types (1.4.0.3).") + .RegisterEntry("Added querying Penumbra for supported features via IPC (1.4.0.3).") + .RegisterEntry("Shape names can now be edited in Penumbras model editor (1.4.0.2).") + .RegisterEntry("Attributes and Shapes can be fully toggled (1.4.0.2).") + .RegisterEntry("Fixed several issues with attributes and shapes (1.4.0.1)."); + + private static void Add1_4_0_0(Changelog log) + => log.NextVersion("Version 1.4.0.0") + .RegisterHighlight("Added two types of new Meta Changes, SHP and ATR (Thanks Karou!).") + .RegisterEntry("Those allow mod creators to toggle custom shape keys and attributes for models on and off, respectively.", 1) + .RegisterEntry("Custom shape keys need to have the format 'shpx_*' and custom attributes need 'atrx_*'.", 1) + .RegisterHighlight( + "Shapes of the following formats will automatically be toggled on if both relevant slots contain the same shape key:", 1) + .RegisterEntry("'shpx_wa_*', for the waist seam between the body and leg slot,", 2) + .RegisterEntry("'shpx_wr_*', for the wrist seams between the body and hands slot,", 2) + .RegisterEntry("'shpx_an_*', for the ankle seams between the leg and feet slot.", 2) + .RegisterEntry( + "Custom shape key and attributes can be turned off in the advanced settings section for the moment, but this is not recommended.", + 1) + .RegisterHighlight("The mod selector width is now draggable within certain restrictions that depend on the total window width.") + .RegisterEntry("The current behavior may not be final, let me know if you have any comments.", 1) + .RegisterEntry("Improved the naming of NPCs for identifiers by using Haselnussbombers new naming functionality (Thanks Hasel!).") + .RegisterEntry("Added global EQP entries to always hide Au Ra horns, Viera ears, or Miqo'te ears, respectively.") + .RegisterEntry("This will leave holes in the heads of the respective race if not modded in some way.", 1) + .RegisterEntry("Added a filter for mods that have temporary settings in the mod selector panel (Thanks Caraxi).") + .RegisterEntry("Made the checkbox for toggling Temporary Settings Mode in the mod tab more visible.") + .RegisterEntry("Improved the option select combo in advanced editing.") + .RegisterEntry("Fixed some issues with item identification for EST changes.") + .RegisterEntry("Fixed the sizing of the mod panel being off by 1 pixel sometimes.") + .RegisterEntry("Fixed an issue with redrawing while in GPose when other plugins broke some assumptions about the game state.") + .RegisterEntry("Fixed a clipping issue within the Meta Manipulations tab in advanced editing.") + .RegisterEntry("Fixed an issue with empty and temporary settings.") + .RegisterHighlight( + "In the Item Swap tab, items changed by this mod are now sorted and highlighted before items changed in the current collection before other items for the source, and inversely for the target. (1.3.6.8)") + .RegisterHighlight( + "Default-valued meta edits should now be kept on import and only removed when the option to keep them is not set AND no other options in the mod edit the same entry. (1.3.6.8)") + .RegisterEntry("Added a right-click context menu on file redirections to copy the full file path. (1.3.6.8)") + .RegisterEntry( + "Added a right-click context menu on the mod export button to open the backup directory in your file explorer. (1.3.6.8)") + .RegisterEntry("Fixed some issues when redrawing characters from other plugins. (1.3.6.8)") + .RegisterEntry( + "Added a modifier key separate from the delete modifier key that is used for less important key-checks, specifically toggling incognito mode. (1.3.6.7)") + .RegisterEntry("Fixed some issues with the Material Editor (Thanks Ny). (1.3.6.6)"); + + private static void Add1_3_6_4(Changelog log) + => log.NextVersion("Version 1.3.6.4") + .RegisterEntry("The material editor should be functional again."); + + private static void Add1_3_6_0(Changelog log) + => log.NextVersion("Version 1.3.6.0") + .RegisterImportant("Updated Penumbra for update 7.20 and Dalamud API 12.") + .RegisterEntry( + "This is not thoroughly tested, but I decided to push to stable instead of testing because otherwise a lot of people would just go to testing just for early access again despite having no business doing so.", + 1) + .RegisterEntry( + "I also do not use most of the functionality of Penumbra myself, so I am unable to even encounter most issues myself.", 1) + .RegisterEntry("If you encounter any issues, please report them quickly on the discord.", 1) + .RegisterHighlight( + "The texture editor now has encoding support for Block Compression 1, 4 and 5 and tooltips explaining when to use which format.") + .RegisterEntry("It also is able to use GPU compression and thus has become much faster for BC7 in particular. (Thanks Ny!)", 1) + .RegisterEntry( + "Added the option to import .atch files found in the particular mod via right-click context menu on the import drag & drop button.") + .RegisterEntry("Added a chat command to clear temporary settings done manually in Penumbra.") + .RegisterEntry( + "The changed item star to select the preferred changed item is a bit more noticeable by default, and its color can be configured.") + .RegisterEntry("Some minor fixes for computing changed items. (Thanks Anna!)") + .RegisterEntry("The EQP entry previously named Unknown 4 was renamed to 'Hide Glove Cuffs'.") + .RegisterEntry("Fixed the changed item identification for EST changes.") + .RegisterEntry("Fixed clipping issues in the changed items panel when no grouping was active."); + + + private static void Add1_3_5_0(Changelog log) + => log.NextVersion("Version 1.3.5.0") + .RegisterImportant( + "Redirections of unsupported file types like .atch will now produce warnings when they are enabled. Please update mods still containing them or request updates from their creators.") + .RegisterEntry("You can now import .atch in the Meta section of advanced editing to add their non-default changes to the mod.") + .RegisterHighlight("Added an option in settings and in the collection bar in the mod tab to always use temporary settings.") + .RegisterEntry( + "While this option is enabled, all changes you make in the current collection will be applied as temporary changes, and you have to use Turn Permanent to make them permanent.", + 1) + .RegisterEntry( + "This should be useful for trying out new mods without needing to reset their settings later, or for creating mod associations in Glamourer from them.", + 1) + .RegisterEntry( + "Added a context menu entry on the mod selector blank-space context menu to clear all temporary settings made manually.") + .RegisterHighlight( + "Resource Trees now consider some additional files like decals, and improved the quick-import behaviour for some files that should not generally be modded.") + .RegisterHighlight("The Changed Item display for single mods has been heavily improved.") + .RegisterEntry("Any changed item will now show how many individual edits are affecting it in the mod in its tooltip.", 1) + .RegisterEntry("Equipment pieces are now grouped by their model id, reducing clutter.", 1) + .RegisterEntry( + "The primary equipment piece displayed is the one with the most changes affecting it, but can be configured to a specific item by the mod creator and locally.", + 1) + .RegisterEntry( + "Preferred changed items stored in the mod will be shared when exporting the mod, and used as the default for local preferences, which will not be shared.", + 2) + .RegisterEntry( + "You can configure whether groups are automatically collapsed or expanded, or remove grouping entirely in the settings.", 1) + .RegisterHighlight("Fixed support for model import/export with more than one UV.") + .RegisterEntry("Added some IPC relating to changed items.") + .RegisterEntry("Skeleton and Physics changes should now be identified in Changed Items.") + .RegisterEntry("Item Swaps will now also correctly swap EQP entries of multi-slot pieces.") + .RegisterEntry("Meta edit transmission through IPC should be a lot more efficient than before.") + .RegisterEntry("Fixed an issue with incognito names in some cutscenes.") + .RegisterEntry("Newly extracted mod folders will now try to rename themselves three times before being considered a failure."); + + private static void Add1_3_4_0(Changelog log) + => log.NextVersion("Version 1.3.4.0") + .RegisterHighlight( + "Added HDR functionality to diffuse buffers. This allows more accurate representation of non-standard color values for e.g. skin or hair colors when used with advanced customizations in Glamourer.") + .RegisterEntry( + "This option requires Wait For Plugins On Load to be enabled in Dalamud and to be enabled on start to work. It is on by default but can be turned off.", + 1) + .RegisterHighlight("Added a new option group type: Combining Groups.") + .RegisterEntry( + "A combining group behaves similarly to a multi group for the user, but instead of enabling the different options separately, it results in exactly one option per choice of settings.", + 1) + .RegisterEntry( + "Example: The user sees 2 checkboxes [+25%, +50%], but the 4 different selection states result in +0%, +25%, +50% or +75% if both are toggled on. Every choice of settings can be configured separately by the mod creator.", + 1) + .RegisterEntry( + "Added new functionality to better track copies of the player character in cutscenes if they get forced to specific clothing, like in the Margrat cutscene. Might improve tracking in wedding ceremonies, too, let me know.") + .RegisterEntry("Added a display of the number of selected files and folders to the multi mod selection.") + .RegisterEntry( + "Added cleaning functionality to remove outdated or unused files or backups from the config and mod folders via manual action.") + .RegisterEntry("Updated the Bone and Material limits in the Model Importer.") + .RegisterEntry("Improved handling of IMC and Material files loaded asynchronously.") + .RegisterEntry("Added IPC functionality to query temporary settings.") + .RegisterEntry("Improved some mod setting IPC functions.") + .RegisterEntry("Fixed some path detection issues in the OnScreen tab.") + .RegisterEntry("Fixed some issues with temporary mod settings.") + .RegisterEntry("Fixed issues with IPC calls before the game has finished loading.") + .RegisterEntry("Fixed using the wrong dye channel in the material editor previews.") + .RegisterEntry("Added some log warnings if outdated materials are loaded by the game.") + .RegisterEntry("Added Schemas for some of the json files generated and read by Penumbra to the solution."); + + private static void Add1_3_3_0(Changelog log) + => log.NextVersion("Version 1.3.3.0") + .RegisterHighlight("Added Temporary Settings to collections.") + .RegisterEntry( + "Settings can be manually turned temporary (and turned back) while editing mod settings via right-click context on the mod or buttons in the settings panel.", + 1) + .RegisterEntry( + "This can be used to test mods or changes without saving those changes permanently or having to reinstate the old settings afterwards.", + 1) + .RegisterEntry( + "More importantly, this can be set via IPC by other plugins, allowing Glamourer to only set and reset temporary settings when applying Mod Associations.", + 1) + .RegisterEntry( + "As an extreme example, it would be possible to only enable the consistent mods for your character in the collection, and let Glamourer handle all outfit mods itself via temporary settings only.", + 1) + .RegisterEntry( + "This required some pretty big changes that were in testing for a while now, but nobody talked about it much so it may still have some bugs or usability issues. Let me know!", + 1) + .RegisterHighlight( + "Added an option to automatically select the collection assigned to the current character on login events. This is off by default.") + .RegisterEntry( + "Added partial copying of color tables in material editing via right-click context menu entries on the import buttons.") + .RegisterHighlight( + "Added handling for TMB files cached by the game that should resolve issues of leaky TMBs from animation and VFX mods.") + .RegisterEntry( + "The enabled checkbox, Priority and Inheriting buttons now stick at the top of the Mod Settings panel even when scrolling down for specific settings.") + .RegisterEntry("When creating new mods with Item Swap, the attributed author of the resulting mod was improved.") + .RegisterEntry("Fixed an issue with rings in the On-Screen tab and in the data sent over to other plugins via IPC.") + .RegisterEntry( + "Fixed some issues when writing material files that resulted in technically valid files that still caused some issues with the game for unknown reasons.") + .RegisterEntry("Fixed some ImGui assertions."); + + private static void Add1_3_2_0(Changelog log) + => log.NextVersion("Version 1.3.2.0") + .RegisterHighlight("Added ATCH meta manipulations that allow the composite editing of attachment points across multiple mods.") + .RegisterEntry("Those ATCH manipulations should be shared via Mare Synchronos.", 1) + .RegisterEntry( + "This is an early implementation and might be bug-prone. Let me know of any issues. It was in testing for quite a while without reports.", + 1) + .RegisterEntry( + "Added jumping to identified mods in the On-Screen tab via Control + Right-Click and improved their display slightly.") + .RegisterEntry("Added some right-click context menu copy options in the File Redirections editor for paths.") + .RegisterHighlight("Added the option to change a specific mod's settings via chat commands by using '/penumbra mod settings'.") + .RegisterEntry("Fixed issues with the copy-pasting of meta manipulations.") + .RegisterEntry("Fixed some other issues related to meta manipulations.") + .RegisterEntry( + "Updated available NPC names and fixed an issue with some supposedly invisible characters in names showing in ImGui."); + + + private static void Add1_3_1_0(Changelog log) + => log.NextVersion("Version 1.3.1.0") + .RegisterEntry("Penumbra has been updated for Dalamud API 11 and patch 7.1.") + .RegisterImportant( + "There are some known issues with potential crashes using certain VFX/SFX mods, probably related to sound files.") + .RegisterEntry( + "If you encounter those issues, please report them in the discord and potentially disable the corresponding mods for the time being.", + 1) + .RegisterImportant( + "The modding of .atch files has been disabled. Outdated modded versions of these files cause crashes when loaded.") + .RegisterEntry("A better way for modular modding of .atch files via meta changes will release to the testing branch soonish.", 1) + .RegisterHighlight("Temporary collections (as created by Mare) will now always respect ownership.") + .RegisterEntry( + "This means that you can toggle this setting off if you do not want it, and Mare will still work for minions and mounts of other players.", + 1) + .RegisterEntry( + "The new physics and animation engine files (.kdb and .bnmb) should now be correctly redirected and respect EST changes.") + .RegisterEntry("Fixed issues with EQP entries being labeled wrongly and global EQP not changing all required values for earrings.") + .RegisterEntry("Fixed an issue with global EQP changes of a mod being reset upon reloading the mod.") + .RegisterEntry("Fixed another issue with left rings and mare synchronization / the on-screen tab.") + .RegisterEntry("Maybe fixed some issues with characters appearing in the login screen being misidentified.") + .RegisterEntry("Some improvements for debug visualization have been made."); + + + private static void Add1_3_0_0(Changelog log) + => log.NextVersion("Version 1.3.0.0") + .RegisterHighlight("The textures tab in the advanced editing window can now import and export .tga files.") + .RegisterEntry("BC4 and BC6 textures can now also be imported.", 1) + .RegisterHighlight("Added item swapping from and to the Glasses slot.") + .RegisterEntry("Reworked quite a bit of things around face wear / bonus items. Please let me know if anything broke.", 1) + .RegisterEntry("The import date of a mod is now shown in the Edit Mod tab, and can be reset via button.") + .RegisterEntry("A button to open the file containing local mod data for a mod was also added.", 1) + .RegisterHighlight( + "IMC groups can now be configured to only apply the attribute flags for their entry, and take the other values from the default value.") + .RegisterEntry("This allows keeping the material index of every IMC entry of a group, while setting the attributes.", 1) + .RegisterHighlight("Model Import/Export was fixed and re-enabled (thanks ackwell and ramen).") + .RegisterHighlight("Added a hack to allow bonus items (face wear, glasses) to have VFX.") + .RegisterEntry("Also fixed the hack that allowed accessories to have VFX not working anymore.", 1) + .RegisterHighlight("Added rudimentary options to edit PBD files in the advanced editing window.") + .RegisterEntry("Preparing the advanced editing window for a mod now does not freeze the game until it is ready.") + .RegisterEntry( + "Meta Manipulations in the advanced editing window are now ordered and do not eat into performance as much when drawn.") + .RegisterEntry("Added a button to the advanced editing window to remove all default-valued meta manipulations from a mod") + .RegisterEntry( + "Default-valued manipulations will now also be removed on import from archives and .pmps, not just .ttmps, if not configured otherwise.", + 1) + .RegisterEntry("Checkbox-based mod filters are now tri-state checkboxes instead of two disjoint checkboxes.") + .RegisterEntry("Paths from the resource logger can now be copied.") + .RegisterEntry("Silenced some redundant error logs when updating mods via Heliosphere.") + .RegisterEntry("Added 'Page' to imported mod data for TexTools interop. The value is not used in Penumbra, just persisted.") + .RegisterEntry("Updated all external dependencies.") + .RegisterEntry("Fixed issue with Demihuman IMC entries.") + .RegisterEntry("Fixed some off-by-one errors on the mod import window.") + .RegisterEntry("Fixed a race-condition concerning the first-time creation of mod-meta files.") + .RegisterEntry("Fixed an issue with long mod titles in the merge mods tab.") + .RegisterEntry("A bunch of other miscellaneous fixes."); + + + private static void Add1_2_1_0(Changelog log) + => log.NextVersion("Version 1.2.1.0") + .RegisterHighlight("Penumbra is now released for Dawntrail!") + .RegisterEntry("Mods themselves may have to be updated. TexTools provides options for this.", 1) + .RegisterEntry("For model files, Penumbra provides a rudimentary update function, but prefer using TexTools if possible.", 1) + .RegisterEntry("Other files, like materials and textures, will have to go through TexTools for the moment.", 1) + .RegisterEntry( + "Some outdated mods can be identified by Penumbra and are prevented from loading entirely (specifically shaders, by Ny).", 1) + .RegisterImportant("I am sorry that it took this long, but there was an immense amount of work to be done from the start.") + .RegisterImportant( + "Since Penumbra has been in Testing for quite a while, multitudes of bugs and issues cropped up that needed to be dealt with.", + 1) + .RegisterEntry("There very well may still be a lot of issues, so please report any you find.", 1) + .RegisterImportant("BUT, please make sure that those issues are not caused by outdated mods before reporting them.", 1) + .RegisterEntry( + "This changelog may seem rather short for the timespan, but I omitted hundreds of smaller fixes and the details of getting Penumbra to work in Dawntrail.", + 1) + .RegisterHighlight("The Material Editing tab in the Advanced Editing Window has been heavily improved (by Ny).") + .RegisterEntry( + "Especially for Dawntrail materials using the new shaders, the window provides much more in-depth and user-friendly editing options.", + 1) + .RegisterHighlight("Many advancements regarding modded shaders, and modding bone deformers have been made.") + .RegisterHighlight("IMC groups now allow their options to toggle attributes off that are on in the default entry.") + .RegisterImportant( + "The 'Update Bibo' button was removed. The functionality is redundant since any mods that old need to be updated anyway.") + .RegisterEntry("Clicking the button on modern mods generally caused more harm than benefit.", 1) + .RegisterEntry( + "If you somehow still need to mass-migrate materials in your models, the Material Reassignment tab in Advanced Editing is still available for this.", + 1) + .RegisterEntry("The On-Screen tab was updated and improved and can now display modded actual paths in more useful form.") + .RegisterImportant("Model Import/Export is temporarily disabled until Dawntrail-related changes can be made.") + .RegisterHighlight("You can now change a mods state in any collection from its Collections tab via right-clicking the state.") + .RegisterHighlight("Items changed in a mod now sort before other items in the Item Swap tab, and are highlighted.") + .RegisterEntry("Path handling was improved in regards to case-sensitivity.") + .RegisterEntry("Fixed an issue with negative search matching on folders with no matches") + .RegisterEntry("Mod option groups on the same priority are now applied in reverse index order. (1.2.0.12)") + .RegisterEntry("Fixed the display of missing files in the Advanced Editing Window's header. (1.2.0.8)") + .RegisterEntry( + "Fixed some, but not all soft-locks that occur when your character gets redrawn while fishing. Just do not do that. (1.2.0.7)") + .RegisterEntry("Improved handling of invalid Offhand IMC files for certain jobs. (1.2.0.6)") + .RegisterEntry("Added automatic reduplication for files in the UI category, as they cause crashes when not unique. (1.2.0.5)") + .RegisterEntry("The mod import popup can now be closed by clicking outside of it, if it is finished. (1.2.0.5)") + .RegisterEntry("Fixed an issue with Mod Normalization skipping the default option. (1.2.0.5)") + .RegisterEntry("Improved the Support Info output. (1.1.1.5)") + .RegisterEntry("Reworked the handling of Meta Manipulations entirely. (1.1.1.3)") + .RegisterEntry("Added a configuration option to disable showing mods in the character lobby and at the aesthetician. (1.1.1.1)") + .RegisterEntry("Fixed an issue with the AddMods API and the root directory. (1.1.1.2)") + .RegisterEntry("Fixed an issue with the Mod Merger file lookup and casing. (1.1.1.2)") + .RegisterEntry("Fixed an issue with file saving not happening when merging mods or swapping items in some cases. (1.1.1.2)"); + + private static void Add1_1_1_0(Changelog log) + => log.NextVersion("Version 1.1.1.0") + .RegisterHighlight("Filtering for mods is now tokenized and can filter for multiple things at once, or exclude specific things.") + .RegisterEntry("Hover over the filter to see the new available options in the tooltip.", 1) + .RegisterEntry("Be aware that the tokenization changed the prior behavior slightly.", 1) + .RegisterEntry("This is open to improvements, if you have any ideas, let me know!", 1) + .RegisterHighlight("Added initial identification of characters in the login-screen by name.") + .RegisterEntry( + "Those characters can not be redrawn and re-use some things, so this may not always behave as expected, but should work in general. Let me know if you encounter edge cases!", + 1) + .RegisterEntry("Added functionality for IMC groups to apply to all variants for a model instead of a specific one.") + .RegisterEntry("Improved the resource tree view with filters and incognito mode. (by Ny)") + .RegisterEntry("Added a tooltip to the global EQP condition.") + .RegisterEntry("Fixed the new worlds not being identified correctly because Square Enix could not be bothered to turn them public.") + .RegisterEntry("Fixed model import getting stuck when doing weight adjustments. (by ackwell)") + .RegisterEntry("Fixed an issue with dye previews in the material editor not applying.") + .RegisterEntry("Fixed an issue with collections not saving on renames.") + .RegisterEntry("Fixed an issue parsing collections with settings set to negative values, which should now be set to 0.") + .RegisterEntry("Fixed an issue with the accessory VFX addition.") + .RegisterEntry("Fixed an issue with GMP animation type entries.") + .RegisterEntry("Fixed another issue with the mod merger.") + .RegisterEntry("Fixed an issue with IMC groups and IPC.") + .RegisterEntry("Fixed some issues with the capitalization of the root directory.") + .RegisterEntry("Fixed IMC attribute tooltips not appearing for disabled checkboxes.") + .RegisterEntry("Added GetChangedItems IPC for single mods. (1.1.0.2)") + .RegisterEntry("Fixed an issue with creating unnamed collections. (1.1.0.2)") + .RegisterEntry("Fixed an issue with the mod merger. (1.1.0.2)") + .RegisterEntry("Fixed the global EQP entry for rings checking for bracelets instead of rings. (1.1.0.2)") + .RegisterEntry("Fixed an issue with newly created collections not being added to the collection list. (1.1.0.1)"); + + private static void Add1_1_0_0(Changelog log) + => log.NextVersion("Version 1.1.0.0") + .RegisterImportant( + "This update comes, again, with a lot of very heavy backend changes (collections and groups) and thus may introduce new issues.") + .RegisterEntry("Updated to .net8 and XIV 6.58, using some new framework facilities to improve performance and stability.") + .RegisterHighlight( + "Added an experimental crash handler that is supposed to write a Penumbra.log file when the game crashes, containing Penumbra-specific information.") + .RegisterEntry("This is disabled by default. It can be enabled in Advanced Settings.", 1) + .RegisterHighlight("Collections now have associated GUIDs as identifiers instead of their names, so they can now be renamed.") + .RegisterEntry("Migrating those collections may introduce issues, please let me know as soon as possible if you encounter any.", 1) + .RegisterEntry("A permanent (non-rolling) backup should be created before the migration in case of any issues.", 1) + .RegisterHighlight( + "Added predefined tags that can be setup in the Settings tab and can be more easily applied or removed from mods. (by DZD)") + .RegisterHighlight( + "A total rework of how options and groups are handled internally, and introduction of the first new group type, the IMC Group.") + .RegisterEntry( + "Mod Creators can add a IMC Group to their mod that controls a single IMC Manipulation, so they can provide options for the separate attributes for it.", + 1) + .RegisterEntry( + "This makes it a lot easier to have combined options: No need for 'A', 'B' and 'AB', you can just define 'A' and 'B' and skip their combinations", + 1) + .RegisterHighlight("A new type of Meta Manipulation was added, 'Global EQP Manipulation'.") + .RegisterEntry( + "Global EQP Manipulations allow accessories to make other equipment pieces not hide them, e.g. whenever a character is wearing a specific Bracelet, neither body nor hand items will ever hide bracelets.", + 1) + .RegisterEntry( + "This can be used if something like a jacket or a stole is put onto an accessory to prevent it from being hidden in general.", + 1) + .RegisterEntry( + "The first empty option in a single-select option group imported from a TTMP will now keep its location instead of being moved to the first option.") + .RegisterEntry("Further empty options are still removed.", 1) + .RegisterHighlight( + "Added a field to rename mods directly from the mod selector context menu, instead of moving them in the filesystem.") + .RegisterEntry("You can choose which rename field (none, either one or both) to display in the settings.", 1) + .RegisterEntry("Added the characterglass.shpk shader file to special shader treatment to fix issues when replacing it. (By Ny)") + .RegisterEntry("Made it more obvious if a user has not set their root directory yet.") + .RegisterEntry( + "You can now paste your current clipboard text into the mod selector filter with a simple right-click as long as it is not focused.") + .RegisterHighlight( + "Added the option to display VFX for accessories if added via IMC edits, which the game does not do inherently (by Ocealot).") + .RegisterEntry("Added support for reading and writing the new material and model file formats from the benchmark.") + .RegisterEntry( + "Added the option to hide Machinist Offhands from the Changed Items tabs (because any change to it changes ALL of them), which is on by default.") + .RegisterEntry("Removed the auto-generated descriptions for newly created groups in Penumbra.") + .RegisterEntry( + "Made some improvements to the Advanced Editing window, for example a much better and more performant Hex Viewer for unstructured data was added.") + .RegisterEntry("Various improvements to model import/export by ackwell (throughout all patches).") + .RegisterEntry( + "Hovering over meta manipulations in other options in the advanced editing window now shows a list of those options.") + .RegisterEntry("Reworked the API and IPC structure heavily.") + .RegisterImportant("This means some plugins interacting with Penumbra may not work correctly until they update.", 1) + .RegisterEntry("Worked around the UI IPC possibly displacing all settings when the drawn additions became too big.") + .RegisterEntry("Fixed an issue where reloading a mod did not ensure settings for that mod being correct afterwards.") + .RegisterEntry("Fixed some issues with the file sizes of compressed files.") + .RegisterEntry("Fixed an issue with merging and deduplicating mods.") + .RegisterEntry("Fixed a crash when scanning for mods without access rights to the folder.") + .RegisterEntry( + "Made plugin conform to Dalamud requirements by adding a punchline and another button to open the menu from the installer.") + .RegisterEntry("Added an option to automatically redraw the player character when saving files. (1.0.0.8)") + .RegisterEntry("Fixed issue with manipulating mods not triggering some events. (1.0.0.7)") + .RegisterEntry("Fixed issue with temporary mods not triggering some events. (1.0.0.6)") + .RegisterEntry("Fixed issue when renaming mods while the advanced edit window is open. (1.0.0.6)") + .RegisterEntry("Fixed issue with empty option groups. (1.0.0.5)") + .RegisterEntry("Fixed issues with cutscene character identification. (1.0.0.4)") + .RegisterEntry("Added locale environment information to support info. (1.0.0.4)") + .RegisterEntry("Fixed an issue with copied mod settings in IPC missing unused settings. (1.0.0.3)"); + + private static void Add1_0_0_0(Changelog log) + => log.NextVersion("Version 1.0.0.0") + .RegisterHighlight("Mods in the mod selector can now be filtered by changed item categories.") + .RegisterHighlight("Model Editing options in the Advanced Editing Window have been greatly extended (by ackwell):") + .RegisterEntry("Attributes and referenced materials can now be set per mesh.", 1) + .RegisterEntry("Model files (.mdl) can now be exported to the well-established glTF format, which can be imported e.g. by Blender.", + 1) + .RegisterEntry("glTF files can also be imported back to a .mdl file.", 1) + .RegisterHighlight( + "Model Export and Import are a work in progress and may encounter issues, not support all cases or produce wrong results, please let us know!", + 1) + .RegisterEntry("The last selected mod and the open/close state of the Advanced Editing Window are now stored across launches.") + .RegisterEntry("Footsteps of certain mounts will now be associated to collections correctly.") + .RegisterEntry("Save-in-Place in the texture tab now requires the configurable modifier.") + .RegisterEntry("Updated OtterTex to a newer version of DirectXTex.") + .RegisterEntry("Fixed an issue with horizontal scrolling if a mod title was very long.") + .RegisterEntry("Fixed an issue with the mod panels header not updating its data when the selected mod updates.") + .RegisterEntry("Fixed some issues with EQDP files for invalid characters.") + .RegisterEntry("Fixed an issue with the FileDialog being drawn twice in certain situations.") + .RegisterEntry( + "A lot of backend changes that should not have an effect on users, but may cause issues if something got messed up."); + + private static void Add8_3_0(Changelog log) + => log.NextVersion("Version 0.8.3.0") + .RegisterHighlight("Improved the UI for the On-Screen tabs with highlighting of used paths, filtering and more selections. (by Ny)") + .RegisterEntry( + "Added an option to replace non-ASCII symbols with underscores for folder paths on mod import since this causes problems on some WINE systems. This option is off by default.") + .RegisterEntry( + "Added support for the Changed Item Icons to load modded icons, but this depends on a not-yet-released Dalamud update.") + .RegisterEntry( + "Penumbra should no longer redraw characters while they are fishing, but wait for them to reel in, because that could cause soft-locks. This may cause other issues, but I have not found any.") + .RegisterEntry( + "Hopefully fixed a bug on mod import where files were being read while they were still saving, causing Penumbra to create wrong options.") + .RegisterEntry("Fixed a few display issues.") + .RegisterEntry("Added some IPC functionality for Xande. (by Asriel)"); + + private static void Add8_2_0(Changelog log) + => log.NextVersion("Version 0.8.2.0") + .RegisterHighlight( + "You can now redraw indoor furniture. This may not be entirely stable and might break some customizable decoration like wallpapered walls.") + .RegisterEntry("The redraw bar has been slightly improved and disables currently unavailable redraw commands now.") + .RegisterEntry("Redrawing players now also actively redraws any accessories they are using.") + .RegisterEntry("Power-users can now redraw game objects by index via chat command.") + .RegisterHighlight( + "You can now filter for the special case 'None' for filters where that makes sense (like Tags or Changed Items).") + .RegisterHighlight("When selecting multiple mods, you can now add or remove tags from them at once.") + .RegisterEntry( + "The dye template combo in advanced material editing now displays the currently selected dye as it would appear with the respective template.") + .RegisterEntry("The On-Screen tab and associated functionality has been heavily improved by Ny.") + .RegisterEntry("Fixed an issue with the changed item identification for left rings.") + .RegisterEntry("Updated BNPC data.") + .RegisterEntry( + "Some configuration like the currently selected tab states are now stored in a separate file that is not backed up and saved less often.") + .RegisterEntry("Added option to open the Penumbra main window at game start independently of Debug Mode.") + .RegisterEntry("Fixed some tooltips in the advanced editing window. (0.8.1.8)") + .RegisterEntry("Fixed clicking to linked changed items not working. (0.8.1.8)") + .RegisterEntry("Support correct handling of offhand-parts for two-handed weapons for changed items. (0.8.1.7)") + .RegisterEntry("Fixed renaming the mod directory not updating paths in the advanced window. (0.8.1.6)") + .RegisterEntry("Fixed portraits not respecting your card settings. (0.8.1.6)") + .RegisterEntry("Added ReverseResolvePlayerPathsAsync for IPC. (0.8.1.6)") + .RegisterEntry("Expanded the tooltip for Wait for Plugins on Startup. (0.8.1.5)") + .RegisterEntry("Disabled window sounds for some popup windows. (0.8.1.5)") + .RegisterEntry("Added support for middle-clicking mods to enable/disable them. (0.8.1.5)"); + + private static void Add8_1_2(Changelog log) + => log.NextVersion("Version 0.8.1.2") + .RegisterEntry("Fixed an issue keeping mods selected after their deletion.") + .RegisterEntry("Maybe fixed an issue causing individual assignments to get lost on game start."); + + private static void Add8_1_1(Changelog log) + => log.NextVersion("Version 0.8.1.1") + .RegisterImportant( + "Updated for 6.5 - Square Enix shuffled around a lot of things this update, so some things still might not work but have not been noticed yet. Please report any issues.") + .RegisterEntry("Added support for chat commands to affect multiple individuals matching the supplied string at once.") + .RegisterEntry( + "Improved messaging: many warnings or errors appearing will stay a little longer and can now be looked at in a Messages tab (visible only if there have been any).") + .RegisterEntry("Fixed an issue with leading or trailing spaces when renaming mods."); + + + private static void Add8_0_0(Changelog log) + => log.NextVersion("Version 0.8.0.0") + .RegisterEntry( + "Penumbra now uses Windows' transparent file system compression by default (on Windows systems). You can disable this functionality in the settings.") + .RegisterImportant("You can retroactively compress your existing mods in the settings via the press of a button, too.", 1) + .RegisterEntry( + "In our tests, this not only was able to reduce storage space by 30-60%, it even decreased loading times since less I/O had to take place.", + 1) + .RegisterEntry("Added emotes to changed item identification.") + .RegisterEntry( + "Added quick select buttons to switch to the current interface collection or the collection applying to the current player character in the mods tab, reworked their text and tooltips slightly.") + .RegisterHighlight("Drag & Drop of multiple mods and folders at once is now supported by holding Control while clicking them.") + .RegisterEntry("You can now disable conflicting mods from the Conflicts panel via Control + Right-click.") + .RegisterEntry("Added checks for your deletion-modifiers for restoring mods from backups or deleting backups.") + .RegisterEntry( + "Penumbra now should automatically try to restore your custom sort order (mod folders) and your active collections from backups if they fail to load. No guarantees though.") + .RegisterEntry("The resource watcher now displays a column providing load state information of resources.") + .RegisterEntry( + "Custom RSP scaling outside of the collection assigned to Base should now be respected for emotes that adjust your stance on height differences.") + .RegisterEntry( + "Mods that replace the skin shaders will not cause visual glitches like loss of head shadows or Free Company crest tattoos anymore (by Ny).") + .RegisterEntry("The Material editor has been improved (by Ny):") + .RegisterHighlight( + "Live-Preview for materials yourself or entities owned by you are currently using, so you can see color set edits in real time.", + 1) + .RegisterEntry( + "Colors on the color table of a material can be highlighted on yourself or entities owned by you by hovering a button.", 1) + .RegisterEntry("The color table has improved color accuracy.", 1) + .RegisterEntry("Materials with non-dyable color tables can be made dyable, and vice-versa.", 1) + .RegisterEntry("The 'Advanced Shader Resources' section has been split apart into dedicated sections.", 1) + .RegisterEntry( + "Addition and removal of shader keys, textures, constants and a color table has been automated following shader requirements and can not be done manually anymore.", + 1) + .RegisterEntry( + "Plain English names and tooltips can now be displayed instead of hexadecimal identifiers or code names by providing dev-kit files installed via certain mods.", + 1) + .RegisterEntry("The Texture editor has been improved (by Ny):") + .RegisterHighlight("The overlay texture can now be combined in several ways and automatically resized to match the input texture.", + 1) + .RegisterEntry("New color manipulation options have been added.", 1) + .RegisterEntry("Modifications to the selected texture can now be saved in-place.", 1) + .RegisterEntry("The On-Screen tab has been improved (by Ny):") + .RegisterEntry("The character list will load more quickly.", 1) + .RegisterEntry("It is now able to deal with characters under transformation effects.", 1) + .RegisterEntry( + "The headers are now color-coded to distinguish between you and other players, and between NPCs that are handled locally or on the server. Colors are customizable.", + 1) + .RegisterEntry("More file types will be recognized and shown.", 1) + .RegisterEntry("The actual paths for game files will be displayed and copied correctly.", 1) + .RegisterEntry("The Shader editor has been improved (by Ny):") + .RegisterEntry( + "New sections 'Shader Resources' and 'Shader Selection' have been added, expanding on some data that was in 'Further Content' before.", + 1) + .RegisterEntry("A fail-safe mode for shader decompilation on platforms that do not fully support it has been added.", 1) + .RegisterEntry("Fixed invalid game paths generated for variants of customizations.") + .RegisterEntry("Lots of minor improvements across the codebase.") + .RegisterEntry("Some unnamed mounts were made available for actor identification. (0.7.3.2)"); + + private static void Add7_3_0(Changelog log) + => log.NextVersion("Version 0.7.3.0") + .RegisterEntry( + "Added the ability to drag and drop mod files from external sources (like a file explorer or browser) into Penumbras mod selector to import them.") + .RegisterEntry("You can also drag and drop texture files into the textures tab of the Advanced Editing Window.", 1) + .RegisterEntry( + "Added a priority display to the mod selector using the currently selected collections priorities. This can be hidden in settings.") + .RegisterEntry("Added IPC for texture conversion, improved texture handling backend and threading.") + .RegisterEntry( + "Added Dalamud Substitution so that other plugins can more easily use replaced icons from Penumbras Interface collection when using Dalamuds new Texture Provider.") + .RegisterEntry("Added a filter to texture selection combos in the textures tab of the Advanced Editing Window.") + .RegisterEntry( + "Changed behaviour when failing to load group JSON files for mods - the pre-existing but failing files are now backed up before being deleted or overwritten.") + .RegisterEntry("Further backend changes, mostly relating to the Glamourer rework.") + .RegisterEntry("Fixed an issue with modded decals not loading correctly when used with the Glamourer rework.") + .RegisterEntry("Fixed missing scaling with UI Scale for some combos.") + .RegisterEntry("Updated the used version of SharpCompress to deal with Zip64 correctly.") + .RegisterEntry("Added a toggle to not display the Changed Item categories in settings (0.7.2.2).") + .RegisterEntry("Many backend changes relating to the Glamourer rework (0.7.2.2).") + .RegisterEntry("Fixed an issue when multiple options in the same option group had the same label (0.7.2.2).") + .RegisterEntry("Fixed an issue with a GPose condition breaking animation and vfx modding in GPose (0.7.2.1).") + .RegisterEntry("Fixed some handling of decals (0.7.2.1)."); + + private static void Add7_2_0(Changelog log) + => log.NextVersion("Version 0.7.2.0") + .RegisterEntry( + "Added Changed Item Categories and icons that can filter for specific types of Changed Items, in the Changed Items Tab as well as in the Changed Items panel for specific mods..") + .RegisterEntry( + "Icons at the top can be clicked to filter, as well as right-clicked to open a context menu with the option to inverse-filter for them", + 1) + .RegisterEntry("There is also an ALL button that can be toggled.", 1) + .RegisterEntry( + "Modded files in the Font category now resolve from the Interface assignment instead of the base assignment, despite not technically being in the UI category.") + .RegisterEntry( + "Timeline files will no longer be associated with specific characters in cutscenes, since there is no way to correctly do this, and it could cause crashes if IVCS-requiring animations were used on characters without IVCS.") + .RegisterEntry("File deletion in the Advanced Editing Window now also checks for your configured deletion key combo.") + .RegisterEntry( + "The Texture tab in the Advanced Editing Window now has some quick convert buttons to just convert the selected texture to a different format in-place.") + .RegisterEntry( + "These buttons only appear if only one texture is selected on the left side, it is not otherwise manipulated, and the texture is a .tex file.", + 1) + .RegisterEntry("The text part of the mod filter in the mod selector now also resets when right-clicking the drop-down arrow.") + .RegisterEntry("The Dissolve Folder option in the mod selector context menu has been moved to the bottom.") + .RegisterEntry("Somewhat improved IMC handling to prevent some issues.") + .RegisterEntry( + "Improved the handling of mod renames on mods with default-search names to correctly rename their search-name in (hopefully) all cases too.") + .RegisterEntry("A lot of backend improvements and changes related to the pending Glamourer rework.") + .RegisterEntry("Fixed an issue where the displayed active collection count in the support info was wrong.") + .RegisterEntry( + "Fixed an issue with created directories dealing badly with non-standard whitespace characters like half-width or non-breaking spaces.") + .RegisterEntry("Fixed an issue with unknown animation and vfx edits not being recognized correctly.") + .RegisterEntry("Fixed an issue where changing option descriptions to be empty was not working correctly.") + .RegisterEntry("Fixed an issue with texture names in the resource tree of the On-Screen views.") + .RegisterEntry("Fixed a bug where the game would crash when drawing folders in the mod selector that contained a '%' symbol.") + .RegisterEntry("Fixed an issue with parallel algorithms obtaining the wrong number of available cores.") + .RegisterEntry("Updated the available selection of Battle NPC names.") + .RegisterEntry("A typo in the 0.7.1.2 Changlog has been fixed.") + .RegisterEntry("Added the Sea of Stars as accepted repository. (0.7.1.4)") + .RegisterEntry("Fixed an issue with collections sometimes not loading correctly, and IMC files not applying correctly. (0.7.1.3)"); + + + private static void Add7_1_2(Changelog log) + => log.NextVersion("Version 0.7.1.2") + .RegisterEntry( + "Changed threaded handling of collection caches. Maybe this fixes the startup problems some people are experiencing.") + .RegisterEntry( + "This is just testing and may not be the solution, or may even make things worse. Sorry if I have to put out multiple small patches again to get this right.", + 1) + .RegisterEntry("Fixed Penumbra failing to load if the main configuration file is corrupted.") + .RegisterEntry("Some miscellaneous small bug fixes.") + .RegisterEntry("Slight changes in behaviour for deduplicator/normalizer, mostly backend.") + .RegisterEntry("A typo in the 0.7.1.0 Changelog has been fixed.") + .RegisterEntry("Fixed left rings not being valid for IMC entries after validation. (7.1.1)") + .RegisterEntry( + "Relaxed the scaling restrictions for RSP scaling values to go from 0.01 to 512.0 instead of the prior upper limit of 8.0, in interface as well as validation, to better support the fetish community. (7.1.1)"); + + private static void Add7_1_0(Changelog log) + => log.NextVersion("Version 0.7.1.0") + .RegisterEntry("Updated for patch 6.4 - there may be some oversights on edge cases, but I could not find any issues myself.") + .RegisterImportant( + "This update changed some Dragoon skills that were moving the player character before to not do that anymore. If you have any mods that applied to those skills, please make sure that they do not contain any redirections for .tmb files. If skills that should no longer move your character still do that for some reason, this is detectable by the server.", + 1) + .RegisterEntry( + "Added a Mod Merging tab in the Advanced Editing Window. This can help you merge multiple mods to one, or split off specific options from an existing mod into a new mod.") + .RegisterEntry( + "Added advanced options to configure the minimum allowed window size for the main window (to reduce it). This is not quite supported and may look bad, so only use it if you really need smaller windows.") + .RegisterEntry("The last tab selected in the main window is now saved and re-used when relaunching Penumbra.") + .RegisterEntry("Added a hook to correctly associate some sounds that are played while weapons are drawn.") + .RegisterEntry("Added a hook to correctly associate sounds that are played while dismounting.") + .RegisterEntry("A hook to associate weapon-associated VFX was expanded to work in more cases.") + .RegisterEntry("TMB resources now use a collection prefix to prevent retained state in some cases.") + .RegisterEntry("Improved startup times a bit.") + .RegisterEntry("Right-Click context menus for collections are now also ordered by name.") + .RegisterEntry("Advanced Editing tabs have been reordered and renamed slightly.") + .RegisterEntry("Added some validation of metadata changes to prevent stalling on load of bad IMC edits.") + .RegisterEntry("Fixed an issue where collections could lose their configured inheritances during startup in some cases.") + .RegisterEntry("Fixed some bugs when mods were removed from collection caches.") + .RegisterEntry("Fixed some bugs with IMC files not correctly reverting to default values in some cases.") + .RegisterEntry("Fixed an issue with the mod import popup not appearing (0.7.0.10)") + .RegisterEntry("Fixed an issue with the file selectors not always opening at the expected locations. (0.7.0.7)") + .RegisterEntry("Fixed some cache handling issues. (0.7.0.5 - 0.7.0.10)") + .RegisterEntry("Fixed an issue with multiple collection context menus appearing for some identifiers (0.7.0.5)") + .RegisterEntry( + "Fixed an issue where the Update Bibo button did only work if the Advanced Editing window was opened before. (0.7.0.5)"); + + private static void Add7_0_4(Changelog log) + => log.NextVersion("Version 0.7.0.4") + .RegisterEntry("Added options to the bulktag slash command to check all/local/mod tags specifically.") + .RegisterEntry("Possibly improved handling of the delayed loading of individual assignments.") + .RegisterEntry("Fixed a bug that caused metadata edits to apply even though mods were disabled.") + .RegisterEntry("Fixed a bug that prevented material reassignments from working.") + .RegisterEntry("Reverted trimming of whitespace for relative paths to only trim the end, not the start. (0.7.0.3)") + .RegisterEntry("Fixed a bug that caused an integer overflow on textures of high dimensions. (0.7.0.3)") + .RegisterEntry("Fixed a bug that caused Penumbra to enter invalid state when deleting mods. (0.7.0.2)") + .RegisterEntry("Added Notification on invalid collection names. (0.7.0.2)"); + + private static void Add7_0_1(Changelog log) + => log.NextVersion("Version 0.7.0.1") + .RegisterEntry("Individual assignments can again be re-ordered by drag-and-dropping them.") + .RegisterEntry("Relax the restriction of a maximum of 32 characters for collection names to 64 characters.") + .RegisterEntry("Fixed a bug that showed the Your Character collection as redundant even if it was not.") + .RegisterEntry("Fixed a bug that caused some required collection caches to not be built on startup and thus mods not to apply.") + .RegisterEntry("Fixed a bug that showed the current collection as unused even if it was used."); + + private static void Add7_0_0(Changelog log) + => log.NextVersion("Version 0.7.0.0") + .RegisterImportant( + "The entire backend was reworked (this is still in progress). While this does not come with a lot of functionality changes, basically every file and functionality was touched.") + .RegisterEntry( + "This may have (re-)introduced some bugs that have not yet been noticed despite a long testing period - there are not many users of the testing branch.", + 1) + .RegisterEntry("If you encounter any - but especially breaking or lossy - bugs, please report them immediately.", 1) + .RegisterEntry("This also fixed or improved numerous bugs and issues that will not be listed here.", 1) + .RegisterEntry("GitHub currently reports 321 changed files with 34541 additions and 28464 deletions.", 1) + .RegisterEntry("Added Notifications on many failures that previously only wrote to log.") + .RegisterEntry("Reworked the Collections Tab to hopefully be much more intuitive. It should be self-explanatory now.") + .RegisterEntry("The tutorial was adapted to the new window, if you are unsure, maybe try restarting it.", 1) + .RegisterEntry( + "You can now toggle an incognito mode in the collection window so it shows shortened names of collections and players.", 1) + .RegisterEntry( + "You can get an overview about the current usage of a selected collection and its active and unused mod settings in the Collection Details panel.", + 1) + .RegisterEntry("The currently selected collection is now highlighted in green (default, configurable) in multiple places.", 1) + .RegisterEntry( + "Mods now have a 'Collections' panel in the Mod Panel containing an overview about usage of the mod in all collections.") + .RegisterEntry("The 'Changed Items' and 'Effective Changes' tab now contain a collection selector.") + .RegisterEntry("Added the On-Screen tab to find what files a specific character is actually using (by Ny).") + .RegisterEntry("Added 3 Quick Move folders in the mod selector that can be setup in context menus for easier cleanup.") + .RegisterEntry( + "Added handling for certain animation files for mounts and fashion accessories to correctly associate them to players.") + .RegisterEntry("The file selectors in the Advanced Mod Editing Window now use filterable combos.") + .RegisterEntry( + "The Advanced Mod Editing Window now shows the number of meta edits and file swaps in unselected options and highlights the option selector.") + .RegisterEntry("Added API/IPC to start unpacking and installing mods from external tools (by Sebastina).") + .RegisterEntry("Hidden files and folders are now ignored for unused files in Advanced Mod Editing (by myr)") + .RegisterEntry("Paths in mods are now automatically trimmed of whitespace on loading.") + .RegisterEntry("Fixed double 'by' in mod author display (by Caraxi).") + .RegisterEntry("Fixed a crash when trying to obtain names from the game data.") + .RegisterEntry("Fixed some issues with tutorial windows.") + .RegisterEntry("Fixed some bugs in the Resource Logger.") + .RegisterEntry("Fixed Button Sizing for collapsible groups and several related bugs.") + .RegisterEntry("Fixed issue with mods with default settings other than 0.") + .RegisterEntry("Fixed issue with commands not registering on startup. (0.6.6.5)") + .RegisterEntry("Improved Startup Times and Time Tracking. (0.6.6.4)") + .RegisterEntry("Add Item Swapping between different types of Accessories and Hats. (0.6.6.4)") + .RegisterEntry("Fixed bugs with assignment of temporary collections and their deletion. (0.6.6.4)") + .RegisterEntry("Fixed bugs with new file loading mechanism. (0.6.6.2, 0.6.6.3)") + .RegisterEntry("Added API/IPC to open and close the main window and select specific tabs and mods. (0.6.6.2)"); + + private static void Add6_6_1(Changelog log) + => log.NextVersion("Version 0.6.6.1") + .RegisterEntry("Added an option to make successful chat commands not print their success confirmations to chat.") + .RegisterEntry("Fixed an issue with migration of old mods not working anymore (fixes Material UI problems).") + .RegisterEntry("Fixed some issues with using the Assign Current Player and Assign Current Target buttons."); + + private static void Add6_6_0(Changelog log) + => log.NextVersion("Version 0.6.6.0") + .RegisterEntry( + "Added new Collection Assignment Groups for Children NPC and Elderly NPC. Those take precedence before any non-individual assignments for any NPC using a child- or elderly model respectively.") + .RegisterEntry( + "Added an option to display Single Selection Groups as a group of radio buttons similar to Multi Selection Groups, when the number of available options is below the specified value. Default value is 2.") + .RegisterEntry("Added a button in option groups to collapse the option list if it has more than 5 available options.") + .RegisterEntry( + "Penumbra now circumvents the games inability to read files at paths longer than 260 UTF16 characters and can also deal with generic unicode symbols in paths.") + .RegisterEntry( + "This means that Penumbra should no longer cause issues when files become too long or when there is a non-ASCII character in them.", + 1) + .RegisterEntry( + "Shorter paths are still better, so restrictions on the root directory have not been relaxed. Mod names should no longer replace non-ASCII symbols on import though.", + 1) + .RegisterEntry( + "Resource logging has been relegated to its own tab with better filtering. Please do not keep resource logging on arbitrarily or set a low record limit if you do, otherwise this eats a lot of performance and memory after a while.") + .RegisterEntry( + "Added a lot of facilities to edit the shader part of .mtrl files and .shpk files themselves in the Advanced Editing Tab (Thanks Ny and aers).") + .RegisterEntry("Added splitting of Multi Selection Groups with too many options when importing .pmp files or adding mods via IPC.") + .RegisterEntry("Discovery, Reloading and Unloading of a specified mod is now possible via HTTP API (Thanks Sebastina).") + .RegisterEntry("Cleaned up the HTTP API somewhat, removed currently useless options.") + .RegisterEntry("Fixed an issue when extracting some textures.") + .RegisterEntry("Fixed an issue with mannequins inheriting individual assignments for the current player when using ownership.") + .RegisterEntry( + "Fixed an issue with the resolving of .phyb and .sklb files for Item Swaps of head or body items with an EST entry but no unique racial model."); + + private static void Add6_5_2(Changelog log) + => log.NextVersion("Version 0.6.5.2") + .RegisterEntry("Updated for game version 6.31 Hotfix.") + .RegisterEntry( + "Added option-specific descriptions for mods, instead of having just descriptions for groups of options. (Thanks Caraxi!)") + .RegisterEntry("Those are now accurately parsed from TTMPs, too.", 1) + .RegisterEntry("Improved launch times somewhat through parallelization of some tasks.") + .RegisterEntry( + "Added some performance tracking for start-up durations and for real time data to Release builds. They can be seen and enabled in the Debug tab when Debug Mode is enabled.") + .RegisterEntry("Fixed an issue with IMC changes and Mare Synchronos interoperability.") + .RegisterEntry("Fixed an issue with housing mannequins crashing the game when resource logging was enabled.") + .RegisterEntry("Fixed an issue generating Mip Maps for texture import on Wine."); + + private static void Add6_5_0(Changelog log) + => log.NextVersion("Version 0.6.5.0") + .RegisterEntry("Fixed an issue with Item Swaps not using applied IMC changes in some cases.") + .RegisterEntry("Improved error message on texture import when failing to create mip maps (slightly).") + .RegisterEntry("Tried to fix duty party banner identification again, also for the recommendation window this time.") + .RegisterEntry("Added batched IPC to improve Mare performance."); + + private static void Add6_4_0(Changelog log) + => log.NextVersion("Version 0.6.4.0") + .RegisterEntry("Fixed an issue with the identification of actors in the duty group portrait.") + .RegisterEntry("Fixed some issues with wrongly cached actors and resources.") + .RegisterEntry("Fixed animation handling after redraws (notably for PLD idle animations with a shield equipped).") + .RegisterEntry("Fixed an issue with collection listing API skipping one collection.") + .RegisterEntry( + "Fixed an issue with BGM files being sometimes loaded from other collections than the base collection, causing crashes.") + .RegisterEntry( + "Also distinguished file resolving for different file categories (improving performance) and disabled resolving for script files entirely.", + 1) + .RegisterEntry("Some miscellaneous backend changes due to the Glamourer rework."); + + private static void Add6_3_0(Changelog log) + => log.NextVersion("Version 0.6.3.0") + .RegisterEntry("Add an Assign Current Target button for individual assignments") + .RegisterEntry("Try identifying all banner actors correctly for PvE duties, Crystalline Conflict and Mahjong.") + .RegisterEntry("Please let me know if this does not work for anything except identical twins.", 1) + .RegisterEntry("Add handling for the 3 new screen actors (now 8 total, for PvE dutie portraits).") + .RegisterEntry("Update the Battle NPC name database for 6.3.") + .RegisterEntry("Added API/IPC functions to obtain or set group or individual collections.") + .RegisterEntry("Maybe fix a problem with textures sometimes not loading from their corresponding collection.") + .RegisterEntry("Another try to fix a problem with the collection selectors breaking state.") + .RegisterEntry("Fix a problem identifying companions.") + .RegisterEntry("Fix a problem when deleting collections assigned to Groups.") + .RegisterEntry( + "Fix a problem when using the Assign Currently Played Character button and then logging onto a different character without restarting in between.") + .RegisterEntry("Some miscellaneous backend changes."); + + private static void Add6_2_0(Changelog log) + => log.NextVersion("Version 0.6.2.0") + .RegisterEntry("Update Penumbra for .net7, Dalamud API 8 and patch 6.3.") + .RegisterEntry("Add a Bulktag chat command to toggle all mods with specific tags. (by SoyaX)") + .RegisterEntry("Add placeholder options for setting individual collections via chat command.") + .RegisterEntry("Add toggles to swap left and/or right rings separately for ring item swap.") + .RegisterEntry("Add handling for looping sound effects caused by animations in non-base collections.") + .RegisterEntry("Add an option to not use any mods at all in the Inspect/Try-On window.") + .RegisterEntry("Add handling for Mahjong actors.") + .RegisterEntry("Improve hint text for File Swaps in Advanced Editing, also inverted file swap display order.") + .RegisterEntry("Fix a problem where the collection selectors could get desynchronized after adding or deleting collections.") + .RegisterEntry("Fix a problem that could cause setting state to get desynchronized.") + .RegisterEntry("Fix an oversight where some special screen actors did not actually respect the settings made for them.") + .RegisterEntry("Add collection and associated game object to Full Resource Logging.") + .RegisterEntry("Add performance tracking for DEBUG-compiled versions (i.e. testing only).") + .RegisterEntry("Add some information to .mdl display and fix not respecting padding when reading them. (0.6.1.3)") + .RegisterEntry("Fix association of some vfx game objects. (0.6.1.3)") + .RegisterEntry("Stop forcing AVFX files to load synchronously. (0.6.1.3)") + .RegisterEntry("Fix an issue when incorporating deduplicated meta files. (0.6.1.2)"); + + private static void Add6_1_1(Changelog log) + => log.NextVersion("Version 0.6.1.1") + .RegisterEntry( + "Added a toggle to use all the effective changes from the entire currently selected collection for swaps, instead of the selected mod.") + .RegisterEntry("Fix using equipment paths for accessory swaps and thus accessory swaps not working at all") + .RegisterEntry("Fix issues with swaps with gender-locked gear where the models for the other gender do not exist.") + .RegisterEntry("Fix swapping universal hairstyles for midlanders breaking them for other races.") + .RegisterEntry("Add some actual error messages on failure to create item swaps.") + .RegisterEntry("Fix warnings about more than one affected item appearing for single items."); + + private static void Add6_1_0(Changelog log) + => log.NextVersion("Version 0.6.1.0 (Happy New Year! Edition)") + .RegisterEntry("Add a prototype for Item Swapping.") + .RegisterEntry("A new tab in Advanced Editing.", 1) + .RegisterEntry("Swapping of Hair, Tail, Ears, Equipment and Accessories is supported. Weapons and Faces may be coming.", 1) + .RegisterEntry("The manipulations currently in use by the selected mod with its currents settings (ignoring enabled state)" + + " should be used when creating the swap, but you can also just swap unmodded things.", 1) + .RegisterEntry("You can write a swap to a new mod, or to a new option in the currently selected mod.", 1) + .RegisterEntry("The swaps are not heavily tested yet, and may also be not perfectly efficient. Please leave feedback.", 1) + .RegisterEntry("More detailed help or explanations will be added later.", 1) + .RegisterEntry("Heavily improve Chat Commands. Use /penumbra help for more information.") + .RegisterEntry("Penumbra now considers meta manipulations for Changed Items.") + .RegisterEntry("Penumbra now tries to associate battle voices to specific actors, so that they work in collections.") + .RegisterEntry( + "Heavily improve .atex and .avfx handling, Penumbra can now associate VFX to specific actors far better, including ground effects.") + .RegisterEntry("Improve some file handling for Mare-Interaction.") + .RegisterEntry("Add Equipment Slots to Demihuman IMC Edits.") + .RegisterEntry( + "Add a toggle to keep metadata edits that apply the default value (and thus do not really change anything) on import from TexTools .meta files.") + .RegisterEntry("Add an option to directly change the 'Wait For Plugins To Load'-Dalamud Option from Penumbra.") + .RegisterEntry("Add API to copy mod settings from one mod to another.") + .RegisterEntry("Fix a problem where creating individual collections did not trigger events.") + .RegisterEntry("Add a Hack to support Anamnesis Redrawing better. (0.6.0.6)") + .RegisterEntry("Fix another problem with the aesthetician. (0.6.0.6)") + .RegisterEntry("Fix a problem with the export directory not being respected. (0.6.0.6)"); + + private static void Add6_0_5(Changelog log) + => log.NextVersion("Version 0.6.0.5") + .RegisterEntry("Allow hyphen as last character in player and retainer names.") + .RegisterEntry("Fix various bugs with ownership and GPose.") + .RegisterEntry("Fix collection selectors not updating for new or deleted collections in some cases.") + .RegisterEntry("Fix Chocobos not being recognized correctly.") + .RegisterEntry("Fix some problems with UI actors.") + .RegisterEntry("Fix problems with aesthetician again."); + + private static void Add6_0_2(Changelog log) + => log.NextVersion("Version 0.6.0.2") + .RegisterEntry("Let Bell Retainer collections apply to retainer-named mannequins.") + .RegisterEntry("Added a few informations to a help marker for new individual assignments.") + .RegisterEntry("Fix bug with Demi Human IMC paths.") + .RegisterEntry("Fix Yourself collection not applying to UI actors.") + .RegisterEntry("Fix Yourself collection not applying during aesthetician."); + + private static void Add6_0_0(Changelog log) + => log.NextVersion("Version 0.6.0.0") + .RegisterEntry("Revamped Individual Collections:") + .RegisterEntry("You can now specify individual collections for players (by name) of specific worlds or any world.", 1) + .RegisterEntry("You can also specify NPCs (by grouped name and type of NPC), and owned NPCs (by specifying an NPC and a Player).", + 1) + .RegisterImportant( + "Migration should move all current names that correspond to NPCs to the appropriate NPC group and all names that can be valid Player names to a Player of any world.", + 1) + .RegisterImportant( + "Please look through your Individual Collections to verify everything migrated correctly and corresponds to the game object you want. You might also want to change the 'Player (Any World)' collections to your specific homeworld.", + 1) + .RegisterEntry("You can also manually sort your Individual Collections by drag and drop now.", 1) + .RegisterEntry("This new system is a pretty big rework, so please report any discrepancies or bugs you find.", 1) + .RegisterEntry("These changes made the specific ownership settings for Retainers and for preferring named over ownership obsolete.", + 1) + .RegisterEntry("General ownership can still be toggled and should apply in order of: Owned NPC > Owner (if enabled) > General NPC.", + 1) + .RegisterEntry( + "Added NPC Model Parsing, changes in NPC models should now display the names of the changed game objects for most NPCs.") + .RegisterEntry("Changed Items now also display variant or subtype in addition to the model set ID where applicable.") + .RegisterEntry("Collection selectors can now be filtered by name.") + .RegisterEntry("Try to use Unicode normalization before replacing invalid path symbols on import for somewhat nicer paths.") + .RegisterEntry("Improved interface for group settings (minimally).") + .RegisterEntry("New Special or Individual Assignments now default to your current Base assignment instead of None.") + .RegisterEntry("Improved Support Info somewhat.") + .RegisterEntry("Added Dye Previews for in-game dyes and dyeing templates in Material Editing.") + .RegisterEntry("Colorset Editing now allows for negative values in all cases.") + .RegisterEntry("Added Export buttons to .mdl and .mtrl previews in Advanced Editing.") + .RegisterEntry("File Selection in the .mdl and .mtrl tabs now shows one associated game path by default and all on hover.") + .RegisterEntry( + "Added the option to reduplicate and normalize a mod, restoring all duplicates and moving the files to appropriate folders. (Duplicates Tab in Advanced Editing)") + .RegisterEntry( + "Added an option to re-export metadata changes to TexTools-typed .meta and .rgsp files. (Meta-Manipulations Tab in Advanced Editing)") + .RegisterEntry("Fixed several bugs with the incorporation of meta changes when not done during TTMP import.") + .RegisterEntry("Fixed a bug with RSP changes on non-base collections not applying correctly in some cases.") + .RegisterEntry("Fixed a bug when dragging options during mod edit.") + .RegisterEntry("Fixed a bug where sometimes the valid folder check caused issues.") + .RegisterEntry("Fixed a bug where collections with inheritances were newly saved on every load.") + .RegisterEntry("Fixed a bug where the /penumbra enable/disable command displayed the wrong message (functionality unchanged).") + .RegisterEntry("Mods without names or invalid mod folders are now warnings instead of errors.") + .RegisterEntry("Added IPC events for mod deletion, addition or moves, and resolving based on game objects.") + .RegisterEntry("Prevent a bug that allowed IPC to add Mods from outside the Penumbra root folder.") + .RegisterEntry("A lot of big backend changes."); + + private static void Add5_11_1(Changelog log) + => log.NextVersion("Version 0.5.11.1") + .RegisterEntry( + "The 0.5.11.0 Update exposed an issue in Penumbras file-saving scheme that rarely could cause some, most or even all of your mods to lose their group information.") + .RegisterEntry( + "If this has happened to you, you will need to reimport affected mods, or manually restore their groups. I am very sorry for that.", + 1) + .RegisterEntry( + "I believe the problem is fixed with 0.5.11.1, but I can not be sure since it would occur only rarely. For the same reason, a testing build would not help (as it also did not with 0.5.11.0 itself).", + 1) + .RegisterImportant( + "If you do encounter this or similar problems in 0.5.11.1, please immediately let me know in Discord so I can revert the update again.", + 1); + + private static void Add5_11_0(Changelog log) + => log.NextVersion("Version 0.5.11.0") + .RegisterEntry( + "Added local data storage for mods in the plugin config folder. This information is not exported together with your mod, but not dependent on collections.") + .RegisterEntry("Moved the import date from mod metadata to local data.", 1) + .RegisterEntry("Added Favorites. You can declare mods as favorites and filter for them.", 1) + .RegisterEntry("Added Local Tags. You can apply custom Tags to mods and filter for them.", 1) + .RegisterEntry( + "Added Mod Tags. Mod Creators (and the Edit Mod tab) can set tags that are stored in the mod meta data and are thus exported.") + .RegisterEntry("Add backface and transparency toggles to .mtrl editing, as well as a info section.") + .RegisterEntry("Meta Manipulation editing now highlights if the selected ID is 0 or 1.") + .RegisterEntry("Fixed a bug when manually adding EQP or EQDP entries to Mods.") + .RegisterEntry("Updated some tooltips and hints.") + .RegisterEntry("Improved handling of IMC exception problems.") + .RegisterEntry("Fixed a bug with misidentification of equipment decals.") + .RegisterEntry( + "Character collections can now be set via chat command, too. (/penumbra collection character | )") + .RegisterEntry("Backend changes regarding API/IPC, consumers can but do not need to use the Penumbra.Api library as a submodule.") + .RegisterEntry("Added API to delete mods and read and set their pseudo-filesystem paths.", 1) + .RegisterEntry("Added API to check Penumbras enabled state and updates to it.", 1); + + private static void Add5_10_0(Changelog log) + => log.NextVersion("Version 0.5.10.0") + .RegisterEntry("Renamed backup functionality to export functionality.") + .RegisterEntry("A default export directory can now optionally be specified.") + .RegisterEntry("If left blank, exports will still be stored in your mod directory.", 1) + .RegisterEntry("Existing exports corresponding to existing mods will be moved automatically if the export directory is changed.", + 1) + .RegisterEntry("Added buttons to export and import all color set rows at once during material editing.") + .RegisterEntry("Fixed texture import being case sensitive on the extension.") + .RegisterEntry("Fixed special collection selector increasing in size on non-default UI styling.") + .RegisterEntry("Fixed color set rows not importing the dye values during material editing.") + .RegisterEntry("Other miscellaneous small fixes."); + + private static void Add5_9_0(Changelog log) + => log.NextVersion("Version 0.5.9.0") + .RegisterEntry("Special Collections are now split between male and female.") + .RegisterEntry("Fix a bug where the Base and Interface Collection were set to None instead of Default on a fresh install.") + .RegisterEntry("Fix a bug where cutscene actors were not properly reset and could be misidentified across multiple cutscenes.") + .RegisterEntry("TexTools .meta and .rgsp files are now incorporated based on file- and game path extensions."); + + private static void Add5_8_7(Changelog log) + => log.NextVersion("Version 0.5.8.7") + .RegisterEntry("Fixed some problems with metadata reloading and reverting and IMC files. (5.8.1 to 5.8.7).") + .RegisterImportant( + "If you encounter any issues, please try completely restarting your game after updating (not just relogging), before reporting them.", + 1); + + private static void Add5_8_0(Changelog log) + => log.NextVersion("Version 0.5.8.0") + .RegisterEntry("Added choices what Change Logs are to be displayed. It is recommended to just keep showing all.") + .RegisterEntry("Added an Interface Collection assignment.") + .RegisterEntry("All your UI mods will have to be in the interface collection.", 1) + .RegisterEntry("Files that are categorized as UI files by the game will only check for redirections in this collection.", 1) + .RegisterImportant( + "Migration should have set your currently assigned Base Collection to the Interface Collection, please verify that.", 1) + .RegisterEntry("New API / IPC for the Interface Collection added.", 1) + .RegisterImportant("API / IPC consumers should verify whether they need to change resolving to the new collection.", 1) + .RegisterImportant( + "If other plugins are not using your interface collection yet, you can just keep Interface and Base the same collection for the time being.") + .RegisterEntry( + "Mods can now have default settings for each option group, that are shown while the mod is unconfigured and taken as initial values when configured.") + .RegisterEntry("Default values are set when importing .ttmps from their default values, and can be changed in the Edit Mod tab.", + 1) + .RegisterEntry("Files that the game loads super early should now be replaceable correctly via base or interface collection.") + .RegisterEntry( + "The 1.0 neck tattoo file should now be replaceable, even in character collections. You can also replace the transparent texture used instead. (This was ugly.)") + .RegisterEntry("Continued Work on the Texture Import/Export Tab:") + .RegisterEntry("Should work with lot more texture types for .dds and .tex files, most notably BC7 compression.", 1) + .RegisterEntry("Supports saving .tex and .dds files in multiple texture types and generating MipMaps for them.", 1) + .RegisterEntry("Interface reworked a bit, gives more information and the overlay side can be collapsed.", 1) + .RegisterImportant( + "May contain bugs or missing safeguards. Generally let me know what's missing, ugly, buggy, not working or could be improved. Not really feasible for me to test it all.", + 1) + .RegisterEntry( + "Added buttons for redrawing self or all as well as a tooltip to describe redraw options and a tutorial step for it.") + .RegisterEntry("Collection Selectors now display None at the top if available.") + .RegisterEntry( + "Adding mods via API/IPC will now cause them to incorporate and then delete TexTools .meta and .rgsp files automatically.") + .RegisterEntry("Fixed an issue with Actor 201 using Your Character collections in cutscenes.") + .RegisterEntry("Fixed issues with and improved mod option editing.") + .RegisterEntry( + "Fixed some issues with and improved file redirection editing - you are now informed if you can not add a game path (because it is invalid or already in use).") + .RegisterEntry("Backend optimizations.") + .RegisterEntry("Changed metadata change system again.", 1) + .RegisterEntry("Improved logging efficiency.", 1); + + private static void Add5_7_1(Changelog log) + => log.NextVersion("Version 0.5.7.1") + .RegisterEntry("Fixed the Changelog window not considering UI Scale correctly.") + .RegisterEntry("Reworked Changelog display slightly."); + + private static void Add5_7_0(Changelog log) + => log.NextVersion("Version 0.5.7.0") + .RegisterEntry("Added a Changelog!") + .RegisterEntry("Files in the UI category will no longer be deduplicated for the moment.") + .RegisterImportant("If you experience UI-related crashes, please re-import your UI mods.", 1) + .RegisterEntry("This is a temporary fix against those not-yet fully understood crashes and may be reworked later.", 1) + .RegisterImportant( + "There is still a possibility of UI related mods crashing the game, we are still investigating - they behave very weirdly. If you continue to experience crashing, try disabling your UI mods.", + 1) + .RegisterEntry( + "On import, Penumbra will now show files with extensions '.ttmp', '.ttmp2' and '.pmp'. You can still select showing generic archive files.") + .RegisterEntry( + "Penumbra Mod Pack ('.pmp') files are meant to be renames of any of the archive types that could already be imported that contain the necessary Penumbra meta files.", + 1) + .RegisterImportant( + "If you distribute any mod as an archive specifically for Penumbra, you should change its extension to '.pmp'. Supported base archive types are ZIP, 7-Zip and RAR.", + 1) + .RegisterEntry("Penumbra will now save mod backups with the file extension '.pmp'. They still are regular ZIP files.", 1) + .RegisterEntry( + "Existing backups in your current mod directory should be automatically renamed. If you manage multiple mod directories, you may need to migrate the other ones manually.", + 1) + .RegisterEntry("Fixed assigned collections not working correctly on adventurer plates.") + .RegisterEntry("Fixed a wrongly displayed folder line in some circumstances.") + .RegisterEntry("Fixed crash after deleting mod options.") + .RegisterEntry("Fixed Inspect Window collections not working correctly.") + .RegisterEntry("Made identically named options selectable in mod configuration. Do not name your options identically.") + .RegisterEntry("Added some additional functionality for Mare Synchronos."); + + #endregion + + private static void AddDummy(Changelog log) + => log.NextVersion(string.Empty); + + private (int, ChangeLogDisplayType) ConfigData() + => (_config.Ephemeral.LastSeenVersion, _config.ChangeLogDisplayType); + + private void Save(int version, ChangeLogDisplayType type) + { + if (_config.Ephemeral.LastSeenVersion != version) + { + _config.Ephemeral.LastSeenVersion = version; + _config.Ephemeral.Save(); + } + + if (_config.ChangeLogDisplayType != type) + { + _config.ChangeLogDisplayType = type; + _config.Save(); + } + } +} diff --git a/Penumbra/UI/Classes/CollectionSelectHeader.cs b/Penumbra/UI/Classes/CollectionSelectHeader.cs new file mode 100644 index 00000000..355a6106 --- /dev/null +++ b/Penumbra/UI/Classes/CollectionSelectHeader.cs @@ -0,0 +1,169 @@ +using Dalamud.Interface; +using Dalamud.Bindings.ImGui; +using OtterGui; +using OtterGui.Raii; +using OtterGui.Services; +using OtterGui.Text; +using Penumbra.Collections; +using Penumbra.Collections.Manager; +using Penumbra.Interop.PathResolving; +using Penumbra.Mods; +using Penumbra.UI.CollectionTab; + +namespace Penumbra.UI.Classes; + +public class CollectionSelectHeader : IUiService +{ + private readonly CollectionCombo _collectionCombo; + private readonly ActiveCollections _activeCollections; + private readonly TutorialService _tutorial; + private readonly ModSelection _selection; + private readonly CollectionResolver _resolver; + private readonly Configuration _config; + + public CollectionSelectHeader(CollectionManager collectionManager, TutorialService tutorial, ModSelection selection, + CollectionResolver resolver, Configuration config) + { + _tutorial = tutorial; + _selection = selection; + _resolver = resolver; + _config = config; + _activeCollections = collectionManager.Active; + _collectionCombo = new CollectionCombo(collectionManager, () => collectionManager.Storage.OrderBy(c => c.Identity.Name).ToList()); + } + + /// Draw the header line that can quick switch between collections. + public void Draw(bool spacing) + { + using var style = ImRaii.PushStyle(ImGuiStyleVar.FrameRounding, 0) + .Push(ImGuiStyleVar.ItemSpacing, new Vector2(0, spacing ? ImGui.GetStyle().ItemSpacing.Y : 0)); + DrawTemporaryCheckbox(); + ImGui.SameLine(); + var comboWidth = ImGui.GetContentRegionAvail().X / 4f; + var buttonSize = new Vector2(comboWidth * 3f / 4f, 0f); + using (var _ = ImRaii.Group()) + { + DrawCollectionButton(buttonSize, GetDefaultCollectionInfo(), 1); + DrawCollectionButton(buttonSize, GetInterfaceCollectionInfo(), 2); + DrawCollectionButton(buttonSize, GetPlayerCollectionInfo(), 3); + DrawCollectionButton(buttonSize, GetInheritedCollectionInfo(), 4); + + _collectionCombo.Draw("##collectionSelector", comboWidth, ColorId.SelectedCollection.Value()); + } + + _tutorial.OpenTutorial(BasicTutorialSteps.CollectionSelectors); + + if (!_activeCollections.CurrentCollectionInUse) + ImGuiUtil.DrawTextButton("The currently selected collection is not used in any way.", -Vector2.UnitX, Colors.PressEnterWarningBg); + } + + private void DrawTemporaryCheckbox() + { + var hold = _config.IncognitoModifier.IsActive(); + using (ImRaii.PushStyle(ImGuiStyleVar.FrameBorderSize, ImUtf8.GlobalScale)) + { + var tint = _config.DefaultTemporaryMode + ? ImGuiCol.Text.Tinted(ColorId.TemporaryModSettingsTint) + : ImGui.GetColorU32(ImGuiCol.TextDisabled); + using var color = ImRaii.PushColor(ImGuiCol.ButtonHovered, ImGui.GetColorU32(ImGuiCol.FrameBg), !hold) + .Push(ImGuiCol.ButtonActive, ImGui.GetColorU32(ImGuiCol.FrameBg), !hold) + .Push(ImGuiCol.Border, tint, _config.DefaultTemporaryMode); + if (ImUtf8.IconButton(FontAwesomeIcon.Stopwatch, ""u8, default, false, tint, ImGui.GetColorU32(ImGuiCol.FrameBg)) && hold) + { + _config.DefaultTemporaryMode = !_config.DefaultTemporaryMode; + _config.Save(); + } + } + + ImUtf8.HoverTooltip(ImGuiHoveredFlags.AllowWhenDisabled, + "Toggle the temporary settings mode, where all changes you do create temporary settings first and need to be made permanent if desired."u8); + if (!hold) + ImUtf8.HoverTooltip(ImGuiHoveredFlags.AllowWhenDisabled, $"\nHold {_config.IncognitoModifier} while clicking to toggle."); + } + + private enum CollectionState + { + Empty, + Selected, + Unavailable, + Available, + } + + private CollectionState CheckCollection(ModCollection? collection, bool inheritance = false) + { + if (collection == null) + return CollectionState.Unavailable; + if (collection == ModCollection.Empty) + return CollectionState.Empty; + if (collection == _activeCollections.Current) + return inheritance ? CollectionState.Unavailable : CollectionState.Selected; + + return CollectionState.Available; + } + + private (ModCollection?, string, string, bool) GetDefaultCollectionInfo() + { + var collection = _activeCollections.Default; + return CheckCollection(collection) switch + { + CollectionState.Empty => (collection, "None", "The base collection is configured to use no mods.", true), + CollectionState.Selected => (collection, collection.Identity.Name, + "The configured base collection is already selected as the current collection.", true), + CollectionState.Available => (collection, collection.Identity.Name, + $"Select the configured base collection {collection.Identity.Name} as the current collection.", false), + _ => throw new Exception("Can not happen."), + }; + } + + private (ModCollection?, string, string, bool) GetPlayerCollectionInfo() + { + var collection = _resolver.PlayerCollection(); + return CheckCollection(collection) switch + { + CollectionState.Empty => (collection, "None", "The loaded player character is configured to use no mods.", true), + CollectionState.Selected => (collection, collection.Identity.Name, + "The collection configured to apply to the loaded player character is already selected as the current collection.", true), + CollectionState.Available => (collection, collection.Identity.Name, + $"Select the collection {collection.Identity.Name} that applies to the loaded player character as the current collection.", + false), + _ => throw new Exception("Can not happen."), + }; + } + + private (ModCollection?, string, string, bool) GetInterfaceCollectionInfo() + { + var collection = _activeCollections.Interface; + return CheckCollection(collection) switch + { + CollectionState.Empty => (collection, "None", "The interface collection is configured to use no mods.", true), + CollectionState.Selected => (collection, collection.Identity.Name, + "The configured interface collection is already selected as the current collection.", true), + CollectionState.Available => (collection, collection.Identity.Name, + $"Select the configured interface collection {collection.Identity.Name} as the current collection.", false), + _ => throw new Exception("Can not happen."), + }; + } + + private (ModCollection?, string, string, bool) GetInheritedCollectionInfo() + { + var collection = _selection.Mod == null ? null : _selection.Collection; + return CheckCollection(collection, true) switch + { + CollectionState.Unavailable => (null, "Not Inherited", + "The settings of the selected mod are not inherited from another collection.", true), + CollectionState.Available => (collection, collection!.Identity.Name, + $"Select the collection {collection!.Identity.Name} from which the selected mod inherits its settings as the current collection.", + false), + _ => throw new Exception("Can not happen."), + }; + } + + private void DrawCollectionButton(Vector2 buttonWidth, (ModCollection?, string, string, bool) tuple, int id) + { + var (collection, name, tooltip, disabled) = tuple; + using var _ = ImRaii.PushId(id); + if (ImGuiUtil.DrawDisabledButton(name, buttonWidth, tooltip, disabled)) + _activeCollections.SetCollection(collection!, CollectionType.Current); + ImGui.SameLine(); + } +} diff --git a/Penumbra/UI/Classes/Colors.cs b/Penumbra/UI/Classes/Colors.cs new file mode 100644 index 00000000..90ef0591 --- /dev/null +++ b/Penumbra/UI/Classes/Colors.cs @@ -0,0 +1,128 @@ +using Dalamud.Bindings.ImGui; +using OtterGui.Custom; + +namespace Penumbra.UI.Classes; + +public enum ColorId : short +{ + EnabledMod, + DisabledMod, + UndefinedMod, + InheritedMod, + InheritedDisabledMod, + NewMod, + NewModTint, + ConflictingMod, + HandledConflictMod, + FolderExpanded, + FolderCollapsed, + FolderLine, + ItemId, + IncreasedMetaValue, + DecreasedMetaValue, + SelectedCollection, + RedundantAssignment, + NoModsAssignment, + NoAssignment, + SelectorPriority, + InGameHighlight, + InGameHighlight2, + ResTreeLocalPlayer, + ResTreePlayer, + ResTreeNetworked, + ResTreeNonNetworked, + PredefinedTagAdd, + PredefinedTagRemove, + TemporaryModSettingsTint, + ChangedItemPreferenceStar, + NoTint, +} + +public static class Colors +{ + // These are written as 0xAABBGGRR. + public const uint PressEnterWarningBg = 0xFF202080; + public const uint RegexWarningBorder = 0xFF0000B0; + public const uint MetaInfoText = 0xAAFFFFFF; + public const uint RedTableBgTint = 0x40000080; + public const uint DiscordColor = CustomGui.DiscordColor; + public const uint FilterActive = 0x807070FF; + public const uint TutorialMarker = 0xFF20FFFF; + public const uint TutorialBorder = 0xD00000FF; + public const uint ReniColorButton = CustomGui.ReniColorButton; + public const uint ReniColorHovered = CustomGui.ReniColorHovered; + public const uint ReniColorActive = CustomGui.ReniColorActive; + + public static uint Tinted(this ColorId color, ColorId tint) + { + var tintValue = ImGui.ColorConvertU32ToFloat4(tint.Value()); + var value = ImGui.ColorConvertU32ToFloat4(color.Value()); + return ImGui.ColorConvertFloat4ToU32(TintColor(value, tintValue)); + } + + public static unsafe uint Tinted(this ImGuiCol color, ColorId tint) + { + var tintValue = ImGui.ColorConvertU32ToFloat4(tint.Value()); + ref var value = ref *ImGui.GetStyleColorVec4(color); + return ImGui.ColorConvertFloat4ToU32(TintColor(value, tintValue)); + } + + private static unsafe Vector4 TintColor(in Vector4 color, in Vector4 tint) + { + var negAlpha = 1 - tint.W; + var newAlpha = negAlpha * color.W + tint.W; + var newR = (negAlpha * color.W * color.X + tint.W * tint.X) / newAlpha; + var newG = (negAlpha * color.W * color.Y + tint.W * tint.Y) / newAlpha; + var newB = (negAlpha * color.W * color.Z + tint.W * tint.Z) / newAlpha; + return new Vector4(newR, newG, newB, newAlpha); + } + + public static (uint DefaultColor, string Name, string Description) Data(this ColorId color) + => color switch + { + // @formatter:off + ColorId.EnabledMod => ( 0xFFFFFFFF, "Enabled Mod", "A mod that is enabled by the currently selected collection." ), + ColorId.DisabledMod => ( 0xFF686880, "Disabled Mod", "A mod that is disabled by the currently selected collection." ), + ColorId.UndefinedMod => ( 0xFF808080, "Mod With No Settings", "A mod that is not configured in the currently selected collection or any of the collections it inherits from, and thus implicitly disabled." ), + ColorId.InheritedMod => ( 0xFFD0FFFF, "Mod Enabled By Inheritance", "A mod that is not configured in the currently selected collection, but enabled in a collection it inherits from." ), + ColorId.InheritedDisabledMod => ( 0xFF688080, "Mod Disabled By Inheritance", "A mod that is not configured in the currently selected collection, but disabled in a collection it inherits from."), + ColorId.NewMod => ( 0xFF66DD66, "New Mod", "A mod that was newly imported or created during this session and has not been enabled yet." ), + ColorId.ConflictingMod => ( 0xFFAAAAFF, "Mod With Unresolved Conflicts", "An enabled mod that has conflicts with another enabled mod on the same priority level." ), + ColorId.HandledConflictMod => ( 0xFFD0FFD0, "Mod With Resolved Conflicts", "An enabled mod that has conflicts with another enabled mod on a different priority level." ), + ColorId.FolderExpanded => ( 0xFFFFF0C0, "Expanded Mod Folder", "A mod folder that is currently expanded." ), + ColorId.FolderCollapsed => ( 0xFFFFF0C0, "Collapsed Mod Folder", "A mod folder that is currently collapsed." ), + ColorId.FolderLine => ( 0xFFFFF0C0, "Expanded Mod Folder Line", "The line signifying which descendants belong to an expanded mod folder." ), + ColorId.ItemId => ( 0xFF808080, "Item Id", "The numeric model id of the given item to the right of changed items." ), + ColorId.IncreasedMetaValue => ( 0x80008000, "Increased Meta Manipulation Value", "An increased meta manipulation value for floats or an enabled toggle where the default is disabled."), + ColorId.DecreasedMetaValue => ( 0x80000080, "Decreased Meta Manipulation Value", "A decreased meta manipulation value for floats or a disabled toggle where the default is enabled."), + ColorId.SelectedCollection => ( 0x6069C056, "Currently Selected Collection", "The collection that is currently selected and being edited."), + ColorId.RedundantAssignment => ( 0x6050D0D0, "Redundant Collection Assignment", "A collection assignment that currently has no effect as it is redundant with more general assignments."), + ColorId.NoModsAssignment => ( 0x50000080, "'Use No Mods' Collection Assignment", "A collection assignment set to not use any mods at all."), + ColorId.NoAssignment => ( 0x00000000, "Unassigned Collection Assignment", "A collection assignment that is not configured to any collection and thus just has no specific treatment."), + ColorId.SelectorPriority => ( 0xFF808080, "Mod Selector Priority", "The priority displayed for non-zero priority mods in the mod selector."), + ColorId.InGameHighlight => ( 0xFFEBCF89, "In-Game Highlight (Primary)", "An in-game element that has been highlighted for ease of editing."), + ColorId.InGameHighlight2 => ( 0xFF446CC0, "In-Game Highlight (Secondary)", "Another in-game element that has been highlighted for ease of editing."), + ColorId.ResTreeLocalPlayer => ( 0xFFFFE0A0, "On-Screen: You", "You and what you own (mount, minion, accessory, pets and so on), in the On-Screen tab." ), + ColorId.ResTreePlayer => ( 0xFFC0FFC0, "On-Screen: Other Players", "Other players and what they own, in the On-Screen tab." ), + ColorId.ResTreeNetworked => ( 0xFFFFFFFF, "On-Screen: Non-Players (Networked)", "Non-player entities handled by the game server, in the On-Screen tab." ), + ColorId.ResTreeNonNetworked => ( 0xFFC0C0FF, "On-Screen: Non-Players (Local)", "Non-player entities handled locally, in the On-Screen tab." ), + ColorId.PredefinedTagAdd => ( 0xFF44AA44, "Predefined Tags: Add Tag", "A predefined tag that is not present on the current mod and can be added." ), + ColorId.PredefinedTagRemove => ( 0xFF2222AA, "Predefined Tags: Remove Tag", "A predefined tag that is already present on the current mod and can be removed." ), + ColorId.TemporaryModSettingsTint => ( 0x30FF0000, "Mod with Temporary Settings", "A mod that has temporary settings. This color is used as a tint for the regular state colors." ), + ColorId.NewModTint => ( 0x8000FF00, "New Mod Tint", "A mod that was newly imported or created during this session and has not been enabled yet. This color is used as a tint for the regular state colors."), + ColorId.NoTint => ( 0x00000000, "No Tint", "The default tint for all mods."), + ColorId.ChangedItemPreferenceStar => ( 0x30FFFFFF, "Preferred Changed Item Star", "The color of the star button in the mod panel's changed items tab to prioritize specific items."), + _ => throw new ArgumentOutOfRangeException( nameof( color ), color, null ), + // @formatter:on + }; + + private static IReadOnlyDictionary _colors = new Dictionary(); + + /// Obtain the configured value for a color. + public static uint Value(this ColorId color) + => _colors.TryGetValue(color, out var value) ? value : color.Data().DefaultColor; + + /// Set the configurable colors dictionary to a value. + public static void SetColors(Configuration config) + => _colors = config.Colors; +} diff --git a/Penumbra/UI/Classes/Combos.cs b/Penumbra/UI/Classes/Combos.cs new file mode 100644 index 00000000..234f7a3e --- /dev/null +++ b/Penumbra/UI/Classes/Combos.cs @@ -0,0 +1,42 @@ +using Dalamud.Interface; +using OtterGui; +using Penumbra.GameData.Enums; +using Penumbra.Meta.Manipulations; + +namespace Penumbra.UI.Classes; + +public static class Combos +{ + // Different combos to use with enums. + public static bool Race(string label, ModelRace current, out ModelRace race, float unscaledWidth = 100) + => ImGuiUtil.GenericEnumCombo(label, unscaledWidth * UiHelpers.Scale, current, out race, RaceEnumExtensions.ToName, 1); + + public static bool Gender(string label, Gender current, out Gender gender, float unscaledWidth = 120) + => ImGuiUtil.GenericEnumCombo(label, unscaledWidth, current, out gender, RaceEnumExtensions.ToName, 1); + + public static bool EqdpEquipSlot(string label, EquipSlot current, out EquipSlot slot, float unscaledWidth = 100) + => ImGuiUtil.GenericEnumCombo(label, unscaledWidth * UiHelpers.Scale, current, out slot, EquipSlotExtensions.EqdpSlots, + EquipSlotExtensions.ToName); + + public static bool EqpEquipSlot(string label, EquipSlot current, out EquipSlot slot, float unscaledWidth = 100) + => ImGuiUtil.GenericEnumCombo(label, unscaledWidth * UiHelpers.Scale, current, out slot, EquipSlotExtensions.EquipmentSlots, + EquipSlotExtensions.ToName); + + public static bool AccessorySlot(string label, EquipSlot current, out EquipSlot slot, float unscaledWidth = 100) + => ImGuiUtil.GenericEnumCombo(label, unscaledWidth * UiHelpers.Scale, current, out slot, EquipSlotExtensions.AccessorySlots, + EquipSlotExtensions.ToName); + + public static bool SubRace(string label, SubRace current, out SubRace subRace, float unscaledWidth = 150) + => ImGuiUtil.GenericEnumCombo(label, unscaledWidth * UiHelpers.Scale, current, out subRace, RaceEnumExtensions.ToName, 1); + + public static bool RspAttribute(string label, RspAttribute current, out RspAttribute attribute, float unscaledWidth = 200) + => ImGuiUtil.GenericEnumCombo(label, unscaledWidth * UiHelpers.Scale, current, out attribute, + RspAttributeExtensions.ToFullString, 0, 1); + + public static bool EstSlot(string label, EstType current, out EstType attribute, float unscaledWidth = 200) + => ImGuiUtil.GenericEnumCombo(label, unscaledWidth * UiHelpers.Scale, current, out attribute); + + public static bool ImcType(string label, ObjectType current, out ObjectType type, float unscaledWidth = 110) + => ImGuiUtil.GenericEnumCombo(label, unscaledWidth * UiHelpers.Scale, current, out type, ObjectTypeExtensions.ValidImcTypes, + ObjectTypeExtensions.ToName); +} diff --git a/Penumbra/UI/Classes/MigrationSectionDrawer.cs b/Penumbra/UI/Classes/MigrationSectionDrawer.cs new file mode 100644 index 00000000..98a59a5b --- /dev/null +++ b/Penumbra/UI/Classes/MigrationSectionDrawer.cs @@ -0,0 +1,163 @@ +using Dalamud.Bindings.ImGui; +using OtterGui.Services; +using OtterGui.Text; +using Penumbra.Services; + +namespace Penumbra.UI.Classes; + +public class MigrationSectionDrawer(MigrationManager migrationManager, Configuration config) : IUiService +{ + private bool _createBackups = true; + private Vector2 _buttonSize; + + public void Draw() + { + using var header = ImUtf8.CollapsingHeaderId("Migration"u8); + if (!header) + return; + + _buttonSize = UiHelpers.InputTextWidth; + DrawSettings(); + ImGui.Separator(); + DrawMdlMigration(); + DrawMdlRestore(); + DrawMdlCleanup(); + // TODO enable when this works + ImGui.Separator(); + //DrawMtrlMigration(); + DrawMtrlRestore(); + DrawMtrlCleanup(); + } + + private void DrawSettings() + { + var value = config.MigrateImportedModelsToV6; + if (ImUtf8.Checkbox("Automatically Migrate V5 Models to V6 on Import"u8, ref value)) + { + config.MigrateImportedModelsToV6 = value; + config.Save(); + } + + ImUtf8.HoverTooltip("This increments the version marker and restructures the bone table to the new version."u8); + + // TODO enable when this works + //value = config.MigrateImportedMaterialsToLegacy; + //if (ImUtf8.Checkbox("Automatically Migrate Materials to Dawntrail on Import"u8, ref value)) + //{ + // config.MigrateImportedMaterialsToLegacy = value; + // config.Save(); + //} + // + //ImUtf8.HoverTooltip( + // "This currently only increases the color-table size and switches the shader from 'character.shpk' to 'characterlegacy.shpk', if the former is used."u8); + + ImUtf8.Checkbox("Create Backups During Manual Migration", ref _createBackups); + } + + private static ReadOnlySpan MigrationTooltip + => "Cancel the migration. This does not revert already finished migrations."u8; + + private void DrawMdlMigration() + { + if (ImUtf8.ButtonEx("Migrate Model Files From V5 to V6"u8, "\0"u8, _buttonSize, migrationManager.IsRunning)) + migrationManager.MigrateMdlDirectory(config.ModDirectory, _createBackups); + + ImUtf8.SameLineInner(); + DrawCancelButton(MigrationManager.TaskType.MdlMigration, "Cancel the migration. This does not revert already finished migrations."u8); + DrawSpinner(migrationManager is { CurrentTask: MigrationManager.TaskType.MdlMigration, IsRunning: true }); + DrawData(migrationManager.MdlMigration, "No model files found."u8, "migrated"u8); + } + + private void DrawMtrlMigration() + { + if (ImUtf8.ButtonEx("Migrate Material Files to Dawntrail"u8, "\0"u8, _buttonSize, migrationManager.IsRunning)) + migrationManager.MigrateMtrlDirectory(config.ModDirectory, _createBackups); + + ImUtf8.SameLineInner(); + DrawCancelButton(MigrationManager.TaskType.MtrlMigration, MigrationTooltip); + DrawSpinner(migrationManager is { CurrentTask: MigrationManager.TaskType.MtrlMigration, IsRunning: true }); + DrawData(migrationManager.MtrlMigration, "No material files found."u8, "migrated"u8); + } + + + private static ReadOnlySpan CleanupTooltip + => "Cancel the cleanup. This is not revertible."u8; + + private void DrawMdlCleanup() + { + if (ImUtf8.ButtonEx("Delete Existing Model Backup Files"u8, "\0"u8, _buttonSize, migrationManager.IsRunning)) + migrationManager.CleanMdlBackups(config.ModDirectory); + + ImUtf8.SameLineInner(); + DrawCancelButton(MigrationManager.TaskType.MdlCleanup, CleanupTooltip); + DrawSpinner(migrationManager is { CurrentTask: MigrationManager.TaskType.MdlCleanup, IsRunning: true }); + DrawData(migrationManager.MdlCleanup, "No model backup files found."u8, "deleted"u8); + } + + private void DrawMtrlCleanup() + { + if (ImUtf8.ButtonEx("Delete Existing Material Backup Files"u8, "\0"u8, _buttonSize, migrationManager.IsRunning)) + migrationManager.CleanMtrlBackups(config.ModDirectory); + + ImUtf8.SameLineInner(); + DrawCancelButton(MigrationManager.TaskType.MtrlCleanup, CleanupTooltip); + DrawSpinner(migrationManager is { CurrentTask: MigrationManager.TaskType.MtrlCleanup, IsRunning: true }); + DrawData(migrationManager.MtrlCleanup, "No material backup files found."u8, "deleted"u8); + } + + private static ReadOnlySpan RestorationTooltip + => "Cancel the restoration. This does not revert already finished restoration."u8; + + private void DrawMdlRestore() + { + if (ImUtf8.ButtonEx("Restore Model Backups"u8, "\0"u8, _buttonSize, migrationManager.IsRunning)) + migrationManager.RestoreMdlBackups(config.ModDirectory); + + ImUtf8.SameLineInner(); + DrawCancelButton(MigrationManager.TaskType.MdlRestoration, RestorationTooltip); + DrawSpinner(migrationManager is { CurrentTask: MigrationManager.TaskType.MdlRestoration, IsRunning: true }); + DrawData(migrationManager.MdlRestoration, "No model backup files found."u8, "restored"u8); + } + + private void DrawMtrlRestore() + { + if (ImUtf8.ButtonEx("Restore Material Backups"u8, "\0"u8, _buttonSize, migrationManager.IsRunning)) + migrationManager.RestoreMtrlBackups(config.ModDirectory); + + ImUtf8.SameLineInner(); + DrawCancelButton(MigrationManager.TaskType.MtrlRestoration, RestorationTooltip); + DrawSpinner(migrationManager is { CurrentTask: MigrationManager.TaskType.MtrlRestoration, IsRunning: true }); + DrawData(migrationManager.MtrlRestoration, "No material backup files found."u8, "restored"u8); + } + + private static void DrawSpinner(bool enabled) + { + if (!enabled) + return; + + ImGui.SameLine(); + ImUtf8.Spinner("Spinner"u8, ImGui.GetTextLineHeight() / 2, 2, ImGui.GetColorU32(ImGuiCol.Text)); + } + + private void DrawCancelButton(MigrationManager.TaskType task, ReadOnlySpan tooltip) + { + using var _ = ImUtf8.PushId((int)task); + if (ImUtf8.ButtonEx("Cancel"u8, tooltip, disabled: !migrationManager.IsRunning || task != migrationManager.CurrentTask)) + migrationManager.Cancel(); + } + + private static void DrawData(MigrationManager.MigrationData data, ReadOnlySpan empty, ReadOnlySpan action) + { + if (!data.HasData) + { + ImUtf8.IconDummy(); + return; + } + + var total = data.Total; + if (total == 0) + ImUtf8.TextFrameAligned(empty); + else + ImUtf8.TextFrameAligned($"{data.Changed} files {action}, {data.Failed} files failed, {total} files found."); + } +} diff --git a/Penumbra/UI/CollectionTab/CollectionCombo.cs b/Penumbra/UI/CollectionTab/CollectionCombo.cs new file mode 100644 index 00000000..bf97f178 --- /dev/null +++ b/Penumbra/UI/CollectionTab/CollectionCombo.cs @@ -0,0 +1,46 @@ +using Dalamud.Bindings.ImGui; +using OtterGui.Extensions; +using OtterGui.Raii; +using OtterGui.Text; +using OtterGui.Widgets; +using Penumbra.Collections; +using Penumbra.Collections.Manager; + +namespace Penumbra.UI.CollectionTab; + +public sealed class CollectionCombo(CollectionManager manager, Func> items) + : FilterComboCache(items, MouseWheelType.Control, Penumbra.Log) +{ + private readonly ImRaii.Color _color = new(); + + protected override void DrawFilter(int currentSelected, float width) + { + _color.Dispose(); + base.DrawFilter(currentSelected, width); + } + + public void Draw(string label, float width, uint color) + { + var current = manager.Active.Current; + if (current != CurrentSelection) + { + CurrentSelectionIdx = Items.IndexOf(current); + UpdateSelection(current); + } + + _color.Push(ImGuiCol.FrameBg, color).Push(ImGuiCol.FrameBgHovered, color); + if (Draw(label, current.Identity.Name, string.Empty, width, ImGui.GetTextLineHeightWithSpacing()) && CurrentSelection != null) + manager.Active.SetCollection(CurrentSelection, CollectionType.Current); + _color.Dispose(); + } + + protected override string ToString(ModCollection obj) + => obj.Identity.Name; + + protected override void DrawCombo(string label, string preview, string tooltip, int currentSelected, float previewWidth, float itemHeight, + ImGuiComboFlags flags) + { + base.DrawCombo(label, preview, tooltip, currentSelected, previewWidth, itemHeight, flags); + ImUtf8.HoverTooltip("Control and mouse wheel to scroll."u8); + } +} diff --git a/Penumbra/UI/CollectionTab/CollectionPanel.cs b/Penumbra/UI/CollectionTab/CollectionPanel.cs new file mode 100644 index 00000000..7a8ca032 --- /dev/null +++ b/Penumbra/UI/CollectionTab/CollectionPanel.cs @@ -0,0 +1,759 @@ +using Dalamud.Game.ClientState.Objects; +using Dalamud.Interface; +using Dalamud.Interface.Components; +using Dalamud.Interface.GameFonts; +using Dalamud.Interface.ImGuiNotification; +using Dalamud.Interface.ManagedFontAtlas; +using Dalamud.Interface.Utility; +using Dalamud.Plugin; +using Dalamud.Bindings.ImGui; +using Dalamud.Plugin.Services; +using OtterGui; +using OtterGui.Classes; +using OtterGui.Extensions; +using OtterGui.Raii; +using OtterGui.Text; +using Penumbra.Collections; +using Penumbra.Collections.Manager; +using Penumbra.GameData.Actors; +using Penumbra.GameData.Enums; +using Penumbra.Mods.Manager; +using Penumbra.Services; +using Penumbra.UI.Classes; + +namespace Penumbra.UI.CollectionTab; + +public sealed class CollectionPanel( + IDalamudPluginInterface pi, + CommunicatorService communicator, + CollectionManager manager, + CollectionSelector selector, + ActorManager actors, + ITargetManager targets, + ModStorage mods, + SaveService saveService, + IncognitoService incognito) + : IDisposable +{ + private readonly CollectionStorage _collections = manager.Storage; + private readonly ActiveCollections _active = manager.Active; + private readonly IndividualAssignmentUi _individualAssignmentUi = new(communicator, actors, manager); + private readonly InheritanceUi _inheritanceUi = new(manager, incognito); + private readonly IFontHandle _nameFont = pi.UiBuilder.FontAtlas.NewGameFontHandle(new GameFontStyle(GameFontFamilyAndSize.Jupiter23)); + + private static readonly IReadOnlyDictionary Buttons = CreateButtons(); + private static readonly IReadOnlyList<(CollectionType, bool, bool, string, uint)> AdvancedTree = CreateTree(); + private readonly List<(CollectionType Type, ActorIdentifier Identifier)> _inUseCache = []; + private string? _newName; + + private int _draggedIndividualAssignment = -1; + + public void Dispose() + { + _individualAssignmentUi.Dispose(); + _nameFont.Dispose(); + } + + /// Draw the panel containing beginners information and simple assignments. + public void DrawSimple() + { + ImGuiUtil.TextWrapped("A collection is a set of mod configurations. You can have as many collections as you desire.\n" + + "The collection you are currently editing in the mod tab can be selected here and is highlighted.\n"); + ImGuiUtil.TextWrapped( + "There are functions you can assign these collections to, so different mod configurations apply for different things.\n" + + "You can assign an existing collection to such a function by clicking the function or dragging the collection over."); + ImGui.Separator(); + + var buttonWidth = new Vector2(200 * ImGuiHelpers.GlobalScale, 2 * ImGui.GetTextLineHeightWithSpacing()); + using var style = ImRaii.PushStyle(ImGuiStyleVar.ButtonTextAlign, Vector2.Zero) + .Push(ImGuiStyleVar.FrameBorderSize, 1 * ImGuiHelpers.GlobalScale); + DrawSimpleCollectionButton(CollectionType.Default, buttonWidth); + DrawSimpleCollectionButton(CollectionType.Interface, buttonWidth); + DrawSimpleCollectionButton(CollectionType.Yourself, buttonWidth); + DrawSimpleCollectionButton(CollectionType.MalePlayerCharacter, buttonWidth); + DrawSimpleCollectionButton(CollectionType.FemalePlayerCharacter, buttonWidth); + DrawSimpleCollectionButton(CollectionType.MaleNonPlayerCharacter, buttonWidth); + DrawSimpleCollectionButton(CollectionType.FemaleNonPlayerCharacter, buttonWidth); + + ImGuiUtil.DrawColoredText(("Individual ", ColorId.NewMod.Value()), + ("Assignments take precedence before anything else and only apply to one specific character or monster.", 0)); + ImGui.Dummy(Vector2.UnitX); + + var specialWidth = buttonWidth with { X = 275 * ImGuiHelpers.GlobalScale }; + DrawCurrentCharacter(specialWidth); + ImGui.SameLine(); + DrawCurrentTarget(specialWidth); + DrawIndividualCollections(buttonWidth); + + var first = true; + + Button(CollectionType.NonPlayerChild); + Button(CollectionType.NonPlayerElderly); + foreach (var race in Enum.GetValues().Skip(1)) + { + Button(CollectionTypeExtensions.FromParts(race, Gender.Male, false)); + Button(CollectionTypeExtensions.FromParts(race, Gender.Female, false)); + Button(CollectionTypeExtensions.FromParts(race, Gender.Male, true)); + Button(CollectionTypeExtensions.FromParts(race, Gender.Female, true)); + } + + return; + + void Button(CollectionType type) + { + var (name, border) = Buttons[type]; + var collection = _active.ByType(type); + if (collection == null) + return; + + if (first) + { + ImGui.Separator(); + ImGui.TextUnformatted("Currently Active Advanced Assignments"); + first = false; + } + + DrawButton(name, type, buttonWidth, border, ActorIdentifier.Invalid, 's', collection); + ImGui.SameLine(); + if (ImGui.GetContentRegionAvail().X < buttonWidth.X + ImGui.GetStyle().ItemSpacing.X + ImGui.GetStyle().WindowPadding.X) + ImGui.NewLine(); + } + } + + /// Draw the panel containing new and existing individual assignments. + public void DrawIndividualPanel() + { + using var style = ImRaii.PushStyle(ImGuiStyleVar.ButtonTextAlign, Vector2.Zero) + .Push(ImGuiStyleVar.FrameBorderSize, 1 * ImGuiHelpers.GlobalScale); + var width = new Vector2(300 * ImGuiHelpers.GlobalScale, 2 * ImGui.GetTextLineHeightWithSpacing()); + + ImGui.Dummy(Vector2.One); + DrawCurrentCharacter(width); + ImGui.SameLine(); + DrawCurrentTarget(width); + ImGui.Separator(); + ImGui.Dummy(Vector2.One); + style.Pop(); + _individualAssignmentUi.DrawWorldCombo(width.X / 2); + ImGui.SameLine(); + _individualAssignmentUi.DrawNewPlayerCollection(width.X); + + _individualAssignmentUi.DrawObjectKindCombo(width.X / 2); + ImGui.SameLine(); + _individualAssignmentUi.DrawNewNpcCollection(width.X); + ImGui.SameLine(); + ImGuiComponents.HelpMarker( + "Battle- and Event NPCs may apply to more than one ID if they share the same name. This is language dependent. If you change your clients language, verify that your collections are still correctly assigned."); + ImGui.Dummy(Vector2.One); + ImGui.Separator(); + style.Push(ImGuiStyleVar.FrameBorderSize, 1 * ImGuiHelpers.GlobalScale); + + DrawNewPlayer(width); + ImGui.SameLine(); + ImGuiUtil.TextWrapped("Also check General Settings for UI characters and inheritance through ownership."); + ImGui.Separator(); + + DrawNewRetainer(width); + ImGui.SameLine(); + ImGuiUtil.TextWrapped("Bell Retainers apply to Mannequins, but not to outdoor retainers, since those only carry their owners name."); + ImGui.Separator(); + + DrawNewNpc(width); + ImGui.SameLine(); + ImGuiUtil.TextWrapped("Some NPCs are available as Battle - and Event NPCs and need to be setup for both if desired."); + ImGui.Separator(); + + DrawNewOwned(width); + ImGui.SameLine(); + ImGuiUtil.TextWrapped("Owned NPCs take precedence before unowned NPCs of the same type."); + ImGui.Separator(); + + DrawIndividualCollections(width with { X = 200 * ImGuiHelpers.GlobalScale }); + } + + /// Draw the panel containing all special group assignments. + public void DrawGroupPanel() + { + ImGui.Dummy(Vector2.One); + using var table = ImRaii.Table("##advanced", 4, ImGuiTableFlags.SizingFixedFit | ImGuiTableFlags.RowBg); + if (!table) + return; + + using var style = ImRaii.PushStyle(ImGuiStyleVar.ButtonTextAlign, Vector2.Zero) + .Push(ImGuiStyleVar.FrameBorderSize, 1 * ImGuiHelpers.GlobalScale); + + var buttonWidth = new Vector2(150 * ImGuiHelpers.GlobalScale, 2 * ImGui.GetTextLineHeightWithSpacing()); + var dummy = new Vector2(1, 0); + + foreach (var (type, pre, post, name, border) in AdvancedTree) + { + ImGui.TableNextColumn(); + if (type is CollectionType.Inactive) + continue; + + if (pre) + ImGui.Dummy(dummy); + DrawAssignmentButton(type, buttonWidth, name, border); + if (post) + ImGui.Dummy(dummy); + } + } + + /// Draw the collection detail panel with inheritance, visible mod settings and statistics. + public void DrawDetailsPanel() + { + var collection = _active.Current; + DrawCollectionName(collection); + DrawStatistics(collection); + DrawCollectionData(collection); + _inheritanceUi.Draw(); + ImGui.Separator(); + DrawInactiveSettingsList(collection); + DrawSettingsList(collection); + } + + private void DrawCollectionData(ModCollection collection) + { + ImGui.Dummy(Vector2.Zero); + ImGui.BeginGroup(); + ImGui.AlignTextToFramePadding(); + ImGui.TextUnformatted("Name"); + ImGui.AlignTextToFramePadding(); + ImGui.TextUnformatted("Identifier"); + ImGui.EndGroup(); + ImGui.SameLine(); + ImGui.BeginGroup(); + var width = ImGui.GetContentRegionAvail().X; + using (ImRaii.Disabled(_collections.DefaultNamed == collection)) + { + using var style = ImRaii.PushStyle(ImGuiStyleVar.ButtonTextAlign, new Vector2(0, 0.5f)); + var name = _newName ?? collection.Identity.Name; + ImGui.SetNextItemWidth(width); + if (ImGui.InputText("##name", ref name, 128)) + _newName = name; + if (ImGui.IsItemDeactivatedAfterEdit() && _newName != null && _newName != collection.Identity.Name) + { + collection.Identity.Name = _newName; + saveService.QueueSave(new ModCollectionSave(mods, collection)); + selector.RestoreCollections(); + _newName = null; + } + else if (ImGui.IsItemDeactivated()) + { + _newName = null; + } + } + if (_collections.DefaultNamed == collection) + ImUtf8.HoverTooltip(ImGuiHoveredFlags.AllowWhenDisabled, "The Default collection can not be renamed."u8); + + var identifier = collection.Identity.Identifier; + var fileName = saveService.FileNames.CollectionFile(collection); + using (ImRaii.PushFont(UiBuilder.MonoFont)) + { + if (ImGui.Button(collection.Identity.Identifier, new Vector2(width, 0))) + try + { + Process.Start(new ProcessStartInfo(fileName) { UseShellExecute = true }); + } + catch (Exception ex) + { + Penumbra.Messager.NotificationMessage(ex, $"Could not open file {fileName}.", $"Could not open file {fileName}", + NotificationType.Warning); + } + } + + if (ImGui.IsItemClicked(ImGuiMouseButton.Right)) + ImGui.SetClipboardText(identifier); + + ImGuiUtil.HoverTooltip( + $"Open the file\n\t{fileName}\ncontaining this design in the .json-editor of your choice.\n\nRight-Click to copy identifier to clipboard."); + + ImGui.EndGroup(); + ImGui.Dummy(Vector2.Zero); + ImGui.Separator(); + ImGui.Dummy(Vector2.Zero); + } + + private void DrawContext(bool open, ModCollection? collection, CollectionType type, ActorIdentifier identifier, string text, char suffix) + { + var label = $"{type}{text}{suffix}"; + if (open) + ImGui.OpenPopup(label); + + using var context = ImRaii.Popup(label); + if (!context) + return; + + using (var color = ImRaii.PushColor(ImGuiCol.Text, Colors.DiscordColor)) + { + if (ImGui.MenuItem("Use no mods.")) + _active.SetCollection(ModCollection.Empty, type, _active.Individuals.GetGroup(identifier)); + } + + if (collection != null && type.CanBeRemoved()) + { + using var color = ImRaii.PushColor(ImGuiCol.Text, Colors.RegexWarningBorder); + if (ImGui.MenuItem("Remove this assignment.")) + _active.SetCollection(null, type, _active.Individuals.GetGroup(identifier)); + } + + foreach (var coll in _collections.OrderBy(c => c.Identity.Name)) + { + if (coll != collection && ImGui.MenuItem($"Use {coll.Identity.Name}.")) + _active.SetCollection(coll, type, _active.Individuals.GetGroup(identifier)); + } + } + + private bool DrawButton(string text, CollectionType type, Vector2 width, uint borderColor, ActorIdentifier id, char suffix, + ModCollection? collection = null) + { + using var group = ImRaii.Group(); + var invalid = type == CollectionType.Individual && !id.IsValid; + var redundancy = _active.RedundancyCheck(type, id); + collection ??= _active.ByType(type, id); + using var color = ImRaii.PushColor(ImGuiCol.Button, + collection == null + ? ColorId.NoAssignment.Value() + : redundancy.Length > 0 + ? ColorId.RedundantAssignment.Value() + : collection == _active.Current + ? ColorId.SelectedCollection.Value() + : collection == ModCollection.Empty + ? ColorId.NoModsAssignment.Value() + : ImGui.GetColorU32(ImGuiCol.Button), !invalid) + .Push(ImGuiCol.Border, borderColor == 0 ? ImGui.GetColorU32(ImGuiCol.TextDisabled) : borderColor); + using var disabled = ImRaii.Disabled(invalid); + var button = ImGui.Button(text, width) || ImGui.IsItemClicked(ImGuiMouseButton.Right); + var hovered = redundancy.Length > 0 && ImGui.IsItemHovered(); + DrawIndividualDragSource(text, id); + DrawIndividualDragTarget(id); + if (!invalid) + { + selector.DragTargetAssignment(type, id); + var name = Name(collection); + var size = ImGui.CalcTextSize(name); + var textPos = ImGui.GetItemRectMax() - size - ImGui.GetStyle().FramePadding; + ImGui.GetWindowDrawList().AddText(textPos, ImGui.GetColorU32(ImGuiCol.Text), name); + DrawContext(button, collection, type, id, text, suffix); + } + + if (hovered) + ImGui.SetTooltip(redundancy); + + return button; + } + + private void DrawIndividualDragSource(string text, ActorIdentifier id) + { + if (!id.IsValid) + return; + + using var source = ImRaii.DragDropSource(); + if (!source) + return; + + ImGui.SetDragDropPayload("DragIndividual", null, 0); + ImGui.TextUnformatted($"Re-ordering {text}..."); + _draggedIndividualAssignment = _active.Individuals.Index(id); + } + + private void DrawIndividualDragTarget(ActorIdentifier id) + { + if (!id.IsValid) + return; + + using var target = ImRaii.DragDropTarget(); + if (!target || !ImGuiUtil.IsDropping("DragIndividual")) + return; + + var currentIdx = _active.Individuals.Index(id); + if (_draggedIndividualAssignment != -1 && currentIdx != -1) + _active.MoveIndividualCollection(_draggedIndividualAssignment, currentIdx); + _draggedIndividualAssignment = -1; + } + + private void DrawSimpleCollectionButton(CollectionType type, Vector2 width) + { + DrawButton(type.ToName(), type, width, 0, ActorIdentifier.Invalid, 's'); + ImGui.SameLine(); + using (var group = ImRaii.Group()) + { + ImGuiUtil.TextWrapped(type.ToDescription()); + switch (type) + { + case CollectionType.Default: ImGui.TextUnformatted("Overruled by any other Assignment."); break; + case CollectionType.Yourself: + ImGuiUtil.DrawColoredText(("Overruled by ", 0), ("Individual ", ColorId.NewMod.Value()), ("Assignments.", 0)); + break; + case CollectionType.MalePlayerCharacter: + ImGuiUtil.DrawColoredText(("Overruled by ", 0), ("Male Racial Player", Colors.DiscordColor), (", ", 0), + ("Your Character", ColorId.HandledConflictMod.Value()), (", or ", 0), + ("Individual ", ColorId.NewMod.Value()), ("Assignments.", 0)); + break; + case CollectionType.FemalePlayerCharacter: + ImGuiUtil.DrawColoredText(("Overruled by ", 0), ("Female Racial Player", Colors.ReniColorActive), (", ", 0), + ("Your Character", ColorId.HandledConflictMod.Value()), (", or ", 0), + ("Individual ", ColorId.NewMod.Value()), ("Assignments.", 0)); + break; + case CollectionType.MaleNonPlayerCharacter: + ImGuiUtil.DrawColoredText(("Overruled by ", 0), ("Male Racial NPC", Colors.DiscordColor), (", ", 0), + ("Children", ColorId.FolderLine.Value()), (", ", 0), ("Elderly", Colors.MetaInfoText), (", or ", 0), + ("Individual ", ColorId.NewMod.Value()), ("Assignments.", 0)); + break; + case CollectionType.FemaleNonPlayerCharacter: + ImGuiUtil.DrawColoredText(("Overruled by ", 0), ("Female Racial NPC", Colors.ReniColorActive), (", ", 0), + ("Children", ColorId.FolderLine.Value()), (", ", 0), ("Elderly", Colors.MetaInfoText), (", or ", 0), + ("Individual ", ColorId.NewMod.Value()), ("Assignments.", 0)); + break; + } + } + + ImGui.Separator(); + } + + private void DrawAssignmentButton(CollectionType type, Vector2 width, string name, uint color) + => DrawButton(name, type, width, color, ActorIdentifier.Invalid, 's', _active.ByType(type)); + + /// Respect incognito mode for names of identifiers. + private string Name(ActorIdentifier id, string? name) + => incognito.IncognitoMode && id.Type is IdentifierType.Player or IdentifierType.Owned + ? id.Incognito(name) + : name ?? id.ToString(); + + /// Respect incognito mode for names of collections. + private string Name(ModCollection? collection) + => collection == null ? "Unassigned" : + collection == ModCollection.Empty ? "Use No Mods" : + incognito.IncognitoMode ? collection.Identity.AnonymizedName : collection.Identity.Name; + + private void DrawIndividualButton(string intro, Vector2 width, string tooltip, char suffix, params ActorIdentifier[] identifiers) + { + if (identifiers.Length > 0 && identifiers[0].IsValid) + { + DrawButton($"{intro} ({Name(identifiers[0], null)})", CollectionType.Individual, width, 0, identifiers[0], suffix); + } + else + { + if (tooltip.Length == 0 && identifiers.Length > 0) + tooltip = $"The current target {identifiers[0].PlayerName} is not valid for an assignment."; + DrawButton($"{intro} (Unavailable)", CollectionType.Individual, width, 0, ActorIdentifier.Invalid, suffix); + } + + ImGuiUtil.HoverTooltip(tooltip); + } + + private void DrawCurrentCharacter(Vector2 width) + => DrawIndividualButton("Current Character", width, string.Empty, 'c', actors.GetCurrentPlayer()); + + private void DrawCurrentTarget(Vector2 width) + => DrawIndividualButton("Current Target", width, string.Empty, 't', + actors.FromObject(targets.Target, false, true, true)); + + private void DrawNewPlayer(Vector2 width) + => DrawIndividualButton("New Player", width, _individualAssignmentUi.PlayerTooltip, 'p', + _individualAssignmentUi.PlayerIdentifiers.FirstOrDefault()); + + private void DrawNewRetainer(Vector2 width) + => DrawIndividualButton("New Bell Retainer", width, _individualAssignmentUi.RetainerTooltip, 'r', + _individualAssignmentUi.RetainerIdentifiers.FirstOrDefault()); + + private void DrawNewNpc(Vector2 width) + => DrawIndividualButton("New NPC", width, _individualAssignmentUi.NpcTooltip, 'n', + _individualAssignmentUi.NpcIdentifiers.FirstOrDefault()); + + private void DrawNewOwned(Vector2 width) + => DrawIndividualButton("New Owned NPC", width, _individualAssignmentUi.OwnedTooltip, 'o', + _individualAssignmentUi.OwnedIdentifiers.FirstOrDefault()); + + private void DrawIndividualCollections(Vector2 width) + { + for (var i = 0; i < _active.Individuals.Count; ++i) + { + var (name, ids, coll) = _active.Individuals.Assignments[i]; + DrawButton(Name(ids[0], name), CollectionType.Individual, width, 0, ids[0], 'i', coll); + + ImGui.SameLine(); + if (ImGui.GetContentRegionAvail().X < width.X + ImGui.GetStyle().ItemSpacing.X + ImGui.GetStyle().WindowPadding.X + && i < _active.Individuals.Count - 1) + ImGui.NewLine(); + } + + if (_active.Individuals.Count > 0) + ImGui.NewLine(); + } + + private void DrawCollectionName(ModCollection collection) + { + ImGui.Dummy(Vector2.One); + using var color = ImRaii.PushColor(ImGuiCol.Border, Colors.MetaInfoText); + using var style = ImRaii.PushStyle(ImGuiStyleVar.FrameBorderSize, 2 * UiHelpers.Scale); + using var f = _nameFont.Push(); + var name = Name(collection); + var size = ImGui.CalcTextSize(name).X; + var pos = ImGui.GetContentRegionAvail().X - size + ImGui.GetStyle().FramePadding.X * 2; + if (pos > 0) + ImGui.SetCursorPosX(pos / 2); + ImGuiUtil.DrawTextButton(name, Vector2.Zero, 0); + ImGui.Dummy(Vector2.One); + } + + private void DrawStatistics(ModCollection collection) + { + GatherInUse(collection); + ImGui.Separator(); + + var buttonHeight = 2 * ImGui.GetTextLineHeightWithSpacing(); + if (_inUseCache.Count == 0 && collection.Inheritance.DirectlyInheritedBy.Count == 0) + { + ImGui.Dummy(Vector2.One); + using var f = _nameFont.Push(); + ImGuiUtil.DrawTextButton("Collection is not used.", new Vector2(ImGui.GetContentRegionAvail().X, buttonHeight), + Colors.PressEnterWarningBg); + ImGui.Dummy(Vector2.One); + ImGui.Separator(); + } + else + { + var buttonWidth = new Vector2(175 * ImGuiHelpers.GlobalScale, buttonHeight); + DrawInUseStatistics(collection, buttonWidth); + DrawInheritanceStatistics(collection, buttonWidth); + } + } + + private void GatherInUse(ModCollection collection) + { + _inUseCache.Clear(); + foreach (var special in CollectionTypeExtensions.Special.Select(t => t.Item1) + .Prepend(CollectionType.Default) + .Prepend(CollectionType.Interface) + .Where(t => _active.ByType(t) == collection)) + _inUseCache.Add((special, ActorIdentifier.Invalid)); + + foreach (var (_, id, coll) in _active.Individuals.Assignments.Where(t + => t.Collection == collection && t.Identifiers.FirstOrDefault().IsValid)) + _inUseCache.Add((CollectionType.Individual, id[0])); + } + + private void DrawInUseStatistics(ModCollection collection, Vector2 buttonWidth) + { + if (_inUseCache.Count <= 0) + return; + + using (var _ = ImRaii.PushStyle(ImGuiStyleVar.FramePadding, Vector2.Zero)) + { + ImGuiUtil.DrawTextButton("In Use By", ImGui.GetContentRegionAvail() with { Y = 0 }, 0); + } + + using var style = ImRaii.PushStyle(ImGuiStyleVar.FrameBorderSize, 1 * ImGuiHelpers.GlobalScale) + .Push(ImGuiStyleVar.ButtonTextAlign, Vector2.Zero); + + foreach (var ((type, id), idx) in _inUseCache.WithIndex()) + { + var name = type == CollectionType.Individual ? Name(id, null) : Buttons[type].Name; + var color = Buttons.TryGetValue(type, out var p) ? p.Border : 0; + DrawButton(name, type, buttonWidth, color, id, 's', collection); + ImGui.SameLine(); + if (ImGui.GetContentRegionAvail().X < buttonWidth.X + ImGui.GetStyle().ItemSpacing.X + ImGui.GetStyle().WindowPadding.X + && idx != _inUseCache.Count - 1) + ImGui.NewLine(); + } + + ImGui.NewLine(); + ImGui.Dummy(Vector2.One); + ImGui.Separator(); + } + + private void DrawInheritanceStatistics(ModCollection collection, Vector2 buttonWidth) + { + if (collection.Inheritance.DirectlyInheritedBy.Count <= 0) + return; + + using (var _ = ImRaii.PushStyle(ImGuiStyleVar.FramePadding, Vector2.Zero)) + { + ImGuiUtil.DrawTextButton("Inherited by", ImGui.GetContentRegionAvail() with { Y = 0 }, 0); + } + + using var f = _nameFont.Push(); + using var style = ImRaii.PushStyle(ImGuiStyleVar.FrameBorderSize, ImGuiHelpers.GlobalScale); + using var color = ImRaii.PushColor(ImGuiCol.Border, Colors.MetaInfoText); + ImGuiUtil.DrawTextButton(Name(collection.Inheritance.DirectlyInheritedBy[0]), Vector2.Zero, 0); + var constOffset = (ImGui.GetStyle().FramePadding.X + ImGuiHelpers.GlobalScale) * 2 + + ImGui.GetStyle().ItemSpacing.X + + ImGui.GetStyle().WindowPadding.X; + foreach (var parent in collection.Inheritance.DirectlyInheritedBy.Skip(1)) + { + var name = Name(parent); + var size = ImGui.CalcTextSize(name).X; + ImGui.SameLine(); + if (constOffset + size >= ImGui.GetContentRegionAvail().X) + ImGui.NewLine(); + ImGuiUtil.DrawTextButton(name, Vector2.Zero, 0); + } + + ImGui.Dummy(Vector2.One); + ImGui.Separator(); + } + + private void DrawSettingsList(ModCollection collection) + { + ImGui.Dummy(Vector2.One); + var size = new Vector2(ImGui.GetContentRegionAvail().X, 10 * ImGui.GetFrameHeightWithSpacing()); + using var table = ImRaii.Table("##activeSettings", 4, ImGuiTableFlags.RowBg | ImGuiTableFlags.ScrollY, size); + if (!table) + return; + + ImGui.TableSetupScrollFreeze(0, 1); + ImGui.TableSetupColumn("Mod Name", ImGuiTableColumnFlags.WidthStretch); + ImGui.TableSetupColumn("Inherited From", ImGuiTableColumnFlags.WidthFixed, 5f * ImGui.GetFrameHeight()); + ImGui.TableSetupColumn("State", ImGuiTableColumnFlags.WidthFixed, 1.75f * ImGui.GetFrameHeight()); + ImGui.TableSetupColumn("Priority", ImGuiTableColumnFlags.WidthFixed, 2.5f * ImGui.GetFrameHeight()); + ImGui.TableHeadersRow(); + foreach (var (mod, (settings, parent)) in mods.Select(m => (m, collection.GetInheritedSettings(m.Index))) + .Where(t => t.Item2.Settings != null) + .OrderBy(t => t.m.Name)) + { + ImGui.TableNextColumn(); + ImGuiUtil.CopyOnClickSelectable(mod.Name); + ImGui.TableNextColumn(); + if (parent != collection) + ImGui.TextUnformatted(Name(parent)); + ImGui.TableNextColumn(); + var enabled = settings!.Enabled; + using (var dis = ImRaii.Disabled()) + { + ImGui.Checkbox("##check", ref enabled); + } + + ImGui.TableNextColumn(); + ImGuiUtil.RightAlign(settings.Priority.ToString(), ImGui.GetStyle().WindowPadding.X); + } + } + + private void DrawInactiveSettingsList(ModCollection collection) + { + if (collection.Settings.Unused.Count == 0) + return; + + ImGui.Dummy(Vector2.One); + var text = collection.Settings.Unused.Count > 1 + ? $"Clear all {collection.Settings.Unused.Count} unused settings from deleted mods." + : "Clear the currently unused setting from a deleted mods."; + if (ImGui.Button(text, new Vector2(ImGui.GetContentRegionAvail().X, 0))) + _collections.CleanUnavailableSettings(collection); + + ImGui.Dummy(Vector2.One); + + var size = new Vector2(ImGui.GetContentRegionAvail().X, + Math.Min(10, collection.Settings.Unused.Count + 1) * ImGui.GetFrameHeightWithSpacing()); + using var table = ImRaii.Table("##inactiveSettings", 4, ImGuiTableFlags.RowBg | ImGuiTableFlags.ScrollY, size); + if (!table) + return; + + ImGui.TableSetupScrollFreeze(0, 1); + ImGui.TableSetupColumn(string.Empty, ImGuiTableColumnFlags.WidthFixed, UiHelpers.IconButtonSize.X); + ImGui.TableSetupColumn("Unused Mod Identifier", ImGuiTableColumnFlags.WidthStretch); + ImGui.TableSetupColumn("State", ImGuiTableColumnFlags.WidthFixed, 1.75f * ImGui.GetFrameHeight()); + ImGui.TableSetupColumn("Priority", ImGuiTableColumnFlags.WidthFixed, 2.5f * ImGui.GetFrameHeight()); + ImGui.TableHeadersRow(); + string? delete = null; + foreach (var (name, settings) in collection.Settings.Unused.OrderBy(n => n.Key)) + { + using var id = ImRaii.PushId(name); + ImGui.TableNextColumn(); + if (ImGuiUtil.DrawDisabledButton(FontAwesomeIcon.Trash.ToIconString(), UiHelpers.IconButtonSize, + "Delete this unused setting.", false, true)) + delete = name; + ImGui.TableNextColumn(); + ImGuiUtil.CopyOnClickSelectable(name); + ImGui.TableNextColumn(); + var enabled = settings.Enabled; + using (var dis = ImRaii.Disabled()) + { + ImGui.Checkbox("##check", ref enabled); + } + + ImGui.TableNextColumn(); + ImGuiUtil.RightAlign(settings.Priority.ToString(), ImGui.GetStyle().WindowPadding.X); + } + + _collections.CleanUnavailableSetting(collection, delete); + ImGui.Separator(); + } + + /// Create names and border colors for special assignments. + private static IReadOnlyDictionary CreateButtons() + { + var ret = Enum.GetValues().ToDictionary(t => t, t => (t.ToName(), 0u)); + + foreach (var race in Enum.GetValues().Skip(1)) + { + var color = race switch + { + SubRace.Midlander => 0xAA5C9FE4u, + SubRace.Highlander => 0xAA5C9FE4u, + SubRace.Wildwood => 0xAA5C9F49u, + SubRace.Duskwight => 0xAA5C9F49u, + SubRace.Plainsfolk => 0xAAEF8CB6u, + SubRace.Dunesfolk => 0xAAEF8CB6u, + SubRace.SeekerOfTheSun => 0xAA8CEFECu, + SubRace.KeeperOfTheMoon => 0xAA8CEFECu, + SubRace.Seawolf => 0xAAEFE68Cu, + SubRace.Hellsguard => 0xAAEFE68Cu, + SubRace.Raen => 0xAAB5EF8Cu, + SubRace.Xaela => 0xAAB5EF8Cu, + SubRace.Helion => 0xAAFFFFFFu, + SubRace.Lost => 0xAAFFFFFFu, + SubRace.Rava => 0xAA607FA7u, + SubRace.Veena => 0xAA607FA7u, + _ => 0u, + }; + + ret[CollectionTypeExtensions.FromParts(race, Gender.Male, false)] = ($"♂ {race.ToShortName()}", color); + ret[CollectionTypeExtensions.FromParts(race, Gender.Female, false)] = ($"♀ {race.ToShortName()}", color); + ret[CollectionTypeExtensions.FromParts(race, Gender.Male, true)] = ($"♂ {race.ToShortName()} (NPC)", color); + ret[CollectionTypeExtensions.FromParts(race, Gender.Female, true)] = ($"♀ {race.ToShortName()} (NPC)", color); + } + + ret[CollectionType.MalePlayerCharacter] = ("♂ Player", 0); + ret[CollectionType.FemalePlayerCharacter] = ("♀ Player", 0); + ret[CollectionType.MaleNonPlayerCharacter] = ("♂ NPC", 0); + ret[CollectionType.FemaleNonPlayerCharacter] = ("♀ NPC", 0); + return ret; + } + + /// Create the special assignment tree in order and with free spaces. + private static IReadOnlyList<(CollectionType, bool, bool, string, uint)> CreateTree() + { + var ret = new List<(CollectionType, bool, bool, string, uint)>(Buttons.Count); + + void Add(CollectionType type, bool pre, bool post) + { + var (name, border) = Buttons[type]; + ret.Add((type, pre, post, name, border)); + } + + Add(CollectionType.Default, false, false); + Add(CollectionType.Interface, false, false); + Add(CollectionType.Inactive, false, false); + Add(CollectionType.Inactive, false, false); + Add(CollectionType.Yourself, false, true); + Add(CollectionType.Inactive, false, true); + Add(CollectionType.NonPlayerChild, false, true); + Add(CollectionType.NonPlayerElderly, false, true); + Add(CollectionType.MalePlayerCharacter, true, true); + Add(CollectionType.FemalePlayerCharacter, true, true); + Add(CollectionType.MaleNonPlayerCharacter, true, true); + Add(CollectionType.FemaleNonPlayerCharacter, true, true); + var pre = true; + foreach (var race in Enum.GetValues().Skip(1)) + { + Add(CollectionTypeExtensions.FromParts(race, Gender.Male, false), pre, !pre); + Add(CollectionTypeExtensions.FromParts(race, Gender.Female, false), pre, !pre); + Add(CollectionTypeExtensions.FromParts(race, Gender.Male, true), pre, !pre); + Add(CollectionTypeExtensions.FromParts(race, Gender.Female, true), pre, !pre); + pre = !pre; + } + + return ret; + } +} diff --git a/Penumbra/UI/CollectionTab/CollectionSelector.cs b/Penumbra/UI/CollectionTab/CollectionSelector.cs new file mode 100644 index 00000000..79254090 --- /dev/null +++ b/Penumbra/UI/CollectionTab/CollectionSelector.cs @@ -0,0 +1,145 @@ +using Dalamud.Bindings.ImGui; +using OtterGui; +using OtterGui.Raii; +using Penumbra.Collections; +using Penumbra.Collections.Manager; +using Penumbra.Communication; +using Penumbra.GameData.Actors; +using Penumbra.Services; +using Penumbra.UI.Classes; + +namespace Penumbra.UI.CollectionTab; + +public sealed class CollectionSelector : ItemSelector, IDisposable +{ + private readonly Configuration _config; + private readonly CommunicatorService _communicator; + private readonly CollectionStorage _storage; + private readonly ActiveCollections _active; + private readonly TutorialService _tutorial; + private readonly IncognitoService _incognito; + + private ModCollection? _dragging; + + public CollectionSelector(Configuration config, CommunicatorService communicator, CollectionStorage storage, ActiveCollections active, + TutorialService tutorial, IncognitoService incognito) + : base([], Flags.Delete | Flags.Add | Flags.Duplicate | Flags.Filter) + { + _config = config; + _communicator = communicator; + _storage = storage; + _active = active; + _tutorial = tutorial; + _incognito = incognito; + + _communicator.CollectionChange.Subscribe(OnCollectionChange, CollectionChange.Priority.CollectionSelector); + // Set items. + OnCollectionChange(CollectionType.Inactive, null, null, string.Empty); + // Set selection. + OnCollectionChange(CollectionType.Current, null, _active.Current, string.Empty); + } + + protected override bool OnDelete(int idx) + { + if (idx < 0 || idx >= Items.Count) + return false; + + // Always return false since we handle the selection update ourselves. + _storage.RemoveCollection(Items[idx]); + return false; + } + + protected override bool DeleteButtonEnabled() + => _storage.DefaultNamed != Current && _config.DeleteModModifier.IsActive(); + + protected override string DeleteButtonTooltip() + => _storage.DefaultNamed == Current + ? $"The selected collection {Name(Current)} can not be deleted." + : $"Delete the currently selected collection {(Current != null ? Name(Current) : string.Empty)}. Hold {_config.DeleteModModifier} to delete."; + + protected override bool OnAdd(string name) + => _storage.AddCollection(name, null); + + protected override bool OnDuplicate(string name, int idx) + { + if (idx < 0 || idx >= Items.Count) + return false; + + return _storage.AddCollection(name, Items[idx]); + } + + protected override bool Filtered(int idx) + => !Items[idx].Identity.Name.Contains(Filter, StringComparison.OrdinalIgnoreCase); + + private const string PayloadString = "Collection"; + + protected override bool OnDraw(int idx) + { + using var color = ImRaii.PushColor(ImGuiCol.Header, ColorId.SelectedCollection.Value()); + var ret = ImGui.Selectable(Name(Items[idx]), idx == CurrentIdx); + using var source = ImRaii.DragDropSource(); + + if (idx == CurrentIdx) + _tutorial.OpenTutorial(BasicTutorialSteps.CurrentCollection); + + if (source) + { + _dragging = Items[idx]; + ImGui.SetDragDropPayload(PayloadString, null, 0); + ImGui.TextUnformatted($"Assigning {Name(_dragging)} to..."); + } + + if (ret) + _active.SetCollection(Items[idx], CollectionType.Current); + + return ret; + } + + public void DragTargetAssignment(CollectionType type, ActorIdentifier identifier) + { + using var target = ImRaii.DragDropTarget(); + if (!target.Success || _dragging == null || !ImGuiUtil.IsDropping(PayloadString)) + return; + + _active.SetCollection(_dragging, type, _active.Individuals.GetGroup(identifier)); + _dragging = null; + } + + public void Dispose() + { + _communicator.CollectionChange.Unsubscribe(OnCollectionChange); + } + + private string Name(ModCollection collection) + => _incognito.IncognitoMode || collection.Identity.Name.Length == 0 ? collection.Identity.AnonymizedName : collection.Identity.Name; + + public void RestoreCollections() + { + Items.Clear(); + Items.Add(_storage.DefaultNamed); + foreach (var c in _storage.OrderBy(c => c.Identity.Name).Where(c => c != _storage.DefaultNamed)) + Items.Add(c); + SetFilterDirty(); + SetCurrent(_active.Current); + } + + private void OnCollectionChange(CollectionType type, ModCollection? old, ModCollection? @new, string _3) + { + switch (type) + { + case CollectionType.Temporary: return; + case CollectionType.Current: + if (@new != null) + SetCurrent(@new); + SetFilterDirty(); + return; + case CollectionType.Inactive: + RestoreCollections(); + SetFilterDirty(); + return; + default: + SetFilterDirty(); + return; + } + } +} diff --git a/Penumbra/UI/CollectionTab/IndividualAssignmentUi.cs b/Penumbra/UI/CollectionTab/IndividualAssignmentUi.cs new file mode 100644 index 00000000..f472e346 --- /dev/null +++ b/Penumbra/UI/CollectionTab/IndividualAssignmentUi.cs @@ -0,0 +1,190 @@ +using Dalamud.Game.ClientState.Objects.Enums; +using Dalamud.Bindings.ImGui; +using OtterGui.Custom; +using Penumbra.Collections; +using Penumbra.Collections.Manager; +using Penumbra.Communication; +using Penumbra.GameData.Actors; +using Penumbra.GameData.Enums; +using Penumbra.GameData.Gui; +using Penumbra.GameData.Structs; +using Penumbra.Services; + +namespace Penumbra.UI.CollectionTab; + +public class IndividualAssignmentUi : IDisposable +{ + private readonly CommunicatorService _communicator; + private readonly ActorManager _actors; + private readonly CollectionManager _collectionManager; + + private WorldCombo _worldCombo = null!; + private NpcCombo _mountCombo = null!; + private NpcCombo _companionCombo = null!; + private NpcCombo _ornamentCombo = null!; + private NpcCombo _bnpcCombo = null!; + private NpcCombo _enpcCombo = null!; + + private bool _ready; + + public IndividualAssignmentUi(CommunicatorService communicator, ActorManager actors, CollectionManager collectionManager) + { + _communicator = communicator; + _actors = actors; + _collectionManager = collectionManager; + _communicator.CollectionChange.Subscribe(UpdateIdentifiers, CollectionChange.Priority.IndividualAssignmentUi); + _actors.Awaiter.ContinueWith(_ => SetupCombos(), TaskScheduler.Default); + } + + public string PlayerTooltip { get; private set; } = NewPlayerTooltipEmpty; + public string RetainerTooltip { get; private set; } = NewRetainerTooltipEmpty; + public string NpcTooltip { get; private set; } = NewNpcTooltipEmpty; + public string OwnedTooltip { get; private set; } = NewPlayerTooltipEmpty; + + public ActorIdentifier[] PlayerIdentifiers + => _playerIdentifiers; + + public ActorIdentifier[] RetainerIdentifiers + => _retainerIdentifiers; + + public ActorIdentifier[] NpcIdentifiers + => _npcIdentifiers; + + public ActorIdentifier[] OwnedIdentifiers + => _ownedIdentifiers; + + public void DrawWorldCombo(float width) + { + if (_ready && _worldCombo.Draw(width)) + UpdateIdentifiersInternal(); + } + + public void DrawObjectKindCombo(float width) + { + if (_ready && IndividualHelpers.DrawObjectKindCombo(width, _newKind, out _newKind, ObjectKinds)) + UpdateIdentifiersInternal(); + } + + public void DrawNewPlayerCollection(float width) + { + if (!_ready) + return; + + ImGui.SetNextItemWidth(width); + if (ImGui.InputTextWithHint("##NewCharacter", "Character Name...", ref _newCharacterName, 32)) + UpdateIdentifiersInternal(); + } + + public void DrawNewNpcCollection(float width) + { + if (!_ready) + return; + + var combo = GetNpcCombo(_newKind); + if (combo.Draw(width)) + UpdateIdentifiersInternal(); + } + + public void Dispose() + => _communicator.CollectionChange.Unsubscribe(UpdateIdentifiers); + + // Input Selections. + private string _newCharacterName = string.Empty; + private ObjectKind _newKind = ObjectKind.BattleNpc; + private ActorIdentifier[] _playerIdentifiers = []; + private ActorIdentifier[] _retainerIdentifiers = []; + private ActorIdentifier[] _npcIdentifiers = []; + private ActorIdentifier[] _ownedIdentifiers = []; + + private const string NewPlayerTooltipEmpty = "Please enter a valid player name and choose an available world or 'Any World'."; + private const string NewRetainerTooltipEmpty = "Please enter a valid retainer name."; + private const string NewPlayerTooltipInvalid = "The entered name is not a valid name for a player character."; + private const string NewRetainerTooltipInvalid = "The entered name is not a valid name for a retainer."; + private const string AlreadyAssigned = "The Individual you specified has already been assigned a collection."; + private const string NewNpcTooltipEmpty = "Please select a valid NPC from the drop down menu first."; + + private static readonly IReadOnlyList ObjectKinds = new[] + { + ObjectKind.BattleNpc, + ObjectKind.EventNpc, + ObjectKind.Companion, + ObjectKind.MountType, + ObjectKind.Ornament, + }; + + private NpcCombo GetNpcCombo(ObjectKind kind) + => kind switch + { + ObjectKind.BattleNpc => _bnpcCombo, + ObjectKind.EventNpc => _enpcCombo, + ObjectKind.MountType => _mountCombo, + ObjectKind.Companion => _companionCombo, + ObjectKind.Ornament => _ornamentCombo, + _ => throw new NotImplementedException(), + }; + + /// Create combos when ready. + private void SetupCombos() + { + _worldCombo = new WorldCombo(_actors.Data.Worlds, Penumbra.Log); + _mountCombo = new NpcCombo("##mountCombo", _actors.Data.Mounts, Penumbra.Log); + _companionCombo = new NpcCombo("##companionCombo", _actors.Data.Companions, Penumbra.Log); + _ornamentCombo = new NpcCombo("##ornamentCombo", _actors.Data.Ornaments, Penumbra.Log); + _bnpcCombo = new NpcCombo("##bnpcCombo", _actors.Data.BNpcs, Penumbra.Log); + _enpcCombo = new NpcCombo("##enpcCombo", _actors.Data.ENpcs, Penumbra.Log); + _ready = true; + } + + private void UpdateIdentifiers(CollectionType type, ModCollection? _1, ModCollection? _2, string _3) + { + if (type == CollectionType.Individual) + UpdateIdentifiersInternal(); + } + + private void UpdateIdentifiersInternal() + { + var combo = GetNpcCombo(_newKind); + PlayerTooltip = _collectionManager.Active.Individuals.CanAdd(IdentifierType.Player, _newCharacterName, + _worldCombo.CurrentSelection.Key, ObjectKind.None, [], out _playerIdentifiers) switch + { + _ when _newCharacterName.Length == 0 => NewPlayerTooltipEmpty, + IndividualCollections.AddResult.Invalid => NewPlayerTooltipInvalid, + IndividualCollections.AddResult.AlreadySet => AlreadyAssigned, + _ => string.Empty, + }; + RetainerTooltip = + _collectionManager.Active.Individuals.CanAdd(IdentifierType.Retainer, _newCharacterName, 0, ObjectKind.None, [], + out _retainerIdentifiers) switch + { + _ when _newCharacterName.Length == 0 => NewRetainerTooltipEmpty, + IndividualCollections.AddResult.Invalid => NewRetainerTooltipInvalid, + IndividualCollections.AddResult.AlreadySet => AlreadyAssigned, + _ => string.Empty, + }; + if (combo.CurrentSelection.Ids != null) + { + NpcTooltip = _collectionManager.Active.Individuals.CanAdd(IdentifierType.Npc, string.Empty, ushort.MaxValue, _newKind, + combo.CurrentSelection.Ids, out _npcIdentifiers) switch + { + IndividualCollections.AddResult.AlreadySet => AlreadyAssigned, + _ => string.Empty, + }; + OwnedTooltip = _collectionManager.Active.Individuals.CanAdd(IdentifierType.Owned, _newCharacterName, + _worldCombo.CurrentSelection.Key, _newKind, + combo.CurrentSelection.Ids, out _ownedIdentifiers) switch + { + _ when _newCharacterName.Length == 0 => NewPlayerTooltipEmpty, + IndividualCollections.AddResult.Invalid => NewPlayerTooltipInvalid, + IndividualCollections.AddResult.AlreadySet => AlreadyAssigned, + _ => string.Empty, + }; + } + else + { + NpcTooltip = NewNpcTooltipEmpty; + OwnedTooltip = NewNpcTooltipEmpty; + _npcIdentifiers = []; + _ownedIdentifiers = []; + } + } +} diff --git a/Penumbra/UI/CollectionTab/InheritanceUi.cs b/Penumbra/UI/CollectionTab/InheritanceUi.cs new file mode 100644 index 00000000..2053f269 --- /dev/null +++ b/Penumbra/UI/CollectionTab/InheritanceUi.cs @@ -0,0 +1,317 @@ +using Dalamud.Interface; +using Dalamud.Bindings.ImGui; +using OtterGui; +using OtterGui.Extensions; +using OtterGui.Raii; +using OtterGui.Services; +using Penumbra.Collections; +using Penumbra.Collections.Manager; +using Penumbra.UI.Classes; + +namespace Penumbra.UI.CollectionTab; + +public class InheritanceUi(CollectionManager collectionManager, IncognitoService incognito) : IUiService +{ + private const int InheritedCollectionHeight = 9; + private const string InheritanceDragDropLabel = "##InheritanceMove"; + + private readonly CollectionStorage _collections = collectionManager.Storage; + private readonly ActiveCollections _active = collectionManager.Active; + private readonly InheritanceManager _inheritance = collectionManager.Inheritances; + + /// Draw the whole inheritance block. + public void Draw() + { + using var id = ImRaii.PushId("##Inheritance"); + ImGuiUtil.DrawColoredText(($"The {TutorialService.SelectedCollection} ", 0), + (Name(_active.Current), ColorId.SelectedCollection.Value() | 0xFF000000), (" inherits from:", 0)); + ImGui.Dummy(Vector2.One); + + DrawCurrentCollectionInheritance(); + ImGui.SameLine(); + DrawInheritanceTrashButton(); + ImGui.SameLine(); + DrawRightText(); + + DrawNewInheritanceSelection(); + ImGui.SameLine(); + if (ImGui.Button("More Information about Inheritance", new Vector2(ImGui.GetContentRegionAvail().X, 0))) + ImGui.OpenPopup("InheritanceHelp"); + + DrawHelpPopup(); + DelayedActions(); + } + + // Keep for reuse. + private readonly HashSet _seenInheritedCollections = new(32); + + // Execute changes only outside of loops. + private ModCollection? _newInheritance; + private ModCollection? _movedInheritance; + private (int, int)? _inheritanceAction; + private ModCollection? _newCurrentCollection; + + private static void DrawRightText() + { + using var group = ImRaii.Group(); + ImGuiUtil.TextWrapped( + "Inheritance is a way to use a baseline of mods across multiple collections, without needing to change all those collections if you want to add a single mod."); + ImGuiUtil.TextWrapped( + "You can select inheritances from the combo below to add them.\nSince the order of inheritances is important, you can reorder them here via drag and drop.\nYou can also delete inheritances by dragging them onto the trash can."); + } + + private static void DrawHelpPopup() + => ImGuiUtil.HelpPopup("InheritanceHelp", new Vector2(1000 * UiHelpers.Scale, 20 * ImGui.GetTextLineHeightWithSpacing()), () => + { + ImGui.NewLine(); + ImGui.TextUnformatted("Every mod in a collection can have three basic states: 'Enabled', 'Disabled' and 'Unconfigured'."); + ImGui.BulletText("If the mod is 'Enabled' or 'Disabled', it does not matter if the collection inherits from other collections."); + ImGui.BulletText( + "If the mod is unconfigured, those inherited-from collections are checked in the order displayed here, including sub-inheritances."); + ImGui.BulletText( + "If a collection is found in which the mod is either 'Enabled' or 'Disabled', the settings from this collection will be used."); + ImGui.BulletText("If no such collection is found, the mod will be treated as disabled."); + ImGui.BulletText( + "Highlighted collections in the left box are never reached because they are already checked in a sub-inheritance before."); + ImGui.NewLine(); + ImGui.TextUnformatted("Example"); + ImGui.BulletText("Collection A has the Bibo+ body and a Hempen Camise mod enabled."); + ImGui.BulletText( + "Collection B inherits from A, leaves Bibo+ unconfigured, but has the Hempen Camise enabled with different settings than A."); + ImGui.BulletText("Collection C also inherits from A, has Bibo+ explicitly disabled and the Hempen Camise unconfigured."); + ImGui.BulletText("Collection D inherits from C and then B and leaves everything unconfigured."); + using var indent = ImRaii.PushIndent(); + ImGui.BulletText("B uses Bibo+ settings from A and its own Hempen Camise settings."); + ImGui.BulletText("C has Bibo+ disabled and uses A's Hempen Camise settings."); + ImGui.BulletText( + "D has Bibo+ disabled and uses A's Hempen Camise settings, not B's. It traversed the collections in Order D -> (C -> A) -> (B -> A)."); + }); + + + /// + /// If an inherited collection is expanded, + /// draw all its flattened, distinct children in order with a tree-line. + /// + private void DrawInheritedChildren(ModCollection collection) + { + using var id = ImRaii.PushId(collection.Identity.Index); + using var indent = ImRaii.PushIndent(); + + // Get start point for the lines (top of the selector). + // Tree line stuff. + var lineStart = ImGui.GetCursorScreenPos(); + var offsetX = -ImGui.GetStyle().IndentSpacing + ImGui.GetTreeNodeToLabelSpacing() / 2; + var drawList = ImGui.GetWindowDrawList(); + var lineSize = Math.Max(0, ImGui.GetStyle().IndentSpacing - 9 * UiHelpers.Scale); + lineStart.X += offsetX; + lineStart.Y -= 2 * UiHelpers.Scale; + var lineEnd = lineStart; + + // Skip the collection itself. + foreach (var inheritance in collection.Inheritance.FlatHierarchy.Skip(1)) + { + // Draw the child, already seen collections are colored as conflicts. + using var color = ImRaii.PushColor(ImGuiCol.Text, ColorId.HandledConflictMod.Value(), + _seenInheritedCollections.Contains(inheritance)); + _seenInheritedCollections.Add(inheritance); + + ImRaii.TreeNode($"{Name(inheritance)}###{inheritance.Identity.Id}", + ImGuiTreeNodeFlags.NoTreePushOnOpen | ImGuiTreeNodeFlags.Leaf | ImGuiTreeNodeFlags.Bullet); + var (minRect, maxRect) = (ImGui.GetItemRectMin(), ImGui.GetItemRectMax()); + DrawInheritanceTreeClicks(inheritance, false); + + // Tree line stuff. + if (minRect.X == 0) + continue; + + // Draw the notch and increase the line length. + var midPoint = (minRect.Y + maxRect.Y) / 2f - 1f; + drawList.AddLine(lineStart with { Y = midPoint }, new Vector2(lineStart.X + lineSize, midPoint), Colors.MetaInfoText, + UiHelpers.Scale); + lineEnd.Y = midPoint; + } + + // Finally, draw the folder line. + drawList.AddLine(lineStart, lineEnd, Colors.MetaInfoText, UiHelpers.Scale); + } + + /// Draw a single primary inherited collection. + private void DrawInheritance(ModCollection collection) + { + using var color = ImRaii.PushColor(ImGuiCol.Text, ColorId.HandledConflictMod.Value(), + _seenInheritedCollections.Contains(collection)); + _seenInheritedCollections.Add(collection); + using var tree = ImRaii.TreeNode($"{Name(collection)}###{collection.Identity.Name}", ImGuiTreeNodeFlags.NoTreePushOnOpen); + color.Pop(); + DrawInheritanceTreeClicks(collection, true); + DrawInheritanceDropSource(collection); + DrawInheritanceDropTarget(collection); + + if (tree) + DrawInheritedChildren(collection); + else + // We still want to keep track of conflicts. + _seenInheritedCollections.UnionWith(collection.Inheritance.FlatHierarchy); + } + + /// Draw the list box containing the current inheritance information. + private void DrawCurrentCollectionInheritance() + { + using var list = ImRaii.ListBox("##inheritanceList", + new Vector2(UiHelpers.InputTextMinusButton, ImGui.GetTextLineHeightWithSpacing() * InheritedCollectionHeight)); + if (!list) + return; + + _seenInheritedCollections.Clear(); + _seenInheritedCollections.Add(_active.Current); + foreach (var collection in _active.Current.Inheritance.DirectlyInheritsFrom.ToList()) + DrawInheritance(collection); + } + + /// Draw a drag and drop button to delete. + private void DrawInheritanceTrashButton() + { + var size = UiHelpers.IconButtonSize with { Y = ImGui.GetTextLineHeightWithSpacing() * InheritedCollectionHeight }; + var buttonColor = ImGui.GetColorU32(ImGuiCol.Button); + // Prevent hovering from highlighting the button. + using var color = ImRaii.PushColor(ImGuiCol.ButtonActive, buttonColor) + .Push(ImGuiCol.ButtonHovered, buttonColor); + ImGuiUtil.DrawDisabledButton(FontAwesomeIcon.Trash.ToIconString(), size, + "Drag primary inheritance here to remove it from the list.", false, true); + + using var target = ImRaii.DragDropTarget(); + if (target.Success && ImGuiUtil.IsDropping(InheritanceDragDropLabel)) + _inheritanceAction = (_active.Current.Inheritance.DirectlyInheritsFrom.IndexOf(_movedInheritance!), -1); + } + + /// + /// Set the current collection, or delete or move an inheritance if the action was triggered during iteration. + /// Can not be done during iteration to keep collections unchanged. + /// + private void DelayedActions() + { + if (_newCurrentCollection != null) + { + _active.SetCollection(_newCurrentCollection, CollectionType.Current); + _newCurrentCollection = null; + } + + if (_inheritanceAction == null) + return; + + if (_inheritanceAction.Value.Item1 >= 0) + { + if (_inheritanceAction.Value.Item2 == -1) + _inheritance.RemoveInheritance(_active.Current, _inheritanceAction.Value.Item1); + else + _inheritance.MoveInheritance(_active.Current, _inheritanceAction.Value.Item1, _inheritanceAction.Value.Item2); + } + + _inheritanceAction = null; + } + + /// + /// Draw the selector to add new inheritances. + /// The add button is only available if the selected collection can actually be added. + /// + private void DrawNewInheritanceSelection() + { + DrawNewInheritanceCombo(); + ImGui.SameLine(); + var inheritance = InheritanceManager.CheckValidInheritance(_active.Current, _newInheritance); + var tt = inheritance switch + { + InheritanceManager.ValidInheritance.Empty => "No valid collection to inherit from selected.", + InheritanceManager.ValidInheritance.Valid => $"Let the {TutorialService.SelectedCollection} inherit from this collection.", + InheritanceManager.ValidInheritance.Self => "The collection can not inherit from itself.", + InheritanceManager.ValidInheritance.Contained => "Already inheriting from this collection.", + InheritanceManager.ValidInheritance.Circle => "Inheriting from this collection would lead to cyclic inheritance.", + _ => string.Empty, + }; + if (ImGuiUtil.DrawDisabledButton(FontAwesomeIcon.Plus.ToIconString(), UiHelpers.IconButtonSize, tt, + inheritance != InheritanceManager.ValidInheritance.Valid, true) + && _inheritance.AddInheritance(_active.Current, _newInheritance!)) + _newInheritance = null; + + if (inheritance != InheritanceManager.ValidInheritance.Valid) + _newInheritance = null; + } + + /// + /// Draw the combo to select new potential inheritances. + /// Only valid inheritances are drawn in the preview, or nothing if no inheritance is available. + /// + private void DrawNewInheritanceCombo() + { + ImGui.SetNextItemWidth(UiHelpers.InputTextMinusButton); + _newInheritance ??= _collections.FirstOrDefault(c + => c != _active.Current && !_active.Current.Inheritance.DirectlyInheritsFrom.Contains(c)) + ?? ModCollection.Empty; + using var combo = ImRaii.Combo("##newInheritance", Name(_newInheritance)); + if (!combo) + return; + + foreach (var collection in _collections + .Where(c => InheritanceManager.CheckValidInheritance(_active.Current, c) == InheritanceManager.ValidInheritance.Valid) + .OrderBy(c => c.Identity.Name)) + { + if (ImGui.Selectable(Name(collection), _newInheritance == collection)) + _newInheritance = collection; + } + } + + /// + /// Move an inherited collection when dropped onto another. + /// Move is delayed due to collection changes. + /// + private void DrawInheritanceDropTarget(ModCollection collection) + { + using var target = ImRaii.DragDropTarget(); + if (!target.Success || !ImGuiUtil.IsDropping(InheritanceDragDropLabel)) + return; + + if (_movedInheritance != null) + { + var idx1 = _active.Current.Inheritance.DirectlyInheritsFrom.IndexOf(_movedInheritance); + var idx2 = _active.Current.Inheritance.DirectlyInheritsFrom.IndexOf(collection); + if (idx1 >= 0 && idx2 >= 0) + _inheritanceAction = (idx1, idx2); + } + + _movedInheritance = null; + } + + /// Move an inherited collection. + private void DrawInheritanceDropSource(ModCollection collection) + { + using var source = ImRaii.DragDropSource(); + if (!source) + return; + + ImGui.SetDragDropPayload(InheritanceDragDropLabel, null, 0); + _movedInheritance = collection; + ImGui.TextUnformatted($"Moving {(_movedInheritance != null ? Name(_movedInheritance) : "Unknown")}..."); + } + + /// + /// Ctrl + Right-Click -> Switch current collection to this (for all). + /// Ctrl + Shift + Right-Click -> Delete this inheritance (only if withDelete). + /// Deletion is delayed due to collection changes. + /// + private void DrawInheritanceTreeClicks(ModCollection collection, bool withDelete) + { + if (ImGui.GetIO().KeyCtrl && ImGui.IsItemClicked(ImGuiMouseButton.Right)) + { + if (withDelete && ImGui.GetIO().KeyShift) + _inheritanceAction = (_active.Current.Inheritance.DirectlyInheritsFrom.IndexOf(collection), -1); + else + _newCurrentCollection = collection; + } + + ImGuiUtil.HoverTooltip($"Control + Right-Click to switch the {TutorialService.SelectedCollection} to this one." + + (withDelete ? "\nControl + Shift + Right-Click to remove this inheritance." : string.Empty)); + } + + private string Name(ModCollection collection) + => incognito.IncognitoMode ? collection.Identity.AnonymizedName : collection.Identity.Name; +} diff --git a/Penumbra/UI/ConfigWindow.cs b/Penumbra/UI/ConfigWindow.cs new file mode 100644 index 00000000..55d0bc19 --- /dev/null +++ b/Penumbra/UI/ConfigWindow.cs @@ -0,0 +1,173 @@ +using Dalamud.Interface.Windowing; +using Dalamud.Plugin; +using Dalamud.Bindings.ImGui; +using OtterGui; +using OtterGui.Custom; +using OtterGui.Raii; +using OtterGui.Services; +using OtterGui.Text; +using Penumbra.Api.Enums; +using Penumbra.Services; +using Penumbra.UI.Classes; +using Penumbra.UI.Tabs; +using Penumbra.Util; + +namespace Penumbra.UI; + +public sealed class ConfigWindow : Window, IUiService +{ + private readonly IDalamudPluginInterface _pluginInterface; + private readonly Configuration _config; + private readonly PerformanceTracker _tracker; + private readonly ValidityChecker _validityChecker; + private Penumbra? _penumbra; + private ConfigTabBar _configTabs = null!; + private string? _lastException; + + public ConfigWindow(PerformanceTracker tracker, IDalamudPluginInterface pi, Configuration config, ValidityChecker checker, + TutorialService tutorial) + : base(GetLabel(checker)) + { + _pluginInterface = pi; + _config = config; + _tracker = tracker; + _validityChecker = checker; + + RespectCloseHotkey = true; + tutorial.UpdateTutorialStep(); + IsOpen = _config.OpenWindowAtStart; + } + + public void OpenSettings() + { + _configTabs.SelectTab = TabType.Settings; + IsOpen = true; + } + + public void Setup(Penumbra penumbra, ConfigTabBar configTabs) + { + _penumbra = penumbra; + _configTabs = configTabs; + _configTabs.SelectTab = _config.Ephemeral.SelectedTab; + } + + public override bool DrawConditions() + => _penumbra != null; + + public override void PreDraw() + { + if (_config.Ephemeral.FixMainWindow) + Flags |= ImGuiWindowFlags.NoResize | ImGuiWindowFlags.NoMove; + else + Flags &= ~(ImGuiWindowFlags.NoResize | ImGuiWindowFlags.NoMove); + SizeConstraints = new WindowSizeConstraints() + { + MinimumSize = _config.MinimumSize, + MaximumSize = new Vector2(4096, 2160), + }; + } + + public override void Draw() + { + using var timer = _tracker.Measure(PerformanceType.UiMainWindow); + UiHelpers.SetupCommonSizes(); + try + { + if (_validityChecker.ImcExceptions.Count > 0) + { + DrawProblemWindow( + $"There were {_validityChecker.ImcExceptions.Count} errors while trying to load IMC files from the game data.\n" + + "This usually means that your game installation was corrupted by updating the game while having TexTools mods still active.\n" + + "It is recommended to not use TexTools and Penumbra (or other Lumina-based tools) at the same time.\n\n" + + "Please use the Launcher's Repair Game Files function to repair your client installation."); + DrawImcExceptions(); + } + else if (!_validityChecker.IsValidSourceRepo) + { + DrawProblemWindow( + $"You are loading a release version of Penumbra from the repository \"{_pluginInterface.SourceRepository}\" instead of the official repository.\n" + + $"Please use the official repository at {ValidityChecker.Repository} or the suite repository at {ValidityChecker.SeaOfStars}.\n\n" + + "If you are developing for Penumbra and see this, you should compile your version in debug mode to avoid it."); + } + else if (_validityChecker.IsNotInstalledPenumbra) + { + DrawProblemWindow( + $"You are loading a release version of Penumbra from \"{_pluginInterface.AssemblyLocation.Directory?.FullName ?? "Unknown"}\" instead of the installedPlugins directory.\n\n" + + "You should not install Penumbra manually, but rather add the plugin repository under settings and then install it via the plugin installer.\n\n" + + "If you do not know how to do this, please take a look at the readme in Penumbras github repository or join us in discord.\n" + + "If you are developing for Penumbra and see this, you should compile your version in debug mode to avoid it."); + } + else if (_validityChecker.DevPenumbraExists) + { + DrawProblemWindow( + $"You are loading a installed version of Penumbra from \"{_pluginInterface.AssemblyLocation.Directory?.FullName ?? "Unknown"}\", " + + "but also still have some remnants of a custom install of Penumbra in your devPlugins folder.\n\n" + + "This can cause some issues, so please go to your \"%%appdata%%\\XIVLauncher\\devPlugins\" folder and delete the Penumbra folder from there.\n\n" + + "If you are developing for Penumbra, try to avoid mixing versions. This warning will not appear if compiled in Debug mode."); + } + else + { + var type = _configTabs.Draw(); + if (type != _config.Ephemeral.SelectedTab) + { + _config.Ephemeral.SelectedTab = type; + _config.Ephemeral.Save(); + } + } + + _lastException = null; + } + catch (Exception e) + { + if (_lastException != null) + { + var text = e.ToString(); + if (text == _lastException) + return; + + _lastException = text; + } + else + { + _lastException = e.ToString(); + } + + Penumbra.Log.Error($"Exception thrown during UI Render:\n{_lastException}"); + } + } + + private static string GetLabel(ValidityChecker checker) + => checker.Version.Length == 0 + ? "Penumbra###PenumbraConfigWindow" + : $"Penumbra v{checker.Version}###PenumbraConfigWindow"; + + private void DrawProblemWindow(string text) + { + using var color = ImRaii.PushColor(ImGuiCol.Text, Colors.RegexWarningBorder); + ImGui.NewLine(); + ImGui.NewLine(); + ImUtf8.TextWrapped(text); + color.Pop(); + + ImGui.NewLine(); + ImGui.NewLine(); + CustomGui.DrawDiscordButton(Penumbra.Messager, 0); + ImGui.SameLine(); + UiHelpers.DrawSupportButton(_penumbra!); + ImGui.NewLine(); + ImGui.NewLine(); + } + + private void DrawImcExceptions() + { + ImGui.TextUnformatted("Exceptions"); + ImGui.Separator(); + using var box = ImRaii.ListBox("##Exceptions", new Vector2(-1, -1)); + foreach (var exception in _validityChecker.ImcExceptions) + { + ImGuiUtil.TextWrapped(exception.ToString()); + ImGui.Separator(); + ImGui.NewLine(); + } + } +} diff --git a/Penumbra/UI/Custom/ImGuiFramedGroup.cs b/Penumbra/UI/Custom/ImGuiFramedGroup.cs deleted file mode 100644 index 0330eb32..00000000 --- a/Penumbra/UI/Custom/ImGuiFramedGroup.cs +++ /dev/null @@ -1,146 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Numerics; -using ImGuiNET; - -namespace Penumbra.UI.Custom -{ - public static partial class ImGuiCustom - { - public static void BeginFramedGroup( string label ) - => BeginFramedGroupInternal( ref label, Vector2.Zero, false ); - - public static void BeginFramedGroup( string label, Vector2 minSize ) - => BeginFramedGroupInternal( ref label, minSize, false ); - - public static bool BeginFramedGroupEdit( ref string label ) - => BeginFramedGroupInternal( ref label, Vector2.Zero, true ); - - public static bool BeginFramedGroupEdit( ref string label, Vector2 minSize ) - => BeginFramedGroupInternal( ref label, minSize, true ); - - private static bool BeginFramedGroupInternal( ref string label, Vector2 minSize, bool edit ) - { - var itemSpacing = ImGui.GetStyle().ItemSpacing; - var frameHeight = ImGui.GetFrameHeight(); - var halfFrameHeight = new Vector2( ImGui.GetFrameHeight() / 2, 0 ); - - ImGui.BeginGroup(); // First group - - ImGui.PushStyleVar( ImGuiStyleVar.FramePadding, Vector2.Zero ); - ImGui.PushStyleVar( ImGuiStyleVar.ItemSpacing, Vector2.Zero ); - - ImGui.BeginGroup(); // Second group - - var effectiveSize = minSize; - if( effectiveSize.X < 0 ) - { - effectiveSize.X = ImGui.GetContentRegionAvail().X; - } - - // Ensure width. - ImGui.Dummy( Vector2.UnitX * effectiveSize.X ); - // Ensure left half boundary width/distance. - ImGui.Dummy( halfFrameHeight ); - - ImGui.SameLine(); - ImGui.BeginGroup(); // Third group. - // Ensure right half of boundary width/distance - ImGui.Dummy( halfFrameHeight ); - - // Label block - ImGui.SameLine(); - var ret = false; - if( edit ) - { - ret = ResizingTextInput( ref label, 1024 ); - } - else - { - ImGui.TextUnformatted( label ); - } - - var labelMin = ImGui.GetItemRectMin(); - var labelMax = ImGui.GetItemRectMax(); - ImGui.SameLine(); - // Ensure height and distance to label. - ImGui.Dummy( Vector2.UnitY * ( frameHeight + itemSpacing.Y ) ); - - ImGui.BeginGroup(); // Fourth Group. - - ImGui.PopStyleVar( 2 ); - - // This seems wrong? - //ImGui.SetWindowSize( new Vector2( ImGui.GetWindowSize().X - frameHeight, ImGui.GetWindowSize().Y ) ); - - var itemWidth = ImGui.CalcItemWidth(); - ImGui.PushItemWidth( Math.Max( 0f, itemWidth - frameHeight ) ); - - LabelStack.Add( ( labelMin, labelMax ) ); - return ret; - } - - private static void DrawClippedRect( Vector2 clipMin, Vector2 clipMax, Vector2 drawMin, Vector2 drawMax, uint color, float thickness ) - { - ImGui.PushClipRect( clipMin, clipMax, true ); - ImGui.GetWindowDrawList().AddRect( drawMin, drawMax, color, thickness ); - ImGui.PopClipRect(); - } - - public static void EndFramedGroup() - { - var borderColor = ImGui.ColorConvertFloat4ToU32( ImGui.GetStyle().Colors[ ( int )ImGuiCol.Border ] ); - var itemSpacing = ImGui.GetStyle().ItemSpacing; - var frameHeight = ImGui.GetFrameHeight(); - var halfFrameHeight = new Vector2( ImGui.GetFrameHeight() / 2, 0 ); - - ImGui.PopItemWidth(); - - ImGui.PushStyleVar( ImGuiStyleVar.FramePadding, Vector2.Zero ); - ImGui.PushStyleVar( ImGuiStyleVar.ItemSpacing, Vector2.Zero ); - - ImGui.EndGroup(); // Close fourth group - ImGui.EndGroup(); // Close third group - - ImGui.SameLine(); - // Ensure right distance. - ImGui.Dummy( halfFrameHeight ); - // Ensure bottom distance - ImGui.Dummy( Vector2.UnitY * ( frameHeight / 2 - itemSpacing.Y ) ); - ImGui.EndGroup(); // Close second group - - var itemMin = ImGui.GetItemRectMin(); - var itemMax = ImGui.GetItemRectMax(); - var (currentLabelMin, currentLabelMax) = LabelStack[ ^1 ]; - LabelStack.RemoveAt( LabelStack.Count - 1 ); - - var halfFrame = new Vector2( frameHeight / 8, frameHeight / 2 ); - currentLabelMin.X -= itemSpacing.X; - currentLabelMax.X += itemSpacing.X; - var frameMin = itemMin + halfFrame; - var frameMax = itemMax - Vector2.UnitX * halfFrame.X; - - // Left - DrawClippedRect( new Vector2( -float.MaxValue, -float.MaxValue ), new Vector2( currentLabelMin.X, float.MaxValue ), frameMin, - frameMax, borderColor, halfFrame.X ); - // Right - DrawClippedRect( new Vector2( currentLabelMax.X, -float.MaxValue ), new Vector2( float.MaxValue, float.MaxValue ), frameMin, - frameMax, borderColor, halfFrame.X ); - // Top - DrawClippedRect( new Vector2( currentLabelMin.X, -float.MaxValue ), new Vector2( currentLabelMax.X, currentLabelMin.Y ), frameMin, - frameMax, borderColor, halfFrame.X ); - // Bottom - DrawClippedRect( new Vector2( currentLabelMin.X, currentLabelMax.Y ), new Vector2( currentLabelMax.X, float.MaxValue ), frameMin, - frameMax, borderColor, halfFrame.X ); - - ImGui.PopStyleVar( 2 ); - // This seems wrong? - // ImGui.SetWindowSize( new Vector2( ImGui.GetWindowSize().X + frameHeight, ImGui.GetWindowSize().Y ) ); - ImGui.Dummy( Vector2.Zero ); - - ImGui.EndGroup(); // Close first group - } - - private static readonly List< (Vector2, Vector2) > LabelStack = new(); - } -} \ No newline at end of file diff --git a/Penumbra/UI/Custom/ImGuiRenameableCombo.cs b/Penumbra/UI/Custom/ImGuiRenameableCombo.cs deleted file mode 100644 index 2e81f125..00000000 --- a/Penumbra/UI/Custom/ImGuiRenameableCombo.cs +++ /dev/null @@ -1,54 +0,0 @@ -using ImGuiNET; - -namespace Penumbra.UI.Custom -{ - public static partial class ImGuiCustom - { - public static bool RenameableCombo( string label, ref int currentItem, out string newName, string[] items, int numItems ) - { - var ret = false; - newName = ""; - var newOption = ""; - if( !ImGui.BeginCombo( label, numItems > 0 ? items[ currentItem ] : newOption ) ) - { - return false; - } - - for( var i = 0; i < numItems; ++i ) - { - var isSelected = i == currentItem; - ImGui.SetNextItemWidth( -1 ); - if( ImGui.InputText( $"##{label}_{i}", ref items[ i ], 64, ImGuiInputTextFlags.EnterReturnsTrue ) ) - { - currentItem = i; - newName = items[ i ]; - ret = true; - ImGui.CloseCurrentPopup(); - } - - if( isSelected ) - { - ImGui.SetItemDefaultFocus(); - } - } - - ImGui.SetNextItemWidth( -1 ); - if( ImGui.InputTextWithHint( $"##{label}_new", "Add new item...", ref newOption, 64, ImGuiInputTextFlags.EnterReturnsTrue ) ) - { - currentItem = numItems; - newName = newOption; - ret = true; - ImGui.CloseCurrentPopup(); - } - - if( numItems == 0 ) - { - ImGui.SetItemDefaultFocus(); - } - - ImGui.EndCombo(); - - return ret; - } - } -} \ No newline at end of file diff --git a/Penumbra/UI/Custom/ImGuiResizingTextInput.cs b/Penumbra/UI/Custom/ImGuiResizingTextInput.cs deleted file mode 100644 index f7ef2c53..00000000 --- a/Penumbra/UI/Custom/ImGuiResizingTextInput.cs +++ /dev/null @@ -1,49 +0,0 @@ -using System.Collections.Generic; -using ImGuiNET; - -namespace Penumbra.UI.Custom -{ - public static partial class ImGuiCustom - { - public static bool InputOrText( bool editable, string label, ref string text, uint maxLength ) - { - if( editable ) - { - return ResizingTextInput( label, ref text, maxLength ); - } - - ImGui.Text( text ); - return false; - } - - public static bool ResizingTextInput( string label, ref string input, uint maxLength ) - => ResizingTextInputIntern( label, ref input, maxLength ).Item1; - - public static bool ResizingTextInput( ref string input, uint maxLength ) - { - var (ret, id) = ResizingTextInputIntern( $"##{input}", ref input, maxLength ); - if( ret ) - { - TextInputWidths.Remove( id ); - } - - return ret; - } - - private static (bool, uint) ResizingTextInputIntern( string label, ref string input, uint maxLength ) - { - var id = ImGui.GetID( label ); - if( !TextInputWidths.TryGetValue( id, out var width ) ) - { - width = ImGui.CalcTextSize( input ).X + 10; - } - - ImGui.SetNextItemWidth( width ); - var ret = ImGui.InputText( label, ref input, maxLength, ImGuiInputTextFlags.EnterReturnsTrue ); - TextInputWidths[ id ] = ImGui.CalcTextSize( input ).X + 10; - return ( ret, id ); - } - - private static readonly Dictionary< uint, float > TextInputWidths = new(); - } -} \ No newline at end of file diff --git a/Penumbra/UI/Custom/ImGuiUtil.cs b/Penumbra/UI/Custom/ImGuiUtil.cs deleted file mode 100644 index 42e9d8f2..00000000 --- a/Penumbra/UI/Custom/ImGuiUtil.cs +++ /dev/null @@ -1,75 +0,0 @@ -using System.Numerics; -using System.Security.Cryptography.X509Certificates; -using System.Windows.Forms; -using Dalamud.Interface; -using ImGuiNET; - -namespace Penumbra.UI.Custom -{ - public static partial class ImGuiCustom - { - public static void CopyOnClickSelectable( string text ) - { - if( ImGui.Selectable( text ) ) - { - Clipboard.SetText( text ); - } - - if( ImGui.IsItemHovered() ) - { - ImGui.SetTooltip( "Click to copy to clipboard." ); - } - } - } - - public static partial class ImGuiCustom - { - public static void VerticalDistance( float distance ) - { - ImGui.SetCursorPosY( ImGui.GetCursorPosY() + distance * ImGuiHelpers.GlobalScale ); - } - - public static void RightJustifiedText( float pos, string text ) - { - ImGui.SetCursorPosX( pos - ImGui.CalcTextSize( text ).X - 2 * ImGui.GetStyle().ItemSpacing.X ); - ImGui.Text( text ); - } - - public static void RightJustifiedLabel( float pos, string text ) - { - ImGui.SetCursorPosX( pos - ImGui.CalcTextSize( text ).X - ImGui.GetStyle().ItemSpacing.X / 2 ); - ImGui.Text( text ); - ImGui.SameLine( pos ); - } - } - - public static partial class ImGuiCustom - { - public static void HoverTooltip( string text ) - { - if( ImGui.IsItemHovered() ) - { - ImGui.SetTooltip( text ); - } - } - } - - public static partial class ImGuiCustom - { - public static bool DisableButton( string label, bool condition ) - { - using var alpha = ImGuiRaii.PushStyle( ImGuiStyleVar.Alpha, 0.5f, !condition ); - return ImGui.Button( label ) && condition; - } - } - - public static partial class ImGuiCustom - { - public static void PrintIcon( FontAwesomeIcon icon ) - { - ImGui.PushFont( UiBuilder.IconFont ); - ImGui.TextUnformatted( icon.ToIconString() ); - ImGui.PopFont(); - } - } -} \ No newline at end of file diff --git a/Penumbra/UI/Custom/Raii/Color.cs b/Penumbra/UI/Custom/Raii/Color.cs deleted file mode 100644 index 95541bd8..00000000 --- a/Penumbra/UI/Custom/Raii/Color.cs +++ /dev/null @@ -1,52 +0,0 @@ -using System; -using System.Numerics; -using ImGuiNET; - -namespace Penumbra.UI.Custom -{ - public static partial class ImGuiRaii - { - public static Color PushColor( ImGuiCol idx, uint color, bool condition = true ) - => new Color().Push( idx, color, condition ); - - public static Color PushColor( ImGuiCol idx, Vector4 color, bool condition = true ) - => new Color().Push( idx, color, condition ); - - public class Color : IDisposable - { - private int _count; - - public Color Push( ImGuiCol idx, uint color, bool condition = true ) - { - if( condition ) - { - ImGui.PushStyleColor( idx, color ); - ++_count; - } - - return this; - } - - public Color Push( ImGuiCol idx, Vector4 color, bool condition = true ) - { - if( condition ) - { - ImGui.PushStyleColor( idx, color ); - ++_count; - } - - return this; - } - - public void Pop( int num = 1 ) - { - num = Math.Min( num, _count ); - _count -= num; - ImGui.PopStyleColor( num ); - } - - public void Dispose() - => Pop( _count ); - } - } -} \ No newline at end of file diff --git a/Penumbra/UI/Custom/Raii/EndStack.cs b/Penumbra/UI/Custom/Raii/EndStack.cs deleted file mode 100644 index ea1e03f9..00000000 --- a/Penumbra/UI/Custom/Raii/EndStack.cs +++ /dev/null @@ -1,40 +0,0 @@ -using System; -using System.Collections.Generic; - -namespace Penumbra.UI.Custom -{ - public static partial class ImGuiRaii - { - public static EndStack DeferredEnd( Action a, bool condition = true ) - => new EndStack().Push( a, condition ); - - public class EndStack : IDisposable - { - private readonly Stack< Action > _cleanActions = new(); - - public EndStack Push( Action a, bool condition = true ) - { - if( condition ) - { - _cleanActions.Push( a ); - } - - return this; - } - - - public EndStack Pop( int num = 1 ) - { - while( num-- > 0 && _cleanActions.TryPop( out var action ) ) - { - action.Invoke(); - } - - return this; - } - - public void Dispose() - => Pop( _cleanActions.Count ); - } - } -} \ No newline at end of file diff --git a/Penumbra/UI/Custom/Raii/Font.cs b/Penumbra/UI/Custom/Raii/Font.cs deleted file mode 100644 index e9656d4d..00000000 --- a/Penumbra/UI/Custom/Raii/Font.cs +++ /dev/null @@ -1,39 +0,0 @@ -using System; -using ImGuiNET; - -namespace Penumbra.UI.Custom -{ - public static partial class ImGuiRaii - { - public static Font PushFont( ImFontPtr font ) - => new( font ); - - public class Font : IDisposable - { - private int _count; - - public Font( ImFontPtr font ) - => Push( font ); - - public Font Push( ImFontPtr font ) - { - ImGui.PushFont( font ); - ++_count; - return this; - } - - public void Pop( int num = 1 ) - { - num = Math.Min( num, _count ); - _count -= num; - while( num-- > 0 ) - { - ImGui.PopFont(); - } - } - - public void Dispose() - => Pop( _count ); - } - } -} \ No newline at end of file diff --git a/Penumbra/UI/Custom/Raii/Indent.cs b/Penumbra/UI/Custom/Raii/Indent.cs deleted file mode 100644 index da9abd8e..00000000 --- a/Penumbra/UI/Custom/Raii/Indent.cs +++ /dev/null @@ -1,72 +0,0 @@ -using System; -using System.Diagnostics; -using Dalamud.Interface; -using ImGuiNET; - -namespace Penumbra.UI.Custom -{ - public static partial class ImGuiRaii - { - public static Indent PushIndent( float f, bool scaled = true, bool condition = true ) - => new Indent().Push( f, condition ); - - public static Indent PushIndent( int i = 1, bool scaled = true, bool condition = true ) - => new Indent().Push( i, condition ); - - public class Indent : IDisposable - { - private float _indentation; - - public Indent Push( float indent, bool scaled = true, bool condition = true ) - { - Debug.Assert( indent >= 0f ); - if( condition ) - { - if( scaled ) - { - indent *= ImGuiHelpers.GlobalScale; - } - - ImGui.Indent( indent ); - _indentation += indent; - } - - return this; - } - - public Indent Push( uint i = 1, bool scaled = true, bool condition = true ) - { - if( condition ) - { - var spacing = i * ImGui.GetStyle().IndentSpacing * ( scaled ? ImGuiHelpers.GlobalScale : 1f ); - ImGui.Indent( spacing ); - _indentation += spacing; - } - - return this; - } - - public void Pop( float indent, bool scaled = true ) - { - if( scaled ) - { - indent *= ImGuiHelpers.GlobalScale; - } - - Debug.Assert( indent >= 0f ); - ImGui.Unindent( indent ); - _indentation -= indent; - } - - public void Pop( uint i, bool scaled = true ) - { - var spacing = i * ImGui.GetStyle().IndentSpacing * ( scaled ? ImGuiHelpers.GlobalScale : 1f ); - ImGui.Unindent( spacing ); - _indentation += spacing; - } - - public void Dispose() - => Pop( _indentation, false ); - } - } -} \ No newline at end of file diff --git a/Penumbra/UI/Custom/Raii/Style.cs b/Penumbra/UI/Custom/Raii/Style.cs deleted file mode 100644 index acc29ab6..00000000 --- a/Penumbra/UI/Custom/Raii/Style.cs +++ /dev/null @@ -1,92 +0,0 @@ -using System; -using System.Numerics; -using ImGuiNET; - -namespace Penumbra.UI.Custom -{ - public static partial class ImGuiRaii - { - public static Style PushStyle( ImGuiStyleVar idx, float value, bool condition = true ) - => new Style().Push( idx, value, condition ); - - public static Style PushStyle( ImGuiStyleVar idx, Vector2 value, bool condition = true ) - => new Style().Push( idx, value, condition ); - - public class Style : IDisposable - { - private int _count; - - [System.Diagnostics.Conditional( "DEBUG" )] - private static void CheckStyleIdx( ImGuiStyleVar idx, Type type ) - { - var shouldThrow = idx switch - { - ImGuiStyleVar.Alpha => type != typeof( float ), - ImGuiStyleVar.WindowPadding => type != typeof( Vector2 ), - ImGuiStyleVar.WindowRounding => type != typeof( float ), - ImGuiStyleVar.WindowBorderSize => type != typeof( float ), - ImGuiStyleVar.WindowMinSize => type != typeof( Vector2 ), - ImGuiStyleVar.WindowTitleAlign => type != typeof( Vector2 ), - ImGuiStyleVar.ChildRounding => type != typeof( float ), - ImGuiStyleVar.ChildBorderSize => type != typeof( float ), - ImGuiStyleVar.PopupRounding => type != typeof( float ), - ImGuiStyleVar.PopupBorderSize => type != typeof( float ), - ImGuiStyleVar.FramePadding => type != typeof( Vector2 ), - ImGuiStyleVar.FrameRounding => type != typeof( float ), - ImGuiStyleVar.FrameBorderSize => type != typeof( float ), - ImGuiStyleVar.ItemSpacing => type != typeof( Vector2 ), - ImGuiStyleVar.ItemInnerSpacing => type != typeof( Vector2 ), - ImGuiStyleVar.IndentSpacing => type != typeof( float ), - ImGuiStyleVar.CellPadding => type != typeof( Vector2 ), - ImGuiStyleVar.ScrollbarSize => type != typeof( float ), - ImGuiStyleVar.ScrollbarRounding => type != typeof( float ), - ImGuiStyleVar.GrabMinSize => type != typeof( float ), - ImGuiStyleVar.GrabRounding => type != typeof( float ), - ImGuiStyleVar.TabRounding => type != typeof( float ), - ImGuiStyleVar.ButtonTextAlign => type != typeof( Vector2 ), - ImGuiStyleVar.SelectableTextAlign => type != typeof( Vector2 ), - _ => throw new ArgumentOutOfRangeException( nameof( idx ), idx, null ), - }; - - if( shouldThrow ) - { - throw new ArgumentException( $"Unable to push {type} to {idx}." ); - } - } - - public Style Push( ImGuiStyleVar idx, float value, bool condition = true ) - { - if( condition ) - { - CheckStyleIdx( idx, typeof( float ) ); - ImGui.PushStyleVar( idx, value ); - ++_count; - } - - return this; - } - - public Style Push( ImGuiStyleVar idx, Vector2 value, bool condition = true ) - { - if( condition ) - { - CheckStyleIdx( idx, typeof( Vector2 ) ); - ImGui.PushStyleVar( idx, value ); - ++_count; - } - - return this; - } - - public void Pop( int num = 1 ) - { - num = Math.Min( num, _count ); - _count -= num; - ImGui.PopStyleVar( num ); - } - - public void Dispose() - => Pop( _count ); - } - } -} \ No newline at end of file diff --git a/Penumbra/UI/FileDialogService.cs b/Penumbra/UI/FileDialogService.cs new file mode 100644 index 00000000..3bbc4ba8 --- /dev/null +++ b/Penumbra/UI/FileDialogService.cs @@ -0,0 +1,158 @@ +using Dalamud.Interface; +using Dalamud.Interface.ImGuiFileDialog; +using Dalamud.Utility; +using Dalamud.Bindings.ImGui; +using OtterGui; +using OtterGui.Extensions; +using OtterGui.Services; +using Penumbra.Communication; +using Penumbra.Services; + +namespace Penumbra.UI; + +public class FileDialogService : IDisposable, IUiService +{ + private readonly CommunicatorService _communicator; + private readonly FileDialogManager _manager; + private readonly ConcurrentDictionary _startPaths = new(); + private bool _isOpen; + + public FileDialogService(CommunicatorService communicator, Configuration config) + { + _communicator = communicator; + _manager = SetupFileManager(config.ModDirectory); + _communicator.ModDirectoryChanged.Subscribe(OnModDirectoryChange, ModDirectoryChanged.Priority.FileDialogService); + } + + public void OpenFilePicker(string title, string filters, Action> callback, int selectionCountMax, string? startPath, + bool forceStartPath) + { + _isOpen = true; + _manager.OpenFileDialog(title, filters, CreateCallback(title, callback), selectionCountMax, + GetStartPath(title, startPath, forceStartPath)); + } + + public void OpenFolderPicker(string title, Action callback, string? startPath, bool forceStartPath) + { + _isOpen = true; + _manager.OpenFolderDialog(title, CreateCallback(title, callback), GetStartPath(title, startPath, forceStartPath)); + } + + public void OpenSavePicker(string title, string filters, string defaultFileName, string defaultExtension, Action callback, + string? startPath, + bool forceStartPath) + { + _isOpen = true; + _manager.SaveFileDialog(title, filters, defaultFileName, defaultExtension, CreateCallback(title, callback), + GetStartPath(title, startPath, forceStartPath)); + } + + public void Close() + { + _isOpen = false; + } + + public void Reset() + { + _isOpen = false; + _manager.Reset(); + } + + public void Draw() + { + if (_isOpen) + _manager.Draw(); + } + + public void Dispose() + { + _startPaths.Clear(); + _manager.Reset(); + _communicator.ModDirectoryChanged.Unsubscribe(OnModDirectoryChange); + } + + private string? GetStartPath(string title, string? startPath, bool forceStartPath) + { + var path = !forceStartPath && _startPaths.TryGetValue(title, out var p) ? p : startPath; + if (!path.IsNullOrEmpty() && !Directory.Exists(path)) + path = null; + return path; + } + + private Action> CreateCallback(string title, Action> callback) + { + return (valid, list) => + { + _isOpen = false; + var loc = HandleRoot(GetCurrentLocation()); + _startPaths[title] = loc; + callback(valid, list.Select(HandleRoot).ToList()); + }; + } + + private Action CreateCallback(string title, Action callback) + { + return (valid, list) => + { + _isOpen = false; + var loc = HandleRoot(GetCurrentLocation()); + _startPaths[title] = loc; + callback(valid, HandleRoot(list)); + }; + } + + private static string HandleRoot(string path) + { + if (path is [_, ':']) + return path + '\\'; + + return path; + } + + // TODO: maybe change this from reflection when its public. + private string GetCurrentLocation() + => (_manager.GetType().GetField("dialog", BindingFlags.Instance | BindingFlags.NonPublic)?.GetValue(_manager) as FileDialog) + ?.GetCurrentPath() + ?? "."; + + /// Set up the file selector with the right flags and custom side bar items. + private static FileDialogManager SetupFileManager(string modDirectory) + { + var fileManager = new FileDialogManager + { + AddedWindowFlags = ImGuiWindowFlags.NoCollapse | ImGuiWindowFlags.NoDocking, + }; + + if (Functions.GetDownloadsFolder(out var downloadsFolder)) + fileManager.CustomSideBarItems.Add(("Downloads", downloadsFolder, FontAwesomeIcon.Download, -1)); + + if (Functions.GetQuickAccessFolders(out var folders)) + foreach (var ((name, path), idx) in folders.WithIndex()) + fileManager.CustomSideBarItems.Add(($"{name}##{idx}", path, FontAwesomeIcon.Folder, -1)); + + // Add Penumbra Root. This is not updated if the root changes right now. + fileManager.CustomSideBarItems.Add(("Root Directory", modDirectory, FontAwesomeIcon.Gamepad, 0)); + + // Remove Videos and Music. + fileManager.CustomSideBarItems.Add(("Videos", string.Empty, 0, -1)); + fileManager.CustomSideBarItems.Add(("Music", string.Empty, 0, -1)); + + return fileManager; + } + + /// Update the Root Directory link on changes. + private void OnModDirectoryChange(string directory, bool valid) + { + var idx = _manager.CustomSideBarItems.IndexOf(t => t.Name == "Root Directory"); + if (idx >= 0) + _manager.CustomSideBarItems.RemoveAt(idx); + + if (!valid) + return; + + if (idx >= 0) + _manager.CustomSideBarItems.Insert(idx, ("Root Directory", directory, FontAwesomeIcon.Gamepad, 0)); + else + _manager.CustomSideBarItems.Add(("Root Directory", directory, FontAwesomeIcon.Gamepad, 0)); + } +} diff --git a/Penumbra/UI/ImportPopup.cs b/Penumbra/UI/ImportPopup.cs new file mode 100644 index 00000000..59ed0308 --- /dev/null +++ b/Penumbra/UI/ImportPopup.cs @@ -0,0 +1,88 @@ +using Dalamud.Game.ClientState.Keys; +using Dalamud.Interface.Windowing; +using Dalamud.Plugin.Services; +using Dalamud.Bindings.ImGui; +using OtterGui.Raii; +using OtterGui.Services; +using Penumbra.Import.Structs; +using Penumbra.Mods.Manager; + +namespace Penumbra.UI; + +/// Draw the progress information for import. +public sealed class ImportPopup : Window, IUiService +{ + public const string WindowLabel = "Penumbra Import Status"; + + private readonly ModImportManager _modImportManager; + + public bool WasDrawn { get; private set; } + public bool PopupWasDrawn { get; private set; } + + public ImportPopup(ModImportManager modImportManager) + : base(WindowLabel, + ImGuiWindowFlags.NoCollapse + | ImGuiWindowFlags.NoDecoration + | ImGuiWindowFlags.NoBackground + | ImGuiWindowFlags.NoMove + | ImGuiWindowFlags.NoInputs + | ImGuiWindowFlags.NoNavFocus + | ImGuiWindowFlags.NoFocusOnAppearing + | ImGuiWindowFlags.NoBringToFrontOnFocus + | ImGuiWindowFlags.NoDocking + | ImGuiWindowFlags.NoTitleBar, true) + { + _modImportManager = modImportManager; + DisableWindowSounds = true; + IsOpen = true; + RespectCloseHotkey = false; + Collapsed = false; + SizeConstraints = new WindowSizeConstraints + { + MinimumSize = Vector2.Zero, + MaximumSize = Vector2.Zero, + }; + } + + public override void PreOpenCheck() + { + WasDrawn = false; + PopupWasDrawn = false; + _modImportManager.TryUnpacking(); + IsOpen = true; + } + + public override void Draw() + { + WasDrawn = true; + if (!_modImportManager.IsImporting(out var import)) + return; + + const string importPopup = "##PenumbraImportPopup"; + if (!ImGui.IsPopupOpen(importPopup)) + ImGui.OpenPopup(importPopup); + + var display = ImGui.GetIO().DisplaySize; + var height = Math.Max(display.Y / 4, 15 * ImGui.GetFrameHeightWithSpacing()); + var width = display.X / 8; + var size = new Vector2(width * 2, height); + ImGui.SetNextWindowPos(ImGui.GetMainViewport().GetCenter(), ImGuiCond.Always, Vector2.One / 2); + ImGui.SetNextWindowSize(size); + using var popup = ImRaii.Popup(importPopup, ImGuiWindowFlags.Modal); + PopupWasDrawn = true; + var terminate = false; + using (var child = ImRaii.Child("##import", new Vector2(-1, size.Y - ImGui.GetFrameHeight() * 2))) + { + if (child.Success && import.DrawProgressInfo(new Vector2(-1, ImGui.GetFrameHeight()))) + if (!ImGui.IsMouseHoveringRect(ImGui.GetWindowPos(), ImGui.GetWindowPos() + ImGui.GetWindowSize()) + && ImGui.IsMouseClicked(ImGuiMouseButton.Left)) + terminate = true; + } + + terminate |= import.State == ImporterState.Done + ? ImGui.Button("Close", -Vector2.UnitX) + : import.DrawCancelButton(-Vector2.UnitX); + if (terminate) + _modImportManager.ClearImport(); + } +} diff --git a/Penumbra/UI/IncognitoService.cs b/Penumbra/UI/IncognitoService.cs new file mode 100644 index 00000000..678e072e --- /dev/null +++ b/Penumbra/UI/IncognitoService.cs @@ -0,0 +1,35 @@ +using Dalamud.Interface; +using Dalamud.Bindings.ImGui; +using Penumbra.UI.Classes; +using OtterGui.Raii; +using OtterGui.Services; +using OtterGui.Text; + +namespace Penumbra.UI; + +public class IncognitoService(TutorialService tutorial, Configuration config) : IService +{ + public bool IncognitoMode + => config.Ephemeral.IncognitoMode; + + public void DrawToggle(float width) + { + var hold = config.IncognitoModifier.IsActive(); + var color = ColorId.FolderExpanded.Value(); + using (ImRaii.PushFrameBorder(ImUtf8.GlobalScale, color)) + { + var tt = IncognitoMode ? "Toggle incognito mode off."u8 : "Toggle incognito mode on."u8; + var icon = IncognitoMode ? FontAwesomeIcon.EyeSlash : FontAwesomeIcon.Eye; + if (ImUtf8.IconButton(icon, tt, new Vector2(width, ImUtf8.FrameHeight), false, color) && hold) + { + config.Ephemeral.IncognitoMode = !IncognitoMode; + config.Ephemeral.Save(); + } + + if (!hold) + ImUtf8.HoverTooltip(ImGuiHoveredFlags.AllowWhenDisabled, $"\nHold {config.IncognitoModifier} while clicking to toggle."); + } + + tutorial.OpenTutorial(BasicTutorialSteps.Incognito); + } +} diff --git a/Penumbra/UI/Integration/IntegrationSettingsRegistry.cs b/Penumbra/UI/Integration/IntegrationSettingsRegistry.cs new file mode 100644 index 00000000..2d3da488 --- /dev/null +++ b/Penumbra/UI/Integration/IntegrationSettingsRegistry.cs @@ -0,0 +1,115 @@ +using Dalamud.Plugin; +using OtterGui.Services; +using OtterGui.Text; +using Penumbra.Api.Enums; + +namespace Penumbra.UI.Integration; + +public sealed class IntegrationSettingsRegistry : IService, IDisposable +{ + private readonly IDalamudPluginInterface _pluginInterface; + + private readonly List<(string InternalName, string Name, Action Draw)> _sections = []; + + private bool _disposed = false; + + public IntegrationSettingsRegistry(IDalamudPluginInterface pluginInterface) + { + _pluginInterface = pluginInterface; + + _pluginInterface.ActivePluginsChanged += OnActivePluginsChanged; + } + + public void Dispose() + { + _disposed = true; + + _pluginInterface.ActivePluginsChanged -= OnActivePluginsChanged; + + _sections.Clear(); + } + + public void Draw() + { + foreach (var (internalName, name, draw) in _sections) + { + if (!ImUtf8.CollapsingHeader($"Integration with {name}###IntegrationSettingsHeader.{internalName}")) + continue; + + using var id = ImUtf8.PushId($"IntegrationSettings.{internalName}"); + try + { + draw(); + } + catch (Exception e) + { + Penumbra.Log.Error($"Error while drawing {internalName} integration settings: {e}"); + } + } + } + + public PenumbraApiEc RegisterSection(Action draw) + { + if (_disposed) + return PenumbraApiEc.SystemDisposed; + + var plugin = GetPlugin(draw); + if (plugin is null) + return PenumbraApiEc.InvalidArgument; + + var section = (plugin.InternalName, plugin.Name, draw); + + var index = FindSectionIndex(plugin.InternalName); + if (index >= 0) + { + if (_sections[index] == section) + return PenumbraApiEc.NothingChanged; + _sections[index] = section; + } + else + _sections.Add(section); + _sections.Sort((lhs, rhs) => string.Compare(lhs.Name, rhs.Name, StringComparison.CurrentCultureIgnoreCase)); + + return PenumbraApiEc.Success; + } + + public bool UnregisterSection(Action draw) + { + var index = FindSectionIndex(draw); + if (index < 0) + return false; + + _sections.RemoveAt(index); + return true; + } + + private void OnActivePluginsChanged(IActivePluginsChangedEventArgs args) + { + if (args.Kind is PluginListInvalidationKind.Loaded) + return; + + foreach (var internalName in args.AffectedInternalNames) + { + var index = FindSectionIndex(internalName); + if (index >= 0 && GetPlugin(_sections[index].Draw) is null) + { + _sections.RemoveAt(index); + Penumbra.Log.Warning($"Removed stale integration setting section of {internalName} (reason: {args.Kind})"); + } + } + } + + private IExposedPlugin? GetPlugin(Delegate @delegate) + => @delegate.Method.DeclaringType + switch + { + null => null, + var type => _pluginInterface.GetPlugin(type.Assembly), + }; + + private int FindSectionIndex(string internalName) + => _sections.FindIndex(section => section.InternalName.Equals(internalName, StringComparison.Ordinal)); + + private int FindSectionIndex(Action draw) + => _sections.FindIndex(section => section.Draw == draw); +} diff --git a/Penumbra/UI/Knowledge/IKnowledgeTab.cs b/Penumbra/UI/Knowledge/IKnowledgeTab.cs new file mode 100644 index 00000000..568d5fda --- /dev/null +++ b/Penumbra/UI/Knowledge/IKnowledgeTab.cs @@ -0,0 +1,8 @@ +namespace Penumbra.UI.Knowledge; + +public interface IKnowledgeTab +{ + public ReadOnlySpan Name { get; } + public ReadOnlySpan SearchTags { get; } + public void Draw(); +} diff --git a/Penumbra/UI/Knowledge/KnowledgeWindow.cs b/Penumbra/UI/Knowledge/KnowledgeWindow.cs new file mode 100644 index 00000000..118ed479 --- /dev/null +++ b/Penumbra/UI/Knowledge/KnowledgeWindow.cs @@ -0,0 +1,78 @@ +using Dalamud.Interface.Utility.Raii; +using Dalamud.Interface.Windowing; +using Dalamud.Bindings.ImGui; +using OtterGui.Services; +using OtterGui.Text; +using Penumbra.String; + +namespace Penumbra.UI.Knowledge; + +/// Draw the progress information for import. +public sealed class KnowledgeWindow : Window, IUiService +{ + private readonly IReadOnlyList _tabs = + [ + new RaceCodeTab(), + ]; + + private IKnowledgeTab? _selected; + private readonly byte[] _filterStore = new byte[256]; + + private ByteString _lower = ByteString.Empty; + + /// Draw the progress information for import. + public KnowledgeWindow() + : base("Penumbra Knowledge Window") + => SizeConstraints = new WindowSizeConstraints + { + MaximumSize = new Vector2(10000, 10000), + MinimumSize = new Vector2(400, 200), + }; + + public override void Draw() + { + DrawSelector(); + ImUtf8.SameLineInner(); + DrawMain(); + } + + private void DrawSelector() + { + using var group = ImUtf8.Group(); + using (ImRaii.PushStyle(ImGuiStyleVar.FrameRounding, 0).Push(ImGuiStyleVar.ItemSpacing, Vector2.Zero)) + { + ImGui.SetNextItemWidth(200 * ImUtf8.GlobalScale); + if (ImUtf8.InputText("##Filter"u8, _filterStore, out TerminatedByteString filter, "Filter..."u8)) + _lower = ByteString.FromSpanUnsafe(filter, true, null, null).AsciiToLowerClone(); + } + + using var child = ImUtf8.Child("KnowledgeSelector"u8, new Vector2(200 * ImUtf8.GlobalScale, ImGui.GetContentRegionAvail().Y), true); + if (!child) + return; + + foreach (var tab in _tabs) + { + if (!_lower.IsEmpty && tab.SearchTags.IndexOf(_lower.Span) < 0) + continue; + + if (ImUtf8.Selectable(tab.Name, _selected == tab)) + _selected = tab; + } + } + + private void DrawMain() + { + using var group = ImUtf8.Group(); + using (ImRaii.PushStyle(ImGuiStyleVar.FrameRounding, 0).Push(ImGuiStyleVar.ItemSpacing, Vector2.Zero)) + { + ImUtf8.TextFramed(_selected == null ? "No Selection"u8 : _selected.Name, ImGui.GetColorU32(ImGuiCol.FrameBg), + new Vector2(ImGui.GetContentRegionAvail().X, 0)); + } + + using var child = ImUtf8.Child("KnowledgeMain"u8, ImGui.GetContentRegionAvail(), true); + if (!child || _selected == null) + return; + + _selected.Draw(); + } +} diff --git a/Penumbra/UI/Knowledge/RaceCodeTab.cs b/Penumbra/UI/Knowledge/RaceCodeTab.cs new file mode 100644 index 00000000..44b544eb --- /dev/null +++ b/Penumbra/UI/Knowledge/RaceCodeTab.cs @@ -0,0 +1,82 @@ +using Dalamud.Bindings.ImGui; +using OtterGui.Text; +using Penumbra.GameData.Enums; + +namespace Penumbra.UI.Knowledge; + +public sealed class RaceCodeTab() : IKnowledgeTab +{ + public ReadOnlySpan Name + => "Race Codes"u8; + + public ReadOnlySpan SearchTags + => "deformersracecodesmodel"u8; + + public void Draw() + { + var size = new Vector2((ImGui.GetContentRegionAvail().X - ImUtf8.ItemSpacing.X) / 2, 0); + using (var table = ImUtf8.Table("adults"u8, 4, ImGuiTableFlags.BordersOuter, size)) + { + if (!table) + return; + + DrawHeaders(); + foreach (var gr in Enum.GetValues()) + { + var (gender, race) = gr.Split(); + if (gender is not Gender.Male and not Gender.Female || race is ModelRace.Unknown) + continue; + + DrawRow(gender, race, false); + } + } + + ImGui.SameLine(); + + using (var table = ImUtf8.Table("children"u8, 4, ImGuiTableFlags.BordersOuter, size)) + { + if (!table) + return; + + DrawHeaders(); + foreach (var race in (ReadOnlySpan) + [ModelRace.Midlander, ModelRace.Elezen, ModelRace.Miqote, ModelRace.AuRa, ModelRace.Unknown]) + { + foreach (var gender in (ReadOnlySpan) [Gender.Male, Gender.Female]) + DrawRow(gender, race, true); + } + } + + return; + + static void DrawHeaders() + { + ImGui.TableNextColumn(); + ImUtf8.TableHeader("Race"u8); + ImGui.TableNextColumn(); + ImUtf8.TableHeader("Gender"u8); + ImGui.TableNextColumn(); + ImUtf8.TableHeader("Age"u8); + ImGui.TableNextColumn(); + ImUtf8.TableHeader("Race Code"u8); + } + + static void DrawRow(Gender gender, ModelRace race, bool child) + { + var gr = child + ? Names.CombinedRace(gender is Gender.Male ? Gender.MaleNpc : Gender.FemaleNpc, race) + : Names.CombinedRace(gender, race); + ImGui.TableNextColumn(); + ImUtf8.Text(race.ToName()); + + ImGui.TableNextColumn(); + ImUtf8.Text(gender.ToName()); + + ImGui.TableNextColumn(); + ImUtf8.Text(child ? "Child"u8 : "Adult"u8); + + ImGui.TableNextColumn(); + ImUtf8.CopyOnClickSelectable(gr.ToRaceCode()); + } + } +} diff --git a/Penumbra/UI/LaunchButton.cs b/Penumbra/UI/LaunchButton.cs index f146b23f..49161c31 100644 --- a/Penumbra/UI/LaunchButton.cs +++ b/Penumbra/UI/LaunchButton.cs @@ -1,56 +1,66 @@ -using System.Numerics; -using ImGuiNET; +using Dalamud.Interface; +using Dalamud.Interface.Textures; +using Dalamud.Interface.Textures.TextureWraps; +using Dalamud.Plugin; +using Dalamud.Plugin.Services; +using OtterGui.Services; -namespace Penumbra.UI +namespace Penumbra.UI; + +/// +/// A Launch Button used in the title screen of the game, +/// using the Dalamud-provided collapsible submenu. +/// +public class LaunchButton : IDisposable, IUiService { - public partial class SettingsInterface + private readonly ConfigWindow _configWindow; + private readonly IUiBuilder _uiBuilder; + private readonly ITitleScreenMenu _title; + private readonly string _fileName; + private readonly ITextureProvider _textureProvider; + + private IReadOnlyTitleScreenMenuEntry? _entry; + + /// + /// Register the launch button to be created on the next draw event. + /// + public LaunchButton(IDalamudPluginInterface pi, ITitleScreenMenu title, ConfigWindow ui, ITextureProvider textureProvider) { - private class ManageModsButton + _uiBuilder = pi.UiBuilder; + _configWindow = ui; + _textureProvider = textureProvider; + _title = title; + _entry = null; + + _fileName = Path.Combine(pi.AssemblyLocation.DirectoryName!, "tsmLogo.png"); + _uiBuilder.Draw += CreateEntry; + } + + public void Dispose() + { + if (_entry != null) + _title.RemoveEntry(_entry); + } + + /// + /// One-Time event to load the image and create the entry on the first drawn frame, but not before. + /// + private void CreateEntry() + { + try { - // magic numbers - private const int Padding = 50; - private const int Width = 200; - private const int Height = 45; - private const string MenuButtonsName = "Penumbra Menu Buttons"; - private const string MenuButtonLabel = "Manage Mods"; + // TODO: update when API updated. + var icon = _textureProvider.GetFromFile(_fileName); + _entry = _title.AddEntry("Manage Penumbra", icon, OnTriggered); - private static readonly Vector2 WindowSize = new( Width, Height ); - private static readonly Vector2 WindowPosOffset = new( Padding + Width, Padding + Height ); - - private const ImGuiWindowFlags ButtonFlags = - ImGuiWindowFlags.AlwaysAutoResize - | ImGuiWindowFlags.NoBackground - | ImGuiWindowFlags.NoDecoration - | ImGuiWindowFlags.NoMove - | ImGuiWindowFlags.NoScrollbar - | ImGuiWindowFlags.NoResize - | ImGuiWindowFlags.NoSavedSettings; - - private readonly SettingsInterface _base; - - public ManageModsButton( SettingsInterface ui ) - => _base = ui; - - public void Draw() - { - if( Dalamud.Conditions.Any() || _base._menu.Visible ) - { - return; - } - - var ss = ImGui.GetMainViewport().Size + ImGui.GetMainViewport().Pos; - ImGui.SetNextWindowViewport( ImGui.GetMainViewport().ID ); - - ImGui.SetNextWindowPos( ss - WindowPosOffset, ImGuiCond.Always ); - - if( ImGui.Begin( MenuButtonsName, ButtonFlags ) - && ImGui.Button( MenuButtonLabel, WindowSize ) ) - { - _base.FlipVisibility(); - } - - ImGui.End(); - } + _uiBuilder.Draw -= CreateEntry; + } + catch (Exception ex) + { + Penumbra.Log.Error($"Could not register title screen menu entry:\n{ex}"); } } -} \ No newline at end of file + + private void OnTriggered() + => _configWindow.Toggle(); +} diff --git a/Penumbra/UI/MenuBar.cs b/Penumbra/UI/MenuBar.cs deleted file mode 100644 index 5c3c6e41..00000000 --- a/Penumbra/UI/MenuBar.cs +++ /dev/null @@ -1,61 +0,0 @@ -using ImGuiNET; -using Penumbra.UI.Custom; - -namespace Penumbra.UI -{ - public partial class SettingsInterface - { - private class MenuBar - { - private const string MenuLabel = "Penumbra"; - private const string MenuItemToggle = "Toggle UI"; - private const string SlashCommand = "/penumbra"; - private const string MenuItemRediscover = "Rediscover Mods"; - private const string MenuItemHide = "Hide Menu Bar"; - -#if DEBUG - private bool _showDebugBar = true; -#else - private const bool _showDebugBar = false; -#endif - - private readonly SettingsInterface _base; - - public MenuBar( SettingsInterface ui ) - => _base = ui; - - public void Draw() - { - if( !_showDebugBar || !ImGui.BeginMainMenuBar() ) - { - return; - } - - using var raii = ImGuiRaii.DeferredEnd( ImGui.EndMainMenuBar ); - - if( !ImGui.BeginMenu( MenuLabel ) ) - { - return; - } - - raii.Push( ImGui.EndMenu ); - - if( ImGui.MenuItem( MenuItemToggle, SlashCommand, _base._menu.Visible ) ) - { - _base.FlipVisibility(); - } - - if( ImGui.MenuItem( MenuItemRediscover ) ) - { - _base.ReloadMods(); - } -#if DEBUG - if( ImGui.MenuItem( MenuItemHide ) ) - { - _showDebugBar = false; - } -#endif - } - } - } -} \ No newline at end of file diff --git a/Penumbra/UI/MenuTabs/TabBrowser.cs b/Penumbra/UI/MenuTabs/TabBrowser.cs deleted file mode 100644 index 8c80cb19..00000000 --- a/Penumbra/UI/MenuTabs/TabBrowser.cs +++ /dev/null @@ -1,24 +0,0 @@ -using System.Diagnostics; -using ImGuiNET; - -namespace Penumbra.UI -{ - public partial class SettingsInterface - { - private class TabBrowser - { - [Conditional( "DEBUG" )] - public void Draw() - { - var ret = ImGui.BeginTabItem( "Available Mods" ); - if( !ret ) - { - return; - } - - ImGui.Text( "woah" ); - ImGui.EndTabItem(); - } - } - } -} \ No newline at end of file diff --git a/Penumbra/UI/MenuTabs/TabCollections.cs b/Penumbra/UI/MenuTabs/TabCollections.cs deleted file mode 100644 index 898f24e7..00000000 --- a/Penumbra/UI/MenuTabs/TabCollections.cs +++ /dev/null @@ -1,302 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Numerics; -using Dalamud.Interface; -using Dalamud.Logging; -using ImGuiNET; -using Penumbra.Mod; -using Penumbra.Mods; -using Penumbra.UI.Custom; -using Penumbra.Util; - -namespace Penumbra.UI -{ - public partial class SettingsInterface - { - private class TabCollections - { - public const string LabelCurrentCollection = "Current Collection"; - private readonly Selector _selector; - private readonly ModManager _manager; - private string _collectionNames = null!; - private string _collectionNamesWithNone = null!; - private ModCollection[] _collections = null!; - private int _currentCollectionIndex; - private int _currentForcedIndex; - private int _currentDefaultIndex; - private readonly Dictionary< string, int > _currentCharacterIndices = new(); - private string _newCollectionName = string.Empty; - private string _newCharacterName = string.Empty; - - private void UpdateNames() - { - _collections = _manager.Collections.Collections.Values.Prepend( ModCollection.Empty ).ToArray(); - _collectionNames = string.Join( "\0", _collections.Skip( 1 ).Select( c => c.Name ) ) + '\0'; - _collectionNamesWithNone = "None\0" + _collectionNames; - UpdateIndices(); - } - - - private int GetIndex( ModCollection collection ) - { - var ret = _collections.IndexOf( c => c.Name == collection.Name ); - if( ret < 0 ) - { - PluginLog.Error( $"Collection {collection.Name} is not found in collections." ); - return 0; - } - - return ret; - } - - private void UpdateIndex() - => _currentCollectionIndex = GetIndex( _manager.Collections.CurrentCollection ) - 1; - - private void UpdateForcedIndex() - => _currentForcedIndex = GetIndex( _manager.Collections.ForcedCollection ); - - private void UpdateDefaultIndex() - => _currentDefaultIndex = GetIndex( _manager.Collections.DefaultCollection ); - - private void UpdateCharacterIndices() - { - _currentCharacterIndices.Clear(); - foreach( var kvp in _manager.Collections.CharacterCollection ) - { - _currentCharacterIndices[ kvp.Key ] = GetIndex( kvp.Value ); - } - } - - private void UpdateIndices() - { - UpdateIndex(); - UpdateDefaultIndex(); - UpdateForcedIndex(); - UpdateCharacterIndices(); - } - - public TabCollections( Selector selector ) - { - _selector = selector; - _manager = Service< ModManager >.Get(); - UpdateNames(); - } - - private void CreateNewCollection( Dictionary< string, ModSettings > settings ) - { - if( _manager.Collections.AddCollection( _newCollectionName, settings ) ) - { - UpdateNames(); - SetCurrentCollection( _manager.Collections.Collections[ _newCollectionName ], true ); - } - - _newCollectionName = string.Empty; - } - - private void DrawCleanCollectionButton() - { - if( ImGui.Button( "Clean Settings" ) ) - { - var changes = ModFunctions.CleanUpCollection( _manager.Collections.CurrentCollection.Settings, - _manager.BasePath.EnumerateDirectories() ); - _manager.Collections.CurrentCollection.UpdateSettings( changes ); - } - - ImGuiCustom.HoverTooltip( - "Remove all stored settings for mods not currently available and fix invalid settings.\nUse at own risk." ); - } - - private void DrawNewCollectionInput() - { - ImGui.InputTextWithHint( "##New Collection", "New Collection", ref _newCollectionName, 64 ); - - using var style = ImGuiRaii.PushStyle( ImGuiStyleVar.Alpha, 0.5f, _newCollectionName.Length == 0 ); - - if( ImGui.Button( "Create New Empty Collection" ) && _newCollectionName.Length > 0 ) - { - CreateNewCollection( new Dictionary< string, ModSettings >() ); - } - - ImGui.SameLine(); - if( ImGui.Button( "Duplicate Current Collection" ) && _newCollectionName.Length > 0 ) - { - CreateNewCollection( _manager.Collections.CurrentCollection.Settings ); - } - - style.Pop(); - - var deleteCondition = _manager.Collections.Collections.Count > 1 - && _manager.Collections.CurrentCollection.Name != ModCollection.DefaultCollection; - ImGui.SameLine(); - if( ImGuiCustom.DisableButton( "Delete Current Collection", deleteCondition ) ) - { - _manager.Collections.RemoveCollection( _manager.Collections.CurrentCollection.Name ); - SetCurrentCollection( _manager.Collections.CurrentCollection, true ); - UpdateNames(); - } - - if( Penumbra.Config.ShowAdvanced ) - { - ImGui.SameLine(); - DrawCleanCollectionButton(); - } - } - - private void SetCurrentCollection( int idx, bool force ) - { - if( !force && idx == _currentCollectionIndex ) - { - return; - } - - _manager.Collections.SetCurrentCollection( _collections[ idx + 1 ] ); - _currentCollectionIndex = idx; - _selector.Cache.TriggerListReset(); - if( _selector.Mod != null ) - { - _selector.SelectModOnUpdate( _selector.Mod.Data.BasePath.Name ); - } - } - - public void SetCurrentCollection( ModCollection collection, bool force = false ) - { - var idx = Array.IndexOf( _collections, collection ) - 1; - if( idx >= 0 ) - { - SetCurrentCollection( idx, force ); - } - } - - public void DrawCurrentCollectionSelector( bool tooltip ) - { - var index = _currentCollectionIndex; - var combo = ImGui.Combo( LabelCurrentCollection, ref index, _collectionNames ); - ImGuiCustom.HoverTooltip( - "This collection will be modified when using the Installed Mods tab and making changes. It does not apply to anything by itself." ); - - if( combo ) - { - SetCurrentCollection( index, false ); - } - } - - private void DrawDefaultCollectionSelector() - { - var index = _currentDefaultIndex; - if( ImGui.Combo( "##Default Collection", ref index, _collectionNamesWithNone ) && index != _currentDefaultIndex ) - { - _manager.Collections.SetDefaultCollection( _collections[ index ] ); - _currentDefaultIndex = index; - } - - ImGuiCustom.HoverTooltip( - "Mods in the default collection are loaded for any character that is not explicitly named in the character collections below.\n" - + "They also take precedence before the forced collection." ); - - ImGui.SameLine(); - ImGuiHelpers.ScaledDummy( 24, 0 ); - ImGui.SameLine(); - ImGui.Text( "Default Collection" ); - } - - private void DrawForcedCollectionSelector() - { - var index = _currentForcedIndex; - if( ImGui.Combo( "##Forced Collection", ref index, _collectionNamesWithNone ) && index != _currentForcedIndex ) - { - _manager.Collections.SetForcedCollection( _collections[ index ] ); - _currentForcedIndex = index; - } - - ImGuiCustom.HoverTooltip( - "Mods in the forced collection are always loaded if not overwritten by anything in the current or character-based collection.\n" - + "Please avoid mixing meta-manipulating mods in Forced and other collections, as this will probably not work correctly." ); - - ImGui.SameLine(); - ImGuiHelpers.ScaledDummy( 24, 0 ); - ImGui.SameLine(); - ImGui.Text( "Forced Collection" ); - } - - private void DrawNewCharacterCollection() - { - ImGui.InputTextWithHint( "##New Character", "New Character Name", ref _newCharacterName, 32 ); - - ImGui.SameLine(); - if( ImGuiCustom.DisableButton( "Create New Character Collection", _newCharacterName.Length > 0 ) ) - { - _manager.Collections.CreateCharacterCollection( _newCharacterName ); - _currentCharacterIndices[ _newCharacterName ] = 0; - _newCharacterName = string.Empty; - } - - ImGuiCustom.HoverTooltip( - "A character collection will be used whenever you manually redraw a character with the Name you have set up.\n" - + "If you enable automatic character redraws in the Settings tab, penumbra will try to use Character collections for corresponding characters automatically.\n" ); - } - - - private void DrawCharacterCollectionSelectors() - { - using var raii = ImGuiRaii.DeferredEnd( ImGui.EndChild ); - if( !ImGui.BeginChild( "##CollectionChild", AutoFillSize, true ) ) - { - return; - } - - DrawDefaultCollectionSelector(); - DrawForcedCollectionSelector(); - - foreach( var name in _manager.Collections.CharacterCollection.Keys.ToArray() ) - { - var idx = _currentCharacterIndices[ name ]; - var tmp = idx; - if( ImGui.Combo( $"##{name}collection", ref tmp, _collectionNamesWithNone ) && idx != tmp ) - { - _manager.Collections.SetCharacterCollection( name, _collections[ tmp ] ); - _currentCharacterIndices[ name ] = tmp; - } - - ImGui.SameLine(); - - using var font = ImGuiRaii.PushFont( UiBuilder.IconFont ); - if( ImGui.Button( $"{FontAwesomeIcon.Trash.ToIconString()}##{name}" ) ) - { - _manager.Collections.RemoveCharacterCollection( name ); - } - - font.Pop(); - - ImGui.SameLine(); - ImGui.Text( name ); - } - - DrawNewCharacterCollection(); - } - - public void Draw() - { - if( !ImGui.BeginTabItem( "Collections" ) ) - { - return; - } - - using var raii = ImGuiRaii.DeferredEnd( ImGui.EndTabItem ) - .Push( ImGui.EndChild ); - - if( ImGui.BeginChild( "##CollectionHandling", new Vector2( -1, ImGui.GetTextLineHeightWithSpacing() * 6 ), true ) ) - { - DrawCurrentCollectionSelector( true ); - - ImGuiHelpers.ScaledDummy( 0, 10 ); - DrawNewCollectionInput(); - } - - raii.Pop(); - - DrawCharacterCollectionSelectors(); - } - } - } -} \ No newline at end of file diff --git a/Penumbra/UI/MenuTabs/TabDebug.cs b/Penumbra/UI/MenuTabs/TabDebug.cs deleted file mode 100644 index baf36b5b..00000000 --- a/Penumbra/UI/MenuTabs/TabDebug.cs +++ /dev/null @@ -1,397 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Numerics; -using System.Reflection; -using System.Runtime.InteropServices; -using Dalamud.Game.ClientState.Objects.Types; -using ImGuiNET; -using Penumbra.Api; -using Penumbra.GameData.Enums; -using Penumbra.GameData.Util; -using Penumbra.Interop; -using Penumbra.Meta; -using Penumbra.Mods; -using Penumbra.UI.Custom; -using Penumbra.Util; - -namespace Penumbra.UI -{ - public partial class SettingsInterface - { - private static void DrawDebugTabPlayers() - { - if( !ImGui.CollapsingHeader( "Players##Debug" ) ) - { - return; - } - - var players = Penumbra.PlayerWatcher.WatchedPlayers().ToArray(); - if( !players.Any() ) - { - return; - } - - if( !ImGui.BeginTable( "##ObjectTable", 13, ImGuiTableFlags.SizingFixedFit | ImGuiTableFlags.ScrollX, - new Vector2( -1, ImGui.GetTextLineHeightWithSpacing() * 4 * players.Length ) ) ) - { - return; - } - - using var raii = ImGuiRaii.DeferredEnd( ImGui.EndTable ); - - var identifier = GameData.GameData.GetIdentifier(); - - foreach( var (actor, equip) in players ) - { - // @formatter:off - ImGui.TableNextRow(); - ImGui.TableNextColumn(); - ImGui.Text( actor ); - ImGui.TableNextColumn(); - ImGui.Text( $"{equip.MainHand}" ); - ImGui.TableNextColumn(); - ImGui.Text( $"{equip.Head}" ); - ImGui.TableNextColumn(); - ImGui.Text( $"{equip.Body}" ); - ImGui.TableNextColumn(); - ImGui.Text( $"{equip.Hands}" ); - ImGui.TableNextColumn(); - ImGui.Text( $"{equip.Legs}" ); - ImGui.TableNextColumn(); - ImGui.Text( $"{equip.Feet}" ); - - ImGui.TableNextRow(); - ImGui.TableNextColumn(); - if (equip.IsSet == 0) - { - ImGui.Text( "(not set)" ); - } - ImGui.TableNextColumn(); - ImGui.Text( identifier.Identify( equip.MainHand.Set, equip.MainHand.Type, equip.MainHand.Variant, EquipSlot.MainHand )?.Name.ToString() ?? "Unknown" ); - ImGui.TableNextColumn(); - ImGui.Text( identifier.Identify( equip.Head.Set, 0, equip.Head.Variant, EquipSlot.Head )?.Name.ToString() ?? "Unknown" ); - ImGui.TableNextColumn(); - ImGui.Text( identifier.Identify( equip.Body.Set, 0, equip.Body.Variant, EquipSlot.Body )?.Name.ToString() ?? "Unknown" ); - ImGui.TableNextColumn(); - ImGui.Text( identifier.Identify( equip.Hands.Set, 0, equip.Hands.Variant, EquipSlot.Hands )?.Name.ToString() ?? "Unknown" ); - ImGui.TableNextColumn(); - ImGui.Text( identifier.Identify( equip.Legs.Set, 0, equip.Legs.Variant, EquipSlot.Legs )?.Name.ToString() ?? "Unknown" ); - ImGui.TableNextColumn(); - ImGui.Text( identifier.Identify( equip.Feet.Set, 0, equip.Feet.Variant, EquipSlot.Feet )?.Name.ToString() ?? "Unknown" ); - - ImGui.TableNextRow(); - ImGui.TableNextColumn(); - ImGui.TableNextColumn(); - ImGui.Text( $"{equip.OffHand}" ); - ImGui.TableNextColumn(); - ImGui.Text( $"{equip.Ears}" ); - ImGui.TableNextColumn(); - ImGui.Text( $"{equip.Neck}" ); - ImGui.TableNextColumn(); - ImGui.Text( $"{equip.Wrists}" ); - ImGui.TableNextColumn(); - ImGui.Text( $"{equip.LFinger}" ); - ImGui.TableNextColumn(); - ImGui.Text( $"{equip.RFinger}" ); - - ImGui.TableNextRow(); - ImGui.TableNextColumn(); - ImGui.TableNextColumn(); - ImGui.Text( identifier.Identify( equip.OffHand.Set, equip.OffHand.Type, equip.OffHand.Variant, EquipSlot.OffHand )?.Name.ToString() ?? "Unknown" ); - ImGui.TableNextColumn(); - ImGui.Text( identifier.Identify( equip.Ears.Set, 0, equip.Ears.Variant, EquipSlot.Ears )?.Name.ToString() ?? "Unknown" ); - ImGui.TableNextColumn(); - ImGui.Text( identifier.Identify( equip.Neck.Set, 0, equip.Neck.Variant, EquipSlot.Neck )?.Name.ToString() ?? "Unknown" ); - ImGui.TableNextColumn(); - ImGui.Text( identifier.Identify( equip.Wrists.Set, 0, equip.Wrists.Variant, EquipSlot.Wrists )?.Name.ToString() ?? "Unknown" ); - ImGui.TableNextColumn(); - ImGui.Text( identifier.Identify( equip.LFinger.Set, 0, equip.LFinger.Variant, EquipSlot.LFinger )?.Name.ToString() ?? "Unknown" ); - ImGui.TableNextColumn(); - ImGui.Text( identifier.Identify( equip.RFinger.Set, 0, equip.RFinger.Variant, EquipSlot.LFinger )?.Name.ToString() ?? "Unknown" ); - // @formatter:on - } - } - - private static void PrintValue( string name, string value ) - { - ImGui.TableNextRow(); - ImGui.TableNextColumn(); - ImGui.Text( name ); - ImGui.TableNextColumn(); - ImGui.Text( value ); - } - - private static void DrawDebugTabGeneral() - { - if( !ImGui.CollapsingHeader( "General##Debug" ) ) - { - return; - } - - if( !ImGui.BeginTable( "##DebugGeneralTable", 2, ImGuiTableFlags.SizingFixedFit, - new Vector2( -1, ImGui.GetTextLineHeightWithSpacing() * 1 ) ) ) - { - return; - } - - using var raii = ImGuiRaii.DeferredEnd( ImGui.EndTable ); - - var manager = Service< ModManager >.Get(); - PrintValue( "Active Collection", manager.Collections.ActiveCollection.Name ); - PrintValue( "Mod Manager BasePath", manager.BasePath.Name ); - PrintValue( "Mod Manager BasePath-Full", manager.BasePath.FullName ); - PrintValue( "Mod Manager BasePath IsRooted", Path.IsPathRooted( Penumbra.Config.ModDirectory ).ToString() ); - PrintValue( "Mod Manager BasePath Exists", Directory.Exists( manager.BasePath.FullName ).ToString() ); - PrintValue( "Mod Manager Valid", manager.Valid.ToString() ); - PrintValue( "Mod Manager Temp Path", manager.TempPath.FullName ); - PrintValue( "Mod Manager Temp Path IsRooted", - ( !Penumbra.Config.TempDirectory.Any() || Path.IsPathRooted( Penumbra.Config.TempDirectory ) ).ToString() ); - PrintValue( "Mod Manager Temp Path Exists", Directory.Exists( manager.TempPath.FullName ).ToString() ); - PrintValue( "Mod Manager Temp Path IsWritable", manager.TempWritable.ToString() ); - } - - private void DrawDebugTabRedraw() - { - if( !ImGui.CollapsingHeader( "Redrawing##Debug" ) ) - { - return; - } - - var queue = ( Queue< (int, string, RedrawType) >? )_penumbra.ObjectReloader.GetType() - .GetField( "_objectIds", BindingFlags.Instance | BindingFlags.NonPublic ) - ?.GetValue( _penumbra.ObjectReloader ) - ?? new Queue< (int, string, RedrawType) >(); - - var currentFrame = ( int? )_penumbra.ObjectReloader.GetType() - .GetField( "_currentFrame", BindingFlags.Instance | BindingFlags.NonPublic ) - ?.GetValue( _penumbra.ObjectReloader ); - - var changedSettings = ( bool? )_penumbra.ObjectReloader.GetType() - .GetField( "_changedSettings", BindingFlags.Instance | BindingFlags.NonPublic ) - ?.GetValue( _penumbra.ObjectReloader ); - - var currentObjectId = ( uint? )_penumbra.ObjectReloader.GetType() - .GetField( "_currentObjectId", BindingFlags.Instance | BindingFlags.NonPublic ) - ?.GetValue( _penumbra.ObjectReloader ); - - var currentObjectName = ( string? )_penumbra.ObjectReloader.GetType() - .GetField( "_currentObjectName", BindingFlags.Instance | BindingFlags.NonPublic ) - ?.GetValue( _penumbra.ObjectReloader ); - - var currentObjectStartState = ( ObjectReloader.LoadingFlags? )_penumbra.ObjectReloader.GetType() - .GetField( "_currentObjectStartState", BindingFlags.Instance | BindingFlags.NonPublic ) - ?.GetValue( _penumbra.ObjectReloader ); - - var currentRedrawType = ( RedrawType? )_penumbra.ObjectReloader.GetType() - .GetField( "_currentRedrawType", BindingFlags.Instance | BindingFlags.NonPublic ) - ?.GetValue( _penumbra.ObjectReloader ); - - var (currentObject, currentObjectIdx) = ( (GameObject?, int) )_penumbra.ObjectReloader.GetType() - .GetMethod( "FindCurrentObject", BindingFlags.NonPublic | BindingFlags.Instance )? - .Invoke( _penumbra.ObjectReloader, Array.Empty< object >() )!; - - var currentRender = currentObject != null - ? ( ObjectReloader.LoadingFlags? )Marshal.ReadInt32( ObjectReloader.RenderPtr( currentObject ) ) - : null; - - var waitFrames = ( int? )_penumbra.ObjectReloader.GetType() - .GetField( "_waitFrames", BindingFlags.Instance | BindingFlags.NonPublic ) - ?.GetValue( _penumbra.ObjectReloader ); - - var wasTarget = ( bool? )_penumbra.ObjectReloader.GetType() - .GetField( "_wasTarget", BindingFlags.Instance | BindingFlags.NonPublic ) - ?.GetValue( _penumbra.ObjectReloader ); - - var gPose = ( bool? )_penumbra.ObjectReloader.GetType() - .GetField( "_inGPose", BindingFlags.Instance | BindingFlags.NonPublic ) - ?.GetValue( _penumbra.ObjectReloader ); - - using var raii = new ImGuiRaii.EndStack(); - if( ImGui.BeginTable( "##RedrawData", 2, ImGuiTableFlags.SizingFixedFit, - new Vector2( -1, ImGui.GetTextLineHeightWithSpacing() * 7 ) ) ) - { - raii.Push( ImGui.EndTable ); - PrintValue( "Current Wait Frame", waitFrames?.ToString() ?? "null" ); - PrintValue( "Current Frame", currentFrame?.ToString() ?? "null" ); - PrintValue( "Currently in GPose", gPose?.ToString() ?? "null" ); - PrintValue( "Current Changed Settings", changedSettings?.ToString() ?? "null" ); - PrintValue( "Current Object Id", currentObjectId?.ToString( "X8" ) ?? "null" ); - PrintValue( "Current Object Name", currentObjectName ?? "null" ); - PrintValue( "Current Object Start State", ( ( int? )currentObjectStartState )?.ToString( "X8" ) ?? "null" ); - PrintValue( "Current Object Was Target", wasTarget?.ToString() ?? "null" ); - PrintValue( "Current Object Redraw", currentRedrawType?.ToString() ?? "null" ); - PrintValue( "Current Object Address", currentObject?.Address.ToString( "X16" ) ?? "null" ); - PrintValue( "Current Object Index", currentObjectIdx >= 0 ? currentObjectIdx.ToString() : "null" ); - PrintValue( "Current Object Render Flags", ( ( int? )currentRender )?.ToString( "X8" ) ?? "null" ); - } - - if( queue.Any() - && ImGui.BeginTable( "##RedrawTable", 3, ImGuiTableFlags.SizingFixedFit | ImGuiTableFlags.ScrollX, - new Vector2( -1, ImGui.GetTextLineHeightWithSpacing() * queue.Count ) ) ) - { - raii.Push( ImGui.EndTable ); - foreach( var (objectId, objectName, redraw) in queue ) - { - ImGui.TableNextRow(); - ImGui.TableNextColumn(); - ImGui.Text( objectName ); - ImGui.TableNextColumn(); - ImGui.Text( $"0x{objectId:X8}" ); - ImGui.TableNextColumn(); - ImGui.Text( redraw.ToString() ); - } - } - - if( queue.Any() && ImGui.Button( "Clear" ) ) - { - queue.Clear(); - _penumbra.ObjectReloader.GetType() - .GetField( "_currentFrame", BindingFlags.Instance | BindingFlags.NonPublic )?.SetValue( _penumbra.ObjectReloader, 0 ); - } - } - - private static void DrawDebugTabTempFiles() - { - if( !ImGui.CollapsingHeader( "Temporary Files##Debug" ) ) - { - return; - } - - if( !ImGui.BeginTable( "##tempFileTable", 4, ImGuiTableFlags.SizingFixedFit ) ) - { - return; - } - - using var raii = ImGuiRaii.DeferredEnd( ImGui.EndTable ); - - foreach( var collection in Service< ModManager >.Get().Collections.Collections.Values.Where( c => c.Cache != null ) ) - { - var manip = collection.Cache!.MetaManipulations; - var files = ( Dictionary< GamePath, MetaManager.FileInformation >? )manip.GetType() - .GetField( "_currentFiles", BindingFlags.NonPublic | BindingFlags.Instance )?.GetValue( manip ) - ?? new Dictionary< GamePath, MetaManager.FileInformation >(); - - - foreach( var (file, info) in files ) - { - ImGui.TableNextRow(); - ImGui.TableNextColumn(); - ImGui.Text( info.CurrentFile?.FullName ?? "None" ); - ImGui.TableNextColumn(); - ImGui.Text( file ); - ImGui.TableNextColumn(); - info.CurrentFile?.Refresh(); - ImGui.Text( info.CurrentFile?.Exists ?? false ? "Exists" : "Missing" ); - ImGui.TableNextColumn(); - ImGui.Text( info.Changed ? "Data Changed" : "Unchanged" ); - } - } - } - - private void DrawDebugTabIpc() - { - if( !ImGui.CollapsingHeader( "IPC##Debug" ) ) - { - return; - } - - var ipc = _penumbra.Ipc; - ImGui.Text( $"API Version: {ipc.Api.ApiVersion}" ); - ImGui.Text( "Available subscriptions:" ); - using var indent = ImGuiRaii.PushIndent(); - if( ipc.ProviderApiVersion != null ) - { - ImGui.Text( PenumbraIpc.LabelProviderApiVersion ); - } - - if( ipc.ProviderRedrawName != null ) - { - ImGui.Text( PenumbraIpc.LabelProviderRedrawName ); - } - - if( ipc.ProviderRedrawObject != null ) - { - ImGui.Text( PenumbraIpc.LabelProviderRedrawObject ); - } - - if( ipc.ProviderRedrawAll != null ) - { - ImGui.Text( PenumbraIpc.LabelProviderRedrawAll ); - } - - if( ipc.ProviderResolveDefault != null ) - { - ImGui.Text( PenumbraIpc.LabelProviderResolveDefault ); - } - - if( ipc.ProviderResolveCharacter != null ) - { - ImGui.Text( PenumbraIpc.LabelProviderResolveCharacter ); - } - - if( ipc.ProviderChangedItemTooltip != null ) - { - ImGui.Text( PenumbraIpc.LabelProviderChangedItemTooltip ); - } - - if( ipc.ProviderChangedItemClick != null ) - { - ImGui.Text( PenumbraIpc.LabelProviderChangedItemClick ); - } - } - - private void DrawDebugTabMissingFiles() - { - if( !ImGui.CollapsingHeader( "Missing Files##Debug" ) ) - { - return; - } - - var manager = Service< ModManager >.Get(); - var cache = manager.Collections.CurrentCollection.Cache; - if( cache == null || !ImGui.BeginTable( "##MissingFilesDebugList", 1, ImGuiTableFlags.RowBg, -Vector2.UnitX ) ) - { - return; - } - - using var raii = ImGuiRaii.DeferredEnd( ImGui.EndTable ); - - foreach( var file in cache.MissingFiles ) - { - ImGui.TableNextRow(); - ImGui.TableNextColumn(); - if( ImGui.Selectable( file.FullName ) ) - { - ImGui.SetClipboardText( file.FullName ); - } - - ImGuiCustom.HoverTooltip( "Click to copy to clipboard." ); - } - } - - private void DrawDebugTab() - { - if( !ImGui.BeginTabItem( "Debug Tab" ) ) - { - return; - } - - using var raii = ImGuiRaii.DeferredEnd( ImGui.EndTabItem ); - - DrawDebugTabGeneral(); - ImGui.NewLine(); - DrawDebugTabMissingFiles(); - ImGui.NewLine(); - DrawDebugTabRedraw(); - ImGui.NewLine(); - DrawDebugTabPlayers(); - ImGui.NewLine(); - DrawDebugTabTempFiles(); - ImGui.NewLine(); - DrawDebugTabIpc(); - ImGui.NewLine(); - } - } -} \ No newline at end of file diff --git a/Penumbra/UI/MenuTabs/TabDebugModels.cs b/Penumbra/UI/MenuTabs/TabDebugModels.cs deleted file mode 100644 index 67765caf..00000000 --- a/Penumbra/UI/MenuTabs/TabDebugModels.cs +++ /dev/null @@ -1,148 +0,0 @@ -using System; -using System.Runtime.InteropServices; -using FFXIVClientStructs.FFXIV.Client.Game.Character; -using FFXIVClientStructs.FFXIV.Client.Graphics.Render; -using FFXIVClientStructs.FFXIV.Client.Graphics.Scene; -using FFXIVClientStructs.FFXIV.Client.System.Resource; -using FFXIVClientStructs.FFXIV.Client.System.Resource.Handle; -using ImGuiNET; -using Lumina.Models.Models; -using Penumbra.UI.Custom; -using DalamudCharacter = Dalamud.Game.ClientState.Objects.Types.Character; - -namespace Penumbra.UI -{ - public partial class SettingsInterface - { - [StructLayout( LayoutKind.Explicit )] - private unsafe struct RenderModel - { - [FieldOffset(0x18)] - public RenderModel* PreviousModel; - [FieldOffset( 0x20 )] - public RenderModel* NextModel; - - [FieldOffset( 0x30 )] - public ResourceHandle* ResourceHandle; - - [FieldOffset( 0x40 )] - public Skeleton* Skeleton; - - [FieldOffset( 0x58 )] - public void** BoneList; - [FieldOffset( 0x60 )] - public int BoneListCount; - - [FieldOffset( 0x68 )] - private void* UnkDXBuffer1; - - [FieldOffset( 0x70 )] - private void* UnkDXBuffer2; - - [FieldOffset( 0x78 )] - private void* UnkDXBuffer3; - - [FieldOffset( 0x90 )] - public void** Materials; - - [FieldOffset( 0x98 )] - public int MaterialCount; - } - - [StructLayout( LayoutKind.Explicit )] - private unsafe struct Material - { - [FieldOffset(0x10)] - public ResourceHandle* ResourceHandle; - [FieldOffset(0x28)] - public void* MaterialData; - - [FieldOffset( 0x48 )] - public Texture* Tex1; - [FieldOffset( 0x60 )] - public Texture* Tex2; - [FieldOffset( 0x78 )] - public Texture* Tex3; - } - - private static unsafe void DrawPlayerModelInfo( DalamudCharacter character ) - { - var name = character.Name.ToString(); - if( !ImGui.CollapsingHeader( $"{name}##Draw" ) ) - { - return; - } - - var model = ( CharacterBase* )( ( Character* )character.Address )->GameObject.GetDrawObject(); - if( model == null ) - { - return; - } - - if( !ImGui.BeginTable( $"##{name}DrawTable", 5, ImGuiTableFlags.RowBg | ImGuiTableFlags.SizingFixedFit ) ) - { - return; - } - - using var raii = ImGuiRaii.DeferredEnd( ImGui.EndTable ); - ImGui.TableNextColumn(); - ImGui.TableHeader( "Slot" ); - ImGui.TableNextColumn(); - ImGui.TableHeader( "Imc Ptr" ); - ImGui.TableNextColumn(); - ImGui.TableHeader( "Imc File" ); - ImGui.TableNextColumn(); - ImGui.TableHeader( "Model Ptr" ); - ImGui.TableNextColumn(); - ImGui.TableHeader( "Model File" ); - - for( var i = 0; i < model->SlotCount; ++i ) - { - var imc = ( ResourceHandle* )model->IMCArray[ i ]; - ImGui.TableNextRow(); - ImGui.TableNextColumn(); - ImGui.Text( $"Slot {i}" ); - ImGui.TableNextColumn(); - ImGui.Text( imc == null ? "NULL" : $"0x{( ulong )imc:X}" ); - ImGui.TableNextColumn(); - if( imc != null ) - { - ImGui.Text( imc->FileName.ToString() ); - } - - var mdl = ( RenderModel* )model->ModelArray[ i ]; - ImGui.TableNextColumn(); - ImGui.Text( mdl == null ? "NULL" : $"0x{( ulong )mdl:X}" ); - if( mdl == null || mdl->ResourceHandle == null || mdl->ResourceHandle->Category != ResourceCategory.Chara ) - { - continue; - } - - - ImGui.TableNextColumn(); - if( mdl != null ) - { - ImGui.Text( mdl->ResourceHandle->FileName.ToString() ); - } - } - } - - private void DrawPlayerModelTab() - { - if( !ImGui.BeginTabItem( "Model Debug" ) ) - { - return; - } - - using var raii = ImGuiRaii.DeferredEnd( ImGui.EndTabItem ); - - var player = Dalamud.ClientState.LocalPlayer; - if( player == null ) - { - return; - } - - DrawPlayerModelInfo( player ); - } - } -} \ No newline at end of file diff --git a/Penumbra/UI/MenuTabs/TabEffective.cs b/Penumbra/UI/MenuTabs/TabEffective.cs deleted file mode 100644 index 8d46d86a..00000000 --- a/Penumbra/UI/MenuTabs/TabEffective.cs +++ /dev/null @@ -1,116 +0,0 @@ -using System.IO; -using System.Linq; -using Dalamud.Interface; -using ImGuiNET; -using Penumbra.GameData.Util; -using Penumbra.Meta; -using Penumbra.Mods; -using Penumbra.UI.Custom; -using Penumbra.Util; - -namespace Penumbra.UI -{ - public partial class SettingsInterface - { - private class TabEffective - { - private const string LabelTab = "Effective Changes"; - private readonly ModManager _modManager; - - private readonly float _leftTextLength = - ImGui.CalcTextSize( "chara/human/c0000/obj/body/b0000/material/v0000/mt_c0000b0000_b.mtrl" ).X + 40; - - public TabEffective() - => _modManager = Service< ModManager >.Get(); - - - private static void DrawFileLine( FileInfo file, GamePath path ) - { - ImGui.TableNextColumn(); - ImGuiCustom.CopyOnClickSelectable( path ); - - ImGui.TableNextColumn(); - ImGuiCustom.PrintIcon( FontAwesomeIcon.LongArrowAltLeft ); - ImGui.SameLine(); - ImGuiCustom.CopyOnClickSelectable( file.FullName ); - } - - private static void DrawManipulationLine( MetaManipulation manip, Mod.Mod mod ) - { - ImGui.TableNextColumn(); - ImGui.Selectable( manip.IdentifierString() ); - - ImGui.TableNextColumn(); - ImGuiCustom.PrintIcon( FontAwesomeIcon.LongArrowAltLeft ); - ImGui.SameLine(); - ImGui.Selectable( mod.Data.Meta.Name ); - } - - public void Draw() - { - if( !ImGui.BeginTabItem( LabelTab ) ) - { - return; - } - - using var raii = ImGuiRaii.DeferredEnd( ImGui.EndTabItem ); - - const ImGuiTableFlags flags = ImGuiTableFlags.RowBg | ImGuiTableFlags.ScrollX; - - var activeCollection = _modManager.Collections.ActiveCollection.Cache; - var forcedCollection = _modManager.Collections.ForcedCollection.Cache; - - var (activeResolved, activeMeta) = activeCollection != null - ? ( activeCollection.ResolvedFiles.Count, activeCollection.MetaManipulations.Count ) - : ( 0, 0 ); - var (forcedResolved, forcedMeta) = forcedCollection != null - ? ( forcedCollection.ResolvedFiles.Count, forcedCollection.MetaManipulations.Count ) - : ( 0, 0 ); - - var lines = activeResolved + forcedResolved + activeMeta + forcedMeta; - ImGuiListClipperPtr clipper; - unsafe - { - clipper = new ImGuiListClipperPtr( ImGuiNative.ImGuiListClipper_ImGuiListClipper() ); - } - - clipper.Begin( lines ); - - if( ImGui.BeginTable( "##effective_changes", 2, flags, AutoFillSize ) ) - { - raii.Push( ImGui.EndTable ); - ImGui.TableSetupColumn( "##tableGamePathCol", ImGuiTableColumnFlags.None, _leftTextLength ); - while( clipper.Step() ) - { - for( var actualRow = clipper.DisplayStart; actualRow < clipper.DisplayEnd; actualRow++ ) - { - var row = actualRow; - ImGui.TableNextRow(); - if( row < activeResolved ) - { - var (gamePath, file) = activeCollection!.ResolvedFiles.ElementAt( row ); - DrawFileLine( file, gamePath ); - } - else if( ( row -= activeResolved ) < forcedResolved ) - { - var (gamePath, file) = forcedCollection!.ResolvedFiles.ElementAt( row ); - DrawFileLine( file, gamePath ); - } - else if( ( row -= forcedResolved ) < activeMeta ) - { - var (manip, mod) = activeCollection!.MetaManipulations.Manipulations.ElementAt( row ); - DrawManipulationLine( manip, mod ); - } - else - { - row -= activeMeta; - var (manip, mod) = forcedCollection!.MetaManipulations.Manipulations.ElementAt( row ); - DrawManipulationLine( manip, mod ); - } - } - } - } - } - } - } -} \ No newline at end of file diff --git a/Penumbra/UI/MenuTabs/TabImport.cs b/Penumbra/UI/MenuTabs/TabImport.cs deleted file mode 100644 index e0166852..00000000 --- a/Penumbra/UI/MenuTabs/TabImport.cs +++ /dev/null @@ -1,189 +0,0 @@ -using System; -using System.IO; -using System.Linq; -using System.Numerics; -using System.Threading.Tasks; -using System.Windows.Forms; -using Dalamud.Logging; -using ImGuiNET; -using Penumbra.Importer; -using Penumbra.Mods; -using Penumbra.UI.Custom; -using Penumbra.Util; - -namespace Penumbra.UI -{ - public partial class SettingsInterface - { - private class TabImport - { - private const string LabelTab = "Import Mods"; - private const string LabelImportButton = "Import TexTools Modpacks"; - private const string LabelFileDialog = "Pick one or more modpacks."; - private const string LabelFileImportRunning = "Import in progress..."; - private const string FileTypeFilter = "TexTools TTMP Modpack (*.ttmp2)|*.ttmp*|All files (*.*)|*.*"; - private const string TooltipModpack1 = "Writing modpack to disk before extracting..."; - - private const uint ColorRed = 0xFF0000C8; - private const uint ColorYellow = 0xFF00C8C8; - - private static readonly Vector2 ImportBarSize = new( -1, 0 ); - - private bool _isImportRunning; - private string _errorMessage = string.Empty; - private TexToolsImport? _texToolsImport; - private readonly SettingsInterface _base; - private readonly ModManager _manager; - - public TabImport( SettingsInterface ui ) - { - _base = ui; - _manager = Service< ModManager >.Get(); - } - - public bool IsImporting() - => _isImportRunning; - - private void RunImportTask() - { - _isImportRunning = true; - Task.Run( async () => - { - try - { - var picker = new OpenFileDialog - { - Multiselect = true, - Filter = FileTypeFilter, - CheckFileExists = true, - Title = LabelFileDialog, - }; - - var result = await picker.ShowDialogAsync(); - - if( result == DialogResult.OK ) - { - _errorMessage = string.Empty; - - foreach( var fileName in picker.FileNames ) - { - PluginLog.Information( $"-> {fileName} START" ); - - try - { - _texToolsImport = new TexToolsImport( _manager.BasePath ); - _texToolsImport.ImportModPack( new FileInfo( fileName ) ); - - PluginLog.Information( $"-> {fileName} OK!" ); - } - catch( Exception ex ) - { - PluginLog.LogError( ex, "Failed to import modpack at {0}", fileName ); - _errorMessage = ex.Message; - } - } - - var directory = _texToolsImport?.ExtractedDirectory; - _texToolsImport = null; - _base.ReloadMods(); - if( directory != null ) - { - _base._menu.InstalledTab.Selector.SelectModOnUpdate( directory.Name ); - } - } - } - catch( Exception e ) - { - PluginLog.Error( $"Error opening file picker dialogue:\n{e}" ); - } - - _isImportRunning = false; - } ); - } - - private void DrawImportButton() - { - if( !_manager.Valid ) - { - using var style = ImGuiRaii.PushStyle( ImGuiStyleVar.Alpha, 0.5f ); - ImGui.Button( LabelImportButton ); - style.Pop(); - - using var color = ImGuiRaii.PushColor( ImGuiCol.Text, ColorRed ); - ImGui.Text( "Can not import since the mod directory path is not valid." ); - ImGui.Dummy( Vector2.UnitY * ImGui.GetTextLineHeightWithSpacing() ); - color.Pop(); - - ImGui.Text( "Please set the mod directory in the settings tab." ); - ImGui.Text( "This folder should preferably be close to the root directory of your (preferably SSD) drive, for example" ); - color.Push( ImGuiCol.Text, ColorYellow ); - ImGui.Text( " D:\\ffxivmods" ); - color.Pop(); - ImGui.Text( "You can return to this tab once you've done that." ); - } - else if( ImGui.Button( LabelImportButton ) ) - { - RunImportTask(); - } - } - - private void DrawImportProgress() - { - ImGui.Button( LabelFileImportRunning ); - - if( _texToolsImport == null ) - { - return; - } - - switch( _texToolsImport.State ) - { - case ImporterState.None: break; - case ImporterState.WritingPackToDisk: - ImGui.Text( TooltipModpack1 ); - break; - case ImporterState.ExtractingModFiles: - { - var str = - $"{_texToolsImport.CurrentModPack} - {_texToolsImport.CurrentProgress} of {_texToolsImport.TotalProgress} files"; - - ImGui.ProgressBar( _texToolsImport.Progress, ImportBarSize, str ); - break; - } - case ImporterState.Done: break; - default: throw new ArgumentOutOfRangeException(); - } - } - - private void DrawFailedImportMessage() - { - using var color = ImGuiRaii.PushColor( ImGuiCol.Text, ColorRed ); - ImGui.Text( $"One or more of your modpacks failed to import:\n\t\t{_errorMessage}" ); - } - - public void Draw() - { - if( !ImGui.BeginTabItem( LabelTab ) ) - { - return; - } - - using var raii = ImGuiRaii.DeferredEnd( ImGui.EndTabItem ); - - if( !_isImportRunning ) - { - DrawImportButton(); - } - else - { - DrawImportProgress(); - } - - if( _errorMessage.Any() ) - { - DrawFailedImportMessage(); - } - } - } - } -} \ No newline at end of file diff --git a/Penumbra/UI/MenuTabs/TabInstalled/ModFilter.cs b/Penumbra/UI/MenuTabs/TabInstalled/ModFilter.cs deleted file mode 100644 index 590ffff0..00000000 --- a/Penumbra/UI/MenuTabs/TabInstalled/ModFilter.cs +++ /dev/null @@ -1,46 +0,0 @@ -using System; - -namespace Penumbra.UI -{ - [Flags] - public enum ModFilter - { - Enabled = 1 << 0, - Disabled = 1 << 1, - NoConflict = 1 << 2, - SolvedConflict = 1 << 3, - UnsolvedConflict = 1 << 4, - HasNoMetaManipulations = 1 << 5, - HasMetaManipulations = 1 << 6, - HasNoFileSwaps = 1 << 7, - HasFileSwaps = 1 << 8, - HasConfig = 1 << 9, - HasNoConfig = 1 << 10, - HasNoFiles = 1 << 11, - HasFiles = 1 << 12, - }; - - public static class ModFilterExtensions - { - public const ModFilter UnfilteredStateMods = ( ModFilter )( ( 1 << 13 ) - 1 ); - - public static string ToName( this ModFilter filter ) - => filter switch - { - ModFilter.Enabled => "Enabled", - ModFilter.Disabled => "Disabled", - ModFilter.NoConflict => "No Conflicts", - ModFilter.SolvedConflict => "Solved Conflicts", - ModFilter.UnsolvedConflict => "Unsolved Conflicts", - ModFilter.HasNoMetaManipulations => "No Meta Manipulations", - ModFilter.HasMetaManipulations => "Meta Manipulations", - ModFilter.HasNoFileSwaps => "No File Swaps", - ModFilter.HasFileSwaps => "File Swaps", - ModFilter.HasNoConfig => "No Configuration", - ModFilter.HasConfig => "Configuration", - ModFilter.HasNoFiles => "No Files", - ModFilter.HasFiles => "Files", - _ => throw new ArgumentOutOfRangeException( nameof( filter ), filter, null ), - }; - } -} \ No newline at end of file diff --git a/Penumbra/UI/MenuTabs/TabInstalled/ModListCache.cs b/Penumbra/UI/MenuTabs/TabInstalled/ModListCache.cs deleted file mode 100644 index ba1e3127..00000000 --- a/Penumbra/UI/MenuTabs/TabInstalled/ModListCache.cs +++ /dev/null @@ -1,310 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using Dalamud.Logging; -using Penumbra.Mods; - -namespace Penumbra.UI -{ - public class ModListCache : IDisposable - { - public const uint DisabledModColor = 0xFF666666u; - public const uint ConflictingModColor = 0xFFAAAAFFu; - public const uint HandledConflictModColor = 0xFF88DDDDu; - - private readonly ModManager _manager; - - private readonly List< Mod.Mod > _modsInOrder = new(); - private readonly List< (bool visible, uint color) > _visibleMods = new(); - private readonly Dictionary< ModFolder, (bool visible, bool enabled) > _visibleFolders = new(); - - private string _modFilter = string.Empty; - private string _modFilterChanges = string.Empty; - private string _modFilterAuthor = string.Empty; - private ModFilter _stateFilter = ModFilterExtensions.UnfilteredStateMods; - private bool _listResetNecessary; - private bool _filterResetNecessary; - - - public ModFilter StateFilter - { - get => _stateFilter; - set - { - var diff = _stateFilter != value; - _stateFilter = value; - if( diff ) - { - TriggerFilterReset(); - } - } - } - - public ModListCache( ModManager manager ) - { - _manager = manager; - ResetModList(); - ModFileSystem.ModFileSystemChanged += TriggerListReset; - } - - public void Dispose() - { - ModFileSystem.ModFileSystemChanged -= TriggerListReset; - } - - public int Count - => _modsInOrder.Count; - - - public bool Update() - { - if( _listResetNecessary ) - { - ResetModList(); - return true; - } - - if( _filterResetNecessary ) - { - ResetFilters(); - return true; - } - - return false; - } - - public void TriggerListReset() - => _listResetNecessary = true; - - public void TriggerFilterReset() - => _filterResetNecessary = true; - - public void RemoveMod( Mod.Mod mod ) - { - var idx = _modsInOrder.IndexOf( mod ); - if( idx >= 0 ) - { - _modsInOrder.RemoveAt( idx ); - _visibleMods.RemoveAt( idx ); - UpdateFolders(); - } - } - - private void SetFolderAndParentsVisible( ModFolder? folder ) - { - while( folder != null && ( !_visibleFolders.TryGetValue( folder, out var state ) || !state.visible ) ) - { - _visibleFolders[ folder ] = ( true, true ); - folder = folder.Parent; - } - } - - private void UpdateFolders() - { - _visibleFolders.Clear(); - - for( var i = 0; i < _modsInOrder.Count; ++i ) - { - if( _visibleMods[ i ].visible ) - { - SetFolderAndParentsVisible( _modsInOrder[ i ].Data.SortOrder.ParentFolder ); - } - } - } - - public void SetTextFilter( string filter ) - { - var lower = filter.ToLowerInvariant(); - if( lower.StartsWith( "c:" ) ) - { - _modFilterChanges = lower.Substring( 2 ); - _modFilter = string.Empty; - _modFilterAuthor = string.Empty; - } - else if( lower.StartsWith( "a:" ) ) - { - _modFilterAuthor = lower.Substring( 2 ); - _modFilter = string.Empty; - _modFilterChanges = string.Empty; - } - else - { - _modFilter = lower; - _modFilterAuthor = string.Empty; - _modFilterChanges = string.Empty; - } - - ResetFilters(); - } - - private void ResetModList() - { - _modsInOrder.Clear(); - _visibleMods.Clear(); - _visibleFolders.Clear(); - - PluginLog.Debug( "Resetting mod selector list..." ); - if( !_modsInOrder.Any() ) - { - foreach( var modData in _manager.StructuredMods.AllMods( _manager.Config.SortFoldersFirst ) ) - { - var mod = _manager.Collections.CurrentCollection.GetMod( modData ); - _modsInOrder.Add( mod ); - _visibleMods.Add( CheckFilters( mod ) ); - } - } - - _listResetNecessary = false; - _filterResetNecessary = false; - } - - private void ResetFilters() - { - _visibleMods.Clear(); - _visibleFolders.Clear(); - PluginLog.Debug( "Resetting mod selector filters..." ); - foreach( var mod in _modsInOrder ) - { - _visibleMods.Add( CheckFilters( mod ) ); - } - - _filterResetNecessary = false; - } - - public (Mod.Mod? mod, int idx) GetModByName( string name ) - { - for( var i = 0; i < Count; ++i ) - { - if( _modsInOrder[ i ].Data.Meta.Name == name ) - { - return ( _modsInOrder[ i ], i ); - } - } - - return ( null, 0 ); - } - - public (Mod.Mod? mod, int idx) GetModByBasePath( string basePath ) - { - for( var i = 0; i < Count; ++i ) - { - if( _modsInOrder[ i ].Data.BasePath.Name == basePath ) - { - return ( _modsInOrder[ i ], i ); - } - } - - return ( null, 0 ); - } - - public (bool visible, bool enabled) GetFolder( ModFolder folder ) - => _visibleFolders.TryGetValue( folder, out var ret ) ? ret : ( false, false ); - - public (Mod.Mod?, bool visible, uint color) GetMod( int idx ) - => idx >= 0 && idx < _modsInOrder.Count - ? ( _modsInOrder[ idx ], _visibleMods[ idx ].visible, _visibleMods[ idx ].color ) - : ( null, false, 0 ); - - private bool CheckFlags( int count, ModFilter hasNoFlag, ModFilter hasFlag ) - { - if( count == 0 ) - { - if( StateFilter.HasFlag( hasNoFlag ) ) - { - return false; - } - } - else if( StateFilter.HasFlag( hasFlag ) ) - { - return false; - } - - return true; - } - - private (bool, uint) CheckFilters( Mod.Mod mod ) - { - var ret = ( false, 0u ); - if( _modFilter.Any() && !mod.Data.Meta.LowerName.Contains( _modFilter ) ) - { - return ret; - } - - if( _modFilterAuthor.Any() && !mod.Data.Meta.LowerAuthor.Contains( _modFilterAuthor ) ) - { - return ret; - } - - if( _modFilterChanges.Any() && !mod.Data.LowerChangedItemsString.Contains( _modFilterChanges ) ) - { - return ret; - } - - if( CheckFlags( mod.Data.Resources.ModFiles.Count, ModFilter.HasNoFiles, ModFilter.HasFiles ) ) - { - return ret; - } - - if( CheckFlags( mod.Data.Meta.FileSwaps.Count, ModFilter.HasNoFileSwaps, ModFilter.HasFileSwaps ) ) - { - return ret; - } - - if( CheckFlags( mod.Data.Resources.MetaManipulations.Count, ModFilter.HasNoMetaManipulations, - ModFilter.HasMetaManipulations ) ) - { - return ret; - } - - if( CheckFlags( mod.Data.Meta.HasGroupsWithConfig ? 1 : 0, ModFilter.HasNoConfig, ModFilter.HasConfig ) ) - { - return ret; - } - - if( !mod.Settings.Enabled ) - { - if( !StateFilter.HasFlag( ModFilter.Disabled ) || !StateFilter.HasFlag( ModFilter.NoConflict ) ) - { - return ret; - } - - ret.Item2 = ret.Item2 == 0 ? DisabledModColor : ret.Item2; - } - - if( mod.Settings.Enabled && !StateFilter.HasFlag( ModFilter.Enabled ) ) - { - return ret; - } - - if( mod.Cache.Conflicts.Any() ) - { - if( mod.Cache.Conflicts.Keys.Any( m => m.Settings.Priority == mod.Settings.Priority ) ) - { - if( !StateFilter.HasFlag( ModFilter.UnsolvedConflict ) ) - { - return ret; - } - - ret.Item2 = ret.Item2 == 0 ? ConflictingModColor : ret.Item2; - } - else - { - if( !StateFilter.HasFlag( ModFilter.SolvedConflict ) ) - { - return ret; - } - - ret.Item2 = ret.Item2 == 0 ? HandledConflictModColor : ret.Item2; - } - } - else if( !StateFilter.HasFlag( ModFilter.NoConflict ) ) - { - return ret; - } - - ret.Item1 = true; - SetFolderAndParentsVisible( mod.Data.SortOrder.ParentFolder ); - return ret; - } - } -} \ No newline at end of file diff --git a/Penumbra/UI/MenuTabs/TabInstalled/TabInstalled.cs b/Penumbra/UI/MenuTabs/TabInstalled/TabInstalled.cs deleted file mode 100644 index ae1764b5..00000000 --- a/Penumbra/UI/MenuTabs/TabInstalled/TabInstalled.cs +++ /dev/null @@ -1,53 +0,0 @@ -using ImGuiNET; -using Penumbra.Mods; -using Penumbra.UI.Custom; -using Penumbra.Util; - -namespace Penumbra.UI -{ - public partial class SettingsInterface - { - private class TabInstalled - { - private const string LabelTab = "Installed Mods"; - - private readonly ModManager _modManager; - public readonly Selector Selector; - public readonly ModPanel ModPanel; - - public TabInstalled( SettingsInterface ui ) - { - Selector = new Selector( ui ); - ModPanel = new ModPanel( ui, Selector ); - _modManager = Service< ModManager >.Get(); - } - - private static void DrawNoModsAvailable() - { - ImGui.Text( "You don't have any mods :(" ); - } - - public void Draw() - { - var ret = ImGui.BeginTabItem( LabelTab ); - if( !ret ) - { - return; - } - - using var raii = ImGuiRaii.DeferredEnd( ImGui.EndTabItem ); - - if( _modManager.Mods.Count > 0 ) - { - Selector.Draw(); - ImGui.SameLine(); - ModPanel.Draw(); - } - else - { - DrawNoModsAvailable(); - } - } - } - } -} \ No newline at end of file diff --git a/Penumbra/UI/MenuTabs/TabInstalled/TabInstalledDetails.cs b/Penumbra/UI/MenuTabs/TabInstalled/TabInstalledDetails.cs deleted file mode 100644 index fd386d50..00000000 --- a/Penumbra/UI/MenuTabs/TabInstalled/TabInstalledDetails.cs +++ /dev/null @@ -1,717 +0,0 @@ -using System.IO; -using System.Linq; -using Dalamud.Interface; -using ImGuiNET; -using Penumbra.GameData.Enums; -using Penumbra.GameData.Util; -using Penumbra.Meta; -using Penumbra.Mod; -using Penumbra.Mods; -using Penumbra.Structs; -using Penumbra.UI.Custom; -using Penumbra.Util; -using ImGui = ImGuiNET.ImGui; - -namespace Penumbra.UI -{ - public partial class SettingsInterface - { - private partial class PluginDetails - { - private const string LabelPluginDetails = "PenumbraPluginDetails"; - private const string LabelAboutTab = "About"; - private const string LabelChangedItemsTab = "Changed Items"; - private const string LabelChangedItemsHeader = "##changedItems"; - private const string LabelConflictsTab = "Mod Conflicts"; - private const string LabelConflictsHeader = "##conflicts"; - private const string LabelFileSwapTab = "File Swaps"; - private const string LabelFileSwapHeader = "##fileSwaps"; - private const string LabelFileListTab = "Files"; - private const string LabelFileListHeader = "##fileList"; - private const string LabelGroupSelect = "##groupSelect"; - private const string LabelOptionSelect = "##optionSelect"; - private const string LabelConfigurationTab = "Configuration"; - - private const string TooltipFilesTab = - "Green files replace their standard game path counterpart (not in any option) or are in all options of a Single-Select option.\n" - + "Yellow files are restricted to some options."; - - private const float OptionSelectionWidth = 140f; - private const float CheckMarkSize = 50f; - private const uint ColorDarkGreen = 0xFF00A000; - private const uint ColorGreen = 0xFF00C800; - private const uint ColorYellow = 0xFF00C8C8; - private const uint ColorDarkRed = 0xFF0000A0; - private const uint ColorRed = 0xFF0000C8; - - - private bool _editMode; - private int _selectedGroupIndex; - private OptionGroup? _selectedGroup; - private int _selectedOptionIndex; - private Option? _selectedOption; - private string _currentGamePaths = ""; - - private (FileInfo name, bool selected, uint color, RelPath relName)[]? _fullFilenameList; - - private readonly Selector _selector; - private readonly SettingsInterface _base; - private readonly ModManager _modManager; - - private void SelectGroup( int idx ) - { - // Not using the properties here because we need it to be not null forgiving in this case. - var numGroups = _selector.Mod?.Data.Meta.Groups.Count ?? 0; - _selectedGroupIndex = idx; - if( _selectedGroupIndex >= numGroups ) - { - _selectedGroupIndex = 0; - } - - if( numGroups > 0 ) - { - _selectedGroup = Meta.Groups.ElementAt( _selectedGroupIndex ).Value; - } - else - { - _selectedGroup = null; - } - } - - private void SelectGroup() - => SelectGroup( _selectedGroupIndex ); - - private void SelectOption( int idx ) - { - _selectedOptionIndex = idx; - if( _selectedOptionIndex >= _selectedGroup?.Options.Count ) - { - _selectedOptionIndex = 0; - } - - if( _selectedGroup?.Options.Count > 0 ) - { - _selectedOption = ( ( OptionGroup )_selectedGroup ).Options[ _selectedOptionIndex ]; - } - else - { - _selectedOption = null; - } - } - - private void SelectOption() - => SelectOption( _selectedOptionIndex ); - - public void ResetState() - { - _fullFilenameList = null; - SelectGroup(); - SelectOption(); - } - - public PluginDetails( SettingsInterface ui, Selector s ) - { - _base = ui; - _selector = s; - ResetState(); - _modManager = Service< ModManager >.Get(); - } - - // This is only drawn when we have a mod selected, so we can forgive nulls. - private Mod.Mod Mod - => _selector.Mod!; - - private ModMeta Meta - => Mod.Data.Meta; - - private void Save() - { - _modManager.Collections.CurrentCollection.Save(); - } - - private void DrawAboutTab() - { - if( !_editMode && Meta.Description.Length == 0 ) - { - return; - } - - if( !ImGui.BeginTabItem( LabelAboutTab ) ) - { - return; - } - - using var raii = ImGuiRaii.DeferredEnd( ImGui.EndTabItem ); - - var desc = Meta.Description; - var flags = _editMode - ? ImGuiInputTextFlags.EnterReturnsTrue | ImGuiInputTextFlags.CtrlEnterForNewLine - : ImGuiInputTextFlags.ReadOnly; - - if( _editMode ) - { - if( ImGui.InputTextMultiline( LabelDescEdit, ref desc, 1 << 16, - AutoFillSize, flags ) ) - { - Meta.Description = desc; - _selector.SaveCurrentMod(); - } - - ImGuiCustom.HoverTooltip( TooltipAboutEdit ); - } - else - { - ImGui.TextWrapped( desc ); - } - } - - private void DrawChangedItemsTab() - { - if( Mod.Data.ChangedItems.Count == 0 || !ImGui.BeginTabItem( LabelChangedItemsTab ) ) - { - return; - } - - using var raii = ImGuiRaii.DeferredEnd( ImGui.EndTabItem ); - - if( !ImGui.BeginListBox( LabelChangedItemsHeader, AutoFillSize ) ) - { - return; - } - - raii.Push( ImGui.EndListBox ); - foreach( var (name, data) in Mod.Data.ChangedItems ) - { - var ret = ImGui.Selectable( name ) ? MouseButton.Left : MouseButton.None; - ret = ImGui.IsItemClicked( ImGuiMouseButton.Right ) ? MouseButton.Right : ret; - ret = ImGui.IsItemClicked( ImGuiMouseButton.Middle ) ? MouseButton.Middle : ret; - - if( ret != MouseButton.None ) - { - _base._penumbra.Api.InvokeClick( ret, data ); - } - - if( _base._penumbra.Api.HasTooltip && ImGui.IsItemHovered() ) - { - ImGui.BeginTooltip(); - raii.Push( ImGui.EndTooltip ); - _base._penumbra.Api.InvokeTooltip( data ); - raii.Pop(); - } - } - } - - private void DrawConflictTab() - { - if( !Mod.Cache.Conflicts.Any() || !ImGui.BeginTabItem( LabelConflictsTab ) ) - { - return; - } - - using var raii = ImGuiRaii.DeferredEnd( ImGui.EndTabItem ); - - ImGui.SetNextItemWidth( -1 ); - if( !ImGui.BeginListBox( LabelConflictsHeader, AutoFillSize ) ) - { - return; - } - - raii.Push( ImGui.EndListBox ); - using var indent = ImGuiRaii.PushIndent( 0 ); - foreach( var (mod, (files, manipulations)) in Mod.Cache.Conflicts ) - { - if( ImGui.Selectable( mod.Data.Meta.Name ) ) - { - _selector.SelectModByDir( mod.Data.BasePath.Name ); - } - - ImGui.SameLine(); - ImGui.Text( $"(Priority {mod.Settings.Priority})" ); - - indent.Push( 15f ); - foreach( var file in files ) - { - ImGui.Selectable( file ); - } - - foreach( var manip in manipulations ) - { - ImGui.Text( manip.IdentifierString() ); - } - - indent.Pop( 15f ); - } - } - - private void DrawFileSwapTab() - { - if( _editMode ) - { - DrawFileSwapTabEdit(); - return; - } - - if( !Meta.FileSwaps.Any() || !ImGui.BeginTabItem( LabelFileSwapTab ) ) - { - return; - } - - using var raii = ImGuiRaii.DeferredEnd( ImGui.EndTabItem ); - - const ImGuiTableFlags flags = ImGuiTableFlags.SizingFixedFit | ImGuiTableFlags.RowBg | ImGuiTableFlags.ScrollX; - - ImGui.SetNextItemWidth( -1 ); - if( !ImGui.BeginTable( LabelFileSwapHeader, 3, flags, AutoFillSize ) ) - { - return; - } - - raii.Push( ImGui.EndTable ); - - foreach( var (source, target) in Meta.FileSwaps ) - { - ImGui.TableNextColumn(); - ImGuiCustom.CopyOnClickSelectable( source ); - - ImGui.TableNextColumn(); - ImGuiCustom.PrintIcon( FontAwesomeIcon.LongArrowAltRight ); - - ImGui.TableNextColumn(); - ImGuiCustom.CopyOnClickSelectable( target ); - - ImGui.TableNextRow(); - } - } - - private void UpdateFilenameList() - { - if( _fullFilenameList != null ) - { - return; - } - - _fullFilenameList = Mod.Data.Resources.ModFiles - .Select( f => ( f, false, ColorGreen, new RelPath( f, Mod.Data.BasePath ) ) ).ToArray(); - - if( Meta.Groups.Count == 0 ) - { - return; - } - - for( var i = 0; i < Mod.Data.Resources.ModFiles.Count; ++i ) - { - foreach( var group in Meta.Groups.Values ) - { - var inAll = true; - foreach( var option in group.Options ) - { - if( option.OptionFiles.ContainsKey( _fullFilenameList[ i ].relName ) ) - { - _fullFilenameList[ i ].color = ColorYellow; - } - else - { - inAll = false; - } - } - - if( inAll && group.SelectionType == SelectType.Single ) - { - _fullFilenameList[ i ].color = ColorGreen; - } - } - } - } - - private void DrawFileListTab() - { - if( !ImGui.BeginTabItem( LabelFileListTab ) ) - { - return; - } - - using var raii = ImGuiRaii.DeferredEnd( ImGui.EndTabItem ); - ImGuiCustom.HoverTooltip( TooltipFilesTab ); - - ImGui.SetNextItemWidth( -1 ); - if( ImGui.BeginListBox( LabelFileListHeader, AutoFillSize ) ) - { - raii.Push( ImGui.EndListBox ); - UpdateFilenameList(); - using var colorRaii = new ImGuiRaii.Color(); - foreach( var (name, _, color, _) in _fullFilenameList! ) - { - colorRaii.Push( ImGuiCol.Text, color ); - ImGui.Selectable( name.FullName ); - colorRaii.Pop(); - } - } - else - { - _fullFilenameList = null; - } - } - - private static int HandleDefaultString( GamePath[] gamePaths, out int removeFolders ) - { - removeFolders = 0; - var defaultIndex = - gamePaths.IndexOf( p => ( ( string )p ).StartsWith( TextDefaultGamePath ) ); - if( defaultIndex < 0 ) - { - return defaultIndex; - } - - string path = gamePaths[ defaultIndex ]; - if( path.Length == TextDefaultGamePath.Length ) - { - return defaultIndex; - } - - if( path[ TextDefaultGamePath.Length ] != '-' - || !int.TryParse( path.Substring( TextDefaultGamePath.Length + 1 ), out removeFolders ) ) - { - return -1; - } - - return defaultIndex; - } - - private void HandleSelectedFilesButton( bool remove ) - { - if( _selectedOption == null ) - { - return; - } - - var option = ( Option )_selectedOption; - - var gamePaths = _currentGamePaths.Split( ';' ).Select( p => new GamePath( p ) ).ToArray(); - if( gamePaths.Length == 0 || ( ( string )gamePaths[ 0 ] ).Length == 0 ) - { - return; - } - - var defaultIndex = HandleDefaultString( gamePaths, out var removeFolders ); - var changed = false; - for( var i = 0; i < Mod.Data.Resources.ModFiles.Count; ++i ) - { - if( !_fullFilenameList![ i ].selected ) - { - continue; - } - - var relName = _fullFilenameList[ i ].relName; - if( defaultIndex >= 0 ) - { - gamePaths[ defaultIndex ] = relName.ToGamePath( removeFolders ); - } - - if( remove && option.OptionFiles.TryGetValue( relName, out var setPaths ) ) - { - if( setPaths.RemoveWhere( p => gamePaths.Contains( p ) ) > 0 ) - { - changed = true; - } - - if( setPaths.Count == 0 && option.OptionFiles.Remove( relName ) ) - { - changed = true; - } - } - else - { - changed = gamePaths - .Aggregate( changed, ( current, gamePath ) => current | option.AddFile( relName, gamePath ) ); - } - } - - if( changed ) - { - _selector.SaveCurrentMod(); - // Since files may have changed, we need to recompute effective files. - foreach( var collection in _modManager.Collections.Collections.Values - .Where( c => c.Cache != null && c.Settings[ Mod!.Data.BasePath.Name ].Enabled ) ) - { - collection.CalculateEffectiveFileList( _modManager.TempPath, false, - collection == _modManager.Collections.ActiveCollection ); - } - - // If the mod is enabled in the current collection, its conflicts may have changed. - if( Mod!.Settings.Enabled ) - { - _selector.Cache.TriggerFilterReset(); - } - } - } - - private void DrawAddToGroupButton() - { - if( ImGui.Button( ButtonAddToGroup ) ) - { - HandleSelectedFilesButton( false ); - } - } - - private void DrawRemoveFromGroupButton() - { - if( ImGui.Button( ButtonRemoveFromGroup ) ) - { - HandleSelectedFilesButton( true ); - } - } - - private void DrawGamePathInput() - { - ImGui.SetNextItemWidth( -1 ); - ImGui.InputTextWithHint( LabelGamePathsEditBox, "Hover for help...", ref _currentGamePaths, - 128 ); - ImGuiCustom.HoverTooltip( TooltipGamePathsEdit ); - } - - private void DrawGroupRow() - { - if( _selectedGroup == null ) - { - SelectGroup(); - } - - if( _selectedOption == null ) - { - SelectOption(); - } - - if( !DrawEditGroupSelector() ) - { - return; - } - - ImGui.SameLine(); - if( !DrawEditOptionSelector() ) - { - return; - } - - ImGui.SameLine(); - DrawAddToGroupButton(); - ImGui.SameLine(); - DrawRemoveFromGroupButton(); - ImGui.SameLine(); - DrawGamePathInput(); - } - - private void DrawFileAndGamePaths( int idx ) - { - void Selectable( uint colorNormal, uint colorReplace ) - { - var loc = _fullFilenameList![ idx ].color; - if( loc == colorNormal ) - { - loc = colorReplace; - } - - using var colors = ImGuiRaii.PushColor( ImGuiCol.Text, loc ); - ImGui.Selectable( _fullFilenameList[ idx ].name.FullName, ref _fullFilenameList[ idx ].selected ); - } - - const float indentWidth = 30f; - if( _selectedOption == null ) - { - Selectable( 0, ColorGreen ); - return; - } - - var fileName = _fullFilenameList![ idx ].relName; - var optionFiles = ( ( Option )_selectedOption ).OptionFiles; - if( optionFiles.TryGetValue( fileName, out var gamePaths ) ) - { - Selectable( 0, ColorGreen ); - - using var indent = ImGuiRaii.PushIndent( indentWidth ); - var tmpPaths = gamePaths.ToArray(); - foreach( var gamePath in tmpPaths ) - { - string tmp = gamePath; - if( ImGui.InputText( $"##{fileName}_{gamePath}", ref tmp, 128, ImGuiInputTextFlags.EnterReturnsTrue ) - && tmp != gamePath ) - { - gamePaths.Remove( gamePath ); - if( tmp.Length > 0 ) - { - gamePaths.Add( new GamePath( tmp ) ); - } - else if( gamePaths.Count == 0 ) - { - optionFiles.Remove( fileName ); - } - - _selector.SaveCurrentMod(); - _selector.ReloadCurrentMod(); - } - } - } - else - { - Selectable( ColorYellow, ColorRed ); - } - } - - private void DrawMultiSelectorCheckBox( OptionGroup group, int idx, int flag, string label ) - { - var enabled = ( flag & ( 1 << idx ) ) != 0; - var oldEnabled = enabled; - if( ImGui.Checkbox( label, ref enabled ) && oldEnabled != enabled ) - { - Mod.Settings.Settings[ group.GroupName ] ^= 1 << idx; - Save(); - // If the mod is enabled, recalculate files and filters. - if( Mod.Settings.Enabled ) - { - _base.RecalculateCurrent( Mod.Data.Resources.MetaManipulations.Count > 0 ); - } - } - } - - private void DrawMultiSelector( OptionGroup group ) - { - if( group.Options.Count == 0 ) - { - return; - } - - ImGuiCustom.BeginFramedGroup( group.GroupName ); - using var raii = ImGuiRaii.DeferredEnd( ImGuiCustom.EndFramedGroup ); - for( var i = 0; i < group.Options.Count; ++i ) - { - DrawMultiSelectorCheckBox( group, i, Mod.Settings.Settings[ group.GroupName ], - $"{group.Options[ i ].OptionName}##{group.GroupName}" ); - } - } - - private void DrawSingleSelector( OptionGroup group ) - { - if( group.Options.Count < 2 ) - { - return; - } - - var code = Mod.Settings.Settings[ group.GroupName ]; - if( ImGui.Combo( group.GroupName, ref code - , group.Options.Select( x => x.OptionName ).ToArray(), group.Options.Count ) - && code != Mod.Settings.Settings[ group.GroupName ] ) - { - Mod.Settings.Settings[ group.GroupName ] = code; - Save(); - // If the mod is enabled, recalculate files and filters. - if( Mod.Settings.Enabled ) - { - _base.RecalculateCurrent( Mod.Data.Resources.MetaManipulations.Count > 0 ); - } - } - } - - private void DrawGroupSelectors() - { - foreach( var g in Meta.Groups.Values.Where( g => g.SelectionType == SelectType.Single ) ) - { - DrawSingleSelector( g ); - } - - foreach( var g in Meta.Groups.Values.Where( g => g.SelectionType == SelectType.Multi ) ) - { - DrawMultiSelector( g ); - } - } - - private void DrawConfigurationTab() - { - if( !_editMode && !Meta.HasGroupsWithConfig || !ImGui.BeginTabItem( LabelConfigurationTab ) ) - { - return; - } - - using var raii = ImGuiRaii.DeferredEnd( ImGui.EndTabItem ); - if( _editMode ) - { - DrawGroupSelectorsEdit(); - } - else - { - DrawGroupSelectors(); - } - } - - private void DrawMetaManipulationsTab() - { - if( !_editMode && Mod.Data.Resources.MetaManipulations.Count == 0 || !ImGui.BeginTabItem( "Meta Manipulations" ) ) - { - return; - } - - using var raii = ImGuiRaii.DeferredEnd( ImGui.EndTabItem ); - - if( !ImGui.BeginListBox( "##MetaManipulations", AutoFillSize ) ) - { - return; - } - - raii.Push( ImGui.EndListBox ); - - var manips = Mod.Data.Resources.MetaManipulations; - var changes = false; - if( _editMode || manips.DefaultData.Count > 0 ) - { - if( ImGui.CollapsingHeader( "Default" ) ) - { - changes = DrawMetaManipulationsTable( "##DefaultManips", manips.DefaultData, ref manips.Count ); - } - } - - foreach( var (groupName, group) in manips.GroupData ) - { - foreach( var (optionName, option) in group ) - { - if( ImGui.CollapsingHeader( $"{groupName} - {optionName}" ) ) - { - changes |= DrawMetaManipulationsTable( $"##{groupName}{optionName}manips", option, ref manips.Count ); - } - } - } - - if( changes ) - { - Mod.Data.Resources.MetaManipulations.SaveToFile( MetaCollection.FileName( Mod.Data.BasePath ) ); - Mod.Data.Resources.SetManipulations( Meta, Mod.Data.BasePath, false ); - _selector.ReloadCurrentMod( true, false ); - } - } - - public void Draw( bool editMode ) - { - _editMode = editMode; - if( !ImGui.BeginTabBar( LabelPluginDetails ) ) - { - return; - } - - using var raii = ImGuiRaii.DeferredEnd( ImGui.EndTabBar ); - DrawAboutTab(); - DrawChangedItemsTab(); - - DrawConfigurationTab(); - if( _editMode ) - { - DrawFileListTabEdit(); - } - else - { - DrawFileListTab(); - } - - DrawFileSwapTab(); - DrawMetaManipulationsTab(); - DrawConflictTab(); - } - } - } -} \ No newline at end of file diff --git a/Penumbra/UI/MenuTabs/TabInstalled/TabInstalledDetailsEdit.cs b/Penumbra/UI/MenuTabs/TabInstalled/TabInstalledDetailsEdit.cs deleted file mode 100644 index 8248e91b..00000000 --- a/Penumbra/UI/MenuTabs/TabInstalled/TabInstalledDetailsEdit.cs +++ /dev/null @@ -1,386 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using System.Numerics; -using Dalamud.Interface; -using ImGuiNET; -using Penumbra.GameData.Util; -using Penumbra.Mods; -using Penumbra.Structs; -using Penumbra.UI.Custom; -using Penumbra.Util; - -namespace Penumbra.UI -{ - public partial class SettingsInterface - { - private partial class PluginDetails - { - private const string LabelDescEdit = "##descedit"; - private const string LabelNewSingleGroupEdit = "##newSingleGroup"; - private const string LabelNewMultiGroup = "##newMultiGroup"; - private const string LabelGamePathsEditBox = "##gamePathsEdit"; - private const string ButtonAddToGroup = "Add to Group"; - private const string ButtonRemoveFromGroup = "Remove from Group"; - private const string TooltipAboutEdit = "Use Ctrl+Enter for newlines."; - private const string TextNoOptionAvailable = "[Not Available]"; - private const string TextDefaultGamePath = "default"; - private const char GamePathsSeparator = ';'; - - private static readonly string TooltipFilesTabEdit = - $"{TooltipFilesTab}\n" - + $"Red Files are replaced in another group or a different option in this group, but not contained in the current option."; - - private static readonly string TooltipGamePathsEdit = - $"Enter all game paths to add or remove, separated by '{GamePathsSeparator}'.\n" - + $"Use '{TextDefaultGamePath}' to add the original file path." - + $"Use '{TextDefaultGamePath}-#' to skip the first # relative directories."; - - private const float MultiEditBoxWidth = 300f; - - private bool DrawEditGroupSelector() - { - ImGui.SetNextItemWidth( OptionSelectionWidth * ImGuiHelpers.GlobalScale ); - if( Meta!.Groups.Count == 0 ) - { - ImGui.Combo( LabelGroupSelect, ref _selectedGroupIndex, TextNoOptionAvailable, 1 ); - return false; - } - - if( ImGui.Combo( LabelGroupSelect, ref _selectedGroupIndex - , Meta.Groups.Values.Select( g => g.GroupName ).ToArray() - , Meta.Groups.Count ) ) - { - SelectGroup(); - SelectOption( 0 ); - } - - return true; - } - - private bool DrawEditOptionSelector() - { - ImGui.SameLine(); - ImGui.SetNextItemWidth( OptionSelectionWidth ); - if( ( _selectedGroup?.Options.Count ?? 0 ) == 0 ) - { - ImGui.Combo( LabelOptionSelect, ref _selectedOptionIndex, TextNoOptionAvailable, 1 ); - return false; - } - - var group = ( OptionGroup )_selectedGroup!; - if( ImGui.Combo( LabelOptionSelect, ref _selectedOptionIndex, group.Options.Select( o => o.OptionName ).ToArray(), - group.Options.Count ) ) - { - SelectOption(); - } - - return true; - } - - private void DrawFileListTabEdit() - { - if( ImGui.BeginTabItem( LabelFileListTab ) ) - { - UpdateFilenameList(); - if( ImGui.IsItemHovered() ) - { - ImGui.SetTooltip( _editMode ? TooltipFilesTabEdit : TooltipFilesTab ); - } - - ImGui.SetNextItemWidth( -1 ); - if( ImGui.BeginListBox( LabelFileListHeader, AutoFillSize - Vector2.UnitY * 1.5f * ImGui.GetTextLineHeight() ) ) - { - for( var i = 0; i < Mod!.Data.Resources.ModFiles.Count; ++i ) - { - DrawFileAndGamePaths( i ); - } - } - - ImGui.EndListBox(); - - DrawGroupRow(); - ImGui.EndTabItem(); - } - else - { - _fullFilenameList = null; - } - } - - private ImGuiRaii.EndStack DrawMultiSelectorEditBegin( OptionGroup group ) - { - var groupName = group.GroupName; - if( ImGuiCustom.BeginFramedGroupEdit( ref groupName ) ) - { - if( _modManager.ChangeModGroup( group.GroupName, groupName, Mod.Data ) && Mod.Data.Meta.RefreshHasGroupsWithConfig() ) - { - _selector.Cache.TriggerFilterReset(); - } - } - - return ImGuiRaii.DeferredEnd( ImGuiCustom.EndFramedGroup ); - } - - private void DrawMultiSelectorEditAdd( OptionGroup group, float nameBoxStart ) - { - var newOption = ""; - ImGui.SetCursorPosX( nameBoxStart ); - ImGui.SetNextItemWidth( MultiEditBoxWidth * ImGuiHelpers.GlobalScale ); - if( ImGui.InputTextWithHint( $"##new_{group.GroupName}_l", "Add new option...", ref newOption, 64, - ImGuiInputTextFlags.EnterReturnsTrue ) - && newOption.Length != 0 ) - { - group.Options.Add( new Option() - { OptionName = newOption, OptionDesc = "", OptionFiles = new Dictionary< RelPath, HashSet< GamePath > >() } ); - _selector.SaveCurrentMod(); - if( Mod!.Data.Meta.RefreshHasGroupsWithConfig() ) - { - _selector.Cache.TriggerFilterReset(); - } - } - } - - private void DrawMultiSelectorEdit( OptionGroup group ) - { - var nameBoxStart = CheckMarkSize; - var flag = Mod!.Settings.Settings[ group.GroupName ]; - - using var raii = DrawMultiSelectorEditBegin( group ); - for( var i = 0; i < group.Options.Count; ++i ) - { - var opt = group.Options[ i ]; - var label = $"##{group.GroupName}_{i}"; - DrawMultiSelectorCheckBox( group, i, flag, label ); - - ImGui.SameLine(); - var newName = opt.OptionName; - - if( nameBoxStart == CheckMarkSize ) - { - nameBoxStart = ImGui.GetCursorPosX(); - } - - ImGui.SetNextItemWidth( MultiEditBoxWidth * ImGuiHelpers.GlobalScale ); - if( ImGui.InputText( $"{label}_l", ref newName, 64, ImGuiInputTextFlags.EnterReturnsTrue ) ) - { - if( newName.Length == 0 ) - { - _modManager.RemoveModOption( i, group, Mod.Data ); - } - else if( newName != opt.OptionName ) - { - group.Options[ i ] = new Option() - { OptionName = newName, OptionDesc = opt.OptionDesc, OptionFiles = opt.OptionFiles }; - _selector.SaveCurrentMod(); - } - - if( Mod!.Data.Meta.RefreshHasGroupsWithConfig() ) - { - _selector.Cache.TriggerFilterReset(); - } - } - } - - DrawMultiSelectorEditAdd( group, nameBoxStart ); - } - - private void DrawSingleSelectorEditGroup( OptionGroup group ) - { - var groupName = group.GroupName; - if( ImGui.InputText( $"##{groupName}_add", ref groupName, 64, ImGuiInputTextFlags.EnterReturnsTrue ) ) - { - if( _modManager.ChangeModGroup( group.GroupName, groupName, Mod.Data ) && Mod.Data.Meta.RefreshHasGroupsWithConfig() ) - { - _selector.Cache.TriggerFilterReset(); - } - } - } - - private float DrawSingleSelectorEdit( OptionGroup group ) - { - var oldSetting = Mod!.Settings.Settings[ group.GroupName ]; - var code = oldSetting; - if( ImGuiCustom.RenameableCombo( $"##{group.GroupName}", ref code, out var newName, - group.Options.Select( x => x.OptionName ).ToArray(), group.Options.Count ) ) - { - if( code == group.Options.Count ) - { - if( newName.Length > 0 ) - { - Mod.Settings.Settings[ group.GroupName ] = code; - group.Options.Add( new Option() - { - OptionName = newName, - OptionDesc = "", - OptionFiles = new Dictionary< RelPath, HashSet< GamePath > >(), - } ); - _selector.SaveCurrentMod(); - } - } - else - { - if( newName.Length == 0 ) - { - _modManager.RemoveModOption( code, group, Mod.Data ); - } - else - { - if( newName != group.Options[ code ].OptionName ) - { - group.Options[ code ] = new Option() - { - OptionName = newName, OptionDesc = group.Options[ code ].OptionDesc, - OptionFiles = group.Options[ code ].OptionFiles, - }; - _selector.SaveCurrentMod(); - } - } - } - - if( Mod.Data.Meta.RefreshHasGroupsWithConfig() ) - { - _selector.Cache.TriggerFilterReset(); - } - } - - if( code != oldSetting ) - { - Save(); - } - - ImGui.SameLine(); - var labelEditPos = ImGui.GetCursorPosX(); - DrawSingleSelectorEditGroup( group ); - - return labelEditPos; - } - - private void DrawAddSingleGroupField( float labelEditPos ) - { - var newGroup = ""; - ImGui.SetCursorPosX( labelEditPos ); - if( labelEditPos == CheckMarkSize ) - { - ImGui.SetNextItemWidth( MultiEditBoxWidth * ImGuiHelpers.GlobalScale ); - } - - if( ImGui.InputTextWithHint( LabelNewSingleGroupEdit, "Add new Single Group...", ref newGroup, 64, - ImGuiInputTextFlags.EnterReturnsTrue ) ) - { - _modManager.ChangeModGroup( "", newGroup, Mod.Data, SelectType.Single ); - // Adds empty group, so can not change filters. - } - } - - private void DrawAddMultiGroupField() - { - var newGroup = ""; - ImGui.SetCursorPosX( CheckMarkSize ); - ImGui.SetNextItemWidth( MultiEditBoxWidth * ImGuiHelpers.GlobalScale ); - if( ImGui.InputTextWithHint( LabelNewMultiGroup, "Add new Multi Group...", ref newGroup, 64, - ImGuiInputTextFlags.EnterReturnsTrue ) ) - { - _modManager.ChangeModGroup( "", newGroup, Mod.Data, SelectType.Multi ); - // Adds empty group, so can not change filters. - } - } - - private void DrawGroupSelectorsEdit() - { - var labelEditPos = CheckMarkSize; - var groups = Meta.Groups.Values.ToArray(); - foreach( var g in groups.Where( g => g.SelectionType == SelectType.Single ) ) - { - labelEditPos = DrawSingleSelectorEdit( g ); - } - - DrawAddSingleGroupField( labelEditPos ); - - foreach( var g in groups.Where( g => g.SelectionType == SelectType.Multi ) ) - { - DrawMultiSelectorEdit( g ); - } - - DrawAddMultiGroupField(); - } - - private void DrawFileSwapTabEdit() - { - if( !ImGui.BeginTabItem( LabelFileSwapTab ) ) - { - return; - } - - using var raii = ImGuiRaii.DeferredEnd( ImGui.EndTabItem ); - - ImGui.SetNextItemWidth( -1 ); - if( !ImGui.BeginListBox( LabelFileSwapHeader, AutoFillSize ) ) - { - return; - } - - raii.Push( ImGui.EndListBox ); - - var swaps = Meta.FileSwaps.Keys.ToArray(); - - ImGui.PushFont( UiBuilder.IconFont ); - var arrowWidth = ImGui.CalcTextSize( FontAwesomeIcon.LongArrowAltRight.ToIconString() ).X; - ImGui.PopFont(); - - var width = ( ImGui.GetWindowWidth() - arrowWidth - 4 * ImGui.GetStyle().ItemSpacing.X ) / 2; - for( var idx = 0; idx < swaps.Length + 1; ++idx ) - { - var key = idx == swaps.Length ? GamePath.GenerateUnchecked( "" ) : swaps[ idx ]; - var value = idx == swaps.Length ? GamePath.GenerateUnchecked( "" ) : Meta.FileSwaps[ key ]; - string keyString = key; - string valueString = value; - - ImGui.SetNextItemWidth( width ); - if( ImGui.InputTextWithHint( $"##swapLhs_{idx}", "Enter new file to be replaced...", ref keyString, - GamePath.MaxGamePathLength, ImGuiInputTextFlags.EnterReturnsTrue ) ) - { - var newKey = new GamePath( keyString ); - if( newKey.CompareTo( key ) != 0 ) - { - if( idx < swaps.Length ) - { - Meta.FileSwaps.Remove( key ); - } - - if( newKey != string.Empty ) - { - Meta.FileSwaps[ newKey ] = value; - } - - _selector.SaveCurrentMod(); - _selector.ReloadCurrentMod(); - } - } - - if( idx >= swaps.Length ) - { - continue; - } - - ImGui.SameLine(); - ImGuiCustom.PrintIcon( FontAwesomeIcon.LongArrowAltRight ); - ImGui.SameLine(); - - ImGui.SetNextItemWidth( width ); - if( ImGui.InputTextWithHint( $"##swapRhs_{idx}", "Enter new replacement path...", ref valueString, - GamePath.MaxGamePathLength, - ImGuiInputTextFlags.EnterReturnsTrue ) ) - { - var newValue = new GamePath( valueString ); - if( newValue.CompareTo( value ) != 0 ) - { - Meta.FileSwaps[ key ] = newValue; - _selector.SaveCurrentMod(); - _selector.Cache.TriggerListReset(); - } - } - } - } - } - } -} \ No newline at end of file diff --git a/Penumbra/UI/MenuTabs/TabInstalled/TabInstalledDetailsManipulations.cs b/Penumbra/UI/MenuTabs/TabInstalled/TabInstalledDetailsManipulations.cs deleted file mode 100644 index 1766286f..00000000 --- a/Penumbra/UI/MenuTabs/TabInstalled/TabInstalledDetailsManipulations.cs +++ /dev/null @@ -1,780 +0,0 @@ -using System; -using System.Collections.Generic; -using System.ComponentModel; -using System.Linq; -using System.Numerics; -using Dalamud.Interface; -using ImGuiNET; -using Lumina.Data.Files; -using Penumbra.GameData.Enums; -using Penumbra.GameData.Structs; -using Penumbra.Meta; -using Penumbra.Meta.Files; -using Penumbra.UI.Custom; -using Penumbra.Util; -using ObjectType = Penumbra.GameData.Enums.ObjectType; - -namespace Penumbra.UI -{ - public partial class SettingsInterface - { - private partial class PluginDetails - { - private int _newManipTypeIdx = 0; - private ushort _newManipSetId = 0; - private ushort _newManipSecondaryId = 0; - private int _newManipSubrace = 0; - private int _newManipRace = 0; - private int _newManipAttribute = 0; - private int _newManipEquipSlot = 0; - private int _newManipObjectType = 0; - private int _newManipGender = 0; - private int _newManipBodySlot = 0; - private ushort _newManipVariant = 0; - - - private static readonly (string, EquipSlot)[] EqpEquipSlots = - { - ( "Head", EquipSlot.Head ), - ( "Body", EquipSlot.Body ), - ( "Hands", EquipSlot.Hands ), - ( "Legs", EquipSlot.Legs ), - ( "Feet", EquipSlot.Feet ), - }; - - private static readonly (string, EquipSlot)[] EqdpEquipSlots = - { - EqpEquipSlots[ 0 ], - EqpEquipSlots[ 1 ], - EqpEquipSlots[ 2 ], - EqpEquipSlots[ 3 ], - EqpEquipSlots[ 4 ], - ( "Ears", EquipSlot.Ears ), - ( "Neck", EquipSlot.Neck ), - ( "Wrist", EquipSlot.Wrists ), - ( "Left Finger", EquipSlot.LFinger ), - ( "Right Finger", EquipSlot.RFinger ), - }; - - private static readonly (string, ModelRace)[] Races = - { - ( ModelRace.Midlander.ToName(), ModelRace.Midlander ), - ( ModelRace.Highlander.ToName(), ModelRace.Highlander ), - ( ModelRace.Elezen.ToName(), ModelRace.Elezen ), - ( ModelRace.Miqote.ToName(), ModelRace.Miqote ), - ( ModelRace.Roegadyn.ToName(), ModelRace.Roegadyn ), - ( ModelRace.Lalafell.ToName(), ModelRace.Lalafell ), - ( ModelRace.AuRa.ToName(), ModelRace.AuRa ), - ( ModelRace.Viera.ToName(), ModelRace.Viera ), - ( ModelRace.Hrothgar.ToName(), ModelRace.Hrothgar ), - }; - - private static readonly (string, Gender)[] Genders = - { - ( Gender.Male.ToName(), Gender.Male ), - ( Gender.Female.ToName(), Gender.Female ), - ( Gender.MaleNpc.ToName(), Gender.MaleNpc ), - ( Gender.FemaleNpc.ToName(), Gender.FemaleNpc ), - }; - - private static readonly (string, ObjectType)[] ObjectTypes = - { - ( "Equipment", ObjectType.Equipment ), - ( "Customization", ObjectType.Character ), - }; - - private static readonly (string, EquipSlot)[] EstEquipSlots = - { - EqpEquipSlots[ 0 ], - EqpEquipSlots[ 1 ], - }; - - private static readonly (string, BodySlot)[] EstBodySlots = - { - ( "Hair", BodySlot.Hair ), - ( "Face", BodySlot.Face ), - }; - - private static readonly (string, SubRace)[] Subraces = - { - ( SubRace.Midlander.ToName(), SubRace.Midlander ), - ( SubRace.Highlander.ToName(), SubRace.Highlander ), - ( SubRace.Wildwood.ToName(), SubRace.Wildwood ), - ( SubRace.Duskwight.ToName(), SubRace.Duskwight ), - ( SubRace.SeekerOfTheSun.ToName(), SubRace.SeekerOfTheSun ), - ( SubRace.KeeperOfTheMoon.ToName(), SubRace.KeeperOfTheMoon ), - ( SubRace.Seawolf.ToName(), SubRace.Seawolf ), - ( SubRace.Hellsguard.ToName(), SubRace.Hellsguard ), - ( SubRace.Plainsfolk.ToName(), SubRace.Plainsfolk ), - ( SubRace.Dunesfolk.ToName(), SubRace.Dunesfolk ), - ( SubRace.Raen.ToName(), SubRace.Raen ), - ( SubRace.Xaela.ToName(), SubRace.Xaela ), - ( SubRace.Rava.ToName(), SubRace.Rava ), - ( SubRace.Veena.ToName(), SubRace.Veena ), - ( SubRace.Helion.ToName(), SubRace.Helion ), - ( SubRace.Lost.ToName(), SubRace.Lost ), - }; - - private static readonly (string, RspAttribute)[] RspAttributes = - { - ( RspAttribute.MaleMinSize.ToFullString(), RspAttribute.MaleMinSize ), - ( RspAttribute.MaleMaxSize.ToFullString(), RspAttribute.MaleMaxSize ), - ( RspAttribute.FemaleMinSize.ToFullString(), RspAttribute.FemaleMinSize ), - ( RspAttribute.FemaleMaxSize.ToFullString(), RspAttribute.FemaleMaxSize ), - ( RspAttribute.BustMinX.ToFullString(), RspAttribute.BustMinX ), - ( RspAttribute.BustMaxX.ToFullString(), RspAttribute.BustMaxX ), - ( RspAttribute.BustMinY.ToFullString(), RspAttribute.BustMinY ), - ( RspAttribute.BustMaxY.ToFullString(), RspAttribute.BustMaxY ), - ( RspAttribute.BustMinZ.ToFullString(), RspAttribute.BustMinZ ), - ( RspAttribute.BustMaxZ.ToFullString(), RspAttribute.BustMaxZ ), - ( RspAttribute.MaleMinTail.ToFullString(), RspAttribute.MaleMinTail ), - ( RspAttribute.MaleMaxTail.ToFullString(), RspAttribute.MaleMaxTail ), - ( RspAttribute.FemaleMinTail.ToFullString(), RspAttribute.FemaleMinTail ), - ( RspAttribute.FemaleMaxTail.ToFullString(), RspAttribute.FemaleMaxTail ), - }; - - private static readonly (string, ObjectType)[] ImcObjectType = - { - ObjectTypes[ 0 ], - ( "Weapon", ObjectType.Weapon ), - ( "Demihuman", ObjectType.DemiHuman ), - ( "Monster", ObjectType.Monster ), - }; - - private static readonly (string, BodySlot)[] ImcBodySlots = - { - EstBodySlots[ 0 ], - EstBodySlots[ 1 ], - ( "Body", BodySlot.Body ), - ( "Tail", BodySlot.Tail ), - ( "Ears", BodySlot.Zear ), - }; - - private static bool PrintCheckBox( string name, ref bool value, bool def ) - { - var color = value == def ? 0 : value ? ColorDarkGreen : ColorDarkRed; - if( color == 0 ) - { - return ImGui.Checkbox( name, ref value ); - } - - using var colorRaii = ImGuiRaii.PushColor( ImGuiCol.Text, color ); - var ret = ImGui.Checkbox( name, ref value ); - return ret; - } - - private bool RestrictedInputInt( string name, ref ushort value, ushort min, ushort max ) - { - int tmp = value; - if( ImGui.InputInt( name, ref tmp, 0, 0, _editMode ? ImGuiInputTextFlags.EnterReturnsTrue : ImGuiInputTextFlags.ReadOnly ) - && tmp != value - && tmp >= min - && tmp <= max ) - { - value = ( ushort )tmp; - return true; - } - - return false; - } - - private static bool DefaultButton< T >( string name, ref T value, T defaultValue ) where T : IComparable< T > - { - var compare = defaultValue.CompareTo( value ); - var color = compare < 0 ? ColorDarkGreen : - compare > 0 ? ColorDarkRed : ImGui.ColorConvertFloat4ToU32( ImGui.GetStyle().Colors[ ( int )ImGuiCol.Button ] ); - - using var colorRaii = ImGuiRaii.PushColor( ImGuiCol.Button, color ); - var ret = ImGui.Button( name, Vector2.UnitX * 120 ) && compare != 0; - ImGui.SameLine(); - return ret; - } - - private bool DrawInputWithDefault( string name, ref ushort value, ushort defaultValue, ushort max ) - => DefaultButton( $"{( _editMode ? "Set to " : "" )}Default: {defaultValue}##imc{name}", ref value, defaultValue ) - || RestrictedInputInt( name, ref value, 0, max ); - - private static bool CustomCombo< T >( string label, IList< (string, T) > namesAndValues, out T value, ref int idx ) - { - value = idx < namesAndValues.Count ? namesAndValues[ idx ].Item2 : default!; - - if( !ImGui.BeginCombo( label, idx < namesAndValues.Count ? namesAndValues[ idx ].Item1 : string.Empty ) ) - { - return false; - } - - using var raii = ImGuiRaii.DeferredEnd( ImGui.EndCombo ); - - for( var i = 0; i < namesAndValues.Count; ++i ) - { - if( !ImGui.Selectable( $"{namesAndValues[ i ].Item1}##{label}{i}", idx == i ) || idx == i ) - { - continue; - } - - idx = i; - value = namesAndValues[ i ].Item2; - return true; - } - - return false; - } - - private bool DrawEqpRow( int manipIdx, IList< MetaManipulation > list ) - { - var ret = false; - var id = list[ manipIdx ].EqpIdentifier; - var val = list[ manipIdx ].EqpValue; - - if( ImGui.BeginPopup( $"##MetaPopup{manipIdx}" ) ) - { - using var raii = ImGuiRaii.DeferredEnd( ImGui.EndPopup ); - var defaults = ( EqpEntry )Service< MetaDefaults >.Get().GetDefaultValue( list[ manipIdx ] )!; - var attributes = Eqp.EqpAttributes[ id.Slot ]; - - foreach( var flag in attributes ) - { - var name = flag.ToLocalName(); - var tmp = val.HasFlag( flag ); - if( PrintCheckBox( $"{name}##manip", ref tmp, defaults.HasFlag( flag ) ) && _editMode && tmp != val.HasFlag( flag ) ) - { - list[ manipIdx ] = MetaManipulation.Eqp( id.Slot, id.SetId, tmp ? val | flag : val & ~flag ); - ret = true; - } - } - } - - ImGui.Text( ObjectType.Equipment.ToString() ); - ImGui.TableNextColumn(); - ImGui.Text( id.SetId.ToString() ); - ImGui.TableNextColumn(); - ImGui.Text( id.Slot.ToString() ); - return ret; - } - - private bool DrawGmpRow( int manipIdx, IList< MetaManipulation > list ) - { - var defaults = ( GmpEntry )Service< MetaDefaults >.Get().GetDefaultValue( list[ manipIdx ] )!; - var ret = false; - var id = list[ manipIdx ].GmpIdentifier; - var val = list[ manipIdx ].GmpValue; - - if( ImGui.BeginPopup( $"##MetaPopup{manipIdx}" ) ) - { - using var raii = ImGuiRaii.DeferredEnd( ImGui.EndPopup ); - var enabled = val.Enabled; - var animated = val.Animated; - var rotationA = val.RotationA; - var rotationB = val.RotationB; - var rotationC = val.RotationC; - ushort unk = val.UnknownTotal; - - ret |= PrintCheckBox( "Visor Enabled##manip", ref enabled, defaults.Enabled ) && enabled != val.Enabled; - ret |= PrintCheckBox( "Visor Animated##manip", ref animated, defaults.Animated ); - ret |= DrawInputWithDefault( "Rotation A##manip", ref rotationA, defaults.RotationA, 0x3FF ); - ret |= DrawInputWithDefault( "Rotation B##manip", ref rotationB, defaults.RotationB, 0x3FF ); - ret |= DrawInputWithDefault( "Rotation C##manip", ref rotationC, defaults.RotationC, 0x3FF ); - ret |= DrawInputWithDefault( "Unknown Byte##manip", ref unk, defaults.UnknownTotal, 0xFF ); - - if( ret && _editMode ) - { - list[ manipIdx ] = MetaManipulation.Gmp( id.SetId, - new GmpEntry - { - Animated = animated, Enabled = enabled, UnknownTotal = ( byte )unk, - RotationA = rotationA, RotationB = rotationB, RotationC = rotationC, - } ); - } - } - - ImGui.Text( ObjectType.Equipment.ToString() ); - ImGui.TableNextColumn(); - ImGui.Text( id.SetId.ToString() ); - ImGui.TableNextColumn(); - ImGui.Text( EquipSlot.Head.ToString() ); - return ret; - } - - private static (bool, bool) GetEqdpBits( EquipSlot slot, EqdpEntry entry ) - { - return slot switch - { - EquipSlot.Head => ( entry.HasFlag( EqdpEntry.Head1 ), entry.HasFlag( EqdpEntry.Head2 ) ), - EquipSlot.Body => ( entry.HasFlag( EqdpEntry.Body1 ), entry.HasFlag( EqdpEntry.Body2 ) ), - EquipSlot.Hands => ( entry.HasFlag( EqdpEntry.Hands1 ), entry.HasFlag( EqdpEntry.Hands2 ) ), - EquipSlot.Legs => ( entry.HasFlag( EqdpEntry.Legs1 ), entry.HasFlag( EqdpEntry.Legs2 ) ), - EquipSlot.Feet => ( entry.HasFlag( EqdpEntry.Feet1 ), entry.HasFlag( EqdpEntry.Feet2 ) ), - EquipSlot.Neck => ( entry.HasFlag( EqdpEntry.Neck1 ), entry.HasFlag( EqdpEntry.Neck2 ) ), - EquipSlot.Ears => ( entry.HasFlag( EqdpEntry.Ears1 ), entry.HasFlag( EqdpEntry.Ears2 ) ), - EquipSlot.Wrists => ( entry.HasFlag( EqdpEntry.Wrists1 ), entry.HasFlag( EqdpEntry.Wrists2 ) ), - EquipSlot.RFinger => ( entry.HasFlag( EqdpEntry.RingR1 ), entry.HasFlag( EqdpEntry.RingR2 ) ), - EquipSlot.LFinger => ( entry.HasFlag( EqdpEntry.RingL1 ), entry.HasFlag( EqdpEntry.RingL2 ) ), - _ => ( false, false ), - }; - } - - private static EqdpEntry SetEqdpBits( EquipSlot slot, EqdpEntry value, bool bit1, bool bit2 ) - { - switch( slot ) - { - case EquipSlot.Head: - value = bit1 ? value | EqdpEntry.Head1 : value & ~EqdpEntry.Head1; - value = bit2 ? value | EqdpEntry.Head2 : value & ~EqdpEntry.Head2; - return value; - case EquipSlot.Body: - value = bit1 ? value | EqdpEntry.Body1 : value & ~EqdpEntry.Body1; - value = bit2 ? value | EqdpEntry.Body2 : value & ~EqdpEntry.Body2; - return value; - case EquipSlot.Hands: - value = bit1 ? value | EqdpEntry.Hands1 : value & ~EqdpEntry.Hands1; - value = bit2 ? value | EqdpEntry.Hands2 : value & ~EqdpEntry.Hands2; - return value; - case EquipSlot.Legs: - value = bit1 ? value | EqdpEntry.Legs1 : value & ~EqdpEntry.Legs1; - value = bit2 ? value | EqdpEntry.Legs2 : value & ~EqdpEntry.Legs2; - return value; - case EquipSlot.Feet: - value = bit1 ? value | EqdpEntry.Feet1 : value & ~EqdpEntry.Feet1; - value = bit2 ? value | EqdpEntry.Feet2 : value & ~EqdpEntry.Feet2; - return value; - case EquipSlot.Neck: - value = bit1 ? value | EqdpEntry.Neck1 : value & ~EqdpEntry.Neck1; - value = bit2 ? value | EqdpEntry.Neck2 : value & ~EqdpEntry.Neck2; - return value; - case EquipSlot.Ears: - value = bit1 ? value | EqdpEntry.Ears1 : value & ~EqdpEntry.Ears1; - value = bit2 ? value | EqdpEntry.Ears2 : value & ~EqdpEntry.Ears2; - return value; - case EquipSlot.Wrists: - value = bit1 ? value | EqdpEntry.Wrists1 : value & ~EqdpEntry.Wrists1; - value = bit2 ? value | EqdpEntry.Wrists2 : value & ~EqdpEntry.Wrists2; - return value; - case EquipSlot.RFinger: - value = bit1 ? value | EqdpEntry.RingR1 : value & ~EqdpEntry.RingR1; - value = bit2 ? value | EqdpEntry.RingR2 : value & ~EqdpEntry.RingR2; - return value; - case EquipSlot.LFinger: - value = bit1 ? value | EqdpEntry.RingL1 : value & ~EqdpEntry.RingL1; - value = bit2 ? value | EqdpEntry.RingL2 : value & ~EqdpEntry.RingL2; - return value; - } - - return value; - } - - private bool DrawEqdpRow( int manipIdx, IList< MetaManipulation > list ) - { - var defaults = ( EqdpEntry )Service< MetaDefaults >.Get().GetDefaultValue( list[ manipIdx ] )!; - var ret = false; - var id = list[ manipIdx ].EqdpIdentifier; - var val = list[ manipIdx ].EqdpValue; - - if( ImGui.BeginPopup( $"##MetaPopup{manipIdx}" ) ) - { - using var raii = ImGuiRaii.DeferredEnd( ImGui.EndPopup ); - var (bit1, bit2) = GetEqdpBits( id.Slot, val ); - var (defBit1, defBit2) = GetEqdpBits( id.Slot, defaults ); - - ret |= PrintCheckBox( "Bit 1##manip", ref bit1, defBit1 ); - ret |= PrintCheckBox( "Bit 2##manip", ref bit2, defBit2 ); - - if( ret && _editMode ) - { - list[ manipIdx ] = MetaManipulation.Eqdp( id.Slot, id.GenderRace, id.SetId, SetEqdpBits( id.Slot, val, bit1, bit2 ) ); - } - } - - ImGui.Text( id.Slot.IsAccessory() - ? ObjectType.Accessory.ToString() - : ObjectType.Equipment.ToString() ); - ImGui.TableNextColumn(); - ImGui.Text( id.SetId.ToString() ); - ImGui.TableNextColumn(); - ImGui.Text( id.Slot.ToString() ); - ImGui.TableNextColumn(); - var (gender, race) = id.GenderRace.Split(); - ImGui.Text( race.ToString() ); - ImGui.TableNextColumn(); - ImGui.Text( gender.ToString() ); - return ret; - } - - private bool DrawEstRow( int manipIdx, IList< MetaManipulation > list ) - { - var defaults = ( ushort )Service< MetaDefaults >.Get().GetDefaultValue( list[ manipIdx ] )!; - var ret = false; - var id = list[ manipIdx ].EstIdentifier; - var val = list[ manipIdx ].EstValue; - if( ImGui.BeginPopup( $"##MetaPopup{manipIdx}" ) ) - { - using var raii = ImGuiRaii.DeferredEnd( ImGui.EndPopup ); - if( DrawInputWithDefault( "No Idea what this does!##manip", ref val, defaults, ushort.MaxValue ) && _editMode ) - { - list[ manipIdx ] = new MetaManipulation( id.Value, val ); - ret = true; - } - } - - ImGui.Text( id.ObjectType.ToString() ); - ImGui.TableNextColumn(); - ImGui.Text( id.PrimaryId.ToString() ); - ImGui.TableNextColumn(); - ImGui.Text( id.ObjectType == ObjectType.Equipment - ? id.EquipSlot.ToString() - : id.BodySlot.ToString() ); - ImGui.TableNextColumn(); - var (gender, race) = id.GenderRace.Split(); - ImGui.Text( race.ToString() ); - ImGui.TableNextColumn(); - ImGui.Text( gender.ToString() ); - - return ret; - } - - private bool DrawImcRow( int manipIdx, IList< MetaManipulation > list ) - { - var defaults = ( ImcFile.ImageChangeData )Service< MetaDefaults >.Get().GetDefaultValue( list[ manipIdx ] )!; - var ret = false; - var id = list[ manipIdx ].ImcIdentifier; - var val = list[ manipIdx ].ImcValue; - - if( ImGui.BeginPopup( $"##MetaPopup{manipIdx}" ) ) - { - using var raii = ImGuiRaii.DeferredEnd( ImGui.EndPopup ); - ushort materialId = val.MaterialId; - ushort vfxId = val.VfxId; - ushort decalId = val.DecalId; - var soundId = ( ushort )( val.SoundId >> 10 ); - var attributeMask = val.AttributeMask; - var materialAnimationId = ( ushort )( val.MaterialAnimationId >> 12 ); - ret |= DrawInputWithDefault( "Material Id", ref materialId, defaults.MaterialId, byte.MaxValue ); - ret |= DrawInputWithDefault( "Vfx Id", ref vfxId, defaults.VfxId, byte.MaxValue ); - ret |= DrawInputWithDefault( "Decal Id", ref decalId, defaults.DecalId, byte.MaxValue ); - ret |= DrawInputWithDefault( "Sound Id", ref soundId, defaults.SoundId, 0x3F ); - ret |= DrawInputWithDefault( "Attribute Mask", ref attributeMask, defaults.AttributeMask, 0x3FF ); - ret |= DrawInputWithDefault( "Material Animation Id", ref materialAnimationId, defaults.MaterialAnimationId, - byte.MaxValue ); - - if( ret && _editMode ) - { - var value = ImcExtensions.FromValues( ( byte )materialId, ( byte )decalId, attributeMask, ( byte )soundId, - ( byte )vfxId, ( byte )materialAnimationId ); - list[ manipIdx ] = new MetaManipulation( id.Value, value.ToInteger() ); - } - } - - ImGui.Text( id.ObjectType.ToString() ); - ImGui.TableNextColumn(); - ImGui.Text( id.PrimaryId.ToString() ); - ImGui.TableNextColumn(); - if( id.ObjectType == ObjectType.Accessory - || id.ObjectType == ObjectType.Equipment ) - { - ImGui.Text( id.ObjectType == ObjectType.Equipment - || id.ObjectType == ObjectType.Accessory - ? id.EquipSlot.ToString() - : id.BodySlot.ToString() ); - } - - ImGui.TableNextColumn(); - ImGui.TableNextColumn(); - ImGui.TableNextColumn(); - if( id.ObjectType != ObjectType.Equipment - && id.ObjectType != ObjectType.Accessory ) - { - ImGui.Text( id.SecondaryId.ToString() ); - } - - ImGui.TableNextColumn(); - ImGui.Text( id.Variant.ToString() ); - return ret; - } - - private bool DrawRspRow( int manipIdx, IList< MetaManipulation > list ) - { - var defaults = ( float )Service< MetaDefaults >.Get().GetDefaultValue( list[ manipIdx ] )!; - var ret = false; - var id = list[ manipIdx ].RspIdentifier; - var val = list[ manipIdx ].RspValue; - - if( ImGui.BeginPopup( $"##MetaPopup{manipIdx}" ) ) - { - using var raii = ImGuiRaii.DeferredEnd( ImGui.EndPopup ); - if( DefaultButton( - $"{( _editMode ? "Set to " : "" )}Default: {defaults:F3}##scaleManip", ref val, defaults ) - && _editMode ) - { - list[ manipIdx ] = MetaManipulation.Rsp( id.SubRace, id.Attribute, defaults ); - ret = true; - } - - ImGui.SetNextItemWidth( 50 * ImGuiHelpers.GlobalScale ); - if( ImGui.InputFloat( "Scale###manip", ref val, 0, 0, "%.3f", - _editMode ? ImGuiInputTextFlags.EnterReturnsTrue : ImGuiInputTextFlags.ReadOnly ) - && val >= 0 - && val <= 5 - && _editMode ) - { - list[ manipIdx ] = MetaManipulation.Rsp( id.SubRace, id.Attribute, val ); - ret = true; - } - } - - ImGui.Text( id.Attribute.ToUngenderedString() ); - ImGui.TableNextColumn(); - ImGui.TableNextColumn(); - ImGui.TableNextColumn(); - ImGui.Text( id.SubRace.ToString() ); - ImGui.TableNextColumn(); - ImGui.Text( id.Attribute.ToGender().ToString() ); - return ret; - } - - private bool DrawManipulationRow( ref int manipIdx, IList< MetaManipulation > list, ref int count ) - { - var type = list[ manipIdx ].Type; - - if( _editMode ) - { - ImGui.TableNextColumn(); - using var font = ImGuiRaii.PushFont( UiBuilder.IconFont ); - if( ImGui.Button( $"{FontAwesomeIcon.Trash.ToIconString()}##manipDelete{manipIdx}" ) ) - { - list.RemoveAt( manipIdx ); - ImGui.TableNextRow(); - --manipIdx; - --count; - return true; - } - } - - ImGui.TableNextColumn(); - ImGui.Text( type.ToString() ); - ImGui.TableNextColumn(); - - var changes = false; - switch( type ) - { - case MetaType.Eqp: - changes = DrawEqpRow( manipIdx, list ); - break; - case MetaType.Gmp: - changes = DrawGmpRow( manipIdx, list ); - break; - case MetaType.Eqdp: - changes = DrawEqdpRow( manipIdx, list ); - break; - case MetaType.Est: - changes = DrawEstRow( manipIdx, list ); - break; - case MetaType.Imc: - changes = DrawImcRow( manipIdx, list ); - break; - case MetaType.Rsp: - changes = DrawRspRow( manipIdx, list ); - break; - } - - ImGui.TableSetColumnIndex( 9 ); - if( ImGui.Selectable( $"{list[ manipIdx ].Value}##{manipIdx}" ) ) - { - ImGui.OpenPopup( $"##MetaPopup{manipIdx}" ); - } - - ImGui.TableNextRow(); - return changes; - } - - - private MetaType DrawNewTypeSelection() - { - ImGui.RadioButton( "IMC##newManipType", ref _newManipTypeIdx, 1 ); - ImGui.SameLine(); - ImGui.RadioButton( "EQDP##newManipType", ref _newManipTypeIdx, 2 ); - ImGui.SameLine(); - ImGui.RadioButton( "EQP##newManipType", ref _newManipTypeIdx, 3 ); - ImGui.SameLine(); - ImGui.RadioButton( "EST##newManipType", ref _newManipTypeIdx, 4 ); - ImGui.SameLine(); - ImGui.RadioButton( "GMP##newManipType", ref _newManipTypeIdx, 5 ); - ImGui.SameLine(); - ImGui.RadioButton( "RSP##newManipType", ref _newManipTypeIdx, 6 ); - return ( MetaType )_newManipTypeIdx; - } - - private bool DrawNewManipulationPopup( string popupName, IList< MetaManipulation > list, ref int count ) - { - var change = false; - if( !ImGui.BeginPopup( popupName ) ) - { - return change; - } - - using var raii = ImGuiRaii.DeferredEnd( ImGui.EndPopup ); - var manipType = DrawNewTypeSelection(); - MetaManipulation? newManip = null; - switch( manipType ) - { - case MetaType.Imc: - { - RestrictedInputInt( "Set Id##newManipImc", ref _newManipSetId, 0, ushort.MaxValue ); - RestrictedInputInt( "Variant##newManipImc", ref _newManipVariant, 0, byte.MaxValue ); - CustomCombo( "Object Type", ImcObjectType, out var objectType, ref _newManipObjectType ); - EquipSlot equipSlot = default; - switch( objectType ) - { - case ObjectType.Equipment: - CustomCombo( "Equipment Slot", EqdpEquipSlots, out equipSlot, ref _newManipEquipSlot ); - newManip = MetaManipulation.Imc( equipSlot, _newManipSetId, _newManipVariant, - new ImcFile.ImageChangeData() ); - break; - case ObjectType.DemiHuman: - case ObjectType.Weapon: - case ObjectType.Monster: - RestrictedInputInt( "Secondary Id##newManipImc", ref _newManipSecondaryId, 0, ushort.MaxValue ); - CustomCombo( "Body Slot", ImcBodySlots, out var bodySlot, ref _newManipBodySlot ); - newManip = MetaManipulation.Imc( objectType, bodySlot, _newManipSetId, _newManipSecondaryId, - _newManipVariant, new ImcFile.ImageChangeData() ); - break; - } - - break; - } - case MetaType.Eqdp: - { - RestrictedInputInt( "Set Id##newManipEqdp", ref _newManipSetId, 0, ushort.MaxValue ); - CustomCombo( "Equipment Slot", EqdpEquipSlots, out var equipSlot, ref _newManipEquipSlot ); - CustomCombo( "Race", Races, out var race, ref _newManipRace ); - CustomCombo( "Gender", Genders, out var gender, ref _newManipGender ); - newManip = MetaManipulation.Eqdp( equipSlot, Names.CombinedRace( gender, race ), ( ushort )_newManipSetId, - new EqdpEntry() ); - break; - } - case MetaType.Eqp: - { - RestrictedInputInt( "Set Id##newManipEqp", ref _newManipSetId, 0, ushort.MaxValue ); - CustomCombo( "Equipment Slot", EqpEquipSlots, out var equipSlot, ref _newManipEquipSlot ); - newManip = MetaManipulation.Eqp( equipSlot, ( ushort )_newManipSetId, 0 ); - break; - } - case MetaType.Est: - { - RestrictedInputInt( "Set Id##newManipEst", ref _newManipSetId, 0, ushort.MaxValue ); - CustomCombo( "Object Type", ObjectTypes, out var objectType, ref _newManipObjectType ); - EquipSlot equipSlot = default; - BodySlot bodySlot = default; - switch( ( ObjectType )_newManipObjectType ) - { - case ObjectType.Equipment: - CustomCombo( "Equipment Slot", EstEquipSlots, out equipSlot, ref _newManipEquipSlot ); - break; - case ObjectType.Character: - CustomCombo( "Body Slot", EstBodySlots, out bodySlot, ref _newManipBodySlot ); - break; - } - - CustomCombo( "Race", Races, out var race, ref _newManipRace ); - CustomCombo( "Gender", Genders, out var gender, ref _newManipGender ); - newManip = MetaManipulation.Est( objectType, equipSlot, Names.CombinedRace( gender, race ), bodySlot, - ( ushort )_newManipSetId, 0 ); - break; - } - case MetaType.Gmp: - RestrictedInputInt( "Set Id##newManipGmp", ref _newManipSetId, 0, ushort.MaxValue ); - newManip = MetaManipulation.Gmp( ( ushort )_newManipSetId, new GmpEntry() ); - break; - case MetaType.Rsp: - CustomCombo( "Subrace", Subraces, out var subRace, ref _newManipSubrace ); - CustomCombo( "Attribute", RspAttributes, out var rspAttribute, ref _newManipAttribute ); - newManip = MetaManipulation.Rsp( subRace, rspAttribute, 1f ); - break; - } - - if( ImGui.Button( "Create Manipulation##newManip", Vector2.UnitX * -1 ) - && newManip != null - && list.All( m => m.Identifier != newManip.Value.Identifier ) ) - { - var def = Service< MetaDefaults >.Get().GetDefaultValue( newManip.Value ); - if( def != null ) - { - var manip = newManip.Value.Type switch - { - MetaType.Est => new MetaManipulation( newManip.Value.Identifier, ( ulong )def ), - MetaType.Eqp => new MetaManipulation( newManip.Value.Identifier, ( ulong )def ), - MetaType.Eqdp => new MetaManipulation( newManip.Value.Identifier, ( ulong )def ), - MetaType.Gmp => new MetaManipulation( newManip.Value.Identifier, ( ulong )def ), - MetaType.Imc => new MetaManipulation( newManip.Value.Identifier, - ( ( ImcFile.ImageChangeData )def ).ToInteger() ), - MetaType.Rsp => MetaManipulation.Rsp( newManip.Value.RspIdentifier.SubRace, - newManip.Value.RspIdentifier.Attribute, ( float )def ), - _ => throw new InvalidEnumArgumentException(), - }; - list.Add( manip ); - change = true; - ++count; - } - - ImGui.CloseCurrentPopup(); - } - - return change; - } - - private bool DrawMetaManipulationsTable( string label, IList< MetaManipulation > list, ref int count ) - { - var numRows = _editMode ? 11 : 10; - var changes = false; - if( list.Count > 0 - && ImGui.BeginTable( label, numRows, - ImGuiTableFlags.BordersInner | ImGuiTableFlags.RowBg | ImGuiTableFlags.SizingFixedFit ) ) - { - using var raii = ImGuiRaii.DeferredEnd( ImGui.EndTable ); - if( _editMode ) - { - ImGui.TableNextColumn(); - } - - ImGui.TableNextColumn(); - ImGui.TableHeader( $"Type##{label}" ); - ImGui.TableNextColumn(); - ImGui.TableHeader( $"Object Type##{label}" ); - ImGui.TableNextColumn(); - ImGui.TableHeader( $"Set##{label}" ); - ImGui.TableNextColumn(); - ImGui.TableHeader( $"Slot##{label}" ); - ImGui.TableNextColumn(); - ImGui.TableHeader( $"Race##{label}" ); - ImGui.TableNextColumn(); - ImGui.TableHeader( $"Gender##{label}" ); - ImGui.TableNextColumn(); - ImGui.TableHeader( $"Secondary ID##{label}" ); - ImGui.TableNextColumn(); - ImGui.TableHeader( $"Variant##{label}" ); - ImGui.TableNextColumn(); - ImGui.TableNextColumn(); - ImGui.TableHeader( $"Value##{label}" ); - ImGui.TableNextRow(); - - for( var i = 0; i < list.Count; ++i ) - { - changes |= DrawManipulationRow( ref i, list, ref count ); - } - } - - var popupName = $"##newManip{label}"; - if( _editMode ) - { - changes |= DrawNewManipulationPopup( $"##newManip{label}", list, ref count ); - if( ImGui.Button( $"Add New Manipulation##{label}", Vector2.UnitX * -1 ) ) - { - ImGui.OpenPopup( popupName ); - } - - return changes; - } - - return false; - } - } - } -} \ No newline at end of file diff --git a/Penumbra/UI/MenuTabs/TabInstalled/TabInstalledModPanel.cs b/Penumbra/UI/MenuTabs/TabInstalled/TabInstalledModPanel.cs deleted file mode 100644 index 2250b80e..00000000 --- a/Penumbra/UI/MenuTabs/TabInstalled/TabInstalledModPanel.cs +++ /dev/null @@ -1,536 +0,0 @@ -using System; -using System.Diagnostics; -using System.IO; -using System.Numerics; -using Dalamud.Interface; -using Dalamud.Logging; -using ImGuiNET; -using Penumbra.Mod; -using Penumbra.Mods; -using Penumbra.UI.Custom; -using Penumbra.Util; - -namespace Penumbra.UI -{ - public partial class SettingsInterface - { - private class ModPanel - { - private const string LabelModPanel = "selectedModInfo"; - private const string LabelEditName = "##editName"; - private const string LabelEditVersion = "##editVersion"; - private const string LabelEditAuthor = "##editAuthor"; - private const string LabelEditWebsite = "##editWebsite"; - private const string LabelModEnabled = "Enabled"; - private const string LabelEditingEnabled = "Enable Editing"; - private const string LabelOverWriteDir = "OverwriteDir"; - private const string ButtonOpenWebsite = "Open Website"; - private const string ButtonOpenModFolder = "Open Mod Folder"; - private const string ButtonRenameModFolder = "Rename Mod Folder"; - private const string ButtonEditJson = "Edit JSON"; - private const string ButtonReloadJson = "Reload JSON"; - private const string ButtonDeduplicate = "Deduplicate"; - private const string ButtonNormalize = "Normalize"; - private const string TooltipOpenModFolder = "Open the directory containing this mod in your default file explorer."; - private const string TooltipRenameModFolder = "Rename the directory containing this mod without opening another application."; - private const string TooltipEditJson = "Open the JSON configuration file in your default application for .json."; - private const string TooltipReloadJson = "Reload the configuration of all mods."; - private const string PopupRenameFolder = "Rename Folder"; - - private const string TooltipDeduplicate = - "Try to find identical files and remove duplicate occurences to reduce the mods disk size.\n" - + "Introduces an invisible single-option Group \"Duplicates\".\nExperimental - use at own risk!"; - - private const string TooltipNormalize = - "Try to reduce unnecessary options or subdirectories to default options if possible.\nExperimental - use at own risk!"; - - private const float HeaderLineDistance = 10f; - private static readonly Vector4 GreyColor = new( 1f, 1f, 1f, 0.66f ); - - private readonly SettingsInterface _base; - private readonly Selector _selector; - private readonly ModManager _modManager; - public readonly PluginDetails Details; - - private bool _editMode; - private string _currentWebsite; - private bool _validWebsite; - - public ModPanel( SettingsInterface ui, Selector s ) - { - _base = ui; - _selector = s; - Details = new PluginDetails( _base, _selector ); - _currentWebsite = Meta?.Website ?? ""; - _modManager = Service< ModManager >.Get(); - } - - private Mod.Mod? Mod - => _selector.Mod; - - private ModMeta? Meta - => Mod?.Data.Meta; - - private void DrawName() - { - var name = Meta!.Name; - if( ImGuiCustom.InputOrText( _editMode, LabelEditName, ref name, 64 ) && _modManager.RenameMod( name, Mod!.Data ) ) - { - _selector.SelectModOnUpdate( Mod.Data.BasePath.Name ); - if( !_modManager.Config.ModSortOrder.ContainsKey( Mod!.Data.BasePath.Name ) ) - { - Mod.Data.Rename( name ); - } - } - } - - private void DrawVersion() - { - if( _editMode ) - { - ImGui.BeginGroup(); - using var raii = ImGuiRaii.DeferredEnd( ImGui.EndGroup ); - ImGui.Text( "(Version " ); - - using var style = ImGuiRaii.PushStyle( ImGuiStyleVar.ItemSpacing, ZeroVector ); - ImGui.SameLine(); - var version = Meta!.Version; - if( ImGuiCustom.ResizingTextInput( LabelEditVersion, ref version, 16 ) - && version != Meta.Version ) - { - Meta.Version = version; - _selector.SaveCurrentMod(); - } - - ImGui.SameLine(); - ImGui.Text( ")" ); - } - else if( Meta!.Version.Length > 0 ) - { - ImGui.Text( $"(Version {Meta.Version})" ); - } - } - - private void DrawAuthor() - { - ImGui.BeginGroup(); - ImGui.TextColored( GreyColor, "by" ); - - ImGui.SameLine(); - var author = Meta!.Author; - if( ImGuiCustom.InputOrText( _editMode, LabelEditAuthor, ref author, 64 ) - && author != Meta.Author ) - { - Meta.Author = author; - _selector.SaveCurrentMod(); - _selector.Cache.TriggerFilterReset(); - } - - ImGui.EndGroup(); - } - - private void DrawWebsite() - { - ImGui.BeginGroup(); - using var raii = ImGuiRaii.DeferredEnd( ImGui.EndGroup ); - if( _editMode ) - { - ImGui.TextColored( GreyColor, "from" ); - ImGui.SameLine(); - var website = Meta!.Website; - if( ImGuiCustom.ResizingTextInput( LabelEditWebsite, ref website, 512 ) - && website != Meta.Website ) - { - Meta.Website = website; - _selector.SaveCurrentMod(); - } - } - else if( Meta!.Website.Length > 0 ) - { - if( _currentWebsite != Meta.Website ) - { - _currentWebsite = Meta.Website; - _validWebsite = Uri.TryCreate( Meta.Website, UriKind.Absolute, out var uriResult ) - && ( uriResult.Scheme == Uri.UriSchemeHttps || uriResult.Scheme == Uri.UriSchemeHttp ); - } - - if( _validWebsite ) - { - if( ImGui.SmallButton( ButtonOpenWebsite ) ) - { - try - { - var process = new ProcessStartInfo( Meta.Website ) - { - UseShellExecute = true, - }; - Process.Start( process ); - } - catch( System.ComponentModel.Win32Exception ) - { - // Do nothing. - } - } - - ImGuiCustom.HoverTooltip( Meta.Website ); - } - else - { - ImGui.TextColored( GreyColor, "from" ); - ImGui.SameLine(); - ImGui.Text( Meta.Website ); - } - } - } - - private void DrawHeaderLine() - { - DrawName(); - ImGui.SameLine(); - DrawVersion(); - ImGui.SameLine(); - DrawAuthor(); - ImGui.SameLine(); - DrawWebsite(); - } - - private void DrawPriority() - { - var priority = Mod!.Settings.Priority; - ImGui.SetNextItemWidth( 50 * ImGuiHelpers.GlobalScale ); - if( ImGui.InputInt( "Priority", ref priority, 0 ) && priority != Mod!.Settings.Priority ) - { - Mod.Settings.Priority = priority; - _base.SaveCurrentCollection( Mod.Data.Resources.MetaManipulations.Count > 0 ); - _selector.Cache.TriggerFilterReset(); - } - - ImGuiCustom.HoverTooltip( - "Higher priority mods take precedence over other mods in the case of file conflicts.\n" - + "In case of identical priority, the alphabetically first mod takes precedence." ); - } - - private void DrawEnabledMark() - { - var enabled = Mod!.Settings.Enabled; - if( ImGui.Checkbox( LabelModEnabled, ref enabled ) ) - { - Mod.Settings.Enabled = enabled; - _base.SaveCurrentCollection( Mod.Data.Resources.MetaManipulations.Count > 0 ); - _selector.Cache.TriggerFilterReset(); - } - } - - public static bool DrawSortOrder( ModData mod, ModManager manager, Selector selector ) - { - var currentSortOrder = mod.SortOrder.FullPath; - ImGui.SetNextItemWidth( 300 * ImGuiHelpers.GlobalScale ); - if( ImGui.InputText( "Sort Order", ref currentSortOrder, 256, ImGuiInputTextFlags.EnterReturnsTrue ) ) - { - manager.ChangeSortOrder( mod, currentSortOrder ); - selector.SelectModOnUpdate( mod.BasePath.Name ); - return true; - } - - return false; - } - - private void DrawEditableMark() - { - ImGui.Checkbox( LabelEditingEnabled, ref _editMode ); - } - - private void DrawOpenModFolderButton() - { - Mod!.Data.BasePath.Refresh(); - if( ImGui.Button( ButtonOpenModFolder ) && Mod.Data.BasePath.Exists ) - { - Process.Start( new ProcessStartInfo( Mod!.Data.BasePath.FullName ) { UseShellExecute = true } ); - } - - ImGuiCustom.HoverTooltip( TooltipOpenModFolder ); - } - - private string _newName = ""; - private bool _keyboardFocus = true; - - private void RenameModFolder( string newName ) - { - _newName = newName.ReplaceBadXivSymbols(); - if( _newName.Length == 0 ) - { - PluginLog.Debug( "New Directory name {NewName} was empty after removing invalid symbols.", newName ); - ImGui.CloseCurrentPopup(); - } - else if( !string.Equals( _newName, Mod!.Data.BasePath.Name, StringComparison.InvariantCultureIgnoreCase ) ) - { - DirectoryInfo dir = Mod!.Data.BasePath; - DirectoryInfo newDir = new( Path.Combine( dir.Parent!.FullName, _newName ) ); - - if( newDir.Exists ) - { - ImGui.OpenPopup( LabelOverWriteDir ); - } - else if( _modManager.RenameModFolder( Mod.Data, newDir ) ) - { - _selector.ReloadCurrentMod(); - ImGui.CloseCurrentPopup(); - } - } - } - - private static bool MergeFolderInto( DirectoryInfo source, DirectoryInfo target ) - { - try - { - foreach( var file in source.EnumerateFiles( "*", SearchOption.AllDirectories ) ) - { - var targetFile = new FileInfo( Path.Combine( target.FullName, file.FullName.Substring( source.FullName.Length + 1 ) ) ); - if( targetFile.Exists ) - { - targetFile.Delete(); - } - - targetFile.Directory?.Create(); - file.MoveTo( targetFile.FullName ); - } - - source.Delete( true ); - return true; - } - catch( Exception e ) - { - PluginLog.Error( $"Could not merge directory {source.FullName} into {target.FullName}:\n{e}" ); - } - - return false; - } - - private bool OverwriteDirPopup() - { - var closeParent = false; - var _ = true; - if( !ImGui.BeginPopupModal( LabelOverWriteDir, ref _, ImGuiWindowFlags.AlwaysAutoResize ) ) - { - return closeParent; - } - - using var raii = ImGuiRaii.DeferredEnd( ImGui.EndPopup ); - - DirectoryInfo dir = Mod!.Data.BasePath; - DirectoryInfo newDir = new( Path.Combine( dir.Parent!.FullName, _newName ) ); - ImGui.Text( - $"The mod directory {newDir} already exists.\nDo you want to merge / overwrite both mods?\nThis may corrupt the resulting mod in irrecoverable ways." ); - var buttonSize = ImGuiHelpers.ScaledVector2( 120, 0 ); - if( ImGui.Button( "Yes", buttonSize ) ) - { - if( MergeFolderInto( dir, newDir ) ) - { - Service< ModManager >.Get()!.RenameModFolder( Mod.Data, newDir, false ); - - _selector.SelectModOnUpdate( _newName ); - - closeParent = true; - ImGui.CloseCurrentPopup(); - } - } - - ImGui.SameLine(); - - if( ImGui.Button( "Cancel", buttonSize ) ) - { - _keyboardFocus = true; - ImGui.CloseCurrentPopup(); - } - - return closeParent; - } - - private void DrawRenameModFolderPopup() - { - var _ = true; - _keyboardFocus |= !ImGui.IsPopupOpen( PopupRenameFolder ); - - ImGui.SetNextWindowPos( ImGui.GetMainViewport().GetCenter(), ImGuiCond.Appearing, new Vector2( 0.5f, 1f ) ); - if( !ImGui.BeginPopupModal( PopupRenameFolder, ref _, ImGuiWindowFlags.AlwaysAutoResize ) ) - { - return; - } - - using var raii = ImGuiRaii.DeferredEnd( ImGui.EndPopup ); - - if( ImGui.IsKeyPressed( ImGui.GetKeyIndex( ImGuiKey.Escape ) ) ) - { - ImGui.CloseCurrentPopup(); - } - - var newName = Mod!.Data.BasePath.Name; - - if( _keyboardFocus ) - { - ImGui.SetKeyboardFocusHere(); - _keyboardFocus = false; - } - - if( ImGui.InputText( "New Folder Name##RenameFolderInput", ref newName, 64, ImGuiInputTextFlags.EnterReturnsTrue ) ) - { - RenameModFolder( newName ); - } - - ImGui.TextColored( GreyColor, - "Please restrict yourself to ascii symbols that are valid in a windows path,\nother symbols will be replaced by underscores." ); - - ImGui.SetNextWindowPos( ImGui.GetMainViewport().GetCenter(), ImGuiCond.Appearing, Vector2.One / 2 ); - - - if( OverwriteDirPopup() ) - { - ImGui.CloseCurrentPopup(); - } - } - - private void DrawRenameModFolderButton() - { - DrawRenameModFolderPopup(); - if( ImGui.Button( ButtonRenameModFolder ) ) - { - ImGui.OpenPopup( PopupRenameFolder ); - } - - ImGuiCustom.HoverTooltip( TooltipRenameModFolder ); - } - - private void DrawEditJsonButton() - { - if( ImGui.Button( ButtonEditJson ) ) - { - _selector.SaveCurrentMod(); - Process.Start( new ProcessStartInfo( Mod!.Data.MetaFile.FullName ) { UseShellExecute = true } ); - } - - ImGuiCustom.HoverTooltip( TooltipEditJson ); - } - - private void DrawReloadJsonButton() - { - if( ImGui.Button( ButtonReloadJson ) ) - { - _selector.ReloadCurrentMod( true, false ); - } - - ImGuiCustom.HoverTooltip( TooltipReloadJson ); - } - - private void DrawResetMetaButton() - { - if( ImGui.Button( "Recompute Metadata" ) ) - { - _selector.ReloadCurrentMod( true, true ); - } - - ImGuiCustom.HoverTooltip( - "Force a recomputation of the metadata_manipulations.json file from all .meta files in the folder.\n" - + "Also reloads the mod.\n" - + "Be aware that this removes all manually added metadata changes." ); - } - - private void DrawDeduplicateButton() - { - if( ImGui.Button( ButtonDeduplicate ) ) - { - ModCleanup.Deduplicate( Mod!.Data.BasePath, Meta! ); - _selector.SaveCurrentMod(); - _selector.ReloadCurrentMod(); - } - - ImGuiCustom.HoverTooltip( TooltipDeduplicate ); - } - - private void DrawNormalizeButton() - { - if( ImGui.Button( ButtonNormalize ) ) - { - ModCleanup.Normalize( Mod!.Data.BasePath, Meta! ); - _selector.SaveCurrentMod(); - _selector.ReloadCurrentMod(); - } - - ImGuiCustom.HoverTooltip( TooltipNormalize ); - } - - private void DrawSplitButton() - { - if( ImGui.Button( "Split Mod" ) ) - { - ModCleanup.SplitMod( Mod!.Data ); - } - - ImGuiCustom.HoverTooltip( - "Split off all options of a mod into single mods that are placed in a collective folder.\n" - + "Does not remove or change the mod itself, just create (potentially inefficient) copies.\n" - + "Experimental - Use at own risk!" ); - } - - private void DrawEditLine() - { - DrawOpenModFolderButton(); - ImGui.SameLine(); - DrawRenameModFolderButton(); - ImGui.SameLine(); - DrawEditJsonButton(); - ImGui.SameLine(); - DrawReloadJsonButton(); - - DrawResetMetaButton(); - ImGui.SameLine(); - DrawDeduplicateButton(); - ImGui.SameLine(); - DrawNormalizeButton(); - ImGui.SameLine(); - DrawSplitButton(); - - DrawSortOrder( Mod!.Data, _modManager, _selector ); - } - - public void Draw() - { - try - { - using var raii = ImGuiRaii.DeferredEnd( ImGui.EndChild ); - var ret = ImGui.BeginChild( LabelModPanel, AutoFillSize, true ); - - if( !ret || Mod == null ) - { - return; - } - - DrawHeaderLine(); - - // Next line with fixed distance. - ImGuiCustom.VerticalDistance( HeaderLineDistance ); - - DrawEnabledMark(); - ImGui.SameLine(); - DrawPriority(); - if( Penumbra.Config.ShowAdvanced ) - { - ImGui.SameLine(); - DrawEditableMark(); - } - - // Next line, if editable. - if( _editMode ) - { - DrawEditLine(); - } - - Details.Draw( _editMode ); - } - catch( Exception ex ) - { - PluginLog.LogError( ex, "Oh no" ); - } - } - } - } -} \ No newline at end of file diff --git a/Penumbra/UI/MenuTabs/TabInstalled/TabInstalledSelector.cs b/Penumbra/UI/MenuTabs/TabInstalled/TabInstalledSelector.cs deleted file mode 100644 index 5f73a2e9..00000000 --- a/Penumbra/UI/MenuTabs/TabInstalled/TabInstalledSelector.cs +++ /dev/null @@ -1,766 +0,0 @@ -using System; -using System.IO; -using System.Linq; -using System.Numerics; -using System.Runtime.InteropServices; -using System.Windows.Forms.VisualStyles; -using Dalamud.Interface; -using Dalamud.Logging; -using ImGuiNET; -using Penumbra.Importer; -using Penumbra.Mod; -using Penumbra.Mods; -using Penumbra.UI.Custom; -using Penumbra.Util; - -namespace Penumbra.UI -{ - public partial class SettingsInterface - { - // Constants - private partial class Selector - { - private const string LabelSelectorList = "##availableModList"; - private const string LabelModFilter = "##ModFilter"; - private const string LabelAddModPopup = "AddModPopup"; - private const string LabelModHelpPopup = "Help##Selector"; - - private const string TooltipModFilter = - "Filter mods for those containing the given substring.\nEnter c:[string] to filter for mods changing specific items.\nEnter a:[string] to filter for mods by specific authors."; - - private const string TooltipDelete = "Delete the selected mod"; - private const string TooltipAdd = "Add an empty mod"; - private const string DialogDeleteMod = "PenumbraDeleteMod"; - private const string ButtonYesDelete = "Yes, delete it"; - private const string ButtonNoDelete = "No, keep it"; - - private const float SelectorPanelWidth = 240f; - - private static readonly Vector2 SelectorButtonSizes = new( 100, 0 ); - private static readonly Vector2 HelpButtonSizes = new( 40, 0 ); - - private static readonly Vector4 DeleteModNameColor = new( 0.7f, 0.1f, 0.1f, 1 ); - } - - // Buttons - private partial class Selector - { - // === Delete === - private int? _deleteIndex; - - private void DrawModTrashButton() - { - using var raii = ImGuiRaii.PushFont( UiBuilder.IconFont ); - - if( ImGui.Button( FontAwesomeIcon.Trash.ToIconString(), SelectorButtonSizes * _selectorScalingFactor ) && _index >= 0 ) - { - _deleteIndex = _index; - } - - raii.Pop(); - - ImGuiCustom.HoverTooltip( TooltipDelete ); - } - - private void DrawDeleteModal() - { - if( _deleteIndex == null ) - { - return; - } - - ImGui.OpenPopup( DialogDeleteMod ); - - var _ = true; - ImGui.SetNextWindowPos( ImGui.GetMainViewport().GetCenter(), ImGuiCond.Appearing, Vector2.One / 2 ); - var ret = ImGui.BeginPopupModal( DialogDeleteMod, ref _, ImGuiWindowFlags.AlwaysAutoResize | ImGuiWindowFlags.NoDecoration ); - if( !ret ) - { - return; - } - - using var raii = ImGuiRaii.DeferredEnd( ImGui.EndPopup ); - - if( Mod == null ) - { - _deleteIndex = null; - ImGui.CloseCurrentPopup(); - return; - } - - ImGui.Text( "Are you sure you want to delete the following mod:" ); - var halfLine = new Vector2( ImGui.GetTextLineHeight() / 2 ); - ImGui.Dummy( halfLine ); - ImGui.TextColored( DeleteModNameColor, Mod.Data.Meta.Name ); - ImGui.Dummy( halfLine ); - - var buttonSize = ImGuiHelpers.ScaledVector2( 120, 0 ); - if( ImGui.Button( ButtonYesDelete, buttonSize ) ) - { - ImGui.CloseCurrentPopup(); - var mod = Mod; - Cache.RemoveMod( mod ); - _modManager.DeleteMod( mod.Data.BasePath ); - ModFileSystem.InvokeChange(); - ClearSelection(); - } - - ImGui.SameLine(); - - if( ImGui.Button( ButtonNoDelete, buttonSize ) ) - { - ImGui.CloseCurrentPopup(); - _deleteIndex = null; - } - } - - // === Add === - private bool _modAddKeyboardFocus = true; - - private void DrawModAddButton() - { - using var raii = ImGuiRaii.PushFont( UiBuilder.IconFont ); - - if( ImGui.Button( FontAwesomeIcon.Plus.ToIconString(), SelectorButtonSizes * _selectorScalingFactor ) ) - { - _modAddKeyboardFocus = true; - ImGui.OpenPopup( LabelAddModPopup ); - } - - raii.Pop(); - - ImGuiCustom.HoverTooltip( TooltipAdd ); - - DrawModAddPopup(); - } - - private void DrawModAddPopup() - { - if( !ImGui.BeginPopup( LabelAddModPopup ) ) - { - return; - } - - using var raii = ImGuiRaii.DeferredEnd( ImGui.EndPopup ); - - if( _modAddKeyboardFocus ) - { - ImGui.SetKeyboardFocusHere(); - _modAddKeyboardFocus = false; - } - - var newName = ""; - if( ImGui.InputTextWithHint( "##AddMod", "New Mod Name...", ref newName, 64, ImGuiInputTextFlags.EnterReturnsTrue ) ) - { - try - { - var newDir = TexToolsImport.CreateModFolder( new DirectoryInfo( Penumbra.Config!.ModDirectory ), - newName ); - var modMeta = new ModMeta - { - Author = "Unknown", - Name = newName.Replace( '/', '\\' ), - Description = string.Empty, - }; - - var metaFile = new FileInfo( Path.Combine( newDir.FullName, "meta.json" ) ); - modMeta.SaveToFile( metaFile ); - _modManager.AddMod( newDir ); - ModFileSystem.InvokeChange(); - SelectModOnUpdate( newDir.Name ); - } - catch( Exception e ) - { - PluginLog.Error( $"Could not create directory for new Mod {newName}:\n{e}" ); - } - - ImGui.CloseCurrentPopup(); - } - - if( ImGui.IsKeyPressed( ImGui.GetKeyIndex( ImGuiKey.Escape ) ) ) - { - ImGui.CloseCurrentPopup(); - } - } - - // === Help === - private void DrawModHelpButton() - { - using var raii = ImGuiRaii.PushFont( UiBuilder.IconFont ); - if( ImGui.Button( FontAwesomeIcon.QuestionCircle.ToIconString(), HelpButtonSizes * _selectorScalingFactor ) ) - { - ImGui.OpenPopup( LabelModHelpPopup ); - } - } - - private static void DrawModHelpPopup() - { - ImGui.SetNextWindowPos( ImGui.GetMainViewport().GetCenter(), ImGuiCond.Appearing, Vector2.One / 2 ); - ImGui.SetNextWindowSize( new Vector2( 5 * SelectorPanelWidth, 33 * ImGui.GetTextLineHeightWithSpacing() ), - ImGuiCond.Appearing ); - var _ = true; - if( !ImGui.BeginPopupModal( LabelModHelpPopup, ref _, ImGuiWindowFlags.NoResize | ImGuiWindowFlags.NoMove ) ) - { - return; - } - - using var raii = ImGuiRaii.DeferredEnd( ImGui.EndPopup ); - - ImGui.Dummy( Vector2.UnitY * ImGui.GetTextLineHeight() ); - ImGui.Text( "Mod Selector" ); - ImGui.BulletText( "Select a mod to obtain more information." ); - ImGui.BulletText( "Mod names are colored according to their current state in the collection:" ); - ImGui.Indent(); - ImGui.Bullet(); - ImGui.SameLine(); - ImGui.Text( "Enabled in the current collection." ); - ImGui.Bullet(); - ImGui.SameLine(); - ImGui.TextColored( ImGui.ColorConvertU32ToFloat4( ModListCache.DisabledModColor ), "Disabled in the current collection." ); - ImGui.Bullet(); - ImGui.SameLine(); - ImGui.TextColored( ImGui.ColorConvertU32ToFloat4( ModListCache.HandledConflictModColor ), - "Enabled and conflicting with another enabled Mod, but on different priorities (i.e. the conflict is solved)." ); - ImGui.Bullet(); - ImGui.SameLine(); - ImGui.TextColored( ImGui.ColorConvertU32ToFloat4( ModListCache.ConflictingModColor ), - "Enabled and conflicting with another enabled Mod on the same priority." ); - ImGui.Unindent(); - ImGui.BulletText( "Right-click a mod to enter its sort order, which is its name by default." ); - ImGui.Indent(); - ImGui.BulletText( "A sort order differing from the mods name will not be displayed, it will just be used for ordering." ); - ImGui.BulletText( - "If the sort order string contains Forward-Slashes ('/'), the preceding substring will be turned into collapsible folders that can group mods." ); - ImGui.BulletText( - "Collapsible folders can contain further collapsible folders, so \"folder1/folder2/folder3/1\" will produce 3 folders\n" - + "\t\t[folder1] -> [folder2] -> [folder3] -> [ModName],\n" - + "where ModName will be sorted as if it was the string '1'." ); - ImGui.Unindent(); - ImGui.BulletText( - "You can drag and drop mods and subfolders into existing folders. Dropping them onto mods is the same as dropping them onto the parent of the mod." ); - ImGui.BulletText( "Right-clicking a folder opens a context menu." ); - ImGui.Indent(); - ImGui.BulletText( - "You can rename folders in the context menu. Leave the text blank and press enter to merge the folder with its parent." ); - ImGui.BulletText( "You can also enable or disable all descendant mods of a folder." ); - ImGui.Unindent(); - ImGui.BulletText( "Use the Filter Mods... input at the top to filter the list for mods with names containing the text." ); - ImGui.Indent(); - ImGui.BulletText( "You can enter c:[string] to filter for Changed Items instead." ); - ImGui.BulletText( "You can enter a:[string] to filter for Mod Authors instead." ); - ImGui.Unindent(); - ImGui.BulletText( "Use the expandable menu beside the input to filter for mods fulfilling specific criteria." ); - ImGui.Dummy( Vector2.UnitY * ImGui.GetTextLineHeight() ); - ImGui.Text( "Mod Management" ); - ImGui.BulletText( "You can delete the currently selected mod with the trashcan button." ); - ImGui.BulletText( "You can add a completely empty mod with the plus button." ); - ImGui.BulletText( "You can import TTMP-based mods in the import tab." ); - ImGui.BulletText( - "You can import penumbra-based mods by moving the corresponding folder into your mod directory in a file explorer, then rediscovering mods." ); - ImGui.BulletText( - "If you enable Advanced Options in the Settings tab, you can toggle Edit Mode to manipulate your selected mod even further." ); - ImGui.Dummy( Vector2.UnitY * ImGui.GetTextLineHeight() ); - ImGui.Dummy( Vector2.UnitX * 2 * SelectorPanelWidth ); - ImGui.SameLine(); - if( ImGui.Button( "Understood", Vector2.UnitX * SelectorPanelWidth ) ) - { - ImGui.CloseCurrentPopup(); - } - } - - // === Main === - private void DrawModsSelectorButtons() - { - // Selector controls - using var style = ImGuiRaii.PushStyle( ImGuiStyleVar.WindowPadding, ZeroVector ) - .Push( ImGuiStyleVar.FrameRounding, 0 ); - - DrawModAddButton(); - ImGui.SameLine(); - DrawModHelpButton(); - ImGui.SameLine(); - DrawModTrashButton(); - } - } - - // Filters - private partial class Selector - { - private string _modFilterInput = ""; - - private void DrawTextFilter() - { - ImGui.SetNextItemWidth( SelectorPanelWidth * _selectorScalingFactor - 22 * ImGuiHelpers.GlobalScale ); - var tmp = _modFilterInput; - if( ImGui.InputTextWithHint( LabelModFilter, "Filter Mods...", ref tmp, 256 ) && _modFilterInput != tmp ) - { - Cache.SetTextFilter( tmp ); - _modFilterInput = tmp; - } - - ImGuiCustom.HoverTooltip( TooltipModFilter ); - } - - private void DrawToggleFilter() - { - if( ImGui.BeginCombo( "##ModStateFilter", "", - ImGuiComboFlags.NoPreview | ImGuiComboFlags.PopupAlignLeft | ImGuiComboFlags.HeightLargest ) ) - { - using var raii = ImGuiRaii.DeferredEnd( ImGui.EndCombo ); - var flags = ( int )Cache.StateFilter; - foreach( ModFilter flag in Enum.GetValues( typeof( ModFilter ) ) ) - { - ImGui.CheckboxFlags( flag.ToName(), ref flags, ( int )flag ); - } - - Cache.StateFilter = ( ModFilter )flags; - } - - ImGuiCustom.HoverTooltip( "Filter mods for their activation status." ); - } - - private void DrawModsSelectorFilter() - { - using var style = ImGuiRaii.PushStyle( ImGuiStyleVar.ItemSpacing, ZeroVector ); - DrawTextFilter(); - ImGui.SameLine(); - DrawToggleFilter(); - } - } - - // Drag'n Drop - private partial class Selector - { - private const string DraggedModLabel = "ModIndex"; - private const string DraggedFolderLabel = "FolderName"; - - private readonly IntPtr _dragDropPayload = Marshal.AllocHGlobal( 4 ); - - private static unsafe bool IsDropping( string name ) - => ImGui.AcceptDragDropPayload( name ).NativePtr != null; - - private void DragDropTarget( ModFolder folder ) - { - if( !ImGui.BeginDragDropTarget() ) - { - return; - } - - using var raii = ImGuiRaii.DeferredEnd( ImGui.EndDragDropTarget ); - - if( IsDropping( DraggedModLabel ) ) - { - var payload = ImGui.GetDragDropPayload(); - var modIndex = Marshal.ReadInt32( payload.Data ); - var mod = Cache.GetMod( modIndex ).Item1; - mod?.Data.Move( folder ); - } - else if( IsDropping( DraggedFolderLabel ) ) - { - var payload = ImGui.GetDragDropPayload(); - var folderName = Marshal.PtrToStringUni( payload.Data ); - if( ModFileSystem.Find( folderName!, out var droppedFolder ) - && !ReferenceEquals( droppedFolder, folder ) - && !folder.FullName.StartsWith( folderName!, StringComparison.InvariantCultureIgnoreCase ) ) - { - droppedFolder.Move( folder ); - } - } - } - - private void DragDropSourceFolder( ModFolder folder ) - { - if( !ImGui.BeginDragDropSource() ) - { - return; - } - - using var raii = ImGuiRaii.DeferredEnd( ImGui.EndDragDropSource ); - - var folderName = folder.FullName; - var ptr = Marshal.StringToHGlobalUni( folderName ); - ImGui.SetDragDropPayload( DraggedFolderLabel, ptr, ( uint )( folderName.Length + 1 ) * 2 ); - ImGui.Text( $"Moving {folderName}..." ); - } - - private void DragDropSourceMod( int modIndex, string modName ) - { - if( !ImGui.BeginDragDropSource() ) - { - return; - } - - using var raii = ImGuiRaii.DeferredEnd( ImGui.EndDragDropSource ); - - Marshal.WriteInt32( _dragDropPayload, modIndex ); - ImGui.SetDragDropPayload( "ModIndex", _dragDropPayload, 4 ); - ImGui.Text( $"Moving {modName}..." ); - } - - ~Selector() - => Marshal.FreeHGlobal( _dragDropPayload ); - } - - // Selection - private partial class Selector - { - public Mod.Mod? Mod { get; private set; } - private int _index; - private string _nextDir = string.Empty; - - private void SetSelection( int idx, Mod.Mod? info ) - { - Mod = info; - if( idx != _index ) - { - _base._menu.InstalledTab.ModPanel.Details.ResetState(); - } - - _index = idx; - _deleteIndex = null; - } - - private void SetSelection( int idx ) - { - if( idx >= Cache.Count ) - { - idx = -1; - } - - if( idx < 0 ) - { - SetSelection( 0, null ); - } - else - { - SetSelection( idx, Cache.GetMod( idx ).Item1 ); - } - } - - public void ReloadSelection() - => SetSelection( _index, Cache.GetMod( _index ).Item1 ); - - public void ClearSelection() - => SetSelection( -1 ); - - public void SelectModOnUpdate( string directory ) - => _nextDir = directory; - - public void SelectModByDir( string name ) - { - var (mod, idx) = Cache.GetModByBasePath( name ); - SetSelection( idx, mod ); - } - - public void ReloadCurrentMod( bool reloadMeta = false, bool recomputeMeta = false ) - { - if( Mod == null ) - { - return; - } - - if( _index >= 0 && _modManager.UpdateMod( Mod.Data, reloadMeta, recomputeMeta ) ) - { - SelectModOnUpdate( Mod.Data.BasePath.Name ); - _base._menu.InstalledTab.ModPanel.Details.ResetState(); - } - } - - public void SaveCurrentMod() - => Mod?.Data.SaveMeta(); - } - - // Right-Clicks - private partial class Selector - { - // === Mod === - private void DrawModOrderPopup( string popupName, Mod.Mod mod, bool firstOpen ) - { - if( !ImGui.BeginPopup( popupName ) ) - { - return; - } - - using var raii = ImGuiRaii.DeferredEnd( ImGui.EndPopup ); - - if( ModPanel.DrawSortOrder( mod.Data, _modManager, this ) ) - { - ImGui.CloseCurrentPopup(); - } - - if( firstOpen ) - { - ImGui.SetKeyboardFocusHere( mod.Data.SortOrder.FullPath.Length - 1 ); - } - } - - // === Folder === - private string _newFolderName = string.Empty; - - private void ChangeStatusOfChildren( ModFolder folder, int currentIdx, bool toWhat ) - { - var change = false; - var metaManips = false; - foreach( var _ in folder.AllMods( _modManager.Config.SortFoldersFirst ) ) - { - var (mod, _, _) = Cache.GetMod( currentIdx++ ); - if( mod != null ) - { - change |= mod.Settings.Enabled != toWhat; - mod!.Settings.Enabled = toWhat; - metaManips |= mod.Data.Resources.MetaManipulations.Count > 0; - } - } - - if( !change ) - { - return; - } - - Cache.TriggerFilterReset(); - var collection = _modManager.Collections.CurrentCollection; - if( collection.Cache != null ) - { - collection.CalculateEffectiveFileList( _modManager.TempPath, metaManips, - collection == _modManager.Collections.ActiveCollection ); - } - - collection.Save(); - } - - private void DrawRenameFolderInput( ModFolder folder ) - { - ImGui.SetNextItemWidth( 150 * ImGuiHelpers.GlobalScale ); - if( !ImGui.InputTextWithHint( "##NewFolderName", "Rename Folder...", ref _newFolderName, 64, - ImGuiInputTextFlags.EnterReturnsTrue ) ) - { - return; - } - - if( _newFolderName.Any() ) - { - folder.Rename( _newFolderName ); - } - else - { - folder.Merge( folder.Parent! ); - } - - _newFolderName = string.Empty; - } - - private void DrawFolderContextMenu( ModFolder folder, int currentIdx, string treeName ) - { - if( !ImGui.BeginPopup( treeName ) ) - { - return; - } - - using var raii = ImGuiRaii.DeferredEnd( ImGui.EndPopup ); - - if( ImGui.MenuItem( "Enable All Descendants" ) ) - { - ChangeStatusOfChildren( folder, currentIdx, true ); - } - - if( ImGui.MenuItem( "Disable All Descendants" ) ) - { - ChangeStatusOfChildren( folder, currentIdx, false ); - } - - ImGuiHelpers.ScaledDummy( 0, 10 ); - DrawRenameFolderInput( folder ); - } - } - - // Main-Interface - private partial class Selector - { - private readonly SettingsInterface _base; - private readonly ModManager _modManager; - public readonly ModListCache Cache; - - private float _selectorScalingFactor = 1; - - public Selector( SettingsInterface ui ) - { - _base = ui; - _modManager = Service< ModManager >.Get(); - Cache = new ModListCache( _modManager ); - } - - private void DrawCollectionButton( string label, string tooltipLabel, float size, ModCollection collection ) - { - if( collection == ModCollection.Empty - || collection == _modManager.Collections.CurrentCollection ) - { - using var _ = ImGuiRaii.PushStyle( ImGuiStyleVar.Alpha, 0.5f ); - ImGui.Button( label, Vector2.UnitX * size ); - } - else if( ImGui.Button( label, Vector2.UnitX * size ) ) - { - _base._menu.CollectionsTab.SetCurrentCollection( collection ); - } - - ImGuiCustom.HoverTooltip( - $"Switches to the currently set {tooltipLabel} collection, if it is not set to None and it is not the current collection already." ); - } - - private void DrawHeaderBar() - { - const float size = 200; - - DrawModsSelectorFilter(); - var textSize = ImGui.CalcTextSize( TabCollections.LabelCurrentCollection ).X + ImGui.GetStyle().ItemInnerSpacing.X; - var comboSize = size * ImGui.GetIO().FontGlobalScale; - var offset = comboSize + textSize; - - var buttonSize = Math.Max( ( ImGui.GetWindowContentRegionWidth() - - offset - - SelectorPanelWidth * _selectorScalingFactor - - 4 * ImGui.GetStyle().ItemSpacing.X ) - / 2, 5f ); - ImGui.SameLine(); - DrawCollectionButton( "Default", "default", buttonSize, _modManager.Collections.DefaultCollection ); - - ImGui.SameLine(); - DrawCollectionButton( "Forced", "forced", buttonSize, _modManager.Collections.ForcedCollection ); - - ImGui.SameLine(); - ImGui.SetNextItemWidth( comboSize ); - using var style = ImGuiRaii.PushStyle( ImGuiStyleVar.ItemSpacing, Vector2.Zero ); - _base._menu.CollectionsTab.DrawCurrentCollectionSelector( false ); - } - - private void DrawFolderContent( ModFolder folder, ref int idx ) - { - // Collection may be manipulated. - foreach( var item in folder.GetItems( _modManager.Config.SortFoldersFirst ).ToArray() ) - { - if( item is ModFolder sub ) - { - var (visible, _) = Cache.GetFolder( sub ); - if( visible ) - { - DrawModFolder( sub, ref idx ); - } - else - { - idx += sub.TotalDescendantMods(); - } - } - else if( item is ModData _ ) - { - var (mod, visible, color) = Cache.GetMod( idx ); - if( mod != null && visible ) - { - DrawMod( mod, idx++, color ); - } - else - { - ++idx; - } - } - } - } - - private void DrawModFolder( ModFolder folder, ref int idx ) - { - var treeName = $"{folder.Name}##{folder.FullName}"; - var open = ImGui.TreeNodeEx( treeName ); - using var raii = ImGuiRaii.DeferredEnd( ImGui.TreePop, open ); - if( ImGui.IsItemClicked( ImGuiMouseButton.Right ) ) - { - _newFolderName = string.Empty; - ImGui.OpenPopup( treeName ); - } - - DrawFolderContextMenu( folder, idx, treeName ); - DragDropTarget( folder ); - DragDropSourceFolder( folder ); - - if( open ) - { - DrawFolderContent( folder, ref idx ); - } - else - { - idx += folder.TotalDescendantMods(); - } - } - - private void DrawMod( Mod.Mod mod, int modIndex, uint color ) - { - using var colorRaii = ImGuiRaii.PushColor( ImGuiCol.Text, color, color != 0 ); - - var selected = ImGui.Selectable( $"{mod.Data.Meta.Name}##{modIndex}", modIndex == _index ); - colorRaii.Pop(); - - var popupName = $"##SortOrderPopup{modIndex}"; - var firstOpen = false; - if( ImGui.IsItemClicked( ImGuiMouseButton.Right ) ) - { - ImGui.OpenPopup( popupName ); - firstOpen = true; - } - - DragDropTarget( mod.Data.SortOrder.ParentFolder ); - DragDropSourceMod( modIndex, mod.Data.Meta.Name ); - - DrawModOrderPopup( popupName, mod, firstOpen ); - - if( selected ) - { - SetSelection( modIndex, mod ); - } - } - - public void Draw() - { - if( Cache.Update() ) - { - if( _nextDir.Any() ) - { - SelectModByDir( _nextDir ); - _nextDir = string.Empty; - } - else if( Mod != null ) - { - SelectModByDir( Mod.Data.BasePath.Name ); - } - } - - _selectorScalingFactor = ImGuiHelpers.GlobalScale - * ( Penumbra.Config.ScaleModSelector - ? ImGui.GetWindowWidth() / SettingsMenu.MinSettingsSize.X - : 1f ); - // Selector pane - DrawHeaderBar(); - using var style = ImGuiRaii.PushStyle( ImGuiStyleVar.ItemSpacing, Vector2.Zero ); - ImGui.BeginGroup(); - using var raii = ImGuiRaii.DeferredEnd( ImGui.EndGroup ) - .Push( ImGui.EndChild ); - // Inlay selector list - if( ImGui.BeginChild( LabelSelectorList, - new Vector2( SelectorPanelWidth * _selectorScalingFactor, -ImGui.GetFrameHeightWithSpacing() ), - true, ImGuiWindowFlags.HorizontalScrollbar ) ) - { - style.Push( ImGuiStyleVar.IndentSpacing, 12.5f ); - - var modIndex = 0; - DrawFolderContent( _modManager.StructuredMods, ref modIndex ); - style.Pop(); - } - - raii.Pop(); - - DrawModsSelectorButtons(); - - style.Pop(); - DrawModHelpPopup(); - - DrawDeleteModal(); - } - } - } -} \ No newline at end of file diff --git a/Penumbra/UI/MenuTabs/TabResourceManager.cs b/Penumbra/UI/MenuTabs/TabResourceManager.cs deleted file mode 100644 index 107bce9f..00000000 --- a/Penumbra/UI/MenuTabs/TabResourceManager.cs +++ /dev/null @@ -1,126 +0,0 @@ -using System.Numerics; -using Dalamud.Interface; -using FFXIVClientStructs.FFXIV.Client.System.Resource; -using FFXIVClientStructs.FFXIV.Client.System.Resource.Handle; -using FFXIVClientStructs.STD; -using ImGuiNET; -using Penumbra.UI.Custom; - -namespace Penumbra.UI -{ - public partial class SettingsInterface - { - private static string GetNodeLabel( string label, uint type, ulong count ) - { - var byte1 = type >> 24; - var byte2 = ( type >> 16 ) & 0xFF; - var byte3 = ( type >> 8 ) & 0xFF; - var byte4 = type & 0xFF; - return byte1 == 0 - ? $"{( char )byte2}{( char )byte3}{( char )byte4} - {count}###{label}{type}Debug" - : $"{( char )byte1}{( char )byte2}{( char )byte3}{( char )byte4} - {count}###{label}{type}Debug"; - } - - private unsafe void DrawResourceMap( string label, StdMap< uint, Pointer< ResourceHandle > >* typeMap ) - { - if( typeMap == null || !ImGui.TreeNodeEx( label ) ) - { - return; - } - - using var raii = ImGuiRaii.DeferredEnd( ImGui.TreePop ); - - if( typeMap->Count == 0 || !ImGui.BeginTable( $"##{label}_table", 4, ImGuiTableFlags.SizingFixedFit | ImGuiTableFlags.RowBg ) ) - { - return; - } - - raii.Push( ImGui.EndTable ); - - ImGui.TableSetupColumn( "Hash", ImGuiTableColumnFlags.WidthFixed, 100 * ImGuiHelpers.GlobalScale ); - ImGui.TableSetupColumn( "Ptr", ImGuiTableColumnFlags.WidthFixed, 100 * ImGuiHelpers.GlobalScale ); - ImGui.TableSetupColumn( "Path", ImGuiTableColumnFlags.WidthFixed, ImGui.GetWindowContentRegionWidth() - 300 * ImGuiHelpers.GlobalScale ); - ImGui.TableSetupColumn( "Refs", ImGuiTableColumnFlags.WidthFixed, 30 * ImGuiHelpers.GlobalScale ); - ImGui.TableHeadersRow(); - - var node = typeMap->SmallestValue; - while( !node->IsNil ) - { - ImGui.TableNextRow(); - ImGui.TableNextColumn(); - ImGui.Text( node->KeyValuePair.Item1.ToString() ); - ImGui.TableNextColumn(); - var address = $"0x{( ulong )node->KeyValuePair.Item2.Value:X}"; - ImGui.Text( address ); - if( ImGui.IsItemClicked() ) - { - ImGui.SetClipboardText( address ); - } - - ImGui.TableNextColumn(); - ImGui.Text( node->KeyValuePair.Item2.Value->FileName.ToString() ); - ImGui.TableNextColumn(); - ImGui.Text( node->KeyValuePair.Item2.Value->RefCount.ToString() ); - node = node->Next(); - } - } - - private unsafe void DrawCategoryContainer( string label, ResourceGraph.CategoryContainer container ) - { - var map = container.MainMap; - if( map == null || !ImGui.TreeNodeEx( $"{label} - {map->Count}###{label}Debug" ) ) - { - return; - } - - using var raii = ImGuiRaii.DeferredEnd( ImGui.TreePop ); - - var node = map->SmallestValue; - while( !node->IsNil ) - { - DrawResourceMap( GetNodeLabel( label, node->KeyValuePair.Item1, node->KeyValuePair.Item2.Value->Count ), - node->KeyValuePair.Item2.Value ); - node = node->Next(); - } - } - - private unsafe void DrawResourceManagerTab() - { - if( !ImGui.BeginTabItem( "Resource Manager Tab" ) ) - { - return; - } - - using var raii = ImGuiRaii.DeferredEnd( ImGui.EndTabItem ); - - var resourceHandler = *( ResourceManager** )( Dalamud.SigScanner.Module.BaseAddress + 0x1D93AC0 ); - - if( resourceHandler == null ) - { - return; - } - - raii.Push( ImGui.EndChild ); - if( !ImGui.BeginChild( "##ResourceManagerChild", -Vector2.One, true ) ) - { - return; - } - - DrawCategoryContainer( "Common", resourceHandler->ResourceGraph->CommonContainer ); - DrawCategoryContainer( "BgCommon", resourceHandler->ResourceGraph->BgCommonContainer ); - DrawCategoryContainer( "Bg", resourceHandler->ResourceGraph->BgContainer ); - DrawCategoryContainer( "Cut", resourceHandler->ResourceGraph->CutContainer ); - DrawCategoryContainer( "Chara", resourceHandler->ResourceGraph->CharaContainer ); - DrawCategoryContainer( "Shader", resourceHandler->ResourceGraph->ShaderContainer ); - DrawCategoryContainer( "Ui", resourceHandler->ResourceGraph->UiContainer ); - DrawCategoryContainer( "Sound", resourceHandler->ResourceGraph->SoundContainer ); - DrawCategoryContainer( "Vfx", resourceHandler->ResourceGraph->VfxContainer ); - DrawCategoryContainer( "UiScript", resourceHandler->ResourceGraph->UiScriptContainer ); - DrawCategoryContainer( "Exd", resourceHandler->ResourceGraph->ExdContainer ); - DrawCategoryContainer( "GameScript", resourceHandler->ResourceGraph->GameScriptContainer ); - DrawCategoryContainer( "Music", resourceHandler->ResourceGraph->MusicContainer ); - DrawCategoryContainer( "SqpackTest", resourceHandler->ResourceGraph->SqpackTestContainer ); - DrawCategoryContainer( "Debug", resourceHandler->ResourceGraph->DebugContainer ); - } - } -} \ No newline at end of file diff --git a/Penumbra/UI/MenuTabs/TabSettings.cs b/Penumbra/UI/MenuTabs/TabSettings.cs deleted file mode 100644 index b52eeb1e..00000000 --- a/Penumbra/UI/MenuTabs/TabSettings.cs +++ /dev/null @@ -1,327 +0,0 @@ -using System; -using System.Diagnostics; -using System.IO; -using System.Numerics; -using System.Text.RegularExpressions; -using Dalamud.Interface; -using Dalamud.Logging; -using ImGuiNET; -using Penumbra.GameData.Enums; -using Penumbra.Interop; -using Penumbra.Mods; -using Penumbra.UI.Custom; -using Penumbra.Util; - -namespace Penumbra.UI -{ - public partial class SettingsInterface - { - private class TabSettings - { - private const string LabelTab = "Settings"; - private const string LabelRootFolder = "Root Folder"; - private const string LabelTempFolder = "Temporary Folder"; - private const string LabelRediscoverButton = "Rediscover Mods"; - private const string LabelOpenFolder = "Open Mods Folder"; - private const string LabelOpenTempFolder = "Open Temporary Folder"; - private const string LabelEnabled = "Enable Mods"; - private const string LabelEnabledPlayerWatch = "Enable automatic Character Redraws"; - private const string LabelWaitFrames = "Wait Frames"; - private const string LabelSortFoldersFirst = "Sort Mod Folders Before Mods"; - private const string LabelScaleModSelector = "Scale Mod Selector With Window Size"; - private const string LabelShowAdvanced = "Show Advanced Settings"; - private const string LabelLogLoadedFiles = "Log all loaded files"; - private const string LabelDisableNotifications = "Disable filesystem change notifications"; - private const string LabelEnableHttpApi = "Enable HTTP API"; - private const string LabelReloadResource = "Reload Player Resource"; - - private readonly SettingsInterface _base; - private readonly Configuration _config; - private bool _configChanged; - - public TabSettings( SettingsInterface ui ) - { - _base = ui; - _config = Penumbra.Config; - _configChanged = false; - } - - private static bool DrawPressEnterWarning( float? width = null ) - { - const uint red = 0xFF202080; - using var color = ImGuiRaii.PushColor( ImGuiCol.Button, red ) - .Push( ImGuiCol.ButtonActive, red ) - .Push( ImGuiCol.ButtonHovered, red ); - var w = Vector2.UnitX * ( width ?? ImGui.CalcItemWidth() ); - return ImGui.Button( "Press Enter to Save", w ); - } - - private void DrawRootFolder() - { - var basePath = _config.ModDirectory; - var save = ImGui.InputText( LabelRootFolder, ref basePath, 255, ImGuiInputTextFlags.EnterReturnsTrue ); - if( _config.ModDirectory == basePath ) - { - return; - } - - if( save || DrawPressEnterWarning() ) - { - _base._menu.InstalledTab.Selector.ClearSelection(); - _base._modManager.DiscoverMods( basePath ); - _base._menu.InstalledTab.Selector.Cache.TriggerListReset(); - } - } - - private void DrawTempFolder() - { - var tempPath = _config.TempDirectory; - ImGui.SetNextItemWidth( 400 * ImGuiHelpers.GlobalScale ); - ImGui.BeginGroup(); - var save = ImGui.InputText( LabelTempFolder, ref tempPath, 255, ImGuiInputTextFlags.EnterReturnsTrue ); - - ImGuiCustom.HoverTooltip( "The folder used to store temporary meta manipulation files.\n" - + "Leave this blank if you have no reason not to.\n" - + "A folder 'penumbrametatmp' will be created as a subdirectory to the specified directory.\n" - + "If none is specified (i.e. this is blank) this folder will be created in the root folder instead." ); - - ImGui.SameLine(); - if( ImGui.Button( LabelOpenTempFolder ) ) - { - if( !Directory.Exists( _base._modManager.TempPath.FullName ) || !_base._modManager.TempWritable ) - { - return; - } - - Process.Start( new ProcessStartInfo( _base._modManager.TempPath.FullName ) - { - UseShellExecute = true, - } ); - } - - ImGui.EndGroup(); - if( tempPath == _config.TempDirectory ) - { - return; - } - - if( save || DrawPressEnterWarning( 400 ) ) - { - _base._modManager.SetTempDirectory( tempPath ); - } - } - - private void DrawRediscoverButton() - { - if( ImGui.Button( LabelRediscoverButton ) ) - { - _base._menu.InstalledTab.Selector.ClearSelection(); - _base._modManager.DiscoverMods(); - _base._menu.InstalledTab.Selector.Cache.TriggerListReset(); - } - } - - private void DrawOpenModsButton() - { - if( ImGui.Button( LabelOpenFolder ) ) - { - if( !Directory.Exists( _config.ModDirectory ) || !Service< ModManager >.Get().Valid ) - { - return; - } - - Process.Start( new ProcessStartInfo( _config.ModDirectory ) - { - UseShellExecute = true, - } ); - } - } - - private void DrawEnabledBox() - { - var enabled = _config.IsEnabled; - if( ImGui.Checkbox( LabelEnabled, ref enabled ) ) - { - _config.IsEnabled = enabled; - _configChanged = true; - Service< ResidentResources >.Get().ReloadPlayerResources(); - _base._penumbra.ObjectReloader.RedrawAll( enabled ? RedrawType.WithSettings : RedrawType.WithoutSettings ); - if( _config.EnablePlayerWatch ) - { - Penumbra.PlayerWatcher.SetStatus( enabled ); - } - } - } - - private void DrawShowAdvancedBox() - { - var showAdvanced = _config.ShowAdvanced; - if( ImGui.Checkbox( LabelShowAdvanced, ref showAdvanced ) ) - { - _config.ShowAdvanced = showAdvanced; - _configChanged = true; - } - } - - private void DrawSortFoldersFirstBox() - { - var foldersFirst = _config.SortFoldersFirst; - if( ImGui.Checkbox( LabelSortFoldersFirst, ref foldersFirst ) ) - { - _config.SortFoldersFirst = foldersFirst; - _base._menu.InstalledTab.Selector.Cache.TriggerListReset(); - _configChanged = true; - } - } - - private void DrawScaleModSelectorBox() - { - var scaleModSelector = _config.ScaleModSelector; - if( ImGui.Checkbox( LabelScaleModSelector, ref scaleModSelector ) ) - { - _config.ScaleModSelector = scaleModSelector; - _configChanged = true; - } - } - - private void DrawLogLoadedFilesBox() - { - ImGui.Checkbox( LabelLogLoadedFiles, ref _base._penumbra.ResourceLoader.LogAllFiles ); - ImGui.SameLine(); - var regex = _base._penumbra.ResourceLoader.LogFileFilter?.ToString() ?? string.Empty; - var tmp = regex; - if( ImGui.InputTextWithHint( "##LogFilter", "Matching this Regex...", ref tmp, 64 ) && tmp != regex ) - { - try - { - var newRegex = tmp.Length > 0 ? new Regex( tmp, RegexOptions.Compiled ) : null; - _base._penumbra.ResourceLoader.LogFileFilter = newRegex; - } - catch( Exception e ) - { - PluginLog.Debug( "Could not create regex:\n{Exception}", e ); - } - } - } - - private void DrawDisableNotificationsBox() - { - var fsWatch = _config.DisableFileSystemNotifications; - if( ImGui.Checkbox( LabelDisableNotifications, ref fsWatch ) ) - { - _config.DisableFileSystemNotifications = fsWatch; - _configChanged = true; - } - } - - private void DrawEnableHttpApiBox() - { - var http = _config.EnableHttpApi; - if( ImGui.Checkbox( LabelEnableHttpApi, ref http ) ) - { - if( http ) - { - _base._penumbra.CreateWebServer(); - } - else - { - _base._penumbra.ShutdownWebServer(); - } - - _config.EnableHttpApi = http; - _configChanged = true; - } - } - - private void DrawEnabledPlayerWatcher() - { - var enabled = _config.EnablePlayerWatch; - if( ImGui.Checkbox( LabelEnabledPlayerWatch, ref enabled ) ) - { - _config.EnablePlayerWatch = enabled; - _configChanged = true; - Penumbra.PlayerWatcher.SetStatus( enabled ); - } - - ImGuiCustom.HoverTooltip( - "If this setting is enabled, penumbra will keep tabs on characters that have a corresponding collection setup in the Collections tab.\n" - + "Penumbra will try to automatically redraw those characters using their collection when they first appear in an instance, or when they change their current equip." ); - - if( !_config.EnablePlayerWatch || !_config.ShowAdvanced ) - { - return; - } - - var waitFrames = _config.WaitFrames; - ImGui.SameLine(); - ImGui.SetNextItemWidth( 50 ); - if( ImGui.InputInt( LabelWaitFrames, ref waitFrames, 0, 0 ) - && waitFrames != _config.WaitFrames - && waitFrames > 0 - && waitFrames < 3000 ) - { - _base._penumbra.ObjectReloader.DefaultWaitFrames = waitFrames; - _config.WaitFrames = waitFrames; - _configChanged = true; - } - - ImGuiCustom.HoverTooltip( - "The number of frames penumbra waits after some events (like zone changes) until it starts trying to redraw actors again, in a range of [1, 3001].\n" - + "Keep this as low as possible while producing stable results." ); - } - - private static void DrawReloadResourceButton() - { - if( ImGui.Button( LabelReloadResource ) ) - { - Service< ResidentResources >.Get().ReloadPlayerResources(); - } - } - - private void DrawAdvancedSettings() - { - DrawTempFolder(); - DrawLogLoadedFilesBox(); - DrawDisableNotificationsBox(); - DrawEnableHttpApiBox(); - DrawReloadResourceButton(); - } - - public void Draw() - { - if( !ImGui.BeginTabItem( LabelTab ) ) - { - return; - } - - using var raii = ImGuiRaii.DeferredEnd( ImGui.EndTabItem ); - - DrawRootFolder(); - - DrawRediscoverButton(); - ImGui.SameLine(); - DrawOpenModsButton(); - - ImGuiCustom.VerticalDistance( DefaultVerticalSpace ); - DrawEnabledBox(); - DrawEnabledPlayerWatcher(); - - ImGuiCustom.VerticalDistance( DefaultVerticalSpace ); - DrawScaleModSelectorBox(); - DrawSortFoldersFirstBox(); - DrawShowAdvancedBox(); - - if( _config.ShowAdvanced ) - { - DrawAdvancedSettings(); - } - - if( _configChanged ) - { - _config.Save(); - _configChanged = false; - } - } - } - } -} \ No newline at end of file diff --git a/Penumbra/UI/ModsTab/DescriptionEditPopup.cs b/Penumbra/UI/ModsTab/DescriptionEditPopup.cs new file mode 100644 index 00000000..7d7a6967 --- /dev/null +++ b/Penumbra/UI/ModsTab/DescriptionEditPopup.cs @@ -0,0 +1,114 @@ +using Dalamud.Interface.Utility; +using Dalamud.Bindings.ImGui; +using OtterGui.Services; +using OtterGui.Text; +using Penumbra.Mods; +using Penumbra.Mods.Groups; +using Penumbra.Mods.Manager; +using Penumbra.Mods.SubMods; + +namespace Penumbra.UI.ModsTab; + +public class DescriptionEditPopup(ModManager modManager) : IUiService +{ + private static ReadOnlySpan PopupId + => "PenumbraEditDescription"u8; + + private bool _hasBeenEdited; + private string _description = string.Empty; + + private object? _current; + private bool _opened; + + public void Open(Mod mod) + { + _current = mod; + _opened = true; + _hasBeenEdited = false; + _description = mod.Description; + } + + public void Open(IModGroup group) + { + _current = group; + _opened = true; + _hasBeenEdited = false; + _description = group.Description; + } + + public void Open(IModOption option) + { + _current = option; + _opened = true; + _hasBeenEdited = false; + _description = option.Description; + } + + public void Draw() + { + if (_current == null) + return; + + if (_opened) + { + _opened = false; + ImUtf8.OpenPopup(PopupId); + } + + var inputSize = ImGuiHelpers.ScaledVector2(800); + using var popup = ImUtf8.Popup(PopupId); + if (!popup) + return; + + if (ImGui.IsWindowAppearing()) + ImGui.SetKeyboardFocusHere(); + + ImUtf8.InputMultiLineOnDeactivated("##editDescription"u8, ref _description, inputSize); + _hasBeenEdited |= ImGui.IsItemEdited(); + UiHelpers.DefaultLineSpace(); + + var buttonSize = new Vector2(ImUtf8.GlobalScale * 100, 0); + + var width = 2 * buttonSize.X + + 4 * ImUtf8.FramePadding.X + + ImUtf8.ItemSpacing.X; + + ImGui.SetCursorPosX((inputSize.X - width) / 2); + DrawSaveButton(buttonSize); + ImGui.SameLine(); + DrawCancelButton(buttonSize); + } + + private void DrawSaveButton(Vector2 buttonSize) + { + if (!ImUtf8.ButtonEx("Save"u8, _hasBeenEdited ? [] : "No changes made yet."u8, buttonSize, !_hasBeenEdited)) + return; + + switch (_current) + { + case Mod mod: + modManager.DataEditor.ChangeModDescription(mod, _description); + break; + case IModGroup group: + modManager.OptionEditor.ChangeGroupDescription(group, _description); + break; + case IModOption option: + modManager.OptionEditor.ChangeOptionDescription(option, _description); + break; + } + + _description = string.Empty; + _hasBeenEdited = false; + ImGui.CloseCurrentPopup(); + } + + private void DrawCancelButton(Vector2 buttonSize) + { + if (!ImUtf8.Button("Cancel"u8, buttonSize) && !ImGui.IsKeyPressed(ImGuiKey.Escape)) + return; + + _description = string.Empty; + _hasBeenEdited = false; + ImGui.CloseCurrentPopup(); + } +} diff --git a/Penumbra/UI/ModsTab/Groups/AddGroupDrawer.cs b/Penumbra/UI/ModsTab/Groups/AddGroupDrawer.cs new file mode 100644 index 00000000..1430f17b --- /dev/null +++ b/Penumbra/UI/ModsTab/Groups/AddGroupDrawer.cs @@ -0,0 +1,159 @@ +using Dalamud.Bindings.ImGui; +using OtterGui.Services; +using OtterGui.Text; +using Penumbra.Api.Enums; +using Penumbra.GameData.Enums; +using Penumbra.GameData.Structs; +using Penumbra.Meta; +using Penumbra.Meta.Manipulations; +using Penumbra.Mods; +using Penumbra.Mods.Manager; +using Penumbra.Mods.Manager.OptionEditor; +using Penumbra.UI.AdvancedWindow.Meta; +using Penumbra.UI.Classes; + +namespace Penumbra.UI.ModsTab.Groups; + +public class AddGroupDrawer : IUiService +{ + private string _groupName = string.Empty; + private bool _groupNameValid; + + private ImcIdentifier _imcIdentifier = ImcIdentifier.Default; + private ImcEntry _defaultEntry; + private bool _imcFileExists; + private bool _entryExists; + private bool _entryInvalid; + private readonly ModManager _modManager; + + public AddGroupDrawer(ModManager modManager) + { + _modManager = modManager; + UpdateEntry(); + } + + public void Draw(Mod mod, float width) + { + var buttonWidth = new Vector2((width - ImUtf8.ItemInnerSpacing.X) / 2, 0); + DrawBasicGroups(mod, width, buttonWidth); + DrawImcData(mod, buttonWidth); + } + + private void DrawBasicGroups(Mod mod, float width, Vector2 buttonWidth) + { + ImGui.SetNextItemWidth(width); + if (ImUtf8.InputText("##name"u8, ref _groupName, "Enter New Name..."u8)) + _groupNameValid = ModGroupEditor.VerifyFileName(mod, null, _groupName, false); + + DrawSingleGroupButton(mod, buttonWidth); + ImUtf8.SameLineInner(); + DrawMultiGroupButton(mod, buttonWidth); + DrawCombiningGroupButton(mod, buttonWidth); + } + + private void DrawSingleGroupButton(Mod mod, Vector2 width) + { + if (!ImUtf8.ButtonEx("Add Single Group"u8, _groupNameValid + ? "Add a new single selection option group to this mod."u8 + : "Can not add a new group of this name."u8, + width, !_groupNameValid)) + return; + + _modManager.OptionEditor.AddModGroup(mod, GroupType.Single, _groupName); + _groupName = string.Empty; + _groupNameValid = false; + } + + private void DrawMultiGroupButton(Mod mod, Vector2 width) + { + if (!ImUtf8.ButtonEx("Add Multi Group"u8, _groupNameValid + ? "Add a new multi selection option group to this mod."u8 + : "Can not add a new group of this name."u8, + width, !_groupNameValid)) + return; + + _modManager.OptionEditor.AddModGroup(mod, GroupType.Multi, _groupName); + _groupName = string.Empty; + _groupNameValid = false; + } + + private void DrawCombiningGroupButton(Mod mod, Vector2 width) + { + if (!ImUtf8.ButtonEx("Add Combining Group"u8, _groupNameValid + ? "Add a new combining option group to this mod."u8 + : "Can not add a new group of this name."u8, + width, !_groupNameValid)) + return; + + _modManager.OptionEditor.AddModGroup(mod, GroupType.Combining, _groupName); + _groupName = string.Empty; + _groupNameValid = false; + } + private void DrawImcInput(float width) + { + var change = ImcMetaDrawer.DrawObjectType(ref _imcIdentifier, width); + ImUtf8.SameLineInner(); + change |= ImcMetaDrawer.DrawPrimaryId(ref _imcIdentifier, width); + if (_imcIdentifier.ObjectType is ObjectType.Weapon or ObjectType.Monster) + { + change |= ImcMetaDrawer.DrawSecondaryId(ref _imcIdentifier, width); + ImUtf8.SameLineInner(); + change |= ImcMetaDrawer.DrawVariant(ref _imcIdentifier, width); + } + else if (_imcIdentifier.ObjectType is ObjectType.DemiHuman) + { + var quarterWidth = (width - ImUtf8.ItemInnerSpacing.X / ImUtf8.GlobalScale) / 2; + change |= ImcMetaDrawer.DrawSecondaryId(ref _imcIdentifier, width); + ImUtf8.SameLineInner(); + change |= ImcMetaDrawer.DrawSlot(ref _imcIdentifier, quarterWidth); + ImUtf8.SameLineInner(); + change |= ImcMetaDrawer.DrawVariant(ref _imcIdentifier, quarterWidth); + } + else + { + change |= ImcMetaDrawer.DrawSlot(ref _imcIdentifier, width); + ImUtf8.SameLineInner(); + change |= ImcMetaDrawer.DrawVariant(ref _imcIdentifier, width); + } + + if (change) + UpdateEntry(); + } + + private void DrawImcData(Mod mod, Vector2 width) + { + var halfWidth = width.X / ImUtf8.GlobalScale; + DrawImcInput(halfWidth); + DrawImcButton(mod, width); + } + + private void DrawImcButton(Mod mod, Vector2 width) + { + if (ImUtf8.ButtonEx("Add IMC Group"u8, !_groupNameValid + ? "Can not add a new group of this name."u8 + : _entryInvalid + ? "The associated IMC entry is invalid."u8 + : "Add a new multi selection option group to this mod."u8, + width, !_groupNameValid || _entryInvalid)) + { + _modManager.OptionEditor.ImcEditor.AddModGroup(mod, _groupName, _imcIdentifier, _defaultEntry); + _groupName = string.Empty; + _groupNameValid = false; + } + + if (_entryInvalid) + { + ImUtf8.SameLineInner(); + var text = _imcFileExists + ? "IMC Entry Does Not Exist"u8 + : "IMC File Does Not Exist"u8; + ImUtf8.TextFramed(text, Colors.PressEnterWarningBg, width); + } + } + + private void UpdateEntry() + { + (_defaultEntry, _imcFileExists, _entryExists) = ImcChecker.GetDefaultEntry(_imcIdentifier, false); + _entryInvalid = !_imcIdentifier.Validate() || _defaultEntry.MaterialId == 0 || !_entryExists; + } +} diff --git a/Penumbra/UI/ModsTab/Groups/CombiningModGroupEditDrawer.cs b/Penumbra/UI/ModsTab/Groups/CombiningModGroupEditDrawer.cs new file mode 100644 index 00000000..e9840e6c --- /dev/null +++ b/Penumbra/UI/ModsTab/Groups/CombiningModGroupEditDrawer.cs @@ -0,0 +1,112 @@ +using Dalamud.Interface; +using Dalamud.Bindings.ImGui; +using OtterGui; +using OtterGui.Extensions; +using OtterGui.Raii; +using OtterGui.Text; +using Penumbra.Mods.Groups; +using Penumbra.Mods.SubMods; + +namespace Penumbra.UI.ModsTab.Groups; + +public readonly struct CombiningModGroupEditDrawer(ModGroupEditDrawer editor, CombiningModGroup group) : IModGroupEditDrawer +{ + public void Draw() + { + foreach (var (option, optionIdx) in group.OptionData.WithIndex()) + { + using var id = ImUtf8.PushId(optionIdx); + editor.DrawOptionPosition(group, option, optionIdx); + + ImUtf8.SameLineInner(); + editor.DrawOptionDefaultMultiBehaviour(group, option, optionIdx); + + ImUtf8.SameLineInner(); + editor.DrawOptionName(option); + + ImUtf8.SameLineInner(); + editor.DrawOptionDescription(option); + + ImUtf8.SameLineInner(); + editor.DrawOptionDelete(option); + } + + DrawNewOption(); + DrawContainerNames(); + } + + private void DrawNewOption() + { + var count = group.OptionData.Count; + if (count >= IModGroup.MaxCombiningOptions) + return; + + var name = editor.DrawNewOptionBase(group, count); + + var validName = name.Length > 0; + if (ImUtf8.IconButton(FontAwesomeIcon.Plus, validName + ? "Add a new option to this group."u8 + : "Please enter a name for the new option."u8, default, !validName)) + { + editor.ModManager.OptionEditor.CombiningEditor.AddOption(group, name); + editor.NewOptionName = null; + } + } + + private unsafe void DrawContainerNames() + { + if (ImUtf8.ButtonEx("Edit Container Names"u8, + "Add optional names to separate data containers of the combining group.\nThose are just for easier identification while editing the mod, and are not generally displayed to the user."u8, + new Vector2(400 * ImUtf8.GlobalScale, 0))) + ImUtf8.OpenPopup("DataContainerNames"u8); + + var sizeX = group.OptionData.Count * (ImGui.GetStyle().ItemInnerSpacing.X + ImGui.GetFrameHeight()) + 300 * ImUtf8.GlobalScale; + ImGui.SetNextWindowSize(new Vector2(sizeX, ImGui.GetFrameHeightWithSpacing() * Math.Min(16, group.Data.Count) + 200 * ImUtf8.GlobalScale)); + using var popup = ImUtf8.Popup("DataContainerNames"u8); + if (!popup) + return; + + foreach (var option in group.OptionData) + { + ImUtf8.RotatedText(option.Name, true); + ImUtf8.SameLineInner(); + } + + ImGui.NewLine(); + ImGui.Separator(); + using var child = ImUtf8.Child("##Child"u8, ImGui.GetContentRegionAvail()); + ImGuiClip.ClippedDraw(group.Data, DrawRow, ImGui.GetFrameHeightWithSpacing()); + } + + private void DrawRow(CombinedDataContainer container, int index) + { + using var id = ImUtf8.PushId(index); + using (ImRaii.Disabled()) + { + for (var i = 0; i < group.OptionData.Count; ++i) + { + id.Push(i); + var check = (index & (1 << i)) != 0; + ImUtf8.Checkbox(""u8, ref check); + ImUtf8.SameLineInner(); + id.Pop(); + } + } + + var name = editor.CombiningDisplayIndex == index ? editor.CombiningDisplayName ?? container.Name : container.Name; + if (ImUtf8.InputText("##Nothing"u8, ref name, "Optional Display Name..."u8)) + { + editor.CombiningDisplayIndex = index; + editor.CombiningDisplayName = name; + } + + if (ImGui.IsItemDeactivatedAfterEdit()) + editor.ModManager.OptionEditor.CombiningEditor.SetDisplayName(container, name); + + if (ImGui.IsItemDeactivated()) + { + editor.CombiningDisplayIndex = -1; + editor.CombiningDisplayName = null; + } + } +} diff --git a/Penumbra/UI/ModsTab/Groups/IModGroupEditDrawer.cs b/Penumbra/UI/ModsTab/Groups/IModGroupEditDrawer.cs new file mode 100644 index 00000000..d7114147 --- /dev/null +++ b/Penumbra/UI/ModsTab/Groups/IModGroupEditDrawer.cs @@ -0,0 +1,6 @@ +namespace Penumbra.UI.ModsTab.Groups; + +public interface IModGroupEditDrawer +{ + public void Draw(); +} diff --git a/Penumbra/UI/ModsTab/Groups/ImcModGroupEditDrawer.cs b/Penumbra/UI/ModsTab/Groups/ImcModGroupEditDrawer.cs new file mode 100644 index 00000000..fa5b0ef6 --- /dev/null +++ b/Penumbra/UI/ModsTab/Groups/ImcModGroupEditDrawer.cs @@ -0,0 +1,188 @@ +using Dalamud.Interface; +using Dalamud.Bindings.ImGui; +using OtterGui.Extensions; +using OtterGui.Raii; +using OtterGui.Text; +using OtterGui.Text.Widget; +using OtterGuiInternal.Utility; +using Penumbra.GameData.Structs; +using Penumbra.Meta; +using Penumbra.Mods.Groups; +using Penumbra.Mods.Manager.OptionEditor; +using Penumbra.Mods.SubMods; +using Penumbra.UI.AdvancedWindow.Meta; + +namespace Penumbra.UI.ModsTab.Groups; + +public readonly struct ImcModGroupEditDrawer(ModGroupEditDrawer editor, ImcModGroup group) : IModGroupEditDrawer +{ + public void Draw() + { + var identifier = group.Identifier; + var defaultEntry = ImcChecker.GetDefaultEntry(identifier, true).Entry; + var entry = group.DefaultEntry; + var changes = false; + + var width = editor.AvailableWidth.X - 3 * ImUtf8.ItemInnerSpacing.X - ImUtf8.ItemSpacing.X - ImUtf8.CalcTextSize("All Variants"u8).X - ImUtf8.CalcTextSize("Only Attributes"u8).X - 2 * ImUtf8.FrameHeight; + ImUtf8.TextFramed(identifier.ToString(), 0, new Vector2(width, 0), borderColor: ImGui.GetColorU32(ImGuiCol.Border)); + + ImUtf8.SameLineInner(); + var allVariants = group.AllVariants; + if (ImUtf8.Checkbox("All Variants"u8, ref allVariants)) + editor.ModManager.OptionEditor.ImcEditor.ChangeAllVariants(group, allVariants); + ImUtf8.HoverTooltip("Make this group overwrite all corresponding variants for this identifier, not just the one specified."u8); + + ImGui.SameLine(); + var onlyAttributes = group.OnlyAttributes; + if (ImUtf8.Checkbox("Only Attributes"u8, ref onlyAttributes)) + editor.ModManager.OptionEditor.ImcEditor.ChangeOnlyAttributes(group, onlyAttributes); + ImUtf8.HoverTooltip("Only overwrite the attribute flags and take all the other values from the game's default entry instead of the one configured here.\n\nMainly useful if used with All Variants to keep the material IDs for each variant."u8); + + using (ImUtf8.Group()) + { + ImUtf8.TextFrameAligned("Material ID"u8); + ImUtf8.TextFrameAligned("VFX ID"u8); + ImUtf8.TextFrameAligned("Decal ID"u8); + } + + ImGui.SameLine(); + using (ImUtf8.Group()) + { + changes |= ImcMetaDrawer.DrawMaterialId(defaultEntry, ref entry, true); + changes |= ImcMetaDrawer.DrawVfxId(defaultEntry, ref entry, true); + changes |= ImcMetaDrawer.DrawDecalId(defaultEntry, ref entry, true); + } + + ImGui.SameLine(0, editor.PriorityWidth); + using (ImUtf8.Group()) + { + ImUtf8.TextFrameAligned("Material Animation ID"u8); + ImUtf8.TextFrameAligned("Sound ID"u8); + ImUtf8.TextFrameAligned("Can Be Disabled"u8); + } + + ImGui.SameLine(); + + using (ImUtf8.Group()) + { + changes |= ImcMetaDrawer.DrawMaterialAnimationId(defaultEntry, ref entry, true); + changes |= ImcMetaDrawer.DrawSoundId(defaultEntry, ref entry, true); + var canBeDisabled = group.CanBeDisabled; + if (ImUtf8.Checkbox("##disabled"u8, ref canBeDisabled)) + editor.ModManager.OptionEditor.ImcEditor.ChangeCanBeDisabled(group, canBeDisabled); + } + + if (changes) + editor.ModManager.OptionEditor.ImcEditor.ChangeDefaultEntry(group, entry); + + ImGui.Dummy(Vector2.Zero); + DrawOptions(); + var attributeCache = new ImcAttributeCache(group); + DrawNewOption(attributeCache); + ImGui.Dummy(Vector2.Zero); + + + using (ImUtf8.Group()) + { + ImUtf8.TextFrameAligned("Default Attributes"u8); + foreach (var option in group.OptionData.Where(o => !o.IsDisableSubMod)) + ImUtf8.TextFrameAligned(option.Name); + } + + ImUtf8.SameLineInner(); + using (ImUtf8.Group()) + { + DrawAttributes(editor.ModManager.OptionEditor.ImcEditor, attributeCache, group.DefaultEntry.AttributeMask, group); + foreach (var (option, idx) in group.OptionData.WithIndex().Where(o => !o.Value.IsDisableSubMod)) + { + using var id = ImUtf8.PushId(idx); + DrawAttributes(editor.ModManager.OptionEditor.ImcEditor, attributeCache, option.AttributeMask, option, + group.DefaultEntry.AttributeMask); + } + } + } + + private void DrawOptions() + { + foreach (var (option, optionIdx) in group.OptionData.WithIndex()) + { + using var id = ImRaii.PushId(optionIdx); + editor.DrawOptionPosition(group, option, optionIdx); + + ImUtf8.SameLineInner(); + editor.DrawOptionDefaultMultiBehaviour(group, option, optionIdx); + + ImUtf8.SameLineInner(); + editor.DrawOptionName(option); + + ImUtf8.SameLineInner(); + editor.DrawOptionDescription(option); + + if (!option.IsDisableSubMod) + { + ImUtf8.SameLineInner(); + editor.DrawOptionDelete(option); + } + } + } + + private void DrawNewOption(in ImcAttributeCache cache) + { + var dis = cache.LowestUnsetMask == 0; + var name = editor.DrawNewOptionBase(group, group.Options.Count); + var validName = name.Length > 0; + var tt = dis + ? "No Free Attribute Slots for New Options..."u8 + : validName + ? "Add a new option to this group."u8 + : "Please enter a name for the new option."u8; + if (ImUtf8.IconButton(FontAwesomeIcon.Plus, tt, default, !validName || dis)) + { + editor.ModManager.OptionEditor.ImcEditor.AddOption(group, cache, name); + editor.NewOptionName = null; + } + } + + private static void DrawAttributes(ImcModGroupEditor editor, in ImcAttributeCache cache, ushort mask, object data, + ushort? defaultMask = null) + { + for (var i = 0; i < ImcEntry.NumAttributes; ++i) + { + using var id = ImRaii.PushId(i); + var flag = 1 << i; + var value = (mask & flag) != 0; + var inDefault = defaultMask.HasValue && (defaultMask & flag) != 0; + using (ImRaii.Disabled(defaultMask != null && !cache.CanChange(i))) + { + if (inDefault ? NegativeCheckbox.Instance.Draw(""u8, ref value) : ImUtf8.Checkbox(""u8, ref value)) + { + if (data is ImcModGroup g) + editor.ChangeDefaultAttribute(g, cache, i, value); + else + editor.ChangeOptionAttribute((ImcSubMod)data, cache, i, value); + } + } + + ImUtf8.HoverTooltip(ImGuiHoveredFlags.AllowWhenDisabled, "ABCDEFGHIJ"u8.Slice(i, 1)); + if (i != 9) + ImUtf8.SameLineInner(); + } + } + + private sealed class NegativeCheckbox : MultiStateCheckbox + { + public static readonly NegativeCheckbox Instance = new(); + + protected override void RenderSymbol(bool value, Vector2 position, float size) + { + if (value) + SymbolHelpers.RenderCross(ImGui.GetWindowDrawList(), position, ImGui.GetColorU32(ImGuiCol.CheckMark), size); + } + + protected override bool NextValue(bool value) + => !value; + + protected override bool PreviousValue(bool value) + => !value; + } +} diff --git a/Penumbra/UI/ModsTab/Groups/ModGroupDrawer.cs b/Penumbra/UI/ModsTab/Groups/ModGroupDrawer.cs new file mode 100644 index 00000000..3d8409ad --- /dev/null +++ b/Penumbra/UI/ModsTab/Groups/ModGroupDrawer.cs @@ -0,0 +1,284 @@ +using Dalamud.Interface.Components; +using Dalamud.Bindings.ImGui; +using OtterGui; +using OtterGui.Extensions; +using OtterGui.Raii; +using OtterGui.Services; +using OtterGui.Text; +using OtterGui.Widgets; +using Penumbra.Collections; +using Penumbra.Collections.Manager; +using Penumbra.Mods; +using Penumbra.Mods.Groups; +using Penumbra.Mods.Settings; +using Penumbra.Mods.SubMods; + +namespace Penumbra.UI.ModsTab.Groups; + +public sealed class ModGroupDrawer : IUiService +{ + private readonly List<(IModGroup, int)> _blockGroupCache = []; + private bool _temporary; + private bool _locked; + private TemporaryModSettings? _tempSettings; + private ModSettings? _settings; + private readonly SingleGroupCombo _combo; + private readonly Configuration _config; + private readonly CollectionManager _collectionManager; + + public ModGroupDrawer(Configuration config, CollectionManager collectionManager) + { + _config = config; + _collectionManager = collectionManager; + _combo = new SingleGroupCombo(this); + } + + private sealed class SingleGroupCombo(ModGroupDrawer parent) + : FilterComboCache(() => _group!.Options, MouseWheelType.Control, Penumbra.Log) + { + private static IModGroup? _group; + private static int _groupIdx; + + protected override bool DrawSelectable(int globalIdx, bool selected) + { + var option = _group!.Options[globalIdx]; + var ret = ImUtf8.Selectable(option.Name, globalIdx == CurrentSelectionIdx); + + if (option.Description.Length > 0) + ImUtf8.SelectableHelpMarker(option.Description); + + return ret; + } + + protected override string ToString(IModOption obj) + => obj.Name; + + public void Draw(IModGroup group, int groupIndex, int currentOption) + { + _group = group; + _groupIdx = groupIndex; + CurrentSelectionIdx = currentOption; + CurrentSelection = _group.Options[CurrentSelectionIdx]; + if (Draw(string.Empty, CurrentSelection.Name, string.Empty, ref CurrentSelectionIdx, UiHelpers.InputTextWidth.X * 3 / 4, + ImGui.GetTextLineHeightWithSpacing())) + parent.SetModSetting(_group, _groupIdx, Setting.Single(CurrentSelectionIdx)); + } + } + + public void Draw(Mod mod, ModSettings settings, TemporaryModSettings? tempSettings) + { + if (mod.Groups.Count <= 0) + return; + + _blockGroupCache.Clear(); + _settings = settings; + _tempSettings = tempSettings; + _temporary = tempSettings != null; + _locked = (tempSettings?.Lock ?? 0) > 0; + var useDummy = true; + foreach (var (group, idx) in mod.Groups.WithIndex()) + { + if (!group.IsOption) + continue; + + switch (group.Behaviour) + { + case GroupDrawBehaviour.SingleSelection when group.Options.Count <= _config.SingleGroupRadioMax: + case GroupDrawBehaviour.MultiSelection: + _blockGroupCache.Add((group, idx)); + break; + + case GroupDrawBehaviour.SingleSelection: + ImGuiUtil.Dummy(UiHelpers.DefaultSpace, useDummy); + useDummy = false; + DrawSingleGroupCombo(group, idx, settings.IsEmpty ? group.DefaultSettings : settings.Settings[idx]); + break; + } + } + + useDummy = true; + foreach (var (group, idx) in _blockGroupCache) + { + ImGuiUtil.Dummy(UiHelpers.DefaultSpace, useDummy); + useDummy = false; + var option = settings.IsEmpty ? group.DefaultSettings : settings.Settings[idx]; + if (group.Behaviour is GroupDrawBehaviour.MultiSelection) + DrawMultiGroup(group, idx, option); + else + DrawSingleGroupRadio(group, idx, option); + } + } + + /// + /// Draw a single group selector as a combo box. + /// If a description is provided, add a help marker besides it. + /// + private void DrawSingleGroupCombo(IModGroup group, int groupIdx, Setting setting) + { + using var id = ImUtf8.PushId(groupIdx); + var selectedOption = setting.AsIndex; + using var disabled = ImRaii.Disabled(_locked); + _combo.Draw(group, groupIdx, selectedOption); + ImGui.SameLine(); + if (group.Description.Length > 0) + ImUtf8.LabeledHelpMarker(group.Name, group.Description); + else + ImUtf8.Text(group.Name); + } + + /// + /// Draw a single group selector as a set of radio buttons. + /// If a description is provided, add a help marker besides it. + /// + private void DrawSingleGroupRadio(IModGroup group, int groupIdx, Setting setting) + { + using var id = ImUtf8.PushId(groupIdx); + var selectedOption = setting.AsIndex; + var minWidth = Widget.BeginFramedGroup(group.Name, group.Description); + var options = group.Options; + DrawCollapseHandling(options, minWidth, DrawOptions); + + Widget.EndFramedGroup(); + return; + + void DrawOptions() + { + using var disabled = ImRaii.Disabled(_locked); + for (var idx = 0; idx < group.Options.Count; ++idx) + { + using var i = ImUtf8.PushId(idx); + var option = options[idx]; + if (ImUtf8.RadioButton(option.Name, selectedOption == idx)) + SetModSetting(group, groupIdx, Setting.Single(idx)); + + if (option.Description.Length <= 0) + continue; + + ImGui.SameLine(); + ImGuiComponents.HelpMarker(option.Description); + } + } + } + + /// + /// Draw a multi group selector as a bordered set of checkboxes. + /// If a description is provided, add a help marker in the title. + /// + private void DrawMultiGroup(IModGroup group, int groupIdx, Setting setting) + { + using var id = ImUtf8.PushId(groupIdx); + var minWidth = Widget.BeginFramedGroup(group.Name, group.Description); + var options = group.Options; + DrawCollapseHandling(options, minWidth, DrawOptions); + + Widget.EndFramedGroup(); + var label = $"##multi{groupIdx}"; + if (ImGui.IsItemClicked(ImGuiMouseButton.Right)) + ImUtf8.OpenPopup($"##multi{groupIdx}"); + + DrawMultiPopup(group, groupIdx, label); + return; + + void DrawOptions() + { + using var disabled = ImRaii.Disabled(_locked); + for (var idx = 0; idx < options.Count; ++idx) + { + using var i = ImUtf8.PushId(idx); + var option = options[idx]; + var enabled = setting.HasFlag(idx); + + if (ImUtf8.Checkbox(option.Name, ref enabled)) + SetModSetting(group, groupIdx, setting.SetBit(idx, enabled)); + + if (option.Description.Length > 0) + { + ImGui.SameLine(); + ImGuiComponents.HelpMarker(option.Description); + } + } + } + } + + private void DrawMultiPopup(IModGroup group, int groupIdx, string label) + { + using var style = ImRaii.PushStyle(ImGuiStyleVar.PopupBorderSize, 1); + using var popup = ImRaii.Popup(label); + if (!popup) + return; + + ImGui.TextUnformatted(group.Name); + using var disabled = ImRaii.Disabled(_locked); + ImGui.Separator(); + if (ImUtf8.Selectable("Enable All"u8)) + SetModSetting(group, groupIdx, Setting.AllBits(group.Options.Count)); + + if (ImUtf8.Selectable("Disable All"u8)) + SetModSetting(group, groupIdx, Setting.Zero); + } + + private void DrawCollapseHandling(IReadOnlyList options, float minWidth, Action draw) + { + if (options.Count <= _config.OptionGroupCollapsibleMin) + { + draw(); + } + else + { + var collapseId = ImUtf8.GetId("Collapse"); + var shown = ImGui.GetStateStorage().GetBool(collapseId, true); + var buttonTextShow = $"Show {options.Count} Options"; + var buttonTextHide = $"Hide {options.Count} Options"; + var buttonWidth = Math.Max(ImUtf8.CalcTextSize(buttonTextShow).X, ImUtf8.CalcTextSize(buttonTextHide).X) + + 2 * ImGui.GetStyle().FramePadding.X; + minWidth = Math.Max(buttonWidth, minWidth); + if (shown) + { + var pos = ImGui.GetCursorPos(); + ImGui.Dummy(UiHelpers.IconButtonSize); + using (var _ = ImRaii.Group()) + { + draw(); + } + + + var width = Math.Max(ImGui.GetItemRectSize().X, minWidth); + var endPos = ImGui.GetCursorPos(); + ImGui.SetCursorPos(pos); + if (ImUtf8.Button(buttonTextHide, new Vector2(width, 0))) + ImGui.GetStateStorage().SetBool(collapseId, !shown); + + ImGui.SetCursorPos(endPos); + } + else + { + var optionWidth = options.Max(o => ImUtf8.CalcTextSize(o.Name).X) + + ImGui.GetStyle().ItemInnerSpacing.X + + ImGui.GetFrameHeight() + + ImGui.GetStyle().FramePadding.X; + var width = Math.Max(optionWidth, minWidth); + if (ImUtf8.Button(buttonTextShow, new Vector2(width, 0))) + ImGui.GetStateStorage().SetBool(collapseId, !shown); + } + } + } + + private ModCollection Current + => _collectionManager.Active.Current; + + [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] + private void SetModSetting(IModGroup group, int groupIdx, Setting setting) + { + if (_temporary || _config.DefaultTemporaryMode) + { + _tempSettings ??= new TemporaryModSettings(group.Mod, _settings); + _tempSettings!.ForceInherit = false; + _tempSettings!.Settings[groupIdx] = setting; + _collectionManager.Editor.SetTemporarySettings(Current, group.Mod, _tempSettings); + } + else + { + _collectionManager.Editor.SetModSetting(Current, group.Mod, groupIdx, setting); + } + } +} diff --git a/Penumbra/UI/ModsTab/Groups/ModGroupEditDrawer.cs b/Penumbra/UI/ModsTab/Groups/ModGroupEditDrawer.cs new file mode 100644 index 00000000..9610f173 --- /dev/null +++ b/Penumbra/UI/ModsTab/Groups/ModGroupEditDrawer.cs @@ -0,0 +1,371 @@ +using Dalamud.Interface; +using Dalamud.Interface.ImGuiNotification; +using Dalamud.Bindings.ImGui; +using OtterGui; +using OtterGui.Classes; +using OtterGui.Extensions; +using OtterGui.Raii; +using OtterGui.Services; +using OtterGui.Text; +using OtterGui.Text.EndObjects; +using Penumbra.Meta; +using Penumbra.Mods; +using Penumbra.Mods.Groups; +using Penumbra.Mods.Manager; +using Penumbra.Mods.Manager.OptionEditor; +using Penumbra.Mods.Settings; +using Penumbra.Mods.SubMods; +using Penumbra.Services; +using Penumbra.UI.Classes; + +namespace Penumbra.UI.ModsTab.Groups; + +public sealed class ModGroupEditDrawer( + ModManager modManager, + Configuration config, + FilenameService filenames, + DescriptionEditPopup descriptionPopup, + ImcChecker imcChecker) : IUiService +{ + private static ReadOnlySpan AcrossGroupsLabel + => "##DragOptionAcross"u8; + + private static ReadOnlySpan InsideGroupLabel + => "##DragOptionInside"u8; + + internal readonly ImcChecker ImcChecker = imcChecker; + internal readonly ModManager ModManager = modManager; + internal readonly Queue ActionQueue = new(); + + internal Vector2 OptionIdxSelectable; + internal Vector2 AvailableWidth; + internal float PriorityWidth; + + internal string? NewOptionName; + private IModGroup? _newOptionGroup; + + private Vector2 _buttonSize; + private float _groupNameWidth; + private float _optionNameWidth; + private float _spacing; + private bool _deleteEnabled; + + private string? _currentGroupName; + private ModPriority? _currentGroupPriority; + private IModGroup? _currentGroupEdited; + private bool _isGroupNameValid = true; + + private IModGroup? _dragDropGroup; + private IModOption? _dragDropOption; + private bool _draggingAcross; + + internal string? CombiningDisplayName; + internal int CombiningDisplayIndex; + + public void Draw(Mod mod) + { + PrepareStyle(); + + using var id = ImUtf8.PushId("##GroupEdit"u8); + foreach (var (group, groupIdx) in mod.Groups.WithIndex()) + DrawGroup(group, groupIdx); + + while (ActionQueue.TryDequeue(out var action)) + action.Invoke(); + } + + private void DrawGroup(IModGroup group, int idx) + { + using var id = ImUtf8.PushId(idx); + using var frame = ImRaii.FramedGroup($"Group #{idx + 1}"); + DrawGroupNameRow(group, idx); + group.EditDrawer(this).Draw(); + } + + private void DrawGroupNameRow(IModGroup group, int idx) + { + DrawGroupName(group); + ImUtf8.SameLineInner(); + DrawGroupMoveButtons(group, idx); + ImUtf8.SameLineInner(); + DrawGroupOpenFile(group, idx); + ImUtf8.SameLineInner(); + DrawGroupDescription(group); + ImUtf8.SameLineInner(); + DrawGroupDelete(group); + ImUtf8.SameLineInner(); + DrawGroupPriority(group); + } + + private void DrawGroupName(IModGroup group) + { + var text = _currentGroupEdited == group ? _currentGroupName ?? group.Name : group.Name; + ImGui.SetNextItemWidth(_groupNameWidth); + using var border = ImRaii.PushFrameBorder(UiHelpers.ScaleX2, Colors.RegexWarningBorder, !_isGroupNameValid); + if (ImUtf8.InputText("##GroupName"u8, ref text)) + { + _currentGroupEdited = group; + _currentGroupName = text; + _isGroupNameValid = text == group.Name || ModGroupEditor.VerifyFileName(group.Mod, group, text, false); + } + + if (ImGui.IsItemDeactivated()) + { + if (_currentGroupName != null && _isGroupNameValid) + ModManager.OptionEditor.RenameModGroup(group, _currentGroupName); + _currentGroupName = null; + _currentGroupEdited = null; + _isGroupNameValid = true; + } + + var tt = _isGroupNameValid + ? "Change the Group name."u8 + : "Current name can not be used for this group."u8; + ImUtf8.HoverTooltip(ImGuiHoveredFlags.AllowWhenDisabled, tt); + } + + private void DrawGroupDelete(IModGroup group) + { + if (ImUtf8.IconButton(FontAwesomeIcon.Trash, !_deleteEnabled)) + ActionQueue.Enqueue(() => ModManager.OptionEditor.DeleteModGroup(group)); + + if (_deleteEnabled) + ImUtf8.HoverTooltip("Delete this option group."u8); + else + ImUtf8.HoverTooltip(ImGuiHoveredFlags.AllowWhenDisabled, + $"Delete this option group.\nHold {config.DeleteModModifier} while clicking to delete."); + } + + private void DrawGroupPriority(IModGroup group) + { + var priority = _currentGroupEdited == group + ? (_currentGroupPriority ?? group.Priority).Value + : group.Priority.Value; + ImGui.SetNextItemWidth(PriorityWidth); + if (ImGui.InputInt("##GroupPriority", ref priority, 0, 0)) + { + _currentGroupEdited = group; + _currentGroupPriority = new ModPriority(priority); + } + + if (ImGui.IsItemDeactivated()) + { + if (_currentGroupPriority.HasValue) + ModManager.OptionEditor.ChangeGroupPriority(group, _currentGroupPriority.Value); + _currentGroupEdited = null; + _currentGroupPriority = null; + } + + ImGuiUtil.HoverTooltip("Group Priority"); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private void DrawGroupDescription(IModGroup group) + { + if (ImUtf8.IconButton(FontAwesomeIcon.Edit, "Edit group description."u8)) + descriptionPopup.Open(group); + } + + private void DrawGroupMoveButtons(IModGroup group, int idx) + { + var isFirst = idx == 0; + if (ImUtf8.IconButton(FontAwesomeIcon.ArrowUp, isFirst)) + ActionQueue.Enqueue(() => ModManager.OptionEditor.MoveModGroup(group, idx - 1)); + + if (isFirst) + ImUtf8.HoverTooltip(ImGuiHoveredFlags.AllowWhenDisabled, "Can not move this group further upwards."u8); + else + ImUtf8.HoverTooltip($"Move this group up to group {idx}."); + + + ImUtf8.SameLineInner(); + var isLast = idx == group.Mod.Groups.Count - 1; + if (ImUtf8.IconButton(FontAwesomeIcon.ArrowDown, isLast)) + ActionQueue.Enqueue(() => ModManager.OptionEditor.MoveModGroup(group, idx + 1)); + + if (isLast) + ImUtf8.HoverTooltip(ImGuiHoveredFlags.AllowWhenDisabled, "Can not move this group further downwards."u8); + else + ImUtf8.HoverTooltip($"Move this group down to group {idx + 2}."); + } + + private void DrawGroupOpenFile(IModGroup group, int idx) + { + var fileName = filenames.OptionGroupFile(group.Mod, idx, config.ReplaceNonAsciiOnImport); + var fileExists = File.Exists(fileName); + if (ImUtf8.IconButton(FontAwesomeIcon.FileExport, !fileExists)) + try + { + Process.Start(new ProcessStartInfo(fileName) { UseShellExecute = true }); + } + catch (Exception e) + { + Penumbra.Messager.NotificationMessage(e, "Could not open editor.", NotificationType.Error); + } + + if (fileExists) + ImUtf8.HoverTooltip($"Open the {group.Name} json file in the text editor of your choice."); + else + ImUtf8.HoverTooltip(ImGuiHoveredFlags.AllowWhenDisabled, $"The {group.Name} json file does not exist."); + } + + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal void DrawOptionPosition(IModGroup group, IModOption option, int optionIdx) + { + ImGui.AlignTextToFramePadding(); + ImUtf8.Selectable($"Option #{optionIdx + 1}", false, size: OptionIdxSelectable); + Target(group, optionIdx); + Source(option); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal void DrawOptionDefaultSingleBehaviour(IModGroup group, IModOption option, int optionIdx) + { + var isDefaultOption = group.DefaultSettings.AsIndex == optionIdx; + if (ImUtf8.RadioButton("##default"u8, isDefaultOption)) + ModManager.OptionEditor.ChangeModGroupDefaultOption(group, Setting.Single(optionIdx)); + ImUtf8.HoverTooltip($"Set {option.Name} as the default choice for this group."); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal void DrawOptionDefaultMultiBehaviour(IModGroup group, IModOption option, int optionIdx) + { + var isDefaultOption = group.DefaultSettings.HasFlag(optionIdx); + if (ImUtf8.Checkbox("##default"u8, ref isDefaultOption)) + ModManager.OptionEditor.ChangeModGroupDefaultOption(group, group.DefaultSettings.SetBit(optionIdx, isDefaultOption)); + ImUtf8.HoverTooltip($"{(isDefaultOption ? "Disable"u8 : "Enable"u8)} {option.Name} per default in this group."); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal void DrawOptionDescription(IModOption option) + { + if (ImUtf8.IconButton(FontAwesomeIcon.Edit, "Edit option description."u8)) + descriptionPopup.Open(option); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal void DrawOptionPriority(MultiSubMod option) + { + var priority = option.Priority.Value; + ImGui.SetNextItemWidth(PriorityWidth); + if (ImUtf8.InputScalarOnDeactivated("##Priority"u8, ref priority)) + ModManager.OptionEditor.MultiEditor.ChangeOptionPriority(option, new ModPriority(priority)); + ImUtf8.HoverTooltip("Option priority inside the mod."u8); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal void DrawOptionName(IModOption option) + { + var name = option.Name; + ImGui.SetNextItemWidth(_optionNameWidth); + if (ImUtf8.InputTextOnDeactivated("##Name"u8, ref name)) + ModManager.OptionEditor.RenameOption(option, name); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal void DrawOptionDelete(IModOption option) + { + if (ImUtf8.IconButton(FontAwesomeIcon.Trash, !_deleteEnabled)) + ActionQueue.Enqueue(() => ModManager.OptionEditor.DeleteOption(option)); + + if (_deleteEnabled) + ImUtf8.HoverTooltip("Delete this option."u8); + else + ImUtf8.HoverTooltip(ImGuiHoveredFlags.AllowWhenDisabled, + $"Delete this option.\nHold {config.DeleteModModifier} while clicking to delete."); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal string DrawNewOptionBase(IModGroup group, int count) + { + ImGui.AlignTextToFramePadding(); + ImUtf8.Selectable($"Option #{count + 1}", false, size: OptionIdxSelectable); + Target(group, count); + + ImUtf8.SameLineInner(); + ImUtf8.IconDummy(); + + ImUtf8.SameLineInner(); + ImGui.SetNextItemWidth(_optionNameWidth); + var newName = _newOptionGroup == group + ? NewOptionName ?? string.Empty + : string.Empty; + if (ImUtf8.InputText("##newOption"u8, ref newName, "Add new option..."u8)) + { + NewOptionName = newName; + _newOptionGroup = group; + } + + ImUtf8.SameLineInner(); + return newName; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private void Source(IModOption option) + { + using var source = ImUtf8.DragDropSource(); + if (!source) + return; + + var across = option.Group is ITexToolsGroup; + + if (!DragDropSource.SetPayload(across ? AcrossGroupsLabel : InsideGroupLabel)) + { + _dragDropGroup = option.Group; + _dragDropOption = option; + _draggingAcross = across; + } + + ImUtf8.Text($"Dragging option {option.Name} from group {option.Group.Name}..."); + } + + private void Target(IModGroup group, int optionIdx) + { + if (_dragDropGroup != group + && (!_draggingAcross || (_dragDropGroup != null && group is MultiModGroup { Options.Count: >= IModGroup.MaxMultiOptions }))) + return; + + using var target = ImUtf8.DragDropTarget(); + if (!target.IsDropping(_draggingAcross ? AcrossGroupsLabel : InsideGroupLabel)) + return; + + if (_dragDropGroup != null && _dragDropOption != null) + { + if (_dragDropGroup == group) + { + var sourceOption = _dragDropOption; + ActionQueue.Enqueue(() => ModManager.OptionEditor.MoveOption(sourceOption, optionIdx)); + } + else + { + // Move from one group to another by deleting, then adding, then moving the option. + var sourceOption = _dragDropOption; + ActionQueue.Enqueue(() => + { + ModManager.OptionEditor.DeleteOption(sourceOption); + if (ModManager.OptionEditor.AddOption(group, sourceOption) is { } newOption) + ModManager.OptionEditor.MoveOption(newOption, optionIdx); + }); + } + } + + _dragDropGroup = null; + _dragDropOption = null; + _draggingAcross = false; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private void PrepareStyle() + { + var totalWidth = 400f * ImUtf8.GlobalScale; + _buttonSize = new Vector2(ImUtf8.FrameHeight); + PriorityWidth = 50 * ImUtf8.GlobalScale; + AvailableWidth = new Vector2(totalWidth + 3 * _spacing + 2 * _buttonSize.X + PriorityWidth, 0); + _groupNameWidth = totalWidth - 3 * (_buttonSize.X + _spacing); + _spacing = ImGui.GetStyle().ItemInnerSpacing.X; + OptionIdxSelectable = ImUtf8.CalcTextSize("Option #88."u8); + _optionNameWidth = totalWidth - OptionIdxSelectable.X - _buttonSize.X - 2 * _spacing; + _deleteEnabled = config.DeleteModModifier.IsActive(); + } +} diff --git a/Penumbra/UI/ModsTab/Groups/MultiModGroupEditDrawer.cs b/Penumbra/UI/ModsTab/Groups/MultiModGroupEditDrawer.cs new file mode 100644 index 00000000..04ca6c82 --- /dev/null +++ b/Penumbra/UI/ModsTab/Groups/MultiModGroupEditDrawer.cs @@ -0,0 +1,63 @@ +using Dalamud.Interface; +using OtterGui.Extensions; +using OtterGui.Raii; +using OtterGui.Text; +using Penumbra.Mods.Groups; + +namespace Penumbra.UI.ModsTab.Groups; + +public readonly struct MultiModGroupEditDrawer(ModGroupEditDrawer editor, MultiModGroup group) : IModGroupEditDrawer +{ + public void Draw() + { + foreach (var (option, optionIdx) in group.OptionData.WithIndex()) + { + using var id = ImRaii.PushId(optionIdx); + editor.DrawOptionPosition(group, option, optionIdx); + + ImUtf8.SameLineInner(); + editor.DrawOptionDefaultMultiBehaviour(group, option, optionIdx); + + ImUtf8.SameLineInner(); + editor.DrawOptionName(option); + + ImUtf8.SameLineInner(); + editor.DrawOptionDescription(option); + + ImUtf8.SameLineInner(); + editor.DrawOptionDelete(option); + + ImUtf8.SameLineInner(); + editor.DrawOptionPriority(option); + } + + DrawNewOption(); + DrawConvertButton(); + } + + private void DrawConvertButton() + { + var g = group; + var e = editor.ModManager.OptionEditor.MultiEditor; + if (ImUtf8.Button("Convert to Single Group"u8, editor.AvailableWidth)) + editor.ActionQueue.Enqueue(() => e.ChangeToSingle(g)); + } + + private void DrawNewOption() + { + var count = group.Options.Count; + if (count >= IModGroup.MaxMultiOptions) + return; + + var name = editor.DrawNewOptionBase(group, count); + + var validName = name.Length > 0; + if (ImUtf8.IconButton(FontAwesomeIcon.Plus, validName + ? "Add a new option to this group."u8 + : "Please enter a name for the new option."u8, default, !validName)) + { + editor.ModManager.OptionEditor.MultiEditor.AddOption(group, name); + editor.NewOptionName = null; + } + } +} diff --git a/Penumbra/UI/ModsTab/Groups/SingleModGroupEditDrawer.cs b/Penumbra/UI/ModsTab/Groups/SingleModGroupEditDrawer.cs new file mode 100644 index 00000000..8fa6a377 --- /dev/null +++ b/Penumbra/UI/ModsTab/Groups/SingleModGroupEditDrawer.cs @@ -0,0 +1,68 @@ +using Dalamud.Interface; +using Dalamud.Bindings.ImGui; +using OtterGui.Extensions; +using OtterGui.Raii; +using OtterGui.Text; +using Penumbra.Mods.Groups; + +namespace Penumbra.UI.ModsTab.Groups; + +public readonly struct SingleModGroupEditDrawer(ModGroupEditDrawer editor, SingleModGroup group) : IModGroupEditDrawer +{ + public void Draw() + { + foreach (var (option, optionIdx) in group.OptionData.WithIndex()) + { + using var id = ImRaii.PushId(optionIdx); + editor.DrawOptionPosition(group, option, optionIdx); + + ImUtf8.SameLineInner(); + editor.DrawOptionDefaultSingleBehaviour(group, option, optionIdx); + + ImUtf8.SameLineInner(); + editor.DrawOptionName(option); + + ImUtf8.SameLineInner(); + editor.DrawOptionDescription(option); + + ImUtf8.SameLineInner(); + editor.DrawOptionDelete(option); + + ImUtf8.SameLineInner(); + ImGui.Dummy(new Vector2(editor.PriorityWidth, 0)); + } + + DrawNewOption(); + DrawConvertButton(); + } + + private void DrawConvertButton() + { + var convertible = group.Options.Count <= IModGroup.MaxMultiOptions; + var g = group; + var e = editor.ModManager.OptionEditor.SingleEditor; + if (ImUtf8.ButtonEx("Convert to Multi Group", editor.AvailableWidth, !convertible)) + editor.ActionQueue.Enqueue(() => e.ChangeToMulti(g)); + if (!convertible) + ImUtf8.HoverTooltip(ImGuiHoveredFlags.AllowWhenDisabled, + "Can not convert to multi group since maximum number of options is exceeded."u8); + } + + private void DrawNewOption() + { + var count = group.Options.Count; + if (count >= int.MaxValue) + return; + + var name = editor.DrawNewOptionBase(group, count); + + var validName = name.Length > 0; + if (ImUtf8.IconButton(FontAwesomeIcon.Plus, validName + ? "Add a new option to this group."u8 + : "Please enter a name for the new option."u8, default, !validName)) + { + editor.ModManager.OptionEditor.SingleEditor.AddOption(group, name); + editor.NewOptionName = null; + } + } +} diff --git a/Penumbra/UI/ModsTab/ModFileSystemSelector.cs b/Penumbra/UI/ModsTab/ModFileSystemSelector.cs new file mode 100644 index 00000000..3f3c82aa --- /dev/null +++ b/Penumbra/UI/ModsTab/ModFileSystemSelector.cs @@ -0,0 +1,855 @@ +using Dalamud.Interface; +using Dalamud.Interface.DragDrop; +using Dalamud.Interface.ImGuiNotification; +using Dalamud.Plugin.Services; +using Dalamud.Bindings.ImGui; +using OtterGui; +using OtterGui.Classes; +using OtterGui.Filesystem; +using OtterGui.FileSystem.Selector; +using OtterGui.Raii; +using OtterGui.Services; +using OtterGui.Text; +using OtterGui.Text.Widget; +using Penumbra.Api.Enums; +using Penumbra.Collections; +using Penumbra.Collections.Manager; +using Penumbra.Communication; +using Penumbra.Mods; +using Penumbra.Mods.Manager; +using Penumbra.Mods.Settings; +using Penumbra.Services; +using Penumbra.UI.Classes; +using MessageService = Penumbra.Services.MessageService; + +namespace Penumbra.UI.ModsTab; + +public sealed class ModFileSystemSelector : FileSystemSelector, IUiService +{ + private readonly CommunicatorService _communicator; + private readonly Configuration _config; + private readonly FileDialogService _fileDialog; + private readonly ModManager _modManager; + private readonly CollectionManager _collectionManager; + private readonly TutorialService _tutorial; + private readonly ModImportManager _modImportManager; + private readonly IDragDropManager _dragDrop; + private readonly ModSearchStringSplitter _filter = new(); + private readonly ModSelection _selection; + + public ModFileSystemSelector(IKeyState keyState, CommunicatorService communicator, ModFileSystem fileSystem, ModManager modManager, + CollectionManager collectionManager, Configuration config, TutorialService tutorial, FileDialogService fileDialog, + MessageService messager, ModImportManager modImportManager, IDragDropManager dragDrop, ModSelection selection) + : base(fileSystem, keyState, Penumbra.Log, HandleException, allowMultipleSelection: true) + { + _communicator = communicator; + _modManager = modManager; + _collectionManager = collectionManager; + _config = config; + _tutorial = tutorial; + _fileDialog = fileDialog; + _modImportManager = modImportManager; + _dragDrop = dragDrop; + _selection = selection; + + // @formatter:off + SubscribeRightClickFolder(EnableDescendants, 10); + SubscribeRightClickFolder(DisableDescendants, 10); + SubscribeRightClickFolder(InheritDescendants, 15); + SubscribeRightClickFolder(OwnDescendants, 15); + SubscribeRightClickFolder(SetDefaultImportFolder, 100); + SubscribeRightClickFolder(f => SetQuickMove(f, 0, _config.QuickMoveFolder1, s => { _config.QuickMoveFolder1 = s; _config.Save(); }), 110); + SubscribeRightClickFolder(f => SetQuickMove(f, 1, _config.QuickMoveFolder2, s => { _config.QuickMoveFolder2 = s; _config.Save(); }), 120); + SubscribeRightClickFolder(f => SetQuickMove(f, 2, _config.QuickMoveFolder3, s => { _config.QuickMoveFolder3 = s; _config.Save(); }), 130); + SubscribeRightClickLeaf(ToggleLeafFavorite); + SubscribeRightClickLeaf(DrawTemporaryOptions); + SubscribeRightClickLeaf(l => QuickMove(l, _config.QuickMoveFolder1, _config.QuickMoveFolder2, _config.QuickMoveFolder3)); + SubscribeRightClickMain(ClearTemporarySettings, 105); + SubscribeRightClickMain(ClearDefaultImportFolder, 100); + SubscribeRightClickMain(() => ClearQuickMove(0, _config.QuickMoveFolder1, () => {_config.QuickMoveFolder1 = string.Empty; _config.Save();}), 110); + SubscribeRightClickMain(() => ClearQuickMove(1, _config.QuickMoveFolder2, () => {_config.QuickMoveFolder2 = string.Empty; _config.Save();}), 120); + SubscribeRightClickMain(() => ClearQuickMove(2, _config.QuickMoveFolder3, () => {_config.QuickMoveFolder3 = string.Empty; _config.Save();}), 130); + UnsubscribeRightClickLeaf(RenameLeaf); + SetRenameSearchPath(_config.ShowRename); + AddButton(AddNewModButton, 0); + AddButton(AddImportModButton, 1); + AddButton(AddHelpButton, 2); + AddButton(DeleteModButton, 1000); + // @formatter:on + SetFilterTooltip(); + + if (_selection.Mod != null) + SelectByValue(_selection.Mod); + _communicator.CollectionChange.Subscribe(OnCollectionChange, CollectionChange.Priority.ModFileSystemSelector); + _communicator.ModSettingChanged.Subscribe(OnSettingChange, ModSettingChanged.Priority.ModFileSystemSelector); + _communicator.CollectionInheritanceChanged.Subscribe(OnInheritanceChange, CollectionInheritanceChanged.Priority.ModFileSystemSelector); + _communicator.ModDataChanged.Subscribe(OnModDataChange, ModDataChanged.Priority.ModFileSystemSelector); + _communicator.ModDiscoveryStarted.Subscribe(StoreCurrentSelection, ModDiscoveryStarted.Priority.ModFileSystemSelector); + _communicator.ModDiscoveryFinished.Subscribe(RestoreLastSelection, ModDiscoveryFinished.Priority.ModFileSystemSelector); + SetFilterDirty(); + SelectionChanged += OnSelectionChanged; + } + + public void SetRenameSearchPath(RenameField value) + { + switch (value) + { + case RenameField.RenameSearchPath: + SubscribeRightClickLeaf(RenameLeafMod, 1000); + UnsubscribeRightClickLeaf(RenameMod); + break; + case RenameField.RenameData: + UnsubscribeRightClickLeaf(RenameLeafMod); + SubscribeRightClickLeaf(RenameMod, 1000); + break; + case RenameField.BothSearchPathPrio: + UnsubscribeRightClickLeaf(RenameLeafMod); + UnsubscribeRightClickLeaf(RenameMod); + SubscribeRightClickLeaf(RenameLeafMod, 1001); + SubscribeRightClickLeaf(RenameMod, 1000); + break; + case RenameField.BothDataPrio: + UnsubscribeRightClickLeaf(RenameLeafMod); + UnsubscribeRightClickLeaf(RenameMod); + SubscribeRightClickLeaf(RenameLeafMod, 1000); + SubscribeRightClickLeaf(RenameMod, 1001); + break; + default: + UnsubscribeRightClickLeaf(RenameLeafMod); + UnsubscribeRightClickLeaf(RenameMod); + break; + } + } + + private static readonly string[] ValidModExtensions = + [ + ".ttmp", + ".ttmp2", + ".pmp", + ".pcp", + ".zip", + ".rar", + ".7z", + ]; + + public new void Draw() + { + _dragDrop.CreateImGuiSource("ModDragDrop", m => m.Extensions.Any(e => ValidModExtensions.Contains(e.ToLowerInvariant())), m => + { + ImUtf8.Text($"Dragging mods for import:\n\t{string.Join("\n\t", m.Files.Select(Path.GetFileName))}"); + return true; + }); + base.Draw(); + if (_dragDrop.CreateImGuiTarget("ModDragDrop", out var files, out _)) + _modImportManager.AddUnpack(files.Where(f => ValidModExtensions.Contains(Path.GetExtension(f.ToLowerInvariant())))); + } + + protected override float CurrentWidth + => _config.Ephemeral.CurrentModSelectorWidth * ImUtf8.GlobalScale; + + protected override float MinimumAbsoluteRemainder + => 550 * ImUtf8.GlobalScale; + + protected override float MinimumScaling + => _config.Ephemeral.ModSelectorMinimumScale; + + protected override float MaximumScaling + => _config.Ephemeral.ModSelectorMaximumScale; + + protected override void SetSize(Vector2 size) + { + base.SetSize(size); + var adaptedSize = MathF.Round(size.X / ImUtf8.GlobalScale); + if (adaptedSize == _config.Ephemeral.CurrentModSelectorWidth) + return; + + _config.Ephemeral.CurrentModSelectorWidth = adaptedSize; + _config.Ephemeral.Save(); + } + + public override void Dispose() + { + base.Dispose(); + _communicator.ModDiscoveryStarted.Unsubscribe(StoreCurrentSelection); + _communicator.ModDiscoveryFinished.Unsubscribe(RestoreLastSelection); + _communicator.ModDataChanged.Unsubscribe(OnModDataChange); + _communicator.ModSettingChanged.Unsubscribe(OnSettingChange); + _communicator.CollectionInheritanceChanged.Unsubscribe(OnInheritanceChange); + _communicator.CollectionChange.Unsubscribe(OnCollectionChange); + } + + public new ModFileSystem.Leaf? SelectedLeaf + => base.SelectedLeaf; + + #region Interface + + // Customization points. + public override ISortMode SortMode + => _config.SortMode; + + 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 + => _config.OpenFoldersByDefault; + + protected override void DrawPopups() + { + DrawHelpPopup(); + + if (ImGuiUtil.OpenNameField("Create New Mod", ref _newModName)) + { + var newDir = _modManager.Creator.CreateEmptyMod(_modManager.BasePath, _newModName); + if (newDir != null) + { + _modManager.AddMod(newDir, false); + _newModName = string.Empty; + } + } + + while (_modImportManager.AddUnpackedMod(out var mod)) + SelectByValue(mod); + } + + protected override void DrawLeafName(FileSystem.Leaf leaf, in ModState state, bool selected) + { + var flags = selected ? ImGuiTreeNodeFlags.Selected | LeafFlags : LeafFlags; + using var c = ImRaii.PushColor(ImGuiCol.Text, state.Color.Tinted(state.Tint)) + .Push(ImGuiCol.HeaderHovered, 0x4000FFFF, leaf.Value.Favorite); + using var id = ImUtf8.PushId(leaf.Value.Index); + ImUtf8.TreeNode(leaf.Value.Name.Text, flags).Dispose(); + if (ImGui.IsItemClicked(ImGuiMouseButton.Middle)) + { + _modManager.SetKnown(leaf.Value); + var (setting, collection) = _collectionManager.Active.Current.GetActualSettings(leaf.Value.Index); + if (_config.DeleteModModifier.ForcedModifier(new DoubleModifier(ModifierHotkey.Control, ModifierHotkey.Shift)).IsActive()) + { + // Delete temporary settings if they exist, regardless of mode, or set to inheriting if none exist. + if (_collectionManager.Active.Current.GetTempSettings(leaf.Value.Index) is not null) + _collectionManager.Editor.SetTemporarySettings(_collectionManager.Active.Current, leaf.Value, null); + else + _collectionManager.Editor.SetModInheritance(_collectionManager.Active.Current, leaf.Value, true); + } + else + { + if (_config.DefaultTemporaryMode) + { + var settings = new TemporaryModSettings(leaf.Value, setting) { ForceInherit = false }; + settings.Enabled = !settings.Enabled; + _collectionManager.Editor.SetTemporarySettings(_collectionManager.Active.Current, leaf.Value, settings); + } + else + { + var inherited = collection != _collectionManager.Active.Current; + if (inherited) + _collectionManager.Editor.SetModInheritance(_collectionManager.Active.Current, leaf.Value, false); + _collectionManager.Editor.SetModState(_collectionManager.Active.Current, leaf.Value, setting is not { Enabled: true }); + } + } + } + + if (!state.Priority.IsDefault && !_config.HidePrioritiesInSelector) + { + var line = ImGui.GetItemRectMin().Y; + var itemPos = ImGui.GetItemRectMax().X; + var maxWidth = ImGui.GetWindowPos().X + ImGui.GetWindowContentRegionMax().X; + var priorityString = $"[{state.Priority}]"; + var size = ImGui.CalcTextSize(priorityString).X; + var remainingSpace = maxWidth - itemPos; + var offset = remainingSpace - size; + if (ImGui.GetScrollMaxY() == 0) + offset -= ImGui.GetStyle().ItemInnerSpacing.X; + + if (offset > ImGui.GetStyle().ItemSpacing.X) + ImGui.GetWindowDrawList().AddText(new Vector2(itemPos + offset, line), ColorId.SelectorPriority.Value(), priorityString); + } + } + + + // Add custom context menu items. + private void EnableDescendants(ModFileSystem.Folder folder) + { + if (ImUtf8.MenuItem("Enable Descendants"u8)) + SetDescendants(folder, true); + } + + private void ClearTemporarySettings() + { + if (ImUtf8.MenuItem("Clear Temporary Settings"u8)) + _collectionManager.Editor.ClearTemporarySettings(_collectionManager.Active.Current); + } + + private void DisableDescendants(ModFileSystem.Folder folder) + { + if (ImUtf8.MenuItem("Disable Descendants"u8)) + SetDescendants(folder, false); + } + + private void InheritDescendants(ModFileSystem.Folder folder) + { + if (ImUtf8.MenuItem("Inherit Descendants"u8)) + SetDescendants(folder, true, true); + } + + private void OwnDescendants(ModFileSystem.Folder folder) + { + if (ImUtf8.MenuItem("Stop Inheriting Descendants"u8)) + SetDescendants(folder, false, true); + } + + private void ToggleLeafFavorite(FileSystem.Leaf mod) + { + if (ImUtf8.MenuItem(mod.Value.Favorite ? "Remove Favorite"u8 : "Mark as Favorite"u8)) + _modManager.DataEditor.ChangeModFavorite(mod.Value, !mod.Value.Favorite); + } + + private void DrawTemporaryOptions(FileSystem.Leaf mod) + { + var tempSettings = _collectionManager.Active.Current.GetTempSettings(mod.Value.Index); + if (tempSettings is { Lock: > 0 }) + return; + + if (tempSettings is { Lock: <= 0 } && ImUtf8.MenuItem("Remove Temporary Settings"u8)) + _collectionManager.Editor.SetTemporarySettings(_collectionManager.Active.Current, mod.Value, null); + var actual = _collectionManager.Active.Current.GetActualSettings(mod.Value.Index).Settings; + if (actual?.Enabled is true && ImUtf8.MenuItem("Disable Temporarily"u8)) + _collectionManager.Editor.SetTemporarySettings(_collectionManager.Active.Current, mod.Value, + new TemporaryModSettings(mod.Value, actual) { Enabled = false }); + + if (actual is not { Enabled: true } && ImUtf8.MenuItem("Enable Temporarily"u8)) + { + var newSettings = actual is null + ? TemporaryModSettings.DefaultSettings(mod.Value, TemporaryModSettings.OwnSource, true) + : new TemporaryModSettings(mod.Value, actual) { Enabled = true }; + _collectionManager.Editor.SetTemporarySettings(_collectionManager.Active.Current, mod.Value, newSettings); + } + + if (tempSettings is null && ImUtf8.MenuItem("Turn Temporary"u8)) + _collectionManager.Editor.SetTemporarySettings(_collectionManager.Active.Current, mod.Value, + new TemporaryModSettings(mod.Value, actual)); + } + + private void SetDefaultImportFolder(ModFileSystem.Folder folder) + { + if (!ImUtf8.MenuItem("Set As Default Import Folder"u8)) + return; + + var newName = folder.FullName(); + if (newName == _config.DefaultImportFolder) + return; + + _config.DefaultImportFolder = newName; + _config.Save(); + } + + private void ClearDefaultImportFolder() + { + if (!ImUtf8.MenuItem("Clear Default Import Folder"u8) || _config.DefaultImportFolder.Length <= 0) + return; + + _config.DefaultImportFolder = string.Empty; + _config.Save(); + } + + private string _newModName = string.Empty; + + private void AddNewModButton(Vector2 size) + { + if (ImUtf8.IconButton(FontAwesomeIcon.Plus, "Create a new, empty mod of a given name."u8, size, !_modManager.Valid)) + ImUtf8.OpenPopup("Create New Mod"u8); + } + + /// Add an import mods button that opens a file selector. + private void AddImportModButton(Vector2 size) + { + var button = ImUtf8.IconButton(FontAwesomeIcon.FileImport, + "Import one or multiple mods from Tex Tools Mod Pack Files or Penumbra Mod Pack Files."u8, size, !_modManager.Valid); + _tutorial.OpenTutorial(BasicTutorialSteps.ModImport); + if (!button) + return; + + var modPath = _config.DefaultModImportPath.Length > 0 + ? _config.DefaultModImportPath + : _config.ModDirectory.Length > 0 + ? _config.ModDirectory + : null; + + _fileDialog.OpenFilePicker("Import Mod Pack", + "Mod Packs{.ttmp,.ttmp2,.pmp,.pcp},TexTools Mod Packs{.ttmp,.ttmp2},Penumbra Mod Packs{.pmp,.pcp},Archives{.zip,.7z,.rar},Penumbra Character Packs{.pcp}", (s, f) => + { + if (!s) + return; + + _modImportManager.AddUnpack(f); + }, 0, modPath, _config.AlwaysOpenDefaultImport); + } + + private void RenameLeafMod(ModFileSystem.Leaf leaf) + { + ImGui.Separator(); + RenameLeaf(leaf); + } + + private void RenameMod(ModFileSystem.Leaf leaf) + { + ImGui.Separator(); + var currentName = leaf.Value.Name.Text; + if (ImGui.IsWindowAppearing()) + ImGui.SetKeyboardFocusHere(0); + ImUtf8.Text("Rename Mod:"u8); + if (ImUtf8.InputText("##RenameMod"u8, ref currentName, flags: ImGuiInputTextFlags.EnterReturnsTrue)) + { + _modManager.DataEditor.ChangeModName(leaf.Value, currentName); + ImGui.CloseCurrentPopup(); + } + + ImUtf8.HoverTooltip("Enter a new name here to rename the changed mod."u8); + } + + private void DeleteModButton(Vector2 size) + => DeleteSelectionButton(size, _config.DeleteModModifier, "mod", "mods", _modManager.DeleteMod); + + private void AddHelpButton(Vector2 size) + { + if (ImUtf8.IconButton(FontAwesomeIcon.QuestionCircle, "Open extended help."u8, size, false)) + ImUtf8.OpenPopup("ExtendedHelp"u8); + + _tutorial.OpenTutorial(BasicTutorialSteps.AdvancedHelp); + } + + private void SetDescendants(ModFileSystem.Folder folder, bool enabled, bool inherit = false) + { + var mods = folder.GetAllDescendants(ISortMode.Lexicographical).OfType().Select(l => + { + // Any mod handled here should not stay new. + _modManager.SetKnown(l.Value); + return l.Value; + }); + + if (inherit) + _collectionManager.Editor.SetMultipleModInheritances(_collectionManager.Active.Current, mods, enabled); + else + _collectionManager.Editor.SetMultipleModStates(_collectionManager.Active.Current, mods, enabled); + } + + private void DrawHelpPopup() + { + ImGuiUtil.HelpPopup("ExtendedHelp", new Vector2(1000 * UiHelpers.Scale, 38.5f * ImGui.GetTextLineHeightWithSpacing()), () => + { + ImGui.Dummy(Vector2.UnitY * ImGui.GetTextLineHeight()); + ImUtf8.Text("Mod Management"u8); + ImUtf8.BulletText("You can create empty mods or import mods with the buttons in this row."u8); + using var indent = ImRaii.PushIndent(); + ImUtf8.BulletText("Supported formats for import are: .ttmp, .ttmp2, .pmp, .pcp."u8); + ImUtf8.BulletText( + "You can also support .zip, .7z or .rar archives, but only if they already contain Penumbra-styled mods with appropriate metadata."u8); + indent.Pop(1); + ImUtf8.BulletText("You can also create empty mod folders and delete mods."u8); + ImUtf8.BulletText( + "For further editing of mods, select them and use the Edit Mod tab in the panel or the Advanced Editing popup."u8); + ImGui.Dummy(Vector2.UnitY * ImGui.GetTextLineHeight()); + ImUtf8.Text("Mod Selector"u8); + ImUtf8.BulletText("Select a mod to obtain more information or change settings."u8); + ImUtf8.BulletText("Names are colored according to your config and their current state in the collection:"u8); + indent.Push(); + ImUtf8.BulletTextColored(ColorId.EnabledMod.Value(), "enabled in the current collection."u8); + ImUtf8.BulletTextColored(ColorId.DisabledMod.Value(), "disabled in the current collection."u8); + ImUtf8.BulletTextColored(ColorId.InheritedMod.Value(), "enabled due to inheritance from another collection."u8); + ImUtf8.BulletTextColored(ColorId.InheritedDisabledMod.Value(), "disabled due to inheritance from another collection."u8); + ImUtf8.BulletTextColored(ColorId.UndefinedMod.Value(), "unconfigured in all inherited collections."u8); + ImUtf8.BulletTextColored(ColorId.HandledConflictMod.Value(), + "enabled and conflicting with another enabled Mod, but on different priorities (i.e. the conflict is solved)."u8); + ImUtf8.BulletTextColored(ColorId.ConflictingMod.Value(), + "enabled and conflicting with another enabled Mod on the same priority."u8); + ImUtf8.BulletTextColored(ColorId.FolderExpanded.Value(), "expanded mod folder."u8); + ImUtf8.BulletTextColored(ColorId.FolderCollapsed.Value(), "collapsed mod folder"u8); + indent.Pop(1); + ImUtf8.BulletText("Middle-click a mod to disable it if it is enabled or enable it if it is disabled."u8); + indent.Push(); + ImUtf8.BulletText( + $"Holding {_config.DeleteModModifier.ForcedModifier(new DoubleModifier(ModifierHotkey.Control, ModifierHotkey.Shift))} while middle-clicking lets it inherit, discarding settings."); + indent.Pop(1); + ImUtf8.BulletText("Right-click a mod to enter its sort order, which is its name by default, possibly with a duplicate number."u8); + indent.Push(); + ImUtf8.BulletText("A sort order differing from the mods name will not be displayed, it will just be used for ordering."u8); + ImUtf8.BulletText( + "If the sort order string contains Forward-Slashes ('/'), the preceding substring will be turned into folders automatically."u8); + indent.Pop(1); + ImUtf8.BulletText( + "You can drag and drop mods and subfolders into existing folders. Dropping them onto mods is the same as dropping them onto the parent of the mod."u8); + indent.Push(); + ImUtf8.BulletText( + "You can select multiple mods and folders by holding Control while clicking them, and then drag all of them at once."u8); + ImUtf8.BulletText( + "Selected mods inside an also selected folder will be ignored when dragging and move inside their folder instead of directly into the target."u8); + indent.Pop(1); + ImUtf8.BulletText("Right-clicking a folder opens a context menu."u8); + ImUtf8.BulletText("Right-clicking empty space allows you to expand or collapse all folders at once."u8); + ImUtf8.BulletText("Use the Filter Mods... input at the top to filter the list for mods whose name or path contain the text."u8); + indent.Push(); + ImUtf8.BulletText("You can enter n:[string] to filter only for names, without path."u8); + ImUtf8.BulletText("You can enter c:[string] to filter for Changed Items instead."u8); + ImUtf8.BulletText("You can enter a:[string] to filter for Mod Authors instead."u8); + indent.Pop(1); + ImUtf8.BulletText("Use the expandable menu beside the input to filter for mods fulfilling specific criteria."u8); + }); + } + + private static void HandleException(Exception e) + => Penumbra.Messager.NotificationMessage(e, e.Message, NotificationType.Warning); + + #endregion + + #region Automatic cache update functions. + + private void OnSettingChange(ModCollection collection, ModSettingChange type, Mod? mod, Setting oldValue, int groupIdx, bool inherited) + { + if (collection == _collectionManager.Active.Current) + SetFilterDirty(); + } + + private void OnModDataChange(ModDataChangeType type, Mod mod, string? oldName) + { + const ModDataChangeType relevantFlags = + ModDataChangeType.Name + | ModDataChangeType.Author + | ModDataChangeType.ModTags + | ModDataChangeType.LocalTags + | ModDataChangeType.Favorite + | ModDataChangeType.ImportDate; + if ((type & relevantFlags) != 0) + SetFilterDirty(); + } + + private void OnInheritanceChange(ModCollection collection, bool _) + { + if (collection == _collectionManager.Active.Current) + SetFilterDirty(); + } + + private void OnCollectionChange(CollectionType collectionType, ModCollection? oldCollection, ModCollection? newCollection, string _) + { + if (collectionType is CollectionType.Current && oldCollection != newCollection) + SetFilterDirty(); + } + + // Keep selections across rediscoveries if possible. + private string _lastSelectedDirectory = string.Empty; + + private void StoreCurrentSelection() + { + _lastSelectedDirectory = Selected?.ModPath.FullName ?? string.Empty; + ClearSelection(); + } + + private void RestoreLastSelection() + { + if (_lastSelectedDirectory.Length <= 0) + return; + + var leaf = (ModFileSystem.Leaf?)FileSystem.Root.GetAllDescendants(ISortMode.Lexicographical) + .FirstOrDefault(l => l is ModFileSystem.Leaf m && m.Value.ModPath.FullName == _lastSelectedDirectory); + Select(leaf, AllowMultipleSelection); + _lastSelectedDirectory = string.Empty; + } + + private void OnSelectionChanged(Mod? oldSelection, Mod? newSelection, in ModState state) + => _selection.SelectMod(newSelection); + + #endregion + + #region Filters + + [StructLayout(LayoutKind.Sequential, Pack = 1)] + public struct ModState + { + public ColorId Color; + public ColorId Tint; + public ModPriority Priority; + } + + private ModFilter _stateFilter = ModFilterExtensions.UnfilteredStateMods; + + private void SetFilterTooltip() + { + FilterTooltip = "Filter mods for those where their full paths or names contain the given strings, split by spaces.\n" + + "Enter c:[string] to filter for mods changing specific items.\n" + + "Enter t:[string] to filter for mods set to specific tags.\n" + + "Enter n:[string] to filter only for mod names and no paths.\n" + + "Enter a:[string] to filter for mods by specific authors.\n" + + $"Enter s:[string] to filter for mods by the categories of the items they change (1-{ChangedItemFlagExtensions.NumCategories + 1} or partial category name).\n\n" + + "Use None as a placeholder value that only matches empty lists or names.\n" + + "Regularly, a mod has to match all supplied criteria separately.\n" + + "Put a - in front of a search token to search only for mods not matching the criterion.\n" + + "Put a ? in front of a search token to search for mods matching at least one of the '?'-criteria.\n" + + "Wrap spaces in \"[string with space]\" to match this exact combination of words.\n\n" + + "Example: 't:Tag1 t:\"Tag 2\" -t:Tag3 -a:None s:Body -c:Hempen ?c:Camise ?n:Top' will match any mod that\n" + + " - contains the tags 'tag1' and 'tag 2'\n" + + " - does not contain the tag 'tag3'\n" + + " - has any author set (negating None means Any)\n" + + " - changes an item of the 'Body' category\n" + + " - and either contains a changed item with 'camise' in it's name, or has 'top' in the mod's name."; + } + + /// Appropriately identify and set the string filter and its type. + protected override bool ChangeFilter(string filterValue) + { + _filter.Parse(filterValue); + return true; + } + + /// + /// Check the state filter for a specific pair of has/has-not flags. + /// Uses count == 0 to check for has-not and count != 0 for has. + /// Returns true if it should be filtered and false if not. + /// + private bool CheckFlags(int count, ModFilter hasNoFlag, ModFilter hasFlag) + => count switch + { + 0 when _stateFilter.HasFlag(hasNoFlag) => false, + 0 => true, + _ when _stateFilter.HasFlag(hasFlag) => false, + _ => true, + }; + + /// + /// 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. + /// + protected override bool ApplyFiltersAndState(FileSystem.IPath path, out ModState state) + { + if (path is ModFileSystem.Folder f) + { + state = default; + return ModFilterExtensions.UnfilteredStateMods != _stateFilter + || !_filter.IsVisible(f); + } + + return ApplyFiltersAndState((ModFileSystem.Leaf)path, out state); + } + + /// Apply the string filters. + private bool ApplyStringFilters(ModFileSystem.Leaf leaf, Mod mod) + => !_filter.IsVisible(leaf); + + /// Only get the text color for a mod if no filters are set. + private (ColorId Color, ColorId Tint) GetTextColor(Mod mod, ModSettings? settings, ModCollection collection) + { + var tint = settings.IsTemporary() + ? ColorId.TemporaryModSettingsTint + : _modManager.IsNew(mod) + ? ColorId.NewModTint + : ColorId.NoTint; + if (settings.IsTemporary()) + tint = ColorId.TemporaryModSettingsTint; + + if (settings == null) + return (ColorId.UndefinedMod, tint); + + if (!settings.Enabled) + return (collection != _collectionManager.Active.Current + ? ColorId.InheritedDisabledMod + : ColorId.DisabledMod, tint); + + var conflicts = _collectionManager.Active.Current.Conflicts(mod); + if (conflicts.Count == 0) + return (collection != _collectionManager.Active.Current ? ColorId.InheritedMod : ColorId.EnabledMod, tint); + + return (conflicts.Any(c => !c.Solved) + ? ColorId.ConflictingMod + : ColorId.HandledConflictMod, tint); + } + + private bool CheckStateFilters(Mod mod, ModSettings? settings, ModCollection collection, ref ModState state) + { + var isNew = _modManager.IsNew(mod); + // Handle mod details. + if (CheckFlags(mod.TotalFileCount, ModFilter.HasNoFiles, ModFilter.HasFiles) + || CheckFlags(mod.TotalSwapCount, ModFilter.HasNoFileSwaps, ModFilter.HasFileSwaps) + || CheckFlags(mod.TotalManipulations, ModFilter.HasNoMetaManipulations, ModFilter.HasMetaManipulations) + || CheckFlags(mod.HasOptions ? 1 : 0, ModFilter.HasNoConfig, ModFilter.HasConfig) + || CheckFlags(isNew ? 1 : 0, ModFilter.NotNew, ModFilter.IsNew)) + return true; + + // Handle Favoritism + if (!_stateFilter.HasFlag(ModFilter.Favorite) && mod.Favorite + || !_stateFilter.HasFlag(ModFilter.NotFavorite) && !mod.Favorite) + return true; + + // Handle Temporary + if (!_stateFilter.HasFlag(ModFilter.Temporary) || !_stateFilter.HasFlag(ModFilter.NotTemporary)) + { + if (settings == null && _stateFilter.HasFlag(ModFilter.Temporary)) + return true; + + if (settings != null && settings.IsTemporary() != _stateFilter.HasFlag(ModFilter.Temporary)) + return true; + } + + // Handle Inheritance + if (collection == _collectionManager.Active.Current) + { + if (!_stateFilter.HasFlag(ModFilter.Uninherited)) + return true; + } + else + { + state.Color = ColorId.InheritedMod; + if (!_stateFilter.HasFlag(ModFilter.Inherited)) + return true; + } + + // isNew color takes precedence before other colors. + if (settings.IsTemporary()) + state.Tint = ColorId.TemporaryModSettingsTint; + else if (isNew) + state.Tint = ColorId.NewModTint; + else + state.Tint = ColorId.NoTint; + + + // Handle settings. + if (settings == null) + { + state.Color = ColorId.UndefinedMod; + if (!_stateFilter.HasFlag(ModFilter.Undefined) + || !_stateFilter.HasFlag(ModFilter.Disabled) + || !_stateFilter.HasFlag(ModFilter.NoConflict)) + return true; + } + else if (!settings.Enabled) + { + state.Color = collection != _collectionManager.Active.Current + ? ColorId.InheritedDisabledMod + : ColorId.DisabledMod; + if (!_stateFilter.HasFlag(ModFilter.Disabled) + || !_stateFilter.HasFlag(ModFilter.NoConflict)) + return true; + } + else + { + if (!_stateFilter.HasFlag(ModFilter.Enabled)) + return true; + + // Conflicts can only be relevant if the mod is enabled. + var conflicts = _collectionManager.Active.Current.Conflicts(mod); + if (conflicts.Count > 0) + { + if (conflicts.Any(c => !c.Solved)) + { + if (!_stateFilter.HasFlag(ModFilter.UnsolvedConflict)) + return true; + + state.Color = ColorId.ConflictingMod; + } + else + { + if (!_stateFilter.HasFlag(ModFilter.SolvedConflict)) + return true; + + state.Color = ColorId.HandledConflictMod; + } + } + else if (!_stateFilter.HasFlag(ModFilter.NoConflict)) + { + return true; + } + } + + + return false; + } + + /// Combined wrapper for handling all filters and setting state. + private bool ApplyFiltersAndState(ModFileSystem.Leaf leaf, out ModState state) + { + var mod = leaf.Value; + var (settings, collection) = _collectionManager.Active.Current.GetActualSettings(mod.Index); + + state = new ModState + { + Color = ColorId.EnabledMod, + Priority = settings?.Priority ?? ModPriority.Default, + }; + if (ApplyStringFilters(leaf, mod)) + return true; + + if (_stateFilter != ModFilterExtensions.UnfilteredStateMods) + return CheckStateFilters(mod, settings, collection, ref state); + + (state.Color, state.Tint) = GetTextColor(mod, settings, collection); + return false; + } + + private bool DrawFilterCombo(ref bool everything) + { + using var combo = ImUtf8.Combo("##filterCombo"u8, ""u8, + ImGuiComboFlags.NoPreview | ImGuiComboFlags.PopupAlignLeft | ImGuiComboFlags.HeightLargest); + var ret = ImGui.IsItemClicked(ImGuiMouseButton.Right); + if (!combo) + return ret; + + using var style = ImRaii.PushStyle(ImGuiStyleVar.ItemSpacing, + ImGui.GetStyle().ItemSpacing with { Y = 3 * UiHelpers.Scale }); + + if (ImUtf8.Checkbox("Everything"u8, ref everything)) + { + _stateFilter = everything ? ModFilterExtensions.UnfilteredStateMods : 0; + SetFilterDirty(); + } + + ImGui.Dummy(new Vector2(0, 5 * UiHelpers.Scale)); + foreach (var (onFlag, offFlag, name) in ModFilterExtensions.TriStatePairs) + { + if (TriStateCheckbox.Instance.Draw(name, ref _stateFilter, onFlag, offFlag)) + SetFilterDirty(); + } + + foreach (var group in ModFilterExtensions.Groups) + { + ImGui.Separator(); + foreach (var (flag, name) in group) + { + if (ImUtf8.Checkbox(name, ref _stateFilter, flag)) + SetFilterDirty(); + } + } + + return ret; + } + + /// Add the state filter combo-button to the right of the filter box. + protected override (float, bool) CustomFilters(float width) + { + var pos = ImGui.GetCursorPos(); + var remainingWidth = width - ImGui.GetFrameHeight(); + var comboPos = new Vector2(pos.X + remainingWidth, pos.Y); + + var everything = _stateFilter == ModFilterExtensions.UnfilteredStateMods; + + ImGui.SetCursorPos(comboPos); + // Draw combo button + using var color = ImRaii.PushColor(ImGuiCol.Button, Colors.FilterActive, !everything); + var rightClick = DrawFilterCombo(ref everything); + _tutorial.OpenTutorial(BasicTutorialSteps.ModFilters); + if (rightClick) + { + _stateFilter = ModFilterExtensions.UnfilteredStateMods; + SetFilterDirty(); + } + + ImUtf8.HoverTooltip("Filter mods for their activation status.\nRight-Click to clear all filters."u8); + ImGui.SetCursorPos(pos); + return (remainingWidth, rightClick); + } + + #endregion +} diff --git a/Penumbra/UI/ModsTab/ModFilter.cs b/Penumbra/UI/ModsTab/ModFilter.cs new file mode 100644 index 00000000..5f120b7d --- /dev/null +++ b/Penumbra/UI/ModsTab/ModFilter.cs @@ -0,0 +1,59 @@ +namespace Penumbra.UI.ModsTab; + +[Flags] +public enum ModFilter +{ + Enabled = 1 << 0, + Disabled = 1 << 1, + Favorite = 1 << 2, + NotFavorite = 1 << 3, + NoConflict = 1 << 4, + SolvedConflict = 1 << 5, + UnsolvedConflict = 1 << 6, + HasNoMetaManipulations = 1 << 7, + HasMetaManipulations = 1 << 8, + HasNoFileSwaps = 1 << 9, + HasFileSwaps = 1 << 10, + HasConfig = 1 << 11, + HasNoConfig = 1 << 12, + HasNoFiles = 1 << 13, + HasFiles = 1 << 14, + IsNew = 1 << 15, + NotNew = 1 << 16, + Inherited = 1 << 17, + Uninherited = 1 << 18, + Temporary = 1 << 19, + NotTemporary = 1 << 20, + Undefined = 1 << 21, +}; + +public static class ModFilterExtensions +{ + public const ModFilter UnfilteredStateMods = (ModFilter)((1 << 22) - 1); + + public static IReadOnlyList<(ModFilter On, ModFilter Off, string Name)> TriStatePairs = + [ + (ModFilter.Enabled, ModFilter.Disabled, "Enabled"), + (ModFilter.IsNew, ModFilter.NotNew, "Newly Imported"), + (ModFilter.Favorite, ModFilter.NotFavorite, "Favorite"), + (ModFilter.HasConfig, ModFilter.HasNoConfig, "Has Options"), + (ModFilter.HasFiles, ModFilter.HasNoFiles, "Has Redirections"), + (ModFilter.HasMetaManipulations, ModFilter.HasNoMetaManipulations, "Has Meta Manipulations"), + (ModFilter.HasFileSwaps, ModFilter.HasNoFileSwaps, "Has File Swaps"), + (ModFilter.Temporary, ModFilter.NotTemporary, "Temporary"), + ]; + + public static IReadOnlyList> Groups = + [ + [ + (ModFilter.NoConflict, "Has No Conflicts"), + (ModFilter.SolvedConflict, "Has Solved Conflicts"), + (ModFilter.UnsolvedConflict, "Has Unsolved Conflicts"), + ], + [ + (ModFilter.Undefined, "Not Configured"), + (ModFilter.Inherited, "Inherited Configuration"), + (ModFilter.Uninherited, "Own Configuration"), + ], + ]; +} diff --git a/Penumbra/UI/ModsTab/ModPanel.cs b/Penumbra/UI/ModsTab/ModPanel.cs new file mode 100644 index 00000000..b7546699 --- /dev/null +++ b/Penumbra/UI/ModsTab/ModPanel.cs @@ -0,0 +1,82 @@ +using Dalamud.Bindings.ImGui; +using Dalamud.Interface.Utility.Raii; +using Dalamud.Plugin; +using OtterGui.Services; +using Penumbra.Mods; +using Penumbra.Services; +using Penumbra.UI.AdvancedWindow; + +namespace Penumbra.UI.ModsTab; + +public class ModPanel : IDisposable, IUiService +{ + private readonly MultiModPanel _multiModPanel; + private readonly ModSelection _selection; + private readonly ModEditWindow _editWindow; + private readonly ModPanelHeader _header; + private readonly ModPanelTabBar _tabs; + private bool _resetCursor; + + public ModPanel(IDalamudPluginInterface pi, ModSelection selection, ModEditWindow editWindow, ModPanelTabBar tabs, + MultiModPanel multiModPanel, CommunicatorService communicator) + { + _selection = selection; + _editWindow = editWindow; + _tabs = tabs; + _multiModPanel = multiModPanel; + _header = new ModPanelHeader(pi, communicator); + _selection.Subscribe(OnSelectionChange, ModSelection.Priority.ModPanel); + OnSelectionChange(null, _selection.Mod); + } + + public void Draw() + { + if (!_valid) + { + _multiModPanel.Draw(); + return; + } + + if (_resetCursor) + { + _resetCursor = false; + ImGui.SetScrollX(0); + } + + _header.Draw(); + ImGui.SetCursorPosX(ImGui.GetScrollX() + ImGui.GetCursorPosX()); + using var child = ImRaii.Child("Tabs", + new Vector2(ImGui.GetWindowContentRegionMax().X - ImGui.GetWindowContentRegionMin().X, ImGui.GetContentRegionAvail().Y)); + if (child) + _tabs.Draw(_mod); + } + + public void Dispose() + { + _selection.Unsubscribe(OnSelectionChange); + _header.Dispose(); + } + + private bool _valid; + private Mod _mod = null!; + + private void OnSelectionChange(Mod? old, Mod? mod) + { + _resetCursor = true; + if (mod == null || _selection.Mod == null) + { + _editWindow.IsOpen = false; + _valid = false; + } + else + { + if (_editWindow.IsOpen) + _editWindow.ChangeMod(mod); + _valid = true; + _mod = mod; + _header.ChangeMod(_mod); + _tabs.Settings.Reset(); + _tabs.Edit.Reset(); + } + } +} diff --git a/Penumbra/UI/ModsTab/ModPanelChangedItemsTab.cs b/Penumbra/UI/ModsTab/ModPanelChangedItemsTab.cs new file mode 100644 index 00000000..332b64f0 --- /dev/null +++ b/Penumbra/UI/ModsTab/ModPanelChangedItemsTab.cs @@ -0,0 +1,348 @@ +using Dalamud.Bindings.ImGui; +using Dalamud.Interface; +using Dalamud.Interface.Utility.Raii; +using OtterGui; +using OtterGui.Classes; +using OtterGui.Services; +using OtterGui.Text; +using OtterGui.Widgets; +using Penumbra.GameData.Data; +using Penumbra.GameData.Enums; +using Penumbra.GameData.Structs; +using Penumbra.Mods; +using Penumbra.Mods.Manager; +using Penumbra.String; +using Penumbra.UI.Classes; + +namespace Penumbra.UI.ModsTab; + +public class ModPanelChangedItemsTab( + ModFileSystemSelector selector, + ChangedItemDrawer drawer, + ImGuiCacheService cacheService, + Configuration config, + ModDataEditor dataEditor) + : ITab, IUiService +{ + private readonly ImGuiCacheService.CacheId _cacheId = cacheService.GetNewId(); + + private class ChangedItemsCache + { + private Mod? _lastSelected; + private ushort _lastUpdate; + private ChangedItemIconFlag _filter = ChangedItemFlagExtensions.DefaultFlags; + private ChangedItemMode _lastMode; + private bool _reset; + public readonly List Data = []; + public bool AnyExpandable { get; private set; } + + public record struct Container + { + public IIdentifiedObjectData Data; + public ByteString Text; + public ByteString ModelData; + public uint Id; + public int Children; + public ChangedItemIconFlag Icon; + public bool Expandable; + public bool Expanded; + public bool Child; + + public static Container Single(string text, IIdentifiedObjectData data) + => new() + { + Child = false, + Text = ByteString.FromStringUnsafe(data.ToName(text), false), + ModelData = ByteString.FromStringUnsafe(data.AdditionalData, false), + Icon = data.GetIcon().ToFlag(), + Expandable = false, + Expanded = false, + Data = data, + Id = 0, + Children = 0, + }; + + public static Container Parent(string text, IIdentifiedObjectData data, uint id, int children, bool expanded) + => new() + { + Child = false, + Text = ByteString.FromStringUnsafe(data.ToName(text), false), + ModelData = ByteString.FromStringUnsafe(data.AdditionalData, false), + Icon = data.GetIcon().ToFlag(), + Expandable = true, + Expanded = expanded, + Data = data, + Id = id, + Children = children, + }; + + public static Container Indent(string text, IIdentifiedObjectData data) + => new() + { + Child = true, + Text = ByteString.FromStringUnsafe(data.ToName(text), false), + ModelData = ByteString.FromStringUnsafe(data.AdditionalData, false), + Icon = data.GetIcon().ToFlag(), + Expandable = false, + Expanded = false, + Data = data, + Id = 0, + Children = 0, + }; + } + + public void Reset() + => _reset = true; + + public void Update(Mod? mod, ChangedItemDrawer drawer, ChangedItemIconFlag filter, ChangedItemMode mode) + { + if (mod == _lastSelected + && _lastSelected!.LastChangedItemsUpdate == _lastUpdate + && _filter == filter + && !_reset + && _lastMode == mode) + return; + + _reset = false; + Data.Clear(); + AnyExpandable = false; + _lastSelected = mod; + _filter = filter; + _lastMode = mode; + if (_lastSelected == null) + return; + + _lastUpdate = _lastSelected.LastChangedItemsUpdate; + + if (mode is ChangedItemMode.Alphabetical) + { + foreach (var (s, i) in _lastSelected.ChangedItems) + { + if (drawer.FilterChangedItem(s, i, LowerString.Empty)) + Data.Add(Container.Single(s, i)); + } + + return; + } + + var tmp = new Dictionary<(PrimaryId, FullEquipType), List>(); + var defaultExpansion = _lastMode is ChangedItemMode.GroupedExpanded; + foreach (var (s, i) in _lastSelected.ChangedItems) + { + if (i is not IdentifiedItem item) + continue; + + if (!drawer.FilterChangedItem(s, item, LowerString.Empty)) + continue; + + if (tmp.TryGetValue((item.Item.PrimaryId, item.Item.Type), out var p)) + p.Add(item); + else + tmp[(item.Item.PrimaryId, item.Item.Type)] = [item]; + } + + foreach (var list in tmp.Values) + { + list.Sort((i1, i2) => + { + // reversed + var preferred = _lastSelected.PreferredChangedItems.Contains(i2.Item.Id) + .CompareTo(_lastSelected.PreferredChangedItems.Contains(i1.Item.Id)); + if (preferred != 0) + return preferred; + + // reversed + var count = i2.Count.CompareTo(i1.Count); + if (count != 0) + return count; + + return string.Compare(i1.Item.Name, i2.Item.Name, StringComparison.Ordinal); + }); + } + + var sortedTmp = tmp.Values.OrderBy(s => s[0].Item.Name).ToArray(); + + var sortedTmpIdx = 0; + foreach (var (s, i) in _lastSelected.ChangedItems) + { + if (i is IdentifiedItem) + continue; + + if (!drawer.FilterChangedItem(s, i, LowerString.Empty)) + continue; + + while (sortedTmpIdx < sortedTmp.Length + && string.Compare(sortedTmp[sortedTmpIdx][0].Item.Name, s, StringComparison.Ordinal) <= 0) + AddList(sortedTmp[sortedTmpIdx++]); + + Data.Add(Container.Single(s, i)); + } + + for (; sortedTmpIdx < sortedTmp.Length; ++sortedTmpIdx) + AddList(sortedTmp[sortedTmpIdx]); + return; + + void AddList(List list) + { + var mainItem = list[0]; + if (list.Count == 1) + { + Data.Add(Container.Single(mainItem.Item.Name, mainItem)); + } + else + { + var id = ImUtf8.GetId($"{mainItem.Item.PrimaryId}{(int)mainItem.Item.Type}"); + var expanded = ImGui.GetStateStorage().GetBool(id, defaultExpansion); + Data.Add(Container.Parent(mainItem.Item.Name, mainItem, id, list.Count - 1, expanded)); + AnyExpandable = true; + if (!expanded) + return; + + foreach (var item in list.Skip(1)) + Data.Add(Container.Indent(item.Item.Name, item)); + } + } + } + } + + public ReadOnlySpan Label + => "Changed Items"u8; + + public bool IsVisible + => selector.Selected!.ChangedItems.Count > 0; + + private ImGuiStoragePtr _stateStorage; + + private Vector2 _buttonSize; + private uint _starColor; + + public void DrawContent() + { + if (cacheService.Cache(_cacheId, () => (new ChangedItemsCache(), "ModPanelChangedItemsCache")) is not { } cache) + return; + + drawer.DrawTypeFilter(); + + _stateStorage = ImGui.GetStateStorage(); + cache.Update(selector.Selected, drawer, config.Ephemeral.ChangedItemFilter, config.ChangedItemDisplay); + ImGui.Separator(); + _buttonSize = new Vector2(ImGui.GetStyle().ItemSpacing.Y + ImGui.GetFrameHeight()); + using var style = ImRaii.PushStyle(ImGuiStyleVar.CellPadding, Vector2.Zero) + .Push(ImGuiStyleVar.ItemSpacing, Vector2.Zero) + .Push(ImGuiStyleVar.FramePadding, Vector2.Zero) + .Push(ImGuiStyleVar.SelectableTextAlign, new Vector2(0.01f, 0.5f)); + using var color = ImRaii.PushColor(ImGuiCol.Button, 0); + + using var table = ImUtf8.Table("##changedItems"u8, cache.AnyExpandable ? 2 : 1, ImGuiTableFlags.RowBg | ImGuiTableFlags.ScrollY, + new Vector2(ImGui.GetContentRegionAvail().X, -1)); + if (!table) + return; + + _starColor = ColorId.ChangedItemPreferenceStar.Value(); + if (cache.AnyExpandable) + { + ImUtf8.TableSetupColumn("##exp"u8, ImGuiTableColumnFlags.WidthFixed, _buttonSize.Y); + ImUtf8.TableSetupColumn("##text"u8, ImGuiTableColumnFlags.WidthStretch); + ImGuiClip.ClippedDraw(cache.Data, DrawContainerExpandable, _buttonSize.Y); + } + else + { + ImGuiClip.ClippedDraw(cache.Data, DrawContainer, _buttonSize.Y); + } + } + + private void DrawContainerExpandable(ChangedItemsCache.Container obj, int idx) + { + using var id = ImUtf8.PushId(idx); + ImGui.TableNextColumn(); + if (obj.Expandable) + { + if (ImUtf8.IconButton(obj.Expanded ? FontAwesomeIcon.CaretDown : FontAwesomeIcon.CaretRight, + obj.Expanded ? "Hide the other items using the same model." : + obj.Children > 1 ? $"Show {obj.Children} other items using the same model." : + "Show one other item using the same model.", + _buttonSize)) + { + _stateStorage.SetBool(obj.Id, !obj.Expanded); + if (cacheService.TryGetCache(_cacheId, out var cache)) + cache.Reset(); + } + } + else if (obj is { Child: true, Data: IdentifiedItem item }) + { + DrawPreferredButton(item, idx); + } + else + { + ImGui.Dummy(_buttonSize); + } + + DrawBaseContainer(obj, idx); + } + + private void DrawContainer(ChangedItemsCache.Container obj, int idx) + { + using var id = ImUtf8.PushId(idx); + DrawBaseContainer(obj, idx); + } + + private void DrawPreferredButton(IdentifiedItem item, int idx) + { + if (ImUtf8.IconButton(FontAwesomeIcon.Star, "Prefer displaying this item instead of the current primary item.\n\nRight-click for more options."u8, _buttonSize, + false, _starColor)) + dataEditor.AddPreferredItem(selector.Selected!, item.Item.Id, false, true); + using var context = ImUtf8.PopupContextItem("StarContext"u8, ImGuiPopupFlags.MouseButtonRight); + if (!context) + return; + + if (cacheService.TryGetCache(_cacheId, out var cache)) + for (--idx; idx >= 0; --idx) + { + if (!cache.Data[idx].Expanded) + continue; + + if (cache.Data[idx].Data is IdentifiedItem it) + { + if (selector.Selected!.PreferredChangedItems.Contains(it.Item.Id) + && ImUtf8.MenuItem("Remove Parent from Local Preferred Items"u8)) + dataEditor.RemovePreferredItem(selector.Selected!, it.Item.Id, false); + if (selector.Selected!.DefaultPreferredItems.Contains(it.Item.Id) + && ImUtf8.MenuItem("Remove Parent from Default Preferred Items"u8)) + dataEditor.RemovePreferredItem(selector.Selected!, it.Item.Id, true); + } + + break; + } + + var enabled = !selector.Selected!.DefaultPreferredItems.Contains(item.Item.Id); + if (enabled) + { + if (ImUtf8.MenuItem("Add to Local and Default Preferred Changed Items"u8)) + dataEditor.AddPreferredItem(selector.Selected!, item.Item.Id, true, true); + } + else + { + if (ImUtf8.MenuItem("Remove from Default Preferred Changed Items"u8)) + dataEditor.RemovePreferredItem(selector.Selected!, item.Item.Id, true); + } + + if (ImUtf8.MenuItem("Reset Local Preferred Items to Default"u8)) + dataEditor.ResetPreferredItems(selector.Selected!); + + if (ImUtf8.MenuItem("Clear Local and Default Preferred Items not Changed by the Mod"u8)) + dataEditor.ClearInvalidPreferredItems(selector.Selected!); + } + + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private void DrawBaseContainer(in ChangedItemsCache.Container obj, int idx) + { + ImGui.TableNextColumn(); + using var indent = ImRaii.PushIndent(1, obj.Child); + drawer.DrawCategoryIcon(obj.Icon, _buttonSize.Y); + ImGui.SameLine(0, 0); + var clicked = ImUtf8.Selectable(obj.Text.Span, false, ImGuiSelectableFlags.None, _buttonSize with { X = 0 }); + drawer.ChangedItemHandling(obj.Data, clicked); + ChangedItemDrawer.DrawModelData(obj.ModelData.Span, _buttonSize.Y); + } +} diff --git a/Penumbra/UI/ModsTab/ModPanelCollectionsTab.cs b/Penumbra/UI/ModsTab/ModPanelCollectionsTab.cs new file mode 100644 index 00000000..70cad148 --- /dev/null +++ b/Penumbra/UI/ModsTab/ModPanelCollectionsTab.cs @@ -0,0 +1,139 @@ +using Dalamud.Bindings.ImGui; +using Dalamud.Interface.Utility; +using OtterGui.Extensions; +using OtterGui.Raii; +using OtterGui.Services; +using OtterGui.Text; +using OtterGui.Widgets; +using Penumbra.Collections; +using Penumbra.Collections.Manager; +using Penumbra.Mods; +using Penumbra.UI.Classes; + +namespace Penumbra.UI.ModsTab; + +public class ModPanelCollectionsTab(CollectionManager manager, ModFileSystemSelector selector) : ITab, IUiService +{ + private enum ModState + { + Enabled, + Disabled, + Unconfigured, + } + + private readonly List<(ModCollection, ModCollection, uint, ModState)> _cache = []; + + public ReadOnlySpan Label + => "Collections"u8; + + public void DrawContent() + { + var (direct, inherited) = CountUsage(selector.Selected!); + ImGui.NewLine(); + if (direct == 1) + ImUtf8.Text("This Mod is directly configured in 1 collection."u8); + else if (direct == 0) + ImUtf8.Text("This mod is entirely unused."u8, Colors.RegexWarningBorder); + else + ImUtf8.Text($"This Mod is directly configured in {direct} collections."); + if (inherited > 0) + ImUtf8.Text($"It is also implicitly used in {inherited} {(inherited == 1 ? "collection" : "collections")} through inheritance."); + + ImGui.NewLine(); + ImGui.Separator(); + ImGui.NewLine(); + using var table = ImUtf8.Table("##modCollections"u8, 3, ImGuiTableFlags.SizingFixedFit | ImGuiTableFlags.RowBg); + if (!table) + return; + + var size = ImUtf8.CalcTextSize(ToText(ModState.Unconfigured)).X + 20 * ImGuiHelpers.GlobalScale; + var collectionSize = 200 * ImGuiHelpers.GlobalScale; + ImGui.TableSetupColumn("Collection", ImGuiTableColumnFlags.WidthFixed, collectionSize); + ImGui.TableSetupColumn("State", ImGuiTableColumnFlags.WidthFixed, size); + ImGui.TableSetupColumn("Inherited From", ImGuiTableColumnFlags.WidthFixed, collectionSize); + + ImGui.TableHeadersRow(); + foreach (var ((collection, parent, color, state), idx) in _cache.WithIndex()) + { + using var id = ImUtf8.PushId(idx); + ImUtf8.DrawTableColumn(collection.Identity.Name); + + ImGui.TableNextColumn(); + ImUtf8.Text(ToText(state), color); + + using (var context = ImUtf8.PopupContextItem("Context"u8, ImGuiPopupFlags.MouseButtonRight)) + { + if (context) + { + ImUtf8.Text(collection.Identity.Name); + ImGui.Separator(); + using (ImRaii.Disabled(state is ModState.Enabled && parent == collection)) + { + if (ImUtf8.MenuItem("Enable"u8)) + { + if (parent != collection) + manager.Editor.SetModInheritance(collection, selector.Selected!, false); + manager.Editor.SetModState(collection, selector.Selected!, true); + } + } + + using (ImRaii.Disabled(state is ModState.Disabled && parent == collection)) + { + if (ImUtf8.MenuItem("Disable"u8)) + { + if (parent != collection) + manager.Editor.SetModInheritance(collection, selector.Selected!, false); + manager.Editor.SetModState(collection, selector.Selected!, false); + } + } + + using (ImRaii.Disabled(parent != collection)) + { + if (ImUtf8.MenuItem("Inherit"u8)) + manager.Editor.SetModInheritance(collection, selector.Selected!, true); + } + } + } + + ImUtf8.DrawTableColumn(parent == collection ? string.Empty : parent.Identity.Name); + } + } + + private static ReadOnlySpan ToText(ModState state) + => state switch + { + ModState.Unconfigured => "Unconfigured"u8, + ModState.Enabled => "Enabled"u8, + ModState.Disabled => "Disabled"u8, + _ => "Unknown"u8, + }; + + private (int Direct, int Inherited) CountUsage(Mod mod) + { + _cache.Clear(); + var undefined = ColorId.UndefinedMod.Value(); + var enabled = ColorId.EnabledMod.Value(); + var inherited = ColorId.InheritedMod.Value(); + var disabled = ColorId.DisabledMod.Value(); + var disInherited = ColorId.InheritedDisabledMod.Value(); + var directCount = 0; + var inheritedCount = 0; + foreach (var collection in manager.Storage) + { + var (settings, parent) = collection.GetInheritedSettings(mod.Index); + var (color, text) = settings == null + ? (undefined, ModState.Unconfigured) + : settings.Enabled + ? (parent == collection ? enabled : inherited, ModState.Enabled) + : (parent == collection ? disabled : disInherited, ModState.Disabled); + _cache.Add((collection, parent, color, text)); + + if (color == enabled) + ++directCount; + else if (color == inherited) + ++inheritedCount; + } + + return (directCount, inheritedCount); + } +} diff --git a/Penumbra/UI/ModsTab/ModPanelConflictsTab.cs b/Penumbra/UI/ModsTab/ModPanelConflictsTab.cs new file mode 100644 index 00000000..1002d8ca --- /dev/null +++ b/Penumbra/UI/ModsTab/ModPanelConflictsTab.cs @@ -0,0 +1,207 @@ +using Dalamud.Interface; +using Dalamud.Interface.Utility; +using Dalamud.Bindings.ImGui; +using OtterGui; +using OtterGui.Extensions; +using OtterGui.Raii; +using OtterGui.Services; +using OtterGui.Text; +using OtterGui.Widgets; +using Penumbra.Collections.Cache; +using Penumbra.Collections.Manager; +using Penumbra.Meta.Manipulations; +using Penumbra.Mods; +using Penumbra.Mods.Editor; +using Penumbra.Mods.Settings; +using Penumbra.String.Classes; +using Penumbra.UI.Classes; + +namespace Penumbra.UI.ModsTab; + +public class ModPanelConflictsTab(CollectionManager collectionManager, ModFileSystemSelector selector) : ITab, IUiService +{ + private int? _currentPriority; + + public ReadOnlySpan Label + => "Conflicts"u8; + + public bool IsVisible + => collectionManager.Active.Current.Conflicts(selector.Selected!).Any(c => !GetPriority(c).IsHidden); + + private readonly ConditionalWeakTable _expandedMods = []; + + private ModPriority GetPriority(ModConflicts conflicts) + { + if (conflicts.Mod2.Index < 0) + return conflicts.Mod2.Priority; + + return collectionManager.Active.Current.GetActualSettings(conflicts.Mod2.Index).Settings?.Priority ?? ModPriority.Default; + } + + public void DrawContent() + { + using var table = ImRaii.Table("conflicts", 3, ImGuiTableFlags.RowBg | ImGuiTableFlags.ScrollY, ImGui.GetContentRegionAvail()); + if (!table) + return; + + var buttonSize = new Vector2(ImGui.GetFrameHeight()); + var spacing = ImGui.GetStyle().ItemInnerSpacing with { Y = ImGui.GetStyle().ItemSpacing.Y }; + var priorityRowWidth = ImGui.CalcTextSize("Priority").X + 20 * ImGuiHelpers.GlobalScale + 2 * buttonSize.X; + var priorityWidth = priorityRowWidth - 2 * (buttonSize.X + spacing.X); + using var style = ImRaii.PushStyle(ImGuiStyleVar.ItemSpacing, spacing); + ImGui.TableSetupColumn("Conflicting Mod", ImGuiTableColumnFlags.WidthStretch); + ImGui.TableSetupColumn("Priority", ImGuiTableColumnFlags.WidthFixed, priorityRowWidth); + ImGui.TableSetupColumn("Files", ImGuiTableColumnFlags.WidthFixed, ImGui.CalcTextSize("Files").X + spacing.X); + + ImGui.TableSetupScrollFreeze(2, 2); + ImGui.TableHeadersRow(); + DrawCurrentRow(priorityWidth); + + // Can not be null because otherwise the tab bar is never drawn. + var mod = selector.Selected!; + foreach (var (conflict, index) in collectionManager.Active.Current.Conflicts(mod).Where(c => !c.Mod2.Priority.IsHidden) + .OrderByDescending(GetPriority) + .ThenBy(c => c.Mod2.Name.Lower).WithIndex()) + { + using var id = ImRaii.PushId(index); + DrawConflictRow(conflict, priorityWidth, buttonSize); + } + } + + private void DrawCurrentRow(float priorityWidth) + { + ImGui.TableNextColumn(); + using var c = ImRaii.PushColor(ImGuiCol.Text, ColorId.FolderLine.Value()); + ImGui.AlignTextToFramePadding(); + ImGui.TextUnformatted(selector.Selected!.Name.Text); + ImGui.TableNextColumn(); + var actualSettings = collectionManager.Active.Current.GetActualSettings(selector.Selected!.Index).Settings!; + var priority = actualSettings.Priority.Value; + // TODO + using (ImRaii.Disabled(actualSettings is TemporaryModSettings)) + { + ImGui.SetNextItemWidth(priorityWidth); + if (ImGui.InputInt("##priority", ref priority, 0, 0, flags: ImGuiInputTextFlags.EnterReturnsTrue)) + _currentPriority = priority; + + if (ImGui.IsItemDeactivatedAfterEdit() && _currentPriority.HasValue) + { + if (_currentPriority != actualSettings.Priority.Value) + collectionManager.Editor.SetModPriority(collectionManager.Active.Current, selector.Selected!, + new ModPriority(_currentPriority.Value)); + + _currentPriority = null; + } + else if (ImGui.IsItemDeactivated()) + { + _currentPriority = null; + } + } + + ImGui.TableNextColumn(); + } + + private void DrawConflictSelectable(ModConflicts conflict) + { + ImGui.AlignTextToFramePadding(); + if (ImGui.Selectable(conflict.Mod2.Name.Text) && conflict.Mod2 is Mod otherMod) + selector.SelectByValue(otherMod); + var hovered = ImGui.IsItemHovered(); + var rightClicked = ImGui.IsItemClicked(ImGuiMouseButton.Right); + if (conflict.Mod2 is Mod otherMod2) + { + if (hovered) + ImGui.SetTooltip("Click to jump to mod, Control + Right-Click to disable mod."); + if (rightClicked && ImGui.GetIO().KeyCtrl) + collectionManager.Editor.SetModState(collectionManager.Active.Current, otherMod2, false); + } + } + + private bool DrawExpandedFiles(ModConflicts conflict) + { + if (!_expandedMods.TryGetValue(conflict.Mod2, out _)) + return false; + + using var indent = ImRaii.PushIndent(30f); + foreach (var data in conflict.Conflicts) + { + _ = data switch + { + Utf8GamePath p => ImUtf8.Selectable(p.Path.Span, false), + IMetaIdentifier m => ImUtf8.Selectable(m.ToString(), false), + _ => false, + }; + } + + return true; + } + + private void DrawConflictRow(ModConflicts conflict, float priorityWidth, Vector2 buttonSize) + { + ImGui.TableNextColumn(); + DrawConflictSelectable(conflict); + var expanded = DrawExpandedFiles(conflict); + ImGui.TableNextColumn(); + var conflictPriority = DrawPriorityInput(conflict, priorityWidth); + ImGui.SameLine(); + var selectedPriority = collectionManager.Active.Current.GetActualSettings(selector.Selected!.Index).Settings!.Priority.Value; + DrawPriorityButtons(conflict.Mod2 as Mod, conflictPriority, selectedPriority, buttonSize); + ImGui.TableNextColumn(); + DrawExpandButton(conflict.Mod2, expanded, buttonSize); + } + + private void DrawExpandButton(IMod mod, bool expanded, Vector2 buttonSize) + { + var (icon, tt) = expanded + ? (FontAwesomeIcon.CaretUp.ToIconString(), "Hide the conflicting files for this mod.") + : (FontAwesomeIcon.CaretDown.ToIconString(), "Show the conflicting files for this mod."); + if (ImGuiUtil.DrawDisabledButton(icon, buttonSize, tt, false, true)) + { + if (expanded) + _expandedMods.Remove(mod); + else + _expandedMods.Add(mod, new object()); + } + } + + private int DrawPriorityInput(ModConflicts conflict, float priorityWidth) + { + using var color = ImRaii.PushColor(ImGuiCol.Text, + conflict.HasPriority ? ColorId.HandledConflictMod.Value() : ColorId.ConflictingMod.Value()); + using var disabled = ImRaii.Disabled(conflict.Mod2.Index < 0); + var priority = _currentPriority ?? GetPriority(conflict).Value; + + ImGui.SetNextItemWidth(priorityWidth); + if (ImGui.InputInt("##priority", ref priority, 0, 0, flags: ImGuiInputTextFlags.EnterReturnsTrue)) + _currentPriority = priority; + + if (ImGui.IsItemDeactivatedAfterEdit() && _currentPriority.HasValue) + { + if (_currentPriority != GetPriority(conflict).Value) + collectionManager.Editor.SetModPriority(collectionManager.Active.Current, (Mod)conflict.Mod2, + new ModPriority(_currentPriority.Value)); + + _currentPriority = null; + } + else if (ImGui.IsItemDeactivated()) + { + _currentPriority = null; + } + + return priority; + } + + private void DrawPriorityButtons(Mod? conflict, int conflictPriority, int selectedPriority, Vector2 buttonSize) + { + if (ImGuiUtil.DrawDisabledButton(FontAwesomeIcon.SortNumericUpAlt.ToIconString(), buttonSize, + $"Set the priority of the currently selected mod to this mods priority plus one. ({selectedPriority} -> {conflictPriority + 1})", + selectedPriority > conflictPriority, true)) + collectionManager.Editor.SetModPriority(collectionManager.Active.Current, selector.Selected!, + new ModPriority(conflictPriority + 1)); + ImGui.SameLine(); + if (ImGuiUtil.DrawDisabledButton(FontAwesomeIcon.SortNumericDownAlt.ToIconString(), buttonSize, + $"Set the priority of this mod to the currently selected mods priority minus one. ({conflictPriority} -> {selectedPriority - 1})", + selectedPriority > conflictPriority || conflict == null, true)) + collectionManager.Editor.SetModPriority(collectionManager.Active.Current, conflict!, new ModPriority(selectedPriority - 1)); + } +} diff --git a/Penumbra/UI/ModsTab/ModPanelDescriptionTab.cs b/Penumbra/UI/ModsTab/ModPanelDescriptionTab.cs new file mode 100644 index 00000000..b8710707 --- /dev/null +++ b/Penumbra/UI/ModsTab/ModPanelDescriptionTab.cs @@ -0,0 +1,58 @@ +using Dalamud.Bindings.ImGui; +using Dalamud.Interface.Utility; +using OtterGui.Raii; +using OtterGui; +using OtterGui.Services; +using OtterGui.Widgets; +using Penumbra.Mods.Manager; + +namespace Penumbra.UI.ModsTab; + +public class ModPanelDescriptionTab( + ModFileSystemSelector selector, + TutorialService tutorial, + ModManager modManager, + PredefinedTagManager predefinedTagsConfig) + : ITab, IUiService +{ + private readonly TagButtons _localTags = new(); + private readonly TagButtons _modTags = new(); + + public ReadOnlySpan Label + => "Description"u8; + + public void DrawContent() + { + using var child = ImRaii.Child("##description"); + if (!child) + return; + + ImGui.Dummy(ImGuiHelpers.ScaledVector2(2)); + + ImGui.Dummy(ImGuiHelpers.ScaledVector2(2)); + var (predefinedTagsEnabled, predefinedTagButtonOffset) = predefinedTagsConfig.Enabled + ? (true, ImGui.GetFrameHeight() + ImGui.GetStyle().WindowPadding.X + (ImGui.GetScrollMaxY() > 0 ? ImGui.GetStyle().ScrollbarSize : 0)) + : (false, 0); + var tagIdx = _localTags.Draw("Local Tags: ", + "Custom tags you can set personally that will not be exported to the mod data but only set for you.\n" + + "If the mod already contains a local tag in its own tags, the local tag will be ignored.", selector.Selected!.LocalTags, + out var editedTag, rightEndOffset: predefinedTagButtonOffset); + tutorial.OpenTutorial(BasicTutorialSteps.Tags); + if (tagIdx >= 0) + modManager.DataEditor.ChangeLocalTag(selector.Selected!, tagIdx, editedTag); + + if (predefinedTagsEnabled) + predefinedTagsConfig.DrawAddFromSharedTagsAndUpdateTags(selector.Selected!.LocalTags, selector.Selected!.ModTags, true, + selector.Selected!); + + if (selector.Selected!.ModTags.Count > 0) + _modTags.Draw("Mod Tags: ", "Tags assigned by the mod creator and saved with the mod data. To edit these, look at Edit Mod.", + selector.Selected!.ModTags, out _, false, + ImGui.CalcTextSize("Local ").X - ImGui.CalcTextSize("Mod ").X); + + ImGui.Dummy(ImGuiHelpers.ScaledVector2(2)); + ImGui.Separator(); + + ImGuiUtil.TextWrapped(selector.Selected!.Description); + } +} diff --git a/Penumbra/UI/ModsTab/ModPanelEditTab.cs b/Penumbra/UI/ModsTab/ModPanelEditTab.cs new file mode 100644 index 00000000..c3737b40 --- /dev/null +++ b/Penumbra/UI/ModsTab/ModPanelEditTab.cs @@ -0,0 +1,370 @@ +using Dalamud.Interface; +using Dalamud.Interface.Components; +using Dalamud.Interface.ImGuiNotification; +using Dalamud.Bindings.ImGui; +using OtterGui; +using OtterGui.Raii; +using OtterGui.Widgets; +using OtterGui.Classes; +using OtterGui.Services; +using OtterGui.Text; +using Penumbra.Mods; +using Penumbra.Mods.Editor; +using Penumbra.Mods.Manager; +using Penumbra.Services; +using Penumbra.Mods.Settings; +using Penumbra.UI.ModsTab.Groups; + +namespace Penumbra.UI.ModsTab; + +public class ModPanelEditTab( + ModManager modManager, + ModFileSystemSelector selector, + ModFileSystem fileSystem, + Services.MessageService messager, + FilenameService filenames, + ModExportManager modExportManager, + Configuration config, + PredefinedTagManager predefinedTagManager, + ModGroupEditDrawer groupEditDrawer, + DescriptionEditPopup descriptionPopup, + AddGroupDrawer addGroupDrawer) + : ITab, IUiService +{ + private readonly TagButtons _modTags = new(); + + private ModFileSystem.Leaf _leaf = null!; + private Mod _mod = null!; + + public ReadOnlySpan Label + => "Edit Mod"u8; + + public void DrawContent() + { + using var child = ImRaii.Child("##editChild", -Vector2.One); + if (!child) + return; + + _leaf = selector.SelectedLeaf!; + _mod = selector.Selected!; + + EditButtons(); + EditRegularMeta(); + UiHelpers.DefaultLineSpace(); + EditLocalData(); + UiHelpers.DefaultLineSpace(); + + if (Input.Text("Mod Path", Input.Path, Input.None, _leaf.FullName(), out var newPath, 256, UiHelpers.InputTextWidth.X)) + try + { + fileSystem.RenameAndMove(_leaf, newPath); + } + catch (Exception e) + { + messager.NotificationMessage(e.Message, NotificationType.Warning, false); + } + + UiHelpers.DefaultLineSpace(); + + FeatureChecker.DrawFeatureFlagInput(modManager.DataEditor, _mod, UiHelpers.InputTextWidth.X); + + UiHelpers.DefaultLineSpace(); + var sharedTagsEnabled = predefinedTagManager.Enabled; + var sharedTagButtonOffset = sharedTagsEnabled ? ImGui.GetFrameHeight() + ImGui.GetStyle().FramePadding.X : 0; + var tagIdx = _modTags.Draw("Mod Tags: ", "Edit tags by clicking them, or add new tags. Empty tags are removed.", _mod.ModTags, + out var editedTag, rightEndOffset: sharedTagButtonOffset); + if (tagIdx >= 0) + modManager.DataEditor.ChangeModTag(_mod, tagIdx, editedTag); + + if (sharedTagsEnabled) + predefinedTagManager.DrawAddFromSharedTagsAndUpdateTags(selector.Selected!.LocalTags, selector.Selected!.ModTags, false, + selector.Selected!); + + + UiHelpers.DefaultLineSpace(); + addGroupDrawer.Draw(_mod, UiHelpers.InputTextWidth.X); + UiHelpers.DefaultLineSpace(); + + groupEditDrawer.Draw(_mod); + descriptionPopup.Draw(); + } + + public void Reset() + { + MoveDirectory.Reset(); + Input.Reset(); + } + + /// The general edit row for non-detailed mod edits. + private void EditButtons() + { + var buttonSize = new Vector2(150 * UiHelpers.Scale, 0); + var folderExists = Directory.Exists(_mod.ModPath.FullName); + var tt = folderExists + ? $"Open \"{_mod.ModPath.FullName}\" in the file explorer of your choice." + : $"Mod directory \"{_mod.ModPath.FullName}\" does not exist."; + if (ImGuiUtil.DrawDisabledButton("Open Mod Directory", buttonSize, tt, !folderExists)) + Process.Start(new ProcessStartInfo(_mod.ModPath.FullName) { UseShellExecute = true }); + + ImGui.SameLine(); + if (ImGuiUtil.DrawDisabledButton("Reload Mod", buttonSize, "Reload the current mod from its files.\n" + + "If the mod directory or meta file do not exist anymore or if the new mod name is empty, the mod is deleted instead.", + false)) + modManager.ReloadMod(_mod); + + BackupButtons(buttonSize); + MoveDirectory.Draw(modManager, _mod, buttonSize); + + UiHelpers.DefaultLineSpace(); + } + + private void BackupButtons(Vector2 buttonSize) + { + var backup = new ModBackup(modExportManager, _mod); + var tt = ModBackup.CreatingBackup + ? "Already exporting a mod." + : backup.Exists + ? $"Overwrite current exported mod \"{backup.Name}\" with current mod." + : $"Create exported archive of current mod at \"{backup.Name}\"."; + if (ImUtf8.ButtonEx("Export Mod"u8, tt, buttonSize, ModBackup.CreatingBackup)) + backup.CreateAsync(); + + if (ImGui.IsItemClicked(ImGuiMouseButton.Right)) + ImUtf8.OpenPopup("context"u8); + + ImGui.SameLine(); + tt = backup.Exists + ? $"Delete existing mod export \"{backup.Name}\" (hold {config.DeleteModModifier} while clicking)." + : $"Exported mod \"{backup.Name}\" does not exist."; + if (ImUtf8.ButtonEx("Delete Export"u8, tt, buttonSize, !backup.Exists || !config.DeleteModModifier.IsActive())) + backup.Delete(); + + tt = backup.Exists + ? $"Restore mod from exported file \"{backup.Name}\" (hold {config.DeleteModModifier} while clicking)." + : $"Exported mod \"{backup.Name}\" does not exist."; + ImGui.SameLine(); + if (ImUtf8.ButtonEx("Restore From Export"u8, tt, buttonSize, !backup.Exists || !config.DeleteModModifier.IsActive())) + backup.Restore(modManager); + if (backup.Exists) + { + ImGui.SameLine(); + using (ImRaii.PushFont(UiBuilder.IconFont)) + { + ImUtf8.Text(FontAwesomeIcon.CheckCircle.ToIconString()); + } + + ImUtf8.HoverTooltip($"Export exists in \"{backup.Name}\"."); + } + + using var context = ImUtf8.Popup("context"u8); + if (!context) + return; + + if (ImUtf8.Selectable("Open Backup Directory"u8)) + Process.Start(new ProcessStartInfo(modExportManager.ExportDirectory.FullName) { UseShellExecute = true }); + } + + /// Anything about editing the regular meta information about the mod. + private void EditRegularMeta() + { + if (Input.Text("Name", Input.Name, Input.None, _mod.Name, out var newName, 256, UiHelpers.InputTextWidth.X)) + modManager.DataEditor.ChangeModName(_mod, newName); + + if (Input.Text("Author", Input.Author, Input.None, _mod.Author, out var newAuthor, 256, UiHelpers.InputTextWidth.X)) + modManager.DataEditor.ChangeModAuthor(_mod, newAuthor); + + if (Input.Text("Version", Input.Version, Input.None, _mod.Version, out var newVersion, 32, + UiHelpers.InputTextWidth.X)) + modManager.DataEditor.ChangeModVersion(_mod, newVersion); + + if (Input.Text("Website", Input.Website, Input.None, _mod.Website, out var newWebsite, 256, + UiHelpers.InputTextWidth.X)) + modManager.DataEditor.ChangeModWebsite(_mod, newWebsite); + + using var style = ImRaii.PushStyle(ImGuiStyleVar.ItemSpacing, new Vector2(UiHelpers.ScaleX3)); + + var reducedSize = new Vector2(UiHelpers.InputTextMinusButton3, 0); + if (ImGui.Button("Edit Description", reducedSize)) + descriptionPopup.Open(_mod); + + + ImGui.SameLine(); + var fileExists = File.Exists(filenames.ModMetaPath(_mod)); + var tt = fileExists + ? "Open the metadata json file in the text editor of your choice." + : "The metadata json file does not exist."; + if (ImGuiUtil.DrawDisabledButton($"{FontAwesomeIcon.FileExport.ToIconString()}##metaFile", UiHelpers.IconButtonSize, tt, + !fileExists, true)) + Process.Start(new ProcessStartInfo(filenames.ModMetaPath(_mod)) { UseShellExecute = true }); + + DrawOpenDefaultMod(); + } + + private void EditLocalData() + { + DrawImportDate(); + DrawOpenLocalData(); + } + + private void DrawImportDate() + { + ImUtf8.TextFramed($"{DateTimeOffset.FromUnixTimeMilliseconds(_mod.ImportDate).ToLocalTime():yyyy/MM/dd HH:mm}", + ImGui.GetColorU32(ImGuiCol.FrameBg, 0.5f), new Vector2(UiHelpers.InputTextMinusButton3, 0)); + ImGui.SameLine(0, 3 * ImUtf8.GlobalScale); + + var canRefresh = config.DeleteModModifier.IsActive(); + var tt = canRefresh + ? "Reset the import date to the current date and time." + : $"Reset the import date to the current date and time.\nHold {config.DeleteModModifier} while clicking to refresh."; + + if (ImUtf8.IconButton(FontAwesomeIcon.Sync, tt, disabled: !canRefresh)) + modManager.DataEditor.ResetModImportDate(_mod); + ImUtf8.SameLineInner(); + ImUtf8.Text("Import Date"u8); + } + + private void DrawOpenLocalData() + { + var file = filenames.LocalDataFile(_mod); + var fileExists = File.Exists(file); + var tt = fileExists + ? "Open the local mod data file in the text editor of your choice."u8 + : "The local mod data file does not exist."u8; + if (ImUtf8.ButtonEx("Open Local Data"u8, tt, UiHelpers.InputTextWidth, !fileExists)) + Process.Start(new ProcessStartInfo(file) { UseShellExecute = true }); + } + + private void DrawOpenDefaultMod() + { + var file = filenames.OptionGroupFile(_mod, -1, false); + var fileExists = File.Exists(file); + var tt = fileExists + ? "Open the default mod data file in the text editor of your choice." + : "The default mod data file does not exist."; + if (ImGuiUtil.DrawDisabledButton("Open Default Data", UiHelpers.InputTextWidth, tt, !fileExists)) + Process.Start(new ProcessStartInfo(file) { UseShellExecute = true }); + } + + + /// A text input for the new directory name and a button to apply the move. + private static class MoveDirectory + { + private static string? _currentModDirectory; + private static NewDirectoryState _state = NewDirectoryState.Identical; + + public static void Reset() + { + _currentModDirectory = null; + _state = NewDirectoryState.Identical; + } + + public static void Draw(ModManager modManager, Mod mod, Vector2 buttonSize) + { + ImGui.SetNextItemWidth(buttonSize.X * 2 + ImGui.GetStyle().ItemSpacing.X); + var tmp = _currentModDirectory ?? mod.ModPath.Name; + if (ImGui.InputText("##newModMove", ref tmp, 64)) + { + _currentModDirectory = tmp; + _state = modManager.NewDirectoryValid(mod.ModPath.Name, _currentModDirectory, out _); + } + + var (disabled, tt) = _state switch + { + NewDirectoryState.Identical => (true, "Current directory name is identical to new one."), + NewDirectoryState.Empty => (true, "Please enter a new directory name first."), + NewDirectoryState.NonExisting => (false, $"Move mod from {mod.ModPath.Name} to {_currentModDirectory}."), + NewDirectoryState.ExistsEmpty => (false, $"Move mod from {mod.ModPath.Name} to {_currentModDirectory}."), + NewDirectoryState.ExistsNonEmpty => (true, $"{_currentModDirectory} already exists and is not empty."), + NewDirectoryState.ExistsAsFile => (true, $"{_currentModDirectory} exists as a file."), + NewDirectoryState.ContainsInvalidSymbols => (true, + $"{_currentModDirectory} contains invalid symbols for FFXIV."), + _ => (true, "Unknown error."), + }; + ImGui.SameLine(); + if (ImGuiUtil.DrawDisabledButton("Rename Mod Directory", buttonSize, tt, disabled) && _currentModDirectory != null) + { + modManager.MoveModDirectory(mod, _currentModDirectory); + Reset(); + } + + ImGui.SameLine(); + ImGuiComponents.HelpMarker( + "The mod directory name is used to correspond stored settings and sort orders, otherwise it has no influence on anything that is displayed.\n" + + "This can currently not be used on pre-existing folders and does not support merges or overwriting."); + } + } + + /// Handles input text and integers in separate fields without buffers for every single one. + private static class Input + { + // Special field indices to reuse the same string buffer. + public const int None = -1; + public const int Name = -2; + public const int Author = -3; + public const int Version = -4; + public const int Website = -5; + public const int Path = -6; + public const int Description = -7; + + // Temporary strings + private static string? _currentEdit; + private static ModPriority? _currentGroupPriority; + private static int _currentField = None; + private static int _optionIndex = None; + + public static void Reset() + { + _currentEdit = null; + _currentGroupPriority = null; + _currentField = None; + _optionIndex = None; + } + + public static bool Text(string label, int field, int option, string oldValue, out string value, uint maxLength, float width) + { + var tmp = field == _currentField && option == _optionIndex ? _currentEdit ?? oldValue : oldValue; + ImGui.SetNextItemWidth(width); + + if (ImGui.InputText(label, ref tmp)) + { + _currentEdit = tmp; + _optionIndex = option; + _currentField = field; + } + + if (ImGui.IsItemDeactivatedAfterEdit() && _currentEdit != null) + { + var ret = _currentEdit != oldValue; + value = _currentEdit; + Reset(); + return ret; + } + + value = string.Empty; + return false; + } + + public static bool Priority(string label, int field, int option, ModPriority oldValue, out ModPriority value, float width) + { + var tmp = (field == _currentField && option == _optionIndex ? _currentGroupPriority ?? oldValue : oldValue).Value; + ImGui.SetNextItemWidth(width); + if (ImGui.InputInt(label, ref tmp, 0, 0)) + { + _currentGroupPriority = new ModPriority(tmp); + _optionIndex = option; + _currentField = field; + } + + if (ImGui.IsItemDeactivatedAfterEdit() && _currentGroupPriority != null) + { + var ret = _currentGroupPriority != oldValue; + value = _currentGroupPriority.Value; + Reset(); + return ret; + } + + value = ModPriority.Default; + return false; + } + } +} diff --git a/Penumbra/UI/ModsTab/ModPanelHeader.cs b/Penumbra/UI/ModsTab/ModPanelHeader.cs new file mode 100644 index 00000000..b42ac680 --- /dev/null +++ b/Penumbra/UI/ModsTab/ModPanelHeader.cs @@ -0,0 +1,269 @@ +using Dalamud.Bindings.ImGui; +using Dalamud.Interface.GameFonts; +using Dalamud.Interface.ManagedFontAtlas; +using Dalamud.Plugin; +using OtterGui; +using OtterGui.Raii; +using Penumbra.Communication; +using Penumbra.Mods; +using Penumbra.Mods.Manager; +using Penumbra.Services; +using Penumbra.UI.Classes; + +namespace Penumbra.UI.ModsTab; + +public class ModPanelHeader : IDisposable +{ + /// We use a big, nice game font for the title. + private readonly IFontHandle _nameFont; + + private readonly CommunicatorService _communicator; + private float _lastPreSettingsHeight; + private bool _dirty = true; + + public ModPanelHeader(IDalamudPluginInterface pi, CommunicatorService communicator) + { + _communicator = communicator; + _nameFont = pi.UiBuilder.FontAtlas.NewGameFontHandle(new GameFontStyle(GameFontFamilyAndSize.Jupiter23)); + _communicator.ModDataChanged.Subscribe(OnModDataChange, ModDataChanged.Priority.ModPanelHeader); + } + + /// + /// Draw the header for the current mod, + /// consisting of its name, version, author and website, if they exist. + /// + public void Draw() + { + UpdateModData(); + var height = ImGui.GetContentRegionAvail().Y; + var maxHeight = 3 * height / 4; + using var child = _lastPreSettingsHeight > maxHeight && _communicator.PreSettingsTabBarDraw.HasSubscribers + ? ImRaii.Child("HeaderChild", new Vector2(ImGui.GetContentRegionAvail().X, maxHeight), false) + : null; + using (ImRaii.Group()) + { + var offset = DrawModName(); + DrawVersion(offset); + DrawSecondRow(offset); + } + + _communicator.PreSettingsTabBarDraw.Invoke(_mod.Identifier, ImGui.GetItemRectSize().X, _nameWidth); + _lastPreSettingsHeight = ImGui.GetCursorPosY(); + } + + public void ChangeMod(Mod mod) + { + _mod = mod; + _dirty = true; + } + + /// + /// Update all mod header data. Should someone change frame padding or item spacing, + /// or his default font, this will break, but he will just have to select a different mod to restore. + /// + private void UpdateModData() + { + if (!_dirty) + return; + + _dirty = false; + _lastPreSettingsHeight = 0; + // Name + var name = $" {_mod.Name} "; + if (name != _modName) + { + using var f = _nameFont.Push(); + _modName = name; + _modNameWidth = ImGui.CalcTextSize(name).X + 2 * (ImGui.GetStyle().FramePadding.X + 2 * UiHelpers.Scale); + } + + // Author + if (_mod.Author != _modAuthor) + { + var author = _mod.Author.IsEmpty ? string.Empty : $"by {_mod.Author}"; + _modAuthor = _mod.Author.Text; + _modAuthorWidth = ImGui.CalcTextSize(author).X; + _secondRowWidth = _modAuthorWidth + _modWebsiteButtonWidth + ImGui.GetStyle().ItemSpacing.X; + } + + // Version + var version = _mod.Version.Length > 0 ? $"({_mod.Version})" : string.Empty; + if (version != _modVersion) + { + _modVersion = version; + _modVersionWidth = ImGui.CalcTextSize(version).X; + } + + // Website + if (_modWebsite != _mod.Website) + { + _modWebsite = _mod.Website; + _websiteValid = Uri.TryCreate(_modWebsite, UriKind.Absolute, out var uriResult) + && (uriResult.Scheme == Uri.UriSchemeHttps || uriResult.Scheme == Uri.UriSchemeHttp); + _modWebsiteButton = _websiteValid ? "Open Website" : _modWebsite.Length == 0 ? string.Empty : $"from {_modWebsite}"; + _modWebsiteButtonWidth = _websiteValid + ? ImGui.CalcTextSize(_modWebsiteButton).X + 2 * ImGui.GetStyle().FramePadding.X + : ImGui.CalcTextSize(_modWebsiteButton).X; + _secondRowWidth = _modAuthorWidth + _modWebsiteButtonWidth + ImGui.GetStyle().ItemSpacing.X; + } + } + + public void Dispose() + { + _nameFont.Dispose(); + _communicator.ModDataChanged.Unsubscribe(OnModDataChange); + } + + // Header data. + private Mod _mod = null!; + private string _modName = string.Empty; + private string _modAuthor = string.Empty; + private string _modVersion = string.Empty; + private string _modWebsite = string.Empty; + private string _modWebsiteButton = string.Empty; + private bool _websiteValid; + + private float _modNameWidth; + private float _modAuthorWidth; + private float _modVersionWidth; + private float _modWebsiteButtonWidth; + private float _secondRowWidth; + + private float _nameWidth; + + /// + /// Draw the mod name in the game font with a 2px border, centered, + /// with at least the width of the version space to each side. + /// + private float DrawModName() + { + var decidingWidth = Math.Max(_secondRowWidth, ImGui.GetWindowWidth()); + var offsetWidth = (decidingWidth - _modNameWidth) / 2; + var offsetVersion = _modVersion.Length > 0 + ? _modVersionWidth + ImGui.GetStyle().ItemSpacing.X + ImGui.GetStyle().WindowPadding.X + : 0; + var offset = Math.Max(offsetWidth, offsetVersion); + if (offset > 0) + { + ImGui.SetCursorPosX(offset); + } + + using var color = ImRaii.PushColor(ImGuiCol.Border, Colors.MetaInfoText); + using var style = ImRaii.PushStyle(ImGuiStyleVar.FrameBorderSize, 2 * UiHelpers.Scale); + using var f = _nameFont.Push(); + ImGuiUtil.DrawTextButton(_modName, Vector2.Zero, 0); + _nameWidth = ImGui.GetItemRectSize().X; + return offset; + } + + /// Draw the version in the top-right corner. + private void DrawVersion(float offset) + { + var oldPos = ImGui.GetCursorPos(); + ImGui.SetCursorPos(new Vector2(2 * offset + _modNameWidth - _modVersionWidth - ImGui.GetStyle().WindowPadding.X, + ImGui.GetStyle().FramePadding.Y)); + ImGuiUtil.TextColored(Colors.MetaInfoText, _modVersion); + ImGui.SetCursorPos(oldPos); + } + + /// + /// Draw author and website if they exist. The website is a button if it is valid. + /// Usually, author begins at the left boundary of the name, + /// and website ends at the right boundary of the name. + /// If their combined width is larger than the name, they are combined-centered. + /// + private void DrawSecondRow(float offset) + { + if (_modAuthor.Length == 0) + { + if (_modWebsiteButton.Length == 0) + { + ImGui.NewLine(); + return; + } + + offset += (_modNameWidth - _modWebsiteButtonWidth) / 2; + ImGui.SetCursorPosX(offset); + DrawWebsite(); + } + else if (_modWebsiteButton.Length == 0) + { + offset += (_modNameWidth - _modAuthorWidth) / 2; + ImGui.SetCursorPosX(offset); + DrawAuthor(); + } + else if (_secondRowWidth < _modNameWidth) + { + ImGui.SetCursorPosX(offset); + DrawAuthor(); + ImGui.SameLine(offset + _modNameWidth - _modWebsiteButtonWidth); + DrawWebsite(); + } + else + { + offset -= (_secondRowWidth - _modNameWidth) / 2; + if (offset > 0) + { + ImGui.SetCursorPosX(offset); + } + + DrawAuthor(); + ImGui.SameLine(); + DrawWebsite(); + } + } + + /// Draw the author text. + private void DrawAuthor() + { + using var style = ImRaii.PushStyle(ImGuiStyleVar.ItemSpacing, Vector2.Zero); + ImGuiUtil.TextColored(Colors.MetaInfoText, "by "); + ImGui.SameLine(); + style.Pop(); + ImGui.TextUnformatted(_modAuthor); + } + + /// + /// Draw either a website button if the source is a valid website address, + /// or a source text if it is not. + /// + private void DrawWebsite() + { + if (_websiteValid) + { + if (ImGui.SmallButton(_modWebsiteButton)) + { + try + { + var process = new ProcessStartInfo(_modWebsite) + { + UseShellExecute = true, + }; + Process.Start(process); + } + catch + { + // ignored + } + } + + ImGuiUtil.HoverTooltip(_modWebsite); + } + else + { + using var style = ImRaii.PushStyle(ImGuiStyleVar.ItemSpacing, Vector2.Zero); + ImGuiUtil.TextColored(Colors.MetaInfoText, "from "); + ImGui.SameLine(); + style.Pop(); + ImGui.TextUnformatted(_modWebsite); + } + } + + /// Just update the data when any relevant field changes. + private void OnModDataChange(ModDataChangeType changeType, Mod mod, string? _2) + { + const ModDataChangeType relevantChanges = + ModDataChangeType.Author | ModDataChangeType.Name | ModDataChangeType.Website | ModDataChangeType.Version; + _dirty = (changeType & relevantChanges) != 0; + } +} diff --git a/Penumbra/UI/ModsTab/ModPanelSettingsTab.cs b/Penumbra/UI/ModsTab/ModPanelSettingsTab.cs new file mode 100644 index 00000000..84f69bcb --- /dev/null +++ b/Penumbra/UI/ModsTab/ModPanelSettingsTab.cs @@ -0,0 +1,261 @@ +using Dalamud.Bindings.ImGui; +using OtterGui.Raii; +using OtterGui.Services; +using OtterGui.Text; +using OtterGui.Widgets; +using Penumbra.UI.Classes; +using Penumbra.Collections.Manager; +using Penumbra.Mods; +using Penumbra.Mods.Manager; +using Penumbra.Services; +using Penumbra.Mods.Settings; +using Penumbra.UI.ModsTab.Groups; +using OtterGui.Extensions; + +namespace Penumbra.UI.ModsTab; + +public class ModPanelSettingsTab( + CollectionManager collectionManager, + ModManager modManager, + ModSelection selection, + TutorialService tutorial, + CommunicatorService communicator, + ModGroupDrawer modGroupDrawer, + Configuration config) + : ITab, IUiService +{ + private bool _inherited; + private bool _temporary; + private bool _locked; + private int? _currentPriority; + + public ReadOnlySpan Label + => "Settings"u8; + + public void DrawHeader() + => tutorial.OpenTutorial(BasicTutorialSteps.ModOptions); + + public void Reset() + => _currentPriority = null; + + public void DrawContent() + { + using var table = ImUtf8.Table("##settings"u8, 1, ImGuiTableFlags.ScrollY, ImGui.GetContentRegionAvail()); + if (!table) + return; + + _inherited = selection.Collection != collectionManager.Active.Current; + _temporary = selection.TemporarySettings != null; + _locked = (selection.TemporarySettings?.Lock ?? 0) > 0; + + ImGui.TableSetupScrollFreeze(0, 1); + ImGui.TableNextColumn(); + DrawTemporaryWarning(); + DrawInheritedWarning(); + ImGui.Dummy(Vector2.Zero); + communicator.PreSettingsPanelDraw.Invoke(selection.Mod!.Identifier); + DrawEnabledInput(); + tutorial.OpenTutorial(BasicTutorialSteps.EnablingMods); + ImGui.SameLine(); + DrawPriorityInput(); + tutorial.OpenTutorial(BasicTutorialSteps.Priority); + DrawRemoveSettings(); + + ImGui.TableNextColumn(); + communicator.PostEnabledDraw.Invoke(selection.Mod!.Identifier); + + modGroupDrawer.Draw(selection.Mod!, selection.Settings, selection.TemporarySettings); + UiHelpers.DefaultLineSpace(); + communicator.PostSettingsPanelDraw.Invoke(selection.Mod!.Identifier); + } + + /// Draw a big tinted bar if the current setting is temporary. + private void DrawTemporaryWarning() + { + if (!_temporary) + return; + + using var color = ImRaii.PushColor(ImGuiCol.Button, ImGuiCol.Button.Tinted(ColorId.TemporaryModSettingsTint)); + var width = new Vector2(ImGui.GetContentRegionAvail().X, 0); + if (ImUtf8.ButtonEx($"These settings are temporarily set by {selection.TemporarySettings!.Source}{(_locked ? " and locked." : ".")}", + width, + _locked)) + collectionManager.Editor.SetTemporarySettings(collectionManager.Active.Current, selection.Mod!, null); + + ImUtf8.HoverTooltip("Changing settings in temporary settings will not save them across sessions.\n"u8 + + "You can click this button to remove the temporary settings and return to your normal settings."u8); + } + + /// Draw a big red bar if the current setting is inherited. + private void DrawInheritedWarning() + { + if (!_inherited) + return; + + using var color = ImRaii.PushColor(ImGuiCol.Button, Colors.PressEnterWarningBg); + var width = new Vector2(ImGui.GetContentRegionAvail().X, 0); + if (ImUtf8.ButtonEx($"These settings are inherited from {selection.Collection.Identity.Name}.", width, _locked)) + { + if (_temporary) + { + selection.TemporarySettings!.ForceInherit = false; + collectionManager.Editor.SetTemporarySettings(collectionManager.Active.Current, selection.Mod!, selection.TemporarySettings); + } + else + { + collectionManager.Editor.SetModInheritance(collectionManager.Active.Current, selection.Mod!, false); + } + } + + ImUtf8.HoverTooltip("You can click this button to copy the current settings to the current selection.\n"u8 + + "You can also just change any setting, which will copy the settings with the single setting changed to the current selection."u8); + } + + /// Draw a checkbox for the enabled status of the mod. + private void DrawEnabledInput() + { + var enabled = selection.Settings.Enabled; + using var disabled = ImRaii.Disabled(_locked); + if (!ImUtf8.Checkbox("Enabled"u8, ref enabled)) + return; + + modManager.SetKnown(selection.Mod!); + if (_temporary || config.DefaultTemporaryMode) + { + var temporarySettings = selection.TemporarySettings ?? new TemporaryModSettings(selection.Mod!, selection.Settings); + temporarySettings.ForceInherit = false; + temporarySettings.Enabled = enabled; + collectionManager.Editor.SetTemporarySettings(collectionManager.Active.Current, selection.Mod!, temporarySettings); + } + else + { + collectionManager.Editor.SetModState(collectionManager.Active.Current, selection.Mod!, enabled); + } + } + + /// + /// Draw a priority input. + /// Priority is changed on deactivation of the input box. + /// + private void DrawPriorityInput() + { + using var group = ImUtf8.Group(); + var settings = selection.Settings; + var priority = _currentPriority ?? settings.Priority.Value; + ImGui.SetNextItemWidth(50 * UiHelpers.Scale); + using var disabled = ImRaii.Disabled(_locked); + if (ImUtf8.InputScalar("##Priority"u8, ref priority)) + _currentPriority = priority; + if (new ModPriority(priority).IsHidden) + ImUtf8.HoverTooltip(ImGuiHoveredFlags.AllowWhenDisabled, + $"This priority is special-cased to hide this mod in conflict tabs ({ModPriority.HiddenMin}, {ModPriority.HiddenMax})."); + + + if (ImGui.IsItemDeactivatedAfterEdit() && _currentPriority.HasValue) + { + if (_currentPriority != settings.Priority.Value) + { + if (_temporary || config.DefaultTemporaryMode) + { + var temporarySettings = selection.TemporarySettings ?? new TemporaryModSettings(selection.Mod!, selection.Settings); + temporarySettings.ForceInherit = false; + temporarySettings.Priority = new ModPriority(_currentPriority.Value); + collectionManager.Editor.SetTemporarySettings(collectionManager.Active.Current, selection.Mod!, + temporarySettings); + } + else + { + collectionManager.Editor.SetModPriority(collectionManager.Active.Current, selection.Mod!, + new ModPriority(_currentPriority.Value)); + } + } + + _currentPriority = null; + } + + ImUtf8.LabeledHelpMarker("Priority"u8, "Mods with a higher number here take precedence before Mods with a lower number.\n"u8 + + "That means, if Mod A should overwrite changes from Mod B, Mod A should have a higher priority number than Mod B."u8); + } + + /// + /// Draw a button to remove the current settings and inherit them instead + /// in the top-right corner of the window/tab. + /// + private void DrawRemoveSettings() + { + var drawInherited = !_inherited && !selection.Settings.IsEmpty; + var scroll = ImGui.GetScrollMaxY() > 0 ? ImGui.GetStyle().ScrollbarSize + ImGui.GetStyle().ItemInnerSpacing.X : 0; + var buttonSize = ImUtf8.CalcTextSize("Turn Permanent_"u8).X; + var offset = drawInherited + ? buttonSize + ImUtf8.CalcTextSize("Inherit Settings"u8).X + ImGui.GetStyle().FramePadding.X * 4 + ImGui.GetStyle().ItemSpacing.X + : buttonSize + ImGui.GetStyle().FramePadding.X * 2; + ImGui.SameLine(ImGui.GetWindowWidth() - offset - scroll); + var enabled = config.DeleteModModifier.IsActive(); + if (drawInherited) + { + var inherit = (enabled, _locked) switch + { + (true, false) => ImUtf8.ButtonEx("Inherit Settings"u8, + "Remove current settings from this collection so that it can inherit them.\n"u8 + + "If no inherited collection has settings for this mod, it will be disabled."u8, default, false), + (false, false) => ImUtf8.ButtonEx("Inherit Settings"u8, + $"Remove current settings from this collection so that it can inherit them.\nHold {config.DeleteModModifier} to inherit.", + default, true), + (_, true) => ImUtf8.ButtonEx("Inherit Settings"u8, + "Remove current settings from this collection so that it can inherit them.\nThe settings are currently locked and can not be changed."u8, + default, true), + }; + if (inherit) + { + if (_temporary || config.DefaultTemporaryMode) + { + var temporarySettings = selection.TemporarySettings ?? new TemporaryModSettings(selection.Mod!, selection.Settings); + temporarySettings.ForceInherit = true; + collectionManager.Editor.SetTemporarySettings(collectionManager.Active.Current, selection.Mod!, + temporarySettings); + } + else + { + collectionManager.Editor.SetModInheritance(collectionManager.Active.Current, selection.Mod!, true); + } + } + + ImGui.SameLine(); + } + + if (_temporary) + { + var overwrite = enabled + ? ImUtf8.ButtonEx("Turn Permanent"u8, + "Overwrite the actual settings for this mod in this collection with the current temporary settings."u8, + new Vector2(buttonSize, 0)) + : ImUtf8.ButtonEx("Turn Permanent"u8, + $"Overwrite the actual settings for this mod in this collection with the current temporary settings.\nHold {config.DeleteModModifier} to overwrite.", + new Vector2(buttonSize, 0), true); + if (overwrite) + { + var settings = collectionManager.Active.Current.GetTempSettings(selection.Mod!.Index)!; + if (settings.ForceInherit) + { + collectionManager.Editor.SetModInheritance(collectionManager.Active.Current, selection.Mod, true); + } + else + { + collectionManager.Editor.SetModState(collectionManager.Active.Current, selection.Mod, settings.Enabled); + collectionManager.Editor.SetModPriority(collectionManager.Active.Current, selection.Mod, settings.Priority); + foreach (var (setting, index) in settings.Settings.WithIndex()) + collectionManager.Editor.SetModSetting(collectionManager.Active.Current, selection.Mod, index, setting); + } + + collectionManager.Editor.SetTemporarySettings(collectionManager.Active.Current, selection.Mod, null); + } + } + else + { + var actual = collectionManager.Active.Current.GetActualSettings(selection.Mod!.Index).Settings; + if (ImUtf8.ButtonEx("Turn Temporary"u8, "Copy the current settings over to temporary settings to experiment with them."u8)) + collectionManager.Editor.SetTemporarySettings(collectionManager.Active.Current, selection.Mod!, + new TemporaryModSettings(selection.Mod!, actual)); + } + } +} diff --git a/Penumbra/UI/ModsTab/ModPanelTabBar.cs b/Penumbra/UI/ModsTab/ModPanelTabBar.cs new file mode 100644 index 00000000..5981d979 --- /dev/null +++ b/Penumbra/UI/ModsTab/ModPanelTabBar.cs @@ -0,0 +1,159 @@ +using Dalamud.Bindings.ImGui; +using Dalamud.Interface; +using OtterGui; +using OtterGui.Raii; +using OtterGui.Services; +using OtterGui.Widgets; +using Penumbra.Mods; +using Penumbra.Mods.Manager; +using Penumbra.UI.AdvancedWindow; + +namespace Penumbra.UI.ModsTab; + +public class ModPanelTabBar : IUiService +{ + private enum ModPanelTabType + { + Description, + Settings, + ChangedItems, + Conflicts, + Collections, + Edit, + }; + + public readonly ModPanelSettingsTab Settings; + public readonly ModPanelDescriptionTab Description; + public readonly ModPanelCollectionsTab Collections; + public readonly ModPanelConflictsTab Conflicts; + public readonly ModPanelChangedItemsTab ChangedItems; + public readonly ModPanelEditTab Edit; + private readonly ModEditWindow _modEditWindow; + private readonly ModManager _modManager; + private readonly TutorialService _tutorial; + + public readonly ITab[] Tabs; + private ModPanelTabType _preferredTab = ModPanelTabType.Settings; + private Mod? _lastMod; + + public ModPanelTabBar(ModEditWindow modEditWindow, ModPanelSettingsTab settings, ModPanelDescriptionTab description, + ModPanelConflictsTab conflicts, ModPanelChangedItemsTab changedItems, ModPanelEditTab edit, ModManager modManager, + TutorialService tutorial, ModPanelCollectionsTab collections) + { + _modEditWindow = modEditWindow; + Settings = settings; + Description = description; + Conflicts = conflicts; + ChangedItems = changedItems; + Edit = edit; + _modManager = modManager; + _tutorial = tutorial; + Collections = collections; + + Tabs = + [ + Settings, + Description, + Conflicts, + ChangedItems, + Collections, + Edit, + ]; + } + + public void Draw(Mod mod) + { + var tabBarHeight = ImGui.GetCursorPosY(); + if (_lastMod != mod) + { + _lastMod = mod; + TabBar.Draw(string.Empty, ImGuiTabBarFlags.NoTooltip, ToLabel(_preferredTab), out _, () => DrawAdvancedEditingButton(mod), Tabs); + } + else + { + TabBar.Draw(string.Empty, ImGuiTabBarFlags.NoTooltip, ReadOnlySpan.Empty, out var label, () => DrawAdvancedEditingButton(mod), + Tabs); + _preferredTab = ToType(label); + } + + DrawFavoriteButton(mod, tabBarHeight); + } + + private ReadOnlySpan ToLabel(ModPanelTabType type) + => type switch + { + ModPanelTabType.Description => Description.Label, + ModPanelTabType.Settings => Settings.Label, + ModPanelTabType.ChangedItems => ChangedItems.Label, + ModPanelTabType.Conflicts => Conflicts.Label, + ModPanelTabType.Collections => Collections.Label, + ModPanelTabType.Edit => Edit.Label, + _ => ReadOnlySpan.Empty, + }; + + private ModPanelTabType ToType(ReadOnlySpan label) + { + if (label == Description.Label) + return ModPanelTabType.Description; + if (label == Settings.Label) + return ModPanelTabType.Settings; + if (label == ChangedItems.Label) + return ModPanelTabType.ChangedItems; + if (label == Conflicts.Label) + return ModPanelTabType.Conflicts; + if (label == Collections.Label) + return ModPanelTabType.Collections; + if (label == Edit.Label) + return ModPanelTabType.Edit; + + return 0; + } + + private void DrawAdvancedEditingButton(Mod mod) + { + if (ImGui.TabItemButton("Advanced Editing", ImGuiTabItemFlags.Trailing | ImGuiTabItemFlags.NoTooltip)) + { + _modEditWindow.ChangeMod(mod); + _modEditWindow.ChangeOption(mod.Default); + _modEditWindow.IsOpen = true; + } + + ImGuiUtil.HoverTooltip( + "Clicking this will open a new window in which you can\nedit the following things per option for this mod:\n\n" + + "\t\t- file redirections\n" + + "\t\t- file swaps\n" + + "\t\t- metadata manipulations\n" + + "\t\t- model materials\n" + + "\t\t- duplicates\n" + + "\t\t- textures"); + } + + private void DrawFavoriteButton(Mod mod, float height) + { + using (var font = ImRaii.PushFont(UiBuilder.IconFont)) + { + var size = ImGui.CalcTextSize(FontAwesomeIcon.Star.ToIconString()) + ImGui.GetStyle().FramePadding * 2; + var newPos = new Vector2(ImGui.GetWindowWidth() - size.X - ImGui.GetStyle().ItemSpacing.X, height); + if (ImGui.GetScrollMaxX() > 0) + newPos.X += ImGui.GetScrollX(); + + var rectUpper = ImGui.GetWindowPos() + newPos; + var color = ImGui.IsMouseHoveringRect(rectUpper, rectUpper + size) ? ImGui.GetColorU32(ImGuiCol.Text) : + mod.Favorite ? 0xFF00FFFF : ImGui.GetColorU32(ImGuiCol.TextDisabled); + using var c = ImRaii.PushColor(ImGuiCol.Text, color) + .Push(ImGuiCol.Button, 0) + .Push(ImGuiCol.ButtonHovered, 0) + .Push(ImGuiCol.ButtonActive, 0); + + ImGui.SetCursorPos(newPos); + if (ImGui.Button(FontAwesomeIcon.Star.ToIconString())) + _modManager.DataEditor.ChangeModFavorite(mod, !mod.Favorite); + } + + var hovered = ImGui.IsItemHovered(); + _tutorial.OpenTutorial(BasicTutorialSteps.Favorites); + + if (hovered) + ImGui.SetTooltip("Favorite"); + } +} diff --git a/Penumbra/UI/ModsTab/ModSearchStringSplitter.cs b/Penumbra/UI/ModsTab/ModSearchStringSplitter.cs new file mode 100644 index 00000000..1eff1919 --- /dev/null +++ b/Penumbra/UI/ModsTab/ModSearchStringSplitter.cs @@ -0,0 +1,138 @@ +using OtterGui.Filesystem; +using OtterGui.Filesystem.Selector; +using Penumbra.Mods; +using Penumbra.Mods.Manager; + +namespace Penumbra.UI.ModsTab; + +public enum ModSearchType : byte +{ + Default = 0, + ChangedItem, + Tag, + Name, + Author, + Category, +} + +public sealed class ModSearchStringSplitter : SearchStringSplitter.Leaf, ModSearchStringSplitter.Entry> +{ + public readonly struct Entry : ISplitterEntry + { + public string Needle { get; init; } + public ModSearchType Type { get; init; } + public ChangedItemIconFlag IconFlagFilter { get; init; } + + public bool Contains(Entry other) + { + if (Type != other.Type) + return false; + if (Type is ModSearchType.Category) + return IconFlagFilter == other.IconFlagFilter; + + return Needle.Contains(other.Needle); + } + } + + protected override bool ConvertToken(char token, out ModSearchType val) + { + val = token switch + { + 'c' or 'C' => ModSearchType.ChangedItem, + 't' or 'T' => ModSearchType.Tag, + 'n' or 'N' => ModSearchType.Name, + 'a' or 'A' => ModSearchType.Author, + 's' or 'S' => ModSearchType.Category, + _ => ModSearchType.Default, + }; + return val is not ModSearchType.Default; + } + + protected override bool AllowsNone(ModSearchType val) + => val switch + { + ModSearchType.Author => true, + ModSearchType.ChangedItem => true, + ModSearchType.Tag => true, + ModSearchType.Category => true, + _ => false, + }; + + protected override void PostProcessing() + { + base.PostProcessing(); + HandleList(General); + HandleList(Forced); + HandleList(Negated); + return; + + static void HandleList(List list) + { + for (var i = 0; i < list.Count; ++i) + { + var entry = list[i]; + if (entry.Type is not ModSearchType.Category) + continue; + + if (ChangedItemDrawer.TryParsePartial(entry.Needle, out var icon)) + list[i] = entry with + { + IconFlagFilter = icon, + Needle = string.Empty, + }; + else + list.RemoveAt(i--); + } + } + } + + public bool IsVisible(ModFileSystem.Folder folder) + { + switch (State) + { + case FilterState.NoFilters: return true; + case FilterState.NoMatches: return false; + } + + var fullName = folder.FullName(); + return Forced.All(i => MatchesName(i, folder.Name, fullName, false)) + && !Negated.Any(i => MatchesName(i, folder.Name, fullName, true)) + && (General.Count == 0 || General.Any(i => MatchesName(i, folder.Name, fullName, false))); + } + + protected override bool Matches(Entry entry, ModFileSystem.Leaf leaf) + => entry.Type switch + { + ModSearchType.Default => leaf.FullName().AsSpan().Contains(entry.Needle, StringComparison.OrdinalIgnoreCase) + || leaf.Value.Name.Lower.AsSpan().Contains(entry.Needle, StringComparison.Ordinal), + ModSearchType.ChangedItem => leaf.Value.LowerChangedItemsString.AsSpan().Contains(entry.Needle, StringComparison.Ordinal), + ModSearchType.Tag => leaf.Value.AllTagsLower.AsSpan().Contains(entry.Needle, StringComparison.Ordinal), + ModSearchType.Name => leaf.Value.Name.Lower.AsSpan().Contains(entry.Needle, StringComparison.Ordinal), + ModSearchType.Author => leaf.Value.Author.Lower.AsSpan().Contains(entry.Needle, StringComparison.Ordinal), + ModSearchType.Category => leaf.Value.ChangedItems.Any(p + => ((p.Value?.Icon.ToFlag() ?? ChangedItemIconFlag.Unknown) & entry.IconFlagFilter) != 0), + _ => true, + }; + + protected override bool MatchesNone(ModSearchType type, bool negated, ModFileSystem.Leaf haystack) + => type switch + { + ModSearchType.Author when negated => !haystack.Value.Author.IsEmpty, + ModSearchType.Author => haystack.Value.Author.IsEmpty, + ModSearchType.ChangedItem when negated => haystack.Value.LowerChangedItemsString.Length > 0, + ModSearchType.ChangedItem => haystack.Value.LowerChangedItemsString.Length == 0, + ModSearchType.Tag when negated => haystack.Value.AllTagsLower.Length > 0, + ModSearchType.Tag => haystack.Value.AllTagsLower.Length == 0, + ModSearchType.Category when negated => haystack.Value.ChangedItems.Count > 0, + ModSearchType.Category => haystack.Value.ChangedItems.Count == 0, + _ => true, + }; + + private static bool MatchesName(Entry entry, ReadOnlySpan name, ReadOnlySpan fullName, bool defaultValue) + => entry.Type switch + { + ModSearchType.Default => fullName.Contains(entry.Needle, StringComparison.OrdinalIgnoreCase), + ModSearchType.Name => name.Contains(entry.Needle, StringComparison.OrdinalIgnoreCase), + _ => defaultValue, + }; +} diff --git a/Penumbra/UI/ModsTab/MultiModPanel.cs b/Penumbra/UI/ModsTab/MultiModPanel.cs new file mode 100644 index 00000000..947ede14 --- /dev/null +++ b/Penumbra/UI/ModsTab/MultiModPanel.cs @@ -0,0 +1,162 @@ +using Dalamud.Bindings.ImGui; +using Dalamud.Interface; +using Dalamud.Interface.Utility; +using OtterGui.Extensions; +using OtterGui.Filesystem; +using OtterGui.Raii; +using OtterGui.Services; +using OtterGui.Text; +using Penumbra.Mods; +using Penumbra.Mods.Manager; + +namespace Penumbra.UI.ModsTab; + +public class MultiModPanel(ModFileSystemSelector selector, ModDataEditor editor, PredefinedTagManager tagManager) : IUiService +{ + public void Draw() + { + if (selector.SelectedPaths.Count == 0) + return; + + ImGui.NewLine(); + var treeNodePos = ImGui.GetCursorPos(); + var numLeaves = DrawModList(); + DrawCounts(treeNodePos, numLeaves); + DrawMultiTagger(); + } + + private void DrawCounts(Vector2 treeNodePos, int numLeaves) + { + var startPos = ImGui.GetCursorPos(); + var numFolders = selector.SelectedPaths.Count - numLeaves; + var text = (numLeaves, numFolders) switch + { + (0, 0) => string.Empty, // should not happen + (> 0, 0) => $"{numLeaves} Mods", + (0, > 0) => $"{numFolders} Folders", + _ => $"{numLeaves} Mods, {numFolders} Folders", + }; + ImGui.SetCursorPos(treeNodePos); + ImUtf8.TextRightAligned(text); + ImGui.SetCursorPos(startPos); + } + + private int DrawModList() + { + using var tree = ImUtf8.TreeNode("Currently Selected Objects###Selected"u8, + ImGuiTreeNodeFlags.DefaultOpen | ImGuiTreeNodeFlags.NoTreePushOnOpen); + ImGui.Separator(); + + + if (!tree) + return selector.SelectedPaths.Count(l => l is ModFileSystem.Leaf); + + var sizeType = new Vector2(ImGui.GetFrameHeight()); + var availableSizePercent = (ImGui.GetContentRegionAvail().X - sizeType.X - 4 * ImGui.GetStyle().CellPadding.X) / 100; + var sizeMods = availableSizePercent * 35; + var sizeFolders = availableSizePercent * 65; + + var leaves = 0; + using (var table = ImUtf8.Table("mods"u8, 3, ImGuiTableFlags.RowBg)) + { + if (!table) + return selector.SelectedPaths.Count(l => l is ModFileSystem.Leaf); + + ImUtf8.TableSetupColumn("type"u8, ImGuiTableColumnFlags.WidthFixed, sizeType.X); + ImUtf8.TableSetupColumn("mod"u8, ImGuiTableColumnFlags.WidthFixed, sizeMods); + ImUtf8.TableSetupColumn("path"u8, 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++); + var (icon, text) = path is ModFileSystem.Leaf l + ? (FontAwesomeIcon.FileCircleMinus, l.Value.Name.Text) + : (FontAwesomeIcon.FolderMinus, string.Empty); + ImGui.TableNextColumn(); + if (ImUtf8.IconButton(icon, "Remove from selection."u8, sizeType)) + selector.RemovePathFromMultiSelection(path); + + ImUtf8.DrawFrameColumn(text); + ImUtf8.DrawFrameColumn(fullName); + if (path is ModFileSystem.Leaf) + ++leaves; + } + } + + ImGui.Separator(); + return leaves; + } + + private string _tag = string.Empty; + private readonly List _addMods = []; + private readonly List<(Mod, int)> _removeMods = []; + + private void DrawMultiTagger() + { + var width = ImGuiHelpers.ScaledVector2(150, 0); + ImUtf8.TextFrameAligned("Multi Tagger:"u8); + ImGui.SameLine(); + + var predefinedTagsEnabled = tagManager.Enabled; + var inputWidth = predefinedTagsEnabled + ? ImGui.GetContentRegionAvail().X - 2 * width.X - 3 * ImGui.GetStyle().ItemInnerSpacing.X - ImGui.GetFrameHeight() + : ImGui.GetContentRegionAvail().X - 2 * (width.X + ImGui.GetStyle().ItemInnerSpacing.X); + ImGui.SetNextItemWidth(inputWidth); + ImUtf8.InputText("##tag"u8, ref _tag, "Local Tag Name..."u8); + + UpdateTagCache(); + var label = _addMods.Count > 0 + ? $"Add to {_addMods.Count} Mods" + : "Add"; + var tooltip = _addMods.Count == 0 + ? _tag.Length == 0 + ? "No tag specified." + : $"All mods selected already contain the tag \"{_tag}\", either locally or as mod data." + : $"Add the tag \"{_tag}\" to {_addMods.Count} mods as a local tag:\n\n\t{string.Join("\n\t", _addMods.Select(m => m.Name.Text))}"; + ImUtf8.SameLineInner(); + if (ImUtf8.ButtonEx(label, tooltip, width, _addMods.Count == 0)) + foreach (var mod in _addMods) + editor.ChangeLocalTag(mod, mod.LocalTags.Count, _tag); + + label = _removeMods.Count > 0 + ? $"Remove from {_removeMods.Count} Mods" + : "Remove"; + tooltip = _removeMods.Count == 0 + ? _tag.Length == 0 + ? "No tag specified." + : $"No selected mod contains the tag \"{_tag}\" locally." + : $"Remove the local tag \"{_tag}\" from {_removeMods.Count} mods:\n\n\t{string.Join("\n\t", _removeMods.Select(m => m.Item1.Name.Text))}"; + ImUtf8.SameLineInner(); + if (ImUtf8.ButtonEx(label, tooltip, width, _removeMods.Count == 0)) + foreach (var (mod, index) in _removeMods) + editor.ChangeLocalTag(mod, index, string.Empty); + + if (predefinedTagsEnabled) + { + ImUtf8.SameLineInner(); + tagManager.DrawToggleButton(); + tagManager.DrawListMulti(selector.SelectedPaths.OfType().Select(l => l.Value)); + } + + ImGui.Separator(); + } + + private void UpdateTagCache() + { + _addMods.Clear(); + _removeMods.Clear(); + if (_tag.Length == 0) + return; + + foreach (var leaf in selector.SelectedPaths.OfType()) + { + var index = leaf.Value.LocalTags.IndexOf(_tag); + if (index >= 0) + _removeMods.Add((leaf.Value, index)); + else if (!leaf.Value.ModTags.Contains(_tag)) + _addMods.Add(leaf.Value); + } + } +} diff --git a/Penumbra/UI/ModsTab/RenameField.cs b/Penumbra/UI/ModsTab/RenameField.cs new file mode 100644 index 00000000..00232750 --- /dev/null +++ b/Penumbra/UI/ModsTab/RenameField.cs @@ -0,0 +1,26 @@ +namespace Penumbra.UI.ModsTab; + +public enum RenameField +{ + None, + RenameSearchPath, + RenameData, + BothSearchPathPrio, + BothDataPrio, +} + +public static class RenameFieldExtensions +{ + public static (string Name, string Desc) GetData(this RenameField value) + => value switch + { + RenameField.None => ("None", "Show no rename fields in the context menu for mods."), + RenameField.RenameSearchPath => ("Search Path", "Show only the search path / move field in the context menu for mods."), + RenameField.RenameData => ("Mod Name", "Show only the mod name field in the context menu for mods."), + RenameField.BothSearchPathPrio => ("Both (Focus Search Path)", + "Show both rename fields in the context menu for mods, but put the keyboard cursor on the search path field."), + RenameField.BothDataPrio => ("Both (Focus Mod Name)", + "Show both rename fields in the context menu for mods, but put the keyboard cursor on the mod name field"), + _ => (string.Empty, string.Empty), + }; +} diff --git a/Penumbra/UI/PredefinedTagManager.cs b/Penumbra/UI/PredefinedTagManager.cs new file mode 100644 index 00000000..5a3a4b62 --- /dev/null +++ b/Penumbra/UI/PredefinedTagManager.cs @@ -0,0 +1,299 @@ +using Dalamud.Bindings.ImGui; +using Dalamud.Interface; +using Dalamud.Interface.ImGuiNotification; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using OtterGui; +using OtterGui.Classes; +using OtterGui.Extensions; +using OtterGui.Raii; +using OtterGui.Services; +using OtterGui.Text; +using Penumbra.Mods; +using Penumbra.Mods.Manager; +using Penumbra.Services; +using Penumbra.UI.Classes; + +namespace Penumbra.UI; + +public sealed class PredefinedTagManager : ISavable, IReadOnlyList, IService +{ + public const int Version = 1; + + public record struct TagData + { } + + private readonly ModManager _modManager; + private readonly SaveService _saveService; + + private bool _isListOpen = false; + private uint _enabledColor; + private uint _disabledColor; + + private readonly SortedList _predefinedTags = []; + + public PredefinedTagManager(ModManager modManager, SaveService saveService) + { + _modManager = modManager; + _saveService = saveService; + Load(); + } + + public string ToFilename(FilenameService fileNames) + => fileNames.PredefinedTagFile; + + public void Save(StreamWriter writer) + { + using var jWriter = new JsonTextWriter(writer); + jWriter.Formatting = Formatting.Indented; + var jObj = new JObject() + { + ["Version"] = Version, + ["Tags"] = JObject.FromObject(_predefinedTags), + }; + jObj.WriteTo(jWriter); + } + + public bool Enabled + => Count > 0; + + public void Save() + => _saveService.DelaySave(this, TimeSpan.FromSeconds(5)); + + private void Load() + { + if (!File.Exists(_saveService.FileNames.PredefinedTagFile)) + return; + + try + { + var text = File.ReadAllText(_saveService.FileNames.PredefinedTagFile); + var jObj = JObject.Parse(text); + var version = jObj["Version"]?.ToObject() ?? 0; + switch (version) + { + case 1: + var tags = jObj["Tags"]?.ToObject>() ?? []; + foreach (var (tag, data) in tags) + _predefinedTags.TryAdd(tag, data); + break; + default: throw new Exception($"Invalid version {version}."); + } + } + catch (Exception ex) + { + Penumbra.Messager.NotificationMessage(ex, + "Error reading predefined tags Configuration, reverting to default.", + "Error reading predefined tags Configuration", NotificationType.Error); + } + } + + public void ChangeSharedTag(int tagIdx, string tag) + { + if (tagIdx < 0 || tagIdx > _predefinedTags.Count) + return; + + if (tagIdx != _predefinedTags.Count) + _predefinedTags.RemoveAt(tagIdx); + + if (!string.IsNullOrEmpty(tag)) + _predefinedTags.TryAdd(tag, default); + + Save(); + } + + public void DrawAddFromSharedTagsAndUpdateTags(IReadOnlyCollection localTags, IReadOnlyCollection modTags, bool editLocal, + Mod mod) + { + DrawToggleButtonTopRight(); + if (!DrawList(localTags, modTags, editLocal, out var changedTag, out var index)) + return; + + if (editLocal) + _modManager.DataEditor.ChangeLocalTag(mod, index, changedTag); + else + _modManager.DataEditor.ChangeModTag(mod, index, changedTag); + } + + public void DrawToggleButton() + { + using var color = ImRaii.PushColor(ImGuiCol.Button, ImGui.GetColorU32(ImGuiCol.ButtonActive), _isListOpen); + if (ImGuiUtil.DrawDisabledButton(FontAwesomeIcon.Tags.ToIconString(), new Vector2(ImGui.GetFrameHeight()), + "Add Predefined Tags...", false, true)) + _isListOpen = !_isListOpen; + } + + private void DrawToggleButtonTopRight() + { + ImGui.SameLine(ImGui.GetContentRegionMax().X + - ImGui.GetFrameHeight() + - (ImGui.GetScrollMaxY() > 0 ? ImGui.GetStyle().ItemInnerSpacing.X : 0)); + DrawToggleButton(); + } + + private bool DrawList(IReadOnlyCollection localTags, IReadOnlyCollection modTags, bool editLocal, out string changedTag, + out int changedIndex) + { + changedTag = string.Empty; + changedIndex = -1; + + if (!_isListOpen) + return false; + + ImUtf8.Text("Predefined Tags"u8); + ImGui.Separator(); + + var ret = false; + _enabledColor = ColorId.PredefinedTagAdd.Value(); + _disabledColor = ColorId.PredefinedTagRemove.Value(); + var (edited, others) = editLocal ? (localTags, modTags) : (modTags, localTags); + foreach (var (tag, idx) in _predefinedTags.Keys.WithIndex()) + { + var tagIdx = edited.IndexOf(tag); + var inOther = tagIdx < 0 && others.IndexOf(tag) >= 0; + if (DrawColoredButton(tag, idx, tagIdx, inOther)) + { + (changedTag, changedIndex) = tagIdx >= 0 ? (string.Empty, tagIdx) : (tag, edited.Count); + ret = true; + } + + ImGui.SameLine(); + } + + ImGui.NewLine(); + ImGui.Separator(); + return ret; + } + + private readonly List _selectedMods = []; + private readonly List<(int Index, int DataIndex)> _countedMods = []; + + private void PrepareLists(IEnumerable selection) + { + _selectedMods.Clear(); + _selectedMods.AddRange(selection); + _countedMods.EnsureCapacity(_selectedMods.Count); + while (_countedMods.Count < _selectedMods.Count) + _countedMods.Add((-1, -1)); + } + + public void DrawListMulti(IEnumerable selection) + { + if (!_isListOpen) + return; + + ImUtf8.Text("Predefined Tags"u8); + PrepareLists(selection); + + _enabledColor = ColorId.PredefinedTagAdd.Value(); + _disabledColor = ColorId.PredefinedTagRemove.Value(); + using var color = new ImRaii.Color(); + foreach (var (tag, idx) in _predefinedTags.Keys.WithIndex()) + { + var alreadyContained = 0; + var inModData = 0; + var missing = 0; + + foreach (var (modIndex, mod) in _selectedMods.Index()) + { + var tagIdx = mod.LocalTags.IndexOf(tag); + if (tagIdx >= 0) + { + ++alreadyContained; + _countedMods[modIndex] = (tagIdx, -1); + } + else + { + var dataIdx = mod.ModTags.IndexOf(tag); + if (dataIdx >= 0) + { + ++inModData; + _countedMods[modIndex] = (-1, dataIdx); + } + else + { + ++missing; + _countedMods[modIndex] = (-1, -1); + } + } + } + + using var id = ImRaii.PushId(idx); + var buttonWidth = CalcTextButtonWidth(tag); + // Prevent adding a new tag past the right edge of the popup + if (buttonWidth + ImGui.GetStyle().ItemSpacing.X >= ImGui.GetContentRegionAvail().X) + ImGui.NewLine(); + + var (usedColor, disabled, tt) = (missing, alreadyContained) switch + { + (> 0, _) => (_enabledColor, false, + $"Add this tag to {missing} mods.{(inModData > 0 ? $" {inModData} mods contain it in their mod tags and are untouched." : string.Empty)}"), + (_, > 0) => (_disabledColor, false, + $"Remove this tag from {alreadyContained} mods.{(inModData > 0 ? $" {inModData} mods contain it in their mod tags and are untouched." : string.Empty)}"), + _ => (_disabledColor, true, "This tag is already present in the mod tags of all selected mods."), + }; + color.Push(ImGuiCol.Button, usedColor); + if (ImUtf8.ButtonEx(tag, tt, new Vector2(buttonWidth, 0), disabled)) + { + if (missing > 0) + foreach (var (mod, (localIdx, _)) in _selectedMods.Zip(_countedMods)) + { + if (localIdx >= 0) + continue; + + _modManager.DataEditor.ChangeLocalTag(mod, mod.LocalTags.Count, tag); + } + else + foreach (var (mod, (localIdx, _)) in _selectedMods.Zip(_countedMods)) + { + if (localIdx < 0) + continue; + + _modManager.DataEditor.ChangeLocalTag(mod, localIdx, string.Empty); + } + } + ImGui.SameLine(); + + color.Pop(); + } + + ImGui.NewLine(); + } + + private bool DrawColoredButton(string buttonLabel, int index, int tagIdx, bool inOther) + { + using var id = ImRaii.PushId(index); + var buttonWidth = CalcTextButtonWidth(buttonLabel); + // Prevent adding a new tag past the right edge of the popup + if (buttonWidth + ImGui.GetStyle().ItemSpacing.X >= ImGui.GetContentRegionAvail().X) + ImGui.NewLine(); + + bool ret; + using (ImRaii.Disabled(inOther)) + { + using var color = ImRaii.PushColor(ImGuiCol.Button, tagIdx >= 0 || inOther ? _disabledColor : _enabledColor); + ret = ImGui.Button(buttonLabel); + } + + if (inOther && ImGui.IsItemHovered(ImGuiHoveredFlags.AllowWhenDisabled)) + ImGui.SetTooltip("This tag is already present in the other set of tags."); + + + return ret; + } + + private static float CalcTextButtonWidth(string text) + => ImGui.CalcTextSize(text).X + 2 * ImGui.GetStyle().FramePadding.X; + + public IEnumerator GetEnumerator() + => _predefinedTags.Keys.GetEnumerator(); + + IEnumerator IEnumerable.GetEnumerator() + => GetEnumerator(); + + public int Count + => _predefinedTags.Count; + + public string this[int index] + => _predefinedTags.Keys[index]; +} diff --git a/Penumbra/UI/ResourceWatcher/Record.cs b/Penumbra/UI/ResourceWatcher/Record.cs new file mode 100644 index 00000000..ba718bc9 --- /dev/null +++ b/Penumbra/UI/ResourceWatcher/Record.cs @@ -0,0 +1,207 @@ +using OtterGui.Classes; +using Penumbra.Collections; +using Penumbra.Enums; +using Penumbra.Interop; +using Penumbra.Interop.Structs; +using Penumbra.String; +using Penumbra.String.Classes; + +namespace Penumbra.UI.ResourceWatcher; + +[Flags] +public enum RecordType : byte +{ + Request = 0x01, + ResourceLoad = 0x02, + FileLoad = 0x04, + Destruction = 0x08, + ResourceComplete = 0x10, +} + +internal unsafe struct Record +{ + public DateTime Time; + public CiByteString Path; + public CiByteString OriginalPath; + public string AssociatedGameObject; + public ModCollection? Collection; + public ResourceHandle* Handle; + public ResourceTypeFlag ResourceType; + public ulong Crc64; + public uint RefCount; + public ResourceCategoryFlag Category; + public RecordType RecordType; + public OptionalBool Synchronously; + public OptionalBool ReturnValue; + public OptionalBool CustomLoad; + public LoadState LoadState; + public uint OsThreadId; + + + public static Record CreateRequest(CiByteString path, bool sync) + => new() + { + Time = DateTime.UtcNow, + Path = path.IsOwned ? path : path.Clone(), + OriginalPath = CiByteString.Empty, + Collection = null, + Handle = null, + ResourceType = ResourceExtensions.Type(path).ToFlag(), + Category = ResourceExtensions.Category(path).ToFlag(), + RefCount = 0, + RecordType = RecordType.Request, + Synchronously = sync, + ReturnValue = OptionalBool.Null, + CustomLoad = OptionalBool.Null, + AssociatedGameObject = string.Empty, + LoadState = LoadState.None, + Crc64 = 0, + OsThreadId = ProcessThreadApi.GetCurrentThreadId(), + }; + + public static Record CreateRequest(CiByteString path, bool sync, FullPath fullPath, ResolveData resolve) + => new() + { + Time = DateTime.UtcNow, + Path = fullPath.InternalName.IsOwned ? fullPath.InternalName : fullPath.InternalName.Clone(), + OriginalPath = path.IsOwned ? path : path.Clone(), + Collection = resolve.Valid ? resolve.ModCollection : null, + Handle = null, + ResourceType = ResourceExtensions.Type(path).ToFlag(), + Category = ResourceExtensions.Category(path).ToFlag(), + RefCount = 0, + RecordType = RecordType.Request, + Synchronously = sync, + ReturnValue = OptionalBool.Null, + CustomLoad = fullPath.InternalName != path, + AssociatedGameObject = string.Empty, + LoadState = LoadState.None, + Crc64 = fullPath.Crc64, + OsThreadId = ProcessThreadApi.GetCurrentThreadId(), + }; + + public static Record CreateDefaultLoad(CiByteString path, ResourceHandle* handle, ModCollection collection, string associatedGameObject) + { + path = path.IsOwned ? path : path.Clone(); + return new Record + { + Time = DateTime.UtcNow, + Path = path, + OriginalPath = path, + Collection = collection, + Handle = handle, + ResourceType = handle->FileType.ToFlag(), + Category = handle->Category.ToFlag(), + RefCount = handle->RefCount, + RecordType = RecordType.ResourceLoad, + Synchronously = OptionalBool.Null, + ReturnValue = OptionalBool.Null, + CustomLoad = false, + AssociatedGameObject = associatedGameObject, + LoadState = handle->LoadState, + Crc64 = 0, + OsThreadId = ProcessThreadApi.GetCurrentThreadId(), + }; + } + + public static Record CreateLoad(FullPath path, CiByteString originalPath, ResourceHandle* handle, ModCollection collection, + string associatedGameObject) + => new() + { + Time = DateTime.UtcNow, + Path = path.InternalName.IsOwned ? path.InternalName : path.InternalName.Clone(), + OriginalPath = originalPath.IsOwned ? originalPath : originalPath.Clone(), + Collection = collection, + Handle = handle, + ResourceType = handle->FileType.ToFlag(), + Category = handle->Category.ToFlag(), + RefCount = handle->RefCount, + RecordType = RecordType.ResourceLoad, + Synchronously = OptionalBool.Null, + ReturnValue = OptionalBool.Null, + CustomLoad = true, + AssociatedGameObject = associatedGameObject, + LoadState = handle->LoadState, + Crc64 = path.Crc64, + OsThreadId = ProcessThreadApi.GetCurrentThreadId(), + }; + + public static Record CreateDestruction(ResourceHandle* handle) + { + var path = handle->FileName().Clone(); + return new Record + { + Time = DateTime.UtcNow, + Path = path, + OriginalPath = CiByteString.Empty, + Collection = null, + Handle = handle, + ResourceType = handle->FileType.ToFlag(), + Category = handle->Category.ToFlag(), + RefCount = handle->RefCount, + RecordType = RecordType.Destruction, + Synchronously = OptionalBool.Null, + ReturnValue = OptionalBool.Null, + CustomLoad = OptionalBool.Null, + AssociatedGameObject = string.Empty, + LoadState = handle->LoadState, + Crc64 = 0, + OsThreadId = ProcessThreadApi.GetCurrentThreadId(), + }; + } + + public static Record CreateFileLoad(CiByteString path, ResourceHandle* handle, bool ret, bool custom) + => new() + { + Time = DateTime.UtcNow, + Path = path.IsOwned ? path : path.Clone(), + OriginalPath = CiByteString.Empty, + Collection = null, + Handle = handle, + ResourceType = handle->FileType.ToFlag(), + Category = handle->Category.ToFlag(), + RefCount = handle->RefCount, + RecordType = RecordType.FileLoad, + Synchronously = OptionalBool.Null, + ReturnValue = ret, + CustomLoad = custom, + AssociatedGameObject = string.Empty, + LoadState = handle->LoadState, + Crc64 = 0, + OsThreadId = ProcessThreadApi.GetCurrentThreadId(), + }; + + public static Record CreateResourceComplete(CiByteString path, ResourceHandle* handle, Utf8GamePath originalPath, ReadOnlySpan additionalData) + => new() + { + Time = DateTime.UtcNow, + Path = CombinedPath(path, additionalData), + OriginalPath = originalPath.Path.IsOwned ? originalPath.Path : originalPath.Path.Clone(), + Collection = null, + Handle = handle, + ResourceType = handle->FileType.ToFlag(), + Category = handle->Category.ToFlag(), + RefCount = handle->RefCount, + RecordType = RecordType.ResourceComplete, + Synchronously = false, + ReturnValue = OptionalBool.Null, + CustomLoad = OptionalBool.Null, + AssociatedGameObject = string.Empty, + LoadState = handle->LoadState, + Crc64 = 0, + OsThreadId = ProcessThreadApi.GetCurrentThreadId(), + }; + + private static CiByteString CombinedPath(CiByteString path, ReadOnlySpan additionalData) + { + if (additionalData.Length is 0) + return path.IsOwned ? path : path.Clone(); + + fixed (byte* ptr = additionalData) + { + // If a path has additional data and is split, it is always in the form of |{additionalData}|{path}, + // so we can just read from the start of additional data - 1 and sum their length +2 for the pipes. + return new CiByteString(new ReadOnlySpan(ptr - 1, additionalData.Length + 2 + path.Length)).Clone(); + } + } +} diff --git a/Penumbra/UI/ResourceWatcher/ResourceWatcher.cs b/Penumbra/UI/ResourceWatcher/ResourceWatcher.cs new file mode 100644 index 00000000..ee3613fc --- /dev/null +++ b/Penumbra/UI/ResourceWatcher/ResourceWatcher.cs @@ -0,0 +1,338 @@ +using Dalamud.Bindings.ImGui; +using FFXIVClientStructs.FFXIV.Client.Game.Object; +using FFXIVClientStructs.FFXIV.Client.System.Resource; +using OtterGui.Raii; +using OtterGui.Services; +using OtterGui.Widgets; +using Penumbra.Api.Enums; +using Penumbra.Collections; +using Penumbra.GameData.Actors; +using Penumbra.GameData.Enums; +using Penumbra.Interop.Hooks.ResourceLoading; +using Penumbra.Interop.Hooks.Resources; +using Penumbra.Interop.Structs; +using Penumbra.String; +using Penumbra.String.Classes; +using Penumbra.UI.Classes; + +namespace Penumbra.UI.ResourceWatcher; + +public sealed class ResourceWatcher : IDisposable, ITab, IUiService +{ + public const int DefaultMaxEntries = 1024; + public const RecordType AllRecords = RecordType.Request | RecordType.ResourceLoad | RecordType.FileLoad | RecordType.Destruction; + + private readonly Configuration _config; + private readonly EphemeralConfig _ephemeral; + private readonly ResourceService _resources; + private readonly ResourceLoader _loader; + private readonly ResourceHandleDestructor _destructor; + private readonly ActorManager _actors; + private readonly List _records = []; + private readonly ConcurrentQueue _newRecords = []; + private readonly ResourceWatcherTable _table; + private string _logFilter = string.Empty; + private Regex? _logRegex; + private int _newMaxEntries; + + public unsafe ResourceWatcher(ActorManager actors, Configuration config, ResourceService resources, ResourceLoader loader, + ResourceHandleDestructor destructor) + { + _actors = actors; + _config = config; + _ephemeral = config.Ephemeral; + _resources = resources; + _destructor = destructor; + _loader = loader; + _table = new ResourceWatcherTable(config.Ephemeral, _records); + _resources.ResourceRequested += OnResourceRequested; + _destructor.Subscribe(OnResourceDestroyed, ResourceHandleDestructor.Priority.ResourceWatcher); + _loader.ResourceLoaded += OnResourceLoaded; + _loader.ResourceComplete += OnResourceComplete; + _loader.FileLoaded += OnFileLoaded; + _loader.PapRequested += OnPapRequested; + UpdateFilter(_ephemeral.ResourceLoggingFilter, false); + _newMaxEntries = _config.MaxResourceWatcherRecords; + } + + private void OnPapRequested(Utf8GamePath original, FullPath? _1, ResolveData _2) + { + if (_ephemeral.EnableResourceLogging && FilterMatch(original.Path, out var match)) + { + Penumbra.Log.Information($"[ResourceLoader] [REQ] {match} was requested asynchronously."); + if (_1.HasValue) + Penumbra.Log.Information( + $"[ResourceLoader] [LOAD] Resolved {_1.Value.FullName} for {match} from collection {_2.ModCollection} for object 0x{_2.AssociatedGameObject:X}."); + } + + if (!_ephemeral.EnableResourceWatcher) + return; + + var record = _1.HasValue + ? Record.CreateRequest(original.Path, false, _1.Value, _2) + : Record.CreateRequest(original.Path, false); + if (!_ephemeral.OnlyAddMatchingResources || _table.WouldBeVisible(record)) + _newRecords.Enqueue(record); + } + + public unsafe void Dispose() + { + Clear(); + _records.TrimExcess(); + _resources.ResourceRequested -= OnResourceRequested; + _destructor.Unsubscribe(OnResourceDestroyed); + _loader.ResourceLoaded -= OnResourceLoaded; + _loader.ResourceComplete -= OnResourceComplete; + _loader.FileLoaded -= OnFileLoaded; + _loader.PapRequested -= OnPapRequested; + } + + private void Clear() + { + _records.Clear(); + _newRecords.Clear(); + _table.Reset(); + } + + public ReadOnlySpan Label + => "Resource Logger"u8; + + public void DrawContent() + { + UpdateRecords(); + + ImGui.SetCursorPosY(ImGui.GetCursorPosY() + ImGui.GetTextLineHeightWithSpacing() / 2); + var isEnabled = _ephemeral.EnableResourceWatcher; + if (ImGui.Checkbox("Enable", ref isEnabled)) + { + _ephemeral.EnableResourceWatcher = isEnabled; + _ephemeral.Save(); + } + + ImGui.SameLine(); + DrawMaxEntries(); + ImGui.SameLine(); + if (ImGui.Button("Clear")) + Clear(); + + ImGui.SameLine(); + var onlyMatching = _ephemeral.OnlyAddMatchingResources; + if (ImGui.Checkbox("Store Only Matching", ref onlyMatching)) + { + _ephemeral.OnlyAddMatchingResources = onlyMatching; + _ephemeral.Save(); + } + + ImGui.SameLine(); + var writeToLog = _ephemeral.EnableResourceLogging; + if (ImGui.Checkbox("Write to Log", ref writeToLog)) + { + _ephemeral.EnableResourceLogging = writeToLog; + _ephemeral.Save(); + } + + ImGui.SameLine(); + DrawFilterInput(); + + ImGui.SetCursorPosY(ImGui.GetCursorPosY() + ImGui.GetTextLineHeightWithSpacing() / 2); + + _table.Draw(ImGui.GetTextLineHeightWithSpacing()); + } + + private void DrawFilterInput() + { + ImGui.SetNextItemWidth(ImGui.GetContentRegionAvail().X); + var tmp = _logFilter; + var invalidRegex = _logRegex == null && _logFilter.Length > 0; + using var color = ImRaii.PushColor(ImGuiCol.Border, Colors.RegexWarningBorder, invalidRegex); + using var style = ImRaii.PushStyle(ImGuiStyleVar.FrameBorderSize, UiHelpers.Scale, invalidRegex); + if (ImGui.InputTextWithHint("##logFilter", "If path matches this Regex...", ref tmp, 256)) + UpdateFilter(tmp, true); + } + + private void UpdateFilter(string newString, bool config) + { + if (newString == _logFilter) + return; + + _logFilter = newString; + try + { + _logRegex = new Regex(_logFilter, RegexOptions.CultureInvariant | RegexOptions.IgnoreCase); + } + catch + { + _logRegex = null; + } + + if (config) + { + _ephemeral.ResourceLoggingFilter = newString; + _ephemeral.Save(); + } + } + + private bool FilterMatch(CiByteString path, out string match) + { + match = path.ToString(); + return _logFilter.Length == 0 || (_logRegex?.IsMatch(match) ?? false) || match.Contains(_logFilter, StringComparison.OrdinalIgnoreCase); + } + + + private void DrawMaxEntries() + { + ImGui.SetNextItemWidth(80 * UiHelpers.Scale); + ImGui.InputInt("Max. Entries", ref _newMaxEntries, 0, 0); + var change = ImGui.IsItemDeactivatedAfterEdit(); + if (ImGui.IsItemClicked(ImGuiMouseButton.Right) && ImGui.GetIO().KeyCtrl) + { + change = true; + _newMaxEntries = DefaultMaxEntries; + } + + var maxEntries = _config.MaxResourceWatcherRecords; + if (maxEntries != DefaultMaxEntries && ImGui.IsItemHovered()) + ImGui.SetTooltip($"CTRL + Right-Click to reset to default {DefaultMaxEntries}."); + + if (!change) + return; + + _newMaxEntries = Math.Max(16, _newMaxEntries); + if (_newMaxEntries == maxEntries) + return; + + _config.MaxResourceWatcherRecords = _newMaxEntries; + _config.Save(); + if (_newMaxEntries > _records.Count) + _records.RemoveRange(0, _records.Count - _newMaxEntries); + } + + private void UpdateRecords() + { + var count = _newRecords.Count; + if (count <= 0) + return; + + while (_newRecords.TryDequeue(out var rec) && count-- > 0) + _records.Add(rec); + + if (_records.Count > _config.MaxResourceWatcherRecords) + _records.RemoveRange(0, _records.Count - _config.MaxResourceWatcherRecords); + + _table.Reset(); + } + + + private unsafe void OnResourceRequested(ref ResourceCategory category, ref ResourceType type, ref int hash, ref Utf8GamePath path, + Utf8GamePath original, GetResourceParameters* parameters, ref bool sync, ref ResourceHandle* returnValue) + { + if (_ephemeral.EnableResourceLogging && FilterMatch(original.Path, out var match)) + Penumbra.Log.Information($"[ResourceLoader] [REQ] {match} was requested {(sync ? "synchronously." : "asynchronously.")}"); + + if (!_ephemeral.EnableResourceWatcher) + return; + + var record = Record.CreateRequest(original.Path, sync); + if (!_ephemeral.OnlyAddMatchingResources || _table.WouldBeVisible(record)) + _newRecords.Enqueue(record); + } + + private unsafe void OnResourceLoaded(ResourceHandle* handle, Utf8GamePath path, FullPath? manipulatedPath, ResolveData data) + { + if (_ephemeral.EnableResourceLogging) + { + var log = FilterMatch(path.Path, out var name); + var name2 = string.Empty; + if (manipulatedPath != null) + log |= FilterMatch(manipulatedPath.Value.InternalName, out name2); + + if (log) + { + var pathString = manipulatedPath != null ? $"custom file {name2} instead of {name}" : name; + Penumbra.Log.Information( + $"[ResourceLoader] [LOAD] [{handle->FileType}] Loaded {pathString} to 0x{(ulong)handle:X} using collection {data.ModCollection.Identity.AnonymizedName} for {Name(data, "no associated object.")} (Refcount {handle->RefCount}) "); + } + } + + if (!_ephemeral.EnableResourceWatcher) + return; + + var record = manipulatedPath == null + ? Record.CreateDefaultLoad(path.Path, handle, data.ModCollection, Name(data)) + : Record.CreateLoad(manipulatedPath.Value, path.Path, handle, data.ModCollection, Name(data)); + if (!_ephemeral.OnlyAddMatchingResources || _table.WouldBeVisible(record)) + _newRecords.Enqueue(record); + } + + private unsafe void OnResourceComplete(ResourceHandle* resource, CiByteString path, Utf8GamePath original, + ReadOnlySpan additionalData, bool isAsync) + { + if (!isAsync) + return; + + if (_ephemeral.EnableResourceLogging && FilterMatch(path, out var match)) + Penumbra.Log.Information( + $"[ResourceLoader] [DONE] [{resource->FileType}] Finished loading {match} into 0x{(ulong)resource:X}, state {resource->LoadState}."); + + if (!_ephemeral.EnableResourceWatcher) + return; + + var record = Record.CreateResourceComplete(path, resource, original, additionalData); + if (!_ephemeral.OnlyAddMatchingResources || _table.WouldBeVisible(record)) + _newRecords.Enqueue(record); + } + + private unsafe void OnFileLoaded(ResourceHandle* resource, CiByteString path, bool success, bool custom, ReadOnlySpan _) + { + if (_ephemeral.EnableResourceLogging && FilterMatch(path, out var match)) + Penumbra.Log.Information( + $"[ResourceLoader] [FILE] [{resource->FileType}] Loading {match} from {(custom ? "local files" : "SqPack")} into 0x{(ulong)resource:X} returned {success}."); + + if (!_ephemeral.EnableResourceWatcher) + return; + + var record = Record.CreateFileLoad(path, resource, success, custom); + if (!_ephemeral.OnlyAddMatchingResources || _table.WouldBeVisible(record)) + _newRecords.Enqueue(record); + } + + private unsafe void OnResourceDestroyed(ResourceHandle* resource) + { + if (_ephemeral.EnableResourceLogging && FilterMatch(resource->FileName(), out var match)) + Penumbra.Log.Information( + $"[ResourceLoader] [DEST] [{resource->FileType}] Destroyed {match} at 0x{(ulong)resource:X}."); + + if (!_ephemeral.EnableResourceWatcher) + return; + + var record = Record.CreateDestruction(resource); + if (!_ephemeral.OnlyAddMatchingResources || _table.WouldBeVisible(record)) + _newRecords.Enqueue(record); + } + + public unsafe string Name(ResolveData resolve, string none = "") + { + if (resolve.AssociatedGameObject == nint.Zero || !_actors.Awaiter.IsCompletedSuccessfully) + return none; + + try + { + var id = _actors.FromObject((GameObject*)resolve.AssociatedGameObject, out _, false, true, true); + if (id.IsValid) + { + if (id.Type is not (IdentifierType.Player or IdentifierType.Owned)) + return id.ToString(); + + var parts = id.ToString().Split(' ', 3); + return string.Join(" ", + parts.Length != 3 ? parts.Select(n => $"{n[0]}.") : parts[..2].Select(n => $"{n[0]}.").Append(parts[2])); + } + } + catch + { + // ignored + } + + return $"0x{resolve.AssociatedGameObject:X}"; + } +} diff --git a/Penumbra/UI/ResourceWatcher/ResourceWatcherTable.cs b/Penumbra/UI/ResourceWatcher/ResourceWatcherTable.cs new file mode 100644 index 00000000..97df095e --- /dev/null +++ b/Penumbra/UI/ResourceWatcher/ResourceWatcherTable.cs @@ -0,0 +1,472 @@ +using Dalamud.Bindings.ImGui; +using Dalamud.Interface; +using OtterGui; +using OtterGui.Classes; +using OtterGui.Raii; +using OtterGui.Table; +using OtterGui.Text; +using Penumbra.Enums; +using Penumbra.Interop.Structs; +using Penumbra.String; +using Penumbra.UI.Classes; + +namespace Penumbra.UI.ResourceWatcher; + +internal sealed class ResourceWatcherTable : Table +{ + public ResourceWatcherTable(EphemeralConfig config, IReadOnlyCollection records) + : base("##records", + records, + new PathColumn { Label = "Path" }, + new RecordTypeColumn(config) { Label = "Record" }, + new CollectionColumn { Label = "Collection" }, + new ObjectColumn { Label = "Game Object" }, + new CustomLoadColumn { Label = "Custom" }, + new SynchronousLoadColumn { Label = "Sync" }, + new OriginalPathColumn { Label = "Original Path" }, + new ResourceCategoryColumn(config) { Label = "Category" }, + new ResourceTypeColumn(config) { Label = "Type" }, + new HandleColumn { Label = "Resource" }, + new LoadStateColumn { Label = "State" }, + new RefCountColumn { Label = "#Ref" }, + new DateColumn { Label = "Time" }, + new Crc64Column { Label = "Crc64" }, + new OsThreadColumn { Label = "TID" } + ) + { } + + public void Reset() + => FilterDirty = true; + + private sealed class PathColumn : ColumnString + { + public override float Width + => 300 * UiHelpers.Scale; + + public override string ToName(Record item) + => item.Path.ToString(); + + public override int Compare(Record lhs, Record rhs) + => lhs.Path.CompareTo(rhs.Path); + + public override void DrawColumn(Record item, int _) + => DrawByteString(item.Path, 280 * UiHelpers.Scale); + } + + private static unsafe void DrawByteString(CiByteString path, float length) + { + if (path.IsEmpty) + return; + + var size = ImUtf8.CalcTextSize(path.Span); + var clicked = false; + if (size.X <= length) + { + clicked = ImUtf8.Selectable(path.Span); + } + else + { + var fileName = path.LastIndexOf((byte)'/'); + using (ImRaii.Group()) + { + CiByteString shortPath; + if (fileName != -1) + { + using var font = ImRaii.PushFont(UiBuilder.IconFont); + clicked = ImUtf8.Selectable(FontAwesomeIcon.EllipsisH.ToIconString()); + ImUtf8.SameLineInner(); + shortPath = path.Substring(fileName, path.Length - fileName); + } + else + { + shortPath = path; + } + + clicked |= ImUtf8.Selectable(shortPath.Span, false, ImGuiSelectableFlags.AllowItemOverlap); + } + + ImUtf8.HoverTooltip(path.Span); + } + + if (clicked) + ImUtf8.SetClipboardText(path.Span); + } + + private sealed class RecordTypeColumn : ColumnFlags + { + private readonly EphemeralConfig _config; + + public RecordTypeColumn(EphemeralConfig config) + { + AllFlags = ResourceWatcher.AllRecords; + _config = config; + } + + public override float Width + => 80 * UiHelpers.Scale; + + public override bool FilterFunc(Record item) + => FilterValue.HasFlag(item.RecordType); + + public override RecordType FilterValue + => _config.ResourceWatcherRecordTypes; + + protected override void SetValue(RecordType value, bool enable) + { + if (enable) + _config.ResourceWatcherRecordTypes |= value; + else + _config.ResourceWatcherRecordTypes &= ~value; + + _config.Save(); + } + + public override void DrawColumn(Record item, int idx) + { + ImGui.TextUnformatted(item.RecordType switch + { + RecordType.Request => "REQ", + RecordType.ResourceLoad => "LOAD", + RecordType.FileLoad => "FILE", + RecordType.Destruction => "DEST", + RecordType.ResourceComplete => "DONE", + _ => string.Empty, + }); + } + } + + private sealed class DateColumn : Column + { + public override float Width + => 80 * UiHelpers.Scale; + + public override int Compare(Record lhs, Record rhs) + => lhs.Time.CompareTo(rhs.Time); + + public override void DrawColumn(Record item, int _) + => ImGui.TextUnformatted($"{item.Time.ToLongTimeString()}.{item.Time.Millisecond:D4}"); + } + + private sealed class Crc64Column : ColumnString + { + public override float Width + => UiBuilder.MonoFont.GetCharAdvance('0') * 17; + + public override unsafe string ToName(Record item) + => item.Crc64 != 0 ? $"{item.Crc64:X16}" : string.Empty; + + public override unsafe void DrawColumn(Record item, int _) + { + using var font = ImRaii.PushFont(UiBuilder.MonoFont, item.Handle != null); + ImUtf8.Text(ToName(item)); + } + } + + + private sealed class CollectionColumn : ColumnString + { + public override float Width + => 80 * UiHelpers.Scale; + + public override string ToName(Record item) + => (item.Collection != null ? item.Collection.Identity.Name : null) ?? string.Empty; + } + + private sealed class ObjectColumn : ColumnString + { + public override float Width + => 200 * UiHelpers.Scale; + + public override string ToName(Record item) + => item.AssociatedGameObject; + } + + private sealed class OriginalPathColumn : ColumnString + { + public override float Width + => 200 * UiHelpers.Scale; + + public override string ToName(Record item) + => item.OriginalPath.ToString(); + + public override int Compare(Record lhs, Record rhs) + => lhs.OriginalPath.CompareTo(rhs.OriginalPath); + + public override void DrawColumn(Record item, int _) + => DrawByteString(item.OriginalPath, 190 * UiHelpers.Scale); + } + + private sealed class ResourceCategoryColumn : ColumnFlags + { + private readonly EphemeralConfig _config; + + public ResourceCategoryColumn(EphemeralConfig config) + { + _config = config; + AllFlags = ResourceExtensions.AllResourceCategories; + } + + public override float Width + => 80 * UiHelpers.Scale; + + public override bool FilterFunc(Record item) + => FilterValue.HasFlag(item.Category); + + public override ResourceCategoryFlag FilterValue + => _config.ResourceWatcherResourceCategories; + + protected override void SetValue(ResourceCategoryFlag value, bool enable) + { + if (enable) + _config.ResourceWatcherResourceCategories |= value; + else + _config.ResourceWatcherResourceCategories &= ~value; + + _config.Save(); + } + + public override void DrawColumn(Record item, int idx) + { + ImGui.TextUnformatted(item.Category.ToString()); + } + } + + private sealed class ResourceTypeColumn : ColumnFlags + { + private readonly EphemeralConfig _config; + + public ResourceTypeColumn(EphemeralConfig config) + { + _config = config; + AllFlags = Enum.GetValues().Aggregate((v, f) => v | f); + for (var i = 0; i < Names.Length; ++i) + Names[i] = Names[i].ToLowerInvariant(); + } + + public override float Width + => 50 * UiHelpers.Scale; + + public override bool FilterFunc(Record item) + => FilterValue.HasFlag(item.ResourceType); + + public override ResourceTypeFlag FilterValue + => _config.ResourceWatcherResourceTypes; + + protected override void SetValue(ResourceTypeFlag value, bool enable) + { + if (enable) + _config.ResourceWatcherResourceTypes |= value; + else + _config.ResourceWatcherResourceTypes &= ~value; + + _config.Save(); + } + + public override void DrawColumn(Record item, int idx) + { + ImGui.TextUnformatted(item.ResourceType.ToString().ToLowerInvariant()); + } + } + + private sealed class LoadStateColumn : ColumnFlags + { + public override float Width + => 50 * UiHelpers.Scale; + + [Flags] + public enum LoadStateFlag : byte + { + Success = 0x01, + Async = 0x02, + Failed = 0x04, + FailedSub = 0x08, + Unknown = 0x10, + None = 0xFF, + } + + protected override string[] Names + => new[] + { + "Loaded", + "Loading", + "Failed", + "Dependency Failed", + "Unknown", + "None", + }; + + public LoadStateColumn() + { + AllFlags = Enum.GetValues().Aggregate((v, f) => v | f); + _filterValue = AllFlags; + } + + private LoadStateFlag _filterValue; + + public override LoadStateFlag FilterValue + => _filterValue; + + protected override void SetValue(LoadStateFlag value, bool enable) + { + if (enable) + _filterValue |= value; + else + _filterValue &= ~value; + } + + public override bool FilterFunc(Record item) + => item.LoadState switch + { + LoadState.None => FilterValue.HasFlag(LoadStateFlag.None), + LoadState.Success => FilterValue.HasFlag(LoadStateFlag.Success), + LoadState.FailedSubResource => FilterValue.HasFlag(LoadStateFlag.FailedSub), + <= LoadState.Constructed => FilterValue.HasFlag(LoadStateFlag.Unknown), + < LoadState.Success => FilterValue.HasFlag(LoadStateFlag.Async), + > LoadState.Success => FilterValue.HasFlag(LoadStateFlag.Failed), + }; + + public override void DrawColumn(Record item, int _) + { + if (item.LoadState == LoadState.None) + return; + + var (icon, color, tt) = item.LoadState switch + { + LoadState.Success => (FontAwesomeIcon.CheckCircle, ColorId.IncreasedMetaValue.Value(), + $"Successfully loaded ({(byte)item.LoadState})."), + LoadState.FailedSubResource => (FontAwesomeIcon.ExclamationCircle, ColorId.DecreasedMetaValue.Value(), + $"Dependencies failed to load ({(byte)item.LoadState})."), + <= LoadState.Constructed => (FontAwesomeIcon.QuestionCircle, ColorId.UndefinedMod.Value(), $"Not yet loaded ({(byte)item.LoadState})."), + < LoadState.Success => (FontAwesomeIcon.Clock, ColorId.FolderLine.Value(), $"Loading asynchronously ({(byte)item.LoadState})."), + > LoadState.Success => (FontAwesomeIcon.Times, ColorId.DecreasedMetaValue.Value(), + $"Failed to load ({(byte)item.LoadState})."), + }; + using (var font = ImRaii.PushFont(UiBuilder.IconFont)) + { + using var c = ImRaii.PushColor(ImGuiCol.Text, color); + ImGui.TextUnformatted(icon.ToIconString()); + } + + ImGuiUtil.HoverTooltip(tt); + } + + public override int Compare(Record lhs, Record rhs) + => lhs.LoadState.CompareTo(rhs.LoadState); + } + + private sealed class HandleColumn : ColumnString + { + public override float Width + => 120 * UiHelpers.Scale; + + public override unsafe string ToName(Record item) + => item.Handle == null ? string.Empty : $"0x{(ulong)item.Handle:X}"; + + public override unsafe void DrawColumn(Record item, int _) + { + using var font = ImRaii.PushFont(UiBuilder.MonoFont, item.Handle != null); + ImGuiUtil.RightAlign(ToName(item)); + } + } + + [Flags] + private enum BoolEnum : byte + { + True = 0x01, + False = 0x02, + Unknown = 0x04, + } + + private class OptBoolColumn : ColumnFlags + { + private BoolEnum _filter; + + public OptBoolColumn() + { + AllFlags = BoolEnum.True | BoolEnum.False | BoolEnum.Unknown; + _filter = AllFlags; + Flags &= ~ImGuiTableColumnFlags.NoSort; + } + + protected bool FilterFunc(OptionalBool b) + => b.Value switch + { + null => _filter.HasFlag(BoolEnum.Unknown), + true => _filter.HasFlag(BoolEnum.True), + false => _filter.HasFlag(BoolEnum.False), + }; + + public override BoolEnum FilterValue + => _filter; + + protected override void SetValue(BoolEnum value, bool enable) + { + if (enable) + _filter |= value; + else + _filter &= ~value; + } + + protected static void DrawColumn(OptionalBool b) + { + using var font = ImRaii.PushFont(UiBuilder.IconFont); + ImGui.TextUnformatted(b.Value switch + { + null => string.Empty, + true => FontAwesomeIcon.Check.ToIconString(), + false => FontAwesomeIcon.Times.ToIconString(), + }); + } + } + + private sealed class CustomLoadColumn : OptBoolColumn + { + public override float Width + => 60 * UiHelpers.Scale; + + public override bool FilterFunc(Record item) + => FilterFunc(item.CustomLoad); + + public override void DrawColumn(Record item, int idx) + => DrawColumn(item.CustomLoad); + } + + private sealed class SynchronousLoadColumn : OptBoolColumn + { + public override float Width + => 45 * UiHelpers.Scale; + + public override bool FilterFunc(Record item) + => FilterFunc(item.Synchronously); + + public override void DrawColumn(Record item, int idx) + => DrawColumn(item.Synchronously); + } + + private sealed class RefCountColumn : Column + { + public override float Width + => 30 * UiHelpers.Scale; + + public override void DrawColumn(Record item, int _) + => ImGuiUtil.RightAlign(item.RefCount.ToString()); + + public override int Compare(Record lhs, Record rhs) + => lhs.RefCount.CompareTo(rhs.RefCount); + } + + private sealed class OsThreadColumn : ColumnString + { + public override float Width + => 60 * UiHelpers.Scale; + + public override string ToName(Record item) + => item.OsThreadId.ToString(); + + public override void DrawColumn(Record item, int _) + => ImGuiUtil.RightAlign(ToName(item)); + + public override int Compare(Record lhs, Record rhs) + => lhs.OsThreadId.CompareTo(rhs.OsThreadId); + } +} diff --git a/Penumbra/UI/SettingsInterface.cs b/Penumbra/UI/SettingsInterface.cs deleted file mode 100644 index 46ace4b4..00000000 --- a/Penumbra/UI/SettingsInterface.cs +++ /dev/null @@ -1,83 +0,0 @@ -using System; -using System.Numerics; -using Penumbra.Mods; -using Penumbra.Util; - -namespace Penumbra.UI -{ - public partial class SettingsInterface : IDisposable - { - private const float DefaultVerticalSpace = 20f; - - private static readonly Vector2 AutoFillSize = new( -1, -1 ); - private static readonly Vector2 ZeroVector = new( 0, 0 ); - - private readonly Penumbra _penumbra; - - private readonly ManageModsButton _manageModsButton; - private readonly MenuBar _menuBar; - private readonly SettingsMenu _menu; - private readonly ModManager _modManager; - - public SettingsInterface( Penumbra penumbra ) - { - _penumbra = penumbra; - _manageModsButton = new ManageModsButton( this ); - _menuBar = new MenuBar( this ); - _menu = new SettingsMenu( this ); - _modManager = Service< ModManager >.Get(); - - Dalamud.PluginInterface.UiBuilder.DisableGposeUiHide = true; - Dalamud.PluginInterface.UiBuilder.Draw += Draw; - Dalamud.PluginInterface.UiBuilder.OpenConfigUi += OpenConfig; - } - - public void Dispose() - { - _menu.InstalledTab.Selector.Cache.Dispose(); - Dalamud.PluginInterface.UiBuilder.Draw -= Draw; - Dalamud.PluginInterface.UiBuilder.OpenConfigUi -= OpenConfig; - } - - private void OpenConfig() - => _menu.Visible = true; - - public void FlipVisibility() - => _menu.Visible = !_menu.Visible; - - public void MakeDebugTabVisible() - => _menu.DebugTabVisible = true; - - public void Draw() - { - _menuBar.Draw(); - _manageModsButton.Draw(); - _menu.Draw(); - } - - private void ReloadMods() - { - _menu.InstalledTab.Selector.ClearSelection(); - _modManager.DiscoverMods( Penumbra.Config.ModDirectory ); - _menu.InstalledTab.Selector.Cache.TriggerListReset(); - } - - private void SaveCurrentCollection( bool recalculateMeta ) - { - var current = _modManager.Collections.CurrentCollection; - current.Save(); - RecalculateCurrent( recalculateMeta ); - } - - private void RecalculateCurrent( bool recalculateMeta ) - { - var current = _modManager.Collections.CurrentCollection; - if( current.Cache != null ) - { - current.CalculateEffectiveFileList( _modManager.TempPath, recalculateMeta, - current == _modManager.Collections.ActiveCollection ); - _menu.InstalledTab.Selector.Cache.TriggerFilterReset(); - } - } - } -} \ No newline at end of file diff --git a/Penumbra/UI/SettingsMenu.cs b/Penumbra/UI/SettingsMenu.cs deleted file mode 100644 index e46034e8..00000000 --- a/Penumbra/UI/SettingsMenu.cs +++ /dev/null @@ -1,90 +0,0 @@ -using System.Numerics; -using ImGuiNET; -using Penumbra.Mods; -using Penumbra.UI.Custom; -using Penumbra.Util; - -namespace Penumbra.UI -{ - public partial class SettingsInterface - { - private class SettingsMenu - { - private const string PenumbraSettingsLabel = "PenumbraSettings"; - - public static readonly Vector2 MinSettingsSize = new( 800, 450 ); - public static readonly Vector2 MaxSettingsSize = new( 69420, 42069 ); - - private readonly SettingsInterface _base; - private readonly TabSettings _settingsTab; - private readonly TabImport _importTab; - private readonly TabBrowser _browserTab; - internal readonly TabCollections CollectionsTab; - internal readonly TabInstalled InstalledTab; - private readonly TabEffective _effectiveTab; - - public SettingsMenu( SettingsInterface ui ) - { - _base = ui; - _settingsTab = new TabSettings( _base ); - _importTab = new TabImport( _base ); - _browserTab = new TabBrowser(); - InstalledTab = new TabInstalled( _base ); - CollectionsTab = new TabCollections( InstalledTab.Selector ); - _effectiveTab = new TabEffective(); - } - -#if DEBUG - private const bool DefaultVisibility = true; -#else - private const bool DefaultVisibility = false; -#endif - public bool Visible = DefaultVisibility; - public bool DebugTabVisible = DefaultVisibility; - - public void Draw() - { - if( !Visible ) - { - return; - } - - ImGui.SetNextWindowSizeConstraints( MinSettingsSize, MaxSettingsSize ); -#if DEBUG - var ret = ImGui.Begin( _base._penumbra.PluginDebugTitleStr, ref Visible ); -#else - var ret = ImGui.Begin( _base._penumbra.Name, ref Visible ); -#endif - using var raii = ImGuiRaii.DeferredEnd( ImGui.End ); - if( !ret ) - { - return; - } - - ImGui.BeginTabBar( PenumbraSettingsLabel ); - raii.Push( ImGui.EndTabBar ); - - _settingsTab.Draw(); - CollectionsTab.Draw(); - _importTab.Draw(); - - if( Service< ModManager >.Get().Valid && !_importTab.IsImporting() ) - { - _browserTab.Draw(); - InstalledTab.Draw(); - - if( Penumbra.Config.ShowAdvanced ) - { - _effectiveTab.Draw(); - } - } - - if( DebugTabVisible ) - { - _base.DrawDebugTab(); - _base.DrawResourceManagerTab(); - } - } - } - } -} \ No newline at end of file diff --git a/Penumbra/UI/Tabs/ChangedItemsTab.cs b/Penumbra/UI/Tabs/ChangedItemsTab.cs new file mode 100644 index 00000000..4dc9474f --- /dev/null +++ b/Penumbra/UI/Tabs/ChangedItemsTab.cs @@ -0,0 +1,117 @@ +using Dalamud.Bindings.ImGui; +using OtterGui; +using OtterGui.Classes; +using OtterGui.Raii; +using OtterGui.Services; +using OtterGui.Text; +using OtterGui.Widgets; +using Penumbra.Api.Enums; +using Penumbra.Collections.Manager; +using Penumbra.GameData.Data; +using Penumbra.Mods; +using Penumbra.Mods.Editor; +using Penumbra.Services; +using Penumbra.UI.Classes; + +namespace Penumbra.UI.Tabs; + +public class ChangedItemsTab( + CollectionManager collectionManager, + CollectionSelectHeader collectionHeader, + ChangedItemDrawer drawer, + CommunicatorService communicator) + : ITab, IUiService +{ + public ReadOnlySpan Label + => "Changed Items"u8; + + private LowerString _changedItemFilter = LowerString.Empty; + private LowerString _changedItemModFilter = LowerString.Empty; + private Vector2 _buttonSize; + + public void DrawContent() + { + collectionHeader.Draw(true); + drawer.DrawTypeFilter(); + var varWidth = DrawFilters(); + using var child = ImUtf8.Child("##changedItemsChild"u8, -Vector2.One); + if (!child) + return; + + _buttonSize = new Vector2(ImGui.GetStyle().ItemSpacing.Y + ImGui.GetFrameHeight()); + using var style = ImRaii.PushStyle(ImGuiStyleVar.CellPadding, Vector2.Zero) + .Push(ImGuiStyleVar.ItemSpacing, Vector2.Zero) + .Push(ImGuiStyleVar.FramePadding, Vector2.Zero) + .Push(ImGuiStyleVar.SelectableTextAlign, new Vector2(0.01f, 0.5f)); + + var skips = ImGuiClip.GetNecessarySkips(_buttonSize.Y); + using var list = ImUtf8.Table("##changedItems"u8, 3, ImGuiTableFlags.RowBg, -Vector2.One); + if (!list) + return; + + const ImGuiTableColumnFlags flags = ImGuiTableColumnFlags.NoResize | ImGuiTableColumnFlags.WidthFixed; + ImUtf8.TableSetupColumn("items"u8, flags, 450 * UiHelpers.Scale); + ImUtf8.TableSetupColumn("mods"u8, flags, varWidth - 140 * UiHelpers.Scale); + ImUtf8.TableSetupColumn("id"u8, flags, 140 * UiHelpers.Scale); + + var items = collectionManager.Active.Current.ChangedItems; + var rest = ImGuiClip.FilteredClippedDraw(items, skips, FilterChangedItem, DrawChangedItemColumn); + ImGuiClip.DrawEndDummy(rest, _buttonSize.Y); + } + + /// Draw a pair of filters and return the variable width of the flexible column. + private float DrawFilters() + { + var varWidth = ImGui.GetContentRegionAvail().X + - 450 * UiHelpers.Scale + - ImGui.GetStyle().ItemSpacing.X; + ImGui.SetNextItemWidth(450 * UiHelpers.Scale); + LowerString.InputWithHint("##changedItemsFilter", "Filter Item...", ref _changedItemFilter, 128); + ImGui.SameLine(); + ImGui.SetNextItemWidth(varWidth); + LowerString.InputWithHint("##changedItemsModFilter", "Filter Mods...", ref _changedItemModFilter, 128); + return varWidth; + } + + /// Apply the current filters. + private bool FilterChangedItem(KeyValuePair, IIdentifiedObjectData)> item) + => drawer.FilterChangedItem(item.Key, item.Value.Item2, _changedItemFilter) + && (_changedItemModFilter.IsEmpty || item.Value.Item1.Any(m => m.Name.Contains(_changedItemModFilter))); + + /// Draw a full column for a changed item. + private void DrawChangedItemColumn(KeyValuePair, IIdentifiedObjectData)> item) + { + ImGui.TableNextColumn(); + drawer.DrawCategoryIcon(item.Value.Item2, _buttonSize.Y); + ImGui.SameLine(0, 0); + var name = item.Value.Item2.ToName(item.Key); + var clicked = ImUtf8.Selectable(name, false, ImGuiSelectableFlags.None, _buttonSize with { X = 0 }); + drawer.ChangedItemHandling(item.Value.Item2, clicked); + + ImGui.TableNextColumn(); + DrawModColumn(item.Value.Item1); + + ImGui.TableNextColumn(); + ChangedItemDrawer.DrawModelData(item.Value.Item2, _buttonSize.Y); + } + + private void DrawModColumn(SingleArray mods) + { + if (mods.Count <= 0) + return; + + var first = mods[0]; + if (ImUtf8.Selectable(first.Name.Text, false, ImGuiSelectableFlags.None, _buttonSize with { X = 0 }) + && ImGui.GetIO().KeyCtrl + && first is Mod mod) + communicator.SelectTab.Invoke(TabType.Mods, mod); + + if (!ImGui.IsItemHovered()) + return; + + using var _ = ImRaii.Tooltip(); + ImUtf8.Text("Hold Control and click to jump to mod.\n"u8); + if (mods.Count > 1) + ImUtf8.Text("Other mods affecting this item:\n" + string.Join("\n", mods.Skip(1).Select(m => m.Name))); + } +} diff --git a/Penumbra/UI/Tabs/CollectionsTab.cs b/Penumbra/UI/Tabs/CollectionsTab.cs new file mode 100644 index 00000000..b458fc16 --- /dev/null +++ b/Penumbra/UI/Tabs/CollectionsTab.cs @@ -0,0 +1,147 @@ +using Dalamud.Bindings.ImGui; +using Dalamud.Plugin; +using Dalamud.Plugin.Services; +using OtterGui.Raii; +using OtterGui.Services; +using OtterGui.Widgets; +using Penumbra.Collections.Manager; +using Penumbra.GameData.Actors; +using Penumbra.Mods.Manager; +using Penumbra.Services; +using Penumbra.UI.CollectionTab; + +namespace Penumbra.UI.Tabs; + +public sealed class CollectionsTab : IDisposable, ITab, IUiService +{ + private readonly EphemeralConfig _config; + private readonly CollectionSelector _selector; + private readonly CollectionPanel _panel; + private readonly TutorialService _tutorial; + private readonly IncognitoService _incognito; + + public enum PanelMode + { + SimpleAssignment, + IndividualAssignment, + GroupAssignment, + Details, + }; + + public PanelMode Mode + { + get => _config.CollectionPanel; + set + { + _config.CollectionPanel = value; + _config.Save(); + } + } + + public CollectionsTab(IDalamudPluginInterface pi, Configuration configuration, CommunicatorService communicator, IncognitoService incognito, + CollectionManager collectionManager, ModStorage modStorage, ActorManager actors, ITargetManager targets, TutorialService tutorial, SaveService saveService) + { + _config = configuration.Ephemeral; + _tutorial = tutorial; + _incognito = incognito; + _selector = new CollectionSelector(configuration, communicator, collectionManager.Storage, collectionManager.Active, _tutorial, incognito); + _panel = new CollectionPanel(pi, communicator, collectionManager, _selector, actors, targets, modStorage, saveService, incognito); + } + + public void Dispose() + { + _selector.Dispose(); + _panel.Dispose(); + } + + public ReadOnlySpan Label + => "Collections"u8; + + public void DrawContent() + { + var width = ImGui.CalcTextSize("nnnnnnnnnnnnnnnnnnnnnnnnnn").X; + using (var group = ImRaii.Group()) + { + _selector.Draw(width); + } + + _tutorial.OpenTutorial(BasicTutorialSteps.EditingCollections); + + ImGui.SameLine(); + using (var group = ImRaii.Group()) + { + DrawHeaderLine(); + DrawPanel(); + } + } + + public void DrawHeader() + { + _tutorial.OpenTutorial(BasicTutorialSteps.Collections); + } + + private void DrawHeaderLine() + { + var withSpacing = ImGui.GetFrameHeightWithSpacing(); + using var style = ImRaii.PushStyle(ImGuiStyleVar.FrameRounding, 0).Push(ImGuiStyleVar.ItemSpacing, Vector2.Zero); + var buttonSize = new Vector2((ImGui.GetContentRegionAvail().X - withSpacing) / 4f, ImGui.GetFrameHeight()); + + using var _ = ImRaii.Group(); + using var color = ImRaii.PushColor(ImGuiCol.Button, ImGui.GetColorU32(ImGuiCol.TabActive), Mode is PanelMode.SimpleAssignment); + if (ImGui.Button("Simple Assignments", buttonSize)) + Mode = PanelMode.SimpleAssignment; + color.Pop(); + _tutorial.OpenTutorial(BasicTutorialSteps.SimpleAssignments); + ImGui.SameLine(); + + color.Push(ImGuiCol.Button, ImGui.GetColorU32(ImGuiCol.TabActive), Mode is PanelMode.IndividualAssignment); + if (ImGui.Button("Individual Assignments", buttonSize)) + Mode = PanelMode.IndividualAssignment; + color.Pop(); + _tutorial.OpenTutorial(BasicTutorialSteps.IndividualAssignments); + ImGui.SameLine(); + + color.Push(ImGuiCol.Button, ImGui.GetColorU32(ImGuiCol.TabActive), Mode is PanelMode.GroupAssignment); + if (ImGui.Button("Group Assignments", buttonSize)) + Mode = PanelMode.GroupAssignment; + color.Pop(); + _tutorial.OpenTutorial(BasicTutorialSteps.GroupAssignments); + ImGui.SameLine(); + + color.Push(ImGuiCol.Button, ImGui.GetColorU32(ImGuiCol.TabActive), Mode is PanelMode.Details); + if (ImGui.Button("Collection Details", buttonSize)) + Mode = PanelMode.Details; + color.Pop(); + _tutorial.OpenTutorial(BasicTutorialSteps.CollectionDetails); + ImGui.SameLine(); + + _incognito.DrawToggle(withSpacing); + } + + private void DrawPanel() + { + using var style = ImRaii.PushStyle(ImGuiStyleVar.ItemSpacing, Vector2.Zero); + using var child = ImRaii.Child("##CollectionSettings", new Vector2(ImGui.GetContentRegionAvail().X, 0), true); + if (!child) + return; + + style.Pop(); + switch (Mode) + { + case PanelMode.SimpleAssignment: + _panel.DrawSimple(); + break; + case PanelMode.IndividualAssignment: + _panel.DrawIndividualPanel(); + break; + case PanelMode.GroupAssignment: + _panel.DrawGroupPanel(); + break; + case PanelMode.Details: + _panel.DrawDetailsPanel(); + break; + } + + style.Push(ImGuiStyleVar.ItemSpacing, Vector2.Zero); + } +} diff --git a/Penumbra/UI/Tabs/ConfigTabBar.cs b/Penumbra/UI/Tabs/ConfigTabBar.cs new file mode 100644 index 00000000..43ae2488 --- /dev/null +++ b/Penumbra/UI/Tabs/ConfigTabBar.cs @@ -0,0 +1,114 @@ +using Dalamud.Bindings.ImGui; +using OtterGui.Services; +using OtterGui.Widgets; +using Penumbra.Api.Enums; +using Penumbra.Mods; +using Penumbra.Services; +using Penumbra.UI.Tabs.Debug; +using Watcher = Penumbra.UI.ResourceWatcher.ResourceWatcher; + +namespace Penumbra.UI.Tabs; + +public class ConfigTabBar : IDisposable, IUiService +{ + private readonly CommunicatorService _communicator; + + public readonly SettingsTab Settings; + public readonly ModsTab Mods; + public readonly CollectionsTab Collections; + public readonly ChangedItemsTab ChangedItems; + public readonly EffectiveTab Effective; + public readonly DebugTab Debug; + public readonly ResourceTab Resource; + public readonly Watcher Watcher; + public readonly OnScreenTab OnScreen; + public readonly MessagesTab Messages; + + public readonly ITab[] Tabs; + + /// The tab to select on the next Draw call, if any. + public TabType SelectTab = TabType.None; + + public ConfigTabBar(CommunicatorService communicator, SettingsTab settings, ModsTab mods, CollectionsTab collections, + ChangedItemsTab changedItems, EffectiveTab effective, DebugTab debug, ResourceTab resource, Watcher watcher, + OnScreenTab onScreen, MessagesTab messages) + { + _communicator = communicator; + + Settings = settings; + Mods = mods; + Collections = collections; + ChangedItems = changedItems; + Effective = effective; + Debug = debug; + Resource = resource; + Watcher = watcher; + OnScreen = onScreen; + Messages = messages; + Tabs = + [ + Settings, + Collections, + Mods, + ChangedItems, + Effective, + OnScreen, + Debug, + Resource, + Watcher, + Messages, + ]; + _communicator.SelectTab.Subscribe(OnSelectTab, Communication.SelectTab.Priority.ConfigTabBar); + } + + public void Dispose() + => _communicator.SelectTab.Unsubscribe(OnSelectTab); + + public TabType Draw() + { + if (TabBar.Draw(string.Empty, ImGuiTabBarFlags.NoTooltip, ToLabel(SelectTab), out var currentLabel, () => { }, Tabs)) + SelectTab = TabType.None; + + return FromLabel(currentLabel); + } + + private ReadOnlySpan ToLabel(TabType type) + => type switch + { + TabType.Settings => Settings.Label, + TabType.Mods => Mods.Label, + TabType.Collections => Collections.Label, + TabType.ChangedItems => ChangedItems.Label, + TabType.EffectiveChanges => Effective.Label, + TabType.OnScreen => OnScreen.Label, + TabType.ResourceWatcher => Watcher.Label, + TabType.Debug => Debug.Label, + TabType.ResourceManager => Resource.Label, + TabType.Messages => Messages.Label, + _ => ReadOnlySpan.Empty, + }; + + private TabType FromLabel(ReadOnlySpan label) + { + // @formatter:off + if (label == Mods.Label) return TabType.Mods; + if (label == Collections.Label) return TabType.Collections; + if (label == Settings.Label) return TabType.Settings; + if (label == ChangedItems.Label) return TabType.ChangedItems; + if (label == Effective.Label) return TabType.EffectiveChanges; + if (label == OnScreen.Label) return TabType.OnScreen; + if (label == Messages.Label) return TabType.Messages; + if (label == Watcher.Label) return TabType.ResourceWatcher; + if (label == Debug.Label) return TabType.Debug; + if (label == Resource.Label) return TabType.ResourceManager; + // @formatter:on + return TabType.None; + } + + private void OnSelectTab(TabType tab, Mod? mod) + { + SelectTab = tab; + if (mod != null) + Mods.SelectMod = mod; + } +} diff --git a/Penumbra/UI/Tabs/Debug/AtchDrawer.cs b/Penumbra/UI/Tabs/Debug/AtchDrawer.cs new file mode 100644 index 00000000..f136bacd --- /dev/null +++ b/Penumbra/UI/Tabs/Debug/AtchDrawer.cs @@ -0,0 +1,57 @@ +using Dalamud.Bindings.ImGui; +using OtterGui.Extensions; +using OtterGui.Text; +using Penumbra.GameData.Files; +using Penumbra.GameData.Files.AtchStructs; + +namespace Penumbra.UI.Tabs.Debug; + +public static class AtchDrawer +{ + public static void Draw(AtchFile file) + { + using (ImUtf8.Group()) + { + ImUtf8.Text("Entries: "u8); + ImUtf8.Text("States: "u8); + } + + ImGui.SameLine(); + using (ImUtf8.Group()) + { + ImUtf8.Text($"{file.Points.Count}"); + if (file.Points.Count == 0) + { + ImUtf8.Text("0"u8); + return; + } + + ImUtf8.Text($"{file.Points[0].Entries.Length}"); + } + + foreach (var (entry, index) in file.Points.WithIndex()) + { + using var id = ImUtf8.PushId(index); + using var tree = ImUtf8.TreeNode($"{index:D3}: {entry.Type.ToName()}"); + if (tree) + { + ImUtf8.TreeNode(entry.Accessory ? "Accessory"u8 : "Weapon"u8, ImGuiTreeNodeFlags.Bullet | ImGuiTreeNodeFlags.Leaf).Dispose(); + foreach (var (state, i) in entry.Entries.WithIndex()) + { + id.Push(i); + using var t = ImUtf8.TreeNode(state.Bone); + if (t) + { + ImUtf8.TreeNode($"Scale: {state.Scale}", ImGuiTreeNodeFlags.Bullet | ImGuiTreeNodeFlags.Leaf).Dispose(); + ImUtf8.TreeNode($"Offset: {state.Offset.X} | {state.Offset.Y} | {state.Offset.Z}", + ImGuiTreeNodeFlags.Bullet | ImGuiTreeNodeFlags.Leaf).Dispose(); + ImUtf8.TreeNode($"Rotation: {state.Rotation.X} | {state.Rotation.Y} | {state.Rotation.Z}", + ImGuiTreeNodeFlags.Bullet | ImGuiTreeNodeFlags.Leaf).Dispose(); + } + + id.Pop(); + } + } + } + } +} diff --git a/Penumbra/UI/Tabs/Debug/CrashDataExtensions.cs b/Penumbra/UI/Tabs/Debug/CrashDataExtensions.cs new file mode 100644 index 00000000..471d770a --- /dev/null +++ b/Penumbra/UI/Tabs/Debug/CrashDataExtensions.cs @@ -0,0 +1,110 @@ +using Dalamud.Bindings.ImGui; +using OtterGui; +using OtterGui.Raii; +using Penumbra.CrashHandler; + +namespace Penumbra.UI.Tabs.Debug; + +public static class CrashDataExtensions +{ + public static void DrawMeta(this CrashData data) + { + using (ImRaii.Group()) + { + ImGui.TextUnformatted(nameof(data.Mode)); + ImGui.TextUnformatted(nameof(data.CrashTime)); + ImGui.TextUnformatted("Current Age"); + ImGui.TextUnformatted(nameof(data.Version)); + ImGui.TextUnformatted(nameof(data.GameVersion)); + ImGui.TextUnformatted(nameof(data.ExitCode)); + ImGui.TextUnformatted(nameof(data.ProcessId)); + ImGui.TextUnformatted(nameof(data.TotalModdedFilesLoaded)); + ImGui.TextUnformatted(nameof(data.TotalCharactersLoaded)); + ImGui.TextUnformatted(nameof(data.TotalVFXFuncsInvoked)); + } + + ImGui.SameLine(); + using (ImRaii.Group()) + { + ImGui.TextUnformatted(data.Mode); + ImGui.TextUnformatted(data.CrashTime.ToString()); + ImGui.TextUnformatted((DateTimeOffset.UtcNow - data.CrashTime).ToString(@"dd\.hh\:mm\:ss")); + ImGui.TextUnformatted(data.Version); + ImGui.TextUnformatted(data.GameVersion); + ImGui.TextUnformatted(data.ExitCode.ToString()); + ImGui.TextUnformatted(data.ProcessId.ToString()); + ImGui.TextUnformatted(data.TotalModdedFilesLoaded.ToString()); + ImGui.TextUnformatted(data.TotalCharactersLoaded.ToString()); + ImGui.TextUnformatted(data.TotalVFXFuncsInvoked.ToString()); + } + } + + public static void DrawCharacters(this CrashData data) + { + using var tree = ImRaii.TreeNode("Last Characters"); + if (!tree) + return; + + using var table = ImRaii.Table("##characterTable", 6, + ImGuiTableFlags.SizingFixedFit | ImGuiTableFlags.RowBg | ImGuiTableFlags.BordersInner); + if (!table) + return; + + ImGuiClip.ClippedDraw(data.LastCharactersLoaded, character => + { + ImGuiUtil.DrawTableColumn(character.Age.ToString(CultureInfo.InvariantCulture)); + ImGuiUtil.DrawTableColumn(character.ThreadId.ToString()); + ImGuiUtil.DrawTableColumn(character.CharacterName); + ImGuiUtil.DrawTableColumn(character.CollectionId.ToString()); + ImGuiUtil.DrawTableColumn(character.CharacterAddress); + ImGuiUtil.DrawTableColumn(character.Timestamp.ToString()); + }, ImGui.GetTextLineHeightWithSpacing()); + } + + public static void DrawFiles(this CrashData data) + { + using var tree = ImRaii.TreeNode("Last Files"); + if (!tree) + return; + + using var table = ImRaii.Table("##filesTable", 8, + ImGuiTableFlags.SizingFixedFit | ImGuiTableFlags.RowBg | ImGuiTableFlags.BordersInner); + if (!table) + return; + + ImGuiClip.ClippedDraw(data.LastModdedFilesLoaded, file => + { + ImGuiUtil.DrawTableColumn(file.Age.ToString(CultureInfo.InvariantCulture)); + ImGuiUtil.DrawTableColumn(file.ThreadId.ToString()); + ImGuiUtil.DrawTableColumn(file.ActualFileName); + ImGuiUtil.DrawTableColumn(file.RequestedFileName); + ImGuiUtil.DrawTableColumn(file.CharacterName); + ImGuiUtil.DrawTableColumn(file.CollectionId.ToString()); + ImGuiUtil.DrawTableColumn(file.CharacterAddress); + ImGuiUtil.DrawTableColumn(file.Timestamp.ToString()); + }, ImGui.GetTextLineHeightWithSpacing()); + } + + public static void DrawVfxInvocations(this CrashData data) + { + using var tree = ImRaii.TreeNode("Last VFX Invocations"); + if (!tree) + return; + + using var table = ImRaii.Table("##vfxTable", 7, + ImGuiTableFlags.SizingFixedFit | ImGuiTableFlags.RowBg | ImGuiTableFlags.BordersInner); + if (!table) + return; + + ImGuiClip.ClippedDraw(data.LastVFXFuncsInvoked, vfx => + { + ImGuiUtil.DrawTableColumn(vfx.Age.ToString(CultureInfo.InvariantCulture)); + ImGuiUtil.DrawTableColumn(vfx.ThreadId.ToString()); + ImGuiUtil.DrawTableColumn(vfx.InvocationType); + ImGuiUtil.DrawTableColumn(vfx.CharacterName); + ImGuiUtil.DrawTableColumn(vfx.CollectionId.ToString()); + ImGuiUtil.DrawTableColumn(vfx.CharacterAddress); + ImGuiUtil.DrawTableColumn(vfx.Timestamp.ToString()); + }, ImGui.GetTextLineHeightWithSpacing()); + } +} diff --git a/Penumbra/UI/Tabs/Debug/CrashHandlerPanel.cs b/Penumbra/UI/Tabs/Debug/CrashHandlerPanel.cs new file mode 100644 index 00000000..672b8c79 --- /dev/null +++ b/Penumbra/UI/Tabs/Debug/CrashHandlerPanel.cs @@ -0,0 +1,136 @@ +using System.Text.Json; +using Dalamud.Bindings.ImGui; +using Dalamud.Interface.DragDrop; +using OtterGui; +using OtterGui.Raii; +using OtterGui.Services; +using Penumbra.CrashHandler; +using Penumbra.Services; + +namespace Penumbra.UI.Tabs.Debug; + +public class CrashHandlerPanel(CrashHandlerService _service, Configuration _config, IDragDropManager _dragDrop) : IService +{ + private CrashData? _lastDump; + private string _lastLoadedFile = string.Empty; + private CrashData? _lastLoad; + private Exception? _lastLoadException; + + public void Draw() + { + DrawDropSource(); + DrawData(); + DrawDropTarget(); + } + + private void DrawData() + { + using var _ = ImRaii.Group(); + using var header = ImRaii.CollapsingHeader("Crash Handler"); + if (!header) + return; + + DrawButtons(); + DrawMainData(); + DrawObject("Last Manual Dump", _lastDump, null); + DrawObject(_lastLoadedFile.Length > 0 ? $"Loaded File ({_lastLoadedFile})###Loaded File" : "Loaded File", _lastLoad, + _lastLoadException); + } + + private void DrawMainData() + { + using var table = ImRaii.Table("##CrashHandlerTable", 2, ImGuiTableFlags.SizingFixedFit); + if (!table) + return; + + PrintValue("Enabled", _config.UseCrashHandler); + PrintValue("Copied Executable Path", _service.CopiedExe); + PrintValue("Original Executable Path", _service.OriginalExe); + PrintValue("Log File Path", _service.LogPath); + PrintValue("XIV Process ID", _service.ProcessId.ToString()); + PrintValue("Crash Handler Running", _service.IsRunning.ToString()); + PrintValue("Crash Handler Process ID", _service.ChildProcessId.ToString()); + PrintValue("Crash Handler Exit Code", _service.ChildExitCode.ToString()); + } + + private void DrawButtons() + { + if (ImGui.Button("Dump Crash Handler Memory")) + _lastDump = _service.Dump()?.Deserialize(); + + if (ImGui.Button("Enable")) + _service.Enable(); + + ImGui.SameLine(); + if (ImGui.Button("Disable")) + _service.Disable(); + + if (ImGui.Button("Shutdown Crash Handler")) + _service.CloseCrashHandler(); + ImGui.SameLine(); + if (ImGui.Button("Relaunch Crash Handler")) + _service.LaunchCrashHandler(); + } + + private void DrawDropSource() + { + _dragDrop.CreateImGuiSource("LogDragDrop", m => m.Files.Any(f => f.EndsWith("Penumbra.log")), m => + { + ImGui.TextUnformatted("Dragging Penumbra.log for import."); + return true; + }); + } + + private void DrawDropTarget() + { + if (!_dragDrop.CreateImGuiTarget("LogDragDrop", out var files, out _)) + return; + + var file = files.FirstOrDefault(f => f.EndsWith("Penumbra.log")); + if (file == null) + return; + + _lastLoadedFile = file; + try + { + var jObj = _service.Load(file); + _lastLoad = jObj?.Deserialize(); + _lastLoadException = null; + } + catch (Exception ex) + { + _lastLoad = null; + _lastLoadException = ex; + } + } + + private static void DrawObject(string name, CrashData? data, Exception? ex) + { + using var tree = ImRaii.TreeNode(name); + if (!tree) + return; + + if (ex != null) + { + ImGuiUtil.TextWrapped(ex.ToString()); + return; + } + + if (data == null) + { + ImGui.TextUnformatted("Nothing loaded."); + return; + } + + data.DrawMeta(); + data.DrawFiles(); + data.DrawCharacters(); + data.DrawVfxInvocations(); + } + + private static void PrintValue(string label, in T data) + { + ImGuiUtil.DrawTableColumn(label); + ImGuiUtil.DrawTableColumn(data?.ToString() ?? "NULL"); + } +} diff --git a/Penumbra/UI/Tabs/Debug/DebugConfigurationDrawer.cs b/Penumbra/UI/Tabs/Debug/DebugConfigurationDrawer.cs new file mode 100644 index 00000000..087670c1 --- /dev/null +++ b/Penumbra/UI/Tabs/Debug/DebugConfigurationDrawer.cs @@ -0,0 +1,16 @@ +using OtterGui.Text; + +namespace Penumbra.UI.Tabs.Debug; + +public static class DebugConfigurationDrawer +{ + public static void Draw() + { + using var id = ImUtf8.CollapsingHeaderId("Debugging Options"u8); + if (!id) + return; + + ImUtf8.Checkbox("Log IMC File Replacements"u8, ref DebugConfiguration.WriteImcBytesToLog); + ImUtf8.Checkbox("Scan for Skin Material Attributes"u8, ref DebugConfiguration.UseSkinMaterialProcessing); + } +} diff --git a/Penumbra/UI/Tabs/Debug/DebugTab.cs b/Penumbra/UI/Tabs/Debug/DebugTab.cs new file mode 100644 index 00000000..c7f0635d --- /dev/null +++ b/Penumbra/UI/Tabs/Debug/DebugTab.cs @@ -0,0 +1,1287 @@ +using Dalamud.Interface; +using Dalamud.Interface.Utility.Raii; +using Dalamud.Interface.Windowing; +using Dalamud.Plugin.Services; +using Dalamud.Utility; +using FFXIVClientStructs.FFXIV.Client.Game.Character; +using FFXIVClientStructs.FFXIV.Client.Game.Group; +using FFXIVClientStructs.FFXIV.Client.Game.Object; +using FFXIVClientStructs.FFXIV.Client.System.Resource; +using FFXIVClientStructs.FFXIV.Client.UI.Agent; +using Dalamud.Bindings.ImGui; +using Dalamud.Game.ClientState.Objects.SubKinds; +using Dalamud.Interface.Colors; +using Microsoft.Extensions.DependencyInjection; +using OtterGui; +using OtterGui.Classes; +using OtterGui.Extensions; +using OtterGui.Services; +using OtterGui.Text; +using OtterGui.Widgets; +using Penumbra.Api; +using Penumbra.Collections.Manager; +using Penumbra.GameData.Actors; +using Penumbra.GameData.DataContainers; +using Penumbra.GameData.Files; +using Penumbra.GameData.Interop; +using Penumbra.Import.Structs; +using Penumbra.Import.Textures; +using Penumbra.Interop.PathResolving; +using Penumbra.Interop.Services; +using Penumbra.Interop.Structs; +using Penumbra.Mods; +using Penumbra.Mods.Manager; +using Penumbra.Services; +using Penumbra.String; +using Penumbra.UI.Classes; +using Penumbra.Util; +using static OtterGui.Raii.ImRaii; +using CharacterBase = FFXIVClientStructs.FFXIV.Client.Graphics.Scene.CharacterBase; +using ImGuiClip = OtterGui.ImGuiClip; +using Penumbra.Api.IpcTester; +using Penumbra.GameData.Data; +using Penumbra.Interop.Hooks.PostProcessing; +using Penumbra.Interop.Hooks.ResourceLoading; +using Penumbra.GameData.Files.StainMapStructs; +using Penumbra.Interop; +using Penumbra.String.Classes; +using Penumbra.UI.AdvancedWindow.Materials; + +namespace Penumbra.UI.Tabs.Debug; + +public class Diagnostics(ServiceManager provider) : IUiService +{ + public void DrawDiagnostics() + { + if (!ImGui.CollapsingHeader("Diagnostics")) + return; + + using var table = ImRaii.Table("##data", 4, ImGuiTableFlags.RowBg); + if (!table) + return; + + foreach (var type in typeof(ActorManager).Assembly.GetTypes() + .Where(t => t is { IsAbstract: false, IsInterface: false } && t.IsAssignableTo(typeof(IAsyncDataContainer)))) + { + var container = (IAsyncDataContainer)provider.Provider!.GetRequiredService(type); + ImGuiUtil.DrawTableColumn(container.Name); + ImGuiUtil.DrawTableColumn(container.Time.ToString()); + ImGuiUtil.DrawTableColumn(Functions.HumanReadableSize(container.Memory)); + ImGuiUtil.DrawTableColumn(container.TotalCount.ToString()); + } + } +} + +public class DebugTab : Window, ITab, IUiService +{ + private readonly PerformanceTracker _performance; + private readonly Configuration _config; + private readonly CollectionManager _collectionManager; + private readonly ModManager _modManager; + private readonly ValidityChecker _validityChecker; + private readonly HttpApi _httpApi; + private readonly ActorManager _actors; + private readonly StainService _stains; + private readonly GlobalVariablesDrawer _globalVariablesDrawer; + private readonly ResourceManagerService _resourceManager; + private readonly ResourceLoader _resourceLoader; + private readonly CollectionResolver _collectionResolver; + private readonly DrawObjectState _drawObjectState; + private readonly PathState _pathState; + private readonly SubfileHelper _subfileHelper; + private readonly IdentifiedCollectionCache _identifiedCollectionCache; + private readonly CutsceneService _cutsceneService; + private readonly ModImportManager _modImporter; + private readonly ImportPopup _importPopup; + private readonly FrameworkManager _framework; + private readonly TextureManager _textureManager; + private readonly ShaderReplacementFixer _shaderReplacementFixer; + private readonly RedrawService _redraws; + private readonly DictEmote _emotes; + private readonly Diagnostics _diagnostics; + private readonly ObjectManager _objects; + private readonly IClientState _clientState; + private readonly IDataManager _dataManager; + private readonly IpcTester _ipcTester; + private readonly CrashHandlerPanel _crashHandlerPanel; + private readonly TexHeaderDrawer _texHeaderDrawer; + private readonly HookOverrideDrawer _hookOverrides; + private readonly RsfService _rsfService; + private readonly SchedulerResourceManagementService _schedulerService; + private readonly ObjectIdentification _objectIdentification; + private readonly RenderTargetDrawer _renderTargetDrawer; + private readonly ModMigratorDebug _modMigratorDebug; + private readonly ShapeInspector _shapeInspector; + + public DebugTab(PerformanceTracker performance, Configuration config, CollectionManager collectionManager, ObjectManager objects, + IClientState clientState, IDataManager dataManager, + ValidityChecker validityChecker, ModManager modManager, HttpApi httpApi, ActorManager actors, StainService stains, + ResourceManagerService resourceManager, ResourceLoader resourceLoader, CollectionResolver collectionResolver, + DrawObjectState drawObjectState, PathState pathState, SubfileHelper subfileHelper, IdentifiedCollectionCache identifiedCollectionCache, + CutsceneService cutsceneService, ModImportManager modImporter, ImportPopup importPopup, FrameworkManager framework, + TextureManager textureManager, ShaderReplacementFixer shaderReplacementFixer, RedrawService redraws, DictEmote emotes, + Diagnostics diagnostics, IpcTester ipcTester, CrashHandlerPanel crashHandlerPanel, TexHeaderDrawer texHeaderDrawer, + HookOverrideDrawer hookOverrides, RsfService rsfService, GlobalVariablesDrawer globalVariablesDrawer, + SchedulerResourceManagementService schedulerService, ObjectIdentification objectIdentification, RenderTargetDrawer renderTargetDrawer, + ModMigratorDebug modMigratorDebug, ShapeInspector shapeInspector) + : base("Penumbra Debug Window", ImGuiWindowFlags.NoCollapse) + { + IsOpen = true; + SizeConstraints = new WindowSizeConstraints + { + MinimumSize = new Vector2(200, 200), + MaximumSize = new Vector2(2000, 2000), + }; + _performance = performance; + _config = config; + _collectionManager = collectionManager; + _validityChecker = validityChecker; + _modManager = modManager; + _httpApi = httpApi; + _actors = actors; + _stains = stains; + _resourceManager = resourceManager; + _resourceLoader = resourceLoader; + _collectionResolver = collectionResolver; + _drawObjectState = drawObjectState; + _pathState = pathState; + _subfileHelper = subfileHelper; + _identifiedCollectionCache = identifiedCollectionCache; + _cutsceneService = cutsceneService; + _modImporter = modImporter; + _importPopup = importPopup; + _framework = framework; + _textureManager = textureManager; + _shaderReplacementFixer = shaderReplacementFixer; + _redraws = redraws; + _emotes = emotes; + _diagnostics = diagnostics; + _ipcTester = ipcTester; + _crashHandlerPanel = crashHandlerPanel; + _texHeaderDrawer = texHeaderDrawer; + _hookOverrides = hookOverrides; + _rsfService = rsfService; + _globalVariablesDrawer = globalVariablesDrawer; + _schedulerService = schedulerService; + _objectIdentification = objectIdentification; + _renderTargetDrawer = renderTargetDrawer; + _modMigratorDebug = modMigratorDebug; + _shapeInspector = shapeInspector; + _objects = objects; + _clientState = clientState; + _dataManager = dataManager; + } + + public ReadOnlySpan Label + => "Debug"u8; + + public bool IsVisible + => _config is { DebugMode: true, Ephemeral.DebugSeparateWindow: false }; + +#if DEBUG + private const string DebugVersionString = "(Debug)"; +#else + private const string DebugVersionString = "(Release)"; +#endif + + public void DrawContent() + { + using var child = Child("##DebugTab", -Vector2.One); + if (!child) + return; + + DrawDebugTabGeneral(); + _crashHandlerPanel.Draw(); + DebugConfigurationDrawer.Draw(); + _diagnostics.DrawDiagnostics(); + DrawPerformanceTab(); + DrawPathResolverDebug(); + DrawActorsDebug(); + DrawCollectionCaches(); + _texHeaderDrawer.Draw(); + _modMigratorDebug.Draw(); + DrawShaderReplacementFixer(); + DrawData(); + DrawCrcCache(); + DrawResourceLoader(); + DrawResourceProblems(); + _renderTargetDrawer.Draw(); + _hookOverrides.Draw(); + DrawPlayerModelInfo(); + _globalVariablesDrawer.Draw(); + DrawCloudApi(); + DrawDebugTabIpc(); + } + + + private unsafe void DrawCollectionCaches() + { + if (!ImGui.CollapsingHeader( + $"Collections ({_collectionManager.Caches.Count}/{_collectionManager.Storage.Count - 1} Caches)###Collections")) + return; + + foreach (var collection in _collectionManager.Storage) + { + if (collection.HasCache) + { + using var color = PushColor(ImGuiCol.Text, ColorId.FolderExpanded.Value()); + using var node = + TreeNode($"{collection.Identity.Name} (Change Counter {collection.Counters.Change})###{collection.Identity.Name}"); + if (!node) + continue; + + color.Pop(); + using (var inheritanceNode = ImUtf8.TreeNode("Inheritance"u8)) + { + if (inheritanceNode) + { + using var table = ImUtf8.Table("table"u8, 3, + ImGuiTableFlags.SizingFixedFit | ImGuiTableFlags.RowBg | ImGuiTableFlags.BordersInnerV); + if (table) + { + var max = Math.Max( + Math.Max(collection.Inheritance.DirectlyInheritedBy.Count, collection.Inheritance.DirectlyInheritsFrom.Count), + collection.Inheritance.FlatHierarchy.Count); + for (var i = 0; i < max; ++i) + { + ImGui.TableNextColumn(); + if (i < collection.Inheritance.DirectlyInheritsFrom.Count) + ImUtf8.Text(collection.Inheritance.DirectlyInheritsFrom[i].Identity.Name); + else + ImGui.Dummy(new Vector2(200 * ImUtf8.GlobalScale, ImGui.GetTextLineHeight())); + ImGui.TableNextColumn(); + if (i < collection.Inheritance.DirectlyInheritedBy.Count) + ImUtf8.Text(collection.Inheritance.DirectlyInheritedBy[i].Identity.Name); + else + ImGui.Dummy(new Vector2(200 * ImUtf8.GlobalScale, ImGui.GetTextLineHeight())); + ImGui.TableNextColumn(); + if (i < collection.Inheritance.FlatHierarchy.Count) + ImUtf8.Text(collection.Inheritance.FlatHierarchy[i].Identity.Name); + else + ImGui.Dummy(new Vector2(200 * ImUtf8.GlobalScale, ImGui.GetTextLineHeight())); + } + } + } + } + + using (var resourceNode = ImUtf8.TreeNode("Custom Resources"u8)) + { + if (resourceNode) + foreach (var (path, resource) in collection._cache!.CustomResources) + { + ImUtf8.TreeNode($"{path} -> 0x{(ulong)resource.ResourceHandle:X}", + ImGuiTreeNodeFlags.Bullet | ImGuiTreeNodeFlags.Leaf).Dispose(); + } + } + + using var modNode = ImUtf8.TreeNode("Enabled Mods"u8); + if (modNode) + foreach (var (mod, paths, manips) in collection._cache!.ModData.Data.OrderBy(t => t.Item1.Name)) + { + using var id = mod is TemporaryMod t ? PushId(t.Priority.Value) : PushId(((Mod)mod).ModPath.Name); + using var node2 = TreeNode(mod.Name.Text); + if (!node2) + continue; + + foreach (var path in paths) + + TreeNode(path.ToString(), ImGuiTreeNodeFlags.Bullet | ImGuiTreeNodeFlags.Leaf).Dispose(); + + foreach (var manip in manips) + TreeNode(manip.ToString(), ImGuiTreeNodeFlags.Bullet | ImGuiTreeNodeFlags.Leaf).Dispose(); + } + } + else + { + using var color = PushColor(ImGuiCol.Text, ColorId.UndefinedMod.Value()); + TreeNode($"{collection.Identity.Name} (Change Counter {collection.Counters.Change})", + ImGuiTreeNodeFlags.Bullet | ImGuiTreeNodeFlags.Leaf).Dispose(); + } + } + } + + /// Draw general information about mod and collection state. + private void DrawDebugTabGeneral() + { + if (!ImGui.CollapsingHeader("General")) + return; + + var separateWindow = _config.Ephemeral.DebugSeparateWindow; + if (ImGui.Checkbox("Draw as Separate Window", ref separateWindow)) + { + IsOpen = true; + _config.Ephemeral.DebugSeparateWindow = separateWindow; + _config.Ephemeral.Save(); + } + + using (var table = Table("##DebugGeneralTable", 2, ImGuiTableFlags.SizingFixedFit)) + { + if (table) + { + PrintValue("Penumbra Version", $"{_validityChecker.Version} {DebugVersionString}"); + PrintValue("Git Commit Hash", _validityChecker.CommitHash); + PrintValue(TutorialService.SelectedCollection, _collectionManager.Active.Current.Identity.Name); + PrintValue(" has Cache", _collectionManager.Active.Current.HasCache.ToString()); + PrintValue(TutorialService.DefaultCollection, _collectionManager.Active.Default.Identity.Name); + PrintValue(" has Cache", _collectionManager.Active.Default.HasCache.ToString()); + PrintValue("Mod Manager BasePath", _modManager.BasePath.Name); + PrintValue("Mod Manager BasePath-Full", _modManager.BasePath.FullName); + PrintValue("Mod Manager BasePath IsRooted", Path.IsPathRooted(_config.ModDirectory).ToString()); + PrintValue("Mod Manager BasePath Exists", Directory.Exists(_modManager.BasePath.FullName).ToString()); + PrintValue("Mod Manager Valid", _modManager.Valid.ToString()); + PrintValue("Web Server Enabled", _httpApi.Enabled.ToString()); + } + } + + + var issues = _modManager.WithIndex().Count(p => p.Index != p.Value.Index); + using (var tree = TreeNode($"Mods ({issues} Issues)###Mods")) + { + if (tree) + { + using var table = Table("##DebugModsTable", 3, ImGuiTableFlags.SizingFixedFit); + if (table) + { + var lastIndex = -1; + foreach (var mod in _modManager) + { + PrintValue(mod.Name, mod.Index.ToString("D5")); + ImGui.TableNextColumn(); + var index = mod.Index; + if (index != lastIndex + 1) + ImGui.TextUnformatted("!!!"); + lastIndex = index; + } + } + } + } + + using (var tree = TreeNode("Mod Import")) + { + if (tree) + { + using var table = Table("##DebugModImport", 2, ImGuiTableFlags.SizingFixedFit); + if (table) + { + var importing = _modImporter.IsImporting(out var importer); + PrintValue("Is Importing", importing.ToString()); + PrintValue("Importer State", (importer?.State ?? ImporterState.None).ToString()); + PrintValue("Import Window Was Drawn", _importPopup.WasDrawn.ToString()); + PrintValue("Import Popup Was Drawn", _importPopup.PopupWasDrawn.ToString()); + ImGui.TableNextColumn(); + ImGui.TextUnformatted("Import Batches"); + ImGui.TableNextColumn(); + foreach (var (batch, index) in _modImporter.ModBatches.WithIndex()) + { + foreach (var mod in batch) + PrintValue(index.ToString(), mod); + } + + ImGui.TableNextColumn(); + ImGui.TextUnformatted("Addable Mods"); + ImGui.TableNextColumn(); + foreach (var mod in _modImporter.AddableMods) + { + ImGui.TableNextColumn(); + ImGui.TableNextColumn(); + ImGui.TextUnformatted(mod.Name); + } + } + } + } + + using (var tree = TreeNode("Framework")) + { + if (tree) + { + using var table = Table("##DebugFramework", 2, ImGuiTableFlags.SizingFixedFit); + if (table) + { + foreach (var important in _framework.Important) + PrintValue(important, "Immediate"); + + foreach (var (onTick, idx) in _framework.OnTick.WithIndex()) + PrintValue(onTick, $"{idx + 1} Tick(s) From Now"); + + foreach (var (time, name) in _framework.Delayed) + { + var span = time - DateTime.UtcNow; + PrintValue(name, $"After {span.Minutes:D2}:{span.Seconds:D2}.{span.Milliseconds / 10:D2} (+ Ticks)"); + } + } + } + } + + using (var tree = TreeNode($"Texture Manager {_textureManager.Tasks.Count}###Texture Manager")) + { + if (tree) + { + using var table = Table("##Tasks", 2, ImGuiTableFlags.RowBg); + if (table) + foreach (var task in _textureManager.Tasks) + { + ImGuiUtil.DrawTableColumn(task.Key.ToString()!); + ImGuiUtil.DrawTableColumn(task.Value.Item1.Status.ToString()); + } + } + } + + using (var tree = TreeNode("Redraw Service")) + { + if (tree) + { + using var table = Table("##redraws", 3, ImGuiTableFlags.RowBg); + if (table) + { + ImGuiUtil.DrawTableColumn("In GPose"); + ImGuiUtil.DrawTableColumn(_redraws.InGPose.ToString()); + ImGui.TableNextColumn(); + + ImGuiUtil.DrawTableColumn("Target"); + ImGuiUtil.DrawTableColumn(_redraws.Target.ToString()); + ImGui.TableNextColumn(); + + foreach (var (objectIdx, idx) in _redraws.Queue.WithIndex()) + { + var (actualIdx, state) = objectIdx < 0 ? (~objectIdx, "Queued") : (objectIdx, "Invisible"); + ImGuiUtil.DrawTableColumn($"Redraw Queue #{idx}"); + ImGuiUtil.DrawTableColumn(actualIdx.ToString()); + ImGuiUtil.DrawTableColumn(state); + } + + foreach (var (objectIdx, idx) in _redraws.AfterGPoseQueue.WithIndex()) + { + var (actualIdx, state) = objectIdx < 0 ? (~objectIdx, "Queued") : (objectIdx, "Invisible"); + ImGuiUtil.DrawTableColumn($"GPose Queue #{idx}"); + ImGuiUtil.DrawTableColumn(actualIdx.ToString()); + ImGuiUtil.DrawTableColumn(state); + } + + foreach (var (name, idx) in _redraws.GPoseNames.OfType().WithIndex()) + { + ImGuiUtil.DrawTableColumn($"GPose Name #{idx}"); + ImGuiUtil.DrawTableColumn(name); + ImGui.TableNextColumn(); + } + } + } + } + + using (var tree = ImUtf8.TreeNode("String Memory"u8)) + { + if (tree) + { + using (ImUtf8.Group()) + { + ImUtf8.Text("Currently Allocated Strings"u8); + ImUtf8.Text("Total Allocated Strings"u8); + ImUtf8.Text("Free'd Allocated Strings"u8); + ImUtf8.Text("Currently Allocated Bytes"u8); + ImUtf8.Text("Total Allocated Bytes"u8); + ImUtf8.Text("Free'd Allocated Bytes"u8); + } + + ImGui.SameLine(); + using (ImUtf8.Group()) + { + ImUtf8.Text($"{PenumbraStringMemory.CurrentStrings}"); + ImUtf8.Text($"{PenumbraStringMemory.AllocatedStrings}"); + ImUtf8.Text($"{PenumbraStringMemory.FreedStrings}"); + ImUtf8.Text($"{PenumbraStringMemory.CurrentBytes}"); + ImUtf8.Text($"{PenumbraStringMemory.AllocatedBytes}"); + ImUtf8.Text($"{PenumbraStringMemory.FreedBytes}"); + } + } + } + } + + private void DrawPerformanceTab() + { + if (!ImGui.CollapsingHeader("Performance")) + return; + + using (var start = TreeNode("Startup Performance", ImGuiTreeNodeFlags.DefaultOpen)) + { + if (start) + ImGui.NewLine(); + } + + _performance.Draw("##performance", "Enable Runtime Performance Tracking", TimingExtensions.ToName); + } + + private unsafe void DrawActorsDebug() + { + if (!ImGui.CollapsingHeader("Actors")) + return; + + using (var objectTree = ImUtf8.TreeNode("Object Manager"u8)) + { + if (objectTree) + { + _objects.DrawDebug(); + + using var table = Table("##actors", 8, ImGuiTableFlags.RowBg | ImGuiTableFlags.SizingFixedFit, + -Vector2.UnitX); + if (!table) + return; + + DrawSpecial("Current Player", _actors.GetCurrentPlayer()); + DrawSpecial("Current Inspect", _actors.GetInspectPlayer()); + DrawSpecial("Current Card", _actors.GetCardPlayer()); + DrawSpecial("Current Glamour", _actors.GetGlamourPlayer()); + + foreach (var obj in _objects) + { + ImGuiUtil.DrawTableColumn(obj.Address != nint.Zero ? $"{((GameObject*)obj.Address)->ObjectIndex}" : "NULL"); + ImGui.TableNextColumn(); + Penumbra.Dynamis.DrawPointer(obj.Address); + ImGui.TableNextColumn(); + if (obj.Address != nint.Zero) + Penumbra.Dynamis.DrawPointer((nint)((Character*)obj.Address)->GameObject.GetDrawObject()); + var identifier = _actors.FromObject(obj, out _, false, true, false); + ImGuiUtil.DrawTableColumn(_actors.ToString(identifier)); + var id = obj.AsObject->ObjectKind is ObjectKind.BattleNpc + ? $"{identifier.DataId} | {obj.AsObject->BaseId}" + : identifier.DataId.ToString(); + ImGuiUtil.DrawTableColumn(id); + ImGui.TableNextColumn(); + Penumbra.Dynamis.DrawPointer(obj.Address != nint.Zero ? *(nint*)obj.Address : nint.Zero); + ImGuiUtil.DrawTableColumn(obj.Address != nint.Zero ? $"0x{obj.AsObject->EntityId:X}" : "NULL"); + ImGuiUtil.DrawTableColumn(obj.Address != nint.Zero + ? obj.AsObject->IsCharacter() ? $"Character: {obj.AsCharacter->ObjectKind}" : "No Character" + : "NULL"); + } + } + } + + using (var shapeTree = ImUtf8.TreeNode("Shape Inspector"u8)) + { + if (shapeTree) + _shapeInspector.Draw(); + } + + return; + + void DrawSpecial(string name, ActorIdentifier id) + { + if (!id.IsValid) + return; + + ImGuiUtil.DrawTableColumn(name); + ImGuiUtil.DrawTableColumn(string.Empty); + ImGuiUtil.DrawTableColumn(string.Empty); + ImGuiUtil.DrawTableColumn(_actors.ToString(id)); + ImGuiUtil.DrawTableColumn(string.Empty); + ImGuiUtil.DrawTableColumn(string.Empty); + ImGuiUtil.DrawTableColumn(string.Empty); + ImGuiUtil.DrawTableColumn(string.Empty); + } + } + + /// + /// Draw information about which draw objects correspond to which game objects + /// and which paths are due to be loaded by which collection. + /// + private unsafe void DrawPathResolverDebug() + { + if (!ImGui.CollapsingHeader("Path Resolver")) + return; + + ImGui.TextUnformatted( + $"Last Game Object: 0x{_collectionResolver.IdentifyLastGameObjectCollection(true).AssociatedGameObject:X} ({_collectionResolver.IdentifyLastGameObjectCollection(true).ModCollection.Identity.Name})"); + using (var drawTree = TreeNode("Draw Object to Object")) + { + if (drawTree) + { + using var table = Table("###DrawObjectResolverTable", 8, ImGuiTableFlags.SizingFixedFit); + if (table) + foreach (var (drawObject, (gameObjectPtr, idx, child)) in _drawObjectState + .OrderBy(kvp => kvp.Value.Item2.Index) + .ThenBy(kvp => kvp.Value.Item3) + .ThenBy(kvp => kvp.Key.Address)) + { + ImGui.TableNextColumn(); + ImUtf8.CopyOnClickSelectable($"{drawObject}"); + ImUtf8.DrawTableColumn($"{gameObjectPtr.Index}"); + using (ImRaii.PushColor(ImGuiCol.Text, 0xFF0000FF, gameObjectPtr.Index != idx)) + { + ImUtf8.DrawTableColumn($"{idx}"); + } + + ImUtf8.DrawTableColumn(child ? "Child"u8 : "Main"u8); + ImGui.TableNextColumn(); + ImUtf8.CopyOnClickSelectable($"{gameObjectPtr}"); + using (ImRaii.PushColor(ImGuiCol.Text, 0xFF0000FF, _objects[idx] != gameObjectPtr)) + { + ImUtf8.DrawTableColumn($"{_objects[idx]}"); + } + + ImUtf8.DrawTableColumn(gameObjectPtr.Utf8Name.Span); + var collection = _collectionResolver.IdentifyCollection(gameObjectPtr.AsObject, true); + ImUtf8.DrawTableColumn(collection.ModCollection.Identity.Name); + } + } + } + + using (var pathTree = TreeNode("Path Collections")) + { + if (pathTree) + { + using var table = Table("###PathCollectionResolverTable", 2, ImGuiTableFlags.SizingFixedFit); + if (table) + foreach (var data in _pathState.CurrentData) + { + ImGui.TableNextColumn(); + ImGui.TextUnformatted($"{data.AssociatedGameObject:X}"); + ImGui.TableNextColumn(); + ImGui.TextUnformatted(data.ModCollection.Identity.Name); + } + } + } + + using (var resourceTree = TreeNode("Subfile Collections")) + { + if (resourceTree) + { + using var table = Table("###ResourceCollectionResolverTable", 4, ImGuiTableFlags.SizingFixedFit); + if (table) + { + ImGuiUtil.DrawTableColumn("Current Mtrl Data"); + ImGuiUtil.DrawTableColumn(_subfileHelper.MtrlData.ModCollection.Identity.Name); + ImGuiUtil.DrawTableColumn($"0x{_subfileHelper.MtrlData.AssociatedGameObject:X}"); + ImGui.TableNextColumn(); + + ImGuiUtil.DrawTableColumn("Current Avfx Data"); + ImGuiUtil.DrawTableColumn(_subfileHelper.AvfxData.ModCollection.Identity.Name); + ImGuiUtil.DrawTableColumn($"0x{_subfileHelper.AvfxData.AssociatedGameObject:X}"); + ImGui.TableNextColumn(); + + ImGuiUtil.DrawTableColumn("Current Resources"); + ImGuiUtil.DrawTableColumn(_subfileHelper.Count.ToString()); + ImGui.TableNextColumn(); + ImGui.TableNextColumn(); + + foreach (var (resource, resolve) in _subfileHelper) + { + ImGuiUtil.DrawTableColumn($"0x{resource:X}"); + ImGuiUtil.DrawTableColumn(resolve.ModCollection.Identity.Name); + ImGuiUtil.DrawTableColumn($"0x{resolve.AssociatedGameObject:X}"); + ImGuiUtil.DrawTableColumn($"{((ResourceHandle*)resource)->FileName()}"); + } + } + } + } + + using (var identifiedTree = TreeNode("Identified Collections")) + { + if (identifiedTree) + { + using var table = Table("##PathCollectionsIdentifiedTable", 4, ImGuiTableFlags.SizingFixedFit); + if (table) + foreach (var (address, identifier, collection) in _identifiedCollectionCache + .OrderBy(kvp => ((GameObject*)kvp.Address)->ObjectIndex)) + { + ImGuiUtil.DrawTableColumn($"{((GameObject*)address)->ObjectIndex}"); + ImGuiUtil.DrawTableColumn($"0x{address:X}"); + ImGuiUtil.DrawTableColumn(identifier.ToString()); + ImGuiUtil.DrawTableColumn(collection.Identity.Name); + } + } + } + + using (var cutsceneTree = TreeNode("Cutscene Actors")) + { + if (cutsceneTree) + { + using var table = Table("###PCutsceneResolverTable", 2, ImGuiTableFlags.SizingFixedFit); + if (table) + foreach (var (idx, actor) in _cutsceneService.Actors) + { + ImGuiUtil.DrawTableColumn($"Cutscene Actor {idx}"); + ImGuiUtil.DrawTableColumn(actor.Name.ToString()); + } + } + } + + using (var groupTree = TreeNode("Group")) + { + if (groupTree) + { + using var table = Table("###PGroupTable", 2, ImGuiTableFlags.SizingFixedFit); + if (table) + { + ImGuiUtil.DrawTableColumn("Group Members"); + ImGuiUtil.DrawTableColumn(GroupManager.Instance()->MainGroup.MemberCount.ToString()); + for (var i = 0; i < 8; ++i) + { + ImGuiUtil.DrawTableColumn($"Member #{i}"); + var member = GroupManager.Instance()->MainGroup.GetPartyMemberByIndex(i); + ImGuiUtil.DrawTableColumn(member == null ? "NULL" : new ByteString(member->Name).ToString()); + } + } + } + } + + using (var bannerTree = TreeNode("Party Banner")) + { + if (bannerTree) + { + var agent = &AgentBannerParty.Instance()->AgentBannerInterface; + if (agent->Data == null) + agent = &AgentBannerMIP.Instance()->AgentBannerInterface; + + ImUtf8.Text("Agent: "); + ImGui.SameLine(0, 0); + Penumbra.Dynamis.DrawPointer((nint)agent); + if (agent->Data != null) + { + using var table = Table("###PBannerTable", 2, ImGuiTableFlags.SizingFixedFit); + if (table) + for (var i = 0; i < 8; ++i) + { + ref var c = ref agent->Data->Characters[i]; + ImGuiUtil.DrawTableColumn($"Character {i}"); + var name = c.Name1.ToString(); + ImGuiUtil.DrawTableColumn(name.Length == 0 ? "NULL" : $"{name} ({c.WorldId})"); + } + } + else + { + ImGui.TextUnformatted("INACTIVE"); + } + } + } + + using (var tmbCache = TreeNode("TMB Cache")) + { + if (tmbCache) + { + using var table = Table("###TmbTable", 2, ImGuiTableFlags.SizingFixedFit); + if (table) + foreach (var (id, name) in _schedulerService.ListedTmbs.OrderBy(kvp => kvp.Key)) + { + ImUtf8.DrawTableColumn($"{id:D6}"); + ImUtf8.DrawTableColumn(name.Span); + } + } + } + } + + private void DrawData() + { + if (!ImGui.CollapsingHeader("Game Data")) + return; + + DrawEmotes(); + DrawActionTmbs(); + DrawStainTemplates(); + DrawAtch(); + DrawChangedItemTest(); + } + + private string _changedItemPath = string.Empty; + private readonly Dictionary _changedItems = []; + + private void DrawChangedItemTest() + { + using var node = TreeNode("Changed Item Test"); + if (!node) + return; + + if (ImUtf8.InputText("##ChangedItemTest"u8, ref _changedItemPath, "Changed Item File Path..."u8)) + { + _changedItems.Clear(); + _objectIdentification.Identify(_changedItems, _changedItemPath); + } + + if (_changedItems.Count == 0) + return; + + using var list = ImUtf8.ListBox("##ChangedItemList"u8, + new Vector2(ImGui.GetContentRegionAvail().X, 8 * ImGui.GetTextLineHeightWithSpacing())); + if (!list) + return; + + foreach (var item in _changedItems) + ImUtf8.Selectable(item.Key); + } + + + private string _emoteSearchFile = string.Empty; + private string _emoteSearchName = string.Empty; + + + private AtchFile? _atchFile; + + private void DrawAtch() + { + try + { + _atchFile ??= new AtchFile(_dataManager.GetFile("chara/xls/attachOffset/c0101.atch")!.Data); + } + catch + { + // ignored + } + + if (_atchFile == null) + return; + + using var mainTree = ImUtf8.TreeNode("Atch File C0101"u8); + if (!mainTree) + return; + + AtchDrawer.Draw(_atchFile); + } + + private void DrawEmotes() + { + using var mainTree = TreeNode("Emotes"); + if (!mainTree) + return; + + ImGui.InputText("File Name", ref _emoteSearchFile, 256); + ImGui.InputText("Emote Name", ref _emoteSearchName, 256); + using var table = Table("##table", 2, ImGuiTableFlags.RowBg | ImGuiTableFlags.ScrollY | ImGuiTableFlags.SizingFixedFit, + new Vector2(-1, 12 * ImGui.GetTextLineHeightWithSpacing())); + if (!table) + return; + + var skips = ImGuiClip.GetNecessarySkips(ImGui.GetTextLineHeightWithSpacing()); + var dummy = ImGuiClip.FilteredClippedDraw(_emotes, skips, + p => p.Key.Contains(_emoteSearchFile, StringComparison.OrdinalIgnoreCase) + && (_emoteSearchName.Length == 0 + || p.Value.Any(s => s.Name.ToDalamudString().TextValue.Contains(_emoteSearchName, StringComparison.OrdinalIgnoreCase))), + p => + { + ImGui.TableNextColumn(); + ImGui.TextUnformatted(p.Key); + ImGui.TableNextColumn(); + ImGui.TextUnformatted(string.Join(", ", p.Value.Select(v => v.Name.ToDalamudString().TextValue))); + }); + ImGuiClip.DrawEndDummy(dummy, ImGui.GetTextLineHeightWithSpacing()); + } + + private string _tmbKeyFilter = string.Empty; + private CiByteString _tmbKeyFilterU8 = CiByteString.Empty; + + private void DrawActionTmbs() + { + using var mainTree = TreeNode("Action TMBs"); + if (!mainTree) + return; + + if (ImGui.InputText("Key", ref _tmbKeyFilter, 256)) + _tmbKeyFilterU8 = CiByteString.FromString(_tmbKeyFilter, out var r, MetaDataComputation.All) ? r : CiByteString.Empty; + using var table = Table("##table", 2, ImGuiTableFlags.RowBg | ImGuiTableFlags.ScrollY | ImGuiTableFlags.SizingFixedFit, + new Vector2(-1, 12 * ImGui.GetTextLineHeightWithSpacing())); + if (!table) + return; + + var skips = ImGuiClip.GetNecessarySkips(ImGui.GetTextLineHeightWithSpacing()); + var dummy = ImGuiClip.FilteredClippedDraw(_schedulerService.ActionTmbs.OrderBy(r => r.Value), skips, + kvp => kvp.Key.Contains(_tmbKeyFilterU8), + p => + { + ImUtf8.DrawTableColumn($"{p.Value}"); + ImUtf8.DrawTableColumn(p.Key.Span); + }); + ImGuiClip.DrawEndDummy(dummy, ImGui.GetTextLineHeightWithSpacing()); + } + + private void DrawStainTemplates() + { + using var mainTree = TreeNode("Staining Templates"); + if (!mainTree) + return; + + using (var legacyTree = TreeNode("stainingtemplate.stm")) + { + if (legacyTree) + DrawStainTemplatesFile(_stains.LegacyStmFile); + } + + using (var gudTree = TreeNode("stainingtemplate_gud.stm")) + { + if (gudTree) + DrawStainTemplatesFile(_stains.GudStmFile); + } + } + + private static void DrawStainTemplatesFile(StmFile stmFile) where TDyePack : unmanaged, IDyePack + { + foreach (var (key, data) in stmFile.Entries) + { + using var tree = TreeNode($"Template {key}"); + if (!tree) + continue; + + using var table = Table("##table", data.Colors.Length + data.Scalars.Length, + ImGuiTableFlags.SizingFixedFit | ImGuiTableFlags.RowBg); + if (!table) + continue; + + for (var i = 0; i < StmFile.StainingTemplateEntry.NumElements; ++i) + { + foreach (var list in data.Colors) + { + var color = list[i]; + ImGui.TableNextColumn(); + var frame = new Vector2(ImGui.GetTextLineHeight()); + ImGui.ColorButton("###color", new Vector4(MtrlTab.PseudoSqrtRgb((Vector3)color), 1), 0, frame); + ImGui.SameLine(); + ImGui.TextUnformatted($"{color.Red:F6} | {color.Green:F6} | {color.Blue:F6}"); + } + + foreach (var list in data.Scalars) + { + var scalar = list[i]; + ImGuiUtil.DrawTableColumn($"{scalar:F6}"); + } + } + } + } + + + private void DrawShaderReplacementFixer() + { + if (!ImGui.CollapsingHeader("Shader Replacement Fixer")) + return; + + var enableShaderReplacementFixer = _shaderReplacementFixer.Enabled; + if (ImGui.Checkbox("Enable Shader Replacement Fixer", ref enableShaderReplacementFixer)) + _shaderReplacementFixer.Enabled = enableShaderReplacementFixer; + + if (!enableShaderReplacementFixer) + return; + + using var table = Table("##ShaderReplacementFixer", 3, ImGuiTableFlags.RowBg | ImGuiTableFlags.SizingFixedFit, + -Vector2.UnitX); + if (!table) + return; + + var slowPathCallDeltas = _shaderReplacementFixer.GetAndResetSlowPathCallDeltas(); + + ImGui.TableSetupColumn("Shader Package Name", ImGuiTableColumnFlags.WidthStretch, 0.6f); + ImGui.TableSetupColumn("Materials with Modded ShPk", ImGuiTableColumnFlags.WidthStretch, 0.2f); + ImGui.TableSetupColumn("\u0394 Slow-Path Calls", ImGuiTableColumnFlags.WidthStretch, 0.2f); + ImGui.TableHeadersRow(); + + ImGui.TableNextColumn(); + ImGui.TextUnformatted("characterglass.shpk"); + ImGui.TableNextColumn(); + ImGui.TextUnformatted($"{_shaderReplacementFixer.ModdedCharacterGlassShpkCount}"); + ImGui.TableNextColumn(); + ImGui.TextUnformatted($"{slowPathCallDeltas.CharacterGlass}"); + + ImGui.TableNextColumn(); + ImGui.TextUnformatted("characterlegacy.shpk"); + ImGui.TableNextColumn(); + ImGui.TextUnformatted($"{_shaderReplacementFixer.ModdedCharacterLegacyShpkCount}"); + ImGui.TableNextColumn(); + ImGui.TextUnformatted($"{slowPathCallDeltas.CharacterLegacy}"); + + ImGui.TableNextColumn(); + ImGui.TextUnformatted("characterocclusion.shpk"); + ImGui.TableNextColumn(); + ImGui.TextUnformatted($"{_shaderReplacementFixer.ModdedCharacterOcclusionShpkCount}"); + ImGui.TableNextColumn(); + ImGui.TextUnformatted($"{slowPathCallDeltas.CharacterOcclusion}"); + + ImGui.TableNextColumn(); + ImGui.TextUnformatted("characterstockings.shpk"); + ImGui.TableNextColumn(); + ImGui.TextUnformatted($"{_shaderReplacementFixer.ModdedCharacterStockingsShpkCount}"); + ImGui.TableNextColumn(); + ImGui.TextUnformatted($"{slowPathCallDeltas.CharacterStockings}"); + + ImGui.TableNextColumn(); + ImGui.TextUnformatted("charactertattoo.shpk"); + ImGui.TableNextColumn(); + ImGui.TextUnformatted($"{_shaderReplacementFixer.ModdedCharacterTattooShpkCount}"); + ImGui.TableNextColumn(); + ImGui.TextUnformatted($"{slowPathCallDeltas.CharacterTattoo}"); + + ImGui.TableNextColumn(); + ImGui.TextUnformatted("charactertransparency.shpk"); + ImGui.TableNextColumn(); + ImGui.TextUnformatted($"{_shaderReplacementFixer.ModdedCharacterTransparencyShpkCount}"); + ImGui.TableNextColumn(); + ImGui.TextUnformatted($"{slowPathCallDeltas.CharacterTransparency}"); + + ImGui.TableNextColumn(); + ImGui.TextUnformatted("hairmask.shpk"); + ImGui.TableNextColumn(); + ImGui.TextUnformatted($"{_shaderReplacementFixer.ModdedHairMaskShpkCount}"); + ImGui.TableNextColumn(); + ImGui.TextUnformatted($"{slowPathCallDeltas.HairMask}"); + + ImGui.TableNextColumn(); + ImGui.TextUnformatted("iris.shpk"); + ImGui.TableNextColumn(); + ImGui.TextUnformatted($"{_shaderReplacementFixer.ModdedIrisShpkCount}"); + ImGui.TableNextColumn(); + ImGui.TextUnformatted($"{slowPathCallDeltas.Iris}"); + + ImGui.TableNextColumn(); + ImGui.TextUnformatted("skin.shpk"); + ImGui.TableNextColumn(); + ImGui.TextUnformatted($"{_shaderReplacementFixer.ModdedSkinShpkCount}"); + ImGui.TableNextColumn(); + ImGui.TextUnformatted($"{slowPathCallDeltas.Skin}"); + } + + /// Draw information about the models, materials and resources currently loaded by the local player. + private unsafe void DrawPlayerModelInfo() + { + var player = _objects.Objects.LocalPlayer; + var name = player?.Name.ToString() ?? "NULL"; + if (!ImGui.CollapsingHeader($"Player Model Info: {name}##Draw") || player == null) + return; + + DrawCopyableAddress("PlayerCharacter"u8, player.Address); + + var model = (CharacterBase*)((Character*)player.Address)->GameObject.GetDrawObject(); + if (model == null) + return; + + DrawCopyableAddress("CharacterBase"u8, model); + + using (var t1 = Table("##table", 2, ImGuiTableFlags.SizingFixedFit)) + { + if (t1) + { + ImGuiUtil.DrawTableColumn("Flags"); + ImGuiUtil.DrawTableColumn($"{model->StateFlags}"); + ImGuiUtil.DrawTableColumn("Has Model In Slot Loaded"); + ImGuiUtil.DrawTableColumn($"{model->HasModelInSlotLoaded:X8}"); + ImGuiUtil.DrawTableColumn("Has Model Files In Slot Loaded"); + ImGuiUtil.DrawTableColumn($"{model->HasModelFilesInSlotLoaded:X8}"); + } + } + + using var table = Table($"##{name}DrawTable", 5, ImGuiTableFlags.RowBg | ImGuiTableFlags.SizingFixedFit); + if (!table) + return; + + ImGui.TableNextColumn(); + ImGui.TableHeader("Slot"); + ImGui.TableNextColumn(); + ImGui.TableHeader("Imc Ptr"); + ImGui.TableNextColumn(); + ImGui.TableHeader("Imc File"); + ImGui.TableNextColumn(); + ImGui.TableHeader("Model Ptr"); + ImGui.TableNextColumn(); + ImGui.TableHeader("Model File"); + + for (var i = 0; i < model->SlotCount; ++i) + { + var imc = (ResourceHandle*)model->IMCArray[i]; + ImGui.TableNextRow(); + ImGui.TableNextColumn(); + ImGui.TextUnformatted($"Slot {i}"); + ImGui.TableNextColumn(); + Penumbra.Dynamis.DrawPointer((nint)imc); + ImGui.TableNextColumn(); + if (imc != null) + UiHelpers.Text(imc); + + var mdl = (RenderModel*)model->Models[i]; + ImGui.TableNextColumn(); + Penumbra.Dynamis.DrawPointer((nint)mdl); + if (mdl == null || mdl->ResourceHandle == null || mdl->ResourceHandle->Category != ResourceCategory.Chara) + continue; + + ImGui.TableNextColumn(); + { + UiHelpers.Text(mdl->ResourceHandle); + } + } + } + + private string _crcInput = string.Empty; + private FullPath _crcPath = FullPath.Empty; + + private unsafe void DrawCrcCache() + { + var header = ImUtf8.CollapsingHeader("CRC Cache"u8); + if (!header) + return; + + if (ImUtf8.InputText("##crcInput"u8, ref _crcInput, "Input path for CRC..."u8)) + _crcPath = new FullPath(_crcInput); + + using var font = ImRaii.PushFont(UiBuilder.MonoFont); + ImUtf8.Text($" CRC32: {_crcPath.InternalName.CiCrc32:X8}"); + ImUtf8.Text($"CI CRC32: {_crcPath.InternalName.Crc32:X8}"); + ImUtf8.Text($" CRC64: {_crcPath.Crc64:X16}"); + + using var table = ImUtf8.Table("table"u8, 2); + if (!table) + return; + + ImUtf8.TableSetupColumn("Hash"u8, ImGuiTableColumnFlags.WidthFixed, 18 * UiBuilder.MonoFont.GetCharAdvance('0')); + ImUtf8.TableSetupColumn("Type"u8, ImGuiTableColumnFlags.WidthFixed, 5 * UiBuilder.MonoFont.GetCharAdvance('0')); + ImGui.TableHeadersRow(); + + foreach (var (hash, type) in _rsfService.CustomCache) + { + ImGui.TableNextColumn(); + ImUtf8.Text($"{hash:X16}"); + ImGui.TableNextColumn(); + ImUtf8.Text($"{type}"); + } + } + + private unsafe void DrawResourceLoader() + { + if (!ImUtf8.CollapsingHeader("Resource Loader"u8)) + return; + + var ongoingLoads = _resourceLoader.OngoingLoads; + var ongoingLoadCount = ongoingLoads.Count; + ImUtf8.Text($"Ongoing Loads: {ongoingLoadCount}"); + + if (ongoingLoadCount == 0) + return; + + using var table = ImUtf8.Table("ongoingLoadTable"u8, 3); + if (!table) + return; + + ImUtf8.TableSetupColumn("Resource Handle"u8, ImGuiTableColumnFlags.WidthStretch, 0.2f); + ImUtf8.TableSetupColumn("Actual Path"u8, ImGuiTableColumnFlags.WidthStretch, 0.4f); + ImUtf8.TableSetupColumn("Original Path"u8, ImGuiTableColumnFlags.WidthStretch, 0.4f); + ImGui.TableHeadersRow(); + + foreach (var (handle, original) in ongoingLoads) + { + ImUtf8.DrawTableColumn($"0x{handle:X}"); + ImUtf8.DrawTableColumn(((ResourceHandle*)handle)->CsHandle.FileName); + ImUtf8.DrawTableColumn(original.Path.Span); + } + } + + /// Draw resources with unusual reference count. + private unsafe void DrawResourceProblems() + { + var header = ImGui.CollapsingHeader("Resource Problems"); + ImGuiUtil.HoverTooltip("Draw resources with unusually high reference count to detect overflows."); + if (!header) + return; + + using var table = Table("##ProblemsTable", 6, ImGuiTableFlags.RowBg | ImGuiTableFlags.SizingFixedFit); + if (!table) + return; + + _resourceManager.IterateResources((_, r) => + { + if (r->RefCount < 10000) + return; + + ImGui.TableNextColumn(); + ImGui.TextUnformatted(((ResourceCategory)r->Type.Value).ToString()); + ImGui.TableNextColumn(); + ImGui.TextUnformatted(r->FileType.ToString("X")); + ImGui.TableNextColumn(); + ImGui.TextUnformatted(r->Id.ToString("X")); + ImGui.TableNextColumn(); + ImGui.TextUnformatted(((ulong)r).ToString("X")); + ImGui.TableNextColumn(); + ImGui.TextUnformatted(r->RefCount.ToString()); + ImGui.TableNextColumn(); + ref var name = ref r->FileName; + if (name.Capacity > 15) + UiHelpers.Text(name.BufferPtr, (int)name.Length); + else + fixed (byte* ptr = name.Buffer) + { + UiHelpers.Text(ptr, (int)name.Length); + } + }); + } + + + private string _cloudTesterPath = string.Empty; + private bool? _cloudTesterReturn; + private Exception? _cloudTesterError; + + private void DrawCloudApi() + { + if (!ImUtf8.CollapsingHeader("Cloud API"u8)) + return; + + using var id = ImRaii.PushId("CloudApiTester"u8); + + if (ImUtf8.InputText("Path"u8, ref _cloudTesterPath, flags: ImGuiInputTextFlags.EnterReturnsTrue)) + { + try + { + _cloudTesterReturn = CloudApi.IsCloudSynced(_cloudTesterPath); + _cloudTesterError = null; + } + catch (Exception e) + { + _cloudTesterReturn = null; + _cloudTesterError = e; + } + } + + if (_cloudTesterReturn.HasValue) + ImUtf8.Text($"Is Cloud Synced? {_cloudTesterReturn}"); + + if (_cloudTesterError is not null) + { + using var color = ImRaii.PushColor(ImGuiCol.Text, ImGuiColors.DalamudRed); + ImUtf8.Text($"{_cloudTesterError}"); + } + } + + + /// Draw information about IPC options and availability. + private void DrawDebugTabIpc() + { + if (!ImUtf8.CollapsingHeader("IPC"u8)) + return; + + using (var tree = ImUtf8.TreeNode("Dynamis"u8)) + { + if (tree) + Penumbra.Dynamis.DrawDebugInfo(); + } + + _ipcTester.Draw(); + } + + /// Helper to print a property and its value in a 2-column table. + private static void PrintValue(string name, string value) + { + ImGui.TableNextColumn(); + ImGui.TextUnformatted(name); + ImGui.TableNextColumn(); + ImGui.TextUnformatted(value); + } + + public override void Draw() + => DrawContent(); + + public override bool DrawConditions() + => _config.DebugMode && _config.Ephemeral.DebugSeparateWindow; + + public override void OnClose() + { + _config.Ephemeral.DebugSeparateWindow = false; + _config.Ephemeral.Save(); + } + + public static unsafe void DrawCopyableAddress(ReadOnlySpan label, void* address) + => DrawCopyableAddress(label, (nint)address); + + public static unsafe void DrawCopyableAddress(ReadOnlySpan label, nint address) + { + Penumbra.Dynamis.DrawPointer(address); + ImUtf8.SameLineInner(); + ImUtf8.Text(label); + } +} diff --git a/Penumbra/UI/Tabs/Debug/GlobalVariablesDrawer.cs b/Penumbra/UI/Tabs/Debug/GlobalVariablesDrawer.cs new file mode 100644 index 00000000..bc5f0765 --- /dev/null +++ b/Penumbra/UI/Tabs/Debug/GlobalVariablesDrawer.cs @@ -0,0 +1,253 @@ +using Dalamud.Bindings.ImGui; +using FFXIVClientStructs.FFXIV.Client.Graphics.Kernel; +using FFXIVClientStructs.FFXIV.Client.System.Scheduler; +using FFXIVClientStructs.FFXIV.Client.System.Scheduler.Resource; +using FFXIVClientStructs.Interop; +using FFXIVClientStructs.STD; +using OtterGui.Services; +using OtterGui.Text; +using Penumbra.Interop.Services; +using Penumbra.Interop.Structs; +using Penumbra.String; +using Penumbra.Util; +using ResidentResourceManager = Penumbra.Interop.Services.ResidentResourceManager; + +namespace Penumbra.UI.Tabs.Debug; + +public unsafe class GlobalVariablesDrawer( + CharacterUtility characterUtility, + ResidentResourceManager residentResources, + SchedulerResourceManagementService scheduler) : IUiService +{ + /// Draw information about some game global variables. + public void Draw() + { + var header = ImUtf8.CollapsingHeader("Global Variables"u8); + ImUtf8.HoverTooltip("Draw information about global variables. Can provide useful starting points for a memory viewer."u8); + if (!header) + return; + + var actionManager = (ActionTimelineManager**)ActionTimelineManager.Instance(); + using (ImUtf8.Group()) + { + Penumbra.Dynamis.DrawPointer(characterUtility.Address); + Penumbra.Dynamis.DrawPointer(residentResources.Address); + Penumbra.Dynamis.DrawPointer(ScheduleManagement.Instance()); + Penumbra.Dynamis.DrawPointer(actionManager); + Penumbra.Dynamis.DrawPointer(actionManager != null ? *actionManager : null); + Penumbra.Dynamis.DrawPointer(scheduler.Address); + Penumbra.Dynamis.DrawPointer(scheduler.Address != null ? *scheduler.Address : null); + Penumbra.Dynamis.DrawPointer(Device.Instance()); + } + + ImGui.SameLine(); + using (ImUtf8.Group()) + { + ImUtf8.Text("CharacterUtility"u8); + ImUtf8.Text("ResidentResourceManager"u8); + ImUtf8.Text("ScheduleManagement"u8); + ImUtf8.Text("ActionTimelineManager*"u8); + ImUtf8.Text("ActionTimelineManager"u8); + ImUtf8.Text("SchedulerResourceManagement*"u8); + ImUtf8.Text("SchedulerResourceManagement"u8); + ImUtf8.Text("Device"u8); + } + + DrawCharacterUtility(); + DrawResidentResources(); + DrawSchedulerResourcesMap(); + DrawSchedulerResourcesList(); + } + + + /// + /// Draw information about the character utility class from SE, + /// displaying all files, their sizes, the default files and the default sizes. + /// + private void DrawCharacterUtility() + { + using var tree = ImUtf8.TreeNode("Character Utility"u8); + if (!tree) + return; + + using var table = ImUtf8.Table("##CharacterUtility"u8, 7, ImGuiTableFlags.RowBg | ImGuiTableFlags.SizingFixedFit, + -Vector2.UnitX); + if (!table) + return; + + for (var idx = 0; idx < CharacterUtility.ReverseIndices.Length; ++idx) + { + var intern = CharacterUtility.ReverseIndices[idx]; + var resource = characterUtility.Address->Resource(idx); + ImUtf8.DrawTableColumn($"[{idx}]"); + ImGui.TableNextColumn(); + Penumbra.Dynamis.DrawPointer(resource); + if (resource == null) + { + ImGui.TableNextRow(); + continue; + } + + ImUtf8.DrawTableColumn(resource->CsHandle.FileName.AsSpan()); + ImGui.TableNextColumn(); + var data = (nint)resource->CsHandle.GetData(); + var length = resource->CsHandle.GetLength(); + Penumbra.Dynamis.DrawPointer(data); + ImUtf8.DrawTableColumn(length.ToString()); + ImGui.TableNextColumn(); + if (intern.Value != -1) + { + Penumbra.Dynamis.DrawPointer(characterUtility.DefaultResource(intern).Address); + ImUtf8.DrawTableColumn($"{characterUtility.DefaultResource(intern).Size}"); + } + else + { + ImGui.TableNextColumn(); + } + } + } + + /// Draw information about the resident resource files. + private void DrawResidentResources() + { + using var tree = ImUtf8.TreeNode("Resident Resources"u8); + if (!tree) + return; + + if (residentResources.Address == null || residentResources.Address->NumResources == 0) + return; + + using var table = ImUtf8.Table("##ResidentResources"u8, 5, ImGuiTableFlags.RowBg | ImGuiTableFlags.SizingFixedFit, + -Vector2.UnitX); + if (!table) + return; + + for (var idx = 0; idx < residentResources.Address->NumResources; ++idx) + { + var resource = residentResources.Address->ResourceList[idx]; + ImUtf8.DrawTableColumn($"[{idx}]"); + ImGui.TableNextColumn(); + Penumbra.Dynamis.DrawPointer(resource); + if (resource == null) + { + ImGui.TableNextRow(); + continue; + } + + ImUtf8.DrawTableColumn(resource->CsHandle.FileName.AsSpan()); + ImGui.TableNextColumn(); + var data = (nint)resource->CsHandle.GetData(); + var length = resource->CsHandle.GetLength(); + Penumbra.Dynamis.DrawPointer(data); + ImUtf8.DrawTableColumn(length.ToString()); + } + } + + private string _schedulerFilterList = string.Empty; + private string _schedulerFilterMap = string.Empty; + private CiByteString _schedulerFilterListU8 = CiByteString.Empty; + private CiByteString _schedulerFilterMapU8 = CiByteString.Empty; + private int _shownResourcesList = 0; + private int _shownResourcesMap = 0; + + private void DrawSchedulerResourcesMap() + { + using var tree = ImUtf8.TreeNode("Scheduler Resources (Map)"u8); + if (!tree) + return; + + if (scheduler.Address == null || scheduler.Scheduler == null) + return; + + if (ImUtf8.InputText("##SchedulerMapFilter"u8, ref _schedulerFilterMap, "Filter..."u8)) + _schedulerFilterMapU8 = CiByteString.FromString(_schedulerFilterMap, out var t, MetaDataComputation.All, false) + ? t + : CiByteString.Empty; + ImUtf8.Text($"{_shownResourcesMap} / {scheduler.Scheduler->Resources.LongCount}"); + using var table = ImUtf8.Table("##SchedulerMapResources"u8, 10, ImGuiTableFlags.RowBg | ImGuiTableFlags.SizingFixedFit, + -Vector2.UnitX); + if (!table) + return; + + // TODO Remove cast when it'll have the right type in CS. + var map = (StdMap>*)&scheduler.Scheduler->Resources; + var total = 0; + _shownResourcesMap = 0; + foreach (var (key, resourcePtr) in *map) + { + var resource = resourcePtr.Value; + if (_schedulerFilterMap.Length is 0 || resource->Name.Buffer.IndexOf(_schedulerFilterMapU8.Span) >= 0) + { + ImUtf8.DrawTableColumn($"[{total:D4}]"); + ImUtf8.DrawTableColumn($"{resource->Name.GetField(16)}"); // Unk1 + ImUtf8.DrawTableColumn(new CiByteString(resource->Name.Buffer, MetaDataComputation.None).Span); + ImUtf8.DrawTableColumn($"{resource->Consumers}"); + ImUtf8.DrawTableColumn($"{PointerExtensions.GetField(resource, 120)}"); // key, Unk1 + ImGui.TableNextColumn(); + Penumbra.Dynamis.DrawPointer(resource); + ImGui.TableNextColumn(); + var resourceHandle = *((ResourceHandle**)resource + 3); + Penumbra.Dynamis.DrawPointer(resourceHandle); + ImGui.TableNextColumn(); + ImUtf8.CopyOnClickSelectable(resourceHandle->FileName().Span); + ImGui.TableNextColumn(); + uint dataLength = 0; + Penumbra.Dynamis.DrawPointer(resource->GetResourceData(&dataLength)); + ImUtf8.DrawTableColumn($"{dataLength}"); + ++_shownResourcesMap; + } + + ++total; + } + } + + private void DrawSchedulerResourcesList() + { + using var tree = ImUtf8.TreeNode("Scheduler Resources (List)"u8); + if (!tree) + return; + + if (scheduler.Address == null || scheduler.Scheduler == null) + return; + + if (ImUtf8.InputText("##SchedulerListFilter"u8, ref _schedulerFilterList, "Filter..."u8)) + _schedulerFilterListU8 = CiByteString.FromString(_schedulerFilterList, out var t, MetaDataComputation.All, false) + ? t + : CiByteString.Empty; + ImUtf8.Text($"{_shownResourcesList} / {scheduler.Scheduler->Resources.LongCount}"); + using var table = ImUtf8.Table("##SchedulerListResources"u8, 10, ImGuiTableFlags.RowBg | ImGuiTableFlags.SizingFixedFit, + -Vector2.UnitX); + if (!table) + return; + + var resource = scheduler.Scheduler->Begin; + var total = 0; + _shownResourcesList = 0; + while (resource != null && total < scheduler.Scheduler->Resources.Count) + { + if (_schedulerFilterList.Length is 0 || resource->Name.Buffer.IndexOf(_schedulerFilterListU8.Span) >= 0) + { + ImUtf8.DrawTableColumn($"[{total:D4}]"); + ImUtf8.DrawTableColumn($"{resource->Name.GetField(16)}"); // Unk1 + ImUtf8.DrawTableColumn(new CiByteString(resource->Name.Buffer, MetaDataComputation.None).Span); + ImUtf8.DrawTableColumn($"{resource->Consumers}"); + ImUtf8.DrawTableColumn($"{PointerExtensions.GetField(resource, 120)}"); // key, Unk1 + ImGui.TableNextColumn(); + Penumbra.Dynamis.DrawPointer(resource); + ImGui.TableNextColumn(); + var resourceHandle = *((ResourceHandle**)resource + 3); + Penumbra.Dynamis.DrawPointer(resourceHandle); + ImGui.TableNextColumn(); + ImUtf8.CopyOnClickSelectable(resourceHandle->FileName().Span); + ImGui.TableNextColumn(); + uint dataLength = 0; + Penumbra.Dynamis.DrawPointer(resource->GetResourceData(&dataLength)); + ImUtf8.DrawTableColumn($"{dataLength}"); + ++_shownResourcesList; + } + + resource = resource->Previous; + ++total; + } + } +} diff --git a/Penumbra/UI/Tabs/Debug/HookOverrideDrawer.cs b/Penumbra/UI/Tabs/Debug/HookOverrideDrawer.cs new file mode 100644 index 00000000..f1024950 --- /dev/null +++ b/Penumbra/UI/Tabs/Debug/HookOverrideDrawer.cs @@ -0,0 +1,84 @@ +using Dalamud.Bindings.ImGui; +using Dalamud.Plugin; +using OtterGui.Services; +using OtterGui.Text; +using Penumbra.Interop.Hooks; + +namespace Penumbra.UI.Tabs.Debug; + +public class HookOverrideDrawer(IDalamudPluginInterface pluginInterface) : IUiService +{ + private HookOverrides? _overrides; + + public void Draw() + { + using var header = ImUtf8.CollapsingHeaderId("Generate Hook Override"u8); + if (!header) + return; + + _overrides ??= HookOverrides.Instance.Clone(); + + if (ImUtf8.Button("Save"u8)) + _overrides.Write(pluginInterface); + + ImGui.SameLine(); + var path = Path.Combine(pluginInterface.GetPluginConfigDirectory(), HookOverrides.FileName); + var exists = File.Exists(path); + if (ImUtf8.ButtonEx("Delete"u8, disabled: !exists, tooltip: exists ? ""u8 : "File does not exist."u8)) + try + { + File.Delete(path); + } + catch (Exception ex) + { + Penumbra.Log.Error($"Could not delete hook override file at {path}:\n{ex}"); + } + + bool? allVisible = null; + ImGui.SameLine(); + if (ImUtf8.Button("Disable All Visible Hooks"u8)) + allVisible = true; + ImGui.SameLine(); + if (ImUtf8.Button("Enable All VisibleHooks"u8)) + allVisible = false; + + bool? all = null; + ImGui.SameLine(); + if (ImUtf8.Button("Disable All Hooks")) + all = true; + ImGui.SameLine(); + if (ImUtf8.Button("Enable All Hooks")) + all = false; + + foreach (var propertyField in typeof(HookOverrides).GetFields().Where(f => f is { IsStatic: false, FieldType.IsValueType: true })) + { + using var tree = ImUtf8.TreeNode(propertyField.Name); + if (!tree) + { + if (all.HasValue) + { + var property = propertyField.GetValue(_overrides); + foreach (var valueField in propertyField.FieldType.GetFields()) + { + valueField.SetValue(property, all.Value); + propertyField.SetValue(_overrides, property); + } + } + } + else + { + allVisible ??= all; + var property = propertyField.GetValue(_overrides); + foreach (var valueField in propertyField.FieldType.GetFields()) + { + var value = valueField.GetValue(property) as bool? ?? false; + if (ImUtf8.Checkbox($"Disable {valueField.Name}", ref value) || allVisible.HasValue) + { + valueField.SetValue(property, allVisible ?? value); + propertyField.SetValue(_overrides, property); + } + } + } + } + } +} diff --git a/Penumbra/UI/Tabs/Debug/ModMigratorDebug.cs b/Penumbra/UI/Tabs/Debug/ModMigratorDebug.cs new file mode 100644 index 00000000..e6e01107 --- /dev/null +++ b/Penumbra/UI/Tabs/Debug/ModMigratorDebug.cs @@ -0,0 +1,55 @@ +using Dalamud.Bindings.ImGui; +using OtterGui.Services; +using OtterGui.Text; +using Penumbra.Services; + +namespace Penumbra.UI.Tabs.Debug; + +public class ModMigratorDebug(ModMigrator migrator) : IUiService +{ + private string _inputPath = string.Empty; + private string _outputPath = string.Empty; + private Task? _indexTask; + private Task? _mdlTask; + + public void Draw() + { + if (!ImUtf8.CollapsingHeaderId("Mod Migrator"u8)) + return; + + ImUtf8.InputText("##input"u8, ref _inputPath, "Input Path..."u8); + ImUtf8.InputText("##output"u8, ref _outputPath, "Output Path..."u8); + + if (ImUtf8.ButtonEx("Create Index Texture"u8, "Requires input to be a path to a normal texture."u8, default, _inputPath.Length == 0 + || _outputPath.Length == 0 + || _indexTask is + { + IsCompleted: false, + })) + _indexTask = migrator.CreateIndexFile(_inputPath, _outputPath); + + if (_indexTask is not null) + { + ImGui.SameLine(); + ImUtf8.TextFrameAligned($"{_indexTask.Status}"); + } + + if (ImUtf8.ButtonEx("Update Model File"u8, "Requires input to be a path to a mdl."u8, default, _inputPath.Length == 0 + || _outputPath.Length == 0 + || _mdlTask is + { + IsCompleted: false, + })) + _mdlTask = Task.Run(() => + { + File.Copy(_inputPath, _outputPath, true); + MigrationManager.TryMigrateSingleModel(_outputPath, false); + }); + + if (_mdlTask is not null) + { + ImGui.SameLine(); + ImUtf8.TextFrameAligned($"{_mdlTask.Status}"); + } + } +} diff --git a/Penumbra/UI/Tabs/Debug/RenderTargetDrawer.cs b/Penumbra/UI/Tabs/Debug/RenderTargetDrawer.cs new file mode 100644 index 00000000..d497f90a --- /dev/null +++ b/Penumbra/UI/Tabs/Debug/RenderTargetDrawer.cs @@ -0,0 +1,98 @@ +using Dalamud.Bindings.ImGui; +using FFXIVClientStructs.FFXIV.Client.Graphics.Kernel; +using FFXIVClientStructs.FFXIV.Client.Graphics.Render; +using OtterGui; +using OtterGui.Services; +using OtterGui.Text; +using Penumbra.Interop.Hooks; +using Penumbra.Interop.Hooks.PostProcessing; +using Penumbra.Services; + +namespace Penumbra.UI.Tabs.Debug; + +public class RenderTargetDrawer(RenderTargetHdrEnabler renderTargetHdrEnabler, DalamudConfigService dalamudConfig, Configuration config) : IUiService +{ + private void DrawStatistics() + { + using (ImUtf8.Group()) + { + ImUtf8.Text("Wait For Plugins (Now)"); + ImUtf8.Text("Wait For Plugins (First Launch)"); + + ImUtf8.Text("HDR Enabled (Now)"); + ImUtf8.Text("HDR Enabled (First Launch)"); + + ImUtf8.Text("HDR Hook Overriden (Now)"); + ImUtf8.Text("HDR Hook Overriden (First Launch)"); + + ImUtf8.Text("HDR Detour Called"); + ImUtf8.Text("Penumbra Reload Count"); + } + ImGui.SameLine(); + using (ImUtf8.Group()) + { + ImUtf8.Text($"{(dalamudConfig.GetDalamudConfig(DalamudConfigService.WaitingForPluginsOption, out bool w) ? w.ToString() : "Unknown")}"); + ImUtf8.Text($"{renderTargetHdrEnabler.FirstLaunchWaitForPluginsState?.ToString() ?? "Unknown"}"); + + ImUtf8.Text($"{config.HdrRenderTargets}"); + ImUtf8.Text($"{renderTargetHdrEnabler.FirstLaunchHdrState}"); + + ImUtf8.Text($"{HookOverrides.Instance.PostProcessing.RenderTargetManagerInitialize}"); + ImUtf8.Text($"{!renderTargetHdrEnabler.FirstLaunchHdrHookOverrideState}"); + + ImUtf8.Text($"{renderTargetHdrEnabler.HdrEnabledSuccess}"); + ImUtf8.Text($"{renderTargetHdrEnabler.PenumbraReloadCount}"); + } + } + + /// Draw information about render targets. + public unsafe void Draw() + { + if (!ImUtf8.CollapsingHeader("Render Targets"u8)) + return; + + DrawStatistics(); + ImUtf8.Dummy(0); + ImGui.Separator(); + ImUtf8.Dummy(0); + var report = renderTargetHdrEnabler.TextureReport; + if (report == null) + { + ImUtf8.Text("The RenderTargetManager report has not been gathered."u8); + ImUtf8.Text("Please restart the game with Debug Mode and Wait for Plugins on Startup enabled to fill this section."u8); + return; + } + + using var table = ImUtf8.Table("##RenderTargetTable"u8, 5, ImGuiTableFlags.RowBg | ImGuiTableFlags.SizingFixedFit); + if (!table) + return; + + ImUtf8.TableSetupColumn("Offset"u8, ImGuiTableColumnFlags.WidthStretch, 0.15f); + ImUtf8.TableSetupColumn("Creation Order"u8, ImGuiTableColumnFlags.WidthStretch, 0.15f); + ImUtf8.TableSetupColumn("Original Texture Format"u8, ImGuiTableColumnFlags.WidthStretch, 0.2f); + ImUtf8.TableSetupColumn("Current Texture Format"u8, ImGuiTableColumnFlags.WidthStretch, 0.2f); + ImUtf8.TableSetupColumn("Comment"u8, ImGuiTableColumnFlags.WidthStretch, 0.3f); + ImGui.TableHeadersRow(); + + foreach (var record in report) + { + ImUtf8.DrawTableColumn($"0x{record.Offset:X}"); + ImUtf8.DrawTableColumn($"{record.CreationOrder}"); + ImUtf8.DrawTableColumn($"{record.OriginalTextureFormat}"); + ImGui.TableNextColumn(); + var texture = *(Texture**)((nint)RenderTargetManager.Instance() + + record.Offset); + if (texture != null) + { + using var color = Dalamud.Interface.Utility.Raii.ImRaii.PushColor(ImGuiCol.Text, ImGuiUtil.HalfBlendText(0xFF), + texture->TextureFormat != record.OriginalTextureFormat); + ImUtf8.Text($"{texture->TextureFormat}"); + } + + ImGui.TableNextColumn(); + var forcedConfig = RenderTargetHdrEnabler.GetForcedTextureConfig(record.CreationOrder); + if (forcedConfig.HasValue) + ImUtf8.Text(forcedConfig.Value.Comment); + } + } +} diff --git a/Penumbra/UI/Tabs/Debug/ShapeInspector.cs b/Penumbra/UI/Tabs/Debug/ShapeInspector.cs new file mode 100644 index 00000000..4c3b43bf --- /dev/null +++ b/Penumbra/UI/Tabs/Debug/ShapeInspector.cs @@ -0,0 +1,284 @@ +using Dalamud.Bindings.ImGui; +using Dalamud.Interface; +using Dalamud.Interface.Utility.Raii; +using OtterGui.Extensions; +using OtterGui.Services; +using OtterGui.Text; +using Penumbra.Collections.Cache; +using Penumbra.GameData.Enums; +using Penumbra.GameData.Interop; +using Penumbra.Interop.PathResolving; +using Penumbra.Meta; +using Penumbra.Meta.Manipulations; + +namespace Penumbra.UI.Tabs.Debug; + +public class ShapeInspector(ObjectManager objects, CollectionResolver resolver) : IUiService +{ + private int _objectIndex; + + public void Draw() + { + ImUtf8.InputScalar("Object Index"u8, ref _objectIndex); + var actor = objects[0]; + if (!actor.IsCharacter) + { + ImUtf8.Text("No valid character."u8); + return; + } + + var human = actor.Model; + if (!human.IsHuman) + { + ImUtf8.Text("No valid character."u8); + return; + } + + DrawCollectionShapeCache(actor); + DrawCharacterShapes(human); + DrawCollectionAttributeCache(actor); + DrawCharacterAttributes(human); + } + + private unsafe void DrawCollectionAttributeCache(Actor actor) + { + var data = resolver.IdentifyCollection(actor.AsObject, true); + using var treeNode1 = ImUtf8.TreeNode($"Collection Attribute Cache ({data.ModCollection})"); + if (!treeNode1.Success || !data.ModCollection.HasCache) + return; + + using var table = ImUtf8.Table("##aCache"u8, 2, ImGuiTableFlags.RowBg); + if (!table) + return; + + ImUtf8.TableSetupColumn("Attribute"u8, ImGuiTableColumnFlags.WidthFixed, 150 * ImUtf8.GlobalScale); + ImUtf8.TableSetupColumn("State"u8, ImGuiTableColumnFlags.WidthStretch); + + ImGui.TableHeadersRow(); + foreach (var (attribute, set) in data.ModCollection.MetaCache!.Atr.Data.OrderBy(a => a.Key)) + { + ImUtf8.DrawTableColumn(attribute.AsSpan); + DrawValues(attribute, set); + } + } + + private unsafe void DrawCollectionShapeCache(Actor actor) + { + var data = resolver.IdentifyCollection(actor.AsObject, true); + using var treeNode1 = ImUtf8.TreeNode($"Collection Shape Cache ({data.ModCollection})"); + if (!treeNode1.Success || !data.ModCollection.HasCache) + return; + + using var table = ImUtf8.Table("##sCache"u8, 3, ImGuiTableFlags.RowBg); + if (!table) + return; + + ImUtf8.TableSetupColumn("Condition"u8, ImGuiTableColumnFlags.WidthFixed, 150 * ImUtf8.GlobalScale); + ImUtf8.TableSetupColumn("Shape"u8, ImGuiTableColumnFlags.WidthFixed, 150 * ImUtf8.GlobalScale); + ImUtf8.TableSetupColumn("State"u8, ImGuiTableColumnFlags.WidthStretch); + + ImGui.TableHeadersRow(); + foreach (var condition in Enum.GetValues()) + { + foreach (var (shape, set) in data.ModCollection.MetaCache!.Shp.State(condition).OrderBy(shp => shp.Key)) + { + ImUtf8.DrawTableColumn(condition.ToString()); + ImUtf8.DrawTableColumn(shape.AsSpan); + DrawValues(shape, set); + } + } + } + + private static void DrawValues(in ShapeAttributeString shapeAttribute, ShapeAttributeHashSet set) + { + ImGui.TableNextColumn(); + + if (set.All is { } value) + { + using var color = ImRaii.PushColor(ImGuiCol.Text, ImGui.GetColorU32(ImGuiCol.TextDisabled), !value); + ImUtf8.Text("All, "u8); + ImGui.SameLine(0, 0); + } + + foreach (var slot in ShapeAttributeManager.UsedModels) + { + if (set[slot] is not { } value2) + continue; + + using var color = ImRaii.PushColor(ImGuiCol.Text, ImGui.GetColorU32(ImGuiCol.TextDisabled), !value2); + ImUtf8.Text($"All {slot.ToName()}, "); + ImGui.SameLine(0, 0); + } + + foreach (var gr in ShapeAttributeHashSet.GenderRaceValues.Skip(1)) + { + if (set[gr] is { } value3) + { + using var color = ImRaii.PushColor(ImGuiCol.Text, ImGui.GetColorU32(ImGuiCol.TextDisabled), !value3); + ImUtf8.Text($"All {gr.ToName()}, "); + ImGui.SameLine(0, 0); + } + else + { + foreach (var slot in ShapeAttributeManager.UsedModels) + { + if (set[slot, gr] is not { } value4) + continue; + + using var color = ImRaii.PushColor(ImGuiCol.Text, ImGui.GetColorU32(ImGuiCol.TextDisabled), !value4); + ImUtf8.Text($"All {gr.ToName()} {slot.ToName()}, "); + ImGui.SameLine(0, 0); + } + } + } + + foreach (var ((slot, id), flags) in set) + { + if ((flags & 3) is not 0) + { + var enabled = (flags & 1) is 1; + + if (set[slot, GenderRace.Unknown] != enabled) + { + using var color = ImRaii.PushColor(ImGuiCol.Text, ImGui.GetColorU32(ImGuiCol.TextDisabled), !enabled); + ImUtf8.Text($"{slot.ToName()} {id.Id:D4}, "); + ImGui.SameLine(0, 0); + } + } + else + { + var currentIndex = BitOperations.TrailingZeroCount(flags) / 2; + var currentFlags = flags >> (2 * currentIndex); + while (currentIndex < ShapeAttributeHashSet.GenderRaceValues.Count) + { + var enabled = (currentFlags & 1) is 1; + var gr = ShapeAttributeHashSet.GenderRaceValues[currentIndex]; + if (set[slot, gr] != enabled) + { + using var color = ImRaii.PushColor(ImGuiCol.Text, ImGui.GetColorU32(ImGuiCol.TextDisabled), !enabled); + ImUtf8.Text($"{gr.ToName()} {slot.ToName()} #{id.Id:D4}, "); + ImGui.SameLine(0, 0); + } + + currentFlags &= ~0x3u; + currentIndex += BitOperations.TrailingZeroCount(currentFlags) / 2; + currentFlags = flags >> (2 * currentIndex); + } + } + } + } + + private unsafe void DrawCharacterShapes(Model human) + { + using var treeNode2 = ImUtf8.TreeNode("Character Model Shapes"u8); + if (!treeNode2) + return; + + using var table = ImUtf8.Table("##shapes"u8, 7, ImGuiTableFlags.RowBg); + if (!table) + return; + + ImUtf8.TableSetupColumn("#"u8, ImGuiTableColumnFlags.WidthFixed, 25 * ImUtf8.GlobalScale); + ImUtf8.TableSetupColumn("Slot"u8, ImGuiTableColumnFlags.WidthFixed, 150 * ImUtf8.GlobalScale); + ImUtf8.TableSetupColumn("Address"u8, ImGuiTableColumnFlags.WidthFixed, UiBuilder.MonoFont.GetCharAdvance('0') * 14); + ImUtf8.TableSetupColumn("Mask"u8, ImGuiTableColumnFlags.WidthFixed, UiBuilder.MonoFont.GetCharAdvance('0') * 8); + ImUtf8.TableSetupColumn("ID"u8, ImGuiTableColumnFlags.WidthFixed, UiBuilder.MonoFont.GetCharAdvance('0') * 4); + ImUtf8.TableSetupColumn("Count"u8, ImGuiTableColumnFlags.WidthFixed, 30 * ImUtf8.GlobalScale); + ImUtf8.TableSetupColumn("Shapes"u8, ImGuiTableColumnFlags.WidthStretch); + + ImGui.TableHeadersRow(); + + var disabledColor = ImGui.GetColorU32(ImGuiCol.TextDisabled); + for (var i = 0; i < human.AsHuman->SlotCount; ++i) + { + ImUtf8.DrawTableColumn($"{(uint)i:D2}"); + ImUtf8.DrawTableColumn(((HumanSlot)i).ToName()); + + ImGui.TableNextColumn(); + var model = human.AsHuman->Models[i]; + Penumbra.Dynamis.DrawPointer((nint)model); + if (model is not null) + { + var mask = model->EnabledShapeKeyIndexMask; + ImUtf8.DrawTableColumn($"{mask:X8}"); + ImUtf8.DrawTableColumn($"{human.GetModelId((HumanSlot)i):D4}"); + ImUtf8.DrawTableColumn($"{model->ModelResourceHandle->Shapes.Count}"); + ImGui.TableNextColumn(); + foreach (var ((shape, flag), idx) in model->ModelResourceHandle->Shapes.WithIndex()) + { + var disabled = (mask & (1u << flag)) is 0; + using var color = ImRaii.PushColor(ImGuiCol.Text, disabledColor, disabled); + ImUtf8.Text(shape.AsSpan()); + ImGui.SameLine(0, 0); + ImUtf8.Text(", "u8); + if (idx % 8 < 7) + ImGui.SameLine(0, 0); + } + } + else + { + ImGui.TableNextColumn(); + ImGui.TableNextColumn(); + ImGui.TableNextColumn(); + ImGui.TableNextColumn(); + } + } + } + + private unsafe void DrawCharacterAttributes(Model human) + { + using var treeNode2 = ImUtf8.TreeNode("Character Model Attributes"u8); + if (!treeNode2) + return; + + using var table = ImUtf8.Table("##attributes"u8, 7, ImGuiTableFlags.RowBg); + if (!table) + return; + + ImUtf8.TableSetupColumn("#"u8, ImGuiTableColumnFlags.WidthFixed, 25 * ImUtf8.GlobalScale); + ImUtf8.TableSetupColumn("Slot"u8, ImGuiTableColumnFlags.WidthFixed, 150 * ImUtf8.GlobalScale); + ImUtf8.TableSetupColumn("Address"u8, ImGuiTableColumnFlags.WidthFixed, UiBuilder.MonoFont.GetCharAdvance('0') * 14); + ImUtf8.TableSetupColumn("Mask"u8, ImGuiTableColumnFlags.WidthFixed, UiBuilder.MonoFont.GetCharAdvance('0') * 8); + ImUtf8.TableSetupColumn("ID"u8, ImGuiTableColumnFlags.WidthFixed, UiBuilder.MonoFont.GetCharAdvance('0') * 4); + ImUtf8.TableSetupColumn("Count"u8, ImGuiTableColumnFlags.WidthFixed, 30 * ImUtf8.GlobalScale); + ImUtf8.TableSetupColumn("Attributes"u8, ImGuiTableColumnFlags.WidthStretch); + + ImGui.TableHeadersRow(); + + var disabledColor = ImGui.GetColorU32(ImGuiCol.TextDisabled); + for (var i = 0; i < human.AsHuman->SlotCount; ++i) + { + ImUtf8.DrawTableColumn($"{(uint)i:D2}"); + ImUtf8.DrawTableColumn(((HumanSlot)i).ToName()); + + ImGui.TableNextColumn(); + var model = human.AsHuman->Models[i]; + Penumbra.Dynamis.DrawPointer((nint)model); + if (model is not null) + { + var mask = model->EnabledAttributeIndexMask; + ImUtf8.DrawTableColumn($"{mask:X8}"); + ImUtf8.DrawTableColumn($"{human.GetModelId((HumanSlot)i):D4}"); + ImUtf8.DrawTableColumn($"{model->ModelResourceHandle->Attributes.Count}"); + ImGui.TableNextColumn(); + foreach (var ((attribute, flag), idx) in model->ModelResourceHandle->Attributes.WithIndex()) + { + var disabled = (mask & (1u << flag)) is 0; + using var color = ImRaii.PushColor(ImGuiCol.Text, disabledColor, disabled); + ImUtf8.Text(attribute.AsSpan()); + ImGui.SameLine(0, 0); + ImUtf8.Text(", "u8); + if (idx % 8 < 7) + ImGui.SameLine(0, 0); + } + } + else + { + ImGui.TableNextColumn(); + ImGui.TableNextColumn(); + ImGui.TableNextColumn(); + ImGui.TableNextColumn(); + } + } + } +} diff --git a/Penumbra/UI/Tabs/Debug/TexHeaderDrawer.cs b/Penumbra/UI/Tabs/Debug/TexHeaderDrawer.cs new file mode 100644 index 00000000..4244e455 --- /dev/null +++ b/Penumbra/UI/Tabs/Debug/TexHeaderDrawer.cs @@ -0,0 +1,117 @@ +using Dalamud.Bindings.ImGui; +using Dalamud.Interface.DragDrop; +using Dalamud.Interface.Utility.Raii; +using Lumina.Data.Files; +using OtterGui.Services; +using OtterGui.Text; +using Penumbra.UI.Classes; + +namespace Penumbra.UI.Tabs.Debug; + +public class TexHeaderDrawer(IDragDropManager dragDrop) : IUiService +{ + private string? _path; + private TexFile.TexHeader _header; + private byte[]? _tex; + private Exception? _exception; + + public void Draw() + { + using var header = ImUtf8.CollapsingHeaderId("Tex Header"u8); + if (!header) + return; + + DrawDragDrop(); + DrawData(); + } + + private void DrawDragDrop() + { + dragDrop.CreateImGuiSource("TexFileDragDrop", m => m.Files.Count == 1 && m.Extensions.Contains(".tex"), m => + { + ImUtf8.Text($"Dragging {m.Files[0]}..."); + return true; + }); + + ImUtf8.Button("Drag .tex here..."); + if (dragDrop.CreateImGuiTarget("TexFileDragDrop", out var files, out _)) + ReadTex(files[0]); + } + + private void DrawData() + { + if (_path == null) + return; + + ImUtf8.TextFramed(_path, 0, borderColor: 0xFFFFFFFF); + + + if (_exception != null) + { + using var color = ImRaii.PushColor(ImGuiCol.Text, Colors.RegexWarningBorder); + ImUtf8.TextWrapped($"Failure to load file:\n{_exception}"); + } + else if (_tex != null) + { + using var table = ImRaii.Table("table", 2, ImGuiTableFlags.SizingFixedFit | ImGuiTableFlags.RowBg); + if (!table) + return; + + TableLine("Format"u8, _header.Format); + TableLine("Width"u8, _header.Width); + TableLine("Height"u8, _header.Height); + TableLine("Depth"u8, _header.Depth); + TableLine("Mip Levels"u8, _header.MipCount); + TableLine("Array Size"u8, _header.ArraySize); + TableLine("Type"u8, _header.Type); + TableLine("Mip Flag"u8, _header.MipUnknownFlag); + TableLine("Byte Size"u8, _tex.Length); + unsafe + { + TableLine("LoD Offset 0"u8, _header.LodOffset[0]); + TableLine("LoD Offset 1"u8, _header.LodOffset[1]); + TableLine("LoD Offset 2"u8, _header.LodOffset[2]); + TableLine("LoD Offset 0"u8, _header.OffsetToSurface[0]); + TableLine("LoD Offset 1"u8, _header.OffsetToSurface[1]); + TableLine("LoD Offset 2"u8, _header.OffsetToSurface[2]); + TableLine("LoD Offset 3"u8, _header.OffsetToSurface[3]); + TableLine("LoD Offset 4"u8, _header.OffsetToSurface[4]); + TableLine("LoD Offset 5"u8, _header.OffsetToSurface[5]); + TableLine("LoD Offset 6"u8, _header.OffsetToSurface[6]); + TableLine("LoD Offset 7"u8, _header.OffsetToSurface[7]); + TableLine("LoD Offset 8"u8, _header.OffsetToSurface[8]); + TableLine("LoD Offset 9"u8, _header.OffsetToSurface[9]); + TableLine("LoD Offset 10"u8, _header.OffsetToSurface[10]); + TableLine("LoD Offset 11"u8, _header.OffsetToSurface[11]); + TableLine("LoD Offset 12"u8, _header.OffsetToSurface[12]); + } + } + } + + private static void TableLine(ReadOnlySpan text, T value) + { + ImGui.TableNextColumn(); + ImUtf8.Text(text); + ImGui.TableNextColumn(); + ImUtf8.Text($"{value}"); + } + + private unsafe void ReadTex(string path) + { + try + { + _path = path; + _tex = File.ReadAllBytes(_path); + if (_tex.Length < sizeof(TexFile.TexHeader)) + throw new Exception($"Size {_tex.Length} does not include a header."); + + _header = MemoryMarshal.Read(_tex); + _exception = null; + } + catch (Exception ex) + { + _tex = null; + _exception = ex; + } + } +} diff --git a/Penumbra/UI/Tabs/EffectiveTab.cs b/Penumbra/UI/Tabs/EffectiveTab.cs new file mode 100644 index 00000000..5691f821 --- /dev/null +++ b/Penumbra/UI/Tabs/EffectiveTab.cs @@ -0,0 +1,192 @@ +using Dalamud.Bindings.ImGui; +using Dalamud.Interface; +using OtterGui; +using OtterGui.Classes; +using OtterGui.Raii; +using OtterGui.Services; +using OtterGui.Text; +using OtterGui.Widgets; +using Penumbra.Collections; +using Penumbra.Collections.Cache; +using Penumbra.Collections.Manager; +using Penumbra.Meta.Manipulations; +using Penumbra.Mods.Editor; +using Penumbra.String.Classes; +using Penumbra.UI.Classes; + +namespace Penumbra.UI.Tabs; + +public class EffectiveTab(CollectionManager collectionManager, CollectionSelectHeader collectionHeader) + : ITab, IUiService +{ + public ReadOnlySpan Label + => "Effective Changes"u8; + + public void DrawContent() + { + SetupEffectiveSizes(); + collectionHeader.Draw(true); + DrawFilters(); + using var child = ImRaii.Child("##EffectiveChangesTab", ImGui.GetContentRegionAvail(), false); + if (!child) + return; + + var height = ImGui.GetTextLineHeightWithSpacing() + 2 * ImGui.GetStyle().CellPadding.Y; + var skips = ImGuiClip.GetNecessarySkips(height); + using var table = ImRaii.Table("##EffectiveChangesTable", 3, ImGuiTableFlags.RowBg); + if (!table) + return; + + ImGui.TableSetupColumn("##gamePath", ImGuiTableColumnFlags.WidthFixed, _effectiveLeftTextLength); + ImGui.TableSetupColumn(string.Empty, ImGuiTableColumnFlags.WidthFixed, _effectiveArrowLength); + ImGui.TableSetupColumn("##file", ImGuiTableColumnFlags.WidthFixed, _effectiveRightTextLength); + + DrawEffectiveRows(collectionManager.Active.Current, skips, height, + _effectiveFilePathFilter.Length > 0 || _effectiveGamePathFilter.Length > 0); + } + + // Sizes + private float _effectiveLeftTextLength; + private float _effectiveRightTextLength; + private float _effectiveUnscaledArrowLength; + private float _effectiveArrowLength; + + // Filters + private LowerString _effectiveGamePathFilter = LowerString.Empty; + private LowerString _effectiveFilePathFilter = LowerString.Empty; + + /// Setup table sizes. + private void SetupEffectiveSizes() + { + if (_effectiveUnscaledArrowLength == 0) + { + using var font = ImRaii.PushFont(UiBuilder.IconFont); + _effectiveUnscaledArrowLength = + ImGui.CalcTextSize(FontAwesomeIcon.LongArrowAltLeft.ToIconString()).X / UiHelpers.Scale; + } + + _effectiveArrowLength = _effectiveUnscaledArrowLength * UiHelpers.Scale; + _effectiveLeftTextLength = 450 * UiHelpers.Scale; + _effectiveRightTextLength = ImGui.GetWindowSize().X - _effectiveArrowLength - _effectiveLeftTextLength; + } + + /// Draw the header line for filters. + private void DrawFilters() + { + var tmp = _effectiveGamePathFilter.Text; + ImGui.SetNextItemWidth(_effectiveLeftTextLength); + if (ImGui.InputTextWithHint("##gamePathFilter", "Filter game path...", ref tmp, 256)) + _effectiveGamePathFilter = tmp; + + ImGui.SameLine(_effectiveArrowLength + _effectiveLeftTextLength + 3 * ImGui.GetStyle().ItemSpacing.X); + ImGui.SetNextItemWidth(-1); + tmp = _effectiveFilePathFilter.Text; + if (ImGui.InputTextWithHint("##fileFilter", "Filter file path...", ref tmp, 256)) + _effectiveFilePathFilter = tmp; + } + + /// Draw all rows for one collection respecting filters and using clipping. + private void DrawEffectiveRows(ModCollection active, int skips, float height, bool hasFilters) + { + // We can use the known counts if no filters are active. + var stop = hasFilters + ? ImGuiClip.FilteredClippedDraw(active.ResolvedFiles, skips, CheckFilters, DrawLine) + : ImGuiClip.ClippedDraw(active.ResolvedFiles, skips, DrawLine, active.ResolvedFiles.Count); + + var m = active.MetaCache; + // If no meta manipulations are active, we can just draw the end dummy. + if (m is { Count: > 0 }) + { + // Filters mean we can not use the known counts. + if (hasFilters) + { + var it2 = m.IdentifierSources.Select(p => (p.Item1.ToString(), p.Item2.Name)); + if (stop >= 0) + { + ImGuiClip.DrawEndDummy(stop + it2.Count(CheckFilters), height); + } + else + { + stop = ImGuiClip.FilteredClippedDraw(it2, skips, CheckFilters, DrawLine, ~stop); + ImGuiClip.DrawEndDummy(stop, height); + } + } + else + { + if (stop >= 0) + { + ImGuiClip.DrawEndDummy(stop + m.Count, height); + } + else + { + stop = ImGuiClip.ClippedDraw(m.IdentifierSources, skips, DrawLine, m.Count, ~stop); + ImGuiClip.DrawEndDummy(stop, height); + } + } + } + else + { + ImGuiClip.DrawEndDummy(stop, height); + } + } + + /// Draw a line for a game path and its redirected file. + private static void DrawLine(KeyValuePair pair) + { + var (path, name) = pair; + ImGui.TableNextColumn(); + ImUtf8.CopyOnClickSelectable(path.Path.Span); + + ImGui.TableNextColumn(); + ImGuiUtil.PrintIcon(FontAwesomeIcon.LongArrowAltLeft); + ImGui.TableNextColumn(); + ImUtf8.CopyOnClickSelectable(name.Path.InternalName.Span); + ImGuiUtil.HoverTooltip($"\nChanged by {name.Mod.Name}."); + } + + /// Draw a line for a path and its name. + private static void DrawLine((string, LowerString) pair) + { + var (path, name) = pair; + ImGui.TableNextColumn(); + ImGuiUtil.CopyOnClickSelectable(path); + + ImGui.TableNextColumn(); + ImGuiUtil.PrintIcon(FontAwesomeIcon.LongArrowAltLeft); + ImGui.TableNextColumn(); + ImGuiUtil.CopyOnClickSelectable(name.Text); + } + + /// Draw a line for a unfiltered/unconverted manipulation and mod-index pair. + private static void DrawLine((IMetaIdentifier, IMod) pair) + { + var (manipulation, mod) = pair; + ImGui.TableNextColumn(); + ImGuiUtil.CopyOnClickSelectable(manipulation.ToString()); + + ImGui.TableNextColumn(); + ImGuiUtil.PrintIcon(FontAwesomeIcon.LongArrowAltLeft); + ImGui.TableNextColumn(); + ImGuiUtil.CopyOnClickSelectable(mod.Name.Text); + } + + /// Check filters for file replacements. + private bool CheckFilters(KeyValuePair kvp) + { + var (gamePath, fullPath) = kvp; + if (_effectiveGamePathFilter.Length > 0 && !gamePath.ToString().Contains(_effectiveGamePathFilter.Lower)) + return false; + + return _effectiveFilePathFilter.Length == 0 || fullPath.Path.FullName.ToLowerInvariant().Contains(_effectiveFilePathFilter.Lower); + } + + /// Check filters for meta manipulations. + private bool CheckFilters((string, LowerString) kvp) + { + var (name, path) = kvp; + if (_effectiveGamePathFilter.Length > 0 && !name.ToLowerInvariant().Contains(_effectiveGamePathFilter.Lower)) + return false; + + return _effectiveFilePathFilter.Length == 0 || path.Contains(_effectiveFilePathFilter.Lower); + } +} diff --git a/Penumbra/UI/Tabs/MessagesTab.cs b/Penumbra/UI/Tabs/MessagesTab.cs new file mode 100644 index 00000000..190f9407 --- /dev/null +++ b/Penumbra/UI/Tabs/MessagesTab.cs @@ -0,0 +1,17 @@ +using OtterGui.Services; +using OtterGui.Widgets; +using Penumbra.Services; + +namespace Penumbra.UI.Tabs; + +public class MessagesTab(MessageService messages) : ITab, IUiService +{ + public ReadOnlySpan Label + => "Messages"u8; + + public bool IsVisible + => messages.Count > 0; + + public void DrawContent() + => messages.Draw(); +} diff --git a/Penumbra/UI/Tabs/ModsTab.cs b/Penumbra/UI/Tabs/ModsTab.cs new file mode 100644 index 00000000..1d24c597 --- /dev/null +++ b/Penumbra/UI/Tabs/ModsTab.cs @@ -0,0 +1,165 @@ +using Dalamud.Bindings.ImGui; +using Dalamud.Game.ClientState.Objects; +using OtterGui; +using OtterGui.Raii; +using Penumbra.UI.Classes; +using Dalamud.Interface; +using Dalamud.Plugin.Services; +using FFXIVClientStructs.FFXIV.Client.Game; +using OtterGui.Services; +using OtterGui.Widgets; +using Penumbra.Api.Enums; +using Penumbra.Interop.Services; +using Penumbra.Mods; +using Penumbra.Mods.Manager; +using Penumbra.UI.ModsTab; +using ModFileSystemSelector = Penumbra.UI.ModsTab.ModFileSystemSelector; +using Penumbra.Collections.Manager; +using Penumbra.GameData.Interop; + +namespace Penumbra.UI.Tabs; + +public class ModsTab( + ModManager modManager, + CollectionManager collectionManager, + ModFileSystemSelector selector, + ModPanel panel, + TutorialService tutorial, + RedrawService redrawService, + Configuration config, + CollectionSelectHeader collectionHeader, + ITargetManager targets, + ObjectManager objects) + : ITab, IUiService +{ + private readonly ActiveCollections _activeCollections = collectionManager.Active; + + public bool IsVisible + => modManager.Valid; + + public ReadOnlySpan Label + => "Mods"u8; + + public void DrawHeader() + => tutorial.OpenTutorial(BasicTutorialSteps.Mods); + + public Mod SelectMod + { + set => selector.SelectByValue(value); + } + + public void DrawContent() + { + try + { + selector.Draw(); + ImGui.SameLine(); + ImGui.SetCursorPosX(MathF.Round(ImGui.GetCursorPosX())); + using var group = ImRaii.Group(); + collectionHeader.Draw(false); + + using var style = ImRaii.PushStyle(ImGuiStyleVar.ItemSpacing, Vector2.Zero); + using (var child = ImRaii.Child("##ModsTabMod", new Vector2(ImGui.GetContentRegionAvail().X, config.HideRedrawBar ? 0 : -ImGui.GetFrameHeight()), + true, ImGuiWindowFlags.HorizontalScrollbar)) + { + style.Pop(); + if (child) + panel.Draw(); + + style.Push(ImGuiStyleVar.ItemSpacing, Vector2.Zero); + } + + style.Push(ImGuiStyleVar.FrameRounding, 0); + DrawRedrawLine(); + } + catch (Exception e) + { + Penumbra.Log.Error($"Exception thrown during ModPanel Render:\n{e}"); + Penumbra.Log.Error($"{modManager.Count} Mods\n" + + $"{_activeCollections.Current.Identity.AnonymizedName} Current Collection\n" + + $"{_activeCollections.Current.Settings.Count} Settings\n" + + $"{selector.SortMode.Name} Sort Mode\n" + + $"{selector.SelectedLeaf?.Name ?? "NULL"} Selected Leaf\n" + + $"{selector.Selected?.Name ?? "NULL"} Selected Mod\n" + + $"{string.Join(", ", _activeCollections.Current.Inheritance.DirectlyInheritsFrom.Select(c => c.Identity.AnonymizedName))} Inheritances\n"); + } + } + + private void DrawRedrawLine() + { + if (config.HideRedrawBar) + { + tutorial.SkipTutorial(BasicTutorialSteps.Redrawing); + return; + } + + var frameHeight = new Vector2(0, ImGui.GetFrameHeight()); + var frameColor = ImGui.GetColorU32(ImGuiCol.FrameBg); + using (var _ = ImRaii.Group()) + { + using (ImRaii.PushFont(UiBuilder.IconFont)) + { + ImGuiUtil.DrawTextButton(FontAwesomeIcon.InfoCircle.ToIconString(), frameHeight, frameColor); + ImGui.SameLine(); + } + + ImGuiUtil.DrawTextButton("Redraw: ", frameHeight, frameColor); + } + + var hovered = ImGui.IsItemHovered(); + tutorial.OpenTutorial(BasicTutorialSteps.Redrawing); + if (hovered) + ImGui.SetTooltip($"The supported modifiers for '/penumbra redraw' are:\n{TutorialService.SupportedRedrawModifiers}"); + + using var id = ImRaii.PushId("Redraw"); + using var disabled = ImRaii.Disabled(objects.Objects.LocalPlayer is null); + ImGui.SameLine(); + var buttonWidth = frameHeight with { X = ImGui.GetContentRegionAvail().X / 5 }; + var tt = !objects[0].Valid + ? "\nCan only be used when you are logged in and your character is available." + : string.Empty; + DrawButton(buttonWidth, "All", string.Empty, tt); + ImGui.SameLine(); + DrawButton(buttonWidth, "Self", "self", tt); + ImGui.SameLine(); + + tt = targets.Target == null && targets.GPoseTarget == null + ? "\nCan only be used when you have a target." + : string.Empty; + DrawButton(buttonWidth, "Target", "target", tt); + ImGui.SameLine(); + + tt = targets.FocusTarget == null + ? "\nCan only be used when you have a focus target." + : string.Empty; + DrawButton(buttonWidth, "Focus", "focus", tt); + ImGui.SameLine(); + + tt = !IsIndoors() + ? "\nCan currently only be used for indoor furniture." + : string.Empty; + DrawButton(frameHeight with { X = ImGui.GetContentRegionAvail().X - 1 }, "Furniture", "furniture", tt); + return; + + void DrawButton(Vector2 size, string label, string lower, string additionalTooltip) + { + using (_ = ImRaii.Disabled(additionalTooltip.Length > 0)) + { + if (ImGui.Button(label, size)) + { + if (lower.Length > 0) + redrawService.RedrawObject(lower, RedrawType.Redraw); + else + redrawService.RedrawAll(RedrawType.Redraw); + } + } + + ImGuiUtil.HoverTooltip(lower.Length > 0 + ? $"Execute '/penumbra redraw {lower}'.{additionalTooltip}" + : $"Execute '/penumbra redraw'.{additionalTooltip}", ImGuiHoveredFlags.AllowWhenDisabled); + } + } + + private static unsafe bool IsIndoors() + => HousingManager.Instance()->IsInside(); +} diff --git a/Penumbra/UI/Tabs/OnScreenTab.cs b/Penumbra/UI/Tabs/OnScreenTab.cs new file mode 100644 index 00000000..fa33f702 --- /dev/null +++ b/Penumbra/UI/Tabs/OnScreenTab.cs @@ -0,0 +1,16 @@ +using OtterGui.Services; +using OtterGui.Widgets; +using Penumbra.UI.AdvancedWindow; + +namespace Penumbra.UI.Tabs; + +public class OnScreenTab(ResourceTreeViewerFactory resourceTreeViewerFactory) : ITab, IUiService +{ + private readonly ResourceTreeViewer _viewer = resourceTreeViewerFactory.Create(0, delegate { }, delegate { }); + + public ReadOnlySpan Label + => "On-Screen"u8; + + public void DrawContent() + => _viewer.Draw(); +} diff --git a/Penumbra/UI/Tabs/ResourceTab.cs b/Penumbra/UI/Tabs/ResourceTab.cs new file mode 100644 index 00000000..2223075d --- /dev/null +++ b/Penumbra/UI/Tabs/ResourceTab.cs @@ -0,0 +1,139 @@ +using Dalamud.Bindings.ImGui; +using Dalamud.Plugin.Services; +using FFXIVClientStructs.FFXIV.Client.System.Resource; +using FFXIVClientStructs.FFXIV.Client.System.Resource.Handle; +using FFXIVClientStructs.Interop; +using FFXIVClientStructs.STD; +using OtterGui; +using OtterGui.Raii; +using OtterGui.Services; +using OtterGui.Widgets; +using Penumbra.Interop.Hooks.ResourceLoading; +using Penumbra.String.Classes; + +namespace Penumbra.UI.Tabs; + +public class ResourceTab(Configuration config, ResourceManagerService resourceManager, ISigScanner sigScanner) + : ITab, IUiService +{ + public ReadOnlySpan Label + => "Resource Manager"u8; + + public bool IsVisible + => config.DebugMode; + + /// Draw a tab to iterate over the main resource maps and see what resources are currently loaded. + public void DrawContent() + { + // Filter for resources containing the input string. + ImGui.SetNextItemWidth(-1); + ImGui.InputTextWithHint("##resourceFilter", "Filter...", ref _resourceManagerFilter, Utf8GamePath.MaxGamePathLength); + + using var child = ImRaii.Child("##ResourceManagerTab", -Vector2.One); + if (!child) + return; + + unsafe + { + resourceManager.IterateGraphs(DrawCategoryContainer); + } + + ImGui.NewLine(); + unsafe + { + ImGui.TextUnformatted( + $"Static Address: 0x{(ulong)resourceManager.ResourceManagerAddress:X} (+0x{(ulong)resourceManager.ResourceManagerAddress - (ulong)sigScanner.Module.BaseAddress:X})"); + ImGui.TextUnformatted($"Actual Address: 0x{(ulong)resourceManager.ResourceManager:X}"); + } + } + + private float _hashColumnWidth; + private float _pathColumnWidth; + private float _refsColumnWidth; + private string _resourceManagerFilter = string.Empty; + + /// Draw a single resource map. + private unsafe void DrawResourceMap(ResourceCategory category, uint ext, StdMap>* map) + { + if (map == null) + return; + + var label = GetNodeLabel((uint)category, ext, map->Count); + using var tree = ImRaii.TreeNode(label); + if (!tree || map->Count == 0) + return; + + using var table = ImRaii.Table("##table", 4, ImGuiTableFlags.SizingFixedFit | ImGuiTableFlags.RowBg); + if (!table) + return; + + ImGui.TableSetupColumn("Hash", ImGuiTableColumnFlags.WidthFixed, _hashColumnWidth); + ImGui.TableSetupColumn("Ptr", ImGuiTableColumnFlags.WidthFixed, _hashColumnWidth); + ImGui.TableSetupColumn("Path", ImGuiTableColumnFlags.WidthFixed, _pathColumnWidth); + ImGui.TableSetupColumn("Refs", ImGuiTableColumnFlags.WidthFixed, _refsColumnWidth); + ImGui.TableHeadersRow(); + + resourceManager.IterateResourceMap(map, (hash, r) => + { + // Filter unwanted names. + if (_resourceManagerFilter.Length != 0 + && !r->FileName.ToString().Contains(_resourceManagerFilter, StringComparison.OrdinalIgnoreCase)) + return; + + var address = $"0x{(ulong)r:X}"; + ImGuiUtil.DrawTableColumn($"0x{hash:X8}"); + ImGui.TableNextColumn(); + ImGuiUtil.CopyOnClickSelectable(address); + + var resource = (Interop.Structs.ResourceHandle*)r; + ImGui.TableNextColumn(); + UiHelpers.Text(resource); + if (ImGui.IsItemClicked()) + { + var data = resource->CsHandle.GetData(); + if (data != null) + { + var length = (int)resource->CsHandle.GetLength(); + ImGui.SetClipboardText(string.Join(" ", + new ReadOnlySpan(data, length).ToArray().Select(b => b.ToString("X2")))); + } + } + + ImGuiUtil.HoverTooltip("Click to copy byte-wise file data to clipboard, if any."); + + ImGuiUtil.DrawTableColumn(r->RefCount.ToString()); + }); + } + + /// Draw a full category for the resource manager. + private unsafe void DrawCategoryContainer(ResourceCategory category, + StdMap>>>* map, int idx) + { + if (map == null) + return; + + using var tree = ImRaii.TreeNode($"({(uint)category:D2}) {category} (Ex {idx}) - {map->Count}###{(uint)category}_{idx}"); + if (tree) + { + SetTableWidths(); + resourceManager.IterateExtMap(map, (ext, m) => DrawResourceMap(category, ext, m)); + } + } + + /// Obtain a label for an extension node. + private static string GetNodeLabel(uint label, uint type, int count) + { + var (lowest, mid1, mid2, highest) = Functions.SplitBytes(type); + return highest == 0 + ? $"({type:X8}) {(char)mid2}{(char)mid1}{(char)lowest} - {count}###{label}{type}" + : $"({type:X8}) {(char)highest}{(char)mid2}{(char)mid1}{(char)lowest} - {count}###{label}{type}"; + } + + /// Set the widths for a resource table. + private void SetTableWidths() + { + _hashColumnWidth = 100 * UiHelpers.Scale; + _pathColumnWidth = ImGui.GetWindowContentRegionMax().X - ImGui.GetWindowContentRegionMin().X - 300 * UiHelpers.Scale; + _refsColumnWidth = 30 * UiHelpers.Scale; + } +} diff --git a/Penumbra/UI/Tabs/SettingsTab.cs b/Penumbra/UI/Tabs/SettingsTab.cs new file mode 100644 index 00000000..09c7c58d --- /dev/null +++ b/Penumbra/UI/Tabs/SettingsTab.cs @@ -0,0 +1,1186 @@ +using Dalamud.Bindings.ImGui; +using Dalamud.Interface; +using Dalamud.Interface.Components; +using Dalamud.Interface.Utility; +using Dalamud.Plugin; +using Dalamud.Plugin.Services; +using Dalamud.Utility; +using OtterGui; +using OtterGui.Compression; +using OtterGui.Custom; +using OtterGui.Raii; +using OtterGui.Services; +using OtterGui.Text; +using OtterGui.Widgets; +using Penumbra.Api; +using Penumbra.Collections; +using Penumbra.Interop; +using Penumbra.Interop.Hooks.PostProcessing; +using Penumbra.Interop.Services; +using Penumbra.Mods.Manager; +using Penumbra.Services; +using Penumbra.UI.Classes; +using Penumbra.UI.Integration; +using Penumbra.UI.ModsTab; + +namespace Penumbra.UI.Tabs; + +public class SettingsTab : ITab, IUiService +{ + public const int RootDirectoryMaxLength = 64; + + public ReadOnlySpan Label + => "Settings"u8; + + private readonly Configuration _config; + private readonly FontReloader _fontReloader; + private readonly TutorialService _tutorial; + private readonly Penumbra _penumbra; + private readonly FileDialogService _fileDialog; + private readonly ModManager _modManager; + private readonly FileWatcher _fileWatcher; + private readonly ModExportManager _modExportManager; + private readonly ModFileSystemSelector _selector; + private readonly CharacterUtility _characterUtility; + private readonly ResidentResourceManager _residentResources; + private readonly HttpApi _httpApi; + private readonly DalamudSubstitutionProvider _dalamudSubstitutionProvider; + private readonly FileCompactor _compactor; + private readonly DalamudConfigService _dalamudConfig; + private readonly IDalamudPluginInterface _pluginInterface; + private readonly IDataManager _gameData; + private readonly PredefinedTagManager _predefinedTagManager; + private readonly CrashHandlerService _crashService; + private readonly MigrationSectionDrawer _migrationDrawer; + private readonly CollectionAutoSelector _autoSelector; + private readonly CleanupService _cleanupService; + private readonly AttributeHook _attributeHook; + private readonly PcpService _pcpService; + private readonly IntegrationSettingsRegistry _integrationSettings; + + private int _minimumX = int.MaxValue; + private int _minimumY = int.MaxValue; + + private readonly TagButtons _sharedTags = new(); + + private string _lastCloudSyncTestedPath = string.Empty; + private bool _lastCloudSyncTestResult = false; + + public SettingsTab(IDalamudPluginInterface pluginInterface, Configuration config, FontReloader fontReloader, TutorialService tutorial, + Penumbra penumbra, FileDialogService fileDialog, ModManager modManager, ModFileSystemSelector selector, + CharacterUtility characterUtility, ResidentResourceManager residentResources, ModExportManager modExportManager, + FileWatcher fileWatcher, HttpApi httpApi, + DalamudSubstitutionProvider dalamudSubstitutionProvider, FileCompactor compactor, DalamudConfigService dalamudConfig, + IDataManager gameData, PredefinedTagManager predefinedTagConfig, CrashHandlerService crashService, + MigrationSectionDrawer migrationDrawer, CollectionAutoSelector autoSelector, CleanupService cleanupService, + AttributeHook attributeHook, PcpService pcpService, IntegrationSettingsRegistry integrationSettings) + { + _pluginInterface = pluginInterface; + _config = config; + _fontReloader = fontReloader; + _tutorial = tutorial; + _penumbra = penumbra; + _fileDialog = fileDialog; + _modManager = modManager; + _selector = selector; + _characterUtility = characterUtility; + _residentResources = residentResources; + _modExportManager = modExportManager; + _fileWatcher = fileWatcher; + _httpApi = httpApi; + _dalamudSubstitutionProvider = dalamudSubstitutionProvider; + _compactor = compactor; + _dalamudConfig = dalamudConfig; + _gameData = gameData; + if (_compactor.CanCompact) + _compactor.Enabled = _config.UseFileSystemCompression; + _predefinedTagManager = predefinedTagConfig; + _crashService = crashService; + _migrationDrawer = migrationDrawer; + _autoSelector = autoSelector; + _cleanupService = cleanupService; + _attributeHook = attributeHook; + _pcpService = pcpService; + _integrationSettings = integrationSettings; + } + + public void DrawHeader() + { + _tutorial.OpenTutorial(BasicTutorialSteps.Fin); + _tutorial.OpenTutorial(BasicTutorialSteps.Faq1); + _tutorial.OpenTutorial(BasicTutorialSteps.Faq2); + } + + public void DrawContent() + { + using var child = ImRaii.Child("##SettingsTab", -Vector2.One, false); + if (!child) + return; + + DrawEnabledBox(); + EphemeralCheckbox("Lock Main Window", "Prevent the main window from being resized or moved.", _config.Ephemeral.FixMainWindow, + v => _config.Ephemeral.FixMainWindow = v); + + ImGui.NewLine(); + DrawRootFolder(); + DrawDirectoryButtons(); + ImGui.NewLine(); + ImGui.NewLine(); + + DrawGeneralSettings(); + _migrationDrawer.Draw(); + DrawColorSettings(); + DrawPredefinedTagsSection(); + DrawAdvancedSettings(); + _integrationSettings.Draw(); + DrawSupportButtons(); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] + private void Checkbox(string label, string tooltip, bool current, Action setter) + { + using var id = ImRaii.PushId(label); + var tmp = current; + if (ImGui.Checkbox(string.Empty, ref tmp) && tmp != current) + { + setter(tmp); + _config.Save(); + } + + ImGui.SameLine(); + ImGuiUtil.LabeledHelpMarker(label, tooltip); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] + private void EphemeralCheckbox(string label, string tooltip, bool current, Action setter) + { + using var id = ImRaii.PushId(label); + var tmp = current; + if (ImGui.Checkbox(string.Empty, ref tmp) && tmp != current) + { + setter(tmp); + _config.Ephemeral.Save(); + } + + ImGui.SameLine(); + ImGuiUtil.LabeledHelpMarker(label, tooltip); + } + + #region Main Settings + + /// + /// Do not change the directory without explicitly pressing enter or this button. + /// Shows up only if the current input does not correspond to the current directory. + /// + private bool DrawPressEnterWarning(string newName, string old, float width, bool saved, bool selected) + { + using var color = ImRaii.PushColor(ImGuiCol.Button, Colors.PressEnterWarningBg); + var w = new Vector2(width, 0); + var (text, valid) = CheckRootDirectoryPath(newName, old, selected); + + return (ImGui.Button(text, w) || saved) && valid; + } + + /// Check a potential new root directory for validity and return the button text and whether it is valid. + private (string Text, bool Valid) CheckRootDirectoryPath(string newName, string old, bool selected) + { + static bool IsSubPathOf(string basePath, string subPath) + { + if (basePath.Length == 0) + return false; + + var rel = Path.GetRelativePath(basePath, subPath); + return rel == "." || !rel.StartsWith('.') && !Path.IsPathRooted(rel); + } + + if (newName.Length > RootDirectoryMaxLength) + return ($"Path is too long. The maximum length is {RootDirectoryMaxLength}.", false); + + if (Path.GetDirectoryName(newName).IsNullOrEmpty()) + return ("Path is not allowed to be a drive root. Please add a directory.", false); + + var desktop = Environment.GetFolderPath(Environment.SpecialFolder.Desktop); + if (IsSubPathOf(desktop, newName)) + return ("Path is not allowed to be on your Desktop.", false); + + var programFiles = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles); + var programFilesX86 = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86); + if (IsSubPathOf(programFiles, newName) || IsSubPathOf(programFilesX86, newName)) + return ("Path is not allowed to be in ProgramFiles.", false); + + var dalamud = _pluginInterface.ConfigDirectory.Parent!.Parent!; + if (IsSubPathOf(dalamud.FullName, newName)) + return ("Path is not allowed to be inside your Dalamud directories.", false); + + if (Functions.GetDownloadsFolder(out var downloads) && IsSubPathOf(downloads, newName)) + return ("Path is not allowed to be inside your Downloads folder.", false); + + var gameDir = _gameData.GameData.DataPath.Parent!.Parent!.FullName; + if (IsSubPathOf(gameDir, newName)) + return ("Path is not allowed to be inside your game folder.", false); + + if (_lastCloudSyncTestedPath != newName) + { + _lastCloudSyncTestResult = CloudApi.IsCloudSynced(newName); + _lastCloudSyncTestedPath = newName; + } + + if (_lastCloudSyncTestResult) + return ("Path is not allowed to be cloud-synced.", false); + + return selected + ? ($"Press Enter or Click Here to Save (Current Directory: {old})", true) + : ($"Click Here to Save (Current Directory: {old})", true); + } + + /// Changing the base mod directory. + private string? _newModDirectory; + + /// + /// Draw a directory picker button that toggles the directory picker. + /// Selecting a directory does behave the same as writing in the text input, i.e. needs to be saved. + /// + private void DrawDirectoryPickerButton() + { + if (!ImGuiUtil.DrawDisabledButton(FontAwesomeIcon.Folder.ToIconString(), UiHelpers.IconButtonSize, + "Select a directory via dialog.", false, true)) + return; + + _newModDirectory ??= _config.ModDirectory; + // Use the current input as start directory if it exists, + // otherwise the current mod directory, otherwise the current application directory. + var startDir = Directory.Exists(_newModDirectory) + ? _newModDirectory + : Directory.Exists(_config.ModDirectory) + ? _config.ModDirectory + : "."; + + _fileDialog.OpenFolderPicker("Choose Mod Directory", (b, s) => _newModDirectory = b ? s : _newModDirectory, startDir, false); + } + + /// + /// Draw the text input for the mod directory, + /// as well as the directory picker button and the enter warning. + /// + private void DrawRootFolder() + { + if (_newModDirectory.IsNullOrEmpty()) + _newModDirectory = _config.ModDirectory; + + bool save, selected; + using (ImRaii.Group()) + { + ImGui.SetNextItemWidth(UiHelpers.InputTextMinusButton3); + using (ImRaii.PushStyle(ImGuiStyleVar.FrameBorderSize, ImGuiHelpers.GlobalScale, !_modManager.Valid)) + { + using var color = ImRaii.PushColor(ImGuiCol.Border, Colors.RegexWarningBorder) + .Push(ImGuiCol.TextDisabled, Colors.RegexWarningBorder, !_modManager.Valid); + save = ImGui.InputTextWithHint("##rootDirectory", "Enter Root Directory here (MANDATORY)...", ref _newModDirectory, + RootDirectoryMaxLength, ImGuiInputTextFlags.EnterReturnsTrue); + } + + selected = ImGui.IsItemActive(); + using var style = ImRaii.PushStyle(ImGuiStyleVar.ItemSpacing, new Vector2(UiHelpers.ScaleX3, 0)); + ImGui.SameLine(); + DrawDirectoryPickerButton(); + style.Pop(); + ImGui.SameLine(); + + const string tt = "This is where Penumbra will store your extracted mod files.\n" + + "TTMP files are not copied, just extracted.\n" + + "This directory needs to be accessible and you need write access here.\n" + + "It is recommended that this directory is placed on a fast hard drive, preferably an SSD.\n" + + "It should also be placed near the root of a logical drive - the shorter the total path to this folder, the better.\n" + + "Definitely do not place it in your Dalamud directory or any sub-directory thereof."; + ImGuiComponents.HelpMarker(tt); + _tutorial.OpenTutorial(BasicTutorialSteps.GeneralTooltips); + ImGui.SameLine(); + ImGui.TextUnformatted("Root Directory"); + ImGuiUtil.HoverTooltip(tt); + } + + _tutorial.OpenTutorial(BasicTutorialSteps.ModDirectory); + ImGui.SameLine(); + var pos = ImGui.GetCursorPosX(); + ImGui.NewLine(); + + if (_config.ModDirectory != _newModDirectory + && _newModDirectory.Length != 0 + && DrawPressEnterWarning(_newModDirectory, _config.ModDirectory, pos, save, selected)) + _modManager.DiscoverMods(_newModDirectory, out _newModDirectory); + } + + /// Draw the Open Directory and Rediscovery buttons. + private void DrawDirectoryButtons() + { + UiHelpers.DrawOpenDirectoryButton(0, _modManager.BasePath, _modManager.Valid); + ImGui.SameLine(); + var tt = _modManager.Valid + ? "Force Penumbra to completely re-scan your root directory as if it was restarted." + : "The currently selected folder is not valid. Please select a different folder."; + if (ImGuiUtil.DrawDisabledButton("Rediscover Mods", Vector2.Zero, tt, !_modManager.Valid)) + _modManager.DiscoverMods(); + } + + /// Draw the Enable Mods Checkbox. + private void DrawEnabledBox() + { + var enabled = _config.EnableMods; + if (ImGui.Checkbox("Enable Mods", ref enabled)) + _penumbra.SetEnabled(enabled); + + _tutorial.OpenTutorial(BasicTutorialSteps.EnableMods); + } + + #endregion + + #region General Settings + + /// Draw all settings pertaining to the Mod Selector. + private void DrawGeneralSettings() + { + if (!ImGui.CollapsingHeader("General")) + { + _tutorial.OpenTutorial(BasicTutorialSteps.GeneralSettings); + return; + } + + _tutorial.OpenTutorial(BasicTutorialSteps.GeneralSettings); + + DrawHidingSettings(); + UiHelpers.DefaultLineSpace(); + + DrawMiscSettings(); + UiHelpers.DefaultLineSpace(); + + DrawIdentificationSettings(); + UiHelpers.DefaultLineSpace(); + + DrawModSelectorSettings(); + UiHelpers.DefaultLineSpace(); + + DrawModHandlingSettings(); + UiHelpers.DefaultLineSpace(); + + DrawModEditorSettings(); + ImGui.NewLine(); + } + + private int _singleGroupRadioMax = int.MaxValue; + + /// Draw a selection for the maximum number of single select options displayed as a radio toggle. + private void DrawSingleSelectRadioMax() + { + if (_singleGroupRadioMax == int.MaxValue) + _singleGroupRadioMax = _config.SingleGroupRadioMax; + + ImGui.SetNextItemWidth(UiHelpers.InputTextWidth.X); + if (ImGui.DragInt("##SingleSelectRadioMax", ref _singleGroupRadioMax, 0.01f, 1)) + _singleGroupRadioMax = Math.Max(1, _singleGroupRadioMax); + + if (ImGui.IsItemDeactivated()) + { + if (_singleGroupRadioMax != _config.SingleGroupRadioMax) + { + _config.SingleGroupRadioMax = _singleGroupRadioMax; + _config.Save(); + } + + _singleGroupRadioMax = int.MaxValue; + } + + ImGuiUtil.LabeledHelpMarker("Upper Limit for Single-Selection Group Radio Buttons", + "All Single-Selection Groups with more options than specified here will be displayed as Combo-Boxes at the top.\n" + + "All other Single-Selection Groups will be displayed as a set of Radio-Buttons."); + } + + private int _collapsibleGroupMin = int.MaxValue; + + /// Draw a selection for the minimum number of options after which a group is drawn as collapsible. + private void DrawCollapsibleGroupMin() + { + if (_collapsibleGroupMin == int.MaxValue) + _collapsibleGroupMin = _config.OptionGroupCollapsibleMin; + + ImGui.SetNextItemWidth(UiHelpers.InputTextWidth.X); + if (ImGui.DragInt("##CollapsibleGroupMin", ref _collapsibleGroupMin, 0.01f, 1)) + _collapsibleGroupMin = Math.Max(2, _collapsibleGroupMin); + + if (ImGui.IsItemDeactivated()) + { + if (_collapsibleGroupMin != _config.OptionGroupCollapsibleMin) + { + _config.OptionGroupCollapsibleMin = _collapsibleGroupMin; + _config.Save(); + } + + _collapsibleGroupMin = int.MaxValue; + } + + ImGuiUtil.LabeledHelpMarker("Collapsible Option Group Limit", + "Lower Limit for option groups displaying the Collapse/Expand button at the top."); + } + + + /// Draw the window hiding state checkboxes. + private void DrawHidingSettings() + { + Checkbox("Open Config Window at Game Start", "Whether the Penumbra main window should be open or closed after launching the game.", + _config.OpenWindowAtStart, v => _config.OpenWindowAtStart = v); + + Checkbox("Hide Config Window when UI is Hidden", + "Hide the Penumbra main window when you manually hide the in-game user interface.", _config.HideUiWhenUiHidden, + v => + { + _config.HideUiWhenUiHidden = v; + _pluginInterface.UiBuilder.DisableUserUiHide = !v; + }); + Checkbox("Hide Config Window when in Cutscenes", + "Hide the Penumbra main window when you are currently watching a cutscene.", _config.HideUiInCutscenes, + v => + { + _config.HideUiInCutscenes = v; + _pluginInterface.UiBuilder.DisableCutsceneUiHide = !v; + }); + Checkbox("Hide Config Window when in GPose", + "Hide the Penumbra main window when you are currently in GPose mode.", _config.HideUiInGPose, + v => + { + _config.HideUiInGPose = v; + _pluginInterface.UiBuilder.DisableGposeUiHide = !v; + }); + } + + /// Draw all settings that do not fit into other categories. + private void DrawMiscSettings() + { + Checkbox("Automatically Select Character-Associated Collection", + "On every login, automatically select the collection associated with the current character as the current collection for editing.", + _config.AutoSelectCollection, _autoSelector.SetAutomaticSelection); + Checkbox("Print Chat Command Success Messages to Chat", + "Chat Commands usually print messages on failure but also on success to confirm your action. You can disable this here.", + _config.PrintSuccessfulCommandsToChat, v => _config.PrintSuccessfulCommandsToChat = v); + Checkbox("Hide Redraw Bar in Mod Panel", "Hides the lower redraw buttons in the mod panel in your Mods tab.", + _config.HideRedrawBar, v => _config.HideRedrawBar = v); + Checkbox("Hide Changed Item Filters", "Hides the category filter line in the Changed Items tab and the Changed Items mod panel.", + _config.HideChangedItemFilters, v => + { + _config.HideChangedItemFilters = v; + if (v) + { + _config.Ephemeral.ChangedItemFilter = ChangedItemFlagExtensions.AllFlags; + _config.Ephemeral.Save(); + } + }); + + ChangedItemModeExtensions.DrawCombo("##ChangedItemMode"u8, _config.ChangedItemDisplay, UiHelpers.InputTextWidth.X, v => + { + _config.ChangedItemDisplay = v; + _config.Save(); + }); + ImUtf8.LabeledHelpMarker("Mod Changed Item Display"u8, + "Configure how to display the changed items of a single mod in the mods info panel."u8); + + Checkbox("Omit Machinist Offhands in Changed Items", + "Omits all Aetherotransformers (machinist offhands) in the changed items tabs because any change on them changes all of them at the moment.\n\n" + + "Changing this triggers a rediscovery of your mods so all changed items can be updated.", + _config.HideMachinistOffhandFromChangedItems, v => + { + _config.HideMachinistOffhandFromChangedItems = v; + _modManager.DiscoverMods(); + }); + Checkbox("Hide Priority Numbers in Mod Selector", + "Hides the bracketed non-zero priority numbers displayed in the mod selector when there is enough space for them.", + _config.HidePrioritiesInSelector, v => _config.HidePrioritiesInSelector = v); + DrawSingleSelectRadioMax(); + DrawCollapsibleGroupMin(); + } + + /// Draw all settings pertaining to actor identification for collections. + private void DrawIdentificationSettings() + { + Checkbox("Use Interface Collection for other Plugin UIs", + "Use the collection assigned to your interface for other plugins requesting UI-textures and icons through Dalamud.", + _dalamudSubstitutionProvider.Enabled, _dalamudSubstitutionProvider.Set); + Checkbox($"Use {TutorialService.AssignedCollections} in Lobby", + "If this is disabled, no mods are applied to characters in the lobby or at the aesthetician.", + _config.ShowModsInLobby, v => _config.ShowModsInLobby = v); + Checkbox($"Use {TutorialService.AssignedCollections} in Character Window", + "Use the individual collection for your characters name or the Your Character collection in your main character window, if it is set.", + _config.UseCharacterCollectionInMainWindow, v => _config.UseCharacterCollectionInMainWindow = v); + Checkbox($"Use {TutorialService.AssignedCollections} in Adventurer Cards", + "Use the appropriate individual collection for the adventurer card you are currently looking at, based on the adventurer's name.", + _config.UseCharacterCollectionsInCards, v => _config.UseCharacterCollectionsInCards = v); + Checkbox($"Use {TutorialService.AssignedCollections} in Try-On Window", + "Use the individual collection for your character's name in your try-on, dye preview or glamour plate window, if it is set.", + _config.UseCharacterCollectionInTryOn, v => _config.UseCharacterCollectionInTryOn = v); + Checkbox("Use No Mods in Inspect Windows", "Use the empty collection for characters you are inspecting, regardless of the character.\n" + + "Takes precedence before the next option.", _config.UseNoModsInInspect, v => _config.UseNoModsInInspect = v); + Checkbox($"Use {TutorialService.AssignedCollections} in Inspect Windows", + "Use the appropriate individual collection for the character you are currently inspecting, based on their name.", + _config.UseCharacterCollectionInInspect, v => _config.UseCharacterCollectionInInspect = v); + Checkbox($"Use {TutorialService.AssignedCollections} based on Ownership", + "Use the owner's name to determine the appropriate individual collection for mounts, companions, accessories and combat pets.", + _config.UseOwnerNameForCharacterCollection, v => _config.UseOwnerNameForCharacterCollection = v); + } + + /// Different supported sort modes as a combo. + private void DrawFolderSortType() + { + var sortMode = _config.SortMode; + ImGui.SetNextItemWidth(UiHelpers.InputTextWidth.X); + using (var combo = ImUtf8.Combo("##sortMode", sortMode.Name)) + { + if (combo) + foreach (var val in Configuration.Constants.ValidSortModes) + { + if (ImUtf8.Selectable(val.Name, val.GetType() == sortMode.GetType()) && val.GetType() != sortMode.GetType()) + { + _config.SortMode = val; + _selector.SetFilterDirty(); + _config.Save(); + } + + ImUtf8.HoverTooltip(val.Description); + } + } + + ImGuiUtil.LabeledHelpMarker("Sort Mode", "Choose the sort mode for the mod selector in the mods tab."); + } + + private void DrawRenameSettings() + { + ImGui.SetNextItemWidth(UiHelpers.InputTextWidth.X); + using (var combo = ImRaii.Combo("##renameSettings", _config.ShowRename.GetData().Name)) + { + if (combo) + foreach (var value in Enum.GetValues()) + { + var (name, desc) = value.GetData(); + if (ImGui.Selectable(name, _config.ShowRename == value)) + { + _config.ShowRename = value; + _selector.SetRenameSearchPath(value); + _config.Save(); + } + + ImGuiUtil.HoverTooltip(desc); + } + } + + ImGui.SameLine(); + const string tt = + "Select which of the two renaming input fields are visible when opening the right-click context menu of a mod in the mod selector."; + ImGuiComponents.HelpMarker(tt); + ImGui.SameLine(); + ImGui.TextUnformatted("Rename Fields in Mod Context Menu"); + ImGuiUtil.HoverTooltip(tt); + } + + /// Draw all settings pertaining to the mod selector. + private void DrawModSelectorSettings() + { + DrawFolderSortType(); + DrawRenameSettings(); + Checkbox("Open Folders by Default", "Whether to start with all folders collapsed or expanded in the mod selector.", + _config.OpenFoldersByDefault, v => + { + _config.OpenFoldersByDefault = v; + _selector.SetFilterDirty(); + }); + + Widget.DoubleModifierSelector("Mod Deletion Modifier", + "A modifier you need to hold while clicking the Delete Mod button for it to take effect.", UiHelpers.InputTextWidth.X, + _config.DeleteModModifier, + v => + { + _config.DeleteModModifier = v; + _config.Save(); + }); + Widget.DoubleModifierSelector("Incognito Modifier", + "A modifier you need to hold while clicking the Incognito or Temporary Settings Mode button for it to take effect.", + UiHelpers.InputTextWidth.X, + _config.IncognitoModifier, + v => + { + _config.IncognitoModifier = v; + _config.Save(); + }); + } + + /// Draw all settings pertaining to import and export of mods. + private void DrawModHandlingSettings() + { + Checkbox("Use Temporary Settings Per Default", + "When you make any changes to your collection, apply them as temporary changes first and require a click to 'turn permanent' if you want to keep them.", + _config.DefaultTemporaryMode, v => _config.DefaultTemporaryMode = v); + Checkbox("Replace Non-Standard Symbols On Import", + "Replace all non-ASCII symbols in mod and option names with underscores when importing mods.", _config.ReplaceNonAsciiOnImport, + v => _config.ReplaceNonAsciiOnImport = v); + Checkbox("Always Open Import at Default Directory", + "Open the import window at the location specified here every time, forgetting your previous path.", + _config.AlwaysOpenDefaultImport, v => _config.AlwaysOpenDefaultImport = v); + Checkbox("Handle PCP Files", + "When encountering specific mods, usually but not necessarily denoted by a .pcp file ending, Penumbra will automatically try to create an associated collection and assign it to a specific character for this mod package. This can turn this behaviour off if unwanted.", + !_config.PcpSettings.DisableHandling, v => _config.PcpSettings.DisableHandling = !v); + + var active = _config.DeleteModModifier.IsActive(); + ImGui.SameLine(); + if (ImUtf8.ButtonEx("Delete all PCP Mods"u8, "Deletes all mods tagged with 'PCP' from the mod list."u8, disabled: !active)) + _pcpService.CleanPcpMods(); + if (!active) + ImUtf8.HoverTooltip(ImGuiHoveredFlags.AllowWhenDisabled, $"Hold {_config.DeleteModModifier} while clicking."); + + ImGui.SameLine(); + if (ImUtf8.ButtonEx("Delete all PCP Collections"u8, "Deletes all collections whose name starts with 'PCP/' from the collection list."u8, + disabled: !active)) + _pcpService.CleanPcpCollections(); + if (!active) + ImUtf8.HoverTooltip(ImGuiHoveredFlags.AllowWhenDisabled, $"Hold {_config.DeleteModModifier} while clicking."); + + Checkbox("Allow Other Plugins Access to PCP Handling", + "When creating or importing PCP files, other plugins can add and interpret their own data to the character.json file.", + _config.PcpSettings.AllowIpc, v => _config.PcpSettings.AllowIpc = v); + + Checkbox("Create PCP Collections", + "When importing PCP files, create the associated collection.", + _config.PcpSettings.CreateCollection, v => _config.PcpSettings.CreateCollection = v); + + Checkbox("Assign PCP Collections", + "When importing PCP files and creating the associated collection, assign it to the associated character.", + _config.PcpSettings.AssignCollection, v => _config.PcpSettings.AssignCollection = v); + DrawDefaultModImportPath(); + DrawDefaultModAuthor(); + DrawDefaultModImportFolder(); + DrawPcpFolder(); + DrawDefaultModExportPath(); + Checkbox("Enable Directory Watcher", + "Enables a File Watcher that automatically listens for Mod files that enter a specified directory, causing Penumbra to open a popup to import these mods.", + _config.EnableDirectoryWatch, _fileWatcher.Toggle); + Checkbox("Enable Fully Automatic Import", + "Uses the File Watcher in order to skip the query popup and automatically import any new mods.", + _config.EnableAutomaticModImport, v => _config.EnableAutomaticModImport = v); + DrawFileWatcherPath(); + } + + + /// Draw input for the default import path for a mod. + private void DrawDefaultModImportPath() + { + var tmp = _config.DefaultModImportPath; + var spacing = new Vector2(UiHelpers.ScaleX3); + using var style = ImRaii.PushStyle(ImGuiStyleVar.ItemSpacing, spacing); + + ImGui.SetNextItemWidth(UiHelpers.InputTextMinusButton3); + if (ImGui.InputText("##defaultModImport", ref tmp, 256)) + _config.DefaultModImportPath = tmp; + + if (ImGui.IsItemDeactivatedAfterEdit()) + _config.Save(); + + ImGui.SameLine(); + if (ImGuiUtil.DrawDisabledButton($"{FontAwesomeIcon.Folder.ToIconString()}##import", UiHelpers.IconButtonSize, + "Select a directory via dialog.", false, true)) + { + var startDir = _config.DefaultModImportPath.Length > 0 && Directory.Exists(_config.DefaultModImportPath) + ? _config.DefaultModImportPath + : Directory.Exists(_config.ModDirectory) + ? _config.ModDirectory + : null; + + _fileDialog.OpenFolderPicker("Choose Default Import Directory", (b, s) => + { + if (!b) + return; + + _config.DefaultModImportPath = s; + _config.Save(); + }, startDir, false); + } + + style.Pop(); + ImGuiUtil.LabeledHelpMarker("Default Mod Import Directory", + "Set the directory that gets opened when using the file picker to import mods for the first time."); + } + + private string _tempExportDirectory = string.Empty; + + /// Draw input for the default export/backup path for mods. + private void DrawDefaultModExportPath() + { + var tmp = _config.ExportDirectory; + var spacing = new Vector2(UiHelpers.ScaleX3); + using var style = ImRaii.PushStyle(ImGuiStyleVar.ItemSpacing, spacing); + ImGui.SetNextItemWidth(UiHelpers.InputTextMinusButton3); + if (ImGui.InputText("##defaultModExport", ref tmp, 256)) + _tempExportDirectory = tmp; + + if (ImGui.IsItemDeactivatedAfterEdit()) + _modExportManager.UpdateExportDirectory(_tempExportDirectory); + + ImGui.SameLine(); + if (ImGuiUtil.DrawDisabledButton($"{FontAwesomeIcon.Folder.ToIconString()}##export", UiHelpers.IconButtonSize, + "Select a directory via dialog.", false, true)) + { + var startDir = _config.ExportDirectory.Length > 0 && Directory.Exists(_config.ExportDirectory) + ? _config.ExportDirectory + : Directory.Exists(_config.ModDirectory) + ? _config.ModDirectory + : null; + _fileDialog.OpenFolderPicker("Choose Default Export Directory", (b, s) => + { + if (b) + _modExportManager.UpdateExportDirectory(s); + }, startDir, false); + } + + style.Pop(); + ImGuiUtil.LabeledHelpMarker("Default Mod Export Directory", + "Set the directory mods get saved to when using the export function or loaded from when reimporting backups.\n" + + "Keep this empty to use the root directory."); + } + + private string? _tempWatchDirectory; + + /// Draw input for the Automatic Mod import path. + private void DrawFileWatcherPath() + { + var tmp = _tempWatchDirectory ?? _config.WatchDirectory; + var spacing = new Vector2(UiHelpers.ScaleX3); + using var style = ImRaii.PushStyle(ImGuiStyleVar.ItemSpacing, spacing); + ImGui.SetNextItemWidth(UiHelpers.InputTextMinusButton3); + if (ImGui.InputText("##fileWatchPath", ref tmp, 256)) + _tempWatchDirectory = tmp; + + if (ImGui.IsItemDeactivated() && _tempWatchDirectory is not null) + { + if (ImGui.IsItemDeactivatedAfterEdit()) + _fileWatcher.UpdateDirectory(_tempWatchDirectory); + _tempWatchDirectory = null; + } + + ImGui.SameLine(); + if (ImGuiUtil.DrawDisabledButton($"{FontAwesomeIcon.Folder.ToIconString()}##fileWatch", UiHelpers.IconButtonSize, + "Select a directory via dialog.", false, true)) + { + var startDir = _config.WatchDirectory.Length > 0 && Directory.Exists(_config.WatchDirectory) + ? _config.WatchDirectory + : Directory.Exists(_config.ModDirectory) + ? _config.ModDirectory + : null; + _fileDialog.OpenFolderPicker("Choose Automatic Import Directory", (b, s) => + { + if (b) + _fileWatcher.UpdateDirectory(s); + }, startDir, false); + } + + style.Pop(); + ImGuiUtil.LabeledHelpMarker("Automatic Import Director", + "Choose the Directory the File Watcher listens to."); + } + + /// Draw input for the default name to input as author into newly generated mods. + private void DrawDefaultModAuthor() + { + var tmp = _config.DefaultModAuthor; + ImGui.SetNextItemWidth(UiHelpers.InputTextWidth.X); + if (ImGui.InputText("##defaultAuthor", ref tmp, 64)) + _config.DefaultModAuthor = tmp; + + if (ImGui.IsItemDeactivatedAfterEdit()) + _config.Save(); + + ImGuiUtil.LabeledHelpMarker("Default Mod Author", "Set the default author stored for newly created mods."); + } + + /// Draw input for the default folder to sort put newly imported mods into. + private void DrawDefaultModImportFolder() + { + var tmp = _config.DefaultImportFolder; + ImGui.SetNextItemWidth(UiHelpers.InputTextWidth.X); + if (ImGui.InputText("##defaultImportFolder", ref tmp, 64)) + _config.DefaultImportFolder = tmp; + + if (ImGui.IsItemDeactivatedAfterEdit()) + _config.Save(); + + ImGuiUtil.LabeledHelpMarker("Default Mod Import Organizational Folder", + "Set the default Penumbra mod folder to place newly imported mods into.\nLeave blank to import into Root."); + } + + /// Draw input for the default folder to sort put newly imported mods into. + private void DrawPcpFolder() + { + var tmp = _config.PcpSettings.FolderName; + ImGui.SetNextItemWidth(UiHelpers.InputTextWidth.X); + if (ImUtf8.InputText("##pcpFolder"u8, ref tmp)) + _config.PcpSettings.FolderName = tmp; + + if (ImGui.IsItemDeactivatedAfterEdit()) + _config.Save(); + + ImGuiUtil.LabeledHelpMarker("Default PCP Organizational Folder", + "The folder any penumbra character packs are moved to on import.\nLeave blank to import into Root."); + } + + + /// Draw all settings pertaining to advanced editing of mods. + private void DrawModEditorSettings() + { + Checkbox("Advanced Editing: Edit Raw Tile UV Transforms", + "Edit the raw matrix components of tile UV transforms, instead of having them decomposed into scale, rotation and shear.", + _config.EditRawTileTransforms, v => _config.EditRawTileTransforms = v); + } + + #endregion + + /// Draw the entire Color subsection. + private void DrawColorSettings() + { + if (!ImGui.CollapsingHeader("Colors")) + return; + + foreach (var color in Enum.GetValues()) + { + var (defaultColor, name, description) = color.Data(); + var currentColor = _config.Colors.GetValueOrDefault(color, defaultColor); + if (Widget.ColorPicker(name, description, currentColor, c => _config.Colors[color] = c, defaultColor)) + _config.Save(); + } + + ImGui.NewLine(); + } + + #region Advanced Settings + + /// Draw all advanced settings. + private void DrawAdvancedSettings() + { + var header = ImGui.CollapsingHeader("Advanced"); + + if (!header) + return; + + DrawCrashHandler(); + DrawMinimumDimensionConfig(); + DrawHdrRenderTargets(); + Checkbox("Auto Deduplicate on Import", + "Automatically deduplicate mod files on import. This will make mod file sizes smaller, but deletes (binary identical) files.", + _config.AutoDeduplicateOnImport, v => _config.AutoDeduplicateOnImport = v); + Checkbox("Auto Reduplicate UI Files on PMP Import", + "Automatically reduplicate and normalize UI-specific files on import from PMP files. This is STRONGLY recommended because deduplicated UI files crash the game.", + _config.AutoReduplicateUiOnImport, v => _config.AutoReduplicateUiOnImport = v); + DrawCompressionBox(); + Checkbox("Keep Default Metadata Changes on Import", + "Normally, metadata changes that equal their default values, which are sometimes exported by TexTools, are discarded. " + + "Toggle this to keep them, for example if an option in a mod is supposed to disable a metadata change from a prior option.", + _config.KeepDefaultMetaChanges, v => _config.KeepDefaultMetaChanges = v); + Checkbox("Enable Custom Shape and Attribute Support", + "Penumbra will allow for custom shape keys and attributes for modded models to be considered and combined.", + _config.EnableCustomShapes, _attributeHook.SetState); + DrawWaitForPluginsReflection(); + DrawEnableHttpApiBox(); + DrawEnableDebugModeBox(); + ImGui.Separator(); + DrawReloadResourceButton(); + DrawReloadFontsButton(); + ImGui.Separator(); + DrawCleanupButtons(); + ImGui.NewLine(); + } + + private void DrawCrashHandler() + { + Checkbox("Enable Penumbra Crash Logging (Experimental)", + "Enables Penumbra to launch a secondary process that records some game activity which may or may not help diagnosing Penumbra-related game crashes.", + _config.UseCrashHandler ?? false, + v => + { + if (v) + _crashService.Enable(); + else + _crashService.Disable(); + }); + } + + private void DrawCompressionBox() + { + if (!_compactor.CanCompact) + return; + + Checkbox("Use Filesystem Compression", + "Use Windows functionality to transparently reduce storage size of mod files on your computer. This might cost performance, but seems to generally be beneficial to performance by shifting more responsibility to the underused CPU and away from the overused hard drives.", + _config.UseFileSystemCompression, + v => + { + _config.UseFileSystemCompression = v; + _compactor.Enabled = v; + }); + ImGui.SameLine(); + if (ImGuiUtil.DrawDisabledButton("Compress Existing Files", Vector2.Zero, + "Try to compress all files in your root directory. This will take a while.", + _compactor.MassCompactRunning || !_modManager.Valid)) + _compactor.StartMassCompact(_modManager.BasePath.EnumerateFiles("*.*", SearchOption.AllDirectories), CompressionAlgorithm.Xpress8K, + true); + + ImGui.SameLine(); + if (ImGuiUtil.DrawDisabledButton("Decompress Existing Files", Vector2.Zero, + "Try to decompress all files in your root directory. This will take a while.", + _compactor.MassCompactRunning || !_modManager.Valid)) + _compactor.StartMassCompact(_modManager.BasePath.EnumerateFiles("*.*", SearchOption.AllDirectories), CompressionAlgorithm.None, + true); + + if (_compactor.MassCompactRunning) + { + ImGui.ProgressBar((float)_compactor.CurrentIndex / _compactor.TotalFiles, + new Vector2(ImGui.GetContentRegionAvail().X - ImGui.GetStyle().ItemSpacing.X - UiHelpers.IconButtonSize.X, + ImGui.GetFrameHeight()), + _compactor.CurrentFile?.FullName[(_modManager.BasePath.FullName.Length + 1)..] ?? "Gathering Files..."); + ImGui.SameLine(); + if (ImGuiUtil.DrawDisabledButton(FontAwesomeIcon.Ban.ToIconString(), UiHelpers.IconButtonSize, "Cancel the mass action.", + !_compactor.MassCompactRunning, true)) + _compactor.CancelMassCompact(); + } + else + { + ImGui.Dummy(UiHelpers.IconButtonSize); + } + } + + /// Draw two integral inputs for minimum dimensions of this window. + private void DrawMinimumDimensionConfig() + { + var x = _minimumX == int.MaxValue ? (int)_config.MinimumSize.X : _minimumX; + var y = _minimumY == int.MaxValue ? (int)_config.MinimumSize.Y : _minimumY; + + var warning = x < Configuration.Constants.MinimumSizeX + ? y < Configuration.Constants.MinimumSizeY + ? "Size is smaller than default: This may look undesirable." + : "Width is smaller than default: This may look undesirable." + : y < Configuration.Constants.MinimumSizeY + ? "Height is smaller than default: This may look undesirable." + : string.Empty; + var buttonWidth = UiHelpers.InputTextWidth.X / 2.5f; + ImGui.SetNextItemWidth(buttonWidth); + if (ImGui.DragInt("##xMinSize", ref x, 0.1f, 500, 1500)) + _minimumX = x; + var edited = ImGui.IsItemDeactivatedAfterEdit(); + + ImGui.SameLine(); + ImGui.SetNextItemWidth(buttonWidth); + if (ImGui.DragInt("##yMinSize", ref y, 0.1f, 300, 1500)) + _minimumY = y; + edited |= ImGui.IsItemDeactivatedAfterEdit(); + + ImGui.SameLine(); + if (ImGuiUtil.DrawDisabledButton("Reset##resetMinSize", new Vector2(buttonWidth / 2 - ImGui.GetStyle().ItemSpacing.X * 2, 0), + $"Reset minimum dimensions to ({Configuration.Constants.MinimumSizeX}, {Configuration.Constants.MinimumSizeY}).", + x == Configuration.Constants.MinimumSizeX && y == Configuration.Constants.MinimumSizeY)) + { + x = Configuration.Constants.MinimumSizeX; + y = Configuration.Constants.MinimumSizeY; + edited = true; + } + + ImGuiUtil.LabeledHelpMarker("Minimum Window Dimensions", + "Set the minimum dimensions for resizing this window. Reducing these dimensions may cause the window to look bad or more confusing and is not recommended."); + + if (warning.Length > 0) + ImGuiUtil.DrawTextButton(warning, UiHelpers.InputTextWidth, Colors.PressEnterWarningBg); + else + ImGui.NewLine(); + + if (!edited) + return; + + _config.MinimumSize = new Vector2(x, y); + _minimumX = int.MaxValue; + _minimumY = int.MaxValue; + _config.Save(); + } + + private void DrawHdrRenderTargets() + { + ImGui.SetNextItemWidth(ImUtf8.CalcTextSize("M"u8).X * 5.0f + ImGui.GetFrameHeight()); + using (var combo = ImUtf8.Combo("##hdrRenderTarget"u8, _config.HdrRenderTargets ? "HDR"u8 : "SDR"u8)) + { + if (combo) + { + if (ImUtf8.Selectable("HDR"u8, _config.HdrRenderTargets) && !_config.HdrRenderTargets) + { + _config.HdrRenderTargets = true; + _config.Save(); + } + + if (ImUtf8.Selectable("SDR"u8, !_config.HdrRenderTargets) && _config.HdrRenderTargets) + { + _config.HdrRenderTargets = false; + _config.Save(); + } + } + } + + ImGui.SameLine(); + ImUtf8.LabeledHelpMarker("Diffuse Dynamic Range"u8, + "Set the dynamic range that can be used for diffuse colors in materials without causing visual artifacts.\n"u8 + + "Changing this setting requires a game restart. It also only works if Wait for Plugins on Startup is enabled."u8); + } + + /// Draw a checkbox for the HTTP API that creates and destroys the web server when toggled. + private void DrawEnableHttpApiBox() + { + var http = _config.EnableHttpApi; + if (ImGui.Checkbox("##http", ref http)) + { + if (http) + _httpApi.CreateWebServer(); + else + _httpApi.ShutdownWebServer(); + + _config.EnableHttpApi = http; + _config.Save(); + } + + ImGui.SameLine(); + ImGuiUtil.LabeledHelpMarker("Enable HTTP API", + "Enables other applications, e.g. Anamnesis, to use some Penumbra functions, like requesting redraws."); + } + + /// Draw a checkbox to toggle Debug mode. + private void DrawEnableDebugModeBox() + { + var tmp = _config.DebugMode; + if (ImGui.Checkbox("##debugMode", ref tmp) && tmp != _config.DebugMode) + { + _config.DebugMode = tmp; + _config.Save(); + } + + ImGui.SameLine(); + ImGuiUtil.LabeledHelpMarker("Enable Debug Mode", + "[DEBUG] Enable the Debug Tab and Resource Manager Tab as well as some additional data collection. Also open the config window on plugin load."); + } + + /// Draw a button that reloads resident resources. + private void DrawReloadResourceButton() + { + if (ImGuiUtil.DrawDisabledButton("Reload Resident Resources", Vector2.Zero, + "Reload some specific files that the game keeps in memory at all times.\nYou usually should not need to do this.", + !_characterUtility.Ready)) + _residentResources.Reload(); + } + + /// Draw a button that reloads fonts. + private void DrawReloadFontsButton() + { + if (ImGuiUtil.DrawDisabledButton("Reload Fonts", Vector2.Zero, "Force the game to reload its font files.", !_fontReloader.Valid)) + _fontReloader.Reload(); + } + + private void DrawCleanupButtons() + { + var enabled = _config.DeleteModModifier.IsActive(); + if (_cleanupService.Progress is not 0.0 and not 1.0) + { + ImUtf8.ProgressBar((float)_cleanupService.Progress, new Vector2(200 * ImUtf8.GlobalScale, ImGui.GetFrameHeight()), + $"{_cleanupService.Progress * 100}%"); + ImGui.SameLine(); + if (ImUtf8.Button("Cancel##FileCleanup"u8)) + _cleanupService.Cancel(); + } + else + { + ImGui.NewLine(); + } + + if (ImUtf8.ButtonEx("Clear Unused Local Mod Data Files"u8, + "Delete all local mod data files that do not correspond to currently installed mods."u8, default, + !enabled || _cleanupService.IsRunning)) + _cleanupService.CleanUnusedLocalData(); + if (!enabled) + ImUtf8.HoverTooltip(ImGuiHoveredFlags.AllowWhenDisabled, $"Hold {_config.DeleteModModifier} while clicking to delete files."); + + if (ImUtf8.ButtonEx("Clear Backup Files"u8, + "Delete all backups of .json configuration files in your configuration folder and all backups of mod group files in your mod directory."u8, + default, !enabled || _cleanupService.IsRunning)) + _cleanupService.CleanBackupFiles(); + if (!enabled) + ImUtf8.HoverTooltip(ImGuiHoveredFlags.AllowWhenDisabled, $"Hold {_config.DeleteModModifier} while clicking to delete files."); + + if (ImUtf8.ButtonEx("Clear All Unused Settings"u8, + "Remove all mod settings in all of your collections that do not correspond to currently installed mods."u8, default, + !enabled || _cleanupService.IsRunning)) + _cleanupService.CleanupAllUnusedSettings(); + if (!enabled) + ImUtf8.HoverTooltip(ImGuiHoveredFlags.AllowWhenDisabled, $"Hold {_config.DeleteModModifier} while clicking to remove settings."); + } + + /// Draw a checkbox that toggles the dalamud setting to wait for plugins on open. + private void DrawWaitForPluginsReflection() + { + if (!_dalamudConfig.GetDalamudConfig(DalamudConfigService.WaitingForPluginsOption, out bool value)) + { + using var disabled = ImRaii.Disabled(); + Checkbox("Wait for Plugins on Startup (Disabled, can not access Dalamud Configuration)", string.Empty, false, _ => { }); + } + else + { + Checkbox("Wait for Plugins on Startup", + "Some mods need to change files that are loaded once when the game starts and never afterwards.\n" + + "This can cause issues with Penumbra loading after the files are already loaded.\n" + + "This setting causes the game to wait until certain plugins have finished loading, making those mods work (in the base collection).\n\n" + + "This changes a setting in the Dalamud Configuration found at /xlsettings -> General.", + value, + v => _dalamudConfig.SetDalamudConfig(DalamudConfigService.WaitingForPluginsOption, v, "doWaitForPluginsOnStartup")); + } + } + + #endregion + + /// Draw the support button group on the right-hand side of the window. + private void DrawSupportButtons() + { + var width = ImGui.CalcTextSize(UiHelpers.SupportInfoButtonText).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, ImGui.GetFrameHeightWithSpacing())); + UiHelpers.DrawSupportButton(_penumbra); + + ImGui.SetCursorPos(new Vector2(xPos, 0)); + CustomGui.DrawDiscordButton(Penumbra.Messager, width); + + ImGui.SetCursorPos(new Vector2(xPos, 2 * ImGui.GetFrameHeightWithSpacing())); + CustomGui.DrawGuideButton(Penumbra.Messager, width); + + ImGui.SetCursorPos(new Vector2(xPos, 3 * ImGui.GetFrameHeightWithSpacing())); + if (ImGui.Button("Restart Tutorial", new Vector2(width, 0))) + { + _config.Ephemeral.TutorialStep = 0; + _config.Ephemeral.Save(); + } + + ImGui.SetCursorPos(new Vector2(xPos, 4 * ImGui.GetFrameHeightWithSpacing())); + if (ImGui.Button("Show Changelogs", new Vector2(width, 0))) + _penumbra.ForceChangelogOpen(); + + ImGui.SetCursorPos(new Vector2(xPos, 5 * ImGui.GetFrameHeightWithSpacing())); + CustomGui.DrawKofiPatreonButton(Penumbra.Messager, new Vector2(width, 0)); + } + + private void DrawPredefinedTagsSection() + { + if (!ImGui.CollapsingHeader("Tags")) + return; + + var tagIdx = _sharedTags.Draw("Predefined Tags: ", + "Predefined tags that can be added or removed from mods with a single click.", _predefinedTagManager, + out var editedTag); + + if (tagIdx >= 0) + _predefinedTagManager.ChangeSharedTag(tagIdx, editedTag); + } +} diff --git a/Penumbra/UI/TutorialService.cs b/Penumbra/UI/TutorialService.cs new file mode 100644 index 00000000..69f2b616 --- /dev/null +++ b/Penumbra/UI/TutorialService.cs @@ -0,0 +1,172 @@ +using OtterGui.Services; +using OtterGui.Widgets; +using Penumbra.Collections; +using Penumbra.Collections.Manager; +using Penumbra.UI.Classes; + +namespace Penumbra.UI; + +/// List of currently available tutorials. +public enum BasicTutorialSteps +{ + GeneralTooltips, + ModDirectory, + EnableMods, + Deprecated1, + GeneralSettings, + Collections, + EditingCollections, + CurrentCollection, + SimpleAssignments, + IndividualAssignments, + GroupAssignments, + CollectionDetails, + Incognito, + Deprecated2, + Mods, + ModImport, + AdvancedHelp, + ModFilters, + CollectionSelectors, + Redrawing, + EnablingMods, + Priority, + ModOptions, + Fin, + Deprecated3, + Faq1, + Faq2, + Favorites, + Tags, +} + +/// Service for the in-game tutorial. +public class TutorialService : IUiService +{ + public const string SelectedCollection = "Selected Collection"; + public const string DefaultCollection = "Base Collection"; + public const string InterfaceCollection = "Interface Collection"; + public const string AssignedCollections = "Assigned Collections"; + + public const string SupportedRedrawModifiers = " - nothing, to redraw all characters\n" + + " - 'self' or '': your own character\n" + + " - 'target' or '': your target\n" + + " - 'focus' or ': your focus target\n" + + " - 'mouseover' or '': the actor you are currently hovering over\n" + + " - 'furniture': most indoor furniture, does not currently work outdoors\n" + + " - any specific actor name to redraw all actors of that exactly matching name."; + + private readonly EphemeralConfig _config; + private readonly Tutorial _tutorial; + + public TutorialService(EphemeralConfig config) + { + _config = config; + _tutorial = new Tutorial() + { + BorderColor = Colors.TutorialBorder, + HighlightColor = Colors.TutorialMarker, + PopupLabel = "Settings Tutorial", + } + .Register("General Tooltips", "This symbol gives you further information about whatever setting it appears next to.\n\n" + + "Hover over them when you are unsure what something does or how to do something.") + .Register("Initial Setup, Step 1: Mod Directory", + "The first step is to set up your mod directory, which is where your mods are extracted to.\n\n" + + "The mod directory should be a short path - like 'C:\\FFXIVMods' - on your fastest available drive. Faster drives improve performance.\n\n" + + "The folder should be an empty folder no other applications write to.") + .Register("Initial Setup, Step 2: Enable Mods", "Do not forget to enable your mods in case they are not.") + .Deprecated() + .Register("General Settings", "Look through all of these settings before starting, they might help you a lot!\n\n" + + "If you do not know what some of these do yet, return to this later!") + .Register("Initial Setup, Step 3: Collections", "Collections are lists of settings for your installed mods.\n\n" + + "This is our next stop!\n\n" + + "Go here after setting up your root folder to continue the tutorial!") + .Register("Initial Setup, Step 4: Managing Collections", + "On the left, we have the collection selector. Here, we can create new collections - either empty ones or by duplicating existing ones - and delete any collections not needed anymore.\n" + + $"There will always be one collection called {ModCollectionIdentity.DefaultCollectionName} that can not be deleted.") + .Register($"Initial Setup, Step 5: {SelectedCollection}", + $"The {SelectedCollection} is the one we highlighted in the selector. It is the collection we are currently looking at and editing.\nAny changes we make in our mod settings later in the next tab will edit this collection.\n" + + $"We should already have the collection named {ModCollectionIdentity.DefaultCollectionName} selected, and for our simple setup, we do not need to do anything here.\n\n") + .Register("Initial Setup, Step 6: Simple Assignments", + "Aside from being a collection of settings, we can also assign collections to different functions. This is used to make different mods apply to different characters.\n" + + "The Simple Assignments panel shows you the possible assignments that are enough for most people along with descriptions.\n" + + $"If you are just starting, you can see that the {ModCollectionIdentity.DefaultCollectionName} is currently assigned to {CollectionType.Default.ToName()} and {CollectionType.Interface.ToName()}.\n" + + "You can also assign 'Use No Mods' instead of a collection by clicking on the function buttons.") + .Register("Individual Assignments", + "In the Individual Assignments panel, you can manually create assignments for very specific characters or monsters, not just yourself or ones you can currently target.") + .Register("Group Assignments", + "In the Group Assignments panel, you can create Assignments for more specific groups of characters based on race or age.") + .Register("Collection Details", + "In the Collection Details panel, you can see a detailed overview over the usage of the currently selected collection, as well as remove outdated mod settings and setup inheritance.\n" + + "Inheritance can be used to make one collection take the settings of another as long as it does not setup the mod in question itself.") + .Register("Incognito Mode", + "This button can toggle Incognito Mode, which shortens all collection names to two letters and a number,\n" + + "and all displayed individual character names to their initials and world, in case you want to share screenshots.\n" + + "It is strongly recommended to not show your characters name in public screenshots when using Penumbra.") + .Deprecated() + .Register("Initial Setup, Step 7: Mods", "Our last stop is the Mods tab, where you can import and setup your mods.\n\n" + + $"Please go there after verifying that your {SelectedCollection} and {DefaultCollection} are setup to your liking.") + .Register("Initial Setup, Step 8: Mod Import", + "Click this button to open a file selector with which to select TTMP mod files. You can select multiple at once.\n\n" + + "It is not recommended to import huge mod packs of all your TexTools mods, but rather import the mods themselves, otherwise you lose out on a lot of Penumbra features!\n\n" + + "A feature to import raw texture mods for Tattoos etc. is available under Advanced Editing, but is currently a work in progress.") + .Register("Advanced Help", "Click this button to get detailed information on what you can do in the mod selector.\n\n" + + "Import and select a mod now to continue.") + .Register("Mod Filters", "You can filter the available mods by name, author, changed items or various attributes here.") + .Register("Collection Selectors", $"This row provides shortcuts to set your {SelectedCollection}.\n\n" + + $"The first button sets it to your {DefaultCollection} (if any).\n\n" + + "The second button sets it to the collection the settings of the currently selected mod are inherited from (if any).\n\n" + + "The third is a regular collection selector to let you choose among all your collections.") + .Register("Redrawing", + "Whenever you change your mod configuration, changes do not immediately take effect. You will need to force the game to reload the relevant files (or if this is not possible, restart the game).\n\n" + + "For this, Penumbra has these buttons as well as the '/penumbra redraw' command, which redraws all actors at once. You can also use several modifiers described in the help marker instead.\n\n" + + "Feel free to use these slash commands (e.g. '/penumbra redraw self') as a macro, too.") + .Register("Initial Setup, Step 9: Enabling Mods", + "Enable a mod here. Disabled mods will not apply to anything in the current collection.\n\n" + + "Mods can be enabled or disabled in a collection, or they can be unconfigured, in which case they will use Inheritance.") + .Register("Initial Setup, Step 10: Priority", + "If two enabled mods in one collection change the same files, there is a conflict.\n\n" + + "Conflicts can be solved by setting a priority. The mod with the higher number will be used for all the conflicting files.\n\n" + + "Conflicts are not a problem, as long as they are correctly resolved with priorities. Negative priorities are possible.") + .Register("Mod Options", "Many mods have options themselves. You can also choose those here.\n\n" + + "Pulldown-options are mutually exclusive, whereas checkmark options can all be enabled separately.") + .Register("Initial Setup - Fin", "Now you should have all information to get Penumbra running and working!\n\n" + + "If there are further questions or you need more help for the advanced features, take a look at the guide linked in the settings page.") + .Deprecated() + .Register("FAQ 1", + "It is advised to not use TexTools and Penumbra at the same time. Penumbra may refuse to work if TexTools broke your game indices.") + .Register("FAQ 2", "Penumbra can change the skin material a mod uses. This is under advanced editing.") + .Register("Favorites", + "You can now toggle mods as favorites using this button. You can filter for favorited mods in the mod selector. Favorites are stored locally, not within the mod, but independently of collections.") + .Register("Tags", + "Mods can now have two types of tags:\n\n- Local Tags are those that you can set for yourself. They are stored locally and are not saved in any way in the mod directory itself.\n- Mod Tags are stored in the mod metadata, are set by the mod creator and are exported together with the mod, they can only be edited in the Edit Mod tab.\n\nIf a mod has a tag in its Mod Tags, this overwrites any identical Local Tags.\n\nYou can filter for tags in the mod selector via 't:text'.") + .EnsureSize(Enum.GetValues().Length); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void OpenTutorial(BasicTutorialSteps step) + => _tutorial.Open((int)step, _config.TutorialStep, v => + { + _config.TutorialStep = v; + _config.Save(); + }); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void SkipTutorial(BasicTutorialSteps step) + => _tutorial.Skip((int)step, _config.TutorialStep, v => + { + _config.TutorialStep = v; + _config.Save(); + }); + + /// Update the current tutorial step if tutorials have changed since last update. + public void UpdateTutorialStep() + { + var tutorial = _tutorial.CurrentEnabledId(_config.TutorialStep); + if (tutorial != _config.TutorialStep) + { + _config.TutorialStep = tutorial; + _config.Save(); + } + } +} diff --git a/Penumbra/UI/UiHelpers.cs b/Penumbra/UI/UiHelpers.cs new file mode 100644 index 00000000..9fe90ee8 --- /dev/null +++ b/Penumbra/UI/UiHelpers.cs @@ -0,0 +1,131 @@ +using Dalamud.Interface.ImGuiNotification; +using Dalamud.Interface.Utility; +using Dalamud.Bindings.ImGui; +using OtterGui; +using OtterGui.Classes; +using OtterGui.Raii; +using Penumbra.Interop.Structs; +using Penumbra.String; + +namespace Penumbra.UI; + +public static class UiHelpers +{ + /// Draw text given by a ByteString. + public static unsafe void Text(ByteString s) + => ImGuiNative.TextUnformatted(s.Path, s.Path + s.Length); + + /// Draw text given by a byte pointer and length. + public static unsafe void Text(byte* s, int length) + => ImGuiNative.TextUnformatted(s, s + length); + + /// Draw text given by a byte span. + public static unsafe void Text(ReadOnlySpan s) + { + fixed (byte* pS = s) + { + Text(pS, s.Length); + } + } + + /// Draw the name of a resource file. + public static unsafe void Text(ResourceHandle* resource) + => Text(resource->CsHandle.FileName.AsSpan()); + + /// Draw a ByteString as a selectable. + public static unsafe bool Selectable(ByteString s, bool selected) + { + var tmp = (byte)(selected ? 1 : 0); + return ImGuiNative.Selectable(s.Path, tmp, ImGuiSelectableFlags.None, Vector2.Zero) != 0; + } + + /// + /// A selectable that copies its text to clipboard on selection and provides a on-hover tooltip about that, + /// using an ByteString. + /// + public static unsafe void CopyOnClickSelectable(ByteString text) + { + if (ImGuiNative.Selectable(text.Path, 0, ImGuiSelectableFlags.None, Vector2.Zero) != 0) + ImGuiNative.SetClipboardText(text.Path); + + if (ImGui.IsItemHovered()) + ImGui.SetTooltip("Click to copy to clipboard."); + } + + /// The longest support button text. + public const string SupportInfoButtonText = "Copy Support Info to Clipboard"; + + /// + /// Draw a button that copies the support info to clipboards. + /// + /// + public static void DrawSupportButton(Penumbra penumbra) + { + if (!ImGui.Button(SupportInfoButtonText)) + return; + + var text = penumbra.GatherSupportInformation(); + ImGui.SetClipboardText(text); + Penumbra.Messager.NotificationMessage($"Copied Support Info to Clipboard.", NotificationType.Success, false); + } + + /// Draw a button to open a specific directory in a file explorer. + /// Specific ID for the given type of directory. + /// The directory to open. + /// Whether the button is available. + public static void DrawOpenDirectoryButton(int id, DirectoryInfo directory, bool condition) + { + using var _ = ImRaii.PushId(id); + if (ImGuiUtil.DrawDisabledButton("Open Directory", Vector2.Zero, "Open this directory in your configured file explorer.", + !condition || !Directory.Exists(directory.FullName))) + Process.Start(new ProcessStartInfo(directory.FullName) + { + UseShellExecute = true, + }); + } + + /// Draw default vertical space. + public static void DefaultLineSpace() + => ImGui.Dummy(DefaultSpace); + + /// Vertical spacing between groups. + public static Vector2 DefaultSpace; + + /// Width of most input fields. + public static Vector2 InputTextWidth; + + /// Frame Height for square icon buttons. + public static Vector2 IconButtonSize; + + /// Input Text Width with space for an additional button with spacing of 3 between them. + public static float InputTextMinusButton3; + + /// Input Text Width with space for an additional button with spacing of default item spacing between them. + public static float InputTextMinusButton; + + /// Multiples of the current Global Scale + public static float Scale; + + public static float ScaleX2; + public static float ScaleX3; + public static float ScaleX4; + public static float ScaleX5; + + public static void SetupCommonSizes() + { + if (ImGuiHelpers.GlobalScale != Scale) + { + Scale = ImGuiHelpers.GlobalScale; + DefaultSpace = new Vector2(0, 10 * Scale); + InputTextWidth = new Vector2(350f * Scale, 0); + ScaleX2 = Scale * 2; + ScaleX3 = Scale * 3; + ScaleX4 = Scale * 4; + ScaleX5 = Scale * 5; + } + + IconButtonSize = new Vector2(ImGui.GetFrameHeight()); + InputTextMinusButton3 = InputTextWidth.X - IconButtonSize.X - ScaleX3; + InputTextMinusButton = InputTextWidth.X - IconButtonSize.X - ImGui.GetStyle().ItemSpacing.X; + } +} diff --git a/Penumbra/UI/WindowSystem.cs b/Penumbra/UI/WindowSystem.cs new file mode 100644 index 00000000..575a381f --- /dev/null +++ b/Penumbra/UI/WindowSystem.cs @@ -0,0 +1,60 @@ +using Dalamud.Interface; +using Dalamud.Interface.Windowing; +using Dalamud.Plugin; +using OtterGui.Services; +using Penumbra.Interop.Services; +using Penumbra.UI.AdvancedWindow; +using Penumbra.UI.Knowledge; +using Penumbra.UI.Tabs.Debug; + +namespace Penumbra.UI; + +public class PenumbraWindowSystem : IDisposable, IUiService +{ + private readonly IUiBuilder _uiBuilder; + private readonly WindowSystem _windowSystem; + private readonly FileDialogService _fileDialog; + private readonly TextureArraySlicer _textureArraySlicer; + public readonly ConfigWindow Window; + public readonly PenumbraChangelog Changelog; + public readonly KnowledgeWindow KnowledgeWindow; + + public PenumbraWindowSystem(IDalamudPluginInterface pi, Configuration config, PenumbraChangelog changelog, ConfigWindow window, + LaunchButton _, ModEditWindow editWindow, FileDialogService fileDialog, ImportPopup importPopup, DebugTab debugTab, + KnowledgeWindow knowledgeWindow, TextureArraySlicer textureArraySlicer) + { + _uiBuilder = pi.UiBuilder; + _fileDialog = fileDialog; + _textureArraySlicer = textureArraySlicer; + KnowledgeWindow = knowledgeWindow; + Changelog = changelog; + Window = window; + _windowSystem = new WindowSystem("Penumbra"); + _windowSystem.AddWindow(changelog.Changelog); + _windowSystem.AddWindow(window); + _windowSystem.AddWindow(editWindow); + _windowSystem.AddWindow(importPopup); + _windowSystem.AddWindow(debugTab); + _windowSystem.AddWindow(KnowledgeWindow); + _uiBuilder.OpenMainUi += Window.Toggle; + _uiBuilder.OpenConfigUi += Window.OpenSettings; + _uiBuilder.Draw += _windowSystem.Draw; + _uiBuilder.Draw += _fileDialog.Draw; + _uiBuilder.Draw += _textureArraySlicer.Tick; + _uiBuilder.DisableGposeUiHide = !config.HideUiInGPose; + _uiBuilder.DisableCutsceneUiHide = !config.HideUiInCutscenes; + _uiBuilder.DisableUserUiHide = !config.HideUiWhenUiHidden; + } + + public void ForceChangelogOpen() + => Changelog.Changelog.ForceOpen = true; + + public void Dispose() + { + _uiBuilder.OpenMainUi -= Window.Toggle; + _uiBuilder.OpenConfigUi -= Window.OpenSettings; + _uiBuilder.Draw -= _windowSystem.Draw; + _uiBuilder.Draw -= _fileDialog.Draw; + _uiBuilder.Draw -= _textureArraySlicer.Tick; + } +} diff --git a/Penumbra/Util/ArrayExtensions.cs b/Penumbra/Util/ArrayExtensions.cs deleted file mode 100644 index 8308890e..00000000 --- a/Penumbra/Util/ArrayExtensions.cs +++ /dev/null @@ -1,87 +0,0 @@ -using System; -using System.Collections.Generic; - -namespace Penumbra.Util -{ - public static class ArrayExtensions - { - public static T[] Slice< T >( this T[] source, int index, int length ) - { - var slice = new T[length]; - Array.Copy( source, index * length, slice, 0, length ); - return slice; - } - - public static void Swap< T >( this T[] array, int idx1, int idx2 ) - { - var tmp = array[ idx1 ]; - array[ idx1 ] = array[ idx2 ]; - array[ idx2 ] = tmp; - } - - public static void Swap< T >( this List< T > array, int idx1, int idx2 ) - { - var tmp = array[ idx1 ]; - array[ idx1 ] = array[ idx2 ]; - array[ idx2 ] = tmp; - } - - public static int IndexOf< T >( this T[] array, Predicate< T > match ) - { - for( var i = 0; i < array.Length; ++i ) - { - if( match( array[ i ] ) ) - { - return i; - } - } - - return -1; - } - - public static void Swap< T >( this T[] array, T lhs, T rhs ) - { - var idx1 = Array.IndexOf( array, lhs ); - if( idx1 < 0 ) - { - return; - } - - var idx2 = Array.IndexOf( array, rhs ); - if( idx2 < 0 ) - { - return; - } - - array.Swap( idx1, idx2 ); - } - - public static void Swap< T >( this List< T > array, T lhs, T rhs ) - { - var idx1 = array.IndexOf( lhs ); - if( idx1 < 0 ) - { - return; - } - - var idx2 = array.IndexOf( rhs ); - if( idx2 < 0 ) - { - return; - } - - array.Swap( idx1, idx2 ); - } - - public static int IndexOf< T >( this IList< T > array, Func< T, bool > predicate ) - { - for( var i = 0; i < array.Count; ++i ) - { - if( predicate.Invoke( array[ i ] ) ) - return i; - } - - return -1; - } - } -} \ No newline at end of file diff --git a/Penumbra/Util/BinaryReaderExtensions.cs b/Penumbra/Util/BinaryReaderExtensions.cs deleted file mode 100644 index 19be89ca..00000000 --- a/Penumbra/Util/BinaryReaderExtensions.cs +++ /dev/null @@ -1,137 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Runtime.CompilerServices; -using System.Runtime.InteropServices; -using System.Text; - -namespace Penumbra.Util -{ - public static class BinaryReaderExtensions - { - /// - /// Reads a structure from the current stream position. - /// - /// - /// The structure to read in to - /// The file data as a structure - public static T ReadStructure< T >( this BinaryReader br ) where T : struct - { - ReadOnlySpan< byte > data = br.ReadBytes( Unsafe.SizeOf< T >() ); - - return MemoryMarshal.Read< T >( data ); - } - - /// - /// Reads many structures from the current stream position. - /// - /// - /// The number of T to read from the stream - /// The structure to read in to - /// A list containing the structures read from the stream - public static List< T > ReadStructures< T >( this BinaryReader br, int count ) where T : struct - { - var size = Marshal.SizeOf< T >(); - var data = br.ReadBytes( size * count ); - - var list = new List< T >( count ); - - for( var i = 0; i < count; i++ ) - { - var offset = size * i; - var span = new ReadOnlySpan< byte >( data, offset, size ); - - list.Add( MemoryMarshal.Read< T >( span ) ); - } - - return list; - } - - public static T[] ReadStructuresAsArray< T >( this BinaryReader br, int count ) where T : struct - { - var size = Marshal.SizeOf< T >(); - var data = br.ReadBytes( size * count ); - - // im a pirate arr - var arr = new T[count]; - - for( var i = 0; i < count; i++ ) - { - var offset = size * i; - var span = new ReadOnlySpan< byte >( data, offset, size ); - - arr[ i ] = MemoryMarshal.Read< T >( span ); - } - - return arr; - } - - /// - /// Moves the BinaryReader position to offset, reads a string, then - /// sets the reader position back to where it was when it started - /// - /// - /// The offset to read a string starting from. - /// - public static string ReadStringOffsetData( this BinaryReader br, long offset ) - => Encoding.UTF8.GetString( ReadRawOffsetData( br, offset ) ); - - /// - /// Moves the BinaryReader position to offset, reads raw bytes until a null byte, then - /// sets the reader position back to where it was when it started - /// - /// - /// The offset to read data starting from. - /// - public static byte[] ReadRawOffsetData( this BinaryReader br, long offset ) - { - var originalPosition = br.BaseStream.Position; - br.BaseStream.Position = offset; - - var chars = new List< byte >(); - - byte current; - while( ( current = br.ReadByte() ) != 0 ) - { - chars.Add( current ); - } - - br.BaseStream.Position = originalPosition; - - return chars.ToArray(); - } - - /// - /// Seeks this BinaryReader's position to the given offset. Syntactic sugar. - /// - public static void Seek( this BinaryReader br, long offset ) - { - br.BaseStream.Position = offset; - } - - /// - /// Reads a byte and moves the stream position back to where it started before the operation - /// - /// The reader to use to read the byte - /// The byte that was read - public static byte PeekByte( this BinaryReader br ) - { - var data = br.ReadByte(); - br.BaseStream.Position--; - return data; - } - - /// - /// Reads bytes and moves the stream position back to where it started before the operation - /// - /// The reader to use to read the bytes - /// The number of bytes to read - /// The read bytes - public static byte[] PeekBytes( this BinaryReader br, int count ) - { - var data = br.ReadBytes( count ); - br.BaseStream.Position -= count; - return data; - } - } -} \ No newline at end of file diff --git a/Penumbra/Util/ChatUtil.cs b/Penumbra/Util/ChatUtil.cs deleted file mode 100644 index 9bd2fc52..00000000 --- a/Penumbra/Util/ChatUtil.cs +++ /dev/null @@ -1,37 +0,0 @@ -using System.Collections.Generic; -using Dalamud.Game.Text; -using Dalamud.Game.Text.SeStringHandling; -using Dalamud.Game.Text.SeStringHandling.Payloads; -using Dalamud.Plugin; -using Lumina.Excel.GeneratedSheets; - -namespace Penumbra.Util -{ - public static class ChatUtil - { - public static void LinkItem( Item item ) - { - var payloadList = new List< Payload > - { - new UIForegroundPayload( ( ushort )( 0x223 + item.Rarity * 2 ) ), - new UIGlowPayload( ( ushort )( 0x224 + item.Rarity * 2 ) ), - new ItemPayload( item.RowId, false ), - new UIForegroundPayload( 500 ), - new UIGlowPayload( 501 ), - new TextPayload( $"{( char )SeIconChar.LinkMarker}" ), - new UIForegroundPayload( 0 ), - new UIGlowPayload( 0 ), - new TextPayload( item.Name ), - new RawPayload( new byte[] { 0x02, 0x27, 0x07, 0xCF, 0x01, 0x01, 0x01, 0xFF, 0x01, 0x03 } ), - new RawPayload( new byte[] { 0x02, 0x13, 0x02, 0xEC, 0x03 } ), - }; - - var payload = new SeString( payloadList ); - - Dalamud.Chat.PrintChat( new XivChatEntry - { - Message = payload, - } ); - } - } -} \ No newline at end of file diff --git a/Penumbra/Util/Crc32.cs b/Penumbra/Util/Crc32.cs deleted file mode 100644 index 655d18e2..00000000 --- a/Penumbra/Util/Crc32.cs +++ /dev/null @@ -1,57 +0,0 @@ -using System.Linq; -using System.Runtime.CompilerServices; - -namespace Penumbra.Util -{ - /// - /// Performs the 32-bit reversed variant of the cyclic redundancy check algorithm - /// - public class Crc32 - { - private const uint Poly = 0xedb88320; - - private static readonly uint[] CrcArray = - Enumerable.Range( 0, 256 ).Select( i => - { - var k = ( uint )i; - for( var j = 0; j < 8; j++ ) - { - k = ( k & 1 ) != 0 ? ( k >> 1 ) ^ Poly : k >> 1; - } - - return k; - } ).ToArray(); - - public uint Checksum - => ~_crc32; - - private uint _crc32 = 0xFFFFFFFF; - - /// - /// Initializes Crc32's state - /// - public void Init() - { - _crc32 = 0xFFFFFFFF; - } - - /// - /// Updates Crc32's state with new data - /// - /// Data to calculate the new CRC from - [MethodImpl( MethodImplOptions.AggressiveInlining )] - public void Update( byte[] data ) - { - foreach( var b in data ) - { - Update( b ); - } - } - - [MethodImpl( MethodImplOptions.AggressiveInlining )] - public void Update( byte b ) - { - _crc32 = CrcArray[ ( _crc32 ^ b ) & 0xFF ] ^ ( ( _crc32 >> 8 ) & 0x00FFFFFF ); - } - } -} \ No newline at end of file diff --git a/Penumbra/Util/DialogExtensions.cs b/Penumbra/Util/DialogExtensions.cs deleted file mode 100644 index eb9c166c..00000000 --- a/Penumbra/Util/DialogExtensions.cs +++ /dev/null @@ -1,82 +0,0 @@ -using System; -using System.Diagnostics; -using System.Drawing; -using System.Threading; -using System.Threading.Tasks; -using System.Windows.Forms; - -namespace Penumbra.Util -{ - public static class DialogExtensions - { - public static Task< DialogResult > ShowDialogAsync( this CommonDialog form ) - { - using var process = Process.GetCurrentProcess(); - return form.ShowDialogAsync( new DialogHandle( process.MainWindowHandle ) ); - } - - public static Task< DialogResult > ShowDialogAsync( this CommonDialog form, IWin32Window owner ) - { - var taskSource = new TaskCompletionSource< DialogResult >(); - var th = new Thread( () => DialogThread( form, owner, taskSource ) ); - th.Start(); - return taskSource.Task; - } - - [STAThread] - private static void DialogThread( CommonDialog form, IWin32Window owner, - TaskCompletionSource< DialogResult > taskSource ) - { - Application.SetCompatibleTextRenderingDefault( false ); - Application.EnableVisualStyles(); - using var hiddenForm = new HiddenForm( form, owner, taskSource ); - Application.Run( hiddenForm ); - Application.ExitThread(); - } - - public class DialogHandle : IWin32Window - { - public IntPtr Handle { get; set; } - - public DialogHandle( IntPtr handle ) - => Handle = handle; - } - - public class HiddenForm : Form - { - private readonly CommonDialog _form; - private readonly IWin32Window _owner; - private readonly TaskCompletionSource< DialogResult > _taskSource; - - public HiddenForm( CommonDialog form, IWin32Window owner, TaskCompletionSource< DialogResult > taskSource ) - { - _form = form; - _owner = owner; - _taskSource = taskSource; - - Opacity = 0; - FormBorderStyle = FormBorderStyle.None; - ShowInTaskbar = false; - Size = new Size( 0, 0 ); - - Shown += HiddenForm_Shown; - } - - private void HiddenForm_Shown( object? sender, EventArgs _ ) - { - Hide(); - try - { - var result = _form.ShowDialog( _owner ); - _taskSource.SetResult( result ); - } - catch( Exception e ) - { - _taskSource.SetException( e ); - } - - Close(); - } - } - } -} \ No newline at end of file diff --git a/Penumbra/Util/DictionaryExtensions.cs b/Penumbra/Util/DictionaryExtensions.cs new file mode 100644 index 00000000..f7aa5598 --- /dev/null +++ b/Penumbra/Util/DictionaryExtensions.cs @@ -0,0 +1,97 @@ +namespace Penumbra.Util; + +public static class DictionaryExtensions +{ + /// Returns whether two dictionaries contain equal keys and values. + public static bool SetEquals(this IReadOnlyDictionary lhs, IReadOnlyDictionary rhs) + { + if (ReferenceEquals(lhs, rhs)) + return true; + + + if (lhs.Count != rhs.Count) + return false; + + foreach (var (key, value) in lhs) + { + if (!rhs.TryGetValue(key, out var rhsValue)) + return false; + + if (value == null) + { + if (rhsValue != null) + return false; + + continue; + } + + if (!value.Equals(rhsValue)) + return false; + } + + return true; + } + + /// Set one dictionary to the other, deleting previous entries and ensuring capacity beforehand. + public static void SetTo(this Dictionary lhs, IReadOnlyDictionary rhs) + where TKey : notnull + { + if (ReferenceEquals(lhs, rhs)) + return; + + lhs.Clear(); + lhs.EnsureCapacity(rhs.Count); + foreach (var (key, value) in rhs) + lhs.Add(key, value); + } + + /// Set all entries in the right-hand dictionary to the same values in the left-hand dictionary, ensuring capacity beforehand. + public static void UpdateTo(this Dictionary lhs, IReadOnlyDictionary rhs) + where TKey : notnull + { + if (ReferenceEquals(lhs, rhs)) + return; + + lhs.EnsureCapacity(rhs.Count); + foreach (var (key, value) in rhs) + lhs[key] = value; + } + + /// Set one set to the other, deleting previous entries and ensuring capacity beforehand. + public static void SetTo(this HashSet lhs, IReadOnlySet rhs) + { + if (ReferenceEquals(lhs, rhs)) + return; + + lhs.Clear(); + lhs.EnsureCapacity(rhs.Count); + foreach (var value in rhs) + lhs.Add(value); + } + + /// Add all entries from the other dictionary that would not overwrite current keys. + public static void AddFrom(this Dictionary lhs, IReadOnlyDictionary rhs) + where TKey : notnull + { + if (ReferenceEquals(lhs, rhs)) + return; + + lhs.EnsureCapacity(lhs.Count + rhs.Count); + foreach (var (key, value) in rhs) + lhs.Add(key, value); + } + + public static int ReplaceValue(this Dictionary dict, TValue from, TValue to) + where TKey : notnull + where TValue : IEquatable + { + var count = 0; + foreach (var (key, _) in dict.ToArray().Where(kvp => kvp.Value.Equals(from))) + { + dict[key] = to; + ++count; + } + + return count; + } +} diff --git a/Penumbra/Util/FixedUlongStringEnumConverter.cs b/Penumbra/Util/FixedUlongStringEnumConverter.cs new file mode 100644 index 00000000..857d951d --- /dev/null +++ b/Penumbra/Util/FixedUlongStringEnumConverter.cs @@ -0,0 +1,77 @@ +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; + +namespace Penumbra.Util; + +// Json.Net has a bug +// ulong enums can not be correctly deserialized if they exceed long.MaxValue. +// These converters fix this, taken from https://stackoverflow.com/questions/61740964/json-net-unable-to-deserialize-ulong-flag-type-enum/ +public class ForceNumericFlagEnumConverter : FixedUlongStringEnumConverter +{ + private static bool HasFlagsAttribute(Type? objectType) + => objectType != null && Attribute.IsDefined(Nullable.GetUnderlyingType(objectType) ?? objectType, typeof(FlagsAttribute)); + + public override void WriteJson(JsonWriter writer, object? value, JsonSerializer serializer) + { + var enumType = value?.GetType(); + if (HasFlagsAttribute(enumType)) + { + var underlyingType = Enum.GetUnderlyingType(enumType!); + var underlyingValue = Convert.ChangeType(value, underlyingType); + writer.WriteValue(underlyingValue); + } + else + { + base.WriteJson(writer, value, serializer); + } + } +} + +public class FixedUlongStringEnumConverter : StringEnumConverter +{ + public override object? ReadJson(JsonReader reader, Type objectType, object? existingValue, JsonSerializer serializer) + { + if (reader.MoveToContentAndAssert().TokenType != JsonToken.Integer || reader.ValueType != typeof(BigInteger)) + return base.ReadJson(reader, objectType, existingValue, serializer); + + // Todo: throw an exception if !this.AllowIntegerValues + // https://www.newtonsoft.com/json/help/html/P_Newtonsoft_Json_Converters_StringEnumConverter_AllowIntegerValues.htm + var enumType = Nullable.GetUnderlyingType(objectType) ?? objectType; + if (Enum.GetUnderlyingType(enumType) == typeof(ulong)) + { + var bigInteger = (BigInteger)reader.Value!; + if (bigInteger >= ulong.MinValue && bigInteger <= ulong.MaxValue) + return Enum.ToObject(enumType, checked((ulong)bigInteger)); + } + + return base.ReadJson(reader, objectType, existingValue, serializer); + } +} + +public static partial class JsonExtensions +{ + public static JsonReader MoveToContentAndAssert(this JsonReader reader) + { + if (reader == null) + throw new ArgumentNullException(); + + if (reader.TokenType == JsonToken.None) // Skip past beginning of stream. + reader.ReadAndAssert(); + + while (reader.TokenType == JsonToken.Comment) // Skip past comments. + reader.ReadAndAssert(); + + return reader; + } + + private static JsonReader ReadAndAssert(this JsonReader reader) + { + if (reader == null) + throw new ArgumentNullException(); + + if (!reader.Read()) + throw new JsonReaderException("Unexpected end of JSON stream."); + + return reader; + } +} diff --git a/Penumbra/Util/GeneralUtil.cs b/Penumbra/Util/GeneralUtil.cs deleted file mode 100644 index 698609db..00000000 --- a/Penumbra/Util/GeneralUtil.cs +++ /dev/null @@ -1,14 +0,0 @@ -using System; -using Dalamud.Logging; - -namespace Penumbra.Util -{ - public static class GeneralUtil - { - public static void PrintDebugAddress( string name, IntPtr address ) - { - var module = Dalamud.SigScanner.Module.BaseAddress.ToInt64(); - PluginLog.Debug( "{Name} found at 0x{Address:X16}, +0x{Offset:X}", name, address.ToInt64(), address.ToInt64() - module ); - } - } -} \ No newline at end of file diff --git a/Penumbra/Util/IdentifierExtensions.cs b/Penumbra/Util/IdentifierExtensions.cs new file mode 100644 index 00000000..f744e940 --- /dev/null +++ b/Penumbra/Util/IdentifierExtensions.cs @@ -0,0 +1,45 @@ +using OtterGui.Classes; +using Penumbra.GameData.Data; +using Penumbra.GameData.Enums; +using Penumbra.GameData.Structs; +using Penumbra.Mods.Editor; +using Penumbra.Mods.SubMods; + +namespace Penumbra.Util; + +public static class IdentifierExtensions +{ + public static void AddChangedItems(this ObjectIdentification identifier, IModDataContainer container, + IDictionary changedItems) + { + foreach (var gamePath in container.Files.Keys.Concat(container.FileSwaps.Keys)) + identifier.Identify(changedItems, gamePath.ToString()); + + foreach (var manip in container.Manipulations.Identifiers) + manip.AddChangedItems(identifier, changedItems); + } + + public static void RemoveMachinistOffhands(this SortedList changedItems) + { + for (var i = 0; i < changedItems.Count; i++) + { + { + var value = changedItems.Values[i]; + if (value is IdentifiedItem { Item.Type: FullEquipType.GunOff }) + changedItems.RemoveAt(i--); + } + } + } + + public static void RemoveMachinistOffhands(this SortedList, IIdentifiedObjectData)> changedItems) + { + for (var i = 0; i < changedItems.Count; i++) + { + { + var value = changedItems.Values[i].Item2; + if (value is IdentifiedItem { Item.Type: FullEquipType.GunOff }) + changedItems.RemoveAt(i--); + } + } + } +} diff --git a/Penumbra/Util/MemoryStreamExtensions.cs b/Penumbra/Util/MemoryStreamExtensions.cs deleted file mode 100644 index 5d4c6235..00000000 --- a/Penumbra/Util/MemoryStreamExtensions.cs +++ /dev/null @@ -1,12 +0,0 @@ -using System.IO; - -namespace Penumbra.Util -{ - public static class MemoryStreamExtensions - { - public static void Write( this MemoryStream stream, byte[] data ) - { - stream.Write( data, 0, data.Length ); - } - } -} \ No newline at end of file diff --git a/Penumbra/Util/PenumbraSqPackStream.cs b/Penumbra/Util/PenumbraSqPackStream.cs index a85e6574..392730e2 100644 --- a/Penumbra/Util/PenumbraSqPackStream.cs +++ b/Penumbra/Util/PenumbraSqPackStream.cs @@ -1,434 +1,388 @@ -using System; -using System.Diagnostics; -using System.IO; -using System.IO.Compression; -using System.Runtime.InteropServices; -using System.Text; -using Lumina; using Lumina.Data.Structs; +using Lumina.Extensions; -namespace Penumbra.Util +namespace Penumbra.Util; + +public class PenumbraSqPackStream : IDisposable { - public class PenumbraSqPackStream : IDisposable + public Stream BaseStream { get; protected set; } + + protected BinaryReader Reader { get; set; } + + public PenumbraSqPackStream(FileInfo file) + : this(file.OpenRead()) + { } + + public PenumbraSqPackStream(Stream stream) { - public Stream BaseStream { get; protected set; } + BaseStream = stream; + Reader = new BinaryReader(BaseStream); + } - protected BinaryReader Reader { get; set; } + public SqPackHeader GetSqPackHeader() + { + BaseStream.Position = 0; - public PenumbraSqPackStream( FileInfo file ) - : this( file.OpenRead() ) - { } + return Reader.ReadStructure(); + } - public PenumbraSqPackStream( Stream stream ) - { - BaseStream = stream; - Reader = new BinaryReader( BaseStream ); - } + public SqPackFileInfo GetFileMetadata(long offset) + { + BaseStream.Position = offset; - public SqPackHeader GetSqPackHeader() - { - BaseStream.Position = 0; + return Reader.ReadStructure(); + } - return Reader.ReadStructure< SqPackHeader >(); - } + public T ReadFile(long offset) where T : PenumbraFileResource + { + using var ms = new MemoryStream(); - public SqPackFileInfo GetFileMetadata( long offset ) + BaseStream.Position = offset; + + var fileInfo = Reader.ReadStructure(); + var file = Activator.CreateInstance(); + + // check if we need to read the extended model header or just default to the standard file header + if (fileInfo.Type == FileType.Model) { BaseStream.Position = offset; - return Reader.ReadStructure< SqPackFileInfo >(); + var modelFileInfo = Reader.ReadStructure(); + + file.FileInfo = new PenumbraFileInfo + { + HeaderSize = modelFileInfo.Size, + Type = modelFileInfo.Type, + BlockCount = modelFileInfo.UsedNumberOfBlocks, + RawFileSize = modelFileInfo.RawFileSize, + Offset = offset, + + // todo: is this useful? + ModelBlock = modelFileInfo, + }; + } + else + { + file.FileInfo = new PenumbraFileInfo + { + HeaderSize = fileInfo.Size, + Type = fileInfo.Type, + BlockCount = fileInfo.NumberOfBlocks, + RawFileSize = fileInfo.RawFileSize, + Offset = offset, + }; } - public T ReadFile< T >( long offset ) where T : PenumbraFileResource + switch (fileInfo.Type) { - using var ms = new MemoryStream(); + case FileType.Empty: throw new FileNotFoundException($"The file located at 0x{offset:x} is empty."); - BaseStream.Position = offset; + case FileType.Standard: + ReadStandardFile(file, ms); + break; - var fileInfo = Reader.ReadStructure< SqPackFileInfo >(); - var file = Activator.CreateInstance< T >(); + case FileType.Model: + ReadModelFile(file, ms); + break; - // check if we need to read the extended model header or just default to the standard file header - if( fileInfo.Type == FileType.Model ) - { - BaseStream.Position = offset; + case FileType.Texture: + ReadTextureFile(file, ms); + break; - var modelFileInfo = Reader.ReadStructure< ModelBlock >(); - - file.FileInfo = new PenumbraFileInfo - { - HeaderSize = modelFileInfo.Size, - Type = modelFileInfo.Type, - BlockCount = modelFileInfo.UsedNumberOfBlocks, - RawFileSize = modelFileInfo.RawFileSize, - Offset = offset, - - // todo: is this useful? - ModelBlock = modelFileInfo, - }; - } - else - { - file.FileInfo = new PenumbraFileInfo - { - HeaderSize = fileInfo.Size, - Type = fileInfo.Type, - BlockCount = fileInfo.NumberOfBlocks, - RawFileSize = fileInfo.RawFileSize, - Offset = offset, - }; - } - - switch( fileInfo.Type ) - { - case FileType.Empty: throw new FileNotFoundException( $"The file located at 0x{offset:x} is empty." ); - - case FileType.Standard: - ReadStandardFile( file, ms ); - break; - - case FileType.Model: - ReadModelFile( file, ms ); - break; - - case FileType.Texture: - ReadTextureFile( file, ms ); - break; - - default: throw new NotImplementedException( $"File Type {( uint )fileInfo.Type} is not implemented." ); - } - - file.Data = ms.ToArray(); - if( file.Data.Length != file.FileInfo.RawFileSize ) - { - Debug.WriteLine( "Read data size does not match file size." ); - } - - file.FileStream = new MemoryStream( file.Data, false ); - file.Reader = new BinaryReader( file.FileStream ); - file.FileStream.Position = 0; - - file.LoadFile(); - - return file; + default: throw new NotImplementedException($"File Type {(uint)fileInfo.Type} is not implemented."); } - private void ReadStandardFile( PenumbraFileResource resource, MemoryStream ms ) + file.Data = ms.ToArray(); + if (file.Data.Length != file.FileInfo.RawFileSize) + Debug.WriteLine("Read data size does not match file size."); + + file.FileStream = new MemoryStream(file.Data, false); + file.Reader = new BinaryReader(file.FileStream); + file.FileStream.Position = 0; + + file.LoadFile(); + + return file; + } + + private void ReadStandardFile(PenumbraFileResource resource, MemoryStream ms) + { + var blocks = Reader.ReadStructures((int)resource.FileInfo!.BlockCount); + + foreach (var block in blocks) + ReadFileBlock(resource.FileInfo.Offset + resource.FileInfo.HeaderSize + block.Offset, ms); + + // reset position ready for reading + ms.Position = 0; + } + + private unsafe void ReadModelFile(PenumbraFileResource resource, MemoryStream ms) + { + var mdlBlock = resource.FileInfo!.ModelBlock; + var baseOffset = resource.FileInfo.Offset + resource.FileInfo.HeaderSize; + + // 1/1/3/3/3 stack/runtime/vertex/egeo/index + // TODO: consider testing if this is more reliable than the Explorer method + // of adding mdlBlock.IndexBufferDataBlockIndex[2] + mdlBlock.IndexBufferDataBlockNum[2] + // i don't want to move this to that method right now, because i know sometimes the index is 0 + // but it seems to work fine in explorer... + int totalBlocks = mdlBlock.StackBlockNum; + totalBlocks += mdlBlock.RuntimeBlockNum; + for (var i = 0; i < 3; i++) + totalBlocks += mdlBlock.VertexBufferBlockNum[i]; + + for (var i = 0; i < 3; i++) + totalBlocks += mdlBlock.EdgeGeometryVertexBufferBlockNum[i]; + + for (var i = 0; i < 3; i++) + totalBlocks += mdlBlock.IndexBufferBlockNum[i]; + + var compressedBlockSizes = Reader.ReadStructures(totalBlocks); + var currentBlock = 0; + var vertexDataOffsets = new int[3]; + var indexDataOffsets = new int[3]; + var vertexBufferSizes = new int[3]; + var indexBufferSizes = new int[3]; + + ms.Seek(0x44, SeekOrigin.Begin); + + Reader.Seek(baseOffset + mdlBlock.StackOffset); + var stackStart = ms.Position; + for (var i = 0; i < mdlBlock.StackBlockNum; i++) { - var blocks = Reader.ReadStructures< DatStdFileBlockInfos >( ( int )resource.FileInfo!.BlockCount ); - - foreach( var block in blocks ) - { - ReadFileBlock( resource.FileInfo.Offset + resource.FileInfo.HeaderSize + block.Offset, ms ); - } - - // reset position ready for reading - ms.Position = 0; + var lastPos = Reader.BaseStream.Position; + ReadFileBlock(ms); + Reader.Seek(lastPos + compressedBlockSizes[currentBlock]); + currentBlock++; } - private unsafe void ReadModelFile( PenumbraFileResource resource, MemoryStream ms ) + var stackEnd = ms.Position; + var stackSize = (int)(stackEnd - stackStart); + + Reader.Seek(baseOffset + mdlBlock.RuntimeOffset); + var runtimeStart = ms.Position; + for (var i = 0; i < mdlBlock.RuntimeBlockNum; i++) { - var mdlBlock = resource.FileInfo!.ModelBlock; - var baseOffset = resource.FileInfo.Offset + resource.FileInfo.HeaderSize; + var lastPos = Reader.BaseStream.Position; + ReadFileBlock(ms); + Reader.Seek(lastPos + compressedBlockSizes[currentBlock]); + currentBlock++; + } - // 1/1/3/3/3 stack/runtime/vertex/egeo/index - // TODO: consider testing if this is more reliable than the Explorer method - // of adding mdlBlock.IndexBufferDataBlockIndex[2] + mdlBlock.IndexBufferDataBlockNum[2] - // i don't want to move this to that method right now, because i know sometimes the index is 0 - // but it seems to work fine in explorer... - int totalBlocks = mdlBlock.StackBlockNum; - totalBlocks += mdlBlock.RuntimeBlockNum; - for( var i = 0; i < 3; i++ ) + var runtimeEnd = ms.Position; + var runtimeSize = (int)(runtimeEnd - runtimeStart); + + for (var i = 0; i < 3; i++) + { + if (mdlBlock.VertexBufferBlockNum[i] != 0) { - totalBlocks += mdlBlock.VertexBufferBlockNum[ i ]; - } + var currentVertexOffset = (int)ms.Position; + if (i == 0 || currentVertexOffset != vertexDataOffsets[i - 1]) + vertexDataOffsets[i] = currentVertexOffset; + else + vertexDataOffsets[i] = 0; - for( var i = 0; i < 3; i++ ) - { - totalBlocks += mdlBlock.EdgeGeometryVertexBufferBlockNum[ i ]; - } + Reader.Seek(baseOffset + mdlBlock.VertexBufferOffset[i]); - for( var i = 0; i < 3; i++ ) - { - totalBlocks += mdlBlock.IndexBufferBlockNum[ i ]; - } - - var compressedBlockSizes = Reader.ReadStructures< ushort >( totalBlocks ); - var currentBlock = 0; - var vertexDataOffsets = new int[3]; - var indexDataOffsets = new int[3]; - var vertexBufferSizes = new int[3]; - var indexBufferSizes = new int[3]; - - ms.Seek( 0x44, SeekOrigin.Begin ); - - Reader.Seek( baseOffset + mdlBlock.StackOffset ); - var stackStart = ms.Position; - for( var i = 0; i < mdlBlock.StackBlockNum; i++ ) - { - var lastPos = Reader.BaseStream.Position; - ReadFileBlock( ms ); - Reader.Seek( lastPos + compressedBlockSizes[ currentBlock ] ); - currentBlock++; - } - - var stackEnd = ms.Position; - var stackSize = ( int )( stackEnd - stackStart ); - - Reader.Seek( baseOffset + mdlBlock.RuntimeOffset ); - var runtimeStart = ms.Position; - for( var i = 0; i < mdlBlock.RuntimeBlockNum; i++ ) - { - var lastPos = Reader.BaseStream.Position; - ReadFileBlock( ms ); - Reader.Seek( lastPos + compressedBlockSizes[ currentBlock ] ); - currentBlock++; - } - - var runtimeEnd = ms.Position; - var runtimeSize = ( int )( runtimeEnd - runtimeStart ); - - for( var i = 0; i < 3; i++ ) - { - if( mdlBlock.VertexBufferBlockNum[ i ] != 0 ) + for (var j = 0; j < mdlBlock.VertexBufferBlockNum[i]; j++) { - var currentVertexOffset = ( int )ms.Position; - if( i == 0 || currentVertexOffset != vertexDataOffsets[ i - 1 ] ) - { - vertexDataOffsets[ i ] = currentVertexOffset; - } - else - { - vertexDataOffsets[ i ] = 0; - } - - Reader.Seek( baseOffset + mdlBlock.VertexBufferOffset[ i ] ); - - for( var j = 0; j < mdlBlock.VertexBufferBlockNum[ i ]; j++ ) - { - var lastPos = Reader.BaseStream.Position; - vertexBufferSizes[ i ] += ( int )ReadFileBlock( ms ); - Reader.Seek( lastPos + compressedBlockSizes[ currentBlock ] ); - currentBlock++; - } - } - - if( mdlBlock.EdgeGeometryVertexBufferBlockNum[ i ] != 0 ) - { - for( var j = 0; j < mdlBlock.EdgeGeometryVertexBufferBlockNum[ i ]; j++ ) - { - var lastPos = Reader.BaseStream.Position; - ReadFileBlock( ms ); - Reader.Seek( lastPos + compressedBlockSizes[ currentBlock ] ); - currentBlock++; - } - } - - if( mdlBlock.IndexBufferBlockNum[ i ] != 0 ) - { - var currentIndexOffset = ( int )ms.Position; - if( i == 0 || currentIndexOffset != indexDataOffsets[ i - 1 ] ) - { - indexDataOffsets[ i ] = currentIndexOffset; - } - else - { - indexDataOffsets[ i ] = 0; - } - - // i guess this is only needed in the vertex area, for i = 0 - // Reader.Seek( baseOffset + mdlBlock.IndexBufferOffset[ i ] ); - - for( var j = 0; j < mdlBlock.IndexBufferBlockNum[ i ]; j++ ) - { - var lastPos = Reader.BaseStream.Position; - indexBufferSizes[ i ] += ( int )ReadFileBlock( ms ); - Reader.Seek( lastPos + compressedBlockSizes[ currentBlock ] ); - currentBlock++; - } + var lastPos = Reader.BaseStream.Position; + vertexBufferSizes[i] += (int)ReadFileBlock(ms); + Reader.Seek(lastPos + compressedBlockSizes[currentBlock]); + currentBlock++; } } - ms.Seek( 0, SeekOrigin.Begin ); - ms.Write( BitConverter.GetBytes( mdlBlock.Version ) ); - ms.Write( BitConverter.GetBytes( stackSize ) ); - ms.Write( BitConverter.GetBytes( runtimeSize ) ); - ms.Write( BitConverter.GetBytes( mdlBlock.VertexDeclarationNum ) ); - ms.Write( BitConverter.GetBytes( mdlBlock.MaterialNum ) ); - for( var i = 0; i < 3; i++ ) - { - ms.Write( BitConverter.GetBytes( vertexDataOffsets[ i ] ) ); - } - - for( var i = 0; i < 3; i++ ) - { - ms.Write( BitConverter.GetBytes( indexDataOffsets[ i ] ) ); - } - - for( var i = 0; i < 3; i++ ) - { - ms.Write( BitConverter.GetBytes( vertexBufferSizes[ i ] ) ); - } - - for( var i = 0; i < 3; i++ ) - { - ms.Write( BitConverter.GetBytes( indexBufferSizes[ i ] ) ); - } - - ms.Write( new[] { mdlBlock.NumLods } ); - ms.Write( BitConverter.GetBytes( mdlBlock.IndexBufferStreamingEnabled ) ); - ms.Write( BitConverter.GetBytes( mdlBlock.EdgeGeometryEnabled ) ); - ms.Write( new byte[] { 0 } ); - } - - private void ReadTextureFile( PenumbraFileResource resource, MemoryStream ms ) - { - var blocks = Reader.ReadStructures< LodBlock >( ( int )resource.FileInfo!.BlockCount ); - - // if there is a mipmap header, the comp_offset - // will not be 0 - var mipMapSize = blocks[ 0 ].CompressedOffset; - if( mipMapSize != 0 ) - { - var originalPos = BaseStream.Position; - - BaseStream.Position = resource.FileInfo.Offset + resource.FileInfo.HeaderSize; - ms.Write( Reader.ReadBytes( ( int )mipMapSize ) ); - - BaseStream.Position = originalPos; - } - - // i is for texture blocks, j is 'data blocks'... - for( byte i = 0; i < blocks.Count; i++ ) - { - // start from comp_offset - var runningBlockTotal = blocks[ i ].CompressedOffset + resource.FileInfo.Offset + resource.FileInfo.HeaderSize; - ReadFileBlock( runningBlockTotal, ms, true ); - - for( var j = 1; j < blocks[ i ].BlockCount; j++ ) + if (mdlBlock.EdgeGeometryVertexBufferBlockNum[i] != 0) + for (var j = 0; j < mdlBlock.EdgeGeometryVertexBufferBlockNum[i]; j++) { - runningBlockTotal += ( uint )Reader.ReadInt16(); - ReadFileBlock( runningBlockTotal, ms, true ); + var lastPos = Reader.BaseStream.Position; + ReadFileBlock(ms); + Reader.Seek(lastPos + compressedBlockSizes[currentBlock]); + currentBlock++; } - // unknown - Reader.ReadInt16(); - } - } - - protected uint ReadFileBlock( MemoryStream dest, bool resetPosition = false ) - => ReadFileBlock( Reader.BaseStream.Position, dest, resetPosition ); - - protected uint ReadFileBlock( long offset, MemoryStream dest, bool resetPosition = false ) - { - var originalPosition = BaseStream.Position; - BaseStream.Position = offset; - - var blockHeader = Reader.ReadStructure< DatBlockHeader >(); - - // uncompressed block - if( blockHeader.CompressedSize == 32000 ) + if (mdlBlock.IndexBufferBlockNum[i] != 0) { - dest.Write( Reader.ReadBytes( ( int )blockHeader.UncompressedSize ) ); - return blockHeader.UncompressedSize; - } + var currentIndexOffset = (int)ms.Position; + if (i == 0 || currentIndexOffset != indexDataOffsets[i - 1]) + indexDataOffsets[i] = currentIndexOffset; + else + indexDataOffsets[i] = 0; - var data = Reader.ReadBytes( ( int )blockHeader.CompressedSize ); + // i guess this is only needed in the vertex area, for i = 0 + // Reader.Seek( baseOffset + mdlBlock.IndexBufferOffset[ i ] ); - using( var compressedStream = new MemoryStream( data ) ) - { - using var zlibStream = new DeflateStream( compressedStream, CompressionMode.Decompress ); - zlibStream.CopyTo( dest ); - } - - if( resetPosition ) - { - BaseStream.Position = originalPosition; - } - - return blockHeader.UncompressedSize; - } - - public void Dispose() - { - Reader?.Dispose(); - } - - public class PenumbraFileInfo - { - public uint HeaderSize; - public FileType Type; - public uint RawFileSize; - public uint BlockCount; - - public long Offset { get; internal set; } - - public ModelBlock ModelBlock { get; internal set; } - } - - public class PenumbraFileResource - { - public PenumbraFileResource() - { } - - public PenumbraFileInfo? FileInfo { get; internal set; } - - public byte[] Data { get; internal set; } = new byte[0]; - - public Span< byte > DataSpan - => Data.AsSpan(); - - public MemoryStream? FileStream { get; internal set; } - - public BinaryReader? Reader { get; internal set; } - - public ParsedFilePath? FilePath { get; internal set; } - - /// - /// Called once the files are read out from the dats. Used to further parse the file into usable data structures. - /// - public virtual void LoadFile() - { - // this function is intentionally left blank - } - - public virtual void SaveFile( string path ) - { - File.WriteAllBytes( path, Data ); - } - - public string GetFileHash() - { - using var sha256 = System.Security.Cryptography.SHA256.Create(); - var hash = sha256.ComputeHash( Data ); - - var sb = new StringBuilder(); - foreach( var b in hash ) + for (var j = 0; j < mdlBlock.IndexBufferBlockNum[i]; j++) { - sb.Append( $"{b:x2}" ); + var lastPos = Reader.BaseStream.Position; + indexBufferSizes[i] += (int)ReadFileBlock(ms); + Reader.Seek(lastPos + compressedBlockSizes[currentBlock]); + currentBlock++; } - - return sb.ToString(); } } - [StructLayout( LayoutKind.Sequential )] - private struct DatBlockHeader - { - public uint Size; - public uint unknown1; - public uint CompressedSize; - public uint UncompressedSize; - }; + ms.Seek(0, SeekOrigin.Begin); + ms.Write(BitConverter.GetBytes(mdlBlock.Version)); + ms.Write(BitConverter.GetBytes(stackSize)); + ms.Write(BitConverter.GetBytes(runtimeSize)); + ms.Write(BitConverter.GetBytes(mdlBlock.VertexDeclarationNum)); + ms.Write(BitConverter.GetBytes(mdlBlock.MaterialNum)); + for (var i = 0; i < 3; i++) + ms.Write(BitConverter.GetBytes(vertexDataOffsets[i])); - [StructLayout( LayoutKind.Sequential )] - private struct LodBlock + for (var i = 0; i < 3; i++) + ms.Write(BitConverter.GetBytes(indexDataOffsets[i])); + + for (var i = 0; i < 3; i++) + ms.Write(BitConverter.GetBytes(vertexBufferSizes[i])); + + for (var i = 0; i < 3; i++) + ms.Write(BitConverter.GetBytes(indexBufferSizes[i])); + + ms.Write(new[] { - public uint CompressedOffset; - public uint CompressedSize; - public uint DecompressedSize; - public uint BlockOffset; - public uint BlockCount; + mdlBlock.NumLods, + }); + ms.Write(BitConverter.GetBytes(mdlBlock.IndexBufferStreamingEnabled)); + ms.Write(BitConverter.GetBytes(mdlBlock.EdgeGeometryEnabled)); + ms.Write(new byte[] + { + 0, + }); + } + + private void ReadTextureFile(PenumbraFileResource resource, MemoryStream ms) + { + if (resource.FileInfo!.BlockCount == 0) + return; + + var blocks = Reader.ReadStructures((int)resource.FileInfo!.BlockCount); + + // if there is a mipmap header, the comp_offset + // will not be 0 + var mipMapSize = blocks[0].CompressedOffset; + if (mipMapSize != 0) + { + var originalPos = BaseStream.Position; + + BaseStream.Position = resource.FileInfo.Offset + resource.FileInfo.HeaderSize; + ms.Write(Reader.ReadBytes((int)mipMapSize)); + + BaseStream.Position = originalPos; + } + + // i is for texture blocks, j is 'data blocks'... + for (byte i = 0; i < blocks.Count; i++) + { + if (blocks[i].CompressedSize == 0) + continue; + + // start from comp_offset + var runningBlockTotal = blocks[i].CompressedOffset + resource.FileInfo.Offset + resource.FileInfo.HeaderSize; + ReadFileBlock(runningBlockTotal, ms, true); + + for (var j = 1; j < blocks[i].BlockCount; j++) + { + runningBlockTotal += (uint)Reader.ReadInt16(); + ReadFileBlock(runningBlockTotal, ms, true); + } + + // unknown + Reader.ReadInt16(); } } -} \ No newline at end of file + + protected uint ReadFileBlock(MemoryStream dest, bool resetPosition = false) + => ReadFileBlock(Reader.BaseStream.Position, dest, resetPosition); + + protected uint ReadFileBlock(long offset, MemoryStream dest, bool resetPosition = false) + { + var originalPosition = BaseStream.Position; + BaseStream.Position = offset; + + var blockHeader = Reader.ReadStructure(); + + // uncompressed block + if (blockHeader.CompressedSize == 32000) + { + dest.Write(Reader.ReadBytes((int)blockHeader.UncompressedSize)); + } + else + { + var data = Reader.ReadBytes((int)blockHeader.CompressedSize); + + using var compressedStream = new MemoryStream(data); + using var zlibStream = new DeflateStream(compressedStream, CompressionMode.Decompress); + zlibStream.CopyTo(dest); + } + + if (resetPosition) + BaseStream.Position = originalPosition; + + return blockHeader.UncompressedSize; + } + + public void Dispose() + { + Reader.Dispose(); + Dispose(true); + } + + protected virtual void Dispose(bool _) + { } + + public class PenumbraFileInfo + { + public uint HeaderSize; + public FileType Type; + public uint RawFileSize; + public uint BlockCount; + + public long Offset { get; internal set; } + + public ModelBlock ModelBlock { get; internal set; } + } + + public class PenumbraFileResource + { + public PenumbraFileResource() + { } + + public PenumbraFileInfo? FileInfo { get; internal set; } + + public byte[] Data { get; internal set; } = new byte[0]; + + public MemoryStream? FileStream { get; internal set; } + + public BinaryReader? Reader { get; internal set; } + + /// + /// Called once the files are read out from the dats. Used to further parse the file into usable data structures. + /// + public virtual void LoadFile() + { + // this function is intentionally left blank + } + } + + [StructLayout(LayoutKind.Sequential)] + private struct DatBlockHeader + { + public uint Size; + public uint unknown1; + public uint CompressedSize; + public uint UncompressedSize; + }; + + [StructLayout(LayoutKind.Sequential)] + private struct LodBlock + { + public uint CompressedOffset; + public uint CompressedSize; + public uint DecompressedSize; + public uint BlockOffset; + public uint BlockCount; + } +} diff --git a/Penumbra/Util/PerformanceType.cs b/Penumbra/Util/PerformanceType.cs new file mode 100644 index 00000000..d5755dfd --- /dev/null +++ b/Penumbra/Util/PerformanceType.cs @@ -0,0 +1,67 @@ +using Dalamud.Plugin.Services; +using OtterGui.Services; + +namespace Penumbra.Util; + +public sealed class PerformanceTracker(IFramework framework) : OtterGui.Classes.PerformanceTracker(framework), IService; + +public enum PerformanceType +{ + UiMainWindow, + UiAdvancedWindow, + CharacterResolver, + IdentifyCollection, + GetResourceHandler, + ReadSqPack, + CharacterBaseCreate, + TimelineResources, + LoadCharacterVfx, + LoadAreaVfx, + LoadSound, + ScheduleClipUpdate, + LoadAction, + LoadCharacterBaseAnimation, + LoadPap, + LoadTextures, + LoadShaders, + LoadApricotResources, + UpdateModels, + GetEqp, + SetupVisor, + SetupCharacter, + ChangeCustomize, + DebugTimes, +} + +public static class TimingExtensions +{ + public static string ToName(this PerformanceType type) + => type switch + { + PerformanceType.UiMainWindow => "Main Interface Drawing", + PerformanceType.UiAdvancedWindow => "Advanced Window Drawing", + PerformanceType.GetResourceHandler => "GetResource Hook", + PerformanceType.ReadSqPack => "ReadSqPack Hook", + PerformanceType.CharacterResolver => "Resolving Characters", + PerformanceType.IdentifyCollection => "Identifying Collections", + PerformanceType.CharacterBaseCreate => "CharacterBaseCreate Hook", + PerformanceType.TimelineResources => "LoadTimelineResources Hook", + PerformanceType.LoadCharacterVfx => "LoadCharacterVfx Hook", + PerformanceType.LoadAreaVfx => "LoadAreaVfx Hook", + PerformanceType.LoadTextures => "LoadTextures Hook", + PerformanceType.LoadShaders => "LoadShaders Hook", + PerformanceType.LoadApricotResources => "LoadApricotFiles Hook", + PerformanceType.UpdateModels => "UpdateModels Hook", + PerformanceType.GetEqp => "GetEqp Hook", + PerformanceType.SetupVisor => "SetupVisor Hook", + PerformanceType.SetupCharacter => "SetupCharacter Hook", + PerformanceType.ChangeCustomize => "ChangeCustomize Hook", + PerformanceType.LoadSound => "LoadSound Hook", + PerformanceType.ScheduleClipUpdate => "ScheduleClipUpdate Hook", + PerformanceType.LoadCharacterBaseAnimation => "LoadCharacterAnimation Hook", + PerformanceType.LoadPap => "LoadPap Hook", + PerformanceType.LoadAction => "LoadAction Hook", + PerformanceType.DebugTimes => "Debug Tracking", + _ => $"Unknown {(int)type}", + }; +} diff --git a/Penumbra/Util/PointerExtensions.cs b/Penumbra/Util/PointerExtensions.cs new file mode 100644 index 00000000..c70e2177 --- /dev/null +++ b/Penumbra/Util/PointerExtensions.cs @@ -0,0 +1,20 @@ +namespace Penumbra.Util; + +public static class PointerExtensions +{ + public static unsafe ref TField GetField(this ref TPointer reference, int offset) + where TPointer : unmanaged + where TField : unmanaged + { + var pointer = (byte*)Unsafe.AsPointer(ref reference) + offset; + return ref *(TField*)pointer; + } + + public static unsafe ref TField GetField(TPointer* itemPointer, int offset) + where TPointer : unmanaged + where TField : unmanaged + { + var pointer = (byte*)itemPointer + offset; + return ref *(TField*)pointer; + } +} diff --git a/Penumbra/Util/RelPath.cs b/Penumbra/Util/RelPath.cs deleted file mode 100644 index 2c08cd9a..00000000 --- a/Penumbra/Util/RelPath.cs +++ /dev/null @@ -1,81 +0,0 @@ -using System; -using System.IO; -using System.Linq; -using Penumbra.GameData.Util; - -namespace Penumbra.Util -{ - public readonly struct RelPath : IComparable - { - public const int MaxRelPathLength = 256; - - private readonly string _path; - - private RelPath( string path, bool _ ) - => _path = path; - - private RelPath( string? path ) - { - if( path != null && path.Length < MaxRelPathLength ) - { - _path = Trim( ReplaceSlash( path ) ); - } - else - { - _path = ""; - } - } - - public RelPath( FileInfo file, DirectoryInfo baseDir ) - => _path = CheckPre( file, baseDir ) ? Trim( Substring( file, baseDir ) ) : ""; - - public RelPath( GamePath gamePath ) - => _path = ReplaceSlash( gamePath ); - - public GamePath ToGamePath( int skipFolders = 0 ) - { - string p = this; - if( skipFolders > 0 ) - { - p = string.Join( "/", p.Split( '\\' ).Skip( skipFolders ) ); - return GamePath.GenerateUncheckedLower( p ); - } - - return GamePath.GenerateUncheckedLower( p.Replace( '\\', '/' ) ); - } - - private static bool CheckPre( FileInfo file, DirectoryInfo baseDir ) - => file.FullName.StartsWith( baseDir.FullName ) && file.FullName.Length < MaxRelPathLength; - - private static string Substring( FileInfo file, DirectoryInfo baseDir ) - => file.FullName.Substring( baseDir.FullName.Length ); - - private static string ReplaceSlash( string path ) - => path.Replace( '/', '\\' ); - - private static string Trim( string path ) - => path.TrimStart( '\\' ); - - public static implicit operator string( RelPath relPath ) - => relPath._path; - - public static explicit operator RelPath( string relPath ) - => new( relPath ); - - public bool Empty - => _path.Length == 0; - - public int CompareTo( object? rhs ) - { - return rhs switch - { - string path => string.Compare( _path, path, StringComparison.InvariantCulture ), - RelPath path => string.Compare( _path, path._path, StringComparison.InvariantCulture ), - _ => -1, - }; - } - - public override string ToString() - => _path; - } -} \ No newline at end of file diff --git a/Penumbra/Util/Service.cs b/Penumbra/Util/Service.cs deleted file mode 100644 index 5391db22..00000000 --- a/Penumbra/Util/Service.cs +++ /dev/null @@ -1,56 +0,0 @@ -using System; - -namespace Penumbra.Util -{ - /// - /// Basic service locator - /// - /// The class you want to store in the service locator - public static class Service< T > where T : class - { - private static T? _object; - - public static void Set( T obj ) - { - // ReSharper disable once JoinNullCheckWithUsage - if( obj == null ) - { - throw new ArgumentNullException( $"{nameof( obj )} is null!" ); - } - - _object = obj; - } - - public static T Set() - { - _object = Activator.CreateInstance< T >(); - - return _object; - } - - public static T Set( params object[] args ) - { - var obj = ( T? )Activator.CreateInstance( typeof( T ), args ); - - // ReSharper disable once JoinNullCheckWithUsage - if( obj == null ) - { - throw new Exception( "what he fuc" ); - } - - _object = obj; - - return obj; - } - - public static T Get() - { - if( _object == null ) - { - throw new InvalidOperationException( $"{nameof( T )} hasn't been registered!" ); - } - - return _object; - } - } -} \ No newline at end of file diff --git a/Penumbra/Util/SingleOrArrayConverter.cs b/Penumbra/Util/SingleOrArrayConverter.cs deleted file mode 100644 index 62840df0..00000000 --- a/Penumbra/Util/SingleOrArrayConverter.cs +++ /dev/null @@ -1,46 +0,0 @@ -using System; -using System.Collections.Generic; -using Newtonsoft.Json; -using Newtonsoft.Json.Linq; - -namespace Penumbra.Util -{ - public class SingleOrArrayConverter< T > : JsonConverter - { - public override bool CanConvert( Type objectType ) - => objectType == typeof( HashSet< T > ); - - public override object ReadJson( JsonReader reader, Type objectType, object? existingValue, JsonSerializer serializer ) - { - var token = JToken.Load( reader ); - - if( token.Type == JTokenType.Array ) - { - return token.ToObject< HashSet< T > >() ?? new HashSet< T >(); - } - - var tmp = token.ToObject< T >(); - return tmp != null - ? new HashSet< T > { tmp } - : new HashSet< T >(); - } - - public override bool CanWrite - => true; - - public override void WriteJson( JsonWriter writer, object? value, JsonSerializer serializer ) - { - writer.WriteStartArray(); - if( value != null ) - { - var v = ( HashSet< T > )value; - foreach( var val in v ) - { - serializer.Serialize( writer, val?.ToString() ); - } - } - - writer.WriteEndArray(); - } - } -} \ No newline at end of file diff --git a/Penumbra/Util/StringPathExtensions.cs b/Penumbra/Util/StringPathExtensions.cs deleted file mode 100644 index 1e309cac..00000000 --- a/Penumbra/Util/StringPathExtensions.cs +++ /dev/null @@ -1,68 +0,0 @@ -using System.Collections.Generic; -using System.IO; -using System.Text; - -namespace Penumbra.Util -{ - public static class StringPathExtensions - { - private static readonly HashSet< char > Invalid = new( Path.GetInvalidFileNameChars() ); - - public static string ReplaceInvalidPathSymbols( this string s, string replacement = "_" ) - { - StringBuilder sb = new( s.Length ); - foreach( var c in s ) - { - if( Invalid.Contains( c ) ) - { - sb.Append( replacement ); - } - else - { - sb.Append( c ); - } - } - - return sb.ToString(); - } - - public static string RemoveInvalidPathSymbols( this string s ) - => string.Concat( s.Split( Path.GetInvalidFileNameChars() ) ); - - public static string ReplaceNonAsciiSymbols( this string s, string replacement = "_" ) - { - StringBuilder sb = new( s.Length ); - foreach( var c in s ) - { - if( c >= 128 ) - { - sb.Append( replacement ); - } - else - { - sb.Append( c ); - } - } - - return sb.ToString(); - } - - public static string ReplaceBadXivSymbols( this string s, string replacement = "_" ) - { - StringBuilder sb = new( s.Length ); - foreach( var c in s ) - { - if( c >= 128 || Invalid.Contains( c ) ) - { - sb.Append( replacement ); - } - else - { - sb.Append( c ); - } - } - - return sb.ToString(); - } - } -} \ No newline at end of file diff --git a/Penumbra/Util/TempFile.cs b/Penumbra/Util/TempFile.cs deleted file mode 100644 index fba296f4..00000000 --- a/Penumbra/Util/TempFile.cs +++ /dev/null @@ -1,34 +0,0 @@ -using System.IO; -using System.Linq; - -namespace Penumbra.Util -{ - public static class TempFile - { - public static FileInfo TempFileName( DirectoryInfo baseDir, string suffix = "" ) - { - const uint maxTries = 15; - for( var i = 0; i < maxTries; ++i ) - { - var name = Path.GetRandomFileName(); - var path = new FileInfo( Path.Combine( baseDir.FullName, - suffix.Any() ? name.Substring( 0, name.LastIndexOf( '.' ) ) + suffix : name ) ); - if( !path.Exists ) - { - return path; - } - } - - throw new IOException(); - } - - public static FileInfo WriteNew( DirectoryInfo baseDir, byte[] data, string suffix = "" ) - { - var fileName = TempFileName( baseDir, suffix ); - using var stream = fileName.OpenWrite(); - stream.Write( data, 0, data.Length ); - fileName.Refresh(); - return fileName; - } - } -} \ No newline at end of file diff --git a/Penumbra/lib/DirectXTexC.dll b/Penumbra/lib/DirectXTexC.dll new file mode 100644 index 00000000..2cab1dce Binary files /dev/null and b/Penumbra/lib/DirectXTexC.dll differ diff --git a/Penumbra/lib/OtterTex.dll b/Penumbra/lib/OtterTex.dll new file mode 100644 index 00000000..c137aee1 Binary files /dev/null and b/Penumbra/lib/OtterTex.dll differ diff --git a/Penumbra/packages.lock.json b/Penumbra/packages.lock.json new file mode 100644 index 00000000..c904870a --- /dev/null +++ b/Penumbra/packages.lock.json @@ -0,0 +1,144 @@ +{ + "version": 1, + "dependencies": { + "net10.0-windows7.0": { + "DotNet.ReproducibleBuilds": { + "type": "Direct", + "requested": "[1.2.39, )", + "resolved": "1.2.39", + "contentHash": "fcFN01tDTIQqDuTwr1jUQK/geofiwjG5DycJQOnC72i1SsLAk1ELe+apBOuZ11UMQG8YKFZG1FgvjZPbqHyatg==" + }, + "EmbedIO": { + "type": "Direct", + "requested": "[3.5.2, )", + "resolved": "3.5.2", + "contentHash": "YU4j+3XvuO8/VPkNf7KWOF1TpMhnyVhXnPsG1mvnDhTJ9D5BZOFXVDvCpE/SkQ1AJ0Aa+dXOVSW3ntgmLL7aJg==", + "dependencies": { + "Unosquare.Swan.Lite": "3.1.0" + } + }, + "PeNet": { + "type": "Direct", + "requested": "[5.1.0, )", + "resolved": "5.1.0", + "contentHash": "XSd1PUwWo5uI8iqVHk7Mm02RT1bjndtAYsaRwLmdYZoHOAmb4ohkvRcZiqxJ7iLfBfdiwm+PHKQIMqDmOavBtw==", + "dependencies": { + "PeNet.Asn1": "2.0.1", + "System.Security.Cryptography.Pkcs": "8.0.1" + } + }, + "SharpCompress": { + "type": "Direct", + "requested": "[0.40.0, )", + "resolved": "0.40.0", + "contentHash": "yP/aFX1jqGikVF7u2f05VEaWN4aCaKNLxSas82UgA2GGVECxq/BcqZx3STHCJ78qilo1azEOk1XpBglIuGMb7w==", + "dependencies": { + "ZstdSharp.Port": "0.8.5" + } + }, + "SharpGLTF.Core": { + "type": "Direct", + "requested": "[1.0.5, )", + "resolved": "1.0.5", + "contentHash": "HNHKPqaHXm7R1nlXZ764K5UI02IeDOQ5DQKLjwYUVNTsSW27jJpw+wLGQx6ZFoiFYqUlyZjmsu+WfEak2GmJAg==" + }, + "SharpGLTF.Toolkit": { + "type": "Direct", + "requested": "[1.0.5, )", + "resolved": "1.0.5", + "contentHash": "piQKk7PH2pSWQSQmCSd8cYPaDtAy/ppAD+Mrh2RUhhHI8awl81HqqLyAauwQhJwea3LNaiJ6f4ehZuOGk89TlA==", + "dependencies": { + "SharpGLTF.Runtime": "1.0.5" + } + }, + "SixLabors.ImageSharp": { + "type": "Direct", + "requested": "[3.1.11, )", + "resolved": "3.1.11", + "contentHash": "JfPLyigLthuE50yi6tMt7Amrenr/fA31t2CvJyhy/kQmfulIBAqo5T/YFUSRHtuYPXRSaUHygFeh6Qd933EoSw==" + }, + "FlatSharp.Compiler": { + "type": "Transitive", + "resolved": "7.9.0", + "contentHash": "MU6808xvdbWJ3Ev+5PKalqQuzvVbn1DzzQH8txRDHGFUNDvHjd+ejqpvnYc9BSJ8Qp8VjkkpJD8OzRYilbPp3A==" + }, + "FlatSharp.Runtime": { + "type": "Transitive", + "resolved": "7.9.0", + "contentHash": "Bm8+WqzEsWNpxqrD5x4x+zQ8dyINlToCreM5FI2oNSfUVc9U9ZB+qztX/jd8rlJb3r0vBSlPwVLpw0xBtPa3Vw==" + }, + "JetBrains.Annotations": { + "type": "Transitive", + "resolved": "2024.3.0", + "contentHash": "ox5pkeLQXjvJdyAB4b2sBYAlqZGLh3PjSnP1bQNVx72ONuTJ9+34/+Rq91Fc0dG29XG9RgZur9+NcP4riihTug==" + }, + "Microsoft.Extensions.DependencyInjection": { + "type": "Transitive", + "resolved": "9.0.2", + "contentHash": "ZffbJrskOZ40JTzcTyKwFHS5eACSWp2bUQBBApIgGV+es8RaTD4OxUG7XxFr3RIPLXtYQ1jQzF2DjKB5fZn7Qg==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.2" + } + }, + "Microsoft.Extensions.DependencyInjection.Abstractions": { + "type": "Transitive", + "resolved": "9.0.2", + "contentHash": "MNe7GSTBf3jQx5vYrXF0NZvn6l7hUKF6J54ENfAgCO8y6xjN1XUmKKWG464LP2ye6QqDiA1dkaWEZBYnhoZzjg==" + }, + "PeNet.Asn1": { + "type": "Transitive", + "resolved": "2.0.1", + "contentHash": "YR2O2YokSAYB+7CXkCDN3bd6/p0K3/AicCPkOJHKUz500v1D/hulCuVlggguqNc3M0LgSfOZKGvVYg2ud1GA9A==" + }, + "SharpGLTF.Runtime": { + "type": "Transitive", + "resolved": "1.0.5", + "contentHash": "EVP32k4LqERxSVICiupT8xQvhHSHJCiXajBjNpqdfdajtREHayuVhH0Jmk6uSjTLX8/IIH9XfT34sw3TwvCziw==", + "dependencies": { + "SharpGLTF.Core": "1.0.5" + } + }, + "System.Security.Cryptography.Pkcs": { + "type": "Transitive", + "resolved": "8.0.1", + "contentHash": "CoCRHFym33aUSf/NtWSVSZa99dkd0Hm7OCZUxORBjRB16LNhIEOf8THPqzIYlvKM0nNDAPTRBa1FxEECrgaxxA==" + }, + "Unosquare.Swan.Lite": { + "type": "Transitive", + "resolved": "3.1.0", + "contentHash": "X3s5QE/KMj3WAPFqFve7St+Ds10BB50u8kW8PmKIn7FVkn7yEXe9Yxr2htt1WV85DRqfFR0MN/BUNHkGHtL4OQ==" + }, + "ZstdSharp.Port": { + "type": "Transitive", + "resolved": "0.8.5", + "contentHash": "TR4j17WeVSEb3ncgL2NqlXEqcy04I+Kk9CaebNDplUeL8XOgjkZ7fP4Wg4grBdPLIqsV86p2QaXTkZoRMVOsew==" + }, + "ottergui": { + "type": "Project", + "dependencies": { + "JetBrains.Annotations": "[2024.3.0, )", + "Microsoft.Extensions.DependencyInjection": "[9.0.2, )" + } + }, + "penumbra.api": { + "type": "Project" + }, + "penumbra.crashhandler": { + "type": "Project" + }, + "penumbra.gamedata": { + "type": "Project", + "dependencies": { + "FlatSharp.Compiler": "[7.9.0, )", + "FlatSharp.Runtime": "[7.9.0, )", + "OtterGui": "[1.0.0, )", + "Penumbra.Api": "[5.13.0, )", + "Penumbra.String": "[1.0.6, )" + } + }, + "penumbra.string": { + "type": "Project" + } + } + } +} \ No newline at end of file diff --git a/Penumbra/tsmLogo.png b/Penumbra/tsmLogo.png new file mode 100644 index 00000000..caae3825 Binary files /dev/null and b/Penumbra/tsmLogo.png differ diff --git a/README.md b/README.md index 401d4be6..d4be2605 100644 --- a/README.md +++ b/README.md @@ -7,13 +7,25 @@ Penumbra is a runtime mod loader for FINAL FANTASY XIV, with a bunch of other us * Resolve conflicts between mods by changing mod order * Files can be edited and are often replicated in-game after a map change or closing and reopening a window -## Current Status -Penumbra, in its current state, is not intended for widespread use. It is mainly aimed at developers and people who don't need their hands held (for now). - -We're working towards a 1.0 release, and you can follow it's progress [here](https://github.com/xivdev/Penumbra/projects/1). +## Support +Either open an issue here or join us in [Discord](https://discord.gg/kVva7DHV4r). ## Contributing Contributions are welcome, but please make an issue first before writing any code. It's possible what you want to implement is out of scope for this project, or could be reworked so that it would provide greater benefit. ## TexTools Mods Penumbra has support for most TexTools modpacks however this is provided on a best-effort basis and support is not guaranteed. Built in tooling will be added to Penumbra over time to avoid many common TexTools use cases. + +## Installing +While this project is still a work in progress, you can use it by adding the following URL to the custom plugin repositories list in your Dalamud settings +An image-based install (and usage) guide to do this is provided by unaffiliated user Serenity: https://reniguide.info/ + +1. `/xlsettings` -> Experimental tab +2. Copy and paste the repo.json link below +3. Click on the + button +4. Click on the "Save and Close" button +5. You will now see Penumbra listed in the Available Plugins tab in the Dalamud Plugin Installer +6. Do not forget to actually install Penumbra from this tab. + +Please do not install Penumbra manually by downloading a release zip and unpacking it into your devPlugins folder. That will require manually updating Penumbra and you will miss out on features and bug fixes as you won't get update notifications automatically. Any manually installed copies of Penumbra should be removed before switching to the custom plugin respository method, as they will conflict. +- https://raw.githubusercontent.com/xivdev/Penumbra/master/repo.json diff --git a/base_repo.json b/base_repo.json deleted file mode 100644 index 2e3a2a06..00000000 --- a/base_repo.json +++ /dev/null @@ -1,22 +0,0 @@ -[ - { - "Author": "Adam", - "Name": "Penumbra", - "Description": "Runtime mod loader and manager.", - "InternalName": "Penumbra", - "AssemblyVersion": "1.0.0.0", - "TestingAssemblyVersion": "1.0.0.0", - "RepoUrl": "https://github.com/xivdev/Penumbra", - "ApplicableVersion": "any", - "DalamudApiLevel": 4, - "IsHide": "False", - "IsTestingExclusive": "False", - "DownloadCount": 0, - "LastUpdate": 0, - "LoadPriority": 69420, - "DownloadLinkInstall": "https://github.com/xivdev/Penumbra/releases/download/1.0.0.0/Penumbra.zip", - "DownloadLinkTesting": "https://github.com/xivdev/Penumbra/releases/download/1.0.0.0/Penumbra.zip", - "DownloadLinkUpdate": "https://github.com/xivdev/Penumbra/releases/download/1.0.0.0/Penumbra.zip", - "IconUrl": "https://raw.githubusercontent.com/xivdev/Penumbra/master/images/icon.png" - } -] \ No newline at end of file diff --git a/repo.json b/repo.json index 49d39441..583e5e52 100644 --- a/repo.json +++ b/repo.json @@ -1,21 +1,26 @@ [ - { - "Author": "Adam", - "Name": "Penumbra", - "Description": "Runtime mod loader and manager.", - "InternalName": "Penumbra", - "AssemblyVersion": "0.4.4.5", - "TestingAssemblyVersion": "0.4.4.5", - "RepoUrl": "https://github.com/xivdev/Penumbra", - "ApplicableVersion": "any", - "DalamudApiLevel": 4, - "IsHide": "False", - "IsTestingExclusive": "False", - "DownloadCount": 0, - "LastUpdate": 0, - "DownloadLinkInstall": "https://github.com/xivdev/Penumbra/releases/download/0.4.4.5/Penumbra.zip", - "DownloadLinkTesting": "https://github.com/xivdev/Penumbra/releases/download/0.4.4.5/Penumbra.zip", - "DownloadLinkUpdate": "https://github.com/xivdev/Penumbra/releases/download/0.4.4.5/Penumbra.zip", - "IconUrl": "https://raw.githubusercontent.com/xivdev/Penumbra/master/images/icon.png" - } + { + "Author": "Ottermandias, Nylfae, Adam, Wintermute", + "Name": "Penumbra", + "Punchline": "Runtime mod loader and manager.", + "Description": "Runtime mod loader and manager.", + "InternalName": "Penumbra", + "AssemblyVersion": "1.5.1.12", + "TestingAssemblyVersion": "1.5.1.12", + "RepoUrl": "https://github.com/xivdev/Penumbra", + "ApplicableVersion": "any", + "DalamudApiLevel": 14, + "TestingDalamudApiLevel": 14, + "IsHide": "False", + "IsTestingExclusive": "False", + "DownloadCount": 0, + "LastUpdate": 0, + "LoadPriority": 69420, + "LoadRequiredState": 2, + "LoadSync": true, + "DownloadLinkInstall": "https://github.com/xivdev/Penumbra/releases/download/1.5.1.12/Penumbra.zip", + "DownloadLinkTesting": "https://github.com/xivdev/Penumbra/releases/download/1.5.1.12/Penumbra.zip", + "DownloadLinkUpdate": "https://github.com/xivdev/Penumbra/releases/download/1.5.1.12/Penumbra.zip", + "IconUrl": "https://raw.githubusercontent.com/xivdev/Penumbra/master/images/icon.png" + } ] diff --git a/schemas/default_mod.json b/schemas/default_mod.json new file mode 100644 index 00000000..8f50c5db --- /dev/null +++ b/schemas/default_mod.json @@ -0,0 +1,19 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "allOf": [ + { + "type": "object", + "properties": { + "Version": { + "description": "Mod Container version, currently unused.", + "type": "integer", + "minimum": 0, + "maximum": 0 + } + } + }, + { + "$ref": "structs/container.json" + } + ] +} diff --git a/schemas/group.json b/schemas/group.json new file mode 100644 index 00000000..4c37b631 --- /dev/null +++ b/schemas/group.json @@ -0,0 +1,57 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "Version": { + "description": "Mod Container version, currently unused.", + "type": "integer" + }, + "Name": { + "description": "Name of the group.", + "type": "string", + "minLength": 1 + }, + "Description": { + "description": "Description of the group.", + "type": [ "string", "null" ] + }, + "Image": { + "description": "Relative path to a preview image for the group. Unused by Penumbra, present for round-trip import/export of TexTools-generated mods.", + "type": ["string", "null" ] + }, + "Page": { + "description": "TexTools page of the group. Unused by Penumbra, present for round-trip import/export of TexTools-generated mods.", + "type": "integer" + }, + "Priority": { + "description": "Priority of the group. If several groups define conflicting files or manipulations, the highest priority wins.", + "type": "integer" + }, + "Type": { + "description": "Group type. Single groups have one and only one of their options active at any point. Multi groups can have zero, one or many of their options active. Combining groups have n options, 2^n containers, and will have one and only one container active depending on the selected options.", + "enum": [ "Single", "Multi", "Imc", "Combining" ] + }, + "DefaultSettings": { + "description": "Default configuration for the group.", + "type": "integer" + } + }, + "required": [ + "Name", + "Type" + ], + "oneOf": [ + { + "$ref": "structs/group_combining.json" + }, + { + "$ref": "structs/group_imc.json" + }, + { + "$ref": "structs/group_multi.json" + }, + { + "$ref": "structs/group_single.json" + } + ] +} diff --git a/schemas/local_mod_data-v3.json b/schemas/local_mod_data-v3.json new file mode 100644 index 00000000..c50e130e --- /dev/null +++ b/schemas/local_mod_data-v3.json @@ -0,0 +1,41 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Local Penumbra Mod Data", + "description": "The locally stored data for an installed mod in Penumbra", + "type": "object", + "properties": { + "FileVersion": { + "description": "Major version of the local data schema used.", + "type": "integer", + "minimum": 3, + "maximum": 3 + }, + "ImportDate": { + "description": "The date and time of the installation of the mod as a Unix Epoch millisecond timestamp.", + "type": "integer" + }, + "LocalTags": { + "description": "User-defined local tags for the mod.", + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "uniqueItems": true + }, + "Favorite": { + "description": "Whether the mod is favourited by the user.", + "type": "boolean" + }, + "PreferredChangedItems": { + "description": "Preferred items to list as the main item of a group.", + "type": "array", + "items": { + "minimum": 0, + "type": "integer" + }, + "uniqueItems": true + } + }, + "required": [ "FileVersion" ] +} diff --git a/schemas/mod_meta-v3.json b/schemas/mod_meta-v3.json new file mode 100644 index 00000000..6fc68714 --- /dev/null +++ b/schemas/mod_meta-v3.json @@ -0,0 +1,69 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Penumbra Mod Metadata", + "description": "Metadata of a Penumbra mod.", + "type": "object", + "properties": { + "FileVersion": { + "description": "Major version of the metadata schema used.", + "type": "integer", + "minimum": 3, + "maximum": 3 + }, + "Name": { + "description": "Name of the mod.", + "type": "string", + "minLength": 1 + }, + "Author": { + "description": "Author of the mod.", + "type": [ "string", "null" ] + }, + "Description": { + "description": "Description of the mod. Can span multiple paragraphs.", + "type": [ "string", "null" ] + }, + "Image": { + "description": "Relative path to a preview image for the mod. Unused by Penumbra, present for round-trip import/export of TexTools-generated mods.", + "type": [ "string", "null" ] + }, + "Version": { + "description": "Version of the mod. Can be an arbitrary string.", + "type": [ "string", "null" ] + }, + "Website": { + "description": "URL of the web page of the mod.", + "type": [ "string", "null" ] + }, + "ModTags": { + "description": "Author-defined tags for the mod.", + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "uniqueItems": true + }, + "DefaultPreferredItems": { + "description": "Default preferred items to list as the main item of a group managed by the mod creator.", + "type": "array", + "items": { + "minimum": 0, + "type": "integer" + }, + "uniqueItems": true + }, + "RequiredFeatures": { + "description": "A list of required features by name.", + "type": "array", + "items": { + "type": "string" + }, + "uniqueItems": true + } + }, + "required": [ + "FileVersion", + "Name" + ] +} diff --git a/schemas/shpk_devkit.json b/schemas/shpk_devkit.json new file mode 100644 index 00000000..f03fbb05 --- /dev/null +++ b/schemas/shpk_devkit.json @@ -0,0 +1,499 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "ShaderKeys": { + "type": "object", + "patternProperties": { + "^\\d+$": { + "$ref": "#/$defs/ShaderKey" + } + }, + "additionalProperties": false + }, + "Comment": { + "$ref": "#/$defs/MayVary" + }, + "Samplers": { + "type": "object", + "patternProperties": { + "^\\d+$": { + "$ref": "#/$defs/MayVary" + } + }, + "additionalProperties": false + }, + "Constants": { + "type": "object", + "patternProperties": { + "^\\d+$": { + "$ref": "#/$defs/MayVary" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false, + "$defs": { + "ShaderKeyValue": { + "type": "object", + "properties": { + "Label": { + "type": "string" + }, + "Description": { + "type": "string" + } + }, + "additionalProperties": false + }, + "ShaderKey": { + "type": "object", + "properties": { + "Label": { + "type": "string" + }, + "Description": { + "type": "string" + }, + "Values": { + "type": "object", + "patternProperties": { + "^\\d+$": { + "$ref": "#/$defs/ShaderKeyValue" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + "Varying": { + "type": "object", + "properties": { + "Vary": { + "type": "array", + "items": { + "$ref": "#/$defs/LaxInteger" + } + }, + "Selectors": { + "description": "Keys are Σ 31^i shaderKey(Vary[i]).", + "type": "object", + "patternProperties": { + "^\\d+$": { + "type": "integer" + } + }, + "additionalProperties": false + }, + "Items": { + "type": "array", + "$comment": "Varying is defined by constraining this array's items to T" + } + }, + "required": [ + "Vary", + "Selectors", + "Items" + ], + "additionalProperties": false + }, + "MayVary": { + "oneOf": [ + { + "type": ["string", "null"] + }, { + "allOf": [ + { + "$ref": "#/$defs/Varying" + }, { + "type": "object", + "properties": { + "Items": { + "type": "array", + "items": { + "type": ["string", "null"] + } + } + } + } + ] + } + ] + }, + "Sampler": { + "type": ["object", "null"], + "properties": { + "Label": { + "type": "string" + }, + "Description": { + "type": "string" + }, + "DefaultTexture": { + "type": "string", + "pattern": "^[^/\\\\][^\\\\]*$" + } + }, + "additionalProperties": false + }, + "MayVary": { + "oneOf": [ + { + "$ref": "#/$defs/Sampler" + }, { + "allOf": [ + { + "$ref": "#/$defs/Varying" + }, { + "type": "object", + "properties": { + "Items": { + "type": "array", + "items": { + "$ref": "#/$defs/Sampler" + } + } + } + } + ] + } + ] + }, + "ConstantBase": { + "type": "object", + "properties": { + "Offset": { + "description": "Defaults to 0. Mutually exclusive with ByteOffset.", + "type": "integer", + "minimum": 0 + }, + "Length": { + "description": "Defaults to up to the end. Mutually exclusive with ByteLength.", + "type": "integer", + "minimum": 0 + }, + "ByteOffset": { + "description": "Defaults to 0. Mutually exclusive with Offset.", + "type": "integer", + "minimum": 0 + }, + "ByteLength": { + "description": "Defaults to up to the end. Mutually exclusive with Length.", + "type": "integer", + "minimum": 0 + }, + "Group": { + "description": "Defaults to \"Further Constants\".", + "type": "string" + }, + "Label": { + "type": "string" + }, + "Description": { + "description": "Defaults to empty.", + "type": "string" + }, + "Type": { + "description": "Defaults to Float.", + "enum": ["Hidden", "Float", "Integer", "Color", "Enum", "Int32", "Int32Enum", "Int8", "Int8Enum", "Int16", "Int16Enum", "Int64", "Int64Enum", "Half", "Double", "TileIndex", "SphereMapIndex"] + } + }, + "not": { + "anyOf": [ + { + "required": ["Offset", "ByteOffset"] + }, { + "required": ["Length", "ByteLenngth"] + } + ] + } + }, + "HiddenConstant": { + "type": "object", + "properties": { + "Type": { + "const": "Hidden" + } + }, + "required": [ + "Type" + ], + "allOf": [ + { + "$ref": "#/$defs/ConstantBase" + } + ], + "unevaluatedProperties": false + }, + "FloatConstant": { + "type": "object", + "properties": { + "Type": { + "enum": ["Float", "Half", "Double"] + }, + "Minimum": { + "description": "Defaults to -∞.", + "type": "number" + }, + "Maximum": { + "description": "Defaults to ∞.", + "type": "number" + }, + "Speed": { + "description": "Defaults to 0.1.", + "type": "number", + "minimum": 0 + }, + "RelativeSpeed": { + "description": "Defaults to 0.", + "type": "number", + "minimum": 0 + }, + "Exponent": { + "description": "Defaults to 1. Uses an odd pseudo-power function, f(x) = sgn(x) |x|^n.", + "type": "number" + }, + "Factor": { + "description": "Defaults to 1.", + "type": "number" + }, + "Bias": { + "description": "Defaults to 0.", + "type": "number" + }, + "Precision": { + "description": "Defaults to 3.", + "type": "integer", + "minimum": 0, + "maximum": 9 + }, + "Slider": { + "description": "Defaults to true. Drag has priority over this.", + "type": "boolean" + }, + "Drag": { + "description": "Defaults to true. Has priority over Slider.", + "type": "boolean" + }, + "Unit": { + "description": "Defaults to no unit.", + "type": "string" + } + }, + "required": [ + "Label" + ], + "allOf": [ + { + "$ref": "#/$defs/ConstantBase" + } + ], + "unevaluatedProperties": false + }, + "IntConstant": { + "type": "object", + "properties": { + "Type": { + "enum": ["Integer", "Int32", "Int8", "Int16", "Int64"] + }, + "Minimum": { + "description": "Defaults to -2^N, N being the explicit integer width specified in the type, or 32 for Int.", + "type": "number" + }, + "Maximum": { + "description": "Defaults to 2^N - 1, N being the explicit integer width specified in the type, or 32 for Int.", + "type": "number" + }, + "Speed": { + "description": "Defaults to 0.25.", + "type": "number", + "minimum": 0 + }, + "RelativeSpeed": { + "description": "Defaults to 0.", + "type": "number", + "minimum": 0 + }, + "Factor": { + "description": "Defaults to 1.", + "type": "number" + }, + "Bias": { + "description": "Defaults to 0.", + "type": "number" + }, + "Hex": { + "description": "Defaults to false. Has priority over Slider and Drag.", + "type": "boolean" + }, + "Slider": { + "description": "Defaults to true. Hex and Drag have priority over this.", + "type": "boolean" + }, + "Drag": { + "description": "Defaults to true. Has priority over Slider, but Hex has priority over this.", + "type": "boolean" + }, + "Unit": { + "description": "Defaults to no unit.", + "type": "string" + } + }, + "required": [ + "Label", + "Type" + ], + "allOf": [ + { + "$ref": "#/$defs/ConstantBase" + } + ], + "unevaluatedProperties": false + }, + "ColorConstant": { + "type": "object", + "properties": { + "Type": { + "const": "Color" + }, + "SquaredRgb": { + "description": "Defaults to false. Uses an odd pseudo-square function, f(x) = sgn(x) |x|².", + "type": "boolean" + }, + "Clamped": { + "description": "Defaults to false.", + "type": "boolean" + } + }, + "required": [ + "Label", + "Type" + ], + "allOf": [ + { + "$ref": "#/$defs/ConstantBase" + } + ], + "unevaluatedProperties": false + }, + "EnumValue": { + "type": "object", + "properties": { + "Label": { + "type": "string" + }, + "Description": { + "type": "string" + }, + "Value": { + "type": "number" + } + }, + "required": [ + "Label", + "Value" + ], + "additionalProperties": false + }, + "EnumConstant": { + "type": "object", + "properties": { + "Type": { + "enum": ["Enum", "Int32Enum", "Int8Enum", "Int16Enum", "Int64Enum"] + }, + "Values": { + "type": "array", + "items": { + "$ref": "#/$defs/EnumValue" + } + } + }, + "required": [ + "Label", + "Type" + ], + "allOf": [ + { + "$ref": "#/$defs/ConstantBase" + } + ], + "unevaluatedProperties": false + }, + "SpecialConstant": { + "type": "object", + "properties": { + "Type": { + "enum": ["TileIndex", "SphereMapIndex"] + } + }, + "required": [ + "Label", + "Type" + ], + "allOf": [ + { + "$ref": "#/$defs/ConstantBase" + } + ], + "unevaluatedProperties": false + }, + "Constant": { + "oneOf": [ + { + "$ref": "#/$defs/HiddenConstant" + }, { + "$ref": "#/$defs/FloatConstant" + }, { + "$ref": "#/$defs/IntConstant" + }, { + "$ref": "#/$defs/ColorConstant" + }, { + "$ref": "#/$defs/EnumConstant" + }, { + "$ref": "#/$defs/SpecialConstant" + } + ] + }, + "MayVary": { + "oneOf": [ + { + "type": ["array", "null"], + "items": { + "$ref": "#/$defs/Constant" + } + }, { + "allOf": [ + { + "$ref": "#/$defs/Varying" + }, { + "type": "object", + "properties": { + "Items": { + "type": "array", + "items": { + "type": ["array", "null"], + "items": { + "$ref": "#/$defs/Constant" + } + } + } + } + } + ] + } + ] + }, + "LaxInteger": { + "oneOf": [ + { + "type": "integer" + }, { + "type": "string", + "pattern": "^\\d+$" + } + ] + } + } +} diff --git a/schemas/structs/container.json b/schemas/structs/container.json new file mode 100644 index 00000000..74db4a23 --- /dev/null +++ b/schemas/structs/container.json @@ -0,0 +1,34 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "Files": { + "description": "File redirections in this container. Keys are game paths, values are relative file paths.", + "type": [ "object", "null" ], + "patternProperties": { + "^[^/\\\\.:?][^\\\\?:]+$": { + "type": "string", + "pattern": "^[^/\\\\.:?][^?:]+$" + } + }, + "additionalProperties": false + }, + "FileSwaps": { + "description": "File swaps in this container. Keys are original game paths, values are actual game paths.", + "type": [ "object", "null" ], + "patternProperties": { + "^[^/\\\\.?:][^\\\\?:]+$": { + "type": "string", + "pattern": "^[^/\\\\.:?][^?:]+$" + } + }, + "additionalProperties": false + }, + "Manipulations": { + "type": [ "array", "null" ], + "items": { + "$ref": "manipulation.json" + } + } + } +} diff --git a/schemas/structs/group_combining.json b/schemas/structs/group_combining.json new file mode 100644 index 00000000..e42edcb8 --- /dev/null +++ b/schemas/structs/group_combining.json @@ -0,0 +1,31 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "Type": { + "const": "Combining" + }, + "Options": { + "type": "array", + "items": { + "$ref": "option.json" + } + }, + "Containers": { + "type": "array", + "items": { + "allOf": [ + { + "$ref": "container.json" + }, + { + "properties": { + "Name": { + "type": [ "string", "null" ] + } + } + } + ] + } + } + } +} diff --git a/schemas/structs/group_imc.json b/schemas/structs/group_imc.json new file mode 100644 index 00000000..48a04bd9 --- /dev/null +++ b/schemas/structs/group_imc.json @@ -0,0 +1,50 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "Type": { + "const": "Imc" + }, + "AllVariants": { + "type": "boolean" + }, + "OnlyAttributes": { + "type": "boolean" + }, + "Identifier": { + "$ref": "meta_imc.json#ImcIdentifier" + }, + "DefaultEntry": { + "$ref": "meta_imc.json#ImcEntry" + }, + "Options": { + "type": "array", + "items": { + "$ref": "option.json", + "oneOf": [ + { + "properties": { + "AttributeMask": { + "type": "integer", + "minimum": 0, + "maximum": 1023 + } + }, + "required": [ + "AttributeMask" + ] + }, + { + "properties": { + "IsDisableSubMod": { + "const": true + } + }, + "required": [ + "IsDisableSubMod" + ] + } + ] + } + } + } +} diff --git a/schemas/structs/group_multi.json b/schemas/structs/group_multi.json new file mode 100644 index 00000000..ca7d4dfa --- /dev/null +++ b/schemas/structs/group_multi.json @@ -0,0 +1,32 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "Type": { + "const": "Multi" + }, + "Options": { + "type": "array", + "items": { + "allOf": [ + { + "$ref": "option.json" + }, + { + "$ref": "container.json" + }, + { + "properties": { + "Priority": { + "type": "integer" + } + } + } + ] + } + } + } +} + + + diff --git a/schemas/structs/group_single.json b/schemas/structs/group_single.json new file mode 100644 index 00000000..24cda88d --- /dev/null +++ b/schemas/structs/group_single.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "Type": { + "const": "Single" + }, + "Options": { + "type": "array", + "items": { + "allOf": [ + { + "$ref": "option.json" + }, + { + "$ref": "container.json" + } + ] + } + } + } +} diff --git a/schemas/structs/manipulation.json b/schemas/structs/manipulation.json new file mode 100644 index 00000000..81f2cef3 --- /dev/null +++ b/schemas/structs/manipulation.json @@ -0,0 +1,115 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "Type": { + "enum": [ "Unknown", "Imc", "Eqdp", "Eqp", "Est", "Gmp", "Rsp", "GlobalEqp", "Atch", "Shp", "Atr" ] + }, + "Manipulation": { + "type": "object" + } + }, + "required": [ "Type", "Manipulation" ], + "oneOf": [ + { + "properties": { + "Type": { + "const": "Imc" + }, + "Manipulation": { + "$ref": "meta_imc.json" + } + } + }, + { + "properties": { + "Type": { + "const": "Eqdp" + }, + "Manipulation": { + "$ref": "meta_eqdp.json" + } + } + }, + { + "properties": { + "Type": { + "const": "Eqp" + }, + "Manipulation": { + "$ref": "meta_eqp.json" + } + } + }, + { + "properties": { + "Type": { + "const": "Est" + }, + "Manipulation": { + "$ref": "meta_est.json" + } + } + }, + { + "properties": { + "Type": { + "const": "Gmp" + }, + "Manipulation": { + "$ref": "meta_gmp.json" + } + } + }, + { + "properties": { + "Type": { + "const": "Rsp" + }, + "Manipulation": { + "$ref": "meta_rsp.json" + } + } + }, + { + "properties": { + "Type": { + "const": "GlobalEqp" + }, + "Manipulation": { + "$ref": "meta_geqp.json" + } + } + }, + { + "properties": { + "Type": { + "const": "Atch" + }, + "Manipulation": { + "$ref": "meta_atch.json" + } + } + }, + { + "properties": { + "Type": { + "const": "Shp" + }, + "Manipulation": { + "$ref": "meta_shp.json" + } + } + }, + { + "properties": { + "Type": { + "const": "Atr" + }, + "Manipulation": { + "$ref": "meta_atr.json" + } + } + } + ] +} diff --git a/schemas/structs/meta_atch.json b/schemas/structs/meta_atch.json new file mode 100644 index 00000000..3c9cbef5 --- /dev/null +++ b/schemas/structs/meta_atch.json @@ -0,0 +1,67 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "Entry": { + "type": "object", + "properties": { + "Bone": { + "type": "string", + "maxLength": 34 + }, + "Scale": { + "type": "number" + }, + "OffsetX": { + "type": "number" + }, + "OffsetY": { + "type": "number" + }, + "OffsetZ": { + "type": "number" + }, + "RotationX": { + "type": "number" + }, + "RotationY": { + "type": "number" + }, + "RotationZ": { + "type": "number" + } + }, + "required": [ + "Bone", + "Scale", + "OffsetX", + "OffsetY", + "OffsetZ", + "RotationX", + "RotationY", + "RotationZ" + ] + }, + "Gender": { + "$ref": "meta_enums.json#Gender" + }, + "Race": { + "$ref": "meta_enums.json#ModelRace" + }, + "Type": { + "type": "string", + "minLength": 1, + "maxLength": 4 + }, + "Index": { + "$ref": "meta_enums.json#U16" + } + }, + "required": [ + "Entry", + "Gender", + "Race", + "Type", + "Index" + ] +} diff --git a/schemas/structs/meta_atr.json b/schemas/structs/meta_atr.json new file mode 100644 index 00000000..479d4127 --- /dev/null +++ b/schemas/structs/meta_atr.json @@ -0,0 +1,27 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "Entry": { + "type": "boolean" + }, + "Slot": { + "$ref": "meta_enums.json#HumanSlot" + }, + "Id": { + "$ref": "meta_enums.json#U16" + }, + "Attribute": { + "type": "string", + "minLength": 5, + "maxLength": 30, + "pattern": "^atrx_" + }, + "GenderRaceCondition": { + "enum": [ 0, 101, 201, 301, 401, 501, 601, 701, 801, 901, 1001, 1101, 1201, 1301, 1401, 1501, 1601, 1701, 1801 ] + } + }, + "required": [ + "Attribute" + ] +} diff --git a/schemas/structs/meta_enums.json b/schemas/structs/meta_enums.json new file mode 100644 index 00000000..bad184e0 --- /dev/null +++ b/schemas/structs/meta_enums.json @@ -0,0 +1,65 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$defs": { + "EquipSlot": { + "$anchor": "EquipSlot", + "enum": [ "Unknown", "MainHand", "OffHand", "Head", "Body", "Hands", "Belt", "Legs", "Feet", "Ears", "Neck", "Wrists", "RFinger", "BothHand", "LFinger", "HeadBody", "BodyHandsLegsFeet", "SoulCrystal", "LegsFeet", "FullBody", "BodyHands", "BodyLegsFeet", "ChestHands", "Nothing", "All" ] + }, + "HumanSlot": { + "$anchor": "HumanSlot", + "enum": [ "Head", "Body", "Hands", "Legs", "Feet", "Ears", "Neck", "Wrists", "RFinger", "LFinger", "Hair", "Face", "Ear", "Glasses", "Unknown" ] + }, + "Gender": { + "$anchor": "Gender", + "enum": [ "Unknown", "Male", "Female", "MaleNpc", "FemaleNpc" ] + }, + "ModelRace": { + "$anchor": "ModelRace", + "enum": [ "Unknown", "Midlander", "Highlander", "Elezen", "Lalafell", "Miqote", "Roegadyn", "AuRa", "Hrothgar", "Viera" ] + }, + "ObjectType": { + "$anchor": "ObjectType", + "enum": [ "Unknown", "Vfx", "DemiHuman", "Accessory", "World", "Housing", "Monster", "Icon", "LoadingScreen", "Map", "Interface", "Equipment", "Character", "Weapon", "Font" ] + }, + "BodySlot": { + "$anchor": "BodySlot", + "enum": [ "Unknown", "Hair", "Face", "Tail", "Body", "Zear" ] + }, + "SubRace": { + "$anchor": "SubRace", + "enum": [ "Unknown", "Midlander", "Highlander", "Wildwood", "Duskwight", "Plainsfolk", "Dunesfolk", "SeekerOfTheSun", "KeeperOfTheMoon", "Seawolf", "Hellsguard", "Raen", "Xaela", "Helion", "Lost", "Rava", "Veena" ] + }, + "ShapeConnectorCondition": { + "$anchor": "ShapeConnectorCondition", + "enum": [ "None", "Wrists", "Waist", "Ankles" ] + }, + "U8": { + "$anchor": "U8", + "oneOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 255 + }, + { + "type": "string", + "pattern": "^0*(1[0-9][0-9]|[0-9]?[0-9]|2[0-4][0-9]|25[0-5])$" + } + ] + }, + "U16": { + "$anchor": "U16", + "oneOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + { + "type": "string", + "pattern": "^0*([1-5][0-9]{4}|[0-9]{0,4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5])$" + } + ] + } + } +} diff --git a/schemas/structs/meta_eqdp.json b/schemas/structs/meta_eqdp.json new file mode 100644 index 00000000..f27606b9 --- /dev/null +++ b/schemas/structs/meta_eqdp.json @@ -0,0 +1,30 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "Entry": { + "type": "integer", + "minimum": 0, + "maximum": 1023 + }, + "Gender": { + "$ref": "meta_enums.json#Gender" + }, + "Race": { + "$ref": "meta_enums.json#ModelRace" + }, + "SetId": { + "$ref": "meta_enums.json#U16" + }, + "Slot": { + "$ref": "meta_enums.json#EquipSlot" + } + }, + "required": [ + "Entry", + "Gender", + "Race", + "SetId", + "Slot" + ] +} diff --git a/schemas/structs/meta_eqp.json b/schemas/structs/meta_eqp.json new file mode 100644 index 00000000..c829d7a7 --- /dev/null +++ b/schemas/structs/meta_eqp.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "Entry": { + "type": "integer" + }, + "SetId": { + "$ref": "meta_enums.json#U16" + }, + "Slot": { + "$ref": "meta_enums.json#EquipSlot" + } + }, + "required": [ + "Entry", + "SetId", + "Slot" + ] +} diff --git a/schemas/structs/meta_est.json b/schemas/structs/meta_est.json new file mode 100644 index 00000000..22bce12b --- /dev/null +++ b/schemas/structs/meta_est.json @@ -0,0 +1,28 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "Entry": { + "$ref": "meta_enums.json#U16" + }, + "Gender": { + "$ref": "meta_enums.json#Gender" + }, + "Race": { + "$ref": "meta_enums.json#ModelRace" + }, + "SetId": { + "$ref": "meta_enums.json#U16" + }, + "Slot": { + "enum": [ "Hair", "Face", "Body", "Head" ] + } + }, + "required": [ + "Entry", + "Gender", + "Race", + "SetId", + "Slot" + ] +} diff --git a/schemas/structs/meta_geqp.json b/schemas/structs/meta_geqp.json new file mode 100644 index 00000000..e38fbb86 --- /dev/null +++ b/schemas/structs/meta_geqp.json @@ -0,0 +1,40 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "Condition": { + "$ref": "meta_enums.json#U16" + }, + "Type": { + "enum": [ "DoNotHideEarrings", "DoNotHideNecklace", "DoNotHideBracelets", "DoNotHideRingR", "DoNotHideRingL", "DoNotHideHrothgarHats", "DoNotHideVieraHats", "HideHorns", "HideVieraEars", "HideMiqoteEars" ] + } + }, + "required": [ "Type" ], + "oneOf": [ + { + "properties": { + "Type": { + "const": [ "DoNotHideHrothgarHats", "DoNotHideVieraHats", "HideHorns", "HideVieraEars", "HideMiqoteEars" ] + }, + "Condition": { + "const": 0 + } + } + }, + { + "properties": { + "Type": { + "const": [ "DoNotHideHrothgarHats", "DoNotHideVieraHats", "HideHorns", "HideVieraEars", "HideMiqoteEars" ] + } + } + }, + { + "properties": { + "Type": { + "const": [ "DoNotHideEarrings", "DoNotHideNecklace", "DoNotHideBracelets", "DoNotHideRingR", "DoNotHideRingL" ] + }, + "Condition": {} + } + } + ] +} diff --git a/schemas/structs/meta_gmp.json b/schemas/structs/meta_gmp.json new file mode 100644 index 00000000..bf1fb1df --- /dev/null +++ b/schemas/structs/meta_gmp.json @@ -0,0 +1,59 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "Entry": { + "type": "object", + "properties": { + "Enabled": { + "type": "boolean" + }, + "Animated": { + "type": "boolean" + }, + "RotationA": { + "type": "integer", + "minimum": 0, + "maximum": 1023 + }, + "RotationB": { + "type": "integer", + "minimum": 0, + "maximum": 1023 + }, + "RotationC": { + "type": "integer", + "minimum": 0, + "maximum": 1023 + }, + "UnknownA": { + "type": "integer", + "minimum": 0, + "maximum": 15 + }, + "UnknownB": { + "type": "integer", + "minimum": 0, + "maximum": 15 + } + }, + "required": [ + "Enabled", + "Animated", + "RotationA", + "RotationB", + "RotationC", + "UnknownA", + "UnknownB" + ], + "additionalProperties": false + }, + "SetId": { + "$ref": "meta_enums.json#U16" + } + }, + "required": [ + "Entry", + "SetId" + ] +} diff --git a/schemas/structs/meta_imc.json b/schemas/structs/meta_imc.json new file mode 100644 index 00000000..aa9a4fca --- /dev/null +++ b/schemas/structs/meta_imc.json @@ -0,0 +1,87 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "Entry": { + "$ref": "#ImcEntry" + } + }, + "required": [ + "Entry" + ], + "allOf": [ + { + "$ref": "#ImcIdentifier" + } + ], + "$defs": { + "ImcIdentifier": { + "type": "object", + "properties": { + "PrimaryId": { + "$ref": "meta_enums.json#U16" + }, + "SecondaryId": { + "$ref": "meta_enums.json#U16" + }, + "Variant": { + "$ref": "meta_enums.json#U8" + }, + "ObjectType": { + "$ref": "meta_enums.json#ObjectType" + }, + "EquipSlot": { + "$ref": "meta_enums.json#EquipSlot" + }, + "BodySlot": { + "$ref": "meta_enums.json#BodySlot" + } + }, + "$anchor": "ImcIdentifier", + "required": [ + "PrimaryId", + "SecondaryId", + "Variant", + "ObjectType", + "EquipSlot", + "BodySlot" + ] + }, + "ImcEntry": { + "type": "object", + "properties": { + "MaterialId": { + "$ref": "meta_enums.json#U8" + }, + "DecalId": { + "$ref": "meta_enums.json#U8" + }, + "VfxId": { + "$ref": "meta_enums.json#U8" + }, + "MaterialAnimationId": { + "$ref": "meta_enums.json#U8" + }, + "AttributeMask": { + "type": "integer", + "minimum": 0, + "maximum": 1023 + }, + "SoundId": { + "type": "integer", + "minimum": 0, + "maximum": 63 + } + }, + "$anchor": "ImcEntry", + "required": [ + "MaterialId", + "DecalId", + "VfxId", + "MaterialAnimationId", + "AttributeMask", + "SoundId" + ] + } + } +} diff --git a/schemas/structs/meta_rsp.json b/schemas/structs/meta_rsp.json new file mode 100644 index 00000000..3354281b --- /dev/null +++ b/schemas/structs/meta_rsp.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "Entry": { + "type": "number" + }, + "SubRace": { + "$ref": "meta_enums.json#SubRace" + }, + "Attribute": { + "enum": [ "MaleMinSize", "MaleMaxSize", "MaleMinTail", "MaleMaxTail", "FemaleMinSize", "FemaleMaxSize", "FemaleMinTail", "FemaleMaxTail", "BustMinX", "BustMinY", "BustMinZ", "BustMaxX", "BustMaxY", "BustMaxZ" ] + } + }, + "required": [ + "Entry", + "SubRace", + "Attribute" + ] +} diff --git a/schemas/structs/meta_shp.json b/schemas/structs/meta_shp.json new file mode 100644 index 00000000..cb7fd0ec --- /dev/null +++ b/schemas/structs/meta_shp.json @@ -0,0 +1,30 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "Entry": { + "type": "boolean" + }, + "Slot": { + "$ref": "meta_enums.json#HumanSlot" + }, + "Id": { + "$ref": "meta_enums.json#U16" + }, + "Shape": { + "type": "string", + "minLength": 5, + "maxLength": 30, + "pattern": "^shpx_" + }, + "ConnectorCondition": { + "$ref": "meta_enums.json#ShapeConnectorCondition" + }, + "GenderRaceCondition": { + "enum": [ 0, 101, 201, 301, 401, 501, 601, 701, 801, 901, 1001, 1101, 1201, 1301, 1401, 1501, 1601, 1701, 1801 ] + } + }, + "required": [ + "Shape" + ] +} diff --git a/schemas/structs/option.json b/schemas/structs/option.json new file mode 100644 index 00000000..c45ccfdb --- /dev/null +++ b/schemas/structs/option.json @@ -0,0 +1,24 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "Name": { + "description": "Name of the option.", + "type": "string", + "minLength": 1 + }, + "Description": { + "description": "Description of the option.", + "type": [ "string", "null" ] + }, + "Priority": { + "description": "Priority of the option. If several enabled options within the group define conflicting files or manipulations, the highest priority wins.", + "type": "integer" + }, + "Image": { + "description": "Unused by Penumbra.", + "type": [ "string", "null" ] + } + }, + "required": [ "Name" ] +}