Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
09a3328
feat: add one-click single-player launcher
titaniumweiner Jul 14, 2026
76cf3f7
Add isolated private MariaDB launcher support
titaniumweiner Jul 14, 2026
f0bad6f
Allow server and client to share DAT files
titaniumweiner Jul 14, 2026
7e6db6b
Prepare public single-player release
titaniumweiner Jul 14, 2026
c8ec5df
Add one-click Decal launch
titaniumweiner Jul 15, 2026
9989cc9
Merge pull request #1 from titaniumweiner/feature/single-player-launcher
titaniumweiner Jul 15, 2026
6ffe06e
Add mod library and automatic client detection
titaniumweiner Jul 15, 2026
187e39c
Merge remote-tracking branch 'origin/master' into feature/single-play…
titaniumweiner Jul 15, 2026
059f5e1
Ship self-contained single-player release
Balarchrex Jul 15, 2026
0a926ba
Ship self-contained ACE Single Player release (#2)
titaniumweiner Jul 15, 2026
cf5d314
Fix Mods window splitter initialization
Balarchrex Jul 15, 2026
b9d101e
Fix Mods window layout crash (#3)
titaniumweiner Jul 15, 2026
5d8795d
Clean up mod library and improve contrast
Balarchrex Jul 15, 2026
0d2d21a
Clean up Mods list and improve text contrast (#4)
titaniumweiner Jul 15, 2026
588a769
Add guarded mod package importer
Balarchrex Jul 15, 2026
93f4adc
Add guarded mod package importer (#5)
titaniumweiner Jul 15, 2026
bfdb7b3
Add preview mod ports and author guide
Balarchrex Jul 15, 2026
e44b6dc
Merge pull request #6 from titaniumweiner/feature/single-player-launcher
titaniumweiner Jul 15, 2026
1778760
Add CustomClothingBase preview package
Balarchrex Jul 16, 2026
aa40c17
Merge pull request #7 from titaniumweiner/feature/single-player-launcher
titaniumweiner Jul 16, 2026
740c938
Add guarded AceForge weenie imports
Balarchrex Jul 17, 2026
de7cb54
Add ACEUniqueWeenies proc mod
Balarchrex Jul 17, 2026
d9ce9da
Rename mod to Expanded Cast on Strike
Balarchrex Jul 17, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
60 changes: 60 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -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)
Expand Down
69 changes: 69 additions & 0 deletions Source/ACE.Server.Tests/SinglePlayerStartupTests.cs
Original file line number Diff line number Diff line change
@@ -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<string>());
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<ArgumentException>(() => 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);
}
}
}
}
34 changes: 30 additions & 4 deletions Source/ACE.Server/ACE.Server.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -285,12 +285,38 @@
</ItemGroup>

<ItemGroup>
<None Include="..\..\Database\Archive\**" CopyToOutputDirectory="PreserveNewest" LinkBase="DatabaseSetupScripts\Archive\" />
<None Include="..\..\Database\Base\**" CopyToOutputDirectory="PreserveNewest" LinkBase="DatabaseSetupScripts\Base\" />
<None Include="..\..\Database\Updates\**" CopyToOutputDirectory="PreserveNewest" LinkBase="DatabaseSetupScripts\Updates\" />
<None Include="..\..\Database\Optional\**" CopyToOutputDirectory="PreserveNewest" LinkBase="DatabaseSetupScripts\Optional\" />
<!--
The .NET 10 publish pipeline can collapse linked files outside the project into
a single DestinationFiles entry. Keep the normal build behavior, exclude these
items from the SDK's publish copy, and copy them with the one-to-one target below.
-->
<None Include="..\..\Database\Archive\**\*" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="Never" Link="DatabaseSetupScripts\Archive\%(RecursiveDir)%(Filename)%(Extension)" />
<None Include="..\..\Database\Base\**\*" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="Never" Link="DatabaseSetupScripts\Base\%(RecursiveDir)%(Filename)%(Extension)" />
<None Include="..\..\Database\Updates\**\*" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="Never" Link="DatabaseSetupScripts\Updates\%(RecursiveDir)%(Filename)%(Extension)" />
<None Include="..\..\Database\Optional\**\*" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="Never" Link="DatabaseSetupScripts\Optional\%(RecursiveDir)%(Filename)%(Extension)" />
</ItemGroup>

