feat(startup): add configurable game root

This commit is contained in:
gamer147
2026-07-28 23:05:25 -04:00
parent b1d4a791b5
commit d2ec834fc3
14 changed files with 339 additions and 25 deletions

View File

@@ -0,0 +1,132 @@
using Age.Engine.Sys4;
public sealed class GameRootSelectionTests
{
[Fact]
public void ExplicitRelativeOverrideWinsAndResolvesAgainstCurrentDirectory()
{
using var tree = new TempTree();
string executableDirectory = tree.CreateDirectory("exe", withCatalog: true);
string currentDirectory = tree.CreateDirectory("working", withCatalog: true);
string selectedDirectory = tree.CreateDirectory("selected", withCatalog: true);
GameRootSelection selection = GameRootSelection.Resolve(
["--scene", "SYSTEM4", "--game-root", Path.Combine("..", "selected")],
Path.Combine(executableDirectory, "OpenMaidEngine.exe"),
currentDirectory);
Assert.Equal(selectedDirectory, selection.Root);
Assert.Equal(GameRootSource.LaunchArgument, selection.Source);
Assert.Equal("launch-argument", selection.SourceName);
}
[Fact]
public void DuplicateOverrideUsesLastDirectory()
{
using var tree = new TempTree();
string currentDirectory = tree.CreateDirectory("working");
tree.CreateDirectory("first", withCatalog: true);
string second = tree.CreateDirectory("second", withCatalog: true);
GameRootSelection selection = GameRootSelection.Resolve(
["--game-root", Path.Combine("..", "first"),
"--game-root", Path.Combine("..", "second")],
null,
currentDirectory);
Assert.Equal(second, selection.Root);
}
[Fact]
public void DefaultsToExecutableDirectory()
{
using var tree = new TempTree();
string executableDirectory = tree.CreateDirectory("exe", withCatalog: true);
string currentDirectory = tree.CreateDirectory("working", withCatalog: true);
GameRootSelection selection = GameRootSelection.Resolve(
[], Path.Combine(executableDirectory, "OpenMaidEngine.exe"), currentDirectory);
Assert.Equal(executableDirectory, selection.Root);
Assert.Equal(GameRootSource.ExecutableDirectory, selection.Source);
}
[Fact]
public void FallsBackToCurrentDirectory()
{
using var tree = new TempTree();
string executableDirectory = tree.CreateDirectory("exe");
string currentDirectory = tree.CreateDirectory("working", withCatalog: true);
GameRootSelection selection = GameRootSelection.Resolve(
[], Path.Combine(executableDirectory, "OpenMaidEngine"), currentDirectory);
Assert.Equal(currentDirectory, selection.Root);
Assert.Equal(GameRootSource.CurrentDirectory, selection.Source);
}
[Theory]
[InlineData("--game-root")]
[InlineData("--game-root", "--scene", "SYSTEM4")]
public void MissingOverridePathIsRejected(params string[] arguments)
{
using var tree = new TempTree();
var error = Assert.Throws<ArgumentException>(
() => GameRootSelection.Resolve(arguments, null, tree.Root));
Assert.Contains("--game-root requires a directory path", error.Message);
}
[Fact]
public void ExplicitDirectoryWithoutCatalogIsRejectedWithoutFallback()
{
using var tree = new TempTree();
string executableDirectory = tree.CreateDirectory("exe", withCatalog: true);
string currentDirectory = tree.CreateDirectory("working");
tree.CreateDirectory("invalid");
var error = Assert.Throws<ArgumentException>(() => GameRootSelection.Resolve(
["--game-root", Path.Combine("..", "invalid")],
Path.Combine(executableDirectory, "OpenMaidEngine.exe"),
currentDirectory));
Assert.Contains("does not contain SYS4INI.BIN", error.Message);
}
[Fact]
public void MissingDefaultsReportSearchedLocationsAndOverride()
{
using var tree = new TempTree();
string executableDirectory = tree.CreateDirectory("exe");
string currentDirectory = tree.CreateDirectory("working");
var error = Assert.Throws<ArgumentException>(() => GameRootSelection.Resolve(
[], Path.Combine(executableDirectory, "OpenMaidEngine"), currentDirectory));
Assert.Contains(executableDirectory, error.Message);
Assert.Contains(currentDirectory, error.Message);
Assert.Contains("--game-root <directory>", error.Message);
}
private sealed class TempTree : IDisposable
{
public string Root { get; } = Path.Combine(
Path.GetTempPath(), "age-game-root-tests", Guid.NewGuid().ToString("N"));
public TempTree() => Directory.CreateDirectory(Root);
public string CreateDirectory(string name, bool withCatalog = false)
{
string path = Path.Combine(Root, name);
Directory.CreateDirectory(path);
if (withCatalog) File.WriteAllBytes(Path.Combine(path, "SYS4INI.BIN"), []);
return Path.GetFullPath(path);
}
public void Dispose()
{
if (Directory.Exists(Root)) Directory.Delete(Root, recursive: true);
}
}
}

