diff --git a/FModel/Creator/Bases/BaseSeason.cs b/FModel/Creator/Bases/BaseSeason.cs index 3c5b558b..675e59ba 100644 --- a/FModel/Creator/Bases/BaseSeason.cs +++ b/FModel/Creator/Bases/BaseSeason.cs @@ -136,12 +136,16 @@ namespace FModel.Creator.Bases int x = 20; foreach (var (index, reward) in BookXpSchedule) { - if (index == 0 || reward.Count <= 0) + if (index == 0 || reward.Count == 0) continue; c.DrawText(index.ToString(), new SKPoint(x + (defaultSize / 2), y - 5), paint); - rarity.Color = reward[0].TheReward.RarityBackgroundColors[0]; + var theReward = reward[0].TheReward; + if (theReward == null) + continue; + + rarity.Color = theReward.RarityBackgroundColors[0]; c.DrawRect(new SKRect(x, y, x + defaultSize, y + defaultSize), bg); c.DrawBitmap(reward[0].RewardIcon, new SKPoint(x, y), icon); var pathBottom = new SKPath { FillType = SKPathFillType.EvenOdd }; diff --git a/FModel/Grabber/Manifests/ValorantAPIManifest.cs b/FModel/Grabber/Manifests/ValorantAPIManifest.cs new file mode 100644 index 00000000..e9b02d57 --- /dev/null +++ b/FModel/Grabber/Manifests/ValorantAPIManifest.cs @@ -0,0 +1,356 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Net; +using System.Net.Http; +using System.Runtime.CompilerServices; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +using FModel.Utils; + +using PakReader; + +namespace FModel.Grabber.Manifests +{ + public class ValorantAPIManifest + { + private const string _url = "https://fmodel.fortnite-api.com/valorant/v1/manifest"; + + private readonly HttpClient _client; + private readonly DirectoryInfo _chunkDirectory; + + public readonly ulong Id; + public readonly Dictionary Chunks; + public readonly ValorantPak[] Paks; + + public ValorantAPIManifest(byte[] data, DirectoryInfo directoryInfo) : this(new MemoryStream(data, false), directoryInfo) { } + public ValorantAPIManifest(Stream stream, DirectoryInfo directoryInfo) : this(new BinaryReader(stream), directoryInfo) { } + public ValorantAPIManifest(BinaryReader reader, DirectoryInfo directoryInfo) + { + Id = reader.ReadUInt64(); + var chunks = reader.ReadInt32(); + Chunks = new Dictionary(chunks); + + for (var i = 0; i < chunks; i++) + { + var chunk = new ValorantChunk(reader); + Chunks.Add(chunk.Id, chunk); + } + + Paks = reader.ReadTArray(() => new ValorantPak(reader)); + + _client = new HttpClient(new HttpClientHandler + { + UseProxy = false, + UseCookies = false, + AutomaticDecompression = DecompressionMethods.All, + CheckCertificateRevocationList = false, + PreAuthenticate = false, + MaxConnectionsPerServer = 1337 + }); + _chunkDirectory = directoryInfo; + } + + public Stream GetPakStream(int index) + { + return new ValorantPakStream(this, index); + } + + public async Task PrefetchChunk(ValorantChunk chunk, CancellationToken cancellationToken) + { + var chunkPath = Path.Combine(_chunkDirectory.FullName, chunk.Id + ".valchunk"); + + if (File.Exists(chunkPath)) + { + return; + } + + using var request = new HttpRequestMessage(HttpMethod.Get, chunk.Url); + using var response = await _client.SendAsync(request, cancellationToken).ConfigureAwait(false); + + if (response.StatusCode == HttpStatusCode.OK) + { + var chunkBytes = await response.Content.ReadAsByteArrayAsync().ConfigureAwait(false); + await using var fs = new FileStream(chunkPath, FileMode.Create, FileAccess.Write, FileShare.Read); + await fs.WriteAsync(chunkBytes, 0, chunkBytes.Length, cancellationToken).ConfigureAwait(false); + } + #if DEBUG + else + { + var content = await response.Content.ReadAsStringAsync().ConfigureAwait(false); + Debugger.Break(); + } + #endif + } + + public async Task GetChunkBytes(ValorantChunk chunk, CancellationToken cancellationToken) + { + var chunkPath = Path.Combine(_chunkDirectory.FullName, chunk.Id + ".valchunk"); + byte[] chunkBytes; + + if (File.Exists(chunkPath)) + { + chunkBytes = new byte[chunk.Size]; + await using var fs = new FileStream(chunkPath, FileMode.Open, FileAccess.Read, FileShare.Read); + await fs.ReadAsync(chunkBytes, 0, chunkBytes.Length, cancellationToken).ConfigureAwait(false); + } + else + { + using var request = new HttpRequestMessage(HttpMethod.Get, chunk.Url); + using var response = await _client.SendAsync(request, cancellationToken).ConfigureAwait(false); + + if (response.StatusCode == HttpStatusCode.OK) + { + chunkBytes = await response.Content.ReadAsByteArrayAsync().ConfigureAwait(false); + await using var fs = new FileStream(chunkPath, FileMode.Create, FileAccess.Write, FileShare.Read); + await fs.WriteAsync(chunkBytes, 0, chunkBytes.Length, cancellationToken).ConfigureAwait(false); + } + else + { + #if DEBUG + var content = await response.Content.ReadAsStringAsync().ConfigureAwait(false); + Debugger.Break(); + #endif + chunkBytes = null; + } + } + + return chunkBytes; + } + + public static async Task DownloadAndParse(DirectoryInfo directoryInfo) + { + using var client = new HttpClient(new HttpClientHandler + { + UseProxy = false, + UseCookies = false, + AutomaticDecompression = DecompressionMethods.All, + CheckCertificateRevocationList = false, + PreAuthenticate = false + }); + using var request = new HttpRequestMessage(HttpMethod.Get, _url); + using var response = await client.SendAsync(request).ConfigureAwait(false); + + if (response.StatusCode != HttpStatusCode.OK) + { + return null; + } + + var responseStream = await response.Content.ReadAsStreamAsync(); + return new ValorantAPIManifest(responseStream, directoryInfo); + } + } + + public readonly struct ValorantChunk + { + private const string _baseUrl = "https://fmodel.fortnite-api.com/valorant/v1/chunks/"; + + public readonly ulong Id; + public readonly uint Size; + public string Url => _baseUrl + Id; + + public ValorantChunk(BinaryReader reader) + { + Id = reader.ReadUInt64(); + Size = reader.ReadUInt32(); + } + + public override string ToString() + { + return $"{Id:X8} | {Strings.GetReadableSize(Size)}"; + } + } + + public readonly struct ValorantPak + { + public readonly ulong Id; + public readonly uint Size; + public readonly string Name; + public readonly ulong[] ChunkIds; + + public ValorantPak(BinaryReader reader) + { + Id = reader.ReadUInt64(); + Size = reader.ReadUInt32(); + var nameLength = reader.ReadInt32(); + var nameBytes = reader.ReadBytes(nameLength); + Name = Encoding.ASCII.GetString(nameBytes); + ChunkIds = new ulong[reader.ReadInt32()]; + + for (var i = 0; i < ChunkIds.Length; i++) + { + ChunkIds[i] = reader.ReadUInt64(); + } + } + + public override string ToString() + { + return $"{Name} | {Strings.GetReadableSize(Size)}"; + } + } + + public class ValorantPakStream : Stream + { + public override bool CanRead { get; } = true; + public override bool CanSeek { get; } = true; + public override bool CanWrite { get; } = false; + public override long Length { get; } + + private long _position; + + public override long Position + { + get => _position; + set + { + if (value >= Length || value < 0) + { + throw new ArgumentOutOfRangeException(); + } + + _position = value; + } + } + + public string FileName { get; } + private readonly ValorantAPIManifest _manifest; + private readonly ValorantChunk[] _chunks; + + public ValorantPakStream(ValorantAPIManifest manifest, int pakIndex) + { + _manifest = manifest; + var pak = manifest.Paks[pakIndex]; + FileName = pak.Name; + Length = pak.Size; + _chunks = new ValorantChunk[pak.ChunkIds.Length]; + + for (var i = 0; i < _chunks.Length; i++) + { + _chunks[i] = manifest.Chunks[pak.ChunkIds[i]]; + } + } + + public override int Read(byte[] buffer, int offset, int count) + { + return ReadAsync(buffer, offset, count, CancellationToken.None).GetAwaiter().GetResult(); + } + + public async Task PrefetchAsync(int i, uint startPos, long count, CancellationToken cancellationToken, int concurrentDownloads = 4) + { + var tasks = new List(); + var sem = new SemaphoreSlim(concurrentDownloads); + + while (count > 0) + { + await sem.WaitAsync(cancellationToken).ConfigureAwait(false); + var chunk = _chunks[i++]; + tasks.Add(PrefetchChunkAsync(chunk)); + + if (i == _chunks.Length) + { + break; + } + + count -= chunk.Size - startPos; + startPos = 0u; + } + + await Task.WhenAll(tasks).ConfigureAwait(false); + sem.Dispose(); + + async Task PrefetchChunkAsync(ValorantChunk chunk) + { + await _manifest.PrefetchChunk(chunk, cancellationToken).ConfigureAwait(false); + sem.Release(); + } + } + + public override async Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + { + var (i, startPos) = GetChunkIndex(_position); + + if (i == -1) + { + return 0; + } + + await PrefetchAsync(i, startPos, count, cancellationToken).ConfigureAwait(false); + var bytesRead = 0; + + while (true) + { + var chunk = _chunks[i]; + var chunkData = await _manifest.GetChunkBytes(chunk, cancellationToken).ConfigureAwait(false); + + var chunkBytes = chunk.Size - startPos; + var bytesLeft = count - bytesRead; + + if (bytesLeft <= chunkBytes) + { + Unsafe.CopyBlockUnaligned(ref buffer[bytesRead + offset], ref chunkData[startPos], (uint)bytesLeft); + bytesRead += bytesLeft; + break; + } + + Unsafe.CopyBlockUnaligned(ref buffer[bytesRead + offset], ref chunkData[startPos], chunkBytes); + bytesRead += (int)chunkBytes; + startPos = 0u; + + if (++i == _chunks.Length) + { + break; + } + } + + _position += bytesRead; + return bytesRead; + } + + private (int Index, uint ChunkPos) GetChunkIndex(long position) + { + for (var i = 0; i < _chunks.Length; i++) + { + var size = _chunks[i].Size; + + if (position < size) + { + return (i, (uint)position); + } + + position -= size; + } + + return (-1, 0u); + } + + public override long Seek(long offset, SeekOrigin origin) + { + Position = origin switch + { + SeekOrigin.Begin => offset, + SeekOrigin.Current => offset + _position, + SeekOrigin.End => Length + offset, + _ => throw new ArgumentOutOfRangeException() + }; + return _position; + } + + public override void Flush() + { + throw new NotSupportedException(); + } + + public override void SetLength(long value) + { + throw new NotSupportedException(); + } + + public override void Write(byte[] buffer, int offset, int count) + { + throw new NotSupportedException(); + } + } +} \ No newline at end of file diff --git a/FModel/Grabber/Paks/PaksGrabber.cs b/FModel/Grabber/Paks/PaksGrabber.cs index cda5e027..773048e1 100644 --- a/FModel/Grabber/Paks/PaksGrabber.cs +++ b/FModel/Grabber/Paks/PaksGrabber.cs @@ -44,9 +44,15 @@ namespace FModel.Grabber.Paks } // Add Pak Files - if (Properties.Settings.Default.PakPath.EndsWith(".manifest")) + if (Properties.Settings.Default.PakPath.EndsWith("-fn.manifest")) { ManifestInfo manifestInfo = await ManifestGrabber.TryGetLatestManifestInfo().ConfigureAwait(false); + + if (manifestInfo == null) + { + throw new Exception("Failed to load latest manifest."); + } + DirectoryInfo chunksDir = Directory.CreateDirectory(Path.Combine(Properties.Settings.Default.OutputPath, "PakChunks")); string manifestPath = Path.Combine(chunksDir.FullName, manifestInfo.Filename); byte[] manifestData; @@ -97,6 +103,41 @@ namespace FModel.Grabber.Paks }); } } + else if (Properties.Settings.Default.PakPath.EndsWith("-val.manifest")) + { + ValorantAPIManifest manifest = await ValorantAPIManifest.DownloadAndParse(Directory.CreateDirectory(Path.Combine(Properties.Settings.Default.OutputPath, "PakChunks"))).ConfigureAwait(false); + + if (manifest == null) + { + throw new Exception("Failed to load latest manifest."); + } + + for (int i = 0; i < manifest.Paks.Length; i++) + { + ValorantPak pak = manifest.Paks[i]; + + var pakFileName = @$"ShooterGame\Content\Paks\{pak.Name}"; + PakFileReader pakFile = new PakFileReader(pakFileName, manifest.GetPakStream(i)); + + if (i == 0) + { + // define the current game thank to the pak path + Folders.SetGameName(pakFileName); + + Globals.Game.Version = pakFile.Info.Version; + Globals.Game.SubVersion = pakFile.Info.SubVersion; + } + + await Application.Current.Dispatcher.InvokeAsync(delegate + { + MenuItems.pakFiles.Add(new PakMenuItemViewModel + { + PakFile = pakFile, + IsEnabled = false + }); + }); + } + } else if (Directory.Exists(Properties.Settings.Default.PakPath)) { // define the current game thank to the pak path diff --git a/FModel/PakReader/Parsers/Objects/FTexturePlatformData.cs b/FModel/PakReader/Parsers/Objects/FTexturePlatformData.cs index 6d345f20..ed5540d2 100644 --- a/FModel/PakReader/Parsers/Objects/FTexturePlatformData.cs +++ b/FModel/PakReader/Parsers/Objects/FTexturePlatformData.cs @@ -1,6 +1,8 @@ using System; using System.IO; +using FModel; + namespace PakReader.Parsers.Objects { public readonly struct FTexturePlatformData @@ -27,7 +29,7 @@ namespace PakReader.Parsers.Objects Mips = reader.ReadTArray(() => new FTexture2DMipMap(reader, ubulk, bulkOffset)); - if (FModel.Globals.Game.Version > EPakVersion.FNAME_BASED_COMPRESSION_METHOD || FModel.Globals.Game.SubVersion == 1) + if (Globals.Game.Version > EPakVersion.FNAME_BASED_COMPRESSION_METHOD || Globals.Game.SubVersion == 1 && Globals.Game.ActualGame != EGame.Fortnite) { bIsVirtual = reader.ReadInt32() != 0; if (bIsVirtual) diff --git a/FModel/Utils/Strings.cs b/FModel/Utils/Strings.cs index 83b46b3e..57c2ec39 100644 --- a/FModel/Utils/Strings.cs +++ b/FModel/Utils/Strings.cs @@ -1,4 +1,5 @@ -using System.Runtime.CompilerServices; +using System; +using System.Runtime.CompilerServices; using System.Text.RegularExpressions; namespace FModel.Utils @@ -21,13 +22,13 @@ namespace FModel.Utils [MethodImpl(MethodImplOptions.AggressiveInlining)] public static string FixPath(string path) { - if (string.IsNullOrWhiteSpace(path)) + if (string.IsNullOrWhiteSpace(path) || path.Length < 5) return string.Empty; string trigger; { string tempPath = path[1..]; - trigger = tempPath.Substring(0, tempPath.IndexOf("/")); + trigger = tempPath.Substring(0, tempPath.IndexOf('/')); } Regex regex = new Regex(trigger); diff --git a/FModel/Windows/Launcher/FLauncher.xaml.cs b/FModel/Windows/Launcher/FLauncher.xaml.cs index 391948f6..2e294884 100644 --- a/FModel/Windows/Launcher/FLauncher.xaml.cs +++ b/FModel/Windows/Launcher/FLauncher.xaml.cs @@ -47,7 +47,7 @@ namespace FModel.Windows.Launcher ComboBoxVm.gamesCbViewModel.Add(new ComboBoxViewModel { Id = i++, Content = "Fortnite [EGL2]", Property = egl2FilesPath }); } - ComboBoxVm.gamesCbViewModel.Add(new ComboBoxViewModel { Id = i++, Content = "Fortnite [LIVE]", Property = "donotedit-youcanteditanyway.manifest" }); + ComboBoxVm.gamesCbViewModel.Add(new ComboBoxViewModel { Id = i++, Content = "Fortnite [LIVE]", Property = "donotedit-youcanteditanyway-fn.manifest" }); string valorantFilesPath = Paks.GetValorantPakFilesPath(); if (!string.IsNullOrEmpty(valorantFilesPath)) @@ -57,6 +57,8 @@ namespace FModel.Windows.Launcher ComboBoxVm.gamesCbViewModel.Add(new ComboBoxViewModel { Id = i++, Content = "Valorant", Property = valorantFilesPath }); } + ComboBoxVm.gamesCbViewModel.Add(new ComboBoxViewModel { Id = i++, Content = "Valorant [LIVE]", Property = "donotedit-youcanteditanyway-val.manifest" }); + string borderlands3FilesPath = Paks.GetBorderlands3PakFilesPath(); if (!string.IsNullOrEmpty(borderlands3FilesPath)) {