From 90bd7d8ef06c30293b1ca7460e06aefb7f90ac2c Mon Sep 17 00:00:00 2001 From: Marco Kirchner Date: Tue, 14 Nov 2023 22:20:44 +0100 Subject: [PATCH] add basic support for modifying rush loadouts --- .../Converters/UpgradeConverter.cs | 37 +++ .../RushLoadoutSettingsViewModel.cs | 49 ++++ .../Views/MainWindow.axaml.cs | 2 +- .../Views/Settings/LinuxSettings.axaml | 2 +- .../Views/Settings/MainSettings.axaml | 2 +- .../Views/Settings/RushLoadoutSettings.axaml | 251 ++++++++++++++++++ .../Settings/RushLoadoutSettings.axaml.cs | 120 +++++++++ .../Settings/SettingsContainerView.axaml | 5 +- .../Settings/SettingsContainerView.axaml.cs | 2 + GiganticEmu.Shared/GameUtils.cs | 166 +++++++++++- GiganticEmu.Shared/GiganticEmu.Shared.csproj | 1 + 11 files changed, 632 insertions(+), 5 deletions(-) create mode 100644 GiganticEmu.Launcher/Converters/UpgradeConverter.cs create mode 100644 GiganticEmu.Launcher/ViewModels/RushLoadoutSettingsViewModel.cs create mode 100644 GiganticEmu.Launcher/Views/Settings/RushLoadoutSettings.axaml create mode 100644 GiganticEmu.Launcher/Views/Settings/RushLoadoutSettings.axaml.cs diff --git a/GiganticEmu.Launcher/Converters/UpgradeConverter.cs b/GiganticEmu.Launcher/Converters/UpgradeConverter.cs new file mode 100644 index 0000000..abdf428 --- /dev/null +++ b/GiganticEmu.Launcher/Converters/UpgradeConverter.cs @@ -0,0 +1,37 @@ +using System; +using System.Globalization; +using Avalonia.Data.Converters; +using GiganticEmu.Shared; + +namespace GiganticEmu.Launcher; + +public class UpgradeConverter : IValueConverter +{ + public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + if (value is SkillUpgrade su) + { + return $"[{su.Skill switch + { + Skill.Skill1 => "LMB", + Skill.Skill2 => "RMB", + Skill.Skill3 => "Q", + Skill.Skill4 => "E", + Skill.Focus => "Focus", + + }}] {su.Path} {su.SubPath}"; + } + + if (value is TalentUpgrade tu) + { + return $"Talent {((int)tu.Upgrade) + 1}"; + } + + return value; + } + + public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + { + throw new NotSupportedException(); + } +} diff --git a/GiganticEmu.Launcher/ViewModels/RushLoadoutSettingsViewModel.cs b/GiganticEmu.Launcher/ViewModels/RushLoadoutSettingsViewModel.cs new file mode 100644 index 0000000..f6f1482 --- /dev/null +++ b/GiganticEmu.Launcher/ViewModels/RushLoadoutSettingsViewModel.cs @@ -0,0 +1,49 @@ + +using System.Linq; +using System.Reactive; +using System.Threading.Tasks; +using DynamicData; +using GiganticEmu.Shared; +using ReactiveUI; +using ReactiveUI.Fody.Helpers; +using Splat; + +namespace GiganticEmu.Launcher; + +public class RushLoadoutSettingsViewModel : ReactiveObject, SettingsContainerViewModel.ISettingsPageViewModel +{ + [Reactive] + public RushLoadout? RushLoadout { get; set; } + + public ISourceList RushLoadouts { get; } = new SourceList(); + + public ReactiveCommand Apply { get; } + public ReactiveCommand Reset { get; } + + public RushLoadoutSettingsViewModel() + { + Apply = ReactiveCommand.CreateFromTask(DoApply); + Reset = ReactiveCommand.CreateFromTask(DoReset); + + _ = Task.Run(DoReset); + } + + private async Task DoApply() + { + if (RushLoadout is RushLoadout loadout) + { + var configuration = Locator.Current.RequireService(); + await GameUtils.SaveRushLoadout(loadout, configuration.Game); + } + } + + private async Task DoReset() + { + var configuration = Locator.Current.RequireService(); + var loadouts = (await GameUtils.GetRushLoadouts(configuration.Game)).ToList(); + + RushLoadouts.Clear(); + RushLoadouts.AddRange(loadouts); + RushLoadout = RushLoadouts.Items.First(); + } +} diff --git a/GiganticEmu.Launcher/Views/MainWindow.axaml.cs b/GiganticEmu.Launcher/Views/MainWindow.axaml.cs index 5c6c46c..42b6f10 100644 --- a/GiganticEmu.Launcher/Views/MainWindow.axaml.cs +++ b/GiganticEmu.Launcher/Views/MainWindow.axaml.cs @@ -88,7 +88,7 @@ public MainWindow() this.OneWayBind(ViewModel, viewModel => viewModel.CurrentPage, view => view.ButtonSettings.IsVisible, - value => value is AppViewModel.Page.Login or AppViewModel.Page.Main + value => value is AppViewModel.Page.Login or AppViewModel.Page.Main or AppViewModel.Page.Connect ) .DisposeWith(disposables); diff --git a/GiganticEmu.Launcher/Views/Settings/LinuxSettings.axaml b/GiganticEmu.Launcher/Views/Settings/LinuxSettings.axaml index d67142c..622ac5e 100644 --- a/GiganticEmu.Launcher/Views/Settings/LinuxSettings.axaml +++ b/GiganticEmu.Launcher/Views/Settings/LinuxSettings.axaml @@ -14,7 +14,7 @@ diff --git a/GiganticEmu.Launcher/Views/Settings/MainSettings.axaml b/GiganticEmu.Launcher/Views/Settings/MainSettings.axaml index 3dea9af..068c8ef 100644 --- a/GiganticEmu.Launcher/Views/Settings/MainSettings.axaml +++ b/GiganticEmu.Launcher/Views/Settings/MainSettings.axaml @@ -14,7 +14,7 @@ diff --git a/GiganticEmu.Launcher/Views/Settings/RushLoadoutSettings.axaml b/GiganticEmu.Launcher/Views/Settings/RushLoadoutSettings.axaml new file mode 100644 index 0000000..c1aab52 --- /dev/null +++ b/GiganticEmu.Launcher/Views/Settings/RushLoadoutSettings.axaml @@ -0,0 +1,251 @@ + + + + + + + + + + + + + + + + + + Loadout + + + + + + + + + + + + + + + + + + Upgrade 1 + + + + + Upgrade 2 + + + + + Upgrade 3 + + + + + Upgrade 4 + + + + + Upgrade 5 + + + + + Upgrade 6 + + + + + Upgrade 7 + + + + + Upgrade 8 + + + + + Upgrade 9 + + + + + Upgrade 10 + + + + + Talent + + + + + \ No newline at end of file diff --git a/GiganticEmu.Launcher/Views/Settings/RushLoadoutSettings.axaml.cs b/GiganticEmu.Launcher/Views/Settings/RushLoadoutSettings.axaml.cs new file mode 100644 index 0000000..9ee6296 --- /dev/null +++ b/GiganticEmu.Launcher/Views/Settings/RushLoadoutSettings.axaml.cs @@ -0,0 +1,120 @@ +using Avalonia.ReactiveUI; +using System.Collections.Generic; +using Avalonia.Controls; +using ReactiveUI; +using System; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using GiganticEmu.Shared; +using System.Linq; +using PeNet.Header.Net.MetaDataTables; + +namespace GiganticEmu.Launcher; + +public partial class RushLoadoutSettings : ReactiveUserControl +{ + private bool _loading = false; + + public RushLoadoutSettings() + { + InitializeComponent(); + ViewModel = new RushLoadoutSettingsViewModel(); + + var upgrades = new List { + Upgrade1, + Upgrade2, + Upgrade3, + Upgrade4, + Upgrade5, + Upgrade6, + Upgrade7, + Upgrade8, + Upgrade9, + Upgrade10, + }; + + foreach (var upgrade in upgrades) + { + upgrade.Items = Enum.GetValues() + .SelectMany(skill => new List + { + new(skill, UpgradePath.Left, null), + new(skill, UpgradePath.Left, UpgradePath.Left), + new(skill, UpgradePath.Left, UpgradePath.Right), + new(skill, UpgradePath.Right, null), + new(skill, UpgradePath.Right, UpgradePath.Left), + new(skill, UpgradePath.Right, UpgradePath.Right), + }) + .ToList(); + } + + Talent.Items = Enum.GetValues().Select(talent => new TalentUpgrade(talent)); + + this.WhenActivated(disposables => + { + this.WhenAnyValue(x => x.ViewModel!.RushLoadouts) + .Subscribe(loadouts => + { + Loadout.Items = loadouts.Items; + }) + .DisposeWith(disposables); + + this.Bind(ViewModel, + viewModel => viewModel.RushLoadout, + view => view.Loadout.SelectedItem + ) + .DisposeWith(disposables); + + this.WhenAnyValue(x => x.ViewModel!.RushLoadout) + .Subscribe(loadout => + { + _loading = true; + if (loadout != null) + { + foreach (var (upgrade, i) in loadout.Upgrades.OfType().Select((x, i) => (x, i))) + { + upgrades[i].SelectedItem = upgrade; + } + Talent.SelectedItem = loadout.Upgrades.OfType().SingleOrDefault(); + } + _loading = false; + }) + .DisposeWith(disposables); + + + Observable.FromEventPattern(Talent, nameof(ComboBox.SelectionChanged)) + .Do(ev => + { + if (!_loading) + { + var args = (SelectionChangedEventArgs)ev.EventArgs; + var oldUpgrade = (TalentUpgrade)args.RemovedItems[0]!; + var index = this.ViewModel!.RushLoadout!.Upgrades.IndexOf(oldUpgrade); + var newUpgrade = (TalentUpgrade)args.AddedItems[0]!; + this.ViewModel!.RushLoadout!.Upgrades[index] = newUpgrade; + } + }) + .Subscribe() + .DisposeWith(disposables); + + + foreach (var upgrade in upgrades) + { + Observable.FromEventPattern(upgrade, nameof(ComboBox.SelectionChanged)) + .Do(ev => + { + if (!_loading) + { + var args = (SelectionChangedEventArgs)ev.EventArgs; + var oldUpgrade = (SkillUpgrade)args.RemovedItems[0]!; + var index = this.ViewModel!.RushLoadout!.Upgrades.IndexOf(oldUpgrade); + var newUpgrade = (SkillUpgrade)args.AddedItems[0]!; + this.ViewModel!.RushLoadout!.Upgrades[index] = newUpgrade; + } + }) + .Subscribe() + .DisposeWith(disposables); + } + }); + } +} \ No newline at end of file diff --git a/GiganticEmu.Launcher/Views/Settings/SettingsContainerView.axaml b/GiganticEmu.Launcher/Views/Settings/SettingsContainerView.axaml index e96884f..b77892c 100644 --- a/GiganticEmu.Launcher/Views/Settings/SettingsContainerView.axaml +++ b/GiganticEmu.Launcher/Views/Settings/SettingsContainerView.axaml @@ -30,7 +30,6 @@ @@ -41,6 +40,10 @@ Linux + + Talent Loadout + + \ No newline at end of file diff --git a/GiganticEmu.Launcher/Views/Settings/SettingsContainerView.axaml.cs b/GiganticEmu.Launcher/Views/Settings/SettingsContainerView.axaml.cs index ba461c2..4f955d3 100644 --- a/GiganticEmu.Launcher/Views/Settings/SettingsContainerView.axaml.cs +++ b/GiganticEmu.Launcher/Views/Settings/SettingsContainerView.axaml.cs @@ -28,11 +28,13 @@ public SettingsContainerView() .DisposeWith(disposables); PageLinuxSettings.Parent!.IsVisible = PlatformUtils.IsLinux; + PageRushLoadoutSettings.Parent!.IsVisible = GameLauncher.GiganticBuild >= GameUtils.BUILD_THROWBACK_EVENT; ViewModel.Pages = new SettingsContainerViewModel.ISettingsPageViewModel[] { PageMainSettings.ViewModel!, PageLinuxSettings.ViewModel!, + PageRushLoadoutSettings.ViewModel!, }.ToList(); }); } diff --git a/GiganticEmu.Shared/GameUtils.cs b/GiganticEmu.Shared/GameUtils.cs index 5557b8a..b7b3bd7 100644 --- a/GiganticEmu.Shared/GameUtils.cs +++ b/GiganticEmu.Shared/GameUtils.cs @@ -1,13 +1,38 @@ +using System; +using System.Collections.Generic; using System.IO; using System.Linq; +using System.Text; +using System.Text.RegularExpressions; using System.Threading.Tasks; +using UE4Config.Parsing; namespace GiganticEmu.Shared; -public static class GameUtils +public enum UpgradePath { Left, Right } +public record Upgrade(); +public enum Skill { Skill1, Skill2, Skill3, Skill4, Focus } +public record SkillUpgrade(Skill Skill, UpgradePath Path, UpgradePath? SubPath) : Upgrade; +public enum Talent { Talent1, Talent2, Talent3 } +public record TalentUpgrade(Talent Upgrade) : Upgrade; +public record RushLoadout(string Hero, int Index, IList Upgrades); + +public static partial class GameUtils { public const int BUILD_THROWBACK_EVENT = 510547; + [GeneratedRegex(@"up_(?\w+)_Rush(?1|2)_(?\d{2})_Provider RxUpgradePathProvider", RegexOptions.IgnoreCase)] + private static partial Regex RushUpgradeRegex(); + + [GeneratedRegex(@"UPT_(?:(?\w+)_U(?1|2)(?:_SU(?1|2))?|Spec(?[0-3]))", RegexOptions.IgnoreCase)] + private static partial Regex RushUpgradePathRegex(); + + [GeneratedRegex(@"bPrototypeOnly\s*=\s*true")] + private static partial Regex HeroPrototypeRegex(); + + [GeneratedRegex(@"(?\w+)Game\.ini", RegexOptions.IgnoreCase)] + private static partial Regex HeroIniRegex(); + public static string GetBaseDir() { return System.Reflection.Assembly.GetExecutingAssembly().Location switch @@ -29,4 +54,143 @@ public static async Task GetGameBuild(string? gameDir = null) .Select(x => int.Parse(x.Trim())) .SingleOrDefault(); } + + public static async Task> GetRushLoadouts(string hero, string? gameDir = null) + { + gameDir ??= GetBaseDir(); + + var config = new ConfigIni("DefaultGame"); + + using (var reader = File.OpenText(Path.Join(gameDir, "RxGame", "Config", "Heroes", $"{hero}Game.ini"))) + { + config.Read(reader); + } + + return config.Sections + .Where(section => section.Name != null) + .Select(section => new { section, match = RushUpgradeRegex().Match(section.Name) }) + .Where(entry => entry.match.Success) + .Select(entry => new + { + set = int.Parse(entry.match.Groups["set"].Value), + upgrade = entry.section.Tokens + .OfType() + .Last(token => token.Key.Trim().Equals("UpgradeType", StringComparison.InvariantCultureIgnoreCase)) + .Value.Trim(), + index = int.Parse(entry.section.Tokens + .OfType() + .Last(token => token.Key.Trim().Equals("GroupIndex", StringComparison.InvariantCultureIgnoreCase)) + .Value.Trim()) + + }) + .GroupBy(upgrade => upgrade.set) + .Select(group => new RushLoadout( + hero, + group.Key, + group + .OrderBy(x => x.index) + .Select(x => RushUpgradePathRegex().Match(x.upgrade)) + .Select(x => x switch + { + _ when x.Groups["spec"].Success => new TalentUpgrade(Enum.Parse($"Talent{int.Parse(x.Groups["spec"].Value)}")), + _ => new SkillUpgrade( + Enum.Parse(x.Groups["skill"].Value, true), + x.Groups["path"].Value switch + { + "1" => UpgradePath.Left, + _ => UpgradePath.Right + }, + x.Groups["sub_path"].Value switch + { + "1" => UpgradePath.Left, + "2" => UpgradePath.Right, + _ => null + } + ) as Upgrade + }) + .ToList() + ) + ); + } + + public static async Task> GetRushLoadouts(string? gameDir = null) + { + gameDir ??= GetBaseDir(); + + var heroes = await GetHeroes(gameDir); + + var loadouts = (await Task.WhenAll(heroes.Select(async hero => + { + try + { + return (await GetRushLoadouts(hero, gameDir)).ToList(); + } + catch (Exception ex) + { + await Console.Error.WriteLineAsync($"Error trying to load Loadouts for hero {hero}: {ex}"); + return null; + } + }))); + + return loadouts.OfType>().SelectMany(x => x); + } + + public static async Task> GetHeroes(string? gameDir = null) + { + gameDir ??= GetBaseDir(); + + var file = Path.Join(gameDir, "RxGame", "Config", "DefaultGame.ini"); + + return (await File.ReadAllLinesAsync(file)) + .Where(line => line.StartsWith("OverriddenBy=..\\RxGame\\Config\\Heroes\\")) + .Select(line => HeroIniRegex().Match(line[37..])) + .Where(match => match.Success) + .Select(match => match.Groups["hero"].Value) + .Where(hero => !HeroPrototypeRegex().IsMatch(File.ReadAllText(Path.Join(gameDir, "RxGame", "Config", "Heroes", $"{hero}Game.ini")))) + .ToList(); + } + + public static async Task SaveRushLoadout(RushLoadout loadout, string? gameDir = null) + { + gameDir ??= GetBaseDir(); + + var config = new ConfigIni("DefaultGame"); + + using (var reader = File.OpenText(Path.Join(gameDir, "RxGame", "Config", "Heroes", $"{loadout.Hero}Game.ini"))) + { + config.Read(reader); + } + + foreach (var (upgrade, i) in loadout.Upgrades.Select((x, i) => (x, i))) + { + var sectionName = $"up_{loadout.Hero}_Rush{loadout.Index}_{i + 1:D2}_Provider RxUpgradePathProvider"; + var section = config.Sections + .Single(section => sectionName.Equals(section.Name?.Trim(), StringComparison.InvariantCultureIgnoreCase)); + + var token = section.Tokens + .OfType() + .Single(token => "UpgradeType".Equals(token.Key?.Trim(), StringComparison.InvariantCultureIgnoreCase)); + + var value = new StringBuilder(" "); + + if (upgrade is SkillUpgrade su) + { + value.Append($"UPT_{su.Skill}_U{((int)su.Path) + 1}"); + + if (su.SubPath is UpgradePath subPath) + { + value.Append($"_SU{((int)subPath) + 1}"); + } + } + else if (upgrade is TalentUpgrade tu) + { + value.Append($"UPT_Spec{((int)tu.Upgrade) + 1}"); + } + + token.Value = value.ToString(); + } + + using var writer = new StreamWriter(File.OpenWrite(Path.Join(gameDir, "RxGame", "Config", "Heroes", $"{loadout.Hero}Game.ini"))); + config.Write(new ConfigIniWriter(writer)); + } } \ No newline at end of file diff --git a/GiganticEmu.Shared/GiganticEmu.Shared.csproj b/GiganticEmu.Shared/GiganticEmu.Shared.csproj index d6cf992..7771b8f 100644 --- a/GiganticEmu.Shared/GiganticEmu.Shared.csproj +++ b/GiganticEmu.Shared/GiganticEmu.Shared.csproj @@ -8,6 +8,7 @@ + \ No newline at end of file