Refactor ViewModels to inherit from BaseViewModel and improve UI thread handling

- Updated HookBrowserViewModel, MainViewModel, SettingsViewModel, and SyncViewModel to inherit from BaseViewModel.
- Introduced BaseViewModel with methods for running actions on the UI thread.
- Enhanced SyncViewModel to handle property changes on the UI thread.
- Refactored ExtractorService to return ExtractorResult instead of IReadOnlyList<HookDefinition>.
- Added ExtractorResult class to encapsulate extraction results, including success status and error messages.
- Implemented UIProgress class for reporting progress on the UI thread.
- Updated hook_groups.json with additional classes and methods for various categories.
- Improved error handling and logging in ExtractorService and SyncViewModel.
This commit is contained in:
Marvin
2026-04-20 04:26:06 +02:00
parent 948d8764c9
commit 2f61e5dc00
16 changed files with 773 additions and 287 deletions
+11 -4
View File
@@ -17,11 +17,10 @@ public sealed class ExtractCommand : AsyncCommand<ExtractCommand.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);
HookClassifier classifier = HookClassifier.LoadFromFile(null, progress);
string? il2CppPath = settings.Path;
if (string.IsNullOrWhiteSpace(il2CppPath))
@@ -39,17 +38,25 @@ public sealed class ExtractCommand : AsyncCommand<ExtractCommand.Settings>
return -1;
}
IReadOnlyList<HookDefinition> hooks = Array.Empty<HookDefinition>();
var extractor = new ExtractorService();
ExtractorResult result = ExtractorResult.Failure("Not executed");
await AnsiConsole.Status()
.Spinner(Spinner.Known.Dots)
.StartAsync("Extracting IL2CPP hooks...", async _ =>
{
hooks = await extractor.ExtractAsync(il2CppPath!, classifier, progress, CancellationToken.None).ConfigureAwait(false);
result = await extractor.ExtractAsync(il2CppPath!, classifier, progress, CancellationToken.None).ConfigureAwait(false);
})
.ConfigureAwait(false);
if (!result.IsSuccess)
{
AnsiConsole.MarkupLine($"[red]✗ {result.Error}[/]");
return -1;
}
var hooks = result.Hooks;
string gameHooksPath = Path.Combine(workingDirectory, "game_hooks.json");
await File.WriteAllTextAsync(
gameHooksPath,
+12 -13
View File
@@ -80,17 +80,16 @@ public sealed class SyncCommand : AsyncCommand<SyncCommand.Settings>
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>());
var result = await extractor.ExtractAsync(il2CppPath, classifier, progress, CancellationToken.None).ConfigureAwait(false);
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);
if (!result.IsSuccess)
{
AnsiConsole.MarkupLine($"[red]✗ {result.Error}[/]");
return -1;
}
var newHooks = result.Hooks;
HookDiff diff = diffService.Diff(oldHooks.ToList(), newHooks.ToList());
var summary = new Table().RoundedBorder();
summary.Title = new TableTitle("Assembly Diff");
@@ -101,7 +100,7 @@ public sealed class SyncCommand : AsyncCommand<SyncCommand.Settings>
summary.AddRow("- Methoden entfernt", diff.Removed.Count.ToString());
AnsiConsole.Write(summary);
SyncResult result = syncService.Sync(new SyncOptions(
SyncResult syncResult = syncService.Sync(new SyncOptions(
FrameworkSourceDir: settings.Source,
Diff: diff,
AllHooks: newHooks.ToList(),
@@ -111,10 +110,10 @@ public sealed class SyncCommand : AsyncCommand<SyncCommand.Settings>
foreach (string line in logLines)
AnsiConsole.MarkupLine($"[grey]{Markup.Escape(line)}[/]");
foreach (string file in result.FilesWritten)
foreach (string file in syncResult.FilesWritten)
AnsiConsole.MarkupLine($"[green]✓[/] {Markup.Escape(Path.GetFileName(file))}");
foreach (string warning in result.Warnings)
foreach (string warning in syncResult.Warnings)
AnsiConsole.MarkupLine($"[yellow]{Markup.Escape(warning)}[/]");
if (!settings.DryRun)
+61 -1
View File
@@ -1,6 +1,8 @@
using Avalonia;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
using Avalonia.Threading;
using gregExtractor.GUI.ViewModels;
namespace gregExtractor.GUI;
@@ -15,6 +17,9 @@ public partial class App : Application
public override void OnFrameworkInitializationCompleted()
{
AppDomain.CurrentDomain.UnhandledException += OnUnhandledException;
TaskScheduler.UnobservedTaskException += OnUnobservedTaskException;
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
desktop.MainWindow = new MainWindow
@@ -25,4 +30,59 @@ public partial class App : Application
base.OnFrameworkInitializationCompleted();
}
}
private static void OnUnhandledException(object sender, UnhandledExceptionEventArgs e)
{
var ex = e.ExceptionObject as Exception;
var msg = ex?.ToString() ?? e.ExceptionObject?.ToString() ?? "Unknown";
var crashLog = Path.Combine(AppContext.BaseDirectory, "crash.log");
File.WriteAllText(crashLog, $"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] CRASH\n{msg}");
try
{
Dispatcher.UIThread.Post(() =>
{
var window = new Window
{
Title = "gregExtractor - Crash",
Width = 500,
Height = 200,
WindowStartupLocation = WindowStartupLocation.CenterScreen,
};
var textBlock = new TextBlock
{
Text = $"Unhandled Exception:\n\n{ex?.Message}\n\nDetails in: {crashLog}",
Margin = new Avalonia.Thickness(20),
TextWrapping = Avalonia.Media.TextWrapping.Wrap
};
var button = new Button
{
Content = "OK",
HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Center,
Margin = new Avalonia.Thickness(20)
};
button.Click += (_, _) => window.Close();
var panel = new StackPanel();
panel.Children.Add(textBlock);
panel.Children.Add(button);
window.Content = panel;
window.Show();
});
}
catch { }
}
private static void OnUnobservedTaskException(object? sender, UnobservedTaskExceptionEventArgs e)
{
e.SetObserved();
var crashLog = Path.Combine(AppContext.BaseDirectory, "crash.log");
File.AppendAllText(crashLog,
$"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] TASK EXCEPTION\n{e.Exception}\n\n");
}
}
+23
View File
@@ -0,0 +1,23 @@
using Avalonia.Threading;
using CommunityToolkit.Mvvm.ComponentModel;
namespace gregExtractor.GUI.ViewModels;
public abstract class BaseViewModel : ObservableObject
{
protected void RunOnUIThread(Action action)
{
if (Dispatcher.UIThread.CheckAccess())
action();
else
Dispatcher.UIThread.Post(action);
}
protected async Task RunOnUIThreadAsync(Action action)
{
if (Dispatcher.UIThread.CheckAccess())
action();
else
await Dispatcher.UIThread.InvokeAsync(action);
}
}
+79 -29
View File
@@ -1,3 +1,4 @@
using Avalonia.Threading;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using gregExtractor.Models;
@@ -6,7 +7,7 @@ using gregExtractor.Utils;
namespace gregExtractor.GUI.ViewModels;
public sealed class CoverageViewModel : ObservableObject
public sealed class CoverageViewModel : BaseViewModel
{
private readonly CoverageAnalyzerService _analyzerService = new();
@@ -24,12 +25,16 @@ public sealed class CoverageViewModel : ObservableObject
public CoverageViewModel()
{
Log($"[Coverage] Initial FrameworkSourceDirsText: '{_frameworkSourceDirsText}'");
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();
Log($"[Coverage] After AutoDetect: Il2CppPath='{Il2CppPath}'");
}
public Func<string?, Task<string?>>? BrowseFolderHandler { get; set; }
@@ -37,19 +42,19 @@ public sealed class CoverageViewModel : ObservableObject
public string Il2CppPath
{
get => _il2CppPath;
set => SetProperty(ref _il2CppPath, value);
set => SetProperty(ref _il2CppPath, value?.Trim() ?? string.Empty);
}
public string GameHooksPath
{
get => _gameHooksPath;
set => SetProperty(ref _gameHooksPath, value);
set => SetProperty(ref _gameHooksPath, value?.Trim() ?? string.Empty);
}
public string FrameworkSourceDirsText
{
get => _frameworkSourceDirsText;
set => SetProperty(ref _frameworkSourceDirsText, value);
set => SetProperty(ref _frameworkSourceDirsText, value?.Trim() ?? string.Empty);
}
public string OutputBasePath
@@ -67,8 +72,16 @@ public sealed class CoverageViewModel : ObservableObject
get => _isAnalyzing;
set
{
if (!SetProperty(ref _isAnalyzing, value))
if (Dispatcher.UIThread.CheckAccess())
{
if (!SetProperty(ref _isAnalyzing, value))
return;
}
else
{
Dispatcher.UIThread.Post(() => SetProperty(ref _isAnalyzing, value));
return;
}
AnalyzeCoverageCommand.NotifyCanExecuteChanged();
OnPropertyChanged(nameof(IsLoading));
@@ -127,10 +140,13 @@ public sealed class CoverageViewModel : ObservableObject
private void AutoDetectPath()
{
Il2CppPath = SteamLocator.FindIl2CppAssembliesPath() ?? string.Empty;
string detected = SteamLocator.FindIl2CppAssembliesPath() ?? string.Empty;
Log($"[SteamLocator] Found: '{detected}'");
Il2CppPath = detected;
if (string.IsNullOrWhiteSpace(Il2CppPath))
Log("Auto-detect for IL2CPP path failed.");
Log("Auto-detect: No IL2CPP path found.");
else
Log($"Auto-detected IL2CPP path: {Il2CppPath}");
}
@@ -160,61 +176,95 @@ public sealed class CoverageViewModel : ObservableObject
private async Task AnalyzeAsync()
{
if (string.IsNullOrWhiteSpace(Il2CppPath) || !Directory.Exists(Il2CppPath))
Log($"[Coverage] Input IL2CPP path: '{Il2CppPath}'");
string trimmedPath = Il2CppPath?.Trim() ?? string.Empty;
Log($"[Coverage] Trimmed path: '{trimmedPath}'");
if (string.IsNullOrWhiteSpace(trimmedPath))
{
Log("IL2CPP path is invalid.");
Log("[Coverage] IL2CPP path is empty.");
return;
}
bool pathExists = Directory.Exists(trimmedPath);
Log($"[Coverage] Path exists: {pathExists}");
if (!pathExists)
{
Log($"[Coverage] IL2CPP directory not found.");
Log($"[Coverage] Checked path: {trimmedPath}");
return;
}
bool dllExists = File.Exists(Path.Combine(trimmedPath, "Assembly-CSharp.dll"));
Log($"[Coverage] Assembly-CSharp.dll exists: {dllExists}");
if (!dllExists)
{
Log($"[Coverage] Assembly-CSharp.dll not found in: {trimmedPath}");
return;
}
if (string.IsNullOrWhiteSpace(GameHooksPath) || !File.Exists(GameHooksPath))
{
Log("game_hooks.json path is invalid.");
Log($"game_hooks.json path invalid: '{GameHooksPath}'");
return;
}
Log($"[Coverage] Framework source dirs text: '{FrameworkSourceDirsText}'");
string[] sourceDirs = CoveragePathResolver.ResolveSourceDirs(FrameworkSourceDirsText, Directory.GetCurrentDirectory());
Log($"[Coverage] Resolved {sourceDirs.Length} source dirs: {string.Join(", ", sourceDirs)}");
if (sourceDirs.Length == 0)
{
Log("No valid source directories found. Provide ';' separated paths.");
Log("ERROR: No valid source directories found.");
Log(" - Add paths separated by ';' in the Framework Source field");
Log(" - Or ensure default paths exist: Hooks/, framework/, ../gregCore/Hooks/");
return;
}
IsAnalyzing = true;
Progress = 0;
Entries.Clear();
RunOnUIThread(() => Entries.Clear());
try
{
var progress = new Progress<string>(message =>
var progress = new UIProgress<string>(msg =>
{
Log(message);
Log(msg);
Progress = Math.Min(99, Progress + 1);
});
CoverageReport report = await Task.Run(() => _analyzerService.Analyze(new AnalyzeOptions(
Il2CppDir: Il2CppPath,
var report = await Task.Run(() => _analyzerService.Analyze(new AnalyzeOptions(
Il2CppDir: trimmedPath,
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;
await Dispatcher.UIThread.InvokeAsync(() =>
{
TotalHooks = report.TotalHooks;
CoveredCount = report.CoveredCount;
PlannedCount = report.PlannedCount;
UncoveredCount = report.UncoveredCount;
CoveragePercent = report.CoveragePercent;
foreach (HookCoverageEntry entry in report.Entries)
Entries.Add(entry);
foreach (HookCoverageEntry entry in report.Entries)
Entries.Add(entry);
Progress = 100;
Log($"Coverage analysis done. Coverage: {report.CoveragePercent:F2}%");
OpenMarkdownReportCommand.NotifyCanExecuteChanged();
Progress = 100;
Log($"Coverage analysis done. Coverage: {report.CoveragePercent:F2}%");
OpenMarkdownReportCommand.NotifyCanExecuteChanged();
});
}
catch (Exception exception)
catch (Exception ex)
{
Log($"Coverage analysis failed: {exception.Message}");
Log($"Coverage analysis failed: {ex.Message}");
}
finally
{
@@ -244,4 +294,4 @@ public sealed class CoverageViewModel : ObservableObject
LogLines.Add(line);
}
}
}
+42 -29
View File
@@ -1,11 +1,13 @@
using Avalonia.Threading;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using gregExtractor.Models;
using gregExtractor.Services;
using gregExtractor.Utils;
namespace gregExtractor.GUI.ViewModels;
public sealed class CreateViewModel : ObservableObject
public sealed class CreateViewModel : BaseViewModel
{
private readonly TemplateService _templateService = new();
@@ -21,8 +23,7 @@ public sealed class CreateViewModel : ObservableObject
public CreateViewModel()
{
string groupsPath = Path.Combine(Directory.GetCurrentDirectory(), "hook_groups.json");
HookClassifier classifier = HookClassifier.LoadFromFile(groupsPath, new Progress<string>(AddLog));
HookClassifier classifier = HookClassifier.LoadFromFile(null, new UIProgress<string>(AddLog));
foreach (string group in classifier.Groups.Keys.OrderBy(x => x, StringComparer.OrdinalIgnoreCase))
Categories.Add(group);
@@ -51,8 +52,16 @@ public sealed class CreateViewModel : ObservableObject
get => _isLoading;
set
{
if (!SetProperty(ref _isLoading, value))
if (Dispatcher.UIThread.CheckAccess())
{
if (!SetProperty(ref _isLoading, value))
return;
}
else
{
Dispatcher.UIThread.Post(() => SetProperty(ref _isLoading, value));
return;
}
NextStepCommand.NotifyCanExecuteChanged();
PreviousStepCommand.NotifyCanExecuteChanged();
@@ -95,7 +104,7 @@ public sealed class CreateViewModel : ObservableObject
get => _outputPath;
set
{
if (!SetProperty(ref _outputPath, value))
if (!SetProperty(ref _outputPath, value?.Trim() ?? string.Empty))
return;
OpenOutputCommand.NotifyCanExecuteChanged();
@@ -160,27 +169,28 @@ public sealed class CreateViewModel : ObservableObject
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);
var result = await Task.Run(() => _templateService.GenerateModAsync(
ModName,
SelectedCategory,
SelectedTemplateType,
OutputPath,
hooksPath,
new UIProgress<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('\\', '/'));
await Dispatcher.UIThread.InvokeAsync(() =>
{
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}");
AddLog($"Mod erstellt: {result.ModDirectory}");
}
catch (Exception exception)
catch (Exception ex)
{
AddLog($"Mod-Erstellung fehlgeschlagen: {exception.Message}");
AddLog($"Mod-Erstellung fehlgeschlagen: {ex.Message}");
}
finally
{
@@ -202,12 +212,15 @@ public sealed class CreateViewModel : ObservableObject
private void RefreshPreview()
{
PreviewTree.Clear();
PreviewTree.Add(ModName + "/");
PreviewTree.Add(" " + ModName + ".csproj");
PreviewTree.Add(" MainPlugin.cs");
PreviewTree.Add(" mod.json");
PreviewTree.Add(" Patches/");
RunOnUIThread(() =>
{
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)
@@ -218,4 +231,4 @@ public sealed class CreateViewModel : ObservableObject
LogLines.Add(line);
}
}
}
+46 -20
View File
@@ -1,3 +1,4 @@
using Avalonia.Threading;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using gregExtractor.Models;
@@ -6,7 +7,7 @@ using gregExtractor.Utils;
namespace gregExtractor.GUI.ViewModels;
public sealed class ExtractorViewModel : ObservableObject
public sealed class ExtractorViewModel : BaseViewModel
{
private readonly ExtractorService _extractorService = new();
private HookClassifier _classifier;
@@ -18,8 +19,7 @@ public sealed class ExtractorViewModel : ObservableObject
public ExtractorViewModel()
{
string groupsPath = Path.Combine(Directory.GetCurrentDirectory(), "hook_groups.json");
_classifier = HookClassifier.LoadFromFile(groupsPath, new Progress<string>(AddLog));
_classifier = HookClassifier.LoadFromFile(null, new UIProgress<string>(AddLog));
foreach ((string groupName, HookGroupConfig config) in _classifier.Groups)
HookGroups.Add(new HookGroupViewModel(groupName, config.Description));
@@ -50,8 +50,19 @@ public sealed class ExtractorViewModel : ObservableObject
get => _isLoading;
set
{
if (!SetProperty(ref _isLoading, value))
if (Dispatcher.UIThread.CheckAccess())
{
if (!SetProperty(ref _isLoading, value))
return;
}
else
{
Dispatcher.UIThread.Post(() =>
{
SetProperty(ref _isLoading, value);
});
return;
}
StartAnalysisCommand.NotifyCanExecuteChanged();
}
@@ -87,9 +98,9 @@ public sealed class ExtractorViewModel : ObservableObject
Il2CppPath = SteamLocator.FindIl2CppAssembliesPath() ?? string.Empty;
if (string.IsNullOrWhiteSpace(Il2CppPath))
AddLog("Kein IL2CPP-Pfad automatisch erkannt.");
AddLog("Kein IL2CPP-Pfad automatisch erkannt.");
else
AddLog($"IL2CPP-Pfad erkannt: {Il2CppPath}");
AddLog($"IL2CPP-Pfad erkannt: {Il2CppPath}");
}
private async Task BrowsePathAsync(string? target)
@@ -111,25 +122,41 @@ public sealed class ExtractorViewModel : ObservableObject
{
if (string.IsNullOrWhiteSpace(Il2CppPath) || !Directory.Exists(Il2CppPath))
{
AddLog("IL2CPP-Pfad ungültig.");
AddLog("IL2CPP-Pfad ungueltig.");
return;
}
IsLoading = true;
Progress = 0;
var progress = new Progress<string>(message =>
var progress = new UIProgress<string>(msg =>
{
AddLog(message);
AddLog(msg);
Progress = Math.Min(98, Progress + 1);
});
try
{
IReadOnlyList<HookDefinition> hooks = await _extractorService
var result = await _extractorService
.ExtractAsync(Il2CppPath, _classifier, progress, CancellationToken.None)
.ConfigureAwait(false);
if (result.IsCancelled)
{
AddLog("Scan abgebrochen.");
IsLoading = false;
return;
}
if (!result.IsSuccess)
{
AddLog(result.Error ?? "Unbekannter Fehler");
IsLoading = false;
return;
}
var hooks = result.Hooks;
string hooksPath = Path.Combine(Directory.GetCurrentDirectory(), "game_hooks.json");
await File.WriteAllTextAsync(hooksPath, JsonSerializer.Serialize(hooks, new JsonSerializerOptions { WriteIndented = true }), CancellationToken.None)
.ConfigureAwait(false);
@@ -139,22 +166,21 @@ public sealed class ExtractorViewModel : ObservableObject
await File.WriteAllTextAsync(unknownPath, JsonSerializer.Serialize(unknown, new JsonSerializerOptions { WriteIndented = true }), CancellationToken.None)
.ConfigureAwait(false);
ApplyGroups(hooks);
await Dispatcher.UIThread.InvokeAsync(() => ApplyGroups(hooks));
Progress = 100;
AddLog($"Analyse abgeschlossen. Hooks: {hooks.Count}");
}
catch (Exception exception)
{
AddLog($"✗ Analyse fehlgeschlagen: {exception.Message}");
}
finally
{
AddLog($"Analyse abgeschlossen. Hooks: {hooks.Count}");
IsLoading = false;
OnPropertyChanged(nameof(TotalHooks));
OnPropertyChanged(nameof(GroupedHooks));
OnPropertyChanged(nameof(UnknownHooks));
OnPropertyChanged(nameof(GroupCount));
}
catch (Exception ex)
{
AddLog($"Kritischer Fehler: {ex.Message}");
IsLoading = false;
}
}
private void ApplyGroups(IEnumerable<HookDefinition> hooks)
@@ -188,4 +214,4 @@ public sealed class ExtractorViewModel : ObservableObject
LogLines.Add(line);
}
}
}
+59 -28
View File
@@ -1,6 +1,9 @@
using Avalonia.Threading;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using gregExtractor.Models;
using gregExtractor.Services;
using gregExtractor.Utils;
namespace gregExtractor.GUI.ViewModels;
@@ -15,14 +18,19 @@ public sealed class HookBrowserRow
public required string Parameters { get; init; }
}
public sealed class HookBrowserViewModel : ObservableObject
public sealed class HookBrowserViewModel : BaseViewModel
{
private readonly ExtractorService _extractorService = new();
private HookClassifier _classifier;
private string _searchText = string.Empty;
private string _selectedGroup = "Alle";
private bool _isLoading;
public HookBrowserViewModel()
{
_classifier = HookClassifier.LoadFromFile(null, new UIProgress<string>(_ => { }));
Groups.Add("Alle");
RefreshCommand = new AsyncRelayCommand(RefreshAsync, () => !IsLoading);
}
@@ -35,7 +43,7 @@ public sealed class HookBrowserViewModel : ObservableObject
if (!SetProperty(ref _searchText, value))
return;
ApplyFilter();
RunOnUIThread(ApplyFilter);
}
}
@@ -47,7 +55,7 @@ public sealed class HookBrowserViewModel : ObservableObject
if (!SetProperty(ref _selectedGroup, value))
return;
ApplyFilter();
RunOnUIThread(ApplyFilter);
}
}
@@ -73,42 +81,65 @@ public sealed class HookBrowserViewModel : ObservableObject
public async Task RefreshAsync()
{
IsLoading = true;
try
_classifier = HookClassifier.LoadFromFile(null, new UIProgress<string>(_ => { }));
string? il2CppPath = SteamLocator.FindIl2CppAssembliesPath();
if (string.IsNullOrWhiteSpace(il2CppPath))
{
string hooksPath = Path.Combine(Directory.GetCurrentDirectory(), "game_hooks.json");
if (!File.Exists(hooksPath))
RunOnUIThread(() =>
{
Rows.Clear();
FilteredRows.Clear();
});
return;
}
IsLoading = true;
try
{
var result = await _extractorService.ExtractAsync(
il2CppPath,
_classifier,
new UIProgress<string>(_ => { }),
CancellationToken.None).ConfigureAwait(false);
if (!result.IsSuccess)
{
RunOnUIThread(() =>
{
Rows.Clear();
FilteredRows.Clear();
});
return;
}
string json = await File.ReadAllTextAsync(hooksPath, CancellationToken.None).ConfigureAwait(false);
HookDefinition[] hooks = JsonSerializer.Deserialize<HookDefinition[]>(json) ?? Array.Empty<HookDefinition>();
var hooks = result.Hooks;
Rows.Clear();
foreach (HookDefinition hook in hooks)
await RunOnUIThreadAsync(() =>
{
Rows.Add(new HookBrowserRow
Rows.Clear();
foreach (HookDefinition hook in hooks)
{
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}")),
});
}
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);
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();
ApplyFilter();
});
}
finally
{
@@ -136,4 +167,4 @@ public sealed class HookBrowserViewModel : ObservableObject
foreach (HookBrowserRow row in query)
FilteredRows.Add(row);
}
}
}
+1 -1
View File
@@ -3,7 +3,7 @@ using CommunityToolkit.Mvvm.Input;
namespace gregExtractor.GUI.ViewModels;
public sealed class MainViewModel : ObservableObject
public sealed class MainViewModel : BaseViewModel
{
private NavigationItemViewModel? _selectedNavigationItem;
private object? _currentPageViewModel;
+5 -5
View File
@@ -4,7 +4,7 @@ using gregExtractor.Utils;
namespace gregExtractor.GUI.ViewModels;
public sealed class SettingsViewModel : ObservableObject
public sealed class SettingsViewModel : BaseViewModel
{
private string _gamePath = SteamLocator.FindGamePath() ?? string.Empty;
private string _frameworkSourcePath = Path.Combine(Directory.GetCurrentDirectory(), "..", "gregCore", "src");
@@ -31,19 +31,19 @@ public sealed class SettingsViewModel : ObservableObject
public string GamePath
{
get => _gamePath;
set => SetProperty(ref _gamePath, value);
set => SetProperty(ref _gamePath, value?.Trim() ?? string.Empty);
}
public string FrameworkSourcePath
{
get => _frameworkSourcePath;
set => SetProperty(ref _frameworkSourcePath, value);
set => SetProperty(ref _frameworkSourcePath, value?.Trim() ?? string.Empty);
}
public string ModsOutputPath
{
get => _modsOutputPath;
set => SetProperty(ref _modsOutputPath, value);
set => SetProperty(ref _modsOutputPath, value?.Trim() ?? string.Empty);
}
public bool IgnoreGettersSetters
@@ -146,4 +146,4 @@ public sealed class SettingsViewModel : ObservableObject
{
}
}
}
}
+66 -36
View File
@@ -1,3 +1,4 @@
using Avalonia.Threading;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using gregExtractor.Models;
@@ -23,7 +24,7 @@ public sealed record HookDiffEntry(
public string Display => $"{ClassName}::{MethodName}";
}
public sealed class SyncViewModel : ObservableObject
public sealed class SyncViewModel : BaseViewModel
{
private readonly ExtractorService _extractorService = new();
private readonly DiffService _diffService = new();
@@ -51,7 +52,7 @@ public sealed class SyncViewModel : ObservableObject
public string FrameworkSourcePath
{
get => _frameworkSourcePath;
set => SetProperty(ref _frameworkSourcePath, value);
set => SetProperty(ref _frameworkSourcePath, value?.Trim() ?? string.Empty);
}
public bool IsDryRun
@@ -77,12 +78,25 @@ public sealed class SyncViewModel : ObservableObject
get => _isRunning;
set
{
if (!SetProperty(ref _isRunning, value))
return;
if (Dispatcher.UIThread.CheckAccess())
{
if (!SetProperty(ref _isRunning, value))
return;
CalculateDiffCommand.NotifyCanExecuteChanged();
RunSyncCommand.NotifyCanExecuteChanged();
OnPropertyChanged(nameof(IsLoading));
CalculateDiffCommand.NotifyCanExecuteChanged();
RunSyncCommand.NotifyCanExecuteChanged();
OnPropertyChanged(nameof(IsLoading));
}
else
{
Dispatcher.UIThread.Post(() =>
{
SetProperty(ref _isRunning, value);
CalculateDiffCommand.NotifyCanExecuteChanged();
RunSyncCommand.NotifyCanExecuteChanged();
OnPropertyChanged(nameof(IsLoading));
});
}
}
}
@@ -123,7 +137,7 @@ public sealed class SyncViewModel : ObservableObject
{
if (!Directory.Exists(FrameworkSourcePath))
{
AddLog("Framework-Source-Pfad ist ungültig.");
AddLog("Framework-Source-Pfad ist ungueltig.");
return;
}
@@ -131,44 +145,49 @@ public sealed class SyncViewModel : ObservableObject
if (diff.Added.Count == 0 && diff.Changed.Count == 0 && diff.Removed.Count == 0)
{
AddLog("= Keine Änderungen erkannt. Sync übersprungen.");
AddLog("Keine Aenderungen erkannt. Sync uebersprungen.");
return;
}
IsRunning = true;
Warnings.Clear();
RunOnUIThread(() => Warnings.Clear());
try
{
HookDefinition[] allHooks = await LoadNewHooksAsync(CancellationToken.None).ConfigureAwait(false);
var progress = new Progress<string>(AddLog);
var progress = new UIProgress<string>(AddLog);
SyncResult result = _syncService.Sync(new SyncOptions(
var result = await Task.Run(() => _syncService.Sync(new SyncOptions(
FrameworkSourceDir: FrameworkSourcePath,
Diff: diff,
AllHooks: allHooks.ToList(),
DryRun: IsDryRun,
Progress: progress));
Progress: progress)), CancellationToken.None).ConfigureAwait(false);
LastResult = result;
foreach (string warning in result.Warnings)
Warnings.Add(warning);
await Dispatcher.UIThread.InvokeAsync(() =>
{
foreach (string warning in result.Warnings)
Warnings.Add(warning);
});
AddLog($"Sync abgeschlossen. Dateien: {result.FilesWritten.Count}, +{result.LinesAdded}/-{result.LinesRemoved} Zeilen");
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}");
AddLog($"game_hooks.json aktualisiert: {hooksPath}");
}
if (UseGitDiff)
await AppendGitDiffStatAsync(CancellationToken.None).ConfigureAwait(false);
}
catch (Exception exception)
catch (Exception ex)
{
AddLog($"Sync fehlgeschlagen: {exception.Message}");
AddLog($"Sync fehlgeschlagen: {ex.Message}");
}
finally
{
@@ -179,8 +198,12 @@ public sealed class SyncViewModel : ObservableObject
private async Task<HookDiff> ComputeDiffAsync(CancellationToken cancellationToken)
{
IsRunning = true;
DiffEntries.Clear();
Warnings.Clear();
RunOnUIThread(() =>
{
DiffEntries.Clear();
Warnings.Clear();
});
try
{
@@ -190,21 +213,24 @@ public sealed class SyncViewModel : ObservableObject
HookDiff diff = _diffService.Diff(oldHooks.ToList(), newHooks.ToList());
_lastDiff = diff;
foreach (HookDefinition hook in diff.Added)
DiffEntries.Add(ToEntry(HookDiffKind.Added, hook));
await Dispatcher.UIThread.InvokeAsync(() =>
{
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.Changed)
DiffEntries.Add(ToEntry(HookDiffKind.Changed, hook));
foreach (HookDefinition hook in diff.Removed)
DiffEntries.Add(ToEntry(HookDiffKind.Removed, 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)
catch (Exception ex)
{
AddLog($"Diff-Berechnung fehlgeschlagen: {exception.Message}");
AddLog($"Diff-Berechnung fehlgeschlagen: {ex.Message}");
return new HookDiff(Array.Empty<HookDefinition>(), Array.Empty<HookDefinition>(), Array.Empty<HookDefinition>());
}
finally
@@ -225,18 +251,22 @@ public sealed class SyncViewModel : ObservableObject
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));
HookClassifier classifier = HookClassifier.LoadFromFile(null, new UIProgress<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)
il2CppPath = il2CppPath.Trim();
var result = await _extractorService
.ExtractAsync(il2CppPath, classifier, new UIProgress<string>(AddLog), cancellationToken)
.ConfigureAwait(false);
return hooks.ToArray();
if (!result.IsSuccess)
throw new InvalidOperationException(result.Error ?? "Extraction failed");
return result.Hooks.ToArray();
}
private async Task AppendGitDiffStatAsync(CancellationToken cancellationToken)
@@ -264,7 +294,7 @@ public sealed class SyncViewModel : ObservableObject
AddLog(output.Trim());
if (process.ExitCode != 0 && !string.IsNullOrWhiteSpace(error))
AddLog("⚠ " + error.Trim());
AddLog(error.Trim());
}
private void OpenBackupFolder()
@@ -293,4 +323,4 @@ public sealed class SyncViewModel : ObservableObject
SyncLog.Add(line);
}
}
}
+18
View File
@@ -0,0 +1,18 @@
namespace gregExtractor.Models;
public sealed record ExtractorResult
{
public bool IsSuccess { get; init; }
public bool IsCancelled { get; init; }
public string? Error { get; init; }
public List<HookDefinition> Hooks { get; init; } = new();
public static ExtractorResult Success(List<HookDefinition> hooks)
=> new() { IsSuccess = true, Hooks = hooks };
public static ExtractorResult Failure(string error)
=> new() { IsSuccess = false, Error = error };
public static ExtractorResult Cancelled()
=> new() { IsCancelled = true };
}
+184 -94
View File
@@ -1,140 +1,230 @@
using gregExtractor.Models;
using Mono.Cecil;
namespace gregExtractor.Services;
public sealed class ExtractorService
{
public async Task<IReadOnlyList<HookDefinition>> ExtractAsync(
public async Task<ExtractorResult> ExtractAsync(
string il2CppDir,
HookClassifier classifier,
IProgress<string>? progress,
CancellationToken cancellationToken)
IProgress<string>? progress = null,
CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(il2CppDir) || !Directory.Exists(il2CppDir))
throw new DirectoryNotFoundException($"IL2CPP directory not found: {il2CppDir}");
return await Task.Run(() =>
{
try
{
ct.ThrowIfCancellationRequested();
string assemblyPath = Path.Combine(il2CppDir, "Assembly-CSharp.dll");
if (!File.Exists(assemblyPath))
throw new FileNotFoundException("Assembly-CSharp.dll was not found.", assemblyPath);
if (string.IsNullOrWhiteSpace(il2CppDir) || !Directory.Exists(il2CppDir))
return ExtractorResult.Failure($"IL2CPP directory not found: {il2CppDir}");
return await Task.Run(() => ExtractInternal(assemblyPath, il2CppDir, classifier, progress, cancellationToken), cancellationToken)
.ConfigureAwait(false);
var assemblyPath = Path.Combine(il2CppDir, "Assembly-CSharp.dll");
if (!File.Exists(assemblyPath))
return ExtractorResult.Failure($"Assembly-CSharp.dll not found in:\n{il2CppDir}");
progress?.Report("Lade Assembly...");
using var assembly = LoadAssemblySafe(assemblyPath, il2CppDir, progress);
if (assembly is null)
return ExtractorResult.Failure("Assembly konnte nicht geladen werden. Siehe crash.log.");
progress?.Report("Scanne Typen...");
var hooks = ScanTypesDefensive(assembly, classifier, progress, ct);
progress?.Report($"✓ {hooks.Count} Hooks gefunden.");
return ExtractorResult.Success(hooks);
}
catch (OperationCanceledException)
{
return ExtractorResult.Cancelled();
}
catch (Exception ex)
{
var crashLog = Path.Combine(AppContext.BaseDirectory, "crash.log");
File.AppendAllText(crashLog,
$"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] SCAN FEHLER\n{ex}\n\n");
return ExtractorResult.Failure($"Scan-Fehler: {ex.Message}\nDetails: crash.log");
}
}, ct);
}
private static IReadOnlyList<HookDefinition> ExtractInternal(
private static AssemblyDefinition? LoadAssemblySafe(
string assemblyPath,
string il2CppDir,
HookClassifier classifier,
IProgress<string>? progress,
CancellationToken cancellationToken)
IProgress<string>? progress)
{
var resolver = new DefaultAssemblyResolver();
resolver.AddSearchDirectory(il2CppDir);
foreach (string directory in Directory.EnumerateDirectories(il2CppDir))
resolver.AddSearchDirectory(directory);
string? melonDir = Path.GetDirectoryName(il2CppDir);
if (melonDir is not null)
{
resolver.AddSearchDirectory(melonDir);
var readerParameters = new ReaderParameters
string net6Dir = Path.Combine(melonDir, "net6");
if (Directory.Exists(net6Dir))
resolver.AddSearchDirectory(net6Dir);
}
foreach (string dir in Directory.EnumerateDirectories(il2CppDir))
resolver.AddSearchDirectory(dir);
var readerParams = new ReaderParameters
{
AssemblyResolver = resolver,
ReadingMode = ReadingMode.Deferred,
ReadSymbols = false,
ThrowIfSymbolsAreNotMatching = false,
};
progress?.Report("Loading Assembly-CSharp.dll via Mono.Cecil...");
progress?.Report($"Loading: {Path.GetFileName(assemblyPath)}");
using AssemblyDefinition assembly = AssemblyDefinition.ReadAssembly(assemblyPath, readerParameters);
var hooks = new List<HookDefinition>(capacity: 8192);
foreach (TypeDefinition type in assembly.MainModule.Types)
try
{
cancellationToken.ThrowIfCancellationRequested();
return AssemblyDefinition.ReadAssembly(assemblyPath, readerParams);
}
catch (AssemblyResolutionException ex)
{
progress?.Report($"Referenz fehlt: {ex.AssemblyReference.Name}");
return LoadAssemblyWithoutResolver(assemblyPath, progress);
}
catch (BadImageFormatException ex)
{
progress?.Report($"Ungültiges Format: {ex.Message}");
return null;
}
catch (IOException ex)
{
progress?.Report($"IO-Fehler: {ex.Message}");
return null;
}
catch (InvalidOperationException ex)
{
progress?.Report($"InvalidOperation: {ex.Message}");
return null;
}
}
if (!type.IsPublic || type.IsInterface)
continue;
private static AssemblyDefinition? LoadAssemblyWithoutResolver(
string assemblyPath,
IProgress<string>? progress)
{
try
{
return AssemblyDefinition.ReadAssembly(assemblyPath,
new ReaderParameters { ReadingMode = ReadingMode.Deferred });
}
catch (Exception ex)
{
progress?.Report($"Fallback-Load fehlgeschlagen: {ex.Message}");
return null;
}
}
string className = type.Name;
string namespaceName = type.Namespace;
private static List<HookDefinition> ScanTypesDefensive(
AssemblyDefinition assembly,
HookClassifier classifier,
IProgress<string>? progress,
CancellationToken ct)
{
var results = new List<HookDefinition>();
var types = assembly.MainModule.Types.ToList();
var total = types.Count;
foreach (MethodDefinition method in type.Methods)
for (int i = 0; i < total; i++)
{
ct.ThrowIfCancellationRequested();
if (i % 100 == 0)
progress?.Report($"Scanne {i}/{total} Typen...");
var type = types[i];
try
{
cancellationToken.ThrowIfCancellationRequested();
if (!IsValidType(type)) continue;
if (!method.IsPublic)
continue;
foreach (var method in type.Methods)
{
try
{
if (!IsValidMethod(method)) 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));
var hook = BuildHookDefinition(type, method, classifier);
results.Add(hook);
}
catch (Exception ex)
{
progress?.Report($"⚠ Methode übersprungen: {type.Name}.{method?.Name} — {ex.Message}");
}
}
}
catch (Exception ex)
{
progress?.Report($"⚠ Typ übersprungen: {type?.Name} — {ex.Message}");
}
}
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();
return results.OrderBy(h => h.Group, StringComparer.OrdinalIgnoreCase)
.ThenBy(h => h.ClassName, StringComparer.OrdinalIgnoreCase)
.ThenBy(h => h.MethodName, StringComparer.OrdinalIgnoreCase)
.ToList();
}
private static string NormalizeTypeName(TypeReference typeReference)
private static HookDefinition BuildHookDefinition(
TypeDefinition type,
MethodDefinition method,
HookClassifier classifier)
{
if (typeReference.IsByReference && typeReference is ByReferenceType byReferenceType)
return NormalizeTypeName(byReferenceType.ElementType);
var parameters = new List<HookParameter>();
if (typeReference is GenericInstanceType genericInstanceType)
foreach (var param in method.Parameters)
{
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}>";
string paramType = param.ParameterType?.Name ?? "object";
string paramName = string.IsNullOrWhiteSpace(param.Name)
? $"arg{param.Index}"
: param.Name;
parameters.Add(new HookParameter(paramName, paramType));
}
if (typeReference.IsArray && typeReference is ArrayType arrayType)
return $"{NormalizeTypeName(arrayType.ElementType)}[]";
string returnTypeName = method.ReturnType?.Name ?? "object";
string group = classifier.Classify(type.Name, method.Name);
string ns = type.Namespace ?? string.Empty;
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,
};
return new HookDefinition(
Group: group,
Namespace: ns,
ClassName: type.Name,
MethodName: method.Name,
ReturnType: returnTypeName,
IsVoid: string.Equals(returnTypeName, "Void", StringComparison.OrdinalIgnoreCase),
Parameters: parameters);
}
}
private static bool IsValidType(TypeDefinition? type)
{
if (type is null) return false;
if (!type.IsPublic) return false;
if (type.IsInterface) return false;
if (string.IsNullOrWhiteSpace(type.Name)) return false;
if (type.Name.StartsWith('<')) return false;
if (type.Name.StartsWith("__", StringComparison.Ordinal)) return false;
return true;
}
private static bool IsValidMethod(MethodDefinition? method)
{
if (method is null) return false;
if (!method.IsPublic) return false;
if (method.IsConstructor) return false;
if (method.IsSpecialName) return false;
if (string.IsNullOrWhiteSpace(method.Name)) return false;
if (method.Name.StartsWith('<')) return false;
if (method.ReturnType is null) return false;
return true;
}
}
+64 -14
View File
@@ -6,22 +6,50 @@ 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"],
["Persistence"] = ["save", "load", "autosave", "serialize", "deserialize", "generateid"],
["Economy"] = ["coin", "money", "shop", "buy", "xp", "reputation", "checkout", "salary", "revenue", "balance", "app", "customer"],
["Networking"] = ["connect", "device", "cable", "ip", "network", "lacp", "sfp"],
["Hardware"] = ["power", "repair", "broken", "server", "switch", "technician", "sfpmodule", "insertport", "slideport"],
["Lifecycle"] = ["pause", "resume", "day", "scene", "quit", "loadlevel", "objective", "operator", "spawn", "destroy"],
["VisualUI"] = ["ui", "menu", "panel", "screen", "button", "hud", "pointer", "select", "submit", "color", "picker", "keyboard", "text", "osk", "scroll", "recycle"],
["Audio"] = ["fade", "volume", "music", "audio", "sound"],
["Character"] = ["move", "walk", "run", "animation", "mouth", "talk", "express", "controller", "input", "look", "waypoint"],
["World"] = ["interact", "collision", "trigger", "animator", "environment", "dumpster", "rope", "usable", "trolley", "gate", "lever"],
["Input"] = ["input", "key", "mouse", "viper", "invert"],
["Steam"] = ["steam", "leaderboard"],
["Settings"] = ["settings", "volume", "gameplay", "controls", "packet", "route", "language", "rect"],
["Facility"] = ["wall", "openwall", "push", "container"],
["CustomImport"] = ["modprefab", "creatematerial", "importobj"],
["Interaction"] = ["interact", "labelaction", "secondaction", "hover"],
["Tutorials"] = ["tutorial", "playvideo", "showtutorial", "stoptutorial"],
["DevTools"] = ["godmod", "cheat", "debug"],
["Maintenance"] = ["technician", "repair", "dispatch", "job"],
["GameState"] = ["commandcenter", "operator", "fcp"],
["Serialization"] = ["hashcode", "equals", "typeindex", "static", "assembly"],
};
private static readonly Dictionary<string, string[]> ClassKeywordMap = new(StringComparer.OrdinalIgnoreCase)
{
["Persistence"] = ["save"],
["Economy"] = ["player", "shop", "economy"],
["Persistence"] = ["save", "serial", "fcp"],
["Economy"] = ["player", "shop", "economy", "balance", "customer", "bill"],
["Networking"] = ["network", "cable", "packet", "switch", "server"],
["Hardware"] = ["server", "rack", "item", "asset"],
["Lifecycle"] = ["loading", "pause", "time", "scene", "playermanager"],
["VisualUI"] = ["menu", "ui", "hud", "panel"],
["Hardware"] = ["server", "rack", "item", "asset", "sfpbox", "setip", "technician", "sfpmodule"],
["Lifecycle"] = ["loading", "pause", "time", "scene", "playermanager", "objective", "commandcenter", "operator"],
["VisualUI"] = ["menu", "ui", "hud", "panel", "button", "color", "picker", "osk", "settingsgraphics", "tmp", "scrollrect", "recycling"],
["Audio"] = ["audio", "music", "sound", "fade"],
["Character"] = ["ai", "character", "npc", "firstperson", "expression", "mouseray", "waypoint"],
["World"] = ["world", "env", "dumpster", "prop", "car", "controller", "rope", "usable", "trolley", "gate", "lever"],
["Input"] = ["input", "viper", "keyboard", "key"],
["Steam"] = ["steam"],
["Settings"] = ["settings", "controls", "gameplay", "volume", "rect"],
["Facility"] = ["wall", "trolley", "gate", "lever"],
["CustomImport"] = ["modloader", "objimporter"],
["Interaction"] = ["interact"],
["Tutorials"] = ["tutorial"],
["DevTools"] = ["godmod"],
["Maintenance"] = ["technician"],
["GameState"] = ["commandcenter", "fcp"],
["Serialization"] = ["registry", "type", "assembly"],
};
public HookClassifier(IReadOnlyDictionary<string, HookGroupConfig> groups)
@@ -31,14 +59,18 @@ public sealed class HookClassifier
public IReadOnlyDictionary<string, HookGroupConfig> Groups { get; }
public static HookClassifier LoadFromFile(string jsonPath, IProgress<string>? progress = null)
public static HookClassifier LoadFromFile(string? configuredPath, IProgress<string>? progress = null)
{
if (!File.Exists(jsonPath))
string jsonPath = ResolveHookGroupsPath(configuredPath);
if (jsonPath is null)
{
progress?.Report($"hook_groups.json not found at '{jsonPath}'. Classifier starts with empty groups.");
progress?.Report("hook_groups.json nicht gefunden. Classifier startet mit leeren Gruppen.");
return new HookClassifier(new Dictionary<string, HookGroupConfig>(StringComparer.OrdinalIgnoreCase));
}
progress?.Report($"Lade hook_groups.json von: {jsonPath}");
try
{
string json = File.ReadAllText(jsonPath);
@@ -71,6 +103,24 @@ public sealed class HookClassifier
}
}
private static string? ResolveHookGroupsPath(string? configuredPath)
{
if (configuredPath is not null && File.Exists(configuredPath))
return configuredPath;
string exeDir = Path.GetDirectoryName(
System.Reflection.Assembly.GetExecutingAssembly().Location)!;
string nextToExe = Path.Combine(exeDir, "hook_groups.json");
if (File.Exists(nextToExe))
return nextToExe;
string inCwd = Path.Combine(Directory.GetCurrentDirectory(), "hook_groups.json");
if (File.Exists(inCwd))
return inCwd;
return null;
}
public string Classify(string className, string methodName)
{
// 1) Exact class + method match
+19
View File
@@ -0,0 +1,19 @@
using Avalonia.Threading;
namespace gregExtractor.Utils;
public sealed class UIProgress<T> : IProgress<T>
{
private readonly Action<T> _handler;
public UIProgress(Action<T> handler)
=> _handler = handler;
public void Report(T value)
{
if (Dispatcher.UIThread.CheckAccess())
_handler(value);
else
Dispatcher.UIThread.Post(() => _handler(value));
}
}
+83 -13
View File
@@ -11,13 +11,13 @@
},
"Facility": {
"description": "Building layouts, room expansions, floor designs",
"classes": ["RoomManager", "BuildingManager", "FacilityManager", "FloorManager"],
"methods": ["AddRoom", "RemoveRoom", "ExpandRoom", "PlaceObject", "RemoveObject", "UnlockArea"]
"classes": ["RoomManager", "BuildingManager", "FacilityManager", "FloorManager", "Wall", "PushTrolleyHandle", "TrolleyTrigger", "GateLever"],
"methods": ["AddRoom", "RemoveRoom", "ExpandRoom", "PlaceObject", "RemoveObject", "UnlockArea", "OpenWall", "OnHoverOver", "ObjectAdded", "OpenGate", "CloseGate", "TruckComing"]
},
"Hardware": {
"description": "Custom servers, racks, switches, equipment",
"classes": ["Server", "Rack", "Switch", "Item", "ShopItemSO", "AssetManagement"],
"methods": ["PowerButton", "SetIP", "UpdateCustomer", "UpdateAppID", "IsBroken", "RepairDevice", "BuyNewItem"]
"classes": ["Server", "Rack", "Switch", "Item", "ShopItemSO", "AssetManagement", "TechnicianManager", "SFPBox", "SetIP", "InputManager", "Technician", "SFPModule"],
"methods": ["PowerButton", "SetIP", "UpdateCustomer", "UpdateAppID", "IsBroken", "RepairDevice", "BuyNewItem", "AddDevice", "RemoveDevice", "Connect", "Disconnect", "AssignJob", "RequestJobDelayed", "RotateTowardsGoal", "SendToContainer", "SetHandIKWeight", "InsertDirectlyIntoPort", "SlideIntoPort"]
},
"Networking": {
"description": "Cable routing, packet visualization, network configs",
@@ -31,27 +31,97 @@
},
"Economy": {
"description": "Player economy, XP, shop transactions",
"classes": ["Player", "ComputerShop"],
"methods": ["UpdateCoin", "UpdateXP", "UpdateReputation", "ButtonCheckOut", "UpdateCartTotal"]
"classes": ["Player", "ComputerShop", "BalanceSheet", "BalanceSheetRow", "CustomerBase", "CustomerBaseDoor", "CustomerCard", "CustomerColor"],
"methods": ["UpdateCoin", "UpdateXP", "UpdateReputation", "ButtonCheckOut", "UpdateCartTotal", "AddRow", "AddSalaryRow", "AddSectionTitle", "AddTotalRow", "RegisterSalary", "TrackFinances", "AddAppPerformance", "AppText", "SetUpApp", "SetUpBase", "InteractOnHover", "OpenDoorAndSetupBase", "SetCustomer", "GetColorForCustomerId", "GetTotalAppSpeed", "GetSubnetsPerApp", "GetVlanIdsPerApp", "GetOrCreateRecord", "RestoreRecord", "InstantiateRow"]
},
"Persistence": {
"description": "Save/Load game state",
"classes": ["SaveSystem", "SaveData"],
"methods": ["SaveGame", "LoadGame", "AutoSave", "Load", "SaveGameData", "LoadGameData"]
"classes": ["SaveSystem", "SaveData", "ColorSerializationSurrogate", "FCP_Persistence"],
"methods": ["SaveGame", "LoadGame", "AutoSave", "Load", "SaveGameData", "LoadGameData", "GetObjectData", "SetObjectData", "GenerateID"]
},
"VisualUI": {
"description": "Interface overhauls, themes, visual enhancements",
"classes": ["MainMenu", "PauseMenu", "UIManager", "HUDManager", "AssetManagement"],
"methods": ["Continue", "NewGame", "Settings", "OpenPanel", "ClosePanel", "SetTheme"]
"classes": ["MainMenu", "PauseMenu", "UIManager", "HUDManager", "AssetManagement", "ButtonExtended", "FlexibleColorPicker", "OSK_Keyboard", "OSK_Key", "OSK_GlyphHandler", "OSK_Keymap", "OSK_Receiver", "OSK_AccentConsole", "SettingsGraphics", "TMP_TextEventHandler", "TMP_TextSelector_B", "RecyclableScrollRect", "HorizontalRecyclingSystem", "VerticalRecyclingSystem", "RecyclingSystem", "AutoScrollRect"],
"methods": ["Continue", "NewGame", "Settings", "OpenPanel", "ClosePanel", "SetTheme", "OnPointerClick", "OnPointerEnter", "OnPointerExit", "OnSelect", "OnSubmit", "OnDeselect", "ChangeMode", "GetColor", "SetColor", "ParseHex", "RGBToHSV", "HSVToRGB", "PickColor", "SetMarker", "GetValue", "IsValidHexChar", "GetNormalizedPointerPosition", "GetSanitizedHex", "Initialize", "OnValueChangedListener", "SetRecyclingBounds", "SetTopAnchor", "ScrollAuto"]
},
"Lifecycle": {
"description": "Scene, load, pause, time events",
"classes": ["LoadingScreen", "PauseMenu", "TimeController", "PlayerManager"],
"methods": ["LoadGameScenes", "LoadLevel", "UnLoadLevel", "Pause", "Resume", "OnApplicationQuit"]
"classes": ["LoadingScreen", "PauseMenu", "TimeController", "PlayerManager", "Objectives", "CommandCenter"],
"methods": ["LoadGameScenes", "LoadLevel", "UnLoadLevel", "Pause", "Resume", "OnApplicationQuit", "SpawnOperatorsForLevel", "DestroyOperatorsForLevel", "SpawnOperatorsForSingleLevel", "ToggleClearWarningAuto"]
},
"Audio": {
"description": "Audio management, music, sound effects",
"classes": ["AudioManager"],
"methods": ["FadeIn", "FadeOut", "FadeOut_FadeIn", "SetEffectsVolume", "SetMasterVolume", "SetMusic", "SetMusicVolume", "SetRacksVolume"]
},
"Character": {
"description": "AI, NPCs, character movement and expressions",
"classes": ["AICharacterControl", "AICharacterExpressions", "FirstPersonController", "MouseLook", "WaypointInitializationSystem", "RayLookAt"],
"methods": ["AgentReachTarget", "AnimSit", "GotoNextPoint", "moveBack", "SetTarget", "Start", "MouthShape", "Talk", "Talking", "GetInput", "GetMouseLook", "OnControllerColliderHit", "ProgressStepCycle", "UpdateCameraPosition", "UpdateNormalFov", "LookAt"]
},
"World": {
"description": "Environment, props, interactables",
"classes": ["Dumpster", "EnvMapAnimator", "CheckIfTouchingWall", "CarController", "ActionKeyHint", "UsableObject", "Rope", "viperInput"],
"methods": ["InteractOnHover", "DelayedOverlapCheck", "SetRenderersEnabled", "OnCollisionEnter", "ResetingTrollerPosition", "CustomKey", "DelayedAppDoorOpening", "AutoDisable"]
},
"Input": {
"description": "Player input handling",
"classes": ["InputManager", "viperInput"],
"methods": ["GetInput", "OnInput", "OnKeyDown", "OnKeyUp", "OnMouseMove"]
},
"Steam": {
"description": "Steam integration, leaderboards",
"classes": ["SteamManager", "SteamLeaderboards"],
"methods": ["InitOnPlayMode", "Init", "OnLeaderboardFound", "RequestUserEntry"]
},
"Settings": {
"description": "Game settings and options",
"classes": ["SettingsControls", "SettingsGameplay", "SettingsVolume", "SettingsSingleton", "RectExtensions"],
"methods": ["InvertY", "SetPacketType", "SetRouteEvalInterval", "OnLanguageDropDownChange", "DisableOnAfterFirstSettingUp"]
},
"CustomImport": {
"description": "Mod loading and custom asset importing",
"classes": ["ModLoader", "ObjImporter"],
"methods": ["GetModPrefab", "GetModPrefabByFolder", "CreateMaterial", "ImportOBJ"]
},
"Interaction": {
"description": "Player interaction with world objects",
"classes": ["Interact"],
"methods": ["LabelActionOnClick", "SecondActionOnClick", "IsAllowedToDoSecondAction", "OnHoverOver"]
},
"Tutorials": {
"description": "Tutorial system integration",
"classes": ["Tutorials"],
"methods": ["PlayVideo", "ShowTutorial", "StopTutorial", "OnEnable"]
},
"DevTools": {
"description": "Developer tools and debug features",
"classes": ["GODMOD"],
"methods": ["StartGodMod", "GODMOD_delayed", "OnEnable", "OnDisable"]
},
"Maintenance": {
"description": "Technician dispatch and repair jobs",
"classes": ["Technician", "TechnicianManager"],
"methods": ["AssignJob", "RequestJobDelayed", "RotateTowardsGoal", "SendToContainer", "SetHandIKWeight", "EnqueueDispatch", "GetActiveJobs", "GetQueuedJobs", "ProcessDispatchQueue", "RequestNextJob", "RestoreJobQueue"]
},
"GameState": {
"description": "Game state and persistence",
"classes": ["CommandCenter", "FCP_Persistence"],
"methods": ["SpawnOperatorsForLevel", "DestroyOperatorsForLevel", "SpawnOperatorsForSingleLevel", "ToggleClearWarningAuto", "GenerateID", "OnEnable", "OnDisable"]
},
"Steam": {
"description": "Steam integration, leaderboards",
"classes": ["SteamManager", "SteamLeaderboards"],
"methods": ["InitOnPlayMode", "Init", "OnLeaderboardFound", "RequestUserEntry"]
},
"Serialization": {
"description": "Type registry, serialization helpers",
"classes": ["AssemblyTypeRegistry"],
"methods": ["BoxedGetHashCode", "Equals", "SetSharedStaticTypeIndices"]
},
"Misc": {
"description": "Other utilities and catch-all hooks",
"classes": [],
"methods": []
}
}
}