Хранилище браслета переживает перезапуск; торговцы чёрно-белые
Исправляет первый баг-репорт мода на Nexus (youkia96581, 11.09.2026): "Items stored in the space bracelet will disappear after leaving the game and going online again". Причина была записана в коде как нерешённая: PlayerVaults - обычный статический Dictionary, save/load не существовало. ХРАНИЛИЩЕ ТЕПЕРЬ ЖИВЁТ В PlayerDataFile, рядом с рюкзаком игрока. Так решено после вопроса пользователя "почему не сделать принцип как у ящика?": ящик хранит вещи тем, что они лежат в чанке (у TileEntity единственный конструктор TileEntity(Chunk)), а браслету нужен был дом в чём-то, что движок и так сохраняет. Четыре постфикса - FromPlayer/Write/Read/ToPlayer, блоб с магией "NECROVLT" и явной длиной дописывается после всего ванильного. Байтовая часть - в сателлитной сборке: PooledBinaryWriter.Write не резолвится из основного проекта (CS7069), как и у PyramidWardWriteHelper. Два дефекта, найденные и убитые по дороге живыми тестами: 1. ModEvents.WorldShuttingDown приходит ПЕРЕД финальным сохранением игрока (GameManager.SaveAndCleanupWorld: событие на IL_0026, SaveLocalPlayerData на IL_00c4). Обработчик, чистивший там кэш, затирал хранилище на каждом корректном выходе. Обработчик убран; свежесть решает авторитетность ToPlayer, а не таймер. 2. Пустой сессионный кэш трактовался как "хранилища нет" и записывался поверх настоящего. Путь восстановления имеет право не сработать, удалять он права не имеет - добавлена страховка LastLoadedVault. Проверено в игре: положил -> вышел -> запустил заново -> вещи на месте, блоб на 54 байта сверен в .ttp побайтово. ТОРГОВЦЫ (npcTraderJoel/Rekt/Bob/Hugh/Jen) - чёрно-белые. Шейдер НЕ подменяется: материал клонируется со своим шейдером, меняется только текстура альбедо на обесцвеченную копию, так что свет, нормали и скиннинг остаются движковыми. Альбедо ищется обходом свойств шейдера, а не по имени: тело - Game/Character/_Albedo, волосы - Game/Autodesk/_MainTex. Плюс 1% прозрачности с сохранением _ZWrite. Опрос раз в 2 с, потому что торговцы стримятся на подходе, а Джен собирается в рантайме. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FEXvXg1FSAQJHrvYbeAKqq
This commit is contained in:
co-authored by
Claude Opus 5
parent
a5f8592903
commit
29431990f6
@@ -0,0 +1,366 @@
|
||||
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.</summary>
|
||||
public const byte PayloadVersion = 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>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. Uses netstandard's own
|
||||
/// BinaryWriter over a MemoryStream, which is why Bag serialization can stay in this
|
||||
/// project instead of the satellite assembly.</summary>
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Null when the payload holds no vault or is a version we do not know.</summary>
|
||||
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 });
|
||||
}
|
||||
|
||||
/// <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";
|
||||
}
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
/// <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;
|
||||
}
|
||||
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 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);
|
||||
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;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user