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.
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.UpdateSmokedoes aCheckSphereagainst the"smoke"layer over your own head, and that is the only definition there is. - Traps are
Aoecomponents, 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.
heelin the console says who is tracked, what the clock is doing, and why anything is still sitting in a pit.