OdinsHand 0.3.2

dotnet add package OdinsHand --version 0.3.2
                    
NuGet\Install-Package OdinsHand -Version 0.3.2
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="OdinsHand" Version="0.3.2" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="OdinsHand" Version="0.3.2" />
                    
Directory.Packages.props
<PackageReference Include="OdinsHand" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add OdinsHand --version 0.3.2
                    
#r "nuget: OdinsHand, 0.3.2"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package OdinsHand@0.3.2
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=OdinsHand&version=0.3.2
                    
Install as a Cake Addin
#tool nuget:?package=OdinsHand&version=0.3.2
                    
Install as a Cake Tool

Odin's Hand

An in-game test harness for Valheim (BepInEx) mods. One command launches the real game, loads a disposable test character and world by itself, runs your test cases against live game objects, takes screenshots, quits, and prints a pass/fail report. No clicking through menus, no "I think it works".

> .\odins.ps1 run
Launching Valheim with the MyMod dev profile...
[harness] PASS  mymod:world-is-up   a local player exists
[harness] PASS  mymod:world-is-up   vanilla content is loaded
[harness] PASS  engine              no engine errors during the run
[harness] 3 passed, 0 failed, 3 checks total.

It was extracted from the harness that verified several of the author's Valheim mods (hundreds of in-game checks per mod, single-player and against a dedicated server).

New here? Start with the step-by-step Getting started guide.

Where it fits

Odin's Hand Agent bridges (e.g. ValAgentBridge)
You write C# test cases, run unattended Nothing; an AI agent drives the live game
Best for Repeatable regression suites, "did my change break anything", CI-style runs Exploratory testing, poking at a running game
Result A pass/fail report you can diff run to run A conversation

They complement each other. Odin's Hand is the one you run before every release.

Quick start

Requirements: Windows, the Steam copy of Valheim (tested on 1.0.17), the .NET SDK 6 or newer, PowerShell 5.1+, and Steam running. BepInEx 5 is downloaded for you.

dotnet new install OdinsHand.Templates
dotnet new odinshand -n MyMod --modGuid com.me.mymod     # a harness plugin next to your mod
cd MyMod
dotnet restore
.\odins.ps1 profile     # once: an isolated BepInEx dev profile (it never touches r2modman, Gale, or your game folder)
.\odins.ps1 run         # build, launch Valheim, run every case, print the report

Close Valheim first. If your game is not in the default Steam path, set the VALHEIM_DIR environment variable.

Test your own mod

  1. In the generated *.Harness.csproj, reference your mod (the commented ProjectReference).
  2. Add your mod's DLL to plugins in odinshand.json.
  3. Set [BepInDependency] in HarnessPlugin.cs to your mod's GUID.
  4. Replace the starter cases in Cases.cs.

The harness is a separate BepInEx plugin, so the DLL you ship carries no test code. Never ship it.

Writing cases

A case is a method. It takes a HarnessContext and returns void (nothing to wait for) or IEnumerator (a coroutine, so it can wait frames and seconds, which most things in Unity need).

[Cases(Category = "staff")]
public class StaffCases
{
    [Case("staff:casts")]
    public IEnumerator Casts(HarnessContext ctx)
    {
        ctx.Protect();                                   // god mode for this case; restored afterwards
        var player = Player.m_localPlayer;
        var before = player.GetEitr();

        VirtualInput.Active = true;
        VirtualInput.Attack = true;
        yield return ctx.Seconds(1f);

        ctx.Check("the buff appeared", player.GetSEMan().HaveStatusEffect(MyHash));
        ctx.CheckApprox("it cost 10 Eitr", before - player.GetEitr(), 10f, 0.2f);
        yield return ctx.Screenshot("staff_after_cast");
    }

