From b6aa28cf166b70508f0f03a3a266839ece84d1c7 Mon Sep 17 00:00:00 2001 From: jneb802 <73610029+jneb802@users.noreply.github.com> Date: Tue, 29 Sep 2026 23:44:23 -0700 Subject: [PATCH 1/3] Add uncapped scrolling server chest storage in 0.1.86 --- CHANGELOG.md | 7 ++ PraetorisClient.csproj | 1 + Properties/AssemblyInfo.cs | 4 +- README.md | 2 +- Source/Core/Plugin.cs | 2 +- Source/ServerChest/ServerChest.cs | 56 +++------ Source/ServerChest/ServerChestPatches.cs | 7 +- Source/ServerChest/ServerChestPiece.cs | 4 +- Source/ServerChest/ServerChestService.cs | 87 ++----------- Source/ServerChest/ServerChestStorage.cs | 145 ++++++++++++++++++++++ docs/server-chest-capacity-validation.md | 38 ++++++ docs/server-chest.md | 4 +- manifest.json | 2 +- tests/ServerChestProof/Probe.cs | 150 +++++++++++++++++++++++ tests/ServerChestProof/Probe.csproj | 4 + tests/ServerChestProof/README.md | 25 ++++ 16 files changed, 406 insertions(+), 132 deletions(-) create mode 100644 Source/ServerChest/ServerChestStorage.cs create mode 100644 docs/server-chest-capacity-validation.md create mode 100644 tests/ServerChestProof/Probe.cs create mode 100644 tests/ServerChestProof/Probe.csproj create mode 100644 tests/ServerChestProof/README.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 4274cd3..39d55fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +## 0.1.86 + +- Remove the Server Chest's 64-slot limit. The chest adds rows as deliveries arrive and uses the game's scrolling inventory view. +- Preserve large chest contents beyond the game's 256-row save limit. Existing chest saves remain readable. +- Refresh deliveries that arrive while the chest is open, and reuse loaded items when only the chest's open state changes. +- Install this version on both server and clients. Large chest saves cannot be read by older mod versions. + ## 0.1.84 - Add Adrenaline Echo, a Unique trinket shardstone available at Epic, Legendary, Mythic, and Ancient tiers. diff --git a/PraetorisClient.csproj b/PraetorisClient.csproj index 8572624..08e96d0 100644 --- a/PraetorisClient.csproj +++ b/PraetorisClient.csproj @@ -49,6 +49,7 @@ + PraetorisClient.GuideImages.%(Filename)%(Extension) diff --git a/Properties/AssemblyInfo.cs b/Properties/AssemblyInfo.cs index 07aab73..3c6cb52 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.85")] -[assembly: AssemblyFileVersion("0.1.85")] +[assembly: AssemblyVersion("0.1.86")] +[assembly: AssemblyFileVersion("0.1.86")] diff --git a/README.md b/README.md index cb3f806..b47c65a 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ PraetorisClient is the shared client-and-server mod for Praetoris-specific gamep - Each registered chest can belong to only one player. - Administrators can deliver items while the player is offline. - Players can remove delivered items, but they cannot place items into the chest. -- The chest supports up to 64 item slots and saves its contents in the world. +- The chest adds rows as deliveries arrive, with no configured slot limit. Scroll through the chest to reach later rows. Contents are saved in the world. - See the [Server Chest command guide](docs/server-chest.md) for delivery and lookup commands. #### Creature Owner Ward diff --git a/Source/Core/Plugin.cs b/Source/Core/Plugin.cs index 8c7e398..b7e6312 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.85"; + private const string ModVersion = "0.1.86"; private const string Author = "warpalicious"; private const string ModGUID = Author + "." + ModName; private const string EpicLootGuid = "randyknapp.mods.epicloot"; diff --git a/Source/ServerChest/ServerChest.cs b/Source/ServerChest/ServerChest.cs index 98e1f0f..a9e82ec 100644 --- a/Source/ServerChest/ServerChest.cs +++ b/Source/ServerChest/ServerChest.cs @@ -13,18 +13,16 @@ internal sealed class ServerChest : MonoBehaviour internal const string OwnerNameLookupKey = "ServerChestOwnerNameKey"; internal const string OwnerPlatformIdKey = "ServerChestOwnerPlatformId"; internal const string OwnerLookupKey = "ServerChestOwnerKey"; - internal const int MaxColumns = 8; - internal const int MaxRows = 8; - internal const int MaxSlots = MaxColumns * MaxRows; + internal const int Columns = 8; private static readonly Dictionary InventoryOwners = new(); private static readonly FieldInfoWrapper InventoryWidth = new(typeof(Inventory), "m_width"); private static readonly FieldInfoWrapper InventoryHeight = new(typeof(Inventory), "m_height"); - private static bool _suppressInventoryChanged; private Container? _container; private Inventory? _inventory; private ZNetView? _nview; + internal byte[]? LoadedItemData; private void Awake() { @@ -43,7 +41,6 @@ private void OnDestroy() { if (_inventory != null) { - _inventory.m_onChanged -= OnInventoryChanged; InventoryOwners.Remove(_inventory); } } @@ -63,28 +60,13 @@ private void RefreshInventoryRegistration() if (_inventory != null) { - _inventory.m_onChanged -= OnInventoryChanged; InventoryOwners.Remove(_inventory); } _inventory = inventory; InventoryOwners[inventory] = this; - ApplyMaxInventoryShape(inventory); + ResizeToFit(inventory); ServerChestLog.Debug("registered live inventory zdo=" + GetZdoId() + " items=" + inventory.NrOfItems().ToString(CultureInfo.InvariantCulture)); - inventory.m_onChanged += OnInventoryChanged; - } - - private void OnInventoryChanged() - { - if (_suppressInventoryChanged || _inventory == null || _nview == null || !_nview.IsValid() || !_nview.IsOwner()) - { - return; - } - - CompactInventory(_inventory); - ApplyMaxInventoryShape(_inventory); - SaveInventoryToZdo(_nview.GetZDO(), _inventory); - ServerChestLog.Debug("live inventory changed zdo=" + _nview.GetZDO().m_uid + " stacks=" + _inventory.NrOfItems().ToString(CultureInfo.InvariantCulture) + " items=" + _inventory.NrOfItemsIncludingStacks().ToString(CultureInfo.InvariantCulture)); } private void OnDestroyed() @@ -198,9 +180,11 @@ internal static string OwnerLookup(ZDO zdo) return zdo.GetString(OwnerLookupKey); } - internal static void ApplyMaxInventoryShape(Inventory inventory) + internal static void ResizeToFit(Inventory inventory, int additionalSlots = 0) { - ApplyInventoryShape(inventory, MaxColumns, MaxRows); + int slots = checked(inventory.NrOfItems() + additionalSlots); + int rows = Math.Max(1, slots / Columns + (slots % Columns == 0 ? 0 : 1)); + ApplyInventoryShape(inventory, Columns, rows); } internal static void ApplyInventoryShape(Inventory inventory, int columns, int rows) @@ -211,7 +195,7 @@ internal static void ApplyInventoryShape(Inventory inventory, int columns, int r internal static void CompactInventory(Inventory inventory) { - CompactInventory(inventory, MaxColumns); + CompactInventory(inventory, Columns); } internal static void CompactInventory(Inventory inventory, int columns) @@ -225,22 +209,23 @@ internal static void CompactInventory(Inventory inventory, int columns) internal static Inventory LoadInventoryFromZdo(ZDO zdo) { - Inventory inventory = new("ServerChest", null, MaxColumns, MaxRows); + Inventory inventory = new("ServerChest", null, Columns, 1); byte[]? data = zdo.GetByteArray(ZDOVars.s_items); if (data != null && data.Length > 0) { try { - inventory.Load(new ZPackage(data)); + ServerChestStorage.Load(inventory, new ZPackage(data)); } catch (Exception ex) { PraetorisClientPlugin.Log.LogWarning("Failed to load ServerChest inventory from ZDO " + zdo.m_uid + ": " + ex.Message); + throw; } } - ApplyMaxInventoryShape(inventory); CompactInventory(inventory); + ResizeToFit(inventory); ServerChestLog.Debug("loaded inventory zdo=" + zdo.m_uid + " stacks=" + inventory.NrOfItems().ToString(CultureInfo.InvariantCulture) + " items=" + inventory.NrOfItemsIncludingStacks().ToString(CultureInfo.InvariantCulture) + " dataLength=" + (data?.Length ?? 0).ToString(CultureInfo.InvariantCulture)); return inventory; } @@ -249,7 +234,8 @@ internal static void SaveInventoryToZdo(ZDO zdo, Inventory inventory) { CompactInventory(inventory); ZPackage package = new(); - inventory.Save(package); + ResizeToFit(inventory); + ServerChestStorage.Save(inventory, package); byte[] data = package.GetArray(); zdo.Set(ZDOVars.s_items, data); ServerChestLog.Debug("saved inventory zdo=" + zdo.m_uid + " stacks=" + inventory.NrOfItems().ToString(CultureInfo.InvariantCulture) + " items=" + inventory.NrOfItemsIncludingStacks().ToString(CultureInfo.InvariantCulture) + " dataLength=" + data.Length.ToString(CultureInfo.InvariantCulture)); @@ -272,20 +258,6 @@ internal static List FindAllZdos() return result; } - internal static void WithSuppressedInventoryChanged(Action action) - { - bool previous = _suppressInventoryChanged; - _suppressInventoryChanged = true; - try - { - action(); - } - finally - { - _suppressInventoryChanged = previous; - } - } - internal static void ShowMessage(string message) { if (Player.m_localPlayer != null) diff --git a/Source/ServerChest/ServerChestPatches.cs b/Source/ServerChest/ServerChestPatches.cs index e6089ca..06a772c 100644 --- a/Source/ServerChest/ServerChestPatches.cs +++ b/Source/ServerChest/ServerChestPatches.cs @@ -44,7 +44,7 @@ private static bool Prefix(InventoryGrid __instance, Inventory inventory) return true; } - ServerChest.ApplyMaxInventoryShape(inventory); + ServerChest.ResizeToFit(inventory); return true; } } @@ -53,6 +53,7 @@ private static bool Prefix(InventoryGrid __instance, Inventory inventory) internal static class ServerChestInventoryGuiUpdateContainerPatch { private static readonly FieldInfo? CurrentContainerField = AccessTools.Field(typeof(InventoryGui), "m_currentContainer"); + private static readonly FieldInfo GridElementsField = AccessTools.Field(typeof(InventoryGrid), "m_elements"); private static bool GridWasCompacted; private static void Postfix(InventoryGui __instance) @@ -86,7 +87,7 @@ private static void Postfix(InventoryGui __instance) if (containerGrid.m_gridRoot != null) { containerGrid.m_gridRoot.gameObject.SetActive(visibleSlots > 0); - float visibleRows = visibleSlots <= 0 ? 0f : (float)Math.Ceiling(visibleSlots / (double)ServerChest.MaxColumns); + float visibleRows = visibleSlots <= 0 ? 0f : (float)Math.Ceiling(visibleSlots / (double)ServerChest.Columns); containerGrid.m_gridRoot.SetSizeWithCurrentAnchors(RectTransform.Axis.Vertical, visibleRows * containerGrid.m_elementSpace); } @@ -106,7 +107,7 @@ private static void RestoreGrid(InventoryGrid inventoryGrid) private static void SetGridElementsActive(InventoryGrid inventoryGrid, int visibleSlots) { - List elements = inventoryGrid.m_elements; + List elements = (List)GridElementsField.GetValue(inventoryGrid); for (int index = 0; index < elements.Count; index++) { InventoryElement element = elements[index]; diff --git a/Source/ServerChest/ServerChestPiece.cs b/Source/ServerChest/ServerChestPiece.cs index 624befb..cd6749c 100644 --- a/Source/ServerChest/ServerChestPiece.cs +++ b/Source/ServerChest/ServerChestPiece.cs @@ -67,8 +67,8 @@ private static void ConfigurePrefab(GameObject prefab) if (container != null) { container.m_name = "Server Chest"; - container.m_width = ServerChest.MaxColumns; - container.m_height = ServerChest.MaxRows; + container.m_width = ServerChest.Columns; + container.m_height = 1; container.m_defaultItems = new DropTable(); container.m_autoDestroyEmpty = false; container.m_discoverStat = PlayerStatType.None; diff --git a/Source/ServerChest/ServerChestService.cs b/Source/ServerChest/ServerChestService.cs index f3eb318..e686ce1 100644 --- a/Source/ServerChest/ServerChestService.cs +++ b/Source/ServerChest/ServerChestService.cs @@ -107,42 +107,17 @@ internal static CommandResult SendItems(string characterName, IReadOnlyList + foreach (SendItem item in items) { - ServerChest.ApplyMaxInventoryShape(inventory); - foreach (SendItem item in items) + if (!TryAddItemAmount(inventory, item, out string error)) { - if (!TryAddItemAmount(inventory, item, out string error)) - { - addFailed = true; - addFailure = error; - return; - } + return CommandResult.Fail("ServerChest delivery failed while adding items: " + error + " No delivery was saved."); } - - if (!addFailed) - { - ServerChest.SaveInventoryToZdo(zdo, inventory); - } - }); - - if (addFailed) - { - ServerChestLog.Debug("send failed owner=" + characterName + " zdo=" + zdo.m_uid + " error=" + addFailure); - return CommandResult.Fail("ServerChest delivery failed while adding items: " + addFailure + " No delivery was saved."); } + ServerChest.SaveInventoryToZdo(zdo, inventory); - int totalAmount = items.Sum(item => item.Amount); + long totalAmount = items.Sum(item => (long)item.Amount); ServerChestLog.Debug("send complete owner=" + ServerChest.OwnerName(zdo) + " zdo=" + zdo.m_uid + " delivered=" + totalAmount.ToString(CultureInfo.InvariantCulture) + " finalStacks=" + inventory.NrOfItems().ToString(CultureInfo.InvariantCulture) + " finalItems=" + inventory.NrOfItemsIncludingStacks().ToString(CultureInfo.InvariantCulture)); return CommandResult.Ok("Delivered " + totalAmount.ToString(CultureInfo.InvariantCulture) + " item(s) to ServerChest for " + ServerChest.OwnerName(zdo) + "."); } @@ -158,8 +133,8 @@ internal static CommandResult Status(string characterName) Inventory inventory = ServerChest.LoadInventoryFromZdo(zdo); int stackCount = inventory.NrOfItems(); int itemCount = inventory.NrOfItemsIncludingStacks(); - int width = stackCount <= 0 ? 0 : ServerChest.MaxColumns; - int height = stackCount <= 0 ? 0 : Math.Max(1, (int)Math.Ceiling(stackCount / (double)ServerChest.MaxColumns)); + int width = stackCount <= 0 ? 0 : ServerChest.Columns; + int height = stackCount <= 0 ? 0 : inventory.GetHeight(); Vector3 position = zdo.GetPosition(); string message = "ServerChest owner=" + ServerChest.OwnerName(zdo) + @@ -269,53 +244,6 @@ internal static CommandResult ValidateItems(IReadOnlyList items) return CommandResult.Ok(""); } - private static int CountRequiredNewSlots(Inventory inventory, IReadOnlyList items) - { - int required = 0; - Dictionary freeStackSpace = new(); - foreach (ItemDrop.ItemData existing in inventory.GetAllItems()) - { - string key = StackKey(existing.m_shared.m_name, existing.m_quality, existing.m_worldLevel); - int free = Math.Max(0, existing.m_shared.m_maxStackSize - existing.m_stack); - if (!freeStackSpace.ContainsKey(key)) - { - freeStackSpace[key] = 0; - } - - freeStackSpace[key] += free; - } - - foreach (SendItem item in items) - { - GameObject prefab = ObjectDB.instance.GetItemPrefab(item.PrefabName); - ItemDrop itemDrop = prefab.GetComponent(); - int maxStack = Math.Max(1, itemDrop.m_itemData.m_shared.m_maxStackSize); - int worldLevel = (int)(byte)Game.m_worldLevel; - string key = StackKey(itemDrop.m_itemData.m_shared.m_name, item.Quality, worldLevel); - int remaining = item.Amount; - if (freeStackSpace.TryGetValue(key, out int free) && free > 0) - { - int used = Math.Min(remaining, free); - remaining -= used; - freeStackSpace[key] = free - used; - } - - if (remaining > 0) - { - int newSlots = (int)Math.Ceiling(remaining / (double)maxStack); - required += newSlots; - freeStackSpace[key] = newSlots * maxStack - remaining; - } - } - - return required; - } - - private static string StackKey(string sharedName, int quality, int worldLevel) - { - return sharedName + "|" + quality.ToString(CultureInfo.InvariantCulture) + "|" + worldLevel.ToString(CultureInfo.InvariantCulture); - } - private static bool TryAddItemAmount(Inventory inventory, SendItem item, out string error) { error = ""; @@ -331,6 +259,7 @@ private static bool TryAddItemAmount(Inventory inventory, SendItem item, out str while (remaining > 0) { int stackAmount = Math.Min(remaining, maxStack); + ServerChest.ResizeToFit(inventory, 1); int chunkBeforeAmount = CountMatchingAmount(inventory, sharedName, item.Quality, worldLevel); ItemDrop.ItemData added = inventory.AddItem(item.PrefabName, stackAmount, item.Quality, 0, 0L, "", cheated: false); int chunkAfterAmount = CountMatchingAmount(inventory, sharedName, item.Quality, worldLevel); diff --git a/Source/ServerChest/ServerChestStorage.cs b/Source/ServerChest/ServerChestStorage.cs new file mode 100644 index 0000000..46ff97a --- /dev/null +++ b/Source/ServerChest/ServerChestStorage.cs @@ -0,0 +1,145 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using HarmonyLib; + +namespace PraetorisClient.ServerChestFeature +{ + // Vanilla saves use byte row coordinates and a ushort stack count. Keep each + // embedded vanilla inventory within those bounds, then restore global rows. + internal static class ServerChestStorage + { + private const int FormatMarker = -1396918321; + private const int FormatVersion = 1; + private const int ChunkSlots = ServerChest.Columns * 256; + private static readonly System.Reflection.MethodInfo InventoryChanged = AccessTools.Method(typeof(Inventory), "Changed"); + + internal static void Save(Inventory inventory, ZPackage package) + { + ServerChest.CompactInventory(inventory); + List items = inventory.GetAllItemsInGridOrder(); + if (items.Count <= ChunkSlots) + { + inventory.Save(package); + return; + } + + package.Write(FormatMarker); + package.Write(FormatVersion); + package.Write(items.Count); + for (int start = 0; start < items.Count; start += ChunkSlots) + { + Inventory chunk = new("ServerChest", null, ServerChest.Columns, 256); + int count = Math.Min(ChunkSlots, items.Count - start); + for (int index = 0; index < count; index++) + { + ItemDrop.ItemData item = items[start + index].Clone(); + item.m_gridPos = new Vector2i(index % ServerChest.Columns, index / ServerChest.Columns); + chunk.GetAllItems().Add(item); + } + + ZPackage data = new(); + chunk.Save(data); + package.Write(data); + } + } + + internal static void Load(Inventory inventory, ZPackage package) + { + // Decode into a separate inventory: a failed read must not replace + // existing contents or trigger a save of partially loaded items. + Inventory loaded = new("ServerChest", null, ServerChest.Columns, 256); + List items; + int position = package.GetPos(); + if (package.ReadInt() != FormatMarker) + { + package.SetPos(position); + loaded.Load(package); + items = loaded.GetAllItemsInGridOrder(); + } + else + { + if (package.ReadInt() != FormatVersion) + throw new InvalidDataException("Unsupported ServerChest storage version."); + int count = package.ReadInt(); + if (count <= ChunkSlots) + throw new InvalidDataException("Invalid ServerChest stack count."); + for (int start = 0; start < count; start += ChunkSlots) + { + ZPackage data = package.ReadPackage(); + Inventory chunk = new("ServerChest", null, ServerChest.Columns, 256); + chunk.Load(data); + if (data.GetPos() != data.Size() || chunk.NrOfItems() != Math.Min(ChunkSlots, count - start)) + throw new InvalidDataException("Incomplete ServerChest storage chunk."); + // Serialization order is the order of slots across chunks. + loaded.GetAllItems().AddRange(chunk.GetAllItemsInGridOrder()); + } + items = loaded.GetAllItems(); + } + + for (int index = 0; index < items.Count; index++) + items[index].m_gridPos = new Vector2i(index % ServerChest.Columns, index / ServerChest.Columns); + inventory.GetAllItems().Clear(); + inventory.GetAllItems().AddRange(items); + ServerChest.ResizeToFit(inventory); + InventoryChanged.Invoke(inventory, new object[] { false, false }); + } + } + + [HarmonyPatch(typeof(Container), "Save")] + internal static class ServerChestContainerSavePatch + { + private static bool Prefix(Container __instance, ZNetView ___m_nview, ref uint ___m_lastRevision) + { + ServerChest chest = __instance.GetComponent(); + if (chest == null) return true; + ZDO zdo = ___m_nview.GetZDO(); + ServerChest.SaveInventoryToZdo(zdo, __instance.GetInventory()); + chest.LoadedItemData = zdo.GetByteArray(ZDOVars.s_items); + ___m_lastRevision = zdo.DataRevision; + return false; + } + } + + [HarmonyPatch(typeof(Container), "Load")] + internal static class ServerChestContainerLoadPatch + { + private static bool Prefix(Container __instance, ref bool __result, ZNetView ___m_nview, + ref uint ___m_lastRevision, ref bool ___m_loading) + { + ServerChest chest = __instance.GetComponent(); + if (chest == null) return true; + ZDO zdo = ___m_nview.GetZDO(); + // Deliveries can arrive after the open response. Unlike ordinary + // chests, this inventory must accept those updates while open. + // Local withdrawals already save and update the byte cache below. + if (zdo.DataRevision == ___m_lastRevision) + return false; + byte[] data = zdo.GetByteArray(ZDOVars.s_items); + // Opening and closing changes the ZDO revision too. Reuse the live + // inventory when the item bytes have not changed. + if (ReferenceEquals(data, chest.LoadedItemData) || + (data != null && chest.LoadedItemData != null && data.SequenceEqual(chest.LoadedItemData))) + { + ___m_lastRevision = zdo.DataRevision; + __result = true; + return false; + } + ___m_loading = true; + try + { + if (data != null && data.Length > 0) + ServerChestStorage.Load(__instance.GetInventory(), new ZPackage(data)); + chest.LoadedItemData = data; + ___m_lastRevision = zdo.DataRevision; + __result = true; + } + finally + { + ___m_loading = false; + } + return false; + } + } +} diff --git a/docs/server-chest-capacity-validation.md b/docs/server-chest-capacity-validation.md new file mode 100644 index 0000000..b19629c --- /dev/null +++ b/docs/server-chest-capacity-validation.md @@ -0,0 +1,38 @@ +# Server Chest capacity validation + +Validated version 0.1.86 on 2026-09-30 UTC with Valdev and Valnet client 01. + +## Baseline + +Both devices used the maintained `praetoris-season-8` profile with the deployed production release, 8.0.29. The original PraetorisClient version was 0.1.82. Server plugin and patcher hashes matched production. Shared client DLLs matched production; server-only omissions and the existing client command helper were retained. + +Only the candidate DLL, the separate client proof plugin, and their test configuration were added. Production was not changed. + +## Results + +- Release build passed with zero errors and 84 existing warnings. The separate proof plugin also built successfully. +- Real-game save/load checks passed with 0, 64, 65, 2,048, and 2,049 stacks. They checked positions and item metadata. A truncated grouped save was rejected without replacing existing contents. +- A save-only check encoded 65,537 stacks without truncating the count. This was not a live rendering test at that size. +- A delivery of 65 unstackable items appeared while the previously empty chest remained open. +- After one withdrawal and another delivery, the chest held 2,049 stacks across 257 rows, with unique positions for every stack. +- Mouse-wheel scrolling reached the last row. The final screenshot showed the last item and the scrollbar at the bottom. +- A server restart and client reconnect preserved all 2,049 stacks. Both server commands and the client reported the same contents. +- Withdrawing to 2,048 stacks saved and reloaded correctly in the native format. Player deposits remained blocked. +- An unrelated network-object revision preserved loaded item instances instead of decoding the inventory again. +- Opening a normal wooden chest afterwards restored its normal 10-slot grid. + +The final test flow produced no ServerChest exceptions. Startup graphics and asset warnings remained. Server graphics errors also appeared in the original baseline log. + +## Limits and compatibility + +There is no configured slot cap. Normal stack limits still apply. The native inventory grid creates elements for all rows, so large inventories cost memory, rendering time, save size, and network traffic. The 1,985-item delivery took about 34 seconds in this test environment. + +More than 2,048 stacks use the new grouped save format. Older mod versions cannot read that format. Deploy matching versions to the server and clients. + +## Reproduction and evidence + +See [the proof plugin instructions](../tests/ServerChestProof/README.md). The helper is excluded from the main build and release package. + +Local command captures and inspected screenshots are in `/Users/benjmarston/Develop/valheim-validation-evidence/server-chest-capacity-20260930`. The final evidence files use the `final-` prefix. + +Test containers and items were removed. Original profile files and the development character were restored. All 446 server and 312 client plugin/patcher files matched their original hashes after restoration. The restored server loaded PraetorisClient 0.1.82 and accepted a player-count query. Valdev was returned to its original stopped state, and the Valnet client was shut down. Both device leases were released. diff --git a/docs/server-chest.md b/docs/server-chest.md index 83f7023..a6f0970 100644 --- a/docs/server-chest.md +++ b/docs/server-chest.md @@ -70,4 +70,6 @@ serverchest_find bjo Use this before sending if you are not sure of the exact character name. `serverchest_send` and `serverchest_status` require one exact registered character name. Exact name matching ignores letter case. -Deliveries only save when the Server Chest has enough remaining capacity. Players can remove items from a Server Chest, but they cannot put items into it. +The Server Chest adds rows as deliveries arrive, with no configured slot limit. Use the inventory scrollbar or mouse wheel to reach later rows. Normal item stack limits still apply. Players can remove items from a Server Chest, but they cannot put items into it. + +Install the same PraetorisClient version on the server and clients. Existing chest saves load automatically. Chests with more than 2,048 stacks use a grouped save format to avoid the game's row-coordinate limit. Older mod versions cannot read that format. Memory, world-save size, network transfer size, and inventory rendering still limit practical storage; this feature does not provide infinite physical storage. diff --git a/manifest.json b/manifest.json index b024e2d..f976c72 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.85", + "version_number": "0.1.86", "website_url": "https://discord.gg/3aaru2VyHJ", "dependencies": [ "denikson-BepInExPack_Valheim-5.4.2333", diff --git a/tests/ServerChestProof/Probe.cs b/tests/ServerChestProof/Probe.cs new file mode 100644 index 0000000..e0b3731 --- /dev/null +++ b/tests/ServerChestProof/Probe.cs @@ -0,0 +1,150 @@ +using System; +using System.Linq; +using BepInEx; +using HarmonyLib; +using UnityEngine; +using UnityEngine.UI; + +[BepInPlugin("praetoris.validation.serverchest", "Server Chest Proof", "1.0.0")] +public class Probe : BaseUnityPlugin +{ + private Container chest; + private void Awake() + { + new Terminal.ConsoleCommand("chest_proof", "spawn/register/open/close/state/take/deposit/normal/roundtrip /cleanup", args => + { + try { Run(args, s => { Logger.LogInfo(s); args.Context.AddString(s); }); } + catch (Exception e) { Logger.LogError(e); args.Context.AddString("FAIL " + e); } + }, onlyAdmin: true); + } + private Container Chest() + { + if (chest == null) chest = UnityEngine.Object.FindObjectsOfType().Where(c => c.name.StartsWith("ServerChest") && c.GetComponent().GetZDO().GetBool("ServerChestProof")).OrderBy(c => Vector3.Distance(c.transform.position, Player.m_localPlayer.transform.position)).First(); + return chest; + } + private void Run(Terminal.ConsoleEventArgs args, Action log) + { + string action = args[1]; + if (action == "responses") + { + System.Collections.Generic.List lines = (System.Collections.Generic.List)AccessTools.Field(typeof(Terminal), "m_chatBuffer").GetValue(Console.instance); + foreach (string line in lines.ToArray().Where(l => l.Contains("ServerChest") || l.Contains("Delivered") || l.Contains("registered"))) log(System.Text.RegularExpressions.Regex.Replace(line, @"platformId=\S+", "platformId=[redacted]")); + return; + } + if (action == "adopt") + { + chest = UnityEngine.Object.FindObjectsOfType().Single(c => c.name.StartsWith("ServerChest") && c.GetComponent().GetZDO().m_uid.ToString() == args[2]); + chest.GetComponent().ClaimOwnership(); + chest.GetComponent().GetZDO().Set("ServerChestProof", true); + log("ADOPT exact test chest " + args[2]); return; + } + if (action == "roundtrip" || action == "saveonly") + { + int count = int.Parse(args[2]); + Type storage = AccessTools.TypeByName("PraetorisClient.ServerChestFeature.ServerChestStorage"); + Inventory source = new Inventory("proof", null, 8, Math.Max(1, (count + 7) / 8)); + ItemDrop.ItemData template = ObjectDB.instance.GetItemPrefab("SwordIron").GetComponent().m_itemData; + for (int i = 0; i < count; i++) + { + ItemDrop.ItemData item = template.Clone(); item.m_dropPrefab = ObjectDB.instance.GetItemPrefab("SwordIron"); + item.m_gridPos = new Vector2i(i % 8, i / 8); item.m_quality = 2; item.m_durability = 42.5f; + item.m_crafterName = "ChestProof"; item.m_crafterID = 123; + item.m_customData["proof-index"] = i.ToString(); + source.GetAllItems().Add(item); + } + ZPackage package = new ZPackage(); + AccessTools.Method(storage, "Save").Invoke(null, new object[] { source, package }); + if (action == "saveonly") + { + ZPackage read = new ZPackage(package.GetArray()); + int marker = read.ReadInt(); int version = read.ReadInt(); int total = read.ReadInt(); int actual = 0; + while (read.GetPos() < read.Size()) + { + ZPackage chunk = read.ReadPackage(); chunk.ReadInt(); actual += chunk.ReadUShort(); + } + log((marker < 0 && version == 1 && total == count && actual == count ? "PASS" : "FAIL") + " grouped save count=" + count + " encodedStacks=" + actual + " bytes=" + package.Size()); + return; + } + Inventory loaded = new Inventory("proof", null, 8, 1); + AccessTools.Method(storage, "Load").Invoke(null, new object[] { loaded, new ZPackage(package.GetArray()) }); + bool valid = loaded.NrOfItems() == count && loaded.GetHeight() == Math.Max(1, (count + 7) / 8); + for (int i = 0; i < count; i++) + { + ItemDrop.ItemData item = loaded.GetAllItems()[i]; + valid &= item.m_gridPos == new Vector2i(i % 8, i / 8) && item.m_quality == 2 && Math.Abs(item.m_durability - 42.5f) < .01f && item.m_crafterName == "ChestProof" && item.m_customData["proof-index"] == i.ToString(); + } + log((valid ? "PASS" : "FAIL") + " roundtrip count=" + count + " rows=" + loaded.GetHeight() + " bytes=" + package.Size() + " metadata-and-positions=" + valid); + if (count > 2048) + { + byte[] truncated = package.GetArray().Take(package.Size() - 5).ToArray(); + try { AccessTools.Method(storage, "Load").Invoke(null, new object[] { loaded, new ZPackage(truncated) }); log("FAIL truncated data accepted"); } + catch { log((loaded.NrOfItems() == count ? "PASS" : "FAIL") + " truncated data rejected without replacing contents"); } + } + return; + } + Player player = Player.m_localPlayer; + if (action == "spawn") + { + Vector3 position = player.transform.position + player.transform.forward * 2; + position.y = ZoneSystem.instance.GetGroundHeight(position); + chest = Instantiate(ZNetScene.instance.GetPrefab("ServerChest"), position, Quaternion.identity).GetComponent(); + chest.GetComponent().GetZDO().Set("ServerChestProof", true); + log("SPAWN chest at " + position); return; + } + if (action == "register") + { + Component component = Chest().GetComponent(AccessTools.TypeByName("PraetorisClient.ServerChestFeature.ServerChest")); + AccessTools.Method(component.GetType(), "RequestRegistration").Invoke(component, null); + log("REGISTER requested"); return; + } + if (action == "open") { Chest().Interact(player, false, false); log("OPEN requested"); return; } + if (action == "close") { InventoryGui.instance.Hide(); log("CLOSE"); return; } + if (action == "normal") + { + Container normal = Instantiate(ZNetScene.instance.GetPrefab("piece_chest_wood"), player.transform.position + player.transform.right * 2, Quaternion.identity).GetComponent(); + normal.gameObject.name = "ChestProofWood"; + normal.GetComponent().GetZDO().Set("ServerChestProof", true); + normal.Interact(player, false, false); log("NORMAL requested"); return; + } + if (action == "cleanup") + { + InventoryGui.instance.Hide(); + foreach (Container c in UnityEngine.Object.FindObjectsOfType().Where(c => c == chest || c.GetComponent().GetZDO().GetBool("ServerChestProof"))) + { c.GetComponent().ClaimOwnership(); c.GetInventory().RemoveAll(); ZNetScene.instance.Destroy(c.gameObject); } + log("CLEANUP"); return; + } + Inventory inventory = Chest().GetInventory(); + InventoryGrid grid = InventoryGui.instance.ContainerGrid; + if (action == "cache") + { + ItemDrop.ItemData first = inventory.GetAllItems().First(); + ZDO zdo = Chest().GetComponent().GetZDO(); + zdo.Set("ServerChestProofRevision", zdo.GetInt("ServerChestProofRevision") + 1); + bool refreshed = (bool)AccessTools.Method(typeof(Container), "Load").Invoke(Chest(), null); + log((refreshed && ReferenceEquals(first, inventory.GetAllItems().First()) ? "PASS" : "FAIL") + " unchanged item data retains live inventory"); return; + } + if (action == "take") + { + ItemDrop.ItemData item = inventory.GetAllItemsInGridOrder().Last(); + int before = inventory.NrOfItems(); + grid.m_onSelected(grid, item, item.m_gridPos, InventoryGrid.Modifier.Move); + log("TAKE before=" + before + " after=" + inventory.NrOfItems()); return; + } + if (action == "deposit") + { + ItemDrop.ItemData item = player.GetInventory().GetAllItems().First(); + int before = inventory.NrOfItems(); + bool moved = inventory.MoveItemToThis(player.GetInventory(), item, item.m_stack, 0, inventory.GetHeight() - 1); + log((!moved && before == inventory.NrOfItems() ? "PASS" : "FAIL") + " deposit blocked=" + !moved); return; + } + if (action == "state") + { + ZDO zdo = Chest().GetComponent().GetZDO(); + log("DATA stacks=" + inventory.NrOfItems() + " rows=" + inventory.GetHeight() + " bytes=" + (zdo.GetByteArray(ZDOVars.s_items)?.Length ?? 0) + " revision=" + zdo.DataRevision + " loadedRevision=" + AccessTools.Field(typeof(Container), "m_lastRevision").GetValue(Chest()) + " owner=" + Chest().IsOwner()); + ScrollRect scroll = grid.m_gridRoot.GetComponentInParent(); + System.Collections.Generic.List elements = (System.Collections.Generic.List)AccessTools.Field(typeof(InventoryGrid), "m_elements").GetValue(grid); + Container current = (Container)AccessTools.Field(typeof(InventoryGui), "m_currentContainer").GetValue(InventoryGui.instance); + log("STATE stacks=" + inventory.NrOfItems() + " rows=" + inventory.GetHeight() + " uniquePositions=" + inventory.GetAllItems().Select(i => i.m_gridPos).Distinct().Count() + " gridElements=" + elements.Count + " active=" + elements.Count(e => e.gameObject.activeSelf) + " contentHeight=" + grid.m_gridRoot.rect.height + " scroll=" + (scroll == null ? "missing" : scroll.verticalNormalizedPosition.ToString()) + " viewport=" + (scroll == null ? "missing" : (scroll.viewport != null ? scroll.viewport.rect.height : ((RectTransform)scroll.transform).rect.height).ToString()) + " current=" + current?.name); + } + } +} diff --git a/tests/ServerChestProof/Probe.csproj b/tests/ServerChestProof/Probe.csproj new file mode 100644 index 0000000..ec7e7e9 --- /dev/null +++ b/tests/ServerChestProof/Probe.csproj @@ -0,0 +1,4 @@ + + + net48110ServerChestProof + diff --git a/tests/ServerChestProof/README.md b/tests/ServerChestProof/README.md new file mode 100644 index 0000000..e0cce47 --- /dev/null +++ b/tests/ServerChestProof/README.md @@ -0,0 +1,25 @@ +# Server Chest live validation + +This is a separate test plugin. It is excluded from the PraetorisClient build and release package. Use it only on a leased test client with a development character and the matching candidate on the test server. + +Build with `dotnet build tests/ServerChestProof/Probe.csproj -c Release`. Install `ServerChestProof.dll` on the test client and allow `praetoris.validation.serverchest` version `1.0.0` in the test server's admin-only mod list. Back up and restore the affected profile files after testing. + +Run these console commands after connecting: + +```text +chest_proof roundtrip 0 +chest_proof roundtrip 1 +chest_proof roundtrip 64 +chest_proof roundtrip 65 +chest_proof roundtrip 2048 +chest_proof roundtrip 2049 +chest_proof saveonly 65537 +chest_proof spawn +chest_proof register +``` + +Each round-trip check verifies stack count, row count, positions, quality, durability, crafter name, and custom item data. The 2,049-stack check also verifies that a truncated save is rejected without replacing existing contents. The save-only check verifies that more than 65,535 stacks can be encoded without count truncation. + +Send items through `serverchest_send SwordIron 65`. Wait for delivery, then run `chest_proof open`. Use the mouse wheel and scrollbar to reach the last row. Capture and inspect screenshots. `chest_proof state` reports item count, unique positions, grid elements, and scroll position. `chest_proof take` withdraws the last stack through the inventory UI callback. `chest_proof deposit` checks that a deposit remains blocked. + +Repeat with 2,049 stacks. Also send a small delivery while the chest is open and check that the count updates. Run `chest_proof cache` to check that an unrelated network-object change keeps the same loaded item instances. Reconnect and restart the test server to check persistence. Use `chest_proof normal` to check the ordinary wooden chest grid after the server chest. Finish with `chest_proof cleanup`. This deletes the marked proof chest and its test items. Restore the development character and profile backups. From 70fa8e77101fb601fde18b23cdfb56f9af19cac4 Mon Sep 17 00:00:00 2001 From: jneb802 <73610029+jneb802@users.noreply.github.com> Date: Wed, 30 Sep 2026 09:17:07 -0700 Subject: [PATCH 2/3] Remove test files and documentation changes from chest feature --- CHANGELOG.md | 7 -- PraetorisClient.csproj | 1 - README.md | 2 +- docs/server-chest-capacity-validation.md | 38 ------ docs/server-chest.md | 4 +- tests/ServerChestProof/Probe.cs | 150 ----------------------- tests/ServerChestProof/Probe.csproj | 4 - tests/ServerChestProof/README.md | 25 ---- 8 files changed, 2 insertions(+), 229 deletions(-) delete mode 100644 docs/server-chest-capacity-validation.md delete mode 100644 tests/ServerChestProof/Probe.cs delete mode 100644 tests/ServerChestProof/Probe.csproj delete mode 100644 tests/ServerChestProof/README.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 39d55fa..4274cd3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,10 +1,3 @@ -## 0.1.86 - -- Remove the Server Chest's 64-slot limit. The chest adds rows as deliveries arrive and uses the game's scrolling inventory view. -- Preserve large chest contents beyond the game's 256-row save limit. Existing chest saves remain readable. -- Refresh deliveries that arrive while the chest is open, and reuse loaded items when only the chest's open state changes. -- Install this version on both server and clients. Large chest saves cannot be read by older mod versions. - ## 0.1.84 - Add Adrenaline Echo, a Unique trinket shardstone available at Epic, Legendary, Mythic, and Ancient tiers. diff --git a/PraetorisClient.csproj b/PraetorisClient.csproj index 08e96d0..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 b47c65a..cb3f806 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ PraetorisClient is the shared client-and-server mod for Praetoris-specific gamep - Each registered chest can belong to only one player. - Administrators can deliver items while the player is offline. - Players can remove delivered items, but they cannot place items into the chest. -- The chest adds rows as deliveries arrive, with no configured slot limit. Scroll through the chest to reach later rows. Contents are saved in the world. +- The chest supports up to 64 item slots and saves its contents in the world. - See the [Server Chest command guide](docs/server-chest.md) for delivery and lookup commands. #### Creature Owner Ward diff --git a/docs/server-chest-capacity-validation.md b/docs/server-chest-capacity-validation.md deleted file mode 100644 index b19629c..0000000 --- a/docs/server-chest-capacity-validation.md +++ /dev/null @@ -1,38 +0,0 @@ -# Server Chest capacity validation - -Validated version 0.1.86 on 2026-09-30 UTC with Valdev and Valnet client 01. - -## Baseline - -Both devices used the maintained `praetoris-season-8` profile with the deployed production release, 8.0.29. The original PraetorisClient version was 0.1.82. Server plugin and patcher hashes matched production. Shared client DLLs matched production; server-only omissions and the existing client command helper were retained. - -Only the candidate DLL, the separate client proof plugin, and their test configuration were added. Production was not changed. - -## Results - -- Release build passed with zero errors and 84 existing warnings. The separate proof plugin also built successfully. -- Real-game save/load checks passed with 0, 64, 65, 2,048, and 2,049 stacks. They checked positions and item metadata. A truncated grouped save was rejected without replacing existing contents. -- A save-only check encoded 65,537 stacks without truncating the count. This was not a live rendering test at that size. -- A delivery of 65 unstackable items appeared while the previously empty chest remained open. -- After one withdrawal and another delivery, the chest held 2,049 stacks across 257 rows, with unique positions for every stack. -- Mouse-wheel scrolling reached the last row. The final screenshot showed the last item and the scrollbar at the bottom. -- A server restart and client reconnect preserved all 2,049 stacks. Both server commands and the client reported the same contents. -- Withdrawing to 2,048 stacks saved and reloaded correctly in the native format. Player deposits remained blocked. -- An unrelated network-object revision preserved loaded item instances instead of decoding the inventory again. -- Opening a normal wooden chest afterwards restored its normal 10-slot grid. - -The final test flow produced no ServerChest exceptions. Startup graphics and asset warnings remained. Server graphics errors also appeared in the original baseline log. - -## Limits and compatibility - -There is no configured slot cap. Normal stack limits still apply. The native inventory grid creates elements for all rows, so large inventories cost memory, rendering time, save size, and network traffic. The 1,985-item delivery took about 34 seconds in this test environment. - -More than 2,048 stacks use the new grouped save format. Older mod versions cannot read that format. Deploy matching versions to the server and clients. - -## Reproduction and evidence - -See [the proof plugin instructions](../tests/ServerChestProof/README.md). The helper is excluded from the main build and release package. - -Local command captures and inspected screenshots are in `/Users/benjmarston/Develop/valheim-validation-evidence/server-chest-capacity-20260930`. The final evidence files use the `final-` prefix. - -Test containers and items were removed. Original profile files and the development character were restored. All 446 server and 312 client plugin/patcher files matched their original hashes after restoration. The restored server loaded PraetorisClient 0.1.82 and accepted a player-count query. Valdev was returned to its original stopped state, and the Valnet client was shut down. Both device leases were released. diff --git a/docs/server-chest.md b/docs/server-chest.md index a6f0970..83f7023 100644 --- a/docs/server-chest.md +++ b/docs/server-chest.md @@ -70,6 +70,4 @@ serverchest_find bjo Use this before sending if you are not sure of the exact character name. `serverchest_send` and `serverchest_status` require one exact registered character name. Exact name matching ignores letter case. -The Server Chest adds rows as deliveries arrive, with no configured slot limit. Use the inventory scrollbar or mouse wheel to reach later rows. Normal item stack limits still apply. Players can remove items from a Server Chest, but they cannot put items into it. - -Install the same PraetorisClient version on the server and clients. Existing chest saves load automatically. Chests with more than 2,048 stacks use a grouped save format to avoid the game's row-coordinate limit. Older mod versions cannot read that format. Memory, world-save size, network transfer size, and inventory rendering still limit practical storage; this feature does not provide infinite physical storage. +Deliveries only save when the Server Chest has enough remaining capacity. Players can remove items from a Server Chest, but they cannot put items into it. diff --git a/tests/ServerChestProof/Probe.cs b/tests/ServerChestProof/Probe.cs deleted file mode 100644 index e0b3731..0000000 --- a/tests/ServerChestProof/Probe.cs +++ /dev/null @@ -1,150 +0,0 @@ -using System; -using System.Linq; -using BepInEx; -using HarmonyLib; -using UnityEngine; -using UnityEngine.UI; - -[BepInPlugin("praetoris.validation.serverchest", "Server Chest Proof", "1.0.0")] -public class Probe : BaseUnityPlugin -{ - private Container chest; - private void Awake() - { - new Terminal.ConsoleCommand("chest_proof", "spawn/register/open/close/state/take/deposit/normal/roundtrip /cleanup", args => - { - try { Run(args, s => { Logger.LogInfo(s); args.Context.AddString(s); }); } - catch (Exception e) { Logger.LogError(e); args.Context.AddString("FAIL " + e); } - }, onlyAdmin: true); - } - private Container Chest() - { - if (chest == null) chest = UnityEngine.Object.FindObjectsOfType().Where(c => c.name.StartsWith("ServerChest") && c.GetComponent().GetZDO().GetBool("ServerChestProof")).OrderBy(c => Vector3.Distance(c.transform.position, Player.m_localPlayer.transform.position)).First(); - return chest; - } - private void Run(Terminal.ConsoleEventArgs args, Action log) - { - string action = args[1]; - if (action == "responses") - { - System.Collections.Generic.List lines = (System.Collections.Generic.List)AccessTools.Field(typeof(Terminal), "m_chatBuffer").GetValue(Console.instance); - foreach (string line in lines.ToArray().Where(l => l.Contains("ServerChest") || l.Contains("Delivered") || l.Contains("registered"))) log(System.Text.RegularExpressions.Regex.Replace(line, @"platformId=\S+", "platformId=[redacted]")); - return; - } - if (action == "adopt") - { - chest = UnityEngine.Object.FindObjectsOfType().Single(c => c.name.StartsWith("ServerChest") && c.GetComponent().GetZDO().m_uid.ToString() == args[2]); - chest.GetComponent().ClaimOwnership(); - chest.GetComponent().GetZDO().Set("ServerChestProof", true); - log("ADOPT exact test chest " + args[2]); return; - } - if (action == "roundtrip" || action == "saveonly") - { - int count = int.Parse(args[2]); - Type storage = AccessTools.TypeByName("PraetorisClient.ServerChestFeature.ServerChestStorage"); - Inventory source = new Inventory("proof", null, 8, Math.Max(1, (count + 7) / 8)); - ItemDrop.ItemData template = ObjectDB.instance.GetItemPrefab("SwordIron").GetComponent().m_itemData; - for (int i = 0; i < count; i++) - { - ItemDrop.ItemData item = template.Clone(); item.m_dropPrefab = ObjectDB.instance.GetItemPrefab("SwordIron"); - item.m_gridPos = new Vector2i(i % 8, i / 8); item.m_quality = 2; item.m_durability = 42.5f; - item.m_crafterName = "ChestProof"; item.m_crafterID = 123; - item.m_customData["proof-index"] = i.ToString(); - source.GetAllItems().Add(item); - } - ZPackage package = new ZPackage(); - AccessTools.Method(storage, "Save").Invoke(null, new object[] { source, package }); - if (action == "saveonly") - { - ZPackage read = new ZPackage(package.GetArray()); - int marker = read.ReadInt(); int version = read.ReadInt(); int total = read.ReadInt(); int actual = 0; - while (read.GetPos() < read.Size()) - { - ZPackage chunk = read.ReadPackage(); chunk.ReadInt(); actual += chunk.ReadUShort(); - } - log((marker < 0 && version == 1 && total == count && actual == count ? "PASS" : "FAIL") + " grouped save count=" + count + " encodedStacks=" + actual + " bytes=" + package.Size()); - return; - } - Inventory loaded = new Inventory("proof", null, 8, 1); - AccessTools.Method(storage, "Load").Invoke(null, new object[] { loaded, new ZPackage(package.GetArray()) }); - bool valid = loaded.NrOfItems() == count && loaded.GetHeight() == Math.Max(1, (count + 7) / 8); - for (int i = 0; i < count; i++) - { - ItemDrop.ItemData item = loaded.GetAllItems()[i]; - valid &= item.m_gridPos == new Vector2i(i % 8, i / 8) && item.m_quality == 2 && Math.Abs(item.m_durability - 42.5f) < .01f && item.m_crafterName == "ChestProof" && item.m_customData["proof-index"] == i.ToString(); - } - log((valid ? "PASS" : "FAIL") + " roundtrip count=" + count + " rows=" + loaded.GetHeight() + " bytes=" + package.Size() + " metadata-and-positions=" + valid); - if (count > 2048) - { - byte[] truncated = package.GetArray().Take(package.Size() - 5).ToArray(); - try { AccessTools.Method(storage, "Load").Invoke(null, new object[] { loaded, new ZPackage(truncated) }); log("FAIL truncated data accepted"); } - catch { log((loaded.NrOfItems() == count ? "PASS" : "FAIL") + " truncated data rejected without replacing contents"); } - } - return; - } - Player player = Player.m_localPlayer; - if (action == "spawn") - { - Vector3 position = player.transform.position + player.transform.forward * 2; - position.y = ZoneSystem.instance.GetGroundHeight(position); - chest = Instantiate(ZNetScene.instance.GetPrefab("ServerChest"), position, Quaternion.identity).GetComponent(); - chest.GetComponent().GetZDO().Set("ServerChestProof", true); - log("SPAWN chest at " + position); return; - } - if (action == "register") - { - Component component = Chest().GetComponent(AccessTools.TypeByName("PraetorisClient.ServerChestFeature.ServerChest")); - AccessTools.Method(component.GetType(), "RequestRegistration").Invoke(component, null); - log("REGISTER requested"); return; - } - if (action == "open") { Chest().Interact(player, false, false); log("OPEN requested"); return; } - if (action == "close") { InventoryGui.instance.Hide(); log("CLOSE"); return; } - if (action == "normal") - { - Container normal = Instantiate(ZNetScene.instance.GetPrefab("piece_chest_wood"), player.transform.position + player.transform.right * 2, Quaternion.identity).GetComponent(); - normal.gameObject.name = "ChestProofWood"; - normal.GetComponent().GetZDO().Set("ServerChestProof", true); - normal.Interact(player, false, false); log("NORMAL requested"); return; - } - if (action == "cleanup") - { - InventoryGui.instance.Hide(); - foreach (Container c in UnityEngine.Object.FindObjectsOfType().Where(c => c == chest || c.GetComponent().GetZDO().GetBool("ServerChestProof"))) - { c.GetComponent().ClaimOwnership(); c.GetInventory().RemoveAll(); ZNetScene.instance.Destroy(c.gameObject); } - log("CLEANUP"); return; - } - Inventory inventory = Chest().GetInventory(); - InventoryGrid grid = InventoryGui.instance.ContainerGrid; - if (action == "cache") - { - ItemDrop.ItemData first = inventory.GetAllItems().First(); - ZDO zdo = Chest().GetComponent().GetZDO(); - zdo.Set("ServerChestProofRevision", zdo.GetInt("ServerChestProofRevision") + 1); - bool refreshed = (bool)AccessTools.Method(typeof(Container), "Load").Invoke(Chest(), null); - log((refreshed && ReferenceEquals(first, inventory.GetAllItems().First()) ? "PASS" : "FAIL") + " unchanged item data retains live inventory"); return; - } - if (action == "take") - { - ItemDrop.ItemData item = inventory.GetAllItemsInGridOrder().Last(); - int before = inventory.NrOfItems(); - grid.m_onSelected(grid, item, item.m_gridPos, InventoryGrid.Modifier.Move); - log("TAKE before=" + before + " after=" + inventory.NrOfItems()); return; - } - if (action == "deposit") - { - ItemDrop.ItemData item = player.GetInventory().GetAllItems().First(); - int before = inventory.NrOfItems(); - bool moved = inventory.MoveItemToThis(player.GetInventory(), item, item.m_stack, 0, inventory.GetHeight() - 1); - log((!moved && before == inventory.NrOfItems() ? "PASS" : "FAIL") + " deposit blocked=" + !moved); return; - } - if (action == "state") - { - ZDO zdo = Chest().GetComponent().GetZDO(); - log("DATA stacks=" + inventory.NrOfItems() + " rows=" + inventory.GetHeight() + " bytes=" + (zdo.GetByteArray(ZDOVars.s_items)?.Length ?? 0) + " revision=" + zdo.DataRevision + " loadedRevision=" + AccessTools.Field(typeof(Container), "m_lastRevision").GetValue(Chest()) + " owner=" + Chest().IsOwner()); - ScrollRect scroll = grid.m_gridRoot.GetComponentInParent(); - System.Collections.Generic.List elements = (System.Collections.Generic.List)AccessTools.Field(typeof(InventoryGrid), "m_elements").GetValue(grid); - Container current = (Container)AccessTools.Field(typeof(InventoryGui), "m_currentContainer").GetValue(InventoryGui.instance); - log("STATE stacks=" + inventory.NrOfItems() + " rows=" + inventory.GetHeight() + " uniquePositions=" + inventory.GetAllItems().Select(i => i.m_gridPos).Distinct().Count() + " gridElements=" + elements.Count + " active=" + elements.Count(e => e.gameObject.activeSelf) + " contentHeight=" + grid.m_gridRoot.rect.height + " scroll=" + (scroll == null ? "missing" : scroll.verticalNormalizedPosition.ToString()) + " viewport=" + (scroll == null ? "missing" : (scroll.viewport != null ? scroll.viewport.rect.height : ((RectTransform)scroll.transform).rect.height).ToString()) + " current=" + current?.name); - } - } -} diff --git a/tests/ServerChestProof/Probe.csproj b/tests/ServerChestProof/Probe.csproj deleted file mode 100644 index ec7e7e9..0000000 --- a/tests/ServerChestProof/Probe.csproj +++ /dev/null @@ -1,4 +0,0 @@ - - - net48110ServerChestProof - diff --git a/tests/ServerChestProof/README.md b/tests/ServerChestProof/README.md deleted file mode 100644 index e0cce47..0000000 --- a/tests/ServerChestProof/README.md +++ /dev/null @@ -1,25 +0,0 @@ -# Server Chest live validation - -This is a separate test plugin. It is excluded from the PraetorisClient build and release package. Use it only on a leased test client with a development character and the matching candidate on the test server. - -Build with `dotnet build tests/ServerChestProof/Probe.csproj -c Release`. Install `ServerChestProof.dll` on the test client and allow `praetoris.validation.serverchest` version `1.0.0` in the test server's admin-only mod list. Back up and restore the affected profile files after testing. - -Run these console commands after connecting: - -```text -chest_proof roundtrip 0 -chest_proof roundtrip 1 -chest_proof roundtrip 64 -chest_proof roundtrip 65 -chest_proof roundtrip 2048 -chest_proof roundtrip 2049 -chest_proof saveonly 65537 -chest_proof spawn -chest_proof register -``` - -Each round-trip check verifies stack count, row count, positions, quality, durability, crafter name, and custom item data. The 2,049-stack check also verifies that a truncated save is rejected without replacing existing contents. The save-only check verifies that more than 65,535 stacks can be encoded without count truncation. - -Send items through `serverchest_send SwordIron 65`. Wait for delivery, then run `chest_proof open`. Use the mouse wheel and scrollbar to reach the last row. Capture and inspect screenshots. `chest_proof state` reports item count, unique positions, grid elements, and scroll position. `chest_proof take` withdraws the last stack through the inventory UI callback. `chest_proof deposit` checks that a deposit remains blocked. - -Repeat with 2,049 stacks. Also send a small delivery while the chest is open and check that the count updates. Run `chest_proof cache` to check that an unrelated network-object change keeps the same loaded item instances. Reconnect and restart the test server to check persistence. Use `chest_proof normal` to check the ordinary wooden chest grid after the server chest. Finish with `chest_proof cleanup`. This deletes the marked proof chest and its test items. Restore the development character and profile backups. From 30ecff67dbbd09d044788a1d883b691bd2c5aa25 Mon Sep 17 00:00:00 2001 From: jneb802 <73610029+jneb802@users.noreply.github.com> Date: Wed, 30 Sep 2026 09:29:53 -0700 Subject: [PATCH 3/3] Return command errors when server chest contents cannot load --- Source/ServerChest/ServerChestService.cs | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/Source/ServerChest/ServerChestService.cs b/Source/ServerChest/ServerChestService.cs index e686ce1..f1e230e 100644 --- a/Source/ServerChest/ServerChestService.cs +++ b/Source/ServerChest/ServerChestService.cs @@ -105,7 +105,15 @@ internal static CommandResult SendItems(string characterName, IReadOnlyList