Files
gregExtractor/HookAutomationService.cs

822 lines
30 KiB
C#

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());
}
}