├── .editorconfig ├── .gitattributes ├── .github └── workflows │ ├── Check.yml │ ├── Publish.yml │ └── TES3Merge.yml ├── .gitignore ├── .gitmodules ├── LICENSE ├── NuGet.Config ├── TES3Merge.Tests ├── FileLoader.cs ├── Installation │ ├── Morrowind.cs │ └── OpenMW.cs ├── Merger │ ├── ALCH.cs │ ├── CREA.cs │ ├── LEVC.cs │ └── NPC_.cs ├── Parser │ └── CELL.cs ├── Plugins │ ├── AOF Potions Recolored.esp │ ├── BTB's Game Improvements (Necro Edit) Tweaked.esp │ ├── Bob's Diverse Blood.ESP │ ├── F&F_NoSitters.ESP │ ├── F&F_base.esm │ ├── F&F_scarce.ESP │ ├── Morrowind.esm │ ├── Patch for Purists - Book Typos.ESP │ ├── Patch for Purists - Semi-Purist Fixes.ESP │ ├── Patch for Purists.esm │ ├── ST_Alchemy_Balance_Sri_1.4.esp │ ├── Wares-base.esm │ ├── merge_add_effects.esp │ ├── merge_base.esp │ ├── merge_edit_all.esp │ ├── merge_minor_tweaks.esp │ ├── tes3conv.exe │ ├── to_esp.bat │ └── to_json.bat ├── Properties │ ├── Resources.Designer.cs │ └── Resources.resx ├── RecordTest.cs ├── TES3Merge.Tests.csproj └── Utility.cs ├── TES3Merge.sln ├── TES3Merge ├── AssemblyInfo.cs ├── Commands │ ├── MergeCommand.cs │ ├── MultipatchCommand.cs │ └── VerifyCommand.cs ├── Extensions │ ├── GenericObjectExtensions.cs │ └── StreamExtensions.cs ├── Merger │ ├── CELL.cs │ ├── CLAS.cs │ ├── CREA.cs │ ├── FACT.cs │ ├── LEVC.cs │ ├── LEVI.cs │ ├── NPC_.cs │ └── Shared.cs ├── Program.cs ├── RecordMerger.cs ├── TES3Merge.csproj ├── TES3Merge.ini ├── Util │ ├── Bsa.cs │ ├── DataFile.cs │ ├── Installation.cs │ ├── ScopedStopwatch.cs │ └── Util.cs └── tes3merge_icon_by_markel.ico ├── changelog.md ├── readme.md ├── tes3merge_icon_by_markel.ico └── tes3merge_icon_by_markel.png /.editorconfig: -------------------------------------------------------------------------------- 1 | # Remove the line below if you want to inherit .editorconfig settings from higher directories 2 | root = true 3 | 4 | # C# files 5 | [*.cs] 6 | 7 | #### Core EditorConfig Options #### 8 | 9 | # Indentation and spacing 10 | indent_size = 4 11 | indent_style = space 12 | tab_width = 4 13 | 14 | # New line preferences 15 | end_of_line = crlf 16 | insert_final_newline = false 17 | 18 | #### .NET Coding Conventions #### 19 | 20 | # Organize usings 21 | dotnet_separate_import_directive_groups = false 22 | dotnet_sort_system_directives_first = false 23 | file_header_template = unset 24 | 25 | # this. and Me. preferences 26 | dotnet_style_qualification_for_event = false 27 | dotnet_style_qualification_for_field = false 28 | dotnet_style_qualification_for_method = false 29 | dotnet_style_qualification_for_property = false 30 | 31 | # Language keywords vs BCL types preferences 32 | dotnet_style_predefined_type_for_locals_parameters_members = true 33 | dotnet_style_predefined_type_for_member_access = true 34 | 35 | # Parentheses preferences 36 | dotnet_style_parentheses_in_arithmetic_binary_operators = always_for_clarity 37 | dotnet_style_parentheses_in_other_binary_operators = always_for_clarity 38 | dotnet_style_parentheses_in_other_operators = never_if_unnecessary 39 | dotnet_style_parentheses_in_relational_binary_operators = always_for_clarity 40 | 41 | # Modifier preferences 42 | dotnet_style_require_accessibility_modifiers = for_non_interface_members 43 | 44 | # Expression-level preferences 45 | dotnet_style_coalesce_expression = true 46 | dotnet_style_collection_initializer = true 47 | dotnet_style_explicit_tuple_names = true 48 | dotnet_style_namespace_match_folder = true 49 | dotnet_style_null_propagation = true 50 | dotnet_style_object_initializer = true 51 | dotnet_style_operator_placement_when_wrapping = beginning_of_line 52 | dotnet_style_prefer_auto_properties = true 53 | dotnet_style_prefer_compound_assignment = true 54 | dotnet_style_prefer_conditional_expression_over_assignment = true 55 | dotnet_style_prefer_conditional_expression_over_return = true 56 | dotnet_style_prefer_inferred_anonymous_type_member_names = true 57 | dotnet_style_prefer_inferred_tuple_names = true 58 | dotnet_style_prefer_is_null_check_over_reference_equality_method = true 59 | dotnet_style_prefer_simplified_boolean_expressions = true 60 | dotnet_style_prefer_simplified_interpolation = true 61 | 62 | # Field preferences 63 | dotnet_style_readonly_field = true 64 | 65 | # Parameter preferences 66 | dotnet_code_quality_unused_parameters = all 67 | 68 | # Suppression preferences 69 | dotnet_remove_unnecessary_suppression_exclusions = none 70 | 71 | # New line preferences 72 | dotnet_style_allow_multiple_blank_lines_experimental = true 73 | dotnet_style_allow_statement_immediately_after_block_experimental = true 74 | 75 | #### C# Coding Conventions #### 76 | 77 | # var preferences 78 | csharp_style_var_elsewhere = true:silent 79 | csharp_style_var_for_built_in_types = true:silent 80 | csharp_style_var_when_type_is_apparent = true:silent 81 | 82 | # Expression-bodied members 83 | csharp_style_expression_bodied_accessors = true:silent 84 | csharp_style_expression_bodied_constructors = false:silent 85 | csharp_style_expression_bodied_indexers = true:silent 86 | csharp_style_expression_bodied_lambdas = true:silent 87 | csharp_style_expression_bodied_local_functions = false:silent 88 | csharp_style_expression_bodied_methods = false:silent 89 | csharp_style_expression_bodied_operators = false:silent 90 | csharp_style_expression_bodied_properties = true:silent 91 | 92 | # Pattern matching preferences 93 | csharp_style_pattern_matching_over_as_with_null_check = true:suggestion 94 | csharp_style_pattern_matching_over_is_with_cast_check = true:suggestion 95 | csharp_style_prefer_extended_property_pattern = true:suggestion 96 | csharp_style_prefer_not_pattern = true:suggestion 97 | csharp_style_prefer_pattern_matching = true:silent 98 | csharp_style_prefer_switch_expression = true:suggestion 99 | 100 | # Null-checking preferences 101 | csharp_style_conditional_delegate_call = true:suggestion 102 | csharp_style_prefer_parameter_null_checking = true:suggestion 103 | 104 | # Modifier preferences 105 | csharp_prefer_static_local_function = true:suggestion 106 | csharp_preferred_modifier_order = public,private,protected,internal,static,extern,new,virtual,abstract,sealed,override,readonly,unsafe,volatile,async 107 | 108 | # Code-block preferences 109 | csharp_prefer_braces = true:silent 110 | csharp_prefer_simple_using_statement = true:suggestion 111 | csharp_style_namespace_declarations = block_scoped:silent 112 | csharp_style_prefer_method_group_conversion = true:silent 113 | 114 | # Expression-level preferences 115 | csharp_prefer_simple_default_expression = true:suggestion 116 | csharp_style_deconstructed_variable_declaration = true:suggestion 117 | csharp_style_implicit_object_creation_when_type_is_apparent = true:suggestion 118 | csharp_style_inlined_variable_declaration = true:suggestion 119 | csharp_style_prefer_index_operator = true:suggestion 120 | csharp_style_prefer_local_over_anonymous_function = true:suggestion 121 | csharp_style_prefer_null_check_over_type_check = true:suggestion 122 | csharp_style_prefer_range_operator = true:suggestion 123 | csharp_style_prefer_tuple_swap = true:suggestion 124 | csharp_style_throw_expression = true:suggestion 125 | csharp_style_unused_value_assignment_preference = discard_variable:suggestion 126 | csharp_style_unused_value_expression_statement_preference = discard_variable:silent 127 | 128 | # 'using' directive preferences 129 | csharp_using_directive_placement = outside_namespace:silent 130 | 131 | # New line preferences 132 | csharp_style_allow_blank_line_after_colon_in_constructor_initializer_experimental = true:silent 133 | csharp_style_allow_blank_lines_between_consecutive_braces_experimental = true:silent 134 | csharp_style_allow_embedded_statements_on_same_line_experimental = true:silent 135 | 136 | #### C# Formatting Rules #### 137 | 138 | # New line preferences 139 | csharp_new_line_before_catch = true 140 | csharp_new_line_before_else = true 141 | csharp_new_line_before_finally = true 142 | csharp_new_line_before_members_in_anonymous_types = true 143 | csharp_new_line_before_members_in_object_initializers = true 144 | csharp_new_line_before_open_brace = all 145 | csharp_new_line_between_query_expression_clauses = true 146 | 147 | # Indentation preferences 148 | csharp_indent_block_contents = true 149 | csharp_indent_braces = false 150 | csharp_indent_case_contents = true 151 | csharp_indent_case_contents_when_block = true 152 | csharp_indent_labels = one_less_than_current 153 | csharp_indent_switch_labels = true 154 | 155 | # Space preferences 156 | csharp_space_after_cast = false 157 | csharp_space_after_colon_in_inheritance_clause = true 158 | csharp_space_after_comma = true 159 | csharp_space_after_dot = false 160 | csharp_space_after_keywords_in_control_flow_statements = true 161 | csharp_space_after_semicolon_in_for_statement = true 162 | csharp_space_around_binary_operators = before_and_after 163 | csharp_space_around_declaration_statements = false 164 | csharp_space_before_colon_in_inheritance_clause = true 165 | csharp_space_before_comma = false 166 | csharp_space_before_dot = false 167 | csharp_space_before_open_square_brackets = false 168 | csharp_space_before_semicolon_in_for_statement = false 169 | csharp_space_between_empty_square_brackets = false 170 | csharp_space_between_method_call_empty_parameter_list_parentheses = false 171 | csharp_space_between_method_call_name_and_opening_parenthesis = false 172 | csharp_space_between_method_call_parameter_list_parentheses = false 173 | csharp_space_between_method_declaration_empty_parameter_list_parentheses = false 174 | csharp_space_between_method_declaration_name_and_open_parenthesis = false 175 | csharp_space_between_method_declaration_parameter_list_parentheses = false 176 | csharp_space_between_parentheses = false 177 | csharp_space_between_square_brackets = false 178 | 179 | # Wrapping preferences 180 | csharp_preserve_single_line_blocks = true 181 | csharp_preserve_single_line_statements = true 182 | 183 | #### Naming styles #### 184 | 185 | # Naming rules 186 | 187 | dotnet_naming_rule.interface_should_be_begins_with_i.severity = suggestion 188 | dotnet_naming_rule.interface_should_be_begins_with_i.symbols = interface 189 | dotnet_naming_rule.interface_should_be_begins_with_i.style = begins_with_i 190 | 191 | dotnet_naming_rule.types_should_be_pascal_case.severity = suggestion 192 | dotnet_naming_rule.types_should_be_pascal_case.symbols = types 193 | dotnet_naming_rule.types_should_be_pascal_case.style = pascal_case 194 | 195 | dotnet_naming_rule.non_field_members_should_be_pascal_case.severity = suggestion 196 | dotnet_naming_rule.non_field_members_should_be_pascal_case.symbols = non_field_members 197 | dotnet_naming_rule.non_field_members_should_be_pascal_case.style = pascal_case 198 | 199 | # Symbol specifications 200 | 201 | dotnet_naming_symbols.interface.applicable_kinds = interface 202 | dotnet_naming_symbols.interface.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected 203 | dotnet_naming_symbols.interface.required_modifiers = 204 | 205 | dotnet_naming_symbols.types.applicable_kinds = class, struct, interface, enum 206 | dotnet_naming_symbols.types.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected 207 | dotnet_naming_symbols.types.required_modifiers = 208 | 209 | dotnet_naming_symbols.non_field_members.applicable_kinds = property, event, method 210 | dotnet_naming_symbols.non_field_members.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected 211 | dotnet_naming_symbols.non_field_members.required_modifiers = 212 | 213 | # Naming styles 214 | 215 | dotnet_naming_style.pascal_case.required_prefix = 216 | dotnet_naming_style.pascal_case.required_suffix = 217 | dotnet_naming_style.pascal_case.word_separator = 218 | dotnet_naming_style.pascal_case.capitalization = pascal_case 219 | 220 | dotnet_naming_style.begins_with_i.required_prefix = I 221 | dotnet_naming_style.begins_with_i.required_suffix = 222 | dotnet_naming_style.begins_with_i.word_separator = 223 | dotnet_naming_style.begins_with_i.capitalization = pascal_case 224 | 225 | [*.{cs,vb}] 226 | dotnet_style_operator_placement_when_wrapping = beginning_of_line 227 | tab_width = 4 228 | indent_size = 4 229 | end_of_line = crlf 230 | dotnet_style_coalesce_expression = true:suggestion 231 | dotnet_style_null_propagation = true:suggestion 232 | dotnet_style_prefer_is_null_check_over_reference_equality_method = true:suggestion 233 | dotnet_style_prefer_auto_properties = true:silent 234 | dotnet_style_object_initializer = true:suggestion 235 | dotnet_style_collection_initializer = true:suggestion 236 | dotnet_style_prefer_simplified_boolean_expressions = true:suggestion 237 | dotnet_style_prefer_conditional_expression_over_assignment = true:silent 238 | dotnet_style_prefer_conditional_expression_over_return = true:silent 239 | dotnet_style_explicit_tuple_names = true:suggestion 240 | dotnet_style_prefer_inferred_tuple_names = true:suggestion 241 | dotnet_style_prefer_inferred_anonymous_type_member_names = true:suggestion 242 | dotnet_style_prefer_compound_assignment = true:suggestion 243 | dotnet_style_prefer_simplified_interpolation = true:suggestion 244 | dotnet_style_namespace_match_folder = true:suggestion 245 | dotnet_style_readonly_field = true:suggestion 246 | dotnet_style_predefined_type_for_locals_parameters_members = true:silent 247 | dotnet_style_predefined_type_for_member_access = true:silent 248 | dotnet_style_require_accessibility_modifiers = for_non_interface_members:silent 249 | dotnet_style_allow_multiple_blank_lines_experimental = true:silent 250 | dotnet_style_allow_statement_immediately_after_block_experimental = true:silent 251 | dotnet_code_quality_unused_parameters = all:suggestion 252 | dotnet_style_parentheses_in_other_binary_operators = always_for_clarity:silent 253 | dotnet_style_parentheses_in_arithmetic_binary_operators = always_for_clarity:silent 254 | dotnet_style_parentheses_in_relational_binary_operators = always_for_clarity:silent 255 | dotnet_style_parentheses_in_other_operators = never_if_unnecessary:silent 256 | dotnet_style_qualification_for_field = false:silent 257 | dotnet_style_qualification_for_property = false:silent 258 | dotnet_style_qualification_for_event = false:silent 259 | dotnet_style_qualification_for_method = false:silent -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Set the default behavior, in case people don't have core.autocrlf set. 2 | * text=auto 3 | 4 | # Explicitly declare text files you want to always be normalized and converted 5 | # to native line endings on checkout. 6 | 7 | # Declare files that will always have CRLF line endings on checkout. 8 | *.sln text eol=lf 9 | *.cs text eol=lf 10 | 11 | # Denote all files that are truly binary and should not be modified. 12 | *.png binary 13 | *.jpg binary -------------------------------------------------------------------------------- /.github/workflows/Check.yml: -------------------------------------------------------------------------------- 1 | name: FastCheck 2 | 3 | on: 4 | pull_request: 5 | branches: [ master, main ] 6 | push: 7 | branches: [ dev* ] 8 | 9 | jobs: 10 | test: 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - uses: actions/checkout@v2 15 | with: 16 | submodules: true 17 | - name: Setup .NET 18 | uses: actions/setup-dotnet@v1 19 | with: 20 | dotnet-version: '6.x' 21 | 22 | - name: Restore dependencies 23 | run: dotnet restore 24 | - name: Build 25 | run: dotnet build --no-restore 26 | 27 | - name: Test 28 | run: dotnet test --no-build --verbosity normal --filter TestCategory!=Installation 29 | -------------------------------------------------------------------------------- /.github/workflows/Publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*' 7 | 8 | jobs: 9 | 10 | publish-linux: 11 | runs-on: ubuntu-latest 12 | permissions: 13 | contents: write 14 | 15 | steps: 16 | - uses: actions/checkout@v2 17 | with: 18 | submodules: true 19 | - name: Setup .NET 20 | uses: actions/setup-dotnet@v1 21 | with: 22 | dotnet-version: '6.x' 23 | 24 | - name: Publish 25 | run: dotnet publish TES3Merge/TES3Merge.csproj -o publish/linux -c Release -r linux-x64 -p:PublishSingleFile=true --no-self-contained 26 | 27 | # create zip 28 | - run: ls -R 29 | - run: zip TES3Merge-linux.zip publish/linux/TES3Merge publish/linux/TES3Merge.ini 30 | 31 | # RELEASE 32 | - name: Release 33 | uses: ncipollo/release-action@v1 34 | with: 35 | draft: true 36 | generateReleaseNotes: true 37 | artifacts: "TES3Merge-linux.zip" 38 | token: ${{ secrets.GITHUB_TOKEN }} 39 | 40 | 41 | publish-win: 42 | runs-on: windows-latest 43 | needs: publish-linux 44 | permissions: 45 | contents: write 46 | 47 | steps: 48 | - uses: actions/checkout@v2 49 | with: 50 | submodules: true 51 | - name: Setup .NET 52 | uses: actions/setup-dotnet@v1 53 | with: 54 | dotnet-version: '6.x' 55 | 56 | - name: Publish 57 | run: dotnet publish TES3Merge/TES3Merge.csproj -o publish/win -c Release -r win-x64 -p:PublishSingleFile=true --no-self-contained 58 | 59 | # create zip 60 | - run: dir 61 | - run: Compress-Archive -LiteralPath "publish\win\TES3Merge.exe","publish\win\TES3Merge.ini" -DestinationPath "TES3Merge-win.zip" 62 | 63 | 64 | # RELEASE 65 | - name: Release 66 | uses: ncipollo/release-action@v1 67 | with: 68 | allowUpdates: true 69 | draft: true 70 | generateReleaseNotes: true 71 | artifacts: "TES3Merge-win.zip" 72 | token: ${{ secrets.GITHUB_TOKEN }} 73 | 74 | 75 | publish-osx: 76 | needs: publish-linux 77 | runs-on: macos-latest 78 | permissions: 79 | contents: write 80 | 81 | steps: 82 | - uses: actions/checkout@v2 83 | with: 84 | submodules: true 85 | - name: Setup .NET 86 | uses: actions/setup-dotnet@v1 87 | with: 88 | dotnet-version: '6.x' 89 | 90 | - name: Publish 91 | run: dotnet publish TES3Merge/TES3Merge.csproj -o publish/osx -c Release -r osx-x64 -p:PublishSingleFile=true --no-self-contained 92 | 93 | # create zip 94 | - run: ls -R 95 | - run: zip TES3Merge-osx.zip publish/osx/TES3Merge publish/osx/TES3Merge.ini 96 | 97 | 98 | # RELEASE 99 | - name: Release 100 | uses: ncipollo/release-action@v1 101 | with: 102 | allowUpdates: true 103 | draft: true 104 | generateReleaseNotes: true 105 | artifacts: "TES3Merge-osx.zip" 106 | token: ${{ secrets.GITHUB_TOKEN }} -------------------------------------------------------------------------------- /.github/workflows/TES3Merge.yml: -------------------------------------------------------------------------------- 1 | name: Check_Build 2 | 3 | on: 4 | push: 5 | branches: [ master, main ] 6 | 7 | jobs: 8 | test: 9 | runs-on: ubuntu-latest 10 | 11 | steps: 12 | - uses: actions/checkout@v2 13 | with: 14 | submodules: true 15 | - name: Setup .NET 16 | uses: actions/setup-dotnet@v1 17 | with: 18 | dotnet-version: '6.x' 19 | 20 | - name: Restore dependencies 21 | run: dotnet restore 22 | - name: Build 23 | run: dotnet build --no-restore 24 | 25 | - name: Test 26 | run: dotnet test --no-build --verbosity normal --filter TestCategory!=Installation 27 | 28 | build-win: 29 | needs: [test] 30 | runs-on: windows-latest 31 | 32 | steps: 33 | - uses: actions/checkout@v2 34 | with: 35 | submodules: true 36 | - name: Setup .NET 37 | uses: actions/setup-dotnet@v1 38 | with: 39 | dotnet-version: '6.x' 40 | 41 | - name: Publish 42 | run: dotnet publish TES3Merge/TES3Merge.csproj -o publish/win -c Release -r win-x64 -p:PublishSingleFile=true --no-self-contained 43 | 44 | - uses: actions/upload-artifact@v4 45 | with: 46 | name: tes3merge-win 47 | path: ./publish/win/TES3Merge.exe 48 | 49 | build-linux: 50 | needs: [test] 51 | runs-on: ubuntu-latest 52 | 53 | steps: 54 | - uses: actions/checkout@v2 55 | with: 56 | submodules: true 57 | - name: Setup .NET 58 | uses: actions/setup-dotnet@v1 59 | with: 60 | dotnet-version: '6.x' 61 | 62 | - name: Publish 63 | run: dotnet publish TES3Merge/TES3Merge.csproj -o publish/linux -c Release -r linux-x64 -p:PublishSingleFile=true --no-self-contained 64 | 65 | - uses: actions/upload-artifact@v4 66 | with: 67 | name: tes3merge-linux 68 | path: ./publish/linux/TES3Merge 69 | 70 | build-osx: 71 | needs: [test] 72 | runs-on: macos-latest 73 | 74 | steps: 75 | - uses: actions/checkout@v2 76 | with: 77 | submodules: true 78 | - name: Setup .NET 79 | uses: actions/setup-dotnet@v1 80 | with: 81 | dotnet-version: '6.x' 82 | 83 | - name: Publish 84 | run: dotnet publish TES3Merge/TES3Merge.csproj -o publish/osx -c Release -r osx-x64 -p:PublishSingleFile=true --no-self-contained 85 | 86 | - uses: actions/upload-artifact@v4 87 | with: 88 | name: tes3merge-osx 89 | path: ./publish/osx/TES3Merge 90 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ## Ignore Visual Studio temporary files, build results, and 2 | ## files generated by popular Visual Studio add-ons. 3 | ## 4 | ## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore 5 | 6 | # User-specific files 7 | *.rsuser 8 | *.suo 9 | *.user 10 | *.userosscache 11 | *.sln.docstates 12 | 13 | # User-specific files (MonoDevelop/Xamarin Studio) 14 | *.userprefs 15 | 16 | # Mono auto generated files 17 | mono_crash.* 18 | 19 | # Build results 20 | [Dd]ebug/ 21 | [Dd]ebugPublic/ 22 | [Rr]elease/ 23 | [Rr]eleases/ 24 | x64/ 25 | x86/ 26 | [Aa][Rr][Mm]/ 27 | [Aa][Rr][Mm]64/ 28 | bld/ 29 | [Bb]in/ 30 | [Oo]bj/ 31 | [Ll]og/ 32 | 33 | # Visual Studio 2015/2017 cache/options directory 34 | .vs/ 35 | # Uncomment if you have tasks that create the project's static files in wwwroot 36 | #wwwroot/ 37 | 38 | # Visual Studio 2017 auto generated files 39 | Generated\ Files/ 40 | 41 | # MSTest test Results 42 | [Tt]est[Rr]esult*/ 43 | [Bb]uild[Ll]og.* 44 | 45 | # NUnit 46 | *.VisualState.xml 47 | TestResult.xml 48 | nunit-*.xml 49 | 50 | # Build Results of an ATL Project 51 | [Dd]ebugPS/ 52 | [Rr]eleasePS/ 53 | dlldata.c 54 | 55 | # Benchmark Results 56 | BenchmarkDotNet.Artifacts/ 57 | 58 | # .NET Core 59 | project.lock.json 60 | project.fragment.lock.json 61 | artifacts/ 62 | 63 | # StyleCop 64 | StyleCopReport.xml 65 | 66 | # Files built by Visual Studio 67 | *_i.c 68 | *_p.c 69 | *_h.h 70 | *.ilk 71 | *.meta 72 | *.obj 73 | *.iobj 74 | *.pch 75 | *.pdb 76 | *.ipdb 77 | *.pgc 78 | *.pgd 79 | *.rsp 80 | *.sbr 81 | *.tlb 82 | *.tli 83 | *.tlh 84 | *.tmp 85 | *.tmp_proj 86 | *_wpftmp.csproj 87 | *.log 88 | *.vspscc 89 | *.vssscc 90 | .builds 91 | *.pidb 92 | *.svclog 93 | *.scc 94 | 95 | # Chutzpah Test files 96 | _Chutzpah* 97 | 98 | # Visual C++ cache files 99 | ipch/ 100 | *.aps 101 | *.ncb 102 | *.opendb 103 | *.opensdf 104 | *.sdf 105 | *.cachefile 106 | *.VC.db 107 | *.VC.VC.opendb 108 | 109 | # Visual Studio profiler 110 | *.psess 111 | *.vsp 112 | *.vspx 113 | *.sap 114 | 115 | # Visual Studio Trace Files 116 | *.e2e 117 | 118 | # TFS 2012 Local Workspace 119 | $tf/ 120 | 121 | # Guidance Automation Toolkit 122 | *.gpState 123 | 124 | # ReSharper is a .NET coding add-in 125 | _ReSharper*/ 126 | *.[Rr]e[Ss]harper 127 | *.DotSettings.user 128 | 129 | # JustCode is a .NET coding add-in 130 | .JustCode 131 | 132 | # TeamCity is a build add-in 133 | _TeamCity* 134 | 135 | # DotCover is a Code Coverage Tool 136 | *.dotCover 137 | 138 | # AxoCover is a Code Coverage Tool 139 | .axoCover/* 140 | !.axoCover/settings.json 141 | 142 | # Visual Studio code coverage results 143 | *.coverage 144 | *.coveragexml 145 | 146 | # NCrunch 147 | _NCrunch_* 148 | .*crunch*.local.xml 149 | nCrunchTemp_* 150 | 151 | # MightyMoose 152 | *.mm.* 153 | AutoTest.Net/ 154 | 155 | # Web workbench (sass) 156 | .sass-cache/ 157 | 158 | # Installshield output folder 159 | [Ee]xpress/ 160 | 161 | # DocProject is a documentation generator add-in 162 | DocProject/buildhelp/ 163 | DocProject/Help/*.HxT 164 | DocProject/Help/*.HxC 165 | DocProject/Help/*.hhc 166 | DocProject/Help/*.hhk 167 | DocProject/Help/*.hhp 168 | DocProject/Help/Html2 169 | DocProject/Help/html 170 | 171 | # Click-Once directory 172 | publish/ 173 | 174 | # Publish Web Output 175 | *.[Pp]ublish.xml 176 | *.azurePubxml 177 | # Note: Comment the next line if you want to checkin your web deploy settings, 178 | # but database connection strings (with potential passwords) will be unencrypted 179 | *.pubxml 180 | *.publishproj 181 | 182 | # Microsoft Azure Web App publish settings. Comment the next line if you want to 183 | # checkin your Azure Web App publish settings, but sensitive information contained 184 | # in these scripts will be unencrypted 185 | PublishScripts/ 186 | 187 | # NuGet Packages 188 | *.nupkg 189 | # NuGet Symbol Packages 190 | *.snupkg 191 | # The packages folder can be ignored because of Package Restore 192 | **/[Pp]ackages/* 193 | # except build/, which is used as an MSBuild target. 194 | !**/[Pp]ackages/build/ 195 | # Uncomment if necessary however generally it will be regenerated when needed 196 | #!**/[Pp]ackages/repositories.config 197 | # NuGet v3's project.json files produces more ignorable files 198 | *.nuget.props 199 | *.nuget.targets 200 | 201 | # Microsoft Azure Build Output 202 | csx/ 203 | *.build.csdef 204 | 205 | # Microsoft Azure Emulator 206 | ecf/ 207 | rcf/ 208 | 209 | # Windows Store app package directories and files 210 | AppPackages/ 211 | BundleArtifacts/ 212 | Package.StoreAssociation.xml 213 | _pkginfo.txt 214 | *.appx 215 | *.appxbundle 216 | *.appxupload 217 | 218 | # Visual Studio cache files 219 | # files ending in .cache can be ignored 220 | *.[Cc]ache 221 | # but keep track of directories ending in .cache 222 | !?*.[Cc]ache/ 223 | 224 | # Others 225 | ClientBin/ 226 | ~$* 227 | *~ 228 | *.dbmdl 229 | *.dbproj.schemaview 230 | *.jfm 231 | *.pfx 232 | *.publishsettings 233 | orleans.codegen.cs 234 | 235 | # Including strong name files can present a security risk 236 | # (https://github.com/github/gitignore/pull/2483#issue-259490424) 237 | #*.snk 238 | 239 | # Since there are multiple workflows, uncomment next line to ignore bower_components 240 | # (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) 241 | #bower_components/ 242 | 243 | # RIA/Silverlight projects 244 | Generated_Code/ 245 | 246 | # Backup & report files from converting an old project file 247 | # to a newer Visual Studio version. Backup files are not needed, 248 | # because we have git ;-) 249 | _UpgradeReport_Files/ 250 | Backup*/ 251 | UpgradeLog*.XML 252 | UpgradeLog*.htm 253 | ServiceFabricBackup/ 254 | *.rptproj.bak 255 | 256 | # SQL Server files 257 | *.mdf 258 | *.ldf 259 | *.ndf 260 | 261 | # Business Intelligence projects 262 | *.rdl.data 263 | *.bim.layout 264 | *.bim_*.settings 265 | *.rptproj.rsuser 266 | *- [Bb]ackup.rdl 267 | *- [Bb]ackup ([0-9]).rdl 268 | *- [Bb]ackup ([0-9][0-9]).rdl 269 | 270 | # Microsoft Fakes 271 | FakesAssemblies/ 272 | 273 | # GhostDoc plugin setting file 274 | *.GhostDoc.xml 275 | 276 | # Node.js Tools for Visual Studio 277 | .ntvs_analysis.dat 278 | node_modules/ 279 | 280 | # Visual Studio 6 build log 281 | *.plg 282 | 283 | # Visual Studio 6 workspace options file 284 | *.opt 285 | 286 | # Visual Studio 6 auto-generated workspace file (contains which files were open etc.) 287 | *.vbw 288 | 289 | # Visual Studio LightSwitch build output 290 | **/*.HTMLClient/GeneratedArtifacts 291 | **/*.DesktopClient/GeneratedArtifacts 292 | **/*.DesktopClient/ModelManifest.xml 293 | **/*.Server/GeneratedArtifacts 294 | **/*.Server/ModelManifest.xml 295 | _Pvt_Extensions 296 | 297 | # Paket dependency manager 298 | .paket/paket.exe 299 | paket-files/ 300 | 301 | # FAKE - F# Make 302 | .fake/ 303 | 304 | # CodeRush personal settings 305 | .cr/personal 306 | 307 | # Python Tools for Visual Studio (PTVS) 308 | __pycache__/ 309 | *.pyc 310 | 311 | # Cake - Uncomment if you are using it 312 | # tools/** 313 | # !tools/packages.config 314 | 315 | # Tabs Studio 316 | *.tss 317 | 318 | # Telerik's JustMock configuration file 319 | *.jmconfig 320 | 321 | # BizTalk build output 322 | *.btp.cs 323 | *.btm.cs 324 | *.odx.cs 325 | *.xsd.cs 326 | 327 | # OpenCover UI analysis results 328 | OpenCover/ 329 | 330 | # Azure Stream Analytics local run output 331 | ASALocalRun/ 332 | 333 | # MSBuild Binary and Structured Log 334 | *.binlog 335 | 336 | # NVidia Nsight GPU debugger configuration file 337 | *.nvuser 338 | 339 | # MFractors (Xamarin productivity tool) working folder 340 | .mfractor/ 341 | 342 | # Local History for Visual Studio 343 | .localhistory/ 344 | 345 | # BeatPulse healthcheck temp database 346 | healthchecksdb 347 | 348 | # Backup folder for Package Reference Convert tool in Visual Studio 2017 349 | MigrationBackup/ 350 | /TES3Merge.Tests/Plugins/*.json 351 | TES3Merge/Properties/launchSettings.json 352 | 353 | # VS Code and Rider 354 | .vscode/ 355 | .idea/ 356 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "TES3Tool"] 2 | path = TES3Tool 3 | url = git@github.com:NullCascade/TES3Tool.git 4 | branch = master 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License 2 | 3 | Copyright (c) 2019 NullCascade 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /NuGet.Config: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /TES3Merge.Tests/FileLoader.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.IO; 3 | 4 | namespace TES3Merge.Tests; 5 | 6 | internal static class FileLoader 7 | { 8 | /// 9 | /// A map of loaded plugins. This is lazy-filled as requested. 10 | /// 11 | private static readonly Dictionary LoadedPlugins = new(); 12 | 13 | /// 14 | /// A filter for all the types we will load. This optimizes loading so we don't load records we will never test. 15 | /// 16 | private static readonly List testedRecords = new(new string[] { 17 | "ALCH", 18 | "CREA", 19 | "LEVC", 20 | "NPC_", 21 | }); 22 | 23 | /// 24 | /// Lazy-loads a plugin in the Plugins folder. Ensure that the plugin is set to copy over to the output folder. 25 | /// 26 | /// The name of the plugin file, including the file extension, relative to the plugins folder. 27 | /// 28 | internal static TES3Lib.TES3? GetPlugin(string name) 29 | { 30 | if (!LoadedPlugins.ContainsKey(name)) 31 | { 32 | var loadedPlugin = TES3Lib.TES3.TES3Load(Path.Combine("Plugins", name), testedRecords); 33 | loadedPlugin.Path = name; // Override path to remove prefix. 34 | LoadedPlugins[name] = loadedPlugin; 35 | return loadedPlugin; 36 | } 37 | return LoadedPlugins[name]; 38 | } 39 | 40 | /// 41 | /// Lazy-loads a plugin through , and returns a record from it with the given . 42 | /// 43 | /// The full file name of the plugin, including file extension, relative to the plugins folder. 44 | /// The id of the record to find. It does not need to manually specify a null terminator. 45 | /// The found record, or null if the plugin could not be loaded or if the record does not exist. 46 | internal static TES3Lib.Base.Record? FindRecord(string pluginName, string id) 47 | { 48 | TES3Lib.TES3? plugin = GetPlugin(pluginName); 49 | return plugin is null ? null : plugin.FindRecord(id); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /TES3Merge.Tests/Installation/Morrowind.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Extensions.DependencyInjection; 2 | using Microsoft.Extensions.Hosting; 3 | using Microsoft.Extensions.Logging; 4 | using Microsoft.VisualStudio.TestTools.UnitTesting; 5 | using Serilog; 6 | using Serilog.Events; 7 | using System; 8 | using System.IO; 9 | using TES3Merge.Util; 10 | using static TES3Merge.Tests.Utility; 11 | 12 | namespace TES3Merge.Tests.Installation; 13 | 14 | [TestClass, TestCategory("Installation")] 15 | public class Morrowind 16 | { 17 | protected IHost _host; 18 | protected Microsoft.Extensions.Logging.ILogger _logger; 19 | 20 | public MorrowindInstallation? Install; 21 | 22 | public Morrowind() 23 | { 24 | // Setup logging. 25 | Log.Logger = new LoggerConfiguration() 26 | .MinimumLevel.Debug() 27 | .MinimumLevel.Override("Microsoft", LogEventLevel.Information) 28 | .Enrich.FromLogContext() 29 | .WriteTo.Console(outputTemplate: "[{Level:u3}] {Message:lj}{NewLine}{Exception}") 30 | .CreateLogger(); 31 | 32 | var hostBuilder = Host.CreateDefaultBuilder().UseSerilog(); 33 | _host = hostBuilder.Build(); 34 | _logger = _host.Services.GetRequiredService>(); 35 | 36 | if (Directory.Exists(Properties.Resources.OpenMWInstallDirectory)) 37 | { 38 | Install = new MorrowindInstallation(Properties.Resources.MorrowindInstallDirectory); 39 | } 40 | } 41 | 42 | [TestMethod] 43 | public void InstallationFound() 44 | { 45 | if (Directory.Exists(Properties.Resources.MorrowindInstallDirectory)) 46 | { 47 | Assert.IsNotNull(Install); 48 | } 49 | } 50 | 51 | [TestMethod] 52 | public void InstallationPathValid() 53 | { 54 | if (Install is null) 55 | { 56 | Assert.Inconclusive(); 57 | return; 58 | } 59 | 60 | _logger.LogInformation("Installation Directory: {path}", Install.RootDirectory); 61 | var exePath = Path.Combine(Install.RootDirectory, "Morrowind.exe"); 62 | Assert.IsTrue(File.Exists(exePath)); 63 | } 64 | 65 | [TestMethod] 66 | public void ArchivesFound() 67 | { 68 | if (Install is null) 69 | { 70 | Assert.Inconclusive(); 71 | return; 72 | } 73 | 74 | _logger.LogInformation("Archives: {list}", Install.Archives); 75 | Assert.IsTrue(Install.Archives.Contains("Morrowind.bsa")); 76 | Assert.IsTrue(Install.Archives.Contains("Tribunal.bsa")); 77 | Assert.IsTrue(Install.Archives.Contains("Bloodmoon.bsa")); 78 | } 79 | 80 | [TestMethod] 81 | public void ArchivesAreInOrder() 82 | { 83 | if (Install is null) 84 | { 85 | Assert.Inconclusive(); 86 | return; 87 | } 88 | 89 | _logger.LogInformation("Archives: {list}", Install.Archives); 90 | Assert.AreEqual(Install.Archives[0], "Morrowind.bsa"); 91 | Assert.AreEqual(Install.Archives[1], "Tribunal.bsa"); 92 | Assert.AreEqual(Install.Archives[2], "Bloodmoon.bsa"); 93 | } 94 | 95 | [TestMethod] 96 | public void GameFilesFound() 97 | { 98 | if (Install is null) 99 | { 100 | Assert.Inconclusive(); 101 | return; 102 | } 103 | 104 | _logger.LogInformation("Game Files: {list}", Install.GameFiles); 105 | Assert.IsTrue(Install.GameFiles.Contains("Morrowind.esm")); 106 | Assert.IsTrue(Install.GameFiles.Contains("Tribunal.esm")); 107 | Assert.IsTrue(Install.GameFiles.Contains("Bloodmoon.esm")); 108 | } 109 | 110 | [TestMethod] 111 | public void GameFilesFoundAreInOrder() 112 | { 113 | if (Install is null) 114 | { 115 | Assert.Inconclusive(); 116 | return; 117 | } 118 | 119 | _logger.LogInformation("Game Files: {list}", Install.GameFiles); 120 | Assert.AreEqual(Install.GameFiles[0], "Morrowind.esm"); 121 | Assert.AreEqual(Install.GameFiles[1], "Tribunal.esm"); 122 | Assert.AreEqual(Install.GameFiles[2], "Bloodmoon.esm"); 123 | } 124 | 125 | [TestMethod] 126 | public void DataFilesFound() 127 | { 128 | if (Install is null) 129 | { 130 | Assert.Inconclusive(); 131 | return; 132 | } 133 | 134 | Assert.IsNotNull(Install.GetDataFile("Morrowind.esm")); 135 | Assert.IsNotNull(Install.GetDataFile("Morrowind.bsa")); 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /TES3Merge.Tests/Installation/OpenMW.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Extensions.DependencyInjection; 2 | using Microsoft.Extensions.Hosting; 3 | using Microsoft.Extensions.Logging; 4 | using Microsoft.VisualStudio.TestTools.UnitTesting; 5 | using Serilog; 6 | using Serilog.Events; 7 | using System; 8 | using System.IO; 9 | using TES3Merge.Util; 10 | using static TES3Merge.Tests.Utility; 11 | 12 | namespace TES3Merge.Tests.Installation; 13 | 14 | [TestClass, TestCategory("Installation")] 15 | public class OpenMW 16 | { 17 | protected IHost _host; 18 | protected Microsoft.Extensions.Logging.ILogger _logger; 19 | 20 | public OpenMWInstallation? Install; 21 | 22 | public OpenMW() 23 | { 24 | // Setup logging. 25 | Log.Logger = new LoggerConfiguration() 26 | .MinimumLevel.Debug() 27 | .MinimumLevel.Override("Microsoft", LogEventLevel.Information) 28 | .Enrich.FromLogContext() 29 | .WriteTo.Console(outputTemplate: "[{Level:u3}] {Message:lj}{NewLine}{Exception}") 30 | .CreateLogger(); 31 | 32 | var hostBuilder = Host.CreateDefaultBuilder().UseSerilog(); 33 | _host = hostBuilder.Build(); 34 | _logger = _host.Services.GetRequiredService>(); 35 | 36 | if (Directory.Exists(Properties.Resources.OpenMWInstallDirectory)) 37 | { 38 | Install = new OpenMWInstallation(Properties.Resources.OpenMWInstallDirectory); 39 | } 40 | } 41 | 42 | [TestMethod] 43 | public void InstallationFound() 44 | { 45 | if (Directory.Exists(Properties.Resources.OpenMWInstallDirectory)) 46 | { 47 | Assert.IsNotNull(Install); 48 | } 49 | } 50 | 51 | [TestMethod] 52 | public void InstallationPathValid() 53 | { 54 | if (Install is null) 55 | { 56 | Assert.Inconclusive(); 57 | return; 58 | } 59 | 60 | _logger.LogInformation("Installation Directory: {path}", Install.RootDirectory); 61 | var cfgPath = Path.Combine(Install.RootDirectory, "openmw.cfg"); 62 | Assert.IsTrue(File.Exists(cfgPath)); 63 | } 64 | 65 | [TestMethod] 66 | public void ArchivesFound() 67 | { 68 | if (Install is null) 69 | { 70 | Assert.Inconclusive(); 71 | return; 72 | } 73 | 74 | _logger.LogInformation("Archives: {list}", Install.Archives); 75 | Assert.IsTrue(Install.Archives.Contains("Morrowind.bsa")); 76 | Assert.IsTrue(Install.Archives.Contains("Tribunal.bsa")); 77 | Assert.IsTrue(Install.Archives.Contains("Bloodmoon.bsa")); 78 | } 79 | 80 | [TestMethod] 81 | public void ArchivesAreInOrder() 82 | { 83 | if (Install is null) 84 | { 85 | Assert.Inconclusive(); 86 | return; 87 | } 88 | 89 | _logger.LogInformation("Archives: {list}", Install.Archives); 90 | Assert.AreEqual(Install.Archives[0], "Morrowind.bsa"); 91 | Assert.AreEqual(Install.Archives[1], "Tribunal.bsa"); 92 | Assert.AreEqual(Install.Archives[2], "Bloodmoon.bsa"); 93 | } 94 | 95 | [TestMethod] 96 | public void GameFilesFound() 97 | { 98 | if (Install is null) 99 | { 100 | Assert.Inconclusive(); 101 | return; 102 | } 103 | 104 | _logger.LogInformation("Game Files: {list}", Install.GameFiles); 105 | Assert.IsTrue(Install.GameFiles.Contains("Morrowind.esm")); 106 | Assert.IsTrue(Install.GameFiles.Contains("Tribunal.esm")); 107 | Assert.IsTrue(Install.GameFiles.Contains("Bloodmoon.esm")); 108 | } 109 | 110 | [TestMethod] 111 | public void GameFilesFoundAreInOrder() 112 | { 113 | if (Install is null) 114 | { 115 | Assert.Inconclusive(); 116 | return; 117 | } 118 | 119 | _logger.LogInformation("Game Files: {list}", Install.GameFiles); 120 | Assert.AreEqual(Install.GameFiles[0], "Morrowind.esm"); 121 | Assert.AreEqual(Install.GameFiles[1], "Tribunal.esm"); 122 | Assert.AreEqual(Install.GameFiles[2], "Bloodmoon.esm"); 123 | } 124 | 125 | [TestMethod] 126 | public void DataFilesFound() 127 | { 128 | if (Install is null) 129 | { 130 | Assert.Inconclusive(); 131 | return; 132 | } 133 | 134 | Assert.IsNotNull(Install.GetDataFile("Morrowind.esm")); 135 | Assert.IsNotNull(Install.GetDataFile("Morrowind.bsa")); 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /TES3Merge.Tests/Merger/ALCH.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Extensions.DependencyInjection; 2 | using Microsoft.Extensions.Logging; 3 | using Microsoft.VisualStudio.TestTools.UnitTesting; 4 | using static TES3Merge.Tests.Utility; 5 | 6 | namespace TES3Merge.Tests.Merger; 7 | 8 | /// 9 | /// Special cases to consider for this record: 10 | /// - Effects can be added and removed. 11 | /// - Effects can be changed, and effect data can be made strange if merged dumbly. 12 | /// 13 | [TestClass] 14 | public class ALCH : RecordTest 15 | { 16 | internal TES3Lib.Records.ALCH MergedDefault; 17 | internal TES3Lib.Records.ALCH p_fortify_intelligence_c; 18 | private static readonly string[] BasicMergeMasters = new string[] { "merge_base.esp", "merge_edit_all.esp", "merge_minor_tweaks.esp" }; 19 | private static readonly string[] AddedEffectsMergeMasters = new string[] { "merge_base.esp", "merge_edit_all.esp", "merge_add_effects.esp", "merge_minor_tweaks.esp" }; 20 | private static readonly string[] RealWorldTestMasters = new string[] { "Morrowind.esm", "BTB's Game Improvements (Necro Edit) Tweaked.esp", "AOF Potions Recolored.esp", "ST_Alchemy_Balance_Sri_1.4.esp" }; 21 | 22 | public ALCH() 23 | { 24 | MergedDefault = CreateMergedRecord("merge_alchemy", AddedEffectsMergeMasters); 25 | p_fortify_intelligence_c = CreateMergedRecord("p_fortify_intelligence_c", RealWorldTestMasters); 26 | 27 | _logger = _host.Services.GetRequiredService>(); 28 | } 29 | 30 | [TestMethod] 31 | public void EditorId() 32 | { 33 | LogRecords("NAME.EditorId", MergedDefault, BasicMergeMasters); 34 | 35 | Assert.AreEqual(MergedDefault.NAME.EditorId, GetCached("merge_minor_tweaks.esp").NAME.EditorId); 36 | } 37 | 38 | [TestMethod] 39 | public void ModelPath() 40 | { 41 | LogRecords("MODL.ModelPath", MergedDefault, BasicMergeMasters); 42 | 43 | Assert.AreEqual(MergedDefault.MODL.ModelPath, GetCached("merge_edit_all.esp").MODL.ModelPath); 44 | } 45 | 46 | [TestMethod] 47 | public void IconPath() 48 | { 49 | LogRecords("TEXT.IconPath", MergedDefault, BasicMergeMasters); 50 | 51 | Assert.AreEqual(MergedDefault.TEXT.IconPath, GetCached("merge_edit_all.esp").TEXT.IconPath); 52 | } 53 | 54 | [TestMethod] 55 | public void DisplayName() 56 | { 57 | LogRecords("FNAM.FileName", MergedDefault, BasicMergeMasters); 58 | 59 | Assert.AreEqual(MergedDefault.FNAM.FileName, GetCached("merge_minor_tweaks.esp").FNAM.FileName); 60 | } 61 | 62 | [TestMethod] 63 | public void Value() 64 | { 65 | LogRecords("ALDT.Value", MergedDefault, BasicMergeMasters); 66 | 67 | Assert.AreEqual(MergedDefault.ALDT.Value, GetCached("merge_edit_all.esp").ALDT.Value); 68 | } 69 | 70 | [TestMethod] 71 | public void Weight() 72 | { 73 | LogRecords("ALDT.Weight", MergedDefault, BasicMergeMasters); 74 | 75 | Assert.AreEqual(MergedDefault.ALDT.Weight, GetCached("merge_edit_all.esp").ALDT.Weight); 76 | } 77 | 78 | [TestMethod] 79 | public void ScriptName() 80 | { 81 | LogRecords("SCRI.ScriptName", MergedDefault, BasicMergeMasters); 82 | 83 | Assert.AreEqual(MergedDefault.SCRI?.ScriptName, GetCached("merge_edit_all.esp").SCRI?.ScriptName); 84 | } 85 | 86 | [TestMethod] 87 | public void Effects() 88 | { 89 | LogRecordsEffects(MergedDefault, AddedEffectsMergeMasters); 90 | 91 | // Ensure we have the right number of effects. 92 | Assert.IsNotNull(MergedDefault.ENAM); 93 | Assert.IsNotNull(GetCached("merge_edit_all.esp").ENAM); 94 | Assert.IsNotNull(GetCached("merge_add_effects.esp").ENAM); 95 | 96 | Assert.AreEqual(MergedDefault.ENAM.Count, GetCached("merge_add_effects.esp").ENAM.Count); 97 | 98 | // Make sure we ended up with the right first effect. 99 | Assert.AreEqual(MergedDefault.ENAM[0].MagicEffect, TES3Lib.Enums.MagicEffect.BoundCuirass); 100 | 101 | // Make sure all the properties were respected from the changed effect. 102 | // We don't want a changed effect to end up with a bunch of invalid properties. 103 | Assert.AreEqual(MergedDefault.ENAM[0].Skill, GetCached("merge_edit_all.esp").ENAM[0].Skill); 104 | Assert.AreEqual(MergedDefault.ENAM[0].Attribute, GetCached("merge_edit_all.esp").ENAM[0].Attribute); 105 | Assert.AreEqual(MergedDefault.ENAM[0].Magnitude, GetCached("merge_edit_all.esp").ENAM[0].Magnitude); 106 | Assert.AreEqual(MergedDefault.ENAM[0].Duration, GetCached("merge_edit_all.esp").ENAM[0].Duration); 107 | 108 | // Ensure that we carried over the right second effect. 109 | Assert.AreEqual(MergedDefault.ENAM[1], GetCached("merge_add_effects.esp").ENAM[1]); 110 | 111 | void LogRecordsEffects(TES3Lib.Records.ALCH merged, params string[] plugins) 112 | { 113 | foreach (var parent in plugins) 114 | { 115 | var plugin = RecordCache[parent]; 116 | _logger.LogInformation("{Plugin} : {Count} ({Parent})", plugin, plugin.ENAM?.Count, parent); 117 | LogRecordsEnumerable(plugin.ENAM); 118 | } 119 | _logger.LogInformation("{MergedObjectsPluginName} : {Count}", MergedObjectsPluginName, merged.ENAM?.Count); 120 | LogRecordsEnumerable(merged.ENAM); 121 | } 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /TES3Merge.Tests/Merger/CREA.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Extensions.DependencyInjection; 2 | using Microsoft.Extensions.Logging; 3 | using Microsoft.VisualStudio.TestTools.UnitTesting; 4 | using System.Linq; 5 | using TES3Lib.Subrecords.Shared; 6 | using static TES3Merge.Tests.Utility; 7 | 8 | namespace TES3Merge.Tests.Merger; 9 | 10 | /// 11 | /// Special cases to consider for this record: 12 | /// 13 | [TestClass] 14 | public class CREA : RecordTest 15 | { 16 | internal TES3Lib.Records.CREA MergedDefault; 17 | 18 | private static readonly string[] BasicMergeMasters = new string[] { "merge_base.esp", "merge_edit_all.esp", "merge_minor_tweaks.esp" }; 19 | private static readonly string[] AddedMergeMasters = new string[] { "merge_base.esp", "merge_edit_all.esp", "merge_add_effects.esp", "merge_minor_tweaks.esp" }; 20 | 21 | public CREA() 22 | { 23 | MergedDefault = CreateMergedRecord("alit", AddedMergeMasters); 24 | 25 | _logger = _host.Services.GetRequiredService>(); 26 | } 27 | 28 | [TestMethod] 29 | public void EditorId() 30 | { 31 | LogRecords("NAME.EditorId", MergedDefault, BasicMergeMasters); 32 | 33 | Assert.AreEqual(MergedDefault.NAME.EditorId, GetCached("merge_minor_tweaks.esp").NAME.EditorId); 34 | } 35 | 36 | [TestMethod] 37 | public void DisplayName() 38 | { 39 | LogRecords("FNAM.FileName", MergedDefault, BasicMergeMasters); 40 | 41 | Assert.AreEqual(MergedDefault.FNAM.FileName, GetCached("merge_minor_tweaks.esp").FNAM.FileName); 42 | } 43 | 44 | [TestMethod] 45 | public void ScriptName() 46 | { 47 | LogRecords("SCRI.ScriptName", MergedDefault, BasicMergeMasters); 48 | 49 | Assert.AreEqual(MergedDefault.SCRI?.ScriptName, GetCached("merge_edit_all.esp").SCRI?.ScriptName); 50 | } 51 | 52 | // TODO 53 | 54 | //"race": "Dark Elf", 55 | //"class": "Barbarian", 56 | //"faction": "", 57 | //"head": "b_n_dark elf_m_head_05", 58 | //"hair": "b_n_dark elf_m_hair_08", 59 | //"npc_flags": 24, 60 | //"data": 61 | //"spells": [], 62 | //"ai_data": 63 | //"travel_destinations" 64 | 65 | [TestMethod] 66 | public void Inventory() 67 | { 68 | LogRecordsInventory(MergedDefault, AddedMergeMasters); 69 | 70 | // this is the load order 71 | var merge_base = GetCached("merge_base.esp").NPCO; 72 | var merge_edit_all = GetCached("merge_edit_all.esp").NPCO; 73 | var merge_add_effects = GetCached("merge_add_effects.esp").NPCO; 74 | var merge_minor_tweaks = GetCached("merge_minor_tweaks.esp").NPCO; 75 | 76 | // Ensure not null 77 | Assert.IsNotNull(merge_base); 78 | Assert.IsNotNull(merge_edit_all); 79 | Assert.IsNotNull(merge_add_effects); 80 | Assert.IsNotNull(merge_minor_tweaks); 81 | Assert.IsNotNull(MergedDefault.AIPackages); 82 | 83 | // TODO 84 | // make sure all the rest is inclusively merged 85 | 86 | // make sure all the rest is non-inclusively merged 87 | 88 | 89 | void LogRecordsInventory(TES3Lib.Records.CREA merged, params string[] plugins) 90 | { 91 | foreach (var parent in plugins) 92 | { 93 | var plugin = RecordCache[parent]; 94 | _logger.LogInformation("{Plugin} : {Count} ({Parent})", plugin, plugin.NPCO?.Count, parent); 95 | LogRecordsEnumerable(plugin.NPCO); 96 | } 97 | _logger.LogInformation("{MergedObjectsPluginName} : {Count}", MergedObjectsPluginName, merged.NPCO?.Count); 98 | LogRecordsEnumerable(merged.NPCO); 99 | } 100 | } 101 | 102 | [TestMethod] 103 | public void AIPackages() 104 | { 105 | LogRecordsAIPackages(MergedDefault, AddedMergeMasters); 106 | 107 | // this is the load order 108 | var merge_base = GetCached("merge_base.esp").AIPackages; 109 | var merge_edit_all = GetCached("merge_edit_all.esp").AIPackages; 110 | var merge_add_effects = GetCached("merge_add_effects.esp").AIPackages; 111 | var merge_minor_tweaks = GetCached("merge_minor_tweaks.esp").AIPackages; 112 | 113 | // Ensure not null 114 | Assert.IsNotNull(merge_base); 115 | Assert.IsNotNull(merge_edit_all); 116 | Assert.IsNotNull(merge_add_effects); 117 | Assert.IsNotNull(merge_minor_tweaks); 118 | Assert.IsNotNull(MergedDefault.AIPackages); 119 | 120 | // wander packages are merged 121 | // distance is taken from merge_add_effects 122 | var distanceMerged = (MergedDefault.AIPackages.First().AIPackage as AI_W)?.Distance; 123 | var distanceCorrect = (merge_add_effects.First().AIPackage as AI_W)?.Distance; 124 | Assert.AreEqual(distanceMerged, distanceCorrect); 125 | 126 | // duration is taken from merge_minor_tweaks 127 | var durationMerged = (MergedDefault.AIPackages.First().AIPackage as AI_W)?.Duration; 128 | var durationCorrect = (merge_minor_tweaks.First().AIPackage as AI_W)?.Duration; 129 | Assert.AreEqual(durationMerged, durationCorrect); 130 | 131 | // other packages are taken by load order 132 | // last esp has the correct amount of packages since no merging is done 133 | Assert.AreEqual(MergedDefault.AIPackages.Count, merge_minor_tweaks.Count); 134 | 135 | // or inclusively merged 136 | // TODO tests 137 | 138 | void LogRecordsAIPackages(TES3Lib.Records.CREA merged, params string[] plugins) 139 | { 140 | foreach (var parent in plugins) 141 | { 142 | var plugin = RecordCache[parent]; 143 | _logger.LogInformation("{Plugin} : {Count} ({Parent})", plugin, plugin.AIPackages?.Count, parent); 144 | LogRecordsEnumerable(plugin.AIPackages?.Select(x => x.AIPackage)); 145 | } 146 | _logger.LogInformation("{MergedObjectsPluginName} : {Count}", MergedObjectsPluginName, merged.AIPackages?.Count); 147 | LogRecordsEnumerable(merged.AIPackages?.Select(x => x.AIPackage)); 148 | } 149 | } 150 | } 151 | -------------------------------------------------------------------------------- /TES3Merge.Tests/Merger/LEVC.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Extensions.DependencyInjection; 2 | using Microsoft.Extensions.Logging; 3 | using Microsoft.VisualStudio.TestTools.UnitTesting; 4 | using System.Linq; 5 | using TES3Lib.Subrecords.Shared; 6 | using static TES3Merge.Tests.Utility; 7 | 8 | namespace TES3Merge.Tests.Merger; 9 | 10 | /// 11 | /// Special cases to consider for this record: 12 | /// 13 | [TestClass] 14 | public class LEVC : RecordTest 15 | { 16 | internal TES3Lib.Records.LEVC Merged__aa_sitters_bardrink_telmora; 17 | 18 | private static readonly string[] FriendsAndFoesMasters = new string[] { "F&F_base.esm", "F&F_NoSitters.ESP", "F&F_scarce.ESP" }; 19 | 20 | public LEVC() 21 | { 22 | Merged__aa_sitters_bardrink_telmora = CreateMergedRecord("_aa_sitters_bardrink_telmora", FriendsAndFoesMasters); 23 | 24 | _logger = _host.Services.GetRequiredService>(); 25 | } 26 | 27 | [TestMethod] 28 | public void EditorId() 29 | { 30 | LogRecords("NAME.EditorId", Merged__aa_sitters_bardrink_telmora, FriendsAndFoesMasters); 31 | 32 | Assert.AreEqual("_aa_sitters_bardrink_telmora\0", Merged__aa_sitters_bardrink_telmora.NAME.EditorId); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /TES3Merge.Tests/Merger/NPC_.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Extensions.DependencyInjection; 2 | using Microsoft.Extensions.Logging; 3 | using Microsoft.VisualStudio.TestTools.UnitTesting; 4 | using System.Linq; 5 | using TES3Lib.Subrecords.Shared; 6 | using static TES3Merge.Tests.Utility; 7 | 8 | namespace TES3Merge.Tests.Merger; 9 | 10 | /// 11 | /// Special cases to consider for this record: 12 | /// 13 | [TestClass] 14 | public class NPC_ : RecordTest 15 | { 16 | internal TES3Lib.Records.NPC_ MergedDefault; 17 | 18 | private static readonly string[] BasicMergeMasters = new string[] { "merge_base.esp", "merge_edit_all.esp", "merge_minor_tweaks.esp" }; 19 | private static readonly string[] AddedMergeMasters = new string[] { "merge_base.esp", "merge_edit_all.esp", "merge_add_effects.esp", "merge_minor_tweaks.esp" }; 20 | 21 | public NPC_() 22 | { 23 | MergedDefault = CreateMergedRecord("zennammu urshumusa", AddedMergeMasters); 24 | 25 | _logger = _host.Services.GetRequiredService>(); 26 | } 27 | 28 | [TestMethod] 29 | public void EditorId() 30 | { 31 | LogRecords("NAME.EditorId", MergedDefault, BasicMergeMasters); 32 | 33 | Assert.AreEqual(MergedDefault.NAME.EditorId, GetCached("merge_minor_tweaks.esp").NAME.EditorId); 34 | } 35 | 36 | [TestMethod] 37 | public void DisplayName() 38 | { 39 | LogRecords("FNAM.FileName", MergedDefault, BasicMergeMasters); 40 | 41 | Assert.AreEqual(MergedDefault.FNAM.FileName, GetCached("merge_minor_tweaks.esp").FNAM.FileName); 42 | } 43 | 44 | [TestMethod] 45 | public void ScriptName() 46 | { 47 | LogRecords("SCRI.ScriptName", MergedDefault, BasicMergeMasters); 48 | 49 | Assert.AreEqual(MergedDefault.SCRI?.ScriptName, GetCached("merge_edit_all.esp").SCRI?.ScriptName); 50 | } 51 | 52 | // TODO 53 | 54 | //"race": "Dark Elf", 55 | //"class": "Barbarian", 56 | //"faction": "", 57 | //"head": "b_n_dark elf_m_head_05", 58 | //"hair": "b_n_dark elf_m_hair_08", 59 | //"npc_flags": 24, 60 | //"data": 61 | //"spells": [], 62 | //"ai_data": 63 | //"travel_destinations" 64 | 65 | [TestMethod] 66 | public void Inventory() 67 | { 68 | LogRecordsInventory(MergedDefault, AddedMergeMasters); 69 | 70 | // this is the load order 71 | var merge_base = GetCached("merge_base.esp").NPCO; 72 | var merge_edit_all = GetCached("merge_edit_all.esp").NPCO; 73 | var merge_add_effects = GetCached("merge_add_effects.esp").NPCO; 74 | var merge_minor_tweaks = GetCached("merge_minor_tweaks.esp").NPCO; 75 | 76 | // Ensure not null 77 | Assert.IsNotNull(merge_base); 78 | Assert.IsNotNull(merge_edit_all); 79 | Assert.IsNotNull(merge_add_effects); 80 | Assert.IsNotNull(merge_minor_tweaks); 81 | Assert.IsNotNull(MergedDefault.AIPackages); 82 | 83 | // TODO 84 | // make sure all the rest is inclusively merged 85 | 86 | // make sure all the rest is non-inclusively merged 87 | 88 | 89 | void LogRecordsInventory(TES3Lib.Records.NPC_ merged, params string[] plugins) 90 | { 91 | foreach (var parent in plugins) 92 | { 93 | var plugin = RecordCache[parent]; 94 | _logger.LogInformation("{Plugin} : {Count} ({Parent})", plugin, plugin.NPCO?.Count, parent); 95 | LogRecordsEnumerable(plugin.NPCO); 96 | } 97 | _logger.LogInformation("{MergedObjectsPluginName} : {Count}", MergedObjectsPluginName, merged.NPCO?.Count); 98 | LogRecordsEnumerable(merged.NPCO); 99 | } 100 | } 101 | 102 | [TestMethod] 103 | public void AIPackages() 104 | { 105 | LogRecordsAIPackages(MergedDefault, AddedMergeMasters); 106 | 107 | // this is the load order 108 | var merge_base = GetCached("merge_base.esp").AIPackages; 109 | var merge_edit_all = GetCached("merge_edit_all.esp").AIPackages; 110 | var merge_add_effects = GetCached("merge_add_effects.esp").AIPackages; 111 | var merge_minor_tweaks = GetCached("merge_minor_tweaks.esp").AIPackages; 112 | 113 | // Ensure not null 114 | Assert.IsNotNull(merge_base); 115 | Assert.IsNotNull(merge_edit_all); 116 | Assert.IsNotNull(merge_add_effects); 117 | Assert.IsNotNull(merge_minor_tweaks); 118 | Assert.IsNotNull(MergedDefault.AIPackages); 119 | 120 | // wander packages are merged 121 | // distance is taken from merge_add_effects 122 | var distanceMerged = (MergedDefault.AIPackages.First().AIPackage as AI_W)?.Distance; 123 | var distanceCorrect = (merge_add_effects.First().AIPackage as AI_W)?.Distance; 124 | Assert.AreEqual(distanceMerged, distanceCorrect); 125 | 126 | // duration is taken from merge_minor_tweaks 127 | var durationMerged = (MergedDefault.AIPackages.First().AIPackage as AI_W)?.Duration; 128 | var durationCorrect = (merge_minor_tweaks.First().AIPackage as AI_W)?.Duration; 129 | Assert.AreEqual(durationMerged, durationCorrect); 130 | 131 | // other packages are taken by load order 132 | // last esp has the correct amount of packages since no merging is done 133 | Assert.AreEqual(MergedDefault.AIPackages.Count, merge_minor_tweaks.Count); 134 | 135 | // or inclusively merged 136 | // TODO tests 137 | 138 | void LogRecordsAIPackages(TES3Lib.Records.NPC_ merged, params string[] plugins) 139 | { 140 | foreach (var parent in plugins) 141 | { 142 | var plugin = RecordCache[parent]; 143 | _logger.LogInformation("{Plugin} : {Count} ({Parent})", plugin, plugin.AIPackages?.Count, parent); 144 | LogRecordsEnumerable(plugin.AIPackages?.Select(x => x.AIPackage)); 145 | } 146 | _logger.LogInformation("{MergedObjectsPluginName} : {Count}", MergedObjectsPluginName, merged.AIPackages?.Count); 147 | LogRecordsEnumerable(merged.AIPackages?.Select(x => x.AIPackage)); 148 | } 149 | } 150 | } 151 | -------------------------------------------------------------------------------- /TES3Merge.Tests/Parser/CELL.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Extensions.DependencyInjection; 2 | using Microsoft.Extensions.Logging; 3 | using Microsoft.VisualStudio.TestTools.UnitTesting; 4 | using System; 5 | using System.Collections.Generic; 6 | using System.IO; 7 | using System.Linq; 8 | using System.Reflection; 9 | using System.Text; 10 | using System.Threading.Tasks; 11 | using TES3Lib; 12 | using TES3Lib.Base; 13 | using static TES3Merge.Tests.Utility; 14 | 15 | namespace TES3Merge.Tests.Parser 16 | { 17 | [TestClass] 18 | public class CELL 19 | { 20 | [TestMethod] 21 | public void Parse() 22 | { 23 | // load esp 24 | var path = Path.Combine("Plugins", "F&F_base.esm"); 25 | var file = TES3.TES3LoadSync(path, new() { "CELL" }); 26 | 27 | // serialize CELL to bytes 28 | var errored = 0; 29 | foreach (var r in file.Records) 30 | { 31 | if (r is null) 32 | { 33 | continue; 34 | } 35 | if (!r.Name.Equals("CELL")) 36 | { 37 | continue; 38 | } 39 | 40 | var newRecord = Activator.CreateInstance(r.GetType(), new object[] { r.GetRawLoadedBytes() }) as TES3Lib.Records.CELL ?? throw new Exception("Could not create activator instance."); 41 | 42 | var newSerialized = newRecord.SerializeRecordForMerge(); 43 | var lastSerialized = (r as TES3Lib.Records.CELL)!.SerializeRecordForMerge(); 44 | 45 | var result = lastSerialized.SequenceEqual(newSerialized); 46 | if (!result) 47 | { 48 | //var outdir = new FileInfo(path).Directory?.FullName; 49 | //File.WriteAllBytes(Path.Combine(outdir!, "file1.bin"), lastSerialized); 50 | //File.WriteAllBytes(Path.Combine(outdir!, "file2.bin"), newSerialized); 51 | 52 | errored++; 53 | } 54 | } 55 | 56 | Assert.IsTrue(errored == 0); 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /TES3Merge.Tests/Plugins/AOF Potions Recolored.esp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NullCascade/TES3Merge/1058246a1a94ab9f045e2574e5022de6516db4d2/TES3Merge.Tests/Plugins/AOF Potions Recolored.esp -------------------------------------------------------------------------------- /TES3Merge.Tests/Plugins/BTB's Game Improvements (Necro Edit) Tweaked.esp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NullCascade/TES3Merge/1058246a1a94ab9f045e2574e5022de6516db4d2/TES3Merge.Tests/Plugins/BTB's Game Improvements (Necro Edit) Tweaked.esp -------------------------------------------------------------------------------- /TES3Merge.Tests/Plugins/Bob's Diverse Blood.ESP: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NullCascade/TES3Merge/1058246a1a94ab9f045e2574e5022de6516db4d2/TES3Merge.Tests/Plugins/Bob's Diverse Blood.ESP -------------------------------------------------------------------------------- /TES3Merge.Tests/Plugins/F&F_NoSitters.ESP: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NullCascade/TES3Merge/1058246a1a94ab9f045e2574e5022de6516db4d2/TES3Merge.Tests/Plugins/F&F_NoSitters.ESP -------------------------------------------------------------------------------- /TES3Merge.Tests/Plugins/F&F_base.esm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NullCascade/TES3Merge/1058246a1a94ab9f045e2574e5022de6516db4d2/TES3Merge.Tests/Plugins/F&F_base.esm -------------------------------------------------------------------------------- /TES3Merge.Tests/Plugins/F&F_scarce.ESP: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NullCascade/TES3Merge/1058246a1a94ab9f045e2574e5022de6516db4d2/TES3Merge.Tests/Plugins/F&F_scarce.ESP -------------------------------------------------------------------------------- /TES3Merge.Tests/Plugins/Morrowind.esm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NullCascade/TES3Merge/1058246a1a94ab9f045e2574e5022de6516db4d2/TES3Merge.Tests/Plugins/Morrowind.esm -------------------------------------------------------------------------------- /TES3Merge.Tests/Plugins/Patch for Purists - Book Typos.ESP: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NullCascade/TES3Merge/1058246a1a94ab9f045e2574e5022de6516db4d2/TES3Merge.Tests/Plugins/Patch for Purists - Book Typos.ESP -------------------------------------------------------------------------------- /TES3Merge.Tests/Plugins/Patch for Purists - Semi-Purist Fixes.ESP: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NullCascade/TES3Merge/1058246a1a94ab9f045e2574e5022de6516db4d2/TES3Merge.Tests/Plugins/Patch for Purists - Semi-Purist Fixes.ESP -------------------------------------------------------------------------------- /TES3Merge.Tests/Plugins/Patch for Purists.esm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NullCascade/TES3Merge/1058246a1a94ab9f045e2574e5022de6516db4d2/TES3Merge.Tests/Plugins/Patch for Purists.esm -------------------------------------------------------------------------------- /TES3Merge.Tests/Plugins/ST_Alchemy_Balance_Sri_1.4.esp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NullCascade/TES3Merge/1058246a1a94ab9f045e2574e5022de6516db4d2/TES3Merge.Tests/Plugins/ST_Alchemy_Balance_Sri_1.4.esp -------------------------------------------------------------------------------- /TES3Merge.Tests/Plugins/Wares-base.esm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NullCascade/TES3Merge/1058246a1a94ab9f045e2574e5022de6516db4d2/TES3Merge.Tests/Plugins/Wares-base.esm -------------------------------------------------------------------------------- /TES3Merge.Tests/Plugins/merge_add_effects.esp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NullCascade/TES3Merge/1058246a1a94ab9f045e2574e5022de6516db4d2/TES3Merge.Tests/Plugins/merge_add_effects.esp -------------------------------------------------------------------------------- /TES3Merge.Tests/Plugins/merge_base.esp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NullCascade/TES3Merge/1058246a1a94ab9f045e2574e5022de6516db4d2/TES3Merge.Tests/Plugins/merge_base.esp -------------------------------------------------------------------------------- /TES3Merge.Tests/Plugins/merge_edit_all.esp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NullCascade/TES3Merge/1058246a1a94ab9f045e2574e5022de6516db4d2/TES3Merge.Tests/Plugins/merge_edit_all.esp -------------------------------------------------------------------------------- /TES3Merge.Tests/Plugins/merge_minor_tweaks.esp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NullCascade/TES3Merge/1058246a1a94ab9f045e2574e5022de6516db4d2/TES3Merge.Tests/Plugins/merge_minor_tweaks.esp -------------------------------------------------------------------------------- /TES3Merge.Tests/Plugins/tes3conv.exe: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NullCascade/TES3Merge/1058246a1a94ab9f045e2574e5022de6516db4d2/TES3Merge.Tests/Plugins/tes3conv.exe -------------------------------------------------------------------------------- /TES3Merge.Tests/Plugins/to_esp.bat: -------------------------------------------------------------------------------- 1 | tes3conv.exe -o "merge_add_effects.json" "merge_add_effects.esp" 2 | tes3conv.exe -o "merge_base.json" "merge_base.esp" 3 | tes3conv.exe -o "merge_edit_all.json" "merge_edit_all.esp" 4 | tes3conv.exe -o "merge_minor_tweaks.json" "merge_minor_tweaks.esp" 5 | tes3conv.exe -o "Morrowind.json" "Morrowind.esm" 6 | tes3conv.exe -o "Bob's Diverse Blood.json" "Bob's Diverse Blood.esp" 7 | tes3conv.exe -o "Patch for Purists.json" "Patch for Purists.esm" -------------------------------------------------------------------------------- /TES3Merge.Tests/Plugins/to_json.bat: -------------------------------------------------------------------------------- 1 | tes3conv.exe -o "merge_add_effects.esp" "merge_add_effects.json" 2 | tes3conv.exe -o "merge_base.esp" "merge_base.json" 3 | tes3conv.exe -o "merge_edit_all.esp" "merge_edit_all.json" 4 | tes3conv.exe -o "merge_minor_tweaks.esp" "merge_minor_tweaks.json" 5 | tes3conv.exe -o "Morrowind.esm" "Morrowind.json" 6 | tes3conv.exe -o "Bob's Diverse Blood.esp" "Bob's Diverse Blood.json" 7 | tes3conv.exe -o "Patch for Purists.esm" "Patch for Purists.json" -------------------------------------------------------------------------------- /TES3Merge.Tests/Properties/Resources.Designer.cs: -------------------------------------------------------------------------------- 1 | //------------------------------------------------------------------------------ 2 | // 3 | // This code was generated by a tool. 4 | // Runtime Version:4.0.30319.42000 5 | // 6 | // Changes to this file may cause incorrect behavior and will be lost if 7 | // the code is regenerated. 8 | // 9 | //------------------------------------------------------------------------------ 10 | 11 | namespace TES3Merge.Tests.Properties { 12 | using System; 13 | 14 | 15 | /// 16 | /// A strongly-typed resource class, for looking up localized strings, etc. 17 | /// 18 | // This class was auto-generated by the StronglyTypedResourceBuilder 19 | // class via a tool like ResGen or Visual Studio. 20 | // To add or remove a member, edit your .ResX file then rerun ResGen 21 | // with the /str option, or rebuild your VS project. 22 | [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "17.0.0.0")] 23 | [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] 24 | [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] 25 | internal class Resources { 26 | 27 | private static global::System.Resources.ResourceManager resourceMan; 28 | 29 | private static global::System.Globalization.CultureInfo resourceCulture; 30 | 31 | [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] 32 | internal Resources() { 33 | } 34 | 35 | /// 36 | /// Returns the cached ResourceManager instance used by this class. 37 | /// 38 | [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] 39 | internal static global::System.Resources.ResourceManager ResourceManager { 40 | get { 41 | if (object.ReferenceEquals(resourceMan, null)) { 42 | global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("TES3Merge.Tests.Properties.Resources", typeof(Resources).Assembly); 43 | resourceMan = temp; 44 | } 45 | return resourceMan; 46 | } 47 | } 48 | 49 | /// 50 | /// Overrides the current thread's CurrentUICulture property for all 51 | /// resource lookups using this strongly typed resource class. 52 | /// 53 | [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] 54 | internal static global::System.Globalization.CultureInfo Culture { 55 | get { 56 | return resourceCulture; 57 | } 58 | set { 59 | resourceCulture = value; 60 | } 61 | } 62 | 63 | /// 64 | /// Looks up a localized string similar to D:\Games\Morrowind. 65 | /// 66 | internal static string MorrowindInstallDirectory { 67 | get { 68 | return ResourceManager.GetString("MorrowindInstallDirectory", resourceCulture); 69 | } 70 | } 71 | 72 | /// 73 | /// Looks up a localized string similar to D:\Games\OpenMW. 74 | /// 75 | internal static string OpenMWInstallDirectory { 76 | get { 77 | return ResourceManager.GetString("OpenMWInstallDirectory", resourceCulture); 78 | } 79 | } 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /TES3Merge.Tests/Properties/Resources.resx: -------------------------------------------------------------------------------- 1 |  2 | 3 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | text/microsoft-resx 110 | 111 | 112 | 2.0 113 | 114 | 115 | System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 116 | 117 | 118 | System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 119 | 120 | 121 | D:\Games\Morrowind 122 | 123 | 124 | D:\Games\OpenMW 125 | 126 | -------------------------------------------------------------------------------- /TES3Merge.Tests/RecordTest.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Extensions.DependencyInjection; 2 | using Microsoft.Extensions.Hosting; 3 | using Microsoft.Extensions.Logging; 4 | using Serilog; 5 | using Serilog.Events; 6 | using System; 7 | using System.Collections; 8 | using System.Collections.Generic; 9 | using System.Linq; 10 | using static TES3Merge.Tests.FileLoader; 11 | 12 | namespace TES3Merge.Tests.Merger; 13 | 14 | public abstract class RecordTest where T : TES3Lib.Base.Record 15 | { 16 | protected Microsoft.Extensions.Logging.ILogger _logger; 17 | protected IHost _host; 18 | 19 | public RecordTest() 20 | { 21 | Log.Logger = new LoggerConfiguration() 22 | .MinimumLevel.Debug() 23 | .MinimumLevel.Override("Microsoft", LogEventLevel.Information) 24 | .Enrich.FromLogContext() 25 | .WriteTo.Console(outputTemplate: "[{Level:u3}] {Message:lj}{NewLine}{Exception}") 26 | .CreateLogger(); 27 | 28 | var hostBuilder = Host.CreateDefaultBuilder().UseSerilog(); 29 | 30 | _host = hostBuilder.Build(); 31 | _logger = _host.Services.GetRequiredService>>(); 32 | } 33 | 34 | #region Record Management 35 | internal static Dictionary RecordCache = new(); 36 | 37 | internal static T GetCached(string plugin) 38 | { 39 | return RecordCache[plugin]; 40 | } 41 | 42 | internal static T CreateMergedRecord(string objectId, params string[] parentFiles) 43 | { 44 | // Load files. 45 | List parents = new(); 46 | foreach (var file in parentFiles) 47 | { 48 | var parent = GetPlugin(file) ?? throw new Exception($"Parent file {file} could not be loaded."); 49 | parents.Add(parent); 50 | } 51 | 52 | // Find records. 53 | List records = new(); 54 | foreach (var parent in parents) 55 | { 56 | var record = RecordCache.ContainsKey(parent.Path) 57 | ? RecordCache[parent.Path] 58 | : parent.FindRecord(objectId) as T ?? throw new Exception($"Parent file {parent.Path} does not have record {objectId}."); 59 | records.Add(record); 60 | RecordCache[parent.Path] = record; 61 | } 62 | 63 | // Create merge. 64 | var first = records.First(); 65 | var last = records.Last(); 66 | var merged = Activator.CreateInstance(last.GetType(), new object[] { last.SerializeRecord() }) as T ?? throw new Exception("Could not create record."); 67 | for (var i = records.Count - 2; i > 0; i--) 68 | { 69 | RecordMerger.Merge(merged, first, records[i]); 70 | } 71 | return merged; 72 | } 73 | #endregion 74 | 75 | #region Logging 76 | internal void LogRecordValue(string property, string plugin) 77 | { 78 | LogRecordValue(GetCached(plugin), property, plugin); 79 | } 80 | 81 | internal void LogRecordValue(T record, string property, string plugin = Utility.MergedObjectsPluginName) 82 | { 83 | _logger.LogInformation("{plugin} : {PropertyValue}", plugin, Utility.GetPropertyValue(record, property)); 84 | } 85 | 86 | internal void LogRecords(string property, T merged, params string[] plugins) 87 | { 88 | foreach (var plugin in plugins) 89 | { 90 | LogRecordValue(property, plugin); 91 | } 92 | LogRecordValue(merged, property); 93 | } 94 | 95 | internal void LogRecordsEnumerable(IEnumerable? items) 96 | { 97 | if (items is null) 98 | { 99 | return; 100 | } 101 | 102 | foreach (var item in items) 103 | { 104 | _logger.LogInformation(" - {Name}: {@Item}", item.GetType().Name, item); 105 | } 106 | } 107 | 108 | #endregion 109 | } 110 | -------------------------------------------------------------------------------- /TES3Merge.Tests/TES3Merge.Tests.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net6.0 5 | enable 6 | true 7 | false 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | True 46 | True 47 | Resources.resx 48 | 49 | 50 | 51 | 52 | 53 | ResXFileCodeGenerator 54 | Resources.Designer.cs 55 | 56 | 57 | 58 | 59 | 60 | PreserveNewest 61 | 62 | 63 | PreserveNewest 64 | 65 | 66 | PreserveNewest 67 | 68 | 69 | PreserveNewest 70 | 71 | 72 | PreserveNewest 73 | 74 | 75 | PreserveNewest 76 | 77 | 78 | PreserveNewest 79 | 80 | 81 | PreserveNewest 82 | 83 | 84 | PreserveNewest 85 | 86 | 87 | PreserveNewest 88 | 89 | 90 | PreserveNewest 91 | 92 | 93 | PreserveNewest 94 | 95 | 96 | PreserveNewest 97 | 98 | 99 | PreserveNewest 100 | 101 | 102 | PreserveNewest 103 | 104 | 105 | PreserveNewest 106 | 107 | 108 | 109 | 110 | -------------------------------------------------------------------------------- /TES3Merge.Tests/Utility.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | 5 | namespace TES3Merge.Tests; 6 | 7 | internal static class Utility 8 | { 9 | internal const string MergedObjectsPluginName = "Merged Objects.esp"; 10 | 11 | internal static TES3Lib.Base.Record? FindRecord(this TES3Lib.TES3 plugin, string id) 12 | { 13 | return plugin.Records.FirstOrDefault(r => r.GetEditorId() == $"{id}\0"); 14 | } 15 | 16 | internal static object? GetPropertyValue(object src, string property) 17 | { 18 | if (src is null) throw new ArgumentException("Value cannot be null.", nameof(src)); 19 | if (property is null) throw new ArgumentException("Value cannot be null.", nameof(property)); 20 | 21 | if (property.Contains('.')) //complex type nested 22 | { 23 | var temp = property.Split(new char[] { '.' }, 2); 24 | var value = GetPropertyValue(src, temp[0]); 25 | if (value is null) return null; 26 | return GetPropertyValue(value, temp[1]); 27 | } 28 | else 29 | { 30 | return src.GetType().GetProperty(property)?.GetValue(src, null); 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /TES3Merge.sln: -------------------------------------------------------------------------------- 1 |  2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio Version 17 4 | VisualStudioVersion = 17.0.32014.148 5 | MinimumVisualStudioVersion = 10.0.40219.1 6 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TES3Lib", "TES3Tool\TES3Lib\TES3Lib.csproj", "{EB6C834A-B58E-4BBF-8BC5-4DEE6A925B4B}" 7 | EndProject 8 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Utility", "TES3Tool\Utility\Utility.csproj", "{1BC5CBBB-E155-4D9A-BC55-6FE75109C1B9}" 9 | EndProject 10 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TES3Merge", "TES3Merge\TES3Merge.csproj", "{5E35B4F8-4150-41D8-B78C-64ED0EF8871E}" 11 | ProjectSection(ProjectDependencies) = postProject 12 | {EB6C834A-B58E-4BBF-8BC5-4DEE6A925B4B} = {EB6C834A-B58E-4BBF-8BC5-4DEE6A925B4B} 13 | {1BC5CBBB-E155-4D9A-BC55-6FE75109C1B9} = {1BC5CBBB-E155-4D9A-BC55-6FE75109C1B9} 14 | EndProjectSection 15 | EndProject 16 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TES3Merge.Tests", "TES3Merge.Tests\TES3Merge.Tests.csproj", "{5F6DDA09-33B6-4FAC-AC34-5FABBFA6D0F6}" 17 | ProjectSection(ProjectDependencies) = postProject 18 | {5E35B4F8-4150-41D8-B78C-64ED0EF8871E} = {5E35B4F8-4150-41D8-B78C-64ED0EF8871E} 19 | EndProjectSection 20 | EndProject 21 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{64D6AD8F-D337-411B-A5D9-0A2F2FDA811E}" 22 | ProjectSection(SolutionItems) = preProject 23 | .editorconfig = .editorconfig 24 | EndProjectSection 25 | EndProject 26 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tests", "Tests", "{95C57EC3-AAEB-493D-8FAC-D54AD18C5BFF}" 27 | EndProject 28 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Shared", "Shared", "{491D16BA-EA9E-42AB-A2AC-455F22A9B4B2}" 29 | EndProject 30 | Global 31 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 32 | Debug|Any CPU = Debug|Any CPU 33 | Release|Any CPU = Release|Any CPU 34 | EndGlobalSection 35 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 36 | {EB6C834A-B58E-4BBF-8BC5-4DEE6A925B4B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 37 | {EB6C834A-B58E-4BBF-8BC5-4DEE6A925B4B}.Debug|Any CPU.Build.0 = Debug|Any CPU 38 | {EB6C834A-B58E-4BBF-8BC5-4DEE6A925B4B}.Release|Any CPU.ActiveCfg = Release|Any CPU 39 | {EB6C834A-B58E-4BBF-8BC5-4DEE6A925B4B}.Release|Any CPU.Build.0 = Release|Any CPU 40 | {1BC5CBBB-E155-4D9A-BC55-6FE75109C1B9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 41 | {1BC5CBBB-E155-4D9A-BC55-6FE75109C1B9}.Debug|Any CPU.Build.0 = Debug|Any CPU 42 | {1BC5CBBB-E155-4D9A-BC55-6FE75109C1B9}.Release|Any CPU.ActiveCfg = Release|Any CPU 43 | {1BC5CBBB-E155-4D9A-BC55-6FE75109C1B9}.Release|Any CPU.Build.0 = Release|Any CPU 44 | {5E35B4F8-4150-41D8-B78C-64ED0EF8871E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 45 | {5E35B4F8-4150-41D8-B78C-64ED0EF8871E}.Debug|Any CPU.Build.0 = Debug|Any CPU 46 | {5E35B4F8-4150-41D8-B78C-64ED0EF8871E}.Release|Any CPU.ActiveCfg = Release|Any CPU 47 | {5E35B4F8-4150-41D8-B78C-64ED0EF8871E}.Release|Any CPU.Build.0 = Release|Any CPU 48 | {5F6DDA09-33B6-4FAC-AC34-5FABBFA6D0F6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 49 | {5F6DDA09-33B6-4FAC-AC34-5FABBFA6D0F6}.Debug|Any CPU.Build.0 = Debug|Any CPU 50 | {5F6DDA09-33B6-4FAC-AC34-5FABBFA6D0F6}.Release|Any CPU.ActiveCfg = Release|Any CPU 51 | {5F6DDA09-33B6-4FAC-AC34-5FABBFA6D0F6}.Release|Any CPU.Build.0 = Release|Any CPU 52 | EndGlobalSection 53 | GlobalSection(SolutionProperties) = preSolution 54 | HideSolutionNode = FALSE 55 | EndGlobalSection 56 | GlobalSection(NestedProjects) = preSolution 57 | {EB6C834A-B58E-4BBF-8BC5-4DEE6A925B4B} = {491D16BA-EA9E-42AB-A2AC-455F22A9B4B2} 58 | {1BC5CBBB-E155-4D9A-BC55-6FE75109C1B9} = {491D16BA-EA9E-42AB-A2AC-455F22A9B4B2} 59 | {5F6DDA09-33B6-4FAC-AC34-5FABBFA6D0F6} = {95C57EC3-AAEB-493D-8FAC-D54AD18C5BFF} 60 | EndGlobalSection 61 | GlobalSection(ExtensibilityGlobals) = postSolution 62 | SolutionGuid = {3535F6FB-C6C9-4C94-8B77-21E8B5E90365} 63 | EndGlobalSection 64 | EndGlobal 65 | -------------------------------------------------------------------------------- /TES3Merge/AssemblyInfo.cs: -------------------------------------------------------------------------------- 1 | using System.Runtime.CompilerServices; 2 | using System.Runtime.InteropServices; 3 | 4 | // In SDK-style projects such as this one, several assembly attributes that were historically 5 | // defined in this file are now automatically added during build and populated with 6 | // values defined in project properties. For details of which attributes are included 7 | // and how to customise this process see: https://aka.ms/assembly-info-properties 8 | 9 | 10 | // Setting ComVisible to false makes the types in this assembly not visible to COM 11 | // components. If you need to access a type in this assembly from COM, set the ComVisible 12 | // attribute to true on that type. 13 | 14 | [assembly: ComVisible(false)] 15 | 16 | // The following GUID is for the ID of the typelib if this project is exposed to COM. 17 | 18 | [assembly: Guid("07e58b19-a0f4-4b20-831a-8478e9546029")] 19 | 20 | [assembly: InternalsVisibleTo("TES3Merge.Tests")] 21 | -------------------------------------------------------------------------------- /TES3Merge/Commands/MultipatchCommand.cs: -------------------------------------------------------------------------------- 1 | using System.CommandLine; 2 | using static TES3Merge.Util.Util; 3 | 4 | namespace TES3Merge.Commands; 5 | 6 | public class MultipatchCommand : Command 7 | { 8 | private new const string Description = "Create a multipatch that merges levelled lists and fixes various other bugs"; 9 | private new const string Name = "multipatch"; 10 | 11 | public MultipatchCommand() : base(Name, Description) 12 | { 13 | this.SetHandler(() => MultipatchAction.Run()); 14 | } 15 | } 16 | 17 | internal static class MultipatchAction 18 | { 19 | /// 20 | /// Main command wrapper 21 | /// 22 | internal static void Run() 23 | { 24 | #if DEBUG == false 25 | try 26 | #else 27 | //Console.WriteLine("Press any button to continue..."); 28 | //Console.ReadLine(); 29 | #endif 30 | { 31 | Multipatch(); 32 | } 33 | 34 | #if DEBUG == false 35 | catch (Exception e) 36 | { 37 | Console.WriteLine("A serious error has occurred. Please post the TES3Merge.log file to GitHub: https://github.com/NullCascade/TES3Merge/issues"); 38 | Logger.WriteLine("An unhandled exception has occurred. Traceback:"); 39 | Logger.WriteLine(e.Message); 40 | Logger.WriteLine(e.StackTrace); 41 | } 42 | #endif 43 | 44 | ShowCompletionPrompt(); 45 | } 46 | 47 | /// 48 | /// tes3cmd multipatch 49 | /// Merge LEVI and LEVC 50 | /// 51 | /// 52 | private static void Multipatch() 53 | { 54 | using var ssw = new ScopedStopwatch(); 55 | 56 | MergeAction.Merge( 57 | new MergeAction.Settings( 58 | true, 59 | new List() { "LEVI", "LEVC", "CREA", "CELL" }, 60 | null, 61 | Util.EPatch.All, 62 | false, 63 | true, 64 | "multipatch.esp")); 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /TES3Merge/Commands/VerifyCommand.cs: -------------------------------------------------------------------------------- 1 | /* 2 | * TODO 3 | * 4 | * check NIF paths for each esp 5 | * 6 | */ 7 | 8 | using System.Collections.Concurrent; 9 | using System.CommandLine; 10 | using System.Reflection; 11 | using System.Text.Json; 12 | using System.Text.RegularExpressions; 13 | using TES3Lib; 14 | using TES3Lib.Base; 15 | using static TES3Merge.Util.Util; 16 | 17 | namespace TES3Merge.Commands; 18 | 19 | public class VerifyCommand : Command 20 | { 21 | private new const string Description = "Checks esps for missing file paths"; 22 | private new const string Name = "verify"; 23 | 24 | public VerifyCommand() : base(Name, Description) 25 | { 26 | this.SetHandler(() => VerifyAction.Run()); 27 | } 28 | } 29 | 30 | internal static class VerifyAction 31 | { 32 | /// 33 | /// Main command wrapper 34 | /// 35 | internal static void Run() 36 | { 37 | #if DEBUG == false 38 | try 39 | #endif 40 | { 41 | Verify(); 42 | } 43 | 44 | #if DEBUG == false 45 | catch (Exception e) 46 | { 47 | Console.WriteLine("A serious error has occurred. Please post the TES3Merge.log file to GitHub: https://github.com/NullCascade/TES3Merge/issues"); 48 | Logger.WriteLine("An unhandled exception has occurred. Traceback:"); 49 | Logger.WriteLine(e.Message); 50 | Logger.WriteLine(e.StackTrace); 51 | } 52 | #endif 53 | 54 | ShowCompletionPrompt(); 55 | } 56 | 57 | /// 58 | /// Verifies all active esps in the current Morrowind directory 59 | /// Parses all enabled records of the plugin and checks paths if the file exists 60 | /// 61 | /// 62 | private static void Verify() 63 | { 64 | ArgumentNullException.ThrowIfNull(CurrentInstallation); 65 | 66 | using var ssw = new ScopedStopwatch(); 67 | LoadConfig(); 68 | ArgumentNullException.ThrowIfNull(Configuration); 69 | 70 | // get merge tags 71 | var (supportedMergeTags, objectIdFilters) = GetMergeTags(); 72 | 73 | // Shorthand install access. 74 | var sortedMasters = CurrentInstallation.GameFiles; 75 | 76 | // Go through and build a record list. 77 | var reportDict = new ConcurrentDictionary>>(); 78 | WriteToLogAndConsole($"Parsing plugins ... "); 79 | //foreach (var sortedMaster in sortedMasters) 80 | Parallel.ForEach(sortedMasters, sortedMaster => 81 | { 82 | // this can be enabled actually 83 | if (Path.GetExtension(sortedMaster) == ".esm") 84 | { 85 | //continue; 86 | return; 87 | } 88 | 89 | var map = new Dictionary>(); 90 | 91 | // go through all records 92 | WriteToLogAndConsole($"Parsing input file: {sortedMaster}"); 93 | var fullGameFilePath = Path.Combine(CurrentInstallation.RootDirectory, "Data Files", $"{sortedMaster}"); 94 | var file = TES3.TES3Load(fullGameFilePath, supportedMergeTags); 95 | foreach (var record in file.Records) 96 | { 97 | #region checks 98 | 99 | if (record is null) 100 | { 101 | continue; 102 | } 103 | if (record.GetType().Equals(typeof(TES3Lib.Records.TES3))) 104 | { 105 | continue; 106 | } 107 | var editorId = record.GetEditorId().Replace("\0", string.Empty); 108 | if (string.IsNullOrEmpty(editorId)) 109 | { 110 | continue; 111 | } 112 | 113 | // Check against object filters. 114 | var allow = true; 115 | var lowerId = editorId.ToLower(); 116 | foreach (var kv in objectIdFilters) 117 | { 118 | try 119 | { 120 | if (Regex.Match(lowerId, kv.Key).Success) 121 | { 122 | allow = kv.Value; 123 | } 124 | } 125 | catch (Exception) 126 | { 127 | 128 | } 129 | } 130 | if (!allow) 131 | { 132 | continue; 133 | } 134 | 135 | #endregion 136 | 137 | // verify here 138 | GetPathsInRecord(record, map); 139 | } 140 | 141 | if (map.Count > 0) 142 | { 143 | reportDict.AddOrUpdate(sortedMaster, map, (key, oldValue) => map); 144 | } 145 | } 146 | ); 147 | 148 | // pretty print 149 | WriteToLogAndConsole($"\n------------------------------------"); 150 | WriteToLogAndConsole($"Results:\n"); 151 | foreach (var (plugin, val) in reportDict) 152 | { 153 | WriteToLogAndConsole($"\n{plugin} ({val.Count})"); 154 | foreach (var (recordID, list) in val) 155 | { 156 | foreach (var item in list) 157 | { 158 | //Console.WriteLine("{0,-20} {1,5}\n", "Name", "Hours"); 159 | WriteToLogAndConsole(string.Format("\t{0,-40} {1,5}", recordID, item)); 160 | } 161 | } 162 | } 163 | // serialize to file 164 | WriteToLogAndConsole($"\n"); 165 | var reportPath = Path.Combine(CurrentInstallation.RootDirectory, "Data Files", "report.json"); 166 | WriteToLogAndConsole($"Writing report to: {reportPath}"); 167 | { 168 | using var fs = new FileStream(reportPath, FileMode.Create); 169 | JsonSerializer.Serialize(fs, reportDict, new JsonSerializerOptions() { WriteIndented = true }); 170 | } 171 | 172 | } 173 | 174 | /// 175 | /// loop through all subrecords of a record 176 | /// 177 | /// 178 | /// 179 | /// 180 | private static void GetPathsInRecord( 181 | Record record, 182 | Dictionary> map) 183 | { 184 | var recordDict = new List(); 185 | var properties = record 186 | .GetType() 187 | .GetProperties(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly) 188 | .OrderBy(x => x.MetadataToken) 189 | .ToList(); 190 | 191 | foreach (var property in properties) 192 | { 193 | var val = record is not null ? property.GetValue(record) : null; 194 | if (val is Subrecord subrecord) 195 | { 196 | GetPathsInSubRecordRecursive(subrecord, recordDict); 197 | } 198 | } 199 | 200 | if (recordDict.Count > 0 && record is not null) 201 | { 202 | var id = record.GetEditorId().TrimEnd('\0'); 203 | map.Add(id, recordDict); 204 | } 205 | } 206 | 207 | /// 208 | /// Loop through all properties of a subrecord 209 | /// and check if a property is a file path 210 | /// then checks if that file exists in the filemap 211 | /// 212 | /// 213 | /// 214 | /// 215 | private static void GetPathsInSubRecordRecursive( 216 | Subrecord subRecord, 217 | List map) 218 | { 219 | ArgumentNullException.ThrowIfNull(CurrentInstallation); 220 | 221 | var recordTypeName = subRecord.Name; 222 | var properties = subRecord 223 | .GetType() 224 | .GetProperties(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly) 225 | .OrderBy(x => x.MetadataToken) 226 | .ToList()!; 227 | 228 | foreach (var property in properties) 229 | { 230 | var val = subRecord is not null ? property.GetValue(subRecord) : null; 231 | if (val is string rawstr) 232 | { 233 | var str = rawstr.TrimEnd('\0').ToLower(); 234 | var file = CurrentInstallation.GetSubstitutingDataFile(str); 235 | if (file is null) 236 | { 237 | map.Add(str); 238 | } 239 | } 240 | else 241 | { 242 | if (val is Subrecord subrecord) 243 | { 244 | GetPathsInSubRecordRecursive(subrecord, map); 245 | } 246 | } 247 | } 248 | } 249 | } 250 | -------------------------------------------------------------------------------- /TES3Merge/Extensions/GenericObjectExtensions.cs: -------------------------------------------------------------------------------- 1 | using System.Collections; 2 | using System.Reflection; 3 | 4 | namespace TES3Merge; 5 | 6 | static class GenericObjectExtensions 7 | { 8 | public static bool IsNonStringEnumerable(this PropertyInfo pi) 9 | { 10 | return pi is not null && pi.PropertyType.IsNonStringEnumerable(); 11 | } 12 | 13 | public static bool IsNonStringEnumerable(this object instance) 14 | { 15 | return instance is not null && instance.GetType().IsNonStringEnumerable(); 16 | } 17 | 18 | public static bool IsNonStringEnumerable(this Type type) 19 | { 20 | if (type is null || type == typeof(string)) 21 | return false; 22 | return typeof(IEnumerable).IsAssignableFrom(type); 23 | } 24 | 25 | internal static bool NullableSequenceEqual(this IEnumerable? a, IEnumerable? b) 26 | { 27 | if (a is null) 28 | { 29 | return b is null; 30 | } 31 | else if (b is null) 32 | { 33 | return a is null; 34 | } 35 | return a.SequenceEqual(b); 36 | } 37 | 38 | public static bool PublicInstancePropertiesEqual(this T self, T to, params string[] ignore) where T : class 39 | { 40 | if (self is not null && to is not null) 41 | { 42 | var type = typeof(T); 43 | var ignoreList = new List(ignore); 44 | var unequalProperties = 45 | from pi in type.GetProperties(BindingFlags.Public | BindingFlags.Instance) 46 | where !ignoreList.Contains(pi.Name) && pi.GetUnderlyingType().IsSimpleType() && pi.GetIndexParameters().Length == 0 47 | let selfValue = type.GetProperty(pi.Name)?.GetValue(self, null) 48 | let toValue = type.GetProperty(pi.Name)?.GetValue(to, null) 49 | where selfValue != toValue && (selfValue is null || !selfValue.Equals(toValue)) 50 | select selfValue; 51 | return !unequalProperties.Any(); 52 | } 53 | return self == to; 54 | } 55 | 56 | /// 57 | /// A set of types that are considered to be "simple" by default. 58 | /// 59 | public static readonly HashSet SimpleTypes = new() 60 | { 61 | typeof(string), 62 | typeof(decimal), 63 | typeof(DateTime), 64 | typeof(DateTimeOffset), 65 | typeof(TimeSpan), 66 | typeof(Guid) 67 | }; 68 | 69 | /// 70 | /// Determine whether a type is simple (String, Decimal, DateTime, etc) 71 | /// or complex (i.e. custom class with public properties and methods). 72 | /// 73 | /// 74 | public static bool IsSimpleType(this Type type) 75 | { 76 | return 77 | type.IsValueType || 78 | type.IsPrimitive || 79 | SimpleTypes.Contains(type) || 80 | (Convert.GetTypeCode(type) != TypeCode.Object); 81 | } 82 | 83 | public static Type GetUnderlyingType(this MemberInfo member) 84 | { 85 | if (member is null) 86 | { 87 | throw new ArgumentException("Input MemberInfo must not be null."); 88 | } 89 | 90 | return member.MemberType switch 91 | { 92 | MemberTypes.Event => ((EventInfo)member).EventHandlerType ?? throw new ArgumentException("EventInfo does not have EventHandlerType."), 93 | MemberTypes.Field => ((FieldInfo)member).FieldType, 94 | MemberTypes.Method => ((MethodInfo)member).ReturnType, 95 | MemberTypes.Property => ((PropertyInfo)member).PropertyType, 96 | _ => throw new ArgumentException("Input MemberInfo must be if type EventInfo, FieldInfo, MethodInfo, or PropertyInfo"), 97 | }; 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /TES3Merge/Extensions/StreamExtensions.cs: -------------------------------------------------------------------------------- 1 | using System.Runtime.InteropServices; 2 | 3 | namespace TES3Merge.Extensions; 4 | 5 | public static class StreamExtensions 6 | { 7 | public static byte[] ToByteArray(this Stream input, bool keepPosition = false) 8 | { 9 | if (input is MemoryStream memoryStream) 10 | { 11 | return memoryStream.ToArray(); 12 | } 13 | else 14 | { 15 | using var ms = new MemoryStream(); 16 | if (!keepPosition) 17 | { 18 | input.Position = 0; 19 | } 20 | input.CopyTo(ms); 21 | return ms.ToArray(); 22 | 23 | } 24 | } 25 | 26 | public static T ReadStruct(this Stream m_stream) where T : struct 27 | { 28 | var size = Marshal.SizeOf(); 29 | 30 | var m_temp = new byte[size]; 31 | m_stream.Read(m_temp, 0, size); 32 | 33 | var handle = GCHandle.Alloc(m_temp, GCHandleType.Pinned); 34 | T item = Marshal.PtrToStructure(handle.AddrOfPinnedObject()); 35 | 36 | handle.Free(); 37 | 38 | return item; 39 | } 40 | 41 | public static void WriteStruct(this Stream m_stream, T value) where T : struct 42 | { 43 | var m_temp = new byte[Marshal.SizeOf()]; 44 | var handle = GCHandle.Alloc(m_temp, GCHandleType.Pinned); 45 | 46 | Marshal.StructureToPtr(value, handle.AddrOfPinnedObject(), true); 47 | m_stream.Write(m_temp, 0, m_temp.Length); 48 | 49 | handle.Free(); 50 | } 51 | 52 | public static T[] ReadStructs(this Stream m_stream, uint count) where T : struct 53 | { 54 | var size = Marshal.SizeOf(); 55 | var items = new T[count]; 56 | 57 | var m_temp = new byte[size]; 58 | for (uint i = 0; i < count; i++) 59 | { 60 | m_stream.Read(m_temp, 0, size); 61 | 62 | var handle = GCHandle.Alloc(m_temp, GCHandleType.Pinned); 63 | items[i] = Marshal.PtrToStructure(handle.AddrOfPinnedObject()); 64 | 65 | handle.Free(); 66 | } 67 | 68 | return items; 69 | } 70 | 71 | public static void WriteStructs(this Stream m_stream, T[] array) where T : struct 72 | { 73 | var size = Marshal.SizeOf(); 74 | var m_temp = new byte[size]; 75 | for (var i = 0; i < array.Length; i++) 76 | { 77 | var handle = GCHandle.Alloc(m_temp, GCHandleType.Pinned); 78 | 79 | Marshal.StructureToPtr(array[i], handle.AddrOfPinnedObject(), true); 80 | m_stream.Write(m_temp, 0, m_temp.Length); 81 | 82 | handle.Free(); 83 | } 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /TES3Merge/Merger/CELL.cs: -------------------------------------------------------------------------------- 1 | namespace TES3Merge.Merger; 2 | 3 | internal static class CELL 4 | { 5 | public static bool Merge(object currentParam, object firstParam, object nextParam) 6 | { 7 | // Get the values as their correct type. 8 | var current = currentParam as TES3Lib.Records.CELL ?? throw new ArgumentException("Current record is of incorrect type."); 9 | var first = firstParam as TES3Lib.Records.CELL ?? throw new ArgumentException("First record is of incorrect type."); 10 | var next = nextParam as TES3Lib.Records.CELL ?? throw new ArgumentException("Next record is of incorrect type."); 11 | 12 | // Store modified state. 13 | var modified = false; 14 | 15 | // Ensure that the record type hasn't changed. 16 | if (!first.Name.Equals(next.Name)) 17 | { 18 | throw new Exception("Record types differ!"); 19 | } 20 | 21 | // Cover the base record flags. 22 | if (current.Flags.SequenceEqual(first.Flags) && !next.Flags.SequenceEqual(first.Flags)) 23 | { 24 | current.Flags = next.Flags; 25 | modified = true; 26 | } 27 | 28 | /* 29 | 30 | Cell Name Patch (--cellnames) 31 | 32 | Creates a patch to ensure renamed cells are not accidentally reverted to 33 | their original name. 34 | 35 | This solves the following plugin conflict that causes bugs: 36 | * Master A names external CELL (1, 1) as: "". 37 | * Plugin B renames CELL (1, 1) to: "My City". 38 | * Plugin C modifies CELL (1, 1), using the original name "", reverting 39 | renaming done by plugin B. 40 | * References in plugin B (such as in scripts) that refer to "My City" break. 41 | 42 | This option works by scanning your currently active plugin load order for 43 | cell name reversions like those in the above example, and ensures whenever 44 | possible that cell renaming is properly maintained. 45 | 46 | */ 47 | if (current is TES3Lib.Records.CELL cell) 48 | { 49 | // only check exterior cells for rename reversion problem 50 | if (!cell.DATA.Flags.Contains(TES3Lib.Enums.Flags.CellFlag.IsInteriorCell)) 51 | { 52 | var currentValue = current.NAME; 53 | var firstValue = first.NAME; 54 | var nextValue = next.NAME; 55 | 56 | // Handle null cases. 57 | if (firstValue is null && currentValue is null && nextValue is not null) 58 | { 59 | current.NAME = nextValue; 60 | modified = true; 61 | } 62 | 63 | var currentIsUnmodified = currentValue is not null ? currentValue.Equals(firstValue) : firstValue is null; 64 | var nextIsModified = !(nextValue is not null ? nextValue.Equals(firstValue) : firstValue is null); 65 | 66 | if (currentIsUnmodified && nextIsModified) 67 | { 68 | if (!string.IsNullOrEmpty(nextValue?.EditorId.TrimEnd('\0'))) 69 | { 70 | current.NAME = nextValue; 71 | modified = true; 72 | } 73 | } 74 | } 75 | } 76 | 77 | return modified; 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /TES3Merge/Merger/CLAS.cs: -------------------------------------------------------------------------------- 1 | using System.Reflection; 2 | 3 | namespace TES3Merge.Merger; 4 | 5 | internal static class CLAS 6 | { 7 | static readonly string[] ClassDataBasicProperties = { "IsPlayable", "Specialization" }; 8 | 9 | public static bool CLDT(PropertyInfo property, object currentParam, object firstParam, object nextParam) 10 | { 11 | // Get the values as their correct type. 12 | var current = property.GetValue(currentParam) as TES3Lib.Subrecords.CLAS.CLDT ?? throw new ArgumentException("Current record is of incorrect type."); 13 | var first = property.GetValue(firstParam) as TES3Lib.Subrecords.CLAS.CLDT ?? throw new ArgumentException("First record is of incorrect type."); 14 | var next = property.GetValue(nextParam) as TES3Lib.Subrecords.CLAS.CLDT ?? throw new ArgumentException("Next record is of incorrect type."); 15 | 16 | bool modified = false; 17 | 18 | // Perform basic merges. 19 | if (RecordMerger.MergeNamedProperties(ClassDataBasicProperties, current, first, next)) 20 | { 21 | modified = true; 22 | } 23 | 24 | return modified; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /TES3Merge/Merger/CREA.cs: -------------------------------------------------------------------------------- 1 | using System.Reflection; 2 | using TES3Lib.Base; 3 | using TES3Lib.Subrecords.Shared; 4 | 5 | namespace TES3Merge.Merger; 6 | 7 | // TODO duplicated code. fix this with some interfaces or something 8 | internal static class CREA 9 | { 10 | /// 11 | /// Merger for the AIPackage list in CREA 12 | /// 13 | /// 14 | /// 15 | /// 16 | /// 17 | /// 18 | /// 19 | /// 20 | internal static bool AIPackage(PropertyInfo property, object currentParam, object firstParam, object nextParam) 21 | { 22 | // Get the values as their correct type. 23 | var current = property.GetValue(currentParam) as List<(IAIPackage AIPackage, TES3Lib.Subrecords.CREA.CNDT CNDT)> 24 | ?? throw new ArgumentException("Current record is of incorrect type."); 25 | var first = property.GetValue(firstParam) as List<(IAIPackage AIPackage, TES3Lib.Subrecords.CREA.CNDT CNDT)> 26 | ?? throw new ArgumentException("First record is of incorrect type."); 27 | var next = property.GetValue(nextParam) as List<(IAIPackage AIPackage, TES3Lib.Subrecords.CREA.CNDT CNDT)> 28 | ?? throw new ArgumentException("Next record is of incorrect type."); 29 | 30 | var modified = false; 31 | 32 | // Ensure that we have a current value. 33 | if (current == null) 34 | { 35 | if (first != null) 36 | { 37 | current = new List<(IAIPackage AIPackage, TES3Lib.Subrecords.CREA.CNDT CNDT)>(first); 38 | property.SetValue(currentParam, current); 39 | } 40 | else if (next != null) 41 | { 42 | current = new List<(IAIPackage AIPackage, TES3Lib.Subrecords.CREA.CNDT CNDT)>(next); 43 | property.SetValue(currentParam, current); 44 | } 45 | else 46 | { 47 | return false; 48 | } 49 | } 50 | 51 | if (first == null) 52 | { 53 | throw new NullReferenceException(nameof(first)); 54 | } 55 | 56 | // for now we only merge the wander package 57 | if (current.Count + first.Count + next.Count > 0) 58 | { 59 | modified = MergeWanderPackage(current, first, next); 60 | } 61 | 62 | return modified; 63 | 64 | static bool MergeWanderPackage( 65 | List<(IAIPackage AIPackage, TES3Lib.Subrecords.CREA.CNDT CNDT)> current, 66 | List<(IAIPackage AIPackage, TES3Lib.Subrecords.CREA.CNDT CNDT)>? first, 67 | List<(IAIPackage AIPackage, TES3Lib.Subrecords.CREA.CNDT CNDT)>? next) 68 | { 69 | // only merge one wander package 70 | var currentValue = current.FirstOrDefault(x => x.AIPackage.GetType() == typeof(AI_W)).AIPackage as AI_W; 71 | var firstValue = first?.FirstOrDefault(x => x.AIPackage.GetType() == typeof(AI_W)).AIPackage as AI_W; 72 | var nextValue = next?.FirstOrDefault(x => x.AIPackage.GetType() == typeof(AI_W)).AIPackage as AI_W; 73 | 74 | // TODO remove multiple wander packages? 75 | 76 | // we always have a current value 77 | 78 | // If we have no first value, but do have a next value, this is a new property. Add it. 79 | if (firstValue is null && nextValue is not null && nextValue is not null) 80 | { 81 | currentValue = nextValue; 82 | return true; 83 | } 84 | // If we have values for everything... 85 | if (firstValue is not null && nextValue is not null) 86 | { 87 | var result = RecordMerger.MergeAllProperties(currentValue, firstValue, nextValue); 88 | return result; 89 | } 90 | 91 | return false; 92 | } 93 | } 94 | 95 | // list of summoned creatures for multipatch in lowercase! 96 | public static List SummonedCreatures = new() 97 | { 98 | "centurion_fire_dead", 99 | "wraith_sul_senipul", 100 | "ancestor_ghost_summon", 101 | "atronach_flame_summon", 102 | "atronach_frost_summon", 103 | "atronach_storm_summon", 104 | "bonelord_summon", 105 | "bonewalker_summon", 106 | "bonewalker_greater_summ", 107 | "centurion_sphere_summon", 108 | "clannfear_summon", 109 | "daedroth_summon", 110 | "dremora_summon", 111 | "golden saint_summon", 112 | "hunger_summon", 113 | "scamp_summon", 114 | "skeleton_summon", 115 | "ancestor_ghost_variner", 116 | "fabricant_summon", 117 | "bm_bear_black_summon", 118 | "bm_wolf_grey_summon", 119 | "bm_wolf_bone_summon" 120 | }; 121 | 122 | } 123 | -------------------------------------------------------------------------------- /TES3Merge/Merger/FACT.cs: -------------------------------------------------------------------------------- 1 | using System.Reflection; 2 | 3 | namespace TES3Merge.Merger; 4 | 5 | internal static class FACT 6 | { 7 | static readonly string[] FactionDataBasicProperties = { "IsHiddenFromPlayer" }; 8 | 9 | public static bool FADT(PropertyInfo property, object currentParam, object firstParam, object nextParam) 10 | { 11 | // Get the values as their correct type. 12 | var current = property.GetValue(currentParam) as TES3Lib.Subrecords.FACT.FADT ?? throw new ArgumentException("Current record is of incorrect type."); 13 | var first = property.GetValue(firstParam) as TES3Lib.Subrecords.FACT.FADT ?? throw new ArgumentException("First record is of incorrect type."); 14 | var next = property.GetValue(nextParam) as TES3Lib.Subrecords.FACT.FADT ?? throw new ArgumentException("Next record is of incorrect type."); 15 | 16 | bool modified = false; 17 | 18 | // Perform basic merges. 19 | if (RecordMerger.MergeNamedProperties(FactionDataBasicProperties, current, first, next)) 20 | { 21 | modified = true; 22 | } 23 | 24 | return modified; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /TES3Merge/Merger/LEVC.cs: -------------------------------------------------------------------------------- 1 | using System.Diagnostics.CodeAnalysis; 2 | using System.Reflection; 3 | using TES3Lib.Subrecords.LEVC; 4 | 5 | namespace TES3Merge.Merger; 6 | 7 | internal static class LEVC 8 | { 9 | private class CRITComparer : EqualityComparer<(CNAM CNAM, INTV INTV)> 10 | { 11 | public override bool Equals((CNAM CNAM, INTV INTV) x, (CNAM CNAM, INTV INTV) y) 12 | { 13 | return string.Equals(x.CNAM.CreatureEditorId, y.CNAM.CreatureEditorId) && x.INTV.PCLevelOfPrevious == y.INTV.PCLevelOfPrevious; 14 | } 15 | 16 | public override int GetHashCode([DisallowNull] (CNAM CNAM, INTV INTV) obj) 17 | { 18 | return base.GetHashCode(); 19 | } 20 | } 21 | 22 | private class KeyValuePairComparer : EqualityComparer> 23 | { 24 | public override bool Equals(KeyValuePair<(CNAM, INTV INTV), int> x, KeyValuePair<(CNAM, INTV INTV), int> y) 25 | { 26 | return CritComparer.Equals(x.Key, y.Key) && x.Value == y.Value; 27 | } 28 | 29 | public override int GetHashCode([DisallowNull] KeyValuePair<(CNAM, INTV INTV), int> obj) 30 | { 31 | return base.GetHashCode(); 32 | } 33 | } 34 | 35 | private static readonly CRITComparer CritComparer = new(); 36 | private static readonly KeyValuePairComparer kvpComparer = new(); 37 | 38 | internal static bool CRIT(PropertyInfo property, object currentParam, object firstParam, object nextParam) 39 | { 40 | // Get the values as their correct type. 41 | var current = property.GetValue(currentParam) as List<(CNAM CNAM, INTV INTV)> 42 | ?? throw new ArgumentException("Current record is of incorrect type."); 43 | var first = property.GetValue(firstParam) as List<(CNAM CNAM, INTV INTV)> 44 | ?? throw new ArgumentException("First record is of incorrect type."); 45 | var next = property.GetValue(nextParam) as List<(CNAM CNAM, INTV INTV)> 46 | ?? throw new ArgumentException("Next record is of incorrect type."); 47 | 48 | var modified = false; 49 | 50 | // Ensure that we have a current value. 51 | if (current == null) 52 | { 53 | if (first != null) 54 | { 55 | current = new List<(CNAM CNAM, INTV INTV)>(first); 56 | property.SetValue(currentParam, current); 57 | } 58 | else if (next != null) 59 | { 60 | current = new List<(CNAM CNAM, INTV INTV)>(next); 61 | property.SetValue(currentParam, current); 62 | } 63 | else 64 | { 65 | return false; 66 | } 67 | } 68 | 69 | if (first == null) 70 | { 71 | throw new ArgumentNullException(nameof(first)); 72 | } 73 | 74 | // minimal distinct inclusive list merge 75 | // map occurences of items in each plugin 76 | var fmap = first.ToLookup(x => x, CritComparer).ToDictionary(x => x.Key, y => y.Count()); 77 | var cmap = current.ToLookup(x => x, CritComparer).ToDictionary(x => x.Key, y => y.Count()); 78 | var nmap = next.ToLookup(x => x, CritComparer).ToDictionary(x => x.Key, y => y.Count()); 79 | 80 | // gather all 81 | var map = fmap 82 | .Union(cmap, kvpComparer) 83 | .Union(nmap, kvpComparer) 84 | .Distinct(kvpComparer) 85 | .ToLookup(x => x.Key, CritComparer) 86 | .ToDictionary(x => x.Key, y => y.Select(x => x.Value).Max()); 87 | 88 | // add by minimal count 89 | var union = new List<(CNAM CNAM, INTV INTV)>(); 90 | foreach (var (item, cnt) in map) 91 | { 92 | for (var i = 0; i < cnt; i++) 93 | { 94 | union.Add(item); 95 | } 96 | } 97 | 98 | // order 99 | union = union 100 | .OrderBy(x => x.INTV.PCLevelOfPrevious) 101 | .ThenBy(x => x.CNAM.CreatureEditorId) 102 | .ToList(); 103 | 104 | // compare to vanilla 105 | if (!union.SequenceEqual(first)) 106 | { 107 | property.SetValue(currentParam, union); 108 | modified = true; 109 | } 110 | 111 | return modified; 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /TES3Merge/Merger/LEVI.cs: -------------------------------------------------------------------------------- 1 | using System.Diagnostics.CodeAnalysis; 2 | using System.Linq; 3 | using System.Reflection; 4 | using TES3Lib.Base; 5 | using TES3Lib.Subrecords.LEVI; 6 | using TES3Lib.Subrecords.Shared; 7 | using static TES3Merge.RecordMerger; 8 | 9 | namespace TES3Merge.Merger; 10 | 11 | internal static class LEVI 12 | { 13 | private class ITEMComparer : EqualityComparer<(INAM CNAM, INTV INTV)> 14 | { 15 | public override bool Equals((INAM CNAM, INTV INTV) x, (INAM CNAM, INTV INTV) y) 16 | { 17 | return string.Equals(x.CNAM.ItemEditorId, y.CNAM.ItemEditorId) && x.INTV.PCLevelOfPrevious == y.INTV.PCLevelOfPrevious; 18 | } 19 | 20 | public override int GetHashCode([DisallowNull] (INAM CNAM, INTV INTV) obj) 21 | { 22 | return base.GetHashCode(); 23 | } 24 | } 25 | 26 | private class KeyValuePairComparer : EqualityComparer> 27 | { 28 | public override bool Equals(KeyValuePair<(INAM, INTV INTV), int> x, KeyValuePair<(INAM, INTV INTV), int> y) 29 | { 30 | return ItemComparer.Equals(x.Key, y.Key) && x.Value == y.Value; 31 | } 32 | 33 | public override int GetHashCode([DisallowNull] KeyValuePair<(INAM, INTV INTV), int> obj) 34 | { 35 | return base.GetHashCode(); 36 | } 37 | } 38 | 39 | private static readonly ITEMComparer ItemComparer = new(); 40 | private static readonly KeyValuePairComparer kvpComparer = new(); 41 | 42 | internal static bool ITEM(PropertyInfo property, object currentParam, object firstParam, object nextParam) 43 | { 44 | // Get the values as their correct type. 45 | var current = property.GetValue(currentParam) as List<(INAM INAM, INTV INTV)> 46 | ?? throw new ArgumentException("Current record is of incorrect type."); 47 | var first = property.GetValue(firstParam) as List<(INAM INAM, INTV INTV)> 48 | ?? throw new ArgumentException("First record is of incorrect type."); 49 | var next = property.GetValue(nextParam) as List<(INAM INAM, INTV INTV)> 50 | ?? throw new ArgumentException("Next record is of incorrect type."); 51 | 52 | var modified = false; 53 | 54 | // Ensure that we have a current value. 55 | if (current == null) 56 | { 57 | if (first != null) 58 | { 59 | current = new List<(INAM INAM, INTV INTV)>(first); 60 | property.SetValue(currentParam, current); 61 | } 62 | else if (next != null) 63 | { 64 | current = new List<(INAM INAM, INTV INTV)>(next); 65 | property.SetValue(currentParam, current); 66 | } 67 | else 68 | { 69 | return false; 70 | } 71 | } 72 | 73 | if (first == null) 74 | { 75 | throw new ArgumentNullException(nameof(first)); 76 | } 77 | 78 | /* 79 | * some special cases: 80 | * 81 | * 1) chance 82 | * 2) list flags 83 | * mod A sets chance_none to 75 and changes the list to only one item 84 | * mod B keeps chance_none at 50 but adds more items 85 | * 86 | * naive outcome: 87 | * chance 75 but a lot of items 88 | * 89 | * desired outcome: 90 | * priority -> needs community rules 91 | * -> but retain the naive merge by default 92 | * 93 | * 2) handle duplicate items 94 | * mod A,B and C all have different lists but keep one item from vanilla 95 | * mod D adds one item 10 times 96 | * 97 | * desired outcome: minimal distinct items 98 | * 99 | */ 100 | 101 | // minimal distinct inclusive list merge 102 | // map occurences of items in each plugin 103 | var fmap = first.ToLookup(x => x, ItemComparer).ToDictionary(x => x.Key, y => y.Count()); 104 | var cmap = current.ToLookup(x => x, ItemComparer).ToDictionary(x => x.Key, y => y.Count()); 105 | var nmap = next.ToLookup(x => x, ItemComparer).ToDictionary(x => x.Key, y => y.Count()); 106 | 107 | // gather all 108 | var map = fmap 109 | .Union(cmap, kvpComparer) 110 | .Union(nmap, kvpComparer) 111 | .Distinct(kvpComparer) 112 | .ToLookup(x => x.Key, ItemComparer) 113 | .ToDictionary(x => x.Key, y => y.Select(x => x.Value).Max()); 114 | 115 | // add by minimal count 116 | var union = new List<(INAM INAM, INTV INTV)>(); 117 | foreach (var (item, cnt) in map) 118 | { 119 | for (var i = 0; i < cnt; i++) 120 | { 121 | union.Add(item); 122 | } 123 | } 124 | 125 | // order 126 | union = union 127 | .OrderBy(x => x.INTV.PCLevelOfPrevious) 128 | .ThenBy(x => x.INAM.ItemEditorId) 129 | .ToList(); 130 | 131 | // compare to vanilla 132 | if (!union.SequenceEqual(first)) 133 | { 134 | property.SetValue(currentParam, union); 135 | modified = true; 136 | 137 | // Update list count. 138 | var levi = currentParam as TES3Lib.Records.LEVI ?? throw new ArgumentException("Object is not of expected type."); 139 | levi.INDX.ItemCount = union.Count; 140 | } 141 | 142 | return modified; 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /TES3Merge/Merger/NPC_.cs: -------------------------------------------------------------------------------- 1 | using System.Reflection; 2 | using TES3Lib.Base; 3 | using TES3Lib.Subrecords.NPC_; 4 | using TES3Lib.Subrecords.Shared; 5 | 6 | namespace TES3Merge.Merger; 7 | 8 | internal static class NPC_ 9 | { 10 | private static readonly string[] NPCDataBasicProperties = { 11 | "Agility", 12 | "Disposition", 13 | "Endurance", 14 | "Fatigue", 15 | "Gold", 16 | "Health", 17 | "Intelligence", 18 | "Level", 19 | "Luck", 20 | "Personality", 21 | "Rank", 22 | "Reputation", 23 | "Speed", 24 | "SpellPts", 25 | "Strength", 26 | "Unknown1", 27 | "Unknown2", 28 | "Unknown3", 29 | "Willpower" 30 | }; 31 | 32 | public static bool NPDT(PropertyInfo property, object currentParam, object firstParam, object nextParam) 33 | { 34 | // Get the values as their correct type. 35 | var current = property.GetValue(currentParam) as NPDT 36 | ?? throw new ArgumentException("Current record is of incorrect type."); 37 | var first = property.GetValue(firstParam) as NPDT 38 | ?? throw new ArgumentException("First record is of incorrect type."); 39 | var next = property.GetValue(nextParam) as NPDT 40 | ?? throw new ArgumentException("Next record is of incorrect type."); 41 | 42 | var modified = false; 43 | 44 | // Perform basic merges. 45 | if (RecordMerger.MergeNamedProperties(NPCDataBasicProperties, current, first, next)) 46 | { 47 | modified = true; 48 | } 49 | 50 | // Ensure that we always have skills, in case that we change the autocalc flag. 51 | if (current.Skills is null && next.Skills is not null) 52 | { 53 | current.Skills = next.Skills; 54 | modified = true; 55 | } 56 | 57 | // element-wise merge 58 | if (current.Skills is not null && next.Skills is not null) 59 | { 60 | if (current.Skills.SequenceEqual(next.Skills)) 61 | { 62 | return modified; 63 | } 64 | if (first.Skills is null) 65 | { 66 | first.Skills = next.Skills; 67 | modified = true; 68 | } 69 | // TODO length check 70 | 71 | for (var i = 0; i < current.Skills.Length; i++) 72 | { 73 | var skill = current.Skills[i]; 74 | var firstSkill = first.Skills[i]; 75 | var nextSkill = next.Skills[i]; 76 | 77 | var currentIsModified = firstSkill != skill; 78 | var nextIsModified = firstSkill != nextSkill; 79 | 80 | if (!currentIsModified && nextIsModified) 81 | { 82 | current.Skills[i] = nextSkill; 83 | } 84 | } 85 | } 86 | return modified; 87 | } 88 | 89 | internal static bool AIPackage(PropertyInfo property, object currentParam, object firstParam, object nextParam) 90 | { 91 | // Get the values as their correct type. 92 | var current = property.GetValue(currentParam) as List<(IAIPackage AIPackage, CNDT CNDT)> 93 | ?? throw new ArgumentException("Current record is of incorrect type."); 94 | var first = property.GetValue(firstParam) as List<(IAIPackage AIPackage, CNDT CNDT)> 95 | ?? throw new ArgumentException("First record is of incorrect type."); 96 | var next = property.GetValue(nextParam) as List<(IAIPackage AIPackage, CNDT CNDT)> 97 | ?? throw new ArgumentException("Next record is of incorrect type."); 98 | 99 | var modified = false; 100 | 101 | // Ensure that we have a current value. 102 | if (current == null) 103 | { 104 | if (first != null) 105 | { 106 | current = new List<(IAIPackage AIPackage, CNDT CNDT)>(first); 107 | property.SetValue(currentParam, current); 108 | } 109 | else if (next != null) 110 | { 111 | current = new List<(IAIPackage AIPackage, CNDT CNDT)>(next); 112 | property.SetValue(currentParam, current); 113 | } 114 | else 115 | { 116 | return false; 117 | } 118 | } 119 | 120 | if (first == null) 121 | { 122 | throw new NullReferenceException(nameof(first)); 123 | } 124 | 125 | // for now we only merge the wander package 126 | if (current.Count + first.Count + next.Count > 0) 127 | { 128 | modified = MergeWanderPackage(current, first, next); 129 | } 130 | 131 | return modified; 132 | 133 | static bool MergeWanderPackage( 134 | List<(IAIPackage AIPackage, CNDT CNDT)> current, 135 | List<(IAIPackage AIPackage, CNDT CNDT)>? first, 136 | List<(IAIPackage AIPackage, CNDT CNDT)>? next) 137 | { 138 | // only merge one wander package 139 | var currentValue = current.FirstOrDefault(x => x.AIPackage.GetType() == typeof(AI_W)).AIPackage as AI_W; 140 | var firstValue = first?.FirstOrDefault(x => x.AIPackage.GetType() == typeof(AI_W)).AIPackage as AI_W; 141 | var nextValue = next?.FirstOrDefault(x => x.AIPackage.GetType() == typeof(AI_W)).AIPackage as AI_W; 142 | 143 | // TODO remove multiple wander packages? 144 | 145 | // we always have a current value 146 | 147 | // If we have no first value, but do have a next value, this is a new property. Add it. 148 | if (firstValue is null && nextValue is not null && nextValue is not null) 149 | { 150 | currentValue = nextValue; 151 | return true; 152 | } 153 | // If we have values for everything... 154 | if (firstValue is not null && nextValue is not null) 155 | { 156 | var result = RecordMerger.MergeAllProperties(currentValue, firstValue, nextValue); 157 | return result; 158 | } 159 | 160 | return false; 161 | } 162 | } 163 | } 164 | -------------------------------------------------------------------------------- /TES3Merge/Merger/Shared.cs: -------------------------------------------------------------------------------- 1 | using System.Reflection; 2 | using static TES3Merge.RecordMerger; 3 | 4 | namespace TES3Merge.Merger; 5 | 6 | internal static class Shared 7 | { 8 | static readonly PublicPropertyComparer BasicComparer = new(); 9 | 10 | [System.Diagnostics.CodeAnalysis.SuppressMessage("Style", "IDE0060:Remove unused parameter", Justification = "This function signature must match other merge functions.")] 11 | internal static bool NoMerge(PropertyInfo property, object currentParam, object firstParam, object nextParam) 12 | { 13 | return false; 14 | } 15 | 16 | internal static bool MergeEffect(List current, List? first, List? next, int index) 17 | { 18 | TES3Lib.Subrecords.Shared.Castable.ENAM? currentValue = current.ElementAtOrDefault(index); 19 | TES3Lib.Subrecords.Shared.Castable.ENAM? firstValue = first?.ElementAtOrDefault(index); 20 | TES3Lib.Subrecords.Shared.Castable.ENAM? nextValue = next?.ElementAtOrDefault(index); 21 | 22 | // If we have values for everything... 23 | if (currentValue is not null && firstValue is not null && nextValue is not null) 24 | { 25 | // If the effect has changed, override it all. 26 | if (nextValue.MagicEffect != firstValue.MagicEffect) 27 | { 28 | current[index] = nextValue; 29 | return true; 30 | } 31 | // Otherwise merge over individual properties. 32 | else 33 | { 34 | return MergeAllProperties(currentValue, firstValue, nextValue); 35 | } 36 | } 37 | 38 | // If we have no first value, but do have a next value, this is a new property. Add it. 39 | if (firstValue is null && nextValue is not null) 40 | { 41 | current.Add(nextValue); 42 | return true; 43 | } 44 | 45 | return false; 46 | } 47 | 48 | internal static bool EffectList(PropertyInfo property, object currentParam, object firstParam, object nextParam) 49 | { 50 | // Get the values as their correct type. 51 | var current = property.GetValue(currentParam) as List; 52 | var first = property.GetValue(firstParam) as List; 53 | var next = property.GetValue(nextParam) as List; 54 | 55 | var modified = false; 56 | 57 | // remove this for now until refactor 58 | // Handle null cases. 59 | //if (!current.NullableSequenceEqual(next) && next is not null) 60 | //{ 61 | // current = new List(next); 62 | // property.SetValue(currentParam, current); 63 | // modified = true; 64 | //} 65 | 66 | // Ensure that we have a current value. 67 | if (current == null) 68 | { 69 | if (first != null) 70 | { 71 | current = new List(first); 72 | property.SetValue(currentParam, current); 73 | } 74 | else if (next != null) 75 | { 76 | current = new List(next); 77 | property.SetValue(currentParam, current); 78 | } 79 | else 80 | { 81 | return false; 82 | } 83 | } 84 | 85 | // 86 | for (var i = 0; i < 8; i++) 87 | { 88 | if (MergeEffect(current, first, next, i)) 89 | { 90 | modified = true; 91 | } 92 | } 93 | 94 | return modified; 95 | } 96 | 97 | internal static bool ItemsList(PropertyInfo property, object currentParam, object firstParam, object nextParam) 98 | { 99 | // Get the values as their correct type. 100 | if (property.GetValue(firstParam) is not List firstAsEnumerable) 101 | { 102 | return false; 103 | } 104 | if (property.GetValue(nextParam) is not List nextAsEnumerable) 105 | { 106 | return false; 107 | } 108 | if (property.GetValue(currentParam) is not List currentAsEnumerable) 109 | { 110 | return false; 111 | } 112 | if (firstAsEnumerable == null || nextAsEnumerable == null) 113 | { 114 | return false; 115 | } 116 | 117 | var modified = false; 118 | 119 | // Ensure that we have a current value. 120 | if (currentAsEnumerable == null) 121 | { 122 | if (firstAsEnumerable != null) 123 | { 124 | currentAsEnumerable = new List(firstAsEnumerable); 125 | property.SetValue(currentParam, currentAsEnumerable); 126 | } 127 | else if (nextAsEnumerable != null) 128 | { 129 | currentAsEnumerable = new List(nextAsEnumerable); 130 | property.SetValue(currentParam, currentAsEnumerable); 131 | } 132 | else 133 | { 134 | return false; 135 | } 136 | } 137 | 138 | if (firstAsEnumerable == null) 139 | { 140 | throw new ArgumentNullException(nameof(firstAsEnumerable)); 141 | } 142 | 143 | // inclusive list merge 144 | IEnumerable? inclusiveValue = firstAsEnumerable 145 | .Union(currentAsEnumerable, BasicComparer) 146 | .Union(nextAsEnumerable, BasicComparer) 147 | .Distinct(BasicComparer); 148 | 149 | if (!inclusiveValue.SequenceEqual(firstAsEnumerable, BasicComparer)) 150 | { 151 | var inclusiveValueAsList = inclusiveValue 152 | .Cast() 153 | .ToList(); 154 | property.SetValue(currentParam, inclusiveValueAsList); 155 | modified = true; 156 | } 157 | 158 | return modified; 159 | } 160 | } 161 | -------------------------------------------------------------------------------- /TES3Merge/Program.cs: -------------------------------------------------------------------------------- 1 | using System.CommandLine; 2 | using System.Reflection; 3 | using TES3Merge.Commands; 4 | using TES3Merge.Util; 5 | using static TES3Merge.Util.Util; 6 | 7 | namespace TES3Merge; 8 | 9 | internal class Program 10 | { 11 | // main entry point to parse commandline options 12 | private static async Task Main(string[] args) 13 | { 14 | var rootCommand = new MergeCommand() 15 | { 16 | new MultipatchCommand(), 17 | new VerifyCommand() 18 | }; 19 | 20 | var version = Assembly.GetExecutingAssembly().GetName().Version; 21 | WriteToLogAndConsole($"TES3Merge v{version}."); 22 | 23 | // Before anything, load the config. 24 | LoadConfig(); 25 | if (Configuration is null) 26 | { 27 | WriteToLogAndConsole("Could not load TES3Merge's configuration. Aborting."); 28 | ShowCompletionPrompt(); 29 | return; 30 | } 31 | 32 | // Get the installation information. 33 | CurrentInstallation = Installation.CreateFromContext(); 34 | if (CurrentInstallation is null) 35 | { 36 | WriteToLogAndConsole("Could not find installation directory. Aborting."); 37 | ShowCompletionPrompt(); 38 | return; 39 | } 40 | else 41 | { 42 | WriteToLogAndConsole($"Installation folder: {CurrentInstallation.RootDirectory}"); 43 | } 44 | 45 | await rootCommand.InvokeAsync(args); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /TES3Merge/RecordMerger.cs: -------------------------------------------------------------------------------- 1 | using System.Collections; 2 | using System.Reflection; 3 | using TES3Lib.Base; 4 | 5 | namespace TES3Merge; 6 | 7 | internal static class RecordMerger 8 | { 9 | internal class PublicPropertyComparer : EqualityComparer 10 | { 11 | public override bool Equals(object? a, object? b) 12 | { 13 | if (a is null && b is null) 14 | { 15 | return true; 16 | } 17 | else if (a is null || b is null) 18 | { 19 | return false; 20 | } 21 | 22 | return a.GetType().GetType().FullName == "System.RuntimeType" ? a.Equals(b) : a.PublicInstancePropertiesEqual(b); 23 | } 24 | 25 | public override int GetHashCode(object b) 26 | { 27 | return base.GetHashCode(); 28 | } 29 | } 30 | 31 | public static readonly Dictionary> MergeTypeFunctionMapper = new(); 32 | public static readonly Dictionary> MergePropertyFunctionMapper = new(); 33 | private static readonly PublicPropertyComparer BasicComparer = new(); 34 | 35 | static RecordMerger() 36 | { 37 | // Define type merge behaviors. 38 | MergeTypeFunctionMapper[typeof(Record)] = MergeTypeRecord; 39 | 40 | MergeTypeFunctionMapper[typeof(TES3Lib.Records.CELL)] = Merger.CELL.Merge; 41 | 42 | 43 | // Define property merge behaviors. 44 | MergePropertyFunctionMapper[typeof(Subrecord)] = MergePropertySubrecord; 45 | 46 | // Shared 47 | MergePropertyFunctionMapper[typeof(List)] = Merger.Shared.EffectList; 48 | 49 | // CREA 50 | MergePropertyFunctionMapper[typeof(List<(IAIPackage, TES3Lib.Subrecords.CREA.CNDT)>)] = Merger.CREA.AIPackage; 51 | 52 | // NPC_ 53 | MergePropertyFunctionMapper[typeof(List<(IAIPackage, TES3Lib.Subrecords.NPC_.CNDT)>)] = Merger.NPC_.AIPackage; 54 | MergePropertyFunctionMapper[typeof(TES3Lib.Subrecords.NPC_.NPDT)] = Merger.NPC_.NPDT; 55 | 56 | // LEVI 57 | MergePropertyFunctionMapper[typeof(List<(TES3Lib.Subrecords.LEVI.INAM INAM, TES3Lib.Subrecords.LEVI.INTV INTV)>)] = Merger.LEVI.ITEM; 58 | MergePropertyFunctionMapper[typeof(TES3Lib.Subrecords.LEVI.INDX)] = Merger.Shared.NoMerge; 59 | 60 | // LEVC 61 | MergePropertyFunctionMapper[typeof(List<(TES3Lib.Subrecords.LEVC.CNAM CNAM, TES3Lib.Subrecords.LEVC.INTV INTV)>)] = Merger.LEVC.CRIT; 62 | MergePropertyFunctionMapper[typeof(TES3Lib.Subrecords.LEVC.INDX)] = Merger.Shared.NoMerge; 63 | 64 | // CLAS 65 | MergePropertyFunctionMapper[typeof(TES3Lib.Subrecords.CLAS.CLDT)] = Merger.CLAS.CLDT; 66 | 67 | // FACT 68 | MergePropertyFunctionMapper[typeof(TES3Lib.Subrecords.FACT.FADT)] = Merger.FACT.FADT; 69 | } 70 | 71 | public static Func? GetTypeMergeFunction(Type? type) 72 | { 73 | while (type is not null) 74 | { 75 | if (MergeTypeFunctionMapper.TryGetValue(type, out var func)) 76 | { 77 | return func; 78 | } 79 | 80 | type = type.BaseType; 81 | } 82 | 83 | return null; 84 | } 85 | 86 | public static Func GetPropertyMergeFunction(Type? type) 87 | { 88 | while (type is not null) 89 | { 90 | if (MergePropertyFunctionMapper.TryGetValue(type, out var func)) 91 | { 92 | return func; 93 | } 94 | type = type.BaseType; 95 | } 96 | 97 | return MergePropertyBase; 98 | } 99 | 100 | public static bool Merge(object current, object first, object next) 101 | { 102 | // We never want to merge an object redundantly. 103 | if (first == next) 104 | { 105 | return false; 106 | } 107 | 108 | // Figure out what merge function we will use. 109 | var mergeFunction = GetTypeMergeFunction(current.GetType()); 110 | return mergeFunction is not null && mergeFunction(current, first, next); 111 | } 112 | 113 | public static bool Merge(PropertyInfo property, object current, object first, object next) 114 | { 115 | // We never want to merge an object redundantly. 116 | if (first == next) 117 | { 118 | return false; 119 | } 120 | 121 | // Figure out what merge function we will use. 122 | var mergeFunction = GetPropertyMergeFunction(property.PropertyType); 123 | return mergeFunction(property, current, first, next); 124 | } 125 | 126 | public static bool MergeAllProperties(object? current, object? first, object? next) 127 | { 128 | if (next is null) 129 | { 130 | return false; 131 | } 132 | 133 | var modified = false; 134 | 135 | var properties = next.GetType().GetProperties(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly).OrderBy(x => x.MetadataToken).ToList()!; 136 | foreach (var property in properties) 137 | { 138 | // Handle null cases. 139 | var currentValue = current is not null ? property.GetValue(current) : null; 140 | var firstValue = first is not null ? property.GetValue(first) : null; 141 | var nextValue = next is not null ? property.GetValue(next) : null; 142 | 143 | if (firstValue is null && currentValue is null && nextValue is not null) 144 | { 145 | property.SetValue(current, nextValue); 146 | modified = true; 147 | continue; 148 | } 149 | else if (firstValue is not null && nextValue is null) 150 | { 151 | // if the base value is not null, but some plugin later in the load order does set the value to null 152 | // then retain the latest value 153 | // TODO set null? 154 | property.SetValue(current, currentValue); 155 | modified = true; 156 | continue; 157 | } 158 | 159 | // Find a merger and run it. 160 | if (Merge(property, current!, first!, next!)) 161 | { 162 | modified = true; 163 | } 164 | } 165 | 166 | return modified; 167 | } 168 | 169 | private static bool MergeTypeRecord(object currentParam, object firstParam, object nextParam) 170 | { 171 | // Get the values as their correct type. 172 | var current = currentParam as Record ?? throw new ArgumentException("Current record is of incorrect type."); 173 | var first = firstParam as Record ?? throw new ArgumentException("First record is of incorrect type."); 174 | var next = nextParam as Record ?? throw new ArgumentException("Next record is of incorrect type."); 175 | 176 | // Store modified state. 177 | var modified = false; 178 | 179 | // Ensure that the record type hasn't changed. 180 | if (!first.Name.Equals(next.Name)) 181 | { 182 | throw new Exception("Record types differ!"); 183 | } 184 | 185 | // Cover the base record flags. 186 | if (current.Flags.SequenceEqual(first.Flags) && !next.Flags.SequenceEqual(first.Flags)) 187 | { 188 | current.Flags = next.Flags; 189 | modified = true; 190 | } 191 | 192 | // Generically merge all other properties. 193 | if (MergeAllProperties(current, first, next)) 194 | { 195 | modified = true; 196 | } 197 | 198 | return modified; 199 | } 200 | 201 | private static bool MergePropertyBase(PropertyInfo property, object currentParam, object firstParam, object nextParam) 202 | { 203 | var currentValue = currentParam is not null ? property.GetValue(currentParam) : null; 204 | var firstValue = firstParam is not null ? property.GetValue(firstParam) : null; 205 | var nextValue = nextParam is not null ? property.GetValue(nextParam) : null; 206 | 207 | // Handle collections. 208 | if (property.PropertyType.IsNonStringEnumerable()) 209 | { 210 | var currentAsEnumerable = (currentValue as IEnumerable)?.Cast()!; 211 | var firstAsEnumerable = (firstValue as IEnumerable)?.Cast()!; 212 | var nextAsEnumerable = (nextValue as IEnumerable)?.Cast()!; 213 | 214 | var currentIsUnmodified = currentValue is not null && firstValue is not null ? currentAsEnumerable.SequenceEqual(firstAsEnumerable, BasicComparer) : currentValue == firstValue; 215 | var nextIsUnmodified = nextValue is not null && firstValue is not null ? nextAsEnumerable.SequenceEqual(firstAsEnumerable, BasicComparer) : nextValue == firstValue; 216 | 217 | if (currentIsUnmodified && !nextIsUnmodified) 218 | { 219 | property.SetValue(currentParam, nextValue); 220 | return true; 221 | } 222 | } 223 | else 224 | { 225 | var currentIsUnmodified = currentValue is not null ? currentValue.Equals(firstValue) : firstValue is null; 226 | var nextIsModified = !(nextValue is not null ? nextValue.Equals(firstValue) : firstValue is null); 227 | 228 | if (currentIsUnmodified && nextIsModified) 229 | { 230 | property.SetValue(currentParam, nextValue); 231 | return true; 232 | } 233 | } 234 | 235 | return false; 236 | } 237 | 238 | public static bool MergePropertySubrecord(PropertyInfo property, object currentParam, object firstParam, object nextParam) 239 | { 240 | // Get the values as their correct type. 241 | var currentValue = currentParam is not null ? property.GetValue(currentParam) : null; 242 | var firstValue = firstParam is not null ? property.GetValue(firstParam) : null; 243 | var nextValue = nextParam is not null ? property.GetValue(nextParam) : null; 244 | 245 | var modified = true; 246 | 247 | // Handle null cases. 248 | if (firstValue is null && currentValue is null && nextValue is not null) 249 | { 250 | property.SetValue(currentParam, nextValue); 251 | modified = true; 252 | } 253 | else if (firstValue is not null && nextValue is null) 254 | { 255 | property.SetValue(currentParam, null); 256 | modified = true; 257 | } 258 | else 259 | { 260 | if (MergeAllProperties(currentValue, firstValue, nextValue)) 261 | { 262 | modified = true; 263 | } 264 | } 265 | 266 | return modified; 267 | } 268 | 269 | public static bool MergeNamedProperties(in string[] propertyNames, object current, object first, object next) 270 | { 271 | var modified = false; 272 | 273 | foreach (var propertyName in propertyNames) 274 | { 275 | var subProperty = current.GetType().GetProperty(propertyName) ?? throw new Exception($"Property '{propertyName}' does not exist for type {current.GetType().FullName}."); 276 | if (Merge(subProperty, current, first, next)) 277 | { 278 | modified = true; 279 | } 280 | } 281 | 282 | return modified; 283 | } 284 | } 285 | -------------------------------------------------------------------------------- /TES3Merge/TES3Merge.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | Exe 5 | net6.0 6 | enable 7 | enable 8 | https://github.com/NullCascade/TES3Merge 9 | 0.11.2 10 | tes3merge 11 | true 12 | en 13 | tes3merge_icon_by_markel.ico 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | PreserveNewest 34 | 35 | 36 | 37 | 38 | -------------------------------------------------------------------------------- /TES3Merge/TES3Merge.ini: -------------------------------------------------------------------------------- 1 | 2 | [General] 3 | ; Enables the "Press any key to exit..." prompt after execution. 4 | PauseOnCompletion = true 5 | 6 | ; If enabled, hex dumps of merged records will be preserved in the logs. This is useful when submitting bug reports. 7 | DumpMergedRecordsToLog = false 8 | 9 | ; The text encoding code to load content files with. All content files need to have the same or compatible encoding to work correctly. 10 | ; Valid encoding for English is 1252. The Polish version uses 1250, the Russian version uses 1251, and the Japanese verion uses 932. 11 | TextEncodingCode = 1252 12 | 13 | ; An override to the installation path. By default TES3Merge will find valid Morrowind or OpenMW installations that are in any parent 14 | ; folder of the working directory. There are multiple ways of making TES3Merge find your installation. In order of priority: 15 | ; * Uncomment and edit the below line to define an explicit InstallPath. 16 | ; * Place TES3Merge in a child directory of the installation. 17 | ; * Run TES3Merge from the working directory of an install. This can be done through arguments in Mod Organizer. 18 | ; * Have Morrowind's install path in your registry. This only functions on Windows, and will not find OpenMW installations. 19 | ; InstallPath = 20 | 21 | ; Blacklist a file from merging. Set a filename to false to ignore it when merging. 22 | [FileFilters] 23 | ; Tamriel_Data.esm = false 24 | 25 | ; Here, filters can be defined to run against object IDs. All strings are compared lowercase. 26 | ; Regex is used here. Here are some examples: 27 | ; `".*" = false` will ignore everything, unless something after it matches true. This can be useful for testing. 28 | ; `"^ash_ghoul$" = true` will allow EXACTLY the "ash_ghoul" id. 29 | ; `"^ttm_" = false` will ignore any ID that begins with "ttm_". 30 | [ObjectFilters] 31 | ; ".*" = false 32 | ; "^ash_ghoul$" = true 33 | ; "^ttm_" = false 34 | 35 | ; Record types can be blocked from merging by specifying the type code as false. 36 | [RecordTypes] 37 | ACTI = true 38 | ALCH = true 39 | APPA = true 40 | ARMO = true 41 | BODY = true 42 | BOOK = true 43 | BSGN = true 44 | CELL = true 45 | CLAS = true 46 | CLOT = true 47 | CONT = true 48 | CREA = true 49 | DOOR = true 50 | ENCH = true 51 | GMST = true 52 | INGR = true 53 | LEVC = true 54 | LEVI = true 55 | LIGH = true 56 | LOCK = true 57 | MGEF = true 58 | MISC = true 59 | NPC_ = true 60 | PROB = true 61 | RACE = true 62 | REPA = true 63 | SKIL = true 64 | SNDG = true 65 | SOUN = true 66 | SPEL = true 67 | STAT = true 68 | WEAP = true 69 | -------------------------------------------------------------------------------- /TES3Merge/Util/Bsa.cs: -------------------------------------------------------------------------------- 1 | using TES3Merge.Extensions; 2 | 3 | namespace TES3Merge.BSA; 4 | 5 | public struct BSAHeader 6 | { 7 | public uint version; 8 | public uint fileNameHashesOffset; 9 | public uint numFiles; 10 | } 11 | 12 | public struct BSAFileInfo 13 | { 14 | public uint size; 15 | public uint offset; 16 | } 17 | 18 | public struct BSAHashRecord 19 | { 20 | public uint value1; 21 | public uint value2; 22 | } 23 | 24 | public class BSARecord 25 | { 26 | public BSARecord(BSAFile archive, string name, BSAFileInfo fileInfo, BSAHashRecord hash) 27 | { 28 | Archive = archive; 29 | Name = name; 30 | FileInfo = fileInfo; 31 | Hash = hash; 32 | } 33 | 34 | public BSAFile Archive { get; } 35 | public string Name; 36 | public BSAFileInfo FileInfo; 37 | public BSAHashRecord Hash; 38 | } 39 | 40 | public class BSAFile 41 | { 42 | public List Files = new(); 43 | 44 | public DateTime ModificationTime { get; } 45 | 46 | public BSAFile(string path) 47 | { 48 | var info = new FileInfo(path); 49 | ModificationTime = info.LastWriteTime; 50 | 51 | using var stream = new FileStream(path, FileMode.Open); 52 | 53 | var header = stream.ReadStruct(); 54 | 55 | var fileinfos = new List(); 56 | for (var i = 0; i < header.numFiles; i++) 57 | { 58 | fileinfos.Add(stream.ReadStruct()); 59 | } 60 | 61 | var fileNameOffsets = new List(); 62 | for (var i = 0; i < header.numFiles; i++) 63 | { 64 | fileNameOffsets.Add(stream.ReadStruct()); 65 | } 66 | 67 | var nameTableOffset = (uint)stream.Position; 68 | uint curOffset = 0; 69 | var fileNames = new List(); 70 | for (var i = 1; i < header.numFiles + 1; i++) 71 | { 72 | uint len; 73 | // last filename hack 74 | if (i != header.numFiles) 75 | { 76 | len = fileNameOffsets[i] - curOffset; 77 | curOffset = fileNameOffsets[i]; 78 | } 79 | else 80 | { 81 | len = header.fileNameHashesOffset - (curOffset + nameTableOffset - 12); 82 | } 83 | 84 | var buffer = new byte[len]; 85 | stream.Read(buffer); 86 | var s = System.Text.Encoding.UTF8.GetString(buffer, 0, buffer.Length).TrimEnd('\0'); 87 | fileNames.Add(s); 88 | } 89 | 90 | var fileHashes = new List(); 91 | for (var i = 0; i < header.numFiles; i++) 92 | { 93 | fileHashes.Add(stream.ReadStruct()); 94 | } 95 | 96 | for (var i = 0; i < header.numFiles; i++) 97 | { 98 | var record = new BSARecord(this, fileNames[i], fileinfos[i], fileHashes[i]); 99 | Files.Add(record); 100 | } 101 | } 102 | }; 103 | -------------------------------------------------------------------------------- /TES3Merge/Util/DataFile.cs: -------------------------------------------------------------------------------- 1 | using TES3Merge.BSA; 2 | 3 | namespace TES3Merge.Util; 4 | 5 | abstract public class DataFile 6 | { 7 | public enum FileType 8 | { 9 | Normal, 10 | Archive, 11 | } 12 | 13 | public FileType Type { get; } 14 | 15 | public DateTime ModificationTime { get; set; } 16 | 17 | public DataFile(FileType type) 18 | { 19 | Type = type; 20 | } 21 | } 22 | 23 | public class NormalDataFile : DataFile 24 | { 25 | public string FilePath { get; } 26 | 27 | public NormalDataFile(string path) : base(FileType.Normal) 28 | { 29 | var info = new FileInfo(path) ?? throw new Exception($"No file info could be found for {FilePath}."); 30 | if (!info.Exists) 31 | { 32 | throw new ArgumentException($"No file exists at {path}."); 33 | } 34 | 35 | FilePath = info.FullName; 36 | ModificationTime = info.LastWriteTime; 37 | } 38 | } 39 | 40 | public class ArchiveDataFile : DataFile 41 | { 42 | public BSARecord Record { get; } 43 | 44 | public ArchiveDataFile(BSARecord record) : base(FileType.Archive) 45 | { 46 | Record = record; 47 | ModificationTime = Record.Archive.ModificationTime; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /TES3Merge/Util/Installation.cs: -------------------------------------------------------------------------------- 1 | using IniParser; 2 | using IniParser.Model; 3 | using Microsoft.Win32; 4 | using System.Runtime.InteropServices; 5 | using System.Text.RegularExpressions; 6 | using TES3Merge.BSA; 7 | 8 | namespace TES3Merge.Util; 9 | 10 | /// 11 | /// Represents an installation of the game. This is used to load and store settings, as well as 12 | /// provide abstraction around game files to support OpenMW's VFS. 13 | /// 14 | public abstract class Installation 15 | { 16 | /// 17 | /// The primary directory that the installation can be found at. 18 | /// 19 | public string RootDirectory { get; } 20 | 21 | /// 22 | /// A list of archives defined by the installation. 23 | /// 24 | public List Archives { get; } = new(); 25 | 26 | /// 27 | /// 28 | /// 29 | public Dictionary ArchiveFiles { get; } = new(); 30 | 31 | /// 32 | /// A list of game files defined by the installation. These are sorted by their last modification time. 33 | /// 34 | public List GameFiles { get; } = new(); 35 | 36 | /// 37 | /// 38 | /// 39 | protected Dictionary? DataFiles; 40 | 41 | public Installation(string path) 42 | { 43 | RootDirectory = path; 44 | } 45 | 46 | /// 47 | /// 48 | /// 49 | /// 50 | /// 51 | private static bool IsValidInstallationDirectory(string path) 52 | { 53 | if (string.IsNullOrEmpty(path)) 54 | { 55 | return false; 56 | } 57 | 58 | return File.Exists(Path.Combine(path, "Morrowind.exe")) || File.Exists(Path.Combine(path, "openmw.cfg")); 59 | } 60 | 61 | /// 62 | /// 63 | /// 64 | /// 65 | private static string? GetContextAwareInstallPath() 66 | { 67 | ArgumentNullException.ThrowIfNull(Util.Configuration); 68 | 69 | // Do we have an explicit install in our config file? 70 | var explicitPath = Util.Configuration["General"]["InstallPath"]; 71 | if (IsValidInstallationDirectory(explicitPath)) 72 | { 73 | return explicitPath; 74 | } 75 | 76 | var cwd = Directory.GetCurrentDirectory(); 77 | if (IsValidInstallationDirectory(cwd)) 78 | return cwd; 79 | 80 | // Search all parent directories for Morrowind/OpenMW. 81 | for (var directory = new DirectoryInfo(cwd); directory is not null; directory = directory.Parent) 82 | { 83 | if (IsValidInstallationDirectory(directory.FullName)) 84 | { 85 | return directory.FullName; 86 | } 87 | } 88 | 89 | // On windows, fall back to the registry for Morrowind. 90 | if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) 91 | { 92 | var registryValue = Registry.GetValue("HKEY_LOCAL_MACHINE\\SOFTWARE\\WOW6432Node\\bethesda softworks\\Morrowind", "Installed Path", null) as string; 93 | if (!string.IsNullOrEmpty(registryValue) && IsValidInstallationDirectory(registryValue)) 94 | { 95 | return registryValue; 96 | } 97 | } 98 | 99 | return null; 100 | } 101 | 102 | /// 103 | /// Attempts to create an object. It checks the current folder, then 104 | /// all parent folders for a valid Morrowind or OpenMW installation. If it does not find 105 | /// anything, it will fall back to registry values (Windows-only). 106 | /// 107 | /// The context-aware installation interface. 108 | public static Installation? CreateFromContext() 109 | { 110 | var path = GetContextAwareInstallPath(); 111 | if (path is null) 112 | { 113 | throw new Exception("Could not determine installation location."); 114 | } 115 | 116 | try 117 | { 118 | if (File.Exists(Path.Combine(path, "Morrowind.exe"))) 119 | { 120 | return new MorrowindInstallation(path); 121 | } 122 | else if (File.Exists(Path.Combine(path, "openmw.cfg"))) 123 | { 124 | return new OpenMWInstallation(path); 125 | } 126 | } 127 | catch (Exception e) 128 | { 129 | Util.Logger.WriteLine(e.Message); 130 | } 131 | 132 | return null; 133 | } 134 | 135 | /// 136 | /// This function is responsible for loading all the files, taking into consideration anything 137 | /// like MO2 or OpenMW's VFS. 138 | /// 139 | protected abstract void LoadDataFiles(); 140 | 141 | /// 142 | /// Gets the output path to write the result merged objects plugin to. 143 | /// 144 | /// The path to use for the installation 145 | public abstract string GetDefaultOutputDirectory(); 146 | 147 | /// 148 | /// Fetches file information relative to the Data Files directory. This file may be a normal 149 | /// file, or it may be a record in a BSA archive. The files mappings here are overwritten so 150 | /// that the last modified file always wins. 151 | /// 152 | /// The path, relative to Data Files, to fetch. 153 | /// The interface to the file. 154 | public DataFile? GetDataFile(string path) 155 | { 156 | if (DataFiles is null) 157 | { 158 | LoadDataFiles(); 159 | } 160 | 161 | ArgumentNullException.ThrowIfNull(DataFiles); 162 | 163 | if (DataFiles.TryGetValue(path.ToLower(), out DataFile? file)) 164 | { 165 | return file; 166 | } 167 | 168 | return null; 169 | } 170 | 171 | /// 172 | /// As , but accounts for changes to paths that Morrowind 173 | /// expects. Some assets will fall back to another file, i.e. texture.tga -> texture.dds. 174 | /// 175 | /// The path, relative to Data Files, to fetch. 176 | /// The interface to the file. 177 | public DataFile? GetSubstitutingDataFile(string path) 178 | { 179 | var raw = GetDataFile(path); 180 | if (raw is not null) 181 | { 182 | return raw; 183 | } 184 | 185 | // Look up valid substitutions. 186 | var extension = Path.GetExtension(path); 187 | if (string.IsNullOrEmpty(extension)) 188 | { 189 | return null; 190 | } 191 | 192 | return extension.ToLower() switch 193 | { 194 | "tga" or "bmp" => GetDataFile(Path.ChangeExtension(path, "dds")), 195 | _ => null, 196 | }; 197 | } 198 | } 199 | 200 | /// 201 | /// Represents an installation specific to the normal Morrowind game engine. There are few extra 202 | /// considerations needed. 203 | /// 204 | public class MorrowindInstallation : Installation 205 | { 206 | /// 207 | /// The deserialized contents of the Morrowind.ini file. 208 | /// 209 | public IniData? Configuration; 210 | 211 | public MorrowindInstallation(string path) : base(path) 212 | { 213 | LoadConfiguration(); 214 | BuildArchiveList(); 215 | BuildGameFilesList(); 216 | } 217 | 218 | /// 219 | /// Loads the member. It also logs malformed 220 | /// ini formatting. 221 | /// 222 | private void LoadConfiguration() 223 | { 224 | try 225 | { 226 | var parser = new FileIniDataParser(); 227 | Configuration = parser.ReadFile(Path.Combine(RootDirectory, "Morrowind.ini")); 228 | } 229 | catch (Exception firstTry) 230 | { 231 | try 232 | { 233 | // Try again with invalid line skipping. 234 | var parser = new FileIniDataParser(); 235 | var config = parser.Parser.Configuration; 236 | config.SkipInvalidLines = true; 237 | config.AllowDuplicateKeys = true; 238 | config.AllowDuplicateSections = true; 239 | Configuration = parser.ReadFile(Path.Combine(RootDirectory, "Morrowind.ini")); 240 | 241 | // If the first pass fails, be more forgiving, but let the user know their INI has issues. 242 | Console.WriteLine("WARNING: Issues were found with your Morrowind.ini file. See TES3Merge.log for details."); 243 | Util.Logger.WriteLine($"WARNING: Could not parse Morrowind.ini with initial pass. Error: {firstTry.Message}"); 244 | } 245 | catch (Exception secondTry) 246 | { 247 | Console.WriteLine("ERROR: Unrecoverable issues were found with your Morrowind.ini file. See TES3Merge.log for details."); 248 | Util.Logger.WriteLine($"ERROR: Could not parse Morrowind.ini with second pass. Error: {secondTry.Message}"); 249 | } 250 | } 251 | } 252 | 253 | /// 254 | /// Fills out the list by parsing Morrowind.ini. 255 | /// 256 | private void BuildArchiveList() 257 | { 258 | ArgumentNullException.ThrowIfNull(Configuration); 259 | 260 | // Always start off with Morrowind. 261 | Archives.Add("Morrowind.bsa"); 262 | 263 | // Load the rest from the ini file. 264 | var configArchives = Configuration["Archives"]; 265 | for (var i = 0; true; ++i) 266 | { 267 | var archive = configArchives["Archive " + i]; 268 | if (string.IsNullOrEmpty(archive)) 269 | { 270 | break; 271 | } 272 | Archives.Add(archive); 273 | } 274 | } 275 | 276 | /// 277 | /// Fills out the list by parsing Morrowind.ini. The list 278 | /// is sorted such that esm files appear before esp files, with ordering by last modification. 279 | /// 280 | private void BuildGameFilesList() 281 | { 282 | ArgumentNullException.ThrowIfNull(Configuration); 283 | 284 | // Get the raw list. 285 | List definedFiles = new(); 286 | var configGameFiles = Configuration["Game Files"]; 287 | for (var i = 0; true; ++i) 288 | { 289 | var gameFile = configGameFiles["GameFile" + i]; 290 | if (string.IsNullOrEmpty(gameFile)) 291 | { 292 | break; 293 | } 294 | definedFiles.Add(gameFile); 295 | } 296 | 297 | // Add ESM files first. 298 | var dataFiles = Path.Combine(RootDirectory, "Data Files"); 299 | foreach (var path in Directory.GetFiles(dataFiles, "*.esm", SearchOption.TopDirectoryOnly).OrderBy(p => File.GetLastWriteTime(p).Ticks)) 300 | { 301 | var fileName = Path.GetFileName(path); 302 | if (definedFiles.Contains(fileName)) 303 | { 304 | GameFiles.Add(fileName); 305 | } 306 | } 307 | 308 | // Then add other content files. 309 | foreach (var path in Directory.GetFiles(dataFiles, "*.esp", SearchOption.TopDirectoryOnly).OrderBy(p => File.GetLastWriteTime(p).Ticks)) 310 | { 311 | var fileName = Path.GetFileName(path); 312 | if (definedFiles.Contains(fileName)) 313 | { 314 | GameFiles.Add(fileName); 315 | } 316 | } 317 | } 318 | 319 | /// 320 | /// Loops through all the archives defined in Morrowind.ini, and fetches record handles to any 321 | /// files that exist in them. If the BSA was modified before the normal file, the BSA takes 322 | /// priority. 323 | /// 324 | /// 325 | private void MapArchiveFiles() 326 | { 327 | ArgumentNullException.ThrowIfNull(DataFiles); 328 | 329 | foreach (var archive in Archives) 330 | { 331 | var archiveFile = GetDataFile(archive) as NormalDataFile ?? throw new Exception($"Archive '{archive}' could not be found."); 332 | var bsa = new BSAFile(archiveFile.FilePath); 333 | foreach (var contained in bsa.Files) 334 | { 335 | var existing = GetDataFile(contained.Name); 336 | if (existing is null || bsa.ModificationTime > existing.ModificationTime) 337 | { 338 | DataFiles[contained.Name.ToLower()] = new ArchiveDataFile(contained); 339 | } 340 | } 341 | ArchiveFiles[archive.ToLower()] = bsa; 342 | } 343 | } 344 | 345 | /// 346 | /// Builds a list of all entries in Data Files, and stores them in the 347 | /// map. This must be called perior to the mapping of 348 | /// BSA file contents. 349 | /// 350 | private void MapNormalFiles() 351 | { 352 | ArgumentNullException.ThrowIfNull(DataFiles); 353 | 354 | var dataFiles = Path.Combine(RootDirectory, "Data Files"); 355 | var physicalFiles = Directory 356 | .GetFiles(dataFiles, "*", SearchOption.AllDirectories) 357 | .Where(x => !x.EndsWith(".mohidden")) 358 | .Where(x => !x.Contains(Path.DirectorySeparatorChar + ".git" + Path.DirectorySeparatorChar)) 359 | .Select(x => x[(dataFiles.Length + 1)..]); 360 | 361 | foreach (var file in physicalFiles) 362 | { 363 | DataFiles[file.ToLower()] = new NormalDataFile(Path.Combine(dataFiles, file)); 364 | } 365 | } 366 | 367 | protected override void LoadDataFiles() 368 | { 369 | DataFiles = new(); 370 | 371 | MapNormalFiles(); 372 | MapArchiveFiles(); 373 | } 374 | 375 | public override string GetDefaultOutputDirectory() 376 | { 377 | return Path.Combine(RootDirectory, "Data Files"); 378 | } 379 | } 380 | 381 | /// 382 | /// Represents an installation specific to the normal OpenMW game engine. Extra considerations 383 | /// include: 384 | /// * Configuration format differs from normal. 385 | /// * Multiple Data Files folders are supported at once. 386 | /// 387 | public class OpenMWInstallation : Installation 388 | { 389 | private List DataDirectories = new(); 390 | private string? DataLocalDirectory; 391 | private string? ResourcesDirectory; 392 | private static readonly string DuplicateSeparatorPattern = 393 | $"[{Regex.Escape($"{Path.DirectorySeparatorChar}{Path.AltDirectorySeparatorChar}")}]+"; 394 | 395 | public OpenMWInstallation(string path) : base(path) 396 | { 397 | LoadConfiguration(path); 398 | 399 | if (!string.IsNullOrEmpty(DataLocalDirectory)) 400 | { 401 | DataDirectories.Add(DataLocalDirectory); 402 | 403 | if (!Directory.Exists(DataLocalDirectory)) 404 | Directory.CreateDirectory(DataLocalDirectory); 405 | } 406 | 407 | if (!string.IsNullOrEmpty(ResourcesDirectory)) 408 | DataDirectories.Insert(0, Path.Combine(ParseDataDirectory(path, ResourcesDirectory), "vfs")); 409 | } 410 | 411 | private static string GetDefaultConfigurationDirectory() 412 | { 413 | if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) 414 | { 415 | var myDocs = Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments); 416 | return Path.Combine(myDocs, "My Games", "OpenMW"); 417 | } 418 | else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) 419 | { 420 | var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); 421 | return Path.Combine(home, ".config", "openmw"); 422 | } 423 | else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) 424 | { 425 | var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); 426 | return Path.Combine(home, "Library", "Preferences", "openmw"); 427 | } 428 | 429 | throw new Exception("Could not determine configuration path."); 430 | } 431 | 432 | private static string GetDefaultUserDataDirectory() 433 | { 434 | if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) 435 | { 436 | var myDocs = Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments); 437 | return Path.Combine(myDocs, "My Games", "OpenMW"); 438 | } 439 | else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) 440 | { 441 | var dataHome = Environment.GetEnvironmentVariable("XDG_DATA_HOME"); 442 | 443 | if (string.IsNullOrEmpty(dataHome)) 444 | dataHome = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".local", "share"); 445 | 446 | return Path.Combine(dataHome, "openmw"); 447 | } 448 | else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) 449 | { 450 | var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); 451 | return Path.Combine(home, "Library", "Application Support", "openmw"); 452 | } 453 | 454 | throw new Exception("Could not determine user data directory."); 455 | } 456 | 457 | private static string ParseDataDirectory(string configDir, string dataDir) 458 | { 459 | if (dataDir.StartsWith('"')) 460 | { 461 | var original = dataDir; 462 | dataDir = ""; 463 | for (int i = 1; i < original.Length; i++) 464 | { 465 | if (original[i] == '&') 466 | i++; 467 | else if (original[i] == '"') 468 | break; 469 | dataDir += original[i]; 470 | } 471 | } 472 | 473 | if (dataDir.StartsWith("?userdata?")) 474 | dataDir = dataDir.Replace("?userdata?", GetDefaultUserDataDirectory()); 475 | else if (dataDir.StartsWith("?userconfig?")) 476 | dataDir = dataDir.Replace("?userconfig?", GetDefaultConfigurationDirectory()); 477 | 478 | if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) 479 | dataDir = dataDir.Replace('/', '\\'); 480 | 481 | if (!Path.IsPathRooted(dataDir)) 482 | dataDir = Path.GetFullPath(Path.Combine(configDir, dataDir)); 483 | 484 | return dataDir; 485 | } 486 | 487 | private void LoadConfiguration(string configDir) 488 | { 489 | var configPath = Path.Combine(configDir, "openmw.cfg"); 490 | if (!File.Exists(configPath)) 491 | { 492 | throw new Exception("openmw.cfg does not exist at the path " + configPath); 493 | } 494 | 495 | List subConfigs = new List { }; 496 | foreach (var line in File.ReadLines(configPath)) 497 | { 498 | if (string.IsNullOrEmpty(line) || line.Trim().StartsWith("#")) continue; 499 | 500 | var tokens = line.Split('=', 2); 501 | 502 | if (tokens.Length < 2) continue; 503 | 504 | var key = tokens[0].Trim(); 505 | var value = tokens[1].Trim(); 506 | 507 | switch (key) 508 | { 509 | case "data": 510 | DataDirectories.Add(ParseDataDirectory(configDir, value)); 511 | break; 512 | case "content": 513 | if (value.ToLower().EndsWith(".omwscripts")) continue; 514 | else if (GameFiles.Contains(value)) 515 | throw new Exception(value + " was listed as a content file by two configurations! The second one was: " + configDir); 516 | 517 | GameFiles.Add(value); 518 | break; 519 | case "fallback-archive": 520 | Archives.Add(value); 521 | break; 522 | case "data-local": 523 | DataLocalDirectory = ParseDataDirectory(configDir, value); 524 | break; 525 | case "config": 526 | subConfigs.Add(ParseDataDirectory(configDir, value)); 527 | break; 528 | case "resources": 529 | ResourcesDirectory = ParseDataDirectory(configDir, value); 530 | break; 531 | } 532 | } 533 | 534 | foreach (string config in subConfigs) 535 | try 536 | { 537 | LoadConfiguration(ParseDataDirectory(configDir, config)); 538 | } 539 | catch (Exception e) 540 | { 541 | Util.Logger.WriteLine("WARNING: Sub-configuration " + configDir + " does not contain an openmw.cfg, skipping due to: " + e); 542 | } 543 | } 544 | 545 | /// 546 | /// Loops through all the archives defined in Morrowind.ini, and fetches record handles to any 547 | /// files that exist in them. BSAs always lose priority to loose files. 548 | /// 549 | /// 550 | private void MapArchiveFiles() 551 | { 552 | ArgumentNullException.ThrowIfNull(DataFiles); 553 | 554 | foreach (var archive in Archives) 555 | { 556 | var archiveFile = GetDataFile(archive) as NormalDataFile ?? throw new Exception($"Archive '{archive}' could not be found."); 557 | var bsa = new BSAFile(archiveFile.FilePath); 558 | foreach (var contained in bsa.Files) 559 | { 560 | var existing = GetDataFile(contained.Name); 561 | if (existing is null) 562 | { 563 | DataFiles[contained.Name.ToLower()] = new ArchiveDataFile(contained); 564 | } 565 | } 566 | ArchiveFiles[archive.ToLower()] = bsa; 567 | } 568 | } 569 | 570 | /// 571 | /// Builds a list of all entries in a data folder, and stores them in the 572 | /// map. This must be called perior to the mapping of 573 | /// BSA file contents. 574 | /// 575 | private void MapNormalFiles(string dataFiles) 576 | { 577 | ArgumentNullException.ThrowIfNull(DataFiles); 578 | 579 | var physicalFiles = Directory 580 | .GetFiles(dataFiles, "*", SearchOption.AllDirectories) 581 | .Where(x => !x.EndsWith(".mohidden")) 582 | //.Where(x => !x.Contains(Path.DirectorySeparatorChar + ".git" + Path.DirectorySeparatorChar)) 583 | .Select(x => Path.GetRelativePath(dataFiles, x)); 584 | 585 | foreach (var file in physicalFiles) 586 | { 587 | DataFiles[file.ToLower()] = new NormalDataFile(Path.Combine(dataFiles, file)); 588 | } 589 | } 590 | 591 | protected override void LoadDataFiles() 592 | { 593 | DataFiles = new(); 594 | 595 | foreach (var dataFiles in DataDirectories) 596 | { 597 | MapNormalFiles(dataFiles); 598 | } 599 | MapArchiveFiles(); 600 | } 601 | 602 | public override string GetDefaultOutputDirectory() 603 | { 604 | if (DataDirectories.Count == 0) 605 | throw new Exception("No data directories defined. No default output directory could be resolved."); 606 | 607 | var outputDirIndex = string.IsNullOrEmpty(DataLocalDirectory) ? 0 : DataDirectories.Count - 1; 608 | 609 | return DataDirectories[outputDirIndex]; 610 | } 611 | } 612 | -------------------------------------------------------------------------------- /TES3Merge/Util/ScopedStopwatch.cs: -------------------------------------------------------------------------------- 1 | using System.Diagnostics; 2 | using System.Runtime.CompilerServices; 3 | 4 | namespace TES3Merge 5 | { 6 | /// 7 | /// A scoped stopwatch that will log the elapsed time automatically when exiting scope. 8 | /// Usage: using var ssw = new ScopedStopwatch(); 9 | /// 10 | public sealed class ScopedStopwatch : IDisposable 11 | { 12 | private readonly Stopwatch _stopwatch; 13 | private readonly string _caller; 14 | 15 | public ScopedStopwatch([CallerMemberName] string name = "") 16 | { 17 | _caller = name; 18 | _stopwatch = Stopwatch.StartNew(); 19 | } 20 | 21 | public void Dispose() 22 | { 23 | _stopwatch.Stop(); 24 | var elapsed = _stopwatch.ElapsedMilliseconds; 25 | Console.WriteLine($"[{_caller}] took {elapsed} ms."); 26 | } 27 | } 28 | } -------------------------------------------------------------------------------- /TES3Merge/Util/Util.cs: -------------------------------------------------------------------------------- 1 | using IniParser; 2 | using IniParser.Model; 3 | using Microsoft.Win32; 4 | using System.Runtime.InteropServices; 5 | using System.Text; 6 | using TES3Merge.BSA; 7 | 8 | namespace TES3Merge.Util; 9 | 10 | [Flags] 11 | public enum EPatch 12 | { 13 | None = 0, 14 | Fogbug = 1, 15 | Cellnames = 2, 16 | Summons = 4, 17 | All = 8, 18 | } 19 | 20 | internal static class Util 21 | { 22 | public static StreamWriter Logger = new("TES3Merge.log", false) 23 | { 24 | AutoFlush = true 25 | }; 26 | 27 | public static Installation? CurrentInstallation; 28 | 29 | public static IniData? Configuration; 30 | 31 | /// 32 | /// Generates a extension to folder map of the modded game 33 | /// 34 | /// 35 | /// 36 | internal static Dictionary> GetExtensionMap(ILookup fileMap) 37 | { 38 | var excludedFolders = new List() { "docs", "distantland", "mwse", "extras", "mash" }; 39 | 40 | // generate the extensionMap 41 | var extensionToFolderMap = new Dictionary>(); 42 | foreach (var grouping in fileMap) 43 | { 44 | var key = grouping.Key; 45 | var group = fileMap[key]; 46 | 47 | var list = new List(); 48 | foreach (var item in group) 49 | { 50 | var splits = item.Split(Path.DirectorySeparatorChar); 51 | var first = splits.FirstOrDefault(); 52 | if (string.IsNullOrEmpty(first)) 53 | { 54 | continue; 55 | } 56 | if (splits.Length == 1) 57 | { 58 | continue; 59 | } 60 | if (excludedFolders.Contains(first)) 61 | { 62 | continue; 63 | } 64 | if (Path.HasExtension(first)) 65 | { 66 | continue; 67 | } 68 | 69 | if (!list.Contains(first)) 70 | { 71 | list.Add(first); 72 | } 73 | } 74 | 75 | if (list.Count > 0) 76 | { 77 | extensionToFolderMap.Add(key, list); 78 | } 79 | } 80 | 81 | return extensionToFolderMap; 82 | } 83 | 84 | /// 85 | /// Returns a list that is a copy of the load order, filtered to certain results. 86 | /// 87 | /// The base sorted load order collection. 88 | /// The filter to include elements from. 89 | /// A copy of , filtered to only elements that match with . 90 | internal static List GetFilteredLoadList(IEnumerable loadOrder, IEnumerable filter) 91 | { 92 | var result = new List(); 93 | 94 | foreach (var file in loadOrder) 95 | { 96 | if (filter.Contains(file)) 97 | { 98 | result.Add(file); 99 | } 100 | } 101 | 102 | return result; 103 | } 104 | 105 | /// 106 | /// CLI completion 107 | /// 108 | internal static void ShowCompletionPrompt() 109 | { 110 | if (Configuration is not null && bool.TryParse(Configuration["General"]["PauseOnCompletion"], out var pauseOnCompletion) && pauseOnCompletion) 111 | { 112 | Console.WriteLine("Press any key to exit..."); 113 | Console.ReadKey(); 114 | } 115 | } 116 | 117 | /// 118 | /// Writes to both the console and the log file. 119 | /// 120 | /// Message to write. 121 | internal static void WriteToLogAndConsole(string Message) 122 | { 123 | Logger.WriteLine(Message); 124 | Console.WriteLine(Message); 125 | } 126 | 127 | /// 128 | /// Load this application's configuration. 129 | /// 130 | internal static void LoadConfig() 131 | { 132 | { 133 | var parser = new FileIniDataParser(); 134 | var iniPath = Path.Combine(System.IO.Directory.GetCurrentDirectory(), "TES3Merge.ini"); 135 | 136 | if (!File.Exists(iniPath)) 137 | iniPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "TES3Merge.ini"); 138 | 139 | Configuration = parser.ReadFile(iniPath); 140 | } 141 | 142 | // Determine what encoding to use. 143 | try 144 | { 145 | var iniEncodingCode = Configuration["General"]["TextEncodingCode"]; 146 | if (int.TryParse(iniEncodingCode, out var newEncodingCode)) 147 | { 148 | // TODO: Check a list of supported encoding codes. 149 | if (newEncodingCode is not 932 and (< 1250 or > 1252)) 150 | { 151 | throw new Exception($"Encoding code '{newEncodingCode}' is not supported. See TES3Merge.ini for supported values."); 152 | } 153 | 154 | // Register the encoding provider so we can understand 1252 and presumably others. 155 | Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); 156 | 157 | var encoding = Encoding.GetEncoding(newEncodingCode); 158 | Logger.WriteLine($"Using encoding: {encoding.EncodingName}"); 159 | Utility.Common.TextEncodingCode = newEncodingCode; 160 | } 161 | else 162 | { 163 | throw new Exception($"Encoding code '{iniEncodingCode}' is not a valid integer. See TES3Merge.ini for supported values."); 164 | } 165 | } 166 | catch (Exception e) 167 | { 168 | // Write the exception as a warning and set the default Windows-1252 encoding. 169 | WriteToLogAndConsole($"WARNING: Could not resolve default text encoding code: {e.Message}"); 170 | Console.WriteLine("Default encoding of Windows-1252 (English) will be used."); 171 | Utility.Common.TextEncodingCode = 1252; 172 | } 173 | 174 | } 175 | 176 | /// 177 | /// GetMergeTags from config and ini 178 | /// 179 | /// 180 | /// 181 | internal static (List supportedMergeTags, List> objectIdFilters) GetMergeTags() 182 | { 183 | ArgumentNullException.ThrowIfNull(Configuration); 184 | 185 | // TODO refactor this? 186 | // Get a list of supported mergable object types. 187 | var supportedMergeTags = new List 188 | { 189 | "ACTI", 190 | "ALCH", 191 | "APPA", 192 | "ARMO", 193 | "BODY", 194 | "BOOK", 195 | "BSGN", 196 | "CELL", 197 | "CLAS", 198 | "CLOT", 199 | "CONT", 200 | "CREA", 201 | //"DIAL", 202 | "DOOR", 203 | "ENCH", 204 | //"FACT", 205 | //"GLOB", 206 | "GMST", 207 | //"INFO", 208 | "INGR", 209 | //"LAND", 210 | "LEVC", 211 | "LEVI", 212 | "LIGH", 213 | "LOCK", 214 | //"LTEX", 215 | "MGEF", 216 | "MISC", 217 | "NPC_", 218 | //"PGRD", 219 | "PROB", 220 | "RACE", 221 | //"REFR", 222 | //"REGN", 223 | "REPA", 224 | //"SCPT", 225 | "SKIL", 226 | "SNDG", 227 | "SOUN", 228 | "SPEL", 229 | "STAT", 230 | "WEAP", 231 | }; 232 | 233 | // Allow INI to remove types from merge. 234 | foreach (var recordTypeConfig in Configuration["RecordTypes"]) 235 | { 236 | if (bool.TryParse(recordTypeConfig.Value, out var supported) && !supported) 237 | { 238 | supportedMergeTags.Remove(recordTypeConfig.KeyName); 239 | } 240 | } 241 | 242 | // Make sure we're going to merge something. 243 | if (supportedMergeTags.Count == 0) 244 | { 245 | WriteToLogAndConsole("ERROR: No valid record types to merge. Check TES3Merge.ini's configuration."); 246 | throw new ArgumentException(); 247 | } 248 | 249 | // Get object ID filtering from INI. 250 | var objectIdFilters = new List>(); 251 | foreach (var kv in Configuration["ObjectFilters"]) 252 | { 253 | if (bool.TryParse(kv.Value, out var allow)) 254 | { 255 | objectIdFilters.Add(new KeyValuePair(kv.KeyName.Trim('"'), allow)); 256 | } 257 | else 258 | { 259 | WriteToLogAndConsole($"WARNING: Filter {kv.KeyName} could not be parsed."); 260 | } 261 | } 262 | 263 | return (supportedMergeTags, objectIdFilters); 264 | } 265 | } 266 | -------------------------------------------------------------------------------- /TES3Merge/tes3merge_icon_by_markel.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NullCascade/TES3Merge/1058246a1a94ab9f045e2574e5022de6516db4d2/TES3Merge/tes3merge_icon_by_markel.ico -------------------------------------------------------------------------------- /changelog.md: -------------------------------------------------------------------------------- 1 | # TES3Merge Changelog 2 | 3 | ## v0.11.2 (2025-01-19) 4 | 5 | * Don't parse files inside .git folders. 6 | 7 | ## v0.11.1 (2024-11-24) 8 | 9 | * Don't parse files that are hidden by MO2. 10 | 11 | ## v0.11 (2023-06-30) 12 | 13 | * Improved support for OpenMW: 14 | * Fixed issue with OpenMW installs where master sizes couldn't be resolved. 15 | * Fixed issue with OpenMW installs where TES3Merge would try to save to a potentially invalid directory. By default the first defined `data` directory will be used as the output path. 16 | * Added `OutputPath` configuration option to the ini. This can be used to explicitly set where the merged plugin will be created. 17 | * Fixed issue where deleted NPC records with no NPC data would cause an error. 18 | * Fixed issue where certain objects would cause a crash when using `-r` or `-ir` parameters to overwrite supported record types. 19 | * Added a new icon by Markel. 20 | 21 | ## v0.10.2 (2022-06-17) 22 | 23 | * Significant refactoring. 24 | * Added general support for handling OpenMW's VFS and reading OpenMW's config files. This starts basic support for OpenMW. TES3Lib still doesn't support omwaddon files. 25 | * More fixes to finding the correct installation path from the current execution folder. 26 | * The Morrowind installation directory can now be hardcoded into the TES3Merge.ini file. 27 | 28 | ## v0.10.1 (2022-06-15) 29 | 30 | * Fixed assembly version. 31 | * Fixed issue where merging LEVC/LEVI records would fail if one of the merging records had no index/collection. 32 | * Improved search for Morrowind directory. TES3Merge can now be placed more than one directory below Morrowind.exe. Morrowind\Tools\TES3Merge\TES3Merge.exe will now always find the installation folder it is associated with instead of defaulting to the registry value. 33 | * Added basic tests to ensure LEVC records could merge. 34 | 35 | ## v0.10 (2022-06-14) 36 | 37 | * Added merging of AI packages. 38 | * Added `multipatch` option: Merges leveled lists and fixes common bugs in mods (cellname reversion, invalid fog, non-persistent summons). This covers these issues better than tes3cmd's multipatch, centralizing TES3Merge as the only command that needs to be run when adding/removing/updating content files. 39 | * Added `verify` option: verifies all esps in the active load order and checks for missing files. 40 | * Added more command line options in general. See GitHub readme or use --help for details. 41 | * Improved merging of NPC values. 42 | * Added more unit testing to the project. 43 | * More major refactors to the code base. 44 | 45 | ## v0.9 (2021-01-09) 46 | 47 | * Fixed error when merging NPC skill values. 48 | * Fixed error merging NPC autocalc status. 49 | * Fixed error when merging NPC and creature AI packages. 50 | * Improved logic when merging alchemy effects. 51 | * Disabled enchantment record merging by default until it can be refined. 52 | * Minor cross-platform code improvements. 53 | * Updated to .NET 6.0. This is now a requirement of the program. 54 | * Added automated test framework to ensure future merge consistency. 55 | 56 | ## v0.8 (2021-02-19) 57 | 58 | * Skips merging AIPackage lists for NPC and CREA records. Has to be hand tailored at a later date. 59 | * Provide more logging and useful feedback on the event of an exception happening when merging properties. 60 | 61 | ## v0.7 (2020-12-20) 62 | 63 | * Pulls in various updates to TES3Tool. This includes a fix to autocalculated NPCs. 64 | * Added file filtering, to blacklist certain esm/esp files from merging. 65 | * Disabled FACT merging entirely until there are cycles to fix it. 66 | 67 | ## v0.6.1 (2020-01-26) 68 | 69 | * Fixed issue where generating merged objects with Merged Objects.esp active would freeze the program. 70 | 71 | ## v0.6 (2020-01-26) 72 | 73 | This update primarily brings up changes in TES3Lib and adds support for more than 255 mods. 74 | 75 | * Fixed compatibility with non-Windows environments (courtesy of Benjamin Winger). No Linux or macOS builds are provided. 76 | * Fixed issue where blacklisting all record types would instead try to merge all record types, including unsupported types. 77 | * Fixed CLAS.CLDT merging to ignore favored attributes/skills. These are no longer merged, and will need to be done manually in a future version. 78 | * Fixed FACT.FADT merging to ignore favored attributes/skills as well as rank data. This could create a crash when approaching NPCs using this faction. See above note. 79 | * Brought upstreem changes to underlying TES3Lib changes, merging in SaintBahamut's work. No expected change in merge behavior. 80 | * Removed 255 mod limit. 81 | 82 | ## v0.5.1 (2019-06-08) 83 | 84 | * Fixed issue that would force default flags onto creatures, resulting in evolved slaughterfish that could go onto land. 85 | * Fixed issue with CREA.FNAM subrecord merging. 86 | 87 | ## v0.5 (2019-06-04) 88 | 89 | * Added support for specifying a file encoding in TES3Merge.ini. This brings in support for Russian, Polish, and Japanese installs. 90 | * Fixed handling of NPC/creature AI travel destinations, as well as escort/follow packages. 91 | 92 | ## v0.4 (2019-06-03) 93 | 94 | * Added support for more record types: BSGN, CLAS, SNDG, SOUN, SPEL, STAT 95 | * Added filter to ignore "Merged_Objects.esp" from other merge object tools. 96 | * Fixed text encoding issues (again, maybe for real this time). Still restricted to Windows-1252 encoded content files. 97 | * Changed record dumping to provide the raw loaded bytes, rather than TES3Merge's interpretation of them. Does not apply to the tool's output serialization. This will help with debugging future issues. 98 | * Made stronger attempts to read invalid Morrowind.ini files. 99 | * Fixed issue where factions with no attributes would error when serializing. 100 | 101 | ## v0.3 (2019-05-31) 102 | 103 | This update merges in changes to Bahamut's TES3Tool to fix Windows-style quotes from becoming question marks. 104 | 105 | * Fixed encoding issues. Note that encoding is restricted to English content files. 106 | * Added support for more record types: BODY, FACT, MGEF, SKIL 107 | 108 | ## v0.2.1 (2019-05-30) 109 | 110 | Hotfix to fix issue where ESM files were loaded prior to ESP files. 111 | 112 | * Fixed issue where ESM files would take priority over ESP files. 113 | * Fixed broken link in readme. 114 | 115 | ## v0.2 (2019-05-30) 116 | 117 | * Improved merge logic. 118 | * Added support for more record types to merge. 119 | * Added support for ignoring certain record types. 120 | * Added support for multi-layer object filtering via regex. 121 | * Added debug option to dump merged record history to log. 122 | * Added ini option to not pause after execution. 123 | * Added Morrowind.ini file checking. TES3Merge will report about invalid entries in the ini file, but will still try to load it. 124 | * Fixed generated file having invalid version/record counts in the header, which made tools like Enchanted Editor complain. 125 | * Fixed issue where load order followed Morrowind.ini instead of also respecting file timestamps. 126 | * Fixed issue where records were checked in reverse order, giving priority to earlier mods. 127 | 128 | ## v0.1 (2019-05-29) 129 | 130 | * Initial release. 131 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # TES3Merge v0.11 2 | 3 | [![.NET](https://github.com/NullCascade/TES3Merge/actions/workflows/TES3Merge.yml/badge.svg)](https://github.com/NullCascade/TES3Merge/actions/workflows/TES3Merge.yml) 4 | 5 | This tool helps to automatically patch conflicts between mods for _The Elder Scrolls III: Morrowind_. 6 | 7 | This program includes an INI file to allow customization. Check [TES3Merge.ini](TES3Merge/TES3Merge.ini) for details on object blacklisting/whitelisting, debug options, and object type toggles. 8 | 9 | ## Usage 10 | 11 | Extract the TES3Merge folder into a subfolder of the Morrowind installation directory. It can work outside this directory, but when managing multiple installs, it will always look to an ancestor directory first to find Morrowind. 12 | 13 | Simply run TES3Merge.exe, then activate the new Merged Objects.esp file. 14 | 15 | If running a Russian, Polish, or Japanese install, see [TES3Merge.ini](TES3Merge/TES3Merge.ini) to specify file encoding. 16 | 17 | Additional command line parameter options are available, detailed below. The default behavior of TES3Merge is equivalent to `TES3Merge.exe --patches all`. 18 | 19 | | Option | Description | 20 | | ------------------------------------------- | ------------------------------------------------------------------------------- | 21 | | `-i`, `--inclusive` | Merge lists inclusively per element (implemented for List). | 22 | | `--no-masters` | Do not add masters to the merged esp. | 23 | | `-r`, `--records ` | Merge only specified record types. | 24 | | `--ignore-records`, `--ir ` | Ignore specified record types | 25 | | `-p`, `--patches ` | Apply any of the patches detailed below. If left empty all patches are applied. | 26 | | `--version` | Show version information | 27 | | `-?`, `-h`, `--help` | Show help and usage information | 28 | 29 | ### Patches 30 | 31 | TES3Merge creates patches to solve common issue in mods. These are all enabled by default, but can be configured by passing the `-p` or `-patches` command line argument. 32 | 33 | | Patch | Description | 34 | | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | 35 | | none | No patches will be applied. | 36 | | all | All patches will be applied. | 37 | | cellnames | Creates a patch to ensure renamed cells are not accidentally reverted to their original name. | 38 | | fogbug | This option creates a patch that fixes all fogbugged cells in your active plugins by setting the fog density of those cells to a non-zero value. | 39 | | summons | This option to the multipatch ensures that known summoned creatures are flagged as persistent. | 40 | 41 | ### Commands 42 | 43 | Additionally, there are additional commands beside the default merge command. You can run them with `TES3Merge.exe multipatch` and `TES3Merge.exe verify`. 44 | 45 | | Command | Description | 46 | | ------------------------------------------- | ------------------------------------------------------------------------------- | 47 | | `multipatch` | Create a multipatch that merges levelled lists and fixes various other bugs. | 48 | | `verify` | Checks esps for missing file paths. | 49 | 50 | ### Configuration 51 | 52 | TES3Merge also contains a configuration file, [TES3Merge.ini](TES3Merge/TES3Merge.ini). Documentation for the config file can be found in the file itself. 53 | 54 | ## Further Details 55 | 56 | As an example, [Patch for Purists](https://www.nexusmods.com/morrowind/mods/45096/?) fixes the Expensive Skirt's name, while [Better Clothes](https://www.nexusmods.com/morrowind/mods/42262/?) provides alternative appearances. If you use the two mods together, the changes from one mod will be ignored. With object merging, the changes that both mods make can make it into the game. The following image demonstrates the resolved conflict: 57 | 58 | ![Example conflict resolution image](https://cdn.discordapp.com/attachments/381219559094616064/583192237450461187/unknown.png) 59 | 60 | Currently, TES3Merge supports the following record types: Activator, Alchemy, Apparatus, Armor, Birthsign, Body Part, Book, Cell, Class\*, Clothing, Container, Creature, Door, Enchantment, Game Setting, Ingredient, Leveled Creature, Leveled Item, Light, Lockpick, Magic Effect, Misc. Item, NPC, Probe, Race\*, Repair Tool, Skill, Sound, Sound Generator, Spell, Static, and Weapon. Types marked with a \* are incomplete merges, still favoring the last loader for parts it doesn't know how to merge. 61 | 62 | Merge rules respect the load order, with the first appearance of the record becoming the base for comparisons. If a later mod modifies the record, its changes will be preserved. Another mod after that will only have its changes made if they differ from the base record. 63 | 64 | ## Contributing and Credits 65 | 66 | TES3Merge is written using C#, and makes use of the [TES3Tool](https://github.com/SaintBahamut/TES3Tool) library by [SaintBahamut](https://github.com/SaintBahamut). A fork of this dependency is cloned with this repo. 67 | 68 | ## License 69 | 70 | TES3Merge is MIT licensed. See [LICENSE](LICENSE) for more information. 71 | -------------------------------------------------------------------------------- /tes3merge_icon_by_markel.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NullCascade/TES3Merge/1058246a1a94ab9f045e2574e5022de6516db4d2/tes3merge_icon_by_markel.ico -------------------------------------------------------------------------------- /tes3merge_icon_by_markel.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NullCascade/TES3Merge/1058246a1a94ab9f045e2574e5022de6516db4d2/tes3merge_icon_by_markel.png --------------------------------------------------------------------------------