merge: bring master extractor import into main

This commit is contained in:
Marvin
2026-04-20 03:00:39 +02:00
129 changed files with 17639 additions and 0 deletions
+18
View File
@@ -86,3 +86,21 @@ dkms.conf
*.out
*.app
# ---> gregExtractor (tooling)
bin/
obj/
.vs/
*.user
*.suo
*.cache
state/
coverage_report.json
coverage_report.md
game_hooks.json
unknown_hooks.json
*.log
*.tmp
+147
View File
@@ -0,0 +1,147 @@
using gregExtractor.Models;
using gregExtractor.Services;
using gregExtractor.Utils;
namespace gregExtractor.Commands;
public sealed class CoverageCommand : AsyncCommand<CoverageCommand.Settings>
{
public sealed class Settings : CommandSettings
{
[CommandOption("--path <IL2CPP_PATH>")]
public string? Path { get; init; }
[CommandOption("--hooks <GAME_HOOKS_JSON>")]
public string? HooksPath { get; init; }
[CommandOption("--sources <SOURCE_DIRS>")]
public string? SourceDirs { get; init; }
[CommandOption("--out <OUTPUT_BASE_PATH>")]
public string? OutputBasePath { get; init; }
[CommandOption("--open")]
public bool OpenAfterGenerate { get; init; }
}
public override async Task<int> 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<string>("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 \"<dir1>;<dir2>\"[/]");
return -1;
}
var analyzer = new CoverageAnalyzerService();
var messages = new List<string>();
var progress = new Progress<string>(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;
}
}
+95
View File
@@ -0,0 +1,95 @@
using gregExtractor.Models;
using gregExtractor.Services;
namespace gregExtractor.Commands;
public sealed class CreateCommand : AsyncCommand<CreateCommand.Settings>
{
public sealed class Settings : CommandSettings
{
[CommandArgument(0, "<MOD_NAME>")]
public string ModName { get; init; } = string.Empty;
[CommandOption("--type <TYPE>")]
[DefaultValue("harmonyPatch")]
public string Type { get; init; } = "harmonyPatch";
[CommandOption("--category <CATEGORY>")]
public string? Category { get; init; }
[CommandOption("--path <OUTPUT_PATH>")]
public string? Path { get; init; }
}
public override async Task<int> 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<string>();
var progress = new Progress<string>(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;
}
}
+86
View File
@@ -0,0 +1,86 @@
using gregExtractor.Models;
using gregExtractor.Services;
using gregExtractor.Utils;
namespace gregExtractor.Commands;
public sealed class ExtractCommand : AsyncCommand<ExtractCommand.Settings>
{
public sealed class Settings : CommandSettings
{
[CommandOption("--path <IL2CPP_PATH>")]
public string? Path { get; init; }
}
public override async Task<int> 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<string>();
var progress = new Progress<string>(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<string>("Enter IL2CPP directory path:");
}
if (string.IsNullOrWhiteSpace(il2CppPath) || !Directory.Exists(il2CppPath))
{
AnsiConsole.MarkupLine("[red]Invalid IL2CPP path.[/]");
return -1;
}
IReadOnlyList<HookDefinition> hooks = Array.Empty<HookDefinition>();
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<string, HookDefinition> 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;
}
}
+179
View File
@@ -0,0 +1,179 @@
using gregExtractor.Models;
using gregExtractor.Services;
using gregExtractor.Utils;
namespace gregExtractor.Commands;
public sealed class SyncCommand : AsyncCommand<SyncCommand.Settings>
{
public sealed class Settings : CommandSettings
{
[CommandOption("--source <FRAMEWORK_SOURCE_DIR>")]
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<int> 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<HookDefinition[]>(await File.ReadAllTextAsync(hooksPath).ConfigureAwait(false))
?? Array.Empty<HookDefinition>();
HookClassifier classifier = HookClassifier.LoadFromFile(groupsPath, new Progress<string>(_ => { }));
string? il2CppPath = SteamLocator.FindIl2CppAssembliesPath();
if (string.IsNullOrWhiteSpace(il2CppPath) || !Directory.Exists(il2CppPath))
{
AnsiConsole.MarkupLine("[yellow]IL2CPP-Pfad konnte nicht automatisch gefunden werden.[/]");
il2CppPath = AnsiConsole.Ask<string>("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<string>();
var progress = new Progress<string>(line => logLines.Add(line));
IReadOnlyList<HookDefinition> newHooks = Array.Empty<HookDefinition>();
HookDiff diff = new(Array.Empty<HookDefinition>(), Array.Empty<HookDefinition>(), Array.Empty<HookDefinition>());
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<string> 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;
}
}
+217
View File
@@ -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);
}
}
+25
View File
@@ -0,0 +1,25 @@
<Application xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="gregExtractor.GUI.App">
<Application.Resources>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<ResourceInclude Source="avares://gregExtractor/GUI/Styles/GregColors.axaml" />
</ResourceDictionary.MergedDictionaries>
</ResourceDictionary>
</Application.Resources>
<Application.Styles>
<FluentTheme />
<StyleInclude Source="avares://gregExtractor/GUI/Styles/GregAnimations.axaml" />
<StyleInclude Source="avares://gregExtractor/GUI/Styles/GregStyles.axaml" />
<StyleInclude Source="avares://gregExtractor/GUI/Controls/GregButton.axaml" />
<StyleInclude Source="avares://gregExtractor/GUI/Controls/GregInput.axaml" />
<StyleInclude Source="avares://gregExtractor/GUI/Controls/GregCard.axaml" />
<StyleInclude Source="avares://gregExtractor/GUI/Controls/GregProgressBar.axaml" />
<StyleInclude Source="avares://gregExtractor/GUI/Controls/StatusBadge.axaml" />
<StyleInclude Source="avares://gregExtractor/GUI/Controls/GregTag.axaml" />
<StyleInclude Source="avares://gregExtractor/GUI/Controls/NavItem.axaml" />
<StyleInclude Source="avares://gregExtractor/GUI/Controls/StatCard.axaml" />
</Application.Styles>
</Application>
+28
View File
@@ -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();
}
}
+6
View File
@@ -0,0 +1,6 @@
<Styles xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<Style Selector="Button.greg-primary"/>
<Style Selector="Button.greg-secondary"/>
<Style Selector="Button.greg-danger"/>
</Styles>
+4
View File
@@ -0,0 +1,4 @@
<Styles xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<Style Selector="Border.greg-card"/>
</Styles>
+4
View File
@@ -0,0 +1,4 @@
<Styles xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<Style Selector="TextBox.greg-input"/>
</Styles>
+4
View File
@@ -0,0 +1,4 @@
<Styles xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<Style Selector="ProgressBar.greg-progress"/>
</Styles>
+8
View File
@@ -0,0 +1,8 @@
<Styles xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<Style Selector="Border.greg-tag">
<Setter Property="Background" Value="{DynamicResource GregBrushAccentCyanDim}"/>
<Setter Property="CornerRadius" Value="20"/>
<Setter Property="Padding" Value="10,2"/>
</Style>
</Styles>
+4
View File
@@ -0,0 +1,4 @@
<Styles xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<Style Selector="Button.greg-nav"/>
</Styles>
+10
View File
@@ -0,0 +1,10 @@
<Styles xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<Style Selector="Border.greg-stat-card">
<Setter Property="Background" Value="{DynamicResource GregBrushBgSurface}"/>
<Setter Property="BorderBrush" Value="{DynamicResource GregBrushBorder}"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="CornerRadius" Value="10"/>
<Setter Property="Padding" Value="12"/>
</Style>
</Styles>
+6
View File
@@ -0,0 +1,6 @@
<Styles xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<Style Selector="Border.badge-covered"/>
<Style Selector="Border.badge-planned"/>
<Style Selector="Border.badge-uncovered"/>
</Styles>
+11
View File
@@ -0,0 +1,11 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="gregExtractor.GUI.Controls.Toast">
<Border Classes="greg-card" Width="320" Margin="0,0,0,8">
<StackPanel Spacing="6">
<TextBlock Text="{Binding Title}" FontWeight="SemiBold"/>
<TextBlock Text="{Binding Message}" Classes="greg-secondary" TextWrapping="Wrap"/>
<ProgressBar Value="{Binding RemainingPercent}" Maximum="100" Classes="greg-progress"/>
</StackPanel>
</Border>
</UserControl>
+11
View File
@@ -0,0 +1,11 @@
using Avalonia.Controls;
namespace gregExtractor.GUI.Controls;
public partial class Toast : UserControl
{
public Toast()
{
InitializeComponent();
}
}
+12
View File
@@ -0,0 +1,12 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:controls="clr-namespace:gregExtractor.GUI.Controls"
x:Class="gregExtractor.GUI.Controls.ToastHost">
<ItemsControl ItemsSource="{Binding Toasts}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<controls:Toast/>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</UserControl>
+11
View File
@@ -0,0 +1,11 @@
using Avalonia.Controls;
namespace gregExtractor.GUI.Controls;
public partial class ToastHost : UserControl
{
public ToastHost()
{
InitializeComponent();
}
}
+93
View File
@@ -0,0 +1,93 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:models="clr-namespace:gregExtractor.Models"
mc:Ignorable="d"
x:Class="gregExtractor.GUI.CoverageView">
<Grid RowDefinitions="Auto,Auto,Auto,*,Auto" Margin="8">
<Border Grid.Row="0" Background="#202020" Padding="10" CornerRadius="8">
<Grid ColumnDefinitions="Auto,*,Auto,Auto">
<TextBlock Grid.Column="0" VerticalAlignment="Center" Text="IL2CPP Path" Foreground="#9AA0A6" Margin="0,0,8,0"/>
<TextBox Grid.Column="1" Text="{Binding Il2CppPath}"/>
<Button Grid.Column="2" Content="🔍 Auto" Command="{Binding AutoDetectPathCommand}" Margin="8,0,0,0"/>
<Button Grid.Column="3" Content="Browse" Command="{Binding BrowseFolderCommand}" CommandParameter="Il2Cpp" Margin="8,0,0,0"/>
</Grid>
</Border>
<Border Grid.Row="1" Background="#202020" Padding="10" CornerRadius="8" Margin="0,8,0,0">
<Grid ColumnDefinitions="Auto,*,Auto">
<TextBlock Grid.Column="0" VerticalAlignment="Center" Text="Hooks JSON" Foreground="#9AA0A6" Margin="0,0,8,0"/>
<TextBox Grid.Column="1" Text="{Binding GameHooksPath}"/>
<TextBlock Grid.Column="2" VerticalAlignment="Center" Text="(set full path manually)" Foreground="#6F7781" Margin="8,0,0,0"/>
</Grid>
</Border>
<Border Grid.Row="2" Background="#202020" Padding="10" CornerRadius="8" Margin="0,8,0,8">
<Grid RowDefinitions="Auto,Auto" ColumnDefinitions="Auto,*,Auto">
<TextBlock Grid.Row="0" Grid.Column="0" VerticalAlignment="Center" Text="Source Dirs" Foreground="#9AA0A6" Margin="0,0,8,0"/>
<TextBox Grid.Row="0" Grid.Column="1" Text="{Binding FrameworkSourceDirsText}"/>
<Button Grid.Row="0" Grid.Column="2" Content="Browse" Command="{Binding BrowseFolderCommand}" CommandParameter="SourceDirs" Margin="8,0,0,0"/>
<TextBlock Grid.Row="1" Grid.Column="0" VerticalAlignment="Center" Text="Output Base" Foreground="#9AA0A6" Margin="0,8,8,0"/>
<TextBox Grid.Row="1" Grid.Column="1" Text="{Binding OutputBasePath}" Margin="0,8,0,0"/>
<Button Grid.Row="1" Grid.Column="2" Content="Browse" Command="{Binding BrowseFolderCommand}" CommandParameter="Output" Margin="8,8,0,0"/>
</Grid>
</Border>
<Grid Grid.Row="3" ColumnDefinitions="320,*">
<Border Grid.Column="0" Background="#202020" Padding="10" CornerRadius="8">
<StackPanel Spacing="8">
<Button Content="📊 Analyze Coverage" Command="{Binding AnalyzeCoverageCommand}" Height="36"/>
<Button Content="📄 Open Markdown Report" Command="{Binding OpenMarkdownReportCommand}" Height="32"/>
<Separator />
<TextBlock Text="SUMMARY" FontWeight="SemiBold" Foreground="#2FD3FF"/>
<TextBlock Text="{Binding TotalHooks, StringFormat='Total: {0}'}"/>
<TextBlock Text="{Binding CoveredCount, StringFormat='Covered: {0}'}"/>
<TextBlock Text="{Binding PlannedCount, StringFormat='Planned: {0}'}"/>
<TextBlock Text="{Binding UncoveredCount, StringFormat='Uncovered: {0}'}"/>
<TextBlock Text="{Binding CoveragePercent, StringFormat='Coverage: {0:F2}%'}"/>
<Separator />
<TextBlock Text="LOG" FontWeight="SemiBold" Foreground="#2FD3FF"/>
<Border Background="#171717" CornerRadius="6" Padding="6" Height="280">
<ListBox ItemsSource="{Binding LogLines}" FontFamily="Cascadia Code, Consolas, monospace"/>
</Border>
</StackPanel>
</Border>
<Border Grid.Column="1" Background="#202020" Padding="10" CornerRadius="8">
<Grid RowDefinitions="Auto,*">
<TextBlock Grid.Row="0" Text="HOOK COVERAGE ENTRIES" FontWeight="SemiBold" Foreground="#2FD3FF"/>
<Border Grid.Row="1" Background="#171717" CornerRadius="6" Padding="8" Margin="0,8,0,0">
<ScrollViewer>
<ItemsControl ItemsSource="{Binding Entries}">
<ItemsControl.ItemTemplate>
<DataTemplate DataType="{x:Type models:HookCoverageEntry}">
<Border BorderBrush="#2B2B2B" BorderThickness="0,0,0,1" Padding="0,6">
<Grid ColumnDefinitions="80,140,200,*,*">
<TextBlock Grid.Column="0" Text="{Binding CoverageStatus}" Foreground="#2FD3FF"/>
<TextBlock Grid.Column="1" Text="{Binding HookDefinition.Group}"/>
<TextBlock Grid.Column="2" Text="{Binding HookDefinition.ClassName}"/>
<TextBlock Grid.Column="3" Text="{Binding HookDefinition.MethodName}"/>
<TextBlock Grid.Column="4" Text="{Binding FoundInFiles.Count, StringFormat='Files: {0}'}" Foreground="#9AA0A6"/>
</Grid>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
</Border>
</Grid>
</Border>
</Grid>
<Grid Grid.Row="4" ColumnDefinitions="Auto,*" Margin="0,8,0,0">
<TextBlock Grid.Column="0" Text="Progress" VerticalAlignment="Center" Foreground="#9AA0A6"/>
<ProgressBar Grid.Column="1" Minimum="0" Maximum="100" Value="{Binding Progress}" Height="16" Margin="8,0,0,0"/>
</Grid>
</Grid>
</UserControl>
+11
View File
@@ -0,0 +1,11 @@
using Avalonia.Controls;
namespace gregExtractor.GUI;
public partial class CoverageView : UserControl
{
public CoverageView()
{
InitializeComponent();
}
}
+79
View File
@@ -0,0 +1,79 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:vm="clr-namespace:gregExtractor.GUI.ViewModels"
xmlns:views="clr-namespace:gregExtractor.GUI.Views"
mc:Ignorable="d"
x:Class="gregExtractor.GUI.MainWindow"
Width="1540"
Height="940"
MinWidth="1280"
MinHeight="820"
Title="gregExtractor">
<Window.DataTemplates>
<DataTemplate DataType="vm:ExtractorViewModel">
<views:ExtractorView />
</DataTemplate>
<DataTemplate DataType="vm:CoverageViewModel">
<views:CoverageView />
</DataTemplate>
<DataTemplate DataType="vm:SyncViewModel">
<views:SyncView />
</DataTemplate>
<DataTemplate DataType="vm:CreateViewModel">
<views:CreateView />
</DataTemplate>
<DataTemplate DataType="vm:HookBrowserViewModel">
<views:HookBrowserView />
</DataTemplate>
<DataTemplate DataType="vm:SettingsViewModel">
<views:SettingsView />
</DataTemplate>
</Window.DataTemplates>
<Grid RowDefinitions="Auto,*" Margin="12">
<Border Grid.Row="0" Classes="greg-card" Padding="8" Margin="0,0,0,12">
<Grid ColumnDefinitions="Auto,*,Auto,Auto,Auto">
<TextBlock Text="gregExtractor" FontSize="18" FontWeight="SemiBold" VerticalAlignment="Center"/>
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="8" VerticalAlignment="Center">
<Border Classes="badge-covered">
<TextBlock Text="Game" FontSize="11"/>
</Border>
<TextBlock Text="{Binding GamePathHint}" Classes="greg-secondary"/>
</StackPanel>
<TextBlock Grid.Column="2" Text="{Binding VersionText}" VerticalAlignment="Center" Classes="greg-secondary"/>
<Button Grid.Column="3" Content="🗕" Classes="greg-secondary" Click="OnMinimizeClicked" Width="36"/>
<Button Grid.Column="4" Content="✕" Classes="greg-danger" Click="OnCloseClicked" Width="36"/>
</Grid>
</Border>
<Grid Grid.Row="1" ColumnDefinitions="250,*">
<Border Grid.Column="0" Classes="greg-card">
<Grid RowDefinitions="Auto,*,Auto">
<TextBlock Text="Navigation" FontSize="16" FontWeight="SemiBold"/>
<ItemsControl Grid.Row="1" ItemsSource="{Binding NavigationItems}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<Button Classes="greg-nav"
Margin="0,0,0,6"
Command="{Binding DataContext.SelectNavItemCommand, RelativeSource={RelativeSource AncestorType=Window}}"
CommandParameter="{Binding}">
<Grid ColumnDefinitions="Auto,*">
<TextBlock Text="{Binding Icon}"/>
<TextBlock Grid.Column="1" Text="{Binding Title}"/>
</Grid>
</Button>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<TextBlock Grid.Row="2" Text="FrikaMF / GregFramework" Classes="greg-secondary" FontSize="11"/>
</Grid>
</Border>
<Border Grid.Column="1" Classes="greg-card" Padding="12">
<ContentControl Content="{Binding CurrentPageViewModel}"/>
</Border>
</Grid>
</Grid>
</Window>
+51
View File
@@ -0,0 +1,51 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Platform.Storage;
using gregExtractor.GUI.ViewModels;
namespace gregExtractor.GUI;
public partial class MainWindow : Window
{
public MainWindow()
{
InitializeComponent();
DataContextChanged += OnDataContextChanged;
WireDataContext();
}
private void OnDataContextChanged(object? sender, EventArgs eventArgs)
{
WireDataContext();
}
private void WireDataContext()
{
if (DataContext is MainViewModel viewModel)
viewModel.WireBrowseHandler(BrowseFolderAsync);
}
private async Task<string?> BrowseFolderAsync(string? target)
{
IReadOnlyList<IStorageFolder> folders = await StorageProvider.OpenFolderPickerAsync(new FolderPickerOpenOptions
{
AllowMultiple = false,
Title = string.IsNullOrWhiteSpace(target) ? "Select folder" : $"Select folder for {target}",
});
if (folders.Count == 0)
return null;
return folders[0].Path.LocalPath;
}
private void OnMinimizeClicked(object? sender, RoutedEventArgs e)
{
WindowState = WindowState.Minimized;
}
private void OnCloseClicked(object? sender, RoutedEventArgs e)
{
Close();
}
}
+3
View File
@@ -0,0 +1,3 @@
<Styles xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
</Styles>
+49
View File
@@ -0,0 +1,49 @@
<ResourceDictionary xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<Color x:Key="GregColorBgBase">#0d0f14</Color>
<Color x:Key="GregColorBgSurface">#151820</Color>
<Color x:Key="GregColorBgElevated">#1c2030</Color>
<Color x:Key="GregColorBgInput">#111318</Color>
<Color x:Key="GregColorBorder">#2a2f3f</Color>
<Color x:Key="GregColorBorderFocus">#4a90d9</Color>
<Color x:Key="GregColorAccentCyan">#00d4ff</Color>
<Color x:Key="GregColorAccentCyanDim">#005f72</Color>
<Color x:Key="GregColorAccentGreen">#00ff88</Color>
<Color x:Key="GregColorAccentYellow">#ffb800</Color>
<Color x:Key="GregColorAccentRed">#ff4455</Color>
<Color x:Key="GregColorAccentPurple">#a855f7</Color>
<Color x:Key="GregColorTextPrimary">#e8eaf0</Color>
<Color x:Key="GregColorTextSecondary">#8892a4</Color>
<Color x:Key="GregColorTextDisabled">#404858</Color>
<SolidColorBrush x:Key="GregBrushBgBase" Color="{DynamicResource GregColorBgBase}"/>
<SolidColorBrush x:Key="GregBrushBgSurface" Color="{DynamicResource GregColorBgSurface}"/>
<SolidColorBrush x:Key="GregBrushBgElevated" Color="{DynamicResource GregColorBgElevated}"/>
<SolidColorBrush x:Key="GregBrushBgInput" Color="{DynamicResource GregColorBgInput}"/>
<SolidColorBrush x:Key="GregBrushBorder" Color="{DynamicResource GregColorBorder}"/>
<SolidColorBrush x:Key="GregBrushBorderFocus" Color="{DynamicResource GregColorBorderFocus}"/>
<SolidColorBrush x:Key="GregBrushAccentCyan" Color="{DynamicResource GregColorAccentCyan}"/>
<SolidColorBrush x:Key="GregBrushAccentCyanDim" Color="{DynamicResource GregColorAccentCyanDim}"/>
<SolidColorBrush x:Key="GregBrushAccentGreen" Color="{DynamicResource GregColorAccentGreen}"/>
<SolidColorBrush x:Key="GregBrushAccentYellow" Color="{DynamicResource GregColorAccentYellow}"/>
<SolidColorBrush x:Key="GregBrushAccentRed" Color="{DynamicResource GregColorAccentRed}"/>
<SolidColorBrush x:Key="GregBrushAccentPurple" Color="{DynamicResource GregColorAccentPurple}"/>
<SolidColorBrush x:Key="GregBrushTextPrimary" Color="{DynamicResource GregColorTextPrimary}"/>
<SolidColorBrush x:Key="GregBrushTextSecondary" Color="{DynamicResource GregColorTextSecondary}"/>
<SolidColorBrush x:Key="GregBrushTextDisabled" Color="{DynamicResource GregColorTextDisabled}"/>
<x:Double x:Key="GregFontXs">11</x:Double>
<x:Double x:Key="GregFontSm">12</x:Double>
<x:Double x:Key="GregFontMd">13</x:Double>
<x:Double x:Key="GregFontLg">15</x:Double>
<x:Double x:Key="GregFontXl">18</x:Double>
<x:Double x:Key="GregFont2Xl">24</x:Double>
<Thickness x:Key="GregSpacingXs">4</Thickness>
<Thickness x:Key="GregSpacingSm">8</Thickness>
<Thickness x:Key="GregSpacingMd">16</Thickness>
<Thickness x:Key="GregSpacingLg">24</Thickness>
<Thickness x:Key="GregSpacingXl">32</Thickness>
</ResourceDictionary>
+139
View File
@@ -0,0 +1,139 @@
<Styles xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<Style Selector="Window">
<Setter Property="Background" Value="{DynamicResource GregBrushBgBase}"/>
<Setter Property="Foreground" Value="{DynamicResource GregBrushTextPrimary}"/>
<Setter Property="FontFamily" Value="JetBrains Mono, Cascadia Code, Consolas"/>
<Setter Property="FontSize" Value="{DynamicResource GregFontMd}"/>
</Style>
<Style Selector="Border.greg-card">
<Setter Property="Background" Value="{DynamicResource GregBrushBgSurface}"/>
<Setter Property="BorderBrush" Value="{DynamicResource GregBrushBorder}"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="CornerRadius" Value="10"/>
<Setter Property="Padding" Value="16"/>
</Style>
<Style Selector="TextBox.greg-input">
<Setter Property="Background" Value="{DynamicResource GregBrushBgInput}"/>
<Setter Property="BorderBrush" Value="{DynamicResource GregBrushBorder}"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="CornerRadius" Value="6"/>
<Setter Property="Height" Value="36"/>
<Setter Property="Padding" Value="12,0"/>
<Setter Property="Foreground" Value="{DynamicResource GregBrushTextPrimary}"/>
</Style>
<Style Selector="Button.greg-primary">
<Setter Property="Height" Value="36"/>
<Setter Property="Padding" Value="12,6"/>
<Setter Property="Background" Value="{DynamicResource GregBrushAccentCyan}"/>
<Setter Property="Foreground" Value="#0d0f14"/>
<Setter Property="CornerRadius" Value="6"/>
<Setter Property="FontWeight" Value="SemiBold"/>
</Style>
<Style Selector="Button.greg-secondary">
<Setter Property="Height" Value="36"/>
<Setter Property="Padding" Value="12,6"/>
<Setter Property="Background" Value="Transparent"/>
<Setter Property="BorderBrush" Value="{DynamicResource GregBrushBorder}"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="CornerRadius" Value="6"/>
<Setter Property="Foreground" Value="{DynamicResource GregBrushTextPrimary}"/>
</Style>
<Style Selector="Button.greg-danger">
<Setter Property="Height" Value="36"/>
<Setter Property="Padding" Value="12,6"/>
<Setter Property="Background" Value="Transparent"/>
<Setter Property="BorderBrush" Value="{DynamicResource GregBrushAccentRed}"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="CornerRadius" Value="6"/>
<Setter Property="Foreground" Value="{DynamicResource GregBrushAccentRed}"/>
</Style>
<Style Selector="Button.greg-nav">
<Setter Property="Height" Value="44"/>
<Setter Property="Padding" Value="16,0"/>
<Setter Property="Background" Value="Transparent"/>
<Setter Property="Foreground" Value="{DynamicResource GregBrushTextSecondary}"/>
<Setter Property="HorizontalContentAlignment" Value="Stretch"/>
<Setter Property="BorderThickness" Value="0"/>
</Style>
<Style Selector="Button.greg-nav:pointerover">
<Setter Property="Background" Value="{DynamicResource GregBrushBgElevated}"/>
</Style>
<Style Selector="Button.greg-nav.active">
<Setter Property="Background" Value="{DynamicResource GregBrushBgElevated}"/>
<Setter Property="Foreground" Value="{DynamicResource GregBrushTextPrimary}"/>
<Setter Property="BorderBrush" Value="{DynamicResource GregBrushAccentCyan}"/>
<Setter Property="BorderThickness" Value="3,0,0,0"/>
</Style>
<Style Selector="ProgressBar.greg-progress">
<Setter Property="Height" Value="6"/>
<Setter Property="Background" Value="{DynamicResource GregBrushBgElevated}"/>
<Setter Property="Foreground" Value="{DynamicResource GregBrushAccentCyan}"/>
</Style>
<Style Selector="DataGrid.greg-grid">
<Setter Property="Background" Value="Transparent"/>
<Setter Property="BorderThickness" Value="0"/>
<Setter Property="Foreground" Value="{DynamicResource GregBrushTextPrimary}"/>
<Setter Property="GridLinesVisibility" Value="Horizontal"/>
</Style>
<Style Selector="TextBlock.greg-section-header">
<Setter Property="FontSize" Value="{DynamicResource GregFontLg}"/>
<Setter Property="FontWeight" Value="SemiBold"/>
<Setter Property="Foreground" Value="{DynamicResource GregBrushTextPrimary}"/>
</Style>
<Style Selector="TextBlock.greg-secondary">
<Setter Property="Foreground" Value="{DynamicResource GregBrushTextSecondary}"/>
<Setter Property="FontSize" Value="{DynamicResource GregFontSm}"/>
</Style>
<Style Selector="Border.badge-covered">
<Setter Property="Background" Value="#1E1A3D2A"/>
<Setter Property="BorderBrush" Value="#6600ff88"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="CornerRadius" Value="20"/>
<Setter Property="Padding" Value="10,2"/>
</Style>
<Style Selector="Border.badge-planned">
<Setter Property="Background" Value="#1Effb800"/>
<Setter Property="BorderBrush" Value="#66ffb800"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="CornerRadius" Value="20"/>
<Setter Property="Padding" Value="10,2"/>
</Style>
<Style Selector="Border.badge-uncovered">
<Setter Property="Background" Value="#1Eff4455"/>
<Setter Property="BorderBrush" Value="#66ff4455"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="CornerRadius" Value="20"/>
<Setter Property="Padding" Value="10,2"/>
</Style>
<Style Selector="Border.diff-added">
<Setter Property="Background" Value="#1a3d1a"/>
<Setter Property="CornerRadius" Value="6"/>
</Style>
<Style Selector="Border.diff-changed">
<Setter Property="Background" Value="#3d3000"/>
<Setter Property="CornerRadius" Value="6"/>
</Style>
<Style Selector="Border.diff-removed">
<Setter Property="Background" Value="#3d0000"/>
<Setter Property="CornerRadius" Value="6"/>
</Style>
</Styles>
+247
View File
@@ -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<string?>(BrowseFolderAsync);
AnalyzeCoverageCommand = new AsyncRelayCommand(AnalyzeAsync, () => !IsAnalyzing);
OpenMarkdownReportCommand = new RelayCommand(OpenMarkdownReport, () => File.Exists(CoverageAnalyzerService.GetReportPaths(OutputBasePath).MarkdownPath));
AutoDetectPath();
}
public Func<string?, Task<string?>>? 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<HookCoverageEntry> Entries { get; } = new();
public ObservableCollection<string> LogLines { get; } = new();
public IRelayCommand AutoDetectPathCommand { get; }
public IAsyncRelayCommand<string?> 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<string>(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);
}
}
+221
View File
@@ -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<string>(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<TemplateType>();
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<string?, Task<string?>>? 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<TemplateType> TemplateTypes { get; }
public ObservableCollection<string> Categories { get; } = new();
public ObservableCollection<string> PreviewTree { get; } = new();
public ObservableCollection<string> 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<string>(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);
}
}
+191
View File
@@ -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<string>(AddLog));
foreach ((string groupName, HookGroupConfig config) in _classifier.Groups)
HookGroups.Add(new HookGroupViewModel(groupName, config.Description));
AutoDetectPathCommand = new RelayCommand(AutoDetectPaths);
BrowsePathCommand = new AsyncRelayCommand<string?>(BrowsePathAsync);
StartAnalysisCommand = new AsyncRelayCommand(StartAnalysisAsync, () => !IsLoading);
AutoDetectPaths();
}
public Func<string?, Task<string?>>? 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<HookGroupViewModel> HookGroups { get; } = new();
public ObservableCollection<string> 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<string?> 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<string>(message =>
{
AddLog(message);
Progress = Math.Min(98, Progress + 1);
});
try
{
IReadOnlyList<HookDefinition> 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<HookDefinition> hooks)
{
Dictionary<string, HookDefinition[]> 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<HookDefinition>());
}
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);
}
}
+139
View File
@@ -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<string> Groups { get; } = new();
public ObservableCollection<HookBrowserRow> Rows { get; } = new();
public ObservableCollection<HookBrowserRow> 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<HookDefinition[]>(json) ?? Array.Empty<HookDefinition>();
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<string> 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<HookBrowserRow> 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);
}
}
+38
View File
@@ -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<HookDefinition> Hooks { get; } = new();
public int Count => Hooks.Count;
public bool IsSelected
{
get => _isSelected;
set => SetProperty(ref _isSelected, value);
}
public void ReplaceHooks(IEnumerable<HookDefinition> hooks)
{
Hooks.Clear();
foreach (HookDefinition hook in hooks)
Hooks.Add(hook);
OnPropertyChanged(nameof(Count));
}
}
+107
View File
@@ -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<NavigationItemViewModel?>(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<NavigationItemViewModel> 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<NavigationItemViewModel?> SelectNavItemCommand { get; }
public void WireBrowseHandler(Func<string?, Task<string?>> 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));
}
}
+22
View File
@@ -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;
}
+149
View File
@@ -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<string?, Task<string?>>? 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
{
}
}
}
+296
View File
@@ -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<string?, Task<string?>>? 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<HookDiffEntry> DiffEntries { get; } = new();
public ObservableCollection<string> SyncLog { get; } = new();
public ObservableCollection<string> 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<string>(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<HookDiff> 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<HookDefinition>(), Array.Empty<HookDefinition>(), Array.Empty<HookDefinition>());
}
finally
{
IsRunning = false;
}
}
private async Task<HookDefinition[]> LoadOldHooksAsync(CancellationToken cancellationToken)
{
string hooksPath = Path.Combine(Directory.GetCurrentDirectory(), "game_hooks.json");
if (!File.Exists(hooksPath))
return Array.Empty<HookDefinition>();
string json = await File.ReadAllTextAsync(hooksPath, cancellationToken).ConfigureAwait(false);
return JsonSerializer.Deserialize<HookDefinition[]>(json) ?? Array.Empty<HookDefinition>();
}
private async Task<HookDefinition[]> LoadNewHooksAsync(CancellationToken cancellationToken)
{
string groupsPath = Path.Combine(Directory.GetCurrentDirectory(), "hook_groups.json");
HookClassifier classifier = HookClassifier.LoadFromFile(groupsPath, new Progress<string>(AddLog));
string? il2CppPath = SteamLocator.FindIl2CppAssembliesPath();
if (string.IsNullOrWhiteSpace(il2CppPath) || !Directory.Exists(il2CppPath))
throw new DirectoryNotFoundException("IL2CPP-Pfad konnte nicht automatisch gefunden werden.");
IReadOnlyList<HookDefinition> hooks = await _extractorService
.ExtractAsync(il2CppPath, classifier, new Progress<string>(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);
}
}
+62
View File
@@ -0,0 +1,62 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:gregExtractor.GUI.ViewModels"
x:Class="gregExtractor.GUI.Views.CoverageView">
<Grid RowDefinitions="Auto,Auto,*,Auto">
<Border Classes="greg-card" Grid.Row="0">
<Grid ColumnDefinitions="Auto,*,Auto,Auto">
<TextBlock Text="IL2CPP" VerticalAlignment="Center" Classes="greg-secondary"/>
<TextBox Grid.Column="1" Text="{Binding Il2CppPath}" Classes="greg-input"/>
<Button Grid.Column="2" Content="Auto" Classes="greg-secondary" Command="{Binding AutoDetectPathCommand}"/>
<Button Grid.Column="3" Content="Browse" Classes="greg-primary" Command="{Binding BrowseFolderCommand}" CommandParameter="Il2Cpp"/>
</Grid>
</Border>
<Border Classes="greg-card" Grid.Row="1">
<Grid ColumnDefinitions="Auto,*,Auto,*">
<TextBlock Text="Hooks" VerticalAlignment="Center" Classes="greg-secondary"/>
<TextBox Grid.Column="1" Text="{Binding GameHooksPath}" Classes="greg-input"/>
<TextBlock Grid.Column="2" Text="Output" VerticalAlignment="Center" Classes="greg-secondary"/>
<TextBox Grid.Column="3" Text="{Binding OutputBasePath}" Classes="greg-input"/>
</Grid>
</Border>
<Grid Grid.Row="2" ColumnDefinitions="280,*">
<Border Classes="greg-card" Grid.Column="0">
<StackPanel Spacing="8">
<Button Content="Coverage analysieren" Classes="greg-primary" Command="{Binding AnalyzeCoverageCommand}" Height="36"/>
<Button Content="Markdown öffnen" Classes="greg-secondary" Command="{Binding OpenMarkdownReportCommand}"/>
<Separator/>
<TextBlock Text="{Binding TotalHooks, StringFormat='Total: {0}'}"/>
<TextBlock Text="{Binding CoveredCount, StringFormat='Covered: {0}'}"/>
<TextBlock Text="{Binding PlannedCount, StringFormat='Planned: {0}'}"/>
<TextBlock Text="{Binding UncoveredCount, StringFormat='Uncovered: {0}'}"/>
<TextBlock Text="{Binding CoveragePercent, StringFormat='Coverage: {0:F2}%'}"/>
<Separator/>
<ListBox ItemsSource="{Binding LogLines}" Height="280"/>
</StackPanel>
</Border>
<Border Classes="greg-card" Grid.Column="1">
<DataGrid ItemsSource="{Binding Entries}" AutoGenerateColumns="False">
<DataGrid.Columns>
<DataGridTextColumn Header="Status" Binding="{Binding CoverageStatus}" Width="120"/>
<DataGridTextColumn Header="Group" Binding="{Binding HookDefinition.Group}" Width="160"/>
<DataGridTextColumn Header="Class" Binding="{Binding HookDefinition.ClassName}" Width="200"/>
<DataGridTextColumn Header="Method" Binding="{Binding HookDefinition.MethodName}" Width="*"/>
<DataGridTextColumn Header="Files" Binding="{Binding FoundInFiles.Count}" Width="80"/>
</DataGrid.Columns>
</DataGrid>
</Border>
</Grid>
<ProgressBar Grid.Row="3" Value="{Binding Progress}" Maximum="100" Classes="greg-progress"/>
<Border IsVisible="{Binding IsLoading}" Background="#880B1220" CornerRadius="12">
<StackPanel HorizontalAlignment="Center" VerticalAlignment="Center" Spacing="8">
<ProgressBar IsIndeterminate="True" Width="220"/>
<TextBlock Text="Coverage läuft..."/>
</StackPanel>
</Border>
</Grid>
</UserControl>
+11
View File
@@ -0,0 +1,11 @@
using Avalonia.Controls;
namespace gregExtractor.GUI.Views;
public partial class CoverageView : UserControl
{
public CoverageView()
{
InitializeComponent();
}
}
+49
View File
@@ -0,0 +1,49 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="gregExtractor.GUI.Views.CreateView">
<Grid RowDefinitions="Auto,*,Auto">
<Border Classes="greg-card" Grid.Row="0">
<StackPanel Orientation="Horizontal" Spacing="12" VerticalAlignment="Center">
<TextBlock Text="Wizard Step:" Classes="greg-secondary"/>
<TextBlock Text="{Binding Step}" FontWeight="SemiBold"/>
<Button Content="Zurück" Classes="greg-secondary" Command="{Binding PreviousStepCommand}"/>
<Button Content="Weiter" Classes="greg-secondary" Command="{Binding NextStepCommand}"/>
</StackPanel>
</Border>
<Grid Grid.Row="1" ColumnDefinitions="380,*">
<Border Classes="greg-card" Grid.Column="0">
<StackPanel Spacing="8">
<TextBlock Text="Mod Name" Classes="greg-secondary"/>
<TextBox Text="{Binding ModName}" Classes="greg-input"/>
<TextBlock Text="Author" Classes="greg-secondary"/>
<TextBox Text="{Binding Author}" Classes="greg-input"/>
<TextBlock Text="Version" Classes="greg-secondary"/>
<TextBox Text="{Binding Version}" Classes="greg-input"/>
<TextBlock Text="Beschreibung" Classes="greg-secondary"/>
<TextBox Text="{Binding Description}" Classes="greg-input"/>
<TextBlock Text="Template" Classes="greg-secondary"/>
<ComboBox ItemsSource="{Binding TemplateTypes}" SelectedItem="{Binding SelectedTemplateType}"/>
<TextBlock Text="Kategorie" Classes="greg-secondary"/>
<ComboBox ItemsSource="{Binding Categories}" SelectedItem="{Binding SelectedCategory}"/>
<Button Content="Output wählen" Classes="greg-secondary" Command="{Binding BrowseOutputPathCommand}"/>
<Button Content="Mod erzeugen" Classes="greg-primary" Command="{Binding GenerateCommand}" Height="36"/>
</StackPanel>
</Border>
<Border Classes="greg-card" Grid.Column="1">
<Grid RowDefinitions="*,*">
<ListBox ItemsSource="{Binding PreviewTree}"/>
<ListBox Grid.Row="1" ItemsSource="{Binding LogLines}"/>
</Grid>
</Border>
</Grid>
<Border IsVisible="{Binding IsLoading}" Background="#880B1220" CornerRadius="12">
<StackPanel HorizontalAlignment="Center" VerticalAlignment="Center" Spacing="8">
<ProgressBar IsIndeterminate="True" Width="220"/>
<TextBlock Text="Mod-Erstellung läuft..."/>
</StackPanel>
</Border>
</Grid>
</UserControl>
+11
View File
@@ -0,0 +1,11 @@
using Avalonia.Controls;
namespace gregExtractor.GUI.Views;
public partial class CreateView : UserControl
{
public CreateView()
{
InitializeComponent();
}
}
+63
View File
@@ -0,0 +1,63 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:models="clr-namespace:gregExtractor.Models"
x:Class="gregExtractor.GUI.Views.ExtractorView">
<Grid RowDefinitions="Auto,Auto,*,Auto">
<Border Classes="greg-card" Grid.Row="0">
<Grid ColumnDefinitions="Auto,*,Auto,Auto">
<TextBlock Text="IL2CPP" VerticalAlignment="Center" Classes="greg-secondary"/>
<TextBox Grid.Column="1" Text="{Binding Il2CppPath}" Classes="greg-input"/>
<Button Grid.Column="2" Content="Auto" Classes="greg-secondary" Command="{Binding AutoDetectPathCommand}"/>
<Button Grid.Column="3" Content="Browse" Classes="greg-primary" Command="{Binding BrowsePathCommand}" CommandParameter="Il2CppPath"/>
</Grid>
</Border>
<Border Classes="greg-card" Grid.Row="1">
<Grid ColumnDefinitions="Auto,*">
<StackPanel Grid.Column="0">
<TextBlock Text="Total" Classes="greg-secondary"/>
<TextBlock Text="{Binding TotalHooks}" FontSize="18" FontWeight="SemiBold"/>
</StackPanel>
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="24">
<StackPanel><TextBlock Text="Grouped" Classes="greg-secondary"/><TextBlock Text="{Binding GroupedHooks}"/></StackPanel>
<StackPanel><TextBlock Text="Unknown" Classes="greg-secondary"/><TextBlock Text="{Binding UnknownHooks}"/></StackPanel>
<StackPanel><TextBlock Text="Groups" Classes="greg-secondary"/><TextBlock Text="{Binding GroupCount}"/></StackPanel>
</StackPanel>
</Grid>
</Border>
<Grid Grid.Row="2" ColumnDefinitions="340,*">
<Border Classes="greg-card" Grid.Column="0">
<Grid RowDefinitions="Auto,*">
<Button Content="Analyse starten" Classes="greg-primary" Command="{Binding StartAnalysisCommand}" Height="36"/>
<ScrollViewer Grid.Row="1">
<ItemsControl ItemsSource="{Binding HookGroups}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<Grid ColumnDefinitions="Auto,*,Auto" Margin="0,4">
<CheckBox IsChecked="{Binding IsSelected}"/>
<TextBlock Grid.Column="1" Text="{Binding GroupName}" Margin="8,0,0,0"/>
<TextBlock Grid.Column="2" Text="{Binding Count}" Classes="greg-secondary"/>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
</Grid>
</Border>
<Border Classes="greg-card" Grid.Column="1">
<ListBox ItemsSource="{Binding LogLines}"/>
</Border>
</Grid>
<ProgressBar Grid.Row="3" Value="{Binding Progress}" Maximum="100" Classes="greg-progress"/>
<Border IsVisible="{Binding IsLoading}" Background="#880B1220" CornerRadius="12">
<StackPanel HorizontalAlignment="Center" VerticalAlignment="Center" Spacing="8">
<ProgressBar IsIndeterminate="True" Width="220"/>
<TextBlock Text="Analyse läuft..."/>
</StackPanel>
</Border>
</Grid>
</UserControl>
+11
View File
@@ -0,0 +1,11 @@
using Avalonia.Controls;
namespace gregExtractor.GUI.Views;
public partial class ExtractorView : UserControl
{
public ExtractorView()
{
InitializeComponent();
}
}
+35
View File
@@ -0,0 +1,35 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="gregExtractor.GUI.Views.HookBrowserView">
<Grid RowDefinitions="Auto,*">
<Border Classes="greg-card" Grid.Row="0">
<Grid ColumnDefinitions="Auto,260,Auto,220,Auto">
<TextBlock Text="Suche" VerticalAlignment="Center" Classes="greg-secondary"/>
<TextBox Grid.Column="1" Text="{Binding SearchText}" Classes="greg-input"/>
<TextBlock Grid.Column="2" Text="Gruppe" VerticalAlignment="Center" Classes="greg-secondary"/>
<ComboBox Grid.Column="3" ItemsSource="{Binding Groups}" SelectedItem="{Binding SelectedGroup}"/>
<Button Grid.Column="4" Content="Refresh" Classes="greg-primary" Command="{Binding RefreshCommand}"/>
</Grid>
</Border>
<Border Classes="greg-card" Grid.Row="1">
<DataGrid ItemsSource="{Binding FilteredRows}" AutoGenerateColumns="False">
<DataGrid.Columns>
<DataGridTextColumn Header="Group" Binding="{Binding Group}" Width="140"/>
<DataGridTextColumn Header="Namespace" Binding="{Binding Namespace}" Width="220"/>
<DataGridTextColumn Header="Class" Binding="{Binding ClassName}" Width="200"/>
<DataGridTextColumn Header="Method" Binding="{Binding MethodName}" Width="200"/>
<DataGridTextColumn Header="Return" Binding="{Binding ReturnType}" Width="160"/>
<DataGridTextColumn Header="Parameters" Binding="{Binding Parameters}" Width="*"/>
</DataGrid.Columns>
</DataGrid>
</Border>
<Border IsVisible="{Binding IsLoading}" Background="#880B1220" CornerRadius="12">
<StackPanel HorizontalAlignment="Center" VerticalAlignment="Center" Spacing="8">
<ProgressBar IsIndeterminate="True" Width="220"/>
<TextBlock Text="Hooks laden..."/>
</StackPanel>
</Border>
</Grid>
</UserControl>
+11
View File
@@ -0,0 +1,11 @@
using Avalonia.Controls;
namespace gregExtractor.GUI.Views;
public partial class HookBrowserView : UserControl
{
public HookBrowserView()
{
InitializeComponent();
}
}
+53
View File
@@ -0,0 +1,53 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="gregExtractor.GUI.Views.SettingsView">
<Grid RowDefinitions="*,Auto">
<Border Classes="greg-card" Grid.Row="0">
<ScrollViewer>
<StackPanel Spacing="10">
<TextBlock Text="Pfade" FontWeight="SemiBold"/>
<TextBlock Text="Game" Classes="greg-secondary"/>
<Grid ColumnDefinitions="*,Auto,Auto">
<TextBox Text="{Binding GamePath}" Classes="greg-input"/>
<Button Grid.Column="1" Content="Auto" Classes="greg-secondary" Command="{Binding AutoDetectGamePathCommand}"/>
<Button Grid.Column="2" Content="Browse" Classes="greg-secondary" Command="{Binding BrowseGamePathCommand}"/>
</Grid>
<TextBlock Text="Framework" Classes="greg-secondary"/>
<Grid ColumnDefinitions="*,Auto">
<TextBox Text="{Binding FrameworkSourcePath}" Classes="greg-input"/>
<Button Grid.Column="1" Content="Browse" Classes="greg-secondary" Command="{Binding BrowseFrameworkPathCommand}"/>
</Grid>
<TextBlock Text="Mod Output" Classes="greg-secondary"/>
<Grid ColumnDefinitions="*,Auto">
<TextBox Text="{Binding ModsOutputPath}" Classes="greg-input"/>
<Button Grid.Column="1" Content="Browse" Classes="greg-secondary" Command="{Binding BrowseOutputPathCommand}"/>
</Grid>
<Separator/>
<TextBlock Text="Optionen" FontWeight="SemiBold"/>
<CheckBox Content="Getter/Setter ignorieren" IsChecked="{Binding IgnoreGettersSetters}"/>
<CheckBox Content="Private Methoden einbeziehen" IsChecked="{Binding IncludePrivateMethods}"/>
<CheckBox Content="Backup vor Sync" IsChecked="{Binding BackupBeforeSync}"/>
<CheckBox Content="Git-Diff nach Sync" IsChecked="{Binding GitDiffAfterSync}"/>
<CheckBox Content="Animationen reduzieren" IsChecked="{Binding ReduceAnimations}"/>
<CheckBox Content="Monospace überall" IsChecked="{Binding MonospaceEverywhere}"/>
</StackPanel>
</ScrollViewer>
</Border>
<Border Grid.Row="1" Classes="greg-card">
<StackPanel Orientation="Horizontal" Spacing="8">
<Button Content="Debug-Info erzeugen" Classes="greg-primary" Command="{Binding CopyDebugInfoCommand}"/>
</StackPanel>
</Border>
<Border IsVisible="{Binding IsLoading}" Background="#880B1220" CornerRadius="12">
<StackPanel HorizontalAlignment="Center" VerticalAlignment="Center" Spacing="8">
<ProgressBar IsIndeterminate="True" Width="220"/>
<TextBlock Text="Einstellungen werden geladen..."/>
</StackPanel>
</Border>
</Grid>
</UserControl>
+11
View File
@@ -0,0 +1,11 @@
using Avalonia.Controls;
namespace gregExtractor.GUI.Views;
public partial class SettingsView : UserControl
{
public SettingsView()
{
InitializeComponent();
}
}
+53
View File
@@ -0,0 +1,53 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:gregExtractor.GUI.ViewModels"
x:Class="gregExtractor.GUI.Views.SyncView">
<Grid RowDefinitions="Auto,Auto,*,Auto">
<Border Classes="greg-card" Grid.Row="0">
<Grid ColumnDefinitions="Auto,*,Auto">
<TextBlock Text="Framework Source" VerticalAlignment="Center" Classes="greg-secondary"/>
<TextBox Grid.Column="1" Text="{Binding FrameworkSourcePath}" Classes="greg-input"/>
<Button Grid.Column="2" Content="Browse" Classes="greg-primary" Command="{Binding BrowseSourcePathCommand}"/>
</Grid>
</Border>
<Border Classes="greg-card" Grid.Row="1">
<StackPanel Orientation="Horizontal" Spacing="16">
<CheckBox Content="Dry Run" IsChecked="{Binding IsDryRun}"/>
<CheckBox Content="Git Diff" IsChecked="{Binding UseGitDiff}"/>
<Button Content="Diff berechnen" Classes="greg-secondary" Command="{Binding CalculateDiffCommand}"/>
<Button Content="Sync ausführen" Classes="greg-primary" Command="{Binding RunSyncCommand}"/>
<Button Content="Backup öffnen" Classes="greg-secondary" Command="{Binding OpenBackupFolderCommand}"/>
</StackPanel>
</Border>
<Grid Grid.Row="2" ColumnDefinitions="2*,*">
<Border Classes="greg-card" Grid.Column="0">
<DataGrid ItemsSource="{Binding DiffEntries}" AutoGenerateColumns="False">
<DataGrid.Columns>
<DataGridTextColumn Header="Kind" Binding="{Binding Kind}" Width="100"/>
<DataGridTextColumn Header="Group" Binding="{Binding Group}" Width="140"/>
<DataGridTextColumn Header="Hook" Binding="{Binding Display}" Width="220"/>
<DataGridTextColumn Header="Signature" Binding="{Binding Signature}" Width="*"/>
</DataGrid.Columns>
</DataGrid>
</Border>
<Border Classes="greg-card" Grid.Column="1">
<Grid RowDefinitions="Auto,*,Auto,*">
<TextBlock Text="Log" FontWeight="SemiBold"/>
<ListBox Grid.Row="1" ItemsSource="{Binding SyncLog}"/>
<TextBlock Grid.Row="2" Text="Warnings" FontWeight="SemiBold"/>
<ListBox Grid.Row="3" ItemsSource="{Binding Warnings}"/>
</Grid>
</Border>
</Grid>
<Border IsVisible="{Binding IsLoading}" Background="#880B1220" CornerRadius="12">
<StackPanel HorizontalAlignment="Center" VerticalAlignment="Center" Spacing="8">
<ProgressBar IsIndeterminate="True" Width="220"/>
<TextBlock Text="Sync läuft..."/>
</StackPanel>
</Border>
</Grid>
</UserControl>
+11
View File
@@ -0,0 +1,11 @@
using Avalonia.Controls;
namespace gregExtractor.GUI.Views;
public partial class SyncView : UserControl
{
public SyncView()
{
InitializeComponent();
}
}
+16
View File
@@ -0,0 +1,16 @@
global using System;
global using System.Collections.Generic;
global using System.Collections.ObjectModel;
global using System.ComponentModel;
global using System.Diagnostics;
global using System.IO;
global using System.Linq;
global using System.Text;
global using System.Text.Json;
global using System.Text.Json.Serialization;
global using System.Text.RegularExpressions;
global using System.Threading;
global using System.Threading.Tasks;
global using Mono.Cecil;
global using Spectre.Console;
global using Spectre.Console.Cli;
+140
View File
@@ -0,0 +1,140 @@
using System.Text;
namespace gregExtractor;
internal static class GregExtractorCli
{
public static int Run(string[] args)
{
try
{
if (args.Length == 0)
return PrintHelp();
string command = args[0].Trim().ToLowerInvariant();
Dictionary<string, string> options = ParseOptions(args.Skip(1).ToArray());
return command switch
{
"scan" => RunScan(options),
"template" => RunTemplate(options),
"help" or "--help" or "-h" => PrintHelp(),
_ => PrintHelp($"Unknown command: {command}"),
};
}
catch (Exception ex)
{
Console.Error.WriteLine($"[gregExtractor] Fehler: {ex}");
return 1;
}
}
private static int RunScan(IReadOnlyDictionary<string, string> options)
{
string source = GetRequired(options, "source");
string state = GetOptional(options, "state", Path.Combine(AppContext.BaseDirectory, "state"));
var store = new SnapshotStore(state);
var service = new HookAutomationService(new SourceScanner(), store);
SourceSnapshot snapshot = service.ScanCurrent(source);
ChangeReport report = service.CompareWithPrevious(snapshot);
service.Persist(snapshot, report);
Console.WriteLine("[gregExtractor] Scan abgeschlossen");
Console.WriteLine($"- Source: {snapshot.SourceRoot}");
Console.WriteLine($"- Files: {snapshot.FileCount}");
Console.WriteLine($"- Methods: {snapshot.Methods.Count}");
Console.WriteLine($"- Added: {report.Added} | Removed: {report.Removed} | SigChanged: {report.SignatureChanged} | BodyChanged: {report.BodyChanged}");
return 0;
}
private static int RunTemplate(IReadOnlyDictionary<string, string> options)
{
string repoRoot = GetRequired(options, "repo");
string output = GetRequired(options, "output");
string plugin = GetOptional(options, "name", "greg.Plugin.HookTemplate");
string packageVersion = GetOptional(options, "gregcore-version", "0.0.0-local");
string @namespace = plugin;
string className = plugin.Split('.', StringSplitOptions.RemoveEmptyEntries).LastOrDefault() ?? "GregPluginHookTemplate";
var store = new SnapshotStore(Path.Combine(repoRoot, "gregExtractor", "state"));
var service = new HookAutomationService(new SourceScanner(), store);
IReadOnlyList<HookCatalogRow> rows = service.LoadHookCatalogRows(repoRoot);
var paths = service.GeneratePluginTemplate(
repoRoot,
output,
plugin,
@namespace,
className,
author: "gregExtractor",
rows,
packageVersion);
Console.WriteLine("[gregExtractor] Template erzeugt");
Console.WriteLine($"- Project: {paths.CsprojPath}");
Console.WriteLine($"- Main: {paths.MainCsPath}");
Console.WriteLine($"- Readme: {paths.ReadmePath}");
return 0;
}
private static string GetRequired(IReadOnlyDictionary<string, string> options, string name)
{
if (!options.TryGetValue(name, out string? value) || string.IsNullOrWhiteSpace(value))
throw new ArgumentException($"Missing required option --{name}");
return value;
}
private static string GetOptional(IReadOnlyDictionary<string, string> options, string name, string fallback)
{
if (options.TryGetValue(name, out string? value) && !string.IsNullOrWhiteSpace(value))
return value;
return fallback;
}
private static Dictionary<string, string> ParseOptions(string[] args)
{
var map = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
for (int index = 0; index < args.Length; index++)
{
string token = args[index];
if (!token.StartsWith("--", StringComparison.Ordinal))
continue;
string key = token[2..].Trim();
string value = "true";
if (index + 1 < args.Length && !args[index + 1].StartsWith("--", StringComparison.Ordinal))
{
value = args[index + 1];
index++;
}
map[key] = value;
}
return map;
}
private static int PrintHelp(string? error = null)
{
if (!string.IsNullOrWhiteSpace(error))
Console.Error.WriteLine($"[gregExtractor] {error}");
var sb = new StringBuilder();
sb.AppendLine("gregExtractor CLI");
sb.AppendLine();
sb.AppendLine("Commands:");
sb.AppendLine(" scan --source <path> [--state <path>]");
sb.AppendLine(" template --repo <path> --output <path> [--name <plugin>] [--gregcore-version <version>]");
sb.AppendLine(" --gui (startet die bestehende GUI)");
Console.WriteLine(sb.ToString());
return string.IsNullOrWhiteSpace(error) ? 0 : 1;
}
}
+821
View File
@@ -0,0 +1,821 @@
using System.Diagnostics;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
namespace gregExtractor;
public sealed class HookAutomationService
{
private static readonly Regex PatchTargetMethodRegex = new(
@"::(?<method>[^\(]+)\(",
RegexOptions.Compiled | RegexOptions.CultureInvariant);
private readonly SourceScanner _scanner;
private readonly SnapshotStore _store;
public HookAutomationService(SourceScanner scanner, SnapshotStore store)
{
_scanner = scanner;
_store = store;
}
public SourceSnapshot ScanCurrent(string sourceRoot)
{
return _scanner.Scan(sourceRoot);
}
public ChangeReport CompareWithPrevious(SourceSnapshot current)
{
SourceSnapshot? previous = _store.TryLoadSnapshot();
if (previous == null)
{
return new ChangeReport
{
CreatedUtc = DateTime.UtcNow,
PreviousCount = 0,
CurrentCount = current.Methods.Count,
Added = current.Methods.Count,
Removed = 0,
SignatureChanged = 0,
BodyChanged = 0,
};
}
Dictionary<string, MethodSnapshot> previousBySignature = previous.Methods
.ToDictionary(x => x.SignatureKey, StringComparer.Ordinal);
Dictionary<string, MethodSnapshot> currentBySignature = current.Methods
.ToDictionary(x => x.SignatureKey, StringComparer.Ordinal);
int added = currentBySignature.Keys.Count(key => !previousBySignature.ContainsKey(key));
int removed = previousBySignature.Keys.Count(key => !currentBySignature.ContainsKey(key));
int bodyChanged = 0;
foreach ((string key, MethodSnapshot curr) in currentBySignature)
{
if (!previousBySignature.TryGetValue(key, out MethodSnapshot? prev))
continue;
if (!string.Equals(prev.BodyHash, curr.BodyHash, StringComparison.Ordinal))
bodyChanged++;
}
var prevByIdentity = previous.Methods
.GroupBy(m => m.IdentityKey, StringComparer.Ordinal)
.ToDictionary(g => g.Key, g => g.Select(x => x.SignatureKey).ToHashSet(StringComparer.Ordinal), StringComparer.Ordinal);
var currByIdentity = current.Methods
.GroupBy(m => m.IdentityKey, StringComparer.Ordinal)
.ToDictionary(g => g.Key, g => g.Select(x => x.SignatureKey).ToHashSet(StringComparer.Ordinal), StringComparer.Ordinal);
int signatureChanged = 0;
foreach (string identity in prevByIdentity.Keys.Intersect(currByIdentity.Keys, StringComparer.Ordinal))
{
if (!prevByIdentity[identity].SetEquals(currByIdentity[identity]))
signatureChanged++;
}
return new ChangeReport
{
CreatedUtc = DateTime.UtcNow,
PreviousCount = previous.Methods.Count,
CurrentCount = current.Methods.Count,
Added = added,
Removed = removed,
SignatureChanged = signatureChanged,
BodyChanged = bodyChanged,
};
}
public void Persist(SourceSnapshot snapshot, ChangeReport report)
{
_store.SaveSnapshot(snapshot);
_store.SaveReport(report);
}
public IReadOnlyList<HookCatalogRow> LoadHookCatalogRows(string repoRoot, string? preferredRoot = null)
{
string? catalogPath = ResolveHookCatalogPath(repoRoot, preferredRoot);
if (catalogPath == null)
throw new FileNotFoundException("greg_hooks.json not found in preferred root, repository root, or gregCore/framework.");
return LoadHookCatalogRowsFromFile(catalogPath);
}
public IReadOnlyList<HookCatalogRow> LoadGregCoreImplementedHookRows(string repoRoot)
{
string gregCoreCatalogPath = Path.Combine(repoRoot, "gregCore", "framework", "greg_hooks.json");
if (!File.Exists(gregCoreCatalogPath))
return Array.Empty<HookCatalogRow>();
return LoadHookCatalogRowsFromFile(gregCoreCatalogPath);
}
private static IReadOnlyList<HookCatalogRow> LoadHookCatalogRowsFromFile(string catalogPath)
{
using JsonDocument document = JsonDocument.Parse(File.ReadAllText(catalogPath));
if (!document.RootElement.TryGetProperty("hooks", out JsonElement hooksElement) || hooksElement.ValueKind != JsonValueKind.Array)
return Array.Empty<HookCatalogRow>();
var rows = new List<HookCatalogRow>(capacity: Math.Max(16, hooksElement.GetArrayLength()));
foreach (JsonElement hook in hooksElement.EnumerateArray())
{
string gregApiCall = hook.TryGetProperty("name", out JsonElement nameEl) ? nameEl.GetString() ?? string.Empty : string.Empty;
string patchTarget = hook.TryGetProperty("patchTarget", out JsonElement patchEl) ? patchEl.GetString() ?? string.Empty : string.Empty;
string assembly = hook.TryGetProperty("assembly", out JsonElement assemblyEl) ? assemblyEl.GetString() ?? "Il2Cpp" : "Il2Cpp";
string il2CppEvent = ExtractEventName(patchTarget);
rows.Add(new HookCatalogRow
{
Il2CppHookEvent = il2CppEvent,
GregApiCall = gregApiCall,
Category = DeriveCategory(gregApiCall),
Assembly = assembly,
PatchTarget = patchTarget,
});
}
return rows
.OrderBy(x => x.Category, StringComparer.OrdinalIgnoreCase)
.ThenBy(x => x.Il2CppHookEvent, StringComparer.OrdinalIgnoreCase)
.ThenBy(x => x.PatchTarget, StringComparer.OrdinalIgnoreCase)
.ToList();
}
public MelonImportResult ImportMelonGeneratedSources(string melonGeneratedRoot, string targetSourceRoot)
{
if (string.IsNullOrWhiteSpace(melonGeneratedRoot) || !Directory.Exists(melonGeneratedRoot))
throw new DirectoryNotFoundException($"Melon generated path not found: {melonGeneratedRoot}");
string? sourceRoot = ResolveSourceRoot(melonGeneratedRoot);
if (sourceRoot == null)
throw new InvalidOperationException("Keine Il2Cpp/Unity C#-Quellen im Melon generated Pfad gefunden.");
string normalizedSourceRoot = Path.GetFullPath(sourceRoot)
.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
string normalizedTargetRoot = Path.GetFullPath(targetSourceRoot)
.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
if (string.Equals(normalizedSourceRoot, normalizedTargetRoot, StringComparison.OrdinalIgnoreCase)
|| IsSubPathOf(normalizedSourceRoot, normalizedTargetRoot)
|| IsSubPathOf(normalizedTargetRoot, normalizedSourceRoot))
{
throw new InvalidOperationException("Import abgebrochen: Quell- und Zielpfad überschneiden sich. Bitte getrennte Pfade wählen.");
}
Directory.CreateDirectory(targetSourceRoot);
foreach (string targetDir in EnumerateAssemblyDirs(targetSourceRoot))
Directory.Delete(targetDir, recursive: true);
int copiedDirectories = 0;
int copiedFiles = 0;
foreach (string sourceDir in EnumerateAssemblyDirs(sourceRoot))
{
string dirName = Path.GetFileName(sourceDir);
string destinationDir = Path.Combine(targetSourceRoot, dirName);
copiedFiles += CopyDirectoryRecursive(sourceDir, destinationDir);
copiedDirectories++;
}
return new MelonImportResult(copiedFiles, copiedDirectories, sourceRoot, targetSourceRoot);
}
public MelonImportResult ImportFromGameDirectory(string gameDirectory, string targetSourceRoot)
{
if (string.IsNullOrWhiteSpace(gameDirectory) || !Directory.Exists(gameDirectory))
throw new DirectoryNotFoundException($"Game directory not found: {gameDirectory}");
string? generatedRoot = ResolveGeneratedRootFromGameDirectory(gameDirectory);
if (generatedRoot == null)
{
throw new InvalidOperationException(
"Im Spielverzeichnis wurden keine Melon/Il2Cpp-Generated C# Quellen gefunden. " +
"Starte das Spiel einmal mit MelonLoader oder wähle den Generated-Ordner direkt.");
}
return ImportMelonGeneratedSources(generatedRoot, targetSourceRoot);
}
public HookCoverage CalculateCoverage(SourceSnapshot snapshot, IReadOnlyList<HookCatalogRow> rows)
{
HashSet<string> expectedKeys = snapshot.Methods
.Select(m => m.SignatureKey)
.ToHashSet(StringComparer.Ordinal);
HashSet<string> hookedKeys = rows
.Select(r => r.SignatureKey)
.Where(k => !string.IsNullOrWhiteSpace(k))
.ToHashSet(StringComparer.Ordinal);
int coveredUnique = expectedKeys.Count(k => hookedKeys.Contains(k));
return new HookCoverage(
expectedUnique: expectedKeys.Count,
hookedUnique: hookedKeys.Count,
coveredUnique: coveredUnique);
}
public IReadOnlyList<AssemblyCoverageRow> CalculateCoverageByAssembly(SourceSnapshot snapshot, IReadOnlyList<HookCatalogRow> rows)
{
Dictionary<string, HashSet<string>> expectedByAssembly = snapshot.Methods
.GroupBy(m => m.Assembly, StringComparer.OrdinalIgnoreCase)
.ToDictionary(
g => g.Key,
g => g.Select(m => m.SignatureKey).ToHashSet(StringComparer.Ordinal),
StringComparer.OrdinalIgnoreCase);
Dictionary<string, HashSet<string>> hookedByAssembly = rows
.GroupBy(r => string.IsNullOrWhiteSpace(r.Assembly) ? "UNKNOWN" : r.Assembly, StringComparer.OrdinalIgnoreCase)
.ToDictionary(
g => g.Key,
g => g.Select(r => r.SignatureKey).Where(x => !string.IsNullOrWhiteSpace(x)).ToHashSet(StringComparer.Ordinal),
StringComparer.OrdinalIgnoreCase);
IEnumerable<string> assemblies = expectedByAssembly.Keys
.Concat(hookedByAssembly.Keys)
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(x => x, StringComparer.OrdinalIgnoreCase);
var result = new List<AssemblyCoverageRow>();
foreach (string assembly in assemblies)
{
expectedByAssembly.TryGetValue(assembly, out HashSet<string>? expectedSet);
hookedByAssembly.TryGetValue(assembly, out HashSet<string>? hookedSet);
expectedSet ??= new HashSet<string>(StringComparer.Ordinal);
hookedSet ??= new HashSet<string>(StringComparer.Ordinal);
int covered = expectedSet.Count(k => hookedSet.Contains(k));
result.Add(new AssemblyCoverageRow(
assembly: assembly,
expectedUnique: expectedSet.Count,
hookedUnique: hookedSet.Count,
coveredUnique: covered));
}
return result;
}
public (string CsprojPath, string MainCsPath, string ReadmePath) GeneratePluginTemplate(
string repoRoot,
string outputDirectory,
string pluginName,
string rootNamespace,
string className,
string author,
IReadOnlyList<HookCatalogRow> rows,
string gregCorePackageVersion = "0.0.0-local")
{
if (string.IsNullOrWhiteSpace(outputDirectory))
throw new ArgumentException("Output directory is required.", nameof(outputDirectory));
if (string.IsNullOrWhiteSpace(pluginName))
throw new ArgumentException("Plugin name is required.", nameof(pluginName));
if (string.IsNullOrWhiteSpace(rootNamespace))
throw new ArgumentException("Namespace is required.", nameof(rootNamespace));
if (string.IsNullOrWhiteSpace(className))
throw new ArgumentException("Class name is required.", nameof(className));
string[] hooks = rows
.Select(r => r.GregApiCall)
.Where(h => !string.IsNullOrWhiteSpace(h))
.Distinct(StringComparer.Ordinal)
.OrderBy(h => h, StringComparer.OrdinalIgnoreCase)
.ToArray();
Directory.CreateDirectory(outputDirectory);
string projectFileName = $"{SanitizeFileName(pluginName)}.csproj";
string projectPath = Path.Combine(outputDirectory, projectFileName);
string mainPath = Path.Combine(outputDirectory, $"{className}.cs");
string harmonyPath = Path.Combine(outputDirectory, $"{className}.HarmonyPatches.cs");
string gameApiBridgePath = Path.Combine(outputDirectory, $"{className}.GameApiBridge.cs");
string readmePath = Path.Combine(outputDirectory, "README.md");
string pluginId = $"{rootNamespace}.{className}";
File.WriteAllText(projectPath, BuildTemplateCsproj(gregCorePackageVersion));
File.WriteAllText(mainPath, BuildTemplateMainClass(pluginName, rootNamespace, className, author, pluginId, hooks));
File.WriteAllText(gameApiBridgePath, BuildGameApiBridgeClass(rootNamespace, className));
File.WriteAllText(harmonyPath, BuildHarmonyPatchClass(rootNamespace, className, rows));
File.WriteAllText(readmePath, BuildTemplateReadme(pluginName, hooks.Length));
return (projectPath, mainPath, readmePath);
}
public static string SuggestDefaultMelonGeneratedPath()
{
string il2CppAssemblies = SteamLocator.TryFindIl2CppAssembliesDirectory();
if (!string.IsNullOrWhiteSpace(il2CppAssemblies))
return il2CppAssemblies;
string appData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
string[] candidates =
{
Path.Combine(appData, "MelonLoader"),
Path.Combine(appData, "MelonLoader", "Generated"),
Path.Combine(appData, "MelonLoader", "Il2CppAssemblyGenerator"),
};
return candidates.FirstOrDefault(Directory.Exists) ?? string.Empty;
}
public static string SuggestDefaultIl2CppAssembliesPath()
{
return SteamLocator.TryFindIl2CppAssembliesDirectory();
}
public static string SuggestDefaultGameDirectory()
{
return SteamLocator.TryFindDataCenterDirectory();
}
private static string? ResolveHookCatalogPath(string repoRoot, string? preferredRoot)
{
var candidates = new List<string>();
if (!string.IsNullOrWhiteSpace(preferredRoot) && Directory.Exists(preferredRoot))
{
candidates.Add(Path.Combine(preferredRoot, "greg_hooks.json"));
candidates.Add(Path.Combine(preferredRoot, "Mods", "greg_hooks.json"));
foreach (string dir in EnumerateDirectoriesBreadthFirst(preferredRoot, maxDepth: 4))
candidates.Add(Path.Combine(dir, "greg_hooks.json"));
}
candidates.Add(Path.Combine(repoRoot, "greg_hooks.json"));
candidates.Add(Path.Combine(repoRoot, "gregCore", "framework", "greg_hooks.json"));
return candidates.FirstOrDefault(File.Exists);
}
private static IEnumerable<string> EnumerateAssemblyDirs(string root)
{
if (!Directory.Exists(root))
yield break;
foreach (string dir in Directory.GetDirectories(root))
{
string name = Path.GetFileName(dir);
if (!(name.StartsWith("Il2Cpp", StringComparison.OrdinalIgnoreCase)
|| name.StartsWith("Unity", StringComparison.OrdinalIgnoreCase)
|| name.StartsWith("UnityEngine", StringComparison.OrdinalIgnoreCase)))
continue;
bool hasCsFiles = Directory.EnumerateFiles(dir, "*.cs", SearchOption.AllDirectories).Any();
if (hasCsFiles)
yield return dir;
}
}
private static string? ResolveSourceRoot(string melonGeneratedRoot)
{
string[] directCandidates =
{
melonGeneratedRoot,
Path.Combine(melonGeneratedRoot, "Assembly-CSharp"),
Path.Combine(melonGeneratedRoot, "Generated"),
Path.Combine(melonGeneratedRoot, "Il2CppAssemblyGenerator"),
Path.Combine(melonGeneratedRoot, "MelonLoader"),
Path.Combine(melonGeneratedRoot, "MelonLoader", "Generated"),
};
foreach (string candidate in directCandidates.Where(Directory.Exists))
{
if (EnumerateAssemblyDirs(candidate).Any())
return candidate;
}
foreach (string dir in EnumerateDirectoriesBreadthFirst(melonGeneratedRoot, maxDepth: 4))
{
if (EnumerateAssemblyDirs(dir).Any())
return dir;
}
return null;
}
private static string? ResolveGeneratedRootFromGameDirectory(string gameDirectory)
{
string[] directCandidates =
{
Path.Combine(gameDirectory, "MelonLoader", "Generated"),
Path.Combine(gameDirectory, "MelonLoader", "Il2CppAssemblyGenerator"),
Path.Combine(gameDirectory, "MelonLoader"),
Path.Combine(gameDirectory, "Mods"),
gameDirectory,
};
foreach (string candidate in directCandidates.Where(Directory.Exists))
{
if (ResolveSourceRoot(candidate) != null)
return candidate;
}
foreach (string dir in EnumerateDirectoriesBreadthFirst(gameDirectory, maxDepth: 6))
{
string name = Path.GetFileName(dir);
bool likelyGenerated =
name.Contains("Generated", StringComparison.OrdinalIgnoreCase)
|| name.Contains("Il2Cpp", StringComparison.OrdinalIgnoreCase)
|| name.Contains("MelonLoader", StringComparison.OrdinalIgnoreCase);
if (!likelyGenerated)
continue;
if (ResolveSourceRoot(dir) != null)
return dir;
}
return null;
}
private static bool IsSubPathOf(string candidatePath, string basePath)
{
return candidatePath.StartsWith(basePath + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)
|| candidatePath.StartsWith(basePath + Path.AltDirectorySeparatorChar, StringComparison.OrdinalIgnoreCase);
}
private static IEnumerable<string> EnumerateDirectoriesBreadthFirst(string root, int maxDepth)
{
if (!Directory.Exists(root) || maxDepth < 0)
yield break;
var queue = new Queue<(string Path, int Depth)>();
queue.Enqueue((root, 0));
while (queue.Count > 0)
{
(string current, int depth) = queue.Dequeue();
if (depth >= maxDepth)
continue;
string[] children;
try
{
children = Directory.GetDirectories(current);
}
catch
{
continue;
}
foreach (string child in children)
{
yield return child;
queue.Enqueue((child, depth + 1));
}
}
}
private static int CopyDirectoryRecursive(string sourceDir, string destinationDir)
{
Directory.CreateDirectory(destinationDir);
int copiedFiles = 0;
foreach (string file in Directory.GetFiles(sourceDir, "*", SearchOption.AllDirectories))
{
string relative = Path.GetRelativePath(sourceDir, file);
string destinationPath = Path.Combine(destinationDir, relative);
Directory.CreateDirectory(Path.GetDirectoryName(destinationPath)!);
File.Copy(file, destinationPath, overwrite: true);
copiedFiles++;
}
return copiedFiles;
}
private static string BuildTemplateCsproj(string gregCorePackageVersion)
{
return $$"""
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net6.0</TargetFramework>
<PlatformTarget>x64</PlatformTarget>
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>
<GregCoreVersion>{{EscapeForCSharpLiteral(gregCorePackageVersion)}}</GregCoreVersion>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="gregCore" Version="$(GregCoreVersion)" />
</ItemGroup>
</Project>
""";
}
private static string BuildTemplateMainClass(
string pluginName,
string rootNamespace,
string className,
string author,
string pluginId,
IReadOnlyList<string> hooks)
{
var hookLines = new StringBuilder();
foreach (string hook in hooks)
hookLines.AppendLine($" \"{EscapeForCSharpLiteral(hook)}\",");
return $$"""
using System;
using greg.Core;
using greg.Core.Plugins;
using greg.Sdk;
using MelonLoader;
[assembly: MelonInfo(typeof({{rootNamespace}}.{{className}}), "{{EscapeForCSharpLiteral(pluginName)}}", gregReleaseVersion.Current, "{{EscapeForCSharpLiteral(author)}}")]
[assembly: MelonGame("Waseku", "Data Center")]
namespace {{rootNamespace}};
public sealed class {{className}} : gregPluginBase
{
private static readonly string[] HookNames = new[]
{
{{hookLines}} };
public override string PluginId => "{{EscapeForCSharpLiteral(pluginId)}}";
public override string DisplayName => "{{EscapeForCSharpLiteral(pluginName)}}";
public override Version RequiredFrameworkVersion => ParseFrameworkVersion(gregReleaseVersion.Current);
public override void OnFrameworkReady()
{
RegisterAllHooks();
MelonLogger.Msg($"[{PluginId}] Hook template aktiv. Subscriptions: {HookNames.Length}");
}
public override void OnApplicationQuitMod()
{
gregEventDispatcher.UnregisterAll(PluginId);
}
private void RegisterAllHooks()
{
foreach (string hookName in HookNames)
{
string localHook = hookName;
gregEventDispatcher.On(localHook, payload => OnHookTriggered(localHook, payload), PluginId);
}
}
private static void OnHookTriggered(string hookName, object payload)
{
string payloadType = payload?.GetType().Name ?? "null";
MelonLogger.Msg($"[HookTemplate] {hookName} | PayloadType={payloadType}");
}
private static Version ParseFrameworkVersion(string version)
{
return Version.TryParse(version, out Version parsed)
? parsed
: new Version(0, 0, 0, 0);
}
}
""";
}
private static string BuildTemplateReadme(string pluginName, int hookCount)
{
return $$"""
# {{pluginName}} - Auto Hook Template
Dieses Template wurde von `gregExtractor` erzeugt.
- Registrierte greg Hooks: **{{hookCount}}**
- Basis: `gregPluginBase` + `gregEventDispatcher.On(...)`
- Harmony-Patches: `*.HarmonyPatches.cs`
- API-Bridge: `*.GameApiBridge.cs`
## Build
```powershell
dotnet build
```
## Hinweise
- Das Template subscribed alle aktuell bekannten `greg.*` Hooks.
- Das Template erwartet `gregCore` als NuGet-Paket (`PackageReference`), nicht als Projekt-Referenz.
- Bei neuen Game-/Melon-Updates Template erneut generieren.
- Eigene Logik in `OnHookTriggered` ergänzen.
""";
}
private static string BuildGameApiBridgeClass(string rootNamespace, string className)
{
return $$"""
using System;
using greg.Sdk;
using MelonLoader;
namespace {{rootNamespace}};
internal static class {{className}}GameApiBridge
{
public static void Emit(string gregHook, string patchTarget, object instance, object result, object[] args)
{
try
{
var payload = new
{
patchTarget,
instanceType = instance?.GetType().FullName,
result,
argCount = args?.Length ?? 0,
args,
};
gregEventDispatcher.Emit(gregHook, payload);
}
catch (Exception ex)
{
MelonLogger.Warning($"[{{className}}] Emit failed for '{gregHook}' ({patchTarget}): {ex.Message}");
}
}
}
""";
}
private static string BuildHarmonyPatchClass(string rootNamespace, string className, IReadOnlyList<HookCatalogRow> rows)
{
var patchDescriptors = rows
.Select(ParsePatchTarget)
.Where(x => x != null)
.Cast<PatchTargetDescriptor>()
.DistinctBy(x => x.Key, StringComparer.Ordinal)
.Take(256)
.ToList();
var classBuilder = new StringBuilder();
foreach (PatchTargetDescriptor descriptor in patchDescriptors)
{
classBuilder.AppendLine($"[HarmonyPatch]");
classBuilder.AppendLine($"internal static class AutoPatch_{SanitizeIdentifier(descriptor.MethodName)}_{Math.Abs(descriptor.Key.GetHashCode())}");
classBuilder.AppendLine("{");
classBuilder.AppendLine($" private const string GregHook = \"{EscapeForCSharpLiteral(descriptor.GregHook)}\";");
classBuilder.AppendLine($" private const string PatchTarget = \"{EscapeForCSharpLiteral(descriptor.PatchTarget)}\";");
classBuilder.AppendLine();
classBuilder.AppendLine(" private static System.Reflection.MethodBase TargetMethod()");
classBuilder.AppendLine($" => AccessTools.Method(\"{EscapeForCSharpLiteral(descriptor.TypeName)}:{EscapeForCSharpLiteral(descriptor.MethodName)}\");");
classBuilder.AppendLine();
classBuilder.AppendLine(" private static void Postfix(object __instance, object __result, object[] __args)");
classBuilder.AppendLine(" {");
classBuilder.AppendLine($" {className}GameApiBridge.Emit(GregHook, PatchTarget, __instance, __result, __args);");
classBuilder.AppendLine(" }");
classBuilder.AppendLine("}");
classBuilder.AppendLine();
}
return $$"""
using HarmonyLib;
namespace {{rootNamespace}};
{{classBuilder}}
""";
}
private sealed record PatchTargetDescriptor(string TypeName, string MethodName, string GregHook, string PatchTarget)
{
public string Key => $"{TypeName}::{MethodName}=>{GregHook}";
}
private static PatchTargetDescriptor? ParsePatchTarget(HookCatalogRow row)
{
if (string.IsNullOrWhiteSpace(row.PatchTarget) || string.IsNullOrWhiteSpace(row.GregApiCall))
return null;
string normalized = row.PatchTarget.Trim();
int methodSeparator = normalized.IndexOf("::", StringComparison.Ordinal);
if (methodSeparator <= 0)
return null;
string typeName = normalized[..methodSeparator].Trim();
string methodPart = normalized[(methodSeparator + 2)..].Trim();
int bracketIndex = methodPart.IndexOf('(');
string methodName = bracketIndex >= 0 ? methodPart[..bracketIndex].Trim() : methodPart;
if (string.IsNullOrWhiteSpace(typeName) || string.IsNullOrWhiteSpace(methodName))
return null;
if (!typeName.StartsWith("Il2Cpp.", StringComparison.Ordinal))
typeName = $"Il2Cpp.{typeName}";
return new PatchTargetDescriptor(typeName, methodName, row.GregApiCall.Trim(), normalized);
}
private static string SanitizeIdentifier(string value)
{
if (string.IsNullOrWhiteSpace(value))
return "Unknown";
var sb = new StringBuilder(value.Length);
foreach (char ch in value)
sb.Append(char.IsLetterOrDigit(ch) || ch == '_' ? ch : '_');
return sb.Length == 0 ? "Unknown" : sb.ToString();
}
private static string SanitizeFileName(string value)
{
char[] invalid = Path.GetInvalidFileNameChars();
var sb = new StringBuilder(value.Length);
foreach (char ch in value)
sb.Append(invalid.Contains(ch) ? '_' : ch);
return sb.ToString();
}
private static string EscapeForCSharpLiteral(string input)
{
return input.Replace("\\", "\\\\").Replace("\"", "\\\"");
}
private static string ExtractEventName(string patchTarget)
{
if (string.IsNullOrWhiteSpace(patchTarget))
return string.Empty;
Match match = PatchTargetMethodRegex.Match(patchTarget);
return match.Success
? match.Groups["method"].Value.Trim()
: patchTarget;
}
private static string DeriveCategory(string gregApiCall)
{
if (string.IsNullOrWhiteSpace(gregApiCall))
return "UNKNOWN";
string[] tokens = gregApiCall.Split('.', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (tokens.Length >= 2)
return tokens[1].ToUpperInvariant();
return "UNKNOWN";
}
public async Task<(int ExitCode, string Output)> RunGeneratorAsync(string repoRoot, CancellationToken cancellationToken)
{
string scriptPath = Path.Combine(repoRoot, "gregCore", "scripts", "Generate-GregHooksFromIl2CppDump.ps1");
if (!File.Exists(scriptPath))
throw new FileNotFoundException("Generator script not found", scriptPath);
string workingDir = Path.Combine(repoRoot, "gregCore");
return await RunProcessAsync(
fileName: "pwsh",
arguments: $"-File \"{scriptPath}\"",
workingDirectory: workingDir,
cancellationToken: cancellationToken);
}
public async Task<(int ExitCode, string Output)> BuildGregCoreAsync(string repoRoot, CancellationToken cancellationToken)
{
string workingDir = Path.Combine(repoRoot, "gregCore");
return await RunProcessAsync(
fileName: "dotnet",
arguments: "build .\\gregCore.csproj -nologo -v:minimal",
workingDirectory: workingDir,
cancellationToken: cancellationToken);
}
private static async Task<(int ExitCode, string Output)> RunProcessAsync(
string fileName,
string arguments,
string workingDirectory,
CancellationToken cancellationToken)
{
var startInfo = new ProcessStartInfo
{
FileName = fileName,
Arguments = arguments,
WorkingDirectory = workingDirectory,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true,
};
using var process = new Process { StartInfo = startInfo, EnableRaisingEvents = true };
var output = new StringWriter();
process.OutputDataReceived += (_, e) =>
{
if (e.Data != null)
output.WriteLine(e.Data);
};
process.ErrorDataReceived += (_, e) =>
{
if (e.Data != null)
output.WriteLine(e.Data);
};
process.Start();
process.BeginOutputReadLine();
process.BeginErrorReadLine();
await process.WaitForExitAsync(cancellationToken);
return (process.ExitCode, output.ToString());
}
}
+208
View File
@@ -0,0 +1,208 @@
using System.Reflection;
using System.Reflection.Metadata;
using System.Runtime.Loader;
using System.Security.Cryptography;
using System.Text;
namespace gregExtractor;
public sealed class Il2CppMetadataScanner
{
public SourceSnapshot Scan(string il2CppAssembliesRoot)
{
if (string.IsNullOrWhiteSpace(il2CppAssembliesRoot) || !Directory.Exists(il2CppAssembliesRoot))
throw new DirectoryNotFoundException($"Il2Cpp assemblies directory not found: {il2CppAssembliesRoot}");
string assemblyCSharpPath = Path.Combine(il2CppAssembliesRoot, "Assembly-CSharp.dll");
if (!File.Exists(assemblyCSharpPath))
throw new FileNotFoundException("Assembly-CSharp.dll not found in Il2Cpp assemblies directory.", assemblyCSharpPath);
string runtimeDir = Path.GetDirectoryName(typeof(object).Assembly.Location)
?? throw new InvalidOperationException("Runtime directory could not be resolved.");
string[] runtimeAssemblies = Directory.EnumerateFiles(runtimeDir, "*.dll", SearchOption.TopDirectoryOnly).ToArray();
string[] gameAssemblies = Directory.EnumerateFiles(il2CppAssembliesRoot, "*.dll", SearchOption.TopDirectoryOnly).ToArray();
string[] resolverPaths = runtimeAssemblies
.Concat(gameAssemblies)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
var methods = new List<MethodSnapshot>(capacity: 8192);
var resolver = new PathAssemblyResolver(resolverPaths);
using var metadataContext = new MetadataLoadContext(resolver);
foreach (string assemblyPath in gameAssemblies)
{
Assembly? assembly = null;
try
{
assembly = metadataContext.LoadFromAssemblyPath(assemblyPath);
}
catch
{
continue;
}
string normalizedAssembly = NormalizeAssemblyName(assembly.GetName().Name);
Type[] types;
try
{
types = assembly.GetTypes();
}
catch (ReflectionTypeLoadException ex)
{
types = ex.Types.Where(t => t != null).Cast<Type>().ToArray();
}
foreach (Type type in types)
{
if (type.IsGenericTypeDefinition)
continue;
string normalizedTypeName;
try
{
normalizedTypeName = NormalizeTypeName(type);
}
catch
{
continue;
}
MethodInfo[] declaredMethods;
try
{
declaredMethods = type.GetMethods(BindingFlags.Instance | BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.DeclaredOnly);
}
catch
{
continue;
}
foreach (MethodInfo method in declaredMethods)
{
if (method.IsSpecialName)
continue;
string argList;
try
{
argList = string.Join(", ", method.GetParameters().Select(p => NormalizeParameterTypeName(p.ParameterType)));
}
catch
{
argList = string.Empty;
}
string patchSignature = $"{normalizedTypeName}::{method.Name}({argList})";
string bodyHash = ComputeMetadataHash(assembly.ManifestModule.ModuleVersionId, method.MetadataToken, patchSignature);
methods.Add(new MethodSnapshot
{
Assembly = normalizedAssembly,
TypeName = normalizedTypeName,
MethodName = method.Name,
SignatureKey = $"{normalizedAssembly}|{patchSignature}",
BodyHash = bodyHash,
});
}
}
}
return new SourceSnapshot
{
CreatedUtc = DateTime.UtcNow,
SourceRoot = il2CppAssembliesRoot,
FileCount = gameAssemblies.Length,
Methods = methods
.GroupBy(m => m.SignatureKey, StringComparer.Ordinal)
.Select(g => g.First())
.OrderBy(m => m.SignatureKey, StringComparer.Ordinal)
.ToList(),
};
}
private static string ComputeMetadataHash(Guid moduleVersionId, int metadataToken, string signature)
{
string raw = $"{moduleVersionId:N}:{metadataToken:X8}:{signature}";
byte[] data = SHA256.HashData(Encoding.UTF8.GetBytes(raw));
return Convert.ToHexString(data);
}
private static string NormalizeAssemblyName(string? assemblyName)
{
if (string.IsNullOrWhiteSpace(assemblyName))
return "UNKNOWN";
return assemblyName.Equals("Assembly-CSharp", StringComparison.OrdinalIgnoreCase)
? "Il2Cpp"
: assemblyName;
}
private static string NormalizeTypeName(Type type)
{
string fullName;
try
{
fullName = type.FullName?.Replace('+', '.') ?? type.Name;
}
catch
{
fullName = type.Name;
}
if (string.IsNullOrWhiteSpace(fullName))
fullName = type.Name;
if (!fullName.StartsWith("Il2Cpp.", StringComparison.Ordinal))
fullName = $"Il2Cpp.{fullName}";
return fullName;
}
private static string NormalizeParameterTypeName(Type parameterType)
{
if (parameterType.IsByRef)
parameterType = parameterType.GetElementType() ?? parameterType;
if (parameterType.IsArray)
{
Type elementType = parameterType.GetElementType() ?? parameterType;
return $"{NormalizeParameterTypeName(elementType)}[]";
}
if (parameterType.IsGenericType)
{
string genericName = parameterType.Name;
int tickIndex = genericName.IndexOf('`');
if (tickIndex >= 0)
genericName = genericName[..tickIndex];
string genericArgs = string.Join(", ", parameterType.GetGenericArguments().Select(NormalizeParameterTypeName));
return $"{genericName}<{genericArgs}>";
}
return parameterType.FullName switch
{
"System.Void" => "void",
"System.Boolean" => "bool",
"System.Byte" => "byte",
"System.SByte" => "sbyte",
"System.Int16" => "short",
"System.UInt16" => "ushort",
"System.Int32" => "int",
"System.UInt32" => "uint",
"System.Int64" => "long",
"System.UInt64" => "ulong",
"System.Single" => "float",
"System.Double" => "double",
"System.Decimal" => "decimal",
"System.String" => "string",
"System.Char" => "char",
"System.Object" => "object",
_ => parameterType.FullName?.Replace('+', '.') ?? parameterType.Name,
};
}
}
+973
View File
@@ -0,0 +1,973 @@
using System.Text;
using System.Threading;
using System.Windows.Forms;
namespace gregExtractor;
public sealed class MainForm : Form
{
private readonly TextBox _txtRepoRoot = new();
private readonly TextBox _txtSourceRoot = new();
private readonly TextBox _txtGameRoot = new();
private readonly TextBox _txtMelonGeneratedRoot = new();
private readonly TextBox _txtTemplateOutput = new();
private readonly TextBox _txtTemplatePluginName = new();
private readonly TextBox _txtSummary = new();
private readonly TextBox _txtLog = new();
private readonly DataGridView _gridHooks = new();
private readonly DataGridView _gridAssemblyCoverage = new();
private readonly ComboBox _cmbCategory = new();
private readonly Label _lblCoverage = new();
private readonly Label _lblGregCoreCoverage = new();
private readonly Label _lblAssemblyCoverage = new();
private readonly Button _btnScan = new();
private readonly Button _btnSync = new();
private readonly Button _btnRegenerate = new();
private readonly Button _btnBuild = new();
private readonly Button _btnImportGame = new();
private readonly Button _btnImportMelon = new();
private readonly Button _btnGenerateTemplate = new();
private readonly Button _btnHelp = new();
private readonly CheckBox _chkAutoRegenerate = new();
private readonly CheckBox _chkBuildAfterRegenerate = new();
private readonly CheckBox _chkWatch = new();
private readonly Label _lblStatus = new();
private readonly SnapshotStore _store;
private readonly HookAutomationService _automation;
private readonly SemaphoreSlim _operationLock = new(1, 1);
private readonly System.Windows.Forms.Timer _debounceTimer = new();
private readonly List<FileSystemWatcher> _watchers = new();
private List<HookCatalogRow> _allHookRows = new();
private volatile bool _pendingFileChange;
public MainForm()
{
string repoRoot = ResolveRepoRoot();
string sourceRoot = HookAutomationService.SuggestDefaultIl2CppAssembliesPath();
if (string.IsNullOrWhiteSpace(sourceRoot))
sourceRoot = Path.Combine(repoRoot, "gregReferences", "Assembly-CSharp");
string gameRoot = HookAutomationService.SuggestDefaultGameDirectory();
string melonGeneratedRoot = HookAutomationService.SuggestDefaultMelonGeneratedPath();
string templateOutput = Path.Combine(repoRoot, "gregExtractor", "generated-template");
string stateRoot = Path.Combine(repoRoot, "gregExtractor", "state");
_store = new SnapshotStore(stateRoot);
_automation = new HookAutomationService(new SourceScanner(), _store);
Text = "gregExtractor - Hook Sync GUI";
Width = 1180;
Height = 900;
StartPosition = FormStartPosition.CenterScreen;
BuildUi(repoRoot, sourceRoot, gameRoot, melonGeneratedRoot, templateOutput);
ConfigureWatcherDebounce();
AppendLog("gregExtractor gestartet.");
AppendLog($"RepoRoot: {repoRoot}");
AppendLog($"SourceRoot: {sourceRoot}");
if (!string.IsNullOrWhiteSpace(gameRoot))
AppendLog($"GameRoot: {gameRoot}");
if (!string.IsNullOrWhiteSpace(melonGeneratedRoot))
AppendLog($"MelonGeneratedRoot: {melonGeneratedRoot}");
RefreshHookRows(_store.TryLoadSnapshot());
DarkTheme.Apply(this);
}
private void BuildUi(string repoRoot, string sourceRoot, string gameRoot, string melonGeneratedRoot, string templateOutput)
{
MinimumSize = new System.Drawing.Size(1200, 860);
var panelTop = new GroupBox
{
Dock = DockStyle.Top,
Height = 320,
Text = "Import, Pfade und Aktionen",
Padding = new Padding(10),
};
Controls.Add(panelTop);
var topLayout = new TableLayoutPanel
{
Dock = DockStyle.Fill,
ColumnCount = 4,
RowCount = 10,
Padding = new Padding(4),
};
topLayout.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, 130));
topLayout.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100));
topLayout.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, 150));
topLayout.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, 250));
panelTop.Controls.Add(topLayout);
for (int row = 0; row < topLayout.RowCount; row++)
topLayout.RowStyles.Add(new RowStyle(SizeType.AutoSize));
void AddPathRow(int rowIndex, string labelText, TextBox textBox, string textValue, string browseText, EventHandler browseAction, Button? actionButton = null)
{
var label = new Label
{
Text = labelText,
AutoSize = true,
Anchor = AnchorStyles.Left,
Margin = new Padding(4, 8, 4, 6),
};
textBox.Text = textValue;
textBox.Dock = DockStyle.Fill;
textBox.Margin = new Padding(4, 4, 8, 4);
var browseButton = new Button
{
Text = browseText,
Dock = DockStyle.Fill,
Margin = new Padding(4),
};
browseButton.Click += browseAction;
topLayout.Controls.Add(label, 0, rowIndex);
topLayout.Controls.Add(textBox, 1, rowIndex);
topLayout.Controls.Add(browseButton, 2, rowIndex);
if (actionButton != null)
{
actionButton.Dock = DockStyle.Fill;
actionButton.Margin = new Padding(4);
topLayout.Controls.Add(actionButton, 3, rowIndex);
}
}
_btnImportMelon.Text = "Melon importieren";
_btnImportMelon.Click += async (_, _) => await ImportFromMelonAsync();
_btnImportGame.Text = "Aus Spielordner importieren";
_btnImportGame.Click += async (_, _) => await ImportFromGameAsync();
AddPathRow(0, "Repo:", _txtRepoRoot, repoRoot, "Wählen", (_, _) => BrowseFolder(_txtRepoRoot));
AddPathRow(1, "Source:", _txtSourceRoot, sourceRoot, "Wählen", (_, _) => BrowseFolder(_txtSourceRoot));
AddPathRow(2, "Melon Generated:", _txtMelonGeneratedRoot, melonGeneratedRoot, "Wählen", (_, _) => BrowseFolder(_txtMelonGeneratedRoot), _btnImportMelon);
AddPathRow(3, "Game:", _txtGameRoot, gameRoot, "Wählen", (_, _) => BrowseFolder(_txtGameRoot), _btnImportGame);
AddPathRow(4, "Template Ziel:", _txtTemplateOutput, templateOutput, "Wählen", (_, _) => BrowseFolder(_txtTemplateOutput));
var pluginLabel = new Label
{
Text = "Plugin Name:",
AutoSize = true,
Anchor = AnchorStyles.Left,
Margin = new Padding(4, 8, 4, 6),
};
topLayout.Controls.Add(pluginLabel, 0, 5);
_txtTemplatePluginName.Text = "greg.Plugin.HookTemplate";
_txtTemplatePluginName.Dock = DockStyle.Fill;
_txtTemplatePluginName.Margin = new Padding(4, 4, 8, 4);
topLayout.Controls.Add(_txtTemplatePluginName, 1, 5);
topLayout.SetColumnSpan(_txtTemplatePluginName, 2);
_btnGenerateTemplate.Text = "Plugin Template bauen (alle Hooks)";
_btnGenerateTemplate.Click += async (_, _) => await GenerateTemplateAsync();
_btnGenerateTemplate.Dock = DockStyle.Fill;
_btnGenerateTemplate.Margin = new Padding(4);
topLayout.Controls.Add(_btnGenerateTemplate, 3, 5);
var actionPanel = new FlowLayoutPanel
{
Dock = DockStyle.Fill,
AutoSize = true,
FlowDirection = FlowDirection.LeftToRight,
WrapContents = false,
Margin = new Padding(4, 6, 4, 2),
};
_btnScan.Text = "1) Nur Änderungen scannen";
_btnScan.AutoSize = true;
_btnScan.Click += async (_, _) => await ScanOnlyAsync();
_btnSync.Text = "2) Scan + bei Änderung syncen";
_btnSync.AutoSize = true;
_btnSync.Click += async (_, _) => await ScanAndSyncIfNeededAsync();
_btnRegenerate.Text = "Hooks regenerieren";
_btnRegenerate.AutoSize = true;
_btnRegenerate.Click += async (_, _) => await RegenerateHooksAsync(buildAfter: _chkBuildAfterRegenerate.Checked);
_btnBuild.Text = "gregCore builden";
_btnBuild.AutoSize = true;
_btnBuild.Click += async (_, _) => await BuildGregCoreAsync();
actionPanel.Controls.Add(_btnScan);
actionPanel.Controls.Add(_btnSync);
actionPanel.Controls.Add(_btnRegenerate);
actionPanel.Controls.Add(_btnBuild);
topLayout.Controls.Add(actionPanel, 0, 6);
topLayout.SetColumnSpan(actionPanel, 4);
var optionsPanel = new FlowLayoutPanel
{
Dock = DockStyle.Fill,
AutoSize = true,
FlowDirection = FlowDirection.LeftToRight,
WrapContents = false,
Margin = new Padding(4, 0, 4, 0),
};
_chkWatch.Text = "Dateien überwachen (continuous mode)";
_chkWatch.AutoSize = true;
_chkWatch.CheckedChanged += (_, _) => ToggleWatchers(_chkWatch.Checked);
_chkAutoRegenerate.Text = "Auto-Regenerate bei Änderungen";
_chkAutoRegenerate.AutoSize = true;
_chkAutoRegenerate.Checked = true;
_chkBuildAfterRegenerate.Text = "Nach Regeneration automatisch builden";
_chkBuildAfterRegenerate.AutoSize = true;
_chkBuildAfterRegenerate.Checked = true;
optionsPanel.Controls.Add(_chkWatch);
optionsPanel.Controls.Add(_chkAutoRegenerate);
optionsPanel.Controls.Add(_chkBuildAfterRegenerate);
topLayout.Controls.Add(optionsPanel, 0, 7);
topLayout.SetColumnSpan(optionsPanel, 4);
_btnHelp.Text = "Help";
_btnHelp.AutoSize = true;
_btnHelp.Click += (_, _) => ShowHelpDialog();
topLayout.Controls.Add(_btnHelp, 3, 8);
_lblStatus.Text = "Status: Bereit";
_lblStatus.Dock = DockStyle.Fill;
_lblStatus.AutoEllipsis = true;
_lblStatus.Margin = new Padding(4, 4, 4, 0);
topLayout.Controls.Add(_lblStatus, 0, 9);
topLayout.SetColumnSpan(_lblStatus, 4);
var panelMain = new SplitContainer
{
Dock = DockStyle.Fill,
Orientation = Orientation.Horizontal,
SplitterDistance = 320,
};
Controls.Add(panelMain);
var topSplit = new SplitContainer
{
Dock = DockStyle.Fill,
Orientation = Orientation.Vertical,
SplitterDistance = 470,
};
panelMain.Panel1.Controls.Add(topSplit);
var summaryGroup = new GroupBox
{
Dock = DockStyle.Fill,
Text = "Change Summary",
Padding = new Padding(8),
};
topSplit.Panel1.Controls.Add(summaryGroup);
_txtSummary.Dock = DockStyle.Fill;
_txtSummary.Multiline = true;
_txtSummary.ScrollBars = ScrollBars.Both;
_txtSummary.Font = new System.Drawing.Font("Consolas", 10f);
summaryGroup.Controls.Add(_txtSummary);
var hooksPanel = new GroupBox
{
Dock = DockStyle.Fill,
Text = "Hook Mapping & Coverage",
Padding = new Padding(8),
};
topSplit.Panel2.Controls.Add(hooksPanel);
var hookToolbar = new TableLayoutPanel
{
Dock = DockStyle.Top,
Height = 58,
ColumnCount = 4,
RowCount = 2,
Margin = new Padding(0, 0, 0, 6),
};
hookToolbar.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, 78));
hookToolbar.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, 210));
hookToolbar.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100));
hookToolbar.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, 260));
hookToolbar.RowStyles.Add(new RowStyle(SizeType.Percent, 50));
hookToolbar.RowStyles.Add(new RowStyle(SizeType.Percent, 50));
hooksPanel.Controls.Add(hookToolbar);
var lblCategory = new Label { Text = "Kategorie:", Anchor = AnchorStyles.Left, AutoSize = true };
hookToolbar.Controls.Add(lblCategory, 0, 0);
_cmbCategory.Dock = DockStyle.Fill;
_cmbCategory.DropDownStyle = ComboBoxStyle.DropDownList;
_cmbCategory.SelectedIndexChanged += (_, _) => ApplyHookFilter();
hookToolbar.Controls.Add(_cmbCategory, 1, 0);
_lblCoverage.Dock = DockStyle.Fill;
_lblCoverage.TextAlign = System.Drawing.ContentAlignment.MiddleLeft;
_lblCoverage.Text = "Katalog: n/a";
hookToolbar.Controls.Add(_lblCoverage, 2, 0);
hookToolbar.SetColumnSpan(_lblCoverage, 2);
var lblGregCore = new Label { Text = "gregCore:", Anchor = AnchorStyles.Left, AutoSize = true };
hookToolbar.Controls.Add(lblGregCore, 0, 1);
_lblGregCoreCoverage.Dock = DockStyle.Fill;
_lblGregCoreCoverage.TextAlign = System.Drawing.ContentAlignment.MiddleLeft;
_lblGregCoreCoverage.Text = "Umsetzung: n/a";
hookToolbar.Controls.Add(_lblGregCoreCoverage, 1, 1);
hookToolbar.SetColumnSpan(_lblGregCoreCoverage, 2);
_lblAssemblyCoverage.Dock = DockStyle.Fill;
_lblAssemblyCoverage.TextAlign = System.Drawing.ContentAlignment.MiddleRight;
_lblAssemblyCoverage.Text = "Assemblies: n/a";
hookToolbar.Controls.Add(_lblAssemblyCoverage, 3, 1);
var hookSplit = new SplitContainer
{
Dock = DockStyle.Fill,
Orientation = Orientation.Horizontal,
SplitterDistance = 165,
};
hooksPanel.Controls.Add(hookSplit);
_gridHooks.Dock = DockStyle.Fill;
_gridHooks.AllowUserToAddRows = false;
_gridHooks.AllowUserToDeleteRows = false;
_gridHooks.ReadOnly = true;
_gridHooks.AutoGenerateColumns = false;
_gridHooks.SelectionMode = DataGridViewSelectionMode.FullRowSelect;
_gridHooks.MultiSelect = false;
_gridHooks.Columns.Add(new DataGridViewTextBoxColumn
{
DataPropertyName = nameof(HookCatalogRow.Il2CppHookEvent),
HeaderText = "IL2CPP Hook Event",
Width = 180,
});
_gridHooks.Columns.Add(new DataGridViewTextBoxColumn
{
DataPropertyName = nameof(HookCatalogRow.GregApiCall),
HeaderText = "greg API Call",
Width = 260,
});
_gridHooks.Columns.Add(new DataGridViewTextBoxColumn
{
DataPropertyName = nameof(HookCatalogRow.Category),
HeaderText = "Kategorie",
Width = 120,
});
_gridHooks.Columns.Add(new DataGridViewTextBoxColumn
{
DataPropertyName = nameof(HookCatalogRow.PatchTarget),
HeaderText = "Patch Target",
AutoSizeMode = DataGridViewAutoSizeColumnMode.Fill,
});
hookSplit.Panel1.Controls.Add(_gridHooks);
_gridAssemblyCoverage.Dock = DockStyle.Fill;
_gridAssemblyCoverage.AllowUserToAddRows = false;
_gridAssemblyCoverage.AllowUserToDeleteRows = false;
_gridAssemblyCoverage.ReadOnly = true;
_gridAssemblyCoverage.AutoGenerateColumns = false;
_gridAssemblyCoverage.SelectionMode = DataGridViewSelectionMode.FullRowSelect;
_gridAssemblyCoverage.MultiSelect = false;
_gridAssemblyCoverage.Columns.Add(new DataGridViewTextBoxColumn
{
DataPropertyName = nameof(AssemblyCoverageRow.Assembly),
HeaderText = "Assembly",
Width = 180,
});
_gridAssemblyCoverage.Columns.Add(new DataGridViewTextBoxColumn
{
DataPropertyName = nameof(AssemblyCoverageRow.CoveragePercent),
HeaderText = "Coverage %",
Width = 90,
DefaultCellStyle = new DataGridViewCellStyle { Format = "F2" },
});
_gridAssemblyCoverage.Columns.Add(new DataGridViewTextBoxColumn
{
DataPropertyName = nameof(AssemblyCoverageRow.CoveredUnique),
HeaderText = "Covered",
Width = 80,
});
_gridAssemblyCoverage.Columns.Add(new DataGridViewTextBoxColumn
{
DataPropertyName = nameof(AssemblyCoverageRow.ExpectedUnique),
HeaderText = "Expected",
Width = 80,
});
_gridAssemblyCoverage.Columns.Add(new DataGridViewTextBoxColumn
{
DataPropertyName = nameof(AssemblyCoverageRow.MissingUnique),
HeaderText = "Missing",
Width = 80,
});
_gridAssemblyCoverage.Columns.Add(new DataGridViewTextBoxColumn
{
DataPropertyName = nameof(AssemblyCoverageRow.HookedUnique),
HeaderText = "Hooked",
Width = 80,
});
hookSplit.Panel2.Controls.Add(_gridAssemblyCoverage);
var logGroup = new GroupBox
{
Dock = DockStyle.Fill,
Text = "Log",
Padding = new Padding(8),
};
panelMain.Panel2.Controls.Add(logGroup);
_txtLog.Dock = DockStyle.Fill;
_txtLog.Multiline = true;
_txtLog.ScrollBars = ScrollBars.Both;
_txtLog.Font = new System.Drawing.Font("Consolas", 10f);
logGroup.Controls.Add(_txtLog);
}
private static string ResolveRepoRoot()
{
string current = AppContext.BaseDirectory;
DirectoryInfo? dir = new DirectoryInfo(current);
while (dir != null)
{
bool hasGregCore = Directory.Exists(Path.Combine(dir.FullName, "gregCore"));
bool hasRefs = Directory.Exists(Path.Combine(dir.FullName, "gregReferences"));
if (hasGregCore && hasRefs)
return dir.FullName;
dir = dir.Parent;
}
return Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", ".."));
}
private void BrowseFolder(TextBox target)
{
using var dialog = new FolderBrowserDialog
{
SelectedPath = target.Text,
UseDescriptionForTitle = true,
Description = "Ordner auswählen",
};
if (dialog.ShowDialog(this) == DialogResult.OK)
target.Text = dialog.SelectedPath;
}
private void ConfigureWatcherDebounce()
{
_debounceTimer.Interval = 2500;
_debounceTimer.Tick += async (_, _) =>
{
_debounceTimer.Stop();
if (!_pendingFileChange)
return;
_pendingFileChange = false;
await ScanAndSyncIfNeededAsync();
};
}
private void ToggleWatchers(bool enable)
{
foreach (FileSystemWatcher watcher in _watchers)
watcher.Dispose();
_watchers.Clear();
if (!enable)
{
AppendLog("Watcher deaktiviert.");
return;
}
string sourceRoot = _txtSourceRoot.Text.Trim();
if (!Directory.Exists(sourceRoot))
{
AppendLog("Watcher konnte nicht gestartet werden: SourceRoot nicht gefunden.");
_chkWatch.Checked = false;
return;
}
string[] directories = Directory.GetDirectories(sourceRoot)
.Where(path =>
{
string name = Path.GetFileName(path);
return name.StartsWith("Il2Cpp", StringComparison.OrdinalIgnoreCase)
|| name.StartsWith("Unity", StringComparison.OrdinalIgnoreCase)
|| name.StartsWith("UnityEngine", StringComparison.OrdinalIgnoreCase);
})
.ToArray();
foreach (string dir in directories)
{
var watcher = new FileSystemWatcher(dir, "*.cs")
{
IncludeSubdirectories = true,
NotifyFilter = NotifyFilters.FileName | NotifyFilters.LastWrite | NotifyFilters.Size,
EnableRaisingEvents = true,
};
watcher.Changed += OnWatchedFileChanged;
watcher.Created += OnWatchedFileChanged;
watcher.Deleted += OnWatchedFileChanged;
watcher.Renamed += (_, _) => OnWatchedFileChanged(null, null!);
_watchers.Add(watcher);
}
AppendLog($"Watcher aktiv: {directories.Length} Source-Ordner.");
}
private void OnWatchedFileChanged(object? sender, FileSystemEventArgs args)
{
_pendingFileChange = true;
_debounceTimer.Stop();
_debounceTimer.Start();
}
private async Task ScanOnlyAsync()
{
await RunExclusiveAsync(async () =>
{
string sourceRoot = _txtSourceRoot.Text.Trim();
SourceSnapshot current = _automation.ScanCurrent(sourceRoot);
ChangeReport report = _automation.CompareWithPrevious(current);
_automation.Persist(current, report);
SetSummary(report, current);
RefreshHookRows(current);
AppendLog($"Scan fertig. Methods: {current.Methods.Count}, Added: {report.Added}, Removed: {report.Removed}, SignatureChanged: {report.SignatureChanged}, BodyChanged: {report.BodyChanged}");
}, "Scanne Änderungen...");
}
private async Task ScanAndSyncIfNeededAsync()
{
await RunExclusiveAsync(async () =>
{
await ScanAndSyncCoreAsync();
}, "Scanne + synchronisiere...");
}
private async Task ImportFromMelonAsync()
{
await RunExclusiveAsync(async () =>
{
string melonRoot = _txtMelonGeneratedRoot.Text.Trim();
string sourceRoot = _txtSourceRoot.Text.Trim();
MelonImportResult result = _automation.ImportMelonGeneratedSources(melonRoot, sourceRoot);
AppendLog($"Melon-Import fertig. Dirs: {result.CopiedDirectories}, Files: {result.CopiedFiles}");
AppendLog($"Quelle: {result.SourceRootUsed}");
AppendLog($"Ziel : {result.TargetRoot}");
await ScanAndSyncCoreAsync();
}, "Importiere Melon-generated Dateien...");
}
private async Task ImportFromGameAsync()
{
await RunExclusiveAsync(async () =>
{
string gameRoot = _txtGameRoot.Text.Trim();
string sourceRoot = _txtSourceRoot.Text.Trim();
MelonImportResult result = _automation.ImportFromGameDirectory(gameRoot, sourceRoot);
_txtMelonGeneratedRoot.Text = result.SourceRootUsed;
AppendLog($"Game-Import fertig. Dirs: {result.CopiedDirectories}, Files: {result.CopiedFiles}");
AppendLog($"Spiel : {gameRoot}");
AppendLog($"Quelle: {result.SourceRootUsed}");
AppendLog($"Ziel : {result.TargetRoot}");
await ScanAndSyncCoreAsync();
}, "Importiere Daten direkt aus dem Spielordner...");
}
private async Task GenerateTemplateAsync()
{
await RunExclusiveAsync(async () =>
{
if (_allHookRows.Count == 0)
RefreshHookRows(_store.TryLoadSnapshot());
string repoRoot = _txtRepoRoot.Text.Trim();
string outputDir = _txtTemplateOutput.Text.Trim();
string pluginName = string.IsNullOrWhiteSpace(_txtTemplatePluginName.Text)
? "greg.Plugin.HookTemplate"
: _txtTemplatePluginName.Text.Trim();
string rootNamespace = pluginName.Replace('-', '_').Replace(' ', '_');
string className = BuildClassNameFromPluginName(pluginName);
(string csprojPath, string mainCsPath, string readmePath) = _automation.GeneratePluginTemplate(
repoRoot: repoRoot,
outputDirectory: outputDir,
pluginName: pluginName,
rootNamespace: rootNamespace,
className: className,
author: "gregExtractor",
rows: _allHookRows);
AppendLog("Plugin-Template erzeugt:");
AppendLog($"- {csprojPath}");
AppendLog($"- {mainCsPath}");
AppendLog($"- {readmePath}");
await Task.CompletedTask;
}, "Erzeuge Plugin-Template...");
}
private async Task ScanAndSyncCoreAsync()
{
string sourceRoot = _txtSourceRoot.Text.Trim();
SourceSnapshot current = _automation.ScanCurrent(sourceRoot);
ChangeReport report = _automation.CompareWithPrevious(current);
_automation.Persist(current, report);
SetSummary(report, current);
RefreshHookRows(current);
if (!report.HasChanges)
{
AppendLog("Keine Änderungen erkannt.");
return;
}
AppendLog($"Änderungen erkannt: +{report.Added} / -{report.Removed} / SigΔ {report.SignatureChanged} / BodyΔ {report.BodyChanged}");
if (_chkAutoRegenerate.Checked)
{
AppendLog("Auto-Regenerate aktiv -> Generator wird gestartet.");
await RegenerateHooksCoreAsync(_chkBuildAfterRegenerate.Checked);
}
else
{
AppendLog("Auto-Regenerate ist aus. Nur Report aktualisiert.");
}
}
private async Task RegenerateHooksAsync(bool buildAfter)
{
await RunExclusiveAsync(async () =>
{
await RegenerateHooksCoreAsync(buildAfter);
}, "Regeneriere Hooks...");
}
private async Task RegenerateHooksCoreAsync(bool buildAfter)
{
string repoRoot = _txtRepoRoot.Text.Trim();
(int exitCode, string output) = await _automation.RunGeneratorAsync(repoRoot, CancellationToken.None);
AppendLog("--- Generator Output ---");
AppendLog(output);
if (exitCode != 0)
{
AppendLog($"Generator fehlgeschlagen (ExitCode={exitCode}).");
return;
}
AppendLog("Generator erfolgreich.");
RefreshHookRows(_store.TryLoadSnapshot());
if (buildAfter)
await BuildGregCoreCoreAsync();
}
private async Task BuildGregCoreAsync()
{
await RunExclusiveAsync(async () =>
{
await BuildGregCoreCoreAsync();
}, "Baue gregCore...");
}
private async Task BuildGregCoreCoreAsync()
{
string repoRoot = _txtRepoRoot.Text.Trim();
(int exitCode, string output) = await _automation.BuildGregCoreAsync(repoRoot, CancellationToken.None);
AppendLog("--- Build Output ---");
AppendLog(output);
if (exitCode == 0)
AppendLog("Build erfolgreich.");
else
AppendLog($"Build fehlgeschlagen (ExitCode={exitCode}).");
}
private async Task RunExclusiveAsync(Func<Task> action, string status)
{
if (!await _operationLock.WaitAsync(0))
{
AppendLog("Eine Operation läuft bereits.");
return;
}
try
{
SetBusyUi(true, status);
await action();
}
catch (Exception ex)
{
AppendLog($"FEHLER: {ex.Message}");
}
finally
{
SetBusyUi(false, "Bereit");
_operationLock.Release();
}
}
private void SetBusyUi(bool busy, string status)
{
_btnScan.Enabled = !busy;
_btnSync.Enabled = !busy;
_btnRegenerate.Enabled = !busy;
_btnBuild.Enabled = !busy;
_btnImportGame.Enabled = !busy;
_btnImportMelon.Enabled = !busy;
_btnGenerateTemplate.Enabled = !busy;
_btnHelp.Enabled = !busy;
_lblStatus.Text = $"Status: {status}";
}
private void SetSummary(ChangeReport report, SourceSnapshot snapshot)
{
var sb = new StringBuilder();
sb.AppendLine("gregExtractor - Change Summary");
sb.AppendLine("----------------------------------------------");
sb.AppendLine($"CreatedUtc : {report.CreatedUtc:yyyy-MM-dd HH:mm:ss} UTC");
sb.AppendLine($"SourceRoot : {snapshot.SourceRoot}");
sb.AppendLine($"Source Files : {snapshot.FileCount}");
sb.AppendLine($"Previous Method Count: {report.PreviousCount}");
sb.AppendLine($"Current Method Count : {report.CurrentCount}");
sb.AppendLine($"Added : {report.Added}");
sb.AppendLine($"Removed : {report.Removed}");
sb.AppendLine($"Signature Changed : {report.SignatureChanged}");
sb.AppendLine($"Body Changed : {report.BodyChanged}");
sb.AppendLine($"Has Changes : {report.HasChanges}");
sb.AppendLine();
sb.AppendLine("Hinweis:");
sb.AppendLine("- Signature Changed: API-/Signaturänderungen erkannt.");
sb.AppendLine("- Body Changed: Verhalten/Implementierung geändert, Signatur gleich.");
_txtSummary.Text = sb.ToString();
}
private void RefreshHookRows(SourceSnapshot? currentSnapshot)
{
try
{
string repoRoot = _txtRepoRoot.Text.Trim();
_allHookRows = _automation.LoadHookCatalogRows(repoRoot, _txtMelonGeneratedRoot.Text.Trim()).ToList();
List<HookCatalogRow> gregCoreRows = _automation.LoadGregCoreImplementedHookRows(repoRoot).ToList();
UpdateCategoryOptions();
ApplyHookFilter();
SourceSnapshot? snapshot = currentSnapshot ?? _store.TryLoadSnapshot();
if (snapshot == null)
{
_lblCoverage.Text = $"Katalog: n/a | Hooks: {_allHookRows.Count}";
_lblGregCoreCoverage.Text = $"Umsetzung: n/a | gregCore Hooks: {gregCoreRows.Count}";
_lblAssemblyCoverage.Text = "Assemblies: n/a";
_gridAssemblyCoverage.DataSource = null;
return;
}
HookCoverage catalogCoverage = _automation.CalculateCoverage(snapshot, _allHookRows);
_lblCoverage.Text = $"Katalog: {catalogCoverage.CoveragePercent:F2}% ({catalogCoverage.CoveredUnique}/{catalogCoverage.ExpectedUnique}) | Missing: {catalogCoverage.MissingUnique}";
HookCoverage gregCoreCoverage = _automation.CalculateCoverage(snapshot, gregCoreRows);
bool pluginReady = Math.Abs(gregCoreCoverage.CoveragePercent - 100d) < 0.0001;
string readyState = pluginReady ? "JA" : "NEIN";
_lblGregCoreCoverage.Text = $"Umsetzung: {gregCoreCoverage.CoveragePercent:F2}% ({gregCoreCoverage.CoveredUnique}/{gregCoreCoverage.ExpectedUnique}) | Missing: {gregCoreCoverage.MissingUnique} | Plugin-Ready: {readyState}";
List<AssemblyCoverageRow> assemblyRows = _automation.CalculateCoverageByAssembly(snapshot, gregCoreRows).ToList();
_gridAssemblyCoverage.DataSource = assemblyRows;
_lblAssemblyCoverage.Text = $"gregCore Assemblies: {assemblyRows.Count}";
}
catch (Exception ex)
{
_lblCoverage.Text = "Katalog: Fehler beim Laden";
_lblGregCoreCoverage.Text = "Umsetzung: Fehler";
_lblAssemblyCoverage.Text = "Assemblies: Fehler";
_gridAssemblyCoverage.DataSource = null;
AppendLog($"Hook-Tabelle konnte nicht geladen werden: {ex.Message}");
}
}
private void UpdateCategoryOptions()
{
string previous = _cmbCategory.SelectedItem?.ToString() ?? "Alle";
List<string> categories = _allHookRows
.Select(r => r.Category)
.Where(c => !string.IsNullOrWhiteSpace(c))
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(c => c, StringComparer.OrdinalIgnoreCase)
.ToList();
_cmbCategory.Items.Clear();
_cmbCategory.Items.Add("Alle");
foreach (string category in categories)
_cmbCategory.Items.Add(category);
int idx = _cmbCategory.Items.IndexOf(previous);
_cmbCategory.SelectedIndex = idx >= 0 ? idx : 0;
}
private void ApplyHookFilter()
{
string selected = _cmbCategory.SelectedItem?.ToString() ?? "Alle";
List<HookCatalogRow> filtered = string.Equals(selected, "Alle", StringComparison.OrdinalIgnoreCase)
? _allHookRows
: _allHookRows.Where(r => string.Equals(r.Category, selected, StringComparison.OrdinalIgnoreCase)).ToList();
_gridHooks.DataSource = filtered;
}
private static string BuildClassNameFromPluginName(string pluginName)
{
char[] separators = { '.', '-', ' ', '_', '/' };
string[] segments = pluginName
.Split(separators, StringSplitOptions.RemoveEmptyEntries)
.Select(s => s.Trim())
.Where(s => s.Length > 0)
.ToArray();
if (segments.Length == 0)
return "GeneratedHookTemplate";
var sb = new StringBuilder();
foreach (string segment in segments)
{
if (segment.Length == 1)
sb.Append(char.ToUpperInvariant(segment[0]));
else
sb.Append(char.ToUpperInvariant(segment[0])).Append(segment[1..]);
}
if (!sb.ToString().EndsWith("Template", StringComparison.Ordinal))
sb.Append("Template");
return sb.ToString();
}
private void AppendLog(string text)
{
string line = $"[{DateTime.Now:HH:mm:ss}] {text}{Environment.NewLine}";
_txtLog.AppendText(line);
}
private void ShowHelpDialog()
{
using var helpForm = new Form
{
Text = "gregExtractor Help",
Width = 980,
Height = 760,
StartPosition = FormStartPosition.CenterParent,
MinimizeBox = false,
MaximizeBox = true,
};
var helpText = new TextBox
{
Dock = DockStyle.Fill,
Multiline = true,
ReadOnly = true,
ScrollBars = ScrollBars.Both,
Font = new System.Drawing.Font("Consolas", 10f),
Text = BuildHelpText(),
};
helpForm.Controls.Add(helpText);
helpForm.ShowDialog(this);
}
private static string BuildHelpText()
{
var sb = new StringBuilder();
sb.AppendLine("gregExtractor - Hilfe");
sb.AppendLine("============================================================");
sb.AppendLine();
sb.AppendLine("Kurzantwort auf deine Frage:");
sb.AppendLine("- 'Melon Generated' ist NICHT der normale MelonLoader-Installationspfad alleine,");
sb.AppendLine(" sondern der Pfad, in dem die von Melon/Il2Cpp erzeugten C#-Quellen liegen.");
sb.AppendLine("- Dieser liegt oft im Spieleordner (z. B. <Game>\\MelonLoader\\Generated),");
sb.AppendLine(" kann aber je nach Setup auch unter %LocalAppData%\\MelonLoader liegen.");
sb.AppendLine("- 'Game' ist der Spieleordner selbst. Der Button 'Aus Spielordner importieren'");
sb.AppendLine(" sucht dort automatisch nach passenden Generated-Quellen.");
sb.AppendLine();
sb.AppendLine("Warum das für dich wichtig ist:");
sb.AppendLine("- Du musst nicht mehr jedes Update manuell mit DotPeek in ein Projekt umbauen.");
sb.AppendLine("- gregExtractor zieht die Daten direkt aus dem Spiel-/Melon-Output,");
sb.AppendLine(" erkennt Änderungen, regeneriert Hooks und zeigt den Umsetzungsstand in gregCore.");
sb.AppendLine();
sb.AppendLine("Felder & Buttons im oberen Bereich:");
sb.AppendLine("- Repo:");
sb.AppendLine(" Root deines Monorepos. Von hier werden Skripte/Builds aufgerufen.");
sb.AppendLine("- Source:");
sb.AppendLine(" Zielordner für extrahierte C#-Quellen (typisch gregReferences\\Assembly-CSharp).");
sb.AppendLine("- Melon Generated:");
sb.AppendLine(" Konkreter Generated-Quellpfad. Nutze 'Melon importieren', wenn du ihn direkt kennst.");
sb.AppendLine("- Game:");
sb.AppendLine(" Spieleverzeichnis. Nutze 'Aus Spielordner importieren', wenn gregExtractor suchen soll.");
sb.AppendLine("- Template Ziel + Plugin Name:");
sb.AppendLine(" Ziel und Name für ein Plugin-Template, das alle bekannten greg Hooks subscribed.");
sb.AppendLine();
sb.AppendLine("Aktionen:");
sb.AppendLine("- 1) Nur Änderungen scannen:");
sb.AppendLine(" Vergleicht aktuellen Source-Stand mit letztem Snapshot.");
sb.AppendLine("- 2) Scan + bei Änderung syncen:");
sb.AppendLine(" Scannt und triggert bei Änderungen automatisch Hook-Workflow.");
sb.AppendLine("- Hooks regenerieren:");
sb.AppendLine(" Führt den Hook-Generator aus (Generate-GregHooksFromIl2CppDump.ps1).");
sb.AppendLine("- gregCore builden:");
sb.AppendLine(" Baut gregCore, damit du sofort siehst ob alles compilebar ist.");
sb.AppendLine("- Plugin Template bauen:");
sb.AppendLine(" Erzeugt Vorlage für Plugin-Entwicklung gegen gregCore-Abhängigkeit.");
sb.AppendLine();
sb.AppendLine("Automations-Optionen:");
sb.AppendLine("- Dateien überwachen:");
sb.AppendLine(" Beobachtet Source-Ordner und stößt nach Änderungen (debounced) Sync an.");
sb.AppendLine("- Auto-Regenerate bei Änderungen:");
sb.AppendLine(" Bei erkanntem Delta startet automatisch die Regeneration.");
sb.AppendLine("- Nach Regeneration automatisch builden:");
sb.AppendLine(" Kette bis zum Build komplett durchlaufen lassen.");
sb.AppendLine();
sb.AppendLine("Hook/Coverage-Bereich (wie hilft das bei gregCore-Entwicklung):");
sb.AppendLine("- Katalog:");
sb.AppendLine(" Zeigt, wie viel vom erwarteten Snapshot im Hook-Katalog abgedeckt ist.");
sb.AppendLine("- gregCore Umsetzung:");
sb.AppendLine(" Zeigt, wie viel davon real in gregCore/framework/greg_hooks.json umgesetzt ist.");
sb.AppendLine("- Plugin-Ready: JA/NEIN:");
sb.AppendLine(" JA bedeutet: volle Abdeckung (100%) für die aktuelle Snapshot-Basis.");
sb.AppendLine(" Dann kann gregCore als stabile Abhängigkeit für Plugins genutzt werden.");
sb.AppendLine("- Assembly-Tabelle:");
sb.AppendLine(" Zeigt pro Assembly, wo noch Lücken (Missing) sind.");
sb.AppendLine();
sb.AppendLine("Empfohlener Workflow nach Game-Update:");
sb.AppendLine("1) Game-Pfad setzen.");
sb.AppendLine("2) 'Aus Spielordner importieren' klicken.");
sb.AppendLine("3) Scan/Sync + Regeneration laufen lassen (automatisch oder manuell).");
sb.AppendLine("4) 'gregCore Umsetzung' und Missing-Werte prüfen.");
sb.AppendLine("5) Bei 100% + Plugin-Ready=JA: Plugin-Template erzeugen und neue Features entwickeln.");
sb.AppendLine();
sb.AppendLine("Troubleshooting:");
sb.AppendLine("- Keine Quellen gefunden:");
sb.AppendLine(" Spiel einmal mit MelonLoader starten oder den Generated-Ordner direkt setzen.");
sb.AppendLine("- Import abgebrochen wegen Pfadüberschneidung:");
sb.AppendLine(" Source und Melon/Game-Pfad müssen getrennte Verzeichnisse sein.");
sb.AppendLine("- Coverage bleibt niedrig:");
sb.AppendLine(" Erst Regeneration + Build ausführen, dann erneut prüfen.");
return sb.ToString();
}
}
+1592
View File
File diff suppressed because it is too large Load Diff
+268
View File
@@ -0,0 +1,268 @@
using System.Text.RegularExpressions;
namespace gregExtractor;
public sealed class ModProjectAnalyzer
{
private static readonly Regex HarmonyPatchRegex = new(@"\[(HarmonyPatch|HarmonyPrefix|HarmonyPostfix|HarmonyTranspiler)", RegexOptions.Compiled | RegexOptions.CultureInvariant);
private static readonly Regex MelonModInheritanceRegex = new(@":\s*MelonMod\b", RegexOptions.Compiled | RegexOptions.CultureInvariant);
private static readonly Regex GregPluginInheritanceRegex = new(@":\s*gregPluginBase\b", RegexOptions.Compiled | RegexOptions.CultureInvariant);
private static readonly Regex GregEventSubscriptionRegex = new(@"gregEventDispatcher\.On\s*\(\s*\""(?<hook>[^\""\r\n]+)\""", RegexOptions.Compiled | RegexOptions.CultureInvariant);
private static readonly Regex GregApiReferenceRegex = new(@"\bgreg\.[A-Za-z0-9_.]+", RegexOptions.Compiled | RegexOptions.CultureInvariant);
private static readonly Regex IdentifierTokenRegex = new(@"[A-Za-z][A-Za-z0-9_]{3,}", RegexOptions.Compiled | RegexOptions.CultureInvariant);
public ModProjectAnalysisResult Analyze(string projectRoot, IReadOnlyList<HookCatalogRow> gregCoreRows)
{
if (string.IsNullOrWhiteSpace(projectRoot) || !Directory.Exists(projectRoot))
throw new DirectoryNotFoundException($"Project path not found: {projectRoot}");
string[] files = Directory.EnumerateFiles(projectRoot, "*.cs", SearchOption.AllDirectories)
.Where(path => !IsIgnoredPath(path))
.ToArray();
int harmonyPatchCount = 0;
int melonModCount = 0;
int gregPluginCount = 0;
int gregSubscriptionCount = 0;
int gregApiRefCount = 0;
var usedHooks = new HashSet<string>(StringComparer.Ordinal);
var keywordTokens = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var fileInsights = new List<ModProjectFileInsightRow>();
foreach (string file in files)
{
string text;
try
{
text = File.ReadAllText(file);
}
catch
{
continue;
}
int fileHarmony = HarmonyPatchRegex.Matches(text).Count;
int fileMelon = MelonModInheritanceRegex.Matches(text).Count;
int fileGregPlugin = GregPluginInheritanceRegex.Matches(text).Count;
int fileGregApi = GregApiReferenceRegex.Matches(text).Count;
harmonyPatchCount += fileHarmony;
melonModCount += fileMelon;
gregPluginCount += fileGregPlugin;
gregApiRefCount += fileGregApi;
MatchCollection subscriptionMatches = GregEventSubscriptionRegex.Matches(text);
int fileSubs = subscriptionMatches.Count;
gregSubscriptionCount += fileSubs;
foreach (Match match in subscriptionMatches)
{
string hook = match.Groups["hook"].Value.Trim();
if (!string.IsNullOrWhiteSpace(hook))
usedHooks.Add(hook);
}
foreach (Match tokenMatch in IdentifierTokenRegex.Matches(text))
{
string token = tokenMatch.Value;
if (!string.IsNullOrWhiteSpace(token) && token.Length >= 4)
keywordTokens.Add(token);
}
bool needsMigration = (fileHarmony > 0 && fileSubs == 0)
|| (fileMelon > 0 && fileGregPlugin == 0)
|| (fileHarmony > fileSubs);
string recommendation = BuildFileRecommendation(fileHarmony, fileMelon, fileGregPlugin, fileSubs, fileGregApi, needsMigration);
fileInsights.Add(new ModProjectFileInsightRow
{
FilePath = file,
HarmonyPatches = fileHarmony,
GregSubscriptions = fileSubs,
GregApiReferences = fileGregApi,
NeedsMigration = needsMigration,
Recommendation = recommendation,
});
}
int totalIntegrationPoints = harmonyPatchCount + melonModCount + gregPluginCount + gregSubscriptionCount;
int migratedPoints = gregPluginCount + gregSubscriptionCount;
int remainingPoints = Math.Max(0, totalIntegrationPoints - migratedPoints);
double migrationPercent = totalIntegrationPoints == 0
? (migratedPoints > 0 ? 100d : 0d)
: Math.Min(100d, 100d * migratedPoints / totalIntegrationPoints);
string[] distinctGregCoreHooks = gregCoreRows
.Select(r => r.GregApiCall)
.Where(h => !string.IsNullOrWhiteSpace(h))
.Distinct(StringComparer.Ordinal)
.ToArray();
int usedGregCoreHooks = usedHooks.Count(h => distinctGregCoreHooks.Contains(h, StringComparer.Ordinal));
int missingGregCoreHooks = Math.Max(0, distinctGregCoreHooks.Length - usedGregCoreHooks);
List<string> suggestions = BuildSuggestedHooks(distinctGregCoreHooks, usedHooks, keywordTokens);
List<MigrationOpportunityRow> opportunities = BuildOpportunities(
harmonyPatchCount,
melonModCount,
gregPluginCount,
gregSubscriptionCount,
usedHooks,
suggestions);
return new ModProjectAnalysisResult
{
ProjectRoot = projectRoot,
CSharpFileCount = files.Length,
HarmonyPatchCount = harmonyPatchCount,
MelonModInheritanceCount = melonModCount,
GregPluginInheritanceCount = gregPluginCount,
GregEventSubscriptionCount = gregSubscriptionCount,
GregApiReferenceCount = gregApiRefCount,
IntegrationPointsTotal = totalIntegrationPoints,
IntegrationPointsMigrated = migratedPoints,
IntegrationPointsRemaining = remainingPoints,
MigrationPercent = migrationPercent,
KnownGregCoreHooks = distinctGregCoreHooks.Length,
UsedGregCoreHooks = usedGregCoreHooks,
MissingGregCoreHooks = missingGregCoreHooks,
UsedHooks = usedHooks.OrderBy(x => x, StringComparer.OrdinalIgnoreCase).ToList(),
SuggestedHooks = suggestions,
Opportunities = opportunities,
FileInsights = fileInsights
.OrderByDescending(x => x.NeedsMigration)
.ThenByDescending(x => x.HarmonyPatches)
.ThenBy(x => x.FilePath, StringComparer.OrdinalIgnoreCase)
.ToList(),
};
}
private static string BuildFileRecommendation(int harmonyCount, int melonCount, int gregPluginCount, int subCount, int gregApiCount, bool needsMigration)
{
if (!needsMigration)
return "Looks aligned with greg usage; keep validating hooks after updates.";
if (melonCount > 0 && gregPluginCount == 0)
return "Add gregPluginBase entry and move lifecycle logic out of MelonMod base.";
if (harmonyCount > 0 && subCount == 0)
return "Replace Harmony patches with gregEventDispatcher.On subscriptions where equivalent hooks exist.";
if (harmonyCount > subCount)
return "Prioritize remaining Harmony-heavy paths and migrate to greg hooks incrementally.";
if (gregApiCount == 0)
return "Introduce greg API usage and map game-specific calls to framework events.";
return "Review this file for additional greg hook opportunities.";
}
private static List<string> BuildSuggestedHooks(
IReadOnlyList<string> gregCoreHooks,
IReadOnlySet<string> usedHooks,
IReadOnlySet<string> keywordTokens)
{
var candidates = new List<(string Hook, int Score)>();
foreach (string hook in gregCoreHooks)
{
if (usedHooks.Contains(hook))
continue;
int score = 0;
foreach (string token in keywordTokens)
{
if (hook.Contains(token, StringComparison.OrdinalIgnoreCase))
score += Math.Min(3, token.Length / 4);
}
if (score > 0)
candidates.Add((hook, score));
}
return candidates
.OrderByDescending(x => x.Score)
.ThenBy(x => x.Hook, StringComparer.OrdinalIgnoreCase)
.Select(x => x.Hook)
.Distinct(StringComparer.Ordinal)
.Take(20)
.ToList();
}
private static List<MigrationOpportunityRow> BuildOpportunities(
int harmonyPatchCount,
int melonModCount,
int gregPluginCount,
int gregSubscriptionCount,
IReadOnlySet<string> usedHooks,
IReadOnlyList<string> suggestedHooks)
{
var rows = new List<MigrationOpportunityRow>();
if (melonModCount > 0 && gregPluginCount == 0)
{
rows.Add(new MigrationOpportunityRow
{
Type = "Base Class",
CurrentPattern = "MelonMod inheritance",
SuggestedGregHook = "gregPluginBase",
Suggestion = "Create a greg plugin entry and move shared lifecycle logic into gregPluginBase.",
});
}
if (harmonyPatchCount > 0)
{
rows.Add(new MigrationOpportunityRow
{
Type = "Patching",
CurrentPattern = $"Harmony patches detected: {harmonyPatchCount}",
SuggestedGregHook = "Use matching greg.* events",
Suggestion = "Replace direct Harmony patches with greg hooks where possible to keep compatibility across updates.",
});
}
if (gregSubscriptionCount == 0)
{
rows.Add(new MigrationOpportunityRow
{
Type = "Events",
CurrentPattern = "No gregEventDispatcher.On subscriptions",
SuggestedGregHook = "gregEventDispatcher.On",
Suggestion = "Subscribe to available greg events to reduce game-specific coupling.",
});
}
foreach (string suggested in suggestedHooks.Take(8))
{
rows.Add(new MigrationOpportunityRow
{
Type = "Candidate Hook",
CurrentPattern = "Not yet used",
SuggestedGregHook = suggested,
Suggestion = "Evaluate this hook for replacing direct game calls in your mod code.",
});
}
if (usedHooks.Count > 0)
{
rows.Add(new MigrationOpportunityRow
{
Type = "Validation",
CurrentPattern = $"Used hooks: {usedHooks.Count}",
SuggestedGregHook = "Run plugin tests against updates",
Suggestion = "Validate hook payload handling after each game update and regenerate as needed.",
});
}
return rows;
}
private static bool IsIgnoredPath(string path)
{
string normalized = path.Replace('/', '\\');
return normalized.Contains("\\bin\\", StringComparison.OrdinalIgnoreCase)
|| normalized.Contains("\\obj\\", StringComparison.OrdinalIgnoreCase)
|| normalized.Contains("\\.git\\", StringComparison.OrdinalIgnoreCase)
|| normalized.Contains("\\generated-template\\", StringComparison.OrdinalIgnoreCase);
}
}
+138
View File
@@ -0,0 +1,138 @@
using System.Text.Json.Serialization;
namespace gregExtractor;
public sealed class MethodSnapshot
{
public string Assembly { get; set; } = string.Empty;
public string TypeName { get; set; } = string.Empty;
public string MethodName { get; set; } = string.Empty;
public string SignatureKey { get; set; } = string.Empty;
public string BodyHash { get; set; } = string.Empty;
[JsonIgnore]
public string IdentityKey => $"{Assembly}|{TypeName}|{MethodName}";
}
public sealed class SourceSnapshot
{
public DateTime CreatedUtc { get; set; }
public string SourceRoot { get; set; } = string.Empty;
public int FileCount { get; set; }
public List<MethodSnapshot> Methods { get; set; } = new();
}
public sealed class ChangeReport
{
public DateTime CreatedUtc { get; set; }
public int PreviousCount { get; set; }
public int CurrentCount { get; set; }
public int Added { get; set; }
public int Removed { get; set; }
public int SignatureChanged { get; set; }
public int BodyChanged { get; set; }
public bool HasChanges => Added > 0 || Removed > 0 || SignatureChanged > 0 || BodyChanged > 0;
}
public sealed class HookCatalogRow
{
public string Il2CppHookEvent { get; set; } = string.Empty;
public string GregApiCall { get; set; } = string.Empty;
public string Category { get; set; } = string.Empty;
public string Assembly { get; set; } = string.Empty;
public string PatchTarget { get; set; } = string.Empty;
public string SignatureKey => $"{Assembly}|{PatchTarget}";
}
public readonly struct HookCoverage
{
public HookCoverage(int expectedUnique, int hookedUnique, int coveredUnique)
{
ExpectedUnique = expectedUnique;
HookedUnique = hookedUnique;
CoveredUnique = coveredUnique;
}
public int ExpectedUnique { get; }
public int HookedUnique { get; }
public int CoveredUnique { get; }
public int MissingUnique => Math.Max(0, ExpectedUnique - CoveredUnique);
public double CoveragePercent => ExpectedUnique == 0 ? 0d : (100d * CoveredUnique / ExpectedUnique);
}
public readonly struct AssemblyCoverageRow
{
public AssemblyCoverageRow(string assembly, int expectedUnique, int hookedUnique, int coveredUnique)
{
Assembly = assembly;
ExpectedUnique = expectedUnique;
HookedUnique = hookedUnique;
CoveredUnique = coveredUnique;
}
public string Assembly { get; }
public int ExpectedUnique { get; }
public int HookedUnique { get; }
public int CoveredUnique { get; }
public int MissingUnique => Math.Max(0, ExpectedUnique - CoveredUnique);
public double CoveragePercent => ExpectedUnique == 0 ? 0d : (100d * CoveredUnique / ExpectedUnique);
}
public readonly struct MelonImportResult
{
public MelonImportResult(int copiedFiles, int copiedDirectories, string sourceRootUsed, string targetRoot)
{
CopiedFiles = copiedFiles;
CopiedDirectories = copiedDirectories;
SourceRootUsed = sourceRootUsed;
TargetRoot = targetRoot;
}
public int CopiedFiles { get; }
public int CopiedDirectories { get; }
public string SourceRootUsed { get; }
public string TargetRoot { get; }
}
public sealed class ModProjectAnalysisResult
{
public string ProjectRoot { get; set; } = string.Empty;
public int CSharpFileCount { get; set; }
public int HarmonyPatchCount { get; set; }
public int MelonModInheritanceCount { get; set; }
public int GregPluginInheritanceCount { get; set; }
public int GregEventSubscriptionCount { get; set; }
public int GregApiReferenceCount { get; set; }
public int IntegrationPointsTotal { get; set; }
public int IntegrationPointsMigrated { get; set; }
public int IntegrationPointsRemaining { get; set; }
public double MigrationPercent { get; set; }
public int KnownGregCoreHooks { get; set; }
public int UsedGregCoreHooks { get; set; }
public int MissingGregCoreHooks { get; set; }
public List<string> UsedHooks { get; set; } = new();
public List<string> SuggestedHooks { get; set; } = new();
public List<MigrationOpportunityRow> Opportunities { get; set; } = new();
public List<ModProjectFileInsightRow> FileInsights { get; set; } = new();
}
public sealed class MigrationOpportunityRow
{
public string Type { get; set; } = string.Empty;
public string CurrentPattern { get; set; } = string.Empty;
public string SuggestedGregHook { get; set; } = string.Empty;
public string Suggestion { get; set; } = string.Empty;
}
public sealed class ModProjectFileInsightRow
{
public string FilePath { get; set; } = string.Empty;
public int HarmonyPatches { get; set; }
public int GregSubscriptions { get; set; }
public int GregApiReferences { get; set; }
public bool NeedsMigration { get; set; }
public string Recommendation { get; set; } = string.Empty;
}
+10
View File
@@ -0,0 +1,10 @@
namespace gregExtractor.Models;
public sealed record CoverageReport(
DateTime GeneratedAt,
int TotalHooks,
int CoveredCount,
int PlannedCount,
int UncoveredCount,
IReadOnlyList<HookCoverageEntry> Entries,
double CoveragePercent);
+8
View File
@@ -0,0 +1,8 @@
namespace gregExtractor.Models;
public enum CoverageStatus
{
Covered,
Planned,
Uncovered,
}
+6
View File
@@ -0,0 +1,6 @@
namespace gregExtractor.Models;
public sealed record HookCoverageEntry(
HookDefinition HookDefinition,
CoverageStatus CoverageStatus,
IReadOnlyList<string> FoundInFiles);
+10
View File
@@ -0,0 +1,10 @@
namespace gregExtractor.Models;
public sealed record HookDefinition(
string Group,
string Namespace,
string ClassName,
string MethodName,
string ReturnType,
bool IsVoid,
IReadOnlyList<HookParameter> Parameters);
+6
View File
@@ -0,0 +1,6 @@
namespace gregExtractor.Models;
public sealed record HookDiff(
IReadOnlyList<HookDefinition> Added,
IReadOnlyList<HookDefinition> Removed,
IReadOnlyList<HookDefinition> Changed);
+6
View File
@@ -0,0 +1,6 @@
namespace gregExtractor.Models;
public sealed record HookGroupConfig(
string Description,
IReadOnlyList<string> Classes,
IReadOnlyList<string> Methods);
+3
View File
@@ -0,0 +1,3 @@
namespace gregExtractor.Models;
public sealed record HookParameter(string Name, string Type);
+7
View File
@@ -0,0 +1,7 @@
namespace gregExtractor.Models;
public sealed record SyncResult(
IReadOnlyList<string> FilesWritten,
int LinesAdded,
int LinesRemoved,
IReadOnlyList<string> Warnings);
+11
View File
@@ -0,0 +1,11 @@
namespace gregExtractor.Models;
public enum TemplateType
{
HarmonyPatch,
CustomServer,
CustomUI,
CustomWorld,
CustomFurniture,
CustomNPC,
}
+53
View File
@@ -0,0 +1,53 @@
using Avalonia;
using gregExtractor.Commands;
namespace gregExtractor;
public static class Program
{
[STAThread]
public static int Main(string[] args)
{
if (args.Length == 0 || string.Equals(args[0], "--gui", StringComparison.OrdinalIgnoreCase))
{
BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
return 0;
}
var app = new CommandApp();
app.Configure(config =>
{
config.SetApplicationName("gregExtractor");
config.PropagateExceptions();
config.ValidateExamples();
config.AddCommand<ExtractCommand>("extract")
.WithDescription("Extract hooks from IL2CPP dummy assemblies and generate game_hooks.json");
config.AddCommand<CreateCommand>("create")
.WithDescription("Create a mod scaffold from generated hooks")
.WithExample("create", "MyEconomyMod", "--type", "harmonyPatch", "--category", "Economy");
config.AddCommand<CoverageCommand>("coverage")
.WithDescription("Analyze implementation coverage against Assembly-CSharp + game_hooks + framework patch sources")
.WithExample("coverage")
.WithExample("coverage", "--path", "C:\\Program Files (x86)\\Steam\\steamapps\\common\\Data Center\\MelonLoader\\Il2CppAssemblies")
.WithExample("coverage", "--sources", ".\\Hooks;.\\framework", "--open");
config.AddCommand<SyncCommand>("sync")
.WithDescription("Synchronize gregCore framework source files with updated Assembly-CSharp hooks")
.WithExample("sync", "--source", ".\\gregCore\\src", "--dry-run")
.WithExample("sync", "--source", ".\\gregCore\\src", "--git", "--force");
});
return app.Run(args);
}
public static AppBuilder BuildAvaloniaApp()
{
return AppBuilder
.Configure<GUI.App>()
.UsePlatformDetect()
.LogToTrace();
}
}
+181
View File
@@ -1,2 +1,183 @@
# gregExtractor
`gregExtractor` is a dual-mode mod scaffolder for **Data Center** (Unity IL2CPP + MelonLoader).
It runs as:
- **Desktop GUI** (`gregExtractor` or `gregExtractor --gui`) using Avalonia.
- **CLI tool** (`gregExtractor extract`, `gregExtractor create ...`) using Spectre.Console.Cli.
Both modes use the exact same shared service layer.
---
## What it does
1. Statically reads IL2CPP dummy assemblies (`Assembly-CSharp.dll`) with **Mono.Cecil**.
2. Classifies discovered methods into groups using `hook_groups.json` + heuristics.
3. Writes extraction output:
- `game_hooks.json`
- `unknown_hooks.json`
4. Generates ready-to-edit mod templates for different use cases.
5. Generates coverage reports (`coverage_report.json` + `coverage_report.md`) by comparing:
- Assembly methods (`Assembly-CSharp.dll`)
- planned hooks (`game_hooks.json`)
- implemented Harmony patches (framework sources)
6. Synchronizes framework target files via hook diff (`sync`) with dry-run and optional git stat output.
---
## Wiki
- `wiki/README.md`
- `wiki/cli.md`
- `wiki/ui.md`
- `wiki/known-issues.md`
---
## Command line usage
### Extract hooks
```powershell
gregExtractor extract
gregExtractor extract --path "C:\Program Files (x86)\Steam\steamapps\common\Data Center\MelonLoader\Il2CppAssemblies"
```
### Create mod scaffold
```powershell
gregExtractor create MyEconomyMod --type harmonyPatch --category Economy
gregExtractor create MyServerMod --type customServer --path "D:\dcmods"
```
### Analyze coverage
```powershell
gregExtractor coverage
gregExtractor coverage --path "C:\Program Files (x86)\Steam\steamapps\common\Data Center\MelonLoader\Il2CppAssemblies"
gregExtractor coverage --sources ".\Hooks;.\framework" --out ".\coverage_report" --open
```
### Sync framework files
```powershell
gregExtractor sync --source ".\gregCore\src" --dry-run
gregExtractor sync --source ".\gregCore\src" --git --force
```
Supported `--type` values:
- `harmonyPatch`
- `customServer`
- `customUI`
- `customWorld`
- `customFurniture`
- `customNPC`
---
## GUI usage
```powershell
gregExtractor
gregExtractor --gui
```
GUI capabilities:
- Auto-detect game + IL2CPP paths.
- Start extraction with live progress/logging.
- Review hooks grouped by category.
- Generate mod scaffolds with selected template/category.
- Open generated output folder.
- Coverage tab for in-app report generation and review.
- Dedicated Sync tab for diff preview + controlled framework update runs.
---
## Project architecture
```text
gregExtractor/
├── Program.cs
├── Models/
├── Services/
├── Commands/
├── Utils/
└── GUI/
```
### Shared Core Services
- `Services/ExtractorService.cs`: Mono.Cecil extraction pipeline.
- `Services/HookClassifier.cs`: JSON-driven + heuristic classification.
- `Services/TemplateService.cs`: scaffold generation.
- `Utils/SteamLocator.cs`: Steam + library + game path discovery.
No service performs direct console output; services only report via `IProgress<string>`.
---
## Build
```powershell
dotnet restore
dotnet build
```
---
## Configuration
`hook_groups.json` at project root is the classification source of truth.
If missing or malformed, extraction still works, but groups may fall back to heuristics and `Uncategorized`.
---
## Generated mod files
Every generated C# file includes this required header:
```csharp
// UNITY 6: All Unity API calls must run on the Main Thread!
// Use MelonCoroutines for asynchronous operations.
// Generated by gregExtractor v1.0 — https://dcmods.com
```
Generated Harmony templates default to **Postfix** patches and include **commented Prefix examples**.
`gregCore` integration points are intentionally generated as commented TODOs until package release is finalized.
---
## Environment assumptions
Generated mod projects use:
- `$(MODS_GAME_DIR)\\MelonLoader\\net6\\MelonLoader.dll`
- `$(MODS_GAME_DIR)\\MelonLoader\\net6\\0Harmony.dll`
- `$(MODS_GAME_DIR)\\MelonLoader\\Il2CppAssemblies\\Assembly-CSharp.dll`
Set `MODS_GAME_DIR` in your shell/CI before building generated mods.
---
## Troubleshooting
### Auto-detect fails
- Ensure Steam is installed and readable from registry.
- Ensure the game exists in a Steam library.
- Use `--path` manually.
### `Assembly-CSharp.dll` not found
- Start the game once with MelonLoader installed.
- Verify `MelonLoader\\Il2CppAssemblies` exists.
### Empty or unexpected groups
- Verify `hook_groups.json` is valid JSON.
- Run extraction again after game updates.
+363
View File
@@ -0,0 +1,363 @@
using gregExtractor.Models;
namespace gregExtractor.Services;
public sealed class CoverageAnalyzerService
{
private readonly ICoverageScanner _primaryScanner;
private readonly ICoverageScanner _fallbackScanner;
public CoverageAnalyzerService(ICoverageScanner? primaryScanner = null, ICoverageScanner? fallbackScanner = null)
{
_primaryScanner = primaryScanner ?? new RoslynCoverageScanner();
_fallbackScanner = fallbackScanner ?? new RegexCoverageScanner();
}
public CoverageReport Analyze(AnalyzeOptions options)
{
if (options is null)
throw new ArgumentNullException(nameof(options));
if (string.IsNullOrWhiteSpace(options.Il2CppDir) || !Directory.Exists(options.Il2CppDir))
throw new DirectoryNotFoundException($"IL2CPP directory not found: {options.Il2CppDir}");
if (string.IsNullOrWhiteSpace(options.GameHooksJsonPath) || !File.Exists(options.GameHooksJsonPath))
throw new FileNotFoundException("game_hooks.json not found.", options.GameHooksJsonPath);
if (options.FrameworkSourceDirs is null || options.FrameworkSourceDirs.Length == 0)
throw new ArgumentException("At least one framework source directory is required.", nameof(options.FrameworkSourceDirs));
IProgress<string>? progress = options.Progress;
progress?.Report("[Coverage] Scanning Assembly-CSharp.dll");
Dictionary<string, HookDefinition> assemblyHooksByKey = ScanAssemblyHooks(options.Il2CppDir);
progress?.Report($"[Coverage] Assembly methods discovered: {assemblyHooksByKey.Count}");
progress?.Report("[Coverage] Loading game_hooks.json");
Dictionary<string, HookDefinition> plannedHooksByKey = LoadPlannedHooks(options.GameHooksJsonPath);
progress?.Report("[Coverage] Scanning framework sources for Harmony patches (Roslyn)");
HashSet<string> implementedKeys;
IReadOnlyDictionary<string, IReadOnlyCollection<string>> matchFiles;
try
{
implementedKeys = _primaryScanner.ScanImplementedPatches(options.FrameworkSourceDirs, progress);
matchFiles = _primaryScanner.LastScanMatches;
}
catch (Exception ex)
{
progress?.Report($"[Coverage] Roslyn scanner failed: {ex.Message}");
progress?.Report("[Coverage] Falling back to Regex scanner");
implementedKeys = _fallbackScanner.ScanImplementedPatches(options.FrameworkSourceDirs, progress);
matchFiles = _fallbackScanner.LastScanMatches;
}
if (implementedKeys.Count == 0)
{
progress?.Report("[Coverage] No patches found by primary scanner, trying fallback scanner");
implementedKeys = _fallbackScanner.ScanImplementedPatches(options.FrameworkSourceDirs, progress);
matchFiles = _fallbackScanner.LastScanMatches;
}
HashSet<string> implementedShortKeys = implementedKeys.Select(ToShortKey).ToHashSet(StringComparer.Ordinal);
HashSet<string> plannedKeys = plannedHooksByKey.Keys.ToHashSet(StringComparer.Ordinal);
HashSet<string> plannedShortKeys = plannedKeys.Select(ToShortKey).ToHashSet(StringComparer.Ordinal);
var entries = new List<HookCoverageEntry>(assemblyHooksByKey.Count);
foreach ((string key, HookDefinition assemblyHook) in assemblyHooksByKey)
{
string shortKey = ToShortKey(key);
bool isCovered = implementedKeys.Contains(key) || implementedShortKeys.Contains(shortKey);
bool isPlanned = plannedKeys.Contains(key) || plannedShortKeys.Contains(shortKey);
CoverageStatus status = isCovered
? CoverageStatus.Covered
: isPlanned
? CoverageStatus.Planned
: CoverageStatus.Uncovered;
HookDefinition hook = assemblyHook;
if (status != CoverageStatus.Uncovered)
{
HookDefinition? plannedHook = plannedHooksByKey.TryGetValue(key, out HookDefinition? direct) ? direct : FindByShortKey(plannedHooksByKey, shortKey);
if (plannedHook is not null)
hook = plannedHook;
}
IReadOnlyList<string> files = ResolveFilesForKey(matchFiles, key, shortKey);
entries.Add(new HookCoverageEntry(hook, status, files));
}
entries = entries
.OrderBy(entry => StatusOrder(entry.CoverageStatus))
.ThenBy(entry => entry.HookDefinition.Group, StringComparer.OrdinalIgnoreCase)
.ThenBy(entry => entry.HookDefinition.ClassName, StringComparer.OrdinalIgnoreCase)
.ThenBy(entry => entry.HookDefinition.MethodName, StringComparer.OrdinalIgnoreCase)
.ToList();
int total = entries.Count;
int covered = entries.Count(entry => entry.CoverageStatus == CoverageStatus.Covered);
int planned = entries.Count(entry => entry.CoverageStatus == CoverageStatus.Planned);
int uncovered = entries.Count(entry => entry.CoverageStatus == CoverageStatus.Uncovered);
double coveragePercent = total == 0 ? 0d : covered / (double)total * 100d;
var report = new CoverageReport(
GeneratedAt: DateTime.UtcNow,
TotalHooks: total,
CoveredCount: covered,
PlannedCount: planned,
UncoveredCount: uncovered,
Entries: entries,
CoveragePercent: coveragePercent);
(string jsonPath, string mdPath) = GetReportPaths(options.OutputBasePath);
WriteJsonReport(report, jsonPath);
WriteMarkdownReport(report, mdPath);
progress?.Report($"[Coverage] Reports saved: {jsonPath} + {mdPath}");
return report;
}
public static (string JsonPath, string MarkdownPath) GetReportPaths(string? outputBasePath)
{
string rawPath = string.IsNullOrWhiteSpace(outputBasePath)
? Path.Combine(Directory.GetCurrentDirectory(), "coverage_report")
: outputBasePath;
string withoutExtension = rawPath.EndsWith(".json", StringComparison.OrdinalIgnoreCase)
? rawPath[..^5]
: rawPath.EndsWith(".md", StringComparison.OrdinalIgnoreCase)
? rawPath[..^3]
: rawPath;
return (withoutExtension + ".json", withoutExtension + ".md");
}
private static Dictionary<string, HookDefinition> ScanAssemblyHooks(string il2CppDir)
{
string assemblyPath = Path.Combine(il2CppDir, "Assembly-CSharp.dll");
if (!File.Exists(assemblyPath))
throw new FileNotFoundException("Assembly-CSharp.dll not found.", assemblyPath);
var resolver = new DefaultAssemblyResolver();
resolver.AddSearchDirectory(il2CppDir);
foreach (string subDir in Directory.EnumerateDirectories(il2CppDir))
resolver.AddSearchDirectory(subDir);
using AssemblyDefinition assembly = AssemblyDefinition.ReadAssembly(assemblyPath, new ReaderParameters
{
AssemblyResolver = resolver,
ReadingMode = ReadingMode.Deferred,
ReadSymbols = false,
});
var result = new Dictionary<string, HookDefinition>(StringComparer.Ordinal);
foreach (TypeDefinition type in assembly.MainModule.Types)
{
if (!type.IsPublic || type.IsInterface)
continue;
foreach (MethodDefinition method in type.Methods)
{
if (!method.IsPublic || method.IsConstructor || method.IsSpecialName)
continue;
string @namespace = string.IsNullOrWhiteSpace(type.Namespace) ? "Il2Cpp" : type.Namespace;
string key = NormalizeKey($"{@namespace}.{type.Name}::{method.Name}");
string returnType = NormalizeTypeName(method.ReturnType);
bool isVoid = string.Equals(returnType, "void", StringComparison.OrdinalIgnoreCase);
HookParameter[] parameters = method.Parameters
.Select(parameter => new HookParameter(parameter.Name, NormalizeTypeName(parameter.ParameterType)))
.ToArray();
result[key] = new HookDefinition(
Group: "Uncategorized",
Namespace: @namespace,
ClassName: type.Name,
MethodName: method.Name,
ReturnType: returnType,
IsVoid: isVoid,
Parameters: parameters);
}
}
return result;
}
private static Dictionary<string, HookDefinition> LoadPlannedHooks(string path)
{
string json = File.ReadAllText(path);
HookDefinition[] hooks = JsonSerializer.Deserialize<HookDefinition[]>(json, new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true,
}) ?? Array.Empty<HookDefinition>();
var map = new Dictionary<string, HookDefinition>(StringComparer.Ordinal);
foreach (HookDefinition hook in hooks)
{
string @namespace = string.IsNullOrWhiteSpace(hook.Namespace) ? "Il2Cpp" : hook.Namespace;
string key = NormalizeKey($"{@namespace}.{hook.ClassName}::{hook.MethodName}");
map[key] = hook with { Namespace = @namespace };
}
return map;
}
private static HookDefinition? FindByShortKey(Dictionary<string, HookDefinition> map, string shortKey)
{
foreach ((string key, HookDefinition value) in map)
{
if (string.Equals(ToShortKey(key), shortKey, StringComparison.Ordinal))
return value;
}
return null;
}
private static IReadOnlyList<string> ResolveFilesForKey(IReadOnlyDictionary<string, IReadOnlyCollection<string>> fileMap, string key, string shortKey)
{
if (fileMap.TryGetValue(key, out IReadOnlyCollection<string>? exact))
return exact.OrderBy(x => x, StringComparer.OrdinalIgnoreCase).ToArray();
foreach ((string candidateKey, IReadOnlyCollection<string> files) in fileMap)
{
if (string.Equals(ToShortKey(candidateKey), shortKey, StringComparison.Ordinal))
return files.OrderBy(x => x, StringComparer.OrdinalIgnoreCase).ToArray();
}
return Array.Empty<string>();
}
private static void WriteJsonReport(CoverageReport report, string path)
{
Directory.CreateDirectory(Path.GetDirectoryName(path) ?? Directory.GetCurrentDirectory());
File.WriteAllText(path, JsonSerializer.Serialize(report, new JsonSerializerOptions { WriteIndented = true }));
}
private static void WriteMarkdownReport(CoverageReport report, string path)
{
Directory.CreateDirectory(Path.GetDirectoryName(path) ?? Directory.GetCurrentDirectory());
var builder = new StringBuilder();
builder.AppendLine("# Greg Coverage Report");
builder.AppendLine();
builder.AppendLine($"Generated: `{report.GeneratedAt:O}`");
builder.AppendLine();
builder.AppendLine($"- Total Hooks: **{report.TotalHooks}**");
builder.AppendLine($"- ✅ Covered: **{report.CoveredCount}**");
builder.AppendLine($"- ⚠️ Planned: **{report.PlannedCount}**");
builder.AppendLine($"- ❌ Uncovered: **{report.UncoveredCount}**");
builder.AppendLine($"- Coverage: **{report.CoveragePercent:F2}%**");
builder.AppendLine();
builder.AppendLine("| Status | Gruppe | Klasse | Methode | Gefunden in |");
builder.AppendLine("|--------|--------|--------|---------|-------------|");
foreach (HookCoverageEntry entry in report.Entries)
{
string status = entry.CoverageStatus switch
{
CoverageStatus.Covered => "✅",
CoverageStatus.Planned => "⚠️",
_ => "❌",
};
string group = string.IsNullOrWhiteSpace(entry.HookDefinition.Group) ? "?" : entry.HookDefinition.Group;
string foundIn = entry.FoundInFiles.Count == 0
? entry.CoverageStatus == CoverageStatus.Uncovered ? "(unbekannt)" : "(geplant)"
: string.Join(", ", entry.FoundInFiles.Select(Path.GetFileName));
builder.AppendLine($"| {status} | {EscapePipe(group)} | {EscapePipe(entry.HookDefinition.ClassName)} | {EscapePipe(entry.HookDefinition.MethodName)} | {EscapePipe(foundIn)} |");
}
File.WriteAllText(path, builder.ToString(), Encoding.UTF8);
}
private static string EscapePipe(string value)
{
return value.Replace("|", "\\|");
}
private static int StatusOrder(CoverageStatus status)
{
return status switch
{
CoverageStatus.Covered => 0,
CoverageStatus.Planned => 1,
_ => 2,
};
}
private static string ToShortKey(string key)
{
string normalized = NormalizeKey(key);
int separator = normalized.IndexOf("::", StringComparison.Ordinal);
if (separator < 0)
return normalized;
string typeName = normalized[..separator];
int dotIndex = typeName.LastIndexOf('.');
string shortType = dotIndex >= 0 ? typeName[(dotIndex + 1)..] : typeName;
string method = normalized[(separator + 2)..];
return $"{shortType}::{method}";
}
private static string NormalizeKey(string key)
{
return key.Replace("global::", string.Empty)
.Replace(" ", string.Empty)
.Trim();
}
private static string NormalizeTypeName(TypeReference type)
{
if (type.IsByReference && type is ByReferenceType byReference)
return NormalizeTypeName(byReference.ElementType);
if (type is GenericInstanceType genericType)
{
string genericName = genericType.Name;
int backtick = genericName.IndexOf('`');
if (backtick >= 0)
genericName = genericName[..backtick];
string genericArgs = string.Join(", ", genericType.GenericArguments.Select(NormalizeTypeName));
return $"{genericName}<{genericArgs}>";
}
if (type.IsArray && type is ArrayType arrayType)
return $"{NormalizeTypeName(arrayType.ElementType)}[]";
return type.FullName switch
{
"System.Void" => "void",
"System.Boolean" => "bool",
"System.Byte" => "byte",
"System.SByte" => "sbyte",
"System.Int16" => "short",
"System.UInt16" => "ushort",
"System.Int32" => "int",
"System.UInt32" => "uint",
"System.Int64" => "long",
"System.UInt64" => "ulong",
"System.Single" => "float",
"System.Double" => "double",
"System.Decimal" => "decimal",
"System.String" => "string",
"System.Char" => "char",
"System.Object" => "object",
_ => type.Name,
};
}
}
public sealed record AnalyzeOptions(
string Il2CppDir,
string GameHooksJsonPath,
string[] FrameworkSourceDirs,
string? OutputBasePath,
IProgress<string>? Progress);
+82
View File
@@ -0,0 +1,82 @@
using gregExtractor.Models;
namespace gregExtractor.Services;
public sealed class DiffService
{
public HookDiff Diff(List<HookDefinition> oldHooks, List<HookDefinition> newHooks)
{
oldHooks ??= new List<HookDefinition>();
newHooks ??= new List<HookDefinition>();
Dictionary<string, HookDefinition> oldMap = oldHooks
.GroupBy(GetKey)
.ToDictionary(group => group.Key, group => group.Last(), StringComparer.Ordinal);
Dictionary<string, HookDefinition> newMap = newHooks
.GroupBy(GetKey)
.ToDictionary(group => group.Key, group => group.Last(), StringComparer.Ordinal);
HookDefinition[] added = newMap
.Where(pair => !oldMap.ContainsKey(pair.Key))
.Select(pair => pair.Value)
.OrderBy(x => x.Group, StringComparer.OrdinalIgnoreCase)
.ThenBy(x => x.ClassName, StringComparer.OrdinalIgnoreCase)
.ThenBy(x => x.MethodName, StringComparer.OrdinalIgnoreCase)
.ToArray();
HookDefinition[] removed = oldMap
.Where(pair => !newMap.ContainsKey(pair.Key))
.Select(pair => pair.Value)
.OrderBy(x => x.Group, StringComparer.OrdinalIgnoreCase)
.ThenBy(x => x.ClassName, StringComparer.OrdinalIgnoreCase)
.ThenBy(x => x.MethodName, StringComparer.OrdinalIgnoreCase)
.ToArray();
HookDefinition[] changed = newMap
.Where(pair => oldMap.TryGetValue(pair.Key, out HookDefinition? oldHook) && HasSignatureChange(oldHook, pair.Value))
.Select(pair => pair.Value)
.OrderBy(x => x.Group, StringComparer.OrdinalIgnoreCase)
.ThenBy(x => x.ClassName, StringComparer.OrdinalIgnoreCase)
.ThenBy(x => x.MethodName, StringComparer.OrdinalIgnoreCase)
.ToArray();
return new HookDiff(added, removed, changed);
}
public static string GetKey(HookDefinition hook)
{
return Normalize($"{hook.Namespace}.{hook.ClassName}::{hook.MethodName}");
}
private static bool HasSignatureChange(HookDefinition oldHook, HookDefinition newHook)
{
if (!string.Equals(Normalize(oldHook.ReturnType), Normalize(newHook.ReturnType), StringComparison.Ordinal))
return true;
if (oldHook.Parameters.Count != newHook.Parameters.Count)
return true;
for (int i = 0; i < oldHook.Parameters.Count; i++)
{
HookParameter oldParameter = oldHook.Parameters[i];
HookParameter newParameter = newHook.Parameters[i];
if (!string.Equals(Normalize(oldParameter.Type), Normalize(newParameter.Type), StringComparison.Ordinal))
return true;
if (!string.Equals(Normalize(oldParameter.Name), Normalize(newParameter.Name), StringComparison.Ordinal))
return true;
}
return false;
}
private static string Normalize(string value)
{
return (value ?? string.Empty)
.Replace("global::", string.Empty)
.Replace(" ", string.Empty)
.Trim();
}
}
+140
View File
@@ -0,0 +1,140 @@
using gregExtractor.Models;
namespace gregExtractor.Services;
public sealed class ExtractorService
{
public async Task<IReadOnlyList<HookDefinition>> ExtractAsync(
string il2CppDir,
HookClassifier classifier,
IProgress<string>? progress,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(il2CppDir) || !Directory.Exists(il2CppDir))
throw new DirectoryNotFoundException($"IL2CPP directory not found: {il2CppDir}");
string assemblyPath = Path.Combine(il2CppDir, "Assembly-CSharp.dll");
if (!File.Exists(assemblyPath))
throw new FileNotFoundException("Assembly-CSharp.dll was not found.", assemblyPath);
return await Task.Run(() => ExtractInternal(assemblyPath, il2CppDir, classifier, progress, cancellationToken), cancellationToken)
.ConfigureAwait(false);
}
private static IReadOnlyList<HookDefinition> ExtractInternal(
string assemblyPath,
string il2CppDir,
HookClassifier classifier,
IProgress<string>? progress,
CancellationToken cancellationToken)
{
var resolver = new DefaultAssemblyResolver();
resolver.AddSearchDirectory(il2CppDir);
foreach (string directory in Directory.EnumerateDirectories(il2CppDir))
resolver.AddSearchDirectory(directory);
var readerParameters = new ReaderParameters
{
AssemblyResolver = resolver,
ReadingMode = ReadingMode.Deferred,
ReadSymbols = false,
};
progress?.Report("Loading Assembly-CSharp.dll via Mono.Cecil...");
using AssemblyDefinition assembly = AssemblyDefinition.ReadAssembly(assemblyPath, readerParameters);
var hooks = new List<HookDefinition>(capacity: 8192);
foreach (TypeDefinition type in assembly.MainModule.Types)
{
cancellationToken.ThrowIfCancellationRequested();
if (!type.IsPublic || type.IsInterface)
continue;
string className = type.Name;
string namespaceName = type.Namespace;
foreach (MethodDefinition method in type.Methods)
{
cancellationToken.ThrowIfCancellationRequested();
if (!method.IsPublic)
continue;
if (method.IsConstructor || method.IsSpecialName)
continue;
if (!method.Parameters.Any() && method.ReturnType.FullName.Equals("System.Void", StringComparison.Ordinal))
continue;
var parameters = method.Parameters
.Select(parameter => new HookParameter(parameter.Name, NormalizeTypeName(parameter.ParameterType)))
.ToArray();
string returnType = NormalizeTypeName(method.ReturnType);
bool isVoid = string.Equals(returnType, "void", StringComparison.OrdinalIgnoreCase);
string group = classifier.Classify(className, method.Name);
hooks.Add(new HookDefinition(
Group: group,
Namespace: namespaceName,
ClassName: className,
MethodName: method.Name,
ReturnType: returnType,
IsVoid: isVoid,
Parameters: parameters));
}
}
progress?.Report($"Extraction finished. Hooks found: {hooks.Count}");
return hooks
.OrderBy(hook => hook.Group, StringComparer.OrdinalIgnoreCase)
.ThenBy(hook => hook.ClassName, StringComparer.OrdinalIgnoreCase)
.ThenBy(hook => hook.MethodName, StringComparer.OrdinalIgnoreCase)
.ToArray();
}
private static string NormalizeTypeName(TypeReference typeReference)
{
if (typeReference.IsByReference && typeReference is ByReferenceType byReferenceType)
return NormalizeTypeName(byReferenceType.ElementType);
if (typeReference is GenericInstanceType genericInstanceType)
{
string genericName = genericInstanceType.Name;
int backtick = genericName.IndexOf('`');
if (backtick >= 0)
genericName = genericName[..backtick];
string genericArgs = string.Join(", ", genericInstanceType.GenericArguments.Select(NormalizeTypeName));
return $"{genericName}<{genericArgs}>";
}
if (typeReference.IsArray && typeReference is ArrayType arrayType)
return $"{NormalizeTypeName(arrayType.ElementType)}[]";
return typeReference.FullName switch
{
"System.Void" => "void",
"System.Boolean" => "bool",
"System.Byte" => "byte",
"System.SByte" => "sbyte",
"System.Int16" => "short",
"System.UInt16" => "ushort",
"System.Int32" => "int",
"System.UInt32" => "uint",
"System.Int64" => "long",
"System.UInt64" => "ulong",
"System.Single" => "float",
"System.Double" => "double",
"System.Decimal" => "decimal",
"System.String" => "string",
"System.Char" => "char",
"System.Object" => "object",
_ => typeReference.Name,
};
}
}
+582
View File
@@ -0,0 +1,582 @@
using gregExtractor.Models;
namespace gregExtractor.Services;
public sealed class FrameworkSyncService
{
private sealed class LineCounters
{
public int Added { get; set; }
public int Removed { get; set; }
}
private const string PatchMarker = "// [GREG_SYNC_INSERT_PATCHES]";
private const string EventIdsMarker = "// [GREG_SYNC_INSERT_EVENTIDS]";
private const string EventMapMarker = "// [GREG_SYNC_INSERT_EVENTIDS_MAP]";
private const string DtoMarker = "// [GREG_SYNC_INSERT_DTOS]";
private const string ApiMarker = "// [GREG_SYNC_INSERT_GAMEAPI]";
public SyncResult Sync(SyncOptions options)
{
if (options is null)
throw new ArgumentNullException(nameof(options));
if (string.IsNullOrWhiteSpace(options.FrameworkSourceDir) || !Directory.Exists(options.FrameworkSourceDir))
throw new DirectoryNotFoundException($"Framework source directory not found: {options.FrameworkSourceDir}");
var filesWritten = new List<string>();
var warnings = new List<string>();
var counters = new LineCounters();
string harmonyPath = FindFile(options.FrameworkSourceDir, "gregHarmonyPatches.cs");
string nativeHooksPath = FindFile(options.FrameworkSourceDir, "gregNativeEventHooks.cs");
string eventsPath = FindFile(options.FrameworkSourceDir, "Events.cs");
string gameApiPath = FindFile(options.FrameworkSourceDir, "gregGameApi.cs");
ProcessFile(harmonyPath, options, progressPrefix: "gregHarmonyPatches.cs", PatchMarker,
(content, now) => ApplyHarmonyChanges(content, options, now, counters),
filesWritten);
ProcessFile(nativeHooksPath, options, progressPrefix: "gregNativeEventHooks.cs", EventIdsMarker,
(content, now) => ApplyNativeHooksChanges(content, options, now, counters),
filesWritten);
ProcessFile(eventsPath, options, progressPrefix: "Events.cs", DtoMarker,
(content, now) => ApplyEventsChanges(content, options, now, counters),
filesWritten);
ProcessFile(gameApiPath, options, progressPrefix: "gregGameApi.cs", ApiMarker,
(content, now) => ApplyGameApiChanges(content, options, now, warnings, counters),
filesWritten);
return new SyncResult(filesWritten, counters.Added, counters.Removed, warnings);
}
private static string ApplyHarmonyChanges(string content, SyncOptions options, DateTime now, LineCounters counters)
{
content = EnsureMarker(content, PatchMarker);
foreach (HookDefinition hook in options.Diff.Added)
{
string className = GetPatchClassName(hook);
if (content.Contains($"internal static class {className}", StringComparison.Ordinal))
continue;
string snippet = BuildHarmonyPatchSnippet(hook, now);
content = InsertAtMarker(content, PatchMarker, snippet);
counters.Added += CountLines(snippet);
options.Progress?.Report($"+ [NEU] {DiffService.GetKey(hook)} → Patch hinzugefügt");
}
foreach (HookDefinition hook in options.Diff.Removed)
{
string className = GetPatchClassName(hook);
if (TryCommentOutClass(content, className, now, "Methode existiert nicht mehr in neuer Assembly-Version", out string updated, out int removedLines))
{
content = updated;
counters.Removed += removedLines;
options.Progress?.Report($"- [ENTFERNT] {DiffService.GetKey(hook)} → Patch auskommentiert");
}
}
foreach (HookDefinition hook in options.Diff.Changed)
{
string className = GetPatchClassName(hook);
if (TryUpdatePostfixSignature(content, className, hook, now, out string updated))
{
content = updated;
options.Progress?.Report($"~ [GEÄNDERT] {DiffService.GetKey(hook)} → Signatur aktualisiert");
}
}
return content;
}
private static string ApplyNativeHooksChanges(string content, SyncOptions options, DateTime now, LineCounters counters)
{
content = EnsureMarker(content, EventIdsMarker);
content = EnsureMarker(content, EventMapMarker);
int nextFreeId = GetNextFreeEventId(content);
foreach (HookDefinition hook in options.Diff.Added)
{
string eventName = GetEventIdName(hook);
if (!content.Contains($"public const int {eventName}", StringComparison.Ordinal))
{
string constSnippet = $" // [AUTO-GENERATED]{Environment.NewLine} public const int {eventName} = {nextFreeId};{Environment.NewLine}";
content = InsertAtMarker(content, EventIdsMarker, constSnippet);
counters.Added += CountLines(constSnippet);
nextFreeId++;
}
if (!content.Contains($"EventIds.{eventName}", StringComparison.Ordinal))
{
string mapSnippet = $" {{ EventIds.{eventName}, GregHookName.Create(\"{Escape(hook.Group)}\", \"{Escape(hook.MethodName)}\") }},{Environment.NewLine}";
content = InsertAtMarker(content, EventMapMarker, mapSnippet);
counters.Added += CountLines(mapSnippet);
}
}
foreach (HookDefinition hook in options.Diff.Removed)
{
string eventName = GetEventIdName(hook);
content = CommentMatchingLine(content, $"public const int {eventName}", $"// [DEPRECATED — {now:O}]", counters);
content = CommentMatchingLine(content, $"EventIds.{eventName}", $"// [DEPRECATED — {now:O}]", counters);
}
return content;
}
private static string ApplyEventsChanges(string content, SyncOptions options, DateTime now, LineCounters counters)
{
content = EnsureMarker(content, DtoMarker);
foreach (HookDefinition hook in options.Diff.Added)
{
if (hook.IsVoid && hook.Parameters.Count == 0)
continue;
string structName = GetDtoName(hook);
if (content.Contains($"public struct {structName}", StringComparison.Ordinal))
continue;
string dto = BuildDtoSnippet(hook, now);
content = InsertAtMarker(content, DtoMarker, dto);
counters.Added += CountLines(dto);
}
foreach (HookDefinition hook in options.Diff.Removed)
{
string structName = GetDtoName(hook);
if (TryCommentOutStruct(content, structName, now, out string updated, out int removedLines))
{
content = updated;
counters.Removed += removedLines;
}
}
return content;
}
private static string ApplyGameApiChanges(string content, SyncOptions options, DateTime now, List<string> warnings, LineCounters counters)
{
content = EnsureMarker(content, ApiMarker);
foreach (HookDefinition hook in options.Diff.Added.Where(IsCriticalHook))
{
string methodToken = $"{hook.MethodName}Delegate";
if (content.Contains(methodToken, StringComparison.Ordinal))
continue;
string snippet = BuildGameApiTodoSnippet(hook, now);
content = InsertAtMarker(content, ApiMarker, snippet);
counters.Added += CountLines(snippet);
warnings.Add($"⚠️ GameAPITable: {hook.MethodName} manuell prüfen + API_TABLE_VERSION erhöhen!");
}
return content;
}
private static string BuildHarmonyPatchSnippet(HookDefinition hook, DateTime now)
{
string className = GetPatchClassName(hook);
string parameterList = BuildPatchParameterList(hook);
string namespaceAndClass = $"{hook.Namespace}.{hook.ClassName}";
string eventName = GetEventIdName(hook);
return $"// [AUTO-GENERATED by gregExtractor sync — {now:yyyy-MM-dd}]\n" +
$"// Hook: {hook.Group} — Überprüfe Payload und aktiviere Event-Dispatch!\n" +
$"[HarmonyPatch(typeof({namespaceAndClass}),\n" +
$" nameof({namespaceAndClass}.{hook.MethodName}))]\n" +
$"internal static class {className}\n" +
"{\n" +
$" private static void Postfix({hook.ClassName} __instance{parameterList})\n" +
" {\n" +
" // TODO: Payload extrahieren und dispatchen\n" +
" // GregHookIntegration.EmitForSimple(\n" +
$" // EventIds.{eventName},\n" +
" // __instance\n" +
" // );\n" +
" }\n" +
"}\n\n";
}
private static string BuildDtoSnippet(HookDefinition hook, DateTime now)
{
string structName = GetDtoName(hook);
var builder = new StringBuilder();
builder.AppendLine($"// [AUTO-GENERATED by gregExtractor sync — {now:O}]");
builder.AppendLine("[StructLayout(LayoutKind.Sequential)]");
builder.AppendLine($"public struct {structName} : IModEvent");
builder.AppendLine("{");
builder.AppendLine(" public DateTime OccurredAtUtc { get; init; }");
foreach (HookParameter parameter in hook.Parameters)
{
string parameterName = ToPascalCase(parameter.Name);
string parameterType = NormalizeTypeName(parameter.Type);
builder.AppendLine($" public {parameterType} {parameterName} {{ get; init; }}");
}
builder.AppendLine("}");
builder.AppendLine();
return builder.ToString();
}
private static string BuildGameApiTodoSnippet(HookDefinition hook, DateTime now)
{
string delegateSignature = string.Join(", ", hook.Parameters.Select(x => $"{NormalizeTypeName(x.Type)} {ToCamelCase(x.Name)}"));
string fnName = ToCamelCase(hook.MethodName) + "Fn";
return $"// [AUTO-GENERATED — Version Bump erforderlich!]\n" +
"// WICHTIG: GameAPITable ist ABI-kritisch.\n" +
"// Neue Felder NUR ANS ENDE anhängen, niemals reordnen!\n" +
"// Nach dieser Änderung: API_TABLE_VERSION erhöhen!\n" +
$"// [GeneratedAt: {now:O}]\n" +
$"// public delegate {NormalizeTypeName(hook.ReturnType)} {hook.MethodName}Delegate({delegateSignature});\n" +
"// public IntPtr " + fnName + "; // Offset: TODO_nextOffset\n\n";
}
private static string EnsureMarker(string content, string marker)
{
if (content.Contains(marker, StringComparison.Ordinal))
return content;
return content.TrimEnd() + Environment.NewLine + Environment.NewLine + marker + Environment.NewLine;
}
private static string InsertAtMarker(string content, string marker, string snippet)
{
int markerIndex = content.IndexOf(marker, StringComparison.Ordinal);
if (markerIndex < 0)
return content + Environment.NewLine + snippet;
int insertIndex = markerIndex + marker.Length;
return content.Insert(insertIndex, Environment.NewLine + snippet);
}
private static int GetNextFreeEventId(string content)
{
MatchCollection matches = Regex.Matches(content, @"public\s+const\s+int\s+\w+\s*=\s*(\d+)\s*;", RegexOptions.CultureInvariant);
int max = 0;
foreach (Match match in matches)
{
if (!int.TryParse(match.Groups[1].Value, out int value))
continue;
max = Math.Max(max, value);
}
return max + 1;
}
private static string CommentMatchingLine(string content, string contains, string deprecationHeader, LineCounters counters)
{
string[] lines = content.Replace("\r", string.Empty, StringComparison.Ordinal).Split('\n');
bool changed = false;
for (int i = 0; i < lines.Length; i++)
{
if (!lines[i].Contains(contains, StringComparison.Ordinal))
continue;
if (lines[i].TrimStart().StartsWith("//", StringComparison.Ordinal))
continue;
lines[i] = "// " + lines[i];
counters.Removed++;
changed = true;
if (i == 0 || !lines[i - 1].Contains("[DEPRECATED", StringComparison.Ordinal))
{
lines[i] = deprecationHeader + Environment.NewLine + lines[i];
counters.Removed++;
}
}
return changed ? string.Join(Environment.NewLine, lines) : content;
}
private static bool TryCommentOutClass(string content, string className, DateTime now, string reason, out string updatedContent, out int removedLines)
{
return TryCommentOutTypeBlock(content, $"internal static class {className}",
$"// [DEPRECATED by gregExtractor sync — {now:O}]\n// {reason}",
out updatedContent,
out removedLines);
}
private static bool TryCommentOutStruct(string content, string structName, DateTime now, out string updatedContent, out int removedLines)
{
bool success = TryCommentOutTypeBlock(content, $"public struct {structName}",
$"[Obsolete(\"Removed in game version {now:O}\")]\n// [DEPRECATED by gregExtractor sync — {now:O}]",
out updatedContent,
out removedLines);
return success;
}
private static bool TryCommentOutTypeBlock(string content, string typeDeclarationNeedle, string header, out string updatedContent, out int removedLines)
{
updatedContent = content;
removedLines = 0;
int typeIndex = content.IndexOf(typeDeclarationNeedle, StringComparison.Ordinal);
if (typeIndex < 0)
return false;
int blockStart = FindBlockStart(content, typeIndex);
int braceStart = content.IndexOf('{', typeIndex);
if (braceStart < 0)
return false;
int blockEnd = FindMatchingBrace(content, braceStart);
if (blockEnd < 0)
return false;
string block = content.Substring(blockStart, blockEnd - blockStart + 1);
if (block.Contains("[DEPRECATED by gregExtractor sync", StringComparison.Ordinal))
return false;
string commentedBlock = string.Join(Environment.NewLine,
block.Replace("\r", string.Empty, StringComparison.Ordinal)
.Split('\n')
.Select(line => line.TrimStart().StartsWith("//", StringComparison.Ordinal) ? line : "// " + line));
removedLines += CountLines(block);
string replacement = header + Environment.NewLine + commentedBlock;
updatedContent = content.Remove(blockStart, block.Length).Insert(blockStart, replacement);
return true;
}
private static bool TryUpdatePostfixSignature(string content, string className, HookDefinition hook, DateTime now, out string updatedContent)
{
updatedContent = content;
int classIndex = content.IndexOf($"internal static class {className}", StringComparison.Ordinal);
if (classIndex < 0)
return false;
int classBraceStart = content.IndexOf('{', classIndex);
if (classBraceStart < 0)
return false;
int classBraceEnd = FindMatchingBrace(content, classBraceStart);
if (classBraceEnd < 0)
return false;
string classBlock = content.Substring(classIndex, classBraceEnd - classIndex + 1);
Match postfixMatch = Regex.Match(classBlock, @"private\s+static\s+void\s+Postfix\s*\(([^)]*)\)", RegexOptions.CultureInvariant);
if (!postfixMatch.Success)
return false;
string newSignature = $"{hook.ClassName} __instance{BuildPatchParameterList(hook)}";
if (string.Equals(postfixMatch.Groups[1].Value.Trim(), newSignature.Trim(), StringComparison.Ordinal))
return false;
string replacedBlock = classBlock.Replace(postfixMatch.Groups[1].Value, newSignature, StringComparison.Ordinal);
if (!replacedBlock.Contains("[SIGNATURE CHANGED", StringComparison.Ordinal))
{
replacedBlock = replacedBlock.Replace("private static void Postfix", $"// [SIGNATURE CHANGED — {now:O}] Bitte Payload prüfen!{Environment.NewLine} private static void Postfix", StringComparison.Ordinal);
}
updatedContent = content.Remove(classIndex, classBlock.Length).Insert(classIndex, replacedBlock);
return true;
}
private static int FindBlockStart(string content, int index)
{
int start = index;
while (start > 0)
{
int previousLineStart = content.LastIndexOf('\n', Math.Max(0, start - 2));
if (previousLineStart < 0)
break;
string line = content[(previousLineStart + 1)..start].Trim();
if (line.StartsWith("[", StringComparison.Ordinal) || line.StartsWith("//", StringComparison.Ordinal))
{
start = previousLineStart + 1;
continue;
}
break;
}
return start;
}
private static int FindMatchingBrace(string content, int openBraceIndex)
{
int depth = 0;
for (int i = openBraceIndex; i < content.Length; i++)
{
char current = content[i];
if (current == '{')
depth++;
else if (current == '}')
depth--;
if (depth == 0)
return i;
}
return -1;
}
private static void ProcessFile(
string filePath,
SyncOptions options,
string progressPrefix,
string requiredMarker,
Func<string, DateTime, string> transform,
List<string> filesWritten)
{
DateTime now = DateTime.UtcNow;
options.Progress?.Report($"> Verarbeite {progressPrefix}");
string content = File.ReadAllText(filePath);
string transformed = transform(content, now);
if (string.Equals(content, transformed, StringComparison.Ordinal))
{
options.Progress?.Report($"= Keine Änderungen in {progressPrefix}");
return;
}
if (options.DryRun)
{
options.Progress?.Report($"[DryRun] Würde {progressPrefix} schreiben");
filesWritten.Add(filePath + " (dry-run)");
return;
}
string backupPath = CreateBackup(filePath, now);
options.Progress?.Report($"> Backup erstellt: {backupPath}");
File.WriteAllText(filePath, transformed, Encoding.UTF8);
filesWritten.Add(filePath);
options.Progress?.Report($"✓ {progressPrefix} aktualisiert");
}
private static string CreateBackup(string filePath, DateTime now)
{
string backupPath = filePath + $".bak.{now:yyyyMMdd_HHmmss}";
File.Copy(filePath, backupPath, overwrite: true);
return backupPath;
}
private static string FindFile(string root, string fileName)
{
string? file = Directory
.EnumerateFiles(root, fileName, SearchOption.AllDirectories)
.FirstOrDefault();
if (string.IsNullOrWhiteSpace(file))
throw new FileNotFoundException($"Required framework file '{fileName}' not found under: {root}");
return file;
}
private static bool IsCriticalHook(HookDefinition hook)
{
string group = hook.Group ?? string.Empty;
string method = hook.MethodName ?? string.Empty;
return group.Contains("economy", StringComparison.OrdinalIgnoreCase)
|| group.Contains("persistence", StringComparison.OrdinalIgnoreCase)
|| group.Contains("hardware", StringComparison.OrdinalIgnoreCase)
|| method.Contains("save", StringComparison.OrdinalIgnoreCase)
|| method.Contains("load", StringComparison.OrdinalIgnoreCase)
|| method.Contains("coin", StringComparison.OrdinalIgnoreCase)
|| method.Contains("money", StringComparison.OrdinalIgnoreCase)
|| method.Contains("server", StringComparison.OrdinalIgnoreCase);
}
private static string BuildPatchParameterList(HookDefinition hook)
{
if (hook.Parameters.Count == 0)
return string.Empty;
return string.Concat(hook.Parameters.Select(parameter => $", {NormalizeTypeName(parameter.Type)} {ToCamelCase(parameter.Name)}"));
}
private static string GetPatchClassName(HookDefinition hook)
{
return SanitizeIdentifier($"{hook.ClassName}_{hook.MethodName}Patch");
}
private static string GetDtoName(HookDefinition hook)
{
return SanitizeIdentifier($"{hook.ClassName}_{hook.MethodName}Data");
}
private static string GetEventIdName(HookDefinition hook)
{
return SanitizeIdentifier($"{hook.Group}_{hook.MethodName}");
}
private static string NormalizeTypeName(string type)
{
return (type ?? "object").Trim();
}
private static string SanitizeIdentifier(string value)
{
var builder = new StringBuilder(value.Length);
foreach (char c in value)
{
if (char.IsLetterOrDigit(c) || c == '_')
builder.Append(c);
else
builder.Append('_');
}
if (builder.Length == 0)
return "GeneratedSymbol";
if (!char.IsLetter(builder[0]) && builder[0] != '_')
builder.Insert(0, '_');
return builder.ToString();
}
private static int CountLines(string text)
{
return text.Replace("\r", string.Empty, StringComparison.Ordinal).Split('\n').Length;
}
private static string ToPascalCase(string value)
{
if (string.IsNullOrWhiteSpace(value))
return "Value";
string[] parts = value.Split(new[] { '_', '-', ' ' }, StringSplitOptions.RemoveEmptyEntries);
if (parts.Length == 0)
return "Value";
return string.Concat(parts.Select(part => char.ToUpperInvariant(part[0]) + part[1..]));
}
private static string ToCamelCase(string value)
{
string pascal = ToPascalCase(value);
if (pascal.Length == 1)
return pascal.ToLowerInvariant();
return char.ToLowerInvariant(pascal[0]) + pascal[1..];
}
private static string Escape(string value)
{
return (value ?? string.Empty).Replace("\\", "\\\\", StringComparison.Ordinal).Replace("\"", "\\\"", StringComparison.Ordinal);
}
}
public sealed record SyncOptions(
string FrameworkSourceDir,
HookDiff Diff,
List<HookDefinition> AllHooks,
bool DryRun,
IProgress<string>? Progress);
+121
View File
@@ -0,0 +1,121 @@
using gregExtractor.Models;
namespace gregExtractor.Services;
public sealed class HookClassifier
{
private static readonly Dictionary<string, string[]> MethodKeywordMap = new(StringComparer.OrdinalIgnoreCase)
{
["Persistence"] = ["save", "load", "autosave"],
["Economy"] = ["coin", "money", "shop", "buy", "xp", "reputation", "checkout"],
["Networking"] = ["connect", "device", "cable", "ip", "network", "lacp"],
["Hardware"] = ["power", "repair", "broken", "server", "switch"],
["Lifecycle"] = ["pause", "resume", "day", "scene", "quit", "loadlevel"],
["VisualUI"] = ["ui", "menu", "panel", "screen", "button", "hud"],
};
private static readonly Dictionary<string, string[]> ClassKeywordMap = new(StringComparer.OrdinalIgnoreCase)
{
["Persistence"] = ["save"],
["Economy"] = ["player", "shop", "economy"],
["Networking"] = ["network", "cable", "packet", "switch", "server"],
["Hardware"] = ["server", "rack", "item", "asset"],
["Lifecycle"] = ["loading", "pause", "time", "scene", "playermanager"],
["VisualUI"] = ["menu", "ui", "hud", "panel"],
};
public HookClassifier(IReadOnlyDictionary<string, HookGroupConfig> groups)
{
Groups = groups;
}
public IReadOnlyDictionary<string, HookGroupConfig> Groups { get; }
public static HookClassifier LoadFromFile(string jsonPath, IProgress<string>? progress = null)
{
if (!File.Exists(jsonPath))
{
progress?.Report($"hook_groups.json not found at '{jsonPath}'. Classifier starts with empty groups.");
return new HookClassifier(new Dictionary<string, HookGroupConfig>(StringComparer.OrdinalIgnoreCase));
}
try
{
string json = File.ReadAllText(jsonPath);
Dictionary<string, HookGroupConfig>? model = JsonSerializer.Deserialize<Dictionary<string, HookGroupConfig>>(
json,
new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true,
ReadCommentHandling = JsonCommentHandling.Skip,
});
var normalized = new Dictionary<string, HookGroupConfig>(StringComparer.OrdinalIgnoreCase);
if (model != null)
{
foreach ((string key, HookGroupConfig value) in model)
{
normalized[key] = new HookGroupConfig(
value.Description ?? string.Empty,
value.Classes ?? Array.Empty<string>(),
value.Methods ?? Array.Empty<string>());
}
}
return new HookClassifier(normalized);
}
catch (Exception exception)
{
progress?.Report($"Failed to parse hook_groups.json: {exception.Message}");
return new HookClassifier(new Dictionary<string, HookGroupConfig>(StringComparer.OrdinalIgnoreCase));
}
}
public string Classify(string className, string methodName)
{
// 1) Exact class + method match
foreach ((string groupName, HookGroupConfig groupConfig) in Groups)
{
bool classMatch = groupConfig.Classes.Any(c => EqualsIgnoreCase(c, className));
bool methodMatch = groupConfig.Methods.Any(m => EqualsIgnoreCase(m, methodName));
if (classMatch && methodMatch)
return groupName;
}
// 2) Class match only
foreach ((string groupName, HookGroupConfig groupConfig) in Groups)
{
if (groupConfig.Classes.Any(c => EqualsIgnoreCase(c, className)))
return groupName;
}
// 3) Method match only
foreach ((string groupName, HookGroupConfig groupConfig) in Groups)
{
if (groupConfig.Methods.Any(m => EqualsIgnoreCase(m, methodName)))
return groupName;
}
// 4) Method keyword heuristic
foreach ((string group, string[] keywords) in MethodKeywordMap)
{
if (keywords.Any(keyword => methodName.Contains(keyword, StringComparison.OrdinalIgnoreCase)))
return group;
}
// 5) Class keyword heuristic
foreach ((string group, string[] keywords) in ClassKeywordMap)
{
if (keywords.Any(keyword => className.Contains(keyword, StringComparison.OrdinalIgnoreCase)))
return group;
}
// 6) Fallback
return "Uncategorized";
}
private static bool EqualsIgnoreCase(string left, string right)
{
return string.Equals(left?.Trim(), right?.Trim(), StringComparison.OrdinalIgnoreCase);
}
}
+8
View File
@@ -0,0 +1,8 @@
namespace gregExtractor.Services;
public interface ICoverageScanner
{
HashSet<string> ScanImplementedPatches(string[] sourceDirs, IProgress<string>? progress);
IReadOnlyDictionary<string, IReadOnlyCollection<string>> LastScanMatches { get; }
}
+57
View File
@@ -0,0 +1,57 @@
namespace gregExtractor.Services;
public sealed class RegexCoverageScanner : ICoverageScanner
{
private static readonly Regex PatchRegex = new(
@"\[HarmonyPatch\(typeof\(([^)]+)\),\s*nameof\(([^)]+)\)\)\]",
RegexOptions.Compiled | RegexOptions.CultureInvariant);
private readonly Dictionary<string, HashSet<string>> _matches = new(StringComparer.Ordinal);
public IReadOnlyDictionary<string, IReadOnlyCollection<string>> LastScanMatches => _matches
.ToDictionary(static x => x.Key, static x => (IReadOnlyCollection<string>)x.Value.ToArray(), StringComparer.Ordinal);
public HashSet<string> ScanImplementedPatches(string[] sourceDirs, IProgress<string>? progress)
{
_matches.Clear();
var keys = new HashSet<string>(StringComparer.Ordinal);
foreach (string dir in sourceDirs.Where(Directory.Exists))
{
foreach (string file in Directory.EnumerateFiles(dir, "*.cs", SearchOption.AllDirectories))
{
progress?.Report($"Regex scanning {file}");
string code = File.ReadAllText(file);
foreach (Match match in PatchRegex.Matches(code))
{
string typeName = match.Groups[1].Value.Replace("global::", string.Empty).Trim();
string methodExpr = match.Groups[2].Value.Trim();
int dotIndex = methodExpr.LastIndexOf('.');
string methodName = dotIndex >= 0 ? methodExpr[(dotIndex + 1)..].Trim() : methodExpr;
if (string.IsNullOrWhiteSpace(typeName) || string.IsNullOrWhiteSpace(methodName))
continue;
string key = NormalizeKey($"{typeName}::{methodName}");
keys.Add(key);
if (!_matches.TryGetValue(key, out HashSet<string>? files))
{
files = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
_matches[key] = files;
}
files.Add(file);
}
}
}
return keys;
}
private static string NormalizeKey(string key)
{
return key.Replace(" ", string.Empty);
}
}
+117
View File
@@ -0,0 +1,117 @@
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
namespace gregExtractor.Services;
public sealed class RoslynCoverageScanner : ICoverageScanner
{
private readonly Dictionary<string, HashSet<string>> _matches = new(StringComparer.Ordinal);
public IReadOnlyDictionary<string, IReadOnlyCollection<string>> LastScanMatches => _matches
.ToDictionary(static x => x.Key, static x => (IReadOnlyCollection<string>)x.Value.ToArray(), StringComparer.Ordinal);
public HashSet<string> ScanImplementedPatches(string[] sourceDirs, IProgress<string>? progress)
{
_matches.Clear();
var keys = new HashSet<string>(StringComparer.Ordinal);
foreach (string file in EnumerateSourceFiles(sourceDirs))
{
progress?.Report($"Roslyn scanning {file}");
string code = File.ReadAllText(file);
SyntaxTree tree = CSharpSyntaxTree.ParseText(code);
SyntaxNode root = tree.GetRoot();
IEnumerable<AttributeSyntax> attributes = root.DescendantNodes().OfType<AttributeSyntax>();
foreach (AttributeSyntax attribute in attributes)
{
if (!IsHarmonyPatchAttribute(attribute))
continue;
if (!TryExtractPatchKey(attribute, out string? key))
continue;
key = NormalizeKey(key!);
keys.Add(key);
if (!_matches.TryGetValue(key, out HashSet<string>? files))
{
files = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
_matches[key] = files;
}
files.Add(file);
}
}
return keys;
}
private static IEnumerable<string> EnumerateSourceFiles(IEnumerable<string> sourceDirs)
{
foreach (string dir in sourceDirs.Where(Directory.Exists))
{
foreach (string file in Directory.EnumerateFiles(dir, "*.cs", SearchOption.AllDirectories))
yield return file;
}
}
private static bool IsHarmonyPatchAttribute(AttributeSyntax attribute)
{
string name = attribute.Name.ToString();
return name.Contains("HarmonyPatch", StringComparison.Ordinal);
}
private static bool TryExtractPatchKey(AttributeSyntax attribute, out string? key)
{
key = null;
if (attribute.ArgumentList is null)
return false;
SeparatedSyntaxList<AttributeArgumentSyntax> args = attribute.ArgumentList.Arguments;
if (args.Count < 2)
return false;
if (args[0].Expression is not TypeOfExpressionSyntax typeOfExpression)
return false;
string typeName = typeOfExpression.Type.ToString().Replace("global::", string.Empty).Trim();
if (string.IsNullOrWhiteSpace(typeName))
return false;
if (!TryExtractMethodName(args[1].Expression, out string? methodName))
return false;
key = $"{typeName}::{methodName}";
return true;
}
private static bool TryExtractMethodName(ExpressionSyntax expression, out string? methodName)
{
methodName = null;
if (expression is InvocationExpressionSyntax invocation
&& invocation.Expression is IdentifierNameSyntax nameofIdentifier
&& nameofIdentifier.Identifier.Text == "nameof"
&& invocation.ArgumentList.Arguments.Count == 1)
{
ExpressionSyntax argExpression = invocation.ArgumentList.Arguments[0].Expression;
methodName = argExpression switch
{
MemberAccessExpressionSyntax memberAccess => memberAccess.Name.Identifier.Text,
IdentifierNameSyntax identifierName => identifierName.Identifier.Text,
_ => null,
};
}
return !string.IsNullOrWhiteSpace(methodName);
}
private static string NormalizeKey(string key)
{
return key.Replace(" ", string.Empty);
}
}
+388
View File
@@ -0,0 +1,388 @@
using gregExtractor.Models;
namespace gregExtractor.Services;
public sealed class TemplateService
{
private const string Header = "// UNITY 6: All Unity API calls must run on the Main Thread!\n// Use MelonCoroutines for asynchronous operations.\n// Generated by gregExtractor v1.0 — https://dcmods.com\n";
public async Task<TemplateGenerationResult> GenerateModAsync(
string modName,
string? category,
TemplateType type,
string outputRoot,
string hooksJsonPath,
IProgress<string>? progress,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(modName))
throw new ArgumentException("Mod name is required.", nameof(modName));
if (string.IsNullOrWhiteSpace(outputRoot))
throw new ArgumentException("Output path is required.", nameof(outputRoot));
return await Task.Run(() =>
{
cancellationToken.ThrowIfCancellationRequested();
string sanitizedName = SanitizeName(modName);
string modDir = Path.Combine(outputRoot, sanitizedName);
Directory.CreateDirectory(modDir);
var generatedFiles = new List<string>();
progress?.Report("Writing base project files...");
generatedFiles.Add(WriteFile(modDir, $"{sanitizedName}.csproj", BuildCsproj(sanitizedName)));
generatedFiles.Add(WriteFile(modDir, "MainPlugin.cs", BuildMainPlugin(sanitizedName, category)));
generatedFiles.Add(WriteFile(modDir, "mod.json", BuildModJson(sanitizedName, category, type)));
switch (type)
{
case TemplateType.HarmonyPatch:
GenerateHarmonyPatches(modDir, sanitizedName, category, hooksJsonPath, generatedFiles, progress, cancellationToken);
break;
case TemplateType.CustomServer:
generatedFiles.Add(WriteFile(modDir, Path.Combine("Definitions", "ServerDefinition.json"), BuildSimpleDefinition("server", sanitizedName, category)));
generatedFiles.Add(WriteFile(modDir, Path.Combine("Logic", $"{sanitizedName}ServerBehaviour.cs"), BuildCustomServerBehaviour(sanitizedName)));
generatedFiles.Add(WriteFile(modDir, Path.Combine("Logic", $"{sanitizedName}ServerRegistration.cs"), BuildCustomServerRegistration(sanitizedName)));
break;
case TemplateType.CustomUI:
generatedFiles.Add(WriteFile(modDir, Path.Combine("UI", $"{sanitizedName}UIManifest.json"), BuildSimpleDefinition("ui", sanitizedName, category)));
generatedFiles.Add(WriteFile(modDir, Path.Combine("UI", $"{sanitizedName}Panel.cs"), BuildCustomUiPanel(sanitizedName)));
break;
case TemplateType.CustomWorld:
generatedFiles.Add(WriteFile(modDir, Path.Combine("World", $"{sanitizedName}WorldDefinition.json"), BuildSimpleDefinition("world", sanitizedName, category)));
generatedFiles.Add(WriteFile(modDir, Path.Combine("World", $"{sanitizedName}WorldLoader.cs"), BuildCustomWorldLoader(sanitizedName)));
break;
case TemplateType.CustomFurniture:
generatedFiles.Add(WriteFile(modDir, Path.Combine("Furniture", $"{sanitizedName}FurnitureDefinition.json"), BuildSimpleDefinition("furniture", sanitizedName, category)));
generatedFiles.Add(WriteFile(modDir, Path.Combine("Furniture", $"{sanitizedName}FurnitureBehaviour.cs"), BuildCustomFurnitureBehaviour(sanitizedName)));
break;
case TemplateType.CustomNPC:
generatedFiles.Add(WriteFile(modDir, Path.Combine("NPC", $"{sanitizedName}NPCDefinition.json"), BuildSimpleDefinition("npc", sanitizedName, category)));
generatedFiles.Add(WriteFile(modDir, Path.Combine("NPC", $"{sanitizedName}NPCBehaviour.cs"), BuildCustomNpcBehaviour(sanitizedName)));
break;
}
progress?.Report($"Mod scaffold generated in '{modDir}'.");
return new TemplateGenerationResult(modDir, generatedFiles);
}, cancellationToken).ConfigureAwait(false);
}
private static void GenerateHarmonyPatches(
string modDir,
string modName,
string? category,
string hooksJsonPath,
List<string> generatedFiles,
IProgress<string>? progress,
CancellationToken cancellationToken)
{
if (!File.Exists(hooksJsonPath))
throw new FileNotFoundException("game_hooks.json was not found. Run 'extract' first.", hooksJsonPath);
string json = File.ReadAllText(hooksJsonPath);
HookDefinition[] hooks = JsonSerializer.Deserialize<HookDefinition[]>(json, new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true,
}) ?? Array.Empty<HookDefinition>();
IEnumerable<HookDefinition> filtered = hooks;
if (!string.IsNullOrWhiteSpace(category))
filtered = filtered.Where(hook => string.Equals(hook.Group, category, StringComparison.OrdinalIgnoreCase));
foreach (HookDefinition hook in filtered)
{
cancellationToken.ThrowIfCancellationRequested();
string groupName = string.IsNullOrWhiteSpace(hook.Group) ? "Misc" : hook.Group;
string patchDir = Path.Combine(modDir, "Patches", groupName);
string patchFile = $"{hook.ClassName}_{hook.MethodName}Patch.cs";
string patchContent = BuildHarmonyPatch(modName, hook, groupName);
generatedFiles.Add(WriteFile(modDir, Path.Combine("Patches", groupName, patchFile), patchContent));
}
progress?.Report($"Harmony patches generated: {generatedFiles.Count(file => file.Contains("Patches", StringComparison.OrdinalIgnoreCase))}");
}
private static string BuildCsproj(string modName)
{
return $$"""
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net6.0</TargetFramework>
<LangVersion>latest</LangVersion>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<Reference Include="MelonLoader">
<HintPath>$(MODS_GAME_DIR)\MelonLoader\net6\MelonLoader.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="HarmonyLib">
<HintPath>$(MODS_GAME_DIR)\MelonLoader\net6\0Harmony.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="Assembly-CSharp">
<HintPath>$(MODS_GAME_DIR)\MelonLoader\Il2CppAssemblies\Assembly-CSharp.dll</HintPath>
<Private>false</Private>
</Reference>
</ItemGroup>
</Project>
""";
}
private static string BuildMainPlugin(string modName, string? category)
{
string safeCategory = string.IsNullOrWhiteSpace(category) ? "Misc" : category;
return $$"""
{{Header}}
using MelonLoader;
[assembly: MelonInfo(typeof({{modName}}.MainPlugin), "{{modName}}", "1.0.0", "YourName")]
[assembly: MelonGame("Waseku", "Data Center")]
namespace {{modName}};
public sealed class MainPlugin : MelonMod
{
public override void OnInitializeMelon()
{
LoggerInstance.Msg("{{modName}} initialized.");
// TODO: Enable gregCore registry calls after NuGet release.
// gregCore.Registry.Register("{{modName}}", "{{safeCategory}}");
}
}
""";
}
private static string BuildHarmonyPatch(string modName, HookDefinition hook, string groupName)
{
string canonicalGroup = groupName.ToLowerInvariant();
string canonicalMethod = hook.MethodName;
string typeName = string.IsNullOrWhiteSpace(hook.Namespace) ? hook.ClassName : $"{hook.Namespace}.{hook.ClassName}";
return $$"""
{{Header}}
using HarmonyLib;
namespace {{modName}}.Patches.{{groupName}};
[HarmonyPatch(typeof({{typeName}}), nameof({{typeName}}.{{hook.MethodName}}))]
public static class {{hook.ClassName}}_{{hook.MethodName}}Patch
{
// Example Prefix:
// private static void Prefix()
// {
// }
private static void Postfix(object? __instance)
{
// TODO: Enable gregCore event bus when NuGet package is available.
// gregGameApi.Publish("greg.{{canonicalGroup}}.{{canonicalMethod}}", new { instance = __instance });
}
}
""";
}
private static string BuildCustomServerBehaviour(string modName)
{
return $$"""
{{Header}}
namespace {{modName}}.Logic;
public sealed class {{modName}}ServerBehaviour
{
public void OnPowerOn()
{
}
public void OnPowerOff()
{
}
public void OnTick(float deltaTime)
{
}
public void OnBreak()
{
}
}
""";
}
private static string BuildCustomServerRegistration(string modName)
{
return $$"""
{{Header}}
namespace {{modName}}.Logic;
public static class {{modName}}ServerRegistration
{
public static void Register()
{
// TODO: Enable gregCore registry call after NuGet release.
// gregCore.Registry.RegisterServer("{{modName}}");
}
}
""";
}
private static string BuildCustomUiPanel(string modName)
{
return $$"""
{{Header}}
namespace {{modName}}.UI;
public sealed class {{modName}}Panel
{
public void Initialize()
{
}
public void Show()
{
}
public void Hide()
{
}
public void BuildUI()
{
}
}
""";
}
private static string BuildCustomWorldLoader(string modName)
{
return $$"""
{{Header}}
namespace {{modName}}.World;
public sealed class {{modName}}WorldLoader
{
public void Register()
{
// TODO: Enable gregCore world registration API.
}
public void OnSceneLoaded(int sceneIndex)
{
}
}
""";
}
private static string BuildCustomFurnitureBehaviour(string modName)
{
return $$"""
{{Header}}
namespace {{modName}}.Furniture;
public sealed class {{modName}}FurnitureBehaviour
{
public void OnPlaced()
{
}
public void OnRemoved()
{
}
public void OnInteract()
{
}
}
""";
}
private static string BuildCustomNpcBehaviour(string modName)
{
return $$"""
{{Header}}
namespace {{modName}}.NPC;
public sealed class {{modName}}NPCBehaviour
{
public void OnHired()
{
}
public void OnFired()
{
}
public void OnTaskAssigned(string taskId)
{
}
public void OnTaskCompleted(string taskId)
{
}
}
""";
}
private static string BuildSimpleDefinition(string type, string modName, string? category)
{
return JsonSerializer.Serialize(
new
{
type,
id = modName,
name = modName,
category = string.IsNullOrWhiteSpace(category) ? "Misc" : category,
version = "1.0.0",
},
new JsonSerializerOptions { WriteIndented = true });
}
private static string BuildModJson(string modName, string? category, TemplateType type)
{
return JsonSerializer.Serialize(
new
{
id = modName,
name = modName,
version = "1.0.0",
author = "YourName",
description = $"Generated {type} mod scaffold.",
category = string.IsNullOrWhiteSpace(category) ? "Misc" : category,
gregCoreVersion = "0.0.0-local",
dependencies = new[] { "gregCore" },
},
new JsonSerializerOptions { WriteIndented = true });
}
private static string WriteFile(string baseDirectory, string relativePath, string content)
{
string fullPath = Path.Combine(baseDirectory, relativePath);
string? directory = Path.GetDirectoryName(fullPath);
if (!string.IsNullOrWhiteSpace(directory))
Directory.CreateDirectory(directory);
File.WriteAllText(fullPath, content);
return fullPath;
}
private static string SanitizeName(string value)
{
var builder = new StringBuilder(value.Length);
foreach (char character in value)
builder.Append(char.IsLetterOrDigit(character) || character is '_' or '.' ? character : '_');
return builder.ToString().Trim('_');
}
}
public sealed record TemplateGenerationResult(string ModDirectory, IReadOnlyList<string> GeneratedFiles);
+44
View File
@@ -0,0 +1,44 @@
using System.Text.Json;
namespace gregExtractor;
public sealed class SnapshotStore
{
private readonly JsonSerializerOptions _jsonOptions = new()
{
WriteIndented = true,
};
public SnapshotStore(string stateDirectory)
{
StateDirectory = stateDirectory;
Directory.CreateDirectory(StateDirectory);
SnapshotPath = Path.Combine(StateDirectory, "source-snapshot.json");
ReportPath = Path.Combine(StateDirectory, "last-change-report.json");
}
public string StateDirectory { get; }
public string SnapshotPath { get; }
public string ReportPath { get; }
public SourceSnapshot? TryLoadSnapshot()
{
if (!File.Exists(SnapshotPath))
return null;
string json = File.ReadAllText(SnapshotPath);
return JsonSerializer.Deserialize<SourceSnapshot>(json, _jsonOptions);
}
public void SaveSnapshot(SourceSnapshot snapshot)
{
string json = JsonSerializer.Serialize(snapshot, _jsonOptions);
File.WriteAllText(SnapshotPath, json);
}
public void SaveReport(ChangeReport report)
{
string json = JsonSerializer.Serialize(report, _jsonOptions);
File.WriteAllText(ReportPath, json);
}
}
+243
View File
@@ -0,0 +1,243 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.RegularExpressions;
namespace gregExtractor;
public sealed class SourceScanner
{
private readonly Il2CppMetadataScanner _metadataScanner = new();
private static readonly Regex ClassRegex = new(
@"^\s*(?:public|private|protected|internal)\s+(?:sealed\s+)?class\s+(?<name>[A-Za-z0-9_]+)(\s*:\s*(?<base>[^\r\n{]+))?",
RegexOptions.Compiled | RegexOptions.CultureInvariant);
private static readonly Regex MethodRegex = new(
@"^\s+(?:public|private|protected|internal)\s+(?<mods>(?:(?:static|unsafe|virtual|override|abstract|sealed|new|extern|partial|async)\s+)*)?(?<sig>.+?)\s*\((?<args>[^\)]*)\)\s*$",
RegexOptions.Compiled | RegexOptions.CultureInvariant);
public SourceSnapshot Scan(string sourceRoot)
{
if (string.IsNullOrWhiteSpace(sourceRoot))
throw new ArgumentException("Source root is required.", nameof(sourceRoot));
if (TryResolveIl2CppAssembliesRoot(sourceRoot, out string? il2CppAssembliesRoot))
{
try
{
return _metadataScanner.Scan(il2CppAssembliesRoot!);
}
catch when (Directory.Exists(sourceRoot))
{
}
}
if (!Directory.Exists(sourceRoot))
throw new DirectoryNotFoundException($"Source root not found: {sourceRoot}");
var methodList = new List<MethodSnapshot>(capacity: 4096);
string[] sourceDirectories = Directory.GetDirectories(sourceRoot)
.Where(path =>
{
string name = Path.GetFileName(path);
return name.StartsWith("Il2Cpp", StringComparison.OrdinalIgnoreCase)
|| name.StartsWith("Unity", StringComparison.OrdinalIgnoreCase)
|| name.StartsWith("UnityEngine", StringComparison.OrdinalIgnoreCase);
})
.ToArray();
if (sourceDirectories.Length == 0)
throw new InvalidOperationException("No supported source directories found. Expected Il2Cpp*/Unity*/UnityEngine* folders or an Il2CppAssemblies DLL path.");
foreach (string directory in sourceDirectories)
{
string assemblyName = Path.GetFileName(directory);
foreach (string file in Directory.EnumerateFiles(directory, "*.cs", SearchOption.AllDirectories))
{
ParseFile(file, assemblyName, methodList);
}
}
return new SourceSnapshot
{
CreatedUtc = DateTime.UtcNow,
SourceRoot = sourceRoot,
FileCount = sourceDirectories.Sum(d => Directory.EnumerateFiles(d, "*.cs", SearchOption.AllDirectories).Count()),
Methods = methodList
.GroupBy(m => m.SignatureKey, StringComparer.Ordinal)
.Select(g => g.First())
.OrderBy(m => m.SignatureKey, StringComparer.Ordinal)
.ToList(),
};
}
private static bool TryResolveIl2CppAssembliesRoot(string path, out string? il2CppAssembliesRoot)
{
il2CppAssembliesRoot = null;
string fullPath = Path.GetFullPath(path);
if (File.Exists(fullPath) && string.Equals(Path.GetFileName(fullPath), "Assembly-CSharp.dll", StringComparison.OrdinalIgnoreCase))
{
il2CppAssembliesRoot = Path.GetDirectoryName(fullPath)!;
return true;
}
if (!Directory.Exists(fullPath))
return false;
string[] candidateDirectories =
{
fullPath,
Path.Combine(fullPath, "MelonLoader", "Il2CppAssemblies"),
Path.Combine(fullPath, "Il2CppAssemblies"),
};
foreach (string candidate in candidateDirectories)
{
if (Directory.Exists(candidate) && File.Exists(Path.Combine(candidate, "Assembly-CSharp.dll")))
{
il2CppAssembliesRoot = candidate;
return true;
}
}
return false;
}
private static void ParseFile(string filePath, string assemblyName, List<MethodSnapshot> output)
{
string? currentClass = null;
string[] lines = File.ReadAllLines(filePath);
for (int index = 0; index < lines.Length; index++)
{
string line = lines[index];
Match classMatch = ClassRegex.Match(line);
if (classMatch.Success)
{
currentClass = classMatch.Groups["name"].Value.Trim();
continue;
}
if (string.IsNullOrWhiteSpace(currentClass))
continue;
Match methodMatch = MethodRegex.Match(line);
if (!methodMatch.Success)
continue;
string signature = methodMatch.Groups["sig"].Value.Trim();
int spaceIndex = signature.LastIndexOf(' ');
if (spaceIndex <= 0)
continue;
string methodName = signature[(spaceIndex + 1)..].Trim();
string argList = methodMatch.Groups["args"].Value.Trim();
string patchSignature = BuildPatchSignature(currentClass, methodName, argList);
string bodyHash = ComputeMethodBodyHash(lines, index);
output.Add(new MethodSnapshot
{
Assembly = assemblyName,
TypeName = currentClass,
MethodName = methodName,
SignatureKey = $"{assemblyName}|{patchSignature}",
BodyHash = bodyHash,
});
}
}
private static string BuildPatchSignature(string className, string methodName, string argList)
{
List<string> types = new();
foreach (string segment in SplitArgSegments(argList))
{
string token = Regex.Replace(segment, @"\s*=\s*[^,)]+$", string.Empty).Trim();
if (token.Length == 0)
continue;
token = Regex.Replace(token, @"^\s*(ref|out|in|readonly)\s+", string.Empty).Trim();
string[] parts = token.Split(' ', StringSplitOptions.RemoveEmptyEntries);
if (parts.Length < 2)
continue;
types.Add(string.Join(' ', parts[..^1]).Trim());
}
if (types.Count == 0)
return $"Il2Cpp.{className}::{methodName}()";
return $"Il2Cpp.{className}::{methodName}({string.Join(", ", types)})";
}
private static IEnumerable<string> SplitArgSegments(string argListText)
{
if (string.IsNullOrWhiteSpace(argListText))
yield break;
int depth = 0;
StringBuilder current = new();
for (int i = 0; i < argListText.Length; i++)
{
char c = argListText[i];
if (c == '<') depth++;
if (c == '>') depth--;
if (c == ',' && depth == 0)
{
string segment = current.ToString().Trim();
if (segment.Length > 0)
yield return segment;
current.Clear();
continue;
}
current.Append(c);
}
if (current.Length > 0)
{
string segment = current.ToString().Trim();
if (segment.Length > 0)
yield return segment;
}
}
private static string ComputeMethodBodyHash(string[] lines, int methodLineIndex)
{
var sb = new StringBuilder();
int braceDepth = 0;
bool startedBody = false;
for (int i = methodLineIndex; i < lines.Length; i++)
{
string line = lines[i];
sb.AppendLine(line.Trim());
foreach (char c in line)
{
if (c == '{')
{
braceDepth++;
startedBody = true;
}
else if (c == '}')
{
braceDepth--;
}
}
if (startedBody && braceDepth <= 0)
break;
if (!startedBody && line.Contains(';'))
break;
}
byte[] data = SHA256.HashData(Encoding.UTF8.GetBytes(sb.ToString()));
return Convert.ToHexString(data);
}
}
+147
View File
@@ -0,0 +1,147 @@
using System.Text.RegularExpressions;
namespace gregExtractor;
public static class SteamLocator
{
public static string TryFindDataCenterDirectory()
{
return TryFindGameDirectory("Data Center");
}
public static string TryFindGameDirectory(string gameFolderName)
{
if (string.IsNullOrWhiteSpace(gameFolderName))
return string.Empty;
foreach (string library in EnumerateSteamLibraryRoots())
{
string candidate = Path.Combine(library, "steamapps", "common", gameFolderName);
if (!Directory.Exists(candidate))
continue;
if (File.Exists(Path.Combine(candidate, "GameAssembly.dll"))
|| Directory.Exists(Path.Combine(candidate, "MelonLoader")))
{
return candidate;
}
}
return string.Empty;
}
public static string TryFindIl2CppAssembliesDirectory(string gameFolderName = "Data Center")
{
string gameDir = TryFindGameDirectory(gameFolderName);
if (string.IsNullOrWhiteSpace(gameDir))
return string.Empty;
string[] candidates =
{
Path.Combine(gameDir, "MelonLoader", "Il2CppAssemblies"),
Path.Combine(gameDir, "Il2CppAssemblies"),
};
foreach (string candidate in candidates)
{
if (Directory.Exists(candidate) && File.Exists(Path.Combine(candidate, "Assembly-CSharp.dll")))
return candidate;
}
return string.Empty;
}
private static IEnumerable<string> EnumerateSteamLibraryRoots()
{
var roots = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
string pf86 = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86);
string pf = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles);
string[] defaults =
{
Path.Combine(pf86, "Steam"),
Path.Combine(pf, "Steam"),
};
foreach (string root in defaults)
{
if (Directory.Exists(root))
roots.Add(root);
}
foreach (string installPath in ReadSteamInstallPathsFromRegistry())
{
if (Directory.Exists(installPath))
roots.Add(installPath);
}
var libraries = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (string steamRoot in roots)
{
libraries.Add(steamRoot);
string vdfPath = Path.Combine(steamRoot, "steamapps", "libraryfolders.vdf");
if (!File.Exists(vdfPath))
continue;
string vdf = File.ReadAllText(vdfPath);
foreach (string parsedLibrary in ParseLibraryFolders(vdf))
{
if (Directory.Exists(parsedLibrary))
libraries.Add(parsedLibrary);
}
}
return libraries;
}
private static IEnumerable<string> ParseLibraryFolders(string vdfContent)
{
if (string.IsNullOrWhiteSpace(vdfContent))
yield break;
foreach (Match match in Regex.Matches(vdfContent, "\"path\"\\s*\"(?<path>[^\"]+)\"", RegexOptions.IgnoreCase))
{
string value = match.Groups["path"].Value.Replace("\\\\", "\\");
if (!string.IsNullOrWhiteSpace(value))
yield return value;
}
foreach (Match match in Regex.Matches(vdfContent, "\"\\d+\"\\s*\"(?<path>[A-Za-z]:\\\\[^\"]+)\"", RegexOptions.IgnoreCase))
{
string value = match.Groups["path"].Value.Replace("\\\\", "\\");
if (!string.IsNullOrWhiteSpace(value))
yield return value;
}
}
private static IEnumerable<string> ReadSteamInstallPathsFromRegistry()
{
if (!OperatingSystem.IsWindows())
return Array.Empty<string>();
const string keyPath = @"SOFTWARE\WOW6432Node\Valve\Steam";
const string keyPath64 = @"SOFTWARE\Valve\Steam";
return ReadRegistryPath(keyPath).Concat(ReadRegistryPath(keyPath64));
}
private static IEnumerable<string> ReadRegistryPath(string keyPath)
{
if (!OperatingSystem.IsWindows())
return Array.Empty<string>();
try
{
using var key = Microsoft.Win32.Registry.LocalMachine.OpenSubKey(keyPath);
if (key?.GetValue("InstallPath") is string installPath && !string.IsNullOrWhiteSpace(installPath))
return new[] { installPath };
}
catch
{
}
return Array.Empty<string>();
}
}
+40
View File
@@ -0,0 +1,40 @@
namespace gregExtractor.Utils;
public static class CoveragePathResolver
{
public static string[] ResolveSourceDirs(string? sourceDirsOption, string workingDirectory)
{
if (!string.IsNullOrWhiteSpace(sourceDirsOption))
{
return sourceDirsOption
.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Where(Directory.Exists)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
}
return GetDefaultSourceDirCandidates(workingDirectory)
.Where(Directory.Exists)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
}
public static string GetDefaultSourcesText(string workingDirectory)
{
return string.Join(';', GetDefaultSourceDirCandidates(workingDirectory).Where(Directory.Exists));
}
private static IEnumerable<string> GetDefaultSourceDirCandidates(string workingDirectory)
{
string? parent = Directory.GetParent(workingDirectory)?.FullName;
yield return Path.Combine(workingDirectory, "Hooks");
yield return Path.Combine(workingDirectory, "framework");
if (!string.IsNullOrWhiteSpace(parent))
{
yield return Path.Combine(parent, "gregCore", "Hooks");
yield return Path.Combine(parent, "gregCore", "framework");
}
}
}
+105
View File
@@ -0,0 +1,105 @@
using Microsoft.Win32;
using System.Runtime.Versioning;
namespace gregExtractor.Utils;
public static class SteamLocator
{
private const string DataCenterFolderName = "Data Center";
public static string? FindGamePath()
{
foreach (string libraryRoot in EnumerateSteamLibraryRoots())
{
string candidate = Path.Combine(libraryRoot, "steamapps", "common", DataCenterFolderName);
if (Directory.Exists(candidate))
return candidate;
}
return null;
}
public static string? FindIl2CppAssembliesPath()
{
string? gamePath = FindGamePath();
if (string.IsNullOrWhiteSpace(gamePath))
return null;
string modernPath = Path.Combine(gamePath, "MelonLoader", "Il2CppAssemblies");
if (Directory.Exists(modernPath))
return modernPath;
string legacyPath = Path.Combine(gamePath, "MelonLoader", "Managed");
if (Directory.Exists(legacyPath))
return legacyPath;
return null;
}
private static IEnumerable<string> EnumerateSteamLibraryRoots()
{
var roots = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (string installPath in ReadSteamInstallPaths())
{
if (string.IsNullOrWhiteSpace(installPath) || !Directory.Exists(installPath))
continue;
roots.Add(installPath);
string libraryFile = Path.Combine(installPath, "steamapps", "libraryfolders.vdf");
if (!File.Exists(libraryFile))
continue;
string content = File.ReadAllText(libraryFile);
foreach (Match match in Regex.Matches(content, "\"path\"\\s+\"([^\"]+)\"", RegexOptions.IgnoreCase))
{
string parsedPath = match.Groups[1].Value.Replace("\\\\", "\\");
if (!string.IsNullOrWhiteSpace(parsedPath) && Directory.Exists(parsedPath))
roots.Add(parsedPath);
}
}
return roots;
}
private static IEnumerable<string> ReadSteamInstallPaths()
{
if (!OperatingSystem.IsWindows())
return Enumerable.Empty<string>();
var paths = new List<string>();
string? wowPath = ReadRegistryValue(@"SOFTWARE\WOW6432Node\Valve\Steam", "InstallPath");
if (!string.IsNullOrWhiteSpace(wowPath))
paths.Add(wowPath);
string? nativePath = ReadRegistryValue(@"SOFTWARE\Valve\Steam", "InstallPath");
if (!string.IsNullOrWhiteSpace(nativePath))
paths.Add(nativePath);
return paths;
}
private static string? ReadRegistryValue(string keyPath, string valueName)
{
if (!OperatingSystem.IsWindows())
return null;
return ReadRegistryValueWindows(keyPath, valueName);
}
[SupportedOSPlatform("windows")]
private static string? ReadRegistryValueWindows(string keyPath, string valueName)
{
try
{
using RegistryKey? key = Registry.LocalMachine.OpenSubKey(keyPath);
return key?.GetValue(valueName) as string;
}
catch
{
return null;
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,29 @@
using System;
using greg.Sdk;
using MelonLoader;
namespace greg.Plugin.HookTemplate;
internal static class HookTemplateGameApiBridge
{
public static void Emit(string gregHook, string patchTarget, object instance, object result, object[] args)
{
try
{
var payload = new
{
patchTarget,
instanceType = instance?.GetType().FullName,
result,
argCount = args?.Length ?? 0,
args,
};
gregEventDispatcher.Emit(gregHook, payload);
}
catch (Exception ex)
{
MelonLogger.Warning($"[HookTemplate] Emit failed for '{gregHook}' ({patchTarget}): {ex.Message}");
}
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+21
View File
@@ -0,0 +1,21 @@
# greg.Plugin.HookTemplate - Auto Hook Template
Dieses Template wurde von `gregExtractor` erzeugt.
- Registrierte greg Hooks: **1284**
- Basis: `gregPluginBase` + `gregEventDispatcher.On(...)`
- Harmony-Patches: `*.HarmonyPatches.cs`
- API-Bridge: `*.GameApiBridge.cs`
## Build
```powershell
dotnet build
```
## Hinweise
- Das Template subscribed alle aktuell bekannten `greg.*` Hooks.
- Das Template erwartet `gregCore` als NuGet-Paket (`PackageReference`), nicht als Projekt-Referenz.
- Bei neuen Game-/Melon-Updates Template erneut generieren.
- Eigene Logik in `OnHookTriggered` ergänzen.
+21
View File
@@ -0,0 +1,21 @@
// UNITY 6: All Unity API calls must run on the Main Thread!
// Use MelonCoroutines for asynchronous operations.
// Generated by gregExtractor v1.0 — https://dcmods.com
using MelonLoader;
[assembly: MelonInfo(typeof(SmokeMod.MainPlugin), "SmokeMod", "1.0.0", "YourName")]
[assembly: MelonGame("Waseku", "Data Center")]
namespace SmokeMod;
public sealed class MainPlugin : MelonMod
{
public override void OnInitializeMelon()
{
LoggerInstance.Msg("SmokeMod initialized.");
// TODO: Enable gregCore registry calls after NuGet release.
// gregCore.Registry.Register("SmokeMod", "Economy");
}
}
@@ -0,0 +1,22 @@
// UNITY 6: All Unity API calls must run on the Main Thread!
// Use MelonCoroutines for asynchronous operations.
// Generated by gregExtractor v1.0 — https://dcmods.com
using HarmonyLib;
namespace SmokeMod.Patches.Economy;
[HarmonyPatch(typeof(Il2Cpp.ComputerShop), nameof(Il2Cpp.ComputerShop.ApplyColorToSpawnedItem))]
public static class ComputerShop_ApplyColorToSpawnedItemPatch
{
// Example Prefix:
// private static void Prefix()
// {
// }
private static void Postfix(object? __instance)
{
// TODO: Enable gregCore event bus when NuGet package is available.
// gregGameApi.Publish("greg.economy.ApplyColorToSpawnedItem", new { instance = __instance });
}
}
@@ -0,0 +1,22 @@
// UNITY 6: All Unity API calls must run on the Main Thread!
// Use MelonCoroutines for asynchronous operations.
// Generated by gregExtractor v1.0 — https://dcmods.com
using HarmonyLib;
namespace SmokeMod.Patches.Economy;
[HarmonyPatch(typeof(Il2Cpp.ComputerShop), nameof(Il2Cpp.ComputerShop.ButtonBuyShopItem))]
public static class ComputerShop_ButtonBuyShopItemPatch
{
// Example Prefix:
// private static void Prefix()
// {
// }
private static void Postfix(object? __instance)
{
// TODO: Enable gregCore event bus when NuGet package is available.
// gregGameApi.Publish("greg.economy.ButtonBuyShopItem", new { instance = __instance });
}
}
@@ -0,0 +1,22 @@
// UNITY 6: All Unity API calls must run on the Main Thread!
// Use MelonCoroutines for asynchronous operations.
// Generated by gregExtractor v1.0 — https://dcmods.com
using HarmonyLib;
namespace SmokeMod.Patches.Economy;
[HarmonyPatch(typeof(Il2Cpp.ComputerShop), nameof(Il2Cpp.ComputerShop.BuyAnotherItem))]
public static class ComputerShop_BuyAnotherItemPatch
{
// Example Prefix:
// private static void Prefix()
// {
// }
private static void Postfix(object? __instance)
{
// TODO: Enable gregCore event bus when NuGet package is available.
// gregGameApi.Publish("greg.economy.BuyAnotherItem", new { instance = __instance });
}
}
@@ -0,0 +1,22 @@
// UNITY 6: All Unity API calls must run on the Main Thread!
// Use MelonCoroutines for asynchronous operations.
// Generated by gregExtractor v1.0 — https://dcmods.com
using HarmonyLib;
namespace SmokeMod.Patches.Economy;
[HarmonyPatch(typeof(Il2Cpp.ComputerShop), nameof(Il2Cpp.ComputerShop.BuyNewItem))]
public static class ComputerShop_BuyNewItemPatch
{
// Example Prefix:
// private static void Prefix()
// {
// }
private static void Postfix(object? __instance)
{
// TODO: Enable gregCore event bus when NuGet package is available.
// gregGameApi.Publish("greg.economy.BuyNewItem", new { instance = __instance });
}
}
@@ -0,0 +1,22 @@
// UNITY 6: All Unity API calls must run on the Main Thread!
// Use MelonCoroutines for asynchronous operations.
// Generated by gregExtractor v1.0 — https://dcmods.com
using HarmonyLib;
namespace SmokeMod.Patches.Economy;
[HarmonyPatch(typeof(Il2Cpp.ComputerShop), nameof(Il2Cpp.ComputerShop.FreeUpSpawnPoint))]
public static class ComputerShop_FreeUpSpawnPointPatch
{
// Example Prefix:
// private static void Prefix()
// {
// }
private static void Postfix(object? __instance)
{
// TODO: Enable gregCore event bus when NuGet package is available.
// gregGameApi.Publish("greg.economy.FreeUpSpawnPoint", new { instance = __instance });
}
}
@@ -0,0 +1,22 @@
// UNITY 6: All Unity API calls must run on the Main Thread!
// Use MelonCoroutines for asynchronous operations.
// Generated by gregExtractor v1.0 — https://dcmods.com
using HarmonyLib;
namespace SmokeMod.Patches.Economy;
[HarmonyPatch(typeof(Il2Cpp.ComputerShop), nameof(Il2Cpp.ComputerShop.GetNextAvailableSpawnPoint))]
public static class ComputerShop_GetNextAvailableSpawnPointPatch
{
// Example Prefix:
// private static void Prefix()
// {
// }
private static void Postfix(object? __instance)
{
// TODO: Enable gregCore event bus when NuGet package is available.
// gregGameApi.Publish("greg.economy.GetNextAvailableSpawnPoint", new { instance = __instance });
}
}
@@ -0,0 +1,22 @@
// UNITY 6: All Unity API calls must run on the Main Thread!
// Use MelonCoroutines for asynchronous operations.
// Generated by gregExtractor v1.0 — https://dcmods.com
using HarmonyLib;
namespace SmokeMod.Patches.Economy;
[HarmonyPatch(typeof(Il2Cpp.ComputerShop), nameof(Il2Cpp.ComputerShop.GetPrefabForItem))]
public static class ComputerShop_GetPrefabForItemPatch
{
// Example Prefix:
// private static void Prefix()
// {
// }
private static void Postfix(object? __instance)
{
// TODO: Enable gregCore event bus when NuGet package is available.
// gregGameApi.Publish("greg.economy.GetPrefabForItem", new { instance = __instance });
}
}
@@ -0,0 +1,22 @@
// UNITY 6: All Unity API calls must run on the Main Thread!
// Use MelonCoroutines for asynchronous operations.
// Generated by gregExtractor v1.0 — https://dcmods.com
using HarmonyLib;
namespace SmokeMod.Patches.Economy;
[HarmonyPatch(typeof(Il2Cpp.ComputerShop), nameof(Il2Cpp.ComputerShop.HandleObjectives))]
public static class ComputerShop_HandleObjectivesPatch
{
// Example Prefix:
// private static void Prefix()
// {
// }
private static void Postfix(object? __instance)
{
// TODO: Enable gregCore event bus when NuGet package is available.
// gregGameApi.Publish("greg.economy.HandleObjectives", new { instance = __instance });
}
}
@@ -0,0 +1,22 @@
// UNITY 6: All Unity API calls must run on the Main Thread!
// Use MelonCoroutines for asynchronous operations.
// Generated by gregExtractor v1.0 — https://dcmods.com
using HarmonyLib;
namespace SmokeMod.Patches.Economy;
[HarmonyPatch(typeof(Il2Cpp.ComputerShop), nameof(Il2Cpp.ComputerShop.InteractOnHover))]
public static class ComputerShop_InteractOnHoverPatch
{
// Example Prefix:
// private static void Prefix()
// {
// }
private static void Postfix(object? __instance)
{
// TODO: Enable gregCore event bus when NuGet package is available.
// gregGameApi.Publish("greg.economy.InteractOnHover", new { instance = __instance });
}
}
@@ -0,0 +1,22 @@
// UNITY 6: All Unity API calls must run on the Main Thread!
// Use MelonCoroutines for asynchronous operations.
// Generated by gregExtractor v1.0 — https://dcmods.com
using HarmonyLib;
namespace SmokeMod.Patches.Economy;
[HarmonyPatch(typeof(Il2Cpp.ComputerShop), nameof(Il2Cpp.ComputerShop.RemoveCartUIItem))]
public static class ComputerShop_RemoveCartUIItemPatch
{
// Example Prefix:
// private static void Prefix()
// {
// }
private static void Postfix(object? __instance)
{
// TODO: Enable gregCore event bus when NuGet package is available.
// gregGameApi.Publish("greg.economy.RemoveCartUIItem", new { instance = __instance });
}
}
@@ -0,0 +1,22 @@
// UNITY 6: All Unity API calls must run on the Main Thread!
// Use MelonCoroutines for asynchronous operations.
// Generated by gregExtractor v1.0 — https://dcmods.com
using HarmonyLib;
namespace SmokeMod.Patches.Economy;
[HarmonyPatch(typeof(Il2Cpp.ComputerShop), nameof(Il2Cpp.ComputerShop.RemoveSpawnedItem))]
public static class ComputerShop_RemoveSpawnedItemPatch
{
// Example Prefix:
// private static void Prefix()
// {
// }
private static void Postfix(object? __instance)
{
// TODO: Enable gregCore event bus when NuGet package is available.
// gregGameApi.Publish("greg.economy.RemoveSpawnedItem", new { instance = __instance });
}
}
@@ -0,0 +1,22 @@
// UNITY 6: All Unity API calls must run on the Main Thread!
// Use MelonCoroutines for asynchronous operations.
// Generated by gregExtractor v1.0 — https://dcmods.com
using HarmonyLib;
namespace SmokeMod.Patches.Economy;
[HarmonyPatch(typeof(Il2Cpp.ComputerShop), nameof(Il2Cpp.ComputerShop.SelectNextAvailable))]
public static class ComputerShop_SelectNextAvailablePatch
{
// Example Prefix:
// private static void Prefix()
// {
// }
private static void Postfix(object? __instance)
{
// TODO: Enable gregCore event bus when NuGet package is available.
// gregGameApi.Publish("greg.economy.SelectNextAvailable", new { instance = __instance });
}
}
@@ -0,0 +1,22 @@
// UNITY 6: All Unity API calls must run on the Main Thread!
// Use MelonCoroutines for asynchronous operations.
// Generated by gregExtractor v1.0 — https://dcmods.com
using HarmonyLib;
namespace SmokeMod.Patches.Economy;
[HarmonyPatch(typeof(Il2Cpp.ComputerShop), nameof(Il2Cpp.ComputerShop.SpawnNewCartItem))]
public static class ComputerShop_SpawnNewCartItemPatch
{
// Example Prefix:
// private static void Prefix()
// {
// }
private static void Postfix(object? __instance)
{
// TODO: Enable gregCore event bus when NuGet package is available.
// gregGameApi.Publish("greg.economy.SpawnNewCartItem", new { instance = __instance });
}
}

Some files were not shown because too many files have changed in this diff Show More