OdinsHand 0.3.2
dotnet add package OdinsHand --version 0.3.2
NuGet\Install-Package OdinsHand -Version 0.3.2
<PackageReference Include="OdinsHand" Version="0.3.2" />
<PackageVersion Include="OdinsHand" Version="0.3.2" />
<PackageReference Include="OdinsHand" />
paket add OdinsHand --version 0.3.2
#r "nuget: OdinsHand, 0.3.2"
#:package OdinsHand@0.3.2
#addin nuget:?package=OdinsHand&version=0.3.2
#tool nuget:?package=OdinsHand&version=0.3.2
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
- In the generated
*.Harness.csproj, reference your mod (the commentedProjectReference). - Add your mod's DLL to
pluginsinodinshand.json. - Set
[BepInDependency]inHarnessPlugin.csto your mod's GUID. - 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:
VirtualInputfeeds 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
WARNline in the report (and underbepInExLoginreport.json). Warnings are only counted. Unity's own errors are not repeated here; the engine check already counts them. - After the run,
Run-Harness.ps1scans 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 asbepinex-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.jsonhas a"telemetry"block:character,world,seed,gameVersion,day;spawns(each time the local player spawned: x, y, z, biome, zone);runStartandrunEnd; andcases, 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.txtand the log end with the same facts in words, for examplespawn #1: (-4.3, 76.8, 6.6) Meadows, zone 0,0.- In a case:
HarnessTelemetry.LastSpawnis the most recent spawn andLocationSnapshot.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>-harnessand 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"inodinshand.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 calledTesterDudeis safe from a test character calledTester. - Both places a character can live are searched: the local
characters_localfolder 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
Serveron 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 runodins.ps1 audit: it lists every non-public game member your DLL references. - Never call
Player.SetControlsyourself. Vanilla calls it every frame; the last call wins.VirtualInputrewrites 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
OnCharacterStartcommits the profile the game loads. - Restore state in
Cleanup, not on the last line ofRun. A case that throws halfway otherwise poisons the cases after it.Protect/Spawn/Watchdo this for you. - A skipped case is information; a spurious failure is noise. Use
[NoPlayer],[Skip]orCanRun/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.Damagecalls withWatchand read theHitData. - Mono inlines tiny methods (under about 20 bytes of IL), so a
Watchon a trivial getter can miss calls. - A key press does not reach mods that read
UnityEngine.Inputdirectly, 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.
-Serverjoins an existing server. - Tested on Valheim 1.0.17. A game update can break access to internals;
auditwill 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 | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET Framework | net472 is compatible. net48 was computed. net481 was computed. |
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.