Dafini-Heel icon

Heel

Looks after your tames. The ones following you catch up on their own after a delay - through portals, out of pits. The ones you parked stop wandering into fires, lava, water, smoke and spike traps.

CHANGELOG

Changelog

2.1.0 — the pack that would not come

A bug report with a very precise shape: "depending on the wolves currently around the player, if they have a high count coming with them at destination, they do not come with unless they are out of asset range."

Four things were wrong on the server path, all four invisible with one or two tames and all four worse the bigger the pack.

The whole feature was behind an eighty-metre wall

Claim refused to move any follower within eighty metres of any player, on the reasoning that a nearby client is simulating it and taking it mid-frame is a race.

AdriftDistance is forty metres. So the player a follower was failing to keep up with was standing right there, being the reason it counted as near somebody. Every stalled follower and every adrift one inside eighty metres was clocked, judged due, and refused — for as long as it stayed there. The only followers the server could ever lift were the ones already out of everybody's asset range, which is exactly, word for word, the report.

And the race it was avoiding is not one the server can lose. ZDOMan.RPC_ZDOData discards any incoming update whose data revision is not strictly newer than the copy it already holds:

if (incoming.DataRevision <= mine.DataRevision) {
    if (incoming.OwnerRevision > mine.OwnerRevision) { ...take the owner... }
    continue;                          // the position is discarded, unread
}

The server is the hub. Every client's update is compared against the server's table on arrival, so once the server raises the revision, a write the client already had in flight lands stale and is dropped. The client gets the owner change and the position in one packet and applies both, and two seconds later vanilla's own ZDOMan.ReleaseZDOS hands ownership back to whichever peer now has the creature in their active area — which is the hand-back you want: the wolf appears behind its player and that player's client walks it.

So the distance test is gone and an identity test replaces it. ZDO.GetOwner() is a session id, ZNetPeer.m_uid matches it to a name, ZDOVars.s_follow says whose it is. Whether the machine holding a wolf belongs to its own player is a string comparison.

The one refusal that survives is the one the old note was really about: a follower left beside a different player stays there. That is a manners rule, not a race — somebody else has that animal on their screen — and it waits for a later sweep.

This also retires a constant this mod should never have carried. The real active area is ZNetScene.PointInsideActiveArea: a Chebyshev box of one and a half zones around the zone centre, plus a radial check, with the multiplier varying by simulation-distance setting and classic mode. Eighty metres was an approximation of a number that moves.

Every arrival landed on the same square metre

Warden called Drop.Spot(..., 0, 1, ...) — index zero of one — for every follower, every time. Arc is the class that opens by describing exactly that failure: "step out of a portal with nine wolves and they arrive on top of you... the portal has become a trap made of your own tames." It was being called in the one way that rebuilds it.

Arc needs the batch size, and the server never has it: the sweep is a cursor, so a mixed pack is discovered over several ticks and the last of them is unknown when the first lands. So Arc.OpenSlot fills a ring as though it were full — fixed angles that never move — and takes the slots from the middle outwards, so every prefix of the sequence looks deliberate: one goes dead behind, two straddle it, five fill the ring, six starts a ring further out.

ArrivalSpacing was bound, documented, and read by nothing but the client

Its own description says "with a large pack this is what stops twelve animals materialising in the same instant", which is precisely what was happening. Berth now holds a per-player slot counter and release clock, keyed on the follow name off the ZDO. A follower told "not yet" keeps its elapsed clock and comes back on the next tick for the next slot.

The counter resets after a quiet spell, and the right length for that spell is the catch-up delay itself: nothing can be lifted twice inside one delay, so a gap that long means the batch is over rather than still trickling in — and a wolf rescued alone an hour later belongs at the front of the arc, not in slot thirteen.

Behind was due north for everybody

A peer reports where it is, never which way it is looking, so the server passed Vector3.forward for everyone. That was fine while every arrival landed on one spot five metres away. With a real sixty-five-degree fan it means a player walking south gets their whole pack dropped in front of them.

Bearing reads a heading out of successive m_refPos samples. Travelling is close enough to facing for this: the point of the arc is to stay out of the player's path, and their path is what this measures. A player standing still keeps their last heading, which is a guess about somebody who is by definition not about to walk into their own wolves.

