diff --git a/Andraste.Host.csproj b/Andraste.Host.csproj index 9d3c2a7..d60bbc3 100644 --- a/Andraste.Host.csproj +++ b/Andraste.Host.csproj @@ -15,5 +15,7 @@ + + diff --git a/BindingRedirects.cs b/BindingRedirects.cs new file mode 100644 index 0000000..c35eb41 --- /dev/null +++ b/BindingRedirects.cs @@ -0,0 +1,43 @@ +using System; +using System.IO; + +namespace Andraste.Host +{ + public static class BindingRedirects + { + [Obsolete("Prefer to use the variants that require specifying the path to the actual .config files")] + public static void Setup(string exePath, string dllName) + { + // Unfortunately, .NET FX requires us to add the config file with the bindings redirect, otherwise it fails to load assemblies. + // This fails when you run the game multiple times with different .configs (or if the .config is locked by the file?), but that's a corner case. + // TODO: In theory we'd need to merge files, because here, dllName.config does not containing transitive rewrites that are part in Andraste.Shared.dll.config + var bindingRedirectFile = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, dllName + ".config"); + var bindingRedirectShared = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Andraste.Shared.dll.config"); + if (File.Exists(bindingRedirectFile)) + { + File.Copy(bindingRedirectFile, exePath + ".config", true); + // For some reason, debugging has shown that sometimes, it tries to resolve the .configs in the Launcher directory. Is that dependant on the app? + File.Copy(bindingRedirectFile, + Path.Combine(AppDomain.CurrentDomain.BaseDirectory, Path.GetFileName(exePath)) + ".config", true); + //File.Copy(bindingRedirectShared, Path.Combine(Path.GetDirectoryName(exePath)!, "Andraste.Shared.dll.config"), true); + } + else if (File.Exists(bindingRedirectShared)) + { + Console.WriteLine("Warning: Framework does not have a specific binding redirect file. Trying Andraste.Shared"); + File.Copy(bindingRedirectShared, exePath + ".config", true); + } + else + { + Console.WriteLine( + $"Warning: Could not find a binding redirect file at {bindingRedirectFile}. Try to have your IDE generate one."); + } + } + + public static void CopyRedirects(string sourceFile, string frameworkPath, string applicationFile) + { + File.Copy(sourceFile, Path.Combine(frameworkPath, applicationFile + ".config"), true); + } + + // TODO: Merge XML files, but this may be non-trivial due to actual version conflicts, so rather make downstream frameworks supply the right config files. + } +} \ No newline at end of file diff --git a/CommandLine/CliEntryPoint.cs b/CommandLine/CliEntryPoint.cs new file mode 100644 index 0000000..5a7671c --- /dev/null +++ b/CommandLine/CliEntryPoint.cs @@ -0,0 +1,275 @@ +using System; +using System.CommandLine; +using System.CommandLine.IO; +using System.CommandLine.Parsing; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using Andraste.Host.Logging; +using Andraste.Shared.Util; + +#nullable enable +namespace Andraste.Host.CommandLine +{ + public class CliEntryPoint: EntryPoint + { + private readonly RootCommand _rootCommand; + + public CliEntryPoint() + { + _rootCommand = new RootCommand("The Andraste Game Launcher Component"); + BuildSubCommands(); + Initialize(); + } + + private void BuildSubCommands() + { + var nonInteractiveOption = new Option("--non-interactive", () => false, + "non-interactive mode: Do not redirect logging output. To be used for programmatic launches"); + + var fileOption = new Option("--file", + "The path to the application's executable") + { + IsRequired = true + }; + + var frameworkDllOption = new Option("--frameworkDll", + () => "Andraste.Payload.Generic.dll", + "The name of the framework dll (that has to be in _this_ folder) to use") + { + IsRequired = true + }; + + var modsJsonPathOption = new Option("--modsJsonPath", + "The path to the mods.json file, that contains all necessary information"); + var modsFolderPathOption = new Option("--modsPath", + "The path to the mods folder. If you can't launch by using a mods.json, this will auto-enable " + + "all mods. Prefer to use --modsJsonPath where possible."); + ValidateSymbolResult modsValidator = result => + { + if (result.Children.Count(s => s.Symbol == modsJsonPathOption || s.Symbol == modsFolderPathOption) != 1) + { + result.ErrorMessage = "Either --modsJsonPath or --modsPath have to be specified"; + return; + } + + if (result.Children.Any(s => s.Symbol == modsJsonPathOption)) + { + var modsJsonPath = result.GetValueForOption(modsJsonPathOption); + if (!File.Exists(modsJsonPath)) + { + result.ErrorMessage = $"File {modsJsonPath} does not exist!"; + } + } + + if (result.Children.Any(s => s.Symbol == modsFolderPathOption)) + { + var modsFolder = result.GetValueForOption(modsFolderPathOption); + if (!Directory.Exists(modsFolder)) + { + result.ErrorMessage = $"Folder {modsFolder} does not exist!"; + } + } + }; + + ValidateSymbolResult validateFilesExist = result => + { + var symbol = result.Children.FirstOrDefault(s => s.Symbol == fileOption); + if (symbol != null) + { + var path = result.GetValueForOption(fileOption); + if (!File.Exists(path)) + { + result.ErrorMessage = $"File {path} does not exist!"; + } + } + + symbol = result.Children.FirstOrDefault(s => s.Symbol == frameworkDllOption); + if (symbol != null && result.GetValueForOption(frameworkDllOption) != null) + { + var path = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, + result.GetValueForOption(frameworkDllOption)!); + if (!File.Exists(path)) + { + result.ErrorMessage = $"File {path} does not exist!"; + } + } + }; + + var commandLineArgument = + new Option("--commandLine", "The command line to pass to the application"); + var launchCommand = new Command("launch", "Launch an executable by path") + { + fileOption, + frameworkDllOption, + modsJsonPathOption, + modsFolderPathOption, + commandLineArgument + }; + launchCommand.AddValidator(modsValidator); + launchCommand.AddValidator(validateFilesExist); + + var monitorCommand = new Command("monitor", "Monitor an executable by path and auto-attach") + { + fileOption, + frameworkDllOption, + modsJsonPathOption, + modsFolderPathOption + }; + monitorCommand.AddValidator(modsValidator); + monitorCommand.AddValidator(validateFilesExist); + + var pidOption = new Option("pid", "The process id to attach to") + { + IsRequired = true + }; + + ValidateSymbolResult validPidValidator = result => + { + var processId = result.GetValueForOption(pidOption); + try + { + var proc = Process.GetProcessById(processId); + } + catch (Exception ex) + { + result.ErrorMessage = $"Process {processId} not found! {ex}"; + } + }; + + var attachCommand = new Command("attach", "Attach to a running process") + { + pidOption, + frameworkDllOption, + modsJsonPathOption, + modsFolderPathOption + }; + attachCommand.AddValidator(modsValidator); + attachCommand.AddValidator(validPidValidator); + + _rootCommand.AddCommand(launchCommand); + _rootCommand.AddCommand(monitorCommand); + _rootCommand.AddCommand(attachCommand); + _rootCommand.AddGlobalOption(nonInteractiveOption); + + launchCommand.SetHandler(LaunchGame, nonInteractiveOption, fileOption, frameworkDllOption, modsJsonPathOption, modsFolderPathOption, commandLineArgument); + monitorCommand.SetHandler(MonitorGame, nonInteractiveOption, fileOption, frameworkDllOption, modsJsonPathOption, modsFolderPathOption); + attachCommand.SetHandler(AttachGame, nonInteractiveOption, pidOption, frameworkDllOption, modsJsonPathOption, modsFolderPathOption); + } + + public void InvokeSync(string commandLine, IConsole? outputConsole) + { + _rootCommand.Invoke(commandLine, outputConsole ?? new SystemConsole()); + } + + public void InvokeSync(string[] commandLine, IConsole? outputConsole) { + _rootCommand.Invoke(commandLine, outputConsole ?? new SystemConsole()); + } + + // Not sure if there is a useful use case. + public async Task InvokeAsync(string commandLine, IConsole? outputConsole) + { + await _rootCommand.InvokeAsync(commandLine, outputConsole ?? new SystemConsole()); + } + + protected virtual void LaunchGame(bool nonInteractive, string applicationPath, string frameworkDllName, + string? modsJsonPath, string? modsFolder, string commandLine) + { + var profileFolder = PreLaunch(modsJsonPath, modsFolder); + // actually, we need the framework folder but with the game name? This fixes binding redirects apparently. + SetupBindingRedirects(applicationPath, frameworkDllName); + var process = StartApplication(applicationPath, commandLine, Path.Combine(AppDomain.CurrentDomain.BaseDirectory, frameworkDllName), profileFolder); + PostLaunch(process, profileFolder, nonInteractive); + } + + protected virtual void SetupBindingRedirects(string applicationPath, string frameworkDllName) + { + var redirectFile = frameworkDllName + ".config"; + if (!File.Exists(redirectFile)) + { + // Fall back to the generic DLL + redirectFile = "Andraste.Payload.Generic.dll.config"; + } + + BindingRedirects.CopyRedirects(redirectFile, AppDomain.CurrentDomain.BaseDirectory, Path.GetFileName(applicationPath)); + } + + protected virtual void MonitorGame(bool nonInteractive, string applicationPath, string frameworkDllName, + string? modsJsonPath, string modsFolder) + { + PreLaunch(modsJsonPath, modsFolder); + SetupBindingRedirects(applicationPath, frameworkDllName); + // TODO: PostLaunch + //PostLaunch(); + } + + protected virtual void AttachGame(bool nonInteractive, int pid, string frameworkDllName, + string? modsJsonPath, string modsFolder) + { + var profileFolder = PreLaunch(modsJsonPath, modsFolder); + var process = Process.GetProcessById(pid); + // TODO: Does this MainModule work? + SetupBindingRedirects(process.MainModule!.FileName, frameworkDllName); + PostLaunch(process, profileFolder, nonInteractive); + } + + protected virtual string PreLaunch(string? modsJsonPath, string? modsFolder) + { + if (!string.IsNullOrEmpty(modsJsonPath)) + { + return Directory.GetParent(modsJsonPath)!.FullName; + } + + if (!Directory.Exists(modsFolder)) + { + Console.WriteLine("Creating \"mods\" folder"); + Directory.CreateDirectory(modsFolder); + } + + // Build the mods.json + var modsJson = ModJsonBuilder.WriteModsJson(modsFolder); + File.WriteAllBytes(Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "mods.json"), modsJson); + return Directory.GetParent(modsFolder)!.FullName; + + } + + protected virtual void PostLaunch(Process? process, string modsJsonFolder, bool nonInteractive) + { + if (nonInteractive) + { + // return PID and terminate. We use the PID as the exit code here as well, because that's easy to read out. + Console.WriteLine(process?.Id ?? -1); + Environment.Exit(process?.Id ?? -1); + } + else + { + if (process == null) + { + Console.Error.WriteLine("Failed to launch the application!"); + return; + } + + Console.Title = $"Andraste Console Launcher - Attached to PID {process.Id}"; + + #region Logging + var output = new FileLoggingHost(Path.Combine(modsJsonFolder, "output.log")); + var err = new FileLoggingHost(Path.Combine(modsJsonFolder, "error.log")); + output.LoggingEvent += (sender, args) => Console.WriteLine(args.Text); + err.LoggingEvent += (sender, args) => Console.Error.WriteLine(args.Text); + output.StartListening(); + err.StartListening(); + #endregion + + // Keep this thread (and thus the application) running + process.WaitForExit(); + + // Dispose/Cleanup + output.StopListening(); + err.StopListening(); + Console.WriteLine("Process exited"); + } + } + } +} +#nullable restore diff --git a/README.md b/README.md new file mode 100644 index 0000000..98cb3f6 --- /dev/null +++ b/README.md @@ -0,0 +1,39 @@ +# Andraste - The "native" C# Modding Framework + +> Britain/Celtic goddess of war, symbolizing invincibility + +The Andraste Modding Framework aims to be a solid base for those writing an +in-process modding framework for native (x86, 32bit) Windows applications (Games). + +It is mostly the result of generalizing code that I would have written +specifically for one game. Releasing it may help others to quickly re-use +functionality as well as maybe contributing and reviewing decisions made here. + +## The Host Library +The host is responsible for launching/injecting the payload into the game and +communicating with it. Now due to a few circumstances, it's not actually as simple as that: +- Andraste's GUI shall support loading/launching from arbitrary paths (i.e. specific +versions and distributions that live in folders outside of the actual GUI), but EasyHook +will always start the CLR with a `PATH` relative to the DLL that initiated the injection. +This would mean, when the GUI was capable of launching, we couldn't load arbitrary distributions. +- Since we want to support multiple versions and potentially even game specific frameworks, +that may extend the communication between the game and the host, it'd be hard to support +all from an application that has the primary goal of providing a user interface. +- The idea hence is to split launching between the actual launcher component (`Andraste.Launcher.exe`), that is +part of the Andraste distribution, and the actual UI (TUI/CLI, GUI). Thus, the launcher +does not worry about any API stability as it can natively talk to the payload, +it's just the UI<->Launcher interface that needs to be stable. +- The "TUI"/CLI will most likely be that exact `Andraste.Launcher.exe`, that the UI also uses, as I don't see any +point in adding yet another CLI/TUI layer with a more simple argument interface. +- This, at the time of writing this ;), sounds like a good idea, especially when +imagining using the generic Andraste GUI (or a CLI when launched via steam et al) +while still leaving the framework distributor the choice of implementation. +Some games may not like being launched directly, so the Launcher can implement +specific workarounds, too. +- This also leaves room for launching multiple profiles from the same UI concurrently, +where every launcher monitors the relevant PID + +Thus, this library contains everything that is needed to build the actual Launchers, but +the CLI will heavily rely on this and basically be a no-op, as the CLI Launcher will have +to have the full potential and the GUI will just call the CLI Launcher that is unique to +each andraste distribution. \ No newline at end of file