Please disclose if any significant portion of your mod was created using AI tools by adding the 'AI Generated' category. Failing to do so may result in the mod being removed from Thunderstore.
ValheimTune
Server-side performance patches for Valheim dedicated servers. Object sync 4.1ms -> 0.09ms per call on a 700k-object world. Players install nothing, wire format untouched. Valheim 1.0 ready.
| Date uploaded | 2 weeks ago |
| Version | 0.7.4 |
| Download link | Akoozie-ValheimTune-0.7.4.zip |
| Downloads | 901 |
| Dependency string | Akoozie-ValheimTune-0.7.4 |
This mod requires the following mods to function
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.2350README
ValheimTune
✅ Valheim 1.0.15 ready
0.7.4 runs on game 1.0.15 (network version 40), and still on 1.0.14 and 1.0.12 (40) and 1.0.7 (39). It is a compatibility rebuild: every method the plugin patches is byte-identical across all four builds, but 0.7.4 has not been booted on 1.0.15 — read the CHANGELOG before you deploy it. Upgrading from 0.7.3 needs no config edit. Running game 0.221.12? Use 0.6.0 instead — the version gate will refuse to apply these patches to an older build.
On Thunderstore:
Akoozie-ValheimTune
Makes a Valheim dedicated server with a big base and a handful of players feel like a small one.
Server-side only — players install nothing. Every change is on the server, the wire format is untouched, and vanilla clients connect exactly as before.
game 1.0.15, 1.0.14 and 1.0.12 (network version 40), 1.0.7 (39), dedicated server only
needs BepInEx 5.4.x
status 0.7.4 is a compatibility rebuild for 1.0.15. Every method this
plugin patches is byte-identical across 1.0.7 to 1.0.15
(decompile diff), it compiles against the 1.0.15 assemblies and
its unit tests pass - but it has NOT been booted on 1.0.15. 0.7.0
WAS verified live on 1.0.7, 2026-09-09: 698,000 objects, a
12,000-instance base, 2-6 players.
Why
Measured on that server, not modelled:
| Vanilla | ValheimTune | |
|---|---|---|
| Sync scan, per player per round | 4.1 ms | 0.07 ms |
| Autosave | 381 ms freeze in one frame | 6 ms slices over ~350 frames |
| Join sync cost | ~11 ms per call | ~5.5 ms |
| Join stream rate | 1,261 objects/s | 3,617 objects/s |
| Server frame rate | 30 fps, hard-capped | 60 fps |
| Relayed updates, 2 players at a base | baseline | ~55 % fewer |
Every knob defaults to vanilla except the ones proven live. What each number comes from is in How it works.
Requirements
- Valheim dedicated server (Steam app 896660). Not the in-client host.
- BepInEx 5.4.x for Valheim (BepInExPack_Valheim).
- Game version listed in
[Compat] KnownGoodBuilds(currently1.0.7, 1.0.12, 1.0.14, 1.0.15). On any other version the plugin runs in vanilla + measurement mode and says so in the log.
Install
On Thunderstore —
Akoozie-ValheimTune. Note that mod managers install to a client profile; for
a dedicated server you still need the DLL on the server, so the manual
steps below are the normal route.
- Install BepInEx on the server. With the
lloesche/valheim-serverDocker image that isBEPINEX=trueinserver.env. - Drop
ValheimTune.dllintoBepInEx/plugins/(Docker:config/bepinex/plugins/). - Restart. The config file appears at
BepInEx/config/akoozie.valheimtune.cfg. - Check the log for:
[ValheimTune] 0.7.4 loaded on game 1.0.15 (net 40), 11 methods patched, replacements on
[ValheimTune] SendZDOs window 10240/2048, 3 constants replaced (expected 3)
Recommended settings
What runs on the reference server. Apply one at a time and read the stats line between changes. Most knobs take effect within 5 seconds without a restart.
[Server]
TargetFrameRate = 60
[Sync]
SendWindowBytes = 32768
MinHeadroomBytes = 4096
AllPeersPerRound = true
RelayMinIntervalMs = 200
[Steam]
SendRateMaxBytesPerSec = 1048576
DirtySets and TopKSort are already on by default.
Upgrading from an earlier release
Drop the new DLL in and restart - no config edit needed. A new plugin
version never rewrites an existing akoozie.valheimtune.cfg (BepInEx only
writes a default when the key is absent), so your file still says
KnownGoodBuilds = 1.0.7. As of 0.7.1 that no longer costs you anything: the
list shipped in the release is treated as a floor, and your config can only
add to it. You will see this once on boot:
[ValheimTune] game 1.0.15 is not in your KnownGoodBuilds (1.0.7) but ships in
this release (1.0.7, 1.0.12, 1.0.14, 1.0.15); using the shipped list. Your config is
from an older version.
Tidy the line up if you like; nothing depends on it.
If the log says replacements OFF
[ValheimTune] game 1.0.16 not in KnownGoodBuilds (1.0.7, 1.0.12, 1.0.14, 1.0.15):
replacement patches inactive, running vanilla + measurement
Your server updated to a game build this plugin has not been verified against. Nothing is broken — the version gate did its job and refused to run old patch logic against new code. But the plugin is now only printing the stats line; none of the optimizations are running. The log line tells you your exact game version, which is what the gate compares against.
You have three options.
1. Wait for a release that lists your version. The safe one. Check the releases page; each one names the game build it was verified against. Meanwhile the stats line still works, so you keep the diagnostics.
2. Force it on and accept the risk. Add your version to the list:
[Compat]
KnownGoodBuilds = 1.0.7, 1.0.12, 1.0.14, 1.0.15, 1.0.16
Restart. Harmony will refuse to patch any method whose signature changed and log it, and the constant-swap transpiler self-aborts unless it matches exactly the three constants it expects — so a shape change fails loudly rather than silently. What it cannot catch is a method whose shape is unchanged but whose semantics moved.
Back up your world first. Every patch here affects performance only — none of them writes your world file — but a game update can move ground under any of them, and a backup costs nothing.
3. Build it yourself against the new server assemblies. See Building from source. If it works, please open an issue saying which game build — that is what gets it into the next release.
Reports welcome either way. DisableOnUnknownBuild = false is the blunt version
of option 2; it forces every replacement patch on for any version, and carries
the same caveat with none of the record of what you tested.
Reading the stats line
Every LogIntervalSeconds, prefixed [ValheimTune]:
frame avg 16.7 max 17.0 ms (60 fps) | syncList avg 0.07 max 0.20 ms | send avg 0.10 ms
| Z max 11418 | peer-sends 400 | zdos/s sent 430 recv 1000 | peers 2
| marks 15750 full 0 dirtyRounds 399 deferred 8300 drained 12 | meshSkips 0
recv by prefab (7848 in window): Fish1=1833 Fish2=1315 ...
hot objects: Wood=69@(-327,-631) ...
What each field means
| Field | Meaning | Healthy |
|---|---|---|
| frame | main-thread frame time | avg at your target, max under ~35 ms except during a join |
| syncList | time per candidate search, per player per round | ~0.1 ms steady, a few ms during a join |
| Z max | largest candidate set seen in a full scan | scales with your base |
| peer-sends | send calls in the window (rounds x players) | ~200 per player per 10 s with AllPeersPerRound |
| zdos/s sent / recv | last-second counters | recv is what your players' clients push; sent is the relay |
| marks | change-hook hits in the window | non-zero with players on; 0 means the hook is dead and the watchdog will fall back |
| full / dirtyRounds | full scans vs dirty-set rounds | full ~0, one per ReconcileSeconds per player |
| deferred | relays held back by the throttle | large is good |
| drained | candidates from the last dirty round | |
| meshSkips | render-mesh rebuilds skipped by SkipRenderMesh |
climbs while players explore new ground, 0 elsewhere |
| DISABLED | appended if the watchdog tripped | should never appear |
| recv by prefab | which prefabs your players are pushing | tells you what to clean up |
| hot objects | per-object counts with world x,z | find the log that never stops rolling |
Full config reference — every knob, default, and when it takes effect
runtime = re-read every ConfigReloadSeconds without a restart.
patch-time = read once when the plugin loads; restart to change.
| Key | Default | When | What |
|---|---|---|---|
[Measure] LogIntervalSeconds |
10 | runtime | Stats line cadence. 0 disables. |
[Measure] ConfigReloadSeconds |
5 | runtime | How often the cfg is re-read. |
[Measure] HotObjectsIgnore |
Player,Fish1,Fish2,Fish3 | runtime | Prefabs left out of the hot-objects line. |
[Server] DeferAssetUnload |
false | runtime | Hold vanilla's hourly UnloadUnusedAssets (443-607 ms of main-thread stall, measured) until no players are connected. Deferred, not skipped. |
[Server] AssetUnloadMaxDeferMinutes |
240 | runtime | Backstop: collect anyway once a deferral has been held this long, so a server that never empties still collects. |
[Server] SkipRenderMesh |
false | runtime | Skip the heightmap render-mesh rebuild on a dedicated server; it is built for every zone a player explores and never drawn. Collision mesh untouched. |
[Server] TargetFrameRate |
0 | runtime | Override the server's hard-coded 30 fps. 0 = leave it. Sync round period is 0.05 s + players / fps. |
[Sync] SendWindowBytes |
10240 | patch-time | Bytes in flight per player before the server stops queueing. Vanilla 10240. |
[Sync] MinHeadroomBytes |
2048 | patch-time | Below this much free window the player is skipped this round. Keep below the window. |
[Sync] OverrideSendWindow |
true | patch-time | Set false if another networking mod changes the send queue size; SendZDOs is then left untouched. At vanilla window values nothing is changed either way. |
[Sync] AllPeersPerRound |
false | runtime | Serve every player each round instead of one per frame. Pays off at 4+ players. |
[Sync] RoundSeconds |
0.05 | runtime | Round period when AllPeersPerRound is on. |
[Sync] DirtySets |
true | runtime | Only consider changed objects each round. A watchdog falls back to vanilla if the change hook ever goes silent. |
[Sync] ReconcileSeconds |
30 | per player at connect | Safety-net full scan interval. |
[Sync] RelayMinIntervalMs |
0 | runtime | Re-send a non-prioritised object to the same player at most this often. 0 = vanilla. 200 is the tested value. |
[Sync] TopKSort |
true | runtime | Bounded-heap selection instead of a full sort of every candidate. |
[Sync] TopK |
0 | runtime | Candidates ordered per round. 0 = SendWindowBytes / 64, never below 64. |
[Steam] SendRateMaxBytesPerSec |
153600 | patch-time | Steam per-connection send cap. Vanilla 150 KB/s. Do not exceed your upload divided by player count. |
[Steam] SendRateMinBytesPerSec |
153600 | patch-time | Leave at vanilla so Steam's estimator can back off on a lossy link. |
[Steam] OverrideSendRate |
true | patch-time | Set false if another networking mod manages Steam send rates; ValheimTune then never writes them. A rate left at vanilla is never written either way. |
[Receive] MaxPacketsPerPeerPerFrame |
0 | runtime | Stop draining one player's socket after this many packets in a frame. 0 = vanilla. Try 64 if one player's burst ever stalls the rest. |
[Cleanup] FloatingDropsRun |
false | one-shot | Set true to scan for item drops and felled logs floating in water. Resets itself. Dry run unless the next key is true. ~50 ms main-thread stall on a 698k-ZDO world. |
[Cleanup] FloatingDropsDelete |
false | runtime | With Run: delete what the scan finds. Hourly backups first. |
[Compat] KnownGoodBuilds |
1.0.7, 1.0.12, 1.0.14, 1.0.15 | patch-time | Game versions this plugin build was verified against. Comma-separated. |
[Compat] DisableOnUnknownBuild |
true | patch-time | On an unlisted version, run only measurement, the send-rate cap and the constant swap. |
How it works
Each row is one measured problem and the patch that answers it.
Problem by problem
| Problem in vanilla | What ValheimTune does | Measured on a 690k-object world |
|---|---|---|
| Server is hard-capped at 30 fps, so every sync round waits for a slow frame | TargetFrameRate knob |
30 -> 60 fps, ~3 % CPU |
| Every sync round rescans every object near every player | Dirty sets: only objects that changed since the last round are considered; full scans only on join, zone change and every 30 s | 4.1 ms -> 0.07 ms per player per round |
| 10 KB send window and a hidden 150 KB/s per-connection Steam cap | Both raised, all players served every 50 ms instead of one per frame | Join streams 2.8x faster |
| Every update from every player is relayed to every other nearby player, ~17 times a second for anything that moves | Relay throttle: non-prioritised objects (fish, drifting items, pieces) re-sent to a given player at most every 200 ms; players and creatures exempt | ~55 % fewer relays with two players at the base |
| Autosave clones the whole world on the main thread, then a writer thread reads memory the game keeps changing (a torn-save race) | Sliced save: the world is serialised on the main thread in 6 ms slices into a buffer; the writer thread only writes | 381 ms freeze in one frame -> 6 ms slices over ~350 frames |
| During a join, every candidate object is fully sorted each round to pick the ~300 that fit | Top-K selection: a bounded heap keeps the best 512, no allocation | Join sync cost ~11 -> ~5.5 ms per call; closes a vanilla field-table leak on the way |
| A game update silently runs old patch logic on new code | Version gate: replacement patches only run on a build listed in the config; anything else logs a warning and runs vanilla plus measurement | Tested both ways on the live server |
| Hundreds of item drops and felled logs floating in water forever, each one a sync every round | One-shot scan and optional delete | 1,446 objects removed; idle inbound traffic 800 -> ~650 updates/s |
The 11 patched methods
Harmony patches on 11 methods of the dedicated-server assembly, all in
Patches/:
| Method | Patch | Purpose |
|---|---|---|
ZDOMan.CreateSyncList |
prefix + postfix | Dirty sets, relay throttle; timing |
ZDO.DataRevision / OwnerRevision setters |
postfix | Mark changed objects |
ZDOMan.ServerSortSendZDOS |
prefix | Top-K selection |
ZDOMan.SendZDOs |
transpiler + prefix/postfix | Window constants; timing |
ZDOMan.SendZDOToPeers2 |
prefix | All players per round |
ZRpc.Update |
prefix | Receive cap |
ZSteamSocket.RegisterGlobalCallbacks |
postfix | Steam send rate |
ZDO.Deserialize |
postfix | Per-prefab tally |
Heightmap.RebuildRenderMesh |
prefix | Skip the render mesh on a headless server |
Game.CollectResources |
prefix | Defer the hourly asset unload until the server is empty |
The save path is no longer patched. Valheim 1.0 writes one file per chunk and clones only dirty chunks, which supersedes the sliced save this plugin used to apply on 0.221.12; measured idle on a 698k-object world, an incremental autosave writes in 3 ms against 2,713 ms for a full one.
Every patch that replaces game logic checks the version gate first and runs vanilla when it is off. Measurement, the send-rate cap and the constant swap run regardless. The constant swap refuses to apply unless it matches exactly the three constants it expects.
Pure logic (dirty-set state, the watchdog rule, the slicer, the top-K heap,
the version check) lives outside Patches/ and has unit tests that run
without the game.
Deliberate limits
- The relay throttle can delay a repeated update of a fish or a rolling log by up to 200 ms. First updates ship on the next round.
- Nothing here changes what a client is asked to render or simulate. Render distance, client-side rate limits and reliable/unreliable lanes would need a client mod and are out of scope.
Building from source
Needs the .NET 8 SDK and the dedicated server's managed assemblies.
# point the build at your server's Managed folder (or copy the DLLs somewhere)
export GameManaged=/path/to/valheim_server_Data/Managed
dotnet build ValheimTune/ValheimTune.csproj -c Release
dotnet test ValheimTune.sln
Output: ValheimTune/bin/Release/netstandard2.1/ValheimTune.dll. Private game
members are publicised at compile time by BepInEx.AssemblyPublicizer.MSBuild;
BepInEx.Core and HarmonyX come from the BepInEx NuGet feed (nuget.config).
deploy.sh is the reference server's build-copy-restart loop; set HOST and
adapt the docker lines to your setup.
When the game updates
See docs/PORTING.md. Short version: the plugin notices,
switches its replacement patches off, and logs it. Players play vanilla plus
the stats line until a rebuild adds the new version to KnownGoodBuilds.
License
MIT. Not affiliated with Iron Gate or Coffee Stain.
Reports from other servers welcome — open an issue with your stats line.
CHANGELOG
Changelog
0.7.9 targets dedicated-server build 25730807 (game 1.0.17, network version 40) and remains valid for 1.0.16, 1.0.15, 1.0.14, 1.0.12 and 1.0.7. 0.7.5 to 0.7.8 target build 25527701 (game 1.0.16, network version 40). 0.7.4 targets build 25390671 (game 1.0.15, network version 40). 0.7.3 targets build 25364309 (game 1.0.14, network version 40). 0.7.1 targets build 25253791 (game 1.0.12, network version 40). 0.7.0 targets build 25185644 (game 1.0.7, network version 39). 0.6.0 and earlier target build 21981590 (game 0.221.12, network version 36).
0.7.9 - 2026-10-06
Compatibility rebuild for Valheim 1.0.17 (dedicated-server build 25730807). No behaviour change; the code is 0.7.8's.
Why you want it: on 1.0.17, 0.7.8 does not list the build in
KnownGoodBuilds, so with the default DisableOnUnknownBuild = true every
replacement patch and every [Fixes] fix falls back to vanilla and only the
stats line keeps running. 0.7.9 adds 1.0.17 to the shipped list. No config
edit needed.
Network version is still 40, so 1.0.17 locks no one out.
Not verified live. A decompile diff of 1.0.17 against 1.0.16 changes 6
files: Inventory.cs, ItemDrop.cs, ServerJoinDataUtils.cs,
VariantDialog.cs, Version.cs and one line of ZDOMan.cs. That line is in
AddObjectsPerChunk, which GetSaveClonePerChunk calls: the incremental save
now also writes chunks missing from the chunk save mapping, not only dirty
ones. The plugin does not patch that method. SpawnerLinkFix reads the
chunk list it returns, so it simply sees more chunks written; SaveDirtyFix
is still needed, because a player edit in a chunk that is already mapped is
still skipped unless something marks it dirty. Every other file the plugin
patches is unchanged.
0.7.8 - 2026-10-01
Documentation only; the plugin code is identical to 0.7.7.
- The README's links to the changelog, the porting notes and older releases were relative, so on Thunderstore and in mod managers they pointed nowhere (#4). They are absolute now, and the docs check rejects a relative link.
- The Thunderstore package now includes this changelog, shown on its Changelog tab.
0.7.7 - 2026-09-30
Vanilla bug fixes and review fixes. No config edit needed; every new fix is on by
default and has its own switch under [Fixes]. Found by a full review of the
plugin and a first read of the server code it had never looked at; the complete
record, including what was checked and found clean, is in the analysis repo's
docs/AUDIT-2026-09-30.md.
Vanilla bugs fixed (any 1.0 server has these):
- Player edits skipped by the incremental save (
SaveDirtyFix). Valheim 1.0 saves only chunks marked dirty, and only changes the server makes mark them. Updates arriving from players - building, chests, signs - never do. An area only players touched since the last save can be skipped, and it reverts on restart while the character keeps the items. It usually hides behind incidental marks (ownership changes, objects crossing zones). - Spawners duplicating creatures after a restart (
SpawnerLinkFix). Every save gives spawner/creature links new hashes, but an unsaved chunk keeps the old ones; a link split across two chunks no longer matches at load and the spawner spawns another creature. Both sides are now saved together. The load log lineRemoved connection from N orphan spawn:sshows the vanilla bug; on our own vanilla world it read 0 at every restart for a month, so it is rare. - 100 ms server freeze on every disconnect (
DisconnectNoSleep, restart to apply).ZSteamSocket.Closesleeps the main thread for 100 ms - for every player leaving, timing out or being refused at join. Removed; the connection closes with Steam's linger so queued messages still go out. - Destroyed-object records kept until restart (
DeadZdoPrune): pruned to the last hour at each save. - Repeat global-key broadcasts (
GlobalKeyDedupe): vanilla compares keys case-sensitively, so e.g. every unshielded ship in the Ashlands ocean re-broadcasts every global key to every player every 10 s.
Fixed in the plugin:
- Dirty sets could hold back distant objects (the far ring) until a player came near, whenever a full scan found 10 or more nearby objects to send - most of the time while travelling.
- Changing Simulation Distance in the graphics settings now triggers a full scan; newly in-range objects no longer wait up to 30 s.
SendWindowBytesmax is now 262144. Above ~450 KB a single package exceeds Steam's 512 KB limit and the player times out. Existing configs above the new max are clamped on load.MinHeadroomBytes >= SendWindowBytesnow leavesSendZDOsvanilla instead of applying a window that sends nothing.- Turning
DeferAssetUnloadoff while a collection was held no longer causes an immediate unload stall when it is turned back on later.
New on the stats line: release max / removePeer max (ms), gc /
heap, dead, saveMarks, linkFixes, keyDedupes. The first two measure
two suspected stalls (the ownership pass every 2 s, and the disconnect cleanup
that walks every object) before anything is changed there.
The log now reads 18 methods patched.
Not verified live. 53/53 unit tests; the disconnect transpiler was checked
against the real 1.0.16 IL of ZSteamSocket.Close. Nobody has booted 0.7.7.
0.7.6 - 2026-09-30
Bug fix. No config change, no new setting.
Why you want it: with dirty sets on (the default), an object the server
creates never reached players until the next safety-net full scan, up to
ReconcileSeconds (30 s) later. Server-side mods that recreate objects hit
this every time: ServersideQoL PrefabConfigurator destroys and recreates
building pieces to apply its changes, and with 0.7.5 each piece vanished for
15-20 s instead of a fraction of a second
(#3).
Cause: dirty sets learn about changes from the DataRevision /
OwnerRevision setters. A ZDO built with ZDOMan.CreateNewZDO and filled with
Deserialize or SetOwnerInternal touches neither. Objects players place were
never affected - they arrive through RPC_ZDOData, which sets the revision.
Fix: a postfix on ZDOMan.CreateNewZDO marks every new object for every
player. All creation paths go through that one method. The log now reads
12 methods patched, and the stats line's marks count now includes object
creations, so it runs higher during building or zone generation. Workaround on
0.7.5: [Sync] DirtySets = false.
Not verified live. Unit tests pass (47/47); nobody has booted 0.7.6.
0.7.5 - 2026-09-25
Compatibility rebuild for Valheim 1.0.16 (dedicated-server build 25527701). No behaviour change.
Why you want it: on 1.0.16, 0.7.4 does not list the build in
KnownGoodBuilds, so with the default DisableOnUnknownBuild = true every
replacement patch falls back to vanilla and only the stats line keeps running.
0.7.5 adds 1.0.16 to the shipped list. No config edit needed.
Network version is still 40, so 1.0.16 locks no one out.
Not verified live. Same caveat as 0.7.1 to 0.7.4. A decompile diff of 1.0.16
against 1.0.15 changes 19 files, all gameplay or client side: SaveSystem.cs
(cloud-only mount on backup restore), SpawnSystem.cs (spawn-hash counter fix),
TerrainComp.cs (duplicate handling again), Player.cs, Achievements.cs,
ObjectDB.cs, PlayerProfile.cs (food-achievement exclusions), a handful of
UI and gamepad files, and Version.cs. Every file this plugin patches -
ZDOMan.cs, ZRpc.cs, ZSteamSocket.cs, ZDO.cs, Game.cs, Heightmap.cs,
ZoneSystem.cs - is unchanged.
0.7.4 - 2026-09-19
Compatibility rebuild for Valheim 1.0.15 (dedicated-server build 25390671). No behaviour change.
Why you want it: on 1.0.15, 0.7.3 does not list the build in
KnownGoodBuilds, so with the default DisableOnUnknownBuild = true every
replacement patch falls back to vanilla and only the stats line keeps running.
0.7.4 adds 1.0.15 to the shipped list. No config edit needed.
Network version is still 40, so 1.0.15 locks no one out.
Not verified live. Same caveat as 0.7.1 to 0.7.3. A decompile diff of 1.0.15
against 1.0.14 changes six files: TerrainComp.cs (the duplicated-terrain fix),
Inventory.cs, InventoryGrid.cs, InventoryGui.cs, ItemDrop.cs (the
cheated-item fix), and Version.cs. Every file this plugin patches - ZDOMan.cs,
ZRpc.cs, ZSteamSocket.cs, ZDO.cs, Game.cs, Heightmap.cs, ZoneSystem.cs -
is unchanged.
0.7.3 - 2026-09-17
Compatibility rebuild for Valheim 1.0.14 (dedicated-server build 25364309). No behaviour change.
Network version is still 40, the same as 1.0.12, so unlike the 1.0.12 patch this one does not lock older servers out. A 1.0.12 server keeps accepting 1.0.14 clients; update when it suits you.
Not verified live. Same caveat as 0.7.1 and 0.7.2. What it rests on: a
decompile diff of the 1.0.14 dedicated-server assembly against 1.0.12 shows
every file this plugin patches is unchanged outright - ZDOMan.cs, ZRpc.cs,
ZSteamSocket.cs, ZDO.cs, Game.cs, Heightmap.cs and ZoneSystem.cs all
diff to zero lines. The anchors are at identical line numbers: CreateSyncList
(1261), ServerSortSendZDOS (1360), SendZDOToPeers2 (886), the SendZDOs
window constants 10240/10240/2048 (1060/1064/1065), and ZDO.DataRevision /
OwnerRevision still auto-properties. 1.0.14 is a broad patch - 32 source files
changed, in combat, inventory, UI and settings - but none of it is in the sync
or networking path. The only networking-adjacent edit is ZNet.Save, which
1.0.14 makes save the player profile on a client-issued save; this plugin does
not patch it.
It compiles against the 1.0.14 assemblies and 47/47 unit tests pass. What stays
unproven is IL-level and a source diff cannot settle it: that Harmony attaches
to all 11 methods, and that the constant-swap transpiler still finds exactly 3
constants. Both show in the first two log lines on boot - if the load line does
not say 11 methods patched or the window line does not say
3 constants replaced (expected 3), set [Compat] DisableOnUnknownBuild = true
and report it.
[Compat] KnownGoodBuildsshipped list is now1.0.7, 1.0.12, 1.0.14. It is a floor, so an existing config keeps its patches without an edit.
0.7.2 - 2026-09-13
Plays nicely with other networking mods (#1).
- Vanilla now means hands off. The Steam send-rate postfix used to write both
rates on every boot, even at the vanilla 153600, which could reset a rate another
networking mod had just set. Each rate is now only written when it differs from
vanilla. Likewise the
SendZDOswindow transpiler leaves the method untouched whileSendWindowBytesandMinHeadroomBytesare both vanilla. No change for anyone who tuned these values. - New
[Steam] OverrideSendRateand[Sync] OverrideSendWindow(defaulttrue). Set either tofalseto stop ValheimTune touching that setting at all, whatever the values say. Patch-time: restart to apply. The load log says which one left the game alone and why. - Four new tests (47 total).
Not verified live. Same caveat as 0.7.1.
0.7.1 - 2026-09-11
Compatibility rebuild for Valheim 1.0.12 (build 25253791, network version 40). No behaviour change.
Not verified live. 0.7.0 was booted on a real server with a player online;
0.7.1 has not been. What it does rest on: a decompile diff of the 1.0.12
dedicated-server assembly shows every method this plugin patches is
byte-identical to 1.0.7, at identical line numbers - CreateSyncList (1261),
ServerSortSendZDOS (1360), the SendZDOs window constants 10240/10240/2048
(1060/1064/1065), and ZDO.DataRevision / OwnerRevision still auto-properties.
ZRpc.cs, ZSteamSocket.cs, ZDO.cs, Game.cs and Heightmap.cs are
unchanged outright. The only ZDOMan edits in 1.0.12 are in
ConvertInventories / ConvertContainers, the world-migration path, which this
plugin does not touch. It compiles against the 1.0.12 assemblies and passes
40/40 unit tests. The gap that leaves: nobody has confirmed Harmony attaches at
runtime on 1.0.12, or that the constant-swap transpiler still finds exactly 3
constants. Read the load line on first boot and treat a
constants replaced (expected 3) mismatch as a reason to set
DisableOnUnknownBuild = true and report it.
- The shipped version list is now a floor, not a default. BepInEx keeps an
existing config on upgrade, so before this change every 0.7.0 install would
have kept
KnownGoodBuilds = 1.0.7and silently dropped every patch the moment it reached 1.0.12 - an upgrade that quietly does nothing. The gate now passes if the build is in the user's list or inCompat.DefaultKnownGoodBuilds(1.0.7, 1.0.12), and logs once when the shipped list is what allowed it. Configs can still add builds; narrowing the list was never a documented way to disable anything - that isDisableOnUnknownBuildand the per-feature knobs. - Builds against
tools/server-managed-1012/;src_server_1012/added. - Five new tests (43 total, was 38).
0.7.0 — 2026-09-09
Port to Valheim 1.0.7, deployed and verified live on 2026-09-09 with a player
online: 11 methods patched, replacements on, 3 constants replaced (expected 3), sync cost 0.09 ms/call against 4.1 ms vanilla, frame 16.7 avg /
17.0 max, no errors. B4a's meshSkips fired for the first time (34), 1.0 zone
generation finally giving it virgin terrain. G1 verified at 18:54 - the
hour boundary landed with a player on and the unload was deferred, frames
staying 16.9-24.6 ms with none of the 443-607 ms stall seen on 0.221.12.
Every feature in the port is verified live. See docs/PORTING-1.0.md for the
full survey and the live numbers.
- S1 sliced save removed. 1.0 rewrote the save path:
ZDOMan.SaveAsyncis gone, replaced bySaveChunks/SaveChunk/SaveCleanupwriting one file per chunk, andGetSaveClonePerChunkclones only dirty chunks. That is a strictly better answer to the freeze S1 was working around, so the feature is deleted rather than ported:SavePatches.cs,SaveSlicer.cs,SaveSlicerTests.cs, and the[Save] SlicedSave/[Save] SaveSliceMsknobs. Vanilla 1.0 autosave cost on a 698k-ZDO world is still unmeasured. - B1 dirty sets ported to per-peer simulation distance. 1.0 removed
ZoneSystem.m_activeArea/m_activeDistantArea; the sync radius is now a per-peerSimulationDistancenegotiated inZNet.RPC_RequestValidSimulationDistanceand clamped server-side tomin(client request, server cap).DirtyPatchesnow mirrors the ring predicate inZDOMan.FindSectorObjects(Chebyshev ring andZonesWithinRadius, except in classic mode) instead of calling the removedZNetScene.InActiveArea(sector, zone, radius). Zone indices areVector2s, notVector2i. NewZoneRingTestscover the ring maths. Version.m_networkVersionrenamed toVersion.c_networkVersion.[Compat] KnownGoodBuildsdefault 0.221.12 -> 1.0.7.- Unchanged and rebuilt as-is: B2 top-K, R2 relay throttle, the
SendZDOsconstant swap, the receive cap, the Steam send rate,TargetFrameRate, G1 deferred asset unload, B4a skip render mesh, the watchdog and the stats line.ZRpc.csandZSteamSocket.csare byte-identical between 0.221.12 and 1.0.7;ServerSortSendZDOSandSendZDOToPeers2have identical bodies. - 38 unit tests.
0.6.0 — 2026-09-07
- G1 deferred asset unload (
[Server] DeferAssetUnload, default off): vanilla runsResources.UnloadUnusedAssets()every hour (Game.cs:239InvokeRepeating, the 3599 s check atGame.cs:299). Measured on GalinBalin at 443-607 ms of main-thread stall, and the cost is independent of what it frees - Unity'sMarkObjectswalks all ~207,000 loaded objects to release one asset. Caught live at 21:04:26 with two players online (frame max 512 ms) and at 22:40:23 idle (frame max 457 ms, matching Unity's own 455.78 ms line). Both vanilla entry points route throughGame.CollectResources, so one prefix covers them. The collection is deferred, not skipped: it runs the moment the last player disconnects, or afterAssetUnloadMaxDeferMinutes(default 240) if the server never empties. Found by reading SmoothServer's module list; the deferral policy is ours. - 33 unit tests.
0.5.0 — 2026-09-07
- B4a skip render mesh (
[Server] SkipRenderMesh, default off): prefix onHeightmap.RebuildRenderMeshthat returns false on a dedicated server. The server regenerates a heightmap for every ghost zone a player explores (ZoneSystem.SpawnZone-> the zone prefab'sHeightmap.OnEnable) and for every terrain edit that loads with one (TerrainComp.Poke); the render half is(m_width+1)^2vertices, colours, UVs and indices plusRecalculateNormals/Tangents/Boundson a-nographicsprocess. The collision mesh, paint mask and material instance are untouched, and every other read ofm_renderMeshis null-guarded. NewmeshSkipscounter on the stats line. Off until a live exploration run shows the counter climbing with nothing visibly wrong.
0.4.2 — 2026-09-07
- Watchdog false trip fixed: "received" is now counted by our own
ZDO.Deserializepostfix over the same window as the marks, instead of the game's one-second-lagging counter, which tripped it when the last player logged out (live at 21:00 with three players on, B1 and the throttle silently off). The watchdog also re-arms itself when marks reappear.
0.4.1 — 2026-09-07
TopKSortdefault on after a live join: syncList ~11 -> ~5.5 ms avg, 24-32 -> 12-20 ms max. Gate tested both ways on the live server.
0.4.0 — 2026-09-07
- Version gate: replacement patches run only on a build in
[Compat] KnownGoodBuilds; unknown build logs a warning and runs vanilla + measurement. Constant-swap transpiler aborts unless exactly 3 constants matched. - B2 top-K selection (
TopKSort, default off): bounded heap instead of a full sort of every candidate; closes the vanillam_tempSortValueleak as a side effect. OneGetZDOper id in the dirty-set drain.
0.3.1 — 2026-09-07
SlicedSavedefault on after the live round-trip (687,133 saved at shutdown, 687,133 loaded). Sync-mode log line says "slices back-to-back" instead of "frames".
0.3.0 — 2026-09-07
- S1 sliced save (
SlicedSave, default off): world serialized on the main thread in SaveSliceMs slices into a memory buffer; the writer thread no longer reads live ZDO arrays. Off until a live round-trip is verified.
0.2.2 — 2026-09-07
- Watchdog uses its own 10 s window and counter; no longer races the stats-line reset (would have disabled B1 after restarts).
0.2.1 — 2026-09-07
- Final-review fixes: all-peers round respects the watchdog; marks split by setter; forced full scan when DirtySets is re-enabled; ReconcileSeconds read per call; floating scan cutoff 0.25 m above water and logs positions;
drainedon the stats line; ZDOID.None ignored in Mark.AllPeersPerRounddefault back to false pending a multi-player test.
0.2.0 — 2026-09-07
- B1 dirty sets: per-peer pending set fed by the revision setters; full scan on join, zone change and every 30 s; watchdog falls back to vanilla if the hook is silent. Live: sync cost per call 4.1 ms -> 0.07 ms.
- R2 relay throttle (
RelayMinIntervalMs), non-prioritized objects only. DirtySetsdefault on.
0.1.5 — hot-objects ignore list.
0.1.4 — hot-objects line with world coordinates.
0.1.3 — floating scan includes felled logs (TreeLog).
0.1.2 — floating item-drop scan/delete; config reload every 5 s.
0.1.1 — per-prefab tally of received ZDOs.
0.1.0 — 2026-09-07
- Measurement stats line;
ConstSwapIL constant swap; Tier C knobs (send window, headroom, all-peers round, Steam send rate); per-peer packet cap;TargetFrameRateknob. Final review fixes: restart grace 150 s, config ranges, per-peer try/catch, measurement hygiene.