From 7eb298f2fbbc47f8a9c3dba56fcb6bd9fc66cc1e Mon Sep 17 00:00:00 2001 From: Marvin <52848568+mleem97@users.noreply.github.com> Date: Mon, 20 Apr 2026 02:56:55 +0200 Subject: [PATCH] feat: initial import of gregExtractor with sync command, redesigned UI, and wiki docs --- .gitignore | 17 + Commands/CoverageCommand.cs | 147 + Commands/CreateCommand.cs | 95 + Commands/ExtractCommand.cs | 86 + Commands/SyncCommand.cs | 179 + DarkTheme.cs | 217 + GUI/App.axaml | 25 + GUI/App.axaml.cs | 28 + GUI/Controls/GregButton.axaml | 6 + GUI/Controls/GregCard.axaml | 4 + GUI/Controls/GregInput.axaml | 4 + GUI/Controls/GregProgressBar.axaml | 4 + GUI/Controls/GregTag.axaml | 8 + GUI/Controls/NavItem.axaml | 4 + GUI/Controls/StatCard.axaml | 10 + GUI/Controls/StatusBadge.axaml | 6 + GUI/Controls/Toast.axaml | 11 + GUI/Controls/Toast.axaml.cs | 11 + GUI/Controls/ToastHost.axaml | 12 + GUI/Controls/ToastHost.axaml.cs | 11 + GUI/CoverageView.axaml | 93 + GUI/CoverageView.axaml.cs | 11 + GUI/MainWindow.axaml | 79 + GUI/MainWindow.axaml.cs | 51 + GUI/Styles/GregAnimations.axaml | 3 + GUI/Styles/GregColors.axaml | 49 + GUI/Styles/GregStyles.axaml | 139 + GUI/ViewModels/CoverageViewModel.cs | 247 ++ GUI/ViewModels/CreateViewModel.cs | 221 + GUI/ViewModels/ExtractorViewModel.cs | 191 + GUI/ViewModels/HookBrowserViewModel.cs | 139 + GUI/ViewModels/HookGroupViewModel.cs | 38 + GUI/ViewModels/MainViewModel.cs | 107 + GUI/ViewModels/NavigationItemViewModel.cs | 22 + GUI/ViewModels/SettingsViewModel.cs | 149 + GUI/ViewModels/SyncViewModel.cs | 296 ++ GUI/Views/CoverageView.axaml | 62 + GUI/Views/CoverageView.axaml.cs | 11 + GUI/Views/CreateView.axaml | 49 + GUI/Views/CreateView.axaml.cs | 11 + GUI/Views/ExtractorView.axaml | 63 + GUI/Views/ExtractorView.axaml.cs | 11 + GUI/Views/HookBrowserView.axaml | 35 + GUI/Views/HookBrowserView.axaml.cs | 11 + GUI/Views/SettingsView.axaml | 53 + GUI/Views/SettingsView.axaml.cs | 11 + GUI/Views/SyncView.axaml | 53 + GUI/Views/SyncView.axaml.cs | 11 + GlobalUsings.cs | 16 + GregExtractorCli.cs | 140 + HookAutomationService.cs | 821 ++++ Il2CppMetadataScanner.cs | 208 + MainForm.cs | 973 +++++ MainFormV2.cs | 1592 +++++++ ModProjectAnalyzer.cs | 268 ++ Models.cs | 138 + Models/CoverageReport.cs | 10 + Models/CoverageStatus.cs | 8 + Models/HookCoverageEntry.cs | 6 + Models/HookDefinition.cs | 10 + Models/HookDiff.cs | 6 + Models/HookGroupConfig.cs | 6 + Models/HookParameter.cs | 3 + Models/SyncResult.cs | 7 + Models/TemplateType.cs | 11 + Program.cs | 53 + README.md | 184 + Services/CoverageAnalyzerService.cs | 363 ++ Services/DiffService.cs | 82 + Services/ExtractorService.cs | 140 + Services/FrameworkSyncService.cs | 582 +++ Services/HookClassifier.cs | 121 + Services/ICoverageScanner.cs | 8 + Services/RegexCoverageScanner.cs | 57 + Services/RoslynCoverageScanner.cs | 117 + Services/TemplateService.cs | 388 ++ SnapshotStore.cs | 44 + SourceScanner.cs | 243 ++ SteamLocator.cs | 147 + Utils/CoveragePathResolver.cs | 40 + Utils/SteamLocator.cs | 105 + generated-template/GregPluginHookTemplate.cs | 1338 ++++++ .../HookTemplate.GameApiBridge.cs | 29 + .../HookTemplate.HarmonyPatches.cs | 3844 +++++++++++++++++ generated-template/HookTemplate.cs | 1338 ++++++ generated-template/README.md | 21 + generated-template/SmokeMod/MainPlugin.cs | 21 + ...mputerShop_ApplyColorToSpawnedItemPatch.cs | 22 + .../ComputerShop_ButtonBuyShopItemPatch.cs | 22 + .../ComputerShop_BuyAnotherItemPatch.cs | 22 + .../Economy/ComputerShop_BuyNewItemPatch.cs | 22 + .../ComputerShop_FreeUpSpawnPointPatch.cs | 22 + ...terShop_GetNextAvailableSpawnPointPatch.cs | 22 + .../ComputerShop_GetPrefabForItemPatch.cs | 22 + .../ComputerShop_HandleObjectivesPatch.cs | 22 + .../ComputerShop_InteractOnHoverPatch.cs | 22 + .../ComputerShop_RemoveCartUIItemPatch.cs | 22 + .../ComputerShop_RemoveSpawnedItemPatch.cs | 22 + .../ComputerShop_SelectNextAvailablePatch.cs | 22 + .../ComputerShop_SpawnNewCartItemPatch.cs | 22 + .../ComputerShop_SpawnPhysicalItemPatch.cs | 22 + .../ComputerShop_UnlockFromSavePatch.cs | 22 + .../ComputerShop__Awake_b__36_0Patch.cs | 22 + ...ustomerBase_GetEffectiveMoneySpeedPatch.cs | 22 + .../Economy/CustomerBase_UpdateMoneyPatch.cs | 22 + .../ModLoader_CreateShopButtonPatch.cs | 22 + .../ModLoader_CreateShopTemplatePatch.cs | 22 + .../Economy/ModShopItem_InitializePatch.cs | 22 + .../MusicPlayer_InteractOnHoverPatch.cs | 22 + .../Patches/Economy/Player_LoadPlayerPatch.cs | 22 + ...r_TurnOnCharacterControllerDelayedPatch.cs | 22 + .../Patches/Economy/Player_UpdateCoinPatch.cs | 22 + .../Economy/Player_UpdateReputationPatch.cs | 22 + .../Patches/Economy/Player_UpdateXPPatch.cs | 22 + .../Patches/Economy/Player_WarpPlayerPatch.cs | 22 + .../SettingsGraphics_SetExposurePatch.cs | 22 + .../ShopCartItem_AddSpawnedItemPatch.cs | 22 + .../Economy/ShopCartItem_InitializePatch.cs | 22 + ...ShopCartItem_RemoveLastSpawnedItemPatch.cs | 22 + ...nts_UpdateCoinsAndPrestige_TopLeftPatch.cs | 22 + generated-template/SmokeMod/SmokeMod.csproj | 22 + generated-template/SmokeMod/mod.json | 12 + .../greg.Plugin.HookTemplate.csproj | 13 + gregExtractor.csproj | 41 + hook_groups.json | 57 + wiki/README.md | 21 + wiki/cli.md | 67 + wiki/known-issues.md | 41 + wiki/ui.md | 52 + 129 files changed, 17641 insertions(+) create mode 100644 .gitignore create mode 100644 Commands/CoverageCommand.cs create mode 100644 Commands/CreateCommand.cs create mode 100644 Commands/ExtractCommand.cs create mode 100644 Commands/SyncCommand.cs create mode 100644 DarkTheme.cs create mode 100644 GUI/App.axaml create mode 100644 GUI/App.axaml.cs create mode 100644 GUI/Controls/GregButton.axaml create mode 100644 GUI/Controls/GregCard.axaml create mode 100644 GUI/Controls/GregInput.axaml create mode 100644 GUI/Controls/GregProgressBar.axaml create mode 100644 GUI/Controls/GregTag.axaml create mode 100644 GUI/Controls/NavItem.axaml create mode 100644 GUI/Controls/StatCard.axaml create mode 100644 GUI/Controls/StatusBadge.axaml create mode 100644 GUI/Controls/Toast.axaml create mode 100644 GUI/Controls/Toast.axaml.cs create mode 100644 GUI/Controls/ToastHost.axaml create mode 100644 GUI/Controls/ToastHost.axaml.cs create mode 100644 GUI/CoverageView.axaml create mode 100644 GUI/CoverageView.axaml.cs create mode 100644 GUI/MainWindow.axaml create mode 100644 GUI/MainWindow.axaml.cs create mode 100644 GUI/Styles/GregAnimations.axaml create mode 100644 GUI/Styles/GregColors.axaml create mode 100644 GUI/Styles/GregStyles.axaml create mode 100644 GUI/ViewModels/CoverageViewModel.cs create mode 100644 GUI/ViewModels/CreateViewModel.cs create mode 100644 GUI/ViewModels/ExtractorViewModel.cs create mode 100644 GUI/ViewModels/HookBrowserViewModel.cs create mode 100644 GUI/ViewModels/HookGroupViewModel.cs create mode 100644 GUI/ViewModels/MainViewModel.cs create mode 100644 GUI/ViewModels/NavigationItemViewModel.cs create mode 100644 GUI/ViewModels/SettingsViewModel.cs create mode 100644 GUI/ViewModels/SyncViewModel.cs create mode 100644 GUI/Views/CoverageView.axaml create mode 100644 GUI/Views/CoverageView.axaml.cs create mode 100644 GUI/Views/CreateView.axaml create mode 100644 GUI/Views/CreateView.axaml.cs create mode 100644 GUI/Views/ExtractorView.axaml create mode 100644 GUI/Views/ExtractorView.axaml.cs create mode 100644 GUI/Views/HookBrowserView.axaml create mode 100644 GUI/Views/HookBrowserView.axaml.cs create mode 100644 GUI/Views/SettingsView.axaml create mode 100644 GUI/Views/SettingsView.axaml.cs create mode 100644 GUI/Views/SyncView.axaml create mode 100644 GUI/Views/SyncView.axaml.cs create mode 100644 GlobalUsings.cs create mode 100644 GregExtractorCli.cs create mode 100644 HookAutomationService.cs create mode 100644 Il2CppMetadataScanner.cs create mode 100644 MainForm.cs create mode 100644 MainFormV2.cs create mode 100644 ModProjectAnalyzer.cs create mode 100644 Models.cs create mode 100644 Models/CoverageReport.cs create mode 100644 Models/CoverageStatus.cs create mode 100644 Models/HookCoverageEntry.cs create mode 100644 Models/HookDefinition.cs create mode 100644 Models/HookDiff.cs create mode 100644 Models/HookGroupConfig.cs create mode 100644 Models/HookParameter.cs create mode 100644 Models/SyncResult.cs create mode 100644 Models/TemplateType.cs create mode 100644 Program.cs create mode 100644 README.md create mode 100644 Services/CoverageAnalyzerService.cs create mode 100644 Services/DiffService.cs create mode 100644 Services/ExtractorService.cs create mode 100644 Services/FrameworkSyncService.cs create mode 100644 Services/HookClassifier.cs create mode 100644 Services/ICoverageScanner.cs create mode 100644 Services/RegexCoverageScanner.cs create mode 100644 Services/RoslynCoverageScanner.cs create mode 100644 Services/TemplateService.cs create mode 100644 SnapshotStore.cs create mode 100644 SourceScanner.cs create mode 100644 SteamLocator.cs create mode 100644 Utils/CoveragePathResolver.cs create mode 100644 Utils/SteamLocator.cs create mode 100644 generated-template/GregPluginHookTemplate.cs create mode 100644 generated-template/HookTemplate.GameApiBridge.cs create mode 100644 generated-template/HookTemplate.HarmonyPatches.cs create mode 100644 generated-template/HookTemplate.cs create mode 100644 generated-template/README.md create mode 100644 generated-template/SmokeMod/MainPlugin.cs create mode 100644 generated-template/SmokeMod/Patches/Economy/ComputerShop_ApplyColorToSpawnedItemPatch.cs create mode 100644 generated-template/SmokeMod/Patches/Economy/ComputerShop_ButtonBuyShopItemPatch.cs create mode 100644 generated-template/SmokeMod/Patches/Economy/ComputerShop_BuyAnotherItemPatch.cs create mode 100644 generated-template/SmokeMod/Patches/Economy/ComputerShop_BuyNewItemPatch.cs create mode 100644 generated-template/SmokeMod/Patches/Economy/ComputerShop_FreeUpSpawnPointPatch.cs create mode 100644 generated-template/SmokeMod/Patches/Economy/ComputerShop_GetNextAvailableSpawnPointPatch.cs create mode 100644 generated-template/SmokeMod/Patches/Economy/ComputerShop_GetPrefabForItemPatch.cs create mode 100644 generated-template/SmokeMod/Patches/Economy/ComputerShop_HandleObjectivesPatch.cs create mode 100644 generated-template/SmokeMod/Patches/Economy/ComputerShop_InteractOnHoverPatch.cs create mode 100644 generated-template/SmokeMod/Patches/Economy/ComputerShop_RemoveCartUIItemPatch.cs create mode 100644 generated-template/SmokeMod/Patches/Economy/ComputerShop_RemoveSpawnedItemPatch.cs create mode 100644 generated-template/SmokeMod/Patches/Economy/ComputerShop_SelectNextAvailablePatch.cs create mode 100644 generated-template/SmokeMod/Patches/Economy/ComputerShop_SpawnNewCartItemPatch.cs create mode 100644 generated-template/SmokeMod/Patches/Economy/ComputerShop_SpawnPhysicalItemPatch.cs create mode 100644 generated-template/SmokeMod/Patches/Economy/ComputerShop_UnlockFromSavePatch.cs create mode 100644 generated-template/SmokeMod/Patches/Economy/ComputerShop__Awake_b__36_0Patch.cs create mode 100644 generated-template/SmokeMod/Patches/Economy/CustomerBase_GetEffectiveMoneySpeedPatch.cs create mode 100644 generated-template/SmokeMod/Patches/Economy/CustomerBase_UpdateMoneyPatch.cs create mode 100644 generated-template/SmokeMod/Patches/Economy/ModLoader_CreateShopButtonPatch.cs create mode 100644 generated-template/SmokeMod/Patches/Economy/ModLoader_CreateShopTemplatePatch.cs create mode 100644 generated-template/SmokeMod/Patches/Economy/ModShopItem_InitializePatch.cs create mode 100644 generated-template/SmokeMod/Patches/Economy/MusicPlayer_InteractOnHoverPatch.cs create mode 100644 generated-template/SmokeMod/Patches/Economy/Player_LoadPlayerPatch.cs create mode 100644 generated-template/SmokeMod/Patches/Economy/Player_TurnOnCharacterControllerDelayedPatch.cs create mode 100644 generated-template/SmokeMod/Patches/Economy/Player_UpdateCoinPatch.cs create mode 100644 generated-template/SmokeMod/Patches/Economy/Player_UpdateReputationPatch.cs create mode 100644 generated-template/SmokeMod/Patches/Economy/Player_UpdateXPPatch.cs create mode 100644 generated-template/SmokeMod/Patches/Economy/Player_WarpPlayerPatch.cs create mode 100644 generated-template/SmokeMod/Patches/Economy/SettingsGraphics_SetExposurePatch.cs create mode 100644 generated-template/SmokeMod/Patches/Economy/ShopCartItem_AddSpawnedItemPatch.cs create mode 100644 generated-template/SmokeMod/Patches/Economy/ShopCartItem_InitializePatch.cs create mode 100644 generated-template/SmokeMod/Patches/Economy/ShopCartItem_RemoveLastSpawnedItemPatch.cs create mode 100644 generated-template/SmokeMod/Patches/Economy/StaticUIElements_UpdateCoinsAndPrestige_TopLeftPatch.cs create mode 100644 generated-template/SmokeMod/SmokeMod.csproj create mode 100644 generated-template/SmokeMod/mod.json create mode 100644 generated-template/greg.Plugin.HookTemplate.csproj create mode 100644 gregExtractor.csproj create mode 100644 hook_groups.json create mode 100644 wiki/README.md create mode 100644 wiki/cli.md create mode 100644 wiki/known-issues.md create mode 100644 wiki/ui.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..458a3d6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,17 @@ +bin/ +obj/ +.vs/ +*.user +*.suo +*.cache + +state/ + +coverage_report.json +coverage_report.md + +game_hooks.json +unknown_hooks.json + +*.log +*.tmp diff --git a/Commands/CoverageCommand.cs b/Commands/CoverageCommand.cs new file mode 100644 index 0000000..eb20799 --- /dev/null +++ b/Commands/CoverageCommand.cs @@ -0,0 +1,147 @@ +using gregExtractor.Models; +using gregExtractor.Services; +using gregExtractor.Utils; + +namespace gregExtractor.Commands; + +public sealed class CoverageCommand : AsyncCommand +{ + public sealed class Settings : CommandSettings + { + [CommandOption("--path ")] + public string? Path { get; init; } + + [CommandOption("--hooks ")] + public string? HooksPath { get; init; } + + [CommandOption("--sources ")] + public string? SourceDirs { get; init; } + + [CommandOption("--out ")] + public string? OutputBasePath { get; init; } + + [CommandOption("--open")] + public bool OpenAfterGenerate { get; init; } + } + + public override async Task ExecuteAsync(CommandContext context, Settings settings) + { + AnsiConsole.Write(new FigletText("gregCoverage").Color(Color.Cyan1)); + + string workingDirectory = Directory.GetCurrentDirectory(); + + string? il2CppPath = settings.Path; + if (string.IsNullOrWhiteSpace(il2CppPath)) + il2CppPath = SteamLocator.FindIl2CppAssembliesPath(); + + if (string.IsNullOrWhiteSpace(il2CppPath)) + { + AnsiConsole.MarkupLine("[yellow]Could not auto-detect Il2CppAssemblies path.[/]"); + il2CppPath = AnsiConsole.Ask("Enter IL2CPP directory path:"); + } + + if (string.IsNullOrWhiteSpace(il2CppPath) || !Directory.Exists(il2CppPath)) + { + AnsiConsole.MarkupLine("[red]Invalid IL2CPP path.[/]"); + return -1; + } + + string hooksPath = string.IsNullOrWhiteSpace(settings.HooksPath) + ? Path.Combine(workingDirectory, "game_hooks.json") + : settings.HooksPath; + + if (!File.Exists(hooksPath)) + { + AnsiConsole.MarkupLine($"[red]Hooks file not found:[/] {Markup.Escape(hooksPath)}"); + return -1; + } + + string[] sourceDirs = CoveragePathResolver.ResolveSourceDirs(settings.SourceDirs, workingDirectory); + if (sourceDirs.Length == 0) + { + AnsiConsole.MarkupLine("[red]No valid framework source directories found.[/]"); + AnsiConsole.MarkupLine("[grey]Use --sources \";\"[/]"); + return -1; + } + + var analyzer = new CoverageAnalyzerService(); + var messages = new List(); + var progress = new Progress(message => messages.Add(message)); + + CoverageReport report = default!; + try + { + await AnsiConsole.Status() + .Spinner(Spinner.Known.Dots) + .StartAsync("Analyzing coverage...", async _ => + { + report = await Task.Run(() => analyzer.Analyze(new AnalyzeOptions( + Il2CppDir: il2CppPath!, + GameHooksJsonPath: hooksPath, + FrameworkSourceDirs: sourceDirs, + OutputBasePath: settings.OutputBasePath, + Progress: progress))) + .ConfigureAwait(false); + }) + .ConfigureAwait(false); + } + catch (Exception exception) + { + AnsiConsole.MarkupLine($"[red]Coverage analysis failed:[/] {Markup.Escape(exception.Message)}"); + return -1; + } + + foreach (string message in messages) + AnsiConsole.MarkupLine($"[grey]{Markup.Escape(message)}[/]"); + + var summary = new Table().RoundedBorder(); + summary.AddColumn("Metric"); + summary.AddColumn("Value"); + summary.AddRow("Total Hooks", report.TotalHooks.ToString()); + summary.AddRow("Covered", report.CoveredCount.ToString()); + summary.AddRow("Planned", report.PlannedCount.ToString()); + summary.AddRow("Uncovered", report.UncoveredCount.ToString()); + summary.AddRow("Coverage", $"{report.CoveragePercent:F2}%"); + AnsiConsole.Write(summary); + + HookCoverageEntry[] uncoveredTop = report.Entries + .Where(entry => entry.CoverageStatus == CoverageStatus.Uncovered) + .Take(10) + .ToArray(); + + if (uncoveredTop.Length > 0) + { + var uncoveredTable = new Table().RoundedBorder(); + uncoveredTable.Title = new TableTitle("Top 10 Uncovered"); + uncoveredTable.AddColumn("Group"); + uncoveredTable.AddColumn("Class"); + uncoveredTable.AddColumn("Method"); + + foreach (HookCoverageEntry entry in uncoveredTop) + { + uncoveredTable.AddRow( + entry.HookDefinition.Group, + entry.HookDefinition.ClassName, + entry.HookDefinition.MethodName); + } + + AnsiConsole.Write(uncoveredTable); + } + + (string jsonPath, string mdPath) = CoverageAnalyzerService.GetReportPaths(settings.OutputBasePath); + AnsiConsole.MarkupLine($"[green]coverage_report.json[/] -> {Markup.Escape(jsonPath)}"); + AnsiConsole.MarkupLine($"[green]coverage_report.md[/] -> {Markup.Escape(mdPath)}"); + + if (settings.OpenAfterGenerate && File.Exists(mdPath)) + { + Process.Start(new ProcessStartInfo + { + FileName = mdPath, + UseShellExecute = true, + }); + } + + return 0; + } + +} diff --git a/Commands/CreateCommand.cs b/Commands/CreateCommand.cs new file mode 100644 index 0000000..7443035 --- /dev/null +++ b/Commands/CreateCommand.cs @@ -0,0 +1,95 @@ +using gregExtractor.Models; +using gregExtractor.Services; + +namespace gregExtractor.Commands; + +public sealed class CreateCommand : AsyncCommand +{ + public sealed class Settings : CommandSettings + { + [CommandArgument(0, "")] + public string ModName { get; init; } = string.Empty; + + [CommandOption("--type ")] + [DefaultValue("harmonyPatch")] + public string Type { get; init; } = "harmonyPatch"; + + [CommandOption("--category ")] + public string? Category { get; init; } + + [CommandOption("--path ")] + public string? Path { get; init; } + } + + public override async Task ExecuteAsync(CommandContext context, Settings settings) + { + if (string.IsNullOrWhiteSpace(settings.ModName)) + { + AnsiConsole.MarkupLine("[red]Mod name is required.[/]"); + return -1; + } + + if (!TryParseTemplateType(settings.Type, out TemplateType templateType)) + { + AnsiConsole.MarkupLine($"[red]Invalid --type value: {Markup.Escape(settings.Type)}[/]"); + return -1; + } + + string outputPath = string.IsNullOrWhiteSpace(settings.Path) + ? Directory.GetCurrentDirectory() + : settings.Path; + + string hooksPath = Path.Combine(Directory.GetCurrentDirectory(), "game_hooks.json"); + var service = new TemplateService(); + + var progressMessages = new List(); + var progress = new Progress(message => progressMessages.Add(message)); + + TemplateGenerationResult result = await service.GenerateModAsync( + settings.ModName, + settings.Category, + templateType, + outputPath, + hooksPath, + progress, + CancellationToken.None) + .ConfigureAwait(false); + + foreach (string message in progressMessages) + AnsiConsole.MarkupLine($"[grey]{Markup.Escape(message)}[/]"); + + AnsiConsole.MarkupLine($"[green]Scaffold created:[/] {Markup.Escape(result.ModDirectory)}"); + + var tree = new Tree(Path.GetFileName(result.ModDirectory)); + foreach (string file in result.GeneratedFiles.OrderBy(file => file, StringComparer.OrdinalIgnoreCase)) + { + string relative = Path.GetRelativePath(result.ModDirectory, file).Replace('\\', '/'); + tree.AddNode(relative); + } + + AnsiConsole.Write(tree); + return 0; + } + + private static bool TryParseTemplateType(string value, out TemplateType templateType) + { + templateType = TemplateType.HarmonyPatch; + + return value.ToLowerInvariant() switch + { + "harmonypatch" => Assign(TemplateType.HarmonyPatch, out templateType), + "customserver" => Assign(TemplateType.CustomServer, out templateType), + "customui" => Assign(TemplateType.CustomUI, out templateType), + "customworld" => Assign(TemplateType.CustomWorld, out templateType), + "customfurniture" => Assign(TemplateType.CustomFurniture, out templateType), + "customnpc" => Assign(TemplateType.CustomNPC, out templateType), + _ => false, + }; + } + + private static bool Assign(TemplateType type, out TemplateType templateType) + { + templateType = type; + return true; + } +} diff --git a/Commands/ExtractCommand.cs b/Commands/ExtractCommand.cs new file mode 100644 index 0000000..0d846ff --- /dev/null +++ b/Commands/ExtractCommand.cs @@ -0,0 +1,86 @@ +using gregExtractor.Models; +using gregExtractor.Services; +using gregExtractor.Utils; + +namespace gregExtractor.Commands; + +public sealed class ExtractCommand : AsyncCommand +{ + public sealed class Settings : CommandSettings + { + [CommandOption("--path ")] + public string? Path { get; init; } + } + + public override async Task ExecuteAsync(CommandContext context, Settings settings) + { + AnsiConsole.Write(new FigletText("gregExtractor").Color(Color.Cyan1)); + + string workingDirectory = Directory.GetCurrentDirectory(); + string groupsPath = Path.Combine(workingDirectory, "hook_groups.json"); + + var logMessages = new List(); + var progress = new Progress(message => logMessages.Add(message)); + HookClassifier classifier = HookClassifier.LoadFromFile(groupsPath, progress); + + string? il2CppPath = settings.Path; + if (string.IsNullOrWhiteSpace(il2CppPath)) + il2CppPath = SteamLocator.FindIl2CppAssembliesPath(); + + if (string.IsNullOrWhiteSpace(il2CppPath)) + { + AnsiConsole.MarkupLine("[yellow]Could not auto-detect Il2CppAssemblies path.[/]"); + il2CppPath = AnsiConsole.Ask("Enter IL2CPP directory path:"); + } + + if (string.IsNullOrWhiteSpace(il2CppPath) || !Directory.Exists(il2CppPath)) + { + AnsiConsole.MarkupLine("[red]Invalid IL2CPP path.[/]"); + return -1; + } + + IReadOnlyList hooks = Array.Empty(); + var extractor = new ExtractorService(); + + await AnsiConsole.Status() + .Spinner(Spinner.Known.Dots) + .StartAsync("Extracting IL2CPP hooks...", async _ => + { + hooks = await extractor.ExtractAsync(il2CppPath!, classifier, progress, CancellationToken.None).ConfigureAwait(false); + }) + .ConfigureAwait(false); + + string gameHooksPath = Path.Combine(workingDirectory, "game_hooks.json"); + await File.WriteAllTextAsync( + gameHooksPath, + JsonSerializer.Serialize(hooks, new JsonSerializerOptions { WriteIndented = true }), + CancellationToken.None) + .ConfigureAwait(false); + + HookDefinition[] uncategorized = hooks + .Where(hook => string.Equals(hook.Group, "Uncategorized", StringComparison.OrdinalIgnoreCase)) + .ToArray(); + + string unknownHooksPath = Path.Combine(workingDirectory, "unknown_hooks.json"); + await File.WriteAllTextAsync( + unknownHooksPath, + JsonSerializer.Serialize(uncategorized, new JsonSerializerOptions { WriteIndented = true }), + CancellationToken.None) + .ConfigureAwait(false); + + var table = new Table().RoundedBorder(); + table.AddColumn("Group"); + table.AddColumn("Count"); + + foreach (IGrouping group in hooks.GroupBy(hook => hook.Group).OrderBy(group => group.Key, StringComparer.OrdinalIgnoreCase)) + table.AddRow(group.Key, group.Count().ToString()); + + AnsiConsole.Write(table); + + foreach (string line in logMessages) + AnsiConsole.MarkupLine($"[grey]{Markup.Escape(line)}[/]"); + + AnsiConsole.MarkupLine($"[green]✓ {hooks.Count} hooks saved[/] -> {Markup.Escape(gameHooksPath)}"); + return 0; + } +} diff --git a/Commands/SyncCommand.cs b/Commands/SyncCommand.cs new file mode 100644 index 0000000..c908d52 --- /dev/null +++ b/Commands/SyncCommand.cs @@ -0,0 +1,179 @@ +using gregExtractor.Models; +using gregExtractor.Services; +using gregExtractor.Utils; + +namespace gregExtractor.Commands; + +public sealed class SyncCommand : AsyncCommand +{ + public sealed class Settings : CommandSettings + { + [CommandOption("--source ")] + public string Source { get; init; } = string.Empty; + + [CommandOption("--dry-run")] + public bool DryRun { get; init; } + + [CommandOption("--git")] + public bool UseGitDiff { get; init; } + + [CommandOption("--force")] + public bool Force { get; init; } + + public override ValidationResult Validate() + { + return string.IsNullOrWhiteSpace(Source) + ? ValidationResult.Error("--source ist erforderlich.") + : ValidationResult.Success(); + } + } + + public override async Task ExecuteAsync(CommandContext context, Settings settings) + { + AnsiConsole.Write(new FigletText("Sync").Color(Color.Yellow)); + + if (!settings.DryRun && !settings.Force) + { + bool confirmed = AnsiConsole.Confirm( + "⚠️ Dies schreibt direkt in deinen Framework-Quellcode!\nBackups werden angelegt. Fortfahren?", + false); + + if (!confirmed) + { + AnsiConsole.MarkupLine("[yellow]Abgebrochen.[/]"); + return 0; + } + } + + string workingDirectory = Directory.GetCurrentDirectory(); + string hooksPath = Path.Combine(workingDirectory, "game_hooks.json"); + string groupsPath = Path.Combine(workingDirectory, "hook_groups.json"); + + if (!File.Exists(hooksPath)) + { + AnsiConsole.MarkupLine($"[red]Vorheriger Zustand fehlt:[/] {Markup.Escape(hooksPath)}"); + return -1; + } + + HookDefinition[] oldHooks = JsonSerializer.Deserialize(await File.ReadAllTextAsync(hooksPath).ConfigureAwait(false)) + ?? Array.Empty(); + + HookClassifier classifier = HookClassifier.LoadFromFile(groupsPath, new Progress(_ => { })); + + string? il2CppPath = SteamLocator.FindIl2CppAssembliesPath(); + if (string.IsNullOrWhiteSpace(il2CppPath) || !Directory.Exists(il2CppPath)) + { + AnsiConsole.MarkupLine("[yellow]IL2CPP-Pfad konnte nicht automatisch gefunden werden.[/]"); + il2CppPath = AnsiConsole.Ask("Pfad zu Il2CppAssemblies:"); + } + + if (string.IsNullOrWhiteSpace(il2CppPath) || !Directory.Exists(il2CppPath)) + { + AnsiConsole.MarkupLine("[red]Ungültiger IL2CPP-Pfad.[/]"); + return -1; + } + + var extractor = new ExtractorService(); + var diffService = new DiffService(); + var syncService = new FrameworkSyncService(); + + var logLines = new List(); + var progress = new Progress(line => logLines.Add(line)); + + IReadOnlyList newHooks = Array.Empty(); + HookDiff diff = new(Array.Empty(), Array.Empty(), Array.Empty()); + + await AnsiConsole.Status() + .Spinner(Spinner.Known.Dots) + .StartAsync("Berechne Assembly-Diff...", async _ => + { + newHooks = await extractor.ExtractAsync(il2CppPath, classifier, progress, CancellationToken.None).ConfigureAwait(false); + diff = diffService.Diff(oldHooks.ToList(), newHooks.ToList()); + }) + .ConfigureAwait(false); + + var summary = new Table().RoundedBorder(); + summary.Title = new TableTitle("Assembly Diff"); + summary.AddColumn("Typ"); + summary.AddColumn("Anzahl"); + summary.AddRow("+ Neue Methoden", diff.Added.Count.ToString()); + summary.AddRow("~ Signaturen geändert", diff.Changed.Count.ToString()); + summary.AddRow("- Methoden entfernt", diff.Removed.Count.ToString()); + AnsiConsole.Write(summary); + + SyncResult result = syncService.Sync(new SyncOptions( + FrameworkSourceDir: settings.Source, + Diff: diff, + AllHooks: newHooks.ToList(), + DryRun: settings.DryRun, + Progress: progress)); + + foreach (string line in logLines) + AnsiConsole.MarkupLine($"[grey]{Markup.Escape(line)}[/]"); + + foreach (string file in result.FilesWritten) + AnsiConsole.MarkupLine($"[green]✓[/] {Markup.Escape(Path.GetFileName(file))}"); + + foreach (string warning in result.Warnings) + AnsiConsole.MarkupLine($"[yellow]{Markup.Escape(warning)}[/]"); + + if (!settings.DryRun) + { + await File.WriteAllTextAsync( + hooksPath, + JsonSerializer.Serialize(newHooks, new JsonSerializerOptions { WriteIndented = true }), + CancellationToken.None) + .ConfigureAwait(false); + } + + if (settings.UseGitDiff) + { + try + { + string gitOutput = await RunGitDiffStatAsync(settings.Source).ConfigureAwait(false); + if (!string.IsNullOrWhiteSpace(gitOutput)) + { + AnsiConsole.MarkupLine("[cyan]git diff --stat[/]"); + AnsiConsole.WriteLine(gitOutput); + } + } + catch (Exception exception) + { + AnsiConsole.MarkupLine($"[yellow]Git-Diff konnte nicht gelesen werden:[/] {Markup.Escape(exception.Message)}"); + } + } + + return 0; + } + + private static async Task RunGitDiffStatAsync(string frameworkSourceDir) + { + string workingDirectory = Directory.Exists(frameworkSourceDir) + ? frameworkSourceDir + : Directory.GetCurrentDirectory(); + + var process = new Process + { + StartInfo = new ProcessStartInfo + { + FileName = "git", + Arguments = "diff --stat", + WorkingDirectory = workingDirectory, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + }, + }; + + process.Start(); + string output = await process.StandardOutput.ReadToEndAsync().ConfigureAwait(false); + string error = await process.StandardError.ReadToEndAsync().ConfigureAwait(false); + await process.WaitForExitAsync().ConfigureAwait(false); + + if (process.ExitCode != 0) + return string.IsNullOrWhiteSpace(error) ? output : error; + + return output; + } +} diff --git a/DarkTheme.cs b/DarkTheme.cs new file mode 100644 index 0000000..0f17f2a --- /dev/null +++ b/DarkTheme.cs @@ -0,0 +1,217 @@ +using System.Drawing; +using System.Windows.Forms; + +namespace gregExtractor; + +internal static class DarkTheme +{ + public static readonly Color Background = Color.FromArgb(30, 30, 30); + public static readonly Color Surface = Color.FromArgb(37, 37, 38); + public static readonly Color SurfaceAlt = Color.FromArgb(45, 45, 48); + public static readonly Color Border = Color.FromArgb(62, 62, 66); + public static readonly Color Foreground = Color.FromArgb(241, 241, 241); + public static readonly Color MutedForeground = Color.FromArgb(185, 185, 185); + public static readonly Color Accent = Color.FromArgb(14, 99, 156); + + public static void Apply(Form form) + { + form.BackColor = Background; + form.ForeColor = Foreground; + + ApplyToControlTree(form); + EnableDarkTabs(form); + } + + private static void ApplyToControlTree(Control root) + { + foreach (Control control in root.Controls) + { + ApplyToControl(control); + if (control.HasChildren) + ApplyToControlTree(control); + } + } + + private static void ApplyToControl(Control control) + { + switch (control) + { + case TabControl tabControl: + tabControl.BackColor = Background; + tabControl.ForeColor = Foreground; + tabControl.DrawMode = TabDrawMode.OwnerDrawFixed; + tabControl.DrawItem -= OnTabControlDrawItem; + tabControl.DrawItem += OnTabControlDrawItem; + break; + + case TabPage tabPage: + tabPage.BackColor = Background; + tabPage.ForeColor = Foreground; + break; + + case GroupBox groupBox: + groupBox.BackColor = Surface; + groupBox.ForeColor = Foreground; + break; + + case SplitContainer split: + split.BackColor = Surface; + split.ForeColor = Foreground; + break; + + case FlowLayoutPanel flow: + flow.BackColor = Surface; + flow.ForeColor = Foreground; + break; + + case TableLayoutPanel table: + table.BackColor = Surface; + table.ForeColor = Foreground; + break; + + case Panel panel: + panel.BackColor = Surface; + panel.ForeColor = Foreground; + break; + + case TextBox textBox: + ApplyTextBoxTheme(textBox); + break; + + case RichTextBox richTextBox: + richTextBox.BackColor = SurfaceAlt; + richTextBox.ForeColor = Foreground; + richTextBox.BorderStyle = BorderStyle.FixedSingle; + break; + + case DataGridView grid: + ApplyDataGridTheme(grid); + break; + + case Button button: + ApplyButtonTheme(button); + break; + + case CheckBox checkBox: + checkBox.BackColor = Surface; + checkBox.ForeColor = Foreground; + break; + + case ComboBox comboBox: + comboBox.BackColor = SurfaceAlt; + comboBox.ForeColor = Foreground; + comboBox.FlatStyle = FlatStyle.Flat; + break; + + case Label label: + label.BackColor = Color.Transparent; + label.ForeColor = Foreground; + break; + + default: + control.BackColor = Surface; + control.ForeColor = Foreground; + break; + } + } + + private static void ApplyTextBoxTheme(TextBox textBox) + { + textBox.BackColor = SurfaceAlt; + textBox.ForeColor = Foreground; + textBox.BorderStyle = BorderStyle.FixedSingle; + + if (textBox.ReadOnly) + { + textBox.BackColor = Color.FromArgb(42, 42, 42); + textBox.ForeColor = MutedForeground; + } + } + + private static void ApplyButtonTheme(Button button) + { + button.BackColor = SurfaceAlt; + button.ForeColor = Foreground; + button.FlatStyle = FlatStyle.Flat; + button.FlatAppearance.BorderColor = Border; + button.FlatAppearance.BorderSize = 1; + button.FlatAppearance.MouseOverBackColor = Color.FromArgb(55, 55, 60); + button.FlatAppearance.MouseDownBackColor = Accent; + button.UseVisualStyleBackColor = false; + } + + private static void ApplyDataGridTheme(DataGridView grid) + { + grid.BackgroundColor = Surface; + grid.GridColor = Border; + grid.BorderStyle = BorderStyle.None; + + grid.EnableHeadersVisualStyles = false; + grid.ColumnHeadersBorderStyle = DataGridViewHeaderBorderStyle.Single; + grid.RowHeadersBorderStyle = DataGridViewHeaderBorderStyle.Single; + + grid.ColumnHeadersDefaultCellStyle.BackColor = Color.FromArgb(51, 51, 55); + grid.ColumnHeadersDefaultCellStyle.ForeColor = Foreground; + grid.ColumnHeadersDefaultCellStyle.SelectionBackColor = Color.FromArgb(62, 62, 66); + grid.ColumnHeadersDefaultCellStyle.SelectionForeColor = Foreground; + + grid.RowHeadersDefaultCellStyle.BackColor = Color.FromArgb(51, 51, 55); + grid.RowHeadersDefaultCellStyle.ForeColor = Foreground; + grid.RowHeadersDefaultCellStyle.SelectionBackColor = Color.FromArgb(62, 62, 66); + grid.RowHeadersDefaultCellStyle.SelectionForeColor = Foreground; + + grid.DefaultCellStyle.BackColor = SurfaceAlt; + grid.DefaultCellStyle.ForeColor = Foreground; + grid.DefaultCellStyle.SelectionBackColor = Accent; + grid.DefaultCellStyle.SelectionForeColor = Color.White; + + grid.AlternatingRowsDefaultCellStyle.BackColor = Color.FromArgb(39, 39, 42); + grid.AlternatingRowsDefaultCellStyle.ForeColor = Foreground; + grid.AlternatingRowsDefaultCellStyle.SelectionBackColor = Accent; + grid.AlternatingRowsDefaultCellStyle.SelectionForeColor = Color.White; + } + + private static void EnableDarkTabs(Control root) + { + foreach (Control control in root.Controls) + { + if (control is TabControl tabControl) + { + tabControl.DrawMode = TabDrawMode.OwnerDrawFixed; + tabControl.DrawItem -= OnTabControlDrawItem; + tabControl.DrawItem += OnTabControlDrawItem; + } + + if (control.HasChildren) + EnableDarkTabs(control); + } + } + + private static void OnTabControlDrawItem(object? sender, DrawItemEventArgs eventArgs) + { + if (sender is not TabControl tabControl) + return; + + Rectangle rectangle = eventArgs.Bounds; + bool selected = (eventArgs.State & DrawItemState.Selected) == DrawItemState.Selected; + + Color back = selected ? SurfaceAlt : Surface; + Color fore = selected ? Foreground : MutedForeground; + + using var backgroundBrush = new SolidBrush(back); + using var textBrush = new SolidBrush(fore); + using var borderPen = new Pen(Border); + + eventArgs.Graphics.FillRectangle(backgroundBrush, rectangle); + eventArgs.Graphics.DrawRectangle(borderPen, rectangle); + + string text = tabControl.TabPages[eventArgs.Index].Text; + TextRenderer.DrawText( + eventArgs.Graphics, + text, + tabControl.Font, + rectangle, + fore, + TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter); + } +} diff --git a/GUI/App.axaml b/GUI/App.axaml new file mode 100644 index 0000000..62cde2d --- /dev/null +++ b/GUI/App.axaml @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/GUI/App.axaml.cs b/GUI/App.axaml.cs new file mode 100644 index 0000000..76a6c30 --- /dev/null +++ b/GUI/App.axaml.cs @@ -0,0 +1,28 @@ +using Avalonia; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Markup.Xaml; +using gregExtractor.GUI.ViewModels; + +namespace gregExtractor.GUI; + +public partial class App : Application +{ + public override void Initialize() + { + AvaloniaXamlLoader.Load(this); + RequestedThemeVariant = Avalonia.Styling.ThemeVariant.Dark; + } + + public override void OnFrameworkInitializationCompleted() + { + if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) + { + desktop.MainWindow = new MainWindow + { + DataContext = new MainViewModel(), + }; + } + + base.OnFrameworkInitializationCompleted(); + } +} diff --git a/GUI/Controls/GregButton.axaml b/GUI/Controls/GregButton.axaml new file mode 100644 index 0000000..863298f --- /dev/null +++ b/GUI/Controls/GregButton.axaml @@ -0,0 +1,6 @@ + + + diff --git a/GUI/Controls/NavItem.axaml b/GUI/Controls/NavItem.axaml new file mode 100644 index 0000000..b44124a --- /dev/null +++ b/GUI/Controls/NavItem.axaml @@ -0,0 +1,4 @@ + + + diff --git a/GUI/Controls/StatusBadge.axaml b/GUI/Controls/StatusBadge.axaml new file mode 100644 index 0000000..4a4867e --- /dev/null +++ b/GUI/Controls/StatusBadge.axaml @@ -0,0 +1,6 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/GUI/ViewModels/CoverageViewModel.cs b/GUI/ViewModels/CoverageViewModel.cs new file mode 100644 index 0000000..b3271ac --- /dev/null +++ b/GUI/ViewModels/CoverageViewModel.cs @@ -0,0 +1,247 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using gregExtractor.Models; +using gregExtractor.Services; +using gregExtractor.Utils; + +namespace gregExtractor.GUI.ViewModels; + +public sealed class CoverageViewModel : ObservableObject +{ + private readonly CoverageAnalyzerService _analyzerService = new(); + + private string _il2CppPath = string.Empty; + private string _gameHooksPath = Path.Combine(Directory.GetCurrentDirectory(), "game_hooks.json"); + private string _frameworkSourceDirsText = CoveragePathResolver.GetDefaultSourcesText(Directory.GetCurrentDirectory()); + private string _outputBasePath = Path.Combine(Directory.GetCurrentDirectory(), "coverage_report"); + private bool _isAnalyzing; + private double _progress; + private int _totalHooks; + private int _coveredCount; + private int _plannedCount; + private int _uncoveredCount; + private double _coveragePercent; + + public CoverageViewModel() + { + AutoDetectPathCommand = new RelayCommand(AutoDetectPath); + BrowseFolderCommand = new AsyncRelayCommand(BrowseFolderAsync); + AnalyzeCoverageCommand = new AsyncRelayCommand(AnalyzeAsync, () => !IsAnalyzing); + OpenMarkdownReportCommand = new RelayCommand(OpenMarkdownReport, () => File.Exists(CoverageAnalyzerService.GetReportPaths(OutputBasePath).MarkdownPath)); + + AutoDetectPath(); + } + + public Func>? BrowseFolderHandler { get; set; } + + public string Il2CppPath + { + get => _il2CppPath; + set => SetProperty(ref _il2CppPath, value); + } + + public string GameHooksPath + { + get => _gameHooksPath; + set => SetProperty(ref _gameHooksPath, value); + } + + public string FrameworkSourceDirsText + { + get => _frameworkSourceDirsText; + set => SetProperty(ref _frameworkSourceDirsText, value); + } + + public string OutputBasePath + { + get => _outputBasePath; + set + { + if (SetProperty(ref _outputBasePath, value)) + OpenMarkdownReportCommand.NotifyCanExecuteChanged(); + } + } + + public bool IsAnalyzing + { + get => _isAnalyzing; + set + { + if (!SetProperty(ref _isAnalyzing, value)) + return; + + AnalyzeCoverageCommand.NotifyCanExecuteChanged(); + OnPropertyChanged(nameof(IsLoading)); + } + } + + public bool IsLoading => IsAnalyzing; + + public double Progress + { + get => _progress; + set => SetProperty(ref _progress, value); + } + + public int TotalHooks + { + get => _totalHooks; + set => SetProperty(ref _totalHooks, value); + } + + public int CoveredCount + { + get => _coveredCount; + set => SetProperty(ref _coveredCount, value); + } + + public int PlannedCount + { + get => _plannedCount; + set => SetProperty(ref _plannedCount, value); + } + + public int UncoveredCount + { + get => _uncoveredCount; + set => SetProperty(ref _uncoveredCount, value); + } + + public double CoveragePercent + { + get => _coveragePercent; + set => SetProperty(ref _coveragePercent, value); + } + + public ObservableCollection Entries { get; } = new(); + + public ObservableCollection LogLines { get; } = new(); + + public IRelayCommand AutoDetectPathCommand { get; } + + public IAsyncRelayCommand BrowseFolderCommand { get; } + + public IAsyncRelayCommand AnalyzeCoverageCommand { get; } + + public IRelayCommand OpenMarkdownReportCommand { get; } + + private void AutoDetectPath() + { + Il2CppPath = SteamLocator.FindIl2CppAssembliesPath() ?? string.Empty; + + if (string.IsNullOrWhiteSpace(Il2CppPath)) + Log("Auto-detect for IL2CPP path failed."); + else + Log($"Auto-detected IL2CPP path: {Il2CppPath}"); + } + + private async Task BrowseFolderAsync(string? target) + { + if (BrowseFolderHandler is null) + return; + + string? selected = await BrowseFolderHandler(target).ConfigureAwait(false); + if (string.IsNullOrWhiteSpace(selected)) + return; + + switch (target) + { + case "Il2Cpp": + Il2CppPath = selected; + break; + case "SourceDirs": + FrameworkSourceDirsText = selected; + break; + case "Output": + OutputBasePath = Path.Combine(selected, "coverage_report"); + break; + } + } + + private async Task AnalyzeAsync() + { + if (string.IsNullOrWhiteSpace(Il2CppPath) || !Directory.Exists(Il2CppPath)) + { + Log("IL2CPP path is invalid."); + return; + } + + if (string.IsNullOrWhiteSpace(GameHooksPath) || !File.Exists(GameHooksPath)) + { + Log("game_hooks.json path is invalid."); + return; + } + + string[] sourceDirs = CoveragePathResolver.ResolveSourceDirs(FrameworkSourceDirsText, Directory.GetCurrentDirectory()); + if (sourceDirs.Length == 0) + { + Log("No valid source directories found. Provide ';' separated paths."); + return; + } + + IsAnalyzing = true; + Progress = 0; + Entries.Clear(); + + try + { + var progress = new Progress(message => + { + Log(message); + Progress = Math.Min(99, Progress + 1); + }); + + CoverageReport report = await Task.Run(() => _analyzerService.Analyze(new AnalyzeOptions( + Il2CppDir: Il2CppPath, + GameHooksJsonPath: GameHooksPath, + FrameworkSourceDirs: sourceDirs, + OutputBasePath: OutputBasePath, + Progress: progress))) + .ConfigureAwait(false); + + TotalHooks = report.TotalHooks; + CoveredCount = report.CoveredCount; + PlannedCount = report.PlannedCount; + UncoveredCount = report.UncoveredCount; + CoveragePercent = report.CoveragePercent; + + foreach (HookCoverageEntry entry in report.Entries) + Entries.Add(entry); + + Progress = 100; + Log($"Coverage analysis done. Coverage: {report.CoveragePercent:F2}%"); + OpenMarkdownReportCommand.NotifyCanExecuteChanged(); + } + catch (Exception exception) + { + Log($"Coverage analysis failed: {exception.Message}"); + } + finally + { + IsAnalyzing = false; + } + } + + private void OpenMarkdownReport() + { + string markdownPath = CoverageAnalyzerService.GetReportPaths(OutputBasePath).MarkdownPath; + if (!File.Exists(markdownPath)) + return; + + Process.Start(new ProcessStartInfo + { + FileName = markdownPath, + UseShellExecute = true, + }); + } + + private void Log(string message) + { + string line = $"[{DateTime.Now:HH:mm:ss}] {message}"; + + if (LogLines.Count > 1000) + LogLines.RemoveAt(0); + + LogLines.Add(line); + } +} diff --git a/GUI/ViewModels/CreateViewModel.cs b/GUI/ViewModels/CreateViewModel.cs new file mode 100644 index 0000000..9d037d0 --- /dev/null +++ b/GUI/ViewModels/CreateViewModel.cs @@ -0,0 +1,221 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using gregExtractor.Models; +using gregExtractor.Services; + +namespace gregExtractor.GUI.ViewModels; + +public sealed class CreateViewModel : ObservableObject +{ + private readonly TemplateService _templateService = new(); + + private int _step = 1; + private string _modName = "MyDataCenterMod"; + private string _author = "TeamGreg"; + private string _version = "1.0.0"; + private string _description = "Generated by gregExtractor"; + private string _outputPath = Directory.GetCurrentDirectory(); + private TemplateType _selectedTemplateType = TemplateType.HarmonyPatch; + private string? _selectedCategory; + private bool _isLoading; + + public CreateViewModel() + { + string groupsPath = Path.Combine(Directory.GetCurrentDirectory(), "hook_groups.json"); + HookClassifier classifier = HookClassifier.LoadFromFile(groupsPath, new Progress(AddLog)); + foreach (string group in classifier.Groups.Keys.OrderBy(x => x, StringComparer.OrdinalIgnoreCase)) + Categories.Add(group); + + if (!Categories.Contains("Misc", StringComparer.OrdinalIgnoreCase)) + Categories.Add("Misc"); + + TemplateTypes = Enum.GetValues(); + + NextStepCommand = new RelayCommand(NextStep, () => !IsLoading); + PreviousStepCommand = new RelayCommand(PreviousStep, () => !IsLoading); + BrowseOutputPathCommand = new AsyncRelayCommand(BrowseOutputPathAsync); + GenerateCommand = new AsyncRelayCommand(GenerateAsync, () => !IsLoading && !string.IsNullOrWhiteSpace(ModName)); + OpenOutputCommand = new RelayCommand(OpenOutput, () => Directory.Exists(OutputPath)); + } + + public Func>? BrowseFolderHandler { get; set; } + + public int Step + { + get => _step; + set => SetProperty(ref _step, Math.Clamp(value, 1, 3)); + } + + public bool IsLoading + { + get => _isLoading; + set + { + if (!SetProperty(ref _isLoading, value)) + return; + + NextStepCommand.NotifyCanExecuteChanged(); + PreviousStepCommand.NotifyCanExecuteChanged(); + GenerateCommand.NotifyCanExecuteChanged(); + } + } + + public string ModName + { + get => _modName; + set + { + if (!SetProperty(ref _modName, value)) + return; + + GenerateCommand.NotifyCanExecuteChanged(); + } + } + + public string Author + { + get => _author; + set => SetProperty(ref _author, value); + } + + public string Version + { + get => _version; + set => SetProperty(ref _version, value); + } + + public string Description + { + get => _description; + set => SetProperty(ref _description, value); + } + + public string OutputPath + { + get => _outputPath; + set + { + if (!SetProperty(ref _outputPath, value)) + return; + + OpenOutputCommand.NotifyCanExecuteChanged(); + } + } + + public TemplateType SelectedTemplateType + { + get => _selectedTemplateType; + set => SetProperty(ref _selectedTemplateType, value); + } + + public string? SelectedCategory + { + get => _selectedCategory; + set => SetProperty(ref _selectedCategory, value); + } + + public IReadOnlyList TemplateTypes { get; } + + public ObservableCollection Categories { get; } = new(); + + public ObservableCollection PreviewTree { get; } = new(); + + public ObservableCollection LogLines { get; } = new(); + + public IRelayCommand NextStepCommand { get; } + + public IRelayCommand PreviousStepCommand { get; } + + public IAsyncRelayCommand BrowseOutputPathCommand { get; } + + public IAsyncRelayCommand GenerateCommand { get; } + + public IRelayCommand OpenOutputCommand { get; } + + private void NextStep() + { + Step = Math.Min(3, Step + 1); + if (Step == 3) + RefreshPreview(); + } + + private void PreviousStep() + { + Step = Math.Max(1, Step - 1); + } + + private async Task BrowseOutputPathAsync() + { + if (BrowseFolderHandler is null) + return; + + string? selected = await BrowseFolderHandler("CreateOutput").ConfigureAwait(false); + if (!string.IsNullOrWhiteSpace(selected)) + OutputPath = selected; + } + + private async Task GenerateAsync() + { + IsLoading = true; + try + { + string hooksPath = Path.Combine(Directory.GetCurrentDirectory(), "game_hooks.json"); + TemplateGenerationResult result = await _templateService + .GenerateModAsync( + ModName, + SelectedCategory, + SelectedTemplateType, + OutputPath, + hooksPath, + new Progress(AddLog), + CancellationToken.None) + .ConfigureAwait(false); + + PreviewTree.Clear(); + PreviewTree.Add(Path.GetFileName(result.ModDirectory) + "/"); + foreach (string file in result.GeneratedFiles.OrderBy(x => x, StringComparer.OrdinalIgnoreCase)) + PreviewTree.Add(" " + Path.GetRelativePath(result.ModDirectory, file).Replace('\\', '/')); + + AddLog($"✓ Mod erstellt: {result.ModDirectory}"); + } + catch (Exception exception) + { + AddLog($"✗ Mod-Erstellung fehlgeschlagen: {exception.Message}"); + } + finally + { + IsLoading = false; + } + } + + private void OpenOutput() + { + if (!Directory.Exists(OutputPath)) + return; + + Process.Start(new ProcessStartInfo + { + FileName = OutputPath, + UseShellExecute = true, + }); + } + + private void RefreshPreview() + { + PreviewTree.Clear(); + PreviewTree.Add(ModName + "/"); + PreviewTree.Add(" " + ModName + ".csproj"); + PreviewTree.Add(" MainPlugin.cs"); + PreviewTree.Add(" mod.json"); + PreviewTree.Add(" Patches/"); + } + + private void AddLog(string message) + { + string line = $"[{DateTime.Now:HH:mm:ss}] {message}"; + if (LogLines.Count > 1000) + LogLines.RemoveAt(0); + + LogLines.Add(line); + } +} diff --git a/GUI/ViewModels/ExtractorViewModel.cs b/GUI/ViewModels/ExtractorViewModel.cs new file mode 100644 index 0000000..af401b4 --- /dev/null +++ b/GUI/ViewModels/ExtractorViewModel.cs @@ -0,0 +1,191 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using gregExtractor.Models; +using gregExtractor.Services; +using gregExtractor.Utils; + +namespace gregExtractor.GUI.ViewModels; + +public sealed class ExtractorViewModel : ObservableObject +{ + private readonly ExtractorService _extractorService = new(); + private HookClassifier _classifier; + + private string _gamePath = string.Empty; + private string _il2CppPath = string.Empty; + private bool _isLoading; + private double _progress; + + public ExtractorViewModel() + { + string groupsPath = Path.Combine(Directory.GetCurrentDirectory(), "hook_groups.json"); + _classifier = HookClassifier.LoadFromFile(groupsPath, new Progress(AddLog)); + + foreach ((string groupName, HookGroupConfig config) in _classifier.Groups) + HookGroups.Add(new HookGroupViewModel(groupName, config.Description)); + + AutoDetectPathCommand = new RelayCommand(AutoDetectPaths); + BrowsePathCommand = new AsyncRelayCommand(BrowsePathAsync); + StartAnalysisCommand = new AsyncRelayCommand(StartAnalysisAsync, () => !IsLoading); + + AutoDetectPaths(); + } + + public Func>? BrowseFolderHandler { get; set; } + + public string GamePath + { + get => _gamePath; + set => SetProperty(ref _gamePath, value); + } + + public string Il2CppPath + { + get => _il2CppPath; + set => SetProperty(ref _il2CppPath, value); + } + + public bool IsLoading + { + get => _isLoading; + set + { + if (!SetProperty(ref _isLoading, value)) + return; + + StartAnalysisCommand.NotifyCanExecuteChanged(); + } + } + + public double Progress + { + get => _progress; + set => SetProperty(ref _progress, value); + } + + public ObservableCollection HookGroups { get; } = new(); + + public ObservableCollection LogLines { get; } = new(); + + public int TotalHooks => HookGroups.Sum(x => x.Count); + + public int GroupedHooks => HookGroups.Where(x => !x.GroupName.Equals("Uncategorized", StringComparison.OrdinalIgnoreCase)).Sum(x => x.Count); + + public int UnknownHooks => HookGroups.Where(x => x.GroupName.Equals("Uncategorized", StringComparison.OrdinalIgnoreCase)).Sum(x => x.Count); + + public int GroupCount => HookGroups.Count; + + public IRelayCommand AutoDetectPathCommand { get; } + + public IAsyncRelayCommand BrowsePathCommand { get; } + + public IAsyncRelayCommand StartAnalysisCommand { get; } + + private void AutoDetectPaths() + { + GamePath = SteamLocator.FindGamePath() ?? string.Empty; + Il2CppPath = SteamLocator.FindIl2CppAssembliesPath() ?? string.Empty; + + if (string.IsNullOrWhiteSpace(Il2CppPath)) + AddLog("⚠ Kein IL2CPP-Pfad automatisch erkannt."); + else + AddLog($"✓ IL2CPP-Pfad erkannt: {Il2CppPath}"); + } + + private async Task BrowsePathAsync(string? target) + { + if (BrowseFolderHandler is null) + return; + + string? selected = await BrowseFolderHandler(target).ConfigureAwait(false); + if (string.IsNullOrWhiteSpace(selected)) + return; + + if (string.Equals(target, "GamePath", StringComparison.OrdinalIgnoreCase)) + GamePath = selected; + else + Il2CppPath = selected; + } + + private async Task StartAnalysisAsync() + { + if (string.IsNullOrWhiteSpace(Il2CppPath) || !Directory.Exists(Il2CppPath)) + { + AddLog("✗ IL2CPP-Pfad ungültig."); + return; + } + + IsLoading = true; + Progress = 0; + + var progress = new Progress(message => + { + AddLog(message); + Progress = Math.Min(98, Progress + 1); + }); + + try + { + IReadOnlyList hooks = await _extractorService + .ExtractAsync(Il2CppPath, _classifier, progress, CancellationToken.None) + .ConfigureAwait(false); + + string hooksPath = Path.Combine(Directory.GetCurrentDirectory(), "game_hooks.json"); + await File.WriteAllTextAsync(hooksPath, JsonSerializer.Serialize(hooks, new JsonSerializerOptions { WriteIndented = true }), CancellationToken.None) + .ConfigureAwait(false); + + string unknownPath = Path.Combine(Directory.GetCurrentDirectory(), "unknown_hooks.json"); + HookDefinition[] unknown = hooks.Where(h => h.Group.Equals("Uncategorized", StringComparison.OrdinalIgnoreCase)).ToArray(); + await File.WriteAllTextAsync(unknownPath, JsonSerializer.Serialize(unknown, new JsonSerializerOptions { WriteIndented = true }), CancellationToken.None) + .ConfigureAwait(false); + + ApplyGroups(hooks); + Progress = 100; + AddLog($"✓ Analyse abgeschlossen. Hooks: {hooks.Count}"); + } + catch (Exception exception) + { + AddLog($"✗ Analyse fehlgeschlagen: {exception.Message}"); + } + finally + { + IsLoading = false; + OnPropertyChanged(nameof(TotalHooks)); + OnPropertyChanged(nameof(GroupedHooks)); + OnPropertyChanged(nameof(UnknownHooks)); + OnPropertyChanged(nameof(GroupCount)); + } + } + + private void ApplyGroups(IEnumerable hooks) + { + Dictionary grouped = hooks + .GroupBy(x => x.Group) + .ToDictionary(x => x.Key, x => x.ToArray(), StringComparer.OrdinalIgnoreCase); + + foreach (HookGroupViewModel group in HookGroups) + { + grouped.TryGetValue(group.GroupName, out HookDefinition[]? values); + group.ReplaceHooks(values ?? Array.Empty()); + } + + foreach ((string groupName, HookDefinition[] values) in grouped) + { + if (HookGroups.Any(x => x.GroupName.Equals(groupName, StringComparison.OrdinalIgnoreCase))) + continue; + + var vm = new HookGroupViewModel(groupName, "Detected at runtime") { IsSelected = true }; + vm.ReplaceHooks(values); + HookGroups.Add(vm); + } + } + + public void AddLog(string message) + { + string line = $"[{DateTime.Now:HH:mm:ss}] {message}"; + if (LogLines.Count > 1500) + LogLines.RemoveAt(0); + + LogLines.Add(line); + } +} diff --git a/GUI/ViewModels/HookBrowserViewModel.cs b/GUI/ViewModels/HookBrowserViewModel.cs new file mode 100644 index 0000000..97e0bc0 --- /dev/null +++ b/GUI/ViewModels/HookBrowserViewModel.cs @@ -0,0 +1,139 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using gregExtractor.Models; + +namespace gregExtractor.GUI.ViewModels; + +public sealed class HookBrowserRow +{ + public required string Group { get; init; } + public required string Namespace { get; init; } + public required string ClassName { get; init; } + public required string MethodName { get; init; } + public required string ReturnType { get; init; } + public required bool IsVoid { get; init; } + public required string Parameters { get; init; } +} + +public sealed class HookBrowserViewModel : ObservableObject +{ + private string _searchText = string.Empty; + private string _selectedGroup = "Alle"; + private bool _isLoading; + + public HookBrowserViewModel() + { + Groups.Add("Alle"); + RefreshCommand = new AsyncRelayCommand(RefreshAsync, () => !IsLoading); + } + + public string SearchText + { + get => _searchText; + set + { + if (!SetProperty(ref _searchText, value)) + return; + + ApplyFilter(); + } + } + + public string SelectedGroup + { + get => _selectedGroup; + set + { + if (!SetProperty(ref _selectedGroup, value)) + return; + + ApplyFilter(); + } + } + + public bool IsLoading + { + get => _isLoading; + set + { + if (!SetProperty(ref _isLoading, value)) + return; + + RefreshCommand.NotifyCanExecuteChanged(); + } + } + + public ObservableCollection Groups { get; } = new(); + + public ObservableCollection Rows { get; } = new(); + + public ObservableCollection FilteredRows { get; } = new(); + + public IAsyncRelayCommand RefreshCommand { get; } + + public async Task RefreshAsync() + { + IsLoading = true; + try + { + string hooksPath = Path.Combine(Directory.GetCurrentDirectory(), "game_hooks.json"); + if (!File.Exists(hooksPath)) + { + Rows.Clear(); + FilteredRows.Clear(); + return; + } + + string json = await File.ReadAllTextAsync(hooksPath, CancellationToken.None).ConfigureAwait(false); + HookDefinition[] hooks = JsonSerializer.Deserialize(json) ?? Array.Empty(); + + Rows.Clear(); + foreach (HookDefinition hook in hooks) + { + Rows.Add(new HookBrowserRow + { + Group = hook.Group, + Namespace = hook.Namespace, + ClassName = hook.ClassName, + MethodName = hook.MethodName, + ReturnType = hook.ReturnType, + IsVoid = hook.IsVoid, + Parameters = string.Join(", ", hook.Parameters.Select(p => $"{p.Type} {p.Name}")), + }); + } + + HashSet groups = hooks.Select(h => h.Group).ToHashSet(StringComparer.OrdinalIgnoreCase); + Groups.Clear(); + Groups.Add("Alle"); + foreach (string group in groups.OrderBy(x => x, StringComparer.OrdinalIgnoreCase)) + Groups.Add(group); + + ApplyFilter(); + } + finally + { + IsLoading = false; + } + } + + private void ApplyFilter() + { + IEnumerable query = Rows; + + if (!SelectedGroup.Equals("Alle", StringComparison.OrdinalIgnoreCase)) + query = query.Where(x => x.Group.Equals(SelectedGroup, StringComparison.OrdinalIgnoreCase)); + + if (!string.IsNullOrWhiteSpace(SearchText)) + { + query = query.Where(x => + x.MethodName.Contains(SearchText, StringComparison.OrdinalIgnoreCase) + || x.ClassName.Contains(SearchText, StringComparison.OrdinalIgnoreCase) + || x.Namespace.Contains(SearchText, StringComparison.OrdinalIgnoreCase) + || x.Group.Contains(SearchText, StringComparison.OrdinalIgnoreCase)); + } + + FilteredRows.Clear(); + foreach (HookBrowserRow row in query) + FilteredRows.Add(row); + } +} diff --git a/GUI/ViewModels/HookGroupViewModel.cs b/GUI/ViewModels/HookGroupViewModel.cs new file mode 100644 index 0000000..fdb7d72 --- /dev/null +++ b/GUI/ViewModels/HookGroupViewModel.cs @@ -0,0 +1,38 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using gregExtractor.Models; + +namespace gregExtractor.GUI.ViewModels; + +public sealed class HookGroupViewModel : ObservableObject +{ + private bool _isSelected = true; + + public HookGroupViewModel(string groupName, string description) + { + GroupName = groupName; + Description = description; + } + + public string GroupName { get; } + + public string Description { get; } + + public ObservableCollection Hooks { get; } = new(); + + public int Count => Hooks.Count; + + public bool IsSelected + { + get => _isSelected; + set => SetProperty(ref _isSelected, value); + } + + public void ReplaceHooks(IEnumerable hooks) + { + Hooks.Clear(); + foreach (HookDefinition hook in hooks) + Hooks.Add(hook); + + OnPropertyChanged(nameof(Count)); + } +} diff --git a/GUI/ViewModels/MainViewModel.cs b/GUI/ViewModels/MainViewModel.cs new file mode 100644 index 0000000..6892e0e --- /dev/null +++ b/GUI/ViewModels/MainViewModel.cs @@ -0,0 +1,107 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; + +namespace gregExtractor.GUI.ViewModels; + +public sealed class MainViewModel : ObservableObject +{ + private NavigationItemViewModel? _selectedNavigationItem; + private object? _currentPageViewModel; + private bool _isMaximized; + + public MainViewModel() + { + Extractor = new ExtractorViewModel(); + Coverage = new CoverageViewModel(); + Sync = new SyncViewModel(); + Create = new CreateViewModel(); + Hooks = new HookBrowserViewModel(); + Settings = new SettingsViewModel(); + + NavigationItems.Add(new NavigationItemViewModel("extractor", "Extractor", "🔍")); + NavigationItems.Add(new NavigationItemViewModel("coverage", "Coverage", "📊")); + NavigationItems.Add(new NavigationItemViewModel("sync", "Sync", "🔄")); + NavigationItems.Add(new NavigationItemViewModel("create", "Create", "⚡")); + NavigationItems.Add(new NavigationItemViewModel("hooks", "Hooks", "📋")); + NavigationItems.Add(new NavigationItemViewModel("settings", "Settings", "⚙")); + + SelectNavItemCommand = new RelayCommand(SelectNavItem); + + SelectNavItem(NavigationItems.FirstOrDefault()); + + _ = Hooks.RefreshAsync(); + } + + public ExtractorViewModel Extractor { get; } + + public CoverageViewModel Coverage { get; } + + public SyncViewModel Sync { get; } + + public CreateViewModel Create { get; } + + public HookBrowserViewModel Hooks { get; } + + public SettingsViewModel Settings { get; } + + public ObservableCollection NavigationItems { get; } = new(); + + public NavigationItemViewModel? SelectedNavigationItem + { + get => _selectedNavigationItem; + set => SetProperty(ref _selectedNavigationItem, value); + } + + public object? CurrentPageViewModel + { + get => _currentPageViewModel; + set => SetProperty(ref _currentPageViewModel, value); + } + + public string GamePathHint => string.IsNullOrWhiteSpace(Extractor.GamePath) ? "Kein Spiel gefunden" : Extractor.GamePath; + + public bool IsGameDetected => !string.IsNullOrWhiteSpace(Extractor.GamePath); + + public string VersionText => "v1.0.0"; + + public bool IsMaximized + { + get => _isMaximized; + set => SetProperty(ref _isMaximized, value); + } + + public IRelayCommand SelectNavItemCommand { get; } + + public void WireBrowseHandler(Func> browseHandler) + { + Extractor.BrowseFolderHandler = browseHandler; + Coverage.BrowseFolderHandler = browseHandler; + Sync.BrowseFolderHandler = browseHandler; + Create.BrowseFolderHandler = browseHandler; + Settings.BrowseFolderHandler = browseHandler; + } + + private void SelectNavItem(NavigationItemViewModel? item) + { + if (item is null) + return; + + foreach (NavigationItemViewModel navItem in NavigationItems) + navItem.IsSelected = ReferenceEquals(navItem, item); + + SelectedNavigationItem = item; + CurrentPageViewModel = item.Key switch + { + "extractor" => Extractor, + "coverage" => Coverage, + "sync" => Sync, + "create" => Create, + "hooks" => Hooks, + "settings" => Settings, + _ => Extractor, + }; + + OnPropertyChanged(nameof(GamePathHint)); + OnPropertyChanged(nameof(IsGameDetected)); + } +} diff --git a/GUI/ViewModels/NavigationItemViewModel.cs b/GUI/ViewModels/NavigationItemViewModel.cs new file mode 100644 index 0000000..3ebd57b --- /dev/null +++ b/GUI/ViewModels/NavigationItemViewModel.cs @@ -0,0 +1,22 @@ +using CommunityToolkit.Mvvm.ComponentModel; + +namespace gregExtractor.GUI.ViewModels; + +public sealed partial class NavigationItemViewModel : ObservableObject +{ + public NavigationItemViewModel(string key, string title, string icon) + { + Key = key; + Title = title; + Icon = icon; + } + + public string Key { get; } + + public string Title { get; } + + public string Icon { get; } + + [ObservableProperty] + private bool _isSelected; +} diff --git a/GUI/ViewModels/SettingsViewModel.cs b/GUI/ViewModels/SettingsViewModel.cs new file mode 100644 index 0000000..ba3688b --- /dev/null +++ b/GUI/ViewModels/SettingsViewModel.cs @@ -0,0 +1,149 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using gregExtractor.Utils; + +namespace gregExtractor.GUI.ViewModels; + +public sealed class SettingsViewModel : ObservableObject +{ + private string _gamePath = SteamLocator.FindGamePath() ?? string.Empty; + private string _frameworkSourcePath = Path.Combine(Directory.GetCurrentDirectory(), "..", "gregCore", "src"); + private string _modsOutputPath = Directory.GetCurrentDirectory(); + private bool _ignoreGettersSetters = true; + private bool _includePrivateMethods; + private bool _backupBeforeSync = true; + private bool _gitDiffAfterSync; + private bool _reduceAnimations; + private bool _monospaceEverywhere = true; + private bool _isLoading; + + public SettingsViewModel() + { + BrowseGamePathCommand = new AsyncRelayCommand(BrowseGamePathAsync); + BrowseFrameworkPathCommand = new AsyncRelayCommand(BrowseFrameworkPathAsync); + BrowseOutputPathCommand = new AsyncRelayCommand(BrowseOutputPathAsync); + AutoDetectGamePathCommand = new RelayCommand(() => GamePath = SteamLocator.FindGamePath() ?? string.Empty); + CopyDebugInfoCommand = new RelayCommand(CopyDebugInfo); + } + + public Func>? BrowseFolderHandler { get; set; } + + public string GamePath + { + get => _gamePath; + set => SetProperty(ref _gamePath, value); + } + + public string FrameworkSourcePath + { + get => _frameworkSourcePath; + set => SetProperty(ref _frameworkSourcePath, value); + } + + public string ModsOutputPath + { + get => _modsOutputPath; + set => SetProperty(ref _modsOutputPath, value); + } + + public bool IgnoreGettersSetters + { + get => _ignoreGettersSetters; + set => SetProperty(ref _ignoreGettersSetters, value); + } + + public bool IncludePrivateMethods + { + get => _includePrivateMethods; + set => SetProperty(ref _includePrivateMethods, value); + } + + public bool BackupBeforeSync + { + get => _backupBeforeSync; + set => SetProperty(ref _backupBeforeSync, value); + } + + public bool GitDiffAfterSync + { + get => _gitDiffAfterSync; + set => SetProperty(ref _gitDiffAfterSync, value); + } + + public bool ReduceAnimations + { + get => _reduceAnimations; + set => SetProperty(ref _reduceAnimations, value); + } + + public bool MonospaceEverywhere + { + get => _monospaceEverywhere; + set => SetProperty(ref _monospaceEverywhere, value); + } + + public bool IsLoading + { + get => _isLoading; + set => SetProperty(ref _isLoading, value); + } + + public IAsyncRelayCommand BrowseGamePathCommand { get; } + + public IAsyncRelayCommand BrowseFrameworkPathCommand { get; } + + public IAsyncRelayCommand BrowseOutputPathCommand { get; } + + public IRelayCommand AutoDetectGamePathCommand { get; } + + public IRelayCommand CopyDebugInfoCommand { get; } + + private async Task BrowseGamePathAsync() + { + if (BrowseFolderHandler is null) + return; + + string? selected = await BrowseFolderHandler("SettingsGamePath").ConfigureAwait(false); + if (!string.IsNullOrWhiteSpace(selected)) + GamePath = selected; + } + + private async Task BrowseFrameworkPathAsync() + { + if (BrowseFolderHandler is null) + return; + + string? selected = await BrowseFolderHandler("SettingsFrameworkPath").ConfigureAwait(false); + if (!string.IsNullOrWhiteSpace(selected)) + FrameworkSourcePath = selected; + } + + private async Task BrowseOutputPathAsync() + { + if (BrowseFolderHandler is null) + return; + + string? selected = await BrowseFolderHandler("SettingsOutputPath").ConfigureAwait(false); + if (!string.IsNullOrWhiteSpace(selected)) + ModsOutputPath = selected; + } + + private void CopyDebugInfo() + { + string content = + $"gregExtractor Debug Info{Environment.NewLine}" + + $"GamePath={GamePath}{Environment.NewLine}" + + $"FrameworkSourcePath={FrameworkSourcePath}{Environment.NewLine}" + + $"ModsOutputPath={ModsOutputPath}{Environment.NewLine}" + + $"OS={Environment.OSVersion}{Environment.NewLine}" + + $"Runtime={Environment.Version}"; + + try + { + File.WriteAllText(Path.Combine(Directory.GetCurrentDirectory(), "debug-info.txt"), content, Encoding.UTF8); + } + catch + { + } + } +} diff --git a/GUI/ViewModels/SyncViewModel.cs b/GUI/ViewModels/SyncViewModel.cs new file mode 100644 index 0000000..61cc2df --- /dev/null +++ b/GUI/ViewModels/SyncViewModel.cs @@ -0,0 +1,296 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using gregExtractor.Models; +using gregExtractor.Services; +using gregExtractor.Utils; + +namespace gregExtractor.GUI.ViewModels; + +public enum HookDiffKind +{ + Added, + Changed, + Removed, +} + +public sealed record HookDiffEntry( + HookDiffKind Kind, + string Group, + string ClassName, + string MethodName, + string Signature) +{ + public string Display => $"{ClassName}::{MethodName}"; +} + +public sealed class SyncViewModel : ObservableObject +{ + private readonly ExtractorService _extractorService = new(); + private readonly DiffService _diffService = new(); + private readonly FrameworkSyncService _syncService = new(); + + private string _frameworkSourcePath = string.Empty; + private bool _isDryRun = true; + private bool _useGitDiff; + private bool _isRunning; + private SyncResult? _lastResult; + private HookDiff? _lastDiff; + + public SyncViewModel() + { + FrameworkSourcePath = Path.Combine(Directory.GetCurrentDirectory(), "..", "gregCore", "src"); + + BrowseSourcePathCommand = new AsyncRelayCommand(BrowseSourcePathAsync); + CalculateDiffCommand = new AsyncRelayCommand(CalculateDiffAsync, () => !IsRunning); + RunSyncCommand = new AsyncRelayCommand(RunSyncAsync, () => !IsRunning); + OpenBackupFolderCommand = new RelayCommand(OpenBackupFolder, () => Directory.Exists(FrameworkSourcePath)); + } + + public Func>? BrowseFolderHandler { get; set; } + + public string FrameworkSourcePath + { + get => _frameworkSourcePath; + set => SetProperty(ref _frameworkSourcePath, value); + } + + public bool IsDryRun + { + get => _isDryRun; + set => SetProperty(ref _isDryRun, value); + } + + public bool UseGitDiff + { + get => _useGitDiff; + set => SetProperty(ref _useGitDiff, value); + } + + public ObservableCollection DiffEntries { get; } = new(); + + public ObservableCollection SyncLog { get; } = new(); + + public ObservableCollection Warnings { get; } = new(); + + public bool IsRunning + { + get => _isRunning; + set + { + if (!SetProperty(ref _isRunning, value)) + return; + + CalculateDiffCommand.NotifyCanExecuteChanged(); + RunSyncCommand.NotifyCanExecuteChanged(); + OnPropertyChanged(nameof(IsLoading)); + } + } + + public bool IsLoading => IsRunning; + + public SyncResult? LastResult + { + get => _lastResult; + set => SetProperty(ref _lastResult, value); + } + + public IAsyncRelayCommand BrowseSourcePathCommand { get; } + + public IAsyncRelayCommand CalculateDiffCommand { get; } + + public IAsyncRelayCommand RunSyncCommand { get; } + + public IRelayCommand OpenBackupFolderCommand { get; } + + private async Task BrowseSourcePathAsync() + { + if (BrowseFolderHandler is null) + return; + + string? selected = await BrowseFolderHandler("FrameworkSourcePath").ConfigureAwait(false); + if (string.IsNullOrWhiteSpace(selected)) + return; + + FrameworkSourcePath = selected; + } + + private async Task CalculateDiffAsync() + { + await ComputeDiffAsync(CancellationToken.None).ConfigureAwait(false); + } + + private async Task RunSyncAsync() + { + if (!Directory.Exists(FrameworkSourcePath)) + { + AddLog("✗ Framework-Source-Pfad ist ungültig."); + return; + } + + HookDiff diff = _lastDiff ?? await ComputeDiffAsync(CancellationToken.None).ConfigureAwait(false); + + if (diff.Added.Count == 0 && diff.Changed.Count == 0 && diff.Removed.Count == 0) + { + AddLog("= Keine Änderungen erkannt. Sync übersprungen."); + return; + } + + IsRunning = true; + Warnings.Clear(); + + try + { + HookDefinition[] allHooks = await LoadNewHooksAsync(CancellationToken.None).ConfigureAwait(false); + var progress = new Progress(AddLog); + + SyncResult result = _syncService.Sync(new SyncOptions( + FrameworkSourceDir: FrameworkSourcePath, + Diff: diff, + AllHooks: allHooks.ToList(), + DryRun: IsDryRun, + Progress: progress)); + + LastResult = result; + foreach (string warning in result.Warnings) + Warnings.Add(warning); + + AddLog($"✓ Sync abgeschlossen. Dateien: {result.FilesWritten.Count}, +{result.LinesAdded}/-{result.LinesRemoved} Zeilen"); + + if (!IsDryRun) + { + string hooksPath = Path.Combine(Directory.GetCurrentDirectory(), "game_hooks.json"); + await File.WriteAllTextAsync(hooksPath, JsonSerializer.Serialize(allHooks, new JsonSerializerOptions { WriteIndented = true }), CancellationToken.None).ConfigureAwait(false); + AddLog($"✓ game_hooks.json aktualisiert: {hooksPath}"); + } + + if (UseGitDiff) + await AppendGitDiffStatAsync(CancellationToken.None).ConfigureAwait(false); + } + catch (Exception exception) + { + AddLog($"✗ Sync fehlgeschlagen: {exception.Message}"); + } + finally + { + IsRunning = false; + } + } + + private async Task ComputeDiffAsync(CancellationToken cancellationToken) + { + IsRunning = true; + DiffEntries.Clear(); + Warnings.Clear(); + + try + { + HookDefinition[] oldHooks = await LoadOldHooksAsync(cancellationToken).ConfigureAwait(false); + HookDefinition[] newHooks = await LoadNewHooksAsync(cancellationToken).ConfigureAwait(false); + + HookDiff diff = _diffService.Diff(oldHooks.ToList(), newHooks.ToList()); + _lastDiff = diff; + + foreach (HookDefinition hook in diff.Added) + DiffEntries.Add(ToEntry(HookDiffKind.Added, hook)); + + foreach (HookDefinition hook in diff.Changed) + DiffEntries.Add(ToEntry(HookDiffKind.Changed, hook)); + + foreach (HookDefinition hook in diff.Removed) + DiffEntries.Add(ToEntry(HookDiffKind.Removed, hook)); + + AddLog($"Diff berechnet: +{diff.Added.Count} ~{diff.Changed.Count} -{diff.Removed.Count}"); + return diff; + } + catch (Exception exception) + { + AddLog($"✗ Diff-Berechnung fehlgeschlagen: {exception.Message}"); + return new HookDiff(Array.Empty(), Array.Empty(), Array.Empty()); + } + finally + { + IsRunning = false; + } + } + + private async Task LoadOldHooksAsync(CancellationToken cancellationToken) + { + string hooksPath = Path.Combine(Directory.GetCurrentDirectory(), "game_hooks.json"); + if (!File.Exists(hooksPath)) + return Array.Empty(); + + string json = await File.ReadAllTextAsync(hooksPath, cancellationToken).ConfigureAwait(false); + return JsonSerializer.Deserialize(json) ?? Array.Empty(); + } + + private async Task LoadNewHooksAsync(CancellationToken cancellationToken) + { + string groupsPath = Path.Combine(Directory.GetCurrentDirectory(), "hook_groups.json"); + HookClassifier classifier = HookClassifier.LoadFromFile(groupsPath, new Progress(AddLog)); + + string? il2CppPath = SteamLocator.FindIl2CppAssembliesPath(); + if (string.IsNullOrWhiteSpace(il2CppPath) || !Directory.Exists(il2CppPath)) + throw new DirectoryNotFoundException("IL2CPP-Pfad konnte nicht automatisch gefunden werden."); + + IReadOnlyList hooks = await _extractorService + .ExtractAsync(il2CppPath, classifier, new Progress(AddLog), cancellationToken) + .ConfigureAwait(false); + + return hooks.ToArray(); + } + + private async Task AppendGitDiffStatAsync(CancellationToken cancellationToken) + { + var process = new Process + { + StartInfo = new ProcessStartInfo + { + FileName = "git", + Arguments = "diff --stat", + WorkingDirectory = FrameworkSourcePath, + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true, + }, + }; + + process.Start(); + string output = await process.StandardOutput.ReadToEndAsync(cancellationToken).ConfigureAwait(false); + string error = await process.StandardError.ReadToEndAsync(cancellationToken).ConfigureAwait(false); + await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false); + + if (!string.IsNullOrWhiteSpace(output)) + AddLog(output.Trim()); + + if (process.ExitCode != 0 && !string.IsNullOrWhiteSpace(error)) + AddLog("⚠ " + error.Trim()); + } + + private void OpenBackupFolder() + { + if (!Directory.Exists(FrameworkSourcePath)) + return; + + Process.Start(new ProcessStartInfo + { + FileName = FrameworkSourcePath, + UseShellExecute = true, + }); + } + + private static HookDiffEntry ToEntry(HookDiffKind kind, HookDefinition hook) + { + string signature = $"{hook.ReturnType} {hook.MethodName}({string.Join(", ", hook.Parameters.Select(p => $"{p.Type} {p.Name}"))})"; + return new HookDiffEntry(kind, hook.Group, hook.ClassName, hook.MethodName, signature); + } + + private void AddLog(string message) + { + string line = $"[{DateTime.Now:HH:mm:ss}] {message}"; + if (SyncLog.Count > 1000) + SyncLog.RemoveAt(0); + + SyncLog.Add(line); + } +} diff --git a/GUI/Views/CoverageView.axaml b/GUI/Views/CoverageView.axaml new file mode 100644 index 0000000..d59f58b --- /dev/null +++ b/GUI/Views/CoverageView.axaml @@ -0,0 +1,62 @@ + + + + + + +