You are viewing a potentially older version of this package. View all versions.
Wubarrk-Valheim10Compatibility-1.4.0 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.

Date uploaded 2 weeks ago
Version 1.4.0
Download link Wubarrk-Valheim10Compatibility-1.4.0.zip
Downloads 659
Dependency string Wubarrk-Valheim10Compatibility-1.4.0

This mod requires the following mods to function

denikson-BepInExPack_Valheim-5.4.2350 icon
denikson-BepInExPack_Valheim

BepInEx pack for Valheim. Preconfigured with the correct entry point for mods and preferred defaults for the community.

Preferred version: 5.4.2350

README

โš’๏ธ Valheim 1.0 Compatibility (MigrationStation)

The Tenth World turned over, and half the realm's mods forgot how to speak to it. These bridges teach them the new tongue.

๐Ÿฐ Wubarrk lives on Hexium โ€” don't miss out

The Wubarrk and RavenIron Studios mods are maintained on valheim.hexium.gg, and that is where their current builds live โ€” the Wubarrk mods all tagged Valheim 1.0 there. The copies under the Wubarrk name here on Thunderstore are mostly the old pre-1.0 lines, several of them deprecated, and they are not kept current. If you run any of these, get them from Hexium:

Wubarrk โ€” Njord ยท Shadows of Midgard ยท Wings of the Valkyrie ยท Fatty ยท StackIT ยท TortalPortal ยท AwayFromHome ยท Let It Grow ยท RuneboundRest ยท Blighted World Heart ยท Dvergr Allies ยท Mists of Avalor ยท VikingOS ยท Get Off My Lawn ยท Wonderland ยท The Eye

RavenIron Studios โ€” WhereTheCrowFlies ยท Valkyries Cargo ยท Cairn ยท FireFront ยท Ragnarok's Wrath ยท The Raven's Call ยท Undertow ยท RavenEye

This patcher is published in both places so a Thunderstore-only profile can still carry its pre-1.0 mods across. The Wubarrk mods above don't need it โ€” they have real 1.0 builds waiting. Questions, bug reports, the clan: Discord. ๐Ÿ—

Valheim10Compatibility is a BepInEx preloader patcher for Valheim 1.0.x+ (Deep North; built against 1.0.7, verified unchanged through the 1.0.12 hotfix โ€” see CHANGELOG.md). It rewrites the game's assemblies in memory, before a single line of game code runs, and restores the method shapes that 1.0 took away โ€” so plugins compiled against 0.221.12 and earlier load and run without being recompiled. It also hooks Harmony's own lookups, so a plugin that patches a method by name survives both the bridges and the overloads vanilla added in 1.0, and gives one interface method a default body, so a plugin class that never implemented it still passes the runtime's own VTable check. Its companion runtime plugin keeps a mod's item-name collision with new 1.0 content from aborting startup on client and server alike.

This is not a gameplay mod. It changes nothing you can see. It exists for one week of the year: the week a major Valheim release lands and the mod list you depend on has not caught up yet. ๐Ÿ›ก๏ธ


โš ๏ธ Read This First

Install it when your admin tools and utility mods break on 1.0 and their authors have not shipped 1.0 builds yet. Remove it when they have. A source fix in the mod itself is always better than a bridge โ€” it survives the next update, and it does not depend on a preloader being present and correctly ordered.

Three things it will never do:

  • ๐Ÿšซ It will not guess. If a bridge cannot carry the old meaning across, it is not injected. An unresolved reference kills one method when it is first called. A wrong answer corrupts a world quietly, at three in the morning, and nobody knows why.
  • ๐Ÿšซ It will not trade a call site for a plugin. See The One Law below โ€” this is the rule that shapes the whole tool.
  • ๐Ÿšซ It will not lie about what it did. Every bridge reports exactly one outcome in the log: Injected, present natively, SKIPPED or BLOCKED. A run against a build it does not fully match is loud, not quietly thin.

๐Ÿช“ The Ritual of Installation

  1. Install BepInExPack Valheim 5.4.2350 or newer โ€” 1.0 runs on Unity 6, and older packs die when Unity's log callbacks are stripped.
  2. Install this package from valheim.hexium.gg with your mod manager, or unzip it over your profile by hand.
  3. Start the game or dedicated server and read BepInEx/LogOutput.log.

