Skip to content

Commit

Permalink
First draft of an alternative CliEntryPoint that serves as the refere…
Browse files Browse the repository at this point in the history
…nce implementation of the Andraste.Launcher.exe, which will essentially just call into this.

Game specific launchers, on the other hand, may want to build upon this entry point to do some specific things.
Thus, Andraste.Host now depends on System.CommandLine. UI Applications do not need to depend on Andraste.Host anymore, though, and can just use the "CLI"
  • Loading branch information
MeFisto94 committed Feb 27, 2024
1 parent 841a56c commit 876daa0
Show file tree
Hide file tree
Showing 4 changed files with 359 additions and 0 deletions.
2 changes: 2 additions & 0 deletions Andraste.Host.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -15,5 +15,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Andraste.EasyHook" Version="1.0.2" />
<PackageReference Include="Andraste.Shared" Version="0.2.0" />
<PackageReference Include="System.CommandLine" Version="2.0.0-beta4.22272.1" />
</ItemGroup>
</Project>
43 changes: 43 additions & 0 deletions BindingRedirects.cs
Original file line number Diff line number Diff line change
@@ -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.
}
}
275 changes: 275 additions & 0 deletions CommandLine/CliEntryPoint.cs
Original file line number Diff line number Diff line change
@@ -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<bool>("--non-interactive", () => false,
"non-interactive mode: Do not redirect logging output. To be used for programmatic launches");

var fileOption = new Option<string>("--file",
"The path to the application's executable")
{
IsRequired = true
};

var frameworkDllOption = new Option<string>("--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<string>("--modsJsonPath",
"The path to the mods.json file, that contains all necessary information");
var modsFolderPathOption = new Option<string>("--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<CommandResult> 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<CommandResult> 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<string>("--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<int>("pid", "The process id to attach to")
{
IsRequired = true
};

ValidateSymbolResult<CommandResult> 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
39 changes: 39 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -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.

0 comments on commit 876daa0

Please sign in to comment.