World state in the doubles
What the ZDO, scene, save, terrain, global-key and registry doubles in Valheim.Testing.Doubles do, and where they differ from the game. Setup, ValheimWorldScope and the role presets are on the core page.
Purpose
Mod code that reads or writes world state compiles against these doubles unchanged. This page lists the rules they copy from the game and the points where they throw or differ, so you know what a passing test proves. Members marked [TestOnly] in the member index exist only here: call them from tests, never from mod code. RPCs, packages and peers are on Networking page. Unity objects, destruction and config are on Unity page. The walkthrough is test logic that uses game types.
ZDOs
| Area | The doubles do | Fails or differs when |
|---|---|---|
| Value keys | Keyed by GetStableHashCode of the name; Set("name", v) and Set("name".GetStableHashCode(), v) are one key |
Each value type has its own table; a bool is an int |
| Value types | float, Vector3, Quaternion, int, bool, long, string, byte array |
— |
| Rotation | GetRotation/SetRotation keep Euler angles |
— |
| Destroy | ZDOMan.DestroyZDO is queued |
FindObjects still returns the ZDO until ProcessDestroyed |
| Session | ZDOMan.GetSessionID() is 1 by default, also with no ZDOMan |
Never 0 (see Networking page) |
| Zone ids | Narrow to short |
— |
| Locations flag | The locations-generated flag is set from a save | The event is not raised |
ZNetView and the live scene
| Area | The doubles do | Fails or differs when |
|---|---|---|
| Components | ZNetView and WearNTear are ordinary components; AddComponent<ZNetView>() is the same view View reads |
GameObject.Networked, Health, View and Wear are fixture shorthands, not game members |
Networked |
Adds a persistent view, as the game's prefabs carry one | m_persistent on a plain view defaults to false |
| Wake | A view gets a ZDO of its prefab at the object's position and rotation, owned by this session; the object joins ZNetScene.Live |
A view that wakes with no ZDOMan destroys itself at the end of the frame |
| Assets | A prefab from ZNetScene.AddPrefab or IsAsset, or a template under an inactive parent, never wakes |
Only its copies get a ZDO |
| Loaded ZDO | Set ZNetView.m_initZDO (and m_useInitZDO) before Instantiate; the view takes it, and its scale with m_syncInitialScale |
m_forceDisableInit makes a waking view destroy itself |
| Prefab name | A new ZDO's prefab is Utils.GetPrefabName of the object; with m_syncInitialScale it carries the authored scale |
— |
| Wake order | AddPrefab adds the view first, so it wakes first in insertion order |
Under UnityOrder.Reversed a component added after it wakes before it; script execution order is not modelled |
| Ghost init | A networked prefab instantiated during ghost initialisation leaves its ZDO | It joins no live scene |
ZNetScene.Destroy |
Resets the view's ZDO (GetZDO() is null), removes the object from the live scene at once |
Only ZDOs this session owns are queued; a compiler saves only when owned |
Plain Object.Destroy |
Does not touch the live scene, as in the game | The destroyed view stays in ZNetScene.Live and FindInstance returns it (see Unity page) |
Saves and restarts
ZDOMan.instance.RoundTripThroughSave() saves and reloads the world; zdo.RoundTripThroughSave() does it for one persistent ZDO.
| Area | The doubles do | Fails or differs when |
|---|---|---|
| Persistence | Only persistent ZDOs are saved; each comes back with no owner and a new id | A stored ZDOID no longer finds it |
| Session-only values | Not saved: the game's list (support, vel, InUse) plus every zdo.AddSessionHash(hash); ZDOMan.SessionOnlyHashes holds the set |
A WearNTear support value is recomputed after a restart |
| Empty values | A current save strips nothing else: empty strings, empty byte arrays and identity rotations survive | — |
ZDO.SavedWorld.BeforeChunkedSave |
Models the first load of an older world: removes support, burnt0..burnt10, room<n>_seed, <n>_crafterName |
Other conversions and unnamed legacy hashes are not modelled |
SavedWorld.BeforeNewSaveFormat |
Also drops empty strings, empty byte arrays and identity rotations, and renames the old spawn keys | — |
| Animator sync | The game stores parameters under Animator.StringToHash(name) + 438569 (ints for bools and ints, floats for floats), session-only |
The sync is not doubled; a mod reading animation state passes the hash it computed |
Heightmaps and terrain rebuilds
Every type is partial. Two Heightmap hooks carry a mod's terrain logic into the rebuild: ModBaseHeight (a biome blend, say) and ModTerrainPass (the seam a Harmony prefix on the game's rebuild uses). WorldGenerator is virtual, so tests plug in synthetic worlds; TerrainWorld puts any Valheim.Testing terrain behind it.
| Area | The doubles do | Fails or differs when |
|---|---|---|
| Rebuild | Heightmap.RebuildTerrain, Poke and Regenerate work over the heightmap's own grid and apply the zone's compiler as TerrainComp.ApplyToHeightmap does |
— |
| Deltas | Every vertex with a non-zero level or smooth delta gets both, whether or not m_modifiedHeight flags it; only those are clamped to ±8 m around the base height |
An unflagged delta shows only on the peer that wrote it, until its compiler next loads |
| No compiler | A zone without a compiler renders its base heights and skips ModTerrainPass |
A compiler that is not m_initialized adds nothing; ModTerrainPass still runs |
| Lookup | Heightmap.FindHeightmap(point) returns the loaded zone holding the point (edges included) or null; GetAllHeightmaps() lists every loaded zone |
A write aimed at an unloaded or wrong zone misses, as in the game |
TerrainComp.FindTerrainCompiler(pos) |
Returns the live compiler of the first such zone that has one, or null; a seam point belongs to the first zone | Never creates one. The game picks the first-created compiler at a seam; the doubles pick the first-loaded zone's |
HeightmapBuilder |
IsTerrainReady queues a build, BuildQueued() builds it, MakeReady(...) builds one and makes it ready; SyncRequests counts requests |
RequestTerrainSync hands a ready build out and removes it; with none ready it builds on the spot, never null |
| Builder heights | Blend the corner biomes, smoothed for a distant LOD | Install a prepared builder, or null, with the scope's WithHeightmapBuilder |
TerrainComp.Save |
Writes compressed TCData: the version, the operation counter with its last point and radius, per vertex a flag and both deltas, per texel a flag and paint | Does nothing unless this peer owns the ZDO and the compiler is initialised (GetZDO().SetOwner(other) or m_initialized = false stops it) |
Save(paintOnly: true) |
Skips the write when the paint hash is unchanged since the last paint-only save | The hash sums the modified texels' channels, so repainting paved as dirt keeps it |
Load() |
Reads the bytes back as the game does, including the older paint grid | — |
Terrain snapshots and assertions
TerrainSnapshot.Of(compiler) copies a compiler's level and smooth deltas, paint mask and both modified flags. TerrainAssert compares the compiler with it afterwards:
| Assertion | Passes when | Fails when |
|---|---|---|
OnlyChangedWithin(before, compiler, (x, z) => ...) |
Every change lies inside the footprint; the rest of the zone, flags included, is as it was | Nothing changed at all |
Unchanged(before, compiler) |
Nothing changed | Any delta, paint or flag changed |
SeamAgrees(west, east) |
Two loaded neighbours agree on shared vertices, rendered height and paint | A zone was written without its neighbour; rebuild both first |
Global keys
| Area | The doubles do | Fails or differs when |
|---|---|---|
SetGlobalKey, RemoveGlobalKey |
Routed calls to the server, by GlobalKeys member, member and float, or string. The server applies at once and broadcasts the whole list as GlobalKeys |
A client changes nothing locally; each broadcast replaces its whole list |
| Handlers | Exist only after ZoneSystem.Start(): the server registers SetGlobalKey and RemoveGlobalKey, a client GlobalKeys; OnNewPeer(peer) sends the list |
A key set on the server before Start() is dropped without an error |
| Delivery | ZRoutedRpc.instance.Deliver(serverId, "GlobalKeys", keys) delivers a broadcast to a client |
A key only the client added (GlobalKeyAdd, public here) is gone after a broadcast |
| Storage | Lower case as name or name value, split at the first space; setting a name again replaces its value |
— |
| Reads | GetGlobalKey(name) finds a key in any case; GetGlobalKey(key, out float) reads the value; GetGlobalKeyExact(line) needs the whole line |
Any string is a key; GlobalKeys members before NonServerOption are world modifiers |
| Saves | SaveGlobalKeys, LoadGlobalKeys keep the other keys; the server writes world modifiers to WorldStartingGlobalKeys, read back by SetStartingGlobalKeys |
WorldStartingGlobalKeys is null with no world loaded, as on a client |
Terrain modifier order
TerrainModifier is a MonoBehaviour. AddComponent<TerrainModifier>() on an active object wakes it into the live list; one under an inactive object stays out. new TerrainModifier(position, view) puts one on an object of its own. TerrainModifier.GetAllInstances() returns the list in apply order:
- Non-player before player (
m_playerModifiction). - Lower
m_sortOrder. - Earlier creation time, read at
Awakefrom the ZDO'sterrainModifierTimeCreatedlong. A modifier placed by the current game reads 0 and sorts before a converted one. - Smaller squared distance of the whole position from the origin, height included. At the same ground distance, a higher modifier comes later.
The list is re-sorted only when a modifier joins or leaves; a full tie keeps joining order. Heightmap.ModifiersInApplyOrder() lists the enabled modifiers within radius + 4 m of a zone. What a modifier does to the heights is not modelled. ValheimWorldScope.WithTerrainModifiers() starts a fresh live list.
Location placement
| Area | The doubles do | Fails or differs when |
|---|---|---|
| Altitude | m_minAltitude and m_maxAltitude are metres above water (y = 30); ZoneLocation.AltitudeAboveWater(y) and IsAltitudeAllowed(y) allow both ends |
A definition written with absolute heights passes on a flat world near 30 m and fails elsewhere; m_waterLevel is not read |
| Slope | SlopeRotationYaw(downhill): local +Z faces downhill, rounded to 22.5 degrees |
— |
CheckPlacement(x, z, world, terrainDelta) |
Returns the first failed rule: origin distance, biome, altitude, distance from the centre, terrain delta | The default biome None matches nothing; biome area, forest, similar locations, vegetation and zone occupancy are not checked |
| Generation | SpawnLocation, SpawnZone, PlaceLocations, SetupLocations, GenerateLocationsIfNeeded exist with the game's signatures |
They do nothing; nothing generates or places locations |
| Tables | m_locationInstances (listed by GetLocationList()), m_locations, m_locationsByHash; ZoneLocation.m_prefab is new SoftReference<GameObject>(name, template) |
Copies of a SoftReference share one reference count |
Registries and helpers
| Area | The doubles do | Fails or differs when |
|---|---|---|
ObjectDB |
Indexes m_items by name hash in Awake and CopyOtherDB; FindDuplicateItems() reports duplicates first |
A second item with the same name throws on the next UpdateRegisters |
| Menu and world loads | DeclareVanilla(items, recipes, effects), LoadMainMenu(prefab), LoadWorld(prefab); AwakePostfix and CopyOtherDBPostfix run where a Harmony postfix would |
An unguarded registration is in the list twice at the second menu load |
ZNetScene index |
Awake indexes m_prefabs and m_nonNetViewPrefabs in m_namedPrefabs; GetPrefab(name), GetPrefab(hash), HasPrefab, AddPrefab; FindDuplicateHashes() |
A duplicate hash throws, naming both prefabs |
| Late registration | MarkNotYetRegistered(names), then FinishRegistering() |
Until then GetPrefab/GetItemPrefab return null and record the lookup in EarlyLookups; HasPrefab is untouched |
Utils |
Gzip Compress, ordinal CustomStartsWith, FindChild, GetEnabledComponentsInChildren, IterateHierarchy |
RoundToInt(2.5f) is 3, RoundToInt(-1.5f) is -1, FloorToInt(0.999f) is 1; below -64000 results truncate toward zero |
ZNet |
A MonoBehaviour with m_loadError; Save(sync, ...) counts its calls |
— |
| Spawn settings | RandomSpawn, RandomObject, CreatureSpawner, DropOnDestroyed, PickableItem carry the game's defaults |
Nothing they do when a location spawns is modelled |
| Drops and items | DropTable chance, count, weights, m_oneOfEach, shared result list for GetDropListItems; Inventory stacking and slot order; SpawnArea weighted choice |
The world level is 0 and the resource rate 1 |
Container, Pickable |
Default items are added once and only by the owner; picked and enabled state lives in the ZDO | — |
Limits
The doubles model the behaviour listed above and nothing more: terrain is the rebuild and the compiler, not the game's mesh, and no location is generated. Anything else on these types is a plain field or a no-op. Behaviour outside this list needs a native check against a real server with Valheim.Testing.Game.