Table of Contents

Building and running

Prerequisites

  • .NET 10 SDK in the band pinned by global.json (currently 10.0.3xx).
  • Your own Asheron's Call data files: client_portal.dat, client_cell_1.dat, client_highres.dat, client_local_English.dat. OpenAC does not distribute them.
  • A server. OpenAC connects to ACEmulator. The examples use a local server at 127.0.0.1:9000.
  • For the graphical client, a Vulkan 1.3 capable GPU and driver. On Linux that means your distribution's Vulkan ICD for your GPU (for example mesa-vulkan-drivers on Ubuntu) and an X11 or Wayland desktop. On macOS it means MoltenVK and the Vulkan loader (brew install molten-vk vulkan-loader) when running from source; MoltenVK is reached through VK_KHR_portability_enumeration, and the loader needs VK_ICD_FILENAMES=$(brew --prefix)/etc/vulkan/icd.d/MoltenVK_icd.json. The Apple-silicon launcher distribution supplies its own validated loader, MoltenVK, and ICD manifest for launched clients. The Intel (osx-x64) distribution does too, from a different source; see docs/ci-and-releases.md.

Windows and Linux (x64), plus macOS (arm64), are supported by the launcher, graphical client, and bake step. The examples below use PowerShell; the bash equivalents differ only in how variables are set. Intel macOS (osx-x64) is also supported, best effort until August 2027; see docs/ci-and-releases.md.

Build and test

dotnet restore AcDream.slnx
dotnet build AcDream.slnx -c Release
dotnet test AcDream.slnx -c Release --no-build --filter "Lane!=InstalledDat&Lane!=PreparedPackage&Lane!=Live&Lane!=Manual&Lane!=Timing&Lane!=Windows&Lane!=Linux&Lane!=MacOS&Lane!=Unix&Lane!=Vulkan&Lane!=SystemFont&Purpose!=Diagnostic&Status!=KnownFailure"

The filter is the portable gate described in release-gate.md. Tests behind a Lane need a specific resource; run them when you have it, for example --filter "Lane=Vulkan" on a machine with a GPU.

On Linux, bash tools/build-linux.sh runs the Release build with .NET and NuGet state isolated under XDG_CACHE_HOME (or /tmp). This is useful in containers and CI workers with a read-only home directory. It redirects generated package locks to that cache so local restores do not rewrite tracked package-lock files. Pass --test to run the portable test filter afterward.

Prepare the content package

Rendering and collision read a validated prepared package instead of decoding world meshes on the frame path. Build it once per machine:

dotnet run --project src/AcDream.Bake/AcDream.Bake.csproj -c Release -- `
  --dat-dir "C:\Games\Asheron's Call" `
  --out "C:\Games\Asheron's Call\acdream.pak"

A complete package from the standard data set is about 570 MiB. It is machine-local; do not commit it. ACDREAM_PAK_PATH overrides the default <DAT directory>/acdream.pak. The launcher runs this step for you.

Run the graphical client

$env:ACDREAM_DAT_DIR   = "C:\Games\Asheron's Call"
$env:ACDREAM_PAK_PATH  = "C:\Games\Asheron's Call\acdream.pak"
$env:ACDREAM_LIVE      = "1"
$env:ACDREAM_TEST_HOST = "127.0.0.1"
$env:ACDREAM_TEST_PORT = "9000"
$env:ACDREAM_TEST_USER = "youraccount"
$env:ACDREAM_TEST_PASS = "yourpassword"

dotnet run --project src/AcDream.App/AcDream.App.csproj -c Release

On Linux:

export ACDREAM_DAT_DIR="$HOME/ac" ACDREAM_PAK_PATH="$HOME/ac/acdream.pak"
export ACDREAM_LIVE=1 ACDREAM_TEST_HOST=127.0.0.1 ACDREAM_TEST_PORT=9000
export ACDREAM_TEST_USER=youraccount ACDREAM_TEST_PASS=yourpassword
dotnet run --project src/AcDream.App/AcDream.App.csproj -c Release

The DAT directory can instead be the first positional argument.

For a macOS source build, set the same game variables and run the managed assembly with the Homebrew loader available:

export DYLD_LIBRARY_PATH="$(brew --prefix)/lib"
export VK_DRIVER_FILES="$(brew --prefix)/etc/vulkan/icd.d/MoltenVK_icd.json"
dotnet src/AcDream.App/bin/Release/net10.0/AcDream.App.dll "$HOME/ac"

The packaged Mac client is named acdream-client and loads its bundled graphics libraries directly. It does not require those environment variables.

Useful startup options

