Content and Harmony census
Reconcile what each game process registered (items, recipes, prefabs, pieces, status effects) with what your mod declares, and check that the mod's Harmony patches are applied in a running game.
Registered content census
A registration double shows that a mod calls ObjectDB or ZNetScene. Only the running game shows what each process ended up with: an item missing on a client whose registration ran too early, a recipe pointing at a station the game cannot find, or two names sharing one hash.
The census asks each process for its items, recipes, network prefabs, build-table pieces and status effects by stable identity, then reconciles them with what the mod declares for each side.
Setup:
- Install the pinned default
valheimCLI.dllon the server and the client. - Require
valheim.observe/content-censuson each actor. It is read-only and needs a loaded world. - Keep the reply under ValheimCLI's 256 KiB extension result. A larger census (a big content mod, or a broad prefix) fails with
result_too_large, naming the prefixes and how many entries of each kind it held. Name fewer or narrower prefixes.
Declare the expectations
Write the expectations from the mod's design, never from a census. A census copied into its own expectations can only pass.
{
"owner": "example.mymod",
"scope": ["MyMod_", "Recipe_MyMod_"],
"items": [{ "name": "MyMod_SurveyStake", "sides": ["server", "client"] }],
"prefabs": [{ "name": "MyMod_SurveyStake", "sides": ["server", "client"] },
{ "name": "MyMod_SurveyPost", "sides": ["server", "client"] }],
"pieces": [{ "name": "MyMod_SurveyPost", "tool": "Hammer", "sides": ["server", "client"],
"station": "none", "resources": ["Wood"] }],
"statusEffects": [{ "name": "MyMod_SurveyBlessing", "sides": ["server", "client"] }],
"recipes": [{ "name": "Recipe_MyMod_SurveyStake", "sides": ["server", "client"],
"item": "MyMod_SurveyStake", "station": "piece_workbench", "resources": ["Wood"] }]
}
| Key | Meaning | Refused when |
|---|---|---|
owner |
The plugin GUID whose build must be pinned on each side | Missing |
scope |
The mod's own name prefixes; only names in scope can be unexpected | An expected name starts with none of them |
items, prefabs, statusEffects |
Entries with name and sides |
Duplicate declaration, invalid side |
pieces |
Entries with name, sides and tool; optional station, resources |
Missing tool |
recipes |
Entries with name, sides; optional item, station, resources |
Duplicate declaration |
name |
The prefab, recipe, piece or effect object | Outside scope |
sides |
server, client or both: where the entry must be registered |
Invalid side |
item |
The item prefab a recipe crafts | |
station |
A prefab name, or none for crafting by hand |
|
resources |
Item prefab names a recipe or piece consumes | |
tool |
The tool whose PieceTable contains the piece |
Unknown keys are refused. Optional dependencies you leave out are not checked. A prefab that exists in ZNetScene without the piece in its tool's table is unresolved, never present. Status effects resolve by ObjectDB.GetStatusEffect(NameHash()), with collisions detected across the full status-effect list.
ContentExpectations.Load(path) reads the file.
Read and reconcile
Read each side through its own strictly pinned actor. Write the evidence before you assert.
var expectations = ContentExpectations.Load("content-expectations.json");
var server = ContentCensus.Read(serverActor, "valheim.observe/content-census", expectations);
var client = ContentCensus.Read(clientActor, "valheim.observe/content-census", expectations);
var report = ContentCensus.Reconcile(expectations,
new SideObservation(CensusSide.Server, plan.Pins["example.mymod"], server),
new SideObservation(CensusSide.Client, plan.Client.Pins["example.mymod"], client));
round.Write("content-census", new { server, client, report }); // evidence first
report.RequirePassed();
Read throws, and none of these is an empty census, for:
| Reply | Why it throws |
|---|---|
| Not ready | No world, registries not populated yet, or a client whose player has not spawned |
| Empty registry | A registry with nothing in it is a fault, not a result |
| Another owner or scope | The reply is not for these expectations |
| Malformed | The reply does not match the census format |
Which checks need a client. Every process builds its own registries from its own plugins, so a server census says nothing about a client. A client expectation is satisfied only by that client's own census, and a server census handed in as the client's is refused. Declare client for items and recipes players craft or carry, pieces they place, status effects applied to them, and prefabs created on the client; a server-only entry declares ["server"], and the client then must not have it in scope. Read a client census only once the client has joined and its player has spawned; later registrations need a later census.
When the census reads. On demand, never at a registration hook. In the world scene, ObjectDB.Awake indexes its items and ZNetScene.Awake its prefabs as the scene loads, and mods register in postfixes on those, so both have settled before a world loads; the main menu's own ObjectDB (FejdStartup.SetupObjectDB) is never read. A recipe's item and resources resolve when ObjectDB.GetItemPrefab returns a prefab of that name whose shared item name is the reference's; its station when ZNetScene.GetPrefab returns a prefab of that name with a crafting station of the same station name.
Census states
Reconcile gives every expected entry a state per side. Every in-scope entry a side was not expected to have is unexpected.
| State | Meaning |
|---|---|
present |
Registered once and the game's own lookup resolves it; a piece also sits in its tool's table with an enabled Piece component |
missing |
Not registered on that side, not observed there, or listed but not found by the game's lookup |
unexpected |
In scope on that side but not declared for it, or a recipe resource that is not declared |
duplicate |
Listed more than once, its hash shared with another name, or two recipes of one name |
unresolved |
A recipe or piece dependency the game cannot find, a piece missing from its tool's table, or a table piece missing from ZNetScene |
unsupported |
A check the census cannot answer: an adapter that omits the kind, a duplicated recipe's dependencies, a reference without a name. Never a pass |
A side is refused, and all its expected entries are missing, when:
- no observation was supplied for it;
- the observation reports being the other side;
- its owner plugin is not loaded, or is another build than pinned (
stale build: ...).
The census reports the MD5 of the owner's file as ValheimCLI's strict pins hash it. Reconcile takes the pinned MD5, or a prefix of at least 8 hex characters. The actor's strict pins still check every loaded plugin before each command.
| Member | Meaning |
|---|---|
Passed |
Every side with expectations was accepted and every entry is present |
Failures |
Every problem as side: state kind name (why) |
RequirePassed() |
Throws unless Passed |
Harmony census
valheim.observe/harmony lists every patched method with each patch's owner, kind, priority, index, before/after and patch method, from HarmonyX's own record.
A patch whose target method is missing is easy to miss. In Valheim 1.0.16, on the Windows dedicated server, the missing target leaves one accesstools-not-found warning (AccessTools.DeclaredMethod: Could not find method ...) in the BepInEx log. Then PatchAll throws "Undefined target method", and only Unity's own log (-logFile) records the exception. The rest of that plugin's Awake, including its later patch classes, never runs, and the mod stays loaded half-patched. The teardown log scan fails on both lines, but only after the run. The census names the missing patch while the run goes on.
Declare the patches and check them:
var declared = new[] { new DeclaredPatch("Terminal::InitTerminal", "postfix", "MyMod.Plugin+RegisterCommands::Postfix") };
HarmonyCensus.Read(actor, "valheim.observe/harmony", owner).Check(owner, declared).RequireApplied();
| Member | Does |
|---|---|
DeclaredPatch(Target, Kind, Patch) |
One patch the mod declares; a target without a parameter list names every overload |
HarmonyCensus.Read(actor, "valheim.observe/harmony", owner) |
Reads the census, filtered to owner |
Check(owner, declared) |
Compares the census with the declared patches |
RequireApplied() |
Fails naming each declared patch that is not applied; other owners' patches on the same methods are reported, never failed |
HarmonyCensus.OthersChanged(before, after, owner) |
Lists other owners' patches that a reload or unload removed or added |
An adapter that patches unpatches only its own Harmony ID (harmony.UnpatchSelf()), never Harmony.UnpatchAll(). A mod that calls Harmony.UnpatchAll() removes ValheimCLI's patches too, so a run can lose its own transport; the log scan names it as harmony-unpatch-all.
FullLifecycle checks its mod's patch first, before the scenario runs.
Limits
- Ownership of an entry is not observable: the registries do not record which plugin added it. That is why
scopeuses name prefixes. - The census checks registration and resolution, not whether a player can build a piece or whether an effect behaves correctly.
- Localization tokens are out of scope.