Unity, config and Harmony in the doubles

How Valheim.Testing.Doubles models Unity objects, their order, BepInEx config and Harmony. Setup and ValheimWorldScope are on the core page.

Purpose

The doubles give your mod's Unity, BepInEx and Harmony calls real behaviour where mod logic depends on it. Everything else is a plain field or a no-op. World state is on World state page; serialization, RPCs and the handshake on Networking page. Members marked [TestOnly] in the member index exist only in tests.

Unity names

Area The doubles do Fails or differs when
Current names Unity 6 names: FindObjectsByType, Rigidbody.linearVelocity, LightType.Rectangle, AnimatorUpdateMode.Fixed —
Obsolete forwarders FindObjectsOfType, FindObjectOfType, Rigidbody.velocity/drag/angularDrag, LightType.Area compile with a warning AnimatorUpdateMode.AnimatePhysics does not exist

Game objects and components

Area The doubles do Fails or differs when
Component queries AddComponent, GetComponent(s), GetComponent(s)InChildren, GetComponentInParent, TryGetComponent, by base type or interface GetComponent(Type) refuses a type that is neither component nor interface
Inactive objects SetActive and activeInHierarchy work as in Unity Without includeInactive, only objects active in their hierarchy count
Transform hierarchy SetParent (keeps world position), parent, GetChild, Find("a/b"), root, localPosition, localScale Rotation is not composed: under a rotated parent, world position and TransformPoint are wrong
Rotation rotation is stored (identity by default); localRotation derived; eulerAngles as Unity's Euler angles Collider bounds ignore the object's own rotation
Quaternion, Color *, eulerAngles in [0, 360), Inverse; Color == with tolerance, Equals exact —
Instantiate Copies components, children, public and [SerializeField] fields, lists, arrays, [Serializable] classes Dictionaries, delegates and private state get the constructor's values
Copy names A copy keeps the prefab's name; Utils.GetPrefabName handles both forms Code matching name + "(Clone)" finds none
Scene search FindObjectsByType, FindFirstObjectByType, FindAnyObjectByType find every GameObject and component; FindObjectsInactive.Include adds inactive ones Assets (ZNetScene.AddPrefab prefabs, GameObject.IsAsset), their children and components are not found; copies are
Component behaviour Colliders (Box, Sphere, Capsule, Mesh) with bounds, ClosestPoint, attachedRigidbody; Renderer.material and MeshFilter.mesh copy on first use; Animator parameters; Canvas.rootCanvas; Light defaults Collider bounds are empty while off; Mesh refuses out-of-range triangle indices
Component fields Unity's own components keep settings in properties, as in native code Reflection over public fields finds none; Instantiate still copies them
UnityEngine.Random Unity's ranges, seeded with Random.InitState The sequence is not Unity's
UnityEngine.Debug Writes to the log capture —

Messages and frames

Area The doubles do Fails or differs when
Awake Runs when a component joins an object active in its hierarchy, or the object becomes active A prefab under an inactive parent wakes only in its copies
Sequence Awake, OnEnable, Start before the first Update; Update and LateUpdate once per frame; OnDisable; OnDestroy while alive —
UnityEngine.Object.RunFrame(deltaTime) Advances Time.time and Time.frameCount, resumes coroutines, runs due Invoke/InvokeRepeating, ends the frame —
Coroutines yield return null, WaitForSeconds, WaitForEndOfFrame, nested routines, WaitUntil/WaitWhile —
Exceptions An exception thrown by a message reaches the test Unity logs it and carries on

Order across objects

Unity promises: Awake then OnEnable per behaviour, Awake inside AddComponent on an active object, every woken object before any Start, and each frame phase (Start, Update, coroutines and Invoke, LateUpdate) for every behaviour before the next. The doubles keep those. Where Unity promises nothing, the doubles use insertion order.

Area The doubles do Fails or differs when
Unordered cases Insertion order for Awake across a hierarchy, frame phases across equal execution order, OnDestroy in one frame, FindObjectsSortMode.None A test can pass only because two objects were made in a lucky order
WithUnityOrder(UnityOrder.Reversed) Runs exactly those orders backwards; the scope restores the order Two orders cover two objects, not every order of three or more
Script execution order Not modelled: DefaultExecutionOrder, project settings, scene loading A result that depends on them needs a native check (Valheim.Testing.Game)

Run a case with two objects once in each order:

[Theory] [InlineData(UnityOrder.Insertion)] [InlineData(UnityOrder.Reversed)]
public void ...(UnityOrder order) { scope.WithUnityOrder(order); ... }

UnityOrderTests has the discriminating case: a registry read in Awake fails reversed; the same read in Start passes in both.

Destroyed objects

