You are viewing a potentially older version of this package. View all versions.
Dafini-Heel-2.0.0 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.

Date uploaded 2 months ago
Version 2.0.0
Download link Dafini-Heel-2.0.0.zip
Downloads 126
Dependency string Dafini-Heel-2.0.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

Heel

Looks after your tames — both halves of them.

Following you? They catch up on their own after a delay. Parked? They stop walking into things that kill them.

The ones following you

If something tamed is following you and it falls a long way behind — or gets stuck somewhere it will never climb out of — it waits 25 seconds and then turns up behind you. Through portals. Out of pits. Off the wrong side of a river.

They arrive in an arc behind your back, one at a time, so a dozen wolves never land on top of you and wall you into a portal.

What it will and will not touch

It only ever moves a creature that is tamed, alive, and following you specifically.

That is three separate safety rules and each one is doing a job:

  • Tamed — a wild wolf is never brought to a player by this mod, under any setting. That would make it a way to drop a two-star troll on somebody.
  • Following you — your friend's boars are their client's business. Two clients both moving the same animal would fight over it, and it would flicker between two places until one gave up.
  • Following — a penned or parked animal is tamed and near you and going nowhere, which is every trigger this mod has. If "following" were not required, walking past your own barn would empty it into the field behind you. Told to stay means stays.

Where it will not put them

A spot has to earn it, and when nothing earns it nothing happens — the follower stays where it is and it tries again shortly. Doing nothing is always safe and always available.

  • Never in water, never on ground that has not loaded, never in mid-air.
  • Never inside a ward you are not admitted to. A convenience mod that can push livestock into a stranger's base is a griefing tool wearing a convenience mod's coat.
  • Never on top of another arrival.

Two ways to fall behind

Adrift — you are more than 40 metres away. The clock runs no matter what they are doing, because a wolf three hundred metres back is running towards you at full speed and will still be running in an hour. This is the portal case.

Stalled — you are near enough that they ought to be with you, and the gap is not closing. Pit, fence, roof, a rock they cannot path around. This is the one a distance check cannot see: a wolf eight metres below you at the bottom of a hole is not far away by any measure worth having, and it is never getting out.

If they are simply behind and gaining, they are left alone to arrive under their own steam — which is what happens almost every time you jog across a field. The delay is the point.

The ones you parked

Anything tamed that is not following anybody stops wandering into open flames, lava, deep water, smoke and spike traps.

Most of this is not new code — it is a switch the game turns off for tames. BaseAI.AvoidFire literally opens with if (m_character.IsTamed()) return false;, and the wander validator already checks fire, lava and water behind three public flags that are simply never set on a tamed creature. Presumably so a follower walks past your hearth instead of bolting; the price is a boar standing in a firepit until it dies. Heel turns them back on for the animals you parked, and leaves followers exactly as the game intends.

Two hazards the game has no switch for, because it has no concept of them as places:

  • Smoke — the same CheckSphere on the "smoke" layer the game runs on your own head.
  • Traps — derived, not listed: anything that hits characters, hits friendly ones, and deals damage. Sharp stakes, dvergr spikes, modded traps. A trap already set to spare friendly targets is left alone, since it was never going to hurt them.

Already standing in something? It walks out. Nothing is teleported or shoved — the AI is just told to pick a new destination now, and the validator refuses to send it back in. That fixes a pen built around a firepit without you rebuilding it.

One to know about: AvoidSmoke will also keep tames out of a working smokehouse. Turn it off if you cure meat around your animals.

Console

heel

Says who it thinks is yours, what the clock is doing, and why anything is still sitting in a pit.

Multiplayer

Client-side only. It moves your followers on your machine, asks nothing of the server and nothing of anyone else's client — so it works on a vanilla server, and two people running different settings cannot desync anything. Everyone who wants it installs it; everyone who does not, does not.

Settings

Everything is in BepInEx/config/dafini.heel.cfg. The ones worth knowing:

Setting Default What it does
CatchUpDelay 25s How long they get to sort themselves out first
AdriftDistance 40m Past this, the clock runs regardless
WithYouDistance 12m Inside this, they count as having arrived
ArrivalSpacing 0.5s Gap between one arrival and the next
MinRadius 5m How far behind you the first ring lands
HoldWhileFighting off Leave tames to finish their fights instead of rescuing them
MindHazards on Keep parked tames out of danger at all
AvoidFire / AvoidLava / AvoidWater on The three the game already knows
AvoidSmoke on Also keeps them out of a working smokehouse
AvoidTraps on Anything built that would hurt them
TrapMargin 1.5m How wide a berth to give a trap

HoldWhileFighting is off on purpose: the wolf you most want rescued is usually the one stuck in a pit with something.

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.