using System; using System.IO; using System.Runtime.CompilerServices; using System.Text; using HarmonyLib; using UnityEngine; namespace NecromancerTome { /// /// Cross-restart persistence for the Spatial Bracelet's vault - the fix for the first bug /// report the mod ever got on Nexus (youkia96581, 11 Sep 2026: "Items stored in the space /// bracelet will disappear after leaving the game and going online again"). /// /// WHY IT LIVES IN THE PLAYER'S SAVE FILE - "почему не сделать принцип как у ящика?" (user, /// 13.09.2026). Right question, and it decided the design. A chest keeps its items because /// they live in a TileEntity, and a TileEntity belongs to a CHUNK: decompiled, `TileEntity` /// has chunkPos and chunk fields and its ONLY constructor is TileEntity(Chunk). The game saves /// and syncs the chunk; the container rides along. That is the whole trick - not a "storage /// system" one can call, but a home in something the engine already persists. The bracelet has /// no position and no chunk, so it got the closest equivalent for something personal: the /// player's own save data, written right after everything vanilla writes, in the same file and /// the same moment as the backpack. /// /// THAT ALSO ANSWERS THE ID QUESTION ("у браслета, как и у ящика, наверняка есть id"). A /// chest's id IS its position. An item has no per-instance id by default - ItemValue.type is /// the item CLASS, identical on every bracelet - but ItemValue.Metadata would hold one and /// genuinely round-trips through saves (ItemValue.Write writes it, ItemValue.ReadData reads it /// back; both checked). Per-bracelet vaults are therefore buildable and deliberately not built: /// keying by the item means losing the bracelet locks the items away forever even though they /// are still in the save file, and it would let ten bracelets be ten warehouses. Keying by the /// player - which storing them IN the player's file does for free - has neither problem. /// /// THE FOUR HOOKS: /// FromPlayer - live player -> file object: attach that player's vault to the file. /// Write - file object -> bytes (Save to disk, or WriteNetwork to the wire, which is /// literally Write + PlayerMetaInfo): append the vault blob. /// Read - bytes -> file object: pull the vault back off the stream. /// ToPlayer - file object -> live player: hand the vault back. /// FromPlayer always reads the CURRENT vault, so there is no dirty flag and no save scheduling /// to get wrong: whenever the game saves the player, it saves the vault. /// /// ================================================================================ /// THE BUG THAT COST TWO TEST RUNS, AND WHY IT IS WORTH A BIG COMMENT /// ================================================================================ /// Earlier versions cleared the session cache from a ModEvents.WorldShuttingDown handler, to /// stop one save's vault leaking into the next. The user reported the vault kept losing its /// contents, and the diagnostics printed the murder weapon in order: /// /// INF SaveAndCleanupWorld /// [NecromancerTome] world shutting down, dropped 1 in-memory vault(s) /// [NecromancerTome] FromPlayer entity 171 - vault NONE /// [NecromancerTome] Write - no vault attached (writes an EMPTY marker) /// /// **WorldShuttingDown fires BEFORE the final player save, not after.** Confirmed in /// GameManager.SaveAndCleanupWorld by decompilation rather than inferred from the log: the /// event is invoked at IL_0026 and SaveLocalPlayerData() is called at IL_00c4, a hundred-odd /// instructions later. So the handler emptied the cache, and the save that followed /// faithfully recorded "this player has no vault" over the real one. Every clean exit wiped /// the vault - which is exactly the symptom the Nexus report described, reintroduced by the /// fix for it. /// /// There is no documentation to have checked first: the community consensus is that the /// official ModAPI is barebones and has no reference for event ordering, so the decompiler is /// the only authority. Treat every ModEvent's position in the shutdown sequence as unknown /// until read out of the method that invokes it. /// /// TWO RULES CAME OUT OF IT, and both are load-bearing here: /// /// 1. A RESTORE PATH MAY FAIL; IT MAY NEVER DELETE. An empty session cache is not evidence /// that the player has no vault - it is the absence of evidence. LastLoadedVault below is /// the safety net, so a broken restore chain costs a restore, not the data. /// 2. FRESHNESS IS DECIDED BY WHAT WAS READ, NOT BY A TIMER. Cross-save leaking is now /// prevented by ToPlayer being authoritative: a player file that was read and explicitly /// carried no vault CLEARS the cache. Nothing has to be cleared "at the right moment" /// any more, which is what made the old approach fragile in the first place. /// public static class SpatialVaultPersistence { /// Payload layout version, independent of the blob framing in /// SpatialVaultBlobIO. An unknown version is skipped, not guessed at - the framing's /// explicit length means we can always step over a payload we do not understand. public const byte PayloadVersion = 1; /// What a PlayerDataFile carries. A class rather than a bare Bag because its mere /// PRESENCE is information: "this file has been read/filled, and the answer - including a /// null Bag - is authoritative". ConditionalWeakTable cannot store null, so a null Bag /// needs a wrapper to be expressible at all. public class VaultSlot { public Bag Bag; } /// Vault attached to a PlayerDataFile while it is being written, read or /// converted. Weak, because PlayerDataFile objects are created fresh for every save and /// every network packet and nothing here should keep one alive. public static readonly ConditionalWeakTable AttachedVaults = new ConditionalWeakTable(); /// /// Last vault seen this session, kept outside the weak table. This is rule 1 above made /// concrete: if the Read -> ToPlayer -> PlayerVaults chain ever fails to complete, the bag /// is still here, so the next save writes the real contents instead of an empty marker. /// /// SINGLE LOCAL PLAYER ONLY. There is one of these per process, so on a dedicated server /// it would be one player's vault handed to whoever asked next. Every use is gated on the /// player being an EntityPlayerLocal - which a dedicated server does not have, and a host /// or single-player game has exactly one of. /// public static Bag LastLoadedVault; /// Last line printed by the save path, so an unchanged vault saved over and over /// does not repeat itself in the log. Kept 2026-09-13 when the fix was confirmed: the /// save pair fires on every autosave, and a player's log should not carry two lines of /// inventory listing every few minutes - but the moment anything CHANGES it still says so, /// which is the part that had diagnostic value. public static string lastSaveLogged; /// Builds the opaque payload SpatialVaultBlobIO wraps. Uses netstandard's own /// BinaryWriter over a MemoryStream, which is why Bag serialization can stay in this /// project instead of the satellite assembly. public static byte[] BuildPayload(Bag _bag) { using (MemoryStream ms = new MemoryStream()) using (BinaryWriter bw = new BinaryWriter(ms)) { bw.Write(PayloadVersion); bool hasBag = _bag != null; bw.Write(hasBag); if (hasBag) { // Plain BinaryWriter is enough: Bag.Write only demands a PooledBinaryWriter // when bag.preferences != null, and vault bags come from `new Bag(int)`, whose // constructor sets nothing but the item array. _bag.Write(bw); } bw.Flush(); return ms.ToArray(); } } /// Null when the payload holds no vault or is a version we do not know. public static Bag ParsePayload(byte[] _payload) { if (_payload == null || _payload.Length == 0) { return null; } using (MemoryStream ms = new MemoryStream(_payload, false)) using (BinaryReader br = new BinaryReader(ms)) { byte version = br.ReadByte(); if (version != PayloadVersion) { Debug.LogWarning("[NecromancerTome] SpatialVaultPersistence: vault payload version " + version + ", expected " + PayloadVersion + " - skipped"); return null; } if (!br.ReadBoolean()) { return null; } // Bag.Read is the STATIC one and returns a new Bag; ReadInto is the instance // version. Symmetric with BuildPayload: preferences were written as absent, so no // PooledBinaryReader is needed here either. return Bag.Read(br); } } public static void Attach(PlayerDataFile _file, Bag _bag) { AttachedVaults.Remove(_file); AttachedVaults.Add(_file, new VaultSlot { Bag = _bag }); } /// Contents of a bag, for the log. Item names rather than just a count, because /// "2 slots, 0 used" was true and useless three test runs in a row - what was needed was /// whether the items the user put in had actually reached this object. public static string Describe(Bag _bag) { if (_bag == null) { return "NONE"; } ItemStack[] slots = _bag.GetSlots(); StringBuilder sb = new StringBuilder(); sb.Append(_bag.SlotCount).Append(" slots, ").Append(_bag.GetUsedSlotCount()).Append(" used"); if (slots != null) { for (int i = 0; i < slots.Length; i++) { ItemStack stack = slots[i]; if (stack == null || stack.IsEmpty()) { continue; } string name = stack.itemValue != null && stack.itemValue.ItemClass != null ? stack.itemValue.ItemClass.GetItemName() : "?"; sb.Append(" [").Append(i).Append("]=").Append(name).Append("x").Append(stack.count); } } return sb.ToString(); } } /// Live player -> save file: take the vault along. [HarmonyPatch(typeof(PlayerDataFile), "FromPlayer")] public static class Patch_PlayerDataFile_FromPlayer_SpatialVault { public static void Postfix(PlayerDataFile __instance, EntityPlayer _player) { try { if (_player == null) { return; } Patch_ItemActionEat_ExecuteAction_SpatialVault.PlayerVaults.TryGetValue(_player.entityId, out Bag bag); string source = bag != null ? "session cache" : null; if (bag == null && _player is EntityPlayerLocal && SpatialVaultPersistence.LastLoadedVault != null) { // Rule 1: never write "no vault" over a vault we know exists. bag = SpatialVaultPersistence.LastLoadedVault; source = "last loaded (session cache was empty)"; } SpatialVaultPersistence.Attach(__instance, bag); string line = "FromPlayer entity " + _player.entityId + " - " + SpatialVaultPersistence.Describe(bag) + (source != null ? ", from " + source : ""); if (line != SpatialVaultPersistence.lastSaveLogged) { SpatialVaultPersistence.lastSaveLogged = line; Debug.Log("[NecromancerTome] SpatialVaultPersistence: " + line); } } catch (Exception e) { Debug.LogError("[NecromancerTome] SpatialVaultPersistence: FromPlayer postfix failed: " + e); } } } /// /// Save file -> live player: hand the vault back. This is also where freshness is decided /// (rule 2): a file that WAS read and explicitly carried no vault clears the cache, so loading /// a different save cannot inherit the previous world's vault. Only a file that was never read /// at all falls back to LastLoadedVault, which is the broken-chain safety net. /// [HarmonyPatch(typeof(PlayerDataFile), "ToPlayer")] public static class Patch_PlayerDataFile_ToPlayer_SpatialVault { public static void Postfix(PlayerDataFile __instance, EntityPlayer _player) { try { if (_player == null) { return; } bool isLocal = _player is EntityPlayerLocal; string note; Bag bag; if (SpatialVaultPersistence.AttachedVaults.TryGetValue(__instance, out SpatialVaultPersistence.VaultSlot slot)) { bag = slot.Bag; note = bag != null ? "from this player file" : "this player file says there is no vault"; } else if (isLocal && SpatialVaultPersistence.LastLoadedVault != null) { bag = SpatialVaultPersistence.LastLoadedVault; note = "nothing attached to this file - fell back to the last loaded vault"; } else { bag = null; note = "nothing attached and nothing loaded"; } if (bag != null) { Patch_ItemActionEat_ExecuteAction_SpatialVault.PlayerVaults[_player.entityId] = bag; } else { Patch_ItemActionEat_ExecuteAction_SpatialVault.PlayerVaults.Remove(_player.entityId); } if (isLocal) { SpatialVaultPersistence.LastLoadedVault = bag; } Debug.Log("[NecromancerTome] SpatialVaultPersistence: ToPlayer entity " + _player.entityId + " - " + SpatialVaultPersistence.Describe(bag) + " (" + note + ")"); } catch (Exception e) { Debug.LogError("[NecromancerTome] SpatialVaultPersistence: ToPlayer postfix failed: " + e); } } } /// Appends the vault after everything vanilla wrote - to disk via Save, or to the /// wire via WriteNetwork. [HarmonyPatch(typeof(PlayerDataFile), "Write")] public static class Patch_PlayerDataFile_Write_SpatialVault { public static void Postfix(PlayerDataFile __instance, PooledBinaryWriter _bw) { try { SpatialVaultPersistence.AttachedVaults.TryGetValue(__instance, out SpatialVaultPersistence.VaultSlot slot); Bag bag = slot != null ? slot.Bag : null; SpatialVaultBlobIO.Write(_bw, SpatialVaultPersistence.BuildPayload(bag)); if (bag == null) { // Always shouted: writing an empty marker is how the vault got destroyed twice, // so it must never again scroll past unnoticed. Debug.LogWarning("[NecromancerTome] SpatialVaultPersistence: Write - no vault attached (writes an EMPTY marker)"); } } catch (Exception e) { Debug.LogError("[NecromancerTome] SpatialVaultPersistence: Write postfix failed: " + e); } } } /// Reads the vault back off the stream. Must never throw: PlayerDataFile.Load treats /// any exception out of Read as "this save is broken, fall back to the .bak". [HarmonyPatch(typeof(PlayerDataFile), "Read")] public static class Patch_PlayerDataFile_Read_SpatialVault { public static void Postfix(PlayerDataFile __instance, PooledBinaryReader _br) { try { byte[] payload = SpatialVaultBlobIO.TryRead(_br); if (payload == null) { // No vault block: a save from before this feature existed, or player data from // somebody without the mod. Deliberately NOT recorded as an authoritative // "no vault" - an absent block is silence, not a denial, and ToPlayer's // fallback is what should handle it. SpatialVaultBlobIO has already put the // stream position back. Debug.Log("[NecromancerTome] SpatialVaultPersistence: Read - no vault block on this stream"); return; } Bag bag = SpatialVaultPersistence.ParsePayload(payload); // Attached even when null: a blob that says "no vault" IS an answer, and ToPlayer // uses it to clear a stale cache when a different save is loaded. SpatialVaultPersistence.Attach(__instance, bag); if (bag != null) { SpatialVaultPersistence.LastLoadedVault = bag; } Debug.Log("[NecromancerTome] SpatialVaultPersistence: Read - blob of " + payload.Length + " byte(s), " + SpatialVaultPersistence.Describe(bag)); } catch (Exception e) { Debug.LogError("[NecromancerTome] SpatialVaultPersistence: Read postfix failed: " + e); } } } }