    [AfterEach] public void Reset(HarnessContext ctx) => VirtualInput.Clear();
}
Attribute Meaning
[Cases] Class holding cases. One instance per run.
[Case("name", TimeoutSeconds = 600)] A case. The name is used in the report and by -Only. The timeout is for cases that are slow on purpose; the default is 300 s.
[Skip("reason")] Recorded as a skip, never a failure.
[Only] If anything has it, only those cases run. For focusing while you work.
[NoPlayer] Valid from the main menu. Without it a case is skipped when there is no local player.
[BeforeEach], [AfterEach] Per-case hooks. [AfterEach] runs even if the case threw.

Prefer subclassing? HarnessCase and HarnessRunner.Add(...) still work; override HarnessHost.AddCases and turn off DiscoverCasesAutomatically.

What a case can do (HarnessContext)

  • Checks (they record, they never throw, so one failure does not hide the rest): Check, CheckNotNull, CheckEqual, CheckApprox, Skip.
  • Waiting: Frames(n), Seconds(s), WaitUntil(condition, timeout, onResult).
  • Screenshots: Screenshot(name), ClipFrame(clip, index) for assembling a short clip outside the game.
  • Helpers that put the world back (undone after the case, even on a throw): Protect(ghost), Spawn(prefab, pos), Watch(method) (records calls and their arguments), OnCleanup(action).
  • Input: VirtualInput feeds synthetic movement, attack and jump to the local player.

What the runner does for you

  • Skips (rather than fails) cases whose preconditions are not met.
  • Isolates cases: one that throws is recorded and the run continues.
  • Abandons a case that exceeds its time limit and records the game state at that moment.
  • Waits out a death and respawn instead of failing everything after it.
  • Fails the run if the engine logged errors, which catches the exception thrown inside somebody's Awake.
  • Reports errors written to BepInEx's log, which the engine check cannot hear: a plugin's Logger.LogError(...) goes to BepInEx, not Unity. See Errors in BepInEx's log below.
  • Fails a run that executed nothing, so a filter typo cannot look like success.
  • Refuses to run against the wrong character, or an unintended server.

Errors in BepInEx's log

A green run must not hide an error sitting in LogOutput.log, so the harness watches that log in two ways:

  • While the suite runs, it listens to BepInEx's own logger. Each error or fatal is recorded with the case that was running and the plugin that logged it, and shows up as a WARN line in the report (and under bepInExLog in report.json). Warnings are only counted. Unity's own errors are not repeated here; the engine check already counts them.
  • After the run, Run-Harness.ps1 scans the whole log. That catches what happens before the suite starts and the harness cannot hear: a plugin that failed to load, missing dependencies, a patch that did not apply. The result is printed, and the full text is saved as bepinex-log-problems.txt (it is included in the run history and the bug-report bundle).

By default these are warnings and do not fail the run, because a mod may log an error on purpose while a case provokes it. To fail on them, set "failOnLogErrors": true in odinshand.json or pass -FailOnLogErrors: an unexpected error then adds a FAIL to the report, and the script exits with code 3.

A case that provokes an error on purpose declares it so it does not count:

ctx.ExpectLogError("could not find spawn point");     // errors containing this text, until the case ends
Logger.LogError("could not find spawn point: boar");   // recorded as expected, not counted

A harmless BepInEx start-up message, Unable to start Unity log writer, is ignored.

Multiplayer: a dedicated server

odins.ps1 server (scripts\Run-ServerHarness.ps1) runs your cases from one client against a real local dedicated server, which catches the bugs that only show when values come from a server. One-time setup: install the dedicated server with steamcmd (app 896660) so that <server root>\server\valheim_server.exe exists, then add a server section to odinshand.json:

"server": {
  "root": "D:\\ValheimDedicated",      // or set VALHEIM_SERVER_ROOT
  "plugins": [ "src/MyMod/bin/Release/MyMod.dll" ],                     // the mod, NOT the harness
  "configFiles": { "com.me.mymod.cfg": "dev/server-config/com.me.mymod.cfg" }   // server config that differs from the client defaults
}

It builds a separate server profile, starts the server, waits for it, joins with the harness, then stops it and scans the server's own logs for errors. A second human player is not required, but anything that needs two people to observe each other stays manual.