And the linter had never looked at any of it

ZNetPeer was not in lint_api.py's receiver table, so every peer field the server path reads — m_playerName, m_refPos, and now m_uid — had been unchecked since the server path was written, along with ZNet.GetPeers. Adding them took the checked reference count from 57 to 67. All clean, but it had been reporting "clean" about code it was not reading.

Testing

166 assertions, up from 126. Each of the four defects was put back deliberately and the suite checked for failing exactly its own assertions and nothing else, as was the linter's new coverage.

2.0.0 — serverside, so nobody else has to install it

Drop Heel on a dedicated server and the catch-up works for every player on it, with vanilla clients. Nothing to install, nothing to keep in version lockstep.

Config: Server / ServerSide, on by default. Ignored anywhere but a dedicated server.

Why the client path could not simply be pointed at a server

It hangs off Player.Update, and a dedicated server has no local player. That is the shallow reason.

The deep one is that a dedicated Valheim server is not a simulation. ZNet.m_referencePosition decides which zones load, and the only things that ever move it are the local Player's SetLocalPlayer and LateUpdate — so on a headless server it sits at Vector3.zero forever. Almost nothing loads. BaseAI.UpdateAI returns early unless IsOwner(), and the owner is whichever client is standing there. There are no creatures to find, no components to read, no colliders to stand on.

So the server path works entirely in ZDOs, and every piece it needed turned out to be public:

ZDOVars.s_tamed / s_follow what a creature is, and whose it is
ZDOMan.GetAllZDOsWithPrefabIterative walking them without a frame spike
ZNetPeer.m_playerName / m_refPos where everybody is, always current
ZDOMan.GetSessionID + ZDO.SetOwner the right to write a position
ZDO.SetPosition which fixes the sector too
ZDOMan.ForceSendZDO telling peers now rather than eventually

Tameable.RPC_Command — the code that runs when you press E on an animal — writes s_follow to Player.GetPlayerName() on follow and to "" on stay. So "tamed, and following you, not somebody else" is answerable from a ZDO alone.

The condition that makes a catch-up necessary is the condition that makes it safe

A ZDO's position only propagates when the writer owns it: SetPosition updates the sector unconditionally, but IncreaseDataRevision sits behind IsOwner(). The server can take ownership, but a client standing next to an animal is simulating it, and yanking it mid-frame is a race they win half the time.

So the server only moves creatures nobody is near — which sounds like a limitation and is very nearly the opposite. Valheim only loads zones around players, so a follower far from everybody is precisely a follower nobody owns. The case it gives up on is one left beside a different player; that waits for a later sweep.

What does not come across, and will not

Hazard avoidance is client-only. m_avoidFire, m_avoidLava and m_avoidWater are plain public fields on a live BaseAI MonoBehaviour, not ZDO values. They exist only on the machine running the AI, which on a server is never the server. Keeping a tame out of a fire is a thing only a client can do, and the startup log and heel both say so rather than shipping a switch that silently does nothing.

A host playing on their own machine keeps the client path, deliberately. They have both a local player and a server, and the client path is strictly better — it can see the ground and it can mind the fires.

The honest weakness

s_follow holds a display name, and names are not unique. Two players called Dafi on one server share every follower between them as far as this can tell. That is vanilla's own behaviour — pressing E stores a name there in vanilla too — so it is not a regression, and no better field is available to a server. But a mod that teleports animals on the strength of a name should say so out loud.

Where it puts them, with no ground to look at

The client path raycasts for a floor. A server cannot: no colliders, no loaded zones. What it has instead is better than a guess — the followed player's position, which has the property that somebody is standing on it. Whatever is under their feet is solid, dry and loaded, because they are on it.

So followers land at the player's own Y, arranged by the same Arc the client path uses, and Landing.Judge still rules — fed the player's footing rather than a guess dressed as a survey. A swimming player is still refused, so a wolf is left behind rather than dropped in the sea. A player at the edge of a drop gets followers placed in mid-air; they fall, take fall damage, and land.

Verified

