
Dyrr
A door policy: characters that have played elsewhere do not come in. Off until an admin turns it on.Dyrr
A door policy. Characters that have played on another world do not come in.
Dyrr is Old Norse for the doorway itself. This mod was called Threshold until 2026-08-18; nothing about it changed but the name. If you ran it under the old name, your settings and your character bindings are carried over on the first run and you do not need to do anything.
Installing
Needs BepInEx. Core is optional, see below. Through a mod manager it is one install. By hand,
put Dyrr.dll in BepInEx/plugins/Dyrr/.
Then start the game once and quit. That first run writes the config file, which does not exist before the mod has loaded, and that is the usual reason people think it is broken.
Nothing is refused until you turn Enforce on. Out of the box the door only logs what it
would have refused, on purpose. The half that does work immediately is the menu guard on the
client, which stops you taking a character into a world it does not belong to.
Why this is its own mod
It used to live inside Rist, and that was the wrong place for it.
Rist awards character levels for skill gains, so it needs to know whether a character's skills were actually earned on this server. It answered that by refusing the connection, which put a levelling mod in charge of who is allowed to play. The failure mode is exactly what you would expect: a bug in an XP system locks people out of a server. And when it fired, the player got Valheim's generic kick screen with the reason written only to the server's log, which on somebody else's server they can never read. The first time it happened in practice, the person affected assumed a completely unrelated mod had blocked them.
So the two halves were separated by what each is actually for:
- Rist keeps the question it has standing to ask, do I pay for these levels?, and answers it by withholding XP. Nobody is disconnected, nothing already earned is removed, and the player is told once, on screen.
- Dyrr owns the question of who comes through the door. That is a server policy, it has nothing to do with levelling, and it is the whole of this mod.
The split also makes each honest about its own limits, which the fused version could not be.
Two halves, and the important one is the preventive half
Refusing at the door is the lesser half, because by the time it fires the harm is already
done. Loading a character into any world writes that world into PlayerProfile.m_worldData
permanently. Refusing the connection afterwards tells you about a mistake you can no longer
undo.
So the other half runs on the client, and it refuses to take a character into a world that is
not its own - a local world or somebody else's server, whether or not that server runs this
mod. For a local world the commit point is FejdStartup.OnWorldStart, in the menu. For a
server there is no equivalent in the menu, because a server's world identity is not known until
after connecting; the check goes at the one moment on the join path where the world is known
and nothing has been written yet, and leaves before the character spawns.
It refuses rather than asking. A confirm dialog was tried and rejected: the damage is irreversible, so a prompt is just a button for doing the unfixable thing by clicking through it. A dead end forces a wrong answer to be diagnosed instead of waved past, so the popup carries everything needed to correct it: both ids and the file to edit.
Each character is bound to the first world it is accepted in, recorded in
BepInEx/config/dyrr-home.txt. That file is protection, not enforcement; editing it only
lets you damage your own character, which is why it is plain text you can open and fix. Nothing
that turns other people away is ever read from the client.
The server case is worth spelling out, because it is where the window is narrow. The client
learns which world it is joining inside ZNet.RPC_PeerInfo, which reads the world name, seed
and uid straight off the wire. The permanent record - the entry in
PlayerProfile.m_worldData - is only ever written by PlayerProfile.GetWorldData, reached
from the logout point, the map data and the spawn point, all of which need a player who has
spawned. Between those two facts there is a window, and this is the whole of it. Leaving is
what vanilla itself does when you are kicked: set the connection status, drop the peer. The
logout that follows saves nothing, because Game.SavePlayerProfile does nothing at all
without a local player and there is not one yet.
So the door and the guard now cover the same ground from both sides, which is exactly why they belong in one mod. This used to live in Rist, warning about a lockout Rist had no part in.
What it checks
Six questions, asked of every connection: has this character spawned in a world other than this one, has the game flagged it for cheats, has it run a console command the game calls a cheat, do its own records disagree with each other, is the client running mods this server does not allow, and did it answer at all. Each can be turned off on its own; see Settings for the full list.
Enforce is off by default, and deliberately. This is the one setting in the family that
can lock people out of a server, including you. It should be something an admin turns on having
read what it does, not something that happens because a mod got installed.
Read this before turning Enforce on
The game never removes entries from a character's world list. One visit anywhere else is
permanent for that character file. Restoring a backup taken before the trip is the only way
back in. The cheat flag is the same, set by devcommands, never cleared.
That severity is the point: it is what makes a skill level on this server mean something. But it has no undo, and it applies to your own character exactly as it does to everyone else's.
How it works
The shape is lifted from Core's version handshake, because the problem is identical. Both ends
register an RPC the moment the connection object exists, in ZNet.OnNewConnection, which
happens before either side sends PeerInfo. ZRpc delivers in order on one connection, so by
the time RPC_PeerInfo runs the answer has arrived and there is something to judge. Anything
later means deciding on data that has not turned up yet, and the symptom of getting that wrong
is a door that admits the first connection and works ever after.
Two deliberate differences from the version that lived in Rist:
It refuses before the player is admitted. Rist's ran after spawn, on a routed RPC, so a refused player watched the world load and then got dropped, which reads far more like a crash than like a rule.
The client sends its raw world list, and the server does the arithmetic. At handshake time the client does not reliably know which world it is joining; the UID arrives later. Rist asked the client to subtract the current world itself, which is part of why it had to run so late. Sending the list lets the server, which certainly knows its own UID, work it out, and lets the whole exchange finish before anyone is let in.
The reason travels to the client. Valheim's refusal screen carries no text of its own, so the message is sent over the wire before the disconnect and logged on the client's own machine. Being told which rule you broke is the difference between a door and a mystery.
Core is optional
Dyrr installs and runs on its own, which is useful if you want a door policy and none of the rest of this suite. Core is a soft dependency, and installing Dyrr no longer installs it.
The door itself works standalone. Doorman carries its own handshake and does its own
refusing on the server side of RPC_PeerInfo; none of that is Core's.
Two things are given up. The version gate, which matters more here than elsewhere: the facts being judged are reported by the client, so an old build of Dyrr answering an unfamiliar question is precisely the case the gate would have caught. And the refusal screen: Core is what carries the reason through to Valheim's kick dialog. Without it a refused player gets the reason in their own log and a generic screen, which is exactly the failure that splitting this out of Rist was meant to fix. It still logs; it just cannot draw.
Install Core on the clients to put the reason back on the screen.
On cheating, and what a door can actually do about it
Start with the line that decides everything: Console.IsCheatsEnabled() returns
ZNet.instance.IsServer(). On a dedicated server a client's own devcommands is inert - it
flips a bool the gate then ignores. So nobody is cheating on somebody else's server with
vanilla. Anybody cheating there is running a mod that patched around that line, which means the
useful question is not did this character use cheats but what is this client running.
That is what RefuseMods asks, and it is the check that actually reaches the problem.
The character checks still matter, because a cheat mod leaves marks. m_usedCheats is a bool
set in Terminal.ConsoleCommand.RunAction, and a mod that switches devcommands on will trip
it. A mod that also clears it will not - but it has to clear the same fact in four places, and
they are written at different moments by different code:
| Record | Written | Independent because |
|---|---|---|
m_usedCheats |
in RunAction, if the command is a cheat |
the one everybody knows about |
m_playerStats[Cheats] |
the very next line | a counter, not a bool |
m_knownCommands |
a few lines down, outside the cheat branch | records the name of every command run |
m_knownWorlds |
in SavePlayerToDisk, by world name |
m_worldData records the same trips by uid |
So the flag being clear while the counter is above zero is not a suspicion, it is an edit. More
world names than world uids means the travel record was scrubbed - and that inequality only
runs one way, so the game itself cannot trip it. That is RefuseTampered, and it is the only
check here that does not need the client to be honest, only consistent.
Which commands count as cheats is decided on the server, from the server's own command table, so a cheat command added by another mod counts for free and the client has no say in the rule it is judged by.
A cheat that never touches the console leaves no mark
Proven here rather than reasoned about. Devkit - one of my own mods - has a god mode switch
that calls player.SetGodMode directly. It does not go through
Terminal.ConsoleCommand.RunAction, so none of the four records above are written: no
flag, no counter, no command name. A character that had just used it came to an enforcing
server and the character checks found nothing, correctly, because there was nothing there.
That is not a gap in those checks, it is their shape. They catch cheating that went through
the console, which is the only kind vanilla can do and the kind a devcommands-enabling mod
does. A mod that flips the state itself is invisible to every record the game keeps.
Which is the whole reason RefuseMods exists, and on that same connection it is what saw it:
A client brought 1 plugin(s) this server does not run: ezomic.valheim.devkit
It was admitted only because that GUID is in AllowedMods. Ask what the client is running,
not only what the character has done.
The limit, stated plainly
PlayerProfile lives on the client, so everything here is self-reported, the mod list
included. A purpose-built client can lie about all of it, and no amount of extra records
changes that - it only raises what a liar has to keep straight.
What this catches is the ordinary case: somebody who installed a cheat mod from Thunderstore and did not think about it, or brought a character that levelled somewhere else. That is a house rule with a lock on the door, not a security boundary. Core's version gate makes it meaningful by refusing clients without the plugin at all; a client that has it and has been modified is beyond what any of this can see.
Before it can refuse: the character-select screen
The refusal popup is a dead end on purpose, and a dead end is a much better thing to arrive at knowingly. So the character-select screen carries one line under the name:
Belongs to world 'longhouse' (-4881...)
or, for a character that has not played anywhere yet:
Not bound to a world yet. The first one it plays in becomes its home.
It sits in m_csSourceInfo, vanilla's own notice label - the one that carries the legacy-save
and cloud-saves-disabled warnings - so it looks like part of the screen because it is. Nothing
there prevents anything; it is what stops the refusal at the next screen being a surprise.
It follows ProtectCharacter. With the protection off, a binding is a leftover in a file
rather than a fact about the character, and saying it would imply a rule that is not running.
Asking the door what it is doing
Enforce off is not a disabled state. It is the state an admin is meant to sit in while
deciding: every connection is still judged and what would have happened is still reported. The
trouble was that it reported one line at a time into a log, at the moment each player
connected, so the question actually being asked - if I turn this on, who stops being able to
play? - could only be answered by reading back through a log for lines that scrolled past
while nobody was watching.
dyrr in the console answers it standing:
Dyrr 1.1.0
World: 'midgard' (-4881...)
Enforce is OFF - failures are reported here and refused to nobody.
Checks: other worlds on, cheats on, unreported on
Refused so far this session: 0
Ragnar admitted
Sigrun would refuse: has played on 2 other world(s)
On a server the same block goes to BepInEx/LogOutput.log, because a console scrolls and a log
file does not. On a client it reports what that machine knows instead: whether a server has
refused it this session, and why.
Two more:
dyrr home- which world each character on this machine belongs to, by name as well as id.dyrr forget <id>- unbind a character, so the next world it plays in becomes its new home. This does not undo anywhere it has already been; the game's record of that is permanent and no mod can clear it.
Neither is a cheat command and neither is admin-gated, because neither reads or changes anything that was not already open to whoever can run it: the report is the server's own state to somebody already at its console, and the bindings are this machine's own text file.
The console needs Valheim's -console launch argument on a client. A dedicated server has one
already.
Turning the mod check on without locking out your own players
ModPolicy = Allow refuses a client running anything the server does not, which is the point,
and also the way to empty a server by accident. Both lists ship empty and DeniedMods
could not honestly ship otherwise - a list of cheat mod GUIDs written in advance is out of date
the week after and reads as complete when it is not.
What you build them from is what actually turns up. Every plugin a client brings that this server does not run is written to the log as it connects, admitted or refused:
A client brought 2 plugin(s) this server does not run: randyknapp.mods.equipmentandquickslots, ...
So the order that works is: leave Enforce off, let people connect, run dyrr and read the
log, put the innocent ones in AllowedMods, and only then turn Enforce on. dyrr prints the
standing verdict for everyone currently connected, which is the whole point of Enforce having
an off position.
One case worth naming because it will be yours: a server does not run the tools you develop
with. If your own client carries something the server does not, it is refused by its own door
like anybody else, and the fix is a line in AllowedMods.
Settings
The file is BepInEx/config/ezomic.valheim.dyrr.cfg. Every entry carries a comment explaining
itself, so the file is the reference; this is the map.
| Setting | Default | What it does |
|---|---|---|
Enabled |
on | Off leaves the plugin loaded and checking nothing. Server side only |
Enforce |
off | On refuses the connection. Off only logs what would have been refused |
RefuseOtherWorlds |
on | Refuse a character that has spawned in any world but this one |
RefuseCheats |
on | Refuse a character the game has flagged for devcommands use |
RefuseCheatCommands |
on | Refuse a character that has run a command the game marks as a cheat |
RefuseTampered |
on | Refuse a character whose own records disagree with each other |
RefuseMods |
on | Judge what the client has loaded, by plugin GUID |
ModPolicy |
Allow |
Allow: only what this server runs plus AllowedMods. Deny: all but DeniedMods |
AllowedMods |
empty | Extra GUIDs a client may run. This server's own plugins are always allowed |
DeniedMods |
empty | GUIDs no client may run. Only read under Deny |
RefuseUnreported |
on | Refuse a connection that answers nothing, or an unreadable profile |
RefusedMessage |
a sentence | Sent to the refused client so it lands in their own log |
ProtectCharacter |
on | The client-side guard, and the binding that feeds it |
ProtectOnServers |
on | Extend that guard to servers: leave a join into the wrong world before spawning |
Note the standing BepInEx behaviour: every entry is written to disk on the first run and the saved value beats a new default in code. Changing a default in a later version does nothing on a machine that has already run the mod.
ProtectCharacter is on while Enforce is off, and that asymmetry is deliberate. Refusing
other people is a policy somebody should choose; refusing to let you irreversibly ruin your own
character is just not standing by while it happens.
Scope
Registers with Core at Requirement.Everyone. Not because clients decide anything, since only the
server does, but because the facts being judged live on the client and have to be reported,
so a client without the plugin answers nothing.
Running more than one server
This used to be a warning, and it was the mod's worst hole. The guard covered local worlds
only, and the door covered servers only where Enforce was on - so a non-enforcing server was
a gap a character walked straight into. The game wrote that world into the profile, and the mod
could do nothing but log
Character 'X' is bound to world A but is in world B. Too late to stop it - that world is now written into the character.
Which is exactly what happened the first time two servers ran here, one enforcing and one not. The character was ruined by the server that was being lenient.
As of 1.1 the client refuses that join itself, before spawning, whatever the server does
and whether or not the server runs this mod. That is what ProtectOnServers is, and it is on
by default. The hole is closed from the side that has something to lose.
The advice has not changed, because a lock is not a reason to stop being careful: keep a separate character per server. One character, one world, permanently. What has changed is that forgetting to do so is no longer irreversible.
Recovering a ruined character
Restoring a character backup taken before the trip clears its travel record and it is admitted again. This is the only way back, and it has been done: a character refused for having visited another world came back from backup and was let in.
Note the backup does not touch dyrr-home.txt, which lives beside the config rather than
with the character - dyrr forget <id>, or deleting that character's line, clears the binding
if it is now wrong. A restored character can therefore carry a stale home. That is harmless,
a home pointing at a server world matches no local world, so the menu guard simply refuses all
of them, which errs toward protection, but the world id quoted in the popup may be the old one.
Status: v1.1
Both branches have been run against a real dedicated server. It refuses a character that has been elsewhere, with the reason on the client's own screen and in its own log; and it admits a clean character on an enforcing server. That second one mattered more than it sounds: until it happened, "works" and "refuses everybody" were indistinguishable, because every test until then involved a character that genuinely had travelled. The only arithmetic in the mod is counting worlds that are not this one, and that is now confirmed in both directions.
Also confirmed: the menu guard's binding, the adoption of bindings from the old home file, and the backup recovery above.
New in 1.1, and untested
Everything in 1.1 compiles and has not been run in game: the client-side guard on servers,
the four extra cheat records, the mod check, the dyrr command and the named bindings.
Two of those deserve to be treated with more suspicion than the rest. The client-side guard
applies the same rule the menu guard has been applying correctly for a while, but at a new
moment, and that moment was read out of the game's own code rather than observed - so it is
unproven until a character has actually been turned back at somebody else's server. And
RefuseTampered has never seen a tampered profile, for the same reason RefuseCheats never
had: producing one takes deliberate work nobody has done here yet.
RefuseCheats has now fired
It shipped in 1.0.0 on by default having never once triggered, which was disclosed here as the
one gap. It needed a character deliberately flagged by devcommands, and nothing until now had
produced one. On 2026-08-19 something did: devcommands then god in the console, quit to the
menu, join an enforcing server.
Refused a connection: is flagged as having used cheats, and has run cheat command 'god'
Both cheat checks in one refusal, on screen and in both logs. Worth noting what the same test proved about the check next to it: the first attempt used a mod's god-mode button rather than the console and set none of the records, which is the distinction the cheating section is about.
Known gaps
RefuseTampered has never fired, for the same reason RefuseCheats had not: producing a
profile whose own records disagree takes deliberate work nobody has done. It is on by default.
Unlike the others it cannot be triggered by playing the game wrong - only by editing a save -
so the risk is that it never triggers rather than that it triggers wrongly.
The migration from the Threshold-era files is confirmed working, on a real run: the config came across with all seven values intact, the character bindings came across in full, and both original files were left untouched, since it copies rather than moves.
License
MIT. See LICENSE.
Reporting bugs
The Discord is the fastest route, and the right one if you are not sure whether what you are seeing is a bug at all. Issues on the repo work too and suit anything long.
Bring BepInEx\LogOutput.log if you can, and say whether you were on a server or your
own world. The log is most of the difference between a fix and a guess, and it is written
every session whether or not anything went wrong.
Part of the Longhouse pack
This is one of the Longhouse pack, a pinned set of my mods that installs in one click and is what the Longhouse server runs. You do not need the pack to use this on its own, and nothing here behaves differently outside it.
The Discord is where the server lives if you want to play on it: small, EU, hard combat difficulty and everything else vanilla.
Author
Robbin Thijssen / Thijssen Software.