Исправляет первый баг-репорт мода на 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
100 lines
3.5 KiB
C#
100 lines
3.5 KiB
C#
using System.IO;
|
|
|
|
namespace NecromancerTome
|
|
{
|
|
/// <summary>
|
|
/// Raw byte-level half of the Spatial Bracelet's vault persistence. Lives in this satellite
|
|
/// assembly for exactly the reason PyramidWardWriteHelper.cs documents: PooledBinaryWriter's
|
|
/// Write overload set cannot be resolved from the main project at all (CS7069), so anything
|
|
/// that actually touches a PooledBinaryWriter/PooledBinaryReader has to be compiled here,
|
|
/// against the game's own mscorlib.
|
|
///
|
|
/// The split is deliberately drawn so that ONLY primitives cross it: this file knows about
|
|
/// byte arrays and stream positions, nothing else. Bag/ItemStack serialization stays in the
|
|
/// main project, where `Bag.Write(BinaryWriter)` against netstandard's own BinaryWriter
|
|
/// already compiles fine (proven - that is how the vault blob is built). Keeping Bag out of
|
|
/// here also keeps UnityEngine out of here, which this project's reference setup (NoStdLib +
|
|
/// the game's mscorlib, no UnityEngine at all) cannot tolerate.
|
|
///
|
|
/// BLOB LAYOUT, appended after everything vanilla PlayerDataFile.Write produces:
|
|
///
|
|
/// int64 Magic "NECROVLT"
|
|
/// int32 payloadLength
|
|
/// byte[] payload (opaque here; the main project builds and parses it)
|
|
///
|
|
/// The magic plus the explicit length is what makes this safe to append to somebody else's
|
|
/// format. On read we remember the stream position first: if the magic is not there (an old
|
|
/// save written before this feature, or a player-data packet from a party that does not have
|
|
/// the mod) the position is put back exactly where it was and the caller is told "no vault" -
|
|
/// so whatever the game reads next still reads the right bytes. That matters concretely:
|
|
/// PlayerDataFile.ReadNetwork calls Read and then goes on to read PlayerMetaInfo from the
|
|
/// same stream, and PlayerDataFile.Load treats ANY exception out of Read as "file is broken,
|
|
/// roll back to the .bak". Neither may be disturbed, so nothing here throws.
|
|
/// </summary>
|
|
public static class SpatialVaultBlobIO
|
|
{
|
|
/// <summary>ASCII "NECROVLT" as one int64 - distinctive enough that stray bytes will not
|
|
/// be mistaken for our block.</summary>
|
|
public const long Magic = 0x4E4543524F564C54L;
|
|
|
|
/// <summary>Magic (8) + length (4).</summary>
|
|
public const int HeaderSize = 12;
|
|
|
|
public static void Write(PooledBinaryWriter _bw, byte[] _payload)
|
|
{
|
|
if (_bw == null || _payload == null)
|
|
{
|
|
return;
|
|
}
|
|
_bw.Write(Magic);
|
|
_bw.Write(_payload.Length);
|
|
_bw.Write(_payload);
|
|
}
|
|
|
|
/// <summary>Returns the payload, or null when this stream carries no vault block. Never
|
|
/// throws, and never leaves the stream anywhere the caller did not expect: either just
|
|
/// past our whole block, or exactly back where it started.</summary>
|
|
public static byte[] TryRead(PooledBinaryReader _br)
|
|
{
|
|
if (_br == null)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
Stream stream = _br.BaseStream;
|
|
if (stream == null || !stream.CanSeek)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
long startPosition = stream.Position;
|
|
try
|
|
{
|
|
if (stream.Length - startPosition < HeaderSize)
|
|
{
|
|
return null;
|
|
}
|
|
if (_br.ReadInt64() != Magic)
|
|
{
|
|
stream.Position = startPosition;
|
|
return null;
|
|
}
|
|
|
|
int length = _br.ReadInt32();
|
|
if (length < 0 || stream.Length - stream.Position < length)
|
|
{
|
|
stream.Position = startPosition;
|
|
return null;
|
|
}
|
|
|
|
return _br.ReadBytes(length);
|
|
}
|
|
catch
|
|
{
|
|
stream.Position = startPosition;
|
|
return null;
|
|
}
|
|
}
|
|
}
|
|
}
|