Valheim.Testing.GameSessions
Release
0.1.0from source2204f554da12; this package on NuGet.org.
This release's API reference. The package's README is this guide; use its versioned source commit from the package metadata when checking an older release.
Assistant-written (Claude).
Valheim.Testing.GameSessions (net10.0) runs game sessions for mod tests: one or more real game processes in roles (a dedicated server, clients, a client that hosts its own world) on this machine or on others reached over SSH or a container, prepared, started, joined and torn down as one run. It is built on Valheim.Testing.Game, which drives one game process; this package adds what a run of several processes, or a run on another machine, needs. Unofficial community tooling; not affiliated with or endorsed by Iron Gate or Coffee Stain. Valheim is a trademark of Iron Gate AB.
A test project that only observes or drives one game through GameActor, OwnedServerSession, ClientSession or a joining client's ClientRounds needs Valheim.Testing.Game alone. Add this package for a hosting client's rounds (HostedClientRounds.Run, whose fixture world is placed through the journalled placement, hosted worlds), for TargetedRegression, which now uses the shared hosted-runtime copy and recovery path, or when the test runs a session: a dedicated server with its clients, a hosted world with peers, a server-only load check, or any actor on another machine.
Topic pages: pinned world dumps (WorldDump) and evidence linked from result.json (review captures and clips).
What it holds
- The session:
GameSession(its server, clients by name, a hosting client, the scenario'sGameActorhandles, named barriers), the role actorsServerActor,ClientActorandHostingClientActor, andModDeclaration. For xUnit, copy both the GameSessionFixture adapter and the native-test skip pattern in ExampleSession. In that example,Available=falseleavesSessionunset when no private session manifest is present, so a plain[Fact]would fail offline;SessionFactAttributeskips that test. The FullLifecycle guide shows the complete setup. - The pinned runner:
PinnedServerRun(validate,run, and--inventoryfor a server on another host) andRunCampaignAsyncfor a session manifest. - Hosts:
IGameHostwithLocalGameHost,SshGameHostandContainerGameHost, the host lock, CLI tunnels, installs, server and interactive-client starts on a host, macOS app-bundle checks. - Where actors run:
EnvironmentRunsplaces a session's actors on anEnvironmentInventory(the inventory itself, the description of the machines, isValheim.Testing.Game's) and reports, recovers and tears down what earlier runs left on its hosts (valheim-test env status|recover|teardown). - Steam accounts:
SteamAccountHoldand its lease, so two clients never run on one account. - Evidence from a host:
WorldDump,ReviewCaptureandReviewClipfetch what the game wrote on its machine into the run's evidence.
From 0.1.0-preview.3 its types are in the Valheim.Testing.GameSessions namespace (using Valheim.Testing.GameSessions; beside using Valheim.Testing.Game;); through 0.1.0-preview.2 they kept the Valheim.Testing.Game namespace.
Game sessions
ServerActor is the one place a run wires an owned server from its plan: each boot gets the plan's environment with the session token, its kept logs (boot-N), a boot-N.process.json record and recorded commands (connection-N.jsonl) in the output, the plan's startup events and quit time, strict pins, and test access on every boot. PinnedServerRun places it, on this machine or a host of the environment, runs it from the disposable profile it stages there, and hands it to the scenario as session.Server. actor.Game is the current boot's GameActor.
GameSession is one run's game processes in their roles: session.Server (a ServerActor) or session.Host (a HostingClientActor, a client that hosts its own world: hosted worlds), and session.Clients["name"] (ClientActors), each with its in-game handle .Game. A scenario is (GameSession session, TPlan plan) => Task and coordinates the actors only through barriers, each a named Setup step with a bounded wait that names itself on timeout: StartAsync() boots the server and brings every client to its menu at the same time (one failure cancels the others, lets them settle, tears down and is rethrown); ServerJoinable(); Join(client) and JoinAll() (the one join, SessionControl.JoinWorld, optionally with an arrival point); Rejoin(client); and Barrier(name, wait, within) for a scenario's own readiness. Disposing the session closes the clients in reverse order, then stops the server, each a Cleanup step. PinnedServerRun builds the session from the plan and calls the scenario with it; until the scenarios declare their clients (#258 step 6), session.OpenClient(clientPlan, name?, directory?) opens one through a new ClientActor.
An owned client on this machine runs with a disposable loader profile
Profiles are the launch mode. Every owned client, every actor of a hosted campaign (which server-load and server-load-ab prepare), every standalone PinnedServerRun dedicated server (on this machine or, with --inventory, on its host: a server-only plan on a remote host) and every TargetedRegression arm runs from a disposable loader profile; the game install is not copied. The one alternative is --in-place (below), which runs an owned client's install as it is outside a prepared campaign; it is not a server or campaign-actor remedy. On Windows the profile's launch folder hard-links the game's files, so the folder that holds it must be on the game's NTFS volume. A default this-machine runtime follows the resolved install path: it stays under ValheimTesting's data folder when that folder is already on the install's volume, otherwise it is under <install volume>\ValheimTesting\runs; journals and locks remain in the data folder. If a probe cannot identify the install's volume, inventory reading keeps the data-folder default and preflight warns, then staging tries the hard links and reports their own error if they fail. Preflight prints the choice and checks the source and runtime volumes and filesystems before staging. An explicit inventory runtime on another drive is still skipped during remote placement; a known cross-volume or unsupported-filesystem result is refused with the required location and the reason (a profile hard-links the install's files, and a hard link cannot cross volumes). env preflight and start give the same refusal. A Steam library on exFAT, FAT32 or a network share cannot hold a profile: move it to NTFS. An owned client outside a prepared campaign can instead use --in-place.
An owned client opened locally outside a prepared hosted campaign (session.OpenClient or ClientActor.OnThisMachine) stages a disposable loader profile. On macOS the installed game's executable runs with Doorstop pointed at the profile; on Windows an owned launch folder hard-links a positive list of launch inputs: valheim.exe or valheim_server.exe, that executable's matching valheim_Data/** or valheim_server_Data/**, MonoBleedingEdge/**, D3D12/**, UnityCrashHandler64.exe, and UnityPlayer.dll, steam_api64.dll, steamclient64.dll, steamwebrtc64.dll, tier0_s64.dll and vstdlib_s64.dll at the root. Text and log output and crash dumps are left out even below a selected tree. Everything else is left out too, including BepInEx and Doorstop (the profile receives its selected loader), logs/, unrelated nested files, root scripts/readmes, steam_appid.txt (the launch supplies SteamAppId), the 32-bit Steam DLLs, mod-manager proxies and unknown root DLLs. When a selected payload file replaces an existing name, the apply step unlinks that name before copying. Profile staging checks the source install before and after, and does not put test plugins or a replacement proxy there. A Unix preloader failure may still write a crash log beside the source executable; strict prevention is tracked in #612. A known root proxy or loader file is left out silently; every other root omission is recorded as <actor>ProfileLeftOut in provenance (profileLeftOut for a targeted regression) with its reason, and nested omissions are aggregated there by top-level folder with a file count. An unknown root DLL's reason says to add it to the game's DLL list if a launch then fails inside Steam or Unity start-up. The selected ValheimCLI set replaces any installed set in the profile. Supplied install and plugin pins are checked against the source first, including a supplied source ValheimCLI pin before replacement; omitted values are observed from the staged profile. The plan is then bound to the source game's code pin plus the profile's loader, patchers and complete startup-plugin set, and PinOrigins records each value as plan or observed. clientLaunchMode and clientInstall in provenance show what ran. The owned folder is journalled before staging, retired once every client launched from it is proven stopped, and recoverable through valheim-test env recover after interruption. A client that leaves and rejoins between rounds reuses that same prepared profile. inPlace still launches the operator's existing install without staging. Prepared hosted campaigns also stage profiles for actors on this machine. They pin the game and loader roots separately on macOS, while Windows uses the owned launch folder; remote Windows and macOS actors use the same owned profiles over SSH (a Linux host runs the dedicated server only, in a profile or a container). Remote macOS dedicated servers use prepared profiles; remote GUI clients use the same prepared profile campaign through SSH (#613). A campaign retires only its owned profile, never the source install. For remote profiles it also compares every source-install file with the preparation listing immediately before each launch.
--in-place (before the mode) or "inPlace": true in a client plan, one name for both, runs the install as it is and writes nothing into it: no ValheimCLI set is staged and no loader is swapped. The install must already hold BepInEx and ValheimCLI; preflight refuses one that does not, naming what is missing. The static check runs only when the plan names a cliManifest; otherwise cliPreflight in the report says live only: static check not run: your install, run in place. A run still refuses to start while Valheim runs on this machine, and journals, leases and local-only test characters work as for a profile. ClientSession.Launch refuses an owned plan that is neither bound to a prepared runtime nor in place.
A hosting client in a session
HostingClientActor is the hosting client as an actor (#258 step 8): one process with both capabilities, the session's IOwnedServer in place of a dedicated server. GameSession.StartAsync prepares it before any actor starts (Prepare: the plan's preflight, the save directory, the placed fixture, each its own step as for hosted worlds), opens it to its menu, requires the session commands, checks the mod's Harmony patches on it when its pins load the mod ("host: the mod's Harmony patches are applied"), and hosts the world ("host the fixture world with the disposable character, protected"; then "establish test access on the owned host"). Its members: Game (server and client at once), WaitUntilJoinable (in its world with its player, and an open server by cli_multiplayer_identity), Restart (leave, which saves, re-pin at the menu, host again, test access; peers are disconnected), LiveLog (an owned host's BepInEx log on this machine), World (the placed HostedWorld). A session has a dedicated server or a host, not both.
A host's peer is a client plan with "joinsHost": true and no join, passwordVariable, crossplay or hostWorld. session.Join(name) waits until the host's world accepts connections, then joins it with SessionControl.JoinHost(plan, host, worldUid): a listen server on Steam is reached through Steam, not an address, so it reads the host's Steam ID from cli_multiplayer_identity (read-only; it must be an open server on the Steamworks backend with a signed-in Steam user's 17-digit ID) and sends cli_connect_steam_user <steamId> once after cli_select_character, then waits as a crossplay join does, verifies the world pins and protects the player. A crossplay (PlayFab) host's peer join is refused: not supported yet. Disposing the session closes the peers first; then, when the run so far passed, the host leaves its world ("the host leaves its world to its menu"); an owned host is stopped, an operator's detached; then the world moves into the evidence only once no client can still host it (it left, or its owned process stopped), otherwise it is named in hostWorldLeftInPlace.
PinnedServerRun.MainAsync(args, HostedRunOptions<TPlan>) is the hosted run's lifecycle: <runner> [--in-place] validate-host|host <plan.json> <new-output-directory>. The options name the plan reader, the plan's hosting client section (Host), the mod's declaration, its log classifications and the scenario (GameSession, TPlan) => Task. validate-host runs only the plan's preflight; host hosts on this machine, runs the scenario, disposes the session, scans the host's logs and writes result.json and junit.xml. FakeGameSession.Hosted(report, output, hostPlan, openClient, peers) is the same session over scripted clients. The native acceptance suite's hosted scenario runs on it.
Limits: a hosted world has no password and is never public, because the start command would carry a password as text. A peer joins a host only within a session that declares it; the runner's hosted modes place the host on this machine and no peers, and a host with peers on other machines is a campaign without a dedicated server (below). The Steam peer join (cli_connect_steam_user) is tested with fakes only. The macOS 1.0.16 native check covered a local hosted fixture, exact UID, protected player and owned-client teardown; broad host-mode behavior is still exercised through scripted transports.
Pinned server runner
PinnedServerRun.MainAsync is the lifecycle of a mod's owned dedicated-server test runner, so the mod's Program.cs supplies only its plan fields and its scenario, (GameSession session, TPlan plan) => Task. Usage is <runner> [--in-place] [--inventory <environments.json>] validate|run <plan.json> <new-output-directory> (--in-place: owned clients run their install as it is); without it the server runs on this machine, from the install the plan's runtime names; with --inventory it runs on an inventory host (a server-only plan on a remote host), from that host's install. Either way the runner places it the same way: this machine is a local host whose one environment, local-server, is that install, with this machine's lock in ValheimTesting's data folder and its runtime root chosen on the install's volume as described above. The run directory keeps the world the server saved and the boot logs (both are fetched into the output too); PinnedServerRun.RunCampaignAsync runs a plan on a campaign's actors (a campaign). A plan derives from ServerRunPlan, which is read strictly (unknown fields refused), and ValidateServerPlan applies the rules every pinned plan follows:
- fully qualified sources, with any supplied manifest checked, and port and time bounds;
- a known executable;
- no runner-owned token or Doorstop variable in the environment;
-batchmode -nographicsand exactly one-savedir {world};- exact values for every pin the plan supplies;
- omitted standalone pins observed from the staged profile and copied world: runtime and world manifests, the runtime's game build, loader and patchers, every startup-loaded staged plugin's MD5, and a fixture
worlduidunless the plan already suppliesworld=anyor an exact world name (see Pins). After binding, every plugin the runner declared required must be present in that staged set.
The plan may also list logScan (per-run severities, named expected lines and the run's own patterns for the log scan, each with a reason).
For a runner with an owned joined client, set PinnedServerRunOptions.ClientPlans to return that plan's client sections (for one optional client: ClientPlans = plan => plan.Client is null ? [] : [plan.Client]). The runner checks each passwordVariable before creating evidence, copying either game, or starting the server. Set the named variable in the runner process (for example, VT_JOIN_PASSWORD); do not put its value in client.environment, which is serialized plan data. Local clients inherit the runner's variable; remote clients receive it through the host's secret-variable channel. An attached client keeps its operator's environment. Campaigns use their declared clients for this check automatically.
The runner then:
- refuses an existing output directory, and checks the host before writing anything there. It also refuses, before copying, when the output's drive lacks room for the world copy plus headroom (2 GB, or a tenth of the copy when that is larger;
freeBytesBeforeCopiesrecords what it saw); - takes the server host's lock, checks every runtime or plugin pin the plan supplies, stages the server's disposable profile from the install and binds every omitted runtime and startup-plugin pin from that profile (scripts that ScriptEngine does not load at startup stay outside the pin set; a plan that explicitly pins one is refused), then requires every plugin the runner declared, copies the world, checks its supplied manifest or pins its hashes as found, and reads a
worlduidonly when the plan supplied neitherworlduidnorworld. It records plan, runner and toolkit hashes, mode, platform, the exact pins and theirplanorobservedorigins inresult.jsonandinput-hashes.json, the checked source ValheimCLI pins and replaced paths when applicable, and the profile's pins asruntimeGameSha256,runtimeLoaderSha256andruntimePatchersSha256; - stops there for
validate; - otherwise checks the CLI port and starts the owned session from the profile on the world copy, with per-boot logs, recorded commands and
DedicatedStartupEvents, with test access established on the server on that boot and on every restart (OwnedServerSession.EnsureTestAccess, which the runner sets for every owned server, a load smoke's included:TestAccess.Ensuresends devcommands, thenconfirmcheats, each verified through ValheimCLI'scli_access; a new server process has neither gate) and runs the scenario; - always disposes the session (clients the scenario left open, then only the owned server), scans the session's logs (
session.Logs: every boot's and client's, plus anysession.AddLog; ascan run logsstep, see log scan at teardown), writesresult.jsonandjunit.xml(see phases below), and prints PASS (only forrun), VALIDATED or FAIL. It exits 0 when every step passed, 1 on a failure and 2 on bad usage. A run whose only failures are host operations with an unknown outcome (a lost reply, a transport failure, an unproven lock; see game hosts) prints UNKNOWN, recordsoutcomein the provenance and exits 3: neither a pass nor a failure.
Phases and the four reported states. Every step is recorded in one of four phases (StepPhase): Preflight, checks before anything is copied (the free-disk check, a client's install and save-directory preflight); Setup, staging, copying, launching, joining, test access, arrival and the transitions between client rounds (save, leave, restart), until the runtime is ready (validate runs the staging and copying Setup steps and launches nothing); Scenario, the mod's own steps, the default for Step(name, action); and Cleanup, stopping, releasing, removing the server's profile and the log scan (which reads what the mod logged, but runs after the processes stop). A failure outside any step is recorded in the phase of the last recorded step. result.json (schema 5) gives each step's Phase, the standalone run's and owned clients' PinOrigins, and four states derived from the steps: PreflightPassed, RuntimeReady (preflight and every Setup step passed), ScenarioPassed (the runtime was ready, and at least one Scenario step ran and every one passed) and CleanupVerified (at least one Cleanup step, and every one passed). junit.xml has one testsuite per phase, named <report> / <phase>. Passed and the exit code are unchanged: every step must pass, so a run whose scenario passed and whose cleanup failed prints FAIL and exits 1, and ScenarioPassed: true, CleanupVerified: false says which. Removing the runtime copy is a Cleanup step: a copy that could not be removed fails the run. A mod's runner tags its own steps with report.Step(StepPhase.Setup, ...) or round.Step(StepPhase.Setup, ...); evidence attached inside a step records that step's phase.
What ran. Every result.json names the toolkit in Toolkit (ToolkitProvenance, introduced in schema 4), read when the result is written. Packages contains package coordinates recorded by the toolkit and ValheimCLI transport assemblies: version, source commit, state and the assembly file's SHA-256 at report time. It does not hash a .nupkg or prove that the entry tool came from those candidate packages. LoadedAssemblies separately records the assemblies actually observed in the process, including the entry runner, with their build metadata, file SHA-256 and loaded module ID (Mvid). Compare the runner's commit, state and hash with the libraries: a source-built valheim-test can run newer code while reusing an older candidate package set. A source-built run validates that source tree only. To claim a package change passed a native test, install a fresh package-only consumer of the exact candidate or released packages from that commit and run it; the release contract independently checks published packages against the exact release commit. A Valheim.Testing* assembly is released when the release workflow built it (release.yml packs with -p:ValheimTestingRelease=true), candidate when its version carries -candidate, and unreleased otherwise. Runner and RunnerSha256 retain the entry name and hash for older readers; when provenance cannot be read, Error says why and the result is still written. Plugins holds, per actor (server, each client by name, the hosting client), the plugin pins its game confirmed with cli_expect when it started: plugin GUID to MD5, or absent.
Hosted copies retain the source install's BepInEx/config/BepInEx.cfg when no reviewed loader package or explicit config replaces it. If a reviewed loader package has no BepInEx.cfg, a macOS profile keeps the source file and a Windows or Linux profile has none (BepInEx writes its defaults; #711); if it has one, the package file wins. An explicitly selected config wins over both. The staged runtime checks the file against the selected source or package hash, so an arm64 Mac entrypoint setting does not silently revert to BepInEx defaults.
What the output keeps. The evidence stays: result.json, junit.xml, the plan and input hashes, each boot's logs and recorded commands, and the world copy, which is the run's actual save. The server's profile goes once its server has stopped, on a pass, a failure or validate. Apart from what the run wrote, it is the install's loader and mods (on Windows, links to the install's game files), which the input hashes already record. Before it is removed, every file the run added or changed in it (BepInEx's log, configs, a mod's cache) is kept in runtime-changes/ under its relative path. runtime-changes/changes.json lists the added, changed and missing files. A file over 1 GB, or past 2 GB kept in all, is listed there with its size and SHA-256 instead. runtimeCopy in the report says what was removed and kept, and outputBytes gives the output's final size, which the runner also prints. The profile stays when its server did not stop cleanly, because a process may still use it, and so does the host's lock: the report names it, and valheim-test env recover removes it once the server has stopped. To keep the whole profile for hands-on debugging, set VALHEIM_TESTING_KEEP_RUNTIME=1 for any runner; in a campaign it keeps every actor's prepared install (the disposable characters still go). One owner retires what a run leaves, after every process it started has stopped: the clients' disposable characters, then each runtime copy, each recorded as its own Cleanup step, under the host locks the run already holds. A campaign has one run id (runId in the report) for its prepared installs (<runtime>/vt-prep-<runId>-<actor>), its server run's directory and its journal: on each host, beside the host's lock, journal/<runId>/<actor>.jsonl gets one line before each copy or disposable character is made, another once it is made, and one when it is retired or kept, and the run's end is the last line (state, cleanupVerified). Each server boot and each remote client is journalled before it starts (process-intended, with its boot or launch directory, where its pid file goes, and the SHA-256 of the command line it will have) and after (process-started, with its pid and start identity); a server boot's two entries also carry its stop's quit budget (quitSeconds), and each host lock once held and released; a client's Steam account lease is journalled on the lease host, as held, released or kept (while its client may still run). A standalone run journals the same on its server host (this machine's too) under its own run id, for its server's profile (<runtime>/<runId>/runtime, staged through <runtime>/<runId>/staging) and the world copy it ships (<runtime>/<runId>/world) too: copy-intended before the profile is staged or the ship script runs, copy-done once made and verified, then copy-retired (the profile, once removed; the world copy, handed over as the run's evidence) or copy-kept. A profile whose staging failed part-way is removed by the stage itself (journalled copy-retired with failed), else by the run's teardown; a ship that failed partway is removed by the teardown; when that fails too, it stays open in the journal for env recover. A campaign's server run journals its world copy the same way. An entry the host cannot write stops the run before that copy, character or process, so an interrupted run leaves a record of every path and process it may own. A run with its server on another host does the same there, still under the host's lock. A Unix profile launch starts in the profile folder, so Steam's relative top-level logs/ output (connection_log_*.txt, stats_log.txt) lands there and reaches evidence when the profile is retired; files from multiple boots merge in that folder. If a Steam client is running, Steam may instead write its own logs in its own folder. The profile never takes the source install's top-level logs/ folder, and a standalone run checks the install against a pinned manifest without it. After the server stops, the run lists the profile again, compares it with the listing made when it was staged, and has the host keep the changed files in <run>/runtime-changes within the same limits. It fetches that folder to runtime-changes/ here (runtimeCopy in the report says what was removed and kept), and removes only <run>/runtime. Root files of a Windows install that the profile does not take are recorded as serverProfileLeftOut, as in a campaign. The run's world and boot logs stay in its host directory, and both are fetched too.
server-load uses the campaign's prepared runtime on every supported platform. The campaign owns its single verified server copy, records the files it stages there, and retires that copy after the server stops. A local or SSH macOS dedicated server uses this same prepared profile path; its world is temporarily placed in the server user's default worlds_local under the host lock and collected into the run's evidence after the server stops.
Copies left behind. Every copy WorldFixture makes on this machine is journalled there before it is made, in ValheimTesting's own folder (journal/<runId>/fixture.jsonl, beside this machine's default lock). Its removal or retire is journalled too, and so is a copy kept for a reason (kept on request, or one a server may still use). A run journals its copies under its own run id (a campaign, which is how server-load runs on every supported platform, a hosted run, a standalone PinnedServerRun and start each have one, recorded as runId in its result), so one run is one journal run; copies made outside any run share one run id per process. A preserved copy without a reason, such as a run's world, is handed over to the output it is in. So valheim-test env status lists a killed process's copies as that run's, and env recover --run ID removes them. Before removing a copy it keeps what changed in <copy>-changes, and a world copy, a run's save, is handed over rather than removed. A copy is recognised only by what the toolkit writes: a valheim-test- directory with 32 hex digits holding fixture-provenance.json. The data folder must be writable: a copy that cannot be journalled is not made, and the error names the folder.
Copies made before runs journalled them are named by no journal. env status lists them as UNJOURNALLED when they are under this machine's ValheimTesting folder or under the runtime of one of its environments. Each entry has its kind (server runtime, client runtime, world or other), its size, when it was made and which running processes use it (OwnedCopies.Find(root) reads the same for any folder). valheim-test env teardown --copy PATH (EnvironmentRuns.TeardownCopyAsync, or OwnedCopies.Remove(path, allowWorld)) removes exactly the copy named. It refuses:
- a path that is not such a copy;
- a copy a running process uses, or one a live process on this machine journalled and still holds (or an older owner record says it does);
- a copy an open run's journal still names (not yet retired, kept or handed over), which belongs to
env recover|teardown --run. A copy a run handed over to its output, such as its world save, can be removed by name.
Before removing a copy, it keeps what that copy's run changed in it in <copy>-changes beside it, with the larger limits when the run did not pass. There is no sweep, and nothing is removed because of its age.
How a run ends in its journal. One owner writes a run's last entry (RunFinish), for every command that ends a run: start, server-load with its A/B and phases, campaigns and sessions, env recover and env teardown --machine-gone, and a library HostedCampaignPreparation.PrepareAsync that fails. Such a run ends in every journal it wrote, on its hosts and on this machine, exactly once, whatever ended it: a pass, a failure or refusal after anything was journalled, or Ctrl+C. (A library PrepareAsync that succeeds hands its prepared campaign to its caller, and no end is journalled for it.) Its run-ended entry has one state word (passed, failed, unknown, recovered: runner gone or abandoned: its machine is gone), and cleanupVerified is true only when the run proved its own cleanup. A run refused before it journalled anything gets no entry, and an abandoned cleanup gets cleanup-abandoned instead (below). A run killed outright writes nothing; env recover records its end once it proves the runner gone and nothing left.
What runs left, from their journals. A journal line requires utc, run, actor, kind and the kind-specific fields object. It normally also names its runner: the machine, process ID and start time of the valheim-test process that wrote it. runner alone is optional because the first recovery records did not write it, and recovery is deliberately never treated as the run's runner; when no other line names one, the runner remains unknown. If runner is present, all three of its members are required. process-started also carries the SHA-256 of the process's command line, read on its host right after the start (Windows Win32_Process.CommandLine as UTF-8, Linux /proc/<pid>/cmdline, once the recorder's env has executed the game). valheim-test env status [--inventory FILE] [--json] (EnvironmentRuns.WriteRunStatusAsync) reads the journal of every host in the inventory (this machine with no file; only the hosts are needed, not an install) and changes nothing. For each run, it takes every copy, disposable character, process, Steam lease and host lock the run journalled and never journalled as retired or released, and checks each one on its host. A process still counts only when its ID, start time and command line all match the journal; an exited process or a reused ID is gone. A launch journalled as intended but never as started (a run killed in between) is judged by its pid file, which the launcher writes as the game starts (its ID and start identity: on Windows the start time as a file time, on Linux the start in clock ticks; a pid file from before this version holds a Linux process's ID only): when no process has that ID and start identity, the launch is gone, and an ID another process now holds is never that launch's and is never stopped. When the named process runs the command line the launch journalled, it is adopted as the run's process: env status names it (process <pid> (started <identity>), "adopted from its pid file") and env recover stops it by identity like any other. On Windows, where the launcher holds its start task until the run acknowledges the pid file, env recover then also stops that launcher's process tree and removes the start task, when the launcher's ownership file (below) names the same game and the journalled task, and only while the launcher is still the one recorded. When that game is already gone (it exited, or the launcher's own stop ended it), the launch stays its launcher (launcher <pid> (started <identity>)) until env recover has retired the launcher and removed the start task. A server launch whose pid file was never written (its start was killed before the launcher wrote it) is judged by the launcher's ownership file instead, which process-intended names (launcherOwnershipFile, and on Windows the start task's name): the launcher writes it into the boot directory before it starts the game (its own ID and start identity, and on Linux and macOS the process group it leads) and again with the game's ID and start identity before the pid file. While that launcher, still leading its process group (on Windows, in its recorded session), or the game it recorded, on the launch's command line, still runs, env status names the launch's launcher (launcher <pid> (started <identity>)) and env recover stops the game, then the launcher's process group or tree, and removes a Windows start task it left; a reused ID is never touched. A lock counts only while its journalled claimant still holds it. Each run that has not ended, or that left something, is printed with what it left, under one of these states:
LIVE: its runner still runs on this machine, or on a Windows host of the journal that is its runner's machine (asked through that host by process ID and start time, for example the station PC's runs read from a Mac);UNKNOWN: it never journalled its end, and only a recovery journalled it (so its runner is unknown), or its runner ran on another machine that is not a Windows host of the journal, or on one that could not be asked;UNRECOVERABLE: something it left cannot be proven its own or gone. Examples are a process whose ID and start time match but whose command line differs or cannot be read; a launch interrupted before its process was journalled whose pid file is missing or unreadable, names a running process without a start identity (a Linux launch from before this version), names one running another command line, or belongs to a launch journalled before launches journalled their command line; a server launch with neither a pid file nor a usable ownership file, or whose launcher is gone without having recorded whether it started the game; or a host that cannot tell;RECOVERABLE: it left things that are provably its own;KEPT: it ended and kept copies or leases on purpose (VALHEIM_TESTING_KEEP_RUNTIME, or a server that may still run).
Runs that ended and left nothing are only counted. A host whose journal cannot be read is printed as UNREADABLE, and none of its runs is reported as over. A journal line that cannot be read (cut short by a full disk or a killed write) is skipped; text and JSON name its run, file, line number and the missing or malformed part, and the host is not clean. The exit code is 0 when every host was read and every run left nothing, and 3 otherwise.
valheim-test env recover --run ID [--launch-gone] [--inventory FILE] [--json] (EnvironmentRuns.RecoverRunAsync) clears what that one run left, exactly as env status judged it; env status is its dry run. It refuses before anything changes when the run is LIVE, UNKNOWN or UNRECOVERABLE, except for the guarded interrupted-launch case below. Otherwise it works in phases across every host:
- each process the run started is stopped only if its ID, start time and command line still all match the journal, checked again right before the stops (by start time, so a reused ID is never touched). It is asked to quit first by the stop of its kind (on macOS, server or client, SIGINT through the one Mac stop,
MacProcess, which checks the command line again itself; elsewhere a server with SIGINT, or Ctrl+Break on Windows, and a client as a remote client's stop asks it), given the quit budget its own stop had (a server boot journals its plan'sQuitSecondsasquitSeconds; a client gets a session's default, 60 s), and killed only after that; - on each host where no process of the run was left, under the host's lock (the run's own when it still holds it, otherwise taken for the recovery, which an active run's lock refuses), and once no conflicting game runs there, its disposable characters are retired and its prepared copies removed, and so are a standalone run's own server profile (with its staging folder) and a world ship that never finished (a world copy that was verified is the run's save, handed over where it is). Before a runtime copy goes, the game's own logs in it are kept in the run's evidence folder on that host,
<runs>/<run>/recovered-<actor>beside itsboot-Nand client launch folders: BepInEx'sBepInEx/LogOutput.log, Unity'stoolkit-unity.log(a server's-logFile) and BepInEx'spreloader_*.log. A client's Unity log is already in its launch folder in the run's directory (its launch's-logFile), so it stays with the run. Only a log written since the copy was made (its journalled start) is the run's; an older one came with the install and is named, not kept. The output names the folder, each log kept and each one left out. A copy whose logs could not be kept is not removed, and the step says so; - its Steam leases are released by their own lease ids, but only when no process of the run was left on any host and every host's journal was read, so a client still signed in to the account keeps its lease. Lease ids are journalled since this version; an older lease is checked in the inventory's lease directory, is gone once its owner no longer holds it, and otherwise stays
UNRECOVERABLE(a lease never lapses; see the escape hatch below); - each host lock is released by its own claimant, but only once nothing of the run is left on that host.
After its steps, env recover reads the journals again and checks that nothing of the run is left. That check reads the run's own hosts (those that hold readable entries of the run, the inventory's lease host, and a host that answered with lines it could not read): a host that cannot be reached at all and shows no entry of the run, such as a NAS that is powered off, does not fail a recovery that did every step. Journal entries name no hosts, so whatever the run left only on such a host is unknown; the output names the host in a NOTE, and env status lists it once the host is back. The Steam lease release and the clean end it records for a run that left nothing still need every host's journal.
A step that fails keeps everything that depends on it, and the rest waits for the next env recover. Each effect is journalled under the actor recovery, after it happens, and each host gets a run-recovered line. env status then shows the run as ended, or names what is still left. Copies and leases a run kept on purpose (KEPT) stay; valheim-test env teardown --run ID does the same as recover and removes those too, including a lease kept because its client might still run, once that client is proven stopped. Nothing is ever removed because of its age, and a Steam lease never lapses on a timer. The exit code is 0 when nothing of the run is left and 3 otherwise.
An UNRECOVERABLE launch may mean the runner died after process-intended but before its pid folder could identify the game. After reading that verdict and independently confirming the machine is the intended disposable host, run valheim-test env recover --run ID --launch-gone. It is accepted only when every unprovable open item is such a launch and every involved journal is readable. A host that still has this run's exact lock is checked while that lock remains held; a host without one is checked under a new journalled recovery claim. Only an idle check permits the stale lock to be released and replaced by the recovery claim. A refusal before any swap leaves the stale lock unchanged; if a later host refuses, or the command is interrupted, after an earlier host's stale lock was already swapped, that host keeps its recovery claim only until the command ends, so its run's items stay open and preflight keeps refusing the host until env recover finishes. The same recovery claims remain held while it appends launch-abandoned with the checked reason and the operator's --launch-gone confirmation and hands the run to ordinary recovery. A claim is released only after that host's recovery succeeds; if something stays, the output says that the recovery claim stays too, and preflight keeps refusing the host. Another run that wins the release/claim boundary refuses the command.
The process check is OS-specific:
- Windows enumerates
Win32_Processacross sessions. It compares the image name (and the leaf ofExecutablePathwhen readable) case-insensitively withvalheim*, and hashes each readableCommandLine. A game image is busy even when its path or command line is hidden from a non-elevated caller. Kernel/minimal entries such as PID 0, PID 4,Registry,Memory CompressionandSecure System, and other non-game processes whose command line is inaccessible, do not make a journal-hash check unknown. Failure to enumerateWin32_Processstill refuses. - macOS runs
LC_ALL=C ps -axww -o pid=,uid=,comm=for executable paths and, when journalled hashes exist, onecommand=listing for every command line. The final field is always the rest of the line, so paths and arguments may contain spaces; hashes are computed from that one all-process command listing rather than apsquery per PID. A failed listing or a row whose command text is needed but unavailable refuses. - Linux lists PID, UID and image with
ps, and reads/proc/<pid>/cmdlineonly when a journalled hash must be checked. A non-root check refuses when/procuseshidepid=1,2,invisible,noaccessorptraceable, because other users may be hidden. It also refuses inside a detected container/PID namespace (including/.dockerenv), because host processes are outside its view. An unreadable required/proccommand line refuses; root may check ahidepidmount when it can still read it.
The command refuses a matching game image or journalled command-line hash under any visible user or session. It never deletes a journal file. Do not edit, move or archive journal files to settle a verdict; if these checks refuse, preserve the named file and resolve the reported process, lock, visibility limitation, incomplete observation or malformed line instead.
valheim-test env teardown --run ID --machine-gone [--inventory FILE] (EnvironmentRuns.ReleaseLeasesOfGoneRunAsync) is the escape hatch for a run whose machine is gone for good, so env recover can never prove its clients stopped, and for a claim whose reply was lost (no journal names it, and no client started on it). The flag is the maintainer's confirmation that no client of that run can still run; a client that still does is not told. On the inventory's lease host it releases every unreleased lease in the lease directory that names the run (in the claim's run line, or in an older claim's owner <runner> <run> client <name>) and journals each release there as lease-released with machineGone=true under the actor recovery. When it released a lease of a run whose end no readable journal holds, it journals that run's end there too (abandoned: its machine is gone), so env status and preflight stop waiting for it; it touches nothing else of the run. It refuses a run that is LIVE, a lease host whose journal cannot be read, and a RECOVERABLE or KEPT run when every host was read (then env teardown --run ID settles it the ordinary way). It needs no new configuration.
session.OpenClient(plan.Client) opens the plan's client through a ClientActor, the one client open path, whose logs (a failed startup's included) the teardown scan reads: on this machine (ClientActor.OnThisMachine, launched or attached as the plan's mode says), or, in a campaign, an owned client started in its assigned host's desktop session (below). Each call is one open; a client the scenario left open is closed at teardown, before the server stops. A runner that opens a client itself uses ClientActor.OnThisMachine(name, plan, output): Start() opens it once, Game is its GameActor, Logs are the logs to scan, and disposing the actor closes it.
A clean test runtime is BepInEx core plus your plugins, with an empty patchers folder. Preloader patchers rewrite game assemblies before any plugin loads, so one a removed mod left behind breaks the whole run with a TypeLoadException on a game type. The patchers pin hashes everything the folder holds, so a leftover patcher is refused before launch, naming the folder's entries; pin a patcher only when the run needs it (for example a hook generator a dependency requires). Plans do not name patchers; a plan that does is refused with that instruction.
Ctrl+C and SIGTERM cancel the run. Its cleanup (stopping what it started, retiring its copies and characters, releasing its leases and locks) then runs on a token of its own, never the cancelled one, and is bounded by 5 minutes (RunCancellation.CleanupBudget). A second Ctrl+C while cleanup runs is the escape from a cleanup that hangs: it stops waiting, and the remaining cleanup steps are recorded as not attempted. The run's journal gets cleanup-abandoned with the reason in place of its end, the runner prints ABANDONED cleanup with the command, and it exits 3. valheim-test env status then lists the run as recoverable, and env recover --run ID finishes the cleanup. The escape never kills anything itself. Options declare the mod (Mod, a ModDeclaration: its session capability and token variable and, optionally, its Harmony census capability, owner and declared patches, which the session checks on the server at runtime-ready as the Setup step "server: the mod's Harmony patches are applied" whenever the server's pins load the mod), apply the mod's own rules to the plan as it will run, for validate and run alike (CheckPlan, a campaign's plan once bound), and add provenance.
Game hosts and the environment inventory
Several machines
A mod author often edits on one machine and runs the game on another: a Windows gaming PC, a Linux box or container for the dedicated server, a rented VM. An SSH Mac server can run a prepared profile campaign; a standalone --inventory server refuses a remote Mac host. A private environment inventory lists your machines (hosts) and, in preference order, the game installs on them (environments) a run may use; an IGameHost does the work on that machine. A standalone run places its dedicated server from it (--inventory); a campaign assigns each of its declared actors (a server and named clients) an environment from it. The inventory holds no credentials and no Steam IDs: a client's identity is the one signed in on its host.
{
"hosts": {
"linux-box": { "kind": "ssh", "platform": "linux", "shell": "bash", "destination": "tester@linux-box.example",
"sshOptions": ["IdentityFile=~/.ssh/valheim_tests"], "lock": "/var/tmp/valheim-testing/lock" },
"gaming-pc": { "kind": "ssh", "platform": "windows", "shell": "pwsh", "destination": "tester@gaming-pc.example",
"lock": "C:\\ValheimTesting\\lock" },
"server-ctr": { "kind": "container", "platform": "linux", "shell": "bash", "container": "valheim-server", "user": "valheim",
"lock": "/home/valheim/lock" }
},
"environments": [
{ "name": "box-server", "host": "linux-box", "roles": ["server"], "install": "/opt/valheim/server",
"runtime": "/srv/valheim-testing/runs", "cliPort": 5577, "gamePort": 2466 },
{ "name": "pc-client", "host": "gaming-pc", "roles": ["client"], "install": "C:\\Games\\Valheim",
"runtime": "C:\\ValheimTesting\\runs", "cliPort": 5578 }
],
"leaseHost": "linux-box",
"leaseDirectory": "/var/tmp/valheim-testing/leases"
}
EnvironmentInventory.Read(path) refuses unknown fields (a credential or Steam field among them, without repeating its value) and validates everything at once before anything starts: known kinds, platforms and shells (pwsh on Windows, bash on macOS and Linux, nothing else; host shells), ssh destinations and options as SshGameHost accepts them (no password, the port given once), absolute paths in the host's own style, an SSH macOS host only for a dedicated server, no client environment on a Linux host (Linux hosts run the dedicated server only), an environment's roles either ["server"] or ["client"], a gamePort only on a server environment, and a listed leaseHost and absolute leaseDirectory when any client environment exists (left out, this machine's when every client is on it). Whether a runtime lies outside its install is decided on the host when a run prepares its copy or profile there, with links resolved on the host: a runtime or staging directory that is the install, lies inside it or holds it is refused, however it is spelled, and so is one whose links the host cannot resolve. Assigning actors then refuses one game client per host twice, a ValheimCLI port used twice on one host and two actors reached on the same port of this machine (a local host's or container's CLI port, an SSH environment's localCliPort). A local host must describe this machine's platform. An inventory describes one person's machines, so keep it beside the mod's tests, not in them. SSH uses keys or an agent only (BatchMode is always on, a password is never accepted), and the host key must already be known. Each sshOptions entry is Name=value, and the value is taken literally: ssh reads a -o value like a line of its config file, so a value with spaces, quotes or a leading # (User=Some Name, IdentityFile=C:\Users\Some Name\.ssh\key) is passed as Name="value", with " and any backslash ssh would read as an escape escaped; other values are passed unchanged. An empty value or one with a control character (a tab, a line break) is refused. A value that needs an escape (a ", \\ as in a UNC path, or a trailing backslash) needs OpenSSH 8.7 or later, which reads escapes; other quoted values, such as a Windows path with spaces, read the same in older versions.
| Operation | IGameHost |
Contract |
|---|---|---|
| Run a script | RunAsync(script, variables, timeout) |
bash or PowerShell. The script travels on standard input, never on a command line, and each variable arrives as a quoted literal no shell interprets. A small wrapper on the host reports the script's own exit code, so a Windows login shell that turns every failure into 1 still returns the real code. |
| Take the host lock | AcquireLockAsync(lockPath, owner, timeout) |
The exclusive creation of an owner file in the lock directory; exactly one run wins. Each acquisition claims as the given owner plus a unique id (HostLock.Owner), so two runs that pass the same owner never both hold the lock and only the holding handle releases it. Another claimant's lock throws HostLockException with its holder, at once: nothing waits or retries. An unproven claim names the claimant it tried, to check with before deciding what to do. Disposing the HostLock releases it and throws if the release cannot be proven. A lock left by a crashed run stays until a person removes it. |
| Ship a revision or files | ShipRevisionAsync(repository, revision, dir, timeout), ShipFilesAsync(localDir, dir, timeout) |
git archive of a commit (tracked files only, never the working tree) or a local directory, as a tar checked by SHA-256 on the host and extracted into a new directory; SOURCE.txt there records the commit, tree and hash. |
| Follow a log | LogOffsetAsync(log, timeout), then WaitForLogAsync(log, offset, success, failures, timeout) |
Event driven on the host (tail -F, or a file watcher under PowerShell); lines are matched here with .NET regular expressions, failure patterns first. Take the offset before the action whose line you await. The log may not exist yet. A log the game recreates at each boot is best given a fresh path (the run's own runtime copy) and offset 0. Expiry returns TimedOut; EnsureMatched() throws the same WaitFailedException and WaitTimeoutException as LogWait. |
| Fetch evidence | FetchDirectoryAsync(dir, localDir, timeout) |
A tar of the host directory, hash and size checked on both ends, extracted into a new local directory; entries that would land outside it are refused. |
| Reach ValheimCLI | OpenCliTunnelAsync(cliPort, readyTimeout, localPort = 0) |
See below. |
A host result is Exited with the script's code, TransportFailed (ssh exit 255, a Docker error or a missing executable, without the host's report), or Unknown. A deadline always gives Unknown with TimedOut set: the script may not have run, may have finished or may still be running. Succeeded and Failed are both false for it, and a lock claim that timed out is HostLockState.Unknown, neither yours nor free. Nothing is retried, because a mutation must never be repeated blindly; check with a read-only script before deciding what to do.
A Windows host without pwsh fails every script with a transport failure that names the install command (host shells); env preflight and a session's host checks show it before anything starts.
ValheimCLI listens on its host's loopback only, and stays that way. For an SSH host, OpenCliTunnelAsync starts ssh -N -L 127.0.0.1:<local>:127.0.0.1:<cliPort> with ExitOnForwardFailure and GatewayPorts=no; neither end is ever bound beyond loopback. A requested local port already in use is refused before ssh starts (a client would otherwise reach whatever listens there); 0 picks a free one. Checking the port and ssh binding it are separate steps, so another process can take it in between: ssh then exits (ExitOnForwardFailure) and the tunnel fails, but a readiness probe made before ssh exited could reach that process. ssh would also open the LocalForward, RemoteForward and DynamicForward entries an ssh config gives the host, possibly beyond loopback, and ClearAllForwardings would clear the tunnel's own forward too, so a host whose effective config (ssh -G) has any is refused. The tunnel is ready when a local connection succeeds, and disposing it stops that ssh process and no other. Connect as usual to 127.0.0.1:tunnel.LocalPort, for example StateWait.Connect(tunnel.Address, tunnel.LocalPort). A local host's CLI is its own port. A container's CLI is reachable only when the container shares the Docker host's network (--network host, Linux, a local Docker daemon): a published port reaches the container's own interface, never its loopback, so any other network mode is refused. Script runs pass ClearAllForwardings=yes, so an ssh config cannot add listeners to them.
| Host | Kind | Shell | Install (example) | Runtime and lock (example) | Server | Client | Tools the scripts use |
|---|---|---|---|---|---|---|---|
| Windows | ssh (OpenSSH Server; any login shell) or local |
pwsh (PowerShell 7) |
C:\Program Files (x86)\Steam\steamapps\common\Valheim dedicated server |
C:\ValheimTesting\runs, C:\ValheimTesting\lock |
Yes | Only in the signed-in desktop session; an SSH session has none | System32\tar.exe (Windows 10 1803, Server 2019 or later) |
| Linux | ssh or local |
bash (POSIX login shell) |
/opt/valheim/server |
/srv/valheim-testing/runs, /var/tmp/valheim-testing/lock |
Yes | No (Linux hosts run the dedicated server only; #707) | coreutils (base64, mktemp, sha256sum, tail -F), tar |
| macOS | local or ssh (Remote Login) |
bash |
/Users/<you>/Library/Application Support/Steam/steamapps/common/Valheim |
/Users/<you>/ValheimTesting/runs, /Users/<you>/ValheimTesting/lock |
No (refused by the profile) | A campaign client in the unlocked desktop session, local or reached over SSH | shasum, BSD tail and tar; SSH clients also need launchctl access to their own GUI domain |
| Linux container | container (docker exec -i) |
bash |
/opt/valheim/server (docker/linux-server) |
/home/valheim/runs, /home/valheim/lock |
Yes | No (dedicated server only) | as Linux |
Paths are literal: they arrive as quoted values, so spaces are fine, and they must be absolute because an SSH session's working directory is the user's home. A lock is per host, so two containers on one machine are two hosts. A bash host needs a POSIX login shell for SSH; a PowerShell host works under cmd.exe, bash or PowerShell.
CI checks every host kind: unit tests with fake processes cover the commands each kind starts, transport failures, timeouts (unknown outcome) and lock contention; the same end-to-end checks (literal values and exit codes, a timeout, lock contention, shipping a revision and files, fetching them back, following a log from an offset) run through the real local shell on every CI OS (bash on Linux and macOS, pwsh on Windows), and the game hosts job runs them over a real ssh to a throwaway sshd on localhost (bash sessions, the loopback tunnel, an unreachable port; the client key's directory has a space in its name, and ssh -G reads quoted option values back as given) and into a throwaway container on the host network.
Limits: GameLaunch.ToStartInfo and ClientSession.Launch launch processes on this machine. PinnedServerRun --inventory runs a dedicated server on a Linux or Windows host, or on this Mac (below); a remote SSH Mac server uses a prepared profile campaign; in a campaign, InteractiveClient starts Windows and SSH Mac clients in their desktop sessions, while a local macOS client uses ClientSession.Launch in the runner's GUI session. A native server-only run (Valheim 1.0.16) passed from macOS over SSH to a Linux VM: the host's lock, the runtime copied and verified on the host, the world shipped and verified there, the loopback tunnel, the owned-session identity, a confirmed save, a server-only restart, the stop by identity and the evidence fetched back. A Windows client was started in its desktop session over SSH during a native crossplay run. A server host in a container has not run a Valheim server this way. On a Windows runner, the default SSH client is the inbox ssh from PATH, which reaches keys held by the Windows ssh-agent service. When the runner itself is inside an SSH session, the default is instead Git for Windows' matching ssh.exe/scp.exe pair: the inbox ssh.exe can hang there when a .NET process sends a script through redirected stdin (#635). Git's client cannot open the named pipe that carries a forwarded agent in a Win32-OpenSSH session, so a host reached from such a session needs a key file (IdentityFile=). A runner counts as inside an SSH session when SSH_CONNECTION or SSH_CLIENT is set to a non-blank value, the same test that picks Ctrl+Break as the Windows quit signal. If that pair is absent in an SSH session, the host refuses before staging and names the remedy. Set the inventory host's ssh to an explicit tested client path for a custom install; an explicit path always wins, while on Windows the bare names ssh and ssh.exe (in any capitalisation) are the default, so forcing the inbox client from inside an SSH session takes its full path. macOS and Linux use ssh from PATH. A hosted server run records the chosen client and why in its provenance (serverHostSshClient). A Windows dedicated server has started headlessly from a disposable copy over SSH: BepInEx loaded, ValheimCLI listened through the loopback tunnel, the game opened its Steam server, and Ctrl+Break made it save and exit cleanly in 45 seconds. This was a focused HostServer smoke; the complete pinned PinnedServerRun --inventory Windows path still needs a scenario run.
A server-only plan on a remote host
PinnedServerRun takes the inventory before the mode, so the same runner and plan run the dedicated server on another machine, for example from macOS to a remote Linux or Windows host:
dotnet run --project tests/Valheim.Testing.NativeAcceptance -- --inventory ../environments.json validate plan.json <new-output-directory>
dotnet run --project tests/Valheim.Testing.NativeAcceptance -- --inventory ../environments.json run plan.json <new-output-directory>
A standalone run has one actor to place, its dedicated server: the first server environment in inventory order whose host and ports can run the plan (below). Its choice and the reason, including each environment it skipped and why, are recorded as serverEnvironment in result.json, beside inventorySha256. When none fits, the run is refused before anything is written. Clients open on this machine; remote clients and several actors are a campaign's (below). A Windows server environment uses the same plan and command:
{ "name": "pc-server", "host": "windows-server", "roles": ["server"], "install": "C:\\vt\\install", "runtime": "C:\\vt\\runs",
"cliPort": 5577, "gamePort": 2466 }
The server host can be Linux over SSH, a Linux container (kind: container, --network host), this Linux or macOS machine (local) with bash, an SSH Mac with bash in a prepared profile campaign, or Windows over SSH with pwsh. An SSH Mac server uses a prepared profile campaign with a pinned native arm64 loader and SteamCMD runtime manifest. A Windows server host uses "platform": "windows" and "shell": "pwsh" over SSH. It starts the server through a short-lived scheduled task, with no Steam client needed. When the session asking is elevated (SSH as an administrator) the task is S4U and the server runs in session 0, with no signed-in desktop. In a user's own ordinary (UAC-filtered) desktop session, the one-PC case, Windows refuses S4U, so the task uses that session's interactive token at the limited run level and the server runs in that desktop session, with its console window there; signing out stops it. A session that is neither (not elevated and not a desktop session, or a user with several desktop sessions) is refused in preflight, naming why, before anything is staged. The server's profile hard-links the install's game files, so preflight requires its environment's runtime folder on the install's NTFS volume before staging. The environment's cliPort must be the plan's port (the runtime's ValheimCLI [Server] Port), and a -port in the plan's arguments (in any case, as the game reads it) must be its gamePort; an environment that does not fit is skipped for the next. A crossplay plan starts the host's server with -crossplay. A Linux host then needs libpulse0 and libpulse-mainloop-glib0, which the game's libparty.so loads; step 4 checks that they are there. On Windows the crossplay preflight requires Party.dll; game startup proves whether it loads. The runner:
- takes the server host's lock (
HostLock) for the whole run; another run's lock refuses this one before anything on the host is touched; - checks every runtime manifest, game build, loader, patcher and plugin pin the plan supplies against
server.installon the host (links refused and Steam'slogs/left out). The plan'sruntime.sourceis not read; the host's install is the runtime. Only then does it stage the server's disposable loader profile in a new run directory,<server.runtime>/<runId>/runtime, the way an owned client's is made: the install's own BepInEx plugins, scripts, config and patchers (fetched here and checked against the install's listing when the host is another machine), the toolkit's pinned ValheimCLI set in place of any ValheimCLI build of the install's own, on the pinned BepInExPack (Windows and Linux; a macOS install keeps its own loader) or on the environment'sloaderPackagewhen it names one. A supplied pin for the install's ValheimCLI is checked there before that intentional replacement; when the replacement has another MD5, its staged pin is recorded as observed. The install is never changed: on Linux and macOS the server runs the install's own executable with Doorstop pointed at the profile, and on Windows from the profile's launch folder, which hard-links the install's game files. The plan is then bound to the profile: itsruntimemanifest to the profile's game files, itsruntimePinsto the profile's (whose loader is the profile's own), and every staged startup plugin inBepInEx/pluginsorBepInEx/scripts, including ValheimCLI, to its MD5. A script plugin is accepted only when the same selection pins ScriptEngine inpluginsand stages its configuration withLoadOnStart = true. Any value omitted from a standalone plan is observed there; a campaign arrives with its lock's expected pins; - copies and verifies the plan's world here as usual, ships that copy to
<run>/world(a tar checked by SHA-256 on the host) and verifies every file there again; - checks the profile as a local runtime is checked: the server executable (and its execute bit on Linux) and the bound
runtimePins(computed on the host's hashes, recorded asruntimeGameSha256and the rest), and again, with the install's pinned paths, right before each boot. A Windows profile also rechecks every root native DLL it hard-linked. The complete source was hashed once during preparation; it is not re-hashed before each boot. For crossplay, Linux checkslibparty.sowithlddand Windows requiresParty.dll.validatestops here; - refuses a CLI port something already listens on at the host, then opens the loopback tunnel to it (
OpenCliTunnelAsync); ValheimCLI is reached only through it and never listens beyond the host's loopback; - starts each boot with
HostServer.StartAsyncin<run>/boot-N: the plan's arguments and environment with{runtime}and{world}as the host's paths, the owned-session token in the server's environment, Doorstop andSteamAppIdasGameLaunch.ForServersets them, in its own session (setsid) under a small recorder on Linux; on Windows, a temporary task starts the server independently of the asking session (S4U in session 0 when elevated, otherwise in the user's desktop session), records its PID and start time and how it logged on (boot-N.process.jsontaskLogon), and is removed. An earlier boot'sBepInEx/LogOutput.logmoves into the boot directory first, so the startup waits for ValheimCLI's listening line in the host's log from offset 0 (StartupEvents.CliListeningWait), then the loaded-world push and the identity handshake (token, process ID, save root) through the tunnel; - stops only the process it started, by process ID and start time (
HostServerProcess), moves the boot's logs into its boot directory and fetches it toboot-N/here (game-0.logBepInEx,game-1.logUnity with-logFile {runtime}/toolkit-unity.log;stdout.logandstderr.logonly where the host records them: Linux and macOS do, a Windows server's start task does not, so a Windows boot's log scan lists no stdout), fetches the world copy tohost-world/, and compares the complete source install, includinglogs/, with its preparation listing by size and modified time. The source-integrity verdict is written toresult.json; a changed, missing or added file is named and fails the run, is never repaired, and does not prevent the owned profile from being removed. SetVALHEIM_TESTING_SOURCE_HASH_AFTER_RUN=1on a gate run to compare content hashes instead. Teardown keeps what the run changed in the profile (runtime-changes/, above), closes the tunnel and releases the lock. The rest of the run directory on the host is never deleted automatically.
To review expected pins in advance, derive a runtime manifest and runtimePins for a host install from the host itself: HostInstall.ListAsync(host, install, timeout) returns every file's SHA-256 (Files, the manifest) and HostInstall.Pins(listing) the pins. A reply lost during the start or the stop leaves the outcome unknown (UNKNOWN, exit 3) and keeps the host's lock, because a server may still run there; remove <lock>/owner by hand once it has stopped. A Linux host needs bash, coreutils (base64, mkfifo, sha256sum, tail), find, setsid, tar and /proc; the Linux server image and Ubuntu have them. A Windows host needs PowerShell 7 (host shells), OpenSSH when reached remotely, and either an elevated session (an administrator over SSH: an S4U task) or the user's own single desktop session (an interactive-token task). Its stop sends Ctrl+Break to the exact server process and kills it only if the clean quit deadline expires.
Clients. In a campaign, session.OpenClient(plan.Client, "player") (the name may be left out when the campaign declares one client) starts an owned client on the host its environment was assigned, through InteractiveClient: the install is its prepared profile launch and the CLI port its environment's, the install's patchers and installPins are checked on the host's hashes, the client host's lock is held until teardown, its earlier BepInEx log moves aside, ValheimCLI is reached through that host's tunnel, and disposing the session stops only that client and fetches its BepInEx log and its Unity log (toolkit-unity.log, which its launch's -logFile puts in that launch folder) to client-N/. Every client of a campaign (with or without a dedicated server, the hosting client included) writes its evidence into its own folder <output>/<name>/ (client-process.json, client-commands.jsonl, its kept logs and client-N/), because clients start at once and each launch writes files of the same names; session.OpenClient(plan, name, directory) with a directory uses that folder instead. A run with one client on this machine and no campaign keeps it in the output itself. A client assigned to a Linux host is refused ("Linux hosts run the dedicated server only; start the client on Windows or macOS."). Before anything else on the client's host, its observed Steam identity is leased and its host checked to be signed in to it (below).
CI covers the runner against a fake host (the whole lifecycle with a restart, validate, an install that is not the plan's pinned runtime (refused before its profile is staged), a profile whose staging fails part-way, another run's lock, a lost start reply and an unproven stop as UNKNOWN with the lock kept, a scenario failure beside an unknown teardown as FAIL, a tunnel that cannot open, a busy port, an environment that does not fit the plan (skipped for the next one, or the run refused when none fits), a crossplay plan's launch, a crossplay host missing libparty.so's libraries, and a remote macOS client and the refusal of a Linux one), the launch, start, stop and listing replies with fakes, and the host scripts for real: install listings and pins through the local shell (bash, or pwsh on Windows), and, on Linux, locally, over ssh to localhost and in a container in the game hosts job, an install shipped, copied and verified on the host, a stand-in server (a script with the server's name) started, awaited in its log from offset 0 after an earlier boot's line, stopped by identity beside a bystander that survives, and its evidence fetched back with the arguments and environment it got. The same hosts check crossplay's libraries with their own ldd: a stand-in libparty.so (a copy of the host's true) loads, and a copy with a dependency renamed is refused, both by the check and by a crossplay start. Natively, a server-only run from macOS over SSH to a Linux VM passed (see Limits above).
Clients in a host's desktop session
A game client needs the logged-in user's desktop session: its display, GPU and signed-in Steam client. A process started over SSH or from a service has none of these, so ClientSession.Launch cannot start a client on another machine. InteractiveClient.StartAsync(host, launch, launchDirectory, timeout, display) starts one inside the host's existing session and returns an InteractiveClientProcess identified by process ID and start time, the identity an owned session checks against its adapter's reported process ID.
var launch = GameLaunch.ForClient(@"C:\Games\Valheim", ["+connect", "server.example:2456"], hostPlatform: ClientPlatform.Windows,
secretVariables: [plan.PasswordVariable!]);
await using var client = await InteractiveClient.StartAsync(new SshGameHost("gaming-pc", "tester@gaming-pc.example", HostShell.Pwsh), launch,
@"C:\ValheimTesting\runs\run-42\client", TimeSpan.FromMinutes(2));
// Then wait for ValheimCLI's line with WaitForLogAsync and connect through OpenCliTunnelAsync.
GameLaunch.ForClient(install, arguments, environment, hostPlatform, secretVariables: …)builds the same launch for a Windows or macOS install on a host, as data:-console,SteamAppId, Doorstop's variables on macOS, inherited Doorstop variables removed, and Doorstop overrides refused. The install is a path on the host; its game executable and BepInEx loader files are checked there before anything starts.- Windows (a
pwshhost): a scheduled task registered for the host user with "Run only when user is logged on" (an interactive token; no password is given or stored), under a unique name per launch (ValheimTesting-client-<id>). Starting it runs a small launcher in the desktop session, which starts the game and records its process ID and start time. The task is always removed again, whatever happened; if the removal cannot be proven, the client it started is stopped and the start fails with theschtasks /Deletecommand to run. A pwsh window may flash once as the task starts. - macOS (a
bashSSH host): the profile campaign checks that the SSH user owns the unlocked console GUI session, then starts a run-owned LaunchAgent in its Aqua domain. The agent launches the chosen arm64 or x64 game slice and records its process identity; stopping or recovery unloads that agent. A local Mac runner can instead useClientSession.Launchdirectly. The remote path still needs a native joined-client campaign check.
The host refuses before anything starts, with InteractiveSessionException: NoSession when the user has no desktop session there (Windows: no process of the user in a session other than 0; more than one session is refused too, because the task could start in either); NoSteam when no Steam client (steam.exe) runs as the user in it. A missing install file, an existing launch directory or a host of the wrong shell is refused as well. StartAsync returns once the game process exists, not when it is ready. StopAsync and disposal kill only that process, and only while its start time still matches, so a reused process ID is never touched; WaitForExitAsync waits on the host. InteractiveClientProcess is an IOwnedProcess, so it can be given to ClientSession.Launch(plan, output, start, connect, ready).
The launch directory keeps the evidence: the recorded identity (its pid file), the launcher's error (Windows). The launch spec is not kept: the Windows launcher deletes its spec.txt once it has read it, as the server's launcher does. Environment values given to GameLaunch.ForClient are not secret: on Windows they pass through that file. A secret, such as the server join password named by ClientRunPlan.PasswordVariable, is named in secretVariables instead: its value is read from this process's environment at launch (a missing one is refused first), never goes on a command line and never enters the composed script, which the host's wrapper keeps in a temporary file while the script runs (and which a killed wrapper, for example after a dropped SSH connection, can leave behind). It follows the script on standard input as a line of its own, which the wrapper holds in memory and hands to the script as the variable VT_SECRETS; the script clears it at once. On Windows it reaches the desktop session through a vt-secrets-<id> file in the user's own temporary directory, which the launcher deletes as it reads and the start script deletes again when it ends; a start killed in between can leave that file (and the task, named in the error) behind. It is redacted from every reply and exception.
Steam account leases
Steam allows one running session per account, and every client needs an account that owns Valheim. A campaign reads the account already signed in on each chosen client host during its host preflight, refuses one it cannot read and two simultaneous clients on one account, and leases each observed identity for the run. You list no Steam IDs and write no account file; there is no opt-out. Rented GPU VM clients are experimental in this campaign flow.
The lease records on the inventory's leaseHost stop separate runs from using the same signed-in account at once. Inventories that may use the same accounts must name the same leaseHost and leaseDirectory. These are runtime claim files keyed by an opaque hash of the identity (the account's lease key), not a handwritten account list; old released claims are pruned.
- A lease is the exclusive creation of the account's next numbered claim file under
<leaseDirectory>/<pool>/<account>(a hard link under bash; a no-replace move under pwsh on Windows), which exactly one claimer wins. Only the holder, proven by its own lease id, can release it. An account is free only when its latest claim is released: a lease never lapses on a timer (#257). It is released by its run, or byenv recover|teardown --run IDonce the run's clients are proven stopped (a crashed run's lease included), or, for a run whose machine is gone for good, byenv teardown --run ID --machine-gone. A claim records its run id, so a run refused by a held lease is told which run holds it and theenv recover --run IDthat releases it once that run is over. A claim's second line is a far-future time, so an older runner that reads it as an expiry also sees it held. An unproven claim isUnknownand, if it happened, is held until a recovery releases it. A session's client holds its account as aSteamAccountHold, which the session acquires; the pool and its acquire and list are internal, and nothing waits or retries. - Reports and exceptions carry the opaque lease key, never a SteamID or a credential.
- The account playing on another computer (#257). Leases keep the tool's own runs apart; a person (or another runner) can still be playing on the account. Starting a client on an account that plays elsewhere does not ask: Steam on the new machine logs
RecvMsgClientLoggedOff('Logged In Elsewhere')the moment the game registers, signs out, and exits by itself a few seconds later, while the game quits before its menu with nothing about it in its own logs (seen on 5 Oct 2026; the other session is untouched). So:session check --hostsand a campaign's preparation ask every other client host of the inventory (one the campaign runs no client on; the campaign's own client hosts already refuse any running Valheim) whether Valheim runs there as the host's user: its process list (Valheim.exeon Windows, by owner;Valheimon macOS, by user id). Another OS user's game is that user's Steam session and is left out. Where one runs, that host's signed-in account (read as the signed-in check reads it) must be none a client would use: a match is refused under the client's name, naming the host. An unreadable account, no signed-in account, a game whose owner cannot be read, or a host that cannot be asked is refused under the host's name: unknown is never a pass. A local host of another platform is another runner's machine and is not asked. Windows'HKCU\Software\Valve\Steam\RunningAppIDis not read: on the station it was readable (0 with no game running), but whether it names Valheim for a game started directly, as the tool starts it, is unverified. Steam'sconsole_log.txtis not read either: it adds only a game under another name, and after a Steam crash an entry never marked removed plus a reused process id would refuse a campaign for an unrelated process.- When a client starts, its host's Steam
logs/connection_log.txt(under the host's Steam root: Windows the user's registeredSteamPath, macOS~/Library/Application Support/Steam) is followed from its length just before the launch. "Logged In Elsewhere" in it ends the start at once: "Steam accountis playing on another computer; Steam on this machine ( ) has exited; when that session has ended, start Steam here and retry." A start that fails another way first (the client quit, a timeout) is named the same when the line is there. Earlier lines never count. A host with no Steam log to find only warns, and the failure is then the client's exit. The tool never answers a Steam prompt, signs in, or starts or stops Steam.
- Steam sign-in: sign into Steam on the client host before the run. The runner does not log into Steam or handle its password or Guard approval. A server join password is a separate plan setting and may still use
ClientRunPlan.PasswordVariable. - Characters: use a disposable local character for each account, staged on the host that runs it (the campaign's
character). The game keeps local characters under the OS user'scharacters_localand Steam Cloud characters under the account; a test must never create or use a cloud character, and one OS user per account keeps each account's local characters apart.
CI covers both with fake processes (the commands and scripts each platform is sent, every refusal, a lost reply as an unknown outcome, a task that could not be removed, stopping by identity, contention and holder reports, release outcomes, and a canary secret that must not appear in any reply, exception, spec, argument list, report or process command line, with a negative control that finds the same value when it is passed as plain environment; the composed script, which the host writes to a file, counts as visible) and through real shells: the wrapper's secret hand-off through the local shell on every CI OS (bash, or pwsh on Windows) and over ssh and in a container in the game hosts job (the script receives each secret, and while it runs no file in the wrapper's temporary directory, the script and the upload, holds one; the negative control passes the same value as an ordinary variable and the same scan finds it in the script file); leases through the local shell on every CI OS (bash, or pwsh on Windows) (ten concurrent claims on three accounts give three distinct holders; a crashed holder's lease never lapses and names its run, another run's id releases nothing, the escape hatch for its run releases it, and the old holder cannot release the next run's lease; a failed run releases its lease; old claims are pruned) and, in the game hosts job, over ssh and in a container; the Windows task start in the Windows runner's own console desktop session, where GitHub runs the job (a stand-in Steam and game: the game runs in that session under the recorded identity, the task is gone afterwards, no secret file is left, and stopping leaves a bystander with the game's name alone); and the refusal of a Linux client (a launch, an install, a profile's client host).
Limits: natively, a Windows client has been started through InteractiveClient in its desktop session over SSH, during a crossplay run; a Linux client is no longer supported (#708). Otherwise the Windows task path has been exercised on a CI runner, locally rather than over SSH, with stand-in processes. A lease store on a network file system has not been tested; it needs hard links (bash on Linux and macOS) or an atomic no-replace rename (pwsh on Windows).
A local macOS campaign client uses {"kind":"local","platform":"macos","shell":"bash"} with the runner in the Mac's unlocked Aqua desktop session. It reuses ClientSession.Launch for strict pins, local ValheimCLI connection and process-owned cleanup. A remote macOS campaign client uses the same inventory, stage, profile, host lock, Steam lease and journal as other actors. The SSH user must be the unlocked console user with a GUI domain and signed-in Steam account. Host preflight checks the game slice, selected loader and console before staging, and checks the console again before launch. The runner creates one run-owned LaunchAgent in that user's GUI domain, starts /usr/bin/arch with the selected slice and pinned profile loader, and reaches ValheimCLI through the normal SSH tunnel. A join password crosses in memory through a one-use FIFO. Stop and recovery prove the game process identity and unload that exact agent before retiring the profile or releasing its lease. A container or locked console is refused. The local-client native check on macOS 26.5.2 and Valheim 1.0.16 loaded a pinned local world by UID with a clean character, then stopped the owned client and removed the staged character and world. A Mac server plus an arm64 client reached through SSH to localhost passed a native joined-client load check on 11 October 2026; a runner on a separate machine remains unverified. For hosted local worlds on this Mac build, Valheim ignored -savedir and read the user's default worlds_local. The runner refuses a custom hostWorld.saveDirectory or -savedir before copying the fixture; use a disposable named world and let it stage into the signed-in user's default data directory. The exact world-UID check still refuses a same-named world from another directory.
The display need not stay lit, but the Mac must stay awake and its console session must remain unlocked. macOS may lock that session when the display turns off; if it does, remote preflight refuses before staging. To permit remote GUI runs with the display off, adjust macOS sleep settings and the Lock Screen password delay for that Mac. The toolkit does not unlock the session or change those settings.
A campaign: remote clients and Steam identities
A run with clients on other machines is a campaign: a manifest declares the actors (one dedicated server and named clients), each with its reviewed dependency lock and, for a client, its disposable character, and names the inventory they are placed from. A minimal one, one server and one remote client, with the inventory above:
{
"inventory": "environments.json", "world": "fixture-world", "join": "linux-box.example:2466",
"server": { "dependencyLock": "locks/server.json" },
"clients": { "player": { "dependencyLock": "locks/client.json",
"character": { "store": "characters", "registeredName": "tester", "fileName": "vt-player" } } }
}
For an SSH Mac dedicated server, give the inventory one macos/bash/ssh host and a server environment pointing to that Mac's SteamCMD server install and a writable profile runtime. The campaign server adds the two reviewed package manifests:
{
"server": {
"dependencyLock": "locks/server.json",
"loaderPackage": "locks/native-mac-loader.json",
"steamRuntimePackage": "locks/steam-runtime.json"
}
}
This is the server part of a campaign, not a second environment configuration. Capture steam-runtime.json on the Mac with valheim-test steam-runtime capture --steamcmd DIR --output steam-runtime.json, then copy that manifest to the runner. Its root still points to the Mac's SteamCMD directory. session check --hosts checks the manifest shape, arm64 loader, free space and host session before any host write. Profile preparation runs the SteamCMD source-byte, arm64-slice and dependency-closure check once before staging and once after copying; env recover --run ID uses the same journal and ownership checks after an interrupted run. Remote Login must be enabled for the SSH user. A remote Mac GUI client additionally needs that SSH user at the unlocked console with Steam signed in; its profile uses a run-owned LaunchAgent in the existing GUI domain.
The character is staged into the client host user's own characters_local and checked against that host's Steam userdata for a same-named Steam Cloud character: both folders are resolved on the client host from its platform's standard paths (Windows %USERPROFILE%\AppData\LocalLow\IronGate\Valheim\characters_local and userdata under Steam's registered SteamPath; macOS under ~/Library/Application Support). session check --hosts prints the resolved folders and refuses, naming every path it tried, when one is missing; set charactersLocalDirectory or steamUserDataDirectory on the character only for a non-standard place. Measured against what it replaces, for a server and one remote client: the old profile.json and accounts.json, plus the four plan fields that had to match them, were 32 hand-written fields; an inventory of two hosts (without sshOptions) and two environments plus this campaign are 33. The inventory is written once per set of machines; each further campaign is about 8 fields.
PinnedServerRun.RunCampaignAsync(manifest, plan, clients, output, options) runs a plan on it (the native acceptance suite wraps it as campaign check|run); valheim-test session check SESSION [--hosts] [--json] reports every independent problem first. With --hosts, it also reads the run journal of every host the campaign touches, the lease host included. It refuses while another run is going on one of the campaign's own hosts (LIVE), left something there or on the lease host (RECOVERABLE, naming env recover --run ID, or UNRECOVERABLE), or may still be going on another machine (UNKNOWN with a runner elsewhere). It also refuses when a host's journal cannot be read. A run going elsewhere that shares only the lease host is not a conflict, since its lease keeps its account apart. The conflicting-use check refuses a Valheim client already running where a client of this run needs the desktop session, and any game running from a copy this run is about to use. A game elsewhere on the host is not a conflict. Each refusal names the process: as a run's when that run journalled it (with the run's state), otherwise as your own game, to stop or to describe in the inventory. The dependency locks and the character replace what used to be prepared by hand on each client install; binding fills the plan's installs, ports, runtime, world and pins from the prepared actors. Each named client, as their own steps:
lease a Steam account for client player, before anything else on the client's host (its lock, listing, port check or start) and before an attached session assumes the client. A held account refuses the client at once, naming its holder run andenv recover --run ID; nothing waits or retries. The report's provenance records the opaque lease key and pool, never the SteamID.client player's host is signed in to Steam account <key> (signed-in check): the host's Steam account record must still match the identity observed in preflight (compared on the runner and never sent to a host or written to a report). Windows readsHKCU\Software\Valve\Steam\ActiveProcess\ActiveUser(the account id, the SteamID64's low 32 bits; 0 when none) in the SSH user's registry, which is the desktop user's; macOS reads the account Steam last signed in from the host user's~/Library/Application Support/Steam/config/loginusers.vdf: itsMostRecentuser, or, since current clients (the macOS client in September 2026) write noMostRecentkey, the user with the single newestTimestamp; a tie or noTimestampis unknown. Another account or none is refused before launch, and so is a state that cannot be read: unknown is never a pass.loginusers.vdfrecords a remembered account, not a live session; it alone cannot prove Steam is running or still signed in. The client start checks process availability separately, then fails promptly if its fresh BepInEx log reportsSteamAPI_Init() failed.- The lease is held for the whole run, with no renewals and no timer.
- At teardown, after the client stopped,
release client player's Steam account lease. A lease that was no longer this run's (a maintainer released it) fails this step. A release that cannot be proven fails it as an unknown outcome (UNKNOWN, exit 3, as an unproven lock release does); the lease is then held untilenv recover --run IDreleases it. A client the scenario left open is stopped first; one whose stop could not be proven keeps its lease (lease-kept), because it may still run on the account, untilenv teardown --run IDproves it stopped.
A lease or signed-in check that does not finish (an unknown outcome) says where its time went, because each of those scripts first reports when it began on its host. "The shell had not begun the script after 90 s" means process start or the transport was starved, for example by a game run loading the machine. "The script began on the host, and it was still running at the deadline" means the script itself was slow. Either way the outcome stays unknown and is never taken as a lost lease.
A session client's ClientSession (opened, launched or attached with the lease by the session's ClientActor) and InteractiveClient.StartAsync(hold, host, launch, launchDirectory, timeout) each refuse a released or unchecked lease (the interactive start also one taken for another host) before anything starts. Dispose the session before the hold: ReleaseAsync throws when the lease was no longer this run's or its release is not proven.
CI covers it with the real lease scripts through this machine's shell as the lease host (bash, or pwsh on Windows): two environments whose clients run on different hosts contend for one account (six runs at once, exactly one wins, the rest are refused with the holder), a crashed holder's lease never lapses, its refusal names its run and env recover --run, and only the escape hatch hands it to the next run while the old holder's release fails, a released lease starts and attaches nothing through ClientSession, an unproven release is unknown, and the hosted runner against fake hosts: the lease is held while the client runs and released after it stopped, a held account refuses the client before its host is touched, naming the holder run, a client that fails to start still releases, one whose stop is unproven keeps its lease, the signed-in check passes only the leased account (another, none, unreadable and an unexpected reply are refused before launch), an attached client leases too and --inventory skips a server environment that does not fit the plan. The loginusers.vdf reading runs through bash on macOS, the registry reading through pwsh on Windows. An unrelated environment canary and the SteamID reach no report, evidence file, host script or lease file, while the lease key does; the negative control finds the canary once the run is given it as its name. Not run natively: two hosts signed in to one test account, where the second must be refused while the first keeps running.
A campaign without a dedicated server (#258 step 8b) leaves server and join out: one of its clients hosts the world and the others are its peers. The manifest's world is the hosted fixture. The inventory assigns only the clients; the preflight, preparation, leases, locks and retirement are the same, and valheim-test session check SESSION [--hosts] checks it as any campaign. The plan names its clients' sections: exactly one has a hostWorld section (its world and worldUid are bound to the campaign's fixture) and every other one sets joinsHost (HostedCampaignPreparation.CheckHostedPlan). PinnedServerRun.RunCampaignAsync(manifest, plan, clients, output, HostedRunOptions<TPlan>) runs it: the session's Host is the hosting client and its Clients are the peers, each opened on its host on its leased identity (CampaignClients, the same client placement a server's campaign uses). Teardown closes the session (peers, then the host, then its world), releases the leases, retires the prepared installs and characters, then releases the client hosts' locks. A hosting client gets its fixture in its host user's own worlds_local, beside the characters_local its character was staged in (hostWorldOn in the provenance), on this machine as on another one (#807). Every other hosting client on this machine (a session's HostingClientActor, HostedClientRounds.Run, valheim-test start) uses this machine's client data directory, through the same journalled placement (#829). There the run first refuses any entry named for the world (<name>, <name>.*, <name>_backup*, any case: what the game writes for a world), journals the placement (a copy that holds hosted-world), ships the verified fixture into its own folder on that host (<runtime>/<runId>/host-world-stage) and checks every file, then moves the world's entries into worlds_local. Once no client can host it, the entries named for the world (the game's backups included) are moved out into <runtime>/<runId>/host-world, where they stay as the run's save, and fetched into the evidence. Nothing in the user's worlds is ever deleted. A world a killed run left there is listed by env status and moved out the same way by env recover --run ID, only once the metadata of every entry named for the world (an <entry>/_main.<n>.fwl2 or <entry>.fwl) states the world UID the run journalled placing; recovery refuses a world of that name with another UID or none readable, a journal line that names no world UID or no worlds_local, and a folder outside that run's directory.
Versions
This package depends on exactly the Valheim.Testing.Game version it was built with: it uses Game's internals, so the two always restore as the pair that was tested together.