From 46a982ba0de67646439c97bd16819dec8bb1f66f Mon Sep 17 00:00:00 2001
From: jneb802 <73610029+jneb802@users.noreply.github.com>
Date: Mon, 28 Sep 2026 20:28:49 -0700
Subject: [PATCH 1/7] Add Builder Belt camera with fueled ward limits
---
PraetorisClient.csproj | 1 +
README.md | 13 +
Source/BuilderCamera/BuilderCamera.cs | 249 ++++++++++++++++++
Source/BuilderCamera/BuilderCameraContent.cs | 102 +++++++
Source/BuilderCamera/BuilderWard.cs | 99 +++++++
Source/Core/Plugin.cs | 8 +
.../BuilderCameraProof.csproj | 12 +
Validation/BuilderCameraProof/ProofPlugin.cs | 77 ++++++
Validation/BuilderCameraProof/README.md | 32 +++
Validation/BuilderCameraProof/RESULTS.md | 54 ++++
Validation/BuilderCameraProof/input-checks.py | 103 ++++++++
11 files changed, 750 insertions(+)
create mode 100644 Source/BuilderCamera/BuilderCamera.cs
create mode 100644 Source/BuilderCamera/BuilderCameraContent.cs
create mode 100644 Source/BuilderCamera/BuilderWard.cs
create mode 100644 Validation/BuilderCameraProof/BuilderCameraProof.csproj
create mode 100644 Validation/BuilderCameraProof/ProofPlugin.cs
create mode 100644 Validation/BuilderCameraProof/README.md
create mode 100644 Validation/BuilderCameraProof/RESULTS.md
create mode 100644 Validation/BuilderCameraProof/input-checks.py
diff --git a/PraetorisClient.csproj b/PraetorisClient.csproj
index 8572624..baa60de 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..06eb8aa 100644
--- a/README.md
+++ b/README.md
@@ -17,6 +17,19 @@ PraetorisClient is the shared client-and-server mod for Praetoris-specific gamep
### Player features
+#### Builder Belt and build camera
+
+- Buy a Builder Belt from Hildir for 1,500 coins. The belt uses a utility equipment slot and does not grant Megingjord's carry-weight bonus.
+- Build a Builder's Ward with 10 Wood, 10 Stone, and 5 Greydwarf Eyes. Add greydwarf eyes through its normal interaction. This ward does not grant building permission or protect the player.
+- Equip the belt and hold a build tool near a fueled ward. Press **F8** to enter or leave build camera mode. `buildercamera` also toggles it; `buildercamera status` reports its state.
+- Use WASD to move, the mouse to look, Space to rise, Left Ctrl to descend, and Shift to move faster. Use the normal build menu and building controls.
+- The body remains in place and remains vulnerable. Damage, lost equipment, a removed ward, teleporting, or body displacement ends camera mode. Using an inventory item ends camera mode first.
+- Terrain and solid structures block camera movement. Both the camera and build targets must remain within the configured distances from the ward and the player's body.
+- Normal resource costs, crafting-station requirements, and building permissions still apply. Combat, general interaction, and automatic pickup are blocked during camera mode.
+- Each active player consumes fuel separately, in prepaid one-second portions. Leaving during a paid second does not refund that fraction. Idle wards use no fuel. Fuel persists in the world save.
+
+The `[BuilderCamera]` config section sets the trader prefab (default `Hildir`), price, maximum fuel (20 eyes), seconds per eye (120), ward range (30 meters), and body range (30 meters). These gameplay values use Jotunn's admin-only configuration synchronization. The toggle key is local. Install the candidate on all participating clients and the server before multiplayer use.
+
#### Discord account linking
- Enter `!link CODE` in game chat to link a Valheim character to Discord.
diff --git a/Source/BuilderCamera/BuilderCamera.cs b/Source/BuilderCamera/BuilderCamera.cs
new file mode 100644
index 0000000..0a2b613
--- /dev/null
+++ b/Source/BuilderCamera/BuilderCamera.cs
@@ -0,0 +1,249 @@
+using System;
+using System.Collections.Generic;
+using System.Reflection;
+using HarmonyLib;
+using UnityEngine;
+
+namespace PraetorisClient.BuilderCameraFeature
+{
+ internal static partial class BuilderCamera
+ {
+ private static BuilderWard? _ward;
+ private static bool _active;
+ private static bool _pending;
+ private static float _requestedAt;
+ private static float _paidUntil;
+ private static int _requestId;
+ private static Vector3 _position;
+ private static Vector3 _bodyPosition;
+ private static Quaternion _rotation;
+ private static float _health;
+ private static GUIStyle? _statusStyle;
+ private static int CollisionMask => LayerMask.GetMask("Default", "static_solid", "Default_small", "piece", "terrain", "vehicle");
+ internal static bool Active => _active;
+ internal static string Status() => $"Build camera: active={_active}, pending={_pending}, equipped={(Player.m_localPlayer && Equipped(Player.m_localPlayer))}, camera={_position}, body={(Player.m_localPlayer ? Player.m_localPlayer.transform.position.ToString() : "none")}, fuel={(_ward ? _ward.Fuel : 0f):0.000}, wardLimit={WardRange.Value}, bodyLimit={BodyRange.Value}";
+ private static bool Paid => _active && Time.time < _paidUntil;
+
+ internal static void DrawStatus()
+ {
+ if (!_active || !_ward || Hud.IsUserHidden()) return;
+ _statusStyle ??= new GUIStyle(GUI.skin.box) { alignment = TextAnchor.MiddleCenter, fontSize = 16 };
+ GUI.Box(new Rect(Screen.width / 2f - 240f, 45f, 480f, 58f),
+ $"BUILD CAMERA | Fuel: {_ward.Fuel:0.0} eyes\n{ToggleKey.Value}: exit | Space / Left Ctrl: up / down", _statusStyle);
+ }
+
+ private static bool UiOpen => Console.IsVisible() || Menu.IsVisible() || InventoryGui.IsVisible() || StoreGui.IsVisible()
+ || Minimap.IsOpen() || (Chat.instance && Chat.instance.HasFocus()) || Hud.IsPieceSelectionVisible() || Hud.InRadial();
+
+ private static bool Equipped(Player player)
+ {
+ ItemDrop.ItemData tool = player.RightItem;
+ bool beltEquipped = player.GetInventory().GetAllItems().Exists(item => item.m_equipped && item.m_dropPrefab && item.m_dropPrefab.name == BeltPrefab);
+ return beltEquipped
+ && tool != null && tool.m_shared.m_buildPieces && player.InPlaceMode();
+ }
+
+ internal static void Update()
+ {
+ Player player = Player.m_localPlayer;
+ if (!player || !GameCamera.instance) { Stop(null); return; }
+ if ((_active || _pending) && (!Equipped(player) || player.IsDead() || player.IsTeleporting() || player.InCutscene()
+ || player.IsAttached() || !_ward || !PrivateArea.CheckAccess(_ward.transform.position, flash: false)
+ || Vector3.Distance(player.transform.position, _bodyPosition) > 0.5f || player.GetHealth() < _health))
+ {
+ Stop("Build camera ended: equipment, body position, health, or ward changed.");
+ return;
+ }
+ _health = player.GetHealth();
+ if (ToggleKey.Value.IsDown() && !UiOpen)
+ {
+ if (_active || _pending) { Stop("Build camera ended."); return; }
+ Start(player);
+ }
+ if (!_active && !_pending) return;
+ if (!WithinLimits(_position) || (_pending && Time.time - _requestedAt > 3f))
+ {
+ Stop("Build camera ended: ward unavailable or distance limit reached.");
+ return;
+ }
+ if (_active && !Paid && !_pending) RequestFuel(player);
+ }
+
+ private static void Start(Player player)
+ {
+ if (!Equipped(player) || player.IsDead() || player.IsTeleporting() || player.IsAttached() || player.InCutscene() || GameCamera.InFreeFly())
+ {
+ player.Message(MessageHud.MessageType.Center, "Equip a Builder Belt and hold a build tool to use the build camera.");
+ return;
+ }
+ _ward = null;
+ float nearest = WardRange.Value;
+ foreach (BuilderWard ward in BuilderWard.Instances)
+ {
+ float distance = Vector3.Distance(ward.transform.position, player.transform.position);
+ if (distance <= nearest && ward.Fuel > 0f && PrivateArea.CheckAccess(ward.transform.position, flash: false)) { _ward = ward; nearest = distance; }
+ }
+ if (!_ward) { player.Message(MessageHud.MessageType.Center, "A fueled Builder's Ward is required nearby."); return; }
+ _position = GameCamera.instance.transform.position;
+ _rotation = GameCamera.instance.transform.rotation;
+ _bodyPosition = player.transform.position;
+ _health = player.GetHealth();
+ if (!WithinLimits(_position) || Physics.CheckSphere(_position, 0.2f, CollisionMask, QueryTriggerInteraction.Ignore))
+ {
+ Stop("Move the camera into clear space within the ward's range.");
+ return;
+ }
+ player.m_autoRun = false;
+ player.SetMoveDir(Vector3.zero);
+ RequestFuel(player);
+ }
+
+ private static void RequestFuel(Player player)
+ {
+ _pending = true;
+ _requestedAt = Time.time;
+ _ward!.Request(player, ++_requestId);
+ }
+
+ internal static void OnGrant(BuilderWard ward, int requestId, float seconds)
+ {
+ if (!_pending || ward != _ward || requestId != _requestId) return;
+ if (seconds <= 0f) { Stop("Builder's Ward has no available fuel."); return; }
+ _pending = false;
+ _paidUntil = Time.time + Mathf.Min(1f, seconds);
+ if (!_active) Player.m_localPlayer.Message(MessageHud.MessageType.Center, "Build camera active. Your body remains vulnerable.");
+ _active = true;
+ }
+
+ internal static void Stop(string? reason)
+ {
+ bool wasRunning = _active || _pending;
+ _active = false;
+ _pending = false;
+ _ward = null;
+ if (wasRunning && reason != null && Player.m_localPlayer) Player.m_localPlayer.Message(MessageHud.MessageType.Center, reason);
+ }
+
+ private static bool WithinLimits(Vector3 point) => _ward && Player.m_localPlayer
+ && Vector3.Distance(point, _ward.transform.position) <= WardRange.Value
+ && Vector3.Distance(point, Player.m_localPlayer.transform.position) <= BodyRange.Value;
+
+ private static void MoveCamera(GameCamera camera, float dt)
+ {
+ if (Physics.CheckSphere(_position, 0.18f, CollisionMask, QueryTriggerInteraction.Ignore)) { Stop("Build camera ended: camera space is blocked."); return; }
+ if (Paid && !UiOpen)
+ {
+ Vector2 mouse = ZInput.GetMouseDelta();
+ Vector3 angles = _rotation.eulerAngles;
+ float pitch = angles.x > 180f ? angles.x - 360f : angles.x;
+ _rotation = Quaternion.Euler(Mathf.Clamp(pitch - mouse.y * 1.5f, -85f, 85f), angles.y + mouse.x * 1.5f, 0f);
+ Vector3 input = new Vector3((ZInput.GetKey(KeyCode.D) ? 1 : 0) - (ZInput.GetKey(KeyCode.A) ? 1 : 0),
+ (ZInput.GetKey(KeyCode.Space) ? 1 : 0) - (ZInput.GetKey(KeyCode.LeftControl) ? 1 : 0),
+ (ZInput.GetKey(KeyCode.W) ? 1 : 0) - (ZInput.GetKey(KeyCode.S) ? 1 : 0));
+ Vector3 delta = (_rotation * new Vector3(input.x, 0f, input.z) + Vector3.up * input.y).normalized
+ * (ZInput.GetKey(KeyCode.LeftShift) ? 10f : 5f) * Mathf.Min(dt, 0.1f);
+ if (delta.sqrMagnitude > 0f)
+ {
+ if (Physics.SphereCast(_position, 0.2f, delta.normalized, out RaycastHit hit, delta.magnitude + 0.02f, CollisionMask, QueryTriggerInteraction.Ignore))
+ delta = delta.normalized * Mathf.Max(0f, hit.distance - 0.02f);
+ Vector3 candidate = _position + delta;
+ if (WithinLimits(candidate) && !Physics.CheckSphere(candidate, 0.18f, CollisionMask, QueryTriggerInteraction.Ignore)) _position = candidate;
+ }
+ }
+ camera.transform.SetPositionAndRotation(_position, _rotation);
+ }
+
+ // Change only the distance checks used by building. Normal placement, cost, station,
+ // protected-area, repair and removal checks still run on the real player.
+ private static float BuildDistance(Vector3 first, Vector3 second)
+ {
+ if (!_active) return Vector3.Distance(first, second);
+ if (!Paid || !Equipped(Player.m_localPlayer)) return float.MaxValue;
+ Vector3 eye = Player.m_localPlayer.m_eye.position;
+ Vector3 target = (first - eye).sqrMagnitude < (second - eye).sqrMagnitude ? second : first;
+ return WithinLimits(target) ? Vector3.Distance(_position, target) : float.MaxValue;
+ }
+
+ [HarmonyPatch(typeof(GameCamera), "UpdateCamera")]
+ private static class CameraUpdate
+ {
+ private static bool Prefix(GameCamera __instance, float dt)
+ {
+ if (!_active) return true;
+ MoveCamera(__instance, dt);
+ return !_active;
+ }
+ }
+
+ [HarmonyPatch(typeof(Player), nameof(Player.SetControls))]
+ private static class BodyControls
+ {
+ private static void Prefix(Player __instance, ref Vector3 movedir, ref bool attack, ref bool attackHold,
+ ref bool secondaryAttack, ref bool secondaryAttackHold, ref bool block, ref bool blockHold,
+ ref bool jump, ref bool crouch, ref bool run, ref bool autoRun, ref bool dodge)
+ {
+ if (!_active || __instance != Player.m_localPlayer) return;
+ movedir = Vector3.zero;
+ attack = attackHold = secondaryAttack = secondaryAttackHold = block = blockHold = jump = crouch = run = autoRun = dodge = false;
+ __instance.m_autoRun = false;
+ __instance.SetMoveDir(Vector3.zero);
+ }
+ }
+
+ [HarmonyPatch]
+ private static class BuildReach
+ {
+ private static IEnumerable TargetMethods()
+ {
+ yield return AccessTools.Method(typeof(Player), "PieceRayTest");
+ yield return AccessTools.Method(typeof(Player), "RemovePiece");
+ yield return AccessTools.Method(typeof(Player), "UpdateWearNTearHover");
+ }
+ private static IEnumerable Transpiler(IEnumerable instructions)
+ {
+ MethodInfo distance = AccessTools.Method(typeof(Vector3), nameof(Vector3.Distance));
+ MethodInfo replacement = AccessTools.Method(typeof(BuilderCamera), nameof(BuildDistance));
+ foreach (CodeInstruction instruction in instructions)
+ {
+ if (instruction.Calls(distance)) instruction.operand = replacement;
+ yield return instruction;
+ }
+ }
+ }
+
+ [HarmonyPatch(typeof(Player), "Interact")]
+ private static class BlockInteraction { private static bool Prefix(Player __instance) => !_active || __instance != Player.m_localPlayer; }
+
+ [HarmonyPatch(typeof(Player), "AutoPickup")]
+ private static class BlockPickup { private static bool Prefix(Player __instance) => !_active || __instance != Player.m_localPlayer; }
+
+ [HarmonyPatch(typeof(Humanoid), nameof(Humanoid.UseItem))]
+ private static class EndBeforeItemUse
+ {
+ private static void Prefix(Humanoid __instance)
+ {
+ if (_active && __instance == Player.m_localPlayer) Stop("Build camera ended: using an item.");
+ }
+ }
+
+ [HarmonyPatch(typeof(Humanoid), nameof(Humanoid.StartAttack))]
+ private static class BlockCombat
+ {
+ private static bool Prefix(Humanoid __instance, ref bool __result)
+ {
+ if (!_active || __instance != Player.m_localPlayer) return true;
+ __result = false;
+ return false;
+ }
+ }
+
+ [HarmonyPatch(typeof(Character), nameof(Character.ApplyDamage))]
+ private static class EndOnDamage
+ {
+ private static void Prefix(Character __instance, HitData hit)
+ {
+ if (__instance == Player.m_localPlayer && hit.GetTotalDamage() > 0f) Stop("Build camera ended: you took damage.");
+ }
+ }
+ }
+}
diff --git a/Source/BuilderCamera/BuilderCameraContent.cs b/Source/BuilderCamera/BuilderCameraContent.cs
new file mode 100644
index 0000000..050b93e
--- /dev/null
+++ b/Source/BuilderCamera/BuilderCameraContent.cs
@@ -0,0 +1,102 @@
+using System;
+using System.Collections.Generic;
+using BepInEx.Configuration;
+using HarmonyLib;
+using Jotunn.Configs;
+using Jotunn.Entities;
+using Jotunn.Managers;
+using Jotunn.Utils;
+using UnityEngine;
+
+namespace PraetorisClient.BuilderCameraFeature
+{
+ internal static partial class BuilderCamera
+ {
+ internal const string BeltPrefab = "PraetorisBuilderBelt";
+ internal const string WardPrefab = "PraetorisBuilderWard";
+ internal static ConfigEntry BeltPrice = null!;
+ internal static ConfigEntry TraderPrefab = null!;
+ internal static ConfigEntry MaxFuel = null!;
+ internal static ConfigEntry SecondsPerEye = null!;
+ internal static ConfigEntry WardRange = null!;
+ internal static ConfigEntry BodyRange = null!;
+ internal static ConfigEntry ToggleKey = null!;
+
+ internal static void Initialize(ConfigFile config)
+ {
+ BeltPrice = Bind(config, "BeltPrice", 1500, "Coins charged by the trader.", new AcceptableValueRange(1, 100000));
+ TraderPrefab = Bind(config, "TraderPrefab", "Hildir", "Trader prefab that sells the Builder Belt (Hildir, Haldor, or BogWitch).");
+ MaxFuel = Bind(config, "MaxFuel", 20, "Maximum greydwarf eyes stored by a Builder's Ward.", new AcceptableValueRange(1, 1000));
+ SecondsPerEye = Bind(config, "SecondsPerEye", 120f, "Camera seconds supplied by one greydwarf eye, per active player.", new AcceptableValueRange(1f, 3600f));
+ WardRange = Bind(config, "WardRange", 30f, "Maximum camera and build target distance from the ward, in meters.", new AcceptableValueRange(2f, 100f));
+ BodyRange = Bind(config, "BodyRange", 30f, "Maximum camera and build target distance from the player's body, in meters.", new AcceptableValueRange(2f, 100f));
+ ToggleKey = config.Bind("BuilderCamera", "ToggleKey", new KeyboardShortcut(KeyCode.F8), "Toggle build camera. Equip the Builder Belt and hold a build tool near a fueled Builder's Ward. WASD moves; Space rises; Left Ctrl lowers; Shift moves faster.");
+ PrefabManager.OnVanillaPrefabsAvailable += RegisterContent;
+ new Terminal.ConsoleCommand("buildercamera", "Toggle build camera, or inspect it with: buildercamera status", args =>
+ {
+ if (args.Length > 1 && args[1] == "status") args.Context.AddString(Status());
+ else if (_active || _pending) Stop("Build camera ended.");
+ else if (Player.m_localPlayer && GameCamera.instance) Start(Player.m_localPlayer);
+ });
+ }
+
+ private static ConfigEntry Bind(ConfigFile config, string key, T value, string description, AcceptableValueBase? range = null)
+ {
+ return config.Bind("BuilderCamera", key, value, new ConfigDescription(description, range, new ConfigurationManagerAttributes { IsAdminOnly = true }));
+ }
+
+ internal static void Shutdown()
+ {
+ Stop(null);
+ PrefabManager.OnVanillaPrefabsAvailable -= RegisterContent;
+ }
+
+ private static void RegisterContent()
+ {
+ CustomItem belt = new CustomItem(BeltPrefab, "BeltStrength");
+ belt.ItemDrop.m_itemData.m_shared.m_name = "Builder Belt";
+ belt.ItemDrop.m_itemData.m_shared.m_description = "Equip this belt and hold a build tool near a fueled Builder's Ward to use the build camera. Your body remains vulnerable. Default key: F8.";
+ belt.ItemDrop.m_itemData.m_shared.m_equipStatusEffect = null;
+ belt.ItemDrop.m_itemData.m_shared.m_setStatusEffect = null;
+ belt.ItemDrop.m_itemData.m_shared.m_setName = "";
+ belt.ItemDrop.m_itemData.m_shared.m_maxQuality = 1;
+ ItemManager.Instance.AddItem(belt);
+
+ CustomPiece ward = new CustomPiece(WardPrefab, "guard_stone", new PieceConfig
+ {
+ Name = "Builder's Ward",
+ Description = "Fuel with greydwarf eyes to use a Builder Belt camera. Does not protect your body or grant building permission.",
+ PieceTable = PieceTables.Hammer,
+ Category = PieceCategories.Misc,
+ Requirements = new[] { new RequirementConfig("Wood", 10, recover: true), new RequirementConfig("Stone", 10, recover: true), new RequirementConfig("GreydwarfEye", 5, recover: true) }
+ });
+ PrivateArea area = ward.PiecePrefab.GetComponent();
+ if (area.m_areaMarker) area.m_areaMarker.gameObject.SetActive(false);
+ if (area.m_enabledEffect) area.m_enabledEffect.SetActive(false);
+ UnityEngine.Object.DestroyImmediate(area);
+ foreach (ItemStand stand in ward.PiecePrefab.GetComponentsInChildren(true)) UnityEngine.Object.DestroyImmediate(stand);
+ foreach (EffectArea effect in ward.PiecePrefab.GetComponentsInChildren(true)) UnityEngine.Object.DestroyImmediate(effect);
+ ward.PiecePrefab.AddComponent();
+ WardBuildIcon.Apply(ward.PiecePrefab, "GreydwarfEye");
+ PieceManager.Instance.AddPiece(ward);
+ PrefabManager.OnVanillaPrefabsAvailable -= RegisterContent;
+ }
+
+ [HarmonyPatch(typeof(Trader), nameof(Trader.GetAvailableItems))]
+ private static class TraderItems
+ {
+ private static void Postfix(Trader __instance, List __result)
+ {
+ if (!string.Equals(Utils.GetPrefabName(__instance.gameObject), TraderPrefab.Value, StringComparison.OrdinalIgnoreCase)) return;
+ GameObject prefab = ObjectDB.instance.GetItemPrefab(BeltPrefab);
+ if (prefab && !__result.Exists(item => item.m_prefab && item.m_prefab.name == BeltPrefab))
+ __result.Add(new Trader.TradeItem
+ {
+ m_prefab = prefab.GetComponent(), m_price = BeltPrice.Value, m_stack = 1,
+ m_buyPlayerEffects = new EffectList(), m_tooltip = "", m_name = "Builder Belt",
+ m_requiredGlobalKey = "", m_buyKey = "", m_incrementKey = ""
+ });
+ }
+ }
+ }
+}
diff --git a/Source/BuilderCamera/BuilderWard.cs b/Source/BuilderCamera/BuilderWard.cs
new file mode 100644
index 0000000..bb64d64
--- /dev/null
+++ b/Source/BuilderCamera/BuilderWard.cs
@@ -0,0 +1,99 @@
+using System.Collections.Generic;
+using UnityEngine;
+
+namespace PraetorisClient.BuilderCameraFeature
+{
+ public sealed class BuilderWard : MonoBehaviour, Hoverable, Interactable
+ {
+ internal static readonly List Instances = new List();
+ private const string FuelKey = "praetoris_builder_fuel";
+ private ZNetView _view = null!;
+ private readonly Dictionary _paidUntil = new Dictionary();
+ internal float Fuel => _view && _view.IsValid() ? _view.GetZDO().GetFloat(FuelKey) : 0f;
+
+ private void Awake()
+ {
+ _view = GetComponent();
+ if (!_view || !_view.IsValid()) return;
+ Instances.Add(this);
+ _view.Register("BuilderFuel", AddFuel);
+ _view.Register("BuilderLease", Grant);
+ _view.Register("BuilderGrant", ReceiveGrant);
+ }
+
+ private void OnDestroy() { Instances.Remove(this); }
+
+ public string GetHoverName() => "Builder's Ward";
+ public float GetHoverOffset() => 0f;
+ public string GetHoverText() => $"Builder's Ward\nFuel: {Fuel:0.0}/{BuilderCamera.MaxFuel.Value} greydwarf eyes\n[$KEY_Use] Add greydwarf eye\nEquip Builder Belt + build tool; {BuilderCamera.ToggleKey.Value} toggles camera";
+ public bool Interact(Humanoid user, bool hold, bool alt)
+ {
+ if (hold) return false;
+ return UseItem(user, user.GetInventory().GetItem("$item_greydwarfeye"));
+ }
+
+ public bool UseItem(Humanoid user, ItemDrop.ItemData item)
+ {
+ if (!(user is Player player) || player != Player.m_localPlayer || !_view.IsValid() || !PrivateArea.CheckAccess(transform.position)) return false;
+ if (item == null || !item.m_dropPrefab || item.m_dropPrefab.name != "GreydwarfEye")
+ {
+ player.Message(MessageHud.MessageType.Center, "Requires greydwarf eyes.");
+ return false;
+ }
+ if (Fuel > BuilderCamera.MaxFuel.Value - 1f + 0.00001f)
+ {
+ player.Message(MessageHud.MessageType.Center, "Builder's Ward is full.");
+ return false;
+ }
+ // Like vanilla fueled pieces, the interacting player pays before the owner records fuel.
+ player.GetInventory().RemoveItem(item, 1);
+ _view.InvokeRPC("BuilderFuel", player.GetPlayerID());
+ return true;
+ }
+
+ private Player? RequestPlayer(long sender, long playerId)
+ {
+ Player player = Player.GetPlayer(playerId);
+ ZNetView view = player ? player.GetComponent() : null!;
+ return view && view.IsValid() && view.GetZDO().GetOwner() == sender ? player : null;
+ }
+
+ private void AddFuel(long sender, long playerId)
+ {
+ if (!_view.IsOwner()) return;
+ Player? player = RequestPlayer(sender, playerId);
+ if (!player || Vector3.Distance(player.transform.position, transform.position) > 6f) return;
+ _view.GetZDO().Set(FuelKey, Mathf.Min(BuilderCamera.MaxFuel.Value, Fuel + 1f));
+ }
+
+ internal void Request(Player player, int requestId) { _view.InvokeRPC("BuilderLease", player.GetPlayerID(), requestId); }
+
+ private void Grant(long sender, long playerId, int requestId)
+ {
+ if (!_view.IsOwner()) return;
+ Player? player = RequestPlayer(sender, playerId);
+ float seconds = 0f;
+ if (player && !player.IsDead() && Vector3.Distance(player.transform.position, transform.position) <= BuilderCamera.WardRange.Value)
+ {
+ if (_paidUntil.TryGetValue(sender, out float until) && until > Time.time)
+ seconds = until - Time.time;
+ else
+ {
+ seconds = Mathf.Min(1f, Fuel * BuilderCamera.SecondsPerEye.Value);
+ if (seconds > 0f)
+ {
+ float remaining = Mathf.Max(0f, Fuel - seconds / BuilderCamera.SecondsPerEye.Value);
+ _view.GetZDO().Set(FuelKey, remaining < 0.000001f ? 0f : remaining);
+ _paidUntil[sender] = Time.time + seconds;
+ }
+ }
+ }
+ _view.InvokeRPC(sender, "BuilderGrant", requestId, seconds);
+ }
+
+ private void ReceiveGrant(long sender, int requestId, float seconds)
+ {
+ if (_view.IsValid() && sender == _view.GetZDO().GetOwner()) BuilderCamera.OnGrant(this, requestId, seconds);
+ }
+ }
+}
diff --git a/Source/Core/Plugin.cs b/Source/Core/Plugin.cs
index 36739b8..9812fed 100644
--- a/Source/Core/Plugin.cs
+++ b/Source/Core/Plugin.cs
@@ -122,6 +122,7 @@ public void Awake()
Instance = this;
BindConfig();
+ BuilderCameraFeature.BuilderCamera.Initialize(Config);
SynchronizationManager.OnConfigurationSynchronized += OnConfigurationSynchronized;
PraetorisMagicEffects.Register();
if (epicLootLoaded)
@@ -191,6 +192,7 @@ private void ApplyHarmonyPatches(bool epicLootLoaded)
private void Update()
{
+ BuilderCameraFeature.BuilderCamera.Update();
MaintenanceMode.UpdateScheduledWindow();
if (Game.instance == null)
@@ -200,8 +202,14 @@ private void Update()
ServerGuideFeature.ServerGuide.Update();
}
+ private void OnGUI()
+ {
+ BuilderCameraFeature.BuilderCamera.DrawStatus();
+ }
+
private void OnDestroy()
{
+ BuilderCameraFeature.BuilderCamera.Shutdown();
SynchronizationManager.OnConfigurationSynchronized -= OnConfigurationSynchronized;
CleanseMeadFeature.Shutdown();
CreatureOwnerWardPiece.Shutdown();
diff --git a/Validation/BuilderCameraProof/BuilderCameraProof.csproj b/Validation/BuilderCameraProof/BuilderCameraProof.csproj
new file mode 100644
index 0000000..b0eb845
--- /dev/null
+++ b/Validation/BuilderCameraProof/BuilderCameraProof.csproj
@@ -0,0 +1,12 @@
+
+
+
+ net481
+ 10
+ enable
+ false
+
+
+ ../../bin/Release/PraetorisClient.dllfalse
+
+
diff --git a/Validation/BuilderCameraProof/ProofPlugin.cs b/Validation/BuilderCameraProof/ProofPlugin.cs
new file mode 100644
index 0000000..83ef06f
--- /dev/null
+++ b/Validation/BuilderCameraProof/ProofPlugin.cs
@@ -0,0 +1,77 @@
+using System;
+using System.Globalization;
+using System.Linq;
+using System.Reflection;
+using BepInEx;
+using PraetorisClient;
+using PraetorisClient.BuilderCameraFeature;
+using UnityEngine;
+
+// Live-test helper only. Never include this assembly in a release.
+[BepInPlugin("praetoris.validation.buildercamera", "Builder camera proof helper", "1.0.0")]
+[BepInDependency("warpalicious.PraetorisClient")]
+public sealed class ProofPlugin : BaseUnityPlugin
+{
+ private static readonly Type CameraType = typeof(PraetorisClientPlugin).Assembly.GetType("PraetorisClient.BuilderCameraFeature.BuilderCamera");
+ private const BindingFlags StaticFlags = BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Public;
+ private void Awake()
+ {
+ new Terminal.ConsoleCommand("builderproof", "Live-test helper: status, refuel, unequip , aim , damage, purchase, emptywood", Run, isCheat: true);
+ }
+
+ private static void Run(Terminal.ConsoleEventArgs args)
+ {
+ Player player = Player.m_localPlayer;
+ if (!player) { args.Context.AddString("No player."); return; }
+ BuilderWard ward = UnityEngine.Object.FindObjectsByType(FindObjectsSortMode.None)
+ .Where(w => w.GetComponent() && w.GetComponent().IsValid())
+ .OrderBy(w => Vector3.Distance(w.transform.position, player.transform.position)).FirstOrDefault();
+ string action = args.Length > 1 ? args[1] : "status";
+ if (action == "reload") PraetorisClientPlugin.Instance!.Config.Reload();
+ else if (action == "refuel") args.Context.AddString("Refuel accepted=" + (ward && ward.Interact(player, false, false)));
+ else if (action == "protect")
+ {
+ Vector3 position = new Vector3(float.Parse(args[2], CultureInfo.InvariantCulture), float.Parse(args[3], CultureInfo.InvariantCulture), float.Parse(args[4], CultureInfo.InvariantCulture));
+ GameObject guard = UnityEngine.Object.Instantiate(ZNetScene.instance.GetPrefab("guard_stone"), position, Quaternion.identity);
+ guard.GetComponent().SetCreator(-12345, default);
+ guard.GetComponent().m_radius = 3f;
+ guard.GetComponent().GetZDO().Set(ZDOVars.s_enabled, true);
+ }
+ else if (action == "unequip")
+ {
+ ItemDrop.ItemData item = player.GetInventory().GetAllItems().Find(i => i.m_dropPrefab && i.m_dropPrefab.name == args[2]);
+ if (item != null) player.UnequipItem(item, false);
+ }
+ else if (action == "aim")
+ {
+ Vector3 target = new Vector3(float.Parse(args[2], CultureInfo.InvariantCulture), float.Parse(args[3], CultureInfo.InvariantCulture), float.Parse(args[4], CultureInfo.InvariantCulture));
+ Quaternion rotation = Quaternion.LookRotation(target - GameCamera.instance.transform.position);
+ CameraType.GetField("_rotation", StaticFlags).SetValue(null, rotation);
+ GameCamera.instance.transform.rotation = rotation;
+ player.SetLookDir(rotation * Vector3.forward);
+ typeof(Player).GetField("m_lookPitch", BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public).SetValue(player,
+ rotation.eulerAngles.x > 180f ? rotation.eulerAngles.x - 360f : rotation.eulerAngles.x);
+ }
+ else if (action == "damage")
+ {
+ HitData hit = new HitData();
+ hit.m_damage.m_damage = 1f;
+ player.ApplyDamage(hit, true, true);
+ }
+ else if (action == "purchase")
+ {
+ Trader trader = UnityEngine.Object.FindObjectsByType(FindObjectsSortMode.None).OrderBy(t => Vector3.Distance(t.transform.position, player.transform.position)).First();
+ Trader.TradeItem item = trader.GetAvailableItems().Single(i => i.m_prefab && i.m_prefab.name == "PraetorisBuilderBelt");
+ trader.Interact(player, false, false);
+ typeof(StoreGui).GetField("m_selectedItem", BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public).SetValue(StoreGui.instance, item);
+ StoreGui.instance.OnBuyItem();
+ args.Context.AddString("Purchased through StoreGui.OnBuyItem; price=" + item.m_price);
+ }
+ else if (action == "emptywood") player.GetInventory().RemoveItem("$item_wood", 10000);
+ args.Context.AddString(CameraType.GetMethod("Status", StaticFlags).Invoke(null, null).ToString());
+ args.Context.AddString($"Health={player.GetHealth():0.00} noCost={player.NoCostCheat()} eye={player.m_eye.position} camera={GameCamera.instance.transform.position} forward={GameCamera.instance.transform.forward}");
+ if (ward) args.Context.AddString($"Ward={ward.transform.position} fuel={ward.GetComponent().GetZDO().GetFloat("praetoris_builder_fuel"):0.000000} {ward.GetHoverText().Replace('\n', ' ')}");
+ foreach (ItemDrop.ItemData item in player.GetInventory().GetAllItems())
+ args.Context.AddString($"ITEM {item.m_dropPrefab?.name} count={item.m_stack} equipped={item.m_equipped}");
+ }
+}
diff --git a/Validation/BuilderCameraProof/README.md b/Validation/BuilderCameraProof/README.md
new file mode 100644
index 0000000..65131b6
--- /dev/null
+++ b/Validation/BuilderCameraProof/README.md
@@ -0,0 +1,32 @@
+# Builder camera live validation
+
+Build the main project first, then this project. Install `BuilderCameraProof.dll` only on the leased test client, alongside the candidate `PraetorisClient.dll`. Remove the helper after validation. It is excluded from the main assembly.
+
+Use the current production mirror profile and back up the candidate DLL, configs, and proof world before testing. The helper requires developer commands. It supplies observation and setup commands; it does not grant camera access or bypass the camera's movement, collision, fuel, or equipment checks.
+
+`builderproof status` prints camera state, player health, inventory, and the nearest ward's fuel. Other actions:
+
+- `refuel`: call the ward's normal interaction.
+- `unequip `: remove equipment through the normal unequip method.
+- `aim `: aim the camera at a point without changing its position.
+- `damage`: apply one point of damage through the game's damage method.
+- `purchase`: buy the belt from the nearest trader through `StoreGui.OnBuyItem`.
+- `emptywood`: remove wood to test the missing-resource check.
+- `reload`: reload the candidate configuration after changing test limits.
+- `protect `: create a foreign ward for a permission test. Other mods can change its radius.
+
+Use keyboard and mouse input for movement and building. `cli_build_try_place` calls placement directly and does not exercise the normal resource-payment path, so it is suitable only for staging.
+
+Required checks:
+
+1. Hildir lists the belt at the configured price. Buying it removes that many coins.
+2. Camera activation fails with a missing or unequipped belt, missing build tool, missing ward, or empty ward.
+3. Adding an eye consumes one inventory item and adds one eye of fuel. The maximum prevents further payment.
+4. Active camera movement leaves the player's body in place. Fuel decreases only while active, in one-second portions per player.
+5. Camera movement stops at terrain, walls, and each configured distance limit. Build targets must also satisfy both limits.
+6. Mouse building consumes normal resources. Missing resources and protected areas prevent building.
+7. Damage, belt removal, tool removal, ward removal, teleporting, and logout end camera mode. Normal movement and camera behavior return.
+8. Save/reload preserves ward fuel. Check the client log for new exceptions.
+9. Capture and inspect a screenshot and recording. Restore the original profile and world content, verify hashes, and remove the helper.
+
+See [RESULTS.md](RESULTS.md) for the recorded client validation and the remaining multiplayer checks.
diff --git a/Validation/BuilderCameraProof/RESULTS.md b/Validation/BuilderCameraProof/RESULTS.md
new file mode 100644
index 0000000..c564703
--- /dev/null
+++ b/Validation/BuilderCameraProof/RESULTS.md
@@ -0,0 +1,54 @@
+# Builder camera validation — 2026-09-28
+
+Candidate: `feature/builder-belt-camera`, based on `e47bc1a` (0.1.83).
+
+Tested DLL SHA-256: `e5dc1dccfed48d51661a3c3f1fc9cd4c66ed6a47ec8d88c7ff2d2ed55e757f76`.
+
+## Environment
+
+- Valnet client 01, Linux, local `TestingWorld`, character `BuilderProof`.
+- Maintained `praetoris-season-8` profile, full production 8.0.29 mod set. Read-only production inspection confirmed that 8.0.30 was staged, not deployed.
+- Candidate replaced PraetorisClient 0.1.82. Jotunn 2.30.2, EpicLoot 0.14.13, ExtraSlots 1.2.14, Protective Wards 2.0.15 remained installed.
+- Developer commands supplied fixtures and observations. Camera movement used keyboard input. Placement used mouse input with `noCost=False`. The helper called the normal store, refuel, and damage methods.
+- Main Release build passed with zero errors and 84 existing warnings. The separate helper build passed with zero warnings or errors. `git diff --check` passed.
+
+## Observed results
+
+| Check | Result |
+| --- | --- |
+| Hildir purchase | Belt listed at 1,500 coins. Normal purchase reduced coins from 1,998 to 498 and added the belt. |
+| Activation requirements | Missing or unequipped belt, missing tool, absent ward, and empty ward each prevented activation. |
+| Refuel | One eye was removed from inventory and added one eye of fuel. A full ward rejected payment. |
+| Fuel lifetime | Idle fuel stayed unchanged. Fuel survived save/reload. Rapid toggling reused the paid portion. Accelerated tests consumed the final fractional eye to zero, ended camera mode, and allowed refueling. |
+| Normal building | Detached-camera placement removed 10 Wood, 10 Stone, and 5 Greydwarf Eyes. Missing wood prevented placement without consuming other materials. |
+| Protected areas | A foreign ward prevented activation near the source ward. A protected remote target returned `PrivateZone`; clicking did not place a piece or consume resources. |
+| Stationary body | Body remained at approximately `(-29, 49.5, -258)` during movement and building. |
+| Distance limits | Each default 30-meter limit stopped movement independently. Changed limits of 10 meters from the ward and 8 meters from the body also stopped movement independently. |
+| Solid collision | Repeated forward input stopped before a stone wall. Repeated downward input stopped approximately 0.23 meters above the terrain ray hit. Body position did not change. |
+| Exit conditions | Damage, belt removal, tool removal, teleport, fuel exhaustion, and active ward removal ended camera mode. Normal camera behavior returned. |
+| Damage proof | Final recording shows health 24 → 23 and the camera exit message. No invulnerability was enabled. |
+
+## Evidence
+
+Artifacts are retained at `/Users/benjmarston/Develop/PraetorisClient-builder-camera-evidence/` on the development Mac. They are not release assets.
+
+- `camera.png`: inspected 1920×1080 screenshot showing the camera HUD, player body, ward, and trader.
+- `build-camera.mp4`: inspected 31.617-second recording, H.264, 1920×1080, 60 fps container. Shows detached movement, normal placement, and damage exit. The game frame rate is lower than the recording frame rate.
+- `video-contact.jpg` and `damage-exit.jpg`: inspected recording samples.
+- `final-record.txt`: matching inventory, body position, health, and camera observations.
+- `collision-proof-final.json`: keyboard collision assertions and terrain ray evidence.
+- `purchase-and-equipment.txt`, `purchase.png`, and `activation-first.txt`: purchase and equipment observations.
+
+Visual review used the contact-sheet tool and manual inspection by the main agent. The automatic review model was unavailable. The accepted recording shows the intended game viewport throughout the inspected samples. Audio was not an acceptance criterion.
+
+The final game log contained no exceptions. Its 44 error lines were shader messages. Warning categories included platform shader support, existing mod registration and asset references, missing audio, local character startup, and save/shutdown messages. No Builder Camera warning or exception was found. The broader mod set still reports missing MWL/DungeonPack assets; this change does not resolve those messages.
+
+## Restoration
+
+After normal logout and game exit, the original candidate slot and entire configuration directory were restored. All 510 original profile files matched their saved SHA-256 values, with zero extra profile files. The world archive contents were restored and compared byte-for-byte. The helper and test character were removed. The prior `praetoris-season-8-8.0.15` selection and game profile links were restored. Recording stopped. Valnet client 01 remains powered on at the user's request. Production was not changed.
+
+## Remaining validation
+
+Dedicated-server and two-player validation are incomplete. Valdev reported an inactive `season8-1-0-migration` selection while its mounted plugin path pointed to `praetoris-season-8`; it was not changed for this client test.
+
+Before production deployment, validate synchronized configuration, remote ward ownership, simultaneous refueling and fuel consumption, ownership transfer, disconnect/reconnect, and normal build permissions with the candidate installed on Valdev and two aligned clients. This client run does not prove those network paths. Repair/removal resource behavior and alternate build tools also need explicit live coverage.
diff --git a/Validation/BuilderCameraProof/input-checks.py b/Validation/BuilderCameraProof/input-checks.py
new file mode 100644
index 0000000..370ac43
--- /dev/null
+++ b/Validation/BuilderCameraProof/input-checks.py
@@ -0,0 +1,103 @@
+#!/usr/bin/env python3
+"""Run on a leased Valnet desktop with the proof character on a stable platform.
+
+Requires the proof plugin, developer commands, an equipped belt and hammer,
+a fueled ward, and a clear site. Uses real X11 keyboard input. Writes JSON
+evidence to stdout. The caller must restore the backed-up test world afterward.
+"""
+import json
+import math
+import os
+import re
+import subprocess
+import time
+
+os.environ.update(DISPLAY=":0", XAUTHORITY="/run/user/1000/gdm/Xauthority")
+
+
+def cli(*args):
+ result = subprocess.run([os.path.expanduser("~/.local/bin/valheim-cli"), *map(str, args)], capture_output=True, text=True, check=True)
+ if "ERROR:" in result.stdout:
+ raise RuntimeError(result.stdout)
+ return result.stdout
+
+
+def vector(text, field):
+ match = re.search(re.escape(field) + r"=\(([^)]+)\)", text)
+ return tuple(map(float, match[1].split(",")))
+
+
+def state():
+ text = cli("buildercamera", "status")
+ return {"active": "active=True" in text, "camera": vector(text, "camera"), "body": vector(text, "body")}
+
+
+def hold(key, seconds):
+ subprocess.run(["xdotool", "keydown", key], check=True)
+ try:
+ time.sleep(seconds)
+ finally:
+ subprocess.run(["xdotool", "keyup", key], check=True)
+
+
+def start():
+ if state()["active"]:
+ cli("buildercamera")
+ time.sleep(1.1)
+ cli("buildercamera")
+ time.sleep(0.2)
+ result = state()
+ assert result["active"], result
+ return result
+
+
+def stop():
+ if state()["active"]:
+ cli("buildercamera")
+
+
+def near(a, b, tolerance=0.06):
+ return math.dist(a, b) <= tolerance
+
+
+def aim(point):
+ cli("builderproof", "aim", *point)
+
+
+subprocess.run(["wmctrl", "-a", "Valheim"], check=True)
+cli("cli_build_select", "piece_repair")
+results = {}
+try:
+ # Keep the player stationary while attempting to cross a solid stone wall.
+ before = start()
+ x, y, z = before["camera"]
+ cli("cli_spawn_at", "stone_wall_4x2", x, y - 1, z + 3)
+ aim((x, y, z + 10))
+ hold("w", 2)
+ blocked = state()
+ hold("w", 1)
+ repeated = state()
+ assert repeated["active"] and near(blocked["camera"], repeated["camera"]), (blocked, repeated)
+ assert z < repeated["camera"][2] < z + 3, (before, repeated)
+ assert near(before["body"], repeated["body"]), (before, repeated)
+ results["wall"] = {"before": before, "blocked": blocked, "repeated": repeated, "wallZ": z + 3}
+
+ # Move sideways out of the wall's width, then try to descend through terrain.
+ hold("a", 1)
+ hold("space", 1.5)
+ raised = state()
+ hold("Control_L", 4)
+ blocked = state()
+ hold("Control_L", 1)
+ repeated = state()
+ assert repeated["active"] and near(blocked["camera"], repeated["camera"]), (blocked, repeated)
+ assert raised["camera"][1] - repeated["camera"][1] > 2, (raised, repeated)
+ assert near(before["body"], repeated["body"]), (before, repeated)
+ aim((repeated["camera"][0], -100, repeated["camera"][2]))
+ ray = cli("cli_build_status")
+ assert "heightmap=True" in ray, ray
+ results["terrain"] = {"raised": raised, "blocked": blocked, "repeated": repeated, "ray": ray}
+ stop()
+finally:
+ stop()
+ print(json.dumps(results, indent=2), flush=True)
From 2530c4a559433c0684693bd345658ead8f94fd6b Mon Sep 17 00:00:00 2001
From: jneb802 <73610029+jneb802@users.noreply.github.com>
Date: Mon, 28 Sep 2026 21:42:20 -0700
Subject: [PATCH 2/7] Remove builder camera test helper and documentation
---
PraetorisClient.csproj | 1 -
README.md | 13 ---
.../BuilderCameraProof.csproj | 12 --
Validation/BuilderCameraProof/ProofPlugin.cs | 77 -------------
Validation/BuilderCameraProof/README.md | 32 ------
Validation/BuilderCameraProof/RESULTS.md | 54 ---------
Validation/BuilderCameraProof/input-checks.py | 103 ------------------
7 files changed, 292 deletions(-)
delete mode 100644 Validation/BuilderCameraProof/BuilderCameraProof.csproj
delete mode 100644 Validation/BuilderCameraProof/ProofPlugin.cs
delete mode 100644 Validation/BuilderCameraProof/README.md
delete mode 100644 Validation/BuilderCameraProof/RESULTS.md
delete mode 100644 Validation/BuilderCameraProof/input-checks.py
diff --git a/PraetorisClient.csproj b/PraetorisClient.csproj
index baa60de..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 06eb8aa..cb3f806 100644
--- a/README.md
+++ b/README.md
@@ -17,19 +17,6 @@ PraetorisClient is the shared client-and-server mod for Praetoris-specific gamep
### Player features
-#### Builder Belt and build camera
-
-- Buy a Builder Belt from Hildir for 1,500 coins. The belt uses a utility equipment slot and does not grant Megingjord's carry-weight bonus.
-- Build a Builder's Ward with 10 Wood, 10 Stone, and 5 Greydwarf Eyes. Add greydwarf eyes through its normal interaction. This ward does not grant building permission or protect the player.
-- Equip the belt and hold a build tool near a fueled ward. Press **F8** to enter or leave build camera mode. `buildercamera` also toggles it; `buildercamera status` reports its state.
-- Use WASD to move, the mouse to look, Space to rise, Left Ctrl to descend, and Shift to move faster. Use the normal build menu and building controls.
-- The body remains in place and remains vulnerable. Damage, lost equipment, a removed ward, teleporting, or body displacement ends camera mode. Using an inventory item ends camera mode first.
-- Terrain and solid structures block camera movement. Both the camera and build targets must remain within the configured distances from the ward and the player's body.
-- Normal resource costs, crafting-station requirements, and building permissions still apply. Combat, general interaction, and automatic pickup are blocked during camera mode.
-- Each active player consumes fuel separately, in prepaid one-second portions. Leaving during a paid second does not refund that fraction. Idle wards use no fuel. Fuel persists in the world save.
-
-The `[BuilderCamera]` config section sets the trader prefab (default `Hildir`), price, maximum fuel (20 eyes), seconds per eye (120), ward range (30 meters), and body range (30 meters). These gameplay values use Jotunn's admin-only configuration synchronization. The toggle key is local. Install the candidate on all participating clients and the server before multiplayer use.
-
#### Discord account linking
- Enter `!link CODE` in game chat to link a Valheim character to Discord.
diff --git a/Validation/BuilderCameraProof/BuilderCameraProof.csproj b/Validation/BuilderCameraProof/BuilderCameraProof.csproj
deleted file mode 100644
index b0eb845..0000000
--- a/Validation/BuilderCameraProof/BuilderCameraProof.csproj
+++ /dev/null
@@ -1,12 +0,0 @@
-
-
-
- net481
- 10
- enable
- false
-
-
- ../../bin/Release/PraetorisClient.dllfalse
-
-
diff --git a/Validation/BuilderCameraProof/ProofPlugin.cs b/Validation/BuilderCameraProof/ProofPlugin.cs
deleted file mode 100644
index 83ef06f..0000000
--- a/Validation/BuilderCameraProof/ProofPlugin.cs
+++ /dev/null
@@ -1,77 +0,0 @@
-using System;
-using System.Globalization;
-using System.Linq;
-using System.Reflection;
-using BepInEx;
-using PraetorisClient;
-using PraetorisClient.BuilderCameraFeature;
-using UnityEngine;
-
-// Live-test helper only. Never include this assembly in a release.
-[BepInPlugin("praetoris.validation.buildercamera", "Builder camera proof helper", "1.0.0")]
-[BepInDependency("warpalicious.PraetorisClient")]
-public sealed class ProofPlugin : BaseUnityPlugin
-{
- private static readonly Type CameraType = typeof(PraetorisClientPlugin).Assembly.GetType("PraetorisClient.BuilderCameraFeature.BuilderCamera");
- private const BindingFlags StaticFlags = BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Public;
- private void Awake()
- {
- new Terminal.ConsoleCommand("builderproof", "Live-test helper: status, refuel, unequip , aim , damage, purchase, emptywood", Run, isCheat: true);
- }
-
- private static void Run(Terminal.ConsoleEventArgs args)
- {
- Player player = Player.m_localPlayer;
- if (!player) { args.Context.AddString("No player."); return; }
- BuilderWard ward = UnityEngine.Object.FindObjectsByType(FindObjectsSortMode.None)
- .Where(w => w.GetComponent() && w.GetComponent().IsValid())
- .OrderBy(w => Vector3.Distance(w.transform.position, player.transform.position)).FirstOrDefault();
- string action = args.Length > 1 ? args[1] : "status";
- if (action == "reload") PraetorisClientPlugin.Instance!.Config.Reload();
- else if (action == "refuel") args.Context.AddString("Refuel accepted=" + (ward && ward.Interact(player, false, false)));
- else if (action == "protect")
- {
- Vector3 position = new Vector3(float.Parse(args[2], CultureInfo.InvariantCulture), float.Parse(args[3], CultureInfo.InvariantCulture), float.Parse(args[4], CultureInfo.InvariantCulture));
- GameObject guard = UnityEngine.Object.Instantiate(ZNetScene.instance.GetPrefab("guard_stone"), position, Quaternion.identity);
- guard.GetComponent().SetCreator(-12345, default);
- guard.GetComponent().m_radius = 3f;
- guard.GetComponent().GetZDO().Set(ZDOVars.s_enabled, true);
- }
- else if (action == "unequip")
- {
- ItemDrop.ItemData item = player.GetInventory().GetAllItems().Find(i => i.m_dropPrefab && i.m_dropPrefab.name == args[2]);
- if (item != null) player.UnequipItem(item, false);
- }
- else if (action == "aim")
- {
- Vector3 target = new Vector3(float.Parse(args[2], CultureInfo.InvariantCulture), float.Parse(args[3], CultureInfo.InvariantCulture), float.Parse(args[4], CultureInfo.InvariantCulture));
- Quaternion rotation = Quaternion.LookRotation(target - GameCamera.instance.transform.position);
- CameraType.GetField("_rotation", StaticFlags).SetValue(null, rotation);
- GameCamera.instance.transform.rotation = rotation;
- player.SetLookDir(rotation * Vector3.forward);
- typeof(Player).GetField("m_lookPitch", BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public).SetValue(player,
- rotation.eulerAngles.x > 180f ? rotation.eulerAngles.x - 360f : rotation.eulerAngles.x);
- }
- else if (action == "damage")
- {
- HitData hit = new HitData();
- hit.m_damage.m_damage = 1f;
- player.ApplyDamage(hit, true, true);
- }
- else if (action == "purchase")
- {
- Trader trader = UnityEngine.Object.FindObjectsByType(FindObjectsSortMode.None).OrderBy(t => Vector3.Distance(t.transform.position, player.transform.position)).First();
- Trader.TradeItem item = trader.GetAvailableItems().Single(i => i.m_prefab && i.m_prefab.name == "PraetorisBuilderBelt");
- trader.Interact(player, false, false);
- typeof(StoreGui).GetField("m_selectedItem", BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public).SetValue(StoreGui.instance, item);
- StoreGui.instance.OnBuyItem();
- args.Context.AddString("Purchased through StoreGui.OnBuyItem; price=" + item.m_price);
- }
- else if (action == "emptywood") player.GetInventory().RemoveItem("$item_wood", 10000);
- args.Context.AddString(CameraType.GetMethod("Status", StaticFlags).Invoke(null, null).ToString());
- args.Context.AddString($"Health={player.GetHealth():0.00} noCost={player.NoCostCheat()} eye={player.m_eye.position} camera={GameCamera.instance.transform.position} forward={GameCamera.instance.transform.forward}");
- if (ward) args.Context.AddString($"Ward={ward.transform.position} fuel={ward.GetComponent().GetZDO().GetFloat("praetoris_builder_fuel"):0.000000} {ward.GetHoverText().Replace('\n', ' ')}");
- foreach (ItemDrop.ItemData item in player.GetInventory().GetAllItems())
- args.Context.AddString($"ITEM {item.m_dropPrefab?.name} count={item.m_stack} equipped={item.m_equipped}");
- }
-}
diff --git a/Validation/BuilderCameraProof/README.md b/Validation/BuilderCameraProof/README.md
deleted file mode 100644
index 65131b6..0000000
--- a/Validation/BuilderCameraProof/README.md
+++ /dev/null
@@ -1,32 +0,0 @@
-# Builder camera live validation
-
-Build the main project first, then this project. Install `BuilderCameraProof.dll` only on the leased test client, alongside the candidate `PraetorisClient.dll`. Remove the helper after validation. It is excluded from the main assembly.
-
-Use the current production mirror profile and back up the candidate DLL, configs, and proof world before testing. The helper requires developer commands. It supplies observation and setup commands; it does not grant camera access or bypass the camera's movement, collision, fuel, or equipment checks.
-
-`builderproof status` prints camera state, player health, inventory, and the nearest ward's fuel. Other actions:
-
-- `refuel`: call the ward's normal interaction.
-- `unequip `: remove equipment through the normal unequip method.
-- `aim `: aim the camera at a point without changing its position.
-- `damage`: apply one point of damage through the game's damage method.
-- `purchase`: buy the belt from the nearest trader through `StoreGui.OnBuyItem`.
-- `emptywood`: remove wood to test the missing-resource check.
-- `reload`: reload the candidate configuration after changing test limits.
-- `protect `: create a foreign ward for a permission test. Other mods can change its radius.
-
-Use keyboard and mouse input for movement and building. `cli_build_try_place` calls placement directly and does not exercise the normal resource-payment path, so it is suitable only for staging.
-
-Required checks:
-
-1. Hildir lists the belt at the configured price. Buying it removes that many coins.
-2. Camera activation fails with a missing or unequipped belt, missing build tool, missing ward, or empty ward.
-3. Adding an eye consumes one inventory item and adds one eye of fuel. The maximum prevents further payment.
-4. Active camera movement leaves the player's body in place. Fuel decreases only while active, in one-second portions per player.
-5. Camera movement stops at terrain, walls, and each configured distance limit. Build targets must also satisfy both limits.
-6. Mouse building consumes normal resources. Missing resources and protected areas prevent building.
-7. Damage, belt removal, tool removal, ward removal, teleporting, and logout end camera mode. Normal movement and camera behavior return.
-8. Save/reload preserves ward fuel. Check the client log for new exceptions.
-9. Capture and inspect a screenshot and recording. Restore the original profile and world content, verify hashes, and remove the helper.
-
-See [RESULTS.md](RESULTS.md) for the recorded client validation and the remaining multiplayer checks.
diff --git a/Validation/BuilderCameraProof/RESULTS.md b/Validation/BuilderCameraProof/RESULTS.md
deleted file mode 100644
index c564703..0000000
--- a/Validation/BuilderCameraProof/RESULTS.md
+++ /dev/null
@@ -1,54 +0,0 @@
-# Builder camera validation — 2026-09-28
-
-Candidate: `feature/builder-belt-camera`, based on `e47bc1a` (0.1.83).
-
-Tested DLL SHA-256: `e5dc1dccfed48d51661a3c3f1fc9cd4c66ed6a47ec8d88c7ff2d2ed55e757f76`.
-
-## Environment
-
-- Valnet client 01, Linux, local `TestingWorld`, character `BuilderProof`.
-- Maintained `praetoris-season-8` profile, full production 8.0.29 mod set. Read-only production inspection confirmed that 8.0.30 was staged, not deployed.
-- Candidate replaced PraetorisClient 0.1.82. Jotunn 2.30.2, EpicLoot 0.14.13, ExtraSlots 1.2.14, Protective Wards 2.0.15 remained installed.
-- Developer commands supplied fixtures and observations. Camera movement used keyboard input. Placement used mouse input with `noCost=False`. The helper called the normal store, refuel, and damage methods.
-- Main Release build passed with zero errors and 84 existing warnings. The separate helper build passed with zero warnings or errors. `git diff --check` passed.
-
-## Observed results
-
-| Check | Result |
-| --- | --- |
-| Hildir purchase | Belt listed at 1,500 coins. Normal purchase reduced coins from 1,998 to 498 and added the belt. |
-| Activation requirements | Missing or unequipped belt, missing tool, absent ward, and empty ward each prevented activation. |
-| Refuel | One eye was removed from inventory and added one eye of fuel. A full ward rejected payment. |
-| Fuel lifetime | Idle fuel stayed unchanged. Fuel survived save/reload. Rapid toggling reused the paid portion. Accelerated tests consumed the final fractional eye to zero, ended camera mode, and allowed refueling. |
-| Normal building | Detached-camera placement removed 10 Wood, 10 Stone, and 5 Greydwarf Eyes. Missing wood prevented placement without consuming other materials. |
-| Protected areas | A foreign ward prevented activation near the source ward. A protected remote target returned `PrivateZone`; clicking did not place a piece or consume resources. |
-| Stationary body | Body remained at approximately `(-29, 49.5, -258)` during movement and building. |
-| Distance limits | Each default 30-meter limit stopped movement independently. Changed limits of 10 meters from the ward and 8 meters from the body also stopped movement independently. |
-| Solid collision | Repeated forward input stopped before a stone wall. Repeated downward input stopped approximately 0.23 meters above the terrain ray hit. Body position did not change. |
-| Exit conditions | Damage, belt removal, tool removal, teleport, fuel exhaustion, and active ward removal ended camera mode. Normal camera behavior returned. |
-| Damage proof | Final recording shows health 24 → 23 and the camera exit message. No invulnerability was enabled. |
-
-## Evidence
-
-Artifacts are retained at `/Users/benjmarston/Develop/PraetorisClient-builder-camera-evidence/` on the development Mac. They are not release assets.
-
-- `camera.png`: inspected 1920×1080 screenshot showing the camera HUD, player body, ward, and trader.
-- `build-camera.mp4`: inspected 31.617-second recording, H.264, 1920×1080, 60 fps container. Shows detached movement, normal placement, and damage exit. The game frame rate is lower than the recording frame rate.
-- `video-contact.jpg` and `damage-exit.jpg`: inspected recording samples.
-- `final-record.txt`: matching inventory, body position, health, and camera observations.
-- `collision-proof-final.json`: keyboard collision assertions and terrain ray evidence.
-- `purchase-and-equipment.txt`, `purchase.png`, and `activation-first.txt`: purchase and equipment observations.
-
-Visual review used the contact-sheet tool and manual inspection by the main agent. The automatic review model was unavailable. The accepted recording shows the intended game viewport throughout the inspected samples. Audio was not an acceptance criterion.
-
-The final game log contained no exceptions. Its 44 error lines were shader messages. Warning categories included platform shader support, existing mod registration and asset references, missing audio, local character startup, and save/shutdown messages. No Builder Camera warning or exception was found. The broader mod set still reports missing MWL/DungeonPack assets; this change does not resolve those messages.
-
-## Restoration
-
-After normal logout and game exit, the original candidate slot and entire configuration directory were restored. All 510 original profile files matched their saved SHA-256 values, with zero extra profile files. The world archive contents were restored and compared byte-for-byte. The helper and test character were removed. The prior `praetoris-season-8-8.0.15` selection and game profile links were restored. Recording stopped. Valnet client 01 remains powered on at the user's request. Production was not changed.
-
-## Remaining validation
-
-Dedicated-server and two-player validation are incomplete. Valdev reported an inactive `season8-1-0-migration` selection while its mounted plugin path pointed to `praetoris-season-8`; it was not changed for this client test.
-
-Before production deployment, validate synchronized configuration, remote ward ownership, simultaneous refueling and fuel consumption, ownership transfer, disconnect/reconnect, and normal build permissions with the candidate installed on Valdev and two aligned clients. This client run does not prove those network paths. Repair/removal resource behavior and alternate build tools also need explicit live coverage.
diff --git a/Validation/BuilderCameraProof/input-checks.py b/Validation/BuilderCameraProof/input-checks.py
deleted file mode 100644
index 370ac43..0000000
--- a/Validation/BuilderCameraProof/input-checks.py
+++ /dev/null
@@ -1,103 +0,0 @@
-#!/usr/bin/env python3
-"""Run on a leased Valnet desktop with the proof character on a stable platform.
-
-Requires the proof plugin, developer commands, an equipped belt and hammer,
-a fueled ward, and a clear site. Uses real X11 keyboard input. Writes JSON
-evidence to stdout. The caller must restore the backed-up test world afterward.
-"""
-import json
-import math
-import os
-import re
-import subprocess
-import time
-
-os.environ.update(DISPLAY=":0", XAUTHORITY="/run/user/1000/gdm/Xauthority")
-
-
-def cli(*args):
- result = subprocess.run([os.path.expanduser("~/.local/bin/valheim-cli"), *map(str, args)], capture_output=True, text=True, check=True)
- if "ERROR:" in result.stdout:
- raise RuntimeError(result.stdout)
- return result.stdout
-
-
-def vector(text, field):
- match = re.search(re.escape(field) + r"=\(([^)]+)\)", text)
- return tuple(map(float, match[1].split(",")))
-
-
-def state():
- text = cli("buildercamera", "status")
- return {"active": "active=True" in text, "camera": vector(text, "camera"), "body": vector(text, "body")}
-
-
-def hold(key, seconds):
- subprocess.run(["xdotool", "keydown", key], check=True)
- try:
- time.sleep(seconds)
- finally:
- subprocess.run(["xdotool", "keyup", key], check=True)
-
-
-def start():
- if state()["active"]:
- cli("buildercamera")
- time.sleep(1.1)
- cli("buildercamera")
- time.sleep(0.2)
- result = state()
- assert result["active"], result
- return result
-
-
-def stop():
- if state()["active"]:
- cli("buildercamera")
-
-
-def near(a, b, tolerance=0.06):
- return math.dist(a, b) <= tolerance
-
-
-def aim(point):
- cli("builderproof", "aim", *point)
-
-
-subprocess.run(["wmctrl", "-a", "Valheim"], check=True)
-cli("cli_build_select", "piece_repair")
-results = {}
-try:
- # Keep the player stationary while attempting to cross a solid stone wall.
- before = start()
- x, y, z = before["camera"]
- cli("cli_spawn_at", "stone_wall_4x2", x, y - 1, z + 3)
- aim((x, y, z + 10))
- hold("w", 2)
- blocked = state()
- hold("w", 1)
- repeated = state()
- assert repeated["active"] and near(blocked["camera"], repeated["camera"]), (blocked, repeated)
- assert z < repeated["camera"][2] < z + 3, (before, repeated)
- assert near(before["body"], repeated["body"]), (before, repeated)
- results["wall"] = {"before": before, "blocked": blocked, "repeated": repeated, "wallZ": z + 3}
-
- # Move sideways out of the wall's width, then try to descend through terrain.
- hold("a", 1)
- hold("space", 1.5)
- raised = state()
- hold("Control_L", 4)
- blocked = state()
- hold("Control_L", 1)
- repeated = state()
- assert repeated["active"] and near(blocked["camera"], repeated["camera"]), (blocked, repeated)
- assert raised["camera"][1] - repeated["camera"][1] > 2, (raised, repeated)
- assert near(before["body"], repeated["body"]), (before, repeated)
- aim((repeated["camera"][0], -100, repeated["camera"][2]))
- ray = cli("cli_build_status")
- assert "heightmap=True" in ray, ray
- results["terrain"] = {"raised": raised, "blocked": blocked, "repeated": repeated, "ray": ray}
- stop()
-finally:
- stop()
- print(json.dumps(results, indent=2), flush=True)
From 7b2bbcd21005d91d1e85a94862538b3c6f8090bc Mon Sep 17 00:00:00 2001
From: jneb802 <73610029+jneb802@users.noreply.github.com>
Date: Mon, 28 Sep 2026 22:12:09 -0700
Subject: [PATCH 3/7] Remove build camera fuel requirement for unlimited use
---
Source/BuilderCamera/BuilderCamera.cs | 48 +++--------
Source/BuilderCamera/BuilderCameraContent.cs | 12 +--
Source/BuilderCamera/BuilderWard.cs | 85 +-------------------
3 files changed, 20 insertions(+), 125 deletions(-)
diff --git a/Source/BuilderCamera/BuilderCamera.cs b/Source/BuilderCamera/BuilderCamera.cs
index 0a2b613..8ca4e57 100644
--- a/Source/BuilderCamera/BuilderCamera.cs
+++ b/Source/BuilderCamera/BuilderCamera.cs
@@ -1,4 +1,3 @@
-using System;
using System.Collections.Generic;
using System.Reflection;
using HarmonyLib;
@@ -10,10 +9,6 @@ internal static partial class BuilderCamera
{
private static BuilderWard? _ward;
private static bool _active;
- private static bool _pending;
- private static float _requestedAt;
- private static float _paidUntil;
- private static int _requestId;
private static Vector3 _position;
private static Vector3 _bodyPosition;
private static Quaternion _rotation;
@@ -21,15 +16,14 @@ internal static partial class BuilderCamera
private static GUIStyle? _statusStyle;
private static int CollisionMask => LayerMask.GetMask("Default", "static_solid", "Default_small", "piece", "terrain", "vehicle");
internal static bool Active => _active;
- internal static string Status() => $"Build camera: active={_active}, pending={_pending}, equipped={(Player.m_localPlayer && Equipped(Player.m_localPlayer))}, camera={_position}, body={(Player.m_localPlayer ? Player.m_localPlayer.transform.position.ToString() : "none")}, fuel={(_ward ? _ward.Fuel : 0f):0.000}, wardLimit={WardRange.Value}, bodyLimit={BodyRange.Value}";
- private static bool Paid => _active && Time.time < _paidUntil;
+ internal static string Status() => $"Build camera: active={_active}, equipped={(Player.m_localPlayer && Equipped(Player.m_localPlayer))}, camera={_position}, body={(Player.m_localPlayer ? Player.m_localPlayer.transform.position.ToString() : "none")}, wardLimit={WardRange.Value}, bodyLimit={BodyRange.Value}";
internal static void DrawStatus()
{
if (!_active || !_ward || Hud.IsUserHidden()) return;
_statusStyle ??= new GUIStyle(GUI.skin.box) { alignment = TextAnchor.MiddleCenter, fontSize = 16 };
GUI.Box(new Rect(Screen.width / 2f - 240f, 45f, 480f, 58f),
- $"BUILD CAMERA | Fuel: {_ward.Fuel:0.0} eyes\n{ToggleKey.Value}: exit | Space / Left Ctrl: up / down", _statusStyle);
+ $"BUILD CAMERA\n{ToggleKey.Value}: exit | Space / Left Ctrl: up / down", _statusStyle);
}
private static bool UiOpen => Console.IsVisible() || Menu.IsVisible() || InventoryGui.IsVisible() || StoreGui.IsVisible()
@@ -47,7 +41,7 @@ internal static void Update()
{
Player player = Player.m_localPlayer;
if (!player || !GameCamera.instance) { Stop(null); return; }
- if ((_active || _pending) && (!Equipped(player) || player.IsDead() || player.IsTeleporting() || player.InCutscene()
+ if (_active && (!Equipped(player) || player.IsDead() || player.IsTeleporting() || player.InCutscene()
|| player.IsAttached() || !_ward || !PrivateArea.CheckAccess(_ward.transform.position, flash: false)
|| Vector3.Distance(player.transform.position, _bodyPosition) > 0.5f || player.GetHealth() < _health))
{
@@ -57,16 +51,15 @@ internal static void Update()
_health = player.GetHealth();
if (ToggleKey.Value.IsDown() && !UiOpen)
{
- if (_active || _pending) { Stop("Build camera ended."); return; }
+ if (_active) { Stop("Build camera ended."); return; }
Start(player);
}
- if (!_active && !_pending) return;
- if (!WithinLimits(_position) || (_pending && Time.time - _requestedAt > 3f))
+ if (!_active) return;
+ if (!WithinLimits(_position))
{
Stop("Build camera ended: ward unavailable or distance limit reached.");
return;
}
- if (_active && !Paid && !_pending) RequestFuel(player);
}
private static void Start(Player player)
@@ -81,9 +74,9 @@ private static void Start(Player player)
foreach (BuilderWard ward in BuilderWard.Instances)
{
float distance = Vector3.Distance(ward.transform.position, player.transform.position);
- if (distance <= nearest && ward.Fuel > 0f && PrivateArea.CheckAccess(ward.transform.position, flash: false)) { _ward = ward; nearest = distance; }
+ if (distance <= nearest && PrivateArea.CheckAccess(ward.transform.position, flash: false)) { _ward = ward; nearest = distance; }
}
- if (!_ward) { player.Message(MessageHud.MessageType.Center, "A fueled Builder's Ward is required nearby."); return; }
+ if (!_ward) { player.Message(MessageHud.MessageType.Center, "A Builder's Ward is required nearby."); return; }
_position = GameCamera.instance.transform.position;
_rotation = GameCamera.instance.transform.rotation;
_bodyPosition = player.transform.position;
@@ -95,31 +88,14 @@ private static void Start(Player player)
}
player.m_autoRun = false;
player.SetMoveDir(Vector3.zero);
- RequestFuel(player);
- }
-
- private static void RequestFuel(Player player)
- {
- _pending = true;
- _requestedAt = Time.time;
- _ward!.Request(player, ++_requestId);
- }
-
- internal static void OnGrant(BuilderWard ward, int requestId, float seconds)
- {
- if (!_pending || ward != _ward || requestId != _requestId) return;
- if (seconds <= 0f) { Stop("Builder's Ward has no available fuel."); return; }
- _pending = false;
- _paidUntil = Time.time + Mathf.Min(1f, seconds);
- if (!_active) Player.m_localPlayer.Message(MessageHud.MessageType.Center, "Build camera active. Your body remains vulnerable.");
_active = true;
+ player.Message(MessageHud.MessageType.Center, "Build camera active. Your body remains vulnerable.");
}
internal static void Stop(string? reason)
{
- bool wasRunning = _active || _pending;
+ bool wasRunning = _active;
_active = false;
- _pending = false;
_ward = null;
if (wasRunning && reason != null && Player.m_localPlayer) Player.m_localPlayer.Message(MessageHud.MessageType.Center, reason);
}
@@ -131,7 +107,7 @@ private static bool WithinLimits(Vector3 point) => _ward && Player.m_localPlayer
private static void MoveCamera(GameCamera camera, float dt)
{
if (Physics.CheckSphere(_position, 0.18f, CollisionMask, QueryTriggerInteraction.Ignore)) { Stop("Build camera ended: camera space is blocked."); return; }
- if (Paid && !UiOpen)
+ if (!UiOpen)
{
Vector2 mouse = ZInput.GetMouseDelta();
Vector3 angles = _rotation.eulerAngles;
@@ -158,7 +134,7 @@ private static void MoveCamera(GameCamera camera, float dt)
private static float BuildDistance(Vector3 first, Vector3 second)
{
if (!_active) return Vector3.Distance(first, second);
- if (!Paid || !Equipped(Player.m_localPlayer)) return float.MaxValue;
+ if (!Equipped(Player.m_localPlayer)) return float.MaxValue;
Vector3 eye = Player.m_localPlayer.m_eye.position;
Vector3 target = (first - eye).sqrMagnitude < (second - eye).sqrMagnitude ? second : first;
return WithinLimits(target) ? Vector3.Distance(_position, target) : float.MaxValue;
diff --git a/Source/BuilderCamera/BuilderCameraContent.cs b/Source/BuilderCamera/BuilderCameraContent.cs
index 050b93e..6e7b207 100644
--- a/Source/BuilderCamera/BuilderCameraContent.cs
+++ b/Source/BuilderCamera/BuilderCameraContent.cs
@@ -16,8 +16,6 @@ internal static partial class BuilderCamera
internal const string WardPrefab = "PraetorisBuilderWard";
internal static ConfigEntry BeltPrice = null!;
internal static ConfigEntry TraderPrefab = null!;
- internal static ConfigEntry MaxFuel = null!;
- internal static ConfigEntry SecondsPerEye = null!;
internal static ConfigEntry WardRange = null!;
internal static ConfigEntry BodyRange = null!;
internal static ConfigEntry ToggleKey = null!;
@@ -26,16 +24,14 @@ internal static void Initialize(ConfigFile config)
{
BeltPrice = Bind(config, "BeltPrice", 1500, "Coins charged by the trader.", new AcceptableValueRange(1, 100000));
TraderPrefab = Bind(config, "TraderPrefab", "Hildir", "Trader prefab that sells the Builder Belt (Hildir, Haldor, or BogWitch).");
- MaxFuel = Bind(config, "MaxFuel", 20, "Maximum greydwarf eyes stored by a Builder's Ward.", new AcceptableValueRange(1, 1000));
- SecondsPerEye = Bind(config, "SecondsPerEye", 120f, "Camera seconds supplied by one greydwarf eye, per active player.", new AcceptableValueRange(1f, 3600f));
WardRange = Bind(config, "WardRange", 30f, "Maximum camera and build target distance from the ward, in meters.", new AcceptableValueRange(2f, 100f));
BodyRange = Bind(config, "BodyRange", 30f, "Maximum camera and build target distance from the player's body, in meters.", new AcceptableValueRange(2f, 100f));
- ToggleKey = config.Bind("BuilderCamera", "ToggleKey", new KeyboardShortcut(KeyCode.F8), "Toggle build camera. Equip the Builder Belt and hold a build tool near a fueled Builder's Ward. WASD moves; Space rises; Left Ctrl lowers; Shift moves faster.");
+ ToggleKey = config.Bind("BuilderCamera", "ToggleKey", new KeyboardShortcut(KeyCode.F8), "Toggle build camera. Equip the Builder Belt and hold a build tool near a Builder's Ward. WASD moves; Space rises; Left Ctrl lowers; Shift moves faster.");
PrefabManager.OnVanillaPrefabsAvailable += RegisterContent;
new Terminal.ConsoleCommand("buildercamera", "Toggle build camera, or inspect it with: buildercamera status", args =>
{
if (args.Length > 1 && args[1] == "status") args.Context.AddString(Status());
- else if (_active || _pending) Stop("Build camera ended.");
+ else if (_active) Stop("Build camera ended.");
else if (Player.m_localPlayer && GameCamera.instance) Start(Player.m_localPlayer);
});
}
@@ -55,7 +51,7 @@ private static void RegisterContent()
{
CustomItem belt = new CustomItem(BeltPrefab, "BeltStrength");
belt.ItemDrop.m_itemData.m_shared.m_name = "Builder Belt";
- belt.ItemDrop.m_itemData.m_shared.m_description = "Equip this belt and hold a build tool near a fueled Builder's Ward to use the build camera. Your body remains vulnerable. Default key: F8.";
+ belt.ItemDrop.m_itemData.m_shared.m_description = "Equip this belt and hold a build tool near a Builder's Ward to use the build camera. Your body remains vulnerable. Default key: F8.";
belt.ItemDrop.m_itemData.m_shared.m_equipStatusEffect = null;
belt.ItemDrop.m_itemData.m_shared.m_setStatusEffect = null;
belt.ItemDrop.m_itemData.m_shared.m_setName = "";
@@ -65,7 +61,7 @@ private static void RegisterContent()
CustomPiece ward = new CustomPiece(WardPrefab, "guard_stone", new PieceConfig
{
Name = "Builder's Ward",
- Description = "Fuel with greydwarf eyes to use a Builder Belt camera. Does not protect your body or grant building permission.",
+ Description = "Enables unlimited use of a Builder Belt camera nearby. Does not protect your body or grant building permission.",
PieceTable = PieceTables.Hammer,
Category = PieceCategories.Misc,
Requirements = new[] { new RequirementConfig("Wood", 10, recover: true), new RequirementConfig("Stone", 10, recover: true), new RequirementConfig("GreydwarfEye", 5, recover: true) }
diff --git a/Source/BuilderCamera/BuilderWard.cs b/Source/BuilderCamera/BuilderWard.cs
index bb64d64..684d3f1 100644
--- a/Source/BuilderCamera/BuilderWard.cs
+++ b/Source/BuilderCamera/BuilderWard.cs
@@ -3,97 +3,20 @@
namespace PraetorisClient.BuilderCameraFeature
{
- public sealed class BuilderWard : MonoBehaviour, Hoverable, Interactable
+ public sealed class BuilderWard : MonoBehaviour, Hoverable
{
internal static readonly List Instances = new List();
- private const string FuelKey = "praetoris_builder_fuel";
- private ZNetView _view = null!;
- private readonly Dictionary _paidUntil = new Dictionary();
- internal float Fuel => _view && _view.IsValid() ? _view.GetZDO().GetFloat(FuelKey) : 0f;
private void Awake()
{
- _view = GetComponent();
- if (!_view || !_view.IsValid()) return;
- Instances.Add(this);
- _view.Register("BuilderFuel", AddFuel);
- _view.Register("BuilderLease", Grant);
- _view.Register("BuilderGrant", ReceiveGrant);
+ ZNetView view = GetComponent();
+ if (view && view.IsValid()) Instances.Add(this);
}
private void OnDestroy() { Instances.Remove(this); }
public string GetHoverName() => "Builder's Ward";
public float GetHoverOffset() => 0f;
- public string GetHoverText() => $"Builder's Ward\nFuel: {Fuel:0.0}/{BuilderCamera.MaxFuel.Value} greydwarf eyes\n[$KEY_Use] Add greydwarf eye\nEquip Builder Belt + build tool; {BuilderCamera.ToggleKey.Value} toggles camera";
- public bool Interact(Humanoid user, bool hold, bool alt)
- {
- if (hold) return false;
- return UseItem(user, user.GetInventory().GetItem("$item_greydwarfeye"));
- }
-
- public bool UseItem(Humanoid user, ItemDrop.ItemData item)
- {
- if (!(user is Player player) || player != Player.m_localPlayer || !_view.IsValid() || !PrivateArea.CheckAccess(transform.position)) return false;
- if (item == null || !item.m_dropPrefab || item.m_dropPrefab.name != "GreydwarfEye")
- {
- player.Message(MessageHud.MessageType.Center, "Requires greydwarf eyes.");
- return false;
- }
- if (Fuel > BuilderCamera.MaxFuel.Value - 1f + 0.00001f)
- {
- player.Message(MessageHud.MessageType.Center, "Builder's Ward is full.");
- return false;
- }
- // Like vanilla fueled pieces, the interacting player pays before the owner records fuel.
- player.GetInventory().RemoveItem(item, 1);
- _view.InvokeRPC("BuilderFuel", player.GetPlayerID());
- return true;
- }
-
- private Player? RequestPlayer(long sender, long playerId)
- {
- Player player = Player.GetPlayer(playerId);
- ZNetView view = player ? player.GetComponent() : null!;
- return view && view.IsValid() && view.GetZDO().GetOwner() == sender ? player : null;
- }
-
- private void AddFuel(long sender, long playerId)
- {
- if (!_view.IsOwner()) return;
- Player? player = RequestPlayer(sender, playerId);
- if (!player || Vector3.Distance(player.transform.position, transform.position) > 6f) return;
- _view.GetZDO().Set(FuelKey, Mathf.Min(BuilderCamera.MaxFuel.Value, Fuel + 1f));
- }
-
- internal void Request(Player player, int requestId) { _view.InvokeRPC("BuilderLease", player.GetPlayerID(), requestId); }
-
- private void Grant(long sender, long playerId, int requestId)
- {
- if (!_view.IsOwner()) return;
- Player? player = RequestPlayer(sender, playerId);
- float seconds = 0f;
- if (player && !player.IsDead() && Vector3.Distance(player.transform.position, transform.position) <= BuilderCamera.WardRange.Value)
- {
- if (_paidUntil.TryGetValue(sender, out float until) && until > Time.time)
- seconds = until - Time.time;
- else
- {
- seconds = Mathf.Min(1f, Fuel * BuilderCamera.SecondsPerEye.Value);
- if (seconds > 0f)
- {
- float remaining = Mathf.Max(0f, Fuel - seconds / BuilderCamera.SecondsPerEye.Value);
- _view.GetZDO().Set(FuelKey, remaining < 0.000001f ? 0f : remaining);
- _paidUntil[sender] = Time.time + seconds;
- }
- }
- }
- _view.InvokeRPC(sender, "BuilderGrant", requestId, seconds);
- }
-
- private void ReceiveGrant(long sender, int requestId, float seconds)
- {
- if (_view.IsValid() && sender == _view.GetZDO().GetOwner()) BuilderCamera.OnGrant(this, requestId, seconds);
- }
+ public string GetHoverText() => $"Builder's Ward\nEquip Builder Belt + build tool; {BuilderCamera.ToggleKey.Value} toggles camera";
}
}
From ea60fb22ccf4fbb328c488fdf1f6bb33082e9804 Mon Sep 17 00:00:00 2001
From: jneb802 <73610029+jneb802@users.noreply.github.com>
Date: Tue, 29 Sep 2026 08:39:31 -0700
Subject: [PATCH 4/7] Show feedback when build camera activation is blocked
---
Source/BuilderCamera/BuilderCamera.cs | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/Source/BuilderCamera/BuilderCamera.cs b/Source/BuilderCamera/BuilderCamera.cs
index 8ca4e57..8a29535 100644
--- a/Source/BuilderCamera/BuilderCamera.cs
+++ b/Source/BuilderCamera/BuilderCamera.cs
@@ -83,7 +83,8 @@ private static void Start(Player player)
_health = player.GetHealth();
if (!WithinLimits(_position) || Physics.CheckSphere(_position, 0.2f, CollisionMask, QueryTriggerInteraction.Ignore))
{
- Stop("Move the camera into clear space within the ward's range.");
+ _ward = null;
+ player.Message(MessageHud.MessageType.Center, "Move the camera into clear space within the ward's range.");
return;
}
player.m_autoRun = false;
From af23700449122365b3120e263ccf3077230ee0ea Mon Sep 17 00:00:00 2001
From: jneb802 <73610029+jneb802@users.noreply.github.com>
Date: Tue, 29 Sep 2026 10:11:49 -0700
Subject: [PATCH 5/7] Guard build reach callsites and handle missing local
player
---
Source/BuilderCamera/BuilderCamera.cs | 33 +++++++++++++++++++--------
1 file changed, 23 insertions(+), 10 deletions(-)
diff --git a/Source/BuilderCamera/BuilderCamera.cs b/Source/BuilderCamera/BuilderCamera.cs
index 8a29535..84a9b04 100644
--- a/Source/BuilderCamera/BuilderCamera.cs
+++ b/Source/BuilderCamera/BuilderCamera.cs
@@ -1,3 +1,4 @@
+using System;
using System.Collections.Generic;
using System.Reflection;
using HarmonyLib;
@@ -132,15 +133,17 @@ private static void MoveCamera(GameCamera camera, float dt)
// Change only the distance checks used by building. Normal placement, cost, station,
// protected-area, repair and removal checks still run on the real player.
- private static float BuildDistance(Vector3 first, Vector3 second)
+ private static float BuildDistance(Vector3 eye, Vector3 target)
{
- if (!_active) return Vector3.Distance(first, second);
- if (!Equipped(Player.m_localPlayer)) return float.MaxValue;
- Vector3 eye = Player.m_localPlayer.m_eye.position;
- Vector3 target = (first - eye).sqrMagnitude < (second - eye).sqrMagnitude ? second : first;
+ if (!_active) return Vector3.Distance(eye, target);
+ Player player = Player.m_localPlayer;
+ if (!player || !Equipped(player)) return float.MaxValue;
return WithinLimits(target) ? Vector3.Distance(_position, target) : float.MaxValue;
}
+ // RemovePiece passes the ray hit first; placement and hover pass the eye first.
+ private static float RemoveDistance(Vector3 target, Vector3 eye) => BuildDistance(eye, target);
+
[HarmonyPatch(typeof(GameCamera), "UpdateCamera")]
private static class CameraUpdate
{
@@ -176,15 +179,25 @@ private static IEnumerable TargetMethods()
yield return AccessTools.Method(typeof(Player), "RemovePiece");
yield return AccessTools.Method(typeof(Player), "UpdateWearNTearHover");
}
- private static IEnumerable Transpiler(IEnumerable instructions)
+ private static IEnumerable Transpiler(IEnumerable instructions, MethodBase __originalMethod)
{
MethodInfo distance = AccessTools.Method(typeof(Vector3), nameof(Vector3.Distance));
- MethodInfo replacement = AccessTools.Method(typeof(BuilderCamera), nameof(BuildDistance));
- foreach (CodeInstruction instruction in instructions)
+ List codes = new List(instructions);
+ // These game methods each contain exactly one distance call: eye-to-ray-hit
+ // build reach. Reject changed IL rather than patching unrelated distance checks.
+ int reachIndex = -1;
+ for (int index = 0; index < codes.Count; index++)
{
- if (instruction.Calls(distance)) instruction.operand = replacement;
- yield return instruction;
+ if (!codes[index].Calls(distance)) continue;
+ if (reachIndex >= 0)
+ throw new InvalidOperationException($"Builder camera: ambiguous build reach in {__originalMethod.Name}.");
+ reachIndex = index;
}
+ if (reachIndex < 0)
+ throw new InvalidOperationException($"Builder camera: missing build reach in {__originalMethod.Name}.");
+ string helper = __originalMethod.Name == "RemovePiece" ? nameof(RemoveDistance) : nameof(BuildDistance);
+ codes[reachIndex].operand = AccessTools.Method(typeof(BuilderCamera), helper);
+ return codes;
}
}
From b6d397c7da936bf86a6205a363430974a2b2d0f2 Mon Sep 17 00:00:00 2001
From: jneb802 <73610029+jneb802@users.noreply.github.com>
Date: Tue, 29 Sep 2026 14:59:39 -0700
Subject: [PATCH 6/7] Default build camera toggle to client-configurable B key
---
Source/BuilderCamera/BuilderCameraContent.cs | 6 ++++--
1 file changed, 4 insertions(+), 2 deletions(-)
diff --git a/Source/BuilderCamera/BuilderCameraContent.cs b/Source/BuilderCamera/BuilderCameraContent.cs
index 6e7b207..6654814 100644
--- a/Source/BuilderCamera/BuilderCameraContent.cs
+++ b/Source/BuilderCamera/BuilderCameraContent.cs
@@ -26,7 +26,9 @@ internal static void Initialize(ConfigFile config)
TraderPrefab = Bind(config, "TraderPrefab", "Hildir", "Trader prefab that sells the Builder Belt (Hildir, Haldor, or BogWitch).");
WardRange = Bind(config, "WardRange", 30f, "Maximum camera and build target distance from the ward, in meters.", new AcceptableValueRange(2f, 100f));
BodyRange = Bind(config, "BodyRange", 30f, "Maximum camera and build target distance from the player's body, in meters.", new AcceptableValueRange(2f, 100f));
- ToggleKey = config.Bind("BuilderCamera", "ToggleKey", new KeyboardShortcut(KeyCode.F8), "Toggle build camera. Equip the Builder Belt and hold a build tool near a Builder's Ward. WASD moves; Space rises; Left Ctrl lowers; Shift moves faster.");
+ ToggleKey = config.Bind("BuilderCamera", "ToggleKey", new KeyboardShortcut(KeyCode.B), new ConfigDescription(
+ "Local client hotkey to toggle build camera. Equip the Builder Belt and hold a build tool near a Builder's Ward. WASD moves; Space rises; Left Ctrl lowers; Shift moves faster.",
+ null, new ConfigurationManagerAttributes { IsAdminOnly = false }));
PrefabManager.OnVanillaPrefabsAvailable += RegisterContent;
new Terminal.ConsoleCommand("buildercamera", "Toggle build camera, or inspect it with: buildercamera status", args =>
{
@@ -51,7 +53,7 @@ private static void RegisterContent()
{
CustomItem belt = new CustomItem(BeltPrefab, "BeltStrength");
belt.ItemDrop.m_itemData.m_shared.m_name = "Builder Belt";
- belt.ItemDrop.m_itemData.m_shared.m_description = "Equip this belt and hold a build tool near a Builder's Ward to use the build camera. Your body remains vulnerable. Default key: F8.";
+ belt.ItemDrop.m_itemData.m_shared.m_description = "Equip this belt and hold a build tool near a Builder's Ward to use the build camera. Your body remains vulnerable. Default key: B.";
belt.ItemDrop.m_itemData.m_shared.m_equipStatusEffect = null;
belt.ItemDrop.m_itemData.m_shared.m_setStatusEffect = null;
belt.ItemDrop.m_itemData.m_shared.m_setName = "";
From 2c836798572a9600a2ec0190704f8baaf48ed021 Mon Sep 17 00:00:00 2001
From: jneb802 <73610029+jneb802@users.noreply.github.com>
Date: Tue, 29 Sep 2026 18:01:34 -0700
Subject: [PATCH 7/7] Block build camera activation while encumbered
---
Source/BuilderCamera/BuilderCamera.cs | 5 +++++
1 file changed, 5 insertions(+)
diff --git a/Source/BuilderCamera/BuilderCamera.cs b/Source/BuilderCamera/BuilderCamera.cs
index 84a9b04..6aea790 100644
--- a/Source/BuilderCamera/BuilderCamera.cs
+++ b/Source/BuilderCamera/BuilderCamera.cs
@@ -65,6 +65,11 @@ internal static void Update()
private static void Start(Player player)
{
+ if (player.IsEncumbered())
+ {
+ player.Message(MessageHud.MessageType.Center, "Cannot enter build camera while encumbered.");
+ return;
+ }
if (!Equipped(player) || player.IsDead() || player.IsTeleporting() || player.IsAttached() || player.InCutscene() || GameCamera.InFreeFly())
{
player.Message(MessageHud.MessageType.Center, "Equip a Builder Belt and hold a build tool to use the build camera.");