Wubarrk-Valheim10Compatibility icon

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 the MethodInfo becomes a Harmony patch target (the patch then fires on vanilla's own calls); wrong when the plugin uses it for MethodInfo.Invoke, because the arity no longer matches.
  • Historical Heritage 2.1.5 and Magic Supremacy 3.0.7 share one CustomSlotSystem that looks up VisEquipment.AttachArmor(int, int) and invokes it with two arguments. On 1.0.12 the native is AttachArmor(int itemHash, int variant = -1, int quality = 0); the hook handed back that 3-parameter method and every custom-slot (belt) visual update threw TargetParameterCountException out of their prefix on VisEquipment.UpdateEquipmentVisuals. Seen live on the dedicated server inside FejdStartup.Start → SetSelectedProfile → SetupCharacterPreview (through AzuEPI's vanity-preview postfix); the server survived because its Start loads the main scene first. On a client whose selected character wears such an item the tail of FejdStartup.Start is 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 returns null (HarmonyX logs AccessTools.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.GetOriginalMethod for [HarmonyPatch] attributes and TargetMethod(); PatchProcessor / Harmony otherwise). Any other caller gets the bridge it named, so MethodInfo.Invoke, delegates and Traverse keep the pre-1.0 arity. Logged as typed 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 to Harmony.Patch / Harmony.Unpatch for the native it forwards to, so manual patches still fire on vanilla's calls. Logged as Harmony 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 Invoke sites (Historical Heritage, Magic Supremacy — broken by 1.3.0–1.4.0, fixed here). 19 are patch targets — attributes and TargetMethod() in those two plus DualWielder, and two manual harmony.Patch(AccessTools.DeclaredMethod(typeof(Inventory), "AddItem", …)) in Vikings Archer — and all 19 still land on the native. Nothing in the set uses Traverse, 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 Invoke throws TargetParameterCountException; 1.4.1 → it returns the 2-parameter bridge and Invoke returns normally. A manual harmony.Patch and 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 the PatchProcessor redirect.

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 + new plugins/ 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 manual Harmony.Patch on 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.UpdateBuildGuiInput transpiler fails because 1.0 deleted the scroll-wheel category cycling it suppressed (the wheel now rotates the placement ghost in Player.UpdatePlacement). Its other 21 patches apply but act on the legacy piece-selection window, which 1.0's Hud.Awake hides permanently in favour of BuildUi — 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 on Player.UpdateBuildGuiInput by 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 Cloth sail_full → MagicaCloth Karve_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 GoldOre item (Assets/GameElements/Items/materials/GoldOre.prefab, absent from 0.221.12) and a GoldOre → Gold conversion on the blast furnace. More Ore Deposits 1.3.5 registers a Jotunn CustomItem also named GoldOre. ObjectDB.UpdateRegisters builds m_itemByHash with Dictionary.Add, so the second one throws ArgumentException: An item with the same key has already been added. Key: 205000016 out of CopyOtherDB ← FejdStartup.SetupObjectDB, and FejdStartup.Start aborts. Server: registers on Steam, never loads a world, every join times out with 5003. Client: no matchmaking init, no profile selection, NullReferenceException from SteamworksMatchmaking.Tick every frame.
  • Why Jotunn's own duplicate guard does not catch it — the ordering bug that makes this fixable. Jotunn 2.30.0's PrefabManager prefix on ObjectDB.CopyOtherDB (priority 0) sets MenuObjectDB and fires OnVanillaPrefabsAvailable. The mod's ItemManager.AddItem then calls RegisterItemInObjectDB(MenuObjectDB, …), whose m_itemByHash.ContainsKey check runs against the menu ObjectDB's hash table — still empty, because the ItemManager prefix that fills it (UpdateRegistersSafe) runs later, at priority -100. The guard passes and the mod's prefab is appended to m_items beside vanilla's. UpdateRegistersSafe then 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's CopyOtherDB also aliases m_items = other.m_items now, 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 on OnVanillaPrefabsAvailable dies 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 dev branch still has the same AddItem path.

What 1.4.0 changes

  • Adapter: ObjectDBShield (new, section 4 of AdapterPlugin.cs). A Harmony prefix on ObjectDB.UpdateRegisters at Priority.Last — so it sees the list after every other prefix has touched it — walks m_items, keeps the first prefab for each GetStableHashCode of the name, removes every later one, and logs one Warning per 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 (default true), in the same BepInEx/config/Valheim10Compatibility.cfg as 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: two ConfigFile instances 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 of GoldOre, which every other peer and every reload resolve through ZNetScene and ObjectDB to 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 SmelterProduceMore prefix does stack *= 20 whenever Smelter.Spawn is called with ore == "GoldOre", in every Smelter-type station. Vanilla 1.0's blast furnace converts GoldOre → Gold (verified from the live Smelter.m_conversion data), 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) and Version.c_networkVersion, the latter by reflection because it is a const the 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), not Game server connected — that is only the Steam registration callback from the start scene, and a server whose FejdStartup.Start aborted still prints it. Added: read server-console.log as well as LogOutput.log (an exception escaping a plugin's Awake() is swallowed by Unity at AddComponent and only the console log shows it), and Opened Steam server is 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 own GoldOre prefab, so ObjectDB.UpdateRegisters throws on the duplicate hash and FejdStartup.Start aborts on client and server alike (client: the menu spams NullReferenceException from SteamworksMatchmaking.Tick and 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.GenerateLocationsCompleted with a handler that calls the pre-1.0 GetLocationList() (return type changed in 1.0 — rule 3, unbridgeable). The MissingMethodException aborts the event's multicast, so ZNet.OnGenerationFinished, the only caller of ZNet.OpenServer, never runs: the server finishes booting with no host socket, Opened Steam server never 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 its AttachArmor/Start patches; on the client that aborts the rest of FejdStartup.Start after profile selection. Remove on 1.0.
  • The TerrainOp wire format changed in 1.0, which affects any mod that builds a TerrainOp at runtime (Mists of Avalor 0.2.2's temple site, for one): TerrainOp.Settings.Serialize now writes only the prefab-name hash, the receiving owner looks it up in ObjectDB.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 of HarmonyLib.AccessTools.GetTypesFromAssembly the 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 a TypeLoadException: VTable setup on 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: Hoverable is an interface. Pre-1.0 it declared only GetHoverText() and GetHoverName(). 1.0 added float 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 implementing Hoverable without 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's Wonderland Client profile): 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): strips MethodAttributes.Abstract from 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 to Hoverable.GetHoverOffset(), body return 0f;. Every existing vanilla call site already reads the interface defensively as component?.GetHoverOffset() ?? 0f, so 0f is vanilla's own answer for "doesn't have one" — the default body changes nothing for any caller. Classes that already implement GetHoverOffset() (every 1.0-built one) are completely untouched; their own override still wins, same as any ordinary interface method.
  • New config switch: [Bridges] HoverableGetHoverOffsetDefault (default true).
  • 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 live Wonderland Client Gale 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 under patchers/ and plugins/. Mod managers create a per-package folder of that name themselves, so the old layout installed as BepInEx/plugins/Valheim10Compatibility/Valheim10Compatibility/Valheim10Compatibility.Adapter.dll. Both DLLs now sit directly in patchers/ and plugins/; the README's hand-install instructions match.
  • Dual release. package.sh folds HexiumDist/…-hexium.zip and ThunderstoreDist/…-thunderstore.zip from 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 in manifest.json's website_url. The manifest description was shortened to Thunderstore's 250-character limit.
  • Developer material (build instructions, the overload-table generator, tools/asmdiff and the diffs/ reports, the retest rig) moved out of the shipped README into DEVELOPMENT.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 defined ZRoutedRpc.Everybody as public const long Everybody = 0L; with metadata flags FieldAttributes.Literal | FieldAttributes.HasDefault. Mods compiled against earlier declarations (notably ServerSync embedded in AzuAntiCheat 4.3.11 and SeedBed 1.2.8) emit the IL instruction ldsfld int64 ZRoutedRpc::Everybody. When the Mono/CLR JIT compiler encounters an ldsfld instruction targeting a field marked Literal, it throws MissingFieldException: ... Due to: Using static instructions with literal field. Because this executed during static class construction (.cctor), both plugins crashed at chainloader startup with TypeInitializationException.

What 1.2.1 changes

  • De-literalize ZRoutedRpc.Everybody in assembly_valheim.dll: During preloader execution, the patcher strips FieldAttributes.Literal and FieldAttributes.HasDefault from ZRoutedRpc.Everybody, converting it to a standard static field (public static long Everybody = 0L;).
    • Legacy ldsfld call sites from ServerSync and 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).
  • 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_networkVersion 39 → 40 (inlined into ZNet, 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_bypassCheatChecks went from a public static field to a public static property (the getter reads the local player's bypasscheatchecks unique key, set by the new hidden console command yesiuseddevcommandsbutiwantmyachievementsanyway). 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 one ldsfld → call get_ swap and nothing else. A plugin that read the field would throw MissingFieldException at 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.IsValid no longer requires cheats for HideBehindDevCommands commands, Chat.isAllowedCommand no longer rejects them, and ShowCommand was rewritten. A command registered with hideBehindDevCommands: true is now executable without devcommands and merely hidden from the tab list. The two ConsoleCommand constructor bridges pass false for that flag — the pre-1.0 constructor had no such flag, and false was and is the old behaviour — so a pre-1.0 command is unaffected. Only a 1.0-built mod that opts into true sees the change.
  • ItemDrop.SaveToZDO(itemData, zdo, index = -1): the guard around the legacy quality/variant sync flipped from index < 0 to index > -1. This is the one change beside a bridge; see the dedicated section below.
  • ZDOMan.Load → new ConvertContainers, which loops slot index -1..13 through a reshaped private ConvertInventories(List<ZDOID>, World, int) (was (List<ZDO>, World)) and a new GetConvertHash(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 (the discover path no longer skips a requirement on an upgrader-station mismatch), TerrainComp.PaintCleared (null guard when Heightmap.FindHeightmap returns null; color2 defaults 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 new V_ and the old Steam_ id forms), Character.UpdateGroundContact (restructured m_onLand block, deep-snow landing object), Attack.DoMeleeAttack (snow clearing now needs a snow shovel), Inventory.Changed (new m_cheatedPopup, "dropped cheated item" message), CookingStation.SpawnItem (new public m_spawnFullDurability = true), Destructible.Destroy (null guard on the spawned ZNetView), Achievements.CanGetAchievements, InventoryGui.UpdateAchievementsList, ServerOptionsGUI.WorldContainsCheatedModifiers (a log line removed), and prefab-data additions on GrapplingPoint, 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, and LoadFromZDO read them back. 1.0 packs the item into one s_itemData byte array (or "{index}_itemData"), and 1.0's LoadFromZDO reads 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.UpdateVisual is the only remaining reader of the top-level s_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 (the GetInt default 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 three SaveToZDO(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 -1 into 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 bridge SaveToZDO(int, ItemData, ZDO) → (itemData, zdo, index) and for the hook's typed-lookup redirect of a SaveToZDO(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.sh now also produces the 1.0.7 → 1.0.12 reports and the 1.0.12 client-vs-server control from the preserved snapshot against libs-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, and control_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 a Vector2i prefix parameter on a Vector2s method 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. ___field injection goes through AccessTools.Field, so this is what keeps a pre-1.0 List<List<Piece>> ___m_availablePieces parameter from loading a HashSet into a List-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/refcheck gained --hook [--pre10 <dir>] to model the hook; corpus-measure.py uses 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

  • GetStableHashCode was reverted before release. The playtest's GetStableHashCode(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.
  • FindSectorObjects silently injected nothing. 1.0 collapsed (int area, int distantArea) into a SimulationDistance struct and deleted ZoneSystem.m_activeArea. The old bridge looked for a 5-parameter target; the shipped method takes 4. Now rebuilt onto new SimulationDistance(area, distantArea, classic: true) — classic: true is required, because with it false vanilla filters each ring through ZoneSystem.ZonesWithinRadius and the swept set becomes a disc rather than the square ring pre-1.0 callers assume.
  • SpawnZone and PlaceVegetation were 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 of PatchAll(), 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..ctor for both ConsoleEvent and ConsoleEventFailable (1.0 inserted bool hideBehindDevCommands at position 8), and Terminal.ConsoleEventArgs..ctor(string, Terminal) (gained a third ConsoleCommand parameter).
  • 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: ZDOMan is still new ZDOMan(512) with m_objectsBySector = new List<ZDO>[512 * 512], and ZoneSystem.SectorToIndex computes (y + 256) * 512 + (x + 256) — the same arithmetic the old m_halfWidth version did. Only the out-of-range answer differs (old -1, new index 0), and the bridge reproduces the old contract including the -1.
  • ZNetScene.InActiveArea rebuilt for the release: the parameters were reordered, not just retyped — pre-1.0 (Vector2i zone, Vector3 refPoint), 1.0.7 static (Vector3 point, Vector2s centerZone). The old first argument is a zone where 1.0 wants a world point, so the bridge converts through ZoneSystem.GetZonePos rather 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.AddStatusEffect x2, four Inventory.AddItem overloads, ItemDrop.OnCreateNew x2, ItemDrop.ItemData.GetTooltip, Heightmap.Poke(bool), TerrainComp.Save(), WorldGenerator.GetBiomeHeight, YesNoPopup..ctor, and ZInput.mousePosition in assembly_utils.

Bridges removed

  • ZDOMan.GetSaveClone() — the stub returned an empty List<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 parameterless GetPortals returning a different type, so the old bridge would have differed by return type alone: legal IL, not expressible in C#, and ambiguous for every AccessTools.Method consumer. It did not fire on 1.0.7, but the code path is gone rather than left armed.
  • ZDO.SetSector(Vector2i) — no SetSector(Vector2s) exists to forward to; 1.0 has private 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 added SetCategory(Piece.PieceCategory) beside SetCategory(int) — the exact ambiguity the patcher now guards other people against. Argument types pinned.
  • Retargeted at libs-Tools/1.0/server; the removed 37u protocol-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.csproj referenced Mono.Cecil from a Windows testbed profile (DEDICATED-SERVER-TESTBED/profile-vikingos-min/...); repointed at libs-Tools/Mono.Cecil.dll.
  • package.sh now folds the Hexium release package into HexiumDist/, matching every other Wubarrk release: manifest.json, icon.png, README.md, CHANGELOG.md and LICENSE.md at the zip root, with patchers/ and plugins/ as separate root-level trees that the mod manager maps onto the profile's own BepInEx/ folders. Keeping them separate is not cosmetic — a preloader patcher in plugins/ never executes, and a plugin in patchers/ is never chainloaded. Adds LICENSE.md (MIT, the house terms) and icon.png; the old dist/ folder and the Thunderstore-named zip are gone.
  • README.md rewritten 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 the index parameter from position 0 to position 2
  • StringExtensionMethods.GetStableHashCode(string) in assembly_utils.dll — the single highest-impact fix in this release. TargetDLLs previously listed only assembly_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 reuse this.m_visitedSectorIndices for the new HashSet<SectorIndex> parameter, matching every internal caller; a mod calling them directly outside FindSectorObjects shares 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 to ZoneSystem.SectorToIndex(Vector2s): SectorIndex but isn't one. The old method computed a raw index into ZDOMan.m_objectsBySector using m_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 and ItemStand.SetVisualItem — technically bridgeable via the same hash-conversion pattern as GetStableHashCode, but every plugin that calls the setters also reads the now-int fields 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. Pairs Vector2i/Vector2s overloads 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.txt reports for assembly_valheim, assembly_utils, and assembly_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 that ZDO.SetSector(Vector2i) is listed for visibility but is not actually bridged (1.0 has no SetSector(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.ChunkedSave is 40, not 45; the "16 new types" claim overstated top-level additions (2 added, 1 removed on the server axis; the rest are nested); Utils.PerlinNoise is DUtils.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.