1.4.2: проверка версии игры и хранилище, которое не стирается

Повод: 1.4.1 на игре 3.3.0 experimental не смогла прочитать Пространственное
хранилище (Bag переписан) и при сохранении записала пустой маркер - вещи
пропали навсегда.

GameVersionGuard: версия из Constants.cVersionInformation (не из const
cVersionMinor - та вшивается в DLL), поддерживается Major 3 и Minor/10 == 2,
то есть любая 3.2.x. На другой версии в главном меню окно «мод несовместим,
обновите» с единственной кнопкой «Выйти из игры» (решение пользователя), а
вместо PatchAll ставятся по отдельности только окно и четыре патча
сохранения хранилища. Всё, чем пользуется защита, сверено с декомпиляцией 3.3.

Хранилище: непрочитанные байты (чужая версия или упавший разбор) проходят
Read -> ToPlayer -> FromPlayer -> Write нетронутыми, ключ - entityId, сброс
при открытии главного меню (последнее сохранение игрока делается раньше,
внутри SaveAndCleanupWorld - проверено по коду игры). Пока байты не
прочитаны, хранилище не открывается и не пополняется, с подсказкой.

Проверено в игре на 3.2 через отладочные файлы FORCE_VAULT_UNREAD и
FORCE_VERSION_GUARD (в .gitignore, в архив не попадают): 603 байта
хранилища прошли сохранение и прочитались обратно совпадающими с исходными
(26 ячеек, 24 предмета); окно появляется, Esc его не закрывает, кнопка
закрывает игру. На настоящей 3.3 не проверено.