Telemetry: where things happened

Every run records where the player was, so a failure that only happens in one biome, or far from the origin, is visible in the report and not lost. This matters most for mods with random spawn points.

  • report.json has a "telemetry" block: character, world, seed, gameVersion, day; spawns (each time the local player spawned: x, y, z, biome, zone); runStart and runEnd; and cases, with the start and end position and the seconds each case took.
  • Spawns and the run start/end also carry nearestBiomes: for each other biome, the distance in metres, compass bearing and exact point of the nearest sample (BlackForest 504 m NE). It looks out to 3,000 m, so a biome further away is not listed, and a biome narrower than the gap between 32 search rays can be missed. BiomeSearch.FindNearest(position) does the same for any point.
  • "Spawn" is where the game placed the player when it spawned. For a brand-new character that is the start of the valkyrie intro flight, which is why runStart (taken after the intro, once the player has landed) is usually the position to look at.
  • report.txt and the log end with the same facts in words, for example spawn #1: (-4.3, 76.8, 6.6) Meadows, zone 0,0.
  • In a case: HarnessTelemetry.LastSpawn is the most recent spawn and LocationSnapshot.Capture() is the player right now, so a case can assert things like "the player is still within 500 m of where it spawned".

odinshand.json

Key Meaning
name Short project name. Dev profile = <parent of project>\<name>Dev-profile.
guid Your harness plugin's BepInEx GUID. The script writes BepInEx/config/<guid>.cfg with [AutoStart] and [Harness].
reportName Your HarnessHost.ReportName. Reports go to %TEMP%\<reportName>-harness.
logTag Your harness plugin's display name (used to tidy the log lines that are printed).
solution Solution or project to build (relative to the project).
plugins DLLs (relative paths) copied into the profile's plugin folder. Include your mod, your harness and OdinsHand.dll.
audit DLLs checked by odins.ps1 audit.
jotunn Optional version (e.g. "2.30.2"): copies Jotunn.dll from the NuGet cache into the profile, for mods that need it.
tests Optional unit-test project run before each deploy.
failOnLogErrors true makes an unexpected error in BepInEx's log fail the run (exit code 3). Default: report it as a warning.
keepRuns How many past runs to keep as small zips (default 5, max 50).
character, world Disposable test save names (defaults OdinsTester, OdinsHandTest).
gameDir, profileDir Optional overrides.

Commands: profile, run, deploy, audit. Switches for run: -Only <text>, -KeepOpen, -NoDeploy, -TimeoutMinutes, -Server host:port -ServerPassword pw (join a dedicated server instead of a local world).

In game, the harness also adds a <reportName>.selftest [filter] console command.

Logs, run history and bug reports

Disk use is deliberately small and bounded:

  • The current run's report and screenshots live in %TEMP%\<reportName>-harness and are replaced by the next run; the game log is replaced on each launch.
  • After each run, the report and the game log are saved as one zip of a few KB in %LOCALAPPDATA%\OdinsHand\runs\<project>, and only the newest 5 are kept ("keepRuns" in odinshand.json, 1 to 50). Screenshots are not kept.
  • Nothing is ever uploaded. There is no network code beyond the one-time BepInEx download.