View File

@@ -0,0 +1,99 @@
namespace Age.Engine.Sys4;
public enum GameRootSource
{
LaunchArgument,
ExecutableDirectory,
CurrentDirectory,
}
/// <summary>
/// Resolves the installed game's root independently of repository/development paths.
/// A valid root is anchored by the game's authoritative SYS4INI catalog.
/// </summary>
public readonly record struct GameRootSelection(string Root, GameRootSource Source)
{
public const string OptionName = "--game-root";
public string Sys4IniPath => Path.Combine(Root, "SYS4INI.BIN");
public string SourceName => Source switch
{
GameRootSource.LaunchArgument => "launch-argument",
GameRootSource.ExecutableDirectory => "executable-directory",
GameRootSource.CurrentDirectory => "current-directory",
_ => throw new InvalidOperationException($"unknown game-root source: {Source}"),
};
public static GameRootSelection Resolve(
IReadOnlyList<string> arguments, string? executablePath, string currentDirectory)
{
ArgumentNullException.ThrowIfNull(arguments);
ArgumentException.ThrowIfNullOrWhiteSpace(currentDirectory);
string cwd = Path.GetFullPath(currentDirectory);
string? overridePath = null;
for (int index = 0; index < arguments.Count; index++)
{
if (arguments[index] != OptionName) continue;
if (index + 1 >= arguments.Count || arguments[index + 1].StartsWith("--", StringComparison.Ordinal))
throw new ArgumentException($"{OptionName} requires a directory path");
overridePath = arguments[++index];
}
if (overridePath != null)
{
string root = Path.GetFullPath(overridePath, cwd);
RequireCatalog(root, $"{OptionName} '{overridePath}'");
return new GameRootSelection(root, GameRootSource.LaunchArgument);
}
var candidates = new List<(string Root, GameRootSource Source)>();
string? executableDirectory = TryGetExecutableDirectory(executablePath, cwd);
if (executableDirectory != null)
candidates.Add((executableDirectory, GameRootSource.ExecutableDirectory));
if (!candidates.Any(candidate => SamePath(candidate.Root, cwd)))
candidates.Add((cwd, GameRootSource.CurrentDirectory));
foreach ((string root, GameRootSource source) in candidates)
if (File.Exists(Path.Combine(root, "SYS4INI.BIN")))
return new GameRootSelection(root, source);
string searched = string.Join(", ", candidates.Select(candidate => $"'{candidate.Root}'"));
throw new ArgumentException(
$"game root not found: SYS4INI.BIN was not present in {searched}; " +
$"pass {OptionName} <directory>");
}
private static string? TryGetExecutableDirectory(string? executablePath, string cwd)
{
if (string.IsNullOrWhiteSpace(executablePath)) return null;
try
{
return Path.GetDirectoryName(Path.GetFullPath(executablePath, cwd));
}
catch (Exception error) when (
error is ArgumentException or NotSupportedException or PathTooLongException)
{
return null;
}
}
private static void RequireCatalog(string root, string description)
{
if (!File.Exists(Path.Combine(root, "SYS4INI.BIN")))
throw new ArgumentException(
$"{description} does not contain SYS4INI.BIN (resolved to '{root}')");
}
private static bool SamePath(string left, string right)
{
StringComparison comparison = OperatingSystem.IsWindows()
? StringComparison.OrdinalIgnoreCase
: StringComparison.Ordinal;
return string.Equals(
Path.TrimEndingDirectorySeparator(left),
Path.TrimEndingDirectorySeparator(right),
comparison);
}
}

View File

@@ -5,7 +5,7 @@ public static class Paths
public static string Workspace => Directory.GetParent(Repo)!.FullName;
public static string Extracted => Path.Combine(Workspace, "extracted");
public static string Data1 => Path.Combine(Extracted, "DATA1");
public static string GameDir => Path.Combine(Workspace, "姫狩りダンジョンマイスター");
public static string GameDir => Path.Combine(Workspace, "Himegari_Game");
public static string Build => Path.Combine(Repo, "build");
public static string OpcodesJson => Path.Combine(Build, "opcodes.json");
public static string AssetSectionsJson => Path.Combine(Build, "asset-sections.json");