The zip carries patchers/Valheim10Compatibility.Patcher.dll and plugins/Valheim10Compatibility.Adapter.dll at its root; your manager maps each top-level folder onto the profile's BepInEx/ folder of the same name. The two DLLs land in different folders, and neither works from the other's:

File Goes to Why
Valheim10Compatibility.Patcher.dll BepInEx/patchers/ BepInEx runs preloader patchers only from patchers/, before the game assemblies load. Dropped in plugins/ it never executes, and nothing will tell you so.
Valheim10Compatibility.Adapter.dll BepInEx/plugins/ An ordinary chainloaded plugin. Dropped in patchers/ it is never loaded.

Unpacking by hand? Copy the .dll out of patchers/ into BepInEx/patchers/ and the one out of plugins/ into BepInEx/plugins/. Do not flatten them together. ๐Ÿ“ฆ

Server and clients: install it on whichever side runs the broken mod. It touches no network protocol and changes no save format, so a patched server and an unpatched client talk to each other exactly as they did before.


โš–๏ธ The One Law

A bridge is only worth injecting if it leaves the plugin better off.

Here is the trap that makes this tool different from every naive "just add the old overload back" patcher.

When a plugin writes [HarmonyPatch(typeof(Inventory), "Changed")] with no argument types, Harmony resolves that target by name alone. Add a second method with that name โ€” exactly what a compatibility bridge does โ€” and the lookup becomes ambiguous. HarmonyX throws out of PatchAll(), and the entire plugin dies at load, every patch in it, not just that one.

That is far worse than the single unresolved call site the bridge was meant to fix.

So before injecting anything, the patcher scans your real BepInEx/plugins folder, reads every [HarmonyPatch] attribute in every Harmony plugin you have installed, and records which game methods are patched by name. Without the hook below, any bridge that would collide with one is refused outright โ€” on the live server list below that blocks 10 bridges to protect 17 plugins, including ProceduralRoads and Venture LocationReset, both of which an earlier release of this very patcher broke by injecting ZoneSystem.SpawnZone(Vector2i, ...) on top of their by-name patches. ๐Ÿฉน

That protection was right, and it was also a ceiling: EffectList.Create was wanted by 11 plugins and stayed blocked because Infinity Hammer patches it by name โ€” and still does. And vanilla itself creates the same ambiguity with no bridge in sight: 1.0 gave Inventory.Load, PieceTable.SetCategory, Attack.GetAttackEitr and fifteen other methods a second overload, so pre-1.0 plugins patching those by name die on plain 1.0.7.

๐Ÿช The Harmony resolution hook

Every Harmony lookup โ€” attribute patches, AccessTools.Method(...) in code, ___field injection โ€” goes through four AccessTools entry points. The patcher installs MonoMod detours on all four from the preloader, before any plugin loads, and resolves the ambiguity the way the plugin author meant:

  • ๐Ÿšซ A by-name lookup never returns a bridge. Every bridge is tagged [Obsolete("Valheim10Compatibility bridge ...")]; the patch lands on the native method vanilla actually calls. Bridges and by-name patches coexist.
  • ๐Ÿ•ฐ๏ธ Between two native overloads, the pre-1.0 one wins. The table was generated from a Cecil diff of the last pre-1.0 build against 1.0.7: 18 names whose overload count went from one to several with the old shape still present. For the three this list 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 merely appends defaulted parameters. The patch then fires on vanilla's own calls. Bridges that transform their arguments (Vector2i โ†’ Vector2s) are never redirected: Harmony binds patch parameters by name with no type check, and a Vector2i prefix parameter on a Vector2s method would be invalid IL.
  • ๐Ÿงพ A field lookup that finds an alias (see PieceTable.m_availablePieces below) is answered from the caller's own IL: a plugin compiled against the old shape gets the alias, everyone else the native field.

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 โ€” and if the hook cannot be installed it blocks exactly as it would without the hook:

[Info   :Valheim10Compatibility]   by-name Harmony patch on Inventory.Changed in AzuAntiArthriticCrafting.dll,
AzuAutoStore.dll, Recycle_N_Reclaim.dll, ServerCharacters.dll: the resolution hook steers it to the native
overload, so this bridge is injected anyway.
[Info   :Valheim10Compatibility] 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

What the hook does not cover, on purpose: plain Type.GetMethod / Type.GetField callers. That is the lookup that killed Wonderland under an earlier release of this patcher, which is why the next rule is untouched.