Area The doubles do Fails or differs when
Null comparison Unity's ==, !=, Equals and bool: a destroyed object equals null, if (obj) is false is null, ?. and ?? skip the overload and see a live reference, as in Unity
Engine members name, gameObject, GetComponent on a destroyed object throw NullReferenceException naming it UnityEngine.MissingReferenceException exists only so code compiles
Own members A component's fields and methods (WearNTear.m_health, ZNetView.GetZDO()) still answer Destroyed objects stay distinct dictionary keys and list entries
Object.Destroy Deferred until UnityEngine.Object.EndOfFrame(), which returns how many objects went DestroyImmediate destroys at once
What goes A GameObject takes its ZNetView and WearNTear; a component alone makes GetComponent return null A plain Destroy leaves the view in ZNetScene.Live; FindInstance still returns it (ZNetScene.RemoveObjects)
Pending destroys Process-wide; ValheimWorldScope ends the frame on dispose Without a scope, call EndOfFrame() in the test or its Dispose

This catches the ?. mistake the modding wiki warns about. Say PieceHealth.Total(pieces) adds up piece?.GetComponent<WearNTear>()?.m_health ?? 0f. This test fails with NullReferenceException until the mod checks if (piece == null) continue; before GetComponent:

[Fact] public void DestroyedPiecesAreSkipped()
{
    using var world = new ValheimWorldScope().WithZdos().WithScene();
    var prefab = ZNetScene.instance!.AddPrefab("stone_wall", health: 1500f);
    var kept = UnityEngine.Object.Instantiate(prefab, new UnityEngine.Vector3(1, 30, 1), UnityEngine.Quaternion.Euler(0, 0, 0));
    var removed = UnityEngine.Object.Instantiate(prefab, new UnityEngine.Vector3(2, 30, 2), UnityEngine.Quaternion.Euler(0, 0, 0));
    UnityEngine.Object.Destroy(removed);
    UnityEngine.Object.EndOfFrame();
    Assert.Equal(1500f, PieceHealth.Total(new[] { kept, removed }));
}

Localization and preferences

Area The doubles do Fails or differs when
Localization Translates $word, $KEY_<binding> (from ZInput.SetBinding) and $1; keeps the game's 100-entry cache AddWord does not clear the cache; SetLanguage drops added words
PlatformPrefs In memory; falls back to PlayerPrefs on a dedicated server, so Localization.instance works under AsDedicatedServer() Set PlatformPrefs.Unavailable to a reason: every call, and a first Localization.instance, throws until cleared

Config

Area The doubles do Fails or differs when
ConfigFile BepInEx 5's ConfigFile: every Bind overload, ConfigEntry<T>.Value, BoxedValue, SettingChanged, ConfigReloaded, Reload, Save, SaveOnConfigSet, TryGetEntry, acceptable ranges and lists Vector2, Vector3, Vector4, Quaternion and Rect throw on Bind; add one with TomlTypeConverter.AddConverter
Value text Escaped strings, invariant floats, enums by name, colours as RRGGBBAA —
Files An in-memory disk; FileText or ConfigFile.WriteFile edits a file, Save writes BepInEx's format; WithConfigFiles() gives an empty disk Paths.ConfigPath is not a real directory: a FileSystemWatcher cannot run; call Reload()
SettingChanged Fires once per real change; values in the file for unbound settings apply when bound Binding with a non-default default raises the file's SettingChanged, as in BepInEx
Config sync Not modelled. Set the entry values a client would receive and test the mod's decisions Test the sync natively: read the entry on server and client after a join and an admin change, re-reading with a bound as LogWait does

Never wait on a single FileSystemWatcher event: it fires several times for one save and can miss events on Linux and mounted or container filesystems.

Harmony

Area The doubles do Fails or differs when
Attributes Every HarmonyPatch constructor (argument types, ArgumentType variations, MethodType), HarmonyPriority, HarmonyBefore, HarmonyAfter record their target in info; HarmonyMethod.Merge combines them Follows HarmonyX, the Harmony BepInEx 5 ships
new Harmony(id).PatchAll() Lists the patch classes it finds in Patches Patches nothing: a test passes even when the target does not exist. Call the prefix or postfix yourself
Traverse, AccessTools Reach members of any visibility on real objects A typed Traverse read casts and throws on another type
[ModuleInitializer] A ModuleInitializerAttribute polyfill for net48 Define VALHEIM_TESTING_NO_POLYFILLS if your project declares its own

Limits

  • Nothing is simulated or drawn: components keep their settings and answer queries, with no physics or rendering.
  • Transform rotation is not composed; collider bounds ignore rotation.
  • Unity's script execution order and the game's scene loading are not modelled.
  • Config sync and Harmony targets are not modelled; check both natively with Valheim.Testing.Game.