diff --git a/.gitignore b/.gitignore index d19c7a8be4..8759729371 100644 --- a/.gitignore +++ b/.gitignore @@ -302,3 +302,14 @@ Database/tools/password.txt docker.env docker-compose.yml docker-compose.arm64 + +# Single-player local build/runtime artifacts (never commit secrets or proprietary files) +.dotnet/ +.dotnet-install.ps1 +artifacts/ +Source/ACE.SinglePlayer/Runtime/ +Source/ACE.SinglePlayer/Logs/ +Source/ACE.SinglePlayer/Client/ +**/ace-server.ready.json +**/ace-server.process.json +**/settings.json diff --git a/README.md b/README.md index afeeafd312..49c0a09bcc 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,63 @@ +# ACE Single Player Launcher + +ACE Single Player Launcher turns the open-source [ACEmulator ACE server](https://github.com/ACEmulator/ACE) into a private, local game session on Windows. One **PLAY** button starts an isolated MariaDB database, starts ACE.Server on your own computer, waits until the world is ready, and launches the original Asheron's Call client. + +The launcher is designed for players who want a persistent personal world without manually maintaining server configuration files or entering a MariaDB root password. Characters and world state are kept between sessions, and nothing is exposed to the internet by default. + +## Highlights + +- Private, loopback-only ACE server and database +- Automatic database credentials protected for the current Windows user +- Persistent characters and world state +- Direct Vanilla client launch, with one-click Decal support when Decal and ThwargLauncher are already installed +- Curated server-mod library plus guarded AceForge Custom Weenie SQL imports +- Self-contained Windows release; players do not need to install .NET +- Pinned portable MariaDB and complete ACE World database included in the release +- Only the original game client and its four proprietary DAT files remain user-supplied + +## Install + +The short version is: + +1. Have a complete Asheron's Call client in one writable folder with `acclient.exe` and all four client DAT files. The usual path is `C:\Turbine\Asheron's Call`. +2. Download the latest ACE Single Player release ZIP and extract the entire archive to a normal writable folder such as `C:\Games\ACE-SinglePlayer`. +3. Run `ACE.SinglePlayer.exe`. If the client is not found automatically, select its folder once, then click **PLAY**. The first private-world import can take several minutes; later launches are much faster. + +On the first Play, the launcher also creates a private server copy of the four DAT files under `%LOCALAPPDATA%\ACESinglePlayer\ServerData`. This prevents ACE.Server from locking the original files needed by the client and requires roughly the same amount of free disk space as the four DATs. These local copies are never included in release ZIPs or GitHub. + +The portable release currently pins ACEmulator server build `1.77.4782` from upstream commit `650c5b75`, ACE World `v0.9.294`, and MariaDB `12.3.2 LTS`. Exact source URLs, hashes, and licenses are recorded in `BUNDLE-MANIFEST.json` and [the third-party notices](docs/THIRD_PARTY_NOTICES.md). + +## Mods + +Open **Server Mods** in the launcher to browse the curated mod library. Installed and ready-to-install entries are listed before unavailable ports. Every entry includes a plain-language description, compatibility result, requirements, source-code links, and a saved-game safety warning. `CriticalOverride` is curated for one-click installation. `HelloCommand`, `SocietyTailoring`, and **Expanded Cast on Strike** are installable **Preview** ports. The last one enables the non-Aetheria equipped-item procs documented by [ACEUniqueWeenies](https://github.com/titaniumweiner/ACEUniqueWeenies); its custom item SQL remains a separate Custom Weenies import. OptimShi's `CustomClothingBase` v1.11 is also an installable Preview using the author's checksum-pinned, unmodified official DLLs after a successful current-ACE loader test. Preview mods are clearly marked as not thoroughly tested in game. Other samples remain listed but unavailable until they are ported and tested. **Import a Mod ZIP...** can atomically install separately rebuilt, checksummed packages in the documented ACE Single Player ZIP format. + +Turning a mod off stops its code after a server restart, but it does not undo experience, items, balances, character properties, or world content already saved by that mod. The launcher blocks removal of mods whose saved data may still depend on them and moves safely removable files to a recovery folder rather than deleting them. See [Mod library and saved-game safety](docs/MOD_LIBRARY.md) for the full policy, or [How to make and import a mod](docs/MOD_AUTHOR_GUIDE.md) to build a compatible package. + +## Custom Weenies and AceForge + +Open **Custom Weenies** to import per-weenie `.sql` files created by [AceForge](https://github.com/shemtar-90/AceForge/releases/tag/v0.3.36). Choose an AceForge output folder or individual SQL files; the launcher previews each WCID, validates a strict list of ACE weenie-property operations, skips unsupported content, checks whether WCIDs already exist, and creates a complete `ace_world` backup before applying the import in one database transaction. Automatic imports require the launcher's Private Database mode. + +Custom weenies become part of the saved world and do not have a one-click uninstaller. Quest, recipe, event, treasure, landscape, and client-DAT changes are not imported by this screen. See [Importing AceForge custom weenies](docs/CUSTOM_WEENIES.md) for the exact workflow and safety limits. + +See the [complete installation guide](docs/SINGLE_PLAYER_INSTALL.md) for prerequisites, expected file names, first-run instructions, troubleshooting, backups, and upgrade guidance. + +> [!IMPORTANT] +> The release does not contain Asheron's Call, `acclient.exe`, proprietary DAT files, Decal, or ThwargLauncher. It does include the redistributable open-source ACE server, ACE World database, and MariaDB runtime with their licenses and source links. Never upload your client or DAT files when sharing builds or reporting problems. + +## Project status + +This is a Windows-focused single-player launcher built as a maintained fork of ACE. Vanilla launch and the private-database path are the primary supported workflow. Decal integration is optional, and Chorizite integration is reserved for future work. See the [architecture](docs/SINGLE_PLAYER_ARCHITECTURE.md), [build instructions](docs/SINGLE_PLAYER_BUILD_AND_TEST.md), and [roadmap](docs/SINGLE_PLAYER_ROADMAP.md) for technical details. + +## Privacy and local files + +User-specific settings and protected credentials are stored under `%LOCALAPPDATA%\ACESinglePlayer`. Private database files, settings, logs, client files, and DAT files are ignored by Git and rejected by the public packaging script. Public release packages also exclude debug symbols that could reveal the build computer's source path. + +## Upstream project + +ACE Single Player is based on ACEmulator ACE and remains under ACE's AGPL-3.0 license. The original ACE project information follows. + +--- + # ACEmulator Core Server [![Discord](https://img.shields.io/discord/261242462972936192.svg?label=play+now!&style=for-the-badge&logo=discord)](https://discord.gg/C2WzhP9) diff --git a/Source/ACE.Server.Tests/SinglePlayerStartupTests.cs b/Source/ACE.Server.Tests/SinglePlayerStartupTests.cs new file mode 100644 index 0000000000..44a2b2650b --- /dev/null +++ b/Source/ACE.Server.Tests/SinglePlayerStartupTests.cs @@ -0,0 +1,69 @@ +using System; +using System.IO; + +using Microsoft.VisualStudio.TestTools.UnitTesting; + +using ACE.Common; + +namespace ACE.Server.Tests +{ + [TestClass] + public class SinglePlayerStartupTests + { + [TestMethod] + public void LaunchOptionsAreOptional() + { + var options = ServerLaunchOptions.Parse(Array.Empty()); + Assert.IsNull(options.ConfigPath); + Assert.IsNull(options.ReadyFilePath); + } + + [TestMethod] + public void LaunchOptionsPreserveAbsolutePathsWithSpaces() + { + var root = Path.Combine(Path.GetTempPath(), "ACE Single Player Test"); + var options = ServerLaunchOptions.Parse(new[] + { + "--config", Path.Combine(root, "Config.js"), + "--ready-file", Path.Combine(root, "server ready.json") + }); + Assert.AreEqual(Path.GetFullPath(Path.Combine(root, "Config.js")), options.ConfigPath); + Assert.AreEqual(Path.GetFullPath(Path.Combine(root, "server ready.json")), options.ReadyFilePath); + } + + [TestMethod] + public void RelativeLauncherPathsAreRejected() + { + Assert.ThrowsExactly(() => ServerLaunchOptions.Parse(new[] { "--config", "Config.js" })); + } + + [TestMethod] + public void ReadyFileIsAtomicValidAndRemovedOnShutdown() + { + var directory = Path.Combine(Path.GetTempPath(), "ACE.SinglePlayer.Server.Tests", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(directory); + var path = Path.Combine(directory, "ready.json"); + try + { + ConfigManager.Initialize(new MasterConfiguration()); + ConfigManager.Config.Server.WorldName = "Ready Test"; + ConfigManager.Config.Server.Network.Host = "127.0.0.1"; + ConfigManager.Config.Server.Network.Port = 9123; + var signal = new ReadyFileSignal(path); + signal.Write(); + + var json = File.ReadAllText(path); + StringAssert.Contains(json, "Ready Test"); + StringAssert.Contains(json, "127.0.0.1"); + Assert.AreEqual(0, Directory.GetFiles(directory, "*.tmp").Length); + + signal.Delete(); + Assert.IsFalse(File.Exists(path)); + } + finally + { + Directory.Delete(directory, true); + } + } + } +} diff --git a/Source/ACE.Server/ACE.Server.csproj b/Source/ACE.Server/ACE.Server.csproj index 9d2d35947e..c3a0951568 100644 --- a/Source/ACE.Server/ACE.Server.csproj +++ b/Source/ACE.Server/ACE.Server.csproj @@ -285,12 +285,38 @@ - - - - + + + + + + + + <_DatabaseArchivePublish Include="..\..\Database\Archive\**\*" /> + <_DatabaseBasePublish Include="..\..\Database\Base\**\*" /> + <_DatabaseUpdatesPublish Include="..\..\Database\Updates\**\*" /> + <_DatabaseOptionalPublish Include="..\..\Database\Optional\**\*" /> + + + + + + + diff --git a/Source/ACE.Server/Program.cs b/Source/ACE.Server/Program.cs index b8a249c14a..f4970908f9 100644 --- a/Source/ACE.Server/Program.cs +++ b/Source/ACE.Server/Program.cs @@ -41,8 +41,14 @@ partial class Program public static readonly bool IsRunningInContainer = Convert.ToBoolean(Environment.GetEnvironmentVariable("DOTNET_RUNNING_IN_CONTAINER")); + private static ReadyFileSignal readyFileSignal; + public static void Main(string[] args) { + var launchOptions = ServerLaunchOptions.Parse(args); + readyFileSignal = new ReadyFileSignal(launchOptions.ReadyFilePath); + readyFileSignal.DeleteStale(); + var consoleTitle = $"ACEmulator - v{ServerBuildInfo.FullVersion}"; Console.Title = consoleTitle; @@ -129,15 +135,17 @@ public static void Main(string[] args) if (IsRunningInContainer) log.Info("ACEmulator is running in a container..."); - var configFile = Path.Combine(exeLocation, "Config.js"); + var configFile = launchOptions.ConfigPath ?? Path.Combine(exeLocation, "Config.js"); var configConfigContainer = Path.Combine(containerConfigDirectory, "Config.js"); - if (IsRunningInContainer && File.Exists(configConfigContainer)) + if (launchOptions.ConfigPath == null && IsRunningInContainer && File.Exists(configConfigContainer)) File.Copy(configConfigContainer, configFile, true); if (!File.Exists(configFile)) { - if (!IsRunningInContainer) + if (launchOptions.ConfigPath != null) + throw new FileNotFoundException("The configuration supplied with --config does not exist.", configFile); + else if (!IsRunningInContainer) DoOutOfBoxSetup(configFile); else { @@ -152,7 +160,7 @@ public static void Main(string[] args) } log.Info("Initializing ConfigManager..."); - ConfigManager.Initialize(); + ConfigManager.Initialize(configFile); log.Info("Initializing ModManager..."); ModManager.Initialize(); @@ -340,6 +348,7 @@ public static void Main(string[] args) if (!PropertyManager.GetBool("world_closed", false).Item) { WorldManager.Open(null); + readyFileSignal.Write(); } } @@ -350,6 +359,8 @@ private static void CurrentDomain_UnhandledException(object sender, UnhandledExc private static void OnProcessExit(object sender, EventArgs e) { + readyFileSignal?.Delete(); + if (!IsRunningInContainer) { if (!ServerManager.ShutdownInitiated) diff --git a/Source/ACE.Server/ReadyFileSignal.cs b/Source/ACE.Server/ReadyFileSignal.cs new file mode 100644 index 0000000000..c87bc6bcd3 --- /dev/null +++ b/Source/ACE.Server/ReadyFileSignal.cs @@ -0,0 +1,74 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.Text.Json; + +using ACE.Common; + +namespace ACE.Server +{ + public sealed class ReadyFileSignal + { + private readonly string path; + + public ReadyFileSignal(string path) + { + this.path = path; + } + + public bool Enabled => !string.IsNullOrWhiteSpace(path); + + public void DeleteStale() + { + if (Enabled && File.Exists(path)) + File.Delete(path); + } + + public void Write() + { + if (!Enabled) + return; + + var directory = Path.GetDirectoryName(path); + if (!string.IsNullOrWhiteSpace(directory)) + Directory.CreateDirectory(directory); + + var payload = new + { + ProcessId = Process.GetCurrentProcess().Id, + WorldName = ConfigManager.Config.Server.WorldName, + Host = ConfigManager.Config.Server.Network.Host, + Port = ConfigManager.Config.Server.Network.Port, + ReadyAtUtc = DateTime.UtcNow + }; + + var temporaryPath = path + "." + Guid.NewGuid().ToString("N") + ".tmp"; + try + { + File.WriteAllText(temporaryPath, JsonSerializer.Serialize(payload)); + File.Move(temporaryPath, path, true); + } + finally + { + if (File.Exists(temporaryPath)) + File.Delete(temporaryPath); + } + } + + public void Delete() + { + if (!Enabled) + return; + + try + { + if (File.Exists(path)) + File.Delete(path); + } + catch + { + // Process shutdown must continue even if cleanup is prevented. + } + } + } +} diff --git a/Source/ACE.Server/ServerLaunchOptions.cs b/Source/ACE.Server/ServerLaunchOptions.cs new file mode 100644 index 0000000000..9be8f47163 --- /dev/null +++ b/Source/ACE.Server/ServerLaunchOptions.cs @@ -0,0 +1,44 @@ +using System; +using System.IO; + +namespace ACE.Server +{ + public sealed class ServerLaunchOptions + { + public string ConfigPath { get; private set; } + + public string ReadyFilePath { get; private set; } + + public static ServerLaunchOptions Parse(string[] args) + { + var options = new ServerLaunchOptions(); + + for (var i = 0; i < args.Length; i++) + { + switch (args[i]) + { + case "--config": + options.ConfigPath = ReadAbsolutePath(args, ref i, "--config"); + break; + case "--ready-file": + options.ReadyFilePath = ReadAbsolutePath(args, ref i, "--ready-file"); + break; + } + } + + return options; + } + + private static string ReadAbsolutePath(string[] args, ref int index, string option) + { + if (index + 1 >= args.Length || string.IsNullOrWhiteSpace(args[index + 1])) + throw new ArgumentException($"{option} requires an absolute path."); + + var path = args[++index]; + if (!Path.IsPathFullyQualified(path)) + throw new ArgumentException($"{option} requires an absolute path: {path}"); + + return Path.GetFullPath(path); + } + } +} diff --git a/Source/ACE.SinglePlayer.DecalHost/ACE.SinglePlayer.DecalHost.csproj b/Source/ACE.SinglePlayer.DecalHost/ACE.SinglePlayer.DecalHost.csproj new file mode 100644 index 0000000000..e7b2c3d868 --- /dev/null +++ b/Source/ACE.SinglePlayer.DecalHost/ACE.SinglePlayer.DecalHost.csproj @@ -0,0 +1,13 @@ + + + Exe + net10.0-windows + true + win-x86 + x86 + x86 + enable + enable + ACE.SinglePlayer.DecalHost + + diff --git a/Source/ACE.SinglePlayer.DecalHost/Program.cs b/Source/ACE.SinglePlayer.DecalHost/Program.cs new file mode 100644 index 0000000000..869ea59faa --- /dev/null +++ b/Source/ACE.SinglePlayer.DecalHost/Program.cs @@ -0,0 +1,143 @@ +using System.ComponentModel; +using System.Runtime.InteropServices; +using System.Text; +using System.Text.Json; + +namespace ACE.SinglePlayer.DecalHost; + +internal static class Program +{ + public static int Main(string[] args) + { + try + { + var options = Parse(args); + var client = Required(options, "--client"); + var decal = Required(options, "--decal"); + var injector = Required(options, "--injector"); + var account = Required(options, "--account"); + var password = Required(options, "--password"); + var host = Required(options, "--host"); + var port = Required(options, "--port"); + + if (Environment.Is64BitProcess) + throw new PlatformNotSupportedException("The Decal launch helper must run as a 32-bit process."); + RequireFile(client, "Asheron's Call client"); + RequireFile(decal, "Decal Inject.dll"); + RequireFile(injector, "Thwarg injector.dll"); + + var commandLine = BuildCommandLine(new[] + { + client, "-a", account, "-v", password, "-h", $"{host}:{port}" + }); + var processId = LaunchWithInstalledThwargInjector( + injector, + commandLine, + Path.GetDirectoryName(client)!, + decal); + if (processId <= 0) + throw new InvalidOperationException("Thwarg's injector did not return a game process."); + + Console.Out.Write(JsonSerializer.Serialize(new + { + ProcessId = processId, + DecalStartupInvoked = true + })); + return 0; + } + catch (Exception ex) + { + Console.Error.Write("The Decal startup path failed: " + Describe(ex)); + return 1; + } + } + + private static int LaunchWithInstalledThwargInjector( + string injectorPath, + string commandLine, + string workingDirectory, + string decalInjectPath) + { + var library = NativeLibrary.Load(injectorPath); + try + { + var export = NativeLibrary.GetExport(library, "LaunchInjected"); + var launch = Marshal.GetDelegateForFunctionPointer(export); + return launch(commandLine, workingDirectory, decalInjectPath, "DecalStartup"); + } + finally + { + NativeLibrary.Free(library); + } + } + + private static void RequireFile(string path, string description) + { + if (!File.Exists(path)) + throw new FileNotFoundException($"{description} is missing.", path); + } + + private static string Describe(Exception exception) + { + if (exception is Win32Exception windowsException) + { + var systemMessage = new Win32Exception(windowsException.NativeErrorCode).Message; + return $"{windowsException.Message} (Windows error {windowsException.NativeErrorCode}: {systemMessage})"; + } + + return exception.Message; + } + + internal static string BuildCommandLine(IEnumerable arguments) => + string.Join(" ", arguments.Select(QuoteArgument)); + + internal static string QuoteArgument(string value) + { + if (value.Length > 0 && !value.Any(character => char.IsWhiteSpace(character) || character == '"')) + return value; + + var result = new StringBuilder().Append('"'); + var backslashes = 0; + foreach (var character in value) + { + if (character == '\\') + { + backslashes++; + continue; + } + + if (character == '"') + result.Append('\\', backslashes * 2 + 1).Append('"'); + else + result.Append('\\', backslashes).Append(character); + backslashes = 0; + } + + result.Append('\\', backslashes * 2).Append('"'); + return result.ToString(); + } + + private static Dictionary Parse(string[] args) + { + var values = new Dictionary(StringComparer.OrdinalIgnoreCase); + for (var index = 0; index < args.Length; index += 2) + { + if (index + 1 >= args.Length || !args[index].StartsWith("--", StringComparison.Ordinal)) + throw new ArgumentException("Invalid Decal host arguments."); + values[args[index]] = args[index + 1]; + } + return values; + } + + private static string Required(IReadOnlyDictionary values, string name) => + values.TryGetValue(name, out var value) && !string.IsNullOrWhiteSpace(value) + ? value + : throw new ArgumentException($"Missing {name}."); + + [UnmanagedFunctionPointer(CallingConvention.Cdecl, CharSet = CharSet.Unicode)] + private delegate int LaunchInjectedDelegate( + string commandLine, + string workingDirectory, + string injectDllPath, + [MarshalAs(UnmanagedType.LPStr)] string initializeFunction); +} diff --git a/Source/ACE.SinglePlayer.Mods.ACEUniqueWeeniesProc/ACE.SinglePlayer.Mods.ACEUniqueWeeniesProc.csproj b/Source/ACE.SinglePlayer.Mods.ACEUniqueWeeniesProc/ACE.SinglePlayer.Mods.ACEUniqueWeeniesProc.csproj new file mode 100644 index 0000000000..ffd3ce07b7 --- /dev/null +++ b/Source/ACE.SinglePlayer.Mods.ACEUniqueWeeniesProc/ACE.SinglePlayer.Mods.ACEUniqueWeeniesProc.csproj @@ -0,0 +1,20 @@ + + + net10.0 + ACEUniqueWeeniesProc + ACEUniqueWeeniesProc + x64 + enable + enable + + + + + + + + + + + + diff --git a/Source/ACE.SinglePlayer.Mods.ACEUniqueWeeniesProc/ACEUniqueWeeniesProcPatch.cs b/Source/ACE.SinglePlayer.Mods.ACEUniqueWeeniesProc/ACEUniqueWeeniesProcPatch.cs new file mode 100644 index 0000000000..02c528b604 --- /dev/null +++ b/Source/ACE.SinglePlayer.Mods.ACEUniqueWeeniesProc/ACEUniqueWeeniesProcPatch.cs @@ -0,0 +1,50 @@ +using System.Linq; + +using ACE.Server.WorldObjects; + +using HarmonyLib; + +namespace ACEUniqueWeeniesProc; + +[HarmonyPatch] +public static class ACEUniqueWeeniesProcPatch +{ + [HarmonyPrefix] + [HarmonyPriority(Priority.First)] + [HarmonyPatch(typeof(WorldObject), nameof(WorldObject.TryProcEquippedItems), + new[] { typeof(WorldObject), typeof(Creature), typeof(bool), typeof(WorldObject) })] + public static bool UseUniqueEquipmentProcFilter(WorldObject __instance, WorldObject attacker, + Creature target, bool selfTarget, WorldObject? weapon) + { + // Preserve stock ACE handling for a proc directly on the object, the + // active weapon, and monster missile projectiles. + if (__instance.HasProc && __instance.ProcSpellSelfTargeted == selfTarget) + __instance.TryProcItem(attacker, target, selfTarget); + + if (weapon is { HasProc: true } && weapon.ProcSpellSelfTargeted == selfTarget) + weapon.TryProcItem(attacker, target, selfTarget); + + if (attacker != __instance && attacker.HasProc && attacker.ProcSpellSelfTargeted == selfTarget) + attacker.TryProcItem(attacker, target, selfTarget); + + // ACE normally limits this equipped-item pass to Aetheria. The + // ACEUniqueWeenies content expects procs on jewelry, armor, and other + // equipped objects. Cloak weave proc type 1 remains excluded exactly + // as documented by the content repository. + if (attacker is Creature wielder) + { + var equippedProcItems = wielder.EquippedObjects.Values.Where(item => + IsEligibleEquippedProc(item.HasProc, item.CloakWeaveProc, + item.ProcSpellSelfTargeted, selfTarget)); + + foreach (var item in equippedProcItems) + item.TryProcItem(attacker, target, selfTarget); + } + + return false; + } + + public static bool IsEligibleEquippedProc(bool hasProc, int? cloakWeaveProc, + bool procSpellSelfTargeted, bool selfTarget) => + hasProc && cloakWeaveProc != 1 && procSpellSelfTargeted == selfTarget; +} diff --git a/Source/ACE.SinglePlayer.Mods.ACEUniqueWeeniesProc/Meta.json b/Source/ACE.SinglePlayer.Mods.ACEUniqueWeeniesProc/Meta.json new file mode 100644 index 0000000000..140544dff8 --- /dev/null +++ b/Source/ACE.SinglePlayer.Mods.ACEUniqueWeeniesProc/Meta.json @@ -0,0 +1,14 @@ +{ + "Name": "Expanded Cast on Strike", + "Author": "titaniumweiner; ACE Single Player port", + "Description": "Enables cast-on-strike procs on non-Aetheria equipped items used by ACEUniqueWeenies content.", + "Version": "1.0.0-sp1", + "Priority": 0, + "Enabled": true, + "HotReload": false, + "RegisterCommands": false, + "CatalogId": "titaniumweiner.ace-unique-weenies-proc", + "TargetAceVersion": "ACE.Server 1.1 / .NET 10", + "DataImpact": "SettingsOnly", + "RemovalPolicy": "Safe" +} diff --git a/Source/ACE.SinglePlayer.Mods.ACEUniqueWeeniesProc/Mod.cs b/Source/ACE.SinglePlayer.Mods.ACEUniqueWeeniesProc/Mod.cs new file mode 100644 index 0000000000..381dc85017 --- /dev/null +++ b/Source/ACE.SinglePlayer.Mods.ACEUniqueWeeniesProc/Mod.cs @@ -0,0 +1,19 @@ +using ACE.Server.Mods; + +using HarmonyLib; + +namespace ACEUniqueWeeniesProc; + +public sealed class Mod : IHarmonyMod +{ + internal const string HarmonyId = "titaniumweiner.ACEUniqueWeeniesProc.ace-single-player"; + private readonly Harmony harmony = new(HarmonyId); + + public void Initialize() + { + harmony.PatchAll(typeof(Mod).Assembly); + Console.WriteLine("[Expanded Cast on Strike] Enabled cast-on-strike procs for equipped items other than cloak weave proc type 1."); + } + + public void Dispose() => harmony.UnpatchAll(HarmonyId); +} diff --git a/Source/ACE.SinglePlayer.Mods.ACEUniqueWeeniesProc/README.md b/Source/ACE.SinglePlayer.Mods.ACEUniqueWeeniesProc/README.md new file mode 100644 index 0000000000..d4e040bc76 --- /dev/null +++ b/Source/ACE.SinglePlayer.Mods.ACEUniqueWeeniesProc/README.md @@ -0,0 +1,13 @@ +# Expanded Cast on Strike for ACE Single Player + +This .NET 10 Harmony mod implements the server-code change documented by [titaniumweiner/ACEUniqueWeenies](https://github.com/titaniumweiner/ACEUniqueWeenies). It lets equipped jewelry, armor, and other items use cast-on-strike proc properties instead of limiting the equipped-item pass to Aetheria. Items with `CloakWeaveProc` type `1` remain excluded. + +The patch preserves the rest of the pinned ACE `TryProcEquippedItems` method, including direct-object, active-weapon, and monster-projectile proc handling. Because the requested filter also sees an active weapon in the equipped-object collection, it intentionally matches the repository's exact behavior rather than silently adding a weapon exclusion. + +The mod changes combat behavior only. It does not include or import the repository's custom weenie SQL. Use the launcher's **Custom Weenies** section for compatible per-weenie SQL files. + +Reviewed content commit: + +Ported source: + +This mod is derived from ACE server behavior and is distributed under the GNU Affero General Public License v3.0 included with ACE Single Player. The linked ACEUniqueWeenies repository does not currently declare a license; its SQL content is not redistributed by this mod package. diff --git a/Source/ACE.SinglePlayer.Mods.ACEUniqueWeeniesProc/ace-mod.json b/Source/ACE.SinglePlayer.Mods.ACEUniqueWeeniesProc/ace-mod.json new file mode 100644 index 0000000000..02fc7eefbc --- /dev/null +++ b/Source/ACE.SinglePlayer.Mods.ACEUniqueWeeniesProc/ace-mod.json @@ -0,0 +1,8 @@ +{ + "formatVersion": 1, + "id": "titaniumweiner.ace-unique-weenies-proc", + "name": "Expanded Cast on Strike", + "version": "1.0.0-sp1", + "folderName": "ACEUniqueWeeniesProc", + "entryAssembly": "ACEUniqueWeeniesProc.dll" +} diff --git a/Source/ACE.SinglePlayer.Mods.CriticalOverride/ACE.SinglePlayer.Mods.CriticalOverride.csproj b/Source/ACE.SinglePlayer.Mods.CriticalOverride/ACE.SinglePlayer.Mods.CriticalOverride.csproj new file mode 100644 index 0000000000..fb59b19f38 --- /dev/null +++ b/Source/ACE.SinglePlayer.Mods.CriticalOverride/ACE.SinglePlayer.Mods.CriticalOverride.csproj @@ -0,0 +1,21 @@ + + + net10.0 + CriticalOverride + CriticalOverride + x64 + enable + enable + + + + + + + + + + + + + diff --git a/Source/ACE.SinglePlayer.Mods.CriticalOverride/CriticalOverridePatch.cs b/Source/ACE.SinglePlayer.Mods.CriticalOverride/CriticalOverridePatch.cs new file mode 100644 index 0000000000..ac35bafaeb --- /dev/null +++ b/Source/ACE.SinglePlayer.Mods.CriticalOverride/CriticalOverridePatch.cs @@ -0,0 +1,35 @@ +using ACE.Server.Entity; +using ACE.Server.WorldObjects; +using ACE.Server.WorldObjects.Entity; + +using HarmonyLib; + +namespace CriticalOverride; + +[HarmonyPatch] +public static class CriticalOverridePatch +{ + [HarmonyPrefix] + [HarmonyPatch(typeof(WorldObject), nameof(WorldObject.GetWeaponCriticalChance), + new[] { typeof(WorldObject), typeof(Creature), typeof(CreatureSkill), typeof(Creature) })] + public static bool OverridePhysicalCriticalChance(Creature target, ref float __result) + { + if (target is Player) + return true; + + __result = Mod.Settings.CritChance; + return false; + } + + [HarmonyPrefix] + [HarmonyPatch(typeof(WorldObject), nameof(WorldObject.GetWeaponMagicCritFrequency), + new[] { typeof(WorldObject), typeof(Creature), typeof(CreatureSkill), typeof(Creature) })] + public static bool OverrideMagicCriticalChance(Creature target, ref float __result) + { + if (target is Player) + return true; + + __result = Mod.Settings.MagicCritChance; + return false; + } +} diff --git a/Source/ACE.SinglePlayer.Mods.CriticalOverride/Meta.json b/Source/ACE.SinglePlayer.Mods.CriticalOverride/Meta.json new file mode 100644 index 0000000000..0b94620e82 --- /dev/null +++ b/Source/ACE.SinglePlayer.Mods.CriticalOverride/Meta.json @@ -0,0 +1,14 @@ +{ + "Name": "CriticalOverride", + "Author": "aquafir; ACE Single Player port", + "Description": "Overrides physical and magic critical-hit chances against non-player creatures.", + "Version": "1.0.0-sp1", + "Priority": 0, + "Enabled": true, + "HotReload": false, + "RegisterCommands": false, + "CatalogId": "aquafir.critical-override", + "TargetAceVersion": "ACE.Server 1.1 / .NET 10", + "DataImpact": "SettingsOnly", + "RemovalPolicy": "Safe" +} diff --git a/Source/ACE.SinglePlayer.Mods.CriticalOverride/Mod.cs b/Source/ACE.SinglePlayer.Mods.CriticalOverride/Mod.cs new file mode 100644 index 0000000000..9686f5f359 --- /dev/null +++ b/Source/ACE.SinglePlayer.Mods.CriticalOverride/Mod.cs @@ -0,0 +1,49 @@ +using System.Text.Json; + +using ACE.Server.Mods; + +using HarmonyLib; + +namespace CriticalOverride; + +public sealed class Mod : IHarmonyMod +{ + private const string HarmonyId = "aquafir.CriticalOverride.ace-single-player"; + private readonly Harmony harmony = new(HarmonyId); + + internal static CriticalOverrideSettings Settings { get; private set; } = new(); + + public void Initialize() + { + Settings = LoadSettings(Path.Combine(this.GetFolder(), "Settings.json")); + harmony.PatchAll(typeof(Mod).Assembly); + ModManager.Log($"CriticalOverride configured physical crit chance {Settings.CritChance:P0} and magic crit chance {Settings.MagicCritChance:P0} against creatures."); + } + + public void Dispose() + { + harmony.UnpatchAll(HarmonyId); + } + + private static CriticalOverrideSettings LoadSettings(string path) + { + if (!File.Exists(path)) + return new CriticalOverrideSettings(); + + var settings = JsonSerializer.Deserialize(File.ReadAllText(path), new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true, + ReadCommentHandling = JsonCommentHandling.Skip, + AllowTrailingCommas = true + }) ?? new CriticalOverrideSettings(); + if (settings.CritChance is < 0 or > 1 || settings.MagicCritChance is < 0 or > 1) + throw new InvalidDataException("CriticalOverride chances must be between 0 and 1."); + return settings; + } +} + +public sealed class CriticalOverrideSettings +{ + public float CritChance { get; set; } = 0.5f; + public float MagicCritChance { get; set; } = 0.05f; +} diff --git a/Source/ACE.SinglePlayer.Mods.CriticalOverride/README.md b/Source/ACE.SinglePlayer.Mods.CriticalOverride/README.md new file mode 100644 index 0000000000..42beb11ee2 --- /dev/null +++ b/Source/ACE.SinglePlayer.Mods.CriticalOverride/README.md @@ -0,0 +1,9 @@ +# CriticalOverride for ACE Single Player + +This is a .NET 10 port of aquafir's `ACE.BaseMod` CriticalOverride sample. It overrides physical and magic critical-hit chances against non-player creatures while leaving player-versus-player calculations unchanged. + +Edit `Settings.json` while the local server is stopped. Values are probabilities from `0` to `1`; for example, `0.5` is 50 percent. Restart the server after changes. + +Original source: + +The original and modified source are licensed under the GNU Affero General Public License v3.0, the same license included with ACE Single Player. diff --git a/Source/ACE.SinglePlayer.Mods.CriticalOverride/ace-mod.json b/Source/ACE.SinglePlayer.Mods.CriticalOverride/ace-mod.json new file mode 100644 index 0000000000..bb6247e438 --- /dev/null +++ b/Source/ACE.SinglePlayer.Mods.CriticalOverride/ace-mod.json @@ -0,0 +1,8 @@ +{ + "formatVersion": 1, + "id": "aquafir.critical-override", + "name": "CriticalOverride", + "version": "1.0.0-sp1", + "folderName": "CriticalOverride", + "entryAssembly": "CriticalOverride.dll" +} diff --git a/Source/ACE.SinglePlayer.Mods.HelloCommand/ACE.SinglePlayer.Mods.HelloCommand.csproj b/Source/ACE.SinglePlayer.Mods.HelloCommand/ACE.SinglePlayer.Mods.HelloCommand.csproj new file mode 100644 index 0000000000..0bd7b916cc --- /dev/null +++ b/Source/ACE.SinglePlayer.Mods.HelloCommand/ACE.SinglePlayer.Mods.HelloCommand.csproj @@ -0,0 +1,20 @@ + + + net10.0 + HelloCommand + HelloCommand + x64 + enable + enable + + + + + + + + + + + + diff --git a/Source/ACE.SinglePlayer.Mods.HelloCommand/Meta.json b/Source/ACE.SinglePlayer.Mods.HelloCommand/Meta.json new file mode 100644 index 0000000000..6fa2d6b025 --- /dev/null +++ b/Source/ACE.SinglePlayer.Mods.HelloCommand/Meta.json @@ -0,0 +1,14 @@ +{ + "Name": "HelloCommand", + "Author": "aquafir; ACE Single Player port", + "Description": "Adds /hello and /bye as a small server-mod command example.", + "Version": "1.0.0-sp1", + "Priority": 0, + "Enabled": true, + "HotReload": false, + "RegisterCommands": false, + "CatalogId": "aquafir.hello-command", + "TargetAceVersion": "ACE.Server 1.1 / .NET 10", + "DataImpact": "None", + "RemovalPolicy": "Safe" +} diff --git a/Source/ACE.SinglePlayer.Mods.HelloCommand/Mod.cs b/Source/ACE.SinglePlayer.Mods.HelloCommand/Mod.cs new file mode 100644 index 0000000000..cfd97bf631 --- /dev/null +++ b/Source/ACE.SinglePlayer.Mods.HelloCommand/Mod.cs @@ -0,0 +1,54 @@ +using ACE.Entity.Enum; +using ACE.Server.Command; +using ACE.Server.Mods; +using ACE.Server.Network; + +namespace HelloCommand; + +public sealed class Mod : IHarmonyMod +{ + private const string HelloCommandName = "hello"; + private const string ByeCommandName = "bye"; + private bool helloRegistered; + private bool byeRegistered; + + public void Initialize() + { + helloRegistered = CommandManager.TryAddCommand( + HandleHello, + HelloCommandName, + AccessLevel.Admin, + CommandHandlerFlag.None, + "Says hello to the current character.", + "No parameters", + overrides: false); + byeRegistered = CommandManager.TryAddCommand( + HandleBye, + ByeCommandName, + AccessLevel.Player, + CommandHandlerFlag.None, + "Logs the current character out.", + "No parameters", + overrides: false); + + Console.WriteLine($"[HelloCommand] /hello: {RegistrationStatus(helloRegistered)}; /bye: {RegistrationStatus(byeRegistered)}."); + } + + public void Dispose() + { + if (helloRegistered) + CommandManager.TryRemoveCommand(HelloCommandName); + if (byeRegistered) + CommandManager.TryRemoveCommand(ByeCommandName); + } + + private static void HandleHello(Session session, string[] parameters) + { + if (session.Player is not null) + session.Player.SendMessage($"Hello, {session.Player.Name}!"); + } + + private static void HandleBye(Session session, string[] parameters) => session.LogOffPlayer(true); + + private static string RegistrationStatus(bool registered) => registered ? "registered" : "name already in use"; +} diff --git a/Source/ACE.SinglePlayer.Mods.HelloCommand/README.md b/Source/ACE.SinglePlayer.Mods.HelloCommand/README.md new file mode 100644 index 0000000000..02854e16d0 --- /dev/null +++ b/Source/ACE.SinglePlayer.Mods.HelloCommand/README.md @@ -0,0 +1,11 @@ +# HelloCommand for ACE Single Player + +This is a .NET 10 port of aquafir's `ACE.BaseMod` HelloCommand sample. It adds `/hello`, which greets the current character, and `/bye`, which logs the character out. + +This is a developer example rather than a substantial gameplay mod. The port is marked **Preview** because it has passed build, registration, removal, package, and launcher tests but has not received thorough in-game testing. + +Original source: + +Modified source: + +The original and modified source are licensed under the GNU Affero General Public License v3.0, the same license included with ACE Single Player. diff --git a/Source/ACE.SinglePlayer.Mods.HelloCommand/ace-mod.json b/Source/ACE.SinglePlayer.Mods.HelloCommand/ace-mod.json new file mode 100644 index 0000000000..3726db3992 --- /dev/null +++ b/Source/ACE.SinglePlayer.Mods.HelloCommand/ace-mod.json @@ -0,0 +1,8 @@ +{ + "formatVersion": 1, + "id": "aquafir.hello-command", + "name": "HelloCommand", + "version": "1.0.0-sp1", + "folderName": "HelloCommand", + "entryAssembly": "HelloCommand.dll" +} diff --git a/Source/ACE.SinglePlayer.Mods.SocietyTailoring/ACE.SinglePlayer.Mods.SocietyTailoring.csproj b/Source/ACE.SinglePlayer.Mods.SocietyTailoring/ACE.SinglePlayer.Mods.SocietyTailoring.csproj new file mode 100644 index 0000000000..e57bc31658 --- /dev/null +++ b/Source/ACE.SinglePlayer.Mods.SocietyTailoring/ACE.SinglePlayer.Mods.SocietyTailoring.csproj @@ -0,0 +1,20 @@ + + + net10.0 + SocietyTailoring + SocietyTailoring + x64 + enable + enable + + + + + + + + + + + + diff --git a/Source/ACE.SinglePlayer.Mods.SocietyTailoring/Meta.json b/Source/ACE.SinglePlayer.Mods.SocietyTailoring/Meta.json new file mode 100644 index 0000000000..c1c6ac84dd --- /dev/null +++ b/Source/ACE.SinglePlayer.Mods.SocietyTailoring/Meta.json @@ -0,0 +1,14 @@ +{ + "Name": "SocietyTailoring", + "Author": "aquafir; ACE Single Player port", + "Description": "Allows Society armor to be used in tailoring while retaining ACE safety checks.", + "Version": "1.0.0-sp1", + "Priority": 0, + "Enabled": true, + "HotReload": false, + "RegisterCommands": false, + "CatalogId": "aquafir.society-tailoring", + "TargetAceVersion": "ACE.Server 1.1 / .NET 10", + "DataImpact": "CharacterData", + "RemovalPolicy": "ChangesRemain" +} diff --git a/Source/ACE.SinglePlayer.Mods.SocietyTailoring/Mod.cs b/Source/ACE.SinglePlayer.Mods.SocietyTailoring/Mod.cs new file mode 100644 index 0000000000..45dd662f71 --- /dev/null +++ b/Source/ACE.SinglePlayer.Mods.SocietyTailoring/Mod.cs @@ -0,0 +1,19 @@ +using ACE.Server.Mods; + +using HarmonyLib; + +namespace SocietyTailoring; + +public sealed class Mod : IHarmonyMod +{ + private const string HarmonyId = "aquafir.SocietyTailoring.ace-single-player"; + private readonly Harmony harmony = new(HarmonyId); + + public void Initialize() + { + harmony.PatchAll(typeof(Mod).Assembly); + Console.WriteLine("[SocietyTailoring] Enabled Society armor tailoring while preserving ACE inventory and retained-item checks."); + } + + public void Dispose() => harmony.UnpatchAll(HarmonyId); +} diff --git a/Source/ACE.SinglePlayer.Mods.SocietyTailoring/README.md b/Source/ACE.SinglePlayer.Mods.SocietyTailoring/README.md new file mode 100644 index 0000000000..8335fd55a1 --- /dev/null +++ b/Source/ACE.SinglePlayer.Mods.SocietyTailoring/README.md @@ -0,0 +1,13 @@ +# SocietyTailoring for ACE Single Player + +This is a .NET 10 port of aquafir's `ACE.BaseMod` SocietyTailoring sample. It allows Society armor to pass ACE's tailoring requirement check while preserving the same-object, player-inventory, and retained-item protections in the pinned ACE server build. + +Completed tailoring changes are permanent. Turning the mod off prevents future Society armor tailoring but does not undo items already tailored. + +**Preview warning:** the port has passed build, Harmony target/signature, package, and launcher tests. It has not received thorough in-game testing across every Society armor and tailoring combination. Back up `%LOCALAPPDATA%\ACESinglePlayer` before using it on a valued world. + +Original source: + +Modified source: + +The original and modified source are licensed under the GNU Affero General Public License v3.0, the same license included with ACE Single Player. diff --git a/Source/ACE.SinglePlayer.Mods.SocietyTailoring/SocietyTailoringPatch.cs b/Source/ACE.SinglePlayer.Mods.SocietyTailoring/SocietyTailoringPatch.cs new file mode 100644 index 0000000000..908c87c4f0 --- /dev/null +++ b/Source/ACE.SinglePlayer.Mods.SocietyTailoring/SocietyTailoringPatch.cs @@ -0,0 +1,41 @@ +using ACE.Entity.Enum; +using ACE.Server.Entity; +using ACE.Server.Network.GameMessages.Messages; +using ACE.Server.WorldObjects; + +using HarmonyLib; + +namespace SocietyTailoring; + +[HarmonyPatch] +public static class SocietyTailoringPatch +{ + [HarmonyPrefix] + [HarmonyPatch(typeof(Tailoring), nameof(Tailoring.VerifyUseRequirements), + new[] { typeof(Player), typeof(WorldObject), typeof(WorldObject) })] + public static bool AllowSocietyArmor(Player player, WorldObject source, WorldObject target, ref WeenieError __result) + { + if (source == target || + player.FindObject(source.Guid.Full, Player.SearchLocations.MyInventory) is null || + player.FindObject(target.Guid.Full, Player.SearchLocations.MyInventory) is null) + { + __result = WeenieError.YouDoNotPassCraftingRequirements; + return false; + } + + if (target.Retained) + { + player.Session.Network.EnqueueSend(new GameMessageSystemChat( + "You must use Sandstone Salvage to remove the retained property before tailoring.", + ChatMessageType.Craft)); + __result = WeenieError.YouDoNotPassCraftingRequirements; + return false; + } + + // This intentionally omits stock ACE's IsSocietyArmor rejection. All + // other checks above mirror Tailoring.VerifyUseRequirements in the + // ACE version pinned by this launcher. + __result = WeenieError.None; + return false; + } +} diff --git a/Source/ACE.SinglePlayer.Mods.SocietyTailoring/ace-mod.json b/Source/ACE.SinglePlayer.Mods.SocietyTailoring/ace-mod.json new file mode 100644 index 0000000000..c0854383e8 --- /dev/null +++ b/Source/ACE.SinglePlayer.Mods.SocietyTailoring/ace-mod.json @@ -0,0 +1,8 @@ +{ + "formatVersion": 1, + "id": "aquafir.society-tailoring", + "name": "SocietyTailoring", + "version": "1.0.0-sp1", + "folderName": "SocietyTailoring", + "entryAssembly": "SocietyTailoring.dll" +} diff --git a/Source/ACE.SinglePlayer.TestHost/ACE.SinglePlayer.TestHost.csproj b/Source/ACE.SinglePlayer.TestHost/ACE.SinglePlayer.TestHost.csproj new file mode 100644 index 0000000000..0ffd497ff9 --- /dev/null +++ b/Source/ACE.SinglePlayer.TestHost/ACE.SinglePlayer.TestHost.csproj @@ -0,0 +1,9 @@ + + + Exe + net10.0 + win-x64 + enable + enable + + diff --git a/Source/ACE.SinglePlayer.TestHost/Program.cs b/Source/ACE.SinglePlayer.TestHost/Program.cs new file mode 100644 index 0000000000..2f2e755a03 --- /dev/null +++ b/Source/ACE.SinglePlayer.TestHost/Program.cs @@ -0,0 +1,44 @@ +using System.Diagnostics; +using System.Net; +using System.Net.Sockets; +using System.Text.Json; + +var argumentRecord = Environment.GetEnvironmentVariable("ACE_SINGLEPLAYER_TEST_RECORD_ARGUMENTS"); +if (!string.IsNullOrWhiteSpace(argumentRecord)) +{ + await File.WriteAllTextAsync(argumentRecord, JsonSerializer.Serialize(args)); + return 0; +} + +if (args.Length == 0) + return 2; + +switch (args[0]) +{ + case "--server-ready": + { + var readyPath = args[1]; + var port = int.Parse(args[2]); + using var listener = new UdpClient(new IPEndPoint(IPAddress.Loopback, port)); + var payload = new + { + ProcessId = Environment.ProcessId, + WorldName = "Test World", + Host = "127.0.0.1", + Port = port, + ReadyAtUtc = DateTime.UtcNow + }; + var temporaryPath = readyPath + ".tmp"; + await File.WriteAllTextAsync(temporaryPath, JsonSerializer.Serialize(payload)); + File.Move(temporaryPath, readyPath, true); + await Console.In.ReadLineAsync(); + return 0; + } + case "--server-exit-before-ready": + return 23; + case "--server-timeout": + await Task.Delay(TimeSpan.FromSeconds(30)); + return 0; + default: + return 3; +} diff --git a/Source/ACE.SinglePlayer.Tests/ACE.SinglePlayer.Tests.csproj b/Source/ACE.SinglePlayer.Tests/ACE.SinglePlayer.Tests.csproj new file mode 100644 index 0000000000..d7913f0d87 --- /dev/null +++ b/Source/ACE.SinglePlayer.Tests/ACE.SinglePlayer.Tests.csproj @@ -0,0 +1,23 @@ + + + net10.0-windows + true + win-x64 + x64 + false + enable + enable + + + + + + + + + + + + + + diff --git a/Source/ACE.SinglePlayer.Tests/ClientLaunchTests.cs b/Source/ACE.SinglePlayer.Tests/ClientLaunchTests.cs new file mode 100644 index 0000000000..03becc7e1f --- /dev/null +++ b/Source/ACE.SinglePlayer.Tests/ClientLaunchTests.cs @@ -0,0 +1,48 @@ +using System.Diagnostics; +using System.Text.Json; + +using Microsoft.VisualStudio.TestTools.UnitTesting; + +using ACE.SinglePlayer.ClientLaunch; +using ACE.SinglePlayer.Models; + +namespace ACE.SinglePlayer.Tests; + +[TestClass] +public sealed class ClientLaunchTests +{ + [TestMethod] + public async Task DirectLaunchKeepsEveryCredentialArgumentSeparateAndExact() + { + var directory = TestPaths.CreateTemporaryDirectory(); + try + { + var recordPath = Path.Combine(directory, "arguments.json"); + const string account = "single player account"; + const string password = "p a\"ss&^!%()[]{}"; + var settings = new LauncherSettings + { + ClientExePath = TestPaths.TestHost, + AccountName = account, + Host = "127.0.0.1", + Port = 9000 + }; + var info = DirectClientLaunchProvider.CreateStartInfo(new ClientLaunchRequest(settings, password)); + info.Environment["ACE_SINGLEPLAYER_TEST_RECORD_ARGUMENTS"] = recordPath; + + using var process = Process.Start(info); + Assert.IsNotNull(process); + await process.WaitForExitAsync(); + var arguments = JsonSerializer.Deserialize(await File.ReadAllTextAsync(recordPath)); + + CollectionAssert.AreEqual(new[] + { + "-a", account, "-v", password, "-h", "127.0.0.1:9000" + }, arguments); + } + finally + { + Directory.Delete(directory, true); + } + } +} diff --git a/Source/ACE.SinglePlayer.Tests/ConfigurationAndValidationTests.cs b/Source/ACE.SinglePlayer.Tests/ConfigurationAndValidationTests.cs new file mode 100644 index 0000000000..d1b2d3a129 --- /dev/null +++ b/Source/ACE.SinglePlayer.Tests/ConfigurationAndValidationTests.cs @@ -0,0 +1,264 @@ +using System.Text.Json; + +using Microsoft.VisualStudio.TestTools.UnitTesting; + +using ACE.Common; +using ACE.SinglePlayer.Configuration; +using ACE.SinglePlayer.Infrastructure; +using ACE.SinglePlayer.Models; + +namespace ACE.SinglePlayer.Tests; + +[TestClass] +public sealed class ConfigurationAndValidationTests +{ + [TestMethod] + public void GeneratedConfigurationIsLoopbackTypedAndSinglePlayerSafe() + { + var root = TestPaths.CreateTemporaryDirectory(); + try + { + var protector = new TestProtector(); + var settings = ValidSettings(root); + settings.ProtectedDatabasePassword = protector.Protect("database secret"); + var configuration = new AceConfigurationWriter(protector).Create(settings); + + Assert.AreEqual("127.0.0.1", configuration.Server.Network.Host); + Assert.AreEqual((uint)9000, configuration.Server.Network.Port); + Assert.AreEqual((uint)4, configuration.Server.Network.MaximumAllowedSessions); + Assert.IsTrue(configuration.Server.Accounts.AllowAutoAccountCreation); + Assert.IsFalse(configuration.Server.WorldDatabasePrecaching); + Assert.IsTrue(configuration.Offline.AutoApplyDatabaseUpdates); + Assert.IsFalse(configuration.Offline.AutoUpdateWorldDatabase); + Assert.AreEqual("database secret", configuration.MySql.Shard.Password); + + var json = JsonSerializer.Serialize(configuration, ConfigManager.SerializerOptions); + Assert.IsNotNull(JsonSerializer.Deserialize(json, ConfigManager.SerializerOptions)); + } + finally + { + Directory.Delete(root, true); + } + } + + [TestMethod] + public void ValidatorRequiresExactCurrentAceDatFiles() + { + var root = TestPaths.CreateTemporaryDirectory(); + try + { + var settings = ValidSettings(root); + File.Delete(Path.Combine(settings.DatFilesDirectory, "client_local_English.dat")); + var result = SetupValidator.Validate(settings); + Assert.IsFalse(result.IsValid); + StringAssert.Contains(result.Message, "client_local_English.dat"); + } + finally + { + Directory.Delete(root, true); + } + } + + [TestMethod] + public void ValidatorRejectsClientWhoseDatFilesAreOnlyInASeparateServerDirectory() + { + var root = TestPaths.CreateTemporaryDirectory(); + try + { + var clientDirectory = Path.Combine(root, "ClientWithoutDats"); + var datDirectory = Path.Combine(root, "ServerDats"); + Directory.CreateDirectory(clientDirectory); + Directory.CreateDirectory(datDirectory); + var settings = ValidSettings(root); + settings.ClientExePath = Path.Combine(clientDirectory, "acclient.exe"); + settings.DatFilesDirectory = datDirectory; + File.WriteAllText(settings.ClientExePath, string.Empty); + foreach (var dat in SetupValidator.RequiredClientDatFiles) + File.WriteAllText(Path.Combine(datDirectory, dat), string.Empty); + + var result = SetupValidator.Validate(settings); + + Assert.IsFalse(result.IsValid); + StringAssert.Contains(result.Message, "same folder as acclient.exe"); + StringAssert.Contains(result.Message, "client_highres.dat"); + } + finally + { + Directory.Delete(root, true); + } + } + + [TestMethod] + public void PathRepairerUsesClientDatsOnlyUntilPrivateServerCopyExists() + { + var root = TestPaths.CreateTemporaryDirectory(); + try + { + var applicationDirectory = Path.Combine(root, "InstalledApp"); + var clientDirectory = Path.Combine(root, "CompleteClient"); + var oldDirectory = Path.Combine(root, "OldBuild"); + Directory.CreateDirectory(Path.Combine(applicationDirectory, "Server")); + Directory.CreateDirectory(Path.Combine(applicationDirectory, "Mods")); + Directory.CreateDirectory(clientDirectory); + Directory.CreateDirectory(Path.Combine(oldDirectory, "Mods")); + File.WriteAllText(Path.Combine(applicationDirectory, "Server", "ACE.Server.exe"), string.Empty); + File.WriteAllText(Path.Combine(oldDirectory, "ACE.Server.exe"), string.Empty); + var client = Path.Combine(clientDirectory, "acclient.exe"); + File.WriteAllText(client, string.Empty); + foreach (var dat in SetupValidator.RequiredClientDatFiles) + File.WriteAllText(Path.Combine(clientDirectory, dat), string.Empty); + + var settings = new LauncherSettings + { + ClientExePath = client, + DatFilesDirectory = Path.Combine(root, "MissingServerData"), + ServerExePath = Path.Combine(oldDirectory, "ACE.Server.exe"), + ModsDirectory = Path.Combine(oldDirectory, "Mods") + }; + + Assert.IsTrue(SettingsPathRepairer.Repair(settings, applicationDirectory)); + Assert.AreEqual(clientDirectory, settings.DatFilesDirectory); + Assert.AreEqual(Path.Combine(applicationDirectory, "Server", "ACE.Server.exe"), settings.ServerExePath); + Assert.AreEqual(Path.Combine(applicationDirectory, "Mods"), settings.ModsDirectory); + Assert.IsFalse(SettingsPathRepairer.Repair(settings, applicationDirectory)); + + var privateServerData = Path.Combine(root, "PrivateServerData"); + Directory.CreateDirectory(privateServerData); + foreach (var dat in SetupValidator.RequiredClientDatFiles) + File.WriteAllText(Path.Combine(privateServerData, dat), string.Empty); + settings.DatFilesDirectory = privateServerData; + + Assert.IsFalse(SettingsPathRepairer.Repair(settings, applicationDirectory)); + Assert.AreEqual(privateServerData, settings.DatFilesDirectory); + } + finally + { + Directory.Delete(root, true); + } + } + + [TestMethod] + public async Task ServerDatProvisionerCreatesAndRefreshesPrivateCopyWithoutChangingClientFiles() + { + var root = TestPaths.CreateTemporaryDirectory(); + try + { + var clientDirectory = Path.Combine(root, "Client"); + var targetDirectory = Path.Combine(root, "PrivateServerData"); + var logDirectory = Path.Combine(root, "Logs"); + Directory.CreateDirectory(clientDirectory); + var client = Path.Combine(clientDirectory, "acclient.exe"); + await File.WriteAllTextAsync(client, string.Empty); + foreach (var dat in SetupValidator.RequiredClientDatFiles) + await File.WriteAllTextAsync(Path.Combine(clientDirectory, dat), "original-" + dat); + + var settings = new LauncherSettings { ClientExePath = client }; + using var log = new LauncherLog(logDirectory); + var provisioner = new ServerDatProvisioner(log, targetDirectory); + + var result = await provisioner.EnsureAsync(settings, CancellationToken.None); + + Assert.AreEqual(targetDirectory, result); + Assert.IsFalse(provisioner.RequiresRefresh(settings)); + foreach (var dat in SetupValidator.RequiredClientDatFiles) + Assert.AreEqual("original-" + dat, await File.ReadAllTextAsync(Path.Combine(targetDirectory, dat))); + + var changedDat = Path.Combine(clientDirectory, SetupValidator.RequiredClientDatFiles[0]); + await File.AppendAllTextAsync(changedDat, "-changed"); + Assert.IsTrue(provisioner.RequiresRefresh(settings)); + await provisioner.EnsureAsync(settings, CancellationToken.None); + Assert.AreEqual(await File.ReadAllTextAsync(changedDat), + await File.ReadAllTextAsync(Path.Combine(targetDirectory, SetupValidator.RequiredClientDatFiles[0]))); + } + finally + { + Directory.Delete(root, true); + } + } + + [TestMethod] + public void PortableBundleAutomaticallyConfiguresDatabaseWorldAndProtectedCredentials() + { + var root = TestPaths.CreateTemporaryDirectory(); + try + { + var applicationDirectory = Path.Combine(root, "InstalledApp"); + var clientDirectory = Path.Combine(root, "CompleteClient"); + CreatePortableBundle(applicationDirectory); + Directory.CreateDirectory(clientDirectory); + var client = Path.Combine(clientDirectory, "acclient.exe"); + File.WriteAllText(client, string.Empty); + foreach (var dat in SetupValidator.RequiredClientDatFiles) + File.WriteAllText(Path.Combine(clientDirectory, dat), string.Empty); + + var settings = LauncherSettings.CreateDefaults(applicationDirectory); + settings.ClientExePath = client; + settings.DatFilesDirectory = clientDirectory; + settings.RuntimeDirectory = Path.Combine(root, "Runtime"); + settings.PrivateDatabaseDirectoryPath = Path.Combine(root, "PrivateDatabase"); + + Assert.IsTrue(BundledDistribution.IsComplete(applicationDirectory)); + Assert.IsTrue(AutomaticSetupConfigurator.Configure(settings, applicationDirectory, new TestProtector())); + Assert.AreEqual(DatabaseMode.Private, settings.DatabaseMode); + Assert.AreEqual(BundledDistribution.MariaDbServerPath(applicationDirectory), settings.ManagedDatabaseExePath); + Assert.AreEqual(Path.Combine(applicationDirectory, "Dependencies", "World", "ACE-World-Database-v0.9.294.sql"), + settings.WorldDatabaseSqlPath); + Assert.IsFalse(string.IsNullOrWhiteSpace(settings.ProtectedAccountPassword)); + Assert.IsFalse(string.IsNullOrWhiteSpace(settings.ProtectedDatabasePassword)); + Assert.IsFalse(string.IsNullOrWhiteSpace(settings.ProtectedPrivateDatabaseAdminPassword)); + Assert.AreEqual(settings.ProtectedDatabasePassword, settings.ProtectedPrivateDatabasePassword); + Assert.IsTrue(SetupValidator.Validate(settings).IsValid, SetupValidator.Validate(settings).Message); + } + finally + { + Directory.Delete(root, true); + } + } + + private static void CreatePortableBundle(string applicationDirectory) + { + var serverDirectory = Path.Combine(applicationDirectory, "Server"); + var mariaDbDirectory = Path.Combine(applicationDirectory, "Dependencies", "MariaDB", "bin"); + var worldDirectory = Path.Combine(applicationDirectory, "Dependencies", "World"); + Directory.CreateDirectory(serverDirectory); + Directory.CreateDirectory(Path.Combine(applicationDirectory, "Mods")); + Directory.CreateDirectory(mariaDbDirectory); + Directory.CreateDirectory(worldDirectory); + File.WriteAllText(Path.Combine(serverDirectory, "ACE.Server.exe"), string.Empty); + File.WriteAllText(Path.Combine(mariaDbDirectory, "mariadbd.exe"), string.Empty); + File.WriteAllText(Path.Combine(mariaDbDirectory, "mariadb-install-db.exe"), string.Empty); + File.WriteAllText(Path.Combine(mariaDbDirectory, "mariadb.exe"), string.Empty); + File.WriteAllText(Path.Combine(worldDirectory, "ACE-World-Database-v0.9.294.sql"), + "INSERT INTO `weenie` VALUES (1, 'human');"); + } + + private static LauncherSettings ValidSettings(string root) + { + var client = Path.Combine(root, "acclient.exe"); + var server = Path.Combine(root, "ACE.Server.exe"); + File.WriteAllText(client, string.Empty); + File.WriteAllText(server, string.Empty); + foreach (var dat in SetupValidator.RequiredClientDatFiles) + File.WriteAllText(Path.Combine(root, dat), string.Empty); + return new LauncherSettings + { + ClientExePath = client, + ServerExePath = server, + DatFilesDirectory = root, + ModsDirectory = Path.Combine(root, "Mods"), + RuntimeDirectory = Path.Combine(root, "Runtime"), + DatabaseMode = DatabaseMode.External, + DatabaseHost = "127.0.0.1", + DatabasePort = 3306, + DatabaseUsername = "root", + ProtectedAccountPassword = "protected", + ProtectedDatabasePassword = "protected" + }; + } + + private sealed class TestProtector : ISecretProtector + { + public string Protect(string value) => Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(value)); + public string Unprotect(string protectedValue) => System.Text.Encoding.UTF8.GetString(Convert.FromBase64String(protectedValue)); + } +} diff --git a/Source/ACE.SinglePlayer.Tests/CustomWeenieTests.cs b/Source/ACE.SinglePlayer.Tests/CustomWeenieTests.cs new file mode 100644 index 0000000000..fa6909dc9b --- /dev/null +++ b/Source/ACE.SinglePlayer.Tests/CustomWeenieTests.cs @@ -0,0 +1,139 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; + +using ACE.SinglePlayer.CustomContent; + +namespace ACE.SinglePlayer.Tests; + +[TestClass] +public sealed class CustomWeenieTests +{ + private const string ValidAceForgeSql = """ + /* ===== FILE: 850001 Test Keeper.sql ===== */ + DELETE FROM `weenie` WHERE `class_Id` = 850001; + + INSERT INTO `weenie` (`class_Id`, `class_Name`, `type`, `last_Modified`) + VALUES (850001, 'Test Keeper; Sir ''Semicolon''', 10, '2026-07-16 00:00:00') /* Creature */; + + INSERT INTO `weenie_properties_int` (`object_Id`, `type`, `value`) + VALUES (850001, 1, 16) + , (850001, 5, 10); + """; + + [TestMethod] + public void AceForgeWeenieSqlIsPreviewedWithWcidNameAndType() + { + var result = new CustomWeenieSqlInspector().Inspect("850001 Test Keeper.sql", ValidAceForgeSql); + + Assert.AreEqual(1, result.Definitions.Count); + Assert.AreEqual(0, result.Issues.Count); + Assert.AreEqual((uint)850001, result.Definitions[0].ClassId); + Assert.AreEqual("Test Keeper; Sir 'Semicolon'", result.Definitions[0].ClassName); + Assert.AreEqual("Creature", result.Definitions[0].TypeName); + Assert.AreEqual(64, result.Definitions[0].Sha256.Length); + } + + [TestMethod] + public void AceForgeEmoteParentPatternIsAllowed() + { + var sql = ValidAceForgeSql + """ + + INSERT INTO `weenie_properties_emote` (`object_Id`, `category`, `probability`) + VALUES (850001, 10, 1); + SET @parent_id = LAST_INSERT_ID(); + INSERT INTO `weenie_properties_emote_action` (`emote_Id`, `order`, `type`) + VALUES (@parent_id, 0, 10); + """; + + var result = new CustomWeenieSqlInspector().Inspect("emote.sql", sql); + + Assert.AreEqual(1, result.Definitions.Count); + Assert.AreEqual(0, result.Issues.Count); + } + + [TestMethod] + public void ImportRejectsStatementsOutsideTheWeenieAllowlist() + { + var sql = ValidAceForgeSql + "\r\nDROP TABLE `weenie`;"; + + var result = new CustomWeenieSqlInspector().Inspect("unsafe.sql", sql); + + Assert.AreEqual(0, result.Definitions.Count); + Assert.AreEqual(1, result.Issues.Count); + StringAssert.Contains(result.Issues[0].Message, "Only DELETE"); + } + + [TestMethod] + public void ImportRejectsPropertyRowsForAnotherWcid() + { + var sql = ValidAceForgeSql.Replace("(850001, 5, 10)", "(850002, 5, 10)", StringComparison.Ordinal); + + var result = new CustomWeenieSqlInspector().Inspect("wrong-owner.sql", sql); + + Assert.AreEqual(0, result.Definitions.Count); + StringAssert.Contains(result.Issues[0].Message, "different WCID"); + } + + [TestMethod] + public void ImportRejectsExecutableMysqlComments() + { + var sql = ValidAceForgeSql + "\r\n/*!50000 DROP TABLE weenie */;"; + + var result = new CustomWeenieSqlInspector().Inspect("conditional.sql", sql); + + Assert.AreEqual(0, result.Definitions.Count); + StringAssert.Contains(result.Issues[0].Message, "Executable SQL comments"); + } + + [TestMethod] + public void QuestOnlyAceForgeExportIsReportedAsUnsupported() + { + const string sql = "DELETE FROM quest WHERE name = 'Test'; INSERT INTO quest (id, name) VALUES (1, 'Test');"; + + var result = new CustomWeenieSqlInspector().Inspect("quest.sql", sql); + + Assert.AreEqual(0, result.Definitions.Count); + StringAssert.Contains(result.Issues[0].Message, "quest, event, recipe"); + } + + [TestMethod] + public void DuplicateWcidsInASelectionAreSkipped() + { + var root = TestPaths.CreateTemporaryDirectory(); + try + { + var first = Path.Combine(root, "first.sql"); + var second = Path.Combine(root, "second.sql"); + File.WriteAllText(first, ValidAceForgeSql); + File.WriteAllText(second, ValidAceForgeSql); + + var result = new CustomWeenieSqlInspector().InspectFiles(new[] { first, second }); + + Assert.AreEqual(0, result.Definitions.Count); + Assert.AreEqual(2, result.Issues.Count); + Assert.IsTrue(result.Issues.All(issue => issue.Message.Contains("also defined", StringComparison.Ordinal))); + } + finally + { + Directory.Delete(root, true); + } + } + + [TestMethod] + public void BackupProgramIsFoundBesideMariaDbServer() + { + var root = TestPaths.CreateTemporaryDirectory(); + try + { + var server = Path.Combine(root, "mariadbd.exe"); + var dump = Path.Combine(root, "mariadb-dump.exe"); + File.WriteAllText(server, string.Empty); + File.WriteAllText(dump, string.Empty); + + Assert.AreEqual(dump, MariaDbWorldBackupService.FindDumpExecutable(server)); + } + finally + { + Directory.Delete(root, true); + } + } +} diff --git a/Source/ACE.SinglePlayer.Tests/ModTests.cs b/Source/ACE.SinglePlayer.Tests/ModTests.cs new file mode 100644 index 0000000000..a4e59838d9 --- /dev/null +++ b/Source/ACE.SinglePlayer.Tests/ModTests.cs @@ -0,0 +1,369 @@ +using System.Text.Json.Nodes; +using System.IO.Compression; +using System.Security.Cryptography; + +using Microsoft.VisualStudio.TestTools.UnitTesting; + +using ACE.Server.Command; +using ACE.Server.Entity; +using ACE.Server.WorldObjects; +using ACE.SinglePlayer.Mods; + +using HarmonyLib; + +namespace ACE.SinglePlayer.Tests; + +[TestClass] +public sealed class ModTests +{ + [TestMethod] + public void AquafirCatalogHasUsefulDescriptionsAndSafetyPolicies() + { + Assert.AreEqual(22, AquafirSampleCatalog.Entries.Count); + Assert.AreEqual(22, AquafirSampleCatalog.Entries.Select(entry => entry.Id).Distinct(StringComparer.OrdinalIgnoreCase).Count()); + Assert.IsTrue(AquafirSampleCatalog.Entries.All(entry => !string.IsNullOrWhiteSpace(entry.Description))); + Assert.IsTrue(AquafirSampleCatalog.Entries.All(entry => !entry.Description.Contains("non-existent mod", StringComparison.OrdinalIgnoreCase))); + Assert.IsTrue(AquafirSampleCatalog.Entries.All(entry => !string.IsNullOrWhiteSpace(entry.SafetyNotice))); + + var criticalOverride = AquafirSampleCatalog.Entries.Single(entry => entry.Id == "aquafir.critical-override"); + Assert.AreEqual(ModCatalogAvailability.Ready, criticalOverride.Availability); + Assert.AreEqual(ModRemovalPolicy.Safe, criticalOverride.RemovalPolicy); + + foreach (var preview in AquafirSampleCatalog.Entries.Where(entry => entry.Availability == ModCatalogAvailability.Preview)) + { + Assert.IsFalse(string.IsNullOrWhiteSpace(preview.PackageRelativePath)); + Assert.IsFalse(string.IsNullOrWhiteSpace(preview.SourceUrl)); + Assert.IsFalse(string.IsNullOrWhiteSpace(preview.PortSourceUrl)); + } + Assert.AreEqual(2, AquafirSampleCatalog.Entries.Count(entry => entry.Availability == ModCatalogAvailability.Preview)); + } + + [TestMethod] + public void CuratedCatalogIncludesCustomClothingBaseAsWarnedPreview() + { + Assert.AreEqual(24, CuratedModCatalog.Entries.Count); + var entry = CuratedModCatalog.Entries.Single(item => item.Id == "optimshi.custom-clothing-base"); + Assert.AreEqual("OptimShi", entry.Author); + Assert.AreEqual(ModCatalogAvailability.Preview, entry.Availability); + Assert.AreEqual(ModDataImpact.WorldData, entry.DataImpact); + Assert.AreEqual(ModRemovalPolicy.DoNotRemove, entry.RemovalPolicy); + Assert.IsTrue(entry.SourceUrl.Contains("OptimShi/CustomClothingBase", StringComparison.Ordinal)); + Assert.IsTrue(entry.PreviewNotice.Contains("not been thoroughly tested", StringComparison.OrdinalIgnoreCase)); + Assert.IsTrue(entry.PackageRelativePath.EndsWith(".zip", StringComparison.OrdinalIgnoreCase)); + } + + [TestMethod] + public void CuratedCatalogIncludesUniqueWeeniesProcAsWarnedPreview() + { + var entry = CuratedModCatalog.Entries.Single(item => + item.Id == "titaniumweiner.ace-unique-weenies-proc"); + + Assert.AreEqual("Expanded Cast on Strike", entry.Name); + Assert.AreEqual("titaniumweiner", entry.Author); + Assert.AreEqual(ModCatalogAvailability.Preview, entry.Availability); + Assert.AreEqual(ModDataImpact.SettingsOnly, entry.DataImpact); + Assert.AreEqual(ModRemovalPolicy.Safe, entry.RemovalPolicy); + Assert.IsTrue(entry.SourceUrl.Contains("titaniumweiner/ACEUniqueWeenies", StringComparison.Ordinal)); + Assert.IsTrue(entry.PreviewNotice.Contains("not been thoroughly tested", StringComparison.OrdinalIgnoreCase)); + Assert.IsTrue(entry.PackageRelativePath.EndsWith(".zip", StringComparison.OrdinalIgnoreCase)); + } + + [TestMethod] + public void HelloCommandRegistersAndRemovesCurrentAceCommands() + { + var mod = new HelloCommand.Mod(); + try + { + mod.Initialize(); + Assert.AreEqual(1, CommandManager.GetCommandByName("hello").Count()); + Assert.AreEqual(1, CommandManager.GetCommandByName("bye").Count()); + } + finally + { + mod.Dispose(); + } + + Assert.AreEqual(0, CommandManager.GetCommandByName("hello").Count()); + Assert.AreEqual(0, CommandManager.GetCommandByName("bye").Count()); + } + + [TestMethod] + public void SocietyTailoringPatchTargetsCurrentAceSignature() + { + var original = AccessTools.Method(typeof(Tailoring), nameof(Tailoring.VerifyUseRequirements), + new[] { typeof(Player), typeof(WorldObject), typeof(WorldObject) }); + Assert.IsNotNull(original); + + var mod = new SocietyTailoring.Mod(); + try + { + mod.Initialize(); + var patchInfo = Harmony.GetPatchInfo(original); + Assert.IsNotNull(patchInfo); + Assert.IsTrue(patchInfo.Prefixes.Any(patch => patch.owner == "aquafir.SocietyTailoring.ace-single-player")); + } + finally + { + mod.Dispose(); + } + + var remaining = Harmony.GetPatchInfo(original); + Assert.IsTrue(remaining is null || remaining.Prefixes.All(patch => patch.owner != "aquafir.SocietyTailoring.ace-single-player")); + } + + [TestMethod] + public void UniqueWeeniesProcPatchTargetsCurrentAceSignatureAndCanBeRemoved() + { + var original = AccessTools.Method(typeof(WorldObject), nameof(WorldObject.TryProcEquippedItems), + new[] { typeof(WorldObject), typeof(Creature), typeof(bool), typeof(WorldObject) }); + Assert.IsNotNull(original); + + var mod = new ACEUniqueWeeniesProc.Mod(); + try + { + mod.Initialize(); + var patchInfo = Harmony.GetPatchInfo(original); + Assert.IsNotNull(patchInfo); + Assert.IsTrue(patchInfo.Prefixes.Any(patch => + patch.owner == "titaniumweiner.ACEUniqueWeeniesProc.ace-single-player")); + } + finally + { + mod.Dispose(); + } + + var remaining = Harmony.GetPatchInfo(original); + Assert.IsTrue(remaining is null || remaining.Prefixes.All(patch => + patch.owner != "titaniumweiner.ACEUniqueWeeniesProc.ace-single-player")); + } + + [TestMethod] + public void UniqueWeeniesProcFilterMatchesDocumentedBehavior() + { + Assert.IsTrue(ACEUniqueWeeniesProc.ACEUniqueWeeniesProcPatch.IsEligibleEquippedProc( + hasProc: true, cloakWeaveProc: null, procSpellSelfTargeted: false, selfTarget: false)); + Assert.IsTrue(ACEUniqueWeeniesProc.ACEUniqueWeeniesProcPatch.IsEligibleEquippedProc( + hasProc: true, cloakWeaveProc: 2, procSpellSelfTargeted: true, selfTarget: true)); + Assert.IsFalse(ACEUniqueWeeniesProc.ACEUniqueWeeniesProcPatch.IsEligibleEquippedProc( + hasProc: true, cloakWeaveProc: 1, procSpellSelfTargeted: false, selfTarget: false)); + Assert.IsFalse(ACEUniqueWeeniesProc.ACEUniqueWeeniesProcPatch.IsEligibleEquippedProc( + hasProc: false, cloakWeaveProc: null, procSpellSelfTargeted: false, selfTarget: false)); + Assert.IsFalse(ACEUniqueWeeniesProc.ACEUniqueWeeniesProcPatch.IsEligibleEquippedProc( + hasProc: true, cloakWeaveProc: null, procSpellSelfTargeted: true, selfTarget: false)); + } + + [TestMethod] + public void CatalogBlocksMissingDependenciesBeforeInstall() + { + var dependency = new ModCatalogEntry( + "dependency", "Dependency", "Author", "Description", "Details", "", ModCatalogAvailability.Ready, + ModDataImpact.None, ModRemovalPolicy.Safe, "Safe", PackageRelativePath: "dependency.zip"); + var dependent = new ModCatalogEntry( + "dependent", "Dependent", "Author", "Description", "Details", "", ModCatalogAvailability.Ready, + ModDataImpact.None, ModRemovalPolicy.Safe, "Safe", DependencyIds: new[] { "dependency" }, PackageRelativePath: "dependent.zip"); + + var service = new ModCatalogService(new[] { dependency, dependent }, TestPaths.CreateTemporaryDirectory()); + var result = service.Merge(Array.Empty()).Single(item => item.Catalog.Id == "dependent"); + + Assert.AreEqual(CompatibilityStatus.MissingDependency, result.CompatibilityStatus); + Assert.IsTrue(result.CompatibilityMessage.Contains("Dependency", StringComparison.Ordinal)); + } + + [TestMethod] + public void PreviewPackageIsInstallableButKeepsLimitedTestingWarning() + { + var root = TestPaths.CreateTemporaryDirectory(); + try + { + File.WriteAllText(Path.Combine(root, "preview.zip"), "package placeholder"); + var preview = new ModCatalogEntry( + "preview", "Preview", "Author", "Description", "Details", "https://example.invalid/original", + ModCatalogAvailability.Preview, ModDataImpact.None, ModRemovalPolicy.Safe, "Back up first.", + PackageRelativePath: "preview.zip", PortSourceUrl: "https://example.invalid/port"); + + var item = new ModCatalogService(new[] { preview }, root).Merge(Array.Empty()).Single(); + + Assert.AreEqual(CompatibilityStatus.Compatible, item.CompatibilityStatus); + Assert.AreEqual("Preview - limited testing", item.Status); + Assert.IsTrue(item.CompatibilityMessage.Contains("not received thorough in-game testing", StringComparison.OrdinalIgnoreCase)); + } + finally + { + Directory.Delete(root, true); + } + } + + [TestMethod] + public void CatalogListsInstalledAndReadyToInstallModsBeforeUnavailableMods() + { + var root = TestPaths.CreateTemporaryDirectory(); + try + { + File.WriteAllText(Path.Combine(root, "ready.zip"), "package placeholder"); + var unavailable = new ModCatalogEntry( + "unavailable", "A Needs Port", "Author", "Description", "Details", "", + ModCatalogAvailability.NeedsPort, ModDataImpact.None, ModRemovalPolicy.Safe, "Safe"); + var ready = new ModCatalogEntry( + "ready", "Z Ready", "Author", "Description", "Details", "", + ModCatalogAvailability.Ready, ModDataImpact.None, ModRemovalPolicy.Safe, "Safe", + PackageRelativePath: "ready.zip"); + var installed = new ModRecord { Name = "Installed", Type = ModType.AceServer, Enabled = true }; + + var result = new ModCatalogService(new[] { unavailable, ready }, root).Merge(new[] { installed }); + + Assert.AreEqual("Installed", result[0].Name); + Assert.AreEqual("Z Ready", result[1].Name); + Assert.AreEqual("A Needs Port", result[2].Name); + } + finally + { + Directory.Delete(root, true); + } + } + + [TestMethod] + public async Task ValidatedPackageInstallsAtomically() + { + var root = TestPaths.CreateTemporaryDirectory(); + try + { + var package = Path.Combine(root, "test.zip"); + CreatePackage(package); + var installer = new ModPackageInstaller(); + var installed = await installer.InstallAsync( + package, "test.mod", Path.Combine(root, "Mods"), Path.Combine(root, "Staging")); + + Assert.AreEqual(Path.Combine(root, "Mods", "TestMod"), installed); + Assert.IsTrue(File.Exists(Path.Combine(installed, "TestMod.dll"))); + Assert.IsTrue(File.Exists(Path.Combine(installed, "Meta.json"))); + Assert.IsFalse(Directory.EnumerateFileSystemEntries(Path.Combine(root, "Staging")).Any()); + } + finally + { + Directory.Delete(root, true); + } + } + + [TestMethod] + public async Task ValidatedPackageCanBeInspectedBeforeImport() + { + var root = TestPaths.CreateTemporaryDirectory(); + try + { + var package = Path.Combine(root, "test.zip"); + CreatePackage(package); + var manifest = await new ModPackageInstaller().InspectAsync(package); + + Assert.AreEqual("test.mod", manifest.Id); + Assert.AreEqual("Test Mod", manifest.Name); + Assert.AreEqual("1.0.0", manifest.Version); + Assert.AreEqual("TestMod.dll", manifest.EntryAssembly); + } + finally + { + Directory.Delete(root, true); + } + } + + [TestMethod] + public async Task PackageTraversalIsRejected() + { + var root = TestPaths.CreateTemporaryDirectory(); + try + { + var package = Path.Combine(root, "unsafe.zip"); + CreatePackage(package, "mod/../escaped.txt"); + var installer = new ModPackageInstaller(); + + await Assert.ThrowsAsync(() => installer.InstallAsync( + package, "test.mod", Path.Combine(root, "Mods"), Path.Combine(root, "Staging"))); + Assert.IsFalse(File.Exists(Path.Combine(root, "escaped.txt"))); + } + finally + { + Directory.Delete(root, true); + } + } + + [TestMethod] + public void ServerMetadataParsesCurrentAceFields() + { + var directory = TestPaths.CreateTemporaryDirectory(); + try + { + File.WriteAllText(Path.Combine(directory, "Meta.json"), """ + { "Name":"Test Mod", "Author":"Author", "Description":"Description", "Version":"1.2.3", "Priority":7, "Enabled":false } + """); + var record = AceServerModProvider.Read(directory); + Assert.IsFalse(record.IsMalformed); + Assert.AreEqual("Test Mod", record.Name); + Assert.AreEqual("Author", record.Author); + Assert.AreEqual((uint)7, record.Priority); + Assert.IsFalse(record.Enabled); + } + finally + { + Directory.Delete(directory, true); + } + } + + [TestMethod] + public async Task EnabledTogglePreservesUnknownJsonFields() + { + var directory = TestPaths.CreateTemporaryDirectory(); + try + { + var path = Path.Combine(directory, "Meta.json"); + await File.WriteAllTextAsync(path, """{"Name":"Test","Enabled":true,"FutureField":{"answer":42}}"""); + await ModMetadataEditor.SetEnabledAsync(path, false); + var metadata = ModMetadataEditor.Parse(await File.ReadAllTextAsync(path)); + Assert.IsFalse(metadata["Enabled"]!.GetValue()); + Assert.AreEqual(42, metadata["FutureField"]!["answer"]!.GetValue()); + } + finally + { + Directory.Delete(directory, true); + } + } + + [TestMethod] + public void MalformedMetadataIsVisibleInsteadOfSilentlySkipped() + { + var directory = TestPaths.CreateTemporaryDirectory(); + try + { + File.WriteAllText(Path.Combine(directory, "Meta.json"), "{not json"); + var record = AceServerModProvider.Read(directory); + Assert.IsTrue(record.IsMalformed); + Assert.AreEqual(CompatibilityStatus.LoadFailed, record.CompatibilityStatus); + Assert.IsFalse(string.IsNullOrWhiteSpace(record.LastLoadError)); + } + finally + { + Directory.Delete(directory, true); + } + } + + private static void CreatePackage(string path, string? additionalEntry = null) + { + using (var archive = ZipFile.Open(path, ZipArchiveMode.Create)) + { + WriteEntry(archive, "ace-mod.json", """ + {"formatVersion":1,"id":"test.mod","name":"Test Mod","version":"1.0.0","folderName":"TestMod","entryAssembly":"TestMod.dll"} + """); + WriteEntry(archive, "mod/Meta.json", """{"Name":"Test Mod","Enabled":true}"""); + WriteEntry(archive, "mod/TestMod.dll", "test assembly placeholder"); + if (additionalEntry is not null) + WriteEntry(archive, additionalEntry, "unsafe"); + } + + File.WriteAllText(path + ".sha256", Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(path)))); + } + + private static void WriteEntry(ZipArchive archive, string name, string content) + { + var entry = archive.CreateEntry(name); + using var writer = new StreamWriter(entry.Open()); + writer.Write(content); + } +} diff --git a/Source/ACE.SinglePlayer.Tests/OrchestrationTests.cs b/Source/ACE.SinglePlayer.Tests/OrchestrationTests.cs new file mode 100644 index 0000000000..0ad86f8b42 --- /dev/null +++ b/Source/ACE.SinglePlayer.Tests/OrchestrationTests.cs @@ -0,0 +1,33 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; + +using ACE.SinglePlayer.Orchestration; +using ACE.SinglePlayer.Processes; + +namespace ACE.SinglePlayer.Tests; + +[TestClass] +public sealed class OrchestrationTests +{ + [TestMethod] + public void DuplicatePlayOperationIsPrevented() + { + var gate = new PlayOperationGate(); + Assert.IsTrue(gate.TryEnter()); + Assert.IsFalse(gate.TryEnter()); + Assert.IsTrue(gate.IsActive); + gate.Exit(); + Assert.IsTrue(gate.TryEnter()); + } + + [TestMethod] + public void OwnershipRequiresPidStartTimeAndExecutablePath() + { + var start = DateTime.UtcNow; + var executable = Path.Combine(Path.GetTempPath(), "ACE.Server.exe"); + var record = new ProcessOwnershipRecord { ProcessId = 123, StartTimeUtc = start, ExecutablePath = executable }; + Assert.IsTrue(record.Matches(123, start.AddMilliseconds(100), executable)); + Assert.IsFalse(record.Matches(124, start, executable)); + Assert.IsFalse(record.Matches(123, start.AddSeconds(2), executable)); + Assert.IsFalse(record.Matches(123, start, Path.Combine(Path.GetTempPath(), "Other.Server.exe"))); + } +} diff --git a/Source/ACE.SinglePlayer.Tests/PrivateDatabaseTests.cs b/Source/ACE.SinglePlayer.Tests/PrivateDatabaseTests.cs new file mode 100644 index 0000000000..66a9a7da2d --- /dev/null +++ b/Source/ACE.SinglePlayer.Tests/PrivateDatabaseTests.cs @@ -0,0 +1,213 @@ +using System.Net; +using System.Net.Sockets; + +using Microsoft.VisualStudio.TestTools.UnitTesting; +using MySqlConnector; + +using ACE.SinglePlayer.Database; +using ACE.SinglePlayer.Infrastructure; +using ACE.SinglePlayer.Models; + +namespace ACE.SinglePlayer.Tests; + +[TestClass] +public sealed class PrivateDatabaseTests +{ + [TestMethod] + public void LocatorFindsNewestCompleteMariaDbInstallation() + { + var root = TestPaths.CreateTemporaryDirectory(); + try + { + CreateFakeInstallation(root, "MariaDB 10.11"); + var expected = CreateFakeInstallation(root, "MariaDB 11.2"); + var incomplete = Path.Combine(root, "MariaDB 12.0", "bin"); + Directory.CreateDirectory(incomplete); + File.WriteAllText(Path.Combine(incomplete, "mariadbd.exe"), string.Empty); + + Assert.AreEqual(expected, MariaDbInstallationLocator.FindUnderRoots(new[] { root })); + } + finally + { + Directory.Delete(root, true); + } + } + + [TestMethod] + public void LocatorFindsImportClientBesideServer() + { + var root = TestPaths.CreateTemporaryDirectory(); + try + { + var server = CreateFakeInstallation(root, "MariaDB 11.2"); + var expected = Path.Combine(Path.GetDirectoryName(server)!, "mariadb.exe"); + Assert.AreEqual(expected, MariaDbInstallationLocator.FindClient(server)); + } + finally + { + Directory.Delete(root, true); + } + } + + [TestMethod] + public async Task WorldPackageInspectorRejectsSchemaOnlySqlAndAcceptsRequiredData() + { + var root = TestPaths.CreateTemporaryDirectory(); + try + { + var schemaOnly = Path.Combine(root, "WorldBase.sql"); + var populated = Path.Combine(root, "ACE-World-Database.sql"); + await File.WriteAllTextAsync(schemaOnly, "CREATE TABLE `weenie` (`class_Id` int);"); + await File.WriteAllTextAsync(populated, + new string('-', 70_000) + "INSERT INTO `weenie` VALUES (1,'human',10,'2005-02-09 10:00:00');"); + + Assert.IsFalse(await WorldSqlPackageInspector.ContainsRequiredWorldDataAsync(schemaOnly, CancellationToken.None)); + Assert.IsTrue(await WorldSqlPackageInspector.ContainsRequiredWorldDataAsync(populated, CancellationToken.None)); + } + finally + { + Directory.Delete(root, true); + } + } + + [TestMethod] + public async Task WorldImportStreamReplacesDatabaseNameAcrossBufferBoundaries() + { + var prefix = new string('x', 1024 * 1024 - 5); + await using var source = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(prefix + "`ace_world` tail")); + await using var destination = new MemoryStream(); + + await DatabaseBootstrapper.CopyReplacingAsync(source, destination, + System.Text.Encoding.UTF8.GetBytes("`ace_world`"), + System.Text.Encoding.UTF8.GetBytes("`custom_world`"), CancellationToken.None); + + Assert.AreEqual(prefix + "`custom_world` tail", System.Text.Encoding.UTF8.GetString(destination.ToArray())); + } + + [TestMethod] + public void PortFinderSkipsAnOccupiedLoopbackPort() + { + using var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + var occupied = (ushort)((IPEndPoint)listener.LocalEndpoint).Port; + if (occupied > ushort.MaxValue - 10) + return; + + var selected = PrivateDatabasePortFinder.FindAvailablePort(occupied, (ushort)(occupied + 10)); + Assert.AreNotEqual(occupied, selected); + } + + [TestMethod] + public void PrivateArgumentsStayLoopbackOnlyAndDoNotEnableRemoteRoot() + { + var initialization = ManagedMariaDbRuntime.CreateInitializationArguments(@"C:\ACE Runtime\Database", "secret value", 3307); + var server = ManagedMariaDbRuntime.CreateServerArguments(@"C:\ACE Runtime\Database", 3307); + + CollectionAssert.Contains(initialization.ToArray(), "--password=secret value"); + CollectionAssert.Contains(server.ToArray(), "--bind-address=127.0.0.1"); + CollectionAssert.DoesNotContain(initialization.ToArray(), "--allow-remote-root-access"); + Assert.AreEqual("--no-defaults", server[0]); + } + + [TestMethod] + [TestCategory("MariaDbIntegration")] + public async Task InstalledMariaDbCanInitializeBootstrapValidateAndStopPrivateInstance() + { + var mariaDb = Environment.GetEnvironmentVariable("ACE_TEST_MARIADBD"); + if (string.IsNullOrWhiteSpace(mariaDb) || !File.Exists(mariaDb)) + return; + + var root = TestPaths.CreateTemporaryDirectory(); + try + { + var protector = new SecretProtector(); + var serverDirectory = Path.Combine(root, "Server"); + var baseDirectory = Path.Combine(serverDirectory, "DatabaseSetupScripts", "Base"); + Directory.CreateDirectory(baseDirectory); + var repositoryRoot = FindRepositoryRoot(); + foreach (var name in new[] { "AuthenticationBase.sql", "ShardBase.sql", "WorldBase.sql" }) + File.Copy(Path.Combine(repositoryRoot, "Database", "Base", name), Path.Combine(baseDirectory, name)); + var populatedWorld = Environment.GetEnvironmentVariable("ACE_TEST_WORLD_SQL"); + if (string.IsNullOrWhiteSpace(populatedWorld) || !File.Exists(populatedWorld)) + { + populatedWorld = Path.Combine(baseDirectory, "PopulatedWorld.sql"); + File.Copy(Path.Combine(baseDirectory, "WorldBase.sql"), populatedWorld); + await File.AppendAllTextAsync(populatedWorld, + "\r\nUSE `ace_world`;\r\nINSERT INTO `weenie` VALUES (1,'human',10,'2005-02-09 10:00:00');\r\n"); + } + + var serverExe = Path.Combine(serverDirectory, "ACE.Server.exe"); + File.WriteAllText(serverExe, string.Empty); + var settings = new LauncherSettings + { + DatabaseMode = DatabaseMode.Private, + DatabaseHost = "127.0.0.1", + DatabasePort = PrivateDatabasePortFinder.FindAvailablePort(3340, 3399), + DatabaseUsername = "ace_singleplayer", + ProtectedDatabasePassword = protector.Protect(SecretProtector.GeneratePassword()), + ProtectedPrivateDatabaseAdminPassword = protector.Protect(SecretProtector.GeneratePassword()), + ManagedDatabaseExePath = mariaDb, + RuntimeDirectory = Path.Combine(root, "Runtime"), + PrivateDatabaseDirectoryPath = Path.Combine(root, "Runtime", "Database"), + ServerExePath = serverExe, + WorldDatabaseSqlPath = populatedWorld + }; + settings.ProtectedPrivateDatabasePassword = settings.ProtectedDatabasePassword; + + using var log = new LauncherLog(Path.Combine(root, "Logs")); + var factory = new DatabaseConnectionFactory(protector); + await using var runtime = new ManagedMariaDbRuntime(factory, new ExternalMariaDbRuntime(factory), log); + await runtime.StartAsync(settings, CancellationToken.None); + + // Reproduce the old launcher's failure state: all world tables exist, but they are empty. + await using (var connection = factory.Create(settings)) + { + await connection.OpenAsync(CancellationToken.None); + var schemaOnlySql = await File.ReadAllTextAsync(Path.Combine(baseDirectory, "WorldBase.sql")); + await using var schemaOnly = new MySqlCommand(schemaOnlySql, connection) { CommandTimeout = 300 }; + await schemaOnly.ExecuteNonQueryAsync(CancellationToken.None); + } + + await new DatabaseBootstrapper(factory).BootstrapAsync(settings, settings.WorldDatabaseSqlPath, CancellationToken.None); + var validation = await runtime.ValidateAsync(settings, CancellationToken.None); + + Assert.IsTrue(validation.IsValid, validation.Message); + Assert.IsTrue(Directory.Exists(Path.Combine(settings.PrivateDatabaseDirectory, "mysql"))); + await runtime.StopAsync(CancellationToken.None); + } + finally + { + for (var attempt = 0; attempt < 10; attempt++) + { + try + { + Directory.Delete(root, true); + break; + } + catch (IOException) when (attempt < 9) + { + await Task.Delay(200); + } + } + } + } + + private static string CreateFakeInstallation(string root, string name) + { + var bin = Path.Combine(root, name, "bin"); + Directory.CreateDirectory(bin); + var server = Path.Combine(bin, "mariadbd.exe"); + File.WriteAllText(server, string.Empty); + File.WriteAllText(Path.Combine(bin, "mariadb-install-db.exe"), string.Empty); + File.WriteAllText(Path.Combine(bin, "mariadb.exe"), string.Empty); + return server; + } + + private static string FindRepositoryRoot() + { + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory is not null && !Directory.Exists(Path.Combine(directory.FullName, "Database", "Base"))) + directory = directory.Parent; + return directory?.FullName ?? throw new DirectoryNotFoundException("Could not locate Database\\Base."); + } +} diff --git a/Source/ACE.SinglePlayer.Tests/ReadyAndProcessSimulationTests.cs b/Source/ACE.SinglePlayer.Tests/ReadyAndProcessSimulationTests.cs new file mode 100644 index 0000000000..478d7420bc --- /dev/null +++ b/Source/ACE.SinglePlayer.Tests/ReadyAndProcessSimulationTests.cs @@ -0,0 +1,116 @@ +using System.Diagnostics; +using System.Net; +using System.Net.Sockets; + +using Microsoft.VisualStudio.TestTools.UnitTesting; + +using ACE.SinglePlayer.Networking; + +namespace ACE.SinglePlayer.Tests; + +[TestClass] +public sealed class ReadyAndProcessSimulationTests +{ + [TestMethod] + public void ReadyFileValidationRejectsAnotherProcess() + { + var json = $$"""{"ProcessId":42,"WorldName":"Test","Host":"127.0.0.1","Port":9000,"ReadyAtUtc":"{{DateTime.UtcNow:O}}"}"""; + Assert.ThrowsExactly(() => ReadyFileMonitor.Validate(json, 43, IPAddress.Loopback, 9000)); + } + + [TestMethod] + public async Task SimulatorWritesReadyFileAndBindsExpectedUdpPort() + { + var directory = TestPaths.CreateTemporaryDirectory(); + using var process = StartHost("--server-ready", Path.Combine(directory, "ready.json"), GetFreeUdpPort().ToString()); + try + { + var port = int.Parse(process.StartInfo.ArgumentList[2]); + var result = await new ReadyFileMonitor(new UdpPortProbe()).WaitAsync( + process.StartInfo.ArgumentList[1], process, IPAddress.Loopback, port, TimeSpan.FromSeconds(5), CancellationToken.None); + Assert.AreEqual(process.Id, result.ProcessId); + await process.StandardInput.WriteLineAsync(string.Empty); + await process.WaitForExitAsync(); + } + finally + { + if (!process.HasExited) process.Kill(true); + Directory.Delete(directory, true); + } + } + + [TestMethod] + public async Task SimulatorExitBeforeReadyIsReported() + { + var directory = TestPaths.CreateTemporaryDirectory(); + using var process = StartHost("--server-exit-before-ready"); + try + { + await AssertThrowsAsync(() => new ReadyFileMonitor(new UdpPortProbe()).WaitAsync( + Path.Combine(directory, "ready.json"), process, IPAddress.Loopback, GetFreeUdpPort(), TimeSpan.FromSeconds(5), CancellationToken.None)); + } + finally + { + Directory.Delete(directory, true); + } + } + + [TestMethod] + public async Task SimulatorTimeoutAndPortProbeTimeoutAreDeterministic() + { + var directory = TestPaths.CreateTemporaryDirectory(); + using var process = StartHost("--server-timeout"); + var port = GetFreeUdpPort(); + try + { + await AssertThrowsAsync(() => new ReadyFileMonitor(new UdpPortProbe()).WaitAsync( + Path.Combine(directory, "ready.json"), process, IPAddress.Loopback, port, TimeSpan.FromMilliseconds(250), CancellationToken.None)); + await AssertThrowsAsync(() => new UdpPortProbe().WaitUntilListeningAsync( + IPAddress.Loopback, port, TimeSpan.FromMilliseconds(150), CancellationToken.None)); + } + finally + { + if (!process.HasExited) + { + process.Kill(true); + await process.WaitForExitAsync(); + } + Directory.Delete(directory, true); + } + } + + private static Process StartHost(params string[] arguments) + { + var info = new ProcessStartInfo + { + FileName = TestPaths.TestHost, + WorkingDirectory = Path.GetDirectoryName(TestPaths.TestHost)!, + UseShellExecute = false, + RedirectStandardInput = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + }; + foreach (var argument in arguments) + info.ArgumentList.Add(argument); + return Process.Start(info) ?? throw new InvalidOperationException("Test host did not start."); + } + + private static int GetFreeUdpPort() + { + using var socket = new UdpClient(new IPEndPoint(IPAddress.Loopback, 0)); + return ((IPEndPoint)socket.Client.LocalEndPoint!).Port; + } + + private static async Task AssertThrowsAsync(Func action) where T : Exception + { + try + { + await action(); + Assert.Fail($"Expected {typeof(T).Name}."); + } + catch (T) + { + } + } +} diff --git a/Source/ACE.SinglePlayer.Tests/SavedPrivateDatabaseIntegrationTests.cs b/Source/ACE.SinglePlayer.Tests/SavedPrivateDatabaseIntegrationTests.cs new file mode 100644 index 0000000000..7e9139ecd6 --- /dev/null +++ b/Source/ACE.SinglePlayer.Tests/SavedPrivateDatabaseIntegrationTests.cs @@ -0,0 +1,48 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; + +using ACE.SinglePlayer.Database; +using ACE.SinglePlayer.Infrastructure; +using ACE.SinglePlayer.Models; + +namespace ACE.SinglePlayer.Tests; + +[TestClass] +public sealed class SavedPrivateDatabaseIntegrationTests +{ + [TestMethod] + [TestCategory("SavedPrivateDatabaseIntegration")] + public async Task SavedPrivateDatabaseCanBootstrapAndValidate() + { + if (!string.Equals(Environment.GetEnvironmentVariable("ACE_TEST_SAVED_PRIVATE_DATABASE"), "1", + StringComparison.Ordinal)) + return; + + var settings = await new SettingsStore().LoadAsync() + ?? throw new InvalidOperationException("ACE Single Player settings have not been saved."); + Assert.AreEqual(DatabaseMode.Private, settings.DatabaseMode); + + var logRoot = TestPaths.CreateTemporaryDirectory(); + try + { + using var log = new LauncherLog(logRoot); + var factory = new DatabaseConnectionFactory(new SecretProtector()); + await using var runtime = new ManagedMariaDbRuntime(factory, new ExternalMariaDbRuntime(factory), log); + try + { + await runtime.StartAsync(settings, CancellationToken.None); + await new DatabaseBootstrapper(factory, log).BootstrapAsync( + settings, settings.WorldDatabaseSqlPath, CancellationToken.None); + var validation = await runtime.ValidateAsync(settings, CancellationToken.None); + Assert.IsTrue(validation.IsValid, validation.Message); + } + finally + { + await runtime.StopAsync(CancellationToken.None); + } + } + finally + { + Directory.Delete(logRoot, recursive: true); + } + } +} diff --git a/Source/ACE.SinglePlayer.Tests/SettingsAndSecretsTests.cs b/Source/ACE.SinglePlayer.Tests/SettingsAndSecretsTests.cs new file mode 100644 index 0000000000..99a3539391 --- /dev/null +++ b/Source/ACE.SinglePlayer.Tests/SettingsAndSecretsTests.cs @@ -0,0 +1,148 @@ +using System.Text.Json; + +using Microsoft.VisualStudio.TestTools.UnitTesting; + +using ACE.SinglePlayer.Infrastructure; +using ACE.SinglePlayer.Models; + +namespace ACE.SinglePlayer.Tests; + +[TestClass] +public sealed class SettingsAndSecretsTests +{ + [TestMethod] + public async Task SettingsRoundTripPreservesVersionAndPersistentAccount() + { + var directory = TestPaths.CreateTemporaryDirectory(); + try + { + var store = new SettingsStore(Path.Combine(directory, "settings.json")); + var settings = new LauncherSettings + { + AccountName = "single player account", + ProtectedAccountPassword = "protected-once", + ClientLaunchMode = ClientLaunchMode.Decal, + Port = 9123 + }; + await store.SaveAsync(settings); + var loaded = await store.LoadAsync(); + + Assert.IsNotNull(loaded); + Assert.AreEqual(LauncherSettings.CurrentVersion, loaded.SettingsVersion); + Assert.AreEqual(settings.AccountName, loaded.AccountName); + Assert.AreEqual(settings.ProtectedAccountPassword, loaded.ProtectedAccountPassword); + Assert.AreEqual(ClientLaunchMode.Decal, loaded.ClientLaunchMode); + Assert.AreEqual((ushort)9123, loaded.Port); + } + finally + { + Directory.Delete(directory, true); + } + } + + [TestMethod] + public async Task NewerSettingsVersionIsRejected() + { + var directory = TestPaths.CreateTemporaryDirectory(); + try + { + var path = Path.Combine(directory, "settings.json"); + await File.WriteAllTextAsync(path, JsonSerializer.Serialize(new { SettingsVersion = LauncherSettings.CurrentVersion + 1 })); + var store = new SettingsStore(path); + await AssertThrowsAsync(() => store.LoadAsync()); + } + finally + { + Directory.Delete(directory, true); + } + } + + [TestMethod] + public async Task VersionOneLocalRootSettingsMigrateToPrivateDatabase() + { + var directory = TestPaths.CreateTemporaryDirectory(); + try + { + var path = Path.Combine(directory, "settings.json"); + await File.WriteAllTextAsync(path, + """ + { + "SettingsVersion": 1, + "DatabaseMode": "External", + "DatabaseHost": "127.0.0.1", + "DatabasePort": 3306, + "DatabaseUsername": "root", + "ProtectedDatabasePassword": "old-protected-password" + } + """); + + var loaded = await new SettingsStore(path).LoadAsync(); + + Assert.IsNotNull(loaded); + Assert.AreEqual(LauncherSettings.CurrentVersion, loaded.SettingsVersion); + Assert.AreEqual(DatabaseMode.Private, loaded.DatabaseMode); + Assert.AreEqual((ushort)3307, loaded.DatabasePort); + Assert.AreEqual("ace_singleplayer", loaded.DatabaseUsername); + Assert.AreEqual(string.Empty, loaded.ProtectedDatabasePassword); + Assert.AreEqual("old-protected-password", loaded.ProtectedExternalDatabasePassword); + } + finally + { + Directory.Delete(directory, true); + } + } + + [TestMethod] + public async Task VersionOneManagedRootPasswordBecomesPrivateAdministratorPassword() + { + var directory = TestPaths.CreateTemporaryDirectory(); + try + { + var path = Path.Combine(directory, "settings.json"); + await File.WriteAllTextAsync(path, + """ + { + "SettingsVersion": 1, + "DatabaseMode": "ManagedExperimental", + "DatabaseHost": "127.0.0.1", + "DatabasePort": 3310, + "DatabaseUsername": "root", + "ProtectedDatabasePassword": "old-managed-root-password" + } + """); + + var loaded = await new SettingsStore(path).LoadAsync(); + + Assert.IsNotNull(loaded); + Assert.AreEqual(DatabaseMode.Private, loaded.DatabaseMode); + Assert.AreEqual("old-managed-root-password", loaded.ProtectedPrivateDatabaseAdminPassword); + Assert.AreEqual(string.Empty, loaded.ProtectedDatabasePassword); + } + finally + { + Directory.Delete(directory, true); + } + } + + [TestMethod] + public void DpapiRoundTripUsesCurrentWindowsUser() + { + var protector = new SecretProtector(); + const string secret = "spaces, punctuation !@#$%^&*() and unicode Ω"; + var protectedValue = protector.Protect(secret); + Assert.AreNotEqual(secret, protectedValue); + Assert.AreEqual(secret, protector.Unprotect(protectedValue)); + } + + private static async Task AssertThrowsAsync(Func action) where T : Exception + { + try + { + await action(); + Assert.Fail($"Expected {typeof(T).Name}."); + } + catch (T) + { + } + } +} diff --git a/Source/ACE.SinglePlayer.Tests/TestPaths.cs b/Source/ACE.SinglePlayer.Tests/TestPaths.cs new file mode 100644 index 0000000000..39d0d10e65 --- /dev/null +++ b/Source/ACE.SinglePlayer.Tests/TestPaths.cs @@ -0,0 +1,27 @@ +namespace ACE.SinglePlayer.Tests; + +internal static class TestPaths +{ + public static string TestHost + { + get + { + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory is not null && !string.Equals(directory.Name, "Source", StringComparison.OrdinalIgnoreCase)) + directory = directory.Parent; + if (directory is null) + throw new DirectoryNotFoundException("Could not locate the Source directory from the test output."); + + var configuration = new DirectoryInfo(AppContext.BaseDirectory).Parent?.Parent?.Name ?? "Debug"; + return Path.Combine(directory.FullName, "ACE.SinglePlayer.TestHost", "bin", configuration, + "net10.0", "win-x64", "ACE.SinglePlayer.TestHost.exe"); + } + } + + public static string CreateTemporaryDirectory() + { + var path = Path.Combine(Path.GetTempPath(), "ACE.SinglePlayer.Tests", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(path); + return path; + } +} diff --git a/Source/ACE.SinglePlayer.Tests/UiLayoutTests.cs b/Source/ACE.SinglePlayer.Tests/UiLayoutTests.cs new file mode 100644 index 0000000000..cf48e39ecc --- /dev/null +++ b/Source/ACE.SinglePlayer.Tests/UiLayoutTests.cs @@ -0,0 +1,108 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; + +using ACE.SinglePlayer.UI; +using ACE.SinglePlayer.Models; +using ACE.SinglePlayer.Mods; +using ACE.SinglePlayer.Database; +using ACE.SinglePlayer.Infrastructure; + +namespace ACE.SinglePlayer.Tests; + +[TestClass] +public sealed class UiLayoutTests +{ + [TestMethod] + public void ModLibraryDoesNotDisplayInstalledDecalComponents() + { + var providers = ModsForm.CreateDisplayedModProviders(Path.GetTempPath()); + + Assert.IsFalse(providers.Any(provider => provider.Type == ModType.DecalPlugin)); + Assert.IsTrue(providers.Any(provider => provider.Type == ModType.AceServer)); + } + + [TestMethod] + public void ModLibraryCanBeConstructedBeforeWindowsCompletesLayout() + { + Exception? failure = null; + var thread = new Thread(() => + { + try + { + var settings = new LauncherSettings + { + ModsDirectory = Path.Combine(Path.GetTempPath(), "ACE.SinglePlayer.Tests", "MissingMods"), + RuntimeDirectory = Path.Combine(Path.GetTempPath(), "ACE.SinglePlayer.Tests", "Runtime") + }; + using var form = new ModsForm(settings, () => false); + } + catch (Exception ex) + { + failure = ex; + } + }); + thread.SetApartmentState(ApartmentState.STA); + thread.Start(); + + Assert.IsTrue(thread.Join(TimeSpan.FromSeconds(10)), "The Mods window constructor did not finish."); + Assert.IsNull(failure, failure?.ToString()); + } + + [TestMethod] + public void CustomWeeniesScreenCanBeConstructedBeforeWindowsCompletesLayout() + { + Exception? failure = null; + var thread = new Thread(() => + { + var root = TestPaths.CreateTemporaryDirectory(); + try + { + using var log = new LauncherLog(Path.Combine(root, "Logs")); + var connectionFactory = new DatabaseConnectionFactory(new SecretProtector()); + var runtimeFactory = new DatabaseRuntimeFactory(connectionFactory, log); + var settings = new LauncherSettings + { + RuntimeDirectory = Path.Combine(root, "Runtime"), + PrivateDatabaseDirectoryPath = Path.Combine(root, "Database") + }; + using var form = new CustomWeeniesForm(settings, () => false, runtimeFactory, connectionFactory, log); + } + catch (Exception ex) + { + failure = ex; + } + finally + { + Directory.Delete(root, true); + } + }); + thread.SetApartmentState(ApartmentState.STA); + thread.Start(); + + Assert.IsTrue(thread.Join(TimeSpan.FromSeconds(10)), "The Custom Weenies window constructor did not finish."); + Assert.IsNull(failure, failure?.ToString()); + } + + [TestMethod] + public void ModLibrarySplitterUsesPreferredLayoutAfterFormIsSized() + { + var layout = ModsForm.CalculateSplitterLayout(1224, 6); + + Assert.AreEqual(730, layout.Distance); + Assert.AreEqual(540, layout.Panel1MinSize); + Assert.AreEqual(360, layout.Panel2MinSize); + } + + [TestMethod] + [DataRow(900, 6)] + [DataRow(400, 6)] + [DataRow(100, 6)] + public void ModLibrarySplitterAlwaysFitsNarrowOrScaledLayout(int width, int splitterWidth) + { + var layout = ModsForm.CalculateSplitterLayout(width, splitterWidth); + var available = Math.Max(0, width - splitterWidth); + + Assert.IsGreaterThanOrEqualTo(layout.Distance, layout.Panel1MinSize); + Assert.IsGreaterThanOrEqualTo(available - layout.Distance, layout.Panel2MinSize); + Assert.IsLessThanOrEqualTo(layout.Panel1MinSize + layout.Panel2MinSize, available); + } +} diff --git a/Source/ACE.SinglePlayer.slnx b/Source/ACE.SinglePlayer.slnx new file mode 100644 index 0000000000..13f0394a92 --- /dev/null +++ b/Source/ACE.SinglePlayer.slnx @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/Source/ACE.SinglePlayer/ACE.SinglePlayer.csproj b/Source/ACE.SinglePlayer/ACE.SinglePlayer.csproj new file mode 100644 index 0000000000..1f6166a5a2 --- /dev/null +++ b/Source/ACE.SinglePlayer/ACE.SinglePlayer.csproj @@ -0,0 +1,21 @@ + + + WinExe + net10.0-windows + true + true + win-x64 + x64 + enable + enable + ACE.SinglePlayer + ACE.SinglePlayer + + + + + + + + + diff --git a/Source/ACE.SinglePlayer/Assets/dereth-launcher-background.png b/Source/ACE.SinglePlayer/Assets/dereth-launcher-background.png new file mode 100644 index 0000000000..43dbf57cc2 Binary files /dev/null and b/Source/ACE.SinglePlayer/Assets/dereth-launcher-background.png differ diff --git a/Source/ACE.SinglePlayer/ClientLaunch/DecalClientLaunchProvider.cs b/Source/ACE.SinglePlayer/ClientLaunch/DecalClientLaunchProvider.cs new file mode 100644 index 0000000000..bcf74f1dab --- /dev/null +++ b/Source/ACE.SinglePlayer/ClientLaunch/DecalClientLaunchProvider.cs @@ -0,0 +1,111 @@ +using System.Diagnostics; +using System.Text.Json; + +using ACE.SinglePlayer.Infrastructure; +using ACE.SinglePlayer.Models; + +namespace ACE.SinglePlayer.ClientLaunch; + +public sealed class DecalClientLaunchProvider : IClientLaunchProvider +{ + private readonly LauncherLog log; + + public DecalClientLaunchProvider(LauncherLog log) + { + this.log = log; + } + + public ClientLaunchMode Mode => ClientLaunchMode.Decal; + public string DisplayName => "Decal through Thwarg"; + + public bool IsAvailable(out string reason) + { + if (DecalDetector.Detect() is null) + { + reason = "Decal was not detected in the Windows registry, or its Inject.dll is missing."; + return false; + } + + if (ThwargDetector.Detect() is null) + { + reason = "ThwargLauncher was not detected, or its injector.dll is missing. Install ThwargLauncher to use Decal mode."; + return false; + } + + if (!File.Exists(FindHostPath())) + { + reason = "The ACE Decal launch helper is not installed. Vanilla mode remains available."; + return false; + } + + reason = string.Empty; + return true; + } + + public async Task LaunchAsync(ClientLaunchRequest request, CancellationToken cancellationToken) + { + var installation = DecalDetector.Detect() + ?? throw new InvalidOperationException("Decal is not installed or its Inject.dll is missing. Select Vanilla mode and retry."); + var thwarg = ThwargDetector.Detect() + ?? throw new InvalidOperationException("ThwargLauncher is not installed or its injector.dll is missing. Install ThwargLauncher or select Vanilla mode."); + var hostPath = FindHostPath(); + if (!File.Exists(hostPath)) + throw new FileNotFoundException("The ACE Decal launch helper is missing. Select Vanilla mode and retry.", hostPath); + + var startInfo = new ProcessStartInfo + { + FileName = hostPath, + WorkingDirectory = Path.GetDirectoryName(hostPath)!, + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + }; + Add(startInfo, "--client", request.Settings.ClientExePath); + Add(startInfo, "--decal", installation.InjectDllPath); + Add(startInfo, "--injector", thwarg.InjectorDllPath); + Add(startInfo, "--account", request.Settings.AccountName); + Add(startInfo, "--password", request.AccountPassword); + Add(startInfo, "--host", request.Settings.Host); + Add(startInfo, "--port", request.Settings.Port.ToString()); + + using var host = Process.Start(startInfo) ?? throw new InvalidOperationException("The ACE Decal launch helper did not start."); + var outputTask = host.StandardOutput.ReadToEndAsync(cancellationToken); + var errorTask = host.StandardError.ReadToEndAsync(cancellationToken); + await host.WaitForExitAsync(cancellationToken); + var output = await outputTask; + var error = await errorTask; + if (host.ExitCode != 0) + throw new InvalidOperationException(string.IsNullOrWhiteSpace(error) + ? "Decal injection failed. Select Vanilla mode and retry." + : "Decal injection failed: " + error.Trim()); + + var result = JsonSerializer.Deserialize(output.Trim(), new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + }) ?? throw new InvalidDataException("The Decal launch helper returned no diagnostic result."); + if (!result.DecalStartupInvoked || result.ProcessId <= 0) + throw new InvalidOperationException("Decal's startup entry point was not invoked. Select Vanilla mode and retry."); + + log.Write($"Started client process {result.ProcessId} with Decal through the installed Thwarg injector."); + return Process.GetProcessById(result.ProcessId); + } + + private static void Add(ProcessStartInfo info, string name, string value) + { + info.ArgumentList.Add(name); + info.ArgumentList.Add(value); + } + + private static string FindHostPath() + { + var toolsPath = Path.Combine(AppContext.BaseDirectory, "Tools", "ACE.SinglePlayer.DecalHost.exe"); + return File.Exists(toolsPath) ? toolsPath : Path.Combine(AppContext.BaseDirectory, "ACE.SinglePlayer.DecalHost.exe"); + } + + private sealed class DecalHostResult + { + public int ProcessId { get; set; } + public bool DecalStartupInvoked { get; set; } + } +} diff --git a/Source/ACE.SinglePlayer/ClientLaunch/DecalDetector.cs b/Source/ACE.SinglePlayer/ClientLaunch/DecalDetector.cs new file mode 100644 index 0000000000..88154812d0 --- /dev/null +++ b/Source/ACE.SinglePlayer/ClientLaunch/DecalDetector.cs @@ -0,0 +1,40 @@ +using Microsoft.Win32; + +namespace ACE.SinglePlayer.ClientLaunch; + +public sealed record DecalInstallation(string AgentDirectory, string InjectDllPath); + +public static class DecalDetector +{ + private const string AgentKey = @"SOFTWARE\Decal\Agent"; + + public static DecalInstallation? Detect() + { + if (!OperatingSystem.IsWindows()) + return null; + + foreach (var hive in new[] { RegistryHive.LocalMachine, RegistryHive.CurrentUser }) + foreach (var view in new[] { RegistryView.Registry32, RegistryView.Registry64 }) + { + try + { + using var baseKey = RegistryKey.OpenBaseKey(hive, view); + using var key = baseKey.OpenSubKey(AgentKey); + var agentPath = key?.GetValue("AgentPath") as string; + if (string.IsNullOrWhiteSpace(agentPath)) + continue; + + var directory = agentPath.Trim().TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + var injectPath = Path.Combine(directory, "Inject.dll"); + if (File.Exists(injectPath)) + return new DecalInstallation(directory, injectPath); + } + catch (UnauthorizedAccessException) + { + // Try the next registry view/hive. + } + } + + return null; + } +} diff --git a/Source/ACE.SinglePlayer/ClientLaunch/DirectClientLaunchProvider.cs b/Source/ACE.SinglePlayer/ClientLaunch/DirectClientLaunchProvider.cs new file mode 100644 index 0000000000..4c6d46c3db --- /dev/null +++ b/Source/ACE.SinglePlayer/ClientLaunch/DirectClientLaunchProvider.cs @@ -0,0 +1,48 @@ +using System.Diagnostics; + +using ACE.SinglePlayer.Models; + +namespace ACE.SinglePlayer.ClientLaunch; + +public sealed class DirectClientLaunchProvider : IClientLaunchProvider +{ + public ClientLaunchMode Mode => ClientLaunchMode.Vanilla; + public string DisplayName => "Vanilla"; + + public bool IsAvailable(out string reason) + { + reason = string.Empty; + return true; + } + + public Task LaunchAsync(ClientLaunchRequest request, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + var process = Process.Start(CreateStartInfo(request)) + ?? throw new InvalidOperationException("Windows did not start acclient.exe."); + return Task.FromResult(process); + } + + public static ProcessStartInfo CreateStartInfo(ClientLaunchRequest request) + { + var settings = request.Settings; + var startInfo = new ProcessStartInfo + { + FileName = settings.ClientExePath, + WorkingDirectory = Path.GetDirectoryName(settings.ClientExePath)!, + UseShellExecute = false + }; + AddAceArguments(startInfo.ArgumentList, settings.AccountName, request.AccountPassword, settings.Host, settings.Port); + return startInfo; + } + + public static void AddAceArguments(IList arguments, string account, string password, string host, ushort port) + { + arguments.Add("-a"); + arguments.Add(account); + arguments.Add("-v"); + arguments.Add(password); + arguments.Add("-h"); + arguments.Add($"{host}:{port}"); + } +} diff --git a/Source/ACE.SinglePlayer/ClientLaunch/IClientLaunchProvider.cs b/Source/ACE.SinglePlayer/ClientLaunch/IClientLaunchProvider.cs new file mode 100644 index 0000000000..c7b7809ab1 --- /dev/null +++ b/Source/ACE.SinglePlayer/ClientLaunch/IClientLaunchProvider.cs @@ -0,0 +1,15 @@ +using System.Diagnostics; + +using ACE.SinglePlayer.Models; + +namespace ACE.SinglePlayer.ClientLaunch; + +public sealed record ClientLaunchRequest(LauncherSettings Settings, string AccountPassword); + +public interface IClientLaunchProvider +{ + ClientLaunchMode Mode { get; } + string DisplayName { get; } + bool IsAvailable(out string reason); + Task LaunchAsync(ClientLaunchRequest request, CancellationToken cancellationToken); +} diff --git a/Source/ACE.SinglePlayer/ClientLaunch/ThwargDetector.cs b/Source/ACE.SinglePlayer/ClientLaunch/ThwargDetector.cs new file mode 100644 index 0000000000..712cca4852 --- /dev/null +++ b/Source/ACE.SinglePlayer/ClientLaunch/ThwargDetector.cs @@ -0,0 +1,40 @@ +using Microsoft.Win32; + +namespace ACE.SinglePlayer.ClientLaunch; + +public sealed record ThwargInstallation(string Directory, string InjectorDllPath); + +public static class ThwargDetector +{ + private const string LauncherKey = @"SOFTWARE\Thwargle Games\ThwargLauncher"; + + public static ThwargInstallation? Detect() + { + if (!OperatingSystem.IsWindows()) + return null; + + foreach (var hive in new[] { RegistryHive.LocalMachine, RegistryHive.CurrentUser }) + foreach (var view in new[] { RegistryView.Registry32, RegistryView.Registry64 }) + { + try + { + using var baseKey = RegistryKey.OpenBaseKey(hive, view); + using var key = baseKey.OpenSubKey(LauncherKey); + var launcherPath = key?.GetValue("Path") as string; + if (string.IsNullOrWhiteSpace(launcherPath)) + continue; + + var directory = launcherPath.Trim().TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + var injectorPath = Path.Combine(directory, "injector.dll"); + if (File.Exists(injectorPath)) + return new ThwargInstallation(directory, injectorPath); + } + catch (UnauthorizedAccessException) + { + // Try the next registry view/hive. + } + } + + return null; + } +} diff --git a/Source/ACE.SinglePlayer/Configuration/AceConfigurationWriter.cs b/Source/ACE.SinglePlayer/Configuration/AceConfigurationWriter.cs new file mode 100644 index 0000000000..b592137179 --- /dev/null +++ b/Source/ACE.SinglePlayer/Configuration/AceConfigurationWriter.cs @@ -0,0 +1,76 @@ +using System.Text.Json; + +using ACE.Common; +using ACE.SinglePlayer.Infrastructure; +using ACE.SinglePlayer.Models; + +namespace ACE.SinglePlayer.Configuration; + +public sealed class AceConfigurationWriter +{ + private readonly ISecretProtector secretProtector; + + public AceConfigurationWriter(ISecretProtector secretProtector) + { + this.secretProtector = secretProtector; + } + + public MasterConfiguration Create(LauncherSettings settings) + { + var databasePassword = secretProtector.Unprotect(settings.ProtectedDatabasePassword); + var configuration = new MasterConfiguration(); + configuration.Server.WorldName = "ACE Single Player"; + configuration.Server.Network.Host = "127.0.0.1"; + configuration.Server.Network.Port = settings.Port; + configuration.Server.Network.MaximumAllowedSessions = 4; + configuration.Server.Network.MaximumAllowedSessionsPerIPAddress = 4; + configuration.Server.Network.AllowUnlimitedSessionsFromIPAddresses = Array.Empty(); + configuration.Server.Accounts.AllowAutoAccountCreation = true; + configuration.Server.DatFilesDirectory = Path.GetFullPath(settings.DatFilesDirectory); + configuration.Server.ModsDirectory = Path.GetFullPath(settings.ModsDirectory); + configuration.Server.WorldDatabasePrecaching = false; + configuration.Server.ServerPerformanceMonitorAutoStart = false; + configuration.Server.ShutdownInterval = 0; + + SetDatabase(configuration.MySql.Authentication, settings, settings.AuthenticationDatabaseName, databasePassword); + SetDatabase(configuration.MySql.Shard, settings, settings.ShardDatabaseName, databasePassword); + SetDatabase(configuration.MySql.World, settings, settings.WorldDatabaseName, databasePassword); + + configuration.Offline.AutoApplyDatabaseUpdates = true; + configuration.Offline.AutoUpdateWorldDatabase = false; + configuration.Offline.AutoApplyWorldCustomizations = false; + configuration.Offline.AutoServerUpdateCheck = false; + configuration.DDD.EnableDATPatching = false; + configuration.DDD.PrecacheCompressedDATFiles = false; + return configuration; + } + + public async Task WriteAsync(LauncherSettings settings, CancellationToken cancellationToken = default) + { + Directory.CreateDirectory(settings.RuntimeDirectory); + var temporaryPath = settings.ConfigPath + "." + Guid.NewGuid().ToString("N") + ".tmp"; + try + { + await using (var stream = new FileStream(temporaryPath, FileMode.CreateNew, FileAccess.Write, FileShare.None)) + await JsonSerializer.SerializeAsync(stream, Create(settings), ConfigManager.SerializerOptions, cancellationToken); + File.Move(temporaryPath, settings.ConfigPath, true); + FilePermissionHardener.RestrictToCurrentUser(settings.ConfigPath); + } + finally + { + if (File.Exists(temporaryPath)) + File.Delete(temporaryPath); + } + } + + private static void SetDatabase(MySqlConfiguration target, LauncherSettings settings, string database, string password) + { + target.Host = settings.DatabaseHost; + target.Port = settings.DatabasePort; + target.Database = database; + target.Username = settings.DatabaseUsername; + target.Password = password; + target.EnableDetailedErrors = false; + target.EnableSensitiveDataLogging = false; + } +} diff --git a/Source/ACE.SinglePlayer/CustomContent/CustomWeenieImportService.cs b/Source/ACE.SinglePlayer/CustomContent/CustomWeenieImportService.cs new file mode 100644 index 0000000000..b944902c4b --- /dev/null +++ b/Source/ACE.SinglePlayer/CustomContent/CustomWeenieImportService.cs @@ -0,0 +1,141 @@ +using System.Text.Json; + +using MySqlConnector; + +using ACE.SinglePlayer.Database; +using ACE.SinglePlayer.Infrastructure; +using ACE.SinglePlayer.Models; + +namespace ACE.SinglePlayer.CustomContent; + +internal sealed record ExistingWeenie(uint ClassId, string ClassName); + +internal sealed record CustomWeenieImportResult(bool Imported, string BackupPath) +{ + public static CustomWeenieImportResult Canceled { get; } = new(false, string.Empty); +} + +internal sealed class CustomWeenieImportService +{ + private readonly DatabaseRuntimeFactory runtimeFactory; + private readonly DatabaseConnectionFactory connectionFactory; + private readonly MariaDbWorldBackupService backupService; + private readonly LauncherLog log; + + public CustomWeenieImportService(DatabaseRuntimeFactory runtimeFactory, + DatabaseConnectionFactory connectionFactory, LauncherLog log) + { + this.runtimeFactory = runtimeFactory; + this.connectionFactory = connectionFactory; + this.log = log; + backupService = new MariaDbWorldBackupService(connectionFactory, log); + } + + public async Task ImportAsync( + LauncherSettings settings, + IReadOnlyList definitions, + Func, bool> confirmReplacement, + IProgress? progress, + CancellationToken cancellationToken) + { + if (settings.DatabaseMode != DatabaseMode.Private) + throw new InvalidOperationException( + "Automatic Custom Weenie imports currently require the launcher's Private Database mode so a verified backup can be created first."); + if (definitions.Count == 0) + throw new InvalidOperationException("No valid custom weenies were selected."); + var duplicate = definitions.GroupBy(item => item.ClassId).FirstOrDefault(group => group.Count() > 1); + if (duplicate is not null) + throw new InvalidOperationException($"WCID {duplicate.Key} appears more than once in this import."); + + progress?.Report("Starting the private database..."); + await using var runtime = runtimeFactory.Create(settings); + await runtime.StartAsync(settings, cancellationToken); + + await using var connection = connectionFactory.Create(settings, settings.WorldDatabaseName); + await connection.OpenAsync(cancellationToken); + var existing = await FindExistingAsync(connection, definitions, cancellationToken); + if (existing.Count > 0 && !confirmReplacement(existing)) + return CustomWeenieImportResult.Canceled; + + progress?.Report("Creating a complete ace_world safety backup..."); + var backupPath = await backupService.CreateAsync(settings, cancellationToken); + + progress?.Report($"Importing {definitions.Count} custom weenie{(definitions.Count == 1 ? string.Empty : "s")}..."); + await using var transaction = await connection.BeginTransactionAsync(cancellationToken); + try + { + foreach (var definition in definitions) + { + cancellationToken.ThrowIfCancellationRequested(); + await using var command = new MySqlCommand(definition.Sql, connection, transaction) + { + CommandTimeout = 120 + }; + await command.ExecuteNonQueryAsync(cancellationToken); + } + await transaction.CommitAsync(cancellationToken); + } + catch + { + try + { + await transaction.RollbackAsync(CancellationToken.None); + } + catch (MySqlException rollbackError) + { + log.Write("Custom weenie import rollback reported an error: " + rollbackError.Message); + } + throw; + } + + log.Write($"Imported {definitions.Count} custom weenie(s): {string.Join(", ", definitions.Select(item => item.ClassId))}."); + await WriteManifestBestEffortAsync(backupPath, definitions); + progress?.Report("Import complete. The new content will be available the next time the server starts."); + return new CustomWeenieImportResult(true, backupPath); + } + + private static async Task> FindExistingAsync(MySqlConnection connection, + IReadOnlyList definitions, CancellationToken cancellationToken) + { + var parameterNames = definitions.Select((_, index) => "@id" + index).ToArray(); + await using var command = new MySqlCommand( + $"SELECT `class_Id`, `class_Name` FROM `weenie` WHERE `class_Id` IN ({string.Join(",", parameterNames)}) ORDER BY `class_Id`", + connection); + for (var index = 0; index < definitions.Count; index++) + command.Parameters.AddWithValue(parameterNames[index], definitions[index].ClassId); + + var result = new List(); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + result.Add(new ExistingWeenie(reader.GetUInt32(0), reader.GetString(1))); + return result; + } + + private async Task WriteManifestBestEffortAsync(string backupPath, + IReadOnlyList definitions) + { + try + { + var manifestPath = Path.ChangeExtension(backupPath, ".import.json"); + var manifest = new + { + importedAt = DateTimeOffset.Now, + safetyBackup = backupPath, + weenies = definitions.Select(item => new + { + wcid = item.ClassId, + name = item.ClassName, + type = item.WeenieType, + sourceFile = item.FilePath, + sha256 = item.Sha256 + }).ToArray() + }; + await File.WriteAllTextAsync(manifestPath, + JsonSerializer.Serialize(manifest, new JsonSerializerOptions { WriteIndented = true })); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + log.Write("The custom weenie import succeeded, but its history manifest could not be written: " + ex.Message); + } + } +} diff --git a/Source/ACE.SinglePlayer/CustomContent/CustomWeenieSqlInspector.cs b/Source/ACE.SinglePlayer/CustomContent/CustomWeenieSqlInspector.cs new file mode 100644 index 0000000000..7a9ee613ec --- /dev/null +++ b/Source/ACE.SinglePlayer/CustomContent/CustomWeenieSqlInspector.cs @@ -0,0 +1,575 @@ +using System.Globalization; +using System.Security.Cryptography; +using System.Text; +using System.Text.RegularExpressions; + +namespace ACE.SinglePlayer.CustomContent; + +internal sealed record CustomWeenieDefinition( + string FilePath, + uint ClassId, + string ClassName, + int WeenieType, + string Sql, + string Sha256) +{ + public string FileName => Path.GetFileName(FilePath); + + public string TypeName => WeenieType switch + { + 2 => "Container", + 6 => "Generic", + 7 => "Food", + 9 => "Gem", + 10 => "Creature", + 12 => "Vendor", + 16 => "Melee weapon", + 17 => "Missile weapon", + 18 => "Caster", + 19 => "Clothing", + 20 => "Armor", + 21 => "Scroll", + 22 or 51 => "Stackable", + 35 => "Generator", + _ => $"Type {WeenieType}" + }; +} + +internal sealed record CustomWeenieIssue(string FilePath, string Message); + +internal sealed record CustomWeenieInspectionResult( + IReadOnlyList Definitions, + IReadOnlyList Issues); + +internal sealed class CustomWeenieSqlInspector +{ + internal const int MaximumFiles = 200; + internal const long MaximumFileBytes = 5 * 1024 * 1024; + internal const long MaximumTotalBytes = 25 * 1024 * 1024; + + private static readonly Regex InsertPrefix = new( + @"\AINSERT\s+INTO\s+`?(?[a-z0-9_]+)`?\s*\((?[^)]*)\)\s*VALUES\s*(?[\s\S]+)\z", + RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + private static readonly Regex DeleteWeenie = new( + @"\ADELETE\s+FROM\s+`?weenie`?\s+WHERE\s+`?class_Id`?\s*=\s*(?\d+)\s*\z", + RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + private static readonly Regex SetParentId = new( + @"\ASET\s+@parent_id\s*=\s*LAST_INSERT_ID\s*\(\s*\)\s*\z", + RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + private static readonly Regex UnsupportedAceForgeContent = new( + @"\b(?:DELETE\s+FROM|INSERT\s+INTO)\s+`?(?:quest|event|recipe|cook_book|treasure_death)\b", + RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + private static readonly Regex ExecutableComment = new(@"/\*(?:!\d*|\+)", + RegexOptions.CultureInvariant); + + private static readonly HashSet AllowedPropertyTables = new(StringComparer.OrdinalIgnoreCase) + { + "weenie_properties_anim_part", + "weenie_properties_attribute", + "weenie_properties_attribute_2nd", + "weenie_properties_body_part", + "weenie_properties_book", + "weenie_properties_book_page_data", + "weenie_properties_bool", + "weenie_properties_create_list", + "weenie_properties_d_i_d", + "weenie_properties_emote", + "weenie_properties_emote_action", + "weenie_properties_event_filter", + "weenie_properties_float", + "weenie_properties_generator", + "weenie_properties_i_i_d", + "weenie_properties_int", + "weenie_properties_int64", + "weenie_properties_palette", + "weenie_properties_position", + "weenie_properties_skill", + "weenie_properties_spell_book", + "weenie_properties_string", + "weenie_properties_texture_map" + }; + + public CustomWeenieInspectionResult InspectFiles(IEnumerable filePaths) + { + var paths = filePaths + .Where(path => !string.IsNullOrWhiteSpace(path)) + .Select(Path.GetFullPath) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(path => path, StringComparer.OrdinalIgnoreCase) + .ToArray(); + if (paths.Length > MaximumFiles) + { + return new CustomWeenieInspectionResult(Array.Empty(), + new[] { new CustomWeenieIssue(string.Empty, $"Select no more than {MaximumFiles} SQL files at once.") }); + } + + var definitions = new List(); + var issues = new List(); + long totalBytes = 0; + foreach (var path in paths) + { + try + { + var file = new FileInfo(path); + if (!file.Exists) + throw new FileNotFoundException("The SQL file no longer exists.", path); + if (!string.Equals(file.Extension, ".sql", StringComparison.OrdinalIgnoreCase)) + throw new InvalidDataException("Only .sql files can be imported here."); + if (file.Length > MaximumFileBytes) + throw new InvalidDataException($"The file is larger than {MaximumFileBytes / 1024 / 1024} MB."); + totalBytes += file.Length; + if (totalBytes > MaximumTotalBytes) + throw new InvalidDataException($"The selected files exceed the {MaximumTotalBytes / 1024 / 1024} MB import limit."); + + var sql = File.ReadAllText(path); + var inspected = Inspect(path, sql); + if (inspected.Definitions.Count == 1) + definitions.Add(inspected.Definitions[0]); + issues.AddRange(inspected.Issues); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidDataException) + { + issues.Add(new CustomWeenieIssue(path, ex.Message)); + } + } + + var duplicateIds = definitions + .GroupBy(item => item.ClassId) + .Where(group => group.Count() > 1) + .ToArray(); + foreach (var duplicate in duplicateIds) + { + foreach (var definition in duplicate) + issues.Add(new CustomWeenieIssue(definition.FilePath, + $"WCID {duplicate.Key} is also defined by another selected file; both copies were skipped.")); + } + if (duplicateIds.Length > 0) + { + var duplicateSet = duplicateIds.Select(group => group.Key).ToHashSet(); + definitions.RemoveAll(item => duplicateSet.Contains(item.ClassId)); + } + + return new CustomWeenieInspectionResult(definitions, issues); + } + + internal CustomWeenieInspectionResult Inspect(string filePath, string sql) + { + try + { + if (string.IsNullOrWhiteSpace(sql)) + throw new InvalidDataException("The SQL file is empty."); + if (ExecutableComment.IsMatch(sql)) + throw new InvalidDataException("Executable SQL comments are not allowed."); + + var cleaned = StripComments(sql); + var statements = SplitStatements(cleaned); + if (statements.Count == 0) + throw new InvalidDataException("The SQL file does not contain any statements."); + + var inserts = new List(); + foreach (var statement in statements.Where(statement => + statement.StartsWith("INSERT", StringComparison.OrdinalIgnoreCase))) + { + if (!TryParseInsert(statement, out var parsed, out var error)) + throw new InvalidDataException(error); + inserts.Add(parsed!); + } + + var rootInserts = inserts.Where(insert => + string.Equals(insert.Table, "weenie", StringComparison.OrdinalIgnoreCase)).ToArray(); + if (rootInserts.Length != 1) + { + if (rootInserts.Length == 0 && UnsupportedAceForgeContent.IsMatch(cleaned)) + throw new InvalidDataException("This is an AceForge quest, event, recipe, or treasure script. The Custom Weenies section currently imports weenie SQL only."); + throw new InvalidDataException("Each file must define exactly one weenie record."); + } + + var root = rootInserts[0]; + if (root.Rows.Count != 1) + throw new InvalidDataException("The weenie INSERT must contain exactly one row."); + var classId = ReadUIntColumn(root, root.Rows[0], "class_Id"); + if (classId == 0) + throw new InvalidDataException("WCID 0 is not valid."); + var className = ReadStringColumn(root, root.Rows[0], "class_Name"); + var weenieType = ReadIntColumn(root, root.Rows[0], "type"); + + var deleteCount = 0; + var rootInsertIndex = statements.FindIndex(statement => string.Equals(statement, root.Statement, StringComparison.Ordinal)); + var lastParentSetIndex = -1; + for (var index = 0; index < statements.Count; index++) + { + var statement = statements[index]; + var delete = DeleteWeenie.Match(statement); + if (delete.Success) + { + deleteCount++; + if (!uint.TryParse(delete.Groups["id"].Value, NumberStyles.None, CultureInfo.InvariantCulture, out var deletedId) || + deletedId != classId) + throw new InvalidDataException($"The DELETE statement must target WCID {classId}."); + if (index > rootInsertIndex) + throw new InvalidDataException("The weenie DELETE must appear before its INSERT."); + continue; + } + + if (SetParentId.IsMatch(statement)) + { + lastParentSetIndex = index; + continue; + } + + if (!statement.StartsWith("INSERT", StringComparison.OrdinalIgnoreCase)) + throw new InvalidDataException("Only DELETE FROM weenie, INSERT INTO weenie property tables, and AceForge's SET @parent_id statement are allowed."); + if (!TryParseInsert(statement, out var insert, out var parseError)) + throw new InvalidDataException(parseError); + ValidateInsert(insert!, classId, index, lastParentSetIndex); + } + + if (deleteCount != 1) + throw new InvalidDataException("Each file must contain exactly one DELETE FROM weenie statement."); + + var hash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(sql))).ToLowerInvariant(); + var definition = new CustomWeenieDefinition(filePath, classId, className, weenieType, sql, hash); + return new CustomWeenieInspectionResult(new[] { definition }, Array.Empty()); + } + catch (InvalidDataException ex) + { + return new CustomWeenieInspectionResult(Array.Empty(), + new[] { new CustomWeenieIssue(filePath, ex.Message) }); + } + } + + private static void ValidateInsert(ParsedInsert insert, uint classId, int statementIndex, int lastParentSetIndex) + { + if (string.Equals(insert.Table, "weenie", StringComparison.OrdinalIgnoreCase)) + { + if (insert.Rows.Count != 1 || ReadUIntColumn(insert, insert.Rows[0], "class_Id") != classId) + throw new InvalidDataException("The weenie INSERT contains an unexpected WCID."); + return; + } + + if (!AllowedPropertyTables.Contains(insert.Table)) + throw new InvalidDataException($"Table '{insert.Table}' is not allowed in a Custom Weenie import."); + if (insert.Rows.Count == 0) + throw new InvalidDataException($"The INSERT into '{insert.Table}' contains no rows."); + + if (string.Equals(insert.Table, "weenie_properties_emote_action", StringComparison.OrdinalIgnoreCase)) + { + var emoteIndex = FindColumn(insert.Columns, "emote_Id"); + if (emoteIndex < 0 || insert.Rows.Any(row => row.Count <= emoteIndex || + !string.Equals(row[emoteIndex].Trim(), "@parent_id", StringComparison.OrdinalIgnoreCase))) + throw new InvalidDataException("Emote actions must be linked through AceForge's @parent_id value."); + if (lastParentSetIndex < 0 || lastParentSetIndex > statementIndex) + throw new InvalidDataException("An emote action is missing its preceding SET @parent_id statement."); + return; + } + + var objectIndex = FindColumn(insert.Columns, "object_Id"); + if (objectIndex < 0) + throw new InvalidDataException($"The INSERT into '{insert.Table}' does not identify its owning weenie."); + foreach (var row in insert.Rows) + { + if (row.Count <= objectIndex || + !uint.TryParse(row[objectIndex].Trim(), NumberStyles.None, CultureInfo.InvariantCulture, out var objectId) || + objectId != classId) + throw new InvalidDataException($"The INSERT into '{insert.Table}' attempts to change a different WCID."); + } + } + + private static uint ReadUIntColumn(ParsedInsert insert, IReadOnlyList row, string column) + { + var index = FindColumn(insert.Columns, column); + if (index < 0 || row.Count <= index || + !uint.TryParse(row[index].Trim(), NumberStyles.None, CultureInfo.InvariantCulture, out var value)) + throw new InvalidDataException($"The weenie INSERT has an invalid {column} value."); + return value; + } + + private static int ReadIntColumn(ParsedInsert insert, IReadOnlyList row, string column) + { + var index = FindColumn(insert.Columns, column); + if (index < 0 || row.Count <= index || + !int.TryParse(row[index].Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var value)) + throw new InvalidDataException($"The weenie INSERT has an invalid {column} value."); + return value; + } + + private static string ReadStringColumn(ParsedInsert insert, IReadOnlyList row, string column) + { + var index = FindColumn(insert.Columns, column); + if (index < 0 || row.Count <= index || !TryUnquote(row[index].Trim(), out var value) || string.IsNullOrWhiteSpace(value)) + throw new InvalidDataException($"The weenie INSERT has an invalid {column} value."); + return value; + } + + private static int FindColumn(IReadOnlyList columns, string name) + { + for (var index = 0; index < columns.Count; index++) + { + if (string.Equals(NormalizeIdentifier(columns[index]), name, StringComparison.OrdinalIgnoreCase)) + return index; + } + return -1; + } + + private static string NormalizeIdentifier(string value) => value.Trim().Trim('`'); + + private static bool TryUnquote(string value, out string result) + { + result = string.Empty; + if (value.Length < 2 || value[0] is not ('\'' or '"') || value[^1] != value[0]) + return false; + var quote = value[0]; + var content = value[1..^1]; + content = content.Replace(new string(quote, 2), quote.ToString(), StringComparison.Ordinal) + .Replace("\\'", "'", StringComparison.Ordinal) + .Replace("\\\"", "\"", StringComparison.Ordinal) + .Replace("\\\\", "\\", StringComparison.Ordinal); + result = content; + return true; + } + + private static bool TryParseInsert(string statement, out ParsedInsert? insert, out string error) + { + insert = null; + error = "The INSERT statement is not in the supported AceForge format."; + var match = InsertPrefix.Match(statement); + if (!match.Success) + return false; + + try + { + var columns = SplitCommaSeparated(match.Groups["columns"].Value); + var rows = ParseValueRows(match.Groups["values"].Value); + if (columns.Count == 0 || rows.Any(row => row.Count != columns.Count)) + { + error = "An INSERT row does not match its declared columns."; + return false; + } + insert = new ParsedInsert(statement, match.Groups["table"].Value, columns, rows); + return true; + } + catch (InvalidDataException ex) + { + error = ex.Message; + return false; + } + } + + private static IReadOnlyList> ParseValueRows(string valueText) + { + var rows = new List>(); + var index = 0; + while (index < valueText.Length) + { + SkipWhitespace(valueText, ref index); + if (index >= valueText.Length || valueText[index] != '(') + throw new InvalidDataException("INSERT VALUES must be a comma-separated list of rows."); + var start = ++index; + var depth = 1; + var quote = '\0'; + while (index < valueText.Length && depth > 0) + { + var character = valueText[index]; + if (quote != '\0') + { + if (character == '\\' && index + 1 < valueText.Length) + index += 2; + else if (character == quote) + { + if (index + 1 < valueText.Length && valueText[index + 1] == quote && quote != '`') + index += 2; + else + { + quote = '\0'; + index++; + } + } + else + index++; + continue; + } + + if (character is '\'' or '"' or '`') + { + quote = character; + index++; + } + else if (character == '(') + { + depth++; + index++; + } + else if (character == ')') + { + depth--; + if (depth == 0) + break; + index++; + } + else + index++; + } + if (depth != 0) + throw new InvalidDataException("An INSERT row has an unmatched parenthesis or quote."); + rows.Add(SplitCommaSeparated(valueText[start..index])); + index++; + SkipWhitespace(valueText, ref index); + if (index >= valueText.Length) + break; + if (valueText[index] != ',') + throw new InvalidDataException("Unsupported text appears after an INSERT row."); + index++; + } + return rows; + } + + private static IReadOnlyList SplitCommaSeparated(string value) + { + var result = new List(); + var start = 0; + var depth = 0; + var quote = '\0'; + for (var index = 0; index < value.Length; index++) + { + var character = value[index]; + if (quote != '\0') + { + if (character == '\\' && index + 1 < value.Length) + index++; + else if (character == quote) + { + if (index + 1 < value.Length && value[index + 1] == quote && quote != '`') + index++; + else + quote = '\0'; + } + continue; + } + if (character is '\'' or '"' or '`') + quote = character; + else if (character == '(') + depth++; + else if (character == ')') + depth--; + else if (character == ',' && depth == 0) + { + result.Add(value[start..index].Trim()); + start = index + 1; + } + } + if (quote != '\0' || depth != 0) + throw new InvalidDataException("A SQL value has an unmatched quote or parenthesis."); + result.Add(value[start..].Trim()); + return result; + } + + private static List SplitStatements(string sql) + { + var statements = new List(); + var start = 0; + var quote = '\0'; + for (var index = 0; index < sql.Length; index++) + { + var character = sql[index]; + if (quote != '\0') + { + if (character == '\\' && index + 1 < sql.Length) + index++; + else if (character == quote) + { + if (index + 1 < sql.Length && sql[index + 1] == quote && quote != '`') + index++; + else + quote = '\0'; + } + continue; + } + if (character is '\'' or '"' or '`') + quote = character; + else if (character == ';') + { + var statement = sql[start..index].Trim(); + if (statement.Length > 0) + statements.Add(statement); + start = index + 1; + } + } + if (quote != '\0') + throw new InvalidDataException("The SQL contains an unmatched quote."); + var tail = sql[start..].Trim(); + if (tail.Length > 0) + statements.Add(tail); + return statements; + } + + private static string StripComments(string sql) + { + var output = new StringBuilder(sql.Length); + var quote = '\0'; + for (var index = 0; index < sql.Length; index++) + { + var character = sql[index]; + if (quote != '\0') + { + output.Append(character); + if (character == '\\' && index + 1 < sql.Length) + output.Append(sql[++index]); + else if (character == quote) + { + if (index + 1 < sql.Length && sql[index + 1] == quote && quote != '`') + output.Append(sql[++index]); + else + quote = '\0'; + } + continue; + } + + if (character is '\'' or '"' or '`') + { + quote = character; + output.Append(character); + continue; + } + if (character == '/' && index + 1 < sql.Length && sql[index + 1] == '*') + { + index += 2; + while (index + 1 < sql.Length && !(sql[index] == '*' && sql[index + 1] == '/')) + { + if (sql[index] is '\r' or '\n') + output.Append(sql[index]); + index++; + } + if (index + 1 >= sql.Length) + throw new InvalidDataException("The SQL contains an unterminated block comment."); + index++; + output.Append(' '); + continue; + } + if (character == '#' || (character == '-' && index + 1 < sql.Length && sql[index + 1] == '-' && + (index + 2 >= sql.Length || char.IsWhiteSpace(sql[index + 2])))) + { + while (index < sql.Length && sql[index] is not ('\r' or '\n')) + index++; + if (index < sql.Length) + output.Append(sql[index]); + continue; + } + output.Append(character); + } + if (quote != '\0') + throw new InvalidDataException("The SQL contains an unmatched quote."); + return output.ToString(); + } + + private static void SkipWhitespace(string value, ref int index) + { + while (index < value.Length && char.IsWhiteSpace(value[index])) + index++; + } + + private sealed record ParsedInsert( + string Statement, + string Table, + IReadOnlyList Columns, + IReadOnlyList> Rows); +} diff --git a/Source/ACE.SinglePlayer/CustomContent/MariaDbWorldBackupService.cs b/Source/ACE.SinglePlayer/CustomContent/MariaDbWorldBackupService.cs new file mode 100644 index 0000000000..c7e213ae8a --- /dev/null +++ b/Source/ACE.SinglePlayer/CustomContent/MariaDbWorldBackupService.cs @@ -0,0 +1,154 @@ +using System.Diagnostics; +using System.Text; + +using MySqlConnector; + +using ACE.SinglePlayer.Database; +using ACE.SinglePlayer.Infrastructure; +using ACE.SinglePlayer.Models; + +namespace ACE.SinglePlayer.CustomContent; + +internal sealed class MariaDbWorldBackupService +{ + private readonly DatabaseConnectionFactory connectionFactory; + private readonly LauncherLog log; + + public MariaDbWorldBackupService(DatabaseConnectionFactory connectionFactory, LauncherLog log) + { + this.connectionFactory = connectionFactory; + this.log = log; + } + + public async Task CreateAsync(LauncherSettings settings, CancellationToken cancellationToken) + { + var dumpExecutable = FindDumpExecutable(settings.ManagedDatabaseExePath) + ?? throw new FileNotFoundException( + "The bundled MariaDB backup program was not found. Extract the complete ACE Single Player release again before importing custom content."); + var privateRoot = Path.GetDirectoryName(settings.PrivateDatabaseDirectory) + ?? throw new InvalidOperationException("The private database folder has no parent directory."); + var backupDirectory = GetBackupDirectory(settings); + Directory.CreateDirectory(backupDirectory); + var stamp = DateTimeOffset.Now.ToString("yyyyMMdd-HHmmss"); + var backupPath = Path.Combine(backupDirectory, + $"ace_world-before-custom-weenies-{stamp}-{Guid.NewGuid():N}.sql"); + var optionsPath = Path.Combine(privateRoot, "DatabaseBackup-" + Guid.NewGuid().ToString("N") + ".cnf"); + + using var databaseConnection = connectionFactory.Create(settings, settings.WorldDatabaseName); + var connection = new MySqlConnectionStringBuilder(databaseConnection.ConnectionString); + var options = $"[client]{Environment.NewLine}" + + $"host={connection.Server}{Environment.NewLine}" + + $"port={connection.Port}{Environment.NewLine}" + + $"user={connection.UserID}{Environment.NewLine}" + + $"password={connection.Password}{Environment.NewLine}" + + $"protocol=tcp{Environment.NewLine}"; + + try + { + await File.WriteAllTextAsync(optionsPath, options, + new UTF8Encoding(encoderShouldEmitUTF8Identifier: false), cancellationToken); + FilePermissionHardener.RestrictToCurrentUser(optionsPath); + + var startInfo = new ProcessStartInfo + { + FileName = dumpExecutable, + WorkingDirectory = Path.GetDirectoryName(dumpExecutable)!, + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + }; + // MariaDB requires the defaults-file argument to be first. + startInfo.ArgumentList.Add($"--defaults-extra-file={optionsPath}"); + startInfo.ArgumentList.Add("--single-transaction"); + startInfo.ArgumentList.Add("--quick"); + startInfo.ArgumentList.Add("--skip-lock-tables"); + startInfo.ArgumentList.Add("--no-tablespaces"); + startInfo.ArgumentList.Add("--hex-blob"); + startInfo.ArgumentList.Add("--default-character-set=utf8mb4"); + startInfo.ArgumentList.Add($"--result-file={backupPath}"); + startInfo.ArgumentList.Add(settings.WorldDatabaseName); + + using var process = new Process { StartInfo = startInfo }; + log.Write("Creating a complete ace_world backup before importing custom weenies."); + if (!process.Start()) + throw new InvalidOperationException("The MariaDB backup program did not start."); + + var standardOutput = process.StandardOutput.ReadToEndAsync(cancellationToken); + var standardError = process.StandardError.ReadToEndAsync(cancellationToken); + try + { + await process.WaitForExitAsync(cancellationToken); + } + catch + { + if (!process.HasExited) + process.Kill(entireProcessTree: true); + throw; + } + + var output = await standardOutput; + var error = await standardError; + if (process.ExitCode != 0) + throw new InvalidOperationException( + $"MariaDB could not create the safety backup (exit code {process.ExitCode}). {Condense(error)}"); + if (!File.Exists(backupPath) || new FileInfo(backupPath).Length == 0) + throw new InvalidOperationException("MariaDB reported success, but the safety backup is empty."); + if (!string.IsNullOrWhiteSpace(output)) + log.Write("[MariaDB backup] " + Condense(output)); + log.Write($"Custom-content safety backup created at '{backupPath}'."); + return backupPath; + } + catch + { + TryDelete(backupPath); + throw; + } + finally + { + TryDelete(optionsPath); + } + } + + internal static string? FindDumpExecutable(string serverExecutable) + { + if (string.IsNullOrWhiteSpace(serverExecutable)) + return null; + var directory = Path.GetDirectoryName(Path.GetFullPath(serverExecutable)); + if (directory is null) + return null; + return new[] { "mariadb-dump.exe", "mysqldump.exe" } + .Select(name => Path.Combine(directory, name)) + .FirstOrDefault(File.Exists); + } + + internal static string GetBackupDirectory(LauncherSettings settings) + { + var root = Path.GetDirectoryName(settings.PrivateDatabaseDirectory) + ?? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "ACESinglePlayer"); + return Path.Combine(root, "Backups", "CustomWeenies"); + } + + private static string Condense(string value) + { + var condensed = string.Join(" ", value.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries)); + return condensed.Length <= 800 ? condensed : condensed[..800] + "..."; + } + + private static void TryDelete(string path) + { + try + { + if (File.Exists(path)) + File.Delete(path); + } + catch (IOException) + { + // A transient cleanup failure must not hide the original result. + } + catch (UnauthorizedAccessException) + { + // A transient cleanup failure must not hide the original result. + } + } +} diff --git a/Source/ACE.SinglePlayer/Database/DatabaseBootstrapper.cs b/Source/ACE.SinglePlayer/Database/DatabaseBootstrapper.cs new file mode 100644 index 0000000000..375a33035c --- /dev/null +++ b/Source/ACE.SinglePlayer/Database/DatabaseBootstrapper.cs @@ -0,0 +1,264 @@ +using System.Diagnostics; +using System.Text; + +using MySqlConnector; + +using ACE.SinglePlayer.Infrastructure; +using ACE.SinglePlayer.Models; + +namespace ACE.SinglePlayer.Database; + +public sealed class DatabaseBootstrapper +{ + private readonly DatabaseConnectionFactory connectionFactory; + private readonly LauncherLog? log; + + public DatabaseBootstrapper(DatabaseConnectionFactory connectionFactory, LauncherLog? log = null) + { + this.connectionFactory = connectionFactory; + this.log = log; + } + + public async Task BootstrapAsync(LauncherSettings settings, string worldSqlPath, CancellationToken cancellationToken) + { + var baseDirectory = Path.Combine(Path.GetDirectoryName(settings.ServerExePath)!, "DatabaseSetupScripts", "Base"); + var authenticationSql = Path.Combine(baseDirectory, "AuthenticationBase.sql"); + var shardSql = Path.Combine(baseDirectory, "ShardBase.sql"); + if (!File.Exists(authenticationSql) || !File.Exists(shardSql)) + throw new FileNotFoundException("ACE authentication and shard base SQL files were not found under Server\\DatabaseSetupScripts\\Base."); + + await using var connection = connectionFactory.Create(settings); + await connection.OpenAsync(cancellationToken); + + await ApplyIfMissingAsync(connection, settings.AuthenticationDatabaseName, "account", authenticationSql, "ace_auth", cancellationToken); + await ApplyIfMissingAsync(connection, settings.ShardDatabaseName, "character", shardSql, "ace_shard", cancellationToken); + await PrepareWorldAsync(connection, settings, worldSqlPath, cancellationToken); + } + + private async Task PrepareWorldAsync(MySqlConnection connection, LauncherSettings settings, string worldSqlPath, + CancellationToken cancellationToken) + { + var schemaExists = await ExternalMariaDbRuntime.SchemaExistsAsync(connection, settings.WorldDatabaseName, cancellationToken); + var tableExists = schemaExists && await ExternalMariaDbRuntime.TableExistsAsync( + connection, settings.WorldDatabaseName, "weenie", cancellationToken); + var requiredDataExists = tableExists && await ExternalMariaDbRuntime.RequiredHumanExistsAsync( + connection, settings.WorldDatabaseName, cancellationToken); + if (requiredDataExists) + return; + + if (schemaExists && settings.DatabaseMode == DatabaseMode.External) + throw new InvalidOperationException( + $"Database '{settings.WorldDatabaseName}' exists but is missing required ACE world data. It was not modified."); + + if (!File.Exists(worldSqlPath)) + throw new FileNotFoundException( + "The bundled populated ACE World database is missing. Extract the complete release again, or select an advanced replacement in Settings.", worldSqlPath); + if (!await WorldSqlPackageInspector.ContainsRequiredWorldDataAsync(worldSqlPath, cancellationToken)) + throw new InvalidDataException( + "The configured World SQL file contains empty table definitions but not the required ACE world data. " + + "Re-extract the complete release or select a populated ACE-World-Database package in advanced Settings."); + + if (settings.DatabaseMode == DatabaseMode.Private) + await ImportPrivateWorldAsync(settings, worldSqlPath, cancellationToken); + else + await ApplySqlAsync(connection, settings.WorldDatabaseName, "weenie", worldSqlPath, "ace_world", cancellationToken); + + if (!await ExternalMariaDbRuntime.TableExistsAsync(connection, settings.WorldDatabaseName, "weenie", cancellationToken) || + !await ExternalMariaDbRuntime.RequiredHumanExistsAsync(connection, settings.WorldDatabaseName, cancellationToken)) + { + throw new InvalidOperationException( + $"The world SQL package completed but '{settings.WorldDatabaseName}' still lacks the required Human world record (weenie 1)."); + } + } + + private async Task ImportPrivateWorldAsync(LauncherSettings settings, string scriptPath, + CancellationToken cancellationToken) + { + var client = MariaDbInstallationLocator.FindClient(settings.ManagedDatabaseExePath) + ?? throw new FileNotFoundException( + "The MariaDB import program (mariadb.exe or mysql.exe) was not found beside mariadbd.exe. Repair the MariaDB installation and retry."); + var privateRoot = Path.GetDirectoryName(settings.PrivateDatabaseDirectory) + ?? throw new InvalidOperationException("The private database directory has no parent directory."); + Directory.CreateDirectory(privateRoot); + var optionsPath = Path.Combine(privateRoot, "DatabaseImport-" + Guid.NewGuid().ToString("N") + ".cnf"); + var targetDatabase = settings.WorldDatabaseName.Replace("`", "``", StringComparison.Ordinal); + using var administrator = connectionFactory.CreatePrivateAdministrator(settings); + var connection = new MySqlConnectionStringBuilder(administrator.ConnectionString); + var options = $"[client]{Environment.NewLine}" + + $"host={connection.Server}{Environment.NewLine}" + + $"port={connection.Port}{Environment.NewLine}" + + $"user={connection.UserID}{Environment.NewLine}" + + $"password={connection.Password}{Environment.NewLine}" + + $"protocol=tcp{Environment.NewLine}"; + + try + { + await File.WriteAllTextAsync(optionsPath, options, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false), cancellationToken); + FilePermissionHardener.RestrictToCurrentUser(optionsPath); + + var startInfo = new ProcessStartInfo + { + FileName = client, + WorkingDirectory = Path.GetDirectoryName(client)!, + UseShellExecute = false, + RedirectStandardInput = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + }; + // MariaDB requires the defaults-file argument to be first. + startInfo.ArgumentList.Add($"--defaults-extra-file={optionsPath}"); + startInfo.ArgumentList.Add("--binary-mode=1"); + startInfo.ArgumentList.Add("--max-allowed-packet=1G"); + + using var process = new Process { StartInfo = startInfo }; + log?.Write($"Importing the populated ACE world database from '{Path.GetFileName(scriptPath)}'. This can take several minutes."); + if (!process.Start()) + throw new InvalidOperationException("The MariaDB world import process did not start."); + + var standardOutput = process.StandardOutput.ReadToEndAsync(); + var standardError = process.StandardError.ReadToEndAsync(); + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeout.CancelAfter(TimeSpan.FromMinutes(20)); + var importToken = timeout.Token; + Exception? copyError = null; + try + { + try + { + await using var sql = new FileStream(scriptPath, FileMode.Open, FileAccess.Read, FileShare.Read, + bufferSize: 1024 * 1024, FileOptions.Asynchronous | FileOptions.SequentialScan); + await CopyReplacingAsync(sql, process.StandardInput.BaseStream, + Encoding.UTF8.GetBytes("`ace_world`"), Encoding.UTF8.GetBytes($"`{targetDatabase}`"), importToken); + await process.StandardInput.BaseStream.FlushAsync(importToken); + } + catch (Exception ex) when (ex is IOException or ObjectDisposedException) + { + copyError = ex; + } + finally + { + process.StandardInput.Close(); + } + + await process.WaitForExitAsync(importToken); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + throw new TimeoutException("The ACE world database import did not finish within 20 minutes."); + } + finally + { + if (!process.HasExited) + { + process.Kill(entireProcessTree: true); + await process.WaitForExitAsync(CancellationToken.None); + } + } + + var output = Condense((await standardError) + " " + (await standardOutput)); + if (process.ExitCode != 0 || copyError is not null) + { + throw new InvalidOperationException( + $"The ACE world database import failed with exit code {process.ExitCode}. {output}", copyError); + } + + log?.Write("The populated ACE world database imported successfully."); + } + finally + { + if (File.Exists(optionsPath)) + { + try + { + File.Delete(optionsPath); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + log?.Write("The temporary private-database import settings could not be removed: " + ex.Message); + } + } + } + } + + internal static async Task CopyReplacingAsync(Stream source, Stream destination, byte[] search, byte[] replacement, + CancellationToken cancellationToken) + { + if (search.Length == 0) + throw new ArgumentException("The search sequence cannot be empty.", nameof(search)); + + var buffer = new byte[1024 * 1024 + search.Length - 1]; + var retained = 0; + while (true) + { + var read = await source.ReadAsync(buffer.AsMemory(retained, buffer.Length - retained), cancellationToken); + var total = retained + read; + var finalBlock = read == 0; + var processThrough = finalBlock ? total : Math.Max(0, total - search.Length + 1); + var segmentStart = 0; + var index = 0; + + while (index < processThrough && index <= total - search.Length) + { + if (buffer.AsSpan(index, search.Length).SequenceEqual(search)) + { + await destination.WriteAsync(buffer.AsMemory(segmentStart, index - segmentStart), cancellationToken); + await destination.WriteAsync(replacement, cancellationToken); + index += search.Length; + segmentStart = index; + } + else + { + index++; + } + } + + var keepFrom = finalBlock ? total : Math.Max(segmentStart, processThrough); + await destination.WriteAsync(buffer.AsMemory(segmentStart, keepFrom - segmentStart), cancellationToken); + if (finalBlock) + return; + + retained = total - keepFrom; + buffer.AsSpan(keepFrom, retained).CopyTo(buffer); + } + } + + private static async Task ApplyIfMissingAsync(MySqlConnection connection, string database, string coreTable, + string scriptPath, string defaultDatabase, CancellationToken cancellationToken) + { + if (await ExternalMariaDbRuntime.SchemaExistsAsync(connection, database, cancellationToken)) + { + if (!await ExternalMariaDbRuntime.TableExistsAsync(connection, database, coreTable, cancellationToken)) + throw new InvalidOperationException($"Database '{database}' exists but is partial. It was not modified."); + return; + } + + await ApplySqlAsync(connection, database, coreTable, scriptPath, defaultDatabase, cancellationToken); + } + + private static async Task ApplySqlAsync(MySqlConnection connection, string database, string coreTable, + string scriptPath, string defaultDatabase, CancellationToken cancellationToken) + { + var sql = await File.ReadAllTextAsync(scriptPath, cancellationToken); + sql = sql.Replace($"`{defaultDatabase}`", $"`{database.Replace("`", "``", StringComparison.Ordinal)}`", StringComparison.Ordinal); + await using var command = new MySqlCommand(sql, connection) { CommandTimeout = 300 }; + try + { + await command.ExecuteNonQueryAsync(cancellationToken); + } + catch (Exception ex) + { + throw new InvalidOperationException( + $"Initializing '{database}' failed. The database may now be partial; correct or remove it before retrying. {ex.Message}", ex); + } + + if (!await ExternalMariaDbRuntime.TableExistsAsync(connection, database, coreTable, cancellationToken)) + throw new InvalidOperationException($"The SQL package completed but '{database}.{coreTable}' is still missing."); + } + + private static string Condense(string value) + { + var condensed = string.Join(" ", value.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries)); + return condensed.Length <= 1000 ? condensed : condensed[..1000] + "..."; + } +} diff --git a/Source/ACE.SinglePlayer/Database/DatabaseConnectionFactory.cs b/Source/ACE.SinglePlayer/Database/DatabaseConnectionFactory.cs new file mode 100644 index 0000000000..d253d41fa5 --- /dev/null +++ b/Source/ACE.SinglePlayer/Database/DatabaseConnectionFactory.cs @@ -0,0 +1,50 @@ +using MySqlConnector; + +using ACE.SinglePlayer.Infrastructure; +using ACE.SinglePlayer.Models; + +namespace ACE.SinglePlayer.Database; + +public sealed class DatabaseConnectionFactory +{ + private readonly ISecretProtector secretProtector; + + public DatabaseConnectionFactory(ISecretProtector secretProtector) + { + this.secretProtector = secretProtector; + } + + public MySqlConnection Create(LauncherSettings settings, string? database = null) + { + return Create(settings, settings.DatabaseUsername, + secretProtector.Unprotect(settings.ProtectedDatabasePassword), database); + } + + public MySqlConnection CreatePrivateAdministrator(LauncherSettings settings) + { + if (settings.DatabaseMode == DatabaseMode.External) + throw new InvalidOperationException("Private administrator credentials are not available in external database mode."); + + return Create(settings, "root", + secretProtector.Unprotect(settings.ProtectedPrivateDatabaseAdminPassword), database: null); + } + + private static MySqlConnection Create(LauncherSettings settings, string username, string password, string? database) + { + var builder = new MySqlConnectionStringBuilder + { + Server = settings.DatabaseHost, + Port = settings.DatabasePort, + UserID = username, + Password = password, + Database = database ?? string.Empty, + SslMode = MySqlSslMode.None, + AllowPublicKeyRetrieval = true, + AllowUserVariables = true, + ConnectionTimeout = 5, + DefaultCommandTimeout = 120, + ApplicationName = "ACE.SinglePlayer" + }; + return new MySqlConnection(builder.ConnectionString); + } +} diff --git a/Source/ACE.SinglePlayer/Database/DatabaseRuntimeFactory.cs b/Source/ACE.SinglePlayer/Database/DatabaseRuntimeFactory.cs new file mode 100644 index 0000000000..715f1b8117 --- /dev/null +++ b/Source/ACE.SinglePlayer/Database/DatabaseRuntimeFactory.cs @@ -0,0 +1,24 @@ +using ACE.SinglePlayer.Infrastructure; +using ACE.SinglePlayer.Models; + +namespace ACE.SinglePlayer.Database; + +public sealed class DatabaseRuntimeFactory +{ + private readonly DatabaseConnectionFactory connectionFactory; + private readonly LauncherLog log; + + public DatabaseRuntimeFactory(DatabaseConnectionFactory connectionFactory, LauncherLog log) + { + this.connectionFactory = connectionFactory; + this.log = log; + } + + public IDatabaseRuntime Create(LauncherSettings settings) + { + var external = new ExternalMariaDbRuntime(connectionFactory); + return settings.DatabaseMode == DatabaseMode.External + ? external + : new ManagedMariaDbRuntime(connectionFactory, external, log); + } +} diff --git a/Source/ACE.SinglePlayer/Database/ExternalMariaDbRuntime.cs b/Source/ACE.SinglePlayer/Database/ExternalMariaDbRuntime.cs new file mode 100644 index 0000000000..2ad7718ed0 --- /dev/null +++ b/Source/ACE.SinglePlayer/Database/ExternalMariaDbRuntime.cs @@ -0,0 +1,96 @@ +using MySqlConnector; + +using ACE.SinglePlayer.Models; + +namespace ACE.SinglePlayer.Database; + +public sealed class ExternalMariaDbRuntime : IDatabaseRuntime +{ + private static readonly (Func Database, string Table)[] RequiredSchemas = + { + (settings => settings.AuthenticationDatabaseName, "account"), + (settings => settings.ShardDatabaseName, "character"), + (settings => settings.WorldDatabaseName, "weenie") + }; + + private readonly DatabaseConnectionFactory connectionFactory; + + public ExternalMariaDbRuntime(DatabaseConnectionFactory connectionFactory) + { + this.connectionFactory = connectionFactory; + } + + public bool IsManaged => false; + + public async Task StartAsync(LauncherSettings settings, CancellationToken cancellationToken) + { + var result = await ValidateAsync(settings, cancellationToken); + if (!result.IsValid) + throw new InvalidOperationException(result.Message); + } + + public async Task ValidateAsync(LauncherSettings settings, CancellationToken cancellationToken) + { + try + { + await using var connection = connectionFactory.Create(settings); + await connection.OpenAsync(cancellationToken); + + foreach (var required in RequiredSchemas) + { + var database = required.Database(settings); + if (!await SchemaExistsAsync(connection, database, cancellationToken)) + return new(false, $"Database '{database}' is missing. Use Initialize Database during setup."); + if (!await TableExistsAsync(connection, database, required.Table, cancellationToken)) + return new(false, $"Database '{database}' is only partially initialized; required table '{required.Table}' is missing."); + } + + if (!await RequiredHumanExistsAsync(connection, settings.WorldDatabaseName, cancellationToken)) + { + return new(false, + $"World database '{settings.WorldDatabaseName}' has tables but not the required Human world record (weenie 1). " + + "Select the populated ACE-World-Database SQL package; Database\\Base\\WorldBase.sql contains only empty tables."); + } + + return new(true, "MariaDB/MySQL is reachable and all three ACE databases contain the required ACE data."); + } + catch (MySqlException ex) + { + return new(false, $"MariaDB/MySQL connection failed: {ex.Message}"); + } + catch (TimeoutException ex) + { + return new(false, ex.Message); + } + } + + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + public ValueTask DisposeAsync() => ValueTask.CompletedTask; + + internal static async Task SchemaExistsAsync(MySqlConnection connection, string database, CancellationToken cancellationToken) + { + await using var command = new MySqlCommand( + "SELECT COUNT(*) FROM information_schema.schemata WHERE schema_name = @database", connection); + command.Parameters.AddWithValue("@database", database); + return Convert.ToInt32(await command.ExecuteScalarAsync(cancellationToken)) == 1; + } + + internal static async Task TableExistsAsync(MySqlConnection connection, string database, string table, CancellationToken cancellationToken) + { + await using var command = new MySqlCommand( + "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = @database AND table_name = @table", connection); + command.Parameters.AddWithValue("@database", database); + command.Parameters.AddWithValue("@table", table); + return Convert.ToInt32(await command.ExecuteScalarAsync(cancellationToken)) == 1; + } + + internal static async Task RequiredHumanExistsAsync(MySqlConnection connection, string database, + CancellationToken cancellationToken) + { + var escapedDatabase = database.Replace("`", "``", StringComparison.Ordinal); + await using var command = new MySqlCommand( + $"SELECT COUNT(*) FROM `{escapedDatabase}`.`weenie` WHERE `class_Id` = 1 AND `class_Name` = 'human'", connection); + return Convert.ToInt32(await command.ExecuteScalarAsync(cancellationToken)) == 1; + } +} diff --git a/Source/ACE.SinglePlayer/Database/IDatabaseRuntime.cs b/Source/ACE.SinglePlayer/Database/IDatabaseRuntime.cs new file mode 100644 index 0000000000..62b20c70e5 --- /dev/null +++ b/Source/ACE.SinglePlayer/Database/IDatabaseRuntime.cs @@ -0,0 +1,13 @@ +using ACE.SinglePlayer.Models; + +namespace ACE.SinglePlayer.Database; + +public sealed record DatabaseValidationResult(bool IsValid, string Message); + +public interface IDatabaseRuntime : IAsyncDisposable +{ + bool IsManaged { get; } + Task StartAsync(LauncherSettings settings, CancellationToken cancellationToken); + Task ValidateAsync(LauncherSettings settings, CancellationToken cancellationToken); + Task StopAsync(CancellationToken cancellationToken); +} diff --git a/Source/ACE.SinglePlayer/Database/ManagedMariaDbRuntime.cs b/Source/ACE.SinglePlayer/Database/ManagedMariaDbRuntime.cs new file mode 100644 index 0000000000..e6e86512f2 --- /dev/null +++ b/Source/ACE.SinglePlayer/Database/ManagedMariaDbRuntime.cs @@ -0,0 +1,431 @@ +using System.Diagnostics; +using System.Text; + +using MySqlConnector; + +using ACE.SinglePlayer.Infrastructure; +using ACE.SinglePlayer.Models; + +namespace ACE.SinglePlayer.Database; + +public sealed class ManagedMariaDbRuntime : IDatabaseRuntime +{ + private const string PrivateUser = "ace_singleplayer"; + private readonly DatabaseConnectionFactory connectionFactory; + private readonly ExternalMariaDbRuntime validator; + private readonly LauncherLog log; + private Process? process; + private LauncherSettings? activeSettings; + + public ManagedMariaDbRuntime(DatabaseConnectionFactory connectionFactory, ExternalMariaDbRuntime validator, LauncherLog log) + { + this.connectionFactory = connectionFactory; + this.validator = validator; + this.log = log; + } + + public bool IsManaged => true; + + public async Task StartAsync(LauncherSettings settings, CancellationToken cancellationToken) + { + if (process is { HasExited: false }) + return; + + ValidatePrivateSettings(settings); + activeSettings = settings; + + var dataDirectory = settings.PrivateDatabaseDirectory; + await MigrateLegacyDataDirectoryAsync(settings, cancellationToken); + var isInitialized = IsInitialized(dataDirectory); + + if (!PrivateDatabasePortFinder.IsAvailable(settings.DatabasePort)) + { + if (await CanConnectAsPrivateAdministratorAsync(settings, cancellationToken)) + { + log.Write($"Using the already-running private MariaDB on 127.0.0.1:{settings.DatabasePort}."); + await ProvisionApplicationUserAsync(settings, cancellationToken); + return; + } + + settings.DatabasePort = PrivateDatabasePortFinder.FindAvailablePort(); + log.Write($"Private MariaDB selected available loopback port {settings.DatabasePort}."); + } + + if (!isInitialized) + await InitializeDataDirectoryAsync(settings, cancellationToken); + + FilePermissionHardener.RestrictDirectoryToCurrentUser(dataDirectory); + process = StartServerProcess(settings); + + try + { + await WaitForAdministratorConnectionAsync(settings, cancellationToken); + await ProvisionApplicationUserAsync(settings, cancellationToken); + log.Write("Private MariaDB is ready and its ACE-only database user is configured."); + } + catch + { + await StopAsync(CancellationToken.None); + throw; + } + } + + public Task ValidateAsync(LauncherSettings settings, CancellationToken cancellationToken) => + validator.ValidateAsync(settings, cancellationToken); + + public async Task StopAsync(CancellationToken cancellationToken) + { + if (process is null || process.HasExited) + return; + + if (activeSettings is not null) + { + try + { + await using var connection = connectionFactory.CreatePrivateAdministrator(activeSettings); + await connection.OpenAsync(cancellationToken); + await using var command = new MySqlCommand("SHUTDOWN", connection) { CommandTimeout = 10 }; + await command.ExecuteNonQueryAsync(cancellationToken); + } + catch (Exception ex) when (ex is MySqlException or TimeoutException or OperationCanceledException) + { + log.Write("Private MariaDB did not accept a graceful shutdown request: " + ex.Message); + } + } + + try + { + await process.WaitForExitAsync(cancellationToken).WaitAsync(TimeSpan.FromSeconds(15), cancellationToken); + } + catch (TimeoutException) + { + log.Write("Private MariaDB did not stop within 15 seconds; stopping only the launcher-owned process."); + process.Kill(entireProcessTree: true); + await process.WaitForExitAsync(cancellationToken); + } + } + + public async ValueTask DisposeAsync() + { + await StopAsync(CancellationToken.None); + process?.Dispose(); + } + + internal static IReadOnlyList CreateInitializationArguments(string dataDirectory, string adminPassword, ushort port) => + new[] + { + $"--datadir={dataDirectory}", + $"--password={adminPassword}", + $"--port={port}", + "--silent" + }; + + internal static IReadOnlyList CreateServerArguments(string dataDirectory, ushort port) => + new[] + { + "--no-defaults", + $"--datadir={dataDirectory}", + $"--port={port}", + "--bind-address=127.0.0.1", + "--skip-networking=0", + "--max-connections=20", + "--local-infile=0", + "--console" + }; + + private static bool IsInitialized(string dataDirectory) => Directory.Exists(Path.Combine(dataDirectory, "mysql")); + + private async Task MigrateLegacyDataDirectoryAsync(LauncherSettings settings, CancellationToken cancellationToken) + { + var source = Path.GetFullPath(settings.LegacyPrivateDatabaseDirectory); + var target = Path.GetFullPath(settings.PrivateDatabaseDirectory); + if (string.Equals(source, target, StringComparison.OrdinalIgnoreCase) || !IsInitialized(source) || + (Directory.Exists(target) && Directory.EnumerateFileSystemEntries(target).Any())) + return; + + if (Directory.Exists(target)) + Directory.Delete(target); + + var parent = Path.GetDirectoryName(target) + ?? throw new InvalidOperationException("The private database directory has no parent directory."); + Directory.CreateDirectory(parent); + var staging = target + ".migrating-" + Guid.NewGuid().ToString("N"); + log.Write("Moving the private MariaDB data out of the launcher folder so cloud-sync software cannot lock its database files."); + + try + { + Directory.CreateDirectory(staging); + foreach (var sourceDirectory in Directory.EnumerateDirectories(source, "*", SearchOption.AllDirectories)) + { + cancellationToken.ThrowIfCancellationRequested(); + Directory.CreateDirectory(Path.Combine(staging, Path.GetRelativePath(source, sourceDirectory))); + } + + foreach (var sourceFile in Directory.EnumerateFiles(source, "*", SearchOption.AllDirectories)) + { + cancellationToken.ThrowIfCancellationRequested(); + var destinationFile = Path.Combine(staging, Path.GetRelativePath(source, sourceFile)); + Directory.CreateDirectory(Path.GetDirectoryName(destinationFile)!); + await using var input = new FileStream(sourceFile, FileMode.Open, FileAccess.Read, FileShare.Read, + bufferSize: 1024 * 1024, FileOptions.Asynchronous | FileOptions.SequentialScan); + await using var output = new FileStream(destinationFile, FileMode.CreateNew, FileAccess.Write, FileShare.None, + bufferSize: 1024 * 1024, FileOptions.Asynchronous | FileOptions.SequentialScan); + await input.CopyToAsync(output, cancellationToken); + } + + if (!IsInitialized(staging)) + throw new InvalidOperationException("The copied private database does not contain MariaDB system tables."); + FilePermissionHardener.RestrictDirectoryToCurrentUser(staging); + Directory.Move(staging, target); + log.Write($"Private MariaDB data moved to '{target}'. The previous copy was retained at '{source}' as a backup."); + } + catch + { + if (Directory.Exists(staging)) + Directory.Delete(staging, recursive: true); + throw; + } + } + + private static void ValidatePrivateSettings(LauncherSettings settings) + { + if (settings.DatabaseMode == DatabaseMode.External) + throw new InvalidOperationException("The private MariaDB runtime cannot start in external database mode."); + if (!File.Exists(settings.ManagedDatabaseExePath)) + throw new FileNotFoundException("The bundled MariaDB runtime was not found. Extract the complete release again, or select mariadbd.exe in advanced Settings.", settings.ManagedDatabaseExePath); + if (MariaDbInstallationLocator.FindInitializer(settings.ManagedDatabaseExePath) is null) + throw new FileNotFoundException("The MariaDB initializer was not found beside mariadbd.exe. Repair the MariaDB installation and retry."); + if (!string.Equals(settings.DatabaseHost, "127.0.0.1", StringComparison.Ordinal)) + throw new InvalidOperationException("The private database must use 127.0.0.1."); + if (!string.Equals(settings.DatabaseUsername, PrivateUser, StringComparison.Ordinal)) + throw new InvalidOperationException($"The private database must use its isolated '{PrivateUser}' account."); + if (string.IsNullOrWhiteSpace(settings.ProtectedDatabasePassword) || + string.IsNullOrWhiteSpace(settings.ProtectedPrivateDatabaseAdminPassword)) + throw new InvalidOperationException("Private database credentials have not been generated. Open Settings and save the setup again."); + } + + private async Task InitializeDataDirectoryAsync(LauncherSettings settings, CancellationToken cancellationToken) + { + var target = settings.PrivateDatabaseDirectory; + if (Directory.Exists(target) && Directory.EnumerateFileSystemEntries(target).Any()) + throw new InvalidOperationException( + $"The private database folder exists but is not a valid initialized MariaDB data directory: {target}. Its contents were left untouched."); + + var parent = Path.GetDirectoryName(target) + ?? throw new InvalidOperationException("The private database directory has no parent directory."); + Directory.CreateDirectory(parent); + var staging = Path.Combine(parent, "Database.initializing-" + Guid.NewGuid().ToString("N")); + var initializer = MariaDbInstallationLocator.FindInitializer(settings.ManagedDatabaseExePath)!; + var adminPassword = GetAdministratorPassword(settings); + var output = new StringBuilder(); + + try + { + using var initializerProcess = new Process + { + StartInfo = CreateProcessStartInfo(initializer, Path.GetDirectoryName(initializer)!, + CreateInitializationArguments(staging, adminPassword, settings.DatabasePort)), + EnableRaisingEvents = true + }; + initializerProcess.OutputDataReceived += (_, args) => AppendRedacted(output, args.Data, adminPassword); + initializerProcess.ErrorDataReceived += (_, args) => AppendRedacted(output, args.Data, adminPassword); + + log.Write("Initializing a private MariaDB data directory for this launcher. The system MariaDB service is not being changed."); + if (!initializerProcess.Start()) + throw new InvalidOperationException("The MariaDB initializer did not start."); + initializerProcess.BeginOutputReadLine(); + initializerProcess.BeginErrorReadLine(); + + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeout.CancelAfter(TimeSpan.FromMinutes(2)); + try + { + await initializerProcess.WaitForExitAsync(timeout.Token); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + if (!initializerProcess.HasExited) + initializerProcess.Kill(entireProcessTree: true); + throw new TimeoutException("MariaDB initialization did not finish within two minutes."); + } + + if (initializerProcess.ExitCode != 0 || !IsInitialized(staging)) + throw new InvalidOperationException( + $"MariaDB initialization failed with exit code {initializerProcess.ExitCode}. {Condense(Snapshot(output))}"); + + if (Directory.Exists(target)) + Directory.Delete(target); + Directory.Move(staging, target); + FilePermissionHardener.RestrictDirectoryToCurrentUser(target); + log.Write("Private MariaDB data directory initialized successfully."); + } + finally + { + if (Directory.Exists(staging)) + { + try + { + Directory.Delete(staging, recursive: true); + } + catch (IOException ex) + { + log.Write("A failed MariaDB initialization staging folder could not be removed: " + ex.Message); + } + } + } + } + + private Process StartServerProcess(LauncherSettings settings) + { + var startInfo = CreateProcessStartInfo(settings.ManagedDatabaseExePath, + Path.GetDirectoryName(settings.ManagedDatabaseExePath)!, + CreateServerArguments(settings.PrivateDatabaseDirectory, settings.DatabasePort)); + + var serverProcess = new Process { StartInfo = startInfo, EnableRaisingEvents = true }; + var adminPassword = GetAdministratorPassword(settings); + var applicationPassword = GetApplicationPassword(settings); + serverProcess.OutputDataReceived += (_, args) => LogMariaDbLine(args.Data, adminPassword, applicationPassword); + serverProcess.ErrorDataReceived += (_, args) => LogMariaDbLine(args.Data, adminPassword, applicationPassword); + if (!serverProcess.Start()) + throw new InvalidOperationException("Private MariaDB did not start."); + serverProcess.BeginOutputReadLine(); + serverProcess.BeginErrorReadLine(); + return serverProcess; + } + + private async Task WaitForAdministratorConnectionAsync(LauncherSettings settings, CancellationToken cancellationToken) + { + var deadline = DateTime.UtcNow.AddSeconds(45); + Exception? lastError = null; + while (DateTime.UtcNow < deadline) + { + cancellationToken.ThrowIfCancellationRequested(); + if (process is { HasExited: true }) + throw new InvalidOperationException($"Private MariaDB exited unexpectedly (exit code {process.ExitCode}). Open Logs for details."); + + try + { + await using var connection = connectionFactory.CreatePrivateAdministrator(settings); + await connection.OpenAsync(cancellationToken); + return; + } + catch (MySqlException ex) + { + lastError = ex; + } + + await Task.Delay(300, cancellationToken); + } + + throw new TimeoutException( + "Private MariaDB started but its protected administrator credential was not accepted. " + + "The existing private data was not changed. Restore the matching settings before retrying. " + lastError?.Message); + } + + private async Task CanConnectAsPrivateAdministratorAsync(LauncherSettings settings, CancellationToken cancellationToken) + { + try + { + await using var connection = connectionFactory.CreatePrivateAdministrator(settings); + await connection.OpenAsync(cancellationToken); + return true; + } + catch (MySqlException) + { + return false; + } + } + + private async Task ProvisionApplicationUserAsync(LauncherSettings settings, CancellationToken cancellationToken) + { + await using var connection = connectionFactory.CreatePrivateAdministrator(settings); + await connection.OpenAsync(cancellationToken); + var password = GetApplicationPassword(settings); + + foreach (var host in new[] { "127.0.0.1", "localhost" }) + { + await ExecuteWithPasswordAsync(connection, + $"CREATE USER IF NOT EXISTS '{PrivateUser}'@'{host}' IDENTIFIED BY @password", password, cancellationToken); + await ExecuteWithPasswordAsync(connection, + $"ALTER USER '{PrivateUser}'@'{host}' IDENTIFIED BY @password", password, cancellationToken); + + foreach (var database in new[] + { + settings.AuthenticationDatabaseName, + settings.ShardDatabaseName, + settings.WorldDatabaseName + }) + { + var escapedDatabase = database.Replace("`", "``", StringComparison.Ordinal); + await using var grant = new MySqlCommand( + $"GRANT ALL PRIVILEGES ON `{escapedDatabase}`.* TO '{PrivateUser}'@'{host}'", connection); + await grant.ExecuteNonQueryAsync(cancellationToken); + } + } + } + + private static async Task ExecuteWithPasswordAsync(MySqlConnection connection, string sql, string password, + CancellationToken cancellationToken) + { + await using var command = new MySqlCommand(sql, connection); + command.Parameters.AddWithValue("@password", password); + await command.ExecuteNonQueryAsync(cancellationToken); + } + + private static ProcessStartInfo CreateProcessStartInfo(string executable, string workingDirectory, + IEnumerable arguments) + { + var startInfo = new ProcessStartInfo + { + FileName = executable, + WorkingDirectory = workingDirectory, + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + }; + foreach (var argument in arguments) + startInfo.ArgumentList.Add(argument); + return startInfo; + } + + private string GetAdministratorPassword(LauncherSettings settings) + { + using var connection = connectionFactory.CreatePrivateAdministrator(settings); + return new MySqlConnectionStringBuilder(connection.ConnectionString).Password; + } + + private string GetApplicationPassword(LauncherSettings settings) + { + using var connection = connectionFactory.Create(settings); + return new MySqlConnectionStringBuilder(connection.ConnectionString).Password; + } + + private void LogMariaDbLine(string? line, params string[] secrets) + { + if (line is null) + return; + foreach (var secret in secrets.Where(value => value.Length > 0)) + line = line.Replace(secret, "[REDACTED]", StringComparison.Ordinal); + log.Write("[Private MariaDB] " + line); + } + + private static void AppendRedacted(StringBuilder output, string? line, string secret) + { + if (line is not null) + lock (output) + output.AppendLine(line.Replace(secret, "[REDACTED]", StringComparison.Ordinal)); + } + + private static string Snapshot(StringBuilder output) + { + lock (output) + return output.ToString(); + } + + private static string Condense(string value) + { + var condensed = string.Join(" ", value.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries)); + return condensed.Length <= 800 ? condensed : condensed[..800] + "..."; + } +} diff --git a/Source/ACE.SinglePlayer/Database/MariaDbInstallationLocator.cs b/Source/ACE.SinglePlayer/Database/MariaDbInstallationLocator.cs new file mode 100644 index 0000000000..7db3060384 --- /dev/null +++ b/Source/ACE.SinglePlayer/Database/MariaDbInstallationLocator.cs @@ -0,0 +1,91 @@ +namespace ACE.SinglePlayer.Database; + +public static class MariaDbInstallationLocator +{ + public static string? FindServerExecutable(string? preferredPath = null) + { + if (!string.IsNullOrWhiteSpace(preferredPath) && File.Exists(preferredPath)) + return Path.GetFullPath(preferredPath); + + foreach (var pathEntry in (Environment.GetEnvironmentVariable("PATH") ?? string.Empty) + .Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + var candidate = Path.Combine(pathEntry, "mariadbd.exe"); + if (File.Exists(candidate) && FindInitializer(candidate) is not null) + return Path.GetFullPath(candidate); + } + + return FindUnderRoots(new[] + { + Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), + Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86) + }); + } + + internal static string? FindUnderRoots(IEnumerable roots) + { + var candidates = new List(); + foreach (var root in roots.Where(path => !string.IsNullOrWhiteSpace(path) && Directory.Exists(path))) + { + try + { + foreach (var directory in Directory.EnumerateDirectories(root, "MariaDB *", SearchOption.TopDirectoryOnly)) + { + var candidate = Path.Combine(directory, "bin", "mariadbd.exe"); + if (File.Exists(candidate) && FindInitializer(candidate) is not null) + candidates.Add(candidate); + } + } + catch (UnauthorizedAccessException) + { + // A locked Program Files child should not prevent checking other roots. + } + } + + return candidates + .OrderByDescending(GetVersionFromParentDirectory) + .ThenByDescending(File.GetLastWriteTimeUtc) + .FirstOrDefault(); + } + + public static string? FindInitializer(string serverExecutablePath) + { + var directory = Path.GetDirectoryName(serverExecutablePath); + if (directory is null) + return null; + + foreach (var name in new[] { "mariadb-install-db.exe", "mysql_install_db.exe" }) + { + var candidate = Path.Combine(directory, name); + if (File.Exists(candidate)) + return candidate; + } + + return null; + } + + public static string? FindClient(string serverExecutablePath) + { + var directory = Path.GetDirectoryName(serverExecutablePath); + if (directory is null) + return null; + + foreach (var name in new[] { "mariadb.exe", "mysql.exe" }) + { + var candidate = Path.Combine(directory, name); + if (File.Exists(candidate)) + return candidate; + } + + return null; + } + + private static Version GetVersionFromParentDirectory(string executablePath) + { + var directoryName = Directory.GetParent(Path.GetDirectoryName(executablePath)!)?.Name ?? string.Empty; + var value = directoryName.StartsWith("MariaDB ", StringComparison.OrdinalIgnoreCase) + ? directoryName["MariaDB ".Length..] + : string.Empty; + return Version.TryParse(value, out var version) ? version : new Version(0, 0); + } +} diff --git a/Source/ACE.SinglePlayer/Database/PrivateDatabasePortFinder.cs b/Source/ACE.SinglePlayer/Database/PrivateDatabasePortFinder.cs new file mode 100644 index 0000000000..2ba1ed777e --- /dev/null +++ b/Source/ACE.SinglePlayer/Database/PrivateDatabasePortFinder.cs @@ -0,0 +1,38 @@ +using System.Net; +using System.Net.Sockets; + +namespace ACE.SinglePlayer.Database; + +public static class PrivateDatabasePortFinder +{ + public static ushort FindAvailablePort(ushort preferredPort = 3307, ushort lastPort = 3399) + { + if (preferredPort == 0 || lastPort < preferredPort) + throw new ArgumentOutOfRangeException(nameof(preferredPort)); + + for (var port = (int)preferredPort; port <= lastPort; port++) + if (IsAvailable((ushort)port)) + return (ushort)port; + + throw new InvalidOperationException($"No free private-database port was found between {preferredPort} and {lastPort}."); + } + + public static bool IsAvailable(ushort port) + { + TcpListener? listener = null; + try + { + listener = new TcpListener(IPAddress.Loopback, port); + listener.Start(); + return true; + } + catch (SocketException) + { + return false; + } + finally + { + listener?.Stop(); + } + } +} diff --git a/Source/ACE.SinglePlayer/Database/WorldSqlPackageInspector.cs b/Source/ACE.SinglePlayer/Database/WorldSqlPackageInspector.cs new file mode 100644 index 0000000000..15f8f7f7f5 --- /dev/null +++ b/Source/ACE.SinglePlayer/Database/WorldSqlPackageInspector.cs @@ -0,0 +1,42 @@ +using System.Text; + +namespace ACE.SinglePlayer.Database; + +public static class WorldSqlPackageInspector +{ + private static readonly byte[] RequiredHumanInsert = + Encoding.ASCII.GetBytes("INSERT INTO `weenie` VALUES (1,"); + + public static async Task ContainsRequiredWorldDataAsync(string path, CancellationToken cancellationToken) + { + if (!File.Exists(path)) + return false; + + await using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read, + bufferSize: 64 * 1024, FileOptions.Asynchronous | FileOptions.SequentialScan); + var buffer = new byte[64 * 1024]; + var matched = 0; + + while (true) + { + var read = await stream.ReadAsync(buffer, cancellationToken); + if (read == 0) + return false; + + for (var index = 0; index < read; index++) + { + var value = buffer[index]; + if (value == RequiredHumanInsert[matched]) + { + matched++; + if (matched == RequiredHumanInsert.Length) + return true; + } + else + { + matched = value == RequiredHumanInsert[0] ? 1 : 0; + } + } + } + } +} diff --git a/Source/ACE.SinglePlayer/Infrastructure/AutomaticSetupConfigurator.cs b/Source/ACE.SinglePlayer/Infrastructure/AutomaticSetupConfigurator.cs new file mode 100644 index 0000000000..e385bc45a7 --- /dev/null +++ b/Source/ACE.SinglePlayer/Infrastructure/AutomaticSetupConfigurator.cs @@ -0,0 +1,66 @@ +using ACE.SinglePlayer.Database; +using ACE.SinglePlayer.Models; + +namespace ACE.SinglePlayer.Infrastructure; + +public static class AutomaticSetupConfigurator +{ + public static bool Configure(LauncherSettings settings, string applicationDirectory, ISecretProtector protector) + { + var changed = SettingsPathRepairer.Repair(settings, applicationDirectory); + if (!BundledDistribution.IsComplete(applicationDirectory)) + return changed; + + changed |= SetIfDifferent(settings.DatabaseMode, DatabaseMode.Private, value => settings.DatabaseMode = value); + changed |= SetIfDifferent(settings.DatabaseHost, "127.0.0.1", value => settings.DatabaseHost = value); + changed |= SetIfDifferent(settings.DatabaseUsername, "ace_singleplayer", value => settings.DatabaseUsername = value); + + if (settings.DatabasePort == 0 || settings.DatabasePort == 3306) + { + settings.DatabasePort = PrivateDatabasePortFinder.FindAvailablePort(); + changed = true; + } + + if (string.IsNullOrWhiteSpace(settings.AccountName)) + { + settings.AccountName = "singleplayer"; + changed = true; + } + + if (string.IsNullOrWhiteSpace(settings.ProtectedAccountPassword)) + { + settings.ProtectedAccountPassword = protector.Protect(SecretProtector.GeneratePassword()); + changed = true; + } + + if (string.IsNullOrWhiteSpace(settings.ProtectedDatabasePassword)) + { + settings.ProtectedDatabasePassword = protector.Protect(SecretProtector.GeneratePassword()); + changed = true; + } + + if (!string.Equals(settings.ProtectedPrivateDatabasePassword, settings.ProtectedDatabasePassword, StringComparison.Ordinal)) + { + settings.ProtectedPrivateDatabasePassword = settings.ProtectedDatabasePassword; + changed = true; + } + + if (string.IsNullOrWhiteSpace(settings.ProtectedPrivateDatabaseAdminPassword) && + !Directory.Exists(Path.Combine(settings.PrivateDatabaseDirectory, "mysql"))) + { + settings.ProtectedPrivateDatabaseAdminPassword = protector.Protect(SecretProtector.GeneratePassword()); + changed = true; + } + + return changed; + } + + private static bool SetIfDifferent(T current, T replacement, Action setter) + { + if (EqualityComparer.Default.Equals(current, replacement)) + return false; + + setter(replacement); + return true; + } +} diff --git a/Source/ACE.SinglePlayer/Infrastructure/BundledDistribution.cs b/Source/ACE.SinglePlayer/Infrastructure/BundledDistribution.cs new file mode 100644 index 0000000000..5e40c408b4 --- /dev/null +++ b/Source/ACE.SinglePlayer/Infrastructure/BundledDistribution.cs @@ -0,0 +1,29 @@ +namespace ACE.SinglePlayer.Infrastructure; + +public static class BundledDistribution +{ + public const string MariaDbVersion = "12.3.2"; + public const string WorldVersion = "0.9.294"; + + public static string MariaDbServerPath(string applicationDirectory) => Path.GetFullPath(Path.Combine( + applicationDirectory, "Dependencies", "MariaDB", "bin", "mariadbd.exe")); + + public static string? FindWorldSqlPath(string applicationDirectory) + { + var directory = Path.GetFullPath(Path.Combine(applicationDirectory, "Dependencies", "World")); + if (!Directory.Exists(directory)) + return null; + + return Directory.EnumerateFiles(directory, "ACE-World-Database-*.sql", SearchOption.TopDirectoryOnly) + .OrderBy(path => path, StringComparer.OrdinalIgnoreCase) + .FirstOrDefault(); + } + + public static bool IsComplete(string applicationDirectory) + { + var mariaDb = MariaDbServerPath(applicationDirectory); + return File.Exists(mariaDb) && Database.MariaDbInstallationLocator.FindInitializer(mariaDb) is not null && + Database.MariaDbInstallationLocator.FindClient(mariaDb) is not null && + FindWorldSqlPath(applicationDirectory) is not null; + } +} diff --git a/Source/ACE.SinglePlayer/Infrastructure/FilePermissionHardener.cs b/Source/ACE.SinglePlayer/Infrastructure/FilePermissionHardener.cs new file mode 100644 index 0000000000..cc2276d1e1 --- /dev/null +++ b/Source/ACE.SinglePlayer/Infrastructure/FilePermissionHardener.cs @@ -0,0 +1,37 @@ +using System.Security.AccessControl; +using System.Security.Principal; + +namespace ACE.SinglePlayer.Infrastructure; + +public static class FilePermissionHardener +{ + public static void RestrictToCurrentUser(string path) + { + if (!OperatingSystem.IsWindows()) + return; + + var user = WindowsIdentity.GetCurrent().User + ?? throw new InvalidOperationException("The current Windows user has no security identifier."); + var security = new FileSecurity(); + security.SetOwner(user); + security.SetAccessRuleProtection(isProtected: true, preserveInheritance: false); + security.AddAccessRule(new FileSystemAccessRule(user, FileSystemRights.FullControl, AccessControlType.Allow)); + new FileInfo(path).SetAccessControl(security); + } + + public static void RestrictDirectoryToCurrentUser(string path) + { + if (!OperatingSystem.IsWindows()) + return; + + var user = WindowsIdentity.GetCurrent().User + ?? throw new InvalidOperationException("The current Windows user has no security identifier."); + var security = new DirectorySecurity(); + security.SetOwner(user); + security.SetAccessRuleProtection(isProtected: true, preserveInheritance: false); + security.AddAccessRule(new FileSystemAccessRule(user, FileSystemRights.FullControl, + InheritanceFlags.ContainerInherit | InheritanceFlags.ObjectInherit, + PropagationFlags.None, AccessControlType.Allow)); + new DirectoryInfo(path).SetAccessControl(security); + } +} diff --git a/Source/ACE.SinglePlayer/Infrastructure/LauncherLog.cs b/Source/ACE.SinglePlayer/Infrastructure/LauncherLog.cs new file mode 100644 index 0000000000..53287b6d21 --- /dev/null +++ b/Source/ACE.SinglePlayer/Infrastructure/LauncherLog.cs @@ -0,0 +1,33 @@ +namespace ACE.SinglePlayer.Infrastructure; + +public sealed class LauncherLog : IDisposable +{ + private readonly object sync = new(); + private readonly StreamWriter writer; + + public LauncherLog(string logsDirectory) + { + Directory.CreateDirectory(logsDirectory); + LogPath = Path.Combine(logsDirectory, "ACE.SinglePlayer.log"); + writer = new StreamWriter(new FileStream(LogPath, FileMode.Append, FileAccess.Write, FileShare.ReadWrite)) + { + AutoFlush = true + }; + } + + public string LogPath { get; } + public event Action? MessageWritten; + + public void Write(string message) + { + if (string.IsNullOrWhiteSpace(message)) + return; + + var line = $"{DateTimeOffset.Now:yyyy-MM-dd HH:mm:ss zzz} {message}"; + lock (sync) + writer.WriteLine(line); + MessageWritten?.Invoke(line); + } + + public void Dispose() => writer.Dispose(); +} diff --git a/Source/ACE.SinglePlayer/Infrastructure/SecretProtector.cs b/Source/ACE.SinglePlayer/Infrastructure/SecretProtector.cs new file mode 100644 index 0000000000..f2c3137bce --- /dev/null +++ b/Source/ACE.SinglePlayer/Infrastructure/SecretProtector.cs @@ -0,0 +1,96 @@ +using System.ComponentModel; +using System.Runtime.InteropServices; +using System.Security.Cryptography; +using System.Text; + +namespace ACE.SinglePlayer.Infrastructure; + +public interface ISecretProtector +{ + string Protect(string value); + string Unprotect(string protectedValue); +} + +public sealed class SecretProtector : ISecretProtector +{ + private const int CryptprotectUiForbidden = 0x1; + + public string Protect(string value) + { + ArgumentNullException.ThrowIfNull(value); + return Convert.ToBase64String(Transform(Encoding.UTF8.GetBytes(value), protect: true)); + } + + public string Unprotect(string protectedValue) + { + ArgumentNullException.ThrowIfNull(protectedValue); + if (protectedValue.Length == 0) + return string.Empty; + + return Encoding.UTF8.GetString(Transform(Convert.FromBase64String(protectedValue), protect: false)); + } + + public static string GeneratePassword() + { + var bytes = RandomNumberGenerator.GetBytes(32); + return Convert.ToBase64String(bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_'); + } + + private static byte[] Transform(byte[] input, bool protect) + { + if (!OperatingSystem.IsWindows()) + throw new PlatformNotSupportedException("Windows DPAPI is required to protect launcher secrets."); + + var inputBlob = new DataBlob(); + var outputBlob = new DataBlob(); + try + { + inputBlob.Size = input.Length; + inputBlob.Data = Marshal.AllocHGlobal(input.Length); + Marshal.Copy(input, 0, inputBlob.Data, input.Length); + + var succeeded = protect + ? CryptProtectData(ref inputBlob, null, IntPtr.Zero, IntPtr.Zero, IntPtr.Zero, CryptprotectUiForbidden, ref outputBlob) + : CryptUnprotectData(ref inputBlob, IntPtr.Zero, IntPtr.Zero, IntPtr.Zero, IntPtr.Zero, CryptprotectUiForbidden, ref outputBlob); + + if (!succeeded) + throw new Win32Exception(Marshal.GetLastWin32Error()); + + var result = new byte[outputBlob.Size]; + Marshal.Copy(outputBlob.Data, result, 0, outputBlob.Size); + return result; + } + finally + { + if (inputBlob.Data != IntPtr.Zero) + { + for (var i = 0; i < input.Length; i++) + Marshal.WriteByte(inputBlob.Data, i, 0); + Marshal.FreeHGlobal(inputBlob.Data); + } + + if (outputBlob.Data != IntPtr.Zero) + LocalFree(outputBlob.Data); + } + } + + [StructLayout(LayoutKind.Sequential)] + private struct DataBlob + { + public int Size; + public IntPtr Data; + } + + [DllImport("crypt32.dll", SetLastError = true, CharSet = CharSet.Unicode)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool CryptProtectData(ref DataBlob dataIn, string? description, IntPtr optionalEntropy, + IntPtr reserved, IntPtr promptStruct, int flags, ref DataBlob dataOut); + + [DllImport("crypt32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool CryptUnprotectData(ref DataBlob dataIn, IntPtr description, IntPtr optionalEntropy, + IntPtr reserved, IntPtr promptStruct, int flags, ref DataBlob dataOut); + + [DllImport("kernel32.dll")] + private static extern IntPtr LocalFree(IntPtr memory); +} diff --git a/Source/ACE.SinglePlayer/Infrastructure/ServerDatProvisioner.cs b/Source/ACE.SinglePlayer/Infrastructure/ServerDatProvisioner.cs new file mode 100644 index 0000000000..b935160de2 --- /dev/null +++ b/Source/ACE.SinglePlayer/Infrastructure/ServerDatProvisioner.cs @@ -0,0 +1,136 @@ +using ACE.SinglePlayer.Models; + +namespace ACE.SinglePlayer.Infrastructure; + +public sealed class ServerDatProvisioner +{ + private const long FreeSpaceSafetyMargin = 64L * 1024 * 1024; + private readonly LauncherLog log; + + public ServerDatProvisioner(LauncherLog log, string? targetDirectory = null) + { + this.log = log; + TargetDirectory = targetDirectory ?? GetDefaultDirectory(); + } + + public string TargetDirectory { get; } + + public static string GetDefaultDirectory() => Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "ACESinglePlayer", "ServerData"); + + public bool RequiresRefresh(LauncherSettings settings) + { + var sourceDirectory = SetupValidator.DetectDatDirectory(settings.ClientExePath); + if (sourceDirectory is null) + return true; + + return SetupValidator.RequiredClientDatFiles.Any(file => + !FilesMatch(Path.Combine(sourceDirectory, file), Path.Combine(TargetDirectory, file))); + } + + public async Task EnsureAsync(LauncherSettings settings, CancellationToken cancellationToken) + { + var sourceDirectory = SetupValidator.DetectDatDirectory(settings.ClientExePath) + ?? throw new InvalidOperationException( + "The selected Asheron's Call client is missing one or more required DAT files."); + var sourceFullPath = Path.GetFullPath(sourceDirectory); + var targetFullPath = Path.GetFullPath(TargetDirectory); + if (string.Equals(sourceFullPath.TrimEnd(Path.DirectorySeparatorChar), + targetFullPath.TrimEnd(Path.DirectorySeparatorChar), StringComparison.OrdinalIgnoreCase)) + throw new InvalidOperationException("The private server DAT directory must be separate from the game client directory."); + + Directory.CreateDirectory(targetFullPath); + FilePermissionHardener.RestrictDirectoryToCurrentUser(targetFullPath); + + var pending = SetupValidator.RequiredClientDatFiles + .Select(file => new DatCopy( + file, + Path.Combine(sourceFullPath, file), + Path.Combine(targetFullPath, file))) + .Where(copy => !FilesMatch(copy.SourcePath, copy.TargetPath)) + .ToArray(); + EnsureFreeSpace(targetFullPath, pending); + + try + { + foreach (var copy in pending) + { + cancellationToken.ThrowIfCancellationRequested(); + log.Write($"Copying {copy.FileName} into the private ACE.Server data directory. The original client file is unchanged."); + await CopyAtomicallyAsync(copy.SourcePath, copy.TargetPath, cancellationToken); + } + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + throw new IOException( + $"ACE Single Player could not prepare its private server data files in '{targetFullPath}'. " + + "Close any running ACE.Server process and make sure the drive has enough free space. " + ex.Message, + ex); + } + + log.Write($"ACE.Server will use the private DAT copy at '{targetFullPath}'. The game client will use its original files at '{sourceFullPath}'."); + return targetFullPath; + } + + private static async Task CopyAtomicallyAsync(string sourcePath, string targetPath, CancellationToken cancellationToken) + { + var temporaryPath = targetPath + "." + Guid.NewGuid().ToString("N") + ".tmp"; + try + { + var sourceInfo = new FileInfo(sourcePath); + await using (var source = new FileStream(sourcePath, FileMode.Open, FileAccess.Read, FileShare.Read, + 1024 * 1024, FileOptions.Asynchronous | FileOptions.SequentialScan)) + await using (var target = new FileStream(temporaryPath, FileMode.CreateNew, FileAccess.Write, FileShare.None, + 1024 * 1024, FileOptions.Asynchronous | FileOptions.SequentialScan)) + { + await source.CopyToAsync(target, 1024 * 1024, cancellationToken); + await target.FlushAsync(cancellationToken); + } + + if (new FileInfo(temporaryPath).Length != sourceInfo.Length) + throw new IOException($"The private copy of {Path.GetFileName(sourcePath)} was incomplete."); + + File.SetLastWriteTimeUtc(temporaryPath, sourceInfo.LastWriteTimeUtc); + File.Move(temporaryPath, targetPath, true); + } + finally + { + if (File.Exists(temporaryPath)) + File.Delete(temporaryPath); + } + } + + private static bool FilesMatch(string sourcePath, string targetPath) + { + if (!File.Exists(sourcePath) || !File.Exists(targetPath)) + return false; + + var source = new FileInfo(sourcePath); + var target = new FileInfo(targetPath); + return source.Length == target.Length && source.LastWriteTimeUtc == target.LastWriteTimeUtc; + } + + private static void EnsureFreeSpace(string targetDirectory, IReadOnlyCollection pending) + { + if (pending.Count == 0) + return; + + var required = pending.Sum(copy => new FileInfo(copy.SourcePath).Length) + FreeSpaceSafetyMargin; + var root = Path.GetPathRoot(targetDirectory); + if (string.IsNullOrWhiteSpace(root)) + return; + + var drive = new DriveInfo(root); + if (drive.AvailableFreeSpace >= required) + return; + + throw new IOException( + $"The private ACE.Server DAT copy needs about {FormatBytes(required)} free on {drive.Name}, " + + $"but only {FormatBytes(drive.AvailableFreeSpace)} is available."); + } + + private static string FormatBytes(long bytes) => $"{bytes / 1024d / 1024d / 1024d:0.00} GB"; + + private sealed record DatCopy(string FileName, string SourcePath, string TargetPath); +} diff --git a/Source/ACE.SinglePlayer/Infrastructure/SettingsPathRepairer.cs b/Source/ACE.SinglePlayer/Infrastructure/SettingsPathRepairer.cs new file mode 100644 index 0000000000..b0356e3d61 --- /dev/null +++ b/Source/ACE.SinglePlayer/Infrastructure/SettingsPathRepairer.cs @@ -0,0 +1,82 @@ +using ACE.SinglePlayer.Models; + +namespace ACE.SinglePlayer.Infrastructure; + +public static class SettingsPathRepairer +{ + public static bool Repair(LauncherSettings settings, string applicationDirectory) + { + var changed = false; + var completeClient = FindCompleteClient(settings.ClientExePath, applicationDirectory); + if (completeClient is not null) + { + var clientDirectory = SetupValidator.DetectDatDirectory(completeClient)!; + changed |= SetIfDifferent(settings.ClientExePath, completeClient, value => settings.ClientExePath = value); + if (!HasServerDatFiles(settings.DatFilesDirectory)) + changed |= SetIfDifferent(settings.DatFilesDirectory, clientDirectory, value => settings.DatFilesDirectory = value); + } + + var packagedServer = Path.GetFullPath(Path.Combine(applicationDirectory, "Server", "ACE.Server.exe")); + if (File.Exists(packagedServer)) + changed |= SetIfDifferent(settings.ServerExePath, packagedServer, value => settings.ServerExePath = value); + + var packagedMods = Path.GetFullPath(Path.Combine(applicationDirectory, "Mods")); + if (Directory.Exists(packagedMods)) + changed |= SetIfDifferent(settings.ModsDirectory, packagedMods, value => settings.ModsDirectory = value); + + var bundledMariaDb = BundledDistribution.MariaDbServerPath(applicationDirectory); + if (File.Exists(bundledMariaDb)) + changed |= SetIfDifferent(settings.ManagedDatabaseExePath, bundledMariaDb, + value => settings.ManagedDatabaseExePath = value); + + var bundledWorld = BundledDistribution.FindWorldSqlPath(applicationDirectory); + if (bundledWorld is not null) + changed |= SetIfDifferent(settings.WorldDatabaseSqlPath, bundledWorld, + value => settings.WorldDatabaseSqlPath = value); + + return changed; + } + + private static string? FindCompleteClient(string configuredPath, string applicationDirectory) + { + var candidates = new[] + { + configuredPath, + Path.Combine(applicationDirectory, "Client", "acclient.exe"), + @"C:\Turbine\Asheron's Call\acclient.exe", + @"C:\Games\Asheron's Call\acclient.exe", + @"C:\Games\AsheronsCall\acclient.exe", + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86), "Turbine", "Asheron's Call", "acclient.exe") + }; + + foreach (var candidate in candidates.Where(path => !string.IsNullOrWhiteSpace(path)).Distinct(StringComparer.OrdinalIgnoreCase)) + { + try + { + var fullPath = Path.GetFullPath(candidate); + if (File.Exists(fullPath) && SetupValidator.DetectDatDirectory(fullPath) is not null) + return fullPath; + } + catch (Exception ex) when (ex is ArgumentException or NotSupportedException or PathTooLongException) + { + // Ignore a malformed stale path and continue through the known safe locations. + } + } + + return null; + } + + private static bool SetIfDifferent(string current, string replacement, Action setter) + { + if (string.Equals(current, replacement, StringComparison.OrdinalIgnoreCase)) + return false; + + setter(replacement); + return true; + } + + private static bool HasServerDatFiles(string directory) => + !string.IsNullOrWhiteSpace(directory) && + Directory.Exists(directory) && + SetupValidator.RequiredDatFiles.All(file => File.Exists(Path.Combine(directory, file))); +} diff --git a/Source/ACE.SinglePlayer/Infrastructure/SettingsStore.cs b/Source/ACE.SinglePlayer/Infrastructure/SettingsStore.cs new file mode 100644 index 0000000000..28aa1e0128 --- /dev/null +++ b/Source/ACE.SinglePlayer/Infrastructure/SettingsStore.cs @@ -0,0 +1,91 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +using ACE.SinglePlayer.Models; + +namespace ACE.SinglePlayer.Infrastructure; + +public sealed class SettingsStore +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + WriteIndented = true, + PropertyNameCaseInsensitive = true, + Converters = { new JsonStringEnumConverter() } + }; + + public SettingsStore(string? settingsPath = null) + { + SettingsPath = settingsPath ?? Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "ACESinglePlayer", "settings.json"); + } + + public string SettingsPath { get; } + + public bool Exists => File.Exists(SettingsPath); + + public async Task LoadAsync(CancellationToken cancellationToken = default) + { + if (!File.Exists(SettingsPath)) + return null; + + await using var stream = File.OpenRead(SettingsPath); + var settings = await JsonSerializer.DeserializeAsync(stream, JsonOptions, cancellationToken); + if (settings is null) + throw new InvalidDataException("The launcher settings file is empty or invalid."); + if (settings.SettingsVersion > LauncherSettings.CurrentVersion) + throw new InvalidDataException($"Settings version {settings.SettingsVersion} is newer than this launcher supports."); + + Migrate(settings); + settings.SettingsVersion = LauncherSettings.CurrentVersion; + return settings; + } + + public async Task SaveAsync(LauncherSettings settings, CancellationToken cancellationToken = default) + { + settings.SettingsVersion = LauncherSettings.CurrentVersion; + var directory = Path.GetDirectoryName(SettingsPath)!; + Directory.CreateDirectory(directory); + + var temporaryPath = SettingsPath + "." + Guid.NewGuid().ToString("N") + ".tmp"; + try + { + await using (var stream = new FileStream(temporaryPath, FileMode.CreateNew, FileAccess.Write, FileShare.None)) + await JsonSerializer.SerializeAsync(stream, settings, JsonOptions, cancellationToken); + File.Move(temporaryPath, SettingsPath, true); + FilePermissionHardener.RestrictToCurrentUser(SettingsPath); + } + finally + { + if (File.Exists(temporaryPath)) + File.Delete(temporaryPath); + } + } + + private static void Migrate(LauncherSettings settings) + { + if (settings.SettingsVersion >= 2) + return; + + var usedExperimentalManagedMode = settings.DatabaseMode == DatabaseMode.ManagedExperimental; + var usedLocalRoot = settings.DatabaseMode == DatabaseMode.External && + string.Equals(settings.DatabaseHost, "127.0.0.1", StringComparison.OrdinalIgnoreCase) && + string.Equals(settings.DatabaseUsername, "root", StringComparison.OrdinalIgnoreCase); + + if (!usedExperimentalManagedMode && !usedLocalRoot) + return; + + var previousPassword = settings.ProtectedDatabasePassword; + var previousUserWasRoot = string.Equals(settings.DatabaseUsername, "root", StringComparison.OrdinalIgnoreCase); + settings.DatabaseMode = DatabaseMode.Private; + settings.DatabaseHost = "127.0.0.1"; + settings.DatabasePort = 3307; + settings.DatabaseUsername = "ace_singleplayer"; + if (usedExperimentalManagedMode && previousUserWasRoot) + settings.ProtectedPrivateDatabaseAdminPassword = previousPassword; + else + settings.ProtectedExternalDatabasePassword = previousPassword; + settings.ProtectedDatabasePassword = string.Empty; + } +} diff --git a/Source/ACE.SinglePlayer/Infrastructure/SetupValidator.cs b/Source/ACE.SinglePlayer/Infrastructure/SetupValidator.cs new file mode 100644 index 0000000000..2039b82075 --- /dev/null +++ b/Source/ACE.SinglePlayer/Infrastructure/SetupValidator.cs @@ -0,0 +1,144 @@ +using System.Net; + +using ACE.SinglePlayer.Models; + +namespace ACE.SinglePlayer.Infrastructure; + +public sealed record ValidationResult(bool IsValid, IReadOnlyList Errors) +{ + public string Message => string.Join(Environment.NewLine, Errors); +} + +public static class SetupValidator +{ + public static readonly string[] RequiredDatFiles = + { + "client_cell_1.dat", + "client_portal.dat", + "client_local_English.dat" + }; + + public static readonly string[] RequiredClientDatFiles = + { + "client_cell_1.dat", + "client_portal.dat", + "client_local_English.dat", + "client_highres.dat" + }; + + public static ValidationResult Validate(LauncherSettings settings) + { + var errors = new List(ValidateClient(settings).Errors); + if (!File.Exists(settings.ServerExePath)) + errors.Add("The bundled ACE.Server executable is missing. Extract the complete ACE Single Player ZIP again."); + + if (!Directory.Exists(settings.DatFilesDirectory)) + errors.Add("Select the folder containing acclient.exe. The DAT folder is filled automatically."); + else + foreach (var file in RequiredDatFiles) + if (!File.Exists(Path.Combine(settings.DatFilesDirectory, file))) + errors.Add($"The DAT directory is missing {file}."); + + if (string.IsNullOrWhiteSpace(settings.ModsDirectory)) + errors.Add("The bundled Mods directory is missing."); + if (string.IsNullOrWhiteSpace(settings.RuntimeDirectory)) + errors.Add("The private Runtime directory is missing."); + if (!IPAddress.TryParse(settings.Host, out var host) || !IPAddress.IsLoopback(host)) + errors.Add("The standard single-player host must be a loopback address (127.0.0.1)."); + if (settings.Port is 0 or ushort.MaxValue) + errors.Add("Choose a server port between 1 and 65534 (ACE also uses the next port)."); + if (string.IsNullOrWhiteSpace(settings.AccountName)) + errors.Add("Enter a persistent local account name."); + if (string.IsNullOrWhiteSpace(settings.ProtectedAccountPassword)) + errors.Add("The persistent local account password has not been generated."); + if (settings.DatabaseMode == DatabaseMode.External) + { + if (string.IsNullOrWhiteSpace(settings.DatabaseHost) || settings.DatabasePort == 0) + errors.Add("Enter valid MariaDB/MySQL connection information."); + if (string.IsNullOrWhiteSpace(settings.DatabaseUsername)) + errors.Add("Enter the MariaDB/MySQL username."); + } + else + { + if (!string.Equals(settings.DatabaseHost, "127.0.0.1", StringComparison.Ordinal)) + errors.Add("The automatic private database must use 127.0.0.1."); + if (!string.Equals(settings.DatabaseUsername, "ace_singleplayer", StringComparison.Ordinal)) + errors.Add("The automatic private database must use its isolated ACE account."); + if (!File.Exists(settings.ManagedDatabaseExePath)) + errors.Add("The bundled private database runtime is missing. Extract the complete ACE Single Player ZIP again."); + else if (Database.MariaDbInstallationLocator.FindInitializer(settings.ManagedDatabaseExePath) is null) + errors.Add("The bundled MariaDB initializer is missing. Extract the complete ACE Single Player ZIP again."); + if (string.IsNullOrWhiteSpace(settings.ProtectedDatabasePassword) || + string.IsNullOrWhiteSpace(settings.ProtectedPrivateDatabaseAdminPassword)) + errors.Add("The automatic private database credentials have not been generated."); + if (!Directory.Exists(Path.Combine(settings.PrivateDatabaseDirectory, "mysql")) && + !File.Exists(settings.WorldDatabaseSqlPath)) + errors.Add("The bundled ACE World database is missing. Extract the complete ACE Single Player ZIP again."); + } + + return new ValidationResult(errors.Count == 0, errors); + } + + public static ValidationResult ValidateClient(LauncherSettings settings) + { + var errors = new List(); + if (!File.Exists(settings.ClientExePath)) + errors.Add("Select the folder containing acclient.exe."); + else if (!string.Equals(Path.GetFileName(settings.ClientExePath), "acclient.exe", StringComparison.OrdinalIgnoreCase)) + errors.Add("The selected client executable must be acclient.exe."); + else + { + var clientDirectory = Path.GetDirectoryName(Path.GetFullPath(settings.ClientExePath))!; + var missingClientDats = RequiredClientDatFiles + .Where(file => !File.Exists(Path.Combine(clientDirectory, file))) + .ToArray(); + if (missingClientDats.Length > 0) + { + errors.Add("The folder containing acclient.exe is missing required client data files: " + + string.Join(", ", missingClientDats) + + ". The AC client must have its DAT files in the same folder as acclient.exe. Copy the complete client installation to a writable folder such as C:\\Games\\AsheronsCall, then select that acclient.exe."); + } + else if (!CanWriteDirectory(clientDirectory)) + { + errors.Add("The folder containing acclient.exe is not writable. Copy the complete AC client installation to a normal folder such as C:\\Games\\AsheronsCall; do not run it from Program Files, OneDrive, or a read-only archive."); + } + } + + return new ValidationResult(errors.Count == 0, errors); + } + + public static string? DetectDatDirectory(string clientExePath) + { + var directory = Path.GetDirectoryName(clientExePath); + return directory is not null && RequiredClientDatFiles.All(file => File.Exists(Path.Combine(directory, file))) + ? directory + : null; + } + + private static bool CanWriteDirectory(string directory) + { + var probe = Path.Combine(directory, ".ace-singleplayer-write-test-" + Guid.NewGuid().ToString("N") + ".tmp"); + try + { + using var stream = new FileStream(probe, FileMode.CreateNew, FileAccess.Write, FileShare.None, 1, FileOptions.DeleteOnClose); + stream.WriteByte(0); + return true; + } + catch (Exception ex) when (ex is UnauthorizedAccessException or IOException) + { + return false; + } + finally + { + try + { + if (File.Exists(probe)) + File.Delete(probe); + } + catch (Exception ex) when (ex is UnauthorizedAccessException or IOException) + { + // A failed cleanup does not change whether the directory accepted the write probe. + } + } + } +} diff --git a/Source/ACE.SinglePlayer/Infrastructure/SingleInstance.cs b/Source/ACE.SinglePlayer/Infrastructure/SingleInstance.cs new file mode 100644 index 0000000000..95132edbb4 --- /dev/null +++ b/Source/ACE.SinglePlayer/Infrastructure/SingleInstance.cs @@ -0,0 +1,21 @@ +namespace ACE.SinglePlayer.Infrastructure; + +public sealed class SingleInstance : IDisposable +{ + private readonly Mutex mutex; + + public SingleInstance(string name) + { + mutex = new Mutex(initiallyOwned: true, name, out var createdNew); + IsPrimary = createdNew; + } + + public bool IsPrimary { get; } + + public void Dispose() + { + if (IsPrimary) + mutex.ReleaseMutex(); + mutex.Dispose(); + } +} diff --git a/Source/ACE.SinglePlayer/Models/LauncherSettings.cs b/Source/ACE.SinglePlayer/Models/LauncherSettings.cs new file mode 100644 index 0000000000..fe7857b2bd --- /dev/null +++ b/Source/ACE.SinglePlayer/Models/LauncherSettings.cs @@ -0,0 +1,91 @@ +using System.Text.Json.Serialization; + +namespace ACE.SinglePlayer.Models; + +public enum DatabaseMode +{ + Private, + External, + // Kept so version-1 settings deserialize and can be migrated safely. + ManagedExperimental +} + +public enum ClientLaunchMode +{ + Vanilla, + Decal, + ChoriziteFuture +} + +public sealed class LauncherSettings +{ + public const int CurrentVersion = 2; + + public int SettingsVersion { get; set; } = CurrentVersion; + public string ClientExePath { get; set; } = string.Empty; + public string ServerExePath { get; set; } = string.Empty; + public string DatFilesDirectory { get; set; } = string.Empty; + public string ModsDirectory { get; set; } = string.Empty; + public string RuntimeDirectory { get; set; } = string.Empty; + public string Host { get; set; } = "127.0.0.1"; + public ushort Port { get; set; } = 9000; + public string AccountName { get; set; } = "singleplayer"; + public string ProtectedAccountPassword { get; set; } = string.Empty; + [JsonConverter(typeof(JsonStringEnumConverter))] + public DatabaseMode DatabaseMode { get; set; } = DatabaseMode.Private; + public string DatabaseHost { get; set; } = "127.0.0.1"; + public ushort DatabasePort { get; set; } = 3307; + public string DatabaseUsername { get; set; } = "ace_singleplayer"; + public string ProtectedDatabasePassword { get; set; } = string.Empty; + public string ProtectedPrivateDatabasePassword { get; set; } = string.Empty; + public string ProtectedExternalDatabasePassword { get; set; } = string.Empty; + public string ProtectedPrivateDatabaseAdminPassword { get; set; } = string.Empty; + public string PrivateDatabaseDirectoryPath { get; set; } = GetDefaultPrivateDatabaseDirectory(); + public string AuthenticationDatabaseName { get; set; } = "ace_auth"; + public string ShardDatabaseName { get; set; } = "ace_shard"; + public string WorldDatabaseName { get; set; } = "ace_world"; + public string ManagedDatabaseExePath { get; set; } = string.Empty; + public string WorldDatabaseSqlPath { get; set; } = string.Empty; + public bool StopServerWhenGameExits { get; set; } = true; + public bool StopManagedDatabaseWhenLauncherExits { get; set; } = true; + public bool MinimizeLauncherAfterClientStarts { get; set; } = true; + public int ServerStartupTimeoutSeconds { get; set; } = 180; + [JsonConverter(typeof(JsonStringEnumConverter))] + public ClientLaunchMode ClientLaunchMode { get; set; } = ClientLaunchMode.Vanilla; + + [JsonIgnore] + public string ConfigPath => Path.Combine(RuntimeDirectory, "Config.js"); + + [JsonIgnore] + public string ReadyFilePath => Path.Combine(RuntimeDirectory, "ace-server.ready.json"); + + [JsonIgnore] + public string OwnershipPath => Path.Combine(RuntimeDirectory, "ace-server.process.json"); + + [JsonIgnore] + public string PrivateDatabaseDirectory => string.IsNullOrWhiteSpace(PrivateDatabaseDirectoryPath) + ? GetDefaultPrivateDatabaseDirectory() + : PrivateDatabaseDirectoryPath; + + [JsonIgnore] + public string LegacyPrivateDatabaseDirectory => Path.Combine(RuntimeDirectory, "Database"); + + public static LauncherSettings CreateDefaults(string applicationDirectory) + { + var root = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "ACESinglePlayer"); + var packagedClient = Path.Combine(applicationDirectory, "Client", "acclient.exe"); + var packagedServer = Path.Combine(applicationDirectory, "Server", "ACE.Server.exe"); + + return new LauncherSettings + { + ClientExePath = File.Exists(packagedClient) ? packagedClient : string.Empty, + ServerExePath = File.Exists(packagedServer) ? packagedServer : string.Empty, + DatFilesDirectory = File.Exists(packagedClient) ? Path.GetDirectoryName(packagedClient)! : string.Empty, + ModsDirectory = Path.Combine(applicationDirectory, "Mods"), + RuntimeDirectory = Path.Combine(root, "Runtime") + }; + } + + private static string GetDefaultPrivateDatabaseDirectory() => Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "ACESinglePlayer", "Database"); +} diff --git a/Source/ACE.SinglePlayer/Models/LauncherState.cs b/Source/ACE.SinglePlayer/Models/LauncherState.cs new file mode 100644 index 0000000000..e061c47017 --- /dev/null +++ b/Source/ACE.SinglePlayer/Models/LauncherState.cs @@ -0,0 +1,16 @@ +namespace ACE.SinglePlayer.Models; + +public enum LauncherState +{ + NotConfigured, + PreparingData, + CheckingDatabase, + StartingDatabase, + StartingServer, + WaitingForWorld, + Ready, + GameRunning, + Error +} + +public sealed record LauncherStateChanged(LauncherState State, string Message); diff --git a/Source/ACE.SinglePlayer/Mods/AceServerModProvider.cs b/Source/ACE.SinglePlayer/Mods/AceServerModProvider.cs new file mode 100644 index 0000000000..176c973b86 --- /dev/null +++ b/Source/ACE.SinglePlayer/Mods/AceServerModProvider.cs @@ -0,0 +1,91 @@ +using System.Text.Json.Nodes; + +namespace ACE.SinglePlayer.Mods; + +public sealed class AceServerModProvider : IModProvider +{ + private readonly string modsDirectory; + + public AceServerModProvider(string modsDirectory) + { + this.modsDirectory = modsDirectory; + } + + public ModType Type => ModType.AceServer; + + public IReadOnlyList Scan() + { + if (!Directory.Exists(modsDirectory)) + return Array.Empty(); + + return Directory.GetDirectories(modsDirectory) + .OrderBy(path => path, StringComparer.OrdinalIgnoreCase) + .Select(Read) + .ToArray(); + } + + public static ModRecord Read(string directory) + { + var metadataPath = Path.Combine(directory, "Meta.json"); + if (!File.Exists(metadataPath)) + return Malformed(directory, metadataPath, "Meta.json is missing."); + + try + { + var metadata = ModMetadataEditor.Parse(File.ReadAllText(metadataPath)); + var name = ReadString(metadata, "Name"); + if (string.IsNullOrWhiteSpace(name)) + return Malformed(directory, metadataPath, "Meta.json has no Name."); + + return new ModRecord + { + CatalogId = ReadString(metadata, "CatalogId"), + Type = ModType.AceServer, + Name = name, + Author = ReadString(metadata, "Author"), + Description = ReadString(metadata, "Description"), + Version = ReadString(metadata, "Version"), + TargetAceVersion = FirstNonEmpty(ReadString(metadata, "TargetAceVersion"), ReadString(metadata, "ACEVersion")), + TargetFramework = ReadString(metadata, "TargetFramework"), + RequiredClientFramework = "None (server-side)", + RequiredDependencies = ReadNode(metadata, "Dependencies"), + Priority = ReadUInt(metadata, "Priority"), + Enabled = ReadBool(metadata, "Enabled", defaultValue: true), + InstalledPath = directory, + MetadataPath = metadataPath, + SettingsPath = Path.Combine(directory, "Settings.json"), + CompatibilityStatus = CompatibilityStatus.Unknown + }; + } + catch (Exception ex) when (ex is IOException or System.Text.Json.JsonException or InvalidOperationException) + { + return Malformed(directory, metadataPath, ex.Message); + } + } + + private static ModRecord Malformed(string directory, string metadataPath, string error) => new() + { + Type = ModType.AceServer, + Name = Path.GetFileName(directory), + InstalledPath = directory, + MetadataPath = metadataPath, + IsMalformed = true, + LastLoadError = error, + CompatibilityStatus = CompatibilityStatus.LoadFailed + }; + + private static JsonNode? Find(JsonObject value, string name) => + value.FirstOrDefault(property => string.Equals(property.Key, name, StringComparison.OrdinalIgnoreCase)).Value; + + private static string ReadString(JsonObject value, string name) => Find(value, name)?.ToString() ?? string.Empty; + + private static bool ReadBool(JsonObject value, string name, bool defaultValue) => + Find(value, name) is JsonValue node && node.TryGetValue(out var result) ? result : defaultValue; + + private static uint ReadUInt(JsonObject value, string name) => + Find(value, name) is JsonValue node && node.TryGetValue(out var result) ? result : 0; + + private static string ReadNode(JsonObject value, string name) => Find(value, name)?.ToJsonString() ?? string.Empty; + + private static string FirstNonEmpty(string first, string second) => string.IsNullOrWhiteSpace(first) ? second : first; +} diff --git a/Source/ACE.SinglePlayer/Mods/AquafirSampleCatalog.cs b/Source/ACE.SinglePlayer/Mods/AquafirSampleCatalog.cs new file mode 100644 index 0000000000..03a5b2de4b --- /dev/null +++ b/Source/ACE.SinglePlayer/Mods/AquafirSampleCatalog.cs @@ -0,0 +1,187 @@ +namespace ACE.SinglePlayer.Mods; + +public static class AquafirSampleCatalog +{ + private const string BaseUrl = "https://github.com/aquafir/ACE.BaseMod/tree/master/Samples/"; + private const string PortBaseUrl = "https://github.com/titaniumweiner/ACE-SinglePlayer/tree/main/Source/ACE.SinglePlayer.Mods."; + + public static IReadOnlyList Entries { get; } = new ModCatalogEntry[] + { + PortRequired( + "aquafir.access-db", "AccessDb", + "Adds administrator examples for moving online or offline characters, listing player locations, and querying creature/login counts from the ACE databases.", + "This is primarily a server-administration and mod-development sample. Its moveall command can permanently change character login locations.", + ModDataImpact.CharacterData, ModRemovalPolicy.ChangesRemain, + "Turning it off does not undo character moves already performed."), + PortRequired( + "aquafir.auto-loot", "AutoLoot", + "Automatically loots creature corpses for a character using selectable Virindi Tank-style loot profiles and a /loot command.", + "Loot profiles choose which generated items a character takes from corpses. It changes server-side looting behavior and does not require Decal.", + ModDataImpact.SettingsOnly, ModRemovalPolicy.Safe, + "Safe to turn off; items already looted remain in character inventories."), + PortRequired( + "aquafir.balance", "Balance", + "Lets you replace many combat, healing, experience, level-cost, critical-hit, rending, accuracy, and damage formulas from Settings.json.", + "Each formula patch can be enabled separately. Because this can change experience awards and progression costs, a character may keep gains made under a previous formula.", + ModDataImpact.CharacterData, ModRemovalPolicy.BackupRequired, + "Back up first. Disabling restores ACE formulas but cannot reverse experience, levels, or item outcomes already earned."), + PortRequired( + "aquafir.bank", "Bank", + "Adds a virtual bank for configured items, pyreals, trade notes, luminance, direct vendor deposits, and optional death-drop rules.", + "Balances are stored on characters. Players can deposit and withdraw configured items or currency with /bank, /cash, and /lum commands.", + ModDataImpact.CharacterData, ModRemovalPolicy.DoNotRemove, + "Do not turn this off while anything is deposited; stored balances could become inaccessible."), + PortRequired( + "aquafir.chat-filter", "ChatFilter", + "Filters chat and tells with configurable blacklists/whitelists, censorship, shadow bans, gags, and temporary bans.", + "This is mainly useful on multiplayer servers. It requires the Profanity.Detector dependency and careful moderation settings.", + ModDataImpact.CharacterData, ModRemovalPolicy.ChangesRemain, + "Existing gag or ban state may remain after the mod is disabled."), + PortRequired( + "aquafir.connection-limit", "ConnectionLimit", + "Limits simultaneous connections while allowing configured IP addresses or landblocks to be exempt for bots or mule characters.", + "This is a multiplayer administration tool and normally has no benefit in a private one-player world.", + ModDataImpact.SettingsOnly, ModRemovalPolicy.Safe, + "Safe to remove, but not recommended for ordinary single-player use.", + availability: ModCatalogAvailability.NotRecommended), + new ModCatalogEntry( + "aquafir.critical-override", "CriticalOverride", "aquafir", + "Overrides physical and magic critical-hit chances against non-player creatures with two simple settings.", + "The ACE Single Player port keeps player-versus-player calculations unchanged. Restart the local server after changing Settings.json.", + BaseUrl + "CriticalOverride", ModCatalogAvailability.Ready, ModDataImpact.SettingsOnly, ModRemovalPolicy.Safe, + "Safe to turn off. Existing combat results are not recalculated.", + TargetAceVersion: "ACE.Server 1.1 / ACE Single Player", + TargetFramework: ".NET 10 port", + PackageRelativePath: @"Packages\aquafir.critical-override-1.0.0-sp1.zip", + PortSourceUrl: PortBaseUrl + "CriticalOverride"), + PortRequired( + "aquafir.custom-spells", "CustomSpells", + "Creates or edits spells and equipment-set spell tiers from Settings.json or an Excel spreadsheet.", + "It can add custom spell IDs, change names and effects, alter stacking categories, and customize set bonuses. Incorrect projectile IDs can crash the client.", + ModDataImpact.WorldData, ModRemovalPolicy.DoNotRemove, + "High risk: saved enchantments or items may refer to custom spell IDs. Do not disable after using custom content without a tested migration."), + PortRequired( + "aquafir.discord", "Discord", + "Relays chat and tells between ACE and Discord and lets approved Discord users run configured server commands.", + "Requires a Discord bot, token, channel IDs, Discord.Net, and a deliberate security setup. It is intended for hosted multiplayer servers.", + ModDataImpact.SettingsOnly, ModRemovalPolicy.Safe, + "Never place a Discord bot token in a public mod package or log.", + availability: ModCatalogAvailability.NotRecommended), + PortRequired( + "aquafir.easy-enlightenment", "EasyEnlightenment", + "Makes enlightenment requirements, resets, bonuses, skill credits, luminance limits, and maximum enlightenment count configurable.", + "Optional skill, attribute, and vital bonuses rely on Expansion's BonusStats feature. It can convert characters to a new persistent progression system.", + ModDataImpact.CharacterData, ModRemovalPolicy.DoNotRemove, + "High risk: converted characters and persistent bonuses may depend on this mod.", + dependencyIds: new[] { "aquafir.expansion" }), + PortRequired( + "aquafir.expansion", "Expansion", + "A large loot and creature expansion framework that can mutate generated items, add bonus properties, sets, slayers, procs, creature behaviors, and other systems.", + "This is an experimental foundation for major gameplay overhauls. Some generated items require its runtime features to keep working as designed.", + ModDataImpact.WorldData, ModRemovalPolicy.DoNotRemove, + "High risk: once it has generated modified loot, removing it can leave dependent items in the saved world."), + new ModCatalogEntry( + "aquafir.hello-command", "HelloCommand", "aquafir", + "A small developer sample that adds /hello and /bye commands to demonstrate how ACE server-mod commands are registered.", + "It is useful for mod authors but adds little to normal gameplay.", + BaseUrl + "HelloCommand", ModCatalogAvailability.Preview, ModDataImpact.None, ModRemovalPolicy.Safe, + "Safe to turn off or remove.", + TargetAceVersion: "ACE.Server 1.1 / ACE Single Player", + TargetFramework: ".NET 10 preview port", + PackageRelativePath: @"Packages\aquafir.hello-command-1.0.0-sp1.zip", + PortSourceUrl: PortBaseUrl + "HelloCommand"), + PortRequired( + "aquafir.imgui-hud", "ImGuiHud", + "An experimental server-side ImGui overlay sample with pickers, filters, textures, tables, and on-screen tools.", + "The upstream README is unfinished. It opens native desktop UI from the server process and needs extra graphics dependencies and focused testing.", + ModDataImpact.SettingsOnly, ModRemovalPolicy.Safe, + "Experimental desktop integration; it is not a Decal in-game plugin.", + availability: ModCatalogAvailability.Experimental), + PortRequired( + "aquafir.ironman", "Ironman", + "Adds opt-in ironman/hardcore rule sets, character templates, item restrictions, fellowship/allegiance restrictions, and progression flags.", + "The upstream documentation is unfinished, but the source enrolls characters and stores custom state used by its restrictions.", + ModDataImpact.CharacterData, ModRemovalPolicy.DoNotRemove, + "High risk: enrolled characters and flagged items may rely on the mod. Test recovery before enabling it on a real save.", + availability: ModCatalogAvailability.Experimental), + PortRequired( + "aquafir.player-save", "PlayerSave", + "Exports character snapshots and can import them as new characters or into another account.", + "The author labels this work in progress. It rewrites extensive character, inventory, and relationship data and does not yet fully handle houses, allegiances, or cross-server IDs.", + ModDataImpact.CharacterData, ModRemovalPolicy.BackupRequired, + "Experimental database operation: always make a full private-database backup before loading a snapshot.", + availability: ModCatalogAvailability.Experimental), + PortRequired( + "aquafir.quality-of-life", "QualityOfLife", + "Bundles configurable convenience changes for fellowships, animations, augment limits, property defaults, tailoring, recklessness, and admin commands.", + "Individual feature groups are selected in Settings.json. Some options can permanently affect augmentations or tailored items.", + ModDataImpact.CharacterData, ModRemovalPolicy.BackupRequired, + "Back up before enabling progression or tailoring changes; turning them off does not undo earlier character or item changes."), + PortRequired( + "aquafir.quest-bonus", "QuestBonus", + "Tracks completed-quest points on each character and multiplies earned experience by a configurable quest-completion bonus.", + "The bonus is recalculated at login and when quests complete, then stored on the character in a configured property.", + ModDataImpact.CharacterData, ModRemovalPolicy.ChangesRemain, + "Turning it off stops future bonus experience but does not remove experience already gained."), + PortRequired( + "aquafir.raise", "Raise", + "Raises the level cap and adds alternate or effectively infinite advancement for levels, skills, attributes, vitals, and ratings.", + "Custom progression counts and spent resources are stored in character properties, with refund and inspection commands.", + ModDataImpact.CharacterData, ModRemovalPolicy.DoNotRemove, + "High risk: characters advanced beyond normal ACE limits may become inconsistent or lose access to progression if the mod is removed."), + PortRequired( + "aquafir.selective-startup", "SelectiveStartup", + "A developer sample that can skip selected ACE startup systems such as networking, world loading, houses, players, or events.", + "This can deliberately create a partial server for testing. Disabling the wrong subsystem can prevent the launcher from reaching a playable world.", + ModDataImpact.WorldData, ModRemovalPolicy.Safe, + "Dangerous for normal play; the launcher will not offer one-click installation.", + availability: ModCatalogAvailability.NotRecommended), + new ModCatalogEntry( + "aquafir.society-tailoring", "SocietyTailoring", "aquafir", + "Allows Society armor to be used in tailoring while keeping inventory and retained-item checks.", + "Tailoring changes the resulting item permanently; disabling the mod stops new uses but does not reverse completed tailoring.", + BaseUrl + "SocietyTailoring", ModCatalogAvailability.Preview, ModDataImpact.CharacterData, ModRemovalPolicy.ChangesRemain, + "Back up first. Existing tailored items remain after the mod is turned off.", + TargetAceVersion: "ACE.Server 1.1 / ACE Single Player", + TargetFramework: ".NET 10 preview port", + PackageRelativePath: @"Packages\aquafir.society-tailoring-1.0.0-sp1.zip", + PortSourceUrl: PortBaseUrl + "SocietyTailoring"), + PortRequired( + "aquafir.tinkering", "Tinkering", + "Reworks tinkering requirement checks, maximum attempts, difficulty scaling, and handling of additional imbue effects.", + "Items can be tinkered under rules that differ from stock ACE, so their saved tinkering counts may exceed normal expectations.", + ModDataImpact.CharacterData, ModRemovalPolicy.BackupRequired, + "Back up first. Turning it off cannot undo tinkers or imbues already applied to items."), + PortRequired( + "aquafir.tower", "Tower", + "An experimental game-mode overhaul combining tower floors, hardcore play, banking, custom loot, aetheria, melee magic, PvP, offline progress, and speedrun features.", + "The upstream README is incomplete and the source contains several interdependent systems with persistent character and item state.", + ModDataImpact.WorldData, ModRemovalPolicy.DoNotRemove, + "High risk and unfinished: use only on a separate test world after it is ported and validated.", + availability: ModCatalogAvailability.Experimental) + }; + + private static ModCatalogEntry PortRequired( + string id, + string name, + string description, + string details, + ModDataImpact dataImpact, + ModRemovalPolicy removalPolicy, + string safetyNotice, + IReadOnlyList? dependencyIds = null, + IReadOnlyList? conflictIds = null, + ModCatalogAvailability availability = ModCatalogAvailability.NeedsPort) => new( + id, + name, + "aquafir", + description, + details, + BaseUrl + name, + availability, + dataImpact, + removalPolicy, + safetyNotice, + DependencyIds: dependencyIds, + ConflictIds: conflictIds); +} diff --git a/Source/ACE.SinglePlayer/Mods/CuratedModCatalog.cs b/Source/ACE.SinglePlayer/Mods/CuratedModCatalog.cs new file mode 100644 index 0000000000..4ebe555c3e --- /dev/null +++ b/Source/ACE.SinglePlayer/Mods/CuratedModCatalog.cs @@ -0,0 +1,40 @@ +namespace ACE.SinglePlayer.Mods; + +public static class CuratedModCatalog +{ + public static IReadOnlyList Entries { get; } = + AquafirSampleCatalog.Entries.Concat(new[] + { + new ModCatalogEntry( + "titaniumweiner.ace-unique-weenies-proc", + "Expanded Cast on Strike", + "titaniumweiner", + "Enables cast-on-strike procs on non-Aetheria equipped items such as the jewelry, armor, and weapons used by ACEUniqueWeenies content.", + "Replaces ACE's Aetheria-only equipped-proc pass with the filter documented by ACEUniqueWeenies: the item must have a proc, must not be cloak weave proc type 1, and must match the current self-target mode. The package changes server combat behavior only; import compatible item SQL separately through Custom Weenies. It intentionally matches the documented filter exactly, including processing an active proc weapon again if that weapon is also present in EquippedObjects.", + "https://github.com/titaniumweiner/ACEUniqueWeenies", + ModCatalogAvailability.Preview, + ModDataImpact.SettingsOnly, + ModRemovalPolicy.Safe, + "Safe to turn off after a server restart. Imported items remain in the world, but their non-Aetheria equipped procs stop working. Earlier combat results are not reversed.", + TargetAceVersion: "ACE.Server 1.1 / ACE Single Player", + TargetFramework: ".NET 10 preview port", + PackageRelativePath: @"Packages\titaniumweiner.ace-unique-weenies-proc-1.0.0-sp1.zip", + PortSourceUrl: "https://github.com/titaniumweiner/ACE-SinglePlayer/tree/main/Source/ACE.SinglePlayer.Mods.ACEUniqueWeeniesProc", + PreviewNotice: "The package builds, loads, targets the pinned TryProcEquippedItems signature, and passes filter and removal tests. Its proc frequency and interactions with every custom item have not been thoroughly tested in game."), + new ModCatalogEntry( + "optimshi.custom-clothing-base", + "CustomClothingBase", + "OptimShi", + "Loads custom server-side ClothingTable entries from JSON files, enabling new clothing colors and appearance combinations without client DAT updates.", + "The official v1.11 package can merge or replace ClothingTable entries, export entries with @clothingbase-export, and reload its cache with @clear-clothing-cache. Place custom JSON files in the installed mod's json folder. The upstream repository currently has no LICENSE file; redistribution is based on OptimShi's permission reported by the ACE Single Player project maintainer.", + "https://github.com/OptimShi/CustomClothingBase", + ModCatalogAvailability.Preview, + ModDataImpact.WorldData, + ModRemovalPolicy.DoNotRemove, + "Back up first. Do not disable or remove this mod while saved items or world content refer to custom ClothingBase IDs.", + TargetAceVersion: "Official v1.11 binary load-tested with ACE.Server 1.1 / .NET 10", + TargetFramework: ".NET 8 upstream binary hosted by .NET 10", + PackageRelativePath: @"Packages\optimshi.custom-clothing-base-1.11-upstream.zip", + PreviewNotice: "OptimShi's unmodified official v1.11 package passed an isolated load test with the bundled ACE server. Clothing behavior, custom JSON content, cache reloads, and saved-world compatibility have not been thoroughly tested in game.") + }).ToArray(); +} diff --git a/Source/ACE.SinglePlayer/Mods/DecalPluginProvider.cs b/Source/ACE.SinglePlayer/Mods/DecalPluginProvider.cs new file mode 100644 index 0000000000..68a721eee5 --- /dev/null +++ b/Source/ACE.SinglePlayer/Mods/DecalPluginProvider.cs @@ -0,0 +1,58 @@ +using Microsoft.Win32; + +namespace ACE.SinglePlayer.Mods; + +public sealed class DecalPluginProvider : IModProvider +{ + private static readonly string[] Categories = { "Plugins", "Services", "NetworkFilters" }; + + public ModType Type => ModType.DecalPlugin; + + public IReadOnlyList Scan() + { + if (!OperatingSystem.IsWindows()) + return Array.Empty(); + + var records = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var hive in new[] { RegistryHive.LocalMachine, RegistryHive.CurrentUser }) + foreach (var view in new[] { RegistryView.Registry32, RegistryView.Registry64 }) + foreach (var category in Categories) + { + try + { + using var baseKey = RegistryKey.OpenBaseKey(hive, view); + using var categoryKey = baseKey.OpenSubKey(@"SOFTWARE\Decal\" + category); + if (categoryKey is null) + continue; + foreach (var subkeyName in categoryKey.GetSubKeyNames()) + { + using var key = categoryKey.OpenSubKey(subkeyName); + var path = (key?.GetValue("Path") as string ?? string.Empty).Trim(); + var name = key?.GetValue("Name") as string; + var enabled = Convert.ToInt32(key?.GetValue("Enabled", 0)) == 1; + var identity = $"{category}:{subkeyName}:{path}"; + records.TryAdd(identity, new ModRecord + { + Type = ModType.DecalPlugin, + Name = string.IsNullOrWhiteSpace(name) ? subkeyName : name, + Description = $"Registered Decal {category.TrimEnd('s')} (read-only inventory).", + RequiredClientFramework = "Decal", + RequiredDependencies = "An installed Decal runtime and the registered plugin files", + Enabled = enabled, + InstalledPath = path, + CompatibilityStatus = File.Exists(path) || Directory.Exists(path) + ? CompatibilityStatus.Unknown + : CompatibilityStatus.MissingDependency, + LastLoadError = File.Exists(path) || Directory.Exists(path) ? string.Empty : "The registered path is missing." + }); + } + } + catch (UnauthorizedAccessException) + { + // Inventory remains best-effort and read-only. + } + } + + return records.Values.OrderBy(record => record.Name, StringComparer.OrdinalIgnoreCase).ToArray(); + } +} diff --git a/Source/ACE.SinglePlayer/Mods/FutureModProviders.cs b/Source/ACE.SinglePlayer/Mods/FutureModProviders.cs new file mode 100644 index 0000000000..68e8e521ce --- /dev/null +++ b/Source/ACE.SinglePlayer/Mods/FutureModProviders.cs @@ -0,0 +1,13 @@ +namespace ACE.SinglePlayer.Mods; + +public sealed class ChorizitePluginProvider : IModProvider +{ + public ModType Type => ModType.ChorizitePlugin; + public IReadOnlyList Scan() => Array.Empty(); +} + +public sealed class AceContentPackProvider : IModProvider +{ + public ModType Type => ModType.WorldContent; + public IReadOnlyList Scan() => Array.Empty(); +} diff --git a/Source/ACE.SinglePlayer/Mods/ModCatalog.cs b/Source/ACE.SinglePlayer/Mods/ModCatalog.cs new file mode 100644 index 0000000000..1cafb6f01c --- /dev/null +++ b/Source/ACE.SinglePlayer/Mods/ModCatalog.cs @@ -0,0 +1,13 @@ +namespace ACE.SinglePlayer.Mods; + +public sealed class ModCatalog +{ + private readonly IReadOnlyList providers; + + public ModCatalog(IEnumerable providers) + { + this.providers = providers.ToArray(); + } + + public IReadOnlyList Scan() => providers.SelectMany(provider => provider.Scan()).ToArray(); +} diff --git a/Source/ACE.SinglePlayer/Mods/ModCatalogService.cs b/Source/ACE.SinglePlayer/Mods/ModCatalogService.cs new file mode 100644 index 0000000000..f2aacca167 --- /dev/null +++ b/Source/ACE.SinglePlayer/Mods/ModCatalogService.cs @@ -0,0 +1,146 @@ +namespace ACE.SinglePlayer.Mods; + +public sealed class ModCatalogService +{ + private readonly IReadOnlyList catalog; + private readonly string applicationDirectory; + + public ModCatalogService(IEnumerable catalog, string applicationDirectory) + { + this.catalog = catalog.ToArray(); + this.applicationDirectory = Path.GetFullPath(applicationDirectory); + } + + public IReadOnlyList Merge(IEnumerable installedRecords) + { + var installed = installedRecords.ToArray(); + var installedByName = installed + .GroupBy(record => record.Name, StringComparer.OrdinalIgnoreCase) + .ToDictionary(group => group.Key, group => group.First(), StringComparer.OrdinalIgnoreCase); + var catalogByName = catalog.ToDictionary(entry => entry.Name, StringComparer.OrdinalIgnoreCase); + var installedIds = catalog + .Where(entry => installedByName.ContainsKey(entry.Name)) + .Select(entry => entry.Id) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + var result = catalog.Select(entry => + { + installedByName.TryGetValue(entry.Name, out var installedRecord); + var packagePath = ResolvePackagePath(entry.PackageRelativePath); + var (status, message) = Evaluate(entry, installedRecord, installedIds, packagePath); + return new ModListItem + { + Catalog = entry, + Installed = installedRecord, + CompatibilityStatus = status, + CompatibilityMessage = message, + PackagePath = packagePath + }; + }).ToList(); + + foreach (var record in installed.Where(record => !catalogByName.ContainsKey(record.Name))) + { + var description = string.IsNullOrWhiteSpace(record.Description) + ? "Installed manually. No curated description is available." + : record.Description; + var entry = new ModCatalogEntry( + "installed." + NormalizeId(record.Name), + record.Name, + string.IsNullOrWhiteSpace(record.Author) ? "Unknown" : record.Author, + description, + "This mod was found in the local Mods folder but is not part of the curated catalog. The launcher cannot verify its dependencies, conflicts, or saved-data behavior.", + string.Empty, + ModCatalogAvailability.Experimental, + ModDataImpact.WorldData, + ModRemovalPolicy.BackupRequired, + "Unknown mod: make a backup before changing or removing it.", + record.TargetAceVersion, + record.TargetFramework); + result.Add(new ModListItem + { + Catalog = entry, + Installed = record, + CompatibilityStatus = record.IsMalformed ? CompatibilityStatus.LoadFailed : CompatibilityStatus.Unknown, + CompatibilityMessage = record.IsMalformed ? record.LastLoadError : "Installed manually; compatibility has not been verified." + }); + } + + return result + .OrderBy(GetDisplayRank) + .ThenBy(item => item.Name, StringComparer.OrdinalIgnoreCase) + .ToArray(); + } + + private static int GetDisplayRank(ModListItem item) => item switch + { + { Installed: not null } => 0, + { CompatibilityStatus: CompatibilityStatus.Compatible } => 1, + _ => 2 + }; + + private (CompatibilityStatus Status, string Message) Evaluate( + ModCatalogEntry entry, + ModRecord? installed, + IReadOnlySet installedIds, + string packagePath) + { + if (installed is not null) + { + if (installed.IsMalformed) + return (CompatibilityStatus.LoadFailed, installed.LastLoadError); + var installedMessage = installed.Enabled + ? "Installed and enabled. A server restart is required after changes." + : "Installed but disabled. A server restart is required after changes."; + if (entry.Availability == ModCatalogAvailability.Preview) + installedMessage += " " + GetPreviewNotice(entry); + return (CompatibilityStatus.Compatible, installedMessage); + } + + if (entry.Availability is not (ModCatalogAvailability.Ready or ModCatalogAvailability.Preview)) + { + var message = entry.Availability switch + { + ModCatalogAvailability.Experimental => "The upstream sample is unfinished or high-risk and has not been ported to this ACE build.", + ModCatalogAvailability.NotRecommended => "This sample is not intended for ordinary single-player use and has not been ported.", + _ => "The upstream .NET 8 sample must be rebuilt and tested against this exact ACE version before installation." + }; + return (CompatibilityStatus.NeedsSourcePort, message); + } + + var missing = entry.Dependencies.Where(id => !installedIds.Contains(id)).ToArray(); + if (missing.Length > 0) + return (CompatibilityStatus.MissingDependency, "Install first: " + FriendlyNames(missing)); + + var conflicts = entry.Conflicts.Where(installedIds.Contains).ToArray(); + if (conflicts.Length > 0) + return (CompatibilityStatus.Conflict, "Conflicts with: " + FriendlyNames(conflicts)); + + if (string.IsNullOrWhiteSpace(packagePath) || !File.Exists(packagePath)) + return (CompatibilityStatus.PackageMissing, "This build does not contain the validated mod package."); + + return entry.Availability == ModCatalogAvailability.Preview + ? (CompatibilityStatus.Compatible, GetPreviewNotice(entry)) + : (CompatibilityStatus.Compatible, "Validated package is included and ready to install."); + } + + private static string GetPreviewNotice(ModCatalogEntry entry) => + string.IsNullOrWhiteSpace(entry.PreviewNotice) + ? "Preview package is included. It builds and passes automated load, patch/registration, checksum, and package checks, but it has not received thorough in-game testing. Back up before use." + : entry.PreviewNotice; + + private string FriendlyNames(IEnumerable ids) => string.Join(", ", ids.Select(id => + catalog.FirstOrDefault(entry => string.Equals(entry.Id, id, StringComparison.OrdinalIgnoreCase))?.Name ?? id)); + + private string ResolvePackagePath(string relativePath) + { + if (string.IsNullOrWhiteSpace(relativePath)) + return string.Empty; + var path = Path.GetFullPath(Path.Combine(applicationDirectory, relativePath)); + var prefix = applicationDirectory.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + Path.DirectorySeparatorChar; + return path.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) ? path : string.Empty; + } + + private static string NormalizeId(string value) => string.Concat(value + .ToLowerInvariant() + .Select(character => char.IsLetterOrDigit(character) ? character : '-')); +} diff --git a/Source/ACE.SinglePlayer/Mods/ModMetadataEditor.cs b/Source/ACE.SinglePlayer/Mods/ModMetadataEditor.cs new file mode 100644 index 0000000000..417ee0a32c --- /dev/null +++ b/Source/ACE.SinglePlayer/Mods/ModMetadataEditor.cs @@ -0,0 +1,41 @@ +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace ACE.SinglePlayer.Mods; + +public static class ModMetadataEditor +{ + public static JsonObject Parse(string json) + { + var node = JsonNode.Parse(json, documentOptions: new JsonDocumentOptions + { + AllowTrailingCommas = true, + CommentHandling = JsonCommentHandling.Skip + }); + return node as JsonObject ?? throw new JsonException("Meta.json must contain one JSON object."); + } + + public static void SetEnabled(JsonObject metadata, bool enabled) + { + var existingName = metadata.Select(property => property.Key) + .FirstOrDefault(name => string.Equals(name, "Enabled", StringComparison.OrdinalIgnoreCase)); + metadata[existingName ?? "Enabled"] = enabled; + } + + public static async Task SetEnabledAsync(string metadataPath, bool enabled, CancellationToken cancellationToken = default) + { + var metadata = Parse(await File.ReadAllTextAsync(metadataPath, cancellationToken)); + SetEnabled(metadata, enabled); + var temporaryPath = metadataPath + "." + Guid.NewGuid().ToString("N") + ".tmp"; + try + { + await File.WriteAllTextAsync(temporaryPath, metadata.ToJsonString(new JsonSerializerOptions { WriteIndented = true }), cancellationToken); + File.Move(temporaryPath, metadataPath, true); + } + finally + { + if (File.Exists(temporaryPath)) + File.Delete(temporaryPath); + } + } +} diff --git a/Source/ACE.SinglePlayer/Mods/ModModels.cs b/Source/ACE.SinglePlayer/Mods/ModModels.cs new file mode 100644 index 0000000000..19ecfe285d --- /dev/null +++ b/Source/ACE.SinglePlayer/Mods/ModModels.cs @@ -0,0 +1,139 @@ +namespace ACE.SinglePlayer.Mods; + +public enum ModType +{ + AceServer, + DecalPlugin, + ChorizitePlugin, + WorldContent +} + +public enum CompatibilityStatus +{ + Compatible, + CompatibleAfterRebuild, + NeedsSourcePort, + MissingDependency, + Conflict, + PackageMissing, + WrongModType, + Unknown, + LoadFailed +} + +public enum ModDataImpact +{ + None, + SettingsOnly, + CharacterData, + WorldData, + ClientData +} + +public enum ModRemovalPolicy +{ + Safe, + ChangesRemain, + BackupRequired, + DoNotRemove +} + +public enum ModCatalogAvailability +{ + Ready, + Preview, + NeedsPort, + Experimental, + NotRecommended +} + +public sealed class ModRecord +{ + public string CatalogId { get; init; } = string.Empty; + public ModType Type { get; init; } + public string Name { get; init; } = string.Empty; + public string Author { get; init; } = string.Empty; + public string Description { get; init; } = string.Empty; + public string Version { get; init; } = string.Empty; + public string TargetAceVersion { get; init; } = string.Empty; + public string TargetFramework { get; init; } = string.Empty; + public string RequiredClientFramework { get; init; } = string.Empty; + public string RequiredDependencies { get; init; } = string.Empty; + public uint Priority { get; init; } + public bool Enabled { get; set; } + public string InstalledPath { get; init; } = string.Empty; + public string MetadataPath { get; init; } = string.Empty; + public string SettingsPath { get; init; } = string.Empty; + public bool IsMalformed { get; init; } + public string LastLoadError { get; init; } = string.Empty; + public CompatibilityStatus CompatibilityStatus { get; init; } = CompatibilityStatus.Unknown; + public bool CanToggle => Type == ModType.AceServer && !IsMalformed; +} + +public sealed record ModCatalogEntry( + string Id, + string Name, + string Author, + string Description, + string Details, + string SourceUrl, + ModCatalogAvailability Availability, + ModDataImpact DataImpact, + ModRemovalPolicy RemovalPolicy, + string SafetyNotice, + string TargetAceVersion = "Current ACE port required", + string TargetFramework = ".NET 8 source sample", + IReadOnlyList? DependencyIds = null, + IReadOnlyList? ConflictIds = null, + string PackageRelativePath = "", + string PortSourceUrl = "", + string PreviewNotice = "") +{ + public IReadOnlyList Dependencies { get; init; } = DependencyIds ?? Array.Empty(); + public IReadOnlyList Conflicts { get; init; } = ConflictIds ?? Array.Empty(); +} + +public sealed class ModListItem +{ + public required ModCatalogEntry Catalog { get; init; } + public ModRecord? Installed { get; init; } + public required CompatibilityStatus CompatibilityStatus { get; init; } + public string CompatibilityMessage { get; init; } = string.Empty; + public string PackagePath { get; init; } = string.Empty; + + public string Name => Catalog.Name; + public string Author => Catalog.Author; + public string Description => Catalog.Description; + public string Safety => Catalog.DataImpact switch + { + ModDataImpact.None => "No saved-data changes", + ModDataImpact.SettingsOnly => "Settings only", + ModDataImpact.CharacterData => "Changes characters", + ModDataImpact.WorldData => "Changes the saved world", + ModDataImpact.ClientData => "Changes client data", + _ => Catalog.DataImpact.ToString() + }; + public string Status => Installed?.IsMalformed == true ? "Install error" + : Installed is not null + ? Catalog.Availability == ModCatalogAvailability.Preview + ? Installed.Enabled ? "Installed - on (preview)" : "Installed - off (preview)" + : Installed.Enabled ? "Installed - on" : "Installed - off" + : Catalog.Availability == ModCatalogAvailability.Preview ? "Preview - limited testing" + : Catalog.Availability == ModCatalogAvailability.Experimental ? "Experimental" + : Catalog.Availability == ModCatalogAvailability.NotRecommended ? "Not recommended" + : CompatibilityStatus switch + { + CompatibilityStatus.Compatible => "Ready to install", + CompatibilityStatus.NeedsSourcePort => "Needs port", + CompatibilityStatus.MissingDependency => "Missing requirement", + CompatibilityStatus.Conflict => "Conflicts", + CompatibilityStatus.PackageMissing => "Package unavailable", + _ => CompatibilityStatus.ToString() + }; +} + +public interface IModProvider +{ + ModType Type { get; } + IReadOnlyList Scan(); +} diff --git a/Source/ACE.SinglePlayer/Mods/ModPackageInstaller.cs b/Source/ACE.SinglePlayer/Mods/ModPackageInstaller.cs new file mode 100644 index 0000000000..6a2b37b48f --- /dev/null +++ b/Source/ACE.SinglePlayer/Mods/ModPackageInstaller.cs @@ -0,0 +1,240 @@ +using System.IO.Compression; +using System.Security.Cryptography; +using System.Text.Json; + +namespace ACE.SinglePlayer.Mods; + +public sealed class ModPackageManifest +{ + public int FormatVersion { get; set; } + public string Id { get; set; } = string.Empty; + public string Name { get; set; } = string.Empty; + public string Version { get; set; } = string.Empty; + public string FolderName { get; set; } = string.Empty; + public string EntryAssembly { get; set; } = string.Empty; +} + +public sealed class ModPackageInstaller +{ + private const long MaximumExpandedSize = 100L * 1024 * 1024; + private const int MaximumEntries = 500; + + public async Task InspectAsync( + string packagePath, + CancellationToken cancellationToken = default) + { + packagePath = Path.GetFullPath(packagePath); + if (!File.Exists(packagePath)) + throw new FileNotFoundException("The mod package is missing.", packagePath); + + await VerifyHashSidecarAsync(packagePath, cancellationToken); + using var archive = ZipFile.OpenRead(packagePath); + ValidateArchiveSize(archive); + var manifestEntry = FindManifest(archive); + var manifest = await ReadManifestAsync(manifestEntry, cancellationToken); + ValidateManifest(manifest, expectedCatalogId: null); + return manifest; + } + + public async Task InstallAsync( + string packagePath, + string expectedCatalogId, + string modsDirectory, + string stagingRoot, + CancellationToken cancellationToken = default) + { + packagePath = Path.GetFullPath(packagePath); + if (!File.Exists(packagePath)) + throw new FileNotFoundException("The mod package is missing.", packagePath); + + await VerifyHashSidecarAsync(packagePath, cancellationToken); + Directory.CreateDirectory(modsDirectory); + Directory.CreateDirectory(stagingRoot); + + var operationDirectory = Path.Combine(stagingRoot, "mod-install-" + Guid.NewGuid().ToString("N")); + var extractedModDirectory = Path.Combine(operationDirectory, "mod"); + Directory.CreateDirectory(extractedModDirectory); + + try + { + using var archive = ZipFile.OpenRead(packagePath); + ValidateArchiveSize(archive); + var manifestEntry = FindManifest(archive); + var manifest = await ReadManifestAsync(manifestEntry, cancellationToken); + ValidateManifest(manifest, expectedCatalogId); + + var seenPaths = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var entry in archive.Entries) + { + cancellationToken.ThrowIfCancellationRequested(); + var normalized = NormalizeEntry(entry.FullName); + if (string.Equals(normalized, "ace-mod.json", StringComparison.OrdinalIgnoreCase)) + continue; + if (!normalized.StartsWith("mod/", StringComparison.OrdinalIgnoreCase)) + throw new InvalidDataException($"Unexpected package entry: {entry.FullName}"); + + var relative = normalized[4..]; + if (string.IsNullOrWhiteSpace(relative)) + continue; + ValidateRelativePath(relative); + if (!seenPaths.Add(relative)) + throw new InvalidDataException($"Duplicate package path: {relative}"); + + var destination = Path.GetFullPath(Path.Combine(extractedModDirectory, relative.Replace('/', Path.DirectorySeparatorChar))); + EnsureInside(extractedModDirectory, destination); + if (normalized.EndsWith('/')) + { + Directory.CreateDirectory(destination); + continue; + } + + Directory.CreateDirectory(Path.GetDirectoryName(destination)!); + await using var source = entry.Open(); + await using var target = new FileStream(destination, FileMode.CreateNew, FileAccess.Write, FileShare.None, 81920, FileOptions.Asynchronous); + await source.CopyToAsync(target, cancellationToken); + } + + ValidateExtractedMod(extractedModDirectory, manifest); + var destinationDirectory = Path.GetFullPath(Path.Combine(modsDirectory, manifest.FolderName)); + EnsureImmediateChild(modsDirectory, destinationDirectory); + if (Directory.Exists(destinationDirectory) || File.Exists(destinationDirectory)) + throw new IOException($"{manifest.Name} is already installed."); + + Directory.Move(extractedModDirectory, destinationDirectory); + return destinationDirectory; + } + finally + { + if (Directory.Exists(operationDirectory)) + Directory.Delete(operationDirectory, recursive: true); + } + } + + public string MoveToQuarantine(string installedDirectory, string modsDirectory, string quarantineRoot) + { + installedDirectory = Path.GetFullPath(installedDirectory); + EnsureImmediateChild(modsDirectory, installedDirectory); + if (!Directory.Exists(installedDirectory)) + throw new DirectoryNotFoundException("The installed mod folder no longer exists."); + + Directory.CreateDirectory(quarantineRoot); + var baseName = Path.GetFileName(installedDirectory) + "-" + DateTime.UtcNow.ToString("yyyyMMdd-HHmmss"); + var destination = Path.Combine(quarantineRoot, baseName); + for (var suffix = 2; Directory.Exists(destination); suffix++) + destination = Path.Combine(quarantineRoot, baseName + "-" + suffix); + + Directory.Move(installedDirectory, destination); + return destination; + } + + private static async Task VerifyHashSidecarAsync(string packagePath, CancellationToken cancellationToken) + { + var sidecarPath = packagePath + ".sha256"; + if (!File.Exists(sidecarPath)) + throw new InvalidDataException("The package checksum is missing."); + + var expected = (await File.ReadAllTextAsync(sidecarPath, cancellationToken)) + .Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries) + .FirstOrDefault() ?? string.Empty; + if (expected.Length != 64 || expected.Any(character => !Uri.IsHexDigit(character))) + throw new InvalidDataException("The package checksum is malformed."); + + await using var stream = new FileStream(packagePath, FileMode.Open, FileAccess.Read, FileShare.Read, 81920, FileOptions.Asynchronous | FileOptions.SequentialScan); + var actual = Convert.ToHexString(await SHA256.HashDataAsync(stream, cancellationToken)); + if (!string.Equals(actual, expected, StringComparison.OrdinalIgnoreCase)) + throw new InvalidDataException("The package checksum does not match. The mod was not installed."); + } + + private static async Task ReadManifestAsync(ZipArchiveEntry entry, CancellationToken cancellationToken) + { + await using var stream = entry.Open(); + return await JsonSerializer.DeserializeAsync(stream, new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + }, cancellationToken) ?? throw new InvalidDataException("The package manifest is empty."); + } + + private static ZipArchiveEntry FindManifest(ZipArchive archive) => + archive.Entries.SingleOrDefault(entry => + string.Equals(NormalizeEntry(entry.FullName), "ace-mod.json", StringComparison.OrdinalIgnoreCase)) + ?? throw new InvalidDataException("The package has no ace-mod.json manifest."); + + private static void ValidateArchiveSize(ZipArchive archive) + { + if (archive.Entries.Count > MaximumEntries) + throw new InvalidDataException("The mod package contains too many files."); + if (archive.Entries.Sum(entry => entry.Length) > MaximumExpandedSize) + throw new InvalidDataException("The expanded mod package is larger than 100 MB."); + } + + private static void ValidateManifest(ModPackageManifest manifest, string? expectedCatalogId) + { + if (manifest.FormatVersion != 1) + throw new InvalidDataException("The package manifest version is not supported."); + if (string.IsNullOrWhiteSpace(manifest.Id)) + throw new InvalidDataException("The package identity is missing."); + if (!string.IsNullOrWhiteSpace(expectedCatalogId) && + !string.Equals(manifest.Id, expectedCatalogId, StringComparison.OrdinalIgnoreCase)) + throw new InvalidDataException("The package identity does not match the selected catalog mod."); + if (string.IsNullOrWhiteSpace(manifest.Name) || string.IsNullOrWhiteSpace(manifest.Version)) + throw new InvalidDataException("The package name or version is missing."); + ValidateSimpleName(manifest.FolderName, "folder"); + ValidateSimpleName(manifest.EntryAssembly, "entry assembly"); + if (!manifest.EntryAssembly.EndsWith(".dll", StringComparison.OrdinalIgnoreCase)) + throw new InvalidDataException("The package entry assembly must be a DLL."); + if (!string.Equals(Path.GetFileNameWithoutExtension(manifest.EntryAssembly), manifest.FolderName, StringComparison.OrdinalIgnoreCase)) + throw new InvalidDataException("ACE requires the mod folder and entry assembly to have the same name."); + } + + private static void ValidateExtractedMod(string directory, ModPackageManifest manifest) + { + var metadataPath = Path.Combine(directory, "Meta.json"); + var assemblyPath = Path.Combine(directory, manifest.EntryAssembly); + if (!File.Exists(metadataPath)) + throw new InvalidDataException("The package does not contain mod/Meta.json."); + if (!File.Exists(assemblyPath)) + throw new InvalidDataException($"The package does not contain mod/{manifest.EntryAssembly}."); + + var metadata = ModMetadataEditor.Parse(File.ReadAllText(metadataPath)); + var name = metadata.FirstOrDefault(property => string.Equals(property.Key, "Name", StringComparison.OrdinalIgnoreCase)).Value?.ToString(); + if (!string.Equals(name, manifest.Name, StringComparison.OrdinalIgnoreCase)) + throw new InvalidDataException("Meta.json does not match the package manifest."); + } + + private static string NormalizeEntry(string value) + { + var normalized = value.Replace('\\', '/'); + if (normalized.StartsWith('/') || Path.IsPathRooted(normalized)) + throw new InvalidDataException($"Absolute package path is not allowed: {value}"); + return normalized; + } + + private static void ValidateRelativePath(string value) + { + var parts = value.Split('/', StringSplitOptions.RemoveEmptyEntries); + if (parts.Length == 0 || parts.Any(part => part is "." or ".." || part.Contains(':'))) + throw new InvalidDataException($"Unsafe package path: {value}"); + } + + private static void ValidateSimpleName(string value, string field) + { + if (string.IsNullOrWhiteSpace(value) || value is "." or ".." || + value.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0 || value.Contains('/') || value.Contains('\\')) + throw new InvalidDataException($"The package {field} is invalid."); + } + + private static void EnsureInside(string parent, string child) + { + var prefix = Path.GetFullPath(parent).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + Path.DirectorySeparatorChar; + if (!child.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + throw new InvalidDataException("A package entry attempted to leave its install directory."); + } + + private static void EnsureImmediateChild(string parent, string child) + { + var expectedParent = Path.GetFullPath(parent).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + var actualParent = Path.GetDirectoryName(Path.GetFullPath(child))?.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + if (!string.Equals(expectedParent, actualParent, StringComparison.OrdinalIgnoreCase)) + throw new InvalidDataException("The mod folder is outside the configured Mods directory."); + } +} diff --git a/Source/ACE.SinglePlayer/Networking/PortProbe.cs b/Source/ACE.SinglePlayer/Networking/PortProbe.cs new file mode 100644 index 0000000000..3b6f08c1da --- /dev/null +++ b/Source/ACE.SinglePlayer/Networking/PortProbe.cs @@ -0,0 +1,38 @@ +using System.Net; +using System.Net.NetworkInformation; + +namespace ACE.SinglePlayer.Networking; + +public interface IPortProbe +{ + bool IsListening(IPAddress address, int port); + Task WaitUntilListeningAsync(IPAddress address, int port, TimeSpan timeout, CancellationToken cancellationToken); +} + +public sealed class UdpPortProbe : IPortProbe +{ + public bool IsListening(IPAddress address, int port) + { + return IPGlobalProperties.GetIPGlobalProperties() + .GetActiveUdpListeners() + .Any(endpoint => endpoint.Port == port && + (endpoint.Address.Equals(address) || + (IPAddress.IsLoopback(address) && IPAddress.IsLoopback(endpoint.Address)))); + } + + public async Task WaitUntilListeningAsync(IPAddress address, int port, TimeSpan timeout, CancellationToken cancellationToken) + { + using var timeoutSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeoutSource.CancelAfter(timeout); + + try + { + while (!IsListening(address, port)) + await Task.Delay(100, timeoutSource.Token); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + throw new TimeoutException($"ACE.Server did not bind UDP {address}:{port} within {timeout.TotalSeconds:N0} seconds."); + } + } +} diff --git a/Source/ACE.SinglePlayer/Networking/ReadyFileMonitor.cs b/Source/ACE.SinglePlayer/Networking/ReadyFileMonitor.cs new file mode 100644 index 0000000000..67daafe246 --- /dev/null +++ b/Source/ACE.SinglePlayer/Networking/ReadyFileMonitor.cs @@ -0,0 +1,84 @@ +using System.Diagnostics; +using System.Net; +using System.Text.Json; + +namespace ACE.SinglePlayer.Networking; + +public sealed class ReadyFilePayload +{ + public int ProcessId { get; set; } + public string WorldName { get; set; } = string.Empty; + public string Host { get; set; } = string.Empty; + public ushort Port { get; set; } + public DateTime ReadyAtUtc { get; set; } +} + +public sealed class ReadyFileMonitor +{ + private readonly IPortProbe portProbe; + + public ReadyFileMonitor(IPortProbe portProbe) + { + this.portProbe = portProbe; + } + + public static ReadyFilePayload Validate(string json, int expectedProcessId, IPAddress expectedHost, int expectedPort) + { + var payload = JsonSerializer.Deserialize(json, new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + }) ?? throw new InvalidDataException("The ACE.Server ready file is empty."); + + if (payload.ProcessId != expectedProcessId) + throw new InvalidDataException("The ready file belongs to a different ACE.Server process."); + if (!IPAddress.TryParse(payload.Host, out var actualHost) || !actualHost.Equals(expectedHost)) + throw new InvalidDataException("The ready file reports an unexpected server address."); + if (payload.Port != expectedPort) + throw new InvalidDataException("The ready file reports an unexpected server port."); + if (payload.ReadyAtUtc == default) + throw new InvalidDataException("The ready file has no valid readiness timestamp."); + + return payload; + } + + public async Task WaitAsync(string path, Process serverProcess, IPAddress host, int port, + TimeSpan timeout, CancellationToken cancellationToken) + { + using var timeoutSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeoutSource.CancelAfter(timeout); + + try + { + while (true) + { + cancellationToken.ThrowIfCancellationRequested(); + if (serverProcess.HasExited) + throw new InvalidOperationException($"ACE.Server exited before the world became ready (exit code {serverProcess.ExitCode})."); + + if (File.Exists(path)) + { + try + { + var payload = Validate(await File.ReadAllTextAsync(path, timeoutSource.Token), serverProcess.Id, host, port); + if (portProbe.IsListening(host, port)) + return payload; + } + catch (IOException) + { + // The server may still be completing the atomic replacement. + } + catch (JsonException) + { + // A partially observed file is retried until timeout. + } + } + + await Task.Delay(100, timeoutSource.Token); + } + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + throw new TimeoutException($"ACE.Server did not open the world within {timeout.TotalSeconds:N0} seconds."); + } + } +} diff --git a/Source/ACE.SinglePlayer/Orchestration/LauncherController.cs b/Source/ACE.SinglePlayer/Orchestration/LauncherController.cs new file mode 100644 index 0000000000..650ea8f8ca --- /dev/null +++ b/Source/ACE.SinglePlayer/Orchestration/LauncherController.cs @@ -0,0 +1,229 @@ +using System.Net; + +using ACE.SinglePlayer.Configuration; +using ACE.SinglePlayer.Database; +using ACE.SinglePlayer.Infrastructure; +using ACE.SinglePlayer.Models; +using ACE.SinglePlayer.Networking; +using ACE.SinglePlayer.Processes; + +namespace ACE.SinglePlayer.Orchestration; + +public sealed class LauncherController : IAsyncDisposable +{ + private readonly AceConfigurationWriter configurationWriter; + private readonly DatabaseRuntimeFactory databaseRuntimeFactory; + private readonly DatabaseBootstrapper databaseBootstrapper; + private readonly SettingsStore settingsStore; + private readonly AceServerProcessManager serverManager; + private readonly ClientProcessManager clientManager; + private readonly ReadyFileMonitor readyFileMonitor; + private readonly ISecretProtector secretProtector; + private readonly LauncherLog log; + private readonly ServerDatProvisioner serverDatProvisioner; + private readonly PlayOperationGate playGate = new(); + private CancellationTokenSource? runCancellation; + private IDatabaseRuntime? databaseRuntime; + + public LauncherController(LauncherSettings settings, AceConfigurationWriter configurationWriter, + DatabaseRuntimeFactory databaseRuntimeFactory, DatabaseBootstrapper databaseBootstrapper, + SettingsStore settingsStore, AceServerProcessManager serverManager, + ClientProcessManager clientManager, ReadyFileMonitor readyFileMonitor, + ISecretProtector secretProtector, LauncherLog log) + { + Settings = settings; + this.configurationWriter = configurationWriter; + this.databaseRuntimeFactory = databaseRuntimeFactory; + this.databaseBootstrapper = databaseBootstrapper; + this.settingsStore = settingsStore; + this.serverManager = serverManager; + this.clientManager = clientManager; + this.readyFileMonitor = readyFileMonitor; + this.secretProtector = secretProtector; + this.log = log; + serverDatProvisioner = new ServerDatProvisioner(log); + State = new LauncherStateMachine(); + + serverManager.UnexpectedExit += exitCode => State.Set(LauncherState.Error, + $"ACE.Server stopped unexpectedly (exit code {exitCode}). Open Logs for details."); + clientManager.ClientExited += exitCode => { _ = HandleClientExitAsync(); }; + } + + public LauncherSettings Settings { get; private set; } + public LauncherStateMachine State { get; } + public bool IsServerRunning => serverManager.IsRunning; + public bool IsClientRunning => clientManager.IsRunning; + + public void UpdateSettings(LauncherSettings settings) + { + Settings = settings; + State.Set(LauncherState.Ready, "Configuration saved. Click Play."); + } + + public async Task PlayAsync(CancellationToken cancellationToken = default) + { + if (!playGate.TryEnter()) + { + log.Write("Ignored a duplicate Play request while startup is already in progress."); + return false; + } + + try + { + if (clientManager.IsRunning) + { + log.Write("Ignored Play because the game is already running."); + return false; + } + + var validation = SetupValidator.Validate(Settings); + if (!validation.IsValid) + { + State.Set(LauncherState.NotConfigured, validation.Message); + return false; + } + + Directory.CreateDirectory(Settings.RuntimeDirectory); + Directory.CreateDirectory(Settings.ModsDirectory); + runCancellation?.Dispose(); + runCancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + var token = runCancellation.Token; + + if (serverManager.IsRunning && serverDatProvisioner.RequiresRefresh(Settings)) + { + log.Write("Stopping the current ACE.Server process before refreshing its private DAT copy."); + await serverManager.StopAsync(token); + } + + State.Set(LauncherState.PreparingData, + "Preparing private server data files. The first run may take a few minutes..."); + var serverDatDirectory = await serverDatProvisioner.EnsureAsync(Settings, token); + if (!string.Equals(Settings.DatFilesDirectory, serverDatDirectory, StringComparison.OrdinalIgnoreCase)) + { + Settings.DatFilesDirectory = serverDatDirectory; + await settingsStore.SaveAsync(Settings, token); + } + + validation = SetupValidator.Validate(Settings); + if (!validation.IsValid) + throw new InvalidOperationException(validation.Message); + + if (!serverManager.IsRunning) + { + databaseRuntime ??= databaseRuntimeFactory.Create(Settings); + State.Set(databaseRuntime.IsManaged ? LauncherState.StartingDatabase : LauncherState.CheckingDatabase, + databaseRuntime.IsManaged ? "Preparing your private local database..." : "Checking MariaDB/MySQL and ACE databases..."); + await databaseRuntime.StartAsync(Settings, token); + + if (databaseRuntime.IsManaged) + { + await databaseBootstrapper.BootstrapAsync(Settings, Settings.WorldDatabaseSqlPath, token); + var databaseValidation = await databaseRuntime.ValidateAsync(Settings, token); + if (!databaseValidation.IsValid) + throw new InvalidOperationException(databaseValidation.Message); + await settingsStore.SaveAsync(Settings, token); + } + + await configurationWriter.WriteAsync(Settings, token); + State.Set(LauncherState.StartingServer, "Starting the local ACE world..."); + var server = await serverManager.TryAttachLauncherOwnedAsync(Settings, token) + ?? await serverManager.StartAsync(Settings, token); + + State.Set(LauncherState.WaitingForWorld, "Waiting for the world to open for logins..."); + await readyFileMonitor.WaitAsync(Settings.ReadyFilePath, server, IPAddress.Loopback, Settings.Port, + TimeSpan.FromSeconds(Settings.ServerStartupTimeoutSeconds), token); + } + + State.Set(LauncherState.Ready, "The local world is ready. Starting Asheron's Call..."); + var password = secretProtector.Unprotect(Settings.ProtectedAccountPassword); + await clientManager.StartAsync(Settings, password, token); + State.Set(LauncherState.GameRunning, "Asheron's Call is running."); + return true; + } + catch (OperationCanceledException) + { + State.Set(LauncherState.Ready, "Startup canceled."); + return false; + } + catch (Exception ex) + { + log.Write("Launch failed: " + ex); + State.Set(LauncherState.Error, ex.Message); + await StopInfrastructureAfterFailureAsync(); + return false; + } + finally + { + playGate.Exit(); + } + } + + public async Task StopAsync(CancellationToken cancellationToken = default) + { + await StopAsync(stopManagedDatabase: true, cancellationToken); + } + + public async Task ShutdownAsync(CancellationToken cancellationToken = default) + { + await StopAsync(Settings.StopManagedDatabaseWhenLauncherExits, cancellationToken); + } + + private async Task StopAsync(bool stopManagedDatabase, CancellationToken cancellationToken) + { + runCancellation?.Cancel(); + try + { + await clientManager.StopAsync(cancellationToken); + await serverManager.StopAsync(cancellationToken); + if (stopManagedDatabase && databaseRuntime?.IsManaged == true) + await databaseRuntime.StopAsync(cancellationToken); + State.Set(LauncherState.Ready, "Stopped. Click Play when you are ready."); + } + catch (Exception ex) + { + log.Write("Stop failed: " + ex); + State.Set(LauncherState.Error, ex.Message); + } + } + + private async Task HandleClientExitAsync() + { + try + { + if (Settings.StopServerWhenGameExits) + { + await serverManager.StopAsync(CancellationToken.None); + if (databaseRuntime?.IsManaged == true && Settings.StopManagedDatabaseWhenLauncherExits) + await databaseRuntime.StopAsync(CancellationToken.None); + } + State.Set(LauncherState.Ready, "Game closed. Click Play to return to your characters."); + } + catch (Exception ex) + { + log.Write("Cleanup after the game closed failed: " + ex); + State.Set(LauncherState.Error, ex.Message); + } + } + + private async Task StopInfrastructureAfterFailureAsync() + { + try + { + await serverManager.StopAsync(CancellationToken.None); + if (databaseRuntime?.IsManaged == true) + await databaseRuntime.StopAsync(CancellationToken.None); + } + catch (Exception stopException) + { + log.Write("Cleanup after failed launch also failed: " + stopException.Message); + } + } + + public async ValueTask DisposeAsync() + { + await ShutdownAsync(CancellationToken.None); + if (databaseRuntime is not null) + await databaseRuntime.DisposeAsync(); + runCancellation?.Dispose(); + } +} diff --git a/Source/ACE.SinglePlayer/Orchestration/LauncherStateMachine.cs b/Source/ACE.SinglePlayer/Orchestration/LauncherStateMachine.cs new file mode 100644 index 0000000000..d3747e49a0 --- /dev/null +++ b/Source/ACE.SinglePlayer/Orchestration/LauncherStateMachine.cs @@ -0,0 +1,17 @@ +using ACE.SinglePlayer.Models; + +namespace ACE.SinglePlayer.Orchestration; + +public sealed class LauncherStateMachine +{ + public LauncherState State { get; private set; } = LauncherState.NotConfigured; + public string Message { get; private set; } = "Complete setup to begin."; + public event Action? Changed; + + public void Set(LauncherState state, string message) + { + State = state; + Message = message; + Changed?.Invoke(new LauncherStateChanged(state, message)); + } +} diff --git a/Source/ACE.SinglePlayer/Orchestration/PlayOperationGate.cs b/Source/ACE.SinglePlayer/Orchestration/PlayOperationGate.cs new file mode 100644 index 0000000000..61db5fc0ac --- /dev/null +++ b/Source/ACE.SinglePlayer/Orchestration/PlayOperationGate.cs @@ -0,0 +1,12 @@ +namespace ACE.SinglePlayer.Orchestration; + +public sealed class PlayOperationGate +{ + private int active; + + public bool IsActive => Volatile.Read(ref active) != 0; + + public bool TryEnter() => Interlocked.CompareExchange(ref active, 1, 0) == 0; + + public void Exit() => Volatile.Write(ref active, 0); +} diff --git a/Source/ACE.SinglePlayer/Processes/AceServerProcessManager.cs b/Source/ACE.SinglePlayer/Processes/AceServerProcessManager.cs new file mode 100644 index 0000000000..9a1c1e0b5a --- /dev/null +++ b/Source/ACE.SinglePlayer/Processes/AceServerProcessManager.cs @@ -0,0 +1,173 @@ +using System.Diagnostics; + +using ACE.SinglePlayer.Infrastructure; +using ACE.SinglePlayer.Models; + +namespace ACE.SinglePlayer.Processes; + +public sealed class AceServerProcessManager : IDisposable +{ + private readonly LauncherLog log; + private Process? process; + private ProcessOwnershipRecord? ownership; + private bool stopping; + private bool standardInputAvailable; + private string? ownershipPath; + + public AceServerProcessManager(LauncherLog log) + { + this.log = log; + } + + public bool IsRunning => process is { HasExited: false }; + public Process? Process => process; + public event Action? UnexpectedExit; + + public async Task TryAttachLauncherOwnedAsync(LauncherSettings settings, CancellationToken cancellationToken) + { + if (IsRunning) + return process; + + var record = await ProcessOwnershipRecord.LoadAsync(settings.OwnershipPath, cancellationToken); + if (record is null) + return null; + try + { + var candidate = System.Diagnostics.Process.GetProcessById(record.ProcessId); + if (candidate.HasExited || !record.Matches(candidate.Id, candidate.StartTime.ToUniversalTime(), settings.ServerExePath)) + { + candidate.Dispose(); + return null; + } + + process = candidate; + ownership = record; + ownershipPath = settings.OwnershipPath; + standardInputAvailable = false; + process.EnableRaisingEvents = true; + process.Exited += OnExited; + log.Write($"Attached to launcher-owned ACE.Server process {process.Id}."); + return process; + } + catch (ArgumentException) + { + return null; + } + catch (InvalidOperationException) + { + return null; + } + } + + public async Task StartAsync(LauncherSettings settings, CancellationToken cancellationToken) + { + if (IsRunning) + return process!; + + Directory.CreateDirectory(settings.RuntimeDirectory); + if (File.Exists(settings.ReadyFilePath)) + File.Delete(settings.ReadyFilePath); + + var startInfo = new ProcessStartInfo + { + FileName = settings.ServerExePath, + WorkingDirectory = Path.GetDirectoryName(settings.ServerExePath)!, + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + RedirectStandardInput = true, + CreateNoWindow = true + }; + startInfo.ArgumentList.Add("--config"); + startInfo.ArgumentList.Add(settings.ConfigPath); + startInfo.ArgumentList.Add("--ready-file"); + startInfo.ArgumentList.Add(settings.ReadyFilePath); + + process = new Process { StartInfo = startInfo, EnableRaisingEvents = true }; + process.OutputDataReceived += (_, args) => { if (args.Data is not null) log.Write("[ACE.Server] " + args.Data); }; + process.ErrorDataReceived += (_, args) => { if (args.Data is not null) log.Write("[ACE.Server error] " + args.Data); }; + process.Exited += OnExited; + if (!process.Start()) + throw new InvalidOperationException("Windows did not start ACE.Server."); + + standardInputAvailable = true; + ownershipPath = settings.OwnershipPath; + ownership = ProcessOwnershipRecord.FromProcess(process, settings.ServerExePath); + await ownership.SaveAsync(ownershipPath, cancellationToken); + process.BeginOutputReadLine(); + process.BeginErrorReadLine(); + log.Write($"Started launcher-owned ACE.Server process {process.Id}."); + return process; + } + + public async Task StopAsync(CancellationToken cancellationToken) + { + if (process is null || process.HasExited) + { + DeleteOwnershipRecord(); + return; + } + + stopping = true; + try + { + if (standardInputAvailable) + { + log.Write("Requesting graceful ACE.Server shutdown with its stop-now console command."); + await process.StandardInput.WriteLineAsync("stop-now"); + await process.StandardInput.FlushAsync(cancellationToken); + } + + try + { + await process.WaitForExitAsync(cancellationToken).WaitAsync(TimeSpan.FromSeconds(20), cancellationToken); + } + catch (TimeoutException) + { + if (IsStillOwnedProcess()) + { + log.Write("ACE.Server did not stop within 20 seconds; forcing only the verified launcher-owned process to exit."); + process.Kill(entireProcessTree: true); + await process.WaitForExitAsync(cancellationToken); + } + } + } + finally + { + DeleteOwnershipRecord(); + stopping = false; + } + } + + private bool IsStillOwnedProcess() + { + return process is { HasExited: false } && ownership is not null && + ownership.Matches(process.Id, process.StartTime.ToUniversalTime(), ownership.ExecutablePath); + } + + private void OnExited(object? sender, EventArgs e) + { + if (sender is not Process exited) + return; + log.Write($"ACE.Server process {exited.Id} exited with code {exited.ExitCode}."); + DeleteOwnershipRecord(); + if (!stopping) + UnexpectedExit?.Invoke(exited.ExitCode); + } + + private void DeleteOwnershipRecord() + { + if (ownershipPath is null || !File.Exists(ownershipPath)) + return; + try + { + File.Delete(ownershipPath); + } + catch (IOException) + { + // A later startup will validate the PID and start time before trusting the record. + } + } + + public void Dispose() => process?.Dispose(); +} diff --git a/Source/ACE.SinglePlayer/Processes/ClientProcessManager.cs b/Source/ACE.SinglePlayer/Processes/ClientProcessManager.cs new file mode 100644 index 0000000000..c1389250a6 --- /dev/null +++ b/Source/ACE.SinglePlayer/Processes/ClientProcessManager.cs @@ -0,0 +1,81 @@ +using System.Diagnostics; + +using ACE.SinglePlayer.ClientLaunch; +using ACE.SinglePlayer.Infrastructure; +using ACE.SinglePlayer.Models; + +namespace ACE.SinglePlayer.Processes; + +public sealed class ClientProcessManager : IDisposable +{ + private readonly IReadOnlyDictionary providers; + private readonly LauncherLog log; + private Process? process; + private bool stopping; + + public ClientProcessManager(IEnumerable providers, LauncherLog log) + { + this.providers = providers.ToDictionary(provider => provider.Mode); + this.log = log; + } + + public bool IsRunning => process is { HasExited: false }; + public event Action? ClientExited; + + public async Task StartAsync(LauncherSettings settings, string accountPassword, CancellationToken cancellationToken) + { + if (IsRunning) + throw new InvalidOperationException("The game is already running."); + if (!providers.TryGetValue(settings.ClientLaunchMode, out var provider)) + throw new NotSupportedException($"Client launch mode '{settings.ClientLaunchMode}' is not implemented."); + if (!provider.IsAvailable(out var reason)) + throw new InvalidOperationException(reason); + + var workingDirectory = Path.GetDirectoryName(Path.GetFullPath(settings.ClientExePath))!; + log.Write($"Launching client executable '{Path.GetFullPath(settings.ClientExePath)}'."); + log.Write($"Client working/DAT directory: '{workingDirectory}'. ACE.Server DAT directory: '{Path.GetFullPath(settings.DatFilesDirectory)}'."); + process = await provider.LaunchAsync(new ClientLaunchRequest(settings, accountPassword), cancellationToken); + process.EnableRaisingEvents = true; + process.Exited += OnExited; + log.Write($"Started acclient.exe process {process.Id} using {provider.DisplayName} mode."); + return process; + } + + public async Task StopAsync(CancellationToken cancellationToken) + { + if (process is null || process.HasExited) + return; + + stopping = true; + try + { + process.CloseMainWindow(); + try + { + await process.WaitForExitAsync(cancellationToken).WaitAsync(TimeSpan.FromSeconds(10), cancellationToken); + } + catch (TimeoutException) + { + process.Kill(entireProcessTree: true); + await process.WaitForExitAsync(cancellationToken); + } + } + finally + { + stopping = false; + } + } + + private void OnExited(object? sender, EventArgs e) + { + if (sender is not Process exited) + return; + log.Write($"acclient.exe process {exited.Id} exited with code {exited.ExitCode}."); + if (exited.ExitCode != 0) + log.Write("Client startup failed. Verify that the complete AC client, including all client DAT files, is beside acclient.exe in a writable folder and is not blocked by Windows Security."); + if (!stopping) + ClientExited?.Invoke(exited.ExitCode); + } + + public void Dispose() => process?.Dispose(); +} diff --git a/Source/ACE.SinglePlayer/Processes/ProcessOwnershipRecord.cs b/Source/ACE.SinglePlayer/Processes/ProcessOwnershipRecord.cs new file mode 100644 index 0000000000..1e70127c97 --- /dev/null +++ b/Source/ACE.SinglePlayer/Processes/ProcessOwnershipRecord.cs @@ -0,0 +1,50 @@ +using System.Diagnostics; +using System.Text.Json; + +namespace ACE.SinglePlayer.Processes; + +public sealed class ProcessOwnershipRecord +{ + public int ProcessId { get; set; } + public DateTime StartTimeUtc { get; set; } + public string ExecutablePath { get; set; } = string.Empty; + + public bool Matches(int processId, DateTime startTimeUtc, string executablePath) + { + return ProcessId == processId && + Math.Abs((StartTimeUtc - startTimeUtc).TotalSeconds) < 1 && + string.Equals(Path.GetFullPath(ExecutablePath), Path.GetFullPath(executablePath), StringComparison.OrdinalIgnoreCase); + } + + public static ProcessOwnershipRecord FromProcess(Process process, string executablePath) => new() + { + ProcessId = process.Id, + StartTimeUtc = process.StartTime.ToUniversalTime(), + ExecutablePath = Path.GetFullPath(executablePath) + }; + + public static async Task LoadAsync(string path, CancellationToken cancellationToken) + { + if (!File.Exists(path)) + return null; + await using var stream = File.OpenRead(path); + return await JsonSerializer.DeserializeAsync(stream, cancellationToken: cancellationToken); + } + + public async Task SaveAsync(string path, CancellationToken cancellationToken) + { + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + var temporaryPath = path + "." + Guid.NewGuid().ToString("N") + ".tmp"; + try + { + await using (var stream = new FileStream(temporaryPath, FileMode.CreateNew, FileAccess.Write, FileShare.None)) + await JsonSerializer.SerializeAsync(stream, this, cancellationToken: cancellationToken); + File.Move(temporaryPath, path, true); + } + finally + { + if (File.Exists(temporaryPath)) + File.Delete(temporaryPath); + } + } +} diff --git a/Source/ACE.SinglePlayer/Program.cs b/Source/ACE.SinglePlayer/Program.cs new file mode 100644 index 0000000000..dc4e47044d --- /dev/null +++ b/Source/ACE.SinglePlayer/Program.cs @@ -0,0 +1,133 @@ +using ACE.SinglePlayer.ClientLaunch; +using ACE.SinglePlayer.Configuration; +using ACE.SinglePlayer.Database; +using ACE.SinglePlayer.Infrastructure; +using ACE.SinglePlayer.Models; +using ACE.SinglePlayer.Networking; +using ACE.SinglePlayer.Orchestration; +using ACE.SinglePlayer.Processes; +using ACE.SinglePlayer.UI; + +namespace ACE.SinglePlayer; + +internal static class Program +{ + [STAThread] + private static void Main() + { + ApplicationConfiguration.Initialize(); + try + { + RunApplication(); + } + catch (Exception ex) + { + WriteStartupFailure(ex); + MessageBox.Show( + "ACE Single Player could not start. Details were written to the launcher log.\r\n\r\n" + ex.Message, + "ACE Single Player startup error", MessageBoxButtons.OK, MessageBoxIcon.Error); + } + } + + private static void RunApplication() + { + using var instance = new SingleInstance(@"Local\ACE.SinglePlayer"); + if (!instance.IsPrimary) + { + MessageBox.Show("ACE Single Player is already open.", "ACE Single Player", MessageBoxButtons.OK, MessageBoxIcon.Information); + return; + } + + var localRoot = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "ACESinglePlayer"); + using var log = new LauncherLog(Path.Combine(localRoot, "Logs")); + var store = new SettingsStore(); + var protector = new SecretProtector(); + var connectionFactory = new DatabaseConnectionFactory(protector); + var runtimeFactory = new DatabaseRuntimeFactory(connectionFactory, log); + var bootstrapper = new DatabaseBootstrapper(connectionFactory, log); + + LauncherSettings settings; + try + { + settings = store.LoadAsync().GetAwaiter().GetResult() ?? LauncherSettings.CreateDefaults(AppContext.BaseDirectory); + } + catch (Exception ex) + { + log.Write("Settings could not be loaded: " + ex); + MessageBox.Show("The saved settings could not be read. Setup will open so they can be replaced.\r\n\r\n" + ex.Message, + "Settings error", MessageBoxButtons.OK, MessageBoxIcon.Warning); + settings = LauncherSettings.CreateDefaults(AppContext.BaseDirectory); + } + + var portableBundle = BundledDistribution.IsComplete(AppContext.BaseDirectory); + var settingsChanged = SettingsPathRepairer.Repair(settings, AppContext.BaseDirectory); + if (portableBundle && settings.DatabaseMode != DatabaseMode.External) + settingsChanged |= AutomaticSetupConfigurator.Configure(settings, AppContext.BaseDirectory, protector); + else if (settings.DatabaseMode != DatabaseMode.External) + settings.ManagedDatabaseExePath = MariaDbInstallationLocator.FindServerExecutable(settings.ManagedDatabaseExePath) + ?? settings.ManagedDatabaseExePath; + + if (settingsChanged) + { + log.Write("Automatically configured the bundled server, world, database, or client paths for this installation."); + if (store.Exists) + store.SaveAsync(settings).GetAwaiter().GetResult(); + } + + if (portableBundle && settings.DatabaseMode != DatabaseMode.External && !SetupValidator.ValidateClient(settings).IsValid) + { + using var quickSetup = new QuickSetupForm(settings); + if (quickSetup.ShowDialog() != DialogResult.OK) + return; + AutomaticSetupConfigurator.Configure(settings, AppContext.BaseDirectory, protector); + store.SaveAsync(settings).GetAwaiter().GetResult(); + } + else if ((!portableBundle || settings.DatabaseMode == DatabaseMode.External) && + (!store.Exists || !SetupValidator.Validate(settings).IsValid)) + { + using var wizard = new SetupWizardForm(settings, store, protector, runtimeFactory, bootstrapper); + if (wizard.ShowDialog() != DialogResult.OK) + return; + settings = wizard.SavedSettings; + } + + var validation = SetupValidator.Validate(settings); + if (!validation.IsValid) + { + MessageBox.Show(validation.Message, "ACE Single Player needs attention", MessageBoxButtons.OK, MessageBoxIcon.Warning); + return; + } + + if (!store.Exists) + store.SaveAsync(settings).GetAwaiter().GetResult(); + + using var serverManager = new AceServerProcessManager(log); + using var clientManager = new ClientProcessManager(new IClientLaunchProvider[] + { + new DirectClientLaunchProvider(), + new DecalClientLaunchProvider(log) + }, log); + var controller = new LauncherController(settings, new AceConfigurationWriter(protector), runtimeFactory, + bootstrapper, store, serverManager, clientManager, new ReadyFileMonitor(new UdpPortProbe()), protector, log); + using var main = new MainForm(controller, store, protector, runtimeFactory, bootstrapper, log); + Application.Run(main); + controller.DisposeAsync().AsTask().GetAwaiter().GetResult(); + } + + private static void WriteStartupFailure(Exception exception) + { + try + { + var logDirectory = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "ACESinglePlayer", "Logs"); + Directory.CreateDirectory(logDirectory); + File.AppendAllText(Path.Combine(logDirectory, "ACE.SinglePlayer.log"), + $"{DateTimeOffset.Now:yyyy-MM-dd HH:mm:ss zzz} Startup failed: {exception}{Environment.NewLine}"); + } + catch + { + // Preserve the original startup error even if logging is unavailable. + } + } +} diff --git a/Source/ACE.SinglePlayer/Properties/AssemblyInfo.cs b/Source/ACE.SinglePlayer/Properties/AssemblyInfo.cs new file mode 100644 index 0000000000..ab596f2fb5 --- /dev/null +++ b/Source/ACE.SinglePlayer/Properties/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("ACE.SinglePlayer.Tests")] diff --git a/Source/ACE.SinglePlayer/UI/CustomWeeniesForm.cs b/Source/ACE.SinglePlayer/UI/CustomWeeniesForm.cs new file mode 100644 index 0000000000..7f9b072184 --- /dev/null +++ b/Source/ACE.SinglePlayer/UI/CustomWeeniesForm.cs @@ -0,0 +1,337 @@ +using System.ComponentModel; +using System.Diagnostics; + +using ACE.SinglePlayer.CustomContent; +using ACE.SinglePlayer.Database; +using ACE.SinglePlayer.Infrastructure; +using ACE.SinglePlayer.Models; + +namespace ACE.SinglePlayer.UI; + +public sealed class CustomWeeniesForm : Form +{ + private const string AceForgeReleaseUrl = "https://github.com/shemtar-90/AceForge/releases/tag/v0.3.36"; + private static readonly Color Night = Color.FromArgb(14, 25, 32); + private static readonly Color DeepSlate = Color.FromArgb(26, 43, 52); + private static readonly Color PaleGold = Color.FromArgb(241, 220, 170); + private static readonly Color Mist = Color.FromArgb(222, 231, 226); + + private readonly LauncherSettings settings; + private readonly Func isServerRunning; + private readonly CustomWeenieSqlInspector inspector = new(); + private readonly CustomWeenieImportService importer; + private readonly DataGridView grid = new() + { + Dock = DockStyle.Fill, + AutoGenerateColumns = false, + ReadOnly = true, + AllowUserToAddRows = false, + AllowUserToDeleteRows = false, + AllowUserToResizeRows = false, + MultiSelect = false, + RowHeadersVisible = false, + SelectionMode = DataGridViewSelectionMode.FullRowSelect, + BackgroundColor = Night, + BorderStyle = BorderStyle.None + }; + private readonly TextBox issues = new() + { + Dock = DockStyle.Fill, + Multiline = true, + ReadOnly = true, + ScrollBars = ScrollBars.Vertical, + BorderStyle = BorderStyle.None + }; + private readonly Label status = new() + { + Dock = DockStyle.Fill, + TextAlign = ContentAlignment.MiddleLeft, + AutoEllipsis = true + }; + private readonly Button chooseFolder = new() { Text = "Choose AceForge Folder...", AutoSize = true }; + private readonly Button chooseFiles = new() { Text = "Choose SQL Files...", AutoSize = true }; + private readonly Button openAceForge = new() { Text = "Open AceForge v0.3.36", AutoSize = true }; + private readonly Button openBackups = new() { Text = "Open Backups", AutoSize = true }; + private readonly Button import = new() { Text = "IMPORT VALID WEENIES", AutoSize = true, Enabled = false }; + private IReadOnlyList selected = Array.Empty(); + + public CustomWeeniesForm(LauncherSettings settings, Func isServerRunning, + DatabaseRuntimeFactory runtimeFactory, DatabaseConnectionFactory connectionFactory, LauncherLog log) + { + this.settings = settings; + this.isServerRunning = isServerRunning; + importer = new CustomWeenieImportService(runtimeFactory, connectionFactory, log); + + Text = "ACE Single Player - Custom Weenies"; + StartPosition = FormStartPosition.CenterParent; + MinimumSize = new Size(940, 680); + Size = new Size(1120, 780); + BackColor = Night; + ForeColor = Mist; + Font = new Font(SystemFonts.MessageBoxFont!.FontFamily, 9.5f); + + ConfigureGrid(); + issues.BackColor = Color.FromArgb(20, 34, 41); + issues.ForeColor = Mist; + status.ForeColor = PaleGold; + status.Font = new Font(Font, FontStyle.Bold); + foreach (var button in new[] { chooseFolder, chooseFiles, openAceForge, openBackups, import }) + StyleButton(button); + import.BackColor = Color.FromArgb(133, 76, 38); + + var root = new TableLayoutPanel + { + Dock = DockStyle.Fill, + RowCount = 5, + ColumnCount = 1, + Padding = new Padding(16), + BackColor = Night + }; + root.RowStyles.Add(new RowStyle(SizeType.Absolute, 72)); + root.RowStyles.Add(new RowStyle(SizeType.Absolute, 92)); + root.RowStyles.Add(new RowStyle(SizeType.Absolute, 58)); + root.RowStyles.Add(new RowStyle(SizeType.Percent, 100)); + root.RowStyles.Add(new RowStyle(SizeType.Absolute, 56)); + + var heading = new Label + { + Dock = DockStyle.Fill, + Font = new Font("Georgia", 19, FontStyle.Bold), + ForeColor = PaleGold, + Text = "CUSTOM WEENIES\r\nImport custom ACE World objects without hand-editing the database." + }; + var explanation = new Label + { + Dock = DockStyle.Fill, + ForeColor = Mist, + Padding = new Padding(0, 8, 0, 4), + Text = "Select the folder where AceForge saved its .sql files, or choose individual files. " + + "The launcher validates every statement, previews each WCID, checks for collisions, and creates a complete ace_world backup before importing. " + + "Quest, recipe, event, and treasure scripts are reported but are not executed by this section." + }; + var actions = new FlowLayoutPanel + { + Dock = DockStyle.Fill, + FlowDirection = FlowDirection.LeftToRight, + WrapContents = true, + Padding = new Padding(0, 7, 0, 0), + BackColor = Color.Transparent + }; + actions.Controls.AddRange(new Control[] { chooseFolder, chooseFiles, openAceForge, openBackups }); + + var content = new TableLayoutPanel + { + Dock = DockStyle.Fill, + RowCount = 2, + ColumnCount = 1, + BackColor = DeepSlate, + Padding = new Padding(10) + }; + content.RowStyles.Add(new RowStyle(SizeType.Percent, 70)); + content.RowStyles.Add(new RowStyle(SizeType.Percent, 30)); + content.Controls.Add(grid, 0, 0); + var issueGroup = new GroupBox + { + Dock = DockStyle.Fill, + Text = "Validation notes and skipped files", + ForeColor = PaleGold, + Padding = new Padding(8) + }; + issueGroup.Controls.Add(issues); + content.Controls.Add(issueGroup, 0, 1); + + var footer = new TableLayoutPanel + { + Dock = DockStyle.Fill, + ColumnCount = 2, + RowCount = 1, + BackColor = Color.Transparent, + Padding = new Padding(0, 8, 0, 0) + }; + footer.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100)); + footer.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize)); + footer.Controls.Add(status, 0, 0); + footer.Controls.Add(import, 1, 0); + + root.Controls.Add(heading, 0, 0); + root.Controls.Add(explanation, 0, 1); + root.Controls.Add(actions, 0, 2); + root.Controls.Add(content, 0, 3); + root.Controls.Add(footer, 0, 4); + Controls.Add(root); + + chooseFolder.Click += (_, _) => ChooseFolder(); + chooseFiles.Click += (_, _) => ChooseFiles(); + openAceForge.Click += (_, _) => Open(AceForgeReleaseUrl); + openBackups.Click += (_, _) => OpenBackupFolder(); + import.Click += async (_, _) => await ImportAsync(); + status.Text = "Choose AceForge weenie SQL files to begin."; + issues.Text = "No files selected."; + } + + private void ChooseFolder() + { + using var dialog = new FolderBrowserDialog + { + Description = "Choose the AceForge output folder containing custom weenie SQL files", + UseDescriptionForTitle = true, + ShowNewFolderButton = false + }; + if (dialog.ShowDialog(this) != DialogResult.OK) + return; + try + { + LoadFiles(Directory.EnumerateFiles(dialog.SelectedPath, "*.sql", SearchOption.AllDirectories)); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + MessageBox.Show(this, ex.Message, "Could not read the folder", MessageBoxButtons.OK, MessageBoxIcon.Error); + } + } + + private void ChooseFiles() + { + using var dialog = new OpenFileDialog + { + Title = "Choose AceForge custom weenie SQL files", + Filter = "SQL files (*.sql)|*.sql", + Multiselect = true, + CheckFileExists = true + }; + if (dialog.ShowDialog(this) == DialogResult.OK) + LoadFiles(dialog.FileNames); + } + + private void LoadFiles(IEnumerable filePaths) + { + var result = inspector.InspectFiles(filePaths); + selected = result.Definitions; + grid.DataSource = new BindingList(selected.ToList()); + issues.Text = result.Issues.Count == 0 + ? "All selected files passed the Custom Weenie safety checks." + : string.Join(Environment.NewLine + Environment.NewLine, result.Issues.Select(issue => + $"{(string.IsNullOrWhiteSpace(issue.FilePath) ? "Selection" : Path.GetFileName(issue.FilePath))}: {issue.Message}")); + import.Enabled = selected.Count > 0; + status.Text = selected.Count == 0 + ? $"No importable weenies found. {result.Issues.Count} file(s) or selection issue(s) reported." + : $"Ready: {selected.Count} valid weenie{(selected.Count == 1 ? string.Empty : "s")}" + + (result.Issues.Count == 0 ? "." : $"; {result.Issues.Count} file(s) skipped."); + } + + private async Task ImportAsync() + { + if (settings.DatabaseMode != DatabaseMode.Private) + { + MessageBox.Show(this, + "Safe automatic imports currently require Private Database mode. The launcher will not modify an external MariaDB installation automatically.", + "Private Database required", MessageBoxButtons.OK, MessageBoxIcon.Information); + return; + } + if (isServerRunning()) + { + MessageBox.Show(this, "Stop the game and local server before importing custom weenies.", + "Server is running", MessageBoxButtons.OK, MessageBoxIcon.Information); + return; + } + if (selected.Count == 0) + return; + + var answer = MessageBox.Show(this, + $"Import {selected.Count} custom weenie{(selected.Count == 1 ? string.Empty : "s")} into your saved world?\r\n\r\n" + + "A complete ace_world backup will be created first. Imported world data does not have a one-click uninstaller.", + "Confirm Custom Weenie import", MessageBoxButtons.YesNo, MessageBoxIcon.Warning); + if (answer != DialogResult.Yes) + return; + + SetBusy(true); + try + { + var progress = new Progress(message => status.Text = message); + var result = await importer.ImportAsync(settings, selected, ConfirmReplacement, progress, CancellationToken.None); + if (!result.Imported) + { + status.Text = "Import canceled. No database changes were made."; + return; + } + + status.Text = $"Imported {selected.Count} custom weenie{(selected.Count == 1 ? string.Empty : "s")}. Start the game to use them."; + MessageBox.Show(this, + $"The custom weenies were imported successfully.\r\n\r\nSafety backup:\r\n{result.BackupPath}", + "Import complete", MessageBoxButtons.OK, MessageBoxIcon.Information); + } + catch (Exception ex) + { + status.Text = "Import failed. The launcher did not complete the database transaction."; + MessageBox.Show(this, ex.Message, "Custom Weenie import failed", MessageBoxButtons.OK, MessageBoxIcon.Error); + } + finally + { + SetBusy(false); + } + } + + private bool ConfirmReplacement(IReadOnlyList existing) + { + var list = string.Join("\r\n", existing.Take(12).Select(item => $"WCID {item.ClassId}: {item.ClassName}")); + if (existing.Count > 12) + list += $"\r\n...and {existing.Count - 12} more"; + return MessageBox.Show(this, + $"{existing.Count} selected WCID{(existing.Count == 1 ? string.Empty : "s")} already exist in this world:\r\n\r\n" + + list + "\r\n\r\nContinuing will replace those records and may affect saved characters or objects. Replace them?", + "Existing WCIDs found", MessageBoxButtons.YesNo, MessageBoxIcon.Warning) == DialogResult.Yes; + } + + private void SetBusy(bool busy) + { + chooseFolder.Enabled = !busy; + chooseFiles.Enabled = !busy; + import.Enabled = !busy && selected.Count > 0; + UseWaitCursor = busy; + } + + private void OpenBackupFolder() + { + var path = MariaDbWorldBackupService.GetBackupDirectory(settings); + Directory.CreateDirectory(path); + Open(path); + } + + private void ConfigureGrid() + { + grid.Columns.Add(new DataGridViewTextBoxColumn + { DataPropertyName = nameof(CustomWeenieDefinition.ClassId), HeaderText = "WCID", Width = 95 }); + grid.Columns.Add(new DataGridViewTextBoxColumn + { DataPropertyName = nameof(CustomWeenieDefinition.ClassName), HeaderText = "Name", Width = 230 }); + grid.Columns.Add(new DataGridViewTextBoxColumn + { DataPropertyName = nameof(CustomWeenieDefinition.TypeName), HeaderText = "Type", Width = 135 }); + grid.Columns.Add(new DataGridViewTextBoxColumn + { DataPropertyName = nameof(CustomWeenieDefinition.FileName), HeaderText = "AceForge SQL file", AutoSizeMode = DataGridViewAutoSizeColumnMode.Fill }); + grid.EnableHeadersVisualStyles = false; + grid.ColumnHeadersDefaultCellStyle.BackColor = DeepSlate; + grid.ColumnHeadersDefaultCellStyle.ForeColor = PaleGold; + grid.ColumnHeadersDefaultCellStyle.Font = new Font(Font, FontStyle.Bold); + grid.ColumnHeadersHeight = 38; + grid.DefaultCellStyle.BackColor = Color.FromArgb(20, 34, 41); + grid.DefaultCellStyle.ForeColor = Mist; + grid.DefaultCellStyle.SelectionBackColor = Color.FromArgb(70, 80, 67); + grid.DefaultCellStyle.SelectionForeColor = Color.White; + grid.AlternatingRowsDefaultCellStyle.BackColor = Color.FromArgb(24, 40, 48); + grid.RowTemplate.Height = 34; + } + + private static void StyleButton(Button button) + { + button.FlatStyle = FlatStyle.Flat; + button.FlatAppearance.BorderColor = Color.FromArgb(126, 146, 145); + button.FlatAppearance.BorderSize = 1; + button.FlatAppearance.MouseOverBackColor = Color.FromArgb(55, 77, 84); + button.FlatAppearance.MouseDownBackColor = Color.FromArgb(33, 51, 59); + button.BackColor = Night; + button.ForeColor = PaleGold; + button.Font = new Font(button.Font, FontStyle.Bold); + button.UseVisualStyleBackColor = false; + } + + private static void Open(string path) => + Process.Start(new ProcessStartInfo { FileName = path, UseShellExecute = true }); +} diff --git a/Source/ACE.SinglePlayer/UI/MainForm.cs b/Source/ACE.SinglePlayer/UI/MainForm.cs new file mode 100644 index 0000000000..6c2c6a37d0 --- /dev/null +++ b/Source/ACE.SinglePlayer/UI/MainForm.cs @@ -0,0 +1,332 @@ +using System.Diagnostics; + +using ACE.SinglePlayer.ClientLaunch; +using ACE.SinglePlayer.Database; +using ACE.SinglePlayer.Infrastructure; +using ACE.SinglePlayer.Models; +using ACE.SinglePlayer.Orchestration; + +namespace ACE.SinglePlayer.UI; + +public sealed class MainForm : Form +{ + private static readonly Color Night = Color.FromArgb(14, 25, 32); + private static readonly Color DeepSlate = Color.FromArgb(26, 43, 52); + private static readonly Color WeatheredGold = Color.FromArgb(205, 160, 82); + private static readonly Color PaleGold = Color.FromArgb(241, 220, 170); + private static readonly Color Mist = Color.FromArgb(222, 231, 226); + private readonly LauncherController controller; + private readonly SettingsStore settingsStore; + private readonly ISecretProtector secretProtector; + private readonly DatabaseRuntimeFactory databaseRuntimeFactory; + private readonly DatabaseBootstrapper databaseBootstrapper; + private readonly LauncherLog log; + private readonly Label title = new() { Text = "A C E S I N G L E P L A Y E R", AutoSize = false, Dock = DockStyle.Fill, TextAlign = ContentAlignment.MiddleCenter }; + private readonly Label state = new() { AutoSize = false, Dock = DockStyle.Fill, TextAlign = ContentAlignment.MiddleCenter }; + private readonly Button play = new() { Text = "PLAY", Width = 320, Height = 96 }; + private readonly CheckBox useDecal = new() + { + AutoSize = true, + Margin = new Padding(3, 5, 3, 5), + AccessibleName = "Use Decal" + }; + private readonly Label useDecalLabel = new() + { + Text = "Use Decal", + AutoSize = true, + Margin = new Padding(2, 4, 4, 4), + Cursor = Cursors.Hand + }; + private readonly Button stop = new() { Text = "Stop", Width = 116, Height = 36, Margin = new Padding(4, 0, 4, 4) }; + private readonly TextBox diagnostics = new() { Dock = DockStyle.Fill, Multiline = true, ReadOnly = true, ScrollBars = ScrollBars.Vertical, Font = new Font(FontFamily.GenericMonospace, 9), BorderStyle = BorderStyle.None }; + private readonly Panel diagnosticPanel = new() { Dock = DockStyle.Fill, Visible = false, Padding = new Padding(12), BorderStyle = BorderStyle.FixedSingle }; + private readonly ToolTip toolTip = new(); + private bool closingAllowed; + private bool updatingDecalPreference; + + public MainForm(LauncherController controller, SettingsStore settingsStore, ISecretProtector secretProtector, + DatabaseRuntimeFactory databaseRuntimeFactory, DatabaseBootstrapper databaseBootstrapper, LauncherLog log) + { + this.controller = controller; + this.settingsStore = settingsStore; + this.secretProtector = secretProtector; + this.databaseRuntimeFactory = databaseRuntimeFactory; + this.databaseBootstrapper = databaseBootstrapper; + this.log = log; + + Text = "ACE Single Player - A Private World in Dereth"; + StartPosition = FormStartPosition.CenterScreen; + MinimumSize = new Size(780, 620); + Size = new Size(900, 710); + BackColor = Night; + ForeColor = Mist; + DoubleBuffered = true; + BackgroundImage = LoadLauncherBackground(); + BackgroundImageLayout = ImageLayout.Zoom; + + title.Font = new Font("Georgia", 21, FontStyle.Bold); + title.ForeColor = PaleGold; + title.BackColor = Color.FromArgb(188, Night); + state.Font = new Font("Georgia", 12, FontStyle.Bold); + state.ForeColor = PaleGold; + state.BackColor = Color.FromArgb(188, Night); + + play.Font = new Font("Georgia", 26, FontStyle.Bold); + play.ForeColor = PaleGold; + play.BackColor = Color.FromArgb(186, 103, 45); + play.FlatStyle = FlatStyle.Flat; + play.FlatAppearance.BorderColor = WeatheredGold; + play.FlatAppearance.BorderSize = 2; + play.FlatAppearance.MouseOverBackColor = Color.FromArgb(205, 126, 55); + play.FlatAppearance.MouseDownBackColor = Color.FromArgb(151, 80, 37); + + useDecal.BackColor = Color.Transparent; + useDecalLabel.ForeColor = PaleGold; + useDecalLabel.BackColor = Color.Transparent; + useDecalLabel.Font = new Font(SystemFonts.MessageBoxFont!.FontFamily, 10, FontStyle.Bold); + StyleSecondaryButton(stop); + + diagnostics.BackColor = Color.FromArgb(9, 17, 22); + diagnostics.ForeColor = Color.FromArgb(190, 214, 204); + diagnosticPanel.BackColor = Color.FromArgb(225, Night); + + var main = new TableLayoutPanel { Dock = DockStyle.Fill, RowCount = 5, ColumnCount = 1, BackColor = Color.Transparent, Padding = new Padding(22, 18, 22, 18) }; + main.RowStyles.Add(new RowStyle(SizeType.Absolute, 56)); + main.RowStyles.Add(new RowStyle(SizeType.Absolute, 67)); + main.RowStyles.Add(new RowStyle(SizeType.Absolute, 150)); + main.RowStyles.Add(new RowStyle(SizeType.Absolute, 58)); + main.RowStyles.Add(new RowStyle(SizeType.Percent, 100)); + main.Controls.Add(title, 0, 0); + main.Controls.Add(state, 0, 1); + + var playHost = new TableLayoutPanel { Dock = DockStyle.Fill, ColumnCount = 3, RowCount = 1, BackColor = Color.Transparent }; + playHost.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 50)); + playHost.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize)); + playHost.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 50)); + var playPanel = new FlowLayoutPanel { AutoSize = true, FlowDirection = FlowDirection.LeftToRight, WrapContents = false, BackColor = Color.Transparent, Padding = new Padding(0, 20, 0, 0) }; + playPanel.Controls.Add(play); + var playOptions = new FlowLayoutPanel + { + AutoSize = true, + FlowDirection = FlowDirection.TopDown, + WrapContents = false, + BackColor = DeepSlate, + Padding = new Padding(10), + Margin = new Padding(12, 4, 0, 0) + }; + var decalOption = new FlowLayoutPanel + { + AutoSize = true, + FlowDirection = FlowDirection.LeftToRight, + WrapContents = false, + BackColor = Color.Transparent, + Margin = new Padding(3, 3, 3, 8) + }; + decalOption.Controls.Add(useDecal); + decalOption.Controls.Add(useDecalLabel); + playOptions.Controls.Add(decalOption); + playOptions.Controls.Add(stop); + playPanel.Controls.Add(playOptions); + playHost.Controls.Add(playPanel, 1, 0); + main.Controls.Add(playHost, 0, 2); + + var actions = new FlowLayoutPanel { Dock = DockStyle.Fill, FlowDirection = FlowDirection.LeftToRight, WrapContents = false, BackColor = Color.FromArgb(216, Night), Padding = new Padding(12, 9, 0, 0) }; + var settings = new Button { Text = "Settings", AutoSize = true }; + var mods = new Button { Text = "Server Mods", AutoSize = true }; + var customWeenies = new Button { Text = "Custom Weenies", AutoSize = true }; + var logs = new Button { Text = "Open Logs", AutoSize = true }; + var showDiagnostics = new CheckBox { Text = "Show diagnostics", AutoSize = true, Padding = new Padding(10, 7, 0, 0) }; + StyleSecondaryButton(settings); + StyleSecondaryButton(mods); + StyleSecondaryButton(customWeenies); + StyleSecondaryButton(logs); + showDiagnostics.ForeColor = Mist; + showDiagnostics.BackColor = Color.Transparent; + actions.Controls.AddRange(new Control[] { settings, mods, customWeenies, logs, showDiagnostics }); + main.Controls.Add(actions, 0, 3); + + diagnosticPanel.Controls.Add(diagnostics); + main.Controls.Add(diagnosticPanel, 0, 4); + Controls.Add(main); + + var decalAvailable = IsDecalAvailable(); + useDecal.Checked = controller.Settings.ClientLaunchMode == ClientLaunchMode.Decal; + useDecal.Enabled = decalAvailable; + useDecalLabel.Text = decalAvailable ? "Use Decal" : "Use Decal (not detected)"; + var decalToolTip = decalAvailable + ? "Launch the game with your installed Decal and ThwargLauncher. Uncheck for Vanilla." + : "Install both Decal and ThwargLauncher to enable this option."; + toolTip.SetToolTip(useDecal, decalToolTip); + toolTip.SetToolTip(useDecalLabel, decalToolTip); + + controller.State.Changed += UpdateState; + log.MessageWritten += AppendDiagnostic; + play.Click += async (_, _) => + { + play.Enabled = false; + var started = await controller.PlayAsync(); + play.Enabled = !controller.IsClientRunning; + if (started && controller.Settings.MinimizeLauncherAfterClientStarts) + WindowState = FormWindowState.Minimized; + }; + stop.Click += async (_, _) => await controller.StopAsync(); + useDecal.CheckedChanged += async (_, _) => await UpdateDecalPreferenceAsync(); + useDecalLabel.Click += (_, _) => + { + if (useDecal.Enabled) + useDecal.Checked = !useDecal.Checked; + }; + settings.Click += async (_, _) => await ShowSettingsAsync(); + mods.Click += (_, _) => new ModsForm(controller.Settings, () => controller.IsServerRunning).ShowDialog(this); + customWeenies.Click += (_, _) => new CustomWeeniesForm(controller.Settings, + () => controller.IsServerRunning, databaseRuntimeFactory, + new DatabaseConnectionFactory(secretProtector), log).ShowDialog(this); + logs.Click += (_, _) => Process.Start(new ProcessStartInfo { FileName = Path.GetDirectoryName(log.LogPath)!, UseShellExecute = true }); + showDiagnostics.CheckedChanged += (_, _) => diagnosticPanel.Visible = showDiagnostics.Checked; + FormClosing += OnFormClosing; + Disposed += (_, _) => + { + BackgroundImage?.Dispose(); + toolTip.Dispose(); + }; + + controller.State.Set(LauncherState.Ready, "Ready - click Play"); + } + + private async Task UpdateDecalPreferenceAsync() + { + if (updatingDecalPreference) + return; + + var requestedMode = useDecal.Checked ? ClientLaunchMode.Decal : ClientLaunchMode.Vanilla; + var previousMode = controller.Settings.ClientLaunchMode; + if (requestedMode == ClientLaunchMode.Decal && !IsDecalAvailable()) + { + SetDecalCheckbox(previousMode == ClientLaunchMode.Decal); + MessageBox.Show(this, "Use Decal requires working Decal and ThwargLauncher installations.", + "Decal unavailable", MessageBoxButtons.OK, MessageBoxIcon.Warning); + return; + } + + useDecal.Enabled = false; + try + { + controller.Settings.ClientLaunchMode = requestedMode; + await settingsStore.SaveAsync(controller.Settings); + controller.UpdateSettings(controller.Settings); + } + catch (Exception ex) + { + controller.Settings.ClientLaunchMode = previousMode; + SetDecalCheckbox(previousMode == ClientLaunchMode.Decal); + MessageBox.Show(this, "The Decal preference could not be saved.\r\n\r\n" + ex.Message, + "Settings error", MessageBoxButtons.OK, MessageBoxIcon.Error); + } + finally + { + useDecal.Enabled = IsDecalAvailable() && !controller.IsServerRunning && !controller.IsClientRunning; + } + } + + private void SetDecalCheckbox(bool value) + { + updatingDecalPreference = true; + try + { + useDecal.Checked = value; + } + finally + { + updatingDecalPreference = false; + } + } + + private static bool IsDecalAvailable() => + DecalDetector.Detect() is not null && ThwargDetector.Detect() is not null; + + private async Task ShowSettingsAsync() + { + if (controller.IsServerRunning) + { + MessageBox.Show(this, "Stop the game and local server before changing settings.", "Server is running", MessageBoxButtons.OK, MessageBoxIcon.Information); + return; + } + using var wizard = new SetupWizardForm(controller.Settings, settingsStore, secretProtector, databaseRuntimeFactory, databaseBootstrapper); + if (wizard.ShowDialog(this) == DialogResult.OK) + controller.UpdateSettings(wizard.SavedSettings); + await Task.CompletedTask; + } + + private void UpdateState(LauncherStateChanged change) + { + if (InvokeRequired) + { + BeginInvoke(() => UpdateState(change)); + return; + } + state.Text = $"{ToDisplayName(change.State)}\r\n{change.Message}"; + state.ForeColor = change.State switch + { + LauncherState.Error => Color.FromArgb(255, 153, 125), + LauncherState.GameRunning => Color.FromArgb(151, 222, 178), + LauncherState.Ready => PaleGold, + _ => Color.FromArgb(151, 201, 230) + }; + stop.Enabled = controller.IsServerRunning || controller.IsClientRunning; + play.Enabled = (change.State is LauncherState.Ready or LauncherState.Error or LauncherState.NotConfigured) && !controller.IsClientRunning; + useDecal.Enabled = IsDecalAvailable() && !controller.IsServerRunning && !controller.IsClientRunning; + if (change.State == LauncherState.Error) + diagnosticPanel.Visible = true; + } + + private static void StyleSecondaryButton(Button button) + { + button.FlatStyle = FlatStyle.Flat; + button.FlatAppearance.BorderColor = Color.FromArgb(126, 146, 145); + button.FlatAppearance.BorderSize = 1; + button.FlatAppearance.MouseOverBackColor = Color.FromArgb(55, 77, 84); + button.FlatAppearance.MouseDownBackColor = Color.FromArgb(33, 51, 59); + button.BackColor = DeepSlate; + button.ForeColor = Mist; + } + + private static Image? LoadLauncherBackground() + { + using var stream = typeof(MainForm).Assembly.GetManifestResourceStream( + "ACE.SinglePlayer.Assets.dereth-launcher-background.png"); + return stream is null ? null : new Bitmap(stream); + } + + private void AppendDiagnostic(string line) + { + if (InvokeRequired) + { + BeginInvoke(() => AppendDiagnostic(line)); + return; + } + diagnostics.AppendText(line + Environment.NewLine); + } + + private async void OnFormClosing(object? sender, FormClosingEventArgs args) + { + if (closingAllowed) + return; + args.Cancel = true; + Enabled = false; + await controller.ShutdownAsync(); + closingAllowed = true; + Close(); + } + + private static string ToDisplayName(LauncherState value) => value switch + { + LauncherState.NotConfigured => "Not configured", + LauncherState.PreparingData => "Preparing game data", + LauncherState.CheckingDatabase => "Checking database", + LauncherState.StartingDatabase => "Starting database", + LauncherState.StartingServer => "Starting server", + LauncherState.WaitingForWorld => "Waiting for world", + LauncherState.GameRunning => "Game running", + _ => value.ToString() + }; +} diff --git a/Source/ACE.SinglePlayer/UI/ModsForm.cs b/Source/ACE.SinglePlayer/UI/ModsForm.cs new file mode 100644 index 0000000000..eb7ffc322f --- /dev/null +++ b/Source/ACE.SinglePlayer/UI/ModsForm.cs @@ -0,0 +1,553 @@ +using System.ComponentModel; +using System.Diagnostics; + +using ACE.SinglePlayer.Models; +using ACE.SinglePlayer.Mods; + +namespace ACE.SinglePlayer.UI; + +public sealed class ModsForm : Form +{ + private static readonly Color Night = Color.FromArgb(14, 25, 32); + private static readonly Color DeepSlate = Color.FromArgb(26, 43, 52); + private static readonly Color WeatheredGold = Color.FromArgb(205, 160, 82); + private static readonly Color PaleGold = Color.FromArgb(241, 220, 170); + private static readonly Color Mist = Color.FromArgb(222, 231, 226); + + private readonly LauncherSettings settings; + private readonly Func isServerRunning; + private readonly ModPackageInstaller installer = new(); + private readonly DataGridView grid = new() + { + Dock = DockStyle.Fill, + AutoGenerateColumns = false, + ReadOnly = true, + AllowUserToAddRows = false, + AllowUserToDeleteRows = false, + AllowUserToResizeRows = false, + MultiSelect = false, + RowHeadersVisible = false, + SelectionMode = DataGridViewSelectionMode.FullRowSelect, + BackgroundColor = Night, + BorderStyle = BorderStyle.None + }; + private readonly Label detailTitle = new() { AutoSize = false, Dock = DockStyle.Top, Height = 42 }; + private readonly Label detailStatus = new() { AutoSize = false, Dock = DockStyle.Top, Height = 44 }; + private readonly TextBox detailText = new() + { + Dock = DockStyle.Fill, + Multiline = true, + ReadOnly = true, + ScrollBars = ScrollBars.Vertical, + BorderStyle = BorderStyle.None + }; + private readonly LinkLabel sourceLink = new() { Text = "View original source code", AutoSize = true }; + private readonly LinkLabel portedSourceLink = new() { Text = "View ACE Single Player ported code", AutoSize = true }; + private readonly Button install = new() { Text = "Install", AutoSize = true }; + private readonly Button toggle = new() { Text = "Turn off", AutoSize = true }; + private readonly Button remove = new() { Text = "Remove", AutoSize = true }; + private readonly Button openSettings = new() { Text = "Settings", AutoSize = true }; + private readonly Button importPackage = new() { Text = "Import a Mod ZIP...", AutoSize = true }; + private readonly Button authorGuide = new() { Text = "How to Make a Mod", AutoSize = true }; + private BindingList items = new(); + + public ModsForm(LauncherSettings settings, Func isServerRunning) + { + this.settings = settings; + this.isServerRunning = isServerRunning; + + Text = "ACE Single Player - Mod Library"; + StartPosition = FormStartPosition.CenterParent; + MinimumSize = new Size(1040, 680); + Size = new Size(1240, 780); + BackColor = Night; + ForeColor = Mist; + Font = new Font(SystemFonts.MessageBoxFont!.FontFamily, 9.5f); + + AddColumns(); + StyleGrid(); + + var heading = new Label + { + Dock = DockStyle.Fill, + Padding = new Padding(18, 10, 18, 4), + Font = new Font("Georgia", 19, FontStyle.Bold), + ForeColor = PaleGold, + Text = "MOD LIBRARY\r\nPick a mod to see what it changes before installing it." + }; + var headerActions = new FlowLayoutPanel + { + Dock = DockStyle.Right, + Width = 330, + FlowDirection = FlowDirection.LeftToRight, + WrapContents = true, + Padding = new Padding(0, 20, 12, 0), + BackColor = Color.Transparent + }; + foreach (var button in new[] { importPackage, authorGuide }) + StyleButton(button); + headerActions.Controls.AddRange(new Control[] { importPackage, authorGuide }); + var header = new Panel { Dock = DockStyle.Top, Height = 82, BackColor = Night }; + header.Controls.Add(heading); + header.Controls.Add(headerActions); + + var split = new SplitContainer + { + Dock = DockStyle.Fill, + SplitterWidth = 6, + BackColor = Night + }; + split.Panel1.Padding = new Padding(14, 8, 6, 14); + split.Panel2.Padding = new Padding(8, 8, 14, 14); + split.Panel1.Controls.Add(grid); + split.Panel2.Controls.Add(BuildDetailsPanel()); + + Controls.Add(split); + Controls.Add(header); + + // SplitContainer starts with a small design-time width. Setting a large + // distance or panel minimum in the initializer throws before the form has + // completed layout, especially with display scaling enabled. + Shown += (_, _) => ConfigureSplitter(split); + + grid.SelectionChanged += (_, _) => UpdateDetails(); + install.Click += async (_, _) => await InstallSelectedAsync(); + toggle.Click += async (_, _) => await ToggleSelectedAsync(); + remove.Click += (_, _) => RemoveSelected(); + openSettings.Click += (_, _) => OpenSelectedSettings(); + importPackage.Click += async (_, _) => await ImportPackageAsync(); + authorGuide.Click += (_, _) => OpenModAuthorGuide(); + sourceLink.LinkClicked += (_, _) => OpenSelectedSource(); + portedSourceLink.LinkClicked += (_, _) => OpenSelectedPortSource(); + + RefreshCatalog(); + } + + private static void ConfigureSplitter(SplitContainer split) + { + var layout = CalculateSplitterLayout(split.ClientSize.Width, split.SplitterWidth); + + // Clear the defaults first so every assignment is valid even on a narrow, + // scaled display. The calculated minimums are restored after the divider. + split.Panel1MinSize = 0; + split.Panel2MinSize = 0; + split.SplitterDistance = layout.Distance; + split.Panel1MinSize = layout.Panel1MinSize; + split.Panel2MinSize = layout.Panel2MinSize; + } + + internal static SplitterLayout CalculateSplitterLayout(int controlWidth, int splitterWidth) + { + var available = Math.Max(0, controlWidth - splitterWidth); + var panel1Minimum = Math.Min(540, available); + var panel2Minimum = Math.Min(360, Math.Max(0, available - panel1Minimum)); + var maximumDistance = Math.Max(panel1Minimum, available - panel2Minimum); + var distance = Math.Clamp(730, panel1Minimum, maximumDistance); + return new SplitterLayout(distance, panel1Minimum, panel2Minimum); + } + + internal sealed record SplitterLayout(int Distance, int Panel1MinSize, int Panel2MinSize); + + private ModListItem? Selected => grid.CurrentRow?.DataBoundItem as ModListItem; + + private Control BuildDetailsPanel() + { + var panel = new Panel { Dock = DockStyle.Fill, BackColor = DeepSlate, Padding = new Padding(16) }; + detailTitle.Font = new Font("Georgia", 17, FontStyle.Bold); + detailTitle.ForeColor = PaleGold; + detailStatus.Font = new Font(Font, FontStyle.Bold); + detailStatus.ForeColor = WeatheredGold; + detailText.BackColor = DeepSlate; + detailText.ForeColor = Mist; + detailText.Font = new Font(Font.FontFamily, 10); + sourceLink.LinkColor = Color.FromArgb(142, 197, 222); + sourceLink.ActiveLinkColor = PaleGold; + portedSourceLink.LinkColor = Color.FromArgb(142, 197, 222); + portedSourceLink.ActiveLinkColor = PaleGold; + + var actions = new FlowLayoutPanel + { + Dock = DockStyle.Bottom, + Height = 84, + FlowDirection = FlowDirection.LeftToRight, + WrapContents = true, + Padding = new Padding(0, 9, 0, 0), + BackColor = Color.Transparent + }; + foreach (var button in new[] { install, toggle, remove, openSettings }) + StyleButton(button); + actions.Controls.AddRange(new Control[] { install, toggle, remove, openSettings }); + + var sourcePanel = new FlowLayoutPanel + { + Dock = DockStyle.Bottom, + Height = 48, + FlowDirection = FlowDirection.LeftToRight, + WrapContents = true, + Padding = new Padding(0, 8, 0, 0) + }; + sourcePanel.Controls.AddRange(new Control[] { sourceLink, portedSourceLink }); + + panel.Controls.Add(detailText); + panel.Controls.Add(sourcePanel); + panel.Controls.Add(actions); + panel.Controls.Add(detailStatus); + panel.Controls.Add(detailTitle); + return panel; + } + + private void RefreshCatalog() + { + var installedCatalog = new ModCatalog(CreateDisplayedModProviders(settings.ModsDirectory)); + var service = new ModCatalogService(CuratedModCatalog.Entries, AppContext.BaseDirectory); + items = new BindingList(service.Merge(installedCatalog.Scan()).ToList()); + grid.DataSource = items; + if (grid.Rows.Count > 0) + grid.Rows[0].Selected = true; + UpdateDetails(); + } + + internal static IReadOnlyList CreateDisplayedModProviders(string modsDirectory) => + new IModProvider[] + { + new AceServerModProvider(modsDirectory), + new ChorizitePluginProvider(), + new AceContentPackProvider() + }; + + private void UpdateDetails() + { + var item = Selected; + if (item is null) + { + detailTitle.Text = "Select a mod"; + detailStatus.Text = string.Empty; + detailText.Text = string.Empty; + SetActions(false, false, false, false, false); + return; + } + + detailTitle.Text = item.Name; + detailStatus.Text = $"{item.Status} | {item.Safety}"; + detailStatus.ForeColor = item.CompatibilityStatus switch + { + CompatibilityStatus.Compatible => Color.FromArgb(151, 222, 178), + CompatibilityStatus.LoadFailed or CompatibilityStatus.Conflict => Color.FromArgb(255, 153, 125), + _ => WeatheredGold + }; + + var dependencies = item.Catalog.Dependencies.Count == 0 + ? "None declared" + : string.Join(", ", item.Catalog.Dependencies.Select(FindCatalogName)); + var testingStatus = item.Catalog.Availability == ModCatalogAvailability.Preview + ? "PREVIEW - " + (string.IsNullOrWhiteSpace(item.Catalog.PreviewNotice) + ? "automated compatibility checks passed, but thorough in-game testing has not been completed." + : item.Catalog.PreviewNotice) + : item.Catalog.Availability == ModCatalogAvailability.Ready + ? "CURATED - packaged for this ACE release." + : "NOT PORTED - source is listed for reference only."; + detailText.Text = + $"WHAT IT DOES\r\n{item.Catalog.Description}\r\n\r\n" + + $"DETAILS\r\n{item.Catalog.Details}\r\n\r\n" + + $"SAVED-GAME SAFETY\r\n{item.Catalog.SafetyNotice}\r\n\r\n" + + $"COMPATIBILITY\r\n{item.CompatibilityMessage}\r\n\r\n" + + $"TESTING STATUS\r\n{testingStatus}\r\n\r\n" + + $"REQUIRES\r\n{dependencies}\r\n\r\n" + + $"AUTHOR / VERSION TARGET\r\n{item.Author} | {item.Catalog.TargetFramework} | {item.Catalog.TargetAceVersion}"; + + var installedRecord = item.Installed; + install.Text = item.CompatibilityStatus == CompatibilityStatus.Compatible + ? "Install" + : "Why unavailable?"; + toggle.Text = installedRecord?.Enabled == true ? "Turn off" : "Turn on"; + SetActions( + installEnabled: installedRecord is null, + toggleEnabled: installedRecord?.CanToggle == true, + removeEnabled: installedRecord?.Type == ModType.AceServer, + settingsEnabled: installedRecord is not null && File.Exists(installedRecord.SettingsPath), + sourceEnabled: !string.IsNullOrWhiteSpace(item.Catalog.SourceUrl)); + } + + private async Task InstallSelectedAsync() + { + var item = Selected; + if (item is null || item.Installed is not null) + return; + if (item.CompatibilityStatus != CompatibilityStatus.Compatible) + { + MessageBox.Show(this, + item.CompatibilityMessage + + "\r\n\r\nImport a Mod ZIP can install a separately rebuilt package, but importing the original source does not port it to this ACE version.", + "Mod is not packaged for this ACE version", MessageBoxButtons.OK, MessageBoxIcon.Information); + return; + } + if (!EnsureServerStopped()) + return; + + var answer = MessageBox.Show(this, + $"Install {item.Name}?\r\n\r\n{item.Catalog.Description}\r\n\r\n{item.Catalog.SafetyNotice}" + + (item.Catalog.Availability == ModCatalogAvailability.Preview + ? "\r\n\r\nPREVIEW WARNING: automated checks passed, but this mod has not been thoroughly tested in game." + : string.Empty) + + "\r\n\r\nThe local server must restart before the mod becomes active.", + "Install mod", MessageBoxButtons.YesNo, MessageBoxIcon.Question); + if (answer != DialogResult.Yes) + return; + + SetActions(false, false, false, false, false); + try + { + await installer.InstallAsync( + item.PackagePath, + item.Catalog.Id, + settings.ModsDirectory, + Path.Combine(settings.RuntimeDirectory, "ModStaging")); + RefreshCatalog(); + MessageBox.Show(this, $"{item.Name} is installed. It will load the next time you click Play.", + "Mod installed", MessageBoxButtons.OK, MessageBoxIcon.Information); + } + catch (Exception ex) + { + MessageBox.Show(this, ex.Message, "Mod was not installed", MessageBoxButtons.OK, MessageBoxIcon.Error); + UpdateDetails(); + } + } + + private async Task ImportPackageAsync() + { + if (!EnsureServerStopped()) + return; + + using var dialog = new OpenFileDialog + { + Title = "Import an ACE Single Player mod package", + Filter = "ACE mod packages (*.zip)|*.zip", + CheckFileExists = true, + Multiselect = false + }; + if (dialog.ShowDialog(this) != DialogResult.OK) + return; + + importPackage.Enabled = false; + try + { + var manifest = await installer.InspectAsync(dialog.FileName); + var catalog = CuratedModCatalog.Entries.FirstOrDefault(entry => + string.Equals(entry.Id, manifest.Id, StringComparison.OrdinalIgnoreCase)); + var compatibilityWarning = catalog?.Availability switch + { + null => "This package is not in the curated catalog. The launcher can validate its checksum and file layout, but cannot prove that its code is compatible or safe for saved characters.", + ModCatalogAvailability.Ready => catalog.SafetyNotice, + ModCatalogAvailability.Preview => catalog.SafetyNotice + "\r\n\r\nPREVIEW WARNING: automated checks passed, but this mod has not been thoroughly tested in game.", + _ => "The bundled catalog still marks this mod as needing a source port. Only continue if this ZIP was rebuilt and tested specifically for the current ACE Single Player release." + }; + + var answer = MessageBox.Show(this, + $"Import {manifest.Name} {manifest.Version}?\r\n\r\nPackage ID: {manifest.Id}\r\n\r\n{compatibilityWarning}\r\n\r\n" + + "A matching .sha256 checksum file was verified. Back up %LOCALAPPDATA%\\ACESinglePlayer before importing unverified code. The server will restart when you next click Play.", + "Import mod package", MessageBoxButtons.YesNo, MessageBoxIcon.Warning); + if (answer != DialogResult.Yes) + return; + + var destination = await installer.InstallAsync( + dialog.FileName, + manifest.Id, + settings.ModsDirectory, + Path.Combine(settings.RuntimeDirectory, "ModStaging")); + RefreshCatalog(); + MessageBox.Show(this, + $"{manifest.Name} was imported to:\r\n{destination}\r\n\r\nIt will load the next time you click Play.", + "Mod imported", MessageBoxButtons.OK, MessageBoxIcon.Information); + } + catch (Exception ex) + { + MessageBox.Show(this, + ex.Message + + "\r\n\r\nA supported package contains ace-mod.json at the ZIP root, its files under mod/, and a matching .zip.sha256 file beside the ZIP.", + "Mod was not imported", MessageBoxButtons.OK, MessageBoxIcon.Error); + } + finally + { + importPackage.Enabled = true; + UpdateDetails(); + } + } + + private async Task ToggleSelectedAsync() + { + var item = Selected; + var record = item?.Installed; + if (item is null || record is null || !record.CanToggle) + return; + if (!EnsureServerStopped()) + return; + + var enabling = !record.Enabled; + if (!enabling && item.Catalog.RemovalPolicy is not ModRemovalPolicy.Safe) + { + var warning = item.Catalog.RemovalPolicy == ModRemovalPolicy.DoNotRemove + ? "This mod may be required by saved characters, items, or world data. Turning it off is strongly discouraged." + : "Some changes made by this mod will remain in the saved game after it is turned off."; + var answer = MessageBox.Show(this, + $"{warning}\r\n\r\n{item.Catalog.SafetyNotice}\r\n\r\nBack up %LOCALAPPDATA%\\ACESinglePlayer first. Turn it off anyway?", + "Saved-game warning", MessageBoxButtons.YesNo, MessageBoxIcon.Warning); + if (answer != DialogResult.Yes) + return; + } + + try + { + await ModMetadataEditor.SetEnabledAsync(record.MetadataPath, enabling); + RefreshCatalog(); + } + catch (Exception ex) + { + MessageBox.Show(this, ex.Message, "Could not change the mod", MessageBoxButtons.OK, MessageBoxIcon.Error); + } + } + + private void RemoveSelected() + { + var item = Selected; + var record = item?.Installed; + if (item is null || record?.Type != ModType.AceServer) + return; + if (!EnsureServerStopped()) + return; + if (item.Catalog.RemovalPolicy == ModRemovalPolicy.DoNotRemove) + { + MessageBox.Show(this, + "Removal is blocked because saved characters, items, or world data may require this mod. You can turn it off for troubleshooting, but keep the files until a tested cleanup migration exists.\r\n\r\n" + item.Catalog.SafetyNotice, + "Removal blocked", MessageBoxButtons.OK, MessageBoxIcon.Warning); + return; + } + + var answer = MessageBox.Show(this, + $"Remove {item.Name}?\r\n\r\n{item.Catalog.SafetyNotice}\r\n\r\nThe files will be moved to a recovery folder, not deleted.", + "Remove mod", MessageBoxButtons.YesNo, MessageBoxIcon.Warning); + if (answer != DialogResult.Yes) + return; + + try + { + var destination = installer.MoveToQuarantine( + record.InstalledPath, + settings.ModsDirectory, + Path.Combine(settings.RuntimeDirectory, "RemovedMods")); + RefreshCatalog(); + MessageBox.Show(this, $"The mod was removed from ACE and retained for recovery at:\r\n{destination}", + "Mod removed", MessageBoxButtons.OK, MessageBoxIcon.Information); + } + catch (Exception ex) + { + MessageBox.Show(this, ex.Message, "Could not remove the mod", MessageBoxButtons.OK, MessageBoxIcon.Error); + } + } + + private void OpenSelectedSettings() + { + var path = Selected?.Installed?.SettingsPath; + if (!string.IsNullOrWhiteSpace(path) && File.Exists(path)) + Open(path); + } + + private void OpenSelectedSource() + { + var url = Selected?.Catalog.SourceUrl; + if (!string.IsNullOrWhiteSpace(url)) + Open(url); + } + + private void OpenSelectedPortSource() + { + var url = Selected?.Catalog.PortSourceUrl; + if (!string.IsNullOrWhiteSpace(url)) + Open(url); + } + + private static void OpenModAuthorGuide() + { + var localGuide = Path.Combine(AppContext.BaseDirectory, "Docs", "MOD_AUTHOR_GUIDE.md"); + Open(File.Exists(localGuide) + ? localGuide + : "https://github.com/titaniumweiner/ACE-SinglePlayer/blob/main/docs/MOD_AUTHOR_GUIDE.md"); + } + + private bool EnsureServerStopped() + { + if (!isServerRunning()) + return true; + MessageBox.Show(this, "Stop the game and local server before changing mods.", + "Server is running", MessageBoxButtons.OK, MessageBoxIcon.Information); + return false; + } + + private static void Open(string path) => + Process.Start(new ProcessStartInfo { FileName = path, UseShellExecute = true }); + + private static string FindCatalogName(string id) => + CuratedModCatalog.Entries.FirstOrDefault(entry => string.Equals(entry.Id, id, StringComparison.OrdinalIgnoreCase))?.Name ?? id; + + private void SetActions(bool installEnabled, bool toggleEnabled, bool removeEnabled, bool settingsEnabled, bool sourceEnabled) + { + install.Enabled = installEnabled; + install.Visible = installEnabled; + toggle.Enabled = toggleEnabled; + toggle.Visible = toggleEnabled; + remove.Enabled = removeEnabled; + remove.Visible = removeEnabled; + openSettings.Enabled = settingsEnabled; + openSettings.Visible = settingsEnabled; + sourceLink.Enabled = sourceEnabled; + sourceLink.Visible = sourceEnabled; + var portedSourceEnabled = sourceEnabled && !string.IsNullOrWhiteSpace(Selected?.Catalog.PortSourceUrl); + portedSourceLink.Enabled = portedSourceEnabled; + portedSourceLink.Visible = portedSourceEnabled; + } + + private void AddColumns() + { + grid.Columns.Add(new DataGridViewTextBoxColumn { DataPropertyName = nameof(ModListItem.Status), HeaderText = "Status", Width = 122 }); + grid.Columns.Add(new DataGridViewTextBoxColumn { DataPropertyName = nameof(ModListItem.Name), HeaderText = "Mod", Width = 145 }); + grid.Columns.Add(new DataGridViewTextBoxColumn { DataPropertyName = nameof(ModListItem.Safety), HeaderText = "Saved-game impact", Width = 155 }); + grid.Columns.Add(new DataGridViewTextBoxColumn { DataPropertyName = nameof(ModListItem.Description), HeaderText = "What it does", AutoSizeMode = DataGridViewAutoSizeColumnMode.Fill }); + } + + private void StyleGrid() + { + grid.EnableHeadersVisualStyles = false; + grid.ColumnHeadersDefaultCellStyle.BackColor = DeepSlate; + grid.ColumnHeadersDefaultCellStyle.ForeColor = PaleGold; + grid.ColumnHeadersDefaultCellStyle.Font = new Font(Font, FontStyle.Bold); + grid.ColumnHeadersHeight = 38; + grid.DefaultCellStyle.BackColor = Color.FromArgb(20, 34, 41); + grid.DefaultCellStyle.ForeColor = Mist; + grid.DefaultCellStyle.SelectionBackColor = Color.FromArgb(70, 80, 67); + grid.DefaultCellStyle.SelectionForeColor = Color.White; + grid.DefaultCellStyle.WrapMode = DataGridViewTriState.True; + grid.AlternatingRowsDefaultCellStyle.BackColor = Color.FromArgb(24, 40, 48); + grid.RowTemplate.Height = 66; + grid.CellFormatting += (_, args) => + { + if (args.RowIndex < 0 || grid.Rows[args.RowIndex].DataBoundItem is not ModListItem item) + return; + if (item.CompatibilityStatus is CompatibilityStatus.LoadFailed or CompatibilityStatus.Conflict) + args.CellStyle.ForeColor = Color.FromArgb(255, 174, 151); + else if (item.Installed is not null) + args.CellStyle.ForeColor = Color.FromArgb(165, 226, 188); + }; + } + + private static void StyleButton(Button button) + { + button.FlatStyle = FlatStyle.Flat; + button.FlatAppearance.BorderColor = Color.FromArgb(126, 146, 145); + button.FlatAppearance.BorderSize = 1; + button.FlatAppearance.MouseOverBackColor = Color.FromArgb(55, 77, 84); + button.FlatAppearance.MouseDownBackColor = Color.FromArgb(33, 51, 59); + button.BackColor = Night; + button.ForeColor = PaleGold; + button.Font = new Font(button.Font, FontStyle.Bold); + button.UseVisualStyleBackColor = false; + } +} diff --git a/Source/ACE.SinglePlayer/UI/QuickSetupForm.cs b/Source/ACE.SinglePlayer/UI/QuickSetupForm.cs new file mode 100644 index 0000000000..58c16dda3a --- /dev/null +++ b/Source/ACE.SinglePlayer/UI/QuickSetupForm.cs @@ -0,0 +1,104 @@ +using ACE.SinglePlayer.Infrastructure; +using ACE.SinglePlayer.Models; + +namespace ACE.SinglePlayer.UI; + +public sealed class QuickSetupForm : Form +{ + private readonly LauncherSettings settings; + private readonly TextBox clientDirectory = new() { Dock = DockStyle.Fill }; + + public QuickSetupForm(LauncherSettings settings) + { + this.settings = settings; + Text = "ACE Single Player - Choose Asheron's Call"; + StartPosition = FormStartPosition.CenterScreen; + FormBorderStyle = FormBorderStyle.FixedDialog; + MaximizeBox = false; + MinimizeBox = false; + ClientSize = new Size(690, 260); + + var existingDirectory = Path.GetDirectoryName(settings.ClientExePath); + clientDirectory.Text = Directory.Exists(existingDirectory) ? existingDirectory : string.Empty; + + var title = new Label + { + Text = "Where is Asheron's Call installed?", + AutoSize = true, + Font = new Font(SystemFonts.MessageBoxFont!.FontFamily, 15, FontStyle.Bold), + Margin = new Padding(0, 0, 0, 12) + }; + var explanation = new Label + { + Text = "Select the folder containing acclient.exe and the four client DAT files. Everything else—the ACE server, private database, and world—is already included.", + AutoSize = true, + MaximumSize = new Size(640, 0), + Margin = new Padding(0, 0, 0, 14) + }; + var browse = new Button { Text = "Browse...", AutoSize = true, Dock = DockStyle.Fill }; + browse.Click += (_, _) => BrowseForClient(); + var pathRow = new TableLayoutPanel { Width = 640, Height = 34, ColumnCount = 2 }; + pathRow.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100)); + pathRow.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize)); + pathRow.Controls.Add(clientDirectory, 0, 0); + pathRow.Controls.Add(browse, 1, 0); + + var continueButton = new Button { Text = "Save and Continue", AutoSize = true }; + continueButton.Click += (_, _) => SaveClientFolder(); + var cancel = new Button { Text = "Cancel", AutoSize = true, DialogResult = DialogResult.Cancel }; + var buttons = new FlowLayoutPanel + { + AutoSize = true, + Dock = DockStyle.Bottom, + FlowDirection = FlowDirection.RightToLeft, + Padding = new Padding(0, 12, 0, 0) + }; + buttons.Controls.Add(continueButton); + buttons.Controls.Add(cancel); + + var content = new FlowLayoutPanel + { + Dock = DockStyle.Fill, + FlowDirection = FlowDirection.TopDown, + WrapContents = false, + Padding = new Padding(24), + AutoScroll = true + }; + content.Controls.Add(title); + content.Controls.Add(explanation); + content.Controls.Add(pathRow); + content.Controls.Add(buttons); + Controls.Add(content); + AcceptButton = continueButton; + CancelButton = cancel; + } + + private void BrowseForClient() + { + using var dialog = new FolderBrowserDialog + { + Description = "Select the folder containing acclient.exe and the Asheron's Call DAT files", + SelectedPath = clientDirectory.Text, + ShowNewFolderButton = false + }; + if (dialog.ShowDialog(this) == DialogResult.OK) + clientDirectory.Text = dialog.SelectedPath; + } + + private void SaveClientFolder() + { + var directory = clientDirectory.Text.Trim(); + settings.ClientExePath = Path.Combine(directory, "acclient.exe"); + settings.DatFilesDirectory = directory; + var validation = SetupValidator.ValidateClient(settings); + if (!validation.IsValid) + { + MessageBox.Show(this, validation.Message, "Choose the complete AC folder", MessageBoxButtons.OK, + MessageBoxIcon.Warning); + return; + } + + DialogResult = DialogResult.OK; + Close(); + } +} diff --git a/Source/ACE.SinglePlayer/UI/SetupWizardForm.cs b/Source/ACE.SinglePlayer/UI/SetupWizardForm.cs new file mode 100644 index 0000000000..86ba086559 --- /dev/null +++ b/Source/ACE.SinglePlayer/UI/SetupWizardForm.cs @@ -0,0 +1,491 @@ +using ACE.SinglePlayer.Database; +using ACE.SinglePlayer.Infrastructure; +using ACE.SinglePlayer.Models; + +namespace ACE.SinglePlayer.UI; + +public sealed class SetupWizardForm : Form +{ + private readonly SettingsStore settingsStore; + private readonly ISecretProtector secretProtector; + private readonly DatabaseRuntimeFactory databaseRuntimeFactory; + private readonly DatabaseBootstrapper databaseBootstrapper; + private readonly TabControl pages = new() { Dock = DockStyle.Fill, Appearance = TabAppearance.FlatButtons, ItemSize = new Size(0, 1), SizeMode = TabSizeMode.Fixed }; + private readonly TextBox clientPath = new(); + private readonly TextBox datPath = new(); + private readonly TextBox serverPath = new(); + private readonly TextBox modsPath = new(); + private readonly TextBox runtimePath = new(); + private readonly ComboBox databaseMode = new() { DropDownStyle = ComboBoxStyle.DropDownList }; + private readonly TextBox databaseHost = new(); + private readonly NumericUpDown databasePort = new() { Minimum = 1, Maximum = 65535 }; + private readonly TextBox databaseUser = new(); + private readonly TextBox databasePassword = new() { UseSystemPasswordChar = true }; + private readonly TextBox authDatabase = new(); + private readonly TextBox shardDatabase = new(); + private readonly TextBox worldDatabase = new(); + private readonly TextBox managedDatabaseExe = new(); + private readonly TextBox worldSql = new(); + private readonly TextBox accountName = new(); + private readonly NumericUpDown serverPort = new() { Minimum = 1, Maximum = 65534 }; + private readonly NumericUpDown startupTimeout = new() { Minimum = 30, Maximum = 900, Increment = 30 }; + private readonly CheckBox stopWithGame = new() { Text = "Stop the local server when the game exits", AutoSize = true }; + private readonly CheckBox stopManagedDatabase = new() { Text = "Stop launcher-managed MariaDB when this launcher exits", AutoSize = true }; + private readonly CheckBox minimize = new() { Text = "Minimize this launcher after the client starts", AutoSize = true }; + private readonly Label pageTitle = new() { AutoSize = true, Font = new Font(SystemFonts.MessageBoxFont!.FontFamily, 15, FontStyle.Bold) }; + private readonly Label databaseStatus = new() { AutoSize = true, MaximumSize = new Size(620, 0) }; + private readonly Label privateDatabaseNote = new() + { + Text = "Recommended: the launcher uses its bundled MariaDB and ACE World files to create an isolated database in local Windows app data. Credentials are generated automatically and the database binds only to this PC.", + AutoSize = true, + MaximumSize = new Size(650, 0) + }; + private readonly Button testDatabase = new() { Text = "Check Database", AutoSize = true }; + private readonly Button initializeDatabase = new() { Text = "Prepare Private Database", AutoSize = true }; + private readonly Button back = new() { Text = "Back", AutoSize = true }; + private readonly Button next = new() { Text = "Next", AutoSize = true }; + private readonly Button finish = new() { Text = "Save Setup", AutoSize = true }; + private readonly List externalDatabaseControls = new(); + private readonly List privateDatabaseControls = new(); + private LauncherSettings settings; + + public SetupWizardForm(LauncherSettings settings, SettingsStore settingsStore, ISecretProtector secretProtector, + DatabaseRuntimeFactory databaseRuntimeFactory, DatabaseBootstrapper databaseBootstrapper) + { + this.settings = settings; + this.settingsStore = settingsStore; + this.secretProtector = secretProtector; + this.databaseRuntimeFactory = databaseRuntimeFactory; + this.databaseBootstrapper = databaseBootstrapper; + + Text = settingsStore.Exists ? "ACE Single Player Settings" : "ACE Single Player Setup"; + StartPosition = FormStartPosition.CenterScreen; + MinimumSize = new Size(760, 590); + Size = new Size(820, 650); + FormBorderStyle = FormBorderStyle.Sizable; + + databaseMode.Items.AddRange(new object[] { "Automatic private database (recommended)", "Existing MariaDB/MySQL (advanced)" }); + + pages.TabPages.Add(CreateClientPage()); + pages.TabPages.Add(CreateServerPage()); + pages.TabPages.Add(CreateDatabasePage()); + pages.TabPages.Add(CreatePlayPage()); + + var header = new Panel { Dock = DockStyle.Top, Height = 62, Padding = new Padding(18, 14, 18, 8) }; + header.Controls.Add(pageTitle); + var buttons = new FlowLayoutPanel + { + Dock = DockStyle.Bottom, + Height = 55, + FlowDirection = FlowDirection.RightToLeft, + Padding = new Padding(12), + WrapContents = false + }; + buttons.Controls.Add(finish); + buttons.Controls.Add(next); + buttons.Controls.Add(back); + Controls.Add(pages); + Controls.Add(buttons); + Controls.Add(header); + + back.Click += (_, _) => { if (pages.SelectedIndex > 0) pages.SelectedIndex--; UpdateNavigation(); }; + next.Click += (_, _) => { if (pages.SelectedIndex < pages.TabCount - 1) pages.SelectedIndex++; UpdateNavigation(); }; + finish.Click += async (_, _) => await SaveAsync(); + pages.SelectedIndexChanged += (_, _) => UpdateNavigation(); + databaseMode.SelectedIndexChanged += (_, _) => UpdateDatabaseModeControls(); + + LoadSettings(); + pages.SelectedIndex = 0; + UpdateNavigation(); + } + + public LauncherSettings SavedSettings => settings; + + private TabPage CreateClientPage() + { + var page = NewPage(); + var layout = NewFields(); + AddPathRow(layout, "acclient.exe", clientPath, "Select acclient.exe", () => BrowseFile(clientPath, "acclient.exe|acclient.exe"), 0); + datPath.ReadOnly = true; + AddPathRow(layout, "DAT files (automatic)", datPath, "Automatically uses the DAT files beside acclient.exe", () => BrowseFolder(datPath), 1); + var note = new Label + { + Text = "Select acclient.exe from a complete, writable AC client folder. The client requires client_cell_1.dat, client_portal.dat, client_local_English.dat, and client_highres.dat beside acclient.exe. The launcher does not download or redistribute these proprietary files.", + AutoSize = true, + MaximumSize = new Size(650, 0) + }; + layout.Controls.Add(note, 0, 2); + layout.SetColumnSpan(note, 3); + page.Controls.Add(layout); + return page; + } + + private TabPage CreateServerPage() + { + var page = NewPage(); + var layout = NewFields(); + AddPathRow(layout, "ACE.Server.exe", serverPath, "Select the published server executable", () => BrowseFile(serverPath, "ACE.Server|ACE.Server.exe"), 0); + AddPathRow(layout, "Mods directory", modsPath, "Select the ACE server-mod directory", () => BrowseFolder(modsPath), 1); + AddPathRow(layout, "Runtime directory", runtimePath, "Select the private runtime-data directory", () => BrowseFolder(runtimePath), 2); + var note = new Label + { + Text = "The dedicated single-player Config.js, ready file, process record, and optional managed database data live under Runtime. The server always binds to 127.0.0.1.", + AutoSize = true, + MaximumSize = new Size(650, 0) + }; + layout.Controls.Add(note, 0, 3); + layout.SetColumnSpan(note, 3); + page.Controls.Add(layout); + return page; + } + + private TabPage CreateDatabasePage() + { + var page = NewPage(); + var layout = NewFields(); + AddRow(layout, "Database mode", databaseMode, 0); + layout.Controls.Add(privateDatabaseNote, 0, 1); + layout.SetColumnSpan(privateDatabaseNote, 3); + privateDatabaseControls.Add(privateDatabaseNote); + + TrackExternal(AddRow(layout, "Host", databaseHost, 2), databaseHost); + TrackExternal(AddRow(layout, "Port", databasePort, 3), databasePort); + TrackExternal(AddRow(layout, "Username", databaseUser, 4), databaseUser); + TrackExternal(AddRow(layout, "Password", databasePassword, 5), databasePassword); + TrackExternal(AddRow(layout, "Authentication DB", authDatabase, 6), authDatabase); + TrackExternal(AddRow(layout, "Shard DB", shardDatabase, 7), shardDatabase); + TrackExternal(AddRow(layout, "World DB", worldDatabase, 8), worldDatabase); + var mariaDbPathControls = AddPathRow(layout, "MariaDB program", managedDatabaseExe, + "Automatically detected; browse to mariadbd.exe only when needed", + () => BrowseFile(managedDatabaseExe, "MariaDB server|mariadbd.exe"), 9); + privateDatabaseControls.AddRange(new Control[] { mariaDbPathControls.Label, managedDatabaseExe, mariaDbPathControls.Button }); + AddPathRow(layout, "World SQL package", worldSql, + "Bundled automatically; browse only to use an advanced replacement world", + () => BrowseFile(worldSql, "SQL files|*.sql"), 10); + + var actions = new FlowLayoutPanel { AutoSize = true, FlowDirection = FlowDirection.LeftToRight }; + actions.Controls.Add(testDatabase); + actions.Controls.Add(initializeDatabase); + layout.Controls.Add(actions, 1, 11); + layout.SetColumnSpan(actions, 2); + layout.Controls.Add(databaseStatus, 1, 12); + layout.SetColumnSpan(databaseStatus, 2); + testDatabase.Click += async (_, _) => await TestDatabaseAsync(testDatabase); + initializeDatabase.Click += async (_, _) => await InitializeDatabaseAsync(initializeDatabase); + page.Controls.Add(layout); + return page; + } + + private TabPage CreatePlayPage() + { + var page = NewPage(); + var layout = NewFields(); + AddRow(layout, "Persistent account", accountName, 0); + AddRow(layout, "Local server port", serverPort, 1); + AddRow(layout, "Startup timeout (seconds)", startupTimeout, 2); + layout.Controls.Add(stopWithGame, 1, 3); + layout.SetColumnSpan(stopWithGame, 2); + layout.Controls.Add(stopManagedDatabase, 1, 4); + layout.SetColumnSpan(stopManagedDatabase, 2); + layout.Controls.Add(minimize, 1, 5); + layout.SetColumnSpan(minimize, 2); + var note = new Label + { + Text = "A strong account password is generated once and protected for your Windows user. The same account is reused on every launch, so its characters remain in the shard database.", + AutoSize = true, + MaximumSize = new Size(650, 0) + }; + layout.Controls.Add(note, 0, 6); + layout.SetColumnSpan(note, 3); + page.Controls.Add(layout); + return page; + } + + private void LoadSettings() + { + SettingsPathRepairer.Repair(settings, AppContext.BaseDirectory); + clientPath.Text = settings.ClientExePath; + datPath.Text = settings.DatFilesDirectory; + serverPath.Text = settings.ServerExePath; + modsPath.Text = settings.ModsDirectory; + runtimePath.Text = settings.RuntimeDirectory; + if (settings.DatabaseMode != DatabaseMode.External) + settings.ManagedDatabaseExePath = MariaDbInstallationLocator.FindServerExecutable(settings.ManagedDatabaseExePath) + ?? settings.ManagedDatabaseExePath; + databaseMode.SelectedIndex = settings.DatabaseMode == DatabaseMode.External ? 1 : 0; + databaseHost.Text = settings.DatabaseHost; + databasePort.Value = settings.DatabasePort; + databaseUser.Text = settings.DatabaseUsername; + try + { + databasePassword.Text = settings.DatabaseMode != DatabaseMode.External || string.IsNullOrEmpty(settings.ProtectedDatabasePassword) + ? string.Empty + : secretProtector.Unprotect(settings.ProtectedDatabasePassword); + } + catch (Exception ex) when (ex is FormatException or System.ComponentModel.Win32Exception) + { + databasePassword.Text = string.Empty; + databaseStatus.Text = "The saved database password could not be decrypted. Enter it again."; + } + authDatabase.Text = settings.AuthenticationDatabaseName; + shardDatabase.Text = settings.ShardDatabaseName; + worldDatabase.Text = settings.WorldDatabaseName; + managedDatabaseExe.Text = settings.ManagedDatabaseExePath; + worldSql.Text = settings.WorldDatabaseSqlPath; + accountName.Text = settings.AccountName; + serverPort.Value = settings.Port; + startupTimeout.Value = Math.Clamp(settings.ServerStartupTimeoutSeconds, (int)startupTimeout.Minimum, (int)startupTimeout.Maximum); + stopWithGame.Checked = settings.StopServerWhenGameExits; + stopManagedDatabase.Checked = settings.StopManagedDatabaseWhenLauncherExits; + minimize.Checked = settings.MinimizeLauncherAfterClientStarts; + UpdateDatabaseModeControls(); + } + + private LauncherSettings BuildSettings() + { + var result = settings; + result.ClientExePath = clientPath.Text.Trim(); + result.DatFilesDirectory = SetupValidator.DetectDatDirectory(result.ClientExePath) ?? datPath.Text.Trim(); + result.ServerExePath = serverPath.Text.Trim(); + result.ModsDirectory = modsPath.Text.Trim(); + result.RuntimeDirectory = runtimePath.Text.Trim(); + result.Host = "127.0.0.1"; + result.Port = (ushort)serverPort.Value; + result.ServerStartupTimeoutSeconds = (int)startupTimeout.Value; + result.AccountName = accountName.Text.Trim(); + if (string.IsNullOrWhiteSpace(result.ProtectedAccountPassword)) + result.ProtectedAccountPassword = secretProtector.Protect(SecretProtector.GeneratePassword()); + var previousDatabaseMode = result.DatabaseMode; + var usePrivateDatabase = databaseMode.SelectedIndex != 1; + if (usePrivateDatabase) + { + if (previousDatabaseMode == DatabaseMode.External) + { + result.ProtectedExternalDatabasePassword = result.ProtectedDatabasePassword; + result.ProtectedDatabasePassword = result.ProtectedPrivateDatabasePassword; + } + + result.DatabaseMode = DatabaseMode.Private; + result.DatabaseHost = "127.0.0.1"; + if (previousDatabaseMode == DatabaseMode.External || result.DatabasePort == 3306) + result.DatabasePort = PrivateDatabasePortFinder.FindAvailablePort(); + result.DatabaseUsername = "ace_singleplayer"; + var privateDatabaseExists = Directory.Exists(Path.Combine(result.PrivateDatabaseDirectory, "mysql")); + if (string.IsNullOrWhiteSpace(result.ProtectedDatabasePassword)) + result.ProtectedDatabasePassword = secretProtector.Protect(SecretProtector.GeneratePassword()); + if (string.IsNullOrWhiteSpace(result.ProtectedPrivateDatabaseAdminPassword) && !privateDatabaseExists) + result.ProtectedPrivateDatabaseAdminPassword = secretProtector.Protect(SecretProtector.GeneratePassword()); + result.ProtectedPrivateDatabasePassword = result.ProtectedDatabasePassword; + result.ManagedDatabaseExePath = MariaDbInstallationLocator.FindServerExecutable(managedDatabaseExe.Text.Trim()) + ?? managedDatabaseExe.Text.Trim(); + } + else + { + if (previousDatabaseMode != DatabaseMode.External) + result.ProtectedPrivateDatabasePassword = result.ProtectedDatabasePassword; + result.DatabaseMode = DatabaseMode.External; + result.DatabaseHost = databaseHost.Text.Trim(); + result.DatabasePort = (ushort)databasePort.Value; + result.DatabaseUsername = databaseUser.Text.Trim(); + result.ProtectedDatabasePassword = secretProtector.Protect(databasePassword.Text); + result.ProtectedExternalDatabasePassword = result.ProtectedDatabasePassword; + result.ManagedDatabaseExePath = managedDatabaseExe.Text.Trim(); + } + result.AuthenticationDatabaseName = authDatabase.Text.Trim(); + result.ShardDatabaseName = shardDatabase.Text.Trim(); + result.WorldDatabaseName = worldDatabase.Text.Trim(); + result.WorldDatabaseSqlPath = worldSql.Text.Trim(); + result.StopServerWhenGameExits = stopWithGame.Checked; + result.StopManagedDatabaseWhenLauncherExits = stopManagedDatabase.Checked; + result.MinimizeLauncherAfterClientStarts = minimize.Checked; + return result; + } + + private async Task SaveAsync() + { + settings = BuildSettings(); + var validation = SetupValidator.Validate(settings); + if (!validation.IsValid) + { + MessageBox.Show(this, validation.Message, "Setup needs attention", MessageBoxButtons.OK, MessageBoxIcon.Warning); + return; + } + finish.Enabled = false; + try + { + Directory.CreateDirectory(settings.RuntimeDirectory); + Directory.CreateDirectory(settings.ModsDirectory); + if (settings.DatabaseMode != DatabaseMode.External) + { + pages.SelectedIndex = 2; + databaseStatus.Text = "Preparing the private database. The first world import can take several minutes..."; + await PrepareDatabaseAsync(settings, CancellationToken.None); + } + + await settingsStore.SaveAsync(settings); + DialogResult = DialogResult.OK; + Close(); + } + catch (Exception ex) + { + databaseStatus.Text = ex.Message; + MessageBox.Show(this, ex.Message, "Database setup needs attention", MessageBoxButtons.OK, MessageBoxIcon.Warning); + } + finally + { + finish.Enabled = true; + } + } + + private async Task TestDatabaseAsync(Button button) + { + button.Enabled = false; + databaseStatus.Text = "Checking..."; + try + { + var current = BuildSettings(); + await using var runtime = databaseRuntimeFactory.Create(current); + await runtime.StartAsync(current, CancellationToken.None); + var result = await runtime.ValidateAsync(current, CancellationToken.None); + databaseStatus.Text = result.Message; + await runtime.StopAsync(CancellationToken.None); + } + catch (Exception ex) + { + databaseStatus.Text = ex.Message; + } + finally + { + button.Enabled = true; + } + } + + private async Task InitializeDatabaseAsync(Button button) + { + button.Enabled = false; + databaseStatus.Text = databaseMode.SelectedIndex == 0 + ? "Preparing the private database. The first world import can take several minutes..." + : "Initializing only databases that are completely missing..."; + var current = BuildSettings(); + try + { + databaseStatus.Text = await PrepareDatabaseAsync(current, CancellationToken.None); + } + catch (Exception ex) + { + databaseStatus.Text = ex.Message; + } + finally + { + button.Enabled = true; + } + } + + private async Task PrepareDatabaseAsync(LauncherSettings current, CancellationToken cancellationToken) + { + await using var runtime = databaseRuntimeFactory.Create(current); + try + { + if (runtime.IsManaged) + await runtime.StartAsync(current, cancellationToken); + await databaseBootstrapper.BootstrapAsync(current, current.WorldDatabaseSqlPath, cancellationToken); + var result = await runtime.ValidateAsync(current, cancellationToken); + if (!result.IsValid) + throw new InvalidOperationException(result.Message); + return result.Message; + } + finally + { + await runtime.StopAsync(CancellationToken.None); + } + } + + private void UpdateDatabaseModeControls() + { + var usePrivateDatabase = databaseMode.SelectedIndex != 1; + foreach (var control in externalDatabaseControls) + control.Visible = !usePrivateDatabase; + foreach (var control in privateDatabaseControls) + control.Visible = usePrivateDatabase; + + testDatabase.Text = usePrivateDatabase ? "Check Private Database" : "Test Connection"; + initializeDatabase.Text = usePrivateDatabase ? "Prepare Private Database" : "Initialize Missing Databases"; + stopManagedDatabase.Visible = usePrivateDatabase; + } + + private void TrackExternal(Label label, Control field) + { + externalDatabaseControls.Add(label); + externalDatabaseControls.Add(field); + } + + private void BrowseFile(TextBox target, string filter) + { + using var dialog = new OpenFileDialog { Filter = filter, CheckFileExists = true, FileName = target.Text }; + if (dialog.ShowDialog(this) != DialogResult.OK) + return; + target.Text = dialog.FileName; + if (target == clientPath && SetupValidator.DetectDatDirectory(dialog.FileName) is { } detected) + datPath.Text = detected; + } + + private void BrowseFolder(TextBox target) + { + using var dialog = new FolderBrowserDialog { SelectedPath = target.Text, ShowNewFolderButton = true }; + if (dialog.ShowDialog(this) == DialogResult.OK) + target.Text = dialog.SelectedPath; + } + + private void UpdateNavigation() + { + var titles = new[] { "Choose your Asheron's Call client", "Choose the local ACE server", "Connect persistent storage", "One-click Play settings" }; + var selectedIndex = pages.SelectedIndex; + if (selectedIndex < 0 || selectedIndex >= Math.Min(titles.Length, pages.TabCount)) + selectedIndex = 0; + + pageTitle.Text = titles[selectedIndex]; + back.Enabled = selectedIndex > 0; + next.Visible = selectedIndex < pages.TabCount - 1; + finish.Visible = selectedIndex == pages.TabCount - 1; + } + + private static TabPage NewPage() => new() { Padding = new Padding(20) }; + + private static TableLayoutPanel NewFields() => new() + { + Dock = DockStyle.Fill, + AutoScroll = true, + ColumnCount = 3, + RowCount = 14, + Padding = new Padding(5), + ColumnStyles = + { + new ColumnStyle(SizeType.Absolute, 160), + new ColumnStyle(SizeType.Percent, 100), + new ColumnStyle(SizeType.Absolute, 105) + } + }; + + private static Label AddRow(TableLayoutPanel layout, string label, Control control, int row) + { + control.Dock = DockStyle.Top; + var labelControl = new Label { Text = label, AutoSize = true, Padding = new Padding(0, 5, 0, 0) }; + layout.Controls.Add(labelControl, 0, row); + layout.Controls.Add(control, 1, row); + layout.SetColumnSpan(control, 2); + return labelControl; + } + + private static (Label Label, Button Button) AddPathRow(TableLayoutPanel layout, string label, TextBox textBox, string accessibleDescription, Action browse, int row) + { + textBox.Dock = DockStyle.Top; + textBox.AccessibleDescription = accessibleDescription; + var button = new Button { Text = "Browse...", AutoSize = true, Dock = DockStyle.Top }; + button.Click += (_, _) => browse(); + var labelControl = new Label { Text = label, AutoSize = true, Padding = new Padding(0, 5, 0, 0) }; + layout.Controls.Add(labelControl, 0, row); + layout.Controls.Add(textBox, 1, row); + layout.Controls.Add(button, 2, row); + return (labelControl, button); + } +} diff --git a/docs/AQUIFIR_MOD_COMPATIBILITY.md b/docs/AQUIFIR_MOD_COMPATIBILITY.md new file mode 100644 index 0000000000..2a1ba70d38 --- /dev/null +++ b/docs/AQUIFIR_MOD_COMPATIBILITY.md @@ -0,0 +1,27 @@ +# Aquafir project compatibility with current ACE + +Assessment updated: 2026-07-15. The portable release uses ACE.Server build `1.77.4782` from upstream commit `650c5b75` on .NET 10. The external repositories were inspected at their default branches; client-side projects were not executed as ACE server mods. + +## ACE.BaseMod sample catalog update + +The launcher now inventories all 22 folders under `aquafir/ACE.BaseMod/Samples` with curated descriptions and saved-game policies. The upstream samples target .NET 8, reference an installed ACE server, and use placeholder metadata, so they are not offered as compatible binaries merely because their source is public. + +`CriticalOverride` is the curated one-click port. `HelloCommand` and `SocietyTailoring` are one-click **Preview** ports compiled against the same server and packaged with identity manifests and SHA-256 checksums. Automated tests cover HelloCommand command registration/removal and SocietyTailoring's current Harmony target/signature, but neither preview is represented as thoroughly gameplay-tested. Every remaining sample stays visible as **Needs port**, **Experimental**, or **Not recommended** until it receives suitable rebuild and test treatment. See `docs/MOD_LIBRARY.md` for install/removal behavior and persistent-data rules. + +| Project | Type | Current target/dependencies | Builds against this ACE as-is? | Without Thwarg/Decal? | Status and recommendation | +|---|---|---|---|---|---| +| `aquafir/ACE.BaseMod` | ACE server-mod framework/templates | `ACE.Shared` net8.0 x64, Harmony 2.3.3, Krafs.Publicizer; package notes say ACE 1.64.4610 and references default to `C:\ACE\Server` | No checkout-to-checkout build path; it targets an older installed ACE output. Current ACE already contains the corresponding `ModManager`, `ModContainer`, `IHarmonyMod`, metadata, commands, and Harmony loader. | Yes, server-side | **Needs source port / do not integrate as a second loader.** Reuse current ACE's loader. Port only still-useful shared helpers or individual samples after license/API review. | +| `aquafir/LootAll` | ACE server mod | net8.0 x64, Harmony 2.3.2, `ACEmulator.ACE.Shared` 1.0.0, publicized/hardcoded `C:\ACE\Server` assemblies; sample-like Meta.json | No. It is not configured for current net10 ACE and was not proven against current APIs. | Yes | **Compatible after rebuild or needs source port.** Retarget net10, reference the exact current ACE publish, update shared/Harmony assumptions, then test load and gameplay. Place only a successful port in the ACE Mods directory. | +| `aquafir/AltLevels` | ACE server mod | net8.0 x64, Harmony 2.3.3, `ACEmulator.ACE.Shared` 1.0.0, EF/ACE assemblies from `C:\ACE\Server`; sample-like Meta.json | No. Same installed-old-ACE assumptions; current API compatibility is unverified. | Yes | **Needs source port.** Retarget/rebuild against current ACE and add behavior/data-migration tests before installation. | +| `aquafir/Chorizite` | Client plugin framework/launcher, not an ACE server mod | net8.0; x86 launcher; native bootstrapper, standalone loader, RmlUI/AC protocol and its own manifest format | Not applicable to ACE.Server; it is a separate client framework and build graph. | Yes without Thwarg and Decal; it uses its own bootstrap/injection path and may optionally load Decal | **Wrong mod type for ModsDirectory / future integration.** Implement the reserved `ChorizitePluginProvider` and launch provider only after its bootstrap licensing, packaging, and current client behavior are tested. | +| `aquafir/UtilityFace` | Decal client plugin and hot-reload network filter | .NET Framework 4.8 x86, Decal 2.9.8.2 interop, D3DService/DirectX, UtilityBelt.Service; some machine-specific reference paths | Not applicable to ACE.Server. Its project may need dependency-path cleanup to reproduce a build. | Works without Thwarg, but not without Decal and its dependencies | **Wrong mod type for ModsDirectory.** Inventory through the read-only Decal provider. Install/register through a tested Decal workflow; use only when Decal launch mode succeeds. | +| `aquafir/InterfaceReplacement` (`InventoryUI`) | Decal client UI plugin and hot-reload network filter | .NET Framework 4.8 x86, Decal Adapter/Interop 2.9.8.2, UtilityBelt.Service, NSIS | Not applicable to ACE.Server. It targets Decal rather than ACE APIs. | Works without Thwarg, but not without Decal and UtilityBelt.Service | **Wrong mod type for ModsDirectory.** Treat as a Decal plugin, keep registry management external/read-only for now, and test with the optional Decal provider. | + +## Important distinctions + +- **ACE server mods** run inside ACE.Server through its existing Harmony loader. They can work with the Vanilla client and never need Decal. +- **Decal plugins** run inside the 32-bit client after Decal injection. They never belong in ACE's server Mods directory. +- **Chorizite plugins** use Chorizite's bootstrap and manifest system. They are neither ACE Harmony mods nor Decal registrations. +- **World content** is database/content SQL and must be validated and backed up as data, not treated as an arbitrary DLL mod. + +The launcher reports metadata and registration state, not binary compatibility. A successful rebuild plus a clean ACE.Server load and functional test is required before marking a server mod Compatible. diff --git a/docs/CUSTOM_WEENIES.md b/docs/CUSTOM_WEENIES.md new file mode 100644 index 0000000000..b0a54b4b64 --- /dev/null +++ b/docs/CUSTOM_WEENIES.md @@ -0,0 +1,45 @@ +# Importing AceForge custom weenies + +ACE Single Player can import custom ACE World objects saved as per-weenie SQL files by [AceForge](https://github.com/shemtar-90/AceForge/releases/tag/v0.3.36). This is intentionally separate from server DLL mods: a server mod changes code while a custom weenie becomes persistent data in `ace_world`. + +## Import workflow + +1. Create or edit the object in AceForge and save its SQL. AceForge normally writes files named like `850001 Example Item.sql` in its configured output folder. +2. Exit the game and stop the local server. +3. Open **Custom Weenies** in ACE Single Player. +4. Click **Choose AceForge Folder...** to scan a complete output folder recursively, or **Choose SQL Files...** for individual files. +5. Review the WCID, name, type, and validation notes. Invalid and unsupported files are skipped rather than executed. +6. Click **IMPORT VALID WEENIES**. If a WCID already exists, the launcher shows the existing records and requires a second confirmation before replacing them. +7. The launcher starts the private database, creates a complete `ace_world` SQL backup, and applies all accepted files together in a transaction. Start the game after the success message. + +Backups and import-history manifests are stored under: + +```text +%LOCALAPPDATA%\ACESinglePlayer\Backups\CustomWeenies +``` + +The **Open Backups** button opens the actual configured location. + +## Accepted SQL + +The importer accepts one weenie per file in ACE's normal idempotent form: + +```sql +DELETE FROM `weenie` WHERE `class_Id` = 850001; +INSERT INTO `weenie` (`class_Id`, `class_Name`, `type`, `last_Modified`) +VALUES (850001, 'Example Item', 6, '2026-07-16 00:00:00'); +``` + +It also accepts inserts into the current ACE `weenie_properties_*` tables when every row belongs to the same WCID. AceForge's `SET @parent_id = LAST_INSERT_ID()` emote pattern is supported. Other statements, other tables, executable comments, property rows targeting a different WCID, multi-weenie files, duplicate selected WCIDs, oversized files, and malformed SQL are rejected. + +This strict format is a safety boundary, not a promise that the content behaves correctly in game. The launcher cannot prove that a DID, spell, generator target, emote, or client-facing asset is valid. + +## Current limits + +- Automatic import requires **Private Database** mode. The launcher does not modify an external MariaDB installation automatically. +- This screen imports weenies only. AceForge quest, recipe, event, treasure, landscape, and client-DAT output is reported as unsupported. +- Imported data has no one-click uninstaller. Re-importing the same WCID replaces it; otherwise, removal or rollback requires a deliberate database migration or restoration from the pre-import backup. +- WCIDs must be chosen for this exact world. Do not overwrite an existing WCID unless replacing that object is intentional. +- Test new content on a disposable save before relying on it in a long-lived world. + +AceForge v0.3.36 is an independent MIT-licensed project. It is linked for convenience and is not bundled with ACE Single Player. diff --git a/docs/MOD_AUTHOR_GUIDE.md b/docs/MOD_AUTHOR_GUIDE.md new file mode 100644 index 0000000000..a2455ab3d1 --- /dev/null +++ b/docs/MOD_AUTHOR_GUIDE.md @@ -0,0 +1,168 @@ +# How to make and import an ACE Single Player mod + +ACE Single Player's Mod ZIP importer handles **server-code mods**: .NET DLLs that use ACE's `IHarmonyMod` interface. Per-weenie AceForge SQL uses the separate **Custom Weenies** screen described in [the Custom Weenies guide](CUSTOM_WEENIES.md). General world SQL packs and client DAT/landscape packs still need a different migration system and are not supported by the Mod ZIP importer. + +The easiest starting point is the small [HelloCommand port](https://github.com/titaniumweiner/ACE-SinglePlayer/tree/main/Source/ACE.SinglePlayer.Mods.HelloCommand). Copy that project, rename its assembly and namespace, and replace its command code with your own feature. + +## 1. Create the project + +Place the project beside `ACE.Server` under `Source` so it can compile against the exact server version bundled by the launcher: + +```text +Source/ + ACE.Server/ + ACE.SinglePlayer.Mods.MyMod/ + ACE.SinglePlayer.Mods.MyMod.csproj + Mod.cs + Meta.json + ace-mod.json + README.md +``` + +Use this project file, changing `MyMod` to a simple name without spaces: + +```xml + + + net10.0 + MyMod + MyMod + x64 + enable + enable + + + + + + + + + +``` + +## 2. Add the mod entry point + +ACE requires a public `Mod` class in a namespace that matches the assembly name. The class implements `IHarmonyMod`: + +```csharp +using ACE.Server.Mods; +using HarmonyLib; + +namespace MyMod; + +public sealed class Mod : IHarmonyMod +{ + private const string HarmonyId = "your-name.MyMod"; + private readonly Harmony harmony = new(HarmonyId); + + public void Initialize() + { + harmony.PatchAll(typeof(Mod).Assembly); + ModManager.Log("MyMod loaded."); + } + + public void Dispose() => harmony.UnpatchAll(HarmonyId); +} +``` + +Harmony patches must target methods that exist in this repository's current `ACE.Server` source. A DLL built for a different ACE release may compile against that release and still fail here because method names or parameters changed. + +## 3. Describe the installed mod + +Create `Meta.json`. The `Name` must match the assembly and folder name: + +```json +{ + "Name": "MyMod", + "Author": "Your name", + "Description": "A plain-language explanation of what the mod changes.", + "Version": "1.0.0", + "Priority": 0, + "Enabled": true, + "HotReload": false, + "RegisterCommands": false, + "CatalogId": "your-name.my-mod", + "TargetAceVersion": "ACE.Server 1.1 / .NET 10", + "DataImpact": "SettingsOnly", + "RemovalPolicy": "Safe" +} +``` + +Choose saved-data labels honestly: + +- `None`: no saved data changes. +- `SettingsOnly`: runtime behavior changes, but saves do not depend on the mod. +- `CharacterData`: characters or items can be changed permanently. +- `WorldData`: database/world content can depend on the mod. +- `ClientData`: matching client files are required. + +Use `Safe`, `ChangesRemain`, `BackupRequired`, or `DoNotRemove` for `RemovalPolicy`. The launcher treats unknown imported code conservatively even when these fields are present. + +## 4. Add the package manifest + +Create `ace-mod.json`: + +```json +{ + "formatVersion": 1, + "id": "your-name.my-mod", + "name": "MyMod", + "version": "1.0.0", + "folderName": "MyMod", + "entryAssembly": "MyMod.dll" +} +``` + +The package ID should be stable across updates. The name, folder, DLL, and `Meta.json` name must agree. + +## 5. Build a checksummed import ZIP + +From the repository root, run: + +```powershell +powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\package-mod.ps1 -ProjectDirectory .\Source\ACE.SinglePlayer.Mods.MyMod +``` + +The helper builds against the repository's ACE version and creates two files under `artifacts\Mods`: + +```text +your-name.my-mod-1.0.0.zip +your-name.my-mod-1.0.0.zip.sha256 +``` + +Internally, the ZIP has the format the launcher requires: + +```text +ace-mod.json +mod/ + MyMod.dll + Meta.json + README.md + LICENSE.txt +``` + +`Settings.json` is included automatically when the project has one. Keep the `.zip` and `.zip.sha256` files together when sharing the mod. + +## 6. Import it + +1. Stop the game and local server. +2. Open **Server Mods** in ACE Single Player. +3. Click **Import a Mod ZIP...** at the top. +4. Choose the `.zip`; the launcher finds and verifies the adjacent `.zip.sha256` file. +5. Read the warning and confirm. +6. Click **Play** to start ACE with the mod. + +The importer checks the checksum, manifest, required files, naming rules, archive paths, size limits, and atomic installation. It **cannot prove arbitrary DLL code is safe** and it cannot discover every conflict between two Harmony patches. + +## Testing checklist before sharing + +- Build using the exact ACE Single Player branch you name as supported. +- Start the private server with only this mod enabled and inspect the server log for load errors. +- Exercise the changed behavior in game, including failure and boundary cases. +- Test a clean install, disable/re-enable, server restart, and removal when removal is claimed safe. +- Test with every declared dependency and likely patch conflict. +- Back up `%LOCALAPPDATA%\ACESinglePlayer` before tests that change characters, items, or the world. +- Link to the complete source and license. Clearly label a package **Preview / not thoroughly tested** until real gameplay coverage exists. + +Do not put Asheron's Call client files or DAT files in a mod ZIP. They are proprietary and the server-mod importer is not designed to switch client data safely. diff --git a/docs/MOD_LIBRARY.md b/docs/MOD_LIBRARY.md new file mode 100644 index 0000000000..6477c075ab --- /dev/null +++ b/docs/MOD_LIBRARY.md @@ -0,0 +1,82 @@ +# Mod library and saved-game safety + +ACE Single Player includes a curated mod library. Open **Server Mods** from the launcher, select a mod, and read its description, requirements, compatibility status, and saved-game warning before changing anything. + +## Current status + +The library inventories all 22 samples in [aquafir/ACE.BaseMod](https://github.com/aquafir/ACE.BaseMod/tree/master/Samples). The upstream `Meta.json` files contain placeholder descriptions, so ACE Single Player's descriptions and safety policies are curated from the actual READMEs and source. + +`CriticalOverride` is curated for this repository's .NET 10 ACE build. `HelloCommand`, `SocietyTailoring`, and **Expanded Cast on Strike** are installable **Preview** ports. Their checksum-verified packages build and pass automated registration/Harmony-target, packaging, and launcher tests, but they have not received thorough in-game testing. OptimShi's `CustomClothingBase` v1.11 is also an installable Preview. Its unmodified official .NET 8 package is pinned by SHA-256 and passed an isolated load test in the exact .NET 10 ACE mod container, but custom clothing behavior and saved-world compatibility are not thoroughly tested. The launcher repeats those warnings before installation. The other samples remain visible but are marked **Needs port**, **Experimental**, or **Not recommended** until they compile against this exact ACE version and pass suitable tests. + +### Expanded Cast on Strike + +**Expanded Cast on Strike** implements the combat-filter change documented by [titaniumweiner/ACEUniqueWeenies](https://github.com/titaniumweiner/ACEUniqueWeenies). Stock ACE runs the equipped-item proc pass only for Aetheria. This mod instead accepts an equipped item when it has a proc, its `CloakWeaveProc` value is not `1`, and its self-target flag matches the current attack. It changes server behavior only; install the mod here, then import compatible repository SQL separately through **Custom Weenies**. + +Turning the mod off is safe for the database: custom items remain, but their non-Aetheria equipped procs stop firing after the server restarts. The port intentionally matches the documented filter exactly and is marked Preview because its proc frequency and interactions with every custom item have not received thorough in-game testing. The reviewed repository commit is [`75d7036c`](https://github.com/titaniumweiner/ACEUniqueWeenies/commit/75d7036c5b281e70b23aba60732aca681ee443d6). + +### CustomClothingBase + +`CustomClothingBase` loads custom ClothingTable entries from JSON without requiring client DAT updates. After installation, place custom `*.json` definitions directly in its `json` folder under the private ACE Mods directory. It can export entries with `@clothingbase-export` and reload with `@clear-clothing-cache`. Custom ClothingBase IDs can become dependencies of saved items or world content, so the launcher blocks removal and recommends a full backup before use. The [original source and examples](https://github.com/OptimShi/CustomClothingBase) remain the authoritative documentation. + +The upstream repository currently has no `LICENSE` file. The official v1.11 binaries are included unchanged based on OptimShi's permission reported by the ACE Single Player project maintainer, with attribution and links preserved in the package and release notices. + +## Importing a separately rebuilt package + +**Import a Mod ZIP...** at the top of the Mods window accepts an ACE Single Player mod ZIP after the game and server are stopped. Import is atomic and requires: + +- `ace-mod.json` at the ZIP root; +- mod files under the ZIP's `mod/` directory; +- `mod/Meta.json` and the manifest's entry DLL; +- a matching `.zip.sha256` checksum file beside the ZIP. + +The manifest format is: + +```json +{ + "formatVersion": 1, + "id": "author.mod-id", + "name": "Mod Name", + "version": "1.0.0", + "folderName": "ModAssemblyName", + "entryAssembly": "ModAssemblyName.dll" +} +``` + +Importing validates identity, checksum, archive paths, size, required files, and ACE's folder/assembly naming rule. It cannot prove that arbitrary DLL code is compatible or safe. In particular, downloading an original Aquafir .NET 8 source folder and putting it in a ZIP does **not** port it to this .NET 10 ACE build. A developer must rebuild and test that source first. The launcher gives an additional warning when a selected package corresponds to a catalog entry that still says **Needs port**. + +See [How to make and import an ACE Single Player mod](MOD_AUTHOR_GUIDE.md) for a working project skeleton, metadata examples, the package helper, testing checklist, and import steps. + +## Install, turn off, and remove are different operations + +- **Install** validates the package identity and checksum, rejects unsafe archive paths, stages all files, and only then moves the complete mod into the ACE `Mods` directory. +- **Turn off** changes the mod's `Enabled` value. It stops the mod code after the next server restart, but it does not erase changes already saved to characters, items, or the world. +- **Remove** moves a safe mod out of ACE into `%LOCALAPPDATA%\ACESinglePlayer\Runtime\RemovedMods`. The files are retained for recovery instead of being deleted. + +The launcher assigns every curated mod one of these saved-data policies: + +- **Safe**: no persistent game data depends on the mod. It can be turned off or moved to recovery. +- **Changes remain**: the mod can be turned off, but completed actions such as item tailoring, experience awards, or character moves are not undone. +- **Backup required**: turning it off may leave unusual but loadable character/item state. Back up `%LOCALAPPDATA%\ACESinglePlayer` first. +- **Do not remove**: characters, items, custom spells, balances, or world objects may require the mod. Removal is blocked until the package provides a tested cleanup migration. + +All mod changes require the game and local server to be stopped. This prevents ACE from loading a half-copied assembly or saving data during a change. + +## Dependencies and conflicts + +Each curated package can declare required mods and incompatible mods. Before installation, the launcher checks that requirements are installed and conflicts are absent. This is a preflight check, not a guarantee that two arbitrary Harmony patches are behaviorally compatible. A curated package is only marked ready after it has been rebuilt for this ACE version and tested with the packages it declares compatible. + +Package updates will eventually add ownership information for custom IDs, database migrations, and explicit upgrade/rollback steps. Unknown manually installed mods are shown conservatively because the launcher cannot infer their saved-data behavior from a DLL. + +## New items, dungeons, towns, and landscape + +These are not all the same kind of mod: + +1. A server mod changes ACE behavior. It can reuse objects and art already known to the original client without changing client DAT files. +2. A world-content pack adds or changes weenies, encounters, quests, recipes, landblock spawns, and other database content. It needs versioned SQL/data migrations, ownership of custom ID ranges, and a database backup/rollback plan. +3. A client-content pack is required for genuinely new models, textures, icons, sounds, terrain, buildings, or landblock geometry. The client and server must use matching DAT content. These packs need checksums, a writable copied client, and an atomic way to switch the complete matched DAT set. + +A new town or dungeon can often use existing client assets with server/world changes, but new terrain or art requires matched client DAT changes. For that reason, future **World Packs** will be managed separately from ordinary DLL mods and will not be presented as a harmless on/off checkbox. + +## Backups + +Private-world characters and world state live under `%LOCALAPPDATA%\ACESinglePlayer\Database`. Until automatic per-mod snapshots are implemented, close the launcher and copy the entire `%LOCALAPPDATA%\ACESinglePlayer` folder before enabling a mod labeled **Backup required** or **Do not remove**. diff --git a/docs/SINGLE_PLAYER_ARCHITECTURE.md b/docs/SINGLE_PLAYER_ARCHITECTURE.md new file mode 100644 index 0000000000..d486bd6bd7 --- /dev/null +++ b/docs/SINGLE_PLAYER_ARCHITECTURE.md @@ -0,0 +1,56 @@ +# ACE Single Player architecture + +## Process boundary + +`ACE.SinglePlayer.exe` is a WinForms orchestrator, not an offline rewrite of ACE. It owns and monitors these optional child processes: + +1. The release's pinned `Dependencies\MariaDB\bin\mariadbd.exe` in automatic private-database mode. +2. The published `ACE.Server.exe`, started with `--config ` and `--ready-file `. +3. `acclient.exe`, launched directly in Vanilla mode or through the package's isolated x86 bridge to an installed ThwargLauncher injector. + +The original server behavior is unchanged when the two new arguments are omitted. The normal cross-platform `Source/ACE.sln` is unchanged; Windows packaging uses `Source/ACE.SinglePlayer.slnx`. + +The release package publishes the launcher and server self-contained for Windows x64. This avoids a first-run dependency on Windows' .NET runtime installer. The isolated Decal helper remains self-contained Windows x86. New setups place operational Runtime files and the private MariaDB data under `%LOCALAPPDATA%\ACESinglePlayer` so cloud-sync tools do not lock database files or copy short-lived configuration credentials. + +## Configuration and readiness + +The launcher references `ACE.Common` and serializes the current `MasterConfiguration`. It does not edit JavaScript with regular expressions. Defaults are `127.0.0.1`, port 9000, four sessions, automatic account creation, no world precache, automatic ACE schema updates, and no automatic world-database download/update. + +At startup ACE.Server deletes a stale requested ready file. After all managers and commands are initialized and `WorldManager.Open` has made logins possible, it atomically writes process ID, world name, host, port, and UTC timestamp. Normal process exit removes the file. The launcher requires a matching PID, loopback host, expected port, a live process, and an active local UDP listener. ACE uses UDP 9000/9001, so a TCP probe would be incorrect. + +## Persistence and security + +The same account name and randomly generated password are passed on every launch. Windows DPAPI protects stored account and database passwords for the current user. Settings and generated server configuration receive a current-user-only Windows ACL. The database credential must be present in the generated ACE Config.js while the server runs; that file is ignored by Git and excluded from packages. + +Process arguments are added individually. Arguments and configuration content are never logged. Process ownership records contain PID, start time, and executable path; shutdown never selects a process by name alone. ACE's supported `stop-now` console command is used before a bounded forced stop. + +## Databases + +`IDatabaseRuntime` separates an advanced external connection from the default private MariaDB. Validation checks authentication, all three schema names, core tables (`account`, `character`, and `weenie`), and the required Human world record (`weenie.class_Id = 1`). `DatabaseBootstrapper` uses the packaged ACE authentication/shard base SQL and the pinned complete world SQL under `Dependencies\World`. It rejects schema-only world data. External databases remain conservative and are never rewritten when incomplete; an isolated private database with empty world tables can be repaired by streaming the bundled full dump through MariaDB's local import client. + +Private mode prefers the pinned bundled `mariadbd.exe` and requires its matching initializer and import client. First initialization occurs in a unique staging directory and is promoted to `%LOCALAPPDATA%\ACESinglePlayer\Database` only after MariaDB's system tables exist. Keeping database data outside the release folder prevents cloud-sync locks and allows application upgrades without replacing characters. Existing `Runtime\Database` data is copied once to the local location and retained at the old path as a backup. The launcher chooses an available local port, passes `--no-defaults`, binds to `127.0.0.1`, disables remote-root creation, and never opens a firewall port. + +Two random passwords are generated: a private administrator credential used only for lifecycle/provisioning and a dedicated `ace_singleplayer` application credential granted only over the three configured ACE schemas. Both are protected by Windows DPAPI. MariaDB output is redacted against both secrets. The launcher sends MariaDB's supported `SHUTDOWN` statement before forcing only its own child after a timeout. A version-1 loopback `root` configuration migrates into the private setup flow; non-root external configurations remain external. + +## Client providers + +`ServerDatProvisioner` copies the four user-supplied DAT files into `%LOCALAPPDATA%\ACESinglePlayer\ServerData` before ACE.Server starts. File size and modification time are used to refresh only changed files, and every replacement is staged before it becomes active. ACE.Server keeps its DAT streams open for performance, so this private copy prevents it from locking the original client files. The proprietary DATs remain local and are never placed in the release archive or repository. + +`IClientLaunchProvider` isolates client choices. `DirectClientLaunchProvider` uses `ProcessStartInfo.ArgumentList` for `-a`, `-v`, and `-h`. Vanilla mode has no Decal, Thwarg, Chorizite, or injection dependency. + +`DecalClientLaunchProvider` detects `SOFTWARE\Decal\Agent\AgentPath` and `SOFTWARE\Thwargle Games\ThwargLauncher\Path` in 32/64-bit HKLM/HKCU views, then validates Decal's `Inject.dll` and ThwargLauncher's `injector.dll`. A separately built, self-contained x86 bridge loads the installed Thwarg native launcher and asks it to start `acclient.exe` with Decal's `DecalStartup` entry point. The account, protected password, and loopback server address are supplied automatically by ACE Single Player. No Decal, ThwargLauncher, ThwargFilter, or third-party injector binary is bundled or copied. + +The main window exposes this choice as a **Use Decal** checkbox beside **PLAY**. Checking it persists `ClientLaunchMode.Decal`; unchecking it persists `ClientLaunchMode.Vanilla`. The checkbox is disabled while the game or server is running and when either required installation is missing. Database and path setup contain no separate client-mode dropdown. + +## Mods + +ACE server mods remain owned by the existing `ModManager`, `ModContainer`, `IHarmonyMod`, and `Meta.json` flow. The launcher only inventories metadata and safely changes `Enabled` while preserving unknown JSON fields. Editing is disabled while the server runs. + +Decal registry inventory is read-only and clearly typed as client-side. Chorizite and world-content providers are empty future boundaries. A DLL's presence never establishes compatibility; ACE.Server output remains the source for load errors. + +## Known Phase-1 limitations + +- A real client and all four proprietary DAT files are user-supplied. +- The client and live character-selection screen were not available for end-to-end testing here. +- Decal mode requires separate working Decal and ThwargLauncher installations. Client creation through the installed Thwarg injector is verified; individual plugins remain outside this project's control. +- Chorizite launch and package installation are reserved for future work. diff --git a/docs/SINGLE_PLAYER_BUILD_AND_TEST.md b/docs/SINGLE_PLAYER_BUILD_AND_TEST.md new file mode 100644 index 0000000000..0065d90909 --- /dev/null +++ b/docs/SINGLE_PLAYER_BUILD_AND_TEST.md @@ -0,0 +1,33 @@ +# ACE Single Player build and test + +Building requires the .NET 10 SDK on Windows. The packaging script publishes the launcher and server self-contained for Windows x64 and the optional Decal helper self-contained for Windows x86. Users of the finished package do not need a separate .NET installation. + +Run these commands from the repository root: + +```powershell +dotnet build .\Source\ACE.SinglePlayer.slnx -c Release +dotnet test .\Source\ACE.SinglePlayer.Tests\ACE.SinglePlayer.Tests.csproj -c Release +dotnet test .\Source\ACE.Server.Tests\ACE.Server.Tests.csproj -c Release --filter "FullyQualifiedName~SinglePlayerStartupTests" +powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\scripts\publish-singleplayer.ps1 -Configuration Release +``` + +The normal launcher test run does not require MariaDB. To additionally exercise real private initialization, isolated-user provisioning, schema bootstrap, validation, and shutdown against an installed MariaDB distribution: + +```powershell +$env:ACE_TEST_MARIADBD = 'C:\Program Files\MariaDB 11.2\bin\mariadbd.exe' +dotnet test .\Source\ACE.SinglePlayer.Tests\ACE.SinglePlayer.Tests.csproj -c Release --filter 'FullyQualifiedName~PrivateDatabaseTests' +``` + +The integration test uses a unique temporary data directory and does not modify the Windows MariaDB service. Set `ACE_TEST_WORLD_SQL` to an extracted official `ACE-World-Database-*.sql` file to exercise the full populated-world import and empty-world repair path; otherwise the test uses a minimal populated fixture. + +The clean package is written to `%LOCALAPPDATA%\ACE-SinglePlayer-Build\ACE-SinglePlayer`, with a shareable `ACE-SinglePlayer.zip` archive beside it. Keeping release output outside the checkout prevents cloud-sync software from locking it. `-OutputDirectory` may select another safe directory whose final name is `ACE-SinglePlayer`; that directory is replaced on every build. By default, the packaging script downloads hash-pinned official MariaDB `12.3.2` and ACE World `v0.9.294` archives into `%LOCALAPPDATA%\ACE-SinglePlayer-BuildCache`, verifies both SHA-256 hashes, and bundles their extracted runtime files. Use `-SkipBundledDependencies` only for a small developer package that is not suitable as the public ready-to-play release. + +Publishing stages final output, compiler output, and intermediate files under the current user's temporary directory. This avoids a .NET 10 publish-transform bug when the repository path contains an apostrophe and prevents OneDrive from locking build files when a developer's source checkout is cloud-synced. It removes debug symbols that can contain build-machine paths, then rejects generated Config.js, settings, logs, private database data, proprietary client/DAT files, unpinned MariaDB servers, or personal user-profile paths. `BUNDLE-MANIFEST.json`, `THIRD_PARTY_NOTICES.md`, and the `Licenses` directory record the redistributed components. + +The normal ACE solution remains separate and can be verified with: + +```powershell +dotnet build .\Source\ACE.sln -c Release +``` + +Upstream `ACE.DatLoader.Tests` require proprietary DAT files at their hardcoded test location. `ACE.Database.Tests` and `ACE.Server.Tests.StartupTests` require a configured live ACE database. Those environment-dependent suites are not prerequisites for the launcher logic tests and must not be reported as passing unless their external data and database are actually available. diff --git a/docs/SINGLE_PLAYER_FIRST_RUN.md b/docs/SINGLE_PLAYER_FIRST_RUN.md new file mode 100644 index 0000000000..dea9e2d831 --- /dev/null +++ b/docs/SINGLE_PLAYER_FIRST_RUN.md @@ -0,0 +1,41 @@ +# ACE Single Player: first run + +ACE Single Player keeps ACE.Server and a bundled MariaDB instance as private local processes and launches the original client directly. The release includes the server, database runtime, complete world, .NET, and curated server mods. It does not include Asheron's Call, proprietary DAT files, Decal, or ThwargLauncher. + +## Before opening the launcher + +1. Put the package in a folder your Windows user can write to, such as `C:\Games\ACE-SinglePlayer`. Do not install it under `Program Files` unless you deliberately choose a different writable Runtime directory. The package is self-contained and does not require a separate .NET installation. +2. Have an existing `acclient.exe` installation. Its directory must contain: + - `client_cell_1.dat` + - `client_portal.dat` + - `client_local_English.dat` + - `client_highres.dat` + Keep the complete client installation together in one writable folder outside OneDrive and `Program Files`. +3. Extract the complete release. Do not move or delete its `Server` or `Dependencies` folders. + +## First start + +Double-click `ACE.SinglePlayer.exe`. + +1. The launcher checks common AC locations automatically. If it cannot find a complete client, select the folder containing `acclient.exe` and all four DAT files. +2. The main launcher opens immediately. Check **Use Decal** when a separate working Decal and ThwargLauncher installation is detected; otherwise leave it unchecked. +3. Click **PLAY**. The first run creates the private database and imports the bundled world, which can take several minutes. Existing complete databases are never overwritten. Later launches skip the import. + +Before the server starts for the first time, the launcher also makes a private copy of the four DAT files under `%LOCALAPPDATA%\ACESinglePlayer\ServerData`. This needs roughly the same amount of free disk space as the four original DATs and prevents ACE.Server from locking the files used by the game client. Later launches reuse the copy unless the originals change. + +The private database lives at `%LOCALAPPDATA%\ACESinglePlayer\Database`. The launcher uses the bundled `Dependencies\MariaDB` programs, initializes into a staging directory, and moves it into place only after initialization succeeds. It creates a dedicated `ace_singleplayer` database user; generated administrator and application passwords are protected with Windows DPAPI and are not displayed or logged. Any separately installed MariaDB service and its `root` account are not changed. + +To use a separately administered server instead, choose **Existing MariaDB/MySQL (advanced)** and enter its host, port, username, password, and database names. Use **Test Connection** and **Initialize Missing Databases** as needed. + +## Normal play + +1. Double-click `ACE.SinglePlayer.exe`. +2. Click **PLAY** once. +3. The launcher starts its private database when selected, checks the persistent schemas, writes a private loopback-only config, starts ACE.Server, waits for an atomic ready signal and the UDP listener, and launches `acclient.exe`. +4. Use the normal character-selection screen. ACE automatically creates the persistent local account on its first successful login. + +Repeated Play clicks do not create duplicate processes. By default, closing the game gracefully stops the launcher-owned server. The Stop button requests ACE's `stop-now` console command and forces termination only if that verified child does not exit within 20 seconds. + +Characters live in `%LOCALAPPDATA%\ACESinglePlayer\Database\ace_shard` when private mode is selected, not in the launcher executable. Preserve the entire `%LOCALAPPDATA%\ACESinglePlayer` directory across upgrades and back it up before replacing or resetting anything. Settings live at `%LOCALAPPDATA%\ACESinglePlayer\settings.json`; secrets there are DPAPI-protected. The generated Runtime `Config.js` necessarily contains the database password for ACE.Server and is restricted to the current Windows user. + +Use **Open Logs** for startup errors. The launcher never writes account or database passwords to its log. The real client and a live ACE world database are required for an end-to-end character-screen test. diff --git a/docs/SINGLE_PLAYER_INSTALL.md b/docs/SINGLE_PLAYER_INSTALL.md new file mode 100644 index 0000000000..801374df2f --- /dev/null +++ b/docs/SINGLE_PLAYER_INSTALL.md @@ -0,0 +1,79 @@ +# Installing ACE Single Player + +ACE Single Player runs the ACE server, a private MariaDB database, and the original Asheron's Call client together on one Windows computer. The portable release includes ACE.Server, MariaDB, the complete ACE World database, and .NET. It does not include the proprietary game client or DAT files, Decal, or ThwargLauncher. + +## What you need + +- A 64-bit Windows 10 or Windows 11 computer. +- A working Asheron's Call client. A typical installation is `C:\Turbine\Asheron's Call`. +- These files together in the client directory: + - `acclient.exe` + - `client_cell_1.dat` + - `client_portal.dat` + - `client_local_English.dat` + - `client_highres.dat` +- Keep the complete client installation in one writable folder outside OneDrive and `Program Files`, such as `C:\Games\AsheronsCall` or `C:\Turbine\Asheron's Call`. +- Optional for Decal mode: working Decal and ThwargLauncher installations. ACE Single Player uses ThwargLauncher's installed `injector.dll` but does not copy or redistribute it. + +## Download and extract + +1. Open this project's **Releases** page and download the latest `ACE-SinglePlayer-*.zip` file. Do not download GitHub's automatically generated **Source code** archives unless you intend to build the program yourself. +2. Create a writable folder outside OneDrive and `Program Files`, such as `C:\Games\ACE-SinglePlayer`. +3. Extract the entire ZIP into that folder. `ACE.SinglePlayer.exe`, `Server`, and `Dependencies` must remain together. + +The release is self-contained. If Windows says that .NET must be installed, the source archive or an incomplete build was downloaded instead of the release ZIP. + +## First-time setup + +1. Double-click `ACE.SinglePlayer.exe`. +2. If prompted, select the folder containing `acclient.exe` and all four DAT files. Standard `C:\Turbine\Asheron's Call` installations are detected automatically. +3. On the main launcher, check **Use Decal** beside **PLAY** when both Decal and ThwargLauncher are installed. Leave it unchecked for Vanilla. +4. Click **PLAY** once. The launcher generates protected credentials, initializes its bundled private MariaDB, imports the bundled world, starts ACE.Server, waits for logins to open, and launches the client. The initial import can take several minutes and is performed only once. + +The first successful login creates the local ACE account automatically. Closing the game normally stops the launcher-owned server and database. + +## Where your files are kept + +- Settings: `%LOCALAPPDATA%\ACESinglePlayer\settings.json` +- Private database and characters: `%LOCALAPPDATA%\ACESinglePlayer\Database` +- Private server DAT copy: `%LOCALAPPDATA%\ACESinglePlayer\ServerData` +- Generated runtime configuration and logs: `%LOCALAPPDATA%\ACESinglePlayer\Runtime` + +Passwords are generated automatically and protected for the current Windows user. The launcher does not change the MariaDB service's `root` account. + +Back up the entire `%LOCALAPPDATA%\ACESinglePlayer` folder before reinstalling Windows, resetting the private database, or making major world changes. Never publish that folder or include it in a GitHub issue. + +## Common problems + +### Nothing opens after clicking PLAY + +Use **Open Logs** in the launcher. Confirm that the entire release was extracted, the `Dependencies` folder is present, and no other program is using the configured local port. + +### MariaDB says access denied for root + +The portable release does not use an installed MariaDB service or its root password. Re-extract the complete release and use **Automatic private database** in advanced Settings. + +### The client cannot open the data files + +Confirm that all four required DAT files are beside `acclient.exe`. The client folder must be writable and outside OneDrive or `Program Files`. Current releases automatically give ACE.Server a separate copy under `%LOCALAPPDATA%\ACESinglePlayer\ServerData`; the launcher log should show different client and server DAT directories. If an older release shows the same directory for both, install the current release. + +### Decal mode is unavailable + +Install and configure both Decal and ThwargLauncher normally, then reopen ACE Single Player. The **Use Decal** checkbox becomes available when both installations are detected. Decal mode deliberately uses their installed files and never bundles them. + +### The world starts and then immediately shuts down + +Use **Open Logs** and verify that `Dependencies\World\ACE-World-Database-v0.9.294.sql` exists. Re-extract the complete release if it is missing. + +## Updating + +1. Stop the game and launcher. +2. Back up `%LOCALAPPDATA%\ACESinglePlayer`. +3. Extract the new release to a new writable folder. +4. Start the new `ACE.SinglePlayer.exe`. Existing local settings and the private database are retained under `%LOCALAPPDATA%`. + +Do not replace the original client DAT files with files from an untrusted package. Future managed content-pack support should use isolated copies with checksums, backups, and rollback. + +## Building from source + +Developers need the .NET 10 SDK and should follow [ACE Single Player build and test](SINGLE_PLAYER_BUILD_AND_TEST.md). Players should use the prebuilt release ZIP instead. diff --git a/docs/SINGLE_PLAYER_ROADMAP.md b/docs/SINGLE_PLAYER_ROADMAP.md new file mode 100644 index 0000000000..d12b56e828 --- /dev/null +++ b/docs/SINGLE_PLAYER_ROADMAP.md @@ -0,0 +1,13 @@ +# ACE Single Player roadmap + +Phase 1 deliberately keeps a narrow, dependable loopback server and direct-client path. Future work should be delivered without weakening Vanilla launch: + +- Optional checksum-verified acquisition of a supported MariaDB distribution so private mode does not require a prior MariaDB installation. +- Signed or checksum-verified mod package installation with provenance and rollback. +- Port selected aquafir ACE server mods to current ACE/.NET after source review and automated compatibility tests. +- Back up and restore authentication, character/shard, world, settings, and managed database data. +- Import an existing ACE account without changing its character ownership. +- Optional last-character selection only if a safe, maintainable approach is found that does not depend on Thwarg, fragile memory offsets, or general UI automation. +- A signed installer that preserves Runtime and database data across upgrades. +- Automatic launcher/server updates with checksum validation, backups, rollback, and no silent client/DAT replacement. +- Implement and test the reserved Chorizite client-launch provider separately from Vanilla and Decal. diff --git a/docs/THIRD_PARTY_NOTICES.md b/docs/THIRD_PARTY_NOTICES.md new file mode 100644 index 0000000000..927a44ad60 --- /dev/null +++ b/docs/THIRD_PARTY_NOTICES.md @@ -0,0 +1,39 @@ +# Third-party components in the portable release + +The ready-to-play ACE Single Player release bundles these unmodified upstream components so players do not need to install a database server or import the world manually. + +## ACEmulator ACE + +- Upstream: +- Pinned upstream commit: `650c5b75ae909957feaf58db320e46be16502653` +- Server build: `1.77.4782` +- License: GNU Affero General Public License version 3 +- Corresponding source, including the single-player modifications: + +## ACE World Database + +- Upstream: +- Release: `v0.9.294` +- Asset: `ACE-World-Database-v0.9.294.sql.zip` +- SHA-256: `aa8275a2fd8edd8c2b95092d2407ece4616ba7b8d7eab1405719bbbfa80c8f89` +- License: GNU Affero General Public License version 3 +- Source: + +## MariaDB Community Server + +- Upstream: +- Release: `12.3.2 LTS`, official Windows x64 ZIP +- SHA-256: `67347c129eb9c5923d002ea34fbfa27c60eb95d36dd73b85af2651cdeceecac5` +- License: GNU General Public License version 2 +- Source: + +## CustomClothingBase + +- Author: OptimShi +- Upstream and source: +- Official release: `v1.11` +- Official asset SHA-256: `505dcb951bdba9ec7788b2f947f3b8d6a7638e06c43000bd38beb129689873a6` +- Packaging: upstream DLLs are unmodified; ACE Single Player adds only its install manifest, metadata, and instructions +- License notice: the upstream repository currently contains no `LICENSE` file. Redistribution is included based on OptimShi's permission reported by the ACE Single Player project maintainer. OptimShi retains ownership of the upstream mod. + +The applicable published license texts are included in the release's `Licenses` directory. Asheron's Call itself, `acclient.exe`, client DAT files, Decal, and ThwargLauncher are not included. diff --git a/packaging/CustomClothingBase/Meta.json b/packaging/CustomClothingBase/Meta.json new file mode 100644 index 0000000000..86876b862a --- /dev/null +++ b/packaging/CustomClothingBase/Meta.json @@ -0,0 +1,14 @@ +{ + "Name": "CustomClothingBase", + "Author": "OptimShi", + "Description": "Loads custom server-side ClothingTable entries from JSON files.", + "Version": "1.11-upstream", + "Priority": 0, + "Enabled": true, + "HotReload": true, + "RegisterCommands": true, + "CatalogId": "optimshi.custom-clothing-base", + "TargetAceVersion": "Official v1.11 binary load-tested with ACE.Server 1.1 / .NET 10", + "DataImpact": "WorldData", + "RemovalPolicy": "DoNotRemove" +} diff --git a/packaging/CustomClothingBase/README.md b/packaging/CustomClothingBase/README.md new file mode 100644 index 0000000000..d794148f17 --- /dev/null +++ b/packaging/CustomClothingBase/README.md @@ -0,0 +1,22 @@ +# CustomClothingBase v1.11 for ACE Single Player + +CustomClothingBase is authored by OptimShi. It loads custom ClothingTable definitions from JSON, allowing server-side clothing colors and appearance combinations without requiring players to install client DAT updates. + +This package contains OptimShi's **unmodified official v1.11 DLLs** plus ACE Single Player packaging metadata and instructions. The official release was SHA-256 verified and passed an isolated load test using the exact ACE.Server bundled with ACE Single Player. + +## Use + +Place custom `*.json` clothing definitions directly in this installed mod's `json` folder. With `WatchContent` enabled in `Settings.json`, saving a JSON file clears the clothing cache automatically. You can also use: + +- `@clothingbase-export ` to export an existing entry; +- `@clear-clothing-cache` to force a reload while testing. + +OptimShi's source and examples: + +Official v1.11 release: + +## Preview and saved-world warning + +The mod loads successfully, but it has not been thoroughly tested in ACE Single Player with every JSON shape, clothing combination, cache-reload path, or saved world. Back up `%LOCALAPPDATA%\ACESinglePlayer` before using it. Do not disable or remove it while world content or saved items refer to custom ClothingBase IDs. + +The upstream repository currently has no `LICENSE` file. This redistribution is included based on permission from OptimShi reported by the ACE Single Player project maintainer. OptimShi retains ownership of the upstream mod. diff --git a/packaging/CustomClothingBase/ace-mod.json b/packaging/CustomClothingBase/ace-mod.json new file mode 100644 index 0000000000..c66dbf4253 --- /dev/null +++ b/packaging/CustomClothingBase/ace-mod.json @@ -0,0 +1,8 @@ +{ + "formatVersion": 1, + "id": "optimshi.custom-clothing-base", + "name": "CustomClothingBase", + "version": "1.11-upstream", + "folderName": "CustomClothingBase", + "entryAssembly": "CustomClothingBase.dll" +} diff --git a/packaging/CustomClothingBase/json/README.txt b/packaging/CustomClothingBase/json/README.txt new file mode 100644 index 0000000000..f4d1ba3a6f --- /dev/null +++ b/packaging/CustomClothingBase/json/README.txt @@ -0,0 +1,7 @@ +Place custom ClothingTable *.json files directly in this folder. + +Source, documentation, and examples: +https://github.com/OptimShi/CustomClothingBase + +Back up your ACE Single Player world before testing custom IDs. Do not remove +the mod while saved items or world content still refer to those IDs. diff --git a/scripts/package-mod.ps1 b/scripts/package-mod.ps1 new file mode 100644 index 0000000000..950ac7dc84 --- /dev/null +++ b/scripts/package-mod.ps1 @@ -0,0 +1,91 @@ +param( + [Parameter(Mandatory = $true)] + [string] $ProjectDirectory, + [string] $OutputDirectory, + [ValidateSet("Debug", "Release")] + [string] $Configuration = "Release" +) + +$ErrorActionPreference = "Stop" +$repoRoot = [IO.Path]::GetFullPath((Split-Path $PSScriptRoot -Parent)) +$ProjectDirectory = (Resolve-Path -LiteralPath $ProjectDirectory).Path +if ([string]::IsNullOrWhiteSpace($OutputDirectory)) { + $OutputDirectory = Join-Path $repoRoot "artifacts\Mods" +} +$OutputDirectory = [IO.Path]::GetFullPath($OutputDirectory) + +$projects = @(Get-ChildItem -LiteralPath $ProjectDirectory -File -Filter "*.csproj") +if ($projects.Count -ne 1) { + throw "The mod directory must contain exactly one .csproj file." +} + +$manifestPath = Join-Path $ProjectDirectory "ace-mod.json" +$metadataPath = Join-Path $ProjectDirectory "Meta.json" +if (-not (Test-Path -LiteralPath $manifestPath) -or -not (Test-Path -LiteralPath $metadataPath)) { + throw "The mod directory must contain ace-mod.json and Meta.json." +} +$manifest = Get-Content -LiteralPath $manifestPath -Raw | ConvertFrom-Json +if ($manifest.formatVersion -ne 1) { + throw "Only ace-mod.json formatVersion 1 is supported." +} +foreach ($field in @("id", "name", "version", "folderName", "entryAssembly")) { + if ([string]::IsNullOrWhiteSpace($manifest.$field)) { + throw "ace-mod.json is missing $field." + } +} +if ($manifest.folderName -match '[\\/:*?"<>|]' -or $manifest.entryAssembly -match '[\\/:*?"<>|]' -or + -not $manifest.entryAssembly.EndsWith(".dll", [StringComparison]::OrdinalIgnoreCase) -or + -not [string]::Equals([IO.Path]::GetFileNameWithoutExtension($manifest.entryAssembly), $manifest.folderName, [StringComparison]::OrdinalIgnoreCase)) { + throw "The folderName and entryAssembly are invalid or do not match." +} + +$localDotnet = Join-Path $repoRoot ".dotnet\dotnet.exe" +$dotnet = if (Test-Path -LiteralPath $localDotnet) { $localDotnet } else { (Get-Command dotnet -ErrorAction Stop).Source } +$tempRoot = [IO.Path]::GetFullPath([IO.Path]::GetTempPath()) +$stage = Join-Path $tempRoot "ACE-Mod-Package-$PID-$($manifest.folderName)" +$build = Join-Path $stage "Build" +$artifacts = Join-Path $stage "Artifacts" +$package = Join-Path $stage "Package" +$modDirectory = Join-Path $package "mod" + +try { + if (Test-Path -LiteralPath $stage) { + Remove-Item -LiteralPath $stage -Recurse -Force + } + New-Item -ItemType Directory -Force -Path $build, $artifacts, $modDirectory, $OutputDirectory | Out-Null + + & $dotnet build $projects[0].FullName -c $Configuration --artifacts-path $artifacts -o $build + if ($LASTEXITCODE -ne 0) { + throw "The mod did not build successfully." + } + + $entryAssembly = Join-Path $build $manifest.entryAssembly + if (-not (Test-Path -LiteralPath $entryAssembly)) { + throw "The build did not produce $($manifest.entryAssembly)." + } + + Copy-Item -LiteralPath $manifestPath -Destination $package + Copy-Item -LiteralPath $entryAssembly, $metadataPath -Destination $modDirectory + foreach ($optionalName in @("Settings.json", "README.md")) { + $optionalPath = Join-Path $ProjectDirectory $optionalName + if (Test-Path -LiteralPath $optionalPath) { + Copy-Item -LiteralPath $optionalPath -Destination $modDirectory + } + } + Copy-Item -LiteralPath (Join-Path $repoRoot "LICENSE") -Destination (Join-Path $modDirectory "LICENSE.txt") + + $archive = Join-Path $OutputDirectory "$($manifest.id)-$($manifest.version).zip" + if (Test-Path -LiteralPath $archive) { + Remove-Item -LiteralPath $archive -Force + } + Compress-Archive -Path (Join-Path $package "*") -DestinationPath $archive -CompressionLevel Optimal + (Get-FileHash -LiteralPath $archive -Algorithm SHA256).Hash | Set-Content -LiteralPath ($archive + ".sha256") -Encoding ascii + + Write-Host "Mod package: $archive" + Write-Host "Checksum: $archive.sha256" +} +finally { + if (Test-Path -LiteralPath $stage) { + Remove-Item -LiteralPath $stage -Recurse -Force + } +} diff --git a/scripts/publish-singleplayer.ps1 b/scripts/publish-singleplayer.ps1 new file mode 100644 index 0000000000..b307ab2386 --- /dev/null +++ b/scripts/publish-singleplayer.ps1 @@ -0,0 +1,289 @@ +param( + [ValidateSet("Debug", "Release")] + [string] $Configuration = "Release", + [string] $OutputDirectory, + [switch] $SkipBundledDependencies +) + +$ErrorActionPreference = "Stop" +$repoRoot = [IO.Path]::GetFullPath((Split-Path $PSScriptRoot -Parent)) +if ([string]::IsNullOrWhiteSpace($OutputDirectory)) { + $localBuildRoot = if ([string]::IsNullOrWhiteSpace($env:LOCALAPPDATA)) { + Join-Path ([IO.Path]::GetTempPath()) "ACE-SinglePlayer-Build" + } else { + Join-Path $env:LOCALAPPDATA "ACE-SinglePlayer-Build" + } + $OutputDirectory = Join-Path $localBuildRoot "ACE-SinglePlayer" +} +$OutputDirectory = [IO.Path]::GetFullPath($OutputDirectory) +$outputPrefix = $OutputDirectory.TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar) + [IO.Path]::DirectorySeparatorChar +if ((Split-Path $OutputDirectory -Leaf) -ine "ACE-SinglePlayer" -or + [string]::Equals($OutputDirectory, [IO.Path]::GetPathRoot($OutputDirectory), [StringComparison]::OrdinalIgnoreCase)) { + throw "OutputDirectory must name a non-root directory called ACE-SinglePlayer. The directory is replaced on every build." +} + +$localDotnet = Join-Path $repoRoot ".dotnet\dotnet.exe" +$dotnet = if (Test-Path -LiteralPath $localDotnet) { + $localDotnet +} else { + (Get-Command dotnet -ErrorAction Stop).Source +} + +# Microsoft.NET.Publish.targets uses a single-quoted item transform for PublishDir. +# Staging outside a repository path containing an apostrophe avoids MSB3094 on .NET 10. +$tempRoot = [IO.Path]::GetFullPath([IO.Path]::GetTempPath()) +$stage = [IO.Path]::GetFullPath((Join-Path $tempRoot "ACE-SinglePlayer-Publish-$PID")) +$tempPrefix = $tempRoot.TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar) + [IO.Path]::DirectorySeparatorChar +if (-not $stage.StartsWith($tempPrefix, [StringComparison]::OrdinalIgnoreCase)) { + throw "Temporary staging directory must remain under $tempRoot" +} +$launcherPublish = Join-Path $stage "Launcher" +$serverPublish = Join-Path $stage "Server" +$decalHostPublish = Join-Path $stage "DecalHost" +$customClothingExtract = Join-Path $stage "CustomClothingBaseExtract" +$customClothingPackage = Join-Path $stage "CustomClothingBasePackage" +$mariaDbExtract = Join-Path $stage "MariaDB" +$worldExtract = Join-Path $stage "World" +$buildArtifacts = Join-Path $stage "BuildArtifacts" + +$mariaDbVersion = "12.3.2" +$mariaDbArchiveName = "mariadb-$mariaDbVersion-winx64.zip" +$mariaDbUri = "https://archive.mariadb.org/mariadb-$mariaDbVersion/winx64-packages/$mariaDbArchiveName" +$mariaDbSha256 = "67347c129eb9c5923d002ea34fbfa27c60eb95d36dd73b85af2651cdeceecac5" +$worldVersion = "0.9.294" +$worldArchiveName = "ACE-World-Database-v$worldVersion.sql.zip" +$worldUri = "https://github.com/ACEmulator/ACE-World-16PY-Patches/releases/download/v$worldVersion/$worldArchiveName" +$worldSha256 = "aa8275a2fd8edd8c2b95092d2407ece4616ba7b8d7eab1405719bbbfa80c8f89" +$customClothingVersion = "1.11" +$customClothingArchiveName = "CustomClothingBase-v$customClothingVersion.zip" +$customClothingUri = "https://github.com/OptimShi/CustomClothingBase/releases/download/v$customClothingVersion/CustomClothingBase.zip" +$customClothingSha256 = "505dcb951bdba9ec7788b2f947f3b8d6a7638e06c43000bd38beb129689873a6" +$cacheRoot = if ([string]::IsNullOrWhiteSpace($env:LOCALAPPDATA)) { + Join-Path $tempRoot "ACE-SinglePlayer-BuildCache" +} else { + Join-Path $env:LOCALAPPDATA "ACE-SinglePlayer-BuildCache" +} + +function Invoke-DotNet([string[]] $Arguments) { + & $dotnet @Arguments + if ($LASTEXITCODE -ne 0) { + throw "dotnet $($Arguments -join ' ') failed with exit code $LASTEXITCODE" + } +} + +function Get-VerifiedDownload([string] $Uri, [string] $Destination, [string] $ExpectedSha256) { + if (Test-Path -LiteralPath $Destination) { + $actual = (Get-FileHash -LiteralPath $Destination -Algorithm SHA256).Hash.ToLowerInvariant() + if ($actual -eq $ExpectedSha256.ToLowerInvariant()) { + Write-Host "Using verified dependency cache: $(Split-Path $Destination -Leaf)" + return + } + Remove-Item -LiteralPath $Destination -Force + } + + Write-Host "Downloading $(Split-Path $Destination -Leaf)..." + Invoke-WebRequest -Uri $Uri -OutFile $Destination -UseBasicParsing + $downloaded = (Get-FileHash -LiteralPath $Destination -Algorithm SHA256).Hash.ToLowerInvariant() + if ($downloaded -ne $ExpectedSha256.ToLowerInvariant()) { + Remove-Item -LiteralPath $Destination -Force + throw "SHA-256 mismatch for $Uri. Expected $ExpectedSha256 but received $downloaded." + } +} + +foreach ($path in @($stage, $OutputDirectory)) { + if (Test-Path -LiteralPath $path) { + Remove-Item -LiteralPath $path -Recurse -Force + } +} +New-Item -ItemType Directory -Path $launcherPublish, $serverPublish, $decalHostPublish, $buildArtifacts, $OutputDirectory | Out-Null + +Write-Host "Publishing ACE.Server as self-contained Windows x64..." +Invoke-DotNet @("publish", (Join-Path $repoRoot "Source\ACE.Server\ACE.Server.csproj"), "-c", $Configuration, "-r", "win-x64", "--self-contained", "true", "--artifacts-path", $buildArtifacts, "-o", $serverPublish) + +Write-Host "Publishing ACE.SinglePlayer as self-contained Windows x64..." +Invoke-DotNet @("publish", (Join-Path $repoRoot "Source\ACE.SinglePlayer\ACE.SinglePlayer.csproj"), "-c", $Configuration, "-r", "win-x64", "--self-contained", "true", "--artifacts-path", $buildArtifacts, "-o", $launcherPublish) + +Write-Host "Publishing the optional Decal helper as a self-contained Windows x86 executable..." +Invoke-DotNet @("publish", (Join-Path $repoRoot "Source\ACE.SinglePlayer.DecalHost\ACE.SinglePlayer.DecalHost.csproj"), "-c", $Configuration, "-r", "win-x86", "--self-contained", "true", "--artifacts-path", $buildArtifacts, "-p:PublishSingleFile=true", "-o", $decalHostPublish) + +Copy-Item -Path (Join-Path $launcherPublish "*") -Destination $OutputDirectory -Recurse -Force +New-Item -ItemType Directory -Path (Join-Path $OutputDirectory "Server"), (Join-Path $OutputDirectory "Tools"), (Join-Path $OutputDirectory "Packages") | Out-Null +Copy-Item -Path (Join-Path $serverPublish "*") -Destination (Join-Path $OutputDirectory "Server") -Recurse -Force +Copy-Item -Path (Join-Path $decalHostPublish "ACE.SinglePlayer.DecalHost.exe") -Destination (Join-Path $OutputDirectory "Tools") -Force + +Write-Host "Building the curated mod packages..." +foreach ($modProject in @( + "ACE.SinglePlayer.Mods.CriticalOverride", + "ACE.SinglePlayer.Mods.ACEUniqueWeeniesProc", + "ACE.SinglePlayer.Mods.HelloCommand", + "ACE.SinglePlayer.Mods.SocietyTailoring" +)) { + & (Join-Path $repoRoot "scripts\package-mod.ps1") ` + -ProjectDirectory (Join-Path $repoRoot "Source\$modProject") ` + -OutputDirectory (Join-Path $OutputDirectory "Packages") ` + -Configuration $Configuration + if ($LASTEXITCODE -ne 0) { + throw "Packaging $modProject failed with exit code $LASTEXITCODE" + } +} + +Write-Host "Packaging OptimShi's checksum-pinned official CustomClothingBase v$customClothingVersion release..." +New-Item -ItemType Directory -Force -Path $cacheRoot, $customClothingExtract, $customClothingPackage | Out-Null +$customClothingArchive = Join-Path $cacheRoot $customClothingArchiveName +Get-VerifiedDownload $customClothingUri $customClothingArchive $customClothingSha256 +Expand-Archive -LiteralPath $customClothingArchive -DestinationPath $customClothingExtract -Force +$customClothingUpstreamFiles = @( + "ACE.Shared.dll", + "CustomClothingBase.dll", + "JsonNet.ContractResolvers.dll", + "Newtonsoft.Json.dll", + "Settings.json" +) +foreach ($fileName in $customClothingUpstreamFiles) { + if (-not (Test-Path -LiteralPath (Join-Path $customClothingExtract $fileName))) { + throw "The official CustomClothingBase archive is missing $fileName." + } +} +$customClothingModDirectory = Join-Path $customClothingPackage "mod" +$customClothingJsonDirectory = Join-Path $customClothingModDirectory "json" +New-Item -ItemType Directory -Force -Path $customClothingModDirectory, $customClothingJsonDirectory | Out-Null +Copy-Item -LiteralPath (Join-Path $repoRoot "packaging\CustomClothingBase\ace-mod.json") -Destination $customClothingPackage +foreach ($fileName in $customClothingUpstreamFiles) { + Copy-Item -LiteralPath (Join-Path $customClothingExtract $fileName) -Destination $customClothingModDirectory +} +Copy-Item -LiteralPath (Join-Path $repoRoot "packaging\CustomClothingBase\Meta.json") -Destination $customClothingModDirectory +Copy-Item -LiteralPath (Join-Path $repoRoot "packaging\CustomClothingBase\README.md") -Destination $customClothingModDirectory +Copy-Item -LiteralPath (Join-Path $repoRoot "packaging\CustomClothingBase\json\README.txt") -Destination $customClothingJsonDirectory +$customClothingOutputArchive = Join-Path $OutputDirectory "Packages\optimshi.custom-clothing-base-1.11-upstream.zip" +Compress-Archive -Path (Join-Path $customClothingPackage "*") -DestinationPath $customClothingOutputArchive -CompressionLevel Optimal +$customClothingPackageSha256 = (Get-FileHash -LiteralPath $customClothingOutputArchive -Algorithm SHA256).Hash.ToLowerInvariant() +$customClothingPackageSha256 | Set-Content -LiteralPath ($customClothingOutputArchive + ".sha256") -Encoding ascii + +if (-not $SkipBundledDependencies) { + New-Item -ItemType Directory -Path $cacheRoot, $mariaDbExtract, $worldExtract -Force | Out-Null + $mariaDbArchive = Join-Path $cacheRoot $mariaDbArchiveName + $worldArchive = Join-Path $cacheRoot $worldArchiveName + Get-VerifiedDownload $mariaDbUri $mariaDbArchive $mariaDbSha256 + Get-VerifiedDownload $worldUri $worldArchive $worldSha256 + + Write-Host "Extracting the pinned portable MariaDB runtime..." + Expand-Archive -LiteralPath $mariaDbArchive -DestinationPath $mariaDbExtract -Force + $mariaDbRoots = @(Get-ChildItem -LiteralPath $mariaDbExtract -Directory | Where-Object { + Test-Path -LiteralPath (Join-Path $_.FullName "bin\mariadbd.exe") + }) + if ($mariaDbRoots.Count -ne 1) { + throw "The MariaDB archive did not contain exactly one complete Windows runtime root." + } + $mariaDbOutput = Join-Path $OutputDirectory "Dependencies\MariaDB" + New-Item -ItemType Directory -Path (Split-Path $mariaDbOutput -Parent) -Force | Out-Null + Move-Item -LiteralPath $mariaDbRoots[0].FullName -Destination $mariaDbOutput + + Write-Host "Extracting the pinned complete ACE World database..." + Expand-Archive -LiteralPath $worldArchive -DestinationPath $worldExtract -Force + $worldSqlFiles = @(Get-ChildItem -LiteralPath $worldExtract -Recurse -File -Filter "ACE-World-Database-*.sql") + if ($worldSqlFiles.Count -ne 1) { + throw "The ACE World archive did not contain exactly one complete ACE-World-Database SQL file." + } + $worldOutput = Join-Path $OutputDirectory "Dependencies\World" + New-Item -ItemType Directory -Path $worldOutput -Force | Out-Null + Move-Item -LiteralPath $worldSqlFiles[0].FullName -Destination (Join-Path $worldOutput "ACE-World-Database-v$worldVersion.sql") -Force + + $licensesOutput = Join-Path $OutputDirectory "Licenses" + New-Item -ItemType Directory -Path $licensesOutput -Force | Out-Null + Copy-Item -LiteralPath (Join-Path $repoRoot "LICENSE") -Destination (Join-Path $licensesOutput "ACE-and-ACE-World-AGPL-3.0.txt") + $mariaDbLicense = Get-ChildItem -LiteralPath $mariaDbOutput -File | Where-Object { $_.Name -in @("COPYING", "COPYING.txt") } | Select-Object -First 1 + if ($null -eq $mariaDbLicense) { + throw "The MariaDB distribution license was not found in the official archive." + } + Copy-Item -LiteralPath $mariaDbLicense.FullName -Destination (Join-Path $licensesOutput "MariaDB-GPL-2.0.txt") + Copy-Item -LiteralPath (Join-Path $repoRoot "docs\THIRD_PARTY_NOTICES.md") -Destination (Join-Path $OutputDirectory "THIRD_PARTY_NOTICES.md") + + $bundleManifest = [ordered]@{ + formatVersion = 1 + aceUpstream = [ordered]@{ + repository = "https://github.com/ACEmulator/ACE" + commit = "650c5b75ae909957feaf58db320e46be16502653" + build = "1.77.4782" + license = "AGPL-3.0" + } + mariaDb = [ordered]@{ + version = $mariaDbVersion + source = $mariaDbUri + sha256 = $mariaDbSha256 + license = "GPL-2.0" + } + aceWorld = [ordered]@{ + version = $worldVersion + source = $worldUri + sha256 = $worldSha256 + license = "AGPL-3.0" + } + customClothingBase = [ordered]@{ + repository = "https://github.com/OptimShi/CustomClothingBase" + version = $customClothingVersion + source = $customClothingUri + upstreamSha256 = $customClothingSha256 + packagedSha256 = $customClothingPackageSha256 + license = "No LICENSE file in upstream repository; redistributed with author permission reported by the project maintainer" + } + } + $bundleManifest | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath (Join-Path $OutputDirectory "BUNDLE-MANIFEST.json") -Encoding utf8 +} + +foreach ($directory in @("Runtime", "Mods", "Logs", "Client", "Docs")) { + New-Item -ItemType Directory -Path (Join-Path $OutputDirectory $directory) -Force | Out-Null +} +Copy-Item (Join-Path $repoRoot "docs\SINGLE_PLAYER_FIRST_RUN.md") (Join-Path $OutputDirectory "FIRST_RUN.md") +Copy-Item (Join-Path $repoRoot "docs\SINGLE_PLAYER_INSTALL.md") (Join-Path $OutputDirectory "INSTALL.md") +Copy-Item (Join-Path $repoRoot "docs\SINGLE_PLAYER_ARCHITECTURE.md") (Join-Path $OutputDirectory "Docs") +Copy-Item (Join-Path $repoRoot "docs\AQUIFIR_MOD_COMPATIBILITY.md") (Join-Path $OutputDirectory "Docs") +Copy-Item (Join-Path $repoRoot "docs\MOD_LIBRARY.md") (Join-Path $OutputDirectory "Docs") +Copy-Item (Join-Path $repoRoot "docs\MOD_AUTHOR_GUIDE.md") (Join-Path $OutputDirectory "Docs") +Copy-Item (Join-Path $repoRoot "docs\CUSTOM_WEENIES.md") (Join-Path $OutputDirectory "Docs") +Copy-Item (Join-Path $repoRoot "docs\SINGLE_PLAYER_ROADMAP.md") (Join-Path $OutputDirectory "Docs") +Copy-Item (Join-Path $repoRoot "docs\SINGLE_PLAYER_BUILD_AND_TEST.md") (Join-Path $OutputDirectory "Docs") +Copy-Item (Join-Path $repoRoot "README.md") $OutputDirectory +Copy-Item (Join-Path $repoRoot "LICENSE") $OutputDirectory + +# Portable public packages do not need debug symbols. Portable PDBs can contain +# source document paths from the computer that produced the release. +Get-ChildItem -LiteralPath $OutputDirectory -Recurse -Filter "*.pdb" -File | Remove-Item -Force + +$forbidden = Get-ChildItem -LiteralPath $OutputDirectory -Recurse -File | Where-Object { + $relativePath = if ($_.FullName.StartsWith($outputPrefix, [StringComparison]::OrdinalIgnoreCase)) { + $_.FullName.Substring($outputPrefix.Length) + } else { + $_.FullName + } + $isPinnedMariaDb = $relativePath -ieq "Dependencies\MariaDB\bin\mariadbd.exe" + $_.Name -in @("Config.js", "settings.json", "acclient.exe", "ace-server.ready.json", "ace-server.process.json") -or + ($_.Name -ieq "mariadbd.exe" -and -not $isPinnedMariaDb) -or + $_.Name -like "client_*.dat" -or $_.Extension -in @(".log", ".pdb") -or + $relativePath -match '^(Runtime|Logs|Client)[\\/]' +} +if ($forbidden) { + throw "Packaging safety check found forbidden runtime/proprietary files: $($forbidden.FullName -join ', ')" +} + +$textExtensions = @(".bat", ".config", ".example", ".js", ".json", ".md", ".ps1", ".sh", ".sql", ".txt", ".xml", ".yml", ".yaml") +$personalPathMatches = Get-ChildItem -LiteralPath $OutputDirectory -Recurse -File | Where-Object { + $relativePath = if ($_.FullName.StartsWith($outputPrefix, [StringComparison]::OrdinalIgnoreCase)) { + $_.FullName.Substring($outputPrefix.Length) + } else { + $_.FullName + } + $_.Extension -in $textExtensions -and $relativePath -notmatch '^Dependencies[\\/]' +} | Select-String -Pattern '(?i)(C:[\\/]Users[\\/][^\\/]+|/Users/[^/]+)' -List +if ($personalPathMatches) { + throw "Packaging safety check found a personal user-profile path: $($personalPathMatches.Path -join ', ')" +} + +$archivePath = "$OutputDirectory.zip" +if (Test-Path -LiteralPath $archivePath) { + Remove-Item -LiteralPath $archivePath -Force +} +Compress-Archive -Path (Join-Path $OutputDirectory "*") -DestinationPath $archivePath -CompressionLevel Optimal + +Remove-Item -LiteralPath $stage -Recurse -Force +Write-Host "ACE Single Player package created at: $OutputDirectory" +Write-Host "Shareable ZIP created at: $archivePath"