๐Ÿ” The return-type rule

A second, categorical refusal, and it is worth understanding because it costs real coverage.

A pre-1.0 call site is resolved on the full IL signature, return type included, so restoring Vector2i ZoneSystem.GetZone(Vector3) next to the native Vector2s one is both legal and exactly what those call sites need. But that pair is not expressible in C#, and reflection cannot tell them apart: Type.GetMethod(name, flags, binder, types, null) selects on name and parameter types only. The moment both exist, every plugin that reflects on that name throws AmbiguousMatchException โ€” in its own lookup, at startup, taking the whole plugin with it.

That is not theoretical. An earlier release of this patcher injected ZDO.GetSector() and ZoneSystem.GetZone(Vector3) in exactly this shape, and Wonderland 0.2.0 died in Awake() on a live 1.0.7 server before initialising a single subsystem, because its compatibility probe does that lookup for GetZone.

So the patcher refuses any bridge that would differ from an existing method by return type alone. The cost is real and measured: ZDO.GetSector() and ZoneSystem.GetZone(Vector3) call sites stay broken (Procedural Roads, on this list). The alternative was those plugins not starting at all.

Everything the guard decides is decided per install, from your plugin folder. A different mod list gets a different answer.


๐Ÿ“Š Tested Against Live Modded Server Lists

Measured against the plugin sets of two real, running modded Valheim servers โ€” not curated samples: four Azumatt inventory mods, Jotunn dependants, JereKuusela admin toolkits and fifteen-plus build-piece packs, warts and all.

Static โ€” every game reference and every Harmony target in 74 real third-party plugins, re-resolved against the patched assemblies (libs-Tools/refcheck, with the hook modelled). The server's profile was only ever read.

Plugins fully clean Unresolved game references Broken Harmony patch targets
๐Ÿ”ด Vanilla, no patcher 37 / 74 139 36
๐ŸŸข With the patcher 65 / 74 37 5

Live โ€” the same real plugin lists, booted on the real 1.0.7 Linux dedicated server, patcher out and patcher in:

