commit 7eb298f2fbbc47f8a9c3dba56fcb6bd9fc66cc1e Author: Marvin <52848568+mleem97@users.noreply.github.com> Date: Mon Apr 20 02:56:55 2026 +0200 feat: initial import of gregExtractor with sync command, redesigned UI, and wiki docs 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 @@ + + + + + + +