From 2398f87ed835b5354afd61c87f9d6c3965cec31e Mon Sep 17 00:00:00 2001
From: jneb802 <73610029+jneb802@users.noreply.github.com>
Date: Tue, 29 Sep 2026 22:50:51 -0700
Subject: [PATCH 1/4] Persist boat password access and protect boat storage
---
PraetorisClient.csproj | 1 +
README.md | 4 +-
Source/Network/RpcNames.cs | 6 +-
Source/README.md | 2 +-
Source/ShipPassword/ShipPasswordData.cs | 23 ++++
.../ShipPassword/ShipPasswordInteraction.cs | 71 ++++++++++-
Source/ShipPassword/ShipPasswordRpc.cs | 55 +++++++--
Source/ShipPassword/ShipPasswordStorage.cs | 114 ++++++++++++++++++
Source/Ships/ShipRudderOwnershipPatch.cs | 7 ++
Tests/ShipPassword/Program.cs | 67 ++++++++++
Tests/ShipPassword/README.md | 32 +++++
Tests/ShipPassword/ShipPassword.Tests.csproj | 11 ++
12 files changed, 379 insertions(+), 14 deletions(-)
create mode 100644 Source/ShipPassword/ShipPasswordStorage.cs
create mode 100644 Tests/ShipPassword/Program.cs
create mode 100644 Tests/ShipPassword/README.md
create mode 100644 Tests/ShipPassword/ShipPassword.Tests.csproj
diff --git a/PraetorisClient.csproj b/PraetorisClient.csproj
index 8572624..27b40c5 100644
--- a/PraetorisClient.csproj
+++ b/PraetorisClient.csproj
@@ -49,6 +49,7 @@
+
PraetorisClient.GuideImages.%(Filename)%(Extension)
diff --git a/README.md b/README.md
index cb3f806..55cb6ec 100644
--- a/README.md
+++ b/README.md
@@ -59,7 +59,9 @@ PraetorisClient is the shared client-and-server mod for Praetoris-specific gamep
- Prevents building in selected Hildir locations, crypts, caves, and Mistlands Dvergr entrances.
- Prevents the specific attackerless water-impact damage applied to boats. Other boat damage still applies.
- Lets a ship creator set, change, or clear a helm password with alternate use at the helm.
-- Requires the password each time a player takes control of a protected ship. Passengers can still board the ship.
+- Setting a password also saves access for the creator.
+- Entering the password once grants that character access to the helm and boat storage until the boat is destroyed. Access survives reconnects, restarts, and password changes. Passengers can still board the ship.
+- Boat storage uses the same password. Players without saved access cannot open, stack into, or take all items from protected boat storage.
- Stores a salted password verifier in the ship ZDO. Destroying the ship removes the password with the ZDO.
- Hides area damage numbers on building pieces and non-player damage numbers on trees and logs.
- Preserves player combat damage numbers. This display option does not change damage.
diff --git a/Source/Network/RpcNames.cs b/Source/Network/RpcNames.cs
index 7ec518d..6af87e7 100644
--- a/Source/Network/RpcNames.cs
+++ b/Source/Network/RpcNames.cs
@@ -21,9 +21,11 @@ internal static class RpcNames
public const string ServerChestCommandRequest = "PraetorisClient_ServerChest_CommandRequest";
public const string ServerChestCommandResponse = "PraetorisClient_ServerChest_CommandResponse";
public const string ShipPasswordSetRequest = "PraetorisClient_ShipPassword_SetRequest";
- public const string ShipPasswordSetGrant = "PraetorisClient_ShipPassword_SetGrant";
+ public const string ShipPasswordSetGrant = "PraetorisClient_ShipPassword_SetGrantV2";
public const string ShipPasswordControlRequest = "PraetorisClient_ShipPassword_ControlRequest";
- public const string ShipPasswordControlGrant = "PraetorisClient_ShipPassword_ControlGrant";
+ public const string ShipPasswordControlGrant = "PraetorisClient_ShipPassword_ControlGrantV2";
+ public const string ShipPasswordStorageRequest = "PraetorisClient_ShipPassword_StorageRequest";
+ public const string ShipPasswordStorageGrant = "PraetorisClient_ShipPassword_StorageGrant";
public const string ShipPasswordOwnerResponse = "PraetorisClient_ShipPassword_OwnerResponse";
public const string ShipPasswordResponse = "PraetorisClient_ShipPassword_Response";
public const string MaintenanceNotice = "PraetorisClient_MaintenanceNotice";
diff --git a/Source/README.md b/Source/README.md
index 7768e28..08f0dcb 100644
--- a/Source/README.md
+++ b/Source/README.md
@@ -6,7 +6,7 @@
- `Network/` - shared RPC names, RPC wiring, and send-state helpers.
- `Patches/` - general Valheim patches that do not yet belong to a narrower feature area.
- `Siege/` - siege portal bridge and related test command.
-- `ShipPassword/` - server-validated ship helm password setup and control access.
+- `ShipPassword/` - server-validated ship passwords and persistent access to the helm and storage.
- `Telemetry/Common/` - shared telemetry serialization, math, and runtime metadata.
- `Telemetry/FrameMetrics/` - local frame-time measurement.
- `Telemetry/RpcTrace/` - local storage, RPC probes, and socket metrics.
diff --git a/Source/ShipPassword/ShipPasswordData.cs b/Source/ShipPassword/ShipPasswordData.cs
index 075bbcc..0c6675d 100644
--- a/Source/ShipPassword/ShipPasswordData.cs
+++ b/Source/ShipPassword/ShipPasswordData.cs
@@ -1,4 +1,5 @@
using System;
+using System.Globalization;
using System.Security.Cryptography;
namespace PraetorisClient.ShipPasswordFeature
@@ -12,6 +13,28 @@ internal static class ShipPasswordData
internal static readonly int SaltHash = "PraetorisShipPassword_salt".GetStableHashCode();
internal static readonly int VerifierHash = "PraetorisShipPassword_verifier".GetStableHashCode();
+ private static readonly int AuthorizedPlayersHash = "PraetorisShipPassword_players".GetStableHashCode();
+
+ internal static bool HasAccess(ZDO? zdo, long playerId)
+ {
+ if (zdo == null || playerId == 0L)
+ {
+ return false;
+ }
+
+ string entry = "|" + playerId.ToString(CultureInfo.InvariantCulture) + "|";
+ return zdo.GetString(AuthorizedPlayersHash, "").Contains(entry);
+ }
+
+ internal static void GrantAccess(ZDO zdo, long playerId)
+ {
+ if (playerId != 0L && !HasAccess(zdo, playerId))
+ {
+ // The boat owns this record. Password changes preserve it; destruction removes it.
+ zdo.Set(AuthorizedPlayersHash, zdo.GetString(AuthorizedPlayersHash, "") +
+ "|" + playerId.ToString(CultureInfo.InvariantCulture) + "|");
+ }
+ }
internal static bool IsProtected(ZDO? zdo)
{
diff --git a/Source/ShipPassword/ShipPasswordInteraction.cs b/Source/ShipPassword/ShipPasswordInteraction.cs
index 58e3b4e..5303979 100644
--- a/Source/ShipPassword/ShipPasswordInteraction.cs
+++ b/Source/ShipPassword/ShipPasswordInteraction.cs
@@ -10,11 +10,14 @@ private enum InputMode
{
None,
SetPassword,
- EnterPassword
+ EnterPassword,
+ EnterStoragePassword
}
private ShipControlls _controls = null!;
private InputMode _mode;
+ private Container? _container;
+ private float _storageRetryUntil;
private void Awake()
{
@@ -45,9 +48,57 @@ internal bool BeginEnterPassword(Player player)
return false;
}
+ ZNetView nview = _controls.m_ship.GetComponent();
+ if (nview != null && nview.IsValid() && ShipPasswordData.HasAccess(nview.GetZDO(), player.GetPlayerID()))
+ {
+ ShipPasswordRpc.RequestControl(nview.GetZDO().m_uid, "");
+ return false;
+ }
+
return Open(InputMode.EnterPassword, "Enter ship password");
}
+ internal bool BeginStoragePassword(Player player, Container container)
+ {
+ if (player != Player.m_localPlayer || !InStorageRange(player, container))
+ {
+ return false;
+ }
+
+ _container = container;
+ _storageRetryUntil = 0f;
+ return Open(InputMode.EnterStoragePassword, "Enter ship password");
+ }
+
+ private void Update()
+ {
+ if (_storageRetryUntil == 0f)
+ {
+ return;
+ }
+
+ Player player = Player.m_localPlayer;
+ if (Time.realtimeSinceStartup > _storageRetryUntil || _container == null || player == null ||
+ !InStorageRange(player, _container))
+ {
+ _storageRetryUntil = 0f;
+ return;
+ }
+
+ ZDO? zdo = ShipPasswordStorage.GetShipZdo(_container!);
+ if (ShipPasswordData.HasAccess(zdo, player.GetPlayerID()))
+ {
+ _storageRetryUntil = 0f;
+ _container.Interact(player, false, false);
+ }
+ }
+
+ private static bool InStorageRange(Player player, Container container)
+ {
+ return Vector3.Distance(player.transform.position, container.transform.position) <=
+ player.m_maxInteractDistance + container.m_hoverOffset;
+ }
+
public string GetText()
{
return "";
@@ -70,7 +121,7 @@ public void SetText(string text)
{
ShipPasswordRpc.RequestSetPassword(nview.GetZDO().m_uid, password);
}
- else if (mode == InputMode.EnterPassword)
+ else if (mode == InputMode.EnterPassword || mode == InputMode.EnterStoragePassword)
{
if (password.Length == 0)
{
@@ -78,7 +129,21 @@ public void SetText(string text)
return;
}
- ShipPasswordRpc.RequestControl(nview.GetZDO().m_uid, password);
+ if (mode == InputMode.EnterStoragePassword)
+ {
+ if (_container == null || Player.m_localPlayer == null ||
+ !InStorageRange(Player.m_localPlayer, _container))
+ {
+ return;
+ }
+
+ _storageRetryUntil = Time.realtimeSinceStartup + 10f;
+ ShipPasswordRpc.RequestStorageAccess(nview.GetZDO().m_uid, password);
+ }
+ else
+ {
+ ShipPasswordRpc.RequestControl(nview.GetZDO().m_uid, password);
+ }
}
}
diff --git a/Source/ShipPassword/ShipPasswordRpc.cs b/Source/ShipPassword/ShipPasswordRpc.cs
index abfed1e..24b2750 100644
--- a/Source/ShipPassword/ShipPasswordRpc.cs
+++ b/Source/ShipPassword/ShipPasswordRpc.cs
@@ -19,6 +19,10 @@ internal static void Register(ZRoutedRpc rpc)
rpc.Register(RpcNames.ShipPasswordSetGrant, OnSetGrant);
rpc.Register(RpcNames.ShipPasswordControlRequest, OnControlRequest);
rpc.Register(RpcNames.ShipPasswordControlGrant, OnControlGrant);
+ rpc.Register(RpcNames.ShipPasswordStorageRequest,
+ (sender, package) => OnAccessRequest(sender, package, false));
+ rpc.Register(RpcNames.ShipPasswordStorageGrant,
+ (sender, package) => OnAccessGrant(sender, package, false));
rpc.Register(RpcNames.ShipPasswordOwnerResponse, OnOwnerResponse);
rpc.Register(RpcNames.ShipPasswordResponse, OnResponse);
}
@@ -33,6 +37,11 @@ internal static void RequestControl(ZDOID shipId, string password)
SendRequest(RpcNames.ShipPasswordControlRequest, shipId, password);
}
+ internal static void RequestStorageAccess(ZDOID shipId, string password)
+ {
+ SendRequest(RpcNames.ShipPasswordStorageRequest, shipId, password);
+ }
+
private static void SendRequest(string rpcName, ZDOID shipId, string password)
{
if (ZRoutedRpc.instance == null)
@@ -65,7 +74,7 @@ private static void OnSetRequest(long sender, ZPackage package)
return;
}
- if (!TryGetAuthorizedShip(sender, shipId, creatorRequired: true, out ZDO? shipZdo, out _))
+ if (!TryGetAuthorizedShip(sender, shipId, creatorRequired: true, out ZDO? shipZdo, out long playerId))
{
SendResponse(sender, false, "Only the nearby ship creator can change its password.");
return;
@@ -84,11 +93,17 @@ private static void OnSetRequest(long sender, ZPackage package)
grant.Write(sender);
grant.Write(saltValue);
grant.Write(verifierValue);
+ grant.Write(playerId);
RegisterPendingOwnerResponse(ownerPeerId, sender, shipId);
ZRoutedRpc.instance.InvokeRoutedRPC(ownerPeerId, RpcNames.ShipPasswordSetGrant, grant);
}
private static void OnControlRequest(long sender, ZPackage package)
+ {
+ OnAccessRequest(sender, package, true);
+ }
+
+ private static void OnAccessRequest(long sender, ZPackage package, bool takeControl)
{
if (!IsServer())
{
@@ -99,7 +114,7 @@ private static void OnControlRequest(long sender, ZPackage package)
string password = package.ReadString();
if (!TryGetAuthorizedShip(sender, shipId, creatorRequired: false, out ZDO? shipZdo, out long playerId))
{
- SendResponse(sender, false, "You must be aboard the ship to use its helm.");
+ SendResponse(sender, false, "You must be near the ship to request access.");
return;
}
@@ -109,7 +124,9 @@ private static void OnControlRequest(long sender, ZPackage package)
return;
}
- if (!ShipPasswordData.IsProtected(shipZdo) || !ShipPasswordData.Verify(shipZdo!, password))
+ if (password.Length > ShipPasswordData.MaximumPasswordLength ||
+ !ShipPasswordData.IsProtected(shipZdo) ||
+ (!ShipPasswordData.HasAccess(shipZdo, playerId) && !ShipPasswordData.Verify(shipZdo!, password)))
{
PraetorisClientPlugin.Log.LogInfo("Rejected ship password for " + shipId + ".");
SendResponse(sender, false, "Incorrect ship password.");
@@ -122,8 +139,10 @@ private static void OnControlRequest(long sender, ZPackage package)
grant.Write(shipId);
grant.Write(sender);
grant.Write(playerId);
+ grant.Write(shipZdo!.GetString(ShipPasswordData.VerifierHash, ""));
RegisterPendingOwnerResponse(ownerPeerId, sender, shipId);
- ZRoutedRpc.instance.InvokeRoutedRPC(ownerPeerId, RpcNames.ShipPasswordControlGrant, grant);
+ ZRoutedRpc.instance.InvokeRoutedRPC(ownerPeerId,
+ takeControl ? RpcNames.ShipPasswordControlGrant : RpcNames.ShipPasswordStorageGrant, grant);
PraetorisClientPlugin.Log.LogInfo("Accepted ship password for " + shipId + ".");
}
@@ -138,6 +157,7 @@ private static void OnSetGrant(long sender, ZPackage package)
long requestingPeerId = package.ReadLong();
string saltValue = package.ReadString();
string verifierValue = package.ReadString();
+ long playerId = package.ReadLong();
GameObject? shipObject = ZNetScene.instance != null ? ZNetScene.instance.FindInstance(shipId) : null;
ZNetView? nview = shipObject != null ? shipObject.GetComponent() : null;
if (nview == null || !nview.IsValid() || !nview.IsOwner())
@@ -148,6 +168,11 @@ private static void OnSetGrant(long sender, ZPackage package)
ShipPasswordData.ApplyVerifier(nview.GetZDO(), saltValue, verifierValue);
bool passwordSet = verifierValue.Length > 0;
+ if (passwordSet)
+ {
+ ShipPasswordData.GrantAccess(nview.GetZDO(), playerId);
+ ZDOMan.instance.ForceSendZDO(requestingPeerId, shipId);
+ }
PraetorisClientPlugin.Log.LogInfo(
(passwordSet ? "Set" : "Cleared") + " ship password for " + shipId + ".");
SendOwnerResponse(
@@ -158,6 +183,11 @@ private static void OnSetGrant(long sender, ZPackage package)
}
private static void OnControlGrant(long sender, ZPackage package)
+ {
+ OnAccessGrant(sender, package, true);
+ }
+
+ private static void OnAccessGrant(long sender, ZPackage package, bool takeControl)
{
if (ZRoutedRpc.instance == null || sender != ZRoutedRpc.instance.GetServerPeerID())
{
@@ -167,19 +197,30 @@ private static void OnControlGrant(long sender, ZPackage package)
ZDOID shipId = package.ReadZDOID();
long requestingPeerId = package.ReadLong();
long playerId = package.ReadLong();
+ string verifier = package.ReadString();
GameObject? shipObject = ZNetScene.instance != null ? ZNetScene.instance.FindInstance(shipId) : null;
Ship? ship = shipObject != null ? shipObject.GetComponent() : null;
ZNetView? nview = shipObject != null ? shipObject.GetComponent() : null;
ShipControlls? controls = ship != null ? ship.m_shipControlls : null;
- if (ship == null || nview == null || controls == null || !nview.IsOwner() ||
- !ShipPasswordData.IsProtected(nview.GetZDO()) || !ship.IsPlayerInBoat(playerId))
+ if (ship == null || nview == null || !nview.IsValid() || !nview.IsOwner() ||
+ !ShipPasswordData.IsProtected(nview.GetZDO()) ||
+ nview.GetZDO().GetString(ShipPasswordData.VerifierHash, "") != verifier ||
+ (takeControl && (controls == null || !ship.IsPlayerInBoat(playerId))))
{
SendOwnerResponse(shipId, requestingPeerId, false, "Ship control request expired. Try again.");
return;
}
- if (controls.GetUser() != playerId && controls.HaveValidUser())
+ ShipPasswordData.GrantAccess(nview.GetZDO(), playerId);
+ ZDOMan.instance.ForceSendZDO(requestingPeerId, shipId);
+ if (!takeControl)
+ {
+ SendOwnerResponse(shipId, requestingPeerId, true, "Ship access saved. You can use its helm and storage.");
+ return;
+ }
+
+ if (controls!.GetUser() != playerId && controls.HaveValidUser())
{
SendOwnerResponse(shipId, requestingPeerId, false, "$msg_inuse");
return;
diff --git a/Source/ShipPassword/ShipPasswordStorage.cs b/Source/ShipPassword/ShipPasswordStorage.cs
new file mode 100644
index 0000000..9de29d1
--- /dev/null
+++ b/Source/ShipPassword/ShipPasswordStorage.cs
@@ -0,0 +1,114 @@
+using HarmonyLib;
+using System.Collections.Generic;
+using System.Reflection;
+
+namespace PraetorisClient.ShipPasswordFeature
+{
+ internal static class ShipPasswordStorage
+ {
+ internal static Ship? GetShip(Container container)
+ {
+ Ship? ship = container.GetComponentInParent();
+ return ship != null ? ship : container.m_rootObjectOverride != null
+ ? container.m_rootObjectOverride.GetComponent() : null;
+ }
+
+ internal static ZDO? GetShipZdo(Container container)
+ {
+ Ship? ship = GetShip(container);
+ ZNetView? nview = ship != null ? ship.GetComponent() : null;
+ return nview != null && nview.IsValid() ? nview.GetZDO() : null;
+ }
+ }
+
+ [HarmonyPatch(typeof(Container), nameof(Container.Interact))]
+ internal static class ShipPasswordStorageInteractPatch
+ {
+ private static bool Prefix(Container __instance, Humanoid character, bool hold, ref bool __result)
+ {
+ ZDO? zdo = ShipPasswordStorage.GetShipZdo(__instance);
+ if (!ShipPasswordData.IsProtected(zdo))
+ {
+ return true;
+ }
+
+ Player? player = character as Player;
+ if (player != null && ShipPasswordData.HasAccess(zdo, player.GetPlayerID()))
+ {
+ return true;
+ }
+
+ __result = false;
+ if (!hold && player != null &&
+ (!__instance.m_checkGuardStone || PrivateArea.CheckAccess(__instance.transform.position)))
+ {
+ Ship? ship = ShipPasswordStorage.GetShip(__instance);
+ ShipPasswordInput? input = ship != null && ship.m_shipControlls != null
+ ? ship.m_shipControlls.GetComponent() : null;
+ input?.BeginStoragePassword(player, __instance);
+ }
+
+ return false;
+ }
+ }
+
+ // Vanilla open, stack, and take-all requests all call CheckAccess on the owner.
+ // Keeping the check here also covers direct requests that do not show the prompt.
+ [HarmonyPatch(typeof(Container), "CheckAccess")]
+ internal static class ShipPasswordStorageAccessPatch
+ {
+ private static void Postfix(Container __instance, long playerID, ref bool __result)
+ {
+ ZDO? zdo = ShipPasswordStorage.GetShipZdo(__instance);
+ if (ShipPasswordData.IsProtected(zdo) && !ShipPasswordData.HasAccess(zdo, playerID))
+ {
+ __result = false;
+ }
+ }
+ }
+
+ [HarmonyPatch]
+ internal static class ShipPasswordStorageSenderPatch
+ {
+ private static IEnumerable TargetMethods()
+ {
+ yield return AccessTools.Method(typeof(Container), "RPC_RequestOpen");
+ yield return AccessTools.Method(typeof(Container), "RPC_RequestStack");
+ yield return AccessTools.Method(typeof(Container), "RPC_RequestTakeAll");
+ }
+
+ private static bool Prefix(Container __instance, long uid, long playerID)
+ {
+ if (!ShipPasswordData.IsProtected(ShipPasswordStorage.GetShipZdo(__instance)))
+ {
+ return true;
+ }
+
+ // A remote caller must not borrow another character's saved access.
+ // Other clients are routed through the server, so resolve the nearby
+ // character's ZDO owner rather than requiring a direct network peer.
+ foreach (Player player in Player.GetAllPlayers())
+ {
+ if (player.GetPlayerID() == playerID)
+ {
+ ZDO? character = ZDOMan.instance.GetZDO(player.GetZDOID());
+ return character != null && character.GetOwner() == uid;
+ }
+ }
+
+ return false;
+ }
+ }
+
+ [HarmonyPatch(typeof(Container), nameof(Container.GetHoverText))]
+ internal static class ShipPasswordStorageHoverPatch
+ {
+ private static void Postfix(Container __instance, ref string __result)
+ {
+ if (ShipPasswordData.IsProtected(ShipPasswordStorage.GetShipZdo(__instance)))
+ {
+ __result += "\nPassword protected";
+ }
+ }
+ }
+}
diff --git a/Source/Ships/ShipRudderOwnershipPatch.cs b/Source/Ships/ShipRudderOwnershipPatch.cs
index f665cf5..0593d78 100644
--- a/Source/Ships/ShipRudderOwnershipPatch.cs
+++ b/Source/Ships/ShipRudderOwnershipPatch.cs
@@ -1,4 +1,5 @@
using HarmonyLib;
+using PraetorisClient.ShipPasswordFeature;
namespace PraetorisClient
{
@@ -18,6 +19,12 @@ private static void Postfix(ShipControlls __instance, bool granted, ZNetView ___
___m_nview.ClaimOwnership();
// The grant can arrive before the previous owner's user value is synchronized.
___m_nview.GetZDO().Set(ZDOVars.s_user, player.GetPlayerID());
+ // The password grant can also precede its ZDO update. Preserve the
+ // local player's saved access when helm use transfers ownership.
+ if (ShipPasswordData.IsProtected(___m_nview.GetZDO()))
+ {
+ ShipPasswordData.GrantAccess(___m_nview.GetZDO(), player.GetPlayerID());
+ }
}
}
}
diff --git a/Tests/ShipPassword/Program.cs b/Tests/ShipPassword/Program.cs
new file mode 100644
index 0000000..48d77e0
--- /dev/null
+++ b/Tests/ShipPassword/Program.cs
@@ -0,0 +1,67 @@
+using System;
+using System.Collections.Generic;
+using PraetorisClient.ShipPasswordFeature;
+
+// This checks permission and password data rules with an in-memory ZDO substitute.
+// World saving, RPCs, Harmony patches, and Unity interactions require live validation.
+internal static class Program
+{
+ private static int _checks;
+
+ private static void Main()
+ {
+ ZDO boat = new ZDO();
+ Check(!ShipPasswordData.IsProtected(boat), "new boat has no password");
+ Check(!ShipPasswordData.HasAccess(boat, 12), "new character has no saved access");
+ ShipPasswordData.CreateVerifier("test-password", out string salt, out string verifier);
+ ShipPasswordData.ApplyVerifier(boat, salt, verifier);
+ Check(ShipPasswordData.Verify(boat, "test-password"), "correct password accepted");
+ Check(!ShipPasswordData.Verify(boat, "wrong-password"), "incorrect password rejected");
+ ShipPasswordData.GrantAccess(boat, 12);
+ ShipPasswordData.GrantAccess(boat, 312);
+ Check(ShipPasswordData.HasAccess(boat, 12), "first character authorized");
+ Check(ShipPasswordData.HasAccess(boat, 312), "second character authorized");
+ Check(!ShipPasswordData.HasAccess(boat, 2), "partial character identifiers cannot match");
+ string saved = boat.Snapshot();
+ ShipPasswordData.GrantAccess(boat, 12);
+ Check(saved == boat.Snapshot(), "repeat grants do not grow the record");
+ ShipPasswordData.GrantAccess(boat, 0);
+ Check(!ShipPasswordData.HasAccess(boat, 0), "invalid character cannot gain access");
+ Check(saved == boat.Snapshot(), "invalid character does not alter the record");
+ ShipPasswordData.CreateVerifier("replacement", out salt, out verifier);
+ ShipPasswordData.ApplyVerifier(boat, salt, verifier);
+ Check(ShipPasswordData.HasAccess(boat, 12), "password change retains saved access");
+ Check(!ShipPasswordData.Verify(boat, "test-password"), "old password rejected after change");
+ ShipPasswordData.ApplyVerifier(boat, "", "");
+ Check(!ShipPasswordData.IsProtected(boat), "clearing password removes protection");
+ Check(ShipPasswordData.HasAccess(boat, 312), "clearing password retains saved access");
+ Check(!ShipPasswordData.HasAccess(new ZDO(), 12), "replacement boat has independent permissions");
+ Check(!ShipPasswordData.HasAccess(null, 12), "missing boat has no access");
+ boat.Set(ShipPasswordData.SaltHash, "invalid base64");
+ Check(!ShipPasswordData.Verify(boat, "replacement"), "damaged verifier fails closed");
+ Console.WriteLine($"Passed {_checks} ship password data checks.");
+ }
+
+ private static void Check(bool passed, string claim)
+ {
+ if (!passed)
+ {
+ throw new InvalidOperationException(claim);
+ }
+
+ _checks++;
+ }
+}
+
+internal sealed class ZDO
+{
+ private readonly Dictionary _values = new Dictionary();
+ internal string GetString(int key, string fallback) => _values.TryGetValue(key, out string? value) ? value : fallback;
+ internal void Set(int key, string value) => _values[key] = value;
+ internal string Snapshot() => System.Text.Json.JsonSerializer.Serialize(_values);
+}
+
+internal static class TestHash
+{
+ internal static int GetStableHashCode(this string value) => StringComparer.Ordinal.GetHashCode(value);
+}
diff --git a/Tests/ShipPassword/README.md b/Tests/ShipPassword/README.md
new file mode 100644
index 0000000..f2e9225
--- /dev/null
+++ b/Tests/ShipPassword/README.md
@@ -0,0 +1,32 @@
+# Boat password validation
+
+Run the isolated data checks with:
+
+```sh
+dotnet run --project Tests/ShipPassword/ShipPassword.Tests.csproj
+dotnet build -c Release
+```
+
+The data checks use an in-memory substitute for ZDO, Valheim's saved network object. They check password verification, character separation, repeated grants, password changes, and independent permissions for a replacement boat. They do not prove world-save persistence or network behavior.
+
+## Live validation pending
+
+Valdev and Valnet client 01 were reserved for server chest tests when this feature was implemented. No candidate files were deployed. Install the candidate on both the server and participating clients; the owner grant messages now use version 2 payloads.
+
+Use the current production Season 8 mirror after acquiring the device leases. Back up candidate destinations and restore them after testing.
+
+1. Record the active profiles, production release, installed mod versions, and initial log positions.
+2. Build a boat with storage. Set a password through alternate use at the helm. Confirm the creator can immediately use the helm and storage.
+3. With a second character, enter a wrong password at both the helm and chest. Confirm access is denied. Cancel a prompt and confirm access remains denied.
+4. Enter the correct password at the chest. Confirm it opens after authorization, and the helm then works without another prompt.
+5. Repeat on a second boat, starting at the helm. Confirm its storage works without another prompt.
+6. With an unauthorized character, try opening, stacking, and taking all items. Confirm each operation is denied, including direct container requests and requests with another character's ID.
+7. Alternate boat ownership between authorized characters. Confirm both retain access and occupied helm behavior is unchanged.
+8. Reconnect the clients. Save and restart the server through the documented lifecycle API. Confirm both characters still have access without a password prompt.
+9. Change, clear, and set the password again. Confirm existing access remains. Confirm a new character needs the current password.
+10. Destroy the boat and build a replacement. Set its password. Confirm the second character must authenticate again. Confirm any dropped cargo follows normal destruction behavior.
+11. Confirm unprotected boats, ordinary chests, and ward restrictions retain their normal behavior.
+12. Check the relevant server and client logs. Capture proof of the prompt, successful storage access, and access after reconnect and restart.
+13. Remove test boats and helpers. Restore and verify all replaced files and profiles before releasing leases.
+
+Remaining risks are live Harmony patch behavior, owner changes, persistence across world saves, and interaction with the full Season 8 mod set. A successful build and the isolated checks do not resolve these risks.
diff --git a/Tests/ShipPassword/ShipPassword.Tests.csproj b/Tests/ShipPassword/ShipPassword.Tests.csproj
new file mode 100644
index 0000000..1acbf95
--- /dev/null
+++ b/Tests/ShipPassword/ShipPassword.Tests.csproj
@@ -0,0 +1,11 @@
+
+
+ Exe
+ net9.0
+ enable
+ SYSLIB0060
+
+
+
+
+
From b3ebb072fbcd7f9d481e4cd291a658830d10119b Mon Sep 17 00:00:00 2001
From: jneb802 <73610029+jneb802@users.noreply.github.com>
Date: Tue, 29 Sep 2026 23:08:00 -0700
Subject: [PATCH 2/4] Remove boat password tests and documentation changes
---
PraetorisClient.csproj | 1 -
README.md | 4 +-
Source/README.md | 2 +-
Tests/ShipPassword/Program.cs | 67 --------------------
Tests/ShipPassword/README.md | 32 ----------
Tests/ShipPassword/ShipPassword.Tests.csproj | 11 ----
6 files changed, 2 insertions(+), 115 deletions(-)
delete mode 100644 Tests/ShipPassword/Program.cs
delete mode 100644 Tests/ShipPassword/README.md
delete mode 100644 Tests/ShipPassword/ShipPassword.Tests.csproj
diff --git a/PraetorisClient.csproj b/PraetorisClient.csproj
index 27b40c5..8572624 100644
--- a/PraetorisClient.csproj
+++ b/PraetorisClient.csproj
@@ -49,7 +49,6 @@
-
PraetorisClient.GuideImages.%(Filename)%(Extension)
diff --git a/README.md b/README.md
index 55cb6ec..cb3f806 100644
--- a/README.md
+++ b/README.md
@@ -59,9 +59,7 @@ PraetorisClient is the shared client-and-server mod for Praetoris-specific gamep
- Prevents building in selected Hildir locations, crypts, caves, and Mistlands Dvergr entrances.
- Prevents the specific attackerless water-impact damage applied to boats. Other boat damage still applies.
- Lets a ship creator set, change, or clear a helm password with alternate use at the helm.
-- Setting a password also saves access for the creator.
-- Entering the password once grants that character access to the helm and boat storage until the boat is destroyed. Access survives reconnects, restarts, and password changes. Passengers can still board the ship.
-- Boat storage uses the same password. Players without saved access cannot open, stack into, or take all items from protected boat storage.
+- Requires the password each time a player takes control of a protected ship. Passengers can still board the ship.
- Stores a salted password verifier in the ship ZDO. Destroying the ship removes the password with the ZDO.
- Hides area damage numbers on building pieces and non-player damage numbers on trees and logs.
- Preserves player combat damage numbers. This display option does not change damage.
diff --git a/Source/README.md b/Source/README.md
index 08f0dcb..7768e28 100644
--- a/Source/README.md
+++ b/Source/README.md
@@ -6,7 +6,7 @@
- `Network/` - shared RPC names, RPC wiring, and send-state helpers.
- `Patches/` - general Valheim patches that do not yet belong to a narrower feature area.
- `Siege/` - siege portal bridge and related test command.
-- `ShipPassword/` - server-validated ship passwords and persistent access to the helm and storage.
+- `ShipPassword/` - server-validated ship helm password setup and control access.
- `Telemetry/Common/` - shared telemetry serialization, math, and runtime metadata.
- `Telemetry/FrameMetrics/` - local frame-time measurement.
- `Telemetry/RpcTrace/` - local storage, RPC probes, and socket metrics.
diff --git a/Tests/ShipPassword/Program.cs b/Tests/ShipPassword/Program.cs
deleted file mode 100644
index 48d77e0..0000000
--- a/Tests/ShipPassword/Program.cs
+++ /dev/null
@@ -1,67 +0,0 @@
-using System;
-using System.Collections.Generic;
-using PraetorisClient.ShipPasswordFeature;
-
-// This checks permission and password data rules with an in-memory ZDO substitute.
-// World saving, RPCs, Harmony patches, and Unity interactions require live validation.
-internal static class Program
-{
- private static int _checks;
-
- private static void Main()
- {
- ZDO boat = new ZDO();
- Check(!ShipPasswordData.IsProtected(boat), "new boat has no password");
- Check(!ShipPasswordData.HasAccess(boat, 12), "new character has no saved access");
- ShipPasswordData.CreateVerifier("test-password", out string salt, out string verifier);
- ShipPasswordData.ApplyVerifier(boat, salt, verifier);
- Check(ShipPasswordData.Verify(boat, "test-password"), "correct password accepted");
- Check(!ShipPasswordData.Verify(boat, "wrong-password"), "incorrect password rejected");
- ShipPasswordData.GrantAccess(boat, 12);
- ShipPasswordData.GrantAccess(boat, 312);
- Check(ShipPasswordData.HasAccess(boat, 12), "first character authorized");
- Check(ShipPasswordData.HasAccess(boat, 312), "second character authorized");
- Check(!ShipPasswordData.HasAccess(boat, 2), "partial character identifiers cannot match");
- string saved = boat.Snapshot();
- ShipPasswordData.GrantAccess(boat, 12);
- Check(saved == boat.Snapshot(), "repeat grants do not grow the record");
- ShipPasswordData.GrantAccess(boat, 0);
- Check(!ShipPasswordData.HasAccess(boat, 0), "invalid character cannot gain access");
- Check(saved == boat.Snapshot(), "invalid character does not alter the record");
- ShipPasswordData.CreateVerifier("replacement", out salt, out verifier);
- ShipPasswordData.ApplyVerifier(boat, salt, verifier);
- Check(ShipPasswordData.HasAccess(boat, 12), "password change retains saved access");
- Check(!ShipPasswordData.Verify(boat, "test-password"), "old password rejected after change");
- ShipPasswordData.ApplyVerifier(boat, "", "");
- Check(!ShipPasswordData.IsProtected(boat), "clearing password removes protection");
- Check(ShipPasswordData.HasAccess(boat, 312), "clearing password retains saved access");
- Check(!ShipPasswordData.HasAccess(new ZDO(), 12), "replacement boat has independent permissions");
- Check(!ShipPasswordData.HasAccess(null, 12), "missing boat has no access");
- boat.Set(ShipPasswordData.SaltHash, "invalid base64");
- Check(!ShipPasswordData.Verify(boat, "replacement"), "damaged verifier fails closed");
- Console.WriteLine($"Passed {_checks} ship password data checks.");
- }
-
- private static void Check(bool passed, string claim)
- {
- if (!passed)
- {
- throw new InvalidOperationException(claim);
- }
-
- _checks++;
- }
-}
-
-internal sealed class ZDO
-{
- private readonly Dictionary _values = new Dictionary();
- internal string GetString(int key, string fallback) => _values.TryGetValue(key, out string? value) ? value : fallback;
- internal void Set(int key, string value) => _values[key] = value;
- internal string Snapshot() => System.Text.Json.JsonSerializer.Serialize(_values);
-}
-
-internal static class TestHash
-{
- internal static int GetStableHashCode(this string value) => StringComparer.Ordinal.GetHashCode(value);
-}
diff --git a/Tests/ShipPassword/README.md b/Tests/ShipPassword/README.md
deleted file mode 100644
index f2e9225..0000000
--- a/Tests/ShipPassword/README.md
+++ /dev/null
@@ -1,32 +0,0 @@
-# Boat password validation
-
-Run the isolated data checks with:
-
-```sh
-dotnet run --project Tests/ShipPassword/ShipPassword.Tests.csproj
-dotnet build -c Release
-```
-
-The data checks use an in-memory substitute for ZDO, Valheim's saved network object. They check password verification, character separation, repeated grants, password changes, and independent permissions for a replacement boat. They do not prove world-save persistence or network behavior.
-
-## Live validation pending
-
-Valdev and Valnet client 01 were reserved for server chest tests when this feature was implemented. No candidate files were deployed. Install the candidate on both the server and participating clients; the owner grant messages now use version 2 payloads.
-
-Use the current production Season 8 mirror after acquiring the device leases. Back up candidate destinations and restore them after testing.
-
-1. Record the active profiles, production release, installed mod versions, and initial log positions.
-2. Build a boat with storage. Set a password through alternate use at the helm. Confirm the creator can immediately use the helm and storage.
-3. With a second character, enter a wrong password at both the helm and chest. Confirm access is denied. Cancel a prompt and confirm access remains denied.
-4. Enter the correct password at the chest. Confirm it opens after authorization, and the helm then works without another prompt.
-5. Repeat on a second boat, starting at the helm. Confirm its storage works without another prompt.
-6. With an unauthorized character, try opening, stacking, and taking all items. Confirm each operation is denied, including direct container requests and requests with another character's ID.
-7. Alternate boat ownership between authorized characters. Confirm both retain access and occupied helm behavior is unchanged.
-8. Reconnect the clients. Save and restart the server through the documented lifecycle API. Confirm both characters still have access without a password prompt.
-9. Change, clear, and set the password again. Confirm existing access remains. Confirm a new character needs the current password.
-10. Destroy the boat and build a replacement. Set its password. Confirm the second character must authenticate again. Confirm any dropped cargo follows normal destruction behavior.
-11. Confirm unprotected boats, ordinary chests, and ward restrictions retain their normal behavior.
-12. Check the relevant server and client logs. Capture proof of the prompt, successful storage access, and access after reconnect and restart.
-13. Remove test boats and helpers. Restore and verify all replaced files and profiles before releasing leases.
-
-Remaining risks are live Harmony patch behavior, owner changes, persistence across world saves, and interaction with the full Season 8 mod set. A successful build and the isolated checks do not resolve these risks.
diff --git a/Tests/ShipPassword/ShipPassword.Tests.csproj b/Tests/ShipPassword/ShipPassword.Tests.csproj
deleted file mode 100644
index 1acbf95..0000000
--- a/Tests/ShipPassword/ShipPassword.Tests.csproj
+++ /dev/null
@@ -1,11 +0,0 @@
-
-
- Exe
- net9.0
- enable
- SYSLIB0060
-
-
-
-
-
From b6d9d397375253a87af22db2ecaf578a24728f3f Mon Sep 17 00:00:00 2001
From: jneb802 <73610029+jneb802@users.noreply.github.com>
Date: Wed, 30 Sep 2026 11:04:50 -0700
Subject: [PATCH 3/4] Use NetworkPerformanceSystem for ship ownership
---
Source/Ships/ShipRudderOwnershipPatch.cs | 31 ------------------------
1 file changed, 31 deletions(-)
delete mode 100644 Source/Ships/ShipRudderOwnershipPatch.cs
diff --git a/Source/Ships/ShipRudderOwnershipPatch.cs b/Source/Ships/ShipRudderOwnershipPatch.cs
deleted file mode 100644
index 0593d78..0000000
--- a/Source/Ships/ShipRudderOwnershipPatch.cs
+++ /dev/null
@@ -1,31 +0,0 @@
-using HarmonyLib;
-using PraetorisClient.ShipPasswordFeature;
-
-namespace PraetorisClient
-{
- [HarmonyPatch(typeof(ShipControlls), "RPC_RequestRespons")]
- internal static class ShipRudderOwnershipPatch
- {
- private static void Postfix(ShipControlls __instance, bool granted, ZNetView ___m_nview)
- {
- Player player = Player.m_localPlayer;
- if (!granted || player == null || !ReferenceEquals(player.GetDoodadController(), __instance))
- {
- return;
- }
-
- if (___m_nview != null && ___m_nview.IsValid())
- {
- ___m_nview.ClaimOwnership();
- // The grant can arrive before the previous owner's user value is synchronized.
- ___m_nview.GetZDO().Set(ZDOVars.s_user, player.GetPlayerID());
- // The password grant can also precede its ZDO update. Preserve the
- // local player's saved access when helm use transfers ownership.
- if (ShipPasswordData.IsProtected(___m_nview.GetZDO()))
- {
- ShipPasswordData.GrantAccess(___m_nview.GetZDO(), player.GetPlayerID());
- }
- }
- }
- }
-}
From 9995b39bc8eadb105c2d7a4b8379bd2adae69432 Mon Sep 17 00:00:00 2001
From: jneb802 <73610029+jneb802@users.noreply.github.com>
Date: Wed, 30 Sep 2026 13:57:38 -0700
Subject: [PATCH 4/4] Bump PraetorisClient to 0.1.87
---
Properties/AssemblyInfo.cs | 4 ++--
Source/Core/Plugin.cs | 2 +-
manifest.json | 2 +-
thunderstore.toml | 2 +-
4 files changed, 5 insertions(+), 5 deletions(-)
diff --git a/Properties/AssemblyInfo.cs b/Properties/AssemblyInfo.cs
index 3c6cb52..26b61a8 100644
--- a/Properties/AssemblyInfo.cs
+++ b/Properties/AssemblyInfo.cs
@@ -31,5 +31,5 @@
// You can specify all the values or you can default the Build and Revision Numbers
// by using the '*' as shown below:
// [assembly: AssemblyVersion("1.0.*")]
-[assembly: AssemblyVersion("0.1.86")]
-[assembly: AssemblyFileVersion("0.1.86")]
+[assembly: AssemblyVersion("0.1.87")]
+[assembly: AssemblyFileVersion("0.1.87")]
diff --git a/Source/Core/Plugin.cs b/Source/Core/Plugin.cs
index b7e6312..a824853 100644
--- a/Source/Core/Plugin.cs
+++ b/Source/Core/Plugin.cs
@@ -29,7 +29,7 @@ namespace PraetorisClient
public class PraetorisClientPlugin : BaseUnityPlugin
{
private const string ModName = "PraetorisClient";
- private const string ModVersion = "0.1.86";
+ private const string ModVersion = "0.1.87";
private const string Author = "warpalicious";
private const string ModGUID = Author + "." + ModName;
private const string EpicLootGuid = "randyknapp.mods.epicloot";
diff --git a/manifest.json b/manifest.json
index f976c72..840242a 100644
--- a/manifest.json
+++ b/manifest.json
@@ -1,7 +1,7 @@
{
"name": "PraetorisClient",
"description": "Client-side Praetoris tools for Discord linking, creative server bridges, RPC tracing, socket metrics, and RPC probes.",
- "version_number": "0.1.86",
+ "version_number": "0.1.87",
"website_url": "https://discord.gg/3aaru2VyHJ",
"dependencies": [
"denikson-BepInExPack_Valheim-5.4.2333",
diff --git a/thunderstore.toml b/thunderstore.toml
index d60afd3..69f9393 100644
--- a/thunderstore.toml
+++ b/thunderstore.toml
@@ -4,7 +4,7 @@ schemaVersion = "0.0.1"
[package]
namespace = "praetoris"
name = "PraetorisClient"
-versionNumber = "0.1.85"
+versionNumber = "0.1.87"
description = "Client-side Praetoris tools for Discord linking, creative server bridges, RPC tracing, socket metrics, and RPC probes."
websiteUrl = "https://discord.gg/3aaru2VyHJ"
containsNsfwContent = false