Files
AlexCubeandClaude Opus 5 29431990f6 Хранилище браслета переживает перезапуск; торговцы чёрно-белые
Исправляет первый баг-репорт мода на 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
2026-09-13 19:54:48 +03:00

367 lines
16 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.</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);
}
}
}
}