126 assertions, seven deliberate re-breaks each confirmed to fail the check that claims to catch it: the busy-client refusal removed, a parked tame treated as a follower, the sweep cursor stopped from wrapping, the water check bypassed, a listen-server host hijacked onto the server path, and the ZDO stub returned to answering its own fallback.

That last one passed on the first attempt — nothing in the suite read a ZDO, so the stub could have swallowed every write and stayed green. There is now a section that asserts the round trip directly, and it fails when the stub lies.

The API lint also grew a fix in the opposite direction: it reported Player.x, Player.y and Player.z as missing members, because receivers are matched by variable name and a Vector3 parameter had been called player. Renamed, and x/y/z/w are now ignored — that class of false positive cannot be real.

1.1.0 — the animals you parked stop walking into the fire

Heel now has two halves, and they are the same subject from opposite ends: it fetches the tames that ARE following you, and it minds the ones that are not.

The game already does this and switches it off for tames

This is the finding, and it is not subtle. BaseAI.AvoidFire opens with:

if (m_character.IsTamed()) return false;

Fire avoidance is not missing for tamed creatures. It is deliberately disabled for them — presumably so a follower walks past your hearth instead of bolting from it. The price of that decision is a boar standing in a firepit until it dies.

BaseAI.IsValidRandomMovePoint already refuses to send a creature somewhere it would be hurt, and already checks fire, lava and deep water — behind m_avoidFire, m_avoidLava and m_avoidWater, three public bools that are simply never set on a tame.

So for three of the five hazards, the fix is three assignments. No new pathing, no new AI.

The two the game has no switch for

Because it has no concept of them as places:

  • Smoke is a layer, not an area — Character.UpdateSmoke does a CheckSphere against the "smoke" layer over your own head, and that is the only definition there is.
  • Traps are Aoe components, and there is no "trap" type. So a trap is derived rather than listed: anything that hits characters, hits friendly ones, and deals damage. That covers sharp stakes, dvergr spikes and any modded trap without naming one — and a trap already configured to spare friendly targets is left alone, because it was never going to hurt them.

Both are added by a postfix on IsValidRandomMovePoint that can only ever turn a yes into a no. The game's own reasons to refuse a spot are all good ones.

Getting out of somewhere it is already standing

Better destinations do not help an animal that has already arrived. BaseAI.ResetRandomMovement is public and does exactly the right thing: it clears the arrived flag and zeroes the timer, so the AI picks a new destination now instead of in up to m_randomMoveInterval seconds — and since the validator will not hand it anywhere hazardous, the place it picks is out.

That is the whole nudge. No teleport, no shove, no new pathing; it walks out under its own power. It fixes a pen built around a firepit without you rebuilding anything.

Only the ones you parked

A follower is left exactly as the game intends, because the game turned fire avoidance off for followers on purpose and walking past your hearth is what that bought. There is an assertion whose only job is that inverting the follow test — which would silently swap Heel's two features over — fails loudly.

Settings

All under Minding, all default on. AvoidSmoke is the one worth a thought: it will also keep tames out of a working smokehouse, which is either exactly what you want or exactly what you don't.

heel in the console now reports the parked population separately from the followers.

1.0.0 — first release

Tames that are following you catch up on their own after a delay.

  • Two triggers, not one. Adrift (more than 40m) and stalled (near, but the gap is not closing). The second is the one a distance check cannot see — a wolf in a pit eight metres below you is right there and is never getting out.
  • Arrivals land in an arc behind you, one every half second, in rings of five. A pack never lands on the portal exit and never lands on top of each other.
  • Works through portals, because the roster is keyed by ZDOID rather than by object — the follower's record survives its zone unloading, which is exactly what happens the instant you step through.
  • Only ever moves a tame that is tamed, alive, and following YOU. Penned animals, parked animals, wild animals and other people's followers are never touched.
  • Refuses unsafe ground: no water, no unloaded terrain, no mid-air, and never inside a ward you are not admitted to. When nothing is safe, nothing happens.
  • Client-side only. Works on a vanilla server; needs nothing from anyone else.
  • heel in the console says who is tracked, what the clock is doing, and why anything is still sitting in a pit.