Variable Effect
ACDREAM_DAT_DIR Data-file directory
ACDREAM_PAK_PATH Prepared package path; defaults to <DAT dir>/acdream.pak
ACDREAM_LIVE=1 Connect to a server instead of loading offline
ACDREAM_TEST_HOST / ACDREAM_TEST_PORT Server endpoint
ACDREAM_TEST_USER / ACDREAM_TEST_PASS Graphical-client credentials
ACDREAM_NO_AUDIO=1 Skip audio initialization
ACDREAM_UNCAPPED_RENDER=1 Disable frame pacing (for measurement only)
ACDREAM_DISPLAY_PROTOCOL=auto\|x11\|wayland Linux window backend selection
ACDREAM_DEVTOOLS=1 Enable the Vulkan validation and debug-utils layers
ACDREAM_HEADLESS_CONSOLE=0\|1 Headless interactive console; defaults to on when stdin is a terminal
ACDREAM_HEADLESS_CONSOLE_STREAM=stderr\|stdout Which stream that console prints to; stderr by default
ACDREAM_PLUGIN_TAGS=a,b Words this client wants to be found by; plugins on the clients running on this machine can see one another's tags and filter on them. The headless config's pluginTags is the same option
ACDREAM_PLUGIN_SETTINGS_FILE=<path> Path to a JSON file holding the startup settings each plugin is given, on either client. The file is the same map the headless config names under pluginSettings, which outranks it; a session that names none falls back to this file. A named file that is missing, unreadable or the wrong shape stops startup with the reason; unset means no settings
ACDREAM_FRAME_PROF=1 Print the rolling [frame-prof] CPU/GPU/allocation line
ACDREAM_FRAME_HISTORY=<path> Write one CSV row per frame to that path on exit
ACDREAM_GPU_STAGE_PROF=1 Print the rolling [gpu-stage] per-stage GPU attribution line

A few other ACDREAM_* variables switch original-client behaviors that are on by default (ACDREAM_RETAIL_CHASE, ACDREAM_CAMERA_COLLIDE, ACDREAM_CAMERA_ALIGN_SLOPE, ACDREAM_RETAIL_CLOSE_DEGRADES; set =0 to disable one for comparison). The rest are diagnostic probes, off by default and documented beside their read sites in src/.

Run a headless session

AcDream.Headless loads no window, GPU, or audio assembly. Create bot.json:

{
  "version": 1,
  "process": {
    "content": {
      "datDirectory": "/opt/ac",
      "preparedAssetPath": "/opt/ac/acdream.pak"
    }
  },
  "sessions": [
    {
      "id": "bot-1",
      "endpoint": { "host": "127.0.0.1", "port": 9000 },
      "account": "youraccount",
      "character": { "index": 0 },
      "policy": { "id": "idle" },
      "credential": {
        "provider": "environment",
        "reference": "ACDREAM_BOT_PASSWORD"
      }
    }
  ]
}

Then:

export ACDREAM_BOT_PASSWORD='yourpassword'
dotnet run --project src/AcDream.Headless/AcDream.Headless.csproj -c Release -- validate --config bot.json
dotnet run --project src/AcDream.Headless/AcDream.Headless.csproj -c Release -- run --config bot.json

Optional per-session fields: plugins (which plugin ids to load), pluginSettings, loginCommands / loginCommandDelayMs, statusFile, characterOptions, and pluginTags — an array of words this session wants to be found by, the same option ACDREAM_PLUGIN_TAGS gives the graphical client. Plugins on the clients running on one machine can see one another's tags and filter on them, so a bot that should look like part of a group carries the group's word:

"pluginTags": ["tank", "group-a"]

statusFile is one JSON line per session event, appended as it happens. A running session keeps that file open for its whole lifetime, so anything that watches it -- the launcher, a script, a person -- must open it with writing shared (FileShare.ReadWrite, or a tool that tails rather than locks). An ordinary exclusive read is refused while the session runs, so a watcher can never take the session's own writing away from it.

pluginSettings is the startup settings each plugin is given, one object of settings per plugin id. A plugin reads only its own, through IPluginHost.SessionSettings:

"pluginSettings": {
  "acdream.example": { "startMacro": "true", "profile": "tank" }
}

A session that names none takes the same map from a file instead, on either client: ACDREAM_PLUGIN_SETTINGS_FILE=<path>, where the whole file is that map and nothing else:

{
  "acdream.example": { "startMacro": "true", "profile": "tank" }
}

Both clients read both places the same way, in the same order — what the session names wins and leaves the file unread — and refuse the same mistakes: a plugin id with nothing behind it, or a setting with no value, stops startup and says which one. A file a client was told to read and could not is a startup error too, never a quiet run with no settings — a plugin that decides what to do on login from a setting would otherwise behave differently under a window than it does without one.

The same document on the graphical client

The graphical client reads the same session-config document:

dotnet run --project src/AcDream.App/AcDream.App.csproj -c Release -- --session-config bot.json

