ValheimNetProbe
Diagnostic-only BepInEx 5 plugin for Valheim 1.0. It measures Taiwan ↔ Australia combat and pickup delay. It does not change send rates, ownership, ZDO scheduling, interpolation, RPCs, combat, or pickups.
Install on the dedicated server and every client in the test. Clients show the overlay. The server writes the peer and event log.
Console
netprobe/netprobe status— print the current snapshotnetprobe export— writeBepInEx/ValheimNetProbe/events-*.csvnetprobe clear— empty the in-memory buffer
Config: BepInEx/config/cjayride.valheimnetprobe.cfg
What was measured (Valheim 1.0)
Inspected in assembly_valheim.dll on this machine. These are the live types.
Network / RTT
ISocket.GetConnectionQualityviaZNet.GetNetStats/ eachZNetPeer.m_socket— this is the game's ping (Steam/PlayFab). This is the RTT number.ZRpc.GetTimeSinceLastPingis not RTT.ZRpc.UpdatePingsends a keepalive (method == 0) every 1s. The reply only resets a timeout timer. Logged askeepalive.ISocket.GetSendQueueSize— bytes waiting on that peer.ZDOMan.SendZDOsskips a peer when this is above 10240, or remaining budget is under 2048.
No extra ping RPC was added. The socket quality value is what the game already uses.
Combat path (do not guess — this is the 1.0 path)
Player.StartAttack— local swing starts.Attack.OnAttackTrigger— melee/area/projectile runs on the attacker.Character.Damage→ZNetView.InvokeRPC("RPC_Damage")→ZRoutedRpc.InvokeRoutedRPC(owner, zdo, "RPC_Damage").- The dedicated server forwards that RPC to the ZDO owner. It does not apply damage unless the server owns the creature.
- Owner runs
Character.RPC_Damage(early-out if not owner) thenApplyDamage/SetHealthon the ZDO.
Remote HP is a ZDO float (ZDOVars.s_health), not a second damage RPC. Observers wait for ZDOMan.RPC_ZDOData.
Pickup path
ItemDrop.Interact→Pickup.CanPickupism_nview.IsOwner()(plus a 0.5s spawn delay).- If not owner:
RequestOwn→InvokeRPC("RPC_RequestOwn")to the current owner, thenPickupUpdateevery 0.05s. - Owner
RPC_RequestOwncallsZDO.SetOwner(requester)only if that machine is the owner. - Requester becomes owner after the owner-change ZDO arrives, then
Player.Pickupruns.
Pickup delay is often one RTT for the ownership RPC plus whatever ZDO send wait (SendZDOs is one peer per ZDOMan tick, and ticks start every 0.05s).
ZDO scheduling (read-only counters)
ZDOMan.SendZDOToPeers2: after 0.05s, send one peer per Update, then wrap. SendZDOs builds m_tempToSync, sorts with ServerSortSendZDOS (distance, age since last sync, ZDO.ObjectType.Prioritized first). Combat HP and item ownership ride this same queue. They are not a separate reliable channel.
Harmony patches (why each exists)
There is no public “on damage sent” or “on ZDO skipped” event. Prefix/postfix only record. Nothing is skipped, rewritten, or reordered.
| Patch | Why |
|---|---|
Player.StartAttack postfix |
Local attack start |
Attack.OnAttackTrigger postfix |
Hit window on attacker |
Character.Damage prefix |
Attacker queued RPC_Damage |
Character.RPC_Damage postfix |
Owner applied path |
Character.ApplyDamage postfix |
Detailed HP apply (off unless DetailedLogging) |
ItemDrop.Pickup / RequestOwn / RPC_RequestOwn / PickupUpdate |
Pickup ownership wait |
ZRoutedRpc.InvokeRoutedRPC / HandleRoutedRPC prefix |
Combat/pickup RPC only (RPC_Damage, RPC_RequestOwn) |
ZDOMan.SendZDOs prefix/postfix |
Queue size, skip/defer, pending list count |
ZDOMan.RPC_ZDOData postfix |
Receive counter |
What cannot be measured accurately
- One number for “client → server ms” across Taiwan and the dedicated server. Different OS clocks.
MonoMsis per process.NetTimeSecis Valheim's net clock and is useful on the server. Do not subtract a clientMonoMsfrom a serverMonoMs. - Observer “I saw the swing” frame without watching animation bones. The overlay does not invent that. Compare attacker
DamageRpcSendon one client withDamageRpcOwneron the owner andRoutedRpcRecvon the server. - True packet loss. Socket quality is what Steam/PlayFab expose, not a custom ping series.
- ZRpc keepalive as RTT. It is not RTT.
What each metric means
| Metric | Meaning |
|---|---|
| RTT | GetConnectionQuality ping for that peer, milliseconds |
| jitter / min / max / avg | Rolling stats of that ping |
| queue | GetSendQueueSize bytes on that peer |
| keepalive | Seconds since ZRpc ping reply (timeout detector, not RTT) |
| zdoDefer | Times SendZDOs would skip because queue > 10 KB or budget < 2 KB |
| pending | Last m_tempToSync.Count after a send attempt |
| frame | Time.unscaledDeltaTime at the event (server lag vs network) |
| BecameOwner waitMs | Time on the picking client from Pickup to owner (same clock) |
Taiwan / Australia test
- Install the same DLL on the dedicated server, Taiwan client, and Australia client.
netprobe clearon all three.- Note which machine owns the target (creature/item
owner=in the log). Dedicated-server ownership vs player ownership changes the hop count. - TEST 1: Taiwan hits a mob 20 times. TEST 2: Australia hits the same kind of mob 20 times.
- TEST 3/4: each player picks up 20 item drops they do not already own (drop from the other player if needed).
netprobe exporton server and both clients. Compare CSVPhaserows. Same-process intervals only.
How to read the cause
- Raw RTT:
BecameOwner waitMs≈ RTT (or ~2× RTT if owner is the other player and the server only forwards). CombatDamageRpcSendon attacker vsDamageRpcOwneron owner separated by about that same RTT.framestays small (a few ms).zdoDeferstays near 0. - ZDO scheduling: RTT is modest but
BecameOwner waitMsor remote HP is much larger.queuehigh,zdoDeferclimbing,pendinglarge on the far peer.SendZDOsis one peer per tick — a busy TW peer can delay AU's packet by extra 50ms steps. - RPC delay:
RoutedRpcRecvon the server is late vs the attacker's send, while ZDO counters are quiet. Forward-only: serverRoutedRpcRecvthen ownerDamageRpcOwner. - Server CPU:
frame/ServerFrameMsspikes at the same timestamp as the event (tens of ms+). Processing itself is still the Damage/RequestOwn postfix; if those are 1–2 ms and frame is 30 ms, the server is busy, not the RPC body. - Ownership:
PickupAttempt can=0thenRequestOwnSendthen longBecameOwner. Combatowner=is the other player, so damage RPC is TW→server→AU (or the reverse), not TW→server apply. - Client interpolation: attack animation feels late but
DamageRpcOwnerand HP ZDO already happened. This probe does not hook animation interpolation; if network intervals match RTT and the feel is worse, look at client presentation next. - Queue starvation: AU
queueandzdoDefergrow while TW stays low.ServerSortSendZDOSstill shares the same 10 KB window per peer, but only one peer is served per ZDOMan update.
Possible Network Optimizer
Do not build this until the CSV shows which row is true.
A. Prioritization — Worth it if pending is large and combat/pickup ZDOs sit behind scenery. ObjectType.Prioritized already exists; a later mod could mark hit/item-owner ZDOs higher without raising global send rate.
B. Per-peer scheduling — Worth it if one peer's send uses the 0.05s slot and the other waits. Today m_nextSendPeer is strictly round-robin, one peer per Update.
C. High-latency compensation — Worth it if AU's zdoDefer is high because GetSendQueueSize stays above 10240 (bytes in flight ≈ bandwidth-delay). Raising the 10 KB cap is a last resort and is not done here.
D. Combat — Worth it if RPC_Damage is fast on the owner but observers wait on SendZDOs. Then prioritize that ZDO, do not “predict” hits.
E. Pickups — Worth it if BecameOwner waitMs >> RTT because of owner hop + ZDO wait. Safe client prediction is a later design; first try prioritizing the item ZDO after SetOwner.
F. Adaptive — Only after A–E. Use RTT, queue, and zdoDefer from this probe as inputs.
The next mod should change one of those, then re-run the same 20-hit / 20-pickup tests.