Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
53 commits
Select commit Hold shift + click to select a range
ba3995b
Add Core-backed server CLI and live resource dashboard
Jacoby6000 Oct 11, 2026
db7fae1
[GHA] Format Source
Jacoby6000 Oct 11, 2026
157c212
Dispose owned registry streams and verify server CLI production bound…
Jacoby6000 Oct 11, 2026
733c5bb
Add typed client and server CLI configuration with System.CommandLine
Jacoby6000 Oct 11, 2026
fbae4d6
[GHA] Format Source
Jacoby6000 Oct 11, 2026
87a7960
Parse CLI once and keep authoritative YAML free of response expansion
Jacoby6000 Oct 11, 2026
562c60d
Skip inactive CLI YAML nodes without schema validation
Jacoby6000 Oct 11, 2026
82c684d
Reject rendered CLI servers with the desync patch enabled
Jacoby6000 Oct 11, 2026
8a460b1
Preserve forwarded process argument boundaries with vetted quoting
Jacoby6000 Oct 11, 2026
45fc805
Ignore argument probe build outputs
Jacoby6000 Oct 11, 2026
337c1e6
Share managed pak ownership across GUI and CLI preparation
Jacoby6000 Oct 11, 2026
de6e065
Add registry queries and dll_db-backed DLL launch management
Jacoby6000 Oct 12, 2026
343f46f
Initialize server CLI profiles before launch
Jacoby6000 Oct 13, 2026
510f886
[GHA] Format Source
Jacoby6000 Oct 13, 2026
31c91c7
Use Plugin Manager DLL validation and cache for CLI launches
Jacoby6000 Oct 13, 2026
f0d4c69
[GHA] Format Source
Jacoby6000 Oct 13, 2026
60866f7
Merge main after DLL validation merge and reconcile CLI preparation
Jacoby6000 Oct 15, 2026
4b699f0
Isolate form regression test from background registry refresh
Jacoby6000 Oct 15, 2026
a9ea8d2
[GHA] Format Source
Jacoby6000 Oct 15, 2026
0b039df
Add compose-style server groups and tabbed server monitoring
Jacoby6000 Oct 15, 2026
9deb235
[GHA] Format Source
Jacoby6000 Oct 15, 2026
9283596
Resolve explicit DLL pins before dependency traversal
Jacoby6000 Oct 15, 2026
dacbaab
Isolate each server's pak and plugin directories
Jacoby6000 Oct 15, 2026
2377958
Revalidate pak dependencies after DLL preparation
Jacoby6000 Oct 15, 2026
895fa21
Use profile assets and shared hard-linked mod caches
Jacoby6000 Oct 16, 2026
68b659d
[GHA] Format Source
Jacoby6000 Oct 16, 2026
a9bc3f9
Rename CLI starting map and expand launch examples
Jacoby6000 Oct 16, 2026
079609f
Add typed per-server INI overrides and independent mod selections
Jacoby6000 Oct 16, 2026
207685a
[GHA] Format Source
Jacoby6000 Oct 16, 2026
15e93e2
Add YAML map rotations and reusable map profiles
Jacoby6000 Oct 19, 2026
c23e157
[GHA] Format Source
Jacoby6000 Oct 19, 2026
ffac44c
Handle monitor exit races and reject incomplete pak dependency sorts
Jacoby6000 Oct 19, 2026
c396931
Use native profile Paks directory for managed mods
Jacoby6000 Oct 20, 2026
fda17d2
Give managed mod paks native patch priority
Jacoby6000 Oct 20, 2026
50fa7f5
Replace alphabetic pak prefixes with numeric patch order
Jacoby6000 Oct 20, 2026
11edaa6
Preserve managed pak ownership during relocation failures
Jacoby6000 Oct 20, 2026
40fd633
Resolve shared DLL constraints before finalizing versions
Jacoby6000 Oct 20, 2026
4de14a6
[GHA] Format Source
Jacoby6000 Oct 20, 2026
7c81d86
docs(annals): add keyword index
Jacoby6000 Oct 20, 2026
99f2f71
Install GUI server mods into the selected native profile
Jacoby6000 Oct 20, 2026
ce5783d
Merge native profile pak paths and numeric patch ordering
Jacoby6000 Oct 20, 2026
fda52e8
Update YAML examples for native profile pak discovery
Jacoby6000 Oct 20, 2026
74fbbb1
Update launch and collision fixtures for numeric pak names
Jacoby6000 Oct 20, 2026
4d90af4
Merge remote-tracking branch 'origin/server-cli' into review/pr150-st…
Jacoby6000 Oct 20, 2026
333f2cf
Merge branch 'fix/native-profile-paks' into review/pr150-starting-map
Jacoby6000 Oct 20, 2026
08634e5
[GHA] Format Source
Jacoby6000 Oct 20, 2026
0754a25
Intersect DLL pak requirements before selecting releases
Jacoby6000 Oct 20, 2026
100ffb9
Merge remote-tracking branch 'origin/server-cli' into review/pr150-st…
Jacoby6000 Oct 20, 2026
a4c097c
Correct profile cleanup disclosures and documentation
Jacoby6000 Oct 20, 2026
306d8c8
[GHA] Format Source
Jacoby6000 Oct 20, 2026
86f7e58
Cancel launch safely when managed pak inventory is invalid
Jacoby6000 Oct 20, 2026
7eb19af
[GHA] Format Source
Jacoby6000 Oct 20, 2026
e10a833
Reject malformed managed pak inventory before reconciliation
Jacoby6000 Oct 20, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ on:
branches:
- 'main'
- 'series/*'
- 'feature/dll-validation'