To report a bug, run odins.ps1 bundle (scripts\New-BugReport.ps1). It zips the latest run into %LOCALAPPDATA%\OdinsHand\bug-reports (newest 3 kept). The bundle holds the report (results, Odin's Hand / BepInEx / game versions, every loaded plugin, where the player was), the game log, your odinshand.json, the harness config and a short system summary. Windows user name, profile path, machine name, Steam account ids, e-mail addresses, server addresses and server passwords are redacted, and screenshots are left out unless you pass -IncludeScreenshots. Redaction is a safety net: open the zip and read it before attaching it to an issue.

Cleaning up test characters

Every project's harness creates its own disposable character, so they pile up. odins.ps1 clean (scripts\Clean-TestSaves.ps1) removes them without touching the characters you play:

  • It is a dry run until you add -Apply, and prints what it would move and which characters it is keeping.
  • Names come only from odinshand.json (character) of the projects you give it, plus any you pass with -Name. The match is exact. There are no wildcards, so a real character called TesterDude is safe from a test character called Tester.
  • Both places a character can live are searched: the local characters_local folder and Steam cloud (Steam\userdata\<id>\892970\remote\characters). A name can exist in both.
  • Files are moved to %LOCALAPPDATA%\OdinsHand\removed-saves\<timestamp> with a manifest, never deleted. Afterwards it checks every other save is byte-for-byte unchanged. It refuses to run while Valheim is running or if a save was written in the last 15 minutes.
  • Worlds are not handled.
.\scripts\Clean-TestSaves.ps1 -Project C:\mods\ModA, C:\mods\ModB            # dry run
.\scripts\Clean-TestSaves.ps1 -Project C:\mods\ModA, C:\mods\ModB -Except KeepMe -Apply

Safety

Cases spawn things, move the clock and change state, so the harness is strict about where it runs:

  • It uses a named, disposable, local character and world, created on demand. It never "picks the first one".
  • After selecting the character it reads back which profile the game will actually load and stops if it is not the configured one. (An early version of this library skipped that step and could run against whoever a human last played; see the changelog.)
  • It will not run on a joined server unless you configured Server on purpose.

Lessons baked in (they cost real debugging time)

  • The publicizer lies at runtime. Referencing the publicized game assembly lets private members compile, but the real assembly still enforces access: the JIT rejects the whole method. Reach private members through AccessTools, and run odins.ps1 audit: it lists every non-public game member your DLL references.
  • Never call Player.SetControls yourself. Vanilla calls it every frame; the last call wins. VirtualInput rewrites the arguments instead.
  • A new character plays an intro cutscene over a valid spawn, and it starts a moment after the spawn callback. Kit.WaitForPlayerReady() waits for it.
  • Highlighting a character is not loading it. Only OnCharacterStart commits the profile the game loads.
  • Restore state in Cleanup, not on the last line of Run. A case that throws halfway otherwise poisons the cases after it. Protect/Spawn/Watch do this for you.
  • A skipped case is information; a spurious failure is noise. Use [NoPlayer], [Skip] or CanRun/SkipReason.
  • Compare against a baseline measured just before the action, not a constant: vanilla's ambient effects (Cold, Wet, Rested) move your numbers.
  • Immortal targets (the training dummy) never lose health. Record Character.Damage calls with Watch and read the HitData.
  • Mono inlines tiny methods (under about 20 bytes of IL), so a Watch on a trivial getter can miss calls.
  • A key press does not reach mods that read UnityEngine.Input directly, only ones that go through the game's input layer.

Limits

  • Windows and PowerShell only, for now. BepInEx 5 only (Valheim's current loader), not BepInEx 6.
  • Needs the real game and Steam, so it cannot run on a hosted CI machine. Run it locally before you tag a release.
  • The dedicated-server scripts used in the author's own repos are not generalised yet. -Server joins an existing server.
  • Tested on Valheim 1.0.17. A game update can break access to internals; audit will tell you which.

Building from source

dotnet build OdinsHand.sln -c Release          # needs Valheim installed (the game DLLs are referenced in place)
.\scripts\Run-Harness.ps1                        # runs the bundled Smoke example in the real game
.\tools\Pack-Release.ps1                         # builds the NuGet package and the template into .\artifacts

License

MIT, see LICENSE. Valheim is a trademark of Iron Gate AB; this project is unaffiliated. No game files are included or redistributed: game DLLs are referenced in place from your own install.

Product Compatible and additional computed target framework versions.
.NET Framework net472 is compatible.  net48 was computed.  net481 was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

This package has no dependencies.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
0.3.2 88 10/15/2026
0.3.1 77 10/15/2026
0.3.0 83 10/15/2026