diff --git a/.github/workflows/cli.yml b/.github/workflows/cli.yml
new file mode 100644
index 000000000..fa5fd2f0e
--- /dev/null
+++ b/.github/workflows/cli.yml
@@ -0,0 +1,152 @@
+name: CLI
+
+on:
+ push:
+ branches: [main, master, cli]
+ paths:
+ - CodeWalker.Cli/**
+ - CodeWalker.Core/**
+ - CodeWalker.ModManager/**
+ - CodeWalker.WinForms/**
+ - CodeWalker/**
+ - CodeWalker.sln
+ - .github/workflows/cli.yml
+ pull_request:
+ paths:
+ - CodeWalker.Cli/**
+ - CodeWalker.Core/**
+ - CodeWalker.ModManager/**
+ - CodeWalker.WinForms/**
+ - CodeWalker/**
+ - CodeWalker.sln
+ - .github/workflows/cli.yml
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+env:
+ DOTNET_NOLOGO: true
+ DOTNET_CLI_TELEMETRY_OPTOUT: true
+ DOTNET_SKIP_FIRST_TIME_EXPERIENCE: true
+
+jobs:
+ test:
+ name: ${{ matrix.os }}
+ runs-on: ${{ matrix.os }}
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ # Windows covers net48 natively. Linux is not redundant: two of the
+ # path bugs this project has hit only reproduce off Windows.
+ - os: windows-latest
+ frameworks: net48 net8.0 net10.0
+ - os: ubuntu-latest
+ frameworks: net8.0 net10.0
+ steps:
+ - uses: actions/checkout@v7
+
+ - uses: actions/setup-dotnet@v6
+ with:
+ dotnet-version: |
+ 8.0.x
+ 10.0.x
+
+ - name: Build
+ run: dotnet build CodeWalker.Cli/CodeWalker.Cli.csproj -warnaserror
+
+ - name: Test
+ shell: bash
+ run: |
+ status=0
+ for framework in ${{ matrix.frameworks }}; do
+ echo "::group::$framework"
+ dotnet test CodeWalker.Cli/CodeWalker.Cli.Tests.csproj -f "$framework" || status=1
+ echo "::endgroup::"
+ done
+ exit $status
+
+ # The runner reports failures to a file rather than to stdout, so without
+ # this a failed run names no test.
+ - name: Show test logs
+ if: failure()
+ shell: bash
+ run: |
+ find CodeWalker.Cli/bin -path '*TestResults*' -name '*.log' -print -exec cat {} +
+
+ solution:
+ name: solution
+ runs-on: windows-latest
+ steps:
+ - uses: actions/checkout@v7
+
+ - uses: actions/setup-dotnet@v6
+ with:
+ dotnet-version: |
+ 8.0.x
+ 10.0.x
+
+ - uses: microsoft/setup-msbuild@v3
+
+ # The CLI jobs build one project. This is what notices when a change to
+ # CodeWalker.Core breaks the WinForms projects that also consume it.
+ # msbuild rather than dotnet build: the solution carries a C++ project.
+ # CodeWalker.csproj sets PlatformTarget to x64 under Release|AnyCPU and the
+ # SDK then infers a win-x64 RuntimeIdentifier that restore has not produced,
+ # which fails as NETSDK1047; clearing it keeps the AnyCPU assets.
+ - name: Build
+ run: msbuild CodeWalker.sln -restore -p:Configuration=Release -p:RuntimeIdentifier= -m
+
+ - uses: actions/upload-artifact@v7
+ with:
+ name: codewalker-solution
+ path: '*/bin/Release/net48/'
+
+ publish:
+ name: publish ${{ matrix.name }}
+ runs-on: ${{ matrix.os }}
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - os: windows-latest
+ name: win-x64
+ args: -f net10.0 -r win-x64 --self-contained true -p:PublishSingleFile=true
+ - os: ubuntu-latest
+ name: linux-x64
+ args: -f net10.0 -r linux-x64 --self-contained true -p:PublishSingleFile=true
+ - os: windows-latest
+ name: net48
+ args: -f net48
+ steps:
+ - uses: actions/checkout@v7
+
+ - uses: actions/setup-dotnet@v6
+ with:
+ dotnet-version: |
+ 8.0.x
+ 10.0.x
+
+ - name: Publish
+ run: dotnet publish CodeWalker.Cli/CodeWalker.Cli.csproj -c Release ${{ matrix.args }} -o publish
+
+ - uses: actions/upload-artifact@v7
+ with:
+ name: codewalker-cli-${{ matrix.name }}
+ path: publish
+
+ format:
+ name: format
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v7
+
+ - uses: actions/setup-dotnet@v6
+ with:
+ dotnet-version: 10.0.x
+
+ - name: Verify formatting
+ run: |
+ dotnet format CodeWalker.Cli/CodeWalker.Cli.csproj --verify-no-changes
+ dotnet format CodeWalker.Cli/CodeWalker.Cli.Tests.csproj --verify-no-changes
diff --git a/.vscode/launch.json b/.vscode/launch.json
new file mode 100644
index 000000000..32c2c4959
--- /dev/null
+++ b/.vscode/launch.json
@@ -0,0 +1,35 @@
+{
+ "version": "0.2.0",
+ "configurations": [
+ {
+ "name": "CLI (net48)",
+ "type": "clr",
+ "request": "launch",
+ "preLaunchTask": "build-cli",
+ "program": "${workspaceFolder}/CodeWalker.Cli/bin/CodeWalker.Cli/Debug/net48/CodeWalker.Cli.exe",
+ "args": [],
+ "cwd": "${workspaceFolder}",
+ "console": "integratedTerminal"
+ },
+ {
+ "name": "CLI (net8.0)",
+ "type": "coreclr",
+ "request": "launch",
+ "preLaunchTask": "build-cli",
+ "program": "${workspaceFolder}/CodeWalker.Cli/bin/CodeWalker.Cli/Debug/net8.0/CodeWalker.Cli.dll",
+ "args": [],
+ "cwd": "${workspaceFolder}",
+ "console": "integratedTerminal"
+ },
+ {
+ "name": "CLI (net10.0)",
+ "type": "coreclr",
+ "request": "launch",
+ "preLaunchTask": "build-cli",
+ "program": "${workspaceFolder}/CodeWalker.Cli/bin/CodeWalker.Cli/Debug/net10.0/CodeWalker.Cli.dll",
+ "args": [],
+ "cwd": "${workspaceFolder}",
+ "console": "integratedTerminal"
+ }
+ ]
+}
diff --git a/.vscode/tasks.json b/.vscode/tasks.json
new file mode 100644
index 000000000..f589760a8
--- /dev/null
+++ b/.vscode/tasks.json
@@ -0,0 +1,67 @@
+{
+ "version": "2.0.0",
+ "tasks": [
+ {
+ "label": "build-cli",
+ "type": "process",
+ "command": "dotnet",
+ "args": ["build", "CodeWalker.Cli/CodeWalker.Cli.csproj"],
+ "group": {
+ "kind": "build",
+ "isDefault": true
+ },
+ "problemMatcher": "$msCompile",
+ "presentation": {
+ "reveal": "silent",
+ "revealProblems": "onProblem"
+ }
+ },
+ {
+ "label": "rebuild-cli",
+ "type": "process",
+ "command": "dotnet",
+ "args": [
+ "build",
+ "CodeWalker.Cli/CodeWalker.Cli.csproj",
+ "--no-incremental"
+ ],
+ "group": "build",
+ "problemMatcher": "$msCompile"
+ },
+ {
+ "label": "clean-cli",
+ "type": "process",
+ "command": "dotnet",
+ "args": ["clean", "CodeWalker.Cli/CodeWalker.Cli.csproj"],
+ "group": "build",
+ "problemMatcher": "$msCompile"
+ },
+ {
+ "label": "format-cli",
+ "type": "process",
+ "command": "dotnet",
+ "args": ["format", "CodeWalker.Cli/CodeWalker.Cli.csproj"],
+ "group": "build",
+ "problemMatcher": []
+ },
+ {
+ "label": "format-cli-tests",
+ "type": "process",
+ "command": "dotnet",
+ "args": ["format", "CodeWalker.Cli/CodeWalker.Cli.Tests.csproj"],
+ "group": "build",
+ "problemMatcher": []
+ },
+ {
+ "label": "test-cli",
+ "type": "process",
+ "command": "dotnet",
+ "args": ["test", "CodeWalker.Cli/CodeWalker.Cli.Tests.csproj"],
+ "group": {
+ "kind": "test",
+ "isDefault": true
+ },
+ "problemMatcher": "$msCompile"
+ }
+ ]
+}
diff --git a/CodeWalker.Cli/.editorconfig b/CodeWalker.Cli/.editorconfig
new file mode 100644
index 000000000..7c87d9801
--- /dev/null
+++ b/CodeWalker.Cli/.editorconfig
@@ -0,0 +1,446 @@
+root = true
+
+# All files
+[*]
+indent_style = space
+
+# Xml files
+[*.xml]
+indent_size = 2
+
+# C# files
+[*.cs]
+
+#### Core EditorConfig Options ####
+
+# Indentation and spacing
+indent_size = 4
+tab_width = 4
+
+# New line preferences
+end_of_line = crlf
+insert_final_newline = true
+
+#### .NET Coding Conventions ####
+[*.{cs,vb}]
+
+# Organize usings
+dotnet_separate_import_directive_groups = true
+dotnet_sort_system_directives_first = true
+file_header_template = unset
+
+# this. and Me. preferences
+dotnet_style_qualification_for_field = true:suggestion
+dotnet_style_qualification_for_property = true:suggestion
+dotnet_style_qualification_for_method = true:suggestion
+dotnet_style_qualification_for_event = true:suggestion
+
+# Language keywords vs BCL types preferences
+dotnet_style_predefined_type_for_locals_parameters_members = true:suggestion
+dotnet_style_predefined_type_for_member_access = true:suggestion
+
+# Parentheses preferences
+dotnet_style_parentheses_in_arithmetic_binary_operators = always_for_clarity:suggestion
+dotnet_style_parentheses_in_relational_binary_operators = always_for_clarity:suggestion
+dotnet_style_parentheses_in_other_binary_operators = always_for_clarity:suggestion
+dotnet_style_parentheses_in_other_operators = never_if_unnecessary:silent
+
+# Modifier preferences
+dotnet_style_require_accessibility_modifiers = for_non_interface_members:warning
+
+# Expression-level preferences
+dotnet_style_coalesce_expression = true:warning
+dotnet_style_collection_initializer = true:warning
+dotnet_style_explicit_tuple_names = true:suggestion
+dotnet_style_namespace_match_folder = true:suggestion
+dotnet_style_null_propagation = true:warning
+dotnet_style_object_initializer = true:warning
+dotnet_style_operator_placement_when_wrapping = beginning_of_line
+dotnet_style_prefer_auto_properties = true:suggestion
+dotnet_style_prefer_collection_expression = when_types_loosely_match:suggestion
+dotnet_style_prefer_compound_assignment = true:suggestion
+dotnet_style_prefer_conditional_expression_over_assignment = true:suggestion
+dotnet_style_prefer_conditional_expression_over_return = true:suggestion
+dotnet_style_prefer_foreach_explicit_cast_in_source = when_strongly_typed:suggestion
+dotnet_style_prefer_inferred_anonymous_type_member_names = true:suggestion
+dotnet_style_prefer_inferred_tuple_names = true:suggestion
+dotnet_style_prefer_is_null_check_over_reference_equality_method = true:warning
+dotnet_style_prefer_simplified_boolean_expressions = true:suggestion
+dotnet_style_prefer_simplified_interpolation = true:suggestion
+
+# Field preferences
+dotnet_style_readonly_field = true:warning
+
+# Parameter preferences
+dotnet_code_quality_unused_parameters = all:warning
+
+# Suppression preferences
+dotnet_remove_unnecessary_suppression_exclusions = none
+
+#### C# Coding Conventions ####
+[*.cs]
+
+# var preferences
+csharp_style_var_elsewhere = false:warning
+csharp_style_var_for_built_in_types = false:warning
+csharp_style_var_when_type_is_apparent = false:warning
+
+# Expression-bodied members
+csharp_style_expression_bodied_accessors = true:suggestion
+csharp_style_expression_bodied_constructors = true:suggestion
+csharp_style_expression_bodied_indexers = true:suggestion
+csharp_style_expression_bodied_lambdas = true:suggestion
+csharp_style_expression_bodied_local_functions = true:silent
+csharp_style_expression_bodied_methods = true:suggestion
+csharp_style_expression_bodied_operators = true:silent
+csharp_style_expression_bodied_properties = true:suggestion
+
+# Pattern matching preferences
+csharp_style_pattern_matching_over_as_with_null_check = true:warning
+csharp_style_pattern_matching_over_is_with_cast_check = true:warning
+csharp_style_prefer_extended_property_pattern = true:suggestion
+csharp_style_prefer_not_pattern = true:suggestion
+csharp_style_prefer_pattern_matching = true:suggestion
+csharp_style_prefer_switch_expression = true:warning
+
+# Null-checking preferences
+csharp_style_conditional_delegate_call = true:suggestion
+
+# Modifier preferences
+csharp_prefer_static_anonymous_function = true:warning
+csharp_prefer_static_local_function = true:warning
+csharp_preferred_modifier_order = public,private,protected,internal,file,const,static,extern,new,virtual,abstract,sealed,override,readonly,unsafe,required,volatile,async:suggestion
+csharp_style_prefer_readonly_struct = true:suggestion
+csharp_style_prefer_readonly_struct_member = true:suggestion
+
+# Code-block preferences
+csharp_prefer_braces = when_multiline:suggestion
+csharp_prefer_simple_using_statement = true:suggestion
+csharp_style_namespace_declarations = file_scoped:warning
+csharp_style_prefer_method_group_conversion = true:suggestion
+csharp_style_prefer_primary_constructors = true:suggestion
+csharp_style_prefer_top_level_statements = true:suggestion
+
+# Expression-level preferences
+csharp_prefer_simple_default_expression = true:suggestion
+csharp_style_deconstructed_variable_declaration = true:suggestion
+csharp_style_implicit_object_creation_when_type_is_apparent = true:suggestion
+csharp_style_inlined_variable_declaration = true:warning
+csharp_style_prefer_index_operator = true:suggestion
+csharp_style_prefer_local_over_anonymous_function = true:suggestion
+csharp_style_prefer_null_check_over_type_check = true:warning
+csharp_style_prefer_range_operator = true:suggestion
+csharp_style_prefer_tuple_swap = true:suggestion
+csharp_style_prefer_utf8_string_literals = true:suggestion
+csharp_style_throw_expression = true:suggestion
+csharp_style_unused_value_assignment_preference = discard_variable:warning
+csharp_style_unused_value_expression_statement_preference = discard_variable:warning
+
+# 'using' directive preferences
+csharp_using_directive_placement = outside_namespace:suggestion
+
+#### C# Formatting Rules ####
+
+# New-line preferences
+csharp_new_line_before_open_brace = all
+csharp_new_line_before_else = true
+csharp_new_line_before_catch = true
+csharp_new_line_before_finally = true
+csharp_new_line_before_members_in_object_initializers = true
+csharp_new_line_before_members_in_anonymous_types = true
+csharp_new_line_between_query_expression_clauses = true
+
+# Indentation preferences
+csharp_indent_case_contents = true
+csharp_indent_switch_labels = true
+csharp_indent_labels = one_less_than_current
+csharp_indent_block_contents = true
+csharp_indent_braces = false
+csharp_indent_case_contents_when_block = true
+
+# Space preferences
+csharp_space_after_cast = false
+csharp_space_after_keywords_in_control_flow_statements = true
+csharp_space_between_parentheses = false
+csharp_space_before_colon_in_inheritance_clause = true
+csharp_space_after_colon_in_inheritance_clause = true
+csharp_space_around_binary_operators = before_and_after
+csharp_space_between_method_declaration_parameter_list_parentheses = false
+csharp_space_between_method_declaration_empty_parameter_list_parentheses = false
+csharp_space_between_method_declaration_name_and_open_parenthesis = false
+csharp_space_between_method_call_parameter_list_parentheses = false
+csharp_space_between_method_call_empty_parameter_list_parentheses = false
+csharp_space_between_method_call_name_and_opening_parenthesis = false
+csharp_space_after_comma = true
+csharp_space_before_comma = false
+csharp_space_after_dot = false
+csharp_space_before_dot = false
+csharp_space_after_semicolon_in_for_statement = true
+csharp_space_before_semicolon_in_for_statement = false
+csharp_space_around_declaration_statements = false
+csharp_space_before_open_square_brackets = false
+csharp_space_between_empty_square_brackets = false
+csharp_space_between_square_brackets = false
+
+# Wrapping preferences
+csharp_preserve_single_line_statements = true
+csharp_preserve_single_line_blocks = true
+
+#### .NET Code Quality Rules (CA) ####
+[*.cs]
+
+# Bulk category severities
+dotnet_analyzer_diagnostic.category-Design.severity = warning
+dotnet_analyzer_diagnostic.category-Maintainability.severity = warning
+dotnet_analyzer_diagnostic.category-Naming.severity = warning
+dotnet_analyzer_diagnostic.category-Performance.severity = warning
+dotnet_analyzer_diagnostic.category-Reliability.severity = warning
+dotnet_analyzer_diagnostic.category-Security.severity = warning
+dotnet_analyzer_diagnostic.category-Usage.severity = warning
+
+# Noisy or inapplicable — suppress/downgrade
+dotnet_diagnostic.CA1031.severity = suggestion # Do not catch general exception types (intentional in CLI)
+dotnet_diagnostic.CA1303.severity = suggestion # Don't pass literals as localized params (no i18n needed)
+dotnet_diagnostic.CA1308.severity = suggestion # Normalize strings to uppercase (ToLowerInvariant is fine)
+
+#### IDE Code Style Rules ####
+[*.cs]
+
+dotnet_diagnostic.IDE0005.severity = warning # Remove unnecessary using directives
+dotnet_diagnostic.IDE0051.severity = warning # Remove unused private members
+dotnet_diagnostic.IDE0052.severity = warning # Remove unread private members
+dotnet_diagnostic.IDE0060.severity = warning # Remove unused parameter
+dotnet_diagnostic.IDE0130.severity = warning # Namespace does not match folder structure
+dotnet_diagnostic.IDE0290.severity = suggestion # Use primary constructors
+
+#### Roslynator Rules (RCS) ####
+[*.cs]
+
+# Code quality — elevate to warning
+dotnet_diagnostic.RCS1015.severity = warning # Use nameof operator
+dotnet_diagnostic.RCS1049.severity = warning # Simplify boolean comparison
+dotnet_diagnostic.RCS1058.severity = warning # Use compound assignment
+dotnet_diagnostic.RCS1068.severity = warning # Simplify logical negation
+dotnet_diagnostic.RCS1077.severity = warning # Optimize LINQ method call
+dotnet_diagnostic.RCS1097.severity = warning # Remove redundant ToString call
+dotnet_diagnostic.RCS1113.severity = warning # Use string.IsNullOrEmpty
+dotnet_diagnostic.RCS1128.severity = warning # Use coalesce expression
+dotnet_diagnostic.RCS1146.severity = warning # Use conditional access
+dotnet_diagnostic.RCS1151.severity = warning # Remove redundant cast
+dotnet_diagnostic.RCS1155.severity = warning # Use StringComparison when comparing strings
+dotnet_diagnostic.RCS1163.severity = warning # Unused parameter
+dotnet_diagnostic.RCS1169.severity = warning # Make field read-only
+dotnet_diagnostic.RCS1187.severity = warning # Use constant instead of field
+dotnet_diagnostic.RCS1197.severity = warning # Optimize StringBuilder.Append call
+dotnet_diagnostic.RCS1199.severity = warning # Unnecessary null check
+dotnet_diagnostic.RCS1202.severity = warning # Avoid NullReferenceException
+dotnet_diagnostic.RCS1213.severity = warning # Remove unused member declaration
+dotnet_diagnostic.RCS1225.severity = warning # Make class sealed
+dotnet_diagnostic.RCS1227.severity = warning # Validate arguments correctly
+dotnet_diagnostic.RCS1233.severity = warning # Use short-circuiting operator
+dotnet_diagnostic.RCS1235.severity = warning # Optimize method call
+dotnet_diagnostic.RCS1246.severity = warning # Use element access
+
+# Simplification — elevate to suggestion
+dotnet_diagnostic.RCS1033.severity = suggestion # Remove redundant boolean literal
+dotnet_diagnostic.RCS1084.severity = suggestion # Use coalesce expression instead of conditional
+dotnet_diagnostic.RCS1104.severity = suggestion # Simplify conditional expression
+dotnet_diagnostic.RCS1105.severity = suggestion # Unnecessary interpolation
+dotnet_diagnostic.RCS1143.severity = suggestion # Simplify coalesce expression
+dotnet_diagnostic.RCS1179.severity = suggestion # Unnecessary assignment
+dotnet_diagnostic.RCS1192.severity = suggestion # Unnecessary verbatim string literal
+dotnet_diagnostic.RCS1196.severity = suggestion # Call extension method as instance method
+dotnet_diagnostic.RCS1218.severity = suggestion # Simplify code branching
+dotnet_diagnostic.RCS1249.severity = suggestion # Unnecessary null-forgiving operator
+
+#### Naming styles ####
+[*.{cs,vb}]
+
+# Naming rules
+
+dotnet_naming_rule.types_and_namespaces_should_be_pascalcase.severity = suggestion
+dotnet_naming_rule.types_and_namespaces_should_be_pascalcase.symbols = types_and_namespaces
+dotnet_naming_rule.types_and_namespaces_should_be_pascalcase.style = pascalcase
+
+dotnet_naming_rule.interfaces_should_be_ipascalcase.severity = suggestion
+dotnet_naming_rule.interfaces_should_be_ipascalcase.symbols = interfaces
+dotnet_naming_rule.interfaces_should_be_ipascalcase.style = ipascalcase
+
+dotnet_naming_rule.type_parameters_should_be_tpascalcase.severity = suggestion
+dotnet_naming_rule.type_parameters_should_be_tpascalcase.symbols = type_parameters
+dotnet_naming_rule.type_parameters_should_be_tpascalcase.style = tpascalcase
+
+dotnet_naming_rule.methods_should_be_pascalcase.severity = suggestion
+dotnet_naming_rule.methods_should_be_pascalcase.symbols = methods
+dotnet_naming_rule.methods_should_be_pascalcase.style = pascalcase
+
+dotnet_naming_rule.properties_should_be_pascalcase.severity = suggestion
+dotnet_naming_rule.properties_should_be_pascalcase.symbols = properties
+dotnet_naming_rule.properties_should_be_pascalcase.style = pascalcase
+
+dotnet_naming_rule.events_should_be_pascalcase.severity = suggestion
+dotnet_naming_rule.events_should_be_pascalcase.symbols = events
+dotnet_naming_rule.events_should_be_pascalcase.style = pascalcase
+
+dotnet_naming_rule.local_variables_should_be_camelcase.severity = suggestion
+dotnet_naming_rule.local_variables_should_be_camelcase.symbols = local_variables
+dotnet_naming_rule.local_variables_should_be_camelcase.style = camelcase
+
+dotnet_naming_rule.local_constants_should_be_camelcase.severity = suggestion
+dotnet_naming_rule.local_constants_should_be_camelcase.symbols = local_constants
+dotnet_naming_rule.local_constants_should_be_camelcase.style = camelcase
+
+dotnet_naming_rule.parameters_should_be_camelcase.severity = suggestion
+dotnet_naming_rule.parameters_should_be_camelcase.symbols = parameters
+dotnet_naming_rule.parameters_should_be_camelcase.style = camelcase
+
+dotnet_naming_rule.public_fields_should_be_pascalcase.severity = suggestion
+dotnet_naming_rule.public_fields_should_be_pascalcase.symbols = public_fields
+dotnet_naming_rule.public_fields_should_be_pascalcase.style = pascalcase
+
+dotnet_naming_rule.private_fields_should_be__camelcase.severity = suggestion
+dotnet_naming_rule.private_fields_should_be__camelcase.symbols = private_fields
+dotnet_naming_rule.private_fields_should_be__camelcase.style = _camelcase
+
+dotnet_naming_rule.private_static_fields_should_be_s_camelcase.severity = suggestion
+dotnet_naming_rule.private_static_fields_should_be_s_camelcase.symbols = private_static_fields
+dotnet_naming_rule.private_static_fields_should_be_s_camelcase.style = s_camelcase
+
+dotnet_naming_rule.public_constant_fields_should_be_pascalcase.severity = suggestion
+dotnet_naming_rule.public_constant_fields_should_be_pascalcase.symbols = public_constant_fields
+dotnet_naming_rule.public_constant_fields_should_be_pascalcase.style = pascalcase
+
+dotnet_naming_rule.private_constant_fields_should_be_pascalcase.severity = suggestion
+dotnet_naming_rule.private_constant_fields_should_be_pascalcase.symbols = private_constant_fields
+dotnet_naming_rule.private_constant_fields_should_be_pascalcase.style = pascalcase
+
+dotnet_naming_rule.public_static_readonly_fields_should_be_pascalcase.severity = suggestion
+dotnet_naming_rule.public_static_readonly_fields_should_be_pascalcase.symbols = public_static_readonly_fields
+dotnet_naming_rule.public_static_readonly_fields_should_be_pascalcase.style = pascalcase
+
+dotnet_naming_rule.private_static_readonly_fields_should_be_pascalcase.severity = suggestion
+dotnet_naming_rule.private_static_readonly_fields_should_be_pascalcase.symbols = private_static_readonly_fields
+dotnet_naming_rule.private_static_readonly_fields_should_be_pascalcase.style = pascalcase
+
+dotnet_naming_rule.enums_should_be_pascalcase.severity = suggestion
+dotnet_naming_rule.enums_should_be_pascalcase.symbols = enums
+dotnet_naming_rule.enums_should_be_pascalcase.style = pascalcase
+
+dotnet_naming_rule.local_functions_should_be_pascalcase.severity = suggestion
+dotnet_naming_rule.local_functions_should_be_pascalcase.symbols = local_functions
+dotnet_naming_rule.local_functions_should_be_pascalcase.style = pascalcase
+
+dotnet_naming_rule.non_field_members_should_be_pascalcase.severity = suggestion
+dotnet_naming_rule.non_field_members_should_be_pascalcase.symbols = non_field_members
+dotnet_naming_rule.non_field_members_should_be_pascalcase.style = pascalcase
+
+# Symbol specifications
+
+dotnet_naming_symbols.interfaces.applicable_kinds = interface
+dotnet_naming_symbols.interfaces.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
+dotnet_naming_symbols.interfaces.required_modifiers =
+
+dotnet_naming_symbols.enums.applicable_kinds = enum
+dotnet_naming_symbols.enums.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
+dotnet_naming_symbols.enums.required_modifiers =
+
+dotnet_naming_symbols.events.applicable_kinds = event
+dotnet_naming_symbols.events.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
+dotnet_naming_symbols.events.required_modifiers =
+
+dotnet_naming_symbols.methods.applicable_kinds = method
+dotnet_naming_symbols.methods.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
+dotnet_naming_symbols.methods.required_modifiers =
+
+dotnet_naming_symbols.properties.applicable_kinds = property
+dotnet_naming_symbols.properties.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
+dotnet_naming_symbols.properties.required_modifiers =
+
+dotnet_naming_symbols.public_fields.applicable_kinds = field
+dotnet_naming_symbols.public_fields.applicable_accessibilities = public, internal
+dotnet_naming_symbols.public_fields.required_modifiers =
+
+dotnet_naming_symbols.private_fields.applicable_kinds = field
+dotnet_naming_symbols.private_fields.applicable_accessibilities = private, protected, protected_internal, private_protected
+dotnet_naming_symbols.private_fields.required_modifiers =
+
+dotnet_naming_symbols.private_static_fields.applicable_kinds = field
+dotnet_naming_symbols.private_static_fields.applicable_accessibilities = private, protected, protected_internal, private_protected
+dotnet_naming_symbols.private_static_fields.required_modifiers = static
+
+dotnet_naming_symbols.types_and_namespaces.applicable_kinds = namespace, class, struct, interface, enum
+dotnet_naming_symbols.types_and_namespaces.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
+dotnet_naming_symbols.types_and_namespaces.required_modifiers =
+
+dotnet_naming_symbols.non_field_members.applicable_kinds = property, event, method
+dotnet_naming_symbols.non_field_members.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
+dotnet_naming_symbols.non_field_members.required_modifiers =
+
+dotnet_naming_symbols.type_parameters.applicable_kinds = namespace
+dotnet_naming_symbols.type_parameters.applicable_accessibilities = *
+dotnet_naming_symbols.type_parameters.required_modifiers =
+
+dotnet_naming_symbols.private_constant_fields.applicable_kinds = field
+dotnet_naming_symbols.private_constant_fields.applicable_accessibilities = private, protected, protected_internal, private_protected
+dotnet_naming_symbols.private_constant_fields.required_modifiers = const
+
+dotnet_naming_symbols.local_variables.applicable_kinds = local
+dotnet_naming_symbols.local_variables.applicable_accessibilities = local
+dotnet_naming_symbols.local_variables.required_modifiers =
+
+dotnet_naming_symbols.local_constants.applicable_kinds = local
+dotnet_naming_symbols.local_constants.applicable_accessibilities = local
+dotnet_naming_symbols.local_constants.required_modifiers = const
+
+dotnet_naming_symbols.parameters.applicable_kinds = parameter
+dotnet_naming_symbols.parameters.applicable_accessibilities = *
+dotnet_naming_symbols.parameters.required_modifiers =
+
+dotnet_naming_symbols.public_constant_fields.applicable_kinds = field
+dotnet_naming_symbols.public_constant_fields.applicable_accessibilities = public, internal
+dotnet_naming_symbols.public_constant_fields.required_modifiers = const
+
+dotnet_naming_symbols.public_static_readonly_fields.applicable_kinds = field
+dotnet_naming_symbols.public_static_readonly_fields.applicable_accessibilities = public, internal
+dotnet_naming_symbols.public_static_readonly_fields.required_modifiers = readonly, static
+
+dotnet_naming_symbols.private_static_readonly_fields.applicable_kinds = field
+dotnet_naming_symbols.private_static_readonly_fields.applicable_accessibilities = private, protected, protected_internal, private_protected
+dotnet_naming_symbols.private_static_readonly_fields.required_modifiers = readonly, static
+
+dotnet_naming_symbols.local_functions.applicable_kinds = local_function
+dotnet_naming_symbols.local_functions.applicable_accessibilities = *
+dotnet_naming_symbols.local_functions.required_modifiers =
+
+# Naming styles
+
+dotnet_naming_style.pascalcase.required_prefix =
+dotnet_naming_style.pascalcase.required_suffix =
+dotnet_naming_style.pascalcase.word_separator =
+dotnet_naming_style.pascalcase.capitalization = pascal_case
+
+dotnet_naming_style.ipascalcase.required_prefix = I
+dotnet_naming_style.ipascalcase.required_suffix =
+dotnet_naming_style.ipascalcase.word_separator =
+dotnet_naming_style.ipascalcase.capitalization = pascal_case
+
+dotnet_naming_style.tpascalcase.required_prefix = T
+dotnet_naming_style.tpascalcase.required_suffix =
+dotnet_naming_style.tpascalcase.word_separator =
+dotnet_naming_style.tpascalcase.capitalization = pascal_case
+
+dotnet_naming_style._camelcase.required_prefix = _
+dotnet_naming_style._camelcase.required_suffix =
+dotnet_naming_style._camelcase.word_separator =
+dotnet_naming_style._camelcase.capitalization = camel_case
+
+dotnet_naming_style.camelcase.required_prefix =
+dotnet_naming_style.camelcase.required_suffix =
+dotnet_naming_style.camelcase.word_separator =
+dotnet_naming_style.camelcase.capitalization = camel_case
+
+dotnet_naming_style.s_camelcase.required_prefix = s_
+dotnet_naming_style.s_camelcase.required_suffix =
+dotnet_naming_style.s_camelcase.word_separator =
+dotnet_naming_style.s_camelcase.capitalization = camel_case
+
diff --git a/CodeWalker.Cli/.gitattributes b/CodeWalker.Cli/.gitattributes
new file mode 100644
index 000000000..e84981f3f
--- /dev/null
+++ b/CodeWalker.Cli/.gitattributes
@@ -0,0 +1,6 @@
+# This project's .editorconfig sets end_of_line = crlf, and EnforceCodeStyleInBuild
+# means dotnet format checks the file on disk. The repository root normalizes to LF
+# with `* text=auto`, so on a non-Windows checkout the two disagree and every file
+# reports ENDOFLINE. Forcing crlf on checkout keeps them in step on every platform;
+# blobs are still stored normalized.
+* text=auto eol=crlf
diff --git a/CodeWalker.Cli/CodeWalker.Cli.Tests.csproj b/CodeWalker.Cli/CodeWalker.Cli.Tests.csproj
new file mode 100644
index 000000000..f18c1d96a
--- /dev/null
+++ b/CodeWalker.Cli/CodeWalker.Cli.Tests.csproj
@@ -0,0 +1,34 @@
+
+
+
+ CodeWalker.Cli
+ true
+ $(DefineConstants);TESTING
+
+ $(NoWarn);CA1707;CS1591
+
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
diff --git a/CodeWalker.Cli/CodeWalker.Cli.csproj b/CodeWalker.Cli/CodeWalker.Cli.csproj
new file mode 100644
index 000000000..f9f30948b
--- /dev/null
+++ b/CodeWalker.Cli/CodeWalker.Cli.csproj
@@ -0,0 +1,33 @@
+
+
+
+ dexyfex
+ dexyfex software
+ dexyfex
+ Command-line tool for extracting GTA V RPF archives
+
+
+
+
+
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+ latest-all
+ true
+ true
+ true
+
+
+
diff --git a/CodeWalker.Cli/Compiler.cs b/CodeWalker.Cli/Compiler.cs
new file mode 100644
index 000000000..211c8c004
--- /dev/null
+++ b/CodeWalker.Cli/Compiler.cs
@@ -0,0 +1,49 @@
+#pragma warning disable IDE0130 // Namespace does not match folder structure
+
+#if !NET5_0_OR_GREATER
+using System.ComponentModel;
+#endif
+
+namespace System.Runtime.CompilerServices
+{
+#if !NET5_0_OR_GREATER
+
+ [EditorBrowsable(EditorBrowsableState.Never)]
+ internal static class IsExternalInit { }
+
+#endif // !NET5_0_OR_GREATER
+
+#if !NET7_0_OR_GREATER
+
+ [AttributeUsage(
+ AttributeTargets.Class
+ | AttributeTargets.Struct
+ | AttributeTargets.Field
+ | AttributeTargets.Property,
+ AllowMultiple = false,
+ Inherited = false
+ )]
+ internal sealed class RequiredMemberAttribute : Attribute { }
+
+ [AttributeUsage(AttributeTargets.All, AllowMultiple = true, Inherited = false)]
+ internal sealed class CompilerFeatureRequiredAttribute(string featureName) : Attribute
+ {
+ public string FeatureName { get; } = featureName;
+ public bool IsOptional { get; init; }
+
+ public const string RefStructs = nameof(RefStructs);
+ public const string RequiredMembers = nameof(RequiredMembers);
+ }
+
+#endif // !NET7_0_OR_GREATER
+}
+
+namespace System.Diagnostics.CodeAnalysis
+{
+#if !NET7_0_OR_GREATER
+ [AttributeUsage(AttributeTargets.Constructor, AllowMultiple = false, Inherited = false)]
+ internal sealed class SetsRequiredMembersAttribute : Attribute { }
+#endif
+}
+
+#pragma warning restore IDE0130 // Namespace does not match folder structure
diff --git a/CodeWalker.Cli/Directory.Build.props b/CodeWalker.Cli/Directory.Build.props
new file mode 100644
index 000000000..f3a389392
--- /dev/null
+++ b/CodeWalker.Cli/Directory.Build.props
@@ -0,0 +1,50 @@
+
+
+
+ Exe
+ net48;net8.0;net10.0
+ latest
+ enable
+ disable
+
+ obj/$(MSBuildProjectName)/
+ bin/$(MSBuildProjectName)/
+
+ $(DefaultItemExcludes);obj/**;bin/**
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ $(WarningsAsErrors);CS8509
+
+
+
diff --git a/CodeWalker.Cli/Handlers/DiffHandler.cs b/CodeWalker.Cli/Handlers/DiffHandler.cs
new file mode 100644
index 000000000..0591bb8d3
--- /dev/null
+++ b/CodeWalker.Cli/Handlers/DiffHandler.cs
@@ -0,0 +1,500 @@
+using System;
+using System.Collections.Generic;
+using System.CommandLine;
+using System.IO;
+using System.Linq;
+using System.Security.Cryptography;
+using System.Text.Json;
+using System.Threading;
+using System.Threading.Tasks;
+
+using CodeWalker.Cli.Helpers;
+using CodeWalker.GameFiles;
+
+namespace CodeWalker.Cli.Handlers;
+
+internal sealed record DiffOptions
+{
+ public required string LeftPath { get; init; }
+ public required string RightPath { get; init; }
+ public required string LeftExePath { get; init; }
+ public required string RightExePath { get; init; }
+ public required bool LeftGen9 { get; init; }
+ public required bool RightGen9 { get; init; }
+ public required bool Recursive { get; init; }
+ public required bool Progress { get; init; }
+ public required bool Verbose { get; init; }
+ public required bool Json { get; init; }
+ public required SizeFormat SizeFormat { get; init; }
+ public required int Threads { get; init; }
+}
+
+internal static class DiffHandler
+{
+ public static Command CreateCommand(CancellationToken cancellationToken = default)
+ {
+ Option verboseOpt = CliOptions.Verbose();
+ Option jsonOpt = CliOptions.Json();
+ Option siOpt = CliOptions.Si();
+ Option threadsOpt = CliOptions.Threads();
+
+ Option leftOption = new("--left", "-l")
+ {
+ Description = "First RPF archive to compare",
+ Required = true,
+ };
+
+ Option rightOption = new("--right", "-r")
+ {
+ Description = "Second RPF archive to compare",
+ Required = true,
+ };
+
+ Option leftExeOption = new("--left-exe", "-le")
+ {
+ Description = "Path to the GTA V installation for the left archive",
+ Required = true,
+ };
+
+ Option rightExeOption = new("--right-exe", "-re")
+ {
+ Description = "Path to the GTA V installation for the right archive",
+ Required = true,
+ };
+
+ Option leftGen9Option = new("--left-gen9", "-lg")
+ {
+ Description = "Use GTA V Enhanced (Gen9) mode for the left archive",
+ };
+
+ Option rightGen9Option = new("--right-gen9", "-rg")
+ {
+ Description = "Use GTA V Enhanced (Gen9) mode for the right archive",
+ };
+
+ Option recursiveOption = new("--recursive", "-R")
+ {
+ Description = "Include nested RPFs in comparison",
+ };
+
+ Option progressOption = CliOptions.Progress();
+
+ Command command = new("diff", "Compare two RPF archives")
+ {
+ leftOption,
+ rightOption,
+ leftExeOption,
+ rightExeOption,
+ leftGen9Option,
+ rightGen9Option,
+ recursiveOption,
+
+ progressOption,
+ verboseOpt,
+ jsonOpt,
+ siOpt,
+ threadsOpt
+ };
+
+ command.Aliases.Add("d");
+
+ command.SetAction(parseResult =>
+ {
+ DiffOptions options = new()
+ {
+ LeftPath = parseResult.GetRequiredValue(leftOption).FullName,
+ RightPath = parseResult.GetRequiredValue(rightOption).FullName,
+ LeftExePath = parseResult.GetRequiredValue(leftExeOption).FullName,
+ RightExePath = parseResult.GetRequiredValue(rightExeOption).FullName,
+ LeftGen9 = parseResult.GetValue(leftGen9Option),
+ RightGen9 = parseResult.GetValue(rightGen9Option),
+ Recursive = parseResult.GetValue(recursiveOption),
+ Progress = parseResult.GetValue(progressOption),
+ Verbose = parseResult.GetValue(verboseOpt),
+ Json = parseResult.GetValue(jsonOpt),
+ SizeFormat = parseResult.GetValue(siOpt) ? SizeFormat.SI : SizeFormat.IEC,
+ Threads = parseResult.GetValue(threadsOpt),
+ };
+ return Execute(options, cancellationToken);
+ });
+
+ return command;
+ }
+
+ public static int Execute(DiffOptions options, CancellationToken cancellationToken = default)
+ {
+ string? leftError = RpfHelper.ValidateInputs(
+ options.LeftPath,
+ options.LeftExePath,
+ options.LeftGen9
+ );
+ if (leftError != null)
+ {
+ return Output.ReportError(
+ leftError,
+ options.Json,
+ ErrorResult([], options)
+ );
+ }
+
+ string? rightError = RpfHelper.ValidateInputs(
+ options.RightPath,
+ options.RightExePath,
+ options.RightGen9
+ );
+ if (rightError != null)
+ {
+ return Output.ReportError(
+ rightError,
+ options.Json,
+ ErrorResult([], options)
+ );
+ }
+
+ List errorMessages = [];
+ try
+ {
+ // Encryption keys are process-wide, so each archive is opened and read while its
+ // own installation's keys are loaded. Metadata comes first for both sides; only the
+ // entries that could still turn out identical are extracted and hashed.
+ if (!options.Json)
+ Console.Error.WriteLine("Loading left encryption keys...");
+ RpfHelper.LoadKeys(options.LeftExePath, options.LeftGen9);
+ RpfFile leftRpf = RpfHelper.OpenRpf(
+ options.LeftPath,
+ options.Verbose,
+ options.Json,
+ errorMessages
+ );
+ List<(RpfFile rpf, RpfFileEntry entry)> leftFiles =
+ RpfHelper.CollectFiles(leftRpf, null, options.Recursive);
+ Dictionary left = BuildMetadata(leftFiles, leftRpf.Root.Path);
+
+ if (!options.Json)
+ Console.Error.WriteLine("Loading right encryption keys...");
+ RpfHelper.LoadKeys(options.RightExePath, options.RightGen9);
+ RpfFile rightRpf = RpfHelper.OpenRpf(
+ options.RightPath,
+ options.Verbose,
+ options.Json,
+ errorMessages
+ );
+ List<(RpfFile rpf, RpfFileEntry entry)> rightFiles =
+ RpfHelper.CollectFiles(rightRpf, null, options.Recursive);
+ Dictionary right = BuildMetadata(rightFiles, rightRpf.Root.Path);
+
+ HashSet candidates = FindHashCandidates(left, right);
+
+ if (candidates.Count > 0)
+ {
+ HashEntries(rightFiles, right, candidates, rightRpf.Root.Path, "right", options, errorMessages, cancellationToken);
+
+ RpfHelper.LoadKeys(options.LeftExePath, options.LeftGen9);
+ HashEntries(leftFiles, left, candidates, leftRpf.Root.Path, "left", options, errorMessages, cancellationToken);
+ }
+
+ Json.DiffResult result = CompareSides(left, right, errorMessages, options);
+
+ if (options.Json)
+ PrintJsonDiff(result);
+ else
+ PrintDiff(result, options);
+
+ return errorMessages.Count > 0 ? 1 : 0;
+ }
+ catch (OperationCanceledException) { throw; }
+ catch (Exception ex)
+ {
+ return Output.ReportError(
+ ex.Message,
+ options.Json,
+ ErrorResult([.. errorMessages], options),
+ options.Verbose ? ex.StackTrace : null
+ );
+ }
+ }
+
+ internal static Json.DiffResult ErrorResult(string[] errorMessages, DiffOptions options) =>
+ new()
+ {
+ Success = false,
+ LeftRpf = options.LeftPath,
+ RightRpf = options.RightPath,
+ Added = [],
+ Removed = [],
+ Modified = [],
+ Unchanged = [],
+ Summary = new Json.DiffSummary
+ {
+ AddedCount = 0,
+ RemovedCount = 0,
+ ModifiedCount = 0,
+ UnchangedCount = 0,
+ },
+ ErrorMessages = errorMessages,
+ };
+
+ ///
+ /// One archive entry as seen from a single side, with its content hash filled in
+ /// only for entries that need a byte-level comparison.
+ ///
+ internal sealed record SideEntry
+ {
+ public required string Name { get; init; }
+ public required long Size { get; init; }
+ public required string Type { get; init; }
+ public string? Hash { get; init; }
+ }
+
+ ///
+ /// Strips the containing archive's own name from an entry path, so two archives compare
+ /// by their contents rather than by what the files on disk happen to be called.
+ ///
+ internal static string RelativeKey(string entryPath, string rootPath)
+ {
+ if (rootPath.Length == 0 || !entryPath.StartsWith(rootPath, StringComparison.OrdinalIgnoreCase))
+ return entryPath;
+
+ string rest = entryPath[rootPath.Length..];
+ return rest.StartsWith('\\') ? rest[1..] : rest;
+ }
+
+ internal static Dictionary BuildMetadata(
+ List<(RpfFile rpf, RpfFileEntry entry)> files,
+ string rootPath
+ ) =>
+ files.ToDictionary(
+ f => RelativeKey(f.entry.Path, rootPath),
+ f => new SideEntry
+ {
+ Name = f.entry.Name,
+ Size = f.entry.GetFileSize(),
+ Type = RpfHelper.GetFileType(f.entry),
+ }
+ );
+
+ ///
+ /// Paths present on both sides with matching size and type. Anything else is already
+ /// decided by its metadata, so its content never has to be read.
+ ///
+ internal static HashSet FindHashCandidates(
+ IReadOnlyDictionary left,
+ IReadOnlyDictionary right
+ )
+ {
+ HashSet candidates = [];
+ foreach (KeyValuePair kvp in left)
+ {
+ if (right.TryGetValue(kvp.Key, out SideEntry? other)
+ && other.Size == kvp.Value.Size
+ && other.Type == kvp.Value.Type)
+ {
+ _ = candidates.Add(kvp.Key);
+ }
+ }
+ return candidates;
+ }
+
+ private static void HashEntries(
+ List<(RpfFile rpf, RpfFileEntry entry)> files,
+ Dictionary side,
+ HashSet candidates,
+ string rootPath,
+ string label,
+ DiffOptions options,
+ List errorMessages,
+ CancellationToken cancellationToken
+ )
+ {
+ (string key, RpfFile rpf, RpfFileEntry entry)[] targets =
+ [
+ .. files
+ .Select(f => (key: RelativeKey(f.entry.Path, rootPath), f.rpf, f.entry))
+ .Where(f => candidates.Contains(f.key)),
+ ];
+
+ string?[] hashes = new string?[targets.Length];
+ string?[] failures = new string?[targets.Length];
+
+ using (ProgressBar progress = new(targets.Length, options.Progress && !options.Json))
+ {
+ _ = Parallel.For(
+ 0,
+ targets.Length,
+ new ParallelOptions { MaxDegreeOfParallelism = options.Threads, CancellationToken = cancellationToken },
+ i =>
+ {
+ (_, RpfFile rpf, RpfFileEntry entry) = targets[i];
+ byte[]? data = rpf.ExtractFile(entry);
+ if (data == null)
+ failures[i] = $"Failed to extract {label} entry: {entry.Path}";
+ else
+ hashes[i] = ComputeHash(data);
+
+ progress.Increment(entry.Path);
+ }
+ );
+ }
+
+ for (int i = 0; i < targets.Length; i++)
+ {
+ if (failures[i] != null)
+ {
+ errorMessages.Add(failures[i]!);
+ continue;
+ }
+ string key = targets[i].key;
+ side[key] = side[key] with { Hash = hashes[i] };
+ }
+ }
+
+ private static string ComputeHash(byte[] data)
+ {
+#if NET5_0_OR_GREATER
+ return Convert.ToHexString(SHA256.HashData(data));
+#else
+ using SHA256 sha = SHA256.Create();
+ return BitConverter.ToString(sha.ComputeHash(data)).Replace("-", string.Empty);
+#endif
+ }
+
+ internal static Json.DiffResult CompareSides(
+ IReadOnlyDictionary left,
+ IReadOnlyDictionary right,
+ List errorMessages,
+ DiffOptions options
+ )
+ {
+ SizeFormat sizeFormat = options.SizeFormat;
+
+ Json.DiffEntry Single(string path, SideEntry entry) =>
+ new()
+ {
+ Path = path,
+ Name = entry.Name,
+ Type = entry.Type,
+ Size = entry.Size,
+ SizeFormatted = sizeFormat.ToFormattedString(entry.Size),
+ };
+
+ List added = [];
+ List removed = [];
+ List modified = [];
+ List unchanged = [];
+
+ foreach (KeyValuePair kvp in left)
+ {
+ if (!right.TryGetValue(kvp.Key, out SideEntry? other))
+ {
+ removed.Add(Single(kvp.Key, kvp.Value));
+ continue;
+ }
+
+ // A missing hash means the entry was never a candidate, or extraction failed.
+ // Either way it cannot be proven identical.
+ bool same = kvp.Value.Hash != null
+ && other.Hash != null
+ && string.Equals(kvp.Value.Hash, other.Hash, StringComparison.Ordinal);
+
+ if (same)
+ {
+ unchanged.Add(Single(kvp.Key, kvp.Value));
+ }
+ else
+ {
+ modified.Add(
+ new Json.DiffEntry
+ {
+ Path = kvp.Key,
+ Name = kvp.Value.Name,
+ Type = kvp.Value.Type,
+ LeftSize = kvp.Value.Size,
+ LeftSizeFormatted = sizeFormat.ToFormattedString(kvp.Value.Size),
+ RightSize = other.Size,
+ RightSizeFormatted = sizeFormat.ToFormattedString(other.Size),
+ }
+ );
+ }
+ }
+
+ added.AddRange(
+ right.Where(kvp => !left.ContainsKey(kvp.Key)).Select(kvp => Single(kvp.Key, kvp.Value))
+ );
+
+ added.Sort((a, b) => string.CompareOrdinal(a.Path, b.Path));
+ removed.Sort((a, b) => string.CompareOrdinal(a.Path, b.Path));
+ modified.Sort((a, b) => string.CompareOrdinal(a.Path, b.Path));
+ unchanged.Sort((a, b) => string.CompareOrdinal(a.Path, b.Path));
+
+ return new Json.DiffResult
+ {
+ Success = errorMessages.Count == 0,
+ LeftRpf = options.LeftPath,
+ RightRpf = options.RightPath,
+ Added = [.. added],
+ Removed = [.. removed],
+ Modified = [.. modified],
+ Unchanged = [.. unchanged],
+ Summary = new Json.DiffSummary
+ {
+ AddedCount = added.Count,
+ RemovedCount = removed.Count,
+ ModifiedCount = modified.Count,
+ UnchangedCount = unchanged.Count,
+ },
+ ErrorMessages = [.. errorMessages],
+ };
+ }
+
+ internal static void PrintJsonDiff(Json.DiffResult result) =>
+ Console.WriteLine(JsonSerializer.Serialize(result, Output.JsonSerializerOptions));
+
+ internal static void PrintDiff(Json.DiffResult result, DiffOptions options)
+ {
+ if (result.Added.Count > 0)
+ {
+ Console.WriteLine($"Added ({result.Added.Count}):");
+ foreach (Json.DiffEntry entry in result.Added)
+ {
+ Console.WriteLine($" + {entry.Path}");
+ }
+ Console.WriteLine();
+ }
+
+ if (result.Removed.Count > 0)
+ {
+ Console.WriteLine($"Removed ({result.Removed.Count}):");
+ foreach (Json.DiffEntry entry in result.Removed)
+ {
+ Console.WriteLine($" - {entry.Path}");
+ }
+ Console.WriteLine();
+ }
+
+ if (result.Modified.Count > 0)
+ {
+ Console.WriteLine($"Modified ({result.Modified.Count}):");
+ foreach (Json.DiffEntry entry in result.Modified)
+ {
+ Console.WriteLine(
+ $" ~ {entry.Path} ({entry.LeftSizeFormatted} -> {entry.RightSizeFormatted})"
+ );
+ }
+ Console.WriteLine();
+ }
+
+ if (options.Verbose && result.Unchanged.Count > 0)
+ {
+ Console.WriteLine($"Unchanged ({result.Unchanged.Count}):");
+ foreach (Json.DiffEntry entry in result.Unchanged)
+ {
+ Console.WriteLine($" = {entry.Path}");
+ }
+ Console.WriteLine();
+ }
+
+ Console.Error.WriteLine(
+ $"Summary: {result.Summary.AddedCount} added, {result.Summary.RemovedCount} removed, {result.Summary.ModifiedCount} modified, {result.Summary.UnchangedCount} unchanged"
+ );
+ }
+}
diff --git a/CodeWalker.Cli/Handlers/ExportAudioHandler.cs b/CodeWalker.Cli/Handlers/ExportAudioHandler.cs
new file mode 100644
index 000000000..743c8f63b
--- /dev/null
+++ b/CodeWalker.Cli/Handlers/ExportAudioHandler.cs
@@ -0,0 +1,137 @@
+using System.CommandLine;
+using System.IO;
+using System.Threading;
+
+using CodeWalker.Cli.Helpers;
+using CodeWalker.GameFiles;
+
+namespace CodeWalker.Cli.Handlers;
+
+internal static class ExportAudioHandler
+{
+ private static readonly string[] DefaultFilters = ["*.awc"];
+
+ public static Command CreateCommand(CancellationToken cancellationToken = default)
+ {
+ Option rpfOpt = CliOptions.Rpf();
+ Option exeOpt = CliOptions.Exe();
+ Option gen9Opt = CliOptions.Gen9();
+ Option filterOpt = CliOptions.Filter();
+ Option recursiveOpt = CliOptions.Recursive();
+ Option verboseOpt = CliOptions.Verbose();
+ Option jsonOpt = CliOptions.Json();
+ Option siOpt = CliOptions.Si();
+ Option threadsOpt = CliOptions.Threads();
+ Option outputOpt = CliOptions.OutputDir();
+ Option dryRunOpt = CliOptions.DryRun();
+ Option noOverwriteOpt = CliOptions.NoOverwrite();
+ Option progressOpt = CliOptions.Progress();
+
+ Command command = new("audio", "Export .awc audio containers to WAV/MIDI files")
+ {
+ rpfOpt, exeOpt, gen9Opt, filterOpt, recursiveOpt,
+ verboseOpt, jsonOpt, siOpt, threadsOpt,
+ outputOpt, dryRunOpt, noOverwriteOpt, progressOpt,
+ };
+ command.Aliases.Add("a");
+ command.Aliases.Add("awc");
+
+ command.SetAction(parseResult =>
+ {
+ string[] filters = Filter.Normalize(parseResult.GetValue(filterOpt));
+ ExportOptions options = new()
+ {
+ RpfPath = parseResult.GetValue(rpfOpt)?.FullName ?? "",
+ ExePath = parseResult.GetRequiredValue(exeOpt).FullName,
+ Gen9 = parseResult.GetValue(gen9Opt),
+ Filters = filters.Length == 0 ? DefaultFilters : filters,
+ Verbose = parseResult.GetValue(verboseOpt),
+ Json = parseResult.GetValue(jsonOpt),
+ Recursive = parseResult.GetValue(recursiveOpt),
+ Threads = parseResult.GetValue(threadsOpt),
+ SizeFormat = parseResult.GetValue(siOpt) ? SizeFormat.SI : SizeFormat.IEC,
+ OutputPath = parseResult.GetValue(outputOpt)?.FullName ?? Directory.GetCurrentDirectory(),
+ DryRun = parseResult.GetValue(dryRunOpt),
+ NoOverwrite = parseResult.GetValue(noOverwriteOpt),
+ Progress = parseResult.GetValue(progressOpt),
+ };
+ return ExportPipeline.Execute(options, "wav", "Audio", ProcessFile, cancellationToken);
+ });
+
+ return command;
+ }
+
+ private static (Json.ExportFileEntry entry, string? _) ProcessFile(
+ RpfFileEntry fileEntry,
+ byte[] data,
+ string fileOutputDir,
+ bool noOverwrite
+ )
+ {
+ AwcFile awc = RpfFile.GetFile(fileEntry, data);
+ if (awc?.Streams == null || awc.Streams.Length == 0)
+ {
+ return (
+ new Json.ExportFileEntry
+ {
+ Path = fileEntry.Path,
+ Name = fileEntry.Name,
+ OutputFiles = 0,
+ Status = "unsupported",
+ },
+ null
+ );
+ }
+
+ bool dirCreated = false;
+ int streamCount = 0;
+ foreach (AwcStream stream in awc.Streams)
+ {
+ // Hash 0 indicates a metadata-only stream with no playable audio data
+ if (stream.Hash == 0)
+ continue;
+
+ string streamName = stream.Name;
+
+ if (stream.MidiChunk?.Data != null)
+ {
+ string midiPath = Path.Combine(fileOutputDir, streamName + ".midi");
+ if (noOverwrite && File.Exists(midiPath))
+ continue;
+ if (!dirCreated)
+ {
+ _ = Directory.CreateDirectory(fileOutputDir);
+ dirCreated = true;
+ }
+ File.WriteAllBytes(midiPath, stream.MidiChunk.Data);
+ streamCount++;
+ }
+ else
+ {
+ byte[] wav = stream.GetWavFile();
+ string wavPath = Path.Combine(fileOutputDir, streamName + ".wav");
+ if (noOverwrite && File.Exists(wavPath))
+ continue;
+ if (!dirCreated)
+ {
+ _ = Directory.CreateDirectory(fileOutputDir);
+ dirCreated = true;
+ }
+ File.WriteAllBytes(wavPath, wav);
+ streamCount++;
+ }
+ }
+
+ return (
+ new Json.ExportFileEntry
+ {
+ Path = fileEntry.Path,
+ Name = fileEntry.Name,
+ OutputPath = streamCount > 0 ? fileOutputDir : null,
+ OutputFiles = streamCount,
+ Status = streamCount > 0 ? "exported" : "skipped",
+ },
+ null
+ );
+ }
+}
diff --git a/CodeWalker.Cli/Handlers/ExportHandler.cs b/CodeWalker.Cli/Handlers/ExportHandler.cs
new file mode 100644
index 000000000..e20110ef4
--- /dev/null
+++ b/CodeWalker.Cli/Handlers/ExportHandler.cs
@@ -0,0 +1,24 @@
+using System.CommandLine;
+using System.Threading;
+
+namespace CodeWalker.Cli.Handlers;
+
+internal static class ExportHandler
+{
+ public static Command CreateCommand(CancellationToken cancellationToken = default)
+ {
+ Command command = new(
+ "export",
+ "Export game files to external formats (XML, DDS, WAV, text)"
+ )
+ {
+ ExportXmlHandler.CreateCommand(cancellationToken),
+ ExportTexturesHandler.CreateCommand(cancellationToken),
+ ExportAudioHandler.CreateCommand(cancellationToken),
+ ExportTextHandler.CreateCommand(cancellationToken),
+ };
+ command.Aliases.Add("e");
+
+ return command;
+ }
+}
diff --git a/CodeWalker.Cli/Handlers/ExportTextHandler.cs b/CodeWalker.Cli/Handlers/ExportTextHandler.cs
new file mode 100644
index 000000000..2a6f27f0e
--- /dev/null
+++ b/CodeWalker.Cli/Handlers/ExportTextHandler.cs
@@ -0,0 +1,139 @@
+using System.CommandLine;
+using System.IO;
+using System.Text;
+using System.Threading;
+
+using CodeWalker.Cli.Helpers;
+using CodeWalker.GameFiles;
+
+namespace CodeWalker.Cli.Handlers;
+
+internal static class ExportTextHandler
+{
+ private static readonly string[] DefaultFilters = ["*.gxt2"];
+
+ public static Command CreateCommand(CancellationToken cancellationToken = default)
+ {
+ Option rpfOpt = CliOptions.Rpf();
+ Option exeOpt = CliOptions.Exe();
+ Option gen9Opt = CliOptions.Gen9();
+ Option filterOpt = CliOptions.Filter();
+ Option recursiveOpt = CliOptions.Recursive();
+ Option verboseOpt = CliOptions.Verbose();
+ Option jsonOpt = CliOptions.Json();
+ Option siOpt = CliOptions.Si();
+ Option threadsOpt = CliOptions.Threads();
+ Option outputOpt = CliOptions.OutputDir();
+ Option dryRunOpt = CliOptions.DryRun();
+ Option noOverwriteOpt = CliOptions.NoOverwrite();
+ Option progressOpt = CliOptions.Progress();
+
+ Command command = new("text", "Export .gxt2 localization files to plain text")
+ {
+ rpfOpt, exeOpt, gen9Opt, filterOpt, recursiveOpt,
+ verboseOpt, jsonOpt, siOpt, threadsOpt,
+ outputOpt, dryRunOpt, noOverwriteOpt, progressOpt,
+ };
+ command.Aliases.Add("g");
+ command.Aliases.Add("gxt2");
+
+ command.SetAction(parseResult =>
+ {
+ string[] filters = Filter.Normalize(parseResult.GetValue(filterOpt));
+ ExportOptions options = new()
+ {
+ RpfPath = parseResult.GetValue(rpfOpt)?.FullName ?? "",
+ ExePath = parseResult.GetRequiredValue(exeOpt).FullName,
+ Gen9 = parseResult.GetValue(gen9Opt),
+ Filters = filters.Length == 0 ? DefaultFilters : filters,
+ Verbose = parseResult.GetValue(verboseOpt),
+ Json = parseResult.GetValue(jsonOpt),
+ Recursive = parseResult.GetValue(recursiveOpt),
+ Threads = parseResult.GetValue(threadsOpt),
+ SizeFormat = parseResult.GetValue(siOpt) ? SizeFormat.SI : SizeFormat.IEC,
+ OutputPath = parseResult.GetValue(outputOpt)?.FullName ?? Directory.GetCurrentDirectory(),
+ DryRun = parseResult.GetValue(dryRunOpt),
+ NoOverwrite = parseResult.GetValue(noOverwriteOpt),
+ Progress = parseResult.GetValue(progressOpt),
+ };
+ return ExportPipeline.Execute(options, "txt", "Text", ProcessFile, cancellationToken);
+ });
+
+ return command;
+ }
+
+ private static (Json.ExportFileEntry entry, string? _) ProcessFile(
+ RpfFileEntry fileEntry,
+ byte[] data,
+ string fileOutputDir,
+ bool noOverwrite
+ )
+ {
+ Gxt2File gxt = RpfFile.GetFile(fileEntry, data);
+ if (gxt == null)
+ {
+ return (
+ new Json.ExportFileEntry
+ {
+ Path = fileEntry.Path,
+ Name = fileEntry.Name,
+ OutputFiles = 0,
+ Status = "unsupported",
+ },
+ null
+ );
+ }
+
+ string text = gxt.ToText();
+
+ if (string.IsNullOrEmpty(text))
+ {
+ return (
+ new Json.ExportFileEntry
+ {
+ Path = fileEntry.Path,
+ Name = fileEntry.Name,
+ OutputFiles = 0,
+ Status = "unsupported",
+ },
+ null
+ );
+ }
+
+ string outputFileName = Path.GetFileNameWithoutExtension(fileEntry.Name) + ".txt";
+ string outputPath = Path.Combine(fileOutputDir, outputFileName);
+
+ if (noOverwrite && File.Exists(outputPath))
+ {
+ return (
+ new Json.ExportFileEntry
+ {
+ Path = fileEntry.Path,
+ Name = fileEntry.Name,
+ OutputFiles = 0,
+ Status = "skipped",
+ },
+ null
+ );
+ }
+
+ if (!Directory.Exists(fileOutputDir))
+ {
+ _ = Directory.CreateDirectory(fileOutputDir);
+ }
+
+ File.WriteAllText(outputPath, text, Encoding.UTF8);
+
+ return (
+ new Json.ExportFileEntry
+ {
+ Path = fileEntry.Path,
+ Name = fileEntry.Name,
+ OutputPath = outputPath,
+ OutputFiles = 1,
+ Status = "exported",
+ },
+ null
+ );
+ }
+}
diff --git a/CodeWalker.Cli/Handlers/ExportTexturesHandler.cs b/CodeWalker.Cli/Handlers/ExportTexturesHandler.cs
new file mode 100644
index 000000000..45a6debc6
--- /dev/null
+++ b/CodeWalker.Cli/Handlers/ExportTexturesHandler.cs
@@ -0,0 +1,123 @@
+using System.CommandLine;
+using System.IO;
+using System.Threading;
+
+using CodeWalker.Cli.Helpers;
+using CodeWalker.GameFiles;
+using CodeWalker.Utils;
+
+namespace CodeWalker.Cli.Handlers;
+
+internal static class ExportTexturesHandler
+{
+ private static readonly string[] DefaultFilters = ["*.ytd"];
+
+ public static Command CreateCommand(CancellationToken cancellationToken = default)
+ {
+ Option rpfOpt = CliOptions.Rpf();
+ Option exeOpt = CliOptions.Exe();
+ Option gen9Opt = CliOptions.Gen9();
+ Option filterOpt = CliOptions.Filter();
+ Option recursiveOpt = CliOptions.Recursive();
+ Option verboseOpt = CliOptions.Verbose();
+ Option jsonOpt = CliOptions.Json();
+ Option siOpt = CliOptions.Si();
+ Option threadsOpt = CliOptions.Threads();
+ Option outputOpt = CliOptions.OutputDir();
+ Option dryRunOpt = CliOptions.DryRun();
+ Option noOverwriteOpt = CliOptions.NoOverwrite();
+ Option progressOpt = CliOptions.Progress();
+
+ Command command = new("textures", "Export .ytd texture dictionaries to DDS files")
+ {
+ rpfOpt, exeOpt, gen9Opt, filterOpt, recursiveOpt,
+ verboseOpt, jsonOpt, siOpt, threadsOpt,
+ outputOpt, dryRunOpt, noOverwriteOpt, progressOpt,
+ };
+ command.Aliases.Add("t");
+ command.Aliases.Add("ytd");
+
+ command.SetAction(parseResult =>
+ {
+ string[] filters = Filter.Normalize(parseResult.GetValue(filterOpt));
+ ExportOptions options = new()
+ {
+ RpfPath = parseResult.GetValue(rpfOpt)?.FullName ?? "",
+ ExePath = parseResult.GetRequiredValue(exeOpt).FullName,
+ Gen9 = parseResult.GetValue(gen9Opt),
+ Filters = filters.Length == 0 ? DefaultFilters : filters,
+ Verbose = parseResult.GetValue(verboseOpt),
+ Json = parseResult.GetValue(jsonOpt),
+ Recursive = parseResult.GetValue(recursiveOpt),
+ Threads = parseResult.GetValue(threadsOpt),
+ SizeFormat = parseResult.GetValue(siOpt) ? SizeFormat.SI : SizeFormat.IEC,
+ OutputPath = parseResult.GetValue(outputOpt)?.FullName ?? Directory.GetCurrentDirectory(),
+ DryRun = parseResult.GetValue(dryRunOpt),
+ NoOverwrite = parseResult.GetValue(noOverwriteOpt),
+ Progress = parseResult.GetValue(progressOpt),
+ };
+ return ExportPipeline.Execute(options, "dds", "Texture", ProcessFile, cancellationToken);
+ });
+
+ return command;
+ }
+
+ private static (Json.ExportFileEntry entry, string? _) ProcessFile(
+ RpfFileEntry fileEntry,
+ byte[] data,
+ string fileOutputDir,
+ bool noOverwrite
+ )
+ {
+ YtdFile ytd = RpfFile.GetFile(fileEntry, data);
+ if (
+ ytd?.TextureDict?.Textures?.data_items == null
+ || ytd.TextureDict.Textures.data_items.Length == 0
+ )
+ {
+ return (
+ new Json.ExportFileEntry
+ {
+ Path = fileEntry.Path,
+ Name = fileEntry.Name,
+ OutputFiles = 0,
+ Status = "unsupported",
+ },
+ null
+ );
+ }
+
+ bool dirCreated = false;
+ int texCount = 0;
+ foreach (Texture tex in ytd.TextureDict.Textures.data_items)
+ {
+ string texName = (tex.Name ?? "unknown") + ".dds";
+ string outputPath = Path.Combine(fileOutputDir, texName);
+
+ if (noOverwrite && File.Exists(outputPath))
+ continue;
+
+ if (!dirCreated)
+ {
+ _ = Directory.CreateDirectory(fileOutputDir);
+ dirCreated = true;
+ }
+
+ byte[] dds = DDSIO.GetDDSFile(tex);
+ File.WriteAllBytes(outputPath, dds);
+ texCount++;
+ }
+
+ return (
+ new Json.ExportFileEntry
+ {
+ Path = fileEntry.Path,
+ Name = fileEntry.Name,
+ OutputPath = texCount > 0 ? fileOutputDir : null,
+ OutputFiles = texCount,
+ Status = texCount > 0 ? "exported" : "skipped",
+ },
+ null
+ );
+ }
+}
diff --git a/CodeWalker.Cli/Handlers/ExportXmlHandler.cs b/CodeWalker.Cli/Handlers/ExportXmlHandler.cs
new file mode 100644
index 000000000..9f1e485c4
--- /dev/null
+++ b/CodeWalker.Cli/Handlers/ExportXmlHandler.cs
@@ -0,0 +1,119 @@
+using System.CommandLine;
+using System.IO;
+using System.Text;
+using System.Threading;
+
+using CodeWalker.Cli.Helpers;
+using CodeWalker.GameFiles;
+
+namespace CodeWalker.Cli.Handlers;
+
+internal static class ExportXmlHandler
+{
+ public static Command CreateCommand(CancellationToken cancellationToken = default)
+ {
+ Option rpfOpt = CliOptions.Rpf();
+ Option exeOpt = CliOptions.Exe();
+ Option gen9Opt = CliOptions.Gen9();
+ Option filterOpt = CliOptions.Filter();
+ Option recursiveOpt = CliOptions.Recursive();
+ Option verboseOpt = CliOptions.Verbose();
+ Option jsonOpt = CliOptions.Json();
+ Option siOpt = CliOptions.Si();
+ Option threadsOpt = CliOptions.Threads();
+ Option outputOpt = CliOptions.OutputDir();
+ Option dryRunOpt = CliOptions.DryRun();
+ Option noOverwriteOpt = CliOptions.NoOverwrite();
+ Option progressOpt = CliOptions.Progress();
+
+ Command command = new("xml", "Export binary game files to XML")
+ {
+ rpfOpt, exeOpt, gen9Opt, filterOpt, recursiveOpt,
+ verboseOpt, jsonOpt, siOpt, threadsOpt,
+ outputOpt, dryRunOpt, noOverwriteOpt, progressOpt,
+ };
+ command.Aliases.Add("x");
+
+ command.SetAction(parseResult =>
+ {
+ ExportOptions options = new()
+ {
+ RpfPath = parseResult.GetValue(rpfOpt)?.FullName ?? "",
+ ExePath = parseResult.GetRequiredValue(exeOpt).FullName,
+ Gen9 = parseResult.GetValue(gen9Opt),
+ Filters = Filter.Normalize(parseResult.GetValue(filterOpt)),
+ Verbose = parseResult.GetValue(verboseOpt),
+ Json = parseResult.GetValue(jsonOpt),
+ Recursive = parseResult.GetValue(recursiveOpt),
+ Threads = parseResult.GetValue(threadsOpt),
+ SizeFormat = parseResult.GetValue(siOpt) ? SizeFormat.SI : SizeFormat.IEC,
+ OutputPath = parseResult.GetValue(outputOpt)?.FullName ?? Directory.GetCurrentDirectory(),
+ DryRun = parseResult.GetValue(dryRunOpt),
+ NoOverwrite = parseResult.GetValue(noOverwriteOpt),
+ Progress = parseResult.GetValue(progressOpt),
+ };
+ return ExportPipeline.Execute(options, "xml", "XML", ProcessFile, cancellationToken);
+ });
+
+ return command;
+ }
+
+ private static (Json.ExportFileEntry entry, string? _) ProcessFile(
+ RpfFileEntry fileEntry,
+ byte[] data,
+ string fileOutputDir,
+ bool noOverwrite
+ )
+ {
+ string xml = MetaXml.GetXml(fileEntry, data, out string filename, fileOutputDir);
+
+ if (string.IsNullOrEmpty(xml))
+ {
+ return (
+ new Json.ExportFileEntry
+ {
+ Path = fileEntry.Path,
+ Name = fileEntry.Name,
+ OutputFiles = 0,
+ Status = "unsupported",
+ },
+ null
+ );
+ }
+
+ if (!string.IsNullOrEmpty(fileOutputDir) && !Directory.Exists(fileOutputDir))
+ {
+ _ = Directory.CreateDirectory(fileOutputDir);
+ }
+
+ string outputPath = Path.Combine(fileOutputDir, filename);
+
+ if (noOverwrite && File.Exists(outputPath))
+ {
+ return (
+ new Json.ExportFileEntry
+ {
+ Path = fileEntry.Path,
+ Name = fileEntry.Name,
+ OutputFiles = 0,
+ Status = "skipped",
+ },
+ null
+ );
+ }
+
+ File.WriteAllText(outputPath, xml, Encoding.UTF8);
+
+ return (
+ new Json.ExportFileEntry
+ {
+ Path = fileEntry.Path,
+ Name = fileEntry.Name,
+ OutputPath = outputPath,
+ OutputFiles = 1,
+ Status = "exported",
+ },
+ null
+ );
+ }
+}
diff --git a/CodeWalker.Cli/Handlers/ExtractHandler.cs b/CodeWalker.Cli/Handlers/ExtractHandler.cs
new file mode 100644
index 000000000..af499604b
--- /dev/null
+++ b/CodeWalker.Cli/Handlers/ExtractHandler.cs
@@ -0,0 +1,343 @@
+using System;
+using System.Collections.Generic;
+using System.CommandLine;
+using System.IO;
+using System.Text.Json;
+using System.Threading;
+using System.Threading.Tasks;
+
+using CodeWalker.Cli.Helpers;
+using CodeWalker.GameFiles;
+
+namespace CodeWalker.Cli.Handlers;
+
+internal sealed record ExtractOptions
+{
+ public required string RpfPath { get; init; }
+ public required string ExePath { get; init; }
+ public required bool Gen9 { get; init; }
+ public required string[] Filters { get; init; }
+ public required bool Verbose { get; init; }
+ public required bool Json { get; init; }
+ public required bool Recursive { get; init; }
+ public required int Threads { get; init; }
+ public required SizeFormat SizeFormat { get; init; }
+ public required string? OutputPath { get; init; }
+ public required bool DryRun { get; init; }
+ public required bool NoOverwrite { get; init; }
+ public required bool Progress { get; init; }
+}
+
+internal static class ExtractHandler
+{
+ public static Command CreateCommand(CancellationToken cancellationToken = default)
+ {
+ Option rpfOpt = CliOptions.Rpf();
+ Option exeOpt = CliOptions.Exe();
+ Option gen9Opt = CliOptions.Gen9();
+ Option filterOpt = CliOptions.Filter();
+ Option recursiveOpt = CliOptions.Recursive();
+ Option verboseOpt = CliOptions.Verbose();
+ Option jsonOpt = CliOptions.Json();
+ Option siOpt = CliOptions.Si();
+ Option threadsOpt = CliOptions.Threads();
+ Option outputOption = CliOptions.OutputDir();
+ Option dryRunOption = CliOptions.DryRun();
+ Option noOverwriteOption = CliOptions.NoOverwrite();
+ Option progressOption = CliOptions.Progress();
+
+ Command command = new("extract", "Extract files from an RPF archive")
+ {
+ rpfOpt,
+ exeOpt,
+ gen9Opt,
+ filterOpt,
+ recursiveOpt,
+ verboseOpt,
+ jsonOpt,
+ siOpt,
+ threadsOpt,
+ outputOption,
+ dryRunOption,
+ noOverwriteOption,
+ progressOption,
+ };
+ command.Aliases.Add("x");
+
+ command.SetAction(parseResult =>
+ {
+ ExtractOptions options = new()
+ {
+ RpfPath = parseResult.GetValue(rpfOpt)?.FullName ?? "",
+ ExePath = parseResult.GetRequiredValue(exeOpt).FullName,
+ Gen9 = parseResult.GetValue(gen9Opt),
+ Filters = Filter.Normalize(parseResult.GetValue(filterOpt)),
+ Verbose = parseResult.GetValue(verboseOpt),
+ Json = parseResult.GetValue(jsonOpt),
+ Recursive = parseResult.GetValue(recursiveOpt),
+ Threads = parseResult.GetValue(threadsOpt),
+ SizeFormat = parseResult.GetValue(siOpt) ? SizeFormat.SI : SizeFormat.IEC,
+ OutputPath = parseResult.GetValue(outputOption)?.FullName,
+ DryRun = parseResult.GetValue(dryRunOption),
+ NoOverwrite = parseResult.GetValue(noOverwriteOption),
+ Progress = parseResult.GetValue(progressOption),
+ };
+ return Execute(options, cancellationToken);
+ });
+
+ return command;
+ }
+
+ public static int Execute(ExtractOptions options, CancellationToken cancellationToken = default)
+ {
+ Json.ExtractResult ErrorResult(string[] errorMessages) =>
+ new()
+ {
+ Success = false,
+ RpfFile = options.RpfPath,
+ OutputDir = options.OutputPath ?? Directory.GetCurrentDirectory(),
+ TotalFiles = 0,
+ Extracted = 0,
+ Skipped = 0,
+ Errors = 0,
+ DryRun = options.DryRun,
+ Files = [],
+ ErrorMessages = errorMessages,
+ };
+
+ string? initError = RpfHelper.ValidateAndLoadKeys(
+ options.RpfPath,
+ options.ExePath,
+ options.Gen9,
+ options.Json
+ );
+ if (initError != null)
+ {
+ return Output.ReportError(initError, options.Json, ErrorResult([]));
+ }
+
+ List scanErrors = [];
+ try
+ {
+ RpfFile rpf = RpfHelper.OpenRpf(
+ options.RpfPath,
+ options.Verbose,
+ options.Json,
+ scanErrors
+ );
+
+ if (!options.Json && options.DryRun)
+ {
+ Console.Error.WriteLine("Dry run mode - no files will be extracted");
+ }
+
+ string outputDir = options.OutputPath ?? Directory.GetCurrentDirectory();
+
+ if (!options.DryRun && !Directory.Exists(outputDir))
+ {
+ _ = Directory.CreateDirectory(outputDir);
+ }
+
+ List<(RpfFile rpf, RpfFileEntry entry)> filesToExtract = RpfHelper.CollectFiles(
+ rpf,
+ options.Filters,
+ options.Recursive
+ );
+
+ int skipped = 0;
+
+ // Process files in parallel, storing results by index to preserve order
+ (Json.FileEntry? jsonEntry, string? errorMessage)[] results =
+ new (Json.FileEntry?, string?)[filesToExtract.Count];
+
+ object consoleLock = new();
+
+ using (
+ ProgressBar progress = new(
+ filesToExtract.Count,
+ options.Progress && !options.Json
+ )
+ )
+ {
+ _ = Parallel.For(
+ 0,
+ filesToExtract.Count,
+ new ParallelOptions { MaxDegreeOfParallelism = options.Threads, CancellationToken = cancellationToken },
+ i =>
+ {
+ (RpfFile sourceRpf, RpfFileEntry fileEntry) = filesToExtract[i];
+ try
+ {
+ string relativePath = fileEntry.Path;
+ string outputPath = Path.Combine(
+ outputDir,
+ relativePath.Replace("\\", Path.DirectorySeparatorChar.ToString(), StringComparison.Ordinal)
+ );
+ string? fileDir = Path.GetDirectoryName(outputPath);
+
+ long size = fileEntry.GetFileSize();
+ string ext = Path.GetExtension(fileEntry.Name).ToLowerInvariant();
+
+ Json.FileEntry jsonEntry = new()
+ {
+ Path = fileEntry.Path,
+ Name = fileEntry.Name,
+ Size = size,
+ SizeFormatted = options.SizeFormat.ToFormattedString(size),
+ Type = RpfHelper.GetFileType(fileEntry),
+ Extension = ext,
+ };
+
+ if (options.DryRun)
+ {
+ if (options.Verbose && !options.Json)
+ {
+ lock (consoleLock)
+ {
+ Console.WriteLine($"Would extract: {fileEntry.Path}");
+ }
+ }
+ results[i] = (jsonEntry with { Status = "dry_run" }, null);
+ }
+ else if (options.NoOverwrite && File.Exists(outputPath))
+ {
+ _ = Interlocked.Increment(ref skipped);
+ if (options.Verbose && !options.Json && !options.Progress)
+ {
+ lock (consoleLock)
+ {
+ Console.Error.WriteLine(
+ $"Skipping (exists): {fileEntry.Path}"
+ );
+ }
+ }
+ results[i] = (jsonEntry with { Status = "skipped" }, null);
+ }
+ else
+ {
+ if (!string.IsNullOrEmpty(fileDir))
+ {
+ _ = Directory.CreateDirectory(fileDir);
+ }
+
+ if (options.Verbose && !options.Json && !options.Progress)
+ {
+ lock (consoleLock)
+ {
+ Console.Error.WriteLine($"Extracting: {fileEntry.Path}");
+ }
+ }
+
+ byte[]? data = sourceRpf.ExtractFile(fileEntry);
+ if (data != null)
+ {
+ File.WriteAllBytes(outputPath, data);
+ results[i] = (
+ jsonEntry with { Status = "extracted" },
+ null
+ );
+ }
+ else
+ {
+ if (options.Verbose && !options.Json)
+ {
+ lock (consoleLock)
+ {
+ Console.Error.WriteLine(
+ $"Warning: Failed to extract {fileEntry.Path}"
+ );
+ }
+ }
+ results[i] = (
+ null,
+ $"Failed to extract: {fileEntry.Path}"
+ );
+ }
+ }
+
+ progress.Increment(fileEntry.Path);
+ }
+ catch (Exception ex)
+ {
+ if (!options.Json)
+ {
+ lock (consoleLock)
+ {
+ Console.Error.WriteLine(
+ $"Error extracting {fileEntry.Path}: {ex.Message}"
+ );
+ }
+ }
+ results[i] = (
+ null,
+ $"Error extracting {fileEntry.Path}: {ex.Message}"
+ );
+ progress.Increment();
+ }
+ }
+ );
+ }
+
+ int extracted = 0;
+ int errors = 0;
+ List files = [];
+ List errorMessages = [.. scanErrors];
+
+ foreach ((Json.FileEntry? jsonEntry, string? errorMessage) in results)
+ {
+ if (errorMessage == null && jsonEntry?.Status is "extracted" or "dry_run")
+ extracted++;
+
+ if (jsonEntry != null)
+ files.Add(jsonEntry);
+
+ if (errorMessage != null)
+ {
+ errors++;
+ errorMessages.Add(errorMessage);
+ }
+ }
+
+ Json.ExtractResult result = new()
+ {
+ Success = errors == 0 && scanErrors.Count == 0,
+ RpfFile = options.RpfPath,
+ OutputDir = options.OutputPath ?? Directory.GetCurrentDirectory(),
+ TotalFiles = filesToExtract.Count,
+ Extracted = extracted,
+ Skipped = skipped,
+ Errors = errors,
+ DryRun = options.DryRun,
+ Files = [.. files],
+ ErrorMessages = [.. errorMessages],
+ };
+
+ if (options.Json)
+ {
+ Console.WriteLine(
+ JsonSerializer.Serialize(result, Output.JsonSerializerOptions)
+ );
+ }
+ else
+ {
+ Console.Error.WriteLine();
+ string action = options.DryRun ? "would be extracted" : "extracted";
+ Console.Error.WriteLine(
+ $"Extraction complete: {extracted} files {action}, {skipped} skipped, {errors} errors"
+ );
+ }
+
+ return (errors > 0 || scanErrors.Count > 0) ? 1 : 0;
+ }
+ catch (OperationCanceledException) { throw; }
+ catch (Exception ex)
+ {
+ return Output.ReportError(
+ ex.Message,
+ options.Json,
+ ErrorResult([.. scanErrors]),
+ options.Verbose ? ex.StackTrace : null
+ );
+ }
+ }
+}
diff --git a/CodeWalker.Cli/Handlers/Gen9Handler.cs b/CodeWalker.Cli/Handlers/Gen9Handler.cs
new file mode 100644
index 000000000..934a26bae
--- /dev/null
+++ b/CodeWalker.Cli/Handlers/Gen9Handler.cs
@@ -0,0 +1,624 @@
+using System;
+using System.Collections.Generic;
+using System.CommandLine;
+using System.IO;
+using System.Linq;
+using System.Text.Json;
+using System.Threading;
+using System.Threading.Tasks;
+
+using CodeWalker.Cli.Helpers;
+using CodeWalker.Core.Utils;
+using CodeWalker.GameFiles;
+
+namespace CodeWalker.Cli.Handlers;
+
+internal sealed record Gen9Options
+{
+ public required string InputPath { get; init; }
+ public required string OutputPath { get; init; }
+ public required string ExePath { get; init; }
+ public required bool Verbose { get; init; }
+ public required bool Json { get; init; }
+ public required SizeFormat SizeFormat { get; init; }
+ public required int Threads { get; init; }
+ public required bool NoRecurse { get; init; }
+ public required bool NoOverwrite { get; init; }
+ public required bool SkipUnconverted { get; init; }
+ public required bool Progress { get; init; }
+}
+
+internal static class Gen9Handler
+{
+ public static Command CreateCommand(CancellationToken cancellationToken = default)
+ {
+ Option exeOpt = CliOptions.Exe();
+ Option verboseOpt = CliOptions.Verbose();
+ Option jsonOpt = CliOptions.Json();
+ Option siOpt = CliOptions.Si();
+ Option threadsOpt = CliOptions.Threads();
+
+ Option inputOption = new("--input", "-i")
+ {
+ Description = "Input folder containing files to convert",
+ Required = true,
+ };
+
+ Option outputOption = new("--output", "-o")
+ {
+ Description = "Output folder for converted files",
+ Required = true,
+ };
+
+ Option noRecurseOption = new("--no-recurse")
+ {
+ Description = "Skip subfolders (default: recurse)",
+ };
+
+ Option noOverwriteOption = new("--no-overwrite")
+ {
+ Description = "Skip existing output files",
+ };
+
+ Option skipUnconvertedOption = new("--skip-unconverted")
+ {
+ Description = "Don't copy files that don't need conversion",
+ };
+
+ Option progressOption = CliOptions.Progress();
+
+ Command command = new("gen9", "Convert files to enhanced (Gen9) format")
+ {
+ inputOption,
+ outputOption,
+ noRecurseOption,
+ noOverwriteOption,
+ skipUnconvertedOption,
+
+ progressOption,
+ exeOpt,
+ verboseOpt,
+ jsonOpt,
+ siOpt,
+ threadsOpt
+ };
+ command.Aliases.Add("g");
+
+ command.SetAction(parseResult =>
+ {
+ Gen9Options options = new()
+ {
+ InputPath = parseResult.GetRequiredValue(inputOption).FullName,
+ OutputPath = parseResult.GetRequiredValue(outputOption).FullName,
+ ExePath = parseResult.GetRequiredValue(exeOpt).FullName,
+ Verbose = parseResult.GetValue(verboseOpt),
+ Json = parseResult.GetValue(jsonOpt),
+ SizeFormat = parseResult.GetValue(siOpt) ? SizeFormat.SI : SizeFormat.IEC,
+ Threads = parseResult.GetValue(threadsOpt),
+ NoRecurse = parseResult.GetValue(noRecurseOption),
+ NoOverwrite = parseResult.GetValue(noOverwriteOption),
+ SkipUnconverted = parseResult.GetValue(skipUnconvertedOption),
+ Progress = parseResult.GetValue(progressOption),
+ };
+ return Execute(options, cancellationToken);
+ });
+
+ return command;
+ }
+
+ public static int Execute(Gen9Options options, CancellationToken cancellationToken = default)
+ {
+ Json.Gen9Result ErrorResult(string[] errorMessages) =>
+ new()
+ {
+ Success = false,
+ InputFolder = options.InputPath,
+ OutputFolder = options.OutputPath,
+ TotalFiles = 0,
+ Converted = 0,
+ Skipped = 0,
+ Copied = 0,
+ Errors = 0,
+ Files = [],
+ ErrorMessages = errorMessages,
+ };
+
+ if (!Directory.Exists(options.InputPath))
+ {
+ return Output.ReportError(
+ $"Input folder not found: {options.InputPath}",
+ options.Json,
+ ErrorResult([])
+ );
+ }
+
+ if (
+ string.Equals(
+ Path.GetFullPath(options.InputPath),
+ Path.GetFullPath(options.OutputPath),
+ StringComparison.OrdinalIgnoreCase
+ )
+ )
+ {
+ return Output.ReportError(
+ "Input folder and Output folder must be different.",
+ options.Json,
+ ErrorResult([])
+ );
+ }
+
+ string? exeError = RpfHelper.ValidateExeAndLoadKeys(
+ options.ExePath,
+ true,
+ options.Json
+ );
+ if (exeError != null)
+ {
+ return Output.ReportError(exeError, options.Json, ErrorResult([]));
+ }
+
+ try
+ {
+ bool previousGen9 = RpfManager.IsGen9;
+ RpfManager.IsGen9 = true;
+
+ try
+ {
+ if (!Directory.Exists(options.OutputPath))
+ {
+ _ = Directory.CreateDirectory(options.OutputPath);
+ }
+
+ string inputFolder = options.InputPath;
+ if (!inputFolder.EndsWith(Path.DirectorySeparatorChar.ToString(), StringComparison.Ordinal))
+ {
+ inputFolder += Path.DirectorySeparatorChar;
+ }
+
+ SearchOption searchOption = options.NoRecurse
+ ? SearchOption.TopDirectoryOnly
+ : SearchOption.AllDirectories;
+
+ string[] allPaths = Directory.GetFileSystemEntries(inputFolder, "*", searchOption);
+
+ ILookup pathsByType = allPaths
+ .Where(File.Exists)
+ .ToLookup(p => Path.GetExtension(p).Equals(".rpf", StringComparison.OrdinalIgnoreCase));
+ List rpfPaths = [.. pathsByType[true]];
+ List filePaths = [.. pathsByType[false]];
+
+ int totalFileCount = filePaths.Count + rpfPaths.Count;
+
+ if (!options.Json)
+ {
+ Console.Error.WriteLine($"Found {totalFileCount} files in {options.InputPath}");
+ }
+
+ int converted = 0;
+ int skipped = 0;
+ int copied = 0;
+ int errors = 0;
+ bool copyUnconverted = !options.SkipUnconverted;
+ List files = [];
+ List errorMessages = [];
+
+ using (
+ ProgressBar progress = new(
+ totalFileCount,
+ options.Progress && !options.Json
+ )
+ )
+ {
+ (Json.Gen9FileEntry entry, string? error)[] nonRpfResults = new (
+ Json.Gen9FileEntry,
+ string?
+ )[filePaths.Count];
+
+ object consoleLock = new();
+
+ _ = Parallel.For(
+ 0,
+ filePaths.Count,
+ new ParallelOptions { MaxDegreeOfParallelism = options.Threads, CancellationToken = cancellationToken },
+ i =>
+ {
+ string path = filePaths[i];
+ string relPath = path[inputFolder.Length..];
+ string outPath = Path.Combine(options.OutputPath, relPath);
+
+ try
+ {
+ if (options.NoOverwrite && File.Exists(outPath))
+ {
+ nonRpfResults[i] = (
+ new Json.Gen9FileEntry
+ {
+ Path = relPath,
+ Status = "skipped",
+ Message = "Output file already exists",
+ },
+ null
+ );
+ if (options.Verbose && !options.Json)
+ {
+ lock (consoleLock)
+ {
+ Console.Error.WriteLine(
+ $"{relPath} - skipped (exists)"
+ );
+ }
+ }
+ progress.Increment(relPath);
+ return;
+ }
+
+ string? outDir = Path.GetDirectoryName(outPath);
+ if (!string.IsNullOrEmpty(outDir) && !Directory.Exists(outDir))
+ {
+ _ = Directory.CreateDirectory(outDir);
+ }
+
+ string ext = Path.GetExtension(path).ToLowerInvariant();
+ byte[] dataIn = File.ReadAllBytes(path);
+ byte[]? dataOut = Gen9Converter.TryConvert(
+ dataIn,
+ ext,
+ msg =>
+ {
+ if (options.Verbose && !options.Json)
+ {
+ lock (consoleLock)
+ {
+ Console.Error.WriteLine(msg);
+ }
+ }
+ },
+ relPath,
+ copyUnconverted,
+ out bool wasConverted
+ );
+
+ if (wasConverted && dataOut != null)
+ {
+ File.WriteAllBytes(outPath, dataOut);
+ nonRpfResults[i] = (
+ new Json.Gen9FileEntry
+ {
+ Path = relPath,
+ Status = "converted",
+ },
+ null
+ );
+ }
+ else if (dataOut != null)
+ {
+ File.WriteAllBytes(outPath, dataOut);
+ nonRpfResults[i] = (
+ new Json.Gen9FileEntry
+ {
+ Path = relPath,
+ Status = "copied",
+ },
+ null
+ );
+ }
+ else
+ {
+ nonRpfResults[i] = (
+ new Json.Gen9FileEntry
+ {
+ Path = relPath,
+ Status = "skipped",
+ },
+ null
+ );
+ }
+
+ progress.Increment(relPath);
+ }
+ catch (Exception ex)
+ {
+ string errorMsg = $"Error processing {relPath}: {ex.Message}";
+ nonRpfResults[i] = (
+ new Json.Gen9FileEntry
+ {
+ Path = relPath,
+ Status = "error",
+ Message = ex.Message,
+ },
+ errorMsg
+ );
+ if (!options.Json)
+ {
+ lock (consoleLock)
+ {
+ Console.Error.WriteLine($"Error: {errorMsg}");
+ }
+ }
+ progress.Increment();
+ }
+ }
+ );
+
+ foreach ((Json.Gen9FileEntry entry, string? error) in nonRpfResults)
+ {
+ files.Add(entry);
+ switch (entry.Status)
+ {
+ case "converted":
+ converted++;
+ break;
+ case "copied":
+ copied++;
+ break;
+ case "skipped":
+ skipped++;
+ break;
+ case "error":
+ errors++;
+ if (error != null)
+ errorMessages.Add(error);
+ break;
+ }
+ }
+
+ // Process RPF files sequentially (unsafe to parallelize)
+ foreach (string path in rpfPaths)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ string relPath = path[inputFolder.Length..];
+ string outPath = Path.Combine(options.OutputPath, relPath);
+
+ try
+ {
+ if (options.NoOverwrite && File.Exists(outPath))
+ {
+ skipped++;
+ files.Add(
+ new Json.Gen9FileEntry
+ {
+ Path = relPath,
+ Status = "skipped",
+ Message = "Output file already exists",
+ }
+ );
+ if (options.Verbose && !options.Json)
+ {
+ Console.Error.WriteLine($"{relPath} - skipped (exists)");
+ }
+ progress.Increment(relPath);
+ continue;
+ }
+
+ string? outDir = Path.GetDirectoryName(outPath);
+ if (!string.IsNullOrEmpty(outDir) && !Directory.Exists(outDir))
+ {
+ _ = Directory.CreateDirectory(outDir);
+ }
+
+ ProcessRpfFile(
+ path,
+ outPath,
+ relPath,
+ options,
+ files,
+ errorMessages,
+ ref converted,
+ ref errors
+ );
+
+ progress.Increment(relPath);
+ }
+ catch (Exception ex)
+ {
+ errors++;
+ string errorMsg = $"Error processing {relPath}: {ex.Message}";
+ errorMessages.Add(errorMsg);
+ files.Add(
+ new Json.Gen9FileEntry
+ {
+ Path = relPath,
+ Status = "error",
+ Message = ex.Message,
+ }
+ );
+ if (!options.Json)
+ {
+ Console.Error.WriteLine($"Error: {errorMsg}");
+ }
+ progress.Increment();
+ }
+ }
+ }
+
+ Json.Gen9Result result = new()
+ {
+ Success = errors == 0,
+ InputFolder = options.InputPath,
+ OutputFolder = options.OutputPath,
+ TotalFiles = totalFileCount,
+ Converted = converted,
+ Skipped = skipped,
+ Copied = copied,
+ Errors = errors,
+ Files = [.. files],
+ ErrorMessages = [.. errorMessages],
+ };
+
+ if (options.Json)
+ {
+ Console.WriteLine(
+ JsonSerializer.Serialize(result, Output.JsonSerializerOptions)
+ );
+ }
+ else
+ {
+ Console.Error.WriteLine();
+ Console.Error.WriteLine(
+ $"Conversion complete: {converted} converted, {copied} copied, {skipped} skipped, {errors} errors"
+ );
+ }
+
+ return errors > 0 ? 1 : 0;
+ }
+ finally
+ {
+ RpfManager.IsGen9 = previousGen9;
+ }
+ }
+ catch (OperationCanceledException) { throw; }
+ catch (Exception ex)
+ {
+ return Output.ReportError(
+ ex.Message,
+ options.Json,
+ ErrorResult([]),
+ options.Verbose ? ex.StackTrace : null
+ );
+ }
+ }
+
+ private static void ProcessRpfFile(
+ string inputPath,
+ string outputPath,
+ string relPath,
+ Gen9Options options,
+ List files,
+ List errorMessages,
+ ref int converted,
+ ref int errors
+ )
+ {
+ if (options.Verbose && !options.Json)
+ {
+ Console.Error.WriteLine($"{relPath} - Converting RPF contents...");
+ }
+
+ File.Copy(inputPath, outputPath, overwrite: true);
+
+ RpfFile rpf = new(outputPath, relPath);
+ rpf.ScanStructure(
+ status =>
+ {
+ if (options.Verbose && !options.Json)
+ Console.Error.WriteLine(status);
+ },
+ error =>
+ {
+ if (!options.Json)
+ Console.Error.WriteLine($"Error: {error}");
+ errorMessages.Add(error);
+ }
+ );
+
+ // Build list of all RPFs (children first, then parents)
+ List rpfList = [];
+ Stack rpfStack = new();
+ rpfStack.Push(rpf);
+ while (rpfStack.Count > 0)
+ {
+ RpfFile current = rpfStack.Pop();
+ if (current.Children != null)
+ {
+ foreach (RpfFile child in current.Children)
+ {
+ rpfStack.Push(child);
+ }
+ }
+ rpfList.Add(current);
+ }
+ rpfList.Reverse();
+
+ HashSet changedParents = [];
+
+ foreach (RpfFile currentRpf in rpfList)
+ {
+ if (currentRpf.AllEntries == null)
+ continue;
+
+ bool changed = changedParents.Contains(currentRpf);
+
+ List resourceEntries = currentRpf.AllEntries
+ .OfType()
+ .OrderBy(rfe => rfe.FileOffset)
+ .ToList();
+
+ foreach (RpfResourceFileEntry rfe in resourceEntries)
+ {
+ if (!Gen9Converter.RequiresConversion(rfe))
+ continue;
+
+ RpfDirectoryEntry dir = rfe.Parent;
+ string name = rfe.Name;
+ string type = Path.GetExtension(rfe.NameLower);
+
+ byte[]? dataIn = currentRpf.ExtractFile(rfe);
+ if (dataIn == null)
+ {
+ errors++;
+ string errorMsg = $"{rfe.Path} - failed to extract";
+ errorMessages.Add(errorMsg);
+ files.Add(
+ new Json.Gen9FileEntry
+ {
+ Path = rfe.Path,
+ Status = "error",
+ Message = "Failed to extract file data",
+ }
+ );
+ continue;
+ }
+ dataIn = ResourceBuilder.Compress(dataIn);
+ dataIn = ResourceBuilder.AddResourceHeader(rfe, dataIn);
+
+ byte[]? dataOut = Gen9Converter.TryConvert(
+ dataIn,
+ type,
+ msg =>
+ {
+ if (options.Verbose && !options.Json)
+ Console.Error.WriteLine(msg);
+ },
+ rfe.Path,
+ false,
+ out bool wasConverted
+ );
+
+ if (!wasConverted || dataOut == null)
+ {
+ errors++;
+ string errorMsg = $"{rfe.Path} - unable to convert";
+ errorMessages.Add(errorMsg);
+ files.Add(
+ new Json.Gen9FileEntry
+ {
+ Path = rfe.Path,
+ Status = "error",
+ Message = "Unable to convert",
+ }
+ );
+ continue;
+ }
+
+ _ = RpfFile.CreateFile(dir, name, dataOut, true);
+ converted++;
+ files.Add(new Json.Gen9FileEntry { Path = rfe.Path, Status = "converted" });
+ changed = true;
+ }
+
+ if (changed)
+ {
+ if (options.Verbose && !options.Json)
+ {
+ Console.Error.WriteLine($"{currentRpf.Path} - Defragmenting");
+ }
+ RpfFile.Defragment(currentRpf, null, false);
+
+ if (currentRpf.Parent != null)
+ {
+ _ = changedParents.Add(currentRpf.Parent);
+ }
+ }
+ }
+ }
+}
diff --git a/CodeWalker.Cli/Handlers/HashHandler.cs b/CodeWalker.Cli/Handlers/HashHandler.cs
new file mode 100644
index 000000000..eb4004c7b
--- /dev/null
+++ b/CodeWalker.Cli/Handlers/HashHandler.cs
@@ -0,0 +1,187 @@
+using System;
+using System.Collections.Generic;
+using System.CommandLine;
+using System.Diagnostics.CodeAnalysis;
+using System.Text.Json;
+using System.Threading;
+
+using CodeWalker.Cli.Helpers;
+using CodeWalker.GameFiles;
+
+namespace CodeWalker.Cli.Handlers;
+
+[ExcludeFromCodeCoverage]
+internal sealed record HashOptions
+{
+ public required string[] Inputs { get; init; }
+ public required string Encoding { get; init; }
+ public required bool Json { get; init; }
+
+ public const string DefaultEncoding = "utf-8";
+}
+
+internal static class HashHandler
+{
+ public static Command CreateCommand(CancellationToken cancellationToken = default)
+ {
+ Option inputOption = new("--input", "-i")
+ {
+ Description = "Text string(s) to hash",
+ Required = true,
+ AllowMultipleArgumentsPerToken = true
+ };
+
+ Option encodingOption = new("--encoding", "-e")
+ {
+ Description = "Encoding: utf-8 (default), ascii",
+ DefaultValueFactory = _ => HashOptions.DefaultEncoding
+ };
+
+ Option jsonOption = new("--json")
+ {
+ Description = "Output results in JSON format"
+ };
+
+ Command command = new("hash", "Generate Jenkins hashes for GTA V game identifiers")
+ {
+ inputOption,
+ encodingOption,
+ jsonOption
+ };
+ command.Aliases.Add("h");
+
+ command.SetAction(parseResult =>
+ {
+ HashOptions options = new()
+ {
+ Inputs = parseResult.GetRequiredValue(inputOption),
+ Encoding = parseResult.GetRequiredValue(encodingOption),
+ Json = parseResult.GetValue(jsonOption)
+ };
+ return Execute(options, cancellationToken);
+ });
+
+ return command;
+ }
+
+ ///
+ /// Hashes every input and prints the results.
+ ///
+ public static int Execute(HashOptions options, CancellationToken cancellationToken = default)
+ {
+ try
+ {
+ JenkHashInputEncoding encoding = ParseEncoding(options.Encoding);
+ Json.HashEntry[] hashes = CollectHashes(options.Inputs, encoding, cancellationToken);
+ if (options.Json)
+ PrintJsonHashes(hashes);
+ else
+ PrintHashes(hashes, cancellationToken);
+
+ return 0;
+ }
+ catch (OperationCanceledException) { throw; }
+ catch (Exception ex)
+ {
+ return Output.ReportError(
+ ex.Message,
+ options.Json,
+ ErrorResult([])
+ );
+ }
+ }
+
+ ///
+ /// A failed result carrying the given messages.
+ ///
+ internal static Json.HashResult ErrorResult(string[] errorMessages) =>
+ new()
+ {
+ Success = false,
+ Hashes = [],
+ ErrorMessages = errorMessages
+ };
+
+ ///
+ /// Parses an encoding name, throwing if it is not recognised.
+ ///
+ internal static JenkHashInputEncoding ParseEncoding(string encoding) =>
+ encoding.ToUpperInvariant() switch
+ {
+ "UTF-8" => JenkHashInputEncoding.UTF8,
+ "ASCII" => JenkHashInputEncoding.ASCII,
+ _ => throw new ArgumentException($"Unknown encoding: {encoding}. Use 'utf-8' or 'ascii'.")
+ };
+
+ ///
+ /// Spells an encoding the way --encoding accepts it, so reported values can be fed
+ /// straight back in.
+ ///
+ internal static string EncodingName(JenkHashInputEncoding encoding) => encoding switch
+ {
+ JenkHashInputEncoding.UTF8 => "utf-8",
+ JenkHashInputEncoding.ASCII => "ascii",
+ _ => encoding.ToString(),
+ };
+
+ ///
+ /// Hashes each input under the given encoding.
+ ///
+ internal static Json.HashEntry[] CollectHashes(
+ string[] inputs,
+ JenkHashInputEncoding encoding,
+ CancellationToken cancellationToken
+ )
+ {
+ List hashes = [];
+ foreach (string input in inputs)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ JenkHash jenkHash = new(input, encoding);
+ hashes.Add(
+ new Json.HashEntry
+ {
+ Input = input,
+ Hash = jenkHash.HashUint,
+ HashSigned = jenkHash.HashInt,
+ HashHex = jenkHash.HashHex,
+ Encoding = EncodingName(jenkHash.Encoding)
+ }
+ );
+ }
+
+ return [.. hashes];
+ }
+
+ ///
+ /// Prints the hashes as JSON.
+ ///
+ internal static void PrintJsonHashes(Json.HashEntry[] hashes)
+ {
+ Json.HashResult result = new()
+ {
+ Success = true,
+ Hashes = hashes,
+ ErrorMessages = []
+ };
+ Console.WriteLine(JsonSerializer.Serialize(result, Output.JsonSerializerOptions));
+ }
+
+ ///
+ /// Prints the hashes one block per input.
+ ///
+ internal static void PrintHashes(
+ Json.HashEntry[] entries,
+ CancellationToken cancellationToken
+ )
+ {
+ foreach (Json.HashEntry entry in entries)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ Console.WriteLine($"Input ({entry.Encoding}): {entry.Input}");
+ Console.WriteLine($" Hash (uint): {entry.Hash}");
+ Console.WriteLine($" Hash (int): {entry.HashSigned}");
+ Console.WriteLine($" Hash (hex): {entry.HashHex}");
+ }
+ }
+}
diff --git a/CodeWalker.Cli/Handlers/InspectHandler.cs b/CodeWalker.Cli/Handlers/InspectHandler.cs
new file mode 100644
index 000000000..dd923de26
--- /dev/null
+++ b/CodeWalker.Cli/Handlers/InspectHandler.cs
@@ -0,0 +1,633 @@
+using System;
+using System.Collections.Generic;
+using System.CommandLine;
+using System.Globalization;
+using System.IO;
+using System.Linq;
+using System.Text.Json;
+using System.Threading;
+
+using CodeWalker.Cli.Helpers;
+using CodeWalker.GameFiles;
+
+using SharpDX;
+
+namespace CodeWalker.Cli.Handlers;
+
+internal sealed record InspectOptions
+{
+ public required string RpfPath { get; init; }
+ public required string ExePath { get; init; }
+ public required bool Gen9 { get; init; }
+ public required string[] Filters { get; init; }
+ public required bool Verbose { get; init; }
+ public required bool Json { get; init; }
+ public required bool Recursive { get; init; }
+ public required SizeFormat SizeFormat { get; init; }
+ public required string FilePath { get; init; }
+}
+
+internal static class InspectHandler
+{
+ public static Command CreateCommand(CancellationToken cancellationToken = default)
+ {
+ Option rpfOpt = CliOptions.Rpf();
+ Option exeOpt = CliOptions.Exe();
+ Option gen9Opt = CliOptions.Gen9();
+ Option filterOpt = CliOptions.Filter();
+ Option recursiveOpt = CliOptions.Recursive();
+ Option verboseOpt = CliOptions.Verbose();
+ Option jsonOpt = CliOptions.Json();
+ Option siOpt = CliOptions.Si();
+
+ Argument pathArg = new("path")
+ {
+ Description = "Path of the file within the RPF archive",
+ };
+
+ Command command = new(
+ "inspect",
+ "Show detailed metadata for a specific file in an RPF archive"
+ )
+ {
+ pathArg,
+ rpfOpt,
+ exeOpt,
+ gen9Opt,
+ filterOpt,
+ recursiveOpt,
+ verboseOpt,
+ jsonOpt,
+ siOpt,
+ };
+ command.Aliases.Add("i");
+
+ command.SetAction(parseResult =>
+ {
+ InspectOptions options = new()
+ {
+ RpfPath = parseResult.GetRequiredValue(rpfOpt).FullName,
+ ExePath = parseResult.GetRequiredValue(exeOpt).FullName,
+ Gen9 = parseResult.GetValue(gen9Opt),
+ Filters = Filter.Normalize(parseResult.GetValue(filterOpt)),
+ Verbose = parseResult.GetValue(verboseOpt),
+ Json = parseResult.GetValue(jsonOpt),
+ Recursive = parseResult.GetValue(recursiveOpt),
+ SizeFormat = parseResult.GetValue(siOpt) ? SizeFormat.SI : SizeFormat.IEC,
+ FilePath = parseResult.GetRequiredValue(pathArg),
+ };
+ return Execute(options, cancellationToken);
+ });
+
+ return command;
+ }
+
+ public static int Execute(InspectOptions options, CancellationToken cancellationToken = default)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ Json.InspectResult ErrorResult(string[] errorMessages) =>
+ new()
+ {
+ Success = false,
+ RpfFile = options.RpfPath,
+ Path = options.FilePath,
+ Name = "",
+ Size = 0,
+ SizeFormatted = "0 B",
+ Type = "",
+ Extension = "",
+ NameHash = 0,
+ ShortNameHash = 0,
+ ErrorMessages = errorMessages,
+ };
+
+ string? initError = RpfHelper.ValidateAndLoadKeys(
+ options.RpfPath,
+ options.ExePath,
+ options.Gen9,
+ options.Json
+ );
+ if (initError != null)
+ {
+ return Output.ReportError(initError, options.Json, ErrorResult([]));
+ }
+
+ List scanErrors = [];
+ try
+ {
+ RpfFile rpf = RpfHelper.OpenRpf(
+ options.RpfPath,
+ options.Verbose,
+ options.Json,
+ scanErrors
+ );
+
+ if (!options.Json)
+ {
+ Console.Error.WriteLine();
+ }
+
+ string normalizedPath = options.FilePath.Replace('\\', '/');
+ RpfFileEntry? found = FindEntry(rpf, normalizedPath, options.Recursive);
+
+ if (found == null)
+ {
+ return Output.ReportError(
+ $"File not found in archive: {options.FilePath}",
+ options.Json,
+ ErrorResult([.. scanErrors])
+ );
+ }
+
+ long size = found.GetFileSize();
+ string ext = Path.GetExtension(found.Name).ToLowerInvariant();
+ string fileType = RpfHelper.GetFileType(found);
+
+ int? resourceVersion = null;
+ long? systemSize = null;
+ long? graphicsSize = null;
+ long? uncompressedSize = null;
+ uint? encryptionType = null;
+
+ if (found is RpfResourceFileEntry rfe)
+ {
+ resourceVersion = rfe.Version;
+ systemSize = rfe.SystemSize;
+ graphicsSize = rfe.GraphicsSize;
+ }
+ else if (found is RpfBinaryFileEntry bfe)
+ {
+ uncompressedSize = bfe.FileUncompressedSize;
+ encryptionType = bfe.EncryptionType;
+ }
+
+ Json.InspectResult result = new()
+ {
+ Success = scanErrors.Count == 0,
+ RpfFile = options.RpfPath,
+ Path = found.Path,
+ Name = found.Name,
+ Size = size,
+ SizeFormatted = options.SizeFormat.ToFormattedString(size),
+ Type = fileType,
+ Extension = ext,
+ NameHash = found.NameHash,
+ ShortNameHash = found.ShortNameHash,
+ ResourceVersion = resourceVersion,
+ SystemSize = systemSize,
+ GraphicsSize = graphicsSize,
+ UncompressedSize = uncompressedSize,
+ EncryptionType = encryptionType,
+ Details = GetDetails(found, ext, options.Verbose),
+ ErrorMessages = [.. scanErrors],
+ };
+
+ if (options.Json)
+ {
+ Console.WriteLine(
+ JsonSerializer.Serialize(result, Output.JsonSerializerOptions)
+ );
+ }
+ else
+ {
+ PrintTextResult(result, options);
+ }
+
+ return scanErrors.Count > 0 ? 1 : 0;
+ }
+ catch (OperationCanceledException) { throw; }
+ catch (Exception ex)
+ {
+ return Output.ReportError(
+ ex.Message,
+ options.Json,
+ ErrorResult([.. scanErrors]),
+ options.Verbose ? ex.StackTrace : null
+ );
+ }
+ }
+
+ private static RpfFileEntry? FindEntry(RpfFile rpf, string normalizedPath, bool recursive)
+ {
+ RpfFileEntry? found = rpf.AllEntries?
+ .OfType()
+ .FirstOrDefault(fe =>
+ fe.Path?.Replace('\\', '/').Equals(normalizedPath, StringComparison.OrdinalIgnoreCase) == true);
+
+ if (found != null)
+ return found;
+
+ if (recursive && rpf.Children != null)
+ {
+ foreach (RpfFile child in rpf.Children)
+ {
+ found = FindEntry(child, normalizedPath, recursive);
+ if (found != null)
+ return found;
+ }
+ }
+
+ return null;
+ }
+
+ private static Json.InspectDetailBase? GetDetails(RpfFileEntry entry, string ext, bool verbose)
+ {
+ try
+ {
+ return ext switch
+ {
+ ".ytd" => GetYtdDetails(entry),
+ ".ydr" => GetYdrDetails(entry),
+ ".ydd" => GetYddDetails(entry),
+ ".yft" => GetYftDetails(entry),
+ ".ymap" => GetYmapDetails(entry),
+ ".ytyp" => GetYtypDetails(entry),
+ ".ybn" => GetYbnDetails(entry),
+ ".awc" => GetAwcDetails(entry),
+ ".gxt2" => GetGxt2Details(entry),
+ _ => null,
+ };
+ }
+ catch (Exception ex)
+ {
+ if (verbose)
+ {
+ Console.Error.WriteLine($"Warning: Failed to read details for {entry.Path}: {ex.Message}");
+ }
+ return null;
+ }
+ }
+
+ private static Json.YtdDetails? GetYtdDetails(RpfFileEntry entry)
+ {
+ YtdFile file = RpfFile.GetFile(entry);
+ if (file?.TextureDict?.Textures?.data_items == null)
+ return null;
+
+ Texture[] textures = file.TextureDict.Textures.data_items;
+ List infos = textures
+ .Where(tex => tex != null)
+ .Select(tex => new Json.TextureInfo
+ {
+ Name = tex.Name ?? "",
+ Width = tex.Width,
+ Height = tex.Height,
+ Format = tex.Format.ToString(),
+ MipLevels = tex.Levels,
+ Stride = tex.Stride,
+ })
+ .ToList();
+
+ return new Json.YtdDetails { TextureCount = infos.Count, Textures = infos };
+ }
+
+ private static Json.YdrDetails? GetYdrDetails(RpfFileEntry entry)
+ {
+ YdrFile file = RpfFile.GetFile(entry);
+ if (file?.Drawable?.DrawableModels == null)
+ return null;
+
+ return new Json.YdrDetails { Lods = GetLodInfos(file.Drawable.DrawableModels) };
+ }
+
+ private static Json.YddDetails? GetYddDetails(RpfFileEntry entry)
+ {
+ YddFile file = RpfFile.GetFile(entry);
+ if (file?.DrawableDict?.Drawables?.data_items == null)
+ return null;
+
+ Drawable?[] drawables = file.DrawableDict.Drawables.data_items;
+ List infos = drawables
+ .Where(d => d != null)
+ .Select(d =>
+ {
+ DrawableGeometry[] geoms = (d!.AllModels ?? [])
+ .Where(m => m?.Geometries != null)
+ .SelectMany(m => m.Geometries)
+ .ToArray();
+ return new Json.DrawableInfo
+ {
+ Name = d.Name ?? "",
+ TotalVertices = geoms.Sum(g => (long)g.VerticesCount),
+ TotalTriangles = geoms.Sum(g => (long)g.TrianglesCount),
+ };
+ })
+ .ToList();
+
+ return new Json.YddDetails { DrawableCount = infos.Count, Drawables = infos };
+ }
+
+ private static Json.YftDetails? GetYftDetails(RpfFileEntry entry)
+ {
+ YftFile file = RpfFile.GetFile(entry);
+ if (file?.Fragment == null)
+ return null;
+
+ List lods = [];
+ if (file.Fragment.Drawable?.DrawableModels != null)
+ {
+ lods = GetLodInfos(file.Fragment.Drawable.DrawableModels);
+ }
+
+ return new Json.YftDetails
+ {
+ Lods = lods,
+ HasDrawableCloth = file.Fragment.DrawableCloth != null,
+ };
+ }
+
+ private static Json.YmapDetails? GetYmapDetails(RpfFileEntry entry)
+ {
+ YmapFile file = RpfFile.GetFile(entry);
+ if (file == null)
+ return null;
+
+ string? entExtMin = null;
+ string? entExtMax = null;
+ string? strExtMin = null;
+ string? strExtMax = null;
+
+ if (file._CMapData.entitiesExtentsMin != default)
+ entExtMin = FormatVector3(file._CMapData.entitiesExtentsMin);
+ if (file._CMapData.entitiesExtentsMax != default)
+ entExtMax = FormatVector3(file._CMapData.entitiesExtentsMax);
+ if (file._CMapData.streamingExtentsMin != default)
+ strExtMin = FormatVector3(file._CMapData.streamingExtentsMin);
+ if (file._CMapData.streamingExtentsMax != default)
+ strExtMax = FormatVector3(file._CMapData.streamingExtentsMax);
+
+ return new Json.YmapDetails
+ {
+ EntityCount = file.AllEntities?.Length ?? 0,
+ CarGeneratorCount = file.CarGenerators?.Length ?? 0,
+ EntitiesExtentsMin = entExtMin,
+ EntitiesExtentsMax = entExtMax,
+ StreamingExtentsMin = strExtMin,
+ StreamingExtentsMax = strExtMax,
+ IsScripted = file.IsScripted,
+ };
+ }
+
+ private static Json.YtypDetails? GetYtypDetails(RpfFileEntry entry)
+ {
+ YtypFile file = RpfFile.GetFile(entry);
+ if (file?.AllArchetypes == null)
+ return null;
+
+ List mloDetails = file.AllArchetypes
+ .OfType()
+ .Select(mlo => new Json.MloInfo
+ {
+ Name = mlo.Hash.ToString(),
+ EntityCount = mlo.entities?.Length ?? 0,
+ RoomCount = mlo.rooms?.Length ?? 0,
+ PortalCount = mlo.portals?.Length ?? 0,
+ })
+ .ToList();
+
+ int mloCount = mloDetails.Count;
+ int timeCount = file.AllArchetypes.OfType().Count();
+ int baseCount = file.AllArchetypes.Length - mloCount - timeCount;
+
+ return new Json.YtypDetails
+ {
+ ArchetypeCount = file.AllArchetypes.Length,
+ BaseCount = baseCount,
+ TimeCount = timeCount,
+ MloCount = mloCount,
+ MloDetails = mloDetails.Count > 0 ? mloDetails : null,
+ };
+ }
+
+ private static Json.YbnDetails? GetYbnDetails(RpfFileEntry entry)
+ {
+ YbnFile file = RpfFile.GetFile(entry);
+ if (file?.Bounds == null)
+ return null;
+
+ int? childCount = null;
+ if (file.Bounds is BoundComposite composite)
+ {
+ childCount = composite.Children?.data_items?.Length ?? 0;
+ }
+
+ return new Json.YbnDetails
+ {
+ BoundsType = file.Bounds.Type.ToString(),
+ ChildCount = childCount,
+ };
+ }
+
+ private static Json.AwcDetails? GetAwcDetails(RpfFileEntry entry)
+ {
+ AwcFile file = RpfFile.GetFile(entry);
+ if (file?.Streams == null)
+ return null;
+
+ List infos = file.Streams
+ .Where(s => s?.StreamInfo != null)
+ .Select(s =>
+ {
+ AwcFormatChunk? fmt = s.FormatChunk;
+ return new Json.AwcStreamInfo
+ {
+ Id = s.StreamInfo.Id,
+ SamplesPerSecond = fmt?.SamplesPerSecond ?? 0,
+ Codec = fmt?.Codec.ToString() ?? "unknown",
+ Samples = fmt?.Samples ?? 0,
+ };
+ })
+ .ToList();
+
+ return new Json.AwcDetails { StreamCount = infos.Count, Streams = infos };
+ }
+
+ private static Json.Gxt2Details? GetGxt2Details(RpfFileEntry entry)
+ {
+ Gxt2File file = RpfFile.GetFile(entry);
+ if (file?.TextEntries == null)
+ return null;
+
+ List infos = file.TextEntries
+ .Take(50)
+ .Select(e =>
+ {
+ string text = e.Text ?? "";
+ if (text.Length > 100)
+ text = text[..100] + "...";
+ return new Json.Gxt2EntryInfo { Hash = $"0x{e.Hash:X8}", Text = text };
+ })
+ .ToList();
+
+ return new Json.Gxt2Details { EntryCount = file.TextEntries.Length, Entries = infos };
+ }
+
+ private static List GetLodInfos(DrawableModelsBlock models)
+ {
+ List lods = [];
+ AddLod(lods, "High", models.High);
+ AddLod(lods, "Med", models.Med);
+ AddLod(lods, "Low", models.Low);
+ AddLod(lods, "VLow", models.VLow);
+ return lods;
+ }
+
+ private static void AddLod(List lods, string level, DrawableModel[]? models)
+ {
+ if (models == null || models.Length == 0)
+ return;
+
+ DrawableGeometry[] allGeoms = models
+ .Where(m => m?.Geometries != null)
+ .SelectMany(m => m.Geometries)
+ .ToArray();
+
+ lods.Add(
+ new Json.LodInfo
+ {
+ Level = level,
+ ModelCount = models.Length,
+ GeometryCount = allGeoms.Length,
+ TotalVertices = allGeoms.Sum(g => (long)g.VerticesCount),
+ TotalTriangles = allGeoms.Sum(g => (long)g.TrianglesCount),
+ }
+ );
+ }
+
+ internal static string FormatVector3(Vector3 v) =>
+ string.Format(CultureInfo.InvariantCulture, "{0:F2}, {1:F2}, {2:F2}", v.X, v.Y, v.Z);
+
+ private static void PrintTextResult(Json.InspectResult result, InspectOptions options)
+ {
+ Console.WriteLine($"Path: {result.Path}");
+ Console.WriteLine($"Name: {result.Name}");
+ Console.WriteLine($"Size: {result.SizeFormatted} ({result.Size} bytes)");
+ Console.WriteLine($"Type: {result.Type}");
+ Console.WriteLine($"Extension: {result.Extension}");
+ Console.WriteLine($"NameHash: 0x{result.NameHash:X8}");
+ Console.WriteLine($"ShortHash: 0x{result.ShortNameHash:X8}");
+
+ if (result.ResourceVersion != null)
+ {
+ Console.WriteLine($"Version: {result.ResourceVersion}");
+ Console.WriteLine($"SystemSize: {result.SystemSize}");
+ Console.WriteLine($"GraphSize: {result.GraphicsSize}");
+ }
+
+ if (result.UncompressedSize != null)
+ {
+ Console.WriteLine(
+ $"Uncompressed: {options.SizeFormat.ToFormattedString(result.UncompressedSize.Value)}"
+ );
+ Console.WriteLine($"Encryption: {result.EncryptionType}");
+ }
+
+ if (result.Details == null)
+ return;
+
+ Console.WriteLine();
+
+ switch (result.Details)
+ {
+ case Json.YtdDetails ytd:
+ Console.WriteLine($"Textures: {ytd.TextureCount}");
+ foreach (Json.TextureInfo tex in ytd.Textures)
+ {
+ Console.WriteLine(
+ $" {tex.Name}: {tex.Width}x{tex.Height} {tex.Format} mips={tex.MipLevels} stride={tex.Stride}"
+ );
+ }
+ break;
+
+ case Json.YdrDetails ydr:
+ PrintLods(ydr.Lods);
+ break;
+
+ case Json.YddDetails ydd:
+ Console.WriteLine($"Drawables: {ydd.DrawableCount}");
+ foreach (Json.DrawableInfo d in ydd.Drawables)
+ {
+ Console.WriteLine(
+ $" {d.Name}: {d.TotalVertices} vertices, {d.TotalTriangles} triangles"
+ );
+ }
+ break;
+
+ case Json.YftDetails yft:
+ PrintLods(yft.Lods);
+ Console.WriteLine($"DrawableCloth: {(yft.HasDrawableCloth ? "yes" : "no")}");
+ break;
+
+ case Json.YmapDetails ymap:
+ Console.WriteLine($"Entities: {ymap.EntityCount}");
+ Console.WriteLine($"Car Generators: {ymap.CarGeneratorCount}");
+ if (ymap.EntitiesExtentsMin != null)
+ {
+ Console.WriteLine(
+ $"Entity Extents: [{ymap.EntitiesExtentsMin}] to [{ymap.EntitiesExtentsMax}]"
+ );
+ }
+ if (ymap.StreamingExtentsMin != null)
+ {
+ Console.WriteLine(
+ $"Stream Extents: [{ymap.StreamingExtentsMin}] to [{ymap.StreamingExtentsMax}]"
+ );
+ }
+ Console.WriteLine($"Scripted: {(ymap.IsScripted ? "yes" : "no")}");
+ break;
+
+ case Json.YtypDetails ytyp:
+ Console.WriteLine($"Archetypes: {ytyp.ArchetypeCount}");
+ Console.WriteLine(
+ $" Base: {ytyp.BaseCount}, Time: {ytyp.TimeCount}, MLO: {ytyp.MloCount}"
+ );
+ if (ytyp.MloDetails != null)
+ {
+ foreach (Json.MloInfo mlo in ytyp.MloDetails)
+ {
+ Console.WriteLine(
+ $" MLO {mlo.Name}: {mlo.EntityCount} entities, {mlo.RoomCount} rooms, {mlo.PortalCount} portals"
+ );
+ }
+ }
+ break;
+
+ case Json.YbnDetails ybn:
+ Console.WriteLine($"Bounds Type: {ybn.BoundsType}");
+ if (ybn.ChildCount != null)
+ Console.WriteLine($"Children: {ybn.ChildCount}");
+ break;
+
+ case Json.AwcDetails awc:
+ Console.WriteLine($"Streams: {awc.StreamCount}");
+ foreach (Json.AwcStreamInfo s in awc.Streams)
+ {
+ Console.WriteLine(
+ $" Stream {s.Id}: {s.Codec} {s.SamplesPerSecond}Hz {s.Samples} samples"
+ );
+ }
+ break;
+
+ case Json.Gxt2Details gxt2:
+ Console.WriteLine($"Text Entries: {gxt2.EntryCount}");
+ foreach (Json.Gxt2EntryInfo e in gxt2.Entries)
+ {
+ Console.WriteLine($" {e.Hash}: {e.Text}");
+ }
+ if (gxt2.EntryCount > gxt2.Entries.Count)
+ Console.WriteLine($" ... and {gxt2.EntryCount - gxt2.Entries.Count} more");
+ break;
+ }
+ }
+
+ private static void PrintLods(IReadOnlyList lods)
+ {
+ foreach (Json.LodInfo lod in lods)
+ {
+ Console.WriteLine(
+ $" {lod.Level}: {lod.ModelCount} models, {lod.GeometryCount} geometries, {lod.TotalVertices} vertices, {lod.TotalTriangles} triangles"
+ );
+ }
+ }
+}
diff --git a/CodeWalker.Cli/Handlers/ListHandler.cs b/CodeWalker.Cli/Handlers/ListHandler.cs
new file mode 100644
index 000000000..dd25af865
--- /dev/null
+++ b/CodeWalker.Cli/Handlers/ListHandler.cs
@@ -0,0 +1,211 @@
+using System;
+using System.Collections.Generic;
+using System.CommandLine;
+using System.IO;
+using System.Text.Json;
+using System.Threading;
+
+using CodeWalker.Cli.Helpers;
+using CodeWalker.GameFiles;
+
+namespace CodeWalker.Cli.Handlers;
+
+internal sealed record ListOptions
+{
+ public required string RpfPath { get; init; }
+ public required string ExePath { get; init; }
+ public required bool Gen9 { get; init; }
+ public required string[] Filters { get; init; }
+ public required bool Verbose { get; init; }
+ public required bool Json { get; init; }
+ public required bool Recursive { get; init; }
+ public required SizeFormat SizeFormat { get; init; }
+}
+
+internal static class ListHandler
+{
+ public static Command CreateCommand(CancellationToken cancellationToken = default)
+ {
+ Option rpfOpt = CliOptions.Rpf();
+ Option exeOpt = CliOptions.Exe();
+ Option gen9Opt = CliOptions.Gen9();
+ Option filterOpt = CliOptions.Filter();
+ Option recursiveOpt = CliOptions.Recursive();
+ Option verboseOpt = CliOptions.Verbose();
+ Option jsonOpt = CliOptions.Json();
+ Option siOpt = CliOptions.Si();
+
+ Command command = new("list", "List contents of an RPF archive")
+ {
+ rpfOpt,
+ exeOpt,
+ gen9Opt,
+ filterOpt,
+ recursiveOpt,
+ verboseOpt,
+ jsonOpt,
+ siOpt,
+ };
+ command.Aliases.Add("l");
+
+ command.SetAction(parseResult =>
+ {
+ ListOptions options = new()
+ {
+ RpfPath = parseResult.GetValue(rpfOpt)?.FullName ?? "",
+ ExePath = parseResult.GetRequiredValue(exeOpt).FullName,
+ Gen9 = parseResult.GetValue(gen9Opt),
+ Filters = Filter.Normalize(parseResult.GetValue(filterOpt)),
+ Verbose = parseResult.GetValue(verboseOpt),
+ Json = parseResult.GetValue(jsonOpt),
+ Recursive = parseResult.GetValue(recursiveOpt),
+ SizeFormat = parseResult.GetValue(siOpt) ? SizeFormat.SI : SizeFormat.IEC,
+ };
+ return Execute(options, cancellationToken);
+ });
+
+ return command;
+ }
+
+ public static int Execute(ListOptions options, CancellationToken cancellationToken = default)
+ {
+ string? initError = RpfHelper.ValidateAndLoadKeys(
+ options.RpfPath,
+ options.ExePath,
+ options.Gen9,
+ options.Json
+ );
+ if (initError != null)
+ {
+ return Output.ReportError(
+ initError,
+ options.Json,
+ ErrorResult([], options)
+ );
+ }
+
+ List scanErrors = [];
+ try
+ {
+ RpfFile rpf = RpfHelper.OpenRpf(
+ options.RpfPath,
+ options.Verbose,
+ options.Json,
+ scanErrors
+ );
+
+ if (!options.Json)
+ Console.Error.WriteLine();
+
+ List<(RpfFile rpf, RpfFileEntry entry)> entries = RpfHelper.CollectFiles(
+ rpf,
+ options.Filters,
+ options.Recursive
+ );
+
+ Json.ListResult result = CollectList(entries, rpf, scanErrors, options, cancellationToken);
+
+ if (options.Json)
+ PrintJsonList(result);
+ else
+ PrintList(result, options, cancellationToken);
+
+ return scanErrors.Count > 0 ? 1 : 0;
+ }
+ catch (OperationCanceledException) { throw; }
+ catch (Exception ex)
+ {
+ return Output.ReportError(
+ ex.Message,
+ options.Json,
+ ErrorResult([.. scanErrors], options),
+ options.Verbose ? ex.StackTrace : null
+ );
+ }
+ }
+
+ internal static Json.ListResult ErrorResult(string[] errorMessages, ListOptions options) =>
+ new()
+ {
+ Success = false,
+ RpfFile = options.RpfPath,
+ TotalFiles = 0,
+ TotalSize = 0,
+ TotalSizeFormatted = "0 B",
+ NestedRpfCount = 0,
+ Files = [],
+ ErrorMessages = errorMessages,
+ };
+
+ internal static Json.ListResult CollectList(
+ List<(RpfFile rpf, RpfFileEntry entry)> entries,
+ RpfFile rpf,
+ List scanErrors,
+ ListOptions options,
+ CancellationToken cancellationToken = default)
+ {
+ long totalSize = 0;
+ List files = [];
+
+ foreach ((RpfFile _, RpfFileEntry fileEntry) in entries)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ long size = fileEntry.GetFileSize();
+ totalSize += size;
+ string ext = Path.GetExtension(fileEntry.Name).ToLowerInvariant();
+
+ files.Add(
+ new Json.FileEntry
+ {
+ Path = fileEntry.Path,
+ Name = fileEntry.Name,
+ Size = size,
+ SizeFormatted = options.SizeFormat.ToFormattedString(size),
+ Type = RpfHelper.GetFileType(fileEntry),
+ Extension = ext,
+ }
+ );
+ }
+
+ return new Json.ListResult
+ {
+ Success = scanErrors.Count == 0,
+ RpfFile = options.RpfPath,
+ TotalFiles = entries.Count,
+ TotalSize = totalSize,
+ TotalSizeFormatted = options.SizeFormat.ToFormattedString(totalSize),
+ NestedRpfCount = rpf.GrandTotalRpfCount,
+ Files = [.. files],
+ ErrorMessages = [.. scanErrors],
+ };
+ }
+
+ internal static void PrintJsonList(Json.ListResult result) =>
+ Console.WriteLine(JsonSerializer.Serialize(result, Output.JsonSerializerOptions));
+
+ internal static void PrintList(
+ Json.ListResult result,
+ ListOptions options,
+ CancellationToken cancellationToken = default)
+ {
+ foreach (Json.FileEntry file in result.Files)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ if (options.Verbose)
+ {
+ string sizeStr = file.SizeFormatted.PadLeft(12);
+ Console.WriteLine($"{sizeStr} {file.Path}");
+ }
+ else
+ {
+ Console.WriteLine(file.Path);
+ }
+ }
+
+ Console.Error.WriteLine();
+ Console.Error.WriteLine(
+ $"Total: {result.TotalFiles} files, {result.TotalSizeFormatted}"
+ );
+ }
+}
diff --git a/CodeWalker.Cli/Handlers/PackHandler.cs b/CodeWalker.Cli/Handlers/PackHandler.cs
new file mode 100644
index 000000000..552ebb1d7
--- /dev/null
+++ b/CodeWalker.Cli/Handlers/PackHandler.cs
@@ -0,0 +1,341 @@
+using System;
+using System.Collections.Generic;
+using System.CommandLine;
+using System.IO;
+using System.Text.Json;
+using System.Threading;
+
+using CodeWalker.Cli.Helpers;
+using CodeWalker.GameFiles;
+
+namespace CodeWalker.Cli.Handlers;
+
+internal sealed record PackOptions
+{
+ public required string InputPath { get; init; }
+ public required string OutputPath { get; init; }
+ public required string ExePath { get; init; }
+ public required bool Verbose { get; init; }
+ public required bool Json { get; init; }
+ public required SizeFormat SizeFormat { get; init; }
+ public required bool Gen9 { get; init; }
+ public required bool Force { get; init; }
+ public required bool Progress { get; init; }
+}
+
+internal static class PackHandler
+{
+ public static Command CreateCommand(CancellationToken cancellationToken = default)
+ {
+ Option exeOpt = CliOptions.Exe();
+ Option verboseOpt = CliOptions.Verbose();
+ Option jsonOpt = CliOptions.Json();
+ Option siOpt = CliOptions.Si();
+
+ Option inputOption = new("--input", "-i")
+ {
+ Description = "Source directory of loose files to pack",
+ Required = true,
+ };
+
+ Option outputOption = new("--output", "-o")
+ {
+ Description = "Output RPF file path",
+ Required = true,
+ };
+
+ Option gen9Option = new("--gen9", "-g")
+ {
+ Description = "Use GTA V Enhanced (Gen9) mode",
+ };
+
+ Option forceOption = new("--force", "-F")
+ {
+ Description = "Overwrite existing output file",
+ };
+
+ Option progressOption = CliOptions.Progress();
+
+ Command command = new("pack", "Create an RPF archive from a directory of loose files")
+ {
+ inputOption,
+ outputOption,
+ gen9Option,
+ forceOption,
+
+ progressOption,
+ exeOpt,
+ verboseOpt,
+ jsonOpt,
+ siOpt
+ };
+
+ command.Aliases.Add("p");
+
+ command.SetAction(parseResult =>
+ {
+ PackOptions options = new()
+ {
+ InputPath = parseResult.GetRequiredValue(inputOption).FullName,
+ OutputPath = parseResult.GetRequiredValue(outputOption).FullName,
+ ExePath = parseResult.GetRequiredValue(exeOpt).FullName,
+ Verbose = parseResult.GetValue(verboseOpt),
+ Json = parseResult.GetValue(jsonOpt),
+ SizeFormat = parseResult.GetValue(siOpt) ? SizeFormat.SI : SizeFormat.IEC,
+ Gen9 = parseResult.GetValue(gen9Option),
+ Force = parseResult.GetValue(forceOption),
+ Progress = parseResult.GetValue(progressOption),
+ };
+ return Execute(options, cancellationToken);
+ });
+
+ return command;
+ }
+
+ public static int Execute(PackOptions options, CancellationToken cancellationToken = default)
+ {
+ Json.PackResult ErrorResult(string[] errorMessages) =>
+ new()
+ {
+ Success = false,
+ InputDir = options.InputPath,
+ OutputFile = options.OutputPath,
+ TotalFiles = 0,
+ TotalDirs = 0,
+ TotalSize = 0,
+ TotalSizeFormatted = "0 B",
+ Errors = 0,
+ ErrorMessages = errorMessages,
+ };
+
+ if (!Directory.Exists(options.InputPath))
+ {
+ return Output.ReportError(
+ $"Input directory not found: {options.InputPath}",
+ options.Json,
+ ErrorResult([])
+ );
+ }
+
+ if (File.Exists(options.OutputPath))
+ {
+ if (!options.Force)
+ {
+ return Output.ReportError(
+ $"Output file already exists: {options.OutputPath}. Use --force to overwrite.",
+ options.Json,
+ ErrorResult([])
+ );
+ }
+ File.Delete(options.OutputPath);
+ }
+
+ string? exeError = RpfHelper.ValidateExeAndLoadKeys(
+ options.ExePath,
+ options.Gen9,
+ options.Json
+ );
+ if (exeError != null)
+ {
+ return Output.ReportError(exeError, options.Json, ErrorResult([]));
+ }
+
+ bool previousGen9 = RpfManager.IsGen9;
+ RpfManager.IsGen9 = options.Gen9;
+ try
+ {
+ // Count files for progress bar
+ string[] allFiles = Directory.GetFiles(
+ options.InputPath,
+ "*",
+ SearchOption.AllDirectories
+ );
+
+ if (!options.Json)
+ {
+ Console.Error.WriteLine(
+ $"Packing {allFiles.Length} files from {options.InputPath}"
+ );
+ }
+
+ string? outputDir = Path.GetDirectoryName(options.OutputPath);
+ if (!string.IsNullOrEmpty(outputDir) && !Directory.Exists(outputDir))
+ {
+ _ = Directory.CreateDirectory(outputDir);
+ }
+
+ string outputFolder = outputDir ?? Directory.GetCurrentDirectory();
+ string outputFileName = Path.GetFileName(options.OutputPath);
+
+ RpfFile rpf = RpfFile.CreateNew(outputFolder, outputFileName);
+
+ if (!options.Json)
+ {
+ Console.Error.WriteLine($"Created RPF: {options.OutputPath}");
+ }
+
+ int totalFiles = 0;
+ int totalDirs = 0;
+ long totalSize = 0;
+ int errors = 0;
+ List errorMessages = [];
+
+ using (
+ ProgressBar progress = new(
+ allFiles.Length,
+ options.Progress && !options.Json
+ )
+ )
+ {
+ AddDirectoryContents(
+ rpf.Root,
+ options.InputPath,
+ options,
+ progress,
+ errorMessages,
+ ref totalFiles,
+ ref totalDirs,
+ ref totalSize,
+ ref errors,
+ cancellationToken
+ );
+ }
+
+ if (!options.Json)
+ {
+ Console.Error.WriteLine("Defragmenting archive...");
+ }
+ RpfFile.Defragment(rpf);
+
+ SizeFormat sizeFormat = options.SizeFormat;
+
+ Json.PackResult result = new()
+ {
+ Success = errors == 0,
+ InputDir = options.InputPath,
+ OutputFile = options.OutputPath,
+ TotalFiles = totalFiles,
+ TotalDirs = totalDirs,
+ TotalSize = totalSize,
+ TotalSizeFormatted = sizeFormat.ToFormattedString(totalSize),
+ Errors = errors,
+ ErrorMessages = [.. errorMessages],
+ };
+
+ if (options.Json)
+ {
+ Console.WriteLine(
+ JsonSerializer.Serialize(result, Output.JsonSerializerOptions)
+ );
+ }
+ else
+ {
+ Console.Error.WriteLine();
+ Console.Error.WriteLine(
+ $"Pack complete: {totalFiles} files, {totalDirs} directories, {sizeFormat.ToFormattedString(totalSize)}, {errors} errors"
+ );
+ }
+
+ return errors > 0 ? 1 : 0;
+ }
+ catch (OperationCanceledException) { throw; }
+ catch (Exception ex)
+ {
+ return Output.ReportError(
+ ex.Message,
+ options.Json,
+ ErrorResult([]),
+ options.Verbose ? ex.StackTrace : null
+ );
+ }
+ finally
+ {
+ RpfManager.IsGen9 = previousGen9;
+ }
+ }
+
+ private static void AddDirectoryContents(
+ RpfDirectoryEntry parentDir,
+ string fsDir,
+ PackOptions options,
+ ProgressBar progress,
+ List errorMessages,
+ ref int totalFiles,
+ ref int totalDirs,
+ ref long totalSize,
+ ref int errors,
+ CancellationToken cancellationToken
+ )
+ {
+ // Add subdirectories first
+ foreach (string subDirPath in Directory.GetDirectories(fsDir))
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ string dirName = Path.GetFileName(subDirPath);
+ try
+ {
+ if (options.Verbose && !options.Json)
+ {
+ Console.Error.WriteLine($"Creating directory: {dirName}");
+ }
+
+ RpfDirectoryEntry newDir = RpfFile.CreateDirectory(parentDir, dirName);
+ totalDirs++;
+
+ AddDirectoryContents(
+ newDir,
+ subDirPath,
+ options,
+ progress,
+ errorMessages,
+ ref totalFiles,
+ ref totalDirs,
+ ref totalSize,
+ ref errors,
+ cancellationToken
+ );
+ }
+ catch (Exception ex)
+ {
+ errors++;
+ string errorMsg = $"Error creating directory {dirName}: {ex.Message}";
+ errorMessages.Add(errorMsg);
+ if (!options.Json)
+ {
+ Console.Error.WriteLine($"Error: {errorMsg}");
+ }
+ }
+ }
+
+ foreach (string filePath in Directory.GetFiles(fsDir))
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ string fileName = Path.GetFileName(filePath);
+ try
+ {
+ byte[] data = File.ReadAllBytes(filePath);
+
+ if (options.Verbose && !options.Json)
+ {
+ Console.Error.WriteLine($"Adding file: {fileName} ({data.Length} bytes)");
+ }
+
+ _ = RpfFile.CreateFile(parentDir, fileName, data);
+ totalFiles++;
+ totalSize += data.Length;
+ progress.Increment(fileName);
+ }
+ catch (Exception ex)
+ {
+ errors++;
+ string errorMsg = $"Error adding file {fileName}: {ex.Message}";
+ errorMessages.Add(errorMsg);
+ if (!options.Json)
+ {
+ Console.Error.WriteLine($"Error: {errorMsg}");
+ }
+ progress.Increment();
+ }
+ }
+ }
+}
diff --git a/CodeWalker.Cli/Handlers/SearchHandler.cs b/CodeWalker.Cli/Handlers/SearchHandler.cs
new file mode 100644
index 000000000..eb0fbb9ee
--- /dev/null
+++ b/CodeWalker.Cli/Handlers/SearchHandler.cs
@@ -0,0 +1,375 @@
+using System;
+using System.Collections.Generic;
+using System.CommandLine;
+using System.IO;
+using System.Text.Json;
+using System.Threading;
+
+using CodeWalker.Cli.Helpers;
+using CodeWalker.GameFiles;
+
+namespace CodeWalker.Cli.Handlers;
+
+internal sealed record SearchOptions
+{
+ public required string RpfPath { get; init; }
+ public required string ExePath { get; init; }
+ public required bool Gen9 { get; init; }
+ public required string[] Filters { get; init; }
+ public required bool Verbose { get; init; }
+ public required bool Json { get; init; }
+ public required bool Recursive { get; init; }
+ public required SizeFormat SizeFormat { get; init; }
+ public required string Pattern { get; init; }
+ public string? DirPath { get; init; }
+}
+
+internal static class SearchHandler
+{
+ public static Command CreateCommand(CancellationToken cancellationToken = default)
+ {
+ Option rpfOpt = CliOptions.Rpf();
+ rpfOpt.Required = false;
+ rpfOpt.Description = "Path to the RPF file; use --dir to search a folder instead";
+ Option exeOpt = CliOptions.Exe();
+ Option gen9Opt = CliOptions.Gen9();
+ Option filterOpt = CliOptions.Filter();
+ Option recursiveOpt = CliOptions.Recursive();
+ Option verboseOpt = CliOptions.Verbose();
+ Option jsonOpt = CliOptions.Json();
+ Option siOpt = CliOptions.Si();
+
+ Option dirOpt = new("--dir", "-D")
+ {
+ Description = "Directory to search; every .rpf below it is searched",
+ };
+
+ Argument patternArg = new("pattern")
+ {
+ Description = "Substring to search for in file paths",
+ };
+
+ Command command = new("search", "Search for files by name or path in one or more RPF archives")
+ {
+ patternArg,
+ rpfOpt,
+ exeOpt,
+ gen9Opt,
+ filterOpt,
+ recursiveOpt,
+ verboseOpt,
+ jsonOpt,
+ siOpt,
+ dirOpt,
+ };
+ command.Aliases.Add("s");
+
+ command.Validators.Add(result =>
+ {
+ bool hasRpf = result.GetValue(rpfOpt) != null;
+ bool hasDir = result.GetValue(dirOpt) != null;
+ if (hasRpf == hasDir)
+ result.AddError("Specify exactly one of --rpf or --dir.");
+ });
+
+ command.SetAction(parseResult =>
+ {
+ SearchOptions options = new()
+ {
+ RpfPath = parseResult.GetValue(rpfOpt)?.FullName ?? "",
+ ExePath = parseResult.GetRequiredValue(exeOpt).FullName,
+ Gen9 = parseResult.GetValue(gen9Opt),
+ Filters = Filter.Normalize(parseResult.GetValue(filterOpt)),
+ Verbose = parseResult.GetValue(verboseOpt),
+ Json = parseResult.GetValue(jsonOpt),
+ Recursive = parseResult.GetValue(recursiveOpt),
+ SizeFormat = parseResult.GetValue(siOpt) ? SizeFormat.SI : SizeFormat.IEC,
+ Pattern = parseResult.GetRequiredValue(patternArg),
+ DirPath = parseResult.GetValue(dirOpt)?.FullName,
+ };
+ return Execute(options, cancellationToken);
+ });
+
+ return command;
+ }
+
+ public static int Execute(SearchOptions options, CancellationToken cancellationToken = default)
+ {
+ if (options.DirPath != null)
+ return ExecuteDirectory(options, cancellationToken);
+
+ string? initError = RpfHelper.ValidateAndLoadKeys(
+ options.RpfPath,
+ options.ExePath,
+ options.Gen9,
+ options.Json
+ );
+ if (initError != null)
+ {
+ return Output.ReportError(
+ initError,
+ options.Json,
+ ErrorResult([], options)
+ );
+ }
+
+ List scanErrors = [];
+ try
+ {
+ RpfFile rpf = RpfHelper.OpenRpf(
+ options.RpfPath,
+ options.Verbose,
+ options.Json,
+ scanErrors
+ );
+
+ if (!options.Json)
+ Console.Error.WriteLine();
+
+ Json.SearchResult result = CollectSearch(rpf, scanErrors, options, cancellationToken: cancellationToken);
+
+ if (options.Json)
+ PrintJsonSearch(result);
+ else
+ PrintSearch(result, options);
+
+ return scanErrors.Count > 0 ? 1 : 0;
+ }
+ catch (OperationCanceledException) { throw; }
+ catch (Exception ex)
+ {
+ return Output.ReportError(
+ ex.Message,
+ options.Json,
+ ErrorResult([.. scanErrors], options),
+ options.Verbose ? ex.StackTrace : null
+ );
+ }
+ }
+
+ internal static int ExecuteDirectory(SearchOptions options, CancellationToken cancellationToken)
+ {
+ if (!Directory.Exists(options.DirPath))
+ {
+ return Output.ReportError(
+ $"Directory not found: {options.DirPath}",
+ options.Json,
+ ErrorResult([], options)
+ );
+ }
+
+ string[] rpfPaths = Directory.GetFiles(options.DirPath!, "*.rpf", SearchOption.AllDirectories);
+ Array.Sort(rpfPaths, StringComparer.OrdinalIgnoreCase);
+
+ if (rpfPaths.Length == 0)
+ {
+ return Output.ReportError(
+ $"No .rpf files found in: {options.DirPath}",
+ options.Json,
+ ErrorResult([], options)
+ );
+ }
+
+ string? initError = RpfHelper.ValidateExeAndLoadKeys(
+ options.ExePath,
+ options.Gen9,
+ options.Json
+ );
+ if (initError != null)
+ {
+ return Output.ReportError(
+ initError,
+ options.Json,
+ ErrorResult([], options)
+ );
+ }
+
+ List allScanErrors = [];
+ List allMatches = [];
+ List rpfFiles = [];
+
+ foreach (string rpfPath in rpfPaths)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ List scanErrors = [];
+ try
+ {
+ RpfFile rpf = RpfHelper.OpenRpf(
+ rpfPath,
+ options.Verbose,
+ options.Json,
+ scanErrors
+ );
+
+ Json.SearchResult partialResult = CollectSearch(rpf, scanErrors, options, archive: rpfPath, cancellationToken: cancellationToken);
+ rpfFiles.Add(rpfPath);
+
+ allMatches.AddRange(partialResult.Matches);
+ allScanErrors.AddRange(scanErrors);
+ }
+ catch (OperationCanceledException)
+ {
+ throw;
+ }
+ catch (Exception ex)
+ {
+ allScanErrors.Add($"{rpfPath}: {ex.Message}");
+ }
+ }
+
+ if (!options.Json)
+ Console.Error.WriteLine();
+
+ Json.SearchResult result = new()
+ {
+ Success = allScanErrors.Count == 0,
+ RpfFile = options.DirPath!,
+ RpfFiles = rpfFiles,
+ Pattern = options.Pattern,
+ MatchCount = allMatches.Count,
+ Matches = allMatches,
+ ErrorMessages = [.. allScanErrors],
+ };
+
+ if (options.Json)
+ PrintJsonSearch(result);
+ else
+ PrintSearch(result, options);
+
+ return allScanErrors.Count > 0 ? 1 : 0;
+ }
+
+ internal static Json.SearchResult ErrorResult(string[] errorMessages, SearchOptions options) =>
+ new()
+ {
+ Success = false,
+ RpfFile = options.RpfPath,
+ RpfFiles = [],
+ Pattern = options.Pattern,
+ MatchCount = 0,
+ Matches = [],
+ ErrorMessages = errorMessages,
+ };
+
+ internal static Json.SearchResult CollectSearch(
+ RpfFile rpf,
+ List scanErrors,
+ SearchOptions options,
+ string? archive = null,
+ CancellationToken cancellationToken = default)
+ {
+ string archivePath = archive ?? options.RpfPath;
+ string normalizedPattern = options.Pattern.Replace('\\', '/');
+
+ List allEntries = [];
+ CollectAllEntries(rpf, options.Recursive, allEntries);
+
+ List matches = [];
+
+ foreach (RpfEntry entry in allEntries)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ if (!Filter.Matches(entry.Path ?? "", options.Filters))
+ continue;
+
+ if (entry.Path?.Replace('\\', '/').Contains(normalizedPattern, StringComparison.OrdinalIgnoreCase) != true)
+ continue;
+
+ long size = 0;
+ string type = "directory";
+ string ext = "";
+
+ if (entry is RpfFileEntry fileEntry)
+ {
+ size = fileEntry.GetFileSize();
+ type = RpfHelper.GetFileType(fileEntry);
+ ext = Path.GetExtension(fileEntry.Name).ToLowerInvariant();
+ }
+
+ matches.Add(new Json.SearchMatch
+ {
+ Archive = archivePath,
+ Path = entry.Path ?? entry.Name ?? "",
+ Name = entry.Name ?? "",
+ Size = size,
+ Type = type,
+ Extension = ext,
+ });
+ }
+
+ return new Json.SearchResult
+ {
+ Success = scanErrors.Count == 0,
+ RpfFile = archivePath,
+ RpfFiles = [archivePath],
+ Pattern = options.Pattern,
+ MatchCount = matches.Count,
+ Matches = matches,
+ ErrorMessages = [.. scanErrors],
+ };
+ }
+
+ internal static void PrintJsonSearch(Json.SearchResult result) =>
+ Console.WriteLine(JsonSerializer.Serialize(result, Output.JsonSerializerOptions));
+
+ internal static void PrintSearch(Json.SearchResult result, SearchOptions options)
+ {
+ bool multiArchive = result.RpfFiles.Count > 1;
+ string? lastArchive = null;
+
+ foreach (Json.SearchMatch match in result.Matches)
+ {
+ if (multiArchive && match.Archive != lastArchive)
+ {
+ if (lastArchive != null)
+ Console.Error.WriteLine();
+ Console.Error.WriteLine($"== {RelativePath(result.RpfFile, match.Archive)} ==");
+ lastArchive = match.Archive;
+ }
+
+ if (options.Verbose)
+ {
+ string sizeStr = options
+ .SizeFormat.ToFormattedString(match.Size)
+ .PadLeft(12);
+ Console.WriteLine($"{sizeStr} {match.Path}");
+ }
+ else
+ {
+ Console.WriteLine(match.Path);
+ }
+ }
+
+ string matchWord = result.MatchCount == 1 ? "match" : "matches";
+ Console.Error.WriteLine();
+ Console.Error.WriteLine(
+ multiArchive
+ ? $"Found {result.MatchCount} {matchWord} across {result.RpfFiles.Count} archive(s) for '{result.Pattern}'"
+ : $"Found {result.MatchCount} {matchWord} for '{result.Pattern}'"
+ );
+ }
+
+ internal static string RelativePath(string basePath, string fullPath)
+ {
+ string normalizedBase = basePath.Replace('\\', '/').TrimEnd('/') + "/";
+ string normalizedFull = fullPath.Replace('\\', '/');
+
+ return normalizedFull.StartsWith(normalizedBase, StringComparison.OrdinalIgnoreCase)
+ ? normalizedFull[normalizedBase.Length..]
+ : Path.GetFileName(fullPath);
+ }
+
+ internal static void CollectAllEntries(RpfFile rpf, bool recursive, List entries)
+ {
+ if (rpf.AllEntries != null)
+ entries.AddRange(rpf.AllEntries);
+
+ if (!recursive || rpf.Children == null)
+ return;
+
+ foreach (RpfFile child in rpf.Children)
+ CollectAllEntries(child, recursive, entries);
+ }
+}
diff --git a/CodeWalker.Cli/Handlers/StatHandler.cs b/CodeWalker.Cli/Handlers/StatHandler.cs
new file mode 100644
index 000000000..b844e461c
--- /dev/null
+++ b/CodeWalker.Cli/Handlers/StatHandler.cs
@@ -0,0 +1,347 @@
+using System;
+using System.Collections.Generic;
+using System.CommandLine;
+using System.Globalization;
+using System.IO;
+using System.Linq;
+using System.Text.Json;
+using System.Threading;
+
+using CodeWalker.Cli.Helpers;
+using CodeWalker.GameFiles;
+
+namespace CodeWalker.Cli.Handlers;
+
+internal sealed record StatOptions
+{
+ public required string RpfPath { get; init; }
+ public required string ExePath { get; init; }
+ public required bool Gen9 { get; init; }
+ public required string[] Filters { get; init; }
+ public required bool Verbose { get; init; }
+ public required bool Json { get; init; }
+ public required bool Recursive { get; init; }
+ public required SizeFormat SizeFormat { get; init; }
+}
+
+internal static class StatHandler
+{
+ public static Command CreateCommand(CancellationToken cancellationToken = default)
+ {
+ Option rpfOpt = CliOptions.Rpf();
+ Option exeOpt = CliOptions.Exe();
+ Option gen9Opt = CliOptions.Gen9();
+ Option filterOpt = CliOptions.Filter();
+ Option recursiveOpt = CliOptions.Recursive();
+ Option verboseOpt = CliOptions.Verbose();
+ Option jsonOpt = CliOptions.Json();
+ Option siOpt = CliOptions.Si();
+
+ Command command = new("stat", "Show aggregate statistics for RPF archive contents")
+ {
+ rpfOpt,
+ exeOpt,
+ gen9Opt,
+ filterOpt,
+ recursiveOpt,
+ verboseOpt,
+ jsonOpt,
+ siOpt,
+ };
+ command.Aliases.Add("S");
+
+ command.SetAction(parseResult =>
+ {
+ StatOptions options = new()
+ {
+ RpfPath = parseResult.GetValue(rpfOpt)?.FullName ?? "",
+ ExePath = parseResult.GetRequiredValue(exeOpt).FullName,
+ Gen9 = parseResult.GetValue(gen9Opt),
+ Filters = Filter.Normalize(parseResult.GetValue(filterOpt)),
+ Verbose = parseResult.GetValue(verboseOpt),
+ Json = parseResult.GetValue(jsonOpt),
+ Recursive = parseResult.GetValue(recursiveOpt),
+ SizeFormat = parseResult.GetValue(siOpt) ? SizeFormat.SI : SizeFormat.IEC,
+ };
+ return Execute(options, cancellationToken);
+ });
+
+ return command;
+ }
+
+ ///
+ /// Validates the archive, collects statistics for the matching entries and prints them.
+ ///
+ public static int Execute(StatOptions options, CancellationToken cancellationToken = default)
+ {
+ string? initError = RpfHelper.ValidateAndLoadKeys(
+ options.RpfPath,
+ options.ExePath,
+ options.Gen9,
+ options.Json
+ );
+ if (initError != null)
+ {
+ return Output.ReportError(
+ initError,
+ options.Json,
+ ErrorResult([], options)
+ );
+ }
+
+ List scanErrors = [];
+ try
+ {
+ RpfFile rpf = RpfHelper.OpenRpf(
+ options.RpfPath,
+ options.Verbose,
+ options.Json,
+ scanErrors
+ );
+
+ if (!options.Json)
+ Console.Error.WriteLine();
+
+ List<(RpfFile rpf, RpfFileEntry entry)> entries = RpfHelper.CollectFiles(
+ rpf,
+ options.Filters,
+ options.Recursive
+ );
+
+ Json.StatResult result = CollectStats(entries, scanErrors, options, cancellationToken);
+
+ if (options.Json)
+ PrintJsonStats(result);
+ else
+ PrintStats(result, options, cancellationToken);
+
+ return scanErrors.Count > 0 ? 1 : 0;
+ }
+ catch (OperationCanceledException) { throw; }
+ catch (Exception ex)
+ {
+ return Output.ReportError(
+ ex.Message,
+ options.Json,
+ ErrorResult([.. scanErrors], options),
+ options.Verbose ? ex.StackTrace : null
+ );
+ }
+ }
+
+ ///
+ /// A failed result carrying the given messages, with every statistic zeroed.
+ ///
+ internal static Json.StatResult ErrorResult(string[] errorMessages, StatOptions options) =>
+ new()
+ {
+ Success = false,
+ RpfFile = options.RpfPath,
+ TotalFiles = 0,
+ TotalSize = 0,
+ TotalSizeFormatted = "0 B",
+ ResourceCount = 0,
+ BinaryCount = 0,
+ CompressedSize = 0,
+ CompressedSizeFormatted = "0 B",
+ UncompressedSize = 0,
+ UncompressedSizeFormatted = "0 B",
+ CompressionRatio = 0,
+ Extensions = [],
+ ErrorMessages = errorMessages
+ };
+
+ ///
+ /// Totals, compression figures and per-extension breakdown for the given entries.
+ ///
+ internal static Json.StatResult CollectStats(
+ List<(RpfFile rpf, RpfFileEntry entry)> entries,
+ List scanErrors,
+ StatOptions options,
+ CancellationToken cancellationToken = default)
+ {
+ int resourceCount = 0;
+ int binaryCount = 0;
+ long totalSize = 0;
+ long compressedSize = 0;
+ long uncompressedSize = 0;
+
+ Dictionary extStats = [];
+
+ foreach ((RpfFile _, RpfFileEntry fileEntry) in entries)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ long size = fileEntry.GetFileSize();
+ totalSize += size;
+
+ string ext = Path.GetExtension(fileEntry.Name).ToLowerInvariant();
+ if (string.IsNullOrEmpty(ext))
+ ext = "(none)";
+
+ if (extStats.TryGetValue(ext, out (int count, long total, long min, long max) stat))
+ {
+ extStats[ext] = (
+ stat.count + 1,
+ stat.total + size,
+ Math.Min(stat.min, size),
+ Math.Max(stat.max, size)
+ );
+ }
+ else
+ {
+ extStats[ext] = (
+ 1,
+ size,
+ size,
+ size
+ );
+ }
+
+ switch (fileEntry)
+ {
+ case RpfResourceFileEntry rfe:
+ resourceCount++;
+ compressedSize += size;
+ uncompressedSize += rfe.SystemSize + rfe.GraphicsSize;
+ break;
+ case RpfBinaryFileEntry bfe:
+ binaryCount++;
+ compressedSize += size;
+ uncompressedSize += bfe.FileUncompressedSize;
+ break;
+ default:
+ throw new InvalidOperationException($"Unknown file entry type: {fileEntry.GetType().FullName}");
+ }
+ }
+
+ double compressionRatio =
+ uncompressedSize > 0 ? (double)compressedSize / uncompressedSize : 0;
+
+ List extensionStats = [
+ .. extStats
+ .OrderByDescending(kv => kv.Value.total)
+ .Select(kv => new Json.ExtensionStat
+ {
+ Extension = kv.Key,
+ Count = kv.Value.count,
+ TotalSize = kv.Value.total,
+ TotalSizeFormatted = options.SizeFormat.ToFormattedString(kv.Value.total),
+ AvgSize = kv.Value.count > 0 ? kv.Value.total / kv.Value.count : 0,
+ AvgSizeFormatted =
+ options.SizeFormat.ToFormattedString(kv.Value.count > 0
+ ? kv.Value.total / kv.Value.count
+ : 0),
+ MinSize = kv.Value.min,
+ MinSizeFormatted = options.SizeFormat.ToFormattedString(kv.Value.min),
+ MaxSize = kv.Value.max,
+ MaxSizeFormatted = options.SizeFormat.ToFormattedString(kv.Value.max)
+ })
+ ];
+
+ return new Json.StatResult
+ {
+ Success = scanErrors.Count == 0,
+ RpfFile = options.RpfPath,
+ TotalFiles = entries.Count,
+ TotalSize = totalSize,
+ TotalSizeFormatted = options.SizeFormat.ToFormattedString(totalSize),
+ ResourceCount = resourceCount,
+ BinaryCount = binaryCount,
+ CompressedSize = compressedSize,
+ CompressedSizeFormatted = options.SizeFormat.ToFormattedString(compressedSize),
+ UncompressedSize = uncompressedSize,
+ UncompressedSizeFormatted = options.SizeFormat.ToFormattedString(uncompressedSize),
+ CompressionRatio = Math.Round(compressionRatio, 4),
+ Extensions = extensionStats,
+ ErrorMessages = [.. scanErrors]
+ };
+ }
+
+ ///
+ /// Prints the statistics as JSON.
+ ///
+ internal static void PrintJsonStats(Json.StatResult result) =>
+ Console.WriteLine(JsonSerializer.Serialize(result, Output.JsonSerializerOptions));
+
+ ///
+ /// Prints the statistics as an aligned table.
+ ///
+ internal static void PrintStats(Json.StatResult result, StatOptions options, CancellationToken cancellationToken = default)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ // Collect all rows for dynamic column sizing
+ string[] headers = ["Extension", "Count", "Total", "Avg", "Min", "Max"];
+ string[][] rows = [
+ .. result.Extensions
+ .Select(ext =>
+ (string[])[
+ ext.Extension,
+ ext.Count.ToString(CultureInfo.InvariantCulture),
+ options.SizeFormat.ToFormattedString(ext.TotalSize),
+ options.SizeFormat.ToFormattedString(ext.AvgSize),
+ options.SizeFormat.ToFormattedString(ext.MinSize),
+ options.SizeFormat.ToFormattedString(ext.MaxSize)
+ ]
+ )
+ ];
+
+ int[] widths = new int[headers.Length];
+ for (int i = 0; i < headers.Length; i++)
+ widths[i] = headers[i].Length;
+
+ foreach (string[] row in rows)
+ for (int i = 0; i < row.Length; i++)
+ widths[i] = Math.Max(widths[i], row[i].Length);
+
+ Console.Write($"+{new string('-', widths[0] + 2)}");
+ for (int i = 1; i < widths.Length; i++)
+ Console.Write($"+{new string('-', widths[i] + 2)}");
+ Console.WriteLine("+");
+
+ // First column left-aligned, the rest right-aligned
+ Console.Write($"| {headers[0].PadRight(widths[0])} ");
+ for (int i = 1; i < headers.Length; i++)
+ Console.Write($"| {headers[i].PadLeft(widths[i])} ");
+ Console.WriteLine("|");
+
+ Console.Write($"+{new string('-', widths[0] + 2)}");
+ for (int i = 1; i < widths.Length; i++)
+ Console.Write($"+{new string('-', widths[i] + 2)}");
+ Console.WriteLine("+");
+
+ foreach (string[] row in rows)
+ {
+ Console.Write($"| {row[0].PadRight(widths[0])} ");
+ for (int i = 1; i < row.Length; i++)
+ Console.Write($"| {row[i].PadLeft(widths[i])} ");
+ Console.WriteLine("|");
+ }
+
+ Console.Write($"+{new string('-', widths[0] + 2)}");
+ for (int i = 1; i < widths.Length; i++)
+ Console.Write($"+{new string('-', widths[i] + 2)}");
+ Console.WriteLine("+");
+
+ Console.Error.WriteLine();
+ Console.Error.WriteLine(
+ $"Total: {result.TotalFiles} files, {options.SizeFormat.ToFormattedString(result.TotalSize)}"
+ );
+ Console.Error.WriteLine($"Types: {result.ResourceCount} resource, {result.BinaryCount} binary");
+
+ if (result.UncompressedSize > 0)
+ {
+ string compressedStr = options.SizeFormat.ToFormattedString(result.CompressedSize);
+ string uncompressedStr = options.SizeFormat.ToFormattedString(result.UncompressedSize);
+ Console.Error.WriteLine(
+ string.Format(
+ CultureInfo.InvariantCulture,
+ "Compression: {0} / {1} ({2:P1} of original)",
+ compressedStr,
+ uncompressedStr,
+ result.CompressionRatio
+ )
+ );
+ }
+ }
+}
diff --git a/CodeWalker.Cli/Handlers/TreeHandler.cs b/CodeWalker.Cli/Handlers/TreeHandler.cs
new file mode 100644
index 000000000..a347f9086
--- /dev/null
+++ b/CodeWalker.Cli/Handlers/TreeHandler.cs
@@ -0,0 +1,376 @@
+using System;
+using System.Collections.Generic;
+using System.CommandLine;
+using System.Diagnostics.CodeAnalysis;
+using System.IO;
+using System.Linq;
+using System.Text.Json;
+using System.Threading;
+
+using CodeWalker.Cli.Helpers;
+using CodeWalker.GameFiles;
+
+namespace CodeWalker.Cli.Handlers;
+
+[ExcludeFromCodeCoverage]
+internal sealed record TreeOptions
+{
+ public required string RpfPath { get; init; }
+ public required string ExePath { get; init; }
+ public required bool Gen9 { get; init; }
+ public required string[] Filters { get; init; }
+ public required bool Verbose { get; init; }
+ public required bool Json { get; init; }
+ public required bool Recursive { get; init; }
+ public required SizeFormat SizeFormat { get; init; }
+ public required int Depth { get; init; }
+}
+
+internal readonly record struct ChildItem(string Name, bool IsDir, RpfEntry Entry, RpfFile? ChildRpf, RpfFileEntry? ArchiveEntry = null);
+
+internal static class TreeHandler
+{
+ public static Command CreateCommand(CancellationToken cancellationToken = default)
+ {
+ Option rpfOpt = CliOptions.Rpf();
+ Option exeOpt = CliOptions.Exe();
+ Option gen9Opt = CliOptions.Gen9();
+ Option filterOpt = CliOptions.Filter();
+ Option recursiveOpt = CliOptions.Recursive();
+ Option verboseOpt = CliOptions.Verbose();
+ Option jsonOpt = CliOptions.Json();
+ Option siOpt = CliOptions.Si();
+ Option depthOption = new("--depth", "-d")
+ {
+ Description = "Maximum depth to display (default: unlimited)",
+ DefaultValueFactory = _ => -1
+ };
+
+ depthOption.Validators.Add(result =>
+ {
+ if (result.GetValue(depthOption) < -1)
+ result.AddError("--depth must be -1 (unlimited) or a non-negative integer.");
+ });
+
+ Command command = new("tree", "Display a visual tree of the RPF directory structure")
+ {
+ rpfOpt,
+ exeOpt,
+ gen9Opt,
+ filterOpt,
+ recursiveOpt,
+ verboseOpt,
+ jsonOpt,
+ siOpt,
+ depthOption,
+ };
+ command.Aliases.Add("t");
+
+ command.SetAction(parseResult =>
+ {
+ TreeOptions options = new()
+ {
+ RpfPath = parseResult.GetValue(rpfOpt)?.FullName ?? "",
+ ExePath = parseResult.GetRequiredValue(exeOpt).FullName,
+ Gen9 = parseResult.GetValue(gen9Opt),
+ Filters = Filter.Normalize(parseResult.GetValue(filterOpt)),
+ Verbose = parseResult.GetValue(verboseOpt),
+ Json = parseResult.GetValue(jsonOpt),
+ Recursive = parseResult.GetValue(recursiveOpt),
+ SizeFormat = parseResult.GetValue(siOpt) ? SizeFormat.SI : SizeFormat.IEC,
+ Depth = parseResult.GetValue(depthOption),
+ };
+ return Execute(options, cancellationToken);
+ });
+
+ return command;
+ }
+
+ public static int Execute(TreeOptions options, CancellationToken cancellationToken = default)
+ {
+ string? initError = RpfHelper.ValidateAndLoadKeys(
+ options.RpfPath,
+ options.ExePath,
+ options.Gen9,
+ options.Json
+ );
+ if (initError != null)
+ {
+ return Output.ReportError(
+ initError,
+ options.Json,
+ ErrorResult([], options)
+ );
+ }
+
+ List scanErrors = [];
+ try
+ {
+ RpfFile rpf = RpfHelper.OpenRpf(
+ options.RpfPath,
+ options.Verbose,
+ options.Json,
+ scanErrors
+ );
+
+ int totalFiles = 0;
+ int totalDirs = 0;
+
+ Json.TreeNode rootNode = BuildTreeNode(
+ rpf.Root,
+ rpf,
+ options,
+ 0,
+ ref totalFiles,
+ ref totalDirs,
+ cancellationToken
+ ) with
+ { Name = Path.GetFileName(options.RpfPath) + "/" };
+
+ if (options.Json)
+ PrintJsonTree(rootNode, totalFiles, totalDirs, scanErrors, options);
+ else
+ PrintTree(rootNode, totalFiles, totalDirs, options, cancellationToken);
+
+ return scanErrors.Count > 0 ? 1 : 0;
+ }
+ catch (OperationCanceledException) { throw; }
+ catch (Exception ex)
+ {
+ return Output.ReportError(
+ ex.Message,
+ options.Json,
+ ErrorResult([.. scanErrors], options),
+ options.Verbose ? ex.StackTrace : null
+ );
+ }
+ }
+
+ internal static Json.TreeResult ErrorResult(string[] errorMessages, TreeOptions options) =>
+ new()
+ {
+ Success = false,
+ RpfFile = options.RpfPath,
+ TotalFiles = 0,
+ TotalDirs = 0,
+ Root = null,
+ ErrorMessages = errorMessages
+ };
+
+ internal static List CollectChildren(RpfDirectoryEntry dir, RpfFile rpf, TreeOptions options)
+ {
+ List items = [];
+ HashSet expandedRpfs = new(StringComparer.Ordinal);
+
+ if (dir.Directories != null)
+ {
+ foreach (RpfDirectoryEntry subDir in dir.Directories)
+ items.Add(new ChildItem(subDir.Name, true, subDir, null));
+ }
+
+ // Add nested RPFs as expandable directories if recursive
+ if (options.Recursive && dir.Files != null && rpf.Children != null)
+ {
+ foreach (RpfFileEntry fileEntry in dir.Files)
+ {
+ if (!fileEntry.NameLower.EndsWith(".rpf", StringComparison.Ordinal))
+ continue;
+
+ RpfFile? child = rpf.Children
+ .FirstOrDefault(c => c.Name == fileEntry.Name && c.Root != null);
+
+ if (child != null)
+ {
+ items.Add(new ChildItem(fileEntry.Name, true, child.Root, child, fileEntry));
+ _ = expandedRpfs.Add(fileEntry.Name);
+ }
+ }
+ }
+
+ if (dir.Files == null)
+ return items;
+
+ // Add files (matching filters, skip RPFs already expanded as directories)
+ items.AddRange(dir.Files
+ .Where(fe =>
+ !expandedRpfs.Contains(fe.Name)
+ && Filter.Matches(fe.Path, options.Filters)
+ )
+ .Select(fe => new ChildItem(fe.Name, false, fe, null)));
+
+ return items;
+ }
+
+ internal static Json.TreeNode BuildTreeNode(
+ RpfDirectoryEntry dir,
+ RpfFile rpf,
+ TreeOptions options,
+ int depth,
+ ref int totalFiles,
+ ref int totalDirs,
+ CancellationToken cancellationToken
+ )
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ List children = [];
+
+ if (options.Depth < 0 || depth < options.Depth)
+ {
+ foreach (ChildItem item in CollectChildren(dir, rpf, options))
+ {
+ if (item.IsDir)
+ {
+ if (item.Entry is not RpfDirectoryEntry subDir)
+ continue;
+
+ Json.TreeNode dirNode = BuildTreeNode(
+ subDir,
+ item.ChildRpf ?? rpf,
+ options,
+ depth + 1,
+ ref totalFiles,
+ ref totalDirs,
+ cancellationToken
+ );
+
+ // Prune empty directories when filters are active
+ if (options.Filters.Length > 0
+ && (dirNode.Children == null || dirNode.Children.Count == 0))
+ {
+ continue;
+ }
+
+ totalDirs++;
+ if (item.ArchiveEntry != null)
+ {
+ long archiveSize = item.ArchiveEntry.GetFileSize();
+ children.Add(dirNode with
+ {
+ Name = item.Name,
+ Size = archiveSize,
+ SizeFormatted = options.SizeFormat.ToFormattedString(archiveSize),
+ FileType = RpfHelper.GetFileType(item.ArchiveEntry)
+ });
+ }
+ else
+ {
+ children.Add(dirNode);
+ }
+ }
+ else
+ {
+ totalFiles++;
+ long? size = null;
+ string? sizeFormatted = null;
+ string? fileType = null;
+ int? version = null;
+
+ if (item.Entry is RpfFileEntry fileEntry)
+ {
+ size = fileEntry.GetFileSize();
+ sizeFormatted = options.SizeFormat.ToFormattedString(size.Value);
+ fileType = RpfHelper.GetFileType(fileEntry);
+ if (fileEntry is RpfResourceFileEntry rfe)
+ version = rfe.Version;
+ }
+
+ children.Add(
+ new Json.TreeNode
+ {
+ Name = item.Name,
+ Path = item.Entry.Path,
+ Type = "file",
+ Size = size,
+ SizeFormatted = sizeFormatted,
+ FileType = fileType,
+ Version = version
+ }
+ );
+ }
+ }
+ }
+
+ return new Json.TreeNode
+ {
+ Name = dir.Name ?? Path.GetFileName(rpf.FilePath),
+ Path = dir.Path ?? rpf.Path,
+ Type = "dir",
+ Children = children
+ };
+ }
+
+ internal static void PrintTree(
+ Json.TreeNode root,
+ int totalFiles,
+ int totalDirs,
+ TreeOptions options,
+ CancellationToken cancellationToken)
+ {
+ Console.WriteLine(root.Name);
+ PrintTreeChildren(root, "", options, cancellationToken);
+ Console.Error.WriteLine();
+ Console.Error.WriteLine($"{totalDirs} directories, {totalFiles} files");
+ }
+
+ internal static void PrintTreeChildren(
+ Json.TreeNode node,
+ string prefix,
+ TreeOptions options,
+ CancellationToken cancellationToken)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ if (node.Children == null)
+ return;
+
+ for (int i = 0; i < node.Children.Count; i++)
+ {
+ bool isLast = i == node.Children.Count - 1;
+ string connector = isLast ? "\u2514\u2500\u2500 " : "\u251c\u2500\u2500 ";
+ string childPrefix = prefix + (isLast ? " " : "\u2502 ");
+
+ Json.TreeNode child = node.Children[i];
+
+ if (child.Type == "dir")
+ {
+ if (options.Verbose && child.SizeFormatted != null)
+ Console.WriteLine($"{prefix}{connector}{child.Name}/ <{child.SizeFormatted}, {child.FileType}>");
+ else
+ Console.WriteLine($"{prefix}{connector}{child.Name}/");
+ PrintTreeChildren(child, childPrefix, options, cancellationToken);
+ }
+ else if (options.Verbose && child.SizeFormatted != null)
+ {
+ string versionStr = child.Version != null ? $" v{child.Version}" : "";
+ Console.WriteLine(
+ $"{prefix}{connector}{child.Name} ({child.SizeFormatted}, {child.FileType}{versionStr})"
+ );
+ }
+ else
+ {
+ Console.WriteLine($"{prefix}{connector}{child.Name}");
+ }
+ }
+ }
+
+ internal static void PrintJsonTree(
+ Json.TreeNode root,
+ int totalFiles,
+ int totalDirs,
+ List scanErrors,
+ TreeOptions options)
+ {
+ Json.TreeResult result = new()
+ {
+ Success = scanErrors.Count == 0,
+ RpfFile = options.RpfPath,
+ TotalFiles = totalFiles,
+ TotalDirs = totalDirs,
+ Root = root,
+ ErrorMessages = [.. scanErrors]
+ };
+
+ Console.WriteLine(
+ JsonSerializer.Serialize(result, Output.JsonSerializerOptions)
+ );
+ }
+}
diff --git a/CodeWalker.Cli/Handlers/ValidateHandler.cs b/CodeWalker.Cli/Handlers/ValidateHandler.cs
new file mode 100644
index 000000000..37b1df027
--- /dev/null
+++ b/CodeWalker.Cli/Handlers/ValidateHandler.cs
@@ -0,0 +1,341 @@
+using System;
+using System.Collections.Generic;
+using System.CommandLine;
+using System.IO;
+using System.Linq;
+using System.Text.Json;
+using System.Threading;
+using System.Threading.Tasks;
+
+using CodeWalker.Cli.Helpers;
+using CodeWalker.GameFiles;
+
+namespace CodeWalker.Cli.Handlers;
+
+internal sealed record ValidateOptions
+{
+ public required string RpfPath { get; init; }
+ public required string ExePath { get; init; }
+ public required bool Gen9 { get; init; }
+ public required string[] Filters { get; init; }
+ public required bool Verbose { get; init; }
+ public required bool Json { get; init; }
+ public required bool Recursive { get; init; }
+ public required int Threads { get; init; }
+ public required SizeFormat SizeFormat { get; init; }
+ public required bool Progress { get; init; }
+}
+
+internal static class ValidateHandler
+{
+ public static Command CreateCommand(CancellationToken cancellationToken = default)
+ {
+ Option rpfOpt = CliOptions.Rpf();
+ Option exeOpt = CliOptions.Exe();
+ Option gen9Opt = CliOptions.Gen9();
+ Option filterOpt = CliOptions.Filter();
+ Option recursiveOpt = CliOptions.Recursive();
+ Option verboseOpt = CliOptions.Verbose();
+ Option jsonOpt = CliOptions.Json();
+ Option siOpt = CliOptions.Si();
+ Option threadsOpt = CliOptions.Threads();
+ Option progressOpt = CliOptions.Progress();
+
+ Command command = new("validate", "Validate game file integrity by parsing RPF contents")
+ {
+ rpfOpt,
+ exeOpt,
+ gen9Opt,
+ filterOpt,
+ recursiveOpt,
+ verboseOpt,
+ jsonOpt,
+ siOpt,
+ threadsOpt,
+ progressOpt,
+ };
+ command.Aliases.Add("val");
+
+ command.SetAction(parseResult =>
+ {
+ ValidateOptions options = new()
+ {
+ RpfPath = parseResult.GetRequiredValue(rpfOpt).FullName,
+ ExePath = parseResult.GetRequiredValue(exeOpt).FullName,
+ Gen9 = parseResult.GetValue(gen9Opt),
+ Filters = Filter.Normalize(parseResult.GetValue(filterOpt)),
+ Verbose = parseResult.GetValue(verboseOpt),
+ Json = parseResult.GetValue(jsonOpt),
+ Recursive = parseResult.GetValue(recursiveOpt),
+ Threads = parseResult.GetValue(threadsOpt),
+ SizeFormat = parseResult.GetValue(siOpt) ? SizeFormat.SI : SizeFormat.IEC,
+ Progress = parseResult.GetValue(progressOpt),
+ };
+ return Execute(options, cancellationToken);
+ });
+
+ return command;
+ }
+
+ public static int Execute(ValidateOptions options, CancellationToken cancellationToken = default)
+ {
+ Json.ValidateResult ErrorResult(string[] errorMessages) =>
+ new()
+ {
+ Success = false,
+ RpfFile = options.RpfPath,
+ TotalFiles = 0,
+ Valid = 0,
+ Warnings = 0,
+ Errors = 0,
+ Skipped = 0,
+ Files = [],
+ ErrorMessages = errorMessages,
+ };
+
+ string? initError = RpfHelper.ValidateAndLoadKeys(
+ options.RpfPath,
+ options.ExePath,
+ options.Gen9,
+ options.Json
+ );
+ if (initError != null)
+ {
+ return Output.ReportError(initError, options.Json, ErrorResult([]));
+ }
+
+ List scanErrors = [];
+ try
+ {
+ RpfFile rpf = RpfHelper.OpenRpf(
+ options.RpfPath,
+ options.Verbose,
+ options.Json,
+ scanErrors
+ );
+
+ if (!options.Json)
+ {
+ Console.Error.WriteLine();
+ }
+
+ List<(RpfFile rpf, RpfFileEntry entry)> entries = RpfHelper.CollectFiles(
+ rpf,
+ options.Filters,
+ options.Recursive
+ );
+
+ Json.ValidateFileEntry?[] results = new Json.ValidateFileEntry?[entries.Count];
+ object consoleLock = new();
+
+ using (ProgressBar progress = new(entries.Count, options.Progress && !options.Json))
+ {
+ _ = Parallel.For(
+ 0,
+ entries.Count,
+ new ParallelOptions { MaxDegreeOfParallelism = options.Threads, CancellationToken = cancellationToken },
+ i =>
+ {
+ (_, RpfFileEntry fileEntry) = entries[i];
+ string ext = Path.GetExtension(fileEntry.Name).ToLowerInvariant();
+
+ try
+ {
+ (string status, string? message) = ValidateFile(
+ fileEntry,
+ ext
+ );
+
+ results[i] = new Json.ValidateFileEntry
+ {
+ Path = fileEntry.Path,
+ Name = fileEntry.Name,
+ Status = status,
+ Message = message,
+ };
+
+ if (
+ !options.Json
+ && !options.Progress
+ && (status == "warning" || status == "error")
+ )
+ {
+ lock (consoleLock)
+ {
+ Console.Error.WriteLine(
+ $"[{status.ToUpperInvariant()}] {fileEntry.Path}: {message}"
+ );
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ results[i] = new Json.ValidateFileEntry
+ {
+ Path = fileEntry.Path,
+ Name = fileEntry.Name,
+ Status = "error",
+ Message = ex.Message,
+ };
+
+ if (!options.Json && !options.Progress)
+ {
+ lock (consoleLock)
+ {
+ Console.Error.WriteLine(
+ $"[ERROR] {fileEntry.Path}: {ex.Message}"
+ );
+ }
+ }
+ }
+
+ progress.Increment(fileEntry.Path);
+ }
+ );
+ }
+
+ List nonNull = results.OfType().ToList();
+ int valid = nonNull.Count(e => e.Status == "valid");
+ int warnings = nonNull.Count(e => e.Status == "warning");
+ int errors = nonNull.Count(e => e.Status == "error");
+ int skipped = nonNull.Count(e => e.Status == "skipped");
+
+ // In verbose mode or JSON, include all; otherwise only warnings/errors
+ List files = (options.Json || options.Verbose)
+ ? nonNull
+ : nonNull.Where(e => e.Status is "warning" or "error").ToList();
+
+ Json.ValidateResult result = new()
+ {
+ Success = errors == 0 && scanErrors.Count == 0,
+ RpfFile = options.RpfPath,
+ TotalFiles = entries.Count,
+ Valid = valid,
+ Warnings = warnings,
+ Errors = errors,
+ Skipped = skipped,
+ Files = files,
+ ErrorMessages = [.. scanErrors],
+ };
+
+ if (options.Json)
+ {
+ Console.WriteLine(
+ JsonSerializer.Serialize(result, Output.JsonSerializerOptions)
+ );
+ }
+ else
+ {
+ Console.Error.WriteLine();
+ Console.Error.WriteLine(
+ $"Validation complete: {valid} valid, {warnings} warnings, {errors} errors, {skipped} skipped"
+ );
+ }
+
+ return (errors > 0 || scanErrors.Count > 0) ? 1 : 0;
+ }
+ catch (OperationCanceledException) { throw; }
+ catch (Exception ex)
+ {
+ return Output.ReportError(
+ ex.Message,
+ options.Json,
+ ErrorResult([.. scanErrors]),
+ options.Verbose ? ex.StackTrace : null
+ );
+ }
+ }
+
+ private static (string status, string? message) ValidateFile(
+ RpfFileEntry fileEntry,
+ string ext
+ )
+ {
+ switch (ext)
+ {
+ case ".ytd":
+ {
+ YtdFile file = RpfFile.GetFile(fileEntry);
+ if (file == null)
+ return ("error", "Failed to load YTD file");
+ if (file.TextureDict?.Textures?.data_items == null || file.TextureDict.Textures.data_items.Length == 0)
+ return ("warning", "Texture dictionary is empty");
+ return ("valid", null);
+ }
+ case ".ydr":
+ {
+ YdrFile file = RpfFile.GetFile(fileEntry);
+ if (file == null)
+ return ("error", "Failed to load YDR file");
+ if (file.Drawable == null)
+ return ("error", "Drawable is null");
+ return ("valid", null);
+ }
+ case ".ydd":
+ {
+ YddFile file = RpfFile.GetFile(fileEntry);
+ if (file == null)
+ return ("error", "Failed to load YDD file");
+ if (file.DrawableDict == null)
+ return ("error", "DrawableDict is null");
+ return ("valid", null);
+ }
+ case ".yft":
+ {
+ YftFile file = RpfFile.GetFile(fileEntry);
+ if (file == null)
+ return ("error", "Failed to load YFT file");
+ if (file.Fragment == null)
+ return ("error", "Fragment is null");
+ return ("valid", null);
+ }
+ case ".ymap":
+ {
+ YmapFile file = RpfFile.GetFile(fileEntry);
+ if (file == null)
+ return ("error", "Failed to load YMAP file");
+ if (file.AllEntities == null || file.AllEntities.Length == 0)
+ return ("warning", "No entities found");
+ return ("valid", null);
+ }
+ case ".ytyp":
+ {
+ YtypFile file = RpfFile.GetFile(fileEntry);
+ if (file == null)
+ return ("error", "Failed to load YTYP file");
+ if (file.AllArchetypes == null || file.AllArchetypes.Length == 0)
+ return ("warning", "No archetypes found");
+ return ("valid", null);
+ }
+ case ".ybn":
+ {
+ YbnFile file = RpfFile.GetFile(fileEntry);
+ if (file == null)
+ return ("error", "Failed to load YBN file");
+ if (file.Bounds == null)
+ return ("error", "Bounds is null");
+ return ("valid", null);
+ }
+ case ".awc":
+ {
+ AwcFile file = RpfFile.GetFile(fileEntry);
+ if (file == null)
+ return ("error", "Failed to load AWC file");
+ if (file.Streams == null || file.Streams.Length == 0)
+ return ("warning", "No audio streams found");
+ return ("valid", null);
+ }
+ case ".gxt2":
+ {
+ Gxt2File file = RpfFile.GetFile(fileEntry);
+ if (file == null)
+ return ("error", "Failed to load GXT2 file");
+ if (file.TextEntries == null || file.TextEntries.Length == 0)
+ return ("warning", "No text entries found");
+ return ("valid", null);
+ }
+ default:
+ return ("skipped", null);
+ }
+ }
+}
diff --git a/CodeWalker.Cli/Helpers/CliOptions.cs b/CodeWalker.Cli/Helpers/CliOptions.cs
new file mode 100644
index 000000000..31ca1d91e
--- /dev/null
+++ b/CodeWalker.Cli/Helpers/CliOptions.cs
@@ -0,0 +1,88 @@
+using System;
+using System.CommandLine;
+using System.IO;
+
+namespace CodeWalker.Cli.Helpers;
+
+internal static class CliOptions
+{
+ public static Option