feat(startup): add configurable game root
This commit is contained in:
132
engine/Age.Engine.Tests/GameRootSelectionTests.cs
Normal file
132
engine/Age.Engine.Tests/GameRootSelectionTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
99
engine/Age.Engine/Sys4/GameRootSelection.cs
Normal file
99
engine/Age.Engine/Sys4/GameRootSelection.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user