Localization: 4 новых ключа на 13 языках, всего 165; README - статус и
число ключей (было 149).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
AlexCube
2026-09-25 00:09:01 +03:00
co-authored by Claude Opus 5.5
parent 77b56be9ae
commit f4470b784f
13 changed files with 296 additions and 6 deletions
+151
View File
@@ -0,0 +1,151 @@
using System;
using System.IO;
using HarmonyLib;
using UnityEngine;
namespace NecromancerTome
{
/// <summary>
/// ПРОВЕРКА ВЕРСИИ ИГРЫ, 1.4.2 (2026-09-24). Повод: 1.4.1 на игре 3.3.0 experimental молча
/// потеряла содержимое Пространственного хранилища - разбор в BACKLOG.md, «Игра 3.3.0
/// experimental». Решение пользователя: при несовместимой версии мод показывает в главном меню
/// окно «мод несовместим, обновите» с ЕДИНСТВЕННОЙ кнопкой «Выйти из игры» - без «играть всё
/// равно».
///
/// ПОЧЕМУ ЧИТАЕТСЯ cVersionInformation, А НЕ cVersionMinor. cVersionMinor - const, компилятор
/// вписывает её значение (20) прямо в нашу DLL, и на 3.3 она всё равно «видела» бы 20.
/// cVersionInformation - static readonly, её значение берётся из игры при запуске.
///
/// ПОЧЕМУ Minor / 10. Игра хранит версию двузначным числом: 3.2.0 = 20, 3.2.1 = 21, 3.3.0 = 30
/// (VersionInformation печатает его как {Minor/10}.{Minor%10}). Проверка по десяткам пропускает
/// любые хотфиксы 3.2.x и не пропускает 3.3 и всё, что будет после.
///
/// ВСЁ, ЧЕМ ПОЛЬЗУЕТСЯ ЭТОТ ФАЙЛ, СВЕРЕНО С ДЕКОМПИЛЯЦИЕЙ 3.3.0 (build 25485021): Constants,
/// VersionInformation, XUiC_MainMenu.OnOpen, XUiC_MessageBoxWindowGroup (класс совпадает целиком),
/// Mod.VersionString. Иначе сборка под 3.2 упала бы на 3.3 раньше, чем показала окно.
///
/// ОТЛАДКА НА 3.2: если в папке мода лежит файл FORCE_VERSION_GUARD, мод ведёт себя так, будто
/// версия несовместима. В релизный архив он не попадает - состав архива задан явным списком.
/// </summary>
public static class GameVersionGuard
{
/// <summary>Под какую версию собран мод: Major и десяток Minor (3.2.x = 3 и 2).</summary>
public const int SupportedMajor = 3;
public const int SupportedMinorTens = 2;
public const string SupportedText = "3.2";
private static bool? supported;
public static bool IsSupported
{
get
{
if (supported == null)
{
supported = Check();
}
return supported.Value;
}
}
public static string GameVersionText
{
get
{
VersionInformation v = Constants.cVersionInformation;
return v != null ? v.LongStringNoBuild : "?";
}
}
private static bool Check()
{
if (ForcedByFile())
{
Debug.LogWarning("[NecromancerTome] GameVersionGuard: FORCE_VERSION_GUARD file present - acting as if the game version were unsupported");
return false;
}
VersionInformation v = Constants.cVersionInformation;
bool ok = v != null && v.Major == SupportedMajor && v.Minor / 10 == SupportedMinorTens;
Debug.Log("[NecromancerTome] GameVersionGuard: game " + GameVersionText + " (Major " + (v != null ? v.Major : -1) +
", Minor " + (v != null ? v.Minor : -1) + "), mod built for " + SupportedText + " -> " +
(ok ? "supported" : "NOT SUPPORTED"));
return ok;
}
private static bool ForcedByFile()
{
return DebugFileExists("FORCE_VERSION_GUARD");
}
/// <summary>Отладка сохранения хранилища байт в байт на 3.2: при файле FORCE_VAULT_UNREAD
/// в папке мода хранилище из сейва не разбирается, а проходит всю цепочку сохранения
/// нетронутым - ровно как на несовместимой версии, но без окна с выходом, так что в мир
/// можно зайти и сохраниться.</summary>
public static bool ForceVaultUnread
{
get { return DebugFileExists("FORCE_VAULT_UNREAD"); }
}
private static bool DebugFileExists(string _name)
{
try
{
string folder = ModEntry.Instance != null ? ModEntry.Instance.Path : null;
return folder != null && File.Exists(Path.Combine(folder, _name));
}
catch (Exception)
{
return false;
}
}
public static void ShowIncompatibleWindow(XUi _xui)
{
string modVersion = ModEntry.Instance != null ? ModEntry.Instance.VersionString : "?";
string text = string.Format(Localization.Get("necroVersionGuardText"), modVersion, SupportedText, GameVersionText);
XUiC_MessageBoxWindowGroup.ShowCustom(_xui,
Localization.Get("necroVersionGuardTitle"),
text,
"",
delegate(XUiC_MessageBoxWindowGroup _box)
{
_box.buttons[0].DefaultConfirm("necroVersionGuardQuit", delegate
{
Debug.Log("[NecromancerTome] GameVersionGuard: player pressed Quit");
Application.Quit();
});
},
_openMainMenuOnClose: false,
_modal: true,
_buttonOnOutsideClick: -1,
// Проверено в игре 2026-09-25: Esc модальное окно не закрывает - оно остаётся на
// экране, и уйти можно только кнопкой. Это и нужно. _buttonOnExternalClose: 0 - на
// случай, если игра всё же закроет окно извне: тогда это тоже выход.
_buttonOnExternalClose: 0);
Debug.LogWarning("[NecromancerTome] GameVersionGuard: incompatible game version, quit window shown");
}
}
/// <summary>Главное меню: здесь показывается окно, и здесь же сбрасываются непрочитанные байты
/// хранилища - возврат в меню значит, что прежний мир закрыт, и его данные не должны перейти к
/// персонажу следующего.</summary>
[HarmonyPatch(typeof(XUiC_MainMenu), "OnOpen")]
public static class Patch_XUiC_MainMenu_OnOpen_VersionGuard
{
public static void Postfix(XUiC_MainMenu __instance)
{
try
{
SpatialVaultPersistence.UnreadableByEntity.Clear();
if (!GameVersionGuard.IsSupported)
{
GameVersionGuard.ShowIncompatibleWindow(__instance.xui);
}
}
catch (Exception e)
{
Debug.LogError("[NecromancerTome] GameVersionGuard: main menu postfix failed: " + e);
}
}
}
}
+30
View File
@@ -19,6 +19,36 @@ namespace NecromancerTome
{
Instance = _modInstance;
var harmony = new Harmony("necromancertome.harmony");
// НЕСОВМЕСТИМАЯ ВЕРСИЯ ИГРЫ (1.4.2, см. GameVersionGuard.cs): ставятся только окно в
// главном меню и сохранение хранилища байт в байт - каждое отдельно, чтобы отказ одного
// не помешал другим. Остальной код мода, написанный под другую версию, не запускается
// вовсе: игра всё равно закроется из главного меню.
if (!GameVersionGuard.IsSupported)
{
System.Type[] safetyPatches =
{
typeof(Patch_XUiC_MainMenu_OnOpen_VersionGuard),
typeof(Patch_PlayerDataFile_Read_SpatialVault),
typeof(Patch_PlayerDataFile_ToPlayer_SpatialVault),
typeof(Patch_PlayerDataFile_FromPlayer_SpatialVault),
typeof(Patch_PlayerDataFile_Write_SpatialVault),
};
foreach (System.Type patch in safetyPatches)
{
try
{
harmony.CreateClassProcessor(patch).Patch();
Debug.Log("[NecromancerTome] ModEntry: unsupported game version - safety patch " + patch.Name + " applied");
}
catch (System.Exception e)
{
Debug.LogError("[NecromancerTome] ModEntry: safety patch " + patch.Name + " failed: " + e);
}
}
return;
}
harmony.PatchAll(Assembly.GetExecutingAssembly());
PetFollowPatch.Init();
// SpatialVaultPersistence needs NO Init(): it is four Harmony postfixes that PatchAll
+7
View File
@@ -116,6 +116,13 @@ namespace NecromancerTome
public static void OpenVault(EntityPlayerLocal player)
{
// Хранилище из сейва не прочитано - открывать нечего, а пустое новое затёрло бы его при
// сохранении. См. SpatialVaultPersistence.UnreadableByEntity.
if (SpatialVaultPersistence.IsLocked(player))
{
GameManager.ShowTooltip(player, "braceletSpatialVaultUnreadable");
return;
}
ProgressionValue progressionValue = player.Progression?.GetProgressionValue(NecromancySkillName);
int level = progressionValue != null ? progressionValue.Level : 0;
int slotCount = Mathf.RoundToInt(level / 10f);
+83 -1
View File
@@ -89,6 +89,37 @@ namespace NecromancerTome
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
@@ -215,6 +246,17 @@ namespace NecromancerTome
{
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)
@@ -260,6 +302,21 @@ namespace NecromancerTome
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;
@@ -309,6 +366,11 @@ namespace NecromancerTome
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)
@@ -346,7 +408,27 @@ namespace NecromancerTome
return;
}
Bag bag = SpatialVaultPersistence.ParsePayload(payload);
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);
+5
View File
@@ -599,6 +599,11 @@ namespace NecromancerTome
/// the same way in both places.</summary>
public static Bag GetVault(EntityPlayerLocal _player)
{
if (SpatialVaultPersistence.IsLocked(_player))
{
Deny(_player, "braceletSpatialVaultUnreadable");
return null;
}
ProgressionValue progressionValue = _player.Progression?.GetProgressionValue(
Patch_ItemActionEat_ExecuteAction_SpatialVault.NecromancySkillName);
int level = progressionValue != null ? progressionValue.Level : 0;