env:
DOTNET_VERSION: '10.x'
Expand Down Expand Up @@ -56,6 +57,21 @@ 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: 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/

- 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/

Expand Down
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,12 @@ Join the [Chivalry 2 Unchained community on Discord](https://discord.gg/chiv2unc
4. Put the downloaded Unchained `Chivalry2Launcher.exe` in place of the old one.
5. Launch the game normally (either from your steam library or from Epic Games Store).

## Launcher CLI

`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, YAML configuration, and the live server dashboard.

## License

This project is licensed under the terms of the [LICENSE](LICENSE) file.
Expand Down
6 changes: 6 additions & 0 deletions StructuredINI/StructuredINIWriter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@ public class StructuredINIWriter {
private readonly List<string> _sectionOrder = new();
private readonly Dictionary<string, string> _bufferedSections = new(StringComparer.OrdinalIgnoreCase);

// Buffer only caller-selected keys; WriteOut preserves all other keys.
public void BufferSection(string sectionName, IEnumerable<KeyValuePair<string, string>> 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<T>(string path, T t) {
var writer = new StructuredINIWriter();
return writer.BufferWrite(t) && writer.WriteOut(path);
Expand Down
163 changes: 163 additions & 0 deletions UnchainedLauncher.CLI.Tests/ArgumentBoundaryTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
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(
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");

public ArgumentBoundaryTests() => Directory.CreateDirectory(_directory);
public void Dispose() => Directory.Delete(_directory, recursive: true);

public static IEnumerable<object[]> 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<string> { "TBL" };
if (command == "server") expected.Add("-server");
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"]);

// 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<ServerOptions>(Parse("server", yaml, [], value));
var expected = new List<string> { "TBL", "-server" };
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);
expected.AddRange(["--server-browser-backend", "https://servers.polehammer.net"]);
Assert.Equal(expected, await CaptureControl(expected));
Assert.Equal(expected, await CaptureLaunch(options.ToLaunchOptions()));
}

[Fact]
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<string> { "TBL", "-server" };
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()));
}

[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<string> { "TBL", "-server" };
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" } : [],
.. serverValue is null ? System.Array.Empty<string>() : ["--name", serverValue, "--description", serverValue,
"--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", "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]);
}

private static readonly string[] FirstRunArguments = [
"-ini:Game:[/Script/TBL.TBLTitleScreen]:bSavedHasAgreedToTOS=True",
"-ini:Game:[/Script/TBL.TBLGameInstance]:FirstLoadCompleted=True"
];

private static IEnumerable<string> 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<string[]> CaptureLaunch(LaunchOptions options) {
var output = Path.Combine(_directory, "launch-result.json");
Directory.CreateDirectory(Path.Combine(_directory, FilePaths.BinDir));
var preparer = Chivalry2LaunchPreparer.Create<LaunchOptions>(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<string[]> CaptureControl(IEnumerable<string> 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<string[]> 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<string[]>(await File.ReadAllTextAsync(output))!;
}

private sealed class NoOpInjector : IProcessInjector {
public bool Inject(Process process) => true;
}
}
2 changes: 2 additions & 0 deletions UnchainedLauncher.CLI.Tests/ArgumentProbe/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
/bin/
/obj/
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>
4 changes: 4 additions & 0 deletions UnchainedLauncher.CLI.Tests/ArgumentProbe/Program.cs
Original file line number Diff line number Diff line change
@@ -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()));
70 changes: 70 additions & 0 deletions UnchainedLauncher.CLI.Tests/CommandLineOptionsTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +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<ArgumentException>(() => 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<ArgumentException>(() => 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;
CliCommandOptions Parse() => CommandLineOptions.Parse(args, (root, tokens, configuration) => {
calls++;
return root.Parse(tokens, configuration);
});
if (args.Contains("--platform=Invalid")) Assert.Throws<ArgumentException>(Parse);
else Assert.True(Parse().Help);
Assert.Equal(1, calls);
}
}

[Fact]
public void GameArgumentsMustFollowSeparatorAndArePreservedExactly() {
string[] prefix = ["server", "--chivalry2-path", ".", "--platform", "Steam"];
Assert.Throws<ArgumentException>(() => 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);
}
}
Loading
Loading