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.
Dyrr
A door policy: characters that have played elsewhere do not come in. Off until an admin turns it on.
| Date uploaded | 15 hours ago |
| Version | 1.1.0 |
| Download link | Ezomic-Dyrr-1.1.0.zip |
| Downloads | 7 |
| Dependency string | Ezomic-Dyrr-1.1.0 |
This mod requires the following mods to function
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.2333README
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.
CHANGELOG
Changelog
Notable changes to Dyrr. Format follows Keep a Changelog, and the mod uses semantic versioning.
[1.1.0] - 2026-08-18
The preventive half now covers servers, which is what it was always supposed to do.
Built, not yet run in game. Everything below compiles and nothing here has been tested against a real server.
Added
-
The client refuses a join into the wrong world, whatever the server does. Until now the only thing standing between a character and a server that would ruin it was that server choosing to enforce, because a server's world identity is not known in the menu and there was nothing to check. There is one moment on the join path where it is known and nothing has been written yet - the client reads the world name, seed and uid inside
ZNet.RPC_PeerInfo, and the permanent record this mod exists to prevent is only ever written byPlayerProfile.GetWorldData, which needs a spawned player. So the check goes there, and the connection is dropped the way vanilla drops a kicked one.This closes the hole the previous README described and left open: a non-enforcing server ruining a character that a different server would then refuse forever. It works whether or not the server runs this mod at all. New setting
ProtectOnServers, on by default, underProtectCharacter. -
The character-select screen says which world a character belongs to. One line above the name, cloned from
m_csFileSource- vanilla's own "Cloud save" label - so the font, size, colour and alignment are the game's and not a guess.It reads the binding file first, and the character second, which is where it earns its keep. A binding is only written when a character spawns while Dyrr is running, so every character that last played before the mod arrived had none and said so. But
PlayerProfile.m_worldDatais one entry per world a character has spawned in, andSaveSystem.GetAllPlayerProfilesparses it in full for the menu - so the answer was already in memory. Exactly one world is that character's home by definition, and it is bound on the spot rather than merely shown; leaving it unbound would mean the menu guard protecting nothing until the character happened to play again.More than one world is left unbound on purpose and named rather than counted: "Has played in BaldoTest and longhouse_20260818". There is no single home to defend, and a count tells somebody they have a problem without telling them what it is. Names come from the local world list where the world is on this disk, topped up from
m_knownWorldswhere it is not - the game's own record of where a character has played, keyed by name wherem_worldDatais keyed by uid. That pairing is what names a server's world that nothing local could.No bare uid ever appears on that line. Nineteen digits standing where the answer goes was the first version of it; a world that still cannot be named now says it is not on this PC, and a name worked out late is written back into the binding so it is only worked out once.
This is the same fact
dyrr homereports, moved to where the decision is made. The console was the cheapest surface to write and the wrong one: off until somebody enables it, a developer's tool, and it has to be asked. The information matters at exactly one moment, and that moment already has a screen. Nothing here prevents anything - it is the sentence that stops the refusal at the next screen being a surprise. -
A
dyrrconsole command.dyrrprints what the door is doing here: the world, whether Enforce is on, which checks are live, how many connections have been refused this session, and the standing verdict for every player currently connected. That last part is the point of Enforce being off - it is meant to be sat in while deciding, and until now it answered "who would stop being able to play?" one line at a time into a log, at the moment each player connected. On a server the report is written to the BepInEx log as well, because a console scrolls and a log file does not.dyrr homelists this machine's character bindings, anddyrr forget <id>unbinds one without going and finding the file. Neither is a cheat command and neither is admin-gated: the report is the server's own state to whoever is already at its console, and the bindings are this machine's own file. -
dyrr-home.txtnow carries the character and world names beside the two ids, and the refusal popup names the world rather than quoting a bare 19-digit number. Nothing is ever matched on the names - two fields still load, four still load on an older build - but the one action this mod asks of a player is "delete the line starting with your character's id", and that is much harder when every line looks the same. -
Four more ways to see a cheat, because one flag is one thing to clear.
RefuseCheatsreadm_usedCheats, a bool set inTerminal.ConsoleCommand.RunAction. A mod that switches devcommands on will trip it; a mod that also clears it will not. So the client now reports the records the game keeps beside it, written at different moments by different code:PlayerStatType.Cheats, a counter incremented on the line after the flag. Flag clear and counter above zero is not a suspicion, it is a record that has been edited.m_knownCommands, the name of every console command the character has ever run, written a few lines further down and outside the branch that sets the flag. New settingRefuseCheatCommands, and the refusal names the command - "has run 'spawn'" is a fact somebody can answer where "used cheats" is only an accusation.m_knownWorlds, worlds by name at save time, againstm_worldData's uids at spawn time. More names than uids means the travel record was scrubbed. The inequality only runs one way, so the game itself cannot trip it.
The last two are
RefuseTampered, on by default. It is the only check here that does not need the client to be honest, only consistent - and consistency across four records written by four pieces of code is a different job from clearing one bool.Which commands count as cheats is decided on the server, from the server's own command table, so a cheat command added by some other mod counts for free. The table is only built when a console exists, which a dedicated server has no guarantee of, so there is a fallback list of all 73 vanilla
isCheat: truecommands - ripped out ofTerminal.InitTerminalrather than typed from memory. -
The client's mod list, judged by the server.
RefuseMods, on by default, withModPolicyatAllow: the plugins the server itself runs are always fine, anything else has to be named inAllowedMods.Denyinverts it for a server that only wants to name what it will not have.This is the check that actually reaches cheating on a dedicated server, and the reason is one line of vanilla:
Console.IsCheatsEnabledreturnsZNet.instance.IsServer(). A client's owndevcommandsis inert on somebody else's server - it flips a bool the gate then ignores - so anybody cheating there is necessarily running a mod that patched around it. What a character did in the past is a weaker question than what the client is running now.Both lists ship empty, and
DeniedModscould not honestly ship otherwise: a list of cheat mod GUIDs written in advance is stale the week after and reads as complete when it is not. Every plugin a client brings that the server does not run is written to the log as it connects, admitted or not, which is what those lists get built from.Self-reported, like everything else here. A purpose-built client can lie about all of it.
-
The refusal message claimed which check had fired, and was usually wrong.
RefusedMessagedefaulted to "This server only accepts characters that have never played anywhere else" and the specific reason was appended in brackets after it. That was true when travel was the only thing that could refuse anybody. With six checks it became a lie on screen: a client turned away for running a mod was told its character had played somewhere else, with the real reason sitting in a parenthesis reading like an aside on the sentence that contradicted it. Seen exactly that way on a dev server.The default is now "This server refused this connection." and the reason follows as its own sentence starting "It", which reads correctly for every check - It is running 'x', It has played on 2 other world(s), It has run cheat command 'god'. A fixed sentence must not claim which of six checks fired.
Fixed
-
A hand edit to
dyrr-home.txtwas silently undone. The file was read once per process and the whole set written back on every bind, so deleting a line - the documented fix for a wrong binding, and what the refusal popup tells you to do - was reverted by the next character that bound. It is now re-read whenever it changes on disk, so the file the popup points at is the file that is actually read, and the edit can be made without restarting. -
Home.Forgetexisted and nothing called it. It isdyrr forgetnow.
[1.0.0] - 2026-08-18
First published release.
The number means published and nothing else. It sat at 0.9.0 while the repo was public and Thunderstore had nothing on it, because a 1.0 with no package behind it claims a release that does not exist, and because a Thunderstore version can never be reissued once uploaded - so the number had to be spent on the build that actually ships rather than on one that drifted away from it on disk.
Everything below this line was already true at 0.9.0. Nothing about the mod's behaviour changed to get here.
Renamed from Threshold, 2026-08-18
Dyrr is Old Norse for the doorway itself, which is both what the mod guards and a better fit beside Vaettir, Nidling and Rist than an English abstract noun was.
Renaming before publishing rather than after is the entire reason it was cheap. Nothing was on Thunderstore to leave stranded, and this mod registers no prefabs, so there are no saved ZDOs keyed on a name that would stop resolving. Two things did have to be carried over by hand, and both fail silently rather than loudly if forgotten:
- The config file. BepInEx names it after the plugin GUID, so a rename hands everyone a
file of defaults. That is not merely lost preferences:
Enforcedefaults to off, so a server that had the door on would quietly have it off with nothing said. The plugin now copiesezomic.valheim.threshold.cfgto its new name on first run, before binding anything, and leaves the old file alone. - The character bindings.
threshold-home.txtis adopted the same wayboon-home.txtalready was, and the fallback is now an ordered chain rather than a single name, so a machine that skipped a release cannot fall between the two. Losing these would hand every character one free trip to another world, silently and exactly once, and that trip has no undo.
Both were then confirmed on a real run rather than left to reasoning: all seven settings and every binding line came across, and both original files were still in place afterwards. The adoption also correctly does not repeat on the next launch.
Fixed since the split
- The dedicated server no longer binds a character it does not have. A server has a
PlayerProfileobject with nobody behind it, so the binding ran there too and minted a fresh phantom id on every startup, one junk line per restart, forever. Binding now requires a local player, which is also the more correct moment: a character has not played anywhere until it spawns. - Bindings are adopted from Rist's
rist-home.txton first run ifdyrr-home.txtdoes not exist. Without that, moving the feature between mods would have silently unbound every character on the machine and handed everyone one free trip to another world.
Verified against a real dedicated server
Both branches, which matters more than it sounds. 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. Until the second one happened, "works" and "refuses everybody" were indistinguishable, because every test until then used a character that had genuinely travelled.
Also confirmed: the menu guard's binding, the adoption of bindings from Rist's old
rist-home.txt, and restoring a character backup as the documented recovery. A refused
character came back from backup and was admitted.
Every server must enforce
Found by running two servers, one enforcing and one not. The menu guard covers local worlds
only; the door covers servers only where Enforce is on. A non-enforcing server is therefore a
hole a bound character walks into, and all the mod can do afterwards is log "too late to stop
it - that world is now written into the character". The lenient server is the one that ruins
the character.
Enforce on every server, and keep a separate character for each.
Known limits
RefuseCheatsis untested; it needs a character deliberately flagged bydevcommands.
[0.1.0] - 2026-08-16
Split out of Rist, where it never belonged.
The line this sits on
Who comes through the door is server policy. It is not a levelling mod's decision.
Rist awards character levels for skill gains, so it wanted to know whether a character's skills were earned here, and answered by refusing the connection. That put an XP system in charge of who is allowed to play, and when it fired the player got Valheim's generic kick screen with the reason written only to the server's log, which on someone else's server they can never read. The first time it happened, the person affected blamed an unrelated mod.
Added
- The door. Connections are refused for characters that have spawned in another world
(
RefuseOtherWorlds), that the game has flagged for cheats (RefuseCheats), or that answered nothing at all (RefuseUnreported). - The menu guard, which is the half that actually protects you. Refusing at the door is
the lesser half: by then the harm is done, because loading a character into any world writes
that world into its profile permanently. So the client also refuses to start a local world
with a character belonging to a different one, at
FejdStartup.OnWorldStart, the last moment anything can be done about it. - The refusal is shown on screen, on both machines, under the stock kicked line, not written to a log the person who needs it cannot read.
- A home file per character,
BepInEx/config/dyrr-home.txt, binding each character to the first world that accepted it. Plain text on purpose: it is protection rather than enforcement, and editing it can only damage your own character.
Enforce is off by default, and that is deliberate
This is the one setting in the family that can lock people out of a server, including you.
The game never removes entries from a character's world list, so one visit anywhere else is
permanent for that character file, and restoring a backup from before the trip is the only way
back in. The cheat flag behaves the same way: set by devcommands, never cleared.
Turning it on should be an admin deciding, having read that paragraph, not something that happens because a mod got installed.
Verified
Refused a real connection on a dedicated server, logged the reason on both machines, and showed
it on the refusal panel. Not yet verified: admitting a genuinely clean character, and
RefuseCheats.