<Target Name="CopyDatabaseSetupScriptsToPublish" AfterTargets="Publish">
<ItemGroup>
<_DatabaseArchivePublish Include="..\..\Database\Archive\**\*" />
<_DatabaseBasePublish Include="..\..\Database\Base\**\*" />
<_DatabaseUpdatesPublish Include="..\..\Database\Updates\**\*" />
<_DatabaseOptionalPublish Include="..\..\Database\Optional\**\*" />
</ItemGroup>
<Copy SourceFiles="@(_DatabaseArchivePublish)"
DestinationFiles="@(_DatabaseArchivePublish->'$(PublishDir)DatabaseSetupScripts\Archive\%(RecursiveDir)%(Filename)%(Extension)')"
SkipUnchangedFiles="true" />
<Copy SourceFiles="@(_DatabaseBasePublish)"
DestinationFiles="@(_DatabaseBasePublish->'$(PublishDir)DatabaseSetupScripts\Base\%(RecursiveDir)%(Filename)%(Extension)')"
SkipUnchangedFiles="true" />
<Copy SourceFiles="@(_DatabaseUpdatesPublish)"
DestinationFiles="@(_DatabaseUpdatesPublish->'$(PublishDir)DatabaseSetupScripts\Updates\%(RecursiveDir)%(Filename)%(Extension)')"
SkipUnchangedFiles="true" />
<Copy SourceFiles="@(_DatabaseOptionalPublish)"
DestinationFiles="@(_DatabaseOptionalPublish->'$(PublishDir)DatabaseSetupScripts\Optional\%(RecursiveDir)%(Filename)%(Extension)')"
SkipUnchangedFiles="true" />
</Target>

<Target Name="PreBuild" BeforeTargets="PreBuildEvent">
<Exec Command="if exist &quot;$(ProjectDir)Config.js&quot; copy &quot;$(ProjectDir)Config.js&quot; &quot;$(TargetDir)Config.js&quot;&#xD;&#xA;if not exist &quot;$(ProjectDir)log4net.config&quot; if exist &quot;$(TargetDir)log4net.config&quot; copy &quot;$(TargetDir)log4net.config&quot; &quot;$(ProjectDir)log4net.config&quot;&#xD;&#xA;if exist &quot;$(ProjectDir)log4net.config&quot; copy &quot;$(ProjectDir)log4net.config&quot; &quot;$(TargetDir)log4net.config&quot;" Condition="'$(OS)' == 'Windows_NT'" />
<Exec Command="if [ -f &quot;$(ProjectDir)Config.js&quot; ] ; then cp &quot;$(ProjectDir)Config.js&quot; &quot;$(TargetDir)Config.js&quot; ; fi&#xD;&#xA;if [ ! -f &quot;$(ProjectDir)log4net.config&quot; ] &amp;&amp; [ -f &quot;$(TargetDir)log4net.config&quot; ] ; then cp &quot;$(TargetDir)log4net.config&quot; &quot;$(ProjectDir)log4net.config&quot; ; fi&#xD;&#xA;if [ -f &quot;$(ProjectDir)log4net.config&quot; ] ; then cp &quot;$(ProjectDir)log4net.config&quot; &quot;$(TargetDir)log4net.config&quot; ; fi" Condition="'$(OS)' != 'Windows_NT'" />
Expand Down
19 changes: 15 additions & 4 deletions Source/ACE.Server/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
{
Expand All @@ -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();
Expand Down Expand Up @@ -340,6 +348,7 @@ public static void Main(string[] args)
if (!PropertyManager.GetBool("world_closed", false).Item)
{
WorldManager.Open(null);
readyFileSignal.Write();
}
}

Expand All @@ -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)
Expand Down
74 changes: 74 additions & 0 deletions Source/ACE.Server/ReadyFileSignal.cs
Original file line number Diff line number Diff line change
@@ -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.
}
}
}
}
44 changes: 44 additions & 0 deletions Source/ACE.Server/ServerLaunchOptions.cs
Original file line number Diff line number Diff line change
@@ -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);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0-windows</TargetFramework>
<EnableWindowsTargeting>true</EnableWindowsTargeting>
<RuntimeIdentifier>win-x86</RuntimeIdentifier>
<PlatformTarget>x86</PlatformTarget>
<Platforms>x86</Platforms>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<AssemblyName>ACE.SinglePlayer.DecalHost</AssemblyName>
</PropertyGroup>
</Project>
Loading