├── .editorconfig ├── .gitattributes ├── .github ├── ISSUE_TEMPLATE │ ├── bug-report.yml │ ├── config.yml │ └── feature-request.yml ├── dependabot.yml └── workflows │ ├── build.yml │ ├── sk2-ci.yml │ └── winget.yml ├── .gitignore ├── DepotDownloader.sln ├── DepotDownloader ├── AccountSettingsStore.cs ├── Ansi.cs ├── AnsiDetector.cs ├── CDNClientPool.cs ├── ConsoleAuthenticator.cs ├── ContentDownloader.cs ├── DepotConfigStore.cs ├── DepotDownloader.csproj ├── DownloadConfig.cs ├── HttpClientFactory.cs ├── HttpDiagnosticEventListener.cs ├── NativeMethods.txt ├── PlatformUtilities.cs ├── Program.cs ├── ProtoManifest.cs ├── Steam3Session.cs └── Util.cs ├── Icon ├── DepotDownloader.ico ├── DepotDownloader.png └── DepotDownloader.svg ├── LICENSE ├── README.md └── global.json /.editorconfig: -------------------------------------------------------------------------------- 1 | # top-most EditorConfig file 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | charset = utf-8 7 | end_of_line = lf 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | # Code files 12 | [*.{cs, csx, vb, vbx}] 13 | indent_size = 4 14 | 15 | # Github yaml files 16 | [*.yml] 17 | indent_size = 2 18 | 19 | # XML project files 20 | [*.{csproj, vbproj, vcxproj, vcxproj.filters, proj, projitems, shproj}] 21 | indent_size = 2 22 | 23 | # XML config files 24 | [*.{props, targets, ruleset, config, nuspec, resx, vsixmanifest, vsct}] 25 | indent_size = 2 26 | 27 | # Dotnet code style settings: 28 | [*.{cs, vb}] 29 | 30 | # IDE0055: Fix formatting 31 | dotnet_diagnostic.IDE0055.severity = warning 32 | 33 | # Sort using and Import directives with System.* appearing first 34 | dotnet_sort_system_directives_first = true 35 | dotnet_separate_import_directive_groups = false 36 | # Avoid "this." and "Me." if not necessary 37 | dotnet_style_qualification_for_field = false:refactoring 38 | dotnet_style_qualification_for_property = false:refactoring 39 | dotnet_style_qualification_for_method = false:refactoring 40 | dotnet_style_qualification_for_event = false:refactoring 41 | 42 | # Use language keywords instead of framework type names for type references 43 | dotnet_style_predefined_type_for_locals_parameters_members = true:suggestion 44 | dotnet_style_predefined_type_for_member_access = true:suggestion 45 | 46 | # Suggest more modern language features when available 47 | dotnet_style_object_initializer = true:suggestion 48 | dotnet_style_collection_initializer = true:suggestion 49 | dotnet_style_coalesce_expression = true:suggestion 50 | dotnet_style_null_propagation = true:suggestion 51 | dotnet_style_explicit_tuple_names = true:suggestion 52 | 53 | # Non-private static fields are PascalCase 54 | dotnet_naming_rule.non_private_static_fields_should_be_pascal_case.severity = suggestion 55 | dotnet_naming_rule.non_private_static_fields_should_be_pascal_case.symbols = non_private_static_fields 56 | dotnet_naming_rule.non_private_static_fields_should_be_pascal_case.style = non_private_static_field_style 57 | 58 | dotnet_naming_symbols.non_private_static_fields.applicable_kinds = field 59 | dotnet_naming_symbols.non_private_static_fields.applicable_accessibilities = public, protected, internal, protected_internal, private_protected 60 | dotnet_naming_symbols.non_private_static_fields.required_modifiers = static 61 | 62 | dotnet_naming_style.non_private_static_field_style.capitalization = pascal_case 63 | 64 | # Non-private readonly fields are PascalCase 65 | dotnet_naming_rule.non_private_readonly_fields_should_be_pascal_case.severity = suggestion 66 | dotnet_naming_rule.non_private_readonly_fields_should_be_pascal_case.symbols = non_private_readonly_fields 67 | dotnet_naming_rule.non_private_readonly_fields_should_be_pascal_case.style = non_private_readonly_field_style 68 | 69 | dotnet_naming_symbols.non_private_readonly_fields.applicable_kinds = field 70 | dotnet_naming_symbols.non_private_readonly_fields.applicable_accessibilities = public, protected, internal, protected_internal, private_protected 71 | dotnet_naming_symbols.non_private_readonly_fields.required_modifiers = readonly 72 | 73 | dotnet_naming_style.non_private_readonly_field_style.capitalization = pascal_case 74 | 75 | # Constants are PascalCase 76 | dotnet_naming_rule.constants_should_be_pascal_case.severity = suggestion 77 | dotnet_naming_rule.constants_should_be_pascal_case.symbols = constants 78 | dotnet_naming_rule.constants_should_be_pascal_case.style = constant_style 79 | 80 | dotnet_naming_symbols.constants.applicable_kinds = field, local 81 | dotnet_naming_symbols.constants.required_modifiers = const 82 | 83 | dotnet_naming_style.constant_style.capitalization = pascal_case 84 | 85 | # Static readonly fields are PascalCase 86 | dotnet_naming_rule.static_fields_should_be_camel_case.severity = suggestion 87 | dotnet_naming_rule.static_fields_should_be_camel_case.symbols = static_fields 88 | dotnet_naming_rule.static_fields_should_be_camel_case.style = static_field_style 89 | 90 | dotnet_naming_symbols.static_fields.applicable_kinds = field 91 | dotnet_naming_symbols.static_fields.required_modifiers = static, readonly 92 | 93 | dotnet_naming_style.static_field_style.capitalization = pascal_case 94 | 95 | # Instance fields are camelCase and start with _ 96 | dotnet_naming_rule.instance_fields_should_be_camel_case.severity = suggestion 97 | dotnet_naming_rule.instance_fields_should_be_camel_case.symbols = instance_fields 98 | dotnet_naming_rule.instance_fields_should_be_camel_case.style = instance_field_style 99 | 100 | dotnet_naming_symbols.instance_fields.applicable_kinds = field 101 | 102 | dotnet_naming_style.instance_field_style.capitalization = camel_case 103 | 104 | # Locals and parameters are camelCase 105 | dotnet_naming_rule.locals_should_be_camel_case.severity = suggestion 106 | dotnet_naming_rule.locals_should_be_camel_case.symbols = locals_and_parameters 107 | dotnet_naming_rule.locals_should_be_camel_case.style = camel_case_style 108 | 109 | dotnet_naming_symbols.locals_and_parameters.applicable_kinds = parameter, local 110 | 111 | dotnet_naming_style.camel_case_style.capitalization = camel_case 112 | 113 | # Local functions are PascalCase 114 | dotnet_naming_rule.local_functions_should_be_pascal_case.severity = suggestion 115 | dotnet_naming_rule.local_functions_should_be_pascal_case.symbols = local_functions 116 | dotnet_naming_rule.local_functions_should_be_pascal_case.style = local_function_style 117 | 118 | dotnet_naming_symbols.local_functions.applicable_kinds = local_function 119 | 120 | dotnet_naming_style.local_function_style.capitalization = pascal_case 121 | 122 | # By default, name items with PascalCase 123 | dotnet_naming_rule.members_should_be_pascal_case.severity = suggestion 124 | dotnet_naming_rule.members_should_be_pascal_case.symbols = all_members 125 | dotnet_naming_rule.members_should_be_pascal_case.style = pascal_case_style 126 | 127 | dotnet_naming_symbols.all_members.applicable_kinds = * 128 | 129 | dotnet_naming_style.pascal_case_style.capitalization = pascal_case 130 | 131 | # error RS2008: Enable analyzer release tracking for the analyzer project containing rule '{0}' 132 | dotnet_diagnostic.RS2008.severity = none 133 | 134 | # IDE0007: Use `var` instead of explicit type 135 | dotnet_diagnostic.IDE0007.severity = warning 136 | 137 | # IDE0035: Remove unreachable code 138 | dotnet_diagnostic.IDE0035.severity = warning 139 | 140 | # IDE0036: Order modifiers 141 | dotnet_diagnostic.IDE0036.severity = warning 142 | 143 | # IDE0043: Format string contains invalid placeholder 144 | dotnet_diagnostic.IDE0043.severity = warning 145 | 146 | # IDE0044: Make field readonly 147 | dotnet_diagnostic.IDE0044.severity = warning 148 | 149 | # CSharp code style settings: 150 | [*.cs] 151 | 152 | # Require file header OR A source file contains a header that does not match the required text 153 | file_header_template = This file is subject to the terms and conditions defined\nin file 'LICENSE', which is part of this source code package. 154 | dotnet_diagnostic.IDE0073.severity = error 155 | 156 | # Newline settings 157 | csharp_new_line_before_open_brace = all 158 | csharp_new_line_before_else = true 159 | csharp_new_line_before_catch = true 160 | csharp_new_line_before_finally = true 161 | # csharp_new_line_before_members_in_object_initializers = true TODO seems like Rider/ReSharper has the value inverted, uncomment when its fixed 162 | csharp_new_line_before_members_in_anonymous_types = true 163 | csharp_new_line_between_query_expression_clauses = true 164 | 165 | # Indentation preferences 166 | csharp_indent_block_contents = true 167 | csharp_indent_braces = false 168 | csharp_indent_case_contents = true 169 | csharp_indent_case_contents_when_block = false 170 | csharp_indent_switch_labels = true 171 | csharp_indent_labels = flush_left 172 | 173 | # Prefer "var" everywhere 174 | csharp_style_var_for_built_in_types = true:suggestion 175 | csharp_style_var_when_type_is_apparent = true:suggestion 176 | csharp_style_var_elsewhere = true:suggestion 177 | 178 | # Prefer method-like constructs to have a block body 179 | csharp_style_expression_bodied_methods = false:none 180 | csharp_style_expression_bodied_constructors = false:none 181 | csharp_style_expression_bodied_operators = false:none 182 | 183 | # Prefer property-like constructs to have an expression-body 184 | csharp_style_expression_bodied_properties = true:none 185 | csharp_style_expression_bodied_indexers = true:none 186 | csharp_style_expression_bodied_accessors = true:none 187 | 188 | # Suggest more modern language features when available 189 | csharp_style_pattern_matching_over_is_with_cast_check = true:suggestion 190 | csharp_style_pattern_matching_over_as_with_null_check = true:suggestion 191 | csharp_style_inlined_variable_declaration = true:suggestion 192 | csharp_style_throw_expression = true:suggestion 193 | csharp_style_conditional_delegate_call = true:suggestion 194 | 195 | # Space preferences 196 | csharp_space_after_cast = false 197 | csharp_space_after_colon_in_inheritance_clause = true 198 | csharp_space_after_comma = true 199 | csharp_space_after_dot = false 200 | csharp_space_after_keywords_in_control_flow_statements = true 201 | csharp_space_after_semicolon_in_for_statement = true 202 | csharp_space_around_binary_operators = before_and_after 203 | csharp_space_around_declaration_statements = do_not_ignore 204 | csharp_space_before_colon_in_inheritance_clause = true 205 | csharp_space_before_comma = false 206 | csharp_space_before_dot = false 207 | csharp_space_before_open_square_brackets = false 208 | csharp_space_before_semicolon_in_for_statement = false 209 | csharp_space_between_empty_square_brackets = false 210 | csharp_space_between_method_call_empty_parameter_list_parentheses = false 211 | csharp_space_between_method_call_name_and_opening_parenthesis = false 212 | csharp_space_between_method_call_parameter_list_parentheses = false 213 | csharp_space_between_method_declaration_empty_parameter_list_parentheses = false 214 | csharp_space_between_method_declaration_name_and_open_parenthesis = false 215 | csharp_space_between_method_declaration_parameter_list_parentheses = false 216 | csharp_space_between_parentheses = false 217 | csharp_space_between_square_brackets = false 218 | 219 | # Blocks are allowed 220 | csharp_prefer_braces = true:silent 221 | csharp_preserve_single_line_blocks = true 222 | csharp_preserve_single_line_statements = true 223 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | *.cs text eol=lf 2 | *.csproj text eol=lf 3 | *.config eol=lf 4 | *.json eol=lf 5 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug-report.yml: -------------------------------------------------------------------------------- 1 | name: Bug Report 2 | description: File a bug report 3 | labels: [bug] 4 | body: 5 | - type: markdown 6 | attributes: 7 | value: | 8 | Thanks for taking the time to fill out this bug report! 9 | - type: textarea 10 | id: what-should-have-happened 11 | attributes: 12 | label: What did you expect to happen? 13 | placeholder: I expected that... 14 | validations: 15 | required: true 16 | - type: textarea 17 | id: what-actually-happened 18 | attributes: 19 | label: Instead of that, what actually happened? 20 | placeholder: ... but instead, what happened was... 21 | validations: 22 | required: true 23 | - type: dropdown 24 | id: operating-system 25 | attributes: 26 | label: Which operating system are you running on? 27 | options: 28 | - Linux 29 | - macOS 30 | - Windows 31 | - Other 32 | validations: 33 | required: true 34 | - type: input 35 | id: version 36 | attributes: 37 | label: Version 38 | description: What version of DepotDownloader are using? 39 | validations: 40 | required: true 41 | - type: input 42 | id: command 43 | attributes: 44 | label: Command 45 | description: Specify the full command you used (except for username and password) 46 | validations: 47 | required: true 48 | - type: textarea 49 | id: logs 50 | attributes: 51 | label: Relevant log output 52 | description: Please copy and paste any relevant log output. This will be automatically formatted into code, so no need for backticks. Run with `-debug` parameter to get additional output. 53 | render: shell 54 | - type: textarea 55 | id: additional-info 56 | attributes: 57 | label: Additional Information 58 | description: Is there anything else that you think we should know? 59 | validations: 60 | required: false 61 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: true 2 | contact_links: 3 | - name: Discussions 4 | url: https://github.com/SteamRE/DepotDownloader/discussions/new 5 | about: Please ask and answer questions here. 6 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature-request.yml: -------------------------------------------------------------------------------- 1 | name: Feature Request 2 | description: Suggest an idea for this project 3 | labels: [enhancement] 4 | body: 5 | - type: markdown 6 | attributes: 7 | value: | 8 | Thanks, we appreciate good ideas! 9 | - type: textarea 10 | id: problem-area 11 | attributes: 12 | label: What problem is this feature trying to solve? 13 | placeholder: I'm really frustrated when... 14 | validations: 15 | required: true 16 | - type: textarea 17 | id: proposed-solution 18 | attributes: 19 | label: How would you like it to be solved? 20 | placeholder: I think that it could be solved by... 21 | validations: 22 | required: true 23 | - type: textarea 24 | id: alternative-solutions 25 | attributes: 26 | label: Have you considered any alternative solutions 27 | placeholder: I did think that that it also could be solved by ..., but... 28 | validations: 29 | required: true 30 | - type: textarea 31 | id: additional-info 32 | attributes: 33 | label: Additional Information 34 | description: Is there anything else that you think we should know? 35 | validations: 36 | required: false 37 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: nuget 4 | directory: "/" 5 | schedule: 6 | interval: weekly 7 | open-pull-requests-limit: 10 8 | 9 | - package-ecosystem: "github-actions" 10 | directory: "/" 11 | schedule: 12 | interval: weekly 13 | ignore: 14 | - dependency-name: "*" 15 | update-types: ["version-update:semver-minor", "version-update:semver-patch"] 16 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: .NET Core CI 2 | 3 | on: 4 | push: 5 | paths-ignore: 6 | - '.github/*' 7 | - '.github/*_TEMPLATE/**' 8 | - '*.md' 9 | pull_request: 10 | paths-ignore: 11 | - '.github/*' 12 | - '.github/*_TEMPLATE/**' 13 | - '*.md' 14 | workflow_dispatch: 15 | 16 | jobs: 17 | build: 18 | name: .NET on ${{ matrix.runs-on }} (${{ matrix.configuration }}) 19 | runs-on: ${{ matrix.runs-on }} 20 | strategy: 21 | fail-fast: false 22 | matrix: 23 | runs-on: [macos-latest, macos-14, ubuntu-latest, windows-latest] 24 | configuration: [Release, Debug] 25 | env: 26 | DOTNET_CLI_TELEMETRY_OPTOUT: 1 27 | steps: 28 | - uses: actions/checkout@v4 29 | 30 | - name: Setup .NET Core 31 | uses: actions/setup-dotnet@v4 32 | 33 | - name: Build 34 | run: dotnet publish DepotDownloader/DepotDownloader.csproj -c ${{ matrix.configuration }} -o artifacts /p:ContinuousIntegrationBuild=true 35 | 36 | - name: Upload artifact 37 | uses: actions/upload-artifact@v4 38 | if: matrix.configuration == 'Release' && matrix.runs-on == 'windows-latest' 39 | with: 40 | name: DepotDownloader-framework 41 | path: artifacts 42 | if-no-files-found: error 43 | 44 | - name: Publish Windows-x64 45 | if: matrix.configuration == 'Release' && matrix.runs-on == 'windows-latest' 46 | run: dotnet publish DepotDownloader/DepotDownloader.csproj --configuration Release -p:PublishSingleFile=true -p:DebugType=embedded --self-contained --runtime win-x64 --output selfcontained-win-x64 47 | 48 | - name: Publish Windows-arm64 49 | if: matrix.configuration == 'Release' && matrix.runs-on == 'windows-latest' 50 | run: dotnet publish DepotDownloader/DepotDownloader.csproj --configuration Release -p:PublishSingleFile=true -p:DebugType=embedded --self-contained --runtime win-arm64 --output selfcontained-win-arm64 51 | 52 | - name: Publish Linux-x64 53 | if: matrix.configuration == 'Release' && matrix.runs-on == 'ubuntu-latest' 54 | run: dotnet publish DepotDownloader/DepotDownloader.csproj --configuration Release -p:PublishSingleFile=true -p:DebugType=embedded --self-contained --runtime linux-x64 --output selfcontained-linux-x64 55 | 56 | - name: Publish Linux-arm 57 | if: matrix.configuration == 'Release' && matrix.runs-on == 'ubuntu-latest' 58 | run: dotnet publish DepotDownloader/DepotDownloader.csproj --configuration Release -p:PublishSingleFile=true -p:DebugType=embedded --self-contained --runtime linux-arm --output selfcontained-linux-arm 59 | 60 | - name: Publish Linux-arm64 61 | if: matrix.configuration == 'Release' && matrix.runs-on == 'ubuntu-latest' 62 | run: dotnet publish DepotDownloader/DepotDownloader.csproj --configuration Release -p:PublishSingleFile=true -p:DebugType=embedded --self-contained --runtime linux-arm64 --output selfcontained-linux-arm64 63 | 64 | - name: Publish macOS-x64 65 | if: matrix.configuration == 'Release' && matrix.runs-on == 'macos-latest' 66 | run: dotnet publish DepotDownloader/DepotDownloader.csproj --configuration Release -p:PublishSingleFile=true -p:DebugType=embedded --self-contained --runtime osx-x64 --output selfcontained-osx-x64 67 | 68 | - name: Publish macOS-arm64 69 | if: matrix.configuration == 'Release' && matrix.runs-on == 'macos-14' 70 | run: dotnet publish DepotDownloader/DepotDownloader.csproj --configuration Release -p:PublishSingleFile=true -p:DebugType=embedded --self-contained --runtime osx-arm64 --output selfcontained-osx-arm64 71 | 72 | - name: Upload Windows-x64 73 | uses: actions/upload-artifact@v4 74 | if: matrix.configuration == 'Release' && matrix.runs-on == 'windows-latest' 75 | with: 76 | name: DepotDownloader-windows-x64 77 | path: selfcontained-win-x64 78 | if-no-files-found: error 79 | 80 | - name: Upload Windows-arm64 81 | uses: actions/upload-artifact@v4 82 | if: matrix.configuration == 'Release' && matrix.runs-on == 'windows-latest' 83 | with: 84 | name: DepotDownloader-windows-arm64 85 | path: selfcontained-win-arm64 86 | if-no-files-found: error 87 | 88 | - name: Upload Linux-x64 89 | uses: actions/upload-artifact@v4 90 | if: matrix.configuration == 'Release' && matrix.runs-on == 'ubuntu-latest' 91 | with: 92 | name: DepotDownloader-linux-x64 93 | path: selfcontained-linux-x64 94 | if-no-files-found: error 95 | 96 | - name: Upload Linux-arm 97 | uses: actions/upload-artifact@v4 98 | if: matrix.configuration == 'Release' && matrix.runs-on == 'ubuntu-latest' 99 | with: 100 | name: DepotDownloader-linux-arm 101 | path: selfcontained-linux-arm 102 | if-no-files-found: error 103 | 104 | - name: Upload Linux-arm64 105 | uses: actions/upload-artifact@v4 106 | if: matrix.configuration == 'Release' && matrix.runs-on == 'ubuntu-latest' 107 | with: 108 | name: DepotDownloader-linux-arm64 109 | path: selfcontained-linux-arm64 110 | if-no-files-found: error 111 | 112 | - name: Upload macOS-x64 113 | uses: actions/upload-artifact@v4 114 | if: matrix.configuration == 'Release' && matrix.runs-on == 'macos-latest' 115 | with: 116 | name: DepotDownloader-macos-x64 117 | path: selfcontained-osx-x64 118 | if-no-files-found: error 119 | 120 | - name: Upload macOS-arm64 121 | uses: actions/upload-artifact@v4 122 | if: matrix.configuration == 'Release' && matrix.runs-on == 'macos-14' 123 | with: 124 | name: DepotDownloader-macos-arm64 125 | path: selfcontained-osx-arm64 126 | if-no-files-found: error 127 | 128 | release: 129 | if: startsWith(github.ref, 'refs/tags/') 130 | needs: build 131 | runs-on: ubuntu-latest 132 | steps: 133 | - name: Download artifacts 134 | uses: actions/download-artifact@v4 135 | with: 136 | path: artifacts 137 | 138 | - name: Display artifacts folder structure 139 | run: ls -Rl 140 | working-directory: artifacts 141 | 142 | - name: Create release files 143 | run: | 144 | set -eux 145 | mkdir release 146 | chmod +x artifacts/DepotDownloader-linux-x64/DepotDownloader 147 | chmod +x artifacts/DepotDownloader-linux-arm/DepotDownloader 148 | chmod +x artifacts/DepotDownloader-linux-arm64/DepotDownloader 149 | chmod +x artifacts/DepotDownloader-macos-x64/DepotDownloader 150 | chmod +x artifacts/DepotDownloader-macos-arm64/DepotDownloader 151 | zip -9j release/DepotDownloader-framework.zip artifacts/DepotDownloader-framework/* 152 | zip -9j release/DepotDownloader-windows-x64.zip artifacts/DepotDownloader-windows-x64/* 153 | zip -9j release/DepotDownloader-windows-arm64.zip artifacts/DepotDownloader-windows-arm64/* 154 | zip -9j release/DepotDownloader-linux-x64.zip artifacts/DepotDownloader-linux-x64/* 155 | zip -9j release/DepotDownloader-linux-arm.zip artifacts/DepotDownloader-linux-arm/* 156 | zip -9j release/DepotDownloader-linux-arm64.zip artifacts/DepotDownloader-linux-arm64/* 157 | zip -9j release/DepotDownloader-macos-x64.zip artifacts/DepotDownloader-macos-x64/* 158 | zip -9j release/DepotDownloader-macos-arm64.zip artifacts/DepotDownloader-macos-arm64/* 159 | 160 | - name: Display structure of archived files 161 | run: ls -Rl 162 | working-directory: release 163 | 164 | - name: Release 165 | uses: softprops/action-gh-release@v2 166 | with: 167 | draft: true 168 | files: release/* 169 | env: 170 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 171 | -------------------------------------------------------------------------------- /.github/workflows/sk2-ci.yml: -------------------------------------------------------------------------------- 1 | name: SteamKit2 Continuous Integration 2 | 3 | on: 4 | schedule: 5 | - cron: '0 1 * * SUN' 6 | workflow_dispatch: 7 | 8 | jobs: 9 | build: 10 | name: .NET on ${{ matrix.runs-on }} (${{ matrix.configuration }}) 11 | runs-on: ${{ matrix.runs-on }} 12 | strategy: 13 | fail-fast: false 14 | matrix: 15 | runs-on: [ macos-latest, macos-14, ubuntu-latest, windows-latest ] 16 | configuration: [ Release, Debug ] 17 | env: 18 | DOTNET_CLI_TELEMETRY_OPTOUT: 1 19 | steps: 20 | - uses: actions/checkout@v4 21 | 22 | - name: Setup .NET Core 23 | uses: actions/setup-dotnet@v4 24 | 25 | - name: Configure NuGet 26 | run: | 27 | dotnet nuget add source --username USERNAME --password ${{ secrets.GITHUB_TOKEN }} --store-password-in-clear-text --name github "https://nuget.pkg.github.com/SteamRE/index.json" 28 | dotnet add DepotDownloader/DepotDownloader.csproj package SteamKit2 --prerelease 29 | 30 | - name: Build 31 | run: dotnet publish DepotDownloader/DepotDownloader.csproj -c ${{ matrix.configuration }} -o artifacts /p:ContinuousIntegrationBuild=true 32 | 33 | - name: Upload artifact 34 | uses: actions/upload-artifact@v4 35 | if: matrix.configuration == 'Release' 36 | with: 37 | name: DepotDownloader-${{ matrix.runs-on }} 38 | path: artifacts 39 | if-no-files-found: error 40 | -------------------------------------------------------------------------------- /.github/workflows/winget.yml: -------------------------------------------------------------------------------- 1 | name: WinGet submission on release 2 | 3 | on: 4 | workflow_dispatch: 5 | release: 6 | types: [published] 7 | 8 | jobs: 9 | winget: 10 | name: Publish winget package 11 | runs-on: windows-latest 12 | steps: 13 | - name: Submit package to Windows Package Manager Community Repository 14 | run: | 15 | $wingetPackage = "SteamRE.DepotDownloader" 16 | 17 | $headers = @{ 18 | Authorization = "Bearer ${{ secrets.GITHUB_TOKEN }}" 19 | } 20 | 21 | $github = Invoke-RestMethod -uri "https://api.github.com/repos/SteamRE/DepotDownloader/releases" -Headers $headers 22 | 23 | $targetRelease = $github | Where-Object -Property name -match '^DepotDownloader' | Select -First 1 24 | $assets = $targetRelease | Select -ExpandProperty assets -First 1 25 | $zipX64Url = $assets | Where-Object -Property name -match 'DepotDownloader-windows-x64.zip' | Select -ExpandProperty browser_download_url 26 | $zipArm64Url = $assets | Where-Object -Property name -match 'DepotDownloader-windows-arm64.zip' | Select -ExpandProperty browser_download_url 27 | $ver = $targetRelease.tag_name -ireplace '^(DepotDownloader[ _])?v?' 28 | 29 | # getting latest wingetcreate file 30 | iwr https://aka.ms/wingetcreate/latest -OutFile wingetcreate.exe 31 | 32 | # how to create a token: https://github.com/microsoft/winget-create?tab=readme-ov-file#github-personal-access-token-classic-permissions 33 | .\wingetcreate.exe update $wingetPackage --submit --version $ver --urls "$zipX64Url" "$zipArm64Url" --token "${{ secrets.PT_WINGET }}" 34 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Build Folders (you can keep bin if you'd like, to store dlls and pdbs) 2 | [Bb]in/ 3 | [Oo]bj/ 4 | 5 | # mstest test results 6 | TestResults 7 | 8 | ## Ignore Visual Studio temporary files, build results, and 9 | ## files generated by popular Visual Studio add-ons. 10 | 11 | # User-specific files 12 | *.suo 13 | *.user 14 | *.sln.docstates 15 | .vs 16 | 17 | # Build results 18 | [Dd]ebug/ 19 | [Rr]elease/ 20 | x64/ 21 | *_i.c 22 | *_p.c 23 | *.ilk 24 | *.meta 25 | *.obj 26 | *.pch 27 | *.pdb 28 | *.pgc 29 | *.pgd 30 | *.rsp 31 | *.sbr 32 | *.tlb 33 | *.tli 34 | *.tlh 35 | *.tmp 36 | *.log 37 | *.vspscc 38 | *.vssscc 39 | .builds 40 | 41 | # Visual C++ cache files 42 | ipch/ 43 | *.aps 44 | *.ncb 45 | *.opensdf 46 | *.sdf 47 | 48 | # Visual Studio profiler 49 | *.psess 50 | *.vsp 51 | *.vspx 52 | 53 | # Guidance Automation Toolkit 54 | *.gpState 55 | 56 | # ReSharper is a .NET coding add-in 57 | _ReSharper* 58 | 59 | # NCrunch 60 | *.ncrunch* 61 | .*crunch*.local.xml 62 | 63 | # Installshield output folder 64 | [Ee]xpress 65 | 66 | # DocProject is a documentation generator add-in 67 | DocProject/buildhelp/ 68 | DocProject/Help/*.HxT 69 | DocProject/Help/*.HxC 70 | DocProject/Help/*.hhc 71 | DocProject/Help/*.hhk 72 | DocProject/Help/*.hhp 73 | DocProject/Help/Html2 74 | DocProject/Help/html 75 | 76 | # Click-Once directory 77 | publish 78 | 79 | # Publish Web Output 80 | *.Publish.xml 81 | 82 | # NuGet Packages Directory 83 | packages 84 | *.nupkg 85 | 86 | # Windows Azure Build Output 87 | csx 88 | *.build.csdef 89 | 90 | # Windows Store app package directory 91 | AppPackages/ 92 | 93 | # Others 94 | [Bb]in 95 | [Oo]bj 96 | sql 97 | TestResults 98 | [Tt]est[Rr]esult* 99 | *.Cache 100 | ClientBin 101 | [Ss]tyle[Cc]op.* 102 | ~$* 103 | *.dbmdl 104 | Generated_Code #added for RIA/Silverlight projects 105 | 106 | # Backup & report files from converting an old project file to a newer 107 | # Visual Studio version. Backup files are not needed, because we have git ;-) 108 | _UpgradeReport_Files/ 109 | Backup*/ 110 | UpgradeLog*.XML 111 | 112 | # third party libs 113 | boost/ 114 | google/ 115 | zlib/ 116 | protobuf/ 117 | cryptopp/ 118 | 119 | # misc 120 | Thumbs.db 121 | launchSettings.json 122 | -------------------------------------------------------------------------------- /DepotDownloader.sln: -------------------------------------------------------------------------------- 1 |  2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio 15 4 | VisualStudioVersion = 15.0.26228.4 5 | MinimumVisualStudioVersion = 10.0.40219.1 6 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DepotDownloader", "DepotDownloader\DepotDownloader.csproj", "{39159C47-ACD3-449F-96CA-4F30C8ED147A}" 7 | EndProject 8 | Global 9 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 10 | Debug|Any CPU = Debug|Any CPU 11 | Release|Any CPU = Release|Any CPU 12 | EndGlobalSection 13 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 14 | {39159C47-ACD3-449F-96CA-4F30C8ED147A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 15 | {39159C47-ACD3-449F-96CA-4F30C8ED147A}.Debug|Any CPU.Build.0 = Debug|Any CPU 16 | {39159C47-ACD3-449F-96CA-4F30C8ED147A}.Release|Any CPU.ActiveCfg = Release|Any CPU 17 | {39159C47-ACD3-449F-96CA-4F30C8ED147A}.Release|Any CPU.Build.0 = Release|Any CPU 18 | EndGlobalSection 19 | GlobalSection(SolutionProperties) = preSolution 20 | HideSolutionNode = FALSE 21 | EndGlobalSection 22 | EndGlobal 23 | -------------------------------------------------------------------------------- /DepotDownloader/AccountSettingsStore.cs: -------------------------------------------------------------------------------- 1 | // This file is subject to the terms and conditions defined 2 | // in file 'LICENSE', which is part of this source code package. 3 | 4 | using System; 5 | using System.Collections.Concurrent; 6 | using System.Collections.Generic; 7 | using System.IO; 8 | using System.IO.Compression; 9 | using System.IO.IsolatedStorage; 10 | using ProtoBuf; 11 | 12 | namespace DepotDownloader 13 | { 14 | [ProtoContract] 15 | class AccountSettingsStore 16 | { 17 | // Member 1 was a Dictionary for SentryData. 18 | 19 | [ProtoMember(2, IsRequired = false)] 20 | public ConcurrentDictionary ContentServerPenalty { get; private set; } 21 | 22 | // Member 3 was a Dictionary for LoginKeys. 23 | 24 | [ProtoMember(4, IsRequired = false)] 25 | public Dictionary LoginTokens { get; private set; } 26 | 27 | [ProtoMember(5, IsRequired = false)] 28 | public Dictionary GuardData { get; private set; } 29 | 30 | string FileName; 31 | 32 | AccountSettingsStore() 33 | { 34 | ContentServerPenalty = new ConcurrentDictionary(); 35 | LoginTokens = new(StringComparer.OrdinalIgnoreCase); 36 | GuardData = new(StringComparer.OrdinalIgnoreCase); 37 | } 38 | 39 | static bool Loaded 40 | { 41 | get { return Instance != null; } 42 | } 43 | 44 | public static AccountSettingsStore Instance; 45 | static readonly IsolatedStorageFile IsolatedStorage = IsolatedStorageFile.GetUserStoreForAssembly(); 46 | 47 | public static void LoadFromFile(string filename) 48 | { 49 | if (Loaded) 50 | throw new Exception("Config already loaded"); 51 | 52 | if (IsolatedStorage.FileExists(filename)) 53 | { 54 | try 55 | { 56 | using var fs = IsolatedStorage.OpenFile(filename, FileMode.Open, FileAccess.Read); 57 | using var ds = new DeflateStream(fs, CompressionMode.Decompress); 58 | Instance = Serializer.Deserialize(ds); 59 | } 60 | catch (IOException ex) 61 | { 62 | Console.WriteLine("Failed to load account settings: {0}", ex.Message); 63 | Instance = new AccountSettingsStore(); 64 | } 65 | } 66 | else 67 | { 68 | Instance = new AccountSettingsStore(); 69 | } 70 | 71 | Instance.FileName = filename; 72 | } 73 | 74 | public static void Save() 75 | { 76 | if (!Loaded) 77 | throw new Exception("Saved config before loading"); 78 | 79 | try 80 | { 81 | using var fs = IsolatedStorage.OpenFile(Instance.FileName, FileMode.Create, FileAccess.Write); 82 | using var ds = new DeflateStream(fs, CompressionMode.Compress); 83 | Serializer.Serialize(ds, Instance); 84 | } 85 | catch (IOException ex) 86 | { 87 | Console.WriteLine("Failed to save account settings: {0}", ex.Message); 88 | } 89 | } 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /DepotDownloader/Ansi.cs: -------------------------------------------------------------------------------- 1 | // This file is subject to the terms and conditions defined 2 | // in file 'LICENSE', which is part of this source code package. 3 | 4 | using System; 5 | using Spectre.Console; 6 | 7 | namespace DepotDownloader; 8 | 9 | static class Ansi 10 | { 11 | // https://conemu.github.io/en/AnsiEscapeCodes.html#ConEmu_specific_OSC 12 | // https://learn.microsoft.com/en-us/windows/terminal/tutorials/progress-bar-sequences 13 | public enum ProgressState 14 | { 15 | Hidden = 0, 16 | Default = 1, 17 | Error = 2, 18 | Indeterminate = 3, 19 | Warning = 4, 20 | } 21 | 22 | const char ESC = (char)0x1B; 23 | const char BEL = (char)0x07; 24 | 25 | private static bool useProgress; 26 | 27 | public static void Init() 28 | { 29 | if (Console.IsInputRedirected || Console.IsOutputRedirected) 30 | { 31 | return; 32 | } 33 | 34 | if (OperatingSystem.IsLinux()) 35 | { 36 | return; 37 | } 38 | 39 | var (supportsAnsi, legacyConsole) = AnsiDetector.Detect(stdError: false, upgrade: true); 40 | 41 | useProgress = supportsAnsi && !legacyConsole; 42 | } 43 | 44 | public static void Progress(ulong downloaded, ulong total) 45 | { 46 | var progress = (byte)MathF.Round(downloaded / (float)total * 100.0f); 47 | Progress(ProgressState.Default, progress); 48 | } 49 | 50 | public static void Progress(ProgressState state, byte progress = 0) 51 | { 52 | if (!useProgress) 53 | { 54 | return; 55 | } 56 | 57 | Console.Write($"{ESC}]9;4;{(byte)state};{progress}{BEL}"); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /DepotDownloader/AnsiDetector.cs: -------------------------------------------------------------------------------- 1 | // Copied from https://github.com/spectreconsole/spectre.console/blob/d79e6adc5f8e637fb35c88f987023ffda6707243/src/Spectre.Console/Internal/Backends/Ansi/AnsiDetector.cs 2 | // MIT License - Copyright(c) 2020 Patrik Svensson, Phil Scott, Nils Andresen 3 | // which is partially based on https://github.com/keqingrong/supports-ansi/blob/master/index.js 4 | // 5 | 6 | using System; 7 | using System.Linq; 8 | using System.Runtime.InteropServices; 9 | using System.Text.RegularExpressions; 10 | using Windows.Win32; 11 | using Windows.Win32.System.Console; 12 | 13 | namespace Spectre.Console; 14 | 15 | internal static class AnsiDetector 16 | { 17 | private static readonly Regex[] _regexes = 18 | [ 19 | new("^xterm"), // xterm, PuTTY, Mintty 20 | new("^rxvt"), // RXVT 21 | new("^eterm"), // Eterm 22 | new("^screen"), // GNU screen, tmux 23 | new("tmux"), // tmux 24 | new("^vt100"), // DEC VT series 25 | new("^vt102"), // DEC VT series 26 | new("^vt220"), // DEC VT series 27 | new("^vt320"), // DEC VT series 28 | new("ansi"), // ANSI 29 | new("scoansi"), // SCO ANSI 30 | new("cygwin"), // Cygwin, MinGW 31 | new("linux"), // Linux console 32 | new("konsole"), // Konsole 33 | new("bvterm"), // Bitvise SSH Client 34 | new("^st-256color"), // Suckless Simple Terminal, st 35 | new("alacritty"), // Alacritty 36 | ]; 37 | 38 | public static (bool SupportsAnsi, bool LegacyConsole) Detect(bool stdError, bool upgrade) 39 | { 40 | // Running on Windows? 41 | if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) 42 | { 43 | // Running under ConEmu? 44 | var conEmu = Environment.GetEnvironmentVariable("ConEmuANSI"); 45 | if (!string.IsNullOrEmpty(conEmu) && conEmu.Equals("On", StringComparison.OrdinalIgnoreCase)) 46 | { 47 | return (true, false); 48 | } 49 | 50 | var supportsAnsi = WindowsSupportsAnsi(upgrade, stdError, out var legacyConsole); 51 | return (supportsAnsi, legacyConsole); 52 | } 53 | 54 | return DetectFromTerm(); 55 | } 56 | 57 | private static (bool SupportsAnsi, bool LegacyConsole) DetectFromTerm() 58 | { 59 | // Check if the terminal is of type ANSI/VT100/xterm compatible. 60 | var term = Environment.GetEnvironmentVariable("TERM"); 61 | if (!string.IsNullOrWhiteSpace(term)) 62 | { 63 | if (_regexes.Any(regex => regex.IsMatch(term))) 64 | { 65 | return (true, false); 66 | } 67 | } 68 | 69 | return (false, true); 70 | } 71 | 72 | private static bool WindowsSupportsAnsi(bool upgrade, bool stdError, out bool isLegacy) 73 | { 74 | isLegacy = false; 75 | 76 | try 77 | { 78 | var @out = PInvoke.GetStdHandle_SafeHandle(stdError ? STD_HANDLE.STD_ERROR_HANDLE :STD_HANDLE.STD_OUTPUT_HANDLE); 79 | 80 | if (!PInvoke.GetConsoleMode(@out, out var mode)) 81 | { 82 | // Could not get console mode, try TERM (set in cygwin, WSL-Shell). 83 | var (ansiFromTerm, legacyFromTerm) = DetectFromTerm(); 84 | 85 | isLegacy = ansiFromTerm ? legacyFromTerm : isLegacy; 86 | return ansiFromTerm; 87 | } 88 | 89 | if ((mode & CONSOLE_MODE.ENABLE_VIRTUAL_TERMINAL_PROCESSING) == 0||true) 90 | { 91 | isLegacy = true; 92 | 93 | if (!upgrade) 94 | { 95 | return false; 96 | } 97 | 98 | // Try enable ANSI support. 99 | mode |= CONSOLE_MODE.ENABLE_VIRTUAL_TERMINAL_PROCESSING | CONSOLE_MODE.DISABLE_NEWLINE_AUTO_RETURN; 100 | if (!PInvoke.SetConsoleMode(@out, mode)) 101 | { 102 | // Enabling failed. 103 | return false; 104 | } 105 | 106 | isLegacy = false; 107 | } 108 | 109 | return true; 110 | } 111 | catch 112 | { 113 | // All we know here is that we don't support ANSI. 114 | return false; 115 | } 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /DepotDownloader/CDNClientPool.cs: -------------------------------------------------------------------------------- 1 | // This file is subject to the terms and conditions defined 2 | // in file 'LICENSE', which is part of this source code package. 3 | 4 | using System; 5 | using System.Collections.Generic; 6 | using System.Linq; 7 | using System.Threading.Tasks; 8 | using SteamKit2.CDN; 9 | 10 | namespace DepotDownloader 11 | { 12 | /// 13 | /// CDNClientPool provides a pool of connections to CDN endpoints, requesting CDN tokens as needed 14 | /// 15 | class CDNClientPool 16 | { 17 | private readonly Steam3Session steamSession; 18 | private readonly uint appId; 19 | public Client CDNClient { get; } 20 | public Server ProxyServer { get; private set; } 21 | 22 | private readonly List servers = []; 23 | private int nextServer; 24 | 25 | public CDNClientPool(Steam3Session steamSession, uint appId) 26 | { 27 | this.steamSession = steamSession; 28 | this.appId = appId; 29 | CDNClient = new Client(steamSession.steamClient); 30 | } 31 | 32 | public async Task UpdateServerList() 33 | { 34 | var servers = await this.steamSession.steamContent.GetServersForSteamPipe(); 35 | 36 | ProxyServer = servers.Where(x => x.UseAsProxy).FirstOrDefault(); 37 | 38 | var weightedCdnServers = servers 39 | .Where(server => 40 | { 41 | var isEligibleForApp = server.AllowedAppIds.Length == 0 || server.AllowedAppIds.Contains(appId); 42 | return isEligibleForApp && (server.Type == "SteamCache" || server.Type == "CDN"); 43 | }) 44 | .Select(server => 45 | { 46 | AccountSettingsStore.Instance.ContentServerPenalty.TryGetValue(server.Host, out var penalty); 47 | 48 | return (server, penalty); 49 | }) 50 | .OrderBy(pair => pair.penalty).ThenBy(pair => pair.server.WeightedLoad); 51 | 52 | foreach (var (server, weight) in weightedCdnServers) 53 | { 54 | for (var i = 0; i < server.NumEntries; i++) 55 | { 56 | this.servers.Add(server); 57 | } 58 | } 59 | 60 | if (this.servers.Count == 0) 61 | { 62 | throw new Exception("Failed to retrieve any download servers."); 63 | } 64 | } 65 | 66 | public Server GetConnection() 67 | { 68 | return servers[nextServer % servers.Count]; 69 | } 70 | 71 | public void ReturnConnection(Server server) 72 | { 73 | if (server == null) return; 74 | 75 | // nothing to do, maybe remove from ContentServerPenalty? 76 | } 77 | 78 | public void ReturnBrokenConnection(Server server) 79 | { 80 | if (server == null) return; 81 | 82 | lock (servers) 83 | { 84 | if (servers[nextServer % servers.Count] == server) 85 | { 86 | nextServer++; 87 | 88 | // TODO: Add server to ContentServerPenalty 89 | } 90 | } 91 | } 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /DepotDownloader/ConsoleAuthenticator.cs: -------------------------------------------------------------------------------- 1 | // This file is subject to the terms and conditions defined 2 | // in file 'LICENSE', which is part of this source code package. 3 | 4 | using System; 5 | using System.Threading.Tasks; 6 | using SteamKit2.Authentication; 7 | 8 | namespace DepotDownloader 9 | { 10 | // This is practically copied from https://github.com/SteamRE/SteamKit/blob/master/SteamKit2/SteamKit2/Steam/Authentication/UserConsoleAuthenticator.cs 11 | internal class ConsoleAuthenticator : IAuthenticator 12 | { 13 | /// 14 | public Task GetDeviceCodeAsync(bool previousCodeWasIncorrect) 15 | { 16 | if (previousCodeWasIncorrect) 17 | { 18 | Console.Error.WriteLine("The previous 2-factor auth code you have provided is incorrect."); 19 | } 20 | 21 | string code; 22 | 23 | do 24 | { 25 | Console.Error.Write("STEAM GUARD! Please enter your 2-factor auth code from your authenticator app: "); 26 | code = Console.ReadLine()?.Trim(); 27 | 28 | if (code == null) 29 | { 30 | break; 31 | } 32 | } 33 | while (string.IsNullOrEmpty(code)); 34 | 35 | return Task.FromResult(code!); 36 | } 37 | 38 | /// 39 | public Task GetEmailCodeAsync(string email, bool previousCodeWasIncorrect) 40 | { 41 | if (previousCodeWasIncorrect) 42 | { 43 | Console.Error.WriteLine("The previous 2-factor auth code you have provided is incorrect."); 44 | } 45 | 46 | string code; 47 | 48 | do 49 | { 50 | Console.Error.Write($"STEAM GUARD! Please enter the auth code sent to the email at {email}: "); 51 | code = Console.ReadLine()?.Trim(); 52 | 53 | if (code == null) 54 | { 55 | break; 56 | } 57 | } 58 | while (string.IsNullOrEmpty(code)); 59 | 60 | return Task.FromResult(code!); 61 | } 62 | 63 | /// 64 | public Task AcceptDeviceConfirmationAsync() 65 | { 66 | if (ContentDownloader.Config.SkipAppConfirmation) 67 | { 68 | return Task.FromResult(false); 69 | } 70 | 71 | Console.Error.WriteLine("STEAM GUARD! Use the Steam Mobile App to confirm your sign in..."); 72 | 73 | return Task.FromResult(true); 74 | } 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /DepotDownloader/ContentDownloader.cs: -------------------------------------------------------------------------------- 1 | // This file is subject to the terms and conditions defined 2 | // in file 'LICENSE', which is part of this source code package. 3 | 4 | using System; 5 | using System.Buffers; 6 | using System.Collections.Concurrent; 7 | using System.Collections.Generic; 8 | using System.IO; 9 | using System.Linq; 10 | using System.Net; 11 | using System.Threading; 12 | using System.Threading.Tasks; 13 | using SteamKit2; 14 | using SteamKit2.CDN; 15 | 16 | namespace DepotDownloader 17 | { 18 | class ContentDownloaderException(string value) : Exception(value) 19 | { 20 | } 21 | 22 | static class ContentDownloader 23 | { 24 | public const uint INVALID_APP_ID = uint.MaxValue; 25 | public const uint INVALID_DEPOT_ID = uint.MaxValue; 26 | public const ulong INVALID_MANIFEST_ID = ulong.MaxValue; 27 | public const string DEFAULT_BRANCH = "public"; 28 | 29 | public static DownloadConfig Config = new(); 30 | 31 | private static Steam3Session steam3; 32 | private static CDNClientPool cdnPool; 33 | 34 | private const string DEFAULT_DOWNLOAD_DIR = "depots"; 35 | private const string CONFIG_DIR = ".DepotDownloader"; 36 | private static readonly string STAGING_DIR = Path.Combine(CONFIG_DIR, "staging"); 37 | 38 | private sealed class DepotDownloadInfo( 39 | uint depotid, uint appId, ulong manifestId, string branch, 40 | string installDir, byte[] depotKey) 41 | { 42 | public uint DepotId { get; } = depotid; 43 | public uint AppId { get; } = appId; 44 | public ulong ManifestId { get; } = manifestId; 45 | public string Branch { get; } = branch; 46 | public string InstallDir { get; } = installDir; 47 | public byte[] DepotKey { get; } = depotKey; 48 | } 49 | 50 | static bool CreateDirectories(uint depotId, uint depotVersion, out string installDir) 51 | { 52 | installDir = null; 53 | try 54 | { 55 | if (string.IsNullOrWhiteSpace(Config.InstallDirectory)) 56 | { 57 | Directory.CreateDirectory(DEFAULT_DOWNLOAD_DIR); 58 | 59 | var depotPath = Path.Combine(DEFAULT_DOWNLOAD_DIR, depotId.ToString()); 60 | Directory.CreateDirectory(depotPath); 61 | 62 | installDir = Path.Combine(depotPath, depotVersion.ToString()); 63 | Directory.CreateDirectory(installDir); 64 | 65 | Directory.CreateDirectory(Path.Combine(installDir, CONFIG_DIR)); 66 | Directory.CreateDirectory(Path.Combine(installDir, STAGING_DIR)); 67 | } 68 | else 69 | { 70 | Directory.CreateDirectory(Config.InstallDirectory); 71 | 72 | installDir = Config.InstallDirectory; 73 | 74 | Directory.CreateDirectory(Path.Combine(installDir, CONFIG_DIR)); 75 | Directory.CreateDirectory(Path.Combine(installDir, STAGING_DIR)); 76 | } 77 | } 78 | catch 79 | { 80 | return false; 81 | } 82 | 83 | return true; 84 | } 85 | 86 | static bool TestIsFileIncluded(string filename) 87 | { 88 | if (!Config.UsingFileList) 89 | return true; 90 | 91 | filename = filename.Replace('\\', '/'); 92 | 93 | if (Config.FilesToDownload.Contains(filename)) 94 | { 95 | return true; 96 | } 97 | 98 | foreach (var rgx in Config.FilesToDownloadRegex) 99 | { 100 | var m = rgx.Match(filename); 101 | 102 | if (m.Success) 103 | return true; 104 | } 105 | 106 | return false; 107 | } 108 | 109 | static async Task AccountHasAccess(uint appId, uint depotId) 110 | { 111 | if (steam3 == null || steam3.steamUser.SteamID == null || (steam3.Licenses == null && steam3.steamUser.SteamID.AccountType != EAccountType.AnonUser)) 112 | return false; 113 | 114 | IEnumerable licenseQuery; 115 | if (steam3.steamUser.SteamID.AccountType == EAccountType.AnonUser) 116 | { 117 | licenseQuery = [17906]; 118 | } 119 | else 120 | { 121 | licenseQuery = steam3.Licenses.Select(x => x.PackageID).Distinct(); 122 | } 123 | 124 | await steam3.RequestPackageInfo(licenseQuery); 125 | 126 | foreach (var license in licenseQuery) 127 | { 128 | if (steam3.PackageInfo.TryGetValue(license, out var package) && package != null) 129 | { 130 | if (package.KeyValues["appids"].Children.Any(child => child.AsUnsignedInteger() == depotId)) 131 | return true; 132 | 133 | if (package.KeyValues["depotids"].Children.Any(child => child.AsUnsignedInteger() == depotId)) 134 | return true; 135 | } 136 | } 137 | 138 | // Check if this app is free to download without a license 139 | var info = GetSteam3AppSection(appId, EAppInfoSection.Common); 140 | if (info != null && info["FreeToDownload"].AsBoolean()) 141 | return true; 142 | 143 | return false; 144 | } 145 | 146 | internal static KeyValue GetSteam3AppSection(uint appId, EAppInfoSection section) 147 | { 148 | if (steam3 == null || steam3.AppInfo == null) 149 | { 150 | return null; 151 | } 152 | 153 | if (!steam3.AppInfo.TryGetValue(appId, out var app) || app == null) 154 | { 155 | return null; 156 | } 157 | 158 | var appinfo = app.KeyValues; 159 | var section_key = section switch 160 | { 161 | EAppInfoSection.Common => "common", 162 | EAppInfoSection.Extended => "extended", 163 | EAppInfoSection.Config => "config", 164 | EAppInfoSection.Depots => "depots", 165 | _ => throw new NotImplementedException(), 166 | }; 167 | var section_kv = appinfo.Children.Where(c => c.Name == section_key).FirstOrDefault(); 168 | return section_kv; 169 | } 170 | 171 | static uint GetSteam3AppBuildNumber(uint appId, string branch) 172 | { 173 | if (appId == INVALID_APP_ID) 174 | return 0; 175 | 176 | 177 | var depots = GetSteam3AppSection(appId, EAppInfoSection.Depots); 178 | var branches = depots["branches"]; 179 | var node = branches[branch]; 180 | 181 | if (node == KeyValue.Invalid) 182 | return 0; 183 | 184 | var buildid = node["buildid"]; 185 | 186 | if (buildid == KeyValue.Invalid) 187 | return 0; 188 | 189 | return uint.Parse(buildid.Value); 190 | } 191 | 192 | static uint GetSteam3DepotProxyAppId(uint depotId, uint appId) 193 | { 194 | var depots = GetSteam3AppSection(appId, EAppInfoSection.Depots); 195 | var depotChild = depots[depotId.ToString()]; 196 | 197 | if (depotChild == KeyValue.Invalid) 198 | return INVALID_APP_ID; 199 | 200 | if (depotChild["depotfromapp"] == KeyValue.Invalid) 201 | return INVALID_APP_ID; 202 | 203 | return depotChild["depotfromapp"].AsUnsignedInteger(); 204 | } 205 | 206 | static async Task GetSteam3DepotManifest(uint depotId, uint appId, string branch) 207 | { 208 | var depots = GetSteam3AppSection(appId, EAppInfoSection.Depots); 209 | var depotChild = depots[depotId.ToString()]; 210 | 211 | if (depotChild == KeyValue.Invalid) 212 | return INVALID_MANIFEST_ID; 213 | 214 | // Shared depots can either provide manifests, or leave you relying on their parent app. 215 | // It seems that with the latter, "sharedinstall" will exist (and equals 2 in the one existance I know of). 216 | // Rather than relay on the unknown sharedinstall key, just look for manifests. Test cases: 111710, 346680. 217 | if (depotChild["manifests"] == KeyValue.Invalid && depotChild["depotfromapp"] != KeyValue.Invalid) 218 | { 219 | var otherAppId = depotChild["depotfromapp"].AsUnsignedInteger(); 220 | if (otherAppId == appId) 221 | { 222 | // This shouldn't ever happen, but ya never know with Valve. Don't infinite loop. 223 | Console.WriteLine("App {0}, Depot {1} has depotfromapp of {2}!", 224 | appId, depotId, otherAppId); 225 | return INVALID_MANIFEST_ID; 226 | } 227 | 228 | await steam3.RequestAppInfo(otherAppId); 229 | 230 | return await GetSteam3DepotManifest(depotId, otherAppId, branch); 231 | } 232 | 233 | var manifests = depotChild["manifests"]; 234 | 235 | if (manifests.Children.Count == 0) 236 | return INVALID_MANIFEST_ID; 237 | 238 | var node = manifests[branch]["gid"]; 239 | 240 | // Non passworded branch, found the manifest 241 | if (node.Value != null) 242 | return ulong.Parse(node.Value); 243 | 244 | // If we requested public branch and it had no manifest, nothing to do 245 | if (string.Equals(branch, DEFAULT_BRANCH, StringComparison.OrdinalIgnoreCase)) 246 | return INVALID_MANIFEST_ID; 247 | 248 | // Either the branch just doesn't exist, or it has a password 249 | if (string.IsNullOrEmpty(Config.BetaPassword)) 250 | { 251 | Console.WriteLine($"Branch {branch} for depot {depotId} was not found, either it does not exist or it has a password."); 252 | return INVALID_MANIFEST_ID; 253 | } 254 | 255 | if (!steam3.AppBetaPasswords.ContainsKey(branch)) 256 | { 257 | // Submit the password to Steam now to get encryption keys 258 | await steam3.CheckAppBetaPassword(appId, Config.BetaPassword); 259 | 260 | if (!steam3.AppBetaPasswords.ContainsKey(branch)) 261 | { 262 | Console.WriteLine($"Error: Password was invalid for branch {branch} (or the branch does not exist)"); 263 | return INVALID_MANIFEST_ID; 264 | } 265 | } 266 | 267 | // Got the password, request private depot section 268 | // TODO: We're probably repeating this request for every depot? 269 | var privateDepotSection = await steam3.GetPrivateBetaDepotSection(appId, branch); 270 | 271 | // Now repeat the same code to get the manifest gid from depot section 272 | depotChild = privateDepotSection[depotId.ToString()]; 273 | 274 | if (depotChild == KeyValue.Invalid) 275 | return INVALID_MANIFEST_ID; 276 | 277 | manifests = depotChild["manifests"]; 278 | 279 | if (manifests.Children.Count == 0) 280 | return INVALID_MANIFEST_ID; 281 | 282 | node = manifests[branch]["gid"]; 283 | 284 | if (node.Value == null) 285 | return INVALID_MANIFEST_ID; 286 | 287 | return ulong.Parse(node.Value); 288 | } 289 | 290 | static string GetAppName(uint appId) 291 | { 292 | var info = GetSteam3AppSection(appId, EAppInfoSection.Common); 293 | if (info == null) 294 | return string.Empty; 295 | 296 | return info["name"].AsString(); 297 | } 298 | 299 | public static bool InitializeSteam3(string username, string password) 300 | { 301 | string loginToken = null; 302 | 303 | if (username != null && Config.RememberPassword) 304 | { 305 | _ = AccountSettingsStore.Instance.LoginTokens.TryGetValue(username, out loginToken); 306 | } 307 | 308 | steam3 = new Steam3Session( 309 | new SteamUser.LogOnDetails 310 | { 311 | Username = username, 312 | Password = loginToken == null ? password : null, 313 | ShouldRememberPassword = Config.RememberPassword, 314 | AccessToken = loginToken, 315 | LoginID = Config.LoginID ?? 0x534B32, // "SK2" 316 | } 317 | ); 318 | 319 | if (!steam3.WaitForCredentials()) 320 | { 321 | Console.WriteLine("Unable to get steam3 credentials."); 322 | return false; 323 | } 324 | 325 | Task.Run(steam3.TickCallbacks); 326 | 327 | return true; 328 | } 329 | 330 | public static void ShutdownSteam3() 331 | { 332 | if (steam3 == null) 333 | return; 334 | 335 | steam3.Disconnect(); 336 | } 337 | 338 | public static async Task DownloadPubfileAsync(uint appId, ulong publishedFileId) 339 | { 340 | var details = await steam3.GetPublishedFileDetails(appId, publishedFileId); 341 | 342 | if (!string.IsNullOrEmpty(details?.file_url)) 343 | { 344 | await DownloadWebFile(appId, details.filename, details.file_url); 345 | } 346 | else if (details?.hcontent_file > 0) 347 | { 348 | await DownloadAppAsync(appId, new List<(uint, ulong)> { (appId, details.hcontent_file) }, DEFAULT_BRANCH, null, null, null, false, true); 349 | } 350 | else 351 | { 352 | Console.WriteLine("Unable to locate manifest ID for published file {0}", publishedFileId); 353 | } 354 | } 355 | 356 | public static async Task DownloadUGCAsync(uint appId, ulong ugcId) 357 | { 358 | SteamCloud.UGCDetailsCallback details = null; 359 | 360 | if (steam3.steamUser.SteamID.AccountType != EAccountType.AnonUser) 361 | { 362 | details = await steam3.GetUGCDetails(ugcId); 363 | } 364 | else 365 | { 366 | Console.WriteLine($"Unable to query UGC details for {ugcId} from an anonymous account"); 367 | } 368 | 369 | if (!string.IsNullOrEmpty(details?.URL)) 370 | { 371 | await DownloadWebFile(appId, details.FileName, details.URL); 372 | } 373 | else 374 | { 375 | await DownloadAppAsync(appId, [(appId, ugcId)], DEFAULT_BRANCH, null, null, null, false, true); 376 | } 377 | } 378 | 379 | private static async Task DownloadWebFile(uint appId, string fileName, string url) 380 | { 381 | if (!CreateDirectories(appId, 0, out var installDir)) 382 | { 383 | Console.WriteLine("Error: Unable to create install directories!"); 384 | return; 385 | } 386 | 387 | var stagingDir = Path.Combine(installDir, STAGING_DIR); 388 | var fileStagingPath = Path.Combine(stagingDir, fileName); 389 | var fileFinalPath = Path.Combine(installDir, fileName); 390 | 391 | Directory.CreateDirectory(Path.GetDirectoryName(fileFinalPath)); 392 | Directory.CreateDirectory(Path.GetDirectoryName(fileStagingPath)); 393 | 394 | using (var file = File.OpenWrite(fileStagingPath)) 395 | using (var client = HttpClientFactory.CreateHttpClient()) 396 | { 397 | Console.WriteLine("Downloading {0}", fileName); 398 | var responseStream = await client.GetStreamAsync(url); 399 | await responseStream.CopyToAsync(file); 400 | } 401 | 402 | if (File.Exists(fileFinalPath)) 403 | { 404 | File.Delete(fileFinalPath); 405 | } 406 | 407 | File.Move(fileStagingPath, fileFinalPath); 408 | } 409 | 410 | public static async Task DownloadAppAsync(uint appId, List<(uint depotId, ulong manifestId)> depotManifestIds, string branch, string os, string arch, string language, bool lv, bool isUgc) 411 | { 412 | cdnPool = new CDNClientPool(steam3, appId); 413 | 414 | // Load our configuration data containing the depots currently installed 415 | var configPath = Config.InstallDirectory; 416 | if (string.IsNullOrWhiteSpace(configPath)) 417 | { 418 | configPath = DEFAULT_DOWNLOAD_DIR; 419 | } 420 | 421 | Directory.CreateDirectory(Path.Combine(configPath, CONFIG_DIR)); 422 | DepotConfigStore.LoadFromFile(Path.Combine(configPath, CONFIG_DIR, "depot.config")); 423 | 424 | await steam3?.RequestAppInfo(appId); 425 | 426 | if (!await AccountHasAccess(appId, appId)) 427 | { 428 | if (steam3.steamUser.SteamID.AccountType != EAccountType.AnonUser && await steam3.RequestFreeAppLicense(appId)) 429 | { 430 | Console.WriteLine("Obtained FreeOnDemand license for app {0}", appId); 431 | 432 | // Fetch app info again in case we didn't get it fully without a license. 433 | await steam3.RequestAppInfo(appId, true); 434 | } 435 | else 436 | { 437 | var contentName = GetAppName(appId); 438 | throw new ContentDownloaderException(string.Format("App {0} ({1}) is not available from this account.", appId, contentName)); 439 | } 440 | } 441 | 442 | var hasSpecificDepots = depotManifestIds.Count > 0; 443 | var depotIdsFound = new List(); 444 | var depotIdsExpected = depotManifestIds.Select(x => x.depotId).ToList(); 445 | var depots = GetSteam3AppSection(appId, EAppInfoSection.Depots); 446 | 447 | if (isUgc) 448 | { 449 | var workshopDepot = depots["workshopdepot"].AsUnsignedInteger(); 450 | if (workshopDepot != 0 && !depotIdsExpected.Contains(workshopDepot)) 451 | { 452 | depotIdsExpected.Add(workshopDepot); 453 | depotManifestIds = depotManifestIds.Select(pair => (workshopDepot, pair.manifestId)).ToList(); 454 | } 455 | 456 | depotIdsFound.AddRange(depotIdsExpected); 457 | } 458 | else 459 | { 460 | Console.WriteLine("Using app branch: '{0}'.", branch); 461 | 462 | if (depots != null) 463 | { 464 | foreach (var depotSection in depots.Children) 465 | { 466 | var id = INVALID_DEPOT_ID; 467 | if (depotSection.Children.Count == 0) 468 | continue; 469 | 470 | if (!uint.TryParse(depotSection.Name, out id)) 471 | continue; 472 | 473 | if (hasSpecificDepots && !depotIdsExpected.Contains(id)) 474 | continue; 475 | 476 | if (!hasSpecificDepots) 477 | { 478 | var depotConfig = depotSection["config"]; 479 | if (depotConfig != KeyValue.Invalid) 480 | { 481 | if (!Config.DownloadAllPlatforms && 482 | depotConfig["oslist"] != KeyValue.Invalid && 483 | !string.IsNullOrWhiteSpace(depotConfig["oslist"].Value)) 484 | { 485 | var oslist = depotConfig["oslist"].Value.Split(','); 486 | if (Array.IndexOf(oslist, os ?? Util.GetSteamOS()) == -1) 487 | continue; 488 | } 489 | 490 | if (!Config.DownloadAllArchs && 491 | depotConfig["osarch"] != KeyValue.Invalid && 492 | !string.IsNullOrWhiteSpace(depotConfig["osarch"].Value)) 493 | { 494 | var depotArch = depotConfig["osarch"].Value; 495 | if (depotArch != (arch ?? Util.GetSteamArch())) 496 | continue; 497 | } 498 | 499 | if (!Config.DownloadAllLanguages && 500 | depotConfig["language"] != KeyValue.Invalid && 501 | !string.IsNullOrWhiteSpace(depotConfig["language"].Value)) 502 | { 503 | var depotLang = depotConfig["language"].Value; 504 | if (depotLang != (language ?? "english")) 505 | continue; 506 | } 507 | 508 | if (!lv && 509 | depotConfig["lowviolence"] != KeyValue.Invalid && 510 | depotConfig["lowviolence"].AsBoolean()) 511 | continue; 512 | } 513 | } 514 | 515 | depotIdsFound.Add(id); 516 | 517 | if (!hasSpecificDepots) 518 | depotManifestIds.Add((id, INVALID_MANIFEST_ID)); 519 | } 520 | } 521 | 522 | if (depotManifestIds.Count == 0 && !hasSpecificDepots) 523 | { 524 | throw new ContentDownloaderException(string.Format("Couldn't find any depots to download for app {0}", appId)); 525 | } 526 | 527 | if (depotIdsFound.Count < depotIdsExpected.Count) 528 | { 529 | var remainingDepotIds = depotIdsExpected.Except(depotIdsFound); 530 | throw new ContentDownloaderException(string.Format("Depot {0} not listed for app {1}", string.Join(", ", remainingDepotIds), appId)); 531 | } 532 | } 533 | 534 | var infos = new List(); 535 | 536 | foreach (var (depotId, manifestId) in depotManifestIds) 537 | { 538 | var info = await GetDepotInfo(depotId, appId, manifestId, branch); 539 | if (info != null) 540 | { 541 | infos.Add(info); 542 | } 543 | } 544 | 545 | Console.WriteLine(); 546 | 547 | try 548 | { 549 | await DownloadSteam3Async(infos).ConfigureAwait(false); 550 | } 551 | catch (OperationCanceledException) 552 | { 553 | Console.WriteLine("App {0} was not completely downloaded.", appId); 554 | throw; 555 | } 556 | } 557 | 558 | static async Task GetDepotInfo(uint depotId, uint appId, ulong manifestId, string branch) 559 | { 560 | if (steam3 != null && appId != INVALID_APP_ID) 561 | { 562 | await steam3.RequestAppInfo(appId); 563 | } 564 | 565 | if (!await AccountHasAccess(appId, depotId)) 566 | { 567 | Console.WriteLine("Depot {0} is not available from this account.", depotId); 568 | 569 | return null; 570 | } 571 | 572 | if (manifestId == INVALID_MANIFEST_ID) 573 | { 574 | manifestId = await GetSteam3DepotManifest(depotId, appId, branch); 575 | if (manifestId == INVALID_MANIFEST_ID && !string.Equals(branch, DEFAULT_BRANCH, StringComparison.OrdinalIgnoreCase)) 576 | { 577 | Console.WriteLine("Warning: Depot {0} does not have branch named \"{1}\". Trying {2} branch.", depotId, branch, DEFAULT_BRANCH); 578 | branch = DEFAULT_BRANCH; 579 | manifestId = await GetSteam3DepotManifest(depotId, appId, branch); 580 | } 581 | 582 | if (manifestId == INVALID_MANIFEST_ID) 583 | { 584 | Console.WriteLine("Depot {0} missing public subsection or manifest section.", depotId); 585 | return null; 586 | } 587 | } 588 | 589 | await steam3.RequestDepotKey(depotId, appId); 590 | if (!steam3.DepotKeys.TryGetValue(depotId, out var depotKey)) 591 | { 592 | Console.WriteLine("No valid depot key for {0}, unable to download.", depotId); 593 | return null; 594 | } 595 | 596 | var uVersion = GetSteam3AppBuildNumber(appId, branch); 597 | 598 | if (!CreateDirectories(depotId, uVersion, out var installDir)) 599 | { 600 | Console.WriteLine("Error: Unable to create install directories!"); 601 | return null; 602 | } 603 | 604 | // For depots that are proxied through depotfromapp, we still need to resolve the proxy app id, unless the app is freetodownload 605 | var containingAppId = appId; 606 | var proxyAppId = GetSteam3DepotProxyAppId(depotId, appId); 607 | if (proxyAppId != INVALID_APP_ID) 608 | { 609 | var common = GetSteam3AppSection(appId, EAppInfoSection.Common); 610 | if (common == null || !common["FreeToDownload"].AsBoolean()) 611 | { 612 | containingAppId = proxyAppId; 613 | } 614 | } 615 | 616 | return new DepotDownloadInfo(depotId, containingAppId, manifestId, branch, installDir, depotKey); 617 | } 618 | 619 | private class ChunkMatch(DepotManifest.ChunkData oldChunk, DepotManifest.ChunkData newChunk) 620 | { 621 | public DepotManifest.ChunkData OldChunk { get; } = oldChunk; 622 | public DepotManifest.ChunkData NewChunk { get; } = newChunk; 623 | } 624 | 625 | private class DepotFilesData 626 | { 627 | public DepotDownloadInfo depotDownloadInfo; 628 | public DepotDownloadCounter depotCounter; 629 | public string stagingDir; 630 | public DepotManifest manifest; 631 | public DepotManifest previousManifest; 632 | public List filteredFiles; 633 | public HashSet allFileNames; 634 | } 635 | 636 | private class FileStreamData 637 | { 638 | public FileStream fileStream; 639 | public SemaphoreSlim fileLock; 640 | public int chunksToDownload; 641 | } 642 | 643 | private class GlobalDownloadCounter 644 | { 645 | public ulong completeDownloadSize; 646 | public ulong totalBytesCompressed; 647 | public ulong totalBytesUncompressed; 648 | } 649 | 650 | private class DepotDownloadCounter 651 | { 652 | public ulong completeDownloadSize; 653 | public ulong sizeDownloaded; 654 | public ulong depotBytesCompressed; 655 | public ulong depotBytesUncompressed; 656 | } 657 | 658 | private static async Task DownloadSteam3Async(List depots) 659 | { 660 | Ansi.Progress(Ansi.ProgressState.Indeterminate); 661 | 662 | await cdnPool.UpdateServerList(); 663 | 664 | var cts = new CancellationTokenSource(); 665 | var downloadCounter = new GlobalDownloadCounter(); 666 | var depotsToDownload = new List(depots.Count); 667 | var allFileNamesAllDepots = new HashSet(); 668 | 669 | // First, fetch all the manifests for each depot (including previous manifests) and perform the initial setup 670 | foreach (var depot in depots) 671 | { 672 | var depotFileData = await ProcessDepotManifestAndFiles(cts, depot, downloadCounter); 673 | 674 | if (depotFileData != null) 675 | { 676 | depotsToDownload.Add(depotFileData); 677 | allFileNamesAllDepots.UnionWith(depotFileData.allFileNames); 678 | } 679 | 680 | cts.Token.ThrowIfCancellationRequested(); 681 | } 682 | 683 | // If we're about to write all the files to the same directory, we will need to first de-duplicate any files by path 684 | // This is in last-depot-wins order, from Steam or the list of depots supplied by the user 685 | if (!string.IsNullOrWhiteSpace(Config.InstallDirectory) && depotsToDownload.Count > 0) 686 | { 687 | var claimedFileNames = new HashSet(); 688 | 689 | for (var i = depotsToDownload.Count - 1; i >= 0; i--) 690 | { 691 | // For each depot, remove all files from the list that have been claimed by a later depot 692 | depotsToDownload[i].filteredFiles.RemoveAll(file => claimedFileNames.Contains(file.FileName)); 693 | 694 | claimedFileNames.UnionWith(depotsToDownload[i].allFileNames); 695 | } 696 | } 697 | 698 | foreach (var depotFileData in depotsToDownload) 699 | { 700 | await DownloadSteam3AsyncDepotFiles(cts, downloadCounter, depotFileData, allFileNamesAllDepots); 701 | } 702 | 703 | Ansi.Progress(Ansi.ProgressState.Hidden); 704 | 705 | Console.WriteLine("Total downloaded: {0} bytes ({1} bytes uncompressed) from {2} depots", 706 | downloadCounter.totalBytesCompressed, downloadCounter.totalBytesUncompressed, depots.Count); 707 | } 708 | 709 | private static async Task ProcessDepotManifestAndFiles(CancellationTokenSource cts, DepotDownloadInfo depot, GlobalDownloadCounter downloadCounter) 710 | { 711 | var depotCounter = new DepotDownloadCounter(); 712 | 713 | Console.WriteLine("Processing depot {0}", depot.DepotId); 714 | 715 | DepotManifest oldManifest = null; 716 | DepotManifest newManifest = null; 717 | var configDir = Path.Combine(depot.InstallDir, CONFIG_DIR); 718 | 719 | var lastManifestId = INVALID_MANIFEST_ID; 720 | DepotConfigStore.Instance.InstalledManifestIDs.TryGetValue(depot.DepotId, out lastManifestId); 721 | 722 | // In case we have an early exit, this will force equiv of verifyall next run. 723 | DepotConfigStore.Instance.InstalledManifestIDs[depot.DepotId] = INVALID_MANIFEST_ID; 724 | DepotConfigStore.Save(); 725 | 726 | if (lastManifestId != INVALID_MANIFEST_ID) 727 | { 728 | // We only have to show this warning if the old manifest ID was different 729 | var badHashWarning = (lastManifestId != depot.ManifestId); 730 | oldManifest = Util.LoadManifestFromFile(configDir, depot.DepotId, lastManifestId, badHashWarning); 731 | } 732 | 733 | if (lastManifestId == depot.ManifestId && oldManifest != null) 734 | { 735 | newManifest = oldManifest; 736 | Console.WriteLine("Already have manifest {0} for depot {1}.", depot.ManifestId, depot.DepotId); 737 | } 738 | else 739 | { 740 | newManifest = Util.LoadManifestFromFile(configDir, depot.DepotId, depot.ManifestId, true); 741 | 742 | if (newManifest != null) 743 | { 744 | Console.WriteLine("Already have manifest {0} for depot {1}.", depot.ManifestId, depot.DepotId); 745 | } 746 | else 747 | { 748 | Console.WriteLine($"Downloading depot {depot.DepotId} manifest"); 749 | 750 | ulong manifestRequestCode = 0; 751 | var manifestRequestCodeExpiration = DateTime.MinValue; 752 | 753 | do 754 | { 755 | cts.Token.ThrowIfCancellationRequested(); 756 | 757 | Server connection = null; 758 | 759 | try 760 | { 761 | connection = cdnPool.GetConnection(); 762 | 763 | string cdnToken = null; 764 | if (steam3.CDNAuthTokens.TryGetValue((depot.DepotId, connection.Host), out var authTokenCallbackPromise)) 765 | { 766 | var result = await authTokenCallbackPromise.Task; 767 | cdnToken = result.Token; 768 | } 769 | 770 | var now = DateTime.Now; 771 | 772 | // In order to download this manifest, we need the current manifest request code 773 | // The manifest request code is only valid for a specific period in time 774 | if (manifestRequestCode == 0 || now >= manifestRequestCodeExpiration) 775 | { 776 | manifestRequestCode = await steam3.GetDepotManifestRequestCodeAsync( 777 | depot.DepotId, 778 | depot.AppId, 779 | depot.ManifestId, 780 | depot.Branch); 781 | // This code will hopefully be valid for one period following the issuing period 782 | manifestRequestCodeExpiration = now.Add(TimeSpan.FromMinutes(5)); 783 | 784 | // If we could not get the manifest code, this is a fatal error 785 | if (manifestRequestCode == 0) 786 | { 787 | cts.Cancel(); 788 | } 789 | } 790 | 791 | DebugLog.WriteLine("ContentDownloader", 792 | "Downloading manifest {0} from {1} with {2}", 793 | depot.ManifestId, 794 | connection, 795 | cdnPool.ProxyServer != null ? cdnPool.ProxyServer : "no proxy"); 796 | newManifest = await cdnPool.CDNClient.DownloadManifestAsync( 797 | depot.DepotId, 798 | depot.ManifestId, 799 | manifestRequestCode, 800 | connection, 801 | depot.DepotKey, 802 | cdnPool.ProxyServer, 803 | cdnToken).ConfigureAwait(false); 804 | 805 | cdnPool.ReturnConnection(connection); 806 | } 807 | catch (TaskCanceledException) 808 | { 809 | Console.WriteLine("Connection timeout downloading depot manifest {0} {1}. Retrying.", depot.DepotId, depot.ManifestId); 810 | } 811 | catch (SteamKitWebRequestException e) 812 | { 813 | // If the CDN returned 403, attempt to get a cdn auth if we didn't yet 814 | if (e.StatusCode == HttpStatusCode.Forbidden && !steam3.CDNAuthTokens.ContainsKey((depot.DepotId, connection.Host))) 815 | { 816 | await steam3.RequestCDNAuthToken(depot.AppId, depot.DepotId, connection); 817 | 818 | cdnPool.ReturnConnection(connection); 819 | 820 | continue; 821 | } 822 | 823 | cdnPool.ReturnBrokenConnection(connection); 824 | 825 | if (e.StatusCode == HttpStatusCode.Unauthorized || e.StatusCode == HttpStatusCode.Forbidden) 826 | { 827 | Console.WriteLine("Encountered {2} for depot manifest {0} {1}. Aborting.", depot.DepotId, depot.ManifestId, (int)e.StatusCode); 828 | break; 829 | } 830 | 831 | if (e.StatusCode == HttpStatusCode.NotFound) 832 | { 833 | Console.WriteLine("Encountered 404 for depot manifest {0} {1}. Aborting.", depot.DepotId, depot.ManifestId); 834 | break; 835 | } 836 | 837 | Console.WriteLine("Encountered error downloading depot manifest {0} {1}: {2}", depot.DepotId, depot.ManifestId, e.StatusCode); 838 | } 839 | catch (OperationCanceledException) 840 | { 841 | break; 842 | } 843 | catch (Exception e) 844 | { 845 | cdnPool.ReturnBrokenConnection(connection); 846 | Console.WriteLine("Encountered error downloading manifest for depot {0} {1}: {2}", depot.DepotId, depot.ManifestId, e.Message); 847 | } 848 | } while (newManifest == null); 849 | 850 | if (newManifest == null) 851 | { 852 | Console.WriteLine("\nUnable to download manifest {0} for depot {1}", depot.ManifestId, depot.DepotId); 853 | cts.Cancel(); 854 | } 855 | 856 | // Throw the cancellation exception if requested so that this task is marked failed 857 | cts.Token.ThrowIfCancellationRequested(); 858 | 859 | Util.SaveManifestToFile(configDir, newManifest); 860 | } 861 | } 862 | 863 | Console.WriteLine("Manifest {0} ({1})", depot.ManifestId, newManifest.CreationTime); 864 | 865 | if (Config.DownloadManifestOnly) 866 | { 867 | DumpManifestToTextFile(depot, newManifest); 868 | return null; 869 | } 870 | 871 | var stagingDir = Path.Combine(depot.InstallDir, STAGING_DIR); 872 | 873 | var filesAfterExclusions = newManifest.Files.AsParallel().Where(f => TestIsFileIncluded(f.FileName)).ToList(); 874 | var allFileNames = new HashSet(filesAfterExclusions.Count); 875 | 876 | // Pre-process 877 | filesAfterExclusions.ForEach(file => 878 | { 879 | allFileNames.Add(file.FileName); 880 | 881 | var fileFinalPath = Path.Combine(depot.InstallDir, file.FileName); 882 | var fileStagingPath = Path.Combine(stagingDir, file.FileName); 883 | 884 | if (file.Flags.HasFlag(EDepotFileFlag.Directory)) 885 | { 886 | Directory.CreateDirectory(fileFinalPath); 887 | Directory.CreateDirectory(fileStagingPath); 888 | } 889 | else 890 | { 891 | // Some manifests don't explicitly include all necessary directories 892 | Directory.CreateDirectory(Path.GetDirectoryName(fileFinalPath)); 893 | Directory.CreateDirectory(Path.GetDirectoryName(fileStagingPath)); 894 | 895 | downloadCounter.completeDownloadSize += file.TotalSize; 896 | depotCounter.completeDownloadSize += file.TotalSize; 897 | } 898 | }); 899 | 900 | return new DepotFilesData 901 | { 902 | depotDownloadInfo = depot, 903 | depotCounter = depotCounter, 904 | stagingDir = stagingDir, 905 | manifest = newManifest, 906 | previousManifest = oldManifest, 907 | filteredFiles = filesAfterExclusions, 908 | allFileNames = allFileNames 909 | }; 910 | } 911 | 912 | private static async Task DownloadSteam3AsyncDepotFiles(CancellationTokenSource cts, 913 | GlobalDownloadCounter downloadCounter, DepotFilesData depotFilesData, HashSet allFileNamesAllDepots) 914 | { 915 | var depot = depotFilesData.depotDownloadInfo; 916 | var depotCounter = depotFilesData.depotCounter; 917 | 918 | Console.WriteLine("Downloading depot {0}", depot.DepotId); 919 | 920 | var files = depotFilesData.filteredFiles.Where(f => !f.Flags.HasFlag(EDepotFileFlag.Directory)).ToArray(); 921 | var networkChunkQueue = new ConcurrentQueue<(FileStreamData fileStreamData, DepotManifest.FileData fileData, DepotManifest.ChunkData chunk)>(); 922 | 923 | var parallelOptions = new ParallelOptions 924 | { 925 | MaxDegreeOfParallelism = Config.MaxDownloads, 926 | CancellationToken = cts.Token 927 | }; 928 | 929 | await Parallel.ForEachAsync(files, parallelOptions, async (file, cancellationToken) => 930 | { 931 | await Task.Yield(); 932 | DownloadSteam3AsyncDepotFile(cts, downloadCounter, depotFilesData, file, networkChunkQueue); 933 | }); 934 | 935 | await Parallel.ForEachAsync(networkChunkQueue, parallelOptions, async (q, cancellationToken) => 936 | { 937 | await DownloadSteam3AsyncDepotFileChunk( 938 | cts, downloadCounter, depotFilesData, 939 | q.fileData, q.fileStreamData, q.chunk 940 | ); 941 | }); 942 | 943 | // Check for deleted files if updating the depot. 944 | if (depotFilesData.previousManifest != null) 945 | { 946 | var previousFilteredFiles = depotFilesData.previousManifest.Files.AsParallel().Where(f => TestIsFileIncluded(f.FileName)).Select(f => f.FileName).ToHashSet(); 947 | 948 | // Check if we are writing to a single output directory. If not, each depot folder is managed independently 949 | if (string.IsNullOrWhiteSpace(Config.InstallDirectory)) 950 | { 951 | // Of the list of files in the previous manifest, remove any file names that exist in the current set of all file names 952 | previousFilteredFiles.ExceptWith(depotFilesData.allFileNames); 953 | } 954 | else 955 | { 956 | // Of the list of files in the previous manifest, remove any file names that exist in the current set of all file names across all depots being downloaded 957 | previousFilteredFiles.ExceptWith(allFileNamesAllDepots); 958 | } 959 | 960 | foreach (var existingFileName in previousFilteredFiles) 961 | { 962 | var fileFinalPath = Path.Combine(depot.InstallDir, existingFileName); 963 | 964 | if (!File.Exists(fileFinalPath)) 965 | continue; 966 | 967 | File.Delete(fileFinalPath); 968 | Console.WriteLine("Deleted {0}", fileFinalPath); 969 | } 970 | } 971 | 972 | DepotConfigStore.Instance.InstalledManifestIDs[depot.DepotId] = depot.ManifestId; 973 | DepotConfigStore.Save(); 974 | 975 | Console.WriteLine("Depot {0} - Downloaded {1} bytes ({2} bytes uncompressed)", depot.DepotId, depotCounter.depotBytesCompressed, depotCounter.depotBytesUncompressed); 976 | } 977 | 978 | private static void DownloadSteam3AsyncDepotFile( 979 | CancellationTokenSource cts, 980 | GlobalDownloadCounter downloadCounter, 981 | DepotFilesData depotFilesData, 982 | DepotManifest.FileData file, 983 | ConcurrentQueue<(FileStreamData, DepotManifest.FileData, DepotManifest.ChunkData)> networkChunkQueue) 984 | { 985 | cts.Token.ThrowIfCancellationRequested(); 986 | 987 | var depot = depotFilesData.depotDownloadInfo; 988 | var stagingDir = depotFilesData.stagingDir; 989 | var depotDownloadCounter = depotFilesData.depotCounter; 990 | var oldProtoManifest = depotFilesData.previousManifest; 991 | DepotManifest.FileData oldManifestFile = null; 992 | if (oldProtoManifest != null) 993 | { 994 | oldManifestFile = oldProtoManifest.Files.SingleOrDefault(f => f.FileName == file.FileName); 995 | } 996 | 997 | var fileFinalPath = Path.Combine(depot.InstallDir, file.FileName); 998 | var fileStagingPath = Path.Combine(stagingDir, file.FileName); 999 | 1000 | // This may still exist if the previous run exited before cleanup 1001 | if (File.Exists(fileStagingPath)) 1002 | { 1003 | File.Delete(fileStagingPath); 1004 | } 1005 | 1006 | List neededChunks; 1007 | var fi = new FileInfo(fileFinalPath); 1008 | var fileDidExist = fi.Exists; 1009 | if (!fileDidExist) 1010 | { 1011 | Console.WriteLine("Pre-allocating {0}", fileFinalPath); 1012 | 1013 | // create new file. need all chunks 1014 | using var fs = File.Create(fileFinalPath); 1015 | try 1016 | { 1017 | fs.SetLength((long)file.TotalSize); 1018 | } 1019 | catch (IOException ex) 1020 | { 1021 | throw new ContentDownloaderException(string.Format("Failed to allocate file {0}: {1}", fileFinalPath, ex.Message)); 1022 | } 1023 | 1024 | neededChunks = new List(file.Chunks); 1025 | } 1026 | else 1027 | { 1028 | // open existing 1029 | if (oldManifestFile != null) 1030 | { 1031 | neededChunks = []; 1032 | 1033 | var hashMatches = oldManifestFile.FileHash.SequenceEqual(file.FileHash); 1034 | if (Config.VerifyAll || !hashMatches) 1035 | { 1036 | // we have a version of this file, but it doesn't fully match what we want 1037 | if (Config.VerifyAll) 1038 | { 1039 | Console.WriteLine("Validating {0}", fileFinalPath); 1040 | } 1041 | 1042 | var matchingChunks = new List(); 1043 | 1044 | foreach (var chunk in file.Chunks) 1045 | { 1046 | var oldChunk = oldManifestFile.Chunks.FirstOrDefault(c => c.ChunkID.SequenceEqual(chunk.ChunkID)); 1047 | if (oldChunk != null) 1048 | { 1049 | matchingChunks.Add(new ChunkMatch(oldChunk, chunk)); 1050 | } 1051 | else 1052 | { 1053 | neededChunks.Add(chunk); 1054 | } 1055 | } 1056 | 1057 | var orderedChunks = matchingChunks.OrderBy(x => x.OldChunk.Offset); 1058 | 1059 | var copyChunks = new List(); 1060 | 1061 | using (var fsOld = File.Open(fileFinalPath, FileMode.Open)) 1062 | { 1063 | foreach (var match in orderedChunks) 1064 | { 1065 | fsOld.Seek((long)match.OldChunk.Offset, SeekOrigin.Begin); 1066 | 1067 | var adler = Util.AdlerHash(fsOld, (int)match.OldChunk.UncompressedLength); 1068 | if (!adler.SequenceEqual(BitConverter.GetBytes(match.OldChunk.Checksum))) 1069 | { 1070 | neededChunks.Add(match.NewChunk); 1071 | } 1072 | else 1073 | { 1074 | copyChunks.Add(match); 1075 | } 1076 | } 1077 | } 1078 | 1079 | if (!hashMatches || neededChunks.Count > 0) 1080 | { 1081 | File.Move(fileFinalPath, fileStagingPath); 1082 | 1083 | using (var fsOld = File.Open(fileStagingPath, FileMode.Open)) 1084 | { 1085 | using var fs = File.Open(fileFinalPath, FileMode.Create); 1086 | try 1087 | { 1088 | fs.SetLength((long)file.TotalSize); 1089 | } 1090 | catch (IOException ex) 1091 | { 1092 | throw new ContentDownloaderException(string.Format("Failed to resize file to expected size {0}: {1}", fileFinalPath, ex.Message)); 1093 | } 1094 | 1095 | foreach (var match in copyChunks) 1096 | { 1097 | fsOld.Seek((long)match.OldChunk.Offset, SeekOrigin.Begin); 1098 | 1099 | var tmp = new byte[match.OldChunk.UncompressedLength]; 1100 | fsOld.ReadExactly(tmp); 1101 | 1102 | fs.Seek((long)match.NewChunk.Offset, SeekOrigin.Begin); 1103 | fs.Write(tmp, 0, tmp.Length); 1104 | } 1105 | } 1106 | 1107 | File.Delete(fileStagingPath); 1108 | } 1109 | } 1110 | } 1111 | else 1112 | { 1113 | // No old manifest or file not in old manifest. We must validate. 1114 | 1115 | using var fs = File.Open(fileFinalPath, FileMode.Open); 1116 | if ((ulong)fi.Length != file.TotalSize) 1117 | { 1118 | try 1119 | { 1120 | fs.SetLength((long)file.TotalSize); 1121 | } 1122 | catch (IOException ex) 1123 | { 1124 | throw new ContentDownloaderException(string.Format("Failed to allocate file {0}: {1}", fileFinalPath, ex.Message)); 1125 | } 1126 | } 1127 | 1128 | Console.WriteLine("Validating {0}", fileFinalPath); 1129 | neededChunks = Util.ValidateSteam3FileChecksums(fs, [.. file.Chunks.OrderBy(x => x.Offset)]); 1130 | } 1131 | 1132 | if (neededChunks.Count == 0) 1133 | { 1134 | lock (depotDownloadCounter) 1135 | { 1136 | depotDownloadCounter.sizeDownloaded += file.TotalSize; 1137 | Console.WriteLine("{0,6:#00.00}% {1}", (depotDownloadCounter.sizeDownloaded / (float)depotDownloadCounter.completeDownloadSize) * 100.0f, fileFinalPath); 1138 | } 1139 | 1140 | lock (downloadCounter) 1141 | { 1142 | downloadCounter.completeDownloadSize -= file.TotalSize; 1143 | } 1144 | 1145 | return; 1146 | } 1147 | 1148 | var sizeOnDisk = (file.TotalSize - (ulong)neededChunks.Select(x => (long)x.UncompressedLength).Sum()); 1149 | lock (depotDownloadCounter) 1150 | { 1151 | depotDownloadCounter.sizeDownloaded += sizeOnDisk; 1152 | } 1153 | 1154 | lock (downloadCounter) 1155 | { 1156 | downloadCounter.completeDownloadSize -= sizeOnDisk; 1157 | } 1158 | } 1159 | 1160 | var fileIsExecutable = file.Flags.HasFlag(EDepotFileFlag.Executable); 1161 | if (fileIsExecutable && (!fileDidExist || oldManifestFile == null || !oldManifestFile.Flags.HasFlag(EDepotFileFlag.Executable))) 1162 | { 1163 | PlatformUtilities.SetExecutable(fileFinalPath, true); 1164 | } 1165 | else if (!fileIsExecutable && oldManifestFile != null && oldManifestFile.Flags.HasFlag(EDepotFileFlag.Executable)) 1166 | { 1167 | PlatformUtilities.SetExecutable(fileFinalPath, false); 1168 | } 1169 | 1170 | var fileStreamData = new FileStreamData 1171 | { 1172 | fileStream = null, 1173 | fileLock = new SemaphoreSlim(1), 1174 | chunksToDownload = neededChunks.Count 1175 | }; 1176 | 1177 | foreach (var chunk in neededChunks) 1178 | { 1179 | networkChunkQueue.Enqueue((fileStreamData, file, chunk)); 1180 | } 1181 | } 1182 | 1183 | private static async Task DownloadSteam3AsyncDepotFileChunk( 1184 | CancellationTokenSource cts, 1185 | GlobalDownloadCounter downloadCounter, 1186 | DepotFilesData depotFilesData, 1187 | DepotManifest.FileData file, 1188 | FileStreamData fileStreamData, 1189 | DepotManifest.ChunkData chunk) 1190 | { 1191 | cts.Token.ThrowIfCancellationRequested(); 1192 | 1193 | var depot = depotFilesData.depotDownloadInfo; 1194 | var depotDownloadCounter = depotFilesData.depotCounter; 1195 | 1196 | var chunkID = Convert.ToHexString(chunk.ChunkID).ToLowerInvariant(); 1197 | 1198 | var written = 0; 1199 | var chunkBuffer = ArrayPool.Shared.Rent((int)chunk.UncompressedLength); 1200 | 1201 | try 1202 | { 1203 | do 1204 | { 1205 | cts.Token.ThrowIfCancellationRequested(); 1206 | 1207 | Server connection = null; 1208 | 1209 | try 1210 | { 1211 | connection = cdnPool.GetConnection(); 1212 | 1213 | string cdnToken = null; 1214 | if (steam3.CDNAuthTokens.TryGetValue((depot.DepotId, connection.Host), out var authTokenCallbackPromise)) 1215 | { 1216 | var result = await authTokenCallbackPromise.Task; 1217 | cdnToken = result.Token; 1218 | } 1219 | 1220 | DebugLog.WriteLine("ContentDownloader", "Downloading chunk {0} from {1} with {2}", chunkID, connection, cdnPool.ProxyServer != null ? cdnPool.ProxyServer : "no proxy"); 1221 | written = await cdnPool.CDNClient.DownloadDepotChunkAsync( 1222 | depot.DepotId, 1223 | chunk, 1224 | connection, 1225 | chunkBuffer, 1226 | depot.DepotKey, 1227 | cdnPool.ProxyServer, 1228 | cdnToken).ConfigureAwait(false); 1229 | 1230 | cdnPool.ReturnConnection(connection); 1231 | 1232 | break; 1233 | } 1234 | catch (TaskCanceledException) 1235 | { 1236 | Console.WriteLine("Connection timeout downloading chunk {0}", chunkID); 1237 | cdnPool.ReturnBrokenConnection(connection); 1238 | } 1239 | catch (SteamKitWebRequestException e) 1240 | { 1241 | // If the CDN returned 403, attempt to get a cdn auth if we didn't yet, 1242 | // if auth task already exists, make sure it didn't complete yet, so that it gets awaited above 1243 | if (e.StatusCode == HttpStatusCode.Forbidden && 1244 | (!steam3.CDNAuthTokens.TryGetValue((depot.DepotId, connection.Host), out var authTokenCallbackPromise) || !authTokenCallbackPromise.Task.IsCompleted)) 1245 | { 1246 | await steam3.RequestCDNAuthToken(depot.AppId, depot.DepotId, connection); 1247 | 1248 | cdnPool.ReturnConnection(connection); 1249 | 1250 | continue; 1251 | } 1252 | 1253 | cdnPool.ReturnBrokenConnection(connection); 1254 | 1255 | if (e.StatusCode == HttpStatusCode.Unauthorized || e.StatusCode == HttpStatusCode.Forbidden) 1256 | { 1257 | Console.WriteLine("Encountered {1} for chunk {0}. Aborting.", chunkID, (int)e.StatusCode); 1258 | break; 1259 | } 1260 | 1261 | Console.WriteLine("Encountered error downloading chunk {0}: {1}", chunkID, e.StatusCode); 1262 | } 1263 | catch (OperationCanceledException) 1264 | { 1265 | break; 1266 | } 1267 | catch (Exception e) 1268 | { 1269 | cdnPool.ReturnBrokenConnection(connection); 1270 | Console.WriteLine("Encountered unexpected error downloading chunk {0}: {1}", chunkID, e.Message); 1271 | } 1272 | } while (written == 0); 1273 | 1274 | if (written == 0) 1275 | { 1276 | Console.WriteLine("Failed to find any server with chunk {0} for depot {1}. Aborting.", chunkID, depot.DepotId); 1277 | cts.Cancel(); 1278 | } 1279 | 1280 | // Throw the cancellation exception if requested so that this task is marked failed 1281 | cts.Token.ThrowIfCancellationRequested(); 1282 | 1283 | try 1284 | { 1285 | await fileStreamData.fileLock.WaitAsync().ConfigureAwait(false); 1286 | 1287 | if (fileStreamData.fileStream == null) 1288 | { 1289 | var fileFinalPath = Path.Combine(depot.InstallDir, file.FileName); 1290 | fileStreamData.fileStream = File.Open(fileFinalPath, FileMode.Open); 1291 | } 1292 | 1293 | fileStreamData.fileStream.Seek((long)chunk.Offset, SeekOrigin.Begin); 1294 | await fileStreamData.fileStream.WriteAsync(chunkBuffer.AsMemory(0, written), cts.Token); 1295 | } 1296 | finally 1297 | { 1298 | fileStreamData.fileLock.Release(); 1299 | } 1300 | } 1301 | finally 1302 | { 1303 | ArrayPool.Shared.Return(chunkBuffer); 1304 | } 1305 | 1306 | var remainingChunks = Interlocked.Decrement(ref fileStreamData.chunksToDownload); 1307 | if (remainingChunks == 0) 1308 | { 1309 | fileStreamData.fileStream?.Dispose(); 1310 | fileStreamData.fileLock.Dispose(); 1311 | } 1312 | 1313 | ulong sizeDownloaded = 0; 1314 | lock (depotDownloadCounter) 1315 | { 1316 | sizeDownloaded = depotDownloadCounter.sizeDownloaded + (ulong)written; 1317 | depotDownloadCounter.sizeDownloaded = sizeDownloaded; 1318 | depotDownloadCounter.depotBytesCompressed += chunk.CompressedLength; 1319 | depotDownloadCounter.depotBytesUncompressed += chunk.UncompressedLength; 1320 | } 1321 | 1322 | lock (downloadCounter) 1323 | { 1324 | downloadCounter.totalBytesCompressed += chunk.CompressedLength; 1325 | downloadCounter.totalBytesUncompressed += chunk.UncompressedLength; 1326 | 1327 | Ansi.Progress(downloadCounter.totalBytesUncompressed, downloadCounter.completeDownloadSize); 1328 | } 1329 | 1330 | if (remainingChunks == 0) 1331 | { 1332 | var fileFinalPath = Path.Combine(depot.InstallDir, file.FileName); 1333 | Console.WriteLine("{0,6:#00.00}% {1}", (sizeDownloaded / (float)depotDownloadCounter.completeDownloadSize) * 100.0f, fileFinalPath); 1334 | } 1335 | } 1336 | 1337 | class ChunkIdComparer : IEqualityComparer 1338 | { 1339 | public bool Equals(byte[] x, byte[] y) 1340 | { 1341 | if (ReferenceEquals(x, y)) return true; 1342 | if (x == null || y == null) return false; 1343 | return x.SequenceEqual(y); 1344 | } 1345 | 1346 | public int GetHashCode(byte[] obj) 1347 | { 1348 | ArgumentNullException.ThrowIfNull(obj); 1349 | 1350 | // ChunkID is SHA-1, so we can just use the first 4 bytes 1351 | return BitConverter.ToInt32(obj, 0); 1352 | } 1353 | } 1354 | 1355 | static void DumpManifestToTextFile(DepotDownloadInfo depot, DepotManifest manifest) 1356 | { 1357 | var txtManifest = Path.Combine(depot.InstallDir, $"manifest_{depot.DepotId}_{depot.ManifestId}.txt"); 1358 | using var sw = new StreamWriter(txtManifest); 1359 | 1360 | sw.WriteLine($"Content Manifest for Depot {depot.DepotId} "); 1361 | sw.WriteLine(); 1362 | sw.WriteLine($"Manifest ID / date : {depot.ManifestId} / {manifest.CreationTime} "); 1363 | 1364 | var uniqueChunks = new HashSet(new ChunkIdComparer()); 1365 | 1366 | foreach (var file in manifest.Files) 1367 | { 1368 | foreach (var chunk in file.Chunks) 1369 | { 1370 | uniqueChunks.Add(chunk.ChunkID); 1371 | } 1372 | } 1373 | 1374 | sw.WriteLine($"Total number of files : {manifest.Files.Count} "); 1375 | sw.WriteLine($"Total number of chunks : {uniqueChunks.Count} "); 1376 | sw.WriteLine($"Total bytes on disk : {manifest.TotalUncompressedSize} "); 1377 | sw.WriteLine($"Total bytes compressed : {manifest.TotalCompressedSize} "); 1378 | sw.WriteLine(); 1379 | sw.WriteLine(); 1380 | sw.WriteLine(" Size Chunks File SHA Flags Name"); 1381 | 1382 | foreach (var file in manifest.Files) 1383 | { 1384 | var sha1Hash = Convert.ToHexString(file.FileHash).ToLower(); 1385 | sw.WriteLine($"{file.TotalSize,14:d} {file.Chunks.Count,6:d} {sha1Hash} {(int)file.Flags,5:x} {file.FileName}"); 1386 | } 1387 | } 1388 | } 1389 | } 1390 | -------------------------------------------------------------------------------- /DepotDownloader/DepotConfigStore.cs: -------------------------------------------------------------------------------- 1 | // This file is subject to the terms and conditions defined 2 | // in file 'LICENSE', which is part of this source code package. 3 | 4 | using System; 5 | using System.Collections.Generic; 6 | using System.IO; 7 | using System.IO.Compression; 8 | using ProtoBuf; 9 | 10 | namespace DepotDownloader 11 | { 12 | [ProtoContract] 13 | class DepotConfigStore 14 | { 15 | [ProtoMember(1)] 16 | public Dictionary InstalledManifestIDs { get; private set; } 17 | 18 | string FileName; 19 | 20 | DepotConfigStore() 21 | { 22 | InstalledManifestIDs = []; 23 | } 24 | 25 | static bool Loaded 26 | { 27 | get { return Instance != null; } 28 | } 29 | 30 | public static DepotConfigStore Instance; 31 | 32 | public static void LoadFromFile(string filename) 33 | { 34 | if (Loaded) 35 | throw new Exception("Config already loaded"); 36 | 37 | if (File.Exists(filename)) 38 | { 39 | using var fs = File.Open(filename, FileMode.Open); 40 | using var ds = new DeflateStream(fs, CompressionMode.Decompress); 41 | Instance = Serializer.Deserialize(ds); 42 | } 43 | else 44 | { 45 | Instance = new DepotConfigStore(); 46 | } 47 | 48 | Instance.FileName = filename; 49 | } 50 | 51 | public static void Save() 52 | { 53 | if (!Loaded) 54 | throw new Exception("Saved config before loading"); 55 | 56 | using var fs = File.Open(Instance.FileName, FileMode.Create); 57 | using var ds = new DeflateStream(fs, CompressionMode.Compress); 58 | Serializer.Serialize(ds, Instance); 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /DepotDownloader/DepotDownloader.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | Exe 4 | net9.0 5 | true 6 | LatestMajor 7 | 3.4.0 8 | Steam Downloading Utility 9 | SteamRE Team 10 | Copyright © SteamRE Team 2025 11 | ..\Icon\DepotDownloader.ico 12 | true 13 | true 14 | true 15 | 16 | 17 | 18 | 19 | Always 20 | 21 | 22 | 23 | 24 | 25 | all 26 | runtime; build; native; contentfiles; analyzers; buildtransitive 27 | 28 | 29 | 30 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /DepotDownloader/DownloadConfig.cs: -------------------------------------------------------------------------------- 1 | // This file is subject to the terms and conditions defined 2 | // in file 'LICENSE', which is part of this source code package. 3 | 4 | using System.Collections.Generic; 5 | using System.Text.RegularExpressions; 6 | 7 | namespace DepotDownloader 8 | { 9 | class DownloadConfig 10 | { 11 | public int CellID { get; set; } 12 | public bool DownloadAllPlatforms { get; set; } 13 | public bool DownloadAllArchs { get; set; } 14 | public bool DownloadAllLanguages { get; set; } 15 | public bool DownloadManifestOnly { get; set; } 16 | public string InstallDirectory { get; set; } 17 | 18 | public bool UsingFileList { get; set; } 19 | public HashSet FilesToDownload { get; set; } 20 | public List FilesToDownloadRegex { get; set; } 21 | 22 | public string BetaPassword { get; set; } 23 | 24 | public bool VerifyAll { get; set; } 25 | 26 | public int MaxDownloads { get; set; } 27 | 28 | public bool RememberPassword { get; set; } 29 | 30 | // A Steam LoginID to allow multiple concurrent connections 31 | public uint? LoginID { get; set; } 32 | 33 | public bool UseQrCode { get; set; } 34 | public bool SkipAppConfirmation { get; set; } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /DepotDownloader/HttpClientFactory.cs: -------------------------------------------------------------------------------- 1 | // This file is subject to the terms and conditions defined 2 | // in file 'LICENSE', which is part of this source code package. 3 | 4 | using System.IO; 5 | using System.Net.Http; 6 | using System.Net.Http.Headers; 7 | using System.Net.Sockets; 8 | using System.Threading; 9 | using System.Threading.Tasks; 10 | 11 | namespace DepotDownloader 12 | { 13 | // This is based on the dotnet issue #44686 and its workaround at https://github.com/dotnet/runtime/issues/44686#issuecomment-733797994 14 | // We don't know if the IPv6 stack is functional. 15 | class HttpClientFactory 16 | { 17 | public static HttpClient CreateHttpClient() 18 | { 19 | var client = new HttpClient(new SocketsHttpHandler 20 | { 21 | ConnectCallback = IPv4ConnectAsync 22 | }); 23 | 24 | var assemblyVersion = typeof(HttpClientFactory).Assembly.GetName().Version.ToString(fieldCount: 3); 25 | client.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("DepotDownloader", assemblyVersion)); 26 | 27 | return client; 28 | } 29 | 30 | static async ValueTask IPv4ConnectAsync(SocketsHttpConnectionContext context, CancellationToken cancellationToken) 31 | { 32 | // By default, we create dual-mode sockets: 33 | // Socket socket = new Socket(SocketType.Stream, ProtocolType.Tcp); 34 | 35 | var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp) 36 | { 37 | NoDelay = true 38 | }; 39 | 40 | try 41 | { 42 | await socket.ConnectAsync(context.DnsEndPoint, cancellationToken).ConfigureAwait(false); 43 | return new NetworkStream(socket, ownsSocket: true); 44 | } 45 | catch 46 | { 47 | socket.Dispose(); 48 | throw; 49 | } 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /DepotDownloader/HttpDiagnosticEventListener.cs: -------------------------------------------------------------------------------- 1 | // This file is subject to the terms and conditions defined 2 | // in file 'LICENSE', which is part of this source code package. 3 | 4 | using System; 5 | using System.Diagnostics.Tracing; 6 | using System.Text; 7 | 8 | namespace DepotDownloader 9 | { 10 | internal sealed class HttpDiagnosticEventListener : EventListener 11 | { 12 | public const EventKeywords TasksFlowActivityIds = (EventKeywords)0x80; 13 | 14 | protected override void OnEventSourceCreated(EventSource eventSource) 15 | { 16 | if (eventSource.Name == "System.Net.Http" || 17 | eventSource.Name == "System.Net.Sockets" || 18 | eventSource.Name == "System.Net.Security" || 19 | eventSource.Name == "System.Net.NameResolution") 20 | { 21 | EnableEvents(eventSource, EventLevel.LogAlways); 22 | } 23 | else if (eventSource.Name == "System.Threading.Tasks.TplEventSource") 24 | { 25 | EnableEvents(eventSource, EventLevel.LogAlways, TasksFlowActivityIds); 26 | } 27 | } 28 | 29 | protected override void OnEventWritten(EventWrittenEventArgs eventData) 30 | { 31 | var sb = new StringBuilder().Append($"{eventData.TimeStamp:HH:mm:ss.fffffff} {eventData.EventSource.Name}.{eventData.EventName}("); 32 | for (var i = 0; i < eventData.Payload?.Count; i++) 33 | { 34 | sb.Append(eventData.PayloadNames?[i]).Append(": ").Append(eventData.Payload[i]); 35 | if (i < eventData.Payload?.Count - 1) 36 | { 37 | sb.Append(", "); 38 | } 39 | } 40 | 41 | sb.Append(')'); 42 | Console.WriteLine(sb.ToString()); 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /DepotDownloader/NativeMethods.txt: -------------------------------------------------------------------------------- 1 | GetConsoleMode 2 | GetConsoleProcessList 3 | GetStdHandle 4 | MessageBox 5 | SetConsoleMode 6 | -------------------------------------------------------------------------------- /DepotDownloader/PlatformUtilities.cs: -------------------------------------------------------------------------------- 1 | // This file is subject to the terms and conditions defined 2 | // in file 'LICENSE', which is part of this source code package. 3 | 4 | using System.IO; 5 | using System.Runtime.InteropServices; 6 | using System.Runtime.Versioning; 7 | 8 | namespace DepotDownloader 9 | { 10 | static class PlatformUtilities 11 | { 12 | public static void SetExecutable(string path, bool value) 13 | { 14 | if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) 15 | { 16 | return; 17 | } 18 | 19 | const UnixFileMode ModeExecute = UnixFileMode.UserExecute | UnixFileMode.GroupExecute | UnixFileMode.OtherExecute; 20 | 21 | var mode = File.GetUnixFileMode(path); 22 | var hasExecuteMask = (mode & ModeExecute) == ModeExecute; 23 | if (hasExecuteMask != value) 24 | { 25 | File.SetUnixFileMode(path, value 26 | ? mode | ModeExecute 27 | : mode & ~ModeExecute); 28 | } 29 | } 30 | 31 | [SupportedOSPlatform("windows5.0")] 32 | public static void VerifyConsoleLaunch() 33 | { 34 | // Reference: https://devblogs.microsoft.com/oldnewthing/20160125-00/?p=92922 35 | var processList = new uint[2]; 36 | var processCount = Windows.Win32.PInvoke.GetConsoleProcessList(processList); 37 | 38 | if (processCount != 1) 39 | { 40 | return; 41 | } 42 | 43 | _ = Windows.Win32.PInvoke.MessageBox( 44 | Windows.Win32.Foundation.HWND.Null, 45 | "Depot Downloader is a console application; there is no GUI.\n\nIf you do not pass any command line parameters, it prints usage info and exits.\n\nYou must use this from a terminal/console.", 46 | "Depot Downloader", 47 | Windows.Win32.UI.WindowsAndMessaging.MESSAGEBOX_STYLE.MB_OK | Windows.Win32.UI.WindowsAndMessaging.MESSAGEBOX_STYLE.MB_ICONWARNING 48 | ); 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /DepotDownloader/Program.cs: -------------------------------------------------------------------------------- 1 | // This file is subject to the terms and conditions defined 2 | // in file 'LICENSE', which is part of this source code package. 3 | 4 | using System; 5 | using System.Collections.Generic; 6 | using System.ComponentModel; 7 | using System.IO; 8 | using System.Linq; 9 | using System.Reflection; 10 | using System.Runtime.InteropServices; 11 | using System.Text.RegularExpressions; 12 | using System.Threading.Tasks; 13 | using SteamKit2; 14 | using SteamKit2.CDN; 15 | 16 | namespace DepotDownloader 17 | { 18 | class Program 19 | { 20 | private static bool[] consumedArgs; 21 | 22 | static async Task Main(string[] args) 23 | { 24 | if (args.Length == 0) 25 | { 26 | PrintVersion(); 27 | PrintUsage(); 28 | 29 | if (OperatingSystem.IsWindowsVersionAtLeast(5, 0)) 30 | { 31 | PlatformUtilities.VerifyConsoleLaunch(); 32 | } 33 | 34 | return 0; 35 | } 36 | 37 | Ansi.Init(); 38 | 39 | DebugLog.Enabled = false; 40 | 41 | AccountSettingsStore.LoadFromFile("account.config"); 42 | 43 | #region Common Options 44 | 45 | // Not using HasParameter because it is case insensitive 46 | if (args.Length == 1 && (args[0] == "-V" || args[0] == "--version")) 47 | { 48 | PrintVersion(true); 49 | return 0; 50 | } 51 | 52 | consumedArgs = new bool[args.Length]; 53 | 54 | if (HasParameter(args, "-debug")) 55 | { 56 | PrintVersion(true); 57 | 58 | DebugLog.Enabled = true; 59 | DebugLog.AddListener((category, message) => 60 | { 61 | Console.WriteLine("[{0}] {1}", category, message); 62 | }); 63 | 64 | var httpEventListener = new HttpDiagnosticEventListener(); 65 | } 66 | 67 | var username = GetParameter(args, "-username") ?? GetParameter(args, "-user"); 68 | var password = GetParameter(args, "-password") ?? GetParameter(args, "-pass"); 69 | ContentDownloader.Config.RememberPassword = HasParameter(args, "-remember-password"); 70 | ContentDownloader.Config.UseQrCode = HasParameter(args, "-qr"); 71 | ContentDownloader.Config.SkipAppConfirmation = HasParameter(args, "-no-mobile"); 72 | 73 | if (username == null) 74 | { 75 | if (ContentDownloader.Config.RememberPassword && !ContentDownloader.Config.UseQrCode) 76 | { 77 | Console.WriteLine("Error: -remember-password can not be used without -username or -qr."); 78 | return 1; 79 | } 80 | } 81 | else if (ContentDownloader.Config.UseQrCode) 82 | { 83 | Console.WriteLine("Error: -qr can not be used with -username."); 84 | return 1; 85 | } 86 | 87 | ContentDownloader.Config.DownloadManifestOnly = HasParameter(args, "-manifest-only"); 88 | 89 | var cellId = GetParameter(args, "-cellid", -1); 90 | if (cellId == -1) 91 | { 92 | cellId = 0; 93 | } 94 | 95 | ContentDownloader.Config.CellID = cellId; 96 | 97 | var fileList = GetParameter(args, "-filelist"); 98 | 99 | if (fileList != null) 100 | { 101 | const string RegexPrefix = "regex:"; 102 | 103 | try 104 | { 105 | ContentDownloader.Config.UsingFileList = true; 106 | ContentDownloader.Config.FilesToDownload = new HashSet(StringComparer.OrdinalIgnoreCase); 107 | ContentDownloader.Config.FilesToDownloadRegex = []; 108 | 109 | var files = await File.ReadAllLinesAsync(fileList); 110 | 111 | foreach (var fileEntry in files) 112 | { 113 | if (string.IsNullOrWhiteSpace(fileEntry)) 114 | { 115 | continue; 116 | } 117 | 118 | if (fileEntry.StartsWith(RegexPrefix)) 119 | { 120 | var rgx = new Regex(fileEntry[RegexPrefix.Length..], RegexOptions.Compiled | RegexOptions.IgnoreCase); 121 | ContentDownloader.Config.FilesToDownloadRegex.Add(rgx); 122 | } 123 | else 124 | { 125 | ContentDownloader.Config.FilesToDownload.Add(fileEntry.Replace('\\', '/')); 126 | } 127 | } 128 | 129 | Console.WriteLine("Using filelist: '{0}'.", fileList); 130 | } 131 | catch (Exception ex) 132 | { 133 | Console.WriteLine("Warning: Unable to load filelist: {0}", ex); 134 | } 135 | } 136 | 137 | ContentDownloader.Config.InstallDirectory = GetParameter(args, "-dir"); 138 | 139 | ContentDownloader.Config.VerifyAll = HasParameter(args, "-verify-all") || HasParameter(args, "-verify_all") || HasParameter(args, "-validate"); 140 | 141 | if (HasParameter(args, "-use-lancache")) 142 | { 143 | await Client.DetectLancacheServerAsync(); 144 | if (Client.UseLancacheServer) 145 | { 146 | Console.WriteLine("Detected Lancache server! Downloads will be directed through the Lancache."); 147 | 148 | // Increasing the number of concurrent downloads when the cache is detected since the downloads will likely 149 | // be served much faster than over the internet. Steam internally has this behavior as well. 150 | if (!HasParameter(args, "-max-downloads")) 151 | { 152 | ContentDownloader.Config.MaxDownloads = 25; 153 | } 154 | } 155 | } 156 | 157 | ContentDownloader.Config.MaxDownloads = GetParameter(args, "-max-downloads", 8); 158 | ContentDownloader.Config.LoginID = HasParameter(args, "-loginid") ? GetParameter(args, "-loginid") : null; 159 | 160 | #endregion 161 | 162 | var appId = GetParameter(args, "-app", ContentDownloader.INVALID_APP_ID); 163 | if (appId == ContentDownloader.INVALID_APP_ID) 164 | { 165 | Console.WriteLine("Error: -app not specified!"); 166 | return 1; 167 | } 168 | 169 | var pubFile = GetParameter(args, "-pubfile", ContentDownloader.INVALID_MANIFEST_ID); 170 | var ugcId = GetParameter(args, "-ugc", ContentDownloader.INVALID_MANIFEST_ID); 171 | if (pubFile != ContentDownloader.INVALID_MANIFEST_ID) 172 | { 173 | #region Pubfile Downloading 174 | 175 | PrintUnconsumedArgs(args); 176 | 177 | if (InitializeSteam(username, password)) 178 | { 179 | try 180 | { 181 | await ContentDownloader.DownloadPubfileAsync(appId, pubFile).ConfigureAwait(false); 182 | } 183 | catch (Exception ex) when ( 184 | ex is ContentDownloaderException 185 | || ex is OperationCanceledException) 186 | { 187 | Console.WriteLine(ex.Message); 188 | return 1; 189 | } 190 | catch (Exception e) 191 | { 192 | Console.WriteLine("Download failed to due to an unhandled exception: {0}", e.Message); 193 | throw; 194 | } 195 | finally 196 | { 197 | ContentDownloader.ShutdownSteam3(); 198 | } 199 | } 200 | else 201 | { 202 | Console.WriteLine("Error: InitializeSteam failed"); 203 | return 1; 204 | } 205 | 206 | #endregion 207 | } 208 | else if (ugcId != ContentDownloader.INVALID_MANIFEST_ID) 209 | { 210 | #region UGC Downloading 211 | 212 | PrintUnconsumedArgs(args); 213 | 214 | if (InitializeSteam(username, password)) 215 | { 216 | try 217 | { 218 | await ContentDownloader.DownloadUGCAsync(appId, ugcId).ConfigureAwait(false); 219 | } 220 | catch (Exception ex) when ( 221 | ex is ContentDownloaderException 222 | || ex is OperationCanceledException) 223 | { 224 | Console.WriteLine(ex.Message); 225 | return 1; 226 | } 227 | catch (Exception e) 228 | { 229 | Console.WriteLine("Download failed to due to an unhandled exception: {0}", e.Message); 230 | throw; 231 | } 232 | finally 233 | { 234 | ContentDownloader.ShutdownSteam3(); 235 | } 236 | } 237 | else 238 | { 239 | Console.WriteLine("Error: InitializeSteam failed"); 240 | return 1; 241 | } 242 | 243 | #endregion 244 | } 245 | else 246 | { 247 | #region App downloading 248 | 249 | var branch = GetParameter(args, "-branch") ?? GetParameter(args, "-beta") ?? ContentDownloader.DEFAULT_BRANCH; 250 | ContentDownloader.Config.BetaPassword = GetParameter(args, "-branchpassword") ?? GetParameter(args, "-betapassword"); 251 | 252 | if (!string.IsNullOrEmpty(ContentDownloader.Config.BetaPassword) && string.IsNullOrEmpty(branch)) 253 | { 254 | Console.WriteLine("Error: Cannot specify -branchpassword when -branch is not specified."); 255 | return 1; 256 | } 257 | 258 | ContentDownloader.Config.DownloadAllPlatforms = HasParameter(args, "-all-platforms"); 259 | 260 | var os = GetParameter(args, "-os"); 261 | 262 | if (ContentDownloader.Config.DownloadAllPlatforms && !string.IsNullOrEmpty(os)) 263 | { 264 | Console.WriteLine("Error: Cannot specify -os when -all-platforms is specified."); 265 | return 1; 266 | } 267 | 268 | ContentDownloader.Config.DownloadAllArchs = HasParameter(args, "-all-archs"); 269 | 270 | var arch = GetParameter(args, "-osarch"); 271 | 272 | if (ContentDownloader.Config.DownloadAllArchs && !string.IsNullOrEmpty(arch)) 273 | { 274 | Console.WriteLine("Error: Cannot specify -osarch when -all-archs is specified."); 275 | return 1; 276 | } 277 | 278 | ContentDownloader.Config.DownloadAllLanguages = HasParameter(args, "-all-languages"); 279 | var language = GetParameter(args, "-language"); 280 | 281 | if (ContentDownloader.Config.DownloadAllLanguages && !string.IsNullOrEmpty(language)) 282 | { 283 | Console.WriteLine("Error: Cannot specify -language when -all-languages is specified."); 284 | return 1; 285 | } 286 | 287 | var lv = HasParameter(args, "-lowviolence"); 288 | 289 | var depotManifestIds = new List<(uint, ulong)>(); 290 | var isUGC = false; 291 | 292 | var depotIdList = GetParameterList(args, "-depot"); 293 | var manifestIdList = GetParameterList(args, "-manifest"); 294 | if (manifestIdList.Count > 0) 295 | { 296 | if (depotIdList.Count != manifestIdList.Count) 297 | { 298 | Console.WriteLine("Error: -manifest requires one id for every -depot specified"); 299 | return 1; 300 | } 301 | 302 | var zippedDepotManifest = depotIdList.Zip(manifestIdList, (depotId, manifestId) => (depotId, manifestId)); 303 | depotManifestIds.AddRange(zippedDepotManifest); 304 | } 305 | else 306 | { 307 | depotManifestIds.AddRange(depotIdList.Select(depotId => (depotId, ContentDownloader.INVALID_MANIFEST_ID))); 308 | } 309 | 310 | PrintUnconsumedArgs(args); 311 | 312 | if (InitializeSteam(username, password)) 313 | { 314 | try 315 | { 316 | await ContentDownloader.DownloadAppAsync(appId, depotManifestIds, branch, os, arch, language, lv, isUGC).ConfigureAwait(false); 317 | } 318 | catch (Exception ex) when ( 319 | ex is ContentDownloaderException 320 | || ex is OperationCanceledException) 321 | { 322 | Console.WriteLine(ex.Message); 323 | return 1; 324 | } 325 | catch (Exception e) 326 | { 327 | Console.WriteLine("Download failed to due to an unhandled exception: {0}", e.Message); 328 | throw; 329 | } 330 | finally 331 | { 332 | ContentDownloader.ShutdownSteam3(); 333 | } 334 | } 335 | else 336 | { 337 | Console.WriteLine("Error: InitializeSteam failed"); 338 | return 1; 339 | } 340 | 341 | #endregion 342 | } 343 | 344 | return 0; 345 | } 346 | 347 | static bool InitializeSteam(string username, string password) 348 | { 349 | if (!ContentDownloader.Config.UseQrCode) 350 | { 351 | if (username != null && password == null && (!ContentDownloader.Config.RememberPassword || !AccountSettingsStore.Instance.LoginTokens.ContainsKey(username))) 352 | { 353 | if (AccountSettingsStore.Instance.LoginTokens.ContainsKey(username)) 354 | { 355 | Console.WriteLine($"Account \"{username}\" has stored credentials. Did you forget to specify -remember-password?"); 356 | } 357 | 358 | do 359 | { 360 | Console.Write("Enter account password for \"{0}\": ", username); 361 | if (Console.IsInputRedirected) 362 | { 363 | password = Console.ReadLine(); 364 | } 365 | else 366 | { 367 | // Avoid console echoing of password 368 | password = Util.ReadPassword(); 369 | } 370 | 371 | Console.WriteLine(); 372 | } while (string.Empty == password); 373 | } 374 | else if (username == null) 375 | { 376 | Console.WriteLine("No username given. Using anonymous account with dedicated server subscription."); 377 | } 378 | } 379 | 380 | if (!string.IsNullOrEmpty(password)) 381 | { 382 | const int MAX_PASSWORD_SIZE = 64; 383 | 384 | if (password.Length > MAX_PASSWORD_SIZE) 385 | { 386 | Console.Error.WriteLine($"Warning: Password is longer than {MAX_PASSWORD_SIZE} characters, which is not supported by Steam."); 387 | } 388 | 389 | if (!password.All(char.IsAscii)) 390 | { 391 | Console.Error.WriteLine("Warning: Password contains non-ASCII characters, which is not supported by Steam."); 392 | } 393 | } 394 | 395 | return ContentDownloader.InitializeSteam3(username, password); 396 | } 397 | 398 | static int IndexOfParam(string[] args, string param) 399 | { 400 | for (var x = 0; x < args.Length; ++x) 401 | { 402 | if (args[x].Equals(param, StringComparison.OrdinalIgnoreCase)) 403 | { 404 | consumedArgs[x] = true; 405 | return x; 406 | } 407 | } 408 | 409 | return -1; 410 | } 411 | 412 | static bool HasParameter(string[] args, string param) 413 | { 414 | return IndexOfParam(args, param) > -1; 415 | } 416 | 417 | static T GetParameter(string[] args, string param, T defaultValue = default) 418 | { 419 | var index = IndexOfParam(args, param); 420 | 421 | if (index == -1 || index == (args.Length - 1)) 422 | return defaultValue; 423 | 424 | var strParam = args[index + 1]; 425 | 426 | var converter = TypeDescriptor.GetConverter(typeof(T)); 427 | if (converter != null) 428 | { 429 | consumedArgs[index + 1] = true; 430 | return (T)converter.ConvertFromString(strParam); 431 | } 432 | 433 | return default; 434 | } 435 | 436 | static List GetParameterList(string[] args, string param) 437 | { 438 | var list = new List(); 439 | var index = IndexOfParam(args, param); 440 | 441 | if (index == -1 || index == (args.Length - 1)) 442 | return list; 443 | 444 | index++; 445 | 446 | while (index < args.Length) 447 | { 448 | var strParam = args[index]; 449 | 450 | if (strParam[0] == '-') break; 451 | 452 | var converter = TypeDescriptor.GetConverter(typeof(T)); 453 | if (converter != null) 454 | { 455 | consumedArgs[index] = true; 456 | list.Add((T)converter.ConvertFromString(strParam)); 457 | } 458 | 459 | index++; 460 | } 461 | 462 | return list; 463 | } 464 | 465 | static void PrintUnconsumedArgs(string[] args) 466 | { 467 | var printError = false; 468 | 469 | for (var index = 0; index < consumedArgs.Length; index++) 470 | { 471 | if (!consumedArgs[index]) 472 | { 473 | printError = true; 474 | Console.Error.WriteLine($"Argument #{index + 1} {args[index]} was not used."); 475 | } 476 | } 477 | 478 | if (printError) 479 | { 480 | Console.Error.WriteLine("Make sure you specified the arguments correctly. Check --help for correct arguments."); 481 | Console.Error.WriteLine(); 482 | } 483 | } 484 | 485 | static void PrintUsage() 486 | { 487 | // Do not use tabs to align parameters here because tab size may differ 488 | Console.WriteLine(); 489 | Console.WriteLine("Usage: downloading one or all depots for an app:"); 490 | Console.WriteLine(" depotdownloader -app [-depot [-manifest ]]"); 491 | Console.WriteLine(" [-username [-password ]] [other options]"); 492 | Console.WriteLine(); 493 | Console.WriteLine("Usage: downloading a workshop item using pubfile id"); 494 | Console.WriteLine(" depotdownloader -app -pubfile [-username [-password ]]"); 495 | Console.WriteLine("Usage: downloading a workshop item using ugc id"); 496 | Console.WriteLine(" depotdownloader -app -ugc [-username [-password ]]"); 497 | Console.WriteLine(); 498 | Console.WriteLine("Parameters:"); 499 | Console.WriteLine(" -app <#> - the AppID to download."); 500 | Console.WriteLine(" -depot <#> - the DepotID to download."); 501 | Console.WriteLine(" -manifest - manifest id of content to download (requires -depot, default: current for branch)."); 502 | Console.WriteLine($" -branch - download from specified branch if available (default: {ContentDownloader.DEFAULT_BRANCH})."); 503 | Console.WriteLine(" -branchpassword - branch password if applicable."); 504 | Console.WriteLine(" -all-platforms - downloads all platform-specific depots when -app is used."); 505 | Console.WriteLine(" -all-archs - download all architecture-specific depots when -app is used."); 506 | Console.WriteLine(" -os - the operating system for which to download the game (windows, macos or linux, default: OS the program is currently running on)"); 507 | Console.WriteLine(" -osarch - the architecture for which to download the game (32 or 64, default: the host's architecture)"); 508 | Console.WriteLine(" -all-languages - download all language-specific depots when -app is used."); 509 | Console.WriteLine(" -language - the language for which to download the game (default: english)"); 510 | Console.WriteLine(" -lowviolence - download low violence depots when -app is used."); 511 | Console.WriteLine(); 512 | Console.WriteLine(" -ugc <#> - the UGC ID to download."); 513 | Console.WriteLine(" -pubfile <#> - the PublishedFileId to download. (Will automatically resolve to UGC id)"); 514 | Console.WriteLine(); 515 | Console.WriteLine(" -username - the username of the account to login to for restricted content."); 516 | Console.WriteLine(" -password - the password of the account to login to for restricted content."); 517 | Console.WriteLine(" -remember-password - if set, remember the password for subsequent logins of this user."); 518 | Console.WriteLine(" use -username -remember-password as login credentials."); 519 | Console.WriteLine(" -qr - display a login QR code to be scanned with the Steam mobile app"); 520 | Console.WriteLine(" -no-mobile - prefer entering a 2FA code instead of prompting to accept in the Steam mobile app"); 521 | Console.WriteLine(); 522 | Console.WriteLine(" -dir - the directory in which to place downloaded files."); 523 | Console.WriteLine(" -filelist - the name of a local file that contains a list of files to download (from the manifest)."); 524 | Console.WriteLine(" prefix file path with `regex:` if you want to match with regex. each file path should be on their own line."); 525 | Console.WriteLine(); 526 | Console.WriteLine(" -validate - include checksum verification of files already downloaded"); 527 | Console.WriteLine(" -manifest-only - downloads a human readable manifest for any depots that would be downloaded."); 528 | Console.WriteLine(" -cellid <#> - the overridden CellID of the content server to download from."); 529 | Console.WriteLine(" -max-downloads <#> - maximum number of chunks to download concurrently. (default: 8)."); 530 | Console.WriteLine(" -loginid <#> - a unique 32-bit integer Steam LogonID in decimal, required if running multiple instances of DepotDownloader concurrently."); 531 | Console.WriteLine(" -use-lancache - forces downloads over the local network via a Lancache instance."); 532 | Console.WriteLine(); 533 | Console.WriteLine(" -debug - enable verbose debug logging."); 534 | Console.WriteLine(" -V or --version - print version and runtime."); 535 | } 536 | 537 | static void PrintVersion(bool printExtra = false) 538 | { 539 | var version = typeof(Program).Assembly.GetCustomAttribute().InformationalVersion; 540 | Console.WriteLine($"DepotDownloader v{version}"); 541 | 542 | if (!printExtra) 543 | { 544 | return; 545 | } 546 | 547 | Console.WriteLine($"Runtime: {RuntimeInformation.FrameworkDescription} on {RuntimeInformation.OSDescription}"); 548 | } 549 | } 550 | } 551 | -------------------------------------------------------------------------------- /DepotDownloader/ProtoManifest.cs: -------------------------------------------------------------------------------- 1 | // This file is subject to the terms and conditions defined 2 | // in file 'LICENSE', which is part of this source code package. 3 | 4 | using System; 5 | using System.Collections.Generic; 6 | using System.IO; 7 | using System.IO.Compression; 8 | using System.Security.Cryptography; 9 | using System.Text; 10 | using ProtoBuf; 11 | using SteamKit2; 12 | 13 | namespace DepotDownloader 14 | { 15 | [ProtoContract] 16 | class ProtoManifest 17 | { 18 | // Proto ctor 19 | private ProtoManifest() 20 | { 21 | Files = []; 22 | } 23 | 24 | public ProtoManifest(DepotManifest sourceManifest, ulong id) : this() 25 | { 26 | sourceManifest.Files.ForEach(f => Files.Add(new FileData(f))); 27 | ID = id; 28 | CreationTime = sourceManifest.CreationTime; 29 | } 30 | 31 | [ProtoContract] 32 | public class FileData 33 | { 34 | // Proto ctor 35 | private FileData() 36 | { 37 | Chunks = []; 38 | } 39 | 40 | public FileData(DepotManifest.FileData sourceData) : this() 41 | { 42 | FileName = sourceData.FileName; 43 | sourceData.Chunks.ForEach(c => Chunks.Add(new ChunkData(c))); 44 | Flags = sourceData.Flags; 45 | TotalSize = sourceData.TotalSize; 46 | FileHash = sourceData.FileHash; 47 | } 48 | 49 | [ProtoMember(1)] 50 | public string FileName { get; internal set; } 51 | 52 | /// 53 | /// Gets the chunks that this file is composed of. 54 | /// 55 | [ProtoMember(2)] 56 | public List Chunks { get; private set; } 57 | 58 | /// 59 | /// Gets the file flags 60 | /// 61 | [ProtoMember(3)] 62 | public EDepotFileFlag Flags { get; private set; } 63 | 64 | /// 65 | /// Gets the total size of this file. 66 | /// 67 | [ProtoMember(4)] 68 | public ulong TotalSize { get; private set; } 69 | 70 | /// 71 | /// Gets the hash of this file. 72 | /// 73 | [ProtoMember(5)] 74 | public byte[] FileHash { get; private set; } 75 | } 76 | 77 | [ProtoContract(SkipConstructor = true)] 78 | public class ChunkData 79 | { 80 | public ChunkData(DepotManifest.ChunkData sourceChunk) 81 | { 82 | ChunkID = sourceChunk.ChunkID; 83 | Checksum = BitConverter.GetBytes(sourceChunk.Checksum); 84 | Offset = sourceChunk.Offset; 85 | CompressedLength = sourceChunk.CompressedLength; 86 | UncompressedLength = sourceChunk.UncompressedLength; 87 | } 88 | 89 | /// 90 | /// Gets the SHA-1 hash chunk id. 91 | /// 92 | [ProtoMember(1)] 93 | public byte[] ChunkID { get; private set; } 94 | 95 | /// 96 | /// Gets the expected Adler32 checksum of this chunk. 97 | /// 98 | [ProtoMember(2)] 99 | public byte[] Checksum { get; private set; } 100 | 101 | /// 102 | /// Gets the chunk offset. 103 | /// 104 | [ProtoMember(3)] 105 | public ulong Offset { get; private set; } 106 | 107 | /// 108 | /// Gets the compressed length of this chunk. 109 | /// 110 | [ProtoMember(4)] 111 | public uint CompressedLength { get; private set; } 112 | 113 | /// 114 | /// Gets the decompressed length of this chunk. 115 | /// 116 | [ProtoMember(5)] 117 | public uint UncompressedLength { get; private set; } 118 | } 119 | 120 | [ProtoMember(1)] 121 | public List Files { get; private set; } 122 | 123 | [ProtoMember(2)] 124 | public ulong ID { get; private set; } 125 | 126 | [ProtoMember(3)] 127 | public DateTime CreationTime { get; private set; } 128 | 129 | public static ProtoManifest LoadFromFile(string filename, out byte[] checksum) 130 | { 131 | if (!File.Exists(filename)) 132 | { 133 | checksum = null; 134 | return null; 135 | } 136 | 137 | using var ms = new MemoryStream(); 138 | using (var fs = File.Open(filename, FileMode.Open)) 139 | using (var ds = new DeflateStream(fs, CompressionMode.Decompress)) 140 | ds.CopyTo(ms); 141 | 142 | checksum = SHA1.HashData(ms.ToArray()); 143 | 144 | ms.Seek(0, SeekOrigin.Begin); 145 | return Serializer.Deserialize(ms); 146 | } 147 | 148 | public void SaveToFile(string filename, out byte[] checksum) 149 | { 150 | using var ms = new MemoryStream(); 151 | Serializer.Serialize(ms, this); 152 | 153 | checksum = SHA1.HashData(ms.ToArray()); 154 | 155 | ms.Seek(0, SeekOrigin.Begin); 156 | 157 | using var fs = File.Open(filename, FileMode.Create); 158 | using var ds = new DeflateStream(fs, CompressionMode.Compress); 159 | ms.CopyTo(ds); 160 | } 161 | 162 | public DepotManifest ConvertToSteamManifest(uint depotId) 163 | { 164 | ulong uncompressedSize = 0, compressedSize = 0; 165 | var newManifest = new DepotManifest(); 166 | newManifest.Files = new List(Files.Count); 167 | 168 | foreach (var file in Files) 169 | { 170 | var fileNameHash = SHA1.HashData(Encoding.UTF8.GetBytes(file.FileName.Replace('/', '\\').ToLowerInvariant())); 171 | var newFile = new DepotManifest.FileData(file.FileName, fileNameHash, file.Flags, file.TotalSize, file.FileHash, null, false, file.Chunks.Count); 172 | 173 | foreach (var chunk in file.Chunks) 174 | { 175 | var newChunk = new DepotManifest.ChunkData(chunk.ChunkID, BitConverter.ToUInt32(chunk.Checksum, 0), chunk.Offset, chunk.CompressedLength, chunk.UncompressedLength); 176 | newFile.Chunks.Add(newChunk); 177 | 178 | uncompressedSize += chunk.UncompressedLength; 179 | compressedSize += chunk.CompressedLength; 180 | } 181 | 182 | newManifest.Files.Add(newFile); 183 | } 184 | 185 | newManifest.FilenamesEncrypted = false; 186 | newManifest.DepotID = depotId; 187 | newManifest.ManifestGID = ID; 188 | newManifest.CreationTime = CreationTime; 189 | newManifest.TotalUncompressedSize = uncompressedSize; 190 | newManifest.TotalCompressedSize = compressedSize; 191 | newManifest.EncryptedCRC = 0; 192 | 193 | return newManifest; 194 | } 195 | } 196 | } 197 | -------------------------------------------------------------------------------- /DepotDownloader/Steam3Session.cs: -------------------------------------------------------------------------------- 1 | // This file is subject to the terms and conditions defined 2 | // in file 'LICENSE', which is part of this source code package. 3 | 4 | using System; 5 | using System.Collections.Concurrent; 6 | using System.Collections.Generic; 7 | using System.Collections.ObjectModel; 8 | using System.Linq; 9 | using System.Threading; 10 | using System.Threading.Tasks; 11 | using QRCoder; 12 | using SteamKit2; 13 | using SteamKit2.Authentication; 14 | using SteamKit2.CDN; 15 | using SteamKit2.Internal; 16 | 17 | namespace DepotDownloader 18 | { 19 | class Steam3Session 20 | { 21 | public bool IsLoggedOn { get; private set; } 22 | 23 | public ReadOnlyCollection Licenses 24 | { 25 | get; 26 | private set; 27 | } 28 | 29 | public Dictionary AppTokens { get; } = []; 30 | public Dictionary PackageTokens { get; } = []; 31 | public Dictionary DepotKeys { get; } = []; 32 | public ConcurrentDictionary<(uint, string), TaskCompletionSource> CDNAuthTokens { get; } = []; 33 | public Dictionary AppInfo { get; } = []; 34 | public Dictionary PackageInfo { get; } = []; 35 | public Dictionary AppBetaPasswords { get; } = []; 36 | 37 | public SteamClient steamClient; 38 | public SteamUser steamUser; 39 | public SteamContent steamContent; 40 | readonly SteamApps steamApps; 41 | readonly SteamCloud steamCloud; 42 | readonly PublishedFile steamPublishedFile; 43 | 44 | readonly CallbackManager callbacks; 45 | 46 | readonly bool authenticatedUser; 47 | bool bConnecting; 48 | bool bAborted; 49 | bool bExpectingDisconnectRemote; 50 | bool bDidDisconnect; 51 | bool bIsConnectionRecovery; 52 | int connectionBackoff; 53 | int seq; // more hack fixes 54 | AuthSession authSession; 55 | readonly CancellationTokenSource abortedToken = new(); 56 | 57 | // input 58 | readonly SteamUser.LogOnDetails logonDetails; 59 | 60 | public Steam3Session(SteamUser.LogOnDetails details) 61 | { 62 | this.logonDetails = details; 63 | this.authenticatedUser = details.Username != null || ContentDownloader.Config.UseQrCode; 64 | 65 | var clientConfiguration = SteamConfiguration.Create(config => 66 | config 67 | .WithHttpClientFactory(HttpClientFactory.CreateHttpClient) 68 | ); 69 | 70 | this.steamClient = new SteamClient(clientConfiguration); 71 | 72 | this.steamUser = this.steamClient.GetHandler(); 73 | this.steamApps = this.steamClient.GetHandler(); 74 | this.steamCloud = this.steamClient.GetHandler(); 75 | var steamUnifiedMessages = this.steamClient.GetHandler(); 76 | this.steamPublishedFile = steamUnifiedMessages.CreateService(); 77 | this.steamContent = this.steamClient.GetHandler(); 78 | 79 | this.callbacks = new CallbackManager(this.steamClient); 80 | 81 | this.callbacks.Subscribe(ConnectedCallback); 82 | this.callbacks.Subscribe(DisconnectedCallback); 83 | this.callbacks.Subscribe(LogOnCallback); 84 | this.callbacks.Subscribe(LicenseListCallback); 85 | 86 | Console.Write("Connecting to Steam3..."); 87 | Connect(); 88 | } 89 | 90 | public delegate bool WaitCondition(); 91 | 92 | private readonly Lock steamLock = new(); 93 | 94 | public bool WaitUntilCallback(Action submitter, WaitCondition waiter) 95 | { 96 | while (!bAborted && !waiter()) 97 | { 98 | lock (steamLock) 99 | { 100 | submitter(); 101 | } 102 | 103 | var seq = this.seq; 104 | do 105 | { 106 | lock (steamLock) 107 | { 108 | callbacks.RunWaitCallbacks(TimeSpan.FromSeconds(1)); 109 | } 110 | } while (!bAborted && this.seq == seq && !waiter()); 111 | } 112 | 113 | return bAborted; 114 | } 115 | 116 | public bool WaitForCredentials() 117 | { 118 | if (IsLoggedOn || bAborted) 119 | return IsLoggedOn; 120 | 121 | WaitUntilCallback(() => { }, () => IsLoggedOn); 122 | 123 | return IsLoggedOn; 124 | } 125 | 126 | public async Task TickCallbacks() 127 | { 128 | var token = abortedToken.Token; 129 | 130 | try 131 | { 132 | while (!token.IsCancellationRequested) 133 | { 134 | await callbacks.RunWaitCallbackAsync(token); 135 | } 136 | } 137 | catch (OperationCanceledException) 138 | { 139 | // 140 | } 141 | } 142 | 143 | public async Task RequestAppInfo(uint appId, bool bForce = false) 144 | { 145 | if ((AppInfo.ContainsKey(appId) && !bForce) || bAborted) 146 | return; 147 | 148 | var appTokens = await steamApps.PICSGetAccessTokens([appId], []); 149 | 150 | if (appTokens.AppTokensDenied.Contains(appId)) 151 | { 152 | Console.WriteLine("Insufficient privileges to get access token for app {0}", appId); 153 | } 154 | 155 | foreach (var token_dict in appTokens.AppTokens) 156 | { 157 | this.AppTokens[token_dict.Key] = token_dict.Value; 158 | } 159 | 160 | var request = new SteamApps.PICSRequest(appId); 161 | 162 | if (AppTokens.TryGetValue(appId, out var token)) 163 | { 164 | request.AccessToken = token; 165 | } 166 | 167 | var appInfoMultiple = await steamApps.PICSGetProductInfo([request], []); 168 | 169 | foreach (var appInfo in appInfoMultiple.Results) 170 | { 171 | foreach (var app_value in appInfo.Apps) 172 | { 173 | var app = app_value.Value; 174 | 175 | Console.WriteLine("Got AppInfo for {0}", app.ID); 176 | AppInfo[app.ID] = app; 177 | } 178 | 179 | foreach (var app in appInfo.UnknownApps) 180 | { 181 | AppInfo[app] = null; 182 | } 183 | } 184 | } 185 | 186 | public async Task RequestPackageInfo(IEnumerable packageIds) 187 | { 188 | var packages = packageIds.ToList(); 189 | packages.RemoveAll(PackageInfo.ContainsKey); 190 | 191 | if (packages.Count == 0 || bAborted) 192 | return; 193 | 194 | var packageRequests = new List(); 195 | 196 | foreach (var package in packages) 197 | { 198 | var request = new SteamApps.PICSRequest(package); 199 | 200 | if (PackageTokens.TryGetValue(package, out var token)) 201 | { 202 | request.AccessToken = token; 203 | } 204 | 205 | packageRequests.Add(request); 206 | } 207 | 208 | var packageInfoMultiple = await steamApps.PICSGetProductInfo([], packageRequests); 209 | 210 | foreach (var packageInfo in packageInfoMultiple.Results) 211 | { 212 | foreach (var package_value in packageInfo.Packages) 213 | { 214 | var package = package_value.Value; 215 | PackageInfo[package.ID] = package; 216 | } 217 | 218 | foreach (var package in packageInfo.UnknownPackages) 219 | { 220 | PackageInfo[package] = null; 221 | } 222 | } 223 | } 224 | 225 | public async Task RequestFreeAppLicense(uint appId) 226 | { 227 | try 228 | { 229 | var resultInfo = await steamApps.RequestFreeLicense(appId); 230 | 231 | return resultInfo.GrantedApps.Contains(appId); 232 | } 233 | catch (Exception ex) 234 | { 235 | Console.WriteLine($"Failed to request FreeOnDemand license for app {appId}: {ex.Message}"); 236 | return false; 237 | } 238 | } 239 | 240 | public async Task RequestDepotKey(uint depotId, uint appid = 0) 241 | { 242 | if (DepotKeys.ContainsKey(depotId) || bAborted) 243 | return; 244 | 245 | var depotKey = await steamApps.GetDepotDecryptionKey(depotId, appid); 246 | 247 | Console.WriteLine("Got depot key for {0} result: {1}", depotKey.DepotID, depotKey.Result); 248 | 249 | if (depotKey.Result != EResult.OK) 250 | { 251 | return; 252 | } 253 | 254 | DepotKeys[depotKey.DepotID] = depotKey.DepotKey; 255 | } 256 | 257 | 258 | public async Task GetDepotManifestRequestCodeAsync(uint depotId, uint appId, ulong manifestId, string branch) 259 | { 260 | if (bAborted) 261 | return 0; 262 | 263 | var requestCode = await steamContent.GetManifestRequestCode(depotId, appId, manifestId, branch); 264 | 265 | if (requestCode == 0) 266 | { 267 | Console.WriteLine($"No manifest request code was returned for depot {depotId} from app {appId}, manifest {manifestId}"); 268 | 269 | if (!authenticatedUser) 270 | { 271 | Console.WriteLine("Suggestion: Try logging in with -username as old manifests may not be available for anonymous accounts."); 272 | } 273 | } 274 | else 275 | { 276 | Console.WriteLine($"Got manifest request code for depot {depotId} from app {appId}, manifest {manifestId}, result: {requestCode}"); 277 | } 278 | 279 | return requestCode; 280 | } 281 | 282 | public async Task RequestCDNAuthToken(uint appid, uint depotid, Server server) 283 | { 284 | var cdnKey = (depotid, server.Host); 285 | var completion = new TaskCompletionSource(); 286 | 287 | if (bAborted || !CDNAuthTokens.TryAdd(cdnKey, completion)) 288 | { 289 | return; 290 | } 291 | 292 | DebugLog.WriteLine(nameof(Steam3Session), $"Requesting CDN auth token for {server.Host}"); 293 | 294 | var cdnAuth = await steamContent.GetCDNAuthToken(appid, depotid, server.Host); 295 | 296 | Console.WriteLine($"Got CDN auth token for {server.Host} result: {cdnAuth.Result} (expires {cdnAuth.Expiration})"); 297 | 298 | if (cdnAuth.Result != EResult.OK) 299 | { 300 | return; 301 | } 302 | 303 | completion.TrySetResult(cdnAuth); 304 | } 305 | 306 | public async Task CheckAppBetaPassword(uint appid, string password) 307 | { 308 | var appPassword = await steamApps.CheckAppBetaPassword(appid, password); 309 | 310 | Console.WriteLine("Retrieved {0} beta keys with result: {1}", appPassword.BetaPasswords.Count, appPassword.Result); 311 | 312 | foreach (var entry in appPassword.BetaPasswords) 313 | { 314 | AppBetaPasswords[entry.Key] = entry.Value; 315 | } 316 | } 317 | 318 | public async Task GetPrivateBetaDepotSection(uint appid, string branch) 319 | { 320 | if (!AppBetaPasswords.TryGetValue(branch, out var branchPassword)) // Should be filled by CheckAppBetaPassword 321 | { 322 | return new KeyValue(); 323 | } 324 | 325 | AppTokens.TryGetValue(appid, out var accessToken); // Should be filled by RequestAppInfo 326 | 327 | var privateBeta = await steamApps.PICSGetPrivateBeta(appid, accessToken, branch, branchPassword); 328 | 329 | Console.WriteLine($"Retrieved private beta depot section for {appid} with result: {privateBeta.Result}"); 330 | 331 | return privateBeta.DepotSection; 332 | } 333 | 334 | public async Task GetPublishedFileDetails(uint appId, PublishedFileID pubFile) 335 | { 336 | var pubFileRequest = new CPublishedFile_GetDetails_Request { appid = appId }; 337 | pubFileRequest.publishedfileids.Add(pubFile); 338 | 339 | var details = await steamPublishedFile.GetDetails(pubFileRequest); 340 | 341 | if (details.Result == EResult.OK) 342 | { 343 | return details.Body.publishedfiledetails.FirstOrDefault(); 344 | } 345 | 346 | throw new Exception($"EResult {(int)details.Result} ({details.Result}) while retrieving file details for pubfile {pubFile}."); 347 | } 348 | 349 | 350 | public async Task GetUGCDetails(UGCHandle ugcHandle) 351 | { 352 | var callback = await steamCloud.RequestUGCDetails(ugcHandle); 353 | 354 | if (callback.Result == EResult.OK) 355 | { 356 | return callback; 357 | } 358 | else if (callback.Result == EResult.FileNotFound) 359 | { 360 | return null; 361 | } 362 | 363 | throw new Exception($"EResult {(int)callback.Result} ({callback.Result}) while retrieving UGC details for {ugcHandle}."); 364 | } 365 | 366 | private void ResetConnectionFlags() 367 | { 368 | bExpectingDisconnectRemote = false; 369 | bDidDisconnect = false; 370 | bIsConnectionRecovery = false; 371 | } 372 | 373 | void Connect() 374 | { 375 | bAborted = false; 376 | bConnecting = true; 377 | connectionBackoff = 0; 378 | authSession = null; 379 | 380 | ResetConnectionFlags(); 381 | this.steamClient.Connect(); 382 | } 383 | 384 | private void Abort(bool sendLogOff = true) 385 | { 386 | Disconnect(sendLogOff); 387 | } 388 | 389 | public void Disconnect(bool sendLogOff = true) 390 | { 391 | if (sendLogOff) 392 | { 393 | steamUser.LogOff(); 394 | } 395 | 396 | bAborted = true; 397 | bConnecting = false; 398 | bIsConnectionRecovery = false; 399 | abortedToken.Cancel(); 400 | steamClient.Disconnect(); 401 | 402 | Ansi.Progress(Ansi.ProgressState.Hidden); 403 | 404 | // flush callbacks until our disconnected event 405 | while (!bDidDisconnect) 406 | { 407 | callbacks.RunWaitAllCallbacks(TimeSpan.FromMilliseconds(100)); 408 | } 409 | } 410 | 411 | private void Reconnect() 412 | { 413 | bIsConnectionRecovery = true; 414 | steamClient.Disconnect(); 415 | } 416 | 417 | private async void ConnectedCallback(SteamClient.ConnectedCallback connected) 418 | { 419 | Console.WriteLine(" Done!"); 420 | bConnecting = false; 421 | 422 | // Update our tracking so that we don't time out, even if we need to reconnect multiple times, 423 | // e.g. if the authentication phase takes a while and therefore multiple connections. 424 | connectionBackoff = 0; 425 | 426 | if (!authenticatedUser) 427 | { 428 | Console.Write("Logging anonymously into Steam3..."); 429 | steamUser.LogOnAnonymous(); 430 | } 431 | else 432 | { 433 | if (logonDetails.Username != null) 434 | { 435 | Console.WriteLine("Logging '{0}' into Steam3...", logonDetails.Username); 436 | } 437 | 438 | if (authSession is null) 439 | { 440 | if (logonDetails.Username != null && logonDetails.Password != null && logonDetails.AccessToken is null) 441 | { 442 | try 443 | { 444 | _ = AccountSettingsStore.Instance.GuardData.TryGetValue(logonDetails.Username, out var guarddata); 445 | authSession = await steamClient.Authentication.BeginAuthSessionViaCredentialsAsync(new AuthSessionDetails 446 | { 447 | DeviceFriendlyName = nameof(DepotDownloader), 448 | Username = logonDetails.Username, 449 | Password = logonDetails.Password, 450 | IsPersistentSession = ContentDownloader.Config.RememberPassword, 451 | GuardData = guarddata, 452 | Authenticator = new ConsoleAuthenticator(), 453 | }); 454 | } 455 | catch (TaskCanceledException) 456 | { 457 | return; 458 | } 459 | catch (Exception ex) 460 | { 461 | Console.Error.WriteLine("Failed to authenticate with Steam: " + ex.Message); 462 | Abort(false); 463 | return; 464 | } 465 | } 466 | else if (logonDetails.AccessToken is null && ContentDownloader.Config.UseQrCode) 467 | { 468 | Console.WriteLine("Logging in with QR code..."); 469 | 470 | try 471 | { 472 | var session = await steamClient.Authentication.BeginAuthSessionViaQRAsync(new AuthSessionDetails 473 | { 474 | DeviceFriendlyName = nameof(DepotDownloader), 475 | IsPersistentSession = ContentDownloader.Config.RememberPassword, 476 | }); 477 | 478 | authSession = session; 479 | 480 | // Steam will periodically refresh the challenge url, so we need a new QR code. 481 | session.ChallengeURLChanged = () => 482 | { 483 | Console.WriteLine(); 484 | Console.WriteLine("The QR code has changed:"); 485 | 486 | DisplayQrCode(session.ChallengeURL); 487 | }; 488 | 489 | // Draw initial QR code immediately 490 | DisplayQrCode(session.ChallengeURL); 491 | } 492 | catch (TaskCanceledException) 493 | { 494 | return; 495 | } 496 | catch (Exception ex) 497 | { 498 | Console.Error.WriteLine("Failed to authenticate with Steam: " + ex.Message); 499 | Abort(false); 500 | return; 501 | } 502 | } 503 | } 504 | 505 | if (authSession != null) 506 | { 507 | try 508 | { 509 | var result = await authSession.PollingWaitForResultAsync(); 510 | 511 | logonDetails.Username = result.AccountName; 512 | logonDetails.Password = null; 513 | logonDetails.AccessToken = result.RefreshToken; 514 | 515 | if (result.NewGuardData != null) 516 | { 517 | AccountSettingsStore.Instance.GuardData[result.AccountName] = result.NewGuardData; 518 | 519 | if (ContentDownloader.Config.UseQrCode) 520 | { 521 | Console.WriteLine($"Success! Next time you can login with -username {result.AccountName} -remember-password instead of -qr."); 522 | } 523 | } 524 | else 525 | { 526 | AccountSettingsStore.Instance.GuardData.Remove(result.AccountName); 527 | } 528 | 529 | AccountSettingsStore.Instance.LoginTokens[result.AccountName] = result.RefreshToken; 530 | AccountSettingsStore.Save(); 531 | } 532 | catch (TaskCanceledException) 533 | { 534 | return; 535 | } 536 | catch (Exception ex) 537 | { 538 | Console.Error.WriteLine("Failed to authenticate with Steam: " + ex.Message); 539 | Abort(false); 540 | return; 541 | } 542 | 543 | authSession = null; 544 | } 545 | 546 | steamUser.LogOn(logonDetails); 547 | } 548 | } 549 | 550 | private void DisconnectedCallback(SteamClient.DisconnectedCallback disconnected) 551 | { 552 | bDidDisconnect = true; 553 | 554 | DebugLog.WriteLine(nameof(Steam3Session), $"Disconnected: bIsConnectionRecovery = {bIsConnectionRecovery}, UserInitiated = {disconnected.UserInitiated}, bExpectingDisconnectRemote = {bExpectingDisconnectRemote}"); 555 | 556 | // When recovering the connection, we want to reconnect even if the remote disconnects us 557 | if (!bIsConnectionRecovery && (disconnected.UserInitiated || bExpectingDisconnectRemote)) 558 | { 559 | Console.WriteLine("Disconnected from Steam"); 560 | 561 | // Any operations outstanding need to be aborted 562 | bAborted = true; 563 | } 564 | else if (connectionBackoff >= 10) 565 | { 566 | Console.WriteLine("Could not connect to Steam after 10 tries"); 567 | Abort(false); 568 | } 569 | else if (!bAborted) 570 | { 571 | connectionBackoff += 1; 572 | 573 | if (bConnecting) 574 | { 575 | Console.WriteLine($"Connection to Steam failed. Trying again (#{connectionBackoff})..."); 576 | } 577 | else 578 | { 579 | Console.WriteLine("Lost connection to Steam. Reconnecting"); 580 | } 581 | 582 | Thread.Sleep(1000 * connectionBackoff); 583 | 584 | // Any connection related flags need to be reset here to match the state after Connect 585 | ResetConnectionFlags(); 586 | steamClient.Connect(); 587 | } 588 | } 589 | 590 | private void LogOnCallback(SteamUser.LoggedOnCallback loggedOn) 591 | { 592 | var isSteamGuard = loggedOn.Result == EResult.AccountLogonDenied; 593 | var is2FA = loggedOn.Result == EResult.AccountLoginDeniedNeedTwoFactor; 594 | var isAccessToken = ContentDownloader.Config.RememberPassword && logonDetails.AccessToken != null && 595 | loggedOn.Result is EResult.InvalidPassword 596 | or EResult.InvalidSignature 597 | or EResult.AccessDenied 598 | or EResult.Expired 599 | or EResult.Revoked; 600 | 601 | if (isSteamGuard || is2FA || isAccessToken) 602 | { 603 | bExpectingDisconnectRemote = true; 604 | Abort(false); 605 | 606 | if (!isAccessToken) 607 | { 608 | Console.WriteLine("This account is protected by Steam Guard."); 609 | } 610 | 611 | if (is2FA) 612 | { 613 | do 614 | { 615 | Console.Write("Please enter your 2 factor auth code from your authenticator app: "); 616 | logonDetails.TwoFactorCode = Console.ReadLine(); 617 | } while (string.Empty == logonDetails.TwoFactorCode); 618 | } 619 | else if (isAccessToken) 620 | { 621 | AccountSettingsStore.Instance.LoginTokens.Remove(logonDetails.Username); 622 | AccountSettingsStore.Save(); 623 | 624 | // TODO: Handle gracefully by falling back to password prompt? 625 | Console.WriteLine($"Access token was rejected ({loggedOn.Result})."); 626 | Abort(false); 627 | return; 628 | } 629 | else 630 | { 631 | do 632 | { 633 | Console.Write("Please enter the authentication code sent to your email address: "); 634 | logonDetails.AuthCode = Console.ReadLine(); 635 | } while (string.Empty == logonDetails.AuthCode); 636 | } 637 | 638 | Console.Write("Retrying Steam3 connection..."); 639 | Connect(); 640 | 641 | return; 642 | } 643 | 644 | if (loggedOn.Result == EResult.TryAnotherCM) 645 | { 646 | Console.Write("Retrying Steam3 connection (TryAnotherCM)..."); 647 | 648 | Reconnect(); 649 | 650 | return; 651 | } 652 | 653 | if (loggedOn.Result == EResult.ServiceUnavailable) 654 | { 655 | Console.WriteLine("Unable to login to Steam3: {0}", loggedOn.Result); 656 | Abort(false); 657 | 658 | return; 659 | } 660 | 661 | if (loggedOn.Result != EResult.OK) 662 | { 663 | Console.WriteLine("Unable to login to Steam3: {0}", loggedOn.Result); 664 | Abort(); 665 | 666 | return; 667 | } 668 | 669 | Console.WriteLine(" Done!"); 670 | 671 | this.seq++; 672 | IsLoggedOn = true; 673 | 674 | if (ContentDownloader.Config.CellID == 0) 675 | { 676 | Console.WriteLine("Using Steam3 suggested CellID: " + loggedOn.CellID); 677 | ContentDownloader.Config.CellID = (int)loggedOn.CellID; 678 | } 679 | } 680 | 681 | private void LicenseListCallback(SteamApps.LicenseListCallback licenseList) 682 | { 683 | if (licenseList.Result != EResult.OK) 684 | { 685 | Console.WriteLine("Unable to get license list: {0} ", licenseList.Result); 686 | Abort(); 687 | 688 | return; 689 | } 690 | 691 | Console.WriteLine("Got {0} licenses for account!", licenseList.LicenseList.Count); 692 | Licenses = licenseList.LicenseList; 693 | 694 | foreach (var license in licenseList.LicenseList) 695 | { 696 | if (license.AccessToken > 0) 697 | { 698 | PackageTokens.TryAdd(license.PackageID, license.AccessToken); 699 | } 700 | } 701 | } 702 | 703 | private static void DisplayQrCode(string challengeUrl) 704 | { 705 | // Encode the link as a QR code 706 | using var qrGenerator = new QRCodeGenerator(); 707 | var qrCodeData = qrGenerator.CreateQrCode(challengeUrl, QRCodeGenerator.ECCLevel.L); 708 | using var qrCode = new AsciiQRCode(qrCodeData); 709 | var qrCodeAsAsciiArt = qrCode.GetLineByLineGraphic(1, drawQuietZones: true); 710 | 711 | Console.WriteLine("Use the Steam Mobile App to sign in with this QR code:"); 712 | 713 | foreach (var line in qrCodeAsAsciiArt) 714 | { 715 | Console.WriteLine(line); 716 | } 717 | } 718 | } 719 | } 720 | -------------------------------------------------------------------------------- /DepotDownloader/Util.cs: -------------------------------------------------------------------------------- 1 | // This file is subject to the terms and conditions defined 2 | // in file 'LICENSE', which is part of this source code package. 3 | 4 | using System; 5 | using System.Collections.Generic; 6 | using System.IO; 7 | using System.Linq; 8 | using System.Runtime.InteropServices; 9 | using System.Security.Cryptography; 10 | using System.Text; 11 | using System.Threading.Tasks; 12 | using SteamKit2; 13 | 14 | namespace DepotDownloader 15 | { 16 | static class Util 17 | { 18 | public static string GetSteamOS() 19 | { 20 | if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) 21 | { 22 | return "windows"; 23 | } 24 | 25 | if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) 26 | { 27 | return "macos"; 28 | } 29 | 30 | if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) 31 | { 32 | return "linux"; 33 | } 34 | 35 | if (RuntimeInformation.IsOSPlatform(OSPlatform.FreeBSD)) 36 | { 37 | // Return linux as freebsd steam client doesn't exist yet 38 | return "linux"; 39 | } 40 | 41 | return "unknown"; 42 | } 43 | 44 | public static string GetSteamArch() 45 | { 46 | return Environment.Is64BitOperatingSystem ? "64" : "32"; 47 | } 48 | 49 | public static string ReadPassword() 50 | { 51 | ConsoleKeyInfo keyInfo; 52 | var password = new StringBuilder(); 53 | 54 | do 55 | { 56 | keyInfo = Console.ReadKey(true); 57 | 58 | if (keyInfo.Key == ConsoleKey.Backspace) 59 | { 60 | if (password.Length > 0) 61 | { 62 | password.Remove(password.Length - 1, 1); 63 | Console.Write("\b \b"); 64 | } 65 | 66 | continue; 67 | } 68 | 69 | /* Printable ASCII characters only */ 70 | var c = keyInfo.KeyChar; 71 | if (c >= ' ' && c <= '~') 72 | { 73 | password.Append(c); 74 | Console.Write('*'); 75 | } 76 | } while (keyInfo.Key != ConsoleKey.Enter); 77 | 78 | return password.ToString(); 79 | } 80 | 81 | // Validate a file against Steam3 Chunk data 82 | public static List ValidateSteam3FileChecksums(FileStream fs, DepotManifest.ChunkData[] chunkdata) 83 | { 84 | var neededChunks = new List(); 85 | 86 | foreach (var data in chunkdata) 87 | { 88 | fs.Seek((long)data.Offset, SeekOrigin.Begin); 89 | 90 | var adler = AdlerHash(fs, (int)data.UncompressedLength); 91 | if (!adler.SequenceEqual(BitConverter.GetBytes(data.Checksum))) 92 | { 93 | neededChunks.Add(data); 94 | } 95 | } 96 | 97 | return neededChunks; 98 | } 99 | 100 | public static byte[] AdlerHash(Stream stream, int length) 101 | { 102 | uint a = 0, b = 0; 103 | for (var i = 0; i < length; i++) 104 | { 105 | var c = (uint)stream.ReadByte(); 106 | 107 | a = (a + c) % 65521; 108 | b = (b + a) % 65521; 109 | } 110 | 111 | return BitConverter.GetBytes(a | (b << 16)); 112 | } 113 | 114 | public static byte[] FileSHAHash(string filename) 115 | { 116 | using (var fs = File.Open(filename, FileMode.Open)) 117 | using (var sha = SHA1.Create()) 118 | { 119 | var output = sha.ComputeHash(fs); 120 | 121 | return output; 122 | } 123 | } 124 | 125 | public static DepotManifest LoadManifestFromFile(string directory, uint depotId, ulong manifestId, bool badHashWarning) 126 | { 127 | // Try loading Steam format manifest first. 128 | var filename = Path.Combine(directory, string.Format("{0}_{1}.manifest", depotId, manifestId)); 129 | 130 | if (File.Exists(filename)) 131 | { 132 | byte[] expectedChecksum; 133 | 134 | try 135 | { 136 | expectedChecksum = File.ReadAllBytes(filename + ".sha"); 137 | } 138 | catch (IOException) 139 | { 140 | expectedChecksum = null; 141 | } 142 | 143 | var currentChecksum = FileSHAHash(filename); 144 | 145 | if (expectedChecksum != null && expectedChecksum.SequenceEqual(currentChecksum)) 146 | { 147 | return DepotManifest.LoadFromFile(filename); 148 | } 149 | else if (badHashWarning) 150 | { 151 | Console.WriteLine("Manifest {0} on disk did not match the expected checksum.", manifestId); 152 | } 153 | } 154 | 155 | // Try converting legacy manifest format. 156 | filename = Path.Combine(directory, string.Format("{0}_{1}.bin", depotId, manifestId)); 157 | 158 | if (File.Exists(filename)) 159 | { 160 | byte[] expectedChecksum; 161 | 162 | try 163 | { 164 | expectedChecksum = File.ReadAllBytes(filename + ".sha"); 165 | } 166 | catch (IOException) 167 | { 168 | expectedChecksum = null; 169 | } 170 | 171 | byte[] currentChecksum; 172 | var oldManifest = ProtoManifest.LoadFromFile(filename, out currentChecksum); 173 | 174 | if (oldManifest != null && (expectedChecksum == null || !expectedChecksum.SequenceEqual(currentChecksum))) 175 | { 176 | oldManifest = null; 177 | 178 | if (badHashWarning) 179 | { 180 | Console.WriteLine("Manifest {0} on disk did not match the expected checksum.", manifestId); 181 | } 182 | } 183 | 184 | if (oldManifest != null) 185 | { 186 | return oldManifest.ConvertToSteamManifest(depotId); 187 | } 188 | } 189 | 190 | return null; 191 | } 192 | 193 | public static bool SaveManifestToFile(string directory, DepotManifest manifest) 194 | { 195 | try 196 | { 197 | var filename = Path.Combine(directory, string.Format("{0}_{1}.manifest", manifest.DepotID, manifest.ManifestGID)); 198 | manifest.SaveToFile(filename); 199 | File.WriteAllBytes(filename + ".sha", FileSHAHash(filename)); 200 | return true; // If serialization completes without throwing an exception, return true 201 | } 202 | catch (Exception) 203 | { 204 | return false; // Return false if an error occurs 205 | } 206 | } 207 | 208 | public static byte[] DecodeHexString(string hex) 209 | { 210 | if (hex == null) 211 | return null; 212 | 213 | var chars = hex.Length; 214 | var bytes = new byte[chars / 2]; 215 | 216 | for (var i = 0; i < chars; i += 2) 217 | bytes[i / 2] = Convert.ToByte(hex.Substring(i, 2), 16); 218 | 219 | return bytes; 220 | } 221 | 222 | /// 223 | /// Decrypts using AES/ECB/PKCS7 224 | /// 225 | public static byte[] SymmetricDecryptECB(byte[] input, byte[] key) 226 | { 227 | using var aes = Aes.Create(); 228 | aes.BlockSize = 128; 229 | aes.KeySize = 256; 230 | aes.Mode = CipherMode.ECB; 231 | aes.Padding = PaddingMode.PKCS7; 232 | 233 | using var aesTransform = aes.CreateDecryptor(key, null); 234 | var output = aesTransform.TransformFinalBlock(input, 0, input.Length); 235 | 236 | return output; 237 | } 238 | } 239 | } 240 | -------------------------------------------------------------------------------- /Icon/DepotDownloader.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SteamRE/DepotDownloader/c553ef4d60c00a4f5fd16c9fe017f569001589ff/Icon/DepotDownloader.ico -------------------------------------------------------------------------------- /Icon/DepotDownloader.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SteamRE/DepotDownloader/c553ef4d60c00a4f5fd16c9fe017f569001589ff/Icon/DepotDownloader.png -------------------------------------------------------------------------------- /Icon/DepotDownloader.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU GENERAL PUBLIC LICENSE 2 | Version 2, June 1991 3 | 4 | Copyright (C) 1989, 1991 Free Software Foundation, Inc., 5 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA 6 | Everyone is permitted to copy and distribute verbatim copies 7 | of this license document, but changing it is not allowed. 8 | 9 | Preamble 10 | 11 | The licenses for most software are designed to take away your 12 | freedom to share and change it. By contrast, the GNU General Public 13 | License is intended to guarantee your freedom to share and change free 14 | software--to make sure the software is free for all its users. This 15 | General Public License applies to most of the Free Software 16 | Foundation's software and to any other program whose authors commit to 17 | using it. (Some other Free Software Foundation software is covered by 18 | the GNU Lesser General Public License instead.) You can apply it to 19 | your programs, too. 20 | 21 | When we speak of free software, we are referring to freedom, not 22 | price. Our General Public Licenses are designed to make sure that you 23 | have the freedom to distribute copies of free software (and charge for 24 | this service if you wish), that you receive source code or can get it 25 | if you want it, that you can change the software or use pieces of it 26 | in new free programs; and that you know you can do these things. 27 | 28 | To protect your rights, we need to make restrictions that forbid 29 | anyone to deny you these rights or to ask you to surrender the rights. 30 | These restrictions translate to certain responsibilities for you if you 31 | distribute copies of the software, or if you modify it. 32 | 33 | For example, if you distribute copies of such a program, whether 34 | gratis or for a fee, you must give the recipients all the rights that 35 | you have. You must make sure that they, too, receive or can get the 36 | source code. And you must show them these terms so they know their 37 | rights. 38 | 39 | We protect your rights with two steps: (1) copyright the software, and 40 | (2) offer you this license which gives you legal permission to copy, 41 | distribute and/or modify the software. 42 | 43 | Also, for each author's protection and ours, we want to make certain 44 | that everyone understands that there is no warranty for this free 45 | software. If the software is modified by someone else and passed on, we 46 | want its recipients to know that what they have is not the original, so 47 | that any problems introduced by others will not reflect on the original 48 | authors' reputations. 49 | 50 | Finally, any free program is threatened constantly by software 51 | patents. We wish to avoid the danger that redistributors of a free 52 | program will individually obtain patent licenses, in effect making the 53 | program proprietary. To prevent this, we have made it clear that any 54 | patent must be licensed for everyone's free use or not licensed at all. 55 | 56 | The precise terms and conditions for copying, distribution and 57 | modification follow. 58 | 59 | GNU GENERAL PUBLIC LICENSE 60 | TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION 61 | 62 | 0. This License applies to any program or other work which contains 63 | a notice placed by the copyright holder saying it may be distributed 64 | under the terms of this General Public License. The "Program", below, 65 | refers to any such program or work, and a "work based on the Program" 66 | means either the Program or any derivative work under copyright law: 67 | that is to say, a work containing the Program or a portion of it, 68 | either verbatim or with modifications and/or translated into another 69 | language. (Hereinafter, translation is included without limitation in 70 | the term "modification".) Each licensee is addressed as "you". 71 | 72 | Activities other than copying, distribution and modification are not 73 | covered by this License; they are outside its scope. The act of 74 | running the Program is not restricted, and the output from the Program 75 | is covered only if its contents constitute a work based on the 76 | Program (independent of having been made by running the Program). 77 | Whether that is true depends on what the Program does. 78 | 79 | 1. You may copy and distribute verbatim copies of the Program's 80 | source code as you receive it, in any medium, provided that you 81 | conspicuously and appropriately publish on each copy an appropriate 82 | copyright notice and disclaimer of warranty; keep intact all the 83 | notices that refer to this License and to the absence of any warranty; 84 | and give any other recipients of the Program a copy of this License 85 | along with the Program. 86 | 87 | You may charge a fee for the physical act of transferring a copy, and 88 | you may at your option offer warranty protection in exchange for a fee. 89 | 90 | 2. You may modify your copy or copies of the Program or any portion 91 | of it, thus forming a work based on the Program, and copy and 92 | distribute such modifications or work under the terms of Section 1 93 | above, provided that you also meet all of these conditions: 94 | 95 | a) You must cause the modified files to carry prominent notices 96 | stating that you changed the files and the date of any change. 97 | 98 | b) You must cause any work that you distribute or publish, that in 99 | whole or in part contains or is derived from the Program or any 100 | part thereof, to be licensed as a whole at no charge to all third 101 | parties under the terms of this License. 102 | 103 | c) If the modified program normally reads commands interactively 104 | when run, you must cause it, when started running for such 105 | interactive use in the most ordinary way, to print or display an 106 | announcement including an appropriate copyright notice and a 107 | notice that there is no warranty (or else, saying that you provide 108 | a warranty) and that users may redistribute the program under 109 | these conditions, and telling the user how to view a copy of this 110 | License. (Exception: if the Program itself is interactive but 111 | does not normally print such an announcement, your work based on 112 | the Program is not required to print an announcement.) 113 | 114 | These requirements apply to the modified work as a whole. If 115 | identifiable sections of that work are not derived from the Program, 116 | and can be reasonably considered independent and separate works in 117 | themselves, then this License, and its terms, do not apply to those 118 | sections when you distribute them as separate works. But when you 119 | distribute the same sections as part of a whole which is a work based 120 | on the Program, the distribution of the whole must be on the terms of 121 | this License, whose permissions for other licensees extend to the 122 | entire whole, and thus to each and every part regardless of who wrote it. 123 | 124 | Thus, it is not the intent of this section to claim rights or contest 125 | your rights to work written entirely by you; rather, the intent is to 126 | exercise the right to control the distribution of derivative or 127 | collective works based on the Program. 128 | 129 | In addition, mere aggregation of another work not based on the Program 130 | with the Program (or with a work based on the Program) on a volume of 131 | a storage or distribution medium does not bring the other work under 132 | the scope of this License. 133 | 134 | 3. You may copy and distribute the Program (or a work based on it, 135 | under Section 2) in object code or executable form under the terms of 136 | Sections 1 and 2 above provided that you also do one of the following: 137 | 138 | a) Accompany it with the complete corresponding machine-readable 139 | source code, which must be distributed under the terms of Sections 140 | 1 and 2 above on a medium customarily used for software interchange; or, 141 | 142 | b) Accompany it with a written offer, valid for at least three 143 | years, to give any third party, for a charge no more than your 144 | cost of physically performing source distribution, a complete 145 | machine-readable copy of the corresponding source code, to be 146 | distributed under the terms of Sections 1 and 2 above on a medium 147 | customarily used for software interchange; or, 148 | 149 | c) Accompany it with the information you received as to the offer 150 | to distribute corresponding source code. (This alternative is 151 | allowed only for noncommercial distribution and only if you 152 | received the program in object code or executable form with such 153 | an offer, in accord with Subsection b above.) 154 | 155 | The source code for a work means the preferred form of the work for 156 | making modifications to it. For an executable work, complete source 157 | code means all the source code for all modules it contains, plus any 158 | associated interface definition files, plus the scripts used to 159 | control compilation and installation of the executable. However, as a 160 | special exception, the source code distributed need not include 161 | anything that is normally distributed (in either source or binary 162 | form) with the major components (compiler, kernel, and so on) of the 163 | operating system on which the executable runs, unless that component 164 | itself accompanies the executable. 165 | 166 | If distribution of executable or object code is made by offering 167 | access to copy from a designated place, then offering equivalent 168 | access to copy the source code from the same place counts as 169 | distribution of the source code, even though third parties are not 170 | compelled to copy the source along with the object code. 171 | 172 | 4. You may not copy, modify, sublicense, or distribute the Program 173 | except as expressly provided under this License. Any attempt 174 | otherwise to copy, modify, sublicense or distribute the Program is 175 | void, and will automatically terminate your rights under this License. 176 | However, parties who have received copies, or rights, from you under 177 | this License will not have their licenses terminated so long as such 178 | parties remain in full compliance. 179 | 180 | 5. You are not required to accept this License, since you have not 181 | signed it. However, nothing else grants you permission to modify or 182 | distribute the Program or its derivative works. These actions are 183 | prohibited by law if you do not accept this License. Therefore, by 184 | modifying or distributing the Program (or any work based on the 185 | Program), you indicate your acceptance of this License to do so, and 186 | all its terms and conditions for copying, distributing or modifying 187 | the Program or works based on it. 188 | 189 | 6. Each time you redistribute the Program (or any work based on the 190 | Program), the recipient automatically receives a license from the 191 | original licensor to copy, distribute or modify the Program subject to 192 | these terms and conditions. You may not impose any further 193 | restrictions on the recipients' exercise of the rights granted herein. 194 | You are not responsible for enforcing compliance by third parties to 195 | this License. 196 | 197 | 7. If, as a consequence of a court judgment or allegation of patent 198 | infringement or for any other reason (not limited to patent issues), 199 | conditions are imposed on you (whether by court order, agreement or 200 | otherwise) that contradict the conditions of this License, they do not 201 | excuse you from the conditions of this License. If you cannot 202 | distribute so as to satisfy simultaneously your obligations under this 203 | License and any other pertinent obligations, then as a consequence you 204 | may not distribute the Program at all. For example, if a patent 205 | license would not permit royalty-free redistribution of the Program by 206 | all those who receive copies directly or indirectly through you, then 207 | the only way you could satisfy both it and this License would be to 208 | refrain entirely from distribution of the Program. 209 | 210 | If any portion of this section is held invalid or unenforceable under 211 | any particular circumstance, the balance of the section is intended to 212 | apply and the section as a whole is intended to apply in other 213 | circumstances. 214 | 215 | It is not the purpose of this section to induce you to infringe any 216 | patents or other property right claims or to contest validity of any 217 | such claims; this section has the sole purpose of protecting the 218 | integrity of the free software distribution system, which is 219 | implemented by public license practices. Many people have made 220 | generous contributions to the wide range of software distributed 221 | through that system in reliance on consistent application of that 222 | system; it is up to the author/donor to decide if he or she is willing 223 | to distribute software through any other system and a licensee cannot 224 | impose that choice. 225 | 226 | This section is intended to make thoroughly clear what is believed to 227 | be a consequence of the rest of this License. 228 | 229 | 8. If the distribution and/or use of the Program is restricted in 230 | certain countries either by patents or by copyrighted interfaces, the 231 | original copyright holder who places the Program under this License 232 | may add an explicit geographical distribution limitation excluding 233 | those countries, so that distribution is permitted only in or among 234 | countries not thus excluded. In such case, this License incorporates 235 | the limitation as if written in the body of this License. 236 | 237 | 9. The Free Software Foundation may publish revised and/or new versions 238 | of the General Public License from time to time. Such new versions will 239 | be similar in spirit to the present version, but may differ in detail to 240 | address new problems or concerns. 241 | 242 | Each version is given a distinguishing version number. If the Program 243 | specifies a version number of this License which applies to it and "any 244 | later version", you have the option of following the terms and conditions 245 | either of that version or of any later version published by the Free 246 | Software Foundation. If the Program does not specify a version number of 247 | this License, you may choose any version ever published by the Free Software 248 | Foundation. 249 | 250 | 10. If you wish to incorporate parts of the Program into other free 251 | programs whose distribution conditions are different, write to the author 252 | to ask for permission. For software which is copyrighted by the Free 253 | Software Foundation, write to the Free Software Foundation; we sometimes 254 | make exceptions for this. Our decision will be guided by the two goals 255 | of preserving the free status of all derivatives of our free software and 256 | of promoting the sharing and reuse of software generally. 257 | 258 | NO WARRANTY 259 | 260 | 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY 261 | FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN 262 | OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES 263 | PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED 264 | OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF 265 | MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS 266 | TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE 267 | PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, 268 | REPAIR OR CORRECTION. 269 | 270 | 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 271 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR 272 | REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, 273 | INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING 274 | OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED 275 | TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY 276 | YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER 277 | PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE 278 | POSSIBILITY OF SUCH DAMAGES. 279 | 280 | END OF TERMS AND CONDITIONS 281 | 282 | How to Apply These Terms to Your New Programs 283 | 284 | If you develop a new program, and you want it to be of the greatest 285 | possible use to the public, the best way to achieve this is to make it 286 | free software which everyone can redistribute and change under these terms. 287 | 288 | To do so, attach the following notices to the program. It is safest 289 | to attach them to the start of each source file to most effectively 290 | convey the exclusion of warranty; and each file should have at least 291 | the "copyright" line and a pointer to where the full notice is found. 292 | 293 | 294 | Copyright (C) 295 | 296 | This program is free software; you can redistribute it and/or modify 297 | it under the terms of the GNU General Public License as published by 298 | the Free Software Foundation; either version 2 of the License, or 299 | (at your option) any later version. 300 | 301 | This program is distributed in the hope that it will be useful, 302 | but WITHOUT ANY WARRANTY; without even the implied warranty of 303 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 304 | GNU General Public License for more details. 305 | 306 | You should have received a copy of the GNU General Public License along 307 | with this program; if not, write to the Free Software Foundation, Inc., 308 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. 309 | 310 | Also add information on how to contact you by electronic and paper mail. 311 | 312 | If the program is interactive, make it output a short notice like this 313 | when it starts in an interactive mode: 314 | 315 | Gnomovision version 69, Copyright (C) year name of author 316 | Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. 317 | This is free software, and you are welcome to redistribute it 318 | under certain conditions; type `show c' for details. 319 | 320 | The hypothetical commands `show w' and `show c' should show the appropriate 321 | parts of the General Public License. Of course, the commands you use may 322 | be called something other than `show w' and `show c'; they could even be 323 | mouse-clicks or menu items--whatever suits your program. 324 | 325 | You should also get your employer (if you work as a programmer) or your 326 | school, if any, to sign a "copyright disclaimer" for the program, if 327 | necessary. Here is a sample; alter the names: 328 | 329 | Yoyodyne, Inc., hereby disclaims all copyright interest in the program 330 | `Gnomovision' (which makes passes at compilers) written by James Hacker. 331 | 332 | , 1 April 1989 333 | Ty Coon, President of Vice 334 | 335 | This General Public License does not permit incorporating your program into 336 | proprietary programs. If your program is a subroutine library, you may 337 | consider it more useful to permit linking proprietary applications with the 338 | library. If this is what you want to do, use the GNU Lesser General 339 | Public License instead of this License. 340 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | DepotDownloader 2 | =============== 3 | 4 | Steam depot downloader utilizing the SteamKit2 library. Supports .NET 8.0 5 | 6 | This program must be run from a console, it has no GUI. 7 | 8 | ## Installation 9 | 10 | ### Directly from GitHub 11 | 12 | Download a binary from [the releases page](https://github.com/SteamRE/DepotDownloader/releases/latest). 13 | 14 | ### via Windows Package Manager CLI (aka winget) 15 | 16 | On Windows, [winget](https://github.com/microsoft/winget-cli) users can download and install 17 | the latest Terminal release by installing the `SteamRE.DepotDownloader` 18 | package: 19 | 20 | ```powershell 21 | winget install --exact --id SteamRE.DepotDownloader 22 | ``` 23 | 24 | ### via Homebrew 25 | 26 | On macOS, [Homebrew](https://brew.sh) users can download and install that latest release by running the following commands: 27 | 28 | ```shell 29 | brew tap steamre/tools 30 | brew install depotdownloader 31 | ``` 32 | 33 | ## Usage 34 | 35 | ### Downloading one or all depots for an app 36 | ```powershell 37 | ./DepotDownloader -app [-depot [-manifest ]] 38 | [-username [-password ]] [other options] 39 | ``` 40 | 41 | For example: `./DepotDownloader -app 730 -depot 731 -manifest 7617088375292372759` 42 | 43 | By default it will use anonymous account ([view which apps are available on it here](https://steamdb.info/sub/17906/)). 44 | 45 | To use your account, specify the `-username ` parameter. Password will be asked interactively if you do 46 | not use specify the `-password` parameter. 47 | 48 | ### Downloading a workshop item using pubfile id 49 | ```powershell 50 | ./DepotDownloader -app -pubfile [-username [-password ]] 51 | ``` 52 | 53 | For example: `./DepotDownloader -app 730 -pubfile 1885082371` 54 | 55 | ### Downloading a workshop item using ugc id 56 | ```powershell 57 | ./DepotDownloader -app -ugc [-username [-password ]] 58 | ``` 59 | 60 | For example: `./DepotDownloader -app 730 -ugc 770604181014286929` 61 | 62 | ## Parameters 63 | 64 | #### Authentication 65 | 66 | Parameter | Description 67 | ----------------------- | ----------- 68 | `-username ` | the username of the account to login to for restricted content. 69 | `-password ` | the password of the account to login to for restricted content. 70 | `-remember-password` | if set, remember the password for subsequent logins of this user. (Use `-username -remember-password` as login credentials) 71 | `-qr` | display a login QR code to be scanned with the Steam mobile app 72 | `-no-mobile` | prefer entering a 2FA code instead of prompting to accept in the Steam mobile app. 73 | `-loginid <#>` | a unique 32-bit integer Steam LogonID in decimal, required if running multiple instances of DepotDownloader concurrently. 74 | 75 | #### Downloading 76 | 77 | Parameter | Description 78 | ------------------------ | ----------- 79 | `-app <#>` | the AppID to download. 80 | `-depot <#>` | the DepotID to download. 81 | `-manifest ` | manifest id of content to download (requires `-depot`, default: current for branch). 82 | `-ugc <#>` | the UGC ID to download. 83 | `-pubfile <#>` | the PublishedFileId to download. (Will automatically resolve to UGC id) 84 | `-branch ` | download from specified branch if available (default: Public). 85 | `-branchpassword ` | branch password if applicable. 86 | 87 | #### Download configuration 88 | 89 | Parameter | Description 90 | ----------------------- | ----------- 91 | `-all-platforms` | downloads all platform-specific depots when `-app` is used. 92 | `-os ` | the operating system for which to download the game (windows, macos or linux, default: OS the program is currently running on) 93 | `-osarch ` | the architecture for which to download the game (32 or 64, default: the host's architecture) 94 | `-all-archs` | download all architecture-specific depots when `-app` is used. 95 | `-all-languages` | download all language-specific depots when `-app` is used. 96 | `-language ` | the language for which to download the game (default: english) 97 | `-lowviolence` | download low violence depots when `-app` is used. 98 | `-dir ` | the directory in which to place downloaded files. 99 | `-filelist ` | the name of a local file that contains a list of files to download (from the manifest). prefix file path with `regex:` if you want to match with regex. each file path should be on their own line. 100 | `-validate` | include checksum verification of files already downloaded. 101 | `-manifest-only` | downloads a human readable manifest for any depots that would be downloaded. 102 | `-cellid <#>` | the overridden CellID of the content server to download from. 103 | `-max-downloads <#>` | maximum number of chunks to download concurrently. (default: 8). 104 | `-use-lancache` | forces downloads over the local network via a Lancache instance. 105 | 106 | #### Other 107 | 108 | Parameter | Description 109 | ----------------------- | ----------- 110 | `-debug` | enable verbose debug logging. 111 | `-V` or `--version` | print version and runtime. 112 | 113 | ## Frequently Asked Questions 114 | 115 | ### Why am I prompted to enter a 2-factor code every time I run the app? 116 | Your 2-factor code authenticates a Steam session. You need to "remember" your session with `-remember-password` which persists the login key for your Steam session. 117 | 118 | ### Can I run DepotDownloader while an account is already connected to Steam? 119 | Any connection to Steam will be closed if they share a LoginID. You can specify a different LoginID with `-loginid`. 120 | 121 | ### Why doesn't my password containing special characters work? Do I have to specify the password on the command line? 122 | If you pass the `-password` parameter with a password that contains special characters, you will need to escape the command appropriately for the shell you are using. You do not have to include the `-password` parameter on the command line as long as you include a `-username`. You will be prompted to enter your password interactively. 123 | 124 | ### I am getting error 401 or no manifest code returned for old manifest ids 125 | Try logging in with a Steam account, this may happen when using anonymous account. 126 | 127 | Steam allows developers to block downloading old manifests, in which case no manifest code is returned even when parameters appear correct. 128 | 129 | ### Why am I getting slow download speeds and frequent connection timeouts? 130 | When downloading old builds, cache server may not have the chunks readily available which makes downloading slower. 131 | Try increasing `-max-downloads` to saturate the network more. 132 | -------------------------------------------------------------------------------- /global.json: -------------------------------------------------------------------------------- 1 | { 2 | "sdk": { 3 | "version": "9.0.100", 4 | "rollForward": "latestMinor" 5 | } 6 | } 7 | --------------------------------------------------------------------------------