Files
gregExtractor/Services/CoverageAnalyzerService.cs
T

364 lines
15 KiB
C#

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