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 Rpf() => new("--rpf", "-r") + { + Description = "Path to the RPF file", + Required = true, + }; + + public static Option Exe(bool required = true) => new("--exe", "-e") + { + Description = "Path to the GTA V installation directory (containing GTA5.exe)", + Required = required, + }; + + public static Option Gen9() => new("--gen9", "-g") + { + Description = "Use GTA V Enhanced (Gen9) mode", + }; + + public static Option Filter() => new("--filter", "-f") + { + Description = "Filter files by glob patterns (e.g. *.ydd); can be specified multiple times", + AllowMultipleArgumentsPerToken = true, + }; + + public static Option Recursive() => new("--recursive", "-R") + { + Description = "Process nested RPF archives", + }; + + public static Option Verbose() => new("--verbose", "-v") + { + Description = "Show verbose output", + }; + + public static Option Json() => new("--json") + { + Description = "Output results in JSON format for scripting", + }; + + public static Option Si() => new("--si") + { + Description = "Use SI units (1000-based: KB, MB) instead of IEC (1024-based: KiB, MiB)", + }; + + public static Option Threads() + { + Option opt = new("--threads", "-t") + { + Description = "Number of threads for parallel processing", + DefaultValueFactory = _ => Environment.ProcessorCount, + }; + opt.Validators.Add(result => + { + if (result.GetValue(opt) < 1) + result.AddError("--threads must be at least 1."); + }); + return opt; + } + + public static Option OutputDir() => new("--output", "-o") + { + Description = "Output directory", + // Relative so help shows "." rather than whichever directory help was run from. + DefaultValueFactory = _ => new DirectoryInfo("."), + }; + + public static Option DryRun() => new("--dry-run", "-n") + { + Description = "Show what would be done without writing any files", + }; + + public static Option NoOverwrite() => new("--no-overwrite") + { + Description = "Skip existing output files instead of overwriting", + }; + + public static Option Progress() => new("--progress", "-P") + { + Description = "Show a progress bar", + }; +} diff --git a/CodeWalker.Cli/Helpers/ExportPipeline.cs b/CodeWalker.Cli/Helpers/ExportPipeline.cs new file mode 100644 index 000000000..efd65a5e8 --- /dev/null +++ b/CodeWalker.Cli/Helpers/ExportPipeline.cs @@ -0,0 +1,337 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; + +using CodeWalker.GameFiles; + +namespace CodeWalker.Cli.Helpers; + +/// +/// Delegate for processing a single file entry during export. +/// Returns an on success (status = "exported", "unsupported", "skipped"), +/// or a tuple with a null entry and error string on failure. +/// +/// The RPF file entry to process. +/// The raw file data extracted from the RPF. +/// The output directory for this file (includes relative path). +/// When true, skip files that already exist at the output path. +internal delegate (Json.ExportFileEntry? entry, string? error) ExportFileProcessor( + RpfFileEntry fileEntry, + byte[] data, + string fileOutputDir, + bool noOverwrite +); + +internal sealed record ExportOptions +{ + 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 ExportPipeline +{ + internal readonly record struct ExportAggregation + { + public required int Exported { get; init; } + public required int Skipped { get; init; } + public required int Errors { get; init; } + public required IReadOnlyList Files { get; init; } + public required IReadOnlyList ErrorMessages { get; init; } + } + + internal static (Json.ExportFileEntry? entry, string? error) ProcessSingleFile( + RpfFileEntry fileEntry, + byte[]? data, + string outputDir, + bool dryRun, + bool noOverwrite, + ExportFileProcessor processor + ) + { + // RPF entry paths are separated with backslashes. Path.GetDirectoryName only + // recognises the platform separator, so they must be translated first or the + // whole path reads as a bare file name and every export lands in the root. + string relativePath = + Path.GetDirectoryName(fileEntry.Path.Replace('\\', Path.DirectorySeparatorChar)) + ?? ""; + + string fileOutputDir = Path.Combine(outputDir, relativePath); + + if (dryRun) + { + return ( + new Json.ExportFileEntry + { + Path = fileEntry.Path, + Name = fileEntry.Name, + OutputFiles = 0, + Status = "dry_run", + }, + null + ); + } + + if (data == null) + return (null, $"Failed to extract: {fileEntry.Path}"); + + (Json.ExportFileEntry? entry, string? error) = processor( + fileEntry, + data, + fileOutputDir, + noOverwrite + ); + + if (error != null) + return (entry, error); + + if (entry != null) + return (entry, null); + + return (null, $"No result for: {fileEntry.Path}"); + } + + internal static ExportAggregation AggregateResults( + (Json.ExportFileEntry? jsonEntry, string? errorMessage)[] results, + IReadOnlyList scanErrors + ) + { + int exported = 0; + int skipped = 0; + int errors = 0; + List files = []; + List errorMessages = [.. scanErrors]; + + foreach ((Json.ExportFileEntry? jsonEntry, string? errorMessage) in results) + { + if (errorMessage == null && jsonEntry?.Status is "exported" or "dry_run") + exported++; + + if (errorMessage == null && jsonEntry?.Status is "unsupported" or "skipped") + skipped++; + + if (jsonEntry != null) + files.Add(jsonEntry); + + if (errorMessage != null) + { + errors++; + errorMessages.Add(errorMessage); + } + else if (jsonEntry?.Status == "error") + { + errors++; + errorMessages.Add($"Error processing: {jsonEntry.Path}"); + } + } + + return new ExportAggregation + { + Exported = exported, + Skipped = skipped, + Errors = errors, + Files = files, + ErrorMessages = errorMessages, + }; + } + + public static int Execute( + ExportOptions options, + string format, + string summaryLabel, + ExportFileProcessor processor, + CancellationToken cancellationToken = default + ) + { + Json.ExportResult ErrorResult(string[] errorMessages) => + new() + { + Success = false, + RpfFile = options.RpfPath, + OutputDir = options.OutputPath, + Format = format, + TotalFiles = 0, + Exported = 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([])); + + try + { + List scanErrors = []; + 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 exported"); + + string outputDir = options.OutputPath; + + if (!options.DryRun && !Directory.Exists(outputDir)) + _ = Directory.CreateDirectory(outputDir); + + List<(RpfFile rpf, RpfFileEntry entry)> filesToExport = RpfHelper.CollectFiles( + rpf, + options.Filters, + options.Recursive + ); + + (Json.ExportFileEntry? jsonEntry, string? errorMessage)[] results = + new (Json.ExportFileEntry?, string?)[filesToExport.Count]; + + object consoleLock = new(); + + using ( + ProgressBar progress = new( + filesToExport.Count, + options is { Progress: true, Json: false } + ) + ) + { + _ = Parallel.For( + 0, + filesToExport.Count, + new ParallelOptions { MaxDegreeOfParallelism = options.Threads, CancellationToken = cancellationToken }, + i => + { + (RpfFile sourceRpf, RpfFileEntry fileEntry) = filesToExport[i]; + try + { + byte[]? data = options.DryRun + ? null + : sourceRpf.ExtractFile(fileEntry); + + (Json.ExportFileEntry? entry, string? error) result = ProcessSingleFile( + fileEntry, + data, + outputDir, + options.DryRun, + options.NoOverwrite, + processor + ); + + results[i] = result; + + if ( + result.entry != null + && options is { Verbose: true, Json: false, Progress: false } + ) + { + if (options.DryRun) + { + lock (consoleLock) + { + Console.WriteLine( + $"Would export: {fileEntry.Path}" + ); + } + } + else if (result.entry.Status == "exported") + { + lock (consoleLock) + { + Console.Error.WriteLine( + $"Exported: {fileEntry.Path} -> {result.entry.OutputFiles} file(s)" + ); + } + } + } + + progress.Increment(fileEntry.Path); + } + catch (Exception ex) + { + if (!options.Json) + { + lock (consoleLock) + { + Console.Error.WriteLine( + $"Error exporting {fileEntry.Path}: {ex.Message}" + ); + } + } + results[i] = ( + null, + $"Error exporting {fileEntry.Path}: {ex.Message}" + ); + progress.Increment(); + } + } + ); + } + + ExportAggregation agg = AggregateResults(results, scanErrors); + + Json.ExportResult jsonResult = new() + { + Success = agg.ErrorMessages.Count == 0, + RpfFile = options.RpfPath, + OutputDir = options.OutputPath, + Format = format, + TotalFiles = filesToExport.Count, + Exported = agg.Exported, + Skipped = agg.Skipped, + Errors = agg.Errors, + DryRun = options.DryRun, + Files = agg.Files, + ErrorMessages = agg.ErrorMessages, + }; + + if (options.Json) + { + Console.WriteLine( + JsonSerializer.Serialize(jsonResult, Output.JsonSerializerOptions) + ); + } + else + { + Console.Error.WriteLine(); + string action = options.DryRun ? "would be exported" : "exported"; + Console.Error.WriteLine( + $"{summaryLabel} export complete: {agg.Exported} files {action}, {agg.Skipped} skipped, {agg.Errors} errors" + ); + } + + return agg.ErrorMessages.Count > 0 ? 1 : 0; + } + catch (OperationCanceledException) { throw; } + catch (Exception ex) + { + return Output.ReportError( + ex.Message, + options.Json, + ErrorResult([]), + options.Verbose ? ex.StackTrace : null + ); + } + } +} diff --git a/CodeWalker.Cli/Helpers/Filter.cs b/CodeWalker.Cli/Helpers/Filter.cs new file mode 100644 index 000000000..59b63b002 --- /dev/null +++ b/CodeWalker.Cli/Helpers/Filter.cs @@ -0,0 +1,105 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Text.RegularExpressions; + +namespace CodeWalker.Cli.Helpers; + +/// +/// Provides methods for filtering file paths based on glob patterns. +/// +internal static class Filter +{ + private static readonly ConcurrentDictionary RegexCache = new(); + + /// + /// Normalizes filter patterns once at parse time: trims, lowercases, and strips blanks. + /// + public static string[] Normalize(string[]? filters) + { + if (filters == null || filters.Length == 0) + return []; + + List result = []; + foreach (string filter in filters) + { + if (string.IsNullOrWhiteSpace(filter)) + continue; + result.Add(filter.Trim().ToLowerInvariant()); + } + return [.. result]; + } + + /// + /// Determines if the given path matches any of the provided glob patterns. + /// Filters should be pre-normalized via . + /// + public static bool Matches(string path, string[]? filters) + { + if (filters == null || filters.Length == 0) + return true; + + string nameLower = path.ToLowerInvariant(); + + foreach (string filter in filters) + { + if (MatchesGlob(nameLower, filter)) + return true; + } + + return false; + } + + private static bool MatchesGlob(string input, string pattern) + { + input = input.Replace('\\', '/'); + pattern = pattern.Replace('\\', '/'); + + bool hasPathSep = pattern.Contains('/', StringComparison.Ordinal); + + // For patterns without path separators, match against filename only + if (!hasPathSep) + { + int lastSlash = input.LastIndexOf('/'); + if (lastSlash >= 0) + input = input[(lastSlash + 1)..]; + } + + // Handle extension-only patterns (e.g., ".ydr" or "ydr" without wildcards) + if (!pattern.Contains('*', StringComparison.Ordinal) && !pattern.Contains('?', StringComparison.Ordinal)) + { + return pattern.StartsWith('.') + ? input.EndsWith(pattern, StringComparison.Ordinal) + : input.EndsWith($".{pattern}", StringComparison.Ordinal); + } + + Regex regex = RegexCache.GetOrAdd( + pattern, + static p => + { + // Escape all regex special chars except * and ? + string regexPattern = Regex.Escape(p); + + // Globstar must be handled before a lone *, or ** matches as two singles + // **/ matches zero or more directory segments + regexPattern = regexPattern.Replace("\\*\\*/", "(.*/)?", StringComparison.Ordinal); + // standalone ** matches any characters including / + regexPattern = regexPattern.Replace("\\*\\*", ".*", StringComparison.Ordinal); + // * matches any characters except / (single path segment) + regexPattern = regexPattern.Replace("\\*", "[^/]*", StringComparison.Ordinal); + // ? matches any single character except / + regexPattern = regexPattern.Replace("\\?", "[^/]", StringComparison.Ordinal); + + // Patterns with path separators match at any path boundary; + // filename-only patterns are anchored to the full filename. + regexPattern = p.Contains('/', StringComparison.Ordinal) + ? $"(?:^|/){regexPattern}$" + : $"^{regexPattern}$"; + + return new Regex(regexPattern, RegexOptions.Compiled); + } + ); + + return regex.IsMatch(input); + } +} diff --git a/CodeWalker.Cli/Helpers/HelpLayout.cs b/CodeWalker.Cli/Helpers/HelpLayout.cs new file mode 100644 index 000000000..a2236d053 --- /dev/null +++ b/CodeWalker.Cli/Helpers/HelpLayout.cs @@ -0,0 +1,146 @@ +using System; +using System.Collections.Generic; +using System.CommandLine; +using System.CommandLine.Help; +using System.CommandLine.Invocation; +using System.IO; +using System.Linq; + +namespace CodeWalker.Cli.Helpers; + +/// +/// Appends the parts of the command reference that generated option tables cannot carry: +/// what each output stream is for, the shape of --json output, and the exit codes. +/// +/// +/// System.CommandLine keeps its help layout API internal, so the help option's action is +/// wrapped rather than reconfigured: the stock help is written first, then these sections. +/// +internal sealed class HelpLayout : SynchronousCommandLineAction +{ + private const string ExportFields = + "rpfFile, outputDir, format, totalFiles, exported, skipped, errors, dryRun, " + + "files[{path, name, outputPath, outputFiles, status}]"; + + /// + /// Fields each command writes under --json, keyed by command name, on top of the + /// success and errorMessages that every result carries. + /// + private static readonly Dictionary JsonFields = new(StringComparer.Ordinal) + { + ["extract"] = "rpfFile, outputDir, totalFiles, extracted, skipped, errors, dryRun, " + + "files[{path, name, size, sizeFormatted, type, extension, status}]", + ["list"] = "rpfFile, totalFiles, totalSize, totalSizeFormatted, nestedRpfCount, " + + "files[{path, name, size, sizeFormatted, type, extension}]", + ["hash"] = "hashes[{input, hash, hashSigned, hashHex, encoding}]", + ["tree"] = "rpfFile, totalFiles, totalDirs, " + + "root{name, path, type, size, sizeFormatted, fileType, version, children[]}", + ["gen9"] = "inputFolder, outputFolder, totalFiles, converted, skipped, copied, errors, " + + "files[{path, status, message}]", + ["pack"] = "inputDir, outputFile, totalFiles, totalDirs, totalSize, " + + "totalSizeFormatted, errors", + ["diff"] = "leftRpf, rightRpf, added[], removed[], modified[], unchanged[], " + + "summary{addedCount, removedCount, modifiedCount, unchangedCount}", + ["stat"] = "rpfFile, totalFiles, totalSize, totalSizeFormatted, resourceCount, " + + "binaryCount, compressedSize, uncompressedSize, compressionRatio, " + + "extensions[{extension, count, totalSize, avgSize, minSize, maxSize}]", + ["search"] = "rpfFile, rpfFiles, pattern, matchCount, " + + "matches[{archive, path, name, size, type, extension}]", + ["validate"] = "rpfFile, totalFiles, valid, warnings, errors, skipped, " + + "files[{path, name, status, message}]", + ["inspect"] = "rpfFile, path, name, size, sizeFormatted, type, extension, nameHash, " + + "shortNameHash, resourceVersion, systemSize, graphicsSize, uncompressedSize, " + + "encryptionType, details (shape depends on the file type)", + ["xml"] = ExportFields, + ["textures"] = ExportFields, + ["audio"] = ExportFields, + ["text"] = ExportFields, + }; + + private readonly HelpAction inner = new(); + + /// + /// Replaces the help action on . The option is recursive, so every + /// subcommand's help goes through it too. + /// + public static void Install(Command root) + { + foreach (HelpOption help in root.Options.OfType()) + help.Action = new HelpLayout(); + } + + public override int Invoke(ParseResult parseResult) + { + int result = this.inner.Invoke(parseResult); + + TextWriter output = parseResult.InvocationConfiguration.Output; + Command command = parseResult.CommandResult.Command; + // MaxWidth is unbounded when stdout is not a terminal; keep prose readable anyway. + int width = Math.Min(100, Math.Max(40, this.inner.MaxWidth)); + + // Only worth stating once, on the root command. + if (!command.Parents.Any()) + WriteStreams(output); + + WriteJsonFields(output, command.Name, width); + WriteExitCodes(output); + + return result; + } + + private static void WriteStreams(TextWriter output) + { + output.WriteLine("Output:"); + output.WriteLine(" stdout Data: file listings, trees, hashes, JSON."); + output.WriteLine(" stderr Progress, status and error messages."); + output.WriteLine(); + output.WriteLine(" Redirecting stdout captures the data alone, so a run stays pipeable"); + output.WriteLine(" while still reporting what it is doing."); + output.WriteLine(); + } + + private static void WriteJsonFields(TextWriter output, string commandName, int width) + { + if (!JsonFields.TryGetValue(commandName, out string? fields)) + return; + + output.WriteLine("JSON output (--json):"); + output.WriteLine(" A single object on stdout. Always present:"); + output.WriteLine(" success Whether the command completed without errors."); + output.WriteLine(" errorMessages Every error encountered, as an array."); + output.WriteLine(); + output.WriteLine(" Alongside those:"); + foreach (string line in Wrap(fields, width - 4)) + output.WriteLine(" " + line); + output.WriteLine(); + } + + private static void WriteExitCodes(TextWriter output) + { + output.WriteLine("Exit codes:"); + output.WriteLine(" 0 Success."); + output.WriteLine(" 1 One or more errors, or invalid arguments."); + output.WriteLine(" 130 Cancelled with Ctrl+C."); + } + + internal static List Wrap(string text, int width) + { + List lines = []; + string current = ""; + foreach (string word in text.Split(' ')) + { + if (current.Length == 0) + current = word; + else if (current.Length + 1 + word.Length <= width) + current += " " + word; + else + { + lines.Add(current); + current = word; + } + } + if (current.Length > 0) + lines.Add(current); + return lines; + } +} diff --git a/CodeWalker.Cli/Helpers/Output.cs b/CodeWalker.Cli/Helpers/Output.cs new file mode 100644 index 000000000..f70f9fcc5 --- /dev/null +++ b/CodeWalker.Cli/Helpers/Output.cs @@ -0,0 +1,57 @@ +using System; +using System.Collections.Generic; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace CodeWalker.Cli.Helpers; + +internal abstract record BaseResult +{ + [JsonPropertyName("success")] + [JsonPropertyOrder(-1)] + public required bool Success { get; init; } + + [JsonPropertyName("errorMessages")] + [JsonPropertyOrder(100)] + public required IReadOnlyList ErrorMessages { get; init; } +} + +internal static class Output +{ + public static readonly JsonSerializerOptions JsonSerializerOptions = new() + { + WriteIndented = true, + }; + + /// + /// Reports an error in JSON or text format and returns exit code 1. + /// The with expression preserves the runtime (derived) type, and + /// serialises using that type so all properties are included. + /// + public static int ReportError( + string message, + bool json, + BaseResult result, + string? stackTrace = null + ) + { + if (json) + { + BaseResult errorResult = result with + { + Success = false, + ErrorMessages = [.. result.ErrorMessages, message], + }; + Console.WriteLine( + JsonSerializer.Serialize(errorResult, errorResult.GetType(), JsonSerializerOptions) + ); + } + else + { + Console.Error.WriteLine($"Error: {message}"); + if (stackTrace != null) + Console.Error.WriteLine(stackTrace); + } + return 1; + } +} diff --git a/CodeWalker.Cli/Helpers/ProgressBar.cs b/CodeWalker.Cli/Helpers/ProgressBar.cs new file mode 100644 index 000000000..827e964c8 --- /dev/null +++ b/CodeWalker.Cli/Helpers/ProgressBar.cs @@ -0,0 +1,194 @@ +using System; +using System.Diagnostics; +using System.Globalization; +using System.IO; +using System.Security; + +namespace CodeWalker.Cli.Helpers; + +/// +/// Displays a console progress bar on stderr to keep stdout clean for data/JSON output. +/// +internal sealed class ProgressBar : IDisposable +{ + /// Default writer when no custom writer is provided. Uses stderr to allow console control. + private static TextWriter DefaultWriter => Console.Error; + /// Detect if the default console writer is redirected, in which case we disable the progress bar to avoid writing control characters to the output. + private static bool IsDefaultWriterRedirected => Console.IsErrorRedirected; + + /// Minimum milliseconds between render updates to prevent flickering. + private const int ThrottleMs = 50; + /// Character width of the [===> ] bar portion. + private const int BarWidth = 40; + + /// Total number of items to process. + private readonly int _total; + /// Output destination (stderr or a caller-supplied writer). + private readonly TextWriter _writer; + /// Whether this instance owns the console (true when no custom writer was provided). + private readonly bool _ownsConsole; + /// Terminal width used for padding and line clearing. + private readonly int _windowWidth; + /// Monotonic timer for throttling render updates. + private readonly Stopwatch _throttle = new(); + /// Guards all mutable state for thread-safe updates. + private readonly object _lock = new(); + /// Tracks whether has been called. + private bool _disposed; + + internal int Current { get; private set; } + internal bool Enabled { get; } + + internal void ResetThrottle() + { + lock (this._lock) + this._throttle.Reset(); + } + + /// + /// Initializes a new instance of the ProgressBar class. + /// + /// Total number of items to process. + /// Whether to enable the progress bar display. + /// Optional text writer for output. When null, writes to stderr with console cursor control. + /// Terminal width used for padding and truncation when a custom writer is provided. + public ProgressBar(int total, bool enabled, TextWriter? writer = null, int windowWidth = 120) + { + this._total = total; + this._writer = writer ?? DefaultWriter; + this._ownsConsole = this._writer == Console.Error || this._writer == Console.Out; + this._windowWidth = windowWidth; + this.Enabled = enabled && total > 0 && (!this._ownsConsole || !IsDefaultWriterRedirected); + if (this.Enabled) + { + if (this._ownsConsole) + { + try + { + Console.CursorVisible = false; + } + catch + { + // Ignore console errors (e.g. redirected output, no terminal) + this._ownsConsole = false; + } + } + this.Render(); + this._throttle.Start(); + } + } + + /// + /// Updates the progress bar to the specified current value. + /// + /// Current number of items processed. + /// Optional current file being processed. + /// + /// Must be called under _lock to ensure thread safety with Increment and Dispose. + /// + private void Update(int current, string? currentFile = null) + { + this.Current = Math.Max(0, Math.Min(current, this._total)); + if (!this.Enabled) + return; + + // Throttle updates to avoid flickering + if (this._throttle is { IsRunning: true, ElapsedMilliseconds: < ThrottleMs } && current < this._total) + return; + + this._throttle.Restart(); + this.Render(currentFile); + } + + /// + /// Increments the progress bar by one. + /// + /// Optional current file being processed. + /// + /// Thread-safe: the increment and render happen atomically under a lock. + /// + public void Increment(string? currentFile = null) + { + lock (this._lock) + { + if (this.Current >= this._total) return; + this.Update(this.Current + 1, currentFile); + } + } + + /// + /// Writes the progress bar line to , overwriting the current console line. + /// + /// Optional filename appended after the percentage stats. + private void Render(string? currentFile = null) + { + if (!this.Enabled || this._disposed) + return; + + try + { + double percent = this._total > 0 ? (double)this.Current / this._total : 0; + int filled = Math.Min((int)(percent * BarWidth), BarWidth); + int winWidth = this._ownsConsole ? Console.WindowWidth : this._windowWidth; + int maxWidth = Math.Max(1, winWidth - 1); + + // Build the full line as a single string: [====> ] 100 % (50/100) file.ytd + string line = filled < BarWidth + ? $"[{new string('=', filled)}>{new string(' ', BarWidth - filled - 1)}" + : $"[{new string('=', filled)}"; + + line += string.Format(CultureInfo.InvariantCulture, "] {0,6:P0} ({1}/{2})", percent, this.Current, this._total); + + if (!string.IsNullOrEmpty(currentFile)) + { + int maxLen = Math.Max(10, winWidth - BarWidth - 30); + string displayFile = + currentFile!.Length > maxLen + ? $"...{currentFile[(currentFile.Length - maxLen + 3)..]}" + : currentFile; + line += $" {displayFile}"; + } + + // Clamp to terminal width to prevent wrapping; pad remainder to overwrite stale characters + if (line.Length > maxWidth) + line = line[..maxWidth]; + else if (line.Length < maxWidth) + line += new string(' ', maxWidth - line.Length); + + if (this._ownsConsole) + Console.SetCursorPosition(0, Console.CursorTop); + + this._writer.Write(line); + } + catch (Exception ex) + when (ex is IOException or InvalidOperationException or SecurityException) + { + // Ignore console errors (e.g. redirected output, no terminal) + } + } + + /// + /// Disposes the progress bar, ensuring the console state is restored. + /// + public void Dispose() + { + lock (this._lock) + { + if (this._disposed || !this.Enabled) + return; + this._disposed = true; + + try + { + this._writer.WriteLine(); + if (this._ownsConsole) + Console.CursorVisible = true; + } + catch (Exception ex) + when (ex is IOException or InvalidOperationException or SecurityException) + { + // Ignore console errors during dispose + } + } + } +} diff --git a/CodeWalker.Cli/Helpers/RpfHelper.cs b/CodeWalker.Cli/Helpers/RpfHelper.cs new file mode 100644 index 000000000..e4500800b --- /dev/null +++ b/CodeWalker.Cli/Helpers/RpfHelper.cs @@ -0,0 +1,171 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; + +using CodeWalker.GameFiles; + +namespace CodeWalker.Cli.Helpers; + +internal static class RpfHelper +{ + /// + /// Validates that the GTA V executable exists in the given directory. + /// Returns null on success, or an error message on failure. + /// + public static string? ValidateExe(string exePath, bool gen9) + { + string exeFile = gen9 ? "GTA5_Enhanced.exe" : "GTA5.exe"; + if (!File.Exists(Path.Combine(exePath, exeFile))) + return $"{exeFile} not found in: {exePath}"; + + return null; + } + + /// + /// Validates that the RPF file and GTA V executable exist. + /// Returns null on success, or an error message on failure. + /// + public static string? ValidateInputs(string rpfPath, string exePath, bool gen9) + { + if (!File.Exists(rpfPath)) + return $"RPF file not found: {rpfPath}"; + + return ValidateExe(exePath, gen9); + } + + /// + /// Validates the GTA V exe, loads encryption keys, and prints status to stderr. + /// For commands that have no --rpf (e.g. gen9, pack). + /// Returns an error message on failure, or null on success. + /// + public static string? ValidateExeAndLoadKeys(string exePath, bool gen9, bool json) + { + string? error = ValidateExe(exePath, gen9); + if (error != null) + return error; + + if (!json) + Console.Error.WriteLine("Loading encryption keys..."); + LoadKeys(exePath, gen9); + + return null; + } + + /// + /// Loads GTA V encryption keys and selects the resource layout for the target generation. + /// is a process-wide switch that every resource reader + /// consults, so it must be set before any resource file is parsed. + /// + public static void LoadKeys(string exePath, bool gen9) + { + RpfManager.IsGen9 = gen9; + GTA5Keys.LoadFromPath(exePath, gen9); + } + + /// + /// Validates inputs, loads encryption keys, and prints status to stderr. + /// Returns an error message on failure, or null on success. + /// + public static string? ValidateAndLoadKeys(string rpfPath, string exePath, bool gen9, bool json) + { + string? error = ValidateInputs(rpfPath, exePath, gen9); + if (error != null) + return error; + + if (!json) + Console.Error.WriteLine("Loading encryption keys..."); + LoadKeys(exePath, gen9); + + return null; + } + + /// + /// Opens an RPF file with standard verbose/json output handling. + /// + public static RpfFile OpenRpf( + string rpfPath, + bool verbose, + bool json, + List errorMessages + ) + { + if (!json) + Console.Error.WriteLine($"Opening RPF: {rpfPath}"); + + string rpfName = Path.GetFileName(rpfPath); + RpfFile rpf = new(rpfPath, rpfName); + rpf.ScanStructure( + status => + { + if (verbose && !json) + Console.Error.WriteLine(status); + }, + error => + { + if (!json) + Console.Error.WriteLine($"Error: {error}"); + errorMessages.Add(error); + } + ); + + if (!json) + { + Console.Error.WriteLine( + $"Found {rpf.GrandTotalFileCount} files in {rpf.GrandTotalRpfCount} archive(s)" + ); + } + + return rpf; + } + + /// + /// Recursively collects file entries from an RPF archive, applying glob filters. + /// + public static List<(RpfFile rpf, RpfFileEntry entry)> CollectFiles( + RpfFile rpf, + string[]? filters, + bool recursive + ) + { + List<(RpfFile, RpfFileEntry)> files = []; + CollectFilesRecursive(rpf, filters, recursive, files); + return files; + } + + private static void CollectFilesRecursive( + RpfFile rpf, + string[]? filters, + bool recursive, + List<(RpfFile, RpfFileEntry)> files + ) + { + if (rpf.AllEntries != null) + { + files.AddRange( + rpf.AllEntries + .OfType() + .Where(fe => + (!recursive || !fe.NameLower.EndsWith(".rpf", StringComparison.Ordinal)) + && Filter.Matches(fe.Path, filters)) + .Select(fe => (rpf, fe)) + ); + } + + if (recursive && rpf.Children != null) + { + foreach (RpfFile child in rpf.Children) + CollectFilesRecursive(child, filters, recursive, files); + } + } + + /// + /// Returns the file type string for a given RPF file entry. + /// + public static string GetFileType(RpfFileEntry fileEntry) => fileEntry switch + { + RpfResourceFileEntry => "resource", + RpfBinaryFileEntry => "binary", + _ => "unknown", + }; +} diff --git a/CodeWalker.Cli/Helpers/SizeFormat.cs b/CodeWalker.Cli/Helpers/SizeFormat.cs new file mode 100644 index 000000000..f5a4d012f --- /dev/null +++ b/CodeWalker.Cli/Helpers/SizeFormat.cs @@ -0,0 +1,61 @@ +using System; +using System.Globalization; + +namespace CodeWalker.Cli.Helpers; + +/// +/// Defines size formatting options for human-readable file sizes. +/// +internal enum SizeFormat +{ + /// IEC format: 1024-based (KiB, MiB, GiB) + IEC = 0, + + /// SI format: 1000-based (KB, MB, GB) + SI = 1, +} + +/// +/// Extension methods for SizeFormat to format byte sizes into human-readable strings. +/// +internal static class SizeFormatExtensions +{ + private static readonly string[] SiSuffixes = ["B", "KB", "MB", "GB", "TB", "PB"]; + private static readonly string[] IecSuffixes = ["B", "KiB", "MiB", "GiB", "TiB", "PiB"]; + + private static double GetDivisor(this SizeFormat format) => + format switch + { + SizeFormat.SI => 1000.0, + SizeFormat.IEC => 1024.0, + _ => throw new ArgumentOutOfRangeException(nameof(format)), + }; + + private static string[] GetSuffixes(this SizeFormat format) => + format switch + { + SizeFormat.SI => SiSuffixes, + SizeFormat.IEC => IecSuffixes, + _ => throw new ArgumentOutOfRangeException(nameof(format)), + }; + + /// + /// Formats the given byte size into a human-readable string based on the size format. + /// Invariant, so the same archive reports the same figures on every machine and the + /// formatted values in --json output stay stable. + /// + public static string ToFormattedString(this SizeFormat format, long bytes) + { + double divisor = format.GetDivisor(); + string[] suffixes = format.GetSuffixes(); + int i = 0; + double size = Math.Abs((double)bytes); + while (size >= divisor && i < suffixes.Length - 1) + { + size /= divisor; + i++; + } + if (bytes < 0) size = -size; + return string.Format(CultureInfo.InvariantCulture, "{0:0.##} {1}", size, suffixes[i]); + } +} diff --git a/CodeWalker.Cli/Json/DiffResult.cs b/CodeWalker.Cli/Json/DiffResult.cs new file mode 100644 index 000000000..20210b30a --- /dev/null +++ b/CodeWalker.Cli/Json/DiffResult.cs @@ -0,0 +1,85 @@ +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Text.Json.Serialization; + +using CodeWalker.Cli.Helpers; + +namespace CodeWalker.Cli.Json; + +[ExcludeFromCodeCoverage] +internal sealed record DiffResult : BaseResult +{ + [JsonPropertyName("leftRpf")] + public required string LeftRpf { get; init; } + + [JsonPropertyName("rightRpf")] + public required string RightRpf { get; init; } + + [JsonPropertyName("added")] + public required IReadOnlyList Added { get; init; } + + [JsonPropertyName("removed")] + public required IReadOnlyList Removed { get; init; } + + [JsonPropertyName("modified")] + public required IReadOnlyList Modified { get; init; } + + [JsonPropertyName("unchanged")] + public required IReadOnlyList Unchanged { get; init; } + + [JsonPropertyName("summary")] + public required DiffSummary Summary { get; init; } +} + +[ExcludeFromCodeCoverage] +internal sealed record DiffEntry +{ + [JsonPropertyName("path")] + public required string Path { get; init; } + + [JsonPropertyName("name")] + public required string Name { get; init; } + + [JsonPropertyName("type")] + public required string Type { get; init; } + + [JsonPropertyName("size")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public long? Size { get; init; } + + [JsonPropertyName("sizeFormatted")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? SizeFormatted { get; init; } + + [JsonPropertyName("leftSize")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public long? LeftSize { get; init; } + + [JsonPropertyName("leftSizeFormatted")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? LeftSizeFormatted { get; init; } + + [JsonPropertyName("rightSize")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public long? RightSize { get; init; } + + [JsonPropertyName("rightSizeFormatted")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? RightSizeFormatted { get; init; } +} + +[ExcludeFromCodeCoverage] +internal sealed record DiffSummary +{ + [JsonPropertyName("addedCount")] + public required int AddedCount { get; init; } + + [JsonPropertyName("removedCount")] + public required int RemovedCount { get; init; } + + [JsonPropertyName("modifiedCount")] + public required int ModifiedCount { get; init; } + + [JsonPropertyName("unchangedCount")] + public required int UnchangedCount { get; init; } +} diff --git a/CodeWalker.Cli/Json/ExportResult.cs b/CodeWalker.Cli/Json/ExportResult.cs new file mode 100644 index 000000000..57824d5de --- /dev/null +++ b/CodeWalker.Cli/Json/ExportResult.cs @@ -0,0 +1,58 @@ +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Text.Json.Serialization; + +using CodeWalker.Cli.Helpers; + +namespace CodeWalker.Cli.Json; + +[ExcludeFromCodeCoverage] +internal sealed record ExportFileEntry +{ + [JsonPropertyName("path")] + public required string Path { get; init; } + + [JsonPropertyName("name")] + public required string Name { get; init; } + + [JsonPropertyName("outputPath")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? OutputPath { get; init; } + + [JsonPropertyName("outputFiles")] + public required int OutputFiles { get; init; } + + [JsonPropertyName("status")] + public required string Status { get; init; } +} + +[ExcludeFromCodeCoverage] +internal sealed record ExportResult : BaseResult +{ + [JsonPropertyName("rpfFile")] + public required string RpfFile { get; init; } + + [JsonPropertyName("outputDir")] + public required string OutputDir { get; init; } + + [JsonPropertyName("format")] + public required string Format { get; init; } + + [JsonPropertyName("totalFiles")] + public required int TotalFiles { get; init; } + + [JsonPropertyName("exported")] + public required int Exported { get; init; } + + [JsonPropertyName("skipped")] + public required int Skipped { get; init; } + + [JsonPropertyName("errors")] + public required int Errors { get; init; } + + [JsonPropertyName("dryRun")] + public required bool DryRun { get; init; } + + [JsonPropertyName("files")] + public required IReadOnlyList Files { get; init; } +} diff --git a/CodeWalker.Cli/Json/ExtractResult.cs b/CodeWalker.Cli/Json/ExtractResult.cs new file mode 100644 index 000000000..b6ca79b8c --- /dev/null +++ b/CodeWalker.Cli/Json/ExtractResult.cs @@ -0,0 +1,35 @@ +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Text.Json.Serialization; + +using CodeWalker.Cli.Helpers; + +namespace CodeWalker.Cli.Json; + +[ExcludeFromCodeCoverage] +internal sealed record ExtractResult : BaseResult +{ + [JsonPropertyName("rpfFile")] + public required string RpfFile { get; init; } + + [JsonPropertyName("outputDir")] + public required string OutputDir { get; init; } + + [JsonPropertyName("totalFiles")] + public required int TotalFiles { get; init; } + + [JsonPropertyName("extracted")] + public required int Extracted { get; init; } + + [JsonPropertyName("skipped")] + public required int Skipped { get; init; } + + [JsonPropertyName("errors")] + public required int Errors { get; init; } + + [JsonPropertyName("dryRun")] + public required bool DryRun { get; init; } + + [JsonPropertyName("files")] + public required IReadOnlyList Files { get; init; } +} diff --git a/CodeWalker.Cli/Json/FileEntry.cs b/CodeWalker.Cli/Json/FileEntry.cs new file mode 100644 index 000000000..d9f873800 --- /dev/null +++ b/CodeWalker.Cli/Json/FileEntry.cs @@ -0,0 +1,30 @@ +using System.Diagnostics.CodeAnalysis; +using System.Text.Json.Serialization; + +namespace CodeWalker.Cli.Json; + +[ExcludeFromCodeCoverage] +internal sealed record FileEntry +{ + [JsonPropertyName("path")] + public required string Path { get; init; } + + [JsonPropertyName("name")] + public required string Name { get; init; } + + [JsonPropertyName("size")] + public required long Size { get; init; } + + [JsonPropertyName("sizeFormatted")] + public required string SizeFormatted { get; init; } + + [JsonPropertyName("type")] + public required string Type { get; init; } + + [JsonPropertyName("extension")] + public required string Extension { get; init; } + + [JsonPropertyName("status")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Status { get; init; } +} diff --git a/CodeWalker.Cli/Json/Gen9Result.cs b/CodeWalker.Cli/Json/Gen9Result.cs new file mode 100644 index 000000000..d10a57d05 --- /dev/null +++ b/CodeWalker.Cli/Json/Gen9Result.cs @@ -0,0 +1,49 @@ +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Text.Json.Serialization; + +using CodeWalker.Cli.Helpers; + +namespace CodeWalker.Cli.Json; + +[ExcludeFromCodeCoverage] +internal sealed record Gen9Result : BaseResult +{ + [JsonPropertyName("inputFolder")] + public required string InputFolder { get; init; } + + [JsonPropertyName("outputFolder")] + public required string OutputFolder { get; init; } + + [JsonPropertyName("totalFiles")] + public required int TotalFiles { get; init; } + + [JsonPropertyName("converted")] + public required int Converted { get; init; } + + [JsonPropertyName("skipped")] + public required int Skipped { get; init; } + + [JsonPropertyName("copied")] + public required int Copied { get; init; } + + [JsonPropertyName("errors")] + public required int Errors { get; init; } + + [JsonPropertyName("files")] + public required IReadOnlyList Files { get; init; } +} + +[ExcludeFromCodeCoverage] +internal sealed record Gen9FileEntry +{ + [JsonPropertyName("path")] + public required string Path { get; init; } + + [JsonPropertyName("status")] + public required string Status { get; init; } + + [JsonPropertyName("message")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Message { get; init; } +} diff --git a/CodeWalker.Cli/Json/HashResult.cs b/CodeWalker.Cli/Json/HashResult.cs new file mode 100644 index 000000000..d70b53531 --- /dev/null +++ b/CodeWalker.Cli/Json/HashResult.cs @@ -0,0 +1,33 @@ +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Text.Json.Serialization; + +using CodeWalker.Cli.Helpers; + +namespace CodeWalker.Cli.Json; + +[ExcludeFromCodeCoverage] +internal sealed record HashResult : BaseResult +{ + [JsonPropertyName("hashes")] + public required IReadOnlyList Hashes { get; init; } +} + +[ExcludeFromCodeCoverage] +internal sealed record HashEntry +{ + [JsonPropertyName("input")] + public required string Input { get; init; } + + [JsonPropertyName("hash")] + public required uint Hash { get; init; } + + [JsonPropertyName("hashSigned")] + public required int HashSigned { get; init; } + + [JsonPropertyName("hashHex")] + public required string HashHex { get; init; } + + [JsonPropertyName("encoding")] + public required string Encoding { get; init; } +} diff --git a/CodeWalker.Cli/Json/InspectResult.cs b/CodeWalker.Cli/Json/InspectResult.cs new file mode 100644 index 000000000..2618d1597 --- /dev/null +++ b/CodeWalker.Cli/Json/InspectResult.cs @@ -0,0 +1,288 @@ +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Text.Json.Serialization; + +using CodeWalker.Cli.Helpers; + +namespace CodeWalker.Cli.Json; + +[ExcludeFromCodeCoverage] +internal sealed record InspectResult : BaseResult +{ + [JsonPropertyName("rpfFile")] + public required string RpfFile { get; init; } + + [JsonPropertyName("path")] + public required string Path { get; init; } + + [JsonPropertyName("name")] + public required string Name { get; init; } + + [JsonPropertyName("size")] + public required long Size { get; init; } + + [JsonPropertyName("sizeFormatted")] + public required string SizeFormatted { get; init; } + + [JsonPropertyName("type")] + public required string Type { get; init; } + + [JsonPropertyName("extension")] + public required string Extension { get; init; } + + [JsonPropertyName("nameHash")] + public required uint NameHash { get; init; } + + [JsonPropertyName("shortNameHash")] + public required uint ShortNameHash { get; init; } + + [JsonPropertyName("resourceVersion")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public int? ResourceVersion { get; init; } + + [JsonPropertyName("systemSize")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public long? SystemSize { get; init; } + + [JsonPropertyName("graphicsSize")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public long? GraphicsSize { get; init; } + + [JsonPropertyName("uncompressedSize")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public long? UncompressedSize { get; init; } + + [JsonPropertyName("encryptionType")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public uint? EncryptionType { get; init; } + + [JsonPropertyName("details")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public InspectDetailBase? Details { get; init; } +} + +[JsonPolymorphic] +[JsonDerivedType(typeof(YtdDetails))] +[JsonDerivedType(typeof(YdrDetails))] +[JsonDerivedType(typeof(YddDetails))] +[JsonDerivedType(typeof(YftDetails))] +[JsonDerivedType(typeof(YmapDetails))] +[JsonDerivedType(typeof(YtypDetails))] +[JsonDerivedType(typeof(YbnDetails))] +[JsonDerivedType(typeof(AwcDetails))] +[JsonDerivedType(typeof(Gxt2Details))] +[ExcludeFromCodeCoverage] +internal abstract record InspectDetailBase; + +[ExcludeFromCodeCoverage] +internal sealed record TextureInfo +{ + [JsonPropertyName("name")] + public required string Name { get; init; } + + [JsonPropertyName("width")] + public required ushort Width { get; init; } + + [JsonPropertyName("height")] + public required ushort Height { get; init; } + + [JsonPropertyName("format")] + public required string Format { get; init; } + + [JsonPropertyName("mipLevels")] + public required byte MipLevels { get; init; } + + [JsonPropertyName("stride")] + public required ushort Stride { get; init; } +} + +[ExcludeFromCodeCoverage] +internal sealed record YtdDetails : InspectDetailBase +{ + [JsonPropertyName("textureCount")] + public required int TextureCount { get; init; } + + [JsonPropertyName("textures")] + public required IReadOnlyList Textures { get; init; } +} + +[ExcludeFromCodeCoverage] +internal sealed record LodInfo +{ + [JsonPropertyName("level")] + public required string Level { get; init; } + + [JsonPropertyName("modelCount")] + public required int ModelCount { get; init; } + + [JsonPropertyName("geometryCount")] + public required int GeometryCount { get; init; } + + [JsonPropertyName("totalVertices")] + public required long TotalVertices { get; init; } + + [JsonPropertyName("totalTriangles")] + public required long TotalTriangles { get; init; } +} + +[ExcludeFromCodeCoverage] +internal sealed record YdrDetails : InspectDetailBase +{ + [JsonPropertyName("lods")] + public required IReadOnlyList Lods { get; init; } +} + +[ExcludeFromCodeCoverage] +internal sealed record DrawableInfo +{ + [JsonPropertyName("name")] + public required string Name { get; init; } + + [JsonPropertyName("totalVertices")] + public required long TotalVertices { get; init; } + + [JsonPropertyName("totalTriangles")] + public required long TotalTriangles { get; init; } +} + +[ExcludeFromCodeCoverage] +internal sealed record YddDetails : InspectDetailBase +{ + [JsonPropertyName("drawableCount")] + public required int DrawableCount { get; init; } + + [JsonPropertyName("drawables")] + public required IReadOnlyList Drawables { get; init; } +} + +[ExcludeFromCodeCoverage] +internal sealed record YftDetails : InspectDetailBase +{ + [JsonPropertyName("lods")] + public required IReadOnlyList Lods { get; init; } + + [JsonPropertyName("hasDrawableCloth")] + public required bool HasDrawableCloth { get; init; } +} + +[ExcludeFromCodeCoverage] +internal sealed record YmapDetails : InspectDetailBase +{ + [JsonPropertyName("entityCount")] + public required int EntityCount { get; init; } + + [JsonPropertyName("carGeneratorCount")] + public required int CarGeneratorCount { get; init; } + + [JsonPropertyName("entitiesExtentsMin")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? EntitiesExtentsMin { get; init; } + + [JsonPropertyName("entitiesExtentsMax")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? EntitiesExtentsMax { get; init; } + + [JsonPropertyName("streamingExtentsMin")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? StreamingExtentsMin { get; init; } + + [JsonPropertyName("streamingExtentsMax")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? StreamingExtentsMax { get; init; } + + [JsonPropertyName("isScripted")] + public required bool IsScripted { get; init; } +} + +[ExcludeFromCodeCoverage] +internal sealed record YtypDetails : InspectDetailBase +{ + [JsonPropertyName("archetypeCount")] + public required int ArchetypeCount { get; init; } + + [JsonPropertyName("baseCount")] + public required int BaseCount { get; init; } + + [JsonPropertyName("timeCount")] + public required int TimeCount { get; init; } + + [JsonPropertyName("mloCount")] + public required int MloCount { get; init; } + + [JsonPropertyName("mloDetails")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public IReadOnlyList? MloDetails { get; init; } +} + +[ExcludeFromCodeCoverage] +internal sealed record MloInfo +{ + [JsonPropertyName("name")] + public required string Name { get; init; } + + [JsonPropertyName("entityCount")] + public required int EntityCount { get; init; } + + [JsonPropertyName("roomCount")] + public required int RoomCount { get; init; } + + [JsonPropertyName("portalCount")] + public required int PortalCount { get; init; } +} + +[ExcludeFromCodeCoverage] +internal sealed record YbnDetails : InspectDetailBase +{ + [JsonPropertyName("boundsType")] + public required string BoundsType { get; init; } + + [JsonPropertyName("childCount")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public int? ChildCount { get; init; } +} + +[ExcludeFromCodeCoverage] +internal sealed record AwcStreamInfo +{ + [JsonPropertyName("id")] + public required uint Id { get; init; } + + [JsonPropertyName("samplesPerSecond")] + public required ushort SamplesPerSecond { get; init; } + + [JsonPropertyName("codec")] + public required string Codec { get; init; } + + [JsonPropertyName("samples")] + public required uint Samples { get; init; } +} + +[ExcludeFromCodeCoverage] +internal sealed record AwcDetails : InspectDetailBase +{ + [JsonPropertyName("streamCount")] + public required int StreamCount { get; init; } + + [JsonPropertyName("streams")] + public required IReadOnlyList Streams { get; init; } +} + +[ExcludeFromCodeCoverage] +internal sealed record Gxt2EntryInfo +{ + [JsonPropertyName("hash")] + public required string Hash { get; init; } + + [JsonPropertyName("text")] + public required string Text { get; init; } +} + +[ExcludeFromCodeCoverage] +internal sealed record Gxt2Details : InspectDetailBase +{ + [JsonPropertyName("entryCount")] + public required int EntryCount { get; init; } + + [JsonPropertyName("entries")] + public required IReadOnlyList Entries { get; init; } +} diff --git a/CodeWalker.Cli/Json/ListResult.cs b/CodeWalker.Cli/Json/ListResult.cs new file mode 100644 index 000000000..ba98f6f3d --- /dev/null +++ b/CodeWalker.Cli/Json/ListResult.cs @@ -0,0 +1,29 @@ +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Text.Json.Serialization; + +using CodeWalker.Cli.Helpers; + +namespace CodeWalker.Cli.Json; + +[ExcludeFromCodeCoverage] +internal sealed record ListResult : BaseResult +{ + [JsonPropertyName("rpfFile")] + public required string RpfFile { get; init; } + + [JsonPropertyName("totalFiles")] + public required int TotalFiles { get; init; } + + [JsonPropertyName("totalSize")] + public required long TotalSize { get; init; } + + [JsonPropertyName("totalSizeFormatted")] + public required string TotalSizeFormatted { get; init; } + + [JsonPropertyName("nestedRpfCount")] + public required long NestedRpfCount { get; init; } + + [JsonPropertyName("files")] + public required IReadOnlyList Files { get; init; } +} diff --git a/CodeWalker.Cli/Json/PackResult.cs b/CodeWalker.Cli/Json/PackResult.cs new file mode 100644 index 000000000..22600def1 --- /dev/null +++ b/CodeWalker.Cli/Json/PackResult.cs @@ -0,0 +1,31 @@ +using System.Diagnostics.CodeAnalysis; +using System.Text.Json.Serialization; + +using CodeWalker.Cli.Helpers; + +namespace CodeWalker.Cli.Json; + +[ExcludeFromCodeCoverage] +internal sealed record PackResult : BaseResult +{ + [JsonPropertyName("inputDir")] + public required string InputDir { get; init; } + + [JsonPropertyName("outputFile")] + public required string OutputFile { get; init; } + + [JsonPropertyName("totalFiles")] + public required int TotalFiles { get; init; } + + [JsonPropertyName("totalDirs")] + public required int TotalDirs { get; init; } + + [JsonPropertyName("totalSize")] + public required long TotalSize { get; init; } + + [JsonPropertyName("totalSizeFormatted")] + public required string TotalSizeFormatted { get; init; } + + [JsonPropertyName("errors")] + public required int Errors { get; init; } +} diff --git a/CodeWalker.Cli/Json/SearchResult.cs b/CodeWalker.Cli/Json/SearchResult.cs new file mode 100644 index 000000000..f22c053cf --- /dev/null +++ b/CodeWalker.Cli/Json/SearchResult.cs @@ -0,0 +1,48 @@ +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Text.Json.Serialization; + +using CodeWalker.Cli.Helpers; + +namespace CodeWalker.Cli.Json; + +[ExcludeFromCodeCoverage] +internal sealed record SearchMatch +{ + [JsonPropertyName("archive")] + public required string Archive { get; init; } + + [JsonPropertyName("path")] + public required string Path { get; init; } + + [JsonPropertyName("name")] + public required string Name { get; init; } + + [JsonPropertyName("size")] + public required long Size { get; init; } + + [JsonPropertyName("type")] + public required string Type { get; init; } + + [JsonPropertyName("extension")] + public required string Extension { get; init; } +} + +[ExcludeFromCodeCoverage] +internal sealed record SearchResult : BaseResult +{ + [JsonPropertyName("rpfFile")] + public required string RpfFile { get; init; } + + [JsonPropertyName("rpfFiles")] + public required IReadOnlyList RpfFiles { get; init; } + + [JsonPropertyName("pattern")] + public required string Pattern { get; init; } + + [JsonPropertyName("matchCount")] + public required int MatchCount { get; init; } + + [JsonPropertyName("matches")] + public required IReadOnlyList Matches { get; init; } +} diff --git a/CodeWalker.Cli/Json/StatResult.cs b/CodeWalker.Cli/Json/StatResult.cs new file mode 100644 index 000000000..4779878ba --- /dev/null +++ b/CodeWalker.Cli/Json/StatResult.cs @@ -0,0 +1,81 @@ +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Text.Json.Serialization; + +using CodeWalker.Cli.Helpers; + +namespace CodeWalker.Cli.Json; + +[ExcludeFromCodeCoverage] +internal sealed record ExtensionStat +{ + [JsonPropertyName("extension")] + public required string Extension { get; init; } + + [JsonPropertyName("count")] + public required int Count { get; init; } + + [JsonPropertyName("totalSize")] + public required long TotalSize { get; init; } + + [JsonPropertyName("totalSizeFormatted")] + public required string TotalSizeFormatted { get; init; } + + [JsonPropertyName("avgSize")] + public required long AvgSize { get; init; } + + [JsonPropertyName("avgSizeFormatted")] + public required string AvgSizeFormatted { get; init; } + + [JsonPropertyName("minSize")] + public required long MinSize { get; init; } + + [JsonPropertyName("minSizeFormatted")] + public required string MinSizeFormatted { get; init; } + + [JsonPropertyName("maxSize")] + public required long MaxSize { get; init; } + + [JsonPropertyName("maxSizeFormatted")] + public required string MaxSizeFormatted { get; init; } +} + +[ExcludeFromCodeCoverage] +internal sealed record StatResult : BaseResult +{ + [JsonPropertyName("rpfFile")] + public required string RpfFile { get; init; } + + [JsonPropertyName("totalFiles")] + public required int TotalFiles { get; init; } + + [JsonPropertyName("totalSize")] + public required long TotalSize { get; init; } + + [JsonPropertyName("totalSizeFormatted")] + public required string TotalSizeFormatted { get; init; } + + [JsonPropertyName("resourceCount")] + public required int ResourceCount { get; init; } + + [JsonPropertyName("binaryCount")] + public required int BinaryCount { get; init; } + + [JsonPropertyName("compressedSize")] + public required long CompressedSize { get; init; } + + [JsonPropertyName("compressedSizeFormatted")] + public required string CompressedSizeFormatted { get; init; } + + [JsonPropertyName("uncompressedSize")] + public required long UncompressedSize { get; init; } + + [JsonPropertyName("uncompressedSizeFormatted")] + public required string UncompressedSizeFormatted { get; init; } + + [JsonPropertyName("compressionRatio")] + public required double CompressionRatio { get; init; } + + [JsonPropertyName("extensions")] + public required IReadOnlyList Extensions { get; init; } +} diff --git a/CodeWalker.Cli/Json/TreeResult.cs b/CodeWalker.Cli/Json/TreeResult.cs new file mode 100644 index 000000000..fa4cca897 --- /dev/null +++ b/CodeWalker.Cli/Json/TreeResult.cs @@ -0,0 +1,56 @@ +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Text.Json.Serialization; + +using CodeWalker.Cli.Helpers; + +namespace CodeWalker.Cli.Json; + +[ExcludeFromCodeCoverage] +internal sealed record TreeResult : BaseResult +{ + [JsonPropertyName("rpfFile")] + public required string RpfFile { get; init; } + + [JsonPropertyName("totalFiles")] + public required int TotalFiles { get; init; } + + [JsonPropertyName("totalDirs")] + public required int TotalDirs { get; init; } + + [JsonPropertyName("root")] + public required TreeNode? Root { get; init; } +} + +[ExcludeFromCodeCoverage] +internal sealed record TreeNode +{ + [JsonPropertyName("name")] + public required string Name { get; init; } + + [JsonPropertyName("path")] + public required string Path { get; init; } + + [JsonPropertyName("type")] + public required string Type { get; init; } + + [JsonPropertyName("size")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public long? Size { get; init; } + + [JsonPropertyName("sizeFormatted")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? SizeFormatted { get; init; } + + [JsonPropertyName("fileType")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? FileType { get; init; } + + [JsonPropertyName("version")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public int? Version { get; init; } + + [JsonPropertyName("children")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public IReadOnlyList? Children { get; init; } +} diff --git a/CodeWalker.Cli/Json/ValidateResult.cs b/CodeWalker.Cli/Json/ValidateResult.cs new file mode 100644 index 000000000..2ba54bd31 --- /dev/null +++ b/CodeWalker.Cli/Json/ValidateResult.cs @@ -0,0 +1,49 @@ +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Text.Json.Serialization; + +using CodeWalker.Cli.Helpers; + +namespace CodeWalker.Cli.Json; + +[ExcludeFromCodeCoverage] +internal sealed record ValidateFileEntry +{ + [JsonPropertyName("path")] + public required string Path { get; init; } + + [JsonPropertyName("name")] + public required string Name { get; init; } + + [JsonPropertyName("status")] + public required string Status { get; init; } + + [JsonPropertyName("message")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Message { get; init; } +} + +[ExcludeFromCodeCoverage] +internal sealed record ValidateResult : BaseResult +{ + [JsonPropertyName("rpfFile")] + public required string RpfFile { get; init; } + + [JsonPropertyName("totalFiles")] + public required int TotalFiles { get; init; } + + [JsonPropertyName("valid")] + public required int Valid { get; init; } + + [JsonPropertyName("warnings")] + public required int Warnings { get; init; } + + [JsonPropertyName("errors")] + public required int Errors { get; init; } + + [JsonPropertyName("skipped")] + public required int Skipped { get; init; } + + [JsonPropertyName("files")] + public required IReadOnlyList Files { get; init; } +} diff --git a/CodeWalker.Cli/Polyfills.cs b/CodeWalker.Cli/Polyfills.cs new file mode 100644 index 000000000..711f657b0 --- /dev/null +++ b/CodeWalker.Cli/Polyfills.cs @@ -0,0 +1,155 @@ +#if (!NETCOREAPP2_1_OR_GREATER && !NETSTANDARD2_1_OR_GREATER) || TESTING +using System; +using System.Diagnostics; +using System.Globalization; +using System.Text; +#endif + +namespace CodeWalker.Cli; + +#pragma warning disable IDE0079 // Remove unnecessary suppression + +#if (!NETCOREAPP2_1_OR_GREATER && !NETSTANDARD2_1_OR_GREATER) || TESTING + +internal static class StringExtensions +{ + public static bool Contains(this string s, string value, StringComparison comparisonType) + { +#pragma warning disable CA2249 // Consider using 'string.Contains' instead of 'string.IndexOf'... this is the implementation of Contains! + return s.IndexOf(value, comparisonType) >= 0; +#pragma warning restore CA2249 + } + + public static bool Contains(this string s, char value) + { +#pragma warning disable CA2249 // Consider using 'string.Contains' instead of 'string.IndexOf'... this is the implementation of Contains! + return s.IndexOf(value.ToString(), StringComparison.Ordinal) >= 0; +#pragma warning restore CA2249 + } + + public static bool Contains(this string s, char value, StringComparison comparisonType) + { +#pragma warning disable CA2249 // Consider using 'string.Contains' instead of 'string.IndexOf'... this is the implementation of Contains! + return s.IndexOf(value.ToString(), comparisonType) >= 0; +#pragma warning restore CA2249 + } + + public static bool StartsWith(this string s, char value) + { + return s.Length > 0 && s[0] == value; + } + + public static bool EndsWith(this string s, char value) + { + return s.Length > 0 && s[^1] == value; + } + + private static string? ReplaceCore( + string searchSpace, + string oldValue, + string? newValue, + CompareInfo compareInfo, + CompareOptions options) + { + Debug.Assert(!string.IsNullOrEmpty(oldValue)); + Debug.Assert(compareInfo != null); + + StringBuilder result = new(); + + bool hasDoneAnyReplacements = false; + + while (true) + { + int index = compareInfo!.IndexOf(searchSpace, oldValue, options); + int matchLength = FindMatchLength(compareInfo, searchSpace, index, oldValue, options); + + // There's the possibility that 'oldValue' has zero collation weight (empty string equivalent). + // If this is the case, we behave as if there are no more substitutions to be made. + + if (index < 0 || matchLength == 0) + { + break; + } + + // append the unmodified portion of search space + _ = result.Append(searchSpace[..index]); + + // append the replacement + _ = result.Append(newValue); + + searchSpace = searchSpace[(index + matchLength)..]; + hasDoneAnyReplacements = true; + } + + // Didn't find 'oldValue' in the remaining search space, or the match + // consisted only of zero collation weight characters. As an optimization, + // if we have not yet performed any replacements, we'll save the + // allocation. + + if (!hasDoneAnyReplacements) + { + return null; + } + + // Append what remains of the search space, then allocate the new string. + + _ = result.Append(searchSpace); + return result.ToString(); + } + + private static int FindMatchLength( + CompareInfo compareInfo, + string source, + int index, + string value, + CompareOptions options) + { + if (index < 0) + return 0; + + // Find the actual span length that culturally matches 'value'. + // Usually len == value.Length, but zero-weight characters (e.g. \u00AD + // on .NET Framework) can make the matched span shorter or longer. + int maxLen = source.Length - index; + for (int len = 1; len <= maxLen; len++) + { + if (compareInfo.Compare(source, index, len, value, 0, value.Length, options) == 0) + return len; + } + + return value.Length; // unreachable: IndexOf guarantees a match exists + } + + public static string Replace(this string s, string oldValue, string? newValue, StringComparison comparisonType) + { + if (comparisonType == StringComparison.Ordinal) + { +#pragma warning disable CA1307 // Specify StringComparison for clarity... this is the implementation of Replace! + return s.Replace(oldValue, newValue); +#pragma warning restore CA1307 + } + + (CompareInfo ci, CompareOptions options) = comparisonType switch + { + StringComparison.CurrentCulture or StringComparison.CurrentCultureIgnoreCase => ( + CultureInfo.CurrentCulture.CompareInfo, + (CompareOptions)((int)comparisonType & (int)CompareOptions.IgnoreCase) + ), + StringComparison.InvariantCulture or StringComparison.InvariantCultureIgnoreCase => ( + CultureInfo.InvariantCulture.CompareInfo, + (CompareOptions)((int)comparisonType & (int)CompareOptions.IgnoreCase) + ), + StringComparison.OrdinalIgnoreCase => ( + CultureInfo.InvariantCulture.CompareInfo, + CompareOptions.OrdinalIgnoreCase + ), + StringComparison.Ordinal => throw new InvalidOperationException("This code path should never be hit, as StringComparison.Ordinal is handled above."), + _ => throw new ArgumentException("The string comparison type passed in is currently not supported.", nameof(comparisonType)), + }; + return ReplaceCore(s, oldValue, newValue, ci, options) ?? s; + } +} + +#endif + +#pragma warning restore IDE0079 // Remove unnecessary suppression diff --git a/CodeWalker.Cli/Program.cs b/CodeWalker.Cli/Program.cs new file mode 100644 index 000000000..2ee7eb34a --- /dev/null +++ b/CodeWalker.Cli/Program.cs @@ -0,0 +1,40 @@ +using System; +using System.CommandLine; +using System.Threading; + +using CodeWalker.Cli.Handlers; +using CodeWalker.Cli.Helpers; + +using CancellationTokenSource cts = new(); +Console.CancelKeyPress += (_, e) => +{ + e.Cancel = true; + cts.Cancel(); +}; + +RootCommand rootCommand = new(description: "CodeWalker CLI - RPF Archive Tool") +{ + ExtractHandler.CreateCommand(cts.Token), + ListHandler.CreateCommand(cts.Token), + HashHandler.CreateCommand(cts.Token), + TreeHandler.CreateCommand(cts.Token), + Gen9Handler.CreateCommand(cts.Token), + PackHandler.CreateCommand(cts.Token), + DiffHandler.CreateCommand(cts.Token), + ExportHandler.CreateCommand(cts.Token), + StatHandler.CreateCommand(cts.Token), + SearchHandler.CreateCommand(cts.Token), + ValidateHandler.CreateCommand(cts.Token), + InspectHandler.CreateCommand(cts.Token), +}; + +HelpLayout.Install(rootCommand); + +try +{ + return rootCommand.Parse(args).Invoke(); +} +catch (OperationCanceledException) +{ + return 130; +} diff --git a/CodeWalker.Cli/Tests/CliOptionsTests.cs b/CodeWalker.Cli/Tests/CliOptionsTests.cs new file mode 100644 index 000000000..d75c74dcc --- /dev/null +++ b/CodeWalker.Cli/Tests/CliOptionsTests.cs @@ -0,0 +1,154 @@ +using System; +using System.CommandLine; +using System.IO; + +using CodeWalker.Cli.Helpers; + +using Xunit; + +namespace CodeWalker.Cli.Tests; + +public sealed class CliOptionsTests +{ + [Fact] + public void Rpf_IsRequired() + { + Option opt = CliOptions.Rpf(); + Assert.True(opt.Required); + } + + [Fact] + public void Rpf_HasAlias() + { + Option opt = CliOptions.Rpf(); + Assert.Contains("-r", opt.Aliases); + } + + [Fact] + public void Exe_RequiredByDefault() + { + Option opt = CliOptions.Exe(); + Assert.True(opt.Required); + } + + [Fact] + public void Exe_OptionalWhenSpecified() + { + Option opt = CliOptions.Exe(required: false); + Assert.False(opt.Required); + } + + [Fact] + public void Exe_HasAlias() + { + Option opt = CliOptions.Exe(); + Assert.Contains("-e", opt.Aliases); + } + + [Fact] + public void Gen9_HasAlias() + { + Option opt = CliOptions.Gen9(); + Assert.Contains("-g", opt.Aliases); + } + + [Fact] + public void Filter_AllowsMultipleArguments() + { + Option opt = CliOptions.Filter(); + Assert.True(opt.AllowMultipleArgumentsPerToken); + } + + [Fact] + public void Filter_HasAlias() + { + Option opt = CliOptions.Filter(); + Assert.Contains("-f", opt.Aliases); + } + + [Fact] + public void Recursive_HasAlias() + { + Option opt = CliOptions.Recursive(); + Assert.Contains("-R", opt.Aliases); + } + + [Fact] + public void Verbose_HasAlias() + { + Option opt = CliOptions.Verbose(); + Assert.Contains("-v", opt.Aliases); + } + + [Fact] + public void Json_HasNoAlias() + { + Option opt = CliOptions.Json(); + // --json has no short alias + Assert.DoesNotContain("-j", opt.Aliases); + } + + [Fact] + public void Threads_HasAlias() + { + Option opt = CliOptions.Threads(); + Assert.Contains("-t", opt.Aliases); + } + + [Fact] + public void Threads_DefaultIsProcessorCount() + { + Option opt = CliOptions.Threads(); + RootCommand root = [opt]; + ParseResult pr = root.Parse(""); + Assert.Equal(Environment.ProcessorCount, pr.GetValue(opt)); + } + + [Fact] + public void Threads_RejectsZero() + { + Option opt = CliOptions.Threads(); + RootCommand root = [opt]; + ParseResult pr = root.Parse("--threads 0"); + Assert.NotEmpty(pr.Errors); + } + + [Fact] + public void Threads_AcceptsOne() + { + Option opt = CliOptions.Threads(); + RootCommand root = [opt]; + ParseResult pr = root.Parse("--threads 1"); + Assert.Empty(pr.Errors); + } + + [Fact] + public void OutputDir_HasAlias() + { + Option opt = CliOptions.OutputDir(); + Assert.Contains("-o", opt.Aliases); + } + + [Fact] + public void DryRun_HasAlias() + { + Option opt = CliOptions.DryRun(); + Assert.Contains("-n", opt.Aliases); + } + + [Fact] + public void Progress_HasAlias() + { + Option opt = CliOptions.Progress(); + Assert.Contains("-P", opt.Aliases); + } + + [Fact] + public void FactoryMethods_ReturnNewInstances() + { + // Each call should return a distinct instance + Assert.NotSame(CliOptions.Rpf(), CliOptions.Rpf()); + Assert.NotSame(CliOptions.Exe(), CliOptions.Exe()); + Assert.NotSame(CliOptions.Threads(), CliOptions.Threads()); + } +} diff --git a/CodeWalker.Cli/Tests/ExportPipelineTests.cs b/CodeWalker.Cli/Tests/ExportPipelineTests.cs new file mode 100644 index 000000000..a800b88ca --- /dev/null +++ b/CodeWalker.Cli/Tests/ExportPipelineTests.cs @@ -0,0 +1,526 @@ +using System; +using System.IO; +using System.Threading; + +using CodeWalker.Cli.Helpers; +using CodeWalker.GameFiles; + +using Xunit; + +namespace CodeWalker.Cli.Tests; + +[Collection("ConsoleOutput")] +public sealed class ExportPipelineExecuteTests +{ + private static ExportOptions MakeOptions(bool json) => + new() + { + RpfPath = "/nonexistent/test.rpf", + ExePath = "/nonexistent", + Gen9 = false, + Filters = [], + Verbose = false, + Json = json, + Recursive = false, + Threads = 1, + SizeFormat = SizeFormat.IEC, + OutputPath = "/tmp/output", + DryRun = false, + NoOverwrite = false, + Progress = false, + }; + + private static readonly ExportFileProcessor NoOpProcessor = (_, _, _, _) => (null, null); + + [Fact] + public void Execute_ReturnsOne_WhenValidationFails_TextMode() + { + TextWriter origOut = Console.Out; + TextWriter origErr = Console.Error; + try + { + StringWriter stderr = new(); + Console.SetOut(new StringWriter()); + Console.SetError(stderr); + int exitCode = ExportPipeline.Execute(MakeOptions(json: false), "xml", "XML", NoOpProcessor, TestContext.Current.CancellationToken); + Assert.Equal(1, exitCode); + Assert.Contains("Error:", stderr.ToString()); + } + finally + { + Console.SetOut(origOut); + Console.SetError(origErr); + } + } + + [Fact] + public void Execute_ReturnsOne_WhenValidationFails_JsonMode() + { + TextWriter origOut = Console.Out; + TextWriter origErr = Console.Error; + try + { + StringWriter stdout = new(); + Console.SetOut(stdout); + Console.SetError(new StringWriter()); + int exitCode = ExportPipeline.Execute(MakeOptions(json: true), "xml", "XML", NoOpProcessor, TestContext.Current.CancellationToken); + Assert.Equal(1, exitCode); + string output = stdout.ToString(); + Assert.Contains("\"success\": false", output); + Assert.Contains("RPF file not found", output); + } + finally + { + Console.SetOut(origOut); + Console.SetError(origErr); + } + } + + [Fact] + public void Execute_WithCancelledToken_StillReturnsValidationError() + { + TextWriter origOut = Console.Out; + TextWriter origErr = Console.Error; + try + { + Console.SetOut(new StringWriter()); + StringWriter stderr = new(); + Console.SetError(stderr); + int exitCode = ExportPipeline.Execute(MakeOptions(json: false), "xml", "XML", NoOpProcessor, new CancellationToken(canceled: true)); + Assert.Equal(1, exitCode); + Assert.Contains("Error:", stderr.ToString()); + } + finally + { + Console.SetOut(origOut); + Console.SetError(origErr); + } + } + + [Fact] + public void Execute_JsonError_ContainsExpectedFields() + { + TextWriter origOut = Console.Out; + try + { + StringWriter stdout = new(); + Console.SetOut(stdout); + int exitCode = ExportPipeline.Execute(MakeOptions(json: true), "textures", "Textures", NoOpProcessor, TestContext.Current.CancellationToken); + Assert.Equal(1, exitCode); + string output = stdout.ToString(); + Assert.Contains("\"format\": \"textures\"", output); + Assert.Contains("\"rpfFile\":", output); + Assert.Contains("\"outputDir\":", output); + } + finally { Console.SetOut(origOut); } + } +} + +[Collection("ConsoleOutput")] +public sealed class ProcessSingleFileTests +{ + private static RpfBinaryFileEntry MakeEntry(string path, string name) => + new() { Path = path, Name = name }; + + [Fact] + public void BackslashEntryPath_MirrorsArchiveStructureInOutputDir() + { + // RPF entry paths use backslashes on every platform. If they are not translated + // before the directory is split off, every file collapses into the output root and + // entries sharing a name overwrite each other. + string? seen = null; + _ = ExportPipeline.ProcessSingleFile( + MakeEntry(@"x64b.rpf\data\lang\spanish_rel.rpf\yoga.gxt2", "yoga.gxt2"), + data: [1], + outputDir: "/out", + dryRun: false, + noOverwrite: false, + processor: (entry, _, fileOutputDir, _) => + { + seen = fileOutputDir; + return (new Json.ExportFileEntry + { + Path = entry.Path, + Name = entry.Name, + OutputFiles = 1, + Status = "exported", + }, null); + } + ); + + Assert.Equal( + Path.Combine("/out", "x64b.rpf", "data", "lang", "spanish_rel.rpf"), + seen + ); + } + + [Fact] + public void DryRun_ReturnsEntryWithNoError() + { + (Json.ExportFileEntry? entry, string? error) = ExportPipeline.ProcessSingleFile( + MakeEntry("folder/test.ydr", "test.ydr"), + data: null, + outputDir: "/out", + dryRun: true, + noOverwrite: false, + processor: (_, _, _, _) => throw new InvalidOperationException("Should not be called") + ); + + Assert.NotNull(entry); + Assert.Null(error); + Assert.Equal("dry_run", entry.Status); + } + + [Fact] + public void DryRun_EntryHasCorrectFields() + { + (Json.ExportFileEntry? entry, string? _) = ExportPipeline.ProcessSingleFile( + MakeEntry("vehicles/adder.ydr", "adder.ydr"), + data: [1, 2, 3], + outputDir: "/out", + dryRun: true, + noOverwrite: false, + processor: (_, _, _, _) => throw new InvalidOperationException("Should not be called") + ); + + Assert.NotNull(entry); + Assert.Equal("vehicles/adder.ydr", entry.Path); + Assert.Equal("adder.ydr", entry.Name); + Assert.Equal(0, entry.OutputFiles); + Assert.Equal("dry_run", entry.Status); + } + + [Fact] + public void NullData_ReturnsExtractionFailure() + { + (Json.ExportFileEntry? entry, string? error) = ExportPipeline.ProcessSingleFile( + MakeEntry("test.ydr", "test.ydr"), + data: null, + outputDir: "/out", + dryRun: false, + noOverwrite: false, + processor: (_, _, _, _) => throw new InvalidOperationException("Should not be called") + ); + + Assert.Null(entry); + Assert.NotNull(error); + Assert.Contains("Failed to extract", error); + Assert.Contains("test.ydr", error); + } + + [Fact] + public void ProcessorReturnsError_ReturnsFailure() + { + Json.ExportFileEntry errorEntry = new() + { + Path = "test.ydr", + Name = "test.ydr", + OutputFiles = 0, + Status = "error", + }; + + (Json.ExportFileEntry? entry, string? error) = ExportPipeline.ProcessSingleFile( + MakeEntry("test.ydr", "test.ydr"), + data: [1], + outputDir: "/out", + dryRun: false, + noOverwrite: false, + processor: (_, _, _, _) => (errorEntry, "conversion failed") + ); + + Assert.Same(errorEntry, entry); + Assert.Equal("conversion failed", error); + } + + [Fact] + public void ProcessorReturnsSuccessEntry_ReturnsSuccess() + { + Json.ExportFileEntry successEntry = new() + { + Path = "test.ydr", + Name = "test.ydr", + OutputFiles = 3, + Status = "exported", + }; + + (Json.ExportFileEntry? entry, string? error) = ExportPipeline.ProcessSingleFile( + MakeEntry("test.ydr", "test.ydr"), + data: [1], + outputDir: "/out", + dryRun: false, + noOverwrite: false, + processor: (_, _, _, _) => (successEntry, null) + ); + + Assert.Same(successEntry, entry); + Assert.Null(error); + } + + [Fact] + public void ProcessorReturnsNullEntry_ReturnsNoResult() + { + (Json.ExportFileEntry? entry, string? error) = ExportPipeline.ProcessSingleFile( + MakeEntry("test.ydr", "test.ydr"), + data: [1], + outputDir: "/out", + dryRun: false, + noOverwrite: false, + processor: (_, _, _, _) => (null, null) + ); + + Assert.Null(entry); + Assert.NotNull(error); + Assert.Contains("No result for", error); + } + + [Fact] + public void ProcessorReturnsUnsupported_ReturnsSuccess() + { + Json.ExportFileEntry unsupportedEntry = new() + { + Path = "test.ybn", + Name = "test.ybn", + OutputFiles = 0, + Status = "unsupported", + }; + + (Json.ExportFileEntry? entry, string? error) = ExportPipeline.ProcessSingleFile( + MakeEntry("test.ybn", "test.ybn"), + data: [1], + outputDir: "/out", + dryRun: false, + noOverwrite: false, + processor: (_, _, _, _) => (unsupportedEntry, null) + ); + + Assert.Same(unsupportedEntry, entry); + Assert.Null(error); + } + + [Fact] + public void ProcessorThrows_ExceptionPropagates() + { + InvalidOperationException ex = Assert.Throws(() => + ExportPipeline.ProcessSingleFile( + MakeEntry("test.ydr", "test.ydr"), + data: [1], + outputDir: "/out", + dryRun: false, + noOverwrite: false, + processor: (_, _, _, _) => throw new InvalidOperationException("processor crashed") + ) + ); + + Assert.Equal("processor crashed", ex.Message); + } +} + +[Collection("ConsoleOutput")] +public sealed class AggregateResultsTests +{ + private static readonly string[] OneScanError = ["scan error 1"]; + private static readonly string[] OneScanWarning = ["scan warning"]; + + private static Json.ExportFileEntry MakeFileEntry(string status) => + new() + { + Path = $"test_{status}.ydr", + Name = $"test_{status}.ydr", + OutputFiles = 1, + Status = status, + }; + + [Fact] + public void EmptyResults_AllZeros_OnlyScanErrors() + { + ExportPipeline.ExportAggregation agg = ExportPipeline.AggregateResults( + [], + OneScanError + ); + + Assert.Equal(0, agg.Exported); + Assert.Equal(0, agg.Skipped); + Assert.Equal(0, agg.Errors); + Assert.Empty(agg.Files); + _ = Assert.Single(agg.ErrorMessages); + Assert.Equal("scan error 1", agg.ErrorMessages[0]); + } + + [Fact] + public void CountsExportedAndDryRun_AsExported() + { + (Json.ExportFileEntry?, string?)[] results = + [ + (MakeFileEntry("exported"), null), + (MakeFileEntry("dry_run"), null), + (MakeFileEntry("exported"), null), + ]; + + ExportPipeline.ExportAggregation agg = ExportPipeline.AggregateResults(results, []); + + Assert.Equal(3, agg.Exported); + } + + [Fact] + public void CountsUnsupportedAndSkipped_AsSkipped() + { + (Json.ExportFileEntry?, string?)[] results = + [ + (MakeFileEntry("unsupported"), null), + (MakeFileEntry("skipped"), null), + ]; + + ExportPipeline.ExportAggregation agg = ExportPipeline.AggregateResults(results, []); + + Assert.Equal(2, agg.Skipped); + } + + [Fact] + public void CountsOnlyProcessedEntries_AsSkipped() + { + // Files excluded by --filter are never handed to the pipeline, so they must + // not turn up in the skipped count. + (Json.ExportFileEntry?, string?)[] results = + [ + (MakeFileEntry("skipped"), null), + (MakeFileEntry("exported"), null), + ]; + + ExportPipeline.ExportAggregation agg = ExportPipeline.AggregateResults(results, []); + + Assert.Equal(1, agg.Skipped); + Assert.Equal(1, agg.Exported); + } + + [Fact] + public void CountsErrors_FromFailedResults() + { + (Json.ExportFileEntry?, string?)[] results = + [ + (null, "error 1"), + (null, "error 2"), + (MakeFileEntry("exported"), null), + ]; + + ExportPipeline.ExportAggregation agg = ExportPipeline.AggregateResults(results, []); + + Assert.Equal(2, agg.Errors); + } + + [Fact] + public void CollectsAllNonNullFileEntries() + { + Json.ExportFileEntry exported = MakeFileEntry("exported"); + Json.ExportFileEntry skipped = MakeFileEntry("skipped"); + + (Json.ExportFileEntry?, string?)[] results = + [ + (exported, null), + (null, "error"), + (skipped, null), + ]; + + ExportPipeline.ExportAggregation agg = ExportPipeline.AggregateResults(results, []); + + Assert.Equal(2, agg.Files.Count); + Assert.Same(exported, agg.Files[0]); + Assert.Same(skipped, agg.Files[1]); + } + + [Fact] + public void ErrorEntryWithMessage_CountedAsError_AndInFiles() + { + Json.ExportFileEntry errorEntry = MakeFileEntry("error"); + + (Json.ExportFileEntry?, string?)[] results = + [ + (errorEntry, "conversion failed"), + ]; + + ExportPipeline.ExportAggregation agg = ExportPipeline.AggregateResults(results, []); + + Assert.Equal(0, agg.Exported); + Assert.Equal(0, agg.Skipped); + Assert.Equal(1, agg.Errors); + _ = Assert.Single(agg.Files); + Assert.Same(errorEntry, agg.Files[0]); + _ = Assert.Single(agg.ErrorMessages); + Assert.Equal("conversion failed", agg.ErrorMessages[0]); + } + + [Fact] + public void ExportedEntryWithError_NotCountedAsExported() + { + Json.ExportFileEntry entry = MakeFileEntry("exported"); + + (Json.ExportFileEntry?, string?)[] results = + [ + (entry, "partial failure"), + ]; + + ExportPipeline.ExportAggregation agg = ExportPipeline.AggregateResults(results, []); + + Assert.Equal(0, agg.Exported); + Assert.Equal(1, agg.Errors); + _ = Assert.Single(agg.Files); + Assert.Same(entry, agg.Files[0]); + } + + [Fact] + public void SkippedEntryWithError_NotCountedAsSkipped() + { + Json.ExportFileEntry entry = MakeFileEntry("skipped"); + + (Json.ExportFileEntry?, string?)[] results = + [ + (entry, "unexpected failure"), + ]; + + ExportPipeline.ExportAggregation agg = ExportPipeline.AggregateResults(results, []); + + Assert.Equal(0, agg.Skipped); + Assert.Equal(1, agg.Errors); + _ = Assert.Single(agg.Files); + Assert.Same(entry, agg.Files[0]); + } + + [Fact] + public void ErrorStatusWithNullError_CountedAsError() + { + Json.ExportFileEntry errorEntry = MakeFileEntry("error"); + + (Json.ExportFileEntry?, string?)[] results = + [ + (errorEntry, null), + ]; + + ExportPipeline.ExportAggregation agg = ExportPipeline.AggregateResults(results, []); + + Assert.Equal(0, agg.Exported); + Assert.Equal(0, agg.Skipped); + Assert.Equal(1, agg.Errors); + _ = Assert.Single(agg.Files); + Assert.Same(errorEntry, agg.Files[0]); + _ = Assert.Single(agg.ErrorMessages); + Assert.Contains("Error processing", agg.ErrorMessages[0]); + } + + [Fact] + public void IncludesScanErrorsAndNewErrors_InErrorMessages() + { + (Json.ExportFileEntry?, string?)[] results = + [ + (null, "extraction failed"), + (MakeFileEntry("exported"), null), + ]; + + ExportPipeline.ExportAggregation agg = ExportPipeline.AggregateResults( + results, + OneScanWarning + ); + + Assert.Equal(2, agg.ErrorMessages.Count); + Assert.Equal("scan warning", agg.ErrorMessages[0]); + Assert.Equal("extraction failed", agg.ErrorMessages[1]); + } +} diff --git a/CodeWalker.Cli/Tests/Handlers/DiffHandlerTests.cs b/CodeWalker.Cli/Tests/Handlers/DiffHandlerTests.cs new file mode 100644 index 000000000..f456d48dd --- /dev/null +++ b/CodeWalker.Cli/Tests/Handlers/DiffHandlerTests.cs @@ -0,0 +1,391 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; + +using CodeWalker.Cli.Handlers; +using CodeWalker.Cli.Helpers; + +using Xunit; + +namespace CodeWalker.Cli.Tests.Handlers; + +public sealed class DiffHandlerTests +{ + private static DiffHandler.SideEntry Entry(long size, string? hash = null, string type = "binary") => + new() + { + Name = "file.dat", + Size = size, + Type = type, + Hash = hash, + }; + + private static DiffOptions Options() => + new() + { + LeftPath = "left.rpf", + RightPath = "right.rpf", + LeftExePath = "/left", + RightExePath = "/right", + LeftGen9 = false, + RightGen9 = false, + Recursive = false, + Progress = false, + Verbose = false, + Json = false, + SizeFormat = SizeFormat.IEC, + Threads = 1, + }; + + // RelativeKey + + [Fact] + public void RelativeKey_StripsArchiveNameAndSeparator() => + Assert.Equal( + @"data\maps\paths.ipl", + DiffHandler.RelativeKey(@"packed.rpf\data\maps\paths.ipl", "packed.rpf") + ); + + [Fact] + public void RelativeKey_IgnoresCaseOfArchiveName() => + Assert.Equal( + @"data\a.ipl", + DiffHandler.RelativeKey(@"Packed.RPF\data\a.ipl", "packed.rpf") + ); + + [Fact] + public void RelativeKey_LeavesPathAloneWhenPrefixDoesNotMatch() => + Assert.Equal( + @"other.rpf\data\a.ipl", + DiffHandler.RelativeKey(@"other.rpf\data\a.ipl", "packed.rpf") + ); + + [Fact] + public void RelativeKey_LeavesPathAloneWhenRootIsEmpty() => + Assert.Equal(@"data\a.ipl", DiffHandler.RelativeKey(@"data\a.ipl", "")); + + [Fact] + public void RelativeKey_DifferentlyNamedArchivesProduceEqualKeys() => + Assert.Equal( + DiffHandler.RelativeKey(@"left.rpf\data\a.ipl", "left.rpf"), + DiffHandler.RelativeKey(@"right.rpf\data\a.ipl", "right.rpf") + ); + + // FindHashCandidates + + [Fact] + public void FindHashCandidates_SameSizeAndType_IsCandidate() + { + HashSet candidates = DiffHandler.FindHashCandidates( + new Dictionary { ["a"] = Entry(10) }, + new Dictionary { ["a"] = Entry(10) } + ); + Assert.Equal(["a"], candidates); + } + + [Fact] + public void FindHashCandidates_DifferentSize_IsNotCandidate() + { + HashSet candidates = DiffHandler.FindHashCandidates( + new Dictionary { ["a"] = Entry(10) }, + new Dictionary { ["a"] = Entry(11) } + ); + Assert.Empty(candidates); + } + + [Fact] + public void FindHashCandidates_DifferentType_IsNotCandidate() + { + HashSet candidates = DiffHandler.FindHashCandidates( + new Dictionary { ["a"] = Entry(10, type: "binary") }, + new Dictionary { ["a"] = Entry(10, type: "resource") } + ); + Assert.Empty(candidates); + } + + [Fact] + public void FindHashCandidates_PathOnOneSideOnly_IsNotCandidate() + { + HashSet candidates = DiffHandler.FindHashCandidates( + new Dictionary { ["a"] = Entry(10) }, + new Dictionary { ["b"] = Entry(10) } + ); + Assert.Empty(candidates); + } + + // CompareSides + + [Fact] + public void CompareSides_PathOnlyOnLeft_IsRemoved() + { + Json.DiffResult result = DiffHandler.CompareSides( + new Dictionary { ["a"] = Entry(10) }, + new Dictionary(), + [], + Options() + ); + Assert.Equal("a", Assert.Single(result.Removed).Path); + Assert.Empty(result.Added); + Assert.Empty(result.Modified); + Assert.Empty(result.Unchanged); + } + + [Fact] + public void CompareSides_PathOnlyOnRight_IsAdded() + { + Json.DiffResult result = DiffHandler.CompareSides( + new Dictionary(), + new Dictionary { ["a"] = Entry(10) }, + [], + Options() + ); + Assert.Equal("a", Assert.Single(result.Added).Path); + Assert.Empty(result.Removed); + } + + [Fact] + public void CompareSides_MatchingHashes_IsUnchanged() + { + Json.DiffResult result = DiffHandler.CompareSides( + new Dictionary { ["a"] = Entry(10, "ABCD") }, + new Dictionary { ["a"] = Entry(10, "ABCD") }, + [], + Options() + ); + Assert.Equal("a", Assert.Single(result.Unchanged).Path); + Assert.Empty(result.Modified); + } + + [Fact] + public void CompareSides_DifferentHashes_IsModified() + { + Json.DiffResult result = DiffHandler.CompareSides( + new Dictionary { ["a"] = Entry(10, "ABCD") }, + new Dictionary { ["a"] = Entry(10, "DCBA") }, + [], + Options() + ); + Assert.Equal("a", Assert.Single(result.Modified).Path); + Assert.Empty(result.Unchanged); + } + + [Fact] + public void CompareSides_DifferentSize_IsModified_WithBothSizes() + { + Json.DiffResult result = DiffHandler.CompareSides( + new Dictionary { ["a"] = Entry(10) }, + new Dictionary { ["a"] = Entry(20) }, + [], + Options() + ); + Json.DiffEntry entry = Assert.Single(result.Modified); + Assert.Equal(10, entry.LeftSize); + Assert.Equal(20, entry.RightSize); + } + + [Fact] + public void CompareSides_UnhashedEntry_IsModified_NotUnchanged() + { + // An entry whose content could not be read is never reported as identical. + Json.DiffResult result = DiffHandler.CompareSides( + new Dictionary { ["a"] = Entry(10) }, + new Dictionary { ["a"] = Entry(10, "ABCD") }, + [], + Options() + ); + _ = Assert.Single(result.Modified); + Assert.Empty(result.Unchanged); + } + + [Fact] + public void CompareSides_SortsEachCategoryByPath() + { + Json.DiffResult result = DiffHandler.CompareSides( + new Dictionary + { + ["c"] = Entry(1), + ["a"] = Entry(1), + ["b"] = Entry(1), + }, + new Dictionary(), + [], + Options() + ); + Assert.Equal(["a", "b", "c"], result.Removed.Select(e => e.Path)); + } + + [Fact] + public void CompareSides_SummaryMatchesCategoryCounts() + { + Json.DiffResult result = DiffHandler.CompareSides( + new Dictionary + { + ["same"] = Entry(1, "AA"), + ["changed"] = Entry(1, "AA"), + ["gone"] = Entry(1), + }, + new Dictionary + { + ["same"] = Entry(1, "AA"), + ["changed"] = Entry(1, "BB"), + ["new"] = Entry(1), + }, + [], + Options() + ); + Assert.Equal(1, result.Summary.AddedCount); + Assert.Equal(1, result.Summary.RemovedCount); + Assert.Equal(1, result.Summary.ModifiedCount); + Assert.Equal(1, result.Summary.UnchangedCount); + } + + [Fact] + public void CompareSides_ErrorMessages_MarkResultUnsuccessful() + { + Json.DiffResult result = DiffHandler.CompareSides( + new Dictionary(), + new Dictionary(), + ["Failed to extract left entry: a"], + Options() + ); + Assert.False(result.Success); + _ = Assert.Single(result.ErrorMessages); + } +} + +[Collection("ConsoleOutput")] +public sealed class DiffHandlerExecuteTests +{ + private static DiffOptions MakeOptions(string leftPath, string rightPath, bool json) => + new() + { + LeftPath = leftPath, + RightPath = rightPath, + LeftExePath = "/nonexistent", + RightExePath = "/nonexistent", + LeftGen9 = false, + RightGen9 = false, + Recursive = false, + Progress = false, + Verbose = false, + Json = json, + SizeFormat = SizeFormat.IEC, + Threads = 1, + }; + + [Fact] + public void Execute_LeftMissing_ReturnsOne() + { + TextWriter origOut = Console.Out; + TextWriter origErr = Console.Error; + try + { + StringWriter stderr = new(); + Console.SetOut(new StringWriter()); + Console.SetError(stderr); + + int exitCode = DiffHandler.Execute(MakeOptions("/nonexistent/left.rpf", "/nonexistent/right.rpf", json: false), TestContext.Current.CancellationToken); + + Assert.Equal(1, exitCode); + Assert.Contains("Error:", stderr.ToString()); + } + finally + { + Console.SetOut(origOut); + Console.SetError(origErr); + } + } + + [Fact] + public void Execute_LeftMissing_Json_ReturnsErrorJson() + { + TextWriter origOut = Console.Out; + TextWriter origErr = Console.Error; + try + { + StringWriter stdout = new(); + Console.SetOut(stdout); + Console.SetError(new StringWriter()); + + int exitCode = DiffHandler.Execute(MakeOptions("/nonexistent/left.rpf", "/nonexistent/right.rpf", json: true), TestContext.Current.CancellationToken); + + Assert.Equal(1, exitCode); + string output = stdout.ToString(); + Assert.Contains("\"success\": false", output); + Assert.Contains("RPF file not found", output); + } + finally + { + Console.SetOut(origOut); + Console.SetError(origErr); + } + } + + [Fact] + public void Execute_RightMissing_WithExistingLeft_ReturnsOne() + { + string dir = Path.Combine(Path.GetTempPath(), "cw_diff_" + Guid.NewGuid().ToString("N")); + _ = Directory.CreateDirectory(dir); + string leftRpf = Path.Combine(dir, "left.rpf"); + File.WriteAllBytes(leftRpf, []); + // Also need a valid exe dir + File.WriteAllBytes(Path.Combine(dir, "GTA5.exe"), []); + try + { + TextWriter origOut = Console.Out; + TextWriter origErr = Console.Error; + try + { + StringWriter stderr = new(); + Console.SetOut(new StringWriter()); + Console.SetError(stderr); + + DiffOptions options = new() + { + LeftPath = leftRpf, + RightPath = "/nonexistent/right.rpf", + LeftExePath = dir, + RightExePath = dir, + LeftGen9 = false, + RightGen9 = false, + Recursive = false, + Progress = false, + Verbose = false, + Json = false, + SizeFormat = SizeFormat.IEC, + Threads = 1, + }; + + int exitCode = DiffHandler.Execute(options, TestContext.Current.CancellationToken); + + Assert.Equal(1, exitCode); + Assert.Contains("RPF file not found", stderr.ToString()); + } + finally + { + Console.SetOut(origOut); + Console.SetError(origErr); + } + } + finally { Directory.Delete(dir, true); } + } + + [Fact] + public void Execute_Json_ErrorContainsExpectedFields() + { + TextWriter origOut = Console.Out; + try + { + StringWriter stdout = new(); + Console.SetOut(stdout); + + _ = DiffHandler.Execute(MakeOptions("/nonexistent/left.rpf", "/nonexistent/right.rpf", json: true), TestContext.Current.CancellationToken); + + string output = stdout.ToString(); + Assert.Contains("\"leftRpf\":", output); + Assert.Contains("\"rightRpf\":", output); + } + finally { Console.SetOut(origOut); } + } +} diff --git a/CodeWalker.Cli/Tests/Handlers/ExtractHandlerTests.cs b/CodeWalker.Cli/Tests/Handlers/ExtractHandlerTests.cs new file mode 100644 index 000000000..22e5789fc --- /dev/null +++ b/CodeWalker.Cli/Tests/Handlers/ExtractHandlerTests.cs @@ -0,0 +1,151 @@ +using System; +using System.IO; + +using CodeWalker.Cli.Handlers; +using CodeWalker.Cli.Helpers; + +using Xunit; + +namespace CodeWalker.Cli.Tests.Handlers; + +[Collection("ConsoleOutput")] +public sealed class ExtractHandlerTests +{ + private static ExtractOptions MakeOptions(string rpfPath, bool json, bool dryRun = false) => + new() + { + RpfPath = rpfPath, + ExePath = "/nonexistent", + Gen9 = false, + Filters = [], + Verbose = false, + Json = json, + Recursive = false, + Threads = 1, + SizeFormat = SizeFormat.IEC, + OutputPath = "/tmp/cw_extract_out", + DryRun = dryRun, + NoOverwrite = false, + Progress = false, + }; + + // Validation failures + + [Fact] + public void Execute_MissingRpf_ReturnsOne() + { + TextWriter origOut = Console.Out; + TextWriter origErr = Console.Error; + try + { + StringWriter stderr = new(); + Console.SetOut(new StringWriter()); + Console.SetError(stderr); + + int exitCode = ExtractHandler.Execute(MakeOptions("/nonexistent/test.rpf", json: false), TestContext.Current.CancellationToken); + + Assert.Equal(1, exitCode); + Assert.Contains("Error:", stderr.ToString()); + } + finally + { + Console.SetOut(origOut); + Console.SetError(origErr); + } + } + + [Fact] + public void Execute_MissingRpf_Json_ReturnsErrorJson() + { + TextWriter origOut = Console.Out; + TextWriter origErr = Console.Error; + try + { + StringWriter stdout = new(); + Console.SetOut(stdout); + Console.SetError(new StringWriter()); + + int exitCode = ExtractHandler.Execute(MakeOptions("/nonexistent/test.rpf", json: true), TestContext.Current.CancellationToken); + + Assert.Equal(1, exitCode); + string output = stdout.ToString(); + Assert.Contains("\"success\": false", output); + Assert.Contains("RPF file not found", output); + } + finally + { + Console.SetOut(origOut); + Console.SetError(origErr); + } + } + + [Fact] + public void Execute_Json_ErrorContainsExpectedFields() + { + TextWriter origOut = Console.Out; + try + { + StringWriter stdout = new(); + Console.SetOut(stdout); + + _ = ExtractHandler.Execute(MakeOptions("/nonexistent/test.rpf", json: true), TestContext.Current.CancellationToken); + + string output = stdout.ToString(); + Assert.Contains("\"rpfFile\":", output); + Assert.Contains("\"totalFiles\": 0", output); + Assert.Contains("\"extracted\": 0", output); + Assert.Contains("\"skipped\": 0", output); + Assert.Contains("\"errors\": 0", output); + Assert.Contains("\"dryRun\": false", output); + } + finally { Console.SetOut(origOut); } + } + + [Fact] + public void Execute_MissingExe_WithExistingRpf_ReturnsOne() + { + string dir = Path.Combine(Path.GetTempPath(), "cw_ext_" + Guid.NewGuid().ToString("N")); + _ = Directory.CreateDirectory(dir); + string rpf = Path.Combine(dir, "test.rpf"); + File.WriteAllBytes(rpf, []); + try + { + TextWriter origOut = Console.Out; + TextWriter origErr = Console.Error; + try + { + Console.SetOut(new StringWriter()); + StringWriter stderr = new(); + Console.SetError(stderr); + + int exitCode = ExtractHandler.Execute(MakeOptions(rpf, json: false), TestContext.Current.CancellationToken); + + Assert.Equal(1, exitCode); + Assert.Contains("Error:", stderr.ToString()); + } + finally + { + Console.SetOut(origOut); + Console.SetError(origErr); + } + } + finally { Directory.Delete(dir, true); } + } + + [Fact] + public void Execute_DryRun_Json_ErrorStillHasDryRunTrue() + { + TextWriter origOut = Console.Out; + try + { + StringWriter stdout = new(); + Console.SetOut(stdout); + + _ = ExtractHandler.Execute(MakeOptions("/nonexistent/test.rpf", json: true, dryRun: true), TestContext.Current.CancellationToken); + + string output = stdout.ToString(); + Assert.Contains("\"dryRun\": true", output); + } + finally { Console.SetOut(origOut); } + } +} diff --git a/CodeWalker.Cli/Tests/Handlers/Gen9HandlerTests.cs b/CodeWalker.Cli/Tests/Handlers/Gen9HandlerTests.cs new file mode 100644 index 000000000..316eacffe --- /dev/null +++ b/CodeWalker.Cli/Tests/Handlers/Gen9HandlerTests.cs @@ -0,0 +1,207 @@ +using System; +using System.IO; + +using CodeWalker.Cli.Handlers; +using CodeWalker.Cli.Helpers; + +using Xunit; + +namespace CodeWalker.Cli.Tests.Handlers; + +[Collection("ConsoleOutput")] +public sealed class Gen9HandlerTests +{ + private static Gen9Options MakeOptions( + string inputPath, + string outputPath, + bool json, + string exePath = "/nonexistent" + ) => + new() + { + InputPath = inputPath, + OutputPath = outputPath, + ExePath = exePath, + Verbose = false, + Json = json, + SizeFormat = SizeFormat.IEC, + Threads = 1, + NoRecurse = false, + NoOverwrite = false, + SkipUnconverted = false, + Progress = false, + }; + + private static string CreateTempDir() + { + string dir = Path.Combine(Path.GetTempPath(), "cw_gen9_" + Guid.NewGuid().ToString("N")); + _ = Directory.CreateDirectory(dir); + return dir; + } + + // Input folder missing + + [Fact] + public void Execute_InputFolderMissing_ReturnsOne() + { + TextWriter origOut = Console.Out; + TextWriter origErr = Console.Error; + try + { + StringWriter stderr = new(); + Console.SetOut(new StringWriter()); + Console.SetError(stderr); + + int exitCode = Gen9Handler.Execute(MakeOptions("/nonexistent/input", "/tmp/out", json: false), TestContext.Current.CancellationToken); + + Assert.Equal(1, exitCode); + Assert.Contains("Error:", stderr.ToString()); + Assert.Contains("Input folder not found", stderr.ToString()); + } + finally + { + Console.SetOut(origOut); + Console.SetError(origErr); + } + } + + [Fact] + public void Execute_InputFolderMissing_Json_ReturnsErrorJson() + { + TextWriter origOut = Console.Out; + TextWriter origErr = Console.Error; + try + { + StringWriter stdout = new(); + Console.SetOut(stdout); + Console.SetError(new StringWriter()); + + int exitCode = Gen9Handler.Execute(MakeOptions("/nonexistent/input", "/tmp/out", json: true), TestContext.Current.CancellationToken); + + Assert.Equal(1, exitCode); + string output = stdout.ToString(); + Assert.Contains("\"success\": false", output); + Assert.Contains("Input folder not found", output); + } + finally + { + Console.SetOut(origOut); + Console.SetError(origErr); + } + } + + // Input equals output + + [Fact] + public void Execute_InputEqualsOutput_ReturnsOne() + { + string dir = CreateTempDir(); + try + { + TextWriter origOut = Console.Out; + TextWriter origErr = Console.Error; + try + { + StringWriter stderr = new(); + Console.SetOut(new StringWriter()); + Console.SetError(stderr); + + int exitCode = Gen9Handler.Execute(MakeOptions(dir, dir, json: false), TestContext.Current.CancellationToken); + + Assert.Equal(1, exitCode); + Assert.Contains("must be different", stderr.ToString()); + } + finally + { + Console.SetOut(origOut); + Console.SetError(origErr); + } + } + finally { Directory.Delete(dir, true); } + } + + [Fact] + public void Execute_InputEqualsOutput_Json_ReturnsErrorJson() + { + string dir = CreateTempDir(); + try + { + TextWriter origOut = Console.Out; + try + { + StringWriter stdout = new(); + Console.SetOut(stdout); + + int exitCode = Gen9Handler.Execute(MakeOptions(dir, dir, json: true), TestContext.Current.CancellationToken); + + Assert.Equal(1, exitCode); + string output = stdout.ToString(); + Assert.Contains("\"success\": false", output); + Assert.Contains("must be different", output); + } + finally { Console.SetOut(origOut); } + } + finally { Directory.Delete(dir, true); } + } + + // Missing exe + + [Fact] + public void Execute_MissingExe_ReturnsOne() + { + string inputDir = CreateTempDir(); + string outputDir = inputDir + "_out"; + try + { + TextWriter origOut = Console.Out; + TextWriter origErr = Console.Error; + try + { + StringWriter stderr = new(); + Console.SetOut(new StringWriter()); + Console.SetError(stderr); + + int exitCode = Gen9Handler.Execute(MakeOptions(inputDir, outputDir, json: false), TestContext.Current.CancellationToken); + + Assert.Equal(1, exitCode); + Assert.Contains("Error:", stderr.ToString()); + } + finally + { + Console.SetOut(origOut); + Console.SetError(origErr); + } + } + finally + { + Directory.Delete(inputDir, true); + if (Directory.Exists(outputDir)) + Directory.Delete(outputDir, true); + } + } + + // JSON error structure + + [Fact] + public void Execute_Json_ErrorContainsExpectedFields() + { + TextWriter origOut = Console.Out; + try + { + StringWriter stdout = new(); + Console.SetOut(stdout); + + _ = Gen9Handler.Execute(MakeOptions("/nonexistent/input", "/tmp/out", json: true), TestContext.Current.CancellationToken); + + string output = stdout.ToString(); + Assert.Contains("\"inputFolder\":", output); + Assert.Contains("\"outputFolder\":", output); + Assert.Contains("\"totalFiles\": 0", output); + Assert.Contains("\"converted\": 0", output); + Assert.Contains("\"skipped\": 0", output); + Assert.Contains("\"copied\": 0", output); + Assert.Contains("\"errors\": 0", output); + } + finally { Console.SetOut(origOut); } + } +} diff --git a/CodeWalker.Cli/Tests/Handlers/HashHandlerTests.cs b/CodeWalker.Cli/Tests/Handlers/HashHandlerTests.cs new file mode 100644 index 000000000..e82350774 --- /dev/null +++ b/CodeWalker.Cli/Tests/Handlers/HashHandlerTests.cs @@ -0,0 +1,478 @@ +using System; +using System.IO; +using System.Text.Json; +using System.Threading; + +using CodeWalker.Cli.Handlers; +using CodeWalker.Cli.Helpers; +using CodeWalker.GameFiles; + +using Xunit; + +namespace CodeWalker.Cli.Tests.Handlers; + +// ParseEncoding + +public sealed class ParseEncodingTests +{ + [Theory] + [InlineData("UTF-8", JenkHashInputEncoding.UTF8)] + [InlineData("utf-8", JenkHashInputEncoding.UTF8)] + [InlineData("Utf-8", JenkHashInputEncoding.UTF8)] + [InlineData("ASCII", JenkHashInputEncoding.ASCII)] + [InlineData("ascii", JenkHashInputEncoding.ASCII)] + [InlineData("Ascii", JenkHashInputEncoding.ASCII)] + public void ValidEncoding_ReturnsExpected(string input, JenkHashInputEncoding expected) => + Assert.Equal(expected, HashHandler.ParseEncoding(input)); + + [Theory] + [InlineData(JenkHashInputEncoding.UTF8, "utf-8")] + [InlineData(JenkHashInputEncoding.ASCII, "ascii")] + public void EncodingName_RoundTripsThroughParseEncoding( + JenkHashInputEncoding encoding, + string expected + ) + { + string name = HashHandler.EncodingName(encoding); + Assert.Equal(expected, name); + Assert.Equal(encoding, HashHandler.ParseEncoding(name)); + } + + [Theory] + [InlineData("utf8")] + [InlineData("latin-1")] + [InlineData("")] + [InlineData("UTF8")] + public void InvalidEncoding_ThrowsArgumentException(string input) + { + ArgumentException ex = Assert.Throws( + () => HashHandler.ParseEncoding(input) + ); + Assert.Contains("Unknown encoding", ex.Message); + Assert.Contains(input, ex.Message); + } +} + +// ErrorResult + +public sealed class ErrorResultTests +{ + [Fact] + public void ErrorResult_SetsSuccessFalse() + { + Json.HashResult result = HashHandler.ErrorResult([]); + Assert.False(result.Success); + Assert.Empty(result.Hashes); + } + + [Fact] + public void ErrorResult_PreservesErrorMessages() + { + string[] msgs = ["err1", "err2"]; + Json.HashResult result = HashHandler.ErrorResult(msgs); + Assert.Equal(msgs, result.ErrorMessages); + } +} + +// PrintHashes + +[Collection("ConsoleOutput")] +public sealed class PrintHashesTests +{ + private static string Capture(string[] inputs, JenkHashInputEncoding encoding) + { + TextWriter orig = Console.Out; + try + { + StringWriter sw = new(); + Console.SetOut(sw); + Json.HashEntry[] hashes = HashHandler.CollectHashes(inputs, encoding, CancellationToken.None); + HashHandler.PrintHashes(hashes, TestContext.Current.CancellationToken); + return sw.ToString(); + } + finally { Console.SetOut(orig); } + } + + [Fact] + public void SingleInput_PrintsAllFourLines() + { + string output = Capture(["test"], JenkHashInputEncoding.UTF8); + Assert.Contains("Input (utf-8): test", output); + Assert.Contains("Hash (uint):", output); + Assert.Contains("Hash (int):", output); + Assert.Contains("Hash (hex):", output); + } + + [Fact] + public void SingleInput_MatchesJenkHash() + { + JenkHash expected = new("test", JenkHashInputEncoding.UTF8); + string output = Capture(["test"], JenkHashInputEncoding.UTF8); + Assert.Contains($"Hash (uint): {expected.HashUint}", output); + Assert.Contains($"Hash (int): {expected.HashInt}", output); + Assert.Contains($"Hash (hex): {expected.HashHex}", output); + } + + [Fact] + public void AsciiEncoding_ShowsAsciiInHeader() + { + string output = Capture(["hello"], JenkHashInputEncoding.ASCII); + Assert.Contains("Input (ascii): hello", output); + } + + [Fact] + public void MultipleInputs_PrintsEach() + { + string output = Capture(["alpha", "bravo"], JenkHashInputEncoding.UTF8); + Assert.Contains("Input (utf-8): alpha", output); + Assert.Contains("Input (utf-8): bravo", output); + } + + [Fact] + public void EmptyString_Succeeds() + { + string output = Capture([""], JenkHashInputEncoding.UTF8); + Assert.Contains("Input (utf-8): ", output); + Assert.Contains("Hash (uint):", output); + } + + [Fact] + public void Cancelled_ThrowsOperationCanceledException() + { + using CancellationTokenSource cts = new(); + cts.Cancel(); + + Json.HashEntry[] hashes = HashHandler.CollectHashes( + ["test"], + JenkHashInputEncoding.UTF8, + CancellationToken.None + ); + + TextWriter orig = Console.Out; + try + { + Console.SetOut(new StringWriter()); + _ = Assert.Throws( + () => HashHandler.PrintHashes(hashes, cts.Token) + ); + } + finally { Console.SetOut(orig); } + } +} + +// PrintJsonHashes + +[Collection("ConsoleOutput")] +public sealed class PrintJsonHashesTests +{ + private static string Capture(string[] inputs, JenkHashInputEncoding encoding) + { + TextWriter orig = Console.Out; + try + { + StringWriter sw = new(); + Console.SetOut(sw); + Json.HashEntry[] hashes = HashHandler.CollectHashes(inputs, encoding, CancellationToken.None); + HashHandler.PrintJsonHashes(hashes); + return sw.ToString(); + } + finally { Console.SetOut(orig); } + } + + [Fact] + public void SingleInput_WritesValidJson() + { + Json.HashResult? result = JsonSerializer.Deserialize( + Capture(["test"], JenkHashInputEncoding.UTF8).Trim(), + Output.JsonSerializerOptions + ); + Assert.NotNull(result); + Assert.True(result.Success); + Assert.Empty(result.ErrorMessages); + _ = Assert.Single(result.Hashes); + } + + [Fact] + public void SingleInput_MatchesJenkHash() + { + JenkHash expected = new("test", JenkHashInputEncoding.UTF8); + Json.HashResult? result = JsonSerializer.Deserialize( + Capture(["test"], JenkHashInputEncoding.UTF8).Trim(), + Output.JsonSerializerOptions + ); + Assert.NotNull(result); + + Json.HashEntry entry = result.Hashes[0]; + Assert.Equal("test", entry.Input); + Assert.Equal(expected.HashUint, entry.Hash); + Assert.Equal(expected.HashInt, entry.HashSigned); + Assert.Equal(expected.HashHex, entry.HashHex); + Assert.Equal("utf-8", entry.Encoding); + } + + [Fact] + public void AsciiEncoding_SetsEncodingField() + { + Json.HashResult? result = JsonSerializer.Deserialize( + Capture(["hello"], JenkHashInputEncoding.ASCII).Trim(), + Output.JsonSerializerOptions + ); + Assert.NotNull(result); + Assert.Equal("ascii", result.Hashes[0].Encoding); + } + + [Fact] + public void MultipleInputs_ReturnsAll() + { + Json.HashResult? result = JsonSerializer.Deserialize( + Capture(["alpha", "bravo"], JenkHashInputEncoding.UTF8).Trim(), + Output.JsonSerializerOptions + ); + Assert.NotNull(result); + Assert.Equal(2, result.Hashes.Count); + Assert.Equal("alpha", result.Hashes[0].Input); + Assert.Equal("bravo", result.Hashes[1].Input); + } +} + +// CollectHashes + +public sealed class CollectHashesTests +{ + [Fact] + public void SingleInput_ReturnsOneEntry() + { + Json.HashEntry[] entries = HashHandler.CollectHashes( + ["vehicle"], + JenkHashInputEncoding.UTF8, + TestContext.Current.CancellationToken + ); + _ = Assert.Single(entries); + } + + [Fact] + public void SingleInput_MatchesJenkHash() + { + JenkHash expected = new("vehicle", JenkHashInputEncoding.UTF8); + Json.HashEntry[] entries = HashHandler.CollectHashes( + ["vehicle"], + JenkHashInputEncoding.UTF8, + TestContext.Current.CancellationToken + ); + + Json.HashEntry entry = entries[0]; + Assert.Equal("vehicle", entry.Input); + Assert.Equal(expected.HashUint, entry.Hash); + Assert.Equal(expected.HashInt, entry.HashSigned); + Assert.Equal(expected.HashHex, entry.HashHex); + Assert.Equal("utf-8", entry.Encoding); + } + + [Fact] + public void AsciiEncoding_SetsEncodingField() + { + Json.HashEntry[] entries = HashHandler.CollectHashes( + ["test"], + JenkHashInputEncoding.ASCII, + TestContext.Current.CancellationToken + ); + Assert.Equal("ascii", entries[0].Encoding); + } + + [Fact] + public void MultipleInputs_ReturnsAll() + { + Json.HashEntry[] entries = HashHandler.CollectHashes( + ["one", "two", "three"], + JenkHashInputEncoding.UTF8, + TestContext.Current.CancellationToken + ); + Assert.Equal(3, entries.Length); + Assert.Equal("one", entries[0].Input); + Assert.Equal("two", entries[1].Input); + Assert.Equal("three", entries[2].Input); + } + + [Fact] + public void DifferentInputs_ProduceDifferentHashes() + { + Json.HashEntry[] entries = HashHandler.CollectHashes( + ["alpha", "beta"], + JenkHashInputEncoding.UTF8, + TestContext.Current.CancellationToken + ); + Assert.NotEqual(entries[0].Hash, entries[1].Hash); + } + + [Fact] + public void SameInput_ProducesSameHash() + { + Json.HashEntry[] a = HashHandler.CollectHashes( + ["deterministic"], + JenkHashInputEncoding.UTF8, + TestContext.Current.CancellationToken + ); + Json.HashEntry[] b = HashHandler.CollectHashes( + ["deterministic"], + JenkHashInputEncoding.UTF8, + TestContext.Current.CancellationToken + ); + Assert.Equal(a[0].Hash, b[0].Hash); + } + + [Fact] + public void Cancelled_ThrowsOperationCanceledException() + { + using CancellationTokenSource cts = new(); + cts.Cancel(); + _ = Assert.Throws( + () => HashHandler.CollectHashes(["test"], + JenkHashInputEncoding.UTF8, + cts.Token + ) + ); + } +} + +// Execute (integration) + +[Collection("ConsoleOutput")] +public sealed class HashExecuteTests +{ + private static HashOptions MakeOptions( + string[] inputs, + string encoding = HashOptions.DefaultEncoding, + bool json = false + ) => new() { Inputs = inputs, Encoding = encoding, Json = json }; + + [Fact] + public void Text_ReturnsZero() + { + TextWriter orig = Console.Out; + try + { + Console.SetOut(new StringWriter()); + Assert.Equal(0, HashHandler.Execute( + MakeOptions(["test"]), + TestContext.Current.CancellationToken + )); + } + finally { Console.SetOut(orig); } + } + + [Fact] + public void Json_WritesValidJson() + { + TextWriter orig = Console.Out; + try + { + StringWriter sw = new(); + Console.SetOut(sw); + Assert.Equal(0, HashHandler.Execute( + MakeOptions(["test"], json: true), + TestContext.Current.CancellationToken + )); + + Json.HashResult? result = JsonSerializer.Deserialize( + sw.ToString().Trim(), Output.JsonSerializerOptions + ); + Assert.NotNull(result); + Assert.True(result.Success); + Assert.Empty(result.ErrorMessages); + _ = Assert.Single(result.Hashes); + } + finally { Console.SetOut(orig); } + } + + [Fact] + public void InvalidEncoding_ReturnsOne() + { + TextWriter origOut = Console.Out; + TextWriter origErr = Console.Error; + try + { + Console.SetOut(new StringWriter()); + StringWriter stderr = new(); + Console.SetError(stderr); + + int exitCode = HashHandler.Execute( + MakeOptions(["test"], encoding: "bad"), + TestContext.Current.CancellationToken + ); + + Assert.Equal(1, exitCode); + Assert.Contains("Unknown encoding", stderr.ToString()); + } + finally + { + Console.SetOut(origOut); + Console.SetError(origErr); + } + } + + [Fact] + public void Json_InvalidEncoding_ReturnsJsonError() + { + TextWriter origOut = Console.Out; + try + { + StringWriter stdout = new(); + Console.SetOut(stdout); + + int exitCode = HashHandler.Execute( + MakeOptions(["test"], encoding: "bad", json: true), + TestContext.Current.CancellationToken + ); + + Assert.Equal(1, exitCode); + string output = stdout.ToString(); + Assert.Contains("\"success\": false", output); + Assert.Contains("Unknown encoding", output); + } + finally { Console.SetOut(origOut); } + } + + [Fact] + public void DefaultEncoding_IsUtf8() => + Assert.Equal("utf-8", HashOptions.DefaultEncoding); + + [Fact] + public void Text_Cancelled_ThrowsOperationCanceledException() + { + using CancellationTokenSource cts = new(); + cts.Cancel(); + + TextWriter orig = Console.Out; + try + { + Console.SetOut(new StringWriter()); + _ = Assert.Throws( + () => HashHandler.Execute( + MakeOptions(["test"]), + cts.Token + ) + ); + } + finally { Console.SetOut(orig); } + } + + [Fact] + public void Json_Cancelled_ThrowsOperationCanceledException() + { + using CancellationTokenSource cts = new(); + cts.Cancel(); + + TextWriter orig = Console.Out; + try + { + Console.SetOut(new StringWriter()); + _ = Assert.Throws( + () => HashHandler.Execute( + MakeOptions(["test"], json: true), + cts.Token + ) + ); + } + finally { Console.SetOut(orig); } + } +} diff --git a/CodeWalker.Cli/Tests/Handlers/InspectHandlerTests.cs b/CodeWalker.Cli/Tests/Handlers/InspectHandlerTests.cs new file mode 100644 index 000000000..957e5d93e --- /dev/null +++ b/CodeWalker.Cli/Tests/Handlers/InspectHandlerTests.cs @@ -0,0 +1,141 @@ +using System; +using System.IO; + +using CodeWalker.Cli.Handlers; +using CodeWalker.Cli.Helpers; + +using SharpDX; + +using Xunit; + +namespace CodeWalker.Cli.Tests.Handlers; + +public sealed class InspectHandlerTests +{ + // FormatVector3 + + [Fact] + public void FormatVector3_Zero_ReturnsFormattedZeros() => + Assert.Equal("0.00, 0.00, 0.00", InspectHandler.FormatVector3(Vector3.Zero)); + + [Fact] + public void FormatVector3_PositiveIntegers() => + Assert.Equal("1.00, 2.00, 3.00", InspectHandler.FormatVector3(new Vector3(1, 2, 3))); + + [Fact] + public void FormatVector3_NegativeValues() => + Assert.Equal("-1.50, -2.75, -3.00", InspectHandler.FormatVector3(new Vector3(-1.5f, -2.75f, -3f))); + + [Fact] + public void FormatVector3_FractionalValues_TwoDecimalPlaces() + { + string result = InspectHandler.FormatVector3(new Vector3(1.123f, 2.567f, 3.999f)); + // F2 rounds to 2 decimal places + Assert.Equal("1.12, 2.57, 4.00", result); + } + + [Fact] + public void FormatVector3_LargeValues() => + Assert.Equal("1000.00, -5000.00, 9999.99", + InspectHandler.FormatVector3(new Vector3(1000f, -5000f, 9999.99f))); + + [Fact] + public void FormatVector3_VerySmallValues() => + Assert.Equal("0.01, 0.00, -0.01", + InspectHandler.FormatVector3(new Vector3(0.01f, 0.001f, -0.01f))); + + // Additional FormatVector3 edge cases + + [Fact] + public void FormatVector3_OneComponent() => + Assert.Equal("1.00, 0.00, 0.00", InspectHandler.FormatVector3(Vector3.UnitX)); + + [Fact] + public void FormatVector3_AllNegative() => + Assert.Equal("-1.00, -1.00, -1.00", InspectHandler.FormatVector3(new Vector3(-1, -1, -1))); +} + +[Collection("ConsoleOutput")] +public sealed class InspectHandlerExecuteTests +{ + private static InspectOptions MakeOptions(string rpfPath, bool json, string filePath = "some/file.ydr") => + new() + { + RpfPath = rpfPath, + ExePath = "/nonexistent", + Gen9 = false, + Filters = [], + Verbose = false, + Json = json, + Recursive = false, + SizeFormat = SizeFormat.IEC, + FilePath = filePath, + }; + + [Fact] + public void Execute_MissingRpf_ReturnsOne() + { + TextWriter origOut = Console.Out; + TextWriter origErr = Console.Error; + try + { + StringWriter stderr = new(); + Console.SetOut(new StringWriter()); + Console.SetError(stderr); + + int exitCode = InspectHandler.Execute(MakeOptions("/nonexistent/test.rpf", json: false), TestContext.Current.CancellationToken); + + Assert.Equal(1, exitCode); + Assert.Contains("Error:", stderr.ToString()); + } + finally + { + Console.SetOut(origOut); + Console.SetError(origErr); + } + } + + [Fact] + public void Execute_MissingRpf_Json_ReturnsErrorJson() + { + TextWriter origOut = Console.Out; + TextWriter origErr = Console.Error; + try + { + StringWriter stdout = new(); + Console.SetOut(stdout); + Console.SetError(new StringWriter()); + + int exitCode = InspectHandler.Execute(MakeOptions("/nonexistent/test.rpf", json: true), TestContext.Current.CancellationToken); + + Assert.Equal(1, exitCode); + string output = stdout.ToString(); + Assert.Contains("\"success\": false", output); + Assert.Contains("RPF file not found", output); + } + finally + { + Console.SetOut(origOut); + Console.SetError(origErr); + } + } + + [Fact] + public void Execute_Json_ErrorContainsExpectedFields() + { + TextWriter origOut = Console.Out; + try + { + StringWriter stdout = new(); + Console.SetOut(stdout); + + _ = InspectHandler.Execute(MakeOptions("/nonexistent/test.rpf", json: true), TestContext.Current.CancellationToken); + + string output = stdout.ToString(); + Assert.Contains("\"rpfFile\":", output); + Assert.Contains("\"path\":", output); + Assert.Contains("\"size\": 0", output); + } + finally { Console.SetOut(origOut); } + } +} diff --git a/CodeWalker.Cli/Tests/Handlers/ListHandlerTests.cs b/CodeWalker.Cli/Tests/Handlers/ListHandlerTests.cs new file mode 100644 index 000000000..a0b6b11f0 --- /dev/null +++ b/CodeWalker.Cli/Tests/Handlers/ListHandlerTests.cs @@ -0,0 +1,692 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text.Json; +using System.Threading; + +using CodeWalker.Cli.Handlers; +using CodeWalker.Cli.Helpers; +using CodeWalker.GameFiles; + +using Xunit; + +namespace CodeWalker.Cli.Tests.Handlers; + +[Collection("ConsoleOutput")] +public sealed class ListHandlerTests +{ + private static ListOptions MakeOptions( + string rpfPath = "/test/test.rpf", + bool json = false, + bool verbose = false, + SizeFormat sizeFormat = SizeFormat.IEC) => + new() + { + RpfPath = rpfPath, + ExePath = "/nonexistent", + Gen9 = false, + Filters = [], + Verbose = verbose, + Json = json, + Recursive = false, + SizeFormat = sizeFormat, + }; + + private static readonly char[] SplitChars = ['\r', '\n']; + + private static RpfBinaryFileEntry MakeBinary(string name, string path, uint fileSize) => + new() + { + Name = name, + NameLower = name.ToLowerInvariant(), + Path = path, + FileSize = fileSize, + FileUncompressedSize = fileSize, + }; + + private static RpfResourceFileEntry MakeResource(string name, string path, uint fileSize) => + new() + { + Name = name, + NameLower = name.ToLowerInvariant(), + Path = path, + FileSize = fileSize, + }; + + private static RpfFile MakeRpf(uint grandTotalRpfCount = 1) => + new("test.rpf", "test.rpf", 0) { GrandTotalRpfCount = grandTotalRpfCount }; + + // Validation failures + + [Fact] + public void Execute_MissingRpf_ReturnsOne() + { + TextWriter origOut = Console.Out; + TextWriter origErr = Console.Error; + try + { + StringWriter stderr = new(); + Console.SetOut(new StringWriter()); + Console.SetError(stderr); + + int exitCode = ListHandler.Execute(MakeOptions("/nonexistent/test.rpf"), TestContext.Current.CancellationToken); + + Assert.Equal(1, exitCode); + Assert.Contains("Error:", stderr.ToString()); + } + finally + { + Console.SetOut(origOut); + Console.SetError(origErr); + } + } + + [Fact] + public void Execute_MissingRpf_Json_ReturnsErrorJson() + { + TextWriter origOut = Console.Out; + TextWriter origErr = Console.Error; + try + { + StringWriter stdout = new(); + Console.SetOut(stdout); + Console.SetError(new StringWriter()); + + int exitCode = ListHandler.Execute(MakeOptions("/nonexistent/test.rpf", json: true), TestContext.Current.CancellationToken); + + Assert.Equal(1, exitCode); + string output = stdout.ToString(); + Assert.Contains("\"success\": false", output); + Assert.Contains("RPF file not found", output); + } + finally + { + Console.SetOut(origOut); + Console.SetError(origErr); + } + } + + [Fact] + public void Execute_Json_ErrorContainsExpectedFields() + { + TextWriter origOut = Console.Out; + TextWriter origErr = Console.Error; + try + { + StringWriter stdout = new(); + Console.SetOut(stdout); + Console.SetError(new StringWriter()); + + _ = ListHandler.Execute(MakeOptions("/nonexistent/test.rpf", json: true), TestContext.Current.CancellationToken); + + string output = stdout.ToString(); + Assert.Contains("\"rpfFile\":", output); + Assert.Contains("\"totalFiles\": 0", output); + Assert.Contains("\"totalSize\": 0", output); + Assert.Contains("\"totalSizeFormatted\": \"0 B\"", output); + Assert.Contains("\"nestedRpfCount\": 0", output); + } + finally + { + Console.SetOut(origOut); + Console.SetError(origErr); + } + } + + [Fact] + public void Execute_MissingExe_WithExistingRpf_ReturnsOne() + { + string dir = Path.Combine(Path.GetTempPath(), "cw_list_" + Guid.NewGuid().ToString("N")); + _ = Directory.CreateDirectory(dir); + string rpf = Path.Combine(dir, "test.rpf"); + File.WriteAllBytes(rpf, []); + try + { + TextWriter origOut = Console.Out; + TextWriter origErr = Console.Error; + try + { + Console.SetOut(new StringWriter()); + StringWriter stderr = new(); + Console.SetError(stderr); + + int exitCode = ListHandler.Execute(MakeOptions(rpf), TestContext.Current.CancellationToken); + + Assert.Equal(1, exitCode); + Assert.Contains("Error:", stderr.ToString()); + } + finally + { + Console.SetOut(origOut); + Console.SetError(origErr); + } + } + finally { Directory.Delete(dir, true); } + } + + // CollectList + + [Fact] + public void CollectList_EmptyEntries_ReturnsZeroTotals() + { + Json.ListResult result = ListHandler.CollectList( + [], + MakeRpf(), + [], + MakeOptions(), + TestContext.Current.CancellationToken + ); + + Assert.True(result.Success); + Assert.Equal(0, result.TotalFiles); + Assert.Equal(0L, result.TotalSize); + Assert.Equal("0 B", result.TotalSizeFormatted); + Assert.Empty(result.Files); + Assert.Empty(result.ErrorMessages); + } + + [Fact] + public void CollectList_BinaryEntries_SumsCorrectly() + { + RpfFile rpf = MakeRpf(grandTotalRpfCount: 3); + RpfBinaryFileEntry e1 = MakeBinary("data.dat", "common\\data.dat", 1024); + RpfBinaryFileEntry e2 = MakeBinary("info.bin", "common\\info.bin", 2048); + + List<(RpfFile rpf, RpfFileEntry entry)> entries = [(rpf, e1), (rpf, e2)]; + + Json.ListResult result = ListHandler.CollectList( + entries, + rpf, + [], + MakeOptions(), + TestContext.Current.CancellationToken + ); + + Assert.True(result.Success); + Assert.Equal(2, result.TotalFiles); + Assert.Equal(3072L, result.TotalSize); + Assert.Equal(SizeFormat.IEC.ToFormattedString(3072), result.TotalSizeFormatted); + Assert.Equal(3L, result.NestedRpfCount); + Assert.Empty(result.ErrorMessages); + } + + [Fact] + public void CollectList_FileEntry_HasCorrectFields() + { + RpfFile rpf = MakeRpf(); + RpfBinaryFileEntry entry = MakeBinary("data.dat", "common\\data.dat", 512); + + Json.ListResult result = ListHandler.CollectList( + [(rpf, entry)], + rpf, + [], + MakeOptions(), + TestContext.Current.CancellationToken + ); + + _ = Assert.Single(result.Files); + Json.FileEntry file = result.Files[0]; + Assert.Equal("common\\data.dat", file.Path); + Assert.Equal("data.dat", file.Name); + Assert.Equal(512L, file.Size); + Assert.Equal(SizeFormat.IEC.ToFormattedString(512), file.SizeFormatted); + Assert.Equal("binary", file.Type); + Assert.Equal(".dat", file.Extension); + } + + [Fact] + public void CollectList_ResourceEntry_TypeIsResource() + { + RpfFile rpf = MakeRpf(); + RpfResourceFileEntry entry = MakeResource("model.ydr", "x64\\model.ydr", 4096); + + Json.ListResult result = ListHandler.CollectList( + [(rpf, entry)], + rpf, + [], + MakeOptions(), + TestContext.Current.CancellationToken + ); + + _ = Assert.Single(result.Files); + Assert.Equal("resource", result.Files[0].Type); + Assert.Equal(".ydr", result.Files[0].Extension); + } + + [Fact] + public void CollectList_SIFormat_UsesCorrectFormatting() + { + RpfFile rpf = MakeRpf(); + RpfBinaryFileEntry entry = MakeBinary("data.dat", "common\\data.dat", 2000); + + Json.ListResult result = ListHandler.CollectList( + [(rpf, entry)], + rpf, + [], + MakeOptions(sizeFormat: SizeFormat.SI), + TestContext.Current.CancellationToken + ); + + Assert.Equal(SizeFormat.SI.ToFormattedString(2000), result.TotalSizeFormatted); + Assert.Equal(SizeFormat.SI.ToFormattedString(2000), result.Files[0].SizeFormatted); + } + + [Fact] + public void CollectList_WithScanErrors_SetsSuccessFalse() + { + RpfFile rpf = MakeRpf(); + List scanErrors = ["scan error 1", "scan error 2"]; + + Json.ListResult result = ListHandler.CollectList( + [], + rpf, + scanErrors, + MakeOptions(), + TestContext.Current.CancellationToken + ); + + Assert.False(result.Success); + Assert.Equal(2, result.ErrorMessages.Count); + Assert.Equal("scan error 1", result.ErrorMessages[0]); + Assert.Equal("scan error 2", result.ErrorMessages[1]); + } + + [Fact] + public void CollectList_NoExtension_ReturnsEmptyExtension() + { + RpfFile rpf = MakeRpf(); + RpfBinaryFileEntry entry = MakeBinary("README", "common\\README", 100); + + Json.ListResult result = ListHandler.CollectList( + [(rpf, entry)], + rpf, + [], + MakeOptions(), + TestContext.Current.CancellationToken + ); + + Assert.Equal("", result.Files[0].Extension); + } + + [Fact] + public void CollectList_Cancelled_ThrowsOperationCanceledException() + { + using CancellationTokenSource cts = new(); + cts.Cancel(); + + RpfFile rpf = MakeRpf(); + RpfBinaryFileEntry entry = MakeBinary("data.dat", "common\\data.dat", 100); + + _ = Assert.Throws( + () => ListHandler.CollectList( + [(rpf, entry)], + rpf, + [], + MakeOptions(), + cts.Token + ) + ); + } + + // ErrorResult + + [Fact] + public void ErrorResult_HasExpectedDefaults() + { + Json.ListResult result = ListHandler.ErrorResult([], MakeOptions("/some/path.rpf")); + + Assert.False(result.Success); + Assert.Equal("/some/path.rpf", result.RpfFile); + Assert.Equal(0, result.TotalFiles); + Assert.Equal(0L, result.TotalSize); + Assert.Equal("0 B", result.TotalSizeFormatted); + Assert.Equal(0L, result.NestedRpfCount); + Assert.Empty(result.Files); + Assert.Empty(result.ErrorMessages); + } + + [Fact] + public void ErrorResult_PreservesErrorMessages() + { + string[] errors = ["err1", "err2"]; + + Json.ListResult result = ListHandler.ErrorResult(errors, MakeOptions()); + + Assert.Equal(2, result.ErrorMessages.Count); + Assert.Equal("err1", result.ErrorMessages[0]); + Assert.Equal("err2", result.ErrorMessages[1]); + } + + // PrintList (text output) + + [Fact] + public void PrintList_NonVerbose_PrintsPathsOnly() + { + Json.ListResult result = new() + { + Success = true, + RpfFile = "test.rpf", + TotalFiles = 2, + TotalSize = 3072, + TotalSizeFormatted = "3 KiB", + NestedRpfCount = 1, + Files = + [ + new Json.FileEntry { Path = "common\\data.dat", Name = "data.dat", Size = 1024, SizeFormatted = "1 KiB", Type = "binary", Extension = ".dat" }, + new Json.FileEntry { Path = "common\\info.bin", Name = "info.bin", Size = 2048, SizeFormatted = "2 KiB", Type = "binary", Extension = ".bin" }, + ], + ErrorMessages = [], + }; + + TextWriter origOut = Console.Out; + TextWriter origErr = Console.Error; + try + { + StringWriter stdout = new(); + Console.SetOut(stdout); + Console.SetError(new StringWriter()); + + ListHandler.PrintList(result, MakeOptions(), TestContext.Current.CancellationToken); + + string output = stdout.ToString(); + string[] lines = output.Split(SplitChars, StringSplitOptions.RemoveEmptyEntries); + Assert.Equal(2, lines.Length); + Assert.Equal("common\\data.dat", lines[0]); + Assert.Equal("common\\info.bin", lines[1]); + } + finally + { + Console.SetOut(origOut); + Console.SetError(origErr); + } + } + + [Fact] + public void PrintList_Verbose_PrintsSizeAndPath() + { + Json.ListResult result = new() + { + Success = true, + RpfFile = "test.rpf", + TotalFiles = 1, + TotalSize = 1024, + TotalSizeFormatted = "1 KiB", + NestedRpfCount = 1, + Files = + [ + new Json.FileEntry { Path = "common\\data.dat", Name = "data.dat", Size = 1024, SizeFormatted = "1 KiB", Type = "binary", Extension = ".dat" }, + ], + ErrorMessages = [], + }; + + TextWriter origOut = Console.Out; + TextWriter origErr = Console.Error; + try + { + StringWriter stdout = new(); + Console.SetOut(stdout); + Console.SetError(new StringWriter()); + + ListHandler.PrintList(result, MakeOptions(verbose: true), TestContext.Current.CancellationToken); + + string output = stdout.ToString(); + Assert.Contains("1 KiB", output); + Assert.Contains("common\\data.dat", output); + } + finally + { + Console.SetOut(origOut); + Console.SetError(origErr); + } + } + + [Fact] + public void PrintList_Verbose_SizeIsPaddedTo12Chars() + { + Json.ListResult result = new() + { + Success = true, + RpfFile = "test.rpf", + TotalFiles = 1, + TotalSize = 100, + TotalSizeFormatted = "100 B", + NestedRpfCount = 1, + Files = + [ + new Json.FileEntry { Path = "a.dat", Name = "a.dat", Size = 100, SizeFormatted = "100 B", Type = "binary", Extension = ".dat" }, + ], + ErrorMessages = [], + }; + + TextWriter origOut = Console.Out; + TextWriter origErr = Console.Error; + try + { + StringWriter stdout = new(); + Console.SetOut(stdout); + Console.SetError(new StringWriter()); + + ListHandler.PrintList(result, MakeOptions(verbose: true), TestContext.Current.CancellationToken); + + string[] lines = stdout.ToString().Split(SplitChars, StringSplitOptions.RemoveEmptyEntries); + _ = Assert.Single(lines); + // "100 B" (5 chars) padded left to 12 = 7 spaces + "100 B" + " " + path + Assert.Equal(" 100 B a.dat", lines[0]); + } + finally + { + Console.SetOut(origOut); + Console.SetError(origErr); + } + } + + [Fact] + public void PrintList_WritesSummaryToStderr() + { + Json.ListResult result = new() + { + Success = true, + RpfFile = "test.rpf", + TotalFiles = 5, + TotalSize = 10240, + TotalSizeFormatted = "10 KiB", + NestedRpfCount = 1, + Files = [], + ErrorMessages = [], + }; + + TextWriter origOut = Console.Out; + TextWriter origErr = Console.Error; + try + { + Console.SetOut(new StringWriter()); + StringWriter stderr = new(); + Console.SetError(stderr); + + ListHandler.PrintList(result, MakeOptions(), TestContext.Current.CancellationToken); + + string errOutput = stderr.ToString(); + Assert.Contains("Total: 5 files, 10 KiB", errOutput); + } + finally + { + Console.SetOut(origOut); + Console.SetError(origErr); + } + } + + [Fact] + public void PrintList_Cancelled_ThrowsOperationCanceledException() + { + using CancellationTokenSource cts = new(); + cts.Cancel(); + + Json.ListResult result = new() + { + Success = true, + RpfFile = "test.rpf", + TotalFiles = 1, + TotalSize = 100, + TotalSizeFormatted = "100 B", + NestedRpfCount = 1, + Files = + [ + new Json.FileEntry { Path = "a.dat", Name = "a.dat", Size = 100, SizeFormatted = "100 B", Type = "binary", Extension = ".dat" }, + ], + ErrorMessages = [], + }; + + TextWriter origOut = Console.Out; + TextWriter origErr = Console.Error; + try + { + Console.SetOut(new StringWriter()); + Console.SetError(new StringWriter()); + + _ = Assert.Throws( + () => ListHandler.PrintList(result, MakeOptions(), cts.Token) + ); + } + finally + { + Console.SetOut(origOut); + Console.SetError(origErr); + } + } + + // PrintJsonList + + [Fact] + public void PrintJsonList_SerializesToStdout() + { + Json.ListResult result = new() + { + Success = true, + RpfFile = "test.rpf", + TotalFiles = 1, + TotalSize = 512, + TotalSizeFormatted = "512 B", + NestedRpfCount = 1, + Files = + [ + new Json.FileEntry { Path = "common\\data.dat", Name = "data.dat", Size = 512, SizeFormatted = "512 B", Type = "binary", Extension = ".dat" }, + ], + ErrorMessages = [], + }; + + TextWriter origOut = Console.Out; + try + { + StringWriter stdout = new(); + Console.SetOut(stdout); + + ListHandler.PrintJsonList(result); + + string output = stdout.ToString(); + Assert.Contains("\"success\": true", output); + Assert.Contains("\"rpfFile\": \"test.rpf\"", output); + Assert.Contains("\"totalFiles\": 1", output); + Assert.Contains("\"totalSize\": 512", output); + Assert.Contains("\"totalSizeFormatted\": \"512 B\"", output); + Assert.Contains("\"nestedRpfCount\": 1", output); + Assert.Contains("\"path\": \"common\\\\data.dat\"", output); + Assert.Contains("\"name\": \"data.dat\"", output); + Assert.Contains("\"size\": 512", output); + Assert.Contains("\"type\": \"binary\"", output); + Assert.Contains("\"extension\": \".dat\"", output); + } + finally { Console.SetOut(origOut); } + } + + [Fact] + public void PrintJsonList_IsValidJson() + { + Json.ListResult result = new() + { + Success = true, + RpfFile = "test.rpf", + TotalFiles = 0, + TotalSize = 0, + TotalSizeFormatted = "0 B", + NestedRpfCount = 0, + Files = [], + ErrorMessages = [], + }; + + TextWriter origOut = Console.Out; + try + { + StringWriter stdout = new(); + Console.SetOut(stdout); + + ListHandler.PrintJsonList(result); + + string output = stdout.ToString().Trim(); + JsonDocument doc = JsonDocument.Parse(output); + Assert.Equal(JsonValueKind.Object, doc.RootElement.ValueKind); + } + finally { Console.SetOut(origOut); } + } + + [Fact] + public void PrintJsonList_OmitsNullStatus() + { + Json.ListResult result = new() + { + Success = true, + RpfFile = "test.rpf", + TotalFiles = 1, + TotalSize = 100, + TotalSizeFormatted = "100 B", + NestedRpfCount = 0, + Files = + [ + new Json.FileEntry { Path = "a.dat", Name = "a.dat", Size = 100, SizeFormatted = "100 B", Type = "binary", Extension = ".dat" }, + ], + ErrorMessages = [], + }; + + TextWriter origOut = Console.Out; + try + { + StringWriter stdout = new(); + Console.SetOut(stdout); + + ListHandler.PrintJsonList(result); + + string output = stdout.ToString(); + Assert.DoesNotContain("\"status\"", output); + } + finally { Console.SetOut(origOut); } + } + + [Fact] + public void PrintJsonList_IncludesErrorMessages() + { + Json.ListResult result = new() + { + Success = false, + RpfFile = "test.rpf", + TotalFiles = 0, + TotalSize = 0, + TotalSizeFormatted = "0 B", + NestedRpfCount = 0, + Files = [], + ErrorMessages = ["something broke"], + }; + + TextWriter origOut = Console.Out; + try + { + StringWriter stdout = new(); + Console.SetOut(stdout); + + ListHandler.PrintJsonList(result); + + string output = stdout.ToString(); + Assert.Contains("\"errorMessages\"", output); + Assert.Contains("something broke", output); + } + finally { Console.SetOut(origOut); } + } +} diff --git a/CodeWalker.Cli/Tests/Handlers/PackHandlerTests.cs b/CodeWalker.Cli/Tests/Handlers/PackHandlerTests.cs new file mode 100644 index 000000000..8cd55c76e --- /dev/null +++ b/CodeWalker.Cli/Tests/Handlers/PackHandlerTests.cs @@ -0,0 +1,215 @@ +using System; +using System.IO; + +using CodeWalker.Cli.Handlers; +using CodeWalker.Cli.Helpers; + +using Xunit; + +namespace CodeWalker.Cli.Tests.Handlers; + +[Collection("ConsoleOutput")] +public sealed class PackHandlerTests +{ + private static PackOptions MakeOptions( + string inputPath, + string outputPath, + bool json, + string exePath = "/nonexistent", + bool force = false + ) => + new() + { + InputPath = inputPath, + OutputPath = outputPath, + ExePath = exePath, + Verbose = false, + Json = json, + SizeFormat = SizeFormat.IEC, + Gen9 = false, + Force = force, + Progress = false, + }; + + private static string CreateTempDir() + { + string dir = Path.Combine(Path.GetTempPath(), "cw_pack_" + Guid.NewGuid().ToString("N")); + _ = Directory.CreateDirectory(dir); + return dir; + } + + // Input dir missing + + [Fact] + public void Execute_InputDirMissing_ReturnsOne() + { + TextWriter origOut = Console.Out; + TextWriter origErr = Console.Error; + try + { + StringWriter stderr = new(); + Console.SetOut(new StringWriter()); + Console.SetError(stderr); + + int exitCode = PackHandler.Execute(MakeOptions("/nonexistent/input", "/tmp/out.rpf", json: false), TestContext.Current.CancellationToken); + + Assert.Equal(1, exitCode); + Assert.Contains("Input directory not found", stderr.ToString()); + } + finally + { + Console.SetOut(origOut); + Console.SetError(origErr); + } + } + + [Fact] + public void Execute_InputDirMissing_Json_ReturnsErrorJson() + { + TextWriter origOut = Console.Out; + TextWriter origErr = Console.Error; + try + { + StringWriter stdout = new(); + Console.SetOut(stdout); + Console.SetError(new StringWriter()); + + int exitCode = PackHandler.Execute(MakeOptions("/nonexistent/input", "/tmp/out.rpf", json: true), TestContext.Current.CancellationToken); + + Assert.Equal(1, exitCode); + string output = stdout.ToString(); + Assert.Contains("\"success\": false", output); + Assert.Contains("Input directory not found", output); + } + finally + { + Console.SetOut(origOut); + Console.SetError(origErr); + } + } + + // Output file exists without --force + + [Fact] + public void Execute_OutputExists_NoForce_ReturnsOne() + { + string inputDir = CreateTempDir(); + string outputFile = Path.Combine(inputDir, "output.rpf"); + File.WriteAllBytes(outputFile, []); + try + { + TextWriter origOut = Console.Out; + TextWriter origErr = Console.Error; + try + { + StringWriter stderr = new(); + Console.SetOut(new StringWriter()); + Console.SetError(stderr); + + int exitCode = PackHandler.Execute( + MakeOptions(inputDir, outputFile, json: false, force: false), + TestContext.Current.CancellationToken + ); + + Assert.Equal(1, exitCode); + Assert.Contains("already exists", stderr.ToString()); + Assert.Contains("--force", stderr.ToString()); + } + finally + { + Console.SetOut(origOut); + Console.SetError(origErr); + } + } + finally { Directory.Delete(inputDir, true); } + } + + [Fact] + public void Execute_OutputExists_NoForce_Json_ReturnsErrorJson() + { + string inputDir = CreateTempDir(); + string outputFile = Path.Combine(inputDir, "output.rpf"); + File.WriteAllBytes(outputFile, []); + try + { + TextWriter origOut = Console.Out; + try + { + StringWriter stdout = new(); + Console.SetOut(stdout); + + int exitCode = PackHandler.Execute( + MakeOptions(inputDir, outputFile, json: true, force: false), + TestContext.Current.CancellationToken + ); + + Assert.Equal(1, exitCode); + string output = stdout.ToString(); + Assert.Contains("\"success\": false", output); + Assert.Contains("already exists", output); + } + finally { Console.SetOut(origOut); } + } + finally { Directory.Delete(inputDir, true); } + } + + // Missing exe + + [Fact] + public void Execute_MissingExe_ReturnsOne() + { + string inputDir = CreateTempDir(); + string outputFile = Path.Combine(Path.GetTempPath(), "cw_pack_out_" + Guid.NewGuid().ToString("N") + ".rpf"); + try + { + TextWriter origOut = Console.Out; + TextWriter origErr = Console.Error; + try + { + StringWriter stderr = new(); + Console.SetOut(new StringWriter()); + Console.SetError(stderr); + + int exitCode = PackHandler.Execute(MakeOptions(inputDir, outputFile, json: false), TestContext.Current.CancellationToken); + + Assert.Equal(1, exitCode); + Assert.Contains("Error:", stderr.ToString()); + } + finally + { + Console.SetOut(origOut); + Console.SetError(origErr); + } + } + finally + { + Directory.Delete(inputDir, true); + if (File.Exists(outputFile)) + File.Delete(outputFile); + } + } + + // JSON error structure + + [Fact] + public void Execute_Json_ErrorContainsExpectedFields() + { + TextWriter origOut = Console.Out; + try + { + StringWriter stdout = new(); + Console.SetOut(stdout); + + _ = PackHandler.Execute(MakeOptions("/nonexistent/input", "/tmp/out.rpf", json: true), TestContext.Current.CancellationToken); + + string output = stdout.ToString(); + Assert.Contains("\"inputDir\":", output); + Assert.Contains("\"outputFile\":", output); + Assert.Contains("\"totalFiles\": 0", output); + Assert.Contains("\"totalDirs\": 0", output); + Assert.Contains("\"totalSize\": 0", output); + Assert.Contains("\"errors\": 0", output); + } + finally { Console.SetOut(origOut); } + } +} diff --git a/CodeWalker.Cli/Tests/Handlers/SearchHandlerTests.cs b/CodeWalker.Cli/Tests/Handlers/SearchHandlerTests.cs new file mode 100644 index 000000000..cb20e96f4 --- /dev/null +++ b/CodeWalker.Cli/Tests/Handlers/SearchHandlerTests.cs @@ -0,0 +1,1464 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading; + +using CodeWalker.Cli.Handlers; +using CodeWalker.Cli.Helpers; +using CodeWalker.GameFiles; + +using Xunit; + +namespace CodeWalker.Cli.Tests.Handlers; + +// ErrorResult + +public sealed class SearchErrorResultTests +{ + private static SearchOptions MakeOptions(string rpfPath = "/test.rpf", string pattern = "*.ydr") => + new() + { + RpfPath = rpfPath, + ExePath = "/nonexistent", + Gen9 = false, + Filters = [], + Verbose = false, + Json = false, + Recursive = false, + SizeFormat = SizeFormat.IEC, + Pattern = pattern, + }; + + [Fact] + public void ErrorResult_SetsSuccessFalse() + { + Json.SearchResult result = SearchHandler.ErrorResult([], MakeOptions(pattern: "*.ydr")); + Assert.False(result.Success); + } + + [Fact] + public void ErrorResult_PreservesRpfFile() + { + Json.SearchResult result = SearchHandler.ErrorResult([], MakeOptions("/my/test.rpf", pattern: "test")); + Assert.Equal("/my/test.rpf", result.RpfFile); + } + + [Fact] + public void ErrorResult_PreservesPattern() + { + Json.SearchResult result = SearchHandler.ErrorResult([], MakeOptions(pattern: "adder")); + Assert.Equal("adder", result.Pattern); + } + + [Fact] + public void ErrorResult_SetsMatchCountZero() + { + Json.SearchResult result = SearchHandler.ErrorResult([], MakeOptions(pattern: "*.ydr")); + Assert.Equal(0, result.MatchCount); + } + + [Fact] + public void ErrorResult_SetsEmptyMatches() + { + Json.SearchResult result = SearchHandler.ErrorResult([], MakeOptions(pattern: "*.ydr")); + Assert.Empty(result.Matches); + } + + [Fact] + public void ErrorResult_PreservesErrorMessages() + { + string[] msgs = ["err1", "err2"]; + Json.SearchResult result = SearchHandler.ErrorResult(msgs, MakeOptions(pattern: "*.ydr")); + Assert.Equal(msgs, result.ErrorMessages); + } +} + +// CollectSearch + +public sealed class SearchCollectSearchTests +{ + private static SearchOptions MakeOptions( + string rpfPath = "/test.rpf", + bool recursive = false, + bool verbose = false, + string[]? filters = null, + string pattern = "") => + new() + { + RpfPath = rpfPath, + ExePath = "/nonexistent", + Gen9 = false, + Filters = filters ?? [], + Verbose = verbose, + Json = false, + Recursive = recursive, + SizeFormat = SizeFormat.IEC, + Pattern = pattern, + }; + + private static RpfFile MakeRpf() => + new("test.rpf", "test.rpf", 0); + + private static RpfBinaryFileEntry MakeBinary(string name, string path, uint fileSize = 1024) => + new() + { + Name = name, + NameLower = name.ToLowerInvariant(), + Path = path, + FileSize = fileSize, + FileUncompressedSize = fileSize, + }; + + private static RpfResourceFileEntry MakeResource(string name, string path, uint fileSize = 2048) => + new() + { + Name = name, + NameLower = name.ToLowerInvariant(), + Path = path, + FileSize = fileSize, + }; + + [Fact] + public void CollectSearch_EmptyEntries_ReturnsZeroMatches() + { + RpfFile rpf = MakeRpf(); + rpf.AllEntries = []; + + Json.SearchResult result = SearchHandler.CollectSearch(rpf, [], MakeOptions(pattern: "adder"), cancellationToken: TestContext.Current.CancellationToken); + + Assert.True(result.Success); + Assert.Equal(0, result.MatchCount); + Assert.Empty(result.Matches); + } + + [Fact] + public void CollectSearch_NullEntries_ReturnsZeroMatches() + { + RpfFile rpf = MakeRpf(); + rpf.AllEntries = null; + + Json.SearchResult result = SearchHandler.CollectSearch(rpf, [], MakeOptions(pattern: "adder"), cancellationToken: TestContext.Current.CancellationToken); + + Assert.True(result.Success); + Assert.Equal(0, result.MatchCount); + } + + [Fact] + public void CollectSearch_SubstringMatch_FindsEntries() + { + RpfFile rpf = MakeRpf(); + rpf.AllEntries = + [ + MakeBinary("adder.ydr", "vehicles/adder.ydr"), + MakeBinary("zentorno.ydr", "vehicles/zentorno.ydr"), + MakeBinary("adder.ytd", "vehicles/adder.ytd"), + ]; + + Json.SearchResult result = SearchHandler.CollectSearch(rpf, [], MakeOptions(pattern: "adder"), cancellationToken: TestContext.Current.CancellationToken); + + Assert.True(result.Success); + Assert.Equal(2, result.MatchCount); + } + + [Fact] + public void CollectSearch_ExtensionSubstring_FindsEntries() + { + RpfFile rpf = MakeRpf(); + rpf.AllEntries = + [ + MakeBinary("adder.ydr", "vehicles/adder.ydr"), + MakeBinary("adder.ytd", "vehicles/adder.ytd"), + MakeBinary("zentorno.ydr", "vehicles/zentorno.ydr"), + ]; + + Json.SearchResult result = SearchHandler.CollectSearch(rpf, [], MakeOptions(pattern: ".ydr"), cancellationToken: TestContext.Current.CancellationToken); + + Assert.True(result.Success); + Assert.Equal(2, result.MatchCount); + } + + [Fact] + public void CollectSearch_MatchPopulatesAllFields() + { + RpfFile rpf = MakeRpf(); + rpf.AllEntries = + [ + MakeBinary("adder.ydr", "vehicles/adder.ydr", fileSize: 4096), + ]; + + Json.SearchResult result = SearchHandler.CollectSearch(rpf, [], MakeOptions(pattern: "adder"), cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal(1, result.MatchCount); + Json.SearchMatch match = result.Matches[0]; + Assert.Equal("vehicles/adder.ydr", match.Path); + Assert.Equal("adder.ydr", match.Name); + Assert.Equal(4096, match.Size); + Assert.Equal("binary", match.Type); + Assert.Equal(".ydr", match.Extension); + } + + [Fact] + public void CollectSearch_ResourceEntry_SetsTypeResource() + { + RpfFile rpf = MakeRpf(); + rpf.AllEntries = + [ + MakeResource("adder.ydr", "vehicles/adder.ydr"), + ]; + + Json.SearchResult result = SearchHandler.CollectSearch(rpf, [], MakeOptions(pattern: "adder"), cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal(1, result.MatchCount); + Assert.Equal("resource", result.Matches[0].Type); + } + + [Fact] + public void CollectSearch_DirectoryEntry_SetsTypeDirectory() + { + RpfFile rpf = MakeRpf(); + rpf.AllEntries = + [ + new RpfDirectoryEntry + { + Name = "vehicles", + NameLower = "vehicles", + Path = "vehicles", + }, + ]; + + Json.SearchResult result = SearchHandler.CollectSearch(rpf, [], MakeOptions(pattern: "vehicles"), cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal(1, result.MatchCount); + Assert.Equal("directory", result.Matches[0].Type); + Assert.Equal(0, result.Matches[0].Size); + Assert.Equal("", result.Matches[0].Extension); + } + + [Fact] + public void CollectSearch_NoMatch_ReturnsEmpty() + { + RpfFile rpf = MakeRpf(); + rpf.AllEntries = + [ + MakeBinary("adder.ydr", "vehicles/adder.ydr"), + ]; + + Json.SearchResult result = SearchHandler.CollectSearch(rpf, [], MakeOptions(pattern: "weapons"), cancellationToken: TestContext.Current.CancellationToken); + + Assert.True(result.Success); + Assert.Equal(0, result.MatchCount); + Assert.Empty(result.Matches); + } + + [Fact] + public void CollectSearch_ScanErrors_SetsSuccessFalse() + { + RpfFile rpf = MakeRpf(); + rpf.AllEntries = + [ + MakeBinary("adder.ydr", "vehicles/adder.ydr"), + ]; + List scanErrors = ["scan error 1"]; + + Json.SearchResult result = SearchHandler.CollectSearch(rpf, scanErrors, MakeOptions(pattern: "adder"), cancellationToken: TestContext.Current.CancellationToken); + + Assert.False(result.Success); + Assert.Equal(1, result.MatchCount); + Assert.Contains("scan error 1", result.ErrorMessages); + } + + [Fact] + public void CollectSearch_WithFilter_NarrowsResults() + { + RpfFile rpf = MakeRpf(); + rpf.AllEntries = + [ + MakeBinary("adder.ydr", "vehicles/adder.ydr"), + MakeBinary("adder.ytd", "vehicles/adder.ytd"), + ]; + + Json.SearchResult result = SearchHandler.CollectSearch(rpf, [], MakeOptions(filters: Filter.Normalize(["*.ydr"]), pattern: "adder"), cancellationToken: TestContext.Current.CancellationToken); + + Assert.True(result.Success); + Assert.Equal(1, result.MatchCount); + Assert.Equal("adder.ydr", result.Matches[0].Name); + } + + [Fact] + public void CollectSearch_WithFilter_EmptyFilters_MatchesAll() + { + RpfFile rpf = MakeRpf(); + rpf.AllEntries = + [ + MakeBinary("adder.ydr", "vehicles/adder.ydr"), + MakeBinary("adder.ytd", "vehicles/adder.ytd"), + ]; + + Json.SearchResult result = SearchHandler.CollectSearch(rpf, [], MakeOptions(pattern: "adder"), cancellationToken: TestContext.Current.CancellationToken); + + Assert.True(result.Success); + Assert.Equal(2, result.MatchCount); + } + + [Fact] + public void CollectSearch_BackslashPattern_NormalizesAndMatches() + { + RpfFile rpf = MakeRpf(); + rpf.AllEntries = + [ + MakeBinary("adder.ydr", "vehicles/adder.ydr"), + MakeBinary("zentorno.ydr", "vehicles/zentorno.ydr"), + ]; + + Json.SearchResult result = SearchHandler.CollectSearch(rpf, [], MakeOptions(pattern: "vehicles\\adder"), cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal(1, result.MatchCount); + Assert.Equal("vehicles/adder.ydr", result.Matches[0].Path); + } + + [Fact] + public void CollectSearch_CaseInsensitive_MatchesUppercasePattern() + { + RpfFile rpf = MakeRpf(); + rpf.AllEntries = + [ + MakeBinary("adder.ydr", "vehicles/adder.ydr"), + MakeBinary("zentorno.ydr", "vehicles/zentorno.ydr"), + ]; + + Json.SearchResult result = SearchHandler.CollectSearch(rpf, [], MakeOptions(pattern: "ADDER"), cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal(1, result.MatchCount); + Assert.Equal("adder.ydr", result.Matches[0].Name); + } + + [Fact] + public void CollectSearch_NullPathEntry_IsSkipped() + { + RpfFile rpf = MakeRpf(); + rpf.AllEntries = + [ + new RpfBinaryFileEntry + { + Name = "adder.ydr", + NameLower = "adder.ydr", + Path = null, + FileSize = 1024, + FileUncompressedSize = 1024, + }, + ]; + + Json.SearchResult result = SearchHandler.CollectSearch(rpf, [], MakeOptions(pattern: "adder"), cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal(0, result.MatchCount); + Assert.Empty(result.Matches); + } + + [Fact] + public void CollectSearch_SetsRpfFileAndPattern() + { + RpfFile rpf = MakeRpf(); + rpf.AllEntries = []; + + Json.SearchResult result = SearchHandler.CollectSearch(rpf, [], MakeOptions("/my/archive.rpf", pattern: "test"), cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal("/my/archive.rpf", result.RpfFile); + Assert.Equal("test", result.Pattern); + } +} + +// CollectAllEntries + +public sealed class SearchCollectAllEntriesTests +{ + private static RpfFile MakeRpf() => + new("test.rpf", "test.rpf", 0); + + private static RpfBinaryFileEntry MakeBinary(string name, string path) => + new() + { + Name = name, + NameLower = name.ToLowerInvariant(), + Path = path, + FileSize = 1024, + FileUncompressedSize = 1024, + }; + + [Fact] + public void CollectAllEntries_NullEntries_CollectsNothing() + { + RpfFile rpf = MakeRpf(); + rpf.AllEntries = null; + List entries = []; + + SearchHandler.CollectAllEntries(rpf, recursive: false, entries); + + Assert.Empty(entries); + } + + [Fact] + public void CollectAllEntries_FlatEntries_CollectsAll() + { + RpfFile rpf = MakeRpf(); + rpf.AllEntries = [MakeBinary("a.ydr", "a.ydr"), MakeBinary("b.ydr", "b.ydr")]; + List entries = []; + + SearchHandler.CollectAllEntries(rpf, recursive: false, entries); + + Assert.Equal(2, entries.Count); + } + + [Fact] + public void CollectAllEntries_NotRecursive_SkipsChildren() + { + RpfFile rpf = MakeRpf(); + rpf.AllEntries = [MakeBinary("a.ydr", "a.ydr")]; + + RpfFile child = new("child.rpf", "child.rpf", 0) + { + AllEntries = [MakeBinary("b.ydr", "child.rpf/b.ydr")], + }; + rpf.Children = [child]; + + List entries = []; + SearchHandler.CollectAllEntries(rpf, recursive: false, entries); + + _ = Assert.Single(entries); + Assert.Equal("a.ydr", entries[0].Name); + } + + [Fact] + public void CollectAllEntries_Recursive_IncludesChildren() + { + RpfFile rpf = MakeRpf(); + rpf.AllEntries = [MakeBinary("a.ydr", "a.ydr")]; + + RpfFile child = new("child.rpf", "child.rpf", 0) + { + AllEntries = [MakeBinary("b.ydr", "child.rpf/b.ydr")], + }; + rpf.Children = [child]; + + List entries = []; + SearchHandler.CollectAllEntries(rpf, recursive: true, entries); + + Assert.Equal(2, entries.Count); + } + + [Fact] + public void CollectAllEntries_Recursive_NestedChildren() + { + RpfFile rpf = MakeRpf(); + rpf.AllEntries = [MakeBinary("a.ydr", "a.ydr")]; + + RpfFile grandchild = new("grandchild.rpf", "grandchild.rpf", 0) + { + AllEntries = [MakeBinary("c.ydr", "grandchild.rpf/c.ydr")], + }; + + RpfFile child = new("child.rpf", "child.rpf", 0) + { + AllEntries = [MakeBinary("b.ydr", "child.rpf/b.ydr")], + Children = [grandchild], + }; + rpf.Children = [child]; + + List entries = []; + SearchHandler.CollectAllEntries(rpf, recursive: true, entries); + + Assert.Equal(3, entries.Count); + } + + [Fact] + public void CollectAllEntries_NullChildren_DoesNotThrow() + { + RpfFile rpf = MakeRpf(); + rpf.AllEntries = [MakeBinary("a.ydr", "a.ydr")]; + rpf.Children = null; + + List entries = []; + SearchHandler.CollectAllEntries(rpf, recursive: true, entries); + + _ = Assert.Single(entries); + } +} + +// Cancellation + +public sealed class SearchCancellationTests +{ + private static RpfFile MakeRpf() => + new("test.rpf", "test.rpf", 0); + + private static RpfBinaryFileEntry MakeBinary(string name, string path) => + new() + { + Name = name, + NameLower = name.ToLowerInvariant(), + Path = path, + FileSize = 1024, + FileUncompressedSize = 1024, + }; + + private static SearchOptions MakeOptions(string pattern = "*") => + new() + { + RpfPath = "/test.rpf", + ExePath = "/nonexistent", + Gen9 = false, + Filters = [], + Verbose = false, + Json = false, + Recursive = false, + SizeFormat = SizeFormat.IEC, + Pattern = pattern, + }; + + [Fact] + public void CollectSearch_Cancelled_ThrowsOperationCanceledException() + { + RpfFile rpf = MakeRpf(); + rpf.AllEntries = + [ + MakeBinary("a.ydr", "a.ydr"), + MakeBinary("b.ydr", "b.ydr"), + ]; + + using CancellationTokenSource cts = new(); + cts.Cancel(); + + _ = Assert.Throws( + () => SearchHandler.CollectSearch(rpf, [], MakeOptions("*"), cancellationToken: cts.Token) + ); + } +} + +// PrintSearch / PrintJsonSearch + +[Collection("ConsoleOutput")] +public sealed class SearchPrintTests +{ + private static readonly char[] SplitChars = ['\r', '\n']; + + private static SearchOptions MakeOptions(bool verbose = false, SizeFormat sizeFormat = SizeFormat.IEC) => + new() + { + RpfPath = "/test.rpf", + ExePath = "/nonexistent", + Gen9 = false, + Filters = [], + Verbose = verbose, + Json = false, + Recursive = false, + SizeFormat = sizeFormat, + Pattern = "", + }; + + private static Json.SearchResult MakeResult( + List? matches = null, + string pattern = "*.ydr", + int? matchCount = null, + IReadOnlyList? rpfFiles = null) => + new() + { + Success = true, + RpfFile = "/test.rpf", + RpfFiles = rpfFiles ?? ["/test.rpf"], + Pattern = pattern, + MatchCount = matchCount ?? matches?.Count ?? 0, + Matches = matches ?? [], + ErrorMessages = [], + }; + + private static Json.SearchMatch MakeMatch( + string path = "vehicles/adder.ydr", + string name = "adder.ydr", + long size = 4096, + string type = "binary", + string extension = ".ydr", + string archive = "/test.rpf") => + new() + { + Archive = archive, + Path = path, + Name = name, + Size = size, + Type = type, + Extension = extension, + }; + + // PrintSearch (text) + + [Fact] + public void PrintSearch_NonVerbose_PrintsPathsOnly() + { + TextWriter origOut = Console.Out; + TextWriter origErr = Console.Error; + try + { + StringWriter stdout = new(); + StringWriter stderr = new(); + Console.SetOut(stdout); + Console.SetError(stderr); + + Json.SearchResult result = MakeResult([MakeMatch(), MakeMatch("weapons/pistol.ydr", "pistol.ydr")]); + SearchHandler.PrintSearch(result, MakeOptions(verbose: false)); + + string output = stdout.ToString(); + string[] lines = output.Split(SplitChars, StringSplitOptions.RemoveEmptyEntries); + Assert.Equal(2, lines.Length); + Assert.Equal("vehicles/adder.ydr", lines[0]); + Assert.Equal("weapons/pistol.ydr", lines[1]); + } + finally + { + Console.SetOut(origOut); + Console.SetError(origErr); + } + } + + [Fact] + public void PrintSearch_Verbose_PrintsSizeAndPath() + { + TextWriter origOut = Console.Out; + TextWriter origErr = Console.Error; + try + { + StringWriter stdout = new(); + StringWriter stderr = new(); + Console.SetOut(stdout); + Console.SetError(stderr); + + Json.SearchResult result = MakeResult([MakeMatch(size: 1024)]); + SearchHandler.PrintSearch(result, MakeOptions(verbose: true)); + + string output = stdout.ToString(); + Assert.Contains("1 KiB", output); + Assert.Contains("vehicles/adder.ydr", output); + } + finally + { + Console.SetOut(origOut); + Console.SetError(origErr); + } + } + + [Fact] + public void PrintSearch_PrintsSummaryToStderr() + { + TextWriter origOut = Console.Out; + TextWriter origErr = Console.Error; + try + { + StringWriter stdout = new(); + StringWriter stderr = new(); + Console.SetOut(stdout); + Console.SetError(stderr); + + Json.SearchResult result = MakeResult( + [MakeMatch()], + pattern: "adder"); + SearchHandler.PrintSearch(result, MakeOptions()); + + string errOutput = stderr.ToString(); + Assert.Contains("Found 1 match for", errOutput); + Assert.Contains("'adder'", errOutput); + } + finally + { + Console.SetOut(origOut); + Console.SetError(origErr); + } + } + + [Fact] + public void PrintSearch_EmptyResults_PrintsSummaryOnly() + { + TextWriter origOut = Console.Out; + TextWriter origErr = Console.Error; + try + { + StringWriter stdout = new(); + StringWriter stderr = new(); + Console.SetOut(stdout); + Console.SetError(stderr); + + Json.SearchResult result = MakeResult(pattern: "nothing"); + SearchHandler.PrintSearch(result, MakeOptions()); + + Assert.Equal("", stdout.ToString()); + Assert.Contains("Found 0 matches", stderr.ToString()); + } + finally + { + Console.SetOut(origOut); + Console.SetError(origErr); + } + } + + [Fact] + public void PrintSearch_Verbose_SingleArchive_NoArchiveHeaders() + { + TextWriter origOut = Console.Out; + TextWriter origErr = Console.Error; + try + { + StringWriter stdout = new(); + StringWriter stderr = new(); + Console.SetOut(stdout); + Console.SetError(stderr); + + Json.SearchResult result = MakeResult([MakeMatch(size: 2048)]); + SearchHandler.PrintSearch(result, MakeOptions(verbose: true)); + + string errOutput = stderr.ToString(); + Assert.DoesNotContain("==", errOutput); + + string stdoutOutput = stdout.ToString(); + Assert.Contains("2 KiB", stdoutOutput); + Assert.Contains("vehicles/adder.ydr", stdoutOutput); + } + finally + { + Console.SetOut(origOut); + Console.SetError(origErr); + } + } + + [Fact] + public void PrintSearch_Verbose_SIFormat_PrintsSIUnits() + { + TextWriter origOut = Console.Out; + TextWriter origErr = Console.Error; + try + { + StringWriter stdout = new(); + StringWriter stderr = new(); + Console.SetOut(stdout); + Console.SetError(stderr); + + Json.SearchResult result = MakeResult([MakeMatch(size: 1000)]); + SearchHandler.PrintSearch(result, MakeOptions(verbose: true, sizeFormat: SizeFormat.SI)); + + string output = stdout.ToString(); + Assert.Contains("1 KB", output); + Assert.DoesNotContain("KiB", output); + Assert.Contains("vehicles/adder.ydr", output); + } + finally + { + Console.SetOut(origOut); + Console.SetError(origErr); + } + } + + // PrintJsonSearch + + [Fact] + public void PrintJsonSearch_OutputsValidJson() + { + TextWriter origOut = Console.Out; + try + { + StringWriter stdout = new(); + Console.SetOut(stdout); + + Json.SearchResult result = MakeResult( + [MakeMatch()], + pattern: "adder"); + SearchHandler.PrintJsonSearch(result); + + string output = stdout.ToString(); + Assert.Contains("\"success\": true", output); + Assert.Contains("\"pattern\": \"adder\"", output); + Assert.Contains("\"matchCount\": 1", output); + Assert.Contains("\"path\": \"vehicles/adder.ydr\"", output); + } + finally + { + Console.SetOut(origOut); + } + } + + [Fact] + public void PrintJsonSearch_EmptyMatches_OutputsEmptyArray() + { + TextWriter origOut = Console.Out; + try + { + StringWriter stdout = new(); + Console.SetOut(stdout); + + Json.SearchResult result = MakeResult(); + SearchHandler.PrintJsonSearch(result); + + string output = stdout.ToString(); + Assert.Contains("\"matches\": []", output); + Assert.Contains("\"matchCount\": 0", output); + } + finally + { + Console.SetOut(origOut); + } + } +} + +// Execute (validation failures) + +[Collection("ConsoleOutput")] +public sealed class SearchHandlerExecuteTests +{ + private static SearchOptions MakeOptions(string rpfPath, bool json, string pattern = "", string? dirPath = null) => + new() + { + RpfPath = rpfPath, + ExePath = "/nonexistent", + Gen9 = false, + Filters = [], + Verbose = false, + Json = json, + Recursive = false, + SizeFormat = SizeFormat.IEC, + Pattern = pattern, + DirPath = dirPath, + }; + + [Fact] + public void Execute_MissingRpf_ReturnsOne() + { + TextWriter origOut = Console.Out; + TextWriter origErr = Console.Error; + try + { + StringWriter stderr = new(); + Console.SetOut(new StringWriter()); + Console.SetError(stderr); + + int exitCode = SearchHandler.Execute(MakeOptions("/nonexistent/test.rpf", json: false, pattern: "*.ydr"), cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal(1, exitCode); + Assert.Contains("Error:", stderr.ToString()); + } + finally + { + Console.SetOut(origOut); + Console.SetError(origErr); + } + } + + [Fact] + public void Execute_MissingRpf_Json_ReturnsErrorJson() + { + TextWriter origOut = Console.Out; + TextWriter origErr = Console.Error; + try + { + StringWriter stdout = new(); + Console.SetOut(stdout); + Console.SetError(new StringWriter()); + + int exitCode = SearchHandler.Execute(MakeOptions("/nonexistent/test.rpf", json: true, pattern: "adder"), cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal(1, exitCode); + string output = stdout.ToString(); + Assert.Contains("\"success\": false", output); + Assert.Contains("RPF file not found", output); + } + finally + { + Console.SetOut(origOut); + Console.SetError(origErr); + } + } + + [Fact] + public void Execute_Json_ErrorContainsExpectedFields() + { + TextWriter origOut = Console.Out; + try + { + StringWriter stdout = new(); + Console.SetOut(stdout); + + _ = SearchHandler.Execute(MakeOptions("/nonexistent/test.rpf", json: true, pattern: "test*"), cancellationToken: TestContext.Current.CancellationToken); + + string output = stdout.ToString(); + Assert.Contains("\"rpfFile\":", output); + Assert.Contains("\"pattern\":", output); + Assert.Contains("\"matchCount\": 0", output); + } + finally { Console.SetOut(origOut); } + } + + [Fact] + public void Execute_WithDirPath_DelegatesToExecuteDirectory() + { + TextWriter origOut = Console.Out; + TextWriter origErr = Console.Error; + try + { + StringWriter stderr = new(); + Console.SetOut(new StringWriter()); + Console.SetError(stderr); + + int exitCode = SearchHandler.Execute( + MakeOptions("/unused.rpf", json: false, pattern: "*.ydr", dirPath: "/nonexistent_dir_xyz_12345"), + cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal(1, exitCode); + Assert.Contains("Directory not found", stderr.ToString()); + } + finally + { + Console.SetOut(origOut); + Console.SetError(origErr); + } + } + + [Fact] + public void Execute_WithDirPath_Json_DelegatesToExecuteDirectory() + { + TextWriter origOut = Console.Out; + TextWriter origErr = Console.Error; + try + { + StringWriter stdout = new(); + Console.SetOut(stdout); + Console.SetError(new StringWriter()); + + int exitCode = SearchHandler.Execute( + MakeOptions("/unused.rpf", json: true, pattern: "adder", dirPath: "/nonexistent_dir_xyz_12345"), + cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal(1, exitCode); + string output = stdout.ToString(); + Assert.Contains("\"success\": false", output); + Assert.Contains("Directory not found", output); + } + finally + { + Console.SetOut(origOut); + Console.SetError(origErr); + } + } +} + +// CollectSearch Archive field + +public sealed class SearchCollectSearchArchiveTests +{ + private static SearchOptions MakeOptions(string rpfPath = "/test.rpf", string pattern = "") => + new() + { + RpfPath = rpfPath, + ExePath = "/nonexistent", + Gen9 = false, + Filters = [], + Verbose = false, + Json = false, + Recursive = false, + SizeFormat = SizeFormat.IEC, + Pattern = pattern, + }; + + private static RpfFile MakeRpf() => + new("test.rpf", "test.rpf", 0); + + private static RpfBinaryFileEntry MakeBinary(string name, string path, uint fileSize = 1024) => + new() + { + Name = name, + NameLower = name.ToLowerInvariant(), + Path = path, + FileSize = fileSize, + FileUncompressedSize = fileSize, + }; + + [Fact] + public void CollectSearch_DefaultArchive_UsesRpfPath() + { + RpfFile rpf = MakeRpf(); + rpf.AllEntries = [MakeBinary("a.ydr", "a.ydr")]; + + Json.SearchResult result = SearchHandler.CollectSearch(rpf, [], MakeOptions("/my/archive.rpf", pattern: "a"), cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal("/my/archive.rpf", result.Matches[0].Archive); + Assert.Equal("/my/archive.rpf", result.RpfFile); + _ = Assert.Single(result.RpfFiles); + Assert.Equal("/my/archive.rpf", result.RpfFiles[0]); + } + + [Fact] + public void CollectSearch_ExplicitArchive_MultipleMatches_AllHaveArchiveField() + { + RpfFile rpf = MakeRpf(); + rpf.AllEntries = + [ + MakeBinary("a.ydr", "a.ydr"), + MakeBinary("b.ydr", "b.ydr"), + ]; + + Json.SearchResult result = SearchHandler.CollectSearch(rpf, [], MakeOptions(pattern: ".ydr"), archive: "/dir/test.rpf", cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal(2, result.MatchCount); + Assert.All(result.Matches, m => Assert.Equal("/dir/test.rpf", m.Archive)); + } + + [Fact] + public void CollectSearch_ExplicitArchive_SetsArchiveField() + { + RpfFile rpf = MakeRpf(); + rpf.AllEntries = [MakeBinary("a.ydr", "a.ydr")]; + + Json.SearchResult result = SearchHandler.CollectSearch(rpf, [], MakeOptions(pattern: "a"), archive: "/dir/custom.rpf", cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal("/dir/custom.rpf", result.Matches[0].Archive); + Assert.Equal("/dir/custom.rpf", result.RpfFile); + _ = Assert.Single(result.RpfFiles); + Assert.Equal("/dir/custom.rpf", result.RpfFiles[0]); + } +} + +// ErrorResult RpfFiles + +public sealed class SearchErrorResultRpfFilesTests +{ + private static SearchOptions MakeOptions(string pattern = "*.ydr") => + new() + { + RpfPath = "/test.rpf", + ExePath = "/nonexistent", + Gen9 = false, + Filters = [], + Verbose = false, + Json = false, + Recursive = false, + SizeFormat = SizeFormat.IEC, + Pattern = pattern, + }; + + [Fact] + public void ErrorResult_SetsEmptyRpfFiles() + { + Json.SearchResult result = SearchHandler.ErrorResult([], MakeOptions("*.ydr")); + Assert.Empty(result.RpfFiles); + } +} + +// PrintSearch multi-archive + +[Collection("ConsoleOutput")] +public sealed class SearchPrintMultiArchiveTests +{ + private static readonly char[] SplitChars = ['\r', '\n']; + + private static SearchOptions MakeOptions(bool verbose = false) => + new() + { + RpfPath = "/dir", + ExePath = "/nonexistent", + Gen9 = false, + Filters = [], + Verbose = verbose, + Json = false, + Recursive = false, + SizeFormat = SizeFormat.IEC, + Pattern = "", + }; + + private static Json.SearchMatch MakeMatch(string archive, string path, string name) => + new() + { + Archive = archive, + Path = path, + Name = name, + Size = 1024, + Type = "binary", + Extension = ".ydr", + }; + + [Fact] + public void PrintSearch_MultiRpf_GroupsByArchive() + { + TextWriter origOut = Console.Out; + TextWriter origErr = Console.Error; + try + { + StringWriter stdout = new(); + StringWriter stderr = new(); + Console.SetOut(stdout); + Console.SetError(stderr); + + Json.SearchResult result = new() + { + Success = true, + RpfFile = "/dir", + RpfFiles = ["/dir/a.rpf", "/dir/b.rpf"], + Pattern = "*.ydr", + MatchCount = 2, + Matches = + [ + MakeMatch("/dir/a.rpf", "vehicles/adder.ydr", "adder.ydr"), + MakeMatch("/dir/b.rpf", "vehicles/zentorno.ydr", "zentorno.ydr"), + ], + ErrorMessages = [], + }; + + SearchHandler.PrintSearch(result, MakeOptions()); + + string errOutput = stderr.ToString(); + Assert.Contains("== a.rpf ==", errOutput); + Assert.Contains("== b.rpf ==", errOutput); + + string[] stdoutLines = stdout.ToString().Split(SplitChars, StringSplitOptions.RemoveEmptyEntries); + Assert.Equal(2, stdoutLines.Length); + Assert.Equal("vehicles/adder.ydr", stdoutLines[0]); + Assert.Equal("vehicles/zentorno.ydr", stdoutLines[1]); + } + finally + { + Console.SetOut(origOut); + Console.SetError(origErr); + } + } + + [Fact] + public void PrintSearch_MultiRpf_SummaryIncludesArchiveCount() + { + TextWriter origOut = Console.Out; + TextWriter origErr = Console.Error; + try + { + StringWriter stdout = new(); + StringWriter stderr = new(); + Console.SetOut(stdout); + Console.SetError(stderr); + + Json.SearchResult result = new() + { + Success = true, + RpfFile = "/dir", + RpfFiles = ["/dir/a.rpf", "/dir/b.rpf"], + Pattern = "adder", + MatchCount = 3, + Matches = + [ + MakeMatch("/dir/a.rpf", "vehicles/adder.ydr", "adder.ydr"), + MakeMatch("/dir/a.rpf", "vehicles/adder.ytd", "adder.ytd"), + MakeMatch("/dir/b.rpf", "vehicles/adder.yft", "adder.yft"), + ], + ErrorMessages = [], + }; + + SearchHandler.PrintSearch(result, MakeOptions()); + + string errOutput = stderr.ToString(); + Assert.Contains("Found 3 matches across 2 archive(s)", errOutput); + Assert.Contains("'adder'", errOutput); + } + finally + { + Console.SetOut(origOut); + Console.SetError(origErr); + } + } + + [Fact] + public void PrintSearch_SingleRpf_NoArchiveHeaders() + { + TextWriter origOut = Console.Out; + TextWriter origErr = Console.Error; + try + { + StringWriter stdout = new(); + StringWriter stderr = new(); + Console.SetOut(stdout); + Console.SetError(stderr); + + Json.SearchResult result = new() + { + Success = true, + RpfFile = "/test.rpf", + RpfFiles = ["/test.rpf"], + Pattern = "*.ydr", + MatchCount = 1, + Matches = + [ + MakeMatch("/test.rpf", "vehicles/adder.ydr", "adder.ydr"), + ], + ErrorMessages = [], + }; + + SearchHandler.PrintSearch(result, MakeOptions()); + + string errOutput = stderr.ToString(); + Assert.DoesNotContain("==", errOutput); + Assert.Contains("Found 1 match for", errOutput); + Assert.DoesNotContain("archive(s)", errOutput); + } + finally + { + Console.SetOut(origOut); + Console.SetError(origErr); + } + } + + [Fact] + public void PrintSearch_MultiRpf_Verbose_ShowsSizeAndArchiveHeaders() + { + TextWriter origOut = Console.Out; + TextWriter origErr = Console.Error; + try + { + StringWriter stdout = new(); + StringWriter stderr = new(); + Console.SetOut(stdout); + Console.SetError(stderr); + + Json.SearchResult result = new() + { + Success = true, + RpfFile = "/dir", + RpfFiles = ["/dir/a.rpf", "/dir/b.rpf"], + Pattern = "*.ydr", + MatchCount = 2, + Matches = + [ + MakeMatch("/dir/a.rpf", "vehicles/adder.ydr", "adder.ydr"), + MakeMatch("/dir/b.rpf", "vehicles/zentorno.ydr", "zentorno.ydr"), + ], + ErrorMessages = [], + }; + + SearchHandler.PrintSearch(result, MakeOptions(verbose: true)); + + string errOutput = stderr.ToString(); + Assert.Contains("== a.rpf ==", errOutput); + Assert.Contains("== b.rpf ==", errOutput); + + string stdoutOutput = stdout.ToString(); + Assert.Contains("1 KiB", stdoutOutput); + Assert.Contains("vehicles/adder.ydr", stdoutOutput); + Assert.Contains("vehicles/zentorno.ydr", stdoutOutput); + } + finally + { + Console.SetOut(origOut); + Console.SetError(origErr); + } + } +} + +// RelativePath + +public sealed class SearchRelativePathTests +{ + [Fact] + public void RelativePath_StripsPrefixCorrectly() + { + string result = SearchHandler.RelativePath("/dir", "/dir/a.rpf"); + Assert.Equal("a.rpf", result); + } + + [Fact] + public void RelativePath_NestedPath_StripsFullPrefix() + { + string result = SearchHandler.RelativePath("/base/dir", "/base/dir/sub/deep/file.rpf"); + Assert.Equal("sub/deep/file.rpf", result); + } + + [Fact] + public void RelativePath_HandlesBackslashes() + { + string result = SearchHandler.RelativePath("C:\\dir", "C:\\dir\\sub\\a.rpf"); + Assert.Equal("sub/a.rpf", result); + } + + [Fact] + public void RelativePath_CaseInsensitiveMatch() + { + string result = SearchHandler.RelativePath("/DIR", "/dir/a.rpf"); + Assert.Equal("a.rpf", result); + } + + [Fact] + public void RelativePath_BaseAlreadyHasTrailingSlash() + { + string result = SearchHandler.RelativePath("/dir/", "/dir/a.rpf"); + Assert.Equal("a.rpf", result); + } + + [Fact] + public void RelativePath_NoCommonPrefix_FallsBackToFileName() + { + string result = SearchHandler.RelativePath("/other", "/dir/a.rpf"); + Assert.Equal("a.rpf", result); + } + + [Fact] + public void RelativePath_MixedForwardAndBackslash() + { + string result = SearchHandler.RelativePath("/base/dir", "/base/dir\\sub\\a.rpf"); + Assert.Equal("sub/a.rpf", result); + } +} + +// ExecuteDirectory + +[Collection("ConsoleOutput")] +public sealed class SearchExecuteDirectoryTests +{ + private static SearchOptions MakeOptions(bool json = false, string pattern = "", string? dirPath = null) => + new() + { + RpfPath = "", + ExePath = "/nonexistent", + Gen9 = false, + Filters = [], + Verbose = false, + Json = json, + Recursive = false, + SizeFormat = SizeFormat.IEC, + Pattern = pattern, + DirPath = dirPath, + }; + + [Fact] + public void ExecuteDirectory_DirNotFound_ReturnsOne() + { + TextWriter origOut = Console.Out; + TextWriter origErr = Console.Error; + try + { + StringWriter stderr = new(); + Console.SetOut(new StringWriter()); + Console.SetError(stderr); + + int exitCode = SearchHandler.ExecuteDirectory( + MakeOptions(pattern: "*.ydr", dirPath: "/nonexistent_dir_xyz_12345"), + TestContext.Current.CancellationToken); + + Assert.Equal(1, exitCode); + Assert.Contains("Directory not found", stderr.ToString()); + } + finally + { + Console.SetOut(origOut); + Console.SetError(origErr); + } + } + + [Fact] + public void ExecuteDirectory_DirNotFound_Json_ReturnsErrorJson() + { + TextWriter origOut = Console.Out; + TextWriter origErr = Console.Error; + try + { + StringWriter stdout = new(); + Console.SetOut(stdout); + Console.SetError(new StringWriter()); + + int exitCode = SearchHandler.ExecuteDirectory( + MakeOptions(json: true, pattern: "adder", dirPath: "/nonexistent_dir_xyz_12345"), + TestContext.Current.CancellationToken); + + Assert.Equal(1, exitCode); + string output = stdout.ToString(); + Assert.Contains("\"success\": false", output); + Assert.Contains("Directory not found", output); + } + finally + { + Console.SetOut(origOut); + Console.SetError(origErr); + } + } + + [Fact] + public void ExecuteDirectory_NoRpfFiles_ReturnsOne() + { + TextWriter origOut = Console.Out; + TextWriter origErr = Console.Error; + string tempDir = Path.Combine(Path.GetTempPath(), $"cw_test_{Guid.NewGuid():N}"); + _ = Directory.CreateDirectory(tempDir); + try + { + StringWriter stderr = new(); + Console.SetOut(new StringWriter()); + Console.SetError(stderr); + + int exitCode = SearchHandler.ExecuteDirectory( + MakeOptions(pattern: "*.ydr", dirPath: tempDir), + TestContext.Current.CancellationToken); + + Assert.Equal(1, exitCode); + Assert.Contains("No .rpf files found", stderr.ToString()); + } + finally + { + Console.SetOut(origOut); + Console.SetError(origErr); + Directory.Delete(tempDir, true); + } + } + + [Fact] + public void ExecuteDirectory_NoRpfFiles_Json_ReturnsErrorJson() + { + TextWriter origOut = Console.Out; + TextWriter origErr = Console.Error; + string tempDir = Path.Combine(Path.GetTempPath(), $"cw_test_{Guid.NewGuid():N}"); + _ = Directory.CreateDirectory(tempDir); + try + { + StringWriter stdout = new(); + Console.SetOut(stdout); + Console.SetError(new StringWriter()); + + int exitCode = SearchHandler.ExecuteDirectory( + MakeOptions(json: true, pattern: "adder", dirPath: tempDir), + TestContext.Current.CancellationToken); + + Assert.Equal(1, exitCode); + string output = stdout.ToString(); + Assert.Contains("\"success\": false", output); + Assert.Contains("No .rpf files found", output); + } + finally + { + Console.SetOut(origOut); + Console.SetError(origErr); + Directory.Delete(tempDir, true); + } + } + + [Fact] + public void ExecuteDirectory_ExeValidationFails_ReturnsOne() + { + TextWriter origOut = Console.Out; + TextWriter origErr = Console.Error; + string tempDir = Path.Combine(Path.GetTempPath(), $"cw_test_{Guid.NewGuid():N}"); + _ = Directory.CreateDirectory(tempDir); + File.WriteAllText(Path.Combine(tempDir, "fake.rpf"), ""); + try + { + StringWriter stderr = new(); + Console.SetOut(new StringWriter()); + Console.SetError(stderr); + + int exitCode = SearchHandler.ExecuteDirectory( + MakeOptions(pattern: "*.ydr", dirPath: tempDir), + TestContext.Current.CancellationToken); + + Assert.Equal(1, exitCode); + Assert.Contains("GTA5.exe not found", stderr.ToString()); + } + finally + { + Console.SetOut(origOut); + Console.SetError(origErr); + Directory.Delete(tempDir, true); + } + } +} diff --git a/CodeWalker.Cli/Tests/Handlers/StatHandlerTests.cs b/CodeWalker.Cli/Tests/Handlers/StatHandlerTests.cs new file mode 100644 index 000000000..30b60fce8 --- /dev/null +++ b/CodeWalker.Cli/Tests/Handlers/StatHandlerTests.cs @@ -0,0 +1,972 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text.Json; +using System.Threading; + +using CodeWalker.Cli.Handlers; +using CodeWalker.Cli.Helpers; +using CodeWalker.GameFiles; + +using Xunit; + +namespace CodeWalker.Cli.Tests.Handlers; + +// ErrorResult + +public sealed class StatErrorResultTests +{ + private static StatOptions MakeOptions(string rpfPath = "/test.rpf") => + new() + { + RpfPath = rpfPath, + ExePath = "/nonexistent", + Gen9 = false, + Filters = [], + Verbose = false, + Json = false, + Recursive = false, + SizeFormat = SizeFormat.IEC + }; + + [Fact] + public void ErrorResult_SetsSuccessFalse() + { + Json.StatResult result = StatHandler.ErrorResult([], MakeOptions()); + Assert.False(result.Success); + } + + [Fact] + public void ErrorResult_PreservesErrorMessages() + { + string[] msgs = ["err1", "err2"]; + Json.StatResult result = StatHandler.ErrorResult(msgs, MakeOptions()); + Assert.Equal(msgs, result.ErrorMessages); + } + + [Fact] + public void ErrorResult_SetsRpfFile() + { + Json.StatResult result = StatHandler.ErrorResult([], MakeOptions("/my/test.rpf")); + Assert.Equal("/my/test.rpf", result.RpfFile); + } + + [Fact] + public void ErrorResult_AllStatsAreZero() + { + Json.StatResult result = StatHandler.ErrorResult([], MakeOptions()); + Assert.Equal(0, result.TotalFiles); + Assert.Equal(0, result.TotalSize); + Assert.Equal(0, result.ResourceCount); + Assert.Equal(0, result.BinaryCount); + Assert.Equal(0, result.CompressedSize); + Assert.Equal(0, result.UncompressedSize); + Assert.Equal(0, result.CompressionRatio); + } + + [Fact] + public void ErrorResult_FormattedSizesAreZeroB() + { + Json.StatResult result = StatHandler.ErrorResult([], MakeOptions()); + Assert.Equal("0 B", result.TotalSizeFormatted); + Assert.Equal("0 B", result.CompressedSizeFormatted); + Assert.Equal("0 B", result.UncompressedSizeFormatted); + } + + [Fact] + public void ErrorResult_ExtensionsAreEmpty() + { + Json.StatResult result = StatHandler.ErrorResult([], MakeOptions()); + Assert.Empty(result.Extensions); + } +} + +// CollectStats + +public sealed class CollectStatsTests +{ + private static StatOptions MakeOptions(SizeFormat fmt = SizeFormat.IEC) => + new() + { + RpfPath = "/test.rpf", + ExePath = "/nonexistent", + Gen9 = false, + Filters = [], + Verbose = false, + Json = false, + Recursive = false, + SizeFormat = fmt + }; + + private static RpfBinaryFileEntry MakeBinary(string name, uint fileSize, uint uncompressedSize) => + new() + { + Name = name, + FileSize = fileSize, + FileUncompressedSize = uncompressedSize + }; + + private static RpfResourceFileEntry MakeResource(string name, uint fileSize, uint sysFlags, uint gfxFlags) => + new() + { + Name = name, + FileSize = fileSize, + SystemFlags = new RpfResourcePageFlags(sysFlags), + GraphicsFlags = new RpfResourcePageFlags(gfxFlags) + }; + + [Fact] + public void EmptyEntries_ReturnsAllZeros() + { + Json.StatResult result = StatHandler.CollectStats( + [], + [], + MakeOptions(), + TestContext.Current.CancellationToken + ); + + Assert.True(result.Success); + Assert.Equal(0, result.TotalFiles); + Assert.Equal(0, result.TotalSize); + Assert.Equal(0, result.ResourceCount); + Assert.Equal(0, result.BinaryCount); + Assert.Equal(0, result.CompressedSize); + Assert.Equal(0, result.UncompressedSize); + Assert.Equal(0, result.CompressionRatio); + Assert.Empty(result.Extensions); + } + + [Fact] + public void SingleBinary_CountsCorrectly() + { + RpfBinaryFileEntry entry = MakeBinary( + "data.dat", + fileSize: 200, + uncompressedSize: 400 + ); + + List<(RpfFile, RpfFileEntry)> entries = [ + (null!, entry) + ]; + + Json.StatResult result = StatHandler.CollectStats( + entries, + [], + MakeOptions(), + TestContext.Current.CancellationToken + ); + + Assert.Equal(1, result.TotalFiles); + Assert.Equal(200, result.TotalSize); // GetFileSize() returns FileSize when non-zero + Assert.Equal(0, result.ResourceCount); + Assert.Equal(1, result.BinaryCount); + Assert.Equal(200, result.CompressedSize); + Assert.Equal(400, result.UncompressedSize); + } + + [Fact] + public void SingleResource_CountsCorrectly() + { + // 0x08000000 -> SystemFlags.Size = 512, 0x04000000 -> GraphicsFlags.Size = 1024 + RpfResourceFileEntry entry = MakeResource( + "model.ydr", + fileSize: 300, + sysFlags: 0x08000000, + gfxFlags: 0x04000000 + ); + + List<(RpfFile, RpfFileEntry)> entries = [ + (null!, entry) + ]; + + Json.StatResult result = StatHandler.CollectStats( + entries, + [], + MakeOptions(), + TestContext.Current.CancellationToken + ); + + Assert.Equal(1, result.TotalFiles); + Assert.Equal(300, result.TotalSize); // GetFileSize() returns FileSize when non-zero + Assert.Equal(1, result.ResourceCount); + Assert.Equal(0, result.BinaryCount); + Assert.Equal(300, result.CompressedSize); + Assert.Equal(512 + 1024, result.UncompressedSize); + } + + [Fact] + public void MixedEntries_AggregatesCorrectly() + { + RpfBinaryFileEntry bin = MakeBinary( + "data.dat", + fileSize: 200, + uncompressedSize: 400 + ); + + RpfResourceFileEntry res = MakeResource( + "model.ydr", + fileSize: 300, + sysFlags: 0x08000000, + gfxFlags: 0x04000000 + ); + + List<(RpfFile, RpfFileEntry)> entries = [ + (null!, bin), + (null!, res) + ]; + + Json.StatResult result = StatHandler.CollectStats( + entries, + [], + MakeOptions(), + TestContext.Current.CancellationToken + ); + + Assert.Equal(2, result.TotalFiles); + Assert.Equal(200 + 300, result.TotalSize); + Assert.Equal(1, result.ResourceCount); + Assert.Equal(1, result.BinaryCount); + Assert.Equal(200 + 300, result.CompressedSize); + Assert.Equal(400 + 512 + 1024, result.UncompressedSize); + } + + [Fact] + public void CompressionRatio_CalculatedCorrectly() + { + RpfBinaryFileEntry entry = MakeBinary( + "data.dat", + fileSize: 250, + uncompressedSize: 1000 + ); + + List<(RpfFile, RpfFileEntry)> entries = [ + (null!, entry) + ]; + + Json.StatResult result = StatHandler.CollectStats( + entries, + [], + MakeOptions(), + TestContext.Current.CancellationToken + ); + + Assert.Equal(0.25, result.CompressionRatio); + } + + [Fact] + public void CompressionRatio_ZeroWhenNoUncompressed() + { + // Entry with FileSize=0 and FileUncompressedSize=0 -> GetFileSize() returns 0 + RpfBinaryFileEntry entry = MakeBinary( + "empty.dat", + fileSize: 0, + uncompressedSize: 0 + ); + + List<(RpfFile, RpfFileEntry)> entries = [ + (null!, entry) + ]; + + Json.StatResult result = StatHandler.CollectStats( + entries, + [], + MakeOptions(), + TestContext.Current.CancellationToken + ); + + Assert.Equal(0, result.CompressionRatio); + } + + [Fact] + public void ExtensionStats_GroupedAndSorted() + { + RpfBinaryFileEntry small = MakeBinary( + "a.dat", + fileSize: 100, + uncompressedSize: 100 + ); + RpfBinaryFileEntry large1 = MakeBinary( + "b.ydr", + fileSize: 500, + uncompressedSize: 500 + ); + RpfBinaryFileEntry large2 = MakeBinary( + "c.ydr", + fileSize: 600, + uncompressedSize: 600 + ); + + List<(RpfFile, RpfFileEntry)> entries = [ + (null!, small), + (null!, large1), + (null!, large2) + ]; + + Json.StatResult result = StatHandler.CollectStats( + entries, + [], + MakeOptions(), + TestContext.Current.CancellationToken + ); + + Assert.Equal(2, result.Extensions.Count); + // .ydr total (1100) > .dat total (100), so .ydr comes first + Assert.Equal(".ydr", result.Extensions[0].Extension); + Assert.Equal(2, result.Extensions[0].Count); + Assert.Equal(1100, result.Extensions[0].TotalSize); + Assert.Equal(".dat", result.Extensions[1].Extension); + Assert.Equal(1, result.Extensions[1].Count); + Assert.Equal(100, result.Extensions[1].TotalSize); + } + + [Fact] + public void ExtensionStats_MinMaxAvg() + { + RpfBinaryFileEntry a = MakeBinary( + "a.dat", + fileSize: 100, + uncompressedSize: 100 + ); + RpfBinaryFileEntry b = MakeBinary( + "b.dat", + fileSize: 300, + uncompressedSize: 300 + ); + + List<(RpfFile, RpfFileEntry)> entries = [ + (null!, a), + (null!, b) + ]; + + Json.StatResult result = StatHandler.CollectStats( + entries, + [], + MakeOptions(), + TestContext.Current.CancellationToken + ); + + Json.ExtensionStat ext = result.Extensions[0]; + Assert.Equal(".dat", ext.Extension); + Assert.Equal(100, ext.MinSize); + Assert.Equal(300, ext.MaxSize); + Assert.Equal(200, ext.AvgSize); // (100 + 300) / 2 + } + + [Fact] + public void NoExtension_CategorizedAsNone() + { + RpfBinaryFileEntry entry = MakeBinary( + "README", + fileSize: 50, + uncompressedSize: 50 + ); + + List<(RpfFile, RpfFileEntry)> entries = [ + (null!, entry) + ]; + + Json.StatResult result = StatHandler.CollectStats( + entries, + [], + MakeOptions(), + TestContext.Current.CancellationToken + ); + + Assert.Equal("(none)", result.Extensions[0].Extension); + } + + [Fact] + public void ScanErrors_SetSuccessFalse() + { + List errors = ["scan failed"]; + + Json.StatResult result = StatHandler.CollectStats( + [], + errors, + MakeOptions(), + TestContext.Current.CancellationToken + ); + + Assert.False(result.Success); + Assert.Equal(errors, result.ErrorMessages); + } + + [Fact] + public void ScanErrors_Empty_SetSuccessTrue() + { + Json.StatResult result = StatHandler.CollectStats( + [], + [], + MakeOptions(), + TestContext.Current.CancellationToken + ); + + Assert.True(result.Success); + Assert.Empty(result.ErrorMessages); + } + + [Fact] + public void CaseInsensitiveExtensionGrouping() + { + RpfBinaryFileEntry upper = MakeBinary( + "A.DAT", + fileSize: 100, + uncompressedSize: 100 + ); + RpfBinaryFileEntry lower = MakeBinary( + "b.dat", + fileSize: 200, + uncompressedSize: 200 + ); + + List<(RpfFile, RpfFileEntry)> entries = [ + (null!, upper), + (null!, lower) + ]; + + Json.StatResult result = StatHandler.CollectStats(entries, [], MakeOptions(), TestContext.Current.CancellationToken); + + _ = Assert.Single(result.Extensions); + Assert.Equal(".dat", result.Extensions[0].Extension); + Assert.Equal(2, result.Extensions[0].Count); + Assert.Equal(300, result.Extensions[0].TotalSize); + } + + [Fact] + public void CompressionRatio_RoundedToFourDecimals() + { + // 1 / 3 = 0.33333... -> should round to 0.3333 + RpfBinaryFileEntry entry = MakeBinary( + "data.dat", + fileSize: 1, + uncompressedSize: 3 + ); + + List<(RpfFile, RpfFileEntry)> entries = [ + (null!, entry) + ]; + + Json.StatResult result = StatHandler.CollectStats( + entries, + [], + MakeOptions(), + TestContext.Current.CancellationToken + ); + + Assert.Equal(0.3333, result.CompressionRatio); + } + + [Fact] + public void AvgSize_TruncatedByIntegerDivision() + { + // 3 files totalling 10 bytes -> avg = 10 / 3 = 3 (integer truncation, not 3.33) + RpfBinaryFileEntry a = MakeBinary( + "a.dat", + fileSize: 1, + uncompressedSize: 1 + ); + RpfBinaryFileEntry b = MakeBinary( + "b.dat", + fileSize: 4, + uncompressedSize: 4 + ); + RpfBinaryFileEntry c = MakeBinary( + "c.dat", + fileSize: 5, + uncompressedSize: 5 + ); + + List<(RpfFile, RpfFileEntry)> entries = [ + (null!, a), + (null!, b), + (null!, c) + ]; + + Json.StatResult result = StatHandler.CollectStats( + entries, + [], + MakeOptions(), + TestContext.Current.CancellationToken + ); + + Assert.Equal(3, result.Extensions[0].AvgSize); // 10 / 3 = 3, not 4 + } + + [Fact] + public void Cancelled_ThrowsOperationCanceledException() + { + using CancellationTokenSource cts = new(); + cts.Cancel(); + + RpfBinaryFileEntry entry = MakeBinary( + "data.dat", + fileSize: 100, + uncompressedSize: 100 + ); + + List<(RpfFile, RpfFileEntry)> entries = [ + (null!, entry) + ]; + + _ = Assert.Throws( + () => StatHandler.CollectStats( + entries, + [], + MakeOptions(), + cts.Token + ) + ); + } +} + +// PrintJsonStats + +[Collection("ConsoleOutput")] +public sealed class PrintJsonStatsTests +{ + private static Json.StatResult MakeResult( + bool success = true, + int totalFiles = 5, + long totalSize = 1024, + int resourceCount = 3, + int binaryCount = 2, + long compressedSize = 800, + long uncompressedSize = 1200, + double compressionRatio = 0.6667) => + new() + { + Success = success, + RpfFile = "/test.rpf", + TotalFiles = totalFiles, + TotalSize = totalSize, + TotalSizeFormatted = SizeFormat.IEC.ToFormattedString(totalSize), + ResourceCount = resourceCount, + BinaryCount = binaryCount, + CompressedSize = compressedSize, + CompressedSizeFormatted = SizeFormat.IEC.ToFormattedString(compressedSize), + UncompressedSize = uncompressedSize, + UncompressedSizeFormatted = SizeFormat.IEC.ToFormattedString(uncompressedSize), + CompressionRatio = compressionRatio, + Extensions = + [ + new Json.ExtensionStat + { + Extension = ".dat", + Count = 2, + TotalSize = 500, + TotalSizeFormatted = "500 B", + AvgSize = 250, + AvgSizeFormatted = "250 B", + MinSize = 200, + MinSizeFormatted = "200 B", + MaxSize = 300, + MaxSizeFormatted = "300 B" + } + ], + ErrorMessages = [] + }; + + [Fact] + public void PrintJsonStats_WritesValidJson() + { + TextWriter orig = Console.Out; + try + { + StringWriter sw = new(); + Console.SetOut(sw); + + StatHandler.PrintJsonStats(MakeResult()); + + Json.StatResult? parsed = JsonSerializer.Deserialize( + sw.ToString().Trim(), Output.JsonSerializerOptions + ); + Assert.NotNull(parsed); + } + finally { Console.SetOut(orig); } + } + + [Fact] + public void PrintJsonStats_ContainsAllFields() + { + TextWriter orig = Console.Out; + try + { + StringWriter sw = new(); + Console.SetOut(sw); + + StatHandler.PrintJsonStats(MakeResult()); + + string json = sw.ToString(); + Assert.Contains("\"success\": true", json); + Assert.Contains("\"rpfFile\": \"/test.rpf\"", json); + Assert.Contains("\"totalFiles\": 5", json); + Assert.Contains("\"totalSize\": 1024", json); + Assert.Contains("\"totalSizeFormatted\":", json); + Assert.Contains("\"resourceCount\": 3", json); + Assert.Contains("\"binaryCount\": 2", json); + Assert.Contains("\"compressedSize\": 800", json); + Assert.Contains("\"compressedSizeFormatted\":", json); + Assert.Contains("\"uncompressedSize\": 1200", json); + Assert.Contains("\"uncompressedSizeFormatted\":", json); + Assert.Contains($"\"compressionRatio\": {JsonSerializer.Serialize(0.6667)}", json); + Assert.Contains("\"extensions\":", json); + } + finally { Console.SetOut(orig); } + } + + [Fact] + public void PrintJsonStats_ExtensionStatFields() + { + TextWriter orig = Console.Out; + try + { + StringWriter sw = new(); + Console.SetOut(sw); + + StatHandler.PrintJsonStats(MakeResult()); + + string json = sw.ToString(); + Assert.Contains("\"extension\": \".dat\"", json); + Assert.Contains("\"count\": 2", json); + Assert.Contains("\"totalSize\": 500", json); + Assert.Contains("\"avgSize\": 250", json); + Assert.Contains("\"minSize\": 200", json); + Assert.Contains("\"maxSize\": 300", json); + } + finally { Console.SetOut(orig); } + } + + [Fact] + public void PrintJsonStats_ErrorResult_ShowsSuccessFalse() + { + TextWriter orig = Console.Out; + try + { + StringWriter sw = new(); + Console.SetOut(sw); + + StatHandler.PrintJsonStats(MakeResult(success: false)); + + Assert.Contains("\"success\": false", sw.ToString()); + } + finally { Console.SetOut(orig); } + } + + [Fact] + public void PrintJsonStats_RoundTripsCorrectly() + { + TextWriter orig = Console.Out; + try + { + StringWriter sw = new(); + Console.SetOut(sw); + + Json.StatResult input = MakeResult(); + StatHandler.PrintJsonStats(input); + + Json.StatResult? parsed = JsonSerializer.Deserialize( + sw.ToString().Trim(), Output.JsonSerializerOptions + ); + Assert.NotNull(parsed); + Assert.Equal(input.TotalFiles, parsed.TotalFiles); + Assert.Equal(input.TotalSize, parsed.TotalSize); + Assert.Equal(input.ResourceCount, parsed.ResourceCount); + Assert.Equal(input.BinaryCount, parsed.BinaryCount); + Assert.Equal(input.CompressedSize, parsed.CompressedSize); + Assert.Equal(input.UncompressedSize, parsed.UncompressedSize); + Assert.Equal(input.CompressionRatio, parsed.CompressionRatio); + Assert.Equal(input.Extensions.Count, parsed.Extensions.Count); + } + finally { Console.SetOut(orig); } + } +} + +// PrintStats (text) + +[Collection("ConsoleOutput")] +public sealed class PrintStatsTests +{ + private static StatOptions MakeOptions(SizeFormat fmt = SizeFormat.IEC) => + new() + { + RpfPath = "/test.rpf", + ExePath = "/nonexistent", + Gen9 = false, + Filters = [], + Verbose = false, + Json = false, + Recursive = false, + SizeFormat = fmt + }; + + private static (string stdout, string stderr) Capture(Json.StatResult result, StatOptions? options = null) + { + options ??= MakeOptions(); + TextWriter origOut = Console.Out; + TextWriter origErr = Console.Error; + try + { + StringWriter sw = new(); + StringWriter se = new(); + Console.SetOut(sw); + Console.SetError(se); + StatHandler.PrintStats(result, options); + return (sw.ToString(), se.ToString()); + } + finally + { + Console.SetOut(origOut); + Console.SetError(origErr); + } + } + + private static Json.StatResult MakeResult(IReadOnlyList? extensions = null) => + new() + { + Success = true, + RpfFile = "/test.rpf", + TotalFiles = 10, + TotalSize = 2048, + TotalSizeFormatted = "2.0 KiB", + ResourceCount = 6, + BinaryCount = 4, + CompressedSize = 1500, + CompressedSizeFormatted = "1.5 KiB", + UncompressedSize = 3000, + UncompressedSizeFormatted = "2.9 KiB", + CompressionRatio = 0.5, + Extensions = extensions ?? + [ + new Json.ExtensionStat + { + Extension = ".ydr", + Count = 3, + TotalSize = 1500, + TotalSizeFormatted = "1.5 KiB", + AvgSize = 500, + AvgSizeFormatted = "500 B", + MinSize = 200, + MinSizeFormatted = "200 B", + MaxSize = 800, + MaxSizeFormatted = "800 B" + } + ], + ErrorMessages = [] + }; + + [Fact] + public void PrintStats_TableHasHeaders() + { + (string stdout, _) = Capture(MakeResult()); + Assert.Contains("Extension", stdout); + Assert.Contains("Count", stdout); + Assert.Contains("Total", stdout); + Assert.Contains("Avg", stdout); + Assert.Contains("Min", stdout); + Assert.Contains("Max", stdout); + } + + [Fact] + public void PrintStats_TableHasSeparator() + { + (string stdout, _) = Capture(MakeResult()); + Assert.Contains("---", stdout); + Assert.Contains("+", stdout); + } + + [Fact] + public void PrintStats_TableHasExtensionRow() + { + (string stdout, _) = Capture(MakeResult()); + Assert.Contains(".ydr", stdout); + } + + [Fact] + public void PrintStats_StderrHasSummary() + { + (_, string stderr) = Capture(MakeResult()); + Assert.Contains("Total: 10 files", stderr); + Assert.Contains("Types: 6 resource, 4 binary", stderr); + } + + [Fact] + public void PrintStats_StderrHasCompression() + { + (_, string stderr) = Capture(MakeResult()); + Assert.Contains("Compression:", stderr); + } + + [Fact] + public void PrintStats_NoCompression_WhenUncompressedIsZero() + { + Json.StatResult result = MakeResult() with { UncompressedSize = 0 }; + (_, string stderr) = Capture(result); + Assert.DoesNotContain("Compression:", stderr); + } + + [Fact] + public void PrintStats_EmptyExtensions_PrintsHeaderOnly() + { + Json.StatResult result = MakeResult(extensions: []); + (string stdout, _) = Capture(result); + Assert.Contains("Extension", stdout); + Assert.DoesNotContain(".ydr", stdout); + } + + [Fact] + public void PrintStats_SIFormat_UsesDecimalUnits() + { + Json.StatResult result = MakeResult() with + { + TotalSize = 2000, + TotalSizeFormatted = SizeFormat.SI.ToFormattedString(2000) + }; + StatOptions options = MakeOptions(SizeFormat.SI); + + (string stdout, string stderr) = Capture(result, options); + + // SI uses KB (1000-based) not KiB (1024-based); format is "0.##" so "2 KB" not "2.0 KB" + Assert.Contains("2 KB", stderr); + Assert.DoesNotContain("KiB", stdout); + } +} + +// Execute (integration) + +[Collection("ConsoleOutput")] +public sealed class StatExecuteTests +{ + private static StatOptions MakeOptions(string rpfPath, bool json) => + new() + { + RpfPath = rpfPath, + ExePath = "/nonexistent", + Gen9 = false, + Filters = [], + Verbose = false, + Json = json, + Recursive = false, + SizeFormat = SizeFormat.IEC + }; + + // Validation failures + + [Fact] + public void Execute_MissingRpf_ReturnsOne() + { + TextWriter origOut = Console.Out; + TextWriter origErr = Console.Error; + try + { + StringWriter stderr = new(); + Console.SetOut(new StringWriter()); + Console.SetError(stderr); + + int exitCode = StatHandler.Execute( + MakeOptions("/nonexistent/test.rpf", json: false), + TestContext.Current.CancellationToken + ); + + Assert.Equal(1, exitCode); + Assert.Contains("Error:", stderr.ToString()); + } + finally + { + Console.SetOut(origOut); + Console.SetError(origErr); + } + } + + [Fact] + public void Execute_MissingRpf_Json_ReturnsErrorJson() + { + TextWriter origOut = Console.Out; + TextWriter origErr = Console.Error; + try + { + StringWriter stdout = new(); + Console.SetOut(stdout); + Console.SetError(new StringWriter()); + + int exitCode = StatHandler.Execute( + MakeOptions("/nonexistent/test.rpf", json: true), + TestContext.Current.CancellationToken + ); + + Assert.Equal(1, exitCode); + string output = stdout.ToString(); + Assert.Contains("\"success\": false", output); + Assert.Contains("RPF file not found", output); + } + finally + { + Console.SetOut(origOut); + Console.SetError(origErr); + } + } + + [Fact] + public void Execute_Json_ErrorContainsAllExpectedFields() + { + TextWriter origOut = Console.Out; + try + { + StringWriter stdout = new(); + Console.SetOut(stdout); + + _ = StatHandler.Execute( + MakeOptions("/nonexistent/test.rpf", json: true), + TestContext.Current.CancellationToken + ); + + string output = stdout.ToString(); + Assert.Contains("\"rpfFile\":", output); + Assert.Contains("\"totalFiles\": 0", output); + Assert.Contains("\"totalSize\": 0", output); + Assert.Contains("\"totalSizeFormatted\":", output); + Assert.Contains("\"resourceCount\": 0", output); + Assert.Contains("\"binaryCount\": 0", output); + Assert.Contains("\"compressedSize\": 0", output); + Assert.Contains("\"compressedSizeFormatted\":", output); + Assert.Contains("\"uncompressedSize\": 0", output); + Assert.Contains("\"uncompressedSizeFormatted\":", output); + Assert.Contains("\"compressionRatio\": 0", output); + Assert.Contains("\"extensions\": []", output); + } + finally { Console.SetOut(origOut); } + } + + [Fact] + public void Execute_MissingExe_WithExistingRpf_ReturnsOne() + { + string dir = Path.Combine(Path.GetTempPath(), "cw_stat_" + Guid.NewGuid().ToString("N")); + _ = Directory.CreateDirectory(dir); + string rpf = Path.Combine(dir, "test.rpf"); + File.WriteAllBytes(rpf, []); + try + { + TextWriter origOut = Console.Out; + TextWriter origErr = Console.Error; + try + { + Console.SetOut(new StringWriter()); + StringWriter stderr = new(); + Console.SetError(stderr); + + int exitCode = StatHandler.Execute( + MakeOptions(rpf, json: false), + TestContext.Current.CancellationToken + ); + + Assert.Equal(1, exitCode); + Assert.Contains("Error:", stderr.ToString()); + } + finally + { + Console.SetOut(origOut); + Console.SetError(origErr); + } + } + finally { Directory.Delete(dir, true); } + } +} diff --git a/CodeWalker.Cli/Tests/Handlers/TreeHandlerTests.cs b/CodeWalker.Cli/Tests/Handlers/TreeHandlerTests.cs new file mode 100644 index 000000000..6aadeddff --- /dev/null +++ b/CodeWalker.Cli/Tests/Handlers/TreeHandlerTests.cs @@ -0,0 +1,1305 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.Json; +using System.Threading; + +using CodeWalker.Cli.Handlers; +using CodeWalker.Cli.Helpers; +using CodeWalker.GameFiles; + +using Xunit; + +namespace CodeWalker.Cli.Tests.Handlers; + +// ErrorResult + +public sealed class TreeErrorResultTests +{ + private static TreeOptions MakeOptions(string rpfPath = "/test.rpf") => + new() + { + RpfPath = rpfPath, + ExePath = "/nonexistent", + Gen9 = false, + Filters = [], + Verbose = false, + Json = false, + Recursive = false, + SizeFormat = SizeFormat.IEC, + Depth = -1, + }; + + [Fact] + public void ErrorResult_SetsSuccessFalse() + { + Json.TreeResult result = TreeHandler.ErrorResult([], MakeOptions()); + Assert.False(result.Success); + } + + [Fact] + public void ErrorResult_PreservesErrorMessages() + { + string[] msgs = ["err1", "err2"]; + Json.TreeResult result = TreeHandler.ErrorResult(msgs, MakeOptions()); + Assert.Equal(msgs, result.ErrorMessages); + } + + [Fact] + public void ErrorResult_SetsRpfFile() + { + Json.TreeResult result = TreeHandler.ErrorResult([], MakeOptions("/my/test.rpf")); + Assert.Equal("/my/test.rpf", result.RpfFile); + } + + [Fact] + public void ErrorResult_CountsAreZero() + { + Json.TreeResult result = TreeHandler.ErrorResult([], MakeOptions()); + Assert.Equal(0, result.TotalFiles); + Assert.Equal(0, result.TotalDirs); + } + + [Fact] + public void ErrorResult_RootIsNull() + { + Json.TreeResult result = TreeHandler.ErrorResult([], MakeOptions()); + Assert.Null(result.Root); + } +} + +// PrintTree (text) + +[Collection("ConsoleOutput")] +public sealed class PrintTreeTests +{ + private static readonly char[] NewLineSeparator = ['\n']; + private static TreeOptions MakeOptions(bool verbose = false) => + new() + { + RpfPath = "/test.rpf", + ExePath = "/nonexistent", + Gen9 = false, + Filters = [], + Verbose = verbose, + Json = false, + Recursive = false, + SizeFormat = SizeFormat.IEC, + Depth = -1, + }; + + private static Json.TreeNode MakeFileNode( + string name, + string path = "", + long? size = null, + string? sizeFormatted = null, + string? fileType = null, + int? version = null) => + new() + { + Name = name, + Path = path, + Type = "file", + Size = size, + SizeFormatted = sizeFormatted, + FileType = fileType, + Version = version + }; + + private static Json.TreeNode MakeDirNode( + string name, + string path = "", + IReadOnlyList? children = null) => + new() + { + Name = name, + Path = path, + Type = "dir", + Children = children ?? [] + }; + + private static (string stdout, string stderr) Capture( + Json.TreeNode root, + int totalFiles, + int totalDirs, + TreeOptions? options = null, + CancellationToken? cancellationToken = null) + { + options ??= MakeOptions(); + CancellationToken ct = cancellationToken ?? TestContext.Current.CancellationToken; + TextWriter origOut = Console.Out; + TextWriter origErr = Console.Error; + try + { + StringWriter sw = new(); + StringWriter se = new(); + Console.SetOut(sw); + Console.SetError(se); + TreeHandler.PrintTree(root, totalFiles, totalDirs, options, ct); + return (sw.ToString(), se.ToString()); + } + finally + { + Console.SetOut(origOut); + Console.SetError(origErr); + } + } + + [Fact] + public void PrintTree_RootNameOnFirstLine() + { + Json.TreeNode root = MakeDirNode("test.rpf/"); + (string stdout, _) = Capture(root, 0, 0); + string firstLine = stdout.Split('\n')[0].TrimEnd('\r'); + Assert.Equal("test.rpf/", firstLine); + } + + [Fact] + public void PrintTree_SummaryOnStderr() + { + Json.TreeNode root = MakeDirNode("test.rpf/"); + (_, string stderr) = Capture(root, 5, 2); + Assert.Contains("2 directories, 5 files", stderr); + } + + [Fact] + public void PrintTree_SingleFile_ShowsConnector() + { + Json.TreeNode root = MakeDirNode("test.rpf/", children: + [ + MakeFileNode("data.dat") + ]); + (string stdout, _) = Capture(root, 1, 0); + Assert.Contains("\u2514\u2500\u2500 data.dat", stdout); + } + + [Fact] + public void PrintTree_MultipleFiles_ShowsBranchAndLastConnectors() + { + Json.TreeNode root = MakeDirNode("test.rpf/", children: + [ + MakeFileNode("a.dat"), + MakeFileNode("b.dat"), + MakeFileNode("c.dat") + ]); + (string stdout, _) = Capture(root, 3, 0); + // First two get ├──, last gets └── + Assert.Contains("\u251c\u2500\u2500 a.dat", stdout); + Assert.Contains("\u251c\u2500\u2500 b.dat", stdout); + Assert.Contains("\u2514\u2500\u2500 c.dat", stdout); + } + + [Fact] + public void PrintTree_Directory_ShowsTrailingSlash() + { + Json.TreeNode root = MakeDirNode("test.rpf/", children: + [ + MakeDirNode("subdir") + ]); + (string stdout, _) = Capture(root, 0, 1); + Assert.Contains("\u2514\u2500\u2500 subdir/", stdout); + } + + [Fact] + public void PrintTree_NestedStructure_ShowsIndentation() + { + Json.TreeNode root = MakeDirNode("test.rpf/", children: + [ + MakeDirNode("subdir", children: + [ + MakeFileNode("nested.dat") + ]), + MakeFileNode("top.dat") + ]); + (string stdout, _) = Capture(root, 2, 1); + // subdir gets ├── (not last), nested.dat gets │ └── + Assert.Contains("\u251c\u2500\u2500 subdir/", stdout); + Assert.Contains("\u2502 \u2514\u2500\u2500 nested.dat", stdout); + Assert.Contains("\u2514\u2500\u2500 top.dat", stdout); + } + + [Fact] + public void PrintTree_LastDirectory_UsesSpacePrefix() + { + Json.TreeNode root = MakeDirNode("test.rpf/", children: + [ + MakeDirNode("lastdir", children: + [ + MakeFileNode("child.dat") + ]) + ]); + (string stdout, _) = Capture(root, 1, 1); + // lastdir is last child -> └──, its children use " " (4 spaces) prefix + Assert.Contains("\u2514\u2500\u2500 lastdir/", stdout); + Assert.Contains(" \u2514\u2500\u2500 child.dat", stdout); + } + + [Fact] + public void PrintTree_Verbose_ShowsSizeAndType() + { + Json.TreeNode root = MakeDirNode("test.rpf/", children: + [ + MakeFileNode("model.ydr", size: 1024, sizeFormatted: "1.0 KiB", fileType: "Resource") + ]); + (string stdout, _) = Capture(root, 1, 0, MakeOptions(verbose: true)); + Assert.Contains("model.ydr (1.0 KiB, Resource)", stdout); + } + + [Fact] + public void PrintTree_Verbose_ShowsVersion() + { + Json.TreeNode root = MakeDirNode("test.rpf/", children: + [ + MakeFileNode("model.ydr", size: 1024, sizeFormatted: "1.0 KiB", fileType: "Resource", version: 110) + ]); + (string stdout, _) = Capture(root, 1, 0, MakeOptions(verbose: true)); + Assert.Contains("model.ydr (1.0 KiB, Resource v110)", stdout); + } + + [Fact] + public void PrintTree_NonVerbose_HidesSizeAndType() + { + Json.TreeNode root = MakeDirNode("test.rpf/", children: + [ + MakeFileNode("model.ydr", size: 1024, sizeFormatted: "1.0 KiB", fileType: "Resource") + ]); + (string stdout, _) = Capture(root, 1, 0, MakeOptions(verbose: false)); + Assert.Contains("model.ydr", stdout); + Assert.DoesNotContain("1.0 KiB", stdout); + Assert.DoesNotContain("Resource", stdout); + } + + [Fact] + public void PrintTree_Verbose_ArchiveDir_ShowsSizeAndType() + { + Json.TreeNode root = MakeDirNode("test.rpf/", children: + [ + new Json.TreeNode + { + Name = "nested.rpf", + Path = "/test.rpf/nested.rpf", + Type = "dir", + Size = 2048, + SizeFormatted = "2.0 KiB", + FileType = "binary", + Children = [MakeFileNode("inner.dat")] + } + ]); + (string stdout, _) = Capture(root, 1, 1, MakeOptions(verbose: true)); + Assert.Contains("nested.rpf/ <2.0 KiB, binary>", stdout); + } + + [Fact] + public void PrintTree_NonVerbose_ArchiveDir_HidesSizeAndType() + { + Json.TreeNode root = MakeDirNode("test.rpf/", children: + [ + new Json.TreeNode + { + Name = "nested.rpf", + Path = "/test.rpf/nested.rpf", + Type = "dir", + Size = 2048, + SizeFormatted = "2.0 KiB", + FileType = "binary", + Children = [MakeFileNode("inner.dat")] + } + ]); + (string stdout, _) = Capture(root, 1, 1, MakeOptions(verbose: false)); + Assert.Contains("nested.rpf/", stdout); + Assert.DoesNotContain("2.0 KiB", stdout); + } + + [Fact] + public void PrintTree_EmptyRoot_ShowsOnlyRootName() + { + Json.TreeNode root = MakeDirNode("empty.rpf/"); + (string stdout, string stderr) = Capture(root, 0, 0); + string firstLine = stdout.Split('\n')[0].TrimEnd('\r'); + Assert.Equal("empty.rpf/", firstLine); + Assert.Contains("0 directories, 0 files", stderr); + } + + [Fact] + public void PrintTree_NullChildren_NoOutput() + { + Json.TreeNode root = new() + { + Name = "test.rpf/", + Path = "", + Type = "dir", + Children = null + }; + (string stdout, _) = Capture(root, 0, 0); + string[] lines = stdout.Split(NewLineSeparator, StringSplitOptions.RemoveEmptyEntries); + _ = Assert.Single(lines); // Only the root name + } +} + +// PrintJsonTree + +[Collection("ConsoleOutput")] +public sealed class PrintJsonTreeTests +{ + private static TreeOptions MakeOptions(string rpfPath = "/test.rpf") => + new() + { + RpfPath = rpfPath, + ExePath = "/nonexistent", + Gen9 = false, + Filters = [], + Verbose = false, + Json = true, + Recursive = false, + SizeFormat = SizeFormat.IEC, + Depth = -1, + }; + + private static Json.TreeNode MakeFileNode( + string name, + string path = "", + long? size = null, + string? sizeFormatted = null, + string? fileType = null, + int? version = null) => + new() + { + Name = name, + Path = path, + Type = "file", + Size = size, + SizeFormatted = sizeFormatted, + FileType = fileType, + Version = version + }; + + private static Json.TreeNode MakeDirNode( + string name, + string path = "", + IReadOnlyList? children = null) => + new() + { + Name = name, + Path = path, + Type = "dir", + Children = children ?? [] + }; + + private static string CaptureJson( + Json.TreeNode root, + int totalFiles, + int totalDirs, + List? scanErrors = null, + TreeOptions? options = null) + { + options ??= MakeOptions(); + scanErrors ??= []; + TextWriter origOut = Console.Out; + try + { + StringWriter sw = new(); + Console.SetOut(sw); + TreeHandler.PrintJsonTree(root, totalFiles, totalDirs, scanErrors, options); + return sw.ToString(); + } + finally { Console.SetOut(origOut); } + } + + [Fact] + public void PrintJsonTree_WritesValidJson() + { + Json.TreeNode root = MakeDirNode("test.rpf/"); + string json = CaptureJson(root, 0, 0); + + Json.TreeResult? parsed = JsonSerializer.Deserialize( + json.Trim(), Output.JsonSerializerOptions + ); + Assert.NotNull(parsed); + } + + [Fact] + public void PrintJsonTree_ContainsAllTopLevelFields() + { + Json.TreeNode root = MakeDirNode("test.rpf/"); + string json = CaptureJson(root, 5, 2); + + Assert.Contains("\"success\": true", json); + Assert.Contains("\"rpfFile\": \"/test.rpf\"", json); + Assert.Contains("\"totalFiles\": 5", json); + Assert.Contains("\"totalDirs\": 2", json); + Assert.Contains("\"root\":", json); + Assert.Contains("\"errorMessages\": []", json); + } + + [Fact] + public void PrintJsonTree_RootNodeHasNamePathType() + { + Json.TreeNode root = MakeDirNode("test.rpf/", path: "/test.rpf"); + string json = CaptureJson(root, 0, 0); + + Assert.Contains("\"name\": \"test.rpf/\"", json); + Assert.Contains("\"path\": \"/test.rpf\"", json); + Assert.Contains("\"type\": \"dir\"", json); + } + + [Fact] + public void PrintJsonTree_FileNodeIncludesSizeFields() + { + Json.TreeNode root = MakeDirNode("test.rpf/", children: + [ + MakeFileNode("model.ydr", path: "model.ydr", size: 1024, sizeFormatted: "1.0 KiB", fileType: "Resource", version: 110) + ]); + string json = CaptureJson(root, 1, 0); + + Assert.Contains("\"size\": 1024", json); + Assert.Contains("\"sizeFormatted\": \"1.0 KiB\"", json); + Assert.Contains("\"fileType\": \"Resource\"", json); + Assert.Contains("\"version\": 110", json); + } + + [Fact] + public void PrintJsonTree_FileNodeOmitsNullOptionalFields() + { + Json.TreeNode root = MakeDirNode("test.rpf/", children: + [ + MakeFileNode("data.dat", path: "data.dat") + ]); + string json = CaptureJson(root, 1, 0); + + // These should be omitted (JsonIgnore WhenWritingNull) + Assert.DoesNotContain("\"size\":", json); + Assert.DoesNotContain("\"sizeFormatted\":", json); + Assert.DoesNotContain("\"fileType\":", json); + Assert.DoesNotContain("\"version\":", json); + } + + [Fact] + public void PrintJsonTree_ScanErrors_SetsSuccessFalse() + { + Json.TreeNode root = MakeDirNode("test.rpf/"); + string json = CaptureJson(root, 0, 0, scanErrors: ["scan failed"]); + + Assert.Contains("\"success\": false", json); + Assert.Contains("scan failed", json); + } + + [Fact] + public void PrintJsonTree_RoundTripsCorrectly() + { + Json.TreeNode root = MakeDirNode("test.rpf/", path: "/test.rpf", children: + [ + MakeDirNode("subdir", path: "/test.rpf/subdir", children: + [ + MakeFileNode("a.dat", path: "/test.rpf/subdir/a.dat", size: 100, sizeFormatted: "100 B", fileType: "Binary") + ]), + MakeFileNode("b.ydr", path: "/test.rpf/b.ydr", size: 500, sizeFormatted: "500 B", fileType: "Resource", version: 110) + ]); + string json = CaptureJson(root, 2, 1); + + Json.TreeResult? parsed = JsonSerializer.Deserialize( + json.Trim(), Output.JsonSerializerOptions + ); + Assert.NotNull(parsed); + Assert.True(parsed.Success); + Assert.Equal(2, parsed.TotalFiles); + Assert.Equal(1, parsed.TotalDirs); + Assert.NotNull(parsed.Root); + Assert.Equal("test.rpf/", parsed.Root.Name); + Assert.NotNull(parsed.Root.Children); + Assert.Equal(2, parsed.Root.Children.Count); + } + + [Fact] + public void PrintJsonTree_PreservesRpfPath() + { + Json.TreeNode root = MakeDirNode("test.rpf/"); + string json = CaptureJson(root, 0, 0, options: MakeOptions("/custom/path.rpf")); + Assert.Contains("\"rpfFile\": \"/custom/path.rpf\"", json); + } +} + +// PrintTreeChildren cancellation + +[Collection("ConsoleOutput")] +public sealed class PrintTreeChildrenCancellationTests +{ + private static TreeOptions MakeOptions() => + new() + { + RpfPath = "/test.rpf", + ExePath = "/nonexistent", + Gen9 = false, + Filters = [], + Verbose = false, + Json = false, + Recursive = false, + SizeFormat = SizeFormat.IEC, + Depth = -1, + }; + + [Fact] + public void PrintTreeChildren_Cancelled_ThrowsOperationCanceledException() + { + using CancellationTokenSource cts = new(); + cts.Cancel(); + + Json.TreeNode node = new() + { + Name = "root", + Path = "", + Type = "dir", + Children = + [ + new Json.TreeNode { Name = "a.dat", Path = "", Type = "file" } + ] + }; + + TextWriter origOut = Console.Out; + try + { + Console.SetOut(new StringWriter()); + _ = Assert.Throws( + () => TreeHandler.PrintTreeChildren(node, "", MakeOptions(), cts.Token) + ); + } + finally { Console.SetOut(origOut); } + } + + [Fact] + public void PrintTree_Cancelled_ThrowsOperationCanceledException() + { + using CancellationTokenSource cts = new(); + cts.Cancel(); + + Json.TreeNode root = new() + { + Name = "test.rpf/", + Path = "", + Type = "dir", + Children = + [ + new Json.TreeNode { Name = "a.dat", Path = "", Type = "file" } + ] + }; + + TextWriter origOut = Console.Out; + TextWriter origErr = Console.Error; + try + { + Console.SetOut(new StringWriter()); + Console.SetError(new StringWriter()); + _ = Assert.Throws( + () => TreeHandler.PrintTree(root, 1, 0, MakeOptions(), cts.Token) + ); + } + finally + { + Console.SetOut(origOut); + Console.SetError(origErr); + } + } +} + +// Execute (integration) + +[Collection("ConsoleOutput")] +public sealed class TreeExecuteTests +{ + private static TreeOptions MakeOptions(string rpfPath, bool json, int depth = -1) => + new() + { + RpfPath = rpfPath, + ExePath = "/nonexistent", + Gen9 = false, + Filters = [], + Verbose = false, + Json = json, + Recursive = false, + SizeFormat = SizeFormat.IEC, + Depth = depth, + }; + + // Validation failures + + [Fact] + public void Execute_MissingRpf_ReturnsOne() + { + TextWriter origOut = Console.Out; + TextWriter origErr = Console.Error; + try + { + StringWriter stderr = new(); + Console.SetOut(new StringWriter()); + Console.SetError(stderr); + + int exitCode = TreeHandler.Execute( + MakeOptions("/nonexistent/test.rpf", json: false), + TestContext.Current.CancellationToken + ); + + Assert.Equal(1, exitCode); + Assert.Contains("Error:", stderr.ToString()); + } + finally + { + Console.SetOut(origOut); + Console.SetError(origErr); + } + } + + [Fact] + public void Execute_MissingRpf_Json_ReturnsErrorJson() + { + TextWriter origOut = Console.Out; + TextWriter origErr = Console.Error; + try + { + StringWriter stdout = new(); + Console.SetOut(stdout); + Console.SetError(new StringWriter()); + + int exitCode = TreeHandler.Execute( + MakeOptions("/nonexistent/test.rpf", json: true), + TestContext.Current.CancellationToken + ); + + Assert.Equal(1, exitCode); + string output = stdout.ToString(); + Assert.Contains("\"success\": false", output); + Assert.Contains("RPF file not found", output); + } + finally + { + Console.SetOut(origOut); + Console.SetError(origErr); + } + } + + [Fact] + public void Execute_Json_ErrorContainsExpectedFields() + { + TextWriter origOut = Console.Out; + try + { + StringWriter stdout = new(); + Console.SetOut(stdout); + + _ = TreeHandler.Execute( + MakeOptions("/nonexistent/test.rpf", json: true), + TestContext.Current.CancellationToken + ); + + string output = stdout.ToString(); + Assert.Contains("\"rpfFile\":", output); + Assert.Contains("\"totalFiles\": 0", output); + Assert.Contains("\"totalDirs\": 0", output); + Assert.Contains("\"root\": null", output); + } + finally { Console.SetOut(origOut); } + } + + [Fact] + public void Execute_MissingExe_WithExistingRpf_ReturnsOne() + { + string dir = Path.Combine(Path.GetTempPath(), "cw_tree_" + Guid.NewGuid().ToString("N")); + _ = Directory.CreateDirectory(dir); + string rpf = Path.Combine(dir, "test.rpf"); + File.WriteAllBytes(rpf, []); + try + { + TextWriter origOut = Console.Out; + TextWriter origErr = Console.Error; + try + { + Console.SetOut(new StringWriter()); + StringWriter stderr = new(); + Console.SetError(stderr); + + TreeOptions options = MakeOptions(rpf, json: false); + int exitCode = TreeHandler.Execute(options, TestContext.Current.CancellationToken); + + Assert.Equal(1, exitCode); + Assert.Contains("Error:", stderr.ToString()); + } + finally + { + Console.SetOut(origOut); + Console.SetError(origErr); + } + } + finally { Directory.Delete(dir, true); } + } +} + +// CollectChildren + +public sealed class CollectChildrenTests +{ + private static TreeOptions MakeOptions( + bool recursive = false, + string[]? filters = null) => + new() + { + RpfPath = "/test.rpf", + ExePath = "/nonexistent", + Gen9 = false, + Filters = filters ?? [], + Verbose = false, + Json = false, + Recursive = recursive, + SizeFormat = SizeFormat.IEC, + Depth = -1, + }; + + private static RpfFile MakeRpf(List? children = null) + { + RpfFile rpf = new("test.rpf", "/test.rpf", 0) + { + Children = children + }; + return rpf; + } + + [Fact] + public void EmptyDirectory_ReturnsEmptyList() + { + RpfDirectoryEntry dir = new() + { + Name = "root", + NameLower = "root", + Path = "/root", + Directories = null!, + Files = null! + }; + + List items = TreeHandler.CollectChildren(dir, MakeRpf(), MakeOptions()); + + Assert.Empty(items); + } + + [Fact] + public void EmptyDirectory_WithEmptyLists_ReturnsEmptyList() + { + RpfDirectoryEntry dir = new() { Name = "root", NameLower = "root", Path = "/root" }; + + List items = TreeHandler.CollectChildren(dir, MakeRpf(), MakeOptions()); + + Assert.Empty(items); + } + + [Fact] + public void Subdirectories_ReturnedAsDirItems() + { + RpfDirectoryEntry sub1 = new() { Name = "sub1", NameLower = "sub1", Path = "/root/sub1" }; + RpfDirectoryEntry sub2 = new() { Name = "sub2", NameLower = "sub2", Path = "/root/sub2" }; + RpfDirectoryEntry dir = new() + { + Name = "root", + NameLower = "root", + Path = "/root", + Directories = [sub1, sub2] + }; + + List items = TreeHandler.CollectChildren(dir, MakeRpf(), MakeOptions()); + + Assert.Equal(2, items.Count); + Assert.All(items, i => Assert.True(i.IsDir)); + Assert.Equal("sub1", items[0].Name); + Assert.Equal("sub2", items[1].Name); + Assert.All(items, i => Assert.Null(i.ChildRpf)); + } + + [Fact] + public void Files_ReturnedAsFileItems() + { + RpfBinaryFileEntry f1 = new() { Name = "a.dat", NameLower = "a.dat", Path = "/root/a.dat" }; + RpfBinaryFileEntry f2 = new() { Name = "b.dat", NameLower = "b.dat", Path = "/root/b.dat" }; + RpfDirectoryEntry dir = new() + { + Name = "root", + NameLower = "root", + Path = "/root", + Files = [f1, f2] + }; + + List items = TreeHandler.CollectChildren(dir, MakeRpf(), MakeOptions()); + + Assert.Equal(2, items.Count); + Assert.All(items, i => Assert.False(i.IsDir)); + Assert.Equal("a.dat", items[0].Name); + Assert.Equal("b.dat", items[1].Name); + } + + [Fact] + public void NonRecursive_RpfFilesListedAsFiles() + { + RpfBinaryFileEntry rpfFile = new() + { + Name = "nested.rpf", + NameLower = "nested.rpf", + Path = "/root/nested.rpf" + }; + RpfDirectoryEntry childRoot = new() { Name = "nested", NameLower = "nested", Path = "/nested" }; + RpfFile childRpf = new("nested.rpf", "/nested.rpf", 0) { Root = childRoot }; + + RpfDirectoryEntry dir = new() + { + Name = "root", + NameLower = "root", + Path = "/root", + Files = [rpfFile] + }; + + List items = TreeHandler.CollectChildren( + dir, MakeRpf(children: [childRpf]), MakeOptions(recursive: false)); + + _ = Assert.Single(items); + Assert.False(items[0].IsDir); + Assert.Equal("nested.rpf", items[0].Name); + } + + [Fact] + public void Recursive_RpfFilesExpandedAsDirectories() + { + RpfBinaryFileEntry rpfFile = new() + { + Name = "nested.rpf", + NameLower = "nested.rpf", + Path = "/root/nested.rpf" + }; + RpfDirectoryEntry childRoot = new() { Name = "nested", NameLower = "nested", Path = "/nested" }; + RpfFile childRpf = new("nested.rpf", "/nested.rpf", 0) { Root = childRoot }; + + RpfDirectoryEntry dir = new() + { + Name = "root", + NameLower = "root", + Path = "/root", + Files = [rpfFile] + }; + + List items = TreeHandler.CollectChildren( + dir, MakeRpf(children: [childRpf]), MakeOptions(recursive: true)); + + // Expanded as dir + not duplicated as file + _ = Assert.Single(items); + Assert.True(items[0].IsDir); + Assert.Equal("nested.rpf", items[0].Name); + Assert.Same(childRpf, items[0].ChildRpf); + Assert.Same(childRoot, items[0].Entry); + Assert.Same(rpfFile, items[0].ArchiveEntry); + } + + [Fact] + public void Recursive_RpfWithNullRoot_NotExpanded() + { + RpfBinaryFileEntry rpfFile = new() + { + Name = "broken.rpf", + NameLower = "broken.rpf", + Path = "/root/broken.rpf" + }; + RpfFile childRpf = new("broken.rpf", "/broken.rpf", 0); + + RpfDirectoryEntry dir = new() + { + Name = "root", + NameLower = "root", + Path = "/root", + Files = [rpfFile] + }; + + List items = TreeHandler.CollectChildren( + dir, MakeRpf(children: [childRpf]), MakeOptions(recursive: true)); + + // Not expanded (null Root), listed as file instead + _ = Assert.Single(items); + Assert.False(items[0].IsDir); + Assert.Equal("broken.rpf", items[0].Name); + } + + [Fact] + public void FilterMatching_OnlyMatchingFilesIncluded() + { + RpfBinaryFileEntry ydr = new() { Name = "model.ydr", NameLower = "model.ydr", Path = "/root/model.ydr" }; + RpfBinaryFileEntry dat = new() { Name = "data.dat", NameLower = "data.dat", Path = "/root/data.dat" }; + RpfDirectoryEntry dir = new() + { + Name = "root", + NameLower = "root", + Path = "/root", + Files = [ydr, dat] + }; + + List items = TreeHandler.CollectChildren( + dir, MakeRpf(), MakeOptions(filters: ["*.ydr"])); + + _ = Assert.Single(items); + Assert.Equal("model.ydr", items[0].Name); + } + + [Fact] + public void DirsAndFiles_OrderedCorrectly() + { + RpfDirectoryEntry sub = new() { Name = "subdir", NameLower = "subdir", Path = "/root/subdir" }; + RpfBinaryFileEntry file = new() { Name = "data.dat", NameLower = "data.dat", Path = "/root/data.dat" }; + RpfDirectoryEntry dir = new() + { + Name = "root", + NameLower = "root", + Path = "/root", + Directories = [sub], + Files = [file] + }; + + List items = TreeHandler.CollectChildren(dir, MakeRpf(), MakeOptions()); + + Assert.Equal(2, items.Count); + Assert.True(items[0].IsDir); // dirs first + Assert.False(items[1].IsDir); // then files + } +} + +// BuildTreeNode + +public sealed class BuildTreeNodeTests +{ + private static TreeOptions MakeOptions( + int depth = -1, + string[]? filters = null) => + new() + { + RpfPath = "/test.rpf", + ExePath = "/nonexistent", + Gen9 = false, + Filters = filters ?? [], + Verbose = false, + Json = false, + Recursive = false, + SizeFormat = SizeFormat.IEC, + Depth = depth, + }; + + private static RpfFile MakeRpf() => new("test.rpf", "/test.rpf", 0); + + [Fact] + public void EmptyDirectory_ReturnsDirNodeWithEmptyChildren() + { + RpfDirectoryEntry dir = new() { Name = "root", NameLower = "root", Path = "/root" }; + int totalFiles = 0, totalDirs = 0; + + Json.TreeNode node = TreeHandler.BuildTreeNode( + dir, MakeRpf(), MakeOptions(), 0, ref totalFiles, ref totalDirs, + TestContext.Current.CancellationToken); + + Assert.Equal("root", node.Name); + Assert.Equal("dir", node.Type); + Assert.NotNull(node.Children); + Assert.Empty(node.Children); + Assert.Equal(0, totalFiles); + Assert.Equal(0, totalDirs); + } + + [Fact] + public void FlatFiles_CorrectTotalFilesCount() + { + RpfBinaryFileEntry f1 = new() + { + Name = "a.dat", + NameLower = "a.dat", + Path = "/root/a.dat", + FileSize = 100 + }; + RpfBinaryFileEntry f2 = new() + { + Name = "b.dat", + NameLower = "b.dat", + Path = "/root/b.dat", + FileSize = 200 + }; + RpfDirectoryEntry dir = new() + { + Name = "root", + NameLower = "root", + Path = "/root", + Files = [f1, f2] + }; + int totalFiles = 0, totalDirs = 0; + + Json.TreeNode node = TreeHandler.BuildTreeNode( + dir, MakeRpf(), MakeOptions(), 0, ref totalFiles, ref totalDirs, + TestContext.Current.CancellationToken); + + Assert.Equal(2, totalFiles); + Assert.Equal(0, totalDirs); + Assert.NotNull(node.Children); + Assert.Equal(2, node.Children.Count); + Assert.All(node.Children, c => Assert.Equal("file", c.Type)); + } + + [Fact] + public void FlatFiles_NodeHasSizeAndType() + { + RpfBinaryFileEntry f = new() + { + Name = "data.dat", + NameLower = "data.dat", + Path = "/root/data.dat", + FileSize = 1024 + }; + RpfDirectoryEntry dir = new() + { + Name = "root", + NameLower = "root", + Path = "/root", + Files = [f] + }; + int totalFiles = 0, totalDirs = 0; + + Json.TreeNode node = TreeHandler.BuildTreeNode( + dir, MakeRpf(), MakeOptions(), 0, ref totalFiles, ref totalDirs, + TestContext.Current.CancellationToken); + + Json.TreeNode fileNode = node.Children![0]; + Assert.Equal("data.dat", fileNode.Name); + Assert.Equal(1024, fileNode.Size); + Assert.NotNull(fileNode.SizeFormatted); + Assert.Equal("binary", fileNode.FileType); + } + + [Fact] + public void NestedDirectories_CorrectTotalDirsCount() + { + RpfDirectoryEntry inner = new() { Name = "inner", NameLower = "inner", Path = "/root/sub/inner" }; + RpfDirectoryEntry sub = new() + { + Name = "sub", + NameLower = "sub", + Path = "/root/sub", + Directories = [inner] + }; + + RpfDirectoryEntry dir = new() + { + Name = "root", + NameLower = "root", + Path = "/root", + Directories = [sub] + }; + int totalFiles = 0, totalDirs = 0; + + Json.TreeNode node = TreeHandler.BuildTreeNode( + dir, MakeRpf(), MakeOptions(), 0, ref totalFiles, ref totalDirs, + TestContext.Current.CancellationToken); + + Assert.Equal(2, totalDirs); + Assert.NotNull(node.Children); + _ = Assert.Single(node.Children); + Assert.Equal("sub", node.Children[0].Name); + Assert.NotNull(node.Children[0].Children); + Json.TreeNode innerNode = Assert.Single(node.Children[0].Children!); + Assert.Equal("inner", innerNode.Name); + } + + [Fact] + public void DepthZero_NoChildrenCollected() + { + RpfBinaryFileEntry f = new() + { + Name = "data.dat", + NameLower = "data.dat", + Path = "/root/data.dat", + FileSize = 100 + }; + RpfDirectoryEntry dir = new() + { + Name = "root", + NameLower = "root", + Path = "/root", + Files = [f] + }; + int totalFiles = 0, totalDirs = 0; + + Json.TreeNode node = TreeHandler.BuildTreeNode( + dir, MakeRpf(), MakeOptions(depth: 0), 0, ref totalFiles, ref totalDirs, + TestContext.Current.CancellationToken); + + Assert.NotNull(node.Children); + Assert.Empty(node.Children); + Assert.Equal(0, totalFiles); + } + + [Fact] + public void DepthOne_OnlyFirstLevel() + { + RpfBinaryFileEntry innerFile = new() + { + Name = "deep.dat", + NameLower = "deep.dat", + Path = "/root/sub/deep.dat", + FileSize = 50 + }; + RpfDirectoryEntry sub = new() + { + Name = "sub", + NameLower = "sub", + Path = "/root/sub", + Files = [innerFile] + }; + + RpfBinaryFileEntry topFile = new() + { + Name = "top.dat", + NameLower = "top.dat", + Path = "/root/top.dat", + FileSize = 100 + }; + RpfDirectoryEntry dir = new() + { + Name = "root", + NameLower = "root", + Path = "/root", + Directories = [sub], + Files = [topFile] + }; + int totalFiles = 0, totalDirs = 0; + + Json.TreeNode node = TreeHandler.BuildTreeNode( + dir, MakeRpf(), MakeOptions(depth: 1), 0, ref totalFiles, ref totalDirs, + TestContext.Current.CancellationToken); + + Assert.Equal(1, totalFiles); // only top.dat counted + Assert.Equal(1, totalDirs); // sub counted as dir + Json.TreeNode subNode = node.Children!.First(c => c.Name == "sub"); + Assert.NotNull(subNode.Children); + Assert.Empty(subNode.Children); // depth limit prevents going deeper + } + + [Fact] + public void FilterWithEmptyDirs_Pruned() + { + RpfDirectoryEntry emptySub = new() + { + Name = "empty", + NameLower = "empty", + Path = "/root/empty" + }; + RpfBinaryFileEntry matchFile = new() + { + Name = "model.ydr", + NameLower = "model.ydr", + Path = "/root/model.ydr", + FileSize = 256 + }; + RpfDirectoryEntry dir = new() + { + Name = "root", + NameLower = "root", + Path = "/root", + Directories = [emptySub], + Files = [matchFile] + }; + int totalFiles = 0, totalDirs = 0; + + Json.TreeNode node = TreeHandler.BuildTreeNode( + dir, MakeRpf(), MakeOptions(filters: ["*.ydr"]), 0, ref totalFiles, ref totalDirs, + TestContext.Current.CancellationToken); + + // Empty dir pruned, only file remains + Assert.Equal(1, totalFiles); + Assert.Equal(0, totalDirs); + Assert.NotNull(node.Children); + _ = Assert.Single(node.Children); + Assert.Equal("model.ydr", node.Children[0].Name); + } + + [Fact] + public void Cancellation_ThrowsOperationCanceledException() + { + using CancellationTokenSource cts = new(); + cts.Cancel(); + + RpfDirectoryEntry dir = new() { Name = "root", NameLower = "root", Path = "/root" }; + int totalFiles = 0, totalDirs = 0; + + _ = Assert.Throws(() => + TreeHandler.BuildTreeNode( + dir, MakeRpf(), MakeOptions(), 0, ref totalFiles, ref totalDirs, cts.Token)); + } + + [Fact] + public void ResourceFile_VersionPopulated() + { + // Version = (sv << 4) + gv where sv = (sysFlags >> 28) & 0xF, gv = (gfxFlags >> 28) & 0xF + // For version 110 = 0x6E = (6 << 4) + 14: sysFlags = 6 << 28, gfxFlags = 14 << 28 + RpfResourceFileEntry rfe = new() + { + Name = "model.ydr", + NameLower = "model.ydr", + Path = "/root/model.ydr", + FileSize = 512, + SystemFlags = (uint)6 << 28, + GraphicsFlags = (uint)14 << 28 + }; + RpfDirectoryEntry dir = new() + { + Name = "root", + NameLower = "root", + Path = "/root", + Files = [rfe] + }; + int totalFiles = 0, totalDirs = 0; + + Json.TreeNode node = TreeHandler.BuildTreeNode( + dir, MakeRpf(), MakeOptions(), 0, ref totalFiles, ref totalDirs, + TestContext.Current.CancellationToken); + + Json.TreeNode fileNode = node.Children![0]; + Assert.Equal("resource", fileNode.FileType); + Assert.Equal(110, fileNode.Version); + } + + [Fact] + public void BinaryFile_VersionNull() + { + RpfBinaryFileEntry bfe = new() + { + Name = "data.dat", + NameLower = "data.dat", + Path = "/root/data.dat", + FileSize = 256 + }; + RpfDirectoryEntry dir = new() + { + Name = "root", + NameLower = "root", + Path = "/root", + Files = [bfe] + }; + int totalFiles = 0, totalDirs = 0; + + Json.TreeNode node = TreeHandler.BuildTreeNode( + dir, MakeRpf(), MakeOptions(), 0, ref totalFiles, ref totalDirs, + TestContext.Current.CancellationToken); + + Json.TreeNode fileNode = node.Children![0]; + Assert.Equal("binary", fileNode.FileType); + Assert.Null(fileNode.Version); + } + + [Fact] + public void DirName_FallsBackToRpfFilePath() + { + RpfDirectoryEntry dir = new() { Name = null!, NameLower = null!, Path = null! }; + RpfFile rpf = new("test.rpf", "/some/path/test.rpf", 0); + int totalFiles = 0, totalDirs = 0; + + Json.TreeNode node = TreeHandler.BuildTreeNode( + dir, rpf, MakeOptions(), 0, ref totalFiles, ref totalDirs, + TestContext.Current.CancellationToken); + + Assert.Equal("test.rpf", node.Name); + Assert.Equal("/some/path/test.rpf", node.Path); + } +} diff --git a/CodeWalker.Cli/Tests/Handlers/ValidateHandlerTests.cs b/CodeWalker.Cli/Tests/Handlers/ValidateHandlerTests.cs new file mode 100644 index 000000000..72b8322c8 --- /dev/null +++ b/CodeWalker.Cli/Tests/Handlers/ValidateHandlerTests.cs @@ -0,0 +1,131 @@ +using System; +using System.IO; + +using CodeWalker.Cli.Handlers; +using CodeWalker.Cli.Helpers; + +using Xunit; + +namespace CodeWalker.Cli.Tests.Handlers; + +[Collection("ConsoleOutput")] +public sealed class ValidateHandlerTests +{ + private static ValidateOptions MakeOptions(string rpfPath, bool json) => + new() + { + RpfPath = rpfPath, + ExePath = "/nonexistent", + Gen9 = false, + Filters = [], + Verbose = false, + Json = json, + Recursive = false, + Threads = 1, + SizeFormat = SizeFormat.IEC, + Progress = false, + }; + + // Validation failures + + [Fact] + public void Execute_MissingRpf_ReturnsOne() + { + TextWriter origOut = Console.Out; + TextWriter origErr = Console.Error; + try + { + StringWriter stderr = new(); + Console.SetOut(new StringWriter()); + Console.SetError(stderr); + + int exitCode = ValidateHandler.Execute(MakeOptions("/nonexistent/test.rpf", json: false), TestContext.Current.CancellationToken); + + Assert.Equal(1, exitCode); + Assert.Contains("Error:", stderr.ToString()); + } + finally + { + Console.SetOut(origOut); + Console.SetError(origErr); + } + } + + [Fact] + public void Execute_MissingRpf_Json_ReturnsErrorJson() + { + TextWriter origOut = Console.Out; + TextWriter origErr = Console.Error; + try + { + StringWriter stdout = new(); + Console.SetOut(stdout); + Console.SetError(new StringWriter()); + + int exitCode = ValidateHandler.Execute(MakeOptions("/nonexistent/test.rpf", json: true), TestContext.Current.CancellationToken); + + Assert.Equal(1, exitCode); + string output = stdout.ToString(); + Assert.Contains("\"success\": false", output); + Assert.Contains("RPF file not found", output); + } + finally + { + Console.SetOut(origOut); + Console.SetError(origErr); + } + } + + [Fact] + public void Execute_Json_ErrorContainsExpectedFields() + { + TextWriter origOut = Console.Out; + try + { + StringWriter stdout = new(); + Console.SetOut(stdout); + + _ = ValidateHandler.Execute(MakeOptions("/nonexistent/test.rpf", json: true), TestContext.Current.CancellationToken); + + string output = stdout.ToString(); + Assert.Contains("\"rpfFile\":", output); + Assert.Contains("\"totalFiles\": 0", output); + Assert.Contains("\"valid\": 0", output); + Assert.Contains("\"warnings\": 0", output); + Assert.Contains("\"errors\": 0", output); + Assert.Contains("\"skipped\": 0", output); + } + finally { Console.SetOut(origOut); } + } + + [Fact] + public void Execute_MissingExe_WithExistingRpf_ReturnsOne() + { + string dir = Path.Combine(Path.GetTempPath(), "cw_val_" + Guid.NewGuid().ToString("N")); + _ = Directory.CreateDirectory(dir); + string rpf = Path.Combine(dir, "test.rpf"); + File.WriteAllBytes(rpf, []); + try + { + TextWriter origOut = Console.Out; + TextWriter origErr = Console.Error; + try + { + Console.SetOut(new StringWriter()); + StringWriter stderr = new(); + Console.SetError(stderr); + + int exitCode = ValidateHandler.Execute(MakeOptions(rpf, json: false), TestContext.Current.CancellationToken); + + Assert.Equal(1, exitCode); + Assert.Contains("Error:", stderr.ToString()); + } + finally + { + Console.SetOut(origOut); + Console.SetError(origErr); + } + } + finally { Directory.Delete(dir, true); } + } +} diff --git a/CodeWalker.Cli/Tests/Helpers/FilterTests.cs b/CodeWalker.Cli/Tests/Helpers/FilterTests.cs new file mode 100644 index 000000000..29ab32f48 --- /dev/null +++ b/CodeWalker.Cli/Tests/Helpers/FilterTests.cs @@ -0,0 +1,100 @@ +using CodeWalker.Cli.Helpers; + +using Xunit; + +namespace CodeWalker.Cli.Tests.Helpers; + +public sealed class FilterTests +{ + [Theory] + // Null or empty returns empty + [InlineData(new string[] { }, null)] + [InlineData(new string[] { }, new string[] { })] + // Trim and lowercase + [InlineData(new[] { ".ydr", "foo" }, new[] { " .YDR ", "Foo" })] + // Strips blank entries + [InlineData(new[] { "a", "b" }, new[] { "a", "", " ", "b" })] + // Preserves wildcards and lowercases + [InlineData(new[] { "*.ydr" }, new[] { " *.YDR " })] + [InlineData(new[] { "model?.ydr" }, new[] { "Model?.YDR" })] + public void Normalize_ReturnsExpectedResult(string[] expected, string[]? input) + { + string[] result = Filter.Normalize(input); + Assert.Equal(expected, result); + } + + [Theory] +#pragma warning disable format + // No filters + [InlineData(true, "anything.ydr")] + [InlineData(true, "anything.ydr", null)] + // Empty path + [InlineData(false, "", ".ydr")] + [InlineData(true, "", null)] + // Extension with dot + [InlineData(true, "model.ydr", ".ydr")] + [InlineData(false, "model.ytd", ".ydr")] + // Extension without dot + [InlineData(true, "model.ydr", "ydr")] + [InlineData(false, "model.ytd", "ydr")] + // Wildcard pattern + [InlineData(true, "model.ydr", "*.ydr")] + [InlineData(true, "dir/model.ydr", "*.ydr")] + [InlineData(false, "model.ytd", "*.ydr")] + [InlineData(false, "dir/model.ytd", "*.ydr")] + // Path pattern + [InlineData(true, "vehicles/foo.ydr", "vehicles/*.ydr")] + [InlineData(true, "vehicles/bar.ydr", "vehicles/*.ydr")] + [InlineData(false, "peds/ped.ydr", "vehicles/*.ydr")] + [InlineData(false, "vehicles/model.ytd", "vehicles/*.ydr")] + // Globstar pattern + [InlineData(true, "x64/dlcpacks/vehicles/car.ydr", "**/vehicles/*.ydr")] + [InlineData(true, "vehicles/car.ydr", "**/vehicles/*.ydr")] + [InlineData(false, "x64/dlcpacks/vehicles/car.ytd", "**/vehicles/*.ydr")] + [InlineData(false, "vehicles/car.ytd", "**/vehicles/*.ydr")] + [InlineData(false, "x64/dlcpacks/peds/foo.ydr", "**/vehicles/*.ydr")] + [InlineData(false, "peds/bar.ydr", "**/vehicles/*.ydr")] + // Case insensitive + [InlineData(true, "MODEL.YDR", ".ydr")] + [InlineData(true, "MODEL.YDR", "ydr")] + [InlineData(true, "MODEL.YDR", "*.ydr")] + [InlineData(true, "X64/DLCPACKS/VEHICLES/CAR.YDR", "**/vehicles/*.ydr")] + [InlineData(true, "VEHICLES/FOO.YDR", "vehicles/*.ydr")] + [InlineData(false, "X64/DLCPACKS/VEHICLES/CAR.YTD", "**/vehicles/*.ydr")] + [InlineData(false, "VEHICLES/FOO.YTD", "vehicles/*.ydr")] + [InlineData(false, "X64/DLCPACKS/PEDS/FOO.YDR", "**/vehicles/*.ydr")] + [InlineData(false, "PEDS/BAR.YDR", "**/vehicles/*.ydr")] + // Single-char wildcard + [InlineData(true, "model1.ydr", "model?.ydr")] + [InlineData(true, "modelA.ydr", "model?.ydr")] + [InlineData(false, "modelAB.ydr", "model?.ydr")] + [InlineData(false, "model.ydr", "model?.ydr")] + [InlineData(true, "a.ydr", "?.ydr")] + [InlineData(false, "ab.ydr", "?.ydr")] + // Single-char wildcard in path + [InlineData(true, "v1/car.ydr", "v?/*.ydr")] + [InlineData(false, "vx/car.ytd", "v?/*.ydr")] + // Standalone ** (no trailing /) + [InlineData(true, "a/b/c.ydr", "**.ydr")] + [InlineData(true, "c.ydr", "**.ydr")] + [InlineData(false, "a/b/c.ytd", "**.ydr")] + // Path pattern matched at mid-path boundary + [InlineData(true, "x64/vehicles/car.ydr", "vehicles/*.ydr")] + [InlineData(true, "a/b/vehicles/car.ydr", "vehicles/*.ydr")] + [InlineData(false, "x64/vehicles/car.ytd", "vehicles/*.ydr")] + [InlineData(false, "x64/notvehicles/car.ydr", "vehicles/*.ydr")] + // Backslash normalized + [InlineData(true, "vehicles/car.ydr", "vehicles\\*.ydr")] + [InlineData(false, "vehicles/car.ytd", "vehicles\\*.ydr")] + // Multiple patterns + [InlineData(true, "model.ydr", "vehicles/*.ydr", "*.ydr")] + [InlineData(false, "model.ytd", "vehicles/*.ydr", "*.ydr")] + [InlineData(true, "vehicles/car.ydr", "peds/*.ydr", "vehicles/*.ydr")] + [InlineData(false, "vehicles/car.ytd", "peds/*.ydr", "vehicles/*.ydr")] +#pragma warning restore format + public void Matches_ReturnsExpectedResult(bool expected, string path, params string[]? filters) + { + bool result = Filter.Matches(path, filters); + Assert.Equal(expected, result); + } +} diff --git a/CodeWalker.Cli/Tests/Helpers/ProgressBarTests.cs b/CodeWalker.Cli/Tests/Helpers/ProgressBarTests.cs new file mode 100644 index 000000000..2164c5b03 --- /dev/null +++ b/CodeWalker.Cli/Tests/Helpers/ProgressBarTests.cs @@ -0,0 +1,345 @@ +using System.IO; +using System.Threading.Tasks; + +using CodeWalker.Cli.Helpers; + +using Xunit; + +namespace CodeWalker.Cli.Tests.Helpers; + +public sealed class ProgressBarTests +{ + // Helper + + /// Increments the bar times, optionally passing a file on the last call. + private static void IncrementTo(ProgressBar bar, int count, string? lastFile = null) + { + for (int i = 1; i < count; i++) + bar.Increment(); + if (count > 0) + bar.Increment(lastFile); + } + + // Disabled-state tests + + [Fact] + public void Constructor_disabled_when_enabled_is_false() + { + StringWriter sw = new(); + using ProgressBar bar = new(100, enabled: false, sw); + Assert.False(bar.Enabled); + Assert.Equal("", sw.ToString()); + } + + [Fact] + public void Constructor_disabled_when_total_is_zero() + { + StringWriter sw = new(); + using ProgressBar bar = new(0, enabled: true, sw); + Assert.False(bar.Enabled); + } + + [Fact] + public void Constructor_disabled_when_total_is_negative() + { + StringWriter sw = new(); + using ProgressBar bar = new(-5, enabled: true, sw); + Assert.False(bar.Enabled); + } + + // Enabled-state tests + + [Fact] + public void Constructor_enabled_with_custom_writer() + { + StringWriter sw = new(); + using ProgressBar bar = new(100, enabled: true, sw); + Assert.True(bar.Enabled); + } + + [Fact] + public void Constructor_renders_initial_zero_percent() + { + StringWriter sw = new(); + using ProgressBar bar = new(100, enabled: true, sw, windowWidth: 120); + string output = sw.ToString(); + Assert.StartsWith("[", output); + Assert.Contains("(0/100)", output); + Assert.Contains(">", output); // cursor indicator at start + } + + // Render format tests + + [Fact] + public void Render_at_50_percent_has_half_filled_bar() + { + StringWriter sw = new(); + using ProgressBar bar = new(100, enabled: true, sw, windowWidth: 120); + // Increment to 49 (throttled, no renders) + IncrementTo(bar, 49); + _ = sw.GetStringBuilder().Clear(); + bar.ResetThrottle(); + bar.Increment(); // 50th — renders after throttle reset + string output = sw.ToString(); + // 50% => filled = (int)(0.5 * 40) = 20 + Assert.Contains(new string('=', 20) + ">", output); + Assert.Contains("(50/100)", output); + } + + [Fact] + public void Render_at_100_percent_has_full_bar_no_cursor() + { + StringWriter sw = new(); + using ProgressBar bar = new(10, enabled: true, sw, windowWidth: 120); + _ = sw.GetStringBuilder().Clear(); + IncrementTo(bar, 10); // == total, bypasses throttle + string output = sw.ToString(); + Assert.Contains(new string('=', 40) + "]", output); + Assert.DoesNotContain(">", output); + Assert.Contains("(10/10)", output); + } + + // File name tests + + [Fact] + public void Render_shows_current_file() + { + StringWriter sw = new(); + using ProgressBar bar = new(10, enabled: true, sw, windowWidth: 120); + _ = sw.GetStringBuilder().Clear(); + IncrementTo(bar, 10, "textures/player.ytd"); // bypasses throttle at total + string output = sw.ToString(); + Assert.Contains("textures/player.ytd", output); + } + + [Fact] + public void Render_truncates_long_file_with_ellipsis() + { + StringWriter sw = new(); + // windowWidth=80 -> maxLen = Max(10, 80-40-30) = 10 + using ProgressBar bar = new(10, enabled: true, sw, windowWidth: 80); + _ = sw.GetStringBuilder().Clear(); + IncrementTo(bar, 10, "very/long/path/to/some/deeply/nested/file.ytd"); + string output = sw.ToString(); + Assert.Contains("...", output); + Assert.DoesNotContain("very/long/path", output); + } + + [Fact] + public void Increment_renders_file_name() + { + StringWriter sw = new(); + using ProgressBar bar = new(10, enabled: true, sw, windowWidth: 120); + _ = sw.GetStringBuilder().Clear(); + // Increment 10 times to hit total (bypasses throttle) + for (int i = 0; i < 9; i++) + bar.Increment(); + _ = sw.GetStringBuilder().Clear(); + bar.Increment("models/vehicle.yft"); + string output = sw.ToString(); + Assert.Contains("models/vehicle.yft", output); + Assert.Contains("(10/10)", output); + } + + [Fact] + public void Render_shows_short_file_without_truncation() + { + StringWriter sw = new(); + using ProgressBar bar = new(10, enabled: true, sw, windowWidth: 200); + _ = sw.GetStringBuilder().Clear(); + IncrementTo(bar, 10, "short.ytd"); + string output = sw.ToString(); + Assert.Contains("short.ytd", output); + Assert.DoesNotContain("...", output); + } + + // State tracking tests + + [Fact] + public void Increment_advances_by_one() + { + StringWriter sw = new(); + using ProgressBar bar = new(100, enabled: true, sw); + bar.Increment(); + bar.Increment(); + bar.Increment(); + Assert.Equal(3, bar.Current); + } + + // Throttle tests + + [Fact] + public void Throttle_skips_rapid_increments() + { + StringWriter sw = new(); + using ProgressBar bar = new(100, enabled: true, sw, windowWidth: 80); + _ = sw.GetStringBuilder().Clear(); + // Rapid increments within the 50ms throttle window - none should render + for (int i = 1; i <= 50; i++) + bar.Increment(); + string output = sw.ToString(); + Assert.Equal("", output); + } + + [Fact] + public void Throttle_bypassed_at_total() + { + StringWriter sw = new(); + using ProgressBar bar = new(10, enabled: true, sw, windowWidth: 80); + _ = sw.GetStringBuilder().Clear(); + // Increment to total always renders even within throttle window + IncrementTo(bar, 10); + Assert.Contains("(10/10)", sw.ToString()); + } + + [Fact] + public void Throttle_reset_allows_render() + { + StringWriter sw = new(); + using ProgressBar bar = new(100, enabled: true, sw, windowWidth: 80); + _ = sw.GetStringBuilder().Clear(); + bar.ResetThrottle(); + bar.Increment(); + Assert.Contains("(1/100)", sw.ToString()); + } + + // Dispose tests + + [Fact] + public void Dispose_writes_newline_when_enabled() + { + StringWriter sw = new(); + ProgressBar bar = new(10, enabled: true, sw, windowWidth: 80); + bar.Dispose(); + Assert.EndsWith(sw.NewLine, sw.ToString()); + } + + [Fact] + public void Dispose_does_not_throw_when_disabled() + { + ProgressBar bar = new(10, enabled: false, new StringWriter()); + bar.Dispose(); + } + + [Fact] + public void Dispose_can_be_called_multiple_times() + { + ProgressBar bar = new(10, enabled: true, new StringWriter()); + bar.Dispose(); + bar.Dispose(); + } + + // Thread safety tests + + [Fact] + public void Concurrent_increments_are_thread_safe() + { + const int total = 10_000; + StringWriter sw = new(); + ProgressBar bar = new(total, enabled: true, sw); + + _ = Parallel.For(0, total, _ => bar.Increment()); + + Assert.Equal(total, bar.Current); + } + + // Disabled-state mutation tests + + [Fact] + public void Increment_on_disabled_bar_writes_nothing() + { + StringWriter sw = new(); + using ProgressBar bar = new(100, enabled: false, sw); + bar.Increment(); + Assert.Equal("", sw.ToString()); + } + + // Clamping tests + + [Fact] + public void Increment_clamps_current_at_total() + { + StringWriter sw = new(); + using ProgressBar bar = new(3, enabled: true, sw); + for (int i = 0; i < 10; i++) + bar.Increment(); + Assert.Equal(3, bar.Current); + } + + // Render exception handling tests + + [Fact] + public void Render_swallows_IOException_from_writer() + { + ThrowingWriter tw = new(); + using ProgressBar bar = new(10, enabled: true, tw, windowWidth: 80); + // Constructor render hit the throwing writer and didn't propagate + // Further increments should also not throw + IncrementTo(bar, 10); + } + + private sealed class ThrowingWriter : StringWriter + { + public override void Write(string? value) => throw new IOException("simulated"); + } + + // Dispose idempotency tests + + [Fact] + public void Dispose_writes_exactly_one_newline() + { + StringWriter sw = new(); + ProgressBar bar = new(10, enabled: true, sw, windowWidth: 80); + _ = sw.GetStringBuilder().Clear(); + bar.Dispose(); + bar.Dispose(); + bar.Dispose(); + // Only one newline despite three Dispose calls + Assert.Equal(sw.NewLine, sw.ToString()); + } + + // Full lifecycle test + + [Fact] + public void Full_lifecycle_renders_progress_to_completion() + { + StringWriter sw = new(); + using ProgressBar bar = new(5, enabled: true, sw, windowWidth: 120); + for (int i = 0; i < 5; i++) + { + bar.ResetThrottle(); + bar.Increment($"step_{i}"); + } + string output = sw.ToString(); + Assert.Contains("(5/5)", output); + Assert.Equal(5, bar.Current); + } + + // Edge case tests + + [Fact] + public void Increment_with_empty_file_name_does_not_display_file() + { + StringWriter sw = new(); + using ProgressBar bar = new(10, enabled: true, sw, windowWidth: 120); + _ = sw.GetStringBuilder().Clear(); + IncrementTo(bar, 10, ""); + string output = sw.ToString(); + Assert.Contains("(10/10)", output); + // Empty file name should not add extra content between stats and padding + Assert.DoesNotContain("...", output); + } + + [Fact] + public void Render_at_narrow_window_truncates_to_fit() + { + StringWriter sw = new(); + using ProgressBar bar = new(10, enabled: true, sw, windowWidth: 30); + _ = sw.GetStringBuilder().Clear(); + IncrementTo(bar, 10, "some/path/to/file.ytd"); + string output = sw.ToString(); + Assert.NotEmpty(output); + // Output must be clamped to windowWidth - 1 to prevent wrapping + Assert.True(output.Length <= 29, $"Output ({output.Length} chars) should not exceed window width - 1 (29)"); + } +} diff --git a/CodeWalker.Cli/Tests/Helpers/SizeFormatTests.cs b/CodeWalker.Cli/Tests/Helpers/SizeFormatTests.cs new file mode 100644 index 000000000..3e36e328d --- /dev/null +++ b/CodeWalker.Cli/Tests/Helpers/SizeFormatTests.cs @@ -0,0 +1,177 @@ +using System; +using System.Globalization; +using System.Threading; + +using CodeWalker.Cli.Helpers; + +using Xunit; + +namespace CodeWalker.Cli.Tests.Helpers; + +public sealed class SizeFormatTests +{ + [Theory] +#pragma warning disable format + // Zero bytes + [InlineData("0 B", 0)] + // Positive edge boundaries + [InlineData("999 B", 999)] + [InlineData("999 KB", 999_000)] + [InlineData("999 MB", 999_000_000)] + [InlineData("999 GB", 999_000_000_000)] + [InlineData("999 TB", 999_000_000_000_000)] + [InlineData("999 PB", 999_000_000_000_000_000)] + [InlineData("999.99 PB", 999_990_000_000_000_000)] + // Negative edge boundaries + [InlineData("-999 B", -999)] + [InlineData("-999 KB", -999_000)] + [InlineData("-999 MB", -999_000_000)] + [InlineData("-999 GB", -999_000_000_000)] + [InlineData("-999 TB", -999_000_000_000_000)] + [InlineData("-999 PB", -999_000_000_000_000_000)] + [InlineData("-999.99 PB", -999_990_000_000_000_000)] + // Positive exact boundaries + [InlineData("1 B", 1)] + [InlineData("1 KB", 1_000)] + [InlineData("1 MB", 1_000_000)] + [InlineData("1 GB", 1_000_000_000)] + [InlineData("1 TB", 1_000_000_000_000)] + [InlineData("1 PB", 1_000_000_000_000_000)] + [InlineData("1000 PB", 1_000_000_000_000_000_000)] + // Negative exact boundaries + [InlineData("-1 B", -1)] + [InlineData("-1 KB", -1_000)] + [InlineData("-1 MB", -1_000_000)] + [InlineData("-1 GB", -1_000_000_000)] + [InlineData("-1 TB", -1_000_000_000_000)] + [InlineData("-1 PB", -1_000_000_000_000_000)] + [InlineData("-1000 PB", -1_000_000_000_000_000_000)] + // Positive fractional values + [InlineData("1.5 KB", 1_500)] + [InlineData("1.5 MB", 1_500_000)] + [InlineData("1.5 GB", 1_500_000_000)] + [InlineData("1.5 TB", 1_500_000_000_000)] + [InlineData("1.5 PB", 1_500_000_000_000_000)] + [InlineData("1500 PB", 1_500_000_000_000_000_000)] + // Negative fractional values + [InlineData("-1.5 KB", -1_500)] + [InlineData("-1.5 MB", -1_500_000)] + [InlineData("-1.5 GB", -1_500_000_000)] + [InlineData("-1.5 TB", -1_500_000_000_000)] + [InlineData("-1.5 PB", -1_500_000_000_000_000)] + [InlineData("-1500 PB", -1_500_000_000_000_000_000)] + // Extremes + [InlineData("9223.37 PB", long.MaxValue)] + [InlineData("-9223.37 PB", long.MinValue)] + // Small non-boundary values + [InlineData("42 B", 42)] + [InlineData("500 B", 500)] + // Rounding (display rounds up to next whole unit) + [InlineData("2 KB", 1_999)] + [InlineData("1000 KB", 999_999)] +#pragma warning restore format + public void SI_ToFormattedString_ReturnsExpectedResults(string expected, long bytes) + { + string result = SizeFormat.SI.ToFormattedString(bytes); + Assert.Equal(expected, result); + } + + [Theory] +#pragma warning disable format + // Zero bytes + [InlineData("0 B", 0)] + // Positive edge boundaries + [InlineData("1023 B", 1023L)] + [InlineData("1023 KiB", 1023L * (1L << 10))] + [InlineData("1023 MiB", 1023L * (1L << 20))] + [InlineData("1023 GiB", 1023L * (1L << 30))] + [InlineData("1023 TiB", 1023L * (1L << 40))] + [InlineData("1023 PiB", 1023L * (1L << 50))] + [InlineData("1023.99 PiB", (long)(1023.99 * (1L << 50)))] + // Negative edge boundaries + [InlineData("-1023 B", -1023L)] + [InlineData("-1023 KiB", -1023L * (1L << 10))] + [InlineData("-1023 MiB", -1023L * (1L << 20))] + [InlineData("-1023 GiB", -1023L * (1L << 30))] + [InlineData("-1023 TiB", -1023L * (1L << 40))] + [InlineData("-1023 PiB", -1023L * (1L << 50))] + [InlineData("-1023.99 PiB", (long)(-1023.99 * (1L << 50)))] + // Positive exact boundaries + [InlineData("1 B", 1L)] + [InlineData("1 KiB", 1L << 10)] + [InlineData("1 MiB", 1L << 20)] + [InlineData("1 GiB", 1L << 30)] + [InlineData("1 TiB", 1L << 40)] + [InlineData("1 PiB", 1L << 50)] + [InlineData("1024 PiB", 1L << 60)] + // Negative exact boundaries + [InlineData("-1 B", -1L)] + [InlineData("-1 KiB", -1L << 10)] + [InlineData("-1 MiB", -1L << 20)] + [InlineData("-1 GiB", -1L << 30)] + [InlineData("-1 TiB", -1L << 40)] + [InlineData("-1 PiB", -1L << 50)] + [InlineData("-1024 PiB", -1L << 60)] + // Positive fractional values + [InlineData("1.5 KiB", 1536L)] + [InlineData("1.5 MiB", 1536L * (1L << 10))] + [InlineData("1.5 GiB", 1536L * (1L << 20))] + [InlineData("1.5 TiB", 1536L * (1L << 30))] + [InlineData("1.5 PiB", 1536L * (1L << 40))] + [InlineData("1536 PiB", 1536L * (1L << 50))] + // Negative fractional values + [InlineData("-1.5 KiB", -1536L)] + [InlineData("-1.5 MiB", -1536L * (1L << 10))] + [InlineData("-1.5 GiB", -1536L * (1L << 20))] + [InlineData("-1.5 TiB", -1536L * (1L << 30))] + [InlineData("-1.5 PiB", -1536L * (1L << 40))] + [InlineData("-1536 PiB", -1536L * (1L << 50))] + // Extremes + [InlineData("8192 PiB", long.MaxValue)] + [InlineData("-8192 PiB", long.MinValue)] + // Small non-boundary values + [InlineData("42 B", 42)] + [InlineData("500 B", 500)] + // Rounding (display rounds up to next whole unit) + [InlineData("2 KiB", (1L << 10) + 1023)] + [InlineData("1024 KiB", (1L << 20) - 1)] +#pragma warning restore format + public void IEC_ToFormattedString_ReturnsExpectedResults(string expected, long bytes) + { + string result = SizeFormat.IEC.ToFormattedString(bytes); + Assert.Equal(expected, result); + } + + [Fact] + public void InvalidFormat_Throws() + { + const int range = 42; // Arbitrary range to test values around the defined enum members + for (int i = -range; i <= range; i++) + { + if (Enum.IsDefined(typeof(SizeFormat), i)) + continue; + + SizeFormat invalid = (SizeFormat)i; + _ = Assert.Throws(() => + invalid.ToFormattedString(1337)); + } + } + + [Theory] + [InlineData("de-DE")] + [InlineData("fr-FR")] + [InlineData("tr-TR")] + public void ToFormattedString_IsInvariantOfCurrentCulture(string culture) + { + // These strings also travel inside --json as the *Formatted fields, so a comma-decimal + // machine must not produce different output from a dot-decimal one. + CultureInfo previous = Thread.CurrentThread.CurrentCulture; + try + { + Thread.CurrentThread.CurrentCulture = new CultureInfo(culture); + Assert.Equal("1.5 KiB", SizeFormat.IEC.ToFormattedString(1536)); + Assert.Equal("1.5 KB", SizeFormat.SI.ToFormattedString(1500)); + } + finally { Thread.CurrentThread.CurrentCulture = previous; } + } +} diff --git a/CodeWalker.Cli/Tests/PolyfillsTests.cs b/CodeWalker.Cli/Tests/PolyfillsTests.cs new file mode 100644 index 000000000..eb33d1619 --- /dev/null +++ b/CodeWalker.Cli/Tests/PolyfillsTests.cs @@ -0,0 +1,210 @@ +using System; + +using Xunit; + +namespace CodeWalker.Cli.Tests; + +/// +/// Fuzz-tests every polyfill in against the built-in +/// .NET implementation. Each method is called with a large combinatorial corpus of +/// hand-picked edge cases plus deterministic random strings, and the result is compared +/// against the equivalent built-in method or string-based overload. +/// +public sealed class StringExtensionsFuzzTests +{ + private static readonly StringComparison[] AllComparisons = + [ + StringComparison.CurrentCulture, + StringComparison.CurrentCultureIgnoreCase, + StringComparison.InvariantCulture, + StringComparison.InvariantCultureIgnoreCase, + StringComparison.Ordinal, + StringComparison.OrdinalIgnoreCase, + ]; + + private static readonly string[] Corpus = BuildCorpus(); + + private static string[] BuildCorpus() + { + string[] handPicked = + [ + "", " ", "a", "A", "ab", "AB", "abc", "ABC", + "hello", "HELLO", "Hello", "Hello World", "hello world", + " spaces ", "tab\there", "new\nline", + "straße", "STRASSE", "Straße", "café", "CAFÉ", + "résumé", "naïve", "日本語", + "a\u00ADb", "a\u00ADbcd", "xa\u00ADbc", "a\0b", "he\u00ADllo", + "abc123!@#", "path/to/file.txt", @"C:\Windows\System32", + "\0null\0", "🎮🎲🎯", new string('x', 200), + "aaa", "aaA", "AaA", + ]; + + // 100 deterministic random strings for breadth + Random rng = new(42); + const string alphabet = "aAbBcC xXyYzZ\t\n\0éß"; + string[] random = new string[100]; + for (int i = 0; i < random.Length; i++) + { + char[] buf = new char[rng.Next(0, 30)]; + for (int j = 0; j < buf.Length; j++) + buf[j] = alphabet[rng.Next(alphabet.Length)]; + random[i] = new string(buf); + } + + string[] result = new string[handPicked.Length + random.Length]; + handPicked.CopyTo(result, 0); + random.CopyTo(result, handPicked.Length); + return result; + } + + private static readonly char[] Chars = + [ + 'a', 'A', 'z', 'Z', ' ', '\t', '\n', '\0', + '/', '\\', '.', '!', 'é', 'ß', 'ñ', '日', 'x', 'X', + ]; + + private static readonly string[] SearchStrings = + [ + "a", "A", "hello", "HELLO", "llo", "World", "world", + "ab", "abc", "straße", "STRASSE", "ß", "SS", "café", "xyz", " ", + "/", "\\", "\0", "🎮", "xx", + ]; + + // Contains(string, StringComparison) + // Polyfill wraps IndexOf; built-in is the native implementation. + + [Fact] + public void Contains_String_Comparison_MatchesBuiltIn() + { + foreach (string s in Corpus) + foreach (string sub in SearchStrings) + foreach (StringComparison cmp in AllComparisons) + AssertBool( + s.Contains(sub, cmp), + StringExtensions.Contains(s, sub, cmp), + $"Contains(\"{Esc(s)}\", \"{Esc(sub)}\", {cmp})"); + } + + // Contains(char) + // Built-in string.Contains(char) exists on .NET 5+. + + [Fact] + public void Contains_Char_MatchesBuiltIn() + { + foreach (string s in Corpus) + foreach (char c in Chars) + AssertBool( + s.Contains(c), + StringExtensions.Contains(s, c), + $"Contains(\"{Esc(s)}\", '{c}')"); + } + + // Contains(char, StringComparison) + // No built-in char overload; verify against string-based Contains. + + [Fact] + public void Contains_Char_Comparison_MatchesStringOverload() + { + foreach (string s in Corpus) + foreach (char c in Chars) + foreach (StringComparison cmp in AllComparisons) + AssertBool( + s.Contains(c.ToString(), cmp), + StringExtensions.Contains(s, c, cmp), + $"Contains(\"{Esc(s)}\", '{c}', {cmp})"); + } + + // StartsWith(char) + // Verify against string-based StartsWith with Ordinal comparison. + + [Fact] + public void StartsWith_Char_MatchesStringOverload() + { + foreach (string s in Corpus) + foreach (char c in Chars) + AssertBool( + s.StartsWith(c.ToString(), StringComparison.Ordinal), + StringExtensions.StartsWith(s, c), + $"StartsWith(\"{Esc(s)}\", '{c}')"); + } + + // EndsWith(char) + // Verify against string-based EndsWith with Ordinal comparison. + + [Fact] + public void EndsWith_Char_MatchesStringOverload() + { + foreach (string s in Corpus) + foreach (char c in Chars) + AssertBool( + s.EndsWith(c.ToString(), StringComparison.Ordinal), + StringExtensions.EndsWith(s, c), + $"EndsWith(\"{Esc(s)}\", '{c}')"); + } + + // Replace(string, string?, StringComparison) + // Ordinal path delegates to built-in; non-Ordinal uses ReplaceCore. + + [Fact] + public void Replace_MatchesBuiltIn() + { + string[] oldValues = ["a", "A", "hello", "HELLO", "llo", "straße", "SS", "ß", " ", "xx"]; + string?[] newValues = [null, "", "X", "YY", "replaced"]; + + foreach (string s in Corpus) + foreach (string old in oldValues) + foreach (string? @new in newValues) + foreach (StringComparison cmp in AllComparisons) + AssertString( + s.Replace(old, @new, cmp), + StringExtensions.Replace(s, old, @new, cmp), + $"Replace(\"{Esc(s)}\", \"{Esc(old)}\", \"{Esc(@new)}\", {cmp})"); + } + + // Helpers + + private static void AssertBool(bool expected, bool actual, string label) => + Assert.True(expected == actual, $"{label}: expected={expected} actual={actual}"); + + private static void AssertString(string expected, string actual, string label) => + Assert.True(string.Equals(expected, actual, StringComparison.Ordinal), + $"{label}: expected=\"{Esc(expected)}\" actual=\"{Esc(actual)}\""); + + private static string Esc(string? s) => + s?.Replace("\0", "\\0", StringComparison.Ordinal) + .Replace("\n", "\\n", StringComparison.Ordinal) + .Replace("\r", "\\r", StringComparison.Ordinal) + .Replace("\t", "\\t", StringComparison.Ordinal) + .Replace("\u00AD", "\\u00AD", StringComparison.Ordinal) ?? "(null)"; +} + +public sealed class StringExtensionsUnitTests +{ + [Fact] + public void Replace_NullOldValue_Throws() + { + _ = Assert.Throws(() => + StringExtensions.Replace("input", null!, "new", StringComparison.Ordinal)); + } + + [Fact] + public void Replace_EmptyOldValue_Throws() + { + _ = Assert.Throws(() => + StringExtensions.Replace("input", "", "new", StringComparison.Ordinal)); + } + + [Fact] + public void Replace_UnsupportedComparison_Throws() + { + _ = Assert.Throws(() => + StringExtensions.Replace("input", "in", "new", (StringComparison)999)); + } + + [Fact] + public void Replace_NullNewValue_DoesNotThrow() + { + string result = StringExtensions.Replace("input", "in", null, StringComparison.Ordinal); + Assert.Equal("input".Replace("in", null), result); + } +} diff --git a/CodeWalker.Cli/Tests/RpfHelperTests.cs b/CodeWalker.Cli/Tests/RpfHelperTests.cs new file mode 100644 index 000000000..a9c65c7a4 --- /dev/null +++ b/CodeWalker.Cli/Tests/RpfHelperTests.cs @@ -0,0 +1,465 @@ +using System; +using System.Collections.Generic; +using System.IO; + +using CodeWalker.Cli.Helpers; +using CodeWalker.GameFiles; + +using Xunit; + +namespace CodeWalker.Cli.Tests; + +[Collection("ConsoleOutput")] +public sealed class RpfHelperTests +{ + private static string CreateTempDir() + { + string dir = Path.Combine(Path.GetTempPath(), "cw_test_" + Guid.NewGuid().ToString("N")); + _ = Directory.CreateDirectory(dir); + return dir; + } + + // --- ValidateExe --- + + [Fact] + public void ValidateExe_ReturnsNull_WhenExeExists() + { + string dir = CreateTempDir(); + try + { + File.WriteAllBytes(Path.Combine(dir, "GTA5.exe"), []); + Assert.Null(RpfHelper.ValidateExe(dir, gen9: false)); + } + finally { Directory.Delete(dir, true); } + } + + [Fact] + public void ValidateExe_ReturnsError_WhenExeMissing() + { + string dir = CreateTempDir(); + try + { + string? error = RpfHelper.ValidateExe(dir, gen9: false); + Assert.NotNull(error); + Assert.Contains("GTA5.exe", error); + } + finally { Directory.Delete(dir, true); } + } + + [Fact] + public void ValidateExe_Gen9_ReturnsNull_WhenEnhancedExeExists() + { + string dir = CreateTempDir(); + try + { + File.WriteAllBytes(Path.Combine(dir, "GTA5_Enhanced.exe"), []); + Assert.Null(RpfHelper.ValidateExe(dir, gen9: true)); + } + finally { Directory.Delete(dir, true); } + } + + [Fact] + public void ValidateExe_Gen9_ReturnsError_WhenEnhancedExeMissing() + { + string dir = CreateTempDir(); + try + { + string? error = RpfHelper.ValidateExe(dir, gen9: true); + Assert.NotNull(error); + Assert.Contains("GTA5_Enhanced.exe", error); + } + finally { Directory.Delete(dir, true); } + } + + // --- ValidateInputs --- + + [Fact] + public void ValidateInputs_ReturnsNull_WhenBothExist() + { + string dir = CreateTempDir(); + try + { + string rpf = Path.Combine(dir, "test.rpf"); + File.WriteAllBytes(rpf, []); + File.WriteAllBytes(Path.Combine(dir, "GTA5.exe"), []); + Assert.Null(RpfHelper.ValidateInputs(rpf, dir, gen9: false)); + } + finally { Directory.Delete(dir, true); } + } + + [Fact] + public void ValidateInputs_ReturnsError_WhenRpfMissing() + { + string? error = RpfHelper.ValidateInputs("/nonexistent/test.rpf", "/tmp", gen9: false); + Assert.NotNull(error); + Assert.Contains("RPF file not found", error); + } + + [Fact] + public void ValidateInputs_ReturnsError_WhenExeMissing() + { + string dir = CreateTempDir(); + try + { + string rpf = Path.Combine(dir, "test.rpf"); + File.WriteAllBytes(rpf, []); + string? error = RpfHelper.ValidateInputs(rpf, dir, gen9: false); + Assert.NotNull(error); + Assert.Contains("GTA5.exe", error); + } + finally { Directory.Delete(dir, true); } + } + + // --- LoadKeys --- + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void LoadKeys_SelectsResourceLayout_BeforeReadingKeys(bool gen9) + { + bool previous = RpfManager.IsGen9; + string dir = CreateTempDir(); + try + { + RpfManager.IsGen9 = !gen9; + // No game executable here, so key loading throws. The layout flag is still set, + // because every resource reader consults it and it must not lag behind the keys. + _ = Assert.Throws(() => RpfHelper.LoadKeys(dir, gen9)); + Assert.Equal(gen9, RpfManager.IsGen9); + } + finally + { + RpfManager.IsGen9 = previous; + Directory.Delete(dir, true); + } + } + + // --- ValidateExeAndLoadKeys / ValidateAndLoadKeys early-return --- + + [Fact] + public void ValidateExeAndLoadKeys_ReturnsError_WhenExeMissing() + { + string dir = CreateTempDir(); + try + { + string? error = RpfHelper.ValidateExeAndLoadKeys(dir, gen9: false, json: true); + Assert.NotNull(error); + Assert.Contains("GTA5.exe", error); + } + finally { Directory.Delete(dir, true); } + } + + [Fact] + public void ValidateAndLoadKeys_ReturnsError_WhenRpfMissing() + { + string? error = RpfHelper.ValidateAndLoadKeys( + "/nonexistent.rpf", "/tmp", gen9: false, json: true + ); + Assert.NotNull(error); + Assert.Contains("RPF file not found", error); + } + + // --- GetFileType --- + + [Fact] + public void GetFileType_Resource() => + Assert.Equal("resource", RpfHelper.GetFileType(new RpfResourceFileEntry())); + + [Fact] + public void GetFileType_Binary() => + Assert.Equal("binary", RpfHelper.GetFileType(new RpfBinaryFileEntry())); + + private sealed class StubFileEntry : RpfFileEntry + { + public override long GetFileSize() => 0; + public override void SetFileSize(uint s) { } + public override void Read(DataReader reader) { } + public override void Write(DataWriter writer) { } + } + + [Fact] + public void GetFileType_Unknown() => + Assert.Equal("unknown", RpfHelper.GetFileType(new StubFileEntry())); + + // --- CollectFiles --- + + private static RpfBinaryFileEntry MakeEntry(string name, string? path = null) => + new() { Name = name, NameLower = name.ToLowerInvariant(), Path = path ?? name }; + + [Fact] + public void CollectFiles_NullEntries_ReturnsEmpty() + { + RpfFile rpf = new("test", "test.rpf", 0) { AllEntries = null }; + Assert.Empty(RpfHelper.CollectFiles(rpf, null, recursive: false)); + } + + [Fact] + public void CollectFiles_ReturnsFileEntries() + { + RpfBinaryFileEntry entry = MakeEntry("test.ydr"); + RpfFile rpf = new("test", "test.rpf", 0) { AllEntries = [entry] }; + List<(RpfFile rpf, RpfFileEntry entry)> files = + RpfHelper.CollectFiles(rpf, null, recursive: false); + _ = Assert.Single(files); + Assert.Same(entry, files[0].entry); + } + + [Fact] + public void CollectFiles_SkipsRpfEntries_WhenRecursive() + { + RpfBinaryFileEntry rpfEntry = MakeEntry("nested.rpf"); + RpfBinaryFileEntry fileEntry = MakeEntry("test.ydr"); + RpfFile rpf = new("test", "test.rpf", 0) { AllEntries = [rpfEntry, fileEntry] }; + List<(RpfFile rpf, RpfFileEntry entry)> files = + RpfHelper.CollectFiles(rpf, null, recursive: true); + _ = Assert.Single(files); + Assert.Equal("test.ydr", files[0].entry.Name); + } + + [Fact] + public void CollectFiles_IncludesRpfEntries_WhenNotRecursive() + { + RpfBinaryFileEntry rpfEntry = MakeEntry("nested.rpf"); + RpfBinaryFileEntry fileEntry = MakeEntry("test.ydr"); + RpfFile rpf = new("test", "test.rpf", 0) { AllEntries = [rpfEntry, fileEntry] }; + List<(RpfFile rpf, RpfFileEntry entry)> files = + RpfHelper.CollectFiles(rpf, null, recursive: false); + Assert.Equal(2, files.Count); + Assert.Contains(files, f => f.entry.Name == "nested.rpf"); + Assert.Contains(files, f => f.entry.Name == "test.ydr"); + } + + [Fact] + public void CollectFiles_SkipsDirectoryEntries() + { + RpfDirectoryEntry dirEntry = new() { Name = "subdir", NameLower = "subdir", Path = "subdir" }; + RpfBinaryFileEntry fileEntry = MakeEntry("test.ydr"); + RpfFile rpf = new("test", "test.rpf", 0) + { + AllEntries = [dirEntry, fileEntry], + }; + List<(RpfFile rpf, RpfFileEntry entry)> files = + RpfHelper.CollectFiles(rpf, null, recursive: false); + _ = Assert.Single(files); + } + + [Fact] + public void CollectFiles_AppliesFilter() + { + RpfBinaryFileEntry e1 = MakeEntry("test.ydr"); + RpfBinaryFileEntry e2 = MakeEntry("test.ytd"); + RpfFile rpf = new("test", "test.rpf", 0) { AllEntries = [e1, e2] }; + List<(RpfFile rpf, RpfFileEntry entry)> files = + RpfHelper.CollectFiles(rpf, ["*.ydr"], recursive: false); + _ = Assert.Single(files); + Assert.Equal("test.ydr", files[0].entry.Name); + } + + [Fact] + public void CollectFiles_MultipleFilters() + { + RpfBinaryFileEntry e1 = MakeEntry("a.ydr"); + RpfBinaryFileEntry e2 = MakeEntry("b.ytd"); + RpfBinaryFileEntry e3 = MakeEntry("c.yft"); + RpfFile rpf = new("test", "test.rpf", 0) { AllEntries = [e1, e2, e3] }; + List<(RpfFile rpf, RpfFileEntry entry)> files = + RpfHelper.CollectFiles(rpf, ["*.ydr", "*.ytd"], recursive: false); + Assert.Equal(2, files.Count); + } + + [Fact] + public void CollectFiles_Recursive_WithFilter() + { + RpfBinaryFileEntry parentYdr = MakeEntry("a.ydr"); + RpfBinaryFileEntry parentYtd = MakeEntry("b.ytd"); + RpfBinaryFileEntry childYdr = MakeEntry("c.ydr"); + RpfBinaryFileEntry childYtd = MakeEntry("d.ytd"); + RpfFile child = new("child", "child.rpf", 0) { AllEntries = [childYdr, childYtd] }; + RpfFile parent = new("parent", "parent.rpf", 0) + { + AllEntries = [parentYdr, parentYtd], + Children = [child], + }; + List<(RpfFile rpf, RpfFileEntry entry)> files = + RpfHelper.CollectFiles(parent, ["*.ydr"], recursive: true); + Assert.Equal(2, files.Count); + Assert.All(files, f => Assert.EndsWith(".ydr", f.entry.Name)); + } + + [Fact] + public void CollectFiles_Recursive_IncludesChildren() + { + RpfBinaryFileEntry parentEntry = MakeEntry("a.ydr"); + RpfBinaryFileEntry childEntry = MakeEntry("b.ydr"); + RpfFile child = new("child", "child.rpf", 0) { AllEntries = [childEntry] }; + RpfFile parent = new("parent", "parent.rpf", 0) + { + AllEntries = [parentEntry], + Children = [child], + }; + Assert.Equal(2, RpfHelper.CollectFiles(parent, null, recursive: true).Count); + } + + [Fact] + public void CollectFiles_NonRecursive_ExcludesChildren() + { + RpfBinaryFileEntry parentEntry = MakeEntry("a.ydr"); + RpfBinaryFileEntry childEntry = MakeEntry("b.ydr"); + RpfFile child = new("child", "child.rpf", 0) { AllEntries = [childEntry] }; + RpfFile parent = new("parent", "parent.rpf", 0) + { + AllEntries = [parentEntry], + Children = [child], + }; + List<(RpfFile rpf, RpfFileEntry entry)> files = + RpfHelper.CollectFiles(parent, null, recursive: false); + _ = Assert.Single(files); + Assert.Equal("a.ydr", files[0].entry.Name); + } + + [Fact] + public void CollectFiles_Recursive_ReturnsCorrectRpfRef() + { + RpfBinaryFileEntry childEntry = MakeEntry("b.ydr"); + RpfFile child = new("child", "child.rpf", 0) { AllEntries = [childEntry] }; + RpfFile parent = new("parent", "parent.rpf", 0) + { + AllEntries = [], + Children = [child], + }; + List<(RpfFile rpf, RpfFileEntry entry)> files = + RpfHelper.CollectFiles(parent, null, recursive: true); + _ = Assert.Single(files); + Assert.Same(child, files[0].rpf); + } + + // --- ReportError --- + + private static Json.ExportResult MakeBaseResult(string[]? errors = null) => + new() + { + Success = true, + RpfFile = "test.rpf", + OutputDir = "/tmp", + Format = "xml", + TotalFiles = 0, + Exported = 0, + Skipped = 0, + Errors = 0, + DryRun = false, + Files = [], + ErrorMessages = errors ?? [], + }; + + [Fact] + public void ReportError_ReturnsOne() + { + TextWriter origOut = Console.Out; + TextWriter origErr = Console.Error; + try + { + Console.SetOut(new StringWriter()); + Console.SetError(new StringWriter()); + Assert.Equal(1, Output.ReportError("err", json: false, MakeBaseResult())); + } + finally + { + Console.SetOut(origOut); + Console.SetError(origErr); + } + } + + [Fact] + public void ReportError_Json_WritesToStdout() + { + TextWriter origOut = Console.Out; + TextWriter origErr = Console.Error; + try + { + StringWriter sw = new(); + Console.SetOut(sw); + Console.SetError(new StringWriter()); + _ = Output.ReportError("test error", json: true, MakeBaseResult()); + string output = sw.ToString(); + Assert.Contains("\"success\": false", output); + Assert.Contains("test error", output); + } + finally + { + Console.SetOut(origOut); + Console.SetError(origErr); + } + } + + [Fact] + public void ReportError_Json_PreservesExistingErrors() + { + TextWriter origOut = Console.Out; + TextWriter origErr = Console.Error; + try + { + StringWriter sw = new(); + Console.SetOut(sw); + Console.SetError(new StringWriter()); + _ = Output.ReportError("new error", json: true, MakeBaseResult(["old error"])); + string output = sw.ToString(); + Assert.Contains("old error", output); + Assert.Contains("new error", output); + } + finally + { + Console.SetOut(origOut); + Console.SetError(origErr); + } + } + + [Fact] + public void ReportError_Text_WritesToStderr() + { + TextWriter origOut = Console.Out; + TextWriter origErr = Console.Error; + try + { + StringWriter stdout = new(); + StringWriter stderr = new(); + Console.SetOut(stdout); + Console.SetError(stderr); + _ = Output.ReportError("test error", json: false, MakeBaseResult()); + Assert.Contains("Error: test error", stderr.ToString()); + Assert.Equal("", stdout.ToString()); + } + finally + { + Console.SetOut(origOut); + Console.SetError(origErr); + } + } + + [Fact] + public void ReportError_Text_IncludesStackTrace() + { + TextWriter origErr = Console.Error; + try + { + StringWriter stderr = new(); + Console.SetError(stderr); + _ = Output.ReportError("err", json: false, MakeBaseResult(), "at Foo.Bar()"); + Assert.Contains("at Foo.Bar()", stderr.ToString()); + } + finally { Console.SetError(origErr); } + } + + [Fact] + public void ReportError_Text_OmitsStackTrace_WhenNull() + { + TextWriter origErr = Console.Error; + try + { + StringWriter stderr = new(); + Console.SetError(stderr); + _ = Output.ReportError("err", json: false, MakeBaseResult()); + Assert.DoesNotContain("at ", stderr.ToString()); + } + finally { Console.SetError(origErr); } + } +} diff --git a/CodeWalker.Core/GameFiles/Resources/ResourceData.cs b/CodeWalker.Core/GameFiles/Resources/ResourceData.cs index c734cc603..f0ea2fa7e 100644 --- a/CodeWalker.Core/GameFiles/Resources/ResourceData.cs +++ b/CodeWalker.Core/GameFiles/Resources/ResourceData.cs @@ -105,6 +105,16 @@ public ResourceDataReader(RpfResourceFileEntry resentry, byte[] data, Endianess // } //} + if ((data == null) || (((long)systemSize + graphicsSize) > data.Length)) + { + //the entry's page flags describe more data than was extracted, which happens when + //the resource couldn't be decompressed. the MemoryStream below would throw anyway, + //but without saying which file it was or what was wrong with it. + throw new InvalidDataException(string.Format( + "Resource data for {0} is {1} bytes, but its page flags require {2} (system {3} + graphics {4}).", + resentry?.Name ?? "(unknown)", data?.Length ?? 0, (long)systemSize + graphicsSize, systemSize, graphicsSize)); + } + this.systemStream = new MemoryStream(data, 0, systemSize); this.graphicsStream = new MemoryStream(data, systemSize, graphicsSize); Position = 0x50000000; diff --git a/CodeWalker.Core/GameFiles/RpfFile.cs b/CodeWalker.Core/GameFiles/RpfFile.cs index bed44c61f..9251490cb 100644 --- a/CodeWalker.Core/GameFiles/RpfFile.cs +++ b/CodeWalker.Core/GameFiles/RpfFile.cs @@ -421,7 +421,7 @@ private void ExtractScripts(BinaryReader br, string outputfolder, Action updateStatus?.Invoke("Extracting " + resentry.Name + "..."); //found a YSC file. extract it! - string ofpath = outputfolder + "\\" + resentry.Name; + string ofpath = System.IO.Path.Combine(outputfolder, resentry.Name); br.BaseStream.Position = StartPos + ((long)resentry.FileOffset * 512); @@ -442,7 +442,7 @@ private void ExtractScripts(BinaryReader br, string outputfolder, Action decr = GTACrypto.DecryptAES(tbytes); //special case! probable duplicate pilot_school.ysc - ofpath = outputfolder + "\\" + Name + "___" + resentry.Name; + ofpath = System.IO.Path.Combine(outputfolder, Name + "___" + resentry.Name); } else { @@ -464,7 +464,7 @@ private void ExtractScripts(BinaryReader br, string outputfolder, Action bool pathok = true; if (File.Exists(ofpath)) { - ofpath = outputfolder + "\\" + Name + "_" + resentry.Name; + ofpath = System.IO.Path.Combine(outputfolder, Name + "_" + resentry.Name); if (File.Exists(ofpath)) { LastError = "Output file " + ofpath + " already exists!"; @@ -621,7 +621,9 @@ public byte[] ExtractFileResource(RpfResourceFileEntry entry, BinaryReader br) } else { - entry.FileSize -= offset; + //couldn't decompress it, so give back what's there. it is shorter than + //the entry's flags describe, and the entry is shared, so FileSize keeps + //describing what is on disk and the caller has to notice the shortfall. data = decr; } @@ -1508,9 +1510,7 @@ public static RpfFile CreateNew(string gtafolder, string relpath, RpfEncryption //create a new, empty RPF file in the filesystem //this will assume that the folder the file is going into already exists! - string fpath = gtafolder; - fpath = fpath.EndsWith("\\") ? fpath : fpath + "\\"; - fpath = relpath.Contains(":") ? relpath : fpath + relpath; + string fpath = System.IO.Path.IsPathRooted(relpath) ? relpath : System.IO.Path.Combine(gtafolder, relpath); if (File.Exists(fpath)) { diff --git a/CodeWalker.Core/GameFiles/Utils/GTAKeys.cs b/CodeWalker.Core/GameFiles/Utils/GTAKeys.cs index b8f8ad862..d73543d74 100644 --- a/CodeWalker.Core/GameFiles/Utils/GTAKeys.cs +++ b/CodeWalker.Core/GameFiles/Utils/GTAKeys.cs @@ -250,8 +250,8 @@ private static void UseMagicData(string path, bool gen9, string key) if (string.IsNullOrEmpty(key)) { - var exefile = gen9 ? "\\gta5_enhanced.exe" : "\\gta5.exe"; - byte[] exedata = File.ReadAllBytes(path + exefile); + var exefile = gen9 ? "GTA5_Enhanced.exe" : "GTA5.exe"; + byte[] exedata = File.ReadAllBytes(Path.Combine(path, exefile)); GenerateV2(exedata, null); } else diff --git a/CodeWalker.Core/Utils/Gen9Converter.cs b/CodeWalker.Core/Utils/Gen9Converter.cs index ddb705783..c80d97496 100644 --- a/CodeWalker.Core/Utils/Gen9Converter.cs +++ b/CodeWalker.Core/Utils/Gen9Converter.cs @@ -40,13 +40,14 @@ public void Convert() Error("Please select an output folder."); return; } - if (inputFolder.EndsWith("\\") == false) + var sep = Path.DirectorySeparatorChar.ToString(); + if (inputFolder.EndsWith(sep) == false) { - inputFolder = inputFolder + "\\"; + inputFolder = inputFolder + sep; } - if (outputFolder.EndsWith("\\") == false) + if (outputFolder.EndsWith(sep) == false) { - outputFolder = outputFolder + "\\"; + outputFolder = outputFolder + sep; } if (inputFolder.Equals(outputFolder, StringComparison.InvariantCultureIgnoreCase)) { diff --git a/CodeWalker.ModManager/SelectFolderForm.cs b/CodeWalker.ModManager/SelectFolderForm.cs index 656b3b7db..8be85d852 100644 --- a/CodeWalker.ModManager/SelectFolderForm.cs +++ b/CodeWalker.ModManager/SelectFolderForm.cs @@ -29,7 +29,7 @@ public SelectFolderForm(SettingsFile settings) public static bool IsGen9Folder(string folder) { - return File.Exists(folder + @"\gta5_enhanced.exe"); + return File.Exists(Path.Combine(folder, "GTA5_Enhanced.exe")); } public static bool ValidateGTAFolder(string folder, bool gen9, out string failReason) @@ -50,7 +50,7 @@ public static bool ValidateGTAFolder(string folder, bool gen9, out string failRe if (gen9) { - if (!File.Exists(folder + @"\gta5_enhanced.exe")) + if (!File.Exists(Path.Combine(folder, "GTA5_Enhanced.exe"))) { failReason = $"GTA5_Enhanced.exe not found in folder \"{folder}\""; return false; @@ -58,7 +58,7 @@ public static bool ValidateGTAFolder(string folder, bool gen9, out string failRe } else { - if (!File.Exists(folder + @"\gta5.exe")) + if (!File.Exists(Path.Combine(folder, "GTA5.exe"))) { failReason = $"GTA5.exe not found in folder \"{folder}\""; return false; diff --git a/CodeWalker.ModManager/SettingsFile.cs b/CodeWalker.ModManager/SettingsFile.cs index 4c66495d4..67556356e 100644 --- a/CodeWalker.ModManager/SettingsFile.cs +++ b/CodeWalker.ModManager/SettingsFile.cs @@ -16,8 +16,8 @@ public class SettingsFile : SimpleKvpFile public string GameName => GameFolderOk ? IsGen9 ? "GTAV (Enhanced)" : "GTAV (Legacy)" : "(None selected)"; public string GameTitle => IsGen9 ? "GTAV Enhanced" : "GTAV Legacy"; - public string GameExeName => IsGen9 ? "gta5_enhanced.exe" : "gta5.exe"; - public string GameExePath => $"{GameFolder}\\{GameExeName}"; + public string GameExeName => IsGen9 ? "GTA5_Enhanced.exe" : "GTA5.exe"; + public string GameExePath => Path.Combine(GameFolder, GameExeName); public string GameModCache => IsGen9 ? "GTAVEnhanced" : "GTAVLegacy"; public bool GameFolderOk { diff --git a/CodeWalker.sln b/CodeWalker.sln index 3e728f0e2..6cad1895c 100644 --- a/CodeWalker.sln +++ b/CodeWalker.sln @@ -27,6 +27,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CodeWalker.ModManager", "Co EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CodeWalker.Gen9Converter", "CodeWalker.Gen9Converter\CodeWalker.Gen9Converter.csproj", "{C099F538-B5F6-4AAF-B877-B0835DE4EFA6}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CodeWalker.Cli", "CodeWalker.Cli\CodeWalker.Cli.csproj", "{D8A7B876-5F4E-4E3D-9C5F-8A1B2C3D4E5F}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CodeWalker.Cli.Tests", "CodeWalker.Cli\CodeWalker.Cli.Tests.csproj", "{414B22C4-53F2-4F7D-841B-B1AA8461F213}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -156,10 +160,36 @@ Global {C099F538-B5F6-4AAF-B877-B0835DE4EFA6}.Release|x64.Build.0 = Release|Any CPU {C099F538-B5F6-4AAF-B877-B0835DE4EFA6}.Release|x86.ActiveCfg = Release|Any CPU {C099F538-B5F6-4AAF-B877-B0835DE4EFA6}.Release|x86.Build.0 = Release|Any CPU + {D8A7B876-5F4E-4E3D-9C5F-8A1B2C3D4E5F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D8A7B876-5F4E-4E3D-9C5F-8A1B2C3D4E5F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D8A7B876-5F4E-4E3D-9C5F-8A1B2C3D4E5F}.Debug|x64.ActiveCfg = Debug|Any CPU + {D8A7B876-5F4E-4E3D-9C5F-8A1B2C3D4E5F}.Debug|x64.Build.0 = Debug|Any CPU + {D8A7B876-5F4E-4E3D-9C5F-8A1B2C3D4E5F}.Debug|x86.ActiveCfg = Debug|Any CPU + {D8A7B876-5F4E-4E3D-9C5F-8A1B2C3D4E5F}.Debug|x86.Build.0 = Debug|Any CPU + {D8A7B876-5F4E-4E3D-9C5F-8A1B2C3D4E5F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D8A7B876-5F4E-4E3D-9C5F-8A1B2C3D4E5F}.Release|Any CPU.Build.0 = Release|Any CPU + {D8A7B876-5F4E-4E3D-9C5F-8A1B2C3D4E5F}.Release|x64.ActiveCfg = Release|Any CPU + {D8A7B876-5F4E-4E3D-9C5F-8A1B2C3D4E5F}.Release|x64.Build.0 = Release|Any CPU + {D8A7B876-5F4E-4E3D-9C5F-8A1B2C3D4E5F}.Release|x86.ActiveCfg = Release|Any CPU + {D8A7B876-5F4E-4E3D-9C5F-8A1B2C3D4E5F}.Release|x86.Build.0 = Release|Any CPU + {414B22C4-53F2-4F7D-841B-B1AA8461F213}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {414B22C4-53F2-4F7D-841B-B1AA8461F213}.Debug|Any CPU.Build.0 = Debug|Any CPU + {414B22C4-53F2-4F7D-841B-B1AA8461F213}.Debug|x64.ActiveCfg = Debug|Any CPU + {414B22C4-53F2-4F7D-841B-B1AA8461F213}.Debug|x64.Build.0 = Debug|Any CPU + {414B22C4-53F2-4F7D-841B-B1AA8461F213}.Debug|x86.ActiveCfg = Debug|Any CPU + {414B22C4-53F2-4F7D-841B-B1AA8461F213}.Debug|x86.Build.0 = Debug|Any CPU + {414B22C4-53F2-4F7D-841B-B1AA8461F213}.Release|Any CPU.ActiveCfg = Release|Any CPU + {414B22C4-53F2-4F7D-841B-B1AA8461F213}.Release|Any CPU.Build.0 = Release|Any CPU + {414B22C4-53F2-4F7D-841B-B1AA8461F213}.Release|x64.ActiveCfg = Release|Any CPU + {414B22C4-53F2-4F7D-841B-B1AA8461F213}.Release|x64.Build.0 = Release|Any CPU + {414B22C4-53F2-4F7D-841B-B1AA8461F213}.Release|x86.ActiveCfg = Release|Any CPU + {414B22C4-53F2-4F7D-841B-B1AA8461F213}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection + GlobalSection(NestedProjects) = preSolution + EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {5D6153C2-98D1-4C3D-9232-4C2BEEAEC8E0} EndGlobalSection diff --git a/CodeWalker/ExploreForm.cs b/CodeWalker/ExploreForm.cs index 593ec4e97..28db24652 100644 --- a/CodeWalker/ExploreForm.cs +++ b/CodeWalker/ExploreForm.cs @@ -195,7 +195,7 @@ private void Init() } catch { - UpdateStatus("Unable to load gta5.exe!"); + UpdateStatus("Unable to load GTA5.exe!"); return; } diff --git a/CodeWalker/Tools/ExtractKeysForm.cs b/CodeWalker/Tools/ExtractKeysForm.cs index 425d24d5e..187591dee 100644 --- a/CodeWalker/Tools/ExtractKeysForm.cs +++ b/CodeWalker/Tools/ExtractKeysForm.cs @@ -45,7 +45,7 @@ private void FolderBrowseButton_Click(object sender, EventArgs e) { GTAFolder.UpdateGTAFolder(false); FolderTextBox.Text = GTAFolder.CurrentGTAFolder; - ExeTextBox.Text = GTAFolder.CurrentGTAFolder + @"\GTA5.exe"; + ExeTextBox.Text = Path.Combine(GTAFolder.CurrentGTAFolder, "GTA5.exe"); } private void ExeBrowseButton_Click(object sender, EventArgs e) diff --git a/CodeWalker/Utils/GTAFolder.cs b/CodeWalker/Utils/GTAFolder.cs index f600993ec..b3a771648 100644 --- a/CodeWalker/Utils/GTAFolder.cs +++ b/CodeWalker/Utils/GTAFolder.cs @@ -19,7 +19,7 @@ public static class GTAFolder public static bool IsGen9Folder(string folder) { - return File.Exists(folder + @"\gta5_enhanced.exe"); + return File.Exists(Path.Combine(folder, "GTA5_Enhanced.exe")); } public static bool ValidateGTAFolder(string folder, bool gen9, out string failReason) @@ -40,7 +40,7 @@ public static bool ValidateGTAFolder(string folder, bool gen9, out string failRe if (gen9) { - if (!File.Exists(folder + @"\gta5_enhanced.exe")) + if (!File.Exists(Path.Combine(folder, "GTA5_Enhanced.exe"))) { failReason = $"GTA5_Enhanced.exe not found in folder \"{folder}\""; return false; @@ -48,7 +48,7 @@ public static bool ValidateGTAFolder(string folder, bool gen9, out string failRe } else { - if(!File.Exists(folder + @"\gta5.exe")) + if (!File.Exists(Path.Combine(folder, "GTA5.exe"))) { failReason = $"GTA5.exe not found in folder \"{folder}\""; return false; diff --git a/README.md b/README.md index 4580ae90f..eb51afc32 100644 --- a/README.md +++ b/README.md @@ -4,12 +4,18 @@ ## Requirements: -- PC version of GTA:V; +For the app: +- PC version of GTA:V, Legacy or Enhanced; - 4GB RAM (8+ recommended); - Windows 7 and above, x64 processor; -- .NET framework 4.5 or newer from [Microsoft](https://www.microsoft.com/net/download/thank-you/net471); +- .NET Framework 4.8 or newer from [Microsoft](https://dotnet.microsoft.com/download/dotnet-framework/net48); - DirectX 11 and Shader Model 4.0 capable graphics. +For the command line program: +- PC version of GTA:V, Legacy or Enhanced; +- Windows or Linux; +- .NET Framework 4.8, .NET 8 or .NET 10. + # App Usage: On first startup, the app will prompt to browse for the GTA:V game folder. If you have the Steam version installed in the default location `(C:\Program Files (x86)\Steam\SteamApps\common\Grand Theft Auto V)`, then this step will be skipped automatically. @@ -33,6 +39,9 @@ view is not needed, and the world loading can be avoided. To activate the menu m # Explorer Mode: The app can be started with the `'explorer'` command line argument. This displays an interface much like OpenIV, with a Windows-Explorer style interface for browsing the game's .rpf archives. Double-click on files to open them. Viewers for most file types are available, but hex view will be shown as a fallback. To activate the explorer mode, run the command: CodeWalker.exe explorer. Alternatively, run the CodeWalker Explorer batch file in the program's directory. +# Command Line: +CodeWalker.Cli is a separate console program for working with archives without the graphical interface. It can list, extract, search, pack, compare and validate `RPF` archives, export files to XML, DDS, WAV and text, convert files to enhanced (Gen9) format, and generate Jenkins hashes. Every command takes a `--json` option and writes a single object to standard output, for use from a script. Run `CodeWalker.Cli --help` for the list of commands, and `CodeWalker.Cli --help` for a command's options, its JSON fields and its exit codes. + # Main Toolbar: The main toolbar is used to access most of the editing features in CodeWalker. Shortcuts for new, open and create files are provided. The selection mode can be changed with the "pointer" button. Move, rotate and scale buttons provide access to the different editing widget modes. Other shortcuts on the toolbar include buttons to open the Selection Info window, and the Project window. See the tooltips on the toolbar items for hints.