364 lines
15 KiB
C#
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);
|