From ba3995bfe41ca18984967ba4a7bdccd0b6ed2d42 Mon Sep 17 00:00:00 2001 From: Jacob Barber Date: Sat, 10 Oct 2026 19:58:26 -0500 Subject: [PATCH 01/58] Add Core-backed server CLI and live resource dashboard --- .github/workflows/ci.yml | 12 ++ README.md | 6 + .../RegistryAndReleaseTests.cs | 138 ++++++++++++++ .../ServerMonitorTests.cs | 101 ++++++++++ .../ServerOptionsTests.cs | 121 ++++++++++++ .../UnchainedLauncher.CLI.Tests.csproj | 18 ++ UnchainedLauncher.CLI/ConsoleDialogue.cs | 26 +++ UnchainedLauncher.CLI/Program.cs | 47 +++++ UnchainedLauncher.CLI/README.md | 180 ++++++++++++++++++ UnchainedLauncher.CLI/RegistrySpec.cs | 44 +++++ UnchainedLauncher.CLI/ServerDashboard.cs | 96 ++++++++++ UnchainedLauncher.CLI/ServerLauncher.cs | 125 ++++++++++++ UnchainedLauncher.CLI/ServerMonitor.cs | 90 +++++++++ UnchainedLauncher.CLI/ServerOptions.cs | 173 +++++++++++++++++ .../UnchainedLauncher.CLI.csproj | 11 ++ .../Utilities/MemoryProgress.cs | 11 +- UnchainedLauncher.sln | 40 ++++ 17 files changed, 1236 insertions(+), 3 deletions(-) create mode 100644 UnchainedLauncher.CLI.Tests/RegistryAndReleaseTests.cs create mode 100644 UnchainedLauncher.CLI.Tests/ServerMonitorTests.cs create mode 100644 UnchainedLauncher.CLI.Tests/ServerOptionsTests.cs create mode 100644 UnchainedLauncher.CLI.Tests/UnchainedLauncher.CLI.Tests.csproj create mode 100644 UnchainedLauncher.CLI/ConsoleDialogue.cs create mode 100644 UnchainedLauncher.CLI/Program.cs create mode 100644 UnchainedLauncher.CLI/README.md create mode 100644 UnchainedLauncher.CLI/RegistrySpec.cs create mode 100644 UnchainedLauncher.CLI/ServerDashboard.cs create mode 100644 UnchainedLauncher.CLI/ServerLauncher.cs create mode 100644 UnchainedLauncher.CLI/ServerMonitor.cs create mode 100644 UnchainedLauncher.CLI/ServerOptions.cs create mode 100644 UnchainedLauncher.CLI/UnchainedLauncher.CLI.csproj diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 10e0ee6a..d7978ac5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -56,6 +56,18 @@ jobs: - name: Run Scanner Configuration Tests run: dotnet test -c ${{ matrix.configuration }} .\UnchainedLauncher.PakScannerApp.Tests --logger:"console;verbosity=detailed" + - name: Run CLI Tests + run: dotnet test -c ${{ matrix.configuration }} .\UnchainedLauncher.CLI.Tests --logger:"console;verbosity=detailed" + + - name: Package CLI binaries + run: dotnet publish UnchainedLauncher.CLI -c ${{ matrix.configuration }} -r win-x64 --self-contained true -o out-cli/ + + - name: Upload CLI Artifacts + uses: actions/upload-artifact@v4 + with: + name: UnchainedLauncher_CLI_${{ matrix.configuration }}_${{ github.sha }} + path: out-cli + - name: Package ${{ matrix.configuration }} binaries run: dotnet publish UnchainedLauncher.GUI -c ${{ matrix.configuration}} -o out/ diff --git a/README.md b/README.md index 2bf4e448..cffbc2b3 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,12 @@ ignored. Only enable publishers you trust: these checks do not make native code Inactive managed DLLs are cached outside `Plugins` and restored when selected. Unknown filenames and unregistered versions stay where they are. +## Server CLI + +`UnchainedLauncher.CLI` is a server-only console application that depends on Core, not WPF. +See [the CLI guide](UnchainedLauncher.CLI/README.md) for building, launch options, +registry formats, and the live server dashboard. + ## License This project is licensed under the terms of the [LICENSE](LICENSE) file. diff --git a/UnchainedLauncher.CLI.Tests/RegistryAndReleaseTests.cs b/UnchainedLauncher.CLI.Tests/RegistryAndReleaseTests.cs new file mode 100644 index 00000000..da44686e --- /dev/null +++ b/UnchainedLauncher.CLI.Tests/RegistryAndReleaseTests.cs @@ -0,0 +1,138 @@ +using UnchainedLauncher.Core.JsonModels.ModMetadata; +using UnchainedLauncher.Core.Services.Mods; +using UnchainedLauncher.Core.Services.Mods.Registry; +using UnchainedLauncher.Core.Services.Processes.Chivalry.LaunchPreparers; +using UnchainedLauncher.Core.Utilities; +using UnchainedLauncher.UnrealModScanner.JsonModels; +using Xunit; + +namespace UnchainedLauncher.CLI.Tests; + +public class RegistryAndReleaseTests : IDisposable { + private readonly string _directory = Path.Combine( + OperatingSystem.IsLinux() ? "/tmp/opencode" : Path.GetTempPath(), "cli-tests-" + Guid.NewGuid().ToString("N")); + + public RegistryAndReleaseTests() => Directory.CreateDirectory(_directory); + public void Dispose() => Directory.Delete(_directory, recursive: true); + + [Fact] + public void ConfigurationSupportsNestedAggregatesAndRelativeLocalPaths() { + var local = Path.Combine(_directory, "local"); + Directory.CreateDirectory(local); + var path = Path.Combine(_directory, "registries.json"); + var metadata = new AggregateModRegistryMetadata([ + new GithubModRegistryMetadata("owner", "repo"), + new AggregateModRegistryMetadata([new LocalModRegistryMetadata("local")]) + ]); + File.WriteAllText(path, JsonHelpers.Serialize(metadata)); + var registry = Assert.IsType(RegistrySpec.Parse("config:" + path).Create()); + // Core flattens nested aggregates. + var localRegistry = Assert.Single(registry.ModRegistries.OfType()); + Assert.Equal(local, localRegistry.RegistryPath); + Assert.Single(registry.ModRegistries.OfType()); + } + + [Fact] + public void MissingLocalRegistryDoesNotCreateADirectory() { + var path = Path.Combine(_directory, "missing"); + Assert.Throws(() => RegistrySpec.Parse("local:" + path).Create()); + Assert.False(Directory.Exists(path)); + } + + [Fact] + public void InvalidConfigurationFailsClearly() { + var path = Path.Combine(_directory, "bad.json"); + File.WriteAllText(path, "{ broken"); + Assert.Throws(() => RegistrySpec.Parse("config:" + path).Create()); + } + + private static Mod Mod(string org, string id, string tag, params Dependency[] dependencies) { + var info = new ModInfo($"https://github.com/{org}/{id}", id, "test", null, null, [], [], dependencies.ToList()); + var release = new Release(tag, "hash", "test.pak", DateTime.UtcNow, info, + new AssetCollections([], [], [], [], []), null); + return new Mod(info, [release]); + } + + private static Mod Common() => Mod("Chiv2-Community", "Unchained-Mods", "v1.0.0"); + + private ModManager Manager(ReleaseCoordinates[] requested, params Mod[] mods) => + new(new AggregateModRegistry(), new PakDir(_directory, []), requested, mods); + + [Fact] + public void ValidRequestedReleaseAndCommonDependencyPass() { + var coordinate = new ReleaseCoordinates("org", "mod", "v1.2.3"); + var manager = Manager([coordinate], Common(), Mod("org", "mod", "v1.2.3")); + ServerLauncher.ValidateReleases(manager, [coordinate]); + } + + [Fact] + public void MissingRequestedReleaseFailsInsteadOfSilentlyIgnoringIt() { + var coordinate = new ReleaseCoordinates("org", "mod", "v1.2.3"); + var manager = Manager([coordinate], Common(), Mod("org", "mod", "v2.0.0")); + Assert.Throws(() => ServerLauncher.ValidateReleases(manager, [coordinate])); + } + + [Fact] + public void MissingCommonReleaseFails() { + Assert.Throws(() => ServerLauncher.ValidateReleases(Manager([]), [])); + } + + [Fact] + public void MissingDependencyFailsInsteadOfSilentlyDroppingIt() { + var coordinate = new ReleaseCoordinates("org", "mod", "v1.0.0"); + var manager = Manager([coordinate], Common(), Mod("org", "mod", "v1.0.0", + new Dependency("https://github.com/org/missing", "*"))); + Assert.Throws(() => ServerLauncher.ValidateReleases(manager, [coordinate])); + } + + [Fact] + public void ConflictBetweenPinnedCommonAndLatestAutomaticCommonFails() { + var common = Common(); + var older = Mod("Chiv2-Community", "Unchained-Mods", "v0.9.0").Releases.Single(); + common.Releases.Add(older); + var coordinate = ReleaseCoordinates.FromRelease(older); + var manager = Manager([coordinate], common); + Assert.Throws(() => ServerLauncher.ValidateReleases(manager, [coordinate])); + } + + [Fact] + public void PinnedReleaseDependencyMustActuallyBeInTheInstallSet() { + var older = Mod("org", "mod", "v1.0.0", new Dependency("https://github.com/org/old-dependency", "*")); + older.Releases.Add(Mod("org", "mod", "v2.0.0").Releases.Single()); + var coordinate = new ReleaseCoordinates("org", "mod", "v1.0.0"); + var manager = Manager([coordinate], Common(), older, Mod("org", "old-dependency", "v1.0.0")); + Assert.Throws(() => ServerLauncher.ValidateReleases(manager, [coordinate])); + } + + [Fact] + public async Task CoreModPreparationAndInventoryRoundTripWorkInAConsoleHost() { + await Task.Run(async () => { + Assert.Null(SynchronizationContext.Current); + var paks = Path.Combine(_directory, "paks"); + Directory.CreateDirectory(paks); + File.WriteAllText(Path.Combine(paks, "pakchunk0-WindowsNoEditor.sig"), "test vanilla signature"); + var source = Path.Combine(_directory, "source.pak"); + File.WriteAllText(source, "test pak payload"); + var registry = new LocalModRegistry(Path.Combine(_directory, "registry")); + var release = Common().Releases.Single() with { + ReleaseHash = FileHelpers.Sha512(source).Match(Right: hash => hash, Left: error => throw new Exception(error.Message)) + }; + await registry.AddRelease(release, source); + var manager = new ModManager(registry, new PakDir(paks, []), []); + Assert.False((await manager.UpdateModsList()).HasErrors); + var options = ServerOptions.Parse(["server", "--chivalry2-path", _directory, "--platform", "Steam"]).ToLaunchOptions(); + var dialogue = new ConsoleDialogue(); + Assert.True((await new Chivalry2ModsInstaller(manager, dialogue).PrepareLaunch(options)).IsSome); + Assert.True((await SigPreparer.Create(manager.PakDir, dialogue).PrepareLaunch(LanguageExt.Unit.Default)).IsSome); + var installed = Assert.Single(((PakDir)manager.PakDir).ManagedPaks); + Assert.Equal("test pak payload", File.ReadAllText(Path.Combine(paks, installed.PakFileName))); + Assert.True(File.Exists(Path.ChangeExtension(Path.Combine(paks, installed.PakFileName), ".sig"))); + + var codec = new ModManagerCodec(registry); + var restored = codec.Deserialize(codec.Serialize(manager)); + Assert.True(restored.Success); + Assert.True((await new Chivalry2ModsInstaller(restored.Result!, dialogue).PrepareLaunch(options)).IsSome); + Assert.Single(((PakDir)restored.Result!.PakDir).ManagedPaks); + }); + } +} diff --git a/UnchainedLauncher.CLI.Tests/ServerMonitorTests.cs b/UnchainedLauncher.CLI.Tests/ServerMonitorTests.cs new file mode 100644 index 00000000..6d5c31da --- /dev/null +++ b/UnchainedLauncher.CLI.Tests/ServerMonitorTests.cs @@ -0,0 +1,101 @@ +using System.Diagnostics; +using UnchainedLauncher.Core.Services.Server.A2S; +using UnchainedLauncher.Core.Utilities; +using Xunit; + +namespace UnchainedLauncher.CLI.Tests; + +public class ServerMonitorTests { + [Fact] + public async Task CoreProgressWorksWithoutAUiSynchronizationContext() { + await Task.Run(() => { + Assert.Null(SynchronizationContext.Current); + var progress = new AccumulatedMemoryProgress(); + var child = new MemoryProgress("install", 25); + progress.AlsoTrack(child); + Assert.Single(progress.Progresses); + Assert.Equal(25, progress.ProgressPercentage); + child.Report(75); + Assert.Equal(75, progress.ProgressPercentage); + }); + } + private static ServerSample Sample(int? players = null, string? map = null, bool available = false) => + new(DateTimeOffset.Now, TimeSpan.FromHours(26), 12.5, 1024, players, players.HasValue ? 64 : null, map, available); + + [Fact] + public void HistoryAndTimelineStayBounded() { + var history = new ServerHistory(); + for (var i = 0; i < 500; i++) history.Add(Sample(i, "Map", true)); + Assert.Equal(ServerHistory.MaxSamples, history.Samples.Count); + Assert.Equal(ServerHistory.MaxEvents, history.Events.Count); + Assert.Equal(499, history.Samples.Last().Players); + } + + [Fact] + public void TimelineRecordsChangesButNotRepeatedIdenticalStatus() { + var history = new ServerHistory(); + history.Add(Sample()); + Assert.Single(history.Events); + history.Add(Sample()); + Assert.Single(history.Events); + history.Add(Sample(0, "Map1", true)); + Assert.Equal(4, history.Events.Count); + history.Add(Sample(0, "Map1", true)); + Assert.Equal(4, history.Events.Count); + history.Add(Sample(2, "Map2", true)); + Assert.Equal(6, history.Events.Count); + history.Add(Sample()); + Assert.Equal(7, history.Events.Count); + Assert.Null(history.Samples.Last().Players); + } + + [Fact] + public void UnknownAndZeroPlayersAreDifferentInDashboard() { + var history = new ServerHistory(); + Assert.Contains(ServerDashboard.FormatLines(42, Sample(), history, 80), l => l.Contains("Players unknown")); + Assert.Contains(ServerDashboard.FormatLines(42, Sample(0, "Map", true), history, 80), l => l.Contains("Players 0/64")); + Assert.Contains(ServerDashboard.FormatLines(42, Sample(), history, 80), l => l.Contains("1d 02:00:00")); + } + + [Fact] + public void SparklinesRespectWidthUnknownSamplesAndScale() { + Assert.Equal(".?@", ServerDashboard.Sparkline([0, null, 100], 10, 100)); + Assert.Equal("?@", ServerDashboard.Sparkline([0, null, 100], 2, 100)); + Assert.Equal("..", ServerDashboard.Sparkline([0, 0], 10)); + Assert.NotEmpty(ServerDashboard.FormatLines(1, Sample(), new ServerHistory(), 1)); + Assert.DoesNotContain('\x1b', ServerDashboard.SafeText("map\x1b[2J\nInjected")); + } + + private sealed class FailingQuery : IA2S { + public Task InfoAsync() => Task.FromException(new TimeoutException()); + } + + private sealed class PendingQuery : IA2S { + public Task InfoAsync() => new TaskCompletionSource().Task; + } + + [Fact] + public async Task QueryFailureStillProducesResourceSamplesWithUnknownPlayers() { + using var process = Process.GetCurrentProcess(); + using var stop = new CancellationTokenSource(TimeSpan.FromMilliseconds(250)); + using var dashboard = new ServerDashboard(false); + var monitor = new ServerMonitor(process, new FailingQuery()); + await monitor.RunAsync(dashboard, stop); + var sample = Assert.Single(monitor.History.Samples); + Assert.False(sample.QueryAvailable); + Assert.Null(sample.Players); + Assert.NotNull(sample.MemoryMiB); + Assert.True(monitor.History.Events.Last().Message == "Stop requested"); + } + + [Fact] + public async Task CancellationInterruptsPendingQuery() { + using var process = Process.GetCurrentProcess(); + using var stop = new CancellationTokenSource(TimeSpan.FromMilliseconds(100)); + using var dashboard = new ServerDashboard(false); + var monitor = new ServerMonitor(process, new PendingQuery()); + await monitor.RunAsync(dashboard, stop).WaitAsync(TimeSpan.FromSeconds(5)); + Assert.Empty(monitor.History.Samples); + Assert.Equal("Stop requested", monitor.History.Events.Last().Message); + } +} diff --git a/UnchainedLauncher.CLI.Tests/ServerOptionsTests.cs b/UnchainedLauncher.CLI.Tests/ServerOptionsTests.cs new file mode 100644 index 00000000..95242581 --- /dev/null +++ b/UnchainedLauncher.CLI.Tests/ServerOptionsTests.cs @@ -0,0 +1,121 @@ +using UnchainedLauncher.Core.Services.Mods.Registry; +using Xunit; + +namespace UnchainedLauncher.CLI.Tests; + +public class ServerOptionsTests { + private static string[] Args(params string[] extra) => + new[] { "server", "--chivalry2-path", ".", "--platform", "Steam" }.Concat(extra).ToArray(); + + [Fact] + public void DefaultsProduceHeadlessServerLaunch() { + var options = ServerOptions.Parse(Args()); + var server = options.ToServerLaunchOptions(); + Assert.True(server.Headless); + Assert.False(server.RegisterWithBackend); + Assert.Equal(7777, server.GamePort); + Assert.Equal(7071, server.QueryPort); + Assert.Equal("FFA_Courtyard", server.Map); + Assert.Equal("TBL -server", options.ToLaunchOptions().LaunchArgs); + Assert.Equal("UnchainedCLI", options.ToLaunchOptions().SavedDirSuffix.IfNone("")); + Assert.Contains(server.ToCLIArgs(), arg => arg.Rendered == "-nullrhi"); + } + + [Theory] + [InlineData("Steam", GamePlatform.Steam)] + [InlineData("steam", GamePlatform.Steam)] + [InlineData("EGS", GamePlatform.EGS)] + public void AcceptsPlatforms(string platform, GamePlatform expected) { + Assert.Equal(expected, ServerOptions.Parse(["server", "--chivalry2-path=.", $"--platform={platform}"]).Platform); + } + + [Fact] + public void RepeatedCoordinatesRegistriesAndActorsAreAccepted() { + var options = ServerOptions.Parse(Args("--mod", "org/one:v1.2.3", "--mod=org/two:release-tag", + "--dll", "org/plugin:v2", "--add-registry", "github:owner/repo", + "--add-registry", "local:my registry", "--add-registry", "config:registries.json", + "--server-mod", "/Game/Mods/Test.Test_C", "--server-mod", "/Game/Mods/Other.Other_C")); + Assert.Equal(2, options.Mods.Count); + Assert.Single(options.Dlls); + Assert.Equal(3, options.Registries.Count); + Assert.Equal(2, options.ToServerLaunchOptions().ServerMods.Count()); + Assert.Equal("v1.2.3", options.Mods[0].Version); + Assert.True(Path.IsPathFullyQualified(options.Registries[1].Location)); + } + + [Fact] + public void GameArgumentsAreEscapedAndNotParsedAsLauncherOptions() { + var options = ServerOptions.Parse(Args("--", "-AUTH_PASSWORD=secret with spaces", "--mod", "game value")); + Assert.Empty(options.Mods); + Assert.Equal(3, options.GameArguments.Count); + Assert.Contains("\"-AUTH_PASSWORD=secret with spaces\"", options.ToLaunchOptions().LaunchArgs); + Assert.Contains("\"game value\"", options.ToLaunchOptions().LaunchArgs); + } + + [Fact] + public void ServerFieldsAreMappedToCoreOptions() { + var options = ServerOptions.Parse(Args("--name", "Test Server", "--description", "Test MOTD", "--map", "TO_Test", + "--password", "secret", "--local-ip", "127.0.0.2", "--game-port", "10001", "--ping-port", "10002", + "--query-port", "10003", "--rcon-port", "10004", "--register", "--no-tui", "--no-plugin-update", + "--allow-unstable-plugin", "--server-browser-backend", "https://example.test", "--saved-dir-suffix", "Test_1")); + var server = options.ToServerLaunchOptions(); + Assert.Equal("Test Server", server.Name); + Assert.Equal("Test MOTD", server.Description); + Assert.Equal("TO_Test", server.Map); + Assert.Equal("secret", server.Password.IfNone("")); + Assert.Equal("127.0.0.2", server.LocalIp.IfNone("")); + Assert.Equal((10001, 10002, 10003, 10004), (server.GamePort, server.BeaconPort, server.QueryPort, server.RconPort)); + Assert.True(server.RegisterWithBackend); + Assert.True(options.NoTui); + Assert.False(options.ToLaunchOptions().CheckForDependencyUpdates); + Assert.True(options.ToLaunchOptions().AllowUnstablePluginReleases); + } + + [Theory] + [InlineData("--mod", "missing-version")] + [InlineData("--mod", "org/mod:")] + [InlineData("--dll", "plugin:v1")] + [InlineData("--platform", "42")] + [InlineData("--game-port", "0")] + [InlineData("--game-port", "65536")] + [InlineData("--query-port", "7777")] + [InlineData("--saved-dir-suffix", "../profile")] + [InlineData("--local-ip", "not-an-ip")] + [InlineData("--server-browser-backend", "file:///server")] + [InlineData("--add-registry", "github:bad")] + [InlineData("--add-registry", "http:unsupported")] + [InlineData("--name", " ")] + public void RejectsInvalidValues(string key, string value) { + Assert.Throws(() => ServerOptions.Parse(Args(key, value))); + } + + [Fact] + public void RejectsMissingRequiredOptionsAndUnknownFlags() { + Assert.Throws(() => ServerOptions.Parse(["server"])); + Assert.Throws(() => ServerOptions.Parse(["server", "--chivalry2-path", "."])); + Assert.Throws(() => ServerOptions.Parse(Args("--typo"))); + Assert.Throws(() => ServerOptions.Parse(Args("--map"))); + Assert.Throws(() => ServerOptions.Parse(Args("--no-tui=true"))); + Assert.Throws(() => ServerOptions.Parse(["client"])); + } + + [Fact] + public void RejectsConflictingModVersionsAndDuplicateScalarOptions() { + Assert.Throws(() => ServerOptions.Parse(Args("--mod", "org/mod:v1", "--mod", "org/mod:v2"))); + Assert.Throws(() => ServerOptions.Parse(Args("--map", "one", "--map", "two"))); + } + + [Fact] + public void HelpDoesNotRequireInstallation() { + Assert.True(ServerOptions.Parse([]).Help); + Assert.True(ServerOptions.Parse(["--help"]).Help); + Assert.True(ServerOptions.Parse(["server", "--help"]).Help); + } + + [Fact] + public void GitHubRegistryCreatesCoreImplementation() { + var registry = Assert.IsType(RegistrySpec.Parse("github:owner/repo").Create()); + Assert.Equal("owner", registry.Organization); + Assert.Equal("repo", registry.RepoName); + } +} diff --git a/UnchainedLauncher.CLI.Tests/UnchainedLauncher.CLI.Tests.csproj b/UnchainedLauncher.CLI.Tests/UnchainedLauncher.CLI.Tests.csproj new file mode 100644 index 00000000..291e4d7a --- /dev/null +++ b/UnchainedLauncher.CLI.Tests/UnchainedLauncher.CLI.Tests.csproj @@ -0,0 +1,18 @@ + + + net10.0 + enable + enable + false + true + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + diff --git a/UnchainedLauncher.CLI/ConsoleDialogue.cs b/UnchainedLauncher.CLI/ConsoleDialogue.cs new file mode 100644 index 00000000..f97036bf --- /dev/null +++ b/UnchainedLauncher.CLI/ConsoleDialogue.cs @@ -0,0 +1,26 @@ +using UnchainedLauncher.Core.Services; +using UnchainedLauncher.Core.Utilities; + +namespace UnchainedLauncher.CLI; + +// The CLI is non-interactive during preparation: repair bad hashes, accept the +// explicitly enabled plugin update policy, but never continue a failed install. +public sealed class ConsoleDialogue : IUserDialogueSpawner { + public void DisplayMessage(string message) => Console.Error.WriteLine(message); + + public UserDialogueChoice DisplayYesNoMessage(string message, string caption) { + DisplayMessage($"{caption}: {message}"); + return caption == "Refresh pak file?" ? UserDialogueChoice.Yes : UserDialogueChoice.No; + } + + public UserDialogueChoice? DisplayUpdateMessage(string titleText, string messageText, + string yesButtonText, string noButtonText, string? cancelButtonText, IEnumerable updates) { + foreach (var update in updates) Console.Error.WriteLine($"Updating {update.Name} to {update.LatestVersion}."); + return UserDialogueChoice.Yes; + } + + public Action DisplayProgress(MemoryProgress progress) { + Console.Error.WriteLine("Preparing mod files..."); + return () => { }; + } +} diff --git a/UnchainedLauncher.CLI/Program.cs b/UnchainedLauncher.CLI/Program.cs new file mode 100644 index 00000000..369f9a51 --- /dev/null +++ b/UnchainedLauncher.CLI/Program.cs @@ -0,0 +1,47 @@ +namespace UnchainedLauncher.CLI; + +public static class Program { + public static async Task Main(string[] args) { + ServerOptions options; + try { + options = ServerOptions.Parse(args); + } + catch (ArgumentException ex) { + Console.Error.WriteLine(ex.Message); + Console.Error.WriteLine("Use 'server --help' for usage."); + return 2; + } + if (options.Help) { Console.WriteLine(ServerOptions.HelpText); return 0; } + if (options.Dlls.Count != 0) { + Console.Error.WriteLine("WARNING: --dll version selection is NOT implemented; ALL DLLs in TBL/Binaries/Win64/Plugins will be injected, regardless of the requested IDs/versions."); + Console.Error.WriteLine("Tracking: https://github.com/Chiv2-Community/UnchainedLauncher/issues/149"); + } + using var stop = new CancellationTokenSource(); + ConsoleCancelEventHandler handler = (_, e) => { e.Cancel = true; stop.Cancel(); }; + Console.CancelKeyPress += handler; + try { + using var process = await ServerLauncher.LaunchAsync(options, stop.Token); + try { + using var dashboard = new ServerDashboard(!options.NoTui); + var monitor = new ServerMonitor(process, ServerLauncher.CreateQuery(options)); + await monitor.RunAsync(dashboard, stop); + return stop.IsCancellationRequested ? 0 : process.ExitCode; + } + finally { + // The CLI owns its server: cancellation and monitor failures must not + // leave an unobserved server running. Kill is Core's existing stop behavior. + if (!process.HasExited) { + Console.Error.WriteLine("Stopping server..."); + process.Kill(entireProcessTree: true); + await process.WaitForExitAsync(); + } + } + } + catch (OperationCanceledException) when (stop.IsCancellationRequested) { return 0; } + catch (Exception ex) { + Console.Error.WriteLine(ex.Message); + return 1; + } + finally { Console.CancelKeyPress -= handler; } + } +} diff --git a/UnchainedLauncher.CLI/README.md b/UnchainedLauncher.CLI/README.md new file mode 100644 index 00000000..8f0777cb --- /dev/null +++ b/UnchainedLauncher.CLI/README.md @@ -0,0 +1,180 @@ +# Server CLI + +This project launches an Unchained Chivalry 2 **server**, without the WPF GUI. +It uses Core for mod installation, plugin updates, signature preparation, +process launching, DLL injection, and A2S queries. Client launching is not included. + +## Build and run + +Install the .NET 10 SDK. From the repository root: + +```powershell +dotnet build UnchainedLauncher.CLI -c Release +dotnet run --project UnchainedLauncher.CLI -c Release -- server --help +``` + +Launch on **Windows** with an existing Chivalry 2 installation: + +```powershell +dotnet run --project UnchainedLauncher.CLI -c Release -- server ` + --chivalry2-path "C:\Games\Chivalry 2" ` + --platform Steam ` + --saved-dir-suffix MyServer ` + --name "My Unchained Server" ` + --map FFA_Courtyard +``` + +The path is the installation root containing `TBL`, not the Win64 directory. +The server is headless (`-nullrhi -unattended -nosound`). The plugin is installed +or updated from the latest stable UnchainedPlugin release by default. Use +`--no-plugin-update` to keep your installed `UnchainedPlugin.dll`; it must already +exist. `--allow-unstable-plugin` permits prerelease plugin updates. + +For deployment without a .NET runtime: + +```powershell +dotnet publish UnchainedLauncher.CLI -c Release -r win-x64 --self-contained true -o out-cli +.\out-cli\UnchainedLauncher.CLI.exe server --help +``` + +Help, parsing, and the unit tests also work on Linux. Actual game launching and +DLL injection require Windows; Wine/Proton launching is not implemented. + +## Registries and mods + +The official `github:Chiv2-Community/C2ModRegistry` registry is always included. +Repeat `--add-registry` to add any combination of Core's supported registry types: + +```text +--add-registry github:owner/repository +--add-registry "local:C:\ModRegistry" +--add-registry "config:C:\Configs\registries.json" +``` + +- `github:` reads the repository's `main/package_db` layout, just like the GUI. +- `local:` points to an existing Core local registry, not a directory of arbitrary paks. +- `config:` reads Core's tagged registry configuration, supporting nested aggregates. + Relative local paths inside it are resolved against the JSON file's directory. +- Relative command-line paths are resolved against the directory where you run the CLI. + +Example aggregate configuration: + +```json +{ + "Kind": "AggregateModRegistry", + "Registries": [ + { "Kind": "GithubModRegistry", "Org": "owner", "RepoName": "repository" }, + { "Kind": "LocalModRegistry", "PakReleaseDir": "../LocalModRegistry" } + ] +} +``` + +Request pak releases using their repository identity and **exact release tag**: + +```text +--mod organization/mod-id:v1.2.3 +--mod organization/another-mod:v2.0.0 +``` + +Display names are not IDs. Core resolves dependencies and includes Unchained-Mods +automatically. Its current dependency policy selects latest stable releases rather +than enforcing dependency version ranges; the CLI rejects missing releases, +missing stable dependencies, and conflicting resolved versions rather than +launching an incomplete set. Registry failures stop launch. +If a pinned older release has different dependencies from the latest release, +Core may resolve the wrong dependency inventory; the CLI detects missing required +dependencies and refuses that launch rather than silently omitting them. + +Installing a pak does **not** automatically activate all of its server actors. +As in the GUI, choose the actors you want to activate, using their blueprint class +paths from release metadata: + +```text +--server-mod /Game/Mods/Example/BP_Example.BP_Example_C +``` + +The CLI stores managed-pak metadata in `\.mod_cache\cli_mod_manager.json`. +On first use it can seed inventory from the existing GUI mod-manager configuration, +but it never overwrites the GUI configuration or reuses its enabled-mod selection. +Core's installation pipeline changes the installed managed pak set and signatures. +Do not prepare a launch concurrently with the GUI or another CLI using the same +installation; multiple running servers sharing that installation must use the +same installed mod set and separate saved suffixes/ports. + +## DLL compatibility behavior + +Every launch recursively injects **all `.dll` files** under +`\TBL\Binaries\Win64\Plugins`, preserving the GUI behavior. + +`--dll organization/dll-id:version` is accepted as a reserved option and emits a +warning. It does **not** download, filter, select, or pin DLL versions yet. +Registry-backed DLL packages and selection are tracked in +[issue #149](https://github.com/Chiv2-Community/UnchainedLauncher/issues/149). +Only put trusted DLLs in this directory. + +## Server options + +Run `server --help` for the full option list. Additional settings include: + +| Option | Default / behavior | +| --- | --- | +| `--saved-dir-suffix` | `UnchainedCLI`; letters, digits, `_`, and `-` only | +| `--name`, `--description`, `--password` | Name, description/MOTD, optional password | +| `--map` | `FFA_Courtyard` | +| `--game-port` | `7777` | +| `--ping-port` | `3075` | +| `--query-port` | `7071` (A2S) | +| `--rcon-port` | `9001` | +| `--local-ip` | Optional local server address; also used for A2S monitoring | +| `--register` | Opt in to server-browser registration; off by default | +| `--server-browser-backend` | `https://servers.polehammer.net` | +| `--no-tui` | Periodic plain-text status instead of the dashboard | + +Ports must be distinct and between 1 and 65535. Configure firewall/port forwarding +for your deployment; this CLI does not change firewall rules. Existing INI files +in the selected saved profile supply settings that are not exposed as flags. + +Pass additional game/plugin arguments after `--`; they are forwarded with escaping: + +```text +UnchainedLauncher.CLI.exe server --chivalry2-path "C:\Games\Chivalry 2" --platform Steam -- -log +``` + +`--platform Steam` creates `steam_appid.txt` with Chivalry 2's app ID if it is missing; +Steam must be available for authentication. `--platform EGS` does not obtain Epic +credentials. Supply the current Epic launch/authentication arguments after `--` +from your existing EGS launch flow. Selecting EGS alone does not authenticate you. +Avoid exposing authentication arguments and passwords in shared scripts or process listings. + +## Live dashboard and shutdown + +While the server is running, the dashboard displays: + +- PID and process uptime. +- CPU use as a percentage of total host logical CPU capacity (0–100%). +- Private process memory in MiB. +- A2S player/max-player counts, current map, and query availability. +- Bounded rolling CPU/memory/player sparklines (up to 120 samples). +- A bounded timeline of launch, map/player changes, and query outages/recovery. + +Metrics are sampled approximately once per second. During startup or query failure, +player count and map are **unknown**, not zero; `?` marks missing chart samples. +The timeline represents observed changes, not per-player join/leave identities or +the full game log. Network throughput and RCON controls are not included yet. + +Press **Q** in the TUI or **Ctrl+C** to stop. Redirected input/output and dumb terminals +automatically use plain-text status (every ten seconds). The CLI owns its server: +stopping it terminates the game process, matching Core's current stop behavior; +there is no detach mode or automatic restart. Natural exits return the server's +exit code, argument errors return `2`, and launch/monitor failures return `1`. + +## Tests + +```text +dotnet test UnchainedLauncher.CLI.Tests -c Debug +dotnet test UnchainedLauncher.CLI.Tests -c Release +``` + +Tests cover parsing, Core option mapping, registry configuration, dependency checks, +bounded history, unavailable telemetry, and monitor cancellation without a game installation. +Windows CI builds, tests, and publishes the CLI separately from the GUI. diff --git a/UnchainedLauncher.CLI/RegistrySpec.cs b/UnchainedLauncher.CLI/RegistrySpec.cs new file mode 100644 index 00000000..5d2b8199 --- /dev/null +++ b/UnchainedLauncher.CLI/RegistrySpec.cs @@ -0,0 +1,44 @@ +using System.Text.RegularExpressions; +using UnchainedLauncher.Core.Services.Mods.Registry; + +namespace UnchainedLauncher.CLI; + +public sealed record RegistrySpec(string Kind, string Location) { + public static RegistrySpec Parse(string value) { + var parts = value.Split(':', 2); + if (parts.Length != 2 || string.IsNullOrWhiteSpace(parts[1])) + throw new ArgumentException("Registry must be github:owner/repo, local:, or config:."); + var kind = parts[0].ToLowerInvariant(); + if (kind == "github") { + if (!Regex.IsMatch(parts[1], @"\A[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+\z")) + throw new ArgumentException("GitHub registry must be github:owner/repo."); + return new RegistrySpec(kind, parts[1]); + } + if (kind is "local" or "config") return new RegistrySpec(kind, Path.GetFullPath(parts[1])); + throw new ArgumentException($"Unsupported registry kind: {kind}"); + } + + public IModRegistry Create() => Kind switch { + "github" => new GithubModRegistry(Location.Split('/')[0], Location.Split('/')[1]), + "local" => Directory.Exists(Location) ? new LocalModRegistry(Location) + : throw new DirectoryNotFoundException($"Local registry directory does not exist: {Location}"), + "config" => LoadConfiguration(), + _ => throw new ArgumentException($"Unsupported registry kind: {Kind}") + }; + + private IModRegistry LoadConfiguration() { + // Core resolves relative local paths against the current directory. Resolve them + // against the configuration's directory instead, without changing process CWD. + var result = Core.Utilities.JsonHelpers.Deserialize(File.ReadAllText(Location)); + if (!result.Success) throw new ArgumentException($"Invalid registry configuration: {Location}", result.Exception); + return ModRegistryCodec.ToClassType(ResolvePaths(result.Result!, Path.GetDirectoryName(Location)!)); + } + + private static ModRegistryMetadata ResolvePaths(ModRegistryMetadata metadata, string directory) => metadata switch { + LocalModRegistryMetadata local => local with { PakReleaseDir = Path.GetFullPath(local.PakReleaseDir, directory) }, + AggregateModRegistryMetadata aggregate => aggregate with { + Registries = aggregate.Registries.Select(r => ResolvePaths(r, directory)).ToArray() + }, + _ => metadata + }; +} diff --git a/UnchainedLauncher.CLI/ServerDashboard.cs b/UnchainedLauncher.CLI/ServerDashboard.cs new file mode 100644 index 00000000..e1661735 --- /dev/null +++ b/UnchainedLauncher.CLI/ServerDashboard.cs @@ -0,0 +1,96 @@ +using System.Globalization; + +namespace UnchainedLauncher.CLI; + +public sealed class ServerDashboard : IDisposable { + private bool _interactive; + private bool? _previousCursorVisible; + private DateTimeOffset _lastPlainOutput = DateTimeOffset.MinValue; + + public ServerDashboard(bool useTui) { + _interactive = useTui && !Console.IsOutputRedirected && !Console.IsInputRedirected + && Environment.GetEnvironmentVariable("TERM") != "dumb"; + if (_interactive) { + try { + if (OperatingSystem.IsWindows()) _previousCursorVisible = Console.CursorVisible; + Console.CursorVisible = false; + Console.Clear(); + } + catch (Exception ex) when (ex is IOException or PlatformNotSupportedException) { _interactive = false; } + } + } + + public bool StopRequested() { + if (!_interactive) return false; + try { + while (Console.KeyAvailable) { + if (Console.ReadKey(intercept: true).Key == ConsoleKey.Q) return true; + } + } + catch (InvalidOperationException) { _interactive = false; } + return false; + } + + public void Render(int pid, ServerSample sample, ServerHistory history) { + if (_interactive) { + try { + var width = Math.Max(1, Console.WindowWidth - 1); + var height = Math.Max(1, Console.WindowHeight - 1); + var lines = FormatLines(pid, sample, history, width); + Console.SetCursorPosition(0, 0); + for (var row = 0; row < height; row++) { + var text = row < lines.Count ? SafeText(lines[row]) : ""; + Console.Write(text[..Math.Min(text.Length, width)].PadRight(width)); + if (row + 1 < height) Console.WriteLine(); + } + return; + } + catch (Exception ex) when (ex is IOException or ArgumentOutOfRangeException or PlatformNotSupportedException) { + _interactive = false; + } + } + if (sample.Time - _lastPlainOutput < TimeSpan.FromSeconds(10)) return; + _lastPlainOutput = sample.Time; + Console.WriteLine(SafeText(string.Join(" | ", FormatLines(pid, sample, history, 80).Take(4)))); + } + + public static IReadOnlyList FormatLines(int pid, ServerSample sample, ServerHistory history, int width) { + string Number(double? value, string suffix) => value.HasValue + ? value.Value.ToString("F1", CultureInfo.InvariantCulture) + suffix : "unknown"; + var players = sample.Players.HasValue ? $"{sample.Players}/{sample.MaxPlayers}" : "unknown"; + var uptime = $"{(int)sample.Uptime.TotalDays}d {sample.Uptime:hh\\:mm\\:ss}"; + var chartWidth = Math.Clamp(width - 22, 1, 80); + var samples = history.Samples.ToArray(); + List lines = [ + $"Unchained Server | PID {pid} | Uptime {uptime} | Q / Ctrl+C: stop", + $"CPU {Number(sample.CpuPercent, "% host")} | Private memory {Number(sample.MemoryMiB, " MiB")}", + $"Players {players} | Map {sample.Map ?? "unknown"}", + $"A2S {(sample.QueryAvailable ? "responding" : "unavailable (starting or query timeout)")} | {sample.Time:HH:mm:ss}", + "", "Recent resource/player history (up to 120 samples; '?' = unknown):", + $"CPU (0-100%) {Sparkline(samples.Select(s => s.CpuPercent), chartWidth, 100)}", + $"Memory {Sparkline(samples.Select(s => s.MemoryMiB), chartWidth)}", + $"Players {Sparkline(samples.Select(s => s.Players.HasValue ? (double?)s.Players.Value : null), chartWidth)}", + "", "Timeline (latest events):" + ]; + lines.AddRange(history.Events.TakeLast(8).Select(e => $" {e.Time:HH:mm:ss} {e.Message}")); + return lines; + } + + public static string Sparkline(IEnumerable values, int width, double? maximum = null) { + const string levels = ".:-=+*#%@"; + var samples = values.TakeLast(Math.Max(1, width)).ToArray(); + var max = maximum ?? samples.Where(v => v.HasValue).Select(v => v!.Value).DefaultIfEmpty(0).Max(); + return new string(samples.Select(value => !value.HasValue ? '?' : levels[max <= 0 ? 0 + : (int)Math.Clamp(value.Value / max * (levels.Length - 1), 0, levels.Length - 1)]).ToArray()); + } + + public static string SafeText(string value) => new(value.Select(c => char.IsControl(c) ? ' ' : c).ToArray()); + + public void Dispose() { + try { + Console.CursorVisible = _previousCursorVisible ?? true; + if (_interactive) Console.WriteLine(); + } + catch (Exception ex) when (ex is IOException or PlatformNotSupportedException) { } + } +} diff --git a/UnchainedLauncher.CLI/ServerLauncher.cs b/UnchainedLauncher.CLI/ServerLauncher.cs new file mode 100644 index 00000000..1b2c08e6 --- /dev/null +++ b/UnchainedLauncher.CLI/ServerLauncher.cs @@ -0,0 +1,125 @@ +using System.Diagnostics; +using System.Net; +using Octokit; +using UnchainedLauncher.Core.Services; +using UnchainedLauncher.Core.Services.Mods; +using UnchainedLauncher.Core.Services.Mods.Registry; +using UnchainedLauncher.Core.Services.Processes; +using UnchainedLauncher.Core.Services.Processes.Chivalry; +using UnchainedLauncher.Core.Services.Processes.Chivalry.LaunchPreparers; +using UnchainedLauncher.Core.Services.Server.A2S; +using UnchainedLauncher.Core.Utilities; + +namespace UnchainedLauncher.CLI; + +public static class ServerLauncher { + public static async Task LaunchAsync(ServerOptions options, CancellationToken cancellationToken) { + if (!OperatingSystem.IsWindows()) throw new PlatformNotSupportedException("Server launching requires Windows (Core's DLL injector uses kernel32.dll)."); + var executable = Path.Combine(options.Chivalry2Path, FilePaths.GameBinPath); + var pakPath = Path.Combine(options.Chivalry2Path, FilePaths.PakDir); + var pluginsPath = Path.Combine(options.Chivalry2Path, FilePaths.PluginDir); + if (!File.Exists(executable)) throw new FileNotFoundException("Chivalry 2 shipping executable was not found. Check --chivalry2-path.", executable); + if (!Directory.Exists(pakPath)) throw new DirectoryNotFoundException($"Chivalry 2 Paks directory was not found: {pakPath}"); + if (!options.UpdatePlugin && !File.Exists(Path.Combine(options.Chivalry2Path, FilePaths.UnchainedPluginPath))) + throw new FileNotFoundException("--no-plugin-update requires an existing UnchainedPlugin.dll in the Plugins directory."); + + var registry = new AggregateModRegistry(new IModRegistry[] { + new GithubModRegistry("Chiv2-Community", "C2ModRegistry") + }.Concat(options.Registries.Select(r => r.Create())).ToArray()); + + // Several existing Core preparers use installation-relative paths. + // This standalone CLI sets CWD only for preparation, then restores it. + var previousDirectory = Directory.GetCurrentDirectory(); + Directory.SetCurrentDirectory(options.Chivalry2Path); + try { + var cachePath = Path.Combine(options.Chivalry2Path, FilePaths.ModCachePath, "cli_mod_manager.json"); + var codec = new ModManagerCodec(registry); + ModManager? cached = null; + var existingCache = File.Exists(cachePath) ? cachePath : FilePaths.ModManagerConfigPath; + if (File.Exists(existingCache)) { + var decoded = codec.Deserialize(File.ReadAllText(existingCache)); + if (!decoded.Success) throw new InvalidDataException($"Cannot read mod cache: {existingCache}", decoded.Exception); + cached = decoded.Result; + } + var manager = new ModManager(registry, + new PakDir(pakPath, (cached?.PakDir as PakDir)?.ManagedPaks ?? []), options.Mods.Distinct()); + Console.Error.WriteLine("Loading mod registries..."); + var fetched = await manager.UpdateModsList(); + cancellationToken.ThrowIfCancellationRequested(); + if (fetched.HasErrors) { + foreach (var error in fetched.Errors) Console.Error.WriteLine(error.Message); + throw new InvalidOperationException("Registry loading failed; refusing to launch with incomplete metadata."); + } + ValidateReleases(manager, options.Mods); + + if (options.Platform == GamePlatform.Steam) { + var appIdPath = Path.Combine(options.Chivalry2Path, FilePaths.SteamAppIdPath); + if (!File.Exists(appIdPath)) File.WriteAllText(appIdPath, "1824220"); + } + var dialogue = new ConsoleDialogue(); + var pluginUpdater = new UnchainedPluginUpdateChecker( + new GithubReleaseLocator(new GitHubClient(new ProductHeaderValue("UnchainedLauncher.CLI")), + "Chiv2-Community", "UnchainedPlugin"), new FileInfoVersionExtractor(), dialogue); + var modInstaller = new Chivalry2ModsInstaller(manager, dialogue); + var signing = SigPreparer.Create(manager.PakDir, dialogue); + // Call preparers sequentially so each receives the previous result. + // Always save the managed-pak inventory, even after a partial installation. + var preparer = Chivalry2LaunchPreparer.Create(async launch => { + cancellationToken.ThrowIfCancellationRequested(); + var updated = await pluginUpdater.PrepareLaunch(launch); + cancellationToken.ThrowIfCancellationRequested(); + if (updated.IsNone) return LanguageExt.Option.None; + if (!File.Exists(Path.Combine(pluginsPath, "UnchainedPlugin.dll"))) + throw new FileNotFoundException("UnchainedPlugin.dll is required to host a server."); + try { + updated = await modInstaller.PrepareLaunch(updated.IfNone(launch)); + } + finally { + codec.SerializeFile(cachePath, manager); + } + cancellationToken.ThrowIfCancellationRequested(); + if (updated.IsNone || (await signing.PrepareLaunch(LanguageExt.Unit.Default)).IsNone) + return LanguageExt.Option.None; + cancellationToken.ThrowIfCancellationRequested(); + return updated; + }); + var launcher = new UnchainedChivalry2Launcher(preparer, new ProcessLauncher(executable), + options.Chivalry2Path, new DllInjector(pluginsPath)); + var result = await launcher.Launch(options.ToLaunchOptions()); + return result.Match( + Right: process => process, + // Do not print LaunchFailed.Message: it contains raw password/auth arguments. + Left: failure => throw new InvalidOperationException("Server launch or DLL injection failed. Check the installation, plugin, and mod files.", failure)); + } + finally { + Directory.SetCurrentDirectory(previousDirectory); + } + } + + public static void ValidateReleases(IModManager manager, IEnumerable requested) { + foreach (var coordinate in requested) { + if (manager.GetRelease(coordinate).IsNone) + throw new ArgumentException($"Requested mod release was not found: {coordinate.Org}/{coordinate.ModuleName}:{coordinate.Version}"); + } + if (manager.GetLatestRelease(CommonMods.UnchainedMods).IsNone) + throw new InvalidOperationException("The registry must provide a stable Chiv2-Community/Unchained-Mods release."); + var releases = manager.GetEnabledAndDependencyReleases().ToArray(); + if (releases.GroupBy(r => (r.Info.Organization, r.Info.RepoName)).Any(g => g.Select(r => r.Tag).Distinct().Count() > 1)) + throw new ArgumentException("Dependency resolution selected conflicting versions of a mod. Core currently selects the latest stable dependencies, including Unchained-Mods."); + foreach (var release in releases) { + foreach (var dependency in release.Info.Dependencies) { + if (manager.GetLatestRelease(ModIdentifier.FromDependency(dependency)).IsNone) + throw new InvalidOperationException($"Missing stable dependency: {dependency.Organization}/{dependency.RepoName}"); + if (!releases.Any(r => r.Info.Organization == dependency.Organization && r.Info.RepoName == dependency.RepoName)) + throw new InvalidOperationException($"Core did not resolve dependency {dependency.Organization}/{dependency.RepoName} for the requested release. Its current resolver uses latest-release dependency metadata."); + } + } + } + + public static IA2S CreateQuery(ServerOptions options) { + var address = options.LocalIp is null ? IPAddress.Loopback : IPAddress.Parse(options.LocalIp); + if (address.Equals(IPAddress.Any)) address = IPAddress.Loopback; + if (address.Equals(IPAddress.IPv6Any)) address = IPAddress.IPv6Loopback; + return new A2S(new IPEndPoint(address, options.QueryPort)); + } +} diff --git a/UnchainedLauncher.CLI/ServerMonitor.cs b/UnchainedLauncher.CLI/ServerMonitor.cs new file mode 100644 index 00000000..69948478 --- /dev/null +++ b/UnchainedLauncher.CLI/ServerMonitor.cs @@ -0,0 +1,90 @@ +using System.ComponentModel; +using System.Diagnostics; +using UnchainedLauncher.Core.Services.Server.A2S; + +namespace UnchainedLauncher.CLI; + +public record ServerSample(DateTimeOffset Time, TimeSpan Uptime, double? CpuPercent, + double? MemoryMiB, int? Players, int? MaxPlayers, string? Map, bool QueryAvailable); +public record TimelineEvent(DateTimeOffset Time, string Message); + +public sealed class ServerHistory { + public const int MaxSamples = 120; + public const int MaxEvents = 50; + private readonly Queue _samples = new(); + private readonly Queue _events = new(); + public IReadOnlyCollection Samples => _samples; + public IReadOnlyCollection Events => _events; + + public void AddEvent(DateTimeOffset time, string message) { + _events.Enqueue(new TimelineEvent(time, message)); + while (_events.Count > MaxEvents) _events.Dequeue(); + } + + public void Add(ServerSample sample) { + var previous = _samples.LastOrDefault(); + if (previous is null || previous.QueryAvailable != sample.QueryAvailable) + AddEvent(sample.Time, sample.QueryAvailable ? "A2S responding" : "A2S unavailable; waiting for server query response"); + if (sample.QueryAvailable) { + if (previous?.Map != sample.Map) AddEvent(sample.Time, $"Map: {sample.Map}"); + if (previous?.Players != sample.Players) AddEvent(sample.Time, $"Players: {sample.Players}/{sample.MaxPlayers}"); + } + _samples.Enqueue(sample); + while (_samples.Count > MaxSamples) _samples.Dequeue(); + } +} + +public sealed class ServerMonitor(Process process, IA2S query) { + public ServerHistory History { get; } = new(); + + public async Task RunAsync(ServerDashboard dashboard, CancellationTokenSource stop) { + var started = new DateTimeOffset(process.StartTime); + var previousTime = Stopwatch.GetTimestamp(); + var previousCpu = process.TotalProcessorTime; + History.AddEvent(DateTimeOffset.Now, $"Server started (PID {process.Id})"); + using var timer = new PeriodicTimer(TimeSpan.FromSeconds(1)); + try { + while (!stop.IsCancellationRequested && !process.HasExited) { + if (dashboard.StopRequested()) { stop.Cancel(); break; } + A2SInfo? info = null; + try { + info = await query.InfoAsync().WaitAsync(stop.Token); + } + catch (OperationCanceledException) when (stop.IsCancellationRequested) { throw; } + catch (Exception) { + // Missing/invalid A2S replies are normal during startup and outages. + // Never convert an unavailable query into a zero player count. + } + if (process.HasExited) break; + process.Refresh(); + double? cpu = null; + double? memory = null; + try { + var nowTicks = Stopwatch.GetTimestamp(); + var elapsed = Stopwatch.GetElapsedTime(previousTime, nowTicks).TotalSeconds; + var totalCpu = process.TotalProcessorTime; + cpu = Math.Clamp((totalCpu - previousCpu).TotalSeconds / elapsed / System.Environment.ProcessorCount * 100, 0, 100); + memory = process.PrivateMemorySize64 / 1048576d; + previousCpu = totalCpu; + previousTime = nowTicks; + } + catch (Exception ex) when (ex is InvalidOperationException or Win32Exception) { + if (process.HasExited) break; + } + var now = DateTimeOffset.Now; + var sample = new ServerSample(now, now - started, cpu, memory, + info?.Players, info?.MaxPlayers, info?.Map, info is not null); + History.Add(sample); + dashboard.Render(process.Id, sample, History); + if (!await timer.WaitForNextTickAsync(stop.Token)) break; + } + } + catch (OperationCanceledException) when (stop.IsCancellationRequested) { } + finally { + var message = stop.IsCancellationRequested ? "Stop requested" : process.HasExited + ? $"Server exited (code {process.ExitCode})" : "Monitoring ended"; + History.AddEvent(DateTimeOffset.Now, message); + Console.Error.WriteLine(message); + } + } +} diff --git a/UnchainedLauncher.CLI/ServerOptions.cs b/UnchainedLauncher.CLI/ServerOptions.cs new file mode 100644 index 00000000..77d5c3be --- /dev/null +++ b/UnchainedLauncher.CLI/ServerOptions.cs @@ -0,0 +1,173 @@ +using System.Net; +using System.Text.RegularExpressions; +using UnchainedLauncher.Core.Services.Mods.Registry; +using UnchainedLauncher.Core.Services.Processes.Chivalry; +using UnchainedLauncher.Core.Utilities; +using static LanguageExt.Prelude; + +namespace UnchainedLauncher.CLI; + +public enum GamePlatform { Steam, EGS } + +public sealed class ServerOptions { + public string Chivalry2Path { get; private set; } = ""; + public string SavedDirSuffix { get; private set; } = "UnchainedCLI"; + public GamePlatform Platform { get; private set; } + public string Name { get; private set; } = "Unchained Server"; + public string Description { get; private set; } = ""; + public string Map { get; private set; } = "FFA_Courtyard"; + public string? Password { get; private set; } + public string? LocalIp { get; private set; } + public string Backend { get; private set; } = "https://servers.polehammer.net"; + public int GamePort { get; private set; } = 7777; + public int PingPort { get; private set; } = 3075; + public int QueryPort { get; private set; } = 7071; + public int RconPort { get; private set; } = 9001; + public bool Register { get; private set; } + public bool NoTui { get; private set; } + public bool UpdatePlugin { get; private set; } = true; + public bool AllowUnstablePlugin { get; private set; } + public bool Help { get; private set; } + public List Registries { get; } = []; + public List Mods { get; } = []; + public List Dlls { get; } = []; + public List ServerMods { get; } = []; + public List GameArguments { get; } = []; + + public static ServerOptions Parse(string[] args) { + var options = new ServerOptions(); + if (args.Length == 0 || args[0] is "--help" or "-h") { + options.Help = true; + return options; + } + if (args[0] != "server") throw new ArgumentException("Expected the 'server' subcommand."); + var seen = new HashSet(); + var platformProvided = false; + for (var i = 1; i < args.Length; i++) { + if (args[i] == "--") { + options.GameArguments.AddRange(args.Skip(i + 1)); + break; + } + var pair = args[i].Split('=', 2); + var key = pair[0]; + if (key is "--help" or "-h") { options.Help = true; return options; } + if (key is not ("--add-registry" or "--mod" or "--dll" or "--server-mod") && !seen.Add(key)) + throw new ArgumentException($"Duplicate option: {key}"); + string Value() { + var value = pair.Length == 2 ? pair[1] + : i + 1 < args.Length && !args[i + 1].StartsWith("--") ? args[++i] + : throw new ArgumentException($"Missing value for {key}."); + if (string.IsNullOrWhiteSpace(value)) throw new ArgumentException($"Empty value for {key}."); + return value; + } + void Flag() { + if (pair.Length != 1) throw new ArgumentException($"{key} does not take a value."); + } + switch (key) { + case "--chivalry2-path": options.Chivalry2Path = Path.GetFullPath(Value()); break; + case "--saved-dir-suffix": options.SavedDirSuffix = Value(); break; + case "--platform": + var platform = Value(); + options.Platform = platform.ToLowerInvariant() switch { + "steam" => GamePlatform.Steam, + "egs" => GamePlatform.EGS, + _ => throw new ArgumentException("--platform must be Steam or EGS.") + }; + platformProvided = true; + break; + case "--add-registry": options.Registries.Add(RegistrySpec.Parse(Value())); break; + case "--mod": options.Mods.Add(ParseCoordinates(Value())); break; + case "--dll": options.Dlls.Add(ParseCoordinates(Value())); break; + case "--server-mod": options.ServerMods.Add(Value()); break; + case "--name": options.Name = Value(); break; + case "--description": options.Description = Value(); break; + case "--map": options.Map = Value(); break; + case "--password": options.Password = Value(); break; + case "--local-ip": + options.LocalIp = Value(); + if (!IPAddress.TryParse(options.LocalIp, out _)) throw new ArgumentException("--local-ip must be an IP address."); + break; + case "--server-browser-backend": + options.Backend = Value(); + if (!Uri.TryCreate(options.Backend, UriKind.Absolute, out var uri) || uri.Scheme is not ("http" or "https")) + throw new ArgumentException("--server-browser-backend must be an HTTP(S) URL."); + break; + case "--game-port": options.GamePort = ParsePort(Value(), key); break; + case "--ping-port": options.PingPort = ParsePort(Value(), key); break; + case "--query-port": options.QueryPort = ParsePort(Value(), key); break; + case "--rcon-port": options.RconPort = ParsePort(Value(), key); break; + case "--register": Flag(); options.Register = true; break; + case "--no-tui": Flag(); options.NoTui = true; break; + case "--no-plugin-update": Flag(); options.UpdatePlugin = false; break; + case "--allow-unstable-plugin": Flag(); options.AllowUnstablePlugin = true; break; + default: throw new ArgumentException($"Unknown option: {key}. Pass game arguments after --."); + } + } + if (options.Chivalry2Path.Length == 0) throw new ArgumentException("--chivalry2-path is required."); + if (!platformProvided) throw new ArgumentException("--platform is required (Steam or EGS)."); + if (!Regex.IsMatch(options.SavedDirSuffix, @"\A[A-Za-z0-9_-]+\z")) + throw new ArgumentException("--saved-dir-suffix may contain only letters, digits, underscores, and hyphens."); + if (new[] { options.GamePort, options.PingPort, options.QueryPort, options.RconPort }.Distinct().Count() != 4) + throw new ArgumentException("Game, ping, query, and RCON ports must be distinct."); + if (options.Mods.GroupBy(m => (m.Org, m.ModuleName)).Any(g => g.Select(m => m.Version).Distinct().Count() > 1)) + throw new ArgumentException("Only one version of each mod may be selected."); + return options; + } + + private static int ParsePort(string value, string option) => + int.TryParse(value, out var port) && port is >= 1 and <= 65535 ? port + : throw new ArgumentException($"{option} must be a port between 1 and 65535."); + + public static ReleaseCoordinates ParseCoordinates(string value) { + var match = Regex.Match(value, @"\A([A-Za-z0-9_.-]+)/([A-Za-z0-9_.-]+):([^\s:/\\]+)\z"); + if (!match.Success) throw new ArgumentException($"Invalid coordinate '{value}'; expected organization/id:version (exact release tag)."); + return new ReleaseCoordinates(match.Groups[1].Value, match.Groups[2].Value, match.Groups[3].Value); + } + + public ServerLaunchOptions ToServerLaunchOptions() => new( + Headless: true, Name, Description, RegisterWithBackend: Register, + Password: Optional(Password), Map, GamePort, BeaconPort: PingPort, QueryPort, RconPort, + FFATimeLimit: null, FFAScoreLimit: null, TDMTimeLimit: null, TDMTicketCount: null, + PlayerBotCount: null, WarmupTime: null, LocalIp: Optional(LocalIp), + ServerMods: ServerMods.Distinct(), DiscordIntegration: None, + DesyncPatch: false, UseBackendBanlist: false, CensorMode: CensorArg.None); + + public LaunchOptions ToLaunchOptions() => new( + EnabledReleases: Mods.Distinct(), ServerBrowserBackend: Some(Backend), + LaunchArgs: string.Join(" ", new[] { "TBL", "-server" }.Concat(GameArguments.Select(ArgumentEscaper.Escape))), + CheckForDependencyUpdates: UpdatePlugin, AllowUnstablePluginReleases: AllowUnstablePlugin, + SavedDirSuffix: Some(SavedDirSuffix), ServerLaunchOptions: Some(ToServerLaunchOptions())); + + public const string HelpText = """ + UnchainedLauncher.CLI server --chivalry2-path --platform Steam|EGS [options] [-- game-args] + + Server-only launcher; Windows is required for launching and DLL injection. + --saved-dir-suffix Saved profile suffix (default: UnchainedCLI) + --add-registry github:owner/repo Add a GitHub registry (repeatable) + --add-registry local: Add a local registry (repeatable) + --add-registry config: Add a Core registry configuration, including aggregates + --mod Install a mod at its exact release tag (repeatable) + --dll RESERVED: warns; still injects ALL Plugins DLLs + --server-mod Activate a server mod actor (repeatable) + --name Server name (default: Unchained Server) + --description Description / MOTD + --map Initial map (default: FFA_Courtyard) + --password Server password + --game-port Default: 7777 + --ping-port Default: 3075 + --query-port A2S query port (default: 7071) + --rcon-port Default: 9001 + --local-ip
Local server address + --register List server in the server browser (default: off) + --server-browser-backend Default: https://servers.polehammer.net + --no-plugin-update Use the existing UnchainedPlugin.dll + --allow-unstable-plugin Allow prerelease plugin updates + --no-tui Print periodic status instead of the live dashboard + --help Show help + + The official GitHub registry is always included; additional registries are additive. + Q or Ctrl+C stops the server. Redirected output automatically disables the TUI. + EGS authentication arguments must be supplied after --; this does not log in to Epic. + DLL package selection is tracked in https://github.com/Chiv2-Community/UnchainedLauncher/issues/149 + """; +} diff --git a/UnchainedLauncher.CLI/UnchainedLauncher.CLI.csproj b/UnchainedLauncher.CLI/UnchainedLauncher.CLI.csproj new file mode 100644 index 00000000..6e7d88c7 --- /dev/null +++ b/UnchainedLauncher.CLI/UnchainedLauncher.CLI.csproj @@ -0,0 +1,11 @@ + + + Exe + net10.0 + enable + enable + + + + + diff --git a/UnchainedLauncher.Core/Utilities/MemoryProgress.cs b/UnchainedLauncher.Core/Utilities/MemoryProgress.cs index 69e8c626..bad04da9 100644 --- a/UnchainedLauncher.Core/Utilities/MemoryProgress.cs +++ b/UnchainedLauncher.Core/Utilities/MemoryProgress.cs @@ -39,10 +39,15 @@ public AccumulatedMemoryProgress(IEnumerable? memoryProgress = n } public void AlsoTrack(MemoryProgress memoryProgress) { - _synchronizationContext.Post(_ => { + void AddProgress() { Progresses.Add(memoryProgress); ProgressPercentage = CalcAggregatePercentage(); - }, null); + } + + // Console hosts have no UI SynchronizationContext. Keep the UI's + // existing dispatch behavior, but track synchronously when headless. + if (_synchronizationContext is null) AddProgress(); + else _synchronizationContext.Post(_ => AddProgress(), null); BindTo(memoryProgress); } @@ -57,4 +62,4 @@ private void BindTo(MemoryProgress mp) { mp.PropertyChanged += OnProgressPropertyChanged; } } -} \ No newline at end of file +} diff --git a/UnchainedLauncher.sln b/UnchainedLauncher.sln index 6ccf74bc..70d238d4 100644 --- a/UnchainedLauncher.sln +++ b/UnchainedLauncher.sln @@ -25,6 +25,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "UnchainedLauncher.UnrealMod EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "UnchainedLauncher.PakScannerApp.Tests", "UnchainedLauncher.PakScannerApp.Tests\UnchainedLauncher.PakScannerApp.Tests.csproj", "{6F9E5F8B-AB88-4A12-AAB8-ABAF5E4BBABA}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "UnchainedLauncher.CLI", "UnchainedLauncher.CLI\UnchainedLauncher.CLI.csproj", "{08148C0B-891C-4573-B825-9682EC76B8D9}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "UnchainedLauncher.CLI.Tests", "UnchainedLauncher.CLI.Tests\UnchainedLauncher.CLI.Tests.csproj", "{B3A5C9DE-6A90-49F1-95EC-908973A1A21F}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -38,6 +42,42 @@ Global Release|x86 = Release|x86 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution + {08148C0B-891C-4573-B825-9682EC76B8D9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {08148C0B-891C-4573-B825-9682EC76B8D9}.Debug|Any CPU.Build.0 = Debug|Any CPU + {08148C0B-891C-4573-B825-9682EC76B8D9}.Debug|x64.ActiveCfg = Debug|Any CPU + {08148C0B-891C-4573-B825-9682EC76B8D9}.Debug|x64.Build.0 = Debug|Any CPU + {08148C0B-891C-4573-B825-9682EC76B8D9}.Debug|x86.ActiveCfg = Debug|Any CPU + {08148C0B-891C-4573-B825-9682EC76B8D9}.Debug|x86.Build.0 = Debug|Any CPU + {08148C0B-891C-4573-B825-9682EC76B8D9}.Debug-FakeChivalryLaunch|Any CPU.ActiveCfg = Debug|Any CPU + {08148C0B-891C-4573-B825-9682EC76B8D9}.Debug-FakeChivalryLaunch|Any CPU.Build.0 = Debug|Any CPU + {08148C0B-891C-4573-B825-9682EC76B8D9}.Debug-FakeChivalryLaunch|x64.ActiveCfg = Debug|Any CPU + {08148C0B-891C-4573-B825-9682EC76B8D9}.Debug-FakeChivalryLaunch|x64.Build.0 = Debug|Any CPU + {08148C0B-891C-4573-B825-9682EC76B8D9}.Debug-FakeChivalryLaunch|x86.ActiveCfg = Debug|Any CPU + {08148C0B-891C-4573-B825-9682EC76B8D9}.Debug-FakeChivalryLaunch|x86.Build.0 = Debug|Any CPU + {08148C0B-891C-4573-B825-9682EC76B8D9}.Release|Any CPU.ActiveCfg = Release|Any CPU + {08148C0B-891C-4573-B825-9682EC76B8D9}.Release|Any CPU.Build.0 = Release|Any CPU + {08148C0B-891C-4573-B825-9682EC76B8D9}.Release|x64.ActiveCfg = Release|Any CPU + {08148C0B-891C-4573-B825-9682EC76B8D9}.Release|x64.Build.0 = Release|Any CPU + {08148C0B-891C-4573-B825-9682EC76B8D9}.Release|x86.ActiveCfg = Release|Any CPU + {08148C0B-891C-4573-B825-9682EC76B8D9}.Release|x86.Build.0 = Release|Any CPU + {B3A5C9DE-6A90-49F1-95EC-908973A1A21F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B3A5C9DE-6A90-49F1-95EC-908973A1A21F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B3A5C9DE-6A90-49F1-95EC-908973A1A21F}.Debug|x64.ActiveCfg = Debug|Any CPU + {B3A5C9DE-6A90-49F1-95EC-908973A1A21F}.Debug|x64.Build.0 = Debug|Any CPU + {B3A5C9DE-6A90-49F1-95EC-908973A1A21F}.Debug|x86.ActiveCfg = Debug|Any CPU + {B3A5C9DE-6A90-49F1-95EC-908973A1A21F}.Debug|x86.Build.0 = Debug|Any CPU + {B3A5C9DE-6A90-49F1-95EC-908973A1A21F}.Debug-FakeChivalryLaunch|Any CPU.ActiveCfg = Debug|Any CPU + {B3A5C9DE-6A90-49F1-95EC-908973A1A21F}.Debug-FakeChivalryLaunch|Any CPU.Build.0 = Debug|Any CPU + {B3A5C9DE-6A90-49F1-95EC-908973A1A21F}.Debug-FakeChivalryLaunch|x64.ActiveCfg = Debug|Any CPU + {B3A5C9DE-6A90-49F1-95EC-908973A1A21F}.Debug-FakeChivalryLaunch|x64.Build.0 = Debug|Any CPU + {B3A5C9DE-6A90-49F1-95EC-908973A1A21F}.Debug-FakeChivalryLaunch|x86.ActiveCfg = Debug|Any CPU + {B3A5C9DE-6A90-49F1-95EC-908973A1A21F}.Debug-FakeChivalryLaunch|x86.Build.0 = Debug|Any CPU + {B3A5C9DE-6A90-49F1-95EC-908973A1A21F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B3A5C9DE-6A90-49F1-95EC-908973A1A21F}.Release|Any CPU.Build.0 = Release|Any CPU + {B3A5C9DE-6A90-49F1-95EC-908973A1A21F}.Release|x64.ActiveCfg = Release|Any CPU + {B3A5C9DE-6A90-49F1-95EC-908973A1A21F}.Release|x64.Build.0 = Release|Any CPU + {B3A5C9DE-6A90-49F1-95EC-908973A1A21F}.Release|x86.ActiveCfg = Release|Any CPU + {B3A5C9DE-6A90-49F1-95EC-908973A1A21F}.Release|x86.Build.0 = Release|Any CPU {0C235765-7B68-48BB-A3E5-87E12C512540}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {0C235765-7B68-48BB-A3E5-87E12C512540}.Debug|Any CPU.Build.0 = Debug|Any CPU {0C235765-7B68-48BB-A3E5-87E12C512540}.Debug|x64.ActiveCfg = Debug|Any CPU From db7fae1f50b64d2693a61d1c60715124a74e5300 Mon Sep 17 00:00:00 2001 From: Jacoby6000 <2672832+Jacoby6000@users.noreply.github.com> Date: Sun, 11 Oct 2026 01:00:37 +0000 Subject: [PATCH 02/58] [GHA] Format Source --- UnchainedLauncher.CLI.Tests/RegistryAndReleaseTests.cs | 2 +- UnchainedLauncher.CLI.Tests/ServerMonitorTests.cs | 2 +- UnchainedLauncher.CLI.Tests/ServerOptionsTests.cs | 2 +- UnchainedLauncher.CLI/ConsoleDialogue.cs | 2 +- UnchainedLauncher.CLI/Program.cs | 2 +- UnchainedLauncher.CLI/RegistrySpec.cs | 2 +- UnchainedLauncher.CLI/ServerDashboard.cs | 2 +- UnchainedLauncher.CLI/ServerLauncher.cs | 4 ++-- UnchainedLauncher.CLI/ServerMonitor.cs | 2 +- UnchainedLauncher.CLI/ServerOptions.cs | 2 +- UnchainedLauncher.Core/Utilities/MemoryProgress.cs | 2 +- 11 files changed, 12 insertions(+), 12 deletions(-) diff --git a/UnchainedLauncher.CLI.Tests/RegistryAndReleaseTests.cs b/UnchainedLauncher.CLI.Tests/RegistryAndReleaseTests.cs index da44686e..a07fb713 100644 --- a/UnchainedLauncher.CLI.Tests/RegistryAndReleaseTests.cs +++ b/UnchainedLauncher.CLI.Tests/RegistryAndReleaseTests.cs @@ -135,4 +135,4 @@ await Task.Run(async () => { Assert.Single(((PakDir)restored.Result!.PakDir).ManagedPaks); }); } -} +} \ No newline at end of file diff --git a/UnchainedLauncher.CLI.Tests/ServerMonitorTests.cs b/UnchainedLauncher.CLI.Tests/ServerMonitorTests.cs index 6d5c31da..f802abd2 100644 --- a/UnchainedLauncher.CLI.Tests/ServerMonitorTests.cs +++ b/UnchainedLauncher.CLI.Tests/ServerMonitorTests.cs @@ -98,4 +98,4 @@ public async Task CancellationInterruptsPendingQuery() { Assert.Empty(monitor.History.Samples); Assert.Equal("Stop requested", monitor.History.Events.Last().Message); } -} +} \ No newline at end of file diff --git a/UnchainedLauncher.CLI.Tests/ServerOptionsTests.cs b/UnchainedLauncher.CLI.Tests/ServerOptionsTests.cs index 95242581..fd19f1c0 100644 --- a/UnchainedLauncher.CLI.Tests/ServerOptionsTests.cs +++ b/UnchainedLauncher.CLI.Tests/ServerOptionsTests.cs @@ -118,4 +118,4 @@ public void GitHubRegistryCreatesCoreImplementation() { Assert.Equal("owner", registry.Organization); Assert.Equal("repo", registry.RepoName); } -} +} \ No newline at end of file diff --git a/UnchainedLauncher.CLI/ConsoleDialogue.cs b/UnchainedLauncher.CLI/ConsoleDialogue.cs index f97036bf..7b216fc5 100644 --- a/UnchainedLauncher.CLI/ConsoleDialogue.cs +++ b/UnchainedLauncher.CLI/ConsoleDialogue.cs @@ -23,4 +23,4 @@ public Action DisplayProgress(MemoryProgress progress) { Console.Error.WriteLine("Preparing mod files..."); return () => { }; } -} +} \ No newline at end of file diff --git a/UnchainedLauncher.CLI/Program.cs b/UnchainedLauncher.CLI/Program.cs index 369f9a51..33d6783b 100644 --- a/UnchainedLauncher.CLI/Program.cs +++ b/UnchainedLauncher.CLI/Program.cs @@ -44,4 +44,4 @@ public static async Task Main(string[] args) { } finally { Console.CancelKeyPress -= handler; } } -} +} \ No newline at end of file diff --git a/UnchainedLauncher.CLI/RegistrySpec.cs b/UnchainedLauncher.CLI/RegistrySpec.cs index 5d2b8199..1dd49bf9 100644 --- a/UnchainedLauncher.CLI/RegistrySpec.cs +++ b/UnchainedLauncher.CLI/RegistrySpec.cs @@ -41,4 +41,4 @@ private IModRegistry LoadConfiguration() { }, _ => metadata }; -} +} \ No newline at end of file diff --git a/UnchainedLauncher.CLI/ServerDashboard.cs b/UnchainedLauncher.CLI/ServerDashboard.cs index e1661735..95fc26c5 100644 --- a/UnchainedLauncher.CLI/ServerDashboard.cs +++ b/UnchainedLauncher.CLI/ServerDashboard.cs @@ -93,4 +93,4 @@ public void Dispose() { } catch (Exception ex) when (ex is IOException or PlatformNotSupportedException) { } } -} +} \ No newline at end of file diff --git a/UnchainedLauncher.CLI/ServerLauncher.cs b/UnchainedLauncher.CLI/ServerLauncher.cs index 1b2c08e6..71dea19d 100644 --- a/UnchainedLauncher.CLI/ServerLauncher.cs +++ b/UnchainedLauncher.CLI/ServerLauncher.cs @@ -1,6 +1,6 @@ +using Octokit; using System.Diagnostics; using System.Net; -using Octokit; using UnchainedLauncher.Core.Services; using UnchainedLauncher.Core.Services.Mods; using UnchainedLauncher.Core.Services.Mods.Registry; @@ -122,4 +122,4 @@ public static IA2S CreateQuery(ServerOptions options) { if (address.Equals(IPAddress.IPv6Any)) address = IPAddress.IPv6Loopback; return new A2S(new IPEndPoint(address, options.QueryPort)); } -} +} \ No newline at end of file diff --git a/UnchainedLauncher.CLI/ServerMonitor.cs b/UnchainedLauncher.CLI/ServerMonitor.cs index 69948478..5752552c 100644 --- a/UnchainedLauncher.CLI/ServerMonitor.cs +++ b/UnchainedLauncher.CLI/ServerMonitor.cs @@ -87,4 +87,4 @@ public async Task RunAsync(ServerDashboard dashboard, CancellationTokenSource st Console.Error.WriteLine(message); } } -} +} \ No newline at end of file diff --git a/UnchainedLauncher.CLI/ServerOptions.cs b/UnchainedLauncher.CLI/ServerOptions.cs index 77d5c3be..942b9601 100644 --- a/UnchainedLauncher.CLI/ServerOptions.cs +++ b/UnchainedLauncher.CLI/ServerOptions.cs @@ -170,4 +170,4 @@ Q or Ctrl+C stops the server. Redirected output automatically disables the TUI. EGS authentication arguments must be supplied after --; this does not log in to Epic. DLL package selection is tracked in https://github.com/Chiv2-Community/UnchainedLauncher/issues/149 """; -} +} \ No newline at end of file diff --git a/UnchainedLauncher.Core/Utilities/MemoryProgress.cs b/UnchainedLauncher.Core/Utilities/MemoryProgress.cs index bad04da9..4165cfa8 100644 --- a/UnchainedLauncher.Core/Utilities/MemoryProgress.cs +++ b/UnchainedLauncher.Core/Utilities/MemoryProgress.cs @@ -62,4 +62,4 @@ private void BindTo(MemoryProgress mp) { mp.PropertyChanged += OnProgressPropertyChanged; } } -} +} \ No newline at end of file From 157c212e77954bed8301c33cab773106fcb0e022 Mon Sep 17 00:00:00 2001 From: Jacob Barber Date: Sat, 10 Oct 2026 20:13:31 -0500 Subject: [PATCH 03/58] Dispose owned registry streams and verify server CLI production boundaries --- .../FileWriterTests.cs | 76 ++++ .../MemoryProgressTests.cs | 21 + .../RegistryAndReleaseTests.cs | 381 +++++++++++------- UnchainedLauncher.CLI/ServerLauncher.cs | 257 ++++++------ .../UnchainedLauncher.CLI.csproj | 1 + .../Services/Mods/Registry/IModRegistry.cs | 293 +++++++------- .../Utilities/FileHelpers.cs | 325 +++++++-------- .../Utilities/MemoryProgress.cs | 134 +++--- 8 files changed, 857 insertions(+), 631 deletions(-) create mode 100644 UnchainedLauncher.CLI.Tests/FileWriterTests.cs create mode 100644 UnchainedLauncher.CLI.Tests/MemoryProgressTests.cs diff --git a/UnchainedLauncher.CLI.Tests/FileWriterTests.cs b/UnchainedLauncher.CLI.Tests/FileWriterTests.cs new file mode 100644 index 00000000..02d72e0f --- /dev/null +++ b/UnchainedLauncher.CLI.Tests/FileWriterTests.cs @@ -0,0 +1,76 @@ +using LanguageExt; +using UnchainedLauncher.Core.JsonModels.ModMetadata; +using UnchainedLauncher.Core.Services.Mods.Registry; +using UnchainedLauncher.Core.Utilities; +using UnchainedLauncher.UnrealModScanner.JsonModels; +using Xunit; + +namespace UnchainedLauncher.CLI.Tests; + +public class FileWriterTests : IDisposable { + private readonly string _directory = Path.Combine( + OperatingSystem.IsLinux() ? "/tmp/opencode" : Path.GetTempPath(), "writer-tests-" + Guid.NewGuid().ToString("N")); + + public FileWriterTests() => Directory.CreateDirectory(_directory); + public void Dispose() => Directory.Delete(_directory, recursive: true); + + [Theory] + [InlineData("success")] + [InlineData("output-failure")] + [InlineData("cancelled")] + [InlineData("abandoned")] + public async Task RegistryInputIsClosedDeterministically(string outcome) { + var source = Path.Combine(_directory, "source.pak"); + File.WriteAllText(source, "registry payload"); + var info = new ModInfo("https://github.com/org/mod", "mod", "test", null, null, [], [], []); + var release = new Release("v1.0.0", "hash", "test.pak", DateTime.UtcNow, info, + new AssetCollections([], [], [], [], []), null); + var registry = new LocalModRegistry(Path.Combine(_directory, "registry")); + await registry.AddRelease(release, source); + var output = outcome == "output-failure" ? _directory : Path.Combine(_directory, "output.pak"); + using var writer = await registry.DownloadPak(ReleaseCoordinates.FromRelease(release), output) + .Match(Right: value => value, Left: error => throw new Exception(error.Message)); + Assert.True(writer.InputStream.CanRead); + if (outcome == "abandoned") { + writer.Dispose(); + } + else { + using var cancellation = new CancellationTokenSource(); + if (outcome == "cancelled") cancellation.Cancel(); + var result = await writer.WriteAsync(Option>.None, cancellation.Token); + Assert.Equal(outcome == "success", result.IsRight); + } + // Check before fixture/writer disposal: Linux must detect the same leak as Windows. + Assert.False(writer.InputStream.CanRead); + var registryPak = Path.Combine(registry.RegistryPath, info.Organization, info.RepoName, release.Tag, release.PakFileName); + using var exclusive = File.Open(registryPak, FileMode.Open, FileAccess.ReadWrite, FileShare.None); + if (outcome == "success") Assert.Equal("registry payload", File.ReadAllText(output)); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task InputIsClosedAfterReadFailureOrMidCopyCancellation(bool cancel) { + using var cancellation = new CancellationTokenSource(); + using var input = new InterruptingStream(cancellation, cancel); + using var writer = new FileWriter(Path.Combine(_directory, "output.pak"), input, input.Length); + var result = await writer.WriteAsync(Option>.None, cancellation.Token); + Assert.True(result.IsLeft); + Assert.False(input.CanRead); + Assert.Equal(cancel, cancellation.IsCancellationRequested); + } + + private sealed class InterruptingStream(CancellationTokenSource cancellation, bool cancel) : MemoryStream(new byte[16]) { + private bool _firstRead = true; + public override ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) { + if (_firstRead) { + _firstRead = false; + buffer.Span[0] = 1; + return ValueTask.FromResult(1); + } + if (!cancel) throw new IOException("Input read failed."); + cancellation.Cancel(); + return base.ReadAsync(buffer, cancellationToken); + } + } +} \ No newline at end of file diff --git a/UnchainedLauncher.CLI.Tests/MemoryProgressTests.cs b/UnchainedLauncher.CLI.Tests/MemoryProgressTests.cs new file mode 100644 index 00000000..647bf616 --- /dev/null +++ b/UnchainedLauncher.CLI.Tests/MemoryProgressTests.cs @@ -0,0 +1,21 @@ +using UnchainedLauncher.Core.Utilities; +using Xunit; + +namespace UnchainedLauncher.CLI.Tests; + +public class MemoryProgressTests { + [Fact] + public async Task ConsoleProgressSupportsConcurrentTrackingAndReports() { + await Task.Run(() => { + Assert.Null(SynchronizationContext.Current); + var progress = new AccumulatedMemoryProgress(); + Parallel.For(0, 128, _ => { + var child = new MemoryProgress(); + progress.AlsoTrack(child); + for (var value = 1; value <= 100; value++) child.Report(value); + }); + Assert.Equal(128, progress.Progresses.Count); + Assert.Equal(100, progress.ProgressPercentage); + }); + } +} \ No newline at end of file diff --git a/UnchainedLauncher.CLI.Tests/RegistryAndReleaseTests.cs b/UnchainedLauncher.CLI.Tests/RegistryAndReleaseTests.cs index a07fb713..4514dbc3 100644 --- a/UnchainedLauncher.CLI.Tests/RegistryAndReleaseTests.cs +++ b/UnchainedLauncher.CLI.Tests/RegistryAndReleaseTests.cs @@ -1,138 +1,243 @@ -using UnchainedLauncher.Core.JsonModels.ModMetadata; -using UnchainedLauncher.Core.Services.Mods; -using UnchainedLauncher.Core.Services.Mods.Registry; -using UnchainedLauncher.Core.Services.Processes.Chivalry.LaunchPreparers; -using UnchainedLauncher.Core.Utilities; -using UnchainedLauncher.UnrealModScanner.JsonModels; -using Xunit; - -namespace UnchainedLauncher.CLI.Tests; - -public class RegistryAndReleaseTests : IDisposable { - private readonly string _directory = Path.Combine( - OperatingSystem.IsLinux() ? "/tmp/opencode" : Path.GetTempPath(), "cli-tests-" + Guid.NewGuid().ToString("N")); - - public RegistryAndReleaseTests() => Directory.CreateDirectory(_directory); - public void Dispose() => Directory.Delete(_directory, recursive: true); - - [Fact] - public void ConfigurationSupportsNestedAggregatesAndRelativeLocalPaths() { - var local = Path.Combine(_directory, "local"); - Directory.CreateDirectory(local); - var path = Path.Combine(_directory, "registries.json"); - var metadata = new AggregateModRegistryMetadata([ - new GithubModRegistryMetadata("owner", "repo"), - new AggregateModRegistryMetadata([new LocalModRegistryMetadata("local")]) - ]); - File.WriteAllText(path, JsonHelpers.Serialize(metadata)); - var registry = Assert.IsType(RegistrySpec.Parse("config:" + path).Create()); - // Core flattens nested aggregates. - var localRegistry = Assert.Single(registry.ModRegistries.OfType()); - Assert.Equal(local, localRegistry.RegistryPath); - Assert.Single(registry.ModRegistries.OfType()); - } - - [Fact] - public void MissingLocalRegistryDoesNotCreateADirectory() { - var path = Path.Combine(_directory, "missing"); - Assert.Throws(() => RegistrySpec.Parse("local:" + path).Create()); - Assert.False(Directory.Exists(path)); - } - - [Fact] - public void InvalidConfigurationFailsClearly() { - var path = Path.Combine(_directory, "bad.json"); - File.WriteAllText(path, "{ broken"); - Assert.Throws(() => RegistrySpec.Parse("config:" + path).Create()); - } - - private static Mod Mod(string org, string id, string tag, params Dependency[] dependencies) { - var info = new ModInfo($"https://github.com/{org}/{id}", id, "test", null, null, [], [], dependencies.ToList()); - var release = new Release(tag, "hash", "test.pak", DateTime.UtcNow, info, - new AssetCollections([], [], [], [], []), null); - return new Mod(info, [release]); - } - - private static Mod Common() => Mod("Chiv2-Community", "Unchained-Mods", "v1.0.0"); - - private ModManager Manager(ReleaseCoordinates[] requested, params Mod[] mods) => - new(new AggregateModRegistry(), new PakDir(_directory, []), requested, mods); - - [Fact] - public void ValidRequestedReleaseAndCommonDependencyPass() { - var coordinate = new ReleaseCoordinates("org", "mod", "v1.2.3"); - var manager = Manager([coordinate], Common(), Mod("org", "mod", "v1.2.3")); - ServerLauncher.ValidateReleases(manager, [coordinate]); - } - - [Fact] - public void MissingRequestedReleaseFailsInsteadOfSilentlyIgnoringIt() { - var coordinate = new ReleaseCoordinates("org", "mod", "v1.2.3"); - var manager = Manager([coordinate], Common(), Mod("org", "mod", "v2.0.0")); - Assert.Throws(() => ServerLauncher.ValidateReleases(manager, [coordinate])); - } - - [Fact] - public void MissingCommonReleaseFails() { - Assert.Throws(() => ServerLauncher.ValidateReleases(Manager([]), [])); - } - - [Fact] - public void MissingDependencyFailsInsteadOfSilentlyDroppingIt() { - var coordinate = new ReleaseCoordinates("org", "mod", "v1.0.0"); - var manager = Manager([coordinate], Common(), Mod("org", "mod", "v1.0.0", - new Dependency("https://github.com/org/missing", "*"))); - Assert.Throws(() => ServerLauncher.ValidateReleases(manager, [coordinate])); - } - - [Fact] - public void ConflictBetweenPinnedCommonAndLatestAutomaticCommonFails() { - var common = Common(); - var older = Mod("Chiv2-Community", "Unchained-Mods", "v0.9.0").Releases.Single(); - common.Releases.Add(older); - var coordinate = ReleaseCoordinates.FromRelease(older); - var manager = Manager([coordinate], common); - Assert.Throws(() => ServerLauncher.ValidateReleases(manager, [coordinate])); - } - - [Fact] - public void PinnedReleaseDependencyMustActuallyBeInTheInstallSet() { - var older = Mod("org", "mod", "v1.0.0", new Dependency("https://github.com/org/old-dependency", "*")); - older.Releases.Add(Mod("org", "mod", "v2.0.0").Releases.Single()); - var coordinate = new ReleaseCoordinates("org", "mod", "v1.0.0"); - var manager = Manager([coordinate], Common(), older, Mod("org", "old-dependency", "v1.0.0")); - Assert.Throws(() => ServerLauncher.ValidateReleases(manager, [coordinate])); - } - - [Fact] - public async Task CoreModPreparationAndInventoryRoundTripWorkInAConsoleHost() { - await Task.Run(async () => { - Assert.Null(SynchronizationContext.Current); - var paks = Path.Combine(_directory, "paks"); - Directory.CreateDirectory(paks); - File.WriteAllText(Path.Combine(paks, "pakchunk0-WindowsNoEditor.sig"), "test vanilla signature"); - var source = Path.Combine(_directory, "source.pak"); - File.WriteAllText(source, "test pak payload"); - var registry = new LocalModRegistry(Path.Combine(_directory, "registry")); - var release = Common().Releases.Single() with { - ReleaseHash = FileHelpers.Sha512(source).Match(Right: hash => hash, Left: error => throw new Exception(error.Message)) - }; - await registry.AddRelease(release, source); - var manager = new ModManager(registry, new PakDir(paks, []), []); - Assert.False((await manager.UpdateModsList()).HasErrors); - var options = ServerOptions.Parse(["server", "--chivalry2-path", _directory, "--platform", "Steam"]).ToLaunchOptions(); - var dialogue = new ConsoleDialogue(); - Assert.True((await new Chivalry2ModsInstaller(manager, dialogue).PrepareLaunch(options)).IsSome); - Assert.True((await SigPreparer.Create(manager.PakDir, dialogue).PrepareLaunch(LanguageExt.Unit.Default)).IsSome); - var installed = Assert.Single(((PakDir)manager.PakDir).ManagedPaks); - Assert.Equal("test pak payload", File.ReadAllText(Path.Combine(paks, installed.PakFileName))); - Assert.True(File.Exists(Path.ChangeExtension(Path.Combine(paks, installed.PakFileName), ".sig"))); - - var codec = new ModManagerCodec(registry); - var restored = codec.Deserialize(codec.Serialize(manager)); - Assert.True(restored.Success); - Assert.True((await new Chivalry2ModsInstaller(restored.Result!, dialogue).PrepareLaunch(options)).IsSome); - Assert.Single(((PakDir)restored.Result!.PakDir).ManagedPaks); - }); - } -} \ No newline at end of file +using LanguageExt; +using LanguageExt.Common; +using System.Diagnostics; +using UnchainedLauncher.Core.JsonModels.ModMetadata; +using UnchainedLauncher.Core.Services.Mods; +using UnchainedLauncher.Core.Services.Mods.Registry; +using UnchainedLauncher.Core.Services.Processes; +using UnchainedLauncher.Core.Services.Processes.Chivalry; +using UnchainedLauncher.Core.Services.Processes.Chivalry.LaunchPreparers; +using UnchainedLauncher.Core.Utilities; +using UnchainedLauncher.UnrealModScanner.JsonModels; +using Xunit; + +namespace UnchainedLauncher.CLI.Tests; + +[Collection("Installation directory")] +public class RegistryAndReleaseTests : IDisposable { + private readonly string _directory = Path.Combine( + OperatingSystem.IsLinux() ? "/tmp/opencode" : Path.GetTempPath(), "cli-tests-" + Guid.NewGuid().ToString("N")); + + public RegistryAndReleaseTests() => Directory.CreateDirectory(_directory); + public void Dispose() => Directory.Delete(_directory, recursive: true); + + [Fact] + public void ConfigurationSupportsNestedAggregatesAndRelativeLocalPaths() { + var local = Path.Combine(_directory, "local"); + Directory.CreateDirectory(local); + var path = Path.Combine(_directory, "registries.json"); + var metadata = new AggregateModRegistryMetadata([ + new GithubModRegistryMetadata("owner", "repo"), + new AggregateModRegistryMetadata([new LocalModRegistryMetadata("local")]) + ]); + File.WriteAllText(path, JsonHelpers.Serialize(metadata)); + var registry = Assert.IsType(RegistrySpec.Parse("config:" + path).Create()); + // Core flattens nested aggregates. + var localRegistry = Assert.Single(registry.ModRegistries.OfType()); + Assert.Equal(local, localRegistry.RegistryPath); + Assert.Single(registry.ModRegistries.OfType()); + } + + [Fact] + public void MissingLocalRegistryDoesNotCreateADirectory() { + var path = Path.Combine(_directory, "missing"); + Assert.Throws(() => RegistrySpec.Parse("local:" + path).Create()); + Assert.False(Directory.Exists(path)); + } + + [Fact] + public void InvalidConfigurationFailsClearly() { + var path = Path.Combine(_directory, "bad.json"); + File.WriteAllText(path, "{ broken"); + Assert.Throws(() => RegistrySpec.Parse("config:" + path).Create()); + } + + private static Mod Mod(string org, string id, string tag, params Dependency[] dependencies) { + var info = new ModInfo($"https://github.com/{org}/{id}", id, "test", null, null, [], [], dependencies.ToList()); + var release = new Release(tag, "hash", "test.pak", DateTime.UtcNow, info, + new AssetCollections([], [], [], [], []), null); + return new Mod(info, [release]); + } + + private static Mod Common() => Mod("Chiv2-Community", "Unchained-Mods", "v1.0.0"); + + private ModManager Manager(ReleaseCoordinates[] requested, params Mod[] mods) => + new(new AggregateModRegistry(), new PakDir(_directory, []), requested, mods); + + [Fact] + public void ValidRequestedReleaseAndCommonDependencyPass() { + var coordinate = new ReleaseCoordinates("org", "mod", "v1.2.3"); + var manager = Manager([coordinate], Common(), Mod("org", "mod", "v1.2.3")); + ServerLauncher.ValidateReleases(manager, [coordinate]); + } + + [Fact] + public void MissingRequestedReleaseFailsInsteadOfSilentlyIgnoringIt() { + var coordinate = new ReleaseCoordinates("org", "mod", "v1.2.3"); + var manager = Manager([coordinate], Common(), Mod("org", "mod", "v2.0.0")); + Assert.Throws(() => ServerLauncher.ValidateReleases(manager, [coordinate])); + } + + [Fact] + public void MissingCommonReleaseFails() { + Assert.Throws(() => ServerLauncher.ValidateReleases(Manager([]), [])); + } + + [Fact] + public void MissingDependencyFailsInsteadOfSilentlyDroppingIt() { + var coordinate = new ReleaseCoordinates("org", "mod", "v1.0.0"); + var manager = Manager([coordinate], Common(), Mod("org", "mod", "v1.0.0", + new Dependency("https://github.com/org/missing", "*"))); + Assert.Throws(() => ServerLauncher.ValidateReleases(manager, [coordinate])); + } + + [Fact] + public void ConflictBetweenPinnedCommonAndLatestAutomaticCommonFails() { + var common = Common(); + var older = Mod("Chiv2-Community", "Unchained-Mods", "v0.9.0").Releases.Single(); + common.Releases.Add(older); + var coordinate = ReleaseCoordinates.FromRelease(older); + var manager = Manager([coordinate], common); + Assert.Throws(() => ServerLauncher.ValidateReleases(manager, [coordinate])); + } + + [Fact] + public void PinnedReleaseDependencyMustActuallyBeInTheInstallSet() { + var older = Mod("org", "mod", "v1.0.0", new Dependency("https://github.com/org/old-dependency", "*")); + older.Releases.Add(Mod("org", "mod", "v2.0.0").Releases.Single()); + var coordinate = new ReleaseCoordinates("org", "mod", "v1.0.0"); + var manager = Manager([coordinate], Common(), older, Mod("org", "old-dependency", "v1.0.0")); + Assert.Throws(() => ServerLauncher.ValidateReleases(manager, [coordinate])); + } + + [Fact] + public async Task CoreModPreparationAndInventoryRoundTripWorkInAConsoleHost() { + await Task.Run(async () => { + Assert.Null(SynchronizationContext.Current); + var paks = Path.Combine(_directory, "paks"); + Directory.CreateDirectory(paks); + File.WriteAllText(Path.Combine(paks, "pakchunk0-WindowsNoEditor.sig"), "test vanilla signature"); + var source = Path.Combine(_directory, "source.pak"); + File.WriteAllText(source, "test pak payload"); + var registry = new LocalModRegistry(Path.Combine(_directory, "registry")); + var release = Common().Releases.Single() with { + ReleaseHash = FileHelpers.Sha512(source).Match(Right: hash => hash, Left: error => throw new Exception(error.Message)) + }; + await registry.AddRelease(release, source); + var manager = new ModManager(registry, new PakDir(paks, []), []); + Assert.False((await manager.UpdateModsList()).HasErrors); + var options = ServerOptions.Parse(["server", "--chivalry2-path", _directory, "--platform", "Steam"]).ToLaunchOptions(); + var dialogue = new ConsoleDialogue(); + Assert.True((await new Chivalry2ModsInstaller(manager, dialogue).PrepareLaunch(options)).IsSome); + var registryPak = Path.Combine(registry.RegistryPath, release.Info.Organization, release.Info.RepoName, + release.Tag, release.PakFileName); + using (File.Open(registryPak, FileMode.Open, FileAccess.ReadWrite, FileShare.None)) { } + Assert.True((await SigPreparer.Create(manager.PakDir, dialogue).PrepareLaunch(LanguageExt.Unit.Default)).IsSome); + var installed = Assert.Single(((PakDir)manager.PakDir).ManagedPaks); + Assert.Equal("test pak payload", File.ReadAllText(Path.Combine(paks, installed.PakFileName))); + Assert.True(File.Exists(Path.ChangeExtension(Path.Combine(paks, installed.PakFileName), ".sig"))); + + var codec = new ModManagerCodec(registry); + var restored = codec.Deserialize(codec.Serialize(manager)); + Assert.True(restored.Success); + Assert.True((await new Chivalry2ModsInstaller(restored.Result!, dialogue).PrepareLaunch(options)).IsSome); + Assert.Single(((PakDir)restored.Result!.PakDir).ManagedPaks); + }); + } + + [Fact] + public async Task ProductionWiringInstallsManyModsAndForwardsActorsFromAnotherDirectory() { + var callerDirectory = Directory.GetCurrentDirectory(); + var install = Path.Combine(_directory, "arbitrary game installation"); + var paks = Path.Combine(install, FilePaths.PakDir); + Directory.CreateDirectory(paks); + File.WriteAllText(Path.Combine(paks, "pakchunk0-WindowsNoEditor.sig"), "vanilla signature"); + var source = Path.Combine(_directory, "source.pak"); + File.WriteAllText(source, "test payload"); + var hash = FileHelpers.Sha512(source).Match(Right: value => value, Left: error => throw new Exception(error.Message)); + var args = new List { "server", "--chivalry2-path", install, "--platform", "Steam" }; + var releases = new List(); + var actors = Enumerable.Range(0, 12).Select(i => $"/Game/Mods/Actor{i}.Actor{i}_C").ToArray(); + for (var i = 0; i < 12; i++) { + var registryPath = Path.Combine(_directory, "registry " + i); + var registry = new LocalModRegistry(registryPath); + var release = Mod("org", "mod" + i, "v1.0.0").Releases.Single() with { ReleaseHash = hash }; + releases.Add(release); + await registry.AddRelease(release, source); + if (i == 0) await registry.AddRelease(Common().Releases.Single() with { ReleaseHash = hash }, source); + args.AddRange(["--add-registry", "local:" + registryPath, "--mod", $"org/mod{i}:v1.0.0", "--server-mod", actors[i]]); + } + var options = ServerOptions.Parse(args.ToArray()); + var aggregate = ServerLauncher.CreateRegistry(options); + var official = Assert.Single(aggregate.ModRegistries.OfType()); + Assert.Equal(("Chiv2-Community", "C2ModRegistry"), (official.Organization, official.RepoName)); + Assert.Equal(13, aggregate.ModRegistries.Count()); + // Keep metadata deterministic/offline; assert the official production entry above, + // then use all real local registries created by the production factory for installs. + var offline = new AggregateModRegistry(aggregate.ModRegistries.OfType().Cast().ToArray()); + var manager = new ModManager(offline, new PakDir(paks, []), options.Mods); + Assert.False((await manager.UpdateModsList()).HasErrors); + ServerLauncher.ValidateReleases(manager, options.Mods); + var process = new CapturingProcessLauncher(); + await ServerLauncher.InInstallationDirectoryAsync(options, async () => { + Assert.Equal(install, Directory.GetCurrentDirectory()); + var dialogue = new ConsoleDialogue(); + var installer = new Chivalry2ModsInstaller(manager, dialogue); + var signing = SigPreparer.Create(manager.PakDir, dialogue); + var preparer = Chivalry2LaunchPreparer.Create(async launch => { + var prepared = await installer.PrepareLaunch(launch); + Assert.True(prepared.IsSome); + Assert.True((await signing.PrepareLaunch(Unit.Default)).IsSome); + return prepared; + }); + // Capture at the OS process boundary; retain Core preparation/argument rendering. + var launcher = new UnchainedChivalry2Launcher(preparer, process, options.Chivalry2Path, new NullInjector()); + return await launcher.Launch(options.ToLaunchOptions()); + }); + Assert.Equal(callerDirectory, Directory.GetCurrentDirectory()); + Assert.Equal(Path.Combine(install, FilePaths.BinDir), process.WorkingDirectory); + Assert.Contains("--server-mods " + string.Join(",", actors), process.Arguments); + var installed = ((PakDir)manager.PakDir).ManagedPaks.ToArray(); + Assert.Equal(13, installed.Length); + foreach (var release in releases) { + Assert.Contains(installed, pak => pak.Coordinates == ReleaseCoordinates.FromRelease(release)); + } + foreach (var pak in installed) { + Assert.Equal("test payload", File.ReadAllText(Path.Combine(paks, pak.PakFileName))); + Assert.True(File.Exists(Path.ChangeExtension(Path.Combine(paks, pak.PakFileName), ".sig"))); + } + foreach (var registry in offline.ModRegistries.Cast()) { + foreach (var path in Directory.EnumerateFiles(registry.RegistryPath, "*.pak", SearchOption.AllDirectories)) { + using (File.Open(path, FileMode.Open, FileAccess.ReadWrite, FileShare.None)) { } + } + } + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ProductionDirectoryScopeRestoresCallerAfterFailureOrCancellation(bool cancel) { + var callerDirectory = Directory.GetCurrentDirectory(); + var options = ServerOptions.Parse(["server", "--chivalry2-path", _directory, "--platform", "Steam"]); + await Assert.ThrowsAnyAsync(() => ServerLauncher.InInstallationDirectoryAsync(options, () => { + Assert.Equal(_directory, Directory.GetCurrentDirectory()); + if (cancel) throw new OperationCanceledException(); + throw new IOException("Preparation failed."); + })); + Assert.Equal(callerDirectory, Directory.GetCurrentDirectory()); + } + + private sealed class CapturingProcessLauncher : IProcessLauncher { + public string Executable => "test-game.exe"; + public string WorkingDirectory { get; private set; } = ""; + public string Arguments { get; private set; } = ""; + public Either Launch(string workingDirectory, string args) { + WorkingDirectory = workingDirectory; + Arguments = args; + return new LaunchFailed(Executable, args, Error.New("Stopped at the OS boundary.")); + } + } +} + +[CollectionDefinition("Installation directory", DisableParallelization = true)] +public class InstallationDirectoryCollection { } \ No newline at end of file diff --git a/UnchainedLauncher.CLI/ServerLauncher.cs b/UnchainedLauncher.CLI/ServerLauncher.cs index 71dea19d..d1fc2063 100644 --- a/UnchainedLauncher.CLI/ServerLauncher.cs +++ b/UnchainedLauncher.CLI/ServerLauncher.cs @@ -1,125 +1,134 @@ -using Octokit; -using System.Diagnostics; -using System.Net; -using UnchainedLauncher.Core.Services; -using UnchainedLauncher.Core.Services.Mods; -using UnchainedLauncher.Core.Services.Mods.Registry; -using UnchainedLauncher.Core.Services.Processes; -using UnchainedLauncher.Core.Services.Processes.Chivalry; -using UnchainedLauncher.Core.Services.Processes.Chivalry.LaunchPreparers; -using UnchainedLauncher.Core.Services.Server.A2S; -using UnchainedLauncher.Core.Utilities; - -namespace UnchainedLauncher.CLI; - -public static class ServerLauncher { - public static async Task LaunchAsync(ServerOptions options, CancellationToken cancellationToken) { - if (!OperatingSystem.IsWindows()) throw new PlatformNotSupportedException("Server launching requires Windows (Core's DLL injector uses kernel32.dll)."); - var executable = Path.Combine(options.Chivalry2Path, FilePaths.GameBinPath); - var pakPath = Path.Combine(options.Chivalry2Path, FilePaths.PakDir); - var pluginsPath = Path.Combine(options.Chivalry2Path, FilePaths.PluginDir); - if (!File.Exists(executable)) throw new FileNotFoundException("Chivalry 2 shipping executable was not found. Check --chivalry2-path.", executable); - if (!Directory.Exists(pakPath)) throw new DirectoryNotFoundException($"Chivalry 2 Paks directory was not found: {pakPath}"); - if (!options.UpdatePlugin && !File.Exists(Path.Combine(options.Chivalry2Path, FilePaths.UnchainedPluginPath))) - throw new FileNotFoundException("--no-plugin-update requires an existing UnchainedPlugin.dll in the Plugins directory."); - - var registry = new AggregateModRegistry(new IModRegistry[] { - new GithubModRegistry("Chiv2-Community", "C2ModRegistry") - }.Concat(options.Registries.Select(r => r.Create())).ToArray()); - - // Several existing Core preparers use installation-relative paths. - // This standalone CLI sets CWD only for preparation, then restores it. - var previousDirectory = Directory.GetCurrentDirectory(); - Directory.SetCurrentDirectory(options.Chivalry2Path); - try { - var cachePath = Path.Combine(options.Chivalry2Path, FilePaths.ModCachePath, "cli_mod_manager.json"); - var codec = new ModManagerCodec(registry); - ModManager? cached = null; - var existingCache = File.Exists(cachePath) ? cachePath : FilePaths.ModManagerConfigPath; - if (File.Exists(existingCache)) { - var decoded = codec.Deserialize(File.ReadAllText(existingCache)); - if (!decoded.Success) throw new InvalidDataException($"Cannot read mod cache: {existingCache}", decoded.Exception); - cached = decoded.Result; - } - var manager = new ModManager(registry, - new PakDir(pakPath, (cached?.PakDir as PakDir)?.ManagedPaks ?? []), options.Mods.Distinct()); - Console.Error.WriteLine("Loading mod registries..."); - var fetched = await manager.UpdateModsList(); - cancellationToken.ThrowIfCancellationRequested(); - if (fetched.HasErrors) { - foreach (var error in fetched.Errors) Console.Error.WriteLine(error.Message); - throw new InvalidOperationException("Registry loading failed; refusing to launch with incomplete metadata."); - } - ValidateReleases(manager, options.Mods); - - if (options.Platform == GamePlatform.Steam) { - var appIdPath = Path.Combine(options.Chivalry2Path, FilePaths.SteamAppIdPath); - if (!File.Exists(appIdPath)) File.WriteAllText(appIdPath, "1824220"); - } - var dialogue = new ConsoleDialogue(); - var pluginUpdater = new UnchainedPluginUpdateChecker( - new GithubReleaseLocator(new GitHubClient(new ProductHeaderValue("UnchainedLauncher.CLI")), - "Chiv2-Community", "UnchainedPlugin"), new FileInfoVersionExtractor(), dialogue); - var modInstaller = new Chivalry2ModsInstaller(manager, dialogue); - var signing = SigPreparer.Create(manager.PakDir, dialogue); - // Call preparers sequentially so each receives the previous result. - // Always save the managed-pak inventory, even after a partial installation. - var preparer = Chivalry2LaunchPreparer.Create(async launch => { - cancellationToken.ThrowIfCancellationRequested(); - var updated = await pluginUpdater.PrepareLaunch(launch); - cancellationToken.ThrowIfCancellationRequested(); - if (updated.IsNone) return LanguageExt.Option.None; - if (!File.Exists(Path.Combine(pluginsPath, "UnchainedPlugin.dll"))) - throw new FileNotFoundException("UnchainedPlugin.dll is required to host a server."); - try { - updated = await modInstaller.PrepareLaunch(updated.IfNone(launch)); - } - finally { - codec.SerializeFile(cachePath, manager); - } - cancellationToken.ThrowIfCancellationRequested(); - if (updated.IsNone || (await signing.PrepareLaunch(LanguageExt.Unit.Default)).IsNone) - return LanguageExt.Option.None; - cancellationToken.ThrowIfCancellationRequested(); - return updated; - }); - var launcher = new UnchainedChivalry2Launcher(preparer, new ProcessLauncher(executable), - options.Chivalry2Path, new DllInjector(pluginsPath)); - var result = await launcher.Launch(options.ToLaunchOptions()); - return result.Match( - Right: process => process, - // Do not print LaunchFailed.Message: it contains raw password/auth arguments. - Left: failure => throw new InvalidOperationException("Server launch or DLL injection failed. Check the installation, plugin, and mod files.", failure)); - } - finally { - Directory.SetCurrentDirectory(previousDirectory); - } - } - - public static void ValidateReleases(IModManager manager, IEnumerable requested) { - foreach (var coordinate in requested) { - if (manager.GetRelease(coordinate).IsNone) - throw new ArgumentException($"Requested mod release was not found: {coordinate.Org}/{coordinate.ModuleName}:{coordinate.Version}"); - } - if (manager.GetLatestRelease(CommonMods.UnchainedMods).IsNone) - throw new InvalidOperationException("The registry must provide a stable Chiv2-Community/Unchained-Mods release."); - var releases = manager.GetEnabledAndDependencyReleases().ToArray(); - if (releases.GroupBy(r => (r.Info.Organization, r.Info.RepoName)).Any(g => g.Select(r => r.Tag).Distinct().Count() > 1)) - throw new ArgumentException("Dependency resolution selected conflicting versions of a mod. Core currently selects the latest stable dependencies, including Unchained-Mods."); - foreach (var release in releases) { - foreach (var dependency in release.Info.Dependencies) { - if (manager.GetLatestRelease(ModIdentifier.FromDependency(dependency)).IsNone) - throw new InvalidOperationException($"Missing stable dependency: {dependency.Organization}/{dependency.RepoName}"); - if (!releases.Any(r => r.Info.Organization == dependency.Organization && r.Info.RepoName == dependency.RepoName)) - throw new InvalidOperationException($"Core did not resolve dependency {dependency.Organization}/{dependency.RepoName} for the requested release. Its current resolver uses latest-release dependency metadata."); - } - } - } - - public static IA2S CreateQuery(ServerOptions options) { - var address = options.LocalIp is null ? IPAddress.Loopback : IPAddress.Parse(options.LocalIp); - if (address.Equals(IPAddress.Any)) address = IPAddress.Loopback; - if (address.Equals(IPAddress.IPv6Any)) address = IPAddress.IPv6Loopback; - return new A2S(new IPEndPoint(address, options.QueryPort)); - } +using Octokit; +using System.Diagnostics; +using System.Net; +using UnchainedLauncher.Core.Services; +using UnchainedLauncher.Core.Services.Mods; +using UnchainedLauncher.Core.Services.Mods.Registry; +using UnchainedLauncher.Core.Services.Processes; +using UnchainedLauncher.Core.Services.Processes.Chivalry; +using UnchainedLauncher.Core.Services.Processes.Chivalry.LaunchPreparers; +using UnchainedLauncher.Core.Services.Server.A2S; +using UnchainedLauncher.Core.Utilities; + +namespace UnchainedLauncher.CLI; + +public static class ServerLauncher { + public static async Task LaunchAsync(ServerOptions options, CancellationToken cancellationToken) { + if (!OperatingSystem.IsWindows()) throw new PlatformNotSupportedException("Server launching requires Windows (Core's DLL injector uses kernel32.dll)."); + var executable = Path.Combine(options.Chivalry2Path, FilePaths.GameBinPath); + var pakPath = Path.Combine(options.Chivalry2Path, FilePaths.PakDir); + var pluginsPath = Path.Combine(options.Chivalry2Path, FilePaths.PluginDir); + if (!File.Exists(executable)) throw new FileNotFoundException("Chivalry 2 shipping executable was not found. Check --chivalry2-path.", executable); + if (!Directory.Exists(pakPath)) throw new DirectoryNotFoundException($"Chivalry 2 Paks directory was not found: {pakPath}"); + if (!options.UpdatePlugin && !File.Exists(Path.Combine(options.Chivalry2Path, FilePaths.UnchainedPluginPath))) + throw new FileNotFoundException("--no-plugin-update requires an existing UnchainedPlugin.dll in the Plugins directory."); + + var registry = CreateRegistry(options); + + return await InInstallationDirectoryAsync(options, async () => { + var cachePath = Path.Combine(options.Chivalry2Path, FilePaths.ModCachePath, "cli_mod_manager.json"); + var codec = new ModManagerCodec(registry); + ModManager? cached = null; + var existingCache = File.Exists(cachePath) ? cachePath : FilePaths.ModManagerConfigPath; + if (File.Exists(existingCache)) { + var decoded = codec.Deserialize(File.ReadAllText(existingCache)); + if (!decoded.Success) throw new InvalidDataException($"Cannot read mod cache: {existingCache}", decoded.Exception); + cached = decoded.Result; + } + var manager = new ModManager(registry, + new PakDir(pakPath, (cached?.PakDir as PakDir)?.ManagedPaks ?? []), options.Mods.Distinct()); + Console.Error.WriteLine("Loading mod registries..."); + var fetched = await manager.UpdateModsList(); + cancellationToken.ThrowIfCancellationRequested(); + if (fetched.HasErrors) { + foreach (var error in fetched.Errors) Console.Error.WriteLine(error.Message); + throw new InvalidOperationException("Registry loading failed; refusing to launch with incomplete metadata."); + } + ValidateReleases(manager, options.Mods); + + if (options.Platform == GamePlatform.Steam) { + var appIdPath = Path.Combine(options.Chivalry2Path, FilePaths.SteamAppIdPath); + if (!File.Exists(appIdPath)) File.WriteAllText(appIdPath, "1824220"); + } + var dialogue = new ConsoleDialogue(); + var pluginUpdater = new UnchainedPluginUpdateChecker( + new GithubReleaseLocator(new GitHubClient(new ProductHeaderValue("UnchainedLauncher.CLI")), + "Chiv2-Community", "UnchainedPlugin"), new FileInfoVersionExtractor(), dialogue); + var modInstaller = new Chivalry2ModsInstaller(manager, dialogue); + var signing = SigPreparer.Create(manager.PakDir, dialogue); + // Call preparers sequentially so each receives the previous result. + // Always save the managed-pak inventory, even after a partial installation. + var preparer = Chivalry2LaunchPreparer.Create(async launch => { + cancellationToken.ThrowIfCancellationRequested(); + var updated = await pluginUpdater.PrepareLaunch(launch); + cancellationToken.ThrowIfCancellationRequested(); + if (updated.IsNone) return LanguageExt.Option.None; + if (!File.Exists(Path.Combine(pluginsPath, "UnchainedPlugin.dll"))) + throw new FileNotFoundException("UnchainedPlugin.dll is required to host a server."); + try { + updated = await modInstaller.PrepareLaunch(updated.IfNone(launch)); + } + finally { + codec.SerializeFile(cachePath, manager); + } + cancellationToken.ThrowIfCancellationRequested(); + if (updated.IsNone || (await signing.PrepareLaunch(LanguageExt.Unit.Default)).IsNone) + return LanguageExt.Option.None; + cancellationToken.ThrowIfCancellationRequested(); + return updated; + }); + var launcher = new UnchainedChivalry2Launcher(preparer, new ProcessLauncher(executable), + options.Chivalry2Path, new DllInjector(pluginsPath)); + var result = await launcher.Launch(options.ToLaunchOptions()); + return result.Match( + Right: process => process, + // Do not print LaunchFailed.Message: it contains raw password/auth arguments. + Left: failure => throw new InvalidOperationException("Server launch or DLL injection failed. Check the installation, plugin, and mod files.", failure)); + }); + } + + internal static AggregateModRegistry CreateRegistry(ServerOptions options) => + new(new IModRegistry[] { + new GithubModRegistry("Chiv2-Community", "C2ModRegistry") + }.Concat(options.Registries.Select(r => r.Create())).ToArray()); + + // Several existing Core preparers use installation-relative paths. + // This standalone CLI sets CWD only for preparation, then restores it. + internal static async Task InInstallationDirectoryAsync(ServerOptions options, Func> action) { + var previousDirectory = Directory.GetCurrentDirectory(); + Directory.SetCurrentDirectory(options.Chivalry2Path); + try { + return await action(); + } + finally { + Directory.SetCurrentDirectory(previousDirectory); + } + } + + public static void ValidateReleases(IModManager manager, IEnumerable requested) { + foreach (var coordinate in requested) { + if (manager.GetRelease(coordinate).IsNone) + throw new ArgumentException($"Requested mod release was not found: {coordinate.Org}/{coordinate.ModuleName}:{coordinate.Version}"); + } + if (manager.GetLatestRelease(CommonMods.UnchainedMods).IsNone) + throw new InvalidOperationException("The registry must provide a stable Chiv2-Community/Unchained-Mods release."); + var releases = manager.GetEnabledAndDependencyReleases().ToArray(); + if (releases.GroupBy(r => (r.Info.Organization, r.Info.RepoName)).Any(g => g.Select(r => r.Tag).Distinct().Count() > 1)) + throw new ArgumentException("Dependency resolution selected conflicting versions of a mod. Core currently selects the latest stable dependencies, including Unchained-Mods."); + foreach (var release in releases) { + foreach (var dependency in release.Info.Dependencies) { + if (manager.GetLatestRelease(ModIdentifier.FromDependency(dependency)).IsNone) + throw new InvalidOperationException($"Missing stable dependency: {dependency.Organization}/{dependency.RepoName}"); + if (!releases.Any(r => r.Info.Organization == dependency.Organization && r.Info.RepoName == dependency.RepoName)) + throw new InvalidOperationException($"Core did not resolve dependency {dependency.Organization}/{dependency.RepoName} for the requested release. Its current resolver uses latest-release dependency metadata."); + } + } + } + + public static IA2S CreateQuery(ServerOptions options) { + var address = options.LocalIp is null ? IPAddress.Loopback : IPAddress.Parse(options.LocalIp); + if (address.Equals(IPAddress.Any)) address = IPAddress.Loopback; + if (address.Equals(IPAddress.IPv6Any)) address = IPAddress.IPv6Loopback; + return new A2S(new IPEndPoint(address, options.QueryPort)); + } } \ No newline at end of file diff --git a/UnchainedLauncher.CLI/UnchainedLauncher.CLI.csproj b/UnchainedLauncher.CLI/UnchainedLauncher.CLI.csproj index 6e7d88c7..5cc4d2b0 100644 --- a/UnchainedLauncher.CLI/UnchainedLauncher.CLI.csproj +++ b/UnchainedLauncher.CLI/UnchainedLauncher.CLI.csproj @@ -6,6 +6,7 @@ enable + diff --git a/UnchainedLauncher.Core/Services/Mods/Registry/IModRegistry.cs b/UnchainedLauncher.Core/Services/Mods/Registry/IModRegistry.cs index ebaa33f6..fed013e3 100644 --- a/UnchainedLauncher.Core/Services/Mods/Registry/IModRegistry.cs +++ b/UnchainedLauncher.Core/Services/Mods/Registry/IModRegistry.cs @@ -1,147 +1,148 @@ -using LanguageExt; -using LanguageExt.Common; -using log4net; -using Semver; -using UnchainedLauncher.Core.JsonModels.ModMetadata; -using UnchainedLauncher.Core.Utilities; -using static LanguageExt.Prelude; - -namespace UnchainedLauncher.Core.Services.Mods.Registry { - - public record ModIdentifier(string Org, string ModuleName) : IComparable { - public static ModIdentifier FromMod(Mod mod) => - new ModIdentifier(mod.LatestReleaseInfo.Organization, mod.LatestReleaseInfo.RepoName); - - public static ModIdentifier FromDependency(Dependency dependency) => new ModIdentifier(dependency.Organization, dependency.RepoName); - public static ModIdentifier FromRelease(Release release) => new ModIdentifier(release.Info.Organization, release.Info.RepoName); - - public bool Matches(Mod mod) => Org == mod.LatestReleaseInfo.Organization && ModuleName == mod.LatestReleaseInfo.RepoName; - - // Intentionally not using equality of this == modId here, because the other ModIdentifier may be a ReleaseCoordinate. - public bool Matches(ModIdentifier modId) => Org == modId.Org && ModuleName == modId.ModuleName; - public int CompareTo(ModIdentifier? other) => - other == null - ? -1 - : (this.Org, this.ModuleName).CompareTo((other.Org, other.ModuleName)); - - public override string ToString() { - return $"('{Org}','{ModuleName}')"; - } - } - - public record ReleaseCoordinates(string Org, string ModuleName, string Version) : ModIdentifier(Org, ModuleName), IComparable { - public static new ReleaseCoordinates FromRelease(Release release) => - new ReleaseCoordinates(release.Info.Organization, release.Info.RepoName, release.Tag); - - public bool Matches(Release release) => Org == release.Info.Organization && ModuleName == release.Info.RepoName && Version == release.Tag; - public int CompareTo(ReleaseCoordinates? other) { - if (other == null) return -1; - - SemVersion.TryParse(Version, SemVersionStyles.Any, out var thisVersion); - SemVersion.TryParse(other.Version, SemVersionStyles.Any, out var otherVersion); - - return (thisVersion, otherVersion) switch { - (null, null) => - (this.Org, this.ModuleName, this.Version).CompareTo((other.Org, other.ModuleName, other.Version)), - (null, _) => - (this.Org, this.ModuleName, new SemVersion(0, 0, 0)).CompareTo((other.Org, other.ModuleName, - otherVersion)), - (_, _) => - (this.Org, this.ModuleName, thisVersion).CompareTo((other.Org, other.ModuleName, otherVersion!)), - }; - } - - public override string ToString() { - return $"('{Org}','{ModuleName}', '{Version}')"; - } - } - - public record GetAllModsResult(IEnumerable Errors, IEnumerable Mods) { - public bool HasErrors => Errors.Any(); - - public GetAllModsResult AddError(RegistryMetadataException error) { - return this with { Errors = Errors.Append(error) }; - } - - public GetAllModsResult AddMod(Mod mod) { - return this with { Mods = Mods.Append(mod) }; - } - - public static GetAllModsResult operator +(GetAllModsResult a, GetAllModsResult b) { - return new GetAllModsResult(a.Errors.Concat(b.Errors), a.Mods.Concat(b.Mods)); - } - }; - - /// - /// A ModRegistry contains all relevant metadata for a repository of mods - /// A ModRegistry does not necessarily know how to download the mods, however - /// it will provide the coordinates which can be used to download the mods via - /// ModRegistryPakFetcher - /// - public interface IModRegistry { - protected static readonly ILog Logger = LogManager.GetLogger(nameof(IModRegistry)); - - public string Name { get; } - - /// - /// Get all mod metadata from this registry - /// - /// - public Task GetAllMods(); - - /// - /// Fetches a mod object from the registry - /// - /// - /// - public EitherAsync GetMod(ModIdentifier modId); - - /// - /// Fetches a specific release from the registry - /// - /// - /// - public EitherAsync GetModRelease(ReleaseCoordinates coords); - - /// - /// Download pak - /// - /// - /// - /// - public EitherAsync DownloadPak(ReleaseCoordinates coordinates, string outputLocation); - } - - public record SizedStream(Stream Stream, long Size); - public record ModPakStreamAcquisitionFailure(ReleaseCoordinates Target, Error Error) : Expected($"Failed to acquire download stream for mod pak '{Target.Org} / {Target.ModuleName} / {Target.Version}", 4000, Some(Error)); - - public abstract record RegistryMetadataException(string Message, int Code, Option Underlying) : Expected(Message, Code, Underlying) { - public record ParseException(string Message, Option Underlying) - : RegistryMetadataException(Message, 0, Underlying); - - public record NotFoundException(ModIdentifier ModId, Option Version, Option Underlying) - : RegistryMetadataException(FormatMessage(ModId, Version), 0, Underlying) { - private static string FormatMessage(ModIdentifier modId, Option version) => - $"Failed to get mod metadata for '{modId.Org}/{modId.ModuleName}" + - version.Map(v => $" / {v}'").IfNone("'"); - }; - public record PackageListRetrievalException(string Message, Option Underlying) : RegistryMetadataException(Message, 0, Underlying); - - public static RegistryMetadataException Parse(string message, Option underlying) => new ParseException(message, underlying); - public static RegistryMetadataException NotFound(ModIdentifier modId, Option underlying) => new NotFoundException(modId, None, underlying); - public static RegistryMetadataException NotFound(ReleaseCoordinates coords, Option underlying) => - new NotFoundException(coords, Some(coords.Version), underlying); - public static RegistryMetadataException PackageListRetrieval(string message, Option underlying) => new PackageListRetrievalException(message, underlying); - - public T Match( - Func parseExceptionFunc, - Func notFoundFunc, - Func packageListRetrievalFunc - ) => this switch { - NotFoundException notFound => notFoundFunc(notFound), - PackageListRetrievalException packageListRetrievalException => packageListRetrievalFunc(packageListRetrievalException), - ParseException parseError => parseExceptionFunc(parseError), - _ => throw new ArgumentOutOfRangeException() - }; - } +using LanguageExt; +using LanguageExt.Common; +using log4net; +using Semver; +using UnchainedLauncher.Core.JsonModels.ModMetadata; +using UnchainedLauncher.Core.Utilities; +using static LanguageExt.Prelude; + +namespace UnchainedLauncher.Core.Services.Mods.Registry { + + public record ModIdentifier(string Org, string ModuleName) : IComparable { + public static ModIdentifier FromMod(Mod mod) => + new ModIdentifier(mod.LatestReleaseInfo.Organization, mod.LatestReleaseInfo.RepoName); + + public static ModIdentifier FromDependency(Dependency dependency) => new ModIdentifier(dependency.Organization, dependency.RepoName); + public static ModIdentifier FromRelease(Release release) => new ModIdentifier(release.Info.Organization, release.Info.RepoName); + + public bool Matches(Mod mod) => Org == mod.LatestReleaseInfo.Organization && ModuleName == mod.LatestReleaseInfo.RepoName; + + // Intentionally not using equality of this == modId here, because the other ModIdentifier may be a ReleaseCoordinate. + public bool Matches(ModIdentifier modId) => Org == modId.Org && ModuleName == modId.ModuleName; + public int CompareTo(ModIdentifier? other) => + other == null + ? -1 + : (this.Org, this.ModuleName).CompareTo((other.Org, other.ModuleName)); + + public override string ToString() { + return $"('{Org}','{ModuleName}')"; + } + } + + public record ReleaseCoordinates(string Org, string ModuleName, string Version) : ModIdentifier(Org, ModuleName), IComparable { + public static new ReleaseCoordinates FromRelease(Release release) => + new ReleaseCoordinates(release.Info.Organization, release.Info.RepoName, release.Tag); + + public bool Matches(Release release) => Org == release.Info.Organization && ModuleName == release.Info.RepoName && Version == release.Tag; + public int CompareTo(ReleaseCoordinates? other) { + if (other == null) return -1; + + SemVersion.TryParse(Version, SemVersionStyles.Any, out var thisVersion); + SemVersion.TryParse(other.Version, SemVersionStyles.Any, out var otherVersion); + + return (thisVersion, otherVersion) switch { + (null, null) => + (this.Org, this.ModuleName, this.Version).CompareTo((other.Org, other.ModuleName, other.Version)), + (null, _) => + (this.Org, this.ModuleName, new SemVersion(0, 0, 0)).CompareTo((other.Org, other.ModuleName, + otherVersion)), + (_, _) => + (this.Org, this.ModuleName, thisVersion).CompareTo((other.Org, other.ModuleName, otherVersion!)), + }; + } + + public override string ToString() { + return $"('{Org}','{ModuleName}', '{Version}')"; + } + } + + public record GetAllModsResult(IEnumerable Errors, IEnumerable Mods) { + public bool HasErrors => Errors.Any(); + + public GetAllModsResult AddError(RegistryMetadataException error) { + return this with { Errors = Errors.Append(error) }; + } + + public GetAllModsResult AddMod(Mod mod) { + return this with { Mods = Mods.Append(mod) }; + } + + public static GetAllModsResult operator +(GetAllModsResult a, GetAllModsResult b) { + return new GetAllModsResult(a.Errors.Concat(b.Errors), a.Mods.Concat(b.Mods)); + } + }; + + /// + /// A ModRegistry contains all relevant metadata for a repository of mods + /// A ModRegistry does not necessarily know how to download the mods, however + /// it will provide the coordinates which can be used to download the mods via + /// ModRegistryPakFetcher + /// + public interface IModRegistry { + protected static readonly ILog Logger = LogManager.GetLogger(nameof(IModRegistry)); + + public string Name { get; } + + /// + /// Get all mod metadata from this registry + /// + /// + public Task GetAllMods(); + + /// + /// Fetches a mod object from the registry + /// + /// + /// + public EitherAsync GetMod(ModIdentifier modId); + + /// + /// Fetches a specific release from the registry + /// + /// + /// + public EitherAsync GetModRelease(ReleaseCoordinates coords); + + /// + /// Acquires a single-use pak writer that owns the registry input stream. + /// The caller must execute WriteAsync or dispose the returned writer. + /// + /// + /// + /// + public EitherAsync DownloadPak(ReleaseCoordinates coordinates, string outputLocation); + } + + public record SizedStream(Stream Stream, long Size); + public record ModPakStreamAcquisitionFailure(ReleaseCoordinates Target, Error Error) : Expected($"Failed to acquire download stream for mod pak '{Target.Org} / {Target.ModuleName} / {Target.Version}", 4000, Some(Error)); + + public abstract record RegistryMetadataException(string Message, int Code, Option Underlying) : Expected(Message, Code, Underlying) { + public record ParseException(string Message, Option Underlying) + : RegistryMetadataException(Message, 0, Underlying); + + public record NotFoundException(ModIdentifier ModId, Option Version, Option Underlying) + : RegistryMetadataException(FormatMessage(ModId, Version), 0, Underlying) { + private static string FormatMessage(ModIdentifier modId, Option version) => + $"Failed to get mod metadata for '{modId.Org}/{modId.ModuleName}" + + version.Map(v => $" / {v}'").IfNone("'"); + }; + public record PackageListRetrievalException(string Message, Option Underlying) : RegistryMetadataException(Message, 0, Underlying); + + public static RegistryMetadataException Parse(string message, Option underlying) => new ParseException(message, underlying); + public static RegistryMetadataException NotFound(ModIdentifier modId, Option underlying) => new NotFoundException(modId, None, underlying); + public static RegistryMetadataException NotFound(ReleaseCoordinates coords, Option underlying) => + new NotFoundException(coords, Some(coords.Version), underlying); + public static RegistryMetadataException PackageListRetrieval(string message, Option underlying) => new PackageListRetrievalException(message, underlying); + + public T Match( + Func parseExceptionFunc, + Func notFoundFunc, + Func packageListRetrievalFunc + ) => this switch { + NotFoundException notFound => notFoundFunc(notFound), + PackageListRetrievalException packageListRetrievalException => packageListRetrievalFunc(packageListRetrievalException), + ParseException parseError => parseExceptionFunc(parseError), + _ => throw new ArgumentOutOfRangeException() + }; + } } \ No newline at end of file diff --git a/UnchainedLauncher.Core/Utilities/FileHelpers.cs b/UnchainedLauncher.Core/Utilities/FileHelpers.cs index 703adc0f..47ea0869 100644 --- a/UnchainedLauncher.Core/Utilities/FileHelpers.cs +++ b/UnchainedLauncher.Core/Utilities/FileHelpers.cs @@ -1,160 +1,167 @@ -using LanguageExt; -using LanguageExt.Common; -using log4net; - -namespace UnchainedLauncher.Core.Utilities { - using static LanguageExt.Prelude; - public static class FileHelpers { - private static readonly ILog Logger = LogManager.GetLogger(nameof(FileHelpers)); - - public const string UserLockSuffix = ".USER_LOCK"; - - public static bool IsFileLocked(string filePath) { - return File.Exists(Path.Combine(Path.GetDirectoryName(filePath)!, UserLockSuffix)) || - File.Exists(filePath + UserLockSuffix); - } - - public static bool DeleteFiles(IEnumerable filePaths) { - Logger.Info("Deleting files: " + string.Join("\n ", filePaths)); - // Delete all files in a transaction. - // If any of them are locked, don't delete any of them. - if (filePaths.Any(IsFileLocked)) { - var lockedFiles = filePaths.Where(IsFileLocked); - Logger.Warn($"Not deleting files because some of them are locked:\n" + string.Join("\n ", lockedFiles)); - return false; - } - - foreach (var filePath in filePaths) { - File.Delete(filePath); - } - - return true; - } - - public static bool DeleteFile(string filePath) { - if (IsFileLocked(filePath)) { - Logger.Warn($"Not deleting file because it is locked: {filePath}"); - return false; - } - - if (File.Exists(filePath)) { - Logger.Info("Deleting file: " + filePath); - File.Delete(filePath); - } - - return true; - } - - public static bool DeleteDirectory(string filePath) { - Logger.Info("Deleting directory: " + filePath); - if (!Directory.Exists(filePath)) { - Logger.Warn($"Not deleting directory because it doesn't exist: {filePath}"); - return false; - } - - var files = Directory.GetFiles(filePath, "*", SearchOption.AllDirectories); - var result = DeleteFiles(files); - - if (result) - Directory.Delete(filePath, true); - - return result; - } - - // Currently unused, but maybe someday. - public static void LockFile(string filePath) { - File.Create(Path.Combine(Path.GetDirectoryName(filePath)!, UserLockSuffix)).Close(); - } - - // Currently unused, but maybe someday. - public static void UnlockFile(string filePath) { - File.Delete(Path.Combine(Path.GetDirectoryName(filePath)!, UserLockSuffix)); - } - - public static Either Sha512(string filePath) { - return Prelude.Try(() => { - using var sha512 = System.Security.Cryptography.SHA512.Create(); - var bytes = File.ReadAllBytes(filePath); - return BitConverter.ToString(sha512.ComputeHash(bytes)).Replace("-", "").ToLowerInvariant(); - }).ToEither() - .MapLeft(error => new HashFailure(filePath, error)); - } - - - /// - /// Asynchronously computes the SHA-512 hash of a file. - /// - /// - /// An Either representing a failure on the left, or a hash on the right. If Right(None), then the file does not exist. - public static EitherAsync> Sha512Async(string filePath) { - - if (!File.Exists(filePath)) - return Prelude.RightAsync>(Prelude.None); - - return - Prelude - .TryAsync(() => File.ReadAllBytesAsync(filePath)) - .ToEither() - .Map(bytes => { - using var sha512 = System.Security.Cryptography.SHA512.Create(); - return Prelude.Some(BitConverter.ToString(sha512.ComputeHash(bytes)).Replace("-", "").ToLowerInvariant()); - }) - .MapLeft(error => new HashFailure(filePath, error)); - } - } - - public record HashFailure(string FilePath, Error Error) : Expected($"Failed to hash file at {FilePath}. Reason: {Error.Message}", Error.Code, Some(Error)); - - - public class FileWriter { - private static readonly ILog logger = LogManager.GetLogger(nameof(FileWriter)); - - public string FilePath { get; } - public Stream InputStream { get; } - public long Size { get; } - - public FileWriter(string filePath, Stream inputStream, long size) { - FilePath = filePath; - InputStream = inputStream; - Size = size; - } - - public EitherAsync WriteAsync(Option> progress, CancellationToken cancellationToken) { - return Prelude.TryAsync( - async () => { - // Ensure the input stream is readable - if (!InputStream.CanRead) { - logger.Error($"The input stream for file '{FilePath}' is not readable."); - throw new ArgumentException("The input stream is not readable.", nameof(InputStream)); - } - - // Create or overwrite the target file - using (var outputStream = new FileStream(FilePath, FileMode.Create, FileAccess.Write, FileShare.None, bufferSize: 4096, useAsync: true)) { - logger.Debug($"Writing file '{FilePath}' from input stream."); - var totalBytesWritten = 0L; // Total bytes written to the file - var buffer = new byte[8 * 1024 * 1024]; // Buffer to hold data from input stream - int bytesRead; - - // While there's data to read from the input stream, read and write asynchronously - var memory = new Memory(buffer); - while ((bytesRead = await InputStream.ReadAsync(memory, cancellationToken)) > 0) { - // Write only the portion of the buffer that was read - await outputStream.WriteAsync(memory[..bytesRead], cancellationToken); - - totalBytesWritten += bytesRead; - var percentage = (double)totalBytesWritten / Size * 100; - - progress.IfSome(p => p.Report(percentage)); - //logger.Debug($"Wrote {totalBytesWritten} bytes to file '{FilePath}'. Progress: {percentage}%"); - } - } - - return default(Unit); - }) - .ToEither(); - } - - - - } +using LanguageExt; +using LanguageExt.Common; +using log4net; + +namespace UnchainedLauncher.Core.Utilities { + using static LanguageExt.Prelude; + public static class FileHelpers { + private static readonly ILog Logger = LogManager.GetLogger(nameof(FileHelpers)); + + public const string UserLockSuffix = ".USER_LOCK"; + + public static bool IsFileLocked(string filePath) { + return File.Exists(Path.Combine(Path.GetDirectoryName(filePath)!, UserLockSuffix)) || + File.Exists(filePath + UserLockSuffix); + } + + public static bool DeleteFiles(IEnumerable filePaths) { + Logger.Info("Deleting files: " + string.Join("\n ", filePaths)); + // Delete all files in a transaction. + // If any of them are locked, don't delete any of them. + if (filePaths.Any(IsFileLocked)) { + var lockedFiles = filePaths.Where(IsFileLocked); + Logger.Warn($"Not deleting files because some of them are locked:\n" + string.Join("\n ", lockedFiles)); + return false; + } + + foreach (var filePath in filePaths) { + File.Delete(filePath); + } + + return true; + } + + public static bool DeleteFile(string filePath) { + if (IsFileLocked(filePath)) { + Logger.Warn($"Not deleting file because it is locked: {filePath}"); + return false; + } + + if (File.Exists(filePath)) { + Logger.Info("Deleting file: " + filePath); + File.Delete(filePath); + } + + return true; + } + + public static bool DeleteDirectory(string filePath) { + Logger.Info("Deleting directory: " + filePath); + if (!Directory.Exists(filePath)) { + Logger.Warn($"Not deleting directory because it doesn't exist: {filePath}"); + return false; + } + + var files = Directory.GetFiles(filePath, "*", SearchOption.AllDirectories); + var result = DeleteFiles(files); + + if (result) + Directory.Delete(filePath, true); + + return result; + } + + // Currently unused, but maybe someday. + public static void LockFile(string filePath) { + File.Create(Path.Combine(Path.GetDirectoryName(filePath)!, UserLockSuffix)).Close(); + } + + // Currently unused, but maybe someday. + public static void UnlockFile(string filePath) { + File.Delete(Path.Combine(Path.GetDirectoryName(filePath)!, UserLockSuffix)); + } + + public static Either Sha512(string filePath) { + return Prelude.Try(() => { + using var sha512 = System.Security.Cryptography.SHA512.Create(); + var bytes = File.ReadAllBytes(filePath); + return BitConverter.ToString(sha512.ComputeHash(bytes)).Replace("-", "").ToLowerInvariant(); + }).ToEither() + .MapLeft(error => new HashFailure(filePath, error)); + } + + + /// + /// Asynchronously computes the SHA-512 hash of a file. + /// + /// + /// An Either representing a failure on the left, or a hash on the right. If Right(None), then the file does not exist. + public static EitherAsync> Sha512Async(string filePath) { + + if (!File.Exists(filePath)) + return Prelude.RightAsync>(Prelude.None); + + return + Prelude + .TryAsync(() => File.ReadAllBytesAsync(filePath)) + .ToEither() + .Map(bytes => { + using var sha512 = System.Security.Cryptography.SHA512.Create(); + return Prelude.Some(BitConverter.ToString(sha512.ComputeHash(bytes)).Replace("-", "").ToLowerInvariant()); + }) + .MapLeft(error => new HashFailure(filePath, error)); + } + } + + public record HashFailure(string FilePath, Error Error) : Expected($"Failed to hash file at {FilePath}. Reason: {Error.Message}", Error.Code, Some(Error)); + + + /// + /// A single-use writer that owns its input stream. WriteAsync disposes the input + /// after success, failure, or cancellation. Dispose the writer if it is not written. + /// + public class FileWriter : IDisposable { + private static readonly ILog logger = LogManager.GetLogger(nameof(FileWriter)); + + public string FilePath { get; } + public Stream InputStream { get; } + public long Size { get; } + + public FileWriter(string filePath, Stream inputStream, long size) { + FilePath = filePath; + InputStream = inputStream; + Size = size; + } + + public EitherAsync WriteAsync(Option> progress, CancellationToken cancellationToken) { + return Prelude.TryAsync( + async () => { + using var inputStream = InputStream; + // Ensure the input stream is readable + if (!InputStream.CanRead) { + logger.Error($"The input stream for file '{FilePath}' is not readable."); + throw new ArgumentException("The input stream is not readable.", nameof(InputStream)); + } + + // Create or overwrite the target file + using (var outputStream = new FileStream(FilePath, FileMode.Create, FileAccess.Write, FileShare.None, bufferSize: 4096, useAsync: true)) { + logger.Debug($"Writing file '{FilePath}' from input stream."); + var totalBytesWritten = 0L; // Total bytes written to the file + var buffer = new byte[8 * 1024 * 1024]; // Buffer to hold data from input stream + int bytesRead; + + // While there's data to read from the input stream, read and write asynchronously + var memory = new Memory(buffer); + while ((bytesRead = await InputStream.ReadAsync(memory, cancellationToken)) > 0) { + // Write only the portion of the buffer that was read + await outputStream.WriteAsync(memory[..bytesRead], cancellationToken); + + totalBytesWritten += bytesRead; + var percentage = (double)totalBytesWritten / Size * 100; + + progress.IfSome(p => p.Report(percentage)); + //logger.Debug($"Wrote {totalBytesWritten} bytes to file '{FilePath}'. Progress: {percentage}%"); + } + } + + return default(Unit); + }) + .ToEither(); + } + + public void Dispose() => InputStream.Dispose(); + + + + } } \ No newline at end of file diff --git a/UnchainedLauncher.Core/Utilities/MemoryProgress.cs b/UnchainedLauncher.Core/Utilities/MemoryProgress.cs index 4165cfa8..ad1bbd88 100644 --- a/UnchainedLauncher.Core/Utilities/MemoryProgress.cs +++ b/UnchainedLauncher.Core/Utilities/MemoryProgress.cs @@ -1,65 +1,71 @@ -using PropertyChanged; -using System.Collections.ObjectModel; -using System.ComponentModel; - -namespace UnchainedLauncher.Core.Utilities { - [AddINotifyPropertyChangedInterface] - public partial class MemoryProgress : IProgress, INotifyPropertyChanged { - public double ProgressPercentage { get; protected set; } - public string TaskName { get; protected set; } - - public MemoryProgress(string? taskName = null, double progress = 0) { - TaskName = taskName ?? ""; - ProgressPercentage = progress; - } - - public void Report(double value) { - ProgressPercentage = Math.Clamp(value, 0.00, 100.00); - } - } - - public class AccumulatedMemoryProgress : MemoryProgress { - private readonly SynchronizationContext? _synchronizationContext; - - // average the progress of sub-percentages - private double CalcAggregatePercentage() => - !Progresses.Any() - ? 100.00 - : Progresses.ToList() - .Sum(mp => mp.ProgressPercentage) - / Progresses.Count; - - public ObservableCollection Progresses { get; private set; } - - public AccumulatedMemoryProgress(IEnumerable? memoryProgress = null, string? taskName = null, SynchronizationContext? synchronizationContext = null) : base(taskName) { - _synchronizationContext = synchronizationContext ?? SynchronizationContext.Current ?? UISynchronizationContext.Context; - Progresses = new ObservableCollection(memoryProgress ?? new List()); - Progresses.ToList().ForEach(BindTo); - ProgressPercentage = CalcAggregatePercentage(); - } - - public void AlsoTrack(MemoryProgress memoryProgress) { - void AddProgress() { - Progresses.Add(memoryProgress); - ProgressPercentage = CalcAggregatePercentage(); - } - - // Console hosts have no UI SynchronizationContext. Keep the UI's - // existing dispatch behavior, but track synchronously when headless. - if (_synchronizationContext is null) AddProgress(); - else _synchronizationContext.Post(_ => AddProgress(), null); - - BindTo(memoryProgress); - } - - private void OnProgressPropertyChanged(object? sender, PropertyChangedEventArgs e) { - if (e.PropertyName == nameof(ProgressPercentage)) { - ProgressPercentage = CalcAggregatePercentage(); - } - } - - private void BindTo(MemoryProgress mp) { - mp.PropertyChanged += OnProgressPropertyChanged; - } - } +using PropertyChanged; +using System.Collections.ObjectModel; +using System.ComponentModel; + +namespace UnchainedLauncher.Core.Utilities { + [AddINotifyPropertyChangedInterface] + public partial class MemoryProgress : IProgress, INotifyPropertyChanged { + public double ProgressPercentage { get; protected set; } + public string TaskName { get; protected set; } + + public MemoryProgress(string? taskName = null, double progress = 0) { + TaskName = taskName ?? ""; + ProgressPercentage = progress; + } + + public void Report(double value) { + ProgressPercentage = Math.Clamp(value, 0.00, 100.00); + } + } + + public class AccumulatedMemoryProgress : MemoryProgress { + private readonly SynchronizationContext? _synchronizationContext; + private readonly object _progressLock = new(); + + // average the progress of sub-percentages + private double CalcAggregatePercentage() { + lock (_progressLock) { + return Progresses.Count == 0 + ? 100.00 + : Progresses.Sum(mp => mp.ProgressPercentage) / Progresses.Count; + } + } + + public ObservableCollection Progresses { get; private set; } + + public AccumulatedMemoryProgress(IEnumerable? memoryProgress = null, string? taskName = null, SynchronizationContext? synchronizationContext = null) : base(taskName) { + _synchronizationContext = synchronizationContext ?? SynchronizationContext.Current ?? UISynchronizationContext.Context; + Progresses = new ObservableCollection(memoryProgress ?? new List()); + Progresses.ToList().ForEach(BindTo); + ProgressPercentage = CalcAggregatePercentage(); + } + + public void AlsoTrack(MemoryProgress memoryProgress) { + void AddProgress() { + lock (_progressLock) { + Progresses.Add(memoryProgress); + ProgressPercentage = CalcAggregatePercentage(); + } + } + + // Console hosts have no UI SynchronizationContext. Keep the UI's + // existing dispatch behavior, but track synchronously when headless. + if (_synchronizationContext is null) AddProgress(); + else _synchronizationContext.Post(_ => AddProgress(), null); + + BindTo(memoryProgress); + } + + private void OnProgressPropertyChanged(object? sender, PropertyChangedEventArgs e) { + if (e.PropertyName == nameof(ProgressPercentage)) { + lock (_progressLock) { + ProgressPercentage = CalcAggregatePercentage(); + } + } + } + + private void BindTo(MemoryProgress mp) { + mp.PropertyChanged += OnProgressPropertyChanged; + } + } } \ No newline at end of file From 733c5bbc11f51a4695f329d34d15f6944a4d0e64 Mon Sep 17 00:00:00 2001 From: Jacob Barber Date: Sat, 10 Oct 2026 22:04:47 -0500 Subject: [PATCH 04/58] Add typed client and server CLI configuration with System.CommandLine --- README.md | 6 +- .../CommandLineOptionsTests.cs | 56 +++ .../LaunchCompositionTests.cs | 42 +++ .../LocalAddressDetectorTests.cs | 36 ++ .../RegistryAndReleaseTests.cs | 32 +- .../ServerOptionsTests.cs | 103 ++++-- .../YamlConfigurationTests.cs | 318 ++++++++++++++++++ UnchainedLauncher.CLI/CliLaunchOptions.cs | 144 ++++++++ UnchainedLauncher.CLI/CommandLineOptions.cs | 157 +++++++++ .../InstallationTypeYamlConverter.cs | 25 ++ UnchainedLauncher.CLI/LaunchConfiguration.cs | 91 +++++ UnchainedLauncher.CLI/LocalAddressDetector.cs | 32 ++ UnchainedLauncher.CLI/Program.cs | 55 +-- UnchainedLauncher.CLI/README.md | 119 ++++++- UnchainedLauncher.CLI/RegistrySpec.cs | 7 +- UnchainedLauncher.CLI/ServerLauncher.cs | 34 +- UnchainedLauncher.CLI/ServerOptions.cs | 173 ---------- .../UnchainedLauncher.CLI.csproj | 2 + UnchainedLauncher.CLI/YamlConfiguration.cs | 37 ++ UnchainedLauncher.CLI/examples/client.yaml | 14 + UnchainedLauncher.CLI/examples/server.yaml | 46 +++ 21 files changed, 1268 insertions(+), 261 deletions(-) create mode 100644 UnchainedLauncher.CLI.Tests/CommandLineOptionsTests.cs create mode 100644 UnchainedLauncher.CLI.Tests/LaunchCompositionTests.cs create mode 100644 UnchainedLauncher.CLI.Tests/LocalAddressDetectorTests.cs create mode 100644 UnchainedLauncher.CLI.Tests/YamlConfigurationTests.cs create mode 100644 UnchainedLauncher.CLI/CliLaunchOptions.cs create mode 100644 UnchainedLauncher.CLI/CommandLineOptions.cs create mode 100644 UnchainedLauncher.CLI/InstallationTypeYamlConverter.cs create mode 100644 UnchainedLauncher.CLI/LaunchConfiguration.cs create mode 100644 UnchainedLauncher.CLI/LocalAddressDetector.cs delete mode 100644 UnchainedLauncher.CLI/ServerOptions.cs create mode 100644 UnchainedLauncher.CLI/YamlConfiguration.cs create mode 100644 UnchainedLauncher.CLI/examples/client.yaml create mode 100644 UnchainedLauncher.CLI/examples/server.yaml diff --git a/README.md b/README.md index cffbc2b3..07c56e0e 100644 --- a/README.md +++ b/README.md @@ -70,11 +70,11 @@ ignored. Only enable publishers you trust: these checks do not make native code Inactive managed DLLs are cached outside `Plugins` and restored when selected. Unknown filenames and unregistered versions stay where they are. -## Server CLI +## Launcher CLI -`UnchainedLauncher.CLI` is a server-only console application that depends on Core, not WPF. +`UnchainedLauncher.CLI` launches clients or servers and depends on Core, not WPF. See [the CLI guide](UnchainedLauncher.CLI/README.md) for building, launch options, -registry formats, and the live server dashboard. +registry formats, YAML configuration, and the live server dashboard. ## License diff --git a/UnchainedLauncher.CLI.Tests/CommandLineOptionsTests.cs b/UnchainedLauncher.CLI.Tests/CommandLineOptionsTests.cs new file mode 100644 index 00000000..8e6872db --- /dev/null +++ b/UnchainedLauncher.CLI.Tests/CommandLineOptionsTests.cs @@ -0,0 +1,56 @@ +using Xunit; + +namespace UnchainedLauncher.CLI.Tests; + +public class CommandLineOptionsTests { + [Theory] + [InlineData("server")] + [InlineData("client")] + public void GeneratedHelpIsSpecificToTheCommand(string command) { + var options = CliLaunchOptions.Parse([command, "--help"]); + Assert.True(options.Help); + Assert.Contains("--chivalry2-path", options.HelpText); + Assert.Contains("--config-path", options.HelpText); + Assert.Equal(command == "server", options.HelpText.Contains("--no-desync-patch")); + Assert.Equal(command == "server", options.HelpText.Contains("--discord-bot-token")); + } + + [Fact] + public void RootHelpListsBothCommands() { + var options = CliLaunchOptions.Parse([]); + Assert.True(options.Help); + Assert.Contains("server", options.HelpText); + Assert.Contains("client", options.HelpText); + } + + [Theory] + [InlineData("--no-desync-patch")] + [InlineData("--name=test")] + [InlineData("--game-port=7778")] + [InlineData("--no-tui")] + public void ServerSpecificOptionsAreRejectedByClient(string flag) { + Assert.Throws(() => CliLaunchOptions.Parse([ + "client", "--chivalry2-path", ".", "--platform", "Steam", flag + ])); + } + + [Theory] + [InlineData("--no-desync-patch")] + [InlineData("--no-plugin-update")] + [InlineData("--name=test")] + public void SingletonOptionsCannotBeRepeated(string flag) { + Assert.Throws(() => CliLaunchOptions.Parse([ + "server", "--chivalry2-path", ".", "--platform", "Steam", flag, flag + ])); + } + + [Fact] + public void GameArgumentsMustFollowSeparatorAndArePreservedExactly() { + string[] prefix = ["server", "--chivalry2-path", ".", "--platform", "Steam"]; + Assert.Throws(() => CliLaunchOptions.Parse([.. prefix, "unexpected"])); + string[] forwarded = ["--help", "--config-path", "not-a-launcher-config", "-AUTH_PASSWORD=with spaces", "", "@game-file"]; + var options = CliLaunchOptions.Parse([.. prefix, "--", .. forwarded]); + Assert.False(options.Help); + Assert.Equal(forwarded, options.GameArguments); + } +} diff --git a/UnchainedLauncher.CLI.Tests/LaunchCompositionTests.cs b/UnchainedLauncher.CLI.Tests/LaunchCompositionTests.cs new file mode 100644 index 00000000..a66c5052 --- /dev/null +++ b/UnchainedLauncher.CLI.Tests/LaunchCompositionTests.cs @@ -0,0 +1,42 @@ +using UnchainedLauncher.Core.JsonModels; +using Xunit; + +namespace UnchainedLauncher.CLI.Tests; + +public class LaunchCompositionTests { + [Theory] + [InlineData("server")] + [InlineData("client")] + public void BothLaunchTypesComposeSharedSettingsAndUseCorePlatform(string command) { + var options = CliLaunchOptions.Parse([ + command, "--chivalry2-path", ".", "--platform", "EGS", "--saved-dir-suffix=Common", + "--no-plugin-update", "--allow-unstable-plugin", "--mod=org/test:v1", + "--dll=org/plugin:v2", "--add-registry=github:owner/repo", "--", "-log" + ]); + Assert.Equal(command == "server", options is ServerOptions); + Assert.Equal(command == "client", options is ClientOptions); + var shared = Assert.IsType(options.Shared); + Assert.Equal(InstallationType.EpicGamesStore, shared.Platform); + Assert.Equal("Common", shared.SavedDirSuffix); + Assert.False(shared.UpdatePlugin); + Assert.True(shared.AllowUnstablePlugin); + Assert.Single(shared.Mods); + Assert.Single(shared.Dlls); + Assert.Single(shared.Registries); + Assert.Equal(new[] { "-log" }, shared.GameArguments); + var core = options.ToLaunchOptions(); + Assert.False(core.CheckForDependencyUpdates); + Assert.True(core.AllowUnstablePluginReleases); + Assert.Equal(command == "server", core.ServerLaunchOptions.IsSome); + } + + [Fact] + public void ServerHasNoClientModeFlagAndClientHasNoServerSettings() { + Assert.Null(typeof(ServerOptions).GetProperty("Client")); + Assert.Null(typeof(ClientOptions).GetProperty("Settings")); + Assert.Null(typeof(ClientOptions).GetMethod("ToServerLaunchOptions")); + Assert.Null(typeof(ServerOptions).GetProperty("UpdatePlugin")); + Assert.Null(typeof(ClientOptions).GetProperty("UpdatePlugin")); + Assert.Equal(typeof(InstallationType), typeof(SharedLaunchOptions).GetProperty("Platform")!.PropertyType); + } +} diff --git a/UnchainedLauncher.CLI.Tests/LocalAddressDetectorTests.cs b/UnchainedLauncher.CLI.Tests/LocalAddressDetectorTests.cs new file mode 100644 index 00000000..18ae5a9c --- /dev/null +++ b/UnchainedLauncher.CLI.Tests/LocalAddressDetectorTests.cs @@ -0,0 +1,36 @@ +using System.Net; +using Xunit; + +namespace UnchainedLauncher.CLI.Tests; + +public class LocalAddressDetectorTests { + [Fact] + public void PrefersUsableDefaultGatewayAddressOverOtherInterfaces() { + var candidates = new[] { + (IPAddress.Parse("172.17.0.1"), false), + (IPAddress.Parse("192.168.1.50"), true), + (IPAddress.Loopback, true), + (IPAddress.IPv6Loopback, true) + }; + Assert.Equal(IPAddress.Parse("192.168.1.50"), LocalAddressDetector.Select(candidates)); + } + + [Fact] + public void FallsBackToAnActiveIpv4OrLoopbackWithoutGateway() { + Assert.Equal(IPAddress.Parse("10.0.0.2"), LocalAddressDetector.Select([(IPAddress.Parse("10.0.0.2"), false)])); + Assert.Equal(IPAddress.Loopback, LocalAddressDetector.Select([ + (IPAddress.Any, true), (IPAddress.Parse("169.254.1.2"), true), (IPAddress.IPv6Any, true) + ])); + Assert.Equal(IPAddress.Loopback, LocalAddressDetector.Select([])); + } + + [Fact] + public void ExplicitOverrideIsNotReplacedAndClientDoesNotGetServerLocalIp() { + var server = CliLaunchOptions.Parse(["server", "--chivalry2-path", ".", "--platform", "Steam", "--local-ip=192.168.50.10"]); + Assert.Equal("192.168.50.10", Assert.IsType(server).Settings.LocalIp); + Assert.Contains("--local-ip 192.168.50.10", Assert.IsType(server).ToServerLaunchOptions().ToCLIArgs().Select(arg => arg.Rendered)); + var client = CliLaunchOptions.Parse(["client", "--chivalry2-path", ".", "--platform", "Steam"]); + Assert.IsType(client); + Assert.True(client.ToLaunchOptions().ServerLaunchOptions.IsNone); + } +} diff --git a/UnchainedLauncher.CLI.Tests/RegistryAndReleaseTests.cs b/UnchainedLauncher.CLI.Tests/RegistryAndReleaseTests.cs index 4514dbc3..0488aa7b 100644 --- a/UnchainedLauncher.CLI.Tests/RegistryAndReleaseTests.cs +++ b/UnchainedLauncher.CLI.Tests/RegistryAndReleaseTests.cs @@ -126,7 +126,7 @@ await Task.Run(async () => { await registry.AddRelease(release, source); var manager = new ModManager(registry, new PakDir(paks, []), []); Assert.False((await manager.UpdateModsList()).HasErrors); - var options = ServerOptions.Parse(["server", "--chivalry2-path", _directory, "--platform", "Steam"]).ToLaunchOptions(); + var options = CliLaunchOptions.Parse(["server", "--chivalry2-path", _directory, "--platform", "Steam"]).ToLaunchOptions(); var dialogue = new ConsoleDialogue(); Assert.True((await new Chivalry2ModsInstaller(manager, dialogue).PrepareLaunch(options)).IsSome); var registryPak = Path.Combine(registry.RegistryPath, release.Info.Organization, release.Info.RepoName, @@ -145,8 +145,10 @@ await Task.Run(async () => { }); } - [Fact] - public async Task ProductionWiringInstallsManyModsAndForwardsActorsFromAnotherDirectory() { + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ProductionWiringInstallsManyModsAndForwardsActorsFromAnotherDirectory(bool useYaml) { var callerDirectory = Directory.GetCurrentDirectory(); var install = Path.Combine(_directory, "arbitrary game installation"); var paks = Path.Combine(install, FilePaths.PakDir); @@ -167,7 +169,19 @@ public async Task ProductionWiringInstallsManyModsAndForwardsActorsFromAnotherDi if (i == 0) await registry.AddRelease(Common().Releases.Single() with { ReleaseHash = hash }, source); args.AddRange(["--add-registry", "local:" + registryPath, "--mod", $"org/mod{i}:v1.0.0", "--server-mod", actors[i]]); } - var options = ServerOptions.Parse(args.ToArray()); + var options = CliLaunchOptions.Parse(args.ToArray()); + if (useYaml) { + string Quote(string value) => "'" + value.Replace("'", "''") + "'"; + var yaml = new List { "chiv2-path: " + Quote(install), "platform: Steam", "mod-setup:", " registries:" }; + yaml.AddRange(options.Shared.Registries.Select(r => " - " + Quote(r.Kind + ":" + r.Location))); + yaml.Add(" mods:"); + yaml.AddRange(options.Shared.Mods.Select(m => " - " + Quote($"{m.Org}/{m.ModuleName}:{m.Version}"))); + yaml.AddRange(["server:", " mod-actors:"]); + yaml.AddRange(actors.Select(actor => " - " + Quote(actor))); + var config = Path.Combine(_directory, "launch.yaml"); + File.WriteAllLines(config, yaml); + options = CliLaunchOptions.Parse(["server", "--config-path", config]); + } var aggregate = ServerLauncher.CreateRegistry(options); var official = Assert.Single(aggregate.ModRegistries.OfType()); Assert.Equal(("Chiv2-Community", "C2ModRegistry"), (official.Organization, official.RepoName)); @@ -175,9 +189,9 @@ public async Task ProductionWiringInstallsManyModsAndForwardsActorsFromAnotherDi // Keep metadata deterministic/offline; assert the official production entry above, // then use all real local registries created by the production factory for installs. var offline = new AggregateModRegistry(aggregate.ModRegistries.OfType().Cast().ToArray()); - var manager = new ModManager(offline, new PakDir(paks, []), options.Mods); + var manager = new ModManager(offline, new PakDir(paks, []), options.Shared.Mods); Assert.False((await manager.UpdateModsList()).HasErrors); - ServerLauncher.ValidateReleases(manager, options.Mods); + ServerLauncher.ValidateReleases(manager, options.Shared.Mods); var process = new CapturingProcessLauncher(); await ServerLauncher.InInstallationDirectoryAsync(options, async () => { Assert.Equal(install, Directory.GetCurrentDirectory()); @@ -191,7 +205,7 @@ await ServerLauncher.InInstallationDirectoryAsync(options, async () => { return prepared; }); // Capture at the OS process boundary; retain Core preparation/argument rendering. - var launcher = new UnchainedChivalry2Launcher(preparer, process, options.Chivalry2Path, new NullInjector()); + var launcher = new UnchainedChivalry2Launcher(preparer, process, options.Shared.Chivalry2Path, new NullInjector()); return await launcher.Launch(options.ToLaunchOptions()); }); Assert.Equal(callerDirectory, Directory.GetCurrentDirectory()); @@ -218,7 +232,7 @@ await ServerLauncher.InInstallationDirectoryAsync(options, async () => { [InlineData(true)] public async Task ProductionDirectoryScopeRestoresCallerAfterFailureOrCancellation(bool cancel) { var callerDirectory = Directory.GetCurrentDirectory(); - var options = ServerOptions.Parse(["server", "--chivalry2-path", _directory, "--platform", "Steam"]); + var options = CliLaunchOptions.Parse(["server", "--chivalry2-path", _directory, "--platform", "Steam"]); await Assert.ThrowsAnyAsync(() => ServerLauncher.InInstallationDirectoryAsync(options, () => { Assert.Equal(_directory, Directory.GetCurrentDirectory()); if (cancel) throw new OperationCanceledException(); @@ -240,4 +254,4 @@ public Either Launch(string workingDirectory, string args } [CollectionDefinition("Installation directory", DisableParallelization = true)] -public class InstallationDirectoryCollection { } \ No newline at end of file +public class InstallationDirectoryCollection { } diff --git a/UnchainedLauncher.CLI.Tests/ServerOptionsTests.cs b/UnchainedLauncher.CLI.Tests/ServerOptionsTests.cs index fd19f1c0..b0e606e9 100644 --- a/UnchainedLauncher.CLI.Tests/ServerOptionsTests.cs +++ b/UnchainedLauncher.CLI.Tests/ServerOptionsTests.cs @@ -1,4 +1,5 @@ using UnchainedLauncher.Core.Services.Mods.Registry; +using UnchainedLauncher.Core.JsonModels; using Xunit; namespace UnchainedLauncher.CLI.Tests; @@ -9,10 +10,14 @@ private static string[] Args(params string[] extra) => [Fact] public void DefaultsProduceHeadlessServerLaunch() { - var options = ServerOptions.Parse(Args()); + var options = Assert.IsType(CliLaunchOptions.Parse(Args())); var server = options.ToServerLaunchOptions(); Assert.True(server.Headless); Assert.False(server.RegisterWithBackend); + Assert.True(server.DesyncPatch); + Assert.True(server.UseBackendBanlist); + Assert.NotNull(options.Settings.LocalIp); + Assert.Equal(options.Settings.LocalIp, server.LocalIp.IfNone("")); Assert.Equal(7777, server.GamePort); Assert.Equal(7071, server.QueryPort); Assert.Equal("FFA_Courtyard", server.Map); @@ -22,31 +27,32 @@ public void DefaultsProduceHeadlessServerLaunch() { } [Theory] - [InlineData("Steam", GamePlatform.Steam)] - [InlineData("steam", GamePlatform.Steam)] - [InlineData("EGS", GamePlatform.EGS)] - public void AcceptsPlatforms(string platform, GamePlatform expected) { - Assert.Equal(expected, ServerOptions.Parse(["server", "--chivalry2-path=.", $"--platform={platform}"]).Platform); + [InlineData("Steam", InstallationType.Steam)] + [InlineData("steam", InstallationType.Steam)] + [InlineData("EGS", InstallationType.EpicGamesStore)] + [InlineData("EpicGamesStore", InstallationType.EpicGamesStore)] + public void AcceptsPlatforms(string platform, InstallationType expected) { + Assert.Equal(expected, CliLaunchOptions.Parse(["server", "--chivalry2-path=.", $"--platform={platform}"]).Shared.Platform); } [Fact] public void RepeatedCoordinatesRegistriesAndActorsAreAccepted() { - var options = ServerOptions.Parse(Args("--mod", "org/one:v1.2.3", "--mod=org/two:release-tag", + var options = CliLaunchOptions.Parse(Args("--mod", "org/one:v1.2.3", "--mod=org/two:release-tag", "--dll", "org/plugin:v2", "--add-registry", "github:owner/repo", "--add-registry", "local:my registry", "--add-registry", "config:registries.json", "--server-mod", "/Game/Mods/Test.Test_C", "--server-mod", "/Game/Mods/Other.Other_C")); - Assert.Equal(2, options.Mods.Count); - Assert.Single(options.Dlls); - Assert.Equal(3, options.Registries.Count); - Assert.Equal(2, options.ToServerLaunchOptions().ServerMods.Count()); - Assert.Equal("v1.2.3", options.Mods[0].Version); - Assert.True(Path.IsPathFullyQualified(options.Registries[1].Location)); + Assert.Equal(2, options.Shared.Mods.Count); + Assert.Single(options.Shared.Dlls); + Assert.Equal(3, options.Shared.Registries.Count); + Assert.Equal(2, Assert.IsType(options).ToServerLaunchOptions().ServerMods.Count()); + Assert.Equal("v1.2.3", options.Shared.Mods[0].Version); + Assert.True(Path.IsPathFullyQualified(options.Shared.Registries[1].Location)); } [Fact] public void GameArgumentsAreEscapedAndNotParsedAsLauncherOptions() { - var options = ServerOptions.Parse(Args("--", "-AUTH_PASSWORD=secret with spaces", "--mod", "game value")); - Assert.Empty(options.Mods); + var options = CliLaunchOptions.Parse(Args("--", "-AUTH_PASSWORD=secret with spaces", "--mod", "game value")); + Assert.Empty(options.Shared.Mods); Assert.Equal(3, options.GameArguments.Count); Assert.Contains("\"-AUTH_PASSWORD=secret with spaces\"", options.ToLaunchOptions().LaunchArgs); Assert.Contains("\"game value\"", options.ToLaunchOptions().LaunchArgs); @@ -54,11 +60,11 @@ public void GameArgumentsAreEscapedAndNotParsedAsLauncherOptions() { [Fact] public void ServerFieldsAreMappedToCoreOptions() { - var options = ServerOptions.Parse(Args("--name", "Test Server", "--description", "Test MOTD", "--map", "TO_Test", + var options = CliLaunchOptions.Parse(Args("--name", "Test Server", "--description", "Test MOTD", "--map", "TO_Test", "--password", "secret", "--local-ip", "127.0.0.2", "--game-port", "10001", "--ping-port", "10002", "--query-port", "10003", "--rcon-port", "10004", "--register", "--no-tui", "--no-plugin-update", "--allow-unstable-plugin", "--server-browser-backend", "https://example.test", "--saved-dir-suffix", "Test_1")); - var server = options.ToServerLaunchOptions(); + var server = Assert.IsType(options).ToServerLaunchOptions(); Assert.Equal("Test Server", server.Name); Assert.Equal("Test MOTD", server.Description); Assert.Equal("TO_Test", server.Map); @@ -66,7 +72,7 @@ public void ServerFieldsAreMappedToCoreOptions() { Assert.Equal("127.0.0.2", server.LocalIp.IfNone("")); Assert.Equal((10001, 10002, 10003, 10004), (server.GamePort, server.BeaconPort, server.QueryPort, server.RconPort)); Assert.True(server.RegisterWithBackend); - Assert.True(options.NoTui); + Assert.True(Assert.IsType(options).NoTui); Assert.False(options.ToLaunchOptions().CheckForDependencyUpdates); Assert.True(options.ToLaunchOptions().AllowUnstablePluginReleases); } @@ -85,31 +91,34 @@ public void ServerFieldsAreMappedToCoreOptions() { [InlineData("--add-registry", "github:bad")] [InlineData("--add-registry", "http:unsupported")] [InlineData("--name", " ")] + [InlineData("--headless", "yes")] + [InlineData("--censor-mode", "invalid")] + [InlineData("--discord-mention-admins", "maybe")] public void RejectsInvalidValues(string key, string value) { - Assert.Throws(() => ServerOptions.Parse(Args(key, value))); + Assert.Throws(() => CliLaunchOptions.Parse(Args(key, value))); } [Fact] public void RejectsMissingRequiredOptionsAndUnknownFlags() { - Assert.Throws(() => ServerOptions.Parse(["server"])); - Assert.Throws(() => ServerOptions.Parse(["server", "--chivalry2-path", "."])); - Assert.Throws(() => ServerOptions.Parse(Args("--typo"))); - Assert.Throws(() => ServerOptions.Parse(Args("--map"))); - Assert.Throws(() => ServerOptions.Parse(Args("--no-tui=true"))); - Assert.Throws(() => ServerOptions.Parse(["client"])); + Assert.Throws(() => CliLaunchOptions.Parse(["server"])); + Assert.Throws(() => CliLaunchOptions.Parse(["server", "--chivalry2-path", "."])); + Assert.Throws(() => CliLaunchOptions.Parse(Args("--typo"))); + Assert.Throws(() => CliLaunchOptions.Parse(Args("--map"))); + Assert.Throws(() => CliLaunchOptions.Parse(Args("--no-tui=true"))); + Assert.Throws(() => CliLaunchOptions.Parse(["client"])); } [Fact] public void RejectsConflictingModVersionsAndDuplicateScalarOptions() { - Assert.Throws(() => ServerOptions.Parse(Args("--mod", "org/mod:v1", "--mod", "org/mod:v2"))); - Assert.Throws(() => ServerOptions.Parse(Args("--map", "one", "--map", "two"))); + Assert.Throws(() => CliLaunchOptions.Parse(Args("--mod", "org/mod:v1", "--mod", "org/mod:v2"))); + Assert.Throws(() => CliLaunchOptions.Parse(Args("--map", "one", "--map", "two"))); } [Fact] public void HelpDoesNotRequireInstallation() { - Assert.True(ServerOptions.Parse([]).Help); - Assert.True(ServerOptions.Parse(["--help"]).Help); - Assert.True(ServerOptions.Parse(["server", "--help"]).Help); + Assert.True(CliLaunchOptions.Parse([]).Help); + Assert.True(CliLaunchOptions.Parse(["--help"]).Help); + Assert.True(CliLaunchOptions.Parse(["server", "--help"]).Help); } [Fact] @@ -118,4 +127,36 @@ public void GitHubRegistryCreatesCoreImplementation() { Assert.Equal("owner", registry.Organization); Assert.Equal("repo", registry.RepoName); } -} \ No newline at end of file + + [Fact] + public void ExtendedServerArgumentsAreMappedToCore() { + var options = CliLaunchOptions.Parse(Args("--headless=false", + "--censor-mode=standard", "--discord-bot-token=token", + "--discord-general-channel-id=123", "--discord-mention-admins=false")); + var server = Assert.IsType(options).ToServerLaunchOptions(); + Assert.False(server.Headless); + Assert.True(server.DesyncPatch); + Assert.True(server.UseBackendBanlist); + Assert.Equal(Core.Services.Processes.Chivalry.CensorArg.Standard, server.CensorMode); + Assert.Null(server.FFATimeLimit); + Assert.Null(server.PlayerBotCount); + Assert.True(server.DiscordIntegration.IsSome); + Assert.Contains("--discord-mention-admins false", server.ToCLIArgs().Select(arg => arg.Rendered)); + } + + [Fact] + public void DefaultEnabledServerFlagsCanBeDisabledOnCommandLine() { + var options = CliLaunchOptions.Parse(Args("--no-desync-patch", "--no-backend-banlist")); + Assert.False(Assert.IsType(options).ToServerLaunchOptions().DesyncPatch); + Assert.False(Assert.IsType(options).ToServerLaunchOptions().UseBackendBanlist); + } + + [Theory] + [InlineData("--no-desync-patch=false")] + [InlineData("--no-backend-banlist=true")] + [InlineData("--desync-patch")] + [InlineData("--use-backend-banlist")] + public void DisableOptionsAreValueLessFlags(string flag) { + Assert.Throws(() => CliLaunchOptions.Parse(Args(flag))); + } +} diff --git a/UnchainedLauncher.CLI.Tests/YamlConfigurationTests.cs b/UnchainedLauncher.CLI.Tests/YamlConfigurationTests.cs new file mode 100644 index 00000000..fcda535a --- /dev/null +++ b/UnchainedLauncher.CLI.Tests/YamlConfigurationTests.cs @@ -0,0 +1,318 @@ +using System.Diagnostics; +using UnchainedLauncher.Core.JsonModels; +using UnchainedLauncher.Core.Services.Mods.Registry; +using Xunit; + +namespace UnchainedLauncher.CLI.Tests; + +[Collection("Installation directory")] +public class YamlConfigurationTests : IDisposable { + private readonly string _directory = Path.Combine( + OperatingSystem.IsLinux() ? "/tmp/opencode" : Path.GetTempPath(), "yaml-cli-tests-" + Guid.NewGuid().ToString("N")); + + public YamlConfigurationTests() => Directory.CreateDirectory(_directory); + public void Dispose() => Directory.Delete(_directory, recursive: true); + + private string Write(string body, bool includeRequired = true) { + var path = Path.Combine(_directory, "launch.yaml"); + File.WriteAllText(path, (includeRequired ? "chiv2-path: './game installation'\nplatform: Steam\n" : "") + body); + return path; + } + + private CliLaunchOptions Load(string body, bool client = false) => + CliLaunchOptions.Parse([client ? "client" : "server", "--config-path", Write(body)]); + + [Fact] + public void ConfigurationIsAuthoritativeWithEitherConfigPathSyntax() { + var path = Write("server:\n name: From YAML\n"); + foreach (var args in new[] { + new[] { "server", "--typo", "--name", "Ignored", "--config-path", path, "--", "-ignored-game-arg" }, + new[] { "server", "--config-path=" + path, "--platform=Invalid", "--help" } + }) { + var options = CliLaunchOptions.Parse(args); + Assert.IsType(options); + Assert.False(options.Help); + Assert.Equal("From YAML", Assert.IsType(options).Settings.Name); + Assert.Empty(options.GameArguments); + } + } + + [Fact] + public void ConfigPathAfterGameSeparatorIsForwardedNotLoaded() { + var options = CliLaunchOptions.Parse(["server", "--chivalry2-path", _directory, "--platform", "Steam", "--", "--config-path", "game-file"]); + Assert.Equal(new[] { "--config-path", "game-file" }, options.GameArguments); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void SubcommandSelectsModeRegardlessOfIncludedBlocks(bool client) { + var options = Load("client: {}\nserver: {}\n", client); + Assert.Equal(client, options is ClientOptions); + Assert.Equal(client, options.ToLaunchOptions().ServerLaunchOptions.IsNone); + Assert.Equal(client ? "TBL" : "TBL -server", options.ToLaunchOptions().LaunchArgs); + Assert.Equal(!client, options.ToLaunchOptions().ToCLIArgs().Any(arg => arg.Rendered == "-nullrhi")); + } + + [Fact] + public void ExplicitClientIgnoresServerFieldsEvenWhenTheyAreInvalid() { + var options = Load(""" + client: + game-args: ['-client-only'] + game-args: ['-AUTH_PASSWORD=auth value'] + server: + game-port: not-a-port + headless: not-a-boolean + typo: whatever + mod-actors: not-a-list + discord: + bot-token: secret + general-channel-id: [] + game-args: ['-server'] + """, client: true); + Assert.IsType(options); + var launch = options.ToLaunchOptions(); + Assert.True(launch.ServerLaunchOptions.IsNone); + Assert.DoesNotContain("-server", launch.LaunchArgs); + Assert.Contains("\"-AUTH_PASSWORD=auth value\"", launch.LaunchArgs); + Assert.Contains("-client-only", launch.LaunchArgs); + } + + [Fact] + public void ServerIgnoresClientBlockAndEachCommandValidatesOnlyItsOwnSchema() { + var server = Load("client: not-a-mapping\nserver: {name: Correct}"); + Assert.Equal("Correct", Assert.IsType(server).Settings.Name); + Assert.Throws(() => Load("client: not-a-mapping", client: true)); + Assert.Throws(() => Load("client: {typo: invalid}", client: true)); + Assert.IsType(Load("server: not-a-mapping\nclient: {}", client: true)); + } + + [Fact] + public void ServerSafetyFlagsDefaultOnAndCanBeDisabledInYaml() { + var defaults = Assert.IsType(Load("server: {}")).ToServerLaunchOptions(); + Assert.True(defaults.DesyncPatch); + Assert.True(defaults.UseBackendBanlist); + Assert.True(defaults.LocalIp.IsSome); + var disabled = Assert.IsType(Load("server: {desync-patch: false, use-backend-banlist: false}")).ToServerLaunchOptions(); + Assert.False(disabled.DesyncPatch); + Assert.False(disabled.UseBackendBanlist); + Assert.DoesNotContain("--desync-patch", disabled.ToCLIArgs().Select(arg => arg.Rendered)); + Assert.DoesNotContain("--use-backend-banlist", disabled.ToCLIArgs().Select(arg => arg.Rendered)); + } + + [Theory] + [InlineData("ffa-time-limit")] + [InlineData("ffa-score-limit")] + [InlineData("tdm-time-limit")] + [InlineData("tdm-ticket-count")] + [InlineData("player-bot-count")] + [InlineData("warmup-time")] + public void IniFieldsAreNotExposedInYamlOrCommandLine(string field) { + Assert.Throws(() => Load($"server: {{{field}: 1}}")); + Assert.Throws(() => CliLaunchOptions.Parse(["server", "--chivalry2-path", ".", "--platform", "Steam", $"--{field}=1"])); + Assert.IsType(Load($"server: {{{field}: 1}}", client: true)); + } + + [Fact] + public void PathsAreRelativeToYamlFileWithoutChangingCwdAndDefaultRegistryRemains() { + var cwd = Directory.GetCurrentDirectory(); + Directory.CreateDirectory(Path.Combine(_directory, "local registry")); + var options = Load(""" + saved-dir-suffix: Test_1 + update-plugin: false + allow-unstable-plugin: true + no-tui: true + mod-setup: + registries: ['local:local registry', 'config:registries.json', 'github:owner/repo'] + mods: ['org/one:v1.2.3', 'org/two:v2.0.0'] + dlls: ['org/plugin:v1.0.0'] + """); + Assert.Equal(cwd, Directory.GetCurrentDirectory()); + Assert.Equal(Path.Combine(_directory, "game installation"), options.Shared.Chivalry2Path); + Assert.Equal(Path.Combine(_directory, "local registry"), options.Shared.Registries[0].Location); + Assert.Equal(Path.Combine(_directory, "registries.json"), options.Shared.Registries[1].Location); + Assert.Equal("owner/repo", options.Shared.Registries[2].Location); + Assert.Equal(2, options.Shared.Mods.Count); + Assert.Single(options.Shared.Dlls); + Assert.Equal("Test_1", options.Shared.SavedDirSuffix); + Assert.False(options.Shared.UpdatePlugin); + Assert.True(options.Shared.AllowUnstablePlugin); + Assert.True(Assert.IsType(options).NoTui); + var plain = Load("mod-setup: { registries: [], mods: [], dlls: [] }"); + var official = Assert.Single(ServerLauncher.CreateRegistry(plain).ModRegistries); + Assert.IsType(official); + Assert.Equal("Chiv2-Community", ((GithubModRegistry)official).Organization); + } + + [Fact] + public void AllServerAndDiscordFieldsMapToCoreAndRenderedArguments() { + var options = Load(""" + client: + typo: this ignored client field would be invalid in client mode + game-args: ['-client-only'] + server-browser-backend: https://example.test + game-args: ['-log'] + server: + headless: false + name: YAML Server + description: Example MOTD + password: private password + map: TO_Test + local-ip: 127.0.0.2 + game-port: 10001 + ping-port: 10002 + query-port: 10003 + rcon-port: 10004 + register: true + mod-actors: ['/Game/A.A_C', '/Game/B.B_C', '/Game/A.A_C'] + desync-patch: true + use-backend-banlist: true + censor-mode: zealous + game-args: ['-custom=value with spaces'] + discord: + bot-token: '--secret-token' + admin-channel-id: '123456789012345678' + general-channel-id: '234567890123456789' + dashboard-channel-id: '345678901234567890' + event-log-channel-id: '456789012345678901' + admin-role-id: '567890123456789012' + mention-admins: false + """); + var server = Assert.IsType(options).ToServerLaunchOptions(); + Assert.False(server.Headless); + Assert.Equal("YAML Server", server.Name); + Assert.Equal("Example MOTD", server.Description); + Assert.Equal("private password", server.Password.IfNone("")); + Assert.Equal("TO_Test", server.Map); + Assert.Equal("127.0.0.2", server.LocalIp.IfNone("")); + Assert.Equal((10001, 10002, 10003, 10004), (server.GamePort, server.BeaconPort, server.QueryPort, server.RconPort)); + Assert.True(server.RegisterWithBackend); + Assert.True(server.DesyncPatch); + Assert.True(server.UseBackendBanlist); + Assert.Equal(Core.Services.Processes.Chivalry.CensorArg.Zealous, server.CensorMode); + Assert.Null(server.FFATimeLimit); + Assert.Null(server.FFAScoreLimit); + Assert.Null(server.TDMTimeLimit); + Assert.Null(server.TDMTicketCount); + Assert.Null(server.PlayerBotCount); + Assert.Null(server.WarmupTime); + Assert.Equal(2, server.ServerMods.Count()); + var rendered = server.ToCLIArgs().Select(arg => arg.Rendered).ToArray(); + Assert.Contains("--server-mods /Game/A.A_C,/Game/B.B_C", rendered); + Assert.Contains("--desync-patch", rendered); + Assert.Contains("--use-backend-banlist", rendered); + Assert.Contains("--discord-bot-token --secret-token", rendered); + Assert.Contains("--discord-mention-admins false", rendered); + Assert.Contains("--discord-admin-channel-id 123456789012345678", rendered); + Assert.Contains("--discord-general-channel-id 234567890123456789", rendered); + Assert.Contains("--discord-dashboard-channel-id 345678901234567890", rendered); + Assert.Contains("--discord-event-log-channel-id 456789012345678901", rendered); + Assert.Contains("--discord-admin-role-id 567890123456789012", rendered); + Assert.DoesNotContain("-nullrhi", rendered); + Assert.Contains("-log", options.ToLaunchOptions().LaunchArgs); + Assert.Contains("\"-custom=value with spaces\"", options.ToLaunchOptions().LaunchArgs); + Assert.DoesNotContain("-client-only", options.ToLaunchOptions().LaunchArgs); + } + + [Theory] + [InlineData("typo: value")] + [InlineData("server: []")] + [InlineData("mod-setup: {registries: not-a-list}")] + [InlineData("mod-setup: {mods: [null]}")] + [InlineData("mod-setup: {mods: ['org/mod:v1', 'org/mod:v2']}")] + [InlineData("server: {query-port: 7777}")] + [InlineData("server: {game-port: 0}")] + [InlineData("server: {game-port: 1.5}")] + [InlineData("server: {headless: invalid}")] + [InlineData("server: {typo: true}")] + [InlineData("server: {censor-mode: invalid}")] + [InlineData("server: {warmup-time: -1}")] + [InlineData("server: {discord: {bot-token: secret}}")] + [InlineData("server: {discord: {general-channel-id: '123'}}")] + [InlineData("server: {name: one, name: two}")] + [InlineData("game-args: {invalid: mapping}")] + public void InvalidConfigurationFailsBeforeLaunching(string body) { + Assert.Throws(() => Load(body)); + } + + [Fact] + public void InvalidYamlMissingFilesAndDocumentsFailWithoutExposingSecrets() { + var malformed = Write("server:\n password: TOP-SECRET\n discord: [\n"); + var error = Assert.Throws(() => CliLaunchOptions.Parse(["server", "--config-path", malformed])); + Assert.DoesNotContain("TOP-SECRET", error.Message); + Assert.Throws(() => YamlConfiguration.Load(Path.Combine(_directory, "missing.yaml"), false)); + Assert.Throws(() => YamlConfiguration.Load(Write("", includeRequired: false), false)); + Assert.Throws(() => YamlConfiguration.Load(Write("---\nchiv2-path: .\nplatform: Steam\n"), false)); + Assert.Throws(() => YamlConfiguration.Load(Write("platform: Steam", includeRequired: false), false)); + Assert.Throws(() => YamlConfiguration.Load(Write("chiv2-path: .", includeRequired: false), false)); + Assert.Throws(() => CliLaunchOptions.Parse(["server", "--config-path"])); + Assert.Throws(() => CliLaunchOptions.Parse(["server", "--config-path="])); + Assert.Throws(() => CliLaunchOptions.Parse(["server", "--config-path=x", "--config-path=y"])); + Assert.Throws(() => CliLaunchOptions.Parse(["--config-path", Write("")])); + } + + [Fact] + public void DirectClientCommandHasNoServerOptions() { + var options = CliLaunchOptions.Parse(["client", "--chivalry2-path", _directory, "--platform", "EGS"]); + Assert.IsType(options); + Assert.True(options.ToLaunchOptions().ServerLaunchOptions.IsNone); + Assert.Equal("TBL", options.ToLaunchOptions().LaunchArgs); + } + + [Theory] + [InlineData("Steam", InstallationType.Steam)] + [InlineData("EGS", InstallationType.EpicGamesStore)] + [InlineData("EpicGamesStore", InstallationType.EpicGamesStore)] + public void YamlUsesCoreInstallationTypeAndPreservesEgsAlias(string platform, InstallationType expected) { + var path = Write($"chiv2-path: .\nplatform: {platform}\nupdate-plugin: false\nallow-unstable-plugin: true\n", includeRequired: false); + foreach (var command in new[] { "server", "client" }) { + var options = CliLaunchOptions.Parse([command, "--config-path", path]); + Assert.Equal(expected, options.Shared.Platform); + Assert.False(options.Shared.UpdatePlugin); + Assert.True(options.Shared.AllowUnstablePlugin); + } + } + + [Theory] + [InlineData("NotSet")] + [InlineData("1")] + [InlineData("Unknown")] + public void PlaceholderAndNumericPlatformsAreNotLaunchable(string platform) { + Assert.Throws(() => CliLaunchOptions.Parse(["client", "--chivalry2-path", ".", "--platform", platform])); + var path = Write($"chiv2-path: .\nplatform: {platform}\n", includeRequired: false); + Assert.Throws(() => CliLaunchOptions.Parse(["server", "--config-path", path])); + } + + [Fact] + public void EmptyOptionalServerFieldsAreAllowed() { + var options = Load("server: {description: '', password: '', local-ip: '', discord: {bot-token: '', general-channel-id: ''}}"); + Assert.Equal("", Assert.IsType(options).Settings.Description); + Assert.True(Assert.IsType(options).ToServerLaunchOptions().Password.IsNone); + Assert.True(Assert.IsType(options).ToDiscordOptions().IsNone); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task ProductionSupervisionLeavesClientRunningButStopsServer(bool client) { + var options = Load(client ? "client: {}\n" : "no-tui: true\n", client); + using var child = Process.Start(new ProcessStartInfo { + FileName = OperatingSystem.IsWindows() ? "cmd.exe" : "/bin/sleep", + Arguments = OperatingSystem.IsWindows() ? "/c ping -n 30 127.0.0.1 > nul" : "30", + UseShellExecute = false, + CreateNoWindow = true + })!; + using var stop = new CancellationTokenSource(); + stop.Cancel(); + try { + Assert.Equal(0, await Program.SuperviseAsync(options, child, stop)); + Assert.Equal(!client, child.HasExited); + } + finally { + if (!child.HasExited) { + child.Kill(entireProcessTree: true); + await child.WaitForExitAsync(); + } + } + } +} diff --git a/UnchainedLauncher.CLI/CliLaunchOptions.cs b/UnchainedLauncher.CLI/CliLaunchOptions.cs new file mode 100644 index 00000000..5cc43926 --- /dev/null +++ b/UnchainedLauncher.CLI/CliLaunchOptions.cs @@ -0,0 +1,144 @@ +using System.Net; +using System.Text.RegularExpressions; +using UnchainedLauncher.Core.JsonModels; +using UnchainedLauncher.Core.Services.Mods.Registry; +using UnchainedLauncher.Core.Services.Processes.Chivalry; +using UnchainedLauncher.Core.Utilities; +using static LanguageExt.Prelude; + +namespace UnchainedLauncher.CLI; + +/// Settings used by both client and server launches. +public sealed class SharedLaunchOptions { + public string Chivalry2Path { get; } + public string SavedDirSuffix { get; } + public InstallationType Platform { get; } + public string Backend { get; } + public bool UpdatePlugin { get; } + public bool AllowUnstablePlugin { get; } + public IReadOnlyList Registries { get; } + public IReadOnlyList Mods { get; } + public IReadOnlyList Dlls { get; } + public IReadOnlyList GameArguments { get; } + + internal SharedLaunchOptions(LaunchConfiguration configuration, string directory) { + Chivalry2Path = Path.GetFullPath(configuration.Chiv2Path!, directory); + SavedDirSuffix = configuration.SavedDirSuffix; + Platform = configuration.Platform!.Value; + Backend = configuration.ServerBrowserBackend; + UpdatePlugin = configuration.UpdatePlugin; + AllowUnstablePlugin = configuration.AllowUnstablePlugin; + GameArguments = Entries(configuration.GameArgs, allowEmpty: true).ToArray(); + var setup = configuration.ModSetup ?? new ModSetupConfiguration(); + Registries = Entries(setup.Registries).Select(value => RegistrySpec.Parse(value, directory)).ToArray(); + Mods = Entries(setup.Mods).Select(ParseCoordinates).ToArray(); + Dlls = Entries(setup.Dlls).Select(ParseCoordinates).ToArray(); + if (!Regex.IsMatch(SavedDirSuffix, @"\A[A-Za-z0-9_-]+\z")) + throw new ArgumentException("saved-dir-suffix may contain only letters, digits, underscores, and hyphens."); + if (!Uri.TryCreate(Backend, UriKind.Absolute, out var backend) || backend.Scheme is not ("http" or "https")) + throw new ArgumentException("server-browser-backend must be an HTTP(S) URL."); + if (Mods.GroupBy(m => (m.Org, m.ModuleName)).Any(g => g.Select(m => m.Version).Distinct().Count() > 1)) + throw new ArgumentException("Only one version of each mod may be selected."); + } + + internal static IEnumerable Entries(IEnumerable? values, bool allowEmpty = false) => + (values ?? []).Select(value => value is null || !allowEmpty && string.IsNullOrWhiteSpace(value) + ? throw new ArgumentException("Configuration lists must contain strings, not null or blank entries.") : value); + + public static ReleaseCoordinates ParseCoordinates(string value) { + var match = Regex.Match(value, @"\A([A-Za-z0-9_.-]+)/([A-Za-z0-9_.-]+):([^\s:/\\]+)\z"); + if (!match.Success) throw new ArgumentException($"Invalid coordinate '{value}'; expected organization/id:version (exact release tag)."); + return new ReleaseCoordinates(match.Groups[1].Value, match.Groups[2].Value, match.Groups[3].Value); + } +} + +public abstract class CliLaunchOptions { + private readonly SharedLaunchOptions? _shared; + public SharedLaunchOptions Shared => _shared ?? throw new InvalidOperationException("Help is not a launch request."); + public IReadOnlyList GameArguments { get; } + public bool Help { get; protected init; } + public string HelpText { get; protected init; } = ""; + + private protected CliLaunchOptions(SharedLaunchOptions shared, IEnumerable gameArguments) { + _shared = shared; + GameArguments = shared.GameArguments.Concat(gameArguments).ToArray(); + } + + private protected CliLaunchOptions(string helpText) { + Help = true; + HelpText = helpText; + GameArguments = []; + } + + public static CliLaunchOptions Parse(string[] args) => CommandLineOptions.Parse(args); + + internal static CliLaunchOptions FromConfiguration(LaunchConfiguration configuration, string directory) { + var shared = new SharedLaunchOptions(configuration, directory); + return configuration switch { + ServerConfiguration server => new ServerOptions(shared, server.Server ?? new ServerSettings(), server.NoTui), + ClientConfiguration client => new ClientOptions(shared, SharedLaunchOptions.Entries(client.Client?.GameArgs, allowEmpty: true)), + _ => throw new ArgumentException("Expected client or server configuration.") + }; + } + + public abstract LaunchOptions ToLaunchOptions(); + + private protected LaunchOptions BuildLaunchOptions(IEnumerable prefix, LanguageExt.Option server) => new( + EnabledReleases: Shared.Mods.Distinct(), ServerBrowserBackend: Some(Shared.Backend), + LaunchArgs: string.Join(" ", prefix.Concat(GameArguments.Select(ArgumentEscaper.Escape))), + CheckForDependencyUpdates: Shared.UpdatePlugin, AllowUnstablePluginReleases: Shared.AllowUnstablePlugin, + SavedDirSuffix: Some(Shared.SavedDirSuffix), ServerLaunchOptions: server); + + internal static CliLaunchOptions ForHelp(string text) => new HelpOptions(text); + + private sealed class HelpOptions : CliLaunchOptions { + // Help is a parser result, never a launch; shared settings are not constructed. + public HelpOptions(string text) : base(text) { } + public override LaunchOptions ToLaunchOptions() => throw new InvalidOperationException("Help is not a launch request."); + } +} + +public sealed class ClientOptions : CliLaunchOptions { + internal ClientOptions(SharedLaunchOptions shared, IEnumerable arguments) : base(shared, arguments) { } + public override LaunchOptions ToLaunchOptions() => BuildLaunchOptions(["TBL"], None); +} + +public sealed class ServerOptions : CliLaunchOptions { + public ServerSettings Settings { get; } + public bool NoTui { get; } + + internal ServerOptions(SharedLaunchOptions shared, ServerSettings settings, bool noTui) + : base(shared, SharedLaunchOptions.Entries(settings.GameArgs, allowEmpty: true)) { + Settings = settings; + NoTui = noTui; + if (new[] { settings.GamePort, settings.PingPort, settings.QueryPort, settings.RconPort }.Distinct().Count() != 4) + throw new ArgumentException("Game, ping, query, and RCON ports must be distinct."); + settings.LocalIp = string.IsNullOrWhiteSpace(settings.LocalIp) ? LocalAddressDetector.Detect().ToString() : settings.LocalIp.Trim(); + if (!IPAddress.TryParse(settings.LocalIp, out _)) throw new ArgumentException("local-ip must be an IP address."); + settings.ModActors = SharedLaunchOptions.Entries(settings.ModActors).Distinct().ToList(); + ToDiscordOptions(); + } + + public LanguageExt.Option ToDiscordOptions() { + if (Settings.Discord is not { } discord) return None; + var channels = new[] { discord.AdminChannelId, discord.GeneralChannelId, discord.DashboardChannelId, discord.EventLogChannelId }; + if (string.IsNullOrWhiteSpace(discord.BotToken) && channels.All(string.IsNullOrWhiteSpace) && string.IsNullOrWhiteSpace(discord.AdminRoleId)) return None; + if (string.IsNullOrWhiteSpace(discord.BotToken) || channels.All(string.IsNullOrWhiteSpace)) + throw new ArgumentException("Discord integration requires a bot token and at least one channel ID."); + LanguageExt.Option Text(string? value) => string.IsNullOrWhiteSpace(value) ? None : Some(value.Trim()); + return Some(new DiscordIntegrationLaunchOptions(discord.BotToken.Trim(), + Text(discord.AdminChannelId), Text(discord.GeneralChannelId), Text(discord.DashboardChannelId), + Text(discord.EventLogChannelId), Text(discord.AdminRoleId), discord.MentionAdmins)); + } + + public ServerLaunchOptions ToServerLaunchOptions() => new( + Settings.Headless, Settings.Name, Settings.Description, RegisterWithBackend: Settings.Register, + Password: string.IsNullOrWhiteSpace(Settings.Password) ? None : Some(Settings.Password.Trim()), + Settings.Map, Settings.GamePort, BeaconPort: Settings.PingPort, Settings.QueryPort, Settings.RconPort, + FFATimeLimit: null, FFAScoreLimit: null, TDMTimeLimit: null, TDMTicketCount: null, + PlayerBotCount: null, WarmupTime: null, LocalIp: Optional(Settings.LocalIp), + ServerMods: Settings.ModActors ?? [], DiscordIntegration: ToDiscordOptions(), + Settings.DesyncPatch, Settings.UseBackendBanlist, Settings.CensorMode); + + public override LaunchOptions ToLaunchOptions() => BuildLaunchOptions(["TBL", "-server"], Some(ToServerLaunchOptions())); +} diff --git a/UnchainedLauncher.CLI/CommandLineOptions.cs b/UnchainedLauncher.CLI/CommandLineOptions.cs new file mode 100644 index 00000000..f89da465 --- /dev/null +++ b/UnchainedLauncher.CLI/CommandLineOptions.cs @@ -0,0 +1,157 @@ +using System.CommandLine; +using System.CommandLine.Help; +using UnchainedLauncher.Core.JsonModels; +using UnchainedLauncher.Core.Services.Processes.Chivalry; + +namespace UnchainedLauncher.CLI; + +internal static class CommandLineOptions { + public static CliLaunchOptions Parse(string[] args) { + var separator = Array.IndexOf(args, "--"); + var launcherArgs = separator < 0 ? args : args[..separator]; + var gameArgs = separator < 0 ? [] : args[(separator + 1)..]; + // Probe only the configuration selector. All other options are deliberately + // ignored when YAML is authoritative, even if their values are invalid. + var selector = new RootCommand(); + var config = new Option("--config-path") { Arity = ArgumentArity.ExactlyOne }; + config.Validators.Add(result => { + if (result.Tokens.Count != 1 || string.IsNullOrWhiteSpace(result.Tokens[0].Value)) + result.AddError("Specify exactly one non-empty --config-path."); + }); + foreach (var name in new[] { "server", "client" }) { + var command = new Command(name) { TreatUnmatchedTokensAsErrors = false }; + command.Options.Add(config); + selector.Subcommands.Add(command); + } + var probe = selector.Parse(launcherArgs); + if (probe.GetResult(config) is { } configResult) { + ThrowErrors(probe); + return YamlConfiguration.Load(probe.GetValue(config)!, probe.CommandResult.Command.Name == "client"); + } + + var root = new RootCommand("Unchained client/server launcher. Windows is required for launching and DLL injection."); + var server = new Definition(client: false); + var client = new Definition(client: true); + root.Subcommands.Add(server.Command); + root.Subcommands.Add(client.Command); + var parsed = root.Parse(launcherArgs); + var definition = parsed.CommandResult.Command == server.Command ? server : client; + if (args.Length == 0 || parsed.GetResult("--help") is not null) { + using var output = new StringWriter(); + var help = new HelpAction(); + parsed.InvocationConfiguration.Output = output; + help.Invoke(parsed); + return CliLaunchOptions.ForHelp(output.ToString()); + } + ThrowErrors(parsed); + if (parsed.CommandResult.Command == root) throw new ArgumentException("Expected the 'server' or 'client' subcommand."); + return definition.ToOptions(parsed, gameArgs); + } + + private static void ThrowErrors(ParseResult result) { + if (result.Errors.Count != 0) throw new ArgumentException(string.Join(Environment.NewLine, result.Errors.Select(error => error.Message))); + } + + private sealed class Definition { + public Command Command { get; } + private readonly LaunchConfiguration _configuration; + private readonly List> _bindings = []; + private readonly Argument _gameArgs = new("game-args") { Arity = ArgumentArity.ZeroOrMore }; + + public Definition(bool client) { + Command = new Command(client ? "client" : "server", client ? "Launch an Unchained client." : "Launch and monitor an Unchained server."); + _configuration = client ? new ClientConfiguration() : new ServerConfiguration { Server = new ServerSettings() }; + Add("--config-path", "YAML replaces other options; the subcommand selects its schema.", null, _ => { }); + Add("--chivalry2-path", "Game installation directory.", null, value => _configuration.Chiv2Path = value, required: true); + var platform = new Option("--platform") { + Description = "Steam or EGS (EpicGamesStore); EGS auth must be forwarded after --.", + Arity = ArgumentArity.ExactlyOne, Required = true, + CustomParser = result => { + if (result.Tokens.Count != 1) { result.AddError("Specify --platform once."); return null; } + try { return InstallationTypeYamlConverter.Parse(result.Tokens[0].Value); } + catch (ArgumentException ex) { result.AddError(ex.Message); return null; } + } + }; + Command.Options.Add(platform); + _bindings.Add(result => _configuration.Platform = result.GetValue(platform)); + Add("--saved-dir-suffix", "Saved profile suffix.", _configuration.SavedDirSuffix, value => _configuration.SavedDirSuffix = value); + Add("--server-browser-backend", "HTTP(S) server-browser backend URL.", _configuration.ServerBrowserBackend, value => _configuration.ServerBrowserBackend = value); + Flag("--no-plugin-update", "Use the existing UnchainedPlugin.dll.", value => _configuration.UpdatePlugin = !value); + Flag("--allow-unstable-plugin", "Allow prerelease plugin updates.", value => _configuration.AllowUnstablePlugin = value); + _configuration.ModSetup = new ModSetupConfiguration(); + Many("--add-registry", "Additional github:owner/repo, local:directory, or config:file registry; official registry is always included.", value => _configuration.ModSetup.Registries = value); + Many("--mod", "Install organization/id:version (exact release tag).", value => _configuration.ModSetup.Mods = value); + Many("--dll", "Reserved: ALL Plugins DLLs still load; selection tracked in issue #149.", value => _configuration.ModSetup.Dlls = value); + if (_configuration is ServerConfiguration serverConfig) { + var settings = serverConfig.Server!; + Add("--name", "Server name.", settings.Name, value => settings.Name = value); + Add("--description", "Description / MOTD.", settings.Description, value => settings.Description = value); + Add("--map", "Initial map.", settings.Map, value => settings.Map = value); + Add("--password", "Server password.", null, value => settings.Password = value); + Add("--local-ip", "Override auto-detected local IPv4 (VPN/multi-NIC).", null, value => settings.LocalIp = value); + Add("--game-port", "Game port.", settings.GamePort, value => settings.GamePort = value); + Add("--ping-port", "Ping port.", settings.PingPort, value => settings.PingPort = value); + Add("--query-port", "A2S query port.", settings.QueryPort, value => settings.QueryPort = value); + Add("--rcon-port", "RCON port.", settings.RconPort, value => settings.RconPort = value); + Add("--headless", "Run without rendering (true/false).", settings.Headless, value => settings.Headless = value); + Add("--censor-mode", "none, standard, sex, or zealous.", settings.CensorMode, value => settings.CensorMode = value); + Flag("--register", "List in the server browser.", value => settings.Register = value); + Flag("--no-tui", "Periodic plain-text status instead of dashboard.", value => serverConfig.NoTui = value); + Flag("--no-desync-patch", "Disable the default-enabled desync patch.", value => settings.DesyncPatch = !value); + Flag("--no-backend-banlist", "Disable the default-enabled backend banlist.", value => settings.UseBackendBanlist = !value); + Many("--server-mod", "Activate a blueprint class (separate from pak installation).", value => settings.ModActors = value); + var discord = new DiscordSettings(); + settings.Discord = discord; + Add("--discord-bot-token", "Bot token; requires at least one channel.", null, value => discord.BotToken = value); + Add("--discord-admin-channel-id", "Admin channel.", null, value => discord.AdminChannelId = value); + Add("--discord-general-channel-id", "General channel.", null, value => discord.GeneralChannelId = value); + Add("--discord-dashboard-channel-id", "Dashboard channel.", null, value => discord.DashboardChannelId = value); + Add("--discord-event-log-channel-id", "Event-log channel.", null, value => discord.EventLogChannelId = value); + Add("--discord-admin-role-id", "Admin role.", null, value => discord.AdminRoleId = value); + Add("--discord-mention-admins", "Mention admins (true/false).", true, value => discord.MentionAdmins = value); + } + // Only tokens following -- are game arguments, never misspelled options. + Command.Arguments.Add(_gameArgs); + Command.Validators.Add(result => { + if (result.GetResult(_gameArgs) is { Tokens.Count: > 0 }) + result.AddError("Pass game arguments after --."); + }); + } + + private void Add(string name, string description, T defaultValue, Action bind, bool required = false) { + var option = new Option(name) { + Description = description, Arity = ArgumentArity.ExactlyOne, + DefaultValueFactory = _ => defaultValue, Required = required + }; + option.Validators.Add(result => { + if (result.Tokens.Count > 1) result.AddError($"Duplicate option: {name}"); + if (result.Tokens.Any(token => string.IsNullOrWhiteSpace(token.Value))) result.AddError($"Empty value for {name}."); + }); + Command.Options.Add(option); + _bindings.Add(result => bind(result.GetValue(option)!)); + } + + private void Flag(string name, string description, Action bind) { + var option = new Option(name) { Description = description, Arity = ArgumentArity.Zero }; + option.Validators.Add(result => { + if (result.IdentifierTokenCount > 1) result.AddError($"Duplicate option: {name}"); + }); + Command.Options.Add(option); + _bindings.Add(result => bind(result.GetValue(option))); + } + + private void Many(string name, string description, Action> bind) { + var option = new Option(name) { Description = description, Arity = ArgumentArity.OneOrMore, AllowMultipleArgumentsPerToken = false }; + Command.Options.Add(option); + _bindings.Add(result => bind([.. result.GetValue(option) ?? []])); + } + + public CliLaunchOptions ToOptions(ParseResult parsed, string[] gameArgs) { + foreach (var bind in _bindings) bind(parsed); + _configuration.GameArgs = [.. gameArgs]; + try { _configuration.Validate(); } + catch (System.ComponentModel.DataAnnotations.ValidationException ex) { throw new ArgumentException(ex.Message); } + return CliLaunchOptions.FromConfiguration(_configuration, Directory.GetCurrentDirectory()); + } + } +} diff --git a/UnchainedLauncher.CLI/InstallationTypeYamlConverter.cs b/UnchainedLauncher.CLI/InstallationTypeYamlConverter.cs new file mode 100644 index 00000000..25800145 --- /dev/null +++ b/UnchainedLauncher.CLI/InstallationTypeYamlConverter.cs @@ -0,0 +1,25 @@ +using UnchainedLauncher.Core.JsonModels; +using YamlDotNet.Core; +using YamlDotNet.Core.Events; +using YamlDotNet.Serialization; + +namespace UnchainedLauncher.CLI; + +internal sealed class InstallationTypeYamlConverter : IYamlTypeConverter { + public bool Accepts(Type type) => type == typeof(InstallationType) || type == typeof(InstallationType?); + + internal static InstallationType Parse(string value) => value.ToLowerInvariant() switch { + "steam" => InstallationType.Steam, + "egs" or "epicgamesstore" => InstallationType.EpicGamesStore, + _ => throw new ArgumentException("Platform must be Steam or EGS (EpicGamesStore).") + }; + + public object ReadYaml(IParser parser, Type type, ObjectDeserializer rootDeserializer) { + var scalar = parser.Consume(); + try { return Parse(scalar.Value); } + catch (ArgumentException) { throw new YamlException(scalar.Start, scalar.End, "Invalid platform."); } + } + + public void WriteYaml(IEmitter emitter, object? value, Type type, ObjectSerializer serializer) => + emitter.Emit(new Scalar(value?.ToString() ?? "null")); +} diff --git a/UnchainedLauncher.CLI/LaunchConfiguration.cs b/UnchainedLauncher.CLI/LaunchConfiguration.cs new file mode 100644 index 00000000..698d7547 --- /dev/null +++ b/UnchainedLauncher.CLI/LaunchConfiguration.cs @@ -0,0 +1,91 @@ +using System.ComponentModel.DataAnnotations; +using UnchainedLauncher.Core.JsonModels; +using UnchainedLauncher.Core.Services.Processes.Chivalry; + +namespace UnchainedLauncher.CLI; + +public abstract class LaunchConfiguration { + [Required] + public string? Chiv2Path { get; set; } + + [Required, EnumDataType(typeof(InstallationType))] + public InstallationType? Platform { get; set; } + + [Required] + public string SavedDirSuffix { get; set; } = "UnchainedCLI"; + public string ServerBrowserBackend { get; set; } = "https://servers.polehammer.net"; + public bool UpdatePlugin { get; set; } = true; + public bool AllowUnstablePlugin { get; set; } + public List? GameArgs { get; set; } = []; + public ModSetupConfiguration? ModSetup { get; set; } + + public virtual void Validate() { + Validator.ValidateObject(this, new ValidationContext(this), validateAllProperties: true); + if (Platform is not (InstallationType.Steam or InstallationType.EpicGamesStore)) + throw new ValidationException("Platform must be Steam or EGS (EpicGamesStore)."); + } +} + +public sealed class ServerConfiguration : LaunchConfiguration { + public bool NoTui { get; set; } + public ServerSettings? Server { get; set; } + // Recognized but intentionally untyped: do not validate the inactive schema. + public object? Client { get; set; } + + public override void Validate() { + base.Validate(); + if (Server is not null) Validator.ValidateObject(Server, new ValidationContext(Server), validateAllProperties: true); + } +} + +public sealed class ClientConfiguration : LaunchConfiguration { + public ClientSettings? Client { get; set; } + public object? Server { get; set; } +} + +public sealed class ModSetupConfiguration { + public List? Registries { get; set; } = []; + public List? Mods { get; set; } = []; + public List? Dlls { get; set; } = []; +} + +public sealed class ClientSettings { + public List? GameArgs { get; set; } = []; +} + +public sealed class ServerSettings { + [Required] + public string Name { get; set; } = "Unchained Server"; + public string Description { get; set; } = ""; + public string? Password { get; set; } + [Required] + public string Map { get; set; } = "FFA_Courtyard"; + public string? LocalIp { get; set; } + [Range(1, 65535)] + public int GamePort { get; set; } = 7777; + [Range(1, 65535)] + public int PingPort { get; set; } = 3075; + [Range(1, 65535)] + public int QueryPort { get; set; } = 7071; + [Range(1, 65535)] + public int RconPort { get; set; } = 9001; + public bool Headless { get; set; } = true; + public bool Register { get; set; } + public bool DesyncPatch { get; set; } = true; + public bool UseBackendBanlist { get; set; } = true; + [EnumDataType(typeof(CensorArg))] + public CensorArg CensorMode { get; set; } = CensorArg.None; + public List? ModActors { get; set; } = []; + public List? GameArgs { get; set; } = []; + public DiscordSettings? Discord { get; set; } +} + +public sealed class DiscordSettings { + public string? BotToken { get; set; } + public string? AdminChannelId { get; set; } + public string? GeneralChannelId { get; set; } + public string? DashboardChannelId { get; set; } + public string? EventLogChannelId { get; set; } + public string? AdminRoleId { get; set; } + public bool MentionAdmins { get; set; } = true; +} diff --git a/UnchainedLauncher.CLI/LocalAddressDetector.cs b/UnchainedLauncher.CLI/LocalAddressDetector.cs new file mode 100644 index 00000000..6e8e2d88 --- /dev/null +++ b/UnchainedLauncher.CLI/LocalAddressDetector.cs @@ -0,0 +1,32 @@ +using System.Net; +using System.Net.NetworkInformation; +using System.Net.Sockets; + +namespace UnchainedLauncher.CLI; + +public static class LocalAddressDetector { + public static IPAddress Detect() { + try { + var addresses = NetworkInterface.GetAllNetworkInterfaces() + .Where(adapter => adapter.OperationalStatus == OperationalStatus.Up + && adapter.NetworkInterfaceType is not (NetworkInterfaceType.Loopback or NetworkInterfaceType.Tunnel)) + .SelectMany(adapter => { + var properties = adapter.GetIPProperties(); + var hasGateway = properties.GatewayAddresses.Any(g => !g.Address.Equals(IPAddress.Any) + && g.Address.AddressFamily == AddressFamily.InterNetwork); + return properties.UnicastAddresses.Select(ip => (ip.Address, HasGateway: hasGateway)); + }); + return Select(addresses); + } + catch (Exception ex) when (ex is NetworkInformationException or PlatformNotSupportedException or SocketException) { + return IPAddress.Loopback; + } + } + + internal static IPAddress Select(IEnumerable<(IPAddress Address, bool HasGateway)> candidates) => + candidates.Where(candidate => candidate.Address.AddressFamily == AddressFamily.InterNetwork + && !candidate.Address.Equals(IPAddress.Any) && !IPAddress.IsLoopback(candidate.Address) + && !(candidate.Address.GetAddressBytes()[0] == 169 && candidate.Address.GetAddressBytes()[1] == 254)) + .OrderByDescending(candidate => candidate.HasGateway) + .Select(candidate => candidate.Address).FirstOrDefault() ?? IPAddress.Loopback; +} diff --git a/UnchainedLauncher.CLI/Program.cs b/UnchainedLauncher.CLI/Program.cs index 33d6783b..85f89b54 100644 --- a/UnchainedLauncher.CLI/Program.cs +++ b/UnchainedLauncher.CLI/Program.cs @@ -1,18 +1,20 @@ +using System.Diagnostics; + namespace UnchainedLauncher.CLI; public static class Program { public static async Task Main(string[] args) { - ServerOptions options; + CliLaunchOptions options; try { - options = ServerOptions.Parse(args); + options = CliLaunchOptions.Parse(args); } catch (ArgumentException ex) { Console.Error.WriteLine(ex.Message); - Console.Error.WriteLine("Use 'server --help' for usage."); + Console.Error.WriteLine("Use '--help' for usage."); return 2; } - if (options.Help) { Console.WriteLine(ServerOptions.HelpText); return 0; } - if (options.Dlls.Count != 0) { + if (options.Help) { Console.Write(options.HelpText); return 0; } + if (options.Shared.Dlls.Count != 0) { Console.Error.WriteLine("WARNING: --dll version selection is NOT implemented; ALL DLLs in TBL/Binaries/Win64/Plugins will be injected, regardless of the requested IDs/versions."); Console.Error.WriteLine("Tracking: https://github.com/Chiv2-Community/UnchainedLauncher/issues/149"); } @@ -21,21 +23,7 @@ public static async Task Main(string[] args) { Console.CancelKeyPress += handler; try { using var process = await ServerLauncher.LaunchAsync(options, stop.Token); - try { - using var dashboard = new ServerDashboard(!options.NoTui); - var monitor = new ServerMonitor(process, ServerLauncher.CreateQuery(options)); - await monitor.RunAsync(dashboard, stop); - return stop.IsCancellationRequested ? 0 : process.ExitCode; - } - finally { - // The CLI owns its server: cancellation and monitor failures must not - // leave an unobserved server running. Kill is Core's existing stop behavior. - if (!process.HasExited) { - Console.Error.WriteLine("Stopping server..."); - process.Kill(entireProcessTree: true); - await process.WaitForExitAsync(); - } - } + return await SuperviseAsync(options, process, stop); } catch (OperationCanceledException) when (stop.IsCancellationRequested) { return 0; } catch (Exception ex) { @@ -44,4 +32,29 @@ public static async Task Main(string[] args) { } finally { Console.CancelKeyPress -= handler; } } -} \ No newline at end of file + + internal static async Task SuperviseAsync(CliLaunchOptions options, Process process, CancellationTokenSource stop) { + if (options is ClientOptions) { + // Match the GUI's client lifecycle: launch the game, then leave it + // running independently. Only servers are owned by this console. + Console.WriteLine($"Client launched (PID {process.Id})."); + return 0; + } + if (options is not ServerOptions server) throw new ArgumentException("Expected a launch request."); + try { + using var dashboard = new ServerDashboard(!server.NoTui); + var monitor = new ServerMonitor(process, ServerLauncher.CreateQuery(server)); + await monitor.RunAsync(dashboard, stop); + return stop.IsCancellationRequested ? 0 : process.ExitCode; + } + finally { + // The CLI owns its server: cancellation and monitor failures must not + // leave an unobserved server running. Kill is Core's existing stop behavior. + if (!process.HasExited) { + Console.Error.WriteLine("Stopping server..."); + process.Kill(entireProcessTree: true); + await process.WaitForExitAsync(); + } + } + } +} diff --git a/UnchainedLauncher.CLI/README.md b/UnchainedLauncher.CLI/README.md index 8f0777cb..97784f9c 100644 --- a/UnchainedLauncher.CLI/README.md +++ b/UnchainedLauncher.CLI/README.md @@ -1,8 +1,8 @@ -# Server CLI +# Launcher CLI -This project launches an Unchained Chivalry 2 **server**, without the WPF GUI. +This project launches an Unchained Chivalry 2 **client or server**, without the WPF GUI. It uses Core for mod installation, plugin updates, signature preparation, -process launching, DLL injection, and A2S queries. Client launching is not included. +process launching, DLL injection, and A2S queries. ## Build and run @@ -25,7 +25,8 @@ dotnet run --project UnchainedLauncher.CLI -c Release -- server ` ``` The path is the installation root containing `TBL`, not the Win64 directory. -The server is headless (`-nullrhi -unattended -nosound`). The plugin is installed +The server is headless by default (`-nullrhi -unattended -nosound`); use +`--headless false` to render it. The plugin is installed or updated from the latest stable UnchainedPlugin release by default. Use `--no-plugin-update` to keep your installed `UnchainedPlugin.dll`; it must already exist. `--allow-unstable-plugin` permits prerelease plugin updates. @@ -40,6 +41,107 @@ dotnet publish UnchainedLauncher.CLI -c Release -r win-x64 --self-contained true Help, parsing, and the unit tests also work on Linux. Actual game launching and DLL injection require Windows; Wine/Proton launching is not implemented. +## YAML configuration + +CLI options and command-specific help are defined with System.CommandLine. +Use `server --help` or `client --help` to list the options for that command. +CLI input and typed YAML configurations share the same models and validation. +Client and server launch requests compose `SharedLaunchOptions` for installation, +platform, profile, registry/mod selection, plugin-update policy, and common game +arguments. Server-only settings are carried only by `ServerOptions`; there is no +client/server mode flag on a launch request. Platforms use Core's `InstallationType`; +`EGS` remains an alias for `EpicGamesStore` in both CLI and YAML input. + +Use a configuration file instead of individual arguments: + +```powershell +.\out-cli\UnchainedLauncher.CLI.exe server --config-path "C:\Configs\server.yaml" +.\out-cli\UnchainedLauncher.CLI.exe client --config-path "C:\Configs\client.yaml" +# From the repository root: +dotnet run --project UnchainedLauncher.CLI -- server --config-path "C:\Configs\server.yaml" +``` + +`--config-path` (or `--config-path=...`) replaces **all other launcher and game +arguments except the required `client`/`server` subcommand**. There are no CLI +overrides or merging. Put all intended options in the file. A `--config-path` +token after the game-argument separator `--` is just a game argument, not a +configuration selector. Relative installation and local/config registry paths +in YAML are resolved relative to the YAML file, not the caller's working directory. + +`chiv2-path` and `platform` (`Steam` or `EGS`) are required. See the complete +[server example](examples/server.yaml) and [client example](examples/client.yaml). + +```yaml +chiv2-path: 'C:\Games\Chivalry 2' +platform: Steam +saved-dir-suffix: MyServer +mod-setup: + registries: [] # Official registry remains enabled, even with an empty list. + mods: [] # organization/mod-id:exact-release-tag + dlls: [] # Reserved; selection remains deferred to issue #149. +server: + name: My Server + description: My server description + game-port: 7777 + ping-port: 3075 + query-port: 7071 + rcon-port: 9001 + map: FFA_Courtyard + mod-actors: [] # Explicit blueprint class paths to activate. + desync-patch: true + use-backend-banlist: true +``` + +Separate mode-specific schemas: +- The **subcommand**, not the document's contents, always determines mode. +- `server --config-path ...` reads the `server` mapping and completely ignores `client`. +- `client --config-path ...` reads the `client` mapping and completely ignores `server`, + including actors, Discord, and server-only arguments; ignored blocks are not schema-validated. +- A missing active block uses that command's defaults. `client` is a mapping, not a boolean mode switch. +- Client launches omit `-server`, headless flags, and server-specific options, + do not create a server dashboard, and return after launching without stopping the game. + +Common root options are `server-browser-backend`, `update-plugin` (default true), +`allow-unstable-plugin` (default false), `no-tui` (default false), and `game-args` +(list of strings). `server.game-args` or `client.game-args` is appended to the +common list only for the matching command. The client schema currently supports +`game-args`; shared installation, platform, plugin, and mod options remain at the root. + +The server section supports name, description/MOTD, password, map, local IP, +all four ports, `headless`, `register`, `mod-actors`, `desync-patch`, +`use-backend-banlist`, and `censor-mode` (`none`, `standard`, `sex`, `zealous`). +Desync patch and backend banlist **default to true in server mode**; set their +YAML values to false, or pass `--no-desync-patch` / `--no-backend-banlist`, to disable. +Local IPv4 is automatically detected from active non-loopback/non-tunnel interfaces, +preferring one with a default gateway; it falls back to another usable IPv4 or loopback. +Set `local-ip` only to override this choice (for example, with a VPN or multiple adapters). +Time/score/ticket limits, player bot count, and warmup are **INI settings**, not +part of either CLI schema; configure them in the selected saved profile. +No INI files are rewritten; other existing saved-profile INI settings still apply. + +Discord configuration is nested under the server: + +```yaml +server: + discord: + bot-token: 'your-bot-token' + general-channel-id: '123456789012345678' + # Optional: admin-channel-id, dashboard-channel-id, event-log-channel-id, + # admin-role-id, mention-admins (default true). +``` + +Discord requires a token and at least one channel. Keep tokens/passwords private +and restrict access to configuration files; do not commit real credentials. +YAML syntax errors report a location without quoting the configuration contents. +Unknown keys, duplicate keys, invalid types, ports, modes, or coordinates fail +before preparing or launching the game. Lists accept any number of entries. + +The `client` command also accepts individual common arguments, for example: + +```text +UnchainedLauncher.CLI.exe client --chivalry2-path "C:\Games\Chivalry 2" --platform Steam +``` + ## Registries and mods The official `github:Chiv2-Community/C2ModRegistry` registry is always included. @@ -125,10 +227,17 @@ Run `server --help` for the full option list. Additional settings include: | `--ping-port` | `3075` | | `--query-port` | `7071` (A2S) | | `--rcon-port` | `9001` | -| `--local-ip` | Optional local server address; also used for A2S monitoring | +| `--local-ip` | Optional override of auto-detected local IPv4; also used for A2S monitoring | | `--register` | Opt in to server-browser registration; off by default | | `--server-browser-backend` | `https://servers.polehammer.net` | | `--no-tui` | Periodic plain-text status instead of the dashboard | +| `--headless true/false` | `true`; server rendering can be enabled | +| `--no-desync-patch`, `--no-backend-banlist` | Disable the default-enabled server features | +| `--censor-mode` | `none`; also `standard`, `sex`, `zealous` | +| `--discord-bot-token` | Requires at least one Discord channel | +| `--discord-admin-channel-id`, `--discord-general-channel-id` | Optional channel IDs | +| `--discord-dashboard-channel-id`, `--discord-event-log-channel-id` | Optional channel IDs | +| `--discord-admin-role-id`, `--discord-mention-admins true/false` | Optional role; mention defaults true | Ports must be distinct and between 1 and 65535. Configure firewall/port forwarding for your deployment; this CLI does not change firewall rules. Existing INI files diff --git a/UnchainedLauncher.CLI/RegistrySpec.cs b/UnchainedLauncher.CLI/RegistrySpec.cs index 1dd49bf9..82fe205e 100644 --- a/UnchainedLauncher.CLI/RegistrySpec.cs +++ b/UnchainedLauncher.CLI/RegistrySpec.cs @@ -4,7 +4,7 @@ namespace UnchainedLauncher.CLI; public sealed record RegistrySpec(string Kind, string Location) { - public static RegistrySpec Parse(string value) { + public static RegistrySpec Parse(string value, string? baseDirectory = null) { var parts = value.Split(':', 2); if (parts.Length != 2 || string.IsNullOrWhiteSpace(parts[1])) throw new ArgumentException("Registry must be github:owner/repo, local:, or config:."); @@ -14,7 +14,8 @@ public static RegistrySpec Parse(string value) { throw new ArgumentException("GitHub registry must be github:owner/repo."); return new RegistrySpec(kind, parts[1]); } - if (kind is "local" or "config") return new RegistrySpec(kind, Path.GetFullPath(parts[1])); + if (kind is "local" or "config") return new RegistrySpec(kind, + baseDirectory is null ? Path.GetFullPath(parts[1]) : Path.GetFullPath(parts[1], baseDirectory)); throw new ArgumentException($"Unsupported registry kind: {kind}"); } @@ -41,4 +42,4 @@ private IModRegistry LoadConfiguration() { }, _ => metadata }; -} \ No newline at end of file +} diff --git a/UnchainedLauncher.CLI/ServerLauncher.cs b/UnchainedLauncher.CLI/ServerLauncher.cs index d1fc2063..5f2523a8 100644 --- a/UnchainedLauncher.CLI/ServerLauncher.cs +++ b/UnchainedLauncher.CLI/ServerLauncher.cs @@ -1,6 +1,7 @@ using Octokit; using System.Diagnostics; -using System.Net; +using System.Net; +using UnchainedLauncher.Core.JsonModels; using UnchainedLauncher.Core.Services; using UnchainedLauncher.Core.Services.Mods; using UnchainedLauncher.Core.Services.Mods.Registry; @@ -13,8 +14,9 @@ namespace UnchainedLauncher.CLI; public static class ServerLauncher { - public static async Task LaunchAsync(ServerOptions options, CancellationToken cancellationToken) { - if (!OperatingSystem.IsWindows()) throw new PlatformNotSupportedException("Server launching requires Windows (Core's DLL injector uses kernel32.dll)."); + public static async Task LaunchAsync(CliLaunchOptions request, CancellationToken cancellationToken) { + var options = request.Shared; + if (!OperatingSystem.IsWindows()) throw new PlatformNotSupportedException("Game launching requires Windows (Core's DLL injector uses kernel32.dll)."); var executable = Path.Combine(options.Chivalry2Path, FilePaths.GameBinPath); var pakPath = Path.Combine(options.Chivalry2Path, FilePaths.PakDir); var pluginsPath = Path.Combine(options.Chivalry2Path, FilePaths.PluginDir); @@ -23,9 +25,9 @@ public static async Task LaunchAsync(ServerOptions options, Cancellatio if (!options.UpdatePlugin && !File.Exists(Path.Combine(options.Chivalry2Path, FilePaths.UnchainedPluginPath))) throw new FileNotFoundException("--no-plugin-update requires an existing UnchainedPlugin.dll in the Plugins directory."); - var registry = CreateRegistry(options); + var registry = CreateRegistry(request); - return await InInstallationDirectoryAsync(options, async () => { + return await InInstallationDirectoryAsync(request, async () => { var cachePath = Path.Combine(options.Chivalry2Path, FilePaths.ModCachePath, "cli_mod_manager.json"); var codec = new ModManagerCodec(registry); ModManager? cached = null; @@ -46,7 +48,7 @@ public static async Task LaunchAsync(ServerOptions options, Cancellatio } ValidateReleases(manager, options.Mods); - if (options.Platform == GamePlatform.Steam) { + if (options.Platform == InstallationType.Steam) { var appIdPath = Path.Combine(options.Chivalry2Path, FilePaths.SteamAppIdPath); if (!File.Exists(appIdPath)) File.WriteAllText(appIdPath, "1824220"); } @@ -64,7 +66,7 @@ public static async Task LaunchAsync(ServerOptions options, Cancellatio cancellationToken.ThrowIfCancellationRequested(); if (updated.IsNone) return LanguageExt.Option.None; if (!File.Exists(Path.Combine(pluginsPath, "UnchainedPlugin.dll"))) - throw new FileNotFoundException("UnchainedPlugin.dll is required to host a server."); + throw new FileNotFoundException("UnchainedPlugin.dll is required for an Unchained launch."); try { updated = await modInstaller.PrepareLaunch(updated.IfNone(launch)); } @@ -79,24 +81,24 @@ public static async Task LaunchAsync(ServerOptions options, Cancellatio }); var launcher = new UnchainedChivalry2Launcher(preparer, new ProcessLauncher(executable), options.Chivalry2Path, new DllInjector(pluginsPath)); - var result = await launcher.Launch(options.ToLaunchOptions()); + var result = await launcher.Launch(request.ToLaunchOptions()); return result.Match( Right: process => process, // Do not print LaunchFailed.Message: it contains raw password/auth arguments. - Left: failure => throw new InvalidOperationException("Server launch or DLL injection failed. Check the installation, plugin, and mod files.", failure)); + Left: failure => throw new InvalidOperationException("Game launch or DLL injection failed. Check the installation, plugin, and mod files.", failure)); }); } - internal static AggregateModRegistry CreateRegistry(ServerOptions options) => + internal static AggregateModRegistry CreateRegistry(CliLaunchOptions options) => new(new IModRegistry[] { new GithubModRegistry("Chiv2-Community", "C2ModRegistry") - }.Concat(options.Registries.Select(r => r.Create())).ToArray()); + }.Concat(options.Shared.Registries.Select(r => r.Create())).ToArray()); // Several existing Core preparers use installation-relative paths. // This standalone CLI sets CWD only for preparation, then restores it. - internal static async Task InInstallationDirectoryAsync(ServerOptions options, Func> action) { + internal static async Task InInstallationDirectoryAsync(CliLaunchOptions options, Func> action) { var previousDirectory = Directory.GetCurrentDirectory(); - Directory.SetCurrentDirectory(options.Chivalry2Path); + Directory.SetCurrentDirectory(options.Shared.Chivalry2Path); try { return await action(); } @@ -126,9 +128,9 @@ public static void ValidateReleases(IModManager manager, IEnumerable Registries { get; } = []; - public List Mods { get; } = []; - public List Dlls { get; } = []; - public List ServerMods { get; } = []; - public List GameArguments { get; } = []; - - public static ServerOptions Parse(string[] args) { - var options = new ServerOptions(); - if (args.Length == 0 || args[0] is "--help" or "-h") { - options.Help = true; - return options; - } - if (args[0] != "server") throw new ArgumentException("Expected the 'server' subcommand."); - var seen = new HashSet(); - var platformProvided = false; - for (var i = 1; i < args.Length; i++) { - if (args[i] == "--") { - options.GameArguments.AddRange(args.Skip(i + 1)); - break; - } - var pair = args[i].Split('=', 2); - var key = pair[0]; - if (key is "--help" or "-h") { options.Help = true; return options; } - if (key is not ("--add-registry" or "--mod" or "--dll" or "--server-mod") && !seen.Add(key)) - throw new ArgumentException($"Duplicate option: {key}"); - string Value() { - var value = pair.Length == 2 ? pair[1] - : i + 1 < args.Length && !args[i + 1].StartsWith("--") ? args[++i] - : throw new ArgumentException($"Missing value for {key}."); - if (string.IsNullOrWhiteSpace(value)) throw new ArgumentException($"Empty value for {key}."); - return value; - } - void Flag() { - if (pair.Length != 1) throw new ArgumentException($"{key} does not take a value."); - } - switch (key) { - case "--chivalry2-path": options.Chivalry2Path = Path.GetFullPath(Value()); break; - case "--saved-dir-suffix": options.SavedDirSuffix = Value(); break; - case "--platform": - var platform = Value(); - options.Platform = platform.ToLowerInvariant() switch { - "steam" => GamePlatform.Steam, - "egs" => GamePlatform.EGS, - _ => throw new ArgumentException("--platform must be Steam or EGS.") - }; - platformProvided = true; - break; - case "--add-registry": options.Registries.Add(RegistrySpec.Parse(Value())); break; - case "--mod": options.Mods.Add(ParseCoordinates(Value())); break; - case "--dll": options.Dlls.Add(ParseCoordinates(Value())); break; - case "--server-mod": options.ServerMods.Add(Value()); break; - case "--name": options.Name = Value(); break; - case "--description": options.Description = Value(); break; - case "--map": options.Map = Value(); break; - case "--password": options.Password = Value(); break; - case "--local-ip": - options.LocalIp = Value(); - if (!IPAddress.TryParse(options.LocalIp, out _)) throw new ArgumentException("--local-ip must be an IP address."); - break; - case "--server-browser-backend": - options.Backend = Value(); - if (!Uri.TryCreate(options.Backend, UriKind.Absolute, out var uri) || uri.Scheme is not ("http" or "https")) - throw new ArgumentException("--server-browser-backend must be an HTTP(S) URL."); - break; - case "--game-port": options.GamePort = ParsePort(Value(), key); break; - case "--ping-port": options.PingPort = ParsePort(Value(), key); break; - case "--query-port": options.QueryPort = ParsePort(Value(), key); break; - case "--rcon-port": options.RconPort = ParsePort(Value(), key); break; - case "--register": Flag(); options.Register = true; break; - case "--no-tui": Flag(); options.NoTui = true; break; - case "--no-plugin-update": Flag(); options.UpdatePlugin = false; break; - case "--allow-unstable-plugin": Flag(); options.AllowUnstablePlugin = true; break; - default: throw new ArgumentException($"Unknown option: {key}. Pass game arguments after --."); - } - } - if (options.Chivalry2Path.Length == 0) throw new ArgumentException("--chivalry2-path is required."); - if (!platformProvided) throw new ArgumentException("--platform is required (Steam or EGS)."); - if (!Regex.IsMatch(options.SavedDirSuffix, @"\A[A-Za-z0-9_-]+\z")) - throw new ArgumentException("--saved-dir-suffix may contain only letters, digits, underscores, and hyphens."); - if (new[] { options.GamePort, options.PingPort, options.QueryPort, options.RconPort }.Distinct().Count() != 4) - throw new ArgumentException("Game, ping, query, and RCON ports must be distinct."); - if (options.Mods.GroupBy(m => (m.Org, m.ModuleName)).Any(g => g.Select(m => m.Version).Distinct().Count() > 1)) - throw new ArgumentException("Only one version of each mod may be selected."); - return options; - } - - private static int ParsePort(string value, string option) => - int.TryParse(value, out var port) && port is >= 1 and <= 65535 ? port - : throw new ArgumentException($"{option} must be a port between 1 and 65535."); - - public static ReleaseCoordinates ParseCoordinates(string value) { - var match = Regex.Match(value, @"\A([A-Za-z0-9_.-]+)/([A-Za-z0-9_.-]+):([^\s:/\\]+)\z"); - if (!match.Success) throw new ArgumentException($"Invalid coordinate '{value}'; expected organization/id:version (exact release tag)."); - return new ReleaseCoordinates(match.Groups[1].Value, match.Groups[2].Value, match.Groups[3].Value); - } - - public ServerLaunchOptions ToServerLaunchOptions() => new( - Headless: true, Name, Description, RegisterWithBackend: Register, - Password: Optional(Password), Map, GamePort, BeaconPort: PingPort, QueryPort, RconPort, - FFATimeLimit: null, FFAScoreLimit: null, TDMTimeLimit: null, TDMTicketCount: null, - PlayerBotCount: null, WarmupTime: null, LocalIp: Optional(LocalIp), - ServerMods: ServerMods.Distinct(), DiscordIntegration: None, - DesyncPatch: false, UseBackendBanlist: false, CensorMode: CensorArg.None); - - public LaunchOptions ToLaunchOptions() => new( - EnabledReleases: Mods.Distinct(), ServerBrowserBackend: Some(Backend), - LaunchArgs: string.Join(" ", new[] { "TBL", "-server" }.Concat(GameArguments.Select(ArgumentEscaper.Escape))), - CheckForDependencyUpdates: UpdatePlugin, AllowUnstablePluginReleases: AllowUnstablePlugin, - SavedDirSuffix: Some(SavedDirSuffix), ServerLaunchOptions: Some(ToServerLaunchOptions())); - - public const string HelpText = """ - UnchainedLauncher.CLI server --chivalry2-path --platform Steam|EGS [options] [-- game-args] - - Server-only launcher; Windows is required for launching and DLL injection. - --saved-dir-suffix Saved profile suffix (default: UnchainedCLI) - --add-registry github:owner/repo Add a GitHub registry (repeatable) - --add-registry local: Add a local registry (repeatable) - --add-registry config: Add a Core registry configuration, including aggregates - --mod Install a mod at its exact release tag (repeatable) - --dll RESERVED: warns; still injects ALL Plugins DLLs - --server-mod Activate a server mod actor (repeatable) - --name Server name (default: Unchained Server) - --description Description / MOTD - --map Initial map (default: FFA_Courtyard) - --password Server password - --game-port Default: 7777 - --ping-port Default: 3075 - --query-port A2S query port (default: 7071) - --rcon-port Default: 9001 - --local-ip
Local server address - --register List server in the server browser (default: off) - --server-browser-backend Default: https://servers.polehammer.net - --no-plugin-update Use the existing UnchainedPlugin.dll - --allow-unstable-plugin Allow prerelease plugin updates - --no-tui Print periodic status instead of the live dashboard - --help Show help - - The official GitHub registry is always included; additional registries are additive. - Q or Ctrl+C stops the server. Redirected output automatically disables the TUI. - EGS authentication arguments must be supplied after --; this does not log in to Epic. - DLL package selection is tracked in https://github.com/Chiv2-Community/UnchainedLauncher/issues/149 - """; -} \ No newline at end of file diff --git a/UnchainedLauncher.CLI/UnchainedLauncher.CLI.csproj b/UnchainedLauncher.CLI/UnchainedLauncher.CLI.csproj index 5cc4d2b0..46b43857 100644 --- a/UnchainedLauncher.CLI/UnchainedLauncher.CLI.csproj +++ b/UnchainedLauncher.CLI/UnchainedLauncher.CLI.csproj @@ -6,6 +6,8 @@ enable + + diff --git a/UnchainedLauncher.CLI/YamlConfiguration.cs b/UnchainedLauncher.CLI/YamlConfiguration.cs new file mode 100644 index 00000000..ead55cbe --- /dev/null +++ b/UnchainedLauncher.CLI/YamlConfiguration.cs @@ -0,0 +1,37 @@ +using System.ComponentModel.DataAnnotations; +using YamlDotNet.Core; +using YamlDotNet.Serialization; +using YamlDotNet.Serialization.NamingConventions; + +namespace UnchainedLauncher.CLI; + +public static class YamlConfiguration { + public static CliLaunchOptions Load(string path, bool client) { + path = Path.GetFullPath(path); + try { + using var reader = File.OpenText(path); + var deserializer = new DeserializerBuilder() + .WithNamingConvention(HyphenatedNamingConvention.Instance) + .WithTypeConverter(new InstallationTypeYamlConverter()) + .WithDuplicateKeyChecking() + .WithEnforceNullability() + .Build(); + LaunchConfiguration? configuration = client + ? deserializer.Deserialize(reader) + : deserializer.Deserialize(reader); + if (configuration is null) throw new ArgumentException("Configuration must contain one YAML mapping."); + configuration.Validate(); + return CliLaunchOptions.FromConfiguration(configuration, Path.GetDirectoryName(path)!); + } + catch (YamlException ex) { + // Library errors can include passwords/tokens; report only location. + throw new ArgumentException($"Invalid YAML configuration or schema at line {ex.Start.Line}, column {ex.Start.Column}."); + } + catch (ValidationException ex) { + throw new ArgumentException("Invalid configuration: " + ex.Message); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { + throw new ArgumentException($"Cannot read YAML configuration: {path}"); + } + } +} diff --git a/UnchainedLauncher.CLI/examples/client.yaml b/UnchainedLauncher.CLI/examples/client.yaml new file mode 100644 index 00000000..fd34ae63 --- /dev/null +++ b/UnchainedLauncher.CLI/examples/client.yaml @@ -0,0 +1,14 @@ +chiv2-path: 'C:\Games\Chivalry 2' +platform: Steam +saved-dir-suffix: MyClient + +mod-setup: + registries: [] + mods: [] + dlls: [] + +# Run with: UnchainedLauncher.CLI.exe client --config-path client.yaml +# A server block, if included, is ignored by the client command. +update-plugin: true +client: + game-args: [] diff --git a/UnchainedLauncher.CLI/examples/server.yaml b/UnchainedLauncher.CLI/examples/server.yaml new file mode 100644 index 00000000..44f96869 --- /dev/null +++ b/UnchainedLauncher.CLI/examples/server.yaml @@ -0,0 +1,46 @@ +# Paths are relative to this file, unless absolute. Change this installation path. +chiv2-path: 'C:\Games\Chivalry 2' +platform: Steam # Steam or EGS; required +saved-dir-suffix: MyServer + +mod-setup: + registries: [] # Official GitHub registry is included automatically. + # Additional entries: github:owner/repository, local:../registry, config:../registries.json + mods: [] # Exact coordinates: organization/mod-id:v1.2.3 + dlls: [] # Reserved: all Plugins DLLs still load; selection is not implemented. + +# Run with: UnchainedLauncher.CLI.exe server --config-path server.yaml +# A client block, if included, is ignored by the server command. +server-browser-backend: https://servers.polehammer.net +update-plugin: true +allow-unstable-plugin: false +no-tui: false +game-args: [] # Common game/auth arguments; one YAML string per argument. + +server: + name: My Unchained Server + description: Example server + password: '' + map: FFA_Courtyard + game-port: 7777 + ping-port: 3075 + query-port: 7071 + rcon-port: 9001 + headless: true + register: false + # local-ip: 192.168.1.10 # Optional override; local IPv4 is auto-detected. + mod-actors: [] # Blueprint class paths, e.g. /Game/Mods/Example/BP_Example.BP_Example_C + desync-patch: true # Default true; explicitly set false to disable. + use-backend-banlist: true # Default true; explicitly set false to disable. + censor-mode: none # none, standard, sex, zealous + game-args: [] # Additional server-only arguments, appended after common game-args. + # INI settings such as time/score/ticket limits, bots, and warmup stay in the saved profile. + # Optional Discord integration: token and at least one channel are required. + # discord: + # bot-token: 'replace-with-your-bot-token' + # admin-channel-id: '123456789012345678' + # general-channel-id: '234567890123456789' + # dashboard-channel-id: '345678901234567890' + # event-log-channel-id: '456789012345678901' + # admin-role-id: '567890123456789012' + # mention-admins: true From fbae4d6c10ce1153b3fc371309401ba0b0066f96 Mon Sep 17 00:00:00 2001 From: Jacoby6000 <2672832+Jacoby6000@users.noreply.github.com> Date: Sun, 11 Oct 2026 03:06:21 +0000 Subject: [PATCH 05/58] [GHA] Format Source --- .../CommandLineOptionsTests.cs | 2 +- .../LaunchCompositionTests.cs | 2 +- .../LocalAddressDetectorTests.cs | 2 +- .../RegistryAndReleaseTests.cs | 46 +++++++++---------- .../ServerOptionsTests.cs | 4 +- .../YamlConfigurationTests.cs | 2 +- UnchainedLauncher.CLI/CliLaunchOptions.cs | 2 +- UnchainedLauncher.CLI/CommandLineOptions.cs | 12 +++-- .../InstallationTypeYamlConverter.cs | 2 +- UnchainedLauncher.CLI/LaunchConfiguration.cs | 2 +- UnchainedLauncher.CLI/LocalAddressDetector.cs | 2 +- UnchainedLauncher.CLI/Program.cs | 2 +- UnchainedLauncher.CLI/RegistrySpec.cs | 2 +- UnchainedLauncher.CLI/ServerLauncher.cs | 36 +++++++-------- UnchainedLauncher.CLI/YamlConfiguration.cs | 2 +- 15 files changed, 61 insertions(+), 59 deletions(-) diff --git a/UnchainedLauncher.CLI.Tests/CommandLineOptionsTests.cs b/UnchainedLauncher.CLI.Tests/CommandLineOptionsTests.cs index 8e6872db..5964e27b 100644 --- a/UnchainedLauncher.CLI.Tests/CommandLineOptionsTests.cs +++ b/UnchainedLauncher.CLI.Tests/CommandLineOptionsTests.cs @@ -53,4 +53,4 @@ public void GameArgumentsMustFollowSeparatorAndArePreservedExactly() { Assert.False(options.Help); Assert.Equal(forwarded, options.GameArguments); } -} +} \ No newline at end of file diff --git a/UnchainedLauncher.CLI.Tests/LaunchCompositionTests.cs b/UnchainedLauncher.CLI.Tests/LaunchCompositionTests.cs index a66c5052..b011e3e9 100644 --- a/UnchainedLauncher.CLI.Tests/LaunchCompositionTests.cs +++ b/UnchainedLauncher.CLI.Tests/LaunchCompositionTests.cs @@ -39,4 +39,4 @@ public void ServerHasNoClientModeFlagAndClientHasNoServerSettings() { Assert.Null(typeof(ClientOptions).GetProperty("UpdatePlugin")); Assert.Equal(typeof(InstallationType), typeof(SharedLaunchOptions).GetProperty("Platform")!.PropertyType); } -} +} \ No newline at end of file diff --git a/UnchainedLauncher.CLI.Tests/LocalAddressDetectorTests.cs b/UnchainedLauncher.CLI.Tests/LocalAddressDetectorTests.cs index 18ae5a9c..b20bb4ee 100644 --- a/UnchainedLauncher.CLI.Tests/LocalAddressDetectorTests.cs +++ b/UnchainedLauncher.CLI.Tests/LocalAddressDetectorTests.cs @@ -33,4 +33,4 @@ public void ExplicitOverrideIsNotReplacedAndClientDoesNotGetServerLocalIp() { Assert.IsType(client); Assert.True(client.ToLaunchOptions().ServerLaunchOptions.IsNone); } -} +} \ No newline at end of file diff --git a/UnchainedLauncher.CLI.Tests/RegistryAndReleaseTests.cs b/UnchainedLauncher.CLI.Tests/RegistryAndReleaseTests.cs index 0488aa7b..80e35270 100644 --- a/UnchainedLauncher.CLI.Tests/RegistryAndReleaseTests.cs +++ b/UnchainedLauncher.CLI.Tests/RegistryAndReleaseTests.cs @@ -126,7 +126,7 @@ await Task.Run(async () => { await registry.AddRelease(release, source); var manager = new ModManager(registry, new PakDir(paks, []), []); Assert.False((await manager.UpdateModsList()).HasErrors); - var options = CliLaunchOptions.Parse(["server", "--chivalry2-path", _directory, "--platform", "Steam"]).ToLaunchOptions(); + var options = CliLaunchOptions.Parse(["server", "--chivalry2-path", _directory, "--platform", "Steam"]).ToLaunchOptions(); var dialogue = new ConsoleDialogue(); Assert.True((await new Chivalry2ModsInstaller(manager, dialogue).PrepareLaunch(options)).IsSome); var registryPak = Path.Combine(registry.RegistryPath, release.Info.Organization, release.Info.RepoName, @@ -145,10 +145,10 @@ await Task.Run(async () => { }); } - [Theory] - [InlineData(false)] - [InlineData(true)] - public async Task ProductionWiringInstallsManyModsAndForwardsActorsFromAnotherDirectory(bool useYaml) { + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ProductionWiringInstallsManyModsAndForwardsActorsFromAnotherDirectory(bool useYaml) { var callerDirectory = Directory.GetCurrentDirectory(); var install = Path.Combine(_directory, "arbitrary game installation"); var paks = Path.Combine(install, FilePaths.PakDir); @@ -169,19 +169,19 @@ public async Task ProductionWiringInstallsManyModsAndForwardsActorsFromAnotherDi if (i == 0) await registry.AddRelease(Common().Releases.Single() with { ReleaseHash = hash }, source); args.AddRange(["--add-registry", "local:" + registryPath, "--mod", $"org/mod{i}:v1.0.0", "--server-mod", actors[i]]); } - var options = CliLaunchOptions.Parse(args.ToArray()); - if (useYaml) { - string Quote(string value) => "'" + value.Replace("'", "''") + "'"; - var yaml = new List { "chiv2-path: " + Quote(install), "platform: Steam", "mod-setup:", " registries:" }; - yaml.AddRange(options.Shared.Registries.Select(r => " - " + Quote(r.Kind + ":" + r.Location))); - yaml.Add(" mods:"); - yaml.AddRange(options.Shared.Mods.Select(m => " - " + Quote($"{m.Org}/{m.ModuleName}:{m.Version}"))); - yaml.AddRange(["server:", " mod-actors:"]); - yaml.AddRange(actors.Select(actor => " - " + Quote(actor))); - var config = Path.Combine(_directory, "launch.yaml"); - File.WriteAllLines(config, yaml); - options = CliLaunchOptions.Parse(["server", "--config-path", config]); - } + var options = CliLaunchOptions.Parse(args.ToArray()); + if (useYaml) { + string Quote(string value) => "'" + value.Replace("'", "''") + "'"; + var yaml = new List { "chiv2-path: " + Quote(install), "platform: Steam", "mod-setup:", " registries:" }; + yaml.AddRange(options.Shared.Registries.Select(r => " - " + Quote(r.Kind + ":" + r.Location))); + yaml.Add(" mods:"); + yaml.AddRange(options.Shared.Mods.Select(m => " - " + Quote($"{m.Org}/{m.ModuleName}:{m.Version}"))); + yaml.AddRange(["server:", " mod-actors:"]); + yaml.AddRange(actors.Select(actor => " - " + Quote(actor))); + var config = Path.Combine(_directory, "launch.yaml"); + File.WriteAllLines(config, yaml); + options = CliLaunchOptions.Parse(["server", "--config-path", config]); + } var aggregate = ServerLauncher.CreateRegistry(options); var official = Assert.Single(aggregate.ModRegistries.OfType()); Assert.Equal(("Chiv2-Community", "C2ModRegistry"), (official.Organization, official.RepoName)); @@ -189,9 +189,9 @@ public async Task ProductionWiringInstallsManyModsAndForwardsActorsFromAnotherDi // Keep metadata deterministic/offline; assert the official production entry above, // then use all real local registries created by the production factory for installs. var offline = new AggregateModRegistry(aggregate.ModRegistries.OfType().Cast().ToArray()); - var manager = new ModManager(offline, new PakDir(paks, []), options.Shared.Mods); + var manager = new ModManager(offline, new PakDir(paks, []), options.Shared.Mods); Assert.False((await manager.UpdateModsList()).HasErrors); - ServerLauncher.ValidateReleases(manager, options.Shared.Mods); + ServerLauncher.ValidateReleases(manager, options.Shared.Mods); var process = new CapturingProcessLauncher(); await ServerLauncher.InInstallationDirectoryAsync(options, async () => { Assert.Equal(install, Directory.GetCurrentDirectory()); @@ -205,7 +205,7 @@ await ServerLauncher.InInstallationDirectoryAsync(options, async () => { return prepared; }); // Capture at the OS process boundary; retain Core preparation/argument rendering. - var launcher = new UnchainedChivalry2Launcher(preparer, process, options.Shared.Chivalry2Path, new NullInjector()); + var launcher = new UnchainedChivalry2Launcher(preparer, process, options.Shared.Chivalry2Path, new NullInjector()); return await launcher.Launch(options.ToLaunchOptions()); }); Assert.Equal(callerDirectory, Directory.GetCurrentDirectory()); @@ -232,7 +232,7 @@ await ServerLauncher.InInstallationDirectoryAsync(options, async () => { [InlineData(true)] public async Task ProductionDirectoryScopeRestoresCallerAfterFailureOrCancellation(bool cancel) { var callerDirectory = Directory.GetCurrentDirectory(); - var options = CliLaunchOptions.Parse(["server", "--chivalry2-path", _directory, "--platform", "Steam"]); + var options = CliLaunchOptions.Parse(["server", "--chivalry2-path", _directory, "--platform", "Steam"]); await Assert.ThrowsAnyAsync(() => ServerLauncher.InInstallationDirectoryAsync(options, () => { Assert.Equal(_directory, Directory.GetCurrentDirectory()); if (cancel) throw new OperationCanceledException(); @@ -254,4 +254,4 @@ public Either Launch(string workingDirectory, string args } [CollectionDefinition("Installation directory", DisableParallelization = true)] -public class InstallationDirectoryCollection { } +public class InstallationDirectoryCollection { } \ No newline at end of file diff --git a/UnchainedLauncher.CLI.Tests/ServerOptionsTests.cs b/UnchainedLauncher.CLI.Tests/ServerOptionsTests.cs index b0e606e9..d110a455 100644 --- a/UnchainedLauncher.CLI.Tests/ServerOptionsTests.cs +++ b/UnchainedLauncher.CLI.Tests/ServerOptionsTests.cs @@ -1,5 +1,5 @@ -using UnchainedLauncher.Core.Services.Mods.Registry; using UnchainedLauncher.Core.JsonModels; +using UnchainedLauncher.Core.Services.Mods.Registry; using Xunit; namespace UnchainedLauncher.CLI.Tests; @@ -159,4 +159,4 @@ public void DefaultEnabledServerFlagsCanBeDisabledOnCommandLine() { public void DisableOptionsAreValueLessFlags(string flag) { Assert.Throws(() => CliLaunchOptions.Parse(Args(flag))); } -} +} \ No newline at end of file diff --git a/UnchainedLauncher.CLI.Tests/YamlConfigurationTests.cs b/UnchainedLauncher.CLI.Tests/YamlConfigurationTests.cs index fcda535a..d25fb593 100644 --- a/UnchainedLauncher.CLI.Tests/YamlConfigurationTests.cs +++ b/UnchainedLauncher.CLI.Tests/YamlConfigurationTests.cs @@ -315,4 +315,4 @@ public async Task ProductionSupervisionLeavesClientRunningButStopsServer(bool cl } } } -} +} \ No newline at end of file diff --git a/UnchainedLauncher.CLI/CliLaunchOptions.cs b/UnchainedLauncher.CLI/CliLaunchOptions.cs index 5cc43926..1409a56f 100644 --- a/UnchainedLauncher.CLI/CliLaunchOptions.cs +++ b/UnchainedLauncher.CLI/CliLaunchOptions.cs @@ -141,4 +141,4 @@ public LanguageExt.Option ToDiscordOptions() { Settings.DesyncPatch, Settings.UseBackendBanlist, Settings.CensorMode); public override LaunchOptions ToLaunchOptions() => BuildLaunchOptions(["TBL", "-server"], Some(ToServerLaunchOptions())); -} +} \ No newline at end of file diff --git a/UnchainedLauncher.CLI/CommandLineOptions.cs b/UnchainedLauncher.CLI/CommandLineOptions.cs index f89da465..22c28d32 100644 --- a/UnchainedLauncher.CLI/CommandLineOptions.cs +++ b/UnchainedLauncher.CLI/CommandLineOptions.cs @@ -1,7 +1,6 @@ using System.CommandLine; using System.CommandLine.Help; using UnchainedLauncher.Core.JsonModels; -using UnchainedLauncher.Core.Services.Processes.Chivalry; namespace UnchainedLauncher.CLI; @@ -65,7 +64,8 @@ public Definition(bool client) { Add("--chivalry2-path", "Game installation directory.", null, value => _configuration.Chiv2Path = value, required: true); var platform = new Option("--platform") { Description = "Steam or EGS (EpicGamesStore); EGS auth must be forwarded after --.", - Arity = ArgumentArity.ExactlyOne, Required = true, + Arity = ArgumentArity.ExactlyOne, + Required = true, CustomParser = result => { if (result.Tokens.Count != 1) { result.AddError("Specify --platform once."); return null; } try { return InstallationTypeYamlConverter.Parse(result.Tokens[0].Value); } @@ -120,8 +120,10 @@ public Definition(bool client) { private void Add(string name, string description, T defaultValue, Action bind, bool required = false) { var option = new Option(name) { - Description = description, Arity = ArgumentArity.ExactlyOne, - DefaultValueFactory = _ => defaultValue, Required = required + Description = description, + Arity = ArgumentArity.ExactlyOne, + DefaultValueFactory = _ => defaultValue, + Required = required }; option.Validators.Add(result => { if (result.Tokens.Count > 1) result.AddError($"Duplicate option: {name}"); @@ -154,4 +156,4 @@ public CliLaunchOptions ToOptions(ParseResult parsed, string[] gameArgs) { return CliLaunchOptions.FromConfiguration(_configuration, Directory.GetCurrentDirectory()); } } -} +} \ No newline at end of file diff --git a/UnchainedLauncher.CLI/InstallationTypeYamlConverter.cs b/UnchainedLauncher.CLI/InstallationTypeYamlConverter.cs index 25800145..b7cf2576 100644 --- a/UnchainedLauncher.CLI/InstallationTypeYamlConverter.cs +++ b/UnchainedLauncher.CLI/InstallationTypeYamlConverter.cs @@ -22,4 +22,4 @@ public object ReadYaml(IParser parser, Type type, ObjectDeserializer rootDeseria public void WriteYaml(IEmitter emitter, object? value, Type type, ObjectSerializer serializer) => emitter.Emit(new Scalar(value?.ToString() ?? "null")); -} +} \ No newline at end of file diff --git a/UnchainedLauncher.CLI/LaunchConfiguration.cs b/UnchainedLauncher.CLI/LaunchConfiguration.cs index 698d7547..6445613c 100644 --- a/UnchainedLauncher.CLI/LaunchConfiguration.cs +++ b/UnchainedLauncher.CLI/LaunchConfiguration.cs @@ -88,4 +88,4 @@ public sealed class DiscordSettings { public string? EventLogChannelId { get; set; } public string? AdminRoleId { get; set; } public bool MentionAdmins { get; set; } = true; -} +} \ No newline at end of file diff --git a/UnchainedLauncher.CLI/LocalAddressDetector.cs b/UnchainedLauncher.CLI/LocalAddressDetector.cs index 6e8e2d88..ea36d567 100644 --- a/UnchainedLauncher.CLI/LocalAddressDetector.cs +++ b/UnchainedLauncher.CLI/LocalAddressDetector.cs @@ -29,4 +29,4 @@ internal static IPAddress Select(IEnumerable<(IPAddress Address, bool HasGateway && !(candidate.Address.GetAddressBytes()[0] == 169 && candidate.Address.GetAddressBytes()[1] == 254)) .OrderByDescending(candidate => candidate.HasGateway) .Select(candidate => candidate.Address).FirstOrDefault() ?? IPAddress.Loopback; -} +} \ No newline at end of file diff --git a/UnchainedLauncher.CLI/Program.cs b/UnchainedLauncher.CLI/Program.cs index 85f89b54..f7d5a8e2 100644 --- a/UnchainedLauncher.CLI/Program.cs +++ b/UnchainedLauncher.CLI/Program.cs @@ -57,4 +57,4 @@ internal static async Task SuperviseAsync(CliLaunchOptions options, Process } } } -} +} \ No newline at end of file diff --git a/UnchainedLauncher.CLI/RegistrySpec.cs b/UnchainedLauncher.CLI/RegistrySpec.cs index 82fe205e..23a80cb5 100644 --- a/UnchainedLauncher.CLI/RegistrySpec.cs +++ b/UnchainedLauncher.CLI/RegistrySpec.cs @@ -42,4 +42,4 @@ private IModRegistry LoadConfiguration() { }, _ => metadata }; -} +} \ No newline at end of file diff --git a/UnchainedLauncher.CLI/ServerLauncher.cs b/UnchainedLauncher.CLI/ServerLauncher.cs index 5f2523a8..97ec809b 100644 --- a/UnchainedLauncher.CLI/ServerLauncher.cs +++ b/UnchainedLauncher.CLI/ServerLauncher.cs @@ -1,7 +1,7 @@ using Octokit; using System.Diagnostics; -using System.Net; -using UnchainedLauncher.Core.JsonModels; +using System.Net; +using UnchainedLauncher.Core.JsonModels; using UnchainedLauncher.Core.Services; using UnchainedLauncher.Core.Services.Mods; using UnchainedLauncher.Core.Services.Mods.Registry; @@ -14,9 +14,9 @@ namespace UnchainedLauncher.CLI; public static class ServerLauncher { - public static async Task LaunchAsync(CliLaunchOptions request, CancellationToken cancellationToken) { - var options = request.Shared; - if (!OperatingSystem.IsWindows()) throw new PlatformNotSupportedException("Game launching requires Windows (Core's DLL injector uses kernel32.dll)."); + public static async Task LaunchAsync(CliLaunchOptions request, CancellationToken cancellationToken) { + var options = request.Shared; + if (!OperatingSystem.IsWindows()) throw new PlatformNotSupportedException("Game launching requires Windows (Core's DLL injector uses kernel32.dll)."); var executable = Path.Combine(options.Chivalry2Path, FilePaths.GameBinPath); var pakPath = Path.Combine(options.Chivalry2Path, FilePaths.PakDir); var pluginsPath = Path.Combine(options.Chivalry2Path, FilePaths.PluginDir); @@ -25,9 +25,9 @@ public static async Task LaunchAsync(CliLaunchOptions request, Cancella if (!options.UpdatePlugin && !File.Exists(Path.Combine(options.Chivalry2Path, FilePaths.UnchainedPluginPath))) throw new FileNotFoundException("--no-plugin-update requires an existing UnchainedPlugin.dll in the Plugins directory."); - var registry = CreateRegistry(request); + var registry = CreateRegistry(request); - return await InInstallationDirectoryAsync(request, async () => { + return await InInstallationDirectoryAsync(request, async () => { var cachePath = Path.Combine(options.Chivalry2Path, FilePaths.ModCachePath, "cli_mod_manager.json"); var codec = new ModManagerCodec(registry); ModManager? cached = null; @@ -48,7 +48,7 @@ public static async Task LaunchAsync(CliLaunchOptions request, Cancella } ValidateReleases(manager, options.Mods); - if (options.Platform == InstallationType.Steam) { + if (options.Platform == InstallationType.Steam) { var appIdPath = Path.Combine(options.Chivalry2Path, FilePaths.SteamAppIdPath); if (!File.Exists(appIdPath)) File.WriteAllText(appIdPath, "1824220"); } @@ -66,7 +66,7 @@ public static async Task LaunchAsync(CliLaunchOptions request, Cancella cancellationToken.ThrowIfCancellationRequested(); if (updated.IsNone) return LanguageExt.Option.None; if (!File.Exists(Path.Combine(pluginsPath, "UnchainedPlugin.dll"))) - throw new FileNotFoundException("UnchainedPlugin.dll is required for an Unchained launch."); + throw new FileNotFoundException("UnchainedPlugin.dll is required for an Unchained launch."); try { updated = await modInstaller.PrepareLaunch(updated.IfNone(launch)); } @@ -81,24 +81,24 @@ public static async Task LaunchAsync(CliLaunchOptions request, Cancella }); var launcher = new UnchainedChivalry2Launcher(preparer, new ProcessLauncher(executable), options.Chivalry2Path, new DllInjector(pluginsPath)); - var result = await launcher.Launch(request.ToLaunchOptions()); + var result = await launcher.Launch(request.ToLaunchOptions()); return result.Match( Right: process => process, // Do not print LaunchFailed.Message: it contains raw password/auth arguments. - Left: failure => throw new InvalidOperationException("Game launch or DLL injection failed. Check the installation, plugin, and mod files.", failure)); + Left: failure => throw new InvalidOperationException("Game launch or DLL injection failed. Check the installation, plugin, and mod files.", failure)); }); } - internal static AggregateModRegistry CreateRegistry(CliLaunchOptions options) => + internal static AggregateModRegistry CreateRegistry(CliLaunchOptions options) => new(new IModRegistry[] { new GithubModRegistry("Chiv2-Community", "C2ModRegistry") - }.Concat(options.Shared.Registries.Select(r => r.Create())).ToArray()); + }.Concat(options.Shared.Registries.Select(r => r.Create())).ToArray()); // Several existing Core preparers use installation-relative paths. // This standalone CLI sets CWD only for preparation, then restores it. - internal static async Task InInstallationDirectoryAsync(CliLaunchOptions options, Func> action) { + internal static async Task InInstallationDirectoryAsync(CliLaunchOptions options, Func> action) { var previousDirectory = Directory.GetCurrentDirectory(); - Directory.SetCurrentDirectory(options.Shared.Chivalry2Path); + Directory.SetCurrentDirectory(options.Shared.Chivalry2Path); try { return await action(); } @@ -128,9 +128,9 @@ public static void ValidateReleases(IModManager manager, IEnumerable Date: Sat, 10 Oct 2026 23:12:41 -0500 Subject: [PATCH 06/58] Parse CLI once and keep authoritative YAML free of response expansion --- .../CommandLineOptionsTests.cs | 124 +-- .../YamlConfigurationTests.cs | 717 ++++++++++-------- UnchainedLauncher.CLI/CommandLineOptions.cs | 324 ++++---- UnchainedLauncher.CLI/README.md | 7 + 4 files changed, 642 insertions(+), 530 deletions(-) diff --git a/UnchainedLauncher.CLI.Tests/CommandLineOptionsTests.cs b/UnchainedLauncher.CLI.Tests/CommandLineOptionsTests.cs index 5964e27b..42448bb5 100644 --- a/UnchainedLauncher.CLI.Tests/CommandLineOptionsTests.cs +++ b/UnchainedLauncher.CLI.Tests/CommandLineOptionsTests.cs @@ -1,56 +1,70 @@ -using Xunit; - -namespace UnchainedLauncher.CLI.Tests; - -public class CommandLineOptionsTests { - [Theory] - [InlineData("server")] - [InlineData("client")] - public void GeneratedHelpIsSpecificToTheCommand(string command) { - var options = CliLaunchOptions.Parse([command, "--help"]); - Assert.True(options.Help); - Assert.Contains("--chivalry2-path", options.HelpText); - Assert.Contains("--config-path", options.HelpText); - Assert.Equal(command == "server", options.HelpText.Contains("--no-desync-patch")); - Assert.Equal(command == "server", options.HelpText.Contains("--discord-bot-token")); - } - - [Fact] - public void RootHelpListsBothCommands() { - var options = CliLaunchOptions.Parse([]); - Assert.True(options.Help); - Assert.Contains("server", options.HelpText); - Assert.Contains("client", options.HelpText); - } - - [Theory] - [InlineData("--no-desync-patch")] - [InlineData("--name=test")] - [InlineData("--game-port=7778")] - [InlineData("--no-tui")] - public void ServerSpecificOptionsAreRejectedByClient(string flag) { - Assert.Throws(() => CliLaunchOptions.Parse([ - "client", "--chivalry2-path", ".", "--platform", "Steam", flag - ])); - } - - [Theory] - [InlineData("--no-desync-patch")] - [InlineData("--no-plugin-update")] - [InlineData("--name=test")] - public void SingletonOptionsCannotBeRepeated(string flag) { - Assert.Throws(() => CliLaunchOptions.Parse([ - "server", "--chivalry2-path", ".", "--platform", "Steam", flag, flag - ])); - } - - [Fact] - public void GameArgumentsMustFollowSeparatorAndArePreservedExactly() { - string[] prefix = ["server", "--chivalry2-path", ".", "--platform", "Steam"]; - Assert.Throws(() => CliLaunchOptions.Parse([.. prefix, "unexpected"])); - string[] forwarded = ["--help", "--config-path", "not-a-launcher-config", "-AUTH_PASSWORD=with spaces", "", "@game-file"]; - var options = CliLaunchOptions.Parse([.. prefix, "--", .. forwarded]); - Assert.False(options.Help); - Assert.Equal(forwarded, options.GameArguments); - } +using Xunit; + +namespace UnchainedLauncher.CLI.Tests; + +public class CommandLineOptionsTests { + [Theory] + [InlineData("server")] + [InlineData("client")] + public void GeneratedHelpIsSpecificToTheCommand(string command) { + var options = CliLaunchOptions.Parse([command, "--help"]); + Assert.True(options.Help); + Assert.Contains("--chivalry2-path", options.HelpText); + Assert.Contains("--config-path", options.HelpText); + Assert.Equal(command == "server", options.HelpText.Contains("--no-desync-patch")); + Assert.Equal(command == "server", options.HelpText.Contains("--discord-bot-token")); + } + + [Fact] + public void RootHelpListsBothCommands() { + var options = CliLaunchOptions.Parse([]); + Assert.True(options.Help); + Assert.Contains("server", options.HelpText); + Assert.Contains("client", options.HelpText); + } + + [Theory] + [InlineData("--no-desync-patch")] + [InlineData("--name=test")] + [InlineData("--game-port=7778")] + [InlineData("--no-tui")] + public void ServerSpecificOptionsAreRejectedByClient(string flag) { + Assert.Throws(() => CliLaunchOptions.Parse([ + "client", "--chivalry2-path", ".", "--platform", "Steam", flag + ])); + } + + [Theory] + [InlineData("--no-desync-patch")] + [InlineData("--no-plugin-update")] + [InlineData("--name=test")] + public void SingletonOptionsCannotBeRepeated(string flag) { + Assert.Throws(() => CliLaunchOptions.Parse([ + "server", "--chivalry2-path", ".", "--platform", "Steam", flag, flag + ])); + } + + [Fact] + public void HelpAndInvalidCliAlsoUseOneLibraryParse() { + foreach (var args in new string[][] { [], ["client", "--help"], ["server", "--platform=Invalid"] }) { + var calls = 0; + CliLaunchOptions Parse() => CommandLineOptions.Parse(args, (root, tokens, configuration) => { + calls++; + return root.Parse(tokens, configuration); + }); + if (args.Contains("--platform=Invalid")) Assert.Throws(Parse); + else Assert.True(Parse().Help); + Assert.Equal(1, calls); + } + } + + [Fact] + public void GameArgumentsMustFollowSeparatorAndArePreservedExactly() { + string[] prefix = ["server", "--chivalry2-path", ".", "--platform", "Steam"]; + Assert.Throws(() => CliLaunchOptions.Parse([.. prefix, "unexpected"])); + string[] forwarded = ["--help", "--config-path", "not-a-launcher-config", "-AUTH_PASSWORD=with spaces", "", "@game-file"]; + var options = CliLaunchOptions.Parse([.. prefix, "--", .. forwarded]); + Assert.False(options.Help); + Assert.Equal(forwarded, options.GameArguments); + } } \ No newline at end of file diff --git a/UnchainedLauncher.CLI.Tests/YamlConfigurationTests.cs b/UnchainedLauncher.CLI.Tests/YamlConfigurationTests.cs index d25fb593..42480162 100644 --- a/UnchainedLauncher.CLI.Tests/YamlConfigurationTests.cs +++ b/UnchainedLauncher.CLI.Tests/YamlConfigurationTests.cs @@ -1,318 +1,401 @@ -using System.Diagnostics; -using UnchainedLauncher.Core.JsonModels; -using UnchainedLauncher.Core.Services.Mods.Registry; -using Xunit; - -namespace UnchainedLauncher.CLI.Tests; - -[Collection("Installation directory")] -public class YamlConfigurationTests : IDisposable { - private readonly string _directory = Path.Combine( - OperatingSystem.IsLinux() ? "/tmp/opencode" : Path.GetTempPath(), "yaml-cli-tests-" + Guid.NewGuid().ToString("N")); - - public YamlConfigurationTests() => Directory.CreateDirectory(_directory); - public void Dispose() => Directory.Delete(_directory, recursive: true); - - private string Write(string body, bool includeRequired = true) { - var path = Path.Combine(_directory, "launch.yaml"); - File.WriteAllText(path, (includeRequired ? "chiv2-path: './game installation'\nplatform: Steam\n" : "") + body); - return path; - } - - private CliLaunchOptions Load(string body, bool client = false) => - CliLaunchOptions.Parse([client ? "client" : "server", "--config-path", Write(body)]); - - [Fact] - public void ConfigurationIsAuthoritativeWithEitherConfigPathSyntax() { - var path = Write("server:\n name: From YAML\n"); - foreach (var args in new[] { - new[] { "server", "--typo", "--name", "Ignored", "--config-path", path, "--", "-ignored-game-arg" }, - new[] { "server", "--config-path=" + path, "--platform=Invalid", "--help" } - }) { - var options = CliLaunchOptions.Parse(args); - Assert.IsType(options); - Assert.False(options.Help); - Assert.Equal("From YAML", Assert.IsType(options).Settings.Name); - Assert.Empty(options.GameArguments); - } - } - - [Fact] - public void ConfigPathAfterGameSeparatorIsForwardedNotLoaded() { - var options = CliLaunchOptions.Parse(["server", "--chivalry2-path", _directory, "--platform", "Steam", "--", "--config-path", "game-file"]); - Assert.Equal(new[] { "--config-path", "game-file" }, options.GameArguments); - } - - [Theory] - [InlineData(true)] - [InlineData(false)] - public void SubcommandSelectsModeRegardlessOfIncludedBlocks(bool client) { - var options = Load("client: {}\nserver: {}\n", client); - Assert.Equal(client, options is ClientOptions); - Assert.Equal(client, options.ToLaunchOptions().ServerLaunchOptions.IsNone); - Assert.Equal(client ? "TBL" : "TBL -server", options.ToLaunchOptions().LaunchArgs); - Assert.Equal(!client, options.ToLaunchOptions().ToCLIArgs().Any(arg => arg.Rendered == "-nullrhi")); - } - - [Fact] - public void ExplicitClientIgnoresServerFieldsEvenWhenTheyAreInvalid() { - var options = Load(""" - client: - game-args: ['-client-only'] - game-args: ['-AUTH_PASSWORD=auth value'] - server: - game-port: not-a-port - headless: not-a-boolean - typo: whatever - mod-actors: not-a-list - discord: - bot-token: secret - general-channel-id: [] - game-args: ['-server'] - """, client: true); - Assert.IsType(options); - var launch = options.ToLaunchOptions(); - Assert.True(launch.ServerLaunchOptions.IsNone); - Assert.DoesNotContain("-server", launch.LaunchArgs); - Assert.Contains("\"-AUTH_PASSWORD=auth value\"", launch.LaunchArgs); - Assert.Contains("-client-only", launch.LaunchArgs); - } - - [Fact] - public void ServerIgnoresClientBlockAndEachCommandValidatesOnlyItsOwnSchema() { - var server = Load("client: not-a-mapping\nserver: {name: Correct}"); - Assert.Equal("Correct", Assert.IsType(server).Settings.Name); - Assert.Throws(() => Load("client: not-a-mapping", client: true)); - Assert.Throws(() => Load("client: {typo: invalid}", client: true)); - Assert.IsType(Load("server: not-a-mapping\nclient: {}", client: true)); - } - - [Fact] - public void ServerSafetyFlagsDefaultOnAndCanBeDisabledInYaml() { - var defaults = Assert.IsType(Load("server: {}")).ToServerLaunchOptions(); - Assert.True(defaults.DesyncPatch); - Assert.True(defaults.UseBackendBanlist); - Assert.True(defaults.LocalIp.IsSome); - var disabled = Assert.IsType(Load("server: {desync-patch: false, use-backend-banlist: false}")).ToServerLaunchOptions(); - Assert.False(disabled.DesyncPatch); - Assert.False(disabled.UseBackendBanlist); - Assert.DoesNotContain("--desync-patch", disabled.ToCLIArgs().Select(arg => arg.Rendered)); - Assert.DoesNotContain("--use-backend-banlist", disabled.ToCLIArgs().Select(arg => arg.Rendered)); - } - - [Theory] - [InlineData("ffa-time-limit")] - [InlineData("ffa-score-limit")] - [InlineData("tdm-time-limit")] - [InlineData("tdm-ticket-count")] - [InlineData("player-bot-count")] - [InlineData("warmup-time")] - public void IniFieldsAreNotExposedInYamlOrCommandLine(string field) { - Assert.Throws(() => Load($"server: {{{field}: 1}}")); - Assert.Throws(() => CliLaunchOptions.Parse(["server", "--chivalry2-path", ".", "--platform", "Steam", $"--{field}=1"])); - Assert.IsType(Load($"server: {{{field}: 1}}", client: true)); - } - - [Fact] - public void PathsAreRelativeToYamlFileWithoutChangingCwdAndDefaultRegistryRemains() { - var cwd = Directory.GetCurrentDirectory(); - Directory.CreateDirectory(Path.Combine(_directory, "local registry")); - var options = Load(""" - saved-dir-suffix: Test_1 - update-plugin: false - allow-unstable-plugin: true - no-tui: true - mod-setup: - registries: ['local:local registry', 'config:registries.json', 'github:owner/repo'] - mods: ['org/one:v1.2.3', 'org/two:v2.0.0'] - dlls: ['org/plugin:v1.0.0'] - """); - Assert.Equal(cwd, Directory.GetCurrentDirectory()); - Assert.Equal(Path.Combine(_directory, "game installation"), options.Shared.Chivalry2Path); - Assert.Equal(Path.Combine(_directory, "local registry"), options.Shared.Registries[0].Location); - Assert.Equal(Path.Combine(_directory, "registries.json"), options.Shared.Registries[1].Location); - Assert.Equal("owner/repo", options.Shared.Registries[2].Location); - Assert.Equal(2, options.Shared.Mods.Count); - Assert.Single(options.Shared.Dlls); - Assert.Equal("Test_1", options.Shared.SavedDirSuffix); - Assert.False(options.Shared.UpdatePlugin); - Assert.True(options.Shared.AllowUnstablePlugin); - Assert.True(Assert.IsType(options).NoTui); - var plain = Load("mod-setup: { registries: [], mods: [], dlls: [] }"); - var official = Assert.Single(ServerLauncher.CreateRegistry(plain).ModRegistries); - Assert.IsType(official); - Assert.Equal("Chiv2-Community", ((GithubModRegistry)official).Organization); - } - - [Fact] - public void AllServerAndDiscordFieldsMapToCoreAndRenderedArguments() { - var options = Load(""" - client: - typo: this ignored client field would be invalid in client mode - game-args: ['-client-only'] - server-browser-backend: https://example.test - game-args: ['-log'] - server: - headless: false - name: YAML Server - description: Example MOTD - password: private password - map: TO_Test - local-ip: 127.0.0.2 - game-port: 10001 - ping-port: 10002 - query-port: 10003 - rcon-port: 10004 - register: true - mod-actors: ['/Game/A.A_C', '/Game/B.B_C', '/Game/A.A_C'] - desync-patch: true - use-backend-banlist: true - censor-mode: zealous - game-args: ['-custom=value with spaces'] - discord: - bot-token: '--secret-token' - admin-channel-id: '123456789012345678' - general-channel-id: '234567890123456789' - dashboard-channel-id: '345678901234567890' - event-log-channel-id: '456789012345678901' - admin-role-id: '567890123456789012' - mention-admins: false - """); - var server = Assert.IsType(options).ToServerLaunchOptions(); - Assert.False(server.Headless); - Assert.Equal("YAML Server", server.Name); - Assert.Equal("Example MOTD", server.Description); - Assert.Equal("private password", server.Password.IfNone("")); - Assert.Equal("TO_Test", server.Map); - Assert.Equal("127.0.0.2", server.LocalIp.IfNone("")); - Assert.Equal((10001, 10002, 10003, 10004), (server.GamePort, server.BeaconPort, server.QueryPort, server.RconPort)); - Assert.True(server.RegisterWithBackend); - Assert.True(server.DesyncPatch); - Assert.True(server.UseBackendBanlist); - Assert.Equal(Core.Services.Processes.Chivalry.CensorArg.Zealous, server.CensorMode); - Assert.Null(server.FFATimeLimit); - Assert.Null(server.FFAScoreLimit); - Assert.Null(server.TDMTimeLimit); - Assert.Null(server.TDMTicketCount); - Assert.Null(server.PlayerBotCount); - Assert.Null(server.WarmupTime); - Assert.Equal(2, server.ServerMods.Count()); - var rendered = server.ToCLIArgs().Select(arg => arg.Rendered).ToArray(); - Assert.Contains("--server-mods /Game/A.A_C,/Game/B.B_C", rendered); - Assert.Contains("--desync-patch", rendered); - Assert.Contains("--use-backend-banlist", rendered); - Assert.Contains("--discord-bot-token --secret-token", rendered); - Assert.Contains("--discord-mention-admins false", rendered); - Assert.Contains("--discord-admin-channel-id 123456789012345678", rendered); - Assert.Contains("--discord-general-channel-id 234567890123456789", rendered); - Assert.Contains("--discord-dashboard-channel-id 345678901234567890", rendered); - Assert.Contains("--discord-event-log-channel-id 456789012345678901", rendered); - Assert.Contains("--discord-admin-role-id 567890123456789012", rendered); - Assert.DoesNotContain("-nullrhi", rendered); - Assert.Contains("-log", options.ToLaunchOptions().LaunchArgs); - Assert.Contains("\"-custom=value with spaces\"", options.ToLaunchOptions().LaunchArgs); - Assert.DoesNotContain("-client-only", options.ToLaunchOptions().LaunchArgs); - } - - [Theory] - [InlineData("typo: value")] - [InlineData("server: []")] - [InlineData("mod-setup: {registries: not-a-list}")] - [InlineData("mod-setup: {mods: [null]}")] - [InlineData("mod-setup: {mods: ['org/mod:v1', 'org/mod:v2']}")] - [InlineData("server: {query-port: 7777}")] - [InlineData("server: {game-port: 0}")] - [InlineData("server: {game-port: 1.5}")] - [InlineData("server: {headless: invalid}")] - [InlineData("server: {typo: true}")] - [InlineData("server: {censor-mode: invalid}")] - [InlineData("server: {warmup-time: -1}")] - [InlineData("server: {discord: {bot-token: secret}}")] - [InlineData("server: {discord: {general-channel-id: '123'}}")] - [InlineData("server: {name: one, name: two}")] - [InlineData("game-args: {invalid: mapping}")] - public void InvalidConfigurationFailsBeforeLaunching(string body) { - Assert.Throws(() => Load(body)); - } - - [Fact] - public void InvalidYamlMissingFilesAndDocumentsFailWithoutExposingSecrets() { - var malformed = Write("server:\n password: TOP-SECRET\n discord: [\n"); - var error = Assert.Throws(() => CliLaunchOptions.Parse(["server", "--config-path", malformed])); - Assert.DoesNotContain("TOP-SECRET", error.Message); - Assert.Throws(() => YamlConfiguration.Load(Path.Combine(_directory, "missing.yaml"), false)); - Assert.Throws(() => YamlConfiguration.Load(Write("", includeRequired: false), false)); - Assert.Throws(() => YamlConfiguration.Load(Write("---\nchiv2-path: .\nplatform: Steam\n"), false)); - Assert.Throws(() => YamlConfiguration.Load(Write("platform: Steam", includeRequired: false), false)); - Assert.Throws(() => YamlConfiguration.Load(Write("chiv2-path: .", includeRequired: false), false)); - Assert.Throws(() => CliLaunchOptions.Parse(["server", "--config-path"])); - Assert.Throws(() => CliLaunchOptions.Parse(["server", "--config-path="])); - Assert.Throws(() => CliLaunchOptions.Parse(["server", "--config-path=x", "--config-path=y"])); - Assert.Throws(() => CliLaunchOptions.Parse(["--config-path", Write("")])); - } - - [Fact] - public void DirectClientCommandHasNoServerOptions() { - var options = CliLaunchOptions.Parse(["client", "--chivalry2-path", _directory, "--platform", "EGS"]); - Assert.IsType(options); - Assert.True(options.ToLaunchOptions().ServerLaunchOptions.IsNone); - Assert.Equal("TBL", options.ToLaunchOptions().LaunchArgs); - } - - [Theory] - [InlineData("Steam", InstallationType.Steam)] - [InlineData("EGS", InstallationType.EpicGamesStore)] - [InlineData("EpicGamesStore", InstallationType.EpicGamesStore)] - public void YamlUsesCoreInstallationTypeAndPreservesEgsAlias(string platform, InstallationType expected) { - var path = Write($"chiv2-path: .\nplatform: {platform}\nupdate-plugin: false\nallow-unstable-plugin: true\n", includeRequired: false); - foreach (var command in new[] { "server", "client" }) { - var options = CliLaunchOptions.Parse([command, "--config-path", path]); - Assert.Equal(expected, options.Shared.Platform); - Assert.False(options.Shared.UpdatePlugin); - Assert.True(options.Shared.AllowUnstablePlugin); - } - } - - [Theory] - [InlineData("NotSet")] - [InlineData("1")] - [InlineData("Unknown")] - public void PlaceholderAndNumericPlatformsAreNotLaunchable(string platform) { - Assert.Throws(() => CliLaunchOptions.Parse(["client", "--chivalry2-path", ".", "--platform", platform])); - var path = Write($"chiv2-path: .\nplatform: {platform}\n", includeRequired: false); - Assert.Throws(() => CliLaunchOptions.Parse(["server", "--config-path", path])); - } - - [Fact] - public void EmptyOptionalServerFieldsAreAllowed() { - var options = Load("server: {description: '', password: '', local-ip: '', discord: {bot-token: '', general-channel-id: ''}}"); - Assert.Equal("", Assert.IsType(options).Settings.Description); - Assert.True(Assert.IsType(options).ToServerLaunchOptions().Password.IsNone); - Assert.True(Assert.IsType(options).ToDiscordOptions().IsNone); - } - - [Theory] - [InlineData(true)] - [InlineData(false)] - public async Task ProductionSupervisionLeavesClientRunningButStopsServer(bool client) { - var options = Load(client ? "client: {}\n" : "no-tui: true\n", client); - using var child = Process.Start(new ProcessStartInfo { - FileName = OperatingSystem.IsWindows() ? "cmd.exe" : "/bin/sleep", - Arguments = OperatingSystem.IsWindows() ? "/c ping -n 30 127.0.0.1 > nul" : "30", - UseShellExecute = false, - CreateNoWindow = true - })!; - using var stop = new CancellationTokenSource(); - stop.Cancel(); - try { - Assert.Equal(0, await Program.SuperviseAsync(options, child, stop)); - Assert.Equal(!client, child.HasExited); - } - finally { - if (!child.HasExited) { - child.Kill(entireProcessTree: true); - await child.WaitForExitAsync(); - } - } - } +using System.Diagnostics; +using UnchainedLauncher.Core.JsonModels; +using UnchainedLauncher.Core.Services.Mods.Registry; +using Xunit; + +namespace UnchainedLauncher.CLI.Tests; + +[Collection("Installation directory")] +public class YamlConfigurationTests : IDisposable { + private readonly string _directory = Path.Combine( + OperatingSystem.IsLinux() ? "/tmp/opencode" : Path.GetTempPath(), "yaml-cli-tests-" + Guid.NewGuid().ToString("N")); + + public YamlConfigurationTests() => Directory.CreateDirectory(_directory); + public void Dispose() => Directory.Delete(_directory, recursive: true); + + private string Write(string body, bool includeRequired = true) { + var path = Path.Combine(_directory, "launch.yaml"); + File.WriteAllText(path, (includeRequired ? "chiv2-path: './game installation'\nplatform: Steam\n" : "") + body); + return path; + } + + private CliLaunchOptions Load(string body, bool client = false) => + CliLaunchOptions.Parse([client ? "client" : "server", "--config-path", Write(body)]); + + [Fact] + public void ConfigurationIsAuthoritativeWithEitherConfigPathSyntax() { + var path = Write("server:\n name: From YAML\n"); + foreach (var args in new[] { + new[] { "server", "--typo", "--name", "Ignored", "--config-path", path, "--", "-ignored-game-arg" }, + new[] { "server", "--config-path=" + path, "--platform=Invalid", "--help" } + }) { + var options = CliLaunchOptions.Parse(args); + Assert.IsType(options); + Assert.False(options.Help); + Assert.Equal("From YAML", Assert.IsType(options).Settings.Name); + Assert.Empty(options.GameArguments); + } + } + + [Fact] + public void AuthoritativeYamlIgnoresResponseFileTokensAndAllOtherOptionErrors() { + var path = Write("server: {name: From YAML}\n"); + var response = Path.Combine(_directory, "ignored.rsp"); + File.WriteAllText(response, "--config-path=must-not-be-loaded --name=must-not-be-bound"); + foreach (var command in new[] { "server", "client" }) { + foreach (var token in new[] { "@" + response, "@" + Path.Combine(_directory, "missing.rsp") }) { + string[] ignored = ["--name", token, "--name=duplicate", "--platform=Invalid", "--game-port=invalid", "--headless=invalid", + "--no-plugin-update", "--no-plugin-update", "--unknown", "unexpected", "--mod=invalid", token]; + foreach (var args in new string[][] { + [command, "--config-path", path, .. ignored, "--", "@game-response"], + [command, .. ignored, "--config-path=" + path], + [command, .. ignored, "--config-path=" + path, "--help"] + }) { + var options = CliLaunchOptions.Parse(args); + Assert.False(options.Help); + Assert.True(options.Shared.UpdatePlugin); + Assert.Empty(options.GameArguments); + if (command == "server") Assert.Equal("From YAML", Assert.IsType(options).Settings.Name); + else Assert.IsType(options); + } + } + } + } + + [Fact] + public void OrdinaryOptionValuesDoNotExpandExistingOrMissingResponseFiles() { + var response = Path.Combine(_directory, "ordinary.rsp"); + File.WriteAllText(response, "--name expanded --platform Invalid"); + foreach (var token in new[] { "@" + response, "@" + Path.Combine(_directory, "missing.rsp") }) { + var options = CliLaunchOptions.Parse(["server", "--chivalry2-path", ".", "--platform", "Steam", "--name", token]); + Assert.Equal(token, Assert.IsType(options).Settings.Name); + Assert.Throws(() => CliLaunchOptions.Parse(["server", "--chivalry2-path", ".", "--platform", "Steam", token])); + } + } + + [Theory] + [InlineData("server", false)] + [InlineData("server", true)] + [InlineData("client", false)] + [InlineData("client", true)] + public void OneLibraryParseControlsBothOrdinaryAndYamlLaunches(string command, bool yaml) { + string[] args = yaml + ? [command, "--config-path", Write(""), "--platform=Invalid", "--unknown"] + : [command, "--chivalry2-path", ".", "--platform", "Steam"]; + var calls = 0; + var options = CommandLineOptions.Parse([.. args, "--", "@game-file", "--config-path", "not-config"], (root, tokens, configuration) => { + calls++; + Assert.Null(configuration.ResponseFileTokenReplacer); + Assert.Equal(args, tokens); + Assert.Equal(new[] { "server", "client" }, root.Subcommands.Select(subcommand => subcommand.Name)); + Assert.Single(root.Options.Where(option => option.Name == "--config-path")); + foreach (var subcommand in root.Subcommands) { + Assert.Single(subcommand.Options.Where(option => option.Name == "--platform")); + Assert.Single(subcommand.Options.Where(option => option.Name == "--chivalry2-path")); + } + return root.Parse(tokens, configuration); + }); + Assert.Equal(1, calls); + Assert.Equal(command == "server", options is ServerOptions); + Assert.Equal(yaml ? [] : new[] { "@game-file", "--config-path", "not-config" }, options.GameArguments); + } + + [Fact] + public void AuthoritativeYamlStillRejectsMissingEmptyAndDuplicateSelectors() { + var path = Write(""); + foreach (var command in new[] { "server", "client" }) { + foreach (var selectors in new[] { + new[] { "--config-path" }, + new[] { "--config-path=" }, + new[] { "--config-path", " " }, + new[] { "--config-path", path, "--config-path", path }, + new[] { "--config-path=" + path, "--config-path=" + path }, + new[] { "--config-path", path, "--config-path" } + }) { + Assert.Throws(() => CliLaunchOptions.Parse([command, "--platform=Invalid", .. selectors])); + Assert.Throws(() => CliLaunchOptions.Parse([command, "--platform=Invalid", .. selectors, "--help"])); + } + } + Assert.Throws(() => CliLaunchOptions.Parse(["--config-path", path])); + Assert.Throws(() => CliLaunchOptions.Parse(["--config-path", path, "--help"])); + } + + [Fact] + public void ConfigPathAfterGameSeparatorIsForwardedNotLoaded() { + var options = CliLaunchOptions.Parse(["server", "--chivalry2-path", _directory, "--platform", "Steam", "--", "--config-path", "game-file"]); + Assert.Equal(new[] { "--config-path", "game-file" }, options.GameArguments); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void SubcommandSelectsModeRegardlessOfIncludedBlocks(bool client) { + var options = Load("client: {}\nserver: {}\n", client); + Assert.Equal(client, options is ClientOptions); + Assert.Equal(client, options.ToLaunchOptions().ServerLaunchOptions.IsNone); + Assert.Equal(client ? "TBL" : "TBL -server", options.ToLaunchOptions().LaunchArgs); + Assert.Equal(!client, options.ToLaunchOptions().ToCLIArgs().Any(arg => arg.Rendered == "-nullrhi")); + } + + [Fact] + public void ExplicitClientIgnoresServerFieldsEvenWhenTheyAreInvalid() { + var options = Load(""" + client: + game-args: ['-client-only'] + game-args: ['-AUTH_PASSWORD=auth value'] + server: + game-port: not-a-port + headless: not-a-boolean + typo: whatever + mod-actors: not-a-list + discord: + bot-token: secret + general-channel-id: [] + game-args: ['-server'] + """, client: true); + Assert.IsType(options); + var launch = options.ToLaunchOptions(); + Assert.True(launch.ServerLaunchOptions.IsNone); + Assert.DoesNotContain("-server", launch.LaunchArgs); + Assert.Contains("\"-AUTH_PASSWORD=auth value\"", launch.LaunchArgs); + Assert.Contains("-client-only", launch.LaunchArgs); + } + + [Fact] + public void ServerIgnoresClientBlockAndEachCommandValidatesOnlyItsOwnSchema() { + var server = Load("client: not-a-mapping\nserver: {name: Correct}"); + Assert.Equal("Correct", Assert.IsType(server).Settings.Name); + Assert.Throws(() => Load("client: not-a-mapping", client: true)); + Assert.Throws(() => Load("client: {typo: invalid}", client: true)); + Assert.IsType(Load("server: not-a-mapping\nclient: {}", client: true)); + } + + [Fact] + public void ServerSafetyFlagsDefaultOnAndCanBeDisabledInYaml() { + var defaults = Assert.IsType(Load("server: {}")).ToServerLaunchOptions(); + Assert.True(defaults.DesyncPatch); + Assert.True(defaults.UseBackendBanlist); + Assert.True(defaults.LocalIp.IsSome); + var disabled = Assert.IsType(Load("server: {desync-patch: false, use-backend-banlist: false}")).ToServerLaunchOptions(); + Assert.False(disabled.DesyncPatch); + Assert.False(disabled.UseBackendBanlist); + Assert.DoesNotContain("--desync-patch", disabled.ToCLIArgs().Select(arg => arg.Rendered)); + Assert.DoesNotContain("--use-backend-banlist", disabled.ToCLIArgs().Select(arg => arg.Rendered)); + } + + [Theory] + [InlineData("ffa-time-limit")] + [InlineData("ffa-score-limit")] + [InlineData("tdm-time-limit")] + [InlineData("tdm-ticket-count")] + [InlineData("player-bot-count")] + [InlineData("warmup-time")] + public void IniFieldsAreNotExposedInYamlOrCommandLine(string field) { + Assert.Throws(() => Load($"server: {{{field}: 1}}")); + Assert.Throws(() => CliLaunchOptions.Parse(["server", "--chivalry2-path", ".", "--platform", "Steam", $"--{field}=1"])); + Assert.IsType(Load($"server: {{{field}: 1}}", client: true)); + } + + [Fact] + public void PathsAreRelativeToYamlFileWithoutChangingCwdAndDefaultRegistryRemains() { + var cwd = Directory.GetCurrentDirectory(); + Directory.CreateDirectory(Path.Combine(_directory, "local registry")); + var options = Load(""" + saved-dir-suffix: Test_1 + update-plugin: false + allow-unstable-plugin: true + no-tui: true + mod-setup: + registries: ['local:local registry', 'config:registries.json', 'github:owner/repo'] + mods: ['org/one:v1.2.3', 'org/two:v2.0.0'] + dlls: ['org/plugin:v1.0.0'] + """); + Assert.Equal(cwd, Directory.GetCurrentDirectory()); + Assert.Equal(Path.Combine(_directory, "game installation"), options.Shared.Chivalry2Path); + Assert.Equal(Path.Combine(_directory, "local registry"), options.Shared.Registries[0].Location); + Assert.Equal(Path.Combine(_directory, "registries.json"), options.Shared.Registries[1].Location); + Assert.Equal("owner/repo", options.Shared.Registries[2].Location); + Assert.Equal(2, options.Shared.Mods.Count); + Assert.Single(options.Shared.Dlls); + Assert.Equal("Test_1", options.Shared.SavedDirSuffix); + Assert.False(options.Shared.UpdatePlugin); + Assert.True(options.Shared.AllowUnstablePlugin); + Assert.True(Assert.IsType(options).NoTui); + var plain = Load("mod-setup: { registries: [], mods: [], dlls: [] }"); + var official = Assert.Single(ServerLauncher.CreateRegistry(plain).ModRegistries); + Assert.IsType(official); + Assert.Equal("Chiv2-Community", ((GithubModRegistry)official).Organization); + } + + [Fact] + public void AllServerAndDiscordFieldsMapToCoreAndRenderedArguments() { + var options = Load(""" + client: + typo: this ignored client field would be invalid in client mode + game-args: ['-client-only'] + server-browser-backend: https://example.test + game-args: ['-log'] + server: + headless: false + name: YAML Server + description: Example MOTD + password: private password + map: TO_Test + local-ip: 127.0.0.2 + game-port: 10001 + ping-port: 10002 + query-port: 10003 + rcon-port: 10004 + register: true + mod-actors: ['/Game/A.A_C', '/Game/B.B_C', '/Game/A.A_C'] + desync-patch: true + use-backend-banlist: true + censor-mode: zealous + game-args: ['-custom=value with spaces'] + discord: + bot-token: '--secret-token' + admin-channel-id: '123456789012345678' + general-channel-id: '234567890123456789' + dashboard-channel-id: '345678901234567890' + event-log-channel-id: '456789012345678901' + admin-role-id: '567890123456789012' + mention-admins: false + """); + var server = Assert.IsType(options).ToServerLaunchOptions(); + Assert.False(server.Headless); + Assert.Equal("YAML Server", server.Name); + Assert.Equal("Example MOTD", server.Description); + Assert.Equal("private password", server.Password.IfNone("")); + Assert.Equal("TO_Test", server.Map); + Assert.Equal("127.0.0.2", server.LocalIp.IfNone("")); + Assert.Equal((10001, 10002, 10003, 10004), (server.GamePort, server.BeaconPort, server.QueryPort, server.RconPort)); + Assert.True(server.RegisterWithBackend); + Assert.True(server.DesyncPatch); + Assert.True(server.UseBackendBanlist); + Assert.Equal(Core.Services.Processes.Chivalry.CensorArg.Zealous, server.CensorMode); + Assert.Null(server.FFATimeLimit); + Assert.Null(server.FFAScoreLimit); + Assert.Null(server.TDMTimeLimit); + Assert.Null(server.TDMTicketCount); + Assert.Null(server.PlayerBotCount); + Assert.Null(server.WarmupTime); + Assert.Equal(2, server.ServerMods.Count()); + var rendered = server.ToCLIArgs().Select(arg => arg.Rendered).ToArray(); + Assert.Contains("--server-mods /Game/A.A_C,/Game/B.B_C", rendered); + Assert.Contains("--desync-patch", rendered); + Assert.Contains("--use-backend-banlist", rendered); + Assert.Contains("--discord-bot-token --secret-token", rendered); + Assert.Contains("--discord-mention-admins false", rendered); + Assert.Contains("--discord-admin-channel-id 123456789012345678", rendered); + Assert.Contains("--discord-general-channel-id 234567890123456789", rendered); + Assert.Contains("--discord-dashboard-channel-id 345678901234567890", rendered); + Assert.Contains("--discord-event-log-channel-id 456789012345678901", rendered); + Assert.Contains("--discord-admin-role-id 567890123456789012", rendered); + Assert.DoesNotContain("-nullrhi", rendered); + Assert.Contains("-log", options.ToLaunchOptions().LaunchArgs); + Assert.Contains("\"-custom=value with spaces\"", options.ToLaunchOptions().LaunchArgs); + Assert.DoesNotContain("-client-only", options.ToLaunchOptions().LaunchArgs); + } + + [Theory] + [InlineData("typo: value")] + [InlineData("server: []")] + [InlineData("mod-setup: {registries: not-a-list}")] + [InlineData("mod-setup: {mods: [null]}")] + [InlineData("mod-setup: {mods: ['org/mod:v1', 'org/mod:v2']}")] + [InlineData("server: {query-port: 7777}")] + [InlineData("server: {game-port: 0}")] + [InlineData("server: {game-port: 1.5}")] + [InlineData("server: {headless: invalid}")] + [InlineData("server: {typo: true}")] + [InlineData("server: {censor-mode: invalid}")] + [InlineData("server: {warmup-time: -1}")] + [InlineData("server: {discord: {bot-token: secret}}")] + [InlineData("server: {discord: {general-channel-id: '123'}}")] + [InlineData("server: {name: one, name: two}")] + [InlineData("game-args: {invalid: mapping}")] + public void InvalidConfigurationFailsBeforeLaunching(string body) { + Assert.Throws(() => Load(body)); + } + + [Fact] + public void InvalidYamlMissingFilesAndDocumentsFailWithoutExposingSecrets() { + var malformed = Write("server:\n password: TOP-SECRET\n discord: [\n"); + var error = Assert.Throws(() => CliLaunchOptions.Parse(["server", "--config-path", malformed])); + Assert.DoesNotContain("TOP-SECRET", error.Message); + Assert.Throws(() => YamlConfiguration.Load(Path.Combine(_directory, "missing.yaml"), false)); + Assert.Throws(() => YamlConfiguration.Load(Write("", includeRequired: false), false)); + Assert.Throws(() => YamlConfiguration.Load(Write("---\nchiv2-path: .\nplatform: Steam\n"), false)); + Assert.Throws(() => YamlConfiguration.Load(Write("platform: Steam", includeRequired: false), false)); + Assert.Throws(() => YamlConfiguration.Load(Write("chiv2-path: .", includeRequired: false), false)); + Assert.Throws(() => CliLaunchOptions.Parse(["server", "--config-path"])); + Assert.Throws(() => CliLaunchOptions.Parse(["server", "--config-path="])); + Assert.Throws(() => CliLaunchOptions.Parse(["server", "--config-path=x", "--config-path=y"])); + Assert.Throws(() => CliLaunchOptions.Parse(["--config-path", Write("")])); + } + + [Fact] + public void DirectClientCommandHasNoServerOptions() { + var options = CliLaunchOptions.Parse(["client", "--chivalry2-path", _directory, "--platform", "EGS"]); + Assert.IsType(options); + Assert.True(options.ToLaunchOptions().ServerLaunchOptions.IsNone); + Assert.Equal("TBL", options.ToLaunchOptions().LaunchArgs); + } + + [Theory] + [InlineData("Steam", InstallationType.Steam)] + [InlineData("EGS", InstallationType.EpicGamesStore)] + [InlineData("EpicGamesStore", InstallationType.EpicGamesStore)] + public void YamlUsesCoreInstallationTypeAndPreservesEgsAlias(string platform, InstallationType expected) { + var path = Write($"chiv2-path: .\nplatform: {platform}\nupdate-plugin: false\nallow-unstable-plugin: true\n", includeRequired: false); + foreach (var command in new[] { "server", "client" }) { + var options = CliLaunchOptions.Parse([command, "--config-path", path]); + Assert.Equal(expected, options.Shared.Platform); + Assert.False(options.Shared.UpdatePlugin); + Assert.True(options.Shared.AllowUnstablePlugin); + } + } + + [Theory] + [InlineData("NotSet")] + [InlineData("1")] + [InlineData("Unknown")] + public void PlaceholderAndNumericPlatformsAreNotLaunchable(string platform) { + Assert.Throws(() => CliLaunchOptions.Parse(["client", "--chivalry2-path", ".", "--platform", platform])); + var path = Write($"chiv2-path: .\nplatform: {platform}\n", includeRequired: false); + Assert.Throws(() => CliLaunchOptions.Parse(["server", "--config-path", path])); + } + + [Fact] + public void EmptyOptionalServerFieldsAreAllowed() { + var options = Load("server: {description: '', password: '', local-ip: '', discord: {bot-token: '', general-channel-id: ''}}"); + Assert.Equal("", Assert.IsType(options).Settings.Description); + Assert.True(Assert.IsType(options).ToServerLaunchOptions().Password.IsNone); + Assert.True(Assert.IsType(options).ToDiscordOptions().IsNone); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task ProductionSupervisionLeavesClientRunningButStopsServer(bool client) { + var options = Load(client ? "client: {}\n" : "no-tui: true\n", client); + using var child = Process.Start(new ProcessStartInfo { + FileName = OperatingSystem.IsWindows() ? "cmd.exe" : "/bin/sleep", + Arguments = OperatingSystem.IsWindows() ? "/c ping -n 30 127.0.0.1 > nul" : "30", + UseShellExecute = false, + CreateNoWindow = true + })!; + using var stop = new CancellationTokenSource(); + stop.Cancel(); + try { + Assert.Equal(0, await Program.SuperviseAsync(options, child, stop)); + Assert.Equal(!client, child.HasExited); + } + finally { + if (!child.HasExited) { + child.Kill(entireProcessTree: true); + await child.WaitForExitAsync(); + } + } + } } \ No newline at end of file diff --git a/UnchainedLauncher.CLI/CommandLineOptions.cs b/UnchainedLauncher.CLI/CommandLineOptions.cs index 22c28d32..4a479330 100644 --- a/UnchainedLauncher.CLI/CommandLineOptions.cs +++ b/UnchainedLauncher.CLI/CommandLineOptions.cs @@ -1,159 +1,167 @@ -using System.CommandLine; -using System.CommandLine.Help; -using UnchainedLauncher.Core.JsonModels; - -namespace UnchainedLauncher.CLI; - -internal static class CommandLineOptions { - public static CliLaunchOptions Parse(string[] args) { - var separator = Array.IndexOf(args, "--"); - var launcherArgs = separator < 0 ? args : args[..separator]; - var gameArgs = separator < 0 ? [] : args[(separator + 1)..]; - // Probe only the configuration selector. All other options are deliberately - // ignored when YAML is authoritative, even if their values are invalid. - var selector = new RootCommand(); - var config = new Option("--config-path") { Arity = ArgumentArity.ExactlyOne }; - config.Validators.Add(result => { - if (result.Tokens.Count != 1 || string.IsNullOrWhiteSpace(result.Tokens[0].Value)) - result.AddError("Specify exactly one non-empty --config-path."); - }); - foreach (var name in new[] { "server", "client" }) { - var command = new Command(name) { TreatUnmatchedTokensAsErrors = false }; - command.Options.Add(config); - selector.Subcommands.Add(command); - } - var probe = selector.Parse(launcherArgs); - if (probe.GetResult(config) is { } configResult) { - ThrowErrors(probe); - return YamlConfiguration.Load(probe.GetValue(config)!, probe.CommandResult.Command.Name == "client"); - } - - var root = new RootCommand("Unchained client/server launcher. Windows is required for launching and DLL injection."); - var server = new Definition(client: false); - var client = new Definition(client: true); - root.Subcommands.Add(server.Command); - root.Subcommands.Add(client.Command); - var parsed = root.Parse(launcherArgs); - var definition = parsed.CommandResult.Command == server.Command ? server : client; - if (args.Length == 0 || parsed.GetResult("--help") is not null) { - using var output = new StringWriter(); - var help = new HelpAction(); - parsed.InvocationConfiguration.Output = output; - help.Invoke(parsed); - return CliLaunchOptions.ForHelp(output.ToString()); - } - ThrowErrors(parsed); - if (parsed.CommandResult.Command == root) throw new ArgumentException("Expected the 'server' or 'client' subcommand."); - return definition.ToOptions(parsed, gameArgs); - } - - private static void ThrowErrors(ParseResult result) { - if (result.Errors.Count != 0) throw new ArgumentException(string.Join(Environment.NewLine, result.Errors.Select(error => error.Message))); - } - - private sealed class Definition { - public Command Command { get; } - private readonly LaunchConfiguration _configuration; - private readonly List> _bindings = []; - private readonly Argument _gameArgs = new("game-args") { Arity = ArgumentArity.ZeroOrMore }; - - public Definition(bool client) { - Command = new Command(client ? "client" : "server", client ? "Launch an Unchained client." : "Launch and monitor an Unchained server."); - _configuration = client ? new ClientConfiguration() : new ServerConfiguration { Server = new ServerSettings() }; - Add("--config-path", "YAML replaces other options; the subcommand selects its schema.", null, _ => { }); - Add("--chivalry2-path", "Game installation directory.", null, value => _configuration.Chiv2Path = value, required: true); - var platform = new Option("--platform") { - Description = "Steam or EGS (EpicGamesStore); EGS auth must be forwarded after --.", - Arity = ArgumentArity.ExactlyOne, - Required = true, - CustomParser = result => { - if (result.Tokens.Count != 1) { result.AddError("Specify --platform once."); return null; } - try { return InstallationTypeYamlConverter.Parse(result.Tokens[0].Value); } - catch (ArgumentException ex) { result.AddError(ex.Message); return null; } - } - }; - Command.Options.Add(platform); - _bindings.Add(result => _configuration.Platform = result.GetValue(platform)); - Add("--saved-dir-suffix", "Saved profile suffix.", _configuration.SavedDirSuffix, value => _configuration.SavedDirSuffix = value); - Add("--server-browser-backend", "HTTP(S) server-browser backend URL.", _configuration.ServerBrowserBackend, value => _configuration.ServerBrowserBackend = value); - Flag("--no-plugin-update", "Use the existing UnchainedPlugin.dll.", value => _configuration.UpdatePlugin = !value); - Flag("--allow-unstable-plugin", "Allow prerelease plugin updates.", value => _configuration.AllowUnstablePlugin = value); - _configuration.ModSetup = new ModSetupConfiguration(); - Many("--add-registry", "Additional github:owner/repo, local:directory, or config:file registry; official registry is always included.", value => _configuration.ModSetup.Registries = value); - Many("--mod", "Install organization/id:version (exact release tag).", value => _configuration.ModSetup.Mods = value); - Many("--dll", "Reserved: ALL Plugins DLLs still load; selection tracked in issue #149.", value => _configuration.ModSetup.Dlls = value); - if (_configuration is ServerConfiguration serverConfig) { - var settings = serverConfig.Server!; - Add("--name", "Server name.", settings.Name, value => settings.Name = value); - Add("--description", "Description / MOTD.", settings.Description, value => settings.Description = value); - Add("--map", "Initial map.", settings.Map, value => settings.Map = value); - Add("--password", "Server password.", null, value => settings.Password = value); - Add("--local-ip", "Override auto-detected local IPv4 (VPN/multi-NIC).", null, value => settings.LocalIp = value); - Add("--game-port", "Game port.", settings.GamePort, value => settings.GamePort = value); - Add("--ping-port", "Ping port.", settings.PingPort, value => settings.PingPort = value); - Add("--query-port", "A2S query port.", settings.QueryPort, value => settings.QueryPort = value); - Add("--rcon-port", "RCON port.", settings.RconPort, value => settings.RconPort = value); - Add("--headless", "Run without rendering (true/false).", settings.Headless, value => settings.Headless = value); - Add("--censor-mode", "none, standard, sex, or zealous.", settings.CensorMode, value => settings.CensorMode = value); - Flag("--register", "List in the server browser.", value => settings.Register = value); - Flag("--no-tui", "Periodic plain-text status instead of dashboard.", value => serverConfig.NoTui = value); - Flag("--no-desync-patch", "Disable the default-enabled desync patch.", value => settings.DesyncPatch = !value); - Flag("--no-backend-banlist", "Disable the default-enabled backend banlist.", value => settings.UseBackendBanlist = !value); - Many("--server-mod", "Activate a blueprint class (separate from pak installation).", value => settings.ModActors = value); - var discord = new DiscordSettings(); - settings.Discord = discord; - Add("--discord-bot-token", "Bot token; requires at least one channel.", null, value => discord.BotToken = value); - Add("--discord-admin-channel-id", "Admin channel.", null, value => discord.AdminChannelId = value); - Add("--discord-general-channel-id", "General channel.", null, value => discord.GeneralChannelId = value); - Add("--discord-dashboard-channel-id", "Dashboard channel.", null, value => discord.DashboardChannelId = value); - Add("--discord-event-log-channel-id", "Event-log channel.", null, value => discord.EventLogChannelId = value); - Add("--discord-admin-role-id", "Admin role.", null, value => discord.AdminRoleId = value); - Add("--discord-mention-admins", "Mention admins (true/false).", true, value => discord.MentionAdmins = value); - } - // Only tokens following -- are game arguments, never misspelled options. - Command.Arguments.Add(_gameArgs); - Command.Validators.Add(result => { - if (result.GetResult(_gameArgs) is { Tokens.Count: > 0 }) - result.AddError("Pass game arguments after --."); - }); - } - - private void Add(string name, string description, T defaultValue, Action bind, bool required = false) { - var option = new Option(name) { - Description = description, - Arity = ArgumentArity.ExactlyOne, - DefaultValueFactory = _ => defaultValue, - Required = required - }; - option.Validators.Add(result => { - if (result.Tokens.Count > 1) result.AddError($"Duplicate option: {name}"); - if (result.Tokens.Any(token => string.IsNullOrWhiteSpace(token.Value))) result.AddError($"Empty value for {name}."); - }); - Command.Options.Add(option); - _bindings.Add(result => bind(result.GetValue(option)!)); - } - - private void Flag(string name, string description, Action bind) { - var option = new Option(name) { Description = description, Arity = ArgumentArity.Zero }; - option.Validators.Add(result => { - if (result.IdentifierTokenCount > 1) result.AddError($"Duplicate option: {name}"); - }); - Command.Options.Add(option); - _bindings.Add(result => bind(result.GetValue(option))); - } - - private void Many(string name, string description, Action> bind) { - var option = new Option(name) { Description = description, Arity = ArgumentArity.OneOrMore, AllowMultipleArgumentsPerToken = false }; - Command.Options.Add(option); - _bindings.Add(result => bind([.. result.GetValue(option) ?? []])); - } - - public CliLaunchOptions ToOptions(ParseResult parsed, string[] gameArgs) { - foreach (var bind in _bindings) bind(parsed); - _configuration.GameArgs = [.. gameArgs]; - try { _configuration.Validate(); } - catch (System.ComponentModel.DataAnnotations.ValidationException ex) { throw new ArgumentException(ex.Message); } - return CliLaunchOptions.FromConfiguration(_configuration, Directory.GetCurrentDirectory()); - } - } +using System.CommandLine; +using System.CommandLine.Help; +using UnchainedLauncher.Core.JsonModels; + +namespace UnchainedLauncher.CLI; + +internal static class CommandLineOptions { + public static CliLaunchOptions Parse(string[] args) => Parse(args, static (root, tokens, configuration) => root.Parse(tokens, configuration)); + + // The delegate is the single library-parse boundary, also exercised by tests. + internal static CliLaunchOptions Parse(string[] args, Func parse) { + var separator = Array.IndexOf(args, "--"); + var launcherArgs = separator < 0 ? args : args[..separator]; + var gameArgs = separator < 0 ? [] : args[(separator + 1)..]; + var root = new RootCommand("Unchained client/server launcher. Windows is required for launching and DLL injection."); + var config = new Option("--config-path") { + Description = "YAML replaces other options; the subcommand selects its schema.", + Arity = ArgumentArity.ExactlyOne, + Recursive = true + }; + root.Options.Add(config); + var server = new Definition(client: false); + var client = new Definition(client: true); + root.Subcommands.Add(server.Command); + root.Subcommands.Add(client.Command); + // Never interpret @values as file reads, including ignored YAML overrides. + // Response-file expansion is deliberately disabled for all launcher input. + var parsed = parse(root, launcherArgs, new ParserConfiguration { ResponseFileTokenReplacer = null }); + var definition = parsed.CommandResult.Command == server.Command ? server : client; + if (parsed.GetResult(config) is { } configResult) { + if (parsed.CommandResult.Command == root) throw new ArgumentException("Expected the 'server' or 'client' subcommand."); + // Help actions skip library validators. Check the parsed selector itself + // so --help cannot bypass its singleton/non-empty contract either. + if (configResult.IdentifierTokenCount != 1 || configResult.Tokens.Count != 1 || string.IsNullOrWhiteSpace(configResult.Tokens[0].Value)) + throw new ArgumentException("Specify exactly one non-empty --config-path."); + // All non-selector conversion, required-option and unknown-token errors + // are irrelevant when YAML is authoritative; do not bind those values. + var errors = parsed.Errors.Where(error => error.SymbolResult == configResult); + ThrowErrors(errors.Select(error => error.Message)); + return YamlConfiguration.Load(parsed.GetValue(config)!, definition.Command == client.Command); + } + if (args.Length == 0 || parsed.GetResult("--help") is not null) { + using var output = new StringWriter(); + var help = new HelpAction(); + parsed.InvocationConfiguration.Output = output; + help.Invoke(parsed); + return CliLaunchOptions.ForHelp(output.ToString()); + } + ThrowErrors(parsed); + if (parsed.CommandResult.Command == root) throw new ArgumentException("Expected the 'server' or 'client' subcommand."); + return definition.ToOptions(parsed, gameArgs); + } + + private static void ThrowErrors(ParseResult result) { + ThrowErrors(result.Errors.Select(error => error.Message)); + } + + private static void ThrowErrors(IEnumerable errors) { + var messages = errors.ToArray(); + if (messages.Length != 0) throw new ArgumentException(string.Join(Environment.NewLine, messages)); + } + + private sealed class Definition { + public Command Command { get; } + private readonly LaunchConfiguration _configuration; + private readonly List> _bindings = []; + private readonly Argument _gameArgs = new("game-args") { Arity = ArgumentArity.ZeroOrMore }; + + public Definition(bool client) { + Command = new Command(client ? "client" : "server", client ? "Launch an Unchained client." : "Launch and monitor an Unchained server."); + _configuration = client ? new ClientConfiguration() : new ServerConfiguration { Server = new ServerSettings() }; + Add("--chivalry2-path", "Game installation directory.", null, value => _configuration.Chiv2Path = value, required: true); + var platform = new Option("--platform") { + Description = "Steam or EGS (EpicGamesStore); EGS auth must be forwarded after --.", + Arity = ArgumentArity.ExactlyOne, + Required = true, + CustomParser = result => { + if (result.Tokens.Count != 1) { result.AddError("Specify --platform once."); return null; } + try { return InstallationTypeYamlConverter.Parse(result.Tokens[0].Value); } + catch (ArgumentException ex) { result.AddError(ex.Message); return null; } + } + }; + Command.Options.Add(platform); + _bindings.Add(result => _configuration.Platform = result.GetValue(platform)); + Add("--saved-dir-suffix", "Saved profile suffix.", _configuration.SavedDirSuffix, value => _configuration.SavedDirSuffix = value); + Add("--server-browser-backend", "HTTP(S) server-browser backend URL.", _configuration.ServerBrowserBackend, value => _configuration.ServerBrowserBackend = value); + Flag("--no-plugin-update", "Use the existing UnchainedPlugin.dll.", value => _configuration.UpdatePlugin = !value); + Flag("--allow-unstable-plugin", "Allow prerelease plugin updates.", value => _configuration.AllowUnstablePlugin = value); + _configuration.ModSetup = new ModSetupConfiguration(); + Many("--add-registry", "Additional github:owner/repo, local:directory, or config:file registry; official registry is always included.", value => _configuration.ModSetup.Registries = value); + Many("--mod", "Install organization/id:version (exact release tag).", value => _configuration.ModSetup.Mods = value); + Many("--dll", "Reserved: ALL Plugins DLLs still load; selection tracked in issue #149.", value => _configuration.ModSetup.Dlls = value); + if (_configuration is ServerConfiguration serverConfig) { + var settings = serverConfig.Server!; + Add("--name", "Server name.", settings.Name, value => settings.Name = value); + Add("--description", "Description / MOTD.", settings.Description, value => settings.Description = value); + Add("--map", "Initial map.", settings.Map, value => settings.Map = value); + Add("--password", "Server password.", null, value => settings.Password = value); + Add("--local-ip", "Override auto-detected local IPv4 (VPN/multi-NIC).", null, value => settings.LocalIp = value); + Add("--game-port", "Game port.", settings.GamePort, value => settings.GamePort = value); + Add("--ping-port", "Ping port.", settings.PingPort, value => settings.PingPort = value); + Add("--query-port", "A2S query port.", settings.QueryPort, value => settings.QueryPort = value); + Add("--rcon-port", "RCON port.", settings.RconPort, value => settings.RconPort = value); + Add("--headless", "Run without rendering (true/false).", settings.Headless, value => settings.Headless = value); + Add("--censor-mode", "none, standard, sex, or zealous.", settings.CensorMode, value => settings.CensorMode = value); + Flag("--register", "List in the server browser.", value => settings.Register = value); + Flag("--no-tui", "Periodic plain-text status instead of dashboard.", value => serverConfig.NoTui = value); + Flag("--no-desync-patch", "Disable the default-enabled desync patch.", value => settings.DesyncPatch = !value); + Flag("--no-backend-banlist", "Disable the default-enabled backend banlist.", value => settings.UseBackendBanlist = !value); + Many("--server-mod", "Activate a blueprint class (separate from pak installation).", value => settings.ModActors = value); + var discord = new DiscordSettings(); + settings.Discord = discord; + Add("--discord-bot-token", "Bot token; requires at least one channel.", null, value => discord.BotToken = value); + Add("--discord-admin-channel-id", "Admin channel.", null, value => discord.AdminChannelId = value); + Add("--discord-general-channel-id", "General channel.", null, value => discord.GeneralChannelId = value); + Add("--discord-dashboard-channel-id", "Dashboard channel.", null, value => discord.DashboardChannelId = value); + Add("--discord-event-log-channel-id", "Event-log channel.", null, value => discord.EventLogChannelId = value); + Add("--discord-admin-role-id", "Admin role.", null, value => discord.AdminRoleId = value); + Add("--discord-mention-admins", "Mention admins (true/false).", true, value => discord.MentionAdmins = value); + } + // Only tokens following -- are game arguments, never misspelled options. + Command.Arguments.Add(_gameArgs); + Command.Validators.Add(result => { + if (result.GetResult(_gameArgs) is { Tokens.Count: > 0 }) + result.AddError("Pass game arguments after --."); + }); + } + + private void Add(string name, string description, T defaultValue, Action bind, bool required = false) { + var option = new Option(name) { + Description = description, + Arity = ArgumentArity.ExactlyOne, + DefaultValueFactory = _ => defaultValue, + Required = required + }; + option.Validators.Add(result => { + if (result.Tokens.Count > 1) result.AddError($"Duplicate option: {name}"); + if (result.Tokens.Any(token => string.IsNullOrWhiteSpace(token.Value))) result.AddError($"Empty value for {name}."); + }); + Command.Options.Add(option); + _bindings.Add(result => bind(result.GetValue(option)!)); + } + + private void Flag(string name, string description, Action bind) { + var option = new Option(name) { Description = description, Arity = ArgumentArity.Zero }; + option.Validators.Add(result => { + if (result.IdentifierTokenCount > 1) result.AddError($"Duplicate option: {name}"); + }); + Command.Options.Add(option); + _bindings.Add(result => bind(result.GetValue(option))); + } + + private void Many(string name, string description, Action> bind) { + var option = new Option(name) { Description = description, Arity = ArgumentArity.OneOrMore, AllowMultipleArgumentsPerToken = false }; + Command.Options.Add(option); + _bindings.Add(result => bind([.. result.GetValue(option) ?? []])); + } + + public CliLaunchOptions ToOptions(ParseResult parsed, string[] gameArgs) { + foreach (var bind in _bindings) bind(parsed); + _configuration.GameArgs = [.. gameArgs]; + try { _configuration.Validate(); } + catch (System.ComponentModel.DataAnnotations.ValidationException ex) { throw new ArgumentException(ex.Message); } + return CliLaunchOptions.FromConfiguration(_configuration, Directory.GetCurrentDirectory()); + } + } } \ No newline at end of file diff --git a/UnchainedLauncher.CLI/README.md b/UnchainedLauncher.CLI/README.md index 97784f9c..c44edd0a 100644 --- a/UnchainedLauncher.CLI/README.md +++ b/UnchainedLauncher.CLI/README.md @@ -68,6 +68,13 @@ token after the game-argument separator `--` is just a game argument, not a configuration selector. Relative installation and local/config registry paths in YAML are resolved relative to the YAML file, not the caller's working directory. +Launcher response-file expansion (`@file`) is deliberately disabled: `@`-prefixed +option values are literal strings, and bare `@file` launcher arguments are rejected +like other unexpected positional arguments. This prevents file reads from ignored +CLI values when YAML is authoritative and keeps one library parse per invocation. +Put reusable launcher settings in YAML instead. Arguments after `--`, including +`@file`, are forwarded unchanged to the game (or ignored when YAML is selected). + `chiv2-path` and `platform` (`Steam` or `EGS`) are required. See the complete [server example](examples/server.yaml) and [client example](examples/client.yaml). From 562c60dedd52d80f76d1543ce81ddc42cc4636c7 Mon Sep 17 00:00:00 2001 From: Jacob Barber Date: Sat, 10 Oct 2026 23:39:57 -0500 Subject: [PATCH 07/58] Skip inactive CLI YAML nodes without schema validation --- .../YamlConfigurationTests.cs | 78 ++++++++ .../IgnoredYamlNodeConverter.cs | 18 ++ UnchainedLauncher.CLI/LaunchConfiguration.cs | 183 +++++++++--------- UnchainedLauncher.CLI/YamlConfiguration.cs | 73 +++---- 4 files changed, 226 insertions(+), 126 deletions(-) create mode 100644 UnchainedLauncher.CLI/IgnoredYamlNodeConverter.cs diff --git a/UnchainedLauncher.CLI.Tests/YamlConfigurationTests.cs b/UnchainedLauncher.CLI.Tests/YamlConfigurationTests.cs index 42480162..13b356bc 100644 --- a/UnchainedLauncher.CLI.Tests/YamlConfigurationTests.cs +++ b/UnchainedLauncher.CLI.Tests/YamlConfigurationTests.cs @@ -170,6 +170,84 @@ public void ServerIgnoresClientBlockAndEachCommandValidatesOnlyItsOwnSchema() { Assert.IsType(Load("server: not-a-mapping\nclient: {}", client: true)); } + public static TheoryData InactiveYamlNodes { + get { + var data = new TheoryData(); + foreach (var client in new[] { true, false }) { + foreach (var node in new[] { + "not-a-mapping", "null", "[]", "{}", "*unused-alias", + "{game-args: [], game-args: []}", + "{nested: {password: first-secret, password: second-secret}}", + "[{nested: {field: one, field: two}}, [scalar, {field: [], field: {}}]]", + "{nested: [{field: one, field: two}], nested: ignored}", + "!ignored-tag {field: one, field: two}", + "|-\n literal scalar\n field: not-a-mapping", + "\n nested:\n field: one\n field: two", + "\n - nested:\n field: one\n field: two\n - [scalar, {}]" + }) data.Add(client, node); + } + return data; + } + } + + [Theory] + [MemberData(nameof(InactiveYamlNodes))] + public void InactiveYamlNodeIsSkippedAtProductionLaunchBoundary(bool client, string node) { + var active = client ? "client" : "server"; + var inactive = client ? "server" : "client"; + var options = Load($"{inactive}: {node}\n{active}: {{game-args: ['-active']}}\ngame-args: ['-shared']\n", client); + Assert.Equal(client, options is ClientOptions); + var launch = options.ToLaunchOptions(); + Assert.Equal(client, launch.ServerLaunchOptions.IsNone); + Assert.Contains("-active", launch.LaunchArgs); + Assert.Contains("-shared", launch.LaunchArgs); + Assert.DoesNotContain("secret", launch.LaunchArgs); + Assert.Equal(!client, launch.ToCLIArgs().Any(arg => arg.Rendered == "-nullrhi")); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void InactiveYamlAliasIsNotBoundToAnActiveObject(bool client) { + var active = client ? "client" : "server"; + var inactive = client ? "server" : "client"; + var options = Load($"{active}: &active {{game-args: ['-active']}}\n{inactive}: *active\n", client); + Assert.Equal(client, options is ClientOptions); + Assert.Contains("-active", options.ToLaunchOptions().LaunchArgs); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void SkippingInactiveYamlRetainsRootSharedAndActiveDuplicateChecks(bool client) { + var active = client ? "client" : "server"; + var inactive = client ? "server" : "client"; + foreach (var body in new[] { + "platform: Steam\n", + "game-args: []\ngame-args: []\n", + "mod-setup: {mods: [], mods: []}\n", + $"{active}: {{game-args: [], game-args: []}}\n", + $"{active}: {{}}\n{active}: {{}}\n", + $"{inactive}: {{}}\n" + }) { + var error = Assert.Throws(() => Load($"{inactive}: {{nested: {{field: one, field: two}}}}\n" + body, client)); + Assert.Contains("Invalid YAML configuration or schema", error.Message); + } + if (!client) { + Assert.Throws(() => Load("client: {}\nserver: {discord: {bot-token: first-secret, bot-token: second-secret}}\n")); + } + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void SkippedInactiveYamlStillRequiresValidSyntaxWithoutExposingSecrets(bool client) { + var inactive = client ? "server" : "client"; + var error = Assert.Throws(() => Load($"{inactive}: {{password: TOP-SECRET, nested: [}}\n", client)); + Assert.Contains("Invalid YAML configuration or schema", error.Message); + Assert.DoesNotContain("TOP-SECRET", error.Message); + } + [Fact] public void ServerSafetyFlagsDefaultOnAndCanBeDisabledInYaml() { var defaults = Assert.IsType(Load("server: {}")).ToServerLaunchOptions(); diff --git a/UnchainedLauncher.CLI/IgnoredYamlNodeConverter.cs b/UnchainedLauncher.CLI/IgnoredYamlNodeConverter.cs new file mode 100644 index 00000000..01628551 --- /dev/null +++ b/UnchainedLauncher.CLI/IgnoredYamlNodeConverter.cs @@ -0,0 +1,18 @@ +using YamlDotNet.Core; +using YamlDotNet.Serialization; + +namespace UnchainedLauncher.CLI; + +internal sealed class IgnoredYamlNodeConverter : IYamlTypeConverter { + // Opt in only via YamlConverter on the opposite command's property, never by type. + public bool Accepts(Type type) => false; + + public object? ReadYaml(IParser parser, Type type, ObjectDeserializer rootDeserializer) { + // Consume one syntactically valid YAML node without materializing or validating its contents. + parser.SkipThisAndNestedEvents(); + return null; + } + + public void WriteYaml(IEmitter emitter, object? value, Type type, ObjectSerializer serializer) => + throw new NotSupportedException("Ignored YAML nodes are only used when loading configuration."); +} \ No newline at end of file diff --git a/UnchainedLauncher.CLI/LaunchConfiguration.cs b/UnchainedLauncher.CLI/LaunchConfiguration.cs index 6445613c..0af7211d 100644 --- a/UnchainedLauncher.CLI/LaunchConfiguration.cs +++ b/UnchainedLauncher.CLI/LaunchConfiguration.cs @@ -1,91 +1,94 @@ -using System.ComponentModel.DataAnnotations; -using UnchainedLauncher.Core.JsonModels; -using UnchainedLauncher.Core.Services.Processes.Chivalry; - -namespace UnchainedLauncher.CLI; - -public abstract class LaunchConfiguration { - [Required] - public string? Chiv2Path { get; set; } - - [Required, EnumDataType(typeof(InstallationType))] - public InstallationType? Platform { get; set; } - - [Required] - public string SavedDirSuffix { get; set; } = "UnchainedCLI"; - public string ServerBrowserBackend { get; set; } = "https://servers.polehammer.net"; - public bool UpdatePlugin { get; set; } = true; - public bool AllowUnstablePlugin { get; set; } - public List? GameArgs { get; set; } = []; - public ModSetupConfiguration? ModSetup { get; set; } - - public virtual void Validate() { - Validator.ValidateObject(this, new ValidationContext(this), validateAllProperties: true); - if (Platform is not (InstallationType.Steam or InstallationType.EpicGamesStore)) - throw new ValidationException("Platform must be Steam or EGS (EpicGamesStore)."); - } -} - -public sealed class ServerConfiguration : LaunchConfiguration { - public bool NoTui { get; set; } - public ServerSettings? Server { get; set; } - // Recognized but intentionally untyped: do not validate the inactive schema. - public object? Client { get; set; } - - public override void Validate() { - base.Validate(); - if (Server is not null) Validator.ValidateObject(Server, new ValidationContext(Server), validateAllProperties: true); - } -} - -public sealed class ClientConfiguration : LaunchConfiguration { - public ClientSettings? Client { get; set; } - public object? Server { get; set; } -} - -public sealed class ModSetupConfiguration { - public List? Registries { get; set; } = []; - public List? Mods { get; set; } = []; - public List? Dlls { get; set; } = []; -} - -public sealed class ClientSettings { - public List? GameArgs { get; set; } = []; -} - -public sealed class ServerSettings { - [Required] - public string Name { get; set; } = "Unchained Server"; - public string Description { get; set; } = ""; - public string? Password { get; set; } - [Required] - public string Map { get; set; } = "FFA_Courtyard"; - public string? LocalIp { get; set; } - [Range(1, 65535)] - public int GamePort { get; set; } = 7777; - [Range(1, 65535)] - public int PingPort { get; set; } = 3075; - [Range(1, 65535)] - public int QueryPort { get; set; } = 7071; - [Range(1, 65535)] - public int RconPort { get; set; } = 9001; - public bool Headless { get; set; } = true; - public bool Register { get; set; } - public bool DesyncPatch { get; set; } = true; - public bool UseBackendBanlist { get; set; } = true; - [EnumDataType(typeof(CensorArg))] - public CensorArg CensorMode { get; set; } = CensorArg.None; - public List? ModActors { get; set; } = []; - public List? GameArgs { get; set; } = []; - public DiscordSettings? Discord { get; set; } -} - -public sealed class DiscordSettings { - public string? BotToken { get; set; } - public string? AdminChannelId { get; set; } - public string? GeneralChannelId { get; set; } - public string? DashboardChannelId { get; set; } - public string? EventLogChannelId { get; set; } - public string? AdminRoleId { get; set; } - public bool MentionAdmins { get; set; } = true; +using System.ComponentModel.DataAnnotations; +using UnchainedLauncher.Core.JsonModels; +using UnchainedLauncher.Core.Services.Processes.Chivalry; +using YamlDotNet.Serialization; + +namespace UnchainedLauncher.CLI; + +public abstract class LaunchConfiguration { + [Required] + public string? Chiv2Path { get; set; } + + [Required, EnumDataType(typeof(InstallationType))] + public InstallationType? Platform { get; set; } + + [Required] + public string SavedDirSuffix { get; set; } = "UnchainedCLI"; + public string ServerBrowserBackend { get; set; } = "https://servers.polehammer.net"; + public bool UpdatePlugin { get; set; } = true; + public bool AllowUnstablePlugin { get; set; } + public List? GameArgs { get; set; } = []; + public ModSetupConfiguration? ModSetup { get; set; } + + public virtual void Validate() { + Validator.ValidateObject(this, new ValidationContext(this), validateAllProperties: true); + if (Platform is not (InstallationType.Steam or InstallationType.EpicGamesStore)) + throw new ValidationException("Platform must be Steam or EGS (EpicGamesStore)."); + } +} + +public sealed class ServerConfiguration : LaunchConfiguration { + public bool NoTui { get; set; } + public ServerSettings? Server { get; set; } + // Recognized but skipped: do not deserialize or validate the inactive schema. + [YamlConverter(typeof(IgnoredYamlNodeConverter))] + public object? Client { get; set; } + + public override void Validate() { + base.Validate(); + if (Server is not null) Validator.ValidateObject(Server, new ValidationContext(Server), validateAllProperties: true); + } +} + +public sealed class ClientConfiguration : LaunchConfiguration { + public ClientSettings? Client { get; set; } + [YamlConverter(typeof(IgnoredYamlNodeConverter))] + public object? Server { get; set; } +} + +public sealed class ModSetupConfiguration { + public List? Registries { get; set; } = []; + public List? Mods { get; set; } = []; + public List? Dlls { get; set; } = []; +} + +public sealed class ClientSettings { + public List? GameArgs { get; set; } = []; +} + +public sealed class ServerSettings { + [Required] + public string Name { get; set; } = "Unchained Server"; + public string Description { get; set; } = ""; + public string? Password { get; set; } + [Required] + public string Map { get; set; } = "FFA_Courtyard"; + public string? LocalIp { get; set; } + [Range(1, 65535)] + public int GamePort { get; set; } = 7777; + [Range(1, 65535)] + public int PingPort { get; set; } = 3075; + [Range(1, 65535)] + public int QueryPort { get; set; } = 7071; + [Range(1, 65535)] + public int RconPort { get; set; } = 9001; + public bool Headless { get; set; } = true; + public bool Register { get; set; } + public bool DesyncPatch { get; set; } = true; + public bool UseBackendBanlist { get; set; } = true; + [EnumDataType(typeof(CensorArg))] + public CensorArg CensorMode { get; set; } = CensorArg.None; + public List? ModActors { get; set; } = []; + public List? GameArgs { get; set; } = []; + public DiscordSettings? Discord { get; set; } +} + +public sealed class DiscordSettings { + public string? BotToken { get; set; } + public string? AdminChannelId { get; set; } + public string? GeneralChannelId { get; set; } + public string? DashboardChannelId { get; set; } + public string? EventLogChannelId { get; set; } + public string? AdminRoleId { get; set; } + public bool MentionAdmins { get; set; } = true; } \ No newline at end of file diff --git a/UnchainedLauncher.CLI/YamlConfiguration.cs b/UnchainedLauncher.CLI/YamlConfiguration.cs index f02065f9..86ce4a99 100644 --- a/UnchainedLauncher.CLI/YamlConfiguration.cs +++ b/UnchainedLauncher.CLI/YamlConfiguration.cs @@ -1,37 +1,38 @@ -using System.ComponentModel.DataAnnotations; -using YamlDotNet.Core; -using YamlDotNet.Serialization; -using YamlDotNet.Serialization.NamingConventions; - -namespace UnchainedLauncher.CLI; - -public static class YamlConfiguration { - public static CliLaunchOptions Load(string path, bool client) { - path = Path.GetFullPath(path); - try { - using var reader = File.OpenText(path); - var deserializer = new DeserializerBuilder() - .WithNamingConvention(HyphenatedNamingConvention.Instance) - .WithTypeConverter(new InstallationTypeYamlConverter()) - .WithDuplicateKeyChecking() - .WithEnforceNullability() - .Build(); - LaunchConfiguration? configuration = client - ? deserializer.Deserialize(reader) - : deserializer.Deserialize(reader); - if (configuration is null) throw new ArgumentException("Configuration must contain one YAML mapping."); - configuration.Validate(); - return CliLaunchOptions.FromConfiguration(configuration, Path.GetDirectoryName(path)!); - } - catch (YamlException ex) { - // Library errors can include passwords/tokens; report only location. - throw new ArgumentException($"Invalid YAML configuration or schema at line {ex.Start.Line}, column {ex.Start.Column}."); - } - catch (ValidationException ex) { - throw new ArgumentException("Invalid configuration: " + ex.Message); - } - catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { - throw new ArgumentException($"Cannot read YAML configuration: {path}"); - } - } +using System.ComponentModel.DataAnnotations; +using YamlDotNet.Core; +using YamlDotNet.Serialization; +using YamlDotNet.Serialization.NamingConventions; + +namespace UnchainedLauncher.CLI; + +public static class YamlConfiguration { + public static CliLaunchOptions Load(string path, bool client) { + path = Path.GetFullPath(path); + try { + using var reader = File.OpenText(path); + var deserializer = new DeserializerBuilder() + .WithNamingConvention(HyphenatedNamingConvention.Instance) + .WithTypeConverter(new InstallationTypeYamlConverter()) + .WithTypeConverter(new IgnoredYamlNodeConverter()) + .WithDuplicateKeyChecking() + .WithEnforceNullability() + .Build(); + LaunchConfiguration? configuration = client + ? deserializer.Deserialize(reader) + : deserializer.Deserialize(reader); + if (configuration is null) throw new ArgumentException("Configuration must contain one YAML mapping."); + configuration.Validate(); + return CliLaunchOptions.FromConfiguration(configuration, Path.GetDirectoryName(path)!); + } + catch (YamlException ex) { + // Library errors can include passwords/tokens; report only location. + throw new ArgumentException($"Invalid YAML configuration or schema at line {ex.Start.Line}, column {ex.Start.Column}."); + } + catch (ValidationException ex) { + throw new ArgumentException("Invalid configuration: " + ex.Message); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { + throw new ArgumentException($"Cannot read YAML configuration: {path}"); + } + } } \ No newline at end of file From 82c684d3a94c9872af53b15343ebb32a43d87cdc Mon Sep 17 00:00:00 2001 From: Jacob Barber Date: Sat, 10 Oct 2026 23:53:01 -0500 Subject: [PATCH 08/58] Reject rendered CLI servers with the desync patch enabled --- .../ServerOptionsTests.cs | 353 ++++++++++-------- .../YamlConfigurationTests.cs | 34 +- UnchainedLauncher.CLI/CliLaunchOptions.cs | 288 +++++++------- UnchainedLauncher.CLI/CommandLineOptions.cs | 2 +- UnchainedLauncher.CLI/README.md | 8 +- UnchainedLauncher.CLI/examples/server.yaml | 2 +- 6 files changed, 376 insertions(+), 311 deletions(-) diff --git a/UnchainedLauncher.CLI.Tests/ServerOptionsTests.cs b/UnchainedLauncher.CLI.Tests/ServerOptionsTests.cs index d110a455..89b38158 100644 --- a/UnchainedLauncher.CLI.Tests/ServerOptionsTests.cs +++ b/UnchainedLauncher.CLI.Tests/ServerOptionsTests.cs @@ -1,162 +1,193 @@ -using UnchainedLauncher.Core.JsonModels; -using UnchainedLauncher.Core.Services.Mods.Registry; -using Xunit; - -namespace UnchainedLauncher.CLI.Tests; - -public class ServerOptionsTests { - private static string[] Args(params string[] extra) => - new[] { "server", "--chivalry2-path", ".", "--platform", "Steam" }.Concat(extra).ToArray(); - - [Fact] - public void DefaultsProduceHeadlessServerLaunch() { - var options = Assert.IsType(CliLaunchOptions.Parse(Args())); - var server = options.ToServerLaunchOptions(); - Assert.True(server.Headless); - Assert.False(server.RegisterWithBackend); - Assert.True(server.DesyncPatch); - Assert.True(server.UseBackendBanlist); - Assert.NotNull(options.Settings.LocalIp); - Assert.Equal(options.Settings.LocalIp, server.LocalIp.IfNone("")); - Assert.Equal(7777, server.GamePort); - Assert.Equal(7071, server.QueryPort); - Assert.Equal("FFA_Courtyard", server.Map); - Assert.Equal("TBL -server", options.ToLaunchOptions().LaunchArgs); - Assert.Equal("UnchainedCLI", options.ToLaunchOptions().SavedDirSuffix.IfNone("")); - Assert.Contains(server.ToCLIArgs(), arg => arg.Rendered == "-nullrhi"); - } - - [Theory] - [InlineData("Steam", InstallationType.Steam)] - [InlineData("steam", InstallationType.Steam)] - [InlineData("EGS", InstallationType.EpicGamesStore)] - [InlineData("EpicGamesStore", InstallationType.EpicGamesStore)] - public void AcceptsPlatforms(string platform, InstallationType expected) { - Assert.Equal(expected, CliLaunchOptions.Parse(["server", "--chivalry2-path=.", $"--platform={platform}"]).Shared.Platform); - } - - [Fact] - public void RepeatedCoordinatesRegistriesAndActorsAreAccepted() { - var options = CliLaunchOptions.Parse(Args("--mod", "org/one:v1.2.3", "--mod=org/two:release-tag", - "--dll", "org/plugin:v2", "--add-registry", "github:owner/repo", - "--add-registry", "local:my registry", "--add-registry", "config:registries.json", - "--server-mod", "/Game/Mods/Test.Test_C", "--server-mod", "/Game/Mods/Other.Other_C")); - Assert.Equal(2, options.Shared.Mods.Count); - Assert.Single(options.Shared.Dlls); - Assert.Equal(3, options.Shared.Registries.Count); - Assert.Equal(2, Assert.IsType(options).ToServerLaunchOptions().ServerMods.Count()); - Assert.Equal("v1.2.3", options.Shared.Mods[0].Version); - Assert.True(Path.IsPathFullyQualified(options.Shared.Registries[1].Location)); - } - - [Fact] - public void GameArgumentsAreEscapedAndNotParsedAsLauncherOptions() { - var options = CliLaunchOptions.Parse(Args("--", "-AUTH_PASSWORD=secret with spaces", "--mod", "game value")); - Assert.Empty(options.Shared.Mods); - Assert.Equal(3, options.GameArguments.Count); - Assert.Contains("\"-AUTH_PASSWORD=secret with spaces\"", options.ToLaunchOptions().LaunchArgs); - Assert.Contains("\"game value\"", options.ToLaunchOptions().LaunchArgs); - } - - [Fact] - public void ServerFieldsAreMappedToCoreOptions() { - var options = CliLaunchOptions.Parse(Args("--name", "Test Server", "--description", "Test MOTD", "--map", "TO_Test", - "--password", "secret", "--local-ip", "127.0.0.2", "--game-port", "10001", "--ping-port", "10002", - "--query-port", "10003", "--rcon-port", "10004", "--register", "--no-tui", "--no-plugin-update", - "--allow-unstable-plugin", "--server-browser-backend", "https://example.test", "--saved-dir-suffix", "Test_1")); - var server = Assert.IsType(options).ToServerLaunchOptions(); - Assert.Equal("Test Server", server.Name); - Assert.Equal("Test MOTD", server.Description); - Assert.Equal("TO_Test", server.Map); - Assert.Equal("secret", server.Password.IfNone("")); - Assert.Equal("127.0.0.2", server.LocalIp.IfNone("")); - Assert.Equal((10001, 10002, 10003, 10004), (server.GamePort, server.BeaconPort, server.QueryPort, server.RconPort)); - Assert.True(server.RegisterWithBackend); - Assert.True(Assert.IsType(options).NoTui); - Assert.False(options.ToLaunchOptions().CheckForDependencyUpdates); - Assert.True(options.ToLaunchOptions().AllowUnstablePluginReleases); - } - - [Theory] - [InlineData("--mod", "missing-version")] - [InlineData("--mod", "org/mod:")] - [InlineData("--dll", "plugin:v1")] - [InlineData("--platform", "42")] - [InlineData("--game-port", "0")] - [InlineData("--game-port", "65536")] - [InlineData("--query-port", "7777")] - [InlineData("--saved-dir-suffix", "../profile")] - [InlineData("--local-ip", "not-an-ip")] - [InlineData("--server-browser-backend", "file:///server")] - [InlineData("--add-registry", "github:bad")] - [InlineData("--add-registry", "http:unsupported")] - [InlineData("--name", " ")] - [InlineData("--headless", "yes")] - [InlineData("--censor-mode", "invalid")] - [InlineData("--discord-mention-admins", "maybe")] - public void RejectsInvalidValues(string key, string value) { - Assert.Throws(() => CliLaunchOptions.Parse(Args(key, value))); - } - - [Fact] - public void RejectsMissingRequiredOptionsAndUnknownFlags() { - Assert.Throws(() => CliLaunchOptions.Parse(["server"])); - Assert.Throws(() => CliLaunchOptions.Parse(["server", "--chivalry2-path", "."])); - Assert.Throws(() => CliLaunchOptions.Parse(Args("--typo"))); - Assert.Throws(() => CliLaunchOptions.Parse(Args("--map"))); - Assert.Throws(() => CliLaunchOptions.Parse(Args("--no-tui=true"))); - Assert.Throws(() => CliLaunchOptions.Parse(["client"])); - } - - [Fact] - public void RejectsConflictingModVersionsAndDuplicateScalarOptions() { - Assert.Throws(() => CliLaunchOptions.Parse(Args("--mod", "org/mod:v1", "--mod", "org/mod:v2"))); - Assert.Throws(() => CliLaunchOptions.Parse(Args("--map", "one", "--map", "two"))); - } - - [Fact] - public void HelpDoesNotRequireInstallation() { - Assert.True(CliLaunchOptions.Parse([]).Help); - Assert.True(CliLaunchOptions.Parse(["--help"]).Help); - Assert.True(CliLaunchOptions.Parse(["server", "--help"]).Help); - } - - [Fact] - public void GitHubRegistryCreatesCoreImplementation() { - var registry = Assert.IsType(RegistrySpec.Parse("github:owner/repo").Create()); - Assert.Equal("owner", registry.Organization); - Assert.Equal("repo", registry.RepoName); - } - - [Fact] - public void ExtendedServerArgumentsAreMappedToCore() { - var options = CliLaunchOptions.Parse(Args("--headless=false", - "--censor-mode=standard", "--discord-bot-token=token", - "--discord-general-channel-id=123", "--discord-mention-admins=false")); - var server = Assert.IsType(options).ToServerLaunchOptions(); - Assert.False(server.Headless); - Assert.True(server.DesyncPatch); - Assert.True(server.UseBackendBanlist); - Assert.Equal(Core.Services.Processes.Chivalry.CensorArg.Standard, server.CensorMode); - Assert.Null(server.FFATimeLimit); - Assert.Null(server.PlayerBotCount); - Assert.True(server.DiscordIntegration.IsSome); - Assert.Contains("--discord-mention-admins false", server.ToCLIArgs().Select(arg => arg.Rendered)); - } - - [Fact] - public void DefaultEnabledServerFlagsCanBeDisabledOnCommandLine() { - var options = CliLaunchOptions.Parse(Args("--no-desync-patch", "--no-backend-banlist")); - Assert.False(Assert.IsType(options).ToServerLaunchOptions().DesyncPatch); - Assert.False(Assert.IsType(options).ToServerLaunchOptions().UseBackendBanlist); - } - - [Theory] - [InlineData("--no-desync-patch=false")] - [InlineData("--no-backend-banlist=true")] - [InlineData("--desync-patch")] - [InlineData("--use-backend-banlist")] - public void DisableOptionsAreValueLessFlags(string flag) { - Assert.Throws(() => CliLaunchOptions.Parse(Args(flag))); - } +using UnchainedLauncher.Core.JsonModels; +using UnchainedLauncher.Core.Services.Mods.Registry; +using Xunit; + +namespace UnchainedLauncher.CLI.Tests; + +public class ServerOptionsTests { + private static string[] Args(params string[] extra) => + new[] { "server", "--chivalry2-path", ".", "--platform", "Steam" }.Concat(extra).ToArray(); + + [Fact] + public void DefaultsProduceHeadlessServerLaunch() { + var options = Assert.IsType(CliLaunchOptions.Parse(Args())); + var server = options.ToServerLaunchOptions(); + Assert.True(server.Headless); + Assert.False(server.RegisterWithBackend); + Assert.True(server.DesyncPatch); + Assert.True(server.UseBackendBanlist); + Assert.NotNull(options.Settings.LocalIp); + Assert.Equal(options.Settings.LocalIp, server.LocalIp.IfNone("")); + Assert.Equal(7777, server.GamePort); + Assert.Equal(7071, server.QueryPort); + Assert.Equal("FFA_Courtyard", server.Map); + Assert.Equal("TBL -server", options.ToLaunchOptions().LaunchArgs); + Assert.Equal("UnchainedCLI", options.ToLaunchOptions().SavedDirSuffix.IfNone("")); + Assert.Contains(server.ToCLIArgs(), arg => arg.Rendered == "-nullrhi"); + Assert.Contains(server.ToCLIArgs(), arg => arg.Rendered == "--desync-patch"); + } + + [Theory] + [InlineData("Steam", InstallationType.Steam)] + [InlineData("steam", InstallationType.Steam)] + [InlineData("EGS", InstallationType.EpicGamesStore)] + [InlineData("EpicGamesStore", InstallationType.EpicGamesStore)] + public void AcceptsPlatforms(string platform, InstallationType expected) { + Assert.Equal(expected, CliLaunchOptions.Parse(["server", "--chivalry2-path=.", $"--platform={platform}"]).Shared.Platform); + } + + [Fact] + public void RepeatedCoordinatesRegistriesAndActorsAreAccepted() { + var options = CliLaunchOptions.Parse(Args("--mod", "org/one:v1.2.3", "--mod=org/two:release-tag", + "--dll", "org/plugin:v2", "--add-registry", "github:owner/repo", + "--add-registry", "local:my registry", "--add-registry", "config:registries.json", + "--server-mod", "/Game/Mods/Test.Test_C", "--server-mod", "/Game/Mods/Other.Other_C")); + Assert.Equal(2, options.Shared.Mods.Count); + Assert.Single(options.Shared.Dlls); + Assert.Equal(3, options.Shared.Registries.Count); + Assert.Equal(2, Assert.IsType(options).ToServerLaunchOptions().ServerMods.Count()); + Assert.Equal("v1.2.3", options.Shared.Mods[0].Version); + Assert.True(Path.IsPathFullyQualified(options.Shared.Registries[1].Location)); + } + + [Fact] + public void GameArgumentsAreEscapedAndNotParsedAsLauncherOptions() { + var options = CliLaunchOptions.Parse(Args("--", "-AUTH_PASSWORD=secret with spaces", "--mod", "game value")); + Assert.Empty(options.Shared.Mods); + Assert.Equal(3, options.GameArguments.Count); + Assert.Contains("\"-AUTH_PASSWORD=secret with spaces\"", options.ToLaunchOptions().LaunchArgs); + Assert.Contains("\"game value\"", options.ToLaunchOptions().LaunchArgs); + } + + [Fact] + public void ServerFieldsAreMappedToCoreOptions() { + var options = CliLaunchOptions.Parse(Args("--name", "Test Server", "--description", "Test MOTD", "--map", "TO_Test", + "--password", "secret", "--local-ip", "127.0.0.2", "--game-port", "10001", "--ping-port", "10002", + "--query-port", "10003", "--rcon-port", "10004", "--register", "--no-tui", "--no-plugin-update", + "--allow-unstable-plugin", "--server-browser-backend", "https://example.test", "--saved-dir-suffix", "Test_1")); + var server = Assert.IsType(options).ToServerLaunchOptions(); + Assert.Equal("Test Server", server.Name); + Assert.Equal("Test MOTD", server.Description); + Assert.Equal("TO_Test", server.Map); + Assert.Equal("secret", server.Password.IfNone("")); + Assert.Equal("127.0.0.2", server.LocalIp.IfNone("")); + Assert.Equal((10001, 10002, 10003, 10004), (server.GamePort, server.BeaconPort, server.QueryPort, server.RconPort)); + Assert.True(server.RegisterWithBackend); + Assert.True(Assert.IsType(options).NoTui); + Assert.False(options.ToLaunchOptions().CheckForDependencyUpdates); + Assert.True(options.ToLaunchOptions().AllowUnstablePluginReleases); + } + + [Theory] + [InlineData("--mod", "missing-version")] + [InlineData("--mod", "org/mod:")] + [InlineData("--dll", "plugin:v1")] + [InlineData("--platform", "42")] + [InlineData("--game-port", "0")] + [InlineData("--game-port", "65536")] + [InlineData("--query-port", "7777")] + [InlineData("--saved-dir-suffix", "../profile")] + [InlineData("--local-ip", "not-an-ip")] + [InlineData("--server-browser-backend", "file:///server")] + [InlineData("--add-registry", "github:bad")] + [InlineData("--add-registry", "http:unsupported")] + [InlineData("--name", " ")] + [InlineData("--headless", "yes")] + [InlineData("--censor-mode", "invalid")] + [InlineData("--discord-mention-admins", "maybe")] + public void RejectsInvalidValues(string key, string value) { + Assert.Throws(() => CliLaunchOptions.Parse(Args(key, value))); + } + + [Fact] + public void RejectsMissingRequiredOptionsAndUnknownFlags() { + Assert.Throws(() => CliLaunchOptions.Parse(["server"])); + Assert.Throws(() => CliLaunchOptions.Parse(["server", "--chivalry2-path", "."])); + Assert.Throws(() => CliLaunchOptions.Parse(Args("--typo"))); + Assert.Throws(() => CliLaunchOptions.Parse(Args("--map"))); + Assert.Throws(() => CliLaunchOptions.Parse(Args("--no-tui=true"))); + Assert.Throws(() => CliLaunchOptions.Parse(["client"])); + } + + [Fact] + public void RejectsConflictingModVersionsAndDuplicateScalarOptions() { + Assert.Throws(() => CliLaunchOptions.Parse(Args("--mod", "org/mod:v1", "--mod", "org/mod:v2"))); + Assert.Throws(() => CliLaunchOptions.Parse(Args("--map", "one", "--map", "two"))); + } + + [Fact] + public void HelpDoesNotRequireInstallation() { + Assert.True(CliLaunchOptions.Parse([]).Help); + Assert.True(CliLaunchOptions.Parse(["--help"]).Help); + Assert.True(CliLaunchOptions.Parse(["server", "--help"]).Help); + } + + [Fact] + public void GitHubRegistryCreatesCoreImplementation() { + var registry = Assert.IsType(RegistrySpec.Parse("github:owner/repo").Create()); + Assert.Equal("owner", registry.Organization); + Assert.Equal("repo", registry.RepoName); + } + + [Fact] + public void ExtendedServerArgumentsAreMappedToCore() { + var options = CliLaunchOptions.Parse(Args("--headless=false", "--no-desync-patch", + "--censor-mode=standard", "--discord-bot-token=token", + "--discord-general-channel-id=123", "--discord-mention-admins=false")); + var server = Assert.IsType(options).ToServerLaunchOptions(); + Assert.False(server.Headless); + Assert.False(server.DesyncPatch); + Assert.True(server.UseBackendBanlist); + Assert.Equal(Core.Services.Processes.Chivalry.CensorArg.Standard, server.CensorMode); + Assert.Null(server.FFATimeLimit); + Assert.Null(server.PlayerBotCount); + Assert.True(server.DiscordIntegration.IsSome); + Assert.Contains("--discord-mention-admins false", server.ToCLIArgs().Select(arg => arg.Rendered)); + Assert.DoesNotContain("-nullrhi", server.ToCLIArgs().Select(arg => arg.Rendered)); + Assert.DoesNotContain("--desync-patch", server.ToCLIArgs().Select(arg => arg.Rendered)); + } + + [Theory] + [InlineData("--headless=false")] + [InlineData("--headless", "false")] + public void RenderingRequiresExplicitlyDisablingDesyncPatch(params string[] arguments) { + var error = Assert.Throws(() => CliLaunchOptions.Parse(Args(arguments))); + Assert.Contains("headless", error.Message); + Assert.Contains("--no-desync-patch", error.Message); + Assert.Contains("server.desync-patch: false", error.Message); + var options = CliLaunchOptions.Parse(Args(arguments.Concat(["--no-desync-patch"]).ToArray())); + var server = Assert.IsType(options).ToServerLaunchOptions(); + Assert.False(server.Headless); + Assert.False(server.DesyncPatch); + Assert.True(server.UseBackendBanlist); + Assert.DoesNotContain("-nullrhi", server.ToCLIArgs().Select(arg => arg.Rendered)); + Assert.DoesNotContain("--desync-patch", server.ToCLIArgs().Select(arg => arg.Rendered)); + Assert.True(options.ToLaunchOptions().ServerLaunchOptions.IsSome); + } + + [Fact] + public void DefaultEnabledServerFlagsCanBeDisabledOnCommandLine() { + var options = CliLaunchOptions.Parse(Args("--no-desync-patch", "--no-backend-banlist")); + Assert.False(Assert.IsType(options).ToServerLaunchOptions().DesyncPatch); + Assert.False(Assert.IsType(options).ToServerLaunchOptions().UseBackendBanlist); + } + + [Fact] + public void ClientLaunchDoesNotApplyServerDesyncCompatibilityPolicy() { + var options = Assert.IsType(CliLaunchOptions.Parse([ + "client", "--chivalry2-path", ".", "--platform", "Steam" + ])); + var launch = options.ToLaunchOptions(); + Assert.True(launch.ServerLaunchOptions.IsNone); + Assert.Equal("TBL", launch.LaunchArgs); + } + + [Theory] + [InlineData("--no-desync-patch=false")] + [InlineData("--no-backend-banlist=true")] + [InlineData("--desync-patch")] + [InlineData("--use-backend-banlist")] + public void DisableOptionsAreValueLessFlags(string flag) { + Assert.Throws(() => CliLaunchOptions.Parse(Args(flag))); + } } \ No newline at end of file diff --git a/UnchainedLauncher.CLI.Tests/YamlConfigurationTests.cs b/UnchainedLauncher.CLI.Tests/YamlConfigurationTests.cs index 13b356bc..6152f1bd 100644 --- a/UnchainedLauncher.CLI.Tests/YamlConfigurationTests.cs +++ b/UnchainedLauncher.CLI.Tests/YamlConfigurationTests.cs @@ -251,9 +251,12 @@ public void SkippedInactiveYamlStillRequiresValidSyntaxWithoutExposingSecrets(bo [Fact] public void ServerSafetyFlagsDefaultOnAndCanBeDisabledInYaml() { var defaults = Assert.IsType(Load("server: {}")).ToServerLaunchOptions(); + Assert.True(defaults.Headless); Assert.True(defaults.DesyncPatch); Assert.True(defaults.UseBackendBanlist); Assert.True(defaults.LocalIp.IsSome); + Assert.Contains("-nullrhi", defaults.ToCLIArgs().Select(arg => arg.Rendered)); + Assert.Contains("--desync-patch", defaults.ToCLIArgs().Select(arg => arg.Rendered)); var disabled = Assert.IsType(Load("server: {desync-patch: false, use-backend-banlist: false}")).ToServerLaunchOptions(); Assert.False(disabled.DesyncPatch); Assert.False(disabled.UseBackendBanlist); @@ -261,6 +264,31 @@ public void ServerSafetyFlagsDefaultOnAndCanBeDisabledInYaml() { Assert.DoesNotContain("--use-backend-banlist", disabled.ToCLIArgs().Select(arg => arg.Rendered)); } + [Theory] + [InlineData("server: {headless: false}")] + [InlineData("server: {headless: false, desync-patch: true}")] + public void RenderingRequiresExplicitlyDisablingDesyncPatchInYaml(string yaml) { + var error = Assert.Throws(() => Load(yaml)); + Assert.Contains("headless", error.Message); + Assert.Contains("--no-desync-patch", error.Message); + Assert.Contains("server.desync-patch: false", error.Message); + var client = Assert.IsType(Load(yaml, client: true)).ToLaunchOptions(); + Assert.True(client.ServerLaunchOptions.IsNone); + Assert.Equal("TBL", client.LaunchArgs); + } + + [Fact] + public void RenderingWithExplicitlyDisabledDesyncPatchInYamlIsAllowed() { + var options = Assert.IsType(Load("server: {headless: false, desync-patch: false}")); + var server = options.ToServerLaunchOptions(); + Assert.False(server.Headless); + Assert.False(server.DesyncPatch); + Assert.True(server.UseBackendBanlist); + Assert.DoesNotContain("-nullrhi", server.ToCLIArgs().Select(arg => arg.Rendered)); + Assert.DoesNotContain("--desync-patch", server.ToCLIArgs().Select(arg => arg.Rendered)); + Assert.True(options.ToLaunchOptions().ServerLaunchOptions.IsSome); + } + [Theory] [InlineData("ffa-time-limit")] [InlineData("ffa-score-limit")] @@ -314,7 +342,7 @@ public void AllServerAndDiscordFieldsMapToCoreAndRenderedArguments() { server-browser-backend: https://example.test game-args: ['-log'] server: - headless: false + headless: true name: YAML Server description: Example MOTD password: private password @@ -340,7 +368,7 @@ public void AllServerAndDiscordFieldsMapToCoreAndRenderedArguments() { mention-admins: false """); var server = Assert.IsType(options).ToServerLaunchOptions(); - Assert.False(server.Headless); + Assert.True(server.Headless); Assert.Equal("YAML Server", server.Name); Assert.Equal("Example MOTD", server.Description); Assert.Equal("private password", server.Password.IfNone("")); @@ -369,7 +397,7 @@ public void AllServerAndDiscordFieldsMapToCoreAndRenderedArguments() { Assert.Contains("--discord-dashboard-channel-id 345678901234567890", rendered); Assert.Contains("--discord-event-log-channel-id 456789012345678901", rendered); Assert.Contains("--discord-admin-role-id 567890123456789012", rendered); - Assert.DoesNotContain("-nullrhi", rendered); + Assert.Contains("-nullrhi", rendered); Assert.Contains("-log", options.ToLaunchOptions().LaunchArgs); Assert.Contains("\"-custom=value with spaces\"", options.ToLaunchOptions().LaunchArgs); Assert.DoesNotContain("-client-only", options.ToLaunchOptions().LaunchArgs); diff --git a/UnchainedLauncher.CLI/CliLaunchOptions.cs b/UnchainedLauncher.CLI/CliLaunchOptions.cs index 1409a56f..e1eb2938 100644 --- a/UnchainedLauncher.CLI/CliLaunchOptions.cs +++ b/UnchainedLauncher.CLI/CliLaunchOptions.cs @@ -1,144 +1,146 @@ -using System.Net; -using System.Text.RegularExpressions; -using UnchainedLauncher.Core.JsonModels; -using UnchainedLauncher.Core.Services.Mods.Registry; -using UnchainedLauncher.Core.Services.Processes.Chivalry; -using UnchainedLauncher.Core.Utilities; -using static LanguageExt.Prelude; - -namespace UnchainedLauncher.CLI; - -/// Settings used by both client and server launches. -public sealed class SharedLaunchOptions { - public string Chivalry2Path { get; } - public string SavedDirSuffix { get; } - public InstallationType Platform { get; } - public string Backend { get; } - public bool UpdatePlugin { get; } - public bool AllowUnstablePlugin { get; } - public IReadOnlyList Registries { get; } - public IReadOnlyList Mods { get; } - public IReadOnlyList Dlls { get; } - public IReadOnlyList GameArguments { get; } - - internal SharedLaunchOptions(LaunchConfiguration configuration, string directory) { - Chivalry2Path = Path.GetFullPath(configuration.Chiv2Path!, directory); - SavedDirSuffix = configuration.SavedDirSuffix; - Platform = configuration.Platform!.Value; - Backend = configuration.ServerBrowserBackend; - UpdatePlugin = configuration.UpdatePlugin; - AllowUnstablePlugin = configuration.AllowUnstablePlugin; - GameArguments = Entries(configuration.GameArgs, allowEmpty: true).ToArray(); - var setup = configuration.ModSetup ?? new ModSetupConfiguration(); - Registries = Entries(setup.Registries).Select(value => RegistrySpec.Parse(value, directory)).ToArray(); - Mods = Entries(setup.Mods).Select(ParseCoordinates).ToArray(); - Dlls = Entries(setup.Dlls).Select(ParseCoordinates).ToArray(); - if (!Regex.IsMatch(SavedDirSuffix, @"\A[A-Za-z0-9_-]+\z")) - throw new ArgumentException("saved-dir-suffix may contain only letters, digits, underscores, and hyphens."); - if (!Uri.TryCreate(Backend, UriKind.Absolute, out var backend) || backend.Scheme is not ("http" or "https")) - throw new ArgumentException("server-browser-backend must be an HTTP(S) URL."); - if (Mods.GroupBy(m => (m.Org, m.ModuleName)).Any(g => g.Select(m => m.Version).Distinct().Count() > 1)) - throw new ArgumentException("Only one version of each mod may be selected."); - } - - internal static IEnumerable Entries(IEnumerable? values, bool allowEmpty = false) => - (values ?? []).Select(value => value is null || !allowEmpty && string.IsNullOrWhiteSpace(value) - ? throw new ArgumentException("Configuration lists must contain strings, not null or blank entries.") : value); - - public static ReleaseCoordinates ParseCoordinates(string value) { - var match = Regex.Match(value, @"\A([A-Za-z0-9_.-]+)/([A-Za-z0-9_.-]+):([^\s:/\\]+)\z"); - if (!match.Success) throw new ArgumentException($"Invalid coordinate '{value}'; expected organization/id:version (exact release tag)."); - return new ReleaseCoordinates(match.Groups[1].Value, match.Groups[2].Value, match.Groups[3].Value); - } -} - -public abstract class CliLaunchOptions { - private readonly SharedLaunchOptions? _shared; - public SharedLaunchOptions Shared => _shared ?? throw new InvalidOperationException("Help is not a launch request."); - public IReadOnlyList GameArguments { get; } - public bool Help { get; protected init; } - public string HelpText { get; protected init; } = ""; - - private protected CliLaunchOptions(SharedLaunchOptions shared, IEnumerable gameArguments) { - _shared = shared; - GameArguments = shared.GameArguments.Concat(gameArguments).ToArray(); - } - - private protected CliLaunchOptions(string helpText) { - Help = true; - HelpText = helpText; - GameArguments = []; - } - - public static CliLaunchOptions Parse(string[] args) => CommandLineOptions.Parse(args); - - internal static CliLaunchOptions FromConfiguration(LaunchConfiguration configuration, string directory) { - var shared = new SharedLaunchOptions(configuration, directory); - return configuration switch { - ServerConfiguration server => new ServerOptions(shared, server.Server ?? new ServerSettings(), server.NoTui), - ClientConfiguration client => new ClientOptions(shared, SharedLaunchOptions.Entries(client.Client?.GameArgs, allowEmpty: true)), - _ => throw new ArgumentException("Expected client or server configuration.") - }; - } - - public abstract LaunchOptions ToLaunchOptions(); - - private protected LaunchOptions BuildLaunchOptions(IEnumerable prefix, LanguageExt.Option server) => new( - EnabledReleases: Shared.Mods.Distinct(), ServerBrowserBackend: Some(Shared.Backend), - LaunchArgs: string.Join(" ", prefix.Concat(GameArguments.Select(ArgumentEscaper.Escape))), - CheckForDependencyUpdates: Shared.UpdatePlugin, AllowUnstablePluginReleases: Shared.AllowUnstablePlugin, - SavedDirSuffix: Some(Shared.SavedDirSuffix), ServerLaunchOptions: server); - - internal static CliLaunchOptions ForHelp(string text) => new HelpOptions(text); - - private sealed class HelpOptions : CliLaunchOptions { - // Help is a parser result, never a launch; shared settings are not constructed. - public HelpOptions(string text) : base(text) { } - public override LaunchOptions ToLaunchOptions() => throw new InvalidOperationException("Help is not a launch request."); - } -} - -public sealed class ClientOptions : CliLaunchOptions { - internal ClientOptions(SharedLaunchOptions shared, IEnumerable arguments) : base(shared, arguments) { } - public override LaunchOptions ToLaunchOptions() => BuildLaunchOptions(["TBL"], None); -} - -public sealed class ServerOptions : CliLaunchOptions { - public ServerSettings Settings { get; } - public bool NoTui { get; } - - internal ServerOptions(SharedLaunchOptions shared, ServerSettings settings, bool noTui) - : base(shared, SharedLaunchOptions.Entries(settings.GameArgs, allowEmpty: true)) { - Settings = settings; - NoTui = noTui; - if (new[] { settings.GamePort, settings.PingPort, settings.QueryPort, settings.RconPort }.Distinct().Count() != 4) - throw new ArgumentException("Game, ping, query, and RCON ports must be distinct."); - settings.LocalIp = string.IsNullOrWhiteSpace(settings.LocalIp) ? LocalAddressDetector.Detect().ToString() : settings.LocalIp.Trim(); - if (!IPAddress.TryParse(settings.LocalIp, out _)) throw new ArgumentException("local-ip must be an IP address."); - settings.ModActors = SharedLaunchOptions.Entries(settings.ModActors).Distinct().ToList(); - ToDiscordOptions(); - } - - public LanguageExt.Option ToDiscordOptions() { - if (Settings.Discord is not { } discord) return None; - var channels = new[] { discord.AdminChannelId, discord.GeneralChannelId, discord.DashboardChannelId, discord.EventLogChannelId }; - if (string.IsNullOrWhiteSpace(discord.BotToken) && channels.All(string.IsNullOrWhiteSpace) && string.IsNullOrWhiteSpace(discord.AdminRoleId)) return None; - if (string.IsNullOrWhiteSpace(discord.BotToken) || channels.All(string.IsNullOrWhiteSpace)) - throw new ArgumentException("Discord integration requires a bot token and at least one channel ID."); - LanguageExt.Option Text(string? value) => string.IsNullOrWhiteSpace(value) ? None : Some(value.Trim()); - return Some(new DiscordIntegrationLaunchOptions(discord.BotToken.Trim(), - Text(discord.AdminChannelId), Text(discord.GeneralChannelId), Text(discord.DashboardChannelId), - Text(discord.EventLogChannelId), Text(discord.AdminRoleId), discord.MentionAdmins)); - } - - public ServerLaunchOptions ToServerLaunchOptions() => new( - Settings.Headless, Settings.Name, Settings.Description, RegisterWithBackend: Settings.Register, - Password: string.IsNullOrWhiteSpace(Settings.Password) ? None : Some(Settings.Password.Trim()), - Settings.Map, Settings.GamePort, BeaconPort: Settings.PingPort, Settings.QueryPort, Settings.RconPort, - FFATimeLimit: null, FFAScoreLimit: null, TDMTimeLimit: null, TDMTicketCount: null, - PlayerBotCount: null, WarmupTime: null, LocalIp: Optional(Settings.LocalIp), - ServerMods: Settings.ModActors ?? [], DiscordIntegration: ToDiscordOptions(), - Settings.DesyncPatch, Settings.UseBackendBanlist, Settings.CensorMode); - - public override LaunchOptions ToLaunchOptions() => BuildLaunchOptions(["TBL", "-server"], Some(ToServerLaunchOptions())); +using System.Net; +using System.Text.RegularExpressions; +using UnchainedLauncher.Core.JsonModels; +using UnchainedLauncher.Core.Services.Mods.Registry; +using UnchainedLauncher.Core.Services.Processes.Chivalry; +using UnchainedLauncher.Core.Utilities; +using static LanguageExt.Prelude; + +namespace UnchainedLauncher.CLI; + +/// Settings used by both client and server launches. +public sealed class SharedLaunchOptions { + public string Chivalry2Path { get; } + public string SavedDirSuffix { get; } + public InstallationType Platform { get; } + public string Backend { get; } + public bool UpdatePlugin { get; } + public bool AllowUnstablePlugin { get; } + public IReadOnlyList Registries { get; } + public IReadOnlyList Mods { get; } + public IReadOnlyList Dlls { get; } + public IReadOnlyList GameArguments { get; } + + internal SharedLaunchOptions(LaunchConfiguration configuration, string directory) { + Chivalry2Path = Path.GetFullPath(configuration.Chiv2Path!, directory); + SavedDirSuffix = configuration.SavedDirSuffix; + Platform = configuration.Platform!.Value; + Backend = configuration.ServerBrowserBackend; + UpdatePlugin = configuration.UpdatePlugin; + AllowUnstablePlugin = configuration.AllowUnstablePlugin; + GameArguments = Entries(configuration.GameArgs, allowEmpty: true).ToArray(); + var setup = configuration.ModSetup ?? new ModSetupConfiguration(); + Registries = Entries(setup.Registries).Select(value => RegistrySpec.Parse(value, directory)).ToArray(); + Mods = Entries(setup.Mods).Select(ParseCoordinates).ToArray(); + Dlls = Entries(setup.Dlls).Select(ParseCoordinates).ToArray(); + if (!Regex.IsMatch(SavedDirSuffix, @"\A[A-Za-z0-9_-]+\z")) + throw new ArgumentException("saved-dir-suffix may contain only letters, digits, underscores, and hyphens."); + if (!Uri.TryCreate(Backend, UriKind.Absolute, out var backend) || backend.Scheme is not ("http" or "https")) + throw new ArgumentException("server-browser-backend must be an HTTP(S) URL."); + if (Mods.GroupBy(m => (m.Org, m.ModuleName)).Any(g => g.Select(m => m.Version).Distinct().Count() > 1)) + throw new ArgumentException("Only one version of each mod may be selected."); + } + + internal static IEnumerable Entries(IEnumerable? values, bool allowEmpty = false) => + (values ?? []).Select(value => value is null || !allowEmpty && string.IsNullOrWhiteSpace(value) + ? throw new ArgumentException("Configuration lists must contain strings, not null or blank entries.") : value); + + public static ReleaseCoordinates ParseCoordinates(string value) { + var match = Regex.Match(value, @"\A([A-Za-z0-9_.-]+)/([A-Za-z0-9_.-]+):([^\s:/\\]+)\z"); + if (!match.Success) throw new ArgumentException($"Invalid coordinate '{value}'; expected organization/id:version (exact release tag)."); + return new ReleaseCoordinates(match.Groups[1].Value, match.Groups[2].Value, match.Groups[3].Value); + } +} + +public abstract class CliLaunchOptions { + private readonly SharedLaunchOptions? _shared; + public SharedLaunchOptions Shared => _shared ?? throw new InvalidOperationException("Help is not a launch request."); + public IReadOnlyList GameArguments { get; } + public bool Help { get; protected init; } + public string HelpText { get; protected init; } = ""; + + private protected CliLaunchOptions(SharedLaunchOptions shared, IEnumerable gameArguments) { + _shared = shared; + GameArguments = shared.GameArguments.Concat(gameArguments).ToArray(); + } + + private protected CliLaunchOptions(string helpText) { + Help = true; + HelpText = helpText; + GameArguments = []; + } + + public static CliLaunchOptions Parse(string[] args) => CommandLineOptions.Parse(args); + + internal static CliLaunchOptions FromConfiguration(LaunchConfiguration configuration, string directory) { + var shared = new SharedLaunchOptions(configuration, directory); + return configuration switch { + ServerConfiguration server => new ServerOptions(shared, server.Server ?? new ServerSettings(), server.NoTui), + ClientConfiguration client => new ClientOptions(shared, SharedLaunchOptions.Entries(client.Client?.GameArgs, allowEmpty: true)), + _ => throw new ArgumentException("Expected client or server configuration.") + }; + } + + public abstract LaunchOptions ToLaunchOptions(); + + private protected LaunchOptions BuildLaunchOptions(IEnumerable prefix, LanguageExt.Option server) => new( + EnabledReleases: Shared.Mods.Distinct(), ServerBrowserBackend: Some(Shared.Backend), + LaunchArgs: string.Join(" ", prefix.Concat(GameArguments.Select(ArgumentEscaper.Escape))), + CheckForDependencyUpdates: Shared.UpdatePlugin, AllowUnstablePluginReleases: Shared.AllowUnstablePlugin, + SavedDirSuffix: Some(Shared.SavedDirSuffix), ServerLaunchOptions: server); + + internal static CliLaunchOptions ForHelp(string text) => new HelpOptions(text); + + private sealed class HelpOptions : CliLaunchOptions { + // Help is a parser result, never a launch; shared settings are not constructed. + public HelpOptions(string text) : base(text) { } + public override LaunchOptions ToLaunchOptions() => throw new InvalidOperationException("Help is not a launch request."); + } +} + +public sealed class ClientOptions : CliLaunchOptions { + internal ClientOptions(SharedLaunchOptions shared, IEnumerable arguments) : base(shared, arguments) { } + public override LaunchOptions ToLaunchOptions() => BuildLaunchOptions(["TBL"], None); +} + +public sealed class ServerOptions : CliLaunchOptions { + public ServerSettings Settings { get; } + public bool NoTui { get; } + + internal ServerOptions(SharedLaunchOptions shared, ServerSettings settings, bool noTui) + : base(shared, SharedLaunchOptions.Entries(settings.GameArgs, allowEmpty: true)) { + Settings = settings; + NoTui = noTui; + if (!settings.Headless && settings.DesyncPatch) + throw new ArgumentException("Desync patch requires a headless server. To enable rendering, pass --no-desync-patch or set server.desync-patch: false in YAML."); + if (new[] { settings.GamePort, settings.PingPort, settings.QueryPort, settings.RconPort }.Distinct().Count() != 4) + throw new ArgumentException("Game, ping, query, and RCON ports must be distinct."); + settings.LocalIp = string.IsNullOrWhiteSpace(settings.LocalIp) ? LocalAddressDetector.Detect().ToString() : settings.LocalIp.Trim(); + if (!IPAddress.TryParse(settings.LocalIp, out _)) throw new ArgumentException("local-ip must be an IP address."); + settings.ModActors = SharedLaunchOptions.Entries(settings.ModActors).Distinct().ToList(); + ToDiscordOptions(); + } + + public LanguageExt.Option ToDiscordOptions() { + if (Settings.Discord is not { } discord) return None; + var channels = new[] { discord.AdminChannelId, discord.GeneralChannelId, discord.DashboardChannelId, discord.EventLogChannelId }; + if (string.IsNullOrWhiteSpace(discord.BotToken) && channels.All(string.IsNullOrWhiteSpace) && string.IsNullOrWhiteSpace(discord.AdminRoleId)) return None; + if (string.IsNullOrWhiteSpace(discord.BotToken) || channels.All(string.IsNullOrWhiteSpace)) + throw new ArgumentException("Discord integration requires a bot token and at least one channel ID."); + LanguageExt.Option Text(string? value) => string.IsNullOrWhiteSpace(value) ? None : Some(value.Trim()); + return Some(new DiscordIntegrationLaunchOptions(discord.BotToken.Trim(), + Text(discord.AdminChannelId), Text(discord.GeneralChannelId), Text(discord.DashboardChannelId), + Text(discord.EventLogChannelId), Text(discord.AdminRoleId), discord.MentionAdmins)); + } + + public ServerLaunchOptions ToServerLaunchOptions() => new( + Settings.Headless, Settings.Name, Settings.Description, RegisterWithBackend: Settings.Register, + Password: string.IsNullOrWhiteSpace(Settings.Password) ? None : Some(Settings.Password.Trim()), + Settings.Map, Settings.GamePort, BeaconPort: Settings.PingPort, Settings.QueryPort, Settings.RconPort, + FFATimeLimit: null, FFAScoreLimit: null, TDMTimeLimit: null, TDMTicketCount: null, + PlayerBotCount: null, WarmupTime: null, LocalIp: Optional(Settings.LocalIp), + ServerMods: Settings.ModActors ?? [], DiscordIntegration: ToDiscordOptions(), + Settings.DesyncPatch, Settings.UseBackendBanlist, Settings.CensorMode); + + public override LaunchOptions ToLaunchOptions() => BuildLaunchOptions(["TBL", "-server"], Some(ToServerLaunchOptions())); } \ No newline at end of file diff --git a/UnchainedLauncher.CLI/CommandLineOptions.cs b/UnchainedLauncher.CLI/CommandLineOptions.cs index 4a479330..77224454 100644 --- a/UnchainedLauncher.CLI/CommandLineOptions.cs +++ b/UnchainedLauncher.CLI/CommandLineOptions.cs @@ -101,7 +101,7 @@ public Definition(bool client) { Add("--ping-port", "Ping port.", settings.PingPort, value => settings.PingPort = value); Add("--query-port", "A2S query port.", settings.QueryPort, value => settings.QueryPort = value); Add("--rcon-port", "RCON port.", settings.RconPort, value => settings.RconPort = value); - Add("--headless", "Run without rendering (true/false).", settings.Headless, value => settings.Headless = value); + Add("--headless", "Run without rendering (true/false). Rendering requires --no-desync-patch.", settings.Headless, value => settings.Headless = value); Add("--censor-mode", "none, standard, sex, or zealous.", settings.CensorMode, value => settings.CensorMode = value); Flag("--register", "List in the server browser.", value => settings.Register = value); Flag("--no-tui", "Periodic plain-text status instead of dashboard.", value => serverConfig.NoTui = value); diff --git a/UnchainedLauncher.CLI/README.md b/UnchainedLauncher.CLI/README.md index c44edd0a..5cc9e5cb 100644 --- a/UnchainedLauncher.CLI/README.md +++ b/UnchainedLauncher.CLI/README.md @@ -26,7 +26,8 @@ dotnet run --project UnchainedLauncher.CLI -c Release -- server ` The path is the installation root containing `TBL`, not the Win64 directory. The server is headless by default (`-nullrhi -unattended -nosound`); use -`--headless false` to render it. The plugin is installed +`--headless false --no-desync-patch` to render it (the desync patch only supports +headless servers). The plugin is installed or updated from the latest stable UnchainedPlugin release by default. Use `--no-plugin-update` to keep your installed `UnchainedPlugin.dll`; it must already exist. `--allow-unstable-plugin` permits prerelease plugin updates. @@ -119,6 +120,9 @@ all four ports, `headless`, `register`, `mod-actors`, `desync-patch`, `use-backend-banlist`, and `censor-mode` (`none`, `standard`, `sex`, `zealous`). Desync patch and backend banlist **default to true in server mode**; set their YAML values to false, or pass `--no-desync-patch` / `--no-backend-banlist`, to disable. +Rendered servers require `headless: false` **and** `desync-patch: false` in YAML, +or `--headless false --no-desync-patch` on the command line. Incompatible settings +are rejected before launch preparation; neither default is silently changed. Local IPv4 is automatically detected from active non-loopback/non-tunnel interfaces, preferring one with a default gateway; it falls back to another usable IPv4 or loopback. Set `local-ip` only to override this choice (for example, with a VPN or multiple adapters). @@ -238,7 +242,7 @@ Run `server --help` for the full option list. Additional settings include: | `--register` | Opt in to server-browser registration; off by default | | `--server-browser-backend` | `https://servers.polehammer.net` | | `--no-tui` | Periodic plain-text status instead of the dashboard | -| `--headless true/false` | `true`; server rendering can be enabled | +| `--headless true/false` | `true`; rendering requires `--no-desync-patch` | | `--no-desync-patch`, `--no-backend-banlist` | Disable the default-enabled server features | | `--censor-mode` | `none`; also `standard`, `sex`, `zealous` | | `--discord-bot-token` | Requires at least one Discord channel | diff --git a/UnchainedLauncher.CLI/examples/server.yaml b/UnchainedLauncher.CLI/examples/server.yaml index 44f96869..955971d3 100644 --- a/UnchainedLauncher.CLI/examples/server.yaml +++ b/UnchainedLauncher.CLI/examples/server.yaml @@ -26,7 +26,7 @@ server: ping-port: 3075 query-port: 7071 rcon-port: 9001 - headless: true + headless: true # Rendering requires headless: false AND desync-patch: false. register: false # local-ip: 192.168.1.10 # Optional override; local IPv4 is auto-detected. mod-actors: [] # Blueprint class paths, e.g. /Game/Mods/Example/BP_Example.BP_Example_C From 8a460b19d2f84f6185f743d0b1831503e9c17cd3 Mon Sep 17 00:00:00 2001 From: Jacob Barber Date: Sun, 11 Oct 2026 00:13:11 -0500 Subject: [PATCH 09/58] Preserve forwarded process argument boundaries with vetted quoting --- .../ArgumentBoundaryTests.cs | 123 ++++++++++++++++++ .../ArgumentProbe/ArgumentProbe.csproj | 8 ++ .../ArgumentProbe/Program.cs | 4 + .../UnchainedLauncher.CLI.Tests.csproj | 9 ++ .../UnchainedLauncher.Core.csproj | 1 + .../Utilities/ArgumentEscaper.cs | 21 +-- 6 files changed, 151 insertions(+), 15 deletions(-) create mode 100644 UnchainedLauncher.CLI.Tests/ArgumentBoundaryTests.cs create mode 100644 UnchainedLauncher.CLI.Tests/ArgumentProbe/ArgumentProbe.csproj create mode 100644 UnchainedLauncher.CLI.Tests/ArgumentProbe/Program.cs diff --git a/UnchainedLauncher.CLI.Tests/ArgumentBoundaryTests.cs b/UnchainedLauncher.CLI.Tests/ArgumentBoundaryTests.cs new file mode 100644 index 00000000..0c0c56e8 --- /dev/null +++ b/UnchainedLauncher.CLI.Tests/ArgumentBoundaryTests.cs @@ -0,0 +1,123 @@ +using System.Diagnostics; +using System.Text.Json; +using UnchainedLauncher.Core.Services.Processes; +using UnchainedLauncher.Core.Services.Processes.Chivalry; +using UnchainedLauncher.Core.Services.Processes.Chivalry.LaunchPreparers; +using UnchainedLauncher.Core.Utilities; +using Xunit; +using static LanguageExt.Prelude; + +namespace UnchainedLauncher.CLI.Tests; + +public sealed class ArgumentBoundaryTests : IDisposable { + private readonly string _directory = Path.Combine( + OperatingSystem.IsLinux() ? "/tmp/opencode" : Path.GetTempPath(), "argument-tests-" + Guid.NewGuid().ToString("N")); + private static string Probe => Path.Combine(AppContext.BaseDirectory, "argument-probe", + OperatingSystem.IsWindows() ? "ArgumentProbe.exe" : "ArgumentProbe"); + + public ArgumentBoundaryTests() => Directory.CreateDirectory(_directory); + public void Dispose() => Directory.Delete(_directory, recursive: true); + + public static IEnumerable ForwardedArguments() { + string[] arguments = ["", "\"", "a\"b", "a\\\"b", "a\\\\\"b", "\\", "tail\\\\", "space tail\\", + "-UserDir=C:\\Server Profiles\\", "tab\tvalue", "line\nbreak", "line\r\nbreak", " \t\n ", + "--", "-AUTH_PASSWORD=secret \"quoted\"\\", "& echo injected; $(echo injected) | >file"]; + foreach (var command in new[] { "client", "server" }) + foreach (var yaml in new[] { false, true }) + foreach (var argument in arguments) + yield return [command, yaml, argument]; + } + + [Theory] + [MemberData(nameof(ForwardedArguments))] + public async Task ForwardingPreservesExactProcessArguments(string command, bool yaml, string argument) { + // Repeat the value so YAML exercises both shared and command-specific forwarding. + var options = Parse(command, yaml, [argument, argument, "sentinel-after-forwarded"]); + var expected = new List { "TBL" }; + if (command == "server") expected.Add("-server"); + expected.AddRange([argument, argument, "sentinel-after-forwarded", "-saveddirsuffix=UnchainedCLI", "-unchained"]); + if (command == "server") expected.AddRange(DefaultServerArguments()); + expected.AddRange(["--server-browser-backend", "https://servers.polehammer.net"]); + + // Independent .NET ArgumentList control: no local escaping or parsing is used to form expected argv. + Assert.Equal(expected, await CaptureControl(expected)); + Assert.Equal(expected, await CaptureLaunch(options.ToLaunchOptions())); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task GeneratedServerValuesAlsoPreserveBoundaries(bool yaml) { + const string value = "quoted \"name\"\\\tline\nend\\"; + var options = Assert.IsType(Parse("server", yaml, [], value)); + var expected = new List { "TBL", "-server", "-saveddirsuffix=UnchainedCLI", "-unchained" }; + expected.AddRange(DefaultServerArguments(value, value, value)); + // Password follows the headless/backend flags, before local IP and desync options. + expected.Insert(expected.IndexOf("--local-ip"), "ServerPassword=" + value); + expected.AddRange(["--server-browser-backend", "https://servers.polehammer.net"]); + Assert.Equal(expected, await CaptureControl(expected)); + Assert.Equal(expected, await CaptureLaunch(options.ToLaunchOptions())); + } + + private CliLaunchOptions Parse(string command, bool yaml, string[] arguments, string? serverValue = null) { + if (!yaml) return CliLaunchOptions.Parse([command, "--chivalry2-path", _directory, "--platform", "Steam", + .. command == "server" ? new[] { "--local-ip", "127.0.0.1" } : [], + .. serverValue is null ? System.Array.Empty() : ["--name", serverValue, "--description", serverValue, + "--map", serverValue, "--password", serverValue], "--", .. arguments]); + var path = Path.Combine(_directory, "launch.yaml"); + // JSON-quoted strings are YAML scalars; cover both shared and command-specific game-args lists. + File.WriteAllText(path, $"chiv2-path: {JsonSerializer.Serialize(_directory)}\nplatform: Steam\n" + + $"game-args: {JsonSerializer.Serialize(arguments.Take(1))}\n{command}:\n" + + (command == "server" ? " local-ip: 127.0.0.1\n" : "") + + (serverValue is null ? "" : string.Concat(new[] { "name", "description", "map", "password" } + .Select(key => $" {key}: {JsonSerializer.Serialize(serverValue)}\n"))) + + $" game-args: {JsonSerializer.Serialize(arguments.Skip(1))}\n"); + return CliLaunchOptions.Parse([command, "--config-path", path]); + } + + private static IEnumerable DefaultServerArguments(string name = "Unchained Server", + string description = "", string map = "FFA_Courtyard") => [ + "-ini:Game:[/Script/TBL.TBLGameMode]:ServerName=" + name, "--next-map-name", map, + "Port=7777", "GameServerPingPort=3075", "GameServerQueryPort=7071", "-rcon", "9001", + "--server-browser-description", description, "--motd", description, "--censor-mode", "none", + "-nullrhi", "-unattended", "-nosound", "--use-backend-banlist", "--local-ip", "127.0.0.1", "--desync-patch" + ]; + + private async Task CaptureLaunch(LaunchOptions options) { + var output = Path.Combine(_directory, "launch-result.json"); + Directory.CreateDirectory(Path.Combine(_directory, FilePaths.BinDir)); + var preparer = Chivalry2LaunchPreparer.Create(opts => Some(opts with { + LaunchArgs = ArgumentEscaper.Escape(output) + " " + opts.LaunchArgs + })); + // Use the actual Core launcher and ProcessLauncher (not a string-capturing mock). + var launcher = new UnchainedChivalry2Launcher(preparer, new ProcessLauncher(Probe), + _directory, new NoOpInjector()); + var result = await launcher.TryLaunch(options); + using var process = result.Match(Left: error => throw new InvalidOperationException(error.Message), Right: process => process); + return await ReadResult(process, output); + } + + private async Task CaptureControl(IEnumerable arguments) { + var output = Path.Combine(_directory, "control-result.json"); + var start = new ProcessStartInfo(Probe) { UseShellExecute = false }; + start.ArgumentList.Add(output); + foreach (var argument in arguments) start.ArgumentList.Add(argument); + using var process = Process.Start(start)!; + return await ReadResult(process, output); + } + + private static async Task ReadResult(Process process, string output) { + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(20)); + try { await process.WaitForExitAsync(timeout.Token); } + catch { + if (!process.HasExited) process.Kill(entireProcessTree: true); + throw; + } + Assert.Equal(0, process.ExitCode); + return JsonSerializer.Deserialize(await File.ReadAllTextAsync(output))!; + } + + private sealed class NoOpInjector : IProcessInjector { + public bool Inject(Process process) => true; + } +} \ No newline at end of file diff --git a/UnchainedLauncher.CLI.Tests/ArgumentProbe/ArgumentProbe.csproj b/UnchainedLauncher.CLI.Tests/ArgumentProbe/ArgumentProbe.csproj new file mode 100644 index 00000000..238f157e --- /dev/null +++ b/UnchainedLauncher.CLI.Tests/ArgumentProbe/ArgumentProbe.csproj @@ -0,0 +1,8 @@ + + + Exe + net10.0 + enable + enable + + diff --git a/UnchainedLauncher.CLI.Tests/ArgumentProbe/Program.cs b/UnchainedLauncher.CLI.Tests/ArgumentProbe/Program.cs new file mode 100644 index 00000000..da36781c --- /dev/null +++ b/UnchainedLauncher.CLI.Tests/ArgumentProbe/Program.cs @@ -0,0 +1,4 @@ +using System.Text.Json; + +// The first argument is the result file; all remaining arguments come from the launcher. +File.WriteAllText(args[0], JsonSerializer.Serialize(args.Skip(1).ToArray())); \ No newline at end of file diff --git a/UnchainedLauncher.CLI.Tests/UnchainedLauncher.CLI.Tests.csproj b/UnchainedLauncher.CLI.Tests/UnchainedLauncher.CLI.Tests.csproj index 291e4d7a..e6e7a877 100644 --- a/UnchainedLauncher.CLI.Tests/UnchainedLauncher.CLI.Tests.csproj +++ b/UnchainedLauncher.CLI.Tests/UnchainedLauncher.CLI.Tests.csproj @@ -7,6 +7,9 @@ true + + + @@ -15,4 +18,10 @@ all + + + + + + diff --git a/UnchainedLauncher.Core/UnchainedLauncher.Core.csproj b/UnchainedLauncher.Core/UnchainedLauncher.Core.csproj index 3c20dea0..2d117cbf 100644 --- a/UnchainedLauncher.Core/UnchainedLauncher.Core.csproj +++ b/UnchainedLauncher.Core/UnchainedLauncher.Core.csproj @@ -27,6 +27,7 @@ + diff --git a/UnchainedLauncher.Core/Utilities/ArgumentEscaper.cs b/UnchainedLauncher.Core/Utilities/ArgumentEscaper.cs index 172dc5c2..eda68c98 100644 --- a/UnchainedLauncher.Core/Utilities/ArgumentEscaper.cs +++ b/UnchainedLauncher.Core/Utilities/ArgumentEscaper.cs @@ -1,16 +1,7 @@ -namespace UnchainedLauncher.Core.Utilities { - public static class ArgumentEscaper { - public static string Escape(string argument) { - var escaped = argument - .Replace("\"", "\\\"") - .ReplaceLineEndings("\\r\\n"); - - // Quote if contains spaces or is empty - if (string.IsNullOrEmpty(argument) || argument.Contains(' ')) { - return $"\"{escaped}\""; - } - - return escaped; - } - } +namespace UnchainedLauncher.Core.Utilities { + public static class ArgumentEscaper { + // Keep the raw command-line contracts used by Core and GUI, but delegate argv quoting + // to CliWrap's .NET-derived implementation (including backslashes, quotes and whitespace). + public static string Escape(string argument) => CliWrap.Builders.ArgumentsBuilder.Escape(argument); + } } \ No newline at end of file From 45fc8058216d54b0162efaa1999ddebe5ff1fed5 Mon Sep 17 00:00:00 2001 From: Jacob Barber Date: Sun, 11 Oct 2026 00:13:31 -0500 Subject: [PATCH 10/58] Ignore argument probe build outputs --- UnchainedLauncher.CLI.Tests/ArgumentProbe/.gitignore | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 UnchainedLauncher.CLI.Tests/ArgumentProbe/.gitignore diff --git a/UnchainedLauncher.CLI.Tests/ArgumentProbe/.gitignore b/UnchainedLauncher.CLI.Tests/ArgumentProbe/.gitignore new file mode 100644 index 00000000..4ded7c4c --- /dev/null +++ b/UnchainedLauncher.CLI.Tests/ArgumentProbe/.gitignore @@ -0,0 +1,2 @@ +/bin/ +/obj/ From 337c1e6c8be3badbde9bcdffa0471dd237428f7a Mon Sep 17 00:00:00 2001 From: Jacob Barber Date: Sun, 11 Oct 2026 00:33:32 -0500 Subject: [PATCH 11/58] Share managed pak ownership across GUI and CLI preparation --- .../ManagedInventoryTests.cs | 176 ++++ UnchainedLauncher.CLI/README.md | 11 +- UnchainedLauncher.CLI/ServerLauncher.cs | 11 +- .../Services/Mods/ManagedPakInventory.cs | 55 ++ .../Services/Mods/PakDir.cs | 859 +++++++++--------- .../LaunchPreparers/Chivalry2ModsInstaller.cs | 229 ++--- UnchainedLauncher.Core/Utilities/FilePaths.cs | 3 +- 7 files changed, 805 insertions(+), 539 deletions(-) create mode 100644 UnchainedLauncher.CLI.Tests/ManagedInventoryTests.cs create mode 100644 UnchainedLauncher.Core/Services/Mods/ManagedPakInventory.cs diff --git a/UnchainedLauncher.CLI.Tests/ManagedInventoryTests.cs b/UnchainedLauncher.CLI.Tests/ManagedInventoryTests.cs new file mode 100644 index 00000000..55915e58 --- /dev/null +++ b/UnchainedLauncher.CLI.Tests/ManagedInventoryTests.cs @@ -0,0 +1,176 @@ +using UnchainedLauncher.Core.JsonModels.ModMetadata; +using UnchainedLauncher.Core.Services.Mods; +using UnchainedLauncher.Core.Services.Mods.Registry; +using UnchainedLauncher.Core.Services.Processes.Chivalry; +using UnchainedLauncher.Core.Services.Processes.Chivalry.LaunchPreparers; +using UnchainedLauncher.Core.Utilities; +using UnchainedLauncher.UnrealModScanner.JsonModels; +using Xunit; + +namespace UnchainedLauncher.CLI.Tests; + +[Collection("Installation directory")] +public class ManagedInventoryTests : IDisposable { + private readonly string _root = Path.Combine(OperatingSystem.IsLinux() ? "/tmp/opencode" : Path.GetTempPath(), + "inventory-tests-" + Guid.NewGuid().ToString("N")); + + public ManagedInventoryTests() => Directory.CreateDirectory(_root); + public void Dispose() => Directory.Delete(_root, recursive: true); + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task SequentialCliGuiCliSharesOwnershipButNotSelectedReleases(bool server) { + await InInstallation(async (registry, common, extra, launch) => { + if (!server) launch = launch with { ServerLaunchOptions = LanguageExt.Option.None }; + var codec = new ModManagerCodec(registry); + var cliCache = Path.Combine(FilePaths.ModCachePath, "cli_mod_manager.json"); + var cli = Manager(registry, []); + await Prepare(cli, launch); + codec.SerializeFile(cliCache, cli); + + // GUI has its own selection and may reorder the shared common pak. + var selected = ReleaseCoordinates.FromRelease(extra); + var gui = Manager(registry, [selected]); + await Prepare(gui, launch); + codec.SerializeFile(Path.GetFullPath(FilePaths.ModManagerConfigPath), gui); + var guiConfiguration = File.ReadAllText(FilePaths.ModManagerConfigPath); + var oldExtra = gui.PakDir.GetManagedPakFilePath(selected).IfNone(""); + Assert.True(File.Exists(oldExtra)); + + var secondCli = Manager(registry, []); + await Prepare(secondCli, launch); + codec.SerializeFile(cliCache, secondCli); + Assert.False(File.Exists(oldExtra)); + Assert.False(File.Exists(Path.ChangeExtension(oldExtra, ".sig"))); + AssertOnlyCommonAndUnknown(secondCli, common); + Assert.Equal(guiConfiguration, File.ReadAllText(FilePaths.ModManagerConfigPath)); + + // The saved GUI snapshot now names obsolete files. Shared ownership wins, + // while GUI's selected release survives and is installed on its next launch. + var decoded = codec.Deserialize(guiConfiguration); + Assert.True(decoded.Success); + gui = decoded.Result!; + Assert.Equal(selected, Assert.Single(gui.EnabledModReleaseCoordinates)); + await Prepare(gui, launch); + Assert.Equal(2, ((PakDir)gui.PakDir).ManagedPaks.Count()); + await Prepare(secondCli, launch); // also reconcile an already-created manager + AssertOnlyCommonAndUnknown(secondCli, common); + }); + } + + [Theory] + [InlineData(true, false)] + [InlineData(false, true)] + [InlineData(true, true)] + public async Task LegacyCachesMigrateOwnershipAndRemoveDuplicateCommonPaks(bool guiCache, bool cliCache) { + await InInstallation(async (registry, common, extra, launch) => { + var codec = new ModManagerCodec(registry); + var paks = Path.GetFullPath(FilePaths.PakDir); + var commonCoordinate = ReleaseCoordinates.FromRelease(common); + var extraCoordinate = ReleaseCoordinates.FromRelease(extra); + var oldCommon = new ManagedPak(commonCoordinate, "legacy-common.pak", 0); + var newCommon = new ManagedPak(commonCoordinate, "gui-common.pak", 1); + var guiOnly = new ManagedPak(extraCoordinate, "gui-only.pak", 0); + foreach (var pak in new[] { oldCommon, newCommon, guiOnly }) { + File.Copy(Path.Combine(_root, "payload.pak"), Path.Combine(paks, pak.PakFileName)); + File.WriteAllText(Path.Combine(paks, Path.ChangeExtension(pak.PakFileName, ".sig")), "signature"); + } + // A single legacy input must be sufficient, including duplicate releases. + if (guiCache) codec.SerializeFile(Path.GetFullPath(FilePaths.ModManagerConfigPath), + new ModManager(registry, new PakDir(FilePaths.PakDir, cliCache ? [newCommon, guiOnly] : [oldCommon, newCommon, guiOnly]), [extraCoordinate])); + if (cliCache) codec.SerializeFile(Path.Combine(FilePaths.ModCachePath, "cli_mod_manager.json"), + new ModManager(registry, new PakDir(paks, guiCache ? [oldCommon] : [oldCommon, newCommon, guiOnly]), [])); + var beforeGui = guiCache ? File.ReadAllText(FilePaths.ModManagerConfigPath) : null; + var manager = Manager(registry, []); + await Prepare(manager, launch); + AssertOnlyCommonAndUnknown(manager, common); + foreach (var pak in new[] { oldCommon, newCommon, guiOnly }) { + Assert.False(File.Exists(Path.Combine(paks, pak.PakFileName))); + Assert.False(File.Exists(Path.Combine(paks, Path.ChangeExtension(pak.PakFileName, ".sig")))); + } + if (guiCache) Assert.Equal(beforeGui, File.ReadAllText(FilePaths.ModManagerConfigPath)); + // Obsolete caches must not override the post-migration shared inventory. + await Prepare(Manager(registry, []), launch); + AssertOnlyCommonAndUnknown(manager, common); + }); + } + + private ModManager Manager(IModRegistry registry, ReleaseCoordinates[] selected) => + new(registry, new PakDir(Path.GetFullPath(FilePaths.PakDir), []), selected); + + [Fact] + public async Task FailedDuplicateRemovalPersistsOwnershipAndCanBeRetried() { + await InInstallation(async (registry, common, extra, launch) => { + var paks = Path.GetFullPath(FilePaths.PakDir); + var coords = ReleaseCoordinates.FromRelease(common); + var first = new ManagedPak(coords, "first.pak", 0); + var duplicate = new ManagedPak(coords, "duplicate.pak", 1); + foreach (var pak in new[] { first, duplicate }) + File.Copy(Path.Combine(_root, "payload.pak"), Path.Combine(paks, pak.PakFileName)); + // A directory at the signature path deterministically rejects File.Delete + // on both Windows and Linux, unlike a platform-dependent file lock. + var blocked = Path.Combine(paks, "duplicate.sig"); + Directory.CreateDirectory(blocked); + var manager = new ModManager(registry, new PakDir(paks, [first, duplicate]), []); + Assert.False((await manager.UpdateModsList()).HasErrors); + Assert.True((await new Chivalry2ModsInstaller(manager, new ConsoleDialogue()).PrepareLaunch(launch)).IsNone); + var persisted = JsonHelpers.Deserialize(File.ReadAllText(Path.Combine(paks, FilePaths.ManagedPakInventoryFileName))); + Assert.True(persisted.Success); + Assert.Equal(2, persisted.Result!.ManagedPaks.Count()); + Assert.True(File.Exists(Path.Combine(paks, first.PakFileName))); + Assert.True(File.Exists(Path.Combine(paks, duplicate.PakFileName))); + Directory.Delete(blocked); + var retry = Manager(registry, []); + await Prepare(retry, launch); + AssertOnlyCommonAndUnknown(retry, common); + }); + } + + private static async Task Prepare(ModManager manager, LaunchOptions launch) { + Assert.False((await manager.UpdateModsList()).HasErrors); + ServerLauncher.ValidateReleases(manager, manager.EnabledModReleaseCoordinates); + var dialogue = new ConsoleDialogue(); + Assert.True((await new Chivalry2ModsInstaller(manager, dialogue).PrepareLaunch(launch)).IsSome); + Assert.True((await SigPreparer.Create(manager.PakDir, dialogue).PrepareLaunch(LanguageExt.Unit.Default)).IsSome); + } + + private static void AssertOnlyCommonAndUnknown(ModManager manager, Release common) { + var pakDir = (PakDir)manager.PakDir; + var owned = Assert.Single(pakDir.ManagedPaks); + Assert.Equal(ReleaseCoordinates.FromRelease(common), owned.Coordinates); + Assert.Equal(3, Directory.GetFiles(pakDir.DirPath, "*.pak").Length); // base, common, unknown + Assert.Equal("user payload", File.ReadAllText(Path.Combine(pakDir.DirPath, "user.pak"))); + Assert.Equal("base payload", File.ReadAllText(Path.Combine(pakDir.DirPath, "pakchunk0-WindowsNoEditor.pak"))); + Assert.True(File.Exists(Path.ChangeExtension(Path.Combine(pakDir.DirPath, owned.PakFileName), ".sig"))); + var inventory = JsonHelpers.Deserialize(File.ReadAllText(Path.Combine(pakDir.DirPath, FilePaths.ManagedPakInventoryFileName))); + Assert.True(inventory.Success); + Assert.Equal(owned, Assert.Single(inventory.Result!.ManagedPaks)); + } + + private async Task InInstallation(Func action) { + var request = CliLaunchOptions.Parse(["server", "--chivalry2-path", _root, "--platform", "Steam"]); + await ServerLauncher.InInstallationDirectoryAsync(request, async () => { + Directory.CreateDirectory(FilePaths.PakDir); + File.WriteAllText(Path.Combine(FilePaths.PakDir, "pakchunk0-WindowsNoEditor.pak"), "base payload"); + File.WriteAllText(Path.Combine(FilePaths.PakDir, "pakchunk0-WindowsNoEditor.sig"), "base signature"); + File.WriteAllText(Path.Combine(FilePaths.PakDir, "user.pak"), "user payload"); + var source = Path.Combine(_root, "payload.pak"); + File.WriteAllText(source, "managed payload"); + var hash = FileHelpers.Sha512(source).Match(Right: h => h, Left: e => throw new Exception(e.Message)); + Release Release(string org, string mod) => new("v1.0.0", hash, "test.pak", DateTime.UtcNow, + new ModInfo($"https://github.com/{org}/{mod}", mod, "test", null, null, [], [], []), + new AssetCollections([], [], [], [], []), null); + var common = Release("Chiv2-Community", "Unchained-Mods"); + var extra = Release("org", "GuiOnlyMod"); + var registry = new LocalModRegistry(Path.Combine(_root, "registry")); + await registry.AddRelease(common, source); + await registry.AddRelease(extra, source); + await action(registry, common, extra, request.ToLaunchOptions()); + foreach (var path in Directory.EnumerateFiles(registry.RegistryPath, "*.pak", SearchOption.AllDirectories)) { + using (File.Open(path, FileMode.Open, FileAccess.ReadWrite, FileShare.None)) { } + } + return 0; + }); + } +} \ No newline at end of file diff --git a/UnchainedLauncher.CLI/README.md b/UnchainedLauncher.CLI/README.md index 5cc9e5cb..e65e59d2 100644 --- a/UnchainedLauncher.CLI/README.md +++ b/UnchainedLauncher.CLI/README.md @@ -206,9 +206,14 @@ paths from release metadata: --server-mod /Game/Mods/Example/BP_Example.BP_Example_C ``` -The CLI stores managed-pak metadata in `\.mod_cache\cli_mod_manager.json`. -On first use it can seed inventory from the existing GUI mod-manager configuration, -but it never overwrites the GUI configuration or reuses its enabled-mod selection. +Core shares physical managed-pak ownership between GUI and CLI through +`\TBL\Content\Paks\.unchained_managed_paks.json`, updated after mod +preparation (including partial failures). On first use it migrates ownership from +both existing host caches; later host snapshots cannot replace this inventory. +The CLI still saves its own metadata in `.mod_cache\cli_mod_manager.json`, but +never overwrites GUI configuration or reuses the GUI's enabled-mod selection. +Each launch reconciles the installed managed set with that host's selected releases; +genuinely unmanaged user paks are preserved. Core's installation pipeline changes the installed managed pak set and signatures. Do not prepare a launch concurrently with the GUI or another CLI using the same installation; multiple running servers sharing that installation must use the diff --git a/UnchainedLauncher.CLI/ServerLauncher.cs b/UnchainedLauncher.CLI/ServerLauncher.cs index 97ec809b..6d2c5dde 100644 --- a/UnchainedLauncher.CLI/ServerLauncher.cs +++ b/UnchainedLauncher.CLI/ServerLauncher.cs @@ -30,15 +30,10 @@ public static async Task LaunchAsync(CliLaunchOptions request, Cancella return await InInstallationDirectoryAsync(request, async () => { var cachePath = Path.Combine(options.Chivalry2Path, FilePaths.ModCachePath, "cli_mod_manager.json"); var codec = new ModManagerCodec(registry); - ModManager? cached = null; - var existingCache = File.Exists(cachePath) ? cachePath : FilePaths.ModManagerConfigPath; - if (File.Exists(existingCache)) { - var decoded = codec.Deserialize(File.ReadAllText(existingCache)); - if (!decoded.Success) throw new InvalidDataException($"Cannot read mod cache: {existingCache}", decoded.Exception); - cached = decoded.Result; - } + // Core reconciles physical ownership across hosts immediately before installing. + // CLI selection comes only from this request, never from a cached GUI selection. var manager = new ModManager(registry, - new PakDir(pakPath, (cached?.PakDir as PakDir)?.ManagedPaks ?? []), options.Mods.Distinct()); + new PakDir(pakPath, []), options.Mods.Distinct()); Console.Error.WriteLine("Loading mod registries..."); var fetched = await manager.UpdateModsList(); cancellationToken.ThrowIfCancellationRequested(); diff --git a/UnchainedLauncher.Core/Services/Mods/ManagedPakInventory.cs b/UnchainedLauncher.Core/Services/Mods/ManagedPakInventory.cs new file mode 100644 index 00000000..30be2694 --- /dev/null +++ b/UnchainedLauncher.Core/Services/Mods/ManagedPakInventory.cs @@ -0,0 +1,55 @@ +using UnchainedLauncher.Core.Utilities; + +namespace UnchainedLauncher.Core.Services.Mods { + // Physical ownership is shared by hosts; enabled releases remain in their own configs. + internal sealed class ManagedPakInventory { + private readonly PakDir _pakDir; + private readonly string _path; + + private ManagedPakInventory(PakDir pakDir) { + _pakDir = pakDir; + _path = Path.Combine(pakDir.DirPath, FilePaths.ManagedPakInventoryFileName); + } + + public static ManagedPakInventory? Reconcile(IModManager manager) { + if (manager.PakDir is not PakDir pakDir) return null; + var inventory = new ManagedPakInventory(pakDir); + IEnumerable owned; + if (File.Exists(inventory._path)) { + // Once migrated, host snapshots must not resurrect obsolete ownership. + var decoded = JsonHelpers.Deserialize(File.ReadAllText(inventory._path)); + if (!decoded.Success) throw new InvalidDataException("Cannot read shared pak inventory.", decoded.Exception); + owned = decoded.Result!.ManagedPaks; + } + else { + var seeds = pakDir.ManagedPaks.ToList(); + // Existing hosts use installation-relative configs. Only migrate them for + // that installation, never for an unrelated/custom PakDir. + if (SameDirectory(pakDir.DirPath, FilePaths.PakDir)) { + var codec = new ModManagerCodec(manager.ModRegistry); + foreach (var path in new[] { FilePaths.ModManagerConfigPath, Path.Combine(FilePaths.ModCachePath, "cli_mod_manager.json") }) { + if (!File.Exists(path)) continue; + var decoded = codec.Deserialize(File.ReadAllText(path)); + if (!decoded.Success) throw new InvalidDataException($"Cannot read mod cache: {path}", decoded.Exception); + if (decoded.Result!.PakDir is PakDir cached && SameDirectory(cached.DirPath, pakDir.DirPath)) + seeds.AddRange(cached.ManagedPaks); + } + } + owned = seeds; + } + pakDir.ReconcileManagedPaks(owned); + return inventory; + } + + public void Save() { + var temporaryPath = _path + ".tmp"; + File.WriteAllText(temporaryPath, JsonHelpers.Serialize(new PakDirMetadata(_pakDir.DirPath, _pakDir.ManagedPaks))); + File.Move(temporaryPath, _path, overwrite: true); + } + + private static bool SameDirectory(string first, string second) => string.Equals( + Path.TrimEndingDirectorySeparator(Path.GetFullPath(first)), + Path.TrimEndingDirectorySeparator(Path.GetFullPath(second)), + OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal); + } +} \ No newline at end of file diff --git a/UnchainedLauncher.Core/Services/Mods/PakDir.cs b/UnchainedLauncher.Core/Services/Mods/PakDir.cs index 1645e0ff..95486107 100644 --- a/UnchainedLauncher.Core/Services/Mods/PakDir.cs +++ b/UnchainedLauncher.Core/Services/Mods/PakDir.cs @@ -1,419 +1,442 @@ -using LanguageExt; -using LanguageExt.Common; -using log4net; -using System.Text; -using System.Text.RegularExpressions; -using UnchainedLauncher.Core.Extensions; -using UnchainedLauncher.Core.Services.Mods.Registry; -using UnchainedLauncher.Core.Utilities; - -namespace UnchainedLauncher.Core.Services.Mods { - using static LanguageExt.Prelude; - - public class PakDir : IPakDir { - public string DirPath { get; } - - private const string BasePakFileName = "pakchunk0-WindowsNoEditor.pak"; - private const string BaseSigFileName = "pakchunk0-WindowsNoEditor.sig"; - - private static readonly ILog Logger = LogManager.GetLogger(nameof(PakDir)); - - public IEnumerable ManagedPaks => _managedPaks; - private readonly List _managedPaks; - - public PakDir(string dirPath, IEnumerable managedPaks) { - DirPath = dirPath; - _managedPaks = managedPaks.ToList(); - SynchronizeWithDir(); - } - - private IEnumerable GetSigFiles() { - return Directory.EnumerateFiles(DirPath, "*.sig"); - } - - private IEnumerable GetModPakFiles() { - return Directory.EnumerateFiles(DirPath, "*.pak") - .Filter(f => !f.EndsWith(BasePakFileName)); - } - - /// - /// Helper for deleting files using try - /// - /// file name to delete - /// - private static Either _deleteFile(string path) { - return PrimitiveExtensions - .TryVoid(() => File.Delete(path)) - .Invoke() - .Match>( - _ => Right(Unit.Default), - e => Error.New($"Failed to delete file: '{path}'", (Exception)e) - ); - } - - /// - /// Helper for copying files - /// - /// - /// - /// - private static Either _copyFile(string path, string destPath) { - return PrimitiveExtensions - .TryVoid(() => File.Copy(path, destPath)) - .Invoke() - .Match>( - _ => Right(Unit.Default), - e => Error.New($"Failed to copy file: '{path}' -> '{destPath}'", (Exception)e) - ); - } - - public Either Uninstall(ModIdentifier coords) => - GetManagedPakFile(coords) - .Match( - Some: pak => Right(pak) - .BindTap(x => _unsignFile(PakNameToPakPath(x.PakFileName))) - .Bind(x => _deleteFile(PakNameToPakPath(x.PakFileName))), - None: () => { - Logger.Debug($"No pak file found for {coords}. Ignoring Uninstall request"); - return Right(Unit.Default); - } - ); - - private EitherAsync _writePak(IPakDir.MakeFileWriter mkFileWriter, Option> progress, string suggestedFileName) { - var unManagedPaks = GetUnmanagedPaks().ToHashSet(); - var actualName = Path.GetFileNameWithoutExtension(suggestedFileName); - var extension = Path.GetExtension(suggestedFileName); - var fullActualName = actualName + extension; - while (_managedPaks.Exists(x => PakNameToPakPath(x.PakFileName) == fullActualName) || unManagedPaks.Contains(PakNameToPakPath(fullActualName))) { - actualName = Successors.TextualSuccessor(actualName); - fullActualName = actualName + extension; - } - - Logger.Info($"Downloading pak to {fullActualName}"); - return mkFileWriter(PakNameToPakPath(fullActualName)) - .Bind(fileWriter => - // TODO: allow passing of a cancellation token here - fileWriter.WriteAsync(progress, CancellationToken.None) - .Map(_ => fullActualName) - ); - } - - public async IAsyncEnumerable> InstallModSet( - IEnumerable installs, - Option progress) { - - // Input is trusted to be pre-sorted by dependency order - var installsList = installs.ToList(); - var installCoordinatesSet = installsList.Select(i => i.Coordinates).ToHashSet(); - - // Collect all pak file names, adding org prefix for duplicate module names - var moduleNameCounts = installsList - .GroupBy(i => i.Coordinates.ModuleName) - .Where(g => g.Count() > 1) - .Select(g => g.Key) - .ToHashSet(); - - var pakFileNames = installsList - .Select(install => { - var moduleName = install.Coordinates.ModuleName; - var needsOrgPrefix = moduleNameCounts.Contains(moduleName); - var baseName = needsOrgPrefix - ? $"{install.Coordinates.Org}_{moduleName}" - : moduleName; - return baseName + ".pak"; - }) - .ToList(); - - // Apply sorted lexicographically to get the final names - var sortedPakNames = ApplySortedLexicographically(pakFileNames).ToList(); - - // Build map of existing managed paks by coordinates (for mods in install set) - var existingByCoords = _managedPaks - .Filter(p => installsList.Exists(s => s.Coordinates == p.Coordinates)) - .ToDictionary(p => p.Coordinates, p => p); - - // Categorize each install: Skip (already correct), Move (relocate), or Download (new) - var installActions = installsList - .Zip(sortedPakNames, (install, finalPakName) => (Install: install, FinalPakName: finalPakName)) - .Select((item, index) => { - var existing = existingByCoords.TryGetValue(item.Install.Coordinates, out var e) ? Some(e) : None; - return existing.Match( - Some: pak => pak.PakFileName == item.FinalPakName - ? new InstallAction.Skip(item.Install, index, pak) - : new InstallAction.Move(item.Install, index, pak, item.FinalPakName) as InstallAction, - None: () => new InstallAction.Download(item.Install, index, item.FinalPakName) - ); - }) - .ToList(); - - // Categorize managed paks not in install set as Remove actions - installActions.AddRange( - _managedPaks - .Filter(p => !installCoordinatesSet.Any(c => c.Matches(p.Coordinates))) - .Select(p => new InstallAction.Remove(p)) - .ToList() - ); - - var removeActions = installActions.OfType().ToList(); - var skipActions = installActions.OfType().ToList(); - var moveActions = installActions.OfType().ToList(); - var downloadActions = installActions.OfType().ToList(); - - var reorderCount = skipActions.Count + moveActions.Count; - - // Set up all progress tracking upfront so users see the full scope of work - var removeProgress = removeActions.Any() - ? progress.Map(aggregateProgress => { - var prog = new MemoryProgress($"Removing {removeActions.Count} paks"); - aggregateProgress.AlsoTrack(prog); - return prog; - }) - : Option.None; - - var reorderProgress = reorderCount > 0 - ? progress.Map(aggregateProgress => { - var prog = new MemoryProgress($"Reordering {reorderCount} paks"); - aggregateProgress.AlsoTrack(prog); - return prog; - }) - : Option.None; - - var downloadProgressMap = downloadActions - .Select(d => { - var prog = progress.Map(aggregateProgress => { - var mp = new MemoryProgress($"Installing {d.Install.Coordinates.ModuleName}"); - aggregateProgress.AlsoTrack(mp); - return mp; - }); - return (Download: d, Progress: prog); - }) - .ToDictionary(x => x.Download, x => x.Progress); - - // Process removes - for (var i = 0; i < removeActions.Count; i++) { - ProcessRemove(removeActions[i]); - removeProgress.IfSome(p => p.Report(100d * (i + 1) / removeActions.Count)); - } - - // Process skips and moves together as "reordering" - var reorderIndex = 0; - foreach (var result in skipActions.Select(ProcessSkip)) { - yield return result; - reorderIndex++; - reorderProgress.IfSome(p => p.Report(100d * reorderIndex / reorderCount)); - } - - foreach (var result in moveActions.Select(ProcessMove)) { - yield return result; - reorderIndex++; - reorderProgress.IfSome(p => p.Report(100d * reorderIndex / reorderCount)); - } - - // Process downloads for mods not yet present - foreach (var download in downloadActions) { - var taskProgress = downloadProgressMap[download]; - yield return await ProcessDownload(download, taskProgress); - } - } - - private abstract record InstallAction { - public record Skip(ModInstallRequest Install, int Index, ManagedPak Existing) : InstallAction; - public record Move(ModInstallRequest Install, int Index, ManagedPak Existing, string TargetPakName) : InstallAction; - public record Download(ModInstallRequest Install, int Index, string FinalPakName) : InstallAction; - public record Remove(ManagedPak Existing) : InstallAction; - } - - private Either ProcessSkip(InstallAction.Skip skip) { - var desired = new ManagedPak(skip.Install.Coordinates, skip.Existing.PakFileName, skip.Index); - - // Update priority in _managedPaks if changed - if (skip.Existing.Priority != skip.Index) { - var idx = _managedPaks.FindIndex(p => p.Coordinates.Matches(skip.Install.Coordinates)); - if (idx >= 0) _managedPaks[idx] = desired; - } - - Logger.Debug($"Skipping {skip.Install.Coordinates.ModuleName} - already at {skip.Existing.PakFileName}"); - return Right(desired); - } - - private Either ProcessMove(InstallAction.Move move) { - var sourcePath = PakNameToPakPath(move.Existing.PakFileName); - var targetPath = PakNameToPakPath(move.TargetPakName); - - return _moveManagedPak(sourcePath, targetPath) - .Map(_ => { - var managedPak = new ManagedPak(move.Install.Coordinates, move.TargetPakName, move.Index); - _managedPaks.RemoveAll(p => p.Coordinates.Matches(move.Install.Coordinates)); - _managedPaks.Add(managedPak); - Logger.Info($"Relocated {move.Install.Coordinates.ModuleName} to {move.TargetPakName}"); - return managedPak; - }); - } - - private async Task> ProcessDownload(InstallAction.Download download, Option taskProgress) { - return (await _writePak(download.Install.Writer, taskProgress.Map(p => (IProgress)p), download.FinalPakName)) - .Map(pakFileName => { - var managedPak = new ManagedPak(download.Install.Coordinates, pakFileName, download.Index); - _managedPaks.Add(managedPak); - _signFile(PakNameToPakPath(managedPak.PakFileName)) - .IfLeft(e => Logger.Error($"Failed to sign {managedPak.PakFileName}", e)); - return managedPak; - }); - } - - private void ProcessRemove(InstallAction.Remove remove) { - Logger.Info($"Removing pak: {remove.Existing.PakFileName} ({remove.Existing.Coordinates.ModuleName} version {remove.Existing.Coordinates.Version})"); - var uninstallResult = Uninstall(remove.Existing.Coordinates); - uninstallResult.IfRight(_ => _managedPaks.RemoveAll(p => p.Coordinates.Matches(remove.Existing.Coordinates))); - uninstallResult.IfLeft(e => Logger.Error($"Failed to remove pak {remove.Existing.PakFileName}: {e}")); - } - - /// - /// Moves a managed pak (and its .sig if present) from source to destination. - /// - private static Either _moveManagedPak(string sourcePakPath, string destPakPath) { - var sourceSigPath = Path.ChangeExtension(sourcePakPath, ".sig"); - var destSigPath = Path.ChangeExtension(destPakPath, ".sig"); - - return _moveFile(sourcePakPath, destPakPath) - .Bind(_ => File.Exists(sourceSigPath) - ? _moveFile(sourceSigPath, destSigPath) - : Right(Unit.Default)); - } - - private static Either _moveFile(string source, string dest) { - return PrimitiveExtensions - .TryVoid(() => File.Move(source, dest)) - .Invoke() - .Match>( - _ => Right(Unit.Default), - e => Error.New($"Failed to move file: '{source}' -> '{dest}'", (Exception)e) - ); - } - - /// - /// Does nothing if the sign file already exists - /// - /// - /// - private Either _signFile(string path) { - var signedName = Path.ChangeExtension(path, ".sig"); - if (File.Exists(signedName)) { - return Right(Unit.Default); - } - var sigPath = Path.Join(DirPath, BaseSigFileName); - return (File.Exists(sigPath) - ? Either.Right(sigPath) - : Error.New($"Default sig file '{sigPath}' not found")) - .Bind(defaultPath => _copyFile(defaultPath, signedName)); - } - - private static Either _unsignFile(string path) { - var signedName = Path.ChangeExtension(path, ".sig"); - return _deleteFile(signedName); - } - - /// - /// Delete any ReleaseMap entries that don't have a corresponding file on disk anymore. - /// NOTE: this should only do anything when the *user* messed something up - /// - private void SynchronizeWithDir() { - var missing = _managedPaks - .Filter(p => !File.Exists(PakNameToPakPath(p.PakFileName))) - .ToHashSet(); - - if (missing.Count == 0) return; - - Logger.LogListWarn( - "The following files were missing from the pak dir when they were expected to exist:", - missing - ); - - _managedPaks.RemoveAll(missing.Contains); - } - - private Option GetManagedPakFile(ModIdentifier coords) { - return _managedPaks - .Filter(p => p.Coordinates.Matches(coords)) - .ToOption(); - } - - public Option GetManagedPakFilePath(ReleaseCoordinates coords) { - return GetManagedPakFile(coords) - .Map(pak => PakNameToPakPath(pak.PakFileName)); - } - - private string PakNameToPakPath(string fileName) { - return Path.Join(DirPath, fileName); - } - - private IEnumerable GetUnmanagedPaks() { - return GetModPakFiles() - .Filter(p => !_managedPaks.Exists(pak => pak.PakFileName.EndsWith(Path.GetFileName(p)))); - } - - public Either, Unit> SignAll() { - return GetModPakFiles() - .Map(_signFile) - .BindLefts(); - } - - public Either, Unit> UnSignAll() { - return GetModPakFiles() - .Map(_unsignFile) - .BindLefts(); - } - - public Either, Unit> Reset() { - return Directory.EnumerateFiles(DirPath) - .Filter(p => !(p.EndsWith(BasePakFileName) || p.EndsWith(BaseSigFileName))) - .Map(_deleteFile) - .BindLefts(); - } - - public Either, Unit> DeleteOrphanedSigs() { - return GetSigFiles() - .Filter(p => !File.Exists(Path.ChangeExtension(p, ".pak"))) - .Map(_deleteFile) - .BindLefts(); - } - - /// - /// represents i using characters from alphabet - /// - /// - /// must be positive - /// - private static string RepUsingAlphabet(string alphabet, int i) { - var systemBase = alphabet.Length; - var sb = new StringBuilder(); - while (i > 0) { - sb.Append(alphabet[i % systemBase]); - i /= systemBase; - } - - return string.Join("", sb.ToString().Reverse()); - } - - /// - /// Given a sequence of strings, modify those strings such - /// that they are sorted lexicographically and return it - /// - /// - /// - private static IEnumerable ApplySortedLexicographically(IEnumerable inputs) { - const string forcedSortDivider = "__-__"; - var inputsList = inputs.ToList(); - - // We reverse the alphabet because we actually want to load dependencies backwards - var alphabet = new string("abcdefghijklmnopqrstuvwxyz".Reverse().ToArray()); - - var requiredSymbols = (int)Math.Ceiling(Math.Log(inputsList.Count, alphabet.Length)); - return inputsList.Map((i, n) => - $"q{RepUsingAlphabet(alphabet, i).PadLeft(requiredSymbols, alphabet[0])}" + - forcedSortDivider + - $"{Regex.Replace(n, $".*{forcedSortDivider}", "")}" - ); - } - } +using LanguageExt; +using LanguageExt.Common; +using log4net; +using System.Text; +using System.Text.RegularExpressions; +using UnchainedLauncher.Core.Extensions; +using UnchainedLauncher.Core.Services.Mods.Registry; +using UnchainedLauncher.Core.Utilities; + +namespace UnchainedLauncher.Core.Services.Mods { + using static LanguageExt.Prelude; + + public class PakDir : IPakDir { + public string DirPath { get; } + + private const string BasePakFileName = "pakchunk0-WindowsNoEditor.pak"; + private const string BaseSigFileName = "pakchunk0-WindowsNoEditor.sig"; + + private static readonly ILog Logger = LogManager.GetLogger(nameof(PakDir)); + + public IEnumerable ManagedPaks => _managedPaks; + private readonly List _managedPaks; + + public PakDir(string dirPath, IEnumerable managedPaks) { + DirPath = dirPath; + _managedPaks = managedPaks.ToList(); + SynchronizeWithDir(); + } + + internal void ReconcileManagedPaks(IEnumerable managedPaks) { + var owned = managedPaks.ToList(); + // Inventory authorizes deletion only inside this directory, never base/user paks. + if (owned.Any(p => string.IsNullOrWhiteSpace(p.PakFileName) + || Path.GetFileName(p.PakFileName) != p.PakFileName + || p.PakFileName.Contains('\\') || p.PakFileName.Contains('/') + || !p.PakFileName.EndsWith(".pak", StringComparison.OrdinalIgnoreCase) + || p.PakFileName.Equals(BasePakFileName, StringComparison.OrdinalIgnoreCase))) + throw new InvalidDataException("Invalid managed pak filename in inventory."); + _managedPaks.Clear(); + _managedPaks.AddRange(owned.DistinctBy(p => p.PakFileName, StringComparer.OrdinalIgnoreCase)); + SynchronizeWithDir(); + } + + private IEnumerable GetSigFiles() { + return Directory.EnumerateFiles(DirPath, "*.sig"); + } + + private IEnumerable GetModPakFiles() { + return Directory.EnumerateFiles(DirPath, "*.pak") + .Filter(f => !f.EndsWith(BasePakFileName)); + } + + /// + /// Helper for deleting files using try + /// + /// file name to delete + /// + private static Either _deleteFile(string path) { + return PrimitiveExtensions + .TryVoid(() => File.Delete(path)) + .Invoke() + .Match>( + _ => Right(Unit.Default), + e => Error.New($"Failed to delete file: '{path}'", (Exception)e) + ); + } + + /// + /// Helper for copying files + /// + /// + /// + /// + private static Either _copyFile(string path, string destPath) { + return PrimitiveExtensions + .TryVoid(() => File.Copy(path, destPath)) + .Invoke() + .Match>( + _ => Right(Unit.Default), + e => Error.New($"Failed to copy file: '{path}' -> '{destPath}'", (Exception)e) + ); + } + + public Either Uninstall(ModIdentifier coords) => + GetManagedPakFile(coords) + .Match( + Some: pak => Right(pak) + .BindTap(x => _unsignFile(PakNameToPakPath(x.PakFileName))) + .Bind(x => _deleteFile(PakNameToPakPath(x.PakFileName))), + None: () => { + Logger.Debug($"No pak file found for {coords}. Ignoring Uninstall request"); + return Right(Unit.Default); + } + ); + + private EitherAsync _writePak(IPakDir.MakeFileWriter mkFileWriter, Option> progress, string suggestedFileName) { + var unManagedPaks = GetUnmanagedPaks().ToHashSet(); + var actualName = Path.GetFileNameWithoutExtension(suggestedFileName); + var extension = Path.GetExtension(suggestedFileName); + var fullActualName = actualName + extension; + while (_managedPaks.Exists(x => PakNameToPakPath(x.PakFileName) == fullActualName) || unManagedPaks.Contains(PakNameToPakPath(fullActualName))) { + actualName = Successors.TextualSuccessor(actualName); + fullActualName = actualName + extension; + } + + Logger.Info($"Downloading pak to {fullActualName}"); + return mkFileWriter(PakNameToPakPath(fullActualName)) + .Bind(fileWriter => + // TODO: allow passing of a cancellation token here + fileWriter.WriteAsync(progress, CancellationToken.None) + .Map(_ => fullActualName) + ); + } + + public async IAsyncEnumerable> InstallModSet( + IEnumerable installs, + Option progress) { + + // Input is trusted to be pre-sorted by dependency order + var installsList = installs.ToList(); + var installCoordinatesSet = installsList.Select(i => i.Coordinates).ToHashSet(); + + // Collect all pak file names, adding org prefix for duplicate module names + var moduleNameCounts = installsList + .GroupBy(i => i.Coordinates.ModuleName) + .Where(g => g.Count() > 1) + .Select(g => g.Key) + .ToHashSet(); + + var pakFileNames = installsList + .Select(install => { + var moduleName = install.Coordinates.ModuleName; + var needsOrgPrefix = moduleNameCounts.Contains(moduleName); + var baseName = needsOrgPrefix + ? $"{install.Coordinates.Org}_{moduleName}" + : moduleName; + return baseName + ".pak"; + }) + .ToList(); + + // Apply sorted lexicographically to get the final names + var sortedPakNames = ApplySortedLexicographically(pakFileNames).ToList(); + + // Build map of existing managed paks by coordinates (for mods in install set) + var existingByCoords = _managedPaks + .Filter(p => installsList.Exists(s => s.Coordinates == p.Coordinates)) + .GroupBy(p => p.Coordinates) + .ToDictionary(g => g.Key, g => g.First()); + + // Categorize each install: Skip (already correct), Move (relocate), or Download (new) + var installActions = installsList + .Zip(sortedPakNames, (install, finalPakName) => (Install: install, FinalPakName: finalPakName)) + .Select((item, index) => { + var existing = existingByCoords.TryGetValue(item.Install.Coordinates, out var e) ? Some(e) : None; + return existing.Match( + Some: pak => pak.PakFileName == item.FinalPakName + ? new InstallAction.Skip(item.Install, index, pak) + : new InstallAction.Move(item.Install, index, pak, item.FinalPakName) as InstallAction, + None: () => new InstallAction.Download(item.Install, index, item.FinalPakName) + ); + }) + .ToList(); + + // Categorize managed paks not in install set as Remove actions + installActions.AddRange( + _managedPaks + .Filter(p => !installCoordinatesSet.Any(c => c.Matches(p.Coordinates)) + || (existingByCoords.TryGetValue(p.Coordinates, out var kept) && kept != p)) + .Select(p => new InstallAction.Remove(p)) + .ToList() + ); + + var removeActions = installActions.OfType().ToList(); + var skipActions = installActions.OfType().ToList(); + var moveActions = installActions.OfType().ToList(); + var downloadActions = installActions.OfType().ToList(); + + var reorderCount = skipActions.Count + moveActions.Count; + + // Set up all progress tracking upfront so users see the full scope of work + var removeProgress = removeActions.Any() + ? progress.Map(aggregateProgress => { + var prog = new MemoryProgress($"Removing {removeActions.Count} paks"); + aggregateProgress.AlsoTrack(prog); + return prog; + }) + : Option.None; + + var reorderProgress = reorderCount > 0 + ? progress.Map(aggregateProgress => { + var prog = new MemoryProgress($"Reordering {reorderCount} paks"); + aggregateProgress.AlsoTrack(prog); + return prog; + }) + : Option.None; + + var downloadProgressMap = downloadActions + .Select(d => { + var prog = progress.Map(aggregateProgress => { + var mp = new MemoryProgress($"Installing {d.Install.Coordinates.ModuleName}"); + aggregateProgress.AlsoTrack(mp); + return mp; + }); + return (Download: d, Progress: prog); + }) + .ToDictionary(x => x.Download, x => x.Progress); + + // Process removes + for (var i = 0; i < removeActions.Count; i++) { + var removed = ProcessRemove(removeActions[i]); + if (removed.IsLeft) { + // Do not reorder and lose ownership of an undeleted duplicate. + yield return removed.Map(_ => removeActions[i].Existing); + yield break; + } + removeProgress.IfSome(p => p.Report(100d * (i + 1) / removeActions.Count)); + } + + // Process skips and moves together as "reordering" + var reorderIndex = 0; + foreach (var result in skipActions.Select(ProcessSkip)) { + yield return result; + reorderIndex++; + reorderProgress.IfSome(p => p.Report(100d * reorderIndex / reorderCount)); + } + + foreach (var result in moveActions.Select(ProcessMove)) { + yield return result; + reorderIndex++; + reorderProgress.IfSome(p => p.Report(100d * reorderIndex / reorderCount)); + } + + // Process downloads for mods not yet present + foreach (var download in downloadActions) { + var taskProgress = downloadProgressMap[download]; + yield return await ProcessDownload(download, taskProgress); + } + } + + private abstract record InstallAction { + public record Skip(ModInstallRequest Install, int Index, ManagedPak Existing) : InstallAction; + public record Move(ModInstallRequest Install, int Index, ManagedPak Existing, string TargetPakName) : InstallAction; + public record Download(ModInstallRequest Install, int Index, string FinalPakName) : InstallAction; + public record Remove(ManagedPak Existing) : InstallAction; + } + + private Either ProcessSkip(InstallAction.Skip skip) { + var desired = new ManagedPak(skip.Install.Coordinates, skip.Existing.PakFileName, skip.Index); + + // Update priority in _managedPaks if changed + if (skip.Existing.Priority != skip.Index) { + var idx = _managedPaks.FindIndex(p => p.Coordinates.Matches(skip.Install.Coordinates)); + if (idx >= 0) _managedPaks[idx] = desired; + } + + Logger.Debug($"Skipping {skip.Install.Coordinates.ModuleName} - already at {skip.Existing.PakFileName}"); + return Right(desired); + } + + private Either ProcessMove(InstallAction.Move move) { + var sourcePath = PakNameToPakPath(move.Existing.PakFileName); + var targetPath = PakNameToPakPath(move.TargetPakName); + + return _moveManagedPak(sourcePath, targetPath) + .Map(_ => { + var managedPak = new ManagedPak(move.Install.Coordinates, move.TargetPakName, move.Index); + _managedPaks.RemoveAll(p => p.Coordinates.Matches(move.Install.Coordinates)); + _managedPaks.Add(managedPak); + Logger.Info($"Relocated {move.Install.Coordinates.ModuleName} to {move.TargetPakName}"); + return managedPak; + }); + } + + private async Task> ProcessDownload(InstallAction.Download download, Option taskProgress) { + return (await _writePak(download.Install.Writer, taskProgress.Map(p => (IProgress)p), download.FinalPakName)) + .Map(pakFileName => { + var managedPak = new ManagedPak(download.Install.Coordinates, pakFileName, download.Index); + _managedPaks.Add(managedPak); + _signFile(PakNameToPakPath(managedPak.PakFileName)) + .IfLeft(e => Logger.Error($"Failed to sign {managedPak.PakFileName}", e)); + return managedPak; + }); + } + + private Either ProcessRemove(InstallAction.Remove remove) { + Logger.Info($"Removing pak: {remove.Existing.PakFileName} ({remove.Existing.Coordinates.ModuleName} version {remove.Existing.Coordinates.Version})"); + var path = PakNameToPakPath(remove.Existing.PakFileName); + var uninstallResult = _unsignFile(path).Bind(_ => _deleteFile(path)); + uninstallResult.IfRight(_ => _managedPaks.RemoveAll(p => p.PakFileName == remove.Existing.PakFileName)); + uninstallResult.IfLeft(e => Logger.Error($"Failed to remove pak {remove.Existing.PakFileName}: {e}")); + return uninstallResult; + } + + /// + /// Moves a managed pak (and its .sig if present) from source to destination. + /// + private static Either _moveManagedPak(string sourcePakPath, string destPakPath) { + var sourceSigPath = Path.ChangeExtension(sourcePakPath, ".sig"); + var destSigPath = Path.ChangeExtension(destPakPath, ".sig"); + + return _moveFile(sourcePakPath, destPakPath) + .Bind(_ => File.Exists(sourceSigPath) + ? _moveFile(sourceSigPath, destSigPath) + : Right(Unit.Default)); + } + + private static Either _moveFile(string source, string dest) { + return PrimitiveExtensions + .TryVoid(() => File.Move(source, dest)) + .Invoke() + .Match>( + _ => Right(Unit.Default), + e => Error.New($"Failed to move file: '{source}' -> '{dest}'", (Exception)e) + ); + } + + /// + /// Does nothing if the sign file already exists + /// + /// + /// + private Either _signFile(string path) { + var signedName = Path.ChangeExtension(path, ".sig"); + if (File.Exists(signedName)) { + return Right(Unit.Default); + } + var sigPath = Path.Join(DirPath, BaseSigFileName); + return (File.Exists(sigPath) + ? Either.Right(sigPath) + : Error.New($"Default sig file '{sigPath}' not found")) + .Bind(defaultPath => _copyFile(defaultPath, signedName)); + } + + private static Either _unsignFile(string path) { + var signedName = Path.ChangeExtension(path, ".sig"); + return _deleteFile(signedName); + } + + /// + /// Delete any ReleaseMap entries that don't have a corresponding file on disk anymore. + /// NOTE: this should only do anything when the *user* messed something up + /// + private void SynchronizeWithDir() { + var missing = _managedPaks + .Filter(p => !File.Exists(PakNameToPakPath(p.PakFileName))) + .ToHashSet(); + + if (missing.Count == 0) return; + + Logger.LogListWarn( + "The following files were missing from the pak dir when they were expected to exist:", + missing + ); + + _managedPaks.RemoveAll(missing.Contains); + } + + private Option GetManagedPakFile(ModIdentifier coords) { + return _managedPaks + .Filter(p => p.Coordinates.Matches(coords)) + .ToOption(); + } + + public Option GetManagedPakFilePath(ReleaseCoordinates coords) { + return GetManagedPakFile(coords) + .Map(pak => PakNameToPakPath(pak.PakFileName)); + } + + private string PakNameToPakPath(string fileName) { + return Path.Join(DirPath, fileName); + } + + private IEnumerable GetUnmanagedPaks() { + return GetModPakFiles() + .Filter(p => !_managedPaks.Exists(pak => pak.PakFileName.EndsWith(Path.GetFileName(p)))); + } + + public Either, Unit> SignAll() { + return GetModPakFiles() + .Map(_signFile) + .BindLefts(); + } + + public Either, Unit> UnSignAll() { + return GetModPakFiles() + .Map(_unsignFile) + .BindLefts(); + } + + public Either, Unit> Reset() { + return Directory.EnumerateFiles(DirPath) + .Filter(p => !(p.EndsWith(BasePakFileName) || p.EndsWith(BaseSigFileName))) + .Map(_deleteFile) + .BindLefts(); + } + + public Either, Unit> DeleteOrphanedSigs() { + return GetSigFiles() + .Filter(p => !File.Exists(Path.ChangeExtension(p, ".pak"))) + .Map(_deleteFile) + .BindLefts(); + } + + /// + /// represents i using characters from alphabet + /// + /// + /// must be positive + /// + private static string RepUsingAlphabet(string alphabet, int i) { + var systemBase = alphabet.Length; + var sb = new StringBuilder(); + while (i > 0) { + sb.Append(alphabet[i % systemBase]); + i /= systemBase; + } + + return string.Join("", sb.ToString().Reverse()); + } + + /// + /// Given a sequence of strings, modify those strings such + /// that they are sorted lexicographically and return it + /// + /// + /// + private static IEnumerable ApplySortedLexicographically(IEnumerable inputs) { + const string forcedSortDivider = "__-__"; + var inputsList = inputs.ToList(); + + // We reverse the alphabet because we actually want to load dependencies backwards + var alphabet = new string("abcdefghijklmnopqrstuvwxyz".Reverse().ToArray()); + + var requiredSymbols = (int)Math.Ceiling(Math.Log(inputsList.Count, alphabet.Length)); + return inputsList.Map((i, n) => + $"q{RepUsingAlphabet(alphabet, i).PadLeft(requiredSymbols, alphabet[0])}" + + forcedSortDivider + + $"{Regex.Replace(n, $".*{forcedSortDivider}", "")}" + ); + } + } } \ No newline at end of file diff --git a/UnchainedLauncher.Core/Services/Processes/Chivalry/LaunchPreparers/Chivalry2ModsInstaller.cs b/UnchainedLauncher.Core/Services/Processes/Chivalry/LaunchPreparers/Chivalry2ModsInstaller.cs index 61e9e6ec..31397973 100644 --- a/UnchainedLauncher.Core/Services/Processes/Chivalry/LaunchPreparers/Chivalry2ModsInstaller.cs +++ b/UnchainedLauncher.Core/Services/Processes/Chivalry/LaunchPreparers/Chivalry2ModsInstaller.cs @@ -1,110 +1,121 @@ -using LanguageExt; -using log4net; -using UnchainedLauncher.Core.Extensions; -using UnchainedLauncher.Core.JsonModels.ModMetadata; -using UnchainedLauncher.Core.Services.Mods; -using UnchainedLauncher.Core.Services.Mods.Registry; -using UnchainedLauncher.Core.Utilities; - -namespace UnchainedLauncher.Core.Services.Processes.Chivalry.LaunchPreparers { - using static LanguageExt.Prelude; - - public class Chivalry2ModsInstaller : IChivalry2LaunchPreparer { - private readonly ILog _logger = LogManager.GetLogger(typeof(Chivalry2ModsInstaller)); - private readonly IUserDialogueSpawner _userDialogueSpawner; - private IModManager _modManager { get; } - - public Chivalry2ModsInstaller(IModManager modManager, IUserDialogueSpawner dialogueSpawner) { - _modManager = modManager; - _userDialogueSpawner = dialogueSpawner; - } - - private OptionAsync GetHashResult(EitherAsync> result) { - return result.TapLeft, object>(e => - _logger.Error($"Failed to hash file", e) - ) - .ToOption() - .Bind(o => o.Match(OptionAsync.Some, OptionAsync.None)); - } - - private OptionAsync GetLocalHash(ReleaseCoordinates coords) { - return _modManager.PakDir.GetManagedPakFilePath(coords) - .Match( - path => GetHashResult(FileHelpers.Sha512Async(path)), - () => OptionAsync.None); - } - - private Unit IfHashInvalid((Release, string, string) validationResult) { - var coords = ReleaseCoordinates.FromRelease(validationResult.Item1); - _logger.Warn($"Hash mismatch: actual hash '{validationResult.Item2}' does not " + - $"match release hash '{validationResult.Item3}' for '{coords}'."); - - var redownloadResponse = _userDialogueSpawner.DisplayYesNoMessage( - $"The hash for release {coords} is not correct. " + - $"This indicates that the file on disk does not match " + - $"what is approved by the mod registry. This could be due to " + - $"simple file corruption, but could also indicate that the version " + - $"you have is malicious.\n\n" + - $"Expected: {validationResult.Item3}\n" + - $"Actual: {validationResult.Item2}\n\n" + - $"Would you like to re-download the pak? (recommended)", "Refresh pak file?"); - - if (redownloadResponse == UserDialogueChoice.Yes) { - _modManager.PakDir.Uninstall(coords) - .IfLeft(e => - _logger.Error($"Failed to uninstall release '{coords}' with invalid hash", e)); - } - - return Unit.Default; - } - - private async Task ValidateHashes(IEnumerable releases) { - var hashValidationTasks = releases - .Map(m => - GetLocalHash(ReleaseCoordinates.FromRelease(m)) - .Map(h => (m, h, m.ReleaseHash)) - // select only those whose hashes do not match - .Filter(o => !o.Item2.Equals(o.Item3)) - .IfSome(IfHashInvalid) - ); - - await Task.WhenAll(hashValidationTasks); - } - - public async Task> PrepareLaunch(LaunchOptions options) { - // Get all releases that will be installed (enabled mods + dependencies) - var releasesToInstall = _modManager.GetEnabledAndDependencyReleases().ToList(); - _logger.LogListInfo("Installing the following releases:", releasesToInstall.Select(ReleaseCoordinates.FromRelease).ToList()); - - // Validate hashes before installation - await ValidateHashes(releasesToInstall); - - // Install mods via ModManager - var progresses = new AccumulatedMemoryProgress(taskName: "Installing releases"); - var closeProgressWindow = _userDialogueSpawner.DisplayProgress(progresses); - - var installResults = await _modManager.InstallMods(progresses).ToListAsync(); - - var (errors, successes) = - installResults.Partition() - .MapFirst(x => x.ToList()) - .MapSecond(x => x.ToList()); - - successes.ForEach(succ => _logger.Debug($"Successfully installed {succ.Coordinates.ModuleName} version {succ.Coordinates.Version}")); - - if (errors.Count != 0) { - errors.ToList().ForEach(e => _logger.Error($"Failed to install releases: {e.Message}")); - var shouldContinue = _userDialogueSpawner.DisplayYesNoMessage( - $"There were errors while installing releases:\n " + - $"{errors.Aggregate((a, b) => $"{a}\n\n{b}")}\n" + - "Would you like to continue anyway?", "Errors encountered" - ); - if (shouldContinue == UserDialogueChoice.No) return None; - } - - closeProgressWindow(); - - return Some(options); - } - } +using LanguageExt; +using log4net; +using UnchainedLauncher.Core.Extensions; +using UnchainedLauncher.Core.JsonModels.ModMetadata; +using UnchainedLauncher.Core.Services.Mods; +using UnchainedLauncher.Core.Services.Mods.Registry; +using UnchainedLauncher.Core.Utilities; + +namespace UnchainedLauncher.Core.Services.Processes.Chivalry.LaunchPreparers { + using static LanguageExt.Prelude; + + public class Chivalry2ModsInstaller : IChivalry2LaunchPreparer { + private readonly ILog _logger = LogManager.GetLogger(typeof(Chivalry2ModsInstaller)); + private readonly IUserDialogueSpawner _userDialogueSpawner; + private IModManager _modManager { get; } + + public Chivalry2ModsInstaller(IModManager modManager, IUserDialogueSpawner dialogueSpawner) { + _modManager = modManager; + _userDialogueSpawner = dialogueSpawner; + } + + private OptionAsync GetHashResult(EitherAsync> result) { + return result.TapLeft, object>(e => + _logger.Error($"Failed to hash file", e) + ) + .ToOption() + .Bind(o => o.Match(OptionAsync.Some, OptionAsync.None)); + } + + private OptionAsync GetLocalHash(ReleaseCoordinates coords) { + return _modManager.PakDir.GetManagedPakFilePath(coords) + .Match( + path => GetHashResult(FileHelpers.Sha512Async(path)), + () => OptionAsync.None); + } + + private Unit IfHashInvalid((Release, string, string) validationResult) { + var coords = ReleaseCoordinates.FromRelease(validationResult.Item1); + _logger.Warn($"Hash mismatch: actual hash '{validationResult.Item2}' does not " + + $"match release hash '{validationResult.Item3}' for '{coords}'."); + + var redownloadResponse = _userDialogueSpawner.DisplayYesNoMessage( + $"The hash for release {coords} is not correct. " + + $"This indicates that the file on disk does not match " + + $"what is approved by the mod registry. This could be due to " + + $"simple file corruption, but could also indicate that the version " + + $"you have is malicious.\n\n" + + $"Expected: {validationResult.Item3}\n" + + $"Actual: {validationResult.Item2}\n\n" + + $"Would you like to re-download the pak? (recommended)", "Refresh pak file?"); + + if (redownloadResponse == UserDialogueChoice.Yes) { + _modManager.PakDir.Uninstall(coords) + .IfLeft(e => + _logger.Error($"Failed to uninstall release '{coords}' with invalid hash", e)); + } + + return Unit.Default; + } + + private async Task ValidateHashes(IEnumerable releases) { + var hashValidationTasks = releases + .Map(m => + GetLocalHash(ReleaseCoordinates.FromRelease(m)) + .Map(h => (m, h, m.ReleaseHash)) + // select only those whose hashes do not match + .Filter(o => !o.Item2.Equals(o.Item3)) + .IfSome(IfHashInvalid) + ); + + await Task.WhenAll(hashValidationTasks); + } + + public async Task> PrepareLaunch(LaunchOptions options) { + var inventory = ManagedPakInventory.Reconcile(_modManager); + try { + return await PrepareMods(options); + } + finally { + // Persist physical ownership even when preparation fails partway through. + inventory?.Save(); + } + } + + private async Task> PrepareMods(LaunchOptions options) { + // Get all releases that will be installed (enabled mods + dependencies) + var releasesToInstall = _modManager.GetEnabledAndDependencyReleases().ToList(); + _logger.LogListInfo("Installing the following releases:", releasesToInstall.Select(ReleaseCoordinates.FromRelease).ToList()); + + // Validate hashes before installation + await ValidateHashes(releasesToInstall); + + // Install mods via ModManager + var progresses = new AccumulatedMemoryProgress(taskName: "Installing releases"); + var closeProgressWindow = _userDialogueSpawner.DisplayProgress(progresses); + + var installResults = await _modManager.InstallMods(progresses).ToListAsync(); + + var (errors, successes) = + installResults.Partition() + .MapFirst(x => x.ToList()) + .MapSecond(x => x.ToList()); + + successes.ForEach(succ => _logger.Debug($"Successfully installed {succ.Coordinates.ModuleName} version {succ.Coordinates.Version}")); + + if (errors.Count != 0) { + errors.ToList().ForEach(e => _logger.Error($"Failed to install releases: {e.Message}")); + var shouldContinue = _userDialogueSpawner.DisplayYesNoMessage( + $"There were errors while installing releases:\n " + + $"{errors.Aggregate((a, b) => $"{a}\n\n{b}")}\n" + + "Would you like to continue anyway?", "Errors encountered" + ); + if (shouldContinue == UserDialogueChoice.No) return None; + } + + closeProgressWindow(); + + return Some(options); + } + } } \ No newline at end of file diff --git a/UnchainedLauncher.Core/Utilities/FilePaths.cs b/UnchainedLauncher.Core/Utilities/FilePaths.cs index d608d124..3a5af57b 100644 --- a/UnchainedLauncher.Core/Utilities/FilePaths.cs +++ b/UnchainedLauncher.Core/Utilities/FilePaths.cs @@ -16,10 +16,11 @@ public static class FilePaths { public const string ModManagerConfigPath = $"{ModCachePath}\\mod_manager_config.json"; public const string DllManagerConfigPath = $"{ModCachePath}\\dll_manager_config.json"; public const string PakDirMetadata = $"{ModCachePath}\\pak_dir_metadata.json"; + public const string ManagedPakInventoryFileName = ".unchained_managed_paks.json"; public static string Chiv2ConfigPath(string? savedDirSuffix) { var unexpandedPath = $@"%LOCALAPPDATA%\Chivalry 2\Saved_{savedDirSuffix}\Config\WindowsNoEditor"; return Environment.ExpandEnvironmentVariables(unexpandedPath); } } -} \ No newline at end of file +} From de6e0654d6d6674dc122b32b5f7897163c88d12c Mon Sep 17 00:00:00 2001 From: Jacob Barber Date: Mon, 12 Oct 2026 16:18:05 -0500 Subject: [PATCH 12/58] Add registry queries and dll_db-backed DLL launch management --- .../CommandLineOptionsTests.cs | 2 +- .../DllManagerTests.cs | 183 ++++++++ .../DllQueryAndLoadingTests.cs | 172 ++++++++ UnchainedLauncher.CLI.Tests/ModQueryTests.cs | 208 +++++++++ .../YamlConfigurationTests.cs | 6 +- UnchainedLauncher.CLI/CliCommandOptions.cs | 6 + UnchainedLauncher.CLI/CliLaunchOptions.cs | 18 +- UnchainedLauncher.CLI/CommandLineOptions.cs | 49 ++- UnchainedLauncher.CLI/DllQuery.cs | 47 ++ UnchainedLauncher.CLI/ModQuery.cs | 61 +++ UnchainedLauncher.CLI/ModQueryDefinition.cs | 76 ++++ UnchainedLauncher.CLI/Program.cs | 21 +- UnchainedLauncher.CLI/README.md | 113 ++++- UnchainedLauncher.CLI/RegistryOptions.cs | 24 + .../RegistryQueryConfiguration.cs | 25 ++ UnchainedLauncher.CLI/ServerLauncher.cs | 17 +- UnchainedLauncher.CLI/YamlConfiguration.cs | 18 +- UnchainedLauncher.CLI/examples/server.yaml | 2 +- .../JsonModels/DllMetadata.cs | 47 ++ .../Services/Mods/DllManager.cs | 197 +++++++++ .../Mods/Registry/AggregateModRegistry.cs | 13 +- .../Services/Mods/Registry/DllCatalog.cs | 87 ++++ .../Mods/Registry/GithubModRegistry.cs | 13 +- .../Services/Mods/Registry/IModRegistry.cs | 7 +- .../Services/Mods/Registry/JsonRegistry.cs | 3 +- .../Mods/Registry/LocalModRegistry.cs | 413 +++++++++--------- .../Processes/Chivalry/IChivalry2Launcher.cs | 361 +++++++-------- .../Services/Processes/UnsafeDllLoading.cs | 39 ++ .../UnchainedLauncher.Core.csproj | 1 + UnchainedLauncher.GUI/App.xaml.cs | 8 +- 30 files changed, 1785 insertions(+), 452 deletions(-) create mode 100644 UnchainedLauncher.CLI.Tests/DllManagerTests.cs create mode 100644 UnchainedLauncher.CLI.Tests/DllQueryAndLoadingTests.cs create mode 100644 UnchainedLauncher.CLI.Tests/ModQueryTests.cs create mode 100644 UnchainedLauncher.CLI/CliCommandOptions.cs create mode 100644 UnchainedLauncher.CLI/DllQuery.cs create mode 100644 UnchainedLauncher.CLI/ModQuery.cs create mode 100644 UnchainedLauncher.CLI/ModQueryDefinition.cs create mode 100644 UnchainedLauncher.CLI/RegistryOptions.cs create mode 100644 UnchainedLauncher.CLI/RegistryQueryConfiguration.cs create mode 100644 UnchainedLauncher.Core/JsonModels/DllMetadata.cs create mode 100644 UnchainedLauncher.Core/Services/Mods/DllManager.cs create mode 100644 UnchainedLauncher.Core/Services/Mods/Registry/DllCatalog.cs create mode 100644 UnchainedLauncher.Core/Services/Processes/UnsafeDllLoading.cs diff --git a/UnchainedLauncher.CLI.Tests/CommandLineOptionsTests.cs b/UnchainedLauncher.CLI.Tests/CommandLineOptionsTests.cs index 42448bb5..b4da308b 100644 --- a/UnchainedLauncher.CLI.Tests/CommandLineOptionsTests.cs +++ b/UnchainedLauncher.CLI.Tests/CommandLineOptionsTests.cs @@ -48,7 +48,7 @@ public void SingletonOptionsCannotBeRepeated(string flag) { public void HelpAndInvalidCliAlsoUseOneLibraryParse() { foreach (var args in new string[][] { [], ["client", "--help"], ["server", "--platform=Invalid"] }) { var calls = 0; - CliLaunchOptions Parse() => CommandLineOptions.Parse(args, (root, tokens, configuration) => { + CliCommandOptions Parse() => CommandLineOptions.Parse(args, (root, tokens, configuration) => { calls++; return root.Parse(tokens, configuration); }); diff --git a/UnchainedLauncher.CLI.Tests/DllManagerTests.cs b/UnchainedLauncher.CLI.Tests/DllManagerTests.cs new file mode 100644 index 00000000..8f24188d --- /dev/null +++ b/UnchainedLauncher.CLI.Tests/DllManagerTests.cs @@ -0,0 +1,183 @@ +using System.Security.Cryptography; +using LanguageExt; +using UnchainedLauncher.Core.JsonModels; +using UnchainedLauncher.Core.JsonModels.ModMetadata; +using UnchainedLauncher.Core.Services.Mods; +using UnchainedLauncher.Core.Services.Mods.Registry; +using UnchainedLauncher.Core.Services.Processes.Chivalry; +using UnchainedLauncher.Core.Utilities; +using Xunit; + +namespace UnchainedLauncher.CLI.Tests; + +public sealed class DllManagerTests { + private static DllRelease Release(string id, string version = "v1.0.0", string target = "shared", params Dependency[] dependencies) => + new(version, Convert.ToHexString(SHA256.HashData([1, 2, 3])), "Plugin.dll", "https://example.test/plugin.dll", 3, + DateTimeOffset.UtcNow, new DllInfo { + RepoUrl = "https://github.com/" + id, + Name = id, + Description = "test", + Authors = ["test"], + Target = target, + PluginDependencies = dependencies.ToList() + }, null); + private static DllPackage Package(params DllRelease[] releases) => new(releases[0].Info, releases.ToList()); + + [Theory] + [InlineData("^1.0.0", true)] + [InlineData(">=v1.0.0,<2.0.0", true)] + [InlineData("==1.0.0", true)] + [InlineData("!=1.0.0", false)] + [InlineData("v1.0.0", true)] + [InlineData(">=2.0.0", false)] + public void RegistryVersionRequirementsAreEnforced(string requirement, bool matches) => + Assert.Equal(matches, DllManager.Satisfies(Release("org/test"), requirement)); + + [Fact] + public void DependenciesAreOrderedBeforeDependentsAndExplicitPinsAreHonored() { + var core = Release("Chiv2-Community/UnchainedPlugin"); + var dependency = Release("org/dependency"); + var plugin = Release("org/plugin", dependencies: [new Dependency("https://github.com/org/dependency", "^1.0.0")]); + var newer = plugin with { Tag = "v2.0.0" }; + var resolved = DllManager.Resolve([Package(core), Package(dependency), Package(plugin, newer)], + [new ReleaseCoordinates("org", "plugin", "v1.0.0")], false, false); + Assert.Equal(new[] { core, dependency, plugin }, resolved); + } + + [Fact] + public void WrongTargetsMissingDependenciesAndCyclesFailClosed() { + var core = Release("Chiv2-Community/UnchainedPlugin", target: "server"); + Assert.Throws(() => DllManager.Resolve([Package(core)], [], false, false)); + core = core with { Info = core.Info with { Target = "shared", PluginDependencies = [new Dependency("https://github.com/org/missing", "1.0.0")] } }; + Assert.Throws(() => DllManager.Resolve([Package(core)], [], false, false)); + var dependency = Release("org/missing", dependencies: [new Dependency("https://github.com/Chiv2-Community/UnchainedPlugin", "1.0.0")]); + Assert.Throws(() => DllManager.Resolve([Package(core), Package(dependency)], [], false, false)); + } + + [Fact] + public void ConflictingPinsAndUnapprovedAutomaticPrereleasesAreRejected() { + var core = Release("Chiv2-Community/UnchainedPlugin"); + var newer = core with { Tag = "v2.0.0" }; + Assert.Throws(() => DllManager.Resolve([Package(core, newer)], + [new ReleaseCoordinates("Chiv2-Community", "UnchainedPlugin", core.Tag), + new ReleaseCoordinates("Chiv2-Community", "UnchainedPlugin", newer.Tag)], false, false)); + var prerelease = core with { Tag = "v3.0.0-rc.1" }; + Assert.Throws(() => DllManager.Resolve([Package(prerelease)], [], false, false)); + Assert.Equal(prerelease, Assert.Single(DllManager.Resolve([Package(prerelease)], [], false, true))); + Assert.Equal(prerelease, Assert.Single(DllManager.Resolve([Package(prerelease)], + [new ReleaseCoordinates("Chiv2-Community", "UnchainedPlugin", prerelease.Tag)], false, false))); + } + + [Theory] + [InlineData("../x.dll")] + [InlineData("..\\x.dll")] + [InlineData("C:x.dll")] + [InlineData("x?.dll")] + [InlineData("x.exe")] + public void UnsafeRegistryFileNamesAreRejected(string name) => + Assert.Throws(() => DllManager.SafeFileName(name)); + + [Fact] + public async Task ExistingFilesRequireRegistryHashAndSizeAndInventoryRecordsApprovedSelection() { + var root = Path.Combine(OperatingSystem.IsLinux() ? "/tmp/opencode" : Path.GetTempPath(), "dll-manager-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); + try { + var release = Release("Chiv2-Community/UnchainedPlugin") with { DllFileName = "UnchainedPlugin.dll" }; + var packagePath = Path.Combine(root, "dll_db", "packages", "Chiv2-Community"); + Directory.CreateDirectory(packagePath); + File.WriteAllText(Path.Combine(packagePath, "UnchainedPlugin.json"), JsonHelpers.Serialize(Package(release))); + var registry = new AggregateModRegistry(new LocalModRegistry(root)); + var mods = new ModManager(registry, new PakDir(root, []), []); + var manager = new DllManager(registry, mods, root); + var options = new LaunchOptions([], Option.None, "", false, false, Option.None, Option.None); + var plugin = Path.Combine(root, "UnchainedPlugin.dll"); + await File.WriteAllBytesAsync(plugin, [1, 2, 3]); + Assert.True((await manager.PrepareLaunch(options)).IsSome); + Assert.Contains(release.Hash, await File.ReadAllTextAsync(Path.Combine(root, "dll_manager.json"))); + await File.WriteAllBytesAsync(plugin, [3, 2, 1]); + Assert.False(await DllManager.MatchesFileAsync(plugin, release)); + Assert.False(manager.Inject(new System.Diagnostics.Process())); + await Assert.ThrowsAsync(() => manager.PrepareLaunch(options)); + Assert.False(manager.Inject(new System.Diagnostics.Process())); + Assert.Equal(new byte[] { 3, 2, 1 }, await File.ReadAllBytesAsync(plugin)); + } + finally { Directory.Delete(root, recursive: true); } + } + + private sealed class DownloadHandler(byte[] bytes) : HttpMessageHandler { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) => + Task.FromResult(new HttpResponseMessage(System.Net.HttpStatusCode.OK) { Content = new ByteArrayContent(bytes) }); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task PakDependenciesAreEnabledOrRejectedWhenExistingSelectionConflicts(bool conflict) { + var root = Path.Combine(OperatingSystem.IsLinux() ? "/tmp/opencode" : Path.GetTempPath(), "dll-pak-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); + try { + var registryRoot = Path.Combine(root, "registry"); + var plugins = Path.Combine(root, "plugins"); + Directory.CreateDirectory(plugins); + var release = Release("Chiv2-Community/UnchainedPlugin") with { DllFileName = "UnchainedPlugin.dll" }; + release = release with { Info = release.Info with { ModDependencies = [new Dependency("https://github.com/org/mod", "<2.0.0")] } }; + var packagePath = Path.Combine(registryRoot, "dll_db", "packages", "Chiv2-Community"); + Directory.CreateDirectory(packagePath); + File.WriteAllText(Path.Combine(packagePath, "UnchainedPlugin.json"), JsonHelpers.Serialize(Package(release))); + var info = new ModInfo("https://github.com/org/mod", "test", "test", null, null, [], ["test"], []); + var pakReleases = new[] { "v1.0.0", "v2.0.0" }.Select(tag => new Release(tag, "hash", "mod.pak", DateTime.UtcNow, + info, new UnchainedLauncher.UnrealModScanner.JsonModels.AssetCollections([], [], [], [], []), null)).ToList(); + var metadata = Path.Combine(registryRoot, "org", "mod"); + Directory.CreateDirectory(metadata); + File.WriteAllText(Path.Combine(metadata, "mod.json"), JsonHelpers.Serialize(new Mod(info, pakReleases))); + await File.WriteAllBytesAsync(Path.Combine(plugins, "UnchainedPlugin.dll"), [1, 2, 3]); + var registry = new AggregateModRegistry(new LocalModRegistry(registryRoot)); + var mods = new ModManager(registry, new PakDir(root, []), conflict ? [new ReleaseCoordinates("org", "mod", "v2.0.0")] : []); + var manager = new DllManager(registry, mods, plugins); + var options = new LaunchOptions([], Option.None, "", false, false, Option.None, Option.None); + if (conflict) await Assert.ThrowsAsync(() => manager.PrepareLaunch(options)); + else { + Assert.True((await manager.PrepareLaunch(options)).IsSome); + Assert.Contains(new ReleaseCoordinates("org", "mod", "v1.0.0"), mods.EnabledModReleaseCoordinates); + Assert.True((await manager.PrepareLaunch(options)).IsSome); // Already enabled is not an error. + } + } + finally { Directory.Delete(root, recursive: true); } + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task DownloadsAreVerifiedBeforePublicationAndUnknownRootFilesArePreserved(bool valid) { + var root = Path.Combine(OperatingSystem.IsLinux() ? "/tmp/opencode" : Path.GetTempPath(), "dll-download-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); + try { + var release = Release("Chiv2-Community/UnchainedPlugin") with { DllFileName = "UnchainedPlugin.dll" }; + var packagePath = Path.Combine(root, "dll_db", "packages", "Chiv2-Community"); + Directory.CreateDirectory(packagePath); + File.WriteAllText(Path.Combine(packagePath, "UnchainedPlugin.json"), JsonHelpers.Serialize(Package(release))); + var legacy = Path.Combine(root, "UnchainedPlugin.dll"); + await File.WriteAllBytesAsync(legacy, [9, 9, 9]); + var registry = new AggregateModRegistry(new LocalModRegistry(root)); + var mods = new ModManager(registry, new PakDir(root, []), []); + using var client = new HttpClient(new DownloadHandler(valid ? [1, 2, 3] : [3, 2, 1])); + var manager = new DllManager(registry, mods, root, client: client); + var options = new LaunchOptions([], Option.None, "", true, false, Option.None, Option.None); + var destination = Path.Combine(root, ".dll-managed", release.Hash.ToLowerInvariant(), release.DllFileName); + if (valid) { + Assert.True((await manager.PrepareLaunch(options)).IsSome); + Assert.True(await DllManager.MatchesFileAsync(destination, release)); + Assert.True(File.Exists(Path.Combine(root, "dll_manager.json"))); + } + else { + await Assert.ThrowsAsync(() => manager.PrepareLaunch(options)); + Assert.False(File.Exists(destination)); + Assert.False(File.Exists(Path.Combine(root, "dll_manager.json"))); + Assert.False(manager.Inject(new System.Diagnostics.Process())); + } + Assert.Equal(new byte[] { 9, 9, 9 }, await File.ReadAllBytesAsync(legacy)); + Assert.Empty(Directory.EnumerateFiles(root, ".dll-download-*")); + } + finally { Directory.Delete(root, recursive: true); } + } +} diff --git a/UnchainedLauncher.CLI.Tests/DllQueryAndLoadingTests.cs b/UnchainedLauncher.CLI.Tests/DllQueryAndLoadingTests.cs new file mode 100644 index 00000000..a35f339b --- /dev/null +++ b/UnchainedLauncher.CLI.Tests/DllQueryAndLoadingTests.cs @@ -0,0 +1,172 @@ +using System.CommandLine; +using UnchainedLauncher.Core.Services.Mods.Registry; +using UnchainedLauncher.Core.Services.Processes; +using Xunit; + +namespace UnchainedLauncher.CLI.Tests; + +public sealed class DllQueryAndLoadingTests : IDisposable { + private readonly string _directory = Path.Combine(OperatingSystem.IsLinux() ? "/tmp/opencode" : Path.GetTempPath(), "dll-query-" + Guid.NewGuid().ToString("N")); + public DllQueryAndLoadingTests() => Directory.CreateDirectory(_directory); + public void Dispose() => Directory.Delete(_directory, recursive: true); + + [Theory] + [InlineData("client")] + [InlineData("server")] + public void UnsafePermissionIsExplicitAndHiddenFromGeneratedHelp(string command) { + string[] args = [command, "--chivalry2-path", ".", "--platform", "Steam"]; + Assert.False(CliLaunchOptions.Parse(args).Shared.UnsafeLoadAllDlls); + Assert.True(CliLaunchOptions.Parse([.. args, UnsafeDllLoading.FlagName]).Shared.UnsafeLoadAllDlls); + Assert.False(CliLaunchOptions.Parse([.. args, "--", UnsafeDllLoading.FlagName]).Shared.UnsafeLoadAllDlls); + var help = CommandLineOptions.Parse([command, "--help"]); + Assert.DoesNotContain(UnsafeDllLoading.FlagName, help.HelpText); + } + + [Fact] + public void UnsafePermissionSurvivesAuthoritativeYamlOnlyWhenExplicitlyOnCommandLine() { + var path = Path.Combine(_directory, "launch.yaml"); + File.WriteAllText(path, "chiv2-path: .\nplatform: Steam\n"); + Assert.False(CliLaunchOptions.Parse(["client", "--config-path", path]).Shared.UnsafeLoadAllDlls); + Assert.True(CliLaunchOptions.Parse(["client", "--config-path", path, UnsafeDllLoading.FlagName]).Shared.UnsafeLoadAllDlls); + File.AppendAllText(path, "unsafe-very-dangerous-load-all-dlls: true\n"); + Assert.Throws(() => CliLaunchOptions.Parse(["client", "--config-path", path])); + } + + [Theory] + [InlineData("--unsafe-very-dangerous-load-all-dlls=true")] + [InlineData("--unsafe-very-dangerous-load-all-dlls=false")] + public void PermissionIsAValueLessFlagEvenWhenYamlIgnoresOtherArguments(string flag) { + var path = Path.Combine(_directory, "launch.yaml"); + File.WriteAllText(path, "chiv2-path: .\nplatform: Steam\n"); + Assert.Throws(() => CliLaunchOptions.Parse(["server", "--config-path", path, flag])); + Assert.Throws(() => UnsafeDllLoading.ParseGuiArguments([flag])); + } + + [Fact] + public void DuplicatePermissionAndQueryPermissionAreRejected() { + Assert.Throws(() => CliLaunchOptions.Parse(["client", "--chivalry2-path", ".", "--platform", "Steam", UnsafeDllLoading.FlagName, UnsafeDllLoading.FlagName])); + Assert.Throws(() => UnsafeDllLoading.ParseGuiArguments([UnsafeDllLoading.FlagName, UnsafeDllLoading.FlagName])); + Assert.Throws(() => CommandLineOptions.Parse(["dll-query", "list", UnsafeDllLoading.FlagName])); + } + + [Fact] + public void GuiPermissionIsRemovedFromGameArgumentsAndDoesNotConsumeAuthOrSeparatorTail() { + string[] args = ["--startunchained", "-AUTH_PASSWORD=secret with spaces", UnsafeDllLoading.FlagName, "--", UnsafeDllLoading.FlagName]; + var parsed = UnsafeDllLoading.ParseGuiArguments(args); + Assert.True(parsed.Enabled); + Assert.Equal(new[] { "--startunchained", "-AUTH_PASSWORD=secret with spaces", "--", UnsafeDllLoading.FlagName }, parsed.RemainingArguments); + var tailOnly = UnsafeDllLoading.ParseGuiArguments(["--", UnsafeDllLoading.FlagName]); + Assert.False(tailOnly.Enabled); + Assert.Equal(new[] { "--", UnsafeDllLoading.FlagName }, tailOnly.RemainingArguments); + } + + [Fact] + public void GuiUnsafeOptionIsHiddenAndResponseFilesCannotGrantPermission() { + var root = new RootCommand(); + var flag = UnsafeDllLoading.CreateOption(); + root.Options.Add(flag); + Assert.True(flag.Hidden); + Assert.False(UnsafeDllLoading.ParseGuiArguments(["@not-a-permission-file", "--startunchained"]).Enabled); + Assert.False(UnsafeDllLoading.ParseGuiArguments(["-AUTH_PASSWORD=" + UnsafeDllLoading.FlagName]).Enabled); + } + + [Fact] + public void DefaultInjectionSelectsOnlyRequiredPluginAndUnsafeSelectsAllRecursiveDlls() { + var nested = Path.Combine(_directory, "nested"); + Directory.CreateDirectory(nested); + foreach (var file in new[] { "UnchainedPlugin.dll", "Unexpected.dll", "not-a-dll.txt", "nested/Other.DLL" }) + File.WriteAllText(Path.Combine(_directory, file), "not executed"); + Assert.Equal(new[] { Path.Combine(_directory, "UnchainedPlugin.dll") }, new DllInjector(_directory).GetDllPaths()); + Assert.Equal(3, new DllInjector(_directory, unsafeLoadAllDlls: true).GetDllPaths().Count()); + Assert.Empty(new DllInjector(Path.Combine(_directory, "missing")).GetDllPaths()); + } + + private const string Info = """ + {"repo_url":"https://github.com/org/plugin","name":"Example plugin","description":"Native plugin","authors":["Author"],"plugin_dependencies":[{"repo_url":"https://github.com/org/native","version":">=1.0.0"}],"mod_dependencies":[{"repo_url":"https://github.com/org/pak","version":"^1.0.0"}]} + """; + + private LocalModRegistry Registry() { + var packages = Path.Combine(_directory, "dll_db", "packages", "org"); + Directory.CreateDirectory(packages); + string Release(string tag, int day) => $$""" + {"tag":"{{tag}}","hash":"{{new string('a', 64)}}","dll_file_name":"Example.dll","download_url":"https://example.test/Example.dll","size":12345,"release_date":"2026-01-0{{day}}T00:00:00Z","info":{{Info}},"release_notes_markdown":"Notes"} + """; + File.WriteAllText(Path.Combine(packages, "plugin.json"), $$""" + {"latest_release_info":{{Info}},"releases":[{{Release("v1.0.0", 1)}},{{Release("v2.0.0", 2)}},{{Release("v3.0.0-rc.1", 3)}}]} + """); + return new LocalModRegistry(_directory); + } + + [Theory] + [InlineData("list")] + [InlineData("get")] + [InlineData("versions")] + public void DllCommandsReuseOneParserAndHidePermissionInHelp(string operation) { + var count = 0; + var help = CommandLineOptions.Parse(["dll-query", operation, "--help"], (root, tokens, config) => { + count++; + return root.Parse(tokens, config); + }); + Assert.Equal(1, count); + Assert.True(help.Help); + Assert.DoesNotContain(UnsafeDllLoading.FlagName, help.HelpText); + Assert.Contains("--add-registry", help.HelpText); + } + + [Fact] + public async Task DllQueryReadsExistingSchemaAndShowsIndependentDependencyKinds() { + var local = Registry(); + var registry = new AggregateModRegistry(local, local); + var list = Assert.IsType(CommandLineOptions.Parse(["dll-query", "list"])); + Assert.Equal(RegistryPackageKind.Dll, list.Kind); + var output = new StringWriter(); + Assert.Equal(0, await DllQuery.RunAsync(list, registry, output, new StringWriter(), CancellationToken.None)); + Assert.Contains("org/plugin\tExample plugin\tv2.0.0", output.ToString()); + var detail = Assert.IsType(CommandLineOptions.Parse(["dll-query", "get", "org/plugin", "--version", "v1.0.0"])); + output = new StringWriter(); + Assert.Equal(0, await DllQuery.RunAsync(detail, registry, output, new StringWriter(), CancellationToken.None)); + foreach (var expected in new[] { "Version: v1.0.0", "Target: shared", "DLL file: Example.dll", "Hash (SHA-256): " + new string('a', 64), "Size: 12345 bytes", "org/native: >=1.0.0", "org/pak: ^1.0.0", "Release notes: Notes" }) + Assert.Contains(expected, output.ToString()); + var versions = Assert.IsType(CommandLineOptions.Parse(["dll-query", "versions", "org/plugin"])); + output = new StringWriter(); + Assert.Equal(0, await DllQuery.RunAsync(versions, registry, output, new StringWriter(), CancellationToken.None)); + Assert.Contains("v3.0.0-rc.1", output.ToString()); + Assert.Equal(3, (await DllCatalog.ReadAsync(registry, CancellationToken.None)).Packages.Single().Releases.Count); + } + + [Fact] + public async Task MissingAndMalformedDllMetadataAreReportedWithoutLoadingAnything() { + var registry = new AggregateModRegistry(Registry()); + var query = Assert.IsType(CommandLineOptions.Parse(["dll-query", "get", "org/missing"])); + Assert.Equal(1, await DllQuery.RunAsync(query, registry, new StringWriter(), new StringWriter(), CancellationToken.None)); + File.WriteAllText(Path.Combine(_directory, "dll_db", "packages", "org", "plugin.json"), "{}"); + var catalog = await DllCatalog.ReadAsync(registry, CancellationToken.None); + Assert.Empty(catalog.Packages); + Assert.Single(catalog.Errors); + } + + [Fact] + public void DllQueryUsesConfiguredRegistriesAndQueryYamlCannotGrantUnsafePermission() { + Registry(); + var path = Path.Combine(_directory, "query.yaml"); + File.WriteAllText(path, "mod-setup: {registries: ['local:.']}\n"); + var query = Assert.IsType(CommandLineOptions.Parse(["dll-query", "get", "org/plugin", "--config-path", path, "--version", "v1.0.0"])); + Assert.Equal(RegistryPackageKind.Dll, query.Kind); + Assert.Equal(_directory, Assert.Single(query.RegistryOptions.Registries).Location.TrimEnd(Path.DirectorySeparatorChar)); + Assert.Equal("v1.0.0", query.Version); + Assert.Equal(2, query.RegistryOptions.CreateRegistry().ModRegistries.Count); + File.AppendAllText(path, "unsafe-very-dangerous-load-all-dlls: true\n"); + Assert.Throws(() => CommandLineOptions.Parse(["dll-query", "list", "--config-path", path])); + } + + [Theory] + [InlineData("\"latest_release_info\":null,\"releases\":[]")] + [InlineData("\"latest_release_info\":{},\"releases\":null")] + public async Task InvalidNullMetadataIsReportedAsCatalogError(string body) { + var registry = new AggregateModRegistry(Registry()); + File.WriteAllText(Path.Combine(_directory, "dll_db", "packages", "org", "plugin.json"), "{" + body + "}"); + var catalog = await DllCatalog.ReadAsync(registry, CancellationToken.None); + Assert.Single(catalog.Errors); + Assert.Empty(catalog.Packages); + } +} diff --git a/UnchainedLauncher.CLI.Tests/ModQueryTests.cs b/UnchainedLauncher.CLI.Tests/ModQueryTests.cs new file mode 100644 index 00000000..5c68ec4e --- /dev/null +++ b/UnchainedLauncher.CLI.Tests/ModQueryTests.cs @@ -0,0 +1,208 @@ +using UnchainedLauncher.Core.JsonModels.ModMetadata; +using UnchainedLauncher.Core.Services.Mods.Registry; +using UnchainedLauncher.Core.Utilities; +using UnchainedLauncher.UnrealModScanner.JsonModels; +using Xunit; + +namespace UnchainedLauncher.CLI.Tests; + +public sealed class ModQueryTests : IDisposable { + private readonly string _directory = Path.Combine(OperatingSystem.IsLinux() ? "/tmp/opencode" : Path.GetTempPath(), "mod-query-" + Guid.NewGuid().ToString("N")); + public ModQueryTests() => Directory.CreateDirectory(_directory); + public void Dispose() => Directory.Delete(_directory, recursive: true); + + private ModQueryOptions Parse(params string[] args) => Assert.IsType(CommandLineOptions.Parse(["mod-query", .. args])); + + [Fact] + public void QueriesNeedNoGameInstallationAndAlwaysIncludeOfficialRegistry() { + var query = Parse("list"); + var official = Assert.IsType(Assert.Single(query.RegistryOptions.CreateRegistry().ModRegistries)); + Assert.Equal("Chiv2-Community", official.Organization); + Assert.Equal("C2ModRegistry", official.RepoName); + } + + [Fact] + public void QueryParsingUsesOneLibraryParseAndSupportsRepeatedRegistryOptions() { + var calls = 0; + var args = new List { "mod-query", "get", "org/mod", "--version=v1.0.0" }; + for (var i = 0; i < 12; i++) args.Add($"--add-registry=github:owner/repo{i}"); + var query = Assert.IsType(CommandLineOptions.Parse(args.ToArray(), (root, tokens, config) => { + calls++; + return root.Parse(tokens, config); + })); + Assert.Equal(1, calls); + Assert.Equal("v1.0.0", query.Version); + Assert.Equal("org", query.Mod!.Org); + Assert.Equal(13, query.RegistryOptions.CreateRegistry().ModRegistries.Count); + } + + [Fact] + public void RegistryYamlUsesRelativePathsAndIgnoresNonQuerySettings() { + var local = Path.Combine(_directory, "local"); + Directory.CreateDirectory(local); + var path = Path.Combine(_directory, "launch.yaml"); + File.WriteAllText(path, """ + platform: invalid + chiv2-path: null + mod-setup: + registries: ['local:local'] + mods: {ignored: invalid-selection} + dlls: not-a-list + server: {name: one, name: two} + client: not-a-mapping + """); + var query = Parse("get", "org/mod", "--config-path", path, "--version", "v1.0.0", "--add-registry=github:ignored/repo"); + Assert.Equal("v1.0.0", query.Version); + Assert.Equal(local, Assert.Single(query.RegistryOptions.Registries).Location); + Assert.Equal(2, query.RegistryOptions.CreateRegistry().ModRegistries.Count); + File.WriteAllText(path, "mod-setup: {registries: ['local:local']}\nmod-setup: {}\n"); + Assert.Throws(() => Parse("list", "--config-path", path)); + } + + [Fact] + public void CoreRegistryConfigurationAndQueryOptionsWorkBeforeSubcommands() { + Directory.CreateDirectory(Path.Combine(_directory, "local")); + var path = Path.Combine(_directory, "registries.json"); + File.WriteAllText(path, JsonHelpers.Serialize(new AggregateModRegistryMetadata([ + new LocalModRegistryMetadata("local"), new GithubModRegistryMetadata("owner", "repo") + ]))); + var query = Parse("--add-registry", "config:" + path, "versions", "org/mod"); + var registry = query.RegistryOptions.CreateRegistry(); + Assert.Equal(3, registry.ModRegistries.Count); + Assert.Equal(Path.Combine(_directory, "local"), Assert.Single(registry.ModRegistries.OfType()).RegistryPath); + } + + [Fact] + public async Task QueriesHonorCancellation() { + var registry = CreateRegistry(); + using var stop = new CancellationTokenSource(); + stop.Cancel(); + await Assert.ThrowsAnyAsync(() => ModQuery.RunAsync(Parse("get", "org/mod"), registry, new StringWriter(), new StringWriter(), stop.Token)); + } + + [Theory] + [InlineData("mod-query")] + [InlineData("mod-query", "get")] + [InlineData("mod-query", "versions", "bad-id")] + [InlineData("mod-query", "get", "org/mod:v1")] + [InlineData("mod-query", "get", "org/mod", "--version=")] + [InlineData("mod-query", "get", "org/mod", "--version=a", "--version=b")] + [InlineData("mod-query", "list", "--version=v1")] + [InlineData("mod-query", "list", "--config-path")] + [InlineData("mod-query", "list", "--config-path=a", "--config-path=b")] + [InlineData("mod-query", "list", "--", "game-args")] + public void InvalidQueryInputIsRejected(params string[] args) => Assert.Throws(() => CommandLineOptions.Parse(args)); + + [Theory] + [InlineData("list")] + [InlineData("get")] + [InlineData("versions")] + public async Task HelpDoesNotAccessRegistries(string operation) { + var result = CommandLineOptions.Parse(["mod-query", operation, "--help"]); + Assert.True(result.Help); + Assert.Contains("--add-registry", result.HelpText); + Assert.Contains("--config-path", result.HelpText); + Assert.Equal(0, await Program.Main(["mod-query", operation, "--help"])); + } + + private LocalModRegistry CreateRegistry(bool stable = true) { + var info = new ModInfo("https://github.com/org/mod", "Display name", "Description", "https://example.test", null, [], ["Author"], + [new Dependency("https://github.com/org/dependency", ">=1.0.0")]); + var manifest = new AssetCollections([], [], [new MapDto("/Game/Maps/Test", "map-hash", null, null, "FFA", "Test Map", null, null, null, null, null)], [], []); + var versions = stable ? new[] { "v1.0.0", "v2.0.0", "v3.0.0-beta.1" } : new[] { "v3.0.0-beta.1" }; + var releases = versions.Select((tag, index) => new Release(tag, "sha512-" + tag, "mod.pak", new DateTime(2026, 1, index + 1, 0, 0, 0, DateTimeKind.Utc), info, manifest, "Release notes")).ToList(); + var metadataPath = Path.Combine(_directory, "org", "mod"); + Directory.CreateDirectory(metadataPath); + File.WriteAllText(Path.Combine(metadataPath, "mod.json"), JsonHelpers.Serialize(new Mod(info, releases))); + foreach (var release in releases) { + var pakDirectory = Path.Combine(metadataPath, release.Tag); + Directory.CreateDirectory(pakDirectory); + File.WriteAllBytes(Path.Combine(pakDirectory, release.PakFileName), new byte[123]); + } + return new LocalModRegistry(_directory); + } + + [Fact] + public async Task ListShowsIdNameAndLatestStableWithoutChangingFiles() { + var registry = CreateRegistry(); + var files = Directory.GetFiles(_directory, "*", SearchOption.AllDirectories).Order().ToArray(); + var output = new StringWriter(); + Assert.Equal(0, await ModQuery.RunAsync(Parse("list"), registry, output, new StringWriter(), CancellationToken.None)); + Assert.Contains("org/mod\tDisplay name\tv2.0.0", output.ToString()); + Assert.Equal(files, Directory.GetFiles(_directory, "*", SearchOption.AllDirectories).Order()); + } + + [Theory] + [InlineData(null, "v2.0.0")] + [InlineData("v1.0.0", "v1.0.0")] + [InlineData("v3.0.0-beta.1", "v3.0.0-beta.1")] + public async Task GetShowsSelectedReleaseAndManifestAndSizeWithoutDownloading(string? version, string expected) { + var registry = new AggregateModRegistry(CreateRegistry()); + var query = version is null ? Parse("get", "org/mod") : Parse("get", "org/mod", "--version", version); + var output = new StringWriter(); + Assert.Equal(0, await ModQuery.RunAsync(query, registry, output, new StringWriter(), CancellationToken.None)); + var text = output.ToString(); + Assert.Contains("Version: " + expected, text); + Assert.Contains("Pak hash (SHA-512): sha512-" + expected, text); + Assert.Contains("Pak file: mod.pak", text); + Assert.Contains("Size: 123 bytes", text); + Assert.Contains("Dependencies (1):", text); + Assert.Contains("org/dependency: >=1.0.0", text); + Assert.Contains("Maps (1):", text); + Assert.Contains("Test Map | /Game/Maps/Test", text); + Assert.Contains("Author", text); + Assert.Contains("Release notes", text); + } + + [Fact] + public async Task VersionsIncludePrereleasesAndUnavailableIdsFailClearly() { + var registry = CreateRegistry(); + var output = new StringWriter(); + Assert.Equal(0, await ModQuery.RunAsync(Parse("versions", "org/mod"), registry, output, new StringWriter(), CancellationToken.None)); + foreach (var version in new[] { "v1.0.0", "v2.0.0", "v3.0.0-beta.1" }) Assert.Contains(version, output.ToString()); + Assert.Equal(1, await ModQuery.RunAsync(Parse("get", "org/mod", "--version", "missing"), registry, new StringWriter(), new StringWriter(), CancellationToken.None)); + Assert.Equal(1, await ModQuery.RunAsync(Parse("get", "org/unknown"), registry, new StringWriter(), new StringWriter(), CancellationToken.None)); + } + + [Fact] + public async Task PrereleaseOnlyModCanBeQueriedAndMissingPakSizeRemainsUnknown() { + var registry = CreateRegistry(stable: false); + File.Delete(Path.Combine(_directory, "org", "mod", "v3.0.0-beta.1", "mod.pak")); + var output = new StringWriter(); + Assert.Equal(0, await ModQuery.RunAsync(Parse("get", "org/mod"), registry, output, new StringWriter(), CancellationToken.None)); + Assert.Contains("Version: v3.0.0-beta.1", output.ToString()); + Assert.Contains("Size: unknown", output.ToString()); + } + + [Fact] + public async Task PartialCatalogKeepsRowsButReportsFailureAndDuplicateModsAreMerged() { + var local = CreateRegistry(); + var registry = new AggregateModRegistry(local, local, new UnavailableRegistry()); + var output = new StringWriter(); + var error = new StringWriter(); + Assert.Equal(1, await ModQuery.RunAsync(Parse("list"), registry, output, error, CancellationToken.None)); + Assert.Single(output.ToString().Split('\n').Where(line => line.StartsWith("org/mod\t"))); + Assert.Contains("registry unavailable", error.ToString()); + } + + [Fact] + public async Task EmptyReleaseCatalogIsHandledWithoutInventingAVersion() { + var registry = CreateRegistry(); + var path = Path.Combine(_directory, "org", "mod", "mod.json"); + var mod = await registry.GetMod(new ModIdentifier("org", "mod")).Match(value => value, _ => throw new Exception()); + File.WriteAllText(path, JsonHelpers.Serialize(mod with { Releases = [] })); + var output = new StringWriter(); + Assert.Equal(0, await ModQuery.RunAsync(Parse("list"), registry, output, new StringWriter(), CancellationToken.None)); + Assert.Contains("org/mod\tDisplay name\tnone", output.ToString()); + Assert.Equal(1, await ModQuery.RunAsync(Parse("get", "org/mod"), registry, new StringWriter(), new StringWriter(), CancellationToken.None)); + } + + private sealed class UnavailableRegistry : IModRegistry { + public string Name => "unavailable"; + private static RegistryMetadataException Error => RegistryMetadataException.PackageListRetrieval("registry unavailable", LanguageExt.Prelude.None); + public Task GetAllMods() => Task.FromResult(new GetAllModsResult([Error], [])); + public LanguageExt.EitherAsync GetMod(ModIdentifier id) => LanguageExt.Prelude.LeftAsync(Error); + public LanguageExt.EitherAsync GetModRelease(ReleaseCoordinates coordinate) => LanguageExt.Prelude.LeftAsync(Error); + public LanguageExt.EitherAsync DownloadPak(ReleaseCoordinates coordinate, string output) => throw new InvalidOperationException("Queries must never download paks."); + } +} \ No newline at end of file diff --git a/UnchainedLauncher.CLI.Tests/YamlConfigurationTests.cs b/UnchainedLauncher.CLI.Tests/YamlConfigurationTests.cs index 6152f1bd..4ba70e6c 100644 --- a/UnchainedLauncher.CLI.Tests/YamlConfigurationTests.cs +++ b/UnchainedLauncher.CLI.Tests/YamlConfigurationTests.cs @@ -87,9 +87,9 @@ public void OneLibraryParseControlsBothOrdinaryAndYamlLaunches(string command, b calls++; Assert.Null(configuration.ResponseFileTokenReplacer); Assert.Equal(args, tokens); - Assert.Equal(new[] { "server", "client" }, root.Subcommands.Select(subcommand => subcommand.Name)); + Assert.Equal(new[] { "server", "client", "mod-query", "dll-query" }, root.Subcommands.Select(subcommand => subcommand.Name)); Assert.Single(root.Options.Where(option => option.Name == "--config-path")); - foreach (var subcommand in root.Subcommands) { + foreach (var subcommand in root.Subcommands.Where(command => command.Name is "client" or "server")) { Assert.Single(subcommand.Options.Where(option => option.Name == "--platform")); Assert.Single(subcommand.Options.Where(option => option.Name == "--chivalry2-path")); } @@ -97,7 +97,7 @@ public void OneLibraryParseControlsBothOrdinaryAndYamlLaunches(string command, b }); Assert.Equal(1, calls); Assert.Equal(command == "server", options is ServerOptions); - Assert.Equal(yaml ? [] : new[] { "@game-file", "--config-path", "not-config" }, options.GameArguments); + Assert.Equal(yaml ? [] : new[] { "@game-file", "--config-path", "not-config" }, Assert.IsAssignableFrom(options).GameArguments); } [Fact] diff --git a/UnchainedLauncher.CLI/CliCommandOptions.cs b/UnchainedLauncher.CLI/CliCommandOptions.cs new file mode 100644 index 00000000..974604c3 --- /dev/null +++ b/UnchainedLauncher.CLI/CliCommandOptions.cs @@ -0,0 +1,6 @@ +namespace UnchainedLauncher.CLI; + +public abstract class CliCommandOptions { + public bool Help { get; protected init; } + public string HelpText { get; protected init; } = ""; +} diff --git a/UnchainedLauncher.CLI/CliLaunchOptions.cs b/UnchainedLauncher.CLI/CliLaunchOptions.cs index e1eb2938..214af85e 100644 --- a/UnchainedLauncher.CLI/CliLaunchOptions.cs +++ b/UnchainedLauncher.CLI/CliLaunchOptions.cs @@ -15,8 +15,11 @@ public sealed class SharedLaunchOptions { public InstallationType Platform { get; } public string Backend { get; } public bool UpdatePlugin { get; } - public bool AllowUnstablePlugin { get; } - public IReadOnlyList Registries { get; } + public bool AllowUnstablePlugin { get; } + public bool UnsafeLoadAllDlls { get; private set; } + internal void EnableUnsafeDllLoading() => UnsafeLoadAllDlls = true; + public RegistryOptions RegistryOptions { get; } + public IReadOnlyList Registries => RegistryOptions.Registries; public IReadOnlyList Mods { get; } public IReadOnlyList Dlls { get; } public IReadOnlyList GameArguments { get; } @@ -30,7 +33,7 @@ internal SharedLaunchOptions(LaunchConfiguration configuration, string directory AllowUnstablePlugin = configuration.AllowUnstablePlugin; GameArguments = Entries(configuration.GameArgs, allowEmpty: true).ToArray(); var setup = configuration.ModSetup ?? new ModSetupConfiguration(); - Registries = Entries(setup.Registries).Select(value => RegistrySpec.Parse(value, directory)).ToArray(); + RegistryOptions = new RegistryOptions(setup.Registries, directory); Mods = Entries(setup.Mods).Select(ParseCoordinates).ToArray(); Dlls = Entries(setup.Dlls).Select(ParseCoordinates).ToArray(); if (!Regex.IsMatch(SavedDirSuffix, @"\A[A-Za-z0-9_-]+\z")) @@ -52,12 +55,10 @@ public static ReleaseCoordinates ParseCoordinates(string value) { } } -public abstract class CliLaunchOptions { +public abstract class CliLaunchOptions : CliCommandOptions { private readonly SharedLaunchOptions? _shared; public SharedLaunchOptions Shared => _shared ?? throw new InvalidOperationException("Help is not a launch request."); public IReadOnlyList GameArguments { get; } - public bool Help { get; protected init; } - public string HelpText { get; protected init; } = ""; private protected CliLaunchOptions(SharedLaunchOptions shared, IEnumerable gameArguments) { _shared = shared; @@ -70,7 +71,8 @@ private protected CliLaunchOptions(string helpText) { GameArguments = []; } - public static CliLaunchOptions Parse(string[] args) => CommandLineOptions.Parse(args); + public static CliLaunchOptions Parse(string[] args) => CommandLineOptions.Parse(args) as CliLaunchOptions + ?? throw new ArgumentException("Expected a client or server launch command."); internal static CliLaunchOptions FromConfiguration(LaunchConfiguration configuration, string directory) { var shared = new SharedLaunchOptions(configuration, directory); @@ -143,4 +145,4 @@ public LanguageExt.Option ToDiscordOptions() { Settings.DesyncPatch, Settings.UseBackendBanlist, Settings.CensorMode); public override LaunchOptions ToLaunchOptions() => BuildLaunchOptions(["TBL", "-server"], Some(ToServerLaunchOptions())); -} \ No newline at end of file +} diff --git a/UnchainedLauncher.CLI/CommandLineOptions.cs b/UnchainedLauncher.CLI/CommandLineOptions.cs index 77224454..e123cdf8 100644 --- a/UnchainedLauncher.CLI/CommandLineOptions.cs +++ b/UnchainedLauncher.CLI/CommandLineOptions.cs @@ -1,33 +1,42 @@ using System.CommandLine; using System.CommandLine.Help; -using UnchainedLauncher.Core.JsonModels; +using UnchainedLauncher.Core.JsonModels; +using UnchainedLauncher.Core.Services.Processes; namespace UnchainedLauncher.CLI; internal static class CommandLineOptions { - public static CliLaunchOptions Parse(string[] args) => Parse(args, static (root, tokens, configuration) => root.Parse(tokens, configuration)); + public static CliCommandOptions Parse(string[] args) => Parse(args, static (root, tokens, configuration) => root.Parse(tokens, configuration)); // The delegate is the single library-parse boundary, also exercised by tests. - internal static CliLaunchOptions Parse(string[] args, Func parse) { + internal static CliCommandOptions Parse(string[] args, Func parse) { var separator = Array.IndexOf(args, "--"); var launcherArgs = separator < 0 ? args : args[..separator]; var gameArgs = separator < 0 ? [] : args[(separator + 1)..]; - var root = new RootCommand("Unchained client/server launcher. Windows is required for launching and DLL injection."); + var root = new RootCommand("Unchained client/server launcher and mod registry queries. Only launching and DLL injection require Windows."); var config = new Option("--config-path") { - Description = "YAML replaces other options; the subcommand selects its schema.", + Description = "Launch YAML overrides launch options; mod-query reads its registry settings.", Arity = ArgumentArity.ExactlyOne, Recursive = true }; - root.Options.Add(config); + root.Options.Add(config); + var unsafeDlls = UnsafeDllLoading.CreateOption(recursive: true); + root.Options.Add(unsafeDlls); var server = new Definition(client: false); var client = new Definition(client: true); root.Subcommands.Add(server.Command); - root.Subcommands.Add(client.Command); + root.Subcommands.Add(client.Command); + var queries = new ModQueryDefinition(); + root.Subcommands.Add(queries.Command); + var dllQueries = new ModQueryDefinition(RegistryPackageKind.Dll); + root.Subcommands.Add(dllQueries.Command); // Never interpret @values as file reads, including ignored YAML overrides. // Response-file expansion is deliberately disabled for all launcher input. - var parsed = parse(root, launcherArgs, new ParserConfiguration { ResponseFileTokenReplacer = null }); + var parsed = parse(root, launcherArgs, new ParserConfiguration { ResponseFileTokenReplacer = null }); + var loadAllDlls = UnsafeDllLoading.Read(parsed, unsafeDlls, launcherArgs); var definition = parsed.CommandResult.Command == server.Command ? server : client; - if (parsed.GetResult(config) is { } configResult) { + var isQuery = queries.Contains(parsed.CommandResult.Command) || dllQueries.Contains(parsed.CommandResult.Command); + if (!isQuery && parsed.GetResult(config) is { } configResult) { if (parsed.CommandResult.Command == root) throw new ArgumentException("Expected the 'server' or 'client' subcommand."); // Help actions skip library validators. Check the parsed selector itself // so --help cannot bypass its singleton/non-empty contract either. @@ -37,7 +46,9 @@ internal static CliLaunchOptions Parse(string[] args, Func error.SymbolResult == configResult); ThrowErrors(errors.Select(error => error.Message)); - return YamlConfiguration.Load(parsed.GetValue(config)!, definition.Command == client.Command); + var configured = YamlConfiguration.Load(parsed.GetValue(config)!, definition.Command == client.Command); + if (loadAllDlls) configured.Shared.EnableUnsafeDllLoading(); + return configured; } if (args.Length == 0 || parsed.GetResult("--help") is not null) { using var output = new StringWriter(); @@ -46,9 +57,16 @@ internal static CliLaunchOptions Parse(string[] args, Func= 0) throw new ArgumentException("mod-query does not accept forwarded game arguments."); + return (dllQueries.Contains(parsed.CommandResult.Command) ? dllQueries : queries).ToOptions(parsed, config); + } if (parsed.CommandResult.Command == root) throw new ArgumentException("Expected the 'server' or 'client' subcommand."); - return definition.ToOptions(parsed, gameArgs); + var launch = definition.ToOptions(parsed, gameArgs); + if (loadAllDlls) launch.Shared.EnableUnsafeDllLoading(); + return launch; } private static void ThrowErrors(ParseResult result) { @@ -87,9 +105,10 @@ public Definition(bool client) { Flag("--no-plugin-update", "Use the existing UnchainedPlugin.dll.", value => _configuration.UpdatePlugin = !value); Flag("--allow-unstable-plugin", "Allow prerelease plugin updates.", value => _configuration.AllowUnstablePlugin = value); _configuration.ModSetup = new ModSetupConfiguration(); - Many("--add-registry", "Additional github:owner/repo, local:directory, or config:file registry; official registry is always included.", value => _configuration.ModSetup.Registries = value); + var registries = RegistryOptions.AddOption(Command); + _bindings.Add(result => _configuration.ModSetup.Registries = [.. result.GetValue(registries) ?? []]); Many("--mod", "Install organization/id:version (exact release tag).", value => _configuration.ModSetup.Mods = value); - Many("--dll", "Reserved: ALL Plugins DLLs still load; selection tracked in issue #149.", value => _configuration.ModSetup.Dlls = value); + Many("--dll", "Select a registry DLL release: organization/id:version (repeatable).", value => _configuration.ModSetup.Dlls = value); if (_configuration is ServerConfiguration serverConfig) { var settings = serverConfig.Server!; Add("--name", "Server name.", settings.Name, value => settings.Name = value); @@ -164,4 +183,4 @@ public CliLaunchOptions ToOptions(ParseResult parsed, string[] gameArgs) { return CliLaunchOptions.FromConfiguration(_configuration, Directory.GetCurrentDirectory()); } } -} \ No newline at end of file +} diff --git a/UnchainedLauncher.CLI/DllQuery.cs b/UnchainedLauncher.CLI/DllQuery.cs new file mode 100644 index 00000000..15955184 --- /dev/null +++ b/UnchainedLauncher.CLI/DllQuery.cs @@ -0,0 +1,47 @@ +using System.Globalization; +using UnchainedLauncher.Core.Services.Mods.Registry; +using static UnchainedLauncher.CLI.ModQuery; + +namespace UnchainedLauncher.CLI; + +public static class DllQuery { + public static async Task RunAsync(ModQueryOptions options, AggregateModRegistry registry, TextWriter output, TextWriter error, CancellationToken token) { + token.ThrowIfCancellationRequested(); + var catalog = await DllCatalog.ReadAsync(registry, token); + foreach (var problem in catalog.Errors) await error.WriteLineAsync(problem); + if (options.Operation == ModQueryOperation.List) { + await output.WriteLineAsync("DLL ID\tNAME\tLATEST VERSION"); + foreach (var package in catalog.Packages) + await output.WriteLineAsync($"{Id(package.LatestReleaseInfo.Identifier)}\t{Line(package.LatestReleaseInfo.Name)}\t{Line(package.LatestRelease?.Tag ?? "none")}"); + return catalog.Errors.Count == 0 ? 0 : 1; + } + var found = catalog.Packages.FirstOrDefault(package => options.Mod!.Matches(package.LatestReleaseInfo.Identifier)); + if (found is null) { + await error.WriteLineAsync($"DLL not found or registry unavailable: {Id(options.Mod!)}"); + return 1; + } + if (options.Operation == ModQueryOperation.Versions) { + await output.WriteLineAsync("VERSION\tRELEASE DATE\tTARGET"); + foreach (var release in found.Releases.OrderByDescending(release => release.Version).ThenByDescending(release => release.ReleaseDate)) + await output.WriteLineAsync($"{Line(release.Tag)}\t{release.ReleaseDate.ToString("O", CultureInfo.InvariantCulture)}\t{Line(release.Info.Target)}"); + } + else { + var selected = options.Version is null ? found.LatestRelease : found.Releases.Find(release => release.Tag == options.Version); + if (selected is null) { + await error.WriteLineAsync($"DLL release not found: {Id(options.Mod!)}:{options.Version ?? "latest"}"); + return 1; + } + await output.WriteLineAsync($"DLL ID: {Id(options.Mod!)}\nName: {Line(selected.Info.Name)}\nVersion: {Line(selected.Tag)}\nDescription: {Line(selected.Info.Description)}\nTarget: {Line(selected.Info.Target)}"); + await output.WriteLineAsync($"Authors: {string.Join(", ", selected.Info.Authors.Select(Line))}\nRepository: {Line(selected.Info.RepoUrl)}\nRelease date: {selected.ReleaseDate.ToString("O", CultureInfo.InvariantCulture)}"); + await output.WriteLineAsync($"DLL file: {Line(selected.DllFileName)}\nHash (SHA-256): {Line(selected.Hash)}\nSize: {selected.Size.ToString(CultureInfo.InvariantCulture)} bytes\nDownload URL: {Line(selected.DownloadUrl)}"); + await output.WriteLineAsync($"Plugin dependencies ({selected.Info.PluginDependencies.Count}):"); + foreach (var dependency in selected.Info.PluginDependencies) + await output.WriteLineAsync($" {Id(ModIdentifier.FromDependency(dependency))}: {Line(dependency.Version)}"); + await output.WriteLineAsync($"Mod dependencies ({selected.Info.ModDependencies.Count}):"); + foreach (var dependency in selected.Info.ModDependencies) + await output.WriteLineAsync($" {Id(ModIdentifier.FromDependency(dependency))}: {Line(dependency.Version)}"); + await output.WriteLineAsync($"Release notes: {Line(selected.ReleaseNotesMarkdown ?? "none")}"); + } + return catalog.Errors.Count == 0 ? 0 : 1; + } +} diff --git a/UnchainedLauncher.CLI/ModQuery.cs b/UnchainedLauncher.CLI/ModQuery.cs new file mode 100644 index 00000000..62851891 --- /dev/null +++ b/UnchainedLauncher.CLI/ModQuery.cs @@ -0,0 +1,61 @@ +using System.Globalization; +using UnchainedLauncher.Core.JsonModels.ModMetadata; +using UnchainedLauncher.Core.Services.Mods.Registry; + +namespace UnchainedLauncher.CLI; + +public static class ModQuery { + public static async Task RunAsync(ModQueryOptions options, IModRegistry registry, TextWriter output, TextWriter error, CancellationToken cancellationToken) { + cancellationToken.ThrowIfCancellationRequested(); + if (options.Operation == ModQueryOperation.List) { + var catalog = await registry.GetAllMods().WaitAsync(cancellationToken); + await output.WriteLineAsync("MOD ID\tNAME\tLATEST VERSION"); + foreach (var mod in catalog.Mods.OrderBy(mod => Id(ModIdentifier.FromMod(mod)), StringComparer.Ordinal)) { + var latest = Latest(mod); + await output.WriteLineAsync($"{Id(ModIdentifier.FromMod(mod))}\t{Line(mod.LatestReleaseInfo.Name)}\t{Line(latest?.Tag ?? "none")}"); + } + foreach (var problem in catalog.Errors) await error.WriteLineAsync(problem.Message); + return catalog.HasErrors ? 1 : 0; + } + var result = await registry.GetMod(options.Mod!).ToEither().WaitAsync(cancellationToken); + if (result.IsLeft) { + await error.WriteLineAsync(result.Match(_ => "", problem => problem.Message)); + return 1; + } + var found = result.Match(mod => mod, _ => throw new InvalidOperationException()); + if (options.Operation == ModQueryOperation.Versions) { + await output.WriteLineAsync("VERSION\tRELEASE DATE\tCHANNEL"); + foreach (var release in found.Releases.OrderByDescending(release => release.Version).ThenByDescending(release => release.ReleaseDate)) + await output.WriteLineAsync($"{Line(release.Tag)}\t{release.ReleaseDate.ToString("O", CultureInfo.InvariantCulture)}\t{(release.Version?.IsRelease == true ? "stable" : "prerelease/non-semver")}"); + return 0; + } + var selected = options.Version is null ? Latest(found) : found.Releases.Find(release => release.Tag == options.Version); + if (selected is null) { + await error.WriteLineAsync($"Release not found: {Id(options.Mod!)}:{options.Version ?? "latest"}"); + return 1; + } + var size = await registry.GetPakSize(selected, cancellationToken); + await output.WriteLineAsync($"Mod ID: {Id(options.Mod!)}\nName: {Line(selected.Info.Name)}\nVersion: {Line(selected.Tag)}\nDescription: {Line(selected.Info.Description)}"); + await output.WriteLineAsync($"Authors: {string.Join(", ", selected.Info.Authors.Select(Line))}\nRepository: {Line(selected.Info.RepoUrl)}\nHomepage: {Line(selected.Info.HomePage ?? "none")}\nRelease date: {selected.ReleaseDate.ToString("O", CultureInfo.InvariantCulture)}"); + await output.WriteLineAsync($"Pak file: {Line(selected.PakFileName)}\nPak hash (SHA-512): {Line(selected.ReleaseHash)}\nSize: {(size is null ? "unknown (not available from registry/file)" : size.Value.ToString(CultureInfo.InvariantCulture) + " bytes")}"); + await output.WriteLineAsync($"Dependencies ({selected.Info.Dependencies.Count}):"); + foreach (var dependency in selected.Info.Dependencies) + await output.WriteLineAsync($" {Id(ModIdentifier.FromDependency(dependency))}: {Line(dependency.Version)}"); + await output.WriteLineAsync($"Maps ({selected.Manifest.Maps.Count}):"); + foreach (var map in selected.Manifest.Maps) + await output.WriteLineAsync($" {Line(map.MapName ?? map.Path)} | {Line(map.Path)} | mode: {Line(map.GameMode ?? map.GamemodeType ?? "unknown")}"); + await output.WriteLineAsync($"Blueprints ({selected.Manifest.Blueprints.Count}):"); + foreach (var blueprint in selected.Manifest.Blueprints) + await output.WriteLineAsync($" {Line(blueprint.ModName ?? blueprint.Path)} | {Line(blueprint.ClassPath ?? blueprint.Path)} | client-side: {blueprint.bClientside?.ToString() ?? "unknown"}"); + await output.WriteLineAsync($"Markers: {selected.Manifest.Markers.Count}\nReplacements: {selected.Manifest.Replacements.Count}\nOther assets: {selected.Manifest.Arbitrary.Count}\nRelease notes: {Line(selected.ReleaseNotesMarkdown ?? "none")}"); + return 0; + } + + private static Release? Latest(Mod mod) { + var stable = mod.LatestRelease; + return stable.IsSome ? stable.IfNone(() => throw new InvalidOperationException()) + : mod.Releases.OrderByDescending(release => release.ReleaseDate).FirstOrDefault(); + } + internal static string Id(ModIdentifier id) => Line(id.Org + "/" + id.ModuleName); + internal static string Line(string value) => new(value.Select(character => char.IsControl(character) ? ' ' : character).ToArray()); +} diff --git a/UnchainedLauncher.CLI/ModQueryDefinition.cs b/UnchainedLauncher.CLI/ModQueryDefinition.cs new file mode 100644 index 00000000..989dd83a --- /dev/null +++ b/UnchainedLauncher.CLI/ModQueryDefinition.cs @@ -0,0 +1,76 @@ +using System.CommandLine; +using UnchainedLauncher.Core.Services.Mods.Registry; + +namespace UnchainedLauncher.CLI; + +public enum ModQueryOperation { List, Get, Versions } +public enum RegistryPackageKind { Mod, Dll } + +public sealed class ModQueryOptions : CliCommandOptions { + public RegistryPackageKind Kind { get; init; } + public ModQueryOperation Operation { get; init; } + public ModIdentifier? Mod { get; init; } + public string? Version { get; init; } + public required RegistryOptions RegistryOptions { get; init; } +} + +internal sealed class ModQueryDefinition { + public Command Command { get; } + private readonly RegistryPackageKind _kind; + private readonly Command _list = new("list", "List mod IDs, names, and latest versions."); + private readonly Command _get = new("get", "Show release metadata, pak information, dependencies, and manifest contents."); + private readonly Command _versions = new("versions", "List available release tags for a mod."); + private readonly Argument _getId = new("mod-id") { Description = "organization/id" }; + private readonly Argument _versionsId = new("mod-id") { Description = "organization/id" }; + private readonly Option _version = new("--version") { Description = "Exact release tag; defaults to latest stable (or newest release if none is stable).", Arity = ArgumentArity.ExactlyOne }; + private readonly Option _registries; + + public ModQueryDefinition(RegistryPackageKind kind = RegistryPackageKind.Mod) { + _kind = kind; + Command = new Command(kind == RegistryPackageKind.Dll ? "dll-query" : "mod-query", "Query configured registries without installing or launching the game."); + if (kind == RegistryPackageKind.Dll) { + _list.Description = "List DLL IDs, names, and latest versions."; + _get.Description = "Show DLL release metadata, hashes, sizes, targets, and dependencies."; + _versions.Description = "List available DLL release tags."; + } + _registries = RegistryOptions.AddOption(Command, recursive: true); + _get.Arguments.Add(_getId); + _versions.Arguments.Add(_versionsId); + _get.Options.Add(_version); + _version.Validators.Add(result => { + if (result.IdentifierTokenCount != 1 || result.Tokens.Count != 1 || string.IsNullOrWhiteSpace(result.Tokens[0].Value)) + result.AddError("Specify one non-empty --version release tag."); + }); + Command.Subcommands.Add(_list); + Command.Subcommands.Add(_get); + Command.Subcommands.Add(_versions); + } + + public bool Contains(Command command) => command == Command || command == _list || command == _get || command == _versions; + + public ModQueryOptions ToOptions(ParseResult parsed, Option config) { + if (parsed.CommandResult.Command == Command) throw new ArgumentException($"Use {Command.Name} list, get , or versions ."); + var directory = Directory.GetCurrentDirectory(); + IEnumerable? specifications = parsed.GetValue(_registries); + if (parsed.GetResult(config) is { } selector) { + if (selector.IdentifierTokenCount != 1 || selector.Tokens.Count != 1 || string.IsNullOrWhiteSpace(selector.Tokens[0].Value)) + throw new ArgumentException("Specify exactly one non-empty --config-path."); + var path = Path.GetFullPath(parsed.GetValue(config)!); + specifications = YamlConfiguration.LoadDocument(path).ModSetup?.Registries; + directory = Path.GetDirectoryName(path)!; + } + var operation = parsed.CommandResult.Command == _list ? ModQueryOperation.List + : parsed.CommandResult.Command == _get ? ModQueryOperation.Get : ModQueryOperation.Versions; + var id = operation == ModQueryOperation.List ? null : parsed.GetValue(operation == ModQueryOperation.Get ? _getId : _versionsId); + ModIdentifier? identifier = null; + if (id is not null) { + // Reuse coordinate validation; no second grammar for organization/id. + var coordinate = SharedLaunchOptions.ParseCoordinates(id + ":query"); + identifier = new ModIdentifier(coordinate.Org, coordinate.ModuleName); + } + return new ModQueryOptions { + Kind = _kind, Operation = operation, Mod = identifier, Version = parsed.GetValue(_version), + RegistryOptions = new RegistryOptions(specifications, directory) + }; + } +} diff --git a/UnchainedLauncher.CLI/Program.cs b/UnchainedLauncher.CLI/Program.cs index f7d5a8e2..59bf69e9 100644 --- a/UnchainedLauncher.CLI/Program.cs +++ b/UnchainedLauncher.CLI/Program.cs @@ -4,24 +4,33 @@ namespace UnchainedLauncher.CLI; public static class Program { public static async Task Main(string[] args) { - CliLaunchOptions options; + CliCommandOptions command; try { - options = CliLaunchOptions.Parse(args); + command = CommandLineOptions.Parse(args); } catch (ArgumentException ex) { Console.Error.WriteLine(ex.Message); Console.Error.WriteLine("Use '--help' for usage."); return 2; } - if (options.Help) { Console.Write(options.HelpText); return 0; } - if (options.Shared.Dlls.Count != 0) { - Console.Error.WriteLine("WARNING: --dll version selection is NOT implemented; ALL DLLs in TBL/Binaries/Win64/Plugins will be injected, regardless of the requested IDs/versions."); + if (command.Help) { Console.Write(command.HelpText); return 0; } + if (command is CliLaunchOptions launch && launch.Shared.Dlls.Count != 0) { + Console.Error.WriteLine("Resolving explicitly selected DLL releases from dll_db; native code will execute in the game."); Console.Error.WriteLine("Tracking: https://github.com/Chiv2-Community/UnchainedLauncher/issues/149"); } + if (command is CliLaunchOptions unsafeLaunch && unsafeLaunch.Shared.UnsafeLoadAllDlls) + Console.Error.WriteLine("DANGER: explicitly enabled injection of ALL DLLs recursively in Plugins; arbitrary native code will execute in the game."); using var stop = new CancellationTokenSource(); ConsoleCancelEventHandler handler = (_, e) => { e.Cancel = true; stop.Cancel(); }; Console.CancelKeyPress += handler; try { + if (command is ModQueryOptions query) { + var registry = query.RegistryOptions.CreateRegistry(); + return query.Kind == RegistryPackageKind.Dll + ? await DllQuery.RunAsync(query, registry, Console.Out, Console.Error, stop.Token) + : await ModQuery.RunAsync(query, registry, Console.Out, Console.Error, stop.Token); + } + var options = (CliLaunchOptions)command; using var process = await ServerLauncher.LaunchAsync(options, stop.Token); return await SuperviseAsync(options, process, stop); } @@ -57,4 +66,4 @@ internal static async Task SuperviseAsync(CliLaunchOptions options, Process } } } -} \ No newline at end of file +} diff --git a/UnchainedLauncher.CLI/README.md b/UnchainedLauncher.CLI/README.md index e65e59d2..66228f80 100644 --- a/UnchainedLauncher.CLI/README.md +++ b/UnchainedLauncher.CLI/README.md @@ -42,6 +42,73 @@ dotnet publish UnchainedLauncher.CLI -c Release -r win-x64 --self-contained true Help, parsing, and the unit tests also work on Linux. Actual game launching and DLL injection require Windows; Wine/Proton launching is not implemented. +## Mod registry queries + +Query metadata without installing mods, changing the game profile, or launching +the game. These commands work on Linux as well as Windows and require no game +installation or platform selection: + +```text +UnchainedLauncher.CLI mod-query list +UnchainedLauncher.CLI mod-query get Chiv2-Community/Unchained-Mods +UnchainedLauncher.CLI mod-query get organization/mod-id --version v1.2.3 +UnchainedLauncher.CLI mod-query versions organization/mod-id +UnchainedLauncher.CLI mod-query list --add-registry local:./registry +UnchainedLauncher.CLI mod-query get organization/mod-id --config-path ./server.yaml +``` + +The official registry is always included. Repeat `--add-registry` to include +GitHub, local, or Core JSON-config registries, using the same formats as launch +commands. `--config-path` reads `mod-setup.registries` from an existing launch +YAML file instead of the `--add-registry` arguments. YAML-relative paths resolve +relative to that file. Only registry configuration is used: installation, +platform, client/server settings, mod selections, and plugin policies are not +required or applied. Query operands and `--version` remain command-line inputs. + +`list` displays mod ID (`organization/id`), name, and latest version. `get` +defaults to Core's latest stable semantic version; if none is stable it shows +the newest release by date. `--version` selects an exact release tag, including +prereleases. `versions` lists all tags, release dates, and stable/prerelease status. + +Details include release-specific description/authors, repository/homepage, +release date/notes, pak filename, SHA-512 hash, size in bytes, declared dependency +ranges, map count/names/paths/modes, blueprints, and other manifest asset counts. +Local size is read from the filesystem; GitHub size uses an HTTP HEAD request, +never a pak download. An inaccessible pak or missing length is reported as +`unknown`, not zero. Declared dependencies are displayed, not installed or resolved. + +Exit status is 0 for success, 2 for invalid arguments/configuration, and 1 for +registry/runtime errors or a missing mod/version. A failed registry during +`list` leaves the available rows visible but reports errors and exits 1 rather +than presenting a partial catalog as complete. Game arguments after `--` are +not accepted by `mod-query`. Use `mod-query --help` for details. + +## DLL registry queries + +```text +UnchainedLauncher.CLI dll-query list +UnchainedLauncher.CLI dll-query get Chiv2-Community/UnchainedPlugin +UnchainedLauncher.CLI dll-query get Chiv2-Community/UnchainedPlugin --version v1.0.0-RC2 +UnchainedLauncher.CLI dll-query versions Chiv2-Community/UnchainedPlugin +UnchainedLauncher.CLI dll-query list --config-path ./server.yaml +``` + +DLL queries share the mod-query command definitions, configured registry object, +and `--add-registry`/`--config-path` behavior. They use the existing +[zero-infra plugin schema](https://github.com/Chiv2-Community/zero-infra-mod-registry/blob/prepare-v1/schemas/plugin.schema.json) +and live C2ModRegistry `dll_db`: `plugin_list_index.txt` and +`packages//.json`. Local registries use +`/dll_db/packages/...`. Pak-only registries without a DLL +database contribute no DLL packages. + +Details show release-specific name/description/authors, execution target +(`client`, `server`, or `shared`), filename, **SHA-256** hash, recorded size, +download URL, plugin dependencies separately from pak mod dependencies, and +release notes. Versions include prereleases; defaults choose latest stable, +falling back to the newest dated release when no stable exists. Queries do not +download, install, or execute DLLs. Recorded hashes/sizes are registry metadata, +not a claim that an installed file has been verified. + ## YAML configuration CLI options and command-specific help are defined with System.CommandLine. @@ -86,7 +153,7 @@ saved-dir-suffix: MyServer mod-setup: registries: [] # Official registry remains enabled, even with an empty list. mods: [] # organization/mod-id:exact-release-tag - dlls: [] # Reserved; selection remains deferred to issue #149. + dlls: [] # Optional dll_db coordinates: organization/id:version. server: name: My Server description: My server description @@ -221,14 +288,44 @@ same installed mod set and separate saved suffixes/ports. ## DLL compatibility behavior -Every launch recursively injects **all `.dll` files** under -`\TBL\Binaries\Win64\Plugins`, preserving the GUI behavior. +Both GUI and CLI resolve the required UnchainedPlugin release from `dll_db`. +`--dll organization/id:version` (or YAML `mod-setup.dlls`) additionally selects +registry releases in CLI launches. Plugin dependencies are resolved before their +dependents; target mismatches, missing releases, cycles, and conflicting versions +fail preparation. Pak dependencies enter the shared mod manager. The GUI uses +this same manager for the core plugin; a GUI DLL-selection editor is not included. + +Every selected DLL must match the registry's SHA-256 and size. Downloads are +verified before installation. Existing matching root DLLs can be reused; +new downloads go into `Plugins/.dll-managed//` without replacing unknown +root DLLs. `Plugins/dll_manager.json` records the last successful selection. +Safe injection uses only that launch's verified allowlist, not the directory or +the saved inventory. Old managed files are retained, not automatically deleted. +`--no-plugin-update` forbids downloads and requires matching local files. +Only explicitly pinned prereleases (or `--allow-unstable-plugin` for automatic +selection) are eligible; automatic selection otherwise uses the latest stable. + +Other DLLs in `\TBL\Binaries\Win64\Plugins` are not enumerated +for injection unless this exact command-line flag is explicitly supplied: + +```text +--unsafe-very-dangerous-load-all-dlls +``` -`--dll organization/dll-id:version` is accepted as a reserved option and emits a -warning. It does **not** download, filter, select, or pin DLL versions yet. -Registry-backed DLL packages and selection are tracked in -[issue #149](https://github.com/Chiv2-Community/UnchainedLauncher/issues/149). -Only put trusted DLLs in this directory. +This is a dangerous compatibility escape hatch: it recursively injects **all** +DLLs in Plugins, executing arbitrary native code in the game. For the GUI, +pass it when starting `UnchainedLauncher.GUI.exe`; for the CLI, pass it to a +`client` or `server` command. It is hidden from generated help, has no GUI +checkbox, is never saved in settings, and is not accepted as a YAML property. +It must be explicitly present before `--`, even with `--config-path`; unlike +ordinary YAML-overridden launch settings, this process-local permission is +honored only from the actual command line. After `--` it is only a game argument +and grants no permission. It is rejected for query commands. + +The unsafe escape hatch also includes retained managed releases; it can load +multiple versions and bypass normal selection. Only opt in if you trust every +DLL in this directory. Registry integrity checks establish file identity, not +that native code is safe. ## Server options diff --git a/UnchainedLauncher.CLI/RegistryOptions.cs b/UnchainedLauncher.CLI/RegistryOptions.cs new file mode 100644 index 00000000..94971831 --- /dev/null +++ b/UnchainedLauncher.CLI/RegistryOptions.cs @@ -0,0 +1,24 @@ +using System.CommandLine; +using UnchainedLauncher.Core.Services.Mods.Registry; + +namespace UnchainedLauncher.CLI; + +public sealed class RegistryOptions { + public IReadOnlyList Registries { get; } + + public RegistryOptions(IEnumerable? specifications, string directory) => + Registries = SharedLaunchOptions.Entries(specifications).Select(value => RegistrySpec.Parse(value, directory)).ToArray(); + + public AggregateModRegistry CreateRegistry() => new(new IModRegistry[] { + new GithubModRegistry("Chiv2-Community", "C2ModRegistry") + }.Concat(Registries.Select(spec => spec.Create()))); + + internal static Option AddOption(Command command, bool recursive = false) { + var option = new Option("--add-registry") { + Description = "Additional github:owner/repo, local:directory, or config:file registry; official registry is always included.", + Arity = ArgumentArity.OneOrMore, AllowMultipleArgumentsPerToken = false, Recursive = recursive + }; + command.Options.Add(option); + return option; + } +} diff --git a/UnchainedLauncher.CLI/RegistryQueryConfiguration.cs b/UnchainedLauncher.CLI/RegistryQueryConfiguration.cs new file mode 100644 index 00000000..985fd7dc --- /dev/null +++ b/UnchainedLauncher.CLI/RegistryQueryConfiguration.cs @@ -0,0 +1,25 @@ +using YamlDotNet.Serialization; + +namespace UnchainedLauncher.CLI; + +// A read-only projection of a launch YAML file. Querying requires only registry +// specifications, not a valid installation, credentials, or launch settings. +public sealed class RegistryQueryConfiguration { + public RegistryQueryModSetup? ModSetup { get; set; } + [YamlConverter(typeof(IgnoredYamlNodeConverter))] public object? Chiv2Path { get; set; } + [YamlConverter(typeof(IgnoredYamlNodeConverter))] public object? Platform { get; set; } + [YamlConverter(typeof(IgnoredYamlNodeConverter))] public object? SavedDirSuffix { get; set; } + [YamlConverter(typeof(IgnoredYamlNodeConverter))] public object? ServerBrowserBackend { get; set; } + [YamlConverter(typeof(IgnoredYamlNodeConverter))] public object? UpdatePlugin { get; set; } + [YamlConverter(typeof(IgnoredYamlNodeConverter))] public object? AllowUnstablePlugin { get; set; } + [YamlConverter(typeof(IgnoredYamlNodeConverter))] public object? NoTui { get; set; } + [YamlConverter(typeof(IgnoredYamlNodeConverter))] public object? GameArgs { get; set; } + [YamlConverter(typeof(IgnoredYamlNodeConverter))] public object? Server { get; set; } + [YamlConverter(typeof(IgnoredYamlNodeConverter))] public object? Client { get; set; } +} + +public sealed class RegistryQueryModSetup { + public List? Registries { get; set; } + [YamlConverter(typeof(IgnoredYamlNodeConverter))] public object? Mods { get; set; } + [YamlConverter(typeof(IgnoredYamlNodeConverter))] public object? Dlls { get; set; } +} diff --git a/UnchainedLauncher.CLI/ServerLauncher.cs b/UnchainedLauncher.CLI/ServerLauncher.cs index 6d2c5dde..b4e3e357 100644 --- a/UnchainedLauncher.CLI/ServerLauncher.cs +++ b/UnchainedLauncher.CLI/ServerLauncher.cs @@ -1,4 +1,3 @@ -using Octokit; using System.Diagnostics; using System.Net; using UnchainedLauncher.Core.JsonModels; @@ -22,8 +21,6 @@ public static async Task LaunchAsync(CliLaunchOptions request, Cancella var pluginsPath = Path.Combine(options.Chivalry2Path, FilePaths.PluginDir); if (!File.Exists(executable)) throw new FileNotFoundException("Chivalry 2 shipping executable was not found. Check --chivalry2-path.", executable); if (!Directory.Exists(pakPath)) throw new DirectoryNotFoundException($"Chivalry 2 Paks directory was not found: {pakPath}"); - if (!options.UpdatePlugin && !File.Exists(Path.Combine(options.Chivalry2Path, FilePaths.UnchainedPluginPath))) - throw new FileNotFoundException("--no-plugin-update requires an existing UnchainedPlugin.dll in the Plugins directory."); var registry = CreateRegistry(request); @@ -48,9 +45,7 @@ public static async Task LaunchAsync(CliLaunchOptions request, Cancella if (!File.Exists(appIdPath)) File.WriteAllText(appIdPath, "1824220"); } var dialogue = new ConsoleDialogue(); - var pluginUpdater = new UnchainedPluginUpdateChecker( - new GithubReleaseLocator(new GitHubClient(new ProductHeaderValue("UnchainedLauncher.CLI")), - "Chiv2-Community", "UnchainedPlugin"), new FileInfoVersionExtractor(), dialogue); + var pluginUpdater = new DllManager(registry, manager, pluginsPath, options.Dlls, options.UnsafeLoadAllDlls, cancellationToken); var modInstaller = new Chivalry2ModsInstaller(manager, dialogue); var signing = SigPreparer.Create(manager.PakDir, dialogue); // Call preparers sequentially so each receives the previous result. @@ -60,8 +55,6 @@ public static async Task LaunchAsync(CliLaunchOptions request, Cancella var updated = await pluginUpdater.PrepareLaunch(launch); cancellationToken.ThrowIfCancellationRequested(); if (updated.IsNone) return LanguageExt.Option.None; - if (!File.Exists(Path.Combine(pluginsPath, "UnchainedPlugin.dll"))) - throw new FileNotFoundException("UnchainedPlugin.dll is required for an Unchained launch."); try { updated = await modInstaller.PrepareLaunch(updated.IfNone(launch)); } @@ -75,7 +68,7 @@ public static async Task LaunchAsync(CliLaunchOptions request, Cancella return updated; }); var launcher = new UnchainedChivalry2Launcher(preparer, new ProcessLauncher(executable), - options.Chivalry2Path, new DllInjector(pluginsPath)); + options.Chivalry2Path, pluginUpdater); var result = await launcher.Launch(request.ToLaunchOptions()); return result.Match( Right: process => process, @@ -85,9 +78,7 @@ public static async Task LaunchAsync(CliLaunchOptions request, Cancella } internal static AggregateModRegistry CreateRegistry(CliLaunchOptions options) => - new(new IModRegistry[] { - new GithubModRegistry("Chiv2-Community", "C2ModRegistry") - }.Concat(options.Shared.Registries.Select(r => r.Create())).ToArray()); + options.Shared.RegistryOptions.CreateRegistry(); // Several existing Core preparers use installation-relative paths. // This standalone CLI sets CWD only for preparation, then restores it. @@ -128,4 +119,4 @@ public static IA2S CreateQuery(ServerOptions options) { if (address.Equals(IPAddress.IPv6Any)) address = IPAddress.IPv6Loopback; return new A2S(new IPEndPoint(address, options.Settings.QueryPort)); } -} \ No newline at end of file +} diff --git a/UnchainedLauncher.CLI/YamlConfiguration.cs b/UnchainedLauncher.CLI/YamlConfiguration.cs index 86ce4a99..7f8b2cbf 100644 --- a/UnchainedLauncher.CLI/YamlConfiguration.cs +++ b/UnchainedLauncher.CLI/YamlConfiguration.cs @@ -6,7 +6,14 @@ namespace UnchainedLauncher.CLI; public static class YamlConfiguration { - public static CliLaunchOptions Load(string path, bool client) { + public static CliLaunchOptions Load(string path, bool client) { + LaunchConfiguration configuration = client ? LoadDocument(path) : LoadDocument(path); + try { configuration.Validate(); } + catch (ValidationException ex) { throw new ArgumentException("Invalid configuration: " + ex.Message); } + return CliLaunchOptions.FromConfiguration(configuration, Path.GetDirectoryName(Path.GetFullPath(path))!); + } + + internal static T LoadDocument(string path) where T : class { path = Path.GetFullPath(path); try { using var reader = File.OpenText(path); @@ -17,12 +24,9 @@ public static CliLaunchOptions Load(string path, bool client) { .WithDuplicateKeyChecking() .WithEnforceNullability() .Build(); - LaunchConfiguration? configuration = client - ? deserializer.Deserialize(reader) - : deserializer.Deserialize(reader); + var configuration = deserializer.Deserialize(reader); if (configuration is null) throw new ArgumentException("Configuration must contain one YAML mapping."); - configuration.Validate(); - return CliLaunchOptions.FromConfiguration(configuration, Path.GetDirectoryName(path)!); + return configuration; } catch (YamlException ex) { // Library errors can include passwords/tokens; report only location. @@ -35,4 +39,4 @@ public static CliLaunchOptions Load(string path, bool client) { throw new ArgumentException($"Cannot read YAML configuration: {path}"); } } -} \ No newline at end of file +} diff --git a/UnchainedLauncher.CLI/examples/server.yaml b/UnchainedLauncher.CLI/examples/server.yaml index 955971d3..0eceaf0b 100644 --- a/UnchainedLauncher.CLI/examples/server.yaml +++ b/UnchainedLauncher.CLI/examples/server.yaml @@ -7,7 +7,7 @@ mod-setup: registries: [] # Official GitHub registry is included automatically. # Additional entries: github:owner/repository, local:../registry, config:../registries.json mods: [] # Exact coordinates: organization/mod-id:v1.2.3 - dlls: [] # Reserved: all Plugins DLLs still load; selection is not implemented. + dlls: [] # Optional dll_db release coordinates; the required core plugin is always included. # Run with: UnchainedLauncher.CLI.exe server --config-path server.yaml # A client block, if included, is ignored by the server command. diff --git a/UnchainedLauncher.Core/JsonModels/DllMetadata.cs b/UnchainedLauncher.Core/JsonModels/DllMetadata.cs new file mode 100644 index 00000000..e3298326 --- /dev/null +++ b/UnchainedLauncher.Core/JsonModels/DllMetadata.cs @@ -0,0 +1,47 @@ +using System.Text.Json.Serialization; +using Semver; +using UnchainedLauncher.Core.JsonModels.ModMetadata; +using UnchainedLauncher.Core.Services.Mods.Registry; + +namespace UnchainedLauncher.Core.JsonModels; + +// Matches zero-infra-mod-registry prepare-v1 and C2ModRegistry/dll_db. +public sealed record DllPackage( + [property: JsonPropertyName("latest_release_info"), JsonRequired] DllInfo LatestReleaseInfo, + [property: JsonPropertyName("releases"), JsonRequired] List Releases) { + public DllRelease? LatestRelease => Releases.Where(release => release.Version?.IsRelease == true) + .OrderByDescending(release => release.Version).FirstOrDefault() + ?? Releases.OrderByDescending(release => release.ReleaseDate).FirstOrDefault(); +} + +public sealed record DllRelease( + [property: JsonPropertyName("tag"), JsonRequired] string Tag, + [property: JsonPropertyName("hash"), JsonRequired] string Hash, + [property: JsonPropertyName("dll_file_name"), JsonRequired] string DllFileName, + [property: JsonPropertyName("download_url"), JsonRequired] string DownloadUrl, + [property: JsonPropertyName("size"), JsonRequired] long Size, + [property: JsonPropertyName("release_date"), JsonRequired] DateTimeOffset ReleaseDate, + [property: JsonPropertyName("info"), JsonRequired] DllInfo Info, + [property: JsonPropertyName("release_notes_markdown")] string? ReleaseNotesMarkdown) { + [JsonIgnore] public SemVersion? Version => SemVersion.TryParse(Tag, SemVersionStyles.AllowV, out var version) ? version : null; +} + +public sealed record DllInfo { + [JsonPropertyName("repo_url"), JsonRequired] public required string RepoUrl { get; init; } + [JsonPropertyName("name"), JsonRequired] public required string Name { get; init; } + [JsonPropertyName("description"), JsonRequired] public required string Description { get; init; } + [JsonPropertyName("authors"), JsonRequired] public required List Authors { get; init; } + [JsonPropertyName("target")] public string Target { get; init; } = "shared"; + [JsonPropertyName("icon_url")] public string? IconUrl { get; init; } + [JsonPropertyName("image_urls")] public List ImageUrls { get; init; } = []; + [JsonPropertyName("plugin_dependencies")] public List PluginDependencies { get; init; } = []; + [JsonPropertyName("mod_dependencies")] public List ModDependencies { get; init; } = []; + [JsonIgnore] public ModIdentifier Identifier => RepositoryIdentifier(RepoUrl); + + internal static ModIdentifier RepositoryIdentifier(string repoUrl) { + if (!Uri.TryCreate(repoUrl, UriKind.Absolute, out var uri) || uri.Scheme != "https" || uri.Host != "github.com" || uri.UserInfo.Length != 0) + throw new ArgumentException("Invalid DLL dependency/repository URL."); + return RegistryUtils.ParseModIdentifier(uri.AbsolutePath.Trim('/'), "/") + ?? throw new ArgumentException("Invalid DLL repository identifier."); + } +} \ No newline at end of file diff --git a/UnchainedLauncher.Core/Services/Mods/DllManager.cs b/UnchainedLauncher.Core/Services/Mods/DllManager.cs new file mode 100644 index 00000000..f0a8410a --- /dev/null +++ b/UnchainedLauncher.Core/Services/Mods/DllManager.cs @@ -0,0 +1,197 @@ +using System.Security.Cryptography; +using LanguageExt; +using Semver; +using UnchainedLauncher.Core.JsonModels; +using UnchainedLauncher.Core.Services.Mods.Registry; +using UnchainedLauncher.Core.Services.Processes; +using UnchainedLauncher.Core.Services.Processes.Chivalry; +using UnchainedLauncher.Core.Services.Processes.Chivalry.LaunchPreparers; + +namespace UnchainedLauncher.Core.Services.Mods; + +/// Registry-approved DLL preparation and the process-local injection allowlist. +public sealed class DllManager : IChivalry2LaunchPreparer, IProcessInjector { + private static readonly HttpClient Client = new() { Timeout = TimeSpan.FromMinutes(5) }; + private readonly AggregateModRegistry _registry; + private readonly IModManager _mods; + private readonly string _directory; + private readonly bool _unsafe; + private readonly IReadOnlyList _requested; + private readonly CancellationToken _cancellationToken; + private readonly HttpClient _client; + private string[] _paths = []; + private bool _prepared; + private IReadOnlyList _preparedReleases = []; + + public DllManager(AggregateModRegistry registry, IModManager mods, string directory, + IEnumerable? requested = null, bool unsafeLoadAllDlls = false, + CancellationToken cancellationToken = default, HttpClient? client = null) { + _registry = registry; + _mods = mods; + _directory = Path.GetFullPath(directory); + _requested = requested?.ToArray() ?? []; + _unsafe = unsafeLoadAllDlls; + _cancellationToken = cancellationToken; + _client = client ?? Client; + } + + public static bool Satisfies(DllRelease release, string requirement) { + if (release.Version is null) return false; + // The registry uses comma-separated comparisons; npm ranges use spaces. + return SatisfiesVersion(release.Version, requirement); + } + + public static bool SatisfiesVersion(SemVersion version, string requirement) => requirement.Split(',').All(part => { + var normalized = System.Text.RegularExpressions.Regex.Replace(part.Trim(), @"^(\^|>=|<=|==|!=|>|<)?v", "$1"); + if (normalized.StartsWith("!=", StringComparison.Ordinal)) + return !SemVersionRange.ParseNpm("=" + normalized[2..], includeAllPrerelease: true).Contains(version); + return SemVersionRange.ParseNpm(normalized.Replace("==", "="), includeAllPrerelease: true).Contains(version); + }); + + public static IReadOnlyList Resolve(IReadOnlyList packages, + IEnumerable requested, bool server, bool allowUnstable) { + var selected = new Dictionary(StringComparer.OrdinalIgnoreCase); + var visiting = new System.Collections.Generic.HashSet(StringComparer.OrdinalIgnoreCase); + var ordered = new List(); + var roots = requested.ToList(); + if (!roots.Any(root => root.Org.Equals("Chiv2-Community", StringComparison.OrdinalIgnoreCase) + && root.ModuleName.Equals("UnchainedPlugin", StringComparison.OrdinalIgnoreCase))) + roots.Insert(0, new ReleaseCoordinates("Chiv2-Community", "UnchainedPlugin", "latest")); + foreach (var root in roots) Visit(root.Org + "/" + root.ModuleName, root.Version, exact: true); + return ordered; + + void Visit(string id, string requirement, bool exact) { + var package = packages.FirstOrDefault(p => (p.LatestReleaseInfo.Identifier.Org + "/" + p.LatestReleaseInfo.Identifier.ModuleName).Equals(id, StringComparison.OrdinalIgnoreCase)) + ?? throw new InvalidOperationException($"DLL package not found: {id}"); + bool Accept(DllRelease r) => exact ? requirement == "latest" || r.Tag == requirement : Satisfies(r, requirement); + if (selected.TryGetValue(id, out var previous)) { + if (visiting.Contains(id)) throw new InvalidOperationException($"Cyclic DLL dependency: {id}"); + if (!Accept(previous)) throw new InvalidOperationException($"Conflicting DLL requirements: {id}"); + return; + } + var candidates = package.Releases.Where(Accept); + if (!exact || requirement == "latest") candidates = candidates.Where(r => allowUnstable || r.Version?.IsRelease == true); + var release = candidates.OrderByDescending(r => r.Version).ThenByDescending(r => r.ReleaseDate).FirstOrDefault() + ?? throw new InvalidOperationException($"DLL release not found: {id}:{requirement}"); + if (release.Info.Target != "shared" && release.Info.Target != (server ? "server" : "client")) + throw new InvalidOperationException($"DLL target does not match launch: {id}"); + selected.Add(id, release); + visiting.Add(id); + foreach (var dependency in release.Info.PluginDependencies) { + var dep = ModIdentifier.FromDependency(dependency); + Visit(dep.Org + "/" + dep.ModuleName, dependency.Version, exact: false); + } + visiting.Remove(id); + ordered.Add(release); + } + } + + public async Task> PrepareLaunch(LaunchOptions options) { + _prepared = false; + _paths = []; + _cancellationToken.ThrowIfCancellationRequested(); + var catalog = await DllCatalog.ReadAsync(_registry, _cancellationToken); + if (catalog.Errors.Count != 0) throw new InvalidOperationException("DLL registry loading failed: " + string.Join("; ", catalog.Errors)); + var releases = Resolve(catalog.Packages, _requested.Concat(options.EnabledDlls), options.ServerLaunchOptions.IsSome, options.AllowUnstablePluginReleases); + if (releases.Any(r => r.Info.ModDependencies.Count != 0)) { + var fetched = await _mods.UpdateModsList(); + if (fetched.HasErrors) throw new InvalidOperationException("Pak registry loading failed while resolving DLL dependencies."); + } + // Resolve pak requirements into the shared mod manager before its installer runs. + foreach (var dependency in releases.SelectMany(r => r.Info.ModDependencies)) { + var id = ModIdentifier.FromDependency(dependency); + var existing = _mods.GetEnabledAndDependencyReleases().Where(r => r.Info.Organization == id.Org && r.Info.RepoName == id.ModuleName).ToArray(); + if (existing.Length != 0) { + if (existing.Any(r => r.Version is null || !SatisfiesVersion(r.Version, dependency.Version))) + throw new InvalidOperationException("DLL pak dependency conflicts with selected mods."); + continue; + } + var mod = _mods.Mods.FirstOrDefault(m => m.LatestReleaseInfo.Organization == id.Org && m.LatestReleaseInfo.RepoName == id.ModuleName); + var release = mod?.Releases.Where(r => SemVersion.TryParse(r.Tag, SemVersionStyles.AllowV, out var v) + && SatisfiesVersion(v, dependency.Version)) + .OrderByDescending(r => SemVersion.Parse(r.Tag, SemVersionStyles.AllowV)).FirstOrDefault() + ?? throw new InvalidOperationException($"DLL pak dependency unavailable: {id.Org}/{id.ModuleName}"); + var coordinate = ReleaseCoordinates.FromRelease(release); + if (!_mods.EnableModRelease(coordinate)) throw new InvalidOperationException("Could not enable DLL pak dependency."); + } + var resolvedPaks = _mods.GetEnabledAndDependencyReleases().ToArray(); + foreach (var dependency in releases.SelectMany(r => r.Info.ModDependencies)) { + var id = ModIdentifier.FromDependency(dependency); + var matches = resolvedPaks.Where(r => r.Info.Organization == id.Org && r.Info.RepoName == id.ModuleName).ToArray(); + if (matches.Length == 0 || matches.Any(r => r.Version is null || !SatisfiesVersion(r.Version, dependency.Version))) + throw new InvalidOperationException("Resolved pak dependencies do not satisfy DLL requirements."); + } + if (resolvedPaks.GroupBy(r => (r.Info.Organization, r.Info.RepoName)).Any(g => g.Select(r => r.Tag).Distinct().Count() > 1)) + throw new InvalidOperationException("Resolved pak dependencies contain conflicting versions."); + var paths = releases.Select(r => SafeFileName(r.DllFileName)).ToArray(); + if (paths.Distinct(StringComparer.OrdinalIgnoreCase).Count() != paths.Length) throw new InvalidOperationException("DLL filename collision."); + Directory.CreateDirectory(_directory); + for (var i = 0; i < releases.Count; i++) { + _cancellationToken.ThrowIfCancellationRequested(); + var release = releases[i]; + var legacyPath = Path.Combine(_directory, paths[i]); + if (File.Exists(legacyPath) && await MatchesFileAsync(legacyPath, release)) { + paths[i] = legacyPath; + continue; + } + var path = Path.Combine(_directory, ".dll-managed", release.Hash.ToLowerInvariant(), paths[i]); + paths[i] = path; + if (File.Exists(path) && await MatchesFileAsync(path, release)) continue; + if (!options.CheckForDependencyUpdates) throw new InvalidOperationException("DLL missing or not registry-approved with dependency updates disabled."); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + var temporary = Path.Combine(_directory, ".dll-download-" + Guid.NewGuid().ToString("N")); + try { + using var response = await _client.GetAsync(release.DownloadUrl, HttpCompletionOption.ResponseHeadersRead, _cancellationToken); + response.EnsureSuccessStatusCode(); + if (response.RequestMessage?.RequestUri is { Scheme: not "https" }) + throw new InvalidOperationException("DLL download redirected to an insecure URL."); + if (response.Content.Headers.ContentLength is { } length && length != release.Size) + throw new InvalidOperationException("DLL download size does not match registry."); + await using (var destination = new FileStream(temporary, FileMode.CreateNew, FileAccess.Write, FileShare.None)) + await response.Content.CopyToAsync(destination, _cancellationToken); + if (!await MatchesFileAsync(temporary, release)) throw new InvalidOperationException("DLL download size or SHA-256 mismatch."); + File.Move(temporary, path, overwrite: true); + } + finally { if (File.Exists(temporary)) File.Delete(temporary); } + } + _paths = paths; + _preparedReleases = releases; + var inventory = System.Text.Json.JsonSerializer.Serialize(releases.Select((release, i) => new { + Repository = release.Info.RepoUrl, + release.Tag, + release.Hash, + release.Size, + Path = _paths[i] + })); + var inventoryPath = Path.Combine(_directory, "dll_manager.json"); + var inventoryTemp = inventoryPath + "." + Guid.NewGuid().ToString("N"); + try { + await File.WriteAllTextAsync(inventoryTemp, inventory); + File.Move(inventoryTemp, inventoryPath, overwrite: true); + } + finally { if (File.Exists(inventoryTemp)) File.Delete(inventoryTemp); } + _prepared = true; + return options; + } + + public static string SafeFileName(string name) { + if (string.IsNullOrWhiteSpace(name) || name.Contains('/') || name.Contains('\\') || name.Contains(':') + || name.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0 || name.IndexOfAny(['<', '>', '"', '|', '?', '*']) >= 0 + || name.Any(char.IsControl) || !name.EndsWith(".dll", StringComparison.OrdinalIgnoreCase)) + throw new InvalidOperationException("Invalid DLL filename."); + return name; + } + + public static async Task MatchesFileAsync(string path, DllRelease release) { + if (new FileInfo(path).Length != release.Size) return false; + await using var file = File.OpenRead(path); + return Convert.ToHexString(await SHA256.HashDataAsync(file)).Equals(release.Hash, StringComparison.OrdinalIgnoreCase); + } + + public bool Inject(System.Diagnostics.Process process) { + if (!_prepared || _paths.Any(p => !File.Exists(p))) return false; + for (var i = 0; i < _paths.Length; i++) + if (!MatchesFileAsync(_paths[i], _preparedReleases[i]).GetAwaiter().GetResult()) return false; + return new DllInjector(_directory, _unsafe, _paths).Inject(process); + } +} diff --git a/UnchainedLauncher.Core/Services/Mods/Registry/AggregateModRegistry.cs b/UnchainedLauncher.Core/Services/Mods/Registry/AggregateModRegistry.cs index bfb24325..b6f396e7 100644 --- a/UnchainedLauncher.Core/Services/Mods/Registry/AggregateModRegistry.cs +++ b/UnchainedLauncher.Core/Services/Mods/Registry/AggregateModRegistry.cs @@ -88,6 +88,17 @@ public EitherAsync GetModRelease(ReleaseCoor .Bind(maybeRelease => maybeRelease.ToEitherAsync(() => RegistryMetadataException.NotFound(coords, None))); + public async Task GetPakSize(Release release, CancellationToken cancellationToken = default) { + foreach (var registry in ModRegistries) { + cancellationToken.ThrowIfCancellationRequested(); + var matches = await registry.GetModRelease(ReleaseCoordinates.FromRelease(release)).Match( + candidate => candidate.ReleaseHash == release.ReleaseHash && candidate.PakFileName == release.PakFileName, + _ => false).WaitAsync(cancellationToken); + if (matches) return await registry.GetPakSize(release, cancellationToken); + } + return null; + } + public EitherAsync DownloadPak(ReleaseCoordinates coordinates, string outputLocation) { return InternalDownloadPak(coordinates, ModRegistries, None).ToAsync(); @@ -148,4 +159,4 @@ private IEnumerable DeduplicateMods(IEnumerable allMods) { ).Values.ToList(); } } -} \ No newline at end of file +} diff --git a/UnchainedLauncher.Core/Services/Mods/Registry/DllCatalog.cs b/UnchainedLauncher.Core/Services/Mods/Registry/DllCatalog.cs new file mode 100644 index 00000000..efde7f8c --- /dev/null +++ b/UnchainedLauncher.Core/Services/Mods/Registry/DllCatalog.cs @@ -0,0 +1,87 @@ +using System.Net; +using System.Text.Json; +using UnchainedLauncher.Core.JsonModels; +using UnchainedLauncher.Core.Utilities; + +namespace UnchainedLauncher.Core.Services.Mods.Registry; + +public sealed record DllCatalogResult(IReadOnlyList Packages, IReadOnlyList Errors); + +/// Read-only typed view of dll_db alongside the existing pak registry. +public static class DllCatalog { + private static readonly HttpClient Client = new() { Timeout = TimeSpan.FromSeconds(15) }; + + public static async Task ReadAsync(AggregateModRegistry registry, CancellationToken cancellationToken) { + var packages = new Dictionary(); + var errors = new List(); + foreach (var source in registry.ModRegistries) { + try { + if (source is GithubModRegistry github) { + var root = $"https://raw.githubusercontent.com/{github.Organization}/{github.RepoName}/refs/heads/main/dll_db"; + using var index = await Client.GetAsync(root + "/plugin_list_index.txt", cancellationToken); + if (index.StatusCode == HttpStatusCode.NotFound) continue; // Pak-only registry. + index.EnsureSuccessStatusCode(); + var text = await index.Content.ReadAsStringAsync(cancellationToken); + foreach (var id in RegistryUtils.ParseLineSeparatedPackageList(text.Trim())) { + cancellationToken.ThrowIfCancellationRequested(); + try { + var json = await Client.GetStringAsync($"{root}/packages/{Uri.EscapeDataString(id.Org)}/{Uri.EscapeDataString(id.ModuleName)}.json", cancellationToken); + Add(json, id); + } + catch (Exception ex) when (IsReadFailure(ex, cancellationToken)) { errors.Add($"{source.Name}: {id.Org}/{id.ModuleName}: {ex.Message}"); } + } + } + else if (source is LocalModRegistry local) { + var path = Path.Combine(local.RegistryPath, "dll_db", "packages"); + if (!Directory.Exists(path)) continue; + foreach (var file in Directory.EnumerateFiles(path, "*.json", SearchOption.AllDirectories)) { + try { Add(await File.ReadAllTextAsync(file, cancellationToken), null); } + catch (Exception ex) when (IsReadFailure(ex, cancellationToken)) { errors.Add($"{source.Name}: {file}: {ex.Message}"); } + } + } + else errors.Add($"DLL queries are not supported by registry: {source.Name}"); + } + catch (Exception ex) when (IsReadFailure(ex, cancellationToken)) { errors.Add($"{source.Name}: {ex.Message}"); } + } + return new DllCatalogResult(packages.Values.OrderBy(package => package.LatestReleaseInfo.Identifier).ToArray(), errors); + + void Add(string json, ModIdentifier? expected) { + var decoded = JsonHelpers.Deserialize(json); + if (!decoded.Success || decoded.Result is null) throw new JsonException("Invalid DLL registry metadata."); + var package = decoded.Result; + ValidateInfo(package.LatestReleaseInfo); + var id = package.LatestReleaseInfo.Identifier; + if (expected is not null && !expected.Matches(id)) throw new JsonException("DLL metadata does not match indexed identity."); + if (package.Releases is null || package.Releases.Any(release => release is null || release.Size < 0 || release.Info is null + || !id.Matches(release.Info.Identifier) || string.IsNullOrWhiteSpace(release.Tag) || string.IsNullOrWhiteSpace(release.Hash) + || string.IsNullOrWhiteSpace(release.DllFileName) || release.Info.Target is not ("client" or "server" or "shared"))) + throw new JsonException("Invalid DLL release metadata."); + foreach (var release in package.Releases) { + ValidateInfo(release.Info); + if (release.Hash.Length != 64 || Convert.FromHexString(release.Hash).Length != 32) + throw new JsonException("DLL hash must be SHA-256."); + if (!Uri.TryCreate(release.DownloadUrl, UriKind.Absolute, out var download) || download.Scheme != "https" || download.UserInfo.Length != 0) + throw new JsonException("DLL download URL must be HTTPS without credentials."); + } + if (packages.TryGetValue(id, out var previous)) { + var merged = previous.Releases.Concat(package.Releases).DistinctBy(release => release.Tag).ToList(); + packages[id] = previous with { Releases = merged }; + } + else packages.Add(id, package); + } + + static void ValidateInfo(DllInfo? info) { + if (info is null || string.IsNullOrWhiteSpace(info.Name) || string.IsNullOrWhiteSpace(info.Description) + || info.Authors is null || info.Authors.Count == 0 || info.Authors.Any(string.IsNullOrWhiteSpace) + || info.PluginDependencies is null || info.ModDependencies is null || info.Target is not ("server" or "client" or "shared")) + throw new JsonException("Invalid DLL package information."); + foreach (var dependency in info.PluginDependencies.Concat(info.ModDependencies)) { + if (dependency is null || string.IsNullOrWhiteSpace(dependency.Version)) throw new JsonException("Invalid DLL dependency."); + DllInfo.RepositoryIdentifier(dependency.RepoUrl); + } + } + } + + private static bool IsReadFailure(Exception ex, CancellationToken token) => ex is HttpRequestException or IOException or UnauthorizedAccessException or JsonException or ArgumentException or FormatException + || ex is OperationCanceledException && !token.IsCancellationRequested; +} \ No newline at end of file diff --git a/UnchainedLauncher.Core/Services/Mods/Registry/GithubModRegistry.cs b/UnchainedLauncher.Core/Services/Mods/Registry/GithubModRegistry.cs index a501f518..059ac305 100644 --- a/UnchainedLauncher.Core/Services/Mods/Registry/GithubModRegistry.cs +++ b/UnchainedLauncher.Core/Services/Mods/Registry/GithubModRegistry.cs @@ -9,6 +9,7 @@ namespace UnchainedLauncher.Core.Services.Mods.Registry { public class GithubModRegistry : JsonRegistry, IModRegistry { private static new readonly ILog Logger = LogManager.GetLogger(typeof(GithubModRegistry)); + private static readonly HttpClient SizeClient = new() { Timeout = TimeSpan.FromSeconds(15) }; public override string Name => $"Github mod registry at {Organization}/{RepoName}"; public string Organization { get; set; } @@ -65,5 +66,15 @@ private EitherAsync GetGithubPakStr private string GetGithubPakDownloadUrl(Release release) => $"https://github.com/{release.Info.Organization}/{release.Info.RepoName}/releases/download/{release.Tag}/{release.PakFileName}"; + + public override async Task GetPakSize(Release release, CancellationToken cancellationToken = default) { + try { + using var request = new HttpRequestMessage(HttpMethod.Head, GetGithubPakDownloadUrl(release)); + using var response = await SizeClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken); + return response.IsSuccessStatusCode ? response.Content.Headers.ContentLength : null; + } + catch (HttpRequestException) { return null; } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) { return null; } + } } -} \ No newline at end of file +} diff --git a/UnchainedLauncher.Core/Services/Mods/Registry/IModRegistry.cs b/UnchainedLauncher.Core/Services/Mods/Registry/IModRegistry.cs index fed013e3..a4cd2c66 100644 --- a/UnchainedLauncher.Core/Services/Mods/Registry/IModRegistry.cs +++ b/UnchainedLauncher.Core/Services/Mods/Registry/IModRegistry.cs @@ -101,7 +101,10 @@ public interface IModRegistry { /// /// /// - public EitherAsync GetModRelease(ReleaseCoordinates coords); + public EitherAsync GetModRelease(ReleaseCoordinates coords); + + /// Returns pak length without downloading the pak, or null if unavailable. + public Task GetPakSize(Release release, CancellationToken cancellationToken = default) => Task.FromResult(null); /// /// Acquires a single-use pak writer that owns the registry input stream. @@ -145,4 +148,4 @@ Func packageListRetrievalFunc _ => throw new ArgumentOutOfRangeException() }; } -} \ No newline at end of file +} diff --git a/UnchainedLauncher.Core/Services/Mods/Registry/JsonRegistry.cs b/UnchainedLauncher.Core/Services/Mods/Registry/JsonRegistry.cs index 9f11887b..b12bdad3 100644 --- a/UnchainedLauncher.Core/Services/Mods/Registry/JsonRegistry.cs +++ b/UnchainedLauncher.Core/Services/Mods/Registry/JsonRegistry.cs @@ -14,6 +14,7 @@ public abstract class JsonRegistry : IModRegistry { public abstract EitherAsync DownloadPak(ReleaseCoordinates coordinates, string outputLocation); public abstract Task GetAllMods(); + public virtual Task GetPakSize(Release release, CancellationToken cancellationToken = default) => Task.FromResult(null); /// /// Gets the mod metadata in a string format. @@ -46,4 +47,4 @@ public EitherAsync GetModRelease(ReleaseCoor .Bind(maybeRelease => maybeRelease.ToEitherAsync(() => RegistryMetadataException.NotFound(coords, None))); } -} \ No newline at end of file +} diff --git a/UnchainedLauncher.Core/Services/Mods/Registry/LocalModRegistry.cs b/UnchainedLauncher.Core/Services/Mods/Registry/LocalModRegistry.cs index edab6e26..d30a4649 100644 --- a/UnchainedLauncher.Core/Services/Mods/Registry/LocalModRegistry.cs +++ b/UnchainedLauncher.Core/Services/Mods/Registry/LocalModRegistry.cs @@ -1,203 +1,212 @@ -using LanguageExt; -using LanguageExt.Common; -using LanguageExt.UnsafeValueAccess; -using System.Collections.Immutable; -using UnchainedLauncher.Core.JsonModels.ModMetadata; -using UnchainedLauncher.Core.Utilities; -using static LanguageExt.Prelude; - -namespace UnchainedLauncher.Core.Services.Mods.Registry { - public class LocalModRegistry : JsonRegistry { - public override string Name => $"Local filesystem registry at {RegistryPath}"; - - private string _registryPathInternal; - - public string RegistryPath { - get => _registryPathInternal; - set { _registryPathInternal = !Path.IsPathRooted(value) ? Path.GetFullPath(value) : value; } - } - - public LocalModRegistry(string registryPath) { - if (registryPath == "") - registryPath = "LocalModRegistry"; - - _registryPathInternal = ""; // Technically does nothing, but cleans up a warning. - RegistryPath = registryPath; - - if (!Directory.Exists(RegistryPath)) Directory.CreateDirectory(RegistryPath); - } - - public event Action? OnRegistryChanged; - - public override EitherAsync DownloadPak(ReleaseCoordinates coordinates, string outputLocation) { - return GetMod(coordinates) - .Map(releaseMetadata => Optional(releaseMetadata.Releases.Find(x => x.Tag == coordinates.Version))) - .MapLeft(e => new ModPakStreamAcquisitionFailure(coordinates, e)) - .Bind(maybeRelease => maybeRelease.ToEitherAsync(() => - new ModPakStreamAcquisitionFailure( - coordinates, - Error.New($"Failed to fetch pak. No releases found for {coordinates.Org} / {coordinates.ModuleName} / {coordinates.Version}.") - ))) - .Bind(ModPakStream) - .Map(sizedStream => new FileWriter(outputLocation, sizedStream.Stream, sizedStream.Size)); - } - - public async Task DeleteRelease(ReleaseCoordinates coordinates) { - return await GetMod(coordinates) - .Bind(mod => GetModRelease(coordinates) - .Map(release => (mod, release))) - .MatchAsync( - async result => { - var mod = result.mod!; - var toDelete = result.release!; - - var modPath = Path.Combine(RegistryPath, coordinates.Org, coordinates.ModuleName); - var releasePath = Path.Combine(modPath, toDelete.Tag); - mod.Releases.Remove(toDelete); - - if (mod.Releases.Count == 0) { - Directory.Delete(modPath, true); - } - else { - Directory.Delete(releasePath, true); - await WriteMod(mod with { - // we know this unwrap is safe because if there were no releases, we would have deleted - LatestReleaseInfo = mod.LatestRelease.ValueUnsafe().Info - }); - } - - OnRegistryChanged?.Invoke(coordinates); - return true; - }, - _ => { - Logger.Warn($"Failed to delete release {coordinates.Org}/{coordinates.ModuleName}/{coordinates.Version}. Mod or specific release not found."); - return false; - } - ); - } - - public async Task AddRelease(Release newVersion, string pakPath) { - ReleaseCoordinates coordinates = ReleaseCoordinates.FromRelease(newVersion); - string modDirectory = Path.Combine(RegistryPath, coordinates.Org, coordinates.ModuleName); - string modJsonPath = Path.Combine(modDirectory, $"{coordinates.ModuleName}.json"); - var mod = await InternalGetModMetadata(modJsonPath) - .Match( - existing => new Mod( - newVersion.Info, - existing.Releases - .Filter(x => !ReleaseCoordinates.FromRelease(x).Matches(newVersion)) - .Append(newVersion).ToList() - ), - _ => new Mod( - newVersion.Info, - new List { newVersion } - ) - ); - - await CopyFileForRelease(newVersion, pakPath); - await WriteMod(mod); - OnRegistryChanged?.Invoke(coordinates); - } - - private Task CopyFileForRelease(Release release, string sourceFile) { - return Task.Run(() => { - string releaseDirectory = Path.Combine( - RegistryPath, - release.Info.Organization, - release.Info.Name, - release.Tag - ); - - string releasePakPath = Path.Combine(releaseDirectory, release.PakFileName); - Directory.CreateDirectory(releaseDirectory); - if (sourceFile == releasePakPath) return; - File.Copy(sourceFile, releasePakPath, true); - }); - } - - private async Task WriteMod(Mod modToWrite) { - ModIdentifier ident = ModIdentifier.FromMod(modToWrite); - string modDirectory = Path.Combine(RegistryPath, ident.Org, ident.ModuleName); - string manifestPath = Path.Combine(modDirectory, $"{ident.ModuleName}.json"); - Directory.CreateDirectory(modDirectory); - await File.WriteAllTextAsync(manifestPath, JsonHelpers.Serialize(modToWrite)); - } - - private EitherAsync InternalGetModMetadata(string jsonManifestPath) { - var dir = Path.GetDirectoryName(jsonManifestPath); - if (dir == null) { - return - LeftAsync(RegistryMetadataException.Parse($"Failed to parse json manifest path {jsonManifestPath}. Got null directory name.", None)); - } - - var parts = dir.Split(Path.DirectorySeparatorChar); - if (parts.Length() < 2) return LeftAsync(RegistryMetadataException.PackageListRetrieval($"Failed to determine module id for file at path {jsonManifestPath}", None)); - var modIdParts = parts.Reverse().Take(2).Reverse(); - - return GetMod(new ModIdentifier(modIdParts.First(), modIdParts.Last())); - } - - public override Task GetAllMods() { - return Task - .Run(() => Directory.EnumerateFiles(RegistryPath, "*.json", SearchOption.AllDirectories)) - .Bind(paths => - paths.ToImmutableList() - .Select(InternalGetModMetadata) - .Partition() - .Select(t => new GetAllModsResult(t.Lefts, t.Rights)) - ); - } - - protected override EitherAsync GetModMetadataString(ModIdentifier modId) { - return EitherAsync - .Right(Path.Combine(RegistryPath, modId.Org, modId.ModuleName)) - .BindAsync(path => Task.Run(() => { - if (!Directory.Exists(path)) - return EitherAsync - .Left(RegistryMetadataException.NotFound(modId, Some(Error.New(new IOException("File not found"))))); - - var manifests = Directory.EnumerateFiles(path, "*.json", SearchOption.TopDirectoryOnly); - - switch (manifests.Count()) { - case > 1: - Logger.Warn($"Found multiple candidates for manifests at {path}. Using the first one."); - break; - case 0: - return LeftAsync(RegistryMetadataException.NotFound(modId, Some(Error.New("No manifests")))); - } - - var manifest = manifests.Single(); - - return Prelude - .TryAsync(Task.Run(() => File.ReadAllText(manifest))) - .ToEither() - .MapLeft(e => RegistryMetadataException.NotFound(modId, e)); - })); - } - - private EitherAsync ModPakStream(Release release) { - // Paks will be found in PakReleasesDir/org/repoName/releaseTag/fileName - var path = Path.Combine(RegistryPath, release.Info.Organization, release.Info.RepoName, release.Tag, release.PakFileName); - - if (!File.Exists(path)) - return - LeftAsync( - new ModPakStreamAcquisitionFailure( - ReleaseCoordinates.FromRelease(release), - Error.New($"Failed to fetch pak. File not found: {path}") - ) - ); - - return - TryAsync(Task.Run(() => File.OpenRead(path))) - .Map(stream => new SizedStream(stream, stream.Length)) - .ToEither() - .MapLeft(e => - new ModPakStreamAcquisitionFailure( - ReleaseCoordinates.FromRelease(release), - Error.New($"Failed to fetch pak from {path}.", e) - ) - ); - } - } +using LanguageExt; +using LanguageExt.Common; +using LanguageExt.UnsafeValueAccess; +using System.Collections.Immutable; +using UnchainedLauncher.Core.JsonModels.ModMetadata; +using UnchainedLauncher.Core.Utilities; +using static LanguageExt.Prelude; + +namespace UnchainedLauncher.Core.Services.Mods.Registry { + public class LocalModRegistry : JsonRegistry { + public override string Name => $"Local filesystem registry at {RegistryPath}"; + + private string _registryPathInternal; + + public string RegistryPath { + get => _registryPathInternal; + set { _registryPathInternal = !Path.IsPathRooted(value) ? Path.GetFullPath(value) : value; } + } + + public LocalModRegistry(string registryPath) { + if (registryPath == "") + registryPath = "LocalModRegistry"; + + _registryPathInternal = ""; // Technically does nothing, but cleans up a warning. + RegistryPath = registryPath; + + if (!Directory.Exists(RegistryPath)) Directory.CreateDirectory(RegistryPath); + } + + public event Action? OnRegistryChanged; + + public override EitherAsync DownloadPak(ReleaseCoordinates coordinates, string outputLocation) { + return GetMod(coordinates) + .Map(releaseMetadata => Optional(releaseMetadata.Releases.Find(x => x.Tag == coordinates.Version))) + .MapLeft(e => new ModPakStreamAcquisitionFailure(coordinates, e)) + .Bind(maybeRelease => maybeRelease.ToEitherAsync(() => + new ModPakStreamAcquisitionFailure( + coordinates, + Error.New($"Failed to fetch pak. No releases found for {coordinates.Org} / {coordinates.ModuleName} / {coordinates.Version}.") + ))) + .Bind(ModPakStream) + .Map(sizedStream => new FileWriter(outputLocation, sizedStream.Stream, sizedStream.Size)); + } + + public async Task DeleteRelease(ReleaseCoordinates coordinates) { + return await GetMod(coordinates) + .Bind(mod => GetModRelease(coordinates) + .Map(release => (mod, release))) + .MatchAsync( + async result => { + var mod = result.mod!; + var toDelete = result.release!; + + var modPath = Path.Combine(RegistryPath, coordinates.Org, coordinates.ModuleName); + var releasePath = Path.Combine(modPath, toDelete.Tag); + mod.Releases.Remove(toDelete); + + if (mod.Releases.Count == 0) { + Directory.Delete(modPath, true); + } + else { + Directory.Delete(releasePath, true); + await WriteMod(mod with { + // we know this unwrap is safe because if there were no releases, we would have deleted + LatestReleaseInfo = mod.LatestRelease.ValueUnsafe().Info + }); + } + + OnRegistryChanged?.Invoke(coordinates); + return true; + }, + _ => { + Logger.Warn($"Failed to delete release {coordinates.Org}/{coordinates.ModuleName}/{coordinates.Version}. Mod or specific release not found."); + return false; + } + ); + } + + public async Task AddRelease(Release newVersion, string pakPath) { + ReleaseCoordinates coordinates = ReleaseCoordinates.FromRelease(newVersion); + string modDirectory = Path.Combine(RegistryPath, coordinates.Org, coordinates.ModuleName); + string modJsonPath = Path.Combine(modDirectory, $"{coordinates.ModuleName}.json"); + var mod = await InternalGetModMetadata(modJsonPath) + .Match( + existing => new Mod( + newVersion.Info, + existing.Releases + .Filter(x => !ReleaseCoordinates.FromRelease(x).Matches(newVersion)) + .Append(newVersion).ToList() + ), + _ => new Mod( + newVersion.Info, + new List { newVersion } + ) + ); + + await CopyFileForRelease(newVersion, pakPath); + await WriteMod(mod); + OnRegistryChanged?.Invoke(coordinates); + } + + private Task CopyFileForRelease(Release release, string sourceFile) { + return Task.Run(() => { + string releaseDirectory = Path.Combine( + RegistryPath, + release.Info.Organization, + release.Info.Name, + release.Tag + ); + + string releasePakPath = Path.Combine(releaseDirectory, release.PakFileName); + Directory.CreateDirectory(releaseDirectory); + if (sourceFile == releasePakPath) return; + File.Copy(sourceFile, releasePakPath, true); + }); + } + + private async Task WriteMod(Mod modToWrite) { + ModIdentifier ident = ModIdentifier.FromMod(modToWrite); + string modDirectory = Path.Combine(RegistryPath, ident.Org, ident.ModuleName); + string manifestPath = Path.Combine(modDirectory, $"{ident.ModuleName}.json"); + Directory.CreateDirectory(modDirectory); + await File.WriteAllTextAsync(manifestPath, JsonHelpers.Serialize(modToWrite)); + } + + private EitherAsync InternalGetModMetadata(string jsonManifestPath) { + var dir = Path.GetDirectoryName(jsonManifestPath); + if (dir == null) { + return + LeftAsync(RegistryMetadataException.Parse($"Failed to parse json manifest path {jsonManifestPath}. Got null directory name.", None)); + } + + var parts = dir.Split(Path.DirectorySeparatorChar); + if (parts.Length() < 2) return LeftAsync(RegistryMetadataException.PackageListRetrieval($"Failed to determine module id for file at path {jsonManifestPath}", None)); + var modIdParts = parts.Reverse().Take(2).Reverse(); + + return GetMod(new ModIdentifier(modIdParts.First(), modIdParts.Last())); + } + + public override Task GetAllMods() { + return Task + .Run(() => Directory.EnumerateFiles(RegistryPath, "*.json", SearchOption.AllDirectories) + .Where(path => !Path.GetRelativePath(RegistryPath, path).StartsWith("dll_db" + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase))) + .Bind(paths => + paths.ToImmutableList() + .Select(InternalGetModMetadata) + .Partition() + .Select(t => new GetAllModsResult(t.Lefts, t.Rights)) + ); + } + + protected override EitherAsync GetModMetadataString(ModIdentifier modId) { + return EitherAsync + .Right(Path.Combine(RegistryPath, modId.Org, modId.ModuleName)) + .BindAsync(path => Task.Run(() => { + if (!Directory.Exists(path)) + return EitherAsync + .Left(RegistryMetadataException.NotFound(modId, Some(Error.New(new IOException("File not found"))))); + + var manifests = Directory.EnumerateFiles(path, "*.json", SearchOption.TopDirectoryOnly); + + switch (manifests.Count()) { + case > 1: + Logger.Warn($"Found multiple candidates for manifests at {path}. Using the first one."); + break; + case 0: + return LeftAsync(RegistryMetadataException.NotFound(modId, Some(Error.New("No manifests")))); + } + + var manifest = manifests.Single(); + + return Prelude + .TryAsync(Task.Run(() => File.ReadAllText(manifest))) + .ToEither() + .MapLeft(e => RegistryMetadataException.NotFound(modId, e)); + })); + } + + private EitherAsync ModPakStream(Release release) { + // Paks will be found in PakReleasesDir/org/repoName/releaseTag/fileName + var path = GetPakPath(release); + + if (!File.Exists(path)) + return + LeftAsync( + new ModPakStreamAcquisitionFailure( + ReleaseCoordinates.FromRelease(release), + Error.New($"Failed to fetch pak. File not found: {path}") + ) + ); + + return + TryAsync(Task.Run(() => File.OpenRead(path))) + .Map(stream => new SizedStream(stream, stream.Length)) + .ToEither() + .MapLeft(e => + new ModPakStreamAcquisitionFailure( + ReleaseCoordinates.FromRelease(release), + Error.New($"Failed to fetch pak from {path}.", e) + ) + ); + } + + private string GetPakPath(Release release) => Path.Combine(RegistryPath, release.Info.Organization, release.Info.RepoName, release.Tag, release.PakFileName); + + public override Task GetPakSize(Release release, CancellationToken cancellationToken = default) { + cancellationToken.ThrowIfCancellationRequested(); + try { return Task.FromResult(new FileInfo(GetPakPath(release)).Length); } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { return Task.FromResult(null); } + } + } } \ No newline at end of file diff --git a/UnchainedLauncher.Core/Services/Processes/Chivalry/IChivalry2Launcher.cs b/UnchainedLauncher.Core/Services/Processes/Chivalry/IChivalry2Launcher.cs index 618747f0..373fc41a 100644 --- a/UnchainedLauncher.Core/Services/Processes/Chivalry/IChivalry2Launcher.cs +++ b/UnchainedLauncher.Core/Services/Processes/Chivalry/IChivalry2Launcher.cs @@ -1,181 +1,182 @@ -using LanguageExt; -using LanguageExt.Common; -using System.Diagnostics; -using UnchainedLauncher.Core.Services.Mods.Registry; -using UnchainedLauncher.Core.Utilities; - -namespace UnchainedLauncher.Core.Services.Processes.Chivalry; - -using static LanguageExt.Prelude; -public interface IChivalry2Launcher { - /// - /// Launches a vanilla game. Implementations may do extra work to enable client side pak file loading - /// - /// - /// - /// Left if the game failed to launch. - /// Right if the game was launched successfully. - /// - Task> Launch(LaunchOptions options); -} - -public abstract record UnchainedLaunchFailure(string Message, int Code, Option Underlying) : Expected(Message, Code, Underlying) { - public record InjectionFailedError() : UnchainedLaunchFailure("Failed to inject process with unchained code.", 0, None); - public record LaunchFailedError(LaunchFailed Failure) : UnchainedLaunchFailure(Failure.Message, 0, Failure.Underlying); - public record DependencyDownloadFailedError(IEnumerable DependencyNames) - : UnchainedLaunchFailure($"Failed to download dependencies '{string.Join("', '", DependencyNames)}'", 0, None); - public record LaunchCancelledError() : UnchainedLaunchFailure($"Launch Cancelled", 0, None); - public record MissingArgumentsError() : UnchainedLaunchFailure("Necessary modded launch arguments not provided.", 0, None); - - - public static UnchainedLaunchFailure InjectionFailed() => - new InjectionFailedError(); - public static UnchainedLaunchFailure LaunchFailed(LaunchFailed failure) => new LaunchFailedError(failure); - public static UnchainedLaunchFailure DependencyDownloadFailed(IEnumerable dependencyNames) => - new DependencyDownloadFailedError(dependencyNames); - - public static UnchainedLaunchFailure LaunchCancelled() => new LaunchCancelledError(); - public static UnchainedLaunchFailure MissingArguments() => new MissingArgumentsError(); - - public LaunchFailed AsLaunchFailed(string args) => new LaunchFailed("unknown", args, this); -} - -public record LaunchOptions( - IEnumerable EnabledReleases, - Option ServerBrowserBackend, - string LaunchArgs, - bool CheckForDependencyUpdates, //TODO: remove this property - bool AllowUnstablePluginReleases, - Option SavedDirSuffix, - Option ServerLaunchOptions -) { - public IReadOnlyList ToCLIArgs() { - var args = new List { - new RawArgs(LaunchArgs), - new UEParameter("-saveddirsuffix", SavedDirSuffix.IfNone("Unchained")), - new Flag("-unchained") - }; - - ServerLaunchOptions.IfSome(opts => args.AddRange(opts.ToCLIArgs())); - ServerBrowserBackend.IfSome(backend => args.Add(new Parameter("--server-browser-backend", backend))); - - return args; - } -}; - -public enum CensorArg { - None, - Standard, - Sex, - Zealous -} - -public record DiscordIntegrationLaunchOptions( - string BotToken, - Option AdminChannelId, - Option GeneralChannelId, - Option DashboardChannelId, - Option EventLogChannelId, - Option AdminRoleId, - bool MentionAdmins -) { - public IReadOnlyList ToCLIArgs() { - var args = new List { - new Parameter("--discord-bot-token", BotToken) - }; - - AdminChannelId.IfSome(id => args.Add(new Parameter("--discord-admin-channel-id", id))); - GeneralChannelId.IfSome(id => args.Add(new Parameter("--discord-general-channel-id", id))); - DashboardChannelId.IfSome(id => args.Add(new Parameter("--discord-dashboard-channel-id", id))); - EventLogChannelId.IfSome(id => args.Add(new Parameter("--discord-event-log-channel-id", id))); - AdminRoleId.IfSome(id => args.Add(new Parameter("--discord-admin-role-id", id))); - - if (!MentionAdmins) { - args.Add(new Parameter("--discord-mention-admins", "false")); - } - - return args; - } -} - -public record ServerLaunchOptions( - bool Headless, - string Name, - string Description, - bool RegisterWithBackend, - Option Password, - string Map, - int GamePort, - int BeaconPort, - int QueryPort, - int RconPort, - int? FFATimeLimit, - int? FFAScoreLimit, - int? TDMTimeLimit, - int? TDMTicketCount, - int? PlayerBotCount, - int? WarmupTime, - Option LocalIp, - IEnumerable ServerMods, - Option DiscordIntegration, - bool DesyncPatch, - bool UseBackendBanlist, - CensorArg CensorMode -) { - public IReadOnlyList ToCLIArgs() { - var args = new List() { - new UEINIParameter("Game", "/Script/TBL.TBLGameMode", "ServerName", Name), - new Parameter("--next-map-name", Map), - new UEParameter("Port", GamePort.ToString()), - new UEParameter("GameServerPingPort", BeaconPort.ToString()), - new UEParameter("GameServerQueryPort", QueryPort.ToString()), - new Parameter("-rcon", RconPort.ToString()), - new Parameter("--server-browser-description", Description), - new Parameter("--motd", Description), - new Parameter("--censor-mode", CensorMode.ToString().ToLower()) - }; - - if (Headless) { - args.Add(new Flag("-nullrhi")); - args.Add(new Flag("-unattended")); - args.Add(new Flag("-nosound")); - } - - if (RegisterWithBackend) - args.Add(new Flag("--register")); - - if (UseBackendBanlist) - args.Add(new Flag("--use-backend-banlist")); - - Password.IfSome(password => args.Add(new UEParameter("ServerPassword", password.Trim()))); - - if (ServerMods.Any()) - args.Add(new Parameter("--server-mods", string.Join(",", ServerMods.Distinct()))); - - LocalIp.IfSome(ip => args.Add(new Parameter("--local-ip", ip))); - - FFAScoreLimit.IfSome(limit => args.Add(new UEMapUrlParameter("FFAScoreLimit", limit.ToString()))); - FFATimeLimit.IfSome(limit => args.Add(new UEMapUrlParameter("FFATimeLimit", limit.ToString()))); - TDMTimeLimit.IfSome(limit => args.Add(new UEMapUrlParameter("TDMTimeLimit", limit.ToString()))); - TDMTicketCount.IfSome(count => args.Add(new UEMapUrlParameter("TDMTicketCount", count.ToString()))); - PlayerBotCount.IfSome(count => args.Add(new UEMapUrlParameter("NumPlayerBots", count.ToString()))); - WarmupTime.IfSome(time => args.Add(new UEMapUrlParameter("WarmupTime", time.ToString()))); - - DiscordIntegration.IfSome(discord => args.AddRange(discord.ToCLIArgs())); - - if (DesyncPatch) - args.Add(new Flag("--desync-patch")); - - return args; - } -}; - - -public abstract record CLIArg(string Rendered); - -public record RawArgs(string Args) : CLIArg(Args); -public record Flag(string FlagName) : CLIArg(FlagName); -public record Parameter(string ParamName, string Value) : CLIArg($"{ParamName} {ArgumentEscaper.Escape(Value)}"); -public record UEParameter(string ParamName, string Value) : CLIArg($"{ParamName}={ArgumentEscaper.Escape(Value)}"); -public record UEINIParameter(string Type, string Section, string Key, string Value) : CLIArg($"-ini:{Type}:[{Section}]:{Key}={ArgumentEscaper.Escape(Value)}"); +using LanguageExt; +using LanguageExt.Common; +using System.Diagnostics; +using UnchainedLauncher.Core.Services.Mods.Registry; +using UnchainedLauncher.Core.Utilities; + +namespace UnchainedLauncher.Core.Services.Processes.Chivalry; + +using static LanguageExt.Prelude; +public interface IChivalry2Launcher { + /// + /// Launches a vanilla game. Implementations may do extra work to enable client side pak file loading + /// + /// + /// + /// Left if the game failed to launch. + /// Right if the game was launched successfully. + /// + Task> Launch(LaunchOptions options); +} + +public abstract record UnchainedLaunchFailure(string Message, int Code, Option Underlying) : Expected(Message, Code, Underlying) { + public record InjectionFailedError() : UnchainedLaunchFailure("Failed to inject process with unchained code.", 0, None); + public record LaunchFailedError(LaunchFailed Failure) : UnchainedLaunchFailure(Failure.Message, 0, Failure.Underlying); + public record DependencyDownloadFailedError(IEnumerable DependencyNames) + : UnchainedLaunchFailure($"Failed to download dependencies '{string.Join("', '", DependencyNames)}'", 0, None); + public record LaunchCancelledError() : UnchainedLaunchFailure($"Launch Cancelled", 0, None); + public record MissingArgumentsError() : UnchainedLaunchFailure("Necessary modded launch arguments not provided.", 0, None); + + + public static UnchainedLaunchFailure InjectionFailed() => + new InjectionFailedError(); + public static UnchainedLaunchFailure LaunchFailed(LaunchFailed failure) => new LaunchFailedError(failure); + public static UnchainedLaunchFailure DependencyDownloadFailed(IEnumerable dependencyNames) => + new DependencyDownloadFailedError(dependencyNames); + + public static UnchainedLaunchFailure LaunchCancelled() => new LaunchCancelledError(); + public static UnchainedLaunchFailure MissingArguments() => new MissingArgumentsError(); + + public LaunchFailed AsLaunchFailed(string args) => new LaunchFailed("unknown", args, this); +} + +public record LaunchOptions( + IEnumerable EnabledReleases, + Option ServerBrowserBackend, + string LaunchArgs, + bool CheckForDependencyUpdates, //TODO: remove this property + bool AllowUnstablePluginReleases, + Option SavedDirSuffix, + Option ServerLaunchOptions +) { + public IReadOnlyList EnabledDlls { get; init; } = []; + public IReadOnlyList ToCLIArgs() { + var args = new List { + new RawArgs(LaunchArgs), + new UEParameter("-saveddirsuffix", SavedDirSuffix.IfNone("Unchained")), + new Flag("-unchained") + }; + + ServerLaunchOptions.IfSome(opts => args.AddRange(opts.ToCLIArgs())); + ServerBrowserBackend.IfSome(backend => args.Add(new Parameter("--server-browser-backend", backend))); + + return args; + } +}; + +public enum CensorArg { + None, + Standard, + Sex, + Zealous +} + +public record DiscordIntegrationLaunchOptions( + string BotToken, + Option AdminChannelId, + Option GeneralChannelId, + Option DashboardChannelId, + Option EventLogChannelId, + Option AdminRoleId, + bool MentionAdmins +) { + public IReadOnlyList ToCLIArgs() { + var args = new List { + new Parameter("--discord-bot-token", BotToken) + }; + + AdminChannelId.IfSome(id => args.Add(new Parameter("--discord-admin-channel-id", id))); + GeneralChannelId.IfSome(id => args.Add(new Parameter("--discord-general-channel-id", id))); + DashboardChannelId.IfSome(id => args.Add(new Parameter("--discord-dashboard-channel-id", id))); + EventLogChannelId.IfSome(id => args.Add(new Parameter("--discord-event-log-channel-id", id))); + AdminRoleId.IfSome(id => args.Add(new Parameter("--discord-admin-role-id", id))); + + if (!MentionAdmins) { + args.Add(new Parameter("--discord-mention-admins", "false")); + } + + return args; + } +} + +public record ServerLaunchOptions( + bool Headless, + string Name, + string Description, + bool RegisterWithBackend, + Option Password, + string Map, + int GamePort, + int BeaconPort, + int QueryPort, + int RconPort, + int? FFATimeLimit, + int? FFAScoreLimit, + int? TDMTimeLimit, + int? TDMTicketCount, + int? PlayerBotCount, + int? WarmupTime, + Option LocalIp, + IEnumerable ServerMods, + Option DiscordIntegration, + bool DesyncPatch, + bool UseBackendBanlist, + CensorArg CensorMode +) { + public IReadOnlyList ToCLIArgs() { + var args = new List() { + new UEINIParameter("Game", "/Script/TBL.TBLGameMode", "ServerName", Name), + new Parameter("--next-map-name", Map), + new UEParameter("Port", GamePort.ToString()), + new UEParameter("GameServerPingPort", BeaconPort.ToString()), + new UEParameter("GameServerQueryPort", QueryPort.ToString()), + new Parameter("-rcon", RconPort.ToString()), + new Parameter("--server-browser-description", Description), + new Parameter("--motd", Description), + new Parameter("--censor-mode", CensorMode.ToString().ToLower()) + }; + + if (Headless) { + args.Add(new Flag("-nullrhi")); + args.Add(new Flag("-unattended")); + args.Add(new Flag("-nosound")); + } + + if (RegisterWithBackend) + args.Add(new Flag("--register")); + + if (UseBackendBanlist) + args.Add(new Flag("--use-backend-banlist")); + + Password.IfSome(password => args.Add(new UEParameter("ServerPassword", password.Trim()))); + + if (ServerMods.Any()) + args.Add(new Parameter("--server-mods", string.Join(",", ServerMods.Distinct()))); + + LocalIp.IfSome(ip => args.Add(new Parameter("--local-ip", ip))); + + FFAScoreLimit.IfSome(limit => args.Add(new UEMapUrlParameter("FFAScoreLimit", limit.ToString()))); + FFATimeLimit.IfSome(limit => args.Add(new UEMapUrlParameter("FFATimeLimit", limit.ToString()))); + TDMTimeLimit.IfSome(limit => args.Add(new UEMapUrlParameter("TDMTimeLimit", limit.ToString()))); + TDMTicketCount.IfSome(count => args.Add(new UEMapUrlParameter("TDMTicketCount", count.ToString()))); + PlayerBotCount.IfSome(count => args.Add(new UEMapUrlParameter("NumPlayerBots", count.ToString()))); + WarmupTime.IfSome(time => args.Add(new UEMapUrlParameter("WarmupTime", time.ToString()))); + + DiscordIntegration.IfSome(discord => args.AddRange(discord.ToCLIArgs())); + + if (DesyncPatch) + args.Add(new Flag("--desync-patch")); + + return args; + } +}; + + +public abstract record CLIArg(string Rendered); + +public record RawArgs(string Args) : CLIArg(Args); +public record Flag(string FlagName) : CLIArg(FlagName); +public record Parameter(string ParamName, string Value) : CLIArg($"{ParamName} {ArgumentEscaper.Escape(Value)}"); +public record UEParameter(string ParamName, string Value) : CLIArg($"{ParamName}={ArgumentEscaper.Escape(Value)}"); +public record UEINIParameter(string Type, string Section, string Key, string Value) : CLIArg($"-ini:{Type}:[{Section}]:{Key}={ArgumentEscaper.Escape(Value)}"); public record UEMapUrlParameter(string Key, string Value) : CLIArg($"?{Key}={Value}"); \ No newline at end of file diff --git a/UnchainedLauncher.Core/Services/Processes/UnsafeDllLoading.cs b/UnchainedLauncher.Core/Services/Processes/UnsafeDllLoading.cs new file mode 100644 index 00000000..b53c9a23 --- /dev/null +++ b/UnchainedLauncher.Core/Services/Processes/UnsafeDllLoading.cs @@ -0,0 +1,39 @@ +using System.CommandLine; + +namespace UnchainedLauncher.Core.Services.Processes; + +/// Process-local command-line permission, never a persisted setting. +public static class UnsafeDllLoading { + public const string FlagName = "--unsafe-very-dangerous-load-all-dlls"; + + public static Option CreateOption(bool recursive = false) { + var option = new Option(FlagName) { Hidden = true, Arity = ArgumentArity.Zero, Recursive = recursive }; + return option; + } + + public static bool Read(ParseResult parsed, Option option, IReadOnlyList commandLine) { + if (parsed.GetResult(option) is not { } result) return false; + if (result.IdentifierTokenCount == 0) return false; + // System.CommandLine tokenizes --flag=true as --flag plus a positional + // token. Permission requires the original literal flag, not that form. + if (!commandLine.Contains(FlagName, StringComparer.Ordinal) || result.IdentifierTokenCount != 1 || result.Tokens.Count != 0) + throw new ArgumentException($"{FlagName} must be supplied once as a value-less command-line flag."); + var errors = parsed.Errors.Where(error => error.SymbolResult == result).Select(error => error.Message).ToArray(); + if (errors.Length != 0) throw new ArgumentException(string.Join(Environment.NewLine, errors)); + return parsed.GetValue(option); + } + + public static (bool Enabled, string[] RemainingArguments) ParseGuiArguments(string[] args) { + var separator = Array.IndexOf(args, "--"); + var prefix = separator < 0 ? args : args[..separator]; + var root = new RootCommand { TreatUnmatchedTokensAsErrors = false }; + var option = CreateOption(); + root.Options.Add(option); + var parsed = root.Parse(prefix, new ParserConfiguration { ResponseFileTokenReplacer = null }); + var enabled = Read(parsed, option, prefix); + // Only a successfully parsed, exact permission token is consumed; game/auth + // arguments (including those after --) remain byte-for-byte unchanged. + var remaining = enabled ? prefix.Where(argument => argument != FlagName) : prefix; + return (enabled, remaining.Concat(separator < 0 ? [] : args[separator..]).ToArray()); + } +} \ No newline at end of file diff --git a/UnchainedLauncher.Core/UnchainedLauncher.Core.csproj b/UnchainedLauncher.Core/UnchainedLauncher.Core.csproj index 2d117cbf..aa9895e6 100644 --- a/UnchainedLauncher.Core/UnchainedLauncher.Core.csproj +++ b/UnchainedLauncher.Core/UnchainedLauncher.Core.csproj @@ -28,6 +28,7 @@ + diff --git a/UnchainedLauncher.GUI/App.xaml.cs b/UnchainedLauncher.GUI/App.xaml.cs index 218ba54a..816592b7 100644 --- a/UnchainedLauncher.GUI/App.xaml.cs +++ b/UnchainedLauncher.GUI/App.xaml.cs @@ -37,10 +37,13 @@ namespace UnchainedLauncher.GUI { /// public partial class App : Application { private static readonly ILog _log = LogManager.GetLogger(typeof(App)); + private bool _unsafeLoadAllDlls; + private string[] _launchArguments = []; protected override async void OnStartup(StartupEventArgs e) { try { base.OnStartup(e); + (_unsafeLoadAllDlls, _launchArguments) = UnsafeDllLoading.ParseGuiArguments(e.Args); AppDomain.CurrentDomain.UnhandledException += (sender, args) => { @@ -263,8 +266,7 @@ private SettingsVM InitializeSettingsViewModel(IChivalry2InstallationFinder inst RegistryWindowVM registryWindowViewModel, RegistryWindowService registryWindowService, ModManager modManager, MessageBoxSpawner userDialogueSpawner) { var cliArgs = string.Join(" ", - Environment.GetCommandLineArgs() - .Skip(1) + _launchArguments .ToList() .Select(ArgumentEscaper.Escape) ); @@ -421,4 +423,4 @@ private void ExecuteExitAction(string source, Action exitAction) { } } } -} \ No newline at end of file +} From 343f46f4880f99b3fc6756f7ca9779ec78078841 Mon Sep 17 00:00:00 2001 From: Jacob Barber Date: Tue, 13 Oct 2026 08:48:46 -0500 Subject: [PATCH 13/58] Initialize server CLI profiles before launch --- .../ServerProfileTests.cs | 81 +++++++++++++++++++ UnchainedLauncher.CLI/README.md | 5 +- UnchainedLauncher.CLI/ServerLauncher.cs | 24 ++++-- 3 files changed, 104 insertions(+), 6 deletions(-) create mode 100644 UnchainedLauncher.CLI.Tests/ServerProfileTests.cs diff --git a/UnchainedLauncher.CLI.Tests/ServerProfileTests.cs b/UnchainedLauncher.CLI.Tests/ServerProfileTests.cs new file mode 100644 index 00000000..2f0a9ad7 --- /dev/null +++ b/UnchainedLauncher.CLI.Tests/ServerProfileTests.cs @@ -0,0 +1,81 @@ +using Xunit; + +namespace UnchainedLauncher.CLI.Tests; + +public sealed class ServerProfileTests : IDisposable { + private readonly string _directory = Path.Combine( + Path.GetTempPath(), "server-profile-tests-" + Guid.NewGuid().ToString("N")); + + public void Dispose() { + if (Directory.Exists(_directory)) Directory.Delete(_directory, recursive: true); + } + + private CliLaunchOptions Request(string command) => CliLaunchOptions.Parse([ + command, "--chivalry2-path", _directory, "--platform", "Steam", "--saved-dir-suffix", "ServerProfileTest", + .. command == "server" ? new[] { "--local-ip", "127.0.0.1" } : [] + ]); + + [Fact] + public void ServerCreatesStartupSettingsInTheSelectedProfile() { + ServerLauncher.InitializeServerProfile(Request("server"), suffix => { + Assert.Equal("ServerProfileTest", suffix); + return _directory; + }); + + var ini = File.ReadAllText(Path.Combine(_directory, "Game.ini")); + Assert.Contains("[/Script/TBL.TBLTitleScreen]", ini); + Assert.Contains("bSavedHasAgreedToTOS=True", ini); + Assert.Contains("[/Script/TBL.TBLGameInstance]", ini); + Assert.Contains("FirstLoadCompleted=True", ini); + Assert.Single(Directory.GetFiles(_directory)); + } + + [Fact] + public void ServerUpdatesStartupSettingsWithoutChangingOtherSettingsAndIsIdempotent() { + Directory.CreateDirectory(_directory); + var path = Path.Combine(_directory, "Game.ini"); + File.WriteAllText(path, """ + ; custom server settings + [/Script/TBL.TBLGameMode] + ServerName=Keep me + MapList=FFA_Courtyard + MapList=FFA_Wardenglade + [/Script/TBL.TBLTitleScreen] + bSavedHasAgreedToTOS=False + CustomTitleSetting=42 + [/Script/TBL.TBLGameInstance] + FirstLoadCompleted=False + CustomInstanceSetting=Keep this too + """); + File.WriteAllText(Path.Combine(_directory, "Engine.ini"), "engine untouched"); + File.WriteAllText(Path.Combine(_directory, "GameUserSettings.ini"), "player untouched"); + + ServerLauncher.InitializeServerProfile(Request("server"), _ => _directory); + var ini = File.ReadAllText(path); + Assert.Contains("bSavedHasAgreedToTOS=True", ini); + Assert.Contains("FirstLoadCompleted=True", ini); + Assert.DoesNotContain("=False", ini); + foreach (var setting in new[] { "; custom server settings", "ServerName=Keep me", "MapList=FFA_Courtyard", + "MapList=FFA_Wardenglade", "CustomTitleSetting=42", "CustomInstanceSetting=Keep this too" }) + Assert.Contains(setting, ini); + Assert.Equal("engine untouched", File.ReadAllText(Path.Combine(_directory, "Engine.ini"))); + Assert.Equal("player untouched", File.ReadAllText(Path.Combine(_directory, "GameUserSettings.ini"))); + + ServerLauncher.InitializeServerProfile(Request("server"), _ => _directory); + Assert.Equal(ini, File.ReadAllText(path)); + } + + [Fact] + public void ClientDoesNotResolveOrCreateAProfile() { + ServerLauncher.InitializeServerProfile(Request("client"), _ => throw new InvalidOperationException("Client must not initialize a profile.")); + Assert.False(Directory.Exists(_directory)); + } + + [Fact] + public void ServerPropagatesProfileWriteFailure() { + Directory.CreateDirectory(_directory); + var blocked = Path.Combine(_directory, "blocked"); + File.WriteAllText(blocked, "not a directory"); + Assert.ThrowsAny(() => ServerLauncher.InitializeServerProfile(Request("server"), _ => blocked)); + } +} diff --git a/UnchainedLauncher.CLI/README.md b/UnchainedLauncher.CLI/README.md index 66228f80..d4efa050 100644 --- a/UnchainedLauncher.CLI/README.md +++ b/UnchainedLauncher.CLI/README.md @@ -195,7 +195,10 @@ preferring one with a default gateway; it falls back to another usable IPv4 or l Set `local-ip` only to override this choice (for example, with a VPN or multiple adapters). Time/score/ticket limits, player bot count, and warmup are **INI settings**, not part of either CLI schema; configure them in the selected saved profile. -No INI files are rewritten; other existing saved-profile INI settings still apply. +Before a server launch, the CLI initializes the selected profile's `Game.ini` with +`bSavedHasAgreedToTOS=True` and `FirstLoadCompleted=True`, matching the GUI's startup +defaults so first-run screens do not block unattended startup. Other existing INI +settings are preserved. Client launches do not initialize or rewrite INI files. Discord configuration is nested under the server: diff --git a/UnchainedLauncher.CLI/ServerLauncher.cs b/UnchainedLauncher.CLI/ServerLauncher.cs index b4e3e357..ae417923 100644 --- a/UnchainedLauncher.CLI/ServerLauncher.cs +++ b/UnchainedLauncher.CLI/ServerLauncher.cs @@ -1,5 +1,7 @@ using System.Diagnostics; -using System.Net; +using System.Net; +using StructuredINI; +using UnchainedLauncher.Core.INIModels.Game; using UnchainedLauncher.Core.JsonModels; using UnchainedLauncher.Core.Services; using UnchainedLauncher.Core.Services.Mods; @@ -64,8 +66,9 @@ public static async Task LaunchAsync(CliLaunchOptions request, Cancella cancellationToken.ThrowIfCancellationRequested(); if (updated.IsNone || (await signing.PrepareLaunch(LanguageExt.Unit.Default)).IsNone) return LanguageExt.Option.None; - cancellationToken.ThrowIfCancellationRequested(); - return updated; + cancellationToken.ThrowIfCancellationRequested(); + InitializeServerProfile(request); + return updated; }); var launcher = new UnchainedChivalry2Launcher(preparer, new ProcessLauncher(executable), options.Chivalry2Path, pluginUpdater); @@ -77,8 +80,19 @@ public static async Task LaunchAsync(CliLaunchOptions request, Cancella }); } - internal static AggregateModRegistry CreateRegistry(CliLaunchOptions options) => - options.Shared.RegistryOptions.CreateRegistry(); + internal static AggregateModRegistry CreateRegistry(CliLaunchOptions options) => + options.Shared.RegistryOptions.CreateRegistry(); + + internal static void InitializeServerProfile(CliLaunchOptions request, Func? configPath = null) { + if (request is not ServerOptions) return; + + var directory = (configPath ?? FilePaths.Chiv2ConfigPath)(request.Shared.SavedDirSuffix); + // Match the GUI's startup defaults without rewriting unrelated server or player settings. + var writer = new StructuredINIWriter(); + if (!writer.BufferWrite(new TBLTitleScreen()) || !writer.BufferWrite(new TBLGameInstance()) || + !writer.WriteOut(Path.Combine(directory, "Game.ini"))) + throw new IOException("Could not initialize the server INI profile; refusing to launch."); + } // Several existing Core preparers use installation-relative paths. // This standalone CLI sets CWD only for preparation, then restores it. From 510f886e52f941f92e7e424f69e71cf5d28af175 Mon Sep 17 00:00:00 2001 From: Jacoby6000 <2672832+Jacoby6000@users.noreply.github.com> Date: Tue, 13 Oct 2026 13:50:32 +0000 Subject: [PATCH 14/58] [GHA] Format Source --- .../DllManagerTests.cs | 38 +++++------ .../DllQueryAndLoadingTests.cs | 6 +- .../ServerProfileTests.cs | 2 +- UnchainedLauncher.CLI/CliCommandOptions.cs | 2 +- UnchainedLauncher.CLI/CliLaunchOptions.cs | 20 +++--- UnchainedLauncher.CLI/CommandLineOptions.cs | 68 +++++++++---------- UnchainedLauncher.CLI/DllQuery.cs | 2 +- UnchainedLauncher.CLI/ModQuery.cs | 2 +- UnchainedLauncher.CLI/ModQueryDefinition.cs | 7 +- UnchainedLauncher.CLI/Program.cs | 2 +- UnchainedLauncher.CLI/RegistryOptions.cs | 6 +- .../RegistryQueryConfiguration.cs | 2 +- UnchainedLauncher.CLI/ServerLauncher.cs | 41 ++++++----- UnchainedLauncher.CLI/YamlConfiguration.cs | 22 +++--- .../JsonModels/DllMetadata.cs | 2 +- .../Services/Mods/DllManager.cs | 28 ++++---- .../Mods/Registry/AggregateModRegistry.cs | 2 +- .../Mods/Registry/GithubModRegistry.cs | 2 +- .../Services/Mods/Registry/IModRegistry.cs | 10 +-- .../Services/Mods/Registry/JsonRegistry.cs | 2 +- 20 files changed, 135 insertions(+), 131 deletions(-) diff --git a/UnchainedLauncher.CLI.Tests/DllManagerTests.cs b/UnchainedLauncher.CLI.Tests/DllManagerTests.cs index 8f24188d..02f66a9d 100644 --- a/UnchainedLauncher.CLI.Tests/DllManagerTests.cs +++ b/UnchainedLauncher.CLI.Tests/DllManagerTests.cs @@ -1,5 +1,5 @@ -using System.Security.Cryptography; using LanguageExt; +using System.Security.Cryptography; using UnchainedLauncher.Core.JsonModels; using UnchainedLauncher.Core.JsonModels.ModMetadata; using UnchainedLauncher.Core.Services.Mods; @@ -45,28 +45,28 @@ public void DependenciesAreOrderedBeforeDependentsAndExplicitPinsAreHonored() { } [Fact] - public void WrongTargetsMissingDependenciesAndCyclesFailClosed() { + public void WrongTargetsMissingDependenciesAndCyclesFailClosed() { var core = Release("Chiv2-Community/UnchainedPlugin", target: "server"); Assert.Throws(() => DllManager.Resolve([Package(core)], [], false, false)); core = core with { Info = core.Info with { Target = "shared", PluginDependencies = [new Dependency("https://github.com/org/missing", "1.0.0")] } }; Assert.Throws(() => DllManager.Resolve([Package(core)], [], false, false)); var dependency = Release("org/missing", dependencies: [new Dependency("https://github.com/Chiv2-Community/UnchainedPlugin", "1.0.0")]); - Assert.Throws(() => DllManager.Resolve([Package(core), Package(dependency)], [], false, false)); - } - - [Fact] - public void ConflictingPinsAndUnapprovedAutomaticPrereleasesAreRejected() { - var core = Release("Chiv2-Community/UnchainedPlugin"); - var newer = core with { Tag = "v2.0.0" }; - Assert.Throws(() => DllManager.Resolve([Package(core, newer)], - [new ReleaseCoordinates("Chiv2-Community", "UnchainedPlugin", core.Tag), - new ReleaseCoordinates("Chiv2-Community", "UnchainedPlugin", newer.Tag)], false, false)); - var prerelease = core with { Tag = "v3.0.0-rc.1" }; - Assert.Throws(() => DllManager.Resolve([Package(prerelease)], [], false, false)); - Assert.Equal(prerelease, Assert.Single(DllManager.Resolve([Package(prerelease)], [], false, true))); - Assert.Equal(prerelease, Assert.Single(DllManager.Resolve([Package(prerelease)], - [new ReleaseCoordinates("Chiv2-Community", "UnchainedPlugin", prerelease.Tag)], false, false))); - } + Assert.Throws(() => DllManager.Resolve([Package(core), Package(dependency)], [], false, false)); + } + + [Fact] + public void ConflictingPinsAndUnapprovedAutomaticPrereleasesAreRejected() { + var core = Release("Chiv2-Community/UnchainedPlugin"); + var newer = core with { Tag = "v2.0.0" }; + Assert.Throws(() => DllManager.Resolve([Package(core, newer)], + [new ReleaseCoordinates("Chiv2-Community", "UnchainedPlugin", core.Tag), + new ReleaseCoordinates("Chiv2-Community", "UnchainedPlugin", newer.Tag)], false, false)); + var prerelease = core with { Tag = "v3.0.0-rc.1" }; + Assert.Throws(() => DllManager.Resolve([Package(prerelease)], [], false, false)); + Assert.Equal(prerelease, Assert.Single(DllManager.Resolve([Package(prerelease)], [], false, true))); + Assert.Equal(prerelease, Assert.Single(DllManager.Resolve([Package(prerelease)], + [new ReleaseCoordinates("Chiv2-Community", "UnchainedPlugin", prerelease.Tag)], false, false))); + } [Theory] [InlineData("../x.dll")] @@ -180,4 +180,4 @@ public async Task DownloadsAreVerifiedBeforePublicationAndUnknownRootFilesArePre } finally { Directory.Delete(root, recursive: true); } } -} +} \ No newline at end of file diff --git a/UnchainedLauncher.CLI.Tests/DllQueryAndLoadingTests.cs b/UnchainedLauncher.CLI.Tests/DllQueryAndLoadingTests.cs index a35f339b..ddcf00dc 100644 --- a/UnchainedLauncher.CLI.Tests/DllQueryAndLoadingTests.cs +++ b/UnchainedLauncher.CLI.Tests/DllQueryAndLoadingTests.cs @@ -66,8 +66,8 @@ public void GuiUnsafeOptionIsHiddenAndResponseFilesCannotGrantPermission() { var flag = UnsafeDllLoading.CreateOption(); root.Options.Add(flag); Assert.True(flag.Hidden); - Assert.False(UnsafeDllLoading.ParseGuiArguments(["@not-a-permission-file", "--startunchained"]).Enabled); - Assert.False(UnsafeDllLoading.ParseGuiArguments(["-AUTH_PASSWORD=" + UnsafeDllLoading.FlagName]).Enabled); + Assert.False(UnsafeDllLoading.ParseGuiArguments(["@not-a-permission-file", "--startunchained"]).Enabled); + Assert.False(UnsafeDllLoading.ParseGuiArguments(["-AUTH_PASSWORD=" + UnsafeDllLoading.FlagName]).Enabled); } [Fact] @@ -169,4 +169,4 @@ public async Task InvalidNullMetadataIsReportedAsCatalogError(string body) { Assert.Single(catalog.Errors); Assert.Empty(catalog.Packages); } -} +} \ No newline at end of file diff --git a/UnchainedLauncher.CLI.Tests/ServerProfileTests.cs b/UnchainedLauncher.CLI.Tests/ServerProfileTests.cs index 2f0a9ad7..d7f2af16 100644 --- a/UnchainedLauncher.CLI.Tests/ServerProfileTests.cs +++ b/UnchainedLauncher.CLI.Tests/ServerProfileTests.cs @@ -78,4 +78,4 @@ public void ServerPropagatesProfileWriteFailure() { File.WriteAllText(blocked, "not a directory"); Assert.ThrowsAny(() => ServerLauncher.InitializeServerProfile(Request("server"), _ => blocked)); } -} +} \ No newline at end of file diff --git a/UnchainedLauncher.CLI/CliCommandOptions.cs b/UnchainedLauncher.CLI/CliCommandOptions.cs index 974604c3..70f968ef 100644 --- a/UnchainedLauncher.CLI/CliCommandOptions.cs +++ b/UnchainedLauncher.CLI/CliCommandOptions.cs @@ -3,4 +3,4 @@ namespace UnchainedLauncher.CLI; public abstract class CliCommandOptions { public bool Help { get; protected init; } public string HelpText { get; protected init; } = ""; -} +} \ No newline at end of file diff --git a/UnchainedLauncher.CLI/CliLaunchOptions.cs b/UnchainedLauncher.CLI/CliLaunchOptions.cs index 214af85e..1232b0df 100644 --- a/UnchainedLauncher.CLI/CliLaunchOptions.cs +++ b/UnchainedLauncher.CLI/CliLaunchOptions.cs @@ -15,11 +15,11 @@ public sealed class SharedLaunchOptions { public InstallationType Platform { get; } public string Backend { get; } public bool UpdatePlugin { get; } - public bool AllowUnstablePlugin { get; } - public bool UnsafeLoadAllDlls { get; private set; } - internal void EnableUnsafeDllLoading() => UnsafeLoadAllDlls = true; - public RegistryOptions RegistryOptions { get; } - public IReadOnlyList Registries => RegistryOptions.Registries; + public bool AllowUnstablePlugin { get; } + public bool UnsafeLoadAllDlls { get; private set; } + internal void EnableUnsafeDllLoading() => UnsafeLoadAllDlls = true; + public RegistryOptions RegistryOptions { get; } + public IReadOnlyList Registries => RegistryOptions.Registries; public IReadOnlyList Mods { get; } public IReadOnlyList Dlls { get; } public IReadOnlyList GameArguments { get; } @@ -33,7 +33,7 @@ internal SharedLaunchOptions(LaunchConfiguration configuration, string directory AllowUnstablePlugin = configuration.AllowUnstablePlugin; GameArguments = Entries(configuration.GameArgs, allowEmpty: true).ToArray(); var setup = configuration.ModSetup ?? new ModSetupConfiguration(); - RegistryOptions = new RegistryOptions(setup.Registries, directory); + RegistryOptions = new RegistryOptions(setup.Registries, directory); Mods = Entries(setup.Mods).Select(ParseCoordinates).ToArray(); Dlls = Entries(setup.Dlls).Select(ParseCoordinates).ToArray(); if (!Regex.IsMatch(SavedDirSuffix, @"\A[A-Za-z0-9_-]+\z")) @@ -55,7 +55,7 @@ public static ReleaseCoordinates ParseCoordinates(string value) { } } -public abstract class CliLaunchOptions : CliCommandOptions { +public abstract class CliLaunchOptions : CliCommandOptions { private readonly SharedLaunchOptions? _shared; public SharedLaunchOptions Shared => _shared ?? throw new InvalidOperationException("Help is not a launch request."); public IReadOnlyList GameArguments { get; } @@ -71,8 +71,8 @@ private protected CliLaunchOptions(string helpText) { GameArguments = []; } - public static CliLaunchOptions Parse(string[] args) => CommandLineOptions.Parse(args) as CliLaunchOptions - ?? throw new ArgumentException("Expected a client or server launch command."); + public static CliLaunchOptions Parse(string[] args) => CommandLineOptions.Parse(args) as CliLaunchOptions + ?? throw new ArgumentException("Expected a client or server launch command."); internal static CliLaunchOptions FromConfiguration(LaunchConfiguration configuration, string directory) { var shared = new SharedLaunchOptions(configuration, directory); @@ -145,4 +145,4 @@ public LanguageExt.Option ToDiscordOptions() { Settings.DesyncPatch, Settings.UseBackendBanlist, Settings.CensorMode); public override LaunchOptions ToLaunchOptions() => BuildLaunchOptions(["TBL", "-server"], Some(ToServerLaunchOptions())); -} +} \ No newline at end of file diff --git a/UnchainedLauncher.CLI/CommandLineOptions.cs b/UnchainedLauncher.CLI/CommandLineOptions.cs index e123cdf8..466f2641 100644 --- a/UnchainedLauncher.CLI/CommandLineOptions.cs +++ b/UnchainedLauncher.CLI/CommandLineOptions.cs @@ -1,42 +1,42 @@ using System.CommandLine; using System.CommandLine.Help; -using UnchainedLauncher.Core.JsonModels; -using UnchainedLauncher.Core.Services.Processes; +using UnchainedLauncher.Core.JsonModels; +using UnchainedLauncher.Core.Services.Processes; namespace UnchainedLauncher.CLI; internal static class CommandLineOptions { - public static CliCommandOptions Parse(string[] args) => Parse(args, static (root, tokens, configuration) => root.Parse(tokens, configuration)); + public static CliCommandOptions Parse(string[] args) => Parse(args, static (root, tokens, configuration) => root.Parse(tokens, configuration)); // The delegate is the single library-parse boundary, also exercised by tests. - internal static CliCommandOptions Parse(string[] args, Func parse) { + internal static CliCommandOptions Parse(string[] args, Func parse) { var separator = Array.IndexOf(args, "--"); var launcherArgs = separator < 0 ? args : args[..separator]; var gameArgs = separator < 0 ? [] : args[(separator + 1)..]; - var root = new RootCommand("Unchained client/server launcher and mod registry queries. Only launching and DLL injection require Windows."); + var root = new RootCommand("Unchained client/server launcher and mod registry queries. Only launching and DLL injection require Windows."); var config = new Option("--config-path") { - Description = "Launch YAML overrides launch options; mod-query reads its registry settings.", + Description = "Launch YAML overrides launch options; mod-query reads its registry settings.", Arity = ArgumentArity.ExactlyOne, Recursive = true }; - root.Options.Add(config); - var unsafeDlls = UnsafeDllLoading.CreateOption(recursive: true); - root.Options.Add(unsafeDlls); + root.Options.Add(config); + var unsafeDlls = UnsafeDllLoading.CreateOption(recursive: true); + root.Options.Add(unsafeDlls); var server = new Definition(client: false); var client = new Definition(client: true); root.Subcommands.Add(server.Command); - root.Subcommands.Add(client.Command); - var queries = new ModQueryDefinition(); - root.Subcommands.Add(queries.Command); - var dllQueries = new ModQueryDefinition(RegistryPackageKind.Dll); - root.Subcommands.Add(dllQueries.Command); + root.Subcommands.Add(client.Command); + var queries = new ModQueryDefinition(); + root.Subcommands.Add(queries.Command); + var dllQueries = new ModQueryDefinition(RegistryPackageKind.Dll); + root.Subcommands.Add(dllQueries.Command); // Never interpret @values as file reads, including ignored YAML overrides. // Response-file expansion is deliberately disabled for all launcher input. - var parsed = parse(root, launcherArgs, new ParserConfiguration { ResponseFileTokenReplacer = null }); - var loadAllDlls = UnsafeDllLoading.Read(parsed, unsafeDlls, launcherArgs); + var parsed = parse(root, launcherArgs, new ParserConfiguration { ResponseFileTokenReplacer = null }); + var loadAllDlls = UnsafeDllLoading.Read(parsed, unsafeDlls, launcherArgs); var definition = parsed.CommandResult.Command == server.Command ? server : client; - var isQuery = queries.Contains(parsed.CommandResult.Command) || dllQueries.Contains(parsed.CommandResult.Command); - if (!isQuery && parsed.GetResult(config) is { } configResult) { + var isQuery = queries.Contains(parsed.CommandResult.Command) || dllQueries.Contains(parsed.CommandResult.Command); + if (!isQuery && parsed.GetResult(config) is { } configResult) { if (parsed.CommandResult.Command == root) throw new ArgumentException("Expected the 'server' or 'client' subcommand."); // Help actions skip library validators. Check the parsed selector itself // so --help cannot bypass its singleton/non-empty contract either. @@ -46,9 +46,9 @@ internal static CliCommandOptions Parse(string[] args, Func error.SymbolResult == configResult); ThrowErrors(errors.Select(error => error.Message)); - var configured = YamlConfiguration.Load(parsed.GetValue(config)!, definition.Command == client.Command); - if (loadAllDlls) configured.Shared.EnableUnsafeDllLoading(); - return configured; + var configured = YamlConfiguration.Load(parsed.GetValue(config)!, definition.Command == client.Command); + if (loadAllDlls) configured.Shared.EnableUnsafeDllLoading(); + return configured; } if (args.Length == 0 || parsed.GetResult("--help") is not null) { using var output = new StringWriter(); @@ -57,16 +57,16 @@ internal static CliCommandOptions Parse(string[] args, Func= 0) throw new ArgumentException("mod-query does not accept forwarded game arguments."); - return (dllQueries.Contains(parsed.CommandResult.Command) ? dllQueries : queries).ToOptions(parsed, config); - } + ThrowErrors(parsed); + if (isQuery) { + if (loadAllDlls) throw new ArgumentException("Unsafe DLL loading is only valid for launch commands."); + if (separator >= 0) throw new ArgumentException("mod-query does not accept forwarded game arguments."); + return (dllQueries.Contains(parsed.CommandResult.Command) ? dllQueries : queries).ToOptions(parsed, config); + } if (parsed.CommandResult.Command == root) throw new ArgumentException("Expected the 'server' or 'client' subcommand."); - var launch = definition.ToOptions(parsed, gameArgs); - if (loadAllDlls) launch.Shared.EnableUnsafeDllLoading(); - return launch; + var launch = definition.ToOptions(parsed, gameArgs); + if (loadAllDlls) launch.Shared.EnableUnsafeDllLoading(); + return launch; } private static void ThrowErrors(ParseResult result) { @@ -105,10 +105,10 @@ public Definition(bool client) { Flag("--no-plugin-update", "Use the existing UnchainedPlugin.dll.", value => _configuration.UpdatePlugin = !value); Flag("--allow-unstable-plugin", "Allow prerelease plugin updates.", value => _configuration.AllowUnstablePlugin = value); _configuration.ModSetup = new ModSetupConfiguration(); - var registries = RegistryOptions.AddOption(Command); - _bindings.Add(result => _configuration.ModSetup.Registries = [.. result.GetValue(registries) ?? []]); + var registries = RegistryOptions.AddOption(Command); + _bindings.Add(result => _configuration.ModSetup.Registries = [.. result.GetValue(registries) ?? []]); Many("--mod", "Install organization/id:version (exact release tag).", value => _configuration.ModSetup.Mods = value); - Many("--dll", "Select a registry DLL release: organization/id:version (repeatable).", value => _configuration.ModSetup.Dlls = value); + Many("--dll", "Select a registry DLL release: organization/id:version (repeatable).", value => _configuration.ModSetup.Dlls = value); if (_configuration is ServerConfiguration serverConfig) { var settings = serverConfig.Server!; Add("--name", "Server name.", settings.Name, value => settings.Name = value); @@ -183,4 +183,4 @@ public CliLaunchOptions ToOptions(ParseResult parsed, string[] gameArgs) { return CliLaunchOptions.FromConfiguration(_configuration, Directory.GetCurrentDirectory()); } } -} +} \ No newline at end of file diff --git a/UnchainedLauncher.CLI/DllQuery.cs b/UnchainedLauncher.CLI/DllQuery.cs index 15955184..10d8059b 100644 --- a/UnchainedLauncher.CLI/DllQuery.cs +++ b/UnchainedLauncher.CLI/DllQuery.cs @@ -44,4 +44,4 @@ public static async Task RunAsync(ModQueryOptions options, AggregateModRegi } return catalog.Errors.Count == 0 ? 0 : 1; } -} +} \ No newline at end of file diff --git a/UnchainedLauncher.CLI/ModQuery.cs b/UnchainedLauncher.CLI/ModQuery.cs index 62851891..7c097858 100644 --- a/UnchainedLauncher.CLI/ModQuery.cs +++ b/UnchainedLauncher.CLI/ModQuery.cs @@ -58,4 +58,4 @@ public static async Task RunAsync(ModQueryOptions options, IModRegistry reg } internal static string Id(ModIdentifier id) => Line(id.Org + "/" + id.ModuleName); internal static string Line(string value) => new(value.Select(character => char.IsControl(character) ? ' ' : character).ToArray()); -} +} \ No newline at end of file diff --git a/UnchainedLauncher.CLI/ModQueryDefinition.cs b/UnchainedLauncher.CLI/ModQueryDefinition.cs index 989dd83a..02b2a404 100644 --- a/UnchainedLauncher.CLI/ModQueryDefinition.cs +++ b/UnchainedLauncher.CLI/ModQueryDefinition.cs @@ -69,8 +69,11 @@ public ModQueryOptions ToOptions(ParseResult parsed, Option config) { identifier = new ModIdentifier(coordinate.Org, coordinate.ModuleName); } return new ModQueryOptions { - Kind = _kind, Operation = operation, Mod = identifier, Version = parsed.GetValue(_version), + Kind = _kind, + Operation = operation, + Mod = identifier, + Version = parsed.GetValue(_version), RegistryOptions = new RegistryOptions(specifications, directory) }; } -} +} \ No newline at end of file diff --git a/UnchainedLauncher.CLI/Program.cs b/UnchainedLauncher.CLI/Program.cs index 59bf69e9..2b475e06 100644 --- a/UnchainedLauncher.CLI/Program.cs +++ b/UnchainedLauncher.CLI/Program.cs @@ -66,4 +66,4 @@ internal static async Task SuperviseAsync(CliLaunchOptions options, Process } } } -} +} \ No newline at end of file diff --git a/UnchainedLauncher.CLI/RegistryOptions.cs b/UnchainedLauncher.CLI/RegistryOptions.cs index 94971831..1bc776a1 100644 --- a/UnchainedLauncher.CLI/RegistryOptions.cs +++ b/UnchainedLauncher.CLI/RegistryOptions.cs @@ -16,9 +16,11 @@ public RegistryOptions(IEnumerable? specifications, string directory) => internal static Option AddOption(Command command, bool recursive = false) { var option = new Option("--add-registry") { Description = "Additional github:owner/repo, local:directory, or config:file registry; official registry is always included.", - Arity = ArgumentArity.OneOrMore, AllowMultipleArgumentsPerToken = false, Recursive = recursive + Arity = ArgumentArity.OneOrMore, + AllowMultipleArgumentsPerToken = false, + Recursive = recursive }; command.Options.Add(option); return option; } -} +} \ No newline at end of file diff --git a/UnchainedLauncher.CLI/RegistryQueryConfiguration.cs b/UnchainedLauncher.CLI/RegistryQueryConfiguration.cs index 985fd7dc..3ec8f7a1 100644 --- a/UnchainedLauncher.CLI/RegistryQueryConfiguration.cs +++ b/UnchainedLauncher.CLI/RegistryQueryConfiguration.cs @@ -22,4 +22,4 @@ public sealed class RegistryQueryModSetup { public List? Registries { get; set; } [YamlConverter(typeof(IgnoredYamlNodeConverter))] public object? Mods { get; set; } [YamlConverter(typeof(IgnoredYamlNodeConverter))] public object? Dlls { get; set; } -} +} \ No newline at end of file diff --git a/UnchainedLauncher.CLI/ServerLauncher.cs b/UnchainedLauncher.CLI/ServerLauncher.cs index ae417923..99e09351 100644 --- a/UnchainedLauncher.CLI/ServerLauncher.cs +++ b/UnchainedLauncher.CLI/ServerLauncher.cs @@ -1,9 +1,8 @@ +using StructuredINI; using System.Diagnostics; -using System.Net; -using StructuredINI; -using UnchainedLauncher.Core.INIModels.Game; +using System.Net; +using UnchainedLauncher.Core.INIModels.Game; using UnchainedLauncher.Core.JsonModels; -using UnchainedLauncher.Core.Services; using UnchainedLauncher.Core.Services.Mods; using UnchainedLauncher.Core.Services.Mods.Registry; using UnchainedLauncher.Core.Services.Processes; @@ -66,9 +65,9 @@ public static async Task LaunchAsync(CliLaunchOptions request, Cancella cancellationToken.ThrowIfCancellationRequested(); if (updated.IsNone || (await signing.PrepareLaunch(LanguageExt.Unit.Default)).IsNone) return LanguageExt.Option.None; - cancellationToken.ThrowIfCancellationRequested(); - InitializeServerProfile(request); - return updated; + cancellationToken.ThrowIfCancellationRequested(); + InitializeServerProfile(request); + return updated; }); var launcher = new UnchainedChivalry2Launcher(preparer, new ProcessLauncher(executable), options.Chivalry2Path, pluginUpdater); @@ -80,19 +79,19 @@ public static async Task LaunchAsync(CliLaunchOptions request, Cancella }); } - internal static AggregateModRegistry CreateRegistry(CliLaunchOptions options) => - options.Shared.RegistryOptions.CreateRegistry(); - - internal static void InitializeServerProfile(CliLaunchOptions request, Func? configPath = null) { - if (request is not ServerOptions) return; - - var directory = (configPath ?? FilePaths.Chiv2ConfigPath)(request.Shared.SavedDirSuffix); - // Match the GUI's startup defaults without rewriting unrelated server or player settings. - var writer = new StructuredINIWriter(); - if (!writer.BufferWrite(new TBLTitleScreen()) || !writer.BufferWrite(new TBLGameInstance()) || - !writer.WriteOut(Path.Combine(directory, "Game.ini"))) - throw new IOException("Could not initialize the server INI profile; refusing to launch."); - } + internal static AggregateModRegistry CreateRegistry(CliLaunchOptions options) => + options.Shared.RegistryOptions.CreateRegistry(); + + internal static void InitializeServerProfile(CliLaunchOptions request, Func? configPath = null) { + if (request is not ServerOptions) return; + + var directory = (configPath ?? FilePaths.Chiv2ConfigPath)(request.Shared.SavedDirSuffix); + // Match the GUI's startup defaults without rewriting unrelated server or player settings. + var writer = new StructuredINIWriter(); + if (!writer.BufferWrite(new TBLTitleScreen()) || !writer.BufferWrite(new TBLGameInstance()) || + !writer.WriteOut(Path.Combine(directory, "Game.ini"))) + throw new IOException("Could not initialize the server INI profile; refusing to launch."); + } // Several existing Core preparers use installation-relative paths. // This standalone CLI sets CWD only for preparation, then restores it. @@ -133,4 +132,4 @@ public static IA2S CreateQuery(ServerOptions options) { if (address.Equals(IPAddress.IPv6Any)) address = IPAddress.IPv6Loopback; return new A2S(new IPEndPoint(address, options.Settings.QueryPort)); } -} +} \ No newline at end of file diff --git a/UnchainedLauncher.CLI/YamlConfiguration.cs b/UnchainedLauncher.CLI/YamlConfiguration.cs index 7f8b2cbf..a531d4f6 100644 --- a/UnchainedLauncher.CLI/YamlConfiguration.cs +++ b/UnchainedLauncher.CLI/YamlConfiguration.cs @@ -6,14 +6,14 @@ namespace UnchainedLauncher.CLI; public static class YamlConfiguration { - public static CliLaunchOptions Load(string path, bool client) { - LaunchConfiguration configuration = client ? LoadDocument(path) : LoadDocument(path); - try { configuration.Validate(); } - catch (ValidationException ex) { throw new ArgumentException("Invalid configuration: " + ex.Message); } - return CliLaunchOptions.FromConfiguration(configuration, Path.GetDirectoryName(Path.GetFullPath(path))!); - } - - internal static T LoadDocument(string path) where T : class { + public static CliLaunchOptions Load(string path, bool client) { + LaunchConfiguration configuration = client ? LoadDocument(path) : LoadDocument(path); + try { configuration.Validate(); } + catch (ValidationException ex) { throw new ArgumentException("Invalid configuration: " + ex.Message); } + return CliLaunchOptions.FromConfiguration(configuration, Path.GetDirectoryName(Path.GetFullPath(path))!); + } + + internal static T LoadDocument(string path) where T : class { path = Path.GetFullPath(path); try { using var reader = File.OpenText(path); @@ -24,9 +24,9 @@ internal static T LoadDocument(string path) where T : class { .WithDuplicateKeyChecking() .WithEnforceNullability() .Build(); - var configuration = deserializer.Deserialize(reader); + var configuration = deserializer.Deserialize(reader); if (configuration is null) throw new ArgumentException("Configuration must contain one YAML mapping."); - return configuration; + return configuration; } catch (YamlException ex) { // Library errors can include passwords/tokens; report only location. @@ -39,4 +39,4 @@ internal static T LoadDocument(string path) where T : class { throw new ArgumentException($"Cannot read YAML configuration: {path}"); } } -} +} \ No newline at end of file diff --git a/UnchainedLauncher.Core/JsonModels/DllMetadata.cs b/UnchainedLauncher.Core/JsonModels/DllMetadata.cs index e3298326..55486aa8 100644 --- a/UnchainedLauncher.Core/JsonModels/DllMetadata.cs +++ b/UnchainedLauncher.Core/JsonModels/DllMetadata.cs @@ -1,5 +1,5 @@ -using System.Text.Json.Serialization; using Semver; +using System.Text.Json.Serialization; using UnchainedLauncher.Core.JsonModels.ModMetadata; using UnchainedLauncher.Core.Services.Mods.Registry; diff --git a/UnchainedLauncher.Core/Services/Mods/DllManager.cs b/UnchainedLauncher.Core/Services/Mods/DllManager.cs index f0a8410a..b5b8ce72 100644 --- a/UnchainedLauncher.Core/Services/Mods/DllManager.cs +++ b/UnchainedLauncher.Core/Services/Mods/DllManager.cs @@ -1,6 +1,6 @@ -using System.Security.Cryptography; using LanguageExt; using Semver; +using System.Security.Cryptography; using UnchainedLauncher.Core.JsonModels; using UnchainedLauncher.Core.Services.Mods.Registry; using UnchainedLauncher.Core.Services.Processes; @@ -112,18 +112,18 @@ public async Task> PrepareLaunch(LaunchOptions options) { .OrderByDescending(r => SemVersion.Parse(r.Tag, SemVersionStyles.AllowV)).FirstOrDefault() ?? throw new InvalidOperationException($"DLL pak dependency unavailable: {id.Org}/{id.ModuleName}"); var coordinate = ReleaseCoordinates.FromRelease(release); - if (!_mods.EnableModRelease(coordinate)) throw new InvalidOperationException("Could not enable DLL pak dependency."); - } - var resolvedPaks = _mods.GetEnabledAndDependencyReleases().ToArray(); - foreach (var dependency in releases.SelectMany(r => r.Info.ModDependencies)) { - var id = ModIdentifier.FromDependency(dependency); - var matches = resolvedPaks.Where(r => r.Info.Organization == id.Org && r.Info.RepoName == id.ModuleName).ToArray(); - if (matches.Length == 0 || matches.Any(r => r.Version is null || !SatisfiesVersion(r.Version, dependency.Version))) - throw new InvalidOperationException("Resolved pak dependencies do not satisfy DLL requirements."); - } - if (resolvedPaks.GroupBy(r => (r.Info.Organization, r.Info.RepoName)).Any(g => g.Select(r => r.Tag).Distinct().Count() > 1)) - throw new InvalidOperationException("Resolved pak dependencies contain conflicting versions."); - var paths = releases.Select(r => SafeFileName(r.DllFileName)).ToArray(); + if (!_mods.EnableModRelease(coordinate)) throw new InvalidOperationException("Could not enable DLL pak dependency."); + } + var resolvedPaks = _mods.GetEnabledAndDependencyReleases().ToArray(); + foreach (var dependency in releases.SelectMany(r => r.Info.ModDependencies)) { + var id = ModIdentifier.FromDependency(dependency); + var matches = resolvedPaks.Where(r => r.Info.Organization == id.Org && r.Info.RepoName == id.ModuleName).ToArray(); + if (matches.Length == 0 || matches.Any(r => r.Version is null || !SatisfiesVersion(r.Version, dependency.Version))) + throw new InvalidOperationException("Resolved pak dependencies do not satisfy DLL requirements."); + } + if (resolvedPaks.GroupBy(r => (r.Info.Organization, r.Info.RepoName)).Any(g => g.Select(r => r.Tag).Distinct().Count() > 1)) + throw new InvalidOperationException("Resolved pak dependencies contain conflicting versions."); + var paths = releases.Select(r => SafeFileName(r.DllFileName)).ToArray(); if (paths.Distinct(StringComparer.OrdinalIgnoreCase).Count() != paths.Length) throw new InvalidOperationException("DLL filename collision."); Directory.CreateDirectory(_directory); for (var i = 0; i < releases.Count; i++) { @@ -194,4 +194,4 @@ public bool Inject(System.Diagnostics.Process process) { if (!MatchesFileAsync(_paths[i], _preparedReleases[i]).GetAwaiter().GetResult()) return false; return new DllInjector(_directory, _unsafe, _paths).Inject(process); } -} +} \ No newline at end of file diff --git a/UnchainedLauncher.Core/Services/Mods/Registry/AggregateModRegistry.cs b/UnchainedLauncher.Core/Services/Mods/Registry/AggregateModRegistry.cs index b6f396e7..7ba39ec9 100644 --- a/UnchainedLauncher.Core/Services/Mods/Registry/AggregateModRegistry.cs +++ b/UnchainedLauncher.Core/Services/Mods/Registry/AggregateModRegistry.cs @@ -159,4 +159,4 @@ private IEnumerable DeduplicateMods(IEnumerable allMods) { ).Values.ToList(); } } -} +} \ No newline at end of file diff --git a/UnchainedLauncher.Core/Services/Mods/Registry/GithubModRegistry.cs b/UnchainedLauncher.Core/Services/Mods/Registry/GithubModRegistry.cs index 059ac305..1e243d6a 100644 --- a/UnchainedLauncher.Core/Services/Mods/Registry/GithubModRegistry.cs +++ b/UnchainedLauncher.Core/Services/Mods/Registry/GithubModRegistry.cs @@ -77,4 +77,4 @@ private string GetGithubPakDownloadUrl(Release release) => catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) { return null; } } } -} +} \ No newline at end of file diff --git a/UnchainedLauncher.Core/Services/Mods/Registry/IModRegistry.cs b/UnchainedLauncher.Core/Services/Mods/Registry/IModRegistry.cs index a4cd2c66..b01a265a 100644 --- a/UnchainedLauncher.Core/Services/Mods/Registry/IModRegistry.cs +++ b/UnchainedLauncher.Core/Services/Mods/Registry/IModRegistry.cs @@ -101,10 +101,10 @@ public interface IModRegistry { /// /// /// - public EitherAsync GetModRelease(ReleaseCoordinates coords); - - /// Returns pak length without downloading the pak, or null if unavailable. - public Task GetPakSize(Release release, CancellationToken cancellationToken = default) => Task.FromResult(null); + public EitherAsync GetModRelease(ReleaseCoordinates coords); + + /// Returns pak length without downloading the pak, or null if unavailable. + public Task GetPakSize(Release release, CancellationToken cancellationToken = default) => Task.FromResult(null); /// /// Acquires a single-use pak writer that owns the registry input stream. @@ -148,4 +148,4 @@ Func packageListRetrievalFunc _ => throw new ArgumentOutOfRangeException() }; } -} +} \ No newline at end of file diff --git a/UnchainedLauncher.Core/Services/Mods/Registry/JsonRegistry.cs b/UnchainedLauncher.Core/Services/Mods/Registry/JsonRegistry.cs index b12bdad3..f65cdb00 100644 --- a/UnchainedLauncher.Core/Services/Mods/Registry/JsonRegistry.cs +++ b/UnchainedLauncher.Core/Services/Mods/Registry/JsonRegistry.cs @@ -47,4 +47,4 @@ public EitherAsync GetModRelease(ReleaseCoor .Bind(maybeRelease => maybeRelease.ToEitherAsync(() => RegistryMetadataException.NotFound(coords, None))); } -} +} \ No newline at end of file From 31c91c77f79b8041182738864056b0e8b5d9b49e Mon Sep 17 00:00:00 2001 From: Jacob Barber Date: Tue, 13 Oct 2026 15:19:51 -0500 Subject: [PATCH 15/58] Use Plugin Manager DLL validation and cache for CLI launches --- .github/workflows/ci.yml | 4 + .../ArgumentBoundaryTests.cs | 4 +- .../DllManagerTests.cs | 89 +- .../DllQueryAndLoadingTests.cs | 6 +- .../FileWriterTests.cs | 4 +- .../ManagedInventoryTests.cs | 4 +- UnchainedLauncher.CLI.Tests/ModQueryTests.cs | 4 +- .../RegistryAndReleaseTests.cs | 4 +- .../YamlConfigurationTests.cs | 4 +- UnchainedLauncher.CLI/ModQuery.cs | 123 +-- UnchainedLauncher.CLI/README.md | 22 +- UnchainedLauncher.CLI/ServerLauncher.cs | 4 +- .../JsonModels/DllMetadata.cs | 9 + .../Services/Dlls/DllManager.cs | 884 +++++++++--------- .../Services/Mods/DllLaunchSession.cs | 164 ++++ .../Services/Mods/DllManager.cs | 197 ---- .../Services/Mods/Registry/DllCatalog.cs | 110 ++- .../Services/Processes/DllInjector.cs | 312 ++++--- .../DllManagerVMTests.cs | 14 +- .../RegistryReleaseFormVMTests.cs | 2 +- UnchainedLauncher.GUI/App.xaml.cs | 850 ++++++++--------- 21 files changed, 1456 insertions(+), 1358 deletions(-) create mode 100644 UnchainedLauncher.Core/Services/Mods/DllLaunchSession.cs delete mode 100644 UnchainedLauncher.Core/Services/Mods/DllManager.cs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d7978ac5..a3b86f38 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,6 +8,7 @@ on: branches: - 'main' - 'series/*' + - 'feature/dll-validation' env: DOTNET_VERSION: '10.x' @@ -59,6 +60,9 @@ jobs: - name: Run CLI Tests run: dotnet test -c ${{ matrix.configuration }} .\UnchainedLauncher.CLI.Tests --logger:"console;verbosity=detailed" + - name: Run Plugin Manager Tests + run: dotnet test -c ${{ matrix.configuration }} .\UnchainedLauncher.GUI.Tests.Unit --logger:"console;verbosity=detailed" + - name: Package CLI binaries run: dotnet publish UnchainedLauncher.CLI -c ${{ matrix.configuration }} -r win-x64 --self-contained true -o out-cli/ diff --git a/UnchainedLauncher.CLI.Tests/ArgumentBoundaryTests.cs b/UnchainedLauncher.CLI.Tests/ArgumentBoundaryTests.cs index 0c0c56e8..28e33992 100644 --- a/UnchainedLauncher.CLI.Tests/ArgumentBoundaryTests.cs +++ b/UnchainedLauncher.CLI.Tests/ArgumentBoundaryTests.cs @@ -11,7 +11,7 @@ namespace UnchainedLauncher.CLI.Tests; public sealed class ArgumentBoundaryTests : IDisposable { private readonly string _directory = Path.Combine( - OperatingSystem.IsLinux() ? "/tmp/opencode" : Path.GetTempPath(), "argument-tests-" + Guid.NewGuid().ToString("N")); + Environment.GetEnvironmentVariable("TMPDIR") ?? (OperatingSystem.IsLinux() ? "/tmp/opencode" : Path.GetTempPath()), "argument-tests-" + Guid.NewGuid().ToString("N")); private static string Probe => Path.Combine(AppContext.BaseDirectory, "argument-probe", OperatingSystem.IsWindows() ? "ArgumentProbe.exe" : "ArgumentProbe"); @@ -120,4 +120,4 @@ private static async Task ReadResult(Process process, string output) { private sealed class NoOpInjector : IProcessInjector { public bool Inject(Process process) => true; } -} \ No newline at end of file +} diff --git a/UnchainedLauncher.CLI.Tests/DllManagerTests.cs b/UnchainedLauncher.CLI.Tests/DllManagerTests.cs index 02f66a9d..b1ea9709 100644 --- a/UnchainedLauncher.CLI.Tests/DllManagerTests.cs +++ b/UnchainedLauncher.CLI.Tests/DllManagerTests.cs @@ -7,12 +7,13 @@ using UnchainedLauncher.Core.Services.Processes.Chivalry; using UnchainedLauncher.Core.Utilities; using Xunit; +using DllManager = UnchainedLauncher.Core.Services.Mods.DllLaunchSession; namespace UnchainedLauncher.CLI.Tests; public sealed class DllManagerTests { private static DllRelease Release(string id, string version = "v1.0.0", string target = "shared", params Dependency[] dependencies) => - new(version, Convert.ToHexString(SHA256.HashData([1, 2, 3])), "Plugin.dll", "https://example.test/plugin.dll", 3, + new(version, Convert.ToHexString(SHA256.HashData([1, 2, 3])).ToLowerInvariant(), "Plugin.dll", "https://example.test/plugin.dll", 3, DateTimeOffset.UtcNow, new DllInfo { RepoUrl = "https://github.com/" + id, Name = id, @@ -77,9 +78,11 @@ [new ReleaseCoordinates("Chiv2-Community", "UnchainedPlugin", core.Tag), public void UnsafeRegistryFileNamesAreRejected(string name) => Assert.Throws(() => DllManager.SafeFileName(name)); - [Fact] - public async Task ExistingFilesRequireRegistryHashAndSizeAndInventoryRecordsApprovedSelection() { - var root = Path.Combine(OperatingSystem.IsLinux() ? "/tmp/opencode" : Path.GetTempPath(), "dll-manager-" + Guid.NewGuid().ToString("N")); + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ExistingFilesRequireRegistryHashAndSizeAndInventoryRecordsApprovedSelection(bool legacyCli) { + var root = Path.Combine(Environment.GetEnvironmentVariable("TMPDIR") ?? (OperatingSystem.IsLinux() ? "/tmp/opencode" : Path.GetTempPath()), "dll-manager-" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(root); try { var release = Release("Chiv2-Community/UnchainedPlugin") with { DllFileName = "UnchainedPlugin.dll" }; @@ -88,15 +91,23 @@ public async Task ExistingFilesRequireRegistryHashAndSizeAndInventoryRecordsAppr File.WriteAllText(Path.Combine(packagePath, "UnchainedPlugin.json"), JsonHelpers.Serialize(Package(release))); var registry = new AggregateModRegistry(new LocalModRegistry(root)); var mods = new ModManager(registry, new PakDir(root, []), []); - var manager = new DllManager(registry, mods, root); + var plugins = Path.Combine(root, "Plugins"); + Directory.CreateDirectory(plugins); + var manager = new DllManager(registry, mods, plugins); var options = new LaunchOptions([], Option.None, "", false, false, Option.None, Option.None); - var plugin = Path.Combine(root, "UnchainedPlugin.dll"); + var plugin = legacyCli + ? Path.Combine(plugins, ".dll-managed", release.Hash, "UnchainedPlugin.dll") + : Path.Combine(plugins, "Managed", "Chiv2-Community", "UnchainedPlugin", release.Hash, "UnchainedPlugin.dll"); + Directory.CreateDirectory(Path.GetDirectoryName(plugin)!); await File.WriteAllBytesAsync(plugin, [1, 2, 3]); Assert.True((await manager.PrepareLaunch(options)).IsSome); - Assert.Contains(release.Hash, await File.ReadAllTextAsync(Path.Combine(root, "dll_manager.json"))); + plugin = Assert.Single(Directory.GetFiles(plugins, "*.dll", SearchOption.AllDirectories)); + Assert.Contains(release.Hash, await File.ReadAllTextAsync(Path.Combine(plugins, "dll_manager.json"))); + Assert.Equal(Path.Combine(plugins, "Managed", "Chiv2-Community", "UnchainedPlugin", "UnchainedPlugin.dll"), plugin); await File.WriteAllBytesAsync(plugin, [3, 2, 1]); Assert.False(await DllManager.MatchesFileAsync(plugin, release)); Assert.False(manager.Inject(new System.Diagnostics.Process())); + await File.WriteAllBytesAsync(Path.Combine(root, ".cache", "dlls", release.Hash.ToLowerInvariant(), release.DllFileName), [3, 2, 1]); await Assert.ThrowsAsync(() => manager.PrepareLaunch(options)); Assert.False(manager.Inject(new System.Diagnostics.Process())); Assert.Equal(new byte[] { 3, 2, 1 }, await File.ReadAllBytesAsync(plugin)); @@ -106,14 +117,51 @@ public async Task ExistingFilesRequireRegistryHashAndSizeAndInventoryRecordsAppr private sealed class DownloadHandler(byte[] bytes) : HttpMessageHandler { protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) => - Task.FromResult(new HttpResponseMessage(System.Net.HttpStatusCode.OK) { Content = new ByteArrayContent(bytes) }); + Task.FromResult(new HttpResponseMessage(System.Net.HttpStatusCode.OK) { RequestMessage = request, Content = new ByteArrayContent(bytes) }); + } + + [Fact] + public async Task SwitchingVersionsKeepsOnlySelectedDllInPluginsAndReusesExternalCache() { + var root = Path.Combine(Path.GetTempPath(), "dll-switch-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); + try { + var plugins = Path.Combine(root, "Plugins"); + var rc3 = Release("Chiv2-Community/UnchainedPlugin", "v1.0.0-RC3") with { DllFileName = "UnchainedPlugin.dll" }; + var rc2 = rc3 with { Tag = "v1.0.0-RC2", Hash = Convert.ToHexString(SHA256.HashData(new byte[] { 4, 5, 6 })).ToLowerInvariant() }; + var oldManagedPath = Path.Combine(plugins, ".dll-managed", rc2.Hash.ToLowerInvariant(), rc2.DllFileName); + Directory.CreateDirectory(Path.GetDirectoryName(oldManagedPath)!); + await File.WriteAllBytesAsync(oldManagedPath, [4, 5, 6]); + var packagePath = Path.Combine(root, "dll_db", "packages", "Chiv2-Community"); + Directory.CreateDirectory(packagePath); + File.WriteAllText(Path.Combine(packagePath, "UnchainedPlugin.json"), JsonHelpers.Serialize(Package(rc3, rc2))); + var registry = new AggregateModRegistry(new LocalModRegistry(root)); + var mods = new ModManager(registry, new PakDir(root, []), []); + var options = new LaunchOptions([], Option.None, "", true, false, Option.None, Option.None); + foreach (var (release, bytes, download) in new[] { + (rc3, new byte[] { 1, 2, 3 }, true), + (rc2, new byte[] { 4, 5, 6 }, true), + (rc3, Array.Empty(), false) + }) { + using var client = new HttpClient(new DownloadHandler(bytes)); + var manager = new DllManager(registry, mods, plugins, + [new ReleaseCoordinates("Chiv2-Community", "UnchainedPlugin", release.Tag)], client: client); + var launch = download ? options : new LaunchOptions([], Option.None, "", false, false, Option.None, Option.None); + Assert.True((await manager.PrepareLaunch(launch)).IsSome); + var active = Assert.Single(Directory.EnumerateFiles(plugins, "*.dll", SearchOption.AllDirectories)); + Assert.True(await DllManager.MatchesFileAsync(active, release)); + Assert.False(Directory.Exists(Path.Combine(plugins, ".cache"))); + } + Assert.Equal(2, Directory.EnumerateFiles(Path.Combine(root, ".cache"), "*.dll", SearchOption.AllDirectories).Count()); + Assert.False(File.Exists(oldManagedPath)); + } + finally { Directory.Delete(root, recursive: true); } } [Theory] [InlineData(false)] [InlineData(true)] public async Task PakDependenciesAreEnabledOrRejectedWhenExistingSelectionConflicts(bool conflict) { - var root = Path.Combine(OperatingSystem.IsLinux() ? "/tmp/opencode" : Path.GetTempPath(), "dll-pak-" + Guid.NewGuid().ToString("N")); + var root = Path.Combine(Environment.GetEnvironmentVariable("TMPDIR") ?? (OperatingSystem.IsLinux() ? "/tmp/opencode" : Path.GetTempPath()), "dll-pak-" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(root); try { var registryRoot = Path.Combine(root, "registry"); @@ -130,7 +178,9 @@ public async Task PakDependenciesAreEnabledOrRejectedWhenExistingSelectionConfli var metadata = Path.Combine(registryRoot, "org", "mod"); Directory.CreateDirectory(metadata); File.WriteAllText(Path.Combine(metadata, "mod.json"), JsonHelpers.Serialize(new Mod(info, pakReleases))); - await File.WriteAllBytesAsync(Path.Combine(plugins, "UnchainedPlugin.dll"), [1, 2, 3]); + var managed = Path.Combine(plugins, "Managed", "Chiv2-Community", "UnchainedPlugin", release.Hash, "UnchainedPlugin.dll"); + Directory.CreateDirectory(Path.GetDirectoryName(managed)!); + await File.WriteAllBytesAsync(managed, [1, 2, 3]); var registry = new AggregateModRegistry(new LocalModRegistry(registryRoot)); var mods = new ModManager(registry, new PakDir(root, []), conflict ? [new ReleaseCoordinates("org", "mod", "v2.0.0")] : []); var manager = new DllManager(registry, mods, plugins); @@ -149,34 +199,39 @@ public async Task PakDependenciesAreEnabledOrRejectedWhenExistingSelectionConfli [InlineData(true)] [InlineData(false)] public async Task DownloadsAreVerifiedBeforePublicationAndUnknownRootFilesArePreserved(bool valid) { - var root = Path.Combine(OperatingSystem.IsLinux() ? "/tmp/opencode" : Path.GetTempPath(), "dll-download-" + Guid.NewGuid().ToString("N")); + var root = Path.Combine(Environment.GetEnvironmentVariable("TMPDIR") ?? (OperatingSystem.IsLinux() ? "/tmp/opencode" : Path.GetTempPath()), "dll-download-" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(root); try { var release = Release("Chiv2-Community/UnchainedPlugin") with { DllFileName = "UnchainedPlugin.dll" }; var packagePath = Path.Combine(root, "dll_db", "packages", "Chiv2-Community"); Directory.CreateDirectory(packagePath); File.WriteAllText(Path.Combine(packagePath, "UnchainedPlugin.json"), JsonHelpers.Serialize(Package(release))); - var legacy = Path.Combine(root, "UnchainedPlugin.dll"); + var plugins = Path.Combine(root, "Plugins"); + Directory.CreateDirectory(plugins); + var legacy = Path.Combine(plugins, "UnchainedPlugin.dll"); await File.WriteAllBytesAsync(legacy, [9, 9, 9]); var registry = new AggregateModRegistry(new LocalModRegistry(root)); var mods = new ModManager(registry, new PakDir(root, []), []); using var client = new HttpClient(new DownloadHandler(valid ? [1, 2, 3] : [3, 2, 1])); - var manager = new DllManager(registry, mods, root, client: client); + var manager = new DllManager(registry, mods, plugins, client: client); var options = new LaunchOptions([], Option.None, "", true, false, Option.None, Option.None); - var destination = Path.Combine(root, ".dll-managed", release.Hash.ToLowerInvariant(), release.DllFileName); + var destination = Path.Combine(root, ".cache", "dlls", release.Hash.ToLowerInvariant(), release.DllFileName); if (valid) { Assert.True((await manager.PrepareLaunch(options)).IsSome); Assert.True(await DllManager.MatchesFileAsync(destination, release)); - Assert.True(File.Exists(Path.Combine(root, "dll_manager.json"))); + var installed = Assert.Single(Directory.GetFiles(Path.Combine(plugins, "Managed"), "*.dll", SearchOption.AllDirectories)); + Assert.True(await DllManager.MatchesFileAsync(installed, release)); + Assert.True(File.Exists(Path.Combine(plugins, "dll_manager.json"))); } else { await Assert.ThrowsAsync(() => manager.PrepareLaunch(options)); Assert.False(File.Exists(destination)); - Assert.False(File.Exists(Path.Combine(root, "dll_manager.json"))); + Assert.False(File.Exists(Path.Combine(plugins, "dll_manager.json"))); Assert.False(manager.Inject(new System.Diagnostics.Process())); + Assert.Equal(new byte[] { 9, 9, 9 }, await File.ReadAllBytesAsync(legacy)); } Assert.Equal(new byte[] { 9, 9, 9 }, await File.ReadAllBytesAsync(legacy)); - Assert.Empty(Directory.EnumerateFiles(root, ".dll-download-*")); + Assert.Empty(Directory.GetFiles(Path.Combine(root, ".cache", "dlls"), "*.tmp", SearchOption.AllDirectories)); } finally { Directory.Delete(root, recursive: true); } } diff --git a/UnchainedLauncher.CLI.Tests/DllQueryAndLoadingTests.cs b/UnchainedLauncher.CLI.Tests/DllQueryAndLoadingTests.cs index ddcf00dc..acda4bad 100644 --- a/UnchainedLauncher.CLI.Tests/DllQueryAndLoadingTests.cs +++ b/UnchainedLauncher.CLI.Tests/DllQueryAndLoadingTests.cs @@ -6,7 +6,7 @@ namespace UnchainedLauncher.CLI.Tests; public sealed class DllQueryAndLoadingTests : IDisposable { - private readonly string _directory = Path.Combine(OperatingSystem.IsLinux() ? "/tmp/opencode" : Path.GetTempPath(), "dll-query-" + Guid.NewGuid().ToString("N")); + private readonly string _directory = Path.Combine(Environment.GetEnvironmentVariable("TMPDIR") ?? (OperatingSystem.IsLinux() ? "/tmp/opencode" : Path.GetTempPath()), "dll-query-" + Guid.NewGuid().ToString("N")); public DllQueryAndLoadingTests() => Directory.CreateDirectory(_directory); public void Dispose() => Directory.Delete(_directory, recursive: true); @@ -76,7 +76,7 @@ public void DefaultInjectionSelectsOnlyRequiredPluginAndUnsafeSelectsAllRecursiv Directory.CreateDirectory(nested); foreach (var file in new[] { "UnchainedPlugin.dll", "Unexpected.dll", "not-a-dll.txt", "nested/Other.DLL" }) File.WriteAllText(Path.Combine(_directory, file), "not executed"); - Assert.Equal(new[] { Path.Combine(_directory, "UnchainedPlugin.dll") }, new DllInjector(_directory).GetDllPaths()); + Assert.Empty(new DllInjector(_directory).GetDllPaths()); Assert.Equal(3, new DllInjector(_directory, unsafeLoadAllDlls: true).GetDllPaths().Count()); Assert.Empty(new DllInjector(Path.Combine(_directory, "missing")).GetDllPaths()); } @@ -169,4 +169,4 @@ public async Task InvalidNullMetadataIsReportedAsCatalogError(string body) { Assert.Single(catalog.Errors); Assert.Empty(catalog.Packages); } -} \ No newline at end of file +} diff --git a/UnchainedLauncher.CLI.Tests/FileWriterTests.cs b/UnchainedLauncher.CLI.Tests/FileWriterTests.cs index 02d72e0f..174e9aaf 100644 --- a/UnchainedLauncher.CLI.Tests/FileWriterTests.cs +++ b/UnchainedLauncher.CLI.Tests/FileWriterTests.cs @@ -9,7 +9,7 @@ namespace UnchainedLauncher.CLI.Tests; public class FileWriterTests : IDisposable { private readonly string _directory = Path.Combine( - OperatingSystem.IsLinux() ? "/tmp/opencode" : Path.GetTempPath(), "writer-tests-" + Guid.NewGuid().ToString("N")); + Environment.GetEnvironmentVariable("TMPDIR") ?? (OperatingSystem.IsLinux() ? "/tmp/opencode" : Path.GetTempPath()), "writer-tests-" + Guid.NewGuid().ToString("N")); public FileWriterTests() => Directory.CreateDirectory(_directory); public void Dispose() => Directory.Delete(_directory, recursive: true); @@ -73,4 +73,4 @@ public override ValueTask ReadAsync(Memory buffer, CancellationToken return base.ReadAsync(buffer, cancellationToken); } } -} \ No newline at end of file +} diff --git a/UnchainedLauncher.CLI.Tests/ManagedInventoryTests.cs b/UnchainedLauncher.CLI.Tests/ManagedInventoryTests.cs index 55915e58..434fcd7e 100644 --- a/UnchainedLauncher.CLI.Tests/ManagedInventoryTests.cs +++ b/UnchainedLauncher.CLI.Tests/ManagedInventoryTests.cs @@ -11,7 +11,7 @@ namespace UnchainedLauncher.CLI.Tests; [Collection("Installation directory")] public class ManagedInventoryTests : IDisposable { - private readonly string _root = Path.Combine(OperatingSystem.IsLinux() ? "/tmp/opencode" : Path.GetTempPath(), + private readonly string _root = Path.Combine(Environment.GetEnvironmentVariable("TMPDIR") ?? (OperatingSystem.IsLinux() ? "/tmp/opencode" : Path.GetTempPath()), "inventory-tests-" + Guid.NewGuid().ToString("N")); public ManagedInventoryTests() => Directory.CreateDirectory(_root); @@ -173,4 +173,4 @@ await ServerLauncher.InInstallationDirectoryAsync(request, async () => { return 0; }); } -} \ No newline at end of file +} diff --git a/UnchainedLauncher.CLI.Tests/ModQueryTests.cs b/UnchainedLauncher.CLI.Tests/ModQueryTests.cs index 5c68ec4e..bb8c3c34 100644 --- a/UnchainedLauncher.CLI.Tests/ModQueryTests.cs +++ b/UnchainedLauncher.CLI.Tests/ModQueryTests.cs @@ -7,7 +7,7 @@ namespace UnchainedLauncher.CLI.Tests; public sealed class ModQueryTests : IDisposable { - private readonly string _directory = Path.Combine(OperatingSystem.IsLinux() ? "/tmp/opencode" : Path.GetTempPath(), "mod-query-" + Guid.NewGuid().ToString("N")); + private readonly string _directory = Path.Combine(Environment.GetEnvironmentVariable("TMPDIR") ?? (OperatingSystem.IsLinux() ? "/tmp/opencode" : Path.GetTempPath()), "mod-query-" + Guid.NewGuid().ToString("N")); public ModQueryTests() => Directory.CreateDirectory(_directory); public void Dispose() => Directory.Delete(_directory, recursive: true); @@ -205,4 +205,4 @@ private sealed class UnavailableRegistry : IModRegistry { public LanguageExt.EitherAsync GetModRelease(ReleaseCoordinates coordinate) => LanguageExt.Prelude.LeftAsync(Error); public LanguageExt.EitherAsync DownloadPak(ReleaseCoordinates coordinate, string output) => throw new InvalidOperationException("Queries must never download paks."); } -} \ No newline at end of file +} diff --git a/UnchainedLauncher.CLI.Tests/RegistryAndReleaseTests.cs b/UnchainedLauncher.CLI.Tests/RegistryAndReleaseTests.cs index 80e35270..a582a6f4 100644 --- a/UnchainedLauncher.CLI.Tests/RegistryAndReleaseTests.cs +++ b/UnchainedLauncher.CLI.Tests/RegistryAndReleaseTests.cs @@ -16,7 +16,7 @@ namespace UnchainedLauncher.CLI.Tests; [Collection("Installation directory")] public class RegistryAndReleaseTests : IDisposable { private readonly string _directory = Path.Combine( - OperatingSystem.IsLinux() ? "/tmp/opencode" : Path.GetTempPath(), "cli-tests-" + Guid.NewGuid().ToString("N")); + Environment.GetEnvironmentVariable("TMPDIR") ?? (OperatingSystem.IsLinux() ? "/tmp/opencode" : Path.GetTempPath()), "cli-tests-" + Guid.NewGuid().ToString("N")); public RegistryAndReleaseTests() => Directory.CreateDirectory(_directory); public void Dispose() => Directory.Delete(_directory, recursive: true); @@ -254,4 +254,4 @@ public Either Launch(string workingDirectory, string args } [CollectionDefinition("Installation directory", DisableParallelization = true)] -public class InstallationDirectoryCollection { } \ No newline at end of file +public class InstallationDirectoryCollection { } diff --git a/UnchainedLauncher.CLI.Tests/YamlConfigurationTests.cs b/UnchainedLauncher.CLI.Tests/YamlConfigurationTests.cs index 4ba70e6c..e26ccde6 100644 --- a/UnchainedLauncher.CLI.Tests/YamlConfigurationTests.cs +++ b/UnchainedLauncher.CLI.Tests/YamlConfigurationTests.cs @@ -8,7 +8,7 @@ namespace UnchainedLauncher.CLI.Tests; [Collection("Installation directory")] public class YamlConfigurationTests : IDisposable { private readonly string _directory = Path.Combine( - OperatingSystem.IsLinux() ? "/tmp/opencode" : Path.GetTempPath(), "yaml-cli-tests-" + Guid.NewGuid().ToString("N")); + Environment.GetEnvironmentVariable("TMPDIR") ?? (OperatingSystem.IsLinux() ? "/tmp/opencode" : Path.GetTempPath()), "yaml-cli-tests-" + Guid.NewGuid().ToString("N")); public YamlConfigurationTests() => Directory.CreateDirectory(_directory); public void Dispose() => Directory.Delete(_directory, recursive: true); @@ -504,4 +504,4 @@ public async Task ProductionSupervisionLeavesClientRunningButStopsServer(bool cl } } } -} \ No newline at end of file +} diff --git a/UnchainedLauncher.CLI/ModQuery.cs b/UnchainedLauncher.CLI/ModQuery.cs index 7c097858..63e5de53 100644 --- a/UnchainedLauncher.CLI/ModQuery.cs +++ b/UnchainedLauncher.CLI/ModQuery.cs @@ -1,61 +1,64 @@ -using System.Globalization; -using UnchainedLauncher.Core.JsonModels.ModMetadata; -using UnchainedLauncher.Core.Services.Mods.Registry; - -namespace UnchainedLauncher.CLI; - -public static class ModQuery { - public static async Task RunAsync(ModQueryOptions options, IModRegistry registry, TextWriter output, TextWriter error, CancellationToken cancellationToken) { - cancellationToken.ThrowIfCancellationRequested(); - if (options.Operation == ModQueryOperation.List) { - var catalog = await registry.GetAllMods().WaitAsync(cancellationToken); - await output.WriteLineAsync("MOD ID\tNAME\tLATEST VERSION"); - foreach (var mod in catalog.Mods.OrderBy(mod => Id(ModIdentifier.FromMod(mod)), StringComparer.Ordinal)) { - var latest = Latest(mod); - await output.WriteLineAsync($"{Id(ModIdentifier.FromMod(mod))}\t{Line(mod.LatestReleaseInfo.Name)}\t{Line(latest?.Tag ?? "none")}"); - } - foreach (var problem in catalog.Errors) await error.WriteLineAsync(problem.Message); - return catalog.HasErrors ? 1 : 0; - } - var result = await registry.GetMod(options.Mod!).ToEither().WaitAsync(cancellationToken); - if (result.IsLeft) { - await error.WriteLineAsync(result.Match(_ => "", problem => problem.Message)); - return 1; - } - var found = result.Match(mod => mod, _ => throw new InvalidOperationException()); - if (options.Operation == ModQueryOperation.Versions) { - await output.WriteLineAsync("VERSION\tRELEASE DATE\tCHANNEL"); - foreach (var release in found.Releases.OrderByDescending(release => release.Version).ThenByDescending(release => release.ReleaseDate)) - await output.WriteLineAsync($"{Line(release.Tag)}\t{release.ReleaseDate.ToString("O", CultureInfo.InvariantCulture)}\t{(release.Version?.IsRelease == true ? "stable" : "prerelease/non-semver")}"); - return 0; - } - var selected = options.Version is null ? Latest(found) : found.Releases.Find(release => release.Tag == options.Version); - if (selected is null) { - await error.WriteLineAsync($"Release not found: {Id(options.Mod!)}:{options.Version ?? "latest"}"); - return 1; - } - var size = await registry.GetPakSize(selected, cancellationToken); - await output.WriteLineAsync($"Mod ID: {Id(options.Mod!)}\nName: {Line(selected.Info.Name)}\nVersion: {Line(selected.Tag)}\nDescription: {Line(selected.Info.Description)}"); - await output.WriteLineAsync($"Authors: {string.Join(", ", selected.Info.Authors.Select(Line))}\nRepository: {Line(selected.Info.RepoUrl)}\nHomepage: {Line(selected.Info.HomePage ?? "none")}\nRelease date: {selected.ReleaseDate.ToString("O", CultureInfo.InvariantCulture)}"); - await output.WriteLineAsync($"Pak file: {Line(selected.PakFileName)}\nPak hash (SHA-512): {Line(selected.ReleaseHash)}\nSize: {(size is null ? "unknown (not available from registry/file)" : size.Value.ToString(CultureInfo.InvariantCulture) + " bytes")}"); - await output.WriteLineAsync($"Dependencies ({selected.Info.Dependencies.Count}):"); - foreach (var dependency in selected.Info.Dependencies) - await output.WriteLineAsync($" {Id(ModIdentifier.FromDependency(dependency))}: {Line(dependency.Version)}"); - await output.WriteLineAsync($"Maps ({selected.Manifest.Maps.Count}):"); - foreach (var map in selected.Manifest.Maps) - await output.WriteLineAsync($" {Line(map.MapName ?? map.Path)} | {Line(map.Path)} | mode: {Line(map.GameMode ?? map.GamemodeType ?? "unknown")}"); - await output.WriteLineAsync($"Blueprints ({selected.Manifest.Blueprints.Count}):"); - foreach (var blueprint in selected.Manifest.Blueprints) - await output.WriteLineAsync($" {Line(blueprint.ModName ?? blueprint.Path)} | {Line(blueprint.ClassPath ?? blueprint.Path)} | client-side: {blueprint.bClientside?.ToString() ?? "unknown"}"); - await output.WriteLineAsync($"Markers: {selected.Manifest.Markers.Count}\nReplacements: {selected.Manifest.Replacements.Count}\nOther assets: {selected.Manifest.Arbitrary.Count}\nRelease notes: {Line(selected.ReleaseNotesMarkdown ?? "none")}"); - return 0; - } - - private static Release? Latest(Mod mod) { - var stable = mod.LatestRelease; - return stable.IsSome ? stable.IfNone(() => throw new InvalidOperationException()) - : mod.Releases.OrderByDescending(release => release.ReleaseDate).FirstOrDefault(); - } - internal static string Id(ModIdentifier id) => Line(id.Org + "/" + id.ModuleName); - internal static string Line(string value) => new(value.Select(character => char.IsControl(character) ? ' ' : character).ToArray()); +using System.Globalization; +using UnchainedLauncher.Core.JsonModels.ModMetadata; +using UnchainedLauncher.Core.Services.Mods.Registry; + +namespace UnchainedLauncher.CLI; + +public static class ModQuery { + public static async Task RunAsync(ModQueryOptions options, IModRegistry registry, TextWriter output, TextWriter error, CancellationToken cancellationToken) { + cancellationToken.ThrowIfCancellationRequested(); + if (options.Operation == ModQueryOperation.List) { + var catalog = await registry.GetAllMods().WaitAsync(cancellationToken); + await output.WriteLineAsync("MOD ID\tNAME\tLATEST VERSION"); + foreach (var mod in catalog.Mods.OrderBy(mod => Id(ModIdentifier.FromMod(mod)), StringComparer.Ordinal)) { + var latest = Latest(mod); + await output.WriteLineAsync($"{Id(ModIdentifier.FromMod(mod))}\t{Line(mod.LatestReleaseInfo.Name)}\t{Line(latest?.Tag ?? "none")}"); + } + foreach (var problem in catalog.Errors) await error.WriteLineAsync(problem.Message); + return catalog.HasErrors ? 1 : 0; + } + var result = await registry.GetMod(options.Mod!).ToEither().WaitAsync(cancellationToken); + if (result.IsLeft) { + await error.WriteLineAsync(result.Match(_ => "", problem => problem.Message)); + return 1; + } + var found = result.Match(mod => mod, _ => throw new InvalidOperationException()); + if (options.Operation == ModQueryOperation.Versions) { + await output.WriteLineAsync("VERSION\tRELEASE DATE\tCHANNEL"); + foreach (var release in found.Releases.OrderByDescending(release => release.Version).ThenByDescending(release => release.ReleaseDate)) + await output.WriteLineAsync($"{Line(release.Tag)}\t{release.ReleaseDate.ToString("O", CultureInfo.InvariantCulture)}\t{(release.Version?.IsRelease == true ? "stable" : "prerelease/non-semver")}"); + return 0; + } + var selected = options.Version is null ? Latest(found) : found.Releases.Find(release => release.Tag == options.Version); + if (selected is null) { + await error.WriteLineAsync($"Release not found: {Id(options.Mod!)}:{options.Version ?? "latest"}"); + return 1; + } + var size = await registry.GetPakSize(selected, cancellationToken); + await output.WriteLineAsync($"Mod ID: {Id(options.Mod!)}\nName: {Line(selected.Info.Name)}\nVersion: {Line(selected.Tag)}\nDescription: {Line(selected.Info.Description)}"); + await output.WriteLineAsync($"Authors: {string.Join(", ", selected.Info.Authors.Select(Line))}\nRepository: {Line(selected.Info.RepoUrl)}\nHomepage: {Line(selected.Info.HomePage ?? "none")}\nRelease date: {selected.ReleaseDate.ToString("O", CultureInfo.InvariantCulture)}"); + await output.WriteLineAsync($"Pak file: {Line(selected.PakFileName)}\nPak hash (SHA-512): {Line(selected.ReleaseHash)}\nSize: {(size is null ? "unknown (not available from registry/file)" : size.Value.ToString(CultureInfo.InvariantCulture) + " bytes")}"); + await output.WriteLineAsync($"Dependencies ({selected.Info.EffectiveModDependencies.Count}):"); + foreach (var dependency in selected.Info.EffectiveModDependencies) + await output.WriteLineAsync($" {Id(ModIdentifier.FromDependency(dependency))}: {Line(dependency.Version)}"); + await output.WriteLineAsync($"Plugin dependencies ({selected.Info.EffectivePluginDependencies.Count}):"); + foreach (var dependency in selected.Info.EffectivePluginDependencies) + await output.WriteLineAsync($" {Id(ModIdentifier.FromDependency(dependency))}: {Line(dependency.Version)}"); + await output.WriteLineAsync($"Maps ({selected.Manifest.Maps.Count}):"); + foreach (var map in selected.Manifest.Maps) + await output.WriteLineAsync($" {Line(map.MapName ?? map.Path)} | {Line(map.Path)} | mode: {Line(map.GameMode ?? map.GamemodeType ?? "unknown")}"); + await output.WriteLineAsync($"Blueprints ({selected.Manifest.Blueprints.Count}):"); + foreach (var blueprint in selected.Manifest.Blueprints) + await output.WriteLineAsync($" {Line(blueprint.ModName ?? blueprint.Path)} | {Line(blueprint.ClassPath ?? blueprint.Path)} | client-side: {blueprint.bClientside?.ToString() ?? "unknown"}"); + await output.WriteLineAsync($"Markers: {selected.Manifest.Markers.Count}\nReplacements: {selected.Manifest.Replacements.Count}\nOther assets: {selected.Manifest.Arbitrary.Count}\nRelease notes: {Line(selected.ReleaseNotesMarkdown ?? "none")}"); + return 0; + } + + private static Release? Latest(Mod mod) { + var stable = mod.LatestRelease; + return stable.IsSome ? stable.IfNone(() => throw new InvalidOperationException()) + : mod.Releases.OrderByDescending(release => release.ReleaseDate).FirstOrDefault(); + } + internal static string Id(ModIdentifier id) => Line(id.Org + "/" + id.ModuleName); + internal static string Line(string value) => new(value.Select(character => char.IsControl(character) ? ' ' : character).ToArray()); } \ No newline at end of file diff --git a/UnchainedLauncher.CLI/README.md b/UnchainedLauncher.CLI/README.md index d4efa050..f9b69095 100644 --- a/UnchainedLauncher.CLI/README.md +++ b/UnchainedLauncher.CLI/README.md @@ -291,20 +291,26 @@ same installed mod set and separate saved suffixes/ports. ## DLL compatibility behavior -Both GUI and CLI resolve the required UnchainedPlugin release from `dll_db`. +GUI and CLI share DLL registry validation, managed storage, caching, and injection. +The CLI selects UnchainedPlugin from `dll_db` for each launch; GUI selections stay +in the Plugin Manager's saved profile. `--dll organization/id:version` (or YAML `mod-setup.dlls`) additionally selects registry releases in CLI launches. Plugin dependencies are resolved before their dependents; target mismatches, missing releases, cycles, and conflicting versions -fail preparation. Pak dependencies enter the shared mod manager. The GUI uses -this same manager for the core plugin; a GUI DLL-selection editor is not included. +fail preparation. Pak dependencies enter the shared mod manager. Every selected DLL must match the registry's SHA-256 and size. Downloads are -verified before installation. Existing matching root DLLs can be reused; -new downloads go into `Plugins/.dll-managed//` without replacing unknown -root DLLs. `Plugins/dll_manager.json` records the last successful selection. +verified before installation. Active DLLs use +`Plugins/Managed///`; inactive registered +versions use `Win64/.cache/dlls//`. Switching versions restores +validated cached files without downloading again. Registered files in historical +`Managed` hash directories or `.dll-managed` directories migrate after validation. +Loose DLLs, unknown filenames, and unregistered versions stay untouched and are +not loaded in safe mode. CLI selections never overwrite the GUI plugin profile. +`Plugins/dll_manager.json` records the last successful selection. Safe injection uses only that launch's verified allowlist, not the directory or -the saved inventory. Old managed files are retained, not automatically deleted. -`--no-plugin-update` forbids downloads and requires matching local files. +the saved inventory. The unsafe load-all option retains other DLLs for injection. +`--no-plugin-update` forbids downloads but permits restoring cached versions. Only explicitly pinned prereleases (or `--allow-unstable-plugin` for automatic selection) are eligible; automatic selection otherwise uses the latest stable. diff --git a/UnchainedLauncher.CLI/ServerLauncher.cs b/UnchainedLauncher.CLI/ServerLauncher.cs index 99e09351..5ee14db4 100644 --- a/UnchainedLauncher.CLI/ServerLauncher.cs +++ b/UnchainedLauncher.CLI/ServerLauncher.cs @@ -46,7 +46,7 @@ public static async Task LaunchAsync(CliLaunchOptions request, Cancella if (!File.Exists(appIdPath)) File.WriteAllText(appIdPath, "1824220"); } var dialogue = new ConsoleDialogue(); - var pluginUpdater = new DllManager(registry, manager, pluginsPath, options.Dlls, options.UnsafeLoadAllDlls, cancellationToken); + var pluginUpdater = new DllLaunchSession(registry, manager, pluginsPath, options.Dlls, options.UnsafeLoadAllDlls, cancellationToken); var modInstaller = new Chivalry2ModsInstaller(manager, dialogue); var signing = SigPreparer.Create(manager.PakDir, dialogue); // Call preparers sequentially so each receives the previous result. @@ -117,7 +117,7 @@ public static void ValidateReleases(IModManager manager, IEnumerable (r.Info.Organization, r.Info.RepoName)).Any(g => g.Select(r => r.Tag).Distinct().Count() > 1)) throw new ArgumentException("Dependency resolution selected conflicting versions of a mod. Core currently selects the latest stable dependencies, including Unchained-Mods."); foreach (var release in releases) { - foreach (var dependency in release.Info.Dependencies) { + foreach (var dependency in release.Info.EffectiveModDependencies) { if (manager.GetLatestRelease(ModIdentifier.FromDependency(dependency)).IsNone) throw new InvalidOperationException($"Missing stable dependency: {dependency.Organization}/{dependency.RepoName}"); if (!releases.Any(r => r.Info.Organization == dependency.Organization && r.Info.RepoName == dependency.RepoName)) diff --git a/UnchainedLauncher.Core/JsonModels/DllMetadata.cs b/UnchainedLauncher.Core/JsonModels/DllMetadata.cs index 55486aa8..a8f9affd 100644 --- a/UnchainedLauncher.Core/JsonModels/DllMetadata.cs +++ b/UnchainedLauncher.Core/JsonModels/DllMetadata.cs @@ -9,6 +9,7 @@ namespace UnchainedLauncher.Core.JsonModels; public sealed record DllPackage( [property: JsonPropertyName("latest_release_info"), JsonRequired] DllInfo LatestReleaseInfo, [property: JsonPropertyName("releases"), JsonRequired] List Releases) { + [JsonIgnore] public DllRelease? LatestRelease => Releases.Where(release => release.Version?.IsRelease == true) .OrderByDescending(release => release.Version).FirstOrDefault() ?? Releases.OrderByDescending(release => release.ReleaseDate).FirstOrDefault(); @@ -24,6 +25,14 @@ public sealed record DllRelease( [property: JsonPropertyName("info"), JsonRequired] DllInfo Info, [property: JsonPropertyName("release_notes_markdown")] string? ReleaseNotesMarkdown) { [JsonIgnore] public SemVersion? Version => SemVersion.TryParse(Tag, SemVersionStyles.AllowV, out var version) ? version : null; + [JsonIgnore] internal Services.Dlls.DllRelease? RegisteredRelease { get; init; } + + public Services.Dlls.DllRelease ToManagedRelease() => RegisteredRelease ?? new( + Info.Identifier.Org, Info.Identifier.ModuleName, Version?.ToString() ?? throw new InvalidDataException("Invalid DLL version."), + DownloadUrl, Hash.ToLowerInvariant(), Size, + new Services.Dlls.PluginInfo(Info.Name, Info.Description, Info.Authors, Info.PluginDependencies, + Info.IconUrl, Info.ImageUrls, Info.ModDependencies, Info.RepoUrl, Info.Target), + Tag, ReleaseDate, ReleaseNotesMarkdown, DllFileName); } public sealed record DllInfo { diff --git a/UnchainedLauncher.Core/Services/Dlls/DllManager.cs b/UnchainedLauncher.Core/Services/Dlls/DllManager.cs index fc80df21..4347d9e4 100644 --- a/UnchainedLauncher.Core/Services/Dlls/DllManager.cs +++ b/UnchainedLauncher.Core/Services/Dlls/DllManager.cs @@ -1,428 +1,458 @@ -using Semver; -using System.Net; -using System.Security.Cryptography; -using System.Text.Json; -using System.Text.RegularExpressions; - -namespace UnchainedLauncher.Core.Services.Dlls { - /// Only explicitly enabled, registry-backed releases may reach the injector. - public sealed class DllManager { - public const string IndexUrl = "https://raw.githubusercontent.com/Chiv2-Community/C2ModRegistry/refs/heads/main/dll_db/index.json"; - public const string DatabaseUrl = "https://raw.githubusercontent.com/Chiv2-Community/C2ModRegistry/refs/heads/main/dll_db"; - public const string PackageListUrl = DatabaseUrl + "/plugin_list_index.txt"; - public const string DefaultPluginId = "Chiv2-Community/UnchainedPlugin"; - private readonly HttpClient _http; - private readonly string _root; - private readonly string _cacheRoot; - private readonly string _statePath; - private readonly string _indexPath; - private readonly Func> _registryUrls; - private string? _approvedSources; - private readonly Dictionary _enabled; - private readonly object _gate = new(); - private bool _defaultPending; - private readonly Func> _modReleases; - public IReadOnlyList Releases { get; private set; } = []; - public event EventHandler? SelectionChanged; - - public DllManager(string pluginDirectory, string statePath, HttpClient http, - Func>? modReleases = null, - Func>? registryUrls = null, string? cacheDirectory = null) { - _registryUrls = registryUrls ?? (() => [DatabaseUrl]); - _modReleases = modReleases ?? (() => []); - var plugins = Path.TrimEndingDirectorySeparator(Path.GetFullPath(pluginDirectory)); - _root = Path.Combine(plugins, "Managed"); - _cacheRoot = Path.TrimEndingDirectorySeparator(Path.GetFullPath(cacheDirectory - ?? Path.Combine(Path.GetDirectoryName(plugins)!, ".cache", "dlls"))); - if (_cacheRoot.Equals(plugins, StringComparison.OrdinalIgnoreCase) - || _cacheRoot.StartsWith(plugins + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)) - throw new ArgumentException("DLL cache must be outside Plugins.", nameof(cacheDirectory)); - _statePath = statePath; - _indexPath = statePath + ".index.json"; - _http = http; - _defaultPending = !File.Exists(statePath); - var enabled = File.Exists(statePath) - ? JsonSerializer.Deserialize>(File.ReadAllText(statePath)) - ?? throw new InvalidDataException("Invalid DLL selection file.") - : new Dictionary(); - _enabled = new Dictionary(enabled, StringComparer.OrdinalIgnoreCase); - if (File.Exists(_indexPath) && File.Exists(_indexPath + ".sources") && - File.ReadAllText(_indexPath + ".sources") == SourceSignature()) { - _approvedSources = SourceSignature(); - Releases = ParseIndex(File.ReadAllText(_indexPath)); - EnableDefault(); - } - } - - public static IReadOnlyList ParseIndex(string json) { - var entries = JsonSerializer.Deserialize>(json) - ?? throw new InvalidDataException("Missing DLL index."); - var keys = new HashSet(StringComparer.OrdinalIgnoreCase); - foreach (var release in entries) { - if (release == null) throw new InvalidDataException("Null DLL release."); - release.Validate(); - if (!keys.Add(release.Key)) throw new InvalidDataException("Duplicate DLL release."); - } - return entries; - } - - public async Task RefreshAsync() { - var sources = RegistryUrls(); - var signature = JsonSerializer.Serialize(sources); - var merged = new Dictionary(StringComparer.OrdinalIgnoreCase); - foreach (var source in sources) { - foreach (var release in await ReadRegistryAsync(source)) { - if (merged.TryGetValue(release.Key, out var old) && Canonical(old) != Canonical(release)) - throw new InvalidDataException($"Conflicting DLL registry records: {release.Key}"); - merged[release.Key] = release; - } - } - var json = JsonSerializer.Serialize(merged.Values.ToArray()); - var releases = ParseIndex(json); - lock (_gate) { - if (SourceSignature() != signature) throw new InvalidOperationException("DLL registry settings changed during refresh. Refresh again."); - // Invalidate the old source marker before replacing the cached approvals. - WriteAtomic(_indexPath + ".sources", ""); - WriteAtomic(_indexPath, json); - WriteAtomic(_indexPath + ".sources", signature); - Releases = releases; - _approvedSources = signature; - EnableDefault(); - } - } - - private string[] RegistryUrls() => _registryUrls().Select(url => { - if (!Uri.TryCreate(url, UriKind.Absolute, out var uri) || uri.Scheme != Uri.UriSchemeHttps || - uri.UserInfo.Length != 0 || uri.Query.Length != 0 || uri.Fragment.Length != 0) - throw new InvalidDataException("Invalid DLL registry URL."); - return uri.AbsoluteUri.TrimEnd('/'); - }).Distinct(StringComparer.Ordinal).OrderBy(url => url, StringComparer.Ordinal).ToArray(); - - private string SourceSignature() => JsonSerializer.Serialize(RegistryUrls()); - - private async Task> ReadRegistryAsync(string databaseUrl) { - // The package list is authoritative. A stale legacy index must not - // re-approve removed releases or block metadata/target updates. - using var listResponse = await _http.GetAsync(databaseUrl + "/plugin_list_index.txt"); - if (listResponse.StatusCode == HttpStatusCode.NotFound) { - using var legacyResponse = await _http.GetAsync(databaseUrl + "/index.json"); - // Mod-only registries have neither format. - return legacyResponse.StatusCode == HttpStatusCode.NotFound ? [] : ParseIndex(await ReadResponse(legacyResponse)); - } - var packages = (await ReadResponse(listResponse)).Split('\n').Select(line => line.Trim()).Where(line => line.Length != 0); - var seen = new HashSet(StringComparer.OrdinalIgnoreCase); - var merged = new Dictionary(StringComparer.OrdinalIgnoreCase); - foreach (var package in packages) { - if (!Regex.IsMatch(package, "^[A-Za-z0-9_-]+/[A-Za-z0-9_-]+$") || !seen.Add(package)) - throw new InvalidDataException("Invalid or duplicate plugin package identifier."); - using var packageResponse = await _http.GetAsync($"{databaseUrl}/packages/{package}.json"); - foreach (var release in ParsePackage(await ReadResponse(packageResponse), package)) - merged.Add(release.Key, release); - } - return merged.Values.ToArray(); - } - - public static IReadOnlyList ParsePackage(string json, string identifier) { - if (!Regex.IsMatch(identifier, "^[A-Za-z0-9_-]+/[A-Za-z0-9_-]+$")) - throw new InvalidDataException("Invalid plugin package identifier."); - var parts = identifier.Split('/'); - var data = JsonSerializer.Deserialize(json) ?? throw new InvalidDataException("Missing plugin metadata."); - if (data.LatestReleaseInfo == null || data.Releases == null || data.Releases.Count == 0) - throw new InvalidDataException("Incomplete plugin metadata."); - data.LatestReleaseInfo.Validate(); - if (data.LatestReleaseInfo.RepoUrl != null && !data.LatestReleaseInfo.RepoUrl.Equals($"https://github.com/{identifier}", StringComparison.OrdinalIgnoreCase)) - throw new InvalidDataException("Plugin repository mismatch."); - var latest = data.Releases.OrderByDescending(release => release?.ReleaseDate).FirstOrDefault(release => release?.Info != null)?.Info; - if (latest == null || CanonicalInfo(latest) != CanonicalInfo(data.LatestReleaseInfo)) - throw new InvalidDataException("Plugin latest-release metadata mismatch."); - // Upstream permits null info/date/filename for previously approved - // historical releases inside an otherwise metadata-backed package. - return ParseIndex(JsonSerializer.Serialize(data.Releases.Select(record => { - if (record == null) throw new InvalidDataException("Null plugin release."); - return record.ToDllRelease(parts[0], parts[1]); - }))); - } - - private static async Task ReadResponse(HttpResponseMessage response) { - response.EnsureSuccessStatusCode(); - if (response.RequestMessage?.RequestUri?.Scheme != Uri.UriSchemeHttps) - throw new InvalidDataException("Insecure DLL metadata redirect."); - return await response.Content.ReadAsStringAsync(); - } - - private static string Canonical(DllRelease release) => JsonSerializer.Serialize(release with { - Organization = release.Organization.ToLowerInvariant(), - Name = release.Name.ToLowerInvariant(), - Tag = release.Tag ?? release.Version, - Info = release.Info == null ? null : NormalizeInfo(release.Info) - }); - - private static PluginInfo NormalizeInfo(PluginInfo info) => info with { - PluginDependencies = info.RequiredPlugins, - ModDependencies = info.RequiredMods, - ImageUrls = info.ImageUrls ?? [], - Schema = null - }; - - private static string CanonicalInfo(PluginInfo info) => JsonSerializer.Serialize(NormalizeInfo(info)); - - private void EnableDefault() { - if (!_defaultPending) return; - var latest = Releases.Where(r => r.Id == DefaultPluginId) - .Where(r => !SemVersion.Parse(r.Version, SemVersionStyles.Strict).IsPrerelease) - .OrderByDescending(r => SemVersion.Parse(r.Version, SemVersionStyles.Strict), - Comparer.Create((a, b) => a.ComparePrecedenceTo(b))).FirstOrDefault(); - if (latest == null) return; - _enabled[latest.Id] = latest.Version; - Save(); - _defaultPending = false; - } - - public bool IsEnabled(DllRelease release) { - lock (_gate) return _enabled.TryGetValue(release.Id, out var version) && version == release.Version; - } - - public IReadOnlyDictionary EnabledSelections { - get { lock (_gate) return new Dictionary(_enabled, StringComparer.OrdinalIgnoreCase); } - } - - // Revoking a selection never requires registry approval and never touches DLL files. - public void Disable(string id) { - lock (_gate) { - if (!_enabled.Remove(id, out var previous)) return; - try { Save(); } - catch { _enabled[id] = previous; throw; } - } - SelectionChanged?.Invoke(this, EventArgs.Empty); - } - - public void SetEnabled(DllRelease release, bool enabled) { - if (!enabled) { - if (IsEnabled(release)) Disable(release.Id); - return; - } - lock (_gate) { - EnsureCurrentSources(); - if (!Releases.Contains(release)) throw new InvalidOperationException("Unregistered DLL."); - var previous = _enabled.GetValueOrDefault(release.Id); - _enabled[release.Id] = release.Version; - try { Save(); } - catch { - if (previous == null) _enabled.Remove(release.Id); - else _enabled[release.Id] = previous; - throw; - } - } - SelectionChanged?.Invoke(this, EventArgs.Empty); - } - - private void Save() => WriteAtomic(_statePath, JsonSerializer.Serialize(_enabled)); - - private static void WriteAtomic(string path, string contents) { - Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(path))!); - var temp = path + "." + Guid.NewGuid().ToString("N") + ".tmp"; - try { - File.WriteAllText(temp, contents); - File.Move(temp, path, true); - } - finally { if (File.Exists(temp)) File.Delete(temp); } - } - - private DllRelease[] SelectedReleases(bool server = false) { - lock (_gate) { - var selected = EnabledForTarget(server); - DependencyValidator.Validate(_modReleases(), selected); - var ordered = new List(); - var visited = new HashSet(StringComparer.OrdinalIgnoreCase); - var visiting = new HashSet(StringComparer.OrdinalIgnoreCase); - void Visit(DllRelease release) { - if (visited.Contains(release.Id)) return; - if (!visiting.Add(release.Id)) throw new InvalidOperationException("Circular plugin dependency."); - foreach (var dependency in release.Info?.RequiredPlugins ?? []) { - var url = new Uri(dependency.RepoUrl); - var id = url.AbsolutePath.Trim('/'); - var required = selected.SingleOrDefault(candidate => candidate.Id.Equals(id, StringComparison.OrdinalIgnoreCase)); - if (required == null || !PluginInfo.MatchesVersion(required.Version, dependency.Version)) - throw new InvalidOperationException($"Enable a compatible registered dependency first: {id} {dependency.Version}"); - Visit(required); - } - visiting.Remove(release.Id); - visited.Add(release.Id); - ordered.Add(release); - } - foreach (var release in selected) Visit(release); - return ordered.ToArray(); - } - } - - private static bool AppliesToTarget(DllRelease release, bool server) => - release.Info?.Target is null or "shared" || release.Info.Target == (server ? "server" : "client"); - - private DllRelease[] EnabledForTarget(bool? server) { - EnsureCurrentSources(); - if (_defaultPending) throw new InvalidOperationException("The default plugin is not registered. Refresh the DLL index before launching."); - return _enabled.Select(pair => Releases.SingleOrDefault(r => r.Id.Equals(pair.Key, StringComparison.OrdinalIgnoreCase) && r.Version == pair.Value) - ?? throw new InvalidOperationException($"Enabled DLL is no longer registered: {pair.Key}:{pair.Value}")) - .Where(release => server == null || AppliesToTarget(release, server.Value)).ToArray(); - } - - public IReadOnlyList<(DllRelease Current, DllRelease Latest)> GetUpdateCandidates(bool? server, bool allowUnstable) { - lock (_gate) { - var updates = new List<(DllRelease, DllRelease)>(); - foreach (var current in EnabledForTarget(server)) { - var latest = Releases.Where(release => release.Id.Equals(current.Id, StringComparison.OrdinalIgnoreCase) - && (server == null || AppliesToTarget(release, server.Value)) - && (allowUnstable || !SemVersion.Parse(release.Version, SemVersionStyles.Strict).IsPrerelease)) - .OrderByDescending(release => SemVersion.Parse(release.Version, SemVersionStyles.Strict), - Comparer.Create((a, b) => a.ComparePrecedenceTo(b))).FirstOrDefault(); - if (latest != null && SemVersion.Parse(latest.Version, SemVersionStyles.Strict) - .ComparePrecedenceTo(SemVersion.Parse(current.Version, SemVersionStyles.Strict)) > 0) - updates.Add((current, latest)); - } - return updates; - } - } - - private void EnsureCurrentSources() { - if (_approvedSources != SourceSignature()) - throw new InvalidOperationException("DLL registry settings changed. Refresh Plugin Manager before launching."); - } - - private string GetPath(DllRelease release) => Path.Combine(_root, release.Organization, release.Name, release.DllFileName ?? release.Name + ".dll"); - private string GetLegacyPath(DllRelease release) => Path.Combine(_root, release.Organization, release.Name, release.Sha256, release.DllFileName ?? release.Name + ".dll"); - private string GetCachePath(DllRelease release) => Path.Combine(_cacheRoot, release.Sha256, release.DllFileName ?? release.Name + ".dll"); - - private static bool MatchesFile(string path, DllRelease release) { - using var stream = File.OpenRead(path); - try { ValidateBytes(stream, release); return true; } - catch (InvalidDataException) { return false; } - } - - private DllRelease? ManagedReleaseAt(string path) => Releases.FirstOrDefault(release => - (GetPath(release).Equals(path, StringComparison.OrdinalIgnoreCase) - || GetLegacyPath(release).Equals(path, StringComparison.OrdinalIgnoreCase)) - && MatchesFile(path, release)); - - public static void ValidateBytes(Stream stream, DllRelease release) { - release.Validate(); - stream.Position = 0; - if (stream.Length != release.Size || - !Convert.ToHexString(SHA256.HashData(stream)).Equals(release.Sha256, StringComparison.OrdinalIgnoreCase)) { - throw new InvalidDataException($"DLL size or SHA-256 mismatch: {release.Key}"); - } - stream.Position = 0; - } - - public async Task PrepareAsync(bool server = false, bool allowDownloads = true) { - var selected = SelectedReleases(server); - // Fill and validate the cache before changing any installed DLLs. - foreach (var release in selected) { - var path = GetPath(release); - if (File.Exists(path) && !MatchesFile(path, release) && ManagedReleaseAt(path) == null) - throw new InvalidDataException($"Unrecognized DLL at managed path; file left unchanged: {path}"); - var cache = GetCachePath(release); - if (File.Exists(cache)) { - using var existing = File.OpenRead(cache); - ValidateBytes(existing, release); - continue; - } - var installed = new[] { path, GetLegacyPath(release) } - .FirstOrDefault(candidate => File.Exists(candidate) && MatchesFile(candidate, release)); - if (installed != null) { - CopyValidated(installed, cache, release); - continue; - } - if (!allowDownloads) - throw new InvalidOperationException($"Automatic plugin downloads are disabled; missing registered DLL: {release.Key}"); - Directory.CreateDirectory(Path.GetDirectoryName(cache)!); - var temp = cache + "." + Guid.NewGuid().ToString("N") + ".tmp"; - try { - using var response = await _http.GetAsync(release.DownloadUrl, HttpCompletionOption.ResponseHeadersRead); - response.EnsureSuccessStatusCode(); - if (response.RequestMessage?.RequestUri?.Scheme != Uri.UriSchemeHttps) - throw new InvalidDataException("Insecure DLL download redirect."); - await using (var source = await response.Content.ReadAsStreamAsync()) - await using (var target = new FileStream(temp, FileMode.CreateNew, FileAccess.ReadWrite, FileShare.None)) { - var buffer = new byte[81920]; - long total = 0; - int count; - while ((count = await source.ReadAsync(buffer)) != 0) { - total += count; - if (total > release.Size) throw new InvalidDataException("DLL exceeds registered size."); - await target.WriteAsync(buffer.AsMemory(0, count)); - } - await target.FlushAsync(); - target.Position = 0; - ValidateBytes(target, release); - } - File.Move(temp, cache, true); - } - finally { if (File.Exists(temp)) File.Delete(temp); } - } - lock (_gate) { - if (!SelectedReleases(server).SequenceEqual(selected)) - throw new InvalidOperationException("Plugin selections or metadata changed during preparation. Launch again."); - var active = selected.Select(GetPath).ToHashSet(StringComparer.OrdinalIgnoreCase); - // Only validated files at paths owned by this manager may be archived. - // Unknown names/versions, even inside Managed, are left untouched. - if (Directory.Exists(_root)) { - foreach (var path in Directory.EnumerateFiles(_root, "*", SearchOption.AllDirectories).ToArray()) { - var release = ManagedReleaseAt(path); - if (release == null || (active.Contains(path) && selected.Any(candidate => - GetPath(candidate).Equals(path, StringComparison.OrdinalIgnoreCase) && MatchesFile(path, candidate)))) - continue; - CopyValidated(path, GetCachePath(release), release); - File.Delete(path); - } - } - foreach (var release in selected) { - var path = GetPath(release); - if (!File.Exists(path)) CopyValidated(GetCachePath(release), path, release); - using var installed = File.OpenRead(path); - ValidateBytes(installed, release); - } - } - } - - private static void CopyValidated(string source, string destination, DllRelease release) { - Directory.CreateDirectory(Path.GetDirectoryName(destination)!); - var temp = destination + "." + Guid.NewGuid().ToString("N") + ".tmp"; - try { - using (var input = new FileStream(source, FileMode.Open, FileAccess.Read, FileShare.Read)) - using (var output = new FileStream(temp, FileMode.CreateNew, FileAccess.ReadWrite, FileShare.None)) { - ValidateBytes(input, release); - input.CopyTo(output); - ValidateBytes(output, release); - } - File.Move(temp, destination, true); - } - finally { if (File.Exists(temp)) File.Delete(temp); } - } - - // Read handles deny writes/deletion on Windows until injection completes. - public IReadOnlyList OpenForInjection(bool server = false) { - var files = new List(); - try { - foreach (var release in SelectedReleases(server)) { - var path = GetPath(release); - var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read); - try { ValidateBytes(stream, release); } - catch { stream.Dispose(); throw; } - files.Add(new ValidatedDll(path, stream)); - } - return files; - } - catch { - foreach (var file in files) file.Dispose(); - throw; - } - } - } - - public sealed class ValidatedDll : IDisposable { - public string Path { get; } - private readonly Stream _stream; - internal ValidatedDll(string path, Stream stream) { Path = path; _stream = stream; } - public void Dispose() => _stream.Dispose(); - } +using Semver; +using System.Net; +using System.Security.Cryptography; +using System.Text.Json; +using System.Text.RegularExpressions; + +namespace UnchainedLauncher.Core.Services.Dlls { + /// Only explicitly enabled, registry-backed releases may reach the injector. + public sealed class DllManager { + public const string IndexUrl = "https://raw.githubusercontent.com/Chiv2-Community/C2ModRegistry/refs/heads/main/dll_db/index.json"; + public const string DatabaseUrl = "https://raw.githubusercontent.com/Chiv2-Community/C2ModRegistry/refs/heads/main/dll_db"; + public const string PackageListUrl = DatabaseUrl + "/plugin_list_index.txt"; + public const string DefaultPluginId = "Chiv2-Community/UnchainedPlugin"; + private readonly HttpClient _http; + private readonly string _root; + private readonly string _legacyCliRoot; + private readonly string _cacheRoot; + private readonly string? _statePath; + private readonly string? _indexPath; + private readonly Func> _registryUrls; + private string? _approvedSources; + private readonly Dictionary _enabled; + private readonly object _gate = new(); + private bool _defaultPending; + private readonly Func> _modReleases; + public IReadOnlyList Releases { get; private set; } = []; + public event EventHandler? SelectionChanged; + + public DllManager(string pluginDirectory, string? statePath, HttpClient http, + Func>? modReleases = null, + Func>? registryUrls = null, string? cacheDirectory = null) { + _registryUrls = registryUrls ?? (() => [DatabaseUrl]); + _modReleases = modReleases ?? (() => []); + var plugins = Path.TrimEndingDirectorySeparator(Path.GetFullPath(pluginDirectory)); + _root = Path.Combine(plugins, "Managed"); + _legacyCliRoot = Path.Combine(plugins, ".dll-managed"); + _cacheRoot = Path.TrimEndingDirectorySeparator(Path.GetFullPath(cacheDirectory + ?? Path.Combine(Path.GetDirectoryName(plugins)!, ".cache", "dlls"))); + if (_cacheRoot.Equals(plugins, StringComparison.OrdinalIgnoreCase) + || _cacheRoot.StartsWith(plugins + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)) + throw new ArgumentException("DLL cache must be outside Plugins.", nameof(cacheDirectory)); + _statePath = statePath; + _indexPath = statePath == null ? null : statePath + ".index.json"; + _http = http; + _defaultPending = statePath != null && !File.Exists(statePath); + var enabled = File.Exists(statePath) + ? JsonSerializer.Deserialize>(File.ReadAllText(statePath)) + ?? throw new InvalidDataException("Invalid DLL selection file.") + : new Dictionary(); + _enabled = new Dictionary(enabled, StringComparer.OrdinalIgnoreCase); + if (_indexPath != null && File.Exists(_indexPath) && File.Exists(_indexPath + ".sources") && + File.ReadAllText(_indexPath + ".sources") == SourceSignature()) { + _approvedSources = SourceSignature(); + Releases = ParseIndex(File.ReadAllText(_indexPath)); + EnableDefault(); + } + } + + public static IReadOnlyList ParseIndex(string json) { + var entries = JsonSerializer.Deserialize>(json) + ?? throw new InvalidDataException("Missing DLL index."); + return ValidateCatalog(entries); + } + + private static IReadOnlyList ValidateCatalog(IReadOnlyList entries) { + var keys = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var release in entries) { + if (release == null) throw new InvalidDataException("Null DLL release."); + release.Validate(); + if (!keys.Add(release.Key)) throw new InvalidDataException("Duplicate DLL release."); + } + return entries; + } + + // CLI selections belong to a single request, not the GUI's saved profile. + public static DllManager CreateForLaunch(string pluginDirectory, IReadOnlyList catalog, + IReadOnlyList selected, HttpClient http, + Func> modReleases, + string? cacheDirectory = null) { + var manager = new DllManager(pluginDirectory, null, http, modReleases, () => [], cacheDirectory) { + Releases = ValidateCatalog(catalog).ToArray(), + _approvedSources = "[]" + }; + foreach (var release in selected) manager.SetEnabled(release, true); + return manager; + } + + public async Task RefreshAsync() { + var sources = RegistryUrls(); + var signature = JsonSerializer.Serialize(sources); + var merged = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var source in sources) { + foreach (var release in await ReadRegistryAsync(source)) { + if (merged.TryGetValue(release.Key, out var old) && Canonical(old) != Canonical(release)) + throw new InvalidDataException($"Conflicting DLL registry records: {release.Key}"); + merged[release.Key] = release; + } + } + var json = JsonSerializer.Serialize(merged.Values.ToArray()); + var releases = ParseIndex(json); + lock (_gate) { + if (SourceSignature() != signature) throw new InvalidOperationException("DLL registry settings changed during refresh. Refresh again."); + // Invalidate the old source marker before replacing the cached approvals. + if (_indexPath != null) { + WriteAtomic(_indexPath + ".sources", ""); + WriteAtomic(_indexPath, json); + WriteAtomic(_indexPath + ".sources", signature); + } + Releases = releases; + _approvedSources = signature; + EnableDefault(); + } + } + + private string[] RegistryUrls() => _registryUrls().Select(url => { + if (!Uri.TryCreate(url, UriKind.Absolute, out var uri) || uri.Scheme != Uri.UriSchemeHttps || + uri.UserInfo.Length != 0 || uri.Query.Length != 0 || uri.Fragment.Length != 0) + throw new InvalidDataException("Invalid DLL registry URL."); + return uri.AbsoluteUri.TrimEnd('/'); + }).Distinct(StringComparer.Ordinal).OrderBy(url => url, StringComparer.Ordinal).ToArray(); + + private string SourceSignature() => JsonSerializer.Serialize(RegistryUrls()); + + private Task> ReadRegistryAsync(string databaseUrl) => ReadRegistryAsync(_http, databaseUrl); + + public static async Task> ReadRegistryAsync(HttpClient http, string databaseUrl, + CancellationToken cancellationToken = default) { + // The package list is authoritative. A stale legacy index must not + // re-approve removed releases or block metadata/target updates. + using var listResponse = await http.GetAsync(databaseUrl + "/plugin_list_index.txt", cancellationToken); + if (listResponse.StatusCode == HttpStatusCode.NotFound) { + using var legacyResponse = await http.GetAsync(databaseUrl + "/index.json", cancellationToken); + // Mod-only registries have neither format. + return legacyResponse.StatusCode == HttpStatusCode.NotFound ? [] : ParseIndex(await ReadResponse(legacyResponse)); + } + var packages = (await ReadResponse(listResponse)).Split('\n').Select(line => line.Trim()).Where(line => line.Length != 0); + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + var merged = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var package in packages) { + if (!Regex.IsMatch(package, "^[A-Za-z0-9_-]+/[A-Za-z0-9_-]+$") || !seen.Add(package)) + throw new InvalidDataException("Invalid or duplicate plugin package identifier."); + using var packageResponse = await http.GetAsync($"{databaseUrl}/packages/{package}.json", cancellationToken); + foreach (var release in ParsePackage(await ReadResponse(packageResponse), package)) + merged.Add(release.Key, release); + } + return merged.Values.ToArray(); + } + + public static IReadOnlyList ParsePackage(string json, string identifier) { + if (!Regex.IsMatch(identifier, "^[A-Za-z0-9_-]+/[A-Za-z0-9_-]+$")) + throw new InvalidDataException("Invalid plugin package identifier."); + var parts = identifier.Split('/'); + var data = JsonSerializer.Deserialize(json) ?? throw new InvalidDataException("Missing plugin metadata."); + if (data.LatestReleaseInfo == null || data.Releases == null || data.Releases.Count == 0) + throw new InvalidDataException("Incomplete plugin metadata."); + data.LatestReleaseInfo.Validate(); + if (data.LatestReleaseInfo.RepoUrl != null && !data.LatestReleaseInfo.RepoUrl.Equals($"https://github.com/{identifier}", StringComparison.OrdinalIgnoreCase)) + throw new InvalidDataException("Plugin repository mismatch."); + var latest = data.Releases.OrderByDescending(release => release?.ReleaseDate).FirstOrDefault(release => release?.Info != null)?.Info; + if (latest == null || CanonicalInfo(latest) != CanonicalInfo(data.LatestReleaseInfo)) + throw new InvalidDataException("Plugin latest-release metadata mismatch."); + // Upstream permits null info/date/filename for previously approved + // historical releases inside an otherwise metadata-backed package. + return ParseIndex(JsonSerializer.Serialize(data.Releases.Select(record => { + if (record == null) throw new InvalidDataException("Null plugin release."); + return record.ToDllRelease(parts[0], parts[1]); + }))); + } + + private static async Task ReadResponse(HttpResponseMessage response) { + response.EnsureSuccessStatusCode(); + if (response.RequestMessage?.RequestUri?.Scheme != Uri.UriSchemeHttps) + throw new InvalidDataException("Insecure DLL metadata redirect."); + return await response.Content.ReadAsStringAsync(); + } + + internal static string Canonical(DllRelease release) => JsonSerializer.Serialize(release with { + Organization = release.Organization.ToLowerInvariant(), + Name = release.Name.ToLowerInvariant(), + Tag = release.Tag ?? release.Version, + Info = release.Info == null ? null : NormalizeInfo(release.Info) + }); + + private static PluginInfo NormalizeInfo(PluginInfo info) => info with { + PluginDependencies = info.RequiredPlugins, + ModDependencies = info.RequiredMods, + ImageUrls = info.ImageUrls ?? [], + Schema = null + }; + + private static string CanonicalInfo(PluginInfo info) => JsonSerializer.Serialize(NormalizeInfo(info)); + + private void EnableDefault() { + if (!_defaultPending) return; + var latest = Releases.Where(r => r.Id == DefaultPluginId) + .Where(r => !SemVersion.Parse(r.Version, SemVersionStyles.Strict).IsPrerelease) + .OrderByDescending(r => SemVersion.Parse(r.Version, SemVersionStyles.Strict), + Comparer.Create((a, b) => a.ComparePrecedenceTo(b))).FirstOrDefault(); + if (latest == null) return; + _enabled[latest.Id] = latest.Version; + Save(); + _defaultPending = false; + } + + public bool IsEnabled(DllRelease release) { + lock (_gate) return _enabled.TryGetValue(release.Id, out var version) && version == release.Version; + } + + public IReadOnlyDictionary EnabledSelections { + get { lock (_gate) return new Dictionary(_enabled, StringComparer.OrdinalIgnoreCase); } + } + + // Revoking a selection never requires registry approval and never touches DLL files. + public void Disable(string id) { + lock (_gate) { + if (!_enabled.Remove(id, out var previous)) return; + try { Save(); } + catch { _enabled[id] = previous; throw; } + } + SelectionChanged?.Invoke(this, EventArgs.Empty); + } + + public void SetEnabled(DllRelease release, bool enabled) { + if (!enabled) { + if (IsEnabled(release)) Disable(release.Id); + return; + } + lock (_gate) { + EnsureCurrentSources(); + if (!Releases.Contains(release)) throw new InvalidOperationException("Unregistered DLL."); + var previous = _enabled.GetValueOrDefault(release.Id); + _enabled[release.Id] = release.Version; + try { Save(); } + catch { + if (previous == null) _enabled.Remove(release.Id); + else _enabled[release.Id] = previous; + throw; + } + } + SelectionChanged?.Invoke(this, EventArgs.Empty); + } + + private void Save() { + if (_statePath != null) WriteAtomic(_statePath, JsonSerializer.Serialize(_enabled)); + } + + private static void WriteAtomic(string path, string contents) { + Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(path))!); + var temp = path + "." + Guid.NewGuid().ToString("N") + ".tmp"; + try { + File.WriteAllText(temp, contents); + File.Move(temp, path, true); + } + finally { if (File.Exists(temp)) File.Delete(temp); } + } + + private DllRelease[] SelectedReleases(bool server = false) { + lock (_gate) { + var selected = EnabledForTarget(server); + DependencyValidator.Validate(_modReleases(), selected); + var ordered = new List(); + var visited = new HashSet(StringComparer.OrdinalIgnoreCase); + var visiting = new HashSet(StringComparer.OrdinalIgnoreCase); + void Visit(DllRelease release) { + if (visited.Contains(release.Id)) return; + if (!visiting.Add(release.Id)) throw new InvalidOperationException("Circular plugin dependency."); + foreach (var dependency in release.Info?.RequiredPlugins ?? []) { + var url = new Uri(dependency.RepoUrl); + var id = url.AbsolutePath.Trim('/'); + var required = selected.SingleOrDefault(candidate => candidate.Id.Equals(id, StringComparison.OrdinalIgnoreCase)); + if (required == null || !PluginInfo.MatchesVersion(required.Version, dependency.Version)) + throw new InvalidOperationException($"Enable a compatible registered dependency first: {id} {dependency.Version}"); + Visit(required); + } + visiting.Remove(release.Id); + visited.Add(release.Id); + ordered.Add(release); + } + foreach (var release in selected) Visit(release); + return ordered.ToArray(); + } + } + + private static bool AppliesToTarget(DllRelease release, bool server) => + release.Info?.Target is null or "shared" || release.Info.Target == (server ? "server" : "client"); + + private DllRelease[] EnabledForTarget(bool? server) { + EnsureCurrentSources(); + if (_defaultPending) throw new InvalidOperationException("The default plugin is not registered. Refresh the DLL index before launching."); + return _enabled.Select(pair => Releases.SingleOrDefault(r => r.Id.Equals(pair.Key, StringComparison.OrdinalIgnoreCase) && r.Version == pair.Value) + ?? throw new InvalidOperationException($"Enabled DLL is no longer registered: {pair.Key}:{pair.Value}")) + .Where(release => server == null || AppliesToTarget(release, server.Value)).ToArray(); + } + + public IReadOnlyList<(DllRelease Current, DllRelease Latest)> GetUpdateCandidates(bool? server, bool allowUnstable) { + lock (_gate) { + var updates = new List<(DllRelease, DllRelease)>(); + foreach (var current in EnabledForTarget(server)) { + var latest = Releases.Where(release => release.Id.Equals(current.Id, StringComparison.OrdinalIgnoreCase) + && (server == null || AppliesToTarget(release, server.Value)) + && (allowUnstable || !SemVersion.Parse(release.Version, SemVersionStyles.Strict).IsPrerelease)) + .OrderByDescending(release => SemVersion.Parse(release.Version, SemVersionStyles.Strict), + Comparer.Create((a, b) => a.ComparePrecedenceTo(b))).FirstOrDefault(); + if (latest != null && SemVersion.Parse(latest.Version, SemVersionStyles.Strict) + .ComparePrecedenceTo(SemVersion.Parse(current.Version, SemVersionStyles.Strict)) > 0) + updates.Add((current, latest)); + } + return updates; + } + } + + private void EnsureCurrentSources() { + if (_approvedSources != SourceSignature()) + throw new InvalidOperationException("DLL registry settings changed. Refresh Plugin Manager before launching."); + } + + private string GetPath(DllRelease release) => Path.Combine(_root, release.Organization, release.Name, release.DllFileName ?? release.Name + ".dll"); + private string GetLegacyPath(DllRelease release) => Path.Combine(_root, release.Organization, release.Name, release.Sha256, release.DllFileName ?? release.Name + ".dll"); + private string GetLegacyCliPath(DllRelease release) => Path.Combine(_legacyCliRoot, release.Sha256, release.DllFileName ?? release.Name + ".dll"); + private string GetCachePath(DllRelease release) => Path.Combine(_cacheRoot, release.Sha256, release.DllFileName ?? release.Name + ".dll"); + + private static bool MatchesFile(string path, DllRelease release) { + using var stream = File.OpenRead(path); + try { ValidateBytes(stream, release); return true; } + catch (InvalidDataException) { return false; } + } + + private DllRelease? ManagedReleaseAt(string path) => Releases.FirstOrDefault(release => + (GetPath(release).Equals(path, StringComparison.OrdinalIgnoreCase) + || GetLegacyPath(release).Equals(path, StringComparison.OrdinalIgnoreCase) + || GetLegacyCliPath(release).Equals(path, StringComparison.OrdinalIgnoreCase)) + && MatchesFile(path, release)); + + public static void ValidateBytes(Stream stream, DllRelease release) { + release.Validate(); + stream.Position = 0; + if (stream.Length != release.Size || + !Convert.ToHexString(SHA256.HashData(stream)).Equals(release.Sha256, StringComparison.OrdinalIgnoreCase)) { + throw new InvalidDataException($"DLL size or SHA-256 mismatch: {release.Key}"); + } + stream.Position = 0; + } + + public async Task PrepareAsync(bool server = false, bool allowDownloads = true, CancellationToken cancellationToken = default) { + var selected = SelectedReleases(server); + // Fill and validate the cache before changing any installed DLLs. + foreach (var release in selected) { + cancellationToken.ThrowIfCancellationRequested(); + var path = GetPath(release); + if (File.Exists(path) && !MatchesFile(path, release) && ManagedReleaseAt(path) == null) + throw new InvalidDataException($"Unrecognized DLL at managed path; file left unchanged: {path}"); + var cache = GetCachePath(release); + if (File.Exists(cache)) { + using var existing = File.OpenRead(cache); + ValidateBytes(existing, release); + continue; + } + var installed = new[] { path, GetLegacyPath(release), GetLegacyCliPath(release) } + .FirstOrDefault(candidate => File.Exists(candidate) && MatchesFile(candidate, release)); + if (installed != null) { + CopyValidated(installed, cache, release); + continue; + } + if (!allowDownloads) + throw new InvalidOperationException($"Automatic plugin downloads are disabled; missing registered DLL: {release.Key}"); + Directory.CreateDirectory(Path.GetDirectoryName(cache)!); + var temp = cache + "." + Guid.NewGuid().ToString("N") + ".tmp"; + try { + using var response = await _http.GetAsync(release.DownloadUrl, HttpCompletionOption.ResponseHeadersRead, cancellationToken); + response.EnsureSuccessStatusCode(); + if (response.RequestMessage?.RequestUri?.Scheme != Uri.UriSchemeHttps) + throw new InvalidDataException("Insecure DLL download redirect."); + await using (var source = await response.Content.ReadAsStreamAsync(cancellationToken)) + await using (var target = new FileStream(temp, FileMode.CreateNew, FileAccess.ReadWrite, FileShare.None)) { + var buffer = new byte[81920]; + long total = 0; + int count; + while ((count = await source.ReadAsync(buffer, cancellationToken)) != 0) { + total += count; + if (total > release.Size) throw new InvalidDataException("DLL exceeds registered size."); + await target.WriteAsync(buffer.AsMemory(0, count), cancellationToken); + } + await target.FlushAsync(cancellationToken); + target.Position = 0; + ValidateBytes(target, release); + } + File.Move(temp, cache, true); + } + finally { if (File.Exists(temp)) File.Delete(temp); } + } + lock (_gate) { + cancellationToken.ThrowIfCancellationRequested(); + if (!SelectedReleases(server).SequenceEqual(selected)) + throw new InvalidOperationException("Plugin selections or metadata changed during preparation. Launch again."); + var active = selected.Select(GetPath).ToHashSet(StringComparer.OrdinalIgnoreCase); + // Only validated files at paths owned by this manager may be archived. + // Unknown names/versions, even inside Managed, are left untouched. + foreach (var managedRoot in new[] { _root, _legacyCliRoot }.Where(Directory.Exists)) { + foreach (var path in Directory.EnumerateFiles(managedRoot, "*", SearchOption.AllDirectories).ToArray()) { + var release = ManagedReleaseAt(path); + if (release == null || (active.Contains(path) && selected.Any(candidate => + GetPath(candidate).Equals(path, StringComparison.OrdinalIgnoreCase) && MatchesFile(path, candidate)))) + continue; + CopyValidated(path, GetCachePath(release), release); + File.Delete(path); + } + } + foreach (var release in selected) { + var path = GetPath(release); + if (!File.Exists(path)) CopyValidated(GetCachePath(release), path, release); + using var installed = File.OpenRead(path); + ValidateBytes(installed, release); + } + } + } + + private static void CopyValidated(string source, string destination, DllRelease release) { + Directory.CreateDirectory(Path.GetDirectoryName(destination)!); + var temp = destination + "." + Guid.NewGuid().ToString("N") + ".tmp"; + try { + using (var input = new FileStream(source, FileMode.Open, FileAccess.Read, FileShare.Read)) + using (var output = new FileStream(temp, FileMode.CreateNew, FileAccess.ReadWrite, FileShare.None)) { + ValidateBytes(input, release); + input.CopyTo(output); + ValidateBytes(output, release); + } + File.Move(temp, destination, true); + } + finally { if (File.Exists(temp)) File.Delete(temp); } + } + + // Read handles deny writes/deletion on Windows until injection completes. + public IReadOnlyList OpenForInjection(bool server = false) { + var files = new List(); + try { + foreach (var release in SelectedReleases(server)) { + var path = GetPath(release); + var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read); + try { ValidateBytes(stream, release); } + catch { stream.Dispose(); throw; } + files.Add(new ValidatedDll(path, stream)); + } + return files; + } + catch { + foreach (var file in files) file.Dispose(); + throw; + } + } + } + + public sealed class ValidatedDll : IDisposable { + public string Path { get; } + private readonly Stream _stream; + internal ValidatedDll(string path, Stream stream) { Path = path; _stream = stream; } + public void Dispose() => _stream.Dispose(); + } } \ No newline at end of file diff --git a/UnchainedLauncher.Core/Services/Mods/DllLaunchSession.cs b/UnchainedLauncher.Core/Services/Mods/DllLaunchSession.cs new file mode 100644 index 00000000..7996a959 --- /dev/null +++ b/UnchainedLauncher.Core/Services/Mods/DllLaunchSession.cs @@ -0,0 +1,164 @@ +using LanguageExt; +using Semver; +using UnchainedLauncher.Core.JsonModels; +using UnchainedLauncher.Core.Services.Dlls; +using UnchainedLauncher.Core.Services.Mods.Registry; +using UnchainedLauncher.Core.Services.Processes; +using UnchainedLauncher.Core.Services.Processes.Chivalry; +using UnchainedLauncher.Core.Services.Processes.Chivalry.LaunchPreparers; +using ManagedDllManager = UnchainedLauncher.Core.Services.Dlls.DllManager; +using DllRelease = UnchainedLauncher.Core.JsonModels.DllRelease; + +namespace UnchainedLauncher.Core.Services.Mods; + +/// Resolves a CLI request, then uses the shared DLL manager for storage and injection. +public sealed class DllLaunchSession : IChivalry2LaunchPreparer, IProcessInjector { + private static readonly HttpClient Client = new() { Timeout = TimeSpan.FromMinutes(5) }; + private readonly AggregateModRegistry _registry; + private readonly IModManager _mods; + private readonly string _directory; + private readonly string? _cacheDirectory; + private readonly bool _unsafe; + private readonly IReadOnlyList _requested; + private readonly CancellationToken _cancellationToken; + private readonly HttpClient _client; + private ManagedDllManager? _manager; + private bool _prepared; + private bool _server; + + public DllLaunchSession(AggregateModRegistry registry, IModManager mods, string directory, + IEnumerable? requested = null, bool unsafeLoadAllDlls = false, + CancellationToken cancellationToken = default, HttpClient? client = null, string? cacheDirectory = null) { + _registry = registry; + _mods = mods; + _directory = Path.GetFullPath(directory); + _cacheDirectory = cacheDirectory; + _requested = requested?.ToArray() ?? []; + _unsafe = unsafeLoadAllDlls; + _cancellationToken = cancellationToken; + _client = client ?? Client; + } + + public static bool Satisfies(DllRelease release, string requirement) => release.Version is { } version && SatisfiesVersion(version, requirement); + + public static bool SatisfiesVersion(SemVersion version, string requirement) => PluginInfo.MatchesVersion(version.ToString(), + string.Join(",", requirement.Split(',').Select(part => + System.Text.RegularExpressions.Regex.Replace(part.Trim(), @"^(\^|>=|<=|==|!=|>|<)?v", "$1")))); + + public static IReadOnlyList Resolve(IReadOnlyList packages, + IEnumerable requested, bool server, bool allowUnstable) { + var selected = new Dictionary(StringComparer.OrdinalIgnoreCase); + var visiting = new System.Collections.Generic.HashSet(StringComparer.OrdinalIgnoreCase); + var ordered = new List(); + var roots = requested.ToList(); + if (!roots.Any(root => root.Org.Equals("Chiv2-Community", StringComparison.OrdinalIgnoreCase) + && root.ModuleName.Equals("UnchainedPlugin", StringComparison.OrdinalIgnoreCase))) + roots.Insert(0, new ReleaseCoordinates("Chiv2-Community", "UnchainedPlugin", "latest")); + foreach (var root in roots) Visit(root.Org + "/" + root.ModuleName, root.Version, exact: true); + return ordered; + + void Visit(string id, string requirement, bool exact) { + var package = packages.FirstOrDefault(p => (p.LatestReleaseInfo.Identifier.Org + "/" + p.LatestReleaseInfo.Identifier.ModuleName).Equals(id, StringComparison.OrdinalIgnoreCase)) + ?? throw new InvalidOperationException($"DLL package not found: {id}"); + bool Accept(DllRelease r) => exact ? requirement == "latest" || r.Tag == requirement : Satisfies(r, requirement); + if (selected.TryGetValue(id, out var previous)) { + if (visiting.Contains(id)) throw new InvalidOperationException($"Cyclic DLL dependency: {id}"); + if (!Accept(previous)) throw new InvalidOperationException($"Conflicting DLL requirements: {id}"); + return; + } + var candidates = package.Releases.Where(Accept) + .Where(release => release.Info.Target == "shared" || release.Info.Target == (server ? "server" : "client")); + if (!exact || requirement == "latest") candidates = candidates.Where(r => allowUnstable || r.Version?.IsRelease == true); + var release = candidates.OrderByDescending(r => r.Version).ThenByDescending(r => r.ReleaseDate).FirstOrDefault() + ?? throw new InvalidOperationException($"DLL release not found for launch target: {id}:{requirement}"); + selected.Add(id, release); + visiting.Add(id); + foreach (var dependency in release.Info.PluginDependencies) { + var dep = ModIdentifier.FromDependency(dependency); + Visit(dep.Org + "/" + dep.ModuleName, dependency.Version, exact: false); + } + visiting.Remove(id); + ordered.Add(release); + } + } + + public async Task> PrepareLaunch(LaunchOptions options) { + _prepared = false; + _manager = null; + _cancellationToken.ThrowIfCancellationRequested(); + var catalog = await DllCatalog.ReadAsync(_registry, _cancellationToken); + if (catalog.Errors.Count != 0) throw new InvalidOperationException("DLL registry loading failed: " + string.Join("; ", catalog.Errors)); + _server = options.ServerLaunchOptions.IsSome; + var releases = Resolve(catalog.Packages, _requested.Concat(options.EnabledDlls), _server, options.AllowUnstablePluginReleases); + if (releases.Any(r => r.Info.ModDependencies.Count != 0)) { + var fetched = await _mods.UpdateModsList(); + if (fetched.HasErrors) throw new InvalidOperationException("Pak registry loading failed while resolving DLL dependencies."); + } + foreach (var dependency in releases.SelectMany(r => r.Info.ModDependencies)) { + var id = ModIdentifier.FromDependency(dependency); + var existing = _mods.GetEnabledAndDependencyReleases().Where(r => + r.Info.RepoUrl.Equals(dependency.RepoUrl, StringComparison.OrdinalIgnoreCase)).ToArray(); + if (existing.Length != 0) { + if (existing.Any(r => r.Version is null || !SatisfiesVersion(r.Version, dependency.Version))) + throw new InvalidOperationException("DLL pak dependency conflicts with selected mods."); + continue; + } + var mod = _mods.Mods.FirstOrDefault(m => m.LatestReleaseInfo.RepoUrl.Equals(dependency.RepoUrl, StringComparison.OrdinalIgnoreCase)); + var release = mod?.Releases.Where(r => r.Version is { } version && SatisfiesVersion(version, dependency.Version)) + .OrderByDescending(r => r.Version).FirstOrDefault() + ?? throw new InvalidOperationException($"DLL pak dependency unavailable: {id.Org}/{id.ModuleName}"); + if (!_mods.EnableModRelease(ReleaseCoordinates.FromRelease(release))) + throw new InvalidOperationException("Could not enable DLL pak dependency."); + } + var resolvedPaks = _mods.GetEnabledAndDependencyReleases().ToArray(); + if (resolvedPaks.GroupBy(r => r.Info.RepoUrl, StringComparer.OrdinalIgnoreCase).Any(g => g.Select(r => r.Tag).Distinct().Count() > 1)) + throw new InvalidOperationException("Resolved pak dependencies contain conflicting versions."); + foreach (var release in releases) SafeFileName(release.DllFileName); + if (releases.Select(release => release.DllFileName).Distinct(StringComparer.OrdinalIgnoreCase).Count() != releases.Count) + throw new InvalidOperationException("DLL filename collision."); + _manager = ManagedDllManager.CreateForLaunch(_directory, + catalog.Packages.SelectMany(package => package.Releases).Select(release => release.ToManagedRelease()).ToArray(), + releases.Select(release => release.ToManagedRelease()).ToArray(), _client, + () => _mods.GetEnabledAndDependencyReleases().ToArray(), _cacheDirectory); + try { await _manager.PrepareAsync(_server, options.CheckForDependencyUpdates, _cancellationToken); } + catch (InvalidDataException ex) { throw new InvalidOperationException(ex.Message, ex); } + var files = _manager.OpenForInjection(_server); + string[] paths; + try { paths = files.Select(file => file.Path).ToArray(); } + finally { foreach (var file in files) file.Dispose(); } + var inventory = System.Text.Json.JsonSerializer.Serialize(releases.Select((release, i) => new { + Repository = release.Info.RepoUrl, + release.Tag, + release.Hash, + release.Size, + Path = paths[i] + })); + var inventoryPath = Path.Combine(_directory, "dll_manager.json"); + Directory.CreateDirectory(_directory); + var temporary = inventoryPath + "." + Guid.NewGuid().ToString("N") + ".tmp"; + try { + await File.WriteAllTextAsync(temporary, inventory, _cancellationToken); + File.Move(temporary, inventoryPath, overwrite: true); + } + finally { if (File.Exists(temporary)) File.Delete(temporary); } + _prepared = true; + return options; + } + + public static string SafeFileName(string name) { + if (string.IsNullOrWhiteSpace(name) || name.Contains('/') || name.Contains('\\') || name.Contains(':') + || name.IndexOfAny(['<', '>', '"', '|', '?', '*']) >= 0 || name.Any(char.IsControl) + || !name.EndsWith(".dll", StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException("Invalid DLL filename."); + return name; + } + + public static Task MatchesFileAsync(string path, DllRelease release) { + using var file = File.OpenRead(path); + try { ManagedDllManager.ValidateBytes(file, release.ToManagedRelease()); return Task.FromResult(true); } + catch (InvalidDataException) { return Task.FromResult(false); } + } + + public bool Inject(System.Diagnostics.Process process) => Inject(process, _server); + public bool Inject(System.Diagnostics.Process process, bool server) => _prepared && server == _server && _manager != null + && new DllInjector(_manager, _directory, _unsafe).Inject(process, server); +} \ No newline at end of file diff --git a/UnchainedLauncher.Core/Services/Mods/DllManager.cs b/UnchainedLauncher.Core/Services/Mods/DllManager.cs deleted file mode 100644 index b5b8ce72..00000000 --- a/UnchainedLauncher.Core/Services/Mods/DllManager.cs +++ /dev/null @@ -1,197 +0,0 @@ -using LanguageExt; -using Semver; -using System.Security.Cryptography; -using UnchainedLauncher.Core.JsonModels; -using UnchainedLauncher.Core.Services.Mods.Registry; -using UnchainedLauncher.Core.Services.Processes; -using UnchainedLauncher.Core.Services.Processes.Chivalry; -using UnchainedLauncher.Core.Services.Processes.Chivalry.LaunchPreparers; - -namespace UnchainedLauncher.Core.Services.Mods; - -/// Registry-approved DLL preparation and the process-local injection allowlist. -public sealed class DllManager : IChivalry2LaunchPreparer, IProcessInjector { - private static readonly HttpClient Client = new() { Timeout = TimeSpan.FromMinutes(5) }; - private readonly AggregateModRegistry _registry; - private readonly IModManager _mods; - private readonly string _directory; - private readonly bool _unsafe; - private readonly IReadOnlyList _requested; - private readonly CancellationToken _cancellationToken; - private readonly HttpClient _client; - private string[] _paths = []; - private bool _prepared; - private IReadOnlyList _preparedReleases = []; - - public DllManager(AggregateModRegistry registry, IModManager mods, string directory, - IEnumerable? requested = null, bool unsafeLoadAllDlls = false, - CancellationToken cancellationToken = default, HttpClient? client = null) { - _registry = registry; - _mods = mods; - _directory = Path.GetFullPath(directory); - _requested = requested?.ToArray() ?? []; - _unsafe = unsafeLoadAllDlls; - _cancellationToken = cancellationToken; - _client = client ?? Client; - } - - public static bool Satisfies(DllRelease release, string requirement) { - if (release.Version is null) return false; - // The registry uses comma-separated comparisons; npm ranges use spaces. - return SatisfiesVersion(release.Version, requirement); - } - - public static bool SatisfiesVersion(SemVersion version, string requirement) => requirement.Split(',').All(part => { - var normalized = System.Text.RegularExpressions.Regex.Replace(part.Trim(), @"^(\^|>=|<=|==|!=|>|<)?v", "$1"); - if (normalized.StartsWith("!=", StringComparison.Ordinal)) - return !SemVersionRange.ParseNpm("=" + normalized[2..], includeAllPrerelease: true).Contains(version); - return SemVersionRange.ParseNpm(normalized.Replace("==", "="), includeAllPrerelease: true).Contains(version); - }); - - public static IReadOnlyList Resolve(IReadOnlyList packages, - IEnumerable requested, bool server, bool allowUnstable) { - var selected = new Dictionary(StringComparer.OrdinalIgnoreCase); - var visiting = new System.Collections.Generic.HashSet(StringComparer.OrdinalIgnoreCase); - var ordered = new List(); - var roots = requested.ToList(); - if (!roots.Any(root => root.Org.Equals("Chiv2-Community", StringComparison.OrdinalIgnoreCase) - && root.ModuleName.Equals("UnchainedPlugin", StringComparison.OrdinalIgnoreCase))) - roots.Insert(0, new ReleaseCoordinates("Chiv2-Community", "UnchainedPlugin", "latest")); - foreach (var root in roots) Visit(root.Org + "/" + root.ModuleName, root.Version, exact: true); - return ordered; - - void Visit(string id, string requirement, bool exact) { - var package = packages.FirstOrDefault(p => (p.LatestReleaseInfo.Identifier.Org + "/" + p.LatestReleaseInfo.Identifier.ModuleName).Equals(id, StringComparison.OrdinalIgnoreCase)) - ?? throw new InvalidOperationException($"DLL package not found: {id}"); - bool Accept(DllRelease r) => exact ? requirement == "latest" || r.Tag == requirement : Satisfies(r, requirement); - if (selected.TryGetValue(id, out var previous)) { - if (visiting.Contains(id)) throw new InvalidOperationException($"Cyclic DLL dependency: {id}"); - if (!Accept(previous)) throw new InvalidOperationException($"Conflicting DLL requirements: {id}"); - return; - } - var candidates = package.Releases.Where(Accept); - if (!exact || requirement == "latest") candidates = candidates.Where(r => allowUnstable || r.Version?.IsRelease == true); - var release = candidates.OrderByDescending(r => r.Version).ThenByDescending(r => r.ReleaseDate).FirstOrDefault() - ?? throw new InvalidOperationException($"DLL release not found: {id}:{requirement}"); - if (release.Info.Target != "shared" && release.Info.Target != (server ? "server" : "client")) - throw new InvalidOperationException($"DLL target does not match launch: {id}"); - selected.Add(id, release); - visiting.Add(id); - foreach (var dependency in release.Info.PluginDependencies) { - var dep = ModIdentifier.FromDependency(dependency); - Visit(dep.Org + "/" + dep.ModuleName, dependency.Version, exact: false); - } - visiting.Remove(id); - ordered.Add(release); - } - } - - public async Task> PrepareLaunch(LaunchOptions options) { - _prepared = false; - _paths = []; - _cancellationToken.ThrowIfCancellationRequested(); - var catalog = await DllCatalog.ReadAsync(_registry, _cancellationToken); - if (catalog.Errors.Count != 0) throw new InvalidOperationException("DLL registry loading failed: " + string.Join("; ", catalog.Errors)); - var releases = Resolve(catalog.Packages, _requested.Concat(options.EnabledDlls), options.ServerLaunchOptions.IsSome, options.AllowUnstablePluginReleases); - if (releases.Any(r => r.Info.ModDependencies.Count != 0)) { - var fetched = await _mods.UpdateModsList(); - if (fetched.HasErrors) throw new InvalidOperationException("Pak registry loading failed while resolving DLL dependencies."); - } - // Resolve pak requirements into the shared mod manager before its installer runs. - foreach (var dependency in releases.SelectMany(r => r.Info.ModDependencies)) { - var id = ModIdentifier.FromDependency(dependency); - var existing = _mods.GetEnabledAndDependencyReleases().Where(r => r.Info.Organization == id.Org && r.Info.RepoName == id.ModuleName).ToArray(); - if (existing.Length != 0) { - if (existing.Any(r => r.Version is null || !SatisfiesVersion(r.Version, dependency.Version))) - throw new InvalidOperationException("DLL pak dependency conflicts with selected mods."); - continue; - } - var mod = _mods.Mods.FirstOrDefault(m => m.LatestReleaseInfo.Organization == id.Org && m.LatestReleaseInfo.RepoName == id.ModuleName); - var release = mod?.Releases.Where(r => SemVersion.TryParse(r.Tag, SemVersionStyles.AllowV, out var v) - && SatisfiesVersion(v, dependency.Version)) - .OrderByDescending(r => SemVersion.Parse(r.Tag, SemVersionStyles.AllowV)).FirstOrDefault() - ?? throw new InvalidOperationException($"DLL pak dependency unavailable: {id.Org}/{id.ModuleName}"); - var coordinate = ReleaseCoordinates.FromRelease(release); - if (!_mods.EnableModRelease(coordinate)) throw new InvalidOperationException("Could not enable DLL pak dependency."); - } - var resolvedPaks = _mods.GetEnabledAndDependencyReleases().ToArray(); - foreach (var dependency in releases.SelectMany(r => r.Info.ModDependencies)) { - var id = ModIdentifier.FromDependency(dependency); - var matches = resolvedPaks.Where(r => r.Info.Organization == id.Org && r.Info.RepoName == id.ModuleName).ToArray(); - if (matches.Length == 0 || matches.Any(r => r.Version is null || !SatisfiesVersion(r.Version, dependency.Version))) - throw new InvalidOperationException("Resolved pak dependencies do not satisfy DLL requirements."); - } - if (resolvedPaks.GroupBy(r => (r.Info.Organization, r.Info.RepoName)).Any(g => g.Select(r => r.Tag).Distinct().Count() > 1)) - throw new InvalidOperationException("Resolved pak dependencies contain conflicting versions."); - var paths = releases.Select(r => SafeFileName(r.DllFileName)).ToArray(); - if (paths.Distinct(StringComparer.OrdinalIgnoreCase).Count() != paths.Length) throw new InvalidOperationException("DLL filename collision."); - Directory.CreateDirectory(_directory); - for (var i = 0; i < releases.Count; i++) { - _cancellationToken.ThrowIfCancellationRequested(); - var release = releases[i]; - var legacyPath = Path.Combine(_directory, paths[i]); - if (File.Exists(legacyPath) && await MatchesFileAsync(legacyPath, release)) { - paths[i] = legacyPath; - continue; - } - var path = Path.Combine(_directory, ".dll-managed", release.Hash.ToLowerInvariant(), paths[i]); - paths[i] = path; - if (File.Exists(path) && await MatchesFileAsync(path, release)) continue; - if (!options.CheckForDependencyUpdates) throw new InvalidOperationException("DLL missing or not registry-approved with dependency updates disabled."); - Directory.CreateDirectory(Path.GetDirectoryName(path)!); - var temporary = Path.Combine(_directory, ".dll-download-" + Guid.NewGuid().ToString("N")); - try { - using var response = await _client.GetAsync(release.DownloadUrl, HttpCompletionOption.ResponseHeadersRead, _cancellationToken); - response.EnsureSuccessStatusCode(); - if (response.RequestMessage?.RequestUri is { Scheme: not "https" }) - throw new InvalidOperationException("DLL download redirected to an insecure URL."); - if (response.Content.Headers.ContentLength is { } length && length != release.Size) - throw new InvalidOperationException("DLL download size does not match registry."); - await using (var destination = new FileStream(temporary, FileMode.CreateNew, FileAccess.Write, FileShare.None)) - await response.Content.CopyToAsync(destination, _cancellationToken); - if (!await MatchesFileAsync(temporary, release)) throw new InvalidOperationException("DLL download size or SHA-256 mismatch."); - File.Move(temporary, path, overwrite: true); - } - finally { if (File.Exists(temporary)) File.Delete(temporary); } - } - _paths = paths; - _preparedReleases = releases; - var inventory = System.Text.Json.JsonSerializer.Serialize(releases.Select((release, i) => new { - Repository = release.Info.RepoUrl, - release.Tag, - release.Hash, - release.Size, - Path = _paths[i] - })); - var inventoryPath = Path.Combine(_directory, "dll_manager.json"); - var inventoryTemp = inventoryPath + "." + Guid.NewGuid().ToString("N"); - try { - await File.WriteAllTextAsync(inventoryTemp, inventory); - File.Move(inventoryTemp, inventoryPath, overwrite: true); - } - finally { if (File.Exists(inventoryTemp)) File.Delete(inventoryTemp); } - _prepared = true; - return options; - } - - public static string SafeFileName(string name) { - if (string.IsNullOrWhiteSpace(name) || name.Contains('/') || name.Contains('\\') || name.Contains(':') - || name.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0 || name.IndexOfAny(['<', '>', '"', '|', '?', '*']) >= 0 - || name.Any(char.IsControl) || !name.EndsWith(".dll", StringComparison.OrdinalIgnoreCase)) - throw new InvalidOperationException("Invalid DLL filename."); - return name; - } - - public static async Task MatchesFileAsync(string path, DllRelease release) { - if (new FileInfo(path).Length != release.Size) return false; - await using var file = File.OpenRead(path); - return Convert.ToHexString(await SHA256.HashDataAsync(file)).Equals(release.Hash, StringComparison.OrdinalIgnoreCase); - } - - public bool Inject(System.Diagnostics.Process process) { - if (!_prepared || _paths.Any(p => !File.Exists(p))) return false; - for (var i = 0; i < _paths.Length; i++) - if (!MatchesFileAsync(_paths[i], _preparedReleases[i]).GetAwaiter().GetResult()) return false; - return new DllInjector(_directory, _unsafe, _paths).Inject(process); - } -} \ No newline at end of file diff --git a/UnchainedLauncher.Core/Services/Mods/Registry/DllCatalog.cs b/UnchainedLauncher.Core/Services/Mods/Registry/DllCatalog.cs index efde7f8c..a65585d4 100644 --- a/UnchainedLauncher.Core/Services/Mods/Registry/DllCatalog.cs +++ b/UnchainedLauncher.Core/Services/Mods/Registry/DllCatalog.cs @@ -1,87 +1,85 @@ -using System.Net; using System.Text.Json; +using System.Text.RegularExpressions; using UnchainedLauncher.Core.JsonModels; -using UnchainedLauncher.Core.Utilities; +using ManagedDllManager = UnchainedLauncher.Core.Services.Dlls.DllManager; +using ManagedDllRelease = UnchainedLauncher.Core.Services.Dlls.DllRelease; +using PluginInfo = UnchainedLauncher.Core.Services.Dlls.PluginInfo; namespace UnchainedLauncher.Core.Services.Mods.Registry; public sealed record DllCatalogResult(IReadOnlyList Packages, IReadOnlyList Errors); -/// Read-only typed view of dll_db alongside the existing pak registry. +/// CLI query projection of the shared, validated DLL registry records. public static class DllCatalog { private static readonly HttpClient Client = new() { Timeout = TimeSpan.FromSeconds(15) }; public static async Task ReadAsync(AggregateModRegistry registry, CancellationToken cancellationToken) { - var packages = new Dictionary(); + var releases = new Dictionary(StringComparer.OrdinalIgnoreCase); var errors = new List(); foreach (var source in registry.ModRegistries) { try { if (source is GithubModRegistry github) { var root = $"https://raw.githubusercontent.com/{github.Organization}/{github.RepoName}/refs/heads/main/dll_db"; - using var index = await Client.GetAsync(root + "/plugin_list_index.txt", cancellationToken); - if (index.StatusCode == HttpStatusCode.NotFound) continue; // Pak-only registry. - index.EnsureSuccessStatusCode(); - var text = await index.Content.ReadAsStringAsync(cancellationToken); - foreach (var id in RegistryUtils.ParseLineSeparatedPackageList(text.Trim())) { - cancellationToken.ThrowIfCancellationRequested(); - try { - var json = await Client.GetStringAsync($"{root}/packages/{Uri.EscapeDataString(id.Org)}/{Uri.EscapeDataString(id.ModuleName)}.json", cancellationToken); - Add(json, id); - } - catch (Exception ex) when (IsReadFailure(ex, cancellationToken)) { errors.Add($"{source.Name}: {id.Org}/{id.ModuleName}: {ex.Message}"); } - } + foreach (var release in await ManagedDllManager.ReadRegistryAsync(Client, root, cancellationToken)) Add(release); } else if (source is LocalModRegistry local) { - var path = Path.Combine(local.RegistryPath, "dll_db", "packages"); - if (!Directory.Exists(path)) continue; - foreach (var file in Directory.EnumerateFiles(path, "*.json", SearchOption.AllDirectories)) { - try { Add(await File.ReadAllTextAsync(file, cancellationToken), null); } - catch (Exception ex) when (IsReadFailure(ex, cancellationToken)) { errors.Add($"{source.Name}: {file}: {ex.Message}"); } + var root = Path.Combine(local.RegistryPath, "dll_db"); + var packages = Path.Combine(root, "packages"); + var index = Path.Combine(root, "plugin_list_index.txt"); + if (!Directory.Exists(packages) && !File.Exists(index)) continue; + var ids = File.Exists(index) + ? (await File.ReadAllLinesAsync(index, cancellationToken)).Select(line => line.Trim()).Where(line => line.Length != 0).ToArray() + : Directory.GetFiles(packages, "*.json", SearchOption.AllDirectories) + .Select(file => Path.GetRelativePath(packages, file)[..^5].Replace(Path.DirectorySeparatorChar, '/')).ToArray(); + if (ids.Distinct(StringComparer.OrdinalIgnoreCase).Count() != ids.Length) + throw new InvalidDataException("Duplicate plugin package identifier."); + foreach (var id in ids) { + try { + if (!Regex.IsMatch(id, "^[A-Za-z0-9_-]+/[A-Za-z0-9_-]+$")) + throw new InvalidDataException("Invalid plugin package identifier."); + var path = Path.Combine(packages, id.Replace('/', Path.DirectorySeparatorChar) + ".json"); + foreach (var release in ManagedDllManager.ParsePackage(await File.ReadAllTextAsync(path, cancellationToken), id)) Add(release); + } + catch (Exception ex) when (IsReadFailure(ex, cancellationToken)) { errors.Add($"{source.Name}: {id}: {ex.Message}"); } } } else errors.Add($"DLL queries are not supported by registry: {source.Name}"); } catch (Exception ex) when (IsReadFailure(ex, cancellationToken)) { errors.Add($"{source.Name}: {ex.Message}"); } } - return new DllCatalogResult(packages.Values.OrderBy(package => package.LatestReleaseInfo.Identifier).ToArray(), errors); + return new DllCatalogResult(releases.Values.GroupBy(release => release.Id, StringComparer.OrdinalIgnoreCase) + .Select(Project).OrderBy(package => package.LatestReleaseInfo.Identifier).ToArray(), errors); - void Add(string json, ModIdentifier? expected) { - var decoded = JsonHelpers.Deserialize(json); - if (!decoded.Success || decoded.Result is null) throw new JsonException("Invalid DLL registry metadata."); - var package = decoded.Result; - ValidateInfo(package.LatestReleaseInfo); - var id = package.LatestReleaseInfo.Identifier; - if (expected is not null && !expected.Matches(id)) throw new JsonException("DLL metadata does not match indexed identity."); - if (package.Releases is null || package.Releases.Any(release => release is null || release.Size < 0 || release.Info is null - || !id.Matches(release.Info.Identifier) || string.IsNullOrWhiteSpace(release.Tag) || string.IsNullOrWhiteSpace(release.Hash) - || string.IsNullOrWhiteSpace(release.DllFileName) || release.Info.Target is not ("client" or "server" or "shared"))) - throw new JsonException("Invalid DLL release metadata."); - foreach (var release in package.Releases) { - ValidateInfo(release.Info); - if (release.Hash.Length != 64 || Convert.FromHexString(release.Hash).Length != 32) - throw new JsonException("DLL hash must be SHA-256."); - if (!Uri.TryCreate(release.DownloadUrl, UriKind.Absolute, out var download) || download.Scheme != "https" || download.UserInfo.Length != 0) - throw new JsonException("DLL download URL must be HTTPS without credentials."); - } - if (packages.TryGetValue(id, out var previous)) { - var merged = previous.Releases.Concat(package.Releases).DistinctBy(release => release.Tag).ToList(); - packages[id] = previous with { Releases = merged }; - } - else packages.Add(id, package); + void Add(ManagedDllRelease release) { + if (releases.TryGetValue(release.Key, out var old) && ManagedDllManager.Canonical(old) != ManagedDllManager.Canonical(release)) + throw new InvalidDataException($"Conflicting DLL registry records: {release.Key}"); + releases[release.Key] = release; } + } - static void ValidateInfo(DllInfo? info) { - if (info is null || string.IsNullOrWhiteSpace(info.Name) || string.IsNullOrWhiteSpace(info.Description) - || info.Authors is null || info.Authors.Count == 0 || info.Authors.Any(string.IsNullOrWhiteSpace) - || info.PluginDependencies is null || info.ModDependencies is null || info.Target is not ("server" or "client" or "shared")) - throw new JsonException("Invalid DLL package information."); - foreach (var dependency in info.PluginDependencies.Concat(info.ModDependencies)) { - if (dependency is null || string.IsNullOrWhiteSpace(dependency.Version)) throw new JsonException("Invalid DLL dependency."); - DllInfo.RepositoryIdentifier(dependency.RepoUrl); - } - } + private static DllPackage Project(IEnumerable group) { + var records = group.ToArray(); + var id = records[0].Id; + var latest = records.OrderByDescending(release => release.ReleaseDate).FirstOrDefault(release => release.Info != null)?.Info; + DllInfo Info(PluginInfo? info) => new() { + RepoUrl = "https://github.com/" + id, + Name = info?.Name ?? records[0].Name, + Description = info?.Description ?? "Metadata unavailable for this historical release.", + Authors = info?.Authors.ToList() ?? [], + Target = info?.Target ?? "shared", + IconUrl = info?.IconUrl, + ImageUrls = info?.ImageUrls?.ToList() ?? [], + PluginDependencies = info?.RequiredPlugins.ToList() ?? [], + ModDependencies = info?.RequiredMods.ToList() ?? [] + }; + return new DllPackage(Info(latest), records.Select(release => new DllRelease( + release.Tag ?? release.Version, release.Sha256, release.DllFileName ?? release.Name + ".dll", release.DownloadUrl, + release.Size, release.ReleaseDate ?? DateTimeOffset.MinValue, Info(release.Info), release.ReleaseNotesMarkdown) { + RegisteredRelease = release + }).ToList()); } - private static bool IsReadFailure(Exception ex, CancellationToken token) => ex is HttpRequestException or IOException or UnauthorizedAccessException or JsonException or ArgumentException or FormatException + private static bool IsReadFailure(Exception ex, CancellationToken token) => ex is HttpRequestException or IOException or InvalidDataException + or UnauthorizedAccessException or JsonException or ArgumentException or FormatException || ex is OperationCanceledException && !token.IsCancellationRequested; } \ No newline at end of file diff --git a/UnchainedLauncher.Core/Services/Processes/DllInjector.cs b/UnchainedLauncher.Core/Services/Processes/DllInjector.cs index 98d058be..b9bc74fa 100644 --- a/UnchainedLauncher.Core/Services/Processes/DllInjector.cs +++ b/UnchainedLauncher.Core/Services/Processes/DllInjector.cs @@ -1,144 +1,170 @@ -using log4net; -using System.Diagnostics; -using System.Runtime.InteropServices; -using System.Text; -using UnchainedLauncher.Core.Extensions; -using UnchainedLauncher.Core.Services.Dlls; - -namespace UnchainedLauncher.Core.Services.Processes { - //Code modified/adapted from https://codingvision.net/c-inject-a-dll-into-a-process-w-createremotethread - public class DllInjector : IProcessInjector { - private readonly ILog _logger = LogManager.GetLogger(nameof(DllInjector)); - - [DllImport("kernel32.dll")] - public static extern IntPtr OpenProcess(int dwDesiredAccess, bool bInheritHandle, int dwProcessId); - - [DllImport("kernel32.dll", CharSet = CharSet.Auto)] - public static extern IntPtr GetModuleHandle(string lpModuleName); - - [DllImport("kernel32.dll", CharSet = CharSet.Ansi, ExactSpelling = true, SetLastError = true)] - static extern IntPtr GetProcAddress(IntPtr hModule, string procName); - - [DllImport("kernel32.dll", SetLastError = true, ExactSpelling = true)] - static extern IntPtr VirtualAllocEx(IntPtr hProcess, IntPtr lpAddress, - uint dwSize, uint flAllocationType, uint flProtect); - - [DllImport("kernel32.dll", SetLastError = true, ExactSpelling = true)] - static extern bool VirtualFreeEx(IntPtr hProcess, IntPtr lpAddress, - uint dwSize, uint dwFreeType); - - [DllImport("kernel32.dll", SetLastError = true)] - static extern bool WriteProcessMemory(IntPtr hProcess, IntPtr lpBaseAddress, byte[] lpBuffer, uint nSize, out UIntPtr lpNumberOfBytesWritten); - - [DllImport("kernel32.dll")] - static extern IntPtr CreateRemoteThread(IntPtr hProcess, - IntPtr lpThreadAttributes, uint dwStackSize, IntPtr lpStartAddress, IntPtr lpParameter, uint dwCreationFlags, IntPtr lpThreadId); - - [DllImport("kernel32.dll")] - static extern uint WaitForSingleObject(IntPtr hProcess, uint dwMilliseconds); - - [DllImport("kernel32.dll")] - static extern bool CloseHandle(IntPtr handle); - - [DllImport("kernel32.dll")] - static extern bool GetExitCodeThread(IntPtr thread, out uint exitCode); - - // privileges - const int ProcessCreateThread = 0x0002; - const int ProcessQueryInformation = 0x0400; - const int ProcessVmOperation = 0x0008; - const int ProcessVmWrite = 0x0020; - const int ProcessVmRead = 0x0010; - - // used for memory allocation - const uint MemCommit = 0x00001000; - const uint MemReserve = 0x00002000; - const uint MemRelease = 0x00008000; - - const uint PageReadwrite = 4; - - private readonly DllManager _dllManager; - - public DllInjector(DllManager dllManager) { - _dllManager = dllManager; - } - - public bool Inject(Process p) => Inject(p, server: false); - - public bool Inject(Process p, bool server) { - IReadOnlyList files = Array.Empty(); - var procHandle = IntPtr.Zero; - var allocMemAddress = IntPtr.Zero; - try { - files = _dllManager.OpenForInjection(server); - var paths = files.Select(file => file.Path).ToArray(); - - if (paths.Length == 0) { - _logger.Info("No dlls present for injection"); - return true; - } - - //Paths to be injected MUST be absolute - paths = paths.Select(p => Path.GetFullPath(p)).ToArray(); - - _logger.LogListInfo("Injecting DLLs: ", paths); - - procHandle = OpenProcess(ProcessCreateThread | - ProcessQueryInformation | ProcessVmOperation | - ProcessVmWrite | ProcessVmRead, false, p.Id); - if (procHandle == IntPtr.Zero) { - return false; - } - - var maxLen = paths.Max(p => p.Length); - var allocSize = checked((uint)((maxLen + 1) * sizeof(char))); - - // Unicode paths must be encoded exactly as LoadLibraryW expects. - var loadLibraryAddr = GetProcAddress(GetModuleHandle("kernel32.dll"), "LoadLibraryW"); - if (loadLibraryAddr == IntPtr.Zero) { - return false; - } - - // alocating some memory on the target process - enough to store the name of the dll - // and storing its address in a pointer - allocMemAddress = VirtualAllocEx(procHandle, - IntPtr.Zero, - allocSize, - MemCommit | MemReserve, PageReadwrite); - if (allocMemAddress == IntPtr.Zero) { - return false; - } - - foreach (var path in paths) { - // writing the name of the dll there - var bytes = Encoding.Unicode.GetBytes(path + '\0'); - var res = WriteProcessMemory(procHandle, allocMemAddress, - bytes, (uint)bytes.Length, - out var bytesWritten); - if (!res || bytesWritten.ToUInt64() != (ulong)bytes.Length) { return false; } - - //inject - var thread = CreateRemoteThread(procHandle, IntPtr.Zero, 0, loadLibraryAddr, allocMemAddress, 0, - IntPtr.Zero); - if (thread == IntPtr.Zero) return false; - try { - if (WaitForSingleObject(thread, unchecked((uint)-1)) != 0 || - !GetExitCodeThread(thread, out var result) || result == 0) return false; - } - finally { CloseHandle(thread); } - } - - return true; - } - catch (Exception e) { - _logger.Error("Injection failed", e); - return false; - } - finally { - if (allocMemAddress != IntPtr.Zero) VirtualFreeEx(procHandle, allocMemAddress, 0, MemRelease); - if (procHandle != IntPtr.Zero) CloseHandle(procHandle); - foreach (var file in files) file.Dispose(); - } - } - } +using log4net; +using System.Diagnostics; +using System.Runtime.InteropServices; +using System.Text; +using UnchainedLauncher.Core.Extensions; +using UnchainedLauncher.Core.Services.Dlls; + +namespace UnchainedLauncher.Core.Services.Processes { + //Code modified/adapted from https://codingvision.net/c-inject-a-dll-into-a-process-w-createremotethread + public class DllInjector : IProcessInjector { + private readonly ILog _logger = LogManager.GetLogger(nameof(DllInjector)); + + [DllImport("kernel32.dll")] + public static extern IntPtr OpenProcess(int dwDesiredAccess, bool bInheritHandle, int dwProcessId); + + [DllImport("kernel32.dll", CharSet = CharSet.Auto)] + public static extern IntPtr GetModuleHandle(string lpModuleName); + + [DllImport("kernel32.dll", CharSet = CharSet.Ansi, ExactSpelling = true, SetLastError = true)] + static extern IntPtr GetProcAddress(IntPtr hModule, string procName); + + [DllImport("kernel32.dll", SetLastError = true, ExactSpelling = true)] + static extern IntPtr VirtualAllocEx(IntPtr hProcess, IntPtr lpAddress, + uint dwSize, uint flAllocationType, uint flProtect); + + [DllImport("kernel32.dll", SetLastError = true, ExactSpelling = true)] + static extern bool VirtualFreeEx(IntPtr hProcess, IntPtr lpAddress, + uint dwSize, uint dwFreeType); + + [DllImport("kernel32.dll", SetLastError = true)] + static extern bool WriteProcessMemory(IntPtr hProcess, IntPtr lpBaseAddress, byte[] lpBuffer, uint nSize, out UIntPtr lpNumberOfBytesWritten); + + [DllImport("kernel32.dll")] + static extern IntPtr CreateRemoteThread(IntPtr hProcess, + IntPtr lpThreadAttributes, uint dwStackSize, IntPtr lpStartAddress, IntPtr lpParameter, uint dwCreationFlags, IntPtr lpThreadId); + + [DllImport("kernel32.dll")] + static extern uint WaitForSingleObject(IntPtr hProcess, uint dwMilliseconds); + + [DllImport("kernel32.dll")] + static extern bool CloseHandle(IntPtr handle); + + [DllImport("kernel32.dll")] + static extern bool GetExitCodeThread(IntPtr thread, out uint exitCode); + + // privileges + const int ProcessCreateThread = 0x0002; + const int ProcessQueryInformation = 0x0400; + const int ProcessVmOperation = 0x0008; + const int ProcessVmWrite = 0x0020; + const int ProcessVmRead = 0x0010; + + // used for memory allocation + const uint MemCommit = 0x00001000; + const uint MemReserve = 0x00002000; + const uint MemRelease = 0x00008000; + + const uint PageReadwrite = 4; + + private readonly DllManager? _dllManager; + private readonly string? _dllDirectory; + private readonly bool _unsafeLoadAllDlls; + + public DllInjector(DllManager dllManager, string? dllDirectory = null, bool unsafeLoadAllDlls = false) { + _dllManager = dllManager; + _dllDirectory = dllDirectory; + _unsafeLoadAllDlls = unsafeLoadAllDlls; + if (unsafeLoadAllDlls && dllDirectory == null) throw new ArgumentException("Unsafe injection requires a plugin directory.", nameof(dllDirectory)); + } + + public DllInjector(string dllDirectory, bool unsafeLoadAllDlls = false) { + _dllDirectory = dllDirectory; + _unsafeLoadAllDlls = unsafeLoadAllDlls; + } + + public IEnumerable GetDllPaths(bool server = false) { + if (_unsafeLoadAllDlls) return UnsafePaths(); + if (_dllManager == null) return []; + var files = _dllManager.OpenForInjection(server); + try { return files.Select(file => file.Path).ToArray(); } + finally { foreach (var file in files) file.Dispose(); } + } + + private string[] UnsafePaths() => _dllDirectory != null && Directory.Exists(_dllDirectory) + ? Directory.EnumerateFiles(_dllDirectory, "*", SearchOption.AllDirectories) + .Where(path => Path.GetExtension(path).Equals(".dll", StringComparison.OrdinalIgnoreCase)) + .Select(Path.GetFullPath).OrderBy(path => path, StringComparer.Ordinal).ToArray() + : []; + + public bool Inject(Process p) => Inject(p, server: false); + + public bool Inject(Process p, bool server) { + IReadOnlyList files = Array.Empty(); + var procHandle = IntPtr.Zero; + var allocMemAddress = IntPtr.Zero; + try { + if (_dllManager == null && !_unsafeLoadAllDlls) return false; + if (_dllManager != null) files = _dllManager.OpenForInjection(server); + var paths = files.Select(file => file.Path).ToArray(); + if (_unsafeLoadAllDlls) paths = paths.Concat(UnsafePaths()).Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); + + if (paths.Length == 0) { + _logger.Info("No dlls present for injection"); + return true; + } + + //Paths to be injected MUST be absolute + paths = paths.Select(p => Path.GetFullPath(p)).ToArray(); + + _logger.LogListInfo("Injecting DLLs: ", paths); + + procHandle = OpenProcess(ProcessCreateThread | + ProcessQueryInformation | ProcessVmOperation | + ProcessVmWrite | ProcessVmRead, false, p.Id); + if (procHandle == IntPtr.Zero) { + return false; + } + + var maxLen = paths.Max(p => p.Length); + var allocSize = checked((uint)((maxLen + 1) * sizeof(char))); + + // Unicode paths must be encoded exactly as LoadLibraryW expects. + var loadLibraryAddr = GetProcAddress(GetModuleHandle("kernel32.dll"), "LoadLibraryW"); + if (loadLibraryAddr == IntPtr.Zero) { + return false; + } + + // alocating some memory on the target process - enough to store the name of the dll + // and storing its address in a pointer + allocMemAddress = VirtualAllocEx(procHandle, + IntPtr.Zero, + allocSize, + MemCommit | MemReserve, PageReadwrite); + if (allocMemAddress == IntPtr.Zero) { + return false; + } + + foreach (var path in paths) { + // writing the name of the dll there + var bytes = Encoding.Unicode.GetBytes(path + '\0'); + var res = WriteProcessMemory(procHandle, allocMemAddress, + bytes, (uint)bytes.Length, + out var bytesWritten); + if (!res || bytesWritten.ToUInt64() != (ulong)bytes.Length) { return false; } + + //inject + var thread = CreateRemoteThread(procHandle, IntPtr.Zero, 0, loadLibraryAddr, allocMemAddress, 0, + IntPtr.Zero); + if (thread == IntPtr.Zero) return false; + try { + if (WaitForSingleObject(thread, unchecked((uint)-1)) != 0 || + !GetExitCodeThread(thread, out var result) || result == 0) return false; + } + finally { CloseHandle(thread); } + } + + return true; + } + catch (Exception e) { + _logger.Error("Injection failed", e); + return false; + } + finally { + if (allocMemAddress != IntPtr.Zero) VirtualFreeEx(procHandle, allocMemAddress, 0, MemRelease); + if (procHandle != IntPtr.Zero) CloseHandle(procHandle); + foreach (var file in files) file.Dispose(); + } + } + } } \ No newline at end of file diff --git a/UnchainedLauncher.GUI.Tests.Unit/DllManagerVMTests.cs b/UnchainedLauncher.GUI.Tests.Unit/DllManagerVMTests.cs index c0491bd4..4a2bf12b 100644 --- a/UnchainedLauncher.GUI.Tests.Unit/DllManagerVMTests.cs +++ b/UnchainedLauncher.GUI.Tests.Unit/DllManagerVMTests.cs @@ -7,7 +7,7 @@ namespace UnchainedLauncher.GUI.Tests.Unit { public sealed class DllManagerVMTests { [Fact] public async Task RemovedPinnedVersionShowsUnavailableEvenWhenOtherVersionsRemainRegistered() { - var directory = Path.Combine(OperatingSystem.IsLinux() ? "/tmp/opencode" : Path.GetTempPath(), "removed-version-" + Guid.NewGuid().ToString("N")); + var directory = Path.Combine(Environment.GetEnvironmentVariable("TMPDIR") ?? (OperatingSystem.IsLinux() ? "/tmp/opencode" : Path.GetTempPath()), "removed-version-" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(directory); try { var handler = new MutableIndexHandler { Json = JsonSerializer.Serialize(new[] { Release("Plugin") }) }; @@ -42,7 +42,7 @@ public async Task RemovedPinnedVersionShowsUnavailableEvenWhenOtherVersionsRemai [InlineData(true, false)] [InlineData(true, true)] public async Task RemovedSelectionsRemainVisibleAndCanBeDisabledAfterRefreshOrRestart(bool removeRegistry, bool restart) { - var directory = Path.Combine(OperatingSystem.IsLinux() ? "/tmp/opencode" : Path.GetTempPath(), "removed-plugin-" + Guid.NewGuid().ToString("N")); + var directory = Path.Combine(Environment.GetEnvironmentVariable("TMPDIR") ?? (OperatingSystem.IsLinux() ? "/tmp/opencode" : Path.GetTempPath()), "removed-plugin-" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(directory); try { var release = Release("RemovedPlugin"); @@ -199,7 +199,7 @@ public async Task OneCardPerPluginAndEnableDisableChangesOnlyThatPlugin() { [Fact] public async Task ModernPackageCardsShowReleaseSpecificTargetsAssetsAndNotes() { - var directory = Path.Combine(OperatingSystem.IsLinux() ? "/tmp/opencode" : Path.GetTempPath(), "dll-package-view-" + Guid.NewGuid().ToString("N")); + var directory = Path.Combine(Environment.GetEnvironmentVariable("TMPDIR") ?? (OperatingSystem.IsLinux() ? "/tmp/opencode" : Path.GetTempPath()), "dll-package-view-" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(directory); try { var shared = new PluginInfo("Core", "Description", ["Author"], Target: "shared"); @@ -261,7 +261,7 @@ public bool AllowUnstablePluginReleases { [Fact] public async Task OriginalSettingsDriveDllControlsAndPrereleaseVisibility() { - var directory = Path.Combine(OperatingSystem.IsLinux() ? "/tmp/opencode" : Path.GetTempPath(), "dll-settings-view-" + Guid.NewGuid().ToString("N")); + var directory = Path.Combine(Environment.GetEnvironmentVariable("TMPDIR") ?? (OperatingSystem.IsLinux() ? "/tmp/opencode" : Path.GetTempPath()), "dll-settings-view-" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(directory); try { var releases = new[] { "1.0.0", "2.0.0-RC1" }.Select(version => new DllRelease("Chiv2-Community", "UnchainedPlugin", version, @@ -350,7 +350,7 @@ protected override Task SendAsync(HttpRequestMessage reques [Fact] public async Task PluginLinksOpenRepositoryAndOriginalDisplayedTagWithoutChangingSelection() { - var directory = Path.Combine(OperatingSystem.IsLinux() ? "/tmp/opencode" : Path.GetTempPath(), "plugin-links-" + Guid.NewGuid().ToString("N")); + var directory = Path.Combine(Environment.GetEnvironmentVariable("TMPDIR") ?? (OperatingSystem.IsLinux() ? "/tmp/opencode" : Path.GetTempPath()), "plugin-links-" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(directory); try { var handler = new MutableIndexHandler { Json = JsonSerializer.Serialize(new[] { @@ -395,7 +395,7 @@ private static DllRelease Release(string name, string version = "1.0.0", string [Fact] public async Task PluginSearchAndSharedSortModesRebuildWithoutChangingSelections() { - var directory = Path.Combine(OperatingSystem.IsLinux() ? "/tmp/opencode" : Path.GetTempPath(), "plugin-sort-" + Guid.NewGuid().ToString("N")); + var directory = Path.Combine(Environment.GetEnvironmentVariable("TMPDIR") ?? (OperatingSystem.IsLinux() ? "/tmp/opencode" : Path.GetTempPath()), "plugin-sort-" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(directory); try { var handler = new MutableIndexHandler { Json = JsonSerializer.Serialize(new[] { @@ -465,7 +465,7 @@ public UnchainedLauncher.Core.Services.UserDialogueChoice DisplayYesNoMessage(st [InlineData(true, false)] [InlineData(true, true)] public async Task UpdatePluginsConfirmsEligibleVisibleEnabledVersionsWithoutDownloading(bool unstable, bool approve) { - var directory = Path.Combine(OperatingSystem.IsLinux() ? "/tmp/opencode" : Path.GetTempPath(), "plugin-update-" + Guid.NewGuid().ToString("N")); + var directory = Path.Combine(Environment.GetEnvironmentVariable("TMPDIR") ?? (OperatingSystem.IsLinux() ? "/tmp/opencode" : Path.GetTempPath()), "plugin-update-" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(directory); try { var handler = new MutableIndexHandler { Json = JsonSerializer.Serialize(new[] { diff --git a/UnchainedLauncher.GUI.Tests.Unit/RegistryReleaseFormVMTests.cs b/UnchainedLauncher.GUI.Tests.Unit/RegistryReleaseFormVMTests.cs index fed6b994..06680a87 100644 --- a/UnchainedLauncher.GUI.Tests.Unit/RegistryReleaseFormVMTests.cs +++ b/UnchainedLauncher.GUI.Tests.Unit/RegistryReleaseFormVMTests.cs @@ -13,7 +13,7 @@ public sealed class RegistryReleaseFormVMTests { [InlineData(true, false)] [InlineData(true, true)] public void EditAndNewReleasePreservePluginAndPakRequirements(bool newRelease, bool modernPakDependencies) { - var path = Path.Combine(OperatingSystem.IsLinux() ? "/tmp/opencode" : Path.GetTempPath(), "form-test-" + Guid.NewGuid().ToString("N") + ".pak"); + var path = Path.Combine(Environment.GetEnvironmentVariable("TMPDIR") ?? (OperatingSystem.IsLinux() ? "/tmp/opencode" : Path.GetTempPath()), "form-test-" + Guid.NewGuid().ToString("N") + ".pak"); File.WriteAllText(path, "pak fixture"); try { var pak = new Dependency("https://github.com/Org/Pak", ">=1.0.0"); diff --git a/UnchainedLauncher.GUI/App.xaml.cs b/UnchainedLauncher.GUI/App.xaml.cs index 816592b7..134099e6 100644 --- a/UnchainedLauncher.GUI/App.xaml.cs +++ b/UnchainedLauncher.GUI/App.xaml.cs @@ -1,426 +1,426 @@ -using LanguageExt; -using log4net; -using System; -using System.Collections.ObjectModel; -using System.Diagnostics; -using System.IO; -using System.Linq; -using System.Net.Http; -using System.Reflection; -using System.Threading.Tasks; -using System.Windows; -using UnchainedLauncher.Core.Extensions; -using UnchainedLauncher.Core.Services; -using UnchainedLauncher.Core.Services.Dlls; -using UnchainedLauncher.Core.Services.Installer; -using UnchainedLauncher.Core.Services.Mods; -using UnchainedLauncher.Core.Services.Mods.Registry; -using UnchainedLauncher.Core.Services.Processes; -using UnchainedLauncher.Core.Services.Processes.Chivalry; -using UnchainedLauncher.Core.Services.Processes.Chivalry.LaunchPreparers; -using UnchainedLauncher.Core.Utilities; -using UnchainedLauncher.GUI.Services; -using UnchainedLauncher.GUI.ViewModels; -using UnchainedLauncher.GUI.ViewModels.Installer; -using UnchainedLauncher.GUI.ViewModels.Registry; -using UnchainedLauncher.GUI.ViewModels.ServersTab; -using UnchainedLauncher.GUI.Views; -using UnchainedLauncher.GUI.Views.Installer; -using UnchainedLauncher.UnrealModScanner.GUI.ViewModels; -using Application = System.Windows.Application; - -namespace UnchainedLauncher.GUI { - using static LanguageExt.Prelude; - - /// - /// Interaction logic for App.xaml - /// - public partial class App : Application { - private static readonly ILog _log = LogManager.GetLogger(typeof(App)); - private bool _unsafeLoadAllDlls; - private string[] _launchArguments = []; - - protected override async void OnStartup(StartupEventArgs e) { - try { - base.OnStartup(e); - (_unsafeLoadAllDlls, _launchArguments) = UnsafeDllLoading.ParseGuiArguments(e.Args); - - AppDomain.CurrentDomain.UnhandledException += - (sender, args) => { - var ex = (Exception)args.ExceptionObject; - _log.Fatal("Unhandled exception", ex); - - File.WriteAllText("crash.log", ex.ToString()); - var currentDirectory = Directory.GetCurrentDirectory(); - MessageBox.Show($"An unhandled exception occurred. Please report this to a developer with {currentDirectory}\\crash.log ", "Error", MessageBoxButton.OK, MessageBoxImage.Error); - }; - - // Capture the UI thread's SynchronizationContext for use throughout the application - UISynchronizationContext.Initialize(); - - var assembly = Assembly.GetExecutingAssembly(); - if (File.Exists("log4net.config")) { - log4net.Config.XmlConfigurator.Configure(new FileInfo("log4net.config")); - } - else if (File.Exists("Resources/log4net.config")) { - // for running in Visual Studio - log4net.Config.XmlConfigurator.Configure(new FileInfo("Resources/log4net.config")); - } - else { - using var configStream = - assembly.GetManifestResourceStream("UnchainedLauncher.GUI.Resources.log4net.config"); - if (configStream != null) { - log4net.Config.XmlConfigurator.Configure(configStream); - } - } - - // Init common dependencies - var githubClient = new Octokit.GitHubClient(new Octokit.ProductHeaderValue("UnchainedLauncher")); - var unchainedLauncherReleaseLocator = - new GithubReleaseLocator(githubClient, "Chiv2-Community", "UnchainedLauncher"); - - - var installationFinder = new Chivalry2InstallationFinder(); - var installer = new UnchainedLauncherInstaller(Shutdown); - - // figure out if we need to install by checking our current working directory - var currentDirectory = new DirectoryInfo(Directory.GetCurrentDirectory()); - var needsInstallation = !installationFinder.IsValidInstallation(currentDirectory); - - var forceSkipInstallation = Environment.GetCommandLineArgs().ToList().Contains("--no-install"); - - if (forceSkipInstallation && needsInstallation) - _log.Info("Skipping installation"); - - var window = - needsInstallation && !forceSkipInstallation - ? InitializeInstallerWindow(installationFinder, installer, unchainedLauncherReleaseLocator) - : await InitializeMainWindow(installationFinder, installer, unchainedLauncherReleaseLocator); - - window?.Show(); - } - catch (Exception ex) { - Debug.WriteLine($"Error starting application: {ex.Message}"); - if (ex.StackTrace != null) - Debug.WriteLine(ex.StackTrace!); - - _log.Fatal("Unhandled exception", ex); - File.WriteAllText("crash.log", ex.ToString()); - var currentDirectory = Directory.GetCurrentDirectory(); - MessageBox.Show($"An unhandled exception occurred. Please report this to a developer with {currentDirectory}\\crash.log ", "Error", MessageBoxButton.OK, MessageBoxImage.Error); - } - } - - // Removed class-level command bindings; handled by Views.UnchainedWindow type. - - private Window? InitializeInstallerWindow(Chivalry2InstallationFinder installationFinder, - IUnchainedLauncherInstaller installer, IReleaseLocator launcherReleaseLocator) { - var installationSelectionVM = new InstallationSelectionPageViewModel(installationFinder); - var versionSelectionVM = new VersionSelectionPageViewModel(launcherReleaseLocator, new FileInfoVersionExtractor()); - var installationLogVM = new InstallerLogPageViewModel( - installer, - () => - from chiv2Installations in installationSelectionVM.Installations - where chiv2Installations.IsSelected - select chiv2Installations.Path - , - () => versionSelectionVM.SelectedVersion! - ); - - var installerPageViewModels = new ObservableCollection { - installationSelectionVM, versionSelectionVM, installationLogVM - }; - - var installerWindowVM = - new InstallerWindowViewModel(installerPageViewModels, installationSelectionVM.Installations); - return new InstallerWindow(installerWindowVM); - } - - private async Task InitializeMainWindow(IChivalry2InstallationFinder installationFinder, - IUnchainedLauncherInstaller installer, IReleaseLocator launcherReleaseLocator) { - var userDialogueSpawner = new MessageBoxSpawner(); - var registryWindowService = new RegistryWindowService(); - - var modRegistry = InitializeModRegistry(FilePaths.RegistryConfigPath); - var modManager = InitializeModManager(FilePaths.ModManagerConfigPath, modRegistry); - var dllManager = new DllManager( - Path.Combine(Directory.GetCurrentDirectory(), FilePaths.PluginDir), - FilePaths.DllManagerConfigPath, new HttpClient(), () => modManager.GetEnabledAndDependencyReleases().ToArray(), - () => modRegistry.ModRegistries.OfType() - .Select(registry => registry.PackageDBBaseUrl[..^"package_db".Length] + "dll_db").ToArray()); - - var registryWindowViewModel = new RegistryWindowVM(modRegistry, registryWindowService); - - var settingsViewModel = InitializeSettingsViewModel(installationFinder, installer, launcherReleaseLocator, registryWindowViewModel, registryWindowService, modManager, userDialogueSpawner); - var dllManagerViewModel = new DllManagerVM(dllManager, settingsViewModel, userDialogueSpawner); - await dllManagerViewModel.RefreshCommand.ExecuteAsync(null); - -#if DEBUG_FAKECHIVALRYLAUNCH - var officialProcessLauncher = new PowershellProcessLauncher( - "Official Chivalry 2" - ); - var unchainedProcessLauncher = new PowershellProcessLauncher( - "Unchained Chivalry 2" - ); -#else - var originalLauncherPath = File.Exists(FilePaths.OriginalLauncherPath) - ? FilePaths.OriginalLauncherPath - : FilePaths.LauncherPath; - var officialProcessLauncher = - new ProcessLauncher(Path.Combine(Directory.GetCurrentDirectory(), originalLauncherPath)); - var unchainedProcessLauncher = - new ProcessLauncher(Path.Combine(Directory.GetCurrentDirectory(), FilePaths.GameBinPath)); -#endif - - var noSigLaunchPreparer = NoSigPreparer.Create(modManager.PakDir, userDialogueSpawner); - var sigLaunchPreparer = SigPreparer.Create(modManager.PakDir, userDialogueSpawner); - - IChivalry2LaunchPreparer pluginInstaller = new DllLaunchPreparer(dllManager, userDialogueSpawner); - - IChivalry2LaunchPreparer modInstaller = new Chivalry2ModsInstaller( - modManager, userDialogueSpawner - ); - - var vanillaLauncher = new Chivalry2Launcher( - noSigLaunchPreparer.IgnoreOptions(), - officialProcessLauncher, - Directory.GetCurrentDirectory() - ); - - var unchainedLauncher = new UnchainedChivalry2Launcher( - pluginInstaller - .AndThen(modInstaller) - .Sub(sigLaunchPreparer), - unchainedProcessLauncher, - Directory.GetCurrentDirectory(), -#if DEBUG_FAKECHIVALRYLAUNCH - // don't shove dlls into processes that don't need them and cause crashes - new NullInjector(true) -#else - new DllInjector(dllManager) -#endif - ); - - var chivProcessMonitor = new ChivalryProcessWatcher(); - - var homeViewModel = new HomeVM( - settingsViewModel, - modManager, - vanillaLauncher, - unchainedLauncher, - userDialogueSpawner, - chivProcessMonitor); - var modListViewModel = new ModListVM(modManager, userDialogueSpawner); - - if (modManager.NeedsMetadataRefresh || !modManager.Mods.Any()) - await modListViewModel.RefreshModListCommand.ExecuteAsync(null); - - // Rebuild the arguments used to launch the application - var envArgs = Environment.GetCommandLineArgs().Skip(1).ToList(); - - // TODO: Replace this if/else chain with a real CLI - if (envArgs.Contains("--startvanilla")) { - homeViewModel.LaunchVanilla().Wait(); - return null; - } - - if (envArgs.Contains("--startunchained")) { - homeViewModel.LaunchUnchained().Wait(); - return null; - } - - var modScanTab = new ModScanTabVM(); - var helpVM = new HelpVM(settingsViewModel, installer, launcherReleaseLocator, modManager.PakDir, userDialogueSpawner, Shutdown); - var availableModsAndMaps = new AvailableModsAndMapsService(modManager, modScanTab); - - var serverConfigurationVMs = - InitializeServerConfigurations(FilePaths.ServerConfigurationsFilePath, modManager, modScanTab, availableModsAndMaps); - - var serversTabViewModel = InitializeServersTab( - FilePaths.ServersTabConfigurationPath, - settingsViewModel, - modManager, - modScanTab, - userDialogueSpawner, - unchainedLauncher, - serverConfigurationVMs, - chivProcessMonitor, - availableModsAndMaps - ); - - var mainWindowViewModel = new MainWindowVM( - homeViewModel, - modListViewModel, - settingsViewModel, - serversTabViewModel, - modScanTab, - helpVM, - dllManagerViewModel - ); - - return new MainWindow(mainWindowViewModel); - } - - private SettingsVM InitializeSettingsViewModel(IChivalry2InstallationFinder installationFinder, - IUnchainedLauncherInstaller installer, IReleaseLocator launcherReleaseLocator, - RegistryWindowVM registryWindowViewModel, RegistryWindowService registryWindowService, ModManager modManager, - MessageBoxSpawner userDialogueSpawner) { - var cliArgs = string.Join(" ", - _launchArguments - .ToList() - .Select(ArgumentEscaper.Escape) - ); - - var settingsCodec = new SettingsCodec( - registryWindowViewModel, - registryWindowService, - installationFinder, - cliArgs - ); - - var settingsViewModel = InitializeFromFileWithCodec( - settingsCodec, - FilePaths.LauncherSettingsFilePath, - () => new SettingsVM( - registryWindowViewModel, - registryWindowService, - SettingsVM.DetectInstallationType(installationFinder), - true, - false, - "", - "https://servers.polehammer.net", - false, - SettingsVM.Version.IsPrerelease, - cliArgs - ) - ); - - RegisterSaveToFileOnExit(settingsViewModel, settingsCodec, FilePaths.LauncherSettingsFilePath); - return settingsViewModel; - } - - private AggregateModRegistry InitializeModRegistry(string jsonPath) { - AggregateModRegistry CreateDefaultModRegistry() => new AggregateModRegistry( - new GithubModRegistry("Chiv2-Community", "C2ModRegistry")); - - var loadedResult = - InitializeFromFileWithCodec(ModRegistryCodec.Instance, jsonPath, CreateDefaultModRegistry); - // Ensure that we've got an AggregateModRegistry. The constructor will handle it if we're wrapping - // another AggregateModRegistry, so no worries there. - var registry = new AggregateModRegistry(loadedResult); - - RegisterSaveToFileOnExit(registry, ModRegistryCodec.Instance, jsonPath); - return registry; - } - - private ModManager InitializeModManager(string jsonPath, IModRegistry registry) { - Func initializeDefaultModManager = () => - new ModManager( - registry, - new PakDir(FilePaths.PakDir, Enumerable.Empty()), - Enumerable.Empty() - ); - - var codec = new ModManagerCodec(registry); - var modManager = InitializeFromFileWithCodec(codec, jsonPath, initializeDefaultModManager); - - RegisterSaveToFileOnExit(modManager, codec, jsonPath); - return modManager; - } - - private ObservableCollection InitializeServerConfigurations(string jsonPath, - IModManager modManager, ModScanTabVM modScanTab, AvailableModsAndMapsService availableModsAndMapsVM) { - Func> initializeDefault = - () => new ObservableCollection(); - - var codec = new ServerConfigurationCodec(modManager, modScanTab, availableModsAndMapsVM); - var serverConfigurations = InitializeFromFileWithCodec(codec, jsonPath, initializeDefault); - - RegisterSaveToFileOnExit(serverConfigurations, codec, jsonPath); - Exit += (_, _) => serverConfigurations.ForEach(x => x.SaveINI()); - - return serverConfigurations; - } - - private ServersTabVM InitializeServersTab( - string jsonPath, - SettingsVM settings, - IModManager modManager, - ModScanTabVM modScanTab, - IUserDialogueSpawner dialogueSpawner, - IChivalry2Launcher launcher, - ObservableCollection serverConfigurations, - IChivalryProcessWatcher processWatcher, - AvailableModsAndMapsService availableModsAndMaps) { - Func initializeDefault = () => new ServersTabVM( - settings, - modManager, - modScanTab, - dialogueSpawner, - launcher, - serverConfigurations, - processWatcher, - availableModsAndMaps - ); - - var codec = new ServerTabCodec(settings, modManager, modScanTab, dialogueSpawner, launcher, serverConfigurations, processWatcher, availableModsAndMaps); - var serversTab = InitializeFromFileWithCodec(codec, jsonPath, initializeDefault); - RegisterSaveToFileOnExit(serversTab, codec, jsonPath); - return serversTab; - } - - private T InitializeFromFileWithCodec(ICodec codec, string filePath, Func initializeDefault) { - _log.Info($"Loading {typeof(T).Name} from {filePath} using {codec.GetType().Name}({codec})..."); - return codec.DeserializeFile(filePath).Match( - None: initializeDefault, - Some: deserializationResult => Optional(deserializationResult.Result).IfNone(() => { - _log.Error( - $"Failed to deserialize saved {typeof(T).Name} data from {filePath} using {codec.GetType().Name}({codec}). Falling back to default.", - deserializationResult.Exception); - return initializeDefault(); - } - )); - } - - private void RegisterSaveToFileOnExit(T t, ICodec codec, string filePath) { - RegisterExitHandler(() => { - try { - _log.Info($"Saving {typeof(T).Name} to {filePath} using {codec.GetType().Name}({codec})..."); - codec.SerializeFile(filePath, t); - } - catch (Exception ex) { - _log.Error( - $"Failed to save configuration for {typeof(T).Name} to {filePath} using {codec.GetType().Name}({codec}).", - ex); - } - }); - } - - /// - /// Registers exit handlers for multiple shutdown scenarios to ensure reliable saving. - /// This should be called once during startup after all components are initialized. - /// - private void RegisterExitHandler(Action exitAction) { - Exit += (_, _) => ExecuteExitAction("Application.Exit", exitAction); - SessionEnding += (_, args) => ExecuteExitAction("SessionEnding", exitAction); - AppDomain.CurrentDomain.ProcessExit += (_, _) => ExecuteExitAction("ProcessExit", exitAction); - AppDomain.CurrentDomain.UnhandledException += (sender, args) => ExecuteExitAction("UnhandledException", exitAction); - Console.CancelKeyPress += (_, args) => ExecuteExitAction("Console.CancelKeyPress", exitAction); - } - - /// - /// Executes all registered exit actions exactly once, regardless of how many - /// exit events fire. This ensures data is saved but prevents duplicate saves. - /// - /// The event source triggering the exit actions (for logging). - /// - private void ExecuteExitAction(string source, Action exitAction) { - try { - exitAction(); - } - catch (Exception ex) { - _log.Error($"Exit action failed during {source}", ex); - } - } - } +using LanguageExt; +using log4net; +using System; +using System.Collections.ObjectModel; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Net.Http; +using System.Reflection; +using System.Threading.Tasks; +using System.Windows; +using UnchainedLauncher.Core.Extensions; +using UnchainedLauncher.Core.Services; +using UnchainedLauncher.Core.Services.Dlls; +using UnchainedLauncher.Core.Services.Installer; +using UnchainedLauncher.Core.Services.Mods; +using UnchainedLauncher.Core.Services.Mods.Registry; +using UnchainedLauncher.Core.Services.Processes; +using UnchainedLauncher.Core.Services.Processes.Chivalry; +using UnchainedLauncher.Core.Services.Processes.Chivalry.LaunchPreparers; +using UnchainedLauncher.Core.Utilities; +using UnchainedLauncher.GUI.Services; +using UnchainedLauncher.GUI.ViewModels; +using UnchainedLauncher.GUI.ViewModels.Installer; +using UnchainedLauncher.GUI.ViewModels.Registry; +using UnchainedLauncher.GUI.ViewModels.ServersTab; +using UnchainedLauncher.GUI.Views; +using UnchainedLauncher.GUI.Views.Installer; +using UnchainedLauncher.UnrealModScanner.GUI.ViewModels; +using Application = System.Windows.Application; + +namespace UnchainedLauncher.GUI { + using static LanguageExt.Prelude; + + /// + /// Interaction logic for App.xaml + /// + public partial class App : Application { + private static readonly ILog _log = LogManager.GetLogger(typeof(App)); + private bool _unsafeLoadAllDlls; + private string[] _launchArguments = []; + + protected override async void OnStartup(StartupEventArgs e) { + try { + base.OnStartup(e); + (_unsafeLoadAllDlls, _launchArguments) = UnsafeDllLoading.ParseGuiArguments(e.Args); + + AppDomain.CurrentDomain.UnhandledException += + (sender, args) => { + var ex = (Exception)args.ExceptionObject; + _log.Fatal("Unhandled exception", ex); + + File.WriteAllText("crash.log", ex.ToString()); + var currentDirectory = Directory.GetCurrentDirectory(); + MessageBox.Show($"An unhandled exception occurred. Please report this to a developer with {currentDirectory}\\crash.log ", "Error", MessageBoxButton.OK, MessageBoxImage.Error); + }; + + // Capture the UI thread's SynchronizationContext for use throughout the application + UISynchronizationContext.Initialize(); + + var assembly = Assembly.GetExecutingAssembly(); + if (File.Exists("log4net.config")) { + log4net.Config.XmlConfigurator.Configure(new FileInfo("log4net.config")); + } + else if (File.Exists("Resources/log4net.config")) { + // for running in Visual Studio + log4net.Config.XmlConfigurator.Configure(new FileInfo("Resources/log4net.config")); + } + else { + using var configStream = + assembly.GetManifestResourceStream("UnchainedLauncher.GUI.Resources.log4net.config"); + if (configStream != null) { + log4net.Config.XmlConfigurator.Configure(configStream); + } + } + + // Init common dependencies + var githubClient = new Octokit.GitHubClient(new Octokit.ProductHeaderValue("UnchainedLauncher")); + var unchainedLauncherReleaseLocator = + new GithubReleaseLocator(githubClient, "Chiv2-Community", "UnchainedLauncher"); + + + var installationFinder = new Chivalry2InstallationFinder(); + var installer = new UnchainedLauncherInstaller(Shutdown); + + // figure out if we need to install by checking our current working directory + var currentDirectory = new DirectoryInfo(Directory.GetCurrentDirectory()); + var needsInstallation = !installationFinder.IsValidInstallation(currentDirectory); + + var forceSkipInstallation = Environment.GetCommandLineArgs().ToList().Contains("--no-install"); + + if (forceSkipInstallation && needsInstallation) + _log.Info("Skipping installation"); + + var window = + needsInstallation && !forceSkipInstallation + ? InitializeInstallerWindow(installationFinder, installer, unchainedLauncherReleaseLocator) + : await InitializeMainWindow(installationFinder, installer, unchainedLauncherReleaseLocator); + + window?.Show(); + } + catch (Exception ex) { + Debug.WriteLine($"Error starting application: {ex.Message}"); + if (ex.StackTrace != null) + Debug.WriteLine(ex.StackTrace!); + + _log.Fatal("Unhandled exception", ex); + File.WriteAllText("crash.log", ex.ToString()); + var currentDirectory = Directory.GetCurrentDirectory(); + MessageBox.Show($"An unhandled exception occurred. Please report this to a developer with {currentDirectory}\\crash.log ", "Error", MessageBoxButton.OK, MessageBoxImage.Error); + } + } + + // Removed class-level command bindings; handled by Views.UnchainedWindow type. + + private Window? InitializeInstallerWindow(Chivalry2InstallationFinder installationFinder, + IUnchainedLauncherInstaller installer, IReleaseLocator launcherReleaseLocator) { + var installationSelectionVM = new InstallationSelectionPageViewModel(installationFinder); + var versionSelectionVM = new VersionSelectionPageViewModel(launcherReleaseLocator, new FileInfoVersionExtractor()); + var installationLogVM = new InstallerLogPageViewModel( + installer, + () => + from chiv2Installations in installationSelectionVM.Installations + where chiv2Installations.IsSelected + select chiv2Installations.Path + , + () => versionSelectionVM.SelectedVersion! + ); + + var installerPageViewModels = new ObservableCollection { + installationSelectionVM, versionSelectionVM, installationLogVM + }; + + var installerWindowVM = + new InstallerWindowViewModel(installerPageViewModels, installationSelectionVM.Installations); + return new InstallerWindow(installerWindowVM); + } + + private async Task InitializeMainWindow(IChivalry2InstallationFinder installationFinder, + IUnchainedLauncherInstaller installer, IReleaseLocator launcherReleaseLocator) { + var userDialogueSpawner = new MessageBoxSpawner(); + var registryWindowService = new RegistryWindowService(); + + var modRegistry = InitializeModRegistry(FilePaths.RegistryConfigPath); + var modManager = InitializeModManager(FilePaths.ModManagerConfigPath, modRegistry); + var dllManager = new DllManager( + Path.Combine(Directory.GetCurrentDirectory(), FilePaths.PluginDir), + FilePaths.DllManagerConfigPath, new HttpClient(), () => modManager.GetEnabledAndDependencyReleases().ToArray(), + () => modRegistry.ModRegistries.OfType() + .Select(registry => registry.PackageDBBaseUrl[..^"package_db".Length] + "dll_db").ToArray()); + + var registryWindowViewModel = new RegistryWindowVM(modRegistry, registryWindowService); + + var settingsViewModel = InitializeSettingsViewModel(installationFinder, installer, launcherReleaseLocator, registryWindowViewModel, registryWindowService, modManager, userDialogueSpawner); + var dllManagerViewModel = new DllManagerVM(dllManager, settingsViewModel, userDialogueSpawner); + await dllManagerViewModel.RefreshCommand.ExecuteAsync(null); + +#if DEBUG_FAKECHIVALRYLAUNCH + var officialProcessLauncher = new PowershellProcessLauncher( + "Official Chivalry 2" + ); + var unchainedProcessLauncher = new PowershellProcessLauncher( + "Unchained Chivalry 2" + ); +#else + var originalLauncherPath = File.Exists(FilePaths.OriginalLauncherPath) + ? FilePaths.OriginalLauncherPath + : FilePaths.LauncherPath; + var officialProcessLauncher = + new ProcessLauncher(Path.Combine(Directory.GetCurrentDirectory(), originalLauncherPath)); + var unchainedProcessLauncher = + new ProcessLauncher(Path.Combine(Directory.GetCurrentDirectory(), FilePaths.GameBinPath)); +#endif + + var noSigLaunchPreparer = NoSigPreparer.Create(modManager.PakDir, userDialogueSpawner); + var sigLaunchPreparer = SigPreparer.Create(modManager.PakDir, userDialogueSpawner); + + IChivalry2LaunchPreparer pluginInstaller = new DllLaunchPreparer(dllManager, userDialogueSpawner); + + IChivalry2LaunchPreparer modInstaller = new Chivalry2ModsInstaller( + modManager, userDialogueSpawner + ); + + var vanillaLauncher = new Chivalry2Launcher( + noSigLaunchPreparer.IgnoreOptions(), + officialProcessLauncher, + Directory.GetCurrentDirectory() + ); + + var unchainedLauncher = new UnchainedChivalry2Launcher( + pluginInstaller + .AndThen(modInstaller) + .Sub(sigLaunchPreparer), + unchainedProcessLauncher, + Directory.GetCurrentDirectory(), +#if DEBUG_FAKECHIVALRYLAUNCH + // don't shove dlls into processes that don't need them and cause crashes + new NullInjector(true) +#else + new DllInjector(dllManager, Path.Combine(Directory.GetCurrentDirectory(), FilePaths.PluginDir), _unsafeLoadAllDlls) +#endif + ); + + var chivProcessMonitor = new ChivalryProcessWatcher(); + + var homeViewModel = new HomeVM( + settingsViewModel, + modManager, + vanillaLauncher, + unchainedLauncher, + userDialogueSpawner, + chivProcessMonitor); + var modListViewModel = new ModListVM(modManager, userDialogueSpawner); + + if (modManager.NeedsMetadataRefresh || !modManager.Mods.Any()) + await modListViewModel.RefreshModListCommand.ExecuteAsync(null); + + // Rebuild the arguments used to launch the application + var envArgs = Environment.GetCommandLineArgs().Skip(1).ToList(); + + // TODO: Replace this if/else chain with a real CLI + if (envArgs.Contains("--startvanilla")) { + homeViewModel.LaunchVanilla().Wait(); + return null; + } + + if (envArgs.Contains("--startunchained")) { + homeViewModel.LaunchUnchained().Wait(); + return null; + } + + var modScanTab = new ModScanTabVM(); + var helpVM = new HelpVM(settingsViewModel, installer, launcherReleaseLocator, modManager.PakDir, userDialogueSpawner, Shutdown); + var availableModsAndMaps = new AvailableModsAndMapsService(modManager, modScanTab); + + var serverConfigurationVMs = + InitializeServerConfigurations(FilePaths.ServerConfigurationsFilePath, modManager, modScanTab, availableModsAndMaps); + + var serversTabViewModel = InitializeServersTab( + FilePaths.ServersTabConfigurationPath, + settingsViewModel, + modManager, + modScanTab, + userDialogueSpawner, + unchainedLauncher, + serverConfigurationVMs, + chivProcessMonitor, + availableModsAndMaps + ); + + var mainWindowViewModel = new MainWindowVM( + homeViewModel, + modListViewModel, + settingsViewModel, + serversTabViewModel, + modScanTab, + helpVM, + dllManagerViewModel + ); + + return new MainWindow(mainWindowViewModel); + } + + private SettingsVM InitializeSettingsViewModel(IChivalry2InstallationFinder installationFinder, + IUnchainedLauncherInstaller installer, IReleaseLocator launcherReleaseLocator, + RegistryWindowVM registryWindowViewModel, RegistryWindowService registryWindowService, ModManager modManager, + MessageBoxSpawner userDialogueSpawner) { + var cliArgs = string.Join(" ", + _launchArguments + .ToList() + .Select(ArgumentEscaper.Escape) + ); + + var settingsCodec = new SettingsCodec( + registryWindowViewModel, + registryWindowService, + installationFinder, + cliArgs + ); + + var settingsViewModel = InitializeFromFileWithCodec( + settingsCodec, + FilePaths.LauncherSettingsFilePath, + () => new SettingsVM( + registryWindowViewModel, + registryWindowService, + SettingsVM.DetectInstallationType(installationFinder), + true, + false, + "", + "https://servers.polehammer.net", + false, + SettingsVM.Version.IsPrerelease, + cliArgs + ) + ); + + RegisterSaveToFileOnExit(settingsViewModel, settingsCodec, FilePaths.LauncherSettingsFilePath); + return settingsViewModel; + } + + private AggregateModRegistry InitializeModRegistry(string jsonPath) { + AggregateModRegistry CreateDefaultModRegistry() => new AggregateModRegistry( + new GithubModRegistry("Chiv2-Community", "C2ModRegistry")); + + var loadedResult = + InitializeFromFileWithCodec(ModRegistryCodec.Instance, jsonPath, CreateDefaultModRegistry); + // Ensure that we've got an AggregateModRegistry. The constructor will handle it if we're wrapping + // another AggregateModRegistry, so no worries there. + var registry = new AggregateModRegistry(loadedResult); + + RegisterSaveToFileOnExit(registry, ModRegistryCodec.Instance, jsonPath); + return registry; + } + + private ModManager InitializeModManager(string jsonPath, IModRegistry registry) { + Func initializeDefaultModManager = () => + new ModManager( + registry, + new PakDir(FilePaths.PakDir, Enumerable.Empty()), + Enumerable.Empty() + ); + + var codec = new ModManagerCodec(registry); + var modManager = InitializeFromFileWithCodec(codec, jsonPath, initializeDefaultModManager); + + RegisterSaveToFileOnExit(modManager, codec, jsonPath); + return modManager; + } + + private ObservableCollection InitializeServerConfigurations(string jsonPath, + IModManager modManager, ModScanTabVM modScanTab, AvailableModsAndMapsService availableModsAndMapsVM) { + Func> initializeDefault = + () => new ObservableCollection(); + + var codec = new ServerConfigurationCodec(modManager, modScanTab, availableModsAndMapsVM); + var serverConfigurations = InitializeFromFileWithCodec(codec, jsonPath, initializeDefault); + + RegisterSaveToFileOnExit(serverConfigurations, codec, jsonPath); + Exit += (_, _) => serverConfigurations.ForEach(x => x.SaveINI()); + + return serverConfigurations; + } + + private ServersTabVM InitializeServersTab( + string jsonPath, + SettingsVM settings, + IModManager modManager, + ModScanTabVM modScanTab, + IUserDialogueSpawner dialogueSpawner, + IChivalry2Launcher launcher, + ObservableCollection serverConfigurations, + IChivalryProcessWatcher processWatcher, + AvailableModsAndMapsService availableModsAndMaps) { + Func initializeDefault = () => new ServersTabVM( + settings, + modManager, + modScanTab, + dialogueSpawner, + launcher, + serverConfigurations, + processWatcher, + availableModsAndMaps + ); + + var codec = new ServerTabCodec(settings, modManager, modScanTab, dialogueSpawner, launcher, serverConfigurations, processWatcher, availableModsAndMaps); + var serversTab = InitializeFromFileWithCodec(codec, jsonPath, initializeDefault); + RegisterSaveToFileOnExit(serversTab, codec, jsonPath); + return serversTab; + } + + private T InitializeFromFileWithCodec(ICodec codec, string filePath, Func initializeDefault) { + _log.Info($"Loading {typeof(T).Name} from {filePath} using {codec.GetType().Name}({codec})..."); + return codec.DeserializeFile(filePath).Match( + None: initializeDefault, + Some: deserializationResult => Optional(deserializationResult.Result).IfNone(() => { + _log.Error( + $"Failed to deserialize saved {typeof(T).Name} data from {filePath} using {codec.GetType().Name}({codec}). Falling back to default.", + deserializationResult.Exception); + return initializeDefault(); + } + )); + } + + private void RegisterSaveToFileOnExit(T t, ICodec codec, string filePath) { + RegisterExitHandler(() => { + try { + _log.Info($"Saving {typeof(T).Name} to {filePath} using {codec.GetType().Name}({codec})..."); + codec.SerializeFile(filePath, t); + } + catch (Exception ex) { + _log.Error( + $"Failed to save configuration for {typeof(T).Name} to {filePath} using {codec.GetType().Name}({codec}).", + ex); + } + }); + } + + /// + /// Registers exit handlers for multiple shutdown scenarios to ensure reliable saving. + /// This should be called once during startup after all components are initialized. + /// + private void RegisterExitHandler(Action exitAction) { + Exit += (_, _) => ExecuteExitAction("Application.Exit", exitAction); + SessionEnding += (_, args) => ExecuteExitAction("SessionEnding", exitAction); + AppDomain.CurrentDomain.ProcessExit += (_, _) => ExecuteExitAction("ProcessExit", exitAction); + AppDomain.CurrentDomain.UnhandledException += (sender, args) => ExecuteExitAction("UnhandledException", exitAction); + Console.CancelKeyPress += (_, args) => ExecuteExitAction("Console.CancelKeyPress", exitAction); + } + + /// + /// Executes all registered exit actions exactly once, regardless of how many + /// exit events fire. This ensures data is saved but prevents duplicate saves. + /// + /// The event source triggering the exit actions (for logging). + /// + private void ExecuteExitAction(string source, Action exitAction) { + try { + exitAction(); + } + catch (Exception ex) { + _log.Error($"Exit action failed during {source}", ex); + } + } + } } From f0d4c697fe3df11a41da0ec5f7097c646b59633c Mon Sep 17 00:00:00 2001 From: Jacoby6000 <2672832+Jacoby6000@users.noreply.github.com> Date: Tue, 13 Oct 2026 20:22:51 +0000 Subject: [PATCH 16/58] [GHA] Format Source --- UnchainedLauncher.CLI.Tests/ArgumentBoundaryTests.cs | 4 ++-- UnchainedLauncher.CLI.Tests/DllQueryAndLoadingTests.cs | 4 ++-- UnchainedLauncher.CLI.Tests/FileWriterTests.cs | 4 ++-- UnchainedLauncher.CLI.Tests/ManagedInventoryTests.cs | 4 ++-- UnchainedLauncher.CLI.Tests/ModQueryTests.cs | 4 ++-- UnchainedLauncher.CLI.Tests/RegistryAndReleaseTests.cs | 4 ++-- UnchainedLauncher.CLI.Tests/YamlConfigurationTests.cs | 4 ++-- UnchainedLauncher.Core/Services/Mods/DllLaunchSession.cs | 2 +- UnchainedLauncher.Core/Utilities/FilePaths.cs | 2 +- UnchainedLauncher.GUI/App.xaml.cs | 4 ++-- 10 files changed, 18 insertions(+), 18 deletions(-) diff --git a/UnchainedLauncher.CLI.Tests/ArgumentBoundaryTests.cs b/UnchainedLauncher.CLI.Tests/ArgumentBoundaryTests.cs index 28e33992..5440b496 100644 --- a/UnchainedLauncher.CLI.Tests/ArgumentBoundaryTests.cs +++ b/UnchainedLauncher.CLI.Tests/ArgumentBoundaryTests.cs @@ -11,7 +11,7 @@ namespace UnchainedLauncher.CLI.Tests; public sealed class ArgumentBoundaryTests : IDisposable { private readonly string _directory = Path.Combine( - Environment.GetEnvironmentVariable("TMPDIR") ?? (OperatingSystem.IsLinux() ? "/tmp/opencode" : Path.GetTempPath()), "argument-tests-" + Guid.NewGuid().ToString("N")); + Environment.GetEnvironmentVariable("TMPDIR") ?? (OperatingSystem.IsLinux() ? "/tmp/opencode" : Path.GetTempPath()), "argument-tests-" + Guid.NewGuid().ToString("N")); private static string Probe => Path.Combine(AppContext.BaseDirectory, "argument-probe", OperatingSystem.IsWindows() ? "ArgumentProbe.exe" : "ArgumentProbe"); @@ -120,4 +120,4 @@ private static async Task ReadResult(Process process, string output) { private sealed class NoOpInjector : IProcessInjector { public bool Inject(Process process) => true; } -} +} \ No newline at end of file diff --git a/UnchainedLauncher.CLI.Tests/DllQueryAndLoadingTests.cs b/UnchainedLauncher.CLI.Tests/DllQueryAndLoadingTests.cs index acda4bad..c3166175 100644 --- a/UnchainedLauncher.CLI.Tests/DllQueryAndLoadingTests.cs +++ b/UnchainedLauncher.CLI.Tests/DllQueryAndLoadingTests.cs @@ -6,7 +6,7 @@ namespace UnchainedLauncher.CLI.Tests; public sealed class DllQueryAndLoadingTests : IDisposable { - private readonly string _directory = Path.Combine(Environment.GetEnvironmentVariable("TMPDIR") ?? (OperatingSystem.IsLinux() ? "/tmp/opencode" : Path.GetTempPath()), "dll-query-" + Guid.NewGuid().ToString("N")); + private readonly string _directory = Path.Combine(Environment.GetEnvironmentVariable("TMPDIR") ?? (OperatingSystem.IsLinux() ? "/tmp/opencode" : Path.GetTempPath()), "dll-query-" + Guid.NewGuid().ToString("N")); public DllQueryAndLoadingTests() => Directory.CreateDirectory(_directory); public void Dispose() => Directory.Delete(_directory, recursive: true); @@ -169,4 +169,4 @@ public async Task InvalidNullMetadataIsReportedAsCatalogError(string body) { Assert.Single(catalog.Errors); Assert.Empty(catalog.Packages); } -} +} \ No newline at end of file diff --git a/UnchainedLauncher.CLI.Tests/FileWriterTests.cs b/UnchainedLauncher.CLI.Tests/FileWriterTests.cs index 174e9aaf..e6565f75 100644 --- a/UnchainedLauncher.CLI.Tests/FileWriterTests.cs +++ b/UnchainedLauncher.CLI.Tests/FileWriterTests.cs @@ -9,7 +9,7 @@ namespace UnchainedLauncher.CLI.Tests; public class FileWriterTests : IDisposable { private readonly string _directory = Path.Combine( - Environment.GetEnvironmentVariable("TMPDIR") ?? (OperatingSystem.IsLinux() ? "/tmp/opencode" : Path.GetTempPath()), "writer-tests-" + Guid.NewGuid().ToString("N")); + Environment.GetEnvironmentVariable("TMPDIR") ?? (OperatingSystem.IsLinux() ? "/tmp/opencode" : Path.GetTempPath()), "writer-tests-" + Guid.NewGuid().ToString("N")); public FileWriterTests() => Directory.CreateDirectory(_directory); public void Dispose() => Directory.Delete(_directory, recursive: true); @@ -73,4 +73,4 @@ public override ValueTask ReadAsync(Memory buffer, CancellationToken return base.ReadAsync(buffer, cancellationToken); } } -} +} \ No newline at end of file diff --git a/UnchainedLauncher.CLI.Tests/ManagedInventoryTests.cs b/UnchainedLauncher.CLI.Tests/ManagedInventoryTests.cs index 434fcd7e..92d5290a 100644 --- a/UnchainedLauncher.CLI.Tests/ManagedInventoryTests.cs +++ b/UnchainedLauncher.CLI.Tests/ManagedInventoryTests.cs @@ -11,7 +11,7 @@ namespace UnchainedLauncher.CLI.Tests; [Collection("Installation directory")] public class ManagedInventoryTests : IDisposable { - private readonly string _root = Path.Combine(Environment.GetEnvironmentVariable("TMPDIR") ?? (OperatingSystem.IsLinux() ? "/tmp/opencode" : Path.GetTempPath()), + private readonly string _root = Path.Combine(Environment.GetEnvironmentVariable("TMPDIR") ?? (OperatingSystem.IsLinux() ? "/tmp/opencode" : Path.GetTempPath()), "inventory-tests-" + Guid.NewGuid().ToString("N")); public ManagedInventoryTests() => Directory.CreateDirectory(_root); @@ -173,4 +173,4 @@ await ServerLauncher.InInstallationDirectoryAsync(request, async () => { return 0; }); } -} +} \ No newline at end of file diff --git a/UnchainedLauncher.CLI.Tests/ModQueryTests.cs b/UnchainedLauncher.CLI.Tests/ModQueryTests.cs index bb8c3c34..17500277 100644 --- a/UnchainedLauncher.CLI.Tests/ModQueryTests.cs +++ b/UnchainedLauncher.CLI.Tests/ModQueryTests.cs @@ -7,7 +7,7 @@ namespace UnchainedLauncher.CLI.Tests; public sealed class ModQueryTests : IDisposable { - private readonly string _directory = Path.Combine(Environment.GetEnvironmentVariable("TMPDIR") ?? (OperatingSystem.IsLinux() ? "/tmp/opencode" : Path.GetTempPath()), "mod-query-" + Guid.NewGuid().ToString("N")); + private readonly string _directory = Path.Combine(Environment.GetEnvironmentVariable("TMPDIR") ?? (OperatingSystem.IsLinux() ? "/tmp/opencode" : Path.GetTempPath()), "mod-query-" + Guid.NewGuid().ToString("N")); public ModQueryTests() => Directory.CreateDirectory(_directory); public void Dispose() => Directory.Delete(_directory, recursive: true); @@ -205,4 +205,4 @@ private sealed class UnavailableRegistry : IModRegistry { public LanguageExt.EitherAsync GetModRelease(ReleaseCoordinates coordinate) => LanguageExt.Prelude.LeftAsync(Error); public LanguageExt.EitherAsync DownloadPak(ReleaseCoordinates coordinate, string output) => throw new InvalidOperationException("Queries must never download paks."); } -} +} \ No newline at end of file diff --git a/UnchainedLauncher.CLI.Tests/RegistryAndReleaseTests.cs b/UnchainedLauncher.CLI.Tests/RegistryAndReleaseTests.cs index a582a6f4..a8b5e255 100644 --- a/UnchainedLauncher.CLI.Tests/RegistryAndReleaseTests.cs +++ b/UnchainedLauncher.CLI.Tests/RegistryAndReleaseTests.cs @@ -16,7 +16,7 @@ namespace UnchainedLauncher.CLI.Tests; [Collection("Installation directory")] public class RegistryAndReleaseTests : IDisposable { private readonly string _directory = Path.Combine( - Environment.GetEnvironmentVariable("TMPDIR") ?? (OperatingSystem.IsLinux() ? "/tmp/opencode" : Path.GetTempPath()), "cli-tests-" + Guid.NewGuid().ToString("N")); + Environment.GetEnvironmentVariable("TMPDIR") ?? (OperatingSystem.IsLinux() ? "/tmp/opencode" : Path.GetTempPath()), "cli-tests-" + Guid.NewGuid().ToString("N")); public RegistryAndReleaseTests() => Directory.CreateDirectory(_directory); public void Dispose() => Directory.Delete(_directory, recursive: true); @@ -254,4 +254,4 @@ public Either Launch(string workingDirectory, string args } [CollectionDefinition("Installation directory", DisableParallelization = true)] -public class InstallationDirectoryCollection { } +public class InstallationDirectoryCollection { } \ No newline at end of file diff --git a/UnchainedLauncher.CLI.Tests/YamlConfigurationTests.cs b/UnchainedLauncher.CLI.Tests/YamlConfigurationTests.cs index e26ccde6..15bbaa06 100644 --- a/UnchainedLauncher.CLI.Tests/YamlConfigurationTests.cs +++ b/UnchainedLauncher.CLI.Tests/YamlConfigurationTests.cs @@ -8,7 +8,7 @@ namespace UnchainedLauncher.CLI.Tests; [Collection("Installation directory")] public class YamlConfigurationTests : IDisposable { private readonly string _directory = Path.Combine( - Environment.GetEnvironmentVariable("TMPDIR") ?? (OperatingSystem.IsLinux() ? "/tmp/opencode" : Path.GetTempPath()), "yaml-cli-tests-" + Guid.NewGuid().ToString("N")); + Environment.GetEnvironmentVariable("TMPDIR") ?? (OperatingSystem.IsLinux() ? "/tmp/opencode" : Path.GetTempPath()), "yaml-cli-tests-" + Guid.NewGuid().ToString("N")); public YamlConfigurationTests() => Directory.CreateDirectory(_directory); public void Dispose() => Directory.Delete(_directory, recursive: true); @@ -504,4 +504,4 @@ public async Task ProductionSupervisionLeavesClientRunningButStopsServer(bool cl } } } -} +} \ No newline at end of file diff --git a/UnchainedLauncher.Core/Services/Mods/DllLaunchSession.cs b/UnchainedLauncher.Core/Services/Mods/DllLaunchSession.cs index 7996a959..dfe5fe18 100644 --- a/UnchainedLauncher.Core/Services/Mods/DllLaunchSession.cs +++ b/UnchainedLauncher.Core/Services/Mods/DllLaunchSession.cs @@ -6,8 +6,8 @@ using UnchainedLauncher.Core.Services.Processes; using UnchainedLauncher.Core.Services.Processes.Chivalry; using UnchainedLauncher.Core.Services.Processes.Chivalry.LaunchPreparers; -using ManagedDllManager = UnchainedLauncher.Core.Services.Dlls.DllManager; using DllRelease = UnchainedLauncher.Core.JsonModels.DllRelease; +using ManagedDllManager = UnchainedLauncher.Core.Services.Dlls.DllManager; namespace UnchainedLauncher.Core.Services.Mods; diff --git a/UnchainedLauncher.Core/Utilities/FilePaths.cs b/UnchainedLauncher.Core/Utilities/FilePaths.cs index 3a5af57b..998617f5 100644 --- a/UnchainedLauncher.Core/Utilities/FilePaths.cs +++ b/UnchainedLauncher.Core/Utilities/FilePaths.cs @@ -23,4 +23,4 @@ public static string Chiv2ConfigPath(string? savedDirSuffix) { return Environment.ExpandEnvironmentVariables(unexpandedPath); } } -} +} \ No newline at end of file diff --git a/UnchainedLauncher.GUI/App.xaml.cs b/UnchainedLauncher.GUI/App.xaml.cs index 134099e6..13feb17b 100644 --- a/UnchainedLauncher.GUI/App.xaml.cs +++ b/UnchainedLauncher.GUI/App.xaml.cs @@ -305,7 +305,7 @@ private AggregateModRegistry InitializeModRegistry(string jsonPath) { var loadedResult = InitializeFromFileWithCodec(ModRegistryCodec.Instance, jsonPath, CreateDefaultModRegistry); - // Ensure that we've got an AggregateModRegistry. The constructor will handle it if we're wrapping + // Ensure that we've got an AggregateModRegistry. The constructor will handle it if we're wrapping // another AggregateModRegistry, so no worries there. var registry = new AggregateModRegistry(loadedResult); @@ -423,4 +423,4 @@ private void ExecuteExitAction(string source, Action exitAction) { } } } -} +} \ No newline at end of file From 4b699f0823a6c8d69523b37123d83ee7bcff78b2 Mon Sep 17 00:00:00 2001 From: Jacob Barber Date: Wed, 14 Oct 2026 21:04:53 -0500 Subject: [PATCH 17/58] Isolate form regression test from background registry refresh --- .../Mods/Registry/LocalReleaseFormRegressionTests.cs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/UnchainedLauncher.Core.Tests.Unit/Services/Mods/Registry/LocalReleaseFormRegressionTests.cs b/UnchainedLauncher.Core.Tests.Unit/Services/Mods/Registry/LocalReleaseFormRegressionTests.cs index 2c190925..fe673ff9 100644 --- a/UnchainedLauncher.Core.Tests.Unit/Services/Mods/Registry/LocalReleaseFormRegressionTests.cs +++ b/UnchainedLauncher.Core.Tests.Unit/Services/Mods/Registry/LocalReleaseFormRegressionTests.cs @@ -27,7 +27,11 @@ public async Task FormSubmissionWritesAndReadsRequiredManifest(bool editing) { var root = Path.Combine(Path.GetTempPath(), "LocalReleaseForm_" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(root); try { - var registry = new LocalModRegistry(Path.Combine(root, "registry")); + // The form test does not exercise the view's async-void list refresh. + // Keep it from reading manifests after the fixture starts cleanup. + var registryMock = new Mock(Path.Combine(root, "registry")) { CallBase = true }; + registryMock.Setup(source => source.GetAllMods()).ReturnsAsync(new GetAllModsResult([], [])); + var registry = registryMock.Object; var pak = Path.Combine(root, "source.pak"); await File.WriteAllTextAsync(pak, "test pak"); RegistryReleaseFormVM form; @@ -71,4 +75,4 @@ public async Task FormSubmissionWritesAndReadsRequiredManifest(bool editing) { finally { Directory.Delete(root, true); } } } -} \ No newline at end of file +} From a9ea8d22ea7af8ea397b3648878d4aebf5e964ac Mon Sep 17 00:00:00 2001 From: Jacoby6000 <2672832+Jacoby6000@users.noreply.github.com> Date: Thu, 15 Oct 2026 02:06:45 +0000 Subject: [PATCH 18/58] [GHA] Format Source --- .../Mods/Registry/LocalReleaseFormRegressionTests.cs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/UnchainedLauncher.Core.Tests.Unit/Services/Mods/Registry/LocalReleaseFormRegressionTests.cs b/UnchainedLauncher.Core.Tests.Unit/Services/Mods/Registry/LocalReleaseFormRegressionTests.cs index fe673ff9..f554f397 100644 --- a/UnchainedLauncher.Core.Tests.Unit/Services/Mods/Registry/LocalReleaseFormRegressionTests.cs +++ b/UnchainedLauncher.Core.Tests.Unit/Services/Mods/Registry/LocalReleaseFormRegressionTests.cs @@ -27,11 +27,11 @@ public async Task FormSubmissionWritesAndReadsRequiredManifest(bool editing) { var root = Path.Combine(Path.GetTempPath(), "LocalReleaseForm_" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(root); try { - // The form test does not exercise the view's async-void list refresh. - // Keep it from reading manifests after the fixture starts cleanup. - var registryMock = new Mock(Path.Combine(root, "registry")) { CallBase = true }; - registryMock.Setup(source => source.GetAllMods()).ReturnsAsync(new GetAllModsResult([], [])); - var registry = registryMock.Object; + // The form test does not exercise the view's async-void list refresh. + // Keep it from reading manifests after the fixture starts cleanup. + var registryMock = new Mock(Path.Combine(root, "registry")) { CallBase = true }; + registryMock.Setup(source => source.GetAllMods()).ReturnsAsync(new GetAllModsResult([], [])); + var registry = registryMock.Object; var pak = Path.Combine(root, "source.pak"); await File.WriteAllTextAsync(pak, "test pak"); RegistryReleaseFormVM form; @@ -75,4 +75,4 @@ public async Task FormSubmissionWritesAndReadsRequiredManifest(bool editing) { finally { Directory.Delete(root, true); } } } -} +} \ No newline at end of file From 0b039df2226e7c2da56d41c0fb135af3a19cecdd Mon Sep 17 00:00:00 2001 From: Jacob Barber Date: Wed, 14 Oct 2026 23:12:34 -0500 Subject: [PATCH 19/58] Add compose-style server groups and tabbed server monitoring --- .../ServerGroupTests.cs | 156 ++++++++++++++++++ .../ServerMonitorTests.cs | 85 +++++++++- .../ServerTabsTests.cs | 59 +++++++ UnchainedLauncher.CLI/CliLaunchOptions.cs | 8 +- UnchainedLauncher.CLI/CommandLineOptions.cs | 6 +- UnchainedLauncher.CLI/LaunchConfiguration.cs | 12 ++ UnchainedLauncher.CLI/Program.cs | 12 +- UnchainedLauncher.CLI/README.md | 74 ++++++++- .../RegistryQueryConfiguration.cs | 1 + UnchainedLauncher.CLI/ServerDashboard.cs | 91 ++++++++-- UnchainedLauncher.CLI/ServerGroupOptions.cs | 35 ++++ UnchainedLauncher.CLI/ServerGroupRunner.cs | 83 ++++++++++ UnchainedLauncher.CLI/ServerMonitor.cs | 49 ++++-- UnchainedLauncher.CLI/ServerTabs.cs | 74 +++++++++ UnchainedLauncher.CLI/TerminalMouse.cs | 45 +++++ UnchainedLauncher.CLI/YamlConfiguration.cs | 7 +- UnchainedLauncher.CLI/examples/servers.yaml | 33 ++++ 17 files changed, 785 insertions(+), 45 deletions(-) create mode 100644 UnchainedLauncher.CLI.Tests/ServerGroupTests.cs create mode 100644 UnchainedLauncher.CLI.Tests/ServerTabsTests.cs create mode 100644 UnchainedLauncher.CLI/ServerGroupOptions.cs create mode 100644 UnchainedLauncher.CLI/ServerGroupRunner.cs create mode 100644 UnchainedLauncher.CLI/ServerTabs.cs create mode 100644 UnchainedLauncher.CLI/TerminalMouse.cs create mode 100644 UnchainedLauncher.CLI/examples/servers.yaml diff --git a/UnchainedLauncher.CLI.Tests/ServerGroupTests.cs b/UnchainedLauncher.CLI.Tests/ServerGroupTests.cs new file mode 100644 index 00000000..4be14dff --- /dev/null +++ b/UnchainedLauncher.CLI.Tests/ServerGroupTests.cs @@ -0,0 +1,156 @@ +using System.Diagnostics; +using UnchainedLauncher.Core.Services.Server.A2S; +using Xunit; + +namespace UnchainedLauncher.CLI.Tests; + +public sealed class ServerGroupTests : IDisposable { + private readonly string _root = Path.Combine(System.Environment.GetEnvironmentVariable("TMPDIR") ?? Path.GetTempPath(), + "server-group-" + Guid.NewGuid().ToString("N")); + + private const string Servers = """ + servers: + courtyard: + name: Courtyard + game-port: 7000 + ping-port: 7001 + query-port: 7002 + rcon-port: 7003 + arena: + name: Arena + game-port: 7004 + ping-port: 7005 + query-port: 7006 + rcon-port: 7007 + """; + + private CliCommandOptions Parse(string body, string command = "server", params string[] extra) { + Directory.CreateDirectory(_root); + var path = Path.Combine(_root, "servers.yaml"); + File.WriteAllText(path, "chiv2-path: ./game\nplatform: Steam\nno-tui: true\nsaved-dir-suffix: Host\n" + body); + return CommandLineOptions.Parse([command, "--config-path", path, .. extra]); + } + + private ServerGroupOptions Group() => Assert.IsType(Parse(Servers)); + public void Dispose() { if (Directory.Exists(_root)) Directory.Delete(_root, true); } + + [Fact] + public void DictionaryPreservesOrderSharedSettingsAndIsolatedProfiles() { + var group = Group(); + Assert.True(group.NoTui); + Assert.Equal(["courtyard", "arena"], group.Servers.Select(entry => entry.Id)); + Assert.Equal(["Host-courtyard", "Host-arena"], group.Servers.Select(entry => entry.Options.Shared.SavedDirSuffix)); + Assert.All(group.Servers, entry => Assert.Equal(Path.Combine(_root, "game"), entry.Options.Shared.Chivalry2Path)); + Assert.Equal(["Courtyard", "Arena"], group.Servers.Select(entry => entry.Options.Settings.Name)); + Assert.Equal(7006, group.Servers[1].Options.ToServerLaunchOptions().QueryPort); + } + + [Fact] + public void PerServerProfilesAndArgumentsRemainIndependent() { + var group = Assert.IsType(Parse(Servers.Replace("name: Arena", "name: Arena\n saved-dir-suffix: ArenaProfile\n game-args: [arena-only]") + + "\ngame-args: [common]\nmod-setup: {registries: [], mods: [], dlls: []}")); + Assert.Equal("ArenaProfile", group.Servers[1].Options.Shared.SavedDirSuffix); + Assert.Equal(["common"], group.Servers[0].Options.GameArguments); + Assert.Equal(["common", "arena-only"], group.Servers[1].Options.GameArguments); + } + + [Theory] + [InlineData("servers: {}")] + [InlineData("servers: {one: null}")] + [InlineData("server: {}\nservers: {one: {}}")] + [InlineData("servers: {'../one': {}}")] + [InlineData("servers: {one: {}, ONE: {}}")] + [InlineData("servers: {one: {}, two: {}}")] + [InlineData("servers: {one: {game-port: 0}}")] + [InlineData("servers: {one: {saved-dir-suffix: '../outside'}}")] + [InlineData("servers: {one: {unsafe-very-dangerous-load-all-dlls: true}}")] + [InlineData("servers: {one: {mod-setup: {mods: []}}}")] + public void InvalidGroupsFailBeforeLaunch(string yaml) => Assert.Throws(() => Parse(yaml)); + + [Fact] + public void DuplicatePortsAndCaseInsensitiveProfilesAreRejected() { + Assert.Throws(() => Parse(Servers.Replace("7007", "7000"))); + Assert.Throws(() => Parse(Servers.Replace("name: Courtyard", "name: Courtyard\n saved-dir-suffix: SAME") + .Replace("name: Arena", "name: Arena\n saved-dir-suffix: same"))); + } + + [Fact] + public void CliOverridesAreIgnoredAndUnsafeAuthorizationAppliesToEveryServer() { + var group = Assert.IsType(Parse(Servers, "server", "--name", "ignored", "--platform", "invalid", + "--unsafe-very-dangerous-load-all-dlls")); + Assert.All(group.Servers, entry => Assert.True(entry.Options.Shared.UnsafeLoadAllDlls)); + Assert.Equal("Courtyard", group.Servers[0].Options.Settings.Name); + Assert.All(Group().Servers, entry => Assert.False(entry.Options.Shared.UnsafeLoadAllDlls)); + } + + [Fact] + public void ClientAndRegistryQueriesSkipInactiveServersSchema() { + Directory.CreateDirectory(_root); + var path = Path.Combine(_root, "query.yaml"); + File.WriteAllText(path, "chiv2-path: .\nplatform: Steam\nservers: [invalid, server, schema]\nmod-setup: {registries: []}"); + Assert.IsType(CommandLineOptions.Parse(["client", "--config-path", path])); + Assert.IsType(CommandLineOptions.Parse(["mod-query", "list", "--config-path", path])); + } + + private sealed class PendingQuery : IA2S { + public Task InfoAsync() => new TaskCompletionSource().Task; + } + + private sealed class FailingQuery : IA2S { + public Task InfoAsync() => Task.FromException(new TimeoutException()); + } + + private static Process Child(int? exitCode = null) { + var info = new ProcessStartInfo(OperatingSystem.IsWindows() ? "cmd.exe" : "/bin/sh") { + UseShellExecute = false, + CreateNoWindow = true + }; + info.ArgumentList.Add(OperatingSystem.IsWindows() ? "/c" : "-c"); + info.ArgumentList.Add(exitCode.HasValue ? $"exit {exitCode}" : OperatingSystem.IsWindows() ? "ping -n 30 127.0.0.1 > nul" : "sleep 30"); + return Process.Start(info)!; + } + + [Fact] + public async Task StartupFailureStopsAlreadyLaunchedServers() { + using var stop = new CancellationTokenSource(); + Task? exited = null; + var calls = 0; + var error = await Assert.ThrowsAsync(() => ServerGroupRunner.RunAsync(Group(), stop, + (_, _) => { + if (++calls == 2) throw new InvalidOperationException("launch failed"); + var child = Child(); + exited = child.WaitForExitAsync(); + return Task.FromResult(child); + }, _ => new PendingQuery())); + Assert.Equal("launch failed", error.Message); + await exited!.WaitAsync(TimeSpan.FromSeconds(5)); + Assert.Equal(2, calls); + } + + [Fact] + public async Task GroupCancellationStopsEveryServerAndInterruptsPendingQueries() { + using var stop = new CancellationTokenSource(); + var exits = new List(); + var order = new List(); + Assert.Equal(0, await ServerGroupRunner.RunAsync(Group(), stop, async (options, _) => { + order.Add(options.Settings.Name); + var child = Child(); + exits.Add(child.WaitForExitAsync()); + if (exits.Count == 2) stop.CancelAfter(250); + await Task.Yield(); + return child; + }, _ => new PendingQuery()).WaitAsync(TimeSpan.FromSeconds(10))); + Assert.Equal(["Courtyard", "Arena"], order); + await Task.WhenAll(exits).WaitAsync(TimeSpan.FromSeconds(5)); + } + + [Fact] + public async Task NaturalExitsReturnFailureWithoutStoppingOtherServersEarly() { + using var stop = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var calls = 0; + Assert.Equal(7, await ServerGroupRunner.RunAsync(Group(), stop, + (_, _) => Task.FromResult(Child(++calls == 1 ? 7 : 0)), _ => new FailingQuery())); + Assert.Equal(2, calls); + Assert.False(stop.IsCancellationRequested); + } +} \ No newline at end of file diff --git a/UnchainedLauncher.CLI.Tests/ServerMonitorTests.cs b/UnchainedLauncher.CLI.Tests/ServerMonitorTests.cs index f802abd2..a51d1371 100644 --- a/UnchainedLauncher.CLI.Tests/ServerMonitorTests.cs +++ b/UnchainedLauncher.CLI.Tests/ServerMonitorTests.cs @@ -25,10 +25,12 @@ private static ServerSample Sample(int? players = null, string? map = null, bool [Fact] public void HistoryAndTimelineStayBounded() { var history = new ServerHistory(); - for (var i = 0; i < 500; i++) history.Add(Sample(i, "Map", true)); + var start = DateTimeOffset.Now; + for (var i = 0; i < ServerHistory.MaxSamples + 10; i++) + history.Add(Sample(i, "Map", true) with { Time = start.AddSeconds(i * 10) }); Assert.Equal(ServerHistory.MaxSamples, history.Samples.Count); Assert.Equal(ServerHistory.MaxEvents, history.Events.Count); - Assert.Equal(499, history.Samples.Last().Players); + Assert.Equal(ServerHistory.MaxSamples + 9, history.Samples.Last().Players); } [Fact] @@ -60,7 +62,7 @@ public void UnknownAndZeroPlayersAreDifferentInDashboard() { [Fact] public void SparklinesRespectWidthUnknownSamplesAndScale() { Assert.Equal(".?@", ServerDashboard.Sparkline([0, null, 100], 10, 100)); - Assert.Equal("?@", ServerDashboard.Sparkline([0, null, 100], 2, 100)); + Assert.Equal(".@", ServerDashboard.Sparkline([0, null, 100], 2, 100)); Assert.Equal("..", ServerDashboard.Sparkline([0, 0], 10)); Assert.NotEmpty(ServerDashboard.FormatLines(1, Sample(), new ServerHistory(), 1)); Assert.DoesNotContain('\x1b', ServerDashboard.SafeText("map\x1b[2J\nInjected")); @@ -74,6 +76,30 @@ private sealed class PendingQuery : IA2S { public Task InfoAsync() => new TaskCompletionSource().Task; } + private sealed class RespondingQuery : IA2S { + public Task InfoAsync() => Task.FromResult(new A2SInfo(17, "Server", "FFA_Courtyard", "FFA", + "Chivalry 2", 0, 5, 64, 0, ServerType.Dedicated, + UnchainedLauncher.Core.Services.Server.A2S.Environment.Windows, true, false)); + } + + [Fact] + public async Task MonitorCollectsGameTypeAndResourceDetailsFromRunningProcess() { + using var process = Process.GetCurrentProcess(); + using var stop = new CancellationTokenSource(TimeSpan.FromMilliseconds(250)); + using var dashboard = new ServerDashboard(false); + var monitor = new ServerMonitor(process, new RespondingQuery()); + await monitor.RunAsync(dashboard, stop); + var sample = Assert.Single(monitor.History.Samples); + Assert.True(sample.QueryAvailable); + Assert.Equal("FFA", sample.GameType); + Assert.Equal("FFA_Courtyard", sample.Map); + Assert.Equal(5, sample.Players); + Assert.Equal(64, sample.MaxPlayers); + Assert.NotNull(sample.HandleCount); + Assert.True(sample.PeakMemoryMiB >= sample.MemoryMiB); + Assert.Equal("Up", monitor.History.State); + } + [Fact] public async Task QueryFailureStillProducesResourceSamplesWithUnknownPlayers() { using var process = Process.GetCurrentProcess(); @@ -85,9 +111,62 @@ public async Task QueryFailureStillProducesResourceSamplesWithUnknownPlayers() { Assert.False(sample.QueryAvailable); Assert.Null(sample.Players); Assert.NotNull(sample.MemoryMiB); + Assert.NotNull(sample.HandleCount); + Assert.Equal(sample.MemoryMiB, sample.PeakMemoryMiB); Assert.True(monitor.History.Events.Last().Message == "Stop requested"); } + [Fact] + public void DashboardIncludesAllRunningServerGuiDataWithoutSecrets() { + var options = Assert.IsType(CommandLineOptions.Parse([ + "server", "--chivalry2-path", ".", "--platform", "Steam", + "--name", "Parity server", "--local-ip", "192.0.2.1", + "--game-port", "7000", "--query-port", "7001", "--ping-port", "7002", "--rcon-port", "7003", + "--server-mod", "/Game/Mods/Example.Actor_C", "--password", "must-not-display" + ])); + var sample = Sample(5, "FFA_Courtyard", true) with { + GameType = "FFA", + HandleCount = 1234, + PeakMemoryMiB = 2048 + }; + var history = new ServerHistory(); + history.Add(sample); + var text = string.Join('\n', ServerDashboard.FormatLines(42, sample, history, 80, options.ToServerLaunchOptions())); + foreach (var expected in new[] { "Parity server", "PID 42", "1d 02:00:00", "Players 5/64", + "FFA_Courtyard", "Game Type FFA", "Game Port 7000", "Query Port 7001", "Beacon Port 7002", + "RCON Port 7003", "Local IP 192.0.2.1", "/Game/Mods/Example.Actor_C", "State Up", + "Restarts 0", "Private memory 1024.0 MiB", "Peak memory 2048.0 MiB", "Handle Count 1234", + "Uptime S=start U=up U", "Memory", "Players", "12 hours", "10s samples" }) + Assert.Contains(expected, text); + Assert.DoesNotContain("must-not-display", text); + } + + [Fact] + public void HistorySamplesEveryTenSecondsAndKeepsUpStateThroughQueryOutages() { + var start = DateTimeOffset.Now; + var history = new ServerHistory(); + history.Add(Sample() with { Time = start }); + Assert.Equal("Starting", history.State); + history.Add(Sample(0, "Map", true) with { Time = start.AddSeconds(1) }); + Assert.Single(history.Samples); + Assert.Equal("Up", history.State); + history.Add(Sample() with { Time = start.AddSeconds(10) }); + Assert.Equal(2, history.Samples.Count); + Assert.True(history.Samples.First().Starting); + Assert.False(history.Samples.Last().Starting); + Assert.Null(history.Samples.Last().Players); + Assert.Equal("Up", history.State); + } + + [Fact] + public void NarrowTerminalWrappingPreservesAllData() { + const string line = "Mods /Game/Mods/LongActorName.Actor_C | RCON Port 7003"; + var wrapped = ServerDashboard.WrapLine(line, 12).ToArray(); + Assert.All(wrapped, part => Assert.InRange(part.Length, 1, 12)); + Assert.Equal(line, string.Concat(wrapped)); + Assert.Equal([""], ServerDashboard.WrapLine("", 1)); + } + [Fact] public async Task CancellationInterruptsPendingQuery() { using var process = Process.GetCurrentProcess(); diff --git a/UnchainedLauncher.CLI.Tests/ServerTabsTests.cs b/UnchainedLauncher.CLI.Tests/ServerTabsTests.cs new file mode 100644 index 00000000..76fa6113 --- /dev/null +++ b/UnchainedLauncher.CLI.Tests/ServerTabsTests.cs @@ -0,0 +1,59 @@ +using Xunit; + +namespace UnchainedLauncher.CLI.Tests; + +public class ServerTabsTests { + [Fact] + public void ArrowNavigationWrapsAndClickSelectsVisibleTabs() { + var tabs = new ServerTabs(["one", "two", "three"]); + Assert.Equal(">one< [two] [three] ", tabs.Layout(80)); + tabs.Move(-1); + Assert.Equal(2, tabs.Selected); + tabs.Move(1); + Assert.Equal(0, tabs.Selected); + Assert.True(tabs.Click(8, 1)); + Assert.Equal(1, tabs.Selected); + Assert.False(tabs.Click(8, 2)); + Assert.False(tabs.Click(80, 1)); + } + + [Fact] + public void NarrowTabBarKeepsSelectedTabVisibleAndRebuildsClickTargets() { + var tabs = new ServerTabs(["one", "two", "three"]); + Assert.Equal(">one<", tabs.Layout(5)); + tabs.Move(2); + Assert.Equal(">thre", tabs.Layout(5)); + Assert.True(tabs.Click(1, 1)); + Assert.Equal(2, tabs.Selected); + tabs.Move(1); + Assert.Equal(">one<", tabs.Layout(5)); + } + + [Theory] + [InlineData("\u001b[<0;8;1M", true)] + [InlineData("\u001b[<0;8;1m", false)] + [InlineData("\u001b[<2;8;1M", false)] + [InlineData("\u001b[<32;8;1M", false)] + [InlineData("\u001b[<0;0;1M", false)] + [InlineData("\u001b[<0;99999999999999999;1M", false)] + public void MouseParserAcceptsOnlyPrimaryPressesWithValidCoordinates(string report, bool expected) { + var parser = new TerminalMouseParser(); + (int Column, int Row)? result = null; + foreach (var value in report) { + Assert.True(parser.Feed(value, out var click)); + result = click; + } + Assert.Equal(expected, result.HasValue); + if (expected) Assert.Equal((8, 1), result!.Value); + } + + [Fact] + public void IncompleteOrMalformedMouseInputDoesNotSwallowQuit() { + var parser = new TerminalMouseParser(); + parser.Feed('\x1b', out _); + Assert.False(parser.Feed('q', out _)); + Assert.False(parser.Feed('q', out _)); + foreach (var value in "\x1b[<" + new string('1', 70)) parser.Feed(value, out _); + Assert.False(parser.Feed('q', out _)); + } +} \ No newline at end of file diff --git a/UnchainedLauncher.CLI/CliLaunchOptions.cs b/UnchainedLauncher.CLI/CliLaunchOptions.cs index 1232b0df..221981dc 100644 --- a/UnchainedLauncher.CLI/CliLaunchOptions.cs +++ b/UnchainedLauncher.CLI/CliLaunchOptions.cs @@ -24,9 +24,9 @@ public sealed class SharedLaunchOptions { public IReadOnlyList Dlls { get; } public IReadOnlyList GameArguments { get; } - internal SharedLaunchOptions(LaunchConfiguration configuration, string directory) { + internal SharedLaunchOptions(LaunchConfiguration configuration, string directory, string? savedDirSuffix = null) { Chivalry2Path = Path.GetFullPath(configuration.Chiv2Path!, directory); - SavedDirSuffix = configuration.SavedDirSuffix; + SavedDirSuffix = savedDirSuffix ?? configuration.SavedDirSuffix; Platform = configuration.Platform!.Value; Backend = configuration.ServerBrowserBackend; UpdatePlugin = configuration.UpdatePlugin; @@ -77,7 +77,9 @@ public static CliLaunchOptions Parse(string[] args) => CommandLineOptions.Parse( internal static CliLaunchOptions FromConfiguration(LaunchConfiguration configuration, string directory) { var shared = new SharedLaunchOptions(configuration, directory); return configuration switch { - ServerConfiguration server => new ServerOptions(shared, server.Server ?? new ServerSettings(), server.NoTui), + ServerConfiguration server => new ServerOptions( + server.Server?.SavedDirSuffix is { } suffix ? new SharedLaunchOptions(configuration, directory, suffix) : shared, + server.Server ?? new ServerSettings(), server.NoTui), ClientConfiguration client => new ClientOptions(shared, SharedLaunchOptions.Entries(client.Client?.GameArgs, allowEmpty: true)), _ => throw new ArgumentException("Expected client or server configuration.") }; diff --git a/UnchainedLauncher.CLI/CommandLineOptions.cs b/UnchainedLauncher.CLI/CommandLineOptions.cs index 466f2641..50e8cb04 100644 --- a/UnchainedLauncher.CLI/CommandLineOptions.cs +++ b/UnchainedLauncher.CLI/CommandLineOptions.cs @@ -47,7 +47,11 @@ internal static CliCommandOptions Parse(string[] args, Func error.SymbolResult == configResult); ThrowErrors(errors.Select(error => error.Message)); var configured = YamlConfiguration.Load(parsed.GetValue(config)!, definition.Command == client.Command); - if (loadAllDlls) configured.Shared.EnableUnsafeDllLoading(); + if (loadAllDlls) { + if (configured is CliLaunchOptions single) single.Shared.EnableUnsafeDllLoading(); + if (configured is ServerGroupOptions group) + foreach (var entry in group.Servers) entry.Options.Shared.EnableUnsafeDllLoading(); + } return configured; } if (args.Length == 0 || parsed.GetResult("--help") is not null) { diff --git a/UnchainedLauncher.CLI/LaunchConfiguration.cs b/UnchainedLauncher.CLI/LaunchConfiguration.cs index 0af7211d..41e8ef1a 100644 --- a/UnchainedLauncher.CLI/LaunchConfiguration.cs +++ b/UnchainedLauncher.CLI/LaunchConfiguration.cs @@ -30,6 +30,7 @@ public virtual void Validate() { public sealed class ServerConfiguration : LaunchConfiguration { public bool NoTui { get; set; } public ServerSettings? Server { get; set; } + public Dictionary? Servers { get; set; } // Recognized but skipped: do not deserialize or validate the inactive schema. [YamlConverter(typeof(IgnoredYamlNodeConverter))] public object? Client { get; set; } @@ -37,6 +38,14 @@ public sealed class ServerConfiguration : LaunchConfiguration { public override void Validate() { base.Validate(); if (Server is not null) Validator.ValidateObject(Server, new ValidationContext(Server), validateAllProperties: true); + if (Servers is not null) { + if (Server is not null) throw new ValidationException("Use either server or servers, not both."); + if (Servers.Count == 0) throw new ValidationException("servers must contain at least one named server."); + foreach (var settings in Servers.Values) { + if (settings is null) throw new ValidationException("Each servers entry must contain server settings."); + Validator.ValidateObject(settings, new ValidationContext(settings), validateAllProperties: true); + } + } } } @@ -44,6 +53,8 @@ public sealed class ClientConfiguration : LaunchConfiguration { public ClientSettings? Client { get; set; } [YamlConverter(typeof(IgnoredYamlNodeConverter))] public object? Server { get; set; } + [YamlConverter(typeof(IgnoredYamlNodeConverter))] + public object? Servers { get; set; } } public sealed class ModSetupConfiguration { @@ -57,6 +68,7 @@ public sealed class ClientSettings { } public sealed class ServerSettings { + public string? SavedDirSuffix { get; set; } [Required] public string Name { get; set; } = "Unchained Server"; public string Description { get; set; } = ""; diff --git a/UnchainedLauncher.CLI/Program.cs b/UnchainedLauncher.CLI/Program.cs index 2b475e06..d5a06f36 100644 --- a/UnchainedLauncher.CLI/Program.cs +++ b/UnchainedLauncher.CLI/Program.cs @@ -14,11 +14,16 @@ public static async Task Main(string[] args) { return 2; } if (command.Help) { Console.Write(command.HelpText); return 0; } - if (command is CliLaunchOptions launch && launch.Shared.Dlls.Count != 0) { + var launches = command switch { + ServerGroupOptions group => group.Servers.Select(entry => (CliLaunchOptions)entry.Options).ToArray(), + CliLaunchOptions single => [single], + _ => Array.Empty() + }; + if (launches.Any(launch => launch.Shared.Dlls.Count != 0)) { Console.Error.WriteLine("Resolving explicitly selected DLL releases from dll_db; native code will execute in the game."); Console.Error.WriteLine("Tracking: https://github.com/Chiv2-Community/UnchainedLauncher/issues/149"); } - if (command is CliLaunchOptions unsafeLaunch && unsafeLaunch.Shared.UnsafeLoadAllDlls) + if (launches.Any(launch => launch.Shared.UnsafeLoadAllDlls)) Console.Error.WriteLine("DANGER: explicitly enabled injection of ALL DLLs recursively in Plugins; arbitrary native code will execute in the game."); using var stop = new CancellationTokenSource(); ConsoleCancelEventHandler handler = (_, e) => { e.Cancel = true; stop.Cancel(); }; @@ -30,6 +35,7 @@ public static async Task Main(string[] args) { ? await DllQuery.RunAsync(query, registry, Console.Out, Console.Error, stop.Token) : await ModQuery.RunAsync(query, registry, Console.Out, Console.Error, stop.Token); } + if (command is ServerGroupOptions group) return await ServerGroupRunner.RunAsync(group, stop); var options = (CliLaunchOptions)command; using var process = await ServerLauncher.LaunchAsync(options, stop.Token); return await SuperviseAsync(options, process, stop); @@ -51,7 +57,7 @@ internal static async Task SuperviseAsync(CliLaunchOptions options, Process } if (options is not ServerOptions server) throw new ArgumentException("Expected a launch request."); try { - using var dashboard = new ServerDashboard(!server.NoTui); + using var dashboard = new ServerDashboard(!server.NoTui, server.ToServerLaunchOptions()); var monitor = new ServerMonitor(process, ServerLauncher.CreateQuery(server)); await monitor.RunAsync(dashboard, stop); return stop.IsCancellationRequested ? 0 : process.ExitCode; diff --git a/UnchainedLauncher.CLI/README.md b/UnchainedLauncher.CLI/README.md index f9b69095..49f599d7 100644 --- a/UnchainedLauncher.CLI/README.md +++ b/UnchainedLauncher.CLI/README.md @@ -217,6 +217,53 @@ YAML syntax errors report a location without quoting the configuration contents. Unknown keys, duplicate keys, invalid types, ports, modes, or coordinates fail before preparing or launching the game. Lists accept any number of entries. +### Multiple servers + +Use a `servers` dictionary instead of the single `server` block: + +```yaml +chiv2-path: 'C:\Games\Chivalry 2' +platform: Steam +saved-dir-suffix: MyHost +servers: + courtyard: + name: Courtyard FFA + map: FFA_Courtyard + game-port: 7777 + ping-port: 3075 + query-port: 7071 + rcon-port: 9001 + arena: + name: Arena FFA + map: FFA_Courtyard + game-port: 7778 + ping-port: 3076 + query-port: 7072 + rcon-port: 9002 +``` + +Run `UnchainedLauncher.CLI.exe server --config-path servers.yaml`. +See [the multi-server example](examples/servers.yaml). The existing `server` schema +and individual CLI launch arguments still launch one server. + +- Keys are tab IDs: unique, case-insensitive letters, digits, underscores, and hyphens. +- Each entry accepts the same fields as `server`, including its own actors, + arguments, password, and Discord settings. Installation, platform, registry, + pak/DLL selections, and common arguments remain shared at the root. +- Profiles default to `-`. Set `saved-dir-suffix` inside an + entry to override it. Profile names must be distinct, ignoring case. +- Assign all four ports to each server. Every port must be distinct across the + entire group, even when local IPs differ. Defaults are not automatically offset. +- All entries are validated before launch. Startup is sequential because Core + preparation changes the installation and working directory. A startup failure + stops previously launched servers; it does not leave a partial group running. +- After startup, one server exiting does not stop the others. Its tab retains the + exit code and last metrics. Once all exit, the CLI returns the first nonzero exit + code in dictionary order, or zero if all succeeded. Q/Ctrl+C stops the entire group. + +Do not mix `server` and `servers` in one document. The client command and registry +queries ignore the inactive `servers` block. + The `client` command also accepts individual common arguments, for example: ```text @@ -381,19 +428,30 @@ Avoid exposing authentication arguments and passwords in shared scripts or proce While the server is running, the dashboard displays: -- PID and process uptime. +- Server name, PID, process uptime, and restart count (zero; no automatic restart). +- Game, query, beacon, and RCON ports, local IP, and configured mod actors. - CPU use as a percentage of total host logical CPU capacity (0–100%). -- Private process memory in MiB. -- A2S player/max-player counts, current map, and query availability. -- Bounded rolling CPU/memory/player sparklines (up to 120 samples). +- Private process memory and observed peak memory in MiB, plus handle count. +- A2S player/max-player counts, current map, game type, and query availability. +- Bounded rolling CPU/memory/player sparklines and an uptime-state strip + (up to 12 hours, sampled every ten seconds, scaled to the terminal width). - A bounded timeline of launch, map/player changes, and query outages/recovery. -Metrics are sampled approximately once per second. During startup or query failure, +Metrics refresh approximately once per second. During startup or query failure, player count and map are **unknown**, not zero; `?` marks missing chart samples. The timeline represents observed changes, not per-player join/leave identities or -the full game log. Network throughput and RCON controls are not included yet. - -Press **Q** in the TUI or **Ctrl+C** to stop. Redirected input/output and dumb terminals +the full game log. The uptime strip shows `S` until the first successful query, +then `U` while the process runs, including query outages, matching the GUI. +Network throughput and RCON controls are not included yet. + +Press **Q** in the TUI or **Ctrl+C** to stop. **Page Up**/**Page Down** scroll wrapped content +and **Home** returns to the top; narrow terminals do not discard long mod lists. +With multiple servers, **Left**/**Right** select tabs, wrapping at either end. +Click a visible tab with the primary mouse button in terminals supporting SGR mouse +reports (including xterm-compatible terminals and Windows Terminal). Keyboard +navigation remains available when mouse reporting is unsupported. Mouse capture +and Windows console modes are restored on exit. +Redirected input/output and dumb terminals automatically use plain-text status (every ten seconds). The CLI owns its server: stopping it terminates the game process, matching Core's current stop behavior; there is no detach mode or automatic restart. Natural exits return the server's diff --git a/UnchainedLauncher.CLI/RegistryQueryConfiguration.cs b/UnchainedLauncher.CLI/RegistryQueryConfiguration.cs index 3ec8f7a1..d809d88f 100644 --- a/UnchainedLauncher.CLI/RegistryQueryConfiguration.cs +++ b/UnchainedLauncher.CLI/RegistryQueryConfiguration.cs @@ -15,6 +15,7 @@ public sealed class RegistryQueryConfiguration { [YamlConverter(typeof(IgnoredYamlNodeConverter))] public object? NoTui { get; set; } [YamlConverter(typeof(IgnoredYamlNodeConverter))] public object? GameArgs { get; set; } [YamlConverter(typeof(IgnoredYamlNodeConverter))] public object? Server { get; set; } + [YamlConverter(typeof(IgnoredYamlNodeConverter))] public object? Servers { get; set; } [YamlConverter(typeof(IgnoredYamlNodeConverter))] public object? Client { get; set; } } diff --git a/UnchainedLauncher.CLI/ServerDashboard.cs b/UnchainedLauncher.CLI/ServerDashboard.cs index 95fc26c5..15c6166e 100644 --- a/UnchainedLauncher.CLI/ServerDashboard.cs +++ b/UnchainedLauncher.CLI/ServerDashboard.cs @@ -1,13 +1,23 @@ using System.Globalization; +using UnchainedLauncher.Core.Services.Processes.Chivalry; namespace UnchainedLauncher.CLI; public sealed class ServerDashboard : IDisposable { private bool _interactive; private bool? _previousCursorVisible; - private DateTimeOffset _lastPlainOutput = DateTimeOffset.MinValue; + private readonly Dictionary _lastPlainOutput = []; + private readonly ServerLaunchOptions? _launchOptions; + private int _scrollOffset; + private readonly ServerTabs? _tabs; + private readonly TerminalMouseParser _mouseParser = new(); + private TerminalMouse? _mouse; + public bool IsInteractive => _interactive; + public int SelectedTab => _tabs?.Selected ?? 0; - public ServerDashboard(bool useTui) { + public ServerDashboard(bool useTui, ServerLaunchOptions? launchOptions = null, IReadOnlyList? tabNames = null) { + _launchOptions = launchOptions; + if (tabNames is { Count: > 0 }) _tabs = new ServerTabs(tabNames); _interactive = useTui && !Console.IsOutputRedirected && !Console.IsInputRedirected && Environment.GetEnvironmentVariable("TERM") != "dumb"; if (_interactive) { @@ -15,6 +25,7 @@ public ServerDashboard(bool useTui) { if (OperatingSystem.IsWindows()) _previousCursorVisible = Console.CursorVisible; Console.CursorVisible = false; Console.Clear(); + if (_tabs is not null) _mouse = new TerminalMouse(); } catch (Exception ex) when (ex is IOException or PlatformNotSupportedException) { _interactive = false; } } @@ -24,22 +35,45 @@ public bool StopRequested() { if (!_interactive) return false; try { while (Console.KeyAvailable) { - if (Console.ReadKey(intercept: true).Key == ConsoleKey.Q) return true; + var key = Console.ReadKey(intercept: true); + if (_mouseParser.Feed(key.KeyChar, out var click)) { + if (click is { } position && _tabs?.Click(position.Column, position.Row) == true) _scrollOffset = 0; + continue; + } + switch (key.Key) { + case ConsoleKey.Q: return true; + case ConsoleKey.PageDown: _scrollOffset += 10; break; + case ConsoleKey.PageUp: _scrollOffset = Math.Max(0, _scrollOffset - 10); break; + case ConsoleKey.Home: _scrollOffset = 0; break; + case ConsoleKey.LeftArrow: _tabs?.Move(-1); _scrollOffset = 0; break; + case ConsoleKey.RightArrow: _tabs?.Move(1); _scrollOffset = 0; break; + } } } catch (InvalidOperationException) { _interactive = false; } return false; } - public void Render(int pid, ServerSample sample, ServerHistory history) { + public void Render(int pid, ServerSample sample, ServerHistory history, + ServerLaunchOptions? launchOptions = null, string? status = null) { if (_interactive) { try { var width = Math.Max(1, Console.WindowWidth - 1); var height = Math.Max(1, Console.WindowHeight - 1); - var lines = FormatLines(pid, sample, history, width); + var lines = FormatLines(pid, sample, history, width, launchOptions ?? _launchOptions) + .SelectMany(line => WrapLine(SafeText(line), width)).ToArray(); + if (status is not null) lines = WrapLine(SafeText(status), width).Concat(lines).ToArray(); + var tabLine = _tabs?.Layout(width); + var firstContentRow = tabLine is null ? 0 : 1; + var contentHeight = Math.Max(0, height - firstContentRow - 1); + _scrollOffset = Math.Clamp(_scrollOffset, 0, Math.Max(0, lines.Length - contentHeight)); Console.SetCursorPosition(0, 0); for (var row = 0; row < height; row++) { - var text = row < lines.Count ? SafeText(lines[row]) : ""; + var index = row - firstContentRow + _scrollOffset; + var text = row == 0 && tabLine is not null ? tabLine + : row == height - 1 + ? $"Left/Right: tabs | PgUp/PgDn: scroll | Q: stop all | {_scrollOffset + 1}-{Math.Min(lines.Length, _scrollOffset + contentHeight)}/{lines.Length}" + : index < lines.Length ? lines[index] : ""; Console.Write(text[..Math.Min(text.Length, width)].PadRight(width)); if (row + 1 < height) Console.WriteLine(); } @@ -49,24 +83,34 @@ public void Render(int pid, ServerSample sample, ServerHistory history) { _interactive = false; } } - if (sample.Time - _lastPlainOutput < TimeSpan.FromSeconds(10)) return; - _lastPlainOutput = sample.Time; - Console.WriteLine(SafeText(string.Join(" | ", FormatLines(pid, sample, history, 80).Take(4)))); + if (_lastPlainOutput.TryGetValue(pid, out var lastOutput) && lastOutput.Status == status + && sample.Time - lastOutput.Time < TimeSpan.FromSeconds(10)) return; + _lastPlainOutput[pid] = (sample.Time, status); + Console.WriteLine(SafeText((status is null ? "" : status + " | ") + string.Join(" | ", + FormatLines(pid, sample, history, 80, launchOptions ?? _launchOptions).TakeWhile(line => line.Length != 0)))); } - public static IReadOnlyList FormatLines(int pid, ServerSample sample, ServerHistory history, int width) { + public static IReadOnlyList FormatLines(int pid, ServerSample sample, ServerHistory history, int width, + ServerLaunchOptions? launchOptions = null) { string Number(double? value, string suffix) => value.HasValue ? value.Value.ToString("F1", CultureInfo.InvariantCulture) + suffix : "unknown"; var players = sample.Players.HasValue ? $"{sample.Players}/{sample.MaxPlayers}" : "unknown"; var uptime = $"{(int)sample.Uptime.TotalDays}d {sample.Uptime:hh\\:mm\\:ss}"; var chartWidth = Math.Clamp(width - 22, 1, 80); var samples = history.Samples.ToArray(); + var actors = launchOptions?.ServerMods.Where(actor => !string.IsNullOrWhiteSpace(actor)).ToArray() ?? []; + string Port(int? port) => port?.ToString(CultureInfo.InvariantCulture) ?? "unknown"; List lines = [ - $"Unchained Server | PID {pid} | Uptime {uptime} | Q / Ctrl+C: stop", + $"{launchOptions?.Name ?? "Unchained Server"} | PID {pid} | Q / Ctrl+C: stop", + $"State {history.State} | Uptime {uptime} | Restarts 0 (no auto-restart)", + $"Players {players} | Map {sample.Map ?? "unknown"} | Game Type {sample.GameType ?? "unknown"}", + $"Game Port {Port(launchOptions?.GamePort)} | Query Port {Port(launchOptions?.QueryPort)} | Beacon Port {Port(launchOptions?.BeaconPort)} | RCON Port {Port(launchOptions?.RconPort)}", + $"Local IP {launchOptions?.LocalIp.IfNone("127.0.0.1") ?? "unknown"} | Mods {(actors.Length == 0 ? "-" : string.Join(", ", actors))}", $"CPU {Number(sample.CpuPercent, "% host")} | Private memory {Number(sample.MemoryMiB, " MiB")}", - $"Players {players} | Map {sample.Map ?? "unknown"}", + $"Peak memory {Number(sample.PeakMemoryMiB, " MiB")} | Handle Count {sample.HandleCount?.ToString(CultureInfo.InvariantCulture) ?? "unknown"}", $"A2S {(sample.QueryAvailable ? "responding" : "unavailable (starting or query timeout)")} | {sample.Time:HH:mm:ss}", - "", "Recent resource/player history (up to 120 samples; '?' = unknown):", + "", "History (up to 12 hours; 10s samples; '?' = unknown):", + $"Uptime S=start U=up {StateHistory(samples, chartWidth)}", $"CPU (0-100%) {Sparkline(samples.Select(s => s.CpuPercent), chartWidth, 100)}", $"Memory {Sparkline(samples.Select(s => s.MemoryMiB), chartWidth)}", $"Players {Sparkline(samples.Select(s => s.Players.HasValue ? (double?)s.Players.Value : null), chartWidth)}", @@ -78,15 +122,32 @@ string Number(double? value, string suffix) => value.HasValue public static string Sparkline(IEnumerable values, int width, double? maximum = null) { const string levels = ".:-=+*#%@"; - var samples = values.TakeLast(Math.Max(1, width)).ToArray(); + var samples = values.ToArray(); var max = maximum ?? samples.Where(v => v.HasValue).Select(v => v!.Value).DefaultIfEmpty(0).Max(); - return new string(samples.Select(value => !value.HasValue ? '?' : levels[max <= 0 ? 0 + return new string(Compress(samples, width).Select(value => !value.HasValue ? '?' : levels[max <= 0 ? 0 : (int)Math.Clamp(value.Value / max * (levels.Length - 1), 0, levels.Length - 1)]).ToArray()); } + private static IEnumerable Compress(T[] samples, int width) { + var count = Math.Min(samples.Length, Math.Max(1, width)); + for (var i = 0; i < count; i++) + yield return samples[(int)((long)(i + 1) * samples.Length / count) - 1]; + } + + private static string StateHistory(ServerSample[] samples, int width) => + new(Compress(samples, width).Select(sample => sample.Starting ? 'S' : 'U').ToArray()); + public static string SafeText(string value) => new(value.Select(c => char.IsControl(c) ? ' ' : c).ToArray()); + public static IEnumerable WrapLine(string line, int width) { + width = Math.Max(1, width); + if (line.Length == 0) yield return ""; + for (var offset = 0; offset < line.Length; offset += width) + yield return line.Substring(offset, Math.Min(width, line.Length - offset)); + } + public void Dispose() { + _mouse?.Dispose(); try { Console.CursorVisible = _previousCursorVisible ?? true; if (_interactive) Console.WriteLine(); diff --git a/UnchainedLauncher.CLI/ServerGroupOptions.cs b/UnchainedLauncher.CLI/ServerGroupOptions.cs new file mode 100644 index 00000000..6d3de99d --- /dev/null +++ b/UnchainedLauncher.CLI/ServerGroupOptions.cs @@ -0,0 +1,35 @@ +using System.Text.RegularExpressions; + +namespace UnchainedLauncher.CLI; + +public sealed record NamedServerOptions(string Id, ServerOptions Options); + +public sealed class ServerGroupOptions : CliCommandOptions { + public IReadOnlyList Servers { get; } + public bool NoTui { get; } + + private ServerGroupOptions(IReadOnlyList servers, bool noTui) { + Servers = servers; + NoTui = noTui; + } + + internal static ServerGroupOptions Create(ServerConfiguration configuration, string directory) { + var servers = new List(); + var ids = new HashSet(StringComparer.OrdinalIgnoreCase); + var profiles = new HashSet(StringComparer.OrdinalIgnoreCase); + var ports = new HashSet(); + foreach (var (id, settings) in configuration.Servers!) { + if (!Regex.IsMatch(id, @"\A[A-Za-z0-9_-]+\z") || !ids.Add(id)) + throw new ArgumentException("Server IDs must be unique (case-insensitive) and contain only letters, digits, underscores, and hyphens."); + var suffix = settings!.SavedDirSuffix ?? $"{configuration.SavedDirSuffix}-{id}"; + var options = new ServerOptions(new SharedLaunchOptions(configuration, directory, suffix), settings, configuration.NoTui); + if (!profiles.Add(options.Shared.SavedDirSuffix)) + throw new ArgumentException("Each server must use a distinct saved-dir-suffix (case-insensitive)."); + foreach (var port in new[] { settings.GamePort, settings.PingPort, settings.QueryPort, settings.RconPort }) { + if (!ports.Add(port)) throw new ArgumentException($"Port {port} is used by more than one server. Assign distinct game, ping, query, and RCON ports to every server."); + } + servers.Add(new NamedServerOptions(id, options)); + } + return new ServerGroupOptions(servers, configuration.NoTui); + } +} diff --git a/UnchainedLauncher.CLI/ServerGroupRunner.cs b/UnchainedLauncher.CLI/ServerGroupRunner.cs new file mode 100644 index 00000000..6fd9d58e --- /dev/null +++ b/UnchainedLauncher.CLI/ServerGroupRunner.cs @@ -0,0 +1,83 @@ +using System.Diagnostics; +using UnchainedLauncher.Core.Services.Server.A2S; + +namespace UnchainedLauncher.CLI; + +internal sealed record ServerTabSnapshot(ServerSample Sample, ServerHistory History, string Status); + +internal static class ServerGroupRunner { + internal static async Task RunAsync(ServerGroupOptions group, CancellationTokenSource stop, + Func>? launch = null, + Func? query = null) { + launch ??= (options, token) => ServerLauncher.LaunchAsync(options, token); + query ??= ServerLauncher.CreateQuery; + using var monitoring = CancellationTokenSource.CreateLinkedTokenSource(stop.Token); + var processes = new List(); + var tasks = new List(); + var snapshots = new ServerTabSnapshot?[group.Servers.Count]; + try { + // Core preparation changes CWD and installation files. Never launch in parallel. + foreach (var entry in group.Servers) { + stop.Token.ThrowIfCancellationRequested(); + Console.Error.WriteLine($"Starting server {entry.Id}..."); + processes.Add(await launch(entry.Options, stop.Token)); + } + + using var dashboard = new ServerDashboard(!group.NoTui, + tabNames: group.Servers.Select(entry => entry.Id).ToArray()); + for (var i = 0; i < processes.Count; i++) tasks.Add(MonitorAsync(i)); + + using var timer = new PeriodicTimer(TimeSpan.FromMilliseconds(100)); + while (true) { + if (dashboard.StopRequested()) stop.Cancel(); + for (var i = 0; i < snapshots.Length; i++) { + if (dashboard.IsInteractive && i != dashboard.SelectedTab) continue; + var snapshot = Volatile.Read(ref snapshots[i]); + if (snapshot is not null) dashboard.Render(processes[i].Id, snapshot.Sample, snapshot.History, + group.Servers[i].Options.ToServerLaunchOptions(), $"[{group.Servers[i].Id}] {snapshot.Status}"); + } + if (stop.IsCancellationRequested || tasks.All(task => task.IsCompleted)) break; + if (tasks.Any(task => task.IsFaulted)) + await Task.WhenAll(tasks.Where(task => task.IsFaulted)); + if (!await timer.WaitForNextTickAsync(stop.Token)) break; + } + monitoring.Cancel(); + await Task.WhenAll(tasks); + return stop.IsCancellationRequested ? 0 : processes.Select(process => process.ExitCode).FirstOrDefault(code => code != 0); + + async Task MonitorAsync(int index) { + var process = processes[index]; + var monitor = new ServerMonitor(process, query(group.Servers[index].Options)); + var last = new ServerSample(DateTimeOffset.Now, TimeSpan.Zero, null, null, null, null, null, false); + Volatile.Write(ref snapshots[index], new(last, monitor.History.Snapshot(), "Starting")); + if (!process.HasExited) { + await monitor.RunAsync((sample, history) => { + last = sample; + Volatile.Write(ref snapshots[index], new(sample, history.Snapshot(), "Running")); + }, monitoring.Token); + } + var status = process.HasExited ? $"Exited (code {process.ExitCode}); last recorded metrics" : "Stopping"; + Volatile.Write(ref snapshots[index], new(last, monitor.History.Snapshot(stopped: process.HasExited), status)); + } + } + catch (OperationCanceledException) when (stop.IsCancellationRequested) { return 0; } + finally { + monitoring.Cancel(); + try { await Task.WhenAll(tasks); } + catch (Exception) { /* The main loop propagates monitor failures; cleanup must still stop every process. */ } + var failedStops = new List(); + foreach (var process in processes) { + try { + if (!process.HasExited) { + process.Kill(entireProcessTree: true); + await process.WaitForExitAsync(); + } + } + catch (Exception) { failedStops.Add(process.Id); } + finally { process.Dispose(); } + } + if (failedStops.Count != 0) + throw new InvalidOperationException($"Could not stop server processes: {string.Join(", ", failedStops)}. Stop them manually."); + } + } +} diff --git a/UnchainedLauncher.CLI/ServerMonitor.cs b/UnchainedLauncher.CLI/ServerMonitor.cs index 5752552c..2040a72d 100644 --- a/UnchainedLauncher.CLI/ServerMonitor.cs +++ b/UnchainedLauncher.CLI/ServerMonitor.cs @@ -5,31 +5,48 @@ namespace UnchainedLauncher.CLI; public record ServerSample(DateTimeOffset Time, TimeSpan Uptime, double? CpuPercent, - double? MemoryMiB, int? Players, int? MaxPlayers, string? Map, bool QueryAvailable); + double? MemoryMiB, int? Players, int? MaxPlayers, string? Map, bool QueryAvailable, + string? GameType = null, int? HandleCount = null, double? PeakMemoryMiB = null) { + public bool Starting { get; init; } +} public record TimelineEvent(DateTimeOffset Time, string Message); public sealed class ServerHistory { - public const int MaxSamples = 120; + public const int MaxSamples = 12 * 60 * 60 / 10; public const int MaxEvents = 50; private readonly Queue _samples = new(); private readonly Queue _events = new(); + private ServerSample? _previous; + private bool _hasQueryResponse; + private bool _stopped; + public string State => _stopped ? "Down" : _hasQueryResponse ? "Up" : "Starting"; public IReadOnlyCollection Samples => _samples; public IReadOnlyCollection Events => _events; + internal ServerHistory Snapshot(bool stopped = false) { + var snapshot = new ServerHistory { _hasQueryResponse = _hasQueryResponse, _stopped = stopped }; + foreach (var sample in _samples) snapshot._samples.Enqueue(sample); + foreach (var entry in _events) snapshot._events.Enqueue(entry); + return snapshot; + } + public void AddEvent(DateTimeOffset time, string message) { _events.Enqueue(new TimelineEvent(time, message)); while (_events.Count > MaxEvents) _events.Dequeue(); } public void Add(ServerSample sample) { - var previous = _samples.LastOrDefault(); + var previous = _previous; if (previous is null || previous.QueryAvailable != sample.QueryAvailable) AddEvent(sample.Time, sample.QueryAvailable ? "A2S responding" : "A2S unavailable; waiting for server query response"); if (sample.QueryAvailable) { if (previous?.Map != sample.Map) AddEvent(sample.Time, $"Map: {sample.Map}"); if (previous?.Players != sample.Players) AddEvent(sample.Time, $"Players: {sample.Players}/{sample.MaxPlayers}"); } - _samples.Enqueue(sample); + _previous = sample; + _hasQueryResponse |= sample.QueryAvailable; + if (_samples.Count != 0 && sample.Time - _samples.Last().Time < TimeSpan.FromSeconds(10)) return; + _samples.Enqueue(sample with { Starting = !_hasQueryResponse }); while (_samples.Count > MaxSamples) _samples.Dequeue(); } } @@ -38,17 +55,25 @@ public sealed class ServerMonitor(Process process, IA2S query) { public ServerHistory History { get; } = new(); public async Task RunAsync(ServerDashboard dashboard, CancellationTokenSource stop) { + await RunAsync((sample, history) => { + if (dashboard.StopRequested()) stop.Cancel(); + dashboard.Render(process.Id, sample, history); + }, stop.Token); + Console.Error.WriteLine(History.Events.Last().Message); + } + + internal async Task RunAsync(Action render, CancellationToken stop) { var started = new DateTimeOffset(process.StartTime); var previousTime = Stopwatch.GetTimestamp(); var previousCpu = process.TotalProcessorTime; + double? peakMemory = null; History.AddEvent(DateTimeOffset.Now, $"Server started (PID {process.Id})"); using var timer = new PeriodicTimer(TimeSpan.FromSeconds(1)); try { while (!stop.IsCancellationRequested && !process.HasExited) { - if (dashboard.StopRequested()) { stop.Cancel(); break; } A2SInfo? info = null; try { - info = await query.InfoAsync().WaitAsync(stop.Token); + info = await query.InfoAsync().WaitAsync(stop); } catch (OperationCanceledException) when (stop.IsCancellationRequested) { throw; } catch (Exception) { @@ -59,12 +84,15 @@ public async Task RunAsync(ServerDashboard dashboard, CancellationTokenSource st process.Refresh(); double? cpu = null; double? memory = null; + int? handles = null; try { var nowTicks = Stopwatch.GetTimestamp(); var elapsed = Stopwatch.GetElapsedTime(previousTime, nowTicks).TotalSeconds; var totalCpu = process.TotalProcessorTime; cpu = Math.Clamp((totalCpu - previousCpu).TotalSeconds / elapsed / System.Environment.ProcessorCount * 100, 0, 100); memory = process.PrivateMemorySize64 / 1048576d; + peakMemory = Math.Max(peakMemory ?? 0, memory.Value); + handles = process.HandleCount; previousCpu = totalCpu; previousTime = nowTicks; } @@ -73,10 +101,11 @@ public async Task RunAsync(ServerDashboard dashboard, CancellationTokenSource st } var now = DateTimeOffset.Now; var sample = new ServerSample(now, now - started, cpu, memory, - info?.Players, info?.MaxPlayers, info?.Map, info is not null); + info?.Players, info?.MaxPlayers, info?.Map, info is not null, + info?.GameType, handles, peakMemory); History.Add(sample); - dashboard.Render(process.Id, sample, History); - if (!await timer.WaitForNextTickAsync(stop.Token)) break; + render(sample, History); + if (!await timer.WaitForNextTickAsync(stop)) break; } } catch (OperationCanceledException) when (stop.IsCancellationRequested) { } @@ -84,7 +113,7 @@ public async Task RunAsync(ServerDashboard dashboard, CancellationTokenSource st var message = stop.IsCancellationRequested ? "Stop requested" : process.HasExited ? $"Server exited (code {process.ExitCode})" : "Monitoring ended"; History.AddEvent(DateTimeOffset.Now, message); - Console.Error.WriteLine(message); + // A group owns console output; individual monitors only record events. } } } \ No newline at end of file diff --git a/UnchainedLauncher.CLI/ServerTabs.cs b/UnchainedLauncher.CLI/ServerTabs.cs new file mode 100644 index 00000000..773e19e1 --- /dev/null +++ b/UnchainedLauncher.CLI/ServerTabs.cs @@ -0,0 +1,74 @@ +using System.Globalization; +using System.Text; + +namespace UnchainedLauncher.CLI; + +public sealed class ServerTabs(IReadOnlyList names) { + private readonly List<(int Start, int End, int Index)> _targets = []; + private int _first; + public int Selected { get; private set; } + + public void Move(int delta) { + if (names.Count != 0) Selected = (Selected + delta % names.Count + names.Count) % names.Count; + } + + public bool Click(int column, int row) { + if (row != 1) return false; + foreach (var target in _targets) { + if (column < target.Start || column > target.End) continue; + Selected = target.Index; + return true; + } + return false; + } + + public string Layout(int width) { + width = Math.Max(1, width); + var text = Build(); + if (_targets.All(target => target.Index != Selected)) { + _first = Selected; + text = Build(); + } + return text; + + string Build() { + _targets.Clear(); + var text = new StringBuilder(); + for (var i = _first; i < names.Count; i++) { + var label = i == Selected ? $">{names[i]}<" : $"[{names[i]}]"; + label = ServerDashboard.SafeText(label); + label = label[..Math.Min(label.Length, Math.Min(width, 24))]; + if (text.Length + label.Length > width) break; + _targets.Add((text.Length + 1, text.Length + label.Length, i)); + text.Append(label); + if (text.Length < width) text.Append(' '); + } + return text.ToString(); + } + } +} + +public sealed class TerminalMouseParser { + private string? _pending; + + public bool Feed(char value, out (int Column, int Row)? click) { + click = null; + if (value == '\x1b') { _pending = "\x1b"; return true; } + if (_pending is null) return false; + _pending += value; + if (_pending.Length > 64 || !(char.IsAsciiDigit(value) || value is '[' or '<' or ';' or 'M' or 'm')) { + _pending = null; + return false; + } + if (value is not ('M' or 'm')) return true; + var sequence = _pending; + _pending = null; + if (!sequence.StartsWith("\x1b[<", StringComparison.Ordinal) || value != 'M') return true; + var parts = sequence[3..^1].Split(';'); + if (parts.Length == 3 && parts[0] == "0" + && int.TryParse(parts[1], NumberStyles.None, CultureInfo.InvariantCulture, out var column) + && int.TryParse(parts[2], NumberStyles.None, CultureInfo.InvariantCulture, out var row) + && column > 0 && row > 0) click = (column, row); + return true; + } +} diff --git a/UnchainedLauncher.CLI/TerminalMouse.cs b/UnchainedLauncher.CLI/TerminalMouse.cs new file mode 100644 index 00000000..550e17d5 --- /dev/null +++ b/UnchainedLauncher.CLI/TerminalMouse.cs @@ -0,0 +1,45 @@ +using System.Runtime.InteropServices; + +namespace UnchainedLauncher.CLI; + +// SGR mouse reports are supported by xterm-compatible terminals and Windows Terminal. +internal sealed class TerminalMouse : IDisposable { + private readonly List<(IntPtr Handle, uint Mode)> _modes = []; + private bool _enabled; + + internal TerminalMouse() { + try { + if (OperatingSystem.IsWindows()) { + if (!SetMode(-10, mode => (mode | 0x200u | 0x80u) & ~0x40u) + || !SetMode(-11, mode => mode | 0x4u)) { + Dispose(); + return; + } + } + Console.Write("\x1b[?1000h\x1b[?1006h"); + _enabled = true; + } + catch (IOException) { Dispose(); } + } + + private bool SetMode(int stream, Func update) { + var handle = GetStdHandle(stream); + if (!GetConsoleMode(handle, out var mode) || !SetConsoleMode(handle, update(mode))) return false; + _modes.Add((handle, mode)); + return true; + } + + public void Dispose() { + try { if (_enabled) Console.Write("\x1b[?1000l\x1b[?1006l"); } + catch (IOException) { } + finally { + _enabled = false; + foreach (var (handle, mode) in _modes) SetConsoleMode(handle, mode); + _modes.Clear(); + } + } + + [DllImport("kernel32.dll")] private static extern IntPtr GetStdHandle(int handle); + [DllImport("kernel32.dll")] private static extern bool GetConsoleMode(IntPtr handle, out uint mode); + [DllImport("kernel32.dll")] private static extern bool SetConsoleMode(IntPtr handle, uint mode); +} diff --git a/UnchainedLauncher.CLI/YamlConfiguration.cs b/UnchainedLauncher.CLI/YamlConfiguration.cs index a531d4f6..f395e223 100644 --- a/UnchainedLauncher.CLI/YamlConfiguration.cs +++ b/UnchainedLauncher.CLI/YamlConfiguration.cs @@ -6,11 +6,14 @@ namespace UnchainedLauncher.CLI; public static class YamlConfiguration { - public static CliLaunchOptions Load(string path, bool client) { + public static CliCommandOptions Load(string path, bool client) { LaunchConfiguration configuration = client ? LoadDocument(path) : LoadDocument(path); try { configuration.Validate(); } catch (ValidationException ex) { throw new ArgumentException("Invalid configuration: " + ex.Message); } - return CliLaunchOptions.FromConfiguration(configuration, Path.GetDirectoryName(Path.GetFullPath(path))!); + var directory = Path.GetDirectoryName(Path.GetFullPath(path))!; + return configuration is ServerConfiguration { Servers: not null } group + ? ServerGroupOptions.Create(group, directory) + : CliLaunchOptions.FromConfiguration(configuration, directory); } internal static T LoadDocument(string path) where T : class { diff --git a/UnchainedLauncher.CLI/examples/servers.yaml b/UnchainedLauncher.CLI/examples/servers.yaml new file mode 100644 index 00000000..64da82d8 --- /dev/null +++ b/UnchainedLauncher.CLI/examples/servers.yaml @@ -0,0 +1,33 @@ +# Run: UnchainedLauncher.CLI.exe server --config-path servers.yaml +# Paths resolve relative to this file. All servers share this installation and mod/DLL set. +chiv2-path: 'C:\Games\Chivalry 2' +platform: Steam +saved-dir-suffix: MyHost # Default profiles: MyHost-courtyard and MyHost-arena. +server-browser-backend: https://servers.polehammer.net +update-plugin: true +allow-unstable-plugin: false +no-tui: false +game-args: [] +mod-setup: + registries: [] # Official registry is included automatically. + mods: [] + dlls: [] + +servers: + courtyard: + name: Courtyard FFA + map: FFA_Courtyard + game-port: 7777 + ping-port: 3075 + query-port: 7071 + rcon-port: 9001 + mod-actors: [] + arena: + name: Arena FFA + map: FFA_Courtyard # Replace with an installed map's travel name. + saved-dir-suffix: ArenaProfile # Optional explicit per-server profile. + game-port: 7778 + ping-port: 3076 + query-port: 7072 + rcon-port: 9002 + mod-actors: [] From 9deb235b3867c632608fc7626fee1c2dff52c9f7 Mon Sep 17 00:00:00 2001 From: Jacoby6000 <2672832+Jacoby6000@users.noreply.github.com> Date: Thu, 15 Oct 2026 04:14:56 +0000 Subject: [PATCH 20/58] [GHA] Format Source --- UnchainedLauncher.CLI/ServerGroupOptions.cs | 2 +- UnchainedLauncher.CLI/ServerGroupRunner.cs | 2 +- UnchainedLauncher.CLI/ServerTabs.cs | 2 +- UnchainedLauncher.CLI/TerminalMouse.cs | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/UnchainedLauncher.CLI/ServerGroupOptions.cs b/UnchainedLauncher.CLI/ServerGroupOptions.cs index 6d3de99d..ba18db1b 100644 --- a/UnchainedLauncher.CLI/ServerGroupOptions.cs +++ b/UnchainedLauncher.CLI/ServerGroupOptions.cs @@ -32,4 +32,4 @@ internal static ServerGroupOptions Create(ServerConfiguration configuration, str } return new ServerGroupOptions(servers, configuration.NoTui); } -} +} \ No newline at end of file diff --git a/UnchainedLauncher.CLI/ServerGroupRunner.cs b/UnchainedLauncher.CLI/ServerGroupRunner.cs index 6fd9d58e..35ced71e 100644 --- a/UnchainedLauncher.CLI/ServerGroupRunner.cs +++ b/UnchainedLauncher.CLI/ServerGroupRunner.cs @@ -80,4 +80,4 @@ await monitor.RunAsync((sample, history) => { throw new InvalidOperationException($"Could not stop server processes: {string.Join(", ", failedStops)}. Stop them manually."); } } -} +} \ No newline at end of file diff --git a/UnchainedLauncher.CLI/ServerTabs.cs b/UnchainedLauncher.CLI/ServerTabs.cs index 773e19e1..53fe6455 100644 --- a/UnchainedLauncher.CLI/ServerTabs.cs +++ b/UnchainedLauncher.CLI/ServerTabs.cs @@ -71,4 +71,4 @@ public bool Feed(char value, out (int Column, int Row)? click) { && column > 0 && row > 0) click = (column, row); return true; } -} +} \ No newline at end of file diff --git a/UnchainedLauncher.CLI/TerminalMouse.cs b/UnchainedLauncher.CLI/TerminalMouse.cs index 550e17d5..0013b5f2 100644 --- a/UnchainedLauncher.CLI/TerminalMouse.cs +++ b/UnchainedLauncher.CLI/TerminalMouse.cs @@ -42,4 +42,4 @@ public void Dispose() { [DllImport("kernel32.dll")] private static extern IntPtr GetStdHandle(int handle); [DllImport("kernel32.dll")] private static extern bool GetConsoleMode(IntPtr handle, out uint mode); [DllImport("kernel32.dll")] private static extern bool SetConsoleMode(IntPtr handle, uint mode); -} +} \ No newline at end of file From 9283596e2e7b99fb380370bca1868670512fc19f Mon Sep 17 00:00:00 2001 From: Jacob Barber Date: Wed, 14 Oct 2026 23:26:47 -0500 Subject: [PATCH 21/58] Resolve explicit DLL pins before dependency traversal --- .../DllManagerTests.cs | 81 +++++++++++++++++++ .../Services/Mods/DllLaunchSession.cs | 12 ++- 2 files changed, 91 insertions(+), 2 deletions(-) diff --git a/UnchainedLauncher.CLI.Tests/DllManagerTests.cs b/UnchainedLauncher.CLI.Tests/DllManagerTests.cs index 6587a774..5eaaed3c 100644 --- a/UnchainedLauncher.CLI.Tests/DllManagerTests.cs +++ b/UnchainedLauncher.CLI.Tests/DllManagerTests.cs @@ -55,6 +55,87 @@ public void WrongTargetsMissingDependenciesAndCyclesFailClosed() { Assert.Throws(() => DllManager.Resolve([Package(core), Package(dependency)], [], false, false)); } + [Theory] + [InlineData(false)] + [InlineData(true)] + public void DependencyPinsAreHonoredInEitherRequestOrder(bool dependencyFirst) { + var core = Release("Chiv2-Community/UnchainedPlugin"); + var older = Release("org/dependency"); + var newer = older with { Tag = "v2.0.0" }; + var plugin = Release("org/plugin", dependencies: [new Dependency("https://github.com/org/dependency", ">=1.0.0")]); + ReleaseCoordinates[] roots = [new("org", "plugin", plugin.Tag), new("ORG", "Dependency", older.Tag)]; + if (dependencyFirst) Array.Reverse(roots); + var resolved = DllManager.Resolve([Package(core), Package(older, newer), Package(plugin)], roots, false, false); + Assert.Equal(new[] { core, older, plugin }, resolved); + } + + [Theory] + [InlineData(false, "v2.0.0", "shared", ">=1.0.0", false, false)] + [InlineData(true, "v2.0.0", "shared", ">=1.0.0", false, false)] + [InlineData(false, "latest", "shared", ">=1.0.0", false, true)] + [InlineData(true, "latest", "shared", ">=1.0.0", false, true)] + [InlineData(false, "v1.0.0", "shared", ">=2.0.0", false, false)] + [InlineData(true, "v1.0.0", "shared", ">=2.0.0", false, false)] + [InlineData(false, "v1.0.0", "server", ">=1.0.0", false, false)] + [InlineData(true, "v1.0.0", "server", ">=1.0.0", false, false)] + [InlineData(false, "v1.0.0", "server", ">=1.0.0", true, true)] + [InlineData(true, "v1.0.0", "server", ">=1.0.0", true, true)] + public void IndexedPinsStillEnforceConflictsRequirementsAndTargets(bool reverse, string otherPin, + string target, string requirement, bool server, bool succeeds) { + var core = Release("Chiv2-Community/UnchainedPlugin"); + var older = Release("org/dependency", target: target); + var newer = older with { Tag = "v2.0.0", Info = older.Info with { Target = "shared" } }; + var plugin = Release("org/plugin", dependencies: [new Dependency("https://github.com/org/dependency", requirement)]); + ReleaseCoordinates[] roots = [new("org", "plugin", plugin.Tag), new("org", "dependency", older.Tag), new("org", "dependency", otherPin)]; + if (reverse) Array.Reverse(roots); + DllPackage[] packages = [Package(core), Package(older, newer), Package(plugin)]; + if (succeeds) Assert.Equal(new[] { core, older, plugin }, DllManager.Resolve(packages, roots, server, false)); + else Assert.Throws(() => DllManager.Resolve(packages, roots, server, false)); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void ExplicitPrereleaseDependencyPinIsHonoredInEitherRequestOrder(bool reverse) { + var core = Release("Chiv2-Community/UnchainedPlugin"); + var dependency = Release("org/dependency", "v1.0.0-rc.1"); + var plugin = Release("org/plugin", dependencies: [new Dependency("https://github.com/org/dependency", "==1.0.0-rc.1")]); + ReleaseCoordinates[] roots = [new("org", "plugin", plugin.Tag), new("org", "dependency", dependency.Tag)]; + if (reverse) Array.Reverse(roots); + Assert.Equal(new[] { core, dependency, plugin }, DllManager.Resolve( + [Package(core), Package(dependency), Package(plugin)], roots, false, false)); + } + + [Theory] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public void AutomaticDependencyPrereleasesStillRequirePermission(bool allowUnstable, bool requestLatest) { + var core = Release("Chiv2-Community/UnchainedPlugin"); + var dependency = Release("org/dependency", "v1.0.0-rc.1"); + var plugin = Release("org/plugin", dependencies: [new Dependency("https://github.com/org/dependency", "==1.0.0-rc.1")]); + List roots = [new("org", "plugin", plugin.Tag)]; + if (requestLatest) roots.Add(new("org", "dependency", "latest")); + DllPackage[] packages = [Package(core), Package(dependency), Package(plugin)]; + if (allowUnstable) Assert.Equal(new[] { core, dependency, plugin }, DllManager.Resolve(packages, roots, false, true)); + else Assert.Throws(() => DllManager.Resolve(packages, roots, false, false)); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void IndexedPinsDoNotBypassDependencyCycleDetection(bool reverse) { + var core = Release("Chiv2-Community/UnchainedPlugin"); + var dependency = Release("org/dependency", dependencies: [new Dependency("https://github.com/org/plugin", ">=1.0.0")]); + var plugin = Release("org/plugin", dependencies: [new Dependency("https://github.com/org/dependency", ">=1.0.0")]); + ReleaseCoordinates[] roots = [new("org", "plugin", plugin.Tag), new("org", "dependency", dependency.Tag)]; + if (reverse) Array.Reverse(roots); + var error = Assert.Throws(() => DllManager.Resolve( + [Package(core), Package(dependency), Package(plugin)], roots, false, false)); + Assert.StartsWith("Cyclic DLL dependency:", error.Message); + } + [Fact] public void ConflictingPinsAndUnapprovedAutomaticPrereleasesAreRejected() { var core = Release("Chiv2-Community/UnchainedPlugin"); diff --git a/UnchainedLauncher.Core/Services/Mods/DllLaunchSession.cs b/UnchainedLauncher.Core/Services/Mods/DllLaunchSession.cs index dfe5fe18..b4513eb2 100644 --- a/UnchainedLauncher.Core/Services/Mods/DllLaunchSession.cs +++ b/UnchainedLauncher.Core/Services/Mods/DllLaunchSession.cs @@ -54,6 +54,13 @@ public static IReadOnlyList Resolve(IReadOnlyList packag if (!roots.Any(root => root.Org.Equals("Chiv2-Community", StringComparison.OrdinalIgnoreCase) && root.ModuleName.Equals("UnchainedPlugin", StringComparison.OrdinalIgnoreCase))) roots.Insert(0, new ReleaseCoordinates("Chiv2-Community", "UnchainedPlugin", "latest")); + var pins = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var root in roots.Where(root => root.Version != "latest")) { + var id = root.Org + "/" + root.ModuleName; + if (pins.TryGetValue(id, out var pin) && pin != root.Version) + throw new InvalidOperationException($"Conflicting DLL requirements: {id}"); + pins[id] = root.Version; + } foreach (var root in roots) Visit(root.Org + "/" + root.ModuleName, root.Version, exact: true); return ordered; @@ -66,9 +73,10 @@ void Visit(string id, string requirement, bool exact) { if (!Accept(previous)) throw new InvalidOperationException($"Conflicting DLL requirements: {id}"); return; } - var candidates = package.Releases.Where(Accept) + var pinned = pins.TryGetValue(id, out var tag); + var candidates = package.Releases.Where(Accept).Where(release => !pinned || release.Tag == tag) .Where(release => release.Info.Target == "shared" || release.Info.Target == (server ? "server" : "client")); - if (!exact || requirement == "latest") candidates = candidates.Where(r => allowUnstable || r.Version?.IsRelease == true); + if (!pinned) candidates = candidates.Where(r => allowUnstable || r.Version?.IsRelease == true); var release = candidates.OrderByDescending(r => r.Version).ThenByDescending(r => r.ReleaseDate).FirstOrDefault() ?? throw new InvalidOperationException($"DLL release not found for launch target: {id}:{requirement}"); selected.Add(id, release); From dacbaab8694df06ab35f16c3591434796bce089d Mon Sep 17 00:00:00 2001 From: Jacob Barber Date: Wed, 14 Oct 2026 23:35:57 -0500 Subject: [PATCH 22/58] Isolate each server's pak and plugin directories --- .../ArgumentBoundaryTests.cs | 19 +++++++++- .../DllManagerTests.cs | 35 +++++++++++++++++ .../ManagedInventoryTests.cs | 38 +++++++++++++++++++ .../ServerGroupTests.cs | 16 ++++++++ .../ServerOptionsTests.cs | 2 +- .../YamlConfigurationTests.cs | 3 +- UnchainedLauncher.CLI/CliLaunchOptions.cs | 7 +++- UnchainedLauncher.CLI/Program.cs | 1 + UnchainedLauncher.CLI/README.md | 23 +++++++++-- UnchainedLauncher.CLI/ServerGroupRunner.cs | 4 ++ UnchainedLauncher.CLI/ServerInstancePaths.cs | 24 ++++++++++++ UnchainedLauncher.CLI/ServerLauncher.cs | 25 ++++++++---- UnchainedLauncher.CLI/examples/server.yaml | 2 + UnchainedLauncher.CLI/examples/servers.yaml | 4 +- .../Services/Mods/ManagedPakInventory.cs | 2 +- .../Services/Mods/ModManagerCodec.cs | 8 ++-- .../Services/Mods/PakDir.cs | 6 ++- 17 files changed, 196 insertions(+), 23 deletions(-) create mode 100644 UnchainedLauncher.CLI/ServerInstancePaths.cs diff --git a/UnchainedLauncher.CLI.Tests/ArgumentBoundaryTests.cs b/UnchainedLauncher.CLI.Tests/ArgumentBoundaryTests.cs index 5440b496..dbc1d1e7 100644 --- a/UnchainedLauncher.CLI.Tests/ArgumentBoundaryTests.cs +++ b/UnchainedLauncher.CLI.Tests/ArgumentBoundaryTests.cs @@ -34,7 +34,8 @@ public async Task ForwardingPreservesExactProcessArguments(string command, bool // Repeat the value so YAML exercises both shared and command-specific forwarding. var options = Parse(command, yaml, [argument, argument, "sentinel-after-forwarded"]); var expected = new List { "TBL" }; - if (command == "server") expected.Add("-server"); + if (command == "server") expected.AddRange(["-server", "--additional-pak-dir", + Path.Combine(_directory, ".unchained", "servers", "Server-UnchainedCLI", "Paks")]); expected.AddRange([argument, argument, "sentinel-after-forwarded", "-saveddirsuffix=UnchainedCLI", "-unchained"]); if (command == "server") expected.AddRange(DefaultServerArguments()); expected.AddRange(["--server-browser-backend", "https://servers.polehammer.net"]); @@ -50,7 +51,9 @@ public async Task ForwardingPreservesExactProcessArguments(string command, bool public async Task GeneratedServerValuesAlsoPreserveBoundaries(bool yaml) { const string value = "quoted \"name\"\\\tline\nend\\"; var options = Assert.IsType(Parse("server", yaml, [], value)); - var expected = new List { "TBL", "-server", "-saveddirsuffix=UnchainedCLI", "-unchained" }; + var expected = new List { "TBL", "-server", "--additional-pak-dir", + Path.Combine(_directory, ".unchained", "servers", "Server-UnchainedCLI", "Paks"), + "-saveddirsuffix=UnchainedCLI", "-unchained" }; expected.AddRange(DefaultServerArguments(value, value, value)); // Password follows the headless/backend flags, before local IP and desync options. expected.Insert(expected.IndexOf("--local-ip"), "ServerPassword=" + value); @@ -59,6 +62,18 @@ public async Task GeneratedServerValuesAlsoPreserveBoundaries(bool yaml) { Assert.Equal(expected, await CaptureLaunch(options.ToLaunchOptions())); } + [Fact] + public async Task PrivatePakDirectoryWithSpacesReachesProcessAsOneArgument() { + var installation = Path.Combine(_directory, "Game With Spaces"); + var options = CliLaunchOptions.Parse(["server", "--chivalry2-path", installation, "--platform", "Steam", "--local-ip", "127.0.0.1"]); + var expected = new List { "TBL", "-server", "--additional-pak-dir", + Path.Combine(installation, ".unchained", "servers", "Server-UnchainedCLI", "Paks"), + "-saveddirsuffix=UnchainedCLI", "-unchained" }; + expected.AddRange(DefaultServerArguments()); + expected.AddRange(["--server-browser-backend", "https://servers.polehammer.net"]); + Assert.Equal(expected, await CaptureLaunch(options.ToLaunchOptions())); + } + private CliLaunchOptions Parse(string command, bool yaml, string[] arguments, string? serverValue = null) { if (!yaml) return CliLaunchOptions.Parse([command, "--chivalry2-path", _directory, "--platform", "Steam", .. command == "server" ? new[] { "--local-ip", "127.0.0.1" } : [], diff --git a/UnchainedLauncher.CLI.Tests/DllManagerTests.cs b/UnchainedLauncher.CLI.Tests/DllManagerTests.cs index 5eaaed3c..d1dd265f 100644 --- a/UnchainedLauncher.CLI.Tests/DllManagerTests.cs +++ b/UnchainedLauncher.CLI.Tests/DllManagerTests.cs @@ -202,6 +202,41 @@ public async Task ExistingFilesRequireRegistryHashAndSizeAndInventoryRecordsAppr finally { Directory.Delete(root, recursive: true); } } + [Fact] + public async Task ServerDllPreparationUsesOnlyEachInstancePluginDirectory() { + var root = Path.Combine(Environment.GetEnvironmentVariable("TMPDIR") ?? Path.GetTempPath(), "dll-instances-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); + try { + var release = Release("Chiv2-Community/UnchainedPlugin") with { DllFileName = "UnchainedPlugin.dll" }; + var packagePath = Path.Combine(root, "registry", "dll_db", "packages", "Chiv2-Community"); + Directory.CreateDirectory(packagePath); + File.WriteAllText(Path.Combine(packagePath, "UnchainedPlugin.json"), JsonHelpers.Serialize(Package(release))); + var registry = new AggregateModRegistry(new LocalModRegistry(Path.Combine(root, "registry"))); + var globalPlugins = Path.Combine(root, FilePaths.PluginDir); + Directory.CreateDirectory(globalPlugins); + var loose = Path.Combine(globalPlugins, "user.dll"); + await File.WriteAllBytesAsync(loose, [9, 9, 9]); + var paths = new List(); + using var http = new HttpClient(new DownloadHandler([1, 2, 3])); + foreach (var suffix in new[] { "One", "Two" }) { + var options = Assert.IsType(CliLaunchOptions.Parse([ + "server", "--chivalry2-path", root, "--platform", "Steam", "--saved-dir-suffix", suffix])); + var mods = ServerLauncher.CreateModManager(options, registry); + var session = ServerLauncher.CreateDllSession(options, registry, mods, client: http); + Assert.True((await session.PrepareLaunch(options.ToLaunchOptions())).IsSome); + var installed = Path.Combine(options.InstancePaths.Plugins, "Managed", "Chiv2-Community", "UnchainedPlugin", "UnchainedPlugin.dll"); + Assert.True(await DllManager.MatchesFileAsync(installed, release)); + Assert.True(File.Exists(Path.Combine(options.InstancePaths.Plugins, "dll_manager.json"))); + paths.Add(installed); + } + Assert.NotEqual(paths[0], paths[1]); + Assert.All(paths, path => Assert.Equal(new byte[] { 1, 2, 3 }, File.ReadAllBytes(path))); + Assert.Equal([loose], Directory.GetFiles(globalPlugins, "*", SearchOption.AllDirectories)); + Assert.Equal(new byte[] { 9, 9, 9 }, await File.ReadAllBytesAsync(loose)); + } + finally { Directory.Delete(root, true); } + } + private sealed class DownloadHandler(byte[] bytes) : HttpMessageHandler { protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) => Task.FromResult(new HttpResponseMessage(System.Net.HttpStatusCode.OK) { RequestMessage = request, Content = new ByteArrayContent(bytes) }); diff --git a/UnchainedLauncher.CLI.Tests/ManagedInventoryTests.cs b/UnchainedLauncher.CLI.Tests/ManagedInventoryTests.cs index a4ed1bb9..13bdf5e4 100644 --- a/UnchainedLauncher.CLI.Tests/ManagedInventoryTests.cs +++ b/UnchainedLauncher.CLI.Tests/ManagedInventoryTests.cs @@ -99,6 +99,44 @@ await InInstallation(async (registry, common, extra, launch) => { private ModManager Manager(IModRegistry registry, ReleaseCoordinates[] selected) => new(registry, new PakDir(Path.GetFullPath(FilePaths.PakDir), []), selected); + [Fact] + public async Task ServerInstancesInstallDifferentPakSetsWithoutChangingBaseOrPeerFiles() { + await InInstallation(async (source, common, extra, _) => { + var registry = new AggregateModRegistry(source); + var first = Assert.IsType(CliLaunchOptions.Parse([ + "server", "--chivalry2-path", _root, "--platform", "Steam", "--saved-dir-suffix", "One"])); + var second = Assert.IsType(CliLaunchOptions.Parse([ + "server", "--chivalry2-path", _root, "--platform", "Steam", "--saved-dir-suffix", "Two", + "--mod", $"{extra.Info.Organization}/{extra.Info.RepoName}:{extra.Tag}"])); + var baseFiles = Directory.GetFiles(Path.GetFullPath(FilePaths.PakDir)) + .ToDictionary(path => path, File.ReadAllBytes); + var firstManager = ServerLauncher.CreateModManager(first, registry); + await Prepare(firstManager, first.ToLaunchOptions()); + var firstFiles = Directory.GetFiles(first.InstancePaths.Paks).ToDictionary(path => path, File.ReadAllBytes); + var secondManager = ServerLauncher.CreateModManager(second, registry); + await Prepare(secondManager, second.ToLaunchOptions()); + + Assert.Single(((PakDir)firstManager.PakDir).ManagedPaks); + Assert.Equal(2, ((PakDir)secondManager.PakDir).ManagedPaks.Count()); + Assert.NotEqual(first.InstancePaths.Paks, second.InstancePaths.Paks); + Assert.Equal(baseFiles.Keys.Order(), Directory.GetFiles(Path.GetFullPath(FilePaths.PakDir)).Order()); + foreach (var (path, bytes) in baseFiles.Concat(firstFiles)) Assert.Equal(bytes, File.ReadAllBytes(path)); + Assert.True(File.Exists(Path.Combine(first.InstancePaths.Paks, FilePaths.ManagedPakInventoryFileName))); + Assert.True(File.Exists(Path.Combine(second.InstancePaths.Paks, FilePaths.ManagedPakInventoryFileName))); + + // Host metadata retains the external signature template when reloaded. + var codec = new ModManagerCodec(registry); + codec.SerializeFile(first.InstancePaths.ModMetadata, firstManager); + var restored = codec.Deserialize(File.ReadAllText(first.InstancePaths.ModMetadata)); + Assert.True(restored.Success); + var restoredPaks = Assert.IsType(restored.Result!.PakDir); + Assert.Equal(Path.Combine(Path.GetFullPath(FilePaths.PakDir), "pakchunk0-WindowsNoEditor.sig"), restoredPaks.DefaultSigPath); + var managed = Assert.Single(restoredPaks.ManagedPaks); + File.Delete(Path.ChangeExtension(Path.Combine(restoredPaks.DirPath, managed.PakFileName), ".sig")); + Assert.True(restoredPaks.SignAll().IsRight); + }); + } + [Fact] public async Task SharedOwnershipPreservesUnmanagedCollisionAndUpdatesLoadOrderPrefixes() { await InInstallation(async (registry, common, extra, launch) => { diff --git a/UnchainedLauncher.CLI.Tests/ServerGroupTests.cs b/UnchainedLauncher.CLI.Tests/ServerGroupTests.cs index 4be14dff..7058f458 100644 --- a/UnchainedLauncher.CLI.Tests/ServerGroupTests.cs +++ b/UnchainedLauncher.CLI.Tests/ServerGroupTests.cs @@ -43,6 +43,8 @@ public void DictionaryPreservesOrderSharedSettingsAndIsolatedProfiles() { Assert.All(group.Servers, entry => Assert.Equal(Path.Combine(_root, "game"), entry.Options.Shared.Chivalry2Path)); Assert.Equal(["Courtyard", "Arena"], group.Servers.Select(entry => entry.Options.Settings.Name)); Assert.Equal(7006, group.Servers[1].Options.ToServerLaunchOptions().QueryPort); + Assert.Equal(2, group.Servers.Select(entry => entry.Options.InstancePaths.Paks).Distinct().Count()); + Assert.Equal(2, group.Servers.Select(entry => entry.Options.InstancePaths.Plugins).Distinct().Count()); } [Fact] @@ -74,6 +76,20 @@ public void DuplicatePortsAndCaseInsensitiveProfilesAreRejected() { .Replace("name: Arena", "name: Arena\n saved-dir-suffix: same"))); } + [Fact] + public void InstanceLeasePreventsConcurrentReuseAndCanBeReacquiredAfterRelease() { + var paths = Group().Servers[0].Options.InstancePaths; + using (paths.AcquireLease()) Assert.Throws(() => paths.AcquireLease()); + using (paths.AcquireLease()) Assert.True(Directory.Exists(paths.Root)); + } + + [Theory] + [InlineData("--additional-pak-dir")] + [InlineData("--additional-pak-dir=other")] + public void ManagedPakDirectoryCannotBeOverriddenByForwardedArguments(string argument) { + Assert.Throws(() => Parse(Servers + $"\ngame-args: ['{argument}']")); + } + [Fact] public void CliOverridesAreIgnoredAndUnsafeAuthorizationAppliesToEveryServer() { var group = Assert.IsType(Parse(Servers, "server", "--name", "ignored", "--platform", "invalid", diff --git a/UnchainedLauncher.CLI.Tests/ServerOptionsTests.cs b/UnchainedLauncher.CLI.Tests/ServerOptionsTests.cs index 89b38158..d8300bd8 100644 --- a/UnchainedLauncher.CLI.Tests/ServerOptionsTests.cs +++ b/UnchainedLauncher.CLI.Tests/ServerOptionsTests.cs @@ -21,7 +21,7 @@ public void DefaultsProduceHeadlessServerLaunch() { Assert.Equal(7777, server.GamePort); Assert.Equal(7071, server.QueryPort); Assert.Equal("FFA_Courtyard", server.Map); - Assert.Equal("TBL -server", options.ToLaunchOptions().LaunchArgs); + Assert.StartsWith("TBL -server --additional-pak-dir ", options.ToLaunchOptions().LaunchArgs); Assert.Equal("UnchainedCLI", options.ToLaunchOptions().SavedDirSuffix.IfNone("")); Assert.Contains(server.ToCLIArgs(), arg => arg.Rendered == "-nullrhi"); Assert.Contains(server.ToCLIArgs(), arg => arg.Rendered == "--desync-patch"); diff --git a/UnchainedLauncher.CLI.Tests/YamlConfigurationTests.cs b/UnchainedLauncher.CLI.Tests/YamlConfigurationTests.cs index 15bbaa06..c43624a8 100644 --- a/UnchainedLauncher.CLI.Tests/YamlConfigurationTests.cs +++ b/UnchainedLauncher.CLI.Tests/YamlConfigurationTests.cs @@ -133,7 +133,8 @@ public void SubcommandSelectsModeRegardlessOfIncludedBlocks(bool client) { var options = Load("client: {}\nserver: {}\n", client); Assert.Equal(client, options is ClientOptions); Assert.Equal(client, options.ToLaunchOptions().ServerLaunchOptions.IsNone); - Assert.Equal(client ? "TBL" : "TBL -server", options.ToLaunchOptions().LaunchArgs); + if (client) Assert.Equal("TBL", options.ToLaunchOptions().LaunchArgs); + else Assert.StartsWith("TBL -server --additional-pak-dir ", options.ToLaunchOptions().LaunchArgs); Assert.Equal(!client, options.ToLaunchOptions().ToCLIArgs().Any(arg => arg.Rendered == "-nullrhi")); } diff --git a/UnchainedLauncher.CLI/CliLaunchOptions.cs b/UnchainedLauncher.CLI/CliLaunchOptions.cs index 221981dc..d5a3b86c 100644 --- a/UnchainedLauncher.CLI/CliLaunchOptions.cs +++ b/UnchainedLauncher.CLI/CliLaunchOptions.cs @@ -110,11 +110,15 @@ internal ClientOptions(SharedLaunchOptions shared, IEnumerable arguments public sealed class ServerOptions : CliLaunchOptions { public ServerSettings Settings { get; } public bool NoTui { get; } + public ServerInstancePaths InstancePaths { get; } internal ServerOptions(SharedLaunchOptions shared, ServerSettings settings, bool noTui) : base(shared, SharedLaunchOptions.Entries(settings.GameArgs, allowEmpty: true)) { Settings = settings; NoTui = noTui; + InstancePaths = new ServerInstancePaths(Path.Combine(shared.Chivalry2Path, ".unchained", "servers", "Server-" + shared.SavedDirSuffix)); + if (GameArguments.Any(argument => argument == "--additional-pak-dir" || argument.StartsWith("--additional-pak-dir=", StringComparison.Ordinal))) + throw new ArgumentException("--additional-pak-dir is managed by the launcher; do not pass it through game-args."); if (!settings.Headless && settings.DesyncPatch) throw new ArgumentException("Desync patch requires a headless server. To enable rendering, pass --no-desync-patch or set server.desync-patch: false in YAML."); if (new[] { settings.GamePort, settings.PingPort, settings.QueryPort, settings.RconPort }.Distinct().Count() != 4) @@ -146,5 +150,6 @@ public LanguageExt.Option ToDiscordOptions() { ServerMods: Settings.ModActors ?? [], DiscordIntegration: ToDiscordOptions(), Settings.DesyncPatch, Settings.UseBackendBanlist, Settings.CensorMode); - public override LaunchOptions ToLaunchOptions() => BuildLaunchOptions(["TBL", "-server"], Some(ToServerLaunchOptions())); + public override LaunchOptions ToLaunchOptions() => BuildLaunchOptions( + ["TBL", "-server", new Parameter("--additional-pak-dir", InstancePaths.Paks).Rendered], Some(ToServerLaunchOptions())); } \ No newline at end of file diff --git a/UnchainedLauncher.CLI/Program.cs b/UnchainedLauncher.CLI/Program.cs index d5a06f36..d857fea1 100644 --- a/UnchainedLauncher.CLI/Program.cs +++ b/UnchainedLauncher.CLI/Program.cs @@ -37,6 +37,7 @@ public static async Task Main(string[] args) { } if (command is ServerGroupOptions group) return await ServerGroupRunner.RunAsync(group, stop); var options = (CliLaunchOptions)command; + using var instanceLease = (options as ServerOptions)?.InstancePaths.AcquireLease(); using var process = await ServerLauncher.LaunchAsync(options, stop.Token); return await SuperviseAsync(options, process, stop); } diff --git a/UnchainedLauncher.CLI/README.md b/UnchainedLauncher.CLI/README.md index 49f599d7..05697697 100644 --- a/UnchainedLauncher.CLI/README.md +++ b/UnchainedLauncher.CLI/README.md @@ -249,9 +249,23 @@ and individual CLI launch arguments still launch one server. - Keys are tab IDs: unique, case-insensitive letters, digits, underscores, and hyphens. - Each entry accepts the same fields as `server`, including its own actors, arguments, password, and Discord settings. Installation, platform, registry, - pak/DLL selections, and common arguments remain shared at the root. + pak/DLL selections, and common arguments remain shared at the root; files are + prepared separately in each instance directory. - Profiles default to `-`. Set `saved-dir-suffix` inside an entry to override it. Profile names must be distinct, ignoring case. +- Server paks, DLLs, inventories, and DLL caches live under + `/.unchained/servers/Server-/`, in `Paks`, + `Plugins`, and `.cache/dlls` subdirectories. This also applies to single-server + launches. Directories persist for reuse; a per-instance lock prevents concurrent + CLI processes from using the same profile. Plugin injection never scans another + instance's directory, including with the unsafe flag. +- The launcher supplies `--additional-pak-dir `. + **This requires an UnchainedPlugin release supporting that option.** Do not pass + the option manually through `game-args`. Signatures use the installation's base + signature as a read-only template; no base pak is copied or modified. +- The plugin option adds a mount directory; it does not suppress game-wide paks + already present in `TBL/Content/Paks`. Existing global mods may still affect all + instances. The launcher does not move or delete them during instance setup. - Assign all four ports to each server. Every port must be distinct across the entire group, even when local IPs differ. Defaults are not automatically offset. - All entries are validated before launch. Startup is sequential because Core @@ -332,9 +346,10 @@ never overwrites GUI configuration or reuses the GUI's enabled-mod selection. Each launch reconciles the installed managed set with that host's selected releases; genuinely unmanaged user paks are preserved. Core's installation pipeline changes the installed managed pak set and signatures. -Do not prepare a launch concurrently with the GUI or another CLI using the same -installation; multiple running servers sharing that installation must use the -same installed mod set and separate saved suffixes/ports. +Client launches and the GUI share the installation's managed pak set; do not prepare +those concurrently. CLI servers instead prepare paks and inject DLLs from private +instance directories. They still need separate saved suffixes/ports; CLI startup +preparation remains sequential because some Core operations change CWD and metadata. ## DLL compatibility behavior diff --git a/UnchainedLauncher.CLI/ServerGroupRunner.cs b/UnchainedLauncher.CLI/ServerGroupRunner.cs index 35ced71e..7bd6e415 100644 --- a/UnchainedLauncher.CLI/ServerGroupRunner.cs +++ b/UnchainedLauncher.CLI/ServerGroupRunner.cs @@ -13,9 +13,12 @@ internal static async Task RunAsync(ServerGroupOptions group, CancellationT query ??= ServerLauncher.CreateQuery; using var monitoring = CancellationTokenSource.CreateLinkedTokenSource(stop.Token); var processes = new List(); + var leases = new List(); var tasks = new List(); var snapshots = new ServerTabSnapshot?[group.Servers.Count]; try { + // Reserve every instance before starting any processes. + foreach (var entry in group.Servers) leases.Add(entry.Options.InstancePaths.AcquireLease()); // Core preparation changes CWD and installation files. Never launch in parallel. foreach (var entry in group.Servers) { stop.Token.ThrowIfCancellationRequested(); @@ -76,6 +79,7 @@ await monitor.RunAsync((sample, history) => { catch (Exception) { failedStops.Add(process.Id); } finally { process.Dispose(); } } + foreach (var lease in leases) lease.Dispose(); if (failedStops.Count != 0) throw new InvalidOperationException($"Could not stop server processes: {string.Join(", ", failedStops)}. Stop them manually."); } diff --git a/UnchainedLauncher.CLI/ServerInstancePaths.cs b/UnchainedLauncher.CLI/ServerInstancePaths.cs new file mode 100644 index 00000000..13b66a85 --- /dev/null +++ b/UnchainedLauncher.CLI/ServerInstancePaths.cs @@ -0,0 +1,24 @@ +using UnchainedLauncher.Core.Services.Mods; + +namespace UnchainedLauncher.CLI; + +public sealed record ServerInstancePaths(string Root) { + public string Paks => Path.Combine(Root, "Paks"); + public string Plugins => Path.Combine(Root, "Plugins"); + public string ModMetadata => Path.Combine(Root, "cli_mod_manager.json"); + + internal IDisposable AcquireLease() { + Directory.CreateDirectory(Root); + try { + return new FileStream(Path.Combine(Root, ".instance.lock"), FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None); + } + catch (IOException ex) { + throw new IOException("Cannot lock the server instance directory; another launcher may be using the same saved profile.", ex); + } + } + + internal PakDir CreatePakDir(string basePakDirectory) { + Directory.CreateDirectory(Paks); + return new PakDir(Paks, [], Path.Combine(basePakDirectory, "pakchunk0-WindowsNoEditor.sig")); + } +} \ No newline at end of file diff --git a/UnchainedLauncher.CLI/ServerLauncher.cs b/UnchainedLauncher.CLI/ServerLauncher.cs index 5ee14db4..53bfa52b 100644 --- a/UnchainedLauncher.CLI/ServerLauncher.cs +++ b/UnchainedLauncher.CLI/ServerLauncher.cs @@ -18,20 +18,19 @@ public static async Task LaunchAsync(CliLaunchOptions request, Cancella var options = request.Shared; if (!OperatingSystem.IsWindows()) throw new PlatformNotSupportedException("Game launching requires Windows (Core's DLL injector uses kernel32.dll)."); var executable = Path.Combine(options.Chivalry2Path, FilePaths.GameBinPath); - var pakPath = Path.Combine(options.Chivalry2Path, FilePaths.PakDir); - var pluginsPath = Path.Combine(options.Chivalry2Path, FilePaths.PluginDir); + var basePakPath = Path.Combine(options.Chivalry2Path, FilePaths.PakDir); + var instance = (request as ServerOptions)?.InstancePaths; if (!File.Exists(executable)) throw new FileNotFoundException("Chivalry 2 shipping executable was not found. Check --chivalry2-path.", executable); - if (!Directory.Exists(pakPath)) throw new DirectoryNotFoundException($"Chivalry 2 Paks directory was not found: {pakPath}"); + if (!Directory.Exists(basePakPath)) throw new DirectoryNotFoundException($"Chivalry 2 Paks directory was not found: {basePakPath}"); var registry = CreateRegistry(request); return await InInstallationDirectoryAsync(request, async () => { - var cachePath = Path.Combine(options.Chivalry2Path, FilePaths.ModCachePath, "cli_mod_manager.json"); + var cachePath = instance?.ModMetadata ?? Path.Combine(options.Chivalry2Path, FilePaths.ModCachePath, "cli_mod_manager.json"); var codec = new ModManagerCodec(registry); // Core reconciles physical ownership across hosts immediately before installing. // CLI selection comes only from this request, never from a cached GUI selection. - var manager = new ModManager(registry, - new PakDir(pakPath, []), options.Mods.Distinct()); + var manager = CreateModManager(request, registry); Console.Error.WriteLine("Loading mod registries..."); var fetched = await manager.UpdateModsList(); cancellationToken.ThrowIfCancellationRequested(); @@ -46,7 +45,7 @@ public static async Task LaunchAsync(CliLaunchOptions request, Cancella if (!File.Exists(appIdPath)) File.WriteAllText(appIdPath, "1824220"); } var dialogue = new ConsoleDialogue(); - var pluginUpdater = new DllLaunchSession(registry, manager, pluginsPath, options.Dlls, options.UnsafeLoadAllDlls, cancellationToken); + var pluginUpdater = CreateDllSession(request, registry, manager, cancellationToken); var modInstaller = new Chivalry2ModsInstaller(manager, dialogue); var signing = SigPreparer.Create(manager.PakDir, dialogue); // Call preparers sequentially so each receives the previous result. @@ -82,6 +81,18 @@ public static async Task LaunchAsync(CliLaunchOptions request, Cancella internal static AggregateModRegistry CreateRegistry(CliLaunchOptions options) => options.Shared.RegistryOptions.CreateRegistry(); + internal static ModManager CreateModManager(CliLaunchOptions request, AggregateModRegistry registry) { + var basePakPath = Path.Combine(request.Shared.Chivalry2Path, FilePaths.PakDir); + return new ModManager(registry, (request as ServerOptions)?.InstancePaths.CreatePakDir(basePakPath) + ?? new PakDir(basePakPath, []), request.Shared.Mods.Distinct()); + } + + internal static DllLaunchSession CreateDllSession(CliLaunchOptions request, AggregateModRegistry registry, + IModManager manager, CancellationToken cancellationToken = default, HttpClient? client = null) => + new(registry, manager, (request as ServerOptions)?.InstancePaths.Plugins + ?? Path.Combine(request.Shared.Chivalry2Path, FilePaths.PluginDir), + request.Shared.Dlls, request.Shared.UnsafeLoadAllDlls, cancellationToken, client); + internal static void InitializeServerProfile(CliLaunchOptions request, Func? configPath = null) { if (request is not ServerOptions) return; diff --git a/UnchainedLauncher.CLI/examples/server.yaml b/UnchainedLauncher.CLI/examples/server.yaml index 0eceaf0b..e3eb3834 100644 --- a/UnchainedLauncher.CLI/examples/server.yaml +++ b/UnchainedLauncher.CLI/examples/server.yaml @@ -10,6 +10,8 @@ mod-setup: dlls: [] # Optional dll_db release coordinates; the required core plugin is always included. # Run with: UnchainedLauncher.CLI.exe server --config-path server.yaml +# Requires UnchainedPlugin support for --additional-pak-dir. +# Paks/Plugins are private to /.unchained/servers/Server-MyServer/. # A client block, if included, is ignored by the server command. server-browser-backend: https://servers.polehammer.net update-plugin: true diff --git a/UnchainedLauncher.CLI/examples/servers.yaml b/UnchainedLauncher.CLI/examples/servers.yaml index 64da82d8..2557b9f6 100644 --- a/UnchainedLauncher.CLI/examples/servers.yaml +++ b/UnchainedLauncher.CLI/examples/servers.yaml @@ -1,5 +1,7 @@ # Run: UnchainedLauncher.CLI.exe server --config-path servers.yaml -# Paths resolve relative to this file. All servers share this installation and mod/DLL set. +# Paths resolve relative to this file. Servers share these selections, not active files. +# Requires UnchainedPlugin support for --additional-pak-dir. +# Each profile has private Paks/Plugins under /.unchained/servers/. chiv2-path: 'C:\Games\Chivalry 2' platform: Steam saved-dir-suffix: MyHost # Default profiles: MyHost-courtyard and MyHost-arena. diff --git a/UnchainedLauncher.Core/Services/Mods/ManagedPakInventory.cs b/UnchainedLauncher.Core/Services/Mods/ManagedPakInventory.cs index 30be2694..ea342ad0 100644 --- a/UnchainedLauncher.Core/Services/Mods/ManagedPakInventory.cs +++ b/UnchainedLauncher.Core/Services/Mods/ManagedPakInventory.cs @@ -43,7 +43,7 @@ private ManagedPakInventory(PakDir pakDir) { public void Save() { var temporaryPath = _path + ".tmp"; - File.WriteAllText(temporaryPath, JsonHelpers.Serialize(new PakDirMetadata(_pakDir.DirPath, _pakDir.ManagedPaks))); + File.WriteAllText(temporaryPath, JsonHelpers.Serialize(new PakDirMetadata(_pakDir.DirPath, _pakDir.ManagedPaks, _pakDir.DefaultSigPath))); File.Move(temporaryPath, _path, overwrite: true); } diff --git a/UnchainedLauncher.Core/Services/Mods/ModManagerCodec.cs b/UnchainedLauncher.Core/Services/Mods/ModManagerCodec.cs index e4744ea6..dee827c7 100644 --- a/UnchainedLauncher.Core/Services/Mods/ModManagerCodec.cs +++ b/UnchainedLauncher.Core/Services/Mods/ModManagerCodec.cs @@ -63,7 +63,7 @@ private static bool IsUsableCache(Mod mod) => private static PakDir CreatePakDir(PakDirMetadata? metadata) => metadata != null - ? new PakDir(metadata.DirPath, metadata.ManagedPaks) + ? new PakDir(metadata.DirPath, metadata.ManagedPaks, metadata.DefaultSigPath) : new PakDir(FilePaths.PakDir, Enumerable.Empty()); public static ModManagerMetadata ToJsonType(IModManager manager) => @@ -72,7 +72,7 @@ public static ModManagerMetadata ToJsonType(IModManager manager) => m.EnabledModReleaseCoordinates, m.Mods, m.PakDir switch { - PakDir pd => new PakDirMetadata(pd.DirPath, pd.ManagedPaks), + PakDir pd => new PakDirMetadata(pd.DirPath, pd.ManagedPaks, pd.DefaultSigPath), _ => null } ), @@ -92,7 +92,9 @@ public record StandardModManagerMetadata( PakDirMetadata? PakDir ) : ModManagerMetadata(ModManagerMetadataKind.StandardModManager); - public record PakDirMetadata(string DirPath, IEnumerable ManagedPaks); + public record PakDirMetadata(string DirPath, IEnumerable ManagedPaks, + [property: System.Text.Json.Serialization.JsonIgnore(Condition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull)] + string? DefaultSigPath = null); internal static class ModManagerMetadataKind { public const string StandardModManager = "StandardModManager"; diff --git a/UnchainedLauncher.Core/Services/Mods/PakDir.cs b/UnchainedLauncher.Core/Services/Mods/PakDir.cs index 06c4e546..3c65cbdb 100644 --- a/UnchainedLauncher.Core/Services/Mods/PakDir.cs +++ b/UnchainedLauncher.Core/Services/Mods/PakDir.cs @@ -12,6 +12,7 @@ namespace UnchainedLauncher.Core.Services.Mods { public class PakDir : IPakDir { public string DirPath { get; } + public string? DefaultSigPath { get; } private const string BasePakFileName = "pakchunk0-WindowsNoEditor.pak"; private const string BaseSigFileName = "pakchunk0-WindowsNoEditor.sig"; @@ -22,8 +23,9 @@ public class PakDir : IPakDir { public IEnumerable ManagedPaks => _managedPaks; private readonly List _managedPaks; - public PakDir(string dirPath, IEnumerable managedPaks) { + public PakDir(string dirPath, IEnumerable managedPaks, string? defaultSigPath = null) { DirPath = dirPath; + DefaultSigPath = defaultSigPath; _managedPaks = managedPaks.ToList(); SynchronizeWithDir(); } @@ -337,7 +339,7 @@ private Either _signFile(string path) { if (File.Exists(signedName)) { return Right(Unit.Default); } - var sigPath = Path.Join(DirPath, BaseSigFileName); + var sigPath = DefaultSigPath ?? Path.Join(DirPath, BaseSigFileName); return (File.Exists(sigPath) ? Either.Right(sigPath) : Error.New($"Default sig file '{sigPath}' not found")) From 237795891422e57afe55aec0f807d917f9401af4 Mon Sep 17 00:00:00 2001 From: Jacob Barber Date: Thu, 15 Oct 2026 10:05:55 -0500 Subject: [PATCH 23/58] Revalidate pak dependencies after DLL preparation --- .../LaunchPreparationTests.cs | 100 ++++++++++++++++++ UnchainedLauncher.CLI/ServerLauncher.cs | 54 ++++++---- 2 files changed, 131 insertions(+), 23 deletions(-) create mode 100644 UnchainedLauncher.CLI.Tests/LaunchPreparationTests.cs diff --git a/UnchainedLauncher.CLI.Tests/LaunchPreparationTests.cs b/UnchainedLauncher.CLI.Tests/LaunchPreparationTests.cs new file mode 100644 index 00000000..5c8274d1 --- /dev/null +++ b/UnchainedLauncher.CLI.Tests/LaunchPreparationTests.cs @@ -0,0 +1,100 @@ +using System.Net; +using System.Security.Cryptography; +using UnchainedLauncher.Core.JsonModels; +using UnchainedLauncher.Core.JsonModels.ModMetadata; +using UnchainedLauncher.Core.Services.Mods; +using UnchainedLauncher.Core.Services.Mods.Registry; +using UnchainedLauncher.Core.Utilities; +using UnchainedLauncher.UnrealModScanner.JsonModels; +using Xunit; + +namespace UnchainedLauncher.CLI.Tests; + +public sealed class LaunchPreparationTests : IDisposable { + private readonly string _directory = Path.Combine(Path.GetTempPath(), "launch-preparation-" + Guid.NewGuid().ToString("N")); + + public LaunchPreparationTests() => Directory.CreateDirectory(_directory); + public void Dispose() => Directory.Delete(_directory, recursive: true); + + [Theory] + [InlineData("client", true, false)] + [InlineData("server", true, false)] + [InlineData("client", true, true)] + [InlineData("server", true, true)] + [InlineData("client", false, false)] + public async Task PreparationValidatesDllSelectedPakDependenciesBeforeInstalling( + string command, bool hasTransitiveDependency, bool dependencyPublished) { + var local = new LocalModRegistry(Path.Combine(_directory, "registry")); + var source = Path.Combine(_directory, "payload.pak"); + File.WriteAllText(source, "pak payload"); + var hash = FileHelpers.Sha512(source).Match(value => value, error => throw new Exception(error.Message)); + Release Pak(string id, string tag, params Dependency[] dependencies) => new(tag, hash, "mod.pak", + new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc), + new ModInfo("https://github.com/" + id, id.Split('/')[1], "test", null, null, [], ["test"], dependencies.ToList()), + new AssetCollections([], [], [], [], []), null); + await local.AddRelease(Pak("Chiv2-Community/Unchained-Mods", "v1.0.0"), source); + await local.AddRelease(Pak("org/required", "v1.0.0", hasTransitiveDependency + ? [new Dependency("https://github.com/org/transitive", "*")] : []), source); + // Core traverses latest-release metadata, which omits the older release's dependency. + await local.AddRelease(Pak("org/required", "v2.0.0"), source); + if (dependencyPublished) await local.AddRelease(Pak("org/transitive", "v1.0.0"), source); + + var dll = new DllRelease("v1.0.0", Convert.ToHexString(SHA256.HashData([1, 2, 3])).ToLowerInvariant(), + "UnchainedPlugin.dll", "https://example.test/UnchainedPlugin.dll", 3, + new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero), + new DllInfo { + RepoUrl = "https://github.com/Chiv2-Community/UnchainedPlugin", + Name = "core", + Description = "test", + Authors = ["test"], + ModDependencies = [new Dependency("https://github.com/org/required", "<2.0.0")] + }, null); + var packages = Path.Combine(local.RegistryPath, "dll_db", "packages", "Chiv2-Community"); + Directory.CreateDirectory(packages); + File.WriteAllText(Path.Combine(packages, "UnchainedPlugin.json"), JsonHelpers.Serialize(new DllPackage(dll.Info, [dll]))); + + var request = CliLaunchOptions.Parse([ + command, "--chivalry2-path", _directory, "--platform", "Steam", + .. command == "server" ? new[] { "--local-ip", "127.0.0.1" } : [] + ]); + var pakPath = Path.Combine(_directory, FilePaths.PakDir); + Directory.CreateDirectory(pakPath); + File.WriteAllText(Path.Combine(pakPath, "pakchunk0-WindowsNoEditor.sig"), "signature"); + var registry = new AggregateModRegistry(local); + var manager = ServerLauncher.CreateModManager(request, registry); + Assert.False((await manager.UpdateModsList()).HasErrors); + ServerLauncher.ValidateReleases(manager, request.Shared.Mods); + Assert.Empty(manager.EnabledModReleaseCoordinates); + + using var client = new HttpClient(new DownloadHandler()); + var plugins = Path.Combine(_directory, "plugins"); + var session = new DllLaunchSession(registry, manager, plugins, client: client, + cacheDirectory: Path.Combine(_directory, "dll-cache")); + var cachePath = Path.Combine(_directory, "cli_mod_manager.json"); + var preparer = ServerLauncher.CreateLaunchPreparer(request, manager, session, new ModManagerCodec(registry), cachePath); + if (hasTransitiveDependency) { + var error = await Assert.ThrowsAsync(() => preparer.PrepareLaunch(request.ToLaunchOptions())); + Assert.Contains(dependencyPublished ? "Core did not resolve dependency org/transitive" : "Missing stable dependency: org/transitive", error.Message); + Assert.Empty(Directory.GetFiles(pakPath, "*.pak")); + Assert.False(File.Exists(cachePath)); + } + else { + Assert.True((await preparer.PrepareLaunch(request.ToLaunchOptions())).IsSome); + Assert.True(File.Exists(cachePath)); + var paks = Directory.GetFiles(pakPath, "*.pak"); + Assert.Equal(2, paks.Length); + Assert.All(paks, pak => Assert.Equal("signature", File.ReadAllText(Path.ChangeExtension(pak, ".sig")))); + } + Assert.True(File.Exists(Path.Combine(plugins, "dll_manager.json"))); + Assert.Contains(manager.EnabledModReleaseCoordinates, coordinate => + coordinate.Org == "org" && coordinate.ModuleName == "required" && coordinate.Version == "v1.0.0"); + } + + private sealed class DownloadHandler : HttpMessageHandler { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) => + Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { + RequestMessage = request, + Content = new ByteArrayContent([1, 2, 3]) + }); + } +} \ No newline at end of file diff --git a/UnchainedLauncher.CLI/ServerLauncher.cs b/UnchainedLauncher.CLI/ServerLauncher.cs index 53bfa52b..6c6bd066 100644 --- a/UnchainedLauncher.CLI/ServerLauncher.cs +++ b/UnchainedLauncher.CLI/ServerLauncher.cs @@ -44,30 +44,8 @@ public static async Task LaunchAsync(CliLaunchOptions request, Cancella var appIdPath = Path.Combine(options.Chivalry2Path, FilePaths.SteamAppIdPath); if (!File.Exists(appIdPath)) File.WriteAllText(appIdPath, "1824220"); } - var dialogue = new ConsoleDialogue(); var pluginUpdater = CreateDllSession(request, registry, manager, cancellationToken); - var modInstaller = new Chivalry2ModsInstaller(manager, dialogue); - var signing = SigPreparer.Create(manager.PakDir, dialogue); - // Call preparers sequentially so each receives the previous result. - // Always save the managed-pak inventory, even after a partial installation. - var preparer = Chivalry2LaunchPreparer.Create(async launch => { - cancellationToken.ThrowIfCancellationRequested(); - var updated = await pluginUpdater.PrepareLaunch(launch); - cancellationToken.ThrowIfCancellationRequested(); - if (updated.IsNone) return LanguageExt.Option.None; - try { - updated = await modInstaller.PrepareLaunch(updated.IfNone(launch)); - } - finally { - codec.SerializeFile(cachePath, manager); - } - cancellationToken.ThrowIfCancellationRequested(); - if (updated.IsNone || (await signing.PrepareLaunch(LanguageExt.Unit.Default)).IsNone) - return LanguageExt.Option.None; - cancellationToken.ThrowIfCancellationRequested(); - InitializeServerProfile(request); - return updated; - }); + var preparer = CreateLaunchPreparer(request, manager, pluginUpdater, codec, cachePath, cancellationToken); var launcher = new UnchainedChivalry2Launcher(preparer, new ProcessLauncher(executable), options.Chivalry2Path, pluginUpdater); var result = await launcher.Launch(request.ToLaunchOptions()); @@ -78,6 +56,36 @@ public static async Task LaunchAsync(CliLaunchOptions request, Cancella }); } + internal static IChivalry2LaunchPreparer CreateLaunchPreparer(CliLaunchOptions request, + ModManager manager, DllLaunchSession pluginUpdater, ModManagerCodec codec, string cachePath, + CancellationToken cancellationToken = default) { + var dialogue = new ConsoleDialogue(); + var modInstaller = new Chivalry2ModsInstaller(manager, dialogue); + var signing = SigPreparer.Create(manager.PakDir, dialogue); + // Call preparers sequentially so each receives the previous result. + // Always save the managed-pak inventory, even after a partial installation. + return Chivalry2LaunchPreparer.Create(async launch => { + cancellationToken.ThrowIfCancellationRequested(); + var updated = await pluginUpdater.PrepareLaunch(launch); + cancellationToken.ThrowIfCancellationRequested(); + if (updated.IsNone) return LanguageExt.Option.None; + // DLL preparation can enable older pak releases with different dependencies. + ValidateReleases(manager, request.Shared.Mods); + try { + updated = await modInstaller.PrepareLaunch(updated.IfNone(launch)); + } + finally { + codec.SerializeFile(cachePath, manager); + } + cancellationToken.ThrowIfCancellationRequested(); + if (updated.IsNone || (await signing.PrepareLaunch(LanguageExt.Unit.Default)).IsNone) + return LanguageExt.Option.None; + cancellationToken.ThrowIfCancellationRequested(); + InitializeServerProfile(request); + return updated; + }); + } + internal static AggregateModRegistry CreateRegistry(CliLaunchOptions options) => options.Shared.RegistryOptions.CreateRegistry(); From 895fa21bafa16b16d468cde839f24b492f1e59be Mon Sep 17 00:00:00 2001 From: Jacob Barber Date: Thu, 15 Oct 2026 21:06:52 -0500 Subject: [PATCH 24/58] Use profile assets and shared hard-linked mod caches Cache verified paks and DLLs with per-object download leases, atomic publication, hard links, and copy fallback. Route CLI assets by saved profile and keep GUI selections shared. Start modded processes suspended for validated injection and initialize server profiles using game-normalized suffixes. --- .../ArgumentBoundaryTests.cs | 22 ++- .../DllManagerTests.cs | 11 +- .../LaunchPreparationTests.cs | 5 +- .../ManagedInventoryTests.cs | 2 + UnchainedLauncher.CLI.Tests/PakCacheTests.cs | 165 ++++++++++++++++++ .../ServerGroupTests.cs | 13 +- .../ServerOptionsTests.cs | 2 +- .../ServerProfileTests.cs | 19 ++ .../SuspendedLaunchTests.cs | 67 +++++++ .../YamlConfigurationTests.cs | 6 +- UnchainedLauncher.CLI/CliLaunchOptions.cs | 23 ++- UnchainedLauncher.CLI/LaunchConfiguration.cs | 1 + UnchainedLauncher.CLI/Program.cs | 2 +- UnchainedLauncher.CLI/README.md | 63 ++++--- UnchainedLauncher.CLI/ServerGroupOptions.cs | 4 +- UnchainedLauncher.CLI/ServerInstancePaths.cs | 14 +- UnchainedLauncher.CLI/ServerLauncher.cs | 22 ++- UnchainedLauncher.CLI/examples/server.yaml | 3 +- UnchainedLauncher.CLI/examples/servers.yaml | 3 +- .../Services/Dlls/DllManagerTests.cs | 121 ++++++++++++- .../Services/Dlls/DllManager.cs | 35 ++-- .../Services/Mods/IModManager.cs | 3 +- .../Services/Mods/IPakDir.cs | 2 +- .../Services/Mods/ManagedPakInventory.cs | 2 +- .../Services/Mods/ModManager.cs | 3 + .../Services/Mods/ModManagerCodec.cs | 8 +- .../Services/Mods/PakCache.cs | 64 +++++++ .../Services/Mods/PakDir.cs | 19 +- .../Services/Mods/ProfileAssetPaths.cs | 27 +++ .../Chivalry/UnchainedChivalry2Launcher.cs | 25 +-- .../Services/Processes/ProcessLauncher.cs | 25 +++ .../Processes/SuspendedProcessLauncher.cs | 87 +++++++++ .../Utilities/AssetCacheFiles.cs | 26 +++ UnchainedLauncher.GUI/App.xaml.cs | 21 ++- UnchainedLauncher.GUI/ViewModels/HelpVM.cs | 7 +- 35 files changed, 813 insertions(+), 109 deletions(-) create mode 100644 UnchainedLauncher.CLI.Tests/PakCacheTests.cs create mode 100644 UnchainedLauncher.CLI.Tests/SuspendedLaunchTests.cs create mode 100644 UnchainedLauncher.Core/Services/Mods/PakCache.cs create mode 100644 UnchainedLauncher.Core/Services/Mods/ProfileAssetPaths.cs create mode 100644 UnchainedLauncher.Core/Services/Processes/SuspendedProcessLauncher.cs create mode 100644 UnchainedLauncher.Core/Utilities/AssetCacheFiles.cs diff --git a/UnchainedLauncher.CLI.Tests/ArgumentBoundaryTests.cs b/UnchainedLauncher.CLI.Tests/ArgumentBoundaryTests.cs index dbc1d1e7..54d5bb59 100644 --- a/UnchainedLauncher.CLI.Tests/ArgumentBoundaryTests.cs +++ b/UnchainedLauncher.CLI.Tests/ArgumentBoundaryTests.cs @@ -34,9 +34,10 @@ public async Task ForwardingPreservesExactProcessArguments(string command, bool // Repeat the value so YAML exercises both shared and command-specific forwarding. var options = Parse(command, yaml, [argument, argument, "sentinel-after-forwarded"]); var expected = new List { "TBL" }; - if (command == "server") expected.AddRange(["-server", "--additional-pak-dir", - Path.Combine(_directory, ".unchained", "servers", "Server-UnchainedCLI", "Paks")]); - expected.AddRange([argument, argument, "sentinel-after-forwarded", "-saveddirsuffix=UnchainedCLI", "-unchained"]); + if (command == "server") expected.Add("-server"); + expected.AddRange(["--additional-pak-dir", options.InstancePaths.Paks]); + if (command == "server") expected.AddRange(FirstRunArguments); + expected.AddRange([argument, argument, "sentinel-after-forwarded", "-saveddirsuffix=" + options.Shared.SavedDirSuffix, "-unchained"]); if (command == "server") expected.AddRange(DefaultServerArguments()); expected.AddRange(["--server-browser-backend", "https://servers.polehammer.net"]); @@ -52,8 +53,9 @@ public async Task GeneratedServerValuesAlsoPreserveBoundaries(bool yaml) { const string value = "quoted \"name\"\\\tline\nend\\"; var options = Assert.IsType(Parse("server", yaml, [], value)); var expected = new List { "TBL", "-server", "--additional-pak-dir", - Path.Combine(_directory, ".unchained", "servers", "Server-UnchainedCLI", "Paks"), - "-saveddirsuffix=UnchainedCLI", "-unchained" }; + options.InstancePaths.Paks }; + expected.AddRange(FirstRunArguments); + expected.AddRange(["-saveddirsuffix=UnchainedCLI", "-unchained"]); expected.AddRange(DefaultServerArguments(value, value, value)); // Password follows the headless/backend flags, before local IP and desync options. expected.Insert(expected.IndexOf("--local-ip"), "ServerPassword=" + value); @@ -67,8 +69,9 @@ public async Task PrivatePakDirectoryWithSpacesReachesProcessAsOneArgument() { var installation = Path.Combine(_directory, "Game With Spaces"); var options = CliLaunchOptions.Parse(["server", "--chivalry2-path", installation, "--platform", "Steam", "--local-ip", "127.0.0.1"]); var expected = new List { "TBL", "-server", "--additional-pak-dir", - Path.Combine(installation, ".unchained", "servers", "Server-UnchainedCLI", "Paks"), - "-saveddirsuffix=UnchainedCLI", "-unchained" }; + options.InstancePaths.Paks }; + expected.AddRange(FirstRunArguments); + expected.AddRange(["-saveddirsuffix=UnchainedCLI", "-unchained"]); expected.AddRange(DefaultServerArguments()); expected.AddRange(["--server-browser-backend", "https://servers.polehammer.net"]); Assert.Equal(expected, await CaptureLaunch(options.ToLaunchOptions())); @@ -90,6 +93,11 @@ private CliLaunchOptions Parse(string command, bool yaml, string[] arguments, st return CliLaunchOptions.Parse([command, "--config-path", path]); } + private static readonly string[] FirstRunArguments = [ + "-ini:Game:[/Script/TBL.TBLTitleScreen]:bSavedHasAgreedToTOS=True", + "-ini:Game:[/Script/TBL.TBLGameInstance]:FirstLoadCompleted=True" + ]; + private static IEnumerable DefaultServerArguments(string name = "Unchained Server", string description = "", string map = "FFA_Courtyard") => [ "-ini:Game:[/Script/TBL.TBLGameMode]:ServerName=" + name, "--next-map-name", map, diff --git a/UnchainedLauncher.CLI.Tests/DllManagerTests.cs b/UnchainedLauncher.CLI.Tests/DllManagerTests.cs index d1dd265f..0273160a 100644 --- a/UnchainedLauncher.CLI.Tests/DllManagerTests.cs +++ b/UnchainedLauncher.CLI.Tests/DllManagerTests.cs @@ -202,8 +202,10 @@ public async Task ExistingFilesRequireRegistryHashAndSizeAndInventoryRecordsAppr finally { Directory.Delete(root, recursive: true); } } - [Fact] - public async Task ServerDllPreparationUsesOnlyEachInstancePluginDirectory() { + [Theory] + [InlineData("client")] + [InlineData("server")] + public async Task DllPreparationUsesOnlyEachProfilePluginDirectory(string command) { var root = Path.Combine(Environment.GetEnvironmentVariable("TMPDIR") ?? Path.GetTempPath(), "dll-instances-" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(root); try { @@ -219,8 +221,9 @@ public async Task ServerDllPreparationUsesOnlyEachInstancePluginDirectory() { var paths = new List(); using var http = new HttpClient(new DownloadHandler([1, 2, 3])); foreach (var suffix in new[] { "One", "Two" }) { - var options = Assert.IsType(CliLaunchOptions.Parse([ - "server", "--chivalry2-path", root, "--platform", "Steam", "--saved-dir-suffix", suffix])); + var options = CliLaunchOptions.Parse([ + command, "--chivalry2-path", root, "--platform", "Steam", "--saved-dir-suffix", suffix]); + options.Shared.Assets = new ProfileAssetPaths(suffix, root); var mods = ServerLauncher.CreateModManager(options, registry); var session = ServerLauncher.CreateDllSession(options, registry, mods, client: http); Assert.True((await session.PrepareLaunch(options.ToLaunchOptions())).IsSome); diff --git a/UnchainedLauncher.CLI.Tests/LaunchPreparationTests.cs b/UnchainedLauncher.CLI.Tests/LaunchPreparationTests.cs index 5c8274d1..ae06e35c 100644 --- a/UnchainedLauncher.CLI.Tests/LaunchPreparationTests.cs +++ b/UnchainedLauncher.CLI.Tests/LaunchPreparationTests.cs @@ -58,6 +58,7 @@ await local.AddRelease(Pak("org/required", "v1.0.0", hasTransitiveDependency .. command == "server" ? new[] { "--local-ip", "127.0.0.1" } : [] ]); var pakPath = Path.Combine(_directory, FilePaths.PakDir); + request.Shared.Assets = new ProfileAssetPaths(request.Shared.SavedDirSuffix, _directory); Directory.CreateDirectory(pakPath); File.WriteAllText(Path.Combine(pakPath, "pakchunk0-WindowsNoEditor.sig"), "signature"); var registry = new AggregateModRegistry(local); @@ -75,13 +76,13 @@ await local.AddRelease(Pak("org/required", "v1.0.0", hasTransitiveDependency if (hasTransitiveDependency) { var error = await Assert.ThrowsAsync(() => preparer.PrepareLaunch(request.ToLaunchOptions())); Assert.Contains(dependencyPublished ? "Core did not resolve dependency org/transitive" : "Missing stable dependency: org/transitive", error.Message); - Assert.Empty(Directory.GetFiles(pakPath, "*.pak")); + Assert.Empty(Directory.GetFiles(request.InstancePaths.Paks, "*.pak")); Assert.False(File.Exists(cachePath)); } else { Assert.True((await preparer.PrepareLaunch(request.ToLaunchOptions())).IsSome); Assert.True(File.Exists(cachePath)); - var paks = Directory.GetFiles(pakPath, "*.pak"); + var paks = Directory.GetFiles(request.InstancePaths.Paks, "*.pak"); Assert.Equal(2, paks.Length); Assert.All(paks, pak => Assert.Equal("signature", File.ReadAllText(Path.ChangeExtension(pak, ".sig")))); } diff --git a/UnchainedLauncher.CLI.Tests/ManagedInventoryTests.cs b/UnchainedLauncher.CLI.Tests/ManagedInventoryTests.cs index 13bdf5e4..e042f5a0 100644 --- a/UnchainedLauncher.CLI.Tests/ManagedInventoryTests.cs +++ b/UnchainedLauncher.CLI.Tests/ManagedInventoryTests.cs @@ -110,6 +110,8 @@ await InInstallation(async (source, common, extra, _) => { "--mod", $"{extra.Info.Organization}/{extra.Info.RepoName}:{extra.Tag}"])); var baseFiles = Directory.GetFiles(Path.GetFullPath(FilePaths.PakDir)) .ToDictionary(path => path, File.ReadAllBytes); + first.Shared.Assets = new ProfileAssetPaths(first.Shared.SavedDirSuffix, _root); + second.Shared.Assets = new ProfileAssetPaths(second.Shared.SavedDirSuffix, _root); var firstManager = ServerLauncher.CreateModManager(first, registry); await Prepare(firstManager, first.ToLaunchOptions()); var firstFiles = Directory.GetFiles(first.InstancePaths.Paks).ToDictionary(path => path, File.ReadAllBytes); diff --git a/UnchainedLauncher.CLI.Tests/PakCacheTests.cs b/UnchainedLauncher.CLI.Tests/PakCacheTests.cs new file mode 100644 index 00000000..e0feee88 --- /dev/null +++ b/UnchainedLauncher.CLI.Tests/PakCacheTests.cs @@ -0,0 +1,165 @@ +using System.Security.Cryptography; +using LanguageExt; +using LanguageExt.Common; +using UnchainedLauncher.Core.Services.Mods; +using UnchainedLauncher.Core.Services.Mods.Registry; +using UnchainedLauncher.Core.Utilities; +using Xunit; +using static LanguageExt.Prelude; + +namespace UnchainedLauncher.CLI.Tests; + +public sealed class PakCacheTests : IDisposable { + private readonly string _root = Path.Combine(Path.GetTempPath(), "pak-cache-" + Guid.NewGuid().ToString("N")); + private static readonly byte[] Bytes = [1, 2, 3, 4]; + private static string Hash => Convert.ToHexString(SHA512.HashData(Bytes)).ToLowerInvariant(); + public PakCacheTests() => Directory.CreateDirectory(_root); + public void Dispose() => Directory.Delete(_root, recursive: true); + + private static ModInstallRequest Request(Func> writer, string? hash = null) => + new(new ReleaseCoordinates("org", "mod", "v1.0.0"), path => writer(path), hash ?? Hash); + + [Fact] + public async Task ConcurrentInstancesDownloadOnceAndUnlinkIndependently() { + var cache = new PakCache(Path.Combine(_root, "cache")); + var downloads = 0; + var request = Request(path => { + Interlocked.Increment(ref downloads); + return RightAsync(new FileWriter(path, new MemoryStream(Bytes), Bytes.Length)); + }); + var first = Path.Combine(_root, "q__first.pak"); + var second = Path.Combine(_root, "r__second.pak"); + var results = await Task.WhenAll(cache.Install(request, first, None).ToEither(), cache.Install(request, second, None).ToEither()); + Assert.All(results, result => Assert.True(result.IsRight, result.ToString())); + Assert.Equal(1, downloads); + Assert.Equal(Bytes, File.ReadAllBytes(first)); + File.Delete(first); + Assert.Equal(Bytes, File.ReadAllBytes(second)); + Assert.Equal(Bytes, File.ReadAllBytes(Path.Combine(cache.Root, Hash, "mod.pak"))); + } + + [Fact] + public async Task WaitingForAnotherDownloaderCanBeCancelled() { + var cache = new PakCache(Path.Combine(_root, "cache")); + var directory = Path.Combine(cache.Root, Hash); + Directory.CreateDirectory(directory); + using var held = new FileStream(Path.Combine(directory, ".lock"), FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None); + using var stop = new CancellationTokenSource(TimeSpan.FromMilliseconds(100)); + var request = Request(_ => throw new InvalidOperationException("Cancelled waiter must not download.")); + var result = await cache.Install(request, Path.Combine(_root, "mod.pak"), None, stop.Token).ToEither() + .WaitAsync(TimeSpan.FromSeconds(5)); + Assert.True(result.IsLeft); + Assert.False(File.Exists(Path.Combine(_root, "mod.pak"))); + } + + [Fact] + public async Task SecondInstanceCanLinkWhileFirstPakIsOpenWithoutDeleteSharing() { + var cache = new PakCache(Path.Combine(_root, "cache")); + var request = Request(path => RightAsync(new FileWriter(path, new MemoryStream(Bytes), Bytes.Length))); + var first = Path.Combine(_root, "first.pak"); + Assert.True((await cache.Install(request, first, None)).IsRight); + using var openPak = new FileStream(first, FileMode.Open, FileAccess.Read, FileShare.Read); + var second = Path.Combine(_root, "second.pak"); + var result = await cache.Install(request, second, None); + Assert.True(result.IsRight, result.ToString()); + Assert.Equal(Bytes, File.ReadAllBytes(second)); + } + + [Fact] + public async Task HashMismatchNeverPublishesCacheOrActiveFile() { + var cache = new PakCache(Path.Combine(_root, "cache")); + var request = Request(path => RightAsync(new FileWriter(path, new MemoryStream([9, 9]), 2))); + var destination = Path.Combine(_root, "mod.pak"); + Assert.True((await cache.Install(request, destination, None)).IsLeft); + Assert.False(File.Exists(destination)); + Assert.False(File.Exists(Path.Combine(cache.Root, Hash, "mod.pak"))); + Assert.Empty(Directory.GetFiles(cache.Root, "*.tmp", SearchOption.AllDirectories)); + } + + [Fact] + public async Task UnknownTransportSizeStillRequiresAndAcceptsTheRegisteredHash() { + var cache = new PakCache(Path.Combine(_root, "cache")); + var request = Request(path => RightAsync(new FileWriter(path, new MemoryStream(Bytes), 0))); + var destination = Path.Combine(_root, "mod.pak"); + Assert.True((await cache.Install(request, destination, None)).IsRight); + Assert.Equal(Bytes, File.ReadAllBytes(destination)); + } + + [Fact] + public async Task TruncatedDownloadNeverPublishes() { + var cache = new PakCache(Path.Combine(_root, "cache")); + var request = Request(path => RightAsync(new FileWriter(path, new MemoryStream(Bytes), Bytes.Length + 1))); + Assert.True((await cache.Install(request, Path.Combine(_root, "mod.pak"), None)).IsLeft); + Assert.False(File.Exists(Path.Combine(cache.Root, Hash, "mod.pak"))); + } + + [Fact] + public async Task OccupiedActivePathIsNeverOverwritten() { + var cache = new PakCache(Path.Combine(_root, "cache")); + var request = Request(path => RightAsync(new FileWriter(path, new MemoryStream(Bytes), Bytes.Length))); + var destination = Path.Combine(_root, "user.pak"); + File.WriteAllBytes(destination, [9]); + Assert.True((await cache.Install(request, destination, None)).IsLeft); + Assert.Equal(new byte[] { 9 }, File.ReadAllBytes(destination)); + Assert.Empty(Directory.GetFiles(_root, "*.tmp", SearchOption.AllDirectories)); + } + + [Fact] + public async Task CorruptCacheFailsClosedWithoutRedownloadingOrPublishing() { + var cache = new PakCache(Path.Combine(_root, "cache")); + var directory = Path.Combine(cache.Root, Hash); + Directory.CreateDirectory(directory); + File.WriteAllBytes(Path.Combine(directory, "mod.pak"), [9]); + var request = Request(_ => throw new InvalidOperationException("Must not download over a cache object.")); + Assert.True((await cache.Install(request, Path.Combine(_root, "mod.pak"), None)).IsLeft); + Assert.Equal(new byte[] { 9 }, File.ReadAllBytes(Path.Combine(directory, "mod.pak"))); + } + + [Theory] + [InlineData("../escape")] + [InlineData("")] + [InlineData("abcd")] + public async Task InvalidHashIsRejectedBeforeDownloading(string hash) { + var cache = new PakCache(Path.Combine(_root, "cache")); + var request = Request(_ => throw new InvalidOperationException("Must not download."), hash); + Assert.True((await cache.Install(request, Path.Combine(_root, "mod.pak"), None)).IsLeft); + } + + [Fact] + public void DefaultClientUsesGuiSharedProfileWhileCliCanSelectAnother() { + var client = CliLaunchOptions.Parse(["client", "--chivalry2-path", _root, "--platform", "Steam"]); + var separate = CliLaunchOptions.Parse(["client", "--chivalry2-path", _root, "--platform", "Steam", "--saved-dir-suffix", "Another"]); + Assert.Equal(ProfileAssetPaths.SharedSuffix, client.Shared.SavedDirSuffix); + Assert.Equal(new ProfileAssetPaths(ProfileAssetPaths.SharedSuffix).Plugins, client.InstancePaths.Plugins); + Assert.NotEqual(client.InstancePaths.Plugins, separate.InstancePaths.Plugins); + } + + [Fact] + public void CacheAndSignaturePathsSurviveMetadataRoundTrip() { + var directory = Path.Combine(_root, "paks"); + Directory.CreateDirectory(directory); + var cache = Path.Combine(_root, "cache"); + var signature = Path.Combine(_root, "base.sig"); + var registry = new AggregateModRegistry(System.Array.Empty()); + var manager = new ModManager(registry, new PakDir(directory, [], signature, cache), []); + var codec = new ModManagerCodec(registry); + var restored = codec.Deserialize(codec.Serialize(manager)); + Assert.True(restored.Success); + var paks = Assert.IsType(restored.Result!.PakDir); + Assert.Equal(cache, paks.CachePath); + Assert.Equal(signature, paks.DefaultSigPath); + } + + [Fact] + public void ProfileAssetsAndSharedCachesUseLocalAppData() { + var first = new ProfileAssetPaths("Host-one", _root); + var second = new ProfileAssetPaths("Host-two", _root); + Assert.Equal(Path.Combine(_root, "Chivalry 2", "Saved_Hostone", "unchained", "paks"), first.Paks); + Assert.Equal(Path.Combine(_root, "Chivalry 2", "Saved_Hostone", "unchained", "plugins"), first.Plugins); + Assert.NotEqual(first.Paks, second.Paks); + Assert.Equal(first.PakCache, second.PakCache); + Assert.Equal(first.DllCache, second.DllCache); + Assert.Equal(Path.Combine(_root, "Chivalry 2", "Saved_unchained", "unchained", "paks"), + new ProfileAssetPaths(ProfileAssetPaths.SharedSuffix, _root).Paks); + } +} diff --git a/UnchainedLauncher.CLI.Tests/ServerGroupTests.cs b/UnchainedLauncher.CLI.Tests/ServerGroupTests.cs index 7058f458..369a3386 100644 --- a/UnchainedLauncher.CLI.Tests/ServerGroupTests.cs +++ b/UnchainedLauncher.CLI.Tests/ServerGroupTests.cs @@ -31,7 +31,12 @@ private CliCommandOptions Parse(string body, string command = "server", params s return CommandLineOptions.Parse([command, "--config-path", path, .. extra]); } - private ServerGroupOptions Group() => Assert.IsType(Parse(Servers)); + private ServerGroupOptions Group() { + var group = Assert.IsType(Parse(Servers)); + foreach (var entry in group.Servers) + entry.Options.Shared.Assets = new UnchainedLauncher.Core.Services.Mods.ProfileAssetPaths(entry.Options.Shared.SavedDirSuffix, _root); + return group; + } public void Dispose() { if (Directory.Exists(_root)) Directory.Delete(_root, true); } [Fact] @@ -76,6 +81,12 @@ public void DuplicatePortsAndCaseInsensitiveProfilesAreRejected() { .Replace("name: Arena", "name: Arena\n saved-dir-suffix: same"))); } + [Fact] + public void ProfilesThatCollideAfterGameHyphenRemovalAreRejected() { + Assert.Throws(() => Parse(Servers.Replace("name: Courtyard", "name: Courtyard\n saved-dir-suffix: Host-one") + .Replace("name: Arena", "name: Arena\n saved-dir-suffix: Hostone"))); + } + [Fact] public void InstanceLeasePreventsConcurrentReuseAndCanBeReacquiredAfterRelease() { var paths = Group().Servers[0].Options.InstancePaths; diff --git a/UnchainedLauncher.CLI.Tests/ServerOptionsTests.cs b/UnchainedLauncher.CLI.Tests/ServerOptionsTests.cs index d8300bd8..306861ef 100644 --- a/UnchainedLauncher.CLI.Tests/ServerOptionsTests.cs +++ b/UnchainedLauncher.CLI.Tests/ServerOptionsTests.cs @@ -179,7 +179,7 @@ public void ClientLaunchDoesNotApplyServerDesyncCompatibilityPolicy() { ])); var launch = options.ToLaunchOptions(); Assert.True(launch.ServerLaunchOptions.IsNone); - Assert.Equal("TBL", launch.LaunchArgs); + Assert.StartsWith("TBL --additional-pak-dir ", launch.LaunchArgs); } [Theory] diff --git a/UnchainedLauncher.CLI.Tests/ServerProfileTests.cs b/UnchainedLauncher.CLI.Tests/ServerProfileTests.cs index d7f2af16..5bc3eedb 100644 --- a/UnchainedLauncher.CLI.Tests/ServerProfileTests.cs +++ b/UnchainedLauncher.CLI.Tests/ServerProfileTests.cs @@ -30,6 +30,25 @@ public void ServerCreatesStartupSettingsInTheSelectedProfile() { Assert.Single(Directory.GetFiles(_directory)); } + [Fact] + public void ServerInitializesTheGameObservedHyphenStrippedProfile() { + var request = CliLaunchOptions.Parse(["server", "--chivalry2-path", _directory, "--platform", "Steam", + "--saved-dir-suffix", "Host-court-yard", "--local-ip", "127.0.0.1"]); + ServerLauncher.InitializeServerProfile(request, suffix => { + Assert.Equal("Hostcourtyard", suffix); + return _directory; + }); + var ini = File.ReadAllText(Path.Combine(_directory, "Game.ini")); + Assert.Contains("bSavedHasAgreedToTOS=True", ini); + Assert.Contains("FirstLoadCompleted=True", ini); + } + + [Fact] + public void HyphenOnlyServerProfileIsRejected() { + Assert.Throws(() => CliLaunchOptions.Parse(["server", "--chivalry2-path", _directory, + "--platform", "Steam", "--saved-dir-suffix", "---", "--local-ip", "127.0.0.1"])); + } + [Fact] public void ServerUpdatesStartupSettingsWithoutChangingOtherSettingsAndIsIdempotent() { Directory.CreateDirectory(_directory); diff --git a/UnchainedLauncher.CLI.Tests/SuspendedLaunchTests.cs b/UnchainedLauncher.CLI.Tests/SuspendedLaunchTests.cs new file mode 100644 index 00000000..b1d25e9c --- /dev/null +++ b/UnchainedLauncher.CLI.Tests/SuspendedLaunchTests.cs @@ -0,0 +1,67 @@ +using System.Diagnostics; +using System.Text.Json; +using UnchainedLauncher.Core.Services.Processes; +using UnchainedLauncher.Core.Utilities; +using Xunit; + +namespace UnchainedLauncher.CLI.Tests; + +public sealed class SuspendedLaunchTests : IDisposable { + private readonly string _directory = Path.Combine(Path.GetTempPath(), "suspended-launch-" + Guid.NewGuid().ToString("N")); + private static string Probe => Path.Combine(AppContext.BaseDirectory, "argument-probe", "ArgumentProbe.exe"); + + public SuspendedLaunchTests() => Directory.CreateDirectory(_directory); + public void Dispose() => Directory.Delete(_directory, recursive: true); + + [Theory] + [InlineData("success")] + [InlineData("false")] + [InlineData("throw")] + public async Task NativeMainThreadRunsOnlyAfterSuccessfulInitialization(string outcome) { + if (!OperatingSystem.IsWindows()) return; + var output = Path.Combine(_directory, "arguments.json"); + Process? observed = null; + try { + IProcessLauncher launcher = new ProcessLauncher(Probe); + var calls = 0; + var result = launcher.Launch(_directory, ArgumentEscaper.Escape(output) + " \"space value\"", process => { + calls++; + observed = Process.GetProcessById(process.Id); + Thread.Sleep(250); + Assert.False(File.Exists(output)); + if (outcome == "throw") throw new InvalidOperationException("test injection failure"); + return outcome == "success"; + }); + Assert.Equal(1, calls); + Assert.NotNull(observed); + if (outcome == "success") { + using var process = result.Match(Left: error => throw error, Right: process => process); + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(20)); + await process.WaitForExitAsync(timeout.Token); + Assert.Equal(0, process.ExitCode); + Assert.Equal(new[] { "space value" }, JsonSerializer.Deserialize(await File.ReadAllTextAsync(output))); + } + else { + Assert.True(result.IsLeft); + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + await observed!.WaitForExitAsync(timeout.Token); + Assert.False(File.Exists(output)); + } + } + finally { + if (observed is not null) { + if (!observed.HasExited) observed.Kill(entireProcessTree: true); + observed.Dispose(); + } + } + } + + [Fact] + public void FailedCreationDoesNotInitialize() { + IProcessLauncher launcher = new ProcessLauncher(Path.Combine(_directory, "missing.exe")); + var called = false; + var result = launcher.Launch(_directory, "", _ => { called = true; return true; }); + Assert.True(result.IsLeft); + Assert.False(called); + } +} \ No newline at end of file diff --git a/UnchainedLauncher.CLI.Tests/YamlConfigurationTests.cs b/UnchainedLauncher.CLI.Tests/YamlConfigurationTests.cs index c43624a8..82797653 100644 --- a/UnchainedLauncher.CLI.Tests/YamlConfigurationTests.cs +++ b/UnchainedLauncher.CLI.Tests/YamlConfigurationTests.cs @@ -133,7 +133,7 @@ public void SubcommandSelectsModeRegardlessOfIncludedBlocks(bool client) { var options = Load("client: {}\nserver: {}\n", client); Assert.Equal(client, options is ClientOptions); Assert.Equal(client, options.ToLaunchOptions().ServerLaunchOptions.IsNone); - if (client) Assert.Equal("TBL", options.ToLaunchOptions().LaunchArgs); + if (client) Assert.StartsWith("TBL --additional-pak-dir ", options.ToLaunchOptions().LaunchArgs); else Assert.StartsWith("TBL -server --additional-pak-dir ", options.ToLaunchOptions().LaunchArgs); Assert.Equal(!client, options.ToLaunchOptions().ToCLIArgs().Any(arg => arg.Rendered == "-nullrhi")); } @@ -275,7 +275,7 @@ public void RenderingRequiresExplicitlyDisablingDesyncPatchInYaml(string yaml) { Assert.Contains("server.desync-patch: false", error.Message); var client = Assert.IsType(Load(yaml, client: true)).ToLaunchOptions(); Assert.True(client.ServerLaunchOptions.IsNone); - Assert.Equal("TBL", client.LaunchArgs); + Assert.StartsWith("TBL --additional-pak-dir ", client.LaunchArgs); } [Fact] @@ -446,7 +446,7 @@ public void DirectClientCommandHasNoServerOptions() { var options = CliLaunchOptions.Parse(["client", "--chivalry2-path", _directory, "--platform", "EGS"]); Assert.IsType(options); Assert.True(options.ToLaunchOptions().ServerLaunchOptions.IsNone); - Assert.Equal("TBL", options.ToLaunchOptions().LaunchArgs); + Assert.StartsWith("TBL --additional-pak-dir ", options.ToLaunchOptions().LaunchArgs); } [Theory] diff --git a/UnchainedLauncher.CLI/CliLaunchOptions.cs b/UnchainedLauncher.CLI/CliLaunchOptions.cs index d5a3b86c..afcde283 100644 --- a/UnchainedLauncher.CLI/CliLaunchOptions.cs +++ b/UnchainedLauncher.CLI/CliLaunchOptions.cs @@ -1,6 +1,7 @@ using System.Net; using System.Text.RegularExpressions; using UnchainedLauncher.Core.JsonModels; +using UnchainedLauncher.Core.Services.Mods; using UnchainedLauncher.Core.Services.Mods.Registry; using UnchainedLauncher.Core.Services.Processes.Chivalry; using UnchainedLauncher.Core.Utilities; @@ -23,10 +24,12 @@ public sealed class SharedLaunchOptions { public IReadOnlyList Mods { get; } public IReadOnlyList Dlls { get; } public IReadOnlyList GameArguments { get; } + internal ProfileAssetPaths Assets { get; set; } internal SharedLaunchOptions(LaunchConfiguration configuration, string directory, string? savedDirSuffix = null) { Chivalry2Path = Path.GetFullPath(configuration.Chiv2Path!, directory); SavedDirSuffix = savedDirSuffix ?? configuration.SavedDirSuffix; + Assets = new ProfileAssetPaths(SavedDirSuffix); Platform = configuration.Platform!.Value; Backend = configuration.ServerBrowserBackend; UpdatePlugin = configuration.UpdatePlugin; @@ -59,10 +62,13 @@ public abstract class CliLaunchOptions : CliCommandOptions { private readonly SharedLaunchOptions? _shared; public SharedLaunchOptions Shared => _shared ?? throw new InvalidOperationException("Help is not a launch request."); public IReadOnlyList GameArguments { get; } + public ServerInstancePaths InstancePaths => new(Shared.Assets.Root, Shared.Assets.PakCache); private protected CliLaunchOptions(SharedLaunchOptions shared, IEnumerable gameArguments) { _shared = shared; GameArguments = shared.GameArguments.Concat(gameArguments).ToArray(); + if (GameArguments.Any(argument => argument == "--additional-pak-dir" || argument.StartsWith("--additional-pak-dir=", StringComparison.Ordinal))) + throw new ArgumentException("--additional-pak-dir is managed by the launcher; do not pass it through game-args."); } private protected CliLaunchOptions(string helpText) { @@ -87,11 +93,15 @@ internal static CliLaunchOptions FromConfiguration(LaunchConfiguration configura public abstract LaunchOptions ToLaunchOptions(); - private protected LaunchOptions BuildLaunchOptions(IEnumerable prefix, LanguageExt.Option server) => new( + private protected LaunchOptions BuildLaunchOptions(IEnumerable prefix, LanguageExt.Option server) { + var start = prefix.ToList(); + start.Insert(server.IsSome ? 2 : 1, new Parameter("--additional-pak-dir", InstancePaths.Paks).Rendered); + return new( EnabledReleases: Shared.Mods.Distinct(), ServerBrowserBackend: Some(Shared.Backend), - LaunchArgs: string.Join(" ", prefix.Concat(GameArguments.Select(ArgumentEscaper.Escape))), + LaunchArgs: string.Join(" ", start.Concat(GameArguments.Select(ArgumentEscaper.Escape))), CheckForDependencyUpdates: Shared.UpdatePlugin, AllowUnstablePluginReleases: Shared.AllowUnstablePlugin, SavedDirSuffix: Some(Shared.SavedDirSuffix), ServerLaunchOptions: server); + } internal static CliLaunchOptions ForHelp(string text) => new HelpOptions(text); @@ -110,13 +120,14 @@ internal ClientOptions(SharedLaunchOptions shared, IEnumerable arguments public sealed class ServerOptions : CliLaunchOptions { public ServerSettings Settings { get; } public bool NoTui { get; } - public ServerInstancePaths InstancePaths { get; } + // Chivalry removes hyphens when choosing its Saved directory (the plugin log path does not). + internal string GameSavedDirSuffix => Shared.SavedDirSuffix.Replace("-", "", StringComparison.Ordinal); internal ServerOptions(SharedLaunchOptions shared, ServerSettings settings, bool noTui) : base(shared, SharedLaunchOptions.Entries(settings.GameArgs, allowEmpty: true)) { Settings = settings; NoTui = noTui; - InstancePaths = new ServerInstancePaths(Path.Combine(shared.Chivalry2Path, ".unchained", "servers", "Server-" + shared.SavedDirSuffix)); + if (GameSavedDirSuffix.Length == 0) throw new ArgumentException("saved-dir-suffix must contain a character other than a hyphen."); if (GameArguments.Any(argument => argument == "--additional-pak-dir" || argument.StartsWith("--additional-pak-dir=", StringComparison.Ordinal))) throw new ArgumentException("--additional-pak-dir is managed by the launcher; do not pass it through game-args."); if (!settings.Headless && settings.DesyncPatch) @@ -151,5 +162,7 @@ public LanguageExt.Option ToDiscordOptions() { Settings.DesyncPatch, Settings.UseBackendBanlist, Settings.CensorMode); public override LaunchOptions ToLaunchOptions() => BuildLaunchOptions( - ["TBL", "-server", new Parameter("--additional-pak-dir", InstancePaths.Paks).Rendered], Some(ToServerLaunchOptions())); + ["TBL", "-server", + "-ini:Game:[/Script/TBL.TBLTitleScreen]:bSavedHasAgreedToTOS=True", + "-ini:Game:[/Script/TBL.TBLGameInstance]:FirstLoadCompleted=True"], Some(ToServerLaunchOptions())); } \ No newline at end of file diff --git a/UnchainedLauncher.CLI/LaunchConfiguration.cs b/UnchainedLauncher.CLI/LaunchConfiguration.cs index 41e8ef1a..4e69a09e 100644 --- a/UnchainedLauncher.CLI/LaunchConfiguration.cs +++ b/UnchainedLauncher.CLI/LaunchConfiguration.cs @@ -50,6 +50,7 @@ public override void Validate() { } public sealed class ClientConfiguration : LaunchConfiguration { + public ClientConfiguration() { SavedDirSuffix = UnchainedLauncher.Core.Services.Mods.ProfileAssetPaths.SharedSuffix; } public ClientSettings? Client { get; set; } [YamlConverter(typeof(IgnoredYamlNodeConverter))] public object? Server { get; set; } diff --git a/UnchainedLauncher.CLI/Program.cs b/UnchainedLauncher.CLI/Program.cs index d857fea1..c33b842d 100644 --- a/UnchainedLauncher.CLI/Program.cs +++ b/UnchainedLauncher.CLI/Program.cs @@ -37,7 +37,7 @@ public static async Task Main(string[] args) { } if (command is ServerGroupOptions group) return await ServerGroupRunner.RunAsync(group, stop); var options = (CliLaunchOptions)command; - using var instanceLease = (options as ServerOptions)?.InstancePaths.AcquireLease(); + using var instanceLease = options.InstancePaths.AcquireLease(); using var process = await ServerLauncher.LaunchAsync(options, stop.Token); return await SuperviseAsync(options, process, stop); } diff --git a/UnchainedLauncher.CLI/README.md b/UnchainedLauncher.CLI/README.md index 05697697..2c0fb567 100644 --- a/UnchainedLauncher.CLI/README.md +++ b/UnchainedLauncher.CLI/README.md @@ -1,6 +1,11 @@ # Launcher CLI This project launches an Unchained Chivalry 2 **client or server**, without the WPF GUI. +On Windows, the shared game launcher creates the game suspended, injects DLLs, +then resumes its main thread. Failed injection terminates the suspended game. +CLI servers also pass TOS-acceptance and first-load-completed INI overrides to +avoid interactive first-run screens. The CLI also initializes the game's saved profile. +Profile-local paks require an UnchainedPlugin build supporting `--additional-pak-dir`. It uses Core for mod installation, plugin updates, signature preparation, process launching, DLL injection, and A2S queries. @@ -199,6 +204,8 @@ Before a server launch, the CLI initializes the selected profile's `Game.ini` wi `bSavedHasAgreedToTOS=True` and `FirstLoadCompleted=True`, matching the GUI's startup defaults so first-run screens do not block unattended startup. Other existing INI settings are preserved. Client launches do not initialize or rewrite INI files. +Chivalry removes hyphens from the suffix in its `Saved_` directory; +the CLI initializes that directory. Plugin log paths retain the original suffix. Discord configuration is nested under the server: @@ -252,11 +259,11 @@ and individual CLI launch arguments still launch one server. pak/DLL selections, and common arguments remain shared at the root; files are prepared separately in each instance directory. - Profiles default to `-`. Set `saved-dir-suffix` inside an - entry to override it. Profile names must be distinct, ignoring case. -- Server paks, DLLs, inventories, and DLL caches live under - `/.unchained/servers/Server-/`, in `Paks`, - `Plugins`, and `.cache/dlls` subdirectories. This also applies to single-server - launches. Directories persist for reuse; a per-instance lock prevents concurrent + entry to override it. Profile names must be distinct, ignoring case and hyphens. +- Active paks and DLLs live under + `%LOCALAPPDATA%/Chivalry 2/Saved_/unchained/{paks,plugins}`. + Hyphens are removed from the saved suffix, matching the game. This also applies to + clients; the default client suffix is `unchained`. A per-profile lock prevents concurrent CLI processes from using the same profile. Plugin injection never scans another instance's directory, including with the unsafe flag. - The launcher supplies `--additional-pak-dir `. @@ -266,6 +273,12 @@ and individual CLI launch arguments still launch one server. - The plugin option adds a mount directory; it does not suppress game-wide paks already present in `TBL/Content/Paks`. Existing global mods may still affect all instances. The launcher does not move or delete them during instance setup. +- Shared caches live under `%LOCALAPPDATA%/Chivalry 2/unchained/cache/{paks,dlls}`. + Pak objects are keyed by SHA-512 and downloaded once under a per-object lock. + Profiles use hard links when supported, or copies from cache across volumes or + unsupported filesystems. Cache objects and linked paks must not be edited in place. + Signatures stay profile-local. Windows may prevent removal or reordering of a + linked pak while another process holds that file open; stop affected servers first. - Assign all four ports to each server. Every port must be distinct across the entire group, even when local IPs differ. Defaults are not automatically offset. - All entries are validated before launch. Startup is sequential because Core @@ -337,18 +350,18 @@ paths from release metadata: --server-mod /Game/Mods/Example/BP_Example.BP_Example_C ``` -Core shares physical managed-pak ownership between GUI and CLI through -`\TBL\Content\Paks\.unchained_managed_paks.json`, updated after mod -preparation (including partial failures). On first use it migrates ownership from -both existing host caches; later host snapshots cannot replace this inventory. -The CLI still saves its own metadata in `.mod_cache\cli_mod_manager.json`, but +Core records physical managed-pak ownership in each active pak directory's +`.unchained_managed_paks.json`, updated after preparation, including partial failures. +That inventory is shared by hosts using the same profile; it does not authorize +changes to another profile or to legacy installation-wide paks. +The CLI saves its metadata in the profile's `unchained/cli_mod_manager.json`, but never overwrites GUI configuration or reuses the GUI's enabled-mod selection. Each launch reconciles the installed managed set with that host's selected releases; genuinely unmanaged user paks are preserved. Core's installation pipeline changes the installed managed pak set and signatures. -Client launches and the GUI share the installation's managed pak set; do not prepare -those concurrently. CLI servers instead prepare paks and inject DLLs from private -instance directories. They still need separate saved suffixes/ports; CLI startup +The GUI uses one shared `Saved_unchained/unchained` pak/plugin set for all clients and +servers. Only the CLI differentiates selections by launch profile. CLI profiles +need separate saved suffixes/ports; CLI startup preparation remains sequential because some Core operations change CWD and metadata. ## DLL compatibility behavior @@ -363,21 +376,29 @@ fail preparation. Pak dependencies enter the shared mod manager. Every selected DLL must match the registry's SHA-256 and size. Downloads are verified before installation. Active DLLs use -`Plugins/Managed///`; inactive registered -versions use `Win64/.cache/dlls//`. Switching versions restores -validated cached files without downloading again. Registered files in historical -`Managed` hash directories or `.dll-managed` directories migrate after validation. +`plugins/Managed///` under the selected +profile's `unchained` directory; inactive registered versions use the shared +`unchained/cache/dlls//`. Switching versions restores +validated cached files without downloading again. +Active DLLs use hard links to validated cached bytes where supported, with an atomic +copy fallback across volumes or on unsupported filesystems. Concurrent preparations +serialize downloads per cache object. Never edit cached or linked DLLs in place. +If Windows blocks removal of a DLL linked to a running peer, stop that peer before +changing versions; preparation fails rather than modifying shared bytes. +Registered files in historical `Managed` hash directories or `.dll-managed` +directories migrate after validation. Loose DLLs, unknown filenames, and unregistered versions stay untouched and are not loaded in safe mode. CLI selections never overwrite the GUI plugin profile. -`Plugins/dll_manager.json` records the last successful selection. +`plugins/dll_manager.json` records the last successful CLI selection. Safe injection uses only that launch's verified allowlist, not the directory or the saved inventory. The unsafe load-all option retains other DLLs for injection. `--no-plugin-update` forbids downloads but permits restoring cached versions. Only explicitly pinned prereleases (or `--allow-unstable-plugin` for automatic selection) are eligible; automatic selection otherwise uses the latest stable. -Other DLLs in `\TBL\Binaries\Win64\Plugins` are not enumerated -for injection unless this exact command-line flag is explicitly supplied: +Other DLLs in the selected profile's `unchained/plugins` directory are not enumerated +for injection unless this exact command-line flag is explicitly supplied. Legacy +installation-wide plugin directories are left untouched and are never scanned: ```text --unsafe-very-dangerous-load-all-dlls @@ -404,7 +425,7 @@ Run `server --help` for the full option list. Additional settings include: | Option | Default / behavior | | --- | --- | -| `--saved-dir-suffix` | `UnchainedCLI`; letters, digits, `_`, and `-` only | +| `--saved-dir-suffix` | client: `unchained`; server: `UnchainedCLI`; letters, digits, `_`, and `-` only | | `--name`, `--description`, `--password` | Name, description/MOTD, optional password | | `--map` | `FFA_Courtyard` | | `--game-port` | `7777` | diff --git a/UnchainedLauncher.CLI/ServerGroupOptions.cs b/UnchainedLauncher.CLI/ServerGroupOptions.cs index ba18db1b..62cc3bcc 100644 --- a/UnchainedLauncher.CLI/ServerGroupOptions.cs +++ b/UnchainedLauncher.CLI/ServerGroupOptions.cs @@ -23,8 +23,8 @@ internal static ServerGroupOptions Create(ServerConfiguration configuration, str throw new ArgumentException("Server IDs must be unique (case-insensitive) and contain only letters, digits, underscores, and hyphens."); var suffix = settings!.SavedDirSuffix ?? $"{configuration.SavedDirSuffix}-{id}"; var options = new ServerOptions(new SharedLaunchOptions(configuration, directory, suffix), settings, configuration.NoTui); - if (!profiles.Add(options.Shared.SavedDirSuffix)) - throw new ArgumentException("Each server must use a distinct saved-dir-suffix (case-insensitive)."); + if (!profiles.Add(options.GameSavedDirSuffix)) + throw new ArgumentException("Each server must use a distinct saved-dir-suffix (case-insensitive, ignoring hyphens)."); foreach (var port in new[] { settings.GamePort, settings.PingPort, settings.QueryPort, settings.RconPort }) { if (!ports.Add(port)) throw new ArgumentException($"Port {port} is used by more than one server. Assign distinct game, ping, query, and RCON ports to every server."); } diff --git a/UnchainedLauncher.CLI/ServerInstancePaths.cs b/UnchainedLauncher.CLI/ServerInstancePaths.cs index 13b66a85..a47c57f2 100644 --- a/UnchainedLauncher.CLI/ServerInstancePaths.cs +++ b/UnchainedLauncher.CLI/ServerInstancePaths.cs @@ -2,9 +2,9 @@ namespace UnchainedLauncher.CLI; -public sealed record ServerInstancePaths(string Root) { - public string Paks => Path.Combine(Root, "Paks"); - public string Plugins => Path.Combine(Root, "Plugins"); +public sealed record ServerInstancePaths(string Root, string? PakCache = null) { + public string Paks => Path.Combine(Root, "paks"); + public string Plugins => Path.Combine(Root, "plugins"); public string ModMetadata => Path.Combine(Root, "cli_mod_manager.json"); internal IDisposable AcquireLease() { @@ -13,12 +13,12 @@ internal IDisposable AcquireLease() { return new FileStream(Path.Combine(Root, ".instance.lock"), FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None); } catch (IOException ex) { - throw new IOException("Cannot lock the server instance directory; another launcher may be using the same saved profile.", ex); + throw new IOException("Cannot lock the profile asset directory; another launcher may be using the same saved profile.", ex); } } - internal PakDir CreatePakDir(string basePakDirectory) { + internal PakDir CreatePakDir(string basePakDirectory, CancellationToken cancellationToken = default) { Directory.CreateDirectory(Paks); - return new PakDir(Paks, [], Path.Combine(basePakDirectory, "pakchunk0-WindowsNoEditor.sig")); + return new PakDir(Paks, [], Path.Combine(basePakDirectory, "pakchunk0-WindowsNoEditor.sig"), PakCache, cancellationToken); } -} \ No newline at end of file +} diff --git a/UnchainedLauncher.CLI/ServerLauncher.cs b/UnchainedLauncher.CLI/ServerLauncher.cs index 6c6bd066..791c0976 100644 --- a/UnchainedLauncher.CLI/ServerLauncher.cs +++ b/UnchainedLauncher.CLI/ServerLauncher.cs @@ -19,18 +19,18 @@ public static async Task LaunchAsync(CliLaunchOptions request, Cancella if (!OperatingSystem.IsWindows()) throw new PlatformNotSupportedException("Game launching requires Windows (Core's DLL injector uses kernel32.dll)."); var executable = Path.Combine(options.Chivalry2Path, FilePaths.GameBinPath); var basePakPath = Path.Combine(options.Chivalry2Path, FilePaths.PakDir); - var instance = (request as ServerOptions)?.InstancePaths; + var instance = request.InstancePaths; if (!File.Exists(executable)) throw new FileNotFoundException("Chivalry 2 shipping executable was not found. Check --chivalry2-path.", executable); if (!Directory.Exists(basePakPath)) throw new DirectoryNotFoundException($"Chivalry 2 Paks directory was not found: {basePakPath}"); var registry = CreateRegistry(request); return await InInstallationDirectoryAsync(request, async () => { - var cachePath = instance?.ModMetadata ?? Path.Combine(options.Chivalry2Path, FilePaths.ModCachePath, "cli_mod_manager.json"); + var cachePath = instance.ModMetadata; var codec = new ModManagerCodec(registry); // Core reconciles physical ownership across hosts immediately before installing. // CLI selection comes only from this request, never from a cached GUI selection. - var manager = CreateModManager(request, registry); + var manager = CreateModManager(request, registry, cancellationToken); Console.Error.WriteLine("Loading mod registries..."); var fetched = await manager.UpdateModsList(); cancellationToken.ThrowIfCancellationRequested(); @@ -89,22 +89,20 @@ internal static IChivalry2LaunchPreparer CreateLaunchPreparer(Cli internal static AggregateModRegistry CreateRegistry(CliLaunchOptions options) => options.Shared.RegistryOptions.CreateRegistry(); - internal static ModManager CreateModManager(CliLaunchOptions request, AggregateModRegistry registry) { + internal static ModManager CreateModManager(CliLaunchOptions request, AggregateModRegistry registry, CancellationToken cancellationToken = default) { var basePakPath = Path.Combine(request.Shared.Chivalry2Path, FilePaths.PakDir); - return new ModManager(registry, (request as ServerOptions)?.InstancePaths.CreatePakDir(basePakPath) - ?? new PakDir(basePakPath, []), request.Shared.Mods.Distinct()); + return new ModManager(registry, request.InstancePaths.CreatePakDir(basePakPath, cancellationToken), request.Shared.Mods.Distinct()); } internal static DllLaunchSession CreateDllSession(CliLaunchOptions request, AggregateModRegistry registry, IModManager manager, CancellationToken cancellationToken = default, HttpClient? client = null) => - new(registry, manager, (request as ServerOptions)?.InstancePaths.Plugins - ?? Path.Combine(request.Shared.Chivalry2Path, FilePaths.PluginDir), - request.Shared.Dlls, request.Shared.UnsafeLoadAllDlls, cancellationToken, client); + new(registry, manager, request.InstancePaths.Plugins, + request.Shared.Dlls, request.Shared.UnsafeLoadAllDlls, cancellationToken, client, request.Shared.Assets.DllCache); internal static void InitializeServerProfile(CliLaunchOptions request, Func? configPath = null) { - if (request is not ServerOptions) return; + if (request is not ServerOptions server) return; - var directory = (configPath ?? FilePaths.Chiv2ConfigPath)(request.Shared.SavedDirSuffix); + var directory = (configPath ?? FilePaths.Chiv2ConfigPath)(server.GameSavedDirSuffix); // Match the GUI's startup defaults without rewriting unrelated server or player settings. var writer = new StructuredINIWriter(); if (!writer.BufferWrite(new TBLTitleScreen()) || !writer.BufferWrite(new TBLGameInstance()) || @@ -151,4 +149,4 @@ public static IA2S CreateQuery(ServerOptions options) { if (address.Equals(IPAddress.IPv6Any)) address = IPAddress.IPv6Loopback; return new A2S(new IPEndPoint(address, options.Settings.QueryPort)); } -} \ No newline at end of file +} diff --git a/UnchainedLauncher.CLI/examples/server.yaml b/UnchainedLauncher.CLI/examples/server.yaml index e3eb3834..40777a21 100644 --- a/UnchainedLauncher.CLI/examples/server.yaml +++ b/UnchainedLauncher.CLI/examples/server.yaml @@ -11,7 +11,8 @@ mod-setup: # Run with: UnchainedLauncher.CLI.exe server --config-path server.yaml # Requires UnchainedPlugin support for --additional-pak-dir. -# Paks/Plugins are private to /.unchained/servers/Server-MyServer/. +# Paks/plugins live in %LOCALAPPDATA%/Chivalry 2/Saved_MyServer/unchained/. +# Pak bytes are shared through the common verified cache and hard links. # A client block, if included, is ignored by the server command. server-browser-backend: https://servers.polehammer.net update-plugin: true diff --git a/UnchainedLauncher.CLI/examples/servers.yaml b/UnchainedLauncher.CLI/examples/servers.yaml index 2557b9f6..50de340b 100644 --- a/UnchainedLauncher.CLI/examples/servers.yaml +++ b/UnchainedLauncher.CLI/examples/servers.yaml @@ -1,7 +1,8 @@ # Run: UnchainedLauncher.CLI.exe server --config-path servers.yaml # Paths resolve relative to this file. Servers share these selections, not active files. # Requires UnchainedPlugin support for --additional-pak-dir. -# Each profile has private Paks/Plugins under /.unchained/servers/. +# Active paks/plugins: %LOCALAPPDATA%/Chivalry 2/Saved_/unchained/. +# Pak bytes are shared through the common content-addressed cache and hard links. chiv2-path: 'C:\Games\Chivalry 2' platform: Steam saved-dir-suffix: MyHost # Default profiles: MyHost-courtyard and MyHost-arena. diff --git a/UnchainedLauncher.Core.Tests.Unit/Services/Dlls/DllManagerTests.cs b/UnchainedLauncher.Core.Tests.Unit/Services/Dlls/DllManagerTests.cs index 07d36ecb..d13a6359 100644 --- a/UnchainedLauncher.Core.Tests.Unit/Services/Dlls/DllManagerTests.cs +++ b/UnchainedLauncher.Core.Tests.Unit/Services/Dlls/DllManagerTests.cs @@ -21,6 +21,123 @@ public sealed class DllManagerTests : IDisposable { private DllManager Manager() => new(PluginPath, StatePath, _http); + [Fact] + public async Task ProfilesShareCachedDllIdentityAndDownloadOnce() { + Index(Release()); + var cacheRoot = Path.Combine(_dir, "shared-cache"); + var first = new DllManager(Path.Combine(_dir, "first", "Plugins"), null, _http, cacheDirectory: cacheRoot); + var second = new DllManager(Path.Combine(_dir, "second", "Plugins"), null, _http, cacheDirectory: cacheRoot); + await first.RefreshAsync(); + await second.RefreshAsync(); + first.SetEnabled(Release(), true); + second.SetEnabled(Release(), true); + var downloads = 0; + _handler.OnBinaryRequest = () => Interlocked.Increment(ref downloads); + await Task.WhenAll(Task.Run(() => first.PrepareAsync()), Task.Run(() => second.PrepareAsync())); + Assert.Equal(1, downloads); + var cache = Assert.Single(Directory.GetFiles(cacheRoot, "*.dll", SearchOption.AllDirectories)); + var firstPath = Assert.Single(Directory.GetFiles(Path.Combine(_dir, "first"), "*.dll", SearchOption.AllDirectories)); + var secondPath = Assert.Single(Directory.GetFiles(Path.Combine(_dir, "second"), "*.dll", SearchOption.AllDirectories)); + AssertSameFile(cache, firstPath); + AssertSameFile(cache, secondPath); + File.Delete(firstPath); + Assert.Equal(_binary, File.ReadAllBytes(secondPath)); + Assert.Equal(_binary, File.ReadAllBytes(cache)); + } + + [Fact] + public async Task ProfileCanLinkWhilePeerHoldsDllOpenWithoutDeleteSharing() { + Index(Release()); + var first = Manager(); + await first.RefreshAsync(); + await first.PrepareAsync(); + var path = Assert.Single(Directory.GetFiles(PluginPath, "*.dll", SearchOption.AllDirectories)); + using var held = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read); + var secondRoot = Path.Combine(_dir, "second", "Plugins"); + var second = new DllManager(secondRoot, null, _http, cacheDirectory: Path.Combine(_dir, ".cache", "dlls")); + await second.RefreshAsync(); + second.SetEnabled(Release(), true); + await second.PrepareAsync(allowDownloads: false); + var secondPath = Assert.Single(Directory.GetFiles(secondRoot, "*.dll", SearchOption.AllDirectories)); + AssertSameFile(path, secondPath); + Assert.Equal(_binary, File.ReadAllBytes(secondPath)); + } + + private static void AssertSameFile(string first, string second) { + if (OperatingSystem.IsWindows()) { + static string Identity(string path) { + using var file = File.OpenRead(path); + Assert.True(GetFileInformationByHandle(file.SafeFileHandle, out var info)); + return $"{info.Volume}:{info.IndexHigh}:{info.IndexLow}"; + } + Assert.Equal(Identity(first), Identity(second)); + } + else if (OperatingSystem.IsLinux()) { + static string Identity(string path) { + var start = new System.Diagnostics.ProcessStartInfo("stat") { RedirectStandardOutput = true }; + start.ArgumentList.Add("--format=%d:%i"); + start.ArgumentList.Add("--"); + start.ArgumentList.Add(path); + using var process = System.Diagnostics.Process.Start(start)!; + var identity = process.StandardOutput.ReadToEnd(); + process.WaitForExit(); + Assert.Equal(0, process.ExitCode); + return identity; + } + Assert.Equal(Identity(first), Identity(second)); + } + } + + [Fact] + public async Task CrossVolumeDllRestorationCopiesValidatedCacheWithoutDownloading() { + var otherVolume = OperatingSystem.IsWindows() ? @"Z:\dev\shm" : "/dev/shm"; + if (!Directory.Exists(otherVolume)) return; + var plugins = Path.Combine(otherVolume, "dll-copy-test-" + Guid.NewGuid().ToString("N")); + try { + Index(Release()); + var first = Manager(); + await first.RefreshAsync(); + await first.PrepareAsync(); + var cacheRoot = Path.Combine(_dir, ".cache", "dlls"); + var cache = Assert.Single(Directory.GetFiles(cacheRoot, "*.dll", SearchOption.AllDirectories)); + var second = new DllManager(plugins, null, _http, cacheDirectory: cacheRoot); + await second.RefreshAsync(); + second.SetEnabled(Release(), true); + await second.PrepareAsync(allowDownloads: false); + var installed = Assert.Single(Directory.GetFiles(plugins, "*.dll", SearchOption.AllDirectories)); + // Deliberately corrupt the test cache to prove the fallback is an independent copy. + File.WriteAllText(cache, "tampered test cache"); + Assert.Equal(_binary, File.ReadAllBytes(installed)); + Assert.Empty(Directory.GetFiles(plugins, "*.tmp", SearchOption.AllDirectories)); + } + finally { if (Directory.Exists(plugins)) Directory.Delete(plugins, true); } + } + + [Fact] + public async Task WaitingForDllCacheLeaseCanBeCancelledWithoutDownloading() { + Index(Release()); + var manager = Manager(); + await manager.RefreshAsync(); + var directory = Path.Combine(_dir, ".cache", "dlls", Release().Sha256); + Directory.CreateDirectory(directory); + using var held = new FileStream(Path.Combine(directory, ".lock"), FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None); + using var stop = new CancellationTokenSource(TimeSpan.FromMilliseconds(100)); + _handler.OnBinaryRequest = () => throw new InvalidOperationException("Cancelled cache waiter must not download."); + await Assert.ThrowsAnyAsync(() => manager.PrepareAsync(cancellationToken: stop.Token) + .WaitAsync(TimeSpan.FromSeconds(5))); + Assert.False(Directory.Exists(PluginPath)); + } + + [System.Runtime.InteropServices.StructLayout(System.Runtime.InteropServices.LayoutKind.Sequential)] + private struct FileInformation { + public uint Attributes, CreationLow, CreationHigh, AccessLow, AccessHigh, WriteLow, WriteHigh, + Volume, SizeHigh, SizeLow, Links, IndexHigh, IndexLow; + } + + [System.Runtime.InteropServices.DllImport("kernel32.dll", SetLastError = true)] + [return: System.Runtime.InteropServices.MarshalAs(System.Runtime.InteropServices.UnmanagedType.Bool)] + private static extern bool GetFileInformationByHandle(Microsoft.Win32.SafeHandles.SafeFileHandle file, out FileInformation information); + [Fact] public async Task RemovedReleaseCanBeDisabledButNeverReenabled() { Index(Release()); @@ -870,8 +987,8 @@ public async Task ActivePublicationCollisionPreservesUnknownFileAndBlocksPrepara reached = true; Assert.Equal(installed, path); Assert.False(File.Exists(path)); - // The production absence check and validated staging copy have - // completed. A separate writer wins the destination before rename. + // The production absence check and cache validation have completed. + // A separate writer wins the destination before link/copy publication. Task.Run(() => File.WriteAllText(path, "unknown user version")).GetAwaiter().GetResult(); }; var prepare = typeof(DllManager).GetMethod("PrepareAsync", diff --git a/UnchainedLauncher.Core/Services/Dlls/DllManager.cs b/UnchainedLauncher.Core/Services/Dlls/DllManager.cs index a211d0e9..c34ad4fe 100644 --- a/UnchainedLauncher.Core/Services/Dlls/DllManager.cs +++ b/UnchainedLauncher.Core/Services/Dlls/DllManager.cs @@ -6,6 +6,7 @@ using System.Security.Cryptography; using System.Text.Json; using System.Text.RegularExpressions; +using UnchainedLauncher.Core.Utilities; namespace UnchainedLauncher.Core.Services.Dlls { /// Only explicitly enabled, registry-backed releases may reach the injector. @@ -363,6 +364,8 @@ private async Task PrepareAsync(bool server, bool allowDownloads, Action if (File.Exists(path) && !MatchesFile(path, release) && ManagedReleaseAt(path) == null) throw new InvalidDataException($"Unrecognized DLL at managed path; file left unchanged: {path}"); var cache = GetCachePath(release); + Directory.CreateDirectory(Path.GetDirectoryName(cache)!); + using var cacheLease = await AssetCacheFiles.LockAsync(Path.Combine(Path.GetDirectoryName(cache)!, ".lock"), cancellationToken); if (File.Exists(cache)) { using var existing = File.OpenRead(cache); ValidateBytes(existing, release); @@ -376,7 +379,6 @@ private async Task PrepareAsync(bool server, bool allowDownloads, Action } if (!allowDownloads) throw new InvalidOperationException($"Automatic plugin downloads are disabled; missing registered DLL: {release.Key}"); - Directory.CreateDirectory(Path.GetDirectoryName(cache)!); var temp = cache + "." + Guid.NewGuid().ToString("N") + ".tmp"; try { using var response = await _http.GetAsync(release.DownloadUrl, HttpCompletionOption.ResponseHeadersRead, cancellationToken); @@ -397,7 +399,7 @@ private async Task PrepareAsync(bool server, bool allowDownloads, Action target.Position = 0; ValidateBytes(target, release); } - File.Move(temp, cache, true); + File.Move(temp, cache); } finally { if (File.Exists(temp)) File.Delete(temp); } } @@ -438,15 +440,28 @@ private static void CopyValidated(string source, string destination, DllRelease try { using var input = archive ? OpenForArchive(source) : new FileStream(source, FileMode.Open, FileAccess.Read, FileShare.Read); - using (var output = new FileStream(temp, FileMode.CreateNew, FileAccess.ReadWrite, FileShare.None)) { - ValidateBytes(input, release); - input.CopyTo(output); - ValidateBytes(output, release); + ValidateBytes(input, release); + if (!archive && !overwrite) { + // The validated cache handle denies replacement on Windows until + // publication completes. Never overwrite shared DLL bytes in place. + beforePublish?.Invoke(); + if (AssetCacheFiles.TryCreateHardLink(destination, source)) return; + } + // A linked source and its cache object have the same identity. Do not + // replace that cache entry while the archival handle protects it. + using var existingCache = archive && File.Exists(destination) + ? new FileStream(destination, FileMode.Open, FileAccess.Read, FileShare.Read | FileShare.Delete) + : null; + if (existingCache != null) ValidateBytes(existingCache, release); + else { + using (var output = new FileStream(temp, FileMode.CreateNew, FileAccess.ReadWrite, FileShare.None)) { + input.CopyTo(output); + ValidateBytes(output, release); + } + if (archive || overwrite) beforePublish?.Invoke(); + // Active publication must fail if another writer won the name. + File.Move(temp, destination, overwrite); } - beforePublish?.Invoke(); - // Cache entries may be replaced, but active publication must atomically - // fail if an external writer has created any destination file. - File.Move(temp, destination, overwrite); // Deterministic test boundary: the cache exists, and the validated // source must still deny writes, renames and pathname replacement. cachePublished?.Invoke(); diff --git a/UnchainedLauncher.Core/Services/Mods/IModManager.cs b/UnchainedLauncher.Core/Services/Mods/IModManager.cs index 0ccd9d8a..c5edf500 100644 --- a/UnchainedLauncher.Core/Services/Mods/IModManager.cs +++ b/UnchainedLauncher.Core/Services/Mods/IModManager.cs @@ -346,7 +346,8 @@ public static async IAsyncEnumerable> InstallMods( coords, outputPath => modManager.ModRegistry .DownloadPak(coords, outputPath) - .MapLeft(e => Error.New(e)) + .MapLeft(e => Error.New(e)), + release.ReleaseHash ); }) .ToList(); diff --git a/UnchainedLauncher.Core/Services/Mods/IPakDir.cs b/UnchainedLauncher.Core/Services/Mods/IPakDir.cs index a407b030..8b61ea95 100644 --- a/UnchainedLauncher.Core/Services/Mods/IPakDir.cs +++ b/UnchainedLauncher.Core/Services/Mods/IPakDir.cs @@ -9,7 +9,7 @@ namespace UnchainedLauncher.Core.Services.Mods { /// /// The coordinates identifying the specific release to install /// A factory function that creates a FileWriter for the given output path - public record ModInstallRequest(ReleaseCoordinates Coordinates, IPakDir.MakeFileWriter Writer); + public record ModInstallRequest(ReleaseCoordinates Coordinates, IPakDir.MakeFileWriter Writer, string? Hash = null); /// /// Represents a pak file that is managed by the PakDir system. diff --git a/UnchainedLauncher.Core/Services/Mods/ManagedPakInventory.cs b/UnchainedLauncher.Core/Services/Mods/ManagedPakInventory.cs index ea342ad0..d3e68e4c 100644 --- a/UnchainedLauncher.Core/Services/Mods/ManagedPakInventory.cs +++ b/UnchainedLauncher.Core/Services/Mods/ManagedPakInventory.cs @@ -43,7 +43,7 @@ private ManagedPakInventory(PakDir pakDir) { public void Save() { var temporaryPath = _path + ".tmp"; - File.WriteAllText(temporaryPath, JsonHelpers.Serialize(new PakDirMetadata(_pakDir.DirPath, _pakDir.ManagedPaks, _pakDir.DefaultSigPath))); + File.WriteAllText(temporaryPath, JsonHelpers.Serialize(new PakDirMetadata(_pakDir.DirPath, _pakDir.ManagedPaks, _pakDir.DefaultSigPath, _pakDir.CachePath))); File.Move(temporaryPath, _path, overwrite: true); } diff --git a/UnchainedLauncher.Core/Services/Mods/ModManager.cs b/UnchainedLauncher.Core/Services/Mods/ModManager.cs index b03ebeee..a8b76ca4 100644 --- a/UnchainedLauncher.Core/Services/Mods/ModManager.cs +++ b/UnchainedLauncher.Core/Services/Mods/ModManager.cs @@ -52,6 +52,9 @@ public class ModManager : IModManager { public IPakDir PakDir { get; } + public ModManager WithPakDir(IPakDir directory) => + new(ModRegistry, directory, EnabledModReleaseCoordinates, Mods) { NeedsMetadataRefresh = NeedsMetadataRefresh }; + public ModManager( IModRegistry modRegistry, IPakDir pakDir, diff --git a/UnchainedLauncher.Core/Services/Mods/ModManagerCodec.cs b/UnchainedLauncher.Core/Services/Mods/ModManagerCodec.cs index dee827c7..ec3dc592 100644 --- a/UnchainedLauncher.Core/Services/Mods/ModManagerCodec.cs +++ b/UnchainedLauncher.Core/Services/Mods/ModManagerCodec.cs @@ -63,7 +63,7 @@ private static bool IsUsableCache(Mod mod) => private static PakDir CreatePakDir(PakDirMetadata? metadata) => metadata != null - ? new PakDir(metadata.DirPath, metadata.ManagedPaks, metadata.DefaultSigPath) + ? new PakDir(metadata.DirPath, metadata.ManagedPaks, metadata.DefaultSigPath, metadata.CachePath) : new PakDir(FilePaths.PakDir, Enumerable.Empty()); public static ModManagerMetadata ToJsonType(IModManager manager) => @@ -72,7 +72,7 @@ public static ModManagerMetadata ToJsonType(IModManager manager) => m.EnabledModReleaseCoordinates, m.Mods, m.PakDir switch { - PakDir pd => new PakDirMetadata(pd.DirPath, pd.ManagedPaks, pd.DefaultSigPath), + PakDir pd => new PakDirMetadata(pd.DirPath, pd.ManagedPaks, pd.DefaultSigPath, pd.CachePath), _ => null } ), @@ -94,7 +94,9 @@ public record StandardModManagerMetadata( public record PakDirMetadata(string DirPath, IEnumerable ManagedPaks, [property: System.Text.Json.Serialization.JsonIgnore(Condition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull)] - string? DefaultSigPath = null); + string? DefaultSigPath = null, + [property: System.Text.Json.Serialization.JsonIgnore(Condition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull)] + string? CachePath = null); internal static class ModManagerMetadataKind { public const string StandardModManager = "StandardModManager"; diff --git a/UnchainedLauncher.Core/Services/Mods/PakCache.cs b/UnchainedLauncher.Core/Services/Mods/PakCache.cs new file mode 100644 index 00000000..fe82afc6 --- /dev/null +++ b/UnchainedLauncher.Core/Services/Mods/PakCache.cs @@ -0,0 +1,64 @@ +using System.Security.Cryptography; +using System.Text.RegularExpressions; +using LanguageExt; +using LanguageExt.Common; +using UnchainedLauncher.Core.Utilities; +using static LanguageExt.Prelude; + +namespace UnchainedLauncher.Core.Services.Mods; + +// Published objects are immutable. Updates replace directory entries, never linked bytes. +public sealed class PakCache(string root) { + public string Root { get; } = Path.GetFullPath(root); + + public EitherAsync Install(ModInstallRequest request, string destination, + Option> progress, CancellationToken cancellationToken = default) => TryAsync(async () => { + cancellationToken.ThrowIfCancellationRequested(); + var hash = request.Hash?.ToLowerInvariant(); + if (hash is null || !Regex.IsMatch(hash, "\\A[a-f0-9]{128}\\z")) + throw new InvalidDataException("A SHA-512 release hash is required for cached paks."); + var directory = Path.Combine(Root, hash); + Directory.CreateDirectory(directory); + using var lease = await AssetCacheFiles.LockAsync(Path.Combine(directory, ".lock"), cancellationToken); + var cached = Path.Combine(directory, "mod.pak"); + if (!File.Exists(cached)) { + var temporary = cached + "." + Guid.NewGuid().ToString("N") + ".tmp"; + try { + var result = await request.Writer(temporary); + using var writer = result.Match(Right: value => value, Left: error => throw error.ToException()); + var written = await writer.WriteAsync(writer.Size > 0 ? progress : None, cancellationToken); + written.IfLeft(error => throw error.ToException()); + // Existing registry writers use zero when HTTP size discovery is unavailable. + if (writer.Size > 0 && new FileInfo(temporary).Length != writer.Size) + throw new InvalidDataException("Pak download size differs from the reported size."); + Validate(temporary, hash); + File.Move(temporary, cached); + } + finally { if (File.Exists(temporary)) File.Delete(temporary); } + } + else Validate(cached, hash); + cancellationToken.ThrowIfCancellationRequested(); + // Creating a hard link is already atomic and does not replace an existing name. + // Renaming a staging link can fail on Windows while a peer has the pak open. + if (AssetCacheFiles.TryCreateHardLink(destination, cached)) { + progress.IfSome(p => p.Report(100)); + return Unit.Default; + } + var staging = destination + "." + Guid.NewGuid().ToString("N") + ".tmp"; + try { + File.Copy(cached, staging, overwrite: false); + // Never overwrite an unmanaged file that appeared during preparation. + File.Move(staging, destination); + } + finally { if (File.Exists(staging)) File.Delete(staging); } + progress.IfSome(p => p.Report(100)); + return Unit.Default; + }).ToEither(); + + private static void Validate(string path, string expected) { + using var stream = File.OpenRead(path); + if (!Convert.ToHexString(SHA512.HashData(stream)).Equals(expected, StringComparison.OrdinalIgnoreCase)) + throw new InvalidDataException($"Pak hash mismatch: {path}"); + } + +} \ No newline at end of file diff --git a/UnchainedLauncher.Core/Services/Mods/PakDir.cs b/UnchainedLauncher.Core/Services/Mods/PakDir.cs index 3c65cbdb..375ac950 100644 --- a/UnchainedLauncher.Core/Services/Mods/PakDir.cs +++ b/UnchainedLauncher.Core/Services/Mods/PakDir.cs @@ -13,6 +13,8 @@ namespace UnchainedLauncher.Core.Services.Mods { public class PakDir : IPakDir { public string DirPath { get; } public string? DefaultSigPath { get; } + public string? CachePath { get; } + private readonly CancellationToken _cancellationToken; private const string BasePakFileName = "pakchunk0-WindowsNoEditor.pak"; private const string BaseSigFileName = "pakchunk0-WindowsNoEditor.sig"; @@ -23,9 +25,12 @@ public class PakDir : IPakDir { public IEnumerable ManagedPaks => _managedPaks; private readonly List _managedPaks; - public PakDir(string dirPath, IEnumerable managedPaks, string? defaultSigPath = null) { + public PakDir(string dirPath, IEnumerable managedPaks, string? defaultSigPath = null, string? cachePath = null, + CancellationToken cancellationToken = default) { DirPath = dirPath; DefaultSigPath = defaultSigPath; + CachePath = cachePath; + _cancellationToken = cancellationToken; _managedPaks = managedPaks.ToList(); SynchronizeWithDir(); } @@ -114,8 +119,7 @@ private EitherAsync _writePak(IPakDir.MakeFileWriter mkFileWriter Logger.Info($"Downloading pak to {fullActualName}"); return mkFileWriter(PakNameToPakPath(fullActualName)) .Bind(fileWriter => - // TODO: allow passing of a cancellation token here - fileWriter.WriteAsync(progress, CancellationToken.None) + fileWriter.WriteAsync(progress, _cancellationToken) .Map(_ => fullActualName) ); } @@ -287,7 +291,12 @@ private Either ProcessMove(InstallAction.Move move) { } private async Task> ProcessDownload(InstallAction.Download download, Option taskProgress) { - return (await _writePak(download.Install.Writer, taskProgress.Map(p => (IProgress)p), download.FinalPakName)) + var name = AvailablePakName(download.FinalPakName); + var progress = taskProgress.Map(p => (IProgress)p); + var written = CachePath is null + ? await _writePak(download.Install.Writer, progress, download.FinalPakName) + : (await new PakCache(CachePath).Install(download.Install, PakNameToPakPath(name), progress, _cancellationToken)).Map(_ => name); + return written .Map(pakFileName => { var managedPak = new ManagedPak(download.Install.Coordinates, pakFileName, download.Index); _managedPaks.Add(managedPak); @@ -453,4 +462,4 @@ private static IEnumerable ApplySortedLexicographically(IEnumerable Path.Combine(Root, "paks"); + public string Plugins => Path.Combine(Root, "plugins"); + public string PakCache { get; } + public string DllCache { get; } + + public ProfileAssetPaths(string suffix, string? localAppData = null) { + var game = Path.Combine(localAppData ?? Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Chivalry 2"); + var normalized = suffix.Replace("-", "", StringComparison.Ordinal); + if (normalized.Length == 0 || normalized.Any(c => !char.IsAsciiLetterOrDigit(c) && c != '_')) + throw new ArgumentException("Invalid saved directory suffix."); + Root = Path.Combine(game, "Saved_" + normalized, "unchained"); + PakCache = Path.Combine(game, "unchained", "cache", "paks"); + DllCache = Path.Combine(game, "unchained", "cache", "dlls"); + } + + public PakDir CreatePakDir(string installation, IEnumerable? managedPaks = null) { + Directory.CreateDirectory(Paks); + return new PakDir(Paks, managedPaks ?? [], Path.Combine(installation, FilePaths.PakDir, "pakchunk0-WindowsNoEditor.sig"), PakCache); + } +} \ No newline at end of file diff --git a/UnchainedLauncher.Core/Services/Processes/Chivalry/UnchainedChivalry2Launcher.cs b/UnchainedLauncher.Core/Services/Processes/Chivalry/UnchainedChivalry2Launcher.cs index c1624b3f..9741ea98 100644 --- a/UnchainedLauncher.Core/Services/Processes/Chivalry/UnchainedChivalry2Launcher.cs +++ b/UnchainedLauncher.Core/Services/Processes/Chivalry/UnchainedChivalry2Launcher.cs @@ -79,26 +79,31 @@ public async Task> TryLaunch(LaunchOptio .Concat(cliArgs.Select(x => x.Rendered)) .Where(x => !string.IsNullOrWhiteSpace(x))); - var launchResult = Launcher.Launch(workingDir, launchOptString); + var injectionFailed = false; + var launchResult = Launcher.Launch(workingDir, launchOptString, process => { + Logger.Info($"Injecting DLLs into {process.Id} before resuming the game."); + try { + injectionFailed = !ProcessInjector.Inject(process, server: updatedLaunchOpts.ServerLaunchOptions.IsSome); + return !injectionFailed; + } + catch { + injectionFailed = true; + throw; + } + }); - return launchResult.Match( + return launchResult.Match>( Left: error => { Logger.Error(error); + if (injectionFailed) return Left(UnchainedLaunchFailure.InjectionFailed()); return Left(UnchainedLaunchFailure.LaunchFailed(error)); }, Right: proc => { Logger.Info("Successfully launched Chivalry 2 process."); - return InjectDLLs(proc, server: updatedLaunchOpts.ServerLaunchOptions.IsSome); + return Right(proc); } ); } - private Either InjectDLLs(Process process, bool server) { - Logger.Info($"Injecting DLLs into {process.Id}"); - if (ProcessInjector.Inject(process, server)) return Right(process); - - process.Kill(); - return Left(UnchainedLaunchFailure.InjectionFailed()); - } } } \ No newline at end of file diff --git a/UnchainedLauncher.Core/Services/Processes/ProcessLauncher.cs b/UnchainedLauncher.Core/Services/Processes/ProcessLauncher.cs index 30e4ab74..f6390761 100644 --- a/UnchainedLauncher.Core/Services/Processes/ProcessLauncher.cs +++ b/UnchainedLauncher.Core/Services/Processes/ProcessLauncher.cs @@ -10,6 +10,10 @@ namespace UnchainedLauncher.Core.Services.Processes { public interface IProcessLauncher { string Executable { get; } public Either Launch(string workingDirectory, string args); + + // Diagnostic launchers and test doubles do not own a suspended native thread. + public Either Launch(string workingDirectory, string args, Func initialize) => + Launch(workingDirectory, args).Bind(process => ProcessLauncher.Initialize(process, initialize, Executable, args)); } public class PowershellProcessLauncher : IProcessLauncher { @@ -57,6 +61,27 @@ public ProcessLauncher(string executable) { Executable = executable; } + public Either Launch(string workingDirectory, string args, Func initialize) { + if (OperatingSystem.IsWindows()) + return SuspendedProcessLauncher.Launch(Executable, workingDirectory, args, initialize); + // Game launching is Windows-only; retain portable argument-probe support. + return Launch(workingDirectory, args).Bind(process => Initialize(process, initialize, Executable, args)); + } + + internal static Either Initialize(Process process, Func initialize, + string executable, string args) { + try { + if (initialize(process)) return Right(process); + throw new InvalidOperationException("DLL injection failed."); + } + catch (Exception error) { + try { if (!process.HasExited) process.Kill(entireProcessTree: true); } + catch (Exception cleanupError) { Logger.Error("Could not terminate failed launch.", cleanupError); } + finally { process.Dispose(); } + return Left(new LaunchFailed(executable, args, error)); + } + } + /// /// Creates a new process with the provided arguments and injects the DLLs. /// diff --git a/UnchainedLauncher.Core/Services/Processes/SuspendedProcessLauncher.cs b/UnchainedLauncher.Core/Services/Processes/SuspendedProcessLauncher.cs new file mode 100644 index 00000000..d5f64e26 --- /dev/null +++ b/UnchainedLauncher.Core/Services/Processes/SuspendedProcessLauncher.cs @@ -0,0 +1,87 @@ +using System.ComponentModel; +using System.Diagnostics; +using System.Runtime.InteropServices; +using System.Text; +using LanguageExt; +using static LanguageExt.Prelude; + +namespace UnchainedLauncher.Core.Services.Processes; + +internal static class SuspendedProcessLauncher { + private static readonly log4net.ILog Logger = log4net.LogManager.GetLogger(typeof(SuspendedProcessLauncher)); + private const uint CreateSuspended = 0x00000004; + internal static Either Launch(string executable, string workingDirectory, string args, + Func initialize) { + var startup = new StartupInfo { Size = Marshal.SizeOf() }; + ProcessInformation native = default; + Process? process = null; + var resumed = false; + try { + var path = Path.GetFullPath(executable); + var commandLine = new StringBuilder($"\"{path}\" {args}"); + if (!CreateProcessW(path, commandLine, IntPtr.Zero, IntPtr.Zero, false, CreateSuspended, + IntPtr.Zero, Path.GetFullPath(workingDirectory), ref startup, out native)) + throw new Win32Exception(Marshal.GetLastWin32Error(), "Could not create suspended process."); + + process = Process.GetProcessById(native.ProcessId); + // Open the managed handle while the original process handle still pins its identity. + _ = process.SafeHandle; + if (!initialize(process)) throw new InvalidOperationException("DLL injection failed."); + var previousSuspendCount = ResumeThread(native.Thread); + if (previousSuspendCount == uint.MaxValue) + throw new Win32Exception(Marshal.GetLastWin32Error(), "Could not resume the game main thread."); + if (previousSuspendCount != 1) + throw new InvalidOperationException("Unexpected game main-thread suspend count."); + resumed = true; + return Right(process); + } + catch (Exception error) { + return Left(new LaunchFailed(executable, args, error)); + } + finally { + if (!resumed && native.Process != IntPtr.Zero) { + // Never resume a game after failed injection, including exceptions from the injector. + if (!TerminateProcess(native.Process, 1)) + Logger.Error("Could not terminate failed suspended launch.", new Win32Exception(Marshal.GetLastWin32Error())); + } + if (native.Thread != IntPtr.Zero) CloseHandle(native.Thread); + if (native.Process != IntPtr.Zero) CloseHandle(native.Process); + if (!resumed) process?.Dispose(); + } + } + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + private struct StartupInfo { + public int Size; + public string? Reserved; + public string? Desktop; + public string? Title; + public uint X, Y, XSize, YSize, XCountChars, YCountChars, FillAttribute, Flags; + public ushort ShowWindow, ReservedSize; + public IntPtr ReservedBytes, StandardInput, StandardOutput, StandardError; + } + + [StructLayout(LayoutKind.Sequential)] + private struct ProcessInformation { + public IntPtr Process, Thread; + public int ProcessId, ThreadId; + } + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true, ExactSpelling = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool CreateProcessW(string applicationName, StringBuilder commandLine, + IntPtr processAttributes, IntPtr threadAttributes, [MarshalAs(UnmanagedType.Bool)] bool inheritHandles, + uint creationFlags, IntPtr environment, string currentDirectory, ref StartupInfo startupInfo, + out ProcessInformation processInformation); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern uint ResumeThread(IntPtr thread); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool TerminateProcess(IntPtr process, uint exitCode); + + [DllImport("kernel32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool CloseHandle(IntPtr handle); +} \ No newline at end of file diff --git a/UnchainedLauncher.Core/Utilities/AssetCacheFiles.cs b/UnchainedLauncher.Core/Utilities/AssetCacheFiles.cs new file mode 100644 index 00000000..1fb49f17 --- /dev/null +++ b/UnchainedLauncher.Core/Utilities/AssetCacheFiles.cs @@ -0,0 +1,26 @@ +using System.Runtime.InteropServices; + +namespace UnchainedLauncher.Core.Utilities; + +internal static class AssetCacheFiles { + internal static async Task LockAsync(string path, CancellationToken cancellationToken) { + var deadline = DateTime.UtcNow.AddMinutes(30); + while (true) { + cancellationToken.ThrowIfCancellationRequested(); + try { return new FileStream(path, FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None); } + catch (IOException) when (DateTime.UtcNow < deadline) { await Task.Delay(100, cancellationToken); } + } + } + + // Direct publication is atomic and never replaces an occupied destination. + internal static bool TryCreateHardLink(string destination, string source) => OperatingSystem.IsWindows() + ? CreateHardLinkW(destination, source, IntPtr.Zero) + : OperatingSystem.IsLinux() && Link(source, destination) == 0; + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true, ExactSpelling = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool CreateHardLinkW(string fileName, string existingFileName, IntPtr securityAttributes); + + [DllImport("libc", EntryPoint = "link", SetLastError = true)] + private static extern int Link(string existingFileName, string fileName); +} \ No newline at end of file diff --git a/UnchainedLauncher.GUI/App.xaml.cs b/UnchainedLauncher.GUI/App.xaml.cs index 720f0df6..76710bc8 100644 --- a/UnchainedLauncher.GUI/App.xaml.cs +++ b/UnchainedLauncher.GUI/App.xaml.cs @@ -143,11 +143,12 @@ select chiv2Installations.Path var modRegistry = InitializeModRegistry(FilePaths.RegistryConfigPath); var modManager = InitializeModManager(FilePaths.ModManagerConfigPath, modRegistry); + var assets = new ProfileAssetPaths(ProfileAssetPaths.SharedSuffix); var dllManager = new DllManager( - Path.Combine(Directory.GetCurrentDirectory(), FilePaths.PluginDir), + assets.Plugins, FilePaths.DllManagerConfigPath, new HttpClient(), () => modManager.GetEnabledAndDependencyReleases().ToArray(), () => modRegistry.ModRegistries.OfType() - .Select(registry => registry.DllDatabaseUrl).ToArray()); + .Select(registry => registry.DllDatabaseUrl).ToArray(), assets.DllCache); var registryWindowViewModel = new RegistryWindowVM(modRegistry, registryWindowService); @@ -188,7 +189,13 @@ select chiv2Installations.Path ); var unchainedLauncher = new UnchainedChivalry2Launcher( - pluginInstaller + Chivalry2LaunchPreparer.Create(options => { + if (System.Text.RegularExpressions.Regex.IsMatch(options.LaunchArgs, @"(?:^|\s)--additional-pak-dir(?:\s|=|$)")) + throw new ArgumentException("The additional pak directory is managed by the launcher."); + return LanguageExt.Prelude.Some(options with { + LaunchArgs = options.LaunchArgs + " " + new Parameter("--additional-pak-dir", assets.Paks).Rendered + }); + }).AndThen(pluginInstaller) .AndThen(modInstaller) .Sub(sigLaunchPreparer), unchainedProcessLauncher, @@ -197,7 +204,7 @@ select chiv2Installations.Path // don't shove dlls into processes that don't need them and cause crashes new NullInjector(true) #else - new DllInjector(dllManager, Path.Combine(Directory.GetCurrentDirectory(), FilePaths.PluginDir), _unsafeLoadAllDlls) + new DllInjector(dllManager, assets.Plugins, _unsafeLoadAllDlls) #endif ); @@ -314,15 +321,19 @@ private AggregateModRegistry InitializeModRegistry(string jsonPath) { } private ModManager InitializeModManager(string jsonPath, IModRegistry registry) { + var assets = new ProfileAssetPaths(ProfileAssetPaths.SharedSuffix); Func initializeDefaultModManager = () => new ModManager( registry, - new PakDir(FilePaths.PakDir, Enumerable.Empty()), + assets.CreatePakDir(Directory.GetCurrentDirectory()), Enumerable.Empty() ); var codec = new ModManagerCodec(registry); var modManager = InitializeFromFileWithCodec(codec, jsonPath, initializeDefaultModManager); + var sameProfile = Path.GetFullPath(modManager.PakDir.DirPath).Equals(Path.GetFullPath(assets.Paks), StringComparison.OrdinalIgnoreCase); + modManager = modManager.WithPakDir(assets.CreatePakDir(Directory.GetCurrentDirectory(), + sameProfile && modManager.PakDir is PakDir existing ? existing.ManagedPaks : [])); RegisterSaveToFileOnExit(modManager, codec, jsonPath); return modManager; diff --git a/UnchainedLauncher.GUI/ViewModels/HelpVM.cs b/UnchainedLauncher.GUI/ViewModels/HelpVM.cs index 1dfef641..c48bc049 100644 --- a/UnchainedLauncher.GUI/ViewModels/HelpVM.cs +++ b/UnchainedLauncher.GUI/ViewModels/HelpVM.cs @@ -149,7 +149,7 @@ private void UninstallLauncher() { private void CleanUpInstallation_actions() { FileHelpers.DeleteDirectory(FilePaths.ModCachePath); - FileHelpers.DeleteDirectory(FilePaths.PluginDir); + FileHelpers.DeleteDirectory(new ProfileAssetPaths(ProfileAssetPaths.SharedSuffix).Plugins); PakDir.Reset(); } @@ -159,8 +159,9 @@ private void CleanUpInstallation() { var message = new List() { "Are you sure? This will disable all mods and reset all settings to their defaults. This will delete the following:", "* All files in .mod_cache", - "* All files in TBL\\Binaries\\Win64\\Plugins", - "* All non-vanilla paks in TBL\\Content\\Paks.", + $"* All files in {new ProfileAssetPaths(ProfileAssetPaths.SharedSuffix).Plugins}", + $"* All non-vanilla files in {PakDir.DirPath}", + "Shared caches, other CLI profiles, and legacy installation-wide assets are retained.", "", "After deleting, the launcher will restart itself." }.Aggregate((accumulator, next) => accumulator + "\n" + next); From 68b659d9c54192269f3499dd6db80a3d033bc128 Mon Sep 17 00:00:00 2001 From: Jacoby6000 <2672832+Jacoby6000@users.noreply.github.com> Date: Fri, 16 Oct 2026 02:09:41 +0000 Subject: [PATCH 25/58] [GHA] Format Source --- UnchainedLauncher.CLI.Tests/PakCacheTests.cs | 4 ++-- UnchainedLauncher.CLI/ServerInstancePaths.cs | 2 +- UnchainedLauncher.CLI/ServerLauncher.cs | 2 +- UnchainedLauncher.Core/Services/Mods/PakCache.cs | 4 ++-- UnchainedLauncher.Core/Services/Mods/PakDir.cs | 2 +- .../Services/Processes/SuspendedProcessLauncher.cs | 2 +- 6 files changed, 8 insertions(+), 8 deletions(-) diff --git a/UnchainedLauncher.CLI.Tests/PakCacheTests.cs b/UnchainedLauncher.CLI.Tests/PakCacheTests.cs index e0feee88..bd4c5bb2 100644 --- a/UnchainedLauncher.CLI.Tests/PakCacheTests.cs +++ b/UnchainedLauncher.CLI.Tests/PakCacheTests.cs @@ -1,6 +1,6 @@ -using System.Security.Cryptography; using LanguageExt; using LanguageExt.Common; +using System.Security.Cryptography; using UnchainedLauncher.Core.Services.Mods; using UnchainedLauncher.Core.Services.Mods.Registry; using UnchainedLauncher.Core.Utilities; @@ -162,4 +162,4 @@ public void ProfileAssetsAndSharedCachesUseLocalAppData() { Assert.Equal(Path.Combine(_root, "Chivalry 2", "Saved_unchained", "unchained", "paks"), new ProfileAssetPaths(ProfileAssetPaths.SharedSuffix, _root).Paks); } -} +} \ No newline at end of file diff --git a/UnchainedLauncher.CLI/ServerInstancePaths.cs b/UnchainedLauncher.CLI/ServerInstancePaths.cs index a47c57f2..2e6c6c94 100644 --- a/UnchainedLauncher.CLI/ServerInstancePaths.cs +++ b/UnchainedLauncher.CLI/ServerInstancePaths.cs @@ -21,4 +21,4 @@ internal PakDir CreatePakDir(string basePakDirectory, CancellationToken cancella Directory.CreateDirectory(Paks); return new PakDir(Paks, [], Path.Combine(basePakDirectory, "pakchunk0-WindowsNoEditor.sig"), PakCache, cancellationToken); } -} +} \ No newline at end of file diff --git a/UnchainedLauncher.CLI/ServerLauncher.cs b/UnchainedLauncher.CLI/ServerLauncher.cs index 791c0976..e5acd22c 100644 --- a/UnchainedLauncher.CLI/ServerLauncher.cs +++ b/UnchainedLauncher.CLI/ServerLauncher.cs @@ -149,4 +149,4 @@ public static IA2S CreateQuery(ServerOptions options) { if (address.Equals(IPAddress.IPv6Any)) address = IPAddress.IPv6Loopback; return new A2S(new IPEndPoint(address, options.Settings.QueryPort)); } -} +} \ No newline at end of file diff --git a/UnchainedLauncher.Core/Services/Mods/PakCache.cs b/UnchainedLauncher.Core/Services/Mods/PakCache.cs index fe82afc6..6f392cc4 100644 --- a/UnchainedLauncher.Core/Services/Mods/PakCache.cs +++ b/UnchainedLauncher.Core/Services/Mods/PakCache.cs @@ -1,7 +1,7 @@ -using System.Security.Cryptography; -using System.Text.RegularExpressions; using LanguageExt; using LanguageExt.Common; +using System.Security.Cryptography; +using System.Text.RegularExpressions; using UnchainedLauncher.Core.Utilities; using static LanguageExt.Prelude; diff --git a/UnchainedLauncher.Core/Services/Mods/PakDir.cs b/UnchainedLauncher.Core/Services/Mods/PakDir.cs index 375ac950..a4b90ea5 100644 --- a/UnchainedLauncher.Core/Services/Mods/PakDir.cs +++ b/UnchainedLauncher.Core/Services/Mods/PakDir.cs @@ -462,4 +462,4 @@ private static IEnumerable ApplySortedLexicographically(IEnumerable Date: Fri, 16 Oct 2026 12:57:21 -0500 Subject: [PATCH 26/58] Rename CLI starting map and expand launch examples --- .../ArgumentBoundaryTests.cs | 4 +- .../LaunchExampleTests.cs | 46 +++++++++++++++++++ .../ServerGroupTests.cs | 4 ++ .../ServerOptionsTests.cs | 11 +++-- .../UnchainedLauncher.CLI.Tests.csproj | 1 + .../YamlConfigurationTests.cs | 4 +- UnchainedLauncher.CLI/CliLaunchOptions.cs | 2 +- UnchainedLauncher.CLI/CommandLineOptions.cs | 2 +- UnchainedLauncher.CLI/LaunchConfiguration.cs | 2 +- UnchainedLauncher.CLI/README.md | 19 +++++--- UnchainedLauncher.CLI/examples/server.yaml | 13 ++++-- UnchainedLauncher.CLI/examples/servers.yaml | 18 +++++--- 12 files changed, 101 insertions(+), 25 deletions(-) create mode 100644 UnchainedLauncher.CLI.Tests/LaunchExampleTests.cs diff --git a/UnchainedLauncher.CLI.Tests/ArgumentBoundaryTests.cs b/UnchainedLauncher.CLI.Tests/ArgumentBoundaryTests.cs index 54d5bb59..037ffed7 100644 --- a/UnchainedLauncher.CLI.Tests/ArgumentBoundaryTests.cs +++ b/UnchainedLauncher.CLI.Tests/ArgumentBoundaryTests.cs @@ -81,13 +81,13 @@ private CliLaunchOptions Parse(string command, bool yaml, string[] arguments, st if (!yaml) return CliLaunchOptions.Parse([command, "--chivalry2-path", _directory, "--platform", "Steam", .. command == "server" ? new[] { "--local-ip", "127.0.0.1" } : [], .. serverValue is null ? System.Array.Empty() : ["--name", serverValue, "--description", serverValue, - "--map", serverValue, "--password", serverValue], "--", .. arguments]); + "--starting-map", serverValue, "--password", serverValue], "--", .. arguments]); var path = Path.Combine(_directory, "launch.yaml"); // JSON-quoted strings are YAML scalars; cover both shared and command-specific game-args lists. File.WriteAllText(path, $"chiv2-path: {JsonSerializer.Serialize(_directory)}\nplatform: Steam\n" + $"game-args: {JsonSerializer.Serialize(arguments.Take(1))}\n{command}:\n" + (command == "server" ? " local-ip: 127.0.0.1\n" : "") + - (serverValue is null ? "" : string.Concat(new[] { "name", "description", "map", "password" } + (serverValue is null ? "" : string.Concat(new[] { "name", "description", "starting-map", "password" } .Select(key => $" {key}: {JsonSerializer.Serialize(serverValue)}\n"))) + $" game-args: {JsonSerializer.Serialize(arguments.Skip(1))}\n"); return CliLaunchOptions.Parse([command, "--config-path", path]); diff --git a/UnchainedLauncher.CLI.Tests/LaunchExampleTests.cs b/UnchainedLauncher.CLI.Tests/LaunchExampleTests.cs new file mode 100644 index 00000000..e1d5a9fe --- /dev/null +++ b/UnchainedLauncher.CLI.Tests/LaunchExampleTests.cs @@ -0,0 +1,46 @@ +using UnchainedLauncher.Core.Services.Mods.Registry; +using Xunit; + +namespace UnchainedLauncher.CLI.Tests; + +public class LaunchExampleTests { + private const string Plugin = "Chiv2-Community/UnchainedPlugin:v1.0.0-RC3"; + private const string Framework = "Chiv2-Community/Unchained-Mods:v0.1.0"; + private const string Mod = "Chiv2-Community/Chiv2Turbo:v0.0.4"; + private const string Actor = "/Game/Mods/AgMods/Chiv2Turbo/Chiv2Turbo.Chiv2Turbo_C"; + + [Theory] + [InlineData("client")] + [InlineData("server")] + public void DirectLaunchExamplesSelectPakAndDllDependencies(string command) { + var options = CliLaunchOptions.Parse([command, "--chivalry2-path", ".", "--platform", "Steam", + "--dll", Plugin, "--mod", Framework, "--mod", Mod, + .. command == "server" ? new[] { "--starting-map", "FFA_Courtyard", "--server-mod", Actor } : []]); + AssertDependencies(options.Shared); + if (options is ServerOptions server) { + Assert.Equal("FFA_Courtyard", server.ToServerLaunchOptions().Map); + Assert.Equal([Actor], server.ToServerLaunchOptions().ServerMods); + } + } + + [Theory] + [InlineData("server.yaml")] + [InlineData("servers.yaml")] + public void YamlLaunchExamplesSelectPakDllAndActorDependencies(string file) { + var command = CommandLineOptions.Parse(["server", "--config-path", Path.Combine(AppContext.BaseDirectory, "examples", file)]); + var servers = command is ServerGroupOptions group ? group.Servers.Select(entry => entry.Options) + : [Assert.IsType(command)]; + Assert.Equal(file == "servers.yaml" ? 2 : 1, servers.Count()); + Assert.All(servers, server => { + AssertDependencies(server.Shared); + Assert.Equal("FFA_Courtyard", server.ToServerLaunchOptions().Map); + Assert.Equal([Actor], server.ToServerLaunchOptions().ServerMods); + }); + } + + private static void AssertDependencies(SharedLaunchOptions options) { + Assert.Equal(new[] { new ReleaseCoordinates("Chiv2-Community", "Unchained-Mods", "v0.1.0"), + new ReleaseCoordinates("Chiv2-Community", "Chiv2Turbo", "v0.0.4") }, options.Mods); + Assert.Equal(new ReleaseCoordinates("Chiv2-Community", "UnchainedPlugin", "v1.0.0-RC3"), Assert.Single(options.Dlls)); + } +} \ No newline at end of file diff --git a/UnchainedLauncher.CLI.Tests/ServerGroupTests.cs b/UnchainedLauncher.CLI.Tests/ServerGroupTests.cs index 369a3386..a33da698 100644 --- a/UnchainedLauncher.CLI.Tests/ServerGroupTests.cs +++ b/UnchainedLauncher.CLI.Tests/ServerGroupTests.cs @@ -12,12 +12,14 @@ public sealed class ServerGroupTests : IDisposable { servers: courtyard: name: Courtyard + starting-map: FFA_Courtyard game-port: 7000 ping-port: 7001 query-port: 7002 rcon-port: 7003 arena: name: Arena + starting-map: TO_Stronghold game-port: 7004 ping-port: 7005 query-port: 7006 @@ -47,6 +49,7 @@ public void DictionaryPreservesOrderSharedSettingsAndIsolatedProfiles() { Assert.Equal(["Host-courtyard", "Host-arena"], group.Servers.Select(entry => entry.Options.Shared.SavedDirSuffix)); Assert.All(group.Servers, entry => Assert.Equal(Path.Combine(_root, "game"), entry.Options.Shared.Chivalry2Path)); Assert.Equal(["Courtyard", "Arena"], group.Servers.Select(entry => entry.Options.Settings.Name)); + Assert.Equal(["FFA_Courtyard", "TO_Stronghold"], group.Servers.Select(entry => entry.Options.ToServerLaunchOptions().Map)); Assert.Equal(7006, group.Servers[1].Options.ToServerLaunchOptions().QueryPort); Assert.Equal(2, group.Servers.Select(entry => entry.Options.InstancePaths.Paks).Distinct().Count()); Assert.Equal(2, group.Servers.Select(entry => entry.Options.InstancePaths.Plugins).Distinct().Count()); @@ -69,6 +72,7 @@ public void PerServerProfilesAndArgumentsRemainIndependent() { [InlineData("servers: {one: {}, ONE: {}}")] [InlineData("servers: {one: {}, two: {}}")] [InlineData("servers: {one: {game-port: 0}}")] + [InlineData("servers: {one: {map: TO_Test}}")] [InlineData("servers: {one: {saved-dir-suffix: '../outside'}}")] [InlineData("servers: {one: {unsafe-very-dangerous-load-all-dlls: true}}")] [InlineData("servers: {one: {mod-setup: {mods: []}}}")] diff --git a/UnchainedLauncher.CLI.Tests/ServerOptionsTests.cs b/UnchainedLauncher.CLI.Tests/ServerOptionsTests.cs index 306861ef..b24b7437 100644 --- a/UnchainedLauncher.CLI.Tests/ServerOptionsTests.cs +++ b/UnchainedLauncher.CLI.Tests/ServerOptionsTests.cs @@ -61,7 +61,7 @@ public void GameArgumentsAreEscapedAndNotParsedAsLauncherOptions() { [Fact] public void ServerFieldsAreMappedToCoreOptions() { - var options = CliLaunchOptions.Parse(Args("--name", "Test Server", "--description", "Test MOTD", "--map", "TO_Test", + var options = CliLaunchOptions.Parse(Args("--name", "Test Server", "--description", "Test MOTD", "--starting-map", "TO_Test", "--password", "secret", "--local-ip", "127.0.0.2", "--game-port", "10001", "--ping-port", "10002", "--query-port", "10003", "--rcon-port", "10004", "--register", "--no-tui", "--no-plugin-update", "--allow-unstable-plugin", "--server-browser-backend", "https://example.test", "--saved-dir-suffix", "Test_1")); @@ -92,6 +92,7 @@ public void ServerFieldsAreMappedToCoreOptions() { [InlineData("--add-registry", "github:bad")] [InlineData("--add-registry", "http:unsupported")] [InlineData("--name", " ")] + [InlineData("--starting-map", " ")] [InlineData("--headless", "yes")] [InlineData("--censor-mode", "invalid")] [InlineData("--discord-mention-admins", "maybe")] @@ -104,7 +105,8 @@ public void RejectsMissingRequiredOptionsAndUnknownFlags() { Assert.Throws(() => CliLaunchOptions.Parse(["server"])); Assert.Throws(() => CliLaunchOptions.Parse(["server", "--chivalry2-path", "."])); Assert.Throws(() => CliLaunchOptions.Parse(Args("--typo"))); - Assert.Throws(() => CliLaunchOptions.Parse(Args("--map"))); + Assert.Throws(() => CliLaunchOptions.Parse(Args("--starting-map"))); + Assert.Throws(() => CliLaunchOptions.Parse(Args("--map", "TO_Test"))); Assert.Throws(() => CliLaunchOptions.Parse(Args("--no-tui=true"))); Assert.Throws(() => CliLaunchOptions.Parse(["client"])); } @@ -112,7 +114,7 @@ public void RejectsMissingRequiredOptionsAndUnknownFlags() { [Fact] public void RejectsConflictingModVersionsAndDuplicateScalarOptions() { Assert.Throws(() => CliLaunchOptions.Parse(Args("--mod", "org/mod:v1", "--mod", "org/mod:v2"))); - Assert.Throws(() => CliLaunchOptions.Parse(Args("--map", "one", "--map", "two"))); + Assert.Throws(() => CliLaunchOptions.Parse(Args("--starting-map", "one", "--starting-map", "two"))); } [Fact] @@ -120,6 +122,9 @@ public void HelpDoesNotRequireInstallation() { Assert.True(CliLaunchOptions.Parse([]).Help); Assert.True(CliLaunchOptions.Parse(["--help"]).Help); Assert.True(CliLaunchOptions.Parse(["server", "--help"]).Help); + var help = CliLaunchOptions.Parse(["server", "--help"]).HelpText; + Assert.Contains("--starting-map", help); + Assert.DoesNotContain("--map", help); } [Fact] diff --git a/UnchainedLauncher.CLI.Tests/UnchainedLauncher.CLI.Tests.csproj b/UnchainedLauncher.CLI.Tests/UnchainedLauncher.CLI.Tests.csproj index e6e7a877..76a88a53 100644 --- a/UnchainedLauncher.CLI.Tests/UnchainedLauncher.CLI.Tests.csproj +++ b/UnchainedLauncher.CLI.Tests/UnchainedLauncher.CLI.Tests.csproj @@ -7,6 +7,7 @@ true + diff --git a/UnchainedLauncher.CLI.Tests/YamlConfigurationTests.cs b/UnchainedLauncher.CLI.Tests/YamlConfigurationTests.cs index 82797653..19678f28 100644 --- a/UnchainedLauncher.CLI.Tests/YamlConfigurationTests.cs +++ b/UnchainedLauncher.CLI.Tests/YamlConfigurationTests.cs @@ -347,7 +347,7 @@ public void AllServerAndDiscordFieldsMapToCoreAndRenderedArguments() { name: YAML Server description: Example MOTD password: private password - map: TO_Test + starting-map: TO_Test local-ip: 127.0.0.2 game-port: 10001 ping-port: 10002 @@ -415,6 +415,8 @@ public void AllServerAndDiscordFieldsMapToCoreAndRenderedArguments() { [InlineData("server: {game-port: 1.5}")] [InlineData("server: {headless: invalid}")] [InlineData("server: {typo: true}")] + [InlineData("server: {map: TO_Test}")] + [InlineData("server: {starting-map: ''}")] [InlineData("server: {censor-mode: invalid}")] [InlineData("server: {warmup-time: -1}")] [InlineData("server: {discord: {bot-token: secret}}")] diff --git a/UnchainedLauncher.CLI/CliLaunchOptions.cs b/UnchainedLauncher.CLI/CliLaunchOptions.cs index afcde283..86f7d0ea 100644 --- a/UnchainedLauncher.CLI/CliLaunchOptions.cs +++ b/UnchainedLauncher.CLI/CliLaunchOptions.cs @@ -155,7 +155,7 @@ public LanguageExt.Option ToDiscordOptions() { public ServerLaunchOptions ToServerLaunchOptions() => new( Settings.Headless, Settings.Name, Settings.Description, RegisterWithBackend: Settings.Register, Password: string.IsNullOrWhiteSpace(Settings.Password) ? None : Some(Settings.Password.Trim()), - Settings.Map, Settings.GamePort, BeaconPort: Settings.PingPort, Settings.QueryPort, Settings.RconPort, + Settings.StartingMap, Settings.GamePort, BeaconPort: Settings.PingPort, Settings.QueryPort, Settings.RconPort, FFATimeLimit: null, FFAScoreLimit: null, TDMTimeLimit: null, TDMTicketCount: null, PlayerBotCount: null, WarmupTime: null, LocalIp: Optional(Settings.LocalIp), ServerMods: Settings.ModActors ?? [], DiscordIntegration: ToDiscordOptions(), diff --git a/UnchainedLauncher.CLI/CommandLineOptions.cs b/UnchainedLauncher.CLI/CommandLineOptions.cs index 50e8cb04..ada516d6 100644 --- a/UnchainedLauncher.CLI/CommandLineOptions.cs +++ b/UnchainedLauncher.CLI/CommandLineOptions.cs @@ -117,7 +117,7 @@ public Definition(bool client) { var settings = serverConfig.Server!; Add("--name", "Server name.", settings.Name, value => settings.Name = value); Add("--description", "Description / MOTD.", settings.Description, value => settings.Description = value); - Add("--map", "Initial map.", settings.Map, value => settings.Map = value); + Add("--starting-map", "Initial map.", settings.StartingMap, value => settings.StartingMap = value); Add("--password", "Server password.", null, value => settings.Password = value); Add("--local-ip", "Override auto-detected local IPv4 (VPN/multi-NIC).", null, value => settings.LocalIp = value); Add("--game-port", "Game port.", settings.GamePort, value => settings.GamePort = value); diff --git a/UnchainedLauncher.CLI/LaunchConfiguration.cs b/UnchainedLauncher.CLI/LaunchConfiguration.cs index 4e69a09e..d719509e 100644 --- a/UnchainedLauncher.CLI/LaunchConfiguration.cs +++ b/UnchainedLauncher.CLI/LaunchConfiguration.cs @@ -75,7 +75,7 @@ public sealed class ServerSettings { public string Description { get; set; } = ""; public string? Password { get; set; } [Required] - public string Map { get; set; } = "FFA_Courtyard"; + public string StartingMap { get; set; } = "FFA_Courtyard"; public string? LocalIp { get; set; } [Range(1, 65535)] public int GamePort { get; set; } = 7777; diff --git a/UnchainedLauncher.CLI/README.md b/UnchainedLauncher.CLI/README.md index 2c0fb567..2725cfaa 100644 --- a/UnchainedLauncher.CLI/README.md +++ b/UnchainedLauncher.CLI/README.md @@ -26,10 +26,16 @@ dotnet run --project UnchainedLauncher.CLI -c Release -- server ` --platform Steam ` --saved-dir-suffix MyServer ` --name "My Unchained Server" ` - --map FFA_Courtyard + --starting-map FFA_Courtyard ` + --dll Chiv2-Community/UnchainedPlugin:v1.0.0-RC3 ` + --mod Chiv2-Community/Unchained-Mods:v0.1.0 ` + --mod Chiv2-Community/Chiv2Turbo:v0.0.4 ` + --server-mod /Game/Mods/AgMods/Chiv2Turbo/Chiv2Turbo.Chiv2Turbo_C ``` The path is the installation root containing `TBL`, not the Win64 directory. +The example pins registered releases; replace the core plugin pin with a registered +build supporting `--additional-pak-dir` before launching. The server is headless by default (`-nullrhi -unattended -nosound`); use `--headless false --no-desync-patch` to render it (the desync patch only supports headless servers). The plugin is installed @@ -166,7 +172,7 @@ server: ping-port: 3075 query-port: 7071 rcon-port: 9001 - map: FFA_Courtyard + starting-map: FFA_Courtyard mod-actors: [] # Explicit blueprint class paths to activate. desync-patch: true use-backend-banlist: true @@ -187,7 +193,7 @@ Common root options are `server-browser-backend`, `update-plugin` (default true) common list only for the matching command. The client schema currently supports `game-args`; shared installation, platform, plugin, and mod options remain at the root. -The server section supports name, description/MOTD, password, map, local IP, +The server section supports name, description/MOTD, password, `starting-map`, local IP, all four ports, `headless`, `register`, `mod-actors`, `desync-patch`, `use-backend-banlist`, and `censor-mode` (`none`, `standard`, `sex`, `zealous`). Desync patch and backend banlist **default to true in server mode**; set their @@ -200,6 +206,7 @@ preferring one with a default gateway; it falls back to another usable IPv4 or l Set `local-ip` only to override this choice (for example, with a VPN or multiple adapters). Time/score/ticket limits, player bot count, and warmup are **INI settings**, not part of either CLI schema; configure them in the selected saved profile. +Use `starting-map` / `--starting-map`; the former `map` / `--map` names are rejected. Before a server launch, the CLI initializes the selected profile's `Game.ini` with `bSavedHasAgreedToTOS=True` and `FirstLoadCompleted=True`, matching the GUI's startup defaults so first-run screens do not block unattended startup. Other existing INI @@ -235,14 +242,14 @@ saved-dir-suffix: MyHost servers: courtyard: name: Courtyard FFA - map: FFA_Courtyard + starting-map: FFA_Courtyard game-port: 7777 ping-port: 3075 query-port: 7071 rcon-port: 9001 arena: name: Arena FFA - map: FFA_Courtyard + starting-map: FFA_Courtyard game-port: 7778 ping-port: 3076 query-port: 7072 @@ -427,7 +434,7 @@ Run `server --help` for the full option list. Additional settings include: | --- | --- | | `--saved-dir-suffix` | client: `unchained`; server: `UnchainedCLI`; letters, digits, `_`, and `-` only | | `--name`, `--description`, `--password` | Name, description/MOTD, optional password | -| `--map` | `FFA_Courtyard` | +| `--starting-map` | `FFA_Courtyard` | | `--game-port` | `7777` | | `--ping-port` | `3075` | | `--query-port` | `7071` (A2S) | diff --git a/UnchainedLauncher.CLI/examples/server.yaml b/UnchainedLauncher.CLI/examples/server.yaml index 40777a21..b166a04d 100644 --- a/UnchainedLauncher.CLI/examples/server.yaml +++ b/UnchainedLauncher.CLI/examples/server.yaml @@ -6,11 +6,15 @@ saved-dir-suffix: MyServer mod-setup: registries: [] # Official GitHub registry is included automatically. # Additional entries: github:owner/repository, local:../registry, config:../registries.json - mods: [] # Exact coordinates: organization/mod-id:v1.2.3 - dlls: [] # Optional dll_db release coordinates; the required core plugin is always included. + mods: + - Chiv2-Community/Unchained-Mods:v0.1.0 + - Chiv2-Community/Chiv2Turbo:v0.0.4 + dlls: + - Chiv2-Community/UnchainedPlugin:v1.0.0-RC3 # Run with: UnchainedLauncher.CLI.exe server --config-path server.yaml # Requires UnchainedPlugin support for --additional-pak-dir. +# Replace the core plugin pin with a registered build supporting that option. # Paks/plugins live in %LOCALAPPDATA%/Chivalry 2/Saved_MyServer/unchained/. # Pak bytes are shared through the common verified cache and hard links. # A client block, if included, is ignored by the server command. @@ -24,7 +28,7 @@ server: name: My Unchained Server description: Example server password: '' - map: FFA_Courtyard + starting-map: FFA_Courtyard game-port: 7777 ping-port: 3075 query-port: 7071 @@ -32,7 +36,8 @@ server: headless: true # Rendering requires headless: false AND desync-patch: false. register: false # local-ip: 192.168.1.10 # Optional override; local IPv4 is auto-detected. - mod-actors: [] # Blueprint class paths, e.g. /Game/Mods/Example/BP_Example.BP_Example_C + mod-actors: + - /Game/Mods/AgMods/Chiv2Turbo/Chiv2Turbo.Chiv2Turbo_C desync-patch: true # Default true; explicitly set false to disable. use-backend-banlist: true # Default true; explicitly set false to disable. censor-mode: none # none, standard, sex, zealous diff --git a/UnchainedLauncher.CLI/examples/servers.yaml b/UnchainedLauncher.CLI/examples/servers.yaml index 50de340b..10aa197a 100644 --- a/UnchainedLauncher.CLI/examples/servers.yaml +++ b/UnchainedLauncher.CLI/examples/servers.yaml @@ -3,6 +3,7 @@ # Requires UnchainedPlugin support for --additional-pak-dir. # Active paks/plugins: %LOCALAPPDATA%/Chivalry 2/Saved_/unchained/. # Pak bytes are shared through the common content-addressed cache and hard links. +# Replace the core plugin pin with a registered build supporting --additional-pak-dir. chiv2-path: 'C:\Games\Chivalry 2' platform: Steam saved-dir-suffix: MyHost # Default profiles: MyHost-courtyard and MyHost-arena. @@ -13,24 +14,29 @@ no-tui: false game-args: [] mod-setup: registries: [] # Official registry is included automatically. - mods: [] - dlls: [] + mods: + - Chiv2-Community/Unchained-Mods:v0.1.0 + - Chiv2-Community/Chiv2Turbo:v0.0.4 + dlls: + - Chiv2-Community/UnchainedPlugin:v1.0.0-RC3 servers: courtyard: name: Courtyard FFA - map: FFA_Courtyard + starting-map: FFA_Courtyard game-port: 7777 ping-port: 3075 query-port: 7071 rcon-port: 9001 - mod-actors: [] + mod-actors: + - /Game/Mods/AgMods/Chiv2Turbo/Chiv2Turbo.Chiv2Turbo_C arena: name: Arena FFA - map: FFA_Courtyard # Replace with an installed map's travel name. + starting-map: FFA_Courtyard # Replace with an installed map's travel name. saved-dir-suffix: ArenaProfile # Optional explicit per-server profile. game-port: 7778 ping-port: 3076 query-port: 7072 rcon-port: 9002 - mod-actors: [] + mod-actors: + - /Game/Mods/AgMods/Chiv2Turbo/Chiv2Turbo.Chiv2Turbo_C From 079609fd5453424f56886dc52966b8e7e1affb62 Mon Sep 17 00:00:00 2001 From: Jacob Barber Date: Fri, 16 Oct 2026 13:47:51 -0500 Subject: [PATCH 27/58] Add typed per-server INI overrides and independent mod selections --- StructuredINI/StructuredINIWriter.cs | 6 + .../ArgumentBoundaryTests.cs | 20 ++ .../IniOverrideTests.cs | 185 ++++++++++++++++++ .../RegistryAndReleaseTests.cs | 10 +- .../ServerGroupTests.cs | 26 ++- .../YamlConfigurationTests.cs | 21 +- UnchainedLauncher.CLI/CliLaunchOptions.cs | 32 +-- UnchainedLauncher.CLI/CommandLineOptions.cs | 17 +- UnchainedLauncher.CLI/IniOverrides.cs | 174 ++++++++++++++++ UnchainedLauncher.CLI/LaunchConfiguration.cs | 9 +- UnchainedLauncher.CLI/README.md | 86 ++++++-- UnchainedLauncher.CLI/ServerGroupOptions.cs | 4 +- UnchainedLauncher.CLI/ServerLauncher.cs | 1 + UnchainedLauncher.CLI/examples/server.yaml | 30 +-- UnchainedLauncher.CLI/examples/servers.yaml | 36 +++- .../Processes/Chivalry/IChivalry2Launcher.cs | 4 + 16 files changed, 582 insertions(+), 79 deletions(-) create mode 100644 UnchainedLauncher.CLI.Tests/IniOverrideTests.cs create mode 100644 UnchainedLauncher.CLI/IniOverrides.cs diff --git a/StructuredINI/StructuredINIWriter.cs b/StructuredINI/StructuredINIWriter.cs index 2ff74688..b19b832b 100644 --- a/StructuredINI/StructuredINIWriter.cs +++ b/StructuredINI/StructuredINIWriter.cs @@ -8,6 +8,12 @@ public class StructuredINIWriter { private readonly List _sectionOrder = new(); private readonly Dictionary _bufferedSections = new(StringComparer.OrdinalIgnoreCase); + // Buffer only caller-selected keys; WriteOut preserves all other keys. + public void BufferSection(string sectionName, IEnumerable> entries) { + if (!_bufferedSections.ContainsKey(sectionName)) _sectionOrder.Add(sectionName); + _bufferedSections[sectionName] = $"[{sectionName}]\n" + string.Join("\n", entries.Select(entry => $"{entry.Key}={entry.Value}")); + } + public static bool Save(string path, T t) { var writer = new StructuredINIWriter(); return writer.BufferWrite(t) && writer.WriteOut(path); diff --git a/UnchainedLauncher.CLI.Tests/ArgumentBoundaryTests.cs b/UnchainedLauncher.CLI.Tests/ArgumentBoundaryTests.cs index 037ffed7..59f58825 100644 --- a/UnchainedLauncher.CLI.Tests/ArgumentBoundaryTests.cs +++ b/UnchainedLauncher.CLI.Tests/ArgumentBoundaryTests.cs @@ -77,6 +77,26 @@ public async Task PrivatePakDirectoryWithSpacesReachesProcessAsOneArgument() { Assert.Equal(expected, await CaptureLaunch(options.ToLaunchOptions())); } + [Theory] + [InlineData(" SomeStructuredThing(foo, bar, 10, baz, false) ")] + [InlineData("quoted \"value\" and trailing slash\\")] + [InlineData("line\nbreak\tand & | > shell text")] + public async Task RawIniOverridesPreserveExactContentsAsOneArgument(string value) { + var path = Path.Combine(_directory, "raw.yaml"); + File.WriteAllText(path, $"chiv2-path: {JsonSerializer.Serialize(_directory)}\nplatform: Steam\nserver:\n" + + " local-ip: 127.0.0.1\n ini:\n Game:\n /Script/TBL.TBLGameMode:\n" + + $" Custom: {{type: raw, value: {JsonSerializer.Serialize(value)}}}\n"); + var options = CliLaunchOptions.Parse(["server", "--config-path", path]); + var expected = new List { "TBL", "-server", "--additional-pak-dir", options.InstancePaths.Paks }; + expected.AddRange(FirstRunArguments); + expected.Add("-ini:Game:[/Script/TBL.TBLGameMode]:Custom=" + value); + expected.AddRange(["-saveddirsuffix=UnchainedCLI", "-unchained"]); + expected.AddRange(DefaultServerArguments()); + expected.AddRange(["--server-browser-backend", "https://servers.polehammer.net"]); + Assert.Equal(expected, await CaptureControl(expected)); + Assert.Equal(expected, await CaptureLaunch(options.ToLaunchOptions())); + } + private CliLaunchOptions Parse(string command, bool yaml, string[] arguments, string? serverValue = null) { if (!yaml) return CliLaunchOptions.Parse([command, "--chivalry2-path", _directory, "--platform", "Steam", .. command == "server" ? new[] { "--local-ip", "127.0.0.1" } : [], diff --git a/UnchainedLauncher.CLI.Tests/IniOverrideTests.cs b/UnchainedLauncher.CLI.Tests/IniOverrideTests.cs new file mode 100644 index 00000000..02c2e095 --- /dev/null +++ b/UnchainedLauncher.CLI.Tests/IniOverrideTests.cs @@ -0,0 +1,185 @@ +using System.Globalization; +using UnchainedLauncher.Core.Services.Processes.Chivalry; +using Xunit; + +namespace UnchainedLauncher.CLI.Tests; + +public sealed class IniOverrideTests : IDisposable { + private readonly string _directory = Path.Combine(Environment.GetEnvironmentVariable("TMPDIR") ?? Path.GetTempPath(), "ini-overrides-" + Guid.NewGuid().ToString("N")); + private const string Section = "/Script/TBL.TBLGameMode"; + + public IniOverrideTests() => Directory.CreateDirectory(_directory); + public void Dispose() => Directory.Delete(_directory, true); + + private ServerOptions Parse(string fields) { + var path = Path.Combine(_directory, "server.yaml"); + File.WriteAllText(path, "chiv2-path: .\nplatform: Steam\nserver:\n ini:\n Game:\n " + Section + ":\n" + + string.Join("\n", fields.Split('\n').Select(line => " " + line))); + return Assert.IsType(CommandLineOptions.Parse(["server", "--config-path", path])); + } + + [Fact] + public void InferredFieldsPatchOnlySelectedKeysAndReplaceLists() { + var options = Parse(""" + MapList: [FFA_Courtyard, FFA_Wardenglade] + BotBackfillEnabled: true + ClassLimits: + - Class: Poleman + ClassLimitPercent: 10 + - Class: Archer + ClassLimitPercent: 0 + """); + var path = Path.Combine(_directory, "Game.ini"); + File.WriteAllText(path, $"; preserve comment\n[{Section}]\nServerName=Original\nBotBackfillEnabled=False\n+MapList=Old\n.ClassLimits=(Class=Knight,ClassLimitPercent=25)\nCustom=keep-me\n[/Other]\nUntouched=yes\n"); + options.Ini.Apply(_directory); + var text = File.ReadAllText(path); + Assert.Contains("ServerName=Original", text); + Assert.Contains("Custom=keep-me", text); + Assert.Contains("Untouched=yes", text); + Assert.Contains("; preserve comment", text); + Assert.Contains("BotBackfillEnabled=True", text); + Assert.Contains("!MapList=ClearArray", text); + Assert.Contains(".MapList=FFA_Courtyard", text); + Assert.Contains(".MapList=FFA_Wardenglade", text); + Assert.Contains(".ClassLimits=(Class=Poleman,ClassLimitPercent=10)", text); + Assert.Contains(".ClassLimits=(Class=Archer,ClassLimitPercent=0)", text); + Assert.DoesNotContain("MapList=Old", text); + Assert.DoesNotContain("Class=Knight", text); + options.Ini.Apply(_directory); + Assert.Equal(text, File.ReadAllText(path)); + } + + [Fact] + public void ExplicitAndInferredWrappersAndPositionalParametersWork() { + var options = Parse(""" + MapList: {type: 'String[]', value: []} + BotBackfillEnabled: {value: false} + ClassLimits: {type: 'ClassLimit[]', value: [[Archer, 0.25]]} + Custom: {type: String, value: example} + Balance: {type: AutoBalance, value: [0, 64, 32]} + Callbacks: {type: CallbackList, value: [first, second]} + """); + options.Ini.Apply(_directory); + var text = File.ReadAllText(Path.Combine(_directory, "Game.ini")); + Assert.Contains("!MapList=ClearArray", text); + Assert.Contains("BotBackfillEnabled=False", text); + Assert.Contains(".ClassLimits=(Class=Archer,ClassLimitPercent=0.25)", text); + Assert.Contains("Custom=example", text); + Assert.Contains("Balance=(MinNumPlayers=0,MaxNumPlayers=64,AllowedNumPlayersDifference=32)", text); + Assert.Contains("Callbacks=(first, second)", text); + } + + [Fact] + public void RawIsNotParsedOrWrittenToProfile() { + var options = Parse("Unknown: {type: raw, value: ' SomeStructuredThing(foo, bar, 10, baz, false) '}"); + Assert.Equal(new UEINIParameter("Game", Section, "Unknown", " SomeStructuredThing(foo, bar, 10, baz, false) ").Rendered, + Assert.Single(options.Ini.RawArguments)); + Assert.Contains(Assert.Single(options.Ini.RawArguments), options.ToLaunchOptions().LaunchArgs); + options.Ini.Apply(_directory); + Assert.False(File.Exists(Path.Combine(_directory, "Game.ini"))); + } + + [Theory] + [InlineData("String", "example", "example")] + [InlineData("Boolean", "true", "True")] + [InlineData("Bool", "false", "False")] + [InlineData("Int32", "-12", "-12")] + [InlineData("Int", "123", "123")] + [InlineData("Single", "1.5", "1.5")] + [InlineData("Float", "1.25", "1.25")] + [InlineData("Double", "2.5", "2.5")] + [InlineData("Decimal", "0.25", "0.25")] + [InlineData("CharacterClass", "poleman", "Poleman")] + public void ExplicitPrimitiveAndEnumTypesUseMappedCodecs(string type, string value, string expected) { + Parse($"Unknown: {{type: {type}, value: {value}}}").Ini.Apply(_directory); + Assert.Contains("Unknown=" + expected, File.ReadAllText(Path.Combine(_directory, "Game.ini"))); + } + + [Theory] + [InlineData("Unknown: example", "Unknown field")] + [InlineData("Unknown: [a, b]", "supply { type:")] + [InlineData("Unknown: {value: example}", "Unknown field")] + [InlineData("Unknown: {type: raw, value: [a]}", "raw requires a string")] + [InlineData("Unknown: {type: SecretType, value: secret}", "Unsupported type")] + [InlineData("Unknown: {type: String}", "supply value")] + [InlineData("Unknown: {type: String, value: x, typo: y}", "Use only type and value")] + [InlineData("BotBackfillEnabled: nope", "Boolean")] + [InlineData("BotBackfillEnabled: {type: String, value: nope}", "requires type Boolean")] + [InlineData("BotBackfillLowBots: 1.5", "Int32")] + [InlineData("MapList: map", "require a list")] + [InlineData("MapList: [null]", "cannot be null")] + [InlineData("ClassLimits: [{Class: Invalid, ClassLimitPercent: 0}]", "CharacterClass")] + [InlineData("ClassLimits: [{Class: Archer}]", "Missing ClassLimit.ClassLimitPercent")] + [InlineData("ClassLimits: [{Class: Archer, ClassLimitPercent: 0, Typo: 1}]", "Invalid or duplicate member")] + [InlineData("ClassLimits: [[Archer]]", "requires a member mapping")] + [InlineData("Unknown: {type: String, value: \"a\\nb\"}", "line breaks")] + [InlineData("MinTimeBeforeStartingMatch: NaN", "finite")] + [InlineData("MapList: []\nmaplist: []", "Duplicate INI field")] + public void InvalidValuesFailBeforeFilesAreWritten(string fields, string error) { + var exception = Assert.Throws(() => Parse(fields)); + Assert.Contains(error, exception.Message); + Assert.False(File.Exists(Path.Combine(_directory, "Game.ini"))); + } + + [Fact] + public void InvariantNumbersAndNoSecretValuesInErrors() { + var previous = CultureInfo.CurrentCulture; + try { + CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo("fr-FR"); + var options = Parse("ClassLimits: [[Archer, 0.25]]"); + options.Ini.Apply(_directory); + Assert.Contains("ClassLimitPercent=0.25", File.ReadAllText(Path.Combine(_directory, "Game.ini"))); + var error = Assert.Throws(() => Parse("BotBackfillEnabled: secret-password")); + Assert.DoesNotContain("secret-password", error.Message); + } + finally { CultureInfo.CurrentCulture = previous; } + } + + [Theory] + [InlineData("ServerName: Custom")] + [InlineData("ServerName: {type: raw, value: Custom}")] + public void ExplicitServerNameIsNotShadowedByGeneratedName(string yaml) { + var options = Parse(yaml); + Assert.DoesNotContain(options.ToServerLaunchOptions().ToCLIArgs(), arg => arg is UEINIParameter { Key: "ServerName" }); + } + + [Fact] + public void ProfilePreparationUsesNormalizedSuffixAndAppliesOnlyRequestedFields() { + var path = Path.Combine(_directory, "launch.yaml"); + File.WriteAllText(path, """ + chiv2-path: . + platform: Steam + server: + saved-dir-suffix: My-Server + ini: + Engine: + /Script/OnlineSubsystemUtils.IpNetDriver: + NetServerMaxTickRate: 30 + ConnectionTimeout: 12.5 + Game: + /Script/TBL.TBLTitleScreen: + bSavedHasAgreedToTOS: false + /Script/TBL.TBLGameMode: + ServerName: INI Name + GameUserSettings: + CustomSection: + PluginOption: {type: Boolean, value: true} + """); + var options = Assert.IsType(CommandLineOptions.Parse(["server", "--config-path", path])); + File.WriteAllText(Path.Combine(_directory, "Engine.ini"), "[/Script/OnlineSubsystemUtils.IpNetDriver]\nMaxClientRate=12345\n"); + ServerLauncher.InitializeServerProfile(options, suffix => { + Assert.Equal("MyServer", suffix); + return _directory; + }); + var engine = File.ReadAllText(Path.Combine(_directory, "Engine.ini")); + Assert.Contains("MaxClientRate=12345", engine); + Assert.Contains("NetServerMaxTickRate=30", engine); + Assert.Contains("ConnectionTimeout=12.5", engine); + Assert.DoesNotContain("InitialConnectTimeout=", engine); + var game = File.ReadAllText(Path.Combine(_directory, "Game.ini")); + Assert.Contains("bSavedHasAgreedToTOS=False", game); + Assert.Contains("ServerName=INI Name", game); + Assert.DoesNotContain("bSavedHasAgreedToTOS=True", options.ToLaunchOptions().LaunchArgs); + Assert.Contains("PluginOption=True", File.ReadAllText(Path.Combine(_directory, "GameUserSettings.ini"))); + } +} \ No newline at end of file diff --git a/UnchainedLauncher.CLI.Tests/RegistryAndReleaseTests.cs b/UnchainedLauncher.CLI.Tests/RegistryAndReleaseTests.cs index a8b5e255..6afd5297 100644 --- a/UnchainedLauncher.CLI.Tests/RegistryAndReleaseTests.cs +++ b/UnchainedLauncher.CLI.Tests/RegistryAndReleaseTests.cs @@ -172,11 +172,11 @@ public async Task ProductionWiringInstallsManyModsAndForwardsActorsFromAnotherDi var options = CliLaunchOptions.Parse(args.ToArray()); if (useYaml) { string Quote(string value) => "'" + value.Replace("'", "''") + "'"; - var yaml = new List { "chiv2-path: " + Quote(install), "platform: Steam", "mod-setup:", " registries:" }; - yaml.AddRange(options.Shared.Registries.Select(r => " - " + Quote(r.Kind + ":" + r.Location))); - yaml.Add(" mods:"); - yaml.AddRange(options.Shared.Mods.Select(m => " - " + Quote($"{m.Org}/{m.ModuleName}:{m.Version}"))); - yaml.AddRange(["server:", " mod-actors:"]); + var yaml = new List { "chiv2-path: " + Quote(install), "platform: Steam", "server:", " mod-setup:", " registries:" }; + yaml.AddRange(options.Shared.Registries.Select(r => " - " + Quote(r.Kind + ":" + r.Location))); + yaml.Add(" mods:"); + yaml.AddRange(options.Shared.Mods.Select(m => " - " + Quote($"{m.Org}/{m.ModuleName}:{m.Version}"))); + yaml.Add(" mod-actors:"); yaml.AddRange(actors.Select(actor => " - " + Quote(actor))); var config = Path.Combine(_directory, "launch.yaml"); File.WriteAllLines(config, yaml); diff --git a/UnchainedLauncher.CLI.Tests/ServerGroupTests.cs b/UnchainedLauncher.CLI.Tests/ServerGroupTests.cs index a33da698..3cfe80c6 100644 --- a/UnchainedLauncher.CLI.Tests/ServerGroupTests.cs +++ b/UnchainedLauncher.CLI.Tests/ServerGroupTests.cs @@ -29,7 +29,7 @@ public sealed class ServerGroupTests : IDisposable { private CliCommandOptions Parse(string body, string command = "server", params string[] extra) { Directory.CreateDirectory(_root); var path = Path.Combine(_root, "servers.yaml"); - File.WriteAllText(path, "chiv2-path: ./game\nplatform: Steam\nno-tui: true\nsaved-dir-suffix: Host\n" + body); + File.WriteAllText(path, "chiv2-path: ./game\nplatform: Steam\nno-tui: true\n" + body); return CommandLineOptions.Parse([command, "--config-path", path, .. extra]); } @@ -46,7 +46,7 @@ public void DictionaryPreservesOrderSharedSettingsAndIsolatedProfiles() { var group = Group(); Assert.True(group.NoTui); Assert.Equal(["courtyard", "arena"], group.Servers.Select(entry => entry.Id)); - Assert.Equal(["Host-courtyard", "Host-arena"], group.Servers.Select(entry => entry.Options.Shared.SavedDirSuffix)); + Assert.Equal(["courtyard", "arena"], group.Servers.Select(entry => entry.Options.Shared.SavedDirSuffix)); Assert.All(group.Servers, entry => Assert.Equal(Path.Combine(_root, "game"), entry.Options.Shared.Chivalry2Path)); Assert.Equal(["Courtyard", "Arena"], group.Servers.Select(entry => entry.Options.Settings.Name)); Assert.Equal(["FFA_Courtyard", "TO_Stronghold"], group.Servers.Select(entry => entry.Options.ToServerLaunchOptions().Map)); @@ -58,7 +58,7 @@ public void DictionaryPreservesOrderSharedSettingsAndIsolatedProfiles() { [Fact] public void PerServerProfilesAndArgumentsRemainIndependent() { var group = Assert.IsType(Parse(Servers.Replace("name: Arena", "name: Arena\n saved-dir-suffix: ArenaProfile\n game-args: [arena-only]") - + "\ngame-args: [common]\nmod-setup: {registries: [], mods: [], dlls: []}")); + + "\ngame-args: [common]")); Assert.Equal("ArenaProfile", group.Servers[1].Options.Shared.SavedDirSuffix); Assert.Equal(["common"], group.Servers[0].Options.GameArguments); Assert.Equal(["common", "arena-only"], group.Servers[1].Options.GameArguments); @@ -75,9 +75,27 @@ public void PerServerProfilesAndArgumentsRemainIndependent() { [InlineData("servers: {one: {map: TO_Test}}")] [InlineData("servers: {one: {saved-dir-suffix: '../outside'}}")] [InlineData("servers: {one: {unsafe-very-dangerous-load-all-dlls: true}}")] - [InlineData("servers: {one: {mod-setup: {mods: []}}}")] + [InlineData("mod-setup: {mods: []}\nservers: {one: {}}")] + [InlineData("saved-dir-suffix: Host\nservers: {one: {}}")] public void InvalidGroupsFailBeforeLaunch(string yaml) => Assert.Throws(() => Parse(yaml)); + [Fact] + public void PerServerModsDllsRegistriesAndIniDoNotLeakAcrossServers() { + var group = Assert.IsType(Parse(Servers + .Replace("name: Courtyard", "name: Courtyard\n mod-setup: {registries: ['local:one'], mods: ['org/one:v1'], dlls: ['org/plugin:v1']}\n ini: {Game: {'/Script/TBL.TBLGameMode': {MapList: [FFA_Courtyard]}}}") + .Replace("name: Arena", "name: Arena\n mod-setup: {registries: ['local:two'], mods: ['org/two:v2'], dlls: []}"))); + var first = group.Servers[0].Options; + var second = group.Servers[1].Options; + Assert.Equal("one", Assert.Single(first.Shared.Mods).ModuleName); + Assert.Equal("two", Assert.Single(second.Shared.Mods).ModuleName); + Assert.Single(first.Shared.Dlls); + Assert.Empty(second.Shared.Dlls); + Assert.Equal(Path.Combine(_root, "one"), Assert.Single(first.Shared.Registries).Location); + Assert.Equal(Path.Combine(_root, "two"), Assert.Single(second.Shared.Registries).Location); + Assert.True(first.Ini.Contains("Game", "/Script/TBL.TBLGameMode", "MapList")); + Assert.False(second.Ini.Contains("Game", "/Script/TBL.TBLGameMode", "MapList")); + } + [Fact] public void DuplicatePortsAndCaseInsensitiveProfilesAreRejected() { Assert.Throws(() => Parse(Servers.Replace("7007", "7000"))); diff --git a/UnchainedLauncher.CLI.Tests/YamlConfigurationTests.cs b/UnchainedLauncher.CLI.Tests/YamlConfigurationTests.cs index 19678f28..3c60ea22 100644 --- a/UnchainedLauncher.CLI.Tests/YamlConfigurationTests.cs +++ b/UnchainedLauncher.CLI.Tests/YamlConfigurationTests.cs @@ -308,14 +308,15 @@ public void PathsAreRelativeToYamlFileWithoutChangingCwdAndDefaultRegistryRemain var cwd = Directory.GetCurrentDirectory(); Directory.CreateDirectory(Path.Combine(_directory, "local registry")); var options = Load(""" - saved-dir-suffix: Test_1 update-plugin: false allow-unstable-plugin: true no-tui: true - mod-setup: - registries: ['local:local registry', 'config:registries.json', 'github:owner/repo'] - mods: ['org/one:v1.2.3', 'org/two:v2.0.0'] - dlls: ['org/plugin:v1.0.0'] + server: + saved-dir-suffix: Test_1 + mod-setup: + registries: ['local:local registry', 'config:registries.json', 'github:owner/repo'] + mods: ['org/one:v1.2.3', 'org/two:v2.0.0'] + dlls: ['org/plugin:v1.0.0'] """); Assert.Equal(cwd, Directory.GetCurrentDirectory()); Assert.Equal(Path.Combine(_directory, "game installation"), options.Shared.Chivalry2Path); @@ -328,7 +329,7 @@ public void PathsAreRelativeToYamlFileWithoutChangingCwdAndDefaultRegistryRemain Assert.False(options.Shared.UpdatePlugin); Assert.True(options.Shared.AllowUnstablePlugin); Assert.True(Assert.IsType(options).NoTui); - var plain = Load("mod-setup: { registries: [], mods: [], dlls: [] }"); + var plain = Load("server: {mod-setup: { registries: [], mods: [], dlls: [] }}"); var official = Assert.Single(ServerLauncher.CreateRegistry(plain).ModRegistries); Assert.IsType(official); Assert.Equal("Chiv2-Community", ((GithubModRegistry)official).Organization); @@ -407,9 +408,11 @@ public void AllServerAndDiscordFieldsMapToCoreAndRenderedArguments() { [Theory] [InlineData("typo: value")] [InlineData("server: []")] - [InlineData("mod-setup: {registries: not-a-list}")] - [InlineData("mod-setup: {mods: [null]}")] - [InlineData("mod-setup: {mods: ['org/mod:v1', 'org/mod:v2']}")] + [InlineData("server: {mod-setup: {registries: not-a-list}}")] + [InlineData("server: {mod-setup: {mods: [null]}}")] + [InlineData("server: {mod-setup: {mods: ['org/mod:v1', 'org/mod:v2']}}")] + [InlineData("saved-dir-suffix: OldRootValue")] + [InlineData("mod-setup: {mods: []}")] [InlineData("server: {query-port: 7777}")] [InlineData("server: {game-port: 0}")] [InlineData("server: {game-port: 1.5}")] diff --git a/UnchainedLauncher.CLI/CliLaunchOptions.cs b/UnchainedLauncher.CLI/CliLaunchOptions.cs index 86f7d0ea..4c13485a 100644 --- a/UnchainedLauncher.CLI/CliLaunchOptions.cs +++ b/UnchainedLauncher.CLI/CliLaunchOptions.cs @@ -26,16 +26,16 @@ public sealed class SharedLaunchOptions { public IReadOnlyList GameArguments { get; } internal ProfileAssetPaths Assets { get; set; } - internal SharedLaunchOptions(LaunchConfiguration configuration, string directory, string? savedDirSuffix = null) { + internal SharedLaunchOptions(LaunchConfiguration configuration, string directory, string? savedDirSuffix = null, ServerSettings? server = null) { Chivalry2Path = Path.GetFullPath(configuration.Chiv2Path!, directory); - SavedDirSuffix = savedDirSuffix ?? configuration.SavedDirSuffix; + SavedDirSuffix = savedDirSuffix ?? server?.SavedDirSuffix ?? (configuration as ClientConfiguration)?.SavedDirSuffix ?? "UnchainedCLI"; Assets = new ProfileAssetPaths(SavedDirSuffix); Platform = configuration.Platform!.Value; Backend = configuration.ServerBrowserBackend; UpdatePlugin = configuration.UpdatePlugin; AllowUnstablePlugin = configuration.AllowUnstablePlugin; GameArguments = Entries(configuration.GameArgs, allowEmpty: true).ToArray(); - var setup = configuration.ModSetup ?? new ModSetupConfiguration(); + var setup = server?.ModSetup ?? (configuration as ClientConfiguration)?.ModSetup ?? new ModSetupConfiguration(); RegistryOptions = new RegistryOptions(setup.Registries, directory); Mods = Entries(setup.Mods).Select(ParseCoordinates).ToArray(); Dlls = Entries(setup.Dlls).Select(ParseCoordinates).ToArray(); @@ -81,12 +81,11 @@ public static CliLaunchOptions Parse(string[] args) => CommandLineOptions.Parse( ?? throw new ArgumentException("Expected a client or server launch command."); internal static CliLaunchOptions FromConfiguration(LaunchConfiguration configuration, string directory) { - var shared = new SharedLaunchOptions(configuration, directory); return configuration switch { ServerConfiguration server => new ServerOptions( - server.Server?.SavedDirSuffix is { } suffix ? new SharedLaunchOptions(configuration, directory, suffix) : shared, + new SharedLaunchOptions(configuration, directory, server: server.Server), server.Server ?? new ServerSettings(), server.NoTui), - ClientConfiguration client => new ClientOptions(shared, SharedLaunchOptions.Entries(client.Client?.GameArgs, allowEmpty: true)), + ClientConfiguration client => new ClientOptions(new SharedLaunchOptions(configuration, directory), SharedLaunchOptions.Entries(client.Client?.GameArgs, allowEmpty: true)), _ => throw new ArgumentException("Expected client or server configuration.") }; } @@ -118,6 +117,7 @@ internal ClientOptions(SharedLaunchOptions shared, IEnumerable arguments } public sealed class ServerOptions : CliLaunchOptions { + internal IniOverrides Ini { get; } public ServerSettings Settings { get; } public bool NoTui { get; } // Chivalry removes hyphens when choosing its Saved directory (the plugin log path does not). @@ -126,6 +126,7 @@ public sealed class ServerOptions : CliLaunchOptions { internal ServerOptions(SharedLaunchOptions shared, ServerSettings settings, bool noTui) : base(shared, SharedLaunchOptions.Entries(settings.GameArgs, allowEmpty: true)) { Settings = settings; + Ini = IniOverrides.Parse(settings.Ini); NoTui = noTui; if (GameSavedDirSuffix.Length == 0) throw new ArgumentException("saved-dir-suffix must contain a character other than a hyphen."); if (GameArguments.Any(argument => argument == "--additional-pak-dir" || argument.StartsWith("--additional-pak-dir=", StringComparison.Ordinal))) @@ -159,10 +160,17 @@ public LanguageExt.Option ToDiscordOptions() { FFATimeLimit: null, FFAScoreLimit: null, TDMTimeLimit: null, TDMTicketCount: null, PlayerBotCount: null, WarmupTime: null, LocalIp: Optional(Settings.LocalIp), ServerMods: Settings.ModActors ?? [], DiscordIntegration: ToDiscordOptions(), - Settings.DesyncPatch, Settings.UseBackendBanlist, Settings.CensorMode); - - public override LaunchOptions ToLaunchOptions() => BuildLaunchOptions( - ["TBL", "-server", - "-ini:Game:[/Script/TBL.TBLTitleScreen]:bSavedHasAgreedToTOS=True", - "-ini:Game:[/Script/TBL.TBLGameInstance]:FirstLoadCompleted=True"], Some(ToServerLaunchOptions())); + Settings.DesyncPatch, Settings.UseBackendBanlist, Settings.CensorMode) { + SuppressServerNameOverride = Ini.Contains("Game", "/Script/TBL.TBLGameMode", "ServerName") + }; + + public override LaunchOptions ToLaunchOptions() { + var prefix = new List { "TBL", "-server" }; + if (!Ini.Contains("Game", "/Script/TBL.TBLTitleScreen", "bSavedHasAgreedToTOS")) + prefix.Add("-ini:Game:[/Script/TBL.TBLTitleScreen]:bSavedHasAgreedToTOS=True"); + if (!Ini.Contains("Game", "/Script/TBL.TBLGameInstance", "FirstLoadCompleted")) + prefix.Add("-ini:Game:[/Script/TBL.TBLGameInstance]:FirstLoadCompleted=True"); + prefix.AddRange(Ini.RawArguments); + return BuildLaunchOptions(prefix, Some(ToServerLaunchOptions())); + } } \ No newline at end of file diff --git a/UnchainedLauncher.CLI/CommandLineOptions.cs b/UnchainedLauncher.CLI/CommandLineOptions.cs index ada516d6..39c4fdd8 100644 --- a/UnchainedLauncher.CLI/CommandLineOptions.cs +++ b/UnchainedLauncher.CLI/CommandLineOptions.cs @@ -104,15 +104,22 @@ public Definition(bool client) { }; Command.Options.Add(platform); _bindings.Add(result => _configuration.Platform = result.GetValue(platform)); - Add("--saved-dir-suffix", "Saved profile suffix.", _configuration.SavedDirSuffix, value => _configuration.SavedDirSuffix = value); + var clientConfig = _configuration as ClientConfiguration; + var serverSettings = (_configuration as ServerConfiguration)?.Server; + Add("--saved-dir-suffix", "Saved profile suffix.", clientConfig?.SavedDirSuffix ?? "UnchainedCLI", value => { + if (clientConfig is not null) clientConfig.SavedDirSuffix = value; + else serverSettings!.SavedDirSuffix = value; + }); Add("--server-browser-backend", "HTTP(S) server-browser backend URL.", _configuration.ServerBrowserBackend, value => _configuration.ServerBrowserBackend = value); Flag("--no-plugin-update", "Use the existing UnchainedPlugin.dll.", value => _configuration.UpdatePlugin = !value); Flag("--allow-unstable-plugin", "Allow prerelease plugin updates.", value => _configuration.AllowUnstablePlugin = value); - _configuration.ModSetup = new ModSetupConfiguration(); + var setup = new ModSetupConfiguration(); + if (clientConfig is not null) clientConfig.ModSetup = setup; + else serverSettings!.ModSetup = setup; var registries = RegistryOptions.AddOption(Command); - _bindings.Add(result => _configuration.ModSetup.Registries = [.. result.GetValue(registries) ?? []]); - Many("--mod", "Install organization/id:version (exact release tag).", value => _configuration.ModSetup.Mods = value); - Many("--dll", "Select a registry DLL release: organization/id:version (repeatable).", value => _configuration.ModSetup.Dlls = value); + _bindings.Add(result => setup.Registries = [.. result.GetValue(registries) ?? []]); + Many("--mod", "Install organization/id:version (exact release tag).", value => setup.Mods = value); + Many("--dll", "Select a registry DLL release: organization/id:version (exact release tag).", value => setup.Dlls = value); if (_configuration is ServerConfiguration serverConfig) { var settings = serverConfig.Server!; Add("--name", "Server name.", settings.Name, value => settings.Name = value); diff --git a/UnchainedLauncher.CLI/IniOverrides.cs b/UnchainedLauncher.CLI/IniOverrides.cs new file mode 100644 index 00000000..7105ed4a --- /dev/null +++ b/UnchainedLauncher.CLI/IniOverrides.cs @@ -0,0 +1,174 @@ +using System.Collections; +using System.Globalization; +using System.Reflection; +using System.Text.RegularExpressions; +using StructuredINI; +using StructuredINI.Codecs; +using UnchainedLauncher.Core.INIModels; +using UnchainedLauncher.Core.Services.Processes.Chivalry; + +namespace UnchainedLauncher.CLI; + +internal sealed class IniOverrides { + private sealed record Entry(string File, string Section, string Key, string[] Values, bool Array, bool Raw); + private readonly List _entries = []; + private static readonly Type[] ScalarTypes = [typeof(string), typeof(bool), typeof(int), typeof(float), typeof(double), typeof(decimal)]; + private static readonly Type[] MappedTypes = ScalarTypes.Concat(typeof(ClassLimit).Assembly.GetTypes() + .Where(type => type.IsDefined(typeof(DeriveCodecAttribute), false))).Append(typeof(CallbackList)).ToArray(); + + internal IEnumerable RawArguments => _entries.Where(entry => entry.Raw) + .Select(entry => new UEINIParameter(entry.File, entry.Section, entry.Key, entry.Values[0]).Rendered); + + internal bool Contains(string file, string section, string key) => _entries.Any(entry => + string.Equals(entry.File, file, StringComparison.OrdinalIgnoreCase) && + string.Equals(entry.Section, section, StringComparison.OrdinalIgnoreCase) && + string.Equals(entry.Key, key, StringComparison.OrdinalIgnoreCase)); + + internal static IniOverrides Parse(Dictionary>>? files) { + var result = new IniOverrides(); + if (files is null) return result; + var paths = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var (file, sections) in files) { + if (!Regex.IsMatch(file, @"\A[A-Za-z][A-Za-z0-9_]*\z")) + throw new ArgumentException("INI file names must be bare categories such as Game, without .ini or a path."); + if (sections is null) throw new ArgumentException($"ini.{file} must contain a section mapping."); + foreach (var (section, fields) in sections) { + ValidateName(section, "section"); + if (fields is null) throw new ArgumentException($"ini.{file}.{section} must contain a field mapping."); + foreach (var (key, node) in fields) { + ValidateName(key, "key"); + var path = $"ini.{file}.{section}.{key}"; + if (!paths.Add($"{file}\0{section}\0{key}")) throw new ArgumentException($"Duplicate INI field: {path} (names are case-insensitive)."); + try { result._entries.Add(ParseEntry(file, section, key, node)); } + catch (Exception ex) when (ex is ArgumentException or FormatException or OverflowException or TargetInvocationException) { + // Do not echo values: INI overrides may contain credentials. + throw new ArgumentException($"Invalid {path}: {ex.Message}"); + } + } + } + } + return result; + } + + private static Entry ParseEntry(string file, string section, string key, object? node) { + var knownType = FindKnownType(file, section, key); + var type = knownType; + var value = node; + if (node is IDictionary map && (map.Contains("type") || map.Contains("value"))) { + if (map.Keys.Cast().Any(name => name is not string text || text is not ("type" or "value")) || !map.Contains("value")) + throw new ArgumentException("Use only type and value in a typed override, and supply value."); + value = map["value"]; + if (map.Contains("type")) { + if (map["type"] is not string name) throw new ArgumentException("type must be a supported type name."); + if (name.Equals("raw", StringComparison.OrdinalIgnoreCase)) { + if (value is not string raw || raw.Contains('\0')) throw new ArgumentException("raw requires a string value without NUL characters."); + return new Entry(file, section, key, [raw], false, true); + } + type = ResolveType(name); + if (knownType is not null && type != knownType) + throw new ArgumentException($"This known field requires type {knownType.Name}, or raw."); + } + } + if (type is null) throw new ArgumentException("Unknown field; supply { type: String, value: ... } (or type: raw for an exact INI string)."); + if (type.IsArray) { + if (value is not IList list) throw new ArgumentException("Array fields require a list of values."); + return new Entry(file, section, key, list.Cast().Select(item => Encode(type.GetElementType()!, item)).ToArray(), true, false); + } + return new Entry(file, section, key, [Encode(type, value)], false, false); + } + + private static Type? FindKnownType(string file, string section, string key) { + var fileProperty = typeof(Chivalry2INI).GetProperties().FirstOrDefault(property => + property.Name.Equals(file, StringComparison.OrdinalIgnoreCase)); + var sectionType = fileProperty?.PropertyType.GetProperties().Select(property => property.PropertyType).FirstOrDefault(type => + string.Equals(type.GetCustomAttribute()?.SectionName, section, StringComparison.OrdinalIgnoreCase)); + return sectionType?.GetProperties().FirstOrDefault(property => + (property.GetCustomAttribute()?.KeyName ?? property.Name).Equals(key, StringComparison.OrdinalIgnoreCase))?.PropertyType; + } + + private static Type ResolveType(string name) { + if (name.EndsWith("[]", StringComparison.Ordinal)) { + var element = ResolveType(name[..^2]); + if (element.IsArray) throw new ArgumentException("Nested INI arrays are not supported."); + return element.MakeArrayType(); + } + if (name.Equals("Bool", StringComparison.OrdinalIgnoreCase)) return typeof(bool); + if (name.Equals("Int", StringComparison.OrdinalIgnoreCase)) return typeof(int); + if (name.Equals("Float", StringComparison.OrdinalIgnoreCase)) return typeof(float); + return MappedTypes.FirstOrDefault(type => type.Name.Equals(name, StringComparison.OrdinalIgnoreCase)) + ?? throw new ArgumentException("Unsupported type. Use String, Boolean, Int32, Single, Double, Decimal, CharacterClass, ClassLimit, AutoBalance, CallbackList, an array type such as String[], or raw."); + } + + private static string Encode(Type type, object? node) { + var value = ConvertValue(type, node); + var codec = type == typeof(CallbackList) ? new CallbackListCodec() : CodecRegistry.Get(type); + var encoded = (string)codec.GetType().GetMethod("Encode")!.Invoke(codec, [value])!; + if (encoded.IndexOfAny(['\r', '\n', '\0']) >= 0) + throw new ArgumentException("Typed INI values cannot contain line breaks or NUL characters."); + return encoded; + } + + private static object ConvertValue(Type type, object? node) { + if (node is null) throw new ArgumentException("INI values cannot be null."); + if (type == typeof(CallbackList)) { + if (node is not IList callbacks) throw new ArgumentException("CallbackList requires a list of strings."); + return new CallbackList(callbacks.Cast().Select(item => (string)ConvertValue(typeof(string), item)).ToArray()); + } + if (ScalarTypes.Contains(type) || type.IsEnum) { + if (node is IDictionary or IList) throw new ArgumentException($"Expected a scalar {type.Name} value."); + var text = Convert.ToString(node, CultureInfo.InvariantCulture)!; + if (type.IsEnum) { + if (!Enum.GetNames(type).Any(name => name.Equals(text, StringComparison.OrdinalIgnoreCase))) + throw new ArgumentException($"Expected a named {type.Name} value."); + return Enum.Parse(type, text, true); + } + try { + var codec = CodecRegistry.Get(type); + var value = codec.GetType().GetMethod("Decode")!.Invoke(codec, [text])!; + if (value is double d && !double.IsFinite(d) || value is float f && !float.IsFinite(f)) + throw new ArgumentException("Numbers must be finite."); + return value; + } + catch (TargetInvocationException) { throw new ArgumentException($"Expected a valid {type.Name} value."); } + } + var constructor = type.GetConstructors().OrderByDescending(ctor => ctor.GetParameters().Length).First(); + var parameters = constructor.GetParameters(); + object?[] args; + if (node is IDictionary fields) { + var values = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (DictionaryEntry field in fields) { + if (field.Key is not string name || !parameters.Any(parameter => parameter.Name!.Equals(name, StringComparison.OrdinalIgnoreCase)) || !values.TryAdd(name, field.Value)) + throw new ArgumentException($"Invalid or duplicate member of {type.Name}; expected {string.Join(", ", parameters.Select(parameter => parameter.Name))}."); + } + args = parameters.Select(parameter => values.TryGetValue(parameter.Name!, out var item) + ? ConvertValue(parameter.ParameterType, item) + : throw new ArgumentException($"Missing {type.Name}.{parameter.Name}.")).ToArray(); + } + else if (node is IList list && list.Count == parameters.Length) { + args = parameters.Select((parameter, index) => ConvertValue(parameter.ParameterType, list[index])).ToArray(); + } + else throw new ArgumentException($"{type.Name} requires a member mapping or a list of {parameters.Length} parameters."); + return constructor.Invoke(args); + } + + private static void ValidateName(string name, string kind) { + if (string.IsNullOrWhiteSpace(name) || name != name.Trim() || name.IndexOfAny(['[', ']', ':', '=', ',', '\r', '\n', '\0', '"']) >= 0 || + kind == "key" && "!+.-;#".Contains(name[0])) + throw new ArgumentException($"Invalid INI {kind} name."); + } + + internal void Apply(string directory) { + foreach (var file in _entries.Where(entry => !entry.Raw).GroupBy(entry => entry.File, StringComparer.OrdinalIgnoreCase)) { + var writer = new StructuredINIWriter(); + foreach (var section in file.GroupBy(entry => entry.Section, StringComparer.OrdinalIgnoreCase)) { + var lines = section.SelectMany(entry => entry.Array + ? new[] { KeyValuePair.Create("!" + entry.Key, "ClearArray") } + .Concat(entry.Values.Select(value => KeyValuePair.Create("." + entry.Key, value))) + : [KeyValuePair.Create(entry.Key, entry.Values[0])]); + writer.BufferSection(section.Key, lines); + } + if (!writer.WriteOut(Path.Combine(directory, file.Key + ".ini"))) + throw new IOException("Could not apply server INI overrides; refusing to launch."); + } + } +} \ No newline at end of file diff --git a/UnchainedLauncher.CLI/LaunchConfiguration.cs b/UnchainedLauncher.CLI/LaunchConfiguration.cs index d719509e..e9f4a35d 100644 --- a/UnchainedLauncher.CLI/LaunchConfiguration.cs +++ b/UnchainedLauncher.CLI/LaunchConfiguration.cs @@ -12,13 +12,10 @@ public abstract class LaunchConfiguration { [Required, EnumDataType(typeof(InstallationType))] public InstallationType? Platform { get; set; } - [Required] - public string SavedDirSuffix { get; set; } = "UnchainedCLI"; public string ServerBrowserBackend { get; set; } = "https://servers.polehammer.net"; public bool UpdatePlugin { get; set; } = true; public bool AllowUnstablePlugin { get; set; } public List? GameArgs { get; set; } = []; - public ModSetupConfiguration? ModSetup { get; set; } public virtual void Validate() { Validator.ValidateObject(this, new ValidationContext(this), validateAllProperties: true); @@ -50,7 +47,9 @@ public override void Validate() { } public sealed class ClientConfiguration : LaunchConfiguration { - public ClientConfiguration() { SavedDirSuffix = UnchainedLauncher.Core.Services.Mods.ProfileAssetPaths.SharedSuffix; } + [Required] + public string SavedDirSuffix { get; set; } = UnchainedLauncher.Core.Services.Mods.ProfileAssetPaths.SharedSuffix; + public ModSetupConfiguration? ModSetup { get; set; } public ClientSettings? Client { get; set; } [YamlConverter(typeof(IgnoredYamlNodeConverter))] public object? Server { get; set; } @@ -69,6 +68,8 @@ public sealed class ClientSettings { } public sealed class ServerSettings { + public ModSetupConfiguration? ModSetup { get; set; } + public Dictionary>>? Ini { get; set; } public string? SavedDirSuffix { get; set; } [Required] public string Name { get; set; } = "Unchained Server"; diff --git a/UnchainedLauncher.CLI/README.md b/UnchainedLauncher.CLI/README.md index 2725cfaa..c94f8dc9 100644 --- a/UnchainedLauncher.CLI/README.md +++ b/UnchainedLauncher.CLI/README.md @@ -65,13 +65,14 @@ UnchainedLauncher.CLI mod-query get Chiv2-Community/Unchained-Mods UnchainedLauncher.CLI mod-query get organization/mod-id --version v1.2.3 UnchainedLauncher.CLI mod-query versions organization/mod-id UnchainedLauncher.CLI mod-query list --add-registry local:./registry -UnchainedLauncher.CLI mod-query get organization/mod-id --config-path ./server.yaml +UnchainedLauncher.CLI mod-query get organization/mod-id --config-path ./registry-query.yaml ``` The official registry is always included. Repeat `--add-registry` to include GitHub, local, or Core JSON-config registries, using the same formats as launch -commands. `--config-path` reads `mod-setup.registries` from an existing launch -YAML file instead of the `--add-registry` arguments. YAML-relative paths resolve +commands. For queries, `--config-path` reads root `mod-setup.registries` from a +query or client YAML file instead of the `--add-registry` arguments. It does not +select or merge the per-server registries in server YAML. YAML-relative paths resolve relative to that file. Only registry configuration is used: installation, platform, client/server settings, mod selections, and plugin policies are not required or applied. Query operands and `--version` remain command-line inputs. @@ -160,12 +161,12 @@ Put reusable launcher settings in YAML instead. Arguments after `--`, including ```yaml chiv2-path: 'C:\Games\Chivalry 2' platform: Steam -saved-dir-suffix: MyServer -mod-setup: - registries: [] # Official registry remains enabled, even with an empty list. - mods: [] # organization/mod-id:exact-release-tag - dlls: [] # Optional dll_db coordinates: organization/id:version. server: + saved-dir-suffix: MyServer + mod-setup: + registries: [] # Official registry remains enabled, even with an empty list. + mods: [] # organization/mod-id:exact-release-tag + dlls: [] # Optional dll_db coordinates: organization/id:version. name: My Server description: My server description game-port: 7777 @@ -191,7 +192,9 @@ Common root options are `server-browser-backend`, `update-plugin` (default true) `allow-unstable-plugin` (default false), `no-tui` (default false), and `game-args` (list of strings). `server.game-args` or `client.game-args` is appended to the common list only for the matching command. The client schema currently supports -`game-args`; shared installation, platform, plugin, and mod options remain at the root. +`game-args`; client installation, platform, plugin, mod, and saved-profile options remain at the root. +For servers, `mod-setup` and `saved-dir-suffix` belong inside `server` or each +`servers` entry, never at the root. A single unnamed server defaults to `UnchainedCLI`. The server section supports name, description/MOTD, password, `starting-map`, local IP, all four ports, `headless`, `register`, `mod-actors`, `desync-patch`, @@ -205,7 +208,7 @@ Local IPv4 is automatically detected from active non-loopback/non-tunnel interfa preferring one with a default gateway; it falls back to another usable IPv4 or loopback. Set `local-ip` only to override this choice (for example, with a VPN or multiple adapters). Time/score/ticket limits, player bot count, and warmup are **INI settings**, not -part of either CLI schema; configure them in the selected saved profile. +dedicated launcher flags; configure them in the selected saved profile or `server.ini`. Use `starting-map` / `--starting-map`; the former `map` / `--map` names are rejected. Before a server launch, the CLI initializes the selected profile's `Game.ini` with `bSavedHasAgreedToTOS=True` and `FirstLoadCompleted=True`, matching the GUI's startup @@ -214,6 +217,56 @@ settings are preserved. Client launches do not initialize or rewrite INI files. Chivalry removes hyphens from the suffix in its `Saved_` directory; the CLI initializes that directory. Plugin log paths retain the original suffix. +### INI overrides + +Nest `ini` under `server` or a named `servers` entry. Its shape is +`file category -> section -> field -> value`: + +```yaml +server: + ini: + Game: + /Script/TBL.TBLGameMode: + MapList: [FFA_Courtyard, FFA_Wardenglade] + BotBackfillEnabled: true + ClassLimits: + - Class: Poleman + ClassLimitPercent: 10 + - Class: Archer + ClassLimitPercent: 0 + SomeUnknownOption: + type: String + value: example + SomeOtherUnknownOption: + type: raw + value: SomeStructuredThing(foo, bar, 10, baz, false) +``` + +- Known fields infer their type from Core's INI models. They also accept + `{value: ...}` or `{type: Boolean, value: true}`. An explicit known-field type + must match its definition, unless it is `raw`. +- Unknown fields require both `type` and `value`; a bare value fails with instructions. +- Types are case-insensitive: `String`, `Boolean` (`Bool`), `Int32` (`Int`), + `Single` (`Float`), `Double`, `Decimal`, `CharacterClass`, `ClassLimit`, + `AutoBalance`, and `CallbackList`. Append `[]` for an array, for example + `{type: 'String[]', value: [one, two]}`. Quote array type names in YAML flow mappings. +- Records accept named members or positional lists in constructor order: + `ClassLimit` uses `[Class, ClassLimitPercent]`; `AutoBalance` uses + `[MinNumPlayers, MaxNumPlayers, AllowedNumPlayersDifference]`. + `CallbackList` accepts a list of strings. Numbers use invariant decimal notation; + enum values must be named members. Values are not rescaled (10 stays 10). +- Typed values patch only selected keys in the server's saved-profile INI files. + Arrays replace that field's existing entries; `[]` explicitly clears it. + Other keys and sections are preserved. These changes persist after the server exits; + omitting a field on a later launch does not undo it. +- `raw` requires a string and bypasses type parsing and validation. Its exact value + is escaped for transport in one `-ini:` argument, not written to the profile. + It cannot override an array: Unreal's command-line INI overrides do not support arrays. +- File categories have no path or `.ini` extension (`Game`, `Engine`, etc.). + All overrides are validated before preparation. Explicit INI `ServerName` and + startup-screen fields take precedence over the launcher's generated defaults. + Forwarded `game-args` remain explicit user arguments; avoid conflicting INI overrides there. + Discord configuration is nested under the server: ```yaml @@ -238,7 +291,6 @@ Use a `servers` dictionary instead of the single `server` block: ```yaml chiv2-path: 'C:\Games\Chivalry 2' platform: Steam -saved-dir-suffix: MyHost servers: courtyard: name: Courtyard FFA @@ -261,11 +313,11 @@ See [the multi-server example](examples/servers.yaml). The existing `server` sch and individual CLI launch arguments still launch one server. - Keys are tab IDs: unique, case-insensitive letters, digits, underscores, and hyphens. -- Each entry accepts the same fields as `server`, including its own actors, - arguments, password, and Discord settings. Installation, platform, registry, - pak/DLL selections, and common arguments remain shared at the root; files are - prepared separately in each instance directory. -- Profiles default to `-`. Set `saved-dir-suffix` inside an +- Each entry accepts the same fields as `server`, including its own `mod-setup`, + `ini`, actors, arguments, password, and Discord settings. Installation, platform, + plugin-update policy, and common arguments remain shared at the root. + Registries and pak/DLL selections are independent; verified bytes share the cache. +- Profiles default to the server key (`courtyard`, `arena`). Set `saved-dir-suffix` inside an entry to override it. Profile names must be distinct, ignoring case and hyphens. - Active paks and DLLs live under `%LOCALAPPDATA%/Chivalry 2/Saved_/unchained/{paks,plugins}`. @@ -376,7 +428,7 @@ preparation remains sequential because some Core operations change CWD and metad GUI and CLI share DLL registry validation, managed storage, caching, and injection. The CLI selects UnchainedPlugin from `dll_db` for each launch; GUI selections stay in the Plugin Manager's saved profile. -`--dll organization/id:version` (or YAML `mod-setup.dlls`) additionally selects +`--dll organization/id:version` (or each server's YAML `mod-setup.dlls`) additionally selects registry releases in CLI launches. Plugin dependencies are resolved before their dependents; target mismatches, missing releases, cycles, and conflicting versions fail preparation. Pak dependencies enter the shared mod manager. diff --git a/UnchainedLauncher.CLI/ServerGroupOptions.cs b/UnchainedLauncher.CLI/ServerGroupOptions.cs index 62cc3bcc..c3d2f3c8 100644 --- a/UnchainedLauncher.CLI/ServerGroupOptions.cs +++ b/UnchainedLauncher.CLI/ServerGroupOptions.cs @@ -21,8 +21,8 @@ internal static ServerGroupOptions Create(ServerConfiguration configuration, str foreach (var (id, settings) in configuration.Servers!) { if (!Regex.IsMatch(id, @"\A[A-Za-z0-9_-]+\z") || !ids.Add(id)) throw new ArgumentException("Server IDs must be unique (case-insensitive) and contain only letters, digits, underscores, and hyphens."); - var suffix = settings!.SavedDirSuffix ?? $"{configuration.SavedDirSuffix}-{id}"; - var options = new ServerOptions(new SharedLaunchOptions(configuration, directory, suffix), settings, configuration.NoTui); + var suffix = settings!.SavedDirSuffix ?? id; + var options = new ServerOptions(new SharedLaunchOptions(configuration, directory, suffix, settings), settings, configuration.NoTui); if (!profiles.Add(options.GameSavedDirSuffix)) throw new ArgumentException("Each server must use a distinct saved-dir-suffix (case-insensitive, ignoring hyphens)."); foreach (var port in new[] { settings.GamePort, settings.PingPort, settings.QueryPort, settings.RconPort }) { diff --git a/UnchainedLauncher.CLI/ServerLauncher.cs b/UnchainedLauncher.CLI/ServerLauncher.cs index e5acd22c..644c8e67 100644 --- a/UnchainedLauncher.CLI/ServerLauncher.cs +++ b/UnchainedLauncher.CLI/ServerLauncher.cs @@ -108,6 +108,7 @@ internal static void InitializeServerProfile(CliLaunchOptions request, Func/unchained/. # Pak bytes are shared through the common content-addressed cache and hard links. # Replace the core plugin pin with a registered build supporting --additional-pak-dir. chiv2-path: 'C:\Games\Chivalry 2' platform: Steam -saved-dir-suffix: MyHost # Default profiles: MyHost-courtyard and MyHost-arena. server-browser-backend: https://servers.polehammer.net update-plugin: true allow-unstable-plugin: false no-tui: false game-args: [] -mod-setup: - registries: [] # Official registry is included automatically. - mods: - - Chiv2-Community/Unchained-Mods:v0.1.0 - - Chiv2-Community/Chiv2Turbo:v0.0.4 - dlls: - - Chiv2-Community/UnchainedPlugin:v1.0.0-RC3 - servers: courtyard: + # saved-dir-suffix defaults to courtyard (the server key). + mod-setup: + registries: [] # Official registry is included automatically. + mods: + - Chiv2-Community/Unchained-Mods:v0.1.0 + - Chiv2-Community/Chiv2Turbo:v0.0.4 + dlls: + - Chiv2-Community/UnchainedPlugin:v1.0.0-RC3 + ini: + Game: + /Script/TBL.TBLGameMode: + MapList: [FFA_Courtyard, FFA_Wardenglade] + BotBackfillEnabled: true + ClassLimits: + - Class: Poleman + ClassLimitPercent: 10 + - Class: Archer + ClassLimitPercent: 0 name: Courtyard FFA starting-map: FFA_Courtyard game-port: 7777 @@ -31,6 +40,13 @@ servers: mod-actors: - /Game/Mods/AgMods/Chiv2Turbo/Chiv2Turbo.Chiv2Turbo_C arena: + mod-setup: + registries: [] + mods: + - Chiv2-Community/Unchained-Mods:v0.1.0 + - Chiv2-Community/Chiv2Turbo:v0.0.4 + dlls: + - Chiv2-Community/UnchainedPlugin:v1.0.0-RC3 name: Arena FFA starting-map: FFA_Courtyard # Replace with an installed map's travel name. saved-dir-suffix: ArenaProfile # Optional explicit per-server profile. diff --git a/UnchainedLauncher.Core/Services/Processes/Chivalry/IChivalry2Launcher.cs b/UnchainedLauncher.Core/Services/Processes/Chivalry/IChivalry2Launcher.cs index 373fc41a..1bb2f456 100644 --- a/UnchainedLauncher.Core/Services/Processes/Chivalry/IChivalry2Launcher.cs +++ b/UnchainedLauncher.Core/Services/Processes/Chivalry/IChivalry2Launcher.cs @@ -123,6 +123,8 @@ public record ServerLaunchOptions( bool UseBackendBanlist, CensorArg CensorMode ) { + public bool SuppressServerNameOverride { get; init; } + public IReadOnlyList ToCLIArgs() { var args = new List() { new UEINIParameter("Game", "/Script/TBL.TBLGameMode", "ServerName", Name), @@ -136,6 +138,8 @@ public IReadOnlyList ToCLIArgs() { new Parameter("--censor-mode", CensorMode.ToString().ToLower()) }; + if (SuppressServerNameOverride) args.RemoveAll(arg => arg is UEINIParameter { Key: "ServerName" }); + if (Headless) { args.Add(new Flag("-nullrhi")); args.Add(new Flag("-unattended")); From 207685a7becd0ca97f4c6b2557b5ca0bcc90fa83 Mon Sep 17 00:00:00 2001 From: Jacoby6000 <2672832+Jacoby6000@users.noreply.github.com> Date: Fri, 16 Oct 2026 18:49:59 +0000 Subject: [PATCH 28/58] [GHA] Format Source --- UnchainedLauncher.CLI/IniOverrides.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/UnchainedLauncher.CLI/IniOverrides.cs b/UnchainedLauncher.CLI/IniOverrides.cs index 7105ed4a..1f03277d 100644 --- a/UnchainedLauncher.CLI/IniOverrides.cs +++ b/UnchainedLauncher.CLI/IniOverrides.cs @@ -1,9 +1,9 @@ +using StructuredINI; +using StructuredINI.Codecs; using System.Collections; using System.Globalization; using System.Reflection; using System.Text.RegularExpressions; -using StructuredINI; -using StructuredINI.Codecs; using UnchainedLauncher.Core.INIModels; using UnchainedLauncher.Core.Services.Processes.Chivalry; From 15e93e22791ee2456f38fea2d069c6e2a5ca85c0 Mon Sep 17 00:00:00 2001 From: Jacob Barber Date: Mon, 19 Oct 2026 11:28:26 -0500 Subject: [PATCH 29/58] Add YAML map rotations and reusable map profiles --- .../IniOverrideTests.cs | 23 +-- .../MapRotationTests.cs | 179 ++++++++++++++++++ .../ServerGroupTests.cs | 6 +- UnchainedLauncher.CLI/CliLaunchOptions.cs | 2 + UnchainedLauncher.CLI/IniOverrides.cs | 2 + UnchainedLauncher.CLI/LaunchConfiguration.cs | 26 ++- UnchainedLauncher.CLI/MapRotation.cs | 69 +++++++ UnchainedLauncher.CLI/README.md | 40 +++- .../RegistryQueryConfiguration.cs | 1 + UnchainedLauncher.CLI/ServerLauncher.cs | 1 + UnchainedLauncher.CLI/YamlConfiguration.cs | 1 + UnchainedLauncher.CLI/examples/server.yaml | 7 +- UnchainedLauncher.CLI/examples/servers.yaml | 10 +- 13 files changed, 347 insertions(+), 20 deletions(-) create mode 100644 UnchainedLauncher.CLI.Tests/MapRotationTests.cs create mode 100644 UnchainedLauncher.CLI/MapRotation.cs diff --git a/UnchainedLauncher.CLI.Tests/IniOverrideTests.cs b/UnchainedLauncher.CLI.Tests/IniOverrideTests.cs index 02c2e095..291dd9db 100644 --- a/UnchainedLauncher.CLI.Tests/IniOverrideTests.cs +++ b/UnchainedLauncher.CLI.Tests/IniOverrideTests.cs @@ -21,7 +21,7 @@ private ServerOptions Parse(string fields) { [Fact] public void InferredFieldsPatchOnlySelectedKeysAndReplaceLists() { var options = Parse(""" - MapList: [FFA_Courtyard, FFA_Wardenglade] + CustomMaps: {type: 'String[]', value: [FFA_Courtyard, FFA_Wardenglade]} BotBackfillEnabled: true ClassLimits: - Class: Poleman @@ -30,7 +30,7 @@ public void InferredFieldsPatchOnlySelectedKeysAndReplaceLists() { ClassLimitPercent: 0 """); var path = Path.Combine(_directory, "Game.ini"); - File.WriteAllText(path, $"; preserve comment\n[{Section}]\nServerName=Original\nBotBackfillEnabled=False\n+MapList=Old\n.ClassLimits=(Class=Knight,ClassLimitPercent=25)\nCustom=keep-me\n[/Other]\nUntouched=yes\n"); + File.WriteAllText(path, $"; preserve comment\n[{Section}]\nServerName=Original\nBotBackfillEnabled=False\n+CustomMaps=Old\n+MapList=KeepMap\n.ClassLimits=(Class=Knight,ClassLimitPercent=25)\nCustom=keep-me\n[/Other]\nUntouched=yes\n"); options.Ini.Apply(_directory); var text = File.ReadAllText(path); Assert.Contains("ServerName=Original", text); @@ -38,12 +38,13 @@ public void InferredFieldsPatchOnlySelectedKeysAndReplaceLists() { Assert.Contains("Untouched=yes", text); Assert.Contains("; preserve comment", text); Assert.Contains("BotBackfillEnabled=True", text); - Assert.Contains("!MapList=ClearArray", text); - Assert.Contains(".MapList=FFA_Courtyard", text); - Assert.Contains(".MapList=FFA_Wardenglade", text); + Assert.Contains("!CustomMaps=ClearArray", text); + Assert.Contains(".CustomMaps=FFA_Courtyard", text); + Assert.Contains(".CustomMaps=FFA_Wardenglade", text); + Assert.Contains("+MapList=KeepMap", text); Assert.Contains(".ClassLimits=(Class=Poleman,ClassLimitPercent=10)", text); Assert.Contains(".ClassLimits=(Class=Archer,ClassLimitPercent=0)", text); - Assert.DoesNotContain("MapList=Old", text); + Assert.DoesNotContain("CustomMaps=Old", text); Assert.DoesNotContain("Class=Knight", text); options.Ini.Apply(_directory); Assert.Equal(text, File.ReadAllText(path)); @@ -52,7 +53,7 @@ public void InferredFieldsPatchOnlySelectedKeysAndReplaceLists() { [Fact] public void ExplicitAndInferredWrappersAndPositionalParametersWork() { var options = Parse(""" - MapList: {type: 'String[]', value: []} + CustomMaps: {type: 'String[]', value: []} BotBackfillEnabled: {value: false} ClassLimits: {type: 'ClassLimit[]', value: [[Archer, 0.25]]} Custom: {type: String, value: example} @@ -61,7 +62,7 @@ public void ExplicitAndInferredWrappersAndPositionalParametersWork() { """); options.Ini.Apply(_directory); var text = File.ReadAllText(Path.Combine(_directory, "Game.ini")); - Assert.Contains("!MapList=ClearArray", text); + Assert.Contains("!CustomMaps=ClearArray", text); Assert.Contains("BotBackfillEnabled=False", text); Assert.Contains(".ClassLimits=(Class=Archer,ClassLimitPercent=0.25)", text); Assert.Contains("Custom=example", text); @@ -106,15 +107,15 @@ public void ExplicitPrimitiveAndEnumTypesUseMappedCodecs(string type, string val [InlineData("BotBackfillEnabled: nope", "Boolean")] [InlineData("BotBackfillEnabled: {type: String, value: nope}", "requires type Boolean")] [InlineData("BotBackfillLowBots: 1.5", "Int32")] - [InlineData("MapList: map", "require a list")] - [InlineData("MapList: [null]", "cannot be null")] + [InlineData("CustomMaps: {type: 'String[]', value: map}", "require a list")] + [InlineData("CustomMaps: {type: 'String[]', value: [null]}", "cannot be null")] [InlineData("ClassLimits: [{Class: Invalid, ClassLimitPercent: 0}]", "CharacterClass")] [InlineData("ClassLimits: [{Class: Archer}]", "Missing ClassLimit.ClassLimitPercent")] [InlineData("ClassLimits: [{Class: Archer, ClassLimitPercent: 0, Typo: 1}]", "Invalid or duplicate member")] [InlineData("ClassLimits: [[Archer]]", "requires a member mapping")] [InlineData("Unknown: {type: String, value: \"a\\nb\"}", "line breaks")] [InlineData("MinTimeBeforeStartingMatch: NaN", "finite")] - [InlineData("MapList: []\nmaplist: []", "Duplicate INI field")] + [InlineData("ClassLimits: []\nclasslimits: []", "Duplicate INI field")] public void InvalidValuesFailBeforeFilesAreWritten(string fields, string error) { var exception = Assert.Throws(() => Parse(fields)); Assert.Contains(error, exception.Message); diff --git a/UnchainedLauncher.CLI.Tests/MapRotationTests.cs b/UnchainedLauncher.CLI.Tests/MapRotationTests.cs new file mode 100644 index 00000000..e5120c30 --- /dev/null +++ b/UnchainedLauncher.CLI.Tests/MapRotationTests.cs @@ -0,0 +1,179 @@ +using Xunit; + +namespace UnchainedLauncher.CLI.Tests; + +public sealed class MapRotationTests : IDisposable { + private readonly string _root = Path.Combine(Environment.GetEnvironmentVariable("TMPDIR") ?? Path.GetTempPath(), "map-rotation-" + Guid.NewGuid().ToString("N")); + private const string Section = "/Script/TBL.TBLGameMode"; + + public MapRotationTests() => Directory.CreateDirectory(_root); + public void Dispose() => Directory.Delete(_root, true); + + private CliCommandOptions Parse(string yaml, string command = "server") { + var path = Path.Combine(_root, "server.yaml"); + File.WriteAllText(path, "chiv2-path: .\nplatform: Steam\n" + yaml); + return CommandLineOptions.Parse([command, "--config-path", path]); + } + + [Fact] + public void MixedReferencesAndInlineEntriesPreserveOrderAndDuplicates() { + var options = Assert.IsType(Parse(""" + server: + map-rotation: + - courtyard + - map-name: FFA_Wardenglade + - courtyard + map-profiles: + courtyard: + map-name: FFA_Courtyard + """)); + Assert.Equal(["FFA_Courtyard", "FFA_Wardenglade", "FFA_Courtyard"], options.Settings.Rotation!.Maps); + Assert.Equal(0, options.Settings.Rotation.StartIndex); + Assert.Equal("FFA_Courtyard", options.ToServerLaunchOptions().Map); + } + + [Fact] + public void ProfilesAreSharedButServerRotationsAndStartingMapsAreIndependent() { + var group = Assert.IsType(Parse(""" + servers: + first: + map-rotation: [courtyard, {map-name: FFA_Wardenglade}] + starting-map: ffa_wardenglade + second: + game-port: 7778 + ping-port: 3076 + query-port: 7072 + rcon-port: 9002 + map-rotation: [courtyard, {map-name: FFA_Bazaar}] + map-profiles: + courtyard: {map-name: FFA_Courtyard} + """)); + var first = group.Servers[0].Options; + var second = group.Servers[1].Options; + Assert.Equal(1, first.Settings.Rotation!.StartIndex); + Assert.Equal(0, second.Settings.Rotation!.StartIndex); + Assert.Equal("FFA_Wardenglade", first.ToServerLaunchOptions().Map); + Assert.Equal("FFA_Courtyard", second.ToServerLaunchOptions().Map); + foreach (var server in group.Servers) { + var directory = Path.Combine(_root, server.Id); + ServerLauncher.InitializeServerProfile(server.Options, _ => directory); + } + var firstIni = File.ReadAllText(Path.Combine(_root, "first", "Game.ini")); + var secondIni = File.ReadAllText(Path.Combine(_root, "second", "Game.ini")); + Assert.Contains(".MapList=FFA_Wardenglade", firstIni); + Assert.DoesNotContain("FFA_Bazaar", firstIni); + Assert.Contains("MapListIndex=1", firstIni); + Assert.Contains(".MapList=FFA_Bazaar", secondIni); + Assert.DoesNotContain("FFA_Wardenglade", secondIni); + Assert.Contains("MapListIndex=0", secondIni); + } + + [Fact] + public void RotationPatchesOnlyMapListAndIndexAndDoesNotAccumulateOnRelaunch() { + var options = Assert.IsType(Parse(""" + server: + map-rotation: + - map-name: FFA_Wardenglade + - map-name: FFA_Wardenglade + - map-name: /Game/Maps/Custom.Custom + """)); + Assert.Equal("FFA_Wardenglade", options.ToServerLaunchOptions().Map); + var path = Path.Combine(_root, "Game.ini"); + File.WriteAllText(path, $"; keep comment\n[{Section}]\n.MapList=Old\n+MapList=Other\nMapListIndex=9\nServerName=KeepName\nCustom=KeepValue\n[/Other]\nUntouched=yes\n"); + options.Settings.Rotation!.Apply(_root); + var text = File.ReadAllText(path); + Assert.Contains("; keep comment", text); + Assert.Contains("ServerName=KeepName", text); + Assert.Contains("Custom=KeepValue", text); + Assert.Contains("Untouched=yes", text); + Assert.DoesNotContain("MapList=Old", text); + Assert.DoesNotContain("MapList=Other", text); + Assert.Contains("!MapList=ClearArray", text); + Assert.Equal(2, text.Split('\n').Count(line => line.TrimEnd('\r') == ".MapList=FFA_Wardenglade")); + Assert.Contains(".MapList=/Game/Maps/Custom.Custom", text); + Assert.Contains("MapListIndex=0", text); + options.Settings.Rotation.Apply(_root); + Assert.Equal(text, File.ReadAllText(path)); + } + + [Fact] + public void OmittedRotationPreservesExistingMapListAndDefaultStartup() { + var options = Assert.IsType(Parse("server: {}")); + Assert.Null(options.Settings.Rotation); + Assert.Equal("FFA_Courtyard", options.ToServerLaunchOptions().Map); + var path = Path.Combine(_root, "Game.ini"); + File.WriteAllText(path, $"[{Section}]\nMapList=KeepMap\nMapListIndex=7\n"); + ServerLauncher.InitializeServerProfile(options, _ => _root); + var text = File.ReadAllText(path); + Assert.Contains("MapList=KeepMap", text); + Assert.Contains("MapListIndex=7", text); + } + + [Theory] + [InlineData("server: {map-rotation: []}")] + [InlineData("server: {map-rotation: [null]}")] + [InlineData("server: {map-rotation: [~]}")] + [InlineData("server: {map-rotation: [[]]}")] + [InlineData("server: {map-rotation: [{}]}")] + [InlineData("server: {map-rotation: [{map-name: null}]}")] + [InlineData("server: {map-rotation: [{map-name: ''}]}")] + [InlineData("server: {map-rotation: [{map-name: 'FFA_Courtyard?mods=Turbo'}]}")] + [InlineData("server: {map-rotation: [{map-name: 'map with spaces'}]}")] + [InlineData("server: {map-rotation: [{map-name: '../outside'}]}")] + [InlineData("server: {map-rotation: [{map-name: FFA_Courtyard, typo: true}]}")] + [InlineData("server: {map-rotation: [{map-name: FFA_Courtyard, ini: {}}]}")] + [InlineData("server: {map-rotation: [{map-name: FFA_Courtyard, mod-actors: []}]}")] + [InlineData("map-profiles: {unused: {}}")] + [InlineData("map-profiles: {unused: null}")] + [InlineData("map-profiles: {'': {map-name: FFA_Courtyard}}")] + [InlineData("map-profiles: {one: {map-name: FFA_Courtyard}, one: {map-name: FFA_Bazaar}}")] + [InlineData("map-profiles: {one: {map-name: FFA_Courtyard, mod-actors: []}}")] + [InlineData("map-profiles: {one: {map-name: FFA_Courtyard, ini: {}}}")] + [InlineData("map-profiles: {one: other}")] + [InlineData("server: {starting-map: FFA_Bazaar, map-rotation: [{map-name: FFA_Courtyard}]}")] + public void InvalidRotationsAndProfilesFailBeforePreparation(string yaml) { + Assert.Throws(() => Parse(yaml)); + Assert.False(File.Exists(Path.Combine(_root, "Game.ini"))); + } + + [Theory] + [InlineData("missing")] + [InlineData("FFA_Courtyard")] + [InlineData("COURTYARD")] + public void MissingReferencesExplainHowToFixThem(string reference) { + var error = Assert.Throws(() => Parse($"server: {{map-rotation: [{reference}]}}\nmap-profiles: {{courtyard: {{map-name: FFA_Courtyard}}}}")); + Assert.Contains("Unknown map-profile", error.Message); + Assert.Contains("map-profiles", error.Message); + Assert.Contains("inline map-name", error.Message); + } + + [Theory] + [InlineData("MapList: [FFA_Courtyard]")] + [InlineData("maplist: null")] + [InlineData("MapList: {type: 'String[]', value: [FFA_Courtyard]}")] + [InlineData("MapList: {type: raw, value: anything}")] + [InlineData("'+MapList': anything")] + [InlineData("'!MapList': anything")] + public void IniMapListAlwaysDirectsUsersToRotation(string field) { + var error = Assert.Throws(() => Parse($"server:\n ini:\n Game:\n {Section}:\n {field}")); + Assert.Contains("MapList", error.Message); + Assert.Contains("map-rotation", error.Message); + } + + [Fact] + public void RotationIndexCannotBeOverriddenByIni() { + var error = Assert.Throws(() => Parse($"server:\n map-rotation: [{{map-name: FFA_Courtyard}}]\n ini:\n Game:\n {Section}:\n MapListIndex: 1")); + Assert.Contains("starting-map", error.Message); + } + + [Theory] + [InlineData("client")] + [InlineData("mod-query")] + [InlineData("dll-query")] + public void InactiveSchemasSkipMapProfilesAndRotation(string command) { + var path = Path.Combine(_root, "inactive.yaml"); + File.WriteAllText(path, "chiv2-path: .\nplatform: Steam\nmap-profiles: [invalid]\nservers: {one: {map-rotation: [missing]}}\n"); + var options = CommandLineOptions.Parse([command, .. command == "client" ? Array.Empty() : ["list"], "--config-path", path]); + Assert.NotNull(options); + } +} \ No newline at end of file diff --git a/UnchainedLauncher.CLI.Tests/ServerGroupTests.cs b/UnchainedLauncher.CLI.Tests/ServerGroupTests.cs index 3cfe80c6..46196ce5 100644 --- a/UnchainedLauncher.CLI.Tests/ServerGroupTests.cs +++ b/UnchainedLauncher.CLI.Tests/ServerGroupTests.cs @@ -82,7 +82,7 @@ public void PerServerProfilesAndArgumentsRemainIndependent() { [Fact] public void PerServerModsDllsRegistriesAndIniDoNotLeakAcrossServers() { var group = Assert.IsType(Parse(Servers - .Replace("name: Courtyard", "name: Courtyard\n mod-setup: {registries: ['local:one'], mods: ['org/one:v1'], dlls: ['org/plugin:v1']}\n ini: {Game: {'/Script/TBL.TBLGameMode': {MapList: [FFA_Courtyard]}}}") + .Replace("name: Courtyard", "name: Courtyard\n mod-setup: {registries: ['local:one'], mods: ['org/one:v1'], dlls: ['org/plugin:v1']}\n ini: {Game: {'/Script/TBL.TBLGameMode': {BotBackfillEnabled: true}}}") .Replace("name: Arena", "name: Arena\n mod-setup: {registries: ['local:two'], mods: ['org/two:v2'], dlls: []}"))); var first = group.Servers[0].Options; var second = group.Servers[1].Options; @@ -92,8 +92,8 @@ public void PerServerModsDllsRegistriesAndIniDoNotLeakAcrossServers() { Assert.Empty(second.Shared.Dlls); Assert.Equal(Path.Combine(_root, "one"), Assert.Single(first.Shared.Registries).Location); Assert.Equal(Path.Combine(_root, "two"), Assert.Single(second.Shared.Registries).Location); - Assert.True(first.Ini.Contains("Game", "/Script/TBL.TBLGameMode", "MapList")); - Assert.False(second.Ini.Contains("Game", "/Script/TBL.TBLGameMode", "MapList")); + Assert.True(first.Ini.Contains("Game", "/Script/TBL.TBLGameMode", "BotBackfillEnabled")); + Assert.False(second.Ini.Contains("Game", "/Script/TBL.TBLGameMode", "BotBackfillEnabled")); } [Fact] diff --git a/UnchainedLauncher.CLI/CliLaunchOptions.cs b/UnchainedLauncher.CLI/CliLaunchOptions.cs index 4c13485a..23320ac0 100644 --- a/UnchainedLauncher.CLI/CliLaunchOptions.cs +++ b/UnchainedLauncher.CLI/CliLaunchOptions.cs @@ -127,6 +127,8 @@ internal ServerOptions(SharedLaunchOptions shared, ServerSettings settings, bool : base(shared, SharedLaunchOptions.Entries(settings.GameArgs, allowEmpty: true)) { Settings = settings; Ini = IniOverrides.Parse(settings.Ini); + if (settings.Rotation is not null && Ini.Contains("Game", "/Script/TBL.TBLGameMode", "MapListIndex")) + throw new ArgumentException("MapListIndex is managed by map-rotation; use starting-map to choose the first map."); NoTui = noTui; if (GameSavedDirSuffix.Length == 0) throw new ArgumentException("saved-dir-suffix must contain a character other than a hyphen."); if (GameArguments.Any(argument => argument == "--additional-pak-dir" || argument.StartsWith("--additional-pak-dir=", StringComparison.Ordinal))) diff --git a/UnchainedLauncher.CLI/IniOverrides.cs b/UnchainedLauncher.CLI/IniOverrides.cs index 1f03277d..e188b539 100644 --- a/UnchainedLauncher.CLI/IniOverrides.cs +++ b/UnchainedLauncher.CLI/IniOverrides.cs @@ -36,6 +36,8 @@ internal static IniOverrides Parse(Dictionary? MapProfiles { get; set; } public bool NoTui { get; set; } public ServerSettings? Server { get; set; } public Dictionary? Servers { get; set; } @@ -34,19 +35,30 @@ public sealed class ServerConfiguration : LaunchConfiguration { public override void Validate() { base.Validate(); - if (Server is not null) Validator.ValidateObject(Server, new ValidationContext(Server), validateAllProperties: true); + foreach (var (name, profile) in MapProfiles ?? []) { + if (string.IsNullOrWhiteSpace(name) || name != name.Trim()) throw new ValidationException("map-profiles keys must not be blank or have surrounding whitespace."); + MapRotation.ValidateMap(profile, $"map-profiles.{name}"); + } + if (Server is not null) ValidateServer(Server); if (Servers is not null) { if (Server is not null) throw new ValidationException("Use either server or servers, not both."); if (Servers.Count == 0) throw new ValidationException("servers must contain at least one named server."); foreach (var settings in Servers.Values) { if (settings is null) throw new ValidationException("Each servers entry must contain server settings."); - Validator.ValidateObject(settings, new ValidationContext(settings), validateAllProperties: true); + ValidateServer(settings); } } } + + private void ValidateServer(ServerSettings settings) { + Validator.ValidateObject(settings, new ValidationContext(settings), validateAllProperties: true); + settings.Rotation = MapRotation.Resolve(settings.MapRotation, MapProfiles, settings.ExplicitStartingMap); + } } public sealed class ClientConfiguration : LaunchConfiguration { + [YamlConverter(typeof(IgnoredYamlNodeConverter))] + public object? MapProfiles { get; set; } [Required] public string SavedDirSuffix { get; set; } = UnchainedLauncher.Core.Services.Mods.ProfileAssetPaths.SharedSuffix; public ModSetupConfiguration? ModSetup { get; set; } @@ -68,6 +80,11 @@ public sealed class ClientSettings { } public sealed class ServerSettings { + public List? MapRotation { get; set; } + [YamlIgnore] + internal MapRotation? Rotation { get; set; } + [YamlIgnore] + internal string? ExplicitStartingMap { get; private set; } public ModSetupConfiguration? ModSetup { get; set; } public Dictionary>>? Ini { get; set; } public string? SavedDirSuffix { get; set; } @@ -76,7 +93,10 @@ public sealed class ServerSettings { public string Description { get; set; } = ""; public string? Password { get; set; } [Required] - public string StartingMap { get; set; } = "FFA_Courtyard"; + public string StartingMap { + get => Rotation?.Maps[Rotation.StartIndex] ?? ExplicitStartingMap ?? "FFA_Courtyard"; + set => ExplicitStartingMap = value; + } public string? LocalIp { get; set; } [Range(1, 65535)] public int GamePort { get; set; } = 7777; diff --git a/UnchainedLauncher.CLI/MapRotation.cs b/UnchainedLauncher.CLI/MapRotation.cs new file mode 100644 index 00000000..79f1e5c1 --- /dev/null +++ b/UnchainedLauncher.CLI/MapRotation.cs @@ -0,0 +1,69 @@ +using System.ComponentModel.DataAnnotations; +using System.Globalization; +using System.Text.RegularExpressions; +using StructuredINI; +using YamlDotNet.Core; +using YamlDotNet.Core.Events; +using YamlDotNet.Serialization; + +namespace UnchainedLauncher.CLI; + +public sealed class MapProfile { + public string? MapName { get; set; } +} + +public sealed record MapRotationEntry(string? Profile, MapProfile? Inline); + +internal sealed class MapRotationEntryYamlConverter : IYamlTypeConverter { + public bool Accepts(Type type) => type == typeof(MapRotationEntry); + + public object ReadYaml(IParser parser, Type type, ObjectDeserializer rootDeserializer) { + if (parser.TryConsume(out var scalar)) { + if (string.IsNullOrWhiteSpace(scalar.Value) || scalar.Style == ScalarStyle.Plain && + (scalar.Value == "~" || scalar.Value.Equals("null", StringComparison.OrdinalIgnoreCase))) + throw new YamlException("map-rotation entries must be profile names or map-name objects."); + return new MapRotationEntry(scalar.Value, null); + } + if (!parser.Accept(out _)) + throw new YamlException("map-rotation entries must be profile names or map-name objects."); + return new MapRotationEntry(null, (MapProfile?)rootDeserializer(typeof(MapProfile))); + } + + public void WriteYaml(IEmitter emitter, object? value, Type type, ObjectSerializer serializer) => + throw new NotSupportedException("Map rotation YAML is read-only."); +} + +internal sealed record MapRotation(IReadOnlyList Maps, int StartIndex) { + internal static void ValidateMap(MapProfile? profile, string location) { + if (profile?.MapName is not { } name || !Regex.IsMatch(name, @"\A/?[A-Za-z0-9_-]+(?:/[A-Za-z0-9_-]+)*(?:\.[A-Za-z0-9_-]+)?\z")) + throw new ValidationException($"{location} requires a map-name containing a short map name or package path, without travel options."); + } + + internal static MapRotation? Resolve(List? entries, Dictionary? profiles, string? startingMap) { + if (entries is null) return null; + if (entries.Count == 0) throw new ValidationException("map-rotation must contain at least one map."); + var maps = new List(); + foreach (var entry in entries) { + var profile = entry?.Inline; + if (entry?.Profile is { } name) { + if (profiles is null || !profiles.TryGetValue(name, out profile)) + throw new ValidationException($"Unknown map-profile '{name}'; define it under root map-profiles or use an inline map-name object."); + } + ValidateMap(profile, "map-rotation entry"); + maps.Add(profile!.MapName!); + } + var index = startingMap is null ? 0 : maps.FindIndex(map => map.Equals(startingMap, StringComparison.OrdinalIgnoreCase)); + if (index < 0) throw new ValidationException("starting-map must match a map-name in map-rotation."); + return new MapRotation(maps.ToArray(), index); + } + + internal void Apply(string directory) { + var writer = new StructuredINIWriter(); + var values = new[] { KeyValuePair.Create("!MapList", "ClearArray") } + .Concat(Maps.Select(map => KeyValuePair.Create(".MapList", map))) + .Append(KeyValuePair.Create("MapListIndex", StartIndex.ToString(CultureInfo.InvariantCulture))); + writer.BufferSection("/Script/TBL.TBLGameMode", values); + if (!writer.WriteOut(Path.Combine(directory, "Game.ini"))) + throw new IOException("Could not apply map-rotation to the server profile; refusing to launch."); + } +} \ No newline at end of file diff --git a/UnchainedLauncher.CLI/README.md b/UnchainedLauncher.CLI/README.md index c94f8dc9..af2cb69c 100644 --- a/UnchainedLauncher.CLI/README.md +++ b/UnchainedLauncher.CLI/README.md @@ -217,6 +217,44 @@ settings are preserved. Client launches do not initialize or rewrite INI files. Chivalry removes hyphens from the suffix in its `Saved_` directory; the CLI initializes that directory. Plugin log paths retain the original suffix. +### Map rotation and profiles + +```yaml +map-profiles: + courtyard: + map-name: FFA_Courtyard + +servers: + first: + map-rotation: + - courtyard + - map-name: FFA_Wardenglade + second: + game-port: 7778 + ping-port: 3076 + query-port: 7072 + rcon-port: 9002 + map-rotation: + - courtyard + - map-name: FFA_Bazaar +``` + +- Put `map-rotation` inside `server` or each `servers` entry. +- Entries are inline `{map-name: ...}` objects or exact, case-sensitive keys from root `map-profiles`. +- A bare string is a profile reference, not a map name. +- Order and repeated entries are preserved. +- The first entry is the default starting map. +- Set `starting-map` to a rotation map-name to start at its first occurrence, matching case-insensitively. +- Map names accept short names or package paths, without travel options. +- An explicit rotation must contain at least one entry. +- Rotation setup replaces only `MapList` and `MapListIndex` in the server's saved `Game.ini`. +- Omitting `map-rotation` preserves the profile's existing rotation and the normal `starting-map` default. +- `MapList` is forbidden in `ini`, including typed and `raw` forms. +- `MapListIndex` cannot be set through `ini` when a rotation is configured. +- Profile and inline map objects currently accept only `map-name`. +- Per-map INI and actors remain deferred to [#154](https://github.com/Chiv2-Community/UnchainedLauncher/issues/154) and [#155](https://github.com/Chiv2-Community/UnchainedLauncher/issues/155). +- Server-level `mod-actors` still apply to every map. + ### INI overrides Nest `ini` under `server` or a named `servers` entry. Its shape is @@ -227,7 +265,6 @@ server: ini: Game: /Script/TBL.TBLGameMode: - MapList: [FFA_Courtyard, FFA_Wardenglade] BotBackfillEnabled: true ClassLimits: - Class: Poleman @@ -259,6 +296,7 @@ server: Arrays replace that field's existing entries; `[]` explicitly clears it. Other keys and sections are preserved. These changes persist after the server exits; omitting a field on a later launch does not undo it. +- Use `map-rotation`, not an INI `MapList` override, to configure maps. - `raw` requires a string and bypasses type parsing and validation. Its exact value is escaped for transport in one `-ini:` argument, not written to the profile. It cannot override an array: Unreal's command-line INI overrides do not support arrays. diff --git a/UnchainedLauncher.CLI/RegistryQueryConfiguration.cs b/UnchainedLauncher.CLI/RegistryQueryConfiguration.cs index d809d88f..c4d22f72 100644 --- a/UnchainedLauncher.CLI/RegistryQueryConfiguration.cs +++ b/UnchainedLauncher.CLI/RegistryQueryConfiguration.cs @@ -16,6 +16,7 @@ public sealed class RegistryQueryConfiguration { [YamlConverter(typeof(IgnoredYamlNodeConverter))] public object? GameArgs { get; set; } [YamlConverter(typeof(IgnoredYamlNodeConverter))] public object? Server { get; set; } [YamlConverter(typeof(IgnoredYamlNodeConverter))] public object? Servers { get; set; } + [YamlConverter(typeof(IgnoredYamlNodeConverter))] public object? MapProfiles { get; set; } [YamlConverter(typeof(IgnoredYamlNodeConverter))] public object? Client { get; set; } } diff --git a/UnchainedLauncher.CLI/ServerLauncher.cs b/UnchainedLauncher.CLI/ServerLauncher.cs index 644c8e67..46a0e18e 100644 --- a/UnchainedLauncher.CLI/ServerLauncher.cs +++ b/UnchainedLauncher.CLI/ServerLauncher.cs @@ -109,6 +109,7 @@ internal static void InitializeServerProfile(CliLaunchOptions request, Func(string path) where T : class { .WithNamingConvention(HyphenatedNamingConvention.Instance) .WithTypeConverter(new InstallationTypeYamlConverter()) .WithTypeConverter(new IgnoredYamlNodeConverter()) + .WithTypeConverter(new MapRotationEntryYamlConverter()) .WithDuplicateKeyChecking() .WithEnforceNullability() .Build(); diff --git a/UnchainedLauncher.CLI/examples/server.yaml b/UnchainedLauncher.CLI/examples/server.yaml index b3c9dc5d..214a388d 100644 --- a/UnchainedLauncher.CLI/examples/server.yaml +++ b/UnchainedLauncher.CLI/examples/server.yaml @@ -1,6 +1,9 @@ # Paths are relative to this file, unless absolute. Change this installation path. chiv2-path: 'C:\Games\Chivalry 2' platform: Steam # Steam or EGS; required +map-profiles: + courtyard: + map-name: FFA_Courtyard # Run with: UnchainedLauncher.CLI.exe server --config-path server.yaml # Requires UnchainedPlugin support for --additional-pak-dir. @@ -15,6 +18,9 @@ no-tui: false game-args: [] # Common game/auth arguments; one YAML string per argument. server: + map-rotation: + - courtyard + - map-name: FFA_Wardenglade saved-dir-suffix: MyServer mod-setup: registries: [] # Official GitHub registry is included automatically. @@ -26,7 +32,6 @@ server: ini: Game: /Script/TBL.TBLGameMode: - MapList: [FFA_Courtyard, FFA_Wardenglade] BotBackfillEnabled: true ClassLimits: - Class: Poleman diff --git a/UnchainedLauncher.CLI/examples/servers.yaml b/UnchainedLauncher.CLI/examples/servers.yaml index debbcd0a..3ab1eb50 100644 --- a/UnchainedLauncher.CLI/examples/servers.yaml +++ b/UnchainedLauncher.CLI/examples/servers.yaml @@ -6,6 +6,9 @@ # Replace the core plugin pin with a registered build supporting --additional-pak-dir. chiv2-path: 'C:\Games\Chivalry 2' platform: Steam +map-profiles: + courtyard: + map-name: FFA_Courtyard server-browser-backend: https://servers.polehammer.net update-plugin: true allow-unstable-plugin: false @@ -13,6 +16,9 @@ no-tui: false game-args: [] servers: courtyard: + map-rotation: + - courtyard + - map-name: FFA_Wardenglade # saved-dir-suffix defaults to courtyard (the server key). mod-setup: registries: [] # Official registry is included automatically. @@ -24,7 +30,6 @@ servers: ini: Game: /Script/TBL.TBLGameMode: - MapList: [FFA_Courtyard, FFA_Wardenglade] BotBackfillEnabled: true ClassLimits: - Class: Poleman @@ -40,6 +45,9 @@ servers: mod-actors: - /Game/Mods/AgMods/Chiv2Turbo/Chiv2Turbo.Chiv2Turbo_C arena: + map-rotation: + - courtyard + - map-name: FFA_Bazaar mod-setup: registries: [] mods: From c23e157f9d9a34b3af5f48f78b4d92a7f5ab39c8 Mon Sep 17 00:00:00 2001 From: Jacoby6000 <2672832+Jacoby6000@users.noreply.github.com> Date: Mon, 19 Oct 2026 16:31:09 +0000 Subject: [PATCH 30/58] [GHA] Format Source --- UnchainedLauncher.CLI/MapRotation.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/UnchainedLauncher.CLI/MapRotation.cs b/UnchainedLauncher.CLI/MapRotation.cs index 79f1e5c1..e978d99a 100644 --- a/UnchainedLauncher.CLI/MapRotation.cs +++ b/UnchainedLauncher.CLI/MapRotation.cs @@ -1,7 +1,7 @@ +using StructuredINI; using System.ComponentModel.DataAnnotations; using System.Globalization; using System.Text.RegularExpressions; -using StructuredINI; using YamlDotNet.Core; using YamlDotNet.Core.Events; using YamlDotNet.Serialization; From ffac44cd30ef160ef6735da88f66790de6001f85 Mon Sep 17 00:00:00 2001 From: Jacob Barber Date: Mon, 19 Oct 2026 12:04:13 -0500 Subject: [PATCH 31/58] Handle monitor exit races and reject incomplete pak dependency sorts --- .../LaunchPreparationTests.cs | 92 ++++++++++++++++--- .../ServerGroupTests.cs | 49 ++++++++++ .../ServerMonitorTests.cs | 15 +++ UnchainedLauncher.CLI/ServerGroupRunner.cs | 10 +- UnchainedLauncher.CLI/ServerLauncher.cs | 4 + UnchainedLauncher.CLI/ServerMonitor.cs | 21 +++-- 6 files changed, 165 insertions(+), 26 deletions(-) diff --git a/UnchainedLauncher.CLI.Tests/LaunchPreparationTests.cs b/UnchainedLauncher.CLI.Tests/LaunchPreparationTests.cs index ae06e35c..50b92995 100644 --- a/UnchainedLauncher.CLI.Tests/LaunchPreparationTests.cs +++ b/UnchainedLauncher.CLI.Tests/LaunchPreparationTests.cs @@ -39,19 +39,7 @@ await local.AddRelease(Pak("org/required", "v1.0.0", hasTransitiveDependency await local.AddRelease(Pak("org/required", "v2.0.0"), source); if (dependencyPublished) await local.AddRelease(Pak("org/transitive", "v1.0.0"), source); - var dll = new DllRelease("v1.0.0", Convert.ToHexString(SHA256.HashData([1, 2, 3])).ToLowerInvariant(), - "UnchainedPlugin.dll", "https://example.test/UnchainedPlugin.dll", 3, - new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero), - new DllInfo { - RepoUrl = "https://github.com/Chiv2-Community/UnchainedPlugin", - Name = "core", - Description = "test", - Authors = ["test"], - ModDependencies = [new Dependency("https://github.com/org/required", "<2.0.0")] - }, null); - var packages = Path.Combine(local.RegistryPath, "dll_db", "packages", "Chiv2-Community"); - Directory.CreateDirectory(packages); - File.WriteAllText(Path.Combine(packages, "UnchainedPlugin.json"), JsonHelpers.Serialize(new DllPackage(dll.Info, [dll]))); + AddPlugin(local, "org/required", "<2.0.0"); var request = CliLaunchOptions.Parse([ command, "--chivalry2-path", _directory, "--platform", "Steam", @@ -91,6 +79,84 @@ await local.AddRelease(Pak("org/required", "v1.0.0", hasTransitiveDependency coordinate.Org == "org" && coordinate.ModuleName == "required" && coordinate.Version == "v1.0.0"); } + [Theory] + [InlineData("cycle", true)] + [InlineData("self", true)] + [InlineData("acyclic", true)] + [InlineData("cycle", false)] + [InlineData("self", false)] + [InlineData("acyclic", false)] + public async Task DependencySortMustRetainEverySelectedReleaseBeforePakMutation(string graph, bool validateDirectly) { + var local = new LocalModRegistry(Path.Combine(_directory, "registry")); + var source = Path.Combine(_directory, "payload.pak"); + File.WriteAllText(source, "pak payload"); + var hash = FileHelpers.Sha512(source).Match(value => value, error => throw new Exception(error.Message)); + Release Pak(string id, params string[] dependencies) => new("v1.0.0", hash, "mod.pak", + new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc), + new ModInfo("https://github.com/" + id, id.Split('/')[1], "test", null, null, [], ["test"], + dependencies.Select(dep => new Dependency("https://github.com/" + dep, "*")).ToList()), + new AssetCollections([], [], [], [], []), null); + await local.AddRelease(Pak("Chiv2-Community/Unchained-Mods"), source); + await local.AddRelease(Pak("org/a", graph == "self" ? "org/a" : "org/b"), source); + await local.AddRelease(Pak("org/b", graph == "cycle" ? ["org/a"] : []), source); + AddPlugin(local, "org/a", ">=1.0.0"); + + var request = CliLaunchOptions.Parse(["client", "--chivalry2-path", _directory, "--platform", "Steam"]); + request.Shared.Assets = new ProfileAssetPaths(request.Shared.SavedDirSuffix, _directory); + var pakPath = Path.Combine(_directory, FilePaths.PakDir); + Directory.CreateDirectory(pakPath); + File.WriteAllText(Path.Combine(pakPath, "pakchunk0-WindowsNoEditor.sig"), "signature"); + var registry = new AggregateModRegistry(local); + var manager = ServerLauncher.CreateModManager(request, registry); + Assert.False((await manager.UpdateModsList()).HasErrors); + var selected = new ReleaseCoordinates("org", "a", "v1.0.0"); + if (validateDirectly) { + manager.EnableModRelease(selected); + if (graph == "acyclic") ServerLauncher.ValidateReleases(manager, [selected]); + else Assert.Contains("cycle", Assert.Throws(() => + ServerLauncher.ValidateReleases(manager, [selected])).Message); + Assert.Empty(Directory.GetFiles(request.InstancePaths.Paks, "*.pak")); + return; + } + ServerLauncher.ValidateReleases(manager, []); + + var unknown = Path.Combine(request.InstancePaths.Paks, "unknown.pak"); + File.WriteAllText(unknown, "preserve unknown bytes"); + using var client = new HttpClient(new DownloadHandler()); + var session = new DllLaunchSession(registry, manager, Path.Combine(_directory, "plugins"), client: client, + cacheDirectory: Path.Combine(_directory, "dll-cache")); + var cachePath = Path.Combine(_directory, "cli_mod_manager.json"); + var preparer = ServerLauncher.CreateLaunchPreparer(request, manager, session, new ModManagerCodec(registry), cachePath); + if (graph == "acyclic") { + Assert.True((await preparer.PrepareLaunch(request.ToLaunchOptions())).IsSome); + Assert.Equal(4, Directory.GetFiles(request.InstancePaths.Paks, "*.pak").Length); + } + else { + var error = await Assert.ThrowsAsync(() => preparer.PrepareLaunch(request.ToLaunchOptions())); + Assert.Contains("cycle", error.Message); + Assert.Equal([unknown], Directory.GetFiles(request.InstancePaths.Paks, "*.pak")); + Assert.False(File.Exists(cachePath)); + } + Assert.Contains(selected, manager.EnabledModReleaseCoordinates); + Assert.Equal("preserve unknown bytes", File.ReadAllText(unknown)); + } + + private static void AddPlugin(LocalModRegistry local, string dependency, string version) { + var dll = new DllRelease("v1.0.0", Convert.ToHexString(SHA256.HashData([1, 2, 3])).ToLowerInvariant(), + "UnchainedPlugin.dll", "https://example.test/UnchainedPlugin.dll", 3, + new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero), + new DllInfo { + RepoUrl = "https://github.com/Chiv2-Community/UnchainedPlugin", + Name = "core", + Description = "test", + Authors = ["test"], + ModDependencies = [new Dependency("https://github.com/" + dependency, version)] + }, null); + var packages = Path.Combine(local.RegistryPath, "dll_db", "packages", "Chiv2-Community"); + Directory.CreateDirectory(packages); + File.WriteAllText(Path.Combine(packages, "UnchainedPlugin.json"), JsonHelpers.Serialize(new DllPackage(dll.Info, [dll]))); + } + private sealed class DownloadHandler : HttpMessageHandler { protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) => Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { diff --git a/UnchainedLauncher.CLI.Tests/ServerGroupTests.cs b/UnchainedLauncher.CLI.Tests/ServerGroupTests.cs index 46196ce5..81f70eb8 100644 --- a/UnchainedLauncher.CLI.Tests/ServerGroupTests.cs +++ b/UnchainedLauncher.CLI.Tests/ServerGroupTests.cs @@ -202,4 +202,53 @@ public async Task NaturalExitsReturnFailureWithoutStoppingOtherServersEarly() { Assert.Equal(2, calls); Assert.False(stop.IsCancellationRequested); } + + [Fact] + public async Task ExitAtMonitorInitializationDoesNotStopSurvivingSibling() { + using var stop = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + Process? first = null; + Process? survivor = null; + Task? survivorExit = null; + var calls = 0; + var queriedSurvivor = false; + Assert.Equal(7, await ServerGroupRunner.RunAsync(Group(), stop, (_, _) => { + if (++calls == 1) { + var info = new ProcessStartInfo(OperatingSystem.IsWindows() ? "cmd.exe" : "/bin/sh") { + UseShellExecute = false, + RedirectStandardInput = true, + ArgumentList = { OperatingSystem.IsWindows() ? "/c" : "-c", + OperatingSystem.IsWindows() ? "set /p ignored= & exit 7" : "read ignored; exit 7" } + }; + first = Process.Start(info)!; + return Task.FromResult(first); + } + survivor = Child(); + survivorExit = survivor.WaitForExitAsync(); + return Task.FromResult(survivor); + }, _ => { + if (!first!.HasExited) { + // Release the real child exactly when group monitoring initializes. + first.StandardInput.WriteLine("exit"); + first.StandardInput.Flush(); + Assert.True(first.WaitForExit(5000)); + return new FailingQuery(); + } + return new CallbackQuery(() => { + Assert.NotNull(survivor); + Assert.False(survivor!.HasExited); + queriedSurvivor = true; + survivor.Kill(entireProcessTree: true); + }); + })); + Assert.True(queriedSurvivor); + await survivorExit!.WaitAsync(TimeSpan.FromSeconds(5)); + Assert.False(stop.IsCancellationRequested); + } + + private sealed class CallbackQuery(Action callback) : IA2S { + public Task InfoAsync() { + callback(); + return Task.FromException(new TimeoutException()); + } + } } \ No newline at end of file diff --git a/UnchainedLauncher.CLI.Tests/ServerMonitorTests.cs b/UnchainedLauncher.CLI.Tests/ServerMonitorTests.cs index a51d1371..29e81558 100644 --- a/UnchainedLauncher.CLI.Tests/ServerMonitorTests.cs +++ b/UnchainedLauncher.CLI.Tests/ServerMonitorTests.cs @@ -6,6 +6,21 @@ namespace UnchainedLauncher.CLI.Tests; public class ServerMonitorTests { + [Fact] + public async Task AlreadyExitedProcessRecordsNormalExitWithoutQuerying() { + var info = new ProcessStartInfo(OperatingSystem.IsWindows() ? "cmd.exe" : "/bin/sh") { + UseShellExecute = false, + ArgumentList = { OperatingSystem.IsWindows() ? "/c" : "-c", "exit 7" } + }; + using var process = Process.Start(info)!; + await process.WaitForExitAsync(); + var monitor = new ServerMonitor(process, new PendingQuery()); + await monitor.RunAsync((_, _) => Assert.True(false, "Exited processes must not render samples"), CancellationToken.None) + .WaitAsync(TimeSpan.FromSeconds(5)); + Assert.Empty(monitor.History.Samples); + Assert.Equal("Server exited (code 7)", monitor.History.Events.Last().Message); + } + [Fact] public async Task CoreProgressWorksWithoutAUiSynchronizationContext() { await Task.Run(() => { diff --git a/UnchainedLauncher.CLI/ServerGroupRunner.cs b/UnchainedLauncher.CLI/ServerGroupRunner.cs index 7bd6e415..b1546022 100644 --- a/UnchainedLauncher.CLI/ServerGroupRunner.cs +++ b/UnchainedLauncher.CLI/ServerGroupRunner.cs @@ -53,12 +53,10 @@ async Task MonitorAsync(int index) { var monitor = new ServerMonitor(process, query(group.Servers[index].Options)); var last = new ServerSample(DateTimeOffset.Now, TimeSpan.Zero, null, null, null, null, null, false); Volatile.Write(ref snapshots[index], new(last, monitor.History.Snapshot(), "Starting")); - if (!process.HasExited) { - await monitor.RunAsync((sample, history) => { - last = sample; - Volatile.Write(ref snapshots[index], new(sample, history.Snapshot(), "Running")); - }, monitoring.Token); - } + await monitor.RunAsync((sample, history) => { + last = sample; + Volatile.Write(ref snapshots[index], new(sample, history.Snapshot(), "Running")); + }, monitoring.Token); var status = process.HasExited ? $"Exited (code {process.ExitCode}); last recorded metrics" : "Stopping"; Volatile.Write(ref snapshots[index], new(last, monitor.History.Snapshot(stopped: process.HasExited), status)); } diff --git a/UnchainedLauncher.CLI/ServerLauncher.cs b/UnchainedLauncher.CLI/ServerLauncher.cs index 46a0e18e..9294121b 100644 --- a/UnchainedLauncher.CLI/ServerLauncher.cs +++ b/UnchainedLauncher.CLI/ServerLauncher.cs @@ -143,6 +143,10 @@ public static void ValidateReleases(IModManager manager, IEnumerable { } internal async Task RunAsync(Action render, CancellationToken stop) { - var started = new DateTimeOffset(process.StartTime); - var previousTime = Stopwatch.GetTimestamp(); - var previousCpu = process.TotalProcessorTime; - double? peakMemory = null; - History.AddEvent(DateTimeOffset.Now, $"Server started (PID {process.Id})"); - using var timer = new PeriodicTimer(TimeSpan.FromSeconds(1)); try { + DateTimeOffset started; + TimeSpan previousCpu; + try { + started = new DateTimeOffset(process.StartTime); + previousCpu = process.TotalProcessorTime; + } + catch (Exception ex) when ((ex is InvalidOperationException or Win32Exception) && process.HasExited) { + return; + } + var previousTime = Stopwatch.GetTimestamp(); + double? peakMemory = null; + History.AddEvent(DateTimeOffset.Now, $"Server started (PID {process.Id})"); + using var timer = new PeriodicTimer(TimeSpan.FromSeconds(1)); while (!stop.IsCancellationRequested && !process.HasExited) { A2SInfo? info = null; try { @@ -81,11 +88,11 @@ internal async Task RunAsync(Action render, Cancell // Never convert an unavailable query into a zero player count. } if (process.HasExited) break; - process.Refresh(); double? cpu = null; double? memory = null; int? handles = null; try { + process.Refresh(); var nowTicks = Stopwatch.GetTimestamp(); var elapsed = Stopwatch.GetElapsedTime(previousTime, nowTicks).TotalSeconds; var totalCpu = process.TotalProcessorTime; From c39693111020ed9994ce12b79a8bf42c1f9638b6 Mon Sep 17 00:00:00 2001 From: Jacob Barber Date: Mon, 19 Oct 2026 19:58:16 -0500 Subject: [PATCH 32/58] Use native profile Paks directory for managed mods --- .../ArgumentBoundaryTests.cs | 13 +++++-------- UnchainedLauncher.CLI.Tests/PakCacheTests.cs | 7 ++++--- .../ServerGroupTests.cs | 11 +++++------ .../ServerOptionsTests.cs | 6 +++--- .../YamlConfigurationTests.cs | 10 +++++----- UnchainedLauncher.CLI/CliLaunchOptions.cs | 10 ++-------- UnchainedLauncher.CLI/README.md | 18 +++++++++--------- UnchainedLauncher.CLI/ServerInstancePaths.cs | 4 ++-- .../Services/Mods/ProfileAssetPaths.cs | 4 ++-- UnchainedLauncher.GUI/App.xaml.cs | 10 ++-------- 10 files changed, 39 insertions(+), 54 deletions(-) diff --git a/UnchainedLauncher.CLI.Tests/ArgumentBoundaryTests.cs b/UnchainedLauncher.CLI.Tests/ArgumentBoundaryTests.cs index 59f58825..a37694df 100644 --- a/UnchainedLauncher.CLI.Tests/ArgumentBoundaryTests.cs +++ b/UnchainedLauncher.CLI.Tests/ArgumentBoundaryTests.cs @@ -35,7 +35,6 @@ public async Task ForwardingPreservesExactProcessArguments(string command, bool var options = Parse(command, yaml, [argument, argument, "sentinel-after-forwarded"]); var expected = new List { "TBL" }; if (command == "server") expected.Add("-server"); - expected.AddRange(["--additional-pak-dir", options.InstancePaths.Paks]); if (command == "server") expected.AddRange(FirstRunArguments); expected.AddRange([argument, argument, "sentinel-after-forwarded", "-saveddirsuffix=" + options.Shared.SavedDirSuffix, "-unchained"]); if (command == "server") expected.AddRange(DefaultServerArguments()); @@ -52,8 +51,7 @@ public async Task ForwardingPreservesExactProcessArguments(string command, bool public async Task GeneratedServerValuesAlsoPreserveBoundaries(bool yaml) { const string value = "quoted \"name\"\\\tline\nend\\"; var options = Assert.IsType(Parse("server", yaml, [], value)); - var expected = new List { "TBL", "-server", "--additional-pak-dir", - options.InstancePaths.Paks }; + var expected = new List { "TBL", "-server" }; expected.AddRange(FirstRunArguments); expected.AddRange(["-saveddirsuffix=UnchainedCLI", "-unchained"]); expected.AddRange(DefaultServerArguments(value, value, value)); @@ -65,11 +63,10 @@ public async Task GeneratedServerValuesAlsoPreserveBoundaries(bool yaml) { } [Fact] - public async Task PrivatePakDirectoryWithSpacesReachesProcessAsOneArgument() { + public async Task NativePakDirectoryDoesNotRequireAProcessArgument() { var installation = Path.Combine(_directory, "Game With Spaces"); var options = CliLaunchOptions.Parse(["server", "--chivalry2-path", installation, "--platform", "Steam", "--local-ip", "127.0.0.1"]); - var expected = new List { "TBL", "-server", "--additional-pak-dir", - options.InstancePaths.Paks }; + var expected = new List { "TBL", "-server" }; expected.AddRange(FirstRunArguments); expected.AddRange(["-saveddirsuffix=UnchainedCLI", "-unchained"]); expected.AddRange(DefaultServerArguments()); @@ -87,7 +84,7 @@ public async Task RawIniOverridesPreserveExactContentsAsOneArgument(string value " local-ip: 127.0.0.1\n ini:\n Game:\n /Script/TBL.TBLGameMode:\n" + $" Custom: {{type: raw, value: {JsonSerializer.Serialize(value)}}}\n"); var options = CliLaunchOptions.Parse(["server", "--config-path", path]); - var expected = new List { "TBL", "-server", "--additional-pak-dir", options.InstancePaths.Paks }; + var expected = new List { "TBL", "-server" }; expected.AddRange(FirstRunArguments); expected.Add("-ini:Game:[/Script/TBL.TBLGameMode]:Custom=" + value); expected.AddRange(["-saveddirsuffix=UnchainedCLI", "-unchained"]); @@ -163,4 +160,4 @@ private static async Task ReadResult(Process process, string output) { private sealed class NoOpInjector : IProcessInjector { public bool Inject(Process process) => true; } -} \ No newline at end of file +} diff --git a/UnchainedLauncher.CLI.Tests/PakCacheTests.cs b/UnchainedLauncher.CLI.Tests/PakCacheTests.cs index bd4c5bb2..7c045545 100644 --- a/UnchainedLauncher.CLI.Tests/PakCacheTests.cs +++ b/UnchainedLauncher.CLI.Tests/PakCacheTests.cs @@ -154,12 +154,13 @@ public void CacheAndSignaturePathsSurviveMetadataRoundTrip() { public void ProfileAssetsAndSharedCachesUseLocalAppData() { var first = new ProfileAssetPaths("Host-one", _root); var second = new ProfileAssetPaths("Host-two", _root); - Assert.Equal(Path.Combine(_root, "Chivalry 2", "Saved_Hostone", "unchained", "paks"), first.Paks); + Assert.Equal(Path.Combine(_root, "Chivalry 2", "Saved_Hostone", "Paks"), first.Paks); + Assert.Equal(first.Paks, new ServerInstancePaths(first.Root, first.PakCache).Paks); Assert.Equal(Path.Combine(_root, "Chivalry 2", "Saved_Hostone", "unchained", "plugins"), first.Plugins); Assert.NotEqual(first.Paks, second.Paks); Assert.Equal(first.PakCache, second.PakCache); Assert.Equal(first.DllCache, second.DllCache); - Assert.Equal(Path.Combine(_root, "Chivalry 2", "Saved_unchained", "unchained", "paks"), + Assert.Equal(Path.Combine(_root, "Chivalry 2", "Saved_unchained", "Paks"), new ProfileAssetPaths(ProfileAssetPaths.SharedSuffix, _root).Paks); } -} \ No newline at end of file +} diff --git a/UnchainedLauncher.CLI.Tests/ServerGroupTests.cs b/UnchainedLauncher.CLI.Tests/ServerGroupTests.cs index 81f70eb8..0069be99 100644 --- a/UnchainedLauncher.CLI.Tests/ServerGroupTests.cs +++ b/UnchainedLauncher.CLI.Tests/ServerGroupTests.cs @@ -116,11 +116,10 @@ public void InstanceLeasePreventsConcurrentReuseAndCanBeReacquiredAfterRelease() using (paths.AcquireLease()) Assert.True(Directory.Exists(paths.Root)); } - [Theory] - [InlineData("--additional-pak-dir")] - [InlineData("--additional-pak-dir=other")] - public void ManagedPakDirectoryCannotBeOverriddenByForwardedArguments(string argument) { - Assert.Throws(() => Parse(Servers + $"\ngame-args: ['{argument}']")); + [Fact] + public void NativePakDirectoriesDoNotAddPluginArguments() { + Assert.All(Group().Servers, entry => + Assert.DoesNotContain("--additional-pak-dir", entry.Options.ToLaunchOptions().LaunchArgs)); } [Fact] @@ -251,4 +250,4 @@ public Task InfoAsync() { return Task.FromException(new TimeoutException()); } } -} \ No newline at end of file +} diff --git a/UnchainedLauncher.CLI.Tests/ServerOptionsTests.cs b/UnchainedLauncher.CLI.Tests/ServerOptionsTests.cs index b24b7437..dfa6a0bb 100644 --- a/UnchainedLauncher.CLI.Tests/ServerOptionsTests.cs +++ b/UnchainedLauncher.CLI.Tests/ServerOptionsTests.cs @@ -21,7 +21,7 @@ public void DefaultsProduceHeadlessServerLaunch() { Assert.Equal(7777, server.GamePort); Assert.Equal(7071, server.QueryPort); Assert.Equal("FFA_Courtyard", server.Map); - Assert.StartsWith("TBL -server --additional-pak-dir ", options.ToLaunchOptions().LaunchArgs); + Assert.StartsWith("TBL -server ", options.ToLaunchOptions().LaunchArgs); Assert.Equal("UnchainedCLI", options.ToLaunchOptions().SavedDirSuffix.IfNone("")); Assert.Contains(server.ToCLIArgs(), arg => arg.Rendered == "-nullrhi"); Assert.Contains(server.ToCLIArgs(), arg => arg.Rendered == "--desync-patch"); @@ -184,7 +184,7 @@ public void ClientLaunchDoesNotApplyServerDesyncCompatibilityPolicy() { ])); var launch = options.ToLaunchOptions(); Assert.True(launch.ServerLaunchOptions.IsNone); - Assert.StartsWith("TBL --additional-pak-dir ", launch.LaunchArgs); + Assert.Equal("TBL", launch.LaunchArgs); } [Theory] @@ -195,4 +195,4 @@ public void ClientLaunchDoesNotApplyServerDesyncCompatibilityPolicy() { public void DisableOptionsAreValueLessFlags(string flag) { Assert.Throws(() => CliLaunchOptions.Parse(Args(flag))); } -} \ No newline at end of file +} diff --git a/UnchainedLauncher.CLI.Tests/YamlConfigurationTests.cs b/UnchainedLauncher.CLI.Tests/YamlConfigurationTests.cs index 3c60ea22..560b79b6 100644 --- a/UnchainedLauncher.CLI.Tests/YamlConfigurationTests.cs +++ b/UnchainedLauncher.CLI.Tests/YamlConfigurationTests.cs @@ -133,8 +133,8 @@ public void SubcommandSelectsModeRegardlessOfIncludedBlocks(bool client) { var options = Load("client: {}\nserver: {}\n", client); Assert.Equal(client, options is ClientOptions); Assert.Equal(client, options.ToLaunchOptions().ServerLaunchOptions.IsNone); - if (client) Assert.StartsWith("TBL --additional-pak-dir ", options.ToLaunchOptions().LaunchArgs); - else Assert.StartsWith("TBL -server --additional-pak-dir ", options.ToLaunchOptions().LaunchArgs); + if (client) Assert.Equal("TBL", options.ToLaunchOptions().LaunchArgs); + else Assert.StartsWith("TBL -server ", options.ToLaunchOptions().LaunchArgs); Assert.Equal(!client, options.ToLaunchOptions().ToCLIArgs().Any(arg => arg.Rendered == "-nullrhi")); } @@ -275,7 +275,7 @@ public void RenderingRequiresExplicitlyDisablingDesyncPatchInYaml(string yaml) { Assert.Contains("server.desync-patch: false", error.Message); var client = Assert.IsType(Load(yaml, client: true)).ToLaunchOptions(); Assert.True(client.ServerLaunchOptions.IsNone); - Assert.StartsWith("TBL --additional-pak-dir ", client.LaunchArgs); + Assert.Equal("TBL", client.LaunchArgs); } [Fact] @@ -451,7 +451,7 @@ public void DirectClientCommandHasNoServerOptions() { var options = CliLaunchOptions.Parse(["client", "--chivalry2-path", _directory, "--platform", "EGS"]); Assert.IsType(options); Assert.True(options.ToLaunchOptions().ServerLaunchOptions.IsNone); - Assert.StartsWith("TBL --additional-pak-dir ", options.ToLaunchOptions().LaunchArgs); + Assert.Equal("TBL", options.ToLaunchOptions().LaunchArgs); } [Theory] @@ -510,4 +510,4 @@ public async Task ProductionSupervisionLeavesClientRunningButStopsServer(bool cl } } } -} \ No newline at end of file +} diff --git a/UnchainedLauncher.CLI/CliLaunchOptions.cs b/UnchainedLauncher.CLI/CliLaunchOptions.cs index 23320ac0..551546e4 100644 --- a/UnchainedLauncher.CLI/CliLaunchOptions.cs +++ b/UnchainedLauncher.CLI/CliLaunchOptions.cs @@ -67,8 +67,6 @@ public abstract class CliLaunchOptions : CliCommandOptions { private protected CliLaunchOptions(SharedLaunchOptions shared, IEnumerable gameArguments) { _shared = shared; GameArguments = shared.GameArguments.Concat(gameArguments).ToArray(); - if (GameArguments.Any(argument => argument == "--additional-pak-dir" || argument.StartsWith("--additional-pak-dir=", StringComparison.Ordinal))) - throw new ArgumentException("--additional-pak-dir is managed by the launcher; do not pass it through game-args."); } private protected CliLaunchOptions(string helpText) { @@ -93,11 +91,9 @@ internal static CliLaunchOptions FromConfiguration(LaunchConfiguration configura public abstract LaunchOptions ToLaunchOptions(); private protected LaunchOptions BuildLaunchOptions(IEnumerable prefix, LanguageExt.Option server) { - var start = prefix.ToList(); - start.Insert(server.IsSome ? 2 : 1, new Parameter("--additional-pak-dir", InstancePaths.Paks).Rendered); return new( EnabledReleases: Shared.Mods.Distinct(), ServerBrowserBackend: Some(Shared.Backend), - LaunchArgs: string.Join(" ", start.Concat(GameArguments.Select(ArgumentEscaper.Escape))), + LaunchArgs: string.Join(" ", prefix.Concat(GameArguments.Select(ArgumentEscaper.Escape))), CheckForDependencyUpdates: Shared.UpdatePlugin, AllowUnstablePluginReleases: Shared.AllowUnstablePlugin, SavedDirSuffix: Some(Shared.SavedDirSuffix), ServerLaunchOptions: server); } @@ -131,8 +127,6 @@ internal ServerOptions(SharedLaunchOptions shared, ServerSettings settings, bool throw new ArgumentException("MapListIndex is managed by map-rotation; use starting-map to choose the first map."); NoTui = noTui; if (GameSavedDirSuffix.Length == 0) throw new ArgumentException("saved-dir-suffix must contain a character other than a hyphen."); - if (GameArguments.Any(argument => argument == "--additional-pak-dir" || argument.StartsWith("--additional-pak-dir=", StringComparison.Ordinal))) - throw new ArgumentException("--additional-pak-dir is managed by the launcher; do not pass it through game-args."); if (!settings.Headless && settings.DesyncPatch) throw new ArgumentException("Desync patch requires a headless server. To enable rendering, pass --no-desync-patch or set server.desync-patch: false in YAML."); if (new[] { settings.GamePort, settings.PingPort, settings.QueryPort, settings.RconPort }.Distinct().Count() != 4) @@ -175,4 +169,4 @@ public override LaunchOptions ToLaunchOptions() { prefix.AddRange(Ini.RawArguments); return BuildLaunchOptions(prefix, Some(ToServerLaunchOptions())); } -} \ No newline at end of file +} diff --git a/UnchainedLauncher.CLI/README.md b/UnchainedLauncher.CLI/README.md index af2cb69c..7e6092d4 100644 --- a/UnchainedLauncher.CLI/README.md +++ b/UnchainedLauncher.CLI/README.md @@ -5,7 +5,7 @@ On Windows, the shared game launcher creates the game suspended, injects DLLs, then resumes its main thread. Failed injection terminates the suspended game. CLI servers also pass TOS-acceptance and first-load-completed INI overrides to avoid interactive first-run screens. The CLI also initializes the game's saved profile. -Profile-local paks require an UnchainedPlugin build supporting `--additional-pak-dir`. +Profile-local paks use the game's native `Saved_/Paks` search directory. It uses Core for mod installation, plugin updates, signature preparation, process launching, DLL injection, and A2S queries. @@ -34,8 +34,7 @@ dotnet run --project UnchainedLauncher.CLI -c Release -- server ` ``` The path is the installation root containing `TBL`, not the Win64 directory. -The example pins registered releases; replace the core plugin pin with a registered -build supporting `--additional-pak-dir` before launching. +The example pins registered releases. The server is headless by default (`-nullrhi -unattended -nosound`); use `--headless false --no-desync-patch` to render it (the desync patch only supports headless servers). The plugin is installed @@ -357,17 +356,18 @@ and individual CLI launch arguments still launch one server. Registries and pak/DLL selections are independent; verified bytes share the cache. - Profiles default to the server key (`courtyard`, `arena`). Set `saved-dir-suffix` inside an entry to override it. Profile names must be distinct, ignoring case and hyphens. -- Active paks and DLLs live under - `%LOCALAPPDATA%/Chivalry 2/Saved_/unchained/{paks,plugins}`. +- Active paks live under `%LOCALAPPDATA%/Chivalry 2/Saved_/Paks`; + DLLs remain under `%LOCALAPPDATA%/Chivalry 2/Saved_/unchained/plugins`. Hyphens are removed from the saved suffix, matching the game. This also applies to clients; the default client suffix is `unchained`. A per-profile lock prevents concurrent CLI processes from using the same profile. Plugin injection never scans another instance's directory, including with the unsafe flag. -- The launcher supplies `--additional-pak-dir `. - **This requires an UnchainedPlugin release supporting that option.** Do not pass - the option manually through `game-args`. Signatures use the installation's base +- The game discovers profile paks without an additional plugin argument. + Signatures use the installation's base signature as a read-only template; no base pak is copied or modified. -- The plugin option adds a mount directory; it does not suppress game-wide paks +- Relaunch with the desired `--mod` selections to install them into `Paks`. + Old files under `unchained/paks` are not migrated or deleted automatically. +- Profile-local discovery does not suppress game-wide paks already present in `TBL/Content/Paks`. Existing global mods may still affect all instances. The launcher does not move or delete them during instance setup. - Shared caches live under `%LOCALAPPDATA%/Chivalry 2/unchained/cache/{paks,dlls}`. diff --git a/UnchainedLauncher.CLI/ServerInstancePaths.cs b/UnchainedLauncher.CLI/ServerInstancePaths.cs index 2e6c6c94..87578c8d 100644 --- a/UnchainedLauncher.CLI/ServerInstancePaths.cs +++ b/UnchainedLauncher.CLI/ServerInstancePaths.cs @@ -3,7 +3,7 @@ namespace UnchainedLauncher.CLI; public sealed record ServerInstancePaths(string Root, string? PakCache = null) { - public string Paks => Path.Combine(Root, "paks"); + public string Paks => Path.Combine(Path.GetDirectoryName(Root)!, "Paks"); public string Plugins => Path.Combine(Root, "plugins"); public string ModMetadata => Path.Combine(Root, "cli_mod_manager.json"); @@ -21,4 +21,4 @@ internal PakDir CreatePakDir(string basePakDirectory, CancellationToken cancella Directory.CreateDirectory(Paks); return new PakDir(Paks, [], Path.Combine(basePakDirectory, "pakchunk0-WindowsNoEditor.sig"), PakCache, cancellationToken); } -} \ No newline at end of file +} diff --git a/UnchainedLauncher.Core/Services/Mods/ProfileAssetPaths.cs b/UnchainedLauncher.Core/Services/Mods/ProfileAssetPaths.cs index ed0e768c..7ecc5808 100644 --- a/UnchainedLauncher.Core/Services/Mods/ProfileAssetPaths.cs +++ b/UnchainedLauncher.Core/Services/Mods/ProfileAssetPaths.cs @@ -5,7 +5,7 @@ namespace UnchainedLauncher.Core.Services.Mods; public sealed class ProfileAssetPaths { public const string SharedSuffix = "unchained"; public string Root { get; } - public string Paks => Path.Combine(Root, "paks"); + public string Paks => Path.Combine(Path.GetDirectoryName(Root)!, "Paks"); public string Plugins => Path.Combine(Root, "plugins"); public string PakCache { get; } public string DllCache { get; } @@ -24,4 +24,4 @@ public PakDir CreatePakDir(string installation, IEnumerable? managed Directory.CreateDirectory(Paks); return new PakDir(Paks, managedPaks ?? [], Path.Combine(installation, FilePaths.PakDir, "pakchunk0-WindowsNoEditor.sig"), PakCache); } -} \ No newline at end of file +} diff --git a/UnchainedLauncher.GUI/App.xaml.cs b/UnchainedLauncher.GUI/App.xaml.cs index 76710bc8..297125fe 100644 --- a/UnchainedLauncher.GUI/App.xaml.cs +++ b/UnchainedLauncher.GUI/App.xaml.cs @@ -189,13 +189,7 @@ select chiv2Installations.Path ); var unchainedLauncher = new UnchainedChivalry2Launcher( - Chivalry2LaunchPreparer.Create(options => { - if (System.Text.RegularExpressions.Regex.IsMatch(options.LaunchArgs, @"(?:^|\s)--additional-pak-dir(?:\s|=|$)")) - throw new ArgumentException("The additional pak directory is managed by the launcher."); - return LanguageExt.Prelude.Some(options with { - LaunchArgs = options.LaunchArgs + " " + new Parameter("--additional-pak-dir", assets.Paks).Rendered - }); - }).AndThen(pluginInstaller) + pluginInstaller .AndThen(modInstaller) .Sub(sigLaunchPreparer), unchainedProcessLauncher, @@ -434,4 +428,4 @@ private void ExecuteExitAction(string source, Action exitAction) { } } } -} \ No newline at end of file +} From fda17d2219013f8043f5906853807af1c5613eba Mon Sep 17 00:00:00 2001 From: Jacob Barber Date: Mon, 19 Oct 2026 20:03:09 -0500 Subject: [PATCH 33/58] Give managed mod paks native patch priority --- .../ManagedInventoryTests.cs | 14 ++++++++------ UnchainedLauncher.CLI/README.md | 3 +++ UnchainedLauncher.Core/Services/Mods/PakDir.cs | 16 ++++++++++------ 3 files changed, 21 insertions(+), 12 deletions(-) diff --git a/UnchainedLauncher.CLI.Tests/ManagedInventoryTests.cs b/UnchainedLauncher.CLI.Tests/ManagedInventoryTests.cs index e042f5a0..41816a60 100644 --- a/UnchainedLauncher.CLI.Tests/ManagedInventoryTests.cs +++ b/UnchainedLauncher.CLI.Tests/ManagedInventoryTests.cs @@ -143,12 +143,12 @@ await InInstallation(async (source, common, extra, _) => { public async Task SharedOwnershipPreservesUnmanagedCollisionAndUpdatesLoadOrderPrefixes() { await InInstallation(async (registry, common, extra, launch) => { var paks = Path.GetFullPath(FilePaths.PakDir); - var occupied = Path.Combine(paks, "q__-__Unchained-Mods.pak"); + var occupied = Path.Combine(paks, "q__-__Unchained-Mods_0_P.pak"); File.WriteAllText(occupied, "user collision"); var manager = Manager(registry, []); await Prepare(manager, launch); var original = Assert.Single(((PakDir)manager.PakDir).ManagedPaks); - Assert.Equal("q__-__Unchained-Mods (1).pak", original.PakFileName); + Assert.Equal("q__-__Unchained-Mods (1)_0_P.pak", original.PakFileName); Assert.True(manager.EnableModRelease(ReleaseCoordinates.FromRelease(extra))); await Prepare(manager, launch); @@ -160,7 +160,8 @@ await InInstallation(async (registry, common, extra, launch) => { Assert.False(File.Exists(Path.Combine(paks, original.PakFileName))); Assert.Equal("user collision", File.ReadAllText(occupied)); Assert.Equal(2, owned.Length); - Assert.All(owned, pak => Assert.True(File.Exists(Path.ChangeExtension(Path.Combine(paks, pak.PakFileName), ".sig")))); + Assert.All(owned, pak => Assert.True(File.Exists(Path.ChangeExtension(Path.Combine(paks, pak.PakFileName), ".sig")))); + Assert.All(owned, pak => Assert.EndsWith("_0_P.pak", pak.PakFileName)); await Prepare(manager, launch); Assert.Equal(owned, ((PakDir)manager.PakDir).ManagedPaks); @@ -209,8 +210,9 @@ private static async Task Prepare(ModManager manager, LaunchOptions launch) { private static void AssertOnlyCommonAndUnknown(ModManager manager, Release common) { var pakDir = (PakDir)manager.PakDir; - var owned = Assert.Single(pakDir.ManagedPaks); - Assert.Equal(ReleaseCoordinates.FromRelease(common), owned.Coordinates); + var owned = Assert.Single(pakDir.ManagedPaks); + Assert.Equal(ReleaseCoordinates.FromRelease(common), owned.Coordinates); + Assert.EndsWith("_0_P.pak", owned.PakFileName); Assert.Equal(3, Directory.GetFiles(pakDir.DirPath, "*.pak").Length); // base, common, unknown Assert.Equal("user payload", File.ReadAllText(Path.Combine(pakDir.DirPath, "user.pak"))); Assert.Equal("base payload", File.ReadAllText(Path.Combine(pakDir.DirPath, "pakchunk0-WindowsNoEditor.pak"))); @@ -245,4 +247,4 @@ await ServerLauncher.InInstallationDirectoryAsync(request, async () => { return 0; }); } -} \ No newline at end of file +} diff --git a/UnchainedLauncher.CLI/README.md b/UnchainedLauncher.CLI/README.md index 7e6092d4..7515b528 100644 --- a/UnchainedLauncher.CLI/README.md +++ b/UnchainedLauncher.CLI/README.md @@ -363,6 +363,9 @@ and individual CLI launch arguments still launch one server. CLI processes from using the same profile. Plugin injection never scans another instance's directory, including with the unsafe flag. - The game discovers profile paks without an additional plugin argument. + Managed filenames end in `_0_P.pak`, using Unreal's native patch priority so + profile mods override vanilla assets. All managed mods share the same patch + version; dependency-order prefixes still determine their relative order. Signatures use the installation's base signature as a read-only template; no base pak is copied or modified. - Relaunch with the desired `--mod` selections to install them into `Paks`. diff --git a/UnchainedLauncher.Core/Services/Mods/PakDir.cs b/UnchainedLauncher.Core/Services/Mods/PakDir.cs index a4b90ea5..c878471c 100644 --- a/UnchainedLauncher.Core/Services/Mods/PakDir.cs +++ b/UnchainedLauncher.Core/Services/Mods/PakDir.cs @@ -18,7 +18,9 @@ public class PakDir : IPakDir { private const string BasePakFileName = "pakchunk0-WindowsNoEditor.pak"; private const string BaseSigFileName = "pakchunk0-WindowsNoEditor.sig"; - private const string ForcedSortDivider = "__-__"; + private const string ForcedSortDivider = "__-__"; + // A fixed patch version keeps all mods above vanilla at the same priority tier. + private const string ManagedPakSuffix = "_0_P.pak"; private static readonly ILog Logger = LogManager.GetLogger(nameof(PakDir)); @@ -103,8 +105,9 @@ public Either Uninstall(ModIdentifier coords) => private string AvailablePakName(string suggestedFileName) { var unManagedPaks = GetUnmanagedPaks().ToHashSet(StringComparer.OrdinalIgnoreCase); - var actualName = Path.GetFileNameWithoutExtension(suggestedFileName); - var extension = Path.GetExtension(suggestedFileName); + var extension = suggestedFileName.EndsWith(ManagedPakSuffix, StringComparison.OrdinalIgnoreCase) + ? ManagedPakSuffix : Path.GetExtension(suggestedFileName); + var actualName = suggestedFileName[..^extension.Length]; var fullActualName = actualName + extension; while (_managedPaks.Exists(x => x.PakFileName.Equals(fullActualName, StringComparison.OrdinalIgnoreCase)) || unManagedPaks.Contains(PakNameToPakPath(fullActualName))) { @@ -146,7 +149,7 @@ public async IAsyncEnumerable> InstallModSet( var baseName = needsOrgPrefix ? $"{install.Coordinates.Org}_{moduleName}" : moduleName; - return baseName + ".pak"; + return baseName + ManagedPakSuffix; }) .ToList(); @@ -168,7 +171,8 @@ public async IAsyncEnumerable> InstallModSet( return existing.Match( // A collision-safe name is reusable only while its load-order prefix is correct. Some: pak => pak.PakFileName.Equals(item.FinalPakName, StringComparison.OrdinalIgnoreCase) - || (unmanagedPaths.Contains(PakNameToPakPath(item.FinalPakName)) + || (pak.PakFileName.EndsWith(ManagedPakSuffix, StringComparison.OrdinalIgnoreCase) + && unmanagedPaths.Contains(PakNameToPakPath(item.FinalPakName)) && pak.PakFileName.StartsWith( item.FinalPakName.Substring(0, item.FinalPakName.IndexOf(ForcedSortDivider, StringComparison.Ordinal) + ForcedSortDivider.Length), StringComparison.OrdinalIgnoreCase)) @@ -462,4 +466,4 @@ private static IEnumerable ApplySortedLexicographically(IEnumerable Date: Mon, 19 Oct 2026 20:10:36 -0500 Subject: [PATCH 34/58] Replace alphabetic pak prefixes with numeric patch order --- .../ManagedInventoryTests.cs | 15 +-- .../NumericPakOrderingTests.cs | 103 ++++++++++++++++++ UnchainedLauncher.CLI/README.md | 8 +- .../Services/Mods/IPakDir.cs | 4 +- .../Services/Mods/PakDir.cs | 76 ++++--------- 5 files changed, 139 insertions(+), 67 deletions(-) create mode 100644 UnchainedLauncher.CLI.Tests/NumericPakOrderingTests.cs diff --git a/UnchainedLauncher.CLI.Tests/ManagedInventoryTests.cs b/UnchainedLauncher.CLI.Tests/ManagedInventoryTests.cs index 41816a60..89a0ec4c 100644 --- a/UnchainedLauncher.CLI.Tests/ManagedInventoryTests.cs +++ b/UnchainedLauncher.CLI.Tests/ManagedInventoryTests.cs @@ -140,28 +140,29 @@ await InInstallation(async (source, common, extra, _) => { } [Fact] - public async Task SharedOwnershipPreservesUnmanagedCollisionAndUpdatesLoadOrderPrefixes() { + public async Task SharedOwnershipPreservesUnmanagedCollisionAndUpdatesPatchOrders() { await InInstallation(async (registry, common, extra, launch) => { var paks = Path.GetFullPath(FilePaths.PakDir); - var occupied = Path.Combine(paks, "q__-__Unchained-Mods_0_P.pak"); + var occupied = Path.Combine(paks, "Unchained-Mods_0_P.pak"); File.WriteAllText(occupied, "user collision"); var manager = Manager(registry, []); await Prepare(manager, launch); var original = Assert.Single(((PakDir)manager.PakDir).ManagedPaks); - Assert.Equal("q__-__Unchained-Mods (1)_0_P.pak", original.PakFileName); + Assert.Equal("Unchained-Mods (1)_0_P.pak", original.PakFileName); Assert.True(manager.EnableModRelease(ReleaseCoordinates.FromRelease(extra))); await Prepare(manager, launch); var owned = ((PakDir)manager.PakDir).ManagedPaks.ToArray(); var expectedOrder = manager.SortByDependencies(manager.GetEnabledAndDependencyReleases()) - .Reverse().Select(ReleaseCoordinates.FromRelease).ToArray(); - Assert.Equal(expectedOrder, owned.OrderBy(pak => pak.PakFileName, StringComparer.OrdinalIgnoreCase) + .Select(ReleaseCoordinates.FromRelease).ToArray(); + Assert.Equal(expectedOrder, owned.OrderBy(pak => pak.Priority) .Select(pak => pak.Coordinates)); - Assert.False(File.Exists(Path.Combine(paks, original.PakFileName))); + Assert.True(File.Exists(Path.Combine(paks, original.PakFileName))); + Assert.Equal(original.PakFileName, Assert.Single(owned, pak => pak.Coordinates == original.Coordinates).PakFileName); Assert.Equal("user collision", File.ReadAllText(occupied)); Assert.Equal(2, owned.Length); Assert.All(owned, pak => Assert.True(File.Exists(Path.ChangeExtension(Path.Combine(paks, pak.PakFileName), ".sig")))); - Assert.All(owned, pak => Assert.EndsWith("_0_P.pak", pak.PakFileName)); + Assert.All(owned, pak => Assert.EndsWith($"_{pak.Priority}_P.pak", pak.PakFileName)); await Prepare(manager, launch); Assert.Equal(owned, ((PakDir)manager.PakDir).ManagedPaks); diff --git a/UnchainedLauncher.CLI.Tests/NumericPakOrderingTests.cs b/UnchainedLauncher.CLI.Tests/NumericPakOrderingTests.cs new file mode 100644 index 00000000..c9d50eac --- /dev/null +++ b/UnchainedLauncher.CLI.Tests/NumericPakOrderingTests.cs @@ -0,0 +1,103 @@ +using LanguageExt.Common; +using System.Text; +using UnchainedLauncher.Core.Services.Mods; +using UnchainedLauncher.Core.Services.Mods.Registry; +using UnchainedLauncher.Core.Utilities; +using Xunit; +using static LanguageExt.Prelude; + +namespace UnchainedLauncher.CLI.Tests; + +public sealed class NumericPakOrderingTests : IDisposable { + private readonly string _root = Path.Combine(Path.GetTempPath(), "numeric-paks-" + Guid.NewGuid().ToString("N")); + private int _downloads; + + public NumericPakOrderingTests() => Directory.CreateDirectory(_root); + public void Dispose() => Directory.Delete(_root, recursive: true); + + private ModInstallRequest Request(string module, string org = "org") => + new(new ReleaseCoordinates(org, module, "v1.0.0"), path => { + _downloads++; + var bytes = Encoding.UTF8.GetBytes(org + "/" + module); + return RightAsync(new FileWriter(path, new MemoryStream(bytes), bytes.Length)); + }); + + private static async Task Install(PakDir directory, params ModInstallRequest[] requests) { + await foreach (var result in directory.InstallModSet(requests, None)) + Assert.True(result.IsRight, result.ToString()); + } + + [Fact] + public async Task NumericOrdersFollowInputRatherThanAlphabeticalNamesPastTenMods() { + var directory = new PakDir(_root, []); + var requests = Enumerable.Range(0, 12).Select(i => Request($"Mod{11 - i}")).ToArray(); + await Install(directory, requests); + var installed = directory.ManagedPaks.OrderBy(pak => pak.Priority).ToArray(); + Assert.Equal(12, installed.Length); + for (var order = 0; order < installed.Length; order++) { + Assert.Equal(requests[order].Coordinates, installed[order].Coordinates); + Assert.Equal($"Mod{11 - order}_{order}_P.pak", installed[order].PakFileName); + } + await Install(directory, requests); + Assert.Equal(installed, directory.ManagedPaks.OrderBy(pak => pak.Priority)); + Assert.Equal(12, _downloads); + } + + [Fact] + public async Task ReorderingPreservesPayloadsAndSignaturesAndKeepsCollisionCountersBeforeOrder() { + var directory = new PakDir(_root, []); + var first = Request("First"); + var second = Request("Second"); + await Install(directory, first, second); + File.WriteAllText(Path.Combine(_root, "First_0_P.sig"), "first signature"); + var occupied = Path.Combine(_root, "First_1_P.pak"); + File.WriteAllText(occupied, "unmanaged"); + + await Install(directory, second, first); + var installed = directory.ManagedPaks.OrderBy(pak => pak.Priority).ToArray(); + Assert.Equal(["Second_0_P.pak", "First (1)_1_P.pak"], installed.Select(pak => pak.PakFileName)); + Assert.Equal("org/First", File.ReadAllText(Path.Combine(_root, installed[1].PakFileName))); + Assert.Equal("first signature", File.ReadAllText(Path.Combine(_root, "First (1)_1_P.sig"))); + Assert.False(File.Exists(Path.Combine(_root, "First_0_P.pak"))); + Assert.Equal("unmanaged", File.ReadAllText(occupied)); + await Install(directory, second, first); + Assert.Equal(installed, directory.ManagedPaks.OrderBy(pak => pak.Priority)); + Assert.Equal(2, _downloads); + + File.Delete(occupied); + await Install(directory, second, first); + Assert.Contains(directory.ManagedPaks, pak => pak.PakFileName == "First_1_P.pak"); + Assert.False(File.Exists(Path.Combine(_root, "First (1)_1_P.pak"))); + Assert.Equal("first signature", File.ReadAllText(Path.Combine(_root, "First_1_P.sig"))); + Assert.Equal(2, _downloads); + } + + [Theory] + [InlineData("q__-__Legacy_0_P.pak", false)] + [InlineData("q__-__Legacy.pak", false)] + [InlineData("q__-__Legacy_0_P.pak", true)] + [InlineData("q__-__Legacy.pak", true)] + public async Task LegacyManagedNamesMigrateWithoutDownloading(string legacyName, bool collision) { + var request = Request("Legacy"); + File.WriteAllText(Path.Combine(_root, legacyName), "legacy payload"); + File.WriteAllText(Path.ChangeExtension(Path.Combine(_root, legacyName), ".sig"), "legacy signature"); + if (collision) File.WriteAllText(Path.Combine(_root, "Legacy_0_P.pak"), "unmanaged"); + var directory = new PakDir(_root, [new ManagedPak(request.Coordinates, legacyName, 0)]); + await Install(directory, request); + var expected = collision ? "Legacy (1)_0_P.pak" : "Legacy_0_P.pak"; + Assert.Equal(expected, Assert.Single(directory.ManagedPaks).PakFileName); + Assert.Equal("legacy payload", File.ReadAllText(Path.Combine(_root, expected))); + Assert.Equal("legacy signature", File.ReadAllText(Path.ChangeExtension(Path.Combine(_root, expected), ".sig"))); + if (collision) Assert.Equal("unmanaged", File.ReadAllText(Path.Combine(_root, "Legacy_0_P.pak"))); + Assert.False(File.Exists(Path.Combine(_root, legacyName))); + Assert.Equal(0, _downloads); + } + + [Fact] + public async Task DuplicateModuleNamesRetainOrganizationDisambiguation() { + var directory = new PakDir(_root, []); + await Install(directory, Request("Common", "one"), Request("Common", "two")); + Assert.Equal(["one_Common_0_P.pak", "two_Common_1_P.pak"], + directory.ManagedPaks.OrderBy(pak => pak.Priority).Select(pak => pak.PakFileName)); + } +} diff --git a/UnchainedLauncher.CLI/README.md b/UnchainedLauncher.CLI/README.md index 7515b528..0224ea19 100644 --- a/UnchainedLauncher.CLI/README.md +++ b/UnchainedLauncher.CLI/README.md @@ -363,9 +363,11 @@ and individual CLI launch arguments still launch one server. CLI processes from using the same profile. Plugin injection never scans another instance's directory, including with the unsafe flag. - The game discovers profile paks without an additional plugin argument. - Managed filenames end in `_0_P.pak`, using Unreal's native patch priority so - profile mods override vanilla assets. All managed mods share the same patch - version; dependency-order prefixes still determine their relative order. + Managed filenames use `__P.pak`, using Unreal's native patch priority + instead of alphabetical prefixes. Orders start at zero in dependency order; + higher numbers override lower numbers, so dependents override their dependencies + and all managed mods override vanilla assets. Collision counters stay before + the order suffix, for example `SomeMod (1)_2_P.pak`. Signatures use the installation's base signature as a read-only template; no base pak is copied or modified. - Relaunch with the desired `--mod` selections to install them into `Paks`. diff --git a/UnchainedLauncher.Core/Services/Mods/IPakDir.cs b/UnchainedLauncher.Core/Services/Mods/IPakDir.cs index 8b61ea95..61808ddc 100644 --- a/UnchainedLauncher.Core/Services/Mods/IPakDir.cs +++ b/UnchainedLauncher.Core/Services/Mods/IPakDir.cs @@ -16,7 +16,7 @@ public record ModInstallRequest(ReleaseCoordinates Coordinates, IPakDir.MakeFile /// /// The release coordinates identifying this pak /// The filename of the pak file within the pak directory - /// The load priority of this pak (lower values load first) + /// The patch priority of this pak (higher values override lower values) public record ManagedPak(ReleaseCoordinates Coordinates, string PakFileName, int Priority) { public override string ToString() => $"{Coordinates} @ {PakFileName} (priority {Priority})"; } @@ -87,4 +87,4 @@ public interface IPakDir { /// string DirPath { get; } } -} \ No newline at end of file +} diff --git a/UnchainedLauncher.Core/Services/Mods/PakDir.cs b/UnchainedLauncher.Core/Services/Mods/PakDir.cs index c878471c..d735dd08 100644 --- a/UnchainedLauncher.Core/Services/Mods/PakDir.cs +++ b/UnchainedLauncher.Core/Services/Mods/PakDir.cs @@ -1,7 +1,6 @@ using LanguageExt; using LanguageExt.Common; using log4net; -using System.Text; using System.Text.RegularExpressions; using UnchainedLauncher.Core.Extensions; using UnchainedLauncher.Core.Services.Mods.Registry; @@ -18,9 +17,6 @@ public class PakDir : IPakDir { private const string BasePakFileName = "pakchunk0-WindowsNoEditor.pak"; private const string BaseSigFileName = "pakchunk0-WindowsNoEditor.sig"; - private const string ForcedSortDivider = "__-__"; - // A fixed patch version keeps all mods above vanilla at the same priority tier. - private const string ManagedPakSuffix = "_0_P.pak"; private static readonly ILog Logger = LogManager.GetLogger(nameof(PakDir)); @@ -103,10 +99,21 @@ public Either Uninstall(ModIdentifier coords) => } ); - private string AvailablePakName(string suggestedFileName) { + private static string PakSuffix(string fileName) { + var patch = Regex.Match(fileName, @"_[0-9]+_P\.pak\z", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + return patch.Success ? patch.Value : Path.GetExtension(fileName); + } + + private static bool IsCollisionName(string fileName, string targetName) { + var suffix = PakSuffix(targetName); + return Regex.IsMatch(fileName, + @"\A" + Regex.Escape(targetName[..^suffix.Length]) + @" \([0-9]+\)" + Regex.Escape(suffix) + @"\z", + RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + } + + private string AvailablePakName(string suggestedFileName) { var unManagedPaks = GetUnmanagedPaks().ToHashSet(StringComparer.OrdinalIgnoreCase); - var extension = suggestedFileName.EndsWith(ManagedPakSuffix, StringComparison.OrdinalIgnoreCase) - ? ManagedPakSuffix : Path.GetExtension(suggestedFileName); + var extension = PakSuffix(suggestedFileName); var actualName = suggestedFileName[..^extension.Length]; var fullActualName = actualName + extension; while (_managedPaks.Exists(x => x.PakFileName.Equals(fullActualName, StringComparison.OrdinalIgnoreCase)) @@ -143,19 +150,17 @@ public async IAsyncEnumerable> InstallModSet( .ToHashSet(); var pakFileNames = installsList - .Select(install => { + .Select((install, order) => { var moduleName = install.Coordinates.ModuleName; var needsOrgPrefix = moduleNameCounts.Contains(moduleName); var baseName = needsOrgPrefix ? $"{install.Coordinates.Org}_{moduleName}" : moduleName; - return baseName + ManagedPakSuffix; + // Unreal gives higher patch numbers priority over dependencies and vanilla. + return FormattableString.Invariant($"{baseName}_{order}_P.pak"); }) .ToList(); - // Apply sorted lexicographically to get the final names - var sortedPakNames = ApplySortedLexicographically(pakFileNames).ToList(); - // Build map of existing managed paks by coordinates (for mods in install set) var existingByCoords = _managedPaks .Filter(p => installCoordinatesSet.Contains(p.Coordinates)) @@ -165,17 +170,14 @@ public async IAsyncEnumerable> InstallModSet( // Categorize each install: Skip (already correct), Move (relocate), or Download (new) var installActions = installsList - .Zip(sortedPakNames, (install, finalPakName) => (Install: install, FinalPakName: finalPakName)) + .Zip(pakFileNames, (install, finalPakName) => (Install: install, FinalPakName: finalPakName)) .Select((item, index) => { var existing = existingByCoords.TryGetValue(item.Install.Coordinates, out var e) ? Some(e) : None; return existing.Match( - // A collision-safe name is reusable only while its load-order prefix is correct. + // Reuse collision-safe names only while their module and numeric order match. Some: pak => pak.PakFileName.Equals(item.FinalPakName, StringComparison.OrdinalIgnoreCase) - || (pak.PakFileName.EndsWith(ManagedPakSuffix, StringComparison.OrdinalIgnoreCase) - && unmanagedPaths.Contains(PakNameToPakPath(item.FinalPakName)) - && pak.PakFileName.StartsWith( - item.FinalPakName.Substring(0, item.FinalPakName.IndexOf(ForcedSortDivider, StringComparison.Ordinal) + ForcedSortDivider.Length), - StringComparison.OrdinalIgnoreCase)) + || (unmanagedPaths.Contains(PakNameToPakPath(item.FinalPakName)) + && IsCollisionName(pak.PakFileName, item.FinalPakName)) ? new InstallAction.Skip(item.Install, index, pak) : new InstallAction.Move(item.Install, index, pak, item.FinalPakName) as InstallAction, None: () => new InstallAction.Download(item.Install, index, item.FinalPakName) @@ -429,41 +431,5 @@ public Either, Unit> DeleteOrphanedSigs() { .BindLefts(); } - /// - /// represents i using characters from alphabet - /// - /// - /// must be positive - /// - private static string RepUsingAlphabet(string alphabet, int i) { - var systemBase = alphabet.Length; - var sb = new StringBuilder(); - while (i > 0) { - sb.Append(alphabet[i % systemBase]); - i /= systemBase; - } - - return string.Join("", sb.ToString().Reverse()); - } - - /// - /// Given a sequence of strings, modify those strings such - /// that they are sorted lexicographically and return it - /// - /// - /// - private static IEnumerable ApplySortedLexicographically(IEnumerable inputs) { - var inputsList = inputs.ToList(); - - // We reverse the alphabet because we actually want to load dependencies backwards - var alphabet = new string("abcdefghijklmnopqrstuvwxyz".Reverse().ToArray()); - - var requiredSymbols = (int)Math.Ceiling(Math.Log(inputsList.Count, alphabet.Length)); - return inputsList.Map((i, n) => - $"q{RepUsingAlphabet(alphabet, i).PadLeft(requiredSymbols, alphabet[0])}" + - ForcedSortDivider + - $"{Regex.Replace(n, $".*{ForcedSortDivider}", "")}" - ); - } } } From 11edaa6e4ac49bafd831be50fe4915cebe5eacc6 Mon Sep 17 00:00:00 2001 From: Jacob Barber Date: Mon, 19 Oct 2026 20:26:10 -0500 Subject: [PATCH 35/58] Preserve managed pak ownership during relocation failures --- .../Services/PakDir/PakOrderCollisionTests.cs | 211 +++++++++++++++--- .../Services/Mods/PakDir.cs | 54 +++-- 2 files changed, 219 insertions(+), 46 deletions(-) diff --git a/UnchainedLauncher.Core.Tests.Unit/Services/PakDir/PakOrderCollisionTests.cs b/UnchainedLauncher.Core.Tests.Unit/Services/PakDir/PakOrderCollisionTests.cs index 01cab1a9..5dc1f3d8 100644 --- a/UnchainedLauncher.Core.Tests.Unit/Services/PakDir/PakOrderCollisionTests.cs +++ b/UnchainedLauncher.Core.Tests.Unit/Services/PakDir/PakOrderCollisionTests.cs @@ -41,24 +41,24 @@ private void AssertUnmanagedUnchanged() { [InlineData(false)] [InlineData(true)] public async Task GrowthAndReorderingPreserveBytesAndMatchFreshInstallNames(bool uppercaseCollision) { - Occupy("q__-__Dependency.pak"); - Occupy(uppercaseCollision ? "QZ__-__DEPENDENCY.pak" : "qz__-__Dependency.pak"); - Occupy("qz__-__Dependency (1).pak"); - Occupy("qy__-__Dependency.pak"); - Occupy("qz__-__Dependent.pak"); + Occupy("Dependency_0_P.pak"); + Occupy(uppercaseCollision ? "DEPENDENCY_1_P.pak" : "Dependency_1_P.pak"); + Occupy("Dependency (1)_1_P.pak"); + Occupy("Dependent_0_P.pak"); + Occupy("Dependent_1_P.pak"); var dir = new ManagedPakDirectory(_dir, []); (await Install(dir, _dependency)).ForEach(r => r.IfLeft(e => throw e)); var original = Assert.Single(dir.ManagedPaks); - Assert.Equal("q__-__Dependency (1).pak", original.PakFileName); + Assert.Equal("Dependency (1)_0_P.pak", original.PakFileName); var originalPath = Path.Combine(_dir, original.PakFileName); var bytes = File.ReadAllBytes(originalPath); var sigBytes = File.ReadAllBytes(Path.ChangeExtension(originalPath, ".sig")); - (await Install(dir, _dependency, _dependent)).ForEach(r => r.IfLeft(e => throw e)); + (await Install(dir, _dependent, _dependency)).ForEach(r => r.IfLeft(e => throw e)); Assert.Equal(2, _writes); - Assert.Equal(new[] { _dependent, _dependency }, dir.ManagedPaks.OrderBy(p => p.PakFileName, StringComparer.OrdinalIgnoreCase).Select(p => p.Coordinates)); + Assert.Equal(new[] { _dependent, _dependency }, dir.ManagedPaks.OrderBy(p => p.Priority).Select(p => p.Coordinates)); var moved = dir.ManagedPaks.Single(p => p.Coordinates == _dependency); - Assert.Equal("qz__-__Dependency (2).pak", moved.PakFileName); + Assert.Equal("Dependency (2)_1_P.pak", moved.PakFileName); Assert.Equal(bytes, File.ReadAllBytes(Path.Combine(_dir, moved.PakFileName))); Assert.Equal(sigBytes, File.ReadAllBytes(Path.ChangeExtension(Path.Combine(_dir, moved.PakFileName), ".sig"))); Assert.False(File.Exists(originalPath)); @@ -66,7 +66,7 @@ public async Task GrowthAndReorderingPreserveBytesAndMatchFreshInstallNames(bool var names = dir.ManagedPaks.OrderBy(p => p.Priority).Select(p => p.PakFileName).ToArray(); // Reconstructing persisted inventory and retrying must not rename or download anything. dir = new ManagedPakDirectory(_dir, dir.ManagedPaks); - (await Install(dir, _dependency, _dependent)).ForEach(r => r.IfLeft(e => throw e)); + (await Install(dir, _dependent, _dependency)).ForEach(r => r.IfLeft(e => throw e)); Assert.Equal(names, dir.ManagedPaks.OrderBy(p => p.Priority).Select(p => p.PakFileName)); Assert.Equal(2, _writes); @@ -75,13 +75,13 @@ public async Task GrowthAndReorderingPreserveBytesAndMatchFreshInstallNames(bool foreach (var (name, content) in _unmanaged) File.WriteAllBytes(Path.Combine(freshPath, name), content); File.WriteAllText(Path.Combine(freshPath, "pakchunk0-WindowsNoEditor.sig"), "signature"); var fresh = new ManagedPakDirectory(freshPath, []); - (await Install(fresh, _dependency, _dependent)).ForEach(r => r.IfLeft(e => throw e)); + (await Install(fresh, _dependent, _dependency)).ForEach(r => r.IfLeft(e => throw e)); Assert.Equal(names, fresh.ManagedPaks.OrderBy(p => p.Priority).Select(p => p.PakFileName)); - (await Install(dir, _dependent, _dependency)).ForEach(r => r.IfLeft(e => throw e)); - Assert.Equal(new[] { _dependency, _dependent }, dir.ManagedPaks.OrderBy(p => p.PakFileName, StringComparer.OrdinalIgnoreCase).Select(p => p.Coordinates)); - Assert.Equal("qy__-__Dependency (1).pak", dir.ManagedPaks.Single(p => p.Coordinates == _dependency).PakFileName); - Assert.Equal("qz__-__Dependent (1).pak", dir.ManagedPaks.Single(p => p.Coordinates == _dependent).PakFileName); + (await Install(dir, _dependency, _dependent)).ForEach(r => r.IfLeft(e => throw e)); + Assert.Equal(new[] { _dependency, _dependent }, dir.ManagedPaks.OrderBy(p => p.Priority).Select(p => p.Coordinates)); + Assert.Equal("Dependency (1)_0_P.pak", dir.ManagedPaks.Single(p => p.Coordinates == _dependency).PakFileName); + Assert.Equal("Dependent (1)_1_P.pak", dir.ManagedPaks.Single(p => p.Coordinates == _dependent).PakFileName); (await Install(dir, _dependency)).ForEach(r => r.IfLeft(e => throw e)); Assert.Equal(original.PakFileName, Assert.Single(dir.ManagedPaks).PakFileName); Assert.Equal(4, _writes); // Only the two original downloads and two fresh-install downloads. @@ -92,18 +92,177 @@ public async Task GrowthAndReorderingPreserveBytesAndMatchFreshInstallNames(bool [InlineData(false)] [InlineData(true)] public async Task CollisionRelocationFailureReturnsErrorWithoutPublishingNewInventory(bool signatureFailure) { - Occupy("q__-__Dependency.pak"); - Occupy("qz__-__Dependency.pak"); + Occupy("Dependency_0_P.pak"); + Occupy("Dependency_1_P.pak"); var dir = new ManagedPakDirectory(_dir, []); (await Install(dir, _dependency)).ForEach(r => r.IfLeft(e => throw e)); var original = Assert.Single(dir.ManagedPaks); - var blocked = Path.Combine(_dir, "qz__-__Dependency (1).pak"); - Directory.CreateDirectory(signatureFailure ? Path.ChangeExtension(blocked, ".sig") : blocked); - var results = await Install(dir, _dependency, _dependent); + var originalPath = Path.Combine(_dir, original.PakFileName); + var bytes = File.ReadAllBytes(originalPath); + var sigBytes = File.ReadAllBytes(Path.ChangeExtension(originalPath, ".sig")); + var blocked = Path.Combine(_dir, "Dependency (1)_1_P.pak"); + var obstruction = signatureFailure ? Path.ChangeExtension(blocked, ".sig") : blocked; + Directory.CreateDirectory(obstruction); + var results = await Install(dir, _dependent, _dependency); Assert.Contains(results, r => r.IsLeft); - Assert.Equal(original, dir.ManagedPaks.Single(p => p.Coordinates == _dependency)); - AssertUnmanagedUnchanged(); - } - - public void Dispose() => Directory.Delete(_dir, true); -} \ No newline at end of file + Assert.Equal(original, dir.ManagedPaks.Single(p => p.Coordinates == _dependency)); + Assert.Equal(bytes, File.ReadAllBytes(originalPath)); + Assert.Equal(sigBytes, File.ReadAllBytes(Path.ChangeExtension(originalPath, ".sig"))); + if (!signatureFailure) Assert.False(File.Exists(Path.ChangeExtension(blocked, ".sig"))); + dir = new ManagedPakDirectory(_dir, dir.ManagedPaks); + Assert.Equal(original, dir.ManagedPaks.Single(p => p.Coordinates == _dependency)); + Directory.Delete(obstruction); + (await Install(dir, _dependent, _dependency)).ForEach(r => r.IfLeft(e => throw e)); + var moved = dir.ManagedPaks.Single(p => p.Coordinates == _dependency); + Assert.Equal("Dependency (1)_1_P.pak", moved.PakFileName); + Assert.Equal(bytes, File.ReadAllBytes(Path.Combine(_dir, moved.PakFileName))); + Assert.Equal(sigBytes, File.ReadAllBytes(Path.ChangeExtension(Path.Combine(_dir, moved.PakFileName), ".sig"))); + Assert.False(File.Exists(originalPath)); + Assert.False(File.Exists(Path.ChangeExtension(originalPath, ".sig"))); + Assert.Single(Directory.GetFiles(_dir, "Dependency*.pak").Where(p => !_unmanaged.ContainsKey(Path.GetFileName(p)))); + Assert.Equal(2, _writes); + AssertUnmanagedUnchanged(); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task LegacyMigrationPreservesSignatureOnlyCollisionsAcrossRestart(bool uppercaseCollision) { + var legacy = new ManagedPak(_dependency, "q__-__Dependency.pak", 0); + var source = Path.Combine(_dir, legacy.PakFileName); + File.WriteAllText(source, "owned payload"); + File.WriteAllText(Path.ChangeExtension(source, ".sig"), "owned signature"); + var unmanagedSig = uppercaseCollision ? "DEPENDENCY_0_P.sig" : "Dependency_0_P.sig"; + var unmanagedBytes = System.Text.Encoding.UTF8.GetBytes("unmanaged signature"); + _unmanaged.Add(unmanagedSig, unmanagedBytes); + File.WriteAllBytes(Path.Combine(_dir, unmanagedSig), unmanagedBytes); + var dir = new ManagedPakDirectory(_dir, [legacy]); + (await Install(dir, _dependency)).ForEach(r => r.IfLeft(e => throw e)); + var migrated = Assert.Single(dir.ManagedPaks); + Assert.Equal("Dependency (1)_0_P.pak", migrated.PakFileName); + dir = new ManagedPakDirectory(_dir, dir.ManagedPaks); + (await Install(dir, _dependency)).ForEach(r => r.IfLeft(e => throw e)); + Assert.Equal(migrated, Assert.Single(dir.ManagedPaks)); + Assert.Single(Directory.GetFiles(_dir, "*.pak")); + Assert.Equal("owned payload", File.ReadAllText(Path.Combine(_dir, migrated.PakFileName))); + Assert.Equal("owned signature", File.ReadAllText(Path.ChangeExtension(Path.Combine(_dir, migrated.PakFileName), ".sig"))); + Assert.False(File.Exists(source)); + Assert.False(File.Exists(Path.ChangeExtension(source, ".sig"))); + Assert.Equal(0, _writes); + AssertUnmanagedUnchanged(); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task CachedLegacyPayloadSurvivesFailedMigrationAndRestart(bool signatureFailure) { + var bytes = System.Text.Encoding.UTF8.GetBytes("immutable cache payload"); + var hash = Convert.ToHexString(System.Security.Cryptography.SHA512.HashData(bytes)).ToLowerInvariant(); + var request = new ModInstallRequest(_dependency, path => { + _writes++; + return LanguageExt.Prelude.RightAsync(new FileWriter(path, new MemoryStream(bytes), bytes.Length)); + }, hash); + var cache = new PakCache(Path.Combine(_dir, "cache")); + var legacy = new ManagedPak(_dependency, "q__-__Dependency.pak", 0); + var source = Path.Combine(_dir, legacy.PakFileName); + (await cache.Install(request, source, LanguageExt.Prelude.None)).IfLeft(e => throw e); + File.WriteAllText(Path.ChangeExtension(source, ".sig"), "owned signature"); + var target = Path.Combine(_dir, "Dependency_0_P.pak"); + var obstruction = signatureFailure ? Path.ChangeExtension(target, ".sig") : target; + Directory.CreateDirectory(obstruction); + var dir = new ManagedPakDirectory(_dir, [legacy], cachePath: cache.Root); + var failed = await dir.InstallModSet([request], LanguageExt.Prelude.None).ToListAsync(); + Assert.Contains(failed, r => r.IsLeft); + Assert.Equal(legacy, Assert.Single(dir.ManagedPaks)); + Assert.Equal(bytes, File.ReadAllBytes(source)); + Assert.Equal("owned signature", File.ReadAllText(Path.ChangeExtension(source, ".sig"))); + Assert.Equal(bytes, File.ReadAllBytes(Path.Combine(cache.Root, hash, "mod.pak"))); + dir = new ManagedPakDirectory(_dir, dir.ManagedPaks, cachePath: cache.Root); + Directory.Delete(obstruction); + (await dir.InstallModSet([request], LanguageExt.Prelude.None).ToListAsync()).ForEach(r => r.IfLeft(e => throw e)); + Assert.Equal("Dependency_0_P.pak", Assert.Single(dir.ManagedPaks).PakFileName); + Assert.Single(Directory.GetFiles(_dir, "*.pak")); + Assert.Equal(bytes, File.ReadAllBytes(target)); + Assert.Equal("owned signature", File.ReadAllText(Path.ChangeExtension(target, ".sig"))); + Assert.Equal(bytes, File.ReadAllBytes(Path.Combine(cache.Root, hash, "mod.pak"))); + Assert.False(File.Exists(source)); + Assert.False(File.Exists(Path.ChangeExtension(source, ".sig"))); + Assert.Equal(1, _writes); + } + + [Fact] + public async Task SignatureCleanupFailureKeepsRelocatedPayloadOwnedAcrossRestart() { + // Windows permits copying a read-only signature but rejects deleting it. + if (!OperatingSystem.IsWindows()) return; + var legacy = new ManagedPak(_dependency, "q__-__Dependency.pak", 0); + var source = Path.Combine(_dir, legacy.PakFileName); + var sourceSig = Path.ChangeExtension(source, ".sig"); + File.WriteAllText(source, "owned payload"); + File.WriteAllText(sourceSig, "owned signature"); + File.SetAttributes(sourceSig, FileAttributes.ReadOnly); + try { + var dir = new ManagedPakDirectory(_dir, [legacy]); + Assert.Contains(await Install(dir, _dependency), r => r.IsLeft); + var moved = Assert.Single(dir.ManagedPaks); + Assert.Equal("Dependency_0_P.pak", moved.PakFileName); + Assert.False(File.Exists(source)); + Assert.Equal("owned signature", File.ReadAllText(sourceSig)); + dir = new ManagedPakDirectory(_dir, dir.ManagedPaks); + (await Install(dir, _dependency)).ForEach(r => r.IfLeft(e => throw e)); + Assert.Equal(moved, Assert.Single(dir.ManagedPaks)); + Assert.Single(Directory.GetFiles(_dir, "*.pak")); + Assert.Equal("owned payload", File.ReadAllText(Path.Combine(_dir, moved.PakFileName))); + Assert.Equal("owned signature", File.ReadAllText(Path.Combine(_dir, "Dependency_0_P.sig"))); + Assert.Equal(0, _writes); + } + finally { + foreach (var signature in Directory.GetFiles(_dir, "*.sig")) File.SetAttributes(signature, FileAttributes.Normal); + } + } + + [Fact] + public async Task FailedSignatureRollbackPreservesSourceOwnershipAndBothSignatures() { + if (!OperatingSystem.IsWindows()) return; + var legacy = new ManagedPak(_dependency, "q__-__Dependency.pak", 0); + var source = Path.Combine(_dir, legacy.PakFileName); + var sourceSig = Path.ChangeExtension(source, ".sig"); + var target = Path.Combine(_dir, "Dependency_0_P.pak"); + var targetSig = Path.ChangeExtension(target, ".sig"); + File.WriteAllText(source, "owned payload"); + File.WriteAllText(sourceSig, "owned signature"); + // File.Copy retains ReadOnly, so cleanup of the staged signature also fails. + File.SetAttributes(sourceSig, FileAttributes.ReadOnly); + Directory.CreateDirectory(target); + try { + var dir = new ManagedPakDirectory(_dir, [legacy]); + var failed = await Install(dir, _dependency); + Assert.Contains(failed, r => r.IsLeft); + failed.ForEach(r => r.IfLeft(e => Assert.Contains("signature cleanup also failed", e.ToString()))); + Assert.Equal(legacy, Assert.Single(dir.ManagedPaks)); + Assert.Equal("owned payload", File.ReadAllText(source)); + Assert.Equal("owned signature", File.ReadAllText(sourceSig)); + Assert.Equal("owned signature", File.ReadAllText(targetSig)); + + dir = new ManagedPakDirectory(_dir, dir.ManagedPaks); + Directory.Delete(target); + File.SetAttributes(sourceSig, FileAttributes.Normal); + (await Install(dir, _dependency)).ForEach(r => r.IfLeft(e => throw e)); + var moved = Assert.Single(dir.ManagedPaks); + Assert.Equal("Dependency (1)_0_P.pak", moved.PakFileName); + Assert.Single(Directory.GetFiles(_dir, "*.pak")); + Assert.Equal("owned payload", File.ReadAllText(Path.Combine(_dir, moved.PakFileName))); + Assert.Equal("owned signature", File.ReadAllText(Path.Combine(_dir, "Dependency (1)_0_P.sig"))); + Assert.Equal("owned signature", File.ReadAllText(targetSig)); + Assert.Equal(FileAttributes.ReadOnly, File.GetAttributes(targetSig) & FileAttributes.ReadOnly); + dir = new ManagedPakDirectory(_dir, dir.ManagedPaks); + (await Install(dir, _dependency)).ForEach(r => r.IfLeft(e => throw e)); + Assert.Equal(moved, Assert.Single(dir.ManagedPaks)); + Assert.Equal(0, _writes); + } + finally { + foreach (var signature in Directory.GetFiles(_dir, "*.sig")) File.SetAttributes(signature, FileAttributes.Normal); + } + } + + public void Dispose() => Directory.Delete(_dir, true); +} diff --git a/UnchainedLauncher.Core/Services/Mods/PakDir.cs b/UnchainedLauncher.Core/Services/Mods/PakDir.cs index d735dd08..7df78b13 100644 --- a/UnchainedLauncher.Core/Services/Mods/PakDir.cs +++ b/UnchainedLauncher.Core/Services/Mods/PakDir.cs @@ -112,12 +112,14 @@ private static bool IsCollisionName(string fileName, string targetName) { } private string AvailablePakName(string suggestedFileName) { - var unManagedPaks = GetUnmanagedPaks().ToHashSet(StringComparer.OrdinalIgnoreCase); + var unManagedPaks = GetUnmanagedPaks().ToHashSet(StringComparer.OrdinalIgnoreCase); + var signatures = GetSigFiles().ToHashSet(StringComparer.OrdinalIgnoreCase); var extension = PakSuffix(suggestedFileName); var actualName = suggestedFileName[..^extension.Length]; var fullActualName = actualName + extension; while (_managedPaks.Exists(x => x.PakFileName.Equals(fullActualName, StringComparison.OrdinalIgnoreCase)) - || unManagedPaks.Contains(PakNameToPakPath(fullActualName))) { + || unManagedPaks.Contains(PakNameToPakPath(fullActualName)) + || signatures.Contains(Path.ChangeExtension(PakNameToPakPath(fullActualName), ".sig"))) { actualName = Successors.TextualSuccessor(actualName); fullActualName = actualName + extension; } @@ -166,7 +168,8 @@ public async IAsyncEnumerable> InstallModSet( .Filter(p => installCoordinatesSet.Contains(p.Coordinates)) .GroupBy(p => p.Coordinates) .ToDictionary(g => g.Key, g => g.First()); - var unmanagedPaths = GetUnmanagedPaks().ToHashSet(StringComparer.OrdinalIgnoreCase); + var unmanagedPaths = GetUnmanagedPaks().ToHashSet(StringComparer.OrdinalIgnoreCase); + var signaturePaths = GetSigFiles().ToHashSet(StringComparer.OrdinalIgnoreCase); // Categorize each install: Skip (already correct), Move (relocate), or Download (new) var installActions = installsList @@ -176,7 +179,8 @@ public async IAsyncEnumerable> InstallModSet( return existing.Match( // Reuse collision-safe names only while their module and numeric order match. Some: pak => pak.PakFileName.Equals(item.FinalPakName, StringComparison.OrdinalIgnoreCase) - || (unmanagedPaths.Contains(PakNameToPakPath(item.FinalPakName)) + || ((unmanagedPaths.Contains(PakNameToPakPath(item.FinalPakName)) + || signaturePaths.Contains(Path.ChangeExtension(PakNameToPakPath(item.FinalPakName), ".sig"))) && IsCollisionName(pak.PakFileName, item.FinalPakName)) ? new InstallAction.Skip(item.Install, index, pak) : new InstallAction.Move(item.Install, index, pak, item.FinalPakName) as InstallAction, @@ -286,14 +290,14 @@ private Either ProcessMove(InstallAction.Move move) { var targetName = AvailablePakName(move.TargetPakName); var targetPath = PakNameToPakPath(targetName); - return _moveManagedPak(sourcePath, targetPath) - .Map(_ => { - var managedPak = new ManagedPak(move.Install.Coordinates, targetName, move.Index); - _managedPaks.RemoveAll(p => p.Coordinates.Matches(move.Install.Coordinates)); - _managedPaks.Add(managedPak); - Logger.Info($"Relocated {move.Install.Coordinates.ModuleName} to {targetName}"); - return managedPak; - }); + var managedPak = new ManagedPak(move.Install.Coordinates, targetName, move.Index); + return _moveManagedPak(sourcePath, targetPath, () => { + // Ownership follows the payload even if removing the old signature fails. + _managedPaks.RemoveAll(p => p.Coordinates.Matches(move.Install.Coordinates)); + _managedPaks.Add(managedPak); + Logger.Info($"Relocated {move.Install.Coordinates.ModuleName} to {targetName}"); + }) + .Map(_ => managedPak); } private async Task> ProcessDownload(InstallAction.Download download, Option taskProgress) { @@ -324,14 +328,24 @@ private Either ProcessRemove(InstallAction.Remove remove) { /// /// Moves a managed pak (and its .sig if present) from source to destination. /// - private static Either _moveManagedPak(string sourcePakPath, string destPakPath) { - var sourceSigPath = Path.ChangeExtension(sourcePakPath, ".sig"); - var destSigPath = Path.ChangeExtension(destPakPath, ".sig"); - - return _moveFile(sourcePakPath, destPakPath) - .Bind(_ => File.Exists(sourceSigPath) - ? _moveFile(sourceSigPath, destSigPath) - : Right(Unit.Default)); + private static Either _moveManagedPak(string sourcePakPath, string destPakPath, Action payloadMoved) { + var sourceSigPath = Path.ChangeExtension(sourcePakPath, ".sig"); + var destSigPath = Path.ChangeExtension(destPakPath, ".sig"); + + // Copy first: a failed payload move never requires rolling back its ownership + // or restoring the only signature. Never overwrite cache-linked file contents. + var hasSignature = File.Exists(sourceSigPath); + return (hasSignature ? _copyFile(sourceSigPath, destSigPath) : Right(Unit.Default)) + .Bind(_ => _moveFile(sourcePakPath, destPakPath).Match>( + Right: _ => { + payloadMoved(); + return hasSignature ? _deleteFile(sourceSigPath) : Right(Unit.Default); + }, + Left: error => hasSignature + ? _deleteFile(destSigPath).Match>( + Right: _ => Left(error), + Left: cleanupError => Left(Error.New($"{error}; signature cleanup also failed: {cleanupError}"))) + : Left(error))); } private static Either _moveFile(string source, string dest) { From 40fd633d42f30246a5d0d70841a0e17ea617d283 Mon Sep 17 00:00:00 2001 From: Jacob Barber Date: Mon, 19 Oct 2026 20:29:26 -0500 Subject: [PATCH 36/58] Resolve shared DLL constraints before finalizing versions --- .../DllManagerTests.cs | 74 +++++++++++++++++++ .../Services/Mods/DllLaunchSession.cs | 56 ++++++++++---- 2 files changed, 115 insertions(+), 15 deletions(-) diff --git a/UnchainedLauncher.CLI.Tests/DllManagerTests.cs b/UnchainedLauncher.CLI.Tests/DllManagerTests.cs index 0273160a..10f4b66d 100644 --- a/UnchainedLauncher.CLI.Tests/DllManagerTests.cs +++ b/UnchainedLauncher.CLI.Tests/DllManagerTests.cs @@ -150,6 +150,80 @@ [new ReleaseCoordinates("Chiv2-Community", "UnchainedPlugin", core.Tag), [new ReleaseCoordinates("Chiv2-Community", "UnchainedPlugin", prerelease.Tag)], false, false))); } + [Theory] + [InlineData(false, false)] + [InlineData(true, false)] + [InlineData(false, true)] + [InlineData(true, true)] + public void SharedDependenciesUseAllRootRequirements(bool reverse, bool conflict) { + var core = Release("Chiv2-Community/UnchainedPlugin"); + var older = Release("org/dependency"); + var newer = older with { Tag = "v2.0.0" }; + var first = Release("org/first", dependencies: [new Dependency("https://github.com/org/dependency", conflict ? ">=2.0.0" : ">=1.0.0")]); + var second = Release("org/second", dependencies: [new Dependency("https://github.com/org/dependency", "<2.0.0")]); + ReleaseCoordinates[] roots = [new("org", "first", first.Tag), new("org", "second", second.Tag)]; + if (reverse) Array.Reverse(roots); + DllPackage[] packages = [Package(core), Package(older, newer), Package(first), Package(second)]; + if (conflict) { + var error = Assert.Throws(() => DllManager.Resolve(packages, roots, false, false)); + Assert.Equal("Conflicting DLL requirements: org/dependency", error.Message); + } + else Assert.Equal(reverse ? new[] { core, older, second, first } : new[] { core, older, first, second }, + DllManager.Resolve(packages, roots, false, false)); + } + + [Theory] + [InlineData(false, false)] + [InlineData(true, false)] + [InlineData(false, true)] + [InlineData(true, true)] + public void ReselectionReplacesTransitiveRequirementsAndDropsAbandonedDependencies(bool reverse, bool constrainDependency) { + var core = Release("Chiv2-Community/UnchainedPlugin"); + var leaf = Release("org/leaf"); + var newerLeaf = leaf with { Tag = "v2.0.0" }; + var abandoned = Release("org/abandoned"); + var older = Release("org/dependency", dependencies: [new Dependency("https://github.com/org/leaf", "<2.0.0")]); + var newer = older with { Tag = "v2.0.0", Info = older.Info with { PluginDependencies = [ + new Dependency("https://github.com/org/leaf", ">=2.0.0"), new Dependency("https://github.com/org/abandoned", ">=1.0.0") ] } }; + var first = Release("org/first", dependencies: [new Dependency("https://github.com/org/dependency", ">=1.0.0")]); + var second = Release("org/second", dependencies: constrainDependency + ? [new Dependency("https://github.com/org/dependency", "<2.0.0"), new Dependency("https://github.com/org/leaf", "<2.0.0")] + : [new Dependency("https://github.com/org/leaf", "<2.0.0")]); + ReleaseCoordinates[] roots = [new("org", "first", first.Tag), new("org", "second", second.Tag)]; + if (reverse) Array.Reverse(roots); + var expected = !reverse ? new[] { core, leaf, older, first, second } + : constrainDependency ? new[] { core, leaf, older, second, first } : new[] { core, leaf, second, older, first }; + Assert.Equal(expected, + DllManager.Resolve([Package(core), Package(older, newer), Package(leaf, newerLeaf), Package(abandoned), Package(first), Package(second)], + roots, false, false)); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void LatestSharedDependencyUsesHighestCompatibleRelease(bool reverse) { + var core = Release("Chiv2-Community/UnchainedPlugin"); + var oldest = Release("org/dependency"); + var compatible = oldest with { Tag = "v1.5.0" }; + var newest = oldest with { Tag = "v2.0.0" }; + var plugin = Release("org/plugin", dependencies: [new Dependency("https://github.com/org/dependency", "<2.0.0")]); + ReleaseCoordinates[] roots = [new("org", "dependency", "latest"), new("org", "plugin", plugin.Tag)]; + if (reverse) Array.Reverse(roots); + Assert.Equal(new[] { core, compatible, plugin }, DllManager.Resolve( + [Package(core), Package(oldest, newest, compatible), Package(plugin)], roots, false, false)); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void UnavailableDependenciesKeepSpecificErrors(bool missingPackage) { + var core = Release("Chiv2-Community/UnchainedPlugin", dependencies: [new Dependency("https://github.com/org/dependency", ">=2.0.0")]); + DllPackage[] packages = missingPackage ? [Package(core)] : [Package(core), Package(Release("org/dependency"))]; + var error = Assert.Throws(() => DllManager.Resolve(packages, [], false, false)); + Assert.Equal(missingPackage ? "DLL package not found: org/dependency" + : "DLL release not found for launch target: org/dependency:>=2.0.0", error.Message); + } + [Theory] [InlineData("../x.dll")] [InlineData("..\\x.dll")] diff --git a/UnchainedLauncher.Core/Services/Mods/DllLaunchSession.cs b/UnchainedLauncher.Core/Services/Mods/DllLaunchSession.cs index b4513eb2..705af521 100644 --- a/UnchainedLauncher.Core/Services/Mods/DllLaunchSession.cs +++ b/UnchainedLauncher.Core/Services/Mods/DllLaunchSession.cs @@ -48,8 +48,6 @@ public static bool SatisfiesVersion(SemVersion version, string requirement) => P public static IReadOnlyList Resolve(IReadOnlyList packages, IEnumerable requested, bool server, bool allowUnstable) { var selected = new Dictionary(StringComparer.OrdinalIgnoreCase); - var visiting = new System.Collections.Generic.HashSet(StringComparer.OrdinalIgnoreCase); - var ordered = new List(); var roots = requested.ToList(); if (!roots.Any(root => root.Org.Equals("Chiv2-Community", StringComparison.OrdinalIgnoreCase) && root.ModuleName.Equals("UnchainedPlugin", StringComparison.OrdinalIgnoreCase))) @@ -61,31 +59,59 @@ public static IReadOnlyList Resolve(IReadOnlyList packag throw new InvalidOperationException($"Conflicting DLL requirements: {id}"); pins[id] = root.Version; } - foreach (var root in roots) Visit(root.Org + "/" + root.ModuleName, root.Version, exact: true); + if (!Search(roots.Select(root => (root.Org + "/" + root.ModuleName, root.Version, true, Array.Empty())).ToArray(), out var error)) + throw new InvalidOperationException(error); + var ordered = new List(); + var emitted = new System.Collections.Generic.HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var root in roots) Emit(root.Org + "/" + root.ModuleName); return ordered; - void Visit(string id, string requirement, bool exact) { - var package = packages.FirstOrDefault(p => (p.LatestReleaseInfo.Identifier.Org + "/" + p.LatestReleaseInfo.Identifier.ModuleName).Equals(id, StringComparison.OrdinalIgnoreCase)) - ?? throw new InvalidOperationException($"DLL package not found: {id}"); + bool Search(IReadOnlyList<(string Id, string Requirement, bool Exact, string[] Path)> pending, out string? failure) { + failure = null; + if (pending.Count == 0) return true; + var (id, requirement, exact, path) = pending[0]; + if (path.Contains(id, StringComparer.OrdinalIgnoreCase)) { + failure = $"Cyclic DLL dependency: {id}"; + return false; + } + var package = packages.FirstOrDefault(p => (p.LatestReleaseInfo.Identifier.Org + "/" + p.LatestReleaseInfo.Identifier.ModuleName).Equals(id, StringComparison.OrdinalIgnoreCase)); + if (package == null) { + failure = $"DLL package not found: {id}"; + return false; + } bool Accept(DllRelease r) => exact ? requirement == "latest" || r.Tag == requirement : Satisfies(r, requirement); if (selected.TryGetValue(id, out var previous)) { - if (visiting.Contains(id)) throw new InvalidOperationException($"Cyclic DLL dependency: {id}"); - if (!Accept(previous)) throw new InvalidOperationException($"Conflicting DLL requirements: {id}"); - return; + if (Accept(previous)) return Search(pending.Skip(1).ToArray(), out failure); + failure = $"Conflicting DLL requirements: {id}"; + return false; } var pinned = pins.TryGetValue(id, out var tag); var candidates = package.Releases.Where(Accept).Where(release => !pinned || release.Tag == tag) .Where(release => release.Info.Target == "shared" || release.Info.Target == (server ? "server" : "client")); if (!pinned) candidates = candidates.Where(r => allowUnstable || r.Version?.IsRelease == true); - var release = candidates.OrderByDescending(r => r.Version).ThenByDescending(r => r.ReleaseDate).FirstOrDefault() - ?? throw new InvalidOperationException($"DLL release not found for launch target: {id}:{requirement}"); - selected.Add(id, release); - visiting.Add(id); + failure = $"DLL release not found for launch target: {id}:{requirement}"; + foreach (var release in candidates.OrderByDescending(r => r.Version).ThenByDescending(r => r.ReleaseDate)) { + selected.Add(id, release); + // Keep later roots in the same search so their constraints can reselect earlier dependencies. + List<(string Id, string Requirement, bool Exact, string[] Path)> next = []; + foreach (var dependency in release.Info.PluginDependencies) { + var dep = ModIdentifier.FromDependency(dependency); + next.Add((dep.Org + "/" + dep.ModuleName, dependency.Version, false, [.. path, id])); + } + next.AddRange(pending.Skip(1)); + if (Search(next, out failure)) return true; + selected.Remove(id); + } + return false; + } + + void Emit(string id) { + if (!emitted.Add(id)) return; + var release = selected[id]; foreach (var dependency in release.Info.PluginDependencies) { var dep = ModIdentifier.FromDependency(dependency); - Visit(dep.Org + "/" + dep.ModuleName, dependency.Version, exact: false); + Emit(dep.Org + "/" + dep.ModuleName); } - visiting.Remove(id); ordered.Add(release); } } From 4de14a6adecdd682cbb748c0f023600715952422 Mon Sep 17 00:00:00 2001 From: Jacoby6000 <2672832+Jacoby6000@users.noreply.github.com> Date: Tue, 20 Oct 2026 01:31:27 +0000 Subject: [PATCH 37/58] [GHA] Format Source --- UnchainedLauncher.CLI.Tests/DllManagerTests.cs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/UnchainedLauncher.CLI.Tests/DllManagerTests.cs b/UnchainedLauncher.CLI.Tests/DllManagerTests.cs index 10f4b66d..203bfa33 100644 --- a/UnchainedLauncher.CLI.Tests/DllManagerTests.cs +++ b/UnchainedLauncher.CLI.Tests/DllManagerTests.cs @@ -183,8 +183,13 @@ public void ReselectionReplacesTransitiveRequirementsAndDropsAbandonedDependenci var newerLeaf = leaf with { Tag = "v2.0.0" }; var abandoned = Release("org/abandoned"); var older = Release("org/dependency", dependencies: [new Dependency("https://github.com/org/leaf", "<2.0.0")]); - var newer = older with { Tag = "v2.0.0", Info = older.Info with { PluginDependencies = [ - new Dependency("https://github.com/org/leaf", ">=2.0.0"), new Dependency("https://github.com/org/abandoned", ">=1.0.0") ] } }; + var newer = older with { + Tag = "v2.0.0", + Info = older.Info with { + PluginDependencies = [ + new Dependency("https://github.com/org/leaf", ">=2.0.0"), new Dependency("https://github.com/org/abandoned", ">=1.0.0") ] + } + }; var first = Release("org/first", dependencies: [new Dependency("https://github.com/org/dependency", ">=1.0.0")]); var second = Release("org/second", dependencies: constrainDependency ? [new Dependency("https://github.com/org/dependency", "<2.0.0"), new Dependency("https://github.com/org/leaf", "<2.0.0")] From 7c81d8630b278413c358ea71ec0c919ebeaf40e6 Mon Sep 17 00:00:00 2001 From: Jacob Barber Date: Mon, 19 Oct 2026 20:35:28 -0500 Subject: [PATCH 38/58] docs(annals): add keyword index --- annals/index.md | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 annals/index.md diff --git a/annals/index.md b/annals/index.md new file mode 100644 index 00000000..859953e3 --- /dev/null +++ b/annals/index.md @@ -0,0 +1,29 @@ +# Annals keyword index + +## A + +- [actor selection](2026-10-16-rename-cli-starting-map.md) + +## C + +- [Chiv2Turbo](2026-10-16-rename-cli-starting-map.md) +- [command-line interface](2026-10-16-rename-cli-starting-map.md) +- [compatibility aliases](2026-10-16-rename-cli-starting-map.md) + +## P + +- [pinned releases](2026-10-16-rename-cli-starting-map.md) + +## S + +- [server configuration](2026-10-16-rename-cli-starting-map.md) +- [starting-map](2026-10-16-rename-cli-starting-map.md) + +## U + +- [Unchained-Mods](2026-10-16-rename-cli-starting-map.md) +- [UnchainedPlugin](2026-10-16-rename-cli-starting-map.md) + +## Y + +- [YAML](2026-10-16-rename-cli-starting-map.md) From 99f2f7180937030417632e4346a26887e0a8e3ff Mon Sep 17 00:00:00 2001 From: Jacob Barber Date: Mon, 19 Oct 2026 20:36:45 -0500 Subject: [PATCH 39/58] Install GUI server mods into the selected native profile --- .../Services/Mods/ProfileAssetPaths.cs | 8 +- .../ProfileModsLaunchPreparerTests.cs | 115 ++++++++++++++++++ .../UnchainedLauncher.GUI.Tests.Unit.csproj | 1 + UnchainedLauncher.GUI/App.xaml.cs | 8 +- .../Services/ProfileModsLaunchPreparer.cs | 27 ++++ 5 files changed, 151 insertions(+), 8 deletions(-) create mode 100644 UnchainedLauncher.GUI.Tests.Unit/ProfileModsLaunchPreparerTests.cs create mode 100644 UnchainedLauncher.GUI/Services/ProfileModsLaunchPreparer.cs diff --git a/UnchainedLauncher.Core/Services/Mods/ProfileAssetPaths.cs b/UnchainedLauncher.Core/Services/Mods/ProfileAssetPaths.cs index 7ecc5808..6287fa77 100644 --- a/UnchainedLauncher.Core/Services/Mods/ProfileAssetPaths.cs +++ b/UnchainedLauncher.Core/Services/Mods/ProfileAssetPaths.cs @@ -3,7 +3,8 @@ namespace UnchainedLauncher.Core.Services.Mods; public sealed class ProfileAssetPaths { - public const string SharedSuffix = "unchained"; + public const string SharedSuffix = "unchained"; + public string Suffix { get; } public string Root { get; } public string Paks => Path.Combine(Path.GetDirectoryName(Root)!, "Paks"); public string Plugins => Path.Combine(Root, "plugins"); @@ -13,8 +14,9 @@ public sealed class ProfileAssetPaths { public ProfileAssetPaths(string suffix, string? localAppData = null) { var game = Path.Combine(localAppData ?? Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Chivalry 2"); var normalized = suffix.Replace("-", "", StringComparison.Ordinal); - if (normalized.Length == 0 || normalized.Any(c => !char.IsAsciiLetterOrDigit(c) && c != '_')) - throw new ArgumentException("Invalid saved directory suffix."); + if (normalized.Length == 0 || normalized.Any(c => !char.IsAsciiLetterOrDigit(c) && c != '_')) + throw new ArgumentException("Invalid saved directory suffix."); + Suffix = normalized; Root = Path.Combine(game, "Saved_" + normalized, "unchained"); PakCache = Path.Combine(game, "unchained", "cache", "paks"); DllCache = Path.Combine(game, "unchained", "cache", "dlls"); diff --git a/UnchainedLauncher.GUI.Tests.Unit/ProfileModsLaunchPreparerTests.cs b/UnchainedLauncher.GUI.Tests.Unit/ProfileModsLaunchPreparerTests.cs new file mode 100644 index 00000000..165cd57e --- /dev/null +++ b/UnchainedLauncher.GUI.Tests.Unit/ProfileModsLaunchPreparerTests.cs @@ -0,0 +1,115 @@ +using LanguageExt; +using LanguageExt.UnsafeValueAccess; +using System.Diagnostics; +using UnchainedLauncher.Core.JsonModels.ModMetadata; +using UnchainedLauncher.Core.Services; +using UnchainedLauncher.Core.Services.Mods; +using UnchainedLauncher.Core.Services.Mods.Registry; +using UnchainedLauncher.Core.Services.Processes; +using UnchainedLauncher.Core.Services.Processes.Chivalry; +using UnchainedLauncher.Core.Utilities; +using UnchainedLauncher.GUI.Services; +using UnchainedLauncher.UnrealModScanner.JsonModels; +using Xunit; + +namespace UnchainedLauncher.GUI.Tests.Unit; + +public sealed class ProfileModsLaunchPreparerTests { + [Theory] + [InlineData(null, "unchained")] + [InlineData("Courtyard", "Courtyard")] + [InlineData("Court_yard_1", "Court_yard_1")] + [InlineData("Court-yard", "Courtyard")] + public async Task LaunchSuffixSelectsInstalledAndSignedPaks(string? suffix, string expectedSuffix) { + var root = Path.Combine(Path.GetTempPath(), "gui-profile-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); + try { + var installation = Path.Combine(root, "game"); + var defaultPaks = Path.Combine(installation, FilePaths.PakDir); + Directory.CreateDirectory(defaultPaks); + File.WriteAllText(Path.Combine(defaultPaks, "pakchunk0-WindowsNoEditor.sig"), "base signature"); + var payload = Path.Combine(root, "source.pak"); + File.WriteAllText(payload, "approved payload"); + var hash = FileHelpers.Sha512(payload).Match(Right: value => value, Left: e => throw new Exception(e.Message)); + Release Release(string name, params Dependency[] dependencies) => new("v1.0.0", hash, "mod.pak", DateTime.UnixEpoch, + new ModInfo($"https://github.com/org/{name}", name, "test", null, null, [], [], dependencies.ToList()), + new AssetCollections([], [], [], [], []), null); + var dependency = Release("Dependency"); + var selected = Release("Selected", new Dependency("https://github.com/org/Dependency", "v1.0.0")); + var unselected = Release("Unselected"); + var registry = new LocalModRegistry(Path.Combine(root, "registry")); + foreach (var release in new[] { dependency, selected, unselected }) await registry.AddRelease(release, payload); + var shared = new ProfileAssetPaths(ProfileAssetPaths.SharedSuffix, root); + var manager = new ModManager(registry, shared.CreatePakDir(installation), [ReleaseCoordinates.FromRelease(selected)]); + Assert.False((await manager.UpdateModsList()).HasErrors); + var dialogue = new Dialogue(); + var preparer = new ProfileModsLaunchPreparer(manager, dialogue, installation, root); + var server = new ServerLaunchOptions(true, "Courtyard", "", false, Option.None, "/Game/Maps/Courtyard", + 7777, 7778, 7779, 7780, null, null, null, null, null, null, Option.None, [], + Option.None, false, false, CensorArg.None); + var options = new LaunchOptions(manager.EnabledModReleaseCoordinates, Option.None, "", false, false, + suffix is null ? Option.None : Option.Some(suffix), + suffix is null ? Option.None : Option.Some(server)); + + var result = await preparer.PrepareLaunch(options); + Assert.True(result.IsSome); + var prepared = result.ValueUnsafe(); + Assert.Equal(options.ServerLaunchOptions, prepared.ServerLaunchOptions); + Assert.Equal(expectedSuffix, prepared.SavedDirSuffix.ValueUnsafe()); + Assert.Contains(prepared.ToCLIArgs(), arg => arg.Rendered == "-saveddirsuffix=" + expectedSuffix); + var process = new CapturingProcessLauncher(); + var launcher = new UnchainedChivalry2Launcher(preparer, process, installation, new NullInjector()); + Assert.True((await launcher.Launch(options)).IsLeft); // Stop at the OS process boundary. + Assert.Contains("-saveddirsuffix=" + expectedSuffix, process.Arguments); + Assert.DoesNotContain("--additional-pak-dir", process.Arguments); + var paks = Path.Combine(root, "Chivalry 2", "Saved_" + expectedSuffix, "Paks"); + var inventory = ReadInventory(paks); + Assert.Equal(2, inventory.Length); + Assert.Contains(inventory, pak => pak.Coordinates == ReleaseCoordinates.FromRelease(selected)); + Assert.Contains(inventory, pak => pak.Coordinates == ReleaseCoordinates.FromRelease(dependency)); + foreach (var pak in inventory) { + Assert.Equal("approved payload", File.ReadAllText(Path.Combine(paks, pak.PakFileName))); + Assert.Equal("base signature", File.ReadAllText(Path.ChangeExtension(Path.Combine(paks, pak.PakFileName), ".sig"))); + Assert.EndsWith($"_{pak.Priority}_P.pak", pak.PakFileName); + } + if (expectedSuffix != ProfileAssetPaths.SharedSuffix) Assert.Empty(Directory.GetFiles(shared.Paks, "*.pak")); + Assert.False(Directory.Exists(new ProfileAssetPaths(expectedSuffix, root).Plugins)); + + // The next launch must use current GUI selections and reconcile this profile's ownership. + Assert.True(manager.DisableModRelease(ReleaseCoordinates.FromRelease(selected))); + Assert.True(manager.EnableModRelease(ReleaseCoordinates.FromRelease(unselected))); + Assert.True((await preparer.PrepareLaunch(options with { EnabledReleases = manager.EnabledModReleaseCoordinates })).IsSome); + Assert.Equal(ReleaseCoordinates.FromRelease(unselected), Assert.Single(ReadInventory(paks)).Coordinates); + Assert.Single(Directory.GetFiles(paks, "*.pak")); + Assert.Single(Directory.GetFiles(paks, "*.sig")); + Assert.Equal("approved payload", File.ReadAllText(Path.Combine(shared.PakCache, hash.ToLowerInvariant(), "mod.pak"))); + Assert.Empty(Directory.GetFiles(shared.PakCache, "*.sig", SearchOption.AllDirectories)); + Assert.Empty(dialogue.Messages); + } + finally { Directory.Delete(root, true); } + } + + private static ManagedPak[] ReadInventory(string paks) { + var result = JsonHelpers.Deserialize(File.ReadAllText(Path.Combine(paks, FilePaths.ManagedPakInventoryFileName))); + Assert.True(result.Success); + return result.Result!.ManagedPaks.ToArray(); + } + + private sealed class Dialogue : IUserDialogueSpawner { + public List Messages { get; } = []; + public void DisplayMessage(string message) => Messages.Add(message); + public UserDialogueChoice DisplayYesNoMessage(string message, string caption) => throw new NotSupportedException(); + public UserDialogueChoice? DisplayUpdateMessage(string titleText, string messageText, string yesButtonText, + string noButtonText, string? cancelButtonText, IEnumerable updates) => throw new NotSupportedException(); + public Action DisplayProgress(MemoryProgress progress) => () => { }; + } + + private sealed class CapturingProcessLauncher : IProcessLauncher { + public string Executable => "test-game.exe"; + public string Arguments { get; private set; } = ""; + public Either Launch(string workingDirectory, string args) { + Arguments = args; + return new LaunchFailed(Executable, args, LanguageExt.Common.Error.New("Stopped at the OS boundary.")); + } + } +} diff --git a/UnchainedLauncher.GUI.Tests.Unit/UnchainedLauncher.GUI.Tests.Unit.csproj b/UnchainedLauncher.GUI.Tests.Unit/UnchainedLauncher.GUI.Tests.Unit.csproj index b857599c..cef00436 100644 --- a/UnchainedLauncher.GUI.Tests.Unit/UnchainedLauncher.GUI.Tests.Unit.csproj +++ b/UnchainedLauncher.GUI.Tests.Unit/UnchainedLauncher.GUI.Tests.Unit.csproj @@ -7,6 +7,7 @@ + diff --git a/UnchainedLauncher.GUI/App.xaml.cs b/UnchainedLauncher.GUI/App.xaml.cs index 297125fe..43942f86 100644 --- a/UnchainedLauncher.GUI/App.xaml.cs +++ b/UnchainedLauncher.GUI/App.xaml.cs @@ -174,12 +174,11 @@ select chiv2Installations.Path #endif var noSigLaunchPreparer = NoSigPreparer.Create(modManager.PakDir, userDialogueSpawner); - var sigLaunchPreparer = SigPreparer.Create(modManager.PakDir, userDialogueSpawner); IChivalry2LaunchPreparer pluginInstaller = new DllLaunchPreparer(dllManager, userDialogueSpawner); - IChivalry2LaunchPreparer modInstaller = new Chivalry2ModsInstaller( - modManager, userDialogueSpawner + IChivalry2LaunchPreparer modInstaller = new ProfileModsLaunchPreparer( + modManager, userDialogueSpawner, Directory.GetCurrentDirectory() ); var vanillaLauncher = new Chivalry2Launcher( @@ -190,8 +189,7 @@ select chiv2Installations.Path var unchainedLauncher = new UnchainedChivalry2Launcher( pluginInstaller - .AndThen(modInstaller) - .Sub(sigLaunchPreparer), + .AndThen(modInstaller), unchainedProcessLauncher, Directory.GetCurrentDirectory(), #if DEBUG_FAKECHIVALRYLAUNCH diff --git a/UnchainedLauncher.GUI/Services/ProfileModsLaunchPreparer.cs b/UnchainedLauncher.GUI/Services/ProfileModsLaunchPreparer.cs new file mode 100644 index 00000000..360b6c0d --- /dev/null +++ b/UnchainedLauncher.GUI/Services/ProfileModsLaunchPreparer.cs @@ -0,0 +1,27 @@ +using System; +using System.IO; +using System.Threading.Tasks; +using LanguageExt; +using UnchainedLauncher.Core.Services; +using UnchainedLauncher.Core.Services.Mods; +using UnchainedLauncher.Core.Services.Processes.Chivalry; +using UnchainedLauncher.Core.Services.Processes.Chivalry.LaunchPreparers; + +namespace UnchainedLauncher.GUI.Services; + +public sealed class ProfileModsLaunchPreparer( + ModManager manager, IUserDialogueSpawner dialogue, string installation, string? localAppData = null) + : IChivalry2LaunchPreparer { + public Task> PrepareLaunch(LaunchOptions options) { + var assets = new ProfileAssetPaths(options.SavedDirSuffix.IfNone(ProfileAssetPaths.SharedSuffix), localAppData); + var sameProfile = Path.GetFullPath(manager.PakDir.DirPath).Equals( + Path.GetFullPath(assets.Paks), StringComparison.OrdinalIgnoreCase); + var profileManager = manager.WithPakDir(assets.CreatePakDir(installation, + sameProfile && manager.PakDir is PakDir existing ? existing.ManagedPaks : [])); + // DLL selection and injection stay bound to the shared manager and plugin directory. + IChivalry2LaunchPreparer installer = new Chivalry2ModsInstaller(profileManager, dialogue); + return installer + .Sub(SigPreparer.Create(profileManager.PakDir, dialogue)) + .PrepareLaunch(options with { SavedDirSuffix = assets.Suffix }); + } +} From fda52e8268bce0da5f250f23296243a9b466364c Mon Sep 17 00:00:00 2001 From: Jacob Barber Date: Mon, 19 Oct 2026 20:41:57 -0500 Subject: [PATCH 40/58] Update YAML examples for native profile pak discovery --- UnchainedLauncher.CLI/examples/server.yaml | 5 ++--- UnchainedLauncher.CLI/examples/servers.yaml | 5 ++--- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/UnchainedLauncher.CLI/examples/server.yaml b/UnchainedLauncher.CLI/examples/server.yaml index 214a388d..19856ad4 100644 --- a/UnchainedLauncher.CLI/examples/server.yaml +++ b/UnchainedLauncher.CLI/examples/server.yaml @@ -6,9 +6,8 @@ map-profiles: map-name: FFA_Courtyard # Run with: UnchainedLauncher.CLI.exe server --config-path server.yaml -# Requires UnchainedPlugin support for --additional-pak-dir. -# Replace the core plugin pin with a registered build supporting that option. -# Paks/plugins live in %LOCALAPPDATA%/Chivalry 2/Saved_MyServer/unchained/. +# Paks live in %LOCALAPPDATA%/Chivalry 2/Saved_MyServer/Paks/. +# Plugins live in %LOCALAPPDATA%/Chivalry 2/Saved_MyServer/unchained/plugins/. # Pak bytes are shared through the common verified cache and hard links. # A client block, if included, is ignored by the server command. server-browser-backend: https://servers.polehammer.net diff --git a/UnchainedLauncher.CLI/examples/servers.yaml b/UnchainedLauncher.CLI/examples/servers.yaml index 3ab1eb50..ea114211 100644 --- a/UnchainedLauncher.CLI/examples/servers.yaml +++ b/UnchainedLauncher.CLI/examples/servers.yaml @@ -1,9 +1,8 @@ # Run: UnchainedLauncher.CLI.exe server --config-path servers.yaml # Paths resolve relative to this file. Each server has independent selections and files. -# Requires UnchainedPlugin support for --additional-pak-dir. -# Active paks/plugins: %LOCALAPPDATA%/Chivalry 2/Saved_/unchained/. +# Active paks: %LOCALAPPDATA%/Chivalry 2/Saved_/Paks/. +# Active plugins: %LOCALAPPDATA%/Chivalry 2/Saved_/unchained/plugins/. # Pak bytes are shared through the common content-addressed cache and hard links. -# Replace the core plugin pin with a registered build supporting --additional-pak-dir. chiv2-path: 'C:\Games\Chivalry 2' platform: Steam map-profiles: From 74fbbb1dd06db3ef6c46a267645181ba2c033f9a Mon Sep 17 00:00:00 2001 From: Jacob Barber Date: Mon, 19 Oct 2026 20:45:50 -0500 Subject: [PATCH 41/58] Update launch and collision fixtures for numeric pak names --- .../Services/PakDir/PakDirTest.cs | 9 +++------ .../Processes/Chivalry/PakLaunchPreparationTests.cs | 4 ++-- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/UnchainedLauncher.Core.Tests.Unit/Services/PakDir/PakDirTest.cs b/UnchainedLauncher.Core.Tests.Unit/Services/PakDir/PakDirTest.cs index 89b75e90..4bb81c66 100644 --- a/UnchainedLauncher.Core.Tests.Unit/Services/PakDir/PakDirTest.cs +++ b/UnchainedLauncher.Core.Tests.Unit/Services/PakDir/PakDirTest.cs @@ -451,8 +451,7 @@ public async Task InstallModSet_PakNameCollision_UsesTextualSuccessor() { PrepareTestDirWithSig(thisTestPath); // Create an unmanaged pak file with a conflicting name - // The lexicographic prefix will make the actual name qa__-__TestMod.pak - File.WriteAllText(Path.Join(thisTestPath, "qa__-__TestMod.pak"), "unmanaged pak contents"); + File.WriteAllText(Path.Join(thisTestPath, "TestMod_0_P.pak"), "unmanaged pak contents"); var pd = new Core.Services.Mods.PakDir(thisTestPath, Enumerable.Empty()); var coords = new ReleaseCoordinates("Org", "TestMod", "1.0.0"); @@ -468,10 +467,8 @@ public async Task InstallModSet_PakNameCollision_UsesTextualSuccessor() { Assert.Single(pd.ManagedPaks); var installedPakName = pd.ManagedPaks.First().PakFileName; - // Should not be exactly the same as the conflicting file - Assert.NotEqual("qa__-__TestMod.pak", installedPakName); - // But should still contain the module name - Assert.Contains("TestMod", installedPakName); + Assert.Equal("TestMod (1)_0_P.pak", installedPakName); + Assert.Equal("unmanaged pak contents", File.ReadAllText(Path.Join(thisTestPath, "TestMod_0_P.pak"))); } [Fact] diff --git a/UnchainedLauncher.Core.Tests.Unit/Services/Processes/Chivalry/PakLaunchPreparationTests.cs b/UnchainedLauncher.Core.Tests.Unit/Services/Processes/Chivalry/PakLaunchPreparationTests.cs index d39861e0..f91227c2 100644 --- a/UnchainedLauncher.Core.Tests.Unit/Services/Processes/Chivalry/PakLaunchPreparationTests.cs +++ b/UnchainedLauncher.Core.Tests.Unit/Services/Processes/Chivalry/PakLaunchPreparationTests.cs @@ -18,8 +18,8 @@ namespace UnchainedLauncher.Core.Tests.Unit.Services.Processes.Chivalry; public sealed class PakLaunchPreparationTests : IDisposable { private readonly string _dir = Path.Combine(Path.GetTempPath(), "pak-launch-" + Guid.NewGuid().ToString("N")); private readonly ReleaseCoordinates _coords = new("Chiv2-Community", "Unchained-Mods", "v0.1.0"); - private const string Preferred = "q__-__Unchained-Mods.pak"; - private const string Installed = "q__-__Unchained-Mods (1).pak"; + private const string Preferred = "Unchained-Mods_0_P.pak"; + private const string Installed = "Unchained-Mods (1)_0_P.pak"; private sealed class ImmediateContext : SynchronizationContext { public override void Post(SendOrPostCallback callback, object? state) { var previous = Current; From 08634e51e17b8c10f3c4ba2cfcbd6545a42a73a3 Mon Sep 17 00:00:00 2001 From: Jacoby6000 <2672832+Jacoby6000@users.noreply.github.com> Date: Tue, 20 Oct 2026 01:48:19 +0000 Subject: [PATCH 42/58] [GHA] Format Source --- .../ArgumentBoundaryTests.cs | 10 +- .../ManagedInventoryTests.cs | 26 +- .../NumericPakOrderingTests.cs | 2 +- UnchainedLauncher.CLI.Tests/PakCacheTests.cs | 8 +- .../ServerGroupTests.cs | 10 +- .../ServerOptionsTests.cs | 6 +- .../YamlConfigurationTests.cs | 10 +- UnchainedLauncher.CLI/CliLaunchOptions.cs | 4 +- UnchainedLauncher.CLI/ServerInstancePaths.cs | 4 +- .../Services/PakDir/PakOrderCollisionTests.cs | 370 +++++++++--------- .../Services/Mods/IPakDir.cs | 2 +- .../Services/Mods/PakDir.cs | 112 +++--- .../Services/Mods/ProfileAssetPaths.cs | 14 +- UnchainedLauncher.GUI/App.xaml.cs | 10 +- .../Services/ProfileModsLaunchPreparer.cs | 4 +- 15 files changed, 296 insertions(+), 296 deletions(-) diff --git a/UnchainedLauncher.CLI.Tests/ArgumentBoundaryTests.cs b/UnchainedLauncher.CLI.Tests/ArgumentBoundaryTests.cs index a37694df..fd46dee8 100644 --- a/UnchainedLauncher.CLI.Tests/ArgumentBoundaryTests.cs +++ b/UnchainedLauncher.CLI.Tests/ArgumentBoundaryTests.cs @@ -51,7 +51,7 @@ public async Task ForwardingPreservesExactProcessArguments(string command, bool public async Task GeneratedServerValuesAlsoPreserveBoundaries(bool yaml) { const string value = "quoted \"name\"\\\tline\nend\\"; var options = Assert.IsType(Parse("server", yaml, [], value)); - var expected = new List { "TBL", "-server" }; + var expected = new List { "TBL", "-server" }; expected.AddRange(FirstRunArguments); expected.AddRange(["-saveddirsuffix=UnchainedCLI", "-unchained"]); expected.AddRange(DefaultServerArguments(value, value, value)); @@ -63,10 +63,10 @@ public async Task GeneratedServerValuesAlsoPreserveBoundaries(bool yaml) { } [Fact] - public async Task NativePakDirectoryDoesNotRequireAProcessArgument() { + public async Task NativePakDirectoryDoesNotRequireAProcessArgument() { var installation = Path.Combine(_directory, "Game With Spaces"); var options = CliLaunchOptions.Parse(["server", "--chivalry2-path", installation, "--platform", "Steam", "--local-ip", "127.0.0.1"]); - var expected = new List { "TBL", "-server" }; + var expected = new List { "TBL", "-server" }; expected.AddRange(FirstRunArguments); expected.AddRange(["-saveddirsuffix=UnchainedCLI", "-unchained"]); expected.AddRange(DefaultServerArguments()); @@ -84,7 +84,7 @@ public async Task RawIniOverridesPreserveExactContentsAsOneArgument(string value " local-ip: 127.0.0.1\n ini:\n Game:\n /Script/TBL.TBLGameMode:\n" + $" Custom: {{type: raw, value: {JsonSerializer.Serialize(value)}}}\n"); var options = CliLaunchOptions.Parse(["server", "--config-path", path]); - var expected = new List { "TBL", "-server" }; + var expected = new List { "TBL", "-server" }; expected.AddRange(FirstRunArguments); expected.Add("-ini:Game:[/Script/TBL.TBLGameMode]:Custom=" + value); expected.AddRange(["-saveddirsuffix=UnchainedCLI", "-unchained"]); @@ -160,4 +160,4 @@ private static async Task ReadResult(Process process, string output) { private sealed class NoOpInjector : IProcessInjector { public bool Inject(Process process) => true; } -} +} \ No newline at end of file diff --git a/UnchainedLauncher.CLI.Tests/ManagedInventoryTests.cs b/UnchainedLauncher.CLI.Tests/ManagedInventoryTests.cs index 89a0ec4c..fbbbe42d 100644 --- a/UnchainedLauncher.CLI.Tests/ManagedInventoryTests.cs +++ b/UnchainedLauncher.CLI.Tests/ManagedInventoryTests.cs @@ -140,29 +140,29 @@ await InInstallation(async (source, common, extra, _) => { } [Fact] - public async Task SharedOwnershipPreservesUnmanagedCollisionAndUpdatesPatchOrders() { + public async Task SharedOwnershipPreservesUnmanagedCollisionAndUpdatesPatchOrders() { await InInstallation(async (registry, common, extra, launch) => { var paks = Path.GetFullPath(FilePaths.PakDir); - var occupied = Path.Combine(paks, "Unchained-Mods_0_P.pak"); + var occupied = Path.Combine(paks, "Unchained-Mods_0_P.pak"); File.WriteAllText(occupied, "user collision"); var manager = Manager(registry, []); await Prepare(manager, launch); var original = Assert.Single(((PakDir)manager.PakDir).ManagedPaks); - Assert.Equal("Unchained-Mods (1)_0_P.pak", original.PakFileName); + Assert.Equal("Unchained-Mods (1)_0_P.pak", original.PakFileName); Assert.True(manager.EnableModRelease(ReleaseCoordinates.FromRelease(extra))); await Prepare(manager, launch); var owned = ((PakDir)manager.PakDir).ManagedPaks.ToArray(); var expectedOrder = manager.SortByDependencies(manager.GetEnabledAndDependencyReleases()) - .Select(ReleaseCoordinates.FromRelease).ToArray(); - Assert.Equal(expectedOrder, owned.OrderBy(pak => pak.Priority) + .Select(ReleaseCoordinates.FromRelease).ToArray(); + Assert.Equal(expectedOrder, owned.OrderBy(pak => pak.Priority) .Select(pak => pak.Coordinates)); - Assert.True(File.Exists(Path.Combine(paks, original.PakFileName))); - Assert.Equal(original.PakFileName, Assert.Single(owned, pak => pak.Coordinates == original.Coordinates).PakFileName); + Assert.True(File.Exists(Path.Combine(paks, original.PakFileName))); + Assert.Equal(original.PakFileName, Assert.Single(owned, pak => pak.Coordinates == original.Coordinates).PakFileName); Assert.Equal("user collision", File.ReadAllText(occupied)); Assert.Equal(2, owned.Length); - Assert.All(owned, pak => Assert.True(File.Exists(Path.ChangeExtension(Path.Combine(paks, pak.PakFileName), ".sig")))); - Assert.All(owned, pak => Assert.EndsWith($"_{pak.Priority}_P.pak", pak.PakFileName)); + Assert.All(owned, pak => Assert.True(File.Exists(Path.ChangeExtension(Path.Combine(paks, pak.PakFileName), ".sig")))); + Assert.All(owned, pak => Assert.EndsWith($"_{pak.Priority}_P.pak", pak.PakFileName)); await Prepare(manager, launch); Assert.Equal(owned, ((PakDir)manager.PakDir).ManagedPaks); @@ -211,9 +211,9 @@ private static async Task Prepare(ModManager manager, LaunchOptions launch) { private static void AssertOnlyCommonAndUnknown(ModManager manager, Release common) { var pakDir = (PakDir)manager.PakDir; - var owned = Assert.Single(pakDir.ManagedPaks); - Assert.Equal(ReleaseCoordinates.FromRelease(common), owned.Coordinates); - Assert.EndsWith("_0_P.pak", owned.PakFileName); + var owned = Assert.Single(pakDir.ManagedPaks); + Assert.Equal(ReleaseCoordinates.FromRelease(common), owned.Coordinates); + Assert.EndsWith("_0_P.pak", owned.PakFileName); Assert.Equal(3, Directory.GetFiles(pakDir.DirPath, "*.pak").Length); // base, common, unknown Assert.Equal("user payload", File.ReadAllText(Path.Combine(pakDir.DirPath, "user.pak"))); Assert.Equal("base payload", File.ReadAllText(Path.Combine(pakDir.DirPath, "pakchunk0-WindowsNoEditor.pak"))); @@ -248,4 +248,4 @@ await ServerLauncher.InInstallationDirectoryAsync(request, async () => { return 0; }); } -} +} \ No newline at end of file diff --git a/UnchainedLauncher.CLI.Tests/NumericPakOrderingTests.cs b/UnchainedLauncher.CLI.Tests/NumericPakOrderingTests.cs index c9d50eac..d4354be9 100644 --- a/UnchainedLauncher.CLI.Tests/NumericPakOrderingTests.cs +++ b/UnchainedLauncher.CLI.Tests/NumericPakOrderingTests.cs @@ -100,4 +100,4 @@ public async Task DuplicateModuleNamesRetainOrganizationDisambiguation() { Assert.Equal(["one_Common_0_P.pak", "two_Common_1_P.pak"], directory.ManagedPaks.OrderBy(pak => pak.Priority).Select(pak => pak.PakFileName)); } -} +} \ No newline at end of file diff --git a/UnchainedLauncher.CLI.Tests/PakCacheTests.cs b/UnchainedLauncher.CLI.Tests/PakCacheTests.cs index 7c045545..0e4eb5ae 100644 --- a/UnchainedLauncher.CLI.Tests/PakCacheTests.cs +++ b/UnchainedLauncher.CLI.Tests/PakCacheTests.cs @@ -154,13 +154,13 @@ public void CacheAndSignaturePathsSurviveMetadataRoundTrip() { public void ProfileAssetsAndSharedCachesUseLocalAppData() { var first = new ProfileAssetPaths("Host-one", _root); var second = new ProfileAssetPaths("Host-two", _root); - Assert.Equal(Path.Combine(_root, "Chivalry 2", "Saved_Hostone", "Paks"), first.Paks); - Assert.Equal(first.Paks, new ServerInstancePaths(first.Root, first.PakCache).Paks); + Assert.Equal(Path.Combine(_root, "Chivalry 2", "Saved_Hostone", "Paks"), first.Paks); + Assert.Equal(first.Paks, new ServerInstancePaths(first.Root, first.PakCache).Paks); Assert.Equal(Path.Combine(_root, "Chivalry 2", "Saved_Hostone", "unchained", "plugins"), first.Plugins); Assert.NotEqual(first.Paks, second.Paks); Assert.Equal(first.PakCache, second.PakCache); Assert.Equal(first.DllCache, second.DllCache); - Assert.Equal(Path.Combine(_root, "Chivalry 2", "Saved_unchained", "Paks"), + Assert.Equal(Path.Combine(_root, "Chivalry 2", "Saved_unchained", "Paks"), new ProfileAssetPaths(ProfileAssetPaths.SharedSuffix, _root).Paks); } -} +} \ No newline at end of file diff --git a/UnchainedLauncher.CLI.Tests/ServerGroupTests.cs b/UnchainedLauncher.CLI.Tests/ServerGroupTests.cs index 0069be99..a40be072 100644 --- a/UnchainedLauncher.CLI.Tests/ServerGroupTests.cs +++ b/UnchainedLauncher.CLI.Tests/ServerGroupTests.cs @@ -116,10 +116,10 @@ public void InstanceLeasePreventsConcurrentReuseAndCanBeReacquiredAfterRelease() using (paths.AcquireLease()) Assert.True(Directory.Exists(paths.Root)); } - [Fact] - public void NativePakDirectoriesDoNotAddPluginArguments() { - Assert.All(Group().Servers, entry => - Assert.DoesNotContain("--additional-pak-dir", entry.Options.ToLaunchOptions().LaunchArgs)); + [Fact] + public void NativePakDirectoriesDoNotAddPluginArguments() { + Assert.All(Group().Servers, entry => + Assert.DoesNotContain("--additional-pak-dir", entry.Options.ToLaunchOptions().LaunchArgs)); } [Fact] @@ -250,4 +250,4 @@ public Task InfoAsync() { return Task.FromException(new TimeoutException()); } } -} +} \ No newline at end of file diff --git a/UnchainedLauncher.CLI.Tests/ServerOptionsTests.cs b/UnchainedLauncher.CLI.Tests/ServerOptionsTests.cs index dfa6a0bb..244c84fb 100644 --- a/UnchainedLauncher.CLI.Tests/ServerOptionsTests.cs +++ b/UnchainedLauncher.CLI.Tests/ServerOptionsTests.cs @@ -21,7 +21,7 @@ public void DefaultsProduceHeadlessServerLaunch() { Assert.Equal(7777, server.GamePort); Assert.Equal(7071, server.QueryPort); Assert.Equal("FFA_Courtyard", server.Map); - Assert.StartsWith("TBL -server ", options.ToLaunchOptions().LaunchArgs); + Assert.StartsWith("TBL -server ", options.ToLaunchOptions().LaunchArgs); Assert.Equal("UnchainedCLI", options.ToLaunchOptions().SavedDirSuffix.IfNone("")); Assert.Contains(server.ToCLIArgs(), arg => arg.Rendered == "-nullrhi"); Assert.Contains(server.ToCLIArgs(), arg => arg.Rendered == "--desync-patch"); @@ -184,7 +184,7 @@ public void ClientLaunchDoesNotApplyServerDesyncCompatibilityPolicy() { ])); var launch = options.ToLaunchOptions(); Assert.True(launch.ServerLaunchOptions.IsNone); - Assert.Equal("TBL", launch.LaunchArgs); + Assert.Equal("TBL", launch.LaunchArgs); } [Theory] @@ -195,4 +195,4 @@ public void ClientLaunchDoesNotApplyServerDesyncCompatibilityPolicy() { public void DisableOptionsAreValueLessFlags(string flag) { Assert.Throws(() => CliLaunchOptions.Parse(Args(flag))); } -} +} \ No newline at end of file diff --git a/UnchainedLauncher.CLI.Tests/YamlConfigurationTests.cs b/UnchainedLauncher.CLI.Tests/YamlConfigurationTests.cs index 560b79b6..4247ed88 100644 --- a/UnchainedLauncher.CLI.Tests/YamlConfigurationTests.cs +++ b/UnchainedLauncher.CLI.Tests/YamlConfigurationTests.cs @@ -133,8 +133,8 @@ public void SubcommandSelectsModeRegardlessOfIncludedBlocks(bool client) { var options = Load("client: {}\nserver: {}\n", client); Assert.Equal(client, options is ClientOptions); Assert.Equal(client, options.ToLaunchOptions().ServerLaunchOptions.IsNone); - if (client) Assert.Equal("TBL", options.ToLaunchOptions().LaunchArgs); - else Assert.StartsWith("TBL -server ", options.ToLaunchOptions().LaunchArgs); + if (client) Assert.Equal("TBL", options.ToLaunchOptions().LaunchArgs); + else Assert.StartsWith("TBL -server ", options.ToLaunchOptions().LaunchArgs); Assert.Equal(!client, options.ToLaunchOptions().ToCLIArgs().Any(arg => arg.Rendered == "-nullrhi")); } @@ -275,7 +275,7 @@ public void RenderingRequiresExplicitlyDisablingDesyncPatchInYaml(string yaml) { Assert.Contains("server.desync-patch: false", error.Message); var client = Assert.IsType(Load(yaml, client: true)).ToLaunchOptions(); Assert.True(client.ServerLaunchOptions.IsNone); - Assert.Equal("TBL", client.LaunchArgs); + Assert.Equal("TBL", client.LaunchArgs); } [Fact] @@ -451,7 +451,7 @@ public void DirectClientCommandHasNoServerOptions() { var options = CliLaunchOptions.Parse(["client", "--chivalry2-path", _directory, "--platform", "EGS"]); Assert.IsType(options); Assert.True(options.ToLaunchOptions().ServerLaunchOptions.IsNone); - Assert.Equal("TBL", options.ToLaunchOptions().LaunchArgs); + Assert.Equal("TBL", options.ToLaunchOptions().LaunchArgs); } [Theory] @@ -510,4 +510,4 @@ public async Task ProductionSupervisionLeavesClientRunningButStopsServer(bool cl } } } -} +} \ No newline at end of file diff --git a/UnchainedLauncher.CLI/CliLaunchOptions.cs b/UnchainedLauncher.CLI/CliLaunchOptions.cs index 551546e4..c46042a5 100644 --- a/UnchainedLauncher.CLI/CliLaunchOptions.cs +++ b/UnchainedLauncher.CLI/CliLaunchOptions.cs @@ -93,7 +93,7 @@ internal static CliLaunchOptions FromConfiguration(LaunchConfiguration configura private protected LaunchOptions BuildLaunchOptions(IEnumerable prefix, LanguageExt.Option server) { return new( EnabledReleases: Shared.Mods.Distinct(), ServerBrowserBackend: Some(Shared.Backend), - LaunchArgs: string.Join(" ", prefix.Concat(GameArguments.Select(ArgumentEscaper.Escape))), + LaunchArgs: string.Join(" ", prefix.Concat(GameArguments.Select(ArgumentEscaper.Escape))), CheckForDependencyUpdates: Shared.UpdatePlugin, AllowUnstablePluginReleases: Shared.AllowUnstablePlugin, SavedDirSuffix: Some(Shared.SavedDirSuffix), ServerLaunchOptions: server); } @@ -169,4 +169,4 @@ public override LaunchOptions ToLaunchOptions() { prefix.AddRange(Ini.RawArguments); return BuildLaunchOptions(prefix, Some(ToServerLaunchOptions())); } -} +} \ No newline at end of file diff --git a/UnchainedLauncher.CLI/ServerInstancePaths.cs b/UnchainedLauncher.CLI/ServerInstancePaths.cs index 87578c8d..9940dd5c 100644 --- a/UnchainedLauncher.CLI/ServerInstancePaths.cs +++ b/UnchainedLauncher.CLI/ServerInstancePaths.cs @@ -3,7 +3,7 @@ namespace UnchainedLauncher.CLI; public sealed record ServerInstancePaths(string Root, string? PakCache = null) { - public string Paks => Path.Combine(Path.GetDirectoryName(Root)!, "Paks"); + public string Paks => Path.Combine(Path.GetDirectoryName(Root)!, "Paks"); public string Plugins => Path.Combine(Root, "plugins"); public string ModMetadata => Path.Combine(Root, "cli_mod_manager.json"); @@ -21,4 +21,4 @@ internal PakDir CreatePakDir(string basePakDirectory, CancellationToken cancella Directory.CreateDirectory(Paks); return new PakDir(Paks, [], Path.Combine(basePakDirectory, "pakchunk0-WindowsNoEditor.sig"), PakCache, cancellationToken); } -} +} \ No newline at end of file diff --git a/UnchainedLauncher.Core.Tests.Unit/Services/PakDir/PakOrderCollisionTests.cs b/UnchainedLauncher.Core.Tests.Unit/Services/PakDir/PakOrderCollisionTests.cs index 5dc1f3d8..1f0c1725 100644 --- a/UnchainedLauncher.Core.Tests.Unit/Services/PakDir/PakOrderCollisionTests.cs +++ b/UnchainedLauncher.Core.Tests.Unit/Services/PakDir/PakOrderCollisionTests.cs @@ -41,24 +41,24 @@ private void AssertUnmanagedUnchanged() { [InlineData(false)] [InlineData(true)] public async Task GrowthAndReorderingPreserveBytesAndMatchFreshInstallNames(bool uppercaseCollision) { - Occupy("Dependency_0_P.pak"); - Occupy(uppercaseCollision ? "DEPENDENCY_1_P.pak" : "Dependency_1_P.pak"); - Occupy("Dependency (1)_1_P.pak"); - Occupy("Dependent_0_P.pak"); - Occupy("Dependent_1_P.pak"); + Occupy("Dependency_0_P.pak"); + Occupy(uppercaseCollision ? "DEPENDENCY_1_P.pak" : "Dependency_1_P.pak"); + Occupy("Dependency (1)_1_P.pak"); + Occupy("Dependent_0_P.pak"); + Occupy("Dependent_1_P.pak"); var dir = new ManagedPakDirectory(_dir, []); (await Install(dir, _dependency)).ForEach(r => r.IfLeft(e => throw e)); var original = Assert.Single(dir.ManagedPaks); - Assert.Equal("Dependency (1)_0_P.pak", original.PakFileName); + Assert.Equal("Dependency (1)_0_P.pak", original.PakFileName); var originalPath = Path.Combine(_dir, original.PakFileName); var bytes = File.ReadAllBytes(originalPath); var sigBytes = File.ReadAllBytes(Path.ChangeExtension(originalPath, ".sig")); - (await Install(dir, _dependent, _dependency)).ForEach(r => r.IfLeft(e => throw e)); + (await Install(dir, _dependent, _dependency)).ForEach(r => r.IfLeft(e => throw e)); Assert.Equal(2, _writes); - Assert.Equal(new[] { _dependent, _dependency }, dir.ManagedPaks.OrderBy(p => p.Priority).Select(p => p.Coordinates)); + Assert.Equal(new[] { _dependent, _dependency }, dir.ManagedPaks.OrderBy(p => p.Priority).Select(p => p.Coordinates)); var moved = dir.ManagedPaks.Single(p => p.Coordinates == _dependency); - Assert.Equal("Dependency (2)_1_P.pak", moved.PakFileName); + Assert.Equal("Dependency (2)_1_P.pak", moved.PakFileName); Assert.Equal(bytes, File.ReadAllBytes(Path.Combine(_dir, moved.PakFileName))); Assert.Equal(sigBytes, File.ReadAllBytes(Path.ChangeExtension(Path.Combine(_dir, moved.PakFileName), ".sig"))); Assert.False(File.Exists(originalPath)); @@ -66,7 +66,7 @@ public async Task GrowthAndReorderingPreserveBytesAndMatchFreshInstallNames(bool var names = dir.ManagedPaks.OrderBy(p => p.Priority).Select(p => p.PakFileName).ToArray(); // Reconstructing persisted inventory and retrying must not rename or download anything. dir = new ManagedPakDirectory(_dir, dir.ManagedPaks); - (await Install(dir, _dependent, _dependency)).ForEach(r => r.IfLeft(e => throw e)); + (await Install(dir, _dependent, _dependency)).ForEach(r => r.IfLeft(e => throw e)); Assert.Equal(names, dir.ManagedPaks.OrderBy(p => p.Priority).Select(p => p.PakFileName)); Assert.Equal(2, _writes); @@ -75,13 +75,13 @@ public async Task GrowthAndReorderingPreserveBytesAndMatchFreshInstallNames(bool foreach (var (name, content) in _unmanaged) File.WriteAllBytes(Path.Combine(freshPath, name), content); File.WriteAllText(Path.Combine(freshPath, "pakchunk0-WindowsNoEditor.sig"), "signature"); var fresh = new ManagedPakDirectory(freshPath, []); - (await Install(fresh, _dependent, _dependency)).ForEach(r => r.IfLeft(e => throw e)); + (await Install(fresh, _dependent, _dependency)).ForEach(r => r.IfLeft(e => throw e)); Assert.Equal(names, fresh.ManagedPaks.OrderBy(p => p.Priority).Select(p => p.PakFileName)); - (await Install(dir, _dependency, _dependent)).ForEach(r => r.IfLeft(e => throw e)); - Assert.Equal(new[] { _dependency, _dependent }, dir.ManagedPaks.OrderBy(p => p.Priority).Select(p => p.Coordinates)); - Assert.Equal("Dependency (1)_0_P.pak", dir.ManagedPaks.Single(p => p.Coordinates == _dependency).PakFileName); - Assert.Equal("Dependent (1)_1_P.pak", dir.ManagedPaks.Single(p => p.Coordinates == _dependent).PakFileName); + (await Install(dir, _dependency, _dependent)).ForEach(r => r.IfLeft(e => throw e)); + Assert.Equal(new[] { _dependency, _dependent }, dir.ManagedPaks.OrderBy(p => p.Priority).Select(p => p.Coordinates)); + Assert.Equal("Dependency (1)_0_P.pak", dir.ManagedPaks.Single(p => p.Coordinates == _dependency).PakFileName); + Assert.Equal("Dependent (1)_1_P.pak", dir.ManagedPaks.Single(p => p.Coordinates == _dependent).PakFileName); (await Install(dir, _dependency)).ForEach(r => r.IfLeft(e => throw e)); Assert.Equal(original.PakFileName, Assert.Single(dir.ManagedPaks).PakFileName); Assert.Equal(4, _writes); // Only the two original downloads and two fresh-install downloads. @@ -92,177 +92,177 @@ public async Task GrowthAndReorderingPreserveBytesAndMatchFreshInstallNames(bool [InlineData(false)] [InlineData(true)] public async Task CollisionRelocationFailureReturnsErrorWithoutPublishingNewInventory(bool signatureFailure) { - Occupy("Dependency_0_P.pak"); - Occupy("Dependency_1_P.pak"); + Occupy("Dependency_0_P.pak"); + Occupy("Dependency_1_P.pak"); var dir = new ManagedPakDirectory(_dir, []); (await Install(dir, _dependency)).ForEach(r => r.IfLeft(e => throw e)); var original = Assert.Single(dir.ManagedPaks); - var originalPath = Path.Combine(_dir, original.PakFileName); - var bytes = File.ReadAllBytes(originalPath); - var sigBytes = File.ReadAllBytes(Path.ChangeExtension(originalPath, ".sig")); - var blocked = Path.Combine(_dir, "Dependency (1)_1_P.pak"); - var obstruction = signatureFailure ? Path.ChangeExtension(blocked, ".sig") : blocked; - Directory.CreateDirectory(obstruction); - var results = await Install(dir, _dependent, _dependency); + var originalPath = Path.Combine(_dir, original.PakFileName); + var bytes = File.ReadAllBytes(originalPath); + var sigBytes = File.ReadAllBytes(Path.ChangeExtension(originalPath, ".sig")); + var blocked = Path.Combine(_dir, "Dependency (1)_1_P.pak"); + var obstruction = signatureFailure ? Path.ChangeExtension(blocked, ".sig") : blocked; + Directory.CreateDirectory(obstruction); + var results = await Install(dir, _dependent, _dependency); Assert.Contains(results, r => r.IsLeft); - Assert.Equal(original, dir.ManagedPaks.Single(p => p.Coordinates == _dependency)); - Assert.Equal(bytes, File.ReadAllBytes(originalPath)); - Assert.Equal(sigBytes, File.ReadAllBytes(Path.ChangeExtension(originalPath, ".sig"))); - if (!signatureFailure) Assert.False(File.Exists(Path.ChangeExtension(blocked, ".sig"))); - dir = new ManagedPakDirectory(_dir, dir.ManagedPaks); - Assert.Equal(original, dir.ManagedPaks.Single(p => p.Coordinates == _dependency)); - Directory.Delete(obstruction); - (await Install(dir, _dependent, _dependency)).ForEach(r => r.IfLeft(e => throw e)); - var moved = dir.ManagedPaks.Single(p => p.Coordinates == _dependency); - Assert.Equal("Dependency (1)_1_P.pak", moved.PakFileName); - Assert.Equal(bytes, File.ReadAllBytes(Path.Combine(_dir, moved.PakFileName))); - Assert.Equal(sigBytes, File.ReadAllBytes(Path.ChangeExtension(Path.Combine(_dir, moved.PakFileName), ".sig"))); - Assert.False(File.Exists(originalPath)); - Assert.False(File.Exists(Path.ChangeExtension(originalPath, ".sig"))); - Assert.Single(Directory.GetFiles(_dir, "Dependency*.pak").Where(p => !_unmanaged.ContainsKey(Path.GetFileName(p)))); - Assert.Equal(2, _writes); - AssertUnmanagedUnchanged(); - } - - [Theory] - [InlineData(false)] - [InlineData(true)] - public async Task LegacyMigrationPreservesSignatureOnlyCollisionsAcrossRestart(bool uppercaseCollision) { - var legacy = new ManagedPak(_dependency, "q__-__Dependency.pak", 0); - var source = Path.Combine(_dir, legacy.PakFileName); - File.WriteAllText(source, "owned payload"); - File.WriteAllText(Path.ChangeExtension(source, ".sig"), "owned signature"); - var unmanagedSig = uppercaseCollision ? "DEPENDENCY_0_P.sig" : "Dependency_0_P.sig"; - var unmanagedBytes = System.Text.Encoding.UTF8.GetBytes("unmanaged signature"); - _unmanaged.Add(unmanagedSig, unmanagedBytes); - File.WriteAllBytes(Path.Combine(_dir, unmanagedSig), unmanagedBytes); - var dir = new ManagedPakDirectory(_dir, [legacy]); - (await Install(dir, _dependency)).ForEach(r => r.IfLeft(e => throw e)); - var migrated = Assert.Single(dir.ManagedPaks); - Assert.Equal("Dependency (1)_0_P.pak", migrated.PakFileName); - dir = new ManagedPakDirectory(_dir, dir.ManagedPaks); - (await Install(dir, _dependency)).ForEach(r => r.IfLeft(e => throw e)); - Assert.Equal(migrated, Assert.Single(dir.ManagedPaks)); - Assert.Single(Directory.GetFiles(_dir, "*.pak")); - Assert.Equal("owned payload", File.ReadAllText(Path.Combine(_dir, migrated.PakFileName))); - Assert.Equal("owned signature", File.ReadAllText(Path.ChangeExtension(Path.Combine(_dir, migrated.PakFileName), ".sig"))); - Assert.False(File.Exists(source)); - Assert.False(File.Exists(Path.ChangeExtension(source, ".sig"))); - Assert.Equal(0, _writes); - AssertUnmanagedUnchanged(); - } - - [Theory] - [InlineData(false)] - [InlineData(true)] - public async Task CachedLegacyPayloadSurvivesFailedMigrationAndRestart(bool signatureFailure) { - var bytes = System.Text.Encoding.UTF8.GetBytes("immutable cache payload"); - var hash = Convert.ToHexString(System.Security.Cryptography.SHA512.HashData(bytes)).ToLowerInvariant(); - var request = new ModInstallRequest(_dependency, path => { - _writes++; - return LanguageExt.Prelude.RightAsync(new FileWriter(path, new MemoryStream(bytes), bytes.Length)); - }, hash); - var cache = new PakCache(Path.Combine(_dir, "cache")); - var legacy = new ManagedPak(_dependency, "q__-__Dependency.pak", 0); - var source = Path.Combine(_dir, legacy.PakFileName); - (await cache.Install(request, source, LanguageExt.Prelude.None)).IfLeft(e => throw e); - File.WriteAllText(Path.ChangeExtension(source, ".sig"), "owned signature"); - var target = Path.Combine(_dir, "Dependency_0_P.pak"); - var obstruction = signatureFailure ? Path.ChangeExtension(target, ".sig") : target; - Directory.CreateDirectory(obstruction); - var dir = new ManagedPakDirectory(_dir, [legacy], cachePath: cache.Root); - var failed = await dir.InstallModSet([request], LanguageExt.Prelude.None).ToListAsync(); - Assert.Contains(failed, r => r.IsLeft); - Assert.Equal(legacy, Assert.Single(dir.ManagedPaks)); - Assert.Equal(bytes, File.ReadAllBytes(source)); - Assert.Equal("owned signature", File.ReadAllText(Path.ChangeExtension(source, ".sig"))); - Assert.Equal(bytes, File.ReadAllBytes(Path.Combine(cache.Root, hash, "mod.pak"))); - dir = new ManagedPakDirectory(_dir, dir.ManagedPaks, cachePath: cache.Root); - Directory.Delete(obstruction); - (await dir.InstallModSet([request], LanguageExt.Prelude.None).ToListAsync()).ForEach(r => r.IfLeft(e => throw e)); - Assert.Equal("Dependency_0_P.pak", Assert.Single(dir.ManagedPaks).PakFileName); - Assert.Single(Directory.GetFiles(_dir, "*.pak")); - Assert.Equal(bytes, File.ReadAllBytes(target)); - Assert.Equal("owned signature", File.ReadAllText(Path.ChangeExtension(target, ".sig"))); - Assert.Equal(bytes, File.ReadAllBytes(Path.Combine(cache.Root, hash, "mod.pak"))); - Assert.False(File.Exists(source)); - Assert.False(File.Exists(Path.ChangeExtension(source, ".sig"))); - Assert.Equal(1, _writes); - } - - [Fact] - public async Task SignatureCleanupFailureKeepsRelocatedPayloadOwnedAcrossRestart() { - // Windows permits copying a read-only signature but rejects deleting it. - if (!OperatingSystem.IsWindows()) return; - var legacy = new ManagedPak(_dependency, "q__-__Dependency.pak", 0); - var source = Path.Combine(_dir, legacy.PakFileName); - var sourceSig = Path.ChangeExtension(source, ".sig"); - File.WriteAllText(source, "owned payload"); - File.WriteAllText(sourceSig, "owned signature"); - File.SetAttributes(sourceSig, FileAttributes.ReadOnly); - try { - var dir = new ManagedPakDirectory(_dir, [legacy]); - Assert.Contains(await Install(dir, _dependency), r => r.IsLeft); - var moved = Assert.Single(dir.ManagedPaks); - Assert.Equal("Dependency_0_P.pak", moved.PakFileName); - Assert.False(File.Exists(source)); - Assert.Equal("owned signature", File.ReadAllText(sourceSig)); - dir = new ManagedPakDirectory(_dir, dir.ManagedPaks); - (await Install(dir, _dependency)).ForEach(r => r.IfLeft(e => throw e)); - Assert.Equal(moved, Assert.Single(dir.ManagedPaks)); - Assert.Single(Directory.GetFiles(_dir, "*.pak")); - Assert.Equal("owned payload", File.ReadAllText(Path.Combine(_dir, moved.PakFileName))); - Assert.Equal("owned signature", File.ReadAllText(Path.Combine(_dir, "Dependency_0_P.sig"))); - Assert.Equal(0, _writes); - } - finally { - foreach (var signature in Directory.GetFiles(_dir, "*.sig")) File.SetAttributes(signature, FileAttributes.Normal); - } - } - - [Fact] - public async Task FailedSignatureRollbackPreservesSourceOwnershipAndBothSignatures() { - if (!OperatingSystem.IsWindows()) return; - var legacy = new ManagedPak(_dependency, "q__-__Dependency.pak", 0); - var source = Path.Combine(_dir, legacy.PakFileName); - var sourceSig = Path.ChangeExtension(source, ".sig"); - var target = Path.Combine(_dir, "Dependency_0_P.pak"); - var targetSig = Path.ChangeExtension(target, ".sig"); - File.WriteAllText(source, "owned payload"); - File.WriteAllText(sourceSig, "owned signature"); - // File.Copy retains ReadOnly, so cleanup of the staged signature also fails. - File.SetAttributes(sourceSig, FileAttributes.ReadOnly); - Directory.CreateDirectory(target); - try { - var dir = new ManagedPakDirectory(_dir, [legacy]); - var failed = await Install(dir, _dependency); - Assert.Contains(failed, r => r.IsLeft); - failed.ForEach(r => r.IfLeft(e => Assert.Contains("signature cleanup also failed", e.ToString()))); - Assert.Equal(legacy, Assert.Single(dir.ManagedPaks)); - Assert.Equal("owned payload", File.ReadAllText(source)); - Assert.Equal("owned signature", File.ReadAllText(sourceSig)); - Assert.Equal("owned signature", File.ReadAllText(targetSig)); - - dir = new ManagedPakDirectory(_dir, dir.ManagedPaks); - Directory.Delete(target); - File.SetAttributes(sourceSig, FileAttributes.Normal); - (await Install(dir, _dependency)).ForEach(r => r.IfLeft(e => throw e)); - var moved = Assert.Single(dir.ManagedPaks); - Assert.Equal("Dependency (1)_0_P.pak", moved.PakFileName); - Assert.Single(Directory.GetFiles(_dir, "*.pak")); - Assert.Equal("owned payload", File.ReadAllText(Path.Combine(_dir, moved.PakFileName))); - Assert.Equal("owned signature", File.ReadAllText(Path.Combine(_dir, "Dependency (1)_0_P.sig"))); - Assert.Equal("owned signature", File.ReadAllText(targetSig)); - Assert.Equal(FileAttributes.ReadOnly, File.GetAttributes(targetSig) & FileAttributes.ReadOnly); - dir = new ManagedPakDirectory(_dir, dir.ManagedPaks); - (await Install(dir, _dependency)).ForEach(r => r.IfLeft(e => throw e)); - Assert.Equal(moved, Assert.Single(dir.ManagedPaks)); - Assert.Equal(0, _writes); - } - finally { - foreach (var signature in Directory.GetFiles(_dir, "*.sig")) File.SetAttributes(signature, FileAttributes.Normal); - } - } - - public void Dispose() => Directory.Delete(_dir, true); -} + Assert.Equal(original, dir.ManagedPaks.Single(p => p.Coordinates == _dependency)); + Assert.Equal(bytes, File.ReadAllBytes(originalPath)); + Assert.Equal(sigBytes, File.ReadAllBytes(Path.ChangeExtension(originalPath, ".sig"))); + if (!signatureFailure) Assert.False(File.Exists(Path.ChangeExtension(blocked, ".sig"))); + dir = new ManagedPakDirectory(_dir, dir.ManagedPaks); + Assert.Equal(original, dir.ManagedPaks.Single(p => p.Coordinates == _dependency)); + Directory.Delete(obstruction); + (await Install(dir, _dependent, _dependency)).ForEach(r => r.IfLeft(e => throw e)); + var moved = dir.ManagedPaks.Single(p => p.Coordinates == _dependency); + Assert.Equal("Dependency (1)_1_P.pak", moved.PakFileName); + Assert.Equal(bytes, File.ReadAllBytes(Path.Combine(_dir, moved.PakFileName))); + Assert.Equal(sigBytes, File.ReadAllBytes(Path.ChangeExtension(Path.Combine(_dir, moved.PakFileName), ".sig"))); + Assert.False(File.Exists(originalPath)); + Assert.False(File.Exists(Path.ChangeExtension(originalPath, ".sig"))); + Assert.Single(Directory.GetFiles(_dir, "Dependency*.pak").Where(p => !_unmanaged.ContainsKey(Path.GetFileName(p)))); + Assert.Equal(2, _writes); + AssertUnmanagedUnchanged(); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task LegacyMigrationPreservesSignatureOnlyCollisionsAcrossRestart(bool uppercaseCollision) { + var legacy = new ManagedPak(_dependency, "q__-__Dependency.pak", 0); + var source = Path.Combine(_dir, legacy.PakFileName); + File.WriteAllText(source, "owned payload"); + File.WriteAllText(Path.ChangeExtension(source, ".sig"), "owned signature"); + var unmanagedSig = uppercaseCollision ? "DEPENDENCY_0_P.sig" : "Dependency_0_P.sig"; + var unmanagedBytes = System.Text.Encoding.UTF8.GetBytes("unmanaged signature"); + _unmanaged.Add(unmanagedSig, unmanagedBytes); + File.WriteAllBytes(Path.Combine(_dir, unmanagedSig), unmanagedBytes); + var dir = new ManagedPakDirectory(_dir, [legacy]); + (await Install(dir, _dependency)).ForEach(r => r.IfLeft(e => throw e)); + var migrated = Assert.Single(dir.ManagedPaks); + Assert.Equal("Dependency (1)_0_P.pak", migrated.PakFileName); + dir = new ManagedPakDirectory(_dir, dir.ManagedPaks); + (await Install(dir, _dependency)).ForEach(r => r.IfLeft(e => throw e)); + Assert.Equal(migrated, Assert.Single(dir.ManagedPaks)); + Assert.Single(Directory.GetFiles(_dir, "*.pak")); + Assert.Equal("owned payload", File.ReadAllText(Path.Combine(_dir, migrated.PakFileName))); + Assert.Equal("owned signature", File.ReadAllText(Path.ChangeExtension(Path.Combine(_dir, migrated.PakFileName), ".sig"))); + Assert.False(File.Exists(source)); + Assert.False(File.Exists(Path.ChangeExtension(source, ".sig"))); + Assert.Equal(0, _writes); + AssertUnmanagedUnchanged(); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task CachedLegacyPayloadSurvivesFailedMigrationAndRestart(bool signatureFailure) { + var bytes = System.Text.Encoding.UTF8.GetBytes("immutable cache payload"); + var hash = Convert.ToHexString(System.Security.Cryptography.SHA512.HashData(bytes)).ToLowerInvariant(); + var request = new ModInstallRequest(_dependency, path => { + _writes++; + return LanguageExt.Prelude.RightAsync(new FileWriter(path, new MemoryStream(bytes), bytes.Length)); + }, hash); + var cache = new PakCache(Path.Combine(_dir, "cache")); + var legacy = new ManagedPak(_dependency, "q__-__Dependency.pak", 0); + var source = Path.Combine(_dir, legacy.PakFileName); + (await cache.Install(request, source, LanguageExt.Prelude.None)).IfLeft(e => throw e); + File.WriteAllText(Path.ChangeExtension(source, ".sig"), "owned signature"); + var target = Path.Combine(_dir, "Dependency_0_P.pak"); + var obstruction = signatureFailure ? Path.ChangeExtension(target, ".sig") : target; + Directory.CreateDirectory(obstruction); + var dir = new ManagedPakDirectory(_dir, [legacy], cachePath: cache.Root); + var failed = await dir.InstallModSet([request], LanguageExt.Prelude.None).ToListAsync(); + Assert.Contains(failed, r => r.IsLeft); + Assert.Equal(legacy, Assert.Single(dir.ManagedPaks)); + Assert.Equal(bytes, File.ReadAllBytes(source)); + Assert.Equal("owned signature", File.ReadAllText(Path.ChangeExtension(source, ".sig"))); + Assert.Equal(bytes, File.ReadAllBytes(Path.Combine(cache.Root, hash, "mod.pak"))); + dir = new ManagedPakDirectory(_dir, dir.ManagedPaks, cachePath: cache.Root); + Directory.Delete(obstruction); + (await dir.InstallModSet([request], LanguageExt.Prelude.None).ToListAsync()).ForEach(r => r.IfLeft(e => throw e)); + Assert.Equal("Dependency_0_P.pak", Assert.Single(dir.ManagedPaks).PakFileName); + Assert.Single(Directory.GetFiles(_dir, "*.pak")); + Assert.Equal(bytes, File.ReadAllBytes(target)); + Assert.Equal("owned signature", File.ReadAllText(Path.ChangeExtension(target, ".sig"))); + Assert.Equal(bytes, File.ReadAllBytes(Path.Combine(cache.Root, hash, "mod.pak"))); + Assert.False(File.Exists(source)); + Assert.False(File.Exists(Path.ChangeExtension(source, ".sig"))); + Assert.Equal(1, _writes); + } + + [Fact] + public async Task SignatureCleanupFailureKeepsRelocatedPayloadOwnedAcrossRestart() { + // Windows permits copying a read-only signature but rejects deleting it. + if (!OperatingSystem.IsWindows()) return; + var legacy = new ManagedPak(_dependency, "q__-__Dependency.pak", 0); + var source = Path.Combine(_dir, legacy.PakFileName); + var sourceSig = Path.ChangeExtension(source, ".sig"); + File.WriteAllText(source, "owned payload"); + File.WriteAllText(sourceSig, "owned signature"); + File.SetAttributes(sourceSig, FileAttributes.ReadOnly); + try { + var dir = new ManagedPakDirectory(_dir, [legacy]); + Assert.Contains(await Install(dir, _dependency), r => r.IsLeft); + var moved = Assert.Single(dir.ManagedPaks); + Assert.Equal("Dependency_0_P.pak", moved.PakFileName); + Assert.False(File.Exists(source)); + Assert.Equal("owned signature", File.ReadAllText(sourceSig)); + dir = new ManagedPakDirectory(_dir, dir.ManagedPaks); + (await Install(dir, _dependency)).ForEach(r => r.IfLeft(e => throw e)); + Assert.Equal(moved, Assert.Single(dir.ManagedPaks)); + Assert.Single(Directory.GetFiles(_dir, "*.pak")); + Assert.Equal("owned payload", File.ReadAllText(Path.Combine(_dir, moved.PakFileName))); + Assert.Equal("owned signature", File.ReadAllText(Path.Combine(_dir, "Dependency_0_P.sig"))); + Assert.Equal(0, _writes); + } + finally { + foreach (var signature in Directory.GetFiles(_dir, "*.sig")) File.SetAttributes(signature, FileAttributes.Normal); + } + } + + [Fact] + public async Task FailedSignatureRollbackPreservesSourceOwnershipAndBothSignatures() { + if (!OperatingSystem.IsWindows()) return; + var legacy = new ManagedPak(_dependency, "q__-__Dependency.pak", 0); + var source = Path.Combine(_dir, legacy.PakFileName); + var sourceSig = Path.ChangeExtension(source, ".sig"); + var target = Path.Combine(_dir, "Dependency_0_P.pak"); + var targetSig = Path.ChangeExtension(target, ".sig"); + File.WriteAllText(source, "owned payload"); + File.WriteAllText(sourceSig, "owned signature"); + // File.Copy retains ReadOnly, so cleanup of the staged signature also fails. + File.SetAttributes(sourceSig, FileAttributes.ReadOnly); + Directory.CreateDirectory(target); + try { + var dir = new ManagedPakDirectory(_dir, [legacy]); + var failed = await Install(dir, _dependency); + Assert.Contains(failed, r => r.IsLeft); + failed.ForEach(r => r.IfLeft(e => Assert.Contains("signature cleanup also failed", e.ToString()))); + Assert.Equal(legacy, Assert.Single(dir.ManagedPaks)); + Assert.Equal("owned payload", File.ReadAllText(source)); + Assert.Equal("owned signature", File.ReadAllText(sourceSig)); + Assert.Equal("owned signature", File.ReadAllText(targetSig)); + + dir = new ManagedPakDirectory(_dir, dir.ManagedPaks); + Directory.Delete(target); + File.SetAttributes(sourceSig, FileAttributes.Normal); + (await Install(dir, _dependency)).ForEach(r => r.IfLeft(e => throw e)); + var moved = Assert.Single(dir.ManagedPaks); + Assert.Equal("Dependency (1)_0_P.pak", moved.PakFileName); + Assert.Single(Directory.GetFiles(_dir, "*.pak")); + Assert.Equal("owned payload", File.ReadAllText(Path.Combine(_dir, moved.PakFileName))); + Assert.Equal("owned signature", File.ReadAllText(Path.Combine(_dir, "Dependency (1)_0_P.sig"))); + Assert.Equal("owned signature", File.ReadAllText(targetSig)); + Assert.Equal(FileAttributes.ReadOnly, File.GetAttributes(targetSig) & FileAttributes.ReadOnly); + dir = new ManagedPakDirectory(_dir, dir.ManagedPaks); + (await Install(dir, _dependency)).ForEach(r => r.IfLeft(e => throw e)); + Assert.Equal(moved, Assert.Single(dir.ManagedPaks)); + Assert.Equal(0, _writes); + } + finally { + foreach (var signature in Directory.GetFiles(_dir, "*.sig")) File.SetAttributes(signature, FileAttributes.Normal); + } + } + + public void Dispose() => Directory.Delete(_dir, true); +} \ No newline at end of file diff --git a/UnchainedLauncher.Core/Services/Mods/IPakDir.cs b/UnchainedLauncher.Core/Services/Mods/IPakDir.cs index 61808ddc..769bfac1 100644 --- a/UnchainedLauncher.Core/Services/Mods/IPakDir.cs +++ b/UnchainedLauncher.Core/Services/Mods/IPakDir.cs @@ -87,4 +87,4 @@ public interface IPakDir { /// string DirPath { get; } } -} +} \ No newline at end of file diff --git a/UnchainedLauncher.Core/Services/Mods/PakDir.cs b/UnchainedLauncher.Core/Services/Mods/PakDir.cs index 7df78b13..906293da 100644 --- a/UnchainedLauncher.Core/Services/Mods/PakDir.cs +++ b/UnchainedLauncher.Core/Services/Mods/PakDir.cs @@ -99,27 +99,27 @@ public Either Uninstall(ModIdentifier coords) => } ); - private static string PakSuffix(string fileName) { - var patch = Regex.Match(fileName, @"_[0-9]+_P\.pak\z", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); - return patch.Success ? patch.Value : Path.GetExtension(fileName); - } - - private static bool IsCollisionName(string fileName, string targetName) { - var suffix = PakSuffix(targetName); - return Regex.IsMatch(fileName, - @"\A" + Regex.Escape(targetName[..^suffix.Length]) + @" \([0-9]+\)" + Regex.Escape(suffix) + @"\z", - RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); - } - - private string AvailablePakName(string suggestedFileName) { - var unManagedPaks = GetUnmanagedPaks().ToHashSet(StringComparer.OrdinalIgnoreCase); - var signatures = GetSigFiles().ToHashSet(StringComparer.OrdinalIgnoreCase); - var extension = PakSuffix(suggestedFileName); - var actualName = suggestedFileName[..^extension.Length]; + private static string PakSuffix(string fileName) { + var patch = Regex.Match(fileName, @"_[0-9]+_P\.pak\z", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + return patch.Success ? patch.Value : Path.GetExtension(fileName); + } + + private static bool IsCollisionName(string fileName, string targetName) { + var suffix = PakSuffix(targetName); + return Regex.IsMatch(fileName, + @"\A" + Regex.Escape(targetName[..^suffix.Length]) + @" \([0-9]+\)" + Regex.Escape(suffix) + @"\z", + RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + } + + private string AvailablePakName(string suggestedFileName) { + var unManagedPaks = GetUnmanagedPaks().ToHashSet(StringComparer.OrdinalIgnoreCase); + var signatures = GetSigFiles().ToHashSet(StringComparer.OrdinalIgnoreCase); + var extension = PakSuffix(suggestedFileName); + var actualName = suggestedFileName[..^extension.Length]; var fullActualName = actualName + extension; while (_managedPaks.Exists(x => x.PakFileName.Equals(fullActualName, StringComparison.OrdinalIgnoreCase)) - || unManagedPaks.Contains(PakNameToPakPath(fullActualName)) - || signatures.Contains(Path.ChangeExtension(PakNameToPakPath(fullActualName), ".sig"))) { + || unManagedPaks.Contains(PakNameToPakPath(fullActualName)) + || signatures.Contains(Path.ChangeExtension(PakNameToPakPath(fullActualName), ".sig"))) { actualName = Successors.TextualSuccessor(actualName); fullActualName = actualName + extension; } @@ -152,14 +152,14 @@ public async IAsyncEnumerable> InstallModSet( .ToHashSet(); var pakFileNames = installsList - .Select((install, order) => { + .Select((install, order) => { var moduleName = install.Coordinates.ModuleName; var needsOrgPrefix = moduleNameCounts.Contains(moduleName); var baseName = needsOrgPrefix ? $"{install.Coordinates.Org}_{moduleName}" : moduleName; - // Unreal gives higher patch numbers priority over dependencies and vanilla. - return FormattableString.Invariant($"{baseName}_{order}_P.pak"); + // Unreal gives higher patch numbers priority over dependencies and vanilla. + return FormattableString.Invariant($"{baseName}_{order}_P.pak"); }) .ToList(); @@ -168,20 +168,20 @@ public async IAsyncEnumerable> InstallModSet( .Filter(p => installCoordinatesSet.Contains(p.Coordinates)) .GroupBy(p => p.Coordinates) .ToDictionary(g => g.Key, g => g.First()); - var unmanagedPaths = GetUnmanagedPaks().ToHashSet(StringComparer.OrdinalIgnoreCase); - var signaturePaths = GetSigFiles().ToHashSet(StringComparer.OrdinalIgnoreCase); + var unmanagedPaths = GetUnmanagedPaks().ToHashSet(StringComparer.OrdinalIgnoreCase); + var signaturePaths = GetSigFiles().ToHashSet(StringComparer.OrdinalIgnoreCase); // Categorize each install: Skip (already correct), Move (relocate), or Download (new) var installActions = installsList - .Zip(pakFileNames, (install, finalPakName) => (Install: install, FinalPakName: finalPakName)) + .Zip(pakFileNames, (install, finalPakName) => (Install: install, FinalPakName: finalPakName)) .Select((item, index) => { var existing = existingByCoords.TryGetValue(item.Install.Coordinates, out var e) ? Some(e) : None; return existing.Match( - // Reuse collision-safe names only while their module and numeric order match. + // Reuse collision-safe names only while their module and numeric order match. Some: pak => pak.PakFileName.Equals(item.FinalPakName, StringComparison.OrdinalIgnoreCase) - || ((unmanagedPaths.Contains(PakNameToPakPath(item.FinalPakName)) - || signaturePaths.Contains(Path.ChangeExtension(PakNameToPakPath(item.FinalPakName), ".sig"))) - && IsCollisionName(pak.PakFileName, item.FinalPakName)) + || ((unmanagedPaths.Contains(PakNameToPakPath(item.FinalPakName)) + || signaturePaths.Contains(Path.ChangeExtension(PakNameToPakPath(item.FinalPakName), ".sig"))) + && IsCollisionName(pak.PakFileName, item.FinalPakName)) ? new InstallAction.Skip(item.Install, index, pak) : new InstallAction.Move(item.Install, index, pak, item.FinalPakName) as InstallAction, None: () => new InstallAction.Download(item.Install, index, item.FinalPakName) @@ -290,14 +290,14 @@ private Either ProcessMove(InstallAction.Move move) { var targetName = AvailablePakName(move.TargetPakName); var targetPath = PakNameToPakPath(targetName); - var managedPak = new ManagedPak(move.Install.Coordinates, targetName, move.Index); - return _moveManagedPak(sourcePath, targetPath, () => { - // Ownership follows the payload even if removing the old signature fails. - _managedPaks.RemoveAll(p => p.Coordinates.Matches(move.Install.Coordinates)); - _managedPaks.Add(managedPak); - Logger.Info($"Relocated {move.Install.Coordinates.ModuleName} to {targetName}"); - }) - .Map(_ => managedPak); + var managedPak = new ManagedPak(move.Install.Coordinates, targetName, move.Index); + return _moveManagedPak(sourcePath, targetPath, () => { + // Ownership follows the payload even if removing the old signature fails. + _managedPaks.RemoveAll(p => p.Coordinates.Matches(move.Install.Coordinates)); + _managedPaks.Add(managedPak); + Logger.Info($"Relocated {move.Install.Coordinates.ModuleName} to {targetName}"); + }) + .Map(_ => managedPak); } private async Task> ProcessDownload(InstallAction.Download download, Option taskProgress) { @@ -328,24 +328,24 @@ private Either ProcessRemove(InstallAction.Remove remove) { /// /// Moves a managed pak (and its .sig if present) from source to destination. /// - private static Either _moveManagedPak(string sourcePakPath, string destPakPath, Action payloadMoved) { - var sourceSigPath = Path.ChangeExtension(sourcePakPath, ".sig"); - var destSigPath = Path.ChangeExtension(destPakPath, ".sig"); - - // Copy first: a failed payload move never requires rolling back its ownership - // or restoring the only signature. Never overwrite cache-linked file contents. - var hasSignature = File.Exists(sourceSigPath); - return (hasSignature ? _copyFile(sourceSigPath, destSigPath) : Right(Unit.Default)) - .Bind(_ => _moveFile(sourcePakPath, destPakPath).Match>( - Right: _ => { - payloadMoved(); - return hasSignature ? _deleteFile(sourceSigPath) : Right(Unit.Default); - }, - Left: error => hasSignature - ? _deleteFile(destSigPath).Match>( - Right: _ => Left(error), - Left: cleanupError => Left(Error.New($"{error}; signature cleanup also failed: {cleanupError}"))) - : Left(error))); + private static Either _moveManagedPak(string sourcePakPath, string destPakPath, Action payloadMoved) { + var sourceSigPath = Path.ChangeExtension(sourcePakPath, ".sig"); + var destSigPath = Path.ChangeExtension(destPakPath, ".sig"); + + // Copy first: a failed payload move never requires rolling back its ownership + // or restoring the only signature. Never overwrite cache-linked file contents. + var hasSignature = File.Exists(sourceSigPath); + return (hasSignature ? _copyFile(sourceSigPath, destSigPath) : Right(Unit.Default)) + .Bind(_ => _moveFile(sourcePakPath, destPakPath).Match>( + Right: _ => { + payloadMoved(); + return hasSignature ? _deleteFile(sourceSigPath) : Right(Unit.Default); + }, + Left: error => hasSignature + ? _deleteFile(destSigPath).Match>( + Right: _ => Left(error), + Left: cleanupError => Left(Error.New($"{error}; signature cleanup also failed: {cleanupError}"))) + : Left(error))); } private static Either _moveFile(string source, string dest) { @@ -446,4 +446,4 @@ public Either, Unit> DeleteOrphanedSigs() { } } -} +} \ No newline at end of file diff --git a/UnchainedLauncher.Core/Services/Mods/ProfileAssetPaths.cs b/UnchainedLauncher.Core/Services/Mods/ProfileAssetPaths.cs index 6287fa77..3f19dc28 100644 --- a/UnchainedLauncher.Core/Services/Mods/ProfileAssetPaths.cs +++ b/UnchainedLauncher.Core/Services/Mods/ProfileAssetPaths.cs @@ -3,10 +3,10 @@ namespace UnchainedLauncher.Core.Services.Mods; public sealed class ProfileAssetPaths { - public const string SharedSuffix = "unchained"; - public string Suffix { get; } + public const string SharedSuffix = "unchained"; + public string Suffix { get; } public string Root { get; } - public string Paks => Path.Combine(Path.GetDirectoryName(Root)!, "Paks"); + public string Paks => Path.Combine(Path.GetDirectoryName(Root)!, "Paks"); public string Plugins => Path.Combine(Root, "plugins"); public string PakCache { get; } public string DllCache { get; } @@ -14,9 +14,9 @@ public sealed class ProfileAssetPaths { public ProfileAssetPaths(string suffix, string? localAppData = null) { var game = Path.Combine(localAppData ?? Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Chivalry 2"); var normalized = suffix.Replace("-", "", StringComparison.Ordinal); - if (normalized.Length == 0 || normalized.Any(c => !char.IsAsciiLetterOrDigit(c) && c != '_')) - throw new ArgumentException("Invalid saved directory suffix."); - Suffix = normalized; + if (normalized.Length == 0 || normalized.Any(c => !char.IsAsciiLetterOrDigit(c) && c != '_')) + throw new ArgumentException("Invalid saved directory suffix."); + Suffix = normalized; Root = Path.Combine(game, "Saved_" + normalized, "unchained"); PakCache = Path.Combine(game, "unchained", "cache", "paks"); DllCache = Path.Combine(game, "unchained", "cache", "dlls"); @@ -26,4 +26,4 @@ public PakDir CreatePakDir(string installation, IEnumerable? managed Directory.CreateDirectory(Paks); return new PakDir(Paks, managedPaks ?? [], Path.Combine(installation, FilePaths.PakDir, "pakchunk0-WindowsNoEditor.sig"), PakCache); } -} +} \ No newline at end of file diff --git a/UnchainedLauncher.GUI/App.xaml.cs b/UnchainedLauncher.GUI/App.xaml.cs index 43942f86..a6c81052 100644 --- a/UnchainedLauncher.GUI/App.xaml.cs +++ b/UnchainedLauncher.GUI/App.xaml.cs @@ -177,8 +177,8 @@ select chiv2Installations.Path IChivalry2LaunchPreparer pluginInstaller = new DllLaunchPreparer(dllManager, userDialogueSpawner); - IChivalry2LaunchPreparer modInstaller = new ProfileModsLaunchPreparer( - modManager, userDialogueSpawner, Directory.GetCurrentDirectory() + IChivalry2LaunchPreparer modInstaller = new ProfileModsLaunchPreparer( + modManager, userDialogueSpawner, Directory.GetCurrentDirectory() ); var vanillaLauncher = new Chivalry2Launcher( @@ -188,8 +188,8 @@ select chiv2Installations.Path ); var unchainedLauncher = new UnchainedChivalry2Launcher( - pluginInstaller - .AndThen(modInstaller), + pluginInstaller + .AndThen(modInstaller), unchainedProcessLauncher, Directory.GetCurrentDirectory(), #if DEBUG_FAKECHIVALRYLAUNCH @@ -426,4 +426,4 @@ private void ExecuteExitAction(string source, Action exitAction) { } } } -} +} \ No newline at end of file diff --git a/UnchainedLauncher.GUI/Services/ProfileModsLaunchPreparer.cs b/UnchainedLauncher.GUI/Services/ProfileModsLaunchPreparer.cs index 360b6c0d..533576c8 100644 --- a/UnchainedLauncher.GUI/Services/ProfileModsLaunchPreparer.cs +++ b/UnchainedLauncher.GUI/Services/ProfileModsLaunchPreparer.cs @@ -1,7 +1,7 @@ +using LanguageExt; using System; using System.IO; using System.Threading.Tasks; -using LanguageExt; using UnchainedLauncher.Core.Services; using UnchainedLauncher.Core.Services.Mods; using UnchainedLauncher.Core.Services.Processes.Chivalry; @@ -24,4 +24,4 @@ public Task> PrepareLaunch(LaunchOptions options) { .Sub(SigPreparer.Create(profileManager.PakDir, dialogue)) .PrepareLaunch(options with { SavedDirSuffix = assets.Suffix }); } -} +} \ No newline at end of file From 0754a25b4aeca0af90018a7925e3c3ac9c508183 Mon Sep 17 00:00:00 2001 From: Jacob Barber Date: Mon, 19 Oct 2026 20:54:01 -0500 Subject: [PATCH 43/58] Intersect DLL pak requirements before selecting releases --- .../LaunchPreparationTests.cs | 80 +++++++++++++++++-- .../Services/Mods/DllLaunchSession.cs | 23 ++++-- 2 files changed, 91 insertions(+), 12 deletions(-) diff --git a/UnchainedLauncher.CLI.Tests/LaunchPreparationTests.cs b/UnchainedLauncher.CLI.Tests/LaunchPreparationTests.cs index 50b92995..d09c86d9 100644 --- a/UnchainedLauncher.CLI.Tests/LaunchPreparationTests.cs +++ b/UnchainedLauncher.CLI.Tests/LaunchPreparationTests.cs @@ -141,20 +141,90 @@ public async Task DependencySortMustRetainEverySelectedReleaseBeforePakMutation( Assert.Equal("preserve unknown bytes", File.ReadAllText(unknown)); } - private static void AddPlugin(LocalModRegistry local, string dependency, string version) { + [Theory] + [InlineData(false, null, false)] + [InlineData(true, null, false)] + [InlineData(false, "v1.0.0", false)] + [InlineData(true, "v1.0.0", false)] + [InlineData(false, "v2.0.0", false)] + [InlineData(true, "v2.0.0", false)] + [InlineData(false, null, true)] + [InlineData(true, null, true)] + public async Task PreparationIntersectsDllPakRequirementsBeforeEnabling( + bool reverse, string? selectedTag, bool incompatible) { + var local = new LocalModRegistry(Path.Combine(_directory, "registry")); + var source = Path.Combine(_directory, "payload.pak"); + File.WriteAllText(source, "pak payload"); + var hash = FileHelpers.Sha512(source).Match(value => value, error => throw new Exception(error.Message)); + Release Pak(string id, string tag) => new(tag, hash, "mod.pak", + new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc), + new ModInfo("https://github.com/" + id, id.Split('/')[1], "test", null, null, [], ["test"], []), + new AssetCollections([], [], [], [], []), null); + await local.AddRelease(Pak("Chiv2-Community/Unchained-Mods", "v1.0.0"), source); + await local.AddRelease(Pak("org/independent", "v1.0.0"), source); + foreach (var tag in new[] { "v1.0.0", "v1.5.0", "v2.0.0" }) + await local.AddRelease(Pak("org/shared", tag), source); + AddPlugin(local, "org/independent", ">=1.0.0"); + AddPlugin(local, "org/shared", incompatible ? ">=2.0.0" : ">=1.0.0", "org/A"); + AddPlugin(local, "ORG/shared", "<2.0.0", "org/B"); + + string[] dlls = reverse ? ["org/B:v1.0.0", "org/A:v1.0.0"] : ["org/A:v1.0.0", "org/B:v1.0.0"]; + var request = CliLaunchOptions.Parse([ + "client", "--chivalry2-path", _directory, "--platform", "Steam", + "--dll", dlls[0], "--dll", dlls[1], + .. selectedTag is null ? Array.Empty() : ["--mod", "org/shared:" + selectedTag] + ]); + request.Shared.Assets = new ProfileAssetPaths(request.Shared.SavedDirSuffix, _directory); + var pakPath = Path.Combine(_directory, FilePaths.PakDir); + Directory.CreateDirectory(pakPath); + File.WriteAllText(Path.Combine(pakPath, "pakchunk0-WindowsNoEditor.sig"), "signature"); + var registry = new AggregateModRegistry(local); + var manager = ServerLauncher.CreateModManager(request, registry); + Assert.False((await manager.UpdateModsList()).HasErrors); + ServerLauncher.ValidateReleases(manager, request.Shared.Mods); + var original = manager.EnabledModReleaseCoordinates.ToArray(); + Assert.Equal(2, request.Shared.Dlls.Count); + using var client = new HttpClient(new DownloadHandler()); + var plugins = Path.Combine(_directory, "plugins"); + var session = new DllLaunchSession(registry, manager, plugins, request.Shared.Dlls, client: client, + cacheDirectory: Path.Combine(_directory, "dll-cache")); + var cachePath = Path.Combine(_directory, "cli_mod_manager.json"); + var preparer = ServerLauncher.CreateLaunchPreparer(request, manager, session, new ModManagerCodec(registry), cachePath); + + if (incompatible || selectedTag == "v2.0.0") { + var error = await Assert.ThrowsAsync(() => preparer.PrepareLaunch(request.ToLaunchOptions())); + Assert.Contains(selectedTag is null ? "DLL pak dependency unavailable" : "conflicts with selected mods", error.Message); + Assert.Equal(original, manager.EnabledModReleaseCoordinates); + Assert.Empty(Directory.GetFiles(request.InstancePaths.Paks, "*.pak")); + Assert.False(File.Exists(cachePath)); + Assert.False(File.Exists(Path.Combine(plugins, "dll_manager.json"))); + } + else { + Assert.True((await preparer.PrepareLaunch(request.ToLaunchOptions())).IsSome); + Assert.Contains(new ReleaseCoordinates("org", "shared", selectedTag ?? "v1.5.0"), manager.EnabledModReleaseCoordinates); + Assert.Single(manager.EnabledModReleaseCoordinates.Where(c => c.ModuleName == "shared")); + Assert.Equal(3, Directory.GetFiles(request.InstancePaths.Paks, "*.pak").Length); + Assert.True(File.Exists(cachePath)); + Assert.True(File.Exists(Path.Combine(plugins, "dll_manager.json"))); + } + } + + private static void AddPlugin(LocalModRegistry local, string dependency, string version, + string identifier = "Chiv2-Community/UnchainedPlugin") { + var parts = identifier.Split('/'); var dll = new DllRelease("v1.0.0", Convert.ToHexString(SHA256.HashData([1, 2, 3])).ToLowerInvariant(), - "UnchainedPlugin.dll", "https://example.test/UnchainedPlugin.dll", 3, + parts[1] + ".dll", "https://example.test/" + parts[1] + ".dll", 3, new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero), new DllInfo { - RepoUrl = "https://github.com/Chiv2-Community/UnchainedPlugin", + RepoUrl = "https://github.com/" + identifier, Name = "core", Description = "test", Authors = ["test"], ModDependencies = [new Dependency("https://github.com/" + dependency, version)] }, null); - var packages = Path.Combine(local.RegistryPath, "dll_db", "packages", "Chiv2-Community"); + var packages = Path.Combine(local.RegistryPath, "dll_db", "packages", parts[0]); Directory.CreateDirectory(packages); - File.WriteAllText(Path.Combine(packages, "UnchainedPlugin.json"), JsonHelpers.Serialize(new DllPackage(dll.Info, [dll]))); + File.WriteAllText(Path.Combine(packages, parts[1] + ".json"), JsonHelpers.Serialize(new DllPackage(dll.Info, [dll]))); } private sealed class DownloadHandler : HttpMessageHandler { diff --git a/UnchainedLauncher.Core/Services/Mods/DllLaunchSession.cs b/UnchainedLauncher.Core/Services/Mods/DllLaunchSession.cs index 705af521..6c5c8f25 100644 --- a/UnchainedLauncher.Core/Services/Mods/DllLaunchSession.cs +++ b/UnchainedLauncher.Core/Services/Mods/DllLaunchSession.cs @@ -1,6 +1,7 @@ using LanguageExt; using Semver; using UnchainedLauncher.Core.JsonModels; +using UnchainedLauncher.Core.JsonModels.ModMetadata; using UnchainedLauncher.Core.Services.Dlls; using UnchainedLauncher.Core.Services.Mods.Registry; using UnchainedLauncher.Core.Services.Processes; @@ -128,19 +129,27 @@ public async Task> PrepareLaunch(LaunchOptions options) { var fetched = await _mods.UpdateModsList(); if (fetched.HasErrors) throw new InvalidOperationException("Pak registry loading failed while resolving DLL dependencies."); } - foreach (var dependency in releases.SelectMany(r => r.Info.ModDependencies)) { - var id = ModIdentifier.FromDependency(dependency); - var existing = _mods.GetEnabledAndDependencyReleases().Where(r => - r.Info.RepoUrl.Equals(dependency.RepoUrl, StringComparison.OrdinalIgnoreCase)).ToArray(); + var selectedPaks = _mods.GetEnabledAndDependencyReleases().ToArray(); + var paksToEnable = new List(); + foreach (var requirements in releases.SelectMany(r => r.Info.ModDependencies) + .GroupBy(d => d.RepoUrl, StringComparer.OrdinalIgnoreCase)) { + var id = ModIdentifier.FromDependency(requirements.First()); + bool Accept(Release release) => release.Version is { } version && + requirements.All(dependency => SatisfiesVersion(version, dependency.Version)); + var existing = selectedPaks.Where(r => + r.Info.RepoUrl.Equals(requirements.Key, StringComparison.OrdinalIgnoreCase)).ToArray(); if (existing.Length != 0) { - if (existing.Any(r => r.Version is null || !SatisfiesVersion(r.Version, dependency.Version))) + if (existing.Any(r => !Accept(r))) throw new InvalidOperationException("DLL pak dependency conflicts with selected mods."); continue; } - var mod = _mods.Mods.FirstOrDefault(m => m.LatestReleaseInfo.RepoUrl.Equals(dependency.RepoUrl, StringComparison.OrdinalIgnoreCase)); - var release = mod?.Releases.Where(r => r.Version is { } version && SatisfiesVersion(version, dependency.Version)) + var mod = _mods.Mods.FirstOrDefault(m => m.LatestReleaseInfo.RepoUrl.Equals(requirements.Key, StringComparison.OrdinalIgnoreCase)); + var release = mod?.Releases.Where(Accept) .OrderByDescending(r => r.Version).FirstOrDefault() ?? throw new InvalidOperationException($"DLL pak dependency unavailable: {id.Org}/{id.ModuleName}"); + paksToEnable.Add(release); + } + foreach (var release in paksToEnable) { if (!_mods.EnableModRelease(ReleaseCoordinates.FromRelease(release))) throw new InvalidOperationException("Could not enable DLL pak dependency."); } From a4c097c6174ff8d3a4f46400b725b16478f07e54 Mon Sep 17 00:00:00 2001 From: Jacob Barber Date: Mon, 19 Oct 2026 21:37:33 -0500 Subject: [PATCH 44/58] Correct profile cleanup disclosures and documentation --- UnchainedLauncher.CLI/README.md | 5 +- .../HelpVMPlatformStubs.cs | 13 +++++ .../HelpVMTests.cs | 50 +++++++++++++++++++ .../UnchainedLauncher.GUI.Tests.Unit.csproj | 1 + UnchainedLauncher.GUI/ViewModels/HelpVM.cs | 7 +-- annals/index.md | 29 ----------- 6 files changed, 71 insertions(+), 34 deletions(-) create mode 100644 UnchainedLauncher.GUI.Tests.Unit/HelpVMPlatformStubs.cs create mode 100644 UnchainedLauncher.GUI.Tests.Unit/HelpVMTests.cs delete mode 100644 annals/index.md diff --git a/UnchainedLauncher.CLI/README.md b/UnchainedLauncher.CLI/README.md index 0224ea19..cdd79c75 100644 --- a/UnchainedLauncher.CLI/README.md +++ b/UnchainedLauncher.CLI/README.md @@ -461,8 +461,9 @@ never overwrites GUI configuration or reuses the GUI's enabled-mod selection. Each launch reconciles the installed managed set with that host's selected releases; genuinely unmanaged user paks are preserved. Core's installation pipeline changes the installed managed pak set and signatures. -The GUI uses one shared `Saved_unchained/unchained` pak/plugin set for all clients and -servers. Only the CLI differentiates selections by launch profile. CLI profiles +The GUI installs paks in each launch profile's `Saved_/Paks` directory, +using a shared GUI mod selection and shared `Saved_unchained/unchained/plugins` DLLs. +CLI mod and DLL selections remain request-specific. CLI profiles need separate saved suffixes/ports; CLI startup preparation remains sequential because some Core operations change CWD and metadata. diff --git a/UnchainedLauncher.GUI.Tests.Unit/HelpVMPlatformStubs.cs b/UnchainedLauncher.GUI.Tests.Unit/HelpVMPlatformStubs.cs new file mode 100644 index 00000000..03207247 --- /dev/null +++ b/UnchainedLauncher.GUI.Tests.Unit/HelpVMPlatformStubs.cs @@ -0,0 +1,13 @@ +// Unused update-path dependencies let the real HelpVM commands run without WPF. +namespace UnchainedLauncher.GUI.ViewModels { + public sealed class SettingsVM { + public Semver.SemVersion Version => throw new NotSupportedException(); + public string CurrentVersion => throw new NotSupportedException(); + } +} + +namespace System.Windows { + public static class MessageBox { + public static void Show(string message) => throw new NotSupportedException(); + } +} diff --git a/UnchainedLauncher.GUI.Tests.Unit/HelpVMTests.cs b/UnchainedLauncher.GUI.Tests.Unit/HelpVMTests.cs new file mode 100644 index 00000000..f0f5465b --- /dev/null +++ b/UnchainedLauncher.GUI.Tests.Unit/HelpVMTests.cs @@ -0,0 +1,50 @@ +using System.Reflection; +using UnchainedLauncher.Core.Services; +using UnchainedLauncher.Core.Services.Mods; +using UnchainedLauncher.Core.Utilities; +using UnchainedLauncher.GUI.ViewModels; +using Xunit; + +namespace UnchainedLauncher.GUI.Tests.Unit; + +public sealed class HelpVMTests { + [Theory] + [InlineData(true)] + [InlineData(false)] + public void CleanupConfirmationsDescribeActualPathsAndRetainedAssets(bool uninstall) { + var paks = DispatchProxy.Create(); + var dialogue = new RejectingDialogue(); + var vm = new HelpVM(null!, null!, null!, paks, dialogue, + _ => throw new InvalidOperationException("Must not exit after rejecting confirmation.")); + + if (uninstall) vm.UninstallLauncherCommand.Execute(null); + else vm.CleanUpInstallationCommand.Execute(null); + + var message = Assert.Single(dialogue.Messages); + Assert.Contains($"* All files in {new ProfileAssetPaths(ProfileAssetPaths.SharedSuffix).Plugins}", message); + Assert.Contains($"* All non-vanilla files in {paks.DirPath}", message); + Assert.Contains("* All files in .mod_cache", message); + Assert.Contains("Shared caches, other CLI profiles, and legacy installation-wide assets are retained.", message); + Assert.DoesNotContain("TBL\\Binaries\\Win64\\Plugins", message); + Assert.DoesNotContain("TBL\\Content\\Paks", message); + } + + public class ReadOnlyPakDir : DispatchProxy { + protected override object? Invoke(MethodInfo? method, object?[]? args) => + method?.Name == "get_DirPath" + ? Path.Combine("confirmation-only", "Saved_unchained", "Paks") + : throw new InvalidOperationException($"Unexpected pak operation: {method?.Name}"); + } + + private sealed class RejectingDialogue : IUserDialogueSpawner { + public List Messages { get; } = []; + public UserDialogueChoice DisplayYesNoMessage(string message, string caption) { + Messages.Add(message); + return UserDialogueChoice.No; + } + public void DisplayMessage(string message) => throw new NotSupportedException(); + public UserDialogueChoice? DisplayUpdateMessage(string titleText, string messageText, string yesButtonText, + string noButtonText, string? cancelButtonText, IEnumerable updates) => throw new NotSupportedException(); + public Action DisplayProgress(MemoryProgress progress) => throw new NotSupportedException(); + } +} diff --git a/UnchainedLauncher.GUI.Tests.Unit/UnchainedLauncher.GUI.Tests.Unit.csproj b/UnchainedLauncher.GUI.Tests.Unit/UnchainedLauncher.GUI.Tests.Unit.csproj index cef00436..9d98826a 100644 --- a/UnchainedLauncher.GUI.Tests.Unit/UnchainedLauncher.GUI.Tests.Unit.csproj +++ b/UnchainedLauncher.GUI.Tests.Unit/UnchainedLauncher.GUI.Tests.Unit.csproj @@ -9,6 +9,7 @@ + diff --git a/UnchainedLauncher.GUI/ViewModels/HelpVM.cs b/UnchainedLauncher.GUI/ViewModels/HelpVM.cs index c48bc049..0d326d98 100644 --- a/UnchainedLauncher.GUI/ViewModels/HelpVM.cs +++ b/UnchainedLauncher.GUI/ViewModels/HelpVM.cs @@ -81,8 +81,9 @@ private void UninstallLauncher() { var message = new List() { "Are you sure? This will disable all mods, reset all settings to their defaults, and uninstall the launcher. This will delete the following:", "* All files in .mod_cache", - "* All files in TBL\\Binaries\\Win64\\Plugins", - "* All non-vanilla paks in TBL\\Content\\Paks.", + $"* All files in {new ProfileAssetPaths(ProfileAssetPaths.SharedSuffix).Plugins}", + $"* All non-vanilla files in {PakDir.DirPath}", + "Shared caches, other CLI profiles, and legacy installation-wide assets are retained.", "", originalExists ? "IMPORTANT: Wait at least 1 second and then launch normally." @@ -248,4 +249,4 @@ await Installer.Install(new DirectoryInfo(Environment.CurrentDirectory), release } } } -} \ No newline at end of file +} diff --git a/annals/index.md b/annals/index.md deleted file mode 100644 index 859953e3..00000000 --- a/annals/index.md +++ /dev/null @@ -1,29 +0,0 @@ -# Annals keyword index - -## A - -- [actor selection](2026-10-16-rename-cli-starting-map.md) - -## C - -- [Chiv2Turbo](2026-10-16-rename-cli-starting-map.md) -- [command-line interface](2026-10-16-rename-cli-starting-map.md) -- [compatibility aliases](2026-10-16-rename-cli-starting-map.md) - -## P - -- [pinned releases](2026-10-16-rename-cli-starting-map.md) - -## S - -- [server configuration](2026-10-16-rename-cli-starting-map.md) -- [starting-map](2026-10-16-rename-cli-starting-map.md) - -## U - -- [Unchained-Mods](2026-10-16-rename-cli-starting-map.md) -- [UnchainedPlugin](2026-10-16-rename-cli-starting-map.md) - -## Y - -- [YAML](2026-10-16-rename-cli-starting-map.md) From 306d8c8308117cde9d57031462c97fcf72d37c2b Mon Sep 17 00:00:00 2001 From: Jacoby6000 <2672832+Jacoby6000@users.noreply.github.com> Date: Tue, 20 Oct 2026 02:39:25 +0000 Subject: [PATCH 45/58] [GHA] Format Source --- UnchainedLauncher.GUI/ViewModels/HelpVM.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/UnchainedLauncher.GUI/ViewModels/HelpVM.cs b/UnchainedLauncher.GUI/ViewModels/HelpVM.cs index 0d326d98..318f727f 100644 --- a/UnchainedLauncher.GUI/ViewModels/HelpVM.cs +++ b/UnchainedLauncher.GUI/ViewModels/HelpVM.cs @@ -249,4 +249,4 @@ await Installer.Install(new DirectoryInfo(Environment.CurrentDirectory), release } } } -} +} \ No newline at end of file From 86f7e58f2fc93459930c98f17057334be0f65d21 Mon Sep 17 00:00:00 2001 From: Jacob Barber Date: Mon, 19 Oct 2026 22:10:28 -0500 Subject: [PATCH 46/58] Cancel launch safely when managed pak inventory is invalid --- .../ManagedInventoryTests.cs | 57 +++++++++++++++++++ UnchainedLauncher.CLI/README.md | 5 +- .../LaunchPreparers/Chivalry2ModsInstaller.cs | 12 +++- 3 files changed, 71 insertions(+), 3 deletions(-) diff --git a/UnchainedLauncher.CLI.Tests/ManagedInventoryTests.cs b/UnchainedLauncher.CLI.Tests/ManagedInventoryTests.cs index fbbbe42d..f1817b7a 100644 --- a/UnchainedLauncher.CLI.Tests/ManagedInventoryTests.cs +++ b/UnchainedLauncher.CLI.Tests/ManagedInventoryTests.cs @@ -1,4 +1,6 @@ using UnchainedLauncher.Core.JsonModels.ModMetadata; +using UnchainedLauncher.Core.Services; +using UnchainedLauncher.Core.Services.Processes; using UnchainedLauncher.Core.Services.Mods; using UnchainedLauncher.Core.Services.Mods.Registry; using UnchainedLauncher.Core.Services.Processes.Chivalry; @@ -99,6 +101,61 @@ await InInstallation(async (registry, common, extra, launch) => { private ModManager Manager(IModRegistry registry, ReleaseCoordinates[] selected) => new(registry, new PakDir(Path.GetFullPath(FilePaths.PakDir), []), selected); + [Theory] + [InlineData(false, "Cannot read shared pak inventory.")] + [InlineData(true, "Invalid managed pak filename in inventory.")] + public async Task InvalidInventoryCancelsBeforeInstallationWithoutChangingFiles(bool invalidFilename, string diagnostic) { + await InInstallation(async (registry, common, extra, launch) => { + var owned = new ManagedPak(ReleaseCoordinates.FromRelease(common), "owned.pak", 0); + var paks = Path.GetFullPath(FilePaths.PakDir); + File.Copy(Path.Combine(_root, "payload.pak"), Path.Combine(paks, owned.PakFileName)); + File.WriteAllText(Path.Combine(paks, "owned.sig"), "owned signature"); + var manager = new ModManager(registry, new PakDir(paks, [owned]), []); + var inventoryPath = Path.Combine(manager.PakDir.DirPath, FilePaths.ManagedPakInventoryFileName); + var invalid = new ManagedPak(ReleaseCoordinates.FromRelease(common), "../outside.pak", 0); + File.WriteAllText(inventoryPath, invalidFilename + ? JsonHelpers.Serialize(new PakDirMetadata(manager.PakDir.DirPath, [invalid])) + : "{"); + var cache = Path.Combine(FilePaths.ModCachePath, "cli_mod_manager.json"); + Directory.CreateDirectory(FilePaths.ModCachePath); + File.WriteAllText(cache, "unchanged host cache"); + var before = Directory.GetFiles(_root, "*", SearchOption.AllDirectories).ToDictionary(path => path, File.ReadAllBytes); + var dialogue = new RejectingDialogue(); + var installer = new Chivalry2ModsInstaller(manager, dialogue); + Assert.True((await installer.PrepareLaunch(launch)).IsNone); + var process = new RejectingProcessLauncher(); + var launcher = new UnchainedChivalry2Launcher(installer, process, _root, new NullInjector()); + Assert.True((await launcher.Launch(launch)).IsLeft); + Assert.Equal(0, process.Calls); + Assert.Equal(2, dialogue.Messages.Count); + Assert.All(dialogue.Messages, message => { + Assert.Contains(diagnostic, message); + Assert.Contains("Launch cancelled", message); + }); + Assert.Equal(owned, Assert.Single(((PakDir)manager.PakDir).ManagedPaks)); + Assert.Equal(before.Keys.Order(), Directory.GetFiles(_root, "*", SearchOption.AllDirectories).Order()); + foreach (var (path, bytes) in before) Assert.Equal(bytes, File.ReadAllBytes(path)); + }); + } + + private sealed class RejectingDialogue : IUserDialogueSpawner { + public List Messages { get; } = []; + public void DisplayMessage(string message) => Messages.Add(message); + public UserDialogueChoice DisplayYesNoMessage(string message, string caption) => throw new InvalidOperationException("Unexpected repair prompt."); + public UserDialogueChoice? DisplayUpdateMessage(string titleText, string messageText, string yesButtonText, + string noButtonText, string? cancelButtonText, IEnumerable updates) => throw new InvalidOperationException("Unexpected update prompt."); + public Action DisplayProgress(MemoryProgress progress) => throw new InvalidOperationException("Installation must not start."); + } + + private sealed class RejectingProcessLauncher : IProcessLauncher { + public string Executable => "test-game.exe"; + public int Calls { get; private set; } + public LanguageExt.Either Launch(string workingDirectory, string args) { + Calls++; + throw new InvalidOperationException("Process launch must not occur."); + } + } + [Fact] public async Task ServerInstancesInstallDifferentPakSetsWithoutChangingBaseOrPeerFiles() { await InInstallation(async (source, common, extra, _) => { diff --git a/UnchainedLauncher.CLI/README.md b/UnchainedLauncher.CLI/README.md index cdd79c75..988b3e67 100644 --- a/UnchainedLauncher.CLI/README.md +++ b/UnchainedLauncher.CLI/README.md @@ -188,8 +188,9 @@ Separate mode-specific schemas: do not create a server dashboard, and return after launching without stopping the game. Common root options are `server-browser-backend`, `update-plugin` (default true), -`allow-unstable-plugin` (default false), `no-tui` (default false), and `game-args` -(list of strings). `server.game-args` or `client.game-args` is appended to the +`allow-unstable-plugin` (default false), and `game-args` (list of strings). +The root `no-tui` option (default false) is server-only. +`server.game-args` or `client.game-args` is appended to the common list only for the matching command. The client schema currently supports `game-args`; client installation, platform, plugin, mod, and saved-profile options remain at the root. For servers, `mod-setup` and `saved-dir-suffix` belong inside `server` or each diff --git a/UnchainedLauncher.Core/Services/Processes/Chivalry/LaunchPreparers/Chivalry2ModsInstaller.cs b/UnchainedLauncher.Core/Services/Processes/Chivalry/LaunchPreparers/Chivalry2ModsInstaller.cs index 7fb7fea7..d611e18c 100644 --- a/UnchainedLauncher.Core/Services/Processes/Chivalry/LaunchPreparers/Chivalry2ModsInstaller.cs +++ b/UnchainedLauncher.Core/Services/Processes/Chivalry/LaunchPreparers/Chivalry2ModsInstaller.cs @@ -72,7 +72,17 @@ private async Task ValidateHashes(IEnumerable releases) { } public async Task> PrepareLaunch(LaunchOptions options) { - var inventory = ManagedPakInventory.Reconcile(_modManager); + ManagedPakInventory? inventory; + try { + inventory = ManagedPakInventory.Reconcile(_modManager); + } + catch (InvalidDataException e) { + _logger.Error($"Failed to reconcile managed pak inventory: {e.Message}"); + _userDialogueSpawner.DisplayMessage( + $"Could not reconcile managed pak inventory: {e.Message}\n" + + "Launch cancelled. Resolve the pak setup errors before launching."); + return None; + } try { return await PrepareMods(options); } From 7eb19af1d93a999bf2759befacef18684631a94e Mon Sep 17 00:00:00 2001 From: Jacoby6000 <2672832+Jacoby6000@users.noreply.github.com> Date: Tue, 20 Oct 2026 03:11:56 +0000 Subject: [PATCH 47/58] [GHA] Format Source --- UnchainedLauncher.CLI.Tests/ManagedInventoryTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/UnchainedLauncher.CLI.Tests/ManagedInventoryTests.cs b/UnchainedLauncher.CLI.Tests/ManagedInventoryTests.cs index f1817b7a..29f883d0 100644 --- a/UnchainedLauncher.CLI.Tests/ManagedInventoryTests.cs +++ b/UnchainedLauncher.CLI.Tests/ManagedInventoryTests.cs @@ -1,8 +1,8 @@ using UnchainedLauncher.Core.JsonModels.ModMetadata; using UnchainedLauncher.Core.Services; -using UnchainedLauncher.Core.Services.Processes; using UnchainedLauncher.Core.Services.Mods; using UnchainedLauncher.Core.Services.Mods.Registry; +using UnchainedLauncher.Core.Services.Processes; using UnchainedLauncher.Core.Services.Processes.Chivalry; using UnchainedLauncher.Core.Services.Processes.Chivalry.LaunchPreparers; using UnchainedLauncher.Core.Utilities; From e10a833881be21485444f0f5859eedca4694edab Mon Sep 17 00:00:00 2001 From: Jacob Barber Date: Mon, 19 Oct 2026 22:43:36 -0500 Subject: [PATCH 48/58] Reject malformed managed pak inventory before reconciliation --- .../ManagedInventoryTests.cs | 43 ++++++++++++++++--- .../Services/Mods/PakDir.cs | 7 ++- 2 files changed, 42 insertions(+), 8 deletions(-) diff --git a/UnchainedLauncher.CLI.Tests/ManagedInventoryTests.cs b/UnchainedLauncher.CLI.Tests/ManagedInventoryTests.cs index 29f883d0..5b08038f 100644 --- a/UnchainedLauncher.CLI.Tests/ManagedInventoryTests.cs +++ b/UnchainedLauncher.CLI.Tests/ManagedInventoryTests.cs @@ -101,10 +101,28 @@ await InInstallation(async (registry, common, extra, launch) => { private ModManager Manager(IModRegistry registry, ReleaseCoordinates[] selected) => new(registry, new PakDir(Path.GetFullPath(FilePaths.PakDir), []), selected); + public static IEnumerable InvalidInventories() { + yield return ["{", "Cannot read shared pak inventory."]; + yield return ["null", "Cannot read shared pak inventory."]; + foreach (var json in new[] { "{}", "{\"ManagedPaks\":null}", "{\"ManagedPaks\":[null]}" }) + yield return [json, "Invalid managed pak inventory."]; + var valid = new ManagedPak(new ReleaseCoordinates("org", "mod", "v1.0.0"), "owned.pak", 0); + foreach (var field in new[] { "Coordinates", "PakFileName", "Org", "ModuleName", "Version" }) { + foreach (var missing in new[] { false, true }) { + var entry = System.Text.Json.Nodes.JsonNode.Parse(JsonHelpers.Serialize(valid))!; + var parent = field is "Org" or "ModuleName" or "Version" ? entry["Coordinates"]! : entry; + if (missing) parent.AsObject().Remove(field); + else parent[field] = null; + yield return [$"{{\"ManagedPaks\":[{entry}]}}", "Invalid managed pak inventory."]; + } + } + var invalid = valid with { PakFileName = "../outside.pak" }; + yield return [JsonHelpers.Serialize(new PakDirMetadata("unused", [invalid])), "Invalid managed pak filename in inventory."]; + } + [Theory] - [InlineData(false, "Cannot read shared pak inventory.")] - [InlineData(true, "Invalid managed pak filename in inventory.")] - public async Task InvalidInventoryCancelsBeforeInstallationWithoutChangingFiles(bool invalidFilename, string diagnostic) { + [MemberData(nameof(InvalidInventories))] + public async Task InvalidInventoryCancelsBeforeInstallationWithoutChangingFiles(string inventoryJson, string diagnostic) { await InInstallation(async (registry, common, extra, launch) => { var owned = new ManagedPak(ReleaseCoordinates.FromRelease(common), "owned.pak", 0); var paks = Path.GetFullPath(FilePaths.PakDir); @@ -112,10 +130,7 @@ await InInstallation(async (registry, common, extra, launch) => { File.WriteAllText(Path.Combine(paks, "owned.sig"), "owned signature"); var manager = new ModManager(registry, new PakDir(paks, [owned]), []); var inventoryPath = Path.Combine(manager.PakDir.DirPath, FilePaths.ManagedPakInventoryFileName); - var invalid = new ManagedPak(ReleaseCoordinates.FromRelease(common), "../outside.pak", 0); - File.WriteAllText(inventoryPath, invalidFilename - ? JsonHelpers.Serialize(new PakDirMetadata(manager.PakDir.DirPath, [invalid])) - : "{"); + File.WriteAllText(inventoryPath, inventoryJson); var cache = Path.Combine(FilePaths.ModCachePath, "cli_mod_manager.json"); Directory.CreateDirectory(FilePaths.ModCachePath); File.WriteAllText(cache, "unchanged host cache"); @@ -138,6 +153,20 @@ await InInstallation(async (registry, common, extra, launch) => { }); } + [Fact] + public async Task EmptyInventoryIsAcceptedWithoutAdoptingUnmanagedFiles() { + await InInstallation(async (registry, common, extra, launch) => { + var manager = Manager(registry, []); + var inventoryPath = Path.Combine(manager.PakDir.DirPath, FilePaths.ManagedPakInventoryFileName); + File.WriteAllText(inventoryPath, "{\"ManagedPaks\":[]}"); + var before = Directory.GetFiles(_root, "*", SearchOption.AllDirectories) + .Where(path => path != inventoryPath).ToDictionary(path => path, File.ReadAllBytes); + await Prepare(manager, launch); + AssertOnlyCommonAndUnknown(manager, common); + foreach (var (path, bytes) in before) Assert.Equal(bytes, File.ReadAllBytes(path)); + }); + } + private sealed class RejectingDialogue : IUserDialogueSpawner { public List Messages { get; } = []; public void DisplayMessage(string message) => Messages.Add(message); diff --git a/UnchainedLauncher.Core/Services/Mods/PakDir.cs b/UnchainedLauncher.Core/Services/Mods/PakDir.cs index 906293da..a4e4f343 100644 --- a/UnchainedLauncher.Core/Services/Mods/PakDir.cs +++ b/UnchainedLauncher.Core/Services/Mods/PakDir.cs @@ -34,7 +34,12 @@ public PakDir(string dirPath, IEnumerable managedPaks, string? defau } internal void ReconcileManagedPaks(IEnumerable managedPaks) { - var owned = managedPaks.ToList(); + var owned = managedPaks?.ToList(); + if (owned is null || owned.Any(p => p is null || p.PakFileName is null + || p.Coordinates is null || string.IsNullOrWhiteSpace(p.Coordinates.Org) + || string.IsNullOrWhiteSpace(p.Coordinates.ModuleName) + || string.IsNullOrWhiteSpace(p.Coordinates.Version))) + throw new InvalidDataException("Invalid managed pak inventory."); // Inventory authorizes deletion only inside this directory, never base/user paks. if (owned.Any(p => string.IsNullOrWhiteSpace(p.PakFileName) || Path.GetFileName(p.PakFileName) != p.PakFileName From 9e43de24aa9647937cec04e6a3e546978b6ac352 Mon Sep 17 00:00:00 2001 From: Jacob Barber Date: Tue, 20 Oct 2026 10:41:58 -0500 Subject: [PATCH 49/58] Share profile asset routing and compose server INI patches --- .../BufferedSectionMergeTests.cs | 27 +++++++ StructuredINI/StructuredINIWriter.cs | 14 ++++ .../NumericPakOrderingTests.cs | 28 +++++++ UnchainedLauncher.CLI.Tests/PakCacheTests.cs | 32 +++++++- .../ServerProfileTests.cs | 78 ++++++++++++++++++- UnchainedLauncher.CLI/CliLaunchOptions.cs | 5 +- UnchainedLauncher.CLI/IniOverrides.cs | 15 ++-- UnchainedLauncher.CLI/IniPatches.cs | 16 ++++ UnchainedLauncher.CLI/MapRotation.cs | 12 +-- UnchainedLauncher.CLI/ServerInstancePaths.cs | 13 ++-- UnchainedLauncher.CLI/ServerLauncher.cs | 12 +-- .../Services/Mods/PakDir.cs | 5 +- .../Services/Mods/ProfileAssetPaths.cs | 10 ++- .../ProfileModsLaunchPreparerTests.cs | 30 +++++++ UnchainedLauncher.GUI/App.xaml.cs | 4 +- .../Services/ProfileModsLaunchPreparer.cs | 7 +- 16 files changed, 265 insertions(+), 43 deletions(-) create mode 100644 StructuredINI.Tests/BufferedSectionMergeTests.cs create mode 100644 UnchainedLauncher.CLI/IniPatches.cs diff --git a/StructuredINI.Tests/BufferedSectionMergeTests.cs b/StructuredINI.Tests/BufferedSectionMergeTests.cs new file mode 100644 index 00000000..514121d3 --- /dev/null +++ b/StructuredINI.Tests/BufferedSectionMergeTests.cs @@ -0,0 +1,27 @@ +namespace StructuredINI.Tests; + +public sealed class BufferedSectionMergeTests { + [Fact] + public void MergeReplacesSelectedKeysButBufferSectionStillReplacesTheSection() { + var path = Path.Combine(Path.GetTempPath(), "ini-merge-" + Guid.NewGuid().ToString("N") + ".ini"); + try { + var writer = new StructuredINIWriter(); + writer.BufferSection("Section", [new("Default", "yes"), new(".Array", "old"), new("Scalar", "old")]); + writer.MergeSection("section", [new("!Array", "ClearArray"), new(".Array", "A"), new(".Array", "A"), new("Scalar", "new")]); + Assert.True(writer.WriteOut(path)); + var text = File.ReadAllText(path); + Assert.Contains("Default=yes", text); + Assert.DoesNotContain("old", text); + Assert.Contains("Scalar=new", text); + Assert.Equal(2, text.Split('\n').Count(line => line.TrimEnd('\r') == ".Array=A")); + File.Delete(path); + writer.BufferSection("SECTION", [new("Only", "replacement")]); + Assert.True(writer.WriteOut(path)); + text = File.ReadAllText(path); + Assert.Contains("Only=replacement", text); + Assert.DoesNotContain("Default", text); + Assert.DoesNotContain("Array", text); + } + finally { File.Delete(path); } + } +} diff --git a/StructuredINI/StructuredINIWriter.cs b/StructuredINI/StructuredINIWriter.cs index b19b832b..90c73f69 100644 --- a/StructuredINI/StructuredINIWriter.cs +++ b/StructuredINI/StructuredINIWriter.cs @@ -14,6 +14,20 @@ public void BufferSection(string sectionName, IEnumerable $"{entry.Key}={entry.Value}")); } + // Merge caller-selected keys into a pending section; later values replace only those keys. + public void MergeSection(string sectionName, IEnumerable> entries) { + var lines = entries.Select(entry => $"{entry.Key}={entry.Value}").ToList(); + if (!_bufferedSections.TryGetValue(sectionName, out var buffered)) { + _sectionOrder.Add(sectionName); + _bufferedSections[sectionName] = $"[{sectionName}]\n" + string.Join("\n", lines); + return; + } + + var section = new IniSection(sectionName, SplitLines(buffered).Skip(1).ToList()); + PatchSection(section, ExtractKeysFromLines(lines), lines); + _bufferedSections[sectionName] = $"[{sectionName}]\n" + string.Join("\n", section.BodyLines); + } + public static bool Save(string path, T t) { var writer = new StructuredINIWriter(); return writer.BufferWrite(t) && writer.WriteOut(path); diff --git a/UnchainedLauncher.CLI.Tests/NumericPakOrderingTests.cs b/UnchainedLauncher.CLI.Tests/NumericPakOrderingTests.cs index d4354be9..f416d407 100644 --- a/UnchainedLauncher.CLI.Tests/NumericPakOrderingTests.cs +++ b/UnchainedLauncher.CLI.Tests/NumericPakOrderingTests.cs @@ -100,4 +100,32 @@ public async Task DuplicateModuleNamesRetainOrganizationDisambiguation() { Assert.Equal(["one_Common_0_P.pak", "two_Common_1_P.pak"], directory.ManagedPaks.OrderBy(pak => pak.Priority).Select(pak => pak.PakFileName)); } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task DownloadUsesCollisionSafeNumericNameForPayloadAndSignature(bool cached) { + File.WriteAllText(Path.Combine(_root, "Mod_0_P.pak"), "unknown payload"); + File.WriteAllText(Path.Combine(_root, "Mod (1)_0_P.sig"), "unknown signature"); + var signature = Path.Combine(_root, "base.sig"); + File.WriteAllText(signature, "base signature"); + var bytes = Encoding.UTF8.GetBytes("selected payload"); + var hash = Convert.ToHexString(System.Security.Cryptography.SHA512.HashData(bytes)); + string? writerPath = null; + var request = new ModInstallRequest(new ReleaseCoordinates("org", "Mod", "v1.0.0"), path => { + writerPath = path; + return RightAsync(new FileWriter(path, new MemoryStream(bytes), bytes.Length)); + }, hash); + var directory = new PakDir(_root, [], signature, cached ? Path.Combine(_root, "cache") : null); + + await Install(directory, request); + + Assert.Equal("Mod (2)_0_P.pak", Assert.Single(directory.ManagedPaks).PakFileName); + var selected = Path.Combine(_root, "Mod (2)_0_P.pak"); + if (!cached) Assert.Equal(selected, writerPath); + Assert.Equal("selected payload", File.ReadAllText(selected)); + Assert.Equal("base signature", File.ReadAllText(Path.ChangeExtension(selected, ".sig"))); + Assert.Equal("unknown payload", File.ReadAllText(Path.Combine(_root, "Mod_0_P.pak"))); + Assert.Equal("unknown signature", File.ReadAllText(Path.Combine(_root, "Mod (1)_0_P.sig"))); + } } \ No newline at end of file diff --git a/UnchainedLauncher.CLI.Tests/PakCacheTests.cs b/UnchainedLauncher.CLI.Tests/PakCacheTests.cs index 0e4eb5ae..85722da9 100644 --- a/UnchainedLauncher.CLI.Tests/PakCacheTests.cs +++ b/UnchainedLauncher.CLI.Tests/PakCacheTests.cs @@ -155,7 +155,7 @@ public void ProfileAssetsAndSharedCachesUseLocalAppData() { var first = new ProfileAssetPaths("Host-one", _root); var second = new ProfileAssetPaths("Host-two", _root); Assert.Equal(Path.Combine(_root, "Chivalry 2", "Saved_Hostone", "Paks"), first.Paks); - Assert.Equal(first.Paks, new ServerInstancePaths(first.Root, first.PakCache).Paks); + Assert.Equal(first.Paks, new ServerInstancePaths(first).Paks); Assert.Equal(Path.Combine(_root, "Chivalry 2", "Saved_Hostone", "unchained", "plugins"), first.Plugins); Assert.NotEqual(first.Paks, second.Paks); Assert.Equal(first.PakCache, second.PakCache); @@ -163,4 +163,34 @@ public void ProfileAssetsAndSharedCachesUseLocalAppData() { Assert.Equal(Path.Combine(_root, "Chivalry 2", "Saved_unchained", "Paks"), new ProfileAssetPaths(ProfileAssetPaths.SharedSuffix, _root).Paks); } + + [Fact] + public async Task InstanceUsesCorePathsSignatureCacheCancellationAndExclusiveLease() { + var assets = new ProfileAssetPaths("Host-one", _root); + var instance = new ServerInstancePaths(assets); + var installation = Path.Combine(_root, "game"); + using var stop = new CancellationTokenSource(); + var paks = instance.CreatePakDir(installation, stop.Token); + Assert.Equal(assets.Plugins, instance.Plugins); + Assert.Equal(assets.PakCache, paks.CachePath); + Assert.Equal(Path.Combine(installation, FilePaths.PakDir, "pakchunk0-WindowsNoEditor.sig"), paks.DefaultSigPath); + using (instance.AcquireLease()) { + Assert.Throws(() => instance.AcquireLease()); + } + using var lease = instance.AcquireLease(); + stop.Cancel(); + var results = new List>(); + await foreach (var result in paks.InstallModSet([Request(_ => throw new Exception("Cancelled install must not download."))], None)) + results.Add(result); + Assert.True(Assert.Single(results).IsLeft); + Assert.Empty(Directory.GetFiles(assets.Paks, "*.pak")); + } + + [Theory] + [InlineData("---")] + [InlineData("../outside")] + [InlineData("bad suffix")] + [InlineData("")] + public void CoreProfileValidationRejectsUnsafeOrEmptyNormalizedSuffix(string suffix) => + Assert.Throws(() => new ProfileAssetPaths(suffix, _root)); } \ No newline at end of file diff --git a/UnchainedLauncher.CLI.Tests/ServerProfileTests.cs b/UnchainedLauncher.CLI.Tests/ServerProfileTests.cs index 5bc3eedb..730a986f 100644 --- a/UnchainedLauncher.CLI.Tests/ServerProfileTests.cs +++ b/UnchainedLauncher.CLI.Tests/ServerProfileTests.cs @@ -97,4 +97,80 @@ public void ServerPropagatesProfileWriteFailure() { File.WriteAllText(blocked, "not a directory"); Assert.ThrowsAny(() => ServerLauncher.InitializeServerProfile(Request("server"), _ => blocked)); } -} \ No newline at end of file + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void ComposedPatchesPreserveDefaultsNeighborsAndArraySemantics(bool rotation) { + Directory.CreateDirectory(_directory); + var config = Path.Combine(_directory, "launch.yaml"); + File.WriteAllText(config, """ + chiv2-path: . + platform: Steam + server: + local-ip: 127.0.0.1 + ini: + game: + /script/tbl.tbltitlescreen: + bSavedHasAgreedToTOS: false + /Script/TBL.TBLGameInstance: + Custom: {type: String, value: selected} + /Script/TBL.TBLGameMode: + ServerName: selected name + Empty: {type: 'String[]', value: []} + Repeats: {type: 'String[]', value: [A, A, B]} + RawOnly: {type: raw, value: secret} + Engine: + Custom: + Selected: {type: Int, value: 7} + """ + (rotation ? "\n map-rotation:\n - {map-name: FFA_New}\n - {map-name: FFA_New}\n" : "")); + File.WriteAllText(Path.Combine(_directory, "Game.ini"), """ + [/Script/TBL.TBLTitleScreen] + Neighbor=title + [/Script/TBL.TBLGameInstance] + Neighbor=instance + [/Script/TBL.TBLGameMode] + Neighbor=mode + .Empty=old + +Repeats=old + MapList=FFA_Old + MapListIndex=3 + """); + File.WriteAllText(Path.Combine(_directory, "Engine.ini"), "[Custom]\nNeighbor=engine\nSelected=1\n"); + File.WriteAllText(Path.Combine(_directory, "GameUserSettings.ini"), "untouched"); + var request = CliLaunchOptions.Parse(["server", "--config-path", config]); + + ServerLauncher.InitializeServerProfile(request, _ => _directory); + + var game = File.ReadAllText(Path.Combine(_directory, "Game.ini")); + Assert.Contains("bSavedHasAgreedToTOS=False", game); + Assert.DoesNotContain("bSavedHasAgreedToTOS=True", game); + Assert.Contains("FirstLoadCompleted=True", game); + Assert.Contains("Custom=selected", game); + foreach (var neighbor in new[] { "title", "instance", "mode" }) Assert.Contains("Neighbor=" + neighbor, game); + Assert.Contains("ServerName=selected name", game); + Assert.Contains("!Empty=ClearArray", game); + Assert.DoesNotContain(".Empty=", game); + Assert.Contains("!Repeats=ClearArray", game); + Assert.DoesNotContain("+Repeats=old", game); + Assert.Equal(new[] { ".Repeats=A", ".Repeats=A", ".Repeats=B" }, + game.Split('\n').Select(line => line.TrimEnd('\r')).Where(line => line.StartsWith(".Repeats="))); + Assert.DoesNotContain("RawOnly", game); + if (rotation) { + Assert.Contains("!MapList=ClearArray", game); + Assert.Equal(2, game.Split('\n').Count(line => line.TrimEnd('\r') == ".MapList=FFA_New")); + Assert.Contains("MapListIndex=0", game); + Assert.DoesNotContain("FFA_Old", game); + } + else { + Assert.Contains("MapList=FFA_Old", game); + Assert.Contains("MapListIndex=3", game); + } + var engine = File.ReadAllText(Path.Combine(_directory, "Engine.ini")); + Assert.Contains("Neighbor=engine", engine); + Assert.Contains("Selected=7", engine); + Assert.Equal("untouched", File.ReadAllText(Path.Combine(_directory, "GameUserSettings.ini"))); + ServerLauncher.InitializeServerProfile(request, _ => _directory); + Assert.Equal(game, File.ReadAllText(Path.Combine(_directory, "Game.ini"))); + } +} diff --git a/UnchainedLauncher.CLI/CliLaunchOptions.cs b/UnchainedLauncher.CLI/CliLaunchOptions.cs index c46042a5..4a800d03 100644 --- a/UnchainedLauncher.CLI/CliLaunchOptions.cs +++ b/UnchainedLauncher.CLI/CliLaunchOptions.cs @@ -62,7 +62,7 @@ public abstract class CliLaunchOptions : CliCommandOptions { private readonly SharedLaunchOptions? _shared; public SharedLaunchOptions Shared => _shared ?? throw new InvalidOperationException("Help is not a launch request."); public IReadOnlyList GameArguments { get; } - public ServerInstancePaths InstancePaths => new(Shared.Assets.Root, Shared.Assets.PakCache); + public ServerInstancePaths InstancePaths => new(Shared.Assets); private protected CliLaunchOptions(SharedLaunchOptions shared, IEnumerable gameArguments) { _shared = shared; @@ -117,7 +117,7 @@ public sealed class ServerOptions : CliLaunchOptions { public ServerSettings Settings { get; } public bool NoTui { get; } // Chivalry removes hyphens when choosing its Saved directory (the plugin log path does not). - internal string GameSavedDirSuffix => Shared.SavedDirSuffix.Replace("-", "", StringComparison.Ordinal); + internal string GameSavedDirSuffix => Shared.Assets.Suffix; internal ServerOptions(SharedLaunchOptions shared, ServerSettings settings, bool noTui) : base(shared, SharedLaunchOptions.Entries(settings.GameArgs, allowEmpty: true)) { @@ -126,7 +126,6 @@ internal ServerOptions(SharedLaunchOptions shared, ServerSettings settings, bool if (settings.Rotation is not null && Ini.Contains("Game", "/Script/TBL.TBLGameMode", "MapListIndex")) throw new ArgumentException("MapListIndex is managed by map-rotation; use starting-map to choose the first map."); NoTui = noTui; - if (GameSavedDirSuffix.Length == 0) throw new ArgumentException("saved-dir-suffix must contain a character other than a hyphen."); if (!settings.Headless && settings.DesyncPatch) throw new ArgumentException("Desync patch requires a headless server. To enable rendering, pass --no-desync-patch or set server.desync-patch: false in YAML."); if (new[] { settings.GamePort, settings.PingPort, settings.QueryPort, settings.RconPort }.Distinct().Count() != 4) diff --git a/UnchainedLauncher.CLI/IniOverrides.cs b/UnchainedLauncher.CLI/IniOverrides.cs index e188b539..a2297037 100644 --- a/UnchainedLauncher.CLI/IniOverrides.cs +++ b/UnchainedLauncher.CLI/IniOverrides.cs @@ -160,17 +160,20 @@ private static void ValidateName(string name, string kind) { } internal void Apply(string directory) { + var files = new Dictionary(StringComparer.OrdinalIgnoreCase); + BufferInto(files); + IniPatches.Write(directory, files); + } + + internal void BufferInto(Dictionary files) { foreach (var file in _entries.Where(entry => !entry.Raw).GroupBy(entry => entry.File, StringComparer.OrdinalIgnoreCase)) { - var writer = new StructuredINIWriter(); + if (!files.TryGetValue(file.Key, out var writer)) files[file.Key] = writer = new StructuredINIWriter(); foreach (var section in file.GroupBy(entry => entry.Section, StringComparer.OrdinalIgnoreCase)) { var lines = section.SelectMany(entry => entry.Array - ? new[] { KeyValuePair.Create("!" + entry.Key, "ClearArray") } - .Concat(entry.Values.Select(value => KeyValuePair.Create("." + entry.Key, value))) + ? IniPatches.Array(entry.Key, entry.Values) : [KeyValuePair.Create(entry.Key, entry.Values[0])]); - writer.BufferSection(section.Key, lines); + writer.MergeSection(section.Key, lines); } - if (!writer.WriteOut(Path.Combine(directory, file.Key + ".ini"))) - throw new IOException("Could not apply server INI overrides; refusing to launch."); } } } \ No newline at end of file diff --git a/UnchainedLauncher.CLI/IniPatches.cs b/UnchainedLauncher.CLI/IniPatches.cs new file mode 100644 index 00000000..5495afae --- /dev/null +++ b/UnchainedLauncher.CLI/IniPatches.cs @@ -0,0 +1,16 @@ +using StructuredINI; + +namespace UnchainedLauncher.CLI; + +internal static class IniPatches { + internal static IEnumerable> Array(string key, IEnumerable values) => + new[] { KeyValuePair.Create("!" + key, "ClearArray") } + .Concat(values.Select(value => KeyValuePair.Create("." + key, value))); + + internal static void Write(string directory, Dictionary files) { + foreach (var (file, writer) in files) { + if (!writer.WriteOut(Path.Combine(directory, file + ".ini"))) + throw new IOException("Could not apply server INI profile; refusing to launch."); + } + } +} diff --git a/UnchainedLauncher.CLI/MapRotation.cs b/UnchainedLauncher.CLI/MapRotation.cs index e978d99a..050a2e89 100644 --- a/UnchainedLauncher.CLI/MapRotation.cs +++ b/UnchainedLauncher.CLI/MapRotation.cs @@ -59,11 +59,13 @@ internal static void ValidateMap(MapProfile? profile, string location) { internal void Apply(string directory) { var writer = new StructuredINIWriter(); - var values = new[] { KeyValuePair.Create("!MapList", "ClearArray") } - .Concat(Maps.Select(map => KeyValuePair.Create(".MapList", map))) + BufferInto(writer); + IniPatches.Write(directory, new(StringComparer.OrdinalIgnoreCase) { ["Game"] = writer }); + } + + internal void BufferInto(StructuredINIWriter writer) { + var values = IniPatches.Array("MapList", Maps) .Append(KeyValuePair.Create("MapListIndex", StartIndex.ToString(CultureInfo.InvariantCulture))); - writer.BufferSection("/Script/TBL.TBLGameMode", values); - if (!writer.WriteOut(Path.Combine(directory, "Game.ini"))) - throw new IOException("Could not apply map-rotation to the server profile; refusing to launch."); + writer.MergeSection("/Script/TBL.TBLGameMode", values); } } \ No newline at end of file diff --git a/UnchainedLauncher.CLI/ServerInstancePaths.cs b/UnchainedLauncher.CLI/ServerInstancePaths.cs index 9940dd5c..1cd9a721 100644 --- a/UnchainedLauncher.CLI/ServerInstancePaths.cs +++ b/UnchainedLauncher.CLI/ServerInstancePaths.cs @@ -2,9 +2,10 @@ namespace UnchainedLauncher.CLI; -public sealed record ServerInstancePaths(string Root, string? PakCache = null) { - public string Paks => Path.Combine(Path.GetDirectoryName(Root)!, "Paks"); - public string Plugins => Path.Combine(Root, "plugins"); +public sealed record ServerInstancePaths(ProfileAssetPaths Assets) { + public string Root => Assets.Root; + public string Paks => Assets.Paks; + public string Plugins => Assets.Plugins; public string ModMetadata => Path.Combine(Root, "cli_mod_manager.json"); internal IDisposable AcquireLease() { @@ -17,8 +18,6 @@ internal IDisposable AcquireLease() { } } - internal PakDir CreatePakDir(string basePakDirectory, CancellationToken cancellationToken = default) { - Directory.CreateDirectory(Paks); - return new PakDir(Paks, [], Path.Combine(basePakDirectory, "pakchunk0-WindowsNoEditor.sig"), PakCache, cancellationToken); - } + internal PakDir CreatePakDir(string installation, CancellationToken cancellationToken = default) => + Assets.CreatePakDir(installation, cancellationToken: cancellationToken); } \ No newline at end of file diff --git a/UnchainedLauncher.CLI/ServerLauncher.cs b/UnchainedLauncher.CLI/ServerLauncher.cs index 9294121b..93a4c417 100644 --- a/UnchainedLauncher.CLI/ServerLauncher.cs +++ b/UnchainedLauncher.CLI/ServerLauncher.cs @@ -90,8 +90,7 @@ internal static AggregateModRegistry CreateRegistry(CliLaunchOptions options) => options.Shared.RegistryOptions.CreateRegistry(); internal static ModManager CreateModManager(CliLaunchOptions request, AggregateModRegistry registry, CancellationToken cancellationToken = default) { - var basePakPath = Path.Combine(request.Shared.Chivalry2Path, FilePaths.PakDir); - return new ModManager(registry, request.InstancePaths.CreatePakDir(basePakPath, cancellationToken), request.Shared.Mods.Distinct()); + return new ModManager(registry, request.InstancePaths.CreatePakDir(request.Shared.Chivalry2Path, cancellationToken), request.Shared.Mods.Distinct()); } internal static DllLaunchSession CreateDllSession(CliLaunchOptions request, AggregateModRegistry registry, @@ -105,11 +104,12 @@ internal static void InitializeServerProfile(CliLaunchOptions request, Func(StringComparer.OrdinalIgnoreCase) { ["Game"] = writer }; + server.Ini.BufferInto(files); + server.Settings.Rotation?.BufferInto(writer); + IniPatches.Write(directory, files); } // Several existing Core preparers use installation-relative paths. diff --git a/UnchainedLauncher.Core/Services/Mods/PakDir.cs b/UnchainedLauncher.Core/Services/Mods/PakDir.cs index a4e4f343..3b3dce73 100644 --- a/UnchainedLauncher.Core/Services/Mods/PakDir.cs +++ b/UnchainedLauncher.Core/Services/Mods/PakDir.cs @@ -131,8 +131,7 @@ private string AvailablePakName(string suggestedFileName) { return fullActualName; } - private EitherAsync _writePak(IPakDir.MakeFileWriter mkFileWriter, Option> progress, string suggestedFileName) { - var fullActualName = AvailablePakName(suggestedFileName); + private EitherAsync _writePak(IPakDir.MakeFileWriter mkFileWriter, Option> progress, string fullActualName) { Logger.Info($"Downloading pak to {fullActualName}"); return mkFileWriter(PakNameToPakPath(fullActualName)) .Bind(fileWriter => @@ -309,7 +308,7 @@ private async Task> ProcessDownload(InstallAction.Down var name = AvailablePakName(download.FinalPakName); var progress = taskProgress.Map(p => (IProgress)p); var written = CachePath is null - ? await _writePak(download.Install.Writer, progress, download.FinalPakName) + ? await _writePak(download.Install.Writer, progress, name) : (await new PakCache(CachePath).Install(download.Install, PakNameToPakPath(name), progress, _cancellationToken)).Map(_ => name); return written .Map(pakFileName => { diff --git a/UnchainedLauncher.Core/Services/Mods/ProfileAssetPaths.cs b/UnchainedLauncher.Core/Services/Mods/ProfileAssetPaths.cs index 3f19dc28..b3bb1cc4 100644 --- a/UnchainedLauncher.Core/Services/Mods/ProfileAssetPaths.cs +++ b/UnchainedLauncher.Core/Services/Mods/ProfileAssetPaths.cs @@ -22,8 +22,14 @@ public ProfileAssetPaths(string suffix, string? localAppData = null) { DllCache = Path.Combine(game, "unchained", "cache", "dlls"); } - public PakDir CreatePakDir(string installation, IEnumerable? managedPaks = null) { + public ModManager Rebind(ModManager manager, string installation) { + var sameProfile = Path.GetFullPath(manager.PakDir.DirPath).Equals(Path.GetFullPath(Paks), StringComparison.OrdinalIgnoreCase); + return manager.WithPakDir(CreatePakDir(installation, + sameProfile && manager.PakDir is PakDir existing ? existing.ManagedPaks : [])); + } + + public PakDir CreatePakDir(string installation, IEnumerable? managedPaks = null, CancellationToken cancellationToken = default) { Directory.CreateDirectory(Paks); - return new PakDir(Paks, managedPaks ?? [], Path.Combine(installation, FilePaths.PakDir, "pakchunk0-WindowsNoEditor.sig"), PakCache); + return new PakDir(Paks, managedPaks ?? [], Path.Combine(installation, FilePaths.PakDir, "pakchunk0-WindowsNoEditor.sig"), PakCache, cancellationToken); } } \ No newline at end of file diff --git a/UnchainedLauncher.GUI.Tests.Unit/ProfileModsLaunchPreparerTests.cs b/UnchainedLauncher.GUI.Tests.Unit/ProfileModsLaunchPreparerTests.cs index 165cd57e..72d21e67 100644 --- a/UnchainedLauncher.GUI.Tests.Unit/ProfileModsLaunchPreparerTests.cs +++ b/UnchainedLauncher.GUI.Tests.Unit/ProfileModsLaunchPreparerTests.cs @@ -15,6 +15,36 @@ namespace UnchainedLauncher.GUI.Tests.Unit; public sealed class ProfileModsLaunchPreparerTests { + [Theory] + [InlineData(true)] + [InlineData(false)] + public void RebindingCopiesOwnershipOnlyWithinTheSameProfileAndSnapshotsSelection(bool sameProfile) { + var root = Path.Combine(Path.GetTempPath(), "gui-rebind-" + Guid.NewGuid().ToString("N")); + try { + var source = new ProfileAssetPaths("Source", root); + var target = sameProfile ? new ProfileAssetPaths("Source", root) : new ProfileAssetPaths("Target", root); + Directory.CreateDirectory(source.Paks); + Directory.CreateDirectory(target.Paks); + var coordinates = new ReleaseCoordinates("org", "mod", "v1.0.0"); + var owned = new ManagedPak(coordinates, "mod_0_P.pak", 0); + File.WriteAllText(Path.Combine(source.Paks, owned.PakFileName), "source"); + if (!sameProfile) File.WriteAllText(Path.Combine(target.Paks, owned.PakFileName), "unknown target payload"); + var manager = new ModManager(new LocalModRegistry(Path.Combine(root, "registry")), + source.CreatePakDir(root, [owned]), [coordinates]); + var rebound = target.Rebind(manager, root); + Assert.NotSame(manager, rebound); + Assert.NotSame(manager.PakDir, rebound.PakDir); + Assert.Same(manager.ModRegistry, rebound.ModRegistry); + Assert.Equal(new[] { coordinates }, rebound.EnabledModReleaseCoordinates); + Assert.Equal(sameProfile ? new[] { owned } : [], Assert.IsType(rebound.PakDir).ManagedPaks); + Assert.Single(Assert.IsType(manager.PakDir).ManagedPaks); + Assert.IsType>(manager.EnabledModReleaseCoordinates).Clear(); + Assert.Single(rebound.EnabledModReleaseCoordinates); + if (!sameProfile) Assert.Equal("unknown target payload", File.ReadAllText(Path.Combine(target.Paks, owned.PakFileName))); + } + finally { if (Directory.Exists(root)) Directory.Delete(root, true); } + } + [Theory] [InlineData(null, "unchained")] [InlineData("Courtyard", "Courtyard")] diff --git a/UnchainedLauncher.GUI/App.xaml.cs b/UnchainedLauncher.GUI/App.xaml.cs index a6c81052..a96b887b 100644 --- a/UnchainedLauncher.GUI/App.xaml.cs +++ b/UnchainedLauncher.GUI/App.xaml.cs @@ -323,9 +323,7 @@ private ModManager InitializeModManager(string jsonPath, IModRegistry registry) var codec = new ModManagerCodec(registry); var modManager = InitializeFromFileWithCodec(codec, jsonPath, initializeDefaultModManager); - var sameProfile = Path.GetFullPath(modManager.PakDir.DirPath).Equals(Path.GetFullPath(assets.Paks), StringComparison.OrdinalIgnoreCase); - modManager = modManager.WithPakDir(assets.CreatePakDir(Directory.GetCurrentDirectory(), - sameProfile && modManager.PakDir is PakDir existing ? existing.ManagedPaks : [])); + modManager = assets.Rebind(modManager, Directory.GetCurrentDirectory()); RegisterSaveToFileOnExit(modManager, codec, jsonPath); return modManager; diff --git a/UnchainedLauncher.GUI/Services/ProfileModsLaunchPreparer.cs b/UnchainedLauncher.GUI/Services/ProfileModsLaunchPreparer.cs index 533576c8..9016d290 100644 --- a/UnchainedLauncher.GUI/Services/ProfileModsLaunchPreparer.cs +++ b/UnchainedLauncher.GUI/Services/ProfileModsLaunchPreparer.cs @@ -1,6 +1,4 @@ using LanguageExt; -using System; -using System.IO; using System.Threading.Tasks; using UnchainedLauncher.Core.Services; using UnchainedLauncher.Core.Services.Mods; @@ -14,10 +12,7 @@ public sealed class ProfileModsLaunchPreparer( : IChivalry2LaunchPreparer { public Task> PrepareLaunch(LaunchOptions options) { var assets = new ProfileAssetPaths(options.SavedDirSuffix.IfNone(ProfileAssetPaths.SharedSuffix), localAppData); - var sameProfile = Path.GetFullPath(manager.PakDir.DirPath).Equals( - Path.GetFullPath(assets.Paks), StringComparison.OrdinalIgnoreCase); - var profileManager = manager.WithPakDir(assets.CreatePakDir(installation, - sameProfile && manager.PakDir is PakDir existing ? existing.ManagedPaks : [])); + var profileManager = assets.Rebind(manager, installation); // DLL selection and injection stay bound to the shared manager and plugin directory. IChivalry2LaunchPreparer installer = new Chivalry2ModsInstaller(profileManager, dialogue); return installer From f3595a44141af8e00b8ca56d19d2ecee90e92236 Mon Sep 17 00:00:00 2001 From: Jacoby6000 <2672832+Jacoby6000@users.noreply.github.com> Date: Tue, 20 Oct 2026 15:43:33 +0000 Subject: [PATCH 50/58] [GHA] Format Source --- StructuredINI.Tests/BufferedSectionMergeTests.cs | 2 +- UnchainedLauncher.CLI.Tests/ServerProfileTests.cs | 2 +- UnchainedLauncher.CLI/IniPatches.cs | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/StructuredINI.Tests/BufferedSectionMergeTests.cs b/StructuredINI.Tests/BufferedSectionMergeTests.cs index 514121d3..76c92a48 100644 --- a/StructuredINI.Tests/BufferedSectionMergeTests.cs +++ b/StructuredINI.Tests/BufferedSectionMergeTests.cs @@ -24,4 +24,4 @@ public void MergeReplacesSelectedKeysButBufferSectionStillReplacesTheSection() { } finally { File.Delete(path); } } -} +} \ No newline at end of file diff --git a/UnchainedLauncher.CLI.Tests/ServerProfileTests.cs b/UnchainedLauncher.CLI.Tests/ServerProfileTests.cs index 730a986f..38eaa64e 100644 --- a/UnchainedLauncher.CLI.Tests/ServerProfileTests.cs +++ b/UnchainedLauncher.CLI.Tests/ServerProfileTests.cs @@ -173,4 +173,4 @@ public void ComposedPatchesPreserveDefaultsNeighborsAndArraySemantics(bool rotat ServerLauncher.InitializeServerProfile(request, _ => _directory); Assert.Equal(game, File.ReadAllText(Path.Combine(_directory, "Game.ini"))); } -} +} \ No newline at end of file diff --git a/UnchainedLauncher.CLI/IniPatches.cs b/UnchainedLauncher.CLI/IniPatches.cs index 5495afae..6f33996a 100644 --- a/UnchainedLauncher.CLI/IniPatches.cs +++ b/UnchainedLauncher.CLI/IniPatches.cs @@ -13,4 +13,4 @@ internal static void Write(string directory, Dictionary Date: Tue, 20 Oct 2026 11:24:42 -0500 Subject: [PATCH 51/58] Validate IPv4 server addresses and clarify DLL download policy --- .../DllManagerTests.cs | 53 ++++++++++++++++++- .../LocalAddressDetectorTests.cs | 20 +++++++ .../YamlConfigurationTests.cs | 35 ++++++++++++ UnchainedLauncher.CLI/CliLaunchOptions.cs | 4 +- UnchainedLauncher.CLI/CommandLineOptions.cs | 2 +- UnchainedLauncher.CLI/README.md | 9 +++- UnchainedLauncher.CLI/ServerLauncher.cs | 1 - 7 files changed, 117 insertions(+), 7 deletions(-) diff --git a/UnchainedLauncher.CLI.Tests/DllManagerTests.cs b/UnchainedLauncher.CLI.Tests/DllManagerTests.cs index 203bfa33..3f2c5de1 100644 --- a/UnchainedLauncher.CLI.Tests/DllManagerTests.cs +++ b/UnchainedLauncher.CLI.Tests/DllManagerTests.cs @@ -320,8 +320,57 @@ public async Task DllPreparationUsesOnlyEachProfilePluginDirectory(string comman } private sealed class DownloadHandler(byte[] bytes) : HttpMessageHandler { - protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) => - Task.FromResult(new HttpResponseMessage(System.Net.HttpStatusCode.OK) { RequestMessage = request, Content = new ByteArrayContent(bytes) }); + public int Requests { get; private set; } + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { + Requests++; + return Task.FromResult(new HttpResponseMessage(System.Net.HttpStatusCode.OK) { RequestMessage = request, Content = new ByteArrayContent(bytes) }); + } + } + + [Theory] + [InlineData("client")] + [InlineData("server")] + public async Task NoPluginUpdateStillSelectsLatestUnlessExplicitlyPinned(string command) { + var root = Path.Combine(Path.GetTempPath(), "dll-no-update-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); + try { + var older = Release("Chiv2-Community/UnchainedPlugin") with { DllFileName = "UnchainedPlugin.dll" }; + var newer = older with { Tag = "v2.0.0", Hash = Convert.ToHexString(SHA256.HashData(new byte[] { 4, 5, 6 })).ToLowerInvariant() }; + var packagePath = Path.Combine(root, "registry", "dll_db", "packages", "Chiv2-Community"); + Directory.CreateDirectory(packagePath); + File.WriteAllText(Path.Combine(packagePath, "UnchainedPlugin.json"), JsonHelpers.Serialize(Package(older, newer))); + var registry = new AggregateModRegistry(new LocalModRegistry(Path.Combine(root, "registry"))); + var mods = new ModManager(registry, new PakDir(Path.Combine(root, "paks"), []), []); + CliLaunchOptions Parse(params string[] extra) { + var options = CliLaunchOptions.Parse([command, "--chivalry2-path", root, "--platform", "Steam", .. extra]); + options.Shared.Assets = new ProfileAssetPaths(options.Shared.SavedDirSuffix, root); + return options; + } + string[] pin = ["--dll", "Chiv2-Community/UnchainedPlugin:v1.0.0"]; + var initial = Parse(pin); + var downloads = new DownloadHandler([1, 2, 3]); + using var client = new HttpClient(downloads); + Assert.True((await ServerLauncher.CreateDllSession(initial, registry, mods, client: client) + .PrepareLaunch(initial.ToLaunchOptions())).IsSome); + var installed = Path.Combine(initial.InstancePaths.Plugins, "Managed", "Chiv2-Community", "UnchainedPlugin", older.DllFileName); + Assert.True(await DllManager.MatchesFileAsync(installed, older)); + + var unpinned = Parse("--no-plugin-update"); + Assert.False(unpinned.ToLaunchOptions().CheckForDependencyUpdates); + var error = await Assert.ThrowsAsync(() => + ServerLauncher.CreateDllSession(unpinned, registry, mods, client: client).PrepareLaunch(unpinned.ToLaunchOptions())); + Assert.Contains("Automatic plugin downloads are disabled; missing registered DLL", error.Message); + Assert.Contains("UnchainedPlugin:2.0.0", error.Message); + Assert.True(await DllManager.MatchesFileAsync(installed, older)); + + File.Delete(installed); + var pinned = Parse(["--no-plugin-update", .. pin]); + Assert.True((await ServerLauncher.CreateDllSession(pinned, registry, mods, client: client) + .PrepareLaunch(pinned.ToLaunchOptions())).IsSome); + Assert.True(await DllManager.MatchesFileAsync(installed, older)); + Assert.Equal(1, downloads.Requests); + } + finally { Directory.Delete(root, recursive: true); } } [Fact] diff --git a/UnchainedLauncher.CLI.Tests/LocalAddressDetectorTests.cs b/UnchainedLauncher.CLI.Tests/LocalAddressDetectorTests.cs index b20bb4ee..2172de70 100644 --- a/UnchainedLauncher.CLI.Tests/LocalAddressDetectorTests.cs +++ b/UnchainedLauncher.CLI.Tests/LocalAddressDetectorTests.cs @@ -1,9 +1,29 @@ using System.Net; +using System.Net.Sockets; using Xunit; namespace UnchainedLauncher.CLI.Tests; public class LocalAddressDetectorTests { + [Theory] + [InlineData("127.0.0.1")] + [InlineData("0.0.0.0")] + public async Task QueryUsesIpv4LoopbackForLocalAndWildcardAddresses(string address) { + using var listener = new UdpClient(new IPEndPoint(IPAddress.Loopback, 0)); + var port = ((IPEndPoint)listener.Client.LocalEndPoint!).Port; + var options = Assert.IsType(CliLaunchOptions.Parse([ + "server", "--chivalry2-path", ".", "--platform", "Steam", "--local-ip", address, "--query-port", port.ToString() + ])); + var query = ServerLauncher.CreateQuery(options).InfoAsync(); + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + var request = await listener.ReceiveAsync(timeout.Token); + Assert.Equal(AddressFamily.InterNetwork, request.RemoteEndPoint.AddressFamily); + byte[] response = [255, 255, 255, 255, 73, 17, + .. "Test\0Map\0Folder\0Game\0"u8.ToArray(), 0, 0, 1, 32, 0, (byte)'D', (byte)'W', 0, 1]; + await listener.SendAsync(response, request.RemoteEndPoint, timeout.Token); + Assert.Equal("Test", (await query).Name); + } + [Fact] public void PrefersUsableDefaultGatewayAddressOverOtherInterfaces() { var candidates = new[] { diff --git a/UnchainedLauncher.CLI.Tests/YamlConfigurationTests.cs b/UnchainedLauncher.CLI.Tests/YamlConfigurationTests.cs index 4247ed88..fa7080cf 100644 --- a/UnchainedLauncher.CLI.Tests/YamlConfigurationTests.cs +++ b/UnchainedLauncher.CLI.Tests/YamlConfigurationTests.cs @@ -1,4 +1,6 @@ using System.Diagnostics; +using System.Net; +using System.Net.Sockets; using UnchainedLauncher.Core.JsonModels; using UnchainedLauncher.Core.Services.Mods.Registry; using Xunit; @@ -22,6 +24,39 @@ private string Write(string body, bool includeRequired = true) { private CliLaunchOptions Load(string body, bool client = false) => CliLaunchOptions.Parse([client ? "client" : "server", "--config-path", Write(body)]); + [Theory] + [InlineData(false, "::1")] + [InlineData(true, "::1")] + [InlineData(false, "::")] + [InlineData(true, "::")] + [InlineData(false, "::ffff:127.0.0.1")] + [InlineData(true, "::ffff:127.0.0.1")] + public void ServerRejectsIpv6DuringParsing(bool yaml, string address) { + var error = Assert.Throws(() => yaml + ? Load($"server: {{local-ip: '{address}'}}") + : CliLaunchOptions.Parse(["server", "--chivalry2-path", ".", "--platform", "Steam", "--local-ip", address])); + Assert.Contains("local-ip must be an IPv4 address", error.Message); + } + + [Theory] + [InlineData(false, "127.0.0.1")] + [InlineData(true, "127.0.0.1")] + [InlineData(false, "192.168.50.10")] + [InlineData(true, "192.168.50.10")] + [InlineData(false, "0.0.0.0")] + [InlineData(true, "0.0.0.0")] + [InlineData(false, null)] + [InlineData(true, null)] + public void ServerAcceptsIpv4OverridesAndAutoDetection(bool yaml, string? address) { + var options = Assert.IsType(yaml + ? Load(address is null ? "server: {}" : $"server: {{local-ip: '{address}'}}") + : CliLaunchOptions.Parse(["server", "--chivalry2-path", ".", "--platform", "Steam", + .. address is null ? Array.Empty() : ["--local-ip", address]])); + Assert.Equal(AddressFamily.InterNetwork, IPAddress.Parse(options.Settings.LocalIp!).AddressFamily); + if (address is not null) Assert.Equal(address, options.Settings.LocalIp); + Assert.Equal(options.Settings.LocalIp, options.ToServerLaunchOptions().LocalIp.IfNone("")); + } + [Fact] public void ConfigurationIsAuthoritativeWithEitherConfigPathSyntax() { var path = Write("server:\n name: From YAML\n"); diff --git a/UnchainedLauncher.CLI/CliLaunchOptions.cs b/UnchainedLauncher.CLI/CliLaunchOptions.cs index 4a800d03..bdfb3cba 100644 --- a/UnchainedLauncher.CLI/CliLaunchOptions.cs +++ b/UnchainedLauncher.CLI/CliLaunchOptions.cs @@ -1,4 +1,5 @@ using System.Net; +using System.Net.Sockets; using System.Text.RegularExpressions; using UnchainedLauncher.Core.JsonModels; using UnchainedLauncher.Core.Services.Mods; @@ -131,7 +132,8 @@ internal ServerOptions(SharedLaunchOptions shared, ServerSettings settings, bool if (new[] { settings.GamePort, settings.PingPort, settings.QueryPort, settings.RconPort }.Distinct().Count() != 4) throw new ArgumentException("Game, ping, query, and RCON ports must be distinct."); settings.LocalIp = string.IsNullOrWhiteSpace(settings.LocalIp) ? LocalAddressDetector.Detect().ToString() : settings.LocalIp.Trim(); - if (!IPAddress.TryParse(settings.LocalIp, out _)) throw new ArgumentException("local-ip must be an IP address."); + if (!IPAddress.TryParse(settings.LocalIp, out var address) || address.AddressFamily != AddressFamily.InterNetwork) + throw new ArgumentException("local-ip must be an IPv4 address."); settings.ModActors = SharedLaunchOptions.Entries(settings.ModActors).Distinct().ToList(); ToDiscordOptions(); } diff --git a/UnchainedLauncher.CLI/CommandLineOptions.cs b/UnchainedLauncher.CLI/CommandLineOptions.cs index 39c4fdd8..77009710 100644 --- a/UnchainedLauncher.CLI/CommandLineOptions.cs +++ b/UnchainedLauncher.CLI/CommandLineOptions.cs @@ -111,7 +111,7 @@ public Definition(bool client) { else serverSettings!.SavedDirSuffix = value; }); Add("--server-browser-backend", "HTTP(S) server-browser backend URL.", _configuration.ServerBrowserBackend, value => _configuration.ServerBrowserBackend = value); - Flag("--no-plugin-update", "Use the existing UnchainedPlugin.dll.", value => _configuration.UpdatePlugin = !value); + Flag("--no-plugin-update", "Disable DLL downloads; required registered versions must be installed or cached (use --dll to pin).", value => _configuration.UpdatePlugin = !value); Flag("--allow-unstable-plugin", "Allow prerelease plugin updates.", value => _configuration.AllowUnstablePlugin = value); var setup = new ModSetupConfiguration(); if (clientConfig is not null) clientConfig.ModSetup = setup; diff --git a/UnchainedLauncher.CLI/README.md b/UnchainedLauncher.CLI/README.md index 988b3e67..146a1c78 100644 --- a/UnchainedLauncher.CLI/README.md +++ b/UnchainedLauncher.CLI/README.md @@ -39,8 +39,11 @@ The server is headless by default (`-nullrhi -unattended -nosound`); use `--headless false --no-desync-patch` to render it (the desync patch only supports headless servers). The plugin is installed or updated from the latest stable UnchainedPlugin release by default. Use -`--no-plugin-update` to keep your installed `UnchainedPlugin.dll`; it must already -exist. `--allow-unstable-plugin` permits prerelease plugin updates. +`--no-plugin-update` to disable DLL downloads, not version selection; all required +registered DLL versions must already be installed or cached. To retain a desired +registered plugin version, pin its exact tag with +`--dll Chiv2-Community/UnchainedPlugin:`. +`--allow-unstable-plugin` permits automatic prerelease selection. For deployment without a .NET runtime: @@ -497,6 +500,8 @@ not loaded in safe mode. CLI selections never overwrite the GUI plugin profile. Safe injection uses only that launch's verified allowlist, not the directory or the saved inventory. The unsafe load-all option retains other DLLs for injection. `--no-plugin-update` forbids downloads but permits restoring cached versions. +It does not retain the installed version; pin the desired registered tag with +`--dll Chiv2-Community/UnchainedPlugin:` and install or cache every required DLL. Only explicitly pinned prereleases (or `--allow-unstable-plugin` for automatic selection) are eligible; automatic selection otherwise uses the latest stable. diff --git a/UnchainedLauncher.CLI/ServerLauncher.cs b/UnchainedLauncher.CLI/ServerLauncher.cs index 93a4c417..cb8553b4 100644 --- a/UnchainedLauncher.CLI/ServerLauncher.cs +++ b/UnchainedLauncher.CLI/ServerLauncher.cs @@ -152,7 +152,6 @@ public static void ValidateReleases(IModManager manager, IEnumerable Date: Tue, 20 Oct 2026 14:52:32 -0500 Subject: [PATCH 52/58] Serialize profile preparation and validate shared asset boundaries --- .../DllCachePublicationTests.cs | 70 +++++++++++ .../RegistryAndReleaseTests.cs | 46 +++++++ UnchainedLauncher.CLI/RegistrySpec.cs | 8 +- UnchainedLauncher.CLI/ServerInstancePaths.cs | 10 +- UnchainedLauncher.CLI/ServerLauncher.cs | 10 +- .../Services/Dlls/DllManager.cs | 6 +- .../Services/Mods/ProfileAssetPaths.cs | 10 ++ .../ProfileModsLaunchPreparerTests.cs | 118 +++++++++++++++++- .../Services/ProfileModsLaunchPreparer.cs | 33 +++-- 9 files changed, 286 insertions(+), 25 deletions(-) create mode 100644 UnchainedLauncher.CLI.Tests/DllCachePublicationTests.cs diff --git a/UnchainedLauncher.CLI.Tests/DllCachePublicationTests.cs b/UnchainedLauncher.CLI.Tests/DllCachePublicationTests.cs new file mode 100644 index 00000000..d3bdaae3 --- /dev/null +++ b/UnchainedLauncher.CLI.Tests/DllCachePublicationTests.cs @@ -0,0 +1,70 @@ +using System.Net; +using System.Reflection; +using System.Security.Cryptography; +using UnchainedLauncher.Core.Services.Dlls; +using Xunit; + +namespace UnchainedLauncher.CLI.Tests; + +public sealed class DllCachePublicationTests { + [Theory] + [InlineData("valid")] + [InlineData("size")] + [InlineData("hash")] + [InlineData("directory")] + public async Task DownloadValidatesConcurrentCacheWinnerWithoutOverwritingIt(string winnerKind) { + var root = Path.Combine(Environment.GetEnvironmentVariable("TMPDIR") ?? + (OperatingSystem.IsLinux() ? "/tmp/opencode" : Path.GetTempPath()), "dll-publication-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); + byte[] bytes = [1, 2, 3]; + var release = new DllRelease("org", "Plugin", "1.0.0", "https://example.test/Plugin.dll", + Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant(), bytes.Length); + var started = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var finish = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + try { + using var http = new HttpClient(new PausedDownload(bytes, started, finish)); + var cacheRoot = Path.Combine(root, "cache"); + var plugins = Path.Combine(root, "Plugins"); + var manager = DllManager.CreateForLaunch(plugins, [release], [release], http, () => [], cacheRoot); + var preparation = manager.PrepareAsync(); + await started.Task.WaitAsync(TimeSpan.FromSeconds(10)); + var source = Path.Combine(root, "source.dll"); + File.WriteAllBytes(source, bytes); + var cache = Path.Combine(cacheRoot, release.Sha256, release.EffectiveFileName); + // Exercise the real portable publisher; native archival uses this publication boundary on Windows. + var copy = typeof(DllManager).GetMethod("CopyValidated", BindingFlags.NonPublic | BindingFlags.Static)!; + copy.Invoke(null, [source, cache, release, false, null, null, true]); + if (winnerKind == "size") File.WriteAllBytes(cache, [9]); + if (winnerKind == "hash") File.WriteAllBytes(cache, [9, 8, 7]); + if (winnerKind == "directory") { File.Delete(cache); Directory.CreateDirectory(cache); } + var winner = winnerKind == "directory" ? null : File.ReadAllBytes(cache); + finish.SetResult(); + if (winnerKind == "valid") { + await preparation.WaitAsync(TimeSpan.FromSeconds(10)); + Assert.Equal(bytes, File.ReadAllBytes(Assert.Single(Directory.GetFiles(plugins, "*.dll", SearchOption.AllDirectories)))); + } + else { + if (winnerKind == "directory") { + var error = await Assert.ThrowsAnyAsync(() => preparation.WaitAsync(TimeSpan.FromSeconds(10))); + Assert.True(error is IOException or UnauthorizedAccessException, error.ToString()); + } + else + await Assert.ThrowsAsync(() => preparation.WaitAsync(TimeSpan.FromSeconds(10))); + Assert.False(Directory.Exists(plugins)); + } + if (winnerKind == "directory") Assert.True(Directory.Exists(cache)); + else Assert.Equal(winner, File.ReadAllBytes(cache)); + Assert.Equal(bytes, File.ReadAllBytes(source)); + Assert.Empty(Directory.GetFiles(root, "*.tmp", SearchOption.AllDirectories)); + } + finally { finish.TrySetResult(); Directory.Delete(root, true); } + } + + private sealed class PausedDownload(byte[] bytes, TaskCompletionSource started, TaskCompletionSource finish) : HttpMessageHandler { + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { + started.SetResult(); + await finish.Task.WaitAsync(cancellationToken); + return new HttpResponseMessage(HttpStatusCode.OK) { RequestMessage = request, Content = new ByteArrayContent(bytes) }; + } + } +} diff --git a/UnchainedLauncher.CLI.Tests/RegistryAndReleaseTests.cs b/UnchainedLauncher.CLI.Tests/RegistryAndReleaseTests.cs index 6afd5297..e790c683 100644 --- a/UnchainedLauncher.CLI.Tests/RegistryAndReleaseTests.cs +++ b/UnchainedLauncher.CLI.Tests/RegistryAndReleaseTests.cs @@ -23,6 +23,7 @@ public class RegistryAndReleaseTests : IDisposable { [Fact] public void ConfigurationSupportsNestedAggregatesAndRelativeLocalPaths() { + var cwd = Directory.GetCurrentDirectory(); var local = Path.Combine(_directory, "local"); Directory.CreateDirectory(local); var path = Path.Combine(_directory, "registries.json"); @@ -36,6 +37,7 @@ public void ConfigurationSupportsNestedAggregatesAndRelativeLocalPaths() { var localRegistry = Assert.Single(registry.ModRegistries.OfType()); Assert.Equal(local, localRegistry.RegistryPath); Assert.Single(registry.ModRegistries.OfType()); + Assert.Equal(cwd, Directory.GetCurrentDirectory()); } [Fact] @@ -45,6 +47,19 @@ public void MissingLocalRegistryDoesNotCreateADirectory() { Assert.False(Directory.Exists(path)); } + [Fact] + public void MissingNestedLocalRegistryDoesNotCreateADirectoryOrChangeCwd() { + var cwd = Directory.GetCurrentDirectory(); + var path = Path.Combine(_directory, "registries.json"); + var metadata = new AggregateModRegistryMetadata([ + new AggregateModRegistryMetadata([new LocalModRegistryMetadata("missing")]) + ]); + File.WriteAllText(path, JsonHelpers.Serialize(metadata)); + Assert.Throws(() => RegistrySpec.Parse("config:" + path).Create()); + Assert.False(Directory.Exists(Path.Combine(_directory, "missing"))); + Assert.Equal(cwd, Directory.GetCurrentDirectory()); + } + [Fact] public void InvalidConfigurationFailsClearly() { var path = Path.Combine(_directory, "bad.json"); @@ -110,6 +125,37 @@ public void PinnedReleaseDependencyMustActuallyBeInTheInstallSet() { Assert.Throws(() => ServerLauncher.ValidateReleases(manager, [coordinate])); } + [Theory] + [InlineData("cycle")] + [InlineData("self")] + [InlineData("out-of-order")] + [InlineData("valid")] + public void PinnedReleaseEdgesMustPrecedeDependentsDespiteDifferentLatestMetadata(string graph) { + var a = Mod("org", "a", "v1.0.0"); + var b = Mod("org", "b", "v1.0.0"); + a.Releases.Add(Mod("org", "a", "v2.0.0").Releases.Single()); + b.Releases.Add(Mod("org", "b", "v2.0.0").Releases.Single()); + ReleaseCoordinates[] requested = [new("org", "a", "v1.0.0"), new("org", "b", "v1.0.0")]; + var manager = Manager(requested, Common(), a, b); + var sorted = manager.SortByDependencies(manager.GetEnabledAndDependencyReleases()).ToArray(); + var pinned = sorted.Where(release => release.Info.Organization == "org").ToArray(); + // Hash-set iteration is unspecified; construct an edge relative to the actual latest-metadata order. + void Depends(Release dependent, Release dependency) => dependent.Info.Dependencies!.Add(new Dependency(dependency.Info.RepoUrl, "*")); + if (graph == "self") Depends(pinned[0], pinned[0]); + else if (graph == "valid") Depends(pinned[1], pinned[0]); + else { + Depends(pinned[0], pinned[1]); + if (graph == "cycle") Depends(pinned[1], pinned[0]); + } + Assert.Equal(sorted, manager.SortByDependencies(manager.GetEnabledAndDependencyReleases()).ToArray()); + File.WriteAllText(Path.Combine(_directory, "unknown.pak"), "untouched"); + if (graph == "valid") ServerLauncher.ValidateReleases(manager, requested); + else Assert.Throws(() => ServerLauncher.ValidateReleases(manager, requested)); + Assert.Equal("untouched", File.ReadAllText(Path.Combine(_directory, "unknown.pak"))); + Assert.Single(Directory.GetFiles(_directory)); + Assert.Empty(Assert.IsType(manager.PakDir).ManagedPaks); + } + [Fact] public async Task CoreModPreparationAndInventoryRoundTripWorkInAConsoleHost() { await Task.Run(async () => { diff --git a/UnchainedLauncher.CLI/RegistrySpec.cs b/UnchainedLauncher.CLI/RegistrySpec.cs index 23a80cb5..21c89292 100644 --- a/UnchainedLauncher.CLI/RegistrySpec.cs +++ b/UnchainedLauncher.CLI/RegistrySpec.cs @@ -21,8 +21,7 @@ public static RegistrySpec Parse(string value, string? baseDirectory = null) { public IModRegistry Create() => Kind switch { "github" => new GithubModRegistry(Location.Split('/')[0], Location.Split('/')[1]), - "local" => Directory.Exists(Location) ? new LocalModRegistry(Location) - : throw new DirectoryNotFoundException($"Local registry directory does not exist: {Location}"), + "local" => new LocalModRegistry(RequireLocalDirectory(Location)), "config" => LoadConfiguration(), _ => throw new ArgumentException($"Unsupported registry kind: {Kind}") }; @@ -36,10 +35,13 @@ private IModRegistry LoadConfiguration() { } private static ModRegistryMetadata ResolvePaths(ModRegistryMetadata metadata, string directory) => metadata switch { - LocalModRegistryMetadata local => local with { PakReleaseDir = Path.GetFullPath(local.PakReleaseDir, directory) }, + LocalModRegistryMetadata local => local with { PakReleaseDir = RequireLocalDirectory(Path.GetFullPath(local.PakReleaseDir, directory)) }, AggregateModRegistryMetadata aggregate => aggregate with { Registries = aggregate.Registries.Select(r => ResolvePaths(r, directory)).ToArray() }, _ => metadata }; + + private static string RequireLocalDirectory(string path) => Directory.Exists(path) ? path + : throw new DirectoryNotFoundException($"Local registry directory does not exist: {path}"); } \ No newline at end of file diff --git a/UnchainedLauncher.CLI/ServerInstancePaths.cs b/UnchainedLauncher.CLI/ServerInstancePaths.cs index 1cd9a721..1b505e24 100644 --- a/UnchainedLauncher.CLI/ServerInstancePaths.cs +++ b/UnchainedLauncher.CLI/ServerInstancePaths.cs @@ -8,15 +8,7 @@ public sealed record ServerInstancePaths(ProfileAssetPaths Assets) { public string Plugins => Assets.Plugins; public string ModMetadata => Path.Combine(Root, "cli_mod_manager.json"); - internal IDisposable AcquireLease() { - Directory.CreateDirectory(Root); - try { - return new FileStream(Path.Combine(Root, ".instance.lock"), FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None); - } - catch (IOException ex) { - throw new IOException("Cannot lock the profile asset directory; another launcher may be using the same saved profile.", ex); - } - } + internal IDisposable AcquireLease() => Assets.AcquireLease(); internal PakDir CreatePakDir(string installation, CancellationToken cancellationToken = default) => Assets.CreatePakDir(installation, cancellationToken: cancellationToken); diff --git a/UnchainedLauncher.CLI/ServerLauncher.cs b/UnchainedLauncher.CLI/ServerLauncher.cs index cb8553b4..1d05865f 100644 --- a/UnchainedLauncher.CLI/ServerLauncher.cs +++ b/UnchainedLauncher.CLI/ServerLauncher.cs @@ -144,9 +144,15 @@ public static void ValidateReleases(IModManager manager, IEnumerable(); + foreach (var release in sorted) { + if (release.Info.EffectiveModDependencies.Any(dependency => !preceding.Contains(ModIdentifier.FromDependency(dependency)))) + throw new InvalidOperationException("Selected mod dependencies contain a cycle or violate the resolved pak order; refusing to install."); + preceding.Add(ModIdentifier.FromRelease(release)); + } } public static IA2S CreateQuery(ServerOptions options) { diff --git a/UnchainedLauncher.Core/Services/Dlls/DllManager.cs b/UnchainedLauncher.Core/Services/Dlls/DllManager.cs index c34ad4fe..4ce7bc63 100644 --- a/UnchainedLauncher.Core/Services/Dlls/DllManager.cs +++ b/UnchainedLauncher.Core/Services/Dlls/DllManager.cs @@ -399,7 +399,11 @@ private async Task PrepareAsync(bool server, bool allowDownloads, Action target.Position = 0; ValidateBytes(target, release); } - File.Move(temp, cache); + try { File.Move(temp, cache); } + catch (IOException) when (File.Exists(cache)) { + using var winner = File.OpenRead(cache); + ValidateBytes(winner, release); + } } finally { if (File.Exists(temp)) File.Delete(temp); } } diff --git a/UnchainedLauncher.Core/Services/Mods/ProfileAssetPaths.cs b/UnchainedLauncher.Core/Services/Mods/ProfileAssetPaths.cs index b3bb1cc4..0dff831a 100644 --- a/UnchainedLauncher.Core/Services/Mods/ProfileAssetPaths.cs +++ b/UnchainedLauncher.Core/Services/Mods/ProfileAssetPaths.cs @@ -22,6 +22,16 @@ public ProfileAssetPaths(string suffix, string? localAppData = null) { DllCache = Path.Combine(game, "unchained", "cache", "dlls"); } + public IDisposable AcquireLease() { + Directory.CreateDirectory(Root); + try { + return new FileStream(Path.Combine(Root, ".instance.lock"), FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None); + } + catch (IOException ex) { + throw new IOException("Cannot lock the profile asset directory; another launcher may be using the same saved profile.", ex); + } + } + public ModManager Rebind(ModManager manager, string installation) { var sameProfile = Path.GetFullPath(manager.PakDir.DirPath).Equals(Path.GetFullPath(Paks), StringComparison.OrdinalIgnoreCase); return manager.WithPakDir(CreatePakDir(installation, diff --git a/UnchainedLauncher.GUI.Tests.Unit/ProfileModsLaunchPreparerTests.cs b/UnchainedLauncher.GUI.Tests.Unit/ProfileModsLaunchPreparerTests.cs index 72d21e67..b2667316 100644 --- a/UnchainedLauncher.GUI.Tests.Unit/ProfileModsLaunchPreparerTests.cs +++ b/UnchainedLauncher.GUI.Tests.Unit/ProfileModsLaunchPreparerTests.cs @@ -19,7 +19,7 @@ public sealed class ProfileModsLaunchPreparerTests { [InlineData(true)] [InlineData(false)] public void RebindingCopiesOwnershipOnlyWithinTheSameProfileAndSnapshotsSelection(bool sameProfile) { - var root = Path.Combine(Path.GetTempPath(), "gui-rebind-" + Guid.NewGuid().ToString("N")); + var root = Scratch("gui-rebind-"); try { var source = new ProfileAssetPaths("Source", root); var target = sameProfile ? new ProfileAssetPaths("Source", root) : new ProfileAssetPaths("Target", root); @@ -51,7 +51,7 @@ public void RebindingCopiesOwnershipOnlyWithinTheSameProfileAndSnapshotsSelectio [InlineData("Court_yard_1", "Court_yard_1")] [InlineData("Court-yard", "Courtyard")] public async Task LaunchSuffixSelectsInstalledAndSignedPaks(string? suffix, string expectedSuffix) { - var root = Path.Combine(Path.GetTempPath(), "gui-profile-" + Guid.NewGuid().ToString("N")); + var root = Scratch("gui-profile-"); Directory.CreateDirectory(root); try { var installation = Path.Combine(root, "game"); @@ -119,6 +119,120 @@ public async Task LaunchSuffixSelectsInstalledAndSignedPaks(string? suffix, stri finally { Directory.Delete(root, true); } } + [Theory] + [InlineData("")] + [InlineData("---")] + [InlineData("../other")] + public async Task InvalidExplicitSuffixCancelsBeforeCreatingProfileAssets(string suffix) { + var root = Scratch("gui-invalid-profile-"); + try { + var registry = new LocalModRegistry(Path.Combine(root, "registry")); + var manager = new ModManager(registry, new PakDir(Path.Combine(root, "source"), []), []); + var before = Directory.GetFileSystemEntries(root, "*", SearchOption.AllDirectories).Order().ToArray(); + var dialogue = new Dialogue(); + var result = await new ProfileModsLaunchPreparer(manager, dialogue, root, root).PrepareLaunch(Options(suffix)); + Assert.True(result.IsNone); + Assert.Contains("Invalid saved directory suffix", Assert.Single(dialogue.Messages)); + Assert.Equal(before, Directory.GetFileSystemEntries(root, "*", SearchOption.AllDirectories).Order().ToArray()); + } + finally { Directory.Delete(root, true); } + } + + [Fact] + public async Task HeldCliLeaseCancelsGuiPreparationWithoutReconcilingOwnedFiles() { + var root = Scratch("gui-cli-lease-"); + try { + var assets = new ProfileAssetPaths("unchained", root); + var coordinates = new ReleaseCoordinates("org", "owned", "v1.0.0"); + var owned = new ManagedPak(coordinates, "owned_0_P.pak", 0); + var manager = new ModManager(new LocalModRegistry(Path.Combine(root, "registry")), assets.CreatePakDir(root), []); + var payload = Path.Combine(assets.Paks, owned.PakFileName); + File.WriteAllText(payload, "owned payload"); + File.WriteAllText(Path.ChangeExtension(payload, ".sig"), "owned signature"); + File.WriteAllText(Path.Combine(assets.Paks, FilePaths.ManagedPakInventoryFileName), + JsonHelpers.Serialize(new PakDirMetadata(assets.Paks, [owned]))); + // Exact CLI convention, independently opened rather than a second test-only lock. + Directory.CreateDirectory(assets.Root); + using var lease = new FileStream(Path.Combine(assets.Root, ".instance.lock"), FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None); + var before = Directory.GetFiles(root, "*", SearchOption.AllDirectories) + .Where(path => !path.EndsWith(".instance.lock")).ToDictionary(path => path, File.ReadAllText); + var dialogue = new Dialogue(); + Assert.True((await new ProfileModsLaunchPreparer(manager, dialogue, root, root).PrepareLaunch(Options(null))).IsNone); + Assert.Contains("another launcher", Assert.Single(dialogue.Messages)); + Assert.Equal(before.Keys.Order(), Directory.GetFiles(root, "*", SearchOption.AllDirectories) + .Where(path => !path.EndsWith(".instance.lock")).Order()); + foreach (var pair in before) Assert.Equal(pair.Value, File.ReadAllText(pair.Key)); + } + finally { Directory.Delete(root, true); } + } + + [Fact] + public async Task PendingGuiTransactionRejectsSameProfileButAllowsAnotherProfile() { + var root = Scratch("gui-concurrent-profile-"); + var finish = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + try { + var defaultPaks = Path.Combine(root, FilePaths.PakDir); + Directory.CreateDirectory(defaultPaks); + File.WriteAllText(Path.Combine(defaultPaks, "pakchunk0-WindowsNoEditor.sig"), "signature"); + var payload = Path.Combine(root, "source.pak"); + File.WriteAllText(payload, "payload"); + var hash = FileHelpers.Sha512(payload).Match(Right: value => value, Left: e => throw new Exception(e.Message)); + var release = new Release("v1.0.0", hash, "mod.pak", DateTime.UnixEpoch, + new ModInfo("https://github.com/org/mod", "mod", "test", null, null, [], [], []), + new AssetCollections([], [], [], [], []), null); + var local = new LocalModRegistry(Path.Combine(root, "registry")); + await local.AddRelease(release, payload); + var started = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var registry = new PausedRegistry(local, started, finish); + var source = new ProfileAssetPaths("Source", root); + var manager = new ModManager(registry, source.CreatePakDir(root), [ReleaseCoordinates.FromRelease(release)]); + Assert.False((await manager.UpdateModsList()).HasErrors); + var firstDialogue = new Dialogue(); + var preparer = new ProfileModsLaunchPreparer(manager, firstDialogue, root, root); + var first = preparer.PrepareLaunch(Options("First")); + await started.Task.WaitAsync(TimeSpan.FromSeconds(10)); + var firstAssets = new ProfileAssetPaths("First", root); + var before = Directory.GetFiles(firstAssets.Paks).ToDictionary(path => path, File.ReadAllBytes); + Assert.True((await preparer.PrepareLaunch(Options("First")).WaitAsync(TimeSpan.FromSeconds(10))).IsNone); + Assert.Contains("another launcher", Assert.Single(firstDialogue.Messages)); + Assert.Equal(before.Keys.Order(), Directory.GetFiles(firstAssets.Paks).Order()); + foreach (var pair in before) Assert.Equal(pair.Value, File.ReadAllBytes(pair.Key)); + // An empty selection avoids waiting on the intentionally shared immutable cache object. + var otherManager = new ModManager(local, source.CreatePakDir(root), []); + var otherDialogue = new Dialogue(); + Assert.True((await new ProfileModsLaunchPreparer(otherManager, otherDialogue, root, root) + .PrepareLaunch(Options("Other"))).IsSome); + Assert.False(first.IsCompleted); + Assert.Empty(otherDialogue.Messages); + finish.SetResult(); + Assert.True((await first.WaitAsync(TimeSpan.FromSeconds(10))).IsSome); + Assert.Single(ReadInventory(firstAssets.Paks)); + Assert.True((await preparer.PrepareLaunch(Options("First"))).IsSome); + } + finally { finish.TrySetResult(); Directory.Delete(root, true); } + } + + private static string Scratch(string prefix) => Path.Combine(Environment.GetEnvironmentVariable("TMPDIR") ?? + (OperatingSystem.IsLinux() ? "/tmp/opencode" : Path.GetTempPath()), prefix + Guid.NewGuid().ToString("N")); + + private static LaunchOptions Options(string? suffix) => new([], Option.None, "", false, false, + suffix is null ? Option.None : Option.Some(suffix), Option.None); + + private sealed class PausedRegistry(IModRegistry inner, TaskCompletionSource started, TaskCompletionSource finish) : IModRegistry { + public string Name => inner.Name; + public Task GetAllMods() => inner.GetAllMods(); + public EitherAsync GetMod(ModIdentifier id) => inner.GetMod(id); + public EitherAsync GetModRelease(ReleaseCoordinates coordinates) => inner.GetModRelease(coordinates); + public Task GetPakSize(Release release, CancellationToken cancellationToken = default) => inner.GetPakSize(release, cancellationToken); + public EitherAsync DownloadPak(ReleaseCoordinates coordinates, string outputLocation) => + Download(coordinates, outputLocation).ToAsync(); + private async Task> Download(ReleaseCoordinates coordinates, string outputLocation) { + started.TrySetResult(); + await finish.Task; + return await inner.DownloadPak(coordinates, outputLocation).ToEither(); + } + } + private static ManagedPak[] ReadInventory(string paks) { var result = JsonHelpers.Deserialize(File.ReadAllText(Path.Combine(paks, FilePaths.ManagedPakInventoryFileName))); Assert.True(result.Success); diff --git a/UnchainedLauncher.GUI/Services/ProfileModsLaunchPreparer.cs b/UnchainedLauncher.GUI/Services/ProfileModsLaunchPreparer.cs index 9016d290..8f3ab150 100644 --- a/UnchainedLauncher.GUI/Services/ProfileModsLaunchPreparer.cs +++ b/UnchainedLauncher.GUI/Services/ProfileModsLaunchPreparer.cs @@ -1,4 +1,6 @@ using LanguageExt; +using System; +using System.IO; using System.Threading.Tasks; using UnchainedLauncher.Core.Services; using UnchainedLauncher.Core.Services.Mods; @@ -10,13 +12,28 @@ namespace UnchainedLauncher.GUI.Services; public sealed class ProfileModsLaunchPreparer( ModManager manager, IUserDialogueSpawner dialogue, string installation, string? localAppData = null) : IChivalry2LaunchPreparer { - public Task> PrepareLaunch(LaunchOptions options) { - var assets = new ProfileAssetPaths(options.SavedDirSuffix.IfNone(ProfileAssetPaths.SharedSuffix), localAppData); - var profileManager = assets.Rebind(manager, installation); - // DLL selection and injection stay bound to the shared manager and plugin directory. - IChivalry2LaunchPreparer installer = new Chivalry2ModsInstaller(profileManager, dialogue); - return installer - .Sub(SigPreparer.Create(profileManager.PakDir, dialogue)) - .PrepareLaunch(options with { SavedDirSuffix = assets.Suffix }); + public async Task> PrepareLaunch(LaunchOptions options) { + ProfileAssetPaths assets; + try { + assets = new ProfileAssetPaths(options.SavedDirSuffix.IfNone(ProfileAssetPaths.SharedSuffix), localAppData); + } + catch (ArgumentException e) { + dialogue.DisplayMessage($"Could not prepare profile assets: {e.Message}\nLaunch cancelled."); + return Option.None; + } + IDisposable lease; + try { lease = assets.AcquireLease(); } + catch (Exception e) when (e is IOException or UnauthorizedAccessException) { + dialogue.DisplayMessage($"Could not prepare profile assets: {e.Message}\nLaunch cancelled."); + return Option.None; + } + using (lease) { + var profileManager = assets.Rebind(manager, installation); + // DLL selection and injection stay bound to the shared manager and plugin directory. + IChivalry2LaunchPreparer installer = new Chivalry2ModsInstaller(profileManager, dialogue); + return await installer + .Sub(SigPreparer.Create(profileManager.PakDir, dialogue)) + .PrepareLaunch(options with { SavedDirSuffix = assets.Suffix }); + } } } \ No newline at end of file From 4780e4863f784c4bf3e88f09bde55e81c0fe0dc2 Mon Sep 17 00:00:00 2001 From: Jacoby6000 <2672832+Jacoby6000@users.noreply.github.com> Date: Tue, 20 Oct 2026 19:54:22 +0000 Subject: [PATCH 53/58] [GHA] Format Source --- UnchainedLauncher.CLI.Tests/DllCachePublicationTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/UnchainedLauncher.CLI.Tests/DllCachePublicationTests.cs b/UnchainedLauncher.CLI.Tests/DllCachePublicationTests.cs index d3bdaae3..6dcf560d 100644 --- a/UnchainedLauncher.CLI.Tests/DllCachePublicationTests.cs +++ b/UnchainedLauncher.CLI.Tests/DllCachePublicationTests.cs @@ -67,4 +67,4 @@ protected override async Task SendAsync(HttpRequestMessage return new HttpResponseMessage(HttpStatusCode.OK) { RequestMessage = request, Content = new ByteArrayContent(bytes) }; } } -} +} \ No newline at end of file From bbd6b873f381c335f7b72bd33afb2444b23b7de6 Mon Sep 17 00:00:00 2001 From: Jacob Barber Date: Tue, 20 Oct 2026 15:53:42 -0500 Subject: [PATCH 54/58] Preserve profile signatures and cancel inventory I/O failures --- .../LaunchPreparationTests.cs | 40 +++++ UnchainedLauncher.CLI/ServerLauncher.cs | 2 +- .../LaunchPreparers/Chivalry2ModsInstaller.cs | 25 +++- .../Chivalry/LaunchPreparers/SigPreparer.cs | 10 +- .../ProfileModsLaunchPreparerTests.cs | 137 +++++++++++++++++- .../Services/ProfileModsLaunchPreparer.cs | 2 +- 6 files changed, 203 insertions(+), 13 deletions(-) diff --git a/UnchainedLauncher.CLI.Tests/LaunchPreparationTests.cs b/UnchainedLauncher.CLI.Tests/LaunchPreparationTests.cs index d09c86d9..26f354f3 100644 --- a/UnchainedLauncher.CLI.Tests/LaunchPreparationTests.cs +++ b/UnchainedLauncher.CLI.Tests/LaunchPreparationTests.cs @@ -16,6 +16,46 @@ public sealed class LaunchPreparationTests : IDisposable { public LaunchPreparationTests() => Directory.CreateDirectory(_directory); public void Dispose() => Directory.Delete(_directory, recursive: true); + [Fact] + public async Task ProfilePreparationPreservesUnknownSignaturesAcrossLaunches() { + var local = new LocalModRegistry(Path.Combine(_directory, "registry")); + var source = Path.Combine(_directory, "payload.pak"); + File.WriteAllText(source, "payload"); + var hash = FileHelpers.Sha512(source).Match(value => value, error => throw new Exception(error.Message)); + foreach (var id in new[] { "Chiv2-Community/Unchained-Mods", "org/mod" }) { + var release = new Release("v1.0.0", hash, "mod.pak", DateTime.UnixEpoch, + new ModInfo("https://github.com/" + id, id.Split('/')[1], "test", null, null, [], [], []), + new AssetCollections([], [], [], [], []), null); + await local.AddRelease(release, source); + } + AddPlugin(local, "org/mod", ">=1.0.0"); + var request = CliLaunchOptions.Parse(["client", "--chivalry2-path", _directory, "--platform", "Steam"]); + request.Shared.Assets = new ProfileAssetPaths(request.Shared.SavedDirSuffix, _directory); + var basePaks = Path.Combine(_directory, FilePaths.PakDir); + Directory.CreateDirectory(basePaks); + File.WriteAllText(Path.Combine(basePaks, "pakchunk0-WindowsNoEditor.sig"), "signature"); + var registry = new AggregateModRegistry(local); + var manager = ServerLauncher.CreateModManager(request, registry); + Assert.False((await manager.UpdateModsList()).HasErrors); + var unknownSigs = new[] { "UserBackup.sig", "mod_1_P.sig" }; + foreach (var name in unknownSigs) File.WriteAllText(Path.Combine(manager.PakDir.DirPath, name), "unknown " + name); + using var client = new HttpClient(new DownloadHandler()); + var session = new DllLaunchSession(registry, manager, request.InstancePaths.Plugins, client: client, + cacheDirectory: Path.Combine(_directory, "dll-cache")); + var preparer = ServerLauncher.CreateLaunchPreparer(request, manager, session, new ModManagerCodec(registry), + Path.Combine(_directory, "host.json")); + ManagedPak[]? first = null; + for (var i = 0; i < 2; i++) { + Assert.True((await preparer.PrepareLaunch(request.ToLaunchOptions())).IsSome); + var owned = Assert.IsType(manager.PakDir).ManagedPaks.ToArray(); + Assert.Equal("mod (1)_1_P.pak", Assert.Single(owned, pak => pak.Coordinates.ModuleName == "mod").PakFileName); + if (first is not null) Assert.Equal(first, owned); + first = owned; + foreach (var pak in owned) Assert.Equal("signature", File.ReadAllText(Path.ChangeExtension(Path.Combine(manager.PakDir.DirPath, pak.PakFileName), ".sig"))); + foreach (var name in unknownSigs) Assert.Equal("unknown " + name, File.ReadAllText(Path.Combine(manager.PakDir.DirPath, name))); + } + } + [Theory] [InlineData("client", true, false)] [InlineData("server", true, false)] diff --git a/UnchainedLauncher.CLI/ServerLauncher.cs b/UnchainedLauncher.CLI/ServerLauncher.cs index 1d05865f..6f127dfa 100644 --- a/UnchainedLauncher.CLI/ServerLauncher.cs +++ b/UnchainedLauncher.CLI/ServerLauncher.cs @@ -61,7 +61,7 @@ internal static IChivalry2LaunchPreparer CreateLaunchPreparer(Cli CancellationToken cancellationToken = default) { var dialogue = new ConsoleDialogue(); var modInstaller = new Chivalry2ModsInstaller(manager, dialogue); - var signing = SigPreparer.Create(manager.PakDir, dialogue); + var signing = SigPreparer.CreateForProfile(manager.PakDir, dialogue); // Call preparers sequentially so each receives the previous result. // Always save the managed-pak inventory, even after a partial installation. return Chivalry2LaunchPreparer.Create(async launch => { diff --git a/UnchainedLauncher.Core/Services/Processes/Chivalry/LaunchPreparers/Chivalry2ModsInstaller.cs b/UnchainedLauncher.Core/Services/Processes/Chivalry/LaunchPreparers/Chivalry2ModsInstaller.cs index d611e18c..ce505140 100644 --- a/UnchainedLauncher.Core/Services/Processes/Chivalry/LaunchPreparers/Chivalry2ModsInstaller.cs +++ b/UnchainedLauncher.Core/Services/Processes/Chivalry/LaunchPreparers/Chivalry2ModsInstaller.cs @@ -76,20 +76,31 @@ public async Task> PrepareLaunch(LaunchOptions options) { try { inventory = ManagedPakInventory.Reconcile(_modManager); } - catch (InvalidDataException e) { - _logger.Error($"Failed to reconcile managed pak inventory: {e.Message}"); - _userDialogueSpawner.DisplayMessage( - $"Could not reconcile managed pak inventory: {e.Message}\n" + - "Launch cancelled. Resolve the pak setup errors before launching."); + catch (Exception e) when (e is InvalidDataException or IOException or UnauthorizedAccessException) { + ReportInventoryFailure(e); return None; } + var saved = true; + Option result; try { - return await PrepareMods(options); + result = await PrepareMods(options); } finally { // Persist physical ownership even when preparation fails partway through. - inventory?.Save(); + try { inventory?.Save(); } + catch (Exception e) when (e is IOException or UnauthorizedAccessException) { + saved = false; + ReportInventoryFailure(e); + } } + return saved ? result : None; + } + + private void ReportInventoryFailure(Exception e) { + _logger.Error($"Failed to reconcile managed pak inventory: {e.Message}"); + _userDialogueSpawner.DisplayMessage( + $"Could not reconcile managed pak inventory: {e.Message}\n" + + "Launch cancelled. Resolve the pak setup errors before launching."); } private async Task> PrepareMods(LaunchOptions options) { diff --git a/UnchainedLauncher.Core/Services/Processes/Chivalry/LaunchPreparers/SigPreparer.cs b/UnchainedLauncher.Core/Services/Processes/Chivalry/LaunchPreparers/SigPreparer.cs index 6d490bcb..3989b064 100644 --- a/UnchainedLauncher.Core/Services/Processes/Chivalry/LaunchPreparers/SigPreparer.cs +++ b/UnchainedLauncher.Core/Services/Processes/Chivalry/LaunchPreparers/SigPreparer.cs @@ -15,20 +15,26 @@ public class SigPreparer : IChivalry2LaunchPreparer { private readonly ILog _logger = LogManager.GetLogger(nameof(SigPreparer)); private readonly IUserDialogueSpawner _userDialogueSpawner; + private readonly bool _deleteOrphanedSigs; public static IChivalry2LaunchPreparer Create(IPakDir pakDir, IUserDialogueSpawner userDialogueSpawner) => new SigPreparer(pakDir, userDialogueSpawner); - private SigPreparer(IPakDir pakDir, IUserDialogueSpawner userDialogueSpawner) { + // Profile ownership cleanup removes managed signatures; unknown signatures must survive. + public static IChivalry2LaunchPreparer CreateForProfile(IPakDir pakDir, IUserDialogueSpawner userDialogueSpawner) => + new SigPreparer(pakDir, userDialogueSpawner, false); + + private SigPreparer(IPakDir pakDir, IUserDialogueSpawner userDialogueSpawner, bool deleteOrphanedSigs = true) { PakDir = pakDir; _userDialogueSpawner = userDialogueSpawner; + _deleteOrphanedSigs = deleteOrphanedSigs; } public Task> PrepareLaunch(Unit input) { _logger.Info("Ensuring sigs are set up for all pak files."); var result = PakDir .SignAll() - .Bind(_ => PakDir.DeleteOrphanedSigs()) + .Bind(result => _deleteOrphanedSigs ? PakDir.DeleteOrphanedSigs() : result) .Match( _ => Some(input), errors => { diff --git a/UnchainedLauncher.GUI.Tests.Unit/ProfileModsLaunchPreparerTests.cs b/UnchainedLauncher.GUI.Tests.Unit/ProfileModsLaunchPreparerTests.cs index b2667316..57f13d1b 100644 --- a/UnchainedLauncher.GUI.Tests.Unit/ProfileModsLaunchPreparerTests.cs +++ b/UnchainedLauncher.GUI.Tests.Unit/ProfileModsLaunchPreparerTests.cs @@ -7,6 +7,7 @@ using UnchainedLauncher.Core.Services.Mods.Registry; using UnchainedLauncher.Core.Services.Processes; using UnchainedLauncher.Core.Services.Processes.Chivalry; +using UnchainedLauncher.Core.Services.Processes.Chivalry.LaunchPreparers; using UnchainedLauncher.Core.Utilities; using UnchainedLauncher.GUI.Services; using UnchainedLauncher.UnrealModScanner.JsonModels; @@ -74,6 +75,10 @@ public async Task LaunchSuffixSelectsInstalledAndSignedPaks(string? suffix, stri Assert.False((await manager.UpdateModsList()).HasErrors); var dialogue = new Dialogue(); var preparer = new ProfileModsLaunchPreparer(manager, dialogue, installation, root); + var profilePaks = new ProfileAssetPaths(expectedSuffix, root).Paks; + Directory.CreateDirectory(profilePaks); + var unknownSigs = new[] { "UserBackup.sig", "Selected_1_P.sig" }; + foreach (var name in unknownSigs) File.WriteAllText(Path.Combine(profilePaks, name), "unknown " + name); var server = new ServerLaunchOptions(true, "Courtyard", "", false, Option.None, "/Game/Maps/Courtyard", 7777, 7778, 7779, 7780, null, null, null, null, null, null, Option.None, [], Option.None, false, false, CensorArg.None); @@ -94,6 +99,10 @@ public async Task LaunchSuffixSelectsInstalledAndSignedPaks(string? suffix, stri Assert.DoesNotContain("--additional-pak-dir", process.Arguments); var paks = Path.Combine(root, "Chivalry 2", "Saved_" + expectedSuffix, "Paks"); var inventory = ReadInventory(paks); + Assert.Equal("Selected (1)_1_P.pak", Assert.Single(inventory, pak => pak.Coordinates == ReleaseCoordinates.FromRelease(selected)).PakFileName); + Assert.True((await preparer.PrepareLaunch(options)).IsSome); + Assert.Equal(inventory, ReadInventory(paks)); + foreach (var name in unknownSigs) Assert.Equal("unknown " + name, File.ReadAllText(Path.Combine(paks, name))); Assert.Equal(2, inventory.Length); Assert.Contains(inventory, pak => pak.Coordinates == ReleaseCoordinates.FromRelease(selected)); Assert.Contains(inventory, pak => pak.Coordinates == ReleaseCoordinates.FromRelease(dependency)); @@ -111,7 +120,8 @@ public async Task LaunchSuffixSelectsInstalledAndSignedPaks(string? suffix, stri Assert.True((await preparer.PrepareLaunch(options with { EnabledReleases = manager.EnabledModReleaseCoordinates })).IsSome); Assert.Equal(ReleaseCoordinates.FromRelease(unselected), Assert.Single(ReadInventory(paks)).Coordinates); Assert.Single(Directory.GetFiles(paks, "*.pak")); - Assert.Single(Directory.GetFiles(paks, "*.sig")); + Assert.Equal(3, Directory.GetFiles(paks, "*.sig").Length); + foreach (var name in unknownSigs) Assert.Equal("unknown " + name, File.ReadAllText(Path.Combine(paks, name))); Assert.Equal("approved payload", File.ReadAllText(Path.Combine(shared.PakCache, hash.ToLowerInvariant(), "mod.pak"))); Assert.Empty(Directory.GetFiles(shared.PakCache, "*.sig", SearchOption.AllDirectories)); Assert.Empty(dialogue.Messages); @@ -215,9 +225,127 @@ public async Task PendingGuiTransactionRejectsSameProfileButAllowsAnotherProfile private static string Scratch(string prefix) => Path.Combine(Environment.GetEnvironmentVariable("TMPDIR") ?? (OperatingSystem.IsLinux() ? "/tmp/opencode" : Path.GetTempPath()), prefix + Guid.NewGuid().ToString("N")); + [Theory] + [InlineData(true, true)] + [InlineData(true, false)] + [InlineData(false, true)] + [InlineData(false, false)] + public async Task InventoryIoFailureCancelsLaunchAndReleasesLease(bool gui, bool lockedRead) { + var root = Scratch("gui-inventory-io-"); + try { + var assets = new ProfileAssetPaths("unchained", root); + var basePaks = Path.Combine(root, FilePaths.PakDir); + Directory.CreateDirectory(basePaks); + File.WriteAllText(Path.Combine(basePaks, "pakchunk0-WindowsNoEditor.sig"), "signature"); + var payload = Path.Combine(root, "source.pak"); + File.WriteAllText(payload, "payload"); + var hash = FileHelpers.Sha512(payload).Match(value => value, error => throw new Exception(error.Message)); + var release = new Release("v1.0.0", hash, "mod.pak", DateTime.UnixEpoch, + new ModInfo("https://github.com/org/mod", "mod", "test", null, null, [], [], []), + new AssetCollections([], [], [], [], []), null); + var registry = new LocalModRegistry(Path.Combine(root, "registry")); + await registry.AddRelease(release, payload); + var manager = new ModManager(registry, assets.CreatePakDir(root), [ReleaseCoordinates.FromRelease(release)]); + Assert.False((await manager.UpdateModsList()).HasErrors); + var path = Path.Combine(assets.Paks, FilePaths.ManagedPakInventoryFileName); + File.WriteAllText(path, "{\"ManagedPaks\":[]}"); + if (!lockedRead) Directory.CreateDirectory(path + ".tmp"); + var dialogue = new Dialogue(); + IChivalry2LaunchPreparer preparer = gui + ? new ProfileModsLaunchPreparer(manager, dialogue, root, root) + : ((IChivalry2LaunchPreparer)new Chivalry2ModsInstaller(manager, dialogue)) + .Sub(SigPreparer.CreateForProfile(manager.PakDir, dialogue)); + using (var held = lockedRead ? new FileStream(path, FileMode.Open, FileAccess.ReadWrite, FileShare.None) : null) { + var process = new CapturingProcessLauncher(); + var launcher = new UnchainedChivalry2Launcher(preparer, process, root, new NullInjector()); + Assert.True((await launcher.Launch(Options(null))).IsLeft); + Assert.Equal("", process.Arguments); + Assert.Contains("Could not reconcile managed pak inventory", Assert.Single(dialogue.Messages)); + Assert.Contains("Launch cancelled", dialogue.Messages[0]); + Assert.Equal(lockedRead ? 0 : 1, Directory.GetFiles(assets.Paks, "*.pak").Length); + // Installation itself signs downloads before the final inventory save. + Assert.Equal(lockedRead ? 0 : 1, Directory.GetFiles(assets.Paks, "*.sig").Length); + Assert.Equal(lockedRead ? 0 : 1, dialogue.ProgressClosed); + using var available = assets.AcquireLease(); + } + Assert.Equal("{\"ManagedPaks\":[]}", File.ReadAllText(path)); + if (!lockedRead) Directory.Delete(path + ".tmp"); + Assert.True((await preparer.PrepareLaunch(Options(null))).IsSome); + Assert.Single(ReadInventory(assets.Paks)); + // A failed save leaves the first download unowned, so retry must preserve it. + Assert.Equal(lockedRead ? 1 : 2, Directory.GetFiles(assets.Paks, "*.sig").Length); + } + finally { Directory.Delete(root, true); } + } + private static LaunchOptions Options(string? suffix) => new([], Option.None, "", false, false, suffix is null ? Option.None : Option.Some(suffix), Option.None); + [Fact] + public async Task EmptyProfilePreparationPreservesUnknownSignatures() { + var root = Scratch("gui-empty-signatures-"); + try { + var assets = new ProfileAssetPaths("unchained", root); + var manager = new ModManager(new LocalModRegistry(Path.Combine(root, "registry")), assets.CreatePakDir(root), []); + var path = Path.Combine(assets.Paks, "UserBackup.sig"); + File.WriteAllBytes(path, [0, 1, 255, 42]); + var preparer = new ProfileModsLaunchPreparer(manager, new Dialogue(), root, root); + for (var i = 0; i < 2; i++) { + Assert.True((await preparer.PrepareLaunch(Options(null))).IsSome); + Assert.Equal(new byte[] { 0, 1, 255, 42 }, File.ReadAllBytes(path)); + Assert.Empty(ReadInventory(assets.Paks)); + } + } + finally { Directory.Delete(root, true); } + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ProfileSigningPreservesLegacySweepContractAndFailsClosed(bool profile) { + var root = Scratch("signing-contract-"); + try { + Directory.CreateDirectory(root); + var orphan = Path.Combine(root, "UserBackup.sig"); + File.WriteAllText(orphan, "unknown signature"); + var paks = new PakDir(root, []); + var dialogue = new Dialogue(); + var preparer = profile ? SigPreparer.CreateForProfile(paks, dialogue) : SigPreparer.Create(paks, dialogue); + Assert.True((await preparer.PrepareLaunch(LanguageExt.Unit.Default)).IsSome); + Assert.Equal(profile, File.Exists(orphan)); + File.WriteAllText(Path.Combine(root, "unknown.pak"), "payload"); + Assert.True((await preparer.PrepareLaunch(LanguageExt.Unit.Default)).IsNone); + Assert.Contains("errors while installing", Assert.Single(dialogue.Messages)); + } + finally { Directory.Delete(root, true); } + } + + [Theory] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public async Task PreparationExceptionsStillEscapeAfterInventorySaveAttempt(bool cancelled, bool saveBlocked) { + var root = Scratch("inventory-original-error-"); + try { + var assets = new ProfileAssetPaths("unchained", root); + var manager = new ModManager(new LocalModRegistry(Path.Combine(root, "registry")), assets.CreatePakDir(root), []); + var path = Path.Combine(assets.Paks, FilePaths.ManagedPakInventoryFileName); + if (saveBlocked) Directory.CreateDirectory(path + ".tmp"); + Exception error = cancelled ? new OperationCanceledException("original") : new InvalidOperationException("original"); + var dialogue = new Dialogue { ProgressError = error }; + var preparer = new ProfileModsLaunchPreparer(manager, dialogue, root, root); + Assert.Same(error, await Assert.ThrowsAnyAsync(() => preparer.PrepareLaunch(Options(null)))); + using var available = assets.AcquireLease(); + if (saveBlocked) Assert.Contains("Launch cancelled", Assert.Single(dialogue.Messages)); + else { + Assert.Empty(dialogue.Messages); + Assert.Empty(ReadInventory(assets.Paks)); + } + } + finally { Directory.Delete(root, true); } + } + private sealed class PausedRegistry(IModRegistry inner, TaskCompletionSource started, TaskCompletionSource finish) : IModRegistry { public string Name => inner.Name; public Task GetAllMods() => inner.GetAllMods(); @@ -241,11 +369,16 @@ private static ManagedPak[] ReadInventory(string paks) { private sealed class Dialogue : IUserDialogueSpawner { public List Messages { get; } = []; + public int ProgressClosed { get; private set; } + public Exception? ProgressError { get; init; } public void DisplayMessage(string message) => Messages.Add(message); public UserDialogueChoice DisplayYesNoMessage(string message, string caption) => throw new NotSupportedException(); public UserDialogueChoice? DisplayUpdateMessage(string titleText, string messageText, string yesButtonText, string noButtonText, string? cancelButtonText, IEnumerable updates) => throw new NotSupportedException(); - public Action DisplayProgress(MemoryProgress progress) => () => { }; + public Action DisplayProgress(MemoryProgress progress) { + if (ProgressError is not null) throw ProgressError; + return () => { ProgressClosed++; }; + } } private sealed class CapturingProcessLauncher : IProcessLauncher { diff --git a/UnchainedLauncher.GUI/Services/ProfileModsLaunchPreparer.cs b/UnchainedLauncher.GUI/Services/ProfileModsLaunchPreparer.cs index 8f3ab150..dd68814a 100644 --- a/UnchainedLauncher.GUI/Services/ProfileModsLaunchPreparer.cs +++ b/UnchainedLauncher.GUI/Services/ProfileModsLaunchPreparer.cs @@ -32,7 +32,7 @@ public async Task> PrepareLaunch(LaunchOptions options) { // DLL selection and injection stay bound to the shared manager and plugin directory. IChivalry2LaunchPreparer installer = new Chivalry2ModsInstaller(profileManager, dialogue); return await installer - .Sub(SigPreparer.Create(profileManager.PakDir, dialogue)) + .Sub(SigPreparer.CreateForProfile(profileManager.PakDir, dialogue)) .PrepareLaunch(options with { SavedDirSuffix = assets.Suffix }); } } From 06b11cd58a4de4ff857ea6f00d3b0a4353b21a50 Mon Sep 17 00:00:00 2001 From: Jacob Barber Date: Tue, 20 Oct 2026 16:09:16 -0500 Subject: [PATCH 55/58] Cancel GUI preparation when profile directories are unavailable --- .../ProfileModsLaunchPreparerTests.cs | 43 +++++++++++++++++++ .../Services/ProfileModsLaunchPreparer.cs | 19 +++++--- 2 files changed, 56 insertions(+), 6 deletions(-) diff --git a/UnchainedLauncher.GUI.Tests.Unit/ProfileModsLaunchPreparerTests.cs b/UnchainedLauncher.GUI.Tests.Unit/ProfileModsLaunchPreparerTests.cs index 57f13d1b..27c2914c 100644 --- a/UnchainedLauncher.GUI.Tests.Unit/ProfileModsLaunchPreparerTests.cs +++ b/UnchainedLauncher.GUI.Tests.Unit/ProfileModsLaunchPreparerTests.cs @@ -222,6 +222,47 @@ public async Task PendingGuiTransactionRejectsSameProfileButAllowsAnotherProfile finally { finish.TrySetResult(); Directory.Delete(root, true); } } + [Theory] + [InlineData(null)] + [InlineData("Target")] + public async Task ObstructedProfilePaksCancelsLaunchAndAllowsRetry(string? suffix) { + var root = Scratch("gui-obstructed-profile-"); + try { + var assets = new ProfileAssetPaths(suffix ?? ProfileAssetPaths.SharedSuffix, root); + var source = new ProfileAssetPaths("Source", root); + var manager = new ModManager(new LocalModRegistry(Path.Combine(root, "registry")), source.CreatePakDir(root), []); + Directory.CreateDirectory(Path.GetDirectoryName(assets.Paks)!); + byte[] obstruction = [0, 1, 255, 42]; + File.WriteAllBytes(assets.Paks, obstruction); + var dialogue = new Dialogue(); + var preparer = new ProfileModsLaunchPreparer(manager, dialogue, root, root); + var process = new CapturingProcessLauncher(); + var launcher = new UnchainedChivalry2Launcher(preparer, process, root, new NullInjector()); + + Assert.True((await preparer.PrepareLaunch(Options(suffix))).IsNone); + Assert.True((await launcher.Launch(Options(suffix))).IsLeft); + Assert.Equal(0, process.Calls); + Assert.Equal(obstruction, File.ReadAllBytes(assets.Paks)); + Assert.Equal(2, dialogue.Messages.Count); + Assert.All(dialogue.Messages, message => { + Assert.Contains("Could not prepare profile assets", message); + Assert.Contains("Launch cancelled", message); + }); + Assert.Equal(0, dialogue.ProgressClosed); + using (assets.AcquireLease()) { } + + File.Delete(assets.Paks); + dialogue.Messages.Clear(); + Assert.True((await preparer.PrepareLaunch(Options(suffix))).IsSome); + Assert.True((await launcher.Launch(Options(suffix))).IsLeft); // Stop at the OS process boundary. + Assert.Equal(1, process.Calls); + Assert.Empty(dialogue.Messages); + Assert.Equal(2, dialogue.ProgressClosed); + using (assets.AcquireLease()) { } + } + finally { Directory.Delete(root, true); } + } + private static string Scratch(string prefix) => Path.Combine(Environment.GetEnvironmentVariable("TMPDIR") ?? (OperatingSystem.IsLinux() ? "/tmp/opencode" : Path.GetTempPath()), prefix + Guid.NewGuid().ToString("N")); @@ -384,7 +425,9 @@ public Action DisplayProgress(MemoryProgress progress) { private sealed class CapturingProcessLauncher : IProcessLauncher { public string Executable => "test-game.exe"; public string Arguments { get; private set; } = ""; + public int Calls { get; private set; } public Either Launch(string workingDirectory, string args) { + Calls++; Arguments = args; return new LaunchFailed(Executable, args, LanguageExt.Common.Error.New("Stopped at the OS boundary.")); } diff --git a/UnchainedLauncher.GUI/Services/ProfileModsLaunchPreparer.cs b/UnchainedLauncher.GUI/Services/ProfileModsLaunchPreparer.cs index dd68814a..347539fe 100644 --- a/UnchainedLauncher.GUI/Services/ProfileModsLaunchPreparer.cs +++ b/UnchainedLauncher.GUI/Services/ProfileModsLaunchPreparer.cs @@ -18,17 +18,19 @@ public async Task> PrepareLaunch(LaunchOptions options) { assets = new ProfileAssetPaths(options.SavedDirSuffix.IfNone(ProfileAssetPaths.SharedSuffix), localAppData); } catch (ArgumentException e) { - dialogue.DisplayMessage($"Could not prepare profile assets: {e.Message}\nLaunch cancelled."); - return Option.None; + return Cancel(e); } IDisposable lease; try { lease = assets.AcquireLease(); } catch (Exception e) when (e is IOException or UnauthorizedAccessException) { - dialogue.DisplayMessage($"Could not prepare profile assets: {e.Message}\nLaunch cancelled."); - return Option.None; + return Cancel(e); } using (lease) { - var profileManager = assets.Rebind(manager, installation); + ModManager profileManager; + try { profileManager = assets.Rebind(manager, installation); } + catch (Exception e) when (e is IOException or UnauthorizedAccessException) { + return Cancel(e); + } // DLL selection and injection stay bound to the shared manager and plugin directory. IChivalry2LaunchPreparer installer = new Chivalry2ModsInstaller(profileManager, dialogue); return await installer @@ -36,4 +38,9 @@ public async Task> PrepareLaunch(LaunchOptions options) { .PrepareLaunch(options with { SavedDirSuffix = assets.Suffix }); } } -} \ No newline at end of file + + private Option Cancel(Exception error) { + dialogue.DisplayMessage($"Could not prepare profile assets: {error.Message}\nLaunch cancelled."); + return Option.None; + } +} From 86050965cb603d9ba6f30cd99934090fe93794e4 Mon Sep 17 00:00:00 2001 From: Jacoby6000 <2672832+Jacoby6000@users.noreply.github.com> Date: Tue, 20 Oct 2026 21:11:20 +0000 Subject: [PATCH 56/58] [GHA] Format Source --- UnchainedLauncher.GUI/Services/ProfileModsLaunchPreparer.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/UnchainedLauncher.GUI/Services/ProfileModsLaunchPreparer.cs b/UnchainedLauncher.GUI/Services/ProfileModsLaunchPreparer.cs index 347539fe..e152a8a1 100644 --- a/UnchainedLauncher.GUI/Services/ProfileModsLaunchPreparer.cs +++ b/UnchainedLauncher.GUI/Services/ProfileModsLaunchPreparer.cs @@ -43,4 +43,4 @@ private Option Cancel(Exception error) { dialogue.DisplayMessage($"Could not prepare profile assets: {error.Message}\nLaunch cancelled."); return Option.None; } -} +} \ No newline at end of file From 18868f6d0dbbaff541802c171d7a04ce9333f379 Mon Sep 17 00:00:00 2001 From: Jacob Barber Date: Tue, 20 Oct 2026 16:40:11 -0500 Subject: [PATCH 57/58] Protect GUI asset preparation and share dependency range parsing --- .../DllManagerTests.cs | 81 +++++++ .../Services/Dlls/DllRelease.cs | 3 +- .../Services/Mods/DllLaunchSession.cs | 4 +- .../Chivalry/LaunchPreparers/NoSigPreparer.cs | 4 + .../ProfileModsLaunchPreparerTests.cs | 203 +++++++++++++++++- UnchainedLauncher.GUI/App.xaml.cs | 7 +- .../Services/ProfileModsLaunchPreparer.cs | 35 ++- 7 files changed, 319 insertions(+), 18 deletions(-) diff --git a/UnchainedLauncher.CLI.Tests/DllManagerTests.cs b/UnchainedLauncher.CLI.Tests/DllManagerTests.cs index 3f2c5de1..5283e66c 100644 --- a/UnchainedLauncher.CLI.Tests/DllManagerTests.cs +++ b/UnchainedLauncher.CLI.Tests/DllManagerTests.cs @@ -24,6 +24,87 @@ private static DllRelease Release(string id, string version = "v1.0.0", string t }, null); private static DllPackage Package(params DllRelease[] releases) => new(releases[0].Info, releases.ToList()); + [Theory] + [InlineData(">=v1.0.0,<2.0.0")] + [InlineData("^v1.0.0")] + [InlineData(" >=v1.0.0 , =1.0.0,<2.0.0")] + [InlineData("^1.0.0")] + [InlineData("v1.0.0")] + [InlineData("==v1.0.0")] + [InlineData("!=v2.0.0")] + public async Task RegisteredLocalPackagesUseTheSameDependencyGrammarDuringSessionPreparation(string requirement) { + var root = Path.Combine(Environment.GetEnvironmentVariable("TMPDIR") ?? (OperatingSystem.IsLinux() ? "/tmp/opencode" : Path.GetTempPath()), + "dll-registered-range-" + Guid.NewGuid().ToString("N")); + try { + var core = Release("Chiv2-Community/UnchainedPlugin", dependencies: [new Dependency("https://github.com/org/dependency", requirement)]) + with { DllFileName = "UnchainedPlugin.dll" }; + var dependency = Release("org/dependency") with { DllFileName = "dependency.dll" }; + var newer = dependency with { Tag = "v2.0.0" }; + foreach (var package in new[] { Package(core), Package(dependency, newer) }) { + var id = package.LatestReleaseInfo.Identifier; + var directory = Path.Combine(root, "registry", "dll_db", "packages", id.Org); + Directory.CreateDirectory(directory); + File.WriteAllText(Path.Combine(directory, id.ModuleName + ".json"), JsonHelpers.Serialize(package)); + } + var registry = new AggregateModRegistry(new LocalModRegistry(Path.Combine(root, "registry"))); + var catalog = await DllCatalog.ReadAsync(registry, CancellationToken.None); + Assert.Empty(catalog.Errors); + Assert.Equal(2, catalog.Packages.Count); + var resolved = DllManager.Resolve(catalog.Packages, [], false, false); + Assert.Equal(new[] { dependency.Info.RepoUrl, core.Info.RepoUrl }, resolved.Select(r => r.Info.RepoUrl)); + Assert.All(resolved, r => Assert.Equal("v1.0.0", r.Tag)); + var assets = new ProfileAssetPaths("Range", root); + foreach (var release in new[] { core, dependency }) { + var cache = Path.Combine(assets.DllCache, release.Hash, release.DllFileName); + Directory.CreateDirectory(Path.GetDirectoryName(cache)!); + File.WriteAllBytes(cache, [1, 2, 3]); + } + var mods = new ModManager(registry, assets.CreatePakDir(root), []); + var session = new DllManager(registry, mods, assets.Plugins, cacheDirectory: assets.DllCache); + var options = new LaunchOptions([], Option.None, "", false, false, Option.None, Option.None); + Assert.True((await session.PrepareLaunch(options)).IsSome); + Assert.Equal(2, Directory.GetFiles(assets.Plugins, "*.dll", SearchOption.AllDirectories).Length); + foreach (var file in Directory.GetFiles(assets.Plugins, "*.dll", SearchOption.AllDirectories)) + Assert.Equal(new byte[] { 1, 2, 3 }, File.ReadAllBytes(file)); + using var inventory = System.Text.Json.JsonDocument.Parse(File.ReadAllText(Path.Combine(assets.Plugins, "dll_manager.json"))); + Assert.Equal(new[] { dependency.Info.RepoUrl, core.Info.RepoUrl }, + inventory.RootElement.EnumerateArray().Select(item => item.GetProperty("Repository").GetString())); + } + finally { Directory.Delete(root, true); } + } + + [Theory] + [InlineData("^vv1.0.0")] + [InlineData(">=v1.0,<2.0.0")] + [InlineData(">=v01.0.0")] + [InlineData(">=V1.0.0")] + [InlineData("=v1.0.0")] + [InlineData(">=v1.0.0,garbage")] + [InlineData(">=v1.0.0,")] + public async Task MalformedRegisteredRequirementsFailBeforeSessionMutatesPlugins(string requirement) { + var root = Path.Combine(Environment.GetEnvironmentVariable("TMPDIR") ?? (OperatingSystem.IsLinux() ? "/tmp/opencode" : Path.GetTempPath()), + "dll-invalid-range-" + Guid.NewGuid().ToString("N")); + try { + var core = Release("Chiv2-Community/UnchainedPlugin", dependencies: [new Dependency("https://github.com/org/dependency", requirement)]); + var directory = Path.Combine(root, "registry", "dll_db", "packages", "Chiv2-Community"); + Directory.CreateDirectory(directory); + File.WriteAllText(Path.Combine(directory, "UnchainedPlugin.json"), JsonHelpers.Serialize(Package(core))); + var registry = new AggregateModRegistry(new LocalModRegistry(Path.Combine(root, "registry"))); + var catalog = await DllCatalog.ReadAsync(registry, CancellationToken.None); + Assert.NotEmpty(catalog.Errors); + var assets = new ProfileAssetPaths("Invalid", root); + var mods = new ModManager(registry, assets.CreatePakDir(root), []); + var session = new DllManager(registry, mods, assets.Plugins, cacheDirectory: assets.DllCache); + var options = new LaunchOptions([], Option.None, "", false, false, Option.None, Option.None); + var error = await Assert.ThrowsAsync(() => session.PrepareLaunch(options)); + Assert.Contains("DLL registry loading failed", error.Message); + Assert.False(Directory.Exists(assets.Plugins)); + Assert.False(Directory.Exists(assets.DllCache)); + } + finally { Directory.Delete(root, true); } + } + [Theory] [InlineData("^1.0.0", true)] [InlineData(">=v1.0.0,<2.0.0", true)] diff --git a/UnchainedLauncher.Core/Services/Dlls/DllRelease.cs b/UnchainedLauncher.Core/Services/Dlls/DllRelease.cs index 3b3bdd37..f82401c3 100644 --- a/UnchainedLauncher.Core/Services/Dlls/DllRelease.cs +++ b/UnchainedLauncher.Core/Services/Dlls/DllRelease.cs @@ -88,7 +88,8 @@ public void Validate() { public static bool MatchesVersion(string version, string requirement) { var candidate = SemVersion.Parse(version, SemVersionStyles.Strict); if (string.IsNullOrEmpty(requirement)) throw new InvalidDataException("Missing dependency version."); - if (requirement.StartsWith('v')) requirement = requirement[1..]; + requirement = string.Join(",", requirement.Split(',').Select(part => + Regex.Replace(part.Trim(), @"^(\^|>=|<=|==|!=|>|<)?v", "$1"))); if (requirement.StartsWith('^')) { var lower = SemVersion.Parse(requirement[1..], SemVersionStyles.Strict); var upper = lower.Major != 0 ? new SemVersion(checked(lower.Major + 1), 0, 0) diff --git a/UnchainedLauncher.Core/Services/Mods/DllLaunchSession.cs b/UnchainedLauncher.Core/Services/Mods/DllLaunchSession.cs index 6c5c8f25..cef74c4f 100644 --- a/UnchainedLauncher.Core/Services/Mods/DllLaunchSession.cs +++ b/UnchainedLauncher.Core/Services/Mods/DllLaunchSession.cs @@ -42,9 +42,7 @@ public DllLaunchSession(AggregateModRegistry registry, IModManager mods, string public static bool Satisfies(DllRelease release, string requirement) => release.Version is { } version && SatisfiesVersion(version, requirement); - public static bool SatisfiesVersion(SemVersion version, string requirement) => PluginInfo.MatchesVersion(version.ToString(), - string.Join(",", requirement.Split(',').Select(part => - System.Text.RegularExpressions.Regex.Replace(part.Trim(), @"^(\^|>=|<=|==|!=|>|<)?v", "$1")))); + public static bool SatisfiesVersion(SemVersion version, string requirement) => PluginInfo.MatchesVersion(version.ToString(), requirement); public static IReadOnlyList Resolve(IReadOnlyList packages, IEnumerable requested, bool server, bool allowUnstable) { diff --git a/UnchainedLauncher.Core/Services/Processes/Chivalry/LaunchPreparers/NoSigPreparer.cs b/UnchainedLauncher.Core/Services/Processes/Chivalry/LaunchPreparers/NoSigPreparer.cs index c06dba4e..bbee502b 100644 --- a/UnchainedLauncher.Core/Services/Processes/Chivalry/LaunchPreparers/NoSigPreparer.cs +++ b/UnchainedLauncher.Core/Services/Processes/Chivalry/LaunchPreparers/NoSigPreparer.cs @@ -1,6 +1,7 @@ using LanguageExt; using log4net; using UnchainedLauncher.Core.Services.Mods; +using UnchainedLauncher.Core.Utilities; using static LanguageExt.Prelude; namespace UnchainedLauncher.Core.Services.Processes.Chivalry.LaunchPreparers { @@ -19,6 +20,9 @@ public class NoSigPreparer : IChivalry2LaunchPreparer { public static IChivalry2LaunchPreparer Create(IPakDir pakDir, IUserDialogueSpawner userDialogueSpawner) => new NoSigPreparer(pakDir, userDialogueSpawner); + public static IChivalry2LaunchPreparer CreateForInstallation(string installation, IUserDialogueSpawner userDialogueSpawner) => + Create(new PakDir(Path.Combine(installation, FilePaths.PakDir), []), userDialogueSpawner); + private NoSigPreparer(IPakDir pakDir, IUserDialogueSpawner userDialogueSpawner) { PakDir = pakDir; _userDialogueSpawner = userDialogueSpawner; diff --git a/UnchainedLauncher.GUI.Tests.Unit/ProfileModsLaunchPreparerTests.cs b/UnchainedLauncher.GUI.Tests.Unit/ProfileModsLaunchPreparerTests.cs index 27c2914c..1ae8e62e 100644 --- a/UnchainedLauncher.GUI.Tests.Unit/ProfileModsLaunchPreparerTests.cs +++ b/UnchainedLauncher.GUI.Tests.Unit/ProfileModsLaunchPreparerTests.cs @@ -16,6 +16,190 @@ namespace UnchainedLauncher.GUI.Tests.Unit; public sealed class ProfileModsLaunchPreparerTests { + [Fact] + public async Task VanillaLaunchOnlyUnsignsInstallationEvenWhileProfileIsLeased() { + var root = Scratch("gui-vanilla-boundary-"); + try { + var installationPaks = Path.Combine(root, FilePaths.PakDir); + Directory.CreateDirectory(installationPaks); + var baseSig = Path.Combine(installationPaks, "pakchunk0-WindowsNoEditor.sig"); + var legacySig = Path.Combine(installationPaks, "legacy.sig"); + File.WriteAllText(baseSig, "base signature"); + File.WriteAllText(legacySig, "legacy signature"); + File.WriteAllText(Path.ChangeExtension(legacySig, ".pak"), "legacy payload"); + var assets = new ProfileAssetPaths(ProfileAssetPaths.SharedSuffix, root); + Directory.CreateDirectory(assets.Paks); + File.WriteAllText(Path.Combine(assets.Paks, "unknown.sig"), "profile signature"); + File.WriteAllText(Path.Combine(assets.Paks, "unknown.pak"), "profile payload"); + File.WriteAllText(Path.Combine(assets.Paks, FilePaths.ManagedPakInventoryFileName), "profile inventory"); + var before = Directory.GetFiles(assets.Paks).ToDictionary(path => path, File.ReadAllBytes); + using var held = assets.AcquireLease(); + var dialogue = new Dialogue(); + var process = new CapturingProcessLauncher(); + var launcher = new Chivalry2Launcher(NoSigPreparer.CreateForInstallation(root, dialogue) + .IgnoreOptions(), process, root); + + Assert.True((await launcher.Launch(Options(null))).IsLeft); + Assert.Equal(1, process.Calls); + Assert.False(File.Exists(legacySig)); + Assert.Equal("legacy payload", File.ReadAllText(Path.ChangeExtension(legacySig, ".pak"))); + Assert.Equal("base signature", File.ReadAllText(baseSig)); + Assert.Equal(before.Keys.Order(), Directory.GetFiles(assets.Paks).Order()); + foreach (var pair in before) Assert.Equal(pair.Value, File.ReadAllBytes(pair.Key)); + Assert.Empty(dialogue.Messages); + } + finally { Directory.Delete(root, true); } + } + + [Theory] + [InlineData(null, true)] + [InlineData("AAA", true)] + [InlineData("AAA", false)] + [InlineData("zzz", true)] + [InlineData("zzz", false)] + public async Task CombinedPipelineLocksBothProfilesBeforeEnteringRealDllPreparer(string? suffix, bool holdShared) { + var root = Scratch("gui-combined-held-"); + try { + var shared = new ProfileAssetPaths(ProfileAssetPaths.SharedSuffix, root); + var selected = new ProfileAssetPaths(suffix ?? ProfileAssetPaths.SharedSuffix, root); + var manager = new ModManager(new LocalModRegistry(Path.Combine(root, "registry")), shared.CreatePakDir(root), []); + Directory.CreateDirectory(selected.Paks); + File.WriteAllText(Path.Combine(selected.Paks, "unknown.sig"), "profile signature"); + var dialogue = new Dialogue(); + using var http = new HttpClient(); + var (dll, active) = CachedDll(shared, http); + var entered = 0; + var preceding = new CallbackPreparer(async options => { + entered++; + return await new DllLaunchPreparer(dll, dialogue).PrepareLaunch(options); + }); + var preparer = new ProfileModsLaunchPreparer(manager, dialogue, root, root, preceding); + var heldAssets = holdShared ? shared : selected; + using (heldAssets.AcquireLease()) { + var before = Directory.GetFiles(root, "*", SearchOption.AllDirectories) + .Where(path => !path.EndsWith(".instance.lock")).ToDictionary(path => path, File.ReadAllBytes); + var process = new CapturingProcessLauncher(); + var launcher = new UnchainedChivalry2Launcher(preparer, process, root, new NullInjector()); + Assert.True((await launcher.Launch(Options(suffix))).IsLeft); + Assert.Equal(0, process.Calls); + Assert.Equal(0, entered); + Assert.False(File.Exists(active)); + Assert.Contains("another launcher", Assert.Single(dialogue.Messages)); + Assert.Equal(before.Keys.Order(), Directory.GetFiles(root, "*", SearchOption.AllDirectories) + .Where(path => !path.EndsWith(".instance.lock")).Order()); + foreach (var pair in before) Assert.Equal(pair.Value, File.ReadAllBytes(pair.Key)); + // The lower-sorted profile is acquired first, regardless of which one holds DLLs. + if (shared.Root != selected.Root) { + var first = new[] { shared, selected }.OrderBy(p => p.Root, StringComparer.OrdinalIgnoreCase).First(); + var second = first == shared ? selected : shared; + if (heldAssets == second) { + Assert.True(File.Exists(Path.Combine(first.Root, ".instance.lock"))); + using (first.AcquireLease()) { } + } + else Assert.False(File.Exists(Path.Combine(second.Root, ".instance.lock"))); + } + } + Assert.True((await preparer.PrepareLaunch(Options(suffix))).IsSome); + Assert.Equal(1, entered); + Assert.Equal(new byte[] { 1, 2, 3 }, File.ReadAllBytes(active)); + Assert.Empty(ReadInventory(selected.Paks)); + Assert.Equal("profile signature", File.ReadAllText(Path.Combine(selected.Paks, "unknown.sig"))); + using (shared.AcquireLease()) { } + using (selected.AcquireLease()) { } + } + finally { Directory.Delete(root, true); } + } + + [Theory] + [InlineData(null, "success")] + [InlineData("un-chained", "success")] + [InlineData("Other", "success")] + [InlineData("Other", "cancel")] + [InlineData("Other", "error")] + [InlineData("Other", "throw-cancel")] + public async Task CombinedPipelineHoldsLeasesAcrossAsyncDllPreparationAndRebindsAfterSelection(string? suffix, string outcome) { + var root = Scratch("gui-combined-async-"); + var finish = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + Task>? pending = null; + try { + var shared = new ProfileAssetPaths(ProfileAssetPaths.SharedSuffix, root); + var selected = new ProfileAssetPaths(suffix ?? ProfileAssetPaths.SharedSuffix, root); + var installationPaks = Path.Combine(root, FilePaths.PakDir); + Directory.CreateDirectory(installationPaks); + File.WriteAllText(Path.Combine(installationPaks, "pakchunk0-WindowsNoEditor.sig"), "signature"); + var payload = Path.Combine(root, "source.pak"); + File.WriteAllText(payload, "pak payload"); + var hash = FileHelpers.Sha512(payload).Match(value => value, error => throw new Exception(error.Message)); + var release = new Release("v1.0.0", hash, "mod.pak", DateTime.UnixEpoch, + new ModInfo("https://github.com/org/mod", "mod", "test", null, null, [], [], []), + new AssetCollections([], [], [], [], []), null); + var registry = new LocalModRegistry(Path.Combine(root, "registry")); + await registry.AddRelease(release, payload); + var manager = new ModManager(registry, shared.CreatePakDir(root), []); + Assert.False((await manager.UpdateModsList()).HasErrors); + var started = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var dialogue = new Dialogue(); + using var http = new HttpClient(); + var (dll, active) = CachedDll(shared, http); + Exception error = outcome == "throw-cancel" ? new OperationCanceledException("original cancellation") + : new InvalidOperationException("original DLL error"); + dialogue.ProgressStarted = () => { + Assert.True(File.Exists(active)); + Assert.Throws(() => shared.AcquireLease()); + Assert.Throws(() => selected.AcquireLease()); + }; + var preceding = new CallbackPreparer(async options => { + Assert.Empty(Directory.GetFiles(selected.Paks, "*.pak")); + started.SetResult(); + await finish.Task; + if (outcome is "error" or "throw-cancel") throw error; + if (outcome == "cancel") return Option.None; + Assert.True(manager.EnableModRelease(ReleaseCoordinates.FromRelease(release))); + return await new DllLaunchPreparer(dll, dialogue).PrepareLaunch(options); + }); + var preparer = new ProfileModsLaunchPreparer(manager, dialogue, root, root, preceding); + pending = preparer.PrepareLaunch(Options(suffix)); + await started.Task.WaitAsync(TimeSpan.FromSeconds(10)); + Assert.Throws(() => shared.AcquireLease()); + Assert.Throws(() => selected.AcquireLease()); + Assert.False(File.Exists(active)); + finish.SetResult(); + if (outcome is "error" or "throw-cancel") Assert.Same(error, await Assert.ThrowsAnyAsync(() => pending)); + else Assert.Equal(outcome == "success", (await pending.WaitAsync(TimeSpan.FromSeconds(10))).IsSome); + using (shared.AcquireLease()) { } + using (selected.AcquireLease()) { } + if (outcome == "success") { + Assert.True(File.Exists(active)); + Assert.Equal(ReleaseCoordinates.FromRelease(release), Assert.Single(ReadInventory(selected.Paks)).Coordinates); + Assert.Single(manager.EnabledModReleaseCoordinates); + } + else { + Assert.False(File.Exists(active)); + Assert.Empty(Directory.GetFiles(selected.Paks)); + } + Assert.Empty(dialogue.Messages); + } + finally { + finish.TrySetResult(); + if (pending is not null) try { await pending; } catch { } + Directory.Delete(root, true); + } + } + + private static (UnchainedLauncher.Core.Services.Dlls.DllManager Manager, string Active) CachedDll(ProfileAssetPaths shared, HttpClient http) { + var release = new UnchainedLauncher.Core.Services.Dlls.DllRelease("org", "plugin", "1.0.0", + "https://example.test/plugin.dll", Convert.ToHexString(System.Security.Cryptography.SHA256.HashData([1, 2, 3])).ToLowerInvariant(), 3); + var cache = Path.Combine(shared.DllCache, release.Sha256, "plugin.dll"); + Directory.CreateDirectory(Path.GetDirectoryName(cache)!); + File.WriteAllBytes(cache, [1, 2, 3]); + return (UnchainedLauncher.Core.Services.Dlls.DllManager.CreateForLaunch(shared.Plugins, [release], [release], http, () => [], shared.DllCache), + Path.Combine(shared.Plugins, "Managed", "org", "plugin", "plugin.dll")); + } + + private sealed class CallbackPreparer(Func>> prepare) : IChivalry2LaunchPreparer { + public Task> PrepareLaunch(LaunchOptions options) => prepare(options); + } + [Theory] [InlineData(true)] [InlineData(false)] @@ -140,7 +324,13 @@ public async Task InvalidExplicitSuffixCancelsBeforeCreatingProfileAssets(string var manager = new ModManager(registry, new PakDir(Path.Combine(root, "source"), []), []); var before = Directory.GetFileSystemEntries(root, "*", SearchOption.AllDirectories).Order().ToArray(); var dialogue = new Dialogue(); - var result = await new ProfileModsLaunchPreparer(manager, dialogue, root, root).PrepareLaunch(Options(suffix)); + var entered = false; + var preceding = new CallbackPreparer(options => { + entered = true; + return Task.FromResult(Option.Some(options)); + }); + var result = await new ProfileModsLaunchPreparer(manager, dialogue, root, root, preceding).PrepareLaunch(Options(suffix)); + Assert.False(entered); Assert.True(result.IsNone); Assert.Contains("Invalid saved directory suffix", Assert.Single(dialogue.Messages)); Assert.Equal(before, Directory.GetFileSystemEntries(root, "*", SearchOption.AllDirectories).Order().ToArray()); @@ -235,13 +425,19 @@ public async Task ObstructedProfilePaksCancelsLaunchAndAllowsRetry(string? suffi byte[] obstruction = [0, 1, 255, 42]; File.WriteAllBytes(assets.Paks, obstruction); var dialogue = new Dialogue(); - var preparer = new ProfileModsLaunchPreparer(manager, dialogue, root, root); + var entered = 0; + var preceding = new CallbackPreparer(options => { + entered++; + return Task.FromResult(Option.Some(options)); + }); + var preparer = new ProfileModsLaunchPreparer(manager, dialogue, root, root, preceding); var process = new CapturingProcessLauncher(); var launcher = new UnchainedChivalry2Launcher(preparer, process, root, new NullInjector()); Assert.True((await preparer.PrepareLaunch(Options(suffix))).IsNone); Assert.True((await launcher.Launch(Options(suffix))).IsLeft); Assert.Equal(0, process.Calls); + Assert.Equal(0, entered); Assert.Equal(obstruction, File.ReadAllBytes(assets.Paks)); Assert.Equal(2, dialogue.Messages.Count); Assert.All(dialogue.Messages, message => { @@ -256,6 +452,7 @@ public async Task ObstructedProfilePaksCancelsLaunchAndAllowsRetry(string? suffi Assert.True((await preparer.PrepareLaunch(Options(suffix))).IsSome); Assert.True((await launcher.Launch(Options(suffix))).IsLeft); // Stop at the OS process boundary. Assert.Equal(1, process.Calls); + Assert.Equal(2, entered); Assert.Empty(dialogue.Messages); Assert.Equal(2, dialogue.ProgressClosed); using (assets.AcquireLease()) { } @@ -412,11 +609,13 @@ private sealed class Dialogue : IUserDialogueSpawner { public List Messages { get; } = []; public int ProgressClosed { get; private set; } public Exception? ProgressError { get; init; } + public Action? ProgressStarted { get; set; } public void DisplayMessage(string message) => Messages.Add(message); public UserDialogueChoice DisplayYesNoMessage(string message, string caption) => throw new NotSupportedException(); public UserDialogueChoice? DisplayUpdateMessage(string titleText, string messageText, string yesButtonText, string noButtonText, string? cancelButtonText, IEnumerable updates) => throw new NotSupportedException(); public Action DisplayProgress(MemoryProgress progress) { + ProgressStarted?.Invoke(); if (ProgressError is not null) throw ProgressError; return () => { ProgressClosed++; }; } diff --git a/UnchainedLauncher.GUI/App.xaml.cs b/UnchainedLauncher.GUI/App.xaml.cs index a96b887b..6ed299d7 100644 --- a/UnchainedLauncher.GUI/App.xaml.cs +++ b/UnchainedLauncher.GUI/App.xaml.cs @@ -173,12 +173,12 @@ select chiv2Installations.Path new ProcessLauncher(Path.Combine(Directory.GetCurrentDirectory(), FilePaths.GameBinPath)); #endif - var noSigLaunchPreparer = NoSigPreparer.Create(modManager.PakDir, userDialogueSpawner); + var noSigLaunchPreparer = NoSigPreparer.CreateForInstallation(Directory.GetCurrentDirectory(), userDialogueSpawner); IChivalry2LaunchPreparer pluginInstaller = new DllLaunchPreparer(dllManager, userDialogueSpawner); IChivalry2LaunchPreparer modInstaller = new ProfileModsLaunchPreparer( - modManager, userDialogueSpawner, Directory.GetCurrentDirectory() + modManager, userDialogueSpawner, Directory.GetCurrentDirectory(), precedingDllPreparer: pluginInstaller ); var vanillaLauncher = new Chivalry2Launcher( @@ -188,8 +188,7 @@ select chiv2Installations.Path ); var unchainedLauncher = new UnchainedChivalry2Launcher( - pluginInstaller - .AndThen(modInstaller), + modInstaller, unchainedProcessLauncher, Directory.GetCurrentDirectory(), #if DEBUG_FAKECHIVALRYLAUNCH diff --git a/UnchainedLauncher.GUI/Services/ProfileModsLaunchPreparer.cs b/UnchainedLauncher.GUI/Services/ProfileModsLaunchPreparer.cs index e152a8a1..515ac548 100644 --- a/UnchainedLauncher.GUI/Services/ProfileModsLaunchPreparer.cs +++ b/UnchainedLauncher.GUI/Services/ProfileModsLaunchPreparer.cs @@ -1,6 +1,8 @@ using LanguageExt; using System; +using System.Collections.Generic; using System.IO; +using System.Linq; using System.Threading.Tasks; using UnchainedLauncher.Core.Services; using UnchainedLauncher.Core.Services.Mods; @@ -10,7 +12,8 @@ namespace UnchainedLauncher.GUI.Services; public sealed class ProfileModsLaunchPreparer( - ModManager manager, IUserDialogueSpawner dialogue, string installation, string? localAppData = null) + ModManager manager, IUserDialogueSpawner dialogue, string installation, string? localAppData = null, + IChivalry2LaunchPreparer? precedingDllPreparer = null) : IChivalry2LaunchPreparer { public async Task> PrepareLaunch(LaunchOptions options) { ProfileAssetPaths assets; @@ -20,12 +23,25 @@ public async Task> PrepareLaunch(LaunchOptions options) { catch (ArgumentException e) { return Cancel(e); } - IDisposable lease; - try { lease = assets.AcquireLease(); } - catch (Exception e) when (e is IOException or UnauthorizedAccessException) { - return Cancel(e); - } - using (lease) { + var profiles = precedingDllPreparer is null ? new[] { assets } + : new[] { assets, new ProfileAssetPaths(ProfileAssetPaths.SharedSuffix, localAppData) }; + var leases = new List(); + try { + try { + foreach (var profile in profiles.DistinctBy(p => p.Root, StringComparer.OrdinalIgnoreCase) + .OrderBy(p => p.Root, StringComparer.OrdinalIgnoreCase)) + leases.Add(profile.AcquireLease()); + Directory.CreateDirectory(assets.Paks); + } + catch (Exception e) when (e is IOException or UnauthorizedAccessException) { + return Cancel(e); + } + options = options with { SavedDirSuffix = assets.Suffix }; + if (precedingDllPreparer is not null) { + var prepared = await precedingDllPreparer.PrepareLaunch(options); + if (prepared.IsNone) return Option.None; + options = prepared.IfNone(options); + } ModManager profileManager; try { profileManager = assets.Rebind(manager, installation); } catch (Exception e) when (e is IOException or UnauthorizedAccessException) { @@ -35,7 +51,10 @@ public async Task> PrepareLaunch(LaunchOptions options) { IChivalry2LaunchPreparer installer = new Chivalry2ModsInstaller(profileManager, dialogue); return await installer .Sub(SigPreparer.CreateForProfile(profileManager.PakDir, dialogue)) - .PrepareLaunch(options with { SavedDirSuffix = assets.Suffix }); + .PrepareLaunch(options); + } + finally { + for (var i = leases.Count - 1; i >= 0; i--) leases[i].Dispose(); } } From 8be3d51efeb037d9da83949b41143b3763b4905e Mon Sep 17 00:00:00 2001 From: Jacob Barber Date: Tue, 20 Oct 2026 23:19:29 -0500 Subject: [PATCH 58/58] Remove duplicate validation and reuse dashboard and cleanup data --- .../ServerGroupTests.cs | 30 ++++++++++++++ .../YamlConfigurationTests.cs | 41 +++++++++++++++++++ UnchainedLauncher.CLI/CliLaunchOptions.cs | 2 - UnchainedLauncher.CLI/ServerGroupRunner.cs | 3 +- .../HelpVMTests.cs | 31 +++++++++----- UnchainedLauncher.GUI/ViewModels/HelpVM.cs | 16 ++++---- 6 files changed, 102 insertions(+), 21 deletions(-) diff --git a/UnchainedLauncher.CLI.Tests/ServerGroupTests.cs b/UnchainedLauncher.CLI.Tests/ServerGroupTests.cs index a40be072..01bf64f3 100644 --- a/UnchainedLauncher.CLI.Tests/ServerGroupTests.cs +++ b/UnchainedLauncher.CLI.Tests/ServerGroupTests.cs @@ -4,6 +4,7 @@ namespace UnchainedLauncher.CLI.Tests; +[Collection("Installation directory")] public sealed class ServerGroupTests : IDisposable { private readonly string _root = Path.Combine(System.Environment.GetEnvironmentVariable("TMPDIR") ?? Path.GetTempPath(), "server-group-" + Guid.NewGuid().ToString("N")); @@ -158,6 +159,35 @@ private static Process Child(int? exitCode = null) { return Process.Start(info)!; } + [Fact] + public async Task DashboardRetainsEachConfigurationAfterLaunchAndQueryInitialization() { + var group = Group(); + using var stop = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + using var output = new StringWriter(); + var original = Console.Out; + var launches = 0; + var queries = 0; + try { + Console.SetOut(output); + Assert.Equal(0, await ServerGroupRunner.RunAsync(group, stop, (options, _) => { + options.Settings.Name = $"prepared-{++launches}"; + return Task.FromResult(Child()); + }, options => { + Assert.Equal(2, launches); + options.Settings.Name += $"-query-{++queries}"; + if (queries == 2) stop.CancelAfter(250); + return new PendingQuery(); + }).WaitAsync(TimeSpan.FromSeconds(15))); + } + finally { Console.SetOut(original); } + var text = output.ToString(); + Assert.Contains("[courtyard] Starting | prepared-1-query-1 | PID", text); + Assert.Contains("[arena] Starting | prepared-2-query-2 | PID", text); + Assert.Contains("Game Port 7000 | Query Port 7002 | Beacon Port 7001 | RCON Port 7003", text); + Assert.Contains("Game Port 7004 | Query Port 7006 | Beacon Port 7005 | RCON Port 7007", text); + Assert.Equal(2, queries); + } + [Fact] public async Task StartupFailureStopsAlreadyLaunchedServers() { using var stop = new CancellationTokenSource(); diff --git a/UnchainedLauncher.CLI.Tests/YamlConfigurationTests.cs b/UnchainedLauncher.CLI.Tests/YamlConfigurationTests.cs index fa7080cf..bb00cc99 100644 --- a/UnchainedLauncher.CLI.Tests/YamlConfigurationTests.cs +++ b/UnchainedLauncher.CLI.Tests/YamlConfigurationTests.cs @@ -24,6 +24,47 @@ private string Write(string body, bool includeRequired = true) { private CliLaunchOptions Load(string body, bool client = false) => CliLaunchOptions.Parse([client ? "client" : "server", "--config-path", Write(body)]); + [Theory] + [InlineData("a", "a")] + [InlineData("Z09_", "Z09_")] + [InlineData("_", "_")] + [InlineData("-Host-one-", "Hostone")] + public void CliAndYamlSuffixesUseTheSameProfileNormalization(string suffix, string normalized) { + foreach (var command in new[] { "client", "server" }) { + var cli = CliLaunchOptions.Parse([command, "--chivalry2-path", ".", "--platform", "Steam", "--saved-dir-suffix=" + suffix]); + var yaml = Load(command == "client" ? $"saved-dir-suffix: '{suffix}'" : $"server: {{saved-dir-suffix: '{suffix}'}}", command == "client"); + foreach (var options in new[] { cli, yaml }) { + Assert.Equal(suffix, options.Shared.SavedDirSuffix); + Assert.Equal(normalized, options.Shared.Assets.Suffix); + } + } + } + + [Theory] + [InlineData("")] + [InlineData("---")] + [InlineData("é")] + [InlineData("A")] + [InlineData("../outside")] + [InlineData("with space")] + [InlineData("line\nbreak")] + [InlineData("name\\other")] + public void CliAndYamlRejectInvalidSuffixesAtTheProfileBoundary(string suffix) { + var expected = Assert.Throws(() => new UnchainedLauncher.Core.Services.Mods.ProfileAssetPaths(suffix)); + foreach (var command in new[] { "client", "server" }) { + var cli = Assert.Throws(() => CliLaunchOptions.Parse([ + command, "--chivalry2-path", ".", "--platform", "Steam", "--saved-dir-suffix=" + suffix, "--mod=invalid"])); + var quoted = System.Text.Json.JsonSerializer.Serialize(suffix); + var yaml = Assert.Throws(() => Load(command == "client" + ? $"saved-dir-suffix: {quoted}\nmod-setup: {{mods: [invalid]}}" + : $"server: {{saved-dir-suffix: {quoted}, mod-setup: {{mods: [invalid]}}}}", command == "client")); + if (suffix.Length == 0) Assert.Contains("Required argument missing for option: '--saved-dir-suffix'", cli.Message); + else Assert.Equal(expected.Message, cli.Message); + if (suffix.Length == 0 && command == "client") Assert.Contains("The SavedDirSuffix field is required.", yaml.Message); + else Assert.Equal(expected.Message, yaml.Message); + } + } + [Theory] [InlineData(false, "::1")] [InlineData(true, "::1")] diff --git a/UnchainedLauncher.CLI/CliLaunchOptions.cs b/UnchainedLauncher.CLI/CliLaunchOptions.cs index bdfb3cba..85b59f26 100644 --- a/UnchainedLauncher.CLI/CliLaunchOptions.cs +++ b/UnchainedLauncher.CLI/CliLaunchOptions.cs @@ -40,8 +40,6 @@ internal SharedLaunchOptions(LaunchConfiguration configuration, string directory RegistryOptions = new RegistryOptions(setup.Registries, directory); Mods = Entries(setup.Mods).Select(ParseCoordinates).ToArray(); Dlls = Entries(setup.Dlls).Select(ParseCoordinates).ToArray(); - if (!Regex.IsMatch(SavedDirSuffix, @"\A[A-Za-z0-9_-]+\z")) - throw new ArgumentException("saved-dir-suffix may contain only letters, digits, underscores, and hyphens."); if (!Uri.TryCreate(Backend, UriKind.Absolute, out var backend) || backend.Scheme is not ("http" or "https")) throw new ArgumentException("server-browser-backend must be an HTTP(S) URL."); if (Mods.GroupBy(m => (m.Org, m.ModuleName)).Any(g => g.Select(m => m.Version).Distinct().Count() > 1)) diff --git a/UnchainedLauncher.CLI/ServerGroupRunner.cs b/UnchainedLauncher.CLI/ServerGroupRunner.cs index b1546022..9103f12b 100644 --- a/UnchainedLauncher.CLI/ServerGroupRunner.cs +++ b/UnchainedLauncher.CLI/ServerGroupRunner.cs @@ -30,6 +30,7 @@ internal static async Task RunAsync(ServerGroupOptions group, CancellationT tabNames: group.Servers.Select(entry => entry.Id).ToArray()); for (var i = 0; i < processes.Count; i++) tasks.Add(MonitorAsync(i)); + var launchOptions = group.Servers.Select(entry => entry.Options.ToServerLaunchOptions()).ToArray(); using var timer = new PeriodicTimer(TimeSpan.FromMilliseconds(100)); while (true) { if (dashboard.StopRequested()) stop.Cancel(); @@ -37,7 +38,7 @@ internal static async Task RunAsync(ServerGroupOptions group, CancellationT if (dashboard.IsInteractive && i != dashboard.SelectedTab) continue; var snapshot = Volatile.Read(ref snapshots[i]); if (snapshot is not null) dashboard.Render(processes[i].Id, snapshot.Sample, snapshot.History, - group.Servers[i].Options.ToServerLaunchOptions(), $"[{group.Servers[i].Id}] {snapshot.Status}"); + launchOptions[i], $"[{group.Servers[i].Id}] {snapshot.Status}"); } if (stop.IsCancellationRequested || tasks.All(task => task.IsCompleted)) break; if (tasks.Any(task => task.IsFaulted)) diff --git a/UnchainedLauncher.GUI.Tests.Unit/HelpVMTests.cs b/UnchainedLauncher.GUI.Tests.Unit/HelpVMTests.cs index f0f5465b..3640c3cf 100644 --- a/UnchainedLauncher.GUI.Tests.Unit/HelpVMTests.cs +++ b/UnchainedLauncher.GUI.Tests.Unit/HelpVMTests.cs @@ -17,29 +17,40 @@ public void CleanupConfirmationsDescribeActualPathsAndRetainedAssets(bool uninst var vm = new HelpVM(null!, null!, null!, paks, dialogue, _ => throw new InvalidOperationException("Must not exit after rejecting confirmation.")); - if (uninstall) vm.UninstallLauncherCommand.Execute(null); - else vm.CleanUpInstallationCommand.Execute(null); + foreach (var path in new[] { Path.Combine("confirmation-only", "Saved_unchained", "Paks"), "changed\nprofile\\[unknown]*\rPaks" }) { + ((ReadOnlyPakDir)paks).Path = path; + if (uninstall) vm.UninstallLauncherCommand.Execute(null); + else vm.CleanUpInstallationCommand.Execute(null); - var message = Assert.Single(dialogue.Messages); - Assert.Contains($"* All files in {new ProfileAssetPaths(ProfileAssetPaths.SharedSuffix).Plugins}", message); - Assert.Contains($"* All non-vanilla files in {paks.DirPath}", message); - Assert.Contains("* All files in .mod_cache", message); - Assert.Contains("Shared caches, other CLI profiles, and legacy installation-wide assets are retained.", message); - Assert.DoesNotContain("TBL\\Binaries\\Win64\\Plugins", message); - Assert.DoesNotContain("TBL\\Content\\Paks", message); + var intro = uninstall + ? "Are you sure? This will disable all mods, reset all settings to their defaults, and uninstall the launcher. This will delete the following:" + : "Are you sure? This will disable all mods and reset all settings to their defaults. This will delete the following:"; + var instruction = !uninstall ? "After deleting, the launcher will restart itself." + : File.Exists(FilePaths.OriginalLauncherPath) ? "IMPORTANT: Wait at least 1 second and then launch normally." + : "IMPORTANT: The original launcher is not present. You will need to verify game files after this so it can be re-downloaded."; + Assert.Equal(string.Join("\n", intro, "* All files in .mod_cache", + $"* All files in {new ProfileAssetPaths(ProfileAssetPaths.SharedSuffix).Plugins}", + $"* All non-vanilla files in {path}", + "Shared caches, other CLI profiles, and legacy installation-wide assets are retained.", "", instruction), dialogue.Messages[^1]); + Assert.Equal(uninstall ? "Really uninstall?" : "Really clean up installation?", dialogue.Captions[^1]); + } + Assert.Equal(2, dialogue.Messages.Count); } public class ReadOnlyPakDir : DispatchProxy { + public string Path { get; set; } = ""; protected override object? Invoke(MethodInfo? method, object?[]? args) => method?.Name == "get_DirPath" - ? Path.Combine("confirmation-only", "Saved_unchained", "Paks") + ? Path : throw new InvalidOperationException($"Unexpected pak operation: {method?.Name}"); } private sealed class RejectingDialogue : IUserDialogueSpawner { public List Messages { get; } = []; + public List Captions { get; } = []; public UserDialogueChoice DisplayYesNoMessage(string message, string caption) { Messages.Add(message); + Captions.Add(caption); return UserDialogueChoice.No; } public void DisplayMessage(string message) => throw new NotSupportedException(); diff --git a/UnchainedLauncher.GUI/ViewModels/HelpVM.cs b/UnchainedLauncher.GUI/ViewModels/HelpVM.cs index 318f727f..20a6ff6b 100644 --- a/UnchainedLauncher.GUI/ViewModels/HelpVM.cs +++ b/UnchainedLauncher.GUI/ViewModels/HelpVM.cs @@ -80,10 +80,7 @@ private void UninstallLauncher() { var originalExists = File.Exists(originalPath); var message = new List() { "Are you sure? This will disable all mods, reset all settings to their defaults, and uninstall the launcher. This will delete the following:", - "* All files in .mod_cache", - $"* All files in {new ProfileAssetPaths(ProfileAssetPaths.SharedSuffix).Plugins}", - $"* All non-vanilla files in {PakDir.DirPath}", - "Shared caches, other CLI profiles, and legacy installation-wide assets are retained.", + CleanupDisclosure(), "", originalExists ? "IMPORTANT: Wait at least 1 second and then launch normally." @@ -148,6 +145,12 @@ private void UninstallLauncher() { ExitProgram(0); } + private string CleanupDisclosure() => + "* All files in .mod_cache\n" + + $"* All files in {new ProfileAssetPaths(ProfileAssetPaths.SharedSuffix).Plugins}\n" + + $"* All non-vanilla files in {PakDir.DirPath}\n" + + "Shared caches, other CLI profiles, and legacy installation-wide assets are retained."; + private void CleanUpInstallation_actions() { FileHelpers.DeleteDirectory(FilePaths.ModCachePath); FileHelpers.DeleteDirectory(new ProfileAssetPaths(ProfileAssetPaths.SharedSuffix).Plugins); @@ -159,10 +162,7 @@ private void CleanUpInstallation() { Logger.Info("CleanUpInstallation button clicked."); var message = new List() { "Are you sure? This will disable all mods and reset all settings to their defaults. This will delete the following:", - "* All files in .mod_cache", - $"* All files in {new ProfileAssetPaths(ProfileAssetPaths.SharedSuffix).Plugins}", - $"* All non-vanilla files in {PakDir.DirPath}", - "Shared caches, other CLI profiles, and legacy installation-wide assets are retained.", + CleanupDisclosure(), "", "After deleting, the launcher will restart itself." }.Aggregate((accumulator, next) => accumulator + "\n" + next);