├── .github └── workflows │ ├── nuget_push.yml │ └── tests.yml ├── .gitignore ├── LICENSE ├── README.md ├── icon.png ├── icon.psd └── src ├── EpicManifestParser.Playground ├── EpicManifestParser.Playground.csproj └── Program.cs ├── EpicManifestParser.Tests ├── DeserializeTests.cs ├── EpicManifestParser.Tests.csproj ├── UncompressTests.cs └── files │ ├── chunk_compressed.bin │ ├── chunk_uncompressed.bin │ ├── manifest.bin │ └── manifest.json ├── EpicManifestParser.ZlibngDotNetDecompressor ├── EpicManifestParser.ZlibngDotNetDecompressor.csproj └── ManifestZlibngDotNetDecompressor.cs ├── EpicManifestParser.sln └── EpicManifestParser ├── Api └── ManifestInfo.cs ├── EpicManifestParser.csproj ├── EpicManifestParser.csproj.DotSettings ├── FFileManifestStream.cs ├── GlobalUsings.cs ├── Json ├── BlobString.cs └── JsonNodeExtensions.cs ├── ManifestParseOptions.cs ├── ManifestZlibStreamDecompressor.cs └── UE ├── EChunkDataListVersion.cs ├── EChunkHashFlags.cs ├── EChunkStorageFlags.cs ├── EChunkVersion.cs ├── EFeatureLevel.cs ├── EFileManifestListVersion.cs ├── EFileMetaFlags.cs ├── EManifestMetaVersion.cs ├── EManifestStorageFlags.cs ├── FBuildPatchAppManifest.cs ├── FChunkHeader.cs ├── FChunkInfo.cs ├── FChunkPart.cs ├── FCustomField.cs ├── FFileManifest.cs ├── FGuid.cs ├── FManifestHeader.cs ├── FManifestMeta.cs ├── FSHAHash.cs └── TypeAliases.cs /.github/workflows/nuget_push.yml: -------------------------------------------------------------------------------- 1 | name: NuGet Push 2 | 3 | on: 4 | workflow_run: 5 | workflows: [Tests] 6 | types: 7 | - completed 8 | 9 | defaults: 10 | run: 11 | working-directory: src 12 | 13 | jobs: 14 | build: 15 | runs-on: ubuntu-latest 16 | #if: github.event.head_commit.message matches '^v[0-9]+(\.[0-9]+)*$' 17 | steps: 18 | - uses: actions/checkout@v4 19 | - name: Setup .NET 20 | uses: actions/setup-dotnet@v4 21 | with: 22 | dotnet-version: | 23 | 8.0.x 24 | 9.0.x 25 | 26 | - name: Build & Pack NuGet Package(s) 27 | run: dotnet pack -c Release --output ~/nuget-packages 28 | 29 | - name: Upload Build Artifact(s) 30 | uses: actions/upload-artifact@v4 31 | with: 32 | name: nuget-packages 33 | path: ~/nuget-packages 34 | 35 | - name: Push NuGet Package(s) 36 | run: dotnet nuget push ~/nuget-packages/*.nupkg --source https://api.nuget.org/v3/index.json --api-key ${{ secrets.NUGET_API_KEY }} --skip-duplicate 37 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | branches: 9 | - master 10 | 11 | defaults: 12 | run: 13 | working-directory: src 14 | 15 | jobs: 16 | build: 17 | runs-on: ubuntu-latest 18 | steps: 19 | - uses: actions/checkout@v4 20 | - name: Setup .NET 21 | uses: actions/setup-dotnet@v4 22 | with: 23 | dotnet-version: | 24 | 8.0.x 25 | 9.0.x 26 | 27 | - name: Restore dependencies 28 | run: dotnet restore 29 | 30 | - name: Build 31 | run: dotnet build --no-restore -c Release 32 | 33 | - name: Run Tests 34 | run: dotnet test --no-restore --no-build -c Release -v normal 35 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ## Ignore Visual Studio temporary files, build results, and 2 | ## files generated by popular Visual Studio add-ons. 3 | ## 4 | ## Get latest from https://github.com/github/gitignore/blob/main/VisualStudio.gitignore 5 | 6 | # User-specific files 7 | *.rsuser 8 | *.suo 9 | *.user 10 | *.userosscache 11 | *.sln.docstates 12 | 13 | # User-specific files (MonoDevelop/Xamarin Studio) 14 | *.userprefs 15 | 16 | # Mono auto generated files 17 | mono_crash.* 18 | 19 | # Build results 20 | [Dd]ebug/ 21 | [Dd]ebugPublic/ 22 | [Rr]elease/ 23 | [Rr]eleases/ 24 | x64/ 25 | x86/ 26 | [Ww][Ii][Nn]32/ 27 | [Aa][Rr][Mm]/ 28 | [Aa][Rr][Mm]64/ 29 | bld/ 30 | [Bb]in/ 31 | [Oo]bj/ 32 | [Ll]og/ 33 | [Ll]ogs/ 34 | 35 | # Visual Studio 2015/2017 cache/options directory 36 | .vs/ 37 | # Uncomment if you have tasks that create the project's static files in wwwroot 38 | #wwwroot/ 39 | 40 | # Visual Studio 2017 auto generated files 41 | Generated\ Files/ 42 | 43 | # MSTest test Results 44 | [Tt]est[Rr]esult*/ 45 | [Bb]uild[Ll]og.* 46 | 47 | # NUnit 48 | *.VisualState.xml 49 | TestResult.xml 50 | nunit-*.xml 51 | 52 | # Build Results of an ATL Project 53 | [Dd]ebugPS/ 54 | [Rr]eleasePS/ 55 | dlldata.c 56 | 57 | # Benchmark Results 58 | BenchmarkDotNet.Artifacts/ 59 | 60 | # .NET Core 61 | project.lock.json 62 | project.fragment.lock.json 63 | artifacts/ 64 | 65 | # ASP.NET Scaffolding 66 | ScaffoldingReadMe.txt 67 | 68 | # StyleCop 69 | StyleCopReport.xml 70 | 71 | # Files built by Visual Studio 72 | *_i.c 73 | *_p.c 74 | *_h.h 75 | *.ilk 76 | *.meta 77 | *.obj 78 | *.iobj 79 | *.pch 80 | *.pdb 81 | *.ipdb 82 | *.pgc 83 | *.pgd 84 | *.rsp 85 | *.sbr 86 | *.tlb 87 | *.tli 88 | *.tlh 89 | *.tmp 90 | *.tmp_proj 91 | *_wpftmp.csproj 92 | *.log 93 | *.tlog 94 | *.vspscc 95 | *.vssscc 96 | .builds 97 | *.pidb 98 | *.svclog 99 | *.scc 100 | 101 | # Chutzpah Test files 102 | _Chutzpah* 103 | 104 | # Visual C++ cache files 105 | ipch/ 106 | *.aps 107 | *.ncb 108 | *.opendb 109 | *.opensdf 110 | *.sdf 111 | *.cachefile 112 | *.VC.db 113 | *.VC.VC.opendb 114 | 115 | # Visual Studio profiler 116 | *.psess 117 | *.vsp 118 | *.vspx 119 | *.sap 120 | 121 | # Visual Studio Trace Files 122 | *.e2e 123 | 124 | # TFS 2012 Local Workspace 125 | $tf/ 126 | 127 | # Guidance Automation Toolkit 128 | *.gpState 129 | 130 | # ReSharper is a .NET coding add-in 131 | _ReSharper*/ 132 | *.[Rr]e[Ss]harper 133 | *.DotSettings.user 134 | 135 | # TeamCity is a build add-in 136 | _TeamCity* 137 | 138 | # DotCover is a Code Coverage Tool 139 | *.dotCover 140 | 141 | # AxoCover is a Code Coverage Tool 142 | .axoCover/* 143 | !.axoCover/settings.json 144 | 145 | # Coverlet is a free, cross platform Code Coverage Tool 146 | coverage*.json 147 | coverage*.xml 148 | coverage*.info 149 | 150 | # Visual Studio code coverage results 151 | *.coverage 152 | *.coveragexml 153 | 154 | # NCrunch 155 | _NCrunch_* 156 | .*crunch*.local.xml 157 | nCrunchTemp_* 158 | 159 | # MightyMoose 160 | *.mm.* 161 | AutoTest.Net/ 162 | 163 | # Web workbench (sass) 164 | .sass-cache/ 165 | 166 | # Installshield output folder 167 | [Ee]xpress/ 168 | 169 | # DocProject is a documentation generator add-in 170 | DocProject/buildhelp/ 171 | DocProject/Help/*.HxT 172 | DocProject/Help/*.HxC 173 | DocProject/Help/*.hhc 174 | DocProject/Help/*.hhk 175 | DocProject/Help/*.hhp 176 | DocProject/Help/Html2 177 | DocProject/Help/html 178 | 179 | # Click-Once directory 180 | publish/ 181 | 182 | # Publish Web Output 183 | *.[Pp]ublish.xml 184 | *.azurePubxml 185 | # Note: Comment the next line if you want to checkin your web deploy settings, 186 | # but database connection strings (with potential passwords) will be unencrypted 187 | *.pubxml 188 | *.publishproj 189 | 190 | # Microsoft Azure Web App publish settings. Comment the next line if you want to 191 | # checkin your Azure Web App publish settings, but sensitive information contained 192 | # in these scripts will be unencrypted 193 | PublishScripts/ 194 | 195 | # NuGet Packages 196 | *.nupkg 197 | # NuGet Symbol Packages 198 | *.snupkg 199 | # The packages folder can be ignored because of Package Restore 200 | **/[Pp]ackages/* 201 | # except build/, which is used as an MSBuild target. 202 | !**/[Pp]ackages/build/ 203 | # Uncomment if necessary however generally it will be regenerated when needed 204 | #!**/[Pp]ackages/repositories.config 205 | # NuGet v3's project.json files produces more ignorable files 206 | *.nuget.props 207 | *.nuget.targets 208 | 209 | # Microsoft Azure Build Output 210 | csx/ 211 | *.build.csdef 212 | 213 | # Microsoft Azure Emulator 214 | ecf/ 215 | rcf/ 216 | 217 | # Windows Store app package directories and files 218 | AppPackages/ 219 | BundleArtifacts/ 220 | Package.StoreAssociation.xml 221 | _pkginfo.txt 222 | *.appx 223 | *.appxbundle 224 | *.appxupload 225 | 226 | # Visual Studio cache files 227 | # files ending in .cache can be ignored 228 | *.[Cc]ache 229 | # but keep track of directories ending in .cache 230 | !?*.[Cc]ache/ 231 | 232 | # Others 233 | ClientBin/ 234 | ~$* 235 | *~ 236 | *.dbmdl 237 | *.dbproj.schemaview 238 | *.jfm 239 | *.pfx 240 | *.publishsettings 241 | orleans.codegen.cs 242 | 243 | # Including strong name files can present a security risk 244 | # (https://github.com/github/gitignore/pull/2483#issue-259490424) 245 | #*.snk 246 | 247 | # Since there are multiple workflows, uncomment next line to ignore bower_components 248 | # (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) 249 | #bower_components/ 250 | 251 | # RIA/Silverlight projects 252 | Generated_Code/ 253 | 254 | # Backup & report files from converting an old project file 255 | # to a newer Visual Studio version. Backup files are not needed, 256 | # because we have git ;-) 257 | _UpgradeReport_Files/ 258 | Backup*/ 259 | UpgradeLog*.XML 260 | UpgradeLog*.htm 261 | ServiceFabricBackup/ 262 | *.rptproj.bak 263 | 264 | # SQL Server files 265 | *.mdf 266 | *.ldf 267 | *.ndf 268 | 269 | # Business Intelligence projects 270 | *.rdl.data 271 | *.bim.layout 272 | *.bim_*.settings 273 | *.rptproj.rsuser 274 | *- [Bb]ackup.rdl 275 | *- [Bb]ackup ([0-9]).rdl 276 | *- [Bb]ackup ([0-9][0-9]).rdl 277 | 278 | # Microsoft Fakes 279 | FakesAssemblies/ 280 | 281 | # GhostDoc plugin setting file 282 | *.GhostDoc.xml 283 | 284 | # Node.js Tools for Visual Studio 285 | .ntvs_analysis.dat 286 | node_modules/ 287 | 288 | # Visual Studio 6 build log 289 | *.plg 290 | 291 | # Visual Studio 6 workspace options file 292 | *.opt 293 | 294 | # Visual Studio 6 auto-generated workspace file (contains which files were open etc.) 295 | *.vbw 296 | 297 | # Visual Studio 6 auto-generated project file (contains which files were open etc.) 298 | *.vbp 299 | 300 | # Visual Studio 6 workspace and project file (working project files containing files to include in project) 301 | *.dsw 302 | *.dsp 303 | 304 | # Visual Studio 6 technical files 305 | *.ncb 306 | *.aps 307 | 308 | # Visual Studio LightSwitch build output 309 | **/*.HTMLClient/GeneratedArtifacts 310 | **/*.DesktopClient/GeneratedArtifacts 311 | **/*.DesktopClient/ModelManifest.xml 312 | **/*.Server/GeneratedArtifacts 313 | **/*.Server/ModelManifest.xml 314 | _Pvt_Extensions 315 | 316 | # Paket dependency manager 317 | .paket/paket.exe 318 | paket-files/ 319 | 320 | # FAKE - F# Make 321 | .fake/ 322 | 323 | # CodeRush personal settings 324 | .cr/personal 325 | 326 | # Python Tools for Visual Studio (PTVS) 327 | __pycache__/ 328 | *.pyc 329 | 330 | # Cake - Uncomment if you are using it 331 | # tools/** 332 | # !tools/packages.config 333 | 334 | # Tabs Studio 335 | *.tss 336 | 337 | # Telerik's JustMock configuration file 338 | *.jmconfig 339 | 340 | # BizTalk build output 341 | *.btp.cs 342 | *.btm.cs 343 | *.odx.cs 344 | *.xsd.cs 345 | 346 | # OpenCover UI analysis results 347 | OpenCover/ 348 | 349 | # Azure Stream Analytics local run output 350 | ASALocalRun/ 351 | 352 | # MSBuild Binary and Structured Log 353 | *.binlog 354 | 355 | # NVidia Nsight GPU debugger configuration file 356 | *.nvuser 357 | 358 | # MFractors (Xamarin productivity tool) working folder 359 | .mfractor/ 360 | 361 | # Local History for Visual Studio 362 | .localhistory/ 363 | 364 | # Visual Studio History (VSHistory) files 365 | .vshistory/ 366 | 367 | # BeatPulse healthcheck temp database 368 | healthchecksdb 369 | 370 | # Backup folder for Package Reference Convert tool in Visual Studio 2017 371 | MigrationBackup/ 372 | 373 | # Ionide (cross platform F# VS Code tools) working folder 374 | .ionide/ 375 | 376 | # Fody - auto-generated XML schema 377 | FodyWeavers.xsd 378 | 379 | # VS Code files for those working on multiple tools 380 | .vscode/* 381 | !.vscode/settings.json 382 | !.vscode/tasks.json 383 | !.vscode/launch.json 384 | !.vscode/extensions.json 385 | *.code-workspace 386 | 387 | # Local History for Visual Studio Code 388 | .history/ 389 | 390 | # Windows Installer files from build outputs 391 | *.cab 392 | *.msi 393 | *.msix 394 | *.msm 395 | *.msp 396 | 397 | # JetBrains Rider 398 | *.sln.iml 399 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 NotOfficer 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 | # EpicManifestParser 4 | A .NET parser & downloader for EpicGames manifests. 5 | 6 | [![GitHub release](https://img.shields.io/github/v/release/NotOfficer/EpicManifestParser?logo=github)](https://github.com/NotOfficer/EpicManifestParser/releases/latest) [![Nuget](https://img.shields.io/nuget/v/EpicManifestParser?logo=nuget)](https://www.nuget.org/packages/EpicManifestParser) ![Nuget DLs](https://img.shields.io/nuget/dt/EpicManifestParser?logo=nuget) [![GitHub issues](https://img.shields.io/github/issues/NotOfficer/EpicManifestParser?logo=github)](https://github.com/NotOfficer/EpicManifestParser/issues) [![GitHub License](https://img.shields.io/github/license/NotOfficer/EpicManifestParser)](https://github.com/NotOfficer/EpicManifestParser/blob/master/LICENSE) 7 | 8 |
9 | 10 | ## NuGet 11 | 12 | Install-Package EpicManifestParser 13 | 14 | ## Usage 15 | 16 | Please take a look into [this](https://github.com/NotOfficer/EpicManifestParser/blob/master/src/EpicManifestParser.Playground/Program.cs). 17 | A more detailed documentation isn't available right now. 18 | -------------------------------------------------------------------------------- /icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NotOfficer/EpicManifestParser/5e1146c06c0e8cc24bb0c050627216ddbcece648/icon.png -------------------------------------------------------------------------------- /icon.psd: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NotOfficer/EpicManifestParser/5e1146c06c0e8cc24bb0c050627216ddbcece648/icon.psd -------------------------------------------------------------------------------- /src/EpicManifestParser.Playground/EpicManifestParser.Playground.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | Exe 5 | net9.0;net8.0 6 | enable 7 | enable 8 | false 9 | AnyCPU;x64 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /src/EpicManifestParser.Playground/Program.cs: -------------------------------------------------------------------------------- 1 | using System.Diagnostics; 2 | 3 | using BenchmarkDotNet.Attributes; 4 | using BenchmarkDotNet.Configs; 5 | using BenchmarkDotNet.Jobs; 6 | 7 | using EpicManifestParser; 8 | using EpicManifestParser.Api; 9 | using EpicManifestParser.UE; 10 | using EpicManifestParser.ZlibngDotNetDecompressor; 11 | 12 | using OffiUtils; 13 | 14 | using ZlibngDotNet; 15 | 16 | var zlibng = new Zlibng(Benchmarks.ZlibngPath); 17 | 18 | await TestLauncherManifest(zlibng); 19 | return; 20 | 21 | static async Task TestLauncherManifest(Zlibng? zlibng = null) 22 | { 23 | var options = new ManifestParseOptions 24 | { 25 | ChunkBaseUrl = "http://download.epicgames.com/Builds/UnrealEngineLauncher/CloudDir/", 26 | //ChunkCacheDirectory = Directory.CreateDirectory(Path.Combine(Benchmarks.DownloadsDir, "chunks_v2")).FullName, 27 | //ManifestCacheDirectory = Directory.CreateDirectory(Path.Combine(Benchmarks.DownloadsDir, "manifests_v2")).FullName, 28 | }; 29 | 30 | if (zlibng is not null) 31 | { 32 | Console.WriteLine($"Zlib-ng version: {zlibng.GetVersionString()}"); 33 | options.Decompressor = ManifestZlibngDotNetDecompressor.Decompress; 34 | options.DecompressorState = zlibng; 35 | } 36 | 37 | Console.WriteLine("Loading manifest bytes..."); 38 | var manifestBuffer = await File.ReadAllBytesAsync(Path.Combine(Benchmarks.DownloadsDir, "EpicGamesLauncher2.9.2-2874913-Portal-Release-Live-Windows.manifest")); 39 | Console.WriteLine("Deserializing manifest..."); 40 | var manifest = FBuildPatchAppManifest.Deserialize(manifestBuffer, options); 41 | 42 | var fileManifest = manifest.FindFile("Portal/Binaries/Win64/EpicGamesLauncher.exe")!; 43 | var stream = fileManifest.GetStream(); 44 | 45 | var fileBytes = new byte[stream.Length]; 46 | 47 | #if !DEBUG 48 | await Task.Delay(TimeSpan.FromSeconds(10)); 49 | 50 | for (var i = 0; i < 10_000; i++) 51 | { 52 | await stream.SaveBytesAsync(fileBytes); 53 | } 54 | #else 55 | var fileName = fileManifest.FileName.CutAfterLast('/')!; 56 | Console.WriteLine($"Saving {fileName}..."); 57 | 58 | try 59 | { 60 | await stream.SaveBytesAsync(fileBytes, ProgressCallback, fileName); 61 | } 62 | catch (Exception ex) 63 | { 64 | var uri = ex.Data["Uri"]; 65 | var headers = ex.Data["Headers"]; 66 | return []; 67 | } 68 | 69 | Console.WriteLine($"Hashes match: {fileManifest.FileHash == FSHAHash.Compute(fileBytes)}"); 70 | #endif 71 | 72 | return fileBytes; 73 | } 74 | 75 | //BenchmarkDotNet.Running.BenchmarkRunner.Run(); 76 | //return; 77 | 78 | var options = new ManifestParseOptions 79 | { 80 | //ChunkBaseUrl = "http://fastly-download.epicgames.com/Builds/Fortnite/Content/CloudDir/", 81 | ChunkBaseUrl = "http://fastly-download.epicgames.com/Builds/Fortnite/CloudDir/", 82 | ChunkCacheDirectory = Directory.CreateDirectory(Path.Combine(Benchmarks.DownloadsDir, "chunks_v2")).FullName, 83 | ManifestCacheDirectory = Directory.CreateDirectory(Path.Combine(Benchmarks.DownloadsDir, "manifests_v2")).FullName, 84 | CacheChunksAsIs = false, 85 | 86 | Decompressor = ManifestZlibngDotNetDecompressor.Decompress, 87 | DecompressorState = zlibng 88 | }; 89 | 90 | var client = options.CreateDefaultClient(); 91 | 92 | using var manifestResponse = await client.GetAsync("https://media.wtf/XlQk.json"); 93 | var manifestInfo1 = await manifestResponse.Content.ReadManifestInfoAsync(); 94 | var manifestInfo2 = await ManifestInfo.DeserializeFileAsync(Benchmarks.ManifestInfoPath); 95 | 96 | //var manifestInfoTuple = await manifestInfo2!.DownloadAndParseAsync(options); 97 | //var parseResult = manifestInfoTuple.InfoElement.TryParseVersionAndCL(out var infoVersion, out var infoCl); 98 | 99 | //var randomGuid = FGuid.Random(); 100 | //var chunkGuid = new FGuid("A76EAD354E9F6F06D0E75CAC2AB1B56C"); 101 | 102 | var manifestBuffer = await File.ReadAllBytesAsync(Benchmarks.ManifestPath); 103 | 104 | var sw = Stopwatch.StartNew(); 105 | var manifest = FBuildPatchAppManifest.Deserialize(manifestBuffer, options); 106 | sw.Stop(); 107 | Console.WriteLine(Math.Round(sw.Elapsed.TotalMilliseconds, 0)); 108 | 109 | { 110 | var fileManifest = manifest.Files.First(x => 111 | x.FileName.EndsWith("/pakchunk0optional-WindowsClient.ucas", StringComparison.Ordinal)); 112 | var fileManifestFileName = Path.GetFileName(fileManifest.FileName); 113 | var fileManifestStream = fileManifest.GetStream(); 114 | 115 | await fileManifestStream.SaveFileAsync(Path.Combine(Benchmarks.DownloadsDir, fileManifestFileName)); 116 | 117 | var fileBuffer = await fileManifestStream.SaveBytesAsync(); 118 | Console.WriteLine($"{fileManifest.FileHash} / {FSHAHash.Compute(fileBuffer)}"); 119 | 120 | sw.Restart(); 121 | fileBuffer = new byte[fileManifest.FileSize]; 122 | await fileManifestStream.SaveBytesAsync(fileBuffer, ProgressCallback, fileManifestFileName); 123 | //await fileManifestStream.SaveToAsync(new MemoryStream(fileBuffer, 0, fileBuffer.Length, true, true), ProgressCallback, fileManifestFileName); 124 | sw.Stop(); 125 | Console.WriteLine($"{fileManifest.FileHash} / {FSHAHash.Compute(fileBuffer)}"); 126 | } 127 | 128 | Console.ReadLine(); 129 | 130 | static void ProgressCallback(SaveProgressChangedEventArgs eventArgs) 131 | { 132 | var text = (string)eventArgs.UserState!; 133 | Console.WriteLine($"{text}: {eventArgs.ProgressPercentage}% ({eventArgs.BytesSaved}/{eventArgs.TotalBytesToSave})"); 134 | } 135 | 136 | [GroupBenchmarksBy(BenchmarkLogicalGroupRule.ByCategory)] 137 | [CategoriesColumn] 138 | [BaselineColumn] 139 | [MemoryDiagnoser(false)] 140 | [SimpleJob(RuntimeMoniker.Net90, baseline: true)] 141 | [SimpleJob(RuntimeMoniker.Net80)] 142 | public class Benchmarks 143 | { 144 | public static string DownloadsDir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Downloads"); 145 | 146 | public static string ManifestPath = Path.Combine(DownloadsDir, "Kauyq4jGHB-SuyDjakmArJ1VU6QYJw.manifest"); 147 | public static string ZlibngPath = Path.Combine(DownloadsDir, "zlib-ng2.dll"); 148 | public static string ManifestInfoPath = Path.Combine(DownloadsDir, "manifestinfo.json"); 149 | 150 | public static string TestChunkPath = Path.Combine(DownloadsDir, "8ED2116F187190BA_996E9BFD428888C4627AE6B1153404C3.chunk"); 151 | 152 | private byte[] _manifestBuffer = null!; 153 | private byte[] _testChunkBuffer = null!; 154 | private byte[] _testTempBuffer = null!; 155 | private Zlibng _zlibng = null!; 156 | private byte[] _manifestInfoBuffer = null!; 157 | private FBuildPatchAppManifest _manifest = null!; 158 | private FFileManifestStream _fileManifestStream1 = null!; 159 | private FFileManifestStream _fileManifestStream2 = null!; 160 | private byte[] _fileBuffer = null!; 161 | private MemoryStream _fileMs = null!; 162 | private string _filePath = null!; 163 | private FGuid _guid; 164 | 165 | [GlobalSetup] 166 | public void Setup() 167 | { 168 | _guid = FGuid.Random(); 169 | _testTempBuffer = new byte[10000000]; 170 | _zlibng = new Zlibng(ZlibngPath); 171 | _testChunkBuffer = File.ReadAllBytes(TestChunkPath); 172 | 173 | _manifestBuffer = File.ReadAllBytes(ManifestPath); 174 | _manifestInfoBuffer = File.ReadAllBytes(ManifestInfoPath); 175 | 176 | _manifest = FBuildPatchAppManifest.Deserialize(_manifestBuffer, options => 177 | { 178 | //options.ChunkBaseUrl = "http://download.epicgames.com/Builds/Fortnite/CloudDir/"; // 20-21 ms 179 | //options.ChunkBaseUrl = "http://cloudflare.epicgamescdn.com/Builds/Fortnite/CloudDir/"; // 34-36 ms 180 | options.ChunkBaseUrl = "http://fastly-download.epicgames.com/Builds/Fortnite/CloudDir/"; // 19-20 ms 181 | //options.ChunkBaseUrl = "http://epicgames-download1.akamaized.net/Builds/Fortnite/CloudDir/"; // 27-28 ms 182 | options.ChunkCacheDirectory = Directory.CreateDirectory(Path.Combine(DownloadsDir, "chunks_v2")).FullName; 183 | }); 184 | var fileManifest = _manifest.Files.First(x => 185 | x.FileName.EndsWith("/pakchunk0optional-WindowsClient.ucas", StringComparison.Ordinal)); 186 | _filePath = Path.Combine(DownloadsDir, Path.GetFileName(fileManifest.FileName)); 187 | _fileBuffer = new byte[fileManifest.FileSize]; 188 | _fileMs = new MemoryStream(_fileBuffer, true); 189 | _fileManifestStream1 = fileManifest.GetStream(true); 190 | _fileManifestStream2 = fileManifest.GetStream(false); 191 | } 192 | 193 | [Benchmark(Baseline = true), BenchmarkCategory("Uncompress")] 194 | public byte[] FChunkInfo_Uncompress_Zlibng() 195 | { 196 | FChunkInfo.Test_Zlibng(_testTempBuffer, _testChunkBuffer, _zlibng, ManifestZlibngDotNetDecompressor.Decompress); 197 | return _testTempBuffer; 198 | } 199 | 200 | [Benchmark, BenchmarkCategory("Uncompress")] 201 | public byte[] FChunkInfo_Uncompress_ZlibStream() 202 | { 203 | FChunkInfo.Test_ZlibStream(_testTempBuffer, _testChunkBuffer); 204 | return _testTempBuffer; 205 | } 206 | 207 | [Benchmark, BenchmarkCategory("Deserialize")] 208 | public FBuildPatchAppManifest FBuildPatchAppManifest_Deserialize() 209 | { 210 | return FBuildPatchAppManifest.Deserialize(_manifestBuffer); 211 | } 212 | 213 | [Benchmark, BenchmarkCategory("Deserialize")] 214 | public ManifestInfo? ManifestInfo_Deserialize() 215 | { 216 | return ManifestInfo.Deserialize(_manifestInfoBuffer); 217 | } 218 | 219 | [BenchmarkCategory("SaveBuffer"), Benchmark(Baseline = true)] 220 | public async Task FFileManifestStream_SaveBuffer() 221 | { 222 | await _fileManifestStream2.SaveBytesAsync(_fileBuffer); 223 | } 224 | 225 | [BenchmarkCategory("SaveBuffer"), Benchmark] 226 | public async Task FFileManifestStream_SaveBuffer_AsIs() 227 | { 228 | await _fileManifestStream1.SaveBytesAsync(_fileBuffer); 229 | } 230 | 231 | //[BenchmarkCategory("SaveFile"), Benchmark(Baseline = true)] 232 | //public async Task FFileManifestStream_SaveFile() 233 | //{ 234 | // await _fileManifestStream2.SaveFileAsync(_filePath); 235 | //} 236 | 237 | //[BenchmarkCategory("SaveFile"), Benchmark] 238 | //public async Task FFileManifestStream_SaveFile_AsIs() 239 | //{ 240 | // await _fileManifestStream1.SaveFileAsync(_filePath); 241 | //} 242 | 243 | [BenchmarkCategory("SaveStream"), Benchmark(Baseline = true)] 244 | public async Task FFileManifestStream_SaveStream() 245 | { 246 | _fileMs.Position = 0; 247 | await _fileManifestStream2.SaveToAsync(_fileMs); 248 | } 249 | 250 | [BenchmarkCategory("SaveStream"), Benchmark] 251 | public async Task FFileManifestStream_SaveStream_AsIs() 252 | { 253 | _fileMs.Position = 0; 254 | await _fileManifestStream1.SaveToAsync(_fileMs); 255 | } 256 | } 257 | -------------------------------------------------------------------------------- /src/EpicManifestParser.Tests/DeserializeTests.cs: -------------------------------------------------------------------------------- 1 | using EpicManifestParser.UE; 2 | 3 | namespace EpicManifestParser.Tests; 4 | 5 | public class DeserializeTests 6 | { 7 | [Fact] 8 | public async Task Deserialize_Binary_Manifest() 9 | { 10 | var manifestBuffer = await File.ReadAllBytesAsync("files/manifest.bin"); 11 | var manifest = FBuildPatchAppManifest.Deserialize(manifestBuffer); 12 | Assert.NotNull(manifest); 13 | 14 | // TODO: more assertions 15 | } 16 | 17 | [Fact] 18 | public async Task Deserialize_Json_Manifest() 19 | { 20 | var manifestBuffer = await File.ReadAllBytesAsync("files/manifest.json"); 21 | var manifest = FBuildPatchAppManifest.Deserialize(manifestBuffer); 22 | Assert.NotNull(manifest); 23 | 24 | // TODO: more assertions 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/EpicManifestParser.Tests/EpicManifestParser.Tests.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | net9.0;net8.0 5 | enable 6 | enable 7 | false 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | PreserveNewest 29 | 30 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /src/EpicManifestParser.Tests/UncompressTests.cs: -------------------------------------------------------------------------------- 1 | using System.Buffers; 2 | using System.Diagnostics; 3 | using System.Net; 4 | 5 | using EpicManifestParser.UE; 6 | using EpicManifestParser.ZlibngDotNetDecompressor; 7 | 8 | using ZlibngDotNet; 9 | 10 | namespace EpicManifestParser.Tests; 11 | 12 | public class UncompressTests : IAsyncLifetime 13 | { 14 | private string _zlibngFilePath = null!; 15 | private Zlibng _zlibng = null!; 16 | private byte[] _uncompressPoolBuffer = null!; 17 | 18 | public async Task InitializeAsync() 19 | { 20 | _zlibngFilePath = await DownloadAsync(); 21 | _zlibng = new Zlibng(_zlibngFilePath); 22 | _uncompressPoolBuffer = ArrayPool.Shared.Rent(10000000); 23 | } 24 | 25 | [Fact] 26 | public async Task Uncompress_Chunk_Default() 27 | { 28 | var chunkBuffer = await File.ReadAllBytesAsync("files/chunk_compressed.bin"); 29 | FChunkInfo.Test_ZlibStream(_uncompressPoolBuffer, chunkBuffer); 30 | } 31 | 32 | [Fact] 33 | public async Task Uncompress_Chunk_Zlibng() 34 | { 35 | var chunkBuffer = await File.ReadAllBytesAsync("files/chunk_compressed.bin"); 36 | FChunkInfo.Test_Zlibng(_uncompressPoolBuffer, chunkBuffer, _zlibng, ManifestZlibngDotNetDecompressor.Decompress); 37 | } 38 | 39 | public Task DisposeAsync() 40 | { 41 | ArrayPool.Shared.Return(_uncompressPoolBuffer); 42 | _zlibng.Dispose(); 43 | File.Delete(_zlibngFilePath); 44 | return Task.CompletedTask; 45 | } 46 | 47 | public static async Task DownloadAsync() 48 | { 49 | if (!OperatingSystem.IsWindows() && !OperatingSystem.IsLinux()) 50 | { 51 | throw new PlatformNotSupportedException("this test is not supported on the current platform"); 52 | } 53 | 54 | const string baseUrl = "https://github.com/NotOfficer/Zlib-ng.NET/releases/download/1.0.0/"; 55 | string url; 56 | 57 | if (OperatingSystem.IsWindows()) 58 | { 59 | url = baseUrl + "zlib-ng2.dll"; 60 | } 61 | else if (OperatingSystem.IsLinux()) 62 | { 63 | url = baseUrl + "libz-ng.so"; 64 | } 65 | else 66 | { 67 | throw new UnreachableException(); 68 | } 69 | 70 | using var client = new HttpClient(new SocketsHttpHandler 71 | { 72 | UseProxy = false, 73 | UseCookies = true, 74 | AutomaticDecompression = DecompressionMethods.All 75 | }); 76 | using var response = await client.GetAsync(url); 77 | response.EnsureSuccessStatusCode(); 78 | var filePath = Path.GetTempFileName(); 79 | await using var fs = File.Create(filePath); 80 | await response.Content.CopyToAsync(fs); 81 | return filePath; 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/EpicManifestParser.Tests/files/chunk_compressed.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NotOfficer/EpicManifestParser/5e1146c06c0e8cc24bb0c050627216ddbcece648/src/EpicManifestParser.Tests/files/chunk_compressed.bin -------------------------------------------------------------------------------- /src/EpicManifestParser.Tests/files/chunk_uncompressed.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NotOfficer/EpicManifestParser/5e1146c06c0e8cc24bb0c050627216ddbcece648/src/EpicManifestParser.Tests/files/chunk_uncompressed.bin -------------------------------------------------------------------------------- /src/EpicManifestParser.Tests/files/manifest.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NotOfficer/EpicManifestParser/5e1146c06c0e8cc24bb0c050627216ddbcece648/src/EpicManifestParser.Tests/files/manifest.bin -------------------------------------------------------------------------------- /src/EpicManifestParser.ZlibngDotNetDecompressor/EpicManifestParser.ZlibngDotNetDecompressor.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | net9.0;net8.0 5 | enable 6 | enable 7 | 8 | EpicManifestParser decompressor using Zlib-ng.NET 9 | manifest, epicgames, manifestparser 10 | 1.0.1.0 11 | 1.0.1.0 12 | 1.0.1 13 | 14 | 15 | 16 | NotOfficer 17 | Copyright (c) 2024 NotOfficer 18 | en 19 | true 20 | icon.png 21 | MIT 22 | true 23 | true 24 | true 25 | true 26 | snupkg 27 | true 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | true 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | -------------------------------------------------------------------------------- /src/EpicManifestParser.ZlibngDotNetDecompressor/ManifestZlibngDotNetDecompressor.cs: -------------------------------------------------------------------------------- 1 | using ZlibngDotNet; 2 | 3 | namespace EpicManifestParser.ZlibngDotNetDecompressor; 4 | 5 | /// 6 | /// A decompressor using . 7 | /// 8 | public static class ManifestZlibngDotNetDecompressor 9 | { 10 | /// 11 | /// Decompresses data buffer into destination buffer. 12 | /// 13 | /// if the decompression was successful; otherwise, . 14 | public static bool Decompress(object? state, byte[] source, int sourceOffset, int sourceLength, byte[] destination, int destinationOffset, int destinationLength) 15 | { 16 | var zlibng = (Zlibng)state!; 17 | 18 | var result = zlibng.Uncompress(destination.AsSpan(destinationOffset, destinationLength), 19 | source.AsSpan(sourceOffset, sourceLength), out int bytesWritten); 20 | 21 | return result == ZlibngCompressionResult.Ok && bytesWritten == destinationLength; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/EpicManifestParser.sln: -------------------------------------------------------------------------------- 1 |  2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio Version 17 4 | VisualStudioVersion = 17.9.34723.18 5 | MinimumVisualStudioVersion = 10.0.40219.1 6 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "EpicManifestParser", "EpicManifestParser\EpicManifestParser.csproj", "{072A83A6-34EC-47CA-9A3B-E213B7073FCA}" 7 | EndProject 8 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EpicManifestParser.Playground", "EpicManifestParser.Playground\EpicManifestParser.Playground.csproj", "{CC7A466C-7EA9-4A36-957B-7A9C2E571ADE}" 9 | EndProject 10 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EpicManifestParser.ZlibngDotNetDecompressor", "EpicManifestParser.ZlibngDotNetDecompressor\EpicManifestParser.ZlibngDotNetDecompressor.csproj", "{F284AF46-619B-460D-8EF4-27F23B3EFD28}" 11 | EndProject 12 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EpicManifestParser.Tests", "EpicManifestParser.Tests\EpicManifestParser.Tests.csproj", "{456D0C9D-2B2A-4777-B626-3AD75697DAA3}" 13 | EndProject 14 | Global 15 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 16 | Debug|Any CPU = Debug|Any CPU 17 | Debug|x64 = Debug|x64 18 | Release|Any CPU = Release|Any CPU 19 | Release|x64 = Release|x64 20 | EndGlobalSection 21 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 22 | {072A83A6-34EC-47CA-9A3B-E213B7073FCA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 23 | {072A83A6-34EC-47CA-9A3B-E213B7073FCA}.Debug|Any CPU.Build.0 = Debug|Any CPU 24 | {072A83A6-34EC-47CA-9A3B-E213B7073FCA}.Debug|x64.ActiveCfg = Debug|Any CPU 25 | {072A83A6-34EC-47CA-9A3B-E213B7073FCA}.Debug|x64.Build.0 = Debug|Any CPU 26 | {072A83A6-34EC-47CA-9A3B-E213B7073FCA}.Release|Any CPU.ActiveCfg = Release|Any CPU 27 | {072A83A6-34EC-47CA-9A3B-E213B7073FCA}.Release|Any CPU.Build.0 = Release|Any CPU 28 | {072A83A6-34EC-47CA-9A3B-E213B7073FCA}.Release|x64.ActiveCfg = Release|Any CPU 29 | {072A83A6-34EC-47CA-9A3B-E213B7073FCA}.Release|x64.Build.0 = Release|Any CPU 30 | {CC7A466C-7EA9-4A36-957B-7A9C2E571ADE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 31 | {CC7A466C-7EA9-4A36-957B-7A9C2E571ADE}.Debug|Any CPU.Build.0 = Debug|Any CPU 32 | {CC7A466C-7EA9-4A36-957B-7A9C2E571ADE}.Debug|x64.ActiveCfg = Debug|x64 33 | {CC7A466C-7EA9-4A36-957B-7A9C2E571ADE}.Debug|x64.Build.0 = Debug|x64 34 | {CC7A466C-7EA9-4A36-957B-7A9C2E571ADE}.Release|Any CPU.ActiveCfg = Release|Any CPU 35 | {CC7A466C-7EA9-4A36-957B-7A9C2E571ADE}.Release|Any CPU.Build.0 = Release|Any CPU 36 | {CC7A466C-7EA9-4A36-957B-7A9C2E571ADE}.Release|x64.ActiveCfg = Release|x64 37 | {CC7A466C-7EA9-4A36-957B-7A9C2E571ADE}.Release|x64.Build.0 = Release|x64 38 | {F284AF46-619B-460D-8EF4-27F23B3EFD28}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 39 | {F284AF46-619B-460D-8EF4-27F23B3EFD28}.Debug|Any CPU.Build.0 = Debug|Any CPU 40 | {F284AF46-619B-460D-8EF4-27F23B3EFD28}.Debug|x64.ActiveCfg = Debug|Any CPU 41 | {F284AF46-619B-460D-8EF4-27F23B3EFD28}.Debug|x64.Build.0 = Debug|Any CPU 42 | {F284AF46-619B-460D-8EF4-27F23B3EFD28}.Release|Any CPU.ActiveCfg = Release|Any CPU 43 | {F284AF46-619B-460D-8EF4-27F23B3EFD28}.Release|Any CPU.Build.0 = Release|Any CPU 44 | {F284AF46-619B-460D-8EF4-27F23B3EFD28}.Release|x64.ActiveCfg = Release|Any CPU 45 | {F284AF46-619B-460D-8EF4-27F23B3EFD28}.Release|x64.Build.0 = Release|Any CPU 46 | {456D0C9D-2B2A-4777-B626-3AD75697DAA3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 47 | {456D0C9D-2B2A-4777-B626-3AD75697DAA3}.Debug|Any CPU.Build.0 = Debug|Any CPU 48 | {456D0C9D-2B2A-4777-B626-3AD75697DAA3}.Debug|x64.ActiveCfg = Debug|Any CPU 49 | {456D0C9D-2B2A-4777-B626-3AD75697DAA3}.Debug|x64.Build.0 = Debug|Any CPU 50 | {456D0C9D-2B2A-4777-B626-3AD75697DAA3}.Release|Any CPU.ActiveCfg = Release|Any CPU 51 | {456D0C9D-2B2A-4777-B626-3AD75697DAA3}.Release|Any CPU.Build.0 = Release|Any CPU 52 | {456D0C9D-2B2A-4777-B626-3AD75697DAA3}.Release|x64.ActiveCfg = Release|Any CPU 53 | {456D0C9D-2B2A-4777-B626-3AD75697DAA3}.Release|x64.Build.0 = Release|Any CPU 54 | EndGlobalSection 55 | GlobalSection(SolutionProperties) = preSolution 56 | HideSolutionNode = FALSE 57 | EndGlobalSection 58 | GlobalSection(ExtensibilityGlobals) = postSolution 59 | SolutionGuid = {ED772168-1701-4024-835A-63B67F162620} 60 | EndGlobalSection 61 | EndGlobal 62 | -------------------------------------------------------------------------------- /src/EpicManifestParser/Api/ManifestInfo.cs: -------------------------------------------------------------------------------- 1 | using System.Diagnostics.CodeAnalysis; 2 | using System.Net.Http.Json; 3 | using System.Text.Json; 4 | using System.Text.Json.Serialization; 5 | using System.Text.RegularExpressions; 6 | 7 | using Flurl; 8 | 9 | namespace EpicManifestParser.Api; 10 | // ReSharper disable UseSymbolAlias 11 | 12 | /// 13 | public class ManifestInfo 14 | { 15 | /// 16 | public required List Elements { get; set; } 17 | 18 | /// 19 | /// Parses the UTF-8 encoded text representing a single JSON value into a . 20 | /// 21 | /// A representation of the JSON value. 22 | /// JSON text to parse. 23 | /// 24 | /// The JSON is invalid, 25 | /// is not compatible with the JSON, 26 | /// or when there is remaining data in the buffer. 27 | /// 28 | public static ManifestInfo? Deserialize(ReadOnlySpan utf8Json) 29 | => JsonSerializer.Deserialize(utf8Json, EpicManifestParserJsonContext.Default.ManifestInfo); 30 | 31 | /// 32 | /// Reads the UTF-8 encoded text representing a single JSON value into a . 33 | /// The Stream will be read to completion. 34 | /// 35 | /// A representation of the JSON value. 36 | /// JSON data to parse. 37 | /// 38 | /// is . 39 | /// 40 | /// 41 | /// The JSON is invalid, 42 | /// is not compatible with the JSON, 43 | /// or when there is remaining data in the Stream. 44 | /// 45 | public static ManifestInfo? Deserialize(Stream utf8Json) 46 | => JsonSerializer.Deserialize(utf8Json, EpicManifestParserJsonContext.Default.ManifestInfo); 47 | 48 | /// 49 | /// Reads the UTF-8 encoded text representing a single JSON value into a . 50 | /// The Stream will be read to completion. 51 | /// 52 | /// A representation of the JSON value. 53 | /// JSON data to parse. 54 | /// 55 | /// The that can be used to cancel the read operation. 56 | /// 57 | /// 58 | /// is . 59 | /// 60 | /// 61 | /// The JSON is invalid, 62 | /// is not compatible with the JSON, 63 | /// or when there is remaining data in the Stream. 64 | /// 65 | public static ValueTask DeserializeAsync(Stream utf8Json, CancellationToken cancellationToken = default) 66 | => JsonSerializer.DeserializeAsync(utf8Json, EpicManifestParserJsonContext.Default.ManifestInfo, cancellationToken); 67 | 68 | /// 69 | /// Reads the UTF-8 encoded text representing a single JSON value into a . 70 | /// The Stream will be read to completion. 71 | /// 72 | /// A representation of the JSON value. 73 | /// JSON file to parse. 74 | /// 75 | /// The JSON is invalid, 76 | /// is not compatible with the JSON, 77 | /// or when there is remaining data in the Stream. 78 | /// 79 | /// 80 | public static ManifestInfo? DeserializeFile(string path) 81 | { 82 | using var fs = File.OpenRead(path); 83 | return JsonSerializer.Deserialize(fs, EpicManifestParserJsonContext.Default.ManifestInfo); 84 | } 85 | 86 | /// 87 | /// Reads the UTF-8 encoded text representing a single JSON value into a . 88 | /// The Stream will be read to completion. 89 | /// 90 | /// A representation of the JSON value. 91 | /// JSON file to parse. 92 | /// 93 | /// The that can be used to cancel the read operation. 94 | /// 95 | /// 96 | /// The JSON is invalid, 97 | /// is not compatible with the JSON, 98 | /// or when there is remaining data in the Stream. 99 | /// 100 | /// 101 | public static ValueTask DeserializeFileAsync(string path, CancellationToken cancellationToken = default) 102 | { 103 | using var fs = File.OpenRead(path); 104 | return JsonSerializer.DeserializeAsync(fs, EpicManifestParserJsonContext.Default.ManifestInfo, cancellationToken); 105 | } 106 | 107 | /// Predicate to select the a single element in 108 | /// Predicate to select the a single manifest in 109 | /// 110 | /// The that can be used to cancel the read operation. 111 | /// 112 | /// Builder for options for parsing and/or caching the manifest 113 | /// 114 | public Task<(FBuildPatchAppManifest Manifest, ManifestInfoElement InfoElement)> DownloadAndParseAsync( 115 | Predicate? elementPredicate = null, Predicate? elementManifestPredicate = null, 116 | CancellationToken cancellationToken = default, Action? optionsBuilder = null) 117 | { 118 | var options = new ManifestParseOptions(); 119 | optionsBuilder?.Invoke(options); 120 | return DownloadAndParseAsync(options, elementPredicate, elementManifestPredicate, cancellationToken); 121 | } 122 | 123 | /// 124 | /// Downloads and parses the manifest. 125 | /// 126 | /// Options for parsing and/or caching the manifest 127 | /// Predicate to select the a single element in 128 | /// Predicate to select the a single manifest in 129 | /// 130 | /// The that can be used to cancel the read operation. 131 | /// 132 | /// 133 | /// The parsed manifest and the selected info element in a 134 | /// 135 | /// When a predicate fails. 136 | /// When the manifest data fails to download. 137 | public async Task<(FBuildPatchAppManifest Manifest, ManifestInfoElement InfoElement)> DownloadAndParseAsync( 138 | ManifestParseOptions options, Predicate? elementPredicate = null, 139 | Predicate? elementManifestPredicate = null, CancellationToken cancellationToken = default) 140 | { 141 | ManifestInfoElement element; 142 | if (elementPredicate is null) 143 | element = Elements[0]; 144 | else 145 | element = Elements.Find(elementPredicate) ?? throw new InvalidOperationException("Could not find ManifestInfoElement based on predicate"); 146 | 147 | ManifestInfoElementManifest elementManifest; 148 | if (elementManifestPredicate is null) 149 | elementManifest = element.Manifests[0]; 150 | else 151 | elementManifest = element.Manifests.Find(elementManifestPredicate) ?? throw new InvalidOperationException("Could not find ManifestInfoElement based on predicate"); 152 | 153 | string? cachePath = null; 154 | 155 | if (options.ManifestCacheDirectory is not null) 156 | { 157 | cachePath = Path.Join(options.ManifestCacheDirectory.AsSpan(), GetFileName(elementManifest.Uri)); 158 | if (File.Exists(cachePath)) 159 | { 160 | var manifestBuffer = await File.ReadAllBytesAsync(cachePath, cancellationToken).ConfigureAwait(false); 161 | var manifest = FBuildPatchAppManifest.Deserialize(manifestBuffer, options); 162 | return (manifest, element); 163 | } 164 | 165 | static ReadOnlySpan GetFileName(Uri uri) 166 | { 167 | var span = uri.OriginalString.AsSpan(); 168 | return span[(span.LastIndexOf('/') + 1)..]; 169 | } 170 | } 171 | 172 | { 173 | Uri manifestUri; 174 | 175 | if (elementManifest.QueryParams is { Count: not 0 }) 176 | { 177 | var url = new Url(elementManifest.Uri); 178 | foreach (var queryParam in elementManifest.QueryParams) 179 | { 180 | url.AppendQueryParam(queryParam.Name, queryParam.Value, true, NullValueHandling.NameOnly); 181 | } 182 | manifestUri = url.ToUri(); 183 | } 184 | else 185 | { 186 | manifestUri = elementManifest.Uri; 187 | } 188 | 189 | options.CreateDefaultClient(); 190 | byte[] manifestBuffer; 191 | 192 | try 193 | { 194 | manifestBuffer = await options.Client!.GetByteArrayAsync(manifestUri, cancellationToken).ConfigureAwait(false); 195 | } 196 | catch (HttpRequestException httpEx) 197 | { 198 | httpEx.Data.Add("ManifestUri", manifestUri); 199 | httpEx.Data.Add("ElementManifest", elementManifest); 200 | httpEx.Data.Add("Element", element); 201 | throw; 202 | } 203 | 204 | var manifest = FBuildPatchAppManifest.Deserialize(manifestBuffer, options); 205 | 206 | if (cachePath is not null) 207 | { 208 | await File.WriteAllBytesAsync(cachePath, manifestBuffer, cancellationToken).ConfigureAwait(false); 209 | } 210 | 211 | return (manifest, element); 212 | } 213 | } 214 | } 215 | 216 | /// 217 | public class ManifestInfoElement 218 | { 219 | /// 220 | public required string AppName { get; set; } 221 | /// 222 | public required string LabelName { get; set; } 223 | /// 224 | public required string BuildVersion { get; set; } 225 | /// 226 | public FSHAHash Hash { get; set; } 227 | /// 228 | public bool UseSignedUrl { get; set; } 229 | /// 230 | public Dictionary? Metadata { get; set; } 231 | /// 232 | public required List Manifests { get; set; } 233 | 234 | /// 235 | public bool TryParseVersionAndCL([NotNullWhen(true)] out Version? version, out int cl) => 236 | ManifestExtensions.TryParseVersionAndCL(BuildVersion, out version, out cl); 237 | } 238 | 239 | /// 240 | public class ManifestInfoElementManifest 241 | { 242 | /// 243 | public required Uri Uri { get; set; } 244 | /// 245 | public List? QueryParams { get; set; } 246 | } 247 | 248 | /// 249 | public class ManifestInfoElementManifestQueryParams 250 | { 251 | /// 252 | public required string Name { get; set; } 253 | /// 254 | public required string Value { get; set; } 255 | } 256 | 257 | 258 | /// 259 | /// Source generated JSON parsers for 260 | /// 261 | [JsonSourceGenerationOptions(JsonSerializerDefaults.Web, Converters = [ typeof(FSHAHashConverter) ])] 262 | [JsonSerializable(typeof(ManifestInfo))] 263 | public partial class EpicManifestParserJsonContext : JsonSerializerContext; 264 | 265 | 266 | /// 267 | /// Extension methods for manifest related things 268 | /// 269 | public static partial class ManifestExtensions 270 | { 271 | [GeneratedRegex(@"(\d+(?:\.\d+)+)-CL-(\d+)", RegexOptions.Singleline | RegexOptions.IgnoreCase)] 272 | internal static partial Regex VersionAndClRegex(); 273 | 274 | /// 275 | /// Attempts to parse and from the . 276 | /// 277 | /// 278 | /// 279 | /// 280 | /// if was successfully parsed; otherwise, . 281 | public static bool TryParseVersionAndCL(string buildVersion, [NotNullWhen(true)] out Version? version, out int cl) 282 | { 283 | version = null; 284 | cl = -1; 285 | if (string.IsNullOrEmpty(buildVersion)) 286 | return false; 287 | 288 | var match = VersionAndClRegex().Match(buildVersion); 289 | if (!match.Success) 290 | return false; 291 | 292 | version = Version.Parse(match.Groups[1].ValueSpan); 293 | cl = int.Parse(match.Groups[2].ValueSpan); 294 | return true; 295 | } 296 | 297 | /// 298 | /// Reads the HTTP content and returns the value that results from deserializing the content as JSON in an asynchronous operation. 299 | /// 300 | /// The content to read from. 301 | /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. 302 | /// The task object representing the asynchronous operation. 303 | public static Task ReadManifestInfoAsync(this HttpContent content, CancellationToken cancellationToken = default) 304 | => content.ReadFromJsonAsync(EpicManifestParserJsonContext.Default.ManifestInfo, cancellationToken); 305 | } 306 | -------------------------------------------------------------------------------- /src/EpicManifestParser/EpicManifestParser.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | net9.0;net8.0 5 | enable 6 | enable 7 | 8 | A .NET parser & downloader for EpicGames manifests 9 | manifest, epicgames, manifestparser 10 | 2.4.1.0 11 | 2.4.1.0 12 | 2.4.1 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | NotOfficer 22 | Copyright (c) 2024 NotOfficer 23 | en 24 | true 25 | icon.png 26 | MIT 27 | true 28 | true 29 | true 30 | true 31 | snupkg 32 | true 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | true 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | -------------------------------------------------------------------------------- /src/EpicManifestParser/EpicManifestParser.csproj.DotSettings: -------------------------------------------------------------------------------- 1 |  2 | Library -------------------------------------------------------------------------------- /src/EpicManifestParser/FFileManifestStream.cs: -------------------------------------------------------------------------------- 1 | using System.Buffers; 2 | using System.Collections; 3 | using System.Runtime.CompilerServices; 4 | 5 | using Microsoft.Win32.SafeHandles; 6 | 7 | using OffiUtils; 8 | 9 | namespace EpicManifestParser; 10 | // ReSharper disable UseSymbolAlias 11 | 12 | /// 13 | /// A stream representing a 14 | /// 15 | public sealed class FFileManifestStream : RandomAccessStream 16 | { 17 | private readonly FFileManifest _fileManifest; 18 | private readonly bool _cacheAsIs; 19 | 20 | /// Always 21 | public override bool CanRead => true; 22 | /// Always 23 | public override bool CanSeek => true; 24 | /// Always 25 | public override bool CanWrite => false; 26 | /// Gets the length/size of the stream 27 | public override long Length => _fileManifest.FileSize; 28 | 29 | private long _position; 30 | 31 | /// Gets or sets the current position within the stream. 32 | public override long Position 33 | { 34 | get => _position; 35 | set 36 | { 37 | if ((ulong)value > (ulong)Length) 38 | throw new ArgumentOutOfRangeException(nameof(Position), value, "Value is negative or exceeds the stream's length"); 39 | 40 | _position = value; 41 | } 42 | } 43 | 44 | /// Gets the file name of the represented by this stream 45 | public string FileName => _fileManifest.FileName; 46 | 47 | internal FFileManifestStream(FFileManifest fileManifest, bool cacheAsIs) 48 | { 49 | if (string.IsNullOrEmpty(fileManifest.Manifest.Options.ChunkBaseUrl)) 50 | throw new ArgumentException("Missing ChunkBaseUrl"); 51 | if (fileManifest.Manifest.Meta.bIsFileData) 52 | throw new NotSupportedException("File-data manifests are not supported"); 53 | 54 | _fileManifest = fileManifest; 55 | _cacheAsIs = cacheAsIs; 56 | } 57 | 58 | /// 59 | /// Asynchronously saves the current stream to another stream. 60 | /// 61 | /// The destination stream. 62 | /// The progress change callback. (optional) 63 | /// The user state for the . (optional) 64 | /// The maximum number of concurrent tasks saving/downloading to the destination. (optional) 65 | /// The token to monitor for cancellation requests. 66 | /// A task that represents the entire save operation. 67 | public async Task SaveToAsync(Stream destination, Action? progressCallback, 68 | object? userState = default, int? maxDegreeOfParallelism = null, CancellationToken cancellationToken = default) 69 | { 70 | if (destination is MemoryStream {Position: 0} ms) 71 | { 72 | ms.Capacity = (int)Length; 73 | if (ms.TryGetBuffer(out var buffer)) 74 | { 75 | await SaveBytesAsync(buffer.Array!, progressCallback, userState, maxDegreeOfParallelism, cancellationToken).ConfigureAwait(false); 76 | ms.Position = (int)Length; 77 | return; 78 | } 79 | } 80 | 81 | // TODO: make concurrent 82 | 83 | var downloadState = new DownloadState(null!, _fileManifest, Length, userState, progressCallback); 84 | 85 | if (_cacheAsIs) 86 | { 87 | var poolBuffer = ArrayPool.Shared.Rent(_fileManifest.Manifest.Options.ChunkDownloadBufferSize); 88 | 89 | try 90 | { 91 | foreach (var fileChunkPart in _fileManifest.ChunkPartsArray) 92 | { 93 | var chunk = _fileManifest.Manifest.Chunks[fileChunkPart.Guid]; 94 | await chunk.ReadDataAsIsAsync(poolBuffer, _fileManifest.Manifest, cancellationToken).ConfigureAwait(false); 95 | await destination.WriteAsync(new ReadOnlyMemory(poolBuffer, (int)fileChunkPart.Offset, (int)fileChunkPart.Size), 96 | cancellationToken).ConfigureAwait(false); 97 | downloadState.OnBytesWritten(fileChunkPart.Size); 98 | } 99 | } 100 | finally 101 | { 102 | ArrayPool.Shared.Return(poolBuffer); 103 | } 104 | } 105 | else 106 | { 107 | var poolBuffer = ArrayPool.Shared.Rent(_fileManifest.Manifest.Options.ChunkDownloadBufferSize); 108 | 109 | try 110 | { 111 | foreach (var fileChunkPart in _fileManifest.ChunkPartsArray) 112 | { 113 | var chunk = _fileManifest.Manifest.Chunks[fileChunkPart.Guid]; 114 | await chunk.ReadDataAsync(poolBuffer, 0, (int)fileChunkPart.Size, 115 | (int)fileChunkPart.Offset, _fileManifest.Manifest, cancellationToken).ConfigureAwait(false); 116 | await destination.WriteAsync(new ReadOnlyMemory(poolBuffer, 0, (int)fileChunkPart.Size), 117 | cancellationToken).ConfigureAwait(false); 118 | downloadState.OnBytesWritten(fileChunkPart.Size); 119 | } 120 | } 121 | finally 122 | { 123 | ArrayPool.Shared.Return(poolBuffer); 124 | } 125 | } 126 | 127 | await destination.FlushAsync(cancellationToken).ConfigureAwait(false); 128 | } 129 | 130 | /// 131 | public Task SaveToAsync(Stream destination, int? maxDegreeOfParallelism = null, CancellationToken cancellationToken = default) 132 | { 133 | return SaveToAsync(destination, null, 0, maxDegreeOfParallelism, cancellationToken); 134 | } 135 | 136 | /// 137 | /// Asynchronously saves the current stream to a buffer. 138 | /// 139 | /// The destination buffer. 140 | /// The progress change callback. (optional) 141 | /// The user state for the . (optional) 142 | /// The maximum number of concurrent tasks saving/downloading to the destination. (optional) 143 | /// The token to monitor for cancellation requests. 144 | /// A task that represents the entire save operation. 145 | public async Task SaveBytesAsync(byte[] destination, Action? progressCallback, 146 | object? userState = default, int? maxDegreeOfParallelism = null, CancellationToken cancellationToken = default) 147 | { 148 | ArgumentOutOfRangeException.ThrowIfLessThan(destination.Length, Length); 149 | 150 | var downloadState = new DownloadState(destination, _fileManifest, Length, userState, progressCallback); 151 | var parallelOptions = new ParallelOptions 152 | { 153 | MaxDegreeOfParallelism = maxDegreeOfParallelism ?? Environment.ProcessorCount, 154 | CancellationToken = cancellationToken 155 | }; 156 | 157 | if (_cacheAsIs) 158 | await Parallel.ForEachAsync(downloadState, parallelOptions, SaveAsIsAsync).ConfigureAwait(false); 159 | else 160 | await Parallel.ForEachAsync(downloadState, parallelOptions, SaveAsync).ConfigureAwait(false); 161 | 162 | return; 163 | 164 | static async ValueTask SaveAsync(ChunkWithOffset tuple, CancellationToken token) 165 | { 166 | await tuple.Chunk.ReadDataAsync(tuple.State.Destination, (int)tuple.Offset, (int)tuple.ChunkPartSize, 167 | (int)tuple.ChunkPartOffset, tuple.State.FileManifest.Manifest, token).ConfigureAwait(false); 168 | tuple.State.OnBytesWritten(tuple.ChunkPartSize); 169 | } 170 | 171 | static async ValueTask SaveAsIsAsync(ChunkWithOffset tuple, CancellationToken token) 172 | { 173 | var poolBuffer = ArrayPool.Shared.Rent(tuple.State.FileManifest.Manifest.Options.ChunkDownloadBufferSize); 174 | 175 | try 176 | { 177 | await tuple.Chunk.ReadDataAsIsAsync(poolBuffer, tuple.State.FileManifest.Manifest, token).ConfigureAwait(false); 178 | Unsafe.CopyBlockUnaligned(ref tuple.State.Destination[tuple.Offset], 179 | ref poolBuffer[tuple.ChunkPartOffset], tuple.ChunkPartSize); 180 | tuple.State.OnBytesWritten(tuple.ChunkPartSize); 181 | } 182 | finally 183 | { 184 | ArrayPool.Shared.Return(poolBuffer); 185 | } 186 | } 187 | } 188 | 189 | /// 190 | /// Asynchronously saves the current stream to a buffer. 191 | /// 192 | /// The destination buffer. 193 | /// The maximum number of concurrent tasks saving/downloading to the destination. (optional) 194 | /// The token to monitor for cancellation requests. 195 | /// A task that represents the entire save operation. 196 | public Task SaveBytesAsync(byte[] destination, int? maxDegreeOfParallelism = null, CancellationToken cancellationToken = default) 197 | { 198 | return SaveBytesAsync(destination, null, 0, maxDegreeOfParallelism, cancellationToken); 199 | } 200 | 201 | /// 202 | /// Asynchronously saves the current stream to a buffer. 203 | /// 204 | /// The progress change callback. (optional) 205 | /// The user state for the . (optional) 206 | /// The maximum number of concurrent tasks saving/downloading to the destination. (optional) 207 | /// The token to monitor for cancellation requests. 208 | /// A task that represents the entire save operation. 209 | public async Task SaveBytesAsync(Action progressCallback, object? userState = default, 210 | int? maxDegreeOfParallelism = null, CancellationToken cancellationToken = default) 211 | { 212 | var destination = new byte[Length]; 213 | await SaveBytesAsync(destination, progressCallback, userState, maxDegreeOfParallelism, cancellationToken).ConfigureAwait(false); 214 | return destination; 215 | } 216 | 217 | /// 218 | /// Asynchronously saves the current stream to a buffer. 219 | /// 220 | /// The maximum number of concurrent tasks saving/downloading to the destination. (optional) 221 | /// The token to monitor for cancellation requests. 222 | /// A task that represents the entire save operation. 223 | public async Task SaveBytesAsync(int? maxDegreeOfParallelism = null, CancellationToken cancellationToken = default) 224 | { 225 | var destination = new byte[Length]; 226 | await SaveBytesAsync(destination, maxDegreeOfParallelism, cancellationToken).ConfigureAwait(false); 227 | return destination; 228 | } 229 | 230 | /// 231 | /// Asynchronously saves the current stream to a file. 232 | /// 233 | /// The path of the destination file. 234 | /// The progress change callback. (optional) 235 | /// The user state for the . (optional) 236 | /// The maximum number of concurrent tasks saving/downloading to the destination. (optional) 237 | /// The token to monitor for cancellation requests. 238 | /// A task that represents the entire save operation. 239 | public async Task SaveFileAsync(string path, Action? progressCallback, object? userState = null, 240 | int? maxDegreeOfParallelism = null, CancellationToken cancellationToken = default) 241 | { 242 | using var destination = File.OpenHandle(path, FileMode.Create, FileAccess.ReadWrite, FileShare.None, FileOptions.Asynchronous, Length); 243 | var downloadState = new DownloadState(destination, _fileManifest, Length, userState, progressCallback); 244 | 245 | var parallelOptions = new ParallelOptions 246 | { 247 | MaxDegreeOfParallelism = maxDegreeOfParallelism ?? Environment.ProcessorCount, 248 | CancellationToken = cancellationToken 249 | }; 250 | 251 | if (_cacheAsIs) 252 | await Parallel.ForEachAsync(downloadState, parallelOptions, SaveAsIsAsync).ConfigureAwait(false); 253 | else 254 | await Parallel.ForEachAsync(downloadState, parallelOptions, SaveAsync).ConfigureAwait(false); 255 | 256 | RandomAccess.FlushToDisk(destination); 257 | return; 258 | 259 | static async ValueTask SaveAsync(ChunkWithOffset tuple, CancellationToken token) 260 | { 261 | var poolBuffer = ArrayPool.Shared.Rent((int)tuple.ChunkPartSize); 262 | 263 | try 264 | { 265 | await tuple.Chunk.ReadDataAsync(poolBuffer, 0, (int)tuple.ChunkPartSize, 266 | (int)tuple.ChunkPartOffset, tuple.State.FileManifest.Manifest, token).ConfigureAwait(false); 267 | await RandomAccess.WriteAsync(tuple.State.Destination, 268 | new ReadOnlyMemory(poolBuffer, 0, (int)tuple.ChunkPartSize), 269 | tuple.Offset, token).ConfigureAwait(false); 270 | tuple.State.OnBytesWritten(tuple.ChunkPartSize); 271 | } 272 | finally 273 | { 274 | ArrayPool.Shared.Return(poolBuffer); 275 | } 276 | } 277 | 278 | static async ValueTask SaveAsIsAsync(ChunkWithOffset tuple, CancellationToken token) 279 | { 280 | var poolBuffer = ArrayPool.Shared.Rent(tuple.State.FileManifest.Manifest.Options.ChunkDownloadBufferSize); 281 | 282 | try 283 | { 284 | await tuple.Chunk.ReadDataAsIsAsync(poolBuffer, tuple.State.FileManifest.Manifest, token).ConfigureAwait(false); 285 | await RandomAccess.WriteAsync(tuple.State.Destination, 286 | new ReadOnlyMemory(poolBuffer, (int)tuple.ChunkPartOffset, (int)tuple.ChunkPartSize), 287 | tuple.Offset, token).ConfigureAwait(false); 288 | tuple.State.OnBytesWritten(tuple.ChunkPartSize); 289 | } 290 | finally 291 | { 292 | ArrayPool.Shared.Return(poolBuffer); 293 | } 294 | } 295 | } 296 | 297 | /// 298 | public Task SaveFileAsync(string path, int? maxDegreeOfParallelism = null, CancellationToken cancellationToken = default) 299 | { 300 | return SaveFileAsync(path, null, 0, maxDegreeOfParallelism, cancellationToken); 301 | } 302 | 303 | /// 304 | /// Reads a sequence of bytes from the current stream. 305 | /// 306 | /// 307 | /// When this method returns, contains the specified byte array with the values between 308 | /// and ( + - 1) 309 | /// replaced by the characters read from the current stream. 310 | /// 311 | /// The zero-based byte offset in at which to begin storing data from the current stream. 312 | /// The maximum number of bytes to read. 313 | /// The total number of bytes written into the . 314 | public override int Read(byte[] buffer, int offset, int count) 315 | { 316 | return ReadAsync(buffer, offset, count, CancellationToken.None).GetAwaiter().GetResult(); 317 | } 318 | 319 | /// 320 | /// Asynchronously reads a sequence of bytes from the the current stream. 321 | /// 322 | /// 323 | /// When this method returns, contains the specified byte array with the values between 324 | /// and ( + - 1) 325 | /// replaced by the characters read from the current stream. 326 | /// 327 | /// The zero-based byte offset in at which to begin storing data from the current stream. 328 | /// The maximum number of bytes to read. 329 | /// The token to monitor for cancellation requests. 330 | /// The total number of bytes written into the . 331 | public override async Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) 332 | { 333 | if (cancellationToken.IsCancellationRequested) 334 | return await Task.FromCanceled(cancellationToken).ConfigureAwait(false); 335 | 336 | var bytesRead = await ReadAtAsync(_position, buffer, offset, count, cancellationToken).ConfigureAwait(false); 337 | _position += bytesRead; 338 | return bytesRead; 339 | } 340 | 341 | /// 342 | /// Asynchronously reads a sequence of bytes from the current stream. 343 | /// 344 | /// The region of memory to write the data into. 345 | /// The token to monitor for cancellation requests. 346 | /// The total number of bytes written into the . 347 | public override async ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) 348 | { 349 | var bytesRead = await ReadAtAsync(_position, buffer, cancellationToken).ConfigureAwait(false); 350 | _position += bytesRead; 351 | return bytesRead; 352 | } 353 | 354 | /// 355 | /// Reads a sequence of bytes from the given of the current stream. 356 | /// 357 | /// The position to begin reading from. 358 | /// 359 | /// When this method returns, contains the specified byte array with the values between 360 | /// and ( + - 1) 361 | /// replaced by the characters read from the current stream. 362 | /// 363 | /// The zero-based byte offset in at which to begin storing data from the current stream. 364 | /// The maximum number of bytes to read. 365 | /// The total number of bytes written into the . 366 | public override int ReadAt(long position, byte[] buffer, int offset, int count) 367 | { 368 | return ReadAtAsync(position, buffer, offset, count, CancellationToken.None).GetAwaiter().GetResult(); 369 | } 370 | 371 | /// 372 | /// Asynchronously reads a sequence of bytes from the given of the current stream. 373 | /// 374 | /// The position to begin reading from. 375 | /// 376 | /// When this method returns, contains the specified byte array with the values between 377 | /// and ( + - 1) 378 | /// replaced by the characters read from the current stream. 379 | /// 380 | /// The zero-based byte offset in at which to begin storing data from the current stream. 381 | /// The maximum number of bytes to read. 382 | /// The token to monitor for cancellation requests. 383 | /// The total number of bytes written into the . 384 | public override async Task ReadAtAsync(long position, byte[] buffer, int offset, int count, CancellationToken cancellationToken = default) 385 | { 386 | var (i, startPos) = GetChunkIndex(position); 387 | if (i == -1) 388 | return 0; 389 | 390 | var bytesRead = 0u; 391 | 392 | if (_cacheAsIs) 393 | { 394 | var poolBuffer = ArrayPool.Shared.Rent(_fileManifest.Manifest.Options.ChunkDownloadBufferSize); 395 | 396 | try 397 | { 398 | while (i < _fileManifest.ChunkPartsArray.Length) 399 | { 400 | var chunkPart = _fileManifest.ChunkPartsArray[i]; 401 | var chunk = _fileManifest.Manifest.Chunks[chunkPart.Guid]; 402 | 403 | await chunk.ReadDataAsIsAsync(poolBuffer, _fileManifest.Manifest, cancellationToken).ConfigureAwait(false); 404 | 405 | var chunkOffset = chunkPart.Offset + startPos; 406 | var chunkBytes = chunkPart.Size - startPos; 407 | var bytesLeft = (uint)count - bytesRead; 408 | 409 | if (bytesLeft <= chunkBytes) 410 | { 411 | Unsafe.CopyBlockUnaligned(ref buffer[bytesRead + offset], ref poolBuffer[chunkOffset], bytesLeft); 412 | bytesRead += bytesLeft; 413 | break; 414 | } 415 | 416 | Unsafe.CopyBlockUnaligned(ref buffer[bytesRead + offset], ref poolBuffer[chunkOffset], chunkBytes); 417 | bytesRead += chunkBytes; 418 | startPos = 0; 419 | 420 | ++i; 421 | } 422 | } 423 | finally 424 | { 425 | ArrayPool.Shared.Return(poolBuffer); 426 | } 427 | } 428 | else 429 | { 430 | while (i < _fileManifest.ChunkPartsArray.Length) 431 | { 432 | var chunkPart = _fileManifest.ChunkPartsArray[i]; 433 | var chunk = _fileManifest.Manifest.Chunks[chunkPart.Guid]; 434 | 435 | var chunkOffset = (int)(chunkPart.Offset + startPos); 436 | var chunkBytes = (int)(chunkPart.Size - startPos); 437 | var bytesLeft = count - (int)bytesRead; 438 | 439 | if (bytesLeft <= chunkBytes) 440 | { 441 | await chunk.ReadDataAsync(buffer, (int)bytesRead + offset, bytesLeft, chunkOffset, _fileManifest.Manifest, cancellationToken).ConfigureAwait(false); 442 | bytesRead += (uint)bytesLeft; 443 | break; 444 | } 445 | 446 | await chunk.ReadDataAsync(buffer, (int)bytesRead + offset, chunkBytes, chunkOffset, _fileManifest.Manifest, cancellationToken).ConfigureAwait(false); 447 | bytesRead += (uint)chunkBytes; 448 | startPos = 0; 449 | 450 | ++i; 451 | } 452 | } 453 | 454 | return (int)bytesRead; 455 | } 456 | 457 | private long _lastChunkPartPosition; 458 | private uint _lastChunkPartSize; 459 | private int _lastChunkPartIndex; 460 | 461 | private (int Index, uint ChunkPos) GetChunkIndexNew(long position) 462 | { 463 | lock (_fileManifest) 464 | { 465 | var maxPosition = _lastChunkPartPosition + _lastChunkPartSize; 466 | if (maxPosition < position && position >= _lastChunkPartPosition) 467 | { 468 | return (_lastChunkPartIndex, (uint)(_lastChunkPartPosition - position)); 469 | } 470 | 471 | var chunkPartPosition = 0L; 472 | 473 | for (var i = 0; i < _fileManifest.ChunkPartsArray.Length; i++) 474 | { 475 | var chunkPart = _fileManifest.ChunkPartsArray[i]; 476 | 477 | if (chunkPartPosition >= position) 478 | { 479 | _lastChunkPartPosition = chunkPartPosition; 480 | _lastChunkPartSize = chunkPart.Size; 481 | _lastChunkPartIndex = i; 482 | return (i, (uint)(chunkPartPosition - position)); 483 | } 484 | 485 | chunkPartPosition += chunkPart.Size; 486 | } 487 | 488 | return (-1, 0); 489 | } 490 | } 491 | 492 | private (int Index, uint ChunkPos) GetChunkIndex(long position) 493 | { 494 | for (var i = 0; i < _fileManifest.ChunkPartsArray.Length; i++) 495 | { 496 | var chunkPart = _fileManifest.ChunkPartsArray[i]; 497 | 498 | if (position < chunkPart.Size) 499 | return (i, (uint)position); 500 | 501 | position -= chunkPart.Size; 502 | } 503 | 504 | return (-1, 0); 505 | } 506 | 507 | /// Sets the position within the current stream to the specified value. 508 | /// The new position within the stream. This is relative to the parameter, and can be positive or negative. 509 | /// A value of type , which acts as the seek reference point. 510 | /// The new position within the stream, calculated by combining the initial reference point and the offset. 511 | /// There is an invalid . 512 | public override long Seek(long offset, SeekOrigin loc) 513 | { 514 | Position = loc switch 515 | { 516 | SeekOrigin.Begin => offset, 517 | SeekOrigin.Current => offset + _position, 518 | SeekOrigin.End => Length + offset, 519 | _ => throw new ArgumentException("Invalid loc", nameof(loc)) 520 | }; 521 | return _position; 522 | } 523 | 524 | /// Not supported 525 | public override void SetLength(long value) 526 | => throw new NotSupportedException(); 527 | /// Not supported 528 | public override void Write(byte[] buffer, int offset, int count) 529 | => throw new NotSupportedException(); 530 | /// Not supported 531 | public override void Flush() 532 | => throw new NotSupportedException(); 533 | } 534 | 535 | /// 536 | /// Event for save progress 537 | /// 538 | public sealed class SaveProgressChangedEventArgs : EventArgs 539 | { 540 | internal SaveProgressChangedEventArgs(object? userState, long bytesSaved, long totalBytesToSave, int progressPercentage) 541 | { 542 | UserState = userState; 543 | BytesSaved = bytesSaved; 544 | TotalBytesToSave = totalBytesToSave; 545 | ProgressPercentage = progressPercentage; 546 | } 547 | 548 | /// 549 | public object? UserState { get; } 550 | /// 551 | public long BytesSaved { get; } 552 | /// 553 | public long TotalBytesToSave { get; } 554 | /// 555 | public int ProgressPercentage { get; } 556 | } 557 | 558 | internal sealed class DownloadState : IEnumerable>, IEnumerator> 559 | where TDestination : class 560 | { 561 | public readonly TDestination Destination; 562 | public readonly FFileManifest FileManifest; 563 | 564 | private readonly LockObject? _lock; 565 | private readonly object? _userState; 566 | private readonly Action? _callback; 567 | private readonly long _totalBytesToSave; 568 | private long _bytesSaved; 569 | private int _lastProgress; 570 | 571 | // IEnumerable & IEnumerator 572 | private long _offset; 573 | private long _lastSize; 574 | private int _chunkpartIndex; 575 | 576 | public DownloadState(TDestination destination, FFileManifest fileManifest, long totalBytesToSave, object? userState, Action? callback) 577 | { 578 | Reset(); 579 | Destination = destination; 580 | FileManifest = fileManifest; 581 | 582 | if (callback is null) return; 583 | _lock = new LockObject(); 584 | _userState = userState; 585 | _callback = callback; 586 | _totalBytesToSave = totalBytesToSave; 587 | } 588 | 589 | public void OnBytesWritten(long amount) 590 | { 591 | if (_callback is null) 592 | return; 593 | 594 | lock (_lock!) 595 | { 596 | _bytesSaved += amount; 597 | var progress = (int)MathF.Truncate((float)_bytesSaved / _totalBytesToSave * 100f); 598 | if (progress != _lastProgress) 599 | { 600 | _lastProgress = progress; 601 | var eventArgs = new SaveProgressChangedEventArgs(_userState, _bytesSaved, _totalBytesToSave, progress); 602 | _callback(eventArgs); 603 | } 604 | } 605 | } 606 | 607 | // IEnumerable 608 | 609 | public IEnumerator> GetEnumerator() => this; 610 | IEnumerator IEnumerable.GetEnumerator() => this; 611 | 612 | // IEnumerator 613 | 614 | public bool MoveNext() 615 | { 616 | _chunkpartIndex++; 617 | if (_chunkpartIndex >= FileManifest.ChunkPartsArray.Length) 618 | return false; 619 | 620 | _offset += _lastSize; 621 | var chunkPart = FileManifest.ChunkPartsArray[_chunkpartIndex]; 622 | _lastSize = chunkPart.Size; 623 | return true; 624 | } 625 | 626 | public void Reset() 627 | { 628 | _offset = 0; 629 | _lastSize = 0; 630 | _chunkpartIndex = -1; 631 | } 632 | 633 | public ChunkWithOffset Current 634 | { 635 | get 636 | { 637 | var chunkPart = FileManifest.ChunkPartsArray[_chunkpartIndex]; 638 | var chunk = FileManifest.Manifest.Chunks[chunkPart.Guid]; 639 | return new ChunkWithOffset(this, chunk, chunkPart.Offset, chunkPart.Size, _offset); 640 | } 641 | } 642 | 643 | object IEnumerator.Current => Current; 644 | 645 | public void Dispose() { } 646 | } 647 | 648 | internal readonly struct ChunkWithOffset where TDestination : class 649 | { 650 | public readonly DownloadState State; 651 | public readonly FChunkInfo Chunk; 652 | public readonly uint ChunkPartOffset; 653 | public readonly uint ChunkPartSize; 654 | public readonly long Offset; 655 | 656 | public ChunkWithOffset(DownloadState state, FChunkInfo chunk, uint chunkPartOffset, uint chunkPartSize, long offset) 657 | { 658 | State = state; 659 | Chunk = chunk; 660 | ChunkPartOffset = chunkPartOffset; 661 | ChunkPartSize = chunkPartSize; 662 | Offset = offset; 663 | } 664 | } 665 | -------------------------------------------------------------------------------- /src/EpicManifestParser/GlobalUsings.cs: -------------------------------------------------------------------------------- 1 | global using EpicManifestParser.UE; 2 | 3 | #if NET9_0_OR_GREATER 4 | global using ManifestData = System.Span; 5 | global using ManifestRoData = System.ReadOnlySpan; 6 | global using ManifestReader = GenericReader.GenericSpanReader; 7 | global using LockObject = System.Threading.Lock; 8 | #else 9 | global using ManifestData = System.Memory; 10 | global using ManifestRoData = System.ReadOnlyMemory; 11 | global using ManifestReader = GenericReader.GenericBufferReader; 12 | global using LockObject = System.Object; 13 | #endif 14 | -------------------------------------------------------------------------------- /src/EpicManifestParser/Json/BlobString.cs: -------------------------------------------------------------------------------- 1 | using System.Diagnostics; 2 | using System.Runtime.CompilerServices; 3 | using System.Runtime.InteropServices; 4 | using System.Text.Json.Serialization; 5 | using System.Text.Json; 6 | 7 | namespace EpicManifestParser.Json; 8 | 9 | [DebuggerDisplay("Value,nq")] 10 | internal readonly struct BlobString where T : struct 11 | { 12 | public T Value { get; } 13 | public BlobString(T value) => Value = value; 14 | 15 | public static BlobString? Parse(ReadOnlySpan source) 16 | { 17 | if (source.Length == 0) return null; 18 | T result = default; 19 | var dest = MemoryMarshal.CreateSpan(ref Unsafe.As(ref result), Unsafe.SizeOf()); 20 | 21 | // Make sure the buffer is at least half the size and that the string is an 22 | // even number of characters long 23 | if (dest.Length >= (uint32)(source.Length / 3) && source.Length % 3 == 0) 24 | { 25 | Span convBuffer = stackalloc uint8[4]; 26 | convBuffer[3] = 0; 27 | 28 | int32 WriteIndex = 0; 29 | // Walk the string 3 chars at a time 30 | for (int32 Index = 0; Index < source.Length; Index += 3, WriteIndex++) 31 | { 32 | convBuffer[0] = source[Index]; 33 | convBuffer[1] = source[Index + 1]; 34 | convBuffer[2] = source[Index + 2]; 35 | dest[WriteIndex] = uint8.Parse(convBuffer); 36 | } 37 | return result; 38 | } 39 | return null; 40 | } 41 | 42 | public static implicit operator BlobString(T value) 43 | { 44 | return new BlobString(value); 45 | } 46 | 47 | public static explicit operator T?(BlobString? holder) 48 | { 49 | return holder?.Value; 50 | } 51 | } 52 | 53 | internal sealed class BlobStringConverter : JsonConverter?> where T : struct 54 | { 55 | public override BlobString? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) 56 | => BlobString.Parse(reader.ValueSpan); 57 | public override void Write(Utf8JsonWriter writer, BlobString? value, JsonSerializerOptions options) 58 | => throw new NotSupportedException(); 59 | } 60 | -------------------------------------------------------------------------------- /src/EpicManifestParser/Json/JsonNodeExtensions.cs: -------------------------------------------------------------------------------- 1 | using System.Text.Json; 2 | using System.Text.Json.Nodes; 3 | 4 | using EpicManifestParser.UE; 5 | 6 | namespace EpicManifestParser.Json; 7 | 8 | internal static class JsonNodeExtensions 9 | { 10 | private static readonly JsonSerializerOptions SerializerOptions = new() 11 | { 12 | Converters = 13 | { 14 | new FGuidConverter(), 15 | new FSHAHashConverter(), 16 | new BlobStringConverter(), 17 | new BlobStringConverter(), 18 | new BlobStringConverter(), 19 | new BlobStringConverter(), 20 | new BlobStringConverter(), 21 | new BlobStringConverter(), 22 | new BlobStringConverter(), 23 | } 24 | }; 25 | 26 | public static T GetBlob(this JsonNode? node, T defaultValue = default) where T : struct 27 | { 28 | return node.Deserialize?>(SerializerOptions)?.Value ?? defaultValue; 29 | } 30 | 31 | public static FGuid GetFGuid(this JsonNode? node) 32 | { 33 | return node.Deserialize(SerializerOptions); 34 | } 35 | 36 | public static FSHAHash GetSha(this JsonNode? node) 37 | { 38 | return node.Deserialize(SerializerOptions); 39 | } 40 | 41 | public static string GetString(this JsonNode? node, string defaultValue = "") 42 | { 43 | return node?.GetValue() ?? defaultValue; 44 | } 45 | 46 | public static T Get(this JsonNode? node, T defaultValue = default!) 47 | { 48 | return node is null ? defaultValue : node.GetValue(); 49 | } 50 | 51 | public static T Parse(this JsonNode? node, T defaultValue = default!) 52 | { 53 | return node is null ? defaultValue : node.Deserialize() ?? defaultValue; 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/EpicManifestParser/ManifestParseOptions.cs: -------------------------------------------------------------------------------- 1 | using System.Net; 2 | 3 | using EpicManifestParser.Api; 4 | 5 | namespace EpicManifestParser; 6 | // ReSharper disable UseSymbolAlias 7 | 8 | /// 9 | /// Options/Configuration for parsing manifests 10 | /// 11 | public class ManifestParseOptions 12 | { 13 | /// 14 | /// Zlib decompress delegate. 15 | /// 16 | public delegate bool DecompressDelegate(object? state, byte[] source, int sourceOffset, int sourceLength, byte[] destination, int destinationOffset, int destinationLength); 17 | 18 | /// 19 | /// Zlib decompress delegate, defaults to . 20 | /// 21 | public DecompressDelegate? Decompressor { get; set; } = ManifestZlibStreamDecompressor.Decompress; 22 | 23 | /// 24 | /// Optional state that gets passed to the delegate. 25 | /// 26 | public object? DecompressorState { get; set; } 27 | 28 | /// 29 | /// Required for downloading, must have a leading slash! 30 | /// 31 | /// 32 | /// Example: http://epicgames-download1.akamaized.net/Builds/Fortnite/CloudDir/
33 | /// Distributionpoints can be found here: here. 34 | ///
35 | public string? ChunkBaseUrl { get; set; } 36 | 37 | /// 38 | /// Your own (optional) used for downloading, must not have a ! 39 | /// 40 | public HttpClient? Client { get; set; } 41 | 42 | /// 43 | /// Buffer size for downloading chunks, defaults to 2097152 bytes (2 MiB). 44 | /// 45 | public int ChunkDownloadBufferSize { get; set; } = 2097152; 46 | 47 | /// 48 | /// Optional for caching chunks, very recommended. 49 | /// 50 | public string? ChunkCacheDirectory { get; set; } 51 | 52 | /// 53 | /// Whether or not to cache the chunks 1:1 as they were downloaded, defaults to . 54 | /// 55 | public bool CacheChunksAsIs { get; set; } 56 | 57 | /// 58 | /// Optional for caching manifests when using . 59 | /// 60 | public string? ManifestCacheDirectory { get; set; } 61 | 62 | /// 63 | /// Creates a default and also sets to its instance. 64 | /// 65 | /// The created . 66 | public HttpClient CreateDefaultClient() 67 | { 68 | if (Client is not null) 69 | return Client; 70 | 71 | var handler = new SocketsHttpHandler 72 | { 73 | UseCookies = false, 74 | UseProxy = false, 75 | AutomaticDecompression = DecompressionMethods.Deflate | DecompressionMethods.GZip, 76 | MaxConnectionsPerServer = 256 77 | }; 78 | Client = new HttpClient(handler) 79 | { 80 | DefaultRequestVersion = new Version(1, 1), 81 | DefaultVersionPolicy = HttpVersionPolicy.RequestVersionExact, 82 | Timeout = TimeSpan.FromSeconds(30) 83 | }; 84 | Client.DefaultRequestHeaders.Accept.ParseAdd("*/*"); 85 | Client.DefaultRequestHeaders.UserAgent.ParseAdd("EpicGamesLauncher/16.13.0-36938137+++Portal+Release-Live Windows/10.0.26100.1.256.64bit"); 86 | Client.DefaultRequestHeaders.ConnectionClose = false; 87 | return Client; 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/EpicManifestParser/ManifestZlibStreamDecompressor.cs: -------------------------------------------------------------------------------- 1 | using System.IO.Compression; 2 | 3 | namespace EpicManifestParser; 4 | 5 | /// 6 | /// The default decompressor using . 7 | /// 8 | public static class ManifestZlibStreamDecompressor 9 | { 10 | /// 11 | /// Decompresses data buffer into destination buffer. 12 | /// 13 | /// if the decompression was successful; otherwise, . 14 | public static bool Decompress(object? state, byte[] source, int sourceOffset, int sourceLength, byte[] destination, int destinationOffset, int destinationLength) 15 | { 16 | using var destinationMs = new MemoryStream(destination, destinationOffset, destinationLength, true, true); 17 | using var sourceMs = new MemoryStream(source, sourceOffset, sourceLength, false, true); 18 | using var zlibStream = new ZLibStream(sourceMs, CompressionMode.Decompress); 19 | zlibStream.CopyTo(destinationMs); 20 | return destinationMs.Position == destinationLength; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/EpicManifestParser/UE/EChunkDataListVersion.cs: -------------------------------------------------------------------------------- 1 | namespace EpicManifestParser.UE; 2 | 3 | internal enum EChunkDataListVersion : uint8 4 | { 5 | Original = 0, 6 | 7 | // Always after the latest version, signifies the latest version plus 1 to allow initialization simplicity. 8 | LatestPlusOne, 9 | Latest = (LatestPlusOne - 1) 10 | } 11 | -------------------------------------------------------------------------------- /src/EpicManifestParser/UE/EChunkHashFlags.cs: -------------------------------------------------------------------------------- 1 | namespace EpicManifestParser.UE; 2 | 3 | [Flags] 4 | internal enum EChunkHashFlags : uint8 5 | { 6 | None = 0x00, 7 | 8 | // Flag for FRollingHash class used, stored in RollingHash on header. 9 | RollingPoly64 = 0x01, 10 | 11 | // Flag for FSHA1 class used, stored in SHAHash on header. 12 | Sha1 = 0x02, 13 | } 14 | -------------------------------------------------------------------------------- /src/EpicManifestParser/UE/EChunkStorageFlags.cs: -------------------------------------------------------------------------------- 1 | namespace EpicManifestParser.UE; 2 | 3 | [Flags] 4 | internal enum EChunkStorageFlags : uint8 5 | { 6 | None = 0x00, 7 | 8 | // Flag for compressed data. 9 | Compressed = 0x01, 10 | 11 | // Flag for encrypted. If also compressed, decrypt first. Encryption will ruin compressibility. 12 | Encrypted = 0x02, 13 | } 14 | -------------------------------------------------------------------------------- /src/EpicManifestParser/UE/EChunkVersion.cs: -------------------------------------------------------------------------------- 1 | namespace EpicManifestParser.UE; 2 | 3 | internal enum EChunkVersion : uint32 4 | { 5 | Invalid = 0, 6 | Original, 7 | StoresShaAndHashType, 8 | StoresDataSizeUncompressed, 9 | 10 | // Always after the latest version, signifies the latest version plus 1 to allow initialization simplicity. 11 | LatestPlusOne, 12 | Latest = (LatestPlusOne - 1) 13 | } 14 | -------------------------------------------------------------------------------- /src/EpicManifestParser/UE/EFeatureLevel.cs: -------------------------------------------------------------------------------- 1 | namespace EpicManifestParser.UE; 2 | 3 | /// 4 | /// UE EFeatureLevel enum 5 | /// 6 | public enum EFeatureLevel 7 | { 8 | /// 9 | /// The original version. 10 | /// 11 | Original = 0, 12 | /// 13 | /// Support for custom fields. 14 | /// 15 | CustomFields, 16 | /// 17 | /// Started storing the version number. 18 | /// 19 | StartStoringVersion, 20 | /// 21 | /// Made after data files where renamed to include the hash value, these chunks now go to ChunksV2. 22 | /// 23 | DataFileRenames, 24 | /// 25 | /// Manifest stores whether build was constructed with chunk or file data. 26 | /// 27 | StoresIfChunkOrFileData, 28 | /// 29 | /// Manifest stores group number for each chunk/file data for reference so that external readers don't need to know how to calculate them. 30 | /// 31 | StoresDataGroupNumbers, 32 | /// 33 | /// Added support for chunk compression, these chunks now go to ChunksV3. NB: Not File Data Compression yet. 34 | /// 35 | ChunkCompressionSupport, 36 | /// 37 | /// Manifest stores product prerequisites info. 38 | /// 39 | StoresPrerequisitesInfo, 40 | /// 41 | /// Manifest stores chunk download sizes. 42 | /// 43 | StoresChunkFileSizes, 44 | /// 45 | /// Manifest can optionally be stored using UObject serialization and compressed. 46 | /// 47 | StoredAsCompressedUClass, 48 | /// 49 | /// Removed and never used. 50 | /// 51 | UNUSED_0, 52 | /// 53 | /// Removed and never used. 54 | /// 55 | UNUSED_1, 56 | /// 57 | /// Manifest stores chunk data SHA1 hash to use in place of data compare, for faster generation. 58 | /// 59 | StoresChunkDataShaHashes, 60 | /// 61 | /// Manifest stores Prerequisite Ids. 62 | /// 63 | StoresPrerequisiteIds, 64 | /// 65 | /// The first minimal binary format was added. UObject classes will no longer be saved out when binary selected. 66 | /// 67 | StoredAsBinaryData, 68 | /// 69 | /// Temporary level where manifest can reference chunks with dynamic window size, but did not serialize them. Chunks from here onwards are stored in ChunksV4. 70 | /// 71 | VariableSizeChunksWithoutWindowSizeChunkInfo, 72 | /// 73 | /// Manifest can reference chunks with dynamic window size, and also serializes them. 74 | /// 75 | VariableSizeChunks, 76 | /// 77 | /// Manifest uses a build id generated from its metadata. 78 | /// 79 | UsesRuntimeGeneratedBuildId, 80 | /// 81 | /// Manifest uses a build id generated unique at build time, and stored in manifest. 82 | /// 83 | UsesBuildTimeGeneratedBuildId, 84 | 85 | /// 86 | /// Undocumented in UE 87 | /// 88 | Unknown1, 89 | /// 90 | /// Undocumented in UE 91 | /// 92 | Unknown2, 93 | /// 94 | /// Used for fortnite currently 95 | /// 96 | Unknown3, 97 | 98 | /// 99 | /// !! Always after the latest version entry, signifies the latest version plus 1 to allow the following Latest alias. 100 | /// 101 | LatestPlusOne = (UsesBuildTimeGeneratedBuildId + 1), 102 | /// 103 | /// An alias for the actual latest version value. 104 | /// 105 | Latest = (LatestPlusOne - 1), 106 | /// 107 | /// An alias to provide the latest version of a manifest supported by file data (nochunks). 108 | /// 109 | LatestNoChunks = StoresChunkFileSizes, 110 | /// 111 | /// An alias to provide the latest version of a manifest supported by a json serialized format. 112 | /// 113 | LatestJson = StoresPrerequisiteIds, 114 | /// 115 | /// An alias to provide the first available version of optimised delta manifest saving. 116 | /// 117 | FirstOptimisedDelta = UsesRuntimeGeneratedBuildId, 118 | 119 | /// 120 | /// More aliases, but this time for values that have been renamed 121 | /// 122 | StoresUniqueBuildId = UsesRuntimeGeneratedBuildId, 123 | 124 | /// 125 | /// JSON manifests were stored with a version of 255 during a certain CL range due to a bug. 126 | /// We will treat this as being StoresChunkFileSizes in code. 127 | /// 128 | BrokenJsonVersion = 255, 129 | /// 130 | /// This is for UObject default, so that we always serialize it. 131 | /// 132 | Invalid = -1 133 | } 134 | -------------------------------------------------------------------------------- /src/EpicManifestParser/UE/EFileManifestListVersion.cs: -------------------------------------------------------------------------------- 1 | namespace EpicManifestParser.UE; 2 | 3 | internal enum EFileManifestListVersion : uint8 4 | { 5 | Original = 0, 6 | 7 | // Always after the latest version, signifies the latest version plus 1 to allow initialization simplicity. 8 | LatestPlusOne, 9 | Latest = (LatestPlusOne - 1) 10 | } 11 | -------------------------------------------------------------------------------- /src/EpicManifestParser/UE/EFileMetaFlags.cs: -------------------------------------------------------------------------------- 1 | namespace EpicManifestParser.UE; 2 | 3 | /// 4 | /// UE EFileMetaFlags enum 5 | /// 6 | [Flags] 7 | public enum EFileMetaFlags : uint8 8 | { 9 | /// 10 | /// None 11 | /// 12 | None = 0, 13 | /// 14 | /// Flag for readonly file. 15 | /// 16 | ReadOnly = 1, 17 | /// 18 | /// Flag for natively compressed. 19 | /// 20 | Compressed = 1 << 1, 21 | /// 22 | /// Flag for unix executable. 23 | /// 24 | UnixExecutable = 1 << 2 25 | } 26 | -------------------------------------------------------------------------------- /src/EpicManifestParser/UE/EManifestMetaVersion.cs: -------------------------------------------------------------------------------- 1 | namespace EpicManifestParser.UE; 2 | 3 | internal enum EManifestMetaVersion : uint8 4 | { 5 | Original = 0, 6 | SerialisesBuildId, 7 | 8 | // Always after the latest version, signifies the latest version plus 1 to allow initialization simplicity. 9 | LatestPlusOne, 10 | Latest = (LatestPlusOne - 1) 11 | } 12 | -------------------------------------------------------------------------------- /src/EpicManifestParser/UE/EManifestStorageFlags.cs: -------------------------------------------------------------------------------- 1 | namespace EpicManifestParser.UE; 2 | 3 | [Flags] 4 | internal enum EManifestStorageFlags : uint8 5 | { 6 | // Stored as raw data. 7 | None = 0, 8 | // Flag for compressed data. 9 | Compressed = 1, 10 | // Flag for encrypted. If also compressed, decrypt first. Encryption will ruin compressibility. 11 | Encrypted = 1 << 1, 12 | } 13 | -------------------------------------------------------------------------------- /src/EpicManifestParser/UE/FBuildPatchAppManifest.cs: -------------------------------------------------------------------------------- 1 | using System.Buffers; 2 | using System.Diagnostics; 3 | using System.Diagnostics.CodeAnalysis; 4 | using System.Runtime.InteropServices; 5 | using System.Text.Json; 6 | using System.Text.Json.Nodes; 7 | 8 | using AsyncKeyedLock; 9 | 10 | using EpicManifestParser.Json; 11 | 12 | namespace EpicManifestParser.UE; 13 | 14 | /// 15 | /// UE FBuildPatchAppManifest struct 16 | /// 17 | public class FBuildPatchAppManifest 18 | { 19 | /// 20 | public FManifestMeta Meta { get; internal set; } = null!; 21 | /// 22 | public IReadOnlyList ChunkList { get; internal set; } = null!; 23 | /// 24 | public IReadOnlyList Files { get; internal set; } = null!; 25 | /// 26 | public IReadOnlyList CustomFields { get; internal set; } = null!; 27 | /// 28 | public IReadOnlyDictionary Chunks { get; internal set; } = null!; 29 | 30 | /// 31 | public int64 TotalBuildSize { get; internal set; } 32 | /// 33 | public int64 TotalDownloadSize { get; internal set; } 34 | 35 | internal ManifestParseOptions Options { get; init; } = null!; 36 | internal AsyncKeyedLocker ChunksLocker { get; set; } = null!; 37 | 38 | internal FBuildPatchAppManifest() { } 39 | 40 | /// 41 | /// Finds a file by . 42 | /// 43 | /// The filename to find. 44 | /// The type to compare the filename. 45 | /// The instance if the the file was found; otherwise, . 46 | public FFileManifest? FindFile(string fileName, StringComparison comparisonType = StringComparison.Ordinal) 47 | => TryFindFile(fileName, comparisonType, out var file) ? file : null; 48 | 49 | /// 50 | /// Tries to find a file by using to compare it. 51 | /// 52 | /// The filename to find. 53 | /// The find result. 54 | /// if the the file was found; otherwise, . 55 | public bool TryFindFile(string fileName, [NotNullWhen(true)] out FFileManifest? fileManifest) 56 | => TryFindFile(fileName, StringComparison.Ordinal, out fileManifest); 57 | 58 | /// 59 | /// Tries to find a file by . 60 | /// 61 | /// The filename to find. 62 | /// The type to compare the filename. 63 | /// The find result. 64 | /// if the the file was found; otherwise, . 65 | public bool TryFindFile(string fileName, StringComparison comparisonType, [NotNullWhen(true)] out FFileManifest? fileManifest) 66 | { 67 | foreach (var file in Files) 68 | { 69 | if (!file.FileName.Equals(fileName, comparisonType)) 70 | continue; 71 | 72 | fileManifest = file; 73 | return true; 74 | } 75 | 76 | fileManifest = null; 77 | return false; 78 | } 79 | 80 | /// 81 | /// Get the chunk sub-directory name 82 | /// 83 | public string GetChunkSubdir() => Meta.FeatureLevel switch 84 | { 85 | > EFeatureLevel.StoredAsBinaryData => "ChunksV4", 86 | > EFeatureLevel.StoresDataGroupNumbers => "ChunksV3", 87 | > EFeatureLevel.StartStoringVersion => "ChunksV2", 88 | _ => "Chunks" 89 | }; 90 | 91 | /// 92 | /// Helper function to decide whether the passed in data is a JSON string we expect to deserialize a manifest from 93 | /// 94 | /// if the is JSON; otherwise, . 95 | public static bool IsJson(ManifestRoData dataInput) 96 | { 97 | // The best we can do is look for the mandatory first character open curly brace, 98 | // it will be within the first 4 characters (may have BOM) 99 | var span = dataInput 100 | #if !NET9_0_OR_GREATER 101 | .Span 102 | #endif 103 | ; 104 | for (var idx = 0; idx < 4 && idx < span.Length; ++idx) 105 | { 106 | if (span[idx] == '{') 107 | { 108 | return true; 109 | } 110 | } 111 | return false; 112 | } 113 | 114 | /// 115 | /// Deserializes a binary or JSON manifest 116 | /// 117 | /// 118 | public static FBuildPatchAppManifest Deserialize(ManifestRoData dataInput, Action? optionsBuilder = null) 119 | { 120 | var options = new ManifestParseOptions(); 121 | optionsBuilder?.Invoke(options); 122 | return Deserialize(dataInput, options); 123 | } 124 | 125 | /// 126 | /// Deserializes a JSON manifest 127 | /// 128 | /// The span to parse from 129 | /// Builder for options/configuration to parse 130 | public static FBuildPatchAppManifest DeserializeJson(ManifestRoData dataInput, Action? optionsBuilder = null) 131 | { 132 | var options = new ManifestParseOptions(); 133 | optionsBuilder?.Invoke(options); 134 | return DeserializeJson(dataInput, options); 135 | } 136 | 137 | /// 138 | /// Deserializes a binary manifest 139 | /// 140 | /// The span to parse from 141 | /// Builder for options/configuration to parse 142 | /// Manifest is encrypted or older than 143 | /// Data is compressed and zlib-ng instance was null 144 | /// Error while parsing 145 | /// Hashes do not match 146 | public static FBuildPatchAppManifest DeserializeBinary(ManifestRoData dataInput, Action? optionsBuilder = null) 147 | { 148 | var options = new ManifestParseOptions(); 149 | optionsBuilder?.Invoke(options); 150 | return DeserializeBinary(dataInput, options); 151 | } 152 | 153 | /// 154 | /// Deserializes a binary or JSON manifest 155 | /// 156 | /// 157 | public static FBuildPatchAppManifest Deserialize(ManifestRoData dataInput, ManifestParseOptions options) 158 | { 159 | return IsJson(dataInput) ? DeserializeJson(dataInput, options) : DeserializeBinary(dataInput, options); 160 | } 161 | 162 | /// 163 | /// Deserializes a JSON manifest 164 | /// 165 | /// The span to parse from 166 | /// Options/Configuration to parse 167 | public static FBuildPatchAppManifest DeserializeJson(ManifestRoData dataInput, ManifestParseOptions options) 168 | { 169 | var reader = JsonNode.Parse(dataInput 170 | #if !NET9_0_OR_GREATER 171 | .Span 172 | #endif 173 | )!.AsObject(); 174 | 175 | var featureLevel = reader["ManifestFileVersion"].GetBlob(EFeatureLevel.CustomFields); 176 | if (featureLevel == EFeatureLevel.BrokenJsonVersion) 177 | featureLevel = EFeatureLevel.StoresChunkFileSizes; 178 | 179 | var meta = new FManifestMeta 180 | { 181 | FeatureLevel = featureLevel, 182 | AppID = reader["AppID"].GetBlob(), 183 | AppName = reader["AppNameString"].GetString(), 184 | BuildVersion = reader["BuildVersionString"].GetString(), 185 | LaunchExe = reader["LaunchExeString"].GetString(), 186 | LaunchCommand = reader["LaunchCommand"].GetString(), 187 | PrereqName = reader["PrereqName"].GetString(), 188 | PrereqPath = reader["PrereqPath"].GetString(), 189 | PrereqArgs = reader["PrereqArgs"].GetString(), 190 | UninstallExe = "", 191 | UninstallCommand = "", 192 | }; 193 | 194 | var jsonFileManifestList = reader["FileManifestList"]!.AsArray(); 195 | var fileManifests = new FFileManifest[jsonFileManifestList.Count]; 196 | var fileManifestsSpan = fileManifests.AsSpan(); 197 | 198 | //var allDataGuids = new HashSet(); 199 | var mutableChunkInfoLookup = new Dictionary(); 200 | 201 | for (var i = 0; i < fileManifestsSpan.Length; i++) 202 | { 203 | var jsonFileManifest = jsonFileManifestList[i]!; 204 | var fileManifest = fileManifestsSpan[i] = new FFileManifest 205 | { 206 | FileName = jsonFileManifest["Filename"].GetString(), 207 | FileHash = jsonFileManifest["FileHash"].GetBlob(), 208 | InstallTags = jsonFileManifest["InstallTags"].Parse([]), 209 | SymlinkTarget = jsonFileManifest["SymlinkTarget"].GetString() 210 | }; 211 | var jsonFileChunkParts = jsonFileManifest["FileChunkParts"]!.AsArray(); 212 | fileManifest.ChunkPartsArray = new FChunkPart[jsonFileChunkParts.Count]; 213 | var chunkPartsSpan = fileManifest.ChunkPartsArray.AsSpan(); 214 | for (var j = 0; j < chunkPartsSpan.Length; j++) 215 | { 216 | var jsonFileChunkPart = jsonFileChunkParts[j]!; 217 | var chunkPartGuid = jsonFileChunkPart["Guid"].GetFGuid(); 218 | var chunkPartOffset = jsonFileChunkPart["Offset"].GetBlob(); 219 | var chunkPartSize = jsonFileChunkPart["Size"].GetBlob(); 220 | chunkPartsSpan[j] = new FChunkPart(chunkPartGuid, chunkPartOffset, chunkPartSize); 221 | 222 | ref var lookupChunk = ref CollectionsMarshal.GetValueRefOrAddDefault(mutableChunkInfoLookup, chunkPartGuid, out var exists); 223 | if (!exists) 224 | { 225 | lookupChunk = new FChunkInfo 226 | { 227 | Guid = chunkPartGuid 228 | }; 229 | } 230 | } 231 | 232 | if (jsonFileManifest["bIsUnixExecutable"].Get()) 233 | fileManifest.FileMetaFlags |= EFileMetaFlags.UnixExecutable; 234 | if (jsonFileManifest["bIsReadOnly"].Get()) 235 | fileManifest.FileMetaFlags |= EFileMetaFlags.ReadOnly; 236 | if (jsonFileManifest["bIsCompressed"].Get()) 237 | fileManifest.FileMetaFlags |= EFileMetaFlags.Compressed; 238 | } 239 | 240 | var chunkList = new FChunkInfo[mutableChunkInfoLookup.Count]; 241 | var chunkListSpan = chunkList.AsSpan(); 242 | var chunkIndex = 0; 243 | foreach (var chunk in mutableChunkInfoLookup.Values) 244 | { 245 | chunkListSpan[chunkIndex++] = chunk; 246 | } 247 | 248 | var hasChunkHashList = false; 249 | var jsonChunkHashListNode = reader["ChunkHashList"]; 250 | if (jsonChunkHashListNode is not null) 251 | { 252 | var jsonChunkHashList = jsonChunkHashListNode.AsObject(); 253 | 254 | foreach (var (guidString, jsonChunkHash) in jsonChunkHashList) 255 | { 256 | var guid = new FGuid(guidString); 257 | var chunkHash = jsonChunkHash.GetBlob(); 258 | mutableChunkInfoLookup[guid].Hash = chunkHash; 259 | } 260 | 261 | hasChunkHashList = true; 262 | } 263 | 264 | var jsonChunkShaListNode = reader["ChunkShaList"]; 265 | if (jsonChunkShaListNode is not null) 266 | { 267 | var jsonChunkShaList = jsonChunkShaListNode.AsObject(); 268 | 269 | foreach (var (guidString, jsonSha) in jsonChunkShaList) 270 | { 271 | var guid = new FGuid(guidString); 272 | var chunkSha = jsonSha.GetSha(); 273 | mutableChunkInfoLookup[guid].ShaHash = chunkSha; 274 | } 275 | } 276 | 277 | var prereqIds = reader["PrereqIds"].Deserialize(); 278 | if (prereqIds is null) 279 | { 280 | // TODO: https://github.com/EpicGames/UnrealEngine/blob/8c31706601135aadf2f957fb76e2af46f04a8ef9/Engine/Source/Runtime/Online/BuildPatchServices/Private/BuildPatchManifest.cpp#L602 281 | meta.PrereqIds = []; 282 | } 283 | else 284 | { 285 | meta.PrereqIds = prereqIds; 286 | } 287 | 288 | var jsonDataGroupListNode = reader["DataGroupList"]; 289 | if (jsonDataGroupListNode is not null) 290 | { 291 | var jsonDataGroupList = jsonDataGroupListNode.AsObject(); 292 | 293 | foreach (var (guidString, jsonDataGroup) in jsonDataGroupList) 294 | { 295 | var guid = new FGuid(guidString); 296 | var dataGroup = jsonDataGroup.GetBlob(); 297 | mutableChunkInfoLookup[guid].GroupNumber = dataGroup; 298 | } 299 | } 300 | else 301 | { 302 | // TODO: https://github.com/EpicGames/UnrealEngine/blob/8c31706601135aadf2f957fb76e2af46f04a8ef9/Engine/Source/Runtime/Online/BuildPatchServices/Private/BuildPatchManifest.cpp#L635 303 | // https://github.com/EpicGames/UnrealEngine/blob/8c31706601135aadf2f957fb76e2af46f04a8ef9/Engine/Source/Runtime/Core/Private/Misc/Crc.cpp#L592 304 | } 305 | 306 | var hasChunkFilesizeList = false; 307 | var jsonChunkFilesizeListNode = reader["ChunkFilesizeList"]; 308 | if (jsonChunkFilesizeListNode is not null) 309 | { 310 | var jsonChunkFilesizeList = jsonChunkFilesizeListNode.AsObject(); 311 | 312 | foreach (var (guidString, jsonFileSize) in jsonChunkFilesizeList) 313 | { 314 | var guid = new FGuid(guidString); 315 | var fileSize = jsonFileSize.GetBlob(); 316 | mutableChunkInfoLookup[guid].FileSize = fileSize; 317 | } 318 | 319 | hasChunkFilesizeList = true; 320 | } 321 | 322 | if (!hasChunkFilesizeList) 323 | { 324 | // Missing chunk list, version before we saved them compressed. Assume original fixed chunk size of 1 MiB. 325 | foreach (var chunk in chunkListSpan) 326 | { 327 | chunk.FileSize = 1048576; 328 | } 329 | } 330 | 331 | if (reader.TryGetPropertyValue("bIsFileData", out var jsonIsFileData)) 332 | { 333 | meta.bIsFileData = jsonIsFileData.Get(); 334 | } 335 | else 336 | { 337 | meta.bIsFileData = !hasChunkHashList; 338 | } 339 | 340 | FCustomField[]? customFields = null; 341 | var jsonCustomFieldsNode = reader["CustomFields"]; 342 | if (jsonCustomFieldsNode is not null) 343 | { 344 | var jsonCustomFields = jsonCustomFieldsNode.AsObject(); 345 | customFields = new FCustomField[jsonCustomFields.Count]; 346 | var customFieldIndex = 0; 347 | 348 | foreach (var (name, jsonValue) in jsonCustomFields) 349 | { 350 | customFields[customFieldIndex++] = new FCustomField 351 | { 352 | Name = name, 353 | Value = jsonValue.GetString() 354 | }; 355 | } 356 | } 357 | 358 | meta.BuildId = FManifestMeta.GetBackwardsCompatibleBuildId(meta); 359 | 360 | var manifest = new FBuildPatchAppManifest 361 | { 362 | Meta = meta, 363 | ChunkList = chunkList, 364 | Files = fileManifests, 365 | CustomFields = customFields ?? [], 366 | Chunks = mutableChunkInfoLookup, 367 | Options = options 368 | }; 369 | manifest.PostSetup(); 370 | 371 | // FileDataList.OnPostLoad(); 372 | { 373 | Array.Sort(fileManifests); 374 | for (var i = 0; i < fileManifestsSpan.Length; i++) 375 | { 376 | var file = fileManifestsSpan[i]; 377 | file.Manifest = manifest; 378 | foreach (var chunkPart in file.ChunkPartsArray.AsSpan()) 379 | { 380 | file.FileSize += chunkPart.Size; 381 | } 382 | } 383 | } 384 | 385 | return manifest; 386 | } 387 | 388 | /// 389 | /// Deserializes a binary manifest 390 | /// 391 | /// The span to parse from 392 | /// Options/Configuration to parse 393 | /// Manifest is encrypted or older than 394 | /// Data is compressed and zlib-ng instance was null 395 | /// Error while parsing 396 | /// Hashes do not match 397 | public static FBuildPatchAppManifest DeserializeBinary(ManifestRoData dataInput, ManifestParseOptions options) 398 | { 399 | var fileReader = new ManifestReader(dataInput); 400 | byte[]? manifestRawDataBuffer = null; 401 | 402 | try 403 | { 404 | var header = new FManifestHeader(ref fileReader); 405 | 406 | if (header.Version < EFeatureLevel.StoredAsBinaryData) 407 | throw new NotSupportedException("Manifests below feature level StoredAsBinaryData are not supported"); 408 | if (header.StoredAs.HasFlag(EManifestStorageFlags.Encrypted)) 409 | throw new NotSupportedException("Encrypted manifests are not supported"); 410 | if (header.StoredAs.HasFlag(EManifestStorageFlags.Compressed) && options.Decompressor is null) 411 | throw new InvalidOperationException("Data is compressed and decompressor delegate was null"); 412 | 413 | ManifestData manifestRawData; 414 | 415 | if (header.StoredAs.HasFlag(EManifestStorageFlags.Compressed)) 416 | { 417 | manifestRawDataBuffer = ArrayPool.Shared.Rent(header.DataSizeCompressed + header.DataSizeUncompressed); 418 | manifestRawData = manifestRawDataBuffer 419 | #if NET9_0_OR_GREATER 420 | .AsSpan 421 | #else 422 | .AsMemory 423 | #endif 424 | (header.DataSizeCompressed, header.DataSizeUncompressed); 425 | 426 | var manifestCompressedData = manifestRawDataBuffer.AsSpan(0, header.DataSizeCompressed); 427 | fileReader.Read(manifestCompressedData); 428 | 429 | var result = options.Decompressor!.Invoke( 430 | options.DecompressorState, 431 | manifestRawDataBuffer, 0, header.DataSizeCompressed, 432 | manifestRawDataBuffer, header.DataSizeCompressed, header.DataSizeUncompressed); 433 | if (!result) 434 | throw new FileLoadException("Failed to uncompress data"); 435 | } 436 | else if (header.StoredAs == EManifestStorageFlags.None) 437 | { 438 | manifestRawDataBuffer = ArrayPool.Shared.Rent(header.DataSizeCompressed); 439 | manifestRawData = manifestRawDataBuffer 440 | #if NET9_0_OR_GREATER 441 | .AsSpan 442 | #else 443 | .AsMemory 444 | #endif 445 | (0, header.DataSizeCompressed); 446 | fileReader.Read(manifestRawData 447 | #if !NET9_0_OR_GREATER 448 | .Span 449 | #endif 450 | ); 451 | } 452 | else 453 | { 454 | throw new UnreachableException("Manifest has invalid or unknown storage flags"); 455 | } 456 | 457 | var hash = FSHAHash.Compute(manifestRawData 458 | #if !NET9_0_OR_GREATER 459 | .Span 460 | #endif 461 | ); 462 | if (header.SHAHash != hash) 463 | throw new InvalidDataException($"Hash does not match. expected: {header.SHAHash}, actual: {hash}"); 464 | 465 | var reader = new ManifestReader(manifestRawData); 466 | var chunks = new Dictionary(); 467 | var manifest = new FBuildPatchAppManifest 468 | { 469 | Chunks = chunks, 470 | Options = options 471 | }; 472 | manifest.Meta = new FManifestMeta(ref reader); 473 | manifest.ChunkList = FChunkInfo.ReadChunkDataList(ref reader, chunks); 474 | manifest.Files = FFileManifest.ReadFileDataList(ref reader, manifest); 475 | manifest.CustomFields = FCustomField.ReadCustomFields(ref reader); 476 | manifest.PostSetup(); 477 | return manifest; 478 | } 479 | finally 480 | { 481 | if (manifestRawDataBuffer is not null) 482 | ArrayPool.Shared.Return(manifestRawDataBuffer); 483 | } 484 | } 485 | 486 | private void PostSetup() 487 | { 488 | foreach (var file in Files) 489 | { 490 | TotalBuildSize += file.FileSize; 491 | } 492 | 493 | foreach (var chunk in ChunkList) 494 | { 495 | TotalDownloadSize += chunk.FileSize; 496 | } 497 | 498 | if (!string.IsNullOrEmpty(Options.ChunkBaseUrl)) 499 | { 500 | ChunksLocker = new AsyncKeyedLocker(lockerOptions => 501 | { 502 | lockerOptions.MaxCount = 1; 503 | lockerOptions.PoolSize = 128; 504 | lockerOptions.PoolInitialFill = 64; 505 | }); 506 | 507 | Options.CreateDefaultClient(); 508 | } 509 | } 510 | } 511 | -------------------------------------------------------------------------------- /src/EpicManifestParser/UE/FChunkHeader.cs: -------------------------------------------------------------------------------- 1 | using System.Runtime.CompilerServices; 2 | 3 | namespace EpicManifestParser.UE; 4 | 5 | internal struct FChunkHeader 6 | { 7 | public const uint32 Magic = 0xB1FE3AA2; 8 | 9 | /// 10 | /// The version of this header data. 11 | /// 12 | public EChunkVersion Version; 13 | /// 14 | /// The size of this header. 15 | /// 16 | public int32 HeaderSize; 17 | /*/// 18 | /// The GUID for this data. 19 | /// 20 | public FGuid Guid;*/ 21 | /// 22 | /// The size of this data compressed. 23 | /// 24 | public int32 DataSizeCompressed; 25 | /// 26 | /// The size of this data uncompressed. 27 | /// 28 | public int32 DataSizeUncompressed; 29 | /// 30 | /// How the chunk data is stored. 31 | /// 32 | public EChunkStorageFlags StoredAs; 33 | /*/// 34 | /// What type of hash we are using. 35 | /// 36 | public EChunkHashFlags HashType; 37 | /// 38 | /// The FRollingHash hashed value for this chunk data. 39 | /// 40 | public uint64 RollingHash; 41 | /// 42 | /// The FSHA hashed value for this chunk data. 43 | /// 44 | public FSHAHash SHAHash;*/ 45 | 46 | [MethodImpl(MethodImplOptions.AggressiveInlining)] 47 | internal static FChunkHeader Parse(ManifestData data) 48 | { 49 | var reader = new ManifestReader(data); 50 | return new FChunkHeader(ref reader); 51 | } 52 | 53 | internal FChunkHeader(ref ManifestReader reader) 54 | { 55 | var startPos = reader.Position; 56 | var archiveSizeLeft = reader.Length - startPos; 57 | var versionSizesSpan = ChunkHeaderVersionSizes.AsSpan(); 58 | var expectedSerializedBytes = versionSizesSpan[(int32)EChunkVersion.Original]; 59 | 60 | if (archiveSizeLeft >= expectedSerializedBytes) 61 | { 62 | var magic = reader.Read(); 63 | if (magic != Magic) 64 | throw new FileLoadException($"invalid chunk magic: 0x{magic:X}"); 65 | 66 | Version = reader.Read(); 67 | HeaderSize = reader.Read(); 68 | DataSizeCompressed = reader.Read(); 69 | 70 | //Guid = reader.Read(); 71 | //RollingHash = reader.Read(); 72 | reader.Position += FGuid.Size + sizeof(uint64); 73 | 74 | StoredAs = reader.Read(); 75 | DataSizeUncompressed = 1024 * 1024; 76 | 77 | if (Version >= EChunkVersion.StoresShaAndHashType) 78 | { 79 | expectedSerializedBytes = versionSizesSpan[(int32)EChunkVersion.StoresShaAndHashType]; 80 | if (archiveSizeLeft >= expectedSerializedBytes) 81 | { 82 | //SHAHash = reader.Read(); 83 | //HashType = reader.Read(); 84 | reader.Position += FSHAHash.Size + sizeof(EChunkHashFlags); 85 | } 86 | 87 | if (Version >= EChunkVersion.StoresDataSizeUncompressed) 88 | { 89 | expectedSerializedBytes = versionSizesSpan[(int32)EChunkVersion.StoresDataSizeUncompressed]; 90 | if (archiveSizeLeft >= expectedSerializedBytes) 91 | { 92 | DataSizeUncompressed = reader.Read(); 93 | } 94 | } 95 | } 96 | } 97 | 98 | var success = reader.Position - startPos == expectedSerializedBytes; 99 | reader.Position = startPos + HeaderSize; 100 | } 101 | 102 | private static readonly uint32[] ChunkHeaderVersionSizes = 103 | [ 104 | // Dummy for indexing. 105 | 0, 106 | // Original is 41 bytes (32b Magic, 32b Version, 32b HeaderSize, 32b DataSizeCompressed, 4x32b GUID, 64b Hash, 8b StoredAs). 107 | 41, 108 | // StoresShaAndHashType is 62 bytes (328b Original, 160b SHA1, 8b HashType). 109 | 62, 110 | // StoresDataSizeUncompressed is 66 bytes (496b StoresShaAndHashType, 32b DataSizeUncompressed). 111 | 66 112 | ]; 113 | } 114 | -------------------------------------------------------------------------------- /src/EpicManifestParser/UE/FChunkInfo.cs: -------------------------------------------------------------------------------- 1 | using System.Buffers; 2 | using System.Diagnostics; 3 | using System.Diagnostics.CodeAnalysis; 4 | using System.Runtime.CompilerServices; 5 | 6 | namespace EpicManifestParser.UE; 7 | 8 | /// 9 | /// UE FChunkInfo struct 10 | /// 11 | public sealed class FChunkInfo 12 | { 13 | /// 14 | /// The GUID for this data. 15 | /// 16 | public FGuid Guid { get; internal set; } 17 | /// 18 | /// The FRollingHash hashed value for this chunk data. 19 | /// 20 | public uint64 Hash { get; internal set; } 21 | /// 22 | /// The FSHA hashed value for this chunk data. 23 | /// 24 | public FSHAHash ShaHash { get; internal set; } 25 | /// 26 | /// The group number this chunk divides into. 27 | /// 28 | public uint8 GroupNumber { get; internal set; } 29 | /// 30 | /// The window size for this chunk. 31 | /// 32 | public uint32 WindowSize { get; internal set; } 33 | /// 34 | /// The file download size for this chunk. 35 | /// 36 | public int64 FileSize { get; internal set; } 37 | 38 | /// 39 | /// 40 | /// Url to download this chunk 41 | /// 42 | public string GetUrl(FBuildPatchAppManifest manifest) => 43 | $"{manifest.Options.ChunkBaseUrl}{manifest.GetChunkSubdir()}/{GroupNumber:D2}/{Hash:X16}_{Guid}.chunk"; 44 | 45 | /// 46 | /// 47 | /// to download this chunk 48 | /// 49 | public Uri GetUri(FBuildPatchAppManifest manifest) => new(GetUrl(manifest), UriKind.Absolute); 50 | 51 | internal string? CachePath { get; set; } 52 | 53 | internal static FChunkInfo[] ReadChunkDataList(ref ManifestReader reader, Dictionary chunksDict) 54 | { 55 | var startPos = reader.Position; 56 | var dataSize = reader.Read(); 57 | var dataVersion = reader.Read(); 58 | var elementCount = reader.Read(); 59 | 60 | var chunks = new FChunkInfo[elementCount]; 61 | var chunksSpan = chunks.AsSpan(); 62 | 63 | chunksDict.EnsureCapacity(elementCount); 64 | 65 | if (dataVersion >= EChunkDataListVersion.Original) 66 | { 67 | for (var i = 0; i < elementCount; i++) 68 | { 69 | var chunk = new FChunkInfo(); 70 | chunk.Guid = reader.Read(); 71 | chunksSpan[i] = chunk; 72 | chunksDict.Add(chunk.Guid, chunk); 73 | } 74 | for (var i = 0; i < elementCount; i++) 75 | chunksSpan[i].Hash = reader.Read(); 76 | for (var i = 0; i < elementCount; i++) 77 | chunksSpan[i].ShaHash = reader.Read(); 78 | for (var i = 0; i < elementCount; i++) 79 | chunksSpan[i].GroupNumber = reader.Read(); 80 | for (var i = 0; i < elementCount; i++) 81 | chunksSpan[i].WindowSize = reader.Read(); 82 | for (var i = 0; i < elementCount; i++) 83 | chunksSpan[i].FileSize = reader.Read(); 84 | } 85 | else 86 | { 87 | var defaultChunk = new FChunkInfo 88 | { 89 | WindowSize = 1048576 90 | }; 91 | chunksSpan.Fill(defaultChunk); 92 | } 93 | 94 | reader.Position = startPos + dataSize; 95 | return chunks; 96 | } 97 | 98 | [SuppressMessage("ReSharper", "UseSymbolAlias")] 99 | internal async Task ReadDataAsIsAsync(byte[] destination, FBuildPatchAppManifest manifest, CancellationToken cancellationToken = default) 100 | { 101 | var fileSize = 0; 102 | var shouldCache = manifest.Options.ChunkCacheDirectory is not null; 103 | string? cachePath = null; 104 | 105 | if (CachePath is not null) 106 | { 107 | using var fileHandle = File.OpenHandle(CachePath); 108 | fileSize = (int)RandomAccess.GetLength(fileHandle); 109 | await RandomAccess.ReadAsync(fileHandle, destination.AsMemory(0, fileSize), 0, cancellationToken).ConfigureAwait(false); 110 | } 111 | else 112 | { 113 | using var _ = await manifest.ChunksLocker.LockAsync(Guid, cancellationToken).ConfigureAwait(false); 114 | 115 | if (CachePath is not null) 116 | { 117 | using var fileHandle = File.OpenHandle(CachePath); 118 | fileSize = (int)RandomAccess.GetLength(fileHandle); 119 | await RandomAccess.ReadAsync(fileHandle, destination.AsMemory(0, fileSize), 0, cancellationToken).ConfigureAwait(false); 120 | } 121 | else if (shouldCache) 122 | { 123 | cachePath = Path.Combine(manifest.Options.ChunkCacheDirectory!, $"v2_{Hash:X16}_{Guid}.chunk"); 124 | if (File.Exists(cachePath)) 125 | { 126 | CachePath = cachePath; 127 | using var fileHandle = File.OpenHandle(CachePath); 128 | fileSize = (int)RandomAccess.GetLength(fileHandle); 129 | await RandomAccess.ReadAsync(fileHandle, destination.AsMemory(0, fileSize), 0, cancellationToken).ConfigureAwait(false); 130 | } 131 | } 132 | 133 | if (fileSize == 0) 134 | { 135 | var uri = GetUri(manifest); 136 | var destMs = new MemoryStream(destination, 0, destination.Length, true); 137 | using var res = await manifest.Options.Client!.GetAsync(uri, cancellationToken).ConfigureAwait(false); 138 | EnsureSuccessStatusCode(res, uri); 139 | await res.Content.CopyToAsync(destMs, cancellationToken).ConfigureAwait(false); 140 | fileSize = (int)destMs.Position; 141 | 142 | if (shouldCache) 143 | { 144 | using (var fileHandle = File.OpenHandle(cachePath!, FileMode.Create, FileAccess.Write, FileShare.None, FileOptions.None, fileSize)) 145 | { 146 | await RandomAccess.WriteAsync(fileHandle, new ReadOnlyMemory(destination, 0, fileSize), 0, cancellationToken).ConfigureAwait(false); 147 | RandomAccess.FlushToDisk(fileHandle); 148 | } 149 | CachePath = cachePath; 150 | } 151 | } 152 | } 153 | 154 | var header = FChunkHeader.Parse(new ManifestData(destination, 0, fileSize)); 155 | 156 | if (header.StoredAs == EChunkStorageFlags.None) 157 | { 158 | Unsafe.CopyBlockUnaligned(ref destination[0], ref destination[header.HeaderSize], (uint)header.DataSizeCompressed); 159 | return header.DataSizeCompressed; 160 | } 161 | 162 | if (header.StoredAs.HasFlag(EChunkStorageFlags.Encrypted)) 163 | throw new NotSupportedException("Encrypted chunks are not supported"); 164 | if (!header.StoredAs.HasFlag(EChunkStorageFlags.Compressed)) 165 | throw new UnreachableException("Unknown/new chunk ChunkStorageFlag"); 166 | if (manifest.Options.Decompressor is null) 167 | throw new InvalidOperationException("Data is compressed and decompressor delegate was null"); 168 | 169 | // cant uncompress in-place 170 | var poolBuffer = ArrayPool.Shared.Rent(header.DataSizeCompressed); 171 | 172 | try 173 | { 174 | Unsafe.CopyBlockUnaligned(ref poolBuffer[0], ref destination[header.HeaderSize], (uint)header.DataSizeCompressed); 175 | 176 | var result = manifest.Options.Decompressor.Invoke( 177 | manifest.Options.DecompressorState, 178 | poolBuffer, 0, header.DataSizeCompressed, 179 | destination, 0, header.DataSizeUncompressed); 180 | if (!result) 181 | throw new FileLoadException("Failed to uncompress data"); 182 | } 183 | finally 184 | { 185 | ArrayPool.Shared.Return(poolBuffer); 186 | } 187 | 188 | return header.DataSizeUncompressed; 189 | } 190 | 191 | [SuppressMessage("ReSharper", "UseSymbolAlias")] 192 | internal async Task ReadDataAsync(byte[] buffer, int offset, int count, int chunkPartOffset, FBuildPatchAppManifest manifest, CancellationToken cancellationToken = default) 193 | { 194 | if (CachePath is not null) 195 | { 196 | using var fileHandle = File.OpenHandle(CachePath); 197 | return await RandomAccess.ReadAsync(fileHandle, buffer.AsMemory(offset, count), chunkPartOffset, cancellationToken).ConfigureAwait(false); 198 | } 199 | 200 | using var _ = await manifest.ChunksLocker.LockAsync(Guid, cancellationToken).ConfigureAwait(false); 201 | 202 | if (CachePath is not null) 203 | { 204 | using var fileHandle = File.OpenHandle(CachePath); 205 | return await RandomAccess.ReadAsync(fileHandle, buffer.AsMemory(offset, count), chunkPartOffset, cancellationToken).ConfigureAwait(false); 206 | } 207 | 208 | var shouldCache = manifest.Options.ChunkCacheDirectory is not null; 209 | string? cachePath = null; 210 | 211 | if (shouldCache) 212 | { 213 | cachePath = Path.Combine(manifest.Options.ChunkCacheDirectory!, $"{Hash:X16}_{Guid}.chunk"); 214 | if (File.Exists(cachePath)) 215 | { 216 | CachePath = cachePath; 217 | using var fileHandle = File.OpenHandle(CachePath); 218 | return await RandomAccess.ReadAsync(fileHandle, buffer.AsMemory(offset, count), chunkPartOffset, cancellationToken).ConfigureAwait(false); 219 | } 220 | } 221 | 222 | byte[]? poolBuffer = null; 223 | byte[]? uncompressPoolBuffer = null; 224 | 225 | try 226 | { 227 | var uri = GetUri(manifest); 228 | using var res = await manifest.Options.Client!.GetAsync(uri, cancellationToken).ConfigureAwait(false); 229 | EnsureSuccessStatusCode(res, uri); 230 | var poolBufferSize = res.Content.Headers.ContentLength ?? manifest.Options.ChunkDownloadBufferSize; 231 | poolBuffer = ArrayPool.Shared.Rent((int)poolBufferSize); 232 | var destMs = new MemoryStream(poolBuffer, 0, poolBuffer.Length, true, true); 233 | await res.Content.CopyToAsync(destMs, cancellationToken).ConfigureAwait(false); 234 | var responseSize = (int)destMs.Length; 235 | 236 | var header = FChunkHeader.Parse(new ManifestData(poolBuffer, 0, responseSize)); 237 | 238 | if (header.StoredAs == EChunkStorageFlags.None) 239 | { 240 | Unsafe.CopyBlockUnaligned(ref buffer[offset], ref poolBuffer[header.HeaderSize + chunkPartOffset], (uint)count); 241 | if (!shouldCache) 242 | return count; 243 | using (var fileHandle = File.OpenHandle(cachePath!, FileMode.Create, FileAccess.Write, FileShare.None, FileOptions.None, header.DataSizeCompressed)) 244 | { 245 | await RandomAccess.WriteAsync(fileHandle, new ReadOnlyMemory(poolBuffer, header.HeaderSize, header.DataSizeCompressed), 0, cancellationToken).ConfigureAwait(false); 246 | RandomAccess.FlushToDisk(fileHandle); 247 | } 248 | CachePath = cachePath; 249 | return count; 250 | } 251 | 252 | if (header.StoredAs.HasFlag(EChunkStorageFlags.Encrypted)) 253 | throw new NotSupportedException("Encrypted chunks are not supported"); 254 | if (!header.StoredAs.HasFlag(EChunkStorageFlags.Compressed)) 255 | throw new UnreachableException("Unknown/new chunk ChunkStorageFlag"); 256 | if (manifest.Options.Decompressor is null) 257 | throw new InvalidOperationException("Data is compressed and decompressor delegate was null"); 258 | 259 | // cant seek for uncompression 260 | uncompressPoolBuffer = ArrayPool.Shared.Rent(header.DataSizeUncompressed); 261 | 262 | var result = manifest.Options.Decompressor.Invoke( 263 | manifest.Options.DecompressorState, 264 | poolBuffer, header.HeaderSize, header.DataSizeCompressed, 265 | uncompressPoolBuffer, 0, header.DataSizeUncompressed); 266 | if (!result) 267 | throw new FileLoadException("Failed to uncompress data"); 268 | 269 | Unsafe.CopyBlockUnaligned(ref buffer[offset], ref uncompressPoolBuffer[chunkPartOffset], (uint)count); 270 | if (!shouldCache) 271 | return count; 272 | using (var fileHandle = File.OpenHandle(cachePath!, FileMode.Create, FileAccess.Write, FileShare.None, FileOptions.None, header.DataSizeUncompressed)) 273 | { 274 | await RandomAccess.WriteAsync(fileHandle, new ReadOnlyMemory(uncompressPoolBuffer, 0, header.DataSizeUncompressed), 0, cancellationToken).ConfigureAwait(false); 275 | RandomAccess.FlushToDisk(fileHandle); 276 | } 277 | CachePath = cachePath; 278 | return count; 279 | } 280 | finally 281 | { 282 | if (poolBuffer is not null) 283 | ArrayPool.Shared.Return(poolBuffer); 284 | if (uncompressPoolBuffer is not null) 285 | ArrayPool.Shared.Return(uncompressPoolBuffer); 286 | } 287 | } 288 | 289 | [MethodImpl(MethodImplOptions.AggressiveInlining)] 290 | internal static void EnsureSuccessStatusCode(HttpResponseMessage res, Uri uri) 291 | { 292 | try 293 | { 294 | res.EnsureSuccessStatusCode(); 295 | } 296 | catch (HttpRequestException ex) 297 | { 298 | ex.Data.Add("Uri", uri); 299 | ex.Data.Add("Headers", res.Headers); 300 | throw; 301 | } 302 | } 303 | 304 | // ReSharper disable once UseSymbolAlias 305 | internal static void Test_Zlibng(byte[] uncompressPoolBuffer, byte[] chunkBuffer, object zlibng, ManifestParseOptions.DecompressDelegate zlibngUncompress) 306 | { 307 | var header = FChunkHeader.Parse(chunkBuffer); 308 | 309 | var result = zlibngUncompress( 310 | zlibng, 311 | chunkBuffer, header.HeaderSize, header.DataSizeCompressed, 312 | uncompressPoolBuffer, 0, header.DataSizeUncompressed); 313 | 314 | if (!result) 315 | throw new FileLoadException("Failed to uncompress chunk data"); 316 | } 317 | 318 | // ReSharper disable once UseSymbolAlias 319 | internal static void Test_ZlibStream(byte[] uncompressPoolBuffer, byte[] chunkBuffer) 320 | { 321 | var header = FChunkHeader.Parse(chunkBuffer); 322 | 323 | var result = ManifestZlibStreamDecompressor.Decompress( 324 | null, 325 | chunkBuffer, header.HeaderSize, header.DataSizeCompressed, 326 | uncompressPoolBuffer, 0, header.DataSizeUncompressed); 327 | 328 | if (!result) 329 | throw new FileLoadException("Failed to uncompress chunk data"); 330 | } 331 | } 332 | -------------------------------------------------------------------------------- /src/EpicManifestParser/UE/FChunkPart.cs: -------------------------------------------------------------------------------- 1 | namespace EpicManifestParser.UE; 2 | 3 | /// 4 | /// UE FChunkPart struct 5 | /// 6 | public readonly struct FChunkPart 7 | { 8 | /// 9 | /// The GUID of the chunk containing this part. 10 | /// 11 | public FGuid Guid { get; } 12 | /// 13 | /// The offset of the first byte into the chunk. 14 | /// 15 | public uint32 Offset { get; } 16 | /// 17 | /// The size of this part. 18 | /// 19 | public uint32 Size { get; } 20 | 21 | internal FChunkPart(FGuid guid, uint32 offset, uint32 size) 22 | { 23 | Guid = guid; 24 | Offset = offset; 25 | Size = size; 26 | } 27 | 28 | internal FChunkPart(ref ManifestReader reader) 29 | { 30 | var startPos = reader.Position; 31 | var dataSize = reader.Read(); 32 | 33 | Guid = reader.Read(); 34 | Offset = reader.Read(); 35 | Size = reader.Read(); 36 | 37 | reader.Position = startPos + dataSize; 38 | } 39 | 40 | #if NET9_0_OR_GREATER 41 | internal static FChunkPart Read(ref ManifestReader reader) => new(ref reader); 42 | #else 43 | internal static FChunkPart Read(GenericReader.IGenericReader genericReader) 44 | { 45 | var reader = (ManifestReader)genericReader; 46 | return new FChunkPart(ref reader); 47 | } 48 | #endif 49 | } 50 | -------------------------------------------------------------------------------- /src/EpicManifestParser/UE/FCustomField.cs: -------------------------------------------------------------------------------- 1 | namespace EpicManifestParser.UE; 2 | 3 | /// 4 | /// UE FCustomField struct 5 | /// 6 | public sealed class FCustomField 7 | { 8 | /// 9 | /// Field name 10 | /// 11 | public string Name { get; internal set; } = ""; 12 | /// 13 | /// Field value 14 | /// 15 | public string Value { get; internal set; } = ""; 16 | 17 | internal FCustomField() { } 18 | 19 | internal static FCustomField[] ReadCustomFields(ref ManifestReader reader) 20 | { 21 | var startPos = reader.Position; 22 | var dataSize = reader.Read(); 23 | var dataVersion = reader.Read(); 24 | var elementCount = reader.Read(); 25 | 26 | var fields = new FCustomField[elementCount]; 27 | var fieldsSpan = fields.AsSpan(); 28 | 29 | if (dataVersion >= EChunkDataListVersion.Original) 30 | { 31 | for (var i = 0; i < elementCount; i++) 32 | { 33 | var field = new FCustomField(); 34 | field.Name = reader.ReadFString(); 35 | fieldsSpan[i] = field; 36 | } 37 | for (var i = 0; i < elementCount; i++) 38 | fieldsSpan[i].Value = reader.ReadFString(); 39 | } 40 | else 41 | { 42 | var defaultField = new FCustomField(); 43 | fieldsSpan.Fill(defaultField); 44 | } 45 | 46 | reader.Position = startPos + dataSize; 47 | return fields; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/EpicManifestParser/UE/FFileManifest.cs: -------------------------------------------------------------------------------- 1 | namespace EpicManifestParser.UE; 2 | 3 | /// 4 | /// UE FFileManifest struct 5 | /// 6 | public sealed class FFileManifest : IComparable, IComparable 7 | { 8 | /// 9 | /// The build relative filename. 10 | /// 11 | public string FileName { get; internal set; } = ""; 12 | /// 13 | /// Whether this is a symlink to another file. 14 | /// 15 | public string SymlinkTarget { get; internal set; } = ""; 16 | /// 17 | /// The file SHA1. 18 | /// 19 | public FSHAHash FileHash { get; internal set; } 20 | /// 21 | /// The flags for this file. 22 | /// 23 | public EFileMetaFlags FileMetaFlags { get; internal set; } 24 | /// 25 | /// The install tags for this file. 26 | /// 27 | public IReadOnlyList InstallTags { get; internal set; } = []; 28 | /// 29 | /// The list of chunk parts to stitch. 30 | /// 31 | public IReadOnlyList ChunkParts => ChunkPartsArray; 32 | internal FChunkPart[] ChunkPartsArray = []; 33 | /// 34 | /// The size of this file. 35 | /// 36 | public int64 FileSize { get; internal set; } 37 | /// 38 | /// The mime type. 39 | /// 40 | public string MimeType { get; internal set; } = ""; 41 | 42 | internal FBuildPatchAppManifest Manifest { get; set; } = null!; 43 | 44 | internal FFileManifest() { } 45 | 46 | internal static FFileManifest[] ReadFileDataList(ref ManifestReader reader, FBuildPatchAppManifest manifest) 47 | { 48 | var startPos = reader.Position; 49 | var dataSize = reader.Read(); 50 | var dataVersion = reader.Read(); 51 | var elementCount = reader.Read(); 52 | 53 | var files = new FFileManifest[elementCount]; 54 | var filesSpan = files.AsSpan(); 55 | 56 | if (dataVersion >= EFileManifestListVersion.Original) 57 | { 58 | for (var i = 0; i < elementCount; i++) 59 | { 60 | var file = new FFileManifest(); 61 | file.FileName = reader.ReadFString(); 62 | filesSpan[i] = file; 63 | } 64 | for (var i = 0; i < elementCount; i++) 65 | filesSpan[i].SymlinkTarget = reader.ReadFString(); 66 | for (var i = 0; i < elementCount; i++) 67 | filesSpan[i].FileHash = reader.Read(); 68 | for (var i = 0; i < elementCount; i++) 69 | filesSpan[i].FileMetaFlags = reader.Read(); 70 | for (var i = 0; i < elementCount; i++) 71 | filesSpan[i].InstallTags = reader.ReadFStringArray(); 72 | for (var i = 0; i < elementCount; i++) 73 | filesSpan[i].ChunkPartsArray = reader.ReadArray(FChunkPart.Read); 74 | 75 | // not to be found in UE, maybe fn specific? 76 | if (dataVersion >= (EFileManifestListVersion)2) 77 | { 78 | for (var i = 0; i < elementCount; i++) // TArray 79 | { 80 | var a = reader.Read(); 81 | reader.Position += a * 16; 82 | } 83 | for (var i = 0; i < elementCount; i++) 84 | filesSpan[i]!.MimeType = reader.ReadFString(); 85 | for (var i = 0; i < elementCount; i++) // Unknown 86 | reader.Position += 32; 87 | } 88 | 89 | // FileDataList.OnPostLoad(); 90 | { 91 | Array.Sort(files); 92 | for (var i = 0; i < elementCount; i++) 93 | { 94 | var file = filesSpan[i]; 95 | file.Manifest = manifest; 96 | foreach (var chunkPart in file.ChunkPartsArray.AsSpan()) 97 | { 98 | file.FileSize += chunkPart.Size; 99 | } 100 | } 101 | } 102 | } 103 | else 104 | { 105 | var defaultFile = new FFileManifest(); 106 | filesSpan.Fill(defaultFile); 107 | } 108 | 109 | reader.Position = startPos + dataSize; 110 | return files; 111 | } 112 | 113 | /// 114 | /// Creates a read-only stream to read filedata from. 115 | /// 116 | public FFileManifestStream GetStream() => new(this, Manifest.Options.CacheChunksAsIs); 117 | 118 | /// 119 | /// Creates a read-only stream to read filedata from. 120 | /// 121 | /// Whether or not to cache the chunks 1:1 as they were downloaded. 122 | public FFileManifestStream GetStream(bool cacheAsIs) => new(this, cacheAsIs); 123 | 124 | 125 | /// 126 | public int CompareTo(FFileManifest? other) 127 | { 128 | if (ReferenceEquals(this, other)) return 0; 129 | if (ReferenceEquals(null, other)) return 1; 130 | return string.Compare(FileName, other.FileName, StringComparison.Ordinal); 131 | } 132 | 133 | /// 134 | public int CompareTo(object? obj) 135 | { 136 | if (ReferenceEquals(null, obj)) return 1; 137 | if (ReferenceEquals(this, obj)) return 0; 138 | return obj is FFileManifest other ? CompareTo(other) : throw new ArgumentException($"Object must be of type {nameof(FFileManifest)}"); 139 | } 140 | 141 | /// 142 | public static bool operator <(FFileManifest? left, FFileManifest? right) 143 | { 144 | return Comparer.Default.Compare(left, right) < 0; 145 | } 146 | 147 | /// 148 | public static bool operator >(FFileManifest? left, FFileManifest? right) 149 | { 150 | return Comparer.Default.Compare(left, right) > 0; 151 | } 152 | 153 | /// 154 | public static bool operator <=(FFileManifest? left, FFileManifest? right) 155 | { 156 | return Comparer.Default.Compare(left, right) <= 0; 157 | } 158 | 159 | /// 160 | public static bool operator >=(FFileManifest? left, FFileManifest? right) 161 | { 162 | return Comparer.Default.Compare(left, right) >= 0; 163 | } 164 | } 165 | -------------------------------------------------------------------------------- /src/EpicManifestParser/UE/FGuid.cs: -------------------------------------------------------------------------------- 1 | using System.Globalization; 2 | using System.Runtime.CompilerServices; 3 | using System.Runtime.InteropServices; 4 | using System.Security.Cryptography; 5 | using System.Text.Json; 6 | using System.Text.Json.Serialization; 7 | using System.Text.Unicode; 8 | 9 | namespace EpicManifestParser.UE; 10 | 11 | /// 12 | /// UE FGuid struct 13 | /// 14 | [StructLayout(LayoutKind.Sequential, Pack = 1)] 15 | public readonly struct FGuid : IEquatable, ISpanFormattable, IUtf8SpanFormattable 16 | { 17 | /// 18 | /// The size of the FGuid/struct. 19 | /// 20 | public const int Size = sizeof(uint32) * 4; 21 | 22 | private readonly uint32 A; 23 | private readonly uint32 B; 24 | private readonly uint32 C; 25 | private readonly uint32 D; 26 | 27 | /// 28 | /// Creates a hex string of the guid. 29 | /// 30 | public string GetHexString(bool upperCase = true) => upperCase 31 | ? $"{A:X8}{B:X8}{C:X8}{D:X8}" 32 | : $"{A:x8}{B:x8}{C:x8}{D:x8}"; 33 | 34 | /// 35 | /// Creates a string of the guid. 36 | /// 37 | public string GetGuidString() => $"{A:x8}-{B >> 16:x4}-{B & 0xffff:x4}-{C >> 16:x4}-{C & 0xffff:x4}{D:x8}"; 38 | 39 | /// 40 | /// Creates a FGuid from values. 41 | /// 42 | /// A value. 43 | /// B value. 44 | /// C value. 45 | /// D value. 46 | public FGuid(uint32 a, uint32 b, uint32 c, uint32 d) 47 | { 48 | A = a; 49 | B = b; 50 | C = c; 51 | D = d; 52 | } 53 | 54 | /// 55 | /// Parses a FGuid from a string. 56 | /// 57 | /// The FGuid string. 58 | public FGuid(string guid) : this(guid.AsSpan()) { } 59 | 60 | /// 61 | /// Parses a FGuid from a string. 62 | /// 63 | /// The FGuid string. 64 | public FGuid(ReadOnlySpan guid) 65 | { 66 | if (guid.Length != 32) 67 | throw new ArgumentOutOfRangeException(nameof(guid), "guid has to be 32 characters long, other parsing is not implemented"); 68 | A = uint32.Parse(guid[ .. 8], NumberStyles.AllowHexSpecifier); 69 | B = uint32.Parse(guid[8 ..16], NumberStyles.AllowHexSpecifier); 70 | C = uint32.Parse(guid[16..24], NumberStyles.AllowHexSpecifier); 71 | D = uint32.Parse(guid[24..32], NumberStyles.AllowHexSpecifier); 72 | } 73 | 74 | /// 75 | /// Parses a FGuid from a UTF8 string. 76 | /// 77 | /// The UTF8 FGuid string. 78 | public FGuid(ReadOnlySpan utf8Guid) 79 | { 80 | if (utf8Guid.Length != 32) 81 | throw new ArgumentOutOfRangeException(nameof(utf8Guid), "guid has to be 32 characters long, other parsing is not implemented"); 82 | A = uint32.Parse(utf8Guid[ .. 8], NumberStyles.AllowHexSpecifier); 83 | B = uint32.Parse(utf8Guid[8 ..16], NumberStyles.AllowHexSpecifier); 84 | C = uint32.Parse(utf8Guid[16..24], NumberStyles.AllowHexSpecifier); 85 | D = uint32.Parse(utf8Guid[24..32], NumberStyles.AllowHexSpecifier); 86 | } 87 | 88 | /// 89 | /// Creates a random FGuid. 90 | /// 91 | public static FGuid Random() 92 | { 93 | Unsafe.SkipInit(out FGuid result); 94 | RandomNumberGenerator.Fill(result.GetSpan()); 95 | return result; 96 | } 97 | 98 | /// 99 | /// Checks for validity. 100 | /// 101 | public bool IsValid() => (A | B | C | D) != 0; 102 | 103 | /// 104 | /// Gets the data of the guid. 105 | /// 106 | public ReadOnlySpan AsSpan() => 107 | MemoryMarshal.CreateReadOnlySpan(ref Unsafe.As(ref Unsafe.AsRef(in A)), Size); 108 | 109 | /// 110 | /// Gets the values of the guid. 111 | /// 112 | public ReadOnlySpan AsIntSpan() => 113 | MemoryMarshal.CreateReadOnlySpan(ref Unsafe.AsRef(in A), 4); 114 | 115 | internal Span GetSpan() => 116 | MemoryMarshal.CreateSpan(ref Unsafe.As(ref Unsafe.AsRef(in A)), Size); 117 | 118 | /// 119 | public bool Equals(FGuid other) 120 | { 121 | return A == other.A && B == other.B && C == other.C && D == other.D; 122 | } 123 | 124 | /// 125 | public override bool Equals(object? obj) 126 | { 127 | return obj is FGuid other && Equals(other); 128 | } 129 | 130 | /// 131 | public override int GetHashCode() 132 | { 133 | return HashCode.Combine(A, B, C, D); 134 | } 135 | 136 | /// 137 | public static bool operator ==(FGuid left, FGuid right) 138 | { 139 | return left.Equals(right); 140 | } 141 | 142 | /// 143 | public static bool operator !=(FGuid left, FGuid right) 144 | { 145 | return !left.Equals(right); 146 | } 147 | 148 | /// 149 | public override string ToString() => GetHexString(); 150 | 151 | /// 152 | public string ToString(string? format, IFormatProvider? formatProvider) 153 | { 154 | FormattableString formattable = $"{A:X8}{B:X8}{C:X8}{D:X8}"; 155 | return formattable.ToString(formatProvider); 156 | } 157 | 158 | /// 159 | public bool TryFormat(Span destination, out int charsWritten, ReadOnlySpan format, IFormatProvider? provider) 160 | { 161 | return destination.TryWrite(provider, $"{A:X8}{B:X8}{C:X8}{D:X8}", out charsWritten); 162 | } 163 | 164 | /// 165 | public bool TryFormat(Span destination, out int bytesWritten, ReadOnlySpan format, IFormatProvider? provider) 166 | { 167 | return Utf8.TryWrite(destination, provider, $"{A:X8}{B:X8}{C:X8}{D:X8}", out bytesWritten); 168 | } 169 | } 170 | 171 | /// 172 | /// Converts from and to JSON. 173 | /// 174 | public sealed class FGuidConverter : JsonConverter 175 | { 176 | /// 177 | public override FGuid Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) 178 | { 179 | if (reader.ValueSpan.IsEmpty) return default; 180 | return new FGuid(reader.ValueSpan); 181 | } 182 | 183 | /// 184 | public override void Write(Utf8JsonWriter writer, FGuid value, JsonSerializerOptions options) 185 | { 186 | Span guidUtf8 = stackalloc byte[FGuid.Size * 2]; 187 | 188 | if (value.TryFormat(guidUtf8, out _, default, null)) 189 | { 190 | writer.WriteStringValue(guidUtf8); 191 | return; 192 | } 193 | 194 | writer.WriteStringValue(string.Empty); 195 | } 196 | } 197 | -------------------------------------------------------------------------------- /src/EpicManifestParser/UE/FManifestHeader.cs: -------------------------------------------------------------------------------- 1 | namespace EpicManifestParser.UE; 2 | 3 | internal class FManifestHeader 4 | { 5 | public const uint32 Magic = 0x44BEC00C; 6 | 7 | /// 8 | /// The version of this header and manifest data format, driven by the feature level. 9 | /// 10 | public readonly EFeatureLevel Version; 11 | /// 12 | /// The size of this header. 13 | /// 14 | public readonly int32 HeaderSize; 15 | /// 16 | /// The size of this data compressed. 17 | /// 18 | public readonly int32 DataSizeCompressed; 19 | /// 20 | /// The size of this data uncompressed. 21 | /// 22 | public readonly int32 DataSizeUncompressed; 23 | /// 24 | /// How the chunk data is stored. 25 | /// 26 | public readonly EManifestStorageFlags StoredAs; 27 | /// 28 | /// The SHA1 hash for the manifest data that follows. 29 | /// 30 | public readonly FSHAHash SHAHash; 31 | 32 | internal FManifestHeader(ref ManifestReader reader) 33 | { 34 | var magic = reader.Read(); 35 | if (magic != Magic) 36 | throw new FileLoadException($"Invalid manifest header magic: 0x{magic:X}"); 37 | 38 | HeaderSize = reader.Read(); 39 | DataSizeUncompressed = reader.Read(); 40 | DataSizeCompressed = reader.Read(); 41 | SHAHash = reader.Read(); 42 | StoredAs = reader.Read(); 43 | Version = HeaderSize > ManifestHeaderVersionSizes[(int32)EFeatureLevel.Original] 44 | ? reader.Read() 45 | : EFeatureLevel.StoredAsCompressedUClass; 46 | 47 | reader.SetPosition(HeaderSize); 48 | } 49 | 50 | // The constant minimum sizes for each version of a header struct. Must be updated. 51 | // If new member variables are added the version MUST be bumped and handled properly here, 52 | // and these values must never change. 53 | private static readonly uint32[] ManifestHeaderVersionSizes = 54 | [ 55 | // EFeatureLevel::Original is 37B (32b Magic, 32b HeaderSize, 32b DataSizeUncompressed, 32b DataSizeCompressed, 160b SHA1, 8b StoredAs) 56 | // This remained the same all up to including EFeatureLevel::StoresPrerequisiteIds. 57 | 37, 37, 37, 37, 37, 37, 37, 37, 37, 37, 37, 37, 37, 37, 58 | // EFeatureLevel::StoredAsBinaryData is 41B, (296b Original, 32b Version). 59 | // This remained the same all up to including EFeatureLevel::UsesBuildTimeGeneratedBuildId. 60 | 41, 41, 41, 41, 41, 61 | // Undocumented, but the latest version is still 41B 62 | 41, 41, 41 63 | ]; 64 | } 65 | -------------------------------------------------------------------------------- /src/EpicManifestParser/UE/FManifestMeta.cs: -------------------------------------------------------------------------------- 1 | namespace EpicManifestParser.UE; 2 | 3 | /// 4 | /// UE FManifestMeta struct 5 | /// 6 | public sealed class FManifestMeta 7 | { 8 | /// 9 | /// The feature level support this build was created with, regardless of the serialised format. 10 | /// 11 | public EFeatureLevel FeatureLevel { get; internal set; } = EFeatureLevel.Invalid; 12 | /// 13 | /// Whether this is a legacy 'nochunks' build. 14 | /// 15 | public bool bIsFileData { get; internal set; } 16 | /// 17 | /// The app id provided at generation. 18 | /// 19 | public uint32 AppID { get; internal set; } 20 | /// 21 | /// The app name string provided at generation. 22 | /// 23 | public string AppName { get; internal set; } = ""; 24 | /// 25 | /// The build version string provided at generation. 26 | /// 27 | public string BuildVersion { get; internal set; } = ""; 28 | /// 29 | /// The file in this manifest designated the application executable of the build. 30 | /// 31 | public string LaunchExe { get; internal set; } = ""; 32 | /// 33 | /// The command line required when launching the application executable. 34 | /// 35 | public string LaunchCommand { get; internal set; } = ""; 36 | /// 37 | /// The set of prerequisite ids for dependencies that this build's prerequisite installer will apply. 38 | /// 39 | public string[] PrereqIds { get; internal set; } = []; 40 | /// 41 | /// A display string for the prerequisite provided at generation. 42 | /// 43 | public string PrereqName { get; internal set; } = ""; 44 | /// 45 | /// The file in this manifest designated the launch executable of the prerequisite installer. 46 | /// 47 | public string PrereqPath { get; internal set; } = ""; 48 | /// 49 | /// The command line required when launching the prerequisite installer. 50 | /// 51 | public string PrereqArgs { get; internal set; } = ""; 52 | /// 53 | /// A unique build id generated at original chunking time to identify an exact build. 54 | /// 55 | public string BuildId { get; internal set; } = ""; 56 | 57 | /// 58 | /// Undocumented 59 | /// 60 | public string UninstallExe { get; internal set; } = ""; 61 | /// 62 | /// Undocumented 63 | /// 64 | public string UninstallCommand { get; internal set; } = ""; 65 | 66 | internal FManifestMeta() { } 67 | internal FManifestMeta(ref ManifestReader reader) 68 | { 69 | var startPos = reader.Position; 70 | var dataSize = reader.Read(); 71 | var dataVersion = reader.Read(); 72 | 73 | if (dataVersion >= EManifestMetaVersion.Original) 74 | { 75 | FeatureLevel = reader.Read(); 76 | bIsFileData = reader.Read() == 1; 77 | AppID = reader.Read(); 78 | AppName = reader.ReadFString(); 79 | BuildVersion = reader.ReadFString(); 80 | LaunchExe = reader.ReadFString(); 81 | LaunchCommand = reader.ReadFString(); 82 | PrereqIds = reader.ReadFStringArray(); 83 | PrereqName = reader.ReadFString(); 84 | PrereqPath = reader.ReadFString(); 85 | PrereqArgs = reader.ReadFString(); 86 | } 87 | 88 | BuildId = dataVersion >= EManifestMetaVersion.SerialisesBuildId 89 | ? reader.ReadFString() 90 | : GetBackwardsCompatibleBuildId(this); 91 | 92 | // not to be found in UE, maybe fn specific? 93 | if (FeatureLevel > EFeatureLevel.UsesBuildTimeGeneratedBuildId) 94 | { 95 | UninstallExe = reader.ReadFString(); 96 | UninstallCommand = reader.ReadFString(); 97 | } 98 | 99 | reader.Position = startPos + dataSize; 100 | } 101 | 102 | internal static string GetBackwardsCompatibleBuildId(in FManifestMeta meta) 103 | { 104 | // TODO: https://github.com/EpicGames/UnrealEngine/blob/a937fa584fbd6d69b7cf9c527907040c9dbf54fc/Engine/Source/Runtime/Online/BuildPatchServices/Private/BuildPatchUtil.cpp#L166 105 | return ""; 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /src/EpicManifestParser/UE/FSHAHash.cs: -------------------------------------------------------------------------------- 1 | using System.Globalization; 2 | using System.Runtime.CompilerServices; 3 | using System.Runtime.InteropServices; 4 | using System.Security.Cryptography; 5 | using System.Text.Json; 6 | using System.Text.Json.Serialization; 7 | 8 | using OffiUtils; 9 | 10 | namespace EpicManifestParser.UE; 11 | // ReSharper disable InconsistentNaming 12 | // ReSharper disable UseSymbolAlias 13 | 14 | /// 15 | /// UE FSHAHash struct 16 | /// 17 | [StructLayout(LayoutKind.Sequential, Pack = 1, Size = Size)] 18 | public readonly struct FSHAHash : IEquatable, ISpanFormattable 19 | { 20 | /// 21 | /// The size of the hash/struct. 22 | /// 23 | public const int Size = 20; 24 | 25 | private readonly long Hash_00_07; 26 | private readonly long Hash_08_15; 27 | private readonly int Hash_16_19; 28 | 29 | /// 30 | public bool Equals(FSHAHash other) 31 | { 32 | return Hash_00_07 == other.Hash_00_07 && Hash_08_15 == other.Hash_08_15 && Hash_16_19 == other.Hash_16_19; 33 | } 34 | 35 | /// 36 | public override bool Equals(object? obj) 37 | { 38 | return obj is FSHAHash other && Equals(other); 39 | } 40 | 41 | /// 42 | public override int GetHashCode() 43 | { 44 | return HashCode.Combine(Hash_00_07, Hash_08_15, Hash_16_19); 45 | } 46 | 47 | /// 48 | public static bool operator ==(FSHAHash left, FSHAHash right) 49 | { 50 | return left.Equals(right); 51 | } 52 | 53 | /// 54 | public static bool operator !=(FSHAHash left, FSHAHash right) 55 | { 56 | return !left.Equals(right); 57 | } 58 | 59 | /// 60 | /// Gets the data of the hash. 61 | /// 62 | /// Hash data in a read-only span 63 | public ReadOnlySpan AsSpan() => 64 | MemoryMarshal.CreateReadOnlySpan(ref Unsafe.As(ref Unsafe.AsRef(in Hash_00_07)), Size); 65 | 66 | internal Span GetSpan() => 67 | MemoryMarshal.CreateSpan(ref Unsafe.As(ref Unsafe.AsRef(in Hash_00_07)), Size); 68 | 69 | /// 70 | /// Computes the hash of data using the SHA1 algorithm. 71 | /// 72 | /// The data to hash 73 | /// The computed hash 74 | public static FSHAHash Compute(ReadOnlySpan source) 75 | { 76 | Unsafe.SkipInit(out FSHAHash hash); 77 | SHA1.TryHashData(source, hash.GetSpan(), out _); 78 | return hash; 79 | } 80 | 81 | /// Returns a representation of the current instance. 82 | /// The value of this , represented as a series of uppercase hexadecimal digits. 83 | public override string ToString() => StringUtils.BytesToHexUpper(AsSpan()); 84 | 85 | /// Returns a representation of the current instance. 86 | /// Whether or not to return an uppercase string. 87 | /// The value of this , represented as a series of hexadecimal digits. 88 | public string ToString(bool upperCase) => upperCase 89 | ? StringUtils.BytesToHexUpper(AsSpan()) 90 | : StringUtils.BytesToHexLower(AsSpan()); 91 | 92 | /// Returns a representation of the current instance, according to the provided format specifier. 93 | /// A read-only span containing the character representing one of the following specifiers that indicates the exact format to use when interpreting input:
94 | /// "x" or "X".
95 | /// When is or empty, "X" is used. 96 | /// 97 | /// Unused, pass a null reference. 98 | /// The value of this , represented as a series of hexadecimal digits in the specified format. 99 | /// If an invalid format is used. 100 | public string ToString(string? format, IFormatProvider? formatProvider) 101 | { 102 | if (format is null || format.Length == 0 || format == "X") 103 | return StringUtils.BytesToHexUpper(AsSpan()); 104 | if (format == "x") 105 | return StringUtils.BytesToHexLower(AsSpan()); 106 | throw new FormatException("the provided format is not valid"); 107 | } 108 | 109 | /// 110 | /// Tries to format the current instance into the provided character span. 111 | /// 112 | /// The span in which to write the as a span of characters. 113 | /// When this method returns, contains the number of characters written into the span. 114 | /// A read-only span containing the character representing one of the following specifiers that indicates the exact format to use when interpreting input:
115 | /// "x" or "X".
116 | /// When is empty, "X" is used. 117 | /// 118 | /// Unused, pass a null reference. 119 | /// if the formatting was successful; otherwise, . 120 | /// If an invalid format is used. 121 | public bool TryFormat(Span destination, out int charsWritten, ReadOnlySpan format, IFormatProvider? provider) 122 | { 123 | if (format.IsEmpty || format is "X") 124 | return StringUtils.TryWriteBytesToHexUpper(AsSpan(), destination, out charsWritten); 125 | if (format is "x") 126 | return StringUtils.TryWriteBytesToHexLower(AsSpan(), destination, out charsWritten); 127 | throw new FormatException("the provided format is not valid"); 128 | } 129 | } 130 | 131 | /// 132 | /// Converts from and to JSON. 133 | /// 134 | public sealed class FSHAHashConverter : JsonConverter 135 | { 136 | /// 137 | public override FSHAHash Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) 138 | { 139 | Unsafe.SkipInit(out FSHAHash result); 140 | var resultSpan = result.GetSpan(); 141 | var span = reader.ValueSpan; 142 | 143 | for (var i = 0; i < resultSpan.Length; i++) 144 | { 145 | resultSpan[i] = byte.Parse(span.Slice(i * 2, 2), NumberStyles.AllowHexSpecifier); 146 | } 147 | 148 | return result; 149 | } 150 | 151 | /// 152 | public override void Write(Utf8JsonWriter writer, FSHAHash value, JsonSerializerOptions options) 153 | { 154 | Span hashUtf16 = stackalloc char[FSHAHash.Size * 2]; 155 | 156 | if (value.TryFormat(hashUtf16, out _, default, null)) 157 | { 158 | writer.WriteStringValue(hashUtf16); 159 | return; 160 | } 161 | 162 | writer.WriteStringValue(string.Empty); 163 | } 164 | } 165 | -------------------------------------------------------------------------------- /src/EpicManifestParser/UE/TypeAliases.cs: -------------------------------------------------------------------------------- 1 | global using int8 = System.SByte; 2 | global using uint8 = System.Byte; 3 | 4 | global using int16 = System.Int16; 5 | global using uint16 = System.UInt16; 6 | 7 | global using int32 = System.Int32; 8 | global using uint32 = System.UInt32; 9 | 10 | global using int64 = System.Int64; 11 | global using uint64 = System.UInt64; 12 | --------------------------------------------------------------------------------