merge: bring master extractor import into main
This commit is contained in:
+18
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,11 @@
|
||||
using Avalonia.Controls;
|
||||
|
||||
namespace gregExtractor.GUI.Controls;
|
||||
|
||||
public partial class Toast : UserControl
|
||||
{
|
||||
public Toast()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -0,0 +1,11 @@
|
||||
using Avalonia.Controls;
|
||||
|
||||
namespace gregExtractor.GUI.Controls;
|
||||
|
||||
public partial class ToastHost : UserControl
|
||||
{
|
||||
public ToastHost()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -0,0 +1,11 @@
|
||||
using Avalonia.Controls;
|
||||
|
||||
namespace gregExtractor.GUI;
|
||||
|
||||
public partial class CoverageView : UserControl
|
||||
{
|
||||
public CoverageView()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
<Styles xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||
</Styles>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -0,0 +1,11 @@
|
||||
using Avalonia.Controls;
|
||||
|
||||
namespace gregExtractor.GUI.Views;
|
||||
|
||||
public partial class CoverageView : UserControl
|
||||
{
|
||||
public CoverageView()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -0,0 +1,11 @@
|
||||
using Avalonia.Controls;
|
||||
|
||||
namespace gregExtractor.GUI.Views;
|
||||
|
||||
public partial class CreateView : UserControl
|
||||
{
|
||||
public CreateView()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -0,0 +1,11 @@
|
||||
using Avalonia.Controls;
|
||||
|
||||
namespace gregExtractor.GUI.Views;
|
||||
|
||||
public partial class ExtractorView : UserControl
|
||||
{
|
||||
public ExtractorView()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -0,0 +1,11 @@
|
||||
using Avalonia.Controls;
|
||||
|
||||
namespace gregExtractor.GUI.Views;
|
||||
|
||||
public partial class HookBrowserView : UserControl
|
||||
{
|
||||
public HookBrowserView()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -0,0 +1,11 @@
|
||||
using Avalonia.Controls;
|
||||
|
||||
namespace gregExtractor.GUI.Views;
|
||||
|
||||
public partial class SettingsView : UserControl
|
||||
{
|
||||
public SettingsView()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -0,0 +1,11 @@
|
||||
using Avalonia.Controls;
|
||||
|
||||
namespace gregExtractor.GUI.Views;
|
||||
|
||||
public partial class SyncView : UserControl
|
||||
{
|
||||
public SyncView()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace gregExtractor.Models;
|
||||
|
||||
public enum CoverageStatus
|
||||
{
|
||||
Covered,
|
||||
Planned,
|
||||
Uncovered,
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace gregExtractor.Models;
|
||||
|
||||
public sealed record HookCoverageEntry(
|
||||
HookDefinition HookDefinition,
|
||||
CoverageStatus CoverageStatus,
|
||||
IReadOnlyList<string> FoundInFiles);
|
||||
@@ -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);
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace gregExtractor.Models;
|
||||
|
||||
public sealed record HookDiff(
|
||||
IReadOnlyList<HookDefinition> Added,
|
||||
IReadOnlyList<HookDefinition> Removed,
|
||||
IReadOnlyList<HookDefinition> Changed);
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace gregExtractor.Models;
|
||||
|
||||
public sealed record HookGroupConfig(
|
||||
string Description,
|
||||
IReadOnlyList<string> Classes,
|
||||
IReadOnlyList<string> Methods);
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace gregExtractor.Models;
|
||||
|
||||
public sealed record HookParameter(string Name, string Type);
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace gregExtractor.Models;
|
||||
|
||||
public sealed record SyncResult(
|
||||
IReadOnlyList<string> FilesWritten,
|
||||
int LinesAdded,
|
||||
int LinesRemoved,
|
||||
IReadOnlyList<string> Warnings);
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace gregExtractor.Models;
|
||||
|
||||
public enum TemplateType
|
||||
{
|
||||
HarmonyPatch,
|
||||
CustomServer,
|
||||
CustomUI,
|
||||
CustomWorld,
|
||||
CustomFurniture,
|
||||
CustomNPC,
|
||||
}
|
||||
+53
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace gregExtractor.Services;
|
||||
|
||||
public interface ICoverageScanner
|
||||
{
|
||||
HashSet<string> ScanImplementedPatches(string[] sourceDirs, IProgress<string>? progress);
|
||||
|
||||
IReadOnlyDictionary<string, IReadOnlyCollection<string>> LastScanMatches { get; }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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>();
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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.
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
+22
@@ -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 });
|
||||
}
|
||||
}
|
||||
+22
@@ -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
Reference in New Issue
Block a user