Please disclose if any significant portion of your mod was created using AI tools by adding the 'AI Generated' category. Failing to do so may result in the mod being removed from Thunderstore.
Valheim10Compatibility
Lets pre-1.0 mods load and run on Valheim 1.0 without recompiling: sector API, console commands, renamed hooks, PieceTable lists, by-name Harmony patches steered past 1.0's overloads, a Hoverable default, and a shield for item-name collisions.
CHANGELOG
Changelog
All notable changes to Valheim10Compatibility (the patcher + adapter pair) and to this repository's documentation and tooling. Newest first.
2026-09-16 — 1.4.1: typed Harmony lookups hand a plugin's own reflection the bridge it named; Historical Heritage's AttachArmor invoke no longer throws
What broke with 1.3.0–1.4.0
- The resolution hook redirected every typed
AccessTools.Method/AccessTools.DeclaredMethod(type, name, Type[])lookup that landed on an append-only bridge to the longer native method. Right when theMethodInfobecomes a Harmony patch target (the patch then fires on vanilla's own calls); wrong when the plugin uses it forMethodInfo.Invoke, because the arity no longer matches. - Historical Heritage 2.1.5 and Magic Supremacy 3.0.7 share one
CustomSlotSystemthat looks upVisEquipment.AttachArmor(int, int)and invokes it with two arguments. On 1.0.12 the native isAttachArmor(int itemHash, int variant = -1, int quality = 0); the hook handed back that 3-parameter method and every custom-slot (belt) visual update threwTargetParameterCountExceptionout of their prefix onVisEquipment.UpdateEquipmentVisuals. Seen live on the dedicated server insideFejdStartup.Start → SetSelectedProfile → SetupCharacterPreview(through AzuEPI's vanity-preview postfix); the server survived because itsStartloads the main scene first. On a client whose selected character wears such an item the tail ofFejdStartup.Startis skipped and the throw repeats on every character preview; in-world, the belt visual never attaches on any patched peer. Without the patcher the lookup returnsnull(HarmonyX logsAccessTools.Method: Could not find method for type VisEquipment and name AttachArmor and parameters (int, int)) and the mods silently skip the visual — the patcher was worse than no patcher here, which is the one thing it must never be.
What 1.4.1 changes (HarmonyResolver.cs only; no IL change, same 54+1 bridges)
- A typed lookup that lands on a bridge is redirected to the native only while Harmony's own patch machinery is resolving a target (
PatchClassProcessor/PatchTools.GetOriginalMethodfor[HarmonyPatch]attributes andTargetMethod();PatchProcessor/Harmonyotherwise). Any other caller gets the bridge it named, soMethodInfo.Invoke, delegates andTraversekeep the pre-1.0 arity. Logged astyped lookup outside Harmony's patch machinery (a reflective call, delegate or Traverse); the bridge it named is returned unchanged …. - A fifth MonoMod hook, on
PatchProcessor..ctor(Harmony, MethodBase), swaps a bridge handed straight toHarmony.Patch/Harmony.Unpatchfor the native it forwards to, so manual patches still fire on vanilla's calls. Logged asHarmony hook: PatchProcessor(Type, "Name(…)") -> … [Harmony.Patch on a compatibility bridge redirected to the native method it forwards to, so the patch fires on vanilla's own calls]. The install line now ends…, DeclaredField, Field; PatchProcessor..ctor). - Bridges that transform their arguments are still never redirected.
Measured
- Census (Cecil IL scan of 95 unique plugin DLLs: the live server plus both Wonderland client profiles): 21 typed lookups land on 6 of the 52 method bridges, in 7 plugins. 2 are
Invokesites (Historical Heritage, Magic Supremacy — broken by 1.3.0–1.4.0, fixed here). 19 are patch targets — attributes andTargetMethod()in those two plus DualWielder, and two manualharmony.Patch(AccessTools.DeclaredMethod(typeof(Inventory), "AddItem", …))in Vikings Archer — and all 19 still land on the native. Nothing in the set usesTraverse, which never entered the hook anyway. - Probe A/B on a bare 1.0.12 dedicated server (one probe plugin, only the patcher DLL swapped): 1.4.0 → the typed lookup returns the 3-parameter native and
InvokethrowsTargetParameterCountException; 1.4.1 → it returns the 2-parameter bridge andInvokereturns normally. A manualharmony.Patchand a typed[HarmonyPatch]attribute on the same method land on the native under both. - The 90-plugin corpus boots to the world scene on both: same 54+1 injected / 0 skipped / 2 blocked, the same two
[Error]lines (SearsCatalog, Advanced Terrain Modifiers), and only the expected relabelled hook lines; Vikings Archer's two manual patches now show thePatchProcessorredirect.
Deploying
- The fix itself is in the patcher; the Adapter carries no code change but is rebuilt as 1.4.1 so both DLLs report the same version. Because the Adapter bytes change, an AzuAntiCheat server must replace its whitelist folder with the 1.4.1 package (not add it beside 1.4.0 — two versions of one plugin in the whitelist lock every non-admin client out), and every client must update to 1.4.1 before joining. Server: new
patchers/DLL + newplugins/folder + whitelist twin, one restart. - Known limits: an attribute
[HarmonyReversePatch]on a typed bridge target still copies the native body (as since 1.2.0); a manualHarmony.Patchon a transforming bridge is no longer warned about at lookup time; a plugin whose static initializer performs its typed lookup while Harmony is already patching that same plugin would still be redirected (none in the corpus does).
Field notes from the same live boot (nothing to ship)
- SearsCatalog 1.8.0's
Player.UpdateBuildGuiInputtranspiler fails because 1.0 deleted the scroll-wheel category cycling it suppressed (the wheel now rotates the placement ghost inPlayer.UpdatePlacement). Its other 21 patches apply but act on the legacy piece-selection window, which 1.0'sHud.Awakehides permanently in favour ofBuildUi— the mod is inert on 1.0 with or without the patcher, and no 1.0 build exists. HarmonyX keeps the failed transpiler registered, so any later patch onPlayer.UpdateBuildGuiInputby any plugin fails too; nothing here will ever target it. - More Vanilla Build Prefabs 1.5.0
Failed to patch prefab Trailership: 1.0 re-rigged the longship's sail (Unity Clothsail_full→ MagicaClothKarve_Sail) and MVBP copies cloth settings from it without a null check. Harmless while[Trailership] Enabled = false(the admin-synced default); an upstream bug, reported to the author. - AzuAntiCheat whitelist hygiene: it keys entries as
"Name vVersion"plus a file hash and requires every key on every non-admin client, so two versions of one mod in the whitelist lock everyone out (admins are exempt and never see it). Replace whitelist folders, never accumulate them. A release that leaves the Adapter bytes untouched would need no whitelist change; this one does not, because both DLLs were bumped.
2026-09-12 — 1.4.0: ObjectDB content-collision shield in the adapter; More Ore Deposits' GoldOre no longer aborts startup
Follows a re-examination of the More Ore Deposits verdict recorded under 1.3.1 ("a content collision, not something to bridge. Remove on 1.0"). The collision is real. The crash was not unavoidable.
What broke without this
- Vanilla 1.0 ships its own
GoldOreitem (Assets/GameElements/Items/materials/GoldOre.prefab, absent from 0.221.12) and aGoldOre → Goldconversion on the blast furnace. More Ore Deposits 1.3.5 registers a JotunnCustomItemalso namedGoldOre.ObjectDB.UpdateRegistersbuildsm_itemByHashwithDictionary.Add, so the second one throwsArgumentException: An item with the same key has already been added. Key: 205000016out ofCopyOtherDB←FejdStartup.SetupObjectDB, andFejdStartup.Startaborts. Server: registers on Steam, never loads a world, every join times out with 5003. Client: no matchmaking init, no profile selection,NullReferenceExceptionfromSteamworksMatchmaking.Tickevery frame. - Why Jotunn's own duplicate guard does not catch it — the ordering bug that makes this fixable. Jotunn 2.30.0's
PrefabManagerprefix onObjectDB.CopyOtherDB(priority 0) setsMenuObjectDBand firesOnVanillaPrefabsAvailable. The mod'sItemManager.AddItemthen callsRegisterItemInObjectDB(MenuObjectDB, …), whosem_itemByHash.ContainsKeycheck runs against the menu ObjectDB's hash table — still empty, because theItemManagerprefix that fills it (UpdateRegistersSafe) runs later, at priority -100. The guard passes and the mod's prefab is appended tom_itemsbeside vanilla's.UpdateRegistersSafethen sees the duplicate and logs[More Ore Deposits] Found duplicate item 'GoldOre' (205000016) in ObjectDB.m_items— but only skips it in Jotunn's own rebuild; it leaves it in the list, and vanilla's rebuild throws. 1.0.12'sCopyOtherDBalso aliasesm_items = other.m_itemsnow, so the duplicate lives in the menu prefab's list itself. Any Jotunn mod whose custom item name collides with new 1.0 content and registers onOnVanillaPrefabsAvailabledies identically; More Ore Deposits is simply the one on this list. - Checked before building anything: no 1.0 build of the mod exists (Thunderstore's latest is 1.3.5 from 2024-04-21; Hexium does not carry it; no fork or successor among the 10.9k packages in Thunderstore's Valheim catalog); the mod has no gold toggle (its config is drop min/max per ore only); Jotunn 2.30.0 is current on both registries and its
devbranch still has the sameAddItempath.
What 1.4.0 changes
- Adapter:
ObjectDBShield(new, section 4 ofAdapterPlugin.cs). A Harmony prefix onObjectDB.UpdateRegistersatPriority.Last— so it sees the list after every other prefix has touched it — walksm_items, keeps the first prefab for eachGetStableHashCodeof the name, removes every later one, and logs oneWarningper removal naming the prefab, the hash, the kept item's token and the dropped item's token. First registration wins, which is vanilla's whenever vanilla owns the name, because the serialized list precedes runtime appends; when two mods collide with each other it is whichever registered first, the same answer Jotunn's own "Already added item" path gives. Vanilla's own list is unique, so the prefix only ever changes behaviour where vanilla would have thrown. - Config:
[Shields] ObjectDBDuplicateItems(defaulttrue), in the sameBepInEx/config/Valheim10Compatibility.cfgas the patcher's four switches. The patcher declares the key — it is the instance that writes the file, so the description reaches disk — and the adapter reads it without ever saving: twoConfigFileinstances saving one path would each rewrite the other's entries as comment-less orphans. Off, the adapter does not install the prefix at all and says so. - Patcher: no IL change. Same 54 + 1 bridges, same alias, same two return-type refusals, same hook; the only edits are the config declaration and the version. Version bump: patcher, adapter, manifest → 1.4.0. Manifest description reworded to mention the shield within Thunderstore's 250 characters.
Measured
Same 91-plugin dedicated-server list as 1.3.0/1.3.1 (corpus-patched, synced to the operator's Wonderland Client versions) with More Ore Deposits 1.3.5 enabled, same 1.4.0 binaries, only [Shields] ObjectDBDuplicateItems toggled between boots:
| Switch off (= plain 1.0 / every prior release) | Switch on (1.4.0 default) | |
|---|---|---|
An item with the same key has already been added. Key: 205000016 |
1, at FejdStartup.SetupObjectDB |
0 |
World scene reached (Zonesystem Awake) |
never — Steam registration only | yes, 218 s |
[ObjectDBShield] Duplicate item prefab warnings |
— | 1 (GoldOre: kept $item_goldore, dropped $GoldOre_warp) |
More Ore Deposits' Injecting 5 custom vegetation |
never reached | present |
| Patcher summary | 54 + 1 injected, 0 skipped, 2 blocked (return type) | identical |
[Error] lines |
2 (both at chainload, before ObjectDB) |
2, the same two as every release (SearsCatalog transpiler, Advanced Terrain Modifiers PaintCleared) |
The shield was first proven with a throwaway probe plugin carrying only the prefix (same list, same 1.3.1 binaries): abort → boot, world scene in 220 s. A normalized diff of that boot's LogOutput.log against the 1.4.0 boot contains nothing beyond the version strings, the probe's own lines and one shutdown-order line — the adapter's prefix does exactly what the probe did and nothing else.
What the shield does not do — read this before re-enabling More Ore Deposits
The shield keeps the game up and names the mod. It does not make the colliding mod right for Valheim 1.0. With it, More Ore Deposits 1.3.5 loads and its five deposits generate, but:
- Its Black Forest gold deposits now yield vanilla's Deep North gold ore. Jotunn's
PrefabManager.GetPrefab("GoldOre")still hands the deposit the mod's own prefab (custom prefabs are checked first), so the freshly dropped item is the mod's locally — but its ZDO carries the hash ofGoldOre, which every other peer and every reload resolve throughZNetSceneandObjectDBto vanilla's prefab. The mod's item is a transient local ghost; the steady state is vanilla gold ore in a Black Forest zone. - Its
SmelterProduceMoreprefix doesstack *= 20wheneverSmelter.Spawnis called withore == "GoldOre", in everySmelter-type station. Vanilla 1.0's blast furnace convertsGoldOre → Gold(verified from the liveSmelter.m_conversiondata), so with this mod installed one gold ore becomes twenty Gold bars. That is the mod's design assumption — gold ore only ever came from the mod — no longer holding, and nothing outside the mod can fix it.
The mod needs a 1.0 build from its author: rename the item prefab (e.g. GoldOre_warp, matching its own token names) and compare the multiplier against that name. Its author (warpalicious / jneb802) is active — Jotunn's own 1.0 pull request is theirs.
Not covered by this shield: ZNetScene.Awake also builds m_namedPrefabs with Dictionary.Add. Jotunn guards its own RegisterToZNetScene, so only a mod adding straight to ZNetScene.m_prefabs could hit it; none on any list measured here does.
Deploying on a server that runs AzuAntiCheat
As for every release: the adapter DLL's bytes changed, the whitelist is hash-checked — copy the new Valheim10Compatibility.Adapter.dll into the server's whitelist folder when upgrading, or every updated client is kicked as "not on whitelist". Never list the patcher DLL.
2026-09-12 — 1.3.1: version cosmetics say "Valheim 1.0.x+", docs corrected, no bridge changes
Follows the first live client join on the Wubarrk Wonderland dedicated server (Valheim 1.0.12, network version 40) with 1.3.0 installed on both sides: 85 plugins on the client, connected at the first attempt, nothing thrown between handshake and spawn.
What 1.3.1 changes
- Adapter
[NetHandshake]line. It printed a hard-coded "Valheim 1.0.7, network version 39" on every outgoing peer handshake, which on a 1.0.12 / network-40 client read like a version mismatch. It now reports the running game's own values —Version.GetVersionString(false)andVersion.c_networkVersion, the latter by reflection because it is aconstthe compiler would otherwise freeze at the reference assembly's value — and says what the adapter is: "adapter 1.3.1 for Valheim 1.0.x+". The prefix still only logs; it never alters the handshake. - Patcher init line. "initialized (target build 1.0.7)" → "initialized (Valheim 1.0.x+; built against 1.0.7, verified on 1.0.12)", with the version number taken from the assembly instead of a string literal.
- No IL change. The injected bridge set, the ambiguity guard, the resolution hook and the config switches are identical to 1.3.0; the two DLLs differ only in version metadata and log text. Version bump: patcher, adapter, manifest → 1.3.1.
Docs corrected
- README: the patcher targets Valheim 1.0.x+ (built against 1.0.7, verified unchanged through the 1.0.12 hotfix) rather than "1.0.7 and 1.0.12"; the outcome table no longer names 1.0.7 as the build.
- DEVELOPMENT.md: the retest rig's boot marker is the world scene (
Zonesystem Awake), notGame server connected— that is only the Steam registration callback from the start scene, and a server whoseFejdStartup.Startaborted still prints it. Added: readserver-console.logas well asLogOutput.log(an exception escaping a plugin'sAwake()is swallowed by Unity atAddComponentand only the console log shows it), andOpened Steam serveris the line that says the host socket was bound.
Field notes from the live 1.0.12 join — no patcher change, recorded so nobody re-diagnoses them
- More Ore Deposits 1.3.5 registers a custom
GoldOre; vanilla 1.0 ships its ownGoldOreprefab, soObjectDB.UpdateRegistersthrows on the duplicate hash andFejdStartup.Startaborts on client and server alike (client: the menu spamsNullReferenceExceptionfromSteamworksMatchmaking.Tickand joins time out; server: never loads the world). A content collision, not something to bridge. Remove on 1.0. - Procedural Roads 1.4.3 subscribes to
ZoneSystem.GenerateLocationsCompletedwith a handler that calls the pre-1.0GetLocationList()(return type changed in 1.0 — rule 3, unbridgeable). TheMissingMethodExceptionaborts the event's multicast, soZNet.OnGenerationFinished, the only caller ofZNet.OpenServer, never runs: the server finishes booting with no host socket,Opened Steam servernever appears, and every join dies with Steam error 5003. Remove on 1.0. - More and Modified Player Cloth Colliders 3.1.0:
MissingFieldException: VisEquipment.m_clothColliders(field type changed) inside itsAttachArmor/Startpatches; on the client that aborts the rest ofFejdStartup.Startafter profile selection. Remove on 1.0. - The TerrainOp wire format changed in 1.0, which affects any mod that builds a
TerrainOpat runtime (Mists of Avalor 0.2.2's temple site, for one):TerrainOp.Settings.Serializenow writes only the prefab-name hash, the receiving owner looks it up inObjectDB.m_terrainOpsByHash, and an unregistered runtime op is logged as "Failed to deserialize TerrainOp settings for prefab hash …, cancelling TerrainOp" and dropped. Pre-1.0 serialized all fifteen settings fields. Not bridged: restoring the old transport would be a behavioural change, not a signature bridge, and is a separate decision.
Deploying on a server that runs AzuAntiCheat
The adapter DLL's bytes change with every release and AzuAntiCheat's whitelist is hash-checked: copy the new Valheim10Compatibility.Adapter.dll into the server's whitelist folder when upgrading, or every updated client gets kicked as "not on whitelist". Never list the patcher DLL — a preloader patcher is not a plugin, clients never report it, and an enforced entry for it reads as "missing".
2026-09-12 — 1.3.0: default interface method for Hoverable.GetHoverOffset(), clears every VTable-failure plugin
Follows a real-mod-list retest of 1.2.1 on the dedicated-server corpus.
What broke without this
TypeLoadException: VTable setup of type X failed, thrown out ofHarmonyLib.AccessTools.GetTypesFromAssemblythe instant a plugin's assembly is scanned — this kills every Harmony patch in that whole assembly, not just the offending class. Documented since 1.2.0 as recompile-only: "Interfaces that gained a method —Hoverable.GetHoverOffset(). The failure is aTypeLoadException: VTable setupon the plugin's own class; no preloader can add a method to somebody else's type."- Root cause, confirmed by decompiling both builds with Cecil:
Hoverableis an interface. Pre-1.0 it declared onlyGetHoverText()andGetHoverName(). 1.0 addedfloat GetHoverOffset()to the interface itself. The CLR requires every interface member to have a filled VTable slot before a type can be constructed, so any pre-1.0 class implementingHoverablewithout that member fails to load — not when the missing method is called, but the moment the assembly is scanned for anything, Harmony patch discovery included. - On the real 91-plugin dedicated-server corpus (
~/valheim-testbed/profiles/corpus-patched, synced on 2026-09-12 to the plugin versions actually running in the operator'sWonderland Clientprofile): CoreWoodExtras (8 distinct types), RustyBuildPieces (2), SeedBed, Historical Heritage, Ravenwood Random Relics and Procedural Roads (1 each) — 14 distinct types across 6 plugins, all previously written off as recompile-only.
What 1.3.0 changes
Injector.DefaultizeInterfaceMethod(new): stripsMethodAttributes.Abstractfrom an interface method and gives it a real body — a "default interface method" at the raw IL level (the ECMA-335 6th-edition / C# 8 feature, applied via Cecil rather than the compiler). A class that never overrides the member is no longer required to: it inherits the default implementation and passes VTable setup instead of failing to load.Patcher.PatchHoverable(new): applies this toHoverable.GetHoverOffset(), bodyreturn 0f;. Every existing vanilla call site already reads the interface defensively ascomponent?.GetHoverOffset() ?? 0f, so0fis vanilla's own answer for "doesn't have one" — the default body changes nothing for any caller. Classes that already implementGetHoverOffset()(every 1.0-built one) are completely untouched; their own override still wins, same as any ordinary interface method.- New config switch:
[Bridges] HoverableGetHoverOffsetDefault(defaulttrue). - Whether Valheim's embedded Mono runtime (Unity 6000.0.75f1) actually honours a default interface method injected this way was an open question until tested live — see Measured below. It does.
Measured
An isolated A/B, not a before/after across two different setups: the same 1.3.0 binary, the same 91-plugin mod list (synced to Wonderland Client's current versions), only Bridges.HoverableGetHoverOffsetDefault toggled between boots. A normalized diff of the two LogOutput.logs (timestamps stripped) contains nothing beyond the Hoverable injection line and its direct, expected consequences (the previously-crashing plugins' own Awake/config/patch-applied lines appearing).
| Switch off (= 1.2.1 behaviour) | Switch on (1.3.0 default) | |
|---|---|---|
TypeLoadException: VTable setup — lines / distinct types / plugins |
36 / 14 / 6 | 0 / 0 / 0 |
| Plugins with a VTable failure | CoreWoodExtras, RustyBuildPieces, SeedBed, Historical Heritage, Ravenwood Random Relics, Procedural Roads | none |
AmbiguousMatchException |
2 (same false-alarm BLOCKED lines as every prior release) | 2 (unchanged) |
Failed to patch |
2 (SearsCatalog, Advanced Terrain Modifiers — unchanged) | 2 (unchanged) |
HarmonyException / MissingFieldException / TypeInitializationException |
0 / 0 / 0 | 0 / 0 / 0 |
Could not find method |
0 | 0 |
| Chainloader | completes, 91 plugins loaded | completes, 91 plugins loaded |
The two TypeLoadException lines that remain in both runs belong to VNEI, and are unrelated: it probes for an optional EpicLoot integration (Could not load file or assembly 'EpicLoot, Version=0.13.0.0...') that is not installed on this test profile. That is a missing-soft-dependency message, not a VTable setup failure, and not something this patcher touches or should.
A correction to the "known-unfixable" list, found while re-syncing the test profile for this measurement
Two names carried since 2026-09-09 as "wait on Jotunn's own 1.0 release" — DvergrAllies and Mists of Avalor, both Wubarrk's own mods — turned out to already be fixed. The corpus-patched test profile was simply running stale copies (1.0.6 / 0.2.1) while ~/valheim-testbed/profiles/final-DvergrAllies and final-MistsofAvalor held working 1.0.7 / 0.2.2 builds against the same current Jotunn (2.30.0, byte-identical Jotunn.dll in both places). Swapping in the current builds: both load, "Patches applied OK" / "7 applied, none failed" for every one of their own patches, zero errors. Not a patcher fix — a reminder to check the mod manager's actual profile, not a stale test fixture, before writing a plugin off. Advize-StumpsRegrow was also found independently fixed by its own author (1.0.5 → 1.1.0) during the same version sync, unrelated to the Hoverable change.
Test environment
~/valheim-testbed/profiles/corpus-patched— the real Linux dedicated-server BepInEx profile, 93 plugin directories (91 actually load; two are client-only and BepInEx correctly refuses them server-side). Synced on 2026-09-12 to match the plugin versions actually running in the operator's liveWonderland ClientGale profile — 88 of 93 shared plugins were stale before the sync, including the two Wubarrk mods and Advize-StumpsRegrow above.- Booted with
~/valheim-testbed/boot-check.sh: the real Steam-installed 1.0.7 dedicated server, BepInEx 5.4.2350, Doorstop-injected, game install read-only. - Version bump: patcher, adapter, manifest → 1.3.0.
Packaging
- The zip no longer nests the DLLs in a
Valheim10Compatibility/folder underpatchers/andplugins/. Mod managers create a per-package folder of that name themselves, so the old layout installed asBepInEx/plugins/Valheim10Compatibility/Valheim10Compatibility/Valheim10Compatibility.Adapter.dll. Both DLLs now sit directly inpatchers/andplugins/; the README's hand-install instructions match. - Dual release.
package.shfoldsHexiumDist/…-hexium.zipandThunderstoreDist/…-thunderstore.zipfrom the same build. They differ only in the README — the Thunderstore one opens with a note that the Wubarrk and RavenIron Studios mods are maintained on valheim.hexium.gg, where their Valheim 1.0 builds are — and inmanifest.json'swebsite_url. The manifest description was shortened to Thunderstore's 250-character limit. - Developer material (build instructions, the overload-table generator,
tools/asmdiffand thediffs/reports, the retest rig) moved out of the shipped README intoDEVELOPMENT.md.
2026-09-12 — 1.2.1: de-literalize ZRoutedRpc.Everybody for ServerSync / legacy ldsfld compatibility
Follows single-player client validation of 1.2.0 on Wonderland Client (Valheim 1.0.7 / 1.0.12).
What broke without this
MissingFieldException: Field not found: .ZRoutedRpc.Everybody Due to: Using static instructions with literal field: In Valheim 1.0, Iron Gate definedZRoutedRpc.Everybodyaspublic const long Everybody = 0L;with metadata flagsFieldAttributes.Literal | FieldAttributes.HasDefault. Mods compiled against earlier declarations (notablyServerSyncembedded in AzuAntiCheat 4.3.11 and SeedBed 1.2.8) emit the IL instructionldsfld int64 ZRoutedRpc::Everybody. When the Mono/CLR JIT compiler encounters anldsfldinstruction targeting a field markedLiteral, it throwsMissingFieldException: ... Due to: Using static instructions with literal field. Because this executed during static class construction (.cctor), both plugins crashed at chainloader startup withTypeInitializationException.
What 1.2.1 changes
- De-literalize
ZRoutedRpc.Everybodyinassembly_valheim.dll: During preloader execution, the patcher stripsFieldAttributes.LiteralandFieldAttributes.HasDefaultfromZRoutedRpc.Everybody, converting it to a standard static field (public static long Everybody = 0L;).- Legacy
ldsfldcall sites fromServerSyncand older plugins now resolve cleanly without exception. - Native 1.0-compiled code is completely unaffected, as the C# compiler inlines constant literals into calling IL at compile time.
- New config switch:
Bridges.ZRoutedRpcEverybodyStatic(default:true).
- Legacy
- Patcher & Adapter version bump: Bumped to
1.2.1.
2026-09-11 — Verified on Valheim 1.0.12 (no release; 1.2.0 unchanged)
Valheim went from 1.0.7 (client build 25185596 / server 25185644, network version 39) to 1.0.12 (client 25253764 /
server 25253791, network version 40; Steam news "Hotfix 1.0.10 & 1.0.12", 2026-09-11). Unity 6000.0.75f1 is
unchanged, BepInEx 5.4.2350 boots the 1.0.12 dedicated server clean, Jotunn 2.30.0 is still current. Everything below
was re-measured against the real 1.0.12 builds; nothing in the patcher or the adapter needed to change, so 1.2.0
ships as it is. The only source edit is a dated note in PreferredOverloads.cs's generated-table comment.
What 1.0.12 changed, and why none of it reaches a bridge
libs-Tools/1.0/{client,server} is now 1.0.12; the 1.0.7 build is preserved verbatim under
libs-Tools/GAME-SNAPSHOT-1.0.7-build25185596/. tools/asmdiff over the two (reports in diffs/*_1.0.7_to_1.0.12.*):
only assembly_valheim changed — 37 types, 55 method bodies, 5 methods added, 1 removed, 6 fields added, 1 removed,
1 constant. assembly_utils, assembly_guiutils, gui_framework, assembly_postprocessing and Splatform are
IL-identical (Splatform's Unity MonoScript blob moved, nothing else). The client differs from the server only in the
FejdStartup / PresentManager client-only lines. Sorted by what a compatibility layer has to care about:
Version.c_networkVersion39 → 40 (inlined intoZNet, matchmaking,FejdStartup,Console.Awake,ZoneSystem.TestSpawnLocation). 1.0.7 and 1.0.12 peers cannot connect. Not the patcher's business — it touches no protocol — but it is why every profile had to be re-booted rather than assumed.PlayerProfile.s_bypassCheatCheckswent from a public static field to a public static property (the getter reads the local player'sbypasscheatchecksunique key, set by the new hidden console commandyesiuseddevcommandsbutiwantmyachievementsanyway). The methods the asmdiff reports as "body-changed 99–100 %, same token count" (Attack.DoMeleeAttack,Character.ApplyDamage/OnDeath,InventoryGui.DoCrafting,Player.TryPlacePiece,Smelter.Spawn,Fermenter.DelayedTap,Container.AddDefaultItems,Piece.DropResources,DropOnDestroyed.OnDestroyed,MineRock.RPC_Hit,ItemSets.TryGetSet,CookingStation.DropAllItems/drop,Destructible.Destroy,Inventory.AddItem, …) are that oneldsfld→call get_swap and nothing else. A plugin that read the field would throwMissingFieldExceptionat JIT; no plugin on the reference profile does (it appears in no unresolved list of the corpus measurement below) and no roster source mentions it. Nothing to bridge: a field cannot be re-added beside a property of the same name without making the name ambiguous for reflection.- Console-command gating loosened.
Terminal.ConsoleCommand.IsValidno longer requires cheats forHideBehindDevCommandscommands,Chat.isAllowedCommandno longer rejects them, andShowCommandwas rewritten. A command registered withhideBehindDevCommands: trueis now executable withoutdevcommandsand merely hidden from the tab list. The twoConsoleCommandconstructor bridges passfalsefor that flag — the pre-1.0 constructor had no such flag, andfalsewas and is the old behaviour — so a pre-1.0 command is unaffected. Only a 1.0-built mod that opts intotruesees the change. ItemDrop.SaveToZDO(itemData, zdo, index = -1): the guard around the legacy quality/variant sync flipped fromindex < 0toindex > -1. This is the one change beside a bridge; see the dedicated section below.ZDOMan.Load→ newConvertContainers, which loops slot index -1..13 through a reshaped privateConvertInventories(List<ZDOID>, World, int)(was(List<ZDO>, World)) and a newGetConvertHash(index, name, default)="{index}_{name}".GetStableHashCode()— the item-stand / armour-stand durability and upgrade-level world conversion. Private, not bridged, not patched by any plugin on the profile; and being a 1 → 1 reshape with the old signature removed, it is not an overload split (see the overload table below).- Body-only fixes behind unchanged signatures, which the forwarding bridges simply inherit:
Player.HaveRequirementItems(thediscoverpath no longer skips a requirement on an upgrader-station mismatch),TerrainComp.PaintCleared(null guard whenHeightmap.FindHeightmapreturns null;color2defaults to black — the method still has the three-parameter shape, so Advanced Terrain Modifiers' prefix is broken exactly as before),ZNet.ListContainsId(=→|=, so ban/permit/admin lists match both the newV_and the oldSteam_id forms),Character.UpdateGroundContact(restructuredm_onLandblock, deep-snow landing object),Attack.DoMeleeAttack(snow clearing now needs a snow shovel),Inventory.Changed(newm_cheatedPopup, "dropped cheated item" message),CookingStation.SpawnItem(new publicm_spawnFullDurability = true),Destructible.Destroy(null guard on the spawnedZNetView),Achievements.CanGetAchievements,InventoryGui.UpdateAchievementsList,ServerOptionsGUI.WorldContainsCheatedModifiers(a log line removed), and prefab-data additions onGrapplingPoint,RuneStone,AchievementUnlockPopup. None changes a parameter list, a return type, a field type or an interface.
The bridges on 1.0.12: the same 53, the same two refusals
~/valheim-testbed/profiles/patcher-dump (patcher + adapter only, VALHEIM10COMPAT_PLUGIN_PATH pointed at the
Wonderland ADMINing plugin folder as on 2026-09-10) booted on the 1.0.12 dedicated server in 28 s. The patcher's 85
log lines are identical to the 1.0.7 run (logs-1.0.7-run/ in the profile), timing excepted: guard scan of 84
Harmony plugins, 779 by-name targets, 17 alias references; hook installed on HarmonyX 2.9.0.0 with 18 preferred
overloads; 52 bridges + the PieceTable.m_availablePieces alias injected into assembly_valheim (12 of them
hook-dependent), 0 already native, 0 skipped, 2 blocked by the return-type rule (ZDO.GetSector(),
ZoneSystem.GetZone(Vector3)); assembly_utils: GetStableHashCode present natively, ZInput.get_mousePosition
injected. The guard scan took 3101 ms instead of 1892 ms (cold cache). BepInEx/DumpedAssemblies/valheim_server/ in
that profile is now the patched 1.0.12 server (assembly_valheim.dll 2,561,024 bytes; the 1.0.7 dump was
2,558,976) and is what the static measurement below resolves against.
PreferredOverloads re-verified, and the generator is in the repo now
The 18-row table was generated on 2026-09-10 by a one-off Cecil script that was never saved. It is now
tools/overloaddiff/ (net8 + Mono.Cecil, README alongside): every method name whose overload count went from
exactly one to two or more with the old parameter list still present, plus the names whose old shape is gone, with
--table to compare against PreferredOverloads.cs and --csharp to emit rows. Run pre-1.0 client (21981559) →
1.0.12 client: the same 18 rows, plus generic ZDOHelper.Remove (left out on purpose, as before) and the same three
old-shape-gone names (ZDO.ReadNumItems, ZoneSystem.GetGroundOffset,
GraphicsSettingsManager.GetCurrentSettingsWithCurrentPresetApplied); the pre-1.0 → 1.0.7 report is line-for-line the
same. 1.0.7 → 1.0.12 on its own (client and server) splits no overload. The table is unchanged; its comment carries
the re-verification date.
ItemDrop.SaveToZDO(ItemData, ZDO) and the guard flip — reviewed, bridge unchanged
The two-argument bridge forwards to the native SaveToZDO(itemData, zdo, index) with index = -1. 1.0.12 flipped the
guard around the block that reads the ZDO's legacy s_quality / s_variant ints and rewrites them when they disagree
with the item: 1.0.7 ran it for index < 0, 1.0.12 runs it for index > -1. Read against all three decompiles:
- Pre-1.0 had no such block.
SaveToZDO(ItemData, ZDO)on build 21981559 wrote ten discrete keys (s_durability,s_stack,s_quality,s_variant,s_crafterID,s_crafterName,s_dataCount,data_N/data__N,s_worldLevel,s_pickedUp) unconditionally, andLoadFromZDOread them back. 1.0 packs the item into ones_itemDatabyte array (or"{index}_itemData"), and 1.0'sLoadFromZDOreads only that; the discrete keys are converted once at world load (ZDOMan.ConvertInventories, which is what 1.0.12 extended to slot indices) and removed. So "pre-1.0 semantics" — writing the ten keys — is not something a bridge could sensibly restore on any 1.0 build: vanilla would never read them, and the item would load empty. The bridge has always meant "the 1.0 equivalent of the plain save". - The block is not item preservation; it is legacy-key hygiene for
ItemStand.ItemStand.UpdateVisualis the only remaining reader of the top-levels_quality/s_variant(GetInt(ZDOVars.s_quality, 1)), and the converter deliberately keeps those two keys when quality ≠ 1 / variant ≠ 0. The block only ever writes when the ZDO already holds such a key with a different value (theGetIntdefault is the item's own value, so a fresh ZDO gets nothing written on either build). Which index range it runs for is Iron Gate's call about stands, and it applies to vanilla's own threeSaveToZDO(itemData, zdo)call sites (ItemDrop.Save,ItemDrop.LoadFromExternalZDO,ItemStand.UpdateAttach) exactly as it applies to the bridge. - The bridge is byte-for-byte what a 1.0.12-compiled
SaveToZDO(item, zdo)call site is — C# bakes the default-1into every such call — so a pre-1.0 caller behaves precisely as a caller compiled against the running build would, on 1.0.7 and 1.0.12 alike. The same holds for the three-argument bridgeSaveToZDO(int, ItemData, ZDO)→(itemData, zdo, index)and for the hook's typed-lookup redirect of aSaveToZDO(ItemData, ZDO)patch onto the native method. Verdict: correct on both builds, no change. Documented here so the next guard flip does not need re-deriving.
Measured (static): 37 / 37 / 65 of 74, the same nine still need their authors
libs-Tools/refcheck/corpus-measure.py over the 74 third-party plugins of the Wonderland ADMINing profile (read-only),
patched_server against the fresh 1.0.12 dump with the hook modelled:
| Fully clean | Unresolved member-uses | Fatal Harmony findings | |
|---|---|---|---|
| Vanilla 1.0.12 client | 37 / 74 | 139 (61 distinct members) | 36 |
| Vanilla 1.0.12 server | 37 / 74 | 139 (61 distinct members) | 36 |
| 1.2.0 on 1.0.12 | 65 / 74 | 37 (34 distinct) | 5 |
Every number equals the 2026-09-10 measurement on 1.0.7, and the nine broken plugins are the same nine for the same
members: AzuExtendedPlayerInventory (26 unresolved, 2 dead targets), CurrencyPocket (3), More Player Cloth Colliders
(1), VNEI (1), WhereTheCrowFlies 1.1.0 (1; fixed at source in 1.1.1), Advanced Terrain Modifiers (3 PaintCleared
parameter names), Resurrection (2), Muted Sails (1), Procedural Roads (2). No plugin changed status in either
direction. (The vanilla client and server columns agree at 37; the "43" in the 1.2.0 table above is the 1.1.1 column,
not a server number.)
Measured (live): the corpus A/B on the 1.0.12 dedicated server is the 1.0.7 A/B
corpus-base (90 plugins, no patcher) and corpus-patched (the same + patcher and adapter) booted on the 1.0.12 server;
their 1.0.7 logs are kept in each profile's logs-1.0.7-run/.
| Log signature | base 1.0.7 | base 1.0.12 | patched 1.0.7 | patched 1.0.12 |
|---|---|---|---|---|
| plugins loaded / to load | 90 / 90 | 90 / 90 | 91 / 91 | 91 / 91 |
Could not find method |
10 | 10 | 0 | 0 |
Failed to patch |
1 | 1 | 2 | 2 |
HarmonyException (a PatchAll() death) |
1 | 1 | 0 | 0 |
TypeLoadException lines / distinct VTable types / plugins |
27 / 16 / 8 | 27 / 16 / 8 | 24 / 16 / 8 | 24 / 16 / 8 |
AmbiguousMatchException thrown |
0 | 0 | 0 | 0 |
Harmony hook: resolutions |
– | – | 43 | 43 (identical set) |
Behind the numbers, unchanged on both sides: the ten Could not find method are AzuExtendedPlayerInventory,
CoreWoodExtras and Vikings Archer (Inventory.AddItem(ItemData, int, int, int)), TrueInstantLootDrop and OdinShip
(EffectList.Create 5-arg), YouAreBeingLogged, Magic Supremacy and DualWielder (ItemData.GetTooltip 5-arg), Magic
Supremacy (VisEquipment.AttachArmor(int, int)) and Mists of Avalor 0.2.1 (Player.PlacePiece 4-arg, whose
HarmonyException is the AVALOR PATCH FAILED line). Base's one Failed to patch is Advanced Terrain Modifiers'
PaintCleared; patched has that plus SearsCatalog's Player.UpdateBuildGuiInput transpiler, which only gets that far
because the hook resolves its SetCategory postfix. The sixteen VTable setup types (Hoverable.GetHoverOffset on the
plugins' own classes) are Advize StumpsRegrow (1), Historical Heritage (1), CoreWoodExtras (8), Ravenwood Random Relics
(1), Rusty Build Pieces (2), Procedural Roads (1), and the old DvergrAllies 1.0.6 and Mists of Avalor 0.2.1 builds still
in the Gale profile (1 each); base logs Rusty's two twice (at its own load and at the assembly scan), hence 27 lines
against 24. The 43 hook resolutions are the same 43 lines. A normalised diff of the whole LogOutput.log pair is
empty for base and one line for patched — an extra [Info :Server Devcommands] Reloading 9 alias data. from Server
Devcommands 1.110.0 re-reading its alias file, a file-watcher timing artefact, not a change.
Docs and tooling
tools/overloaddiff/added and documented;tools/asmdiff/run-diffs.shnow also produces the 1.0.7 → 1.0.12 reports and the 1.0.12 client-vs-server control from the preserved snapshot againstlibs-Tools/1.0.diffs/:server_assembly_valheim_1.0.7_to_1.0.12.{md,json,ildiff.txt},client_assembly_valheim_1.0.7_to_1.0.12.*, the five other client assemblies, andcontrol_1.0.12_client_vs_server.*.PreferredOverloads.cs: re-verification note in the comment (no IL change; the shipped 1.2.0 DLLs are untouched).
2026-09-10 — 1.2.0: the Harmony resolution hook, six more bridges, the PieceTable alias
43 → 65 of the 74 third-party plugins on the reference profile load clean. The other nine need a recompile (field type
changes and an InventoryGrid rewrite) and no preloader can help them.
The hook, and why the guard was never going to be enough
1.1.x's One Law refused every bridge that sat beside a by-name Harmony patch, because HarmonyX resolves such a
patch with a plain Type.GetMethod(name, flags) and a second overload makes that throw out of PatchAll(). On the
reference profile that refused 10 bridges — EffectList.Create alone was wanted by 11 plugins and blocked because
Infinity Hammer patches it by name, and Infinity Hammer's 1.0 build still does. Those blocks were never going to
clear on their own.
Worse: vanilla creates the same ambiguity without any bridge. 1.0 added a second overload to Inventory.Load,
PieceTable.SetCategory, Attack.GetAttackEitr and fifteen other methods, so a pre-1.0 plugin patching any of them by
name dies on plain 1.0.7 (six of them on this profile: AzuExtendedPlayerInventory, CoreWoodExtras, Historical Heritage,
SearsCatalog, ValheimCuisine...). The 1.1.1 README's answer was "the fix is one line in that mod". True, and useless
for a server admin.
So 1.2.0 installs MonoMod detours on the four AccessTools entry points every Harmony lookup goes through
(DeclaredMethod, Method, DeclaredField, Field), from the preloader, before any plugin loads:
- a by-name lookup never returns a bridge (tagged
[Obsolete("Valheim10Compatibility bridge ...")]) — the patch lands on the native method vanilla calls, and bridges and by-name patches coexist; - when two native overloads remain, the one matching the single pre-1.0 overload wins. The table
(
PreferredOverloads.cs) was generated from a Cecil diff of build 21981559 against 1.0.7: 18 names, and for the three this profile hits the decompile confirms vanilla still calls the old shape, so the patch is live, not an anchor; - a typed lookup that names a bridge's exact parameter list is redirected to the native method the bridge forwards
to, when the bridge only appends defaulted parameters (same leading parameter types and return type). Bridges that
transform their arguments (
Vector2i→Vector2s) stay as they are: Harmony binds patch parameters by name with no type check, so aVector2iprefix parameter on aVector2smethod would emit invalid IL; - a field lookup that finds both a native field and an alias with the same name is answered from the caller's own IL
(the guard now records which assembly references which shape): old-shape plugins get the alias, everyone else the
native field.
___fieldinjection goes throughAccessTools.Field, so this is what keeps a pre-1.0List<List<Piece>> ___m_availablePiecesparameter from loading aHashSetinto aList-typed argument.
Anything the hook does not understand falls through to HarmonyX's own behaviour, exception included. The guard is still built at every boot; with the hook in it is advisory (the log names every plugin whose by-name patch now relies on the hook — 12 bridges, 20 plugins here), and if the hook fails to install it blocks exactly as 1.1.x did.
What the hook does not cover, deliberately: plain Type.GetMethod / Type.GetField callers. That is precisely
the lookup that killed Wonderland 0.2.0 under 1.1.0, so the return-type rule
stays and ZDO.GetSector() / ZoneSystem.GetZone(Vector3) stay refused.
Six more appended-parameter bridges
Inventory.IsTeleportable() (forwarding false reproduces the old body), CensorShittyWords.FilterUGC(string, UGCType, PlatformUserID, long), Game.SavePlayerProfile(bool), CookingStation.SetSlot / GetSlot (the latter
discards 1.0's new out bool cheated into a local), SlowUpdate.SUpdate(float, Vector2i) (callvirt, so Plant and
StaticPhysics overrides still win) — and PlayerProfile.GetCharacterFolderPath(FileSource), which moved to
SaveSystem and whose argument changed numbering: FileHelpers.FileSource went from Auto=0, Local=1, Cloud=2, Legacy=3 to flags 1, 2, 4, 8. The bridge computes 1 << old before forwarding; forwarding blindly would answer
the Cloud path for a Local request. (The same enum passed to any surviving method cannot be fixed this way, and
neither can PieceCategory — see the alias caveat.)
The PieceTable.m_availablePieces alias
1.0 renamed the per-category lists to private m_availablePiecesByCategory and reused the old name for a flat
HashSet<Piece>. Fifteen build-piece mods on the profile read the old field. The alias is a second field with the
old name and type, assigned in the constructor to the same list object as m_availablePiecesByCategory, so the
two views cannot diverge (the injector refuses if the native field were ever reassigned outside the constructor —
it is not). IL binds fields on name and signature, so vanilla and 1.0-built plugins keep the HashSet and
pre-1.0 plugins get the lists. Reflection is the hazard — two fields, one name — handled two ways: the field hook
above for AccessTools/___ consumers, and a guard scan that refuses the alias when any installed plugin passes the
literal "m_availablePieces" to Type.GetField or Traverse.Field (none on this profile; 17 plugins reference the
old shape in IL, zero look it up by string).
Caveat, and the config switch for it. 1.0 inserted PieceCategory.DeepNorth = 5, pushing Feasts/Food/Meads to
6/7/8 and Max to 9. A pre-1.0 mod bakes those numbers at compile time, so a piece it files under old Feasts (5)
lands in the DeepNorth tab, and a custom tab it created at old Max (8) shares the Meads list. Wrong tab, not a
crash. Bridges.PieceTableAvailablePiecesAlias = false in BepInEx/config/Valheim10Compatibility.cfg turns the
alias off; Harmony.ResolutionHook = false turns the hook off (1.1.x behaviour).
The adapter's build-menu shield was broken on 1.0.7
EnsurePieceTableCapacity read m_availablePieces through Traverse as a List<List<Piece>>. On 1.0.7 that field
is a HashSet, so the cast threw InvalidCastException inside six PieceTable prefixes on every client with a build
menu — and with the alias in place it would have thrown AmbiguousMatchException instead. The dedicated-server boots
never open a build menu, which is why 1.1.x never saw it. It now reads m_availablePiecesByCategory directly and
sizes against PieceCategory.Max instead of a baked 7. Adapter plugin version is now 1.2.0, in step with the package.
Measured
Static, refcheck over the 74 third-party plugins of the Wonderland ADMINing profile (after the operator's own
updates of 18 plugins on 2026-09-10) against the dumped 1.0.7 dedicated-server assemblies, with the hook modelled
(--hook --pre10):
| Fully clean | Unresolved member-uses | Fatal Harmony findings | |
|---|---|---|---|
| Vanilla 1.0.7 | 37 / 74 | 139 | 36 |
| Valheim10Compatibility 1.1.1 | 43 / 74 | 75 | 14 |
| Valheim10Compatibility 1.2.0 | 65 / 74 | 37 | 5 |
Live, the whole profile (90 plugins, the old Wubarrk builds included) booted twice on the real 1.0.7 dedicated server, same list, patcher out and patcher in:
| Log signature | No patcher | 1.2.0 |
|---|---|---|
Could not find method (a typed patch target is gone) |
10 | 0 |
A patch lost at PatchAll() (Failed to patch / HarmonyException) |
2 | 2, different ones |
TypeLoadException: VTable setup (Hoverable.GetHoverOffset on the mod's own types) |
16 types / 8 plugins | same 16 |
AmbiguousMatchException |
0 | 0 |
The two lost patches without the patcher were Mists of Avalor 0.2.1's no-build protection (its typed PlacePiece
target is gone; the bridge plus the redirect restore it - AVALOR PATCH FAILED disappears) and Advanced Terrain
Modifiers' PaintCleared prefix (names three parameters 1.0 removed; identical on both boots). The one that appears
with the patcher is a SearsCatalog transpiler whose IL pattern no longer exists in Player.UpdateBuildGuiInput -
SearsCatalog never got that far before: its SetCategory postfix was ambiguous and the plugin applied nothing,
silently. The VTable failures are an interface change (Hoverable gained a method) on the plugin's own classes; no
preloader can add a method to somebody else's type. Recompile, as the roster did.
The nine still broken and why: AzuExtendedPlayerInventory and CurrencyPocket (the InventoryGrid.Element rewrite,
plus AzuEPI's eleven VisEquipment string slots), VNEI (Player.m_knownBiome type), More Player Cloth Colliders
(m_clothColliders type), Muted Sails (Ship.m_sailCloth type), Resurrection (PlayerInfo.m_host and
PrivilegeManager.GetNetworkUserId, both gone), Procedural Roads (GetZone / GetLocationList return types),
Advanced Terrain Modifiers (TerrainComp.PaintCleared lost three parameters its prefix names), WhereTheCrowFlies
1.1.0 (m_playerStats is an array now; fixed at source in 1.1.1).
Also
- Bridges are now tagged
[Obsolete("Valheim10Compatibility bridge -> Target(ParamTypes)")]so the hook can find the native method a typed lookup should be redirected to. The message still starts with the bare tag other tools match. - The guard's scan now walks IL for the aliased field (pre-filtered on the raw bytes, so only the ~18 plugins that mention the name pay for it): ~1.9 s at boot on this profile, up from ~0.25 s.
libs-Tools/refcheckgained--hook [--pre10 <dir>]to model the hook;corpus-measure.pyuses it for the patched column.libs-Tools/BepInEx/core/now carries the two MonoMod DLLs from the BepInEx pack for the build.- Patcher log line to look for:
Harmony resolution hook installed on HarmonyX 2.9.0.0 (...). Every resolution the hook makes is logged once,Harmony hook: AccessTools.DeclaredMethod(Inventory, "Load") -> Void Inventory.Load(ZPackage) [vanilla added an overload in 1.0; the pre-1.0 shape is taken] <- CoreWoodExtras.
2026-09-09 (later) — 1.1.1: never inject a return-type-only overload
1.1.0 killed Wonderland 0.2.0 on a live 1.0.7 server. Reported by the Wonderland session after root-causing it on VanillaBean, with the crash reproduced across boots:
[Message:Wonderland] [Wonderland] Starting Wonderland v0.2.0 (server-only, built for Valheim 1.0.7)...
[Error : Unity Log] AmbiguousMatchException: Ambiguous match found.
System.DefaultBinder.SelectMethod (...)
Wonderland.Core.Compat.GameShape.Detect ()
Wonderland.WonderlandPlugin.Awake ()
GameShape.Detect() probes the build with typeof(ZoneSystem).GetMethod("GetZone", flags, binder, new[]{ typeof(Vector3) }, null). That overload selects on name and parameter types only — it cannot see a return type. 1.1.0 injected Vector2i GetZone(Vector3) beside the native Vector2s GetZone(Vector3), so the lookup matched two methods and threw, in Wonderland's own Awake(), before a single subsystem initialised.
The rule, and why it should already have been there
The patcher had already refused ZDOMan.GetPortals for precisely this reason, and the README said so in as many words: "injecting the old one would differ by return type alone: legal IL, not expressible in C#, and ambiguous for every AccessTools.Method consumer." That reasoning was applied by hand to one method and not carried to the other two. It is now enforced categorically in Injector.Begin(): no bridge may match an existing method on name and parameter types, whatever its return type. ZDO.GetSector() and ZoneSystem.GetZone(Vector3) are refused, each with a BLOCKED line naming the clash.
This failure mode is distinct from the by-name Harmony ambiguity the guard already handled, and no plugin scan can find it reliably — a plain Type.GetMethod in mod code is not an attribute and need not use a literal string. Hence a categorical rule rather than another scanner.
What it costs, measured
Re-run over the same 87 plugin DLLs of the live Wonderland ADMINing list:
| Unresolved game references | Plugins with none | Broken Harmony patch targets | |
|---|---|---|---|
| Vanilla 1.0.7, no patcher | 327 | 25 / 87 | 102 |
| 1.1.0 | 179 | 42 / 87 | 69 |
| 1.1.1 | 188 | 41 / 87 | 69 |
Nine call sites across nine plugins are given back (AwayFromHome, HearthBelow, LetItGrow, MistsofAvalor, More_World_Locations_AIO, ProceduralRoads, ServerDevcommands, TortalPortal, WorldEditCommands), and HearthBelow returns to one unresolved reference. Nothing is worse off than with no patcher at all. That is the deliberate trade: an unresolved call site kills the one method that made it, when it is called; a return-type-only overload kills any plugin that reflects on the name, at startup, whole.
Verification
The IL verifier now asserts the invariant for every injected bridge: none may share a name and parameter list with another method in its type. Run against a 1.1.0-shaped build it fails on exactly ZDO.GetSector and ZoneSystem.GetZone; against 1.1.1 all 35 bridges pass, alongside the existing operand-resolution and stack-balance checks.
2026-09-09 — retargeted at the 1.0.7 release build, 37 bridges, ambiguity guard
The 2026-09-08 patcher was written and measured against the public-test build 23105022 (0.221.13). The shipped game is 1.0.7 (client 25185596 / dedicated server 25185644, network version 39), and the sector API changed again between the two. Audited against libs-Tools/1.0/DECOMPILED/: of the 24 bridges, 8 silently no-opped on the release build, 1 was unnecessary, and 1 actively broke plugins. Every bridge has been re-derived against the release.
The three things that were wrong
GetStableHashCodewas reverted before release. The playtest'sGetStableHashCode(string, bool addToNameHash = true)does not exist on 1.0.7; the 1-arg overload is the only one, identical to pre-1.0. The measured "65 of 93 plugins" headline that justified this patcher's largest claim describes the playtest, not the shipped game. The bridge correctly no-ops; the doc comment claiming otherwise has been rewritten.FindSectorObjectssilently injected nothing. 1.0 collapsed(int area, int distantArea)into aSimulationDistancestruct and deletedZoneSystem.m_activeArea. The old bridge looked for a 5-parameter target; the shipped method takes 4. Now rebuilt ontonew SimulationDistance(area, distantArea, classic: true)—classic: trueis required, because with it false vanilla filters each ring throughZoneSystem.ZonesWithinRadiusand the swept set becomes a disc rather than the square ring pre-1.0 callers assume.SpawnZoneandPlaceVegetationwere breaking plugins. Both are patched by name ([HarmonyPatch(typeof(ZoneSystem), "SpawnZone")]) by ProceduralRoads, Venture LocationReset and Mists of Avalor. Injecting a second overload makes that lookup ambiguous, HarmonyX throws out ofPatchAll(), and the whole plugin dies — strictly worse than the unresolved reference the bridge was fixing.
New: the ambiguity guard
AmbiguityGuard scans the real BepInEx/plugins folder at preloader time, records every game method patched by name (no argument types), and refuses any bridge that would collide with one, reporting it as a BLOCKED line naming the plugins it protects. On the Wonderland ADMINing list it blocks 10 bridges and protects 17 plugins. Override the scanned folder with VALHEIM10COMPAT_PLUGIN_PATH.
Every bridge now reports exactly one outcome — Injected, present natively, SKIPPED or BLOCKED — plus a per-assembly summary. The previous release logged only successes, so a run against a build it did not match looked clean while leaving the highest-impact breakages unbridged.
Bridges added
- Console-command surface (the reason admin tools died at boot):
Terminal.ConsoleCommand..ctorfor bothConsoleEventandConsoleEventFailable(1.0 insertedbool hideBehindDevCommandsat position 8), andTerminal.ConsoleEventArgs..ctor(string, Terminal)(gained a thirdConsoleCommandparameter). - Patch anchors for members 1.0 renamed out from under by-name patches:
Container.RPC_OpenRespons,Container.RPC_TakeAllRespons,Minimap.OnMapRightClick. These restore the name so the plugin loads; vanilla calls the renamed method directly, so a prefix on an anchor never fires and that one feature stays inert. Documented as such in the log line and the README. ZDOMan.SectorToIndex(Vector2i) -> int, previously written off as unbridgeable. Re-checked:ZDOManis stillnew ZDOMan(512)withm_objectsBySector = new List<ZDO>[512 * 512], andZoneSystem.SectorToIndexcomputes(y + 256) * 512 + (x + 256)— the same arithmetic the oldm_halfWidthversion did. Only the out-of-range answer differs (old-1, new index0), and the bridge reproduces the old contract including the-1.ZNetScene.InActiveArearebuilt for the release: the parameters were reordered, not just retyped — pre-1.0(Vector2i zone, Vector3 refPoint), 1.0.7static (Vector3 point, Vector2s centerZone). The old first argument is a zone where 1.0 wants a world point, so the bridge converts throughZoneSystem.GetZonePosrather than swapping arguments, which would compile and answer wrong.- Appended-parameter drift (C# bakes default-argument values in at the call site, so a method that merely gained an optional parameter still breaks every plugin compiled before it):
Character.Message(43 of 87 plugins),SEMan.AddStatusEffectx2, fourInventory.AddItemoverloads,ItemDrop.OnCreateNewx2,ItemDrop.ItemData.GetTooltip,Heightmap.Poke(bool),TerrainComp.Save(),WorldGenerator.GetBiomeHeight,YesNoPopup..ctor, andZInput.mousePositioninassembly_utils.
Bridges removed
ZDOMan.GetSaveClone()— the stub returned an emptyList<ZDO>, i.e. reported that the world contains zero ZDOs. No plugin on the reference list calls it.ZDOMan.GetPortals()— 1.0 still has a parameterlessGetPortalsreturning a different type, so the old bridge would have differed by return type alone: legal IL, not expressible in C#, and ambiguous for everyAccessTools.Methodconsumer. It did not fire on 1.0.7, but the code path is gone rather than left armed.ZDO.SetSector(Vector2i)— noSetSector(Vector2s)exists to forward to; 1.0 hasprivate SetSector(ZoneSystem.SectorIndex).ZNetScene.InActiveArea(..., int)/OutsideActiveArea(..., int)— both took an explicit ring radius that 1.0 removed entirely.
Adapter
- Fixed a latent load failure in our own plugin.
[HarmonyPatch(typeof(PieceTable), nameof(PieceTable.SetCategory))]had no argument types, and 1.0 addedSetCategory(Piece.PieceCategory)besideSetCategory(int)— the exact ambiguity the patcher now guards other people against. Argument types pinned. - Retargeted at
libs-Tools/1.0/server; the removed37uprotocol-tolerance claim is gone from the docs.
Measured against a live modded server list
Run over the real 1.0.7 dedicated-server assemblies, then every game reference in all 87 plugin DLLs of the live Wonderland ADMINing profile re-resolved against the patched result. The profile was only read; nothing was installed, moved or restarted.
| Unresolved game references | Plugins with none | Broken Harmony patch targets | |
|---|---|---|---|
| Vanilla 1.0.7, no patcher | 327 | 25 / 87 | 102 |
| With this release | 179 | 42 / 87 | 69 |
No plugin regressed on either measure. All 37 injected bridges were verified post-injection: every operand resolves and every evaluation stack is balanced. Server Devcommands 1.109.0, which previously died in PatchAll() on Container.RPC_OpenRespons before registering a single command, now loads with its console commands intact.
Housekeeping
Valheim10Compatibility.Patcher.csprojreferenced Mono.Cecil from a Windows testbed profile (DEDICATED-SERVER-TESTBED/profile-vikingos-min/...); repointed atlibs-Tools/Mono.Cecil.dll.package.shnow folds the Hexium release package intoHexiumDist/, matching every other Wubarrk release:manifest.json,icon.png,README.md,CHANGELOG.mdandLICENSE.mdat the zip root, withpatchers/andplugins/as separate root-level trees that the mod manager maps onto the profile's ownBepInEx/folders. Keeping them separate is not cosmetic — a preloader patcher inplugins/never executes, and a plugin inpatchers/is never chainloaded. AddsLICENSE.md(MIT, the house terms) andicon.png; the olddist/folder and the Thunderstore-named zip are gone.README.mdrewritten as a release document.
2026-09-08 — 24-bridge patcher, verified against real plugins, asmdiff tool
Three rounds of gap-closing driven by TheEye's ModImpact scanner running the patcher against 93 real BepInEx plugin DLLs on the real Valheim 1.0 playtest build (23105022 / 0.221.13), not just static analysis. Measured result: 65 of 93 plugins broken with no patcher installed, 17 of 93 broken with it.
Patcher (Valheim10Compatibility.Patcher)
Added, on top of the 11 bridges shipped 2026-09-07 (ZDO.GetSector, ZoneSystem.PokeLocalZone/IsZoneGenerated/IsZoneLoaded/GetZone/GetZonePos, ZNetScene.InActiveArea(Vector2i,Vector2i)/HaveInstanceInSector, ZDOMan.GetSaveClone/GetPortals):
ZoneSystem.SpawnZone(Vector2i, SpawnMode, out GameObject)ZNetScene.InActiveArea(Vector2i, Vector3),InActiveArea(Vector2i, Vector2i, int),OutsideActiveArea(Vector3, Vector2i, int)ItemDrop.SaveToZDO(int, ItemData, ZDO),SaveToZDO(ItemData, ZDO),LoadFromZDO(int, ItemData, ZDO)— 1.0 moved theindexparameter from position 0 to position 2StringExtensionMethods.GetStableHashCode(string)inassembly_utils.dll— the single highest-impact fix in this release.TargetDLLspreviously listed onlyassembly_valheim.dll; this method was never patched at all. Measured at 65/93 plugins affected, because C# bakes default-argument values in at the call site and every plugin compiled before 1.0 calls the now-removed 1-arg overload.Patch()now branches on the target assembly's name.ZDOMan.FindSectorObjects(Vector2i, int, int, List<ZDO>, List<ZDO>),FindObjects(Vector2i, List<ZDO>),FindDistantObjects(Vector2i, List<ZDO>)— the latter two reusethis.m_visitedSectorIndicesfor the newHashSet<SectorIndex>parameter, matching every internal caller; a mod calling them directly outsideFindSectorObjectsshares that live, only-occasionally-cleared set rather than isolated state.ZoneSystem.PlaceVegetation(Vector2i, ...)
Every injected bridge (24 total) is tagged [Obsolete("Valheim10Compatibility bridge")] so other tooling can distinguish an injected bridge from a native 1.0 method via reflection.
Fixed a correctness bug: the GetSaveClone() stub silently returned an empty List<ZDO>. It now calls UnityEngine.Debug.LogWarning on every invocation so a mod reading it for world state notices instead of seeing "zero ZDOs".
Deliberately not bridged, with reasoning recorded in README.md:
ZDOMan.SectorToIndex(Vector2i): int— looked like a rename toZoneSystem.SectorToIndex(Vector2s): SectorIndexbut isn't one. The old method computed a raw index intoZDOMan.m_objectsBySectorusingm_halfWidth, which was removed in 1.0 along with that storage scheme. A bridge would silently hand back a number that no longer corresponds to anything.ZDOExtraData.GetFloats/GetInts/GetLongs/GetStrings/GetVec3s/GetQuaternions/GetByteArrays(ZDOID)— needs a generic-method Cecil bridge per type; not shipped untested.VisEquipment's item-slot setters andItemStand.SetVisualItem— technically bridgeable via the same hash-conversion pattern asGetStableHashCode, but every plugin that calls the setters also reads the now-intfields directly (confirmed by TheEye's scan), so bridging the setters alone clears zero plugins. Not implemented.- Field type changes (
ZoneSystem.m_zones/m_locationInstances,ZDOMan.m_objectsByOutsideSector), ambiguous Harmony patch targets ([HarmonyPatch(typeof(Inventory), "Load")]with no argument types) — not fixable by bridge injection, ever; documented for mod authors instead.
Tooling
- Added
tools/asmdiff: a Cecil-based, IL-body-aware whole-assembly differ. PairsVector2i/Vector2soverloads by best body similarity, inlines lambda/local-function/state-machine bodies into their containing methods, classifies every method and field change (unchanged / body-changed / body-changed:type-only / sig-changed / sig-changed:type-only / renamed / added / removed), and reports constant and enum value changes that a signature-only diff would miss. Validated against an independently-authored baseline (the ValkyriesCargo session's manual body reads) and against TheEye's own Huginn structural diff of the real game dumps. - Added
diffs/: generated.md/.json/.ildiff.txtreports forassembly_valheim,assembly_utils, andassembly_guiutils, 0.221.12 server vs 0.221.13 server, plus a 0.221.12 client-vs-server control that isolates build-kind differences from version movement.
Documentation
README.md§2.A: added the four newly-bridged pre-1.0 signatures to the coverage table, with a note thatZDO.SetSector(Vector2i)is listed for visibility but is not actually bridged (1.0 has noSetSector(Vector2s)overload to wrap).README.md§6 (new): measured before/after broken-plugin counts from TheEye's real-build scan, and the full list of what's still broken with an explanation of why none of it is a bridge-injection job.- Corrected doc/reality mismatches found during the 2026-09-07 audit:
World.ChunkedSaveis40, not45; the "16 new types" claim overstated top-level additions (2 added, 1 removed on the server axis; the rest are nested);Utils.PerlinNoiseisDUtils.PerlinNoise;Inventory.AddItem(int prefabHash, ...)overloads are private, not public.
2026-09-07 — Initial adapter + Cecil preloader patcher
First working version: Valheim10Compatibility.Patcher (Mono.Cecil preloader, 11 Vector2i bridge overloads across ZDO/ZoneSystem/ZNetScene/ZDOMan) and Valheim10Compatibility.Adapter (Harmony runtime plugin: save-migration backup watchdog, PieceTable category capacity shield, Hud.UpdatePieceList exception finalizer, network handshake logging). Comparative diff analysis of 0.221.12 vs 0.221.13 (DIFF_ANALYSIS_0.221.12_to_0.221.13.md) and adapter/patcher design doc (ADAPTER_PATCHER_DESIGN.md) written for Don Johnson's review.