EasyLogin
[AI-generated] Remembers each server's join password and auto-fills/auto-submits it. Per-server records, auto-forget on wrong password, DPAPI-encrypted. Client-only, no server sync.EasyLogin —— Valheim 联机密码管家(纯客户端)
AI 生成声明:本模组由 AI 智能体(WorkBuddy)在用户的明确需求与逐项验收下编写; 功能设计、真机测试与发布决定均由用户主导。
进过一次的服务器,密码自动记住;下次再进自动填/自动提交。
Valheim 原版没有这个能力——不是没做好,是根本没写。本模组用三个 Harmony 补丁补上, 不改游戏数据、不加网络校验、主机和队友装不装都完全不受影响。
一、效果
| 场景 | 原版 | 装了 EasyLogin |
|---|---|---|
| 第一次进某人的带密码服务器 | 手输密码 | 照常手输(模组会记下来) |
| 第二次进同一台服务器 | 再输一遍 | 自动填好并提交,直接进 |
| 房主改了密码 | 提示密码错误 | 自动忘掉旧密码,下次让你重新输(然后记住新的) |
| 换另一台服务器 | 每次都要输 | 各存各的,互不干扰 |
每个服务器分开记录,匹配依据是 Steam ID / 跨平台 host ID / 房间码 / IP:端口, 不是"上一次输的密码"这种全局变量。
二、安装
把 EasyLogin.dll 丢进 BepInEx/plugins/ 即可(或者用 r2modman 导入 EasyLogin-1.0.0.zip)。
依赖只有 BepInExPack_Valheim。不需要 Jotunn,不需要任何前置框架。
三、原版为什么做不到(IL 实证)
进服密码唯一的入口是 ZNet.RPC_ClientHandshake(ZRpc rpc, bool needPassword, string serverPasswordSalt),
由服务端在握手时发起。1.0 正式版里的实现(assembly_valheim.dll,IL 偏移为函数内相对偏移):
0000 m_connectingDialog.gameObject.SetActive(false)
0011 m_serverPasswordSalt = serverPasswordSalt ← 服务端下发的随机盐
0017 if (!needPassword) { SendPeerInfo(rpc, ""); return; } → 0077
001A m_passwordDialog.gameObject.SetActive(true) ← 弹出要求输密码的框
002B var input = m_passwordDialog.GetComponent<GuiInputField>()
0037 input.text = ""
0042 input.ActivateInputField()
0047 input.OnInputSubmit.AddListener(OnPasswordEntered)
005D m_tempPasswordRPC = rpc
0064 call FejdStartup::get_ServerPassword
0069 brfalse.s → 0083 ← 原版唯一的"自动填充"
006B OnPasswordEntered(FejdStartup.ServerPassword)
也就是说:原版确实内置了自动填充,条件是 FejdStartup.ServerPassword != null。
那这个 ServerPassword 是谁写的?查全程序集的引用:
引用了 ['FejdStartup::set_ServerPassword'] 的方法共 1 个:
FejdStartup ParseArguments
只有 ParseArguments 一处——也就是命令行参数。FejdStartup.ParseArguments 里认的正是
程序集字符串堆里那个 -password(另有 $error_password / $menu_passwordinvalid 等提示文案)。
所以只有像 valheim.exe -password xxx 这样启动才会自动填,
正常点着玩的时候这个字段永远是 null,每次都得手打。
顺带一个必须知道的细节:密码是 MD5(ASCII(明文 + 盐))(ZNet.HashPassword,IL 就是
Encoding.ASCII.GetBytes(password + salt) → MD5CryptoServiceProvider.ComputeHash),
而盐是服务端每次握手现生成的 16 字节随机数(ZNet.ServerPasswordSalt() 里
RandomNumberGenerator.GetBytes(16))。
所以本地没法预先算好哈希存起来,只能存明文 —— 任何"记住联机密码"的模组都绕不开这一点。
本模组会尽量别让它以明文躺在硬盘上(见第五节)。
四、三个补丁(全部只改本地行为)
| 补丁 | 目标方法 | 做什么 |
|---|---|---|
| ① 自动填 | ZNet.RPC_ClientHandshake postfix |
需要密码、弹窗还开着、本地存过 → 调原版 ZNet.OnPasswordEntered(pwd),完全复用原版那条提交路径(含 MD5+盐),不绕过任何校验 |
| ② 记录 | ZNet.OnPasswordEntered postfix |
把这次真正用到的密码记下来(首次手输、后来改的密码、自动提交都经过这里) |
| ③ 遗忘 | ZNet.RPC_Error postfix |
error == 6 时删掉该服务器的记录 |
补丁 ① 里那个"弹窗还开着"的判断用的是 ZNet.InPasswordDialog(),
正好把"原版已经靠 -password 填过了"这种情况排除掉,不会重复提交。
补丁 ③ 的 6 是 ConnectionStatus.ErrorPassword。服务端校验在 ZNet.RPC_PeerInfo 里:
0411 ldsfld ZNet::m_serverPassword ← 服务端设的密码
0416 ldloc.s 13 ← 客户端发来的哈希
0418 String::op_Inequality
041D brfalse → 04C8 ← 相等就放行
048E ZRpc::Invoke("Error", (int)6) ← 不等就发 Error(6)
客户端 ZNet.RPC_Error(rpc, int error) 的实现只有 m_connectionStatus = error + 一行日志。
⚠️
ConnectionStatus这个枚举在程序集里是tdNotPublic, C# 里根本写不出ConnectionStatus.ErrorPassword(编译期就报"找不到类型"), 所以源码里只能写魔法数字 6。
这一条特别重要:不删记录的话,房主一改密码,你就会永远自动填那个旧密码、
连密码框都不弹出来,直接把自己锁在门外,只能手动去删 json。
如果实在不想让它忘,配置里的 ForgetOnPasswordError 可以关掉(不推荐)。
五、服务器是怎么认出来的
ZNet 上有三个 SetServerHost 重载,全程序集只有 FejdStartup.JoinServer 会调用
(三个 lambda:<JoinServer>b__74_5、<>c__DisplayClass74_0::<JoinServer>b__4、
<>c__DisplayClass74_1::<JoinServer>b__6),而且每次开头都先 ZNet.ResetServerHost() 清一遍。
所以在"服务端要求输密码"那一刻,这些字段必然就是当前这台服务器的:
| 入口 | 写入的字段 | 本模组的 key |
|---|---|---|
| Steam 联机(好友/服务器列表) | m_serverSteamID (ulong) |
steam:<steamid> |
| 跨平台联机 | m_serverPlayFabPlayerId (string) |
playfab:<id> |
| 跨平台联机(没有 host ID 时) | ZPlayFabMatchmaking.JoinCode |
code:<房间码> |
| 直连 IP / 专用服务器 | m_serverHost + m_serverHostPort |
ip:<ip>:<端口> |
ZNet.m_onlineBackend(OnlineBackendType:0=Steamworks、1=PlayFab、2=EOS、3=CustomSocket)
用来判定走哪条路,避免把上一次联机的房间码套到这一次上。
一个必须说清楚的限制:服务器名字当不了判据
ZNet.m_ServerName 全程序集只有三处写入 —— SetServer、OpenServer、
<OnSteamServerRegistered>...MoveNext,全是开服侧。
客户端走的是 FejdStartup.JoinServer 里的 ZNet.SetServer(false, false, false, "", "", null),
名字传的是空串,客户端这个字段永远是空的。
客户端要拿名字只能用 ZNet.GetWorldName()(实现就是 m_world == null ? null : m_world.m_name),
而 m_world 是认证成功之后由 RPC_PeerInfo 建起来的 ——
也就是说握手那一刻根本不知道服务器叫什么,名字只能事后回填。
所以:
servers.json里的ServerName只是给你看的标签(认证成功后几秒内回填),- 它不参与匹配。想按名字认服务器的方案在这个 API 下走不通。
六、密码怎么存
文件:BepInEx/config/EasyLogin/servers.json(UTF-8 无 BOM,手工可编辑)
{
"Version": 1,
"Comment": "EasyLogin 自动生成。Note 字段随便写,模组不会动它;Password 字段带前缀,别手改前缀。",
"Records": [
{
"Key": "steam:76561198000000001",
"Kind": "Steam",
"SteamId": "76561198000000001",
"PlayFabId": "",
"Host": "",
"Port": 0,
"JoinCode": "",
"ServerName": "老王的英灵殿",
"Password": "dpapi:AQAAANCMnd8BFdERjHoAwE/Cl+sBAAAA...",
"LastJoinedUtc": 1789488735,
"LastJoinedLocal": "2026-09-16 00:12:15",
"UseCount": 3,
"Note": ""
}
]
}
Password 字段带前缀,三种格式可以共存:
| 前缀 | 含义 | 换电脑能读吗 |
|---|---|---|
dpapi: |
Windows DPAPI(ProtectedData,CurrentUser 作用域),真正用你当前 Windows 账号的密钥加密 |
❌ 不能 |
obf: |
固定密钥异或 + Base64。只是防肩窥,不是加密 | ✅ 能 |
plain: |
明文。只能手工改出来(模组自己不会写) | ✅ 能 |
游戏 Managed 目录下自带 System.Security.dll,实测里面有
System.Security.Cryptography.ProtectedData.Protect/Unprotect,所以默认走 DPAPI。
万一运行环境不支持,会自动退化到 obf: 并打一行警告,功能不受影响
(Secret.cs 里把 DPAPI 的两个调用隔离成独立方法,就是为了让类型解析失败的异常能被 catch 到)。
想完全明文(方便自己看/改):把某条记录的
"Password": "dpapi:xxx"手工改成"Password": "plain:你的密码"即可,模组认这个前缀。
为什么不用 Newtonsoft.Json(游戏自带 13.0.0):实测把游戏那份拿到 .NET Framework 下加载会
FileLoadException / 0xCORSEC_E_INVALID_STRONGNAME(强名称验证失败)。
Mono 一般不校验强名称、理论上没问题,但这一点我没法离线证实 ——
万一真加载不了整个模组就废了。所以改成自带的 src/Json.cs(一百来行),
好处是所有逻辑都能离线跑测试。UnityEngine.JsonUtility 是原生实现,脱离 Unity 跑不了,同样排除。
七、配置(BepInEx/config/easylogin.client.only.cfg)
| 分区 | 键 | 默认 | 说明 |
|---|---|---|---|
| 1 - 自动登录 | Enabled |
true |
总开关 |
| 1 - 自动登录 | AutoSubmit |
true |
true 直接提交;false 只把密码填进输入框,等你按回车 |
| 1 - 自动登录 | ForgetOnPasswordError |
true |
服务器说密码不对就删记录。强烈建议保持 true,否则改密码后会被锁在门外 |
| 1 - 自动登录 | RememberManualInput |
true |
手动输的密码也记下来;关掉后新服务器不新增记录(已有的仍会更新) |
| 2 - 服务器识别 | MatchByJoinCode |
true |
跨平台后端下用房间码兜底匹配 |
| 3 - 存储 | EncryptWithWindowsUserKey |
true |
用 DPAPI 加密;关掉则退化为 obf: 混淆(可跨机移植) |
| 3 - 存储 | ShowPasswordInLog |
false |
把密码原文打进 BepInEx 日志,只在排错时开 |
| 4 - 日志 | VerboseLog |
false |
每次识别服务器都打一行 key / 房间码 / IP |
八、故障排查
启动时 BepInEx/LogOutput.log 里应该能看到(这是自证清单,出问题先看这几行):
EasyLogin 1.0.0 开始挂载 Harmony 补丁……存储文件:...\BepInEx\config\EasyLogin\servers.json
已挂载补丁类:Patch_ClientHandshake
已挂载补丁类:Patch_OnPasswordEntered
已挂载补丁类:Patch_RpcError
Harmony 补丁挂载完成:3/3 成功。
---- 补丁挂载自检 ----
ZNet.RPC_ClientHandshake() prefix=0 postfix=1 OK
ZNet.OnPasswordEntered() prefix=0 postfix=1 OK
ZNet.RPC_Error() prefix=0 postfix=1 OK
-----------------------
EasyLogin 就绪:已载入 N 条服务器密码记录。
| 现象 | 看日志里有没有这句 | 原因 |
|---|---|---|
| 完全没反应 | 没有 开始挂载 Harmony 补丁 |
DLL 没装上,或不是这个版本 |
| 补丁没挂上 | 某行是 !! 未挂上 !! |
游戏版本变了,方法签名对不上 |
| 不自动填 | 没存过这个服务器的密码,等你手输:... |
正常,第一次就该手输 |
| 不自动填 | 有记录但密码解不出来 |
换过 Windows 账号/电脑,DPAPI 解不开 → 删掉该条重输 |
| 不自动填 | 识别不出当前服务器的身份(后端 ...) |
Steam ID / host ID / IP / 房间码全是空的,把 VerboseLog 打开看细节 |
| 老说密码错 | 服务器 ... 报告密码错误 |
房主改密码了 → 记录已自动删除,重进一次手输新密码即可 |
| DPAPI 不能用 | DPAPI 不可用,退化为简单混淆存储 |
已自动退化,功能正常 |
九、联机安全
- 没有
[NetworkCompatibility]声明(=NotEnforced,不参与连接校验) - 没有 Jotunn 依赖,没有内嵌 ServerSync / ConditionalConfigSync
- 不碰任何游戏数据、不改
ObjectDB、不发任何自定义网络包
本模组做的只是"把你自己知道的密码重新敲一遍",所以:主机不装、队友不装,都不影响你进服,
也不影响别人进你的服。(判据见 valheim-mod-compat-check 技能的实测结论。)
十、已知边界
- 只处理"服务端要求密码"这一条路(
RPC_ClientHandshake)。这是 Valheim 唯一的联机密码入口。 - 专用服务器(无头服务端)的密码是服务端自己配的,客户端这边一样适用。
ServerName只能事后回填,认证失败时会是空的 —— 不影响匹配。- 房间码(
ZPlayFabMatchmaking.JoinCode)是反射读的:直接静态引用那个类会牵出整套 PlayFab 初始化,反射更省事也更安全。读不到就当作没有,Steam / 直连 IP 两条路不受影响。
十一、开发与验证
EasyLogin/
├── EasyLogin.csproj
├── src/
│ ├── EasyLoginPlugin.cs 插件本体、配置项、补丁挂载 + 自检
│ ├── Patches.cs 三个 Harmony 补丁(含 IL 实证注释)
│ ├── ServerIdentity.cs 服务器识别(反射读 ZNet 私有静态字段)
│ ├── PasswordStore.cs servers.json 读写 + 匹配/遗忘
│ ├── Secret.cs DPAPI / 混淆 / 明文 三种存储
│ └── Json.cs 零依赖 JSON 编解码
├── _test/ 离线测试工程(不进模组)
├── _tools/ dnfile 查 API 用的小脚本
├── _make_icon.py 手写 PNG 图标(带像素自检)
└── _build.py 打包 dist/ + 写进 r2modman
离线测试(不进游戏就能验证核心逻辑,41 项检查):
cd _test && dotnet build -c Release && ./bin/Release/EasyLoginTests.exe
覆盖:DPAPI/混淆/明文的往返、自写 JSON 的转义与容错(BOM/CRLF/多余字段/坏输入)、
写入→读回→匹配→遗忘→回填、含引号换行中文的密码、手工改成 plain: 前缀、坏文件优雅降级。
交付前自查:
python <skills>/valheim-mod-dev/scripts/verify_dll.py bin/Release/EasyLogin.dll
python <skills>/valheim-mod-compat-check/scripts/scan_compat.py bin/Release
期望:3 个类级 [HarmonyPatch]、依赖表干净(无 Jotunn)、无 NetworkCompatibility 声明。