Log signature ๐Ÿ”ด Vanilla ๐ŸŸข With the patcher
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 (an interface gained a method the plugin's own class never implemented) 36 lines / 14 types / 6 plugins 0
AmbiguousMatchException actually thrown 0 0

Not one plugin is worse off than with no patcher at all. โœ… The two lost patches without the patcher are Mists of Avalor's no-build protection (the bridge plus the redirect bring it back) and Advanced Terrain Modifiers' PaintCleared prefix (names three parameters 1.0 removed โ€” identical either way). 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, because its SetCategory postfix was ambiguous and the plugin applied nothing, silently. The TypeLoadException row is CoreWoodExtras, RustyBuildPieces, SeedBed, Historical Heritage, Ravenwood Random Relics and Procedural Roads, isolated with an A/B on one fixed mod list and binary (only the fix's own config switch toggled) so the count is attributable to the fix alone. Full per-plugin detail and methodology: CHANGELOG.md.


๐Ÿ“œ What the Bridges Restore

54 bridges, injected into assembly_valheim.dll and assembly_utils.dll, each tagged [Obsolete("Valheim10Compatibility bridge -> Target(...)")] so other tooling can tell a bridge from a native method by reflection, and so the hook can find the native method a typed lookup belongs on. (One of the 54 โ€” the Hoverable default interface method below โ€” isn't a forwarding bridge in the usual sense, but it is counted in the patcher's own summary line the same way.)

๐Ÿงญ The sector migration (Vector2i โ†’ Vector2s)

1.0 moved zones and sectors from 32-bit Vector2i to 16-bit Vector2s. Bridged as conversion wrappers: ZoneSystem.GetZonePos, PokeLocalZone, IsZoneGenerated, IsZoneLoaded, ZNetScene.HaveInstanceInSector.

ZDO.GetSector() and ZoneSystem.GetZone(Vector3) are not bridged, because their old and new forms differ by return type alone โ€” see the return-type rule below.

๐ŸŒ The sector sweep โ€” the one that matters most

ZDOMan.FindSectorObjects is what AwayFromHome, Let It Grow, TortalPortal, HearthBelow and Wonderland all call to find what exists near a point. 1.0 collapsed its two int distance parameters into a SimulationDistance struct and deleted ZoneSystem.m_activeArea outright. The bridge rebuilds the struct from the old arguments with classic: true โ€” that flag is not optional: with it false, vanilla filters each ring through ZoneSystem.ZonesWithinRadius and the swept set becomes a disc instead of the square ring every pre-1.0 caller is written against.

FindObjects and FindDistantObjects gained a visit-tracking HashSet. The bridges pass the game's own set and clear it first, exactly as vanilla does โ€” miss that and the second call in a session returns nothing at all, because every sector is already marked visited.

๐Ÿงฎ ZDOMan.SectorToIndex โ€” re-examined and recovered

Previously written off as unbridgeable, on the reasoning that it indexed m_objectsBySector through the removed m_halfWidth. Re-checked against 1.0.7: ZDOMan is still built as new ZDOMan(512) with m_objectsBySector = new List<ZDO>[512 * 512], and ZoneSystem.SectorToIndex computes (y + 256) * 512 + (x + 256) โ€” byte-for-byte the arithmetic the old method did. The only behavioural difference is the out-of-range answer: the old contract returned -1, the new one returns index 0, which is also a legal sector. The bridge reproduces the old contract exactly, -1 included, so callers that range-check against m_objectsBySector.Length keep working. This is what brings back resetdungeon.

๐Ÿ–ฅ๏ธ The console-command surface

The admin-tool bridge. 1.0 inserted bool hideBehindDevCommands into Terminal.ConsoleCommand's constructor and gave ConsoleEventArgs a third parameter. Every command every admin mod registers goes through these two, which is why breaking them takes whole toolkits down at once. Both constructors bridged.

๐Ÿ”€ Reordered, not just retyped

ZNetScene.InActiveArea went from (Vector2i zone, Vector3 refPoint) to static (Vector3 point, Vector2s centerZone). The first argument used to be a zone and is now a world point โ€” swapping the arguments would compile, run, and answer confidently wrong. The bridges convert properly, through ZoneSystem.GetZonePos.

โž• The 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. This is the most common breakage on the list, and the quietest:

Character.Message (43 of 87 plugins) ยท MessageHud.ShowMessage ยท SEMan.AddStatusEffect ร—2 ยท EffectList.Create (11 plugins) ยท four Inventory.AddItem overloads ยท Inventory.Changed ยท ItemDrop.SaveToZDO / LoadFromZDO / OnCreateNew ร—2 ยท ItemData.GetTooltip ยท Heightmap.Poke ยท TerrainComp.Save ยท WorldGenerator.GetBiomeHeight ยท YesNoPopup..ctor ยท Piece.SetCreator ยท Player.PlacePiece ยท ZoneSystem.SpawnLocation ยท VisEquipment.AttachArmor ยท Humanoid.IsTeleportable ยท ZInput.mousePosition

Also Inventory.IsTeleportable() ยท CensorShittyWords.FilterUGC ยท Game.SavePlayerProfile ยท CookingStation.SetSlot / GetSlot (1.0's new out bool cheated is discarded into a local) ยท SlowUpdate.SUpdate(float, Vector2i) (callvirt, so Plant and StaticPhysics overrides win) ยท and PlayerProfile.GetCharacterFolderPath(FileSource), which moved to SaveSystem and whose argument changed numbering โ€” FileHelpers.FileSource went from 0/1/2/3 to flags 1/2/4/8, so the bridge computes 1 << old before forwarding rather than answering the Cloud path for a Local request.

ZRoutedRpc.Everybody is de-literalized from a const to a plain static field, so ldsfld int64 ZRoutedRpc::Everybody instructions from ServerSync and older mods (AzuAntiCheat, SeedBed) resolve without throwing MissingFieldException.

Hoverable.GetHoverOffset() is given a default body โ€” see the default interface method below.

๐Ÿงฑ The PieceTable.m_availablePieces alias

1.0 renamed the per-category piece lists to private m_availablePiecesByCategory and reused the old name for a flat HashSet<Piece>. Fifteen build-piece mods on the list read the old field โ€” to count tabs, index them, or add a list for a custom category. The alias is a second field with the old name and type, assigned in the constructor to the same list object, so the two views cannot diverge. 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. The field hook handles AccessTools and ___field consumers; a guard scan refuses the alias outright if any installed plugin passes the literal "m_availablePieces" to Type.GetField or Traverse.Field.

โš ๏ธ The caveat: 1.0 inserted PieceCategory.DeepNorth = 5, pushing Feasts/Food/Meads to 6/7/8. A pre-1.0 mod bakes those numbers at compile time, so a piece it files under old Feasts lands in the DeepNorth tab, and a custom tab created at old Max (8) shares the Meads list. Wrong tab, not a crash. If your build menu looks wrong, turn the alias off (see Configuration).

๐ŸŽญ The default interface method

How it works. Hoverable is an interface, not a class, and 1.0 added float GetHoverOffset() to it โ€” pre-1.0 it only declared GetHoverText() and GetHoverName(). The CLR requires every interface member to have a filled VTable slot before a type can be constructed, so a pre-1.0 class implementing Hoverable without that member fails TypeLoadException: VTable setup the instant its assembly is scanned โ€” not when the missing method is called, but the moment Harmony (or anything else) reflects over that assembly at all, taking every patch in it down together.

Every vanilla call site already reads the interface defensively โ€” component?.GetHoverOffset() ?? 0f โ€” so instead of leaving the member abstract, the patcher gives Hoverable.GetHoverOffset() itself a body: return 0f;. That is a default interface method, the same feature C# 8 formalized (ECMA-335 6th edition), applied at the raw IL level with Cecil rather than through the compiler. A class that never overrides the member is no longer required to โ€” it inherits the default and passes VTable setup. A class that does implement it (every 1.0-built one) is untouched; its own override still wins, exactly as with any other interface method.

What we tested it with. The uncertainty here wasn't the IL โ€” it was whether Valheim's embedded Mono runtime (Unity 6000.0.75f1) actually honours a default interface method injected this way, since nothing in this patcher had exercised that CLR feature before. Confirmed live with a controlled A/B on a real dedicated-server plugin list (91 third-party plugins), same binary, only the config switch toggled between boots โ€” chart in Tested Against Live Modded Server Lists above, full methodology in CHANGELOG.md. Config: [Bridges] HoverableGetHoverOffsetDefault, default true.

โš ๏ธ Procedural Roads is a partial case. Its VTable crash is gone โ€” it now loads and applies its own patches โ€” but it separately calls ZoneSystem.GetZone(Vector3) expecting the pre-1.0 Vector2i return, which stays refused under the return-type rule. Better off than before, not fully fixed.

๐Ÿ•ฏ๏ธ The three patch anchors โ€” read this one

Container.RPC_OpenRespons, Container.RPC_TakeAllRespons and Minimap.OnMapRightClick were renamed or replaced by a lambda in 1.0, and plugins patch all three by name, so their absence kills those plugins outright at PatchAll().

These three bridges restore the name so the plugin loads. They do not restore the behaviour: vanilla calls the renamed method directly, so a prefix attached to an anchor never fires. The plugin works; that one feature is inert.

This is the only place in the entire patcher where a name comes back without its meaning, it is a deliberate trade of one dead feature against one dead plugin, and each anchor says so in its own log line. ๐Ÿ•ฏ๏ธ


๐Ÿšซ What Is Deliberately Not Bridged

Absence here is a decision, not an oversight.

  • Field type and shape changes โ€” ZoneSystem.m_zones and m_locationInstances (generic arguments changed), VisEquipment's eleven m_*Item slots (string โ†’ int hash), ItemStand.m_visualName, Minimap.m_explored (bool[] โ†’ BitArray), Player.m_knownBiome, Ship.m_sailCloth, VisEquipment.m_clothColliders, PlayerProfile.m_playerStats. A field's declared type cannot be patched around โ€” the one exception is PieceTable.m_availablePieces, where the old data still exists under a new name and an alias can point at it. The rest have to recompile.
  • Enum renumbering โ€” FileHelpers.FileSource (0/1/2/3 โ†’ flags 1/2/4/8) and Piece.PieceCategory (DeepNorth inserted at 5). A pre-1.0 plugin bakes the old numbers into every call site, and the methods it passes them to still exist, so nothing fails to resolve and nothing can be intercepted. Only the one method that moved (GetCharacterFolderPath) gets a remapping bridge.
  • ZoneSystem.m_activeArea / m_activeDistantArea โ€” deleted in favour of SimulationDistance, which is now player-configurable and synced from the server. A field could be injected, but it would read zero forever, and a plugin sweeping a radius of zero silently does nothing. That is worse than the load-time failure it gets now.
  • ZDO.SetSector, ZDOMan.GetSaveClone, ZDOMan.GetPortals โ€” the storage model behind all three changed (SectorIndex, chunked saves). 1.0 still has a parameterless GetPortals returning a different type, so re-adding the old one would differ by return type alone: legal IL, not expressible in C#, and ambiguous for every AccessTools.Method consumer that touches it. The previous release's GetSaveClone stub returned an empty list โ€” that is, it told any mod that asked that the world contains zero ZDOs โ€” and it is gone.
  • ZNetScene.InActiveArea(โ€ฆ, int) and OutsideActiveArea(โ€ฆ, int) โ€” both took an explicit ring radius that 1.0 removed entirely. No forwarding preserves the caller's intent.
  • ZDO.GetSector() and ZoneSystem.GetZone(Vector3) โ€” refused under the return-type rule. Bridging them breaks reflection for every plugin that looks either name up.
  • Transpilers whose IL pattern is gone โ€” a transpiler searches the target's body for a specific instruction sequence; when 1.0 rewrote that body the search fails inside the plugin (SearsCatalog's UpdateBuildGuiInput, on this list). Nothing outside the plugin can restore an IL pattern.
  • ZDOExtraData's seven Get*(ZDOID) accessors โ€” replaced by a single GetData(ZDOID, out โ€ฆ). Restoring them needs a generic-method Cecil bridge per type; only World Edit Commands uses them, and it is broken for other reasons anyway.

๐Ÿ” Reading the Log

[Info   :Valheim10Compatibility] Ambiguity guard: scanned 84 Harmony plugins under '.../BepInEx/plugins' in 1892 ms; 779 by-name patch targets recorded; 17 plugin(s) reference a pre-1.0 field shape this patcher aliases.
[Info   :Valheim10Compatibility] Harmony resolution hook installed on HarmonyX 2.9.0.0 (AccessTools.DeclaredMethod, Method, DeclaredField, Field); ...
[Info   :Valheim10Compatibility] Injected ZDOMan.FindSectorObjects(Vector2i, int, int, List<ZDO>, List<ZDO>) -> SimulationDistance(area, distantArea, classic: true)
[Info   :Valheim10Compatibility]   by-name Harmony patch on EffectList.Create in InfinityHammer.dll, MoreVanillaBuildPrefabs.dll: the resolution hook steers it to the native overload, so this bridge is injected anyway.
[Info   :Valheim10Compatibility] Injected EffectList.Create(Vector3, Quaternion, Transform, float, int)
[Warning:Valheim10Compatibility] BLOCKED ZoneSystem.GetZone(Vector3) -> Vector2i: this would differ from the existing Vector2s GetZone(...) by RETURN TYPE ALONE ...
[Info   :Valheim10Compatibility] assembly_valheim: 52 bridge(s) injected (12 of them beside a by-name Harmony patch that the resolution hook steers past them), 0 already native, 0 skipped (no target on this build), 2 blocked (0 would break a by-name Harmony patch or a reflective field lookup, 2 would be a return-type-only overload).
[Info   :Valheim10Compatibility] Harmony hook: AccessTools.DeclaredMethod(Inventory, "Changed") -> Void Inventory.Changed(Boolean, Boolean)  [1 bridge overload(s) excluded]  <- AzuAutoStore

The summary line is the one to read.

Outcome Means
Injected The bridge is in. Callers resolve.
present natively The running 1.0.x build already has this shape. Nothing to do.
SKIPPED โš ๏ธ The target shape was not found. On 1.0.7 through 1.0.12 this is zero โ€” anything else means the game changed again and this tool needs re-checking against the new build.
BLOCKED Refused on purpose: it would differ from an existing method by return type alone, a plugin reflects on the aliased field by string, or โ€” only when the hook is not installed โ€” it would break a by-name Harmony patch. The line names the plugins it protects.
by-name Harmony patch on ... Advisory: that plugin's patch now depends on the hook. If you ever turn the hook off, these become BLOCKED.
Harmony hook: ... One line per resolution the hook made, saying what was asked, what was answered, why, and by whom.

If the plugin folder cannot be found, the guard says so plainly and every overload bridge proceeds unprotected. Set VALHEIM10COMPAT_PLUGIN_PATH for nonstandard layouts. If the hook line reads NOT installed, the patcher has fallen back to refusing every colliding bridge outright, and says so.


โš™๏ธ Configuration

BepInEx/config/Valheim10Compatibility.cfg is written on first run. Five switches, all on by default; each exists so you can turn a whole mechanism off from a text file if it misbehaves on your list, without removing the patcher. The first four are the patcher's; the fifth is read by the runtime adapter from the same file:

Key Default Off means
[Harmony] ResolutionHook true Every bridge beside a by-name patch is refused, and pre-1.0 plugins patching Inventory.Load / SetCategory / GetAttackEitr by name die at PatchAll() as on plain 1.0.7.
[Bridges] PieceTableAvailablePiecesAlias true The fifteen build-piece mods lose their piece lists again; use it if the category caveat above bites.
[Bridges] ZRoutedRpcEverybodyStatic true ServerSync-based mods (AzuAntiCheat, SeedBed, ...) that read ZRoutedRpc.Everybody throw MissingFieldException again.
[Bridges] HoverableGetHoverOffsetDefault true Pre-1.0-compiled classes implementing Hoverable without GetHoverOffset() throw TypeLoadException: VTable setup again, taking their whole assembly's Harmony patches down with them.
[Shields] ObjectDBDuplicateItems true A mod whose custom item name collides with new 1.0 content (More Ore Deposits' GoldOre) aborts FejdStartup.Start again โ€” server never loads a world, client menu spams NullReferenceException โ€” exactly as on plain 1.0.

๐Ÿ›ก๏ธ The Runtime Adapter

A small companion plugin (BepInEx/plugins/) handling what a preloader cannot:

  • ๐Ÿ’พ Pre-migration world backups โ€” copies <World>.db and <World>.fwl before 1.0 converts a monolithic save into chunked format. It converts once and does not convert back.

  • ๐Ÿงฑ PieceTable category capacity shield โ€” vanilla uses fixed selection arrays sized to PieceCategory.Max and throws IndexOutOfRangeException when custom categories push past it. The adapter resizes them, reading m_availablePiecesByCategory directly.

  • ๐Ÿ–ผ๏ธ Hud.UpdatePieceList finalizer โ€” keeps one outdated build-menu mod from taking the HUD down with it.

  • ๐Ÿ“ฆ ObjectDB content-collision shield โ€” ObjectDB.UpdateRegisters builds its item table with Dictionary.Add, so two item prefabs sharing a name throw out of FejdStartup.SetupObjectDB and startup aborts on client and server alike. Vanilla's own list is unique; the duplicate comes from a mod registering an item under a name 1.0 now uses itself, and Jotunn lets it through (its AddItem checks the menu ObjectDB's hash table before the Jotunn hook that fills it has run). The adapter drops the later duplicate before vanilla's loop โ€” the first registration wins, which is vanilla's whenever vanilla owns the name โ€” and logs which item, which mod token, and why. It only ever acts where vanilla would have thrown.

    Measured on the 91-plugin dedicated-server list with More Ore Deposits 1.3.5 enabled, same binary, only [Shields] ObjectDBDuplicateItems toggled: off โ€” ArgumentException: An item with the same key has already been added. Key: 205000016 at SetupObjectDB, no world ever loads; on โ€” the world scene comes up, the shield fires exactly once (GoldOre: kept vanilla's $item_goldore, dropped the mod's $GoldOre_warp), Jotunn injects the mod's five deposits, and the only [Error] lines are the same two every release has had. The shield keeps the game up; it does not make the colliding mod right for 1.0. More Ore Deposits' gold deposits then drop vanilla's Deep North gold ore, and its Smelter.Spawn ร—20 prefix applies to vanilla's blast furnace (GoldOre โ†’ Gold) too. Read the warning line, then ask that mod's author for a 1.0 build.


๐Ÿ“š Changelog

Every release, what it changed and what was measured, newest first: CHANGELOG.md, shipped alongside this file.

โš–๏ธ License

MIT โ€” see LICENSE.md. The preloader modifies Valheim's assemblies in memory only; no game file on disk is altered, and none is redistributed here.


Forged in the Wubarrk halls. Every Wubarrk mod lives on valheim.hexium.gg.

May your mod list survive the update. ๐Ÿ—

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.