├── .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 | [](https://github.com/NotOfficer/EpicManifestParser/releases/latest) [](https://www.nuget.org/packages/EpicManifestParser)  [](https://github.com/NotOfficer/EpicManifestParser/issues) [](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 |
--------------------------------------------------------------------------------