822 lines
30 KiB
C#
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());
|
|
}
|
|
}
|