It requires exactly one session in the document, and it honours every per-session field that decides what a plugin sees: character, plugins, pluginTags, pluginSettings, loginCommands, loginCommandDelayMs, characterOptions and statusFile. Both clients check characterOptions against the same declarable list and seed it the same way on login, so a document means the same thing whichever client reads it; a client started without a document declares none, so a player who sets an option in the panels is never overruled. One field only the windowless client acts on is accepted and ignored here rather than refused, so one document still starts either client: policy (which bot policy drives a windowless session; a graphical session is driven by the player). The one field the graphical client refuses is "mode": "probe", because a probe never selects a character and there is no windowed session to show.

Where a document and a startup option say the same thing, the document wins: its pluginTags outranks ACDREAM_PLUGIN_TAGS, and its pluginSettings outranks ACDREAM_PLUGIN_SETTINGS_FILE, which is left unread. A field the document leaves out still falls back to the startup option, on either client.

For a single local session, run also accepts --user and --password. Add more session entries for a multi-session process. Built-in policies: idle, lifecycle-smoke, observer-movement, portal-route-smoke.

The headless console

run --console turns a headless process into an interactive client: it prints the chat box and reads typed lines. Chat appears with the same wording the graphical client's chat window uses, behind a short tag standing in for the colour that window would draw the line in, and honouring that window's message-type filters:

[say] Bob says, "hi there"
[tell] Bob tells you, "meet me"
[fellowship] [Fellowship] Bob says, "group up"
[combat] A Drudge Slinker slashes you for 9 points of damage!
[client] navigation route loaded
-- entered world

Lines the console produces about the session itself start with --, so they can never be read as chat. [client] marks text the client produced for itself, which the graphical client shows in its status overlay.

The console is on by default when standard input is a terminal. ACDREAM_HEADLESS_CONSOLE=1 forces it on for a redirected stdin, and =0 turns it off; --console overrides both.

Typing

A typed line goes into the same chat entry the graphical client's chat box types into, so it does exactly what that line does there:

hello                     say it out loud
/f group up               say it on the fellowship channel
@tell Bob, meet me        tell Bob
/r on my way              reply to whoever told you last
hello *wave*              say it and play the wave
/loc                      a client command
@who                      anything the client does not claim goes to the server
/vt start                 a verb a plugin registered

The entry remembers the last 100 lines, and it remembers where plain text is aimed. Aim it with a channel verb or a tell and the next plain line follows: after @tell Bob, meet me a bare hello is a tell to Bob, exactly as it is in the chat box. A plugin that stages a line with Chat.Compose shows it as -- draft: ..., and pressing Enter on an empty console line sends it.

Client commands that need a window

The client's own verbs work on either client. Two of them draw something, and a client without a window answers in plain words rather than not knowing the verb:

/nav grid                 Navigation: this client has nothing to draw the grid on
/nav route Bob            Navigation: this client has nothing to draw a route on

Everything else /nav and /motor do is identical with or without a window, and so is /status, which answers the session's generation, where it is in its life, and where the character stands:

/status
generation=3 state=InWorld position=cell=0xC6A9002B local=(84.31,112.07,42.00)

/status is a verb of the client's, not of the console's, so the graphical chat box answers it with the same line. The verbs the client reserves on the command registry -- nav, motor and status -- are not available to a plugin.

The console's own verbs

Three verbs belong to the console rather than to the client. Each is offered to the session's command registry first, so a plugin that registers the same verb keeps it:

Verb What it does
/quit Ends every session in the process, gracefully. @quit still goes to the server.
/session <id> Chooses which session an unaddressed line goes to. With no id it says which one that is.
/sessions Lists the sessions this process is running and marks the one being talked to.

More than one session

A process running several sessions gives them one console between them. Every printed line names the session it came from, and a line can be addressed to one session without changing which one the next line goes to:

-- [alpha] entered world
-- [beta] entered world
[alpha] [say] Bob says, "hi there"
@beta /loc                       one line to beta
/session beta                    every following line to beta
-- [alpha] now talking to beta
/sessions
-- [alpha] session alpha
-- [beta] session beta (talking to this one)

An at sign is only read as an address when it names a session this process is really running, so the server verbs that start with one -- @tell, @who -- are left alone.

Two streams. The machine-readable JSON diagnostic lines keep standard output, unchanged, so a script can parse them while a person watches the console. The console itself prints to standard error. Send it to standard output instead with --console-stream stdout, or with ACDREAM_HEADLESS_CONSOLE_STREAM=stdout; --console-stream stderr restores the default. The flag wins over the variable.

Run the launcher from source

dotnet run --project src/AcDream.Launcher/AcDream.Launcher.csproj -c Release

The launcher installs released builds from the GitHub Releases feed. A source-built launcher will offer to update itself to the latest release; that is expected.

Shaders

GLSL sources live in src/AcDream.App/Rendering/Shaders/; the committed SPIR-V beside them is what the client loads. After editing a shader run tools/compile-shaders.ps1, which uses glslc from a Vulkan SDK if present and otherwise the bundled tools/ShaderCompiler.