Files
necromants-tome-7d2d-3-2/HarmonySrc/SpatialVaultPersistence.cs
T
AlexCubeandClaude Opus 5.5 e8e21d5a58 Сборка под игру 3.3: хранилище, Пирамида, проверка версии
- Пирамида: StreamModeWrite/Read теперь глобальные перечисления (в 3.2 были внутри TileEntity).
- Хранилище: Bag поверх ItemStackGrid (конструктор с местом хранения, Resize, ItemGrid),
  запись и чтение через PooledBinaryWriter/Reader. Формат хранилища 2; формат 1 из сейвов 3.2
  читается. Незнакомый номер формата больше не считается «хранилища нет» - байты сохраняются.
- Сфера на браслете: ModificationCount/GetModification/SetModification.
- Непустой сундук: TEFeatureStorage.IsEmpty (ITileEntityLootable в 3.3 нет).
- Поиск ножа в инвентаре через ItemGrid.
- GameVersionGuard пускает только 3.3.

Проверено в игре на V 3.3.0 b14: хранилище из сейва 3.2 (24 стопки) прочиталось, сохранилось
и перечиталось; Пирамида включается; «Жертва», обыск трупа и мешки работают.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 17:44:11 +03:00

464 lines
22 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System;
using System.IO;
using System.Runtime.CompilerServices;
using System.Text;
using HarmonyLib;
using UnityEngine;
namespace NecromancerTome
{
/// <summary>
/// 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.
/// </summary>
public static class SpatialVaultPersistence
{
/// <summary>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.
///
/// 2 = игра 3.3 (мод 1.5.0): сумка пишется новым Bag.Write (свой байт версии 2 и
/// ItemStackGrid). 1 = игра 3.2, старый формат сумки; 3.3 читает его сам (Bag.readInto,
/// ветка версии &lt; 2), поэтому хранилище из сейва 3.2 переезжает без потерь.
/// Номер поднят, чтобы сборка под 3.2 не приняла новый формат за свой.</summary>
public const byte PayloadVersion = 2;
/// <summary>Старейший номер, который эта сборка ещё читает.</summary>
public const byte OldestReadablePayloadVersion = 1;
/// <summary>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.</summary>
public class VaultSlot
{
public Bag Bag;
/// <summary>Байты хранилища, которые эта сборка не прочитала: версия игры не та, под
/// которую собран мод, или разбор упал. Пишутся обратно в файл байт в байт.</summary>
public byte[] RawPayload;
}
/// <summary>ХРАНИЛИЩЕ НЕ ДОЛЖНО ТЕРЯТЬСЯ НА ЧУЖОЙ ВЕРСИИ ИГРЫ, 1.4.2 (2026-09-24). В 1.4.1 на
/// игре 3.3 разбор хранилища падал, и при сохранении мод писал вместо него ПУСТОЙ маркер -
/// вещи пропадали навсегда. Теперь непрочитанные байты проходят всю цепочку сохранения
/// нетронутыми: Read кладёт их в слот файла, ToPlayer переносит к игроку (сюда), FromPlayer
/// отдаёт новому файлу (игра на каждом сохранении создаёт НОВЫЙ PlayerDataFile), Write
/// пишет их как есть. Сборка под нужную версию потом прочтёт их обычным путём.
///
/// Ключ - entityId. Очищается при каждом открытии главного меню (GameVersionGuard.cs), чтобы
/// байты одного мира не перешли к персонажу другого.
///
/// Пока байты здесь, хранилище не открывается и не пополняется (SpatialVaultPatch.OpenVault,
/// SpatialVaultPickup.Begin): вещи, положенные в новое пустое хранилище, всё равно не
/// сохранились бы - запись отдаёт приоритет нетронутым байтам.</summary>
public static readonly System.Collections.Generic.Dictionary<int, byte[]> UnreadableByEntity =
new System.Collections.Generic.Dictionary<int, byte[]>();
public static bool IsLocked(EntityPlayer _player)
{
return _player != null && UnreadableByEntity.ContainsKey(_player.entityId);
}
public static void AttachRaw(PlayerDataFile _file, byte[] _raw)
{
AttachedVaults.Remove(_file);
AttachedVaults.Add(_file, new VaultSlot { RawPayload = _raw });
}
/// <summary>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.</summary>
public static readonly ConditionalWeakTable<PlayerDataFile, VaultSlot> AttachedVaults =
new ConditionalWeakTable<PlayerDataFile, VaultSlot>();
/// <summary>
/// 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.
/// </summary>
public static Bag LastLoadedVault;
/// <summary>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.</summary>
public static string lastSaveLogged;
/// <summary>Builds the opaque payload SpatialVaultBlobIO wraps. Заголовок (версия и
/// «есть ли сумка») пишется в MemoryStream напрямую, а сама сумка - через
/// PooledBinaryWriter поверх того же потока: в 3.3 Bag.Write принимает только его.
/// Сами Write-перегрузки PooledBinaryWriter из этого проекта не вызываются (см.
/// NecromancerTEPersistence.csproj), поэтому заголовок и не идёт через него.</summary>
public static byte[] BuildPayload(Bag _bag)
{
using (MemoryStream ms = new MemoryStream())
{
ms.WriteByte(PayloadVersion);
bool hasBag = _bag != null;
ms.WriteByte(hasBag ? (byte)1 : (byte)0);
if (hasBag)
{
using (PooledBinaryWriter pw = MemoryPools.poolBinaryWriter.AllocSync(_bReset: false))
{
pw.SetBaseStream(ms);
_bag.Write(pw, StreamModeWrite.Persistency);
pw.Flush();
}
}
return ms.ToArray();
}
}
/// <summary>Null when the payload holds no vault. Незнакомый номер версии - исключение,
/// а НЕ null: null значит «хранилища нет», и следующее сохранение стёрло бы вещи.
/// Исключение ловит Read-постфикс и сохраняет байты нетронутыми (AttachRaw).</summary>
public static Bag ParsePayload(byte[] _payload)
{
if (_payload == null || _payload.Length == 0)
{
return null;
}
using (MemoryStream ms = new MemoryStream(_payload, false))
{
int version = ms.ReadByte();
if (version < OldestReadablePayloadVersion || version > PayloadVersion)
{
throw new InvalidDataException("vault payload version " + version + ", this build reads " +
OldestReadablePayloadVersion + ".." + PayloadVersion);
}
if (ms.ReadByte() != 1)
{
return null;
}
// Сумка сама пишет свой номер формата, и Bag.readInto 3.3 понимает оба: и старый
// (из сейва 3.2), и новый. Место хранения - как у сумки дрона (то же окно), владельца нет:
// так же игра сама создаёт сумку из старого сейва (PlayerDataFile, legacyBag).
using (PooledBinaryReader pr = MemoryPools.poolBinaryReader.AllocSync(_bReset: false))
{
pr.SetBaseStream(ms);
return Bag.Read(pr, XUiC_ItemStack.StackLocationTypes.LootContainer, StreamModeRead.Persistency, null);
}
}
}
public static void Attach(PlayerDataFile _file, Bag _bag)
{
AttachedVaults.Remove(_file);
AttachedVaults.Add(_file, new VaultSlot { Bag = _bag });
}
/// <summary>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.</summary>
public static string Describe(Bag _bag)
{
if (_bag == null)
{
return "NONE";
}
ItemStackGrid slots = _bag.ItemGrid;
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.GetItem(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();
}
}
/// <summary>Live player -> save file: take the vault along.</summary>
[HarmonyPatch(typeof(PlayerDataFile), "FromPlayer")]
public static class Patch_PlayerDataFile_FromPlayer_SpatialVault
{
public static void Postfix(PlayerDataFile __instance, EntityPlayer _player)
{
try
{
if (_player == null)
{
return;
}
if (SpatialVaultPersistence.UnreadableByEntity.TryGetValue(_player.entityId, out byte[] raw))
{
SpatialVaultPersistence.AttachRaw(__instance, raw);
string rawLine = "FromPlayer entity " + _player.entityId + " - carrying " + raw.Length + " unread byte(s) unchanged";
if (rawLine != SpatialVaultPersistence.lastSaveLogged)
{
SpatialVaultPersistence.lastSaveLogged = rawLine;
Debug.Log("[NecromancerTome] SpatialVaultPersistence: " + rawLine);
}
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);
}
}
}
/// <summary>
/// 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.
/// </summary>
[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 rawSlot) &&
rawSlot.RawPayload != null)
{
SpatialVaultPersistence.UnreadableByEntity[_player.entityId] = rawSlot.RawPayload;
Patch_ItemActionEat_ExecuteAction_SpatialVault.PlayerVaults.Remove(_player.entityId);
if (isLocal)
{
SpatialVaultPersistence.LastLoadedVault = null;
}
Debug.LogWarning("[NecromancerTome] SpatialVaultPersistence: ToPlayer entity " + _player.entityId +
" - vault kept as " + rawSlot.RawPayload.Length + " unread byte(s); it stays locked and is saved unchanged");
return;
}
SpatialVaultPersistence.UnreadableByEntity.Remove(_player.entityId);
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);
}
}
}
/// <summary>Appends the vault after everything vanilla wrote - to disk via Save, or to the
/// wire via WriteNetwork.</summary>
[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);
if (slot != null && slot.RawPayload != null)
{
SpatialVaultBlobIO.Write(_bw, slot.RawPayload);
return;
}
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);
}
}
}
/// <summary>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".</summary>
[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;
}
if (!GameVersionGuard.IsSupported || GameVersionGuard.ForceVaultUnread)
{
SpatialVaultPersistence.AttachRaw(__instance, payload);
Debug.LogWarning("[NecromancerTome] SpatialVaultPersistence: Read - game version not supported, " +
payload.Length + " byte(s) kept unread and will be saved unchanged");
return;
}
Bag bag;
try
{
bag = SpatialVaultPersistence.ParsePayload(payload);
}
catch (Exception parseError)
{
// Упал сам разбор (на 3.3 это был MissingMethodException на Bag.Read). Байты не
// выбрасываем: они уйдут в файл как были.
SpatialVaultPersistence.AttachRaw(__instance, payload);
Debug.LogError("[NecromancerTome] SpatialVaultPersistence: Read - could not parse the vault, " +
payload.Length + " byte(s) kept unread and will be saved unchanged: " + parseError);
return;
}
// 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);
}
}
}
}