World observations
The read-only probes and observations a system test reads from ValheimCLI's built-in commands: terrain, global keys, synced config, a client without the mod, and a dungeon's saved rooms.
The pinned valheimCLI.dll provides valheim.observe/* and the other built-in commands on every actor. No other command DLL is needed. A mod-specific action or observation goes into the mod's test adapter. Registered content and Harmony patches are on Content and Harmony census.
Terrain, surface and paint probes
| Type | Does | Fails or refuses when |
|---|---|---|
TerrainProbe |
Compares up to 256 declared height expectations (HeightExpectation(X, Z, Height)) against one explicit layer, generator or loaded-ground, and reports every residual |
A sample exceeds the tolerance, or an observation is incomplete (no heightmap loaded) |
SurfaceProbe |
Compares loaded heightmap vertices and the client's own mesh colliders separately; Supported requires a stationary, grounded local player |
The player is flying, attached, teleporting, or only at the right position |
PaintProbe |
Compares loaded paint channels at declared points | A sample exceeds the tolerance |
SiteSearch |
Finds test sites on a fresh world from generator heights read through ValheimCLI; the mod's own rule decides what a site is (Find(server, worldUid, grids, enough), Around(centreX, centreZ, spacing, side)) |
No site matches: SiteNotFoundException, with how many samples it read |
A read-only game check of each is in examples/ObserveCheck: the ground, surface and paint probes. SharedWorld is a no-game example with declared expectations. To record a site's ground, collider and paint as evidence instead of asserting on it, use a terrain site snapshot; for its objects, an area object snapshot.
Global keys
In Valheim 1.0.16 a key change goes to the server, which applies it and sends its whole list to every client. Each client replaces its list with it. Keys are stored lower case, as name or name value.
await GlobalKeyFixture.Apply(server, client, changePath, listPath, set: ["defeated_eikthyr"], remove: ["nomap"], timeout, interval);
Apply issues each change once through the fixture command, which refuses unless FixtureGate allows fixture commands. It checks that the server kept the change, then waits until the client lists exactly the server's keys. On expiry it names the keys the client lacks and the ones only it has.
| Piece | Does |
|---|---|
Set(server, changePath, name, value) |
Sets one key on the server |
Remove(server, changePath, name) |
Removes one key |
Read(actor, listPath) |
Lists a process's keys |
Compare(server, client) |
A GlobalKeyDifference: MissingOnClient, ExtraOnClient |
WaitForClient(server, client, listPath, timeout, interval) |
Waits until the client lists the server's keys |
The game's setkey console command is not used: it refuses a key that is not a world-modifier setting unless the game already counts as cheated. ValheimCLI's cli_check_global_key is not used because it answers for one key at a time and cannot show a key only the client has.
A world-modifier key (one the world-modifiers menu sets, such as nomap) set or removed through GlobalKeyCommands.Change is also written into, or removed from, the world's saved starting global keys, as the game does. Other keys, boss keys for example, are saved only in the world's own key list.
Synced config
var value = SyncedConfig.Read(actor, "valheim.observe/config", guid, section, key);
Read returns the entry's current value in that process, as BepInEx would write it to the file (true, 5, 1.5), with its type and default. It reads the ConfigEntry itself on each side; how the mod syncs it is the mod's business. Sections and keys are percent-encoded on the way (Server%20Settings, SyncedConfig.Token), because extension arguments are single tokens.
| Member | Does | Fails when |
|---|---|---|
WaitForValue(client, ..., expected, timeout, interval) |
Waits for a value to arrive, for example after an admin change on the server | At once, when the plugin is not loaded or the entry does not exist |
RequireSame(server, client) |
Compares both sides | The values differ |
Only entries in the plugin's own Config are found. Nothing here writes config.
Vanilla clients
A server-side mod that says clients do not need it fails quietly on a client without it. In Valheim 1.0.16 an object whose prefab only the mod registers is never created there (the game logs "Missing prefab hash" and skips it), a per-object RPC without a handler logs "Failed to find rpc method", and objects can throw while they unload.
Give the client BepInEx, ValheimCLI and the mod's test adapter, pin the mod absent in the client plan, and run VanillaClientCheck as a ClientRounds measurement (measure: check.Measure):
| Field | Meaning |
|---|---|
Capability |
The client's unresolved-prefab census command: valheim.observe/unresolved-prefabs on the default plugin |
Points |
Each point the client arrives at, then takes a census of the objects within Radius |
Radius |
The census radius in metres |
KnownPrefabs |
Names to resolve an unresolved hash to a prefab in the failure |
ClientLogs |
The client's logs so far, scanned after the census |
A census is complete once every zone it touches has loaded. The census leaves out the client's own player object, so an empty census proves nothing. The check fails on any unresolved hash, naming it and, when one of KnownPrefabs hashes to it, the prefab. With ClientLogs it then fails on missing-prefab-hash, rpc-method-missing and nre-remove-objects lines. Pass VanillaClientCheck.LogClassifications to the run's teardown scan as well, so those lines fail the whole run wherever they appear.
Evidence: {round}-vanilla-client-{n}-arrival.json, {round}-vanilla-client-{n}.json, {round}-vanilla-client-logs.json. StableHash.Of(name) is the game's prefab hash.
A client loads zones, and so objects, only within about near simulation distance x 64 + 32 m of its player (about 160 m at default settings). Keep Radius inside that: a larger census never becomes complete on a client.
Dungeon rooms
In Valheim 1.0.16 a dungeon generator saves its rooms as one byte array, roomData: an int32 count, then per room the room prefab's name hash, its position and its rotation as Euler angles (little-endian int32 and float32). Worlds saved before that keep rooms and room<i>, room<i>_pos, room<i>_rot fields, which the game reads when roomData is absent; the observation reads either and says which (Format).
The game gives the interior its environment with a 64 m wide box centred on the location's zone, 5000 m above the location. A room reaching outside that zone is partly outside the interior.
var dungeons = DungeonRooms.Read(server, path, x, z); // radius defaults to 64
foreach (var dungeon in dungeons) DungeonRooms.RequireWithinZone(dungeon, sizes);
| Member | Does |
|---|---|
Read(server, path, x, z) |
Lists the generators near a location's ground position, each paired with the location nearest (x, z) in the 3 x 3 zones around it; an unvisited dungeon has no saved generator, so the list is empty |
InteriorOffset |
The generator's position minus the location's: its offset in the location prefab, turned by the location's rotation |
RequireWithinZone(dungeon, sizes) |
Fails naming every room past the zone's edge, a generator in another zone than its location, a dungeon without saved rooms, and one without a location |
Give room sizes (Room.m_size, by room prefab name, as RoomSize) to check each room's footprint turned by its yaw. A generator displaced into a neighbouring zone still pairs with its location and fails the zone comparison.
Limits
- Observations read. Nothing on this page changes the world except
GlobalKeyFixture's guarded fixture command. - A room without a size is checked by its centre only, which cannot see an oversized room.
- A census larger than the client's loaded radius never completes.
- Neither a replay of a terrain capture nor a synthetic plane alone establishes the game's terrain conversion or client physics.