Compare commits
5
Commits
ab1b3eadaf
..
v1.0.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4801341676 | ||
|
|
29431990f6 | ||
|
|
a5f8592903 | ||
|
|
ccf58a2ec8 | ||
|
|
a645c53ff3 |
@@ -131,11 +131,21 @@
|
||||
Найденные параметры - HypothermalResist (холод) и HyperthermalResist (жара). Живой
|
||||
ванильный образец: modArmorInsulatedLinerT1/T2/T3 (Data/Config/item_modifiers.xml
|
||||
~1873), они ставят ровно эту пару. Величина у них по тирам: T1 1->2.5, T2 2.8->4.3,
|
||||
T3 4.6->6 на ОДИН элемент брони, а элементов четыре. Взято 5 - примерно уровень
|
||||
одной детали брони с T3-подкладкой, и ровно то число, которое ваниль использовала во
|
||||
вкомментированных modArmorInsulatedLiner/modArmorCoolingMesh (там 5 на холод и 5 на
|
||||
жару, но двумя РАЗНЫМИ модами; здесь оба в одном, что щедрее - но это стоит слота из
|
||||
четырёх и работает только с ножом в руках, см. ниже). Крутить это число - одна правка.
|
||||
T3 4.6->6 на ОДИН элемент брони, а элементов четыре.
|
||||
|
||||
ЗНАЧЕНИЕ 5 -> 50, 2026-09-13, прямое указание пользователя ("по факту она поднимает
|
||||
сопротивление всего на 5, а надо на 50"). Изначально стояло 5 - примерно уровень одной
|
||||
детали брони с T3-подкладкой, и ровно то число, которым ваниль пользуется во
|
||||
вкомментированных modArmorInsulatedLiner/modArmorCoolingMesh. Это было осознанно
|
||||
скромно; пользователь хочет иначе, и его решение тут главнее моей балансной оценки.
|
||||
|
||||
ЧТО 50 ОЗНАЧАЕТ НА САМОМ ДЕЛЕ, раз единица - градусы, а не проценты (формула ниже):
|
||||
любая уличная температура в пределах 50 градусов от комфортных 70 подтягивается К 70
|
||||
ЦЕЛИКОМ, потому что там стоит min/max-ограничение. То есть от 20 до 120 по шкале игры
|
||||
это не "сильная защита", а полный иммунитет: и снежная вершина, и пустынный полдень
|
||||
перестают быть угрозой. Это примерно в 10 раз больше, чем даёт набор брони с
|
||||
T3-подкладками на всех четырёх деталях. Записано не в укор, а чтобы через месяц не
|
||||
пришлось гадать, почему термометр перестал что-либо значить.
|
||||
|
||||
ЕДИНИЦА ИЗМЕРЕНИЯ - градусы, на которые сдвигается уличная температура в сторону
|
||||
комфортной, а не проценты (PlayerEntityStats, декомпиляция):
|
||||
@@ -173,8 +183,8 @@
|
||||
<property name="SellableToTrader" value="false"/>
|
||||
|
||||
<effect_group tiered="false">
|
||||
<passive_effect name="HypothermalResist" operation="base_add" value="5"/>
|
||||
<passive_effect name="HyperthermalResist" operation="base_add" value="5"/>
|
||||
<passive_effect name="HypothermalResist" operation="base_add" value="50"/>
|
||||
<passive_effect name="HyperthermalResist" operation="base_add" value="50"/>
|
||||
</effect_group>
|
||||
</item_modifier>
|
||||
|
||||
|
||||
+131
-12
@@ -214,7 +214,31 @@
|
||||
<triggered_effect trigger="onProjectileImpact" action="AddBuff" target="positionAOE" range="2" buff="buffNecroDeviatorCharm">
|
||||
<requirement name="EntityTagCompare" target="other" tags="zombie"/>
|
||||
</triggered_effect>
|
||||
<!-- Generic stone-on-flesh thud, deliberately left UNCONDITIONAL: it fires on every
|
||||
impact (ground, wall, zombie) so a miss still sounds like something landed. -->
|
||||
<triggered_effect trigger="onProjectileImpact" action="PlaySound" sound="stonehitorganic"/>
|
||||
<!-- The charm's OWN cue - fires only on a hit that actually charms, gated by the exact
|
||||
same "other is a zombie" requirement the AddBuff above uses, so it can never fire on
|
||||
a miss. Requirements on a PlaySound effect are the vanilla stun baton pattern
|
||||
(Data/Config/items.xml:4005 - IsAlive/EntityTagCompare on target="other"), not
|
||||
invented here; onProjectileImpact populating "other" with the hit entity is proven
|
||||
by the AddBuff right above, which already works in game.
|
||||
|
||||
target="other" is meant to play the clip FROM the zombie, i.e. positional at the
|
||||
point of impact instead of at the thrower's head (the attribute itself is vanilla -
|
||||
items.xml:5514 uses target="self"). If it turns out silent in game, drop just the
|
||||
attribute: the effect then plays on self and the gating still holds.
|
||||
|
||||
THE MOD'S OWN SOUND, the first one in the whole mod that is not a borrowed vanilla
|
||||
id: "necroSpiritStoneHit" is defined in this mod's Config/sounds.xml and its clip
|
||||
lives in Resources/necrosounds, built from the Unity project (see that file's header
|
||||
for why a plain wav next to the XML cannot work). If the sound is missing in game,
|
||||
the failure is SILENT - look for "AudioManager LoadAudio failed to load audio clip"
|
||||
in the game log, and note that the generic stonehitorganic above will still play, so
|
||||
"I heard something" is not proof this one fired. -->
|
||||
<triggered_effect trigger="onProjectileImpact" action="PlaySound" target="other" sound="necroSpiritStoneHit">
|
||||
<requirement name="EntityTagCompare" target="other" tags="zombie"/>
|
||||
</triggered_effect>
|
||||
</effect_group>
|
||||
</item>
|
||||
</append>
|
||||
@@ -1373,7 +1397,29 @@
|
||||
rest of the mod's hand-drawn icons). -->
|
||||
<append xpath="/items">
|
||||
<item name="braceletSpatialVault">
|
||||
<property name="Tags" value="T0,weapon,attPerception"/>
|
||||
<!-- MOD SLOTS ADDED 2026-09-13 ("добавь хранилищу 4 слота под модификации. Сами
|
||||
модификации реализуем потом"). Two tags, exactly the scheme necroWpnBladeNecroKnife
|
||||
already proved on 2026-09-07 - see that item's own comment for the full
|
||||
decompiled reasoning:
|
||||
|
||||
noMods - blocks every vanilla mod. All 87 vanilla item_modifiers that
|
||||
declare blocked_tags at all list noMods among them; the
|
||||
remaining 24 cannot reach this item anyway (10 dyes and 7 drone
|
||||
mods need a cosmetic slot or the drone tag, 2 are quest items,
|
||||
1 needs perkArchery, 3 are CreativeMode Test/Dev). "noMods"
|
||||
means nothing in code - it is purely a naming convention used
|
||||
inside other items' blocked_tags.
|
||||
necroBracelet - the positive half, reserved for the mods that come later. Every
|
||||
future bracelet mod MUST declare
|
||||
installable_tags="necroBracelet": a modifier with no
|
||||
installable_tags at all fits ANYTHING (XUiM_AssembleItem short-
|
||||
circuits on InstallableTags.IsEmpty), so forgetting it produces
|
||||
the exact opposite of what is wanted.
|
||||
|
||||
Deliberately NOT adding canHaveCosmetic: that tag alone is what creates the paint
|
||||
slot (ItemValue's constructor sizes CosmeticMods by it), and the knife had to have
|
||||
it removed for precisely this reason. No tag, no slot, no dyes. -->
|
||||
<property name="Tags" value="T0,weapon,attPerception,noMods,necroBracelet"/>
|
||||
<!-- ItemTypeIcon="melee" REMOVED 2026-09-07 (user report: "поверх пиктограмм некоторых
|
||||
рецептов стоят странные пиктограммы... то ли факел, то ли спичка"). This was the
|
||||
small badge drawn in the TOP-LEFT corner over the item's own icon in the recipe
|
||||
@@ -1401,19 +1447,64 @@
|
||||
(bundle, computer, forge, explosion, campfire, gunsmithing, book). -->
|
||||
<property name="DescriptionKey" value="braceletSpatialVaultDesc"/>
|
||||
<property name="CustomIcon" value="ProstranstvennoeHranilische"/>
|
||||
<!-- Same "seed"-style grip as braceletThiefLoop originally had (see that item's own
|
||||
comment for the full history) - foodCropYuccaFruit's own HoldType="31" +
|
||||
parcelGenericPrefab.prefab. Unlike Thief's Loop, this item never touches
|
||||
Class="Zoom" (both its actions are Class="Eat"), so it never hit the
|
||||
"Attachments" transform error that forced Thief's Loop onto a real weapon mesh -
|
||||
no reason to change this one's mesh too. TintColor changed to green 2026-08-30
|
||||
per direct request. -->
|
||||
<!-- МЕШ И ХВАТ, 2026-09-13. Просьба в два захода: сперва "пусть будет камень, а
|
||||
хват давай сделаем как когда пытаешься ставить какой-нибудь блок", затем
|
||||
уточнение - "браслет это браслет... в идеале меш камня вообще убрать". Было:
|
||||
свёрток-«семечко» parcelGenericPrefab.prefab (коробочка, перевязанная бечёвкой -
|
||||
для браслета нелепо) + HoldType="31", и то и другое унаследовано от Петли вора.
|
||||
|
||||
ИТОГ: в руке НЕТ НИЧЕГО, только кулак. Пустой префаб собирать не пришлось - в
|
||||
движке есть готовое свойство, и вся связка целиком списана с ванильного
|
||||
vehicleMinibikePlaceable (items.xml:13385), у которого стоят ровно те же две
|
||||
строки подряд: HoldType="7" + HoldingItemHidden="true".
|
||||
|
||||
HoldType="7" - это и есть блочный хват ("кулак вниз, как будто держишь руль"),
|
||||
не угаданный номер. Декомпилировано Mono.Cecil'ом из Assembly-CSharp 3.2.0
|
||||
(сам Mono.Cecil.dll лежит в Mods/0_TFP_Harmony, отдельный декомпилятор не нужен):
|
||||
|
||||
ItemClassBlock..ctor -> HoldType = new DataItem<int>(7)
|
||||
AnimationDelayData.AnimationDelay[7] =
|
||||
new AnimationDelays(0, 0f, 0f, .31f, .31f, true) <- последний флаг TwoHanded
|
||||
|
||||
В blocks.xml свойства HoldType нет ни разу (0 вхождений), то есть КАЖДЫЙ блок в
|
||||
игре держится именно семёркой из этого конструктора. Костет
|
||||
(meleeWpnKnucklesT0LeatherKnuckles) - это HoldType="70", запасной вариант не
|
||||
понадобился.
|
||||
|
||||
HoldingItemHidden="true" - штатное свойство ItemClass, а не трюк:
|
||||
ItemClass..cctor заводит PropHoldingItemHidden = "HoldingItemHidden",
|
||||
ItemClass.Init читает его через StringParsers.ParseBool, а
|
||||
Inventory.setHoldingItemTransform в самом конце делает
|
||||
holdingItemTransform.gameObject.SetActive(!HoldingItemHidden). Гасится ТОЛЬКО
|
||||
модель в руке: иконка в инвентаре (своя рисованная ProstranstvennoeHranilische)
|
||||
и мешок на земле не трогаются, действия предмета живут в ItemActionEat и от
|
||||
этого GameObject не зависят.
|
||||
|
||||
Пустой меш поставить было НЕЛЬЗЯ, и это проверено, а не предположено:
|
||||
ItemClass.CloneModel, если имя меша пустое и ассет не загрузился, подставляет
|
||||
заглушку "@:Other/Items/Crafting/leather.fbx" - в руке оказался бы кусок кожи.
|
||||
Единственный ванильный предмет вообще без Meshfile - meleeHandMaster (голые
|
||||
руки), и он выкручивается через Canhold="false", что нам не подходит: браслет
|
||||
надо держать, чтобы им пользоваться.
|
||||
|
||||
Meshfile оставлен камнем как безобидная затычка (в руке он скрыт, а для
|
||||
MeshPurpose World/Local/Preview что-то иметь надо), DropMeshfile - ванильный
|
||||
мешок sack_droppedPrefab, ровно тем же приёмом и по той же причине, что у
|
||||
vehicleMinibikePlaceable: выброшенный предмет должно быть видно на земле, а
|
||||
своей модели у него нет. HandMeshfile убран за ненадобностью.
|
||||
|
||||
Про HoldType и действия: единственное место, где ItemActionEat вообще читает
|
||||
HoldType, - AnimationDelay[HoldType].RayCast (в PercentDone и IsActionRunning),
|
||||
и он равен 0f и у старого 31, и у нового 7 (InitStatic заполняет все 100 слотов
|
||||
нулями, ItemClassBlock переписывает слот 7, оставляя RayCast нулём).
|
||||
ExecuteAction, за которую держится SpatialVaultPatch.cs, HoldType не читает
|
||||
вовсе - проверено сканом IL по всей сборке. -->
|
||||
<property name="Material" value="Morganic"/>
|
||||
<property name="Meshfile" value="@:Other/Items/Food/parcelGenericPrefab.prefab"/>
|
||||
<property name="HandMeshfile" value="@:Other/Items/Food/parcelGenericPrefab.prefab"/>
|
||||
<property name="DropMeshfile" value="@:Other/Items/Food/parcelGenericPrefab.prefab"/>
|
||||
<property name="Meshfile" value="@:Other/Items/Crafting/rock_smallPrefab.prefab"/>
|
||||
<property name="DropMeshfile" value="@:Other/Items/Misc/sack_droppedPrefab.prefab"/>
|
||||
<property name="TintColor" value="30, 200, 60"/>
|
||||
<property name="HoldType" value="31"/>
|
||||
<property name="HoldType" value="7"/>
|
||||
<property name="HoldingItemHidden" value="true"/>
|
||||
<property name="Weight" value="0"/>
|
||||
<property name="Stacknumber" value="1"/>
|
||||
<property name="EconomicValue" value="0"/>
|
||||
@@ -1426,6 +1517,34 @@
|
||||
<property name="Class" value="Eat"/>
|
||||
<property name="Delay" value="0.3"/>
|
||||
</property>
|
||||
|
||||
<!-- FOUR MOD SLOTS. The count is a passive_effect, not an item property - same shape
|
||||
the knife uses, and flat rather than a per-quality list because quality means
|
||||
nothing on this item.
|
||||
|
||||
NOTE THE MISSING ATTRIBUTE: this effect_group has NO tiered="false", and that is
|
||||
the entire point. ItemClass.HasQuality is literally Effects.IsOwnerTiered(), and
|
||||
ItemValue.FireEvent bails out with `if (!HasQuality) return;` BEFORE it walks
|
||||
Modifications[] - so on an untiered item the slots still appear and still accept
|
||||
mods, and not one triggered_effect inside them ever fires. That silent failure
|
||||
cost a whole debugging session on the knife on 2026-09-07; it is not repeated
|
||||
here. The slots themselves would work either way (Modifications is allocated
|
||||
unconditionally, earlier), which is exactly what makes the failure so quiet.
|
||||
|
||||
Quality is not SHOWN, though: ShowQuality is a separate property that defaults to
|
||||
false (vanilla sets it to true explicitly on the ~80 items that want a quality
|
||||
bar), and it is deliberately left unset here. The item behaves as tiered for the
|
||||
mod system and still reads as a plain bracelet in the UI.
|
||||
|
||||
FOR THE MODS THEMSELVES, WHEN THEY GET WRITTEN: give each one its OWN
|
||||
modifier_tags. XUiC_ItemPartStack.CanSwap counts already-installed mods whose
|
||||
modifier_tags intersect the one being installed and refuses at
|
||||
num >= ItemClass.MaxModsAllowed, which defaults to 1 - so a shared tag like
|
||||
"necroBraceletMod" across all four would leave exactly one of these four slots
|
||||
usable. -->
|
||||
<effect_group name="braceletSpatialVault">
|
||||
<passive_effect name="ModSlots" operation="base_set" value="4"/>
|
||||
</effect_group>
|
||||
</item>
|
||||
</append>
|
||||
</config>
|
||||
|
||||
@@ -0,0 +1,419 @@
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
using UnityEngine.Rendering;
|
||||
|
||||
namespace NecromancerTome
|
||||
{
|
||||
/// <summary>
|
||||
/// Renders every trader in black and white (user request 2026-09-13: "сделать модельки всех
|
||||
/// торговцев полупрозрачными и чёрнобелыми", then after two in-game looks: "прозрачность у
|
||||
/// торговцев убираем совсем"). Fits the mod - the necromancer deals with the dead, and the
|
||||
/// only people still trading are not quite alive.
|
||||
///
|
||||
/// TRANSPARENCY WAS DROPPED, THEN ASKED BACK FOR AT A SLIVER. The user first said "убираем
|
||||
/// совсем", confirmed the result ("торговец стал непрозрачным и полностью чёрно-белым, как и
|
||||
/// требовалось"), and then asked for "лёгкую прозрачность, буквально 1%" to push him a little
|
||||
/// further towards a ghost. Dropping it was still the release this effect needed, because it
|
||||
/// is what allowed the shader to stay put - see below; the 1% is now a separate, optional
|
||||
/// layer on top (ApplyTransparency) that cannot break the greyscale if the shaders refuse it.
|
||||
///
|
||||
/// The two failed attempts are worth keeping written down, because neither could have been
|
||||
/// predicted from the decompiler and each was settled by one log line:
|
||||
///
|
||||
/// 1. "Unlit/Transparent Greyscale" (what GameManager uses for greyed-out item icons) has
|
||||
/// NO _Color - nowhere to put an alpha. Traders came out opaque, and because that shader
|
||||
/// is built for NGUI atlases rather than skinned meshes, the user saw them "в негативе".
|
||||
/// 2. "Unlit/Transparent Colored" has no _Color either: `has _Color: False` in the log.
|
||||
/// NGUI tints through VERTEX colours, not a material property, and a character mesh has
|
||||
/// none - so that whole family of shaders was always a dead end here.
|
||||
///
|
||||
/// WITHOUT THE ALPHA REQUIREMENT THE SHADER DOES NOT HAVE TO BE REPLACED AT ALL, and that is
|
||||
/// strictly better than anything above: the material is cloned with its own shader intact and
|
||||
/// only its albedo texture is swapped for a desaturated copy. Lighting, normal maps, specular,
|
||||
/// skinning - all still the game's own. The trader looks exactly like himself, in black and
|
||||
/// white. Nothing can go "negative", because nothing but the pixels changes.
|
||||
///
|
||||
/// THE ALBEDO IS FOUND, NOT ASSUMED - this is what the earlier runs bought us. The first
|
||||
/// attempt reached for _MainTex and produced an untextured silhouette, because traders are
|
||||
/// drawn by TWO different shaders and only one of them uses that name. The probe below walks
|
||||
/// the shader's declared properties instead, and the log then said exactly what they are:
|
||||
///
|
||||
/// shader 'Game/Character' texture properties: _Albedo=set, _Normal=set, _RMOE=set,
|
||||
/// _texcoord=empty; chosen albedo: HD_Rekt 4096x4096
|
||||
/// shader 'Game/Autodesk' texture properties: _MainTex=set, _BumpMap=set, ...;
|
||||
/// chosen albedo: HD_Rekt_Hair 2048x2048
|
||||
///
|
||||
/// So the body uses _Albedo and the hair uses _MainTex - which is precisely why the name is
|
||||
/// discovered rather than hard-coded, and why the probe stays in: Jen is assembled by a
|
||||
/// different character system than Rekt (AvatarSDCSController vs AvatarNpcController in
|
||||
/// entityclasses.xml) and may well introduce a third shader.
|
||||
///
|
||||
/// GREYSCALE IS DONE TO THE TEXTURE, via a RenderTexture round trip. The round trip is the
|
||||
/// point: game textures are compressed with isReadable=false, so GetPixels on the original
|
||||
/// throws - blitting into an ARGB32 RenderTexture and reading THAT back is the standard way to
|
||||
/// reach pixels the CPU was never handed. Luma weights 0.299/0.587/0.114 rather than a flat
|
||||
/// average, so it reads like a black-and-white photograph instead of a muddy one. Cached per
|
||||
/// source texture: these are 4096x4096, and a readback per renderer per sweep would be
|
||||
/// indefensible.
|
||||
///
|
||||
/// WHY A TICK AND NOT A SPAWN HOOK. Traders are streamed in on approach ("force spawning
|
||||
/// pending entity npcTraderRekt" appeared ~4 minutes after the world loaded), and Jen is built
|
||||
/// at runtime, so her renderers do not all exist when the entity is added to the world.
|
||||
/// Polling with ModEvents.UnityUpdate - the same approach PetFollowPatch.cs already uses here -
|
||||
/// avoids guessing at the right moment inside someone else's character pipeline. A trader with
|
||||
/// no renderers yet is simply not marked done and is picked up on the next sweep.
|
||||
/// </summary>
|
||||
public static class GhostTraderPatch
|
||||
{
|
||||
/// <summary>Seconds between sweeps.</summary>
|
||||
public const float SweepInterval = 2f;
|
||||
|
||||
/// <summary>1 = solid. 0.99 is the "буквально 1%" the user asked for on 2026-09-13 after
|
||||
/// seeing the black-and-white traders: a hint of not-quite-there rather than a ghost.
|
||||
/// Deliberately close to opaque for a second reason too - see ApplyTransparency, which
|
||||
/// keeps depth writing on precisely because a nearly-solid character can afford to.</summary>
|
||||
public const float GhostAlpha = 0.99f;
|
||||
|
||||
/// <summary>Colour properties that might carry an alpha, best first.</summary>
|
||||
public static readonly string[] TintNameHints = { "_Color", "_BaseColor", "_TintColor", "_Tint" };
|
||||
|
||||
/// <summary>Entity ids already converted. Cleared when the world unloads.</summary>
|
||||
public static readonly HashSet<int> Ghosted = new HashSet<int>();
|
||||
|
||||
/// <summary>Source shader names already described in the log, so the probe says each
|
||||
/// distinct thing once rather than once per trader per part.</summary>
|
||||
public static readonly HashSet<string> ProbedShaders = new HashSet<string>();
|
||||
|
||||
/// <summary>Desaturated copies, keyed by the texture they came from.</summary>
|
||||
public static readonly Dictionary<Texture, Texture2D> GreyTextures = new Dictionary<Texture, Texture2D>();
|
||||
|
||||
/// <summary>Property names that look like an albedo, best first. Confirmed in game:
|
||||
/// "Game/Character" uses _Albedo, "Game/Autodesk" uses _MainTex. Anything else falls
|
||||
/// through to "the first texture property that has something in it".</summary>
|
||||
public static readonly string[] AlbedoNameHints =
|
||||
{
|
||||
"_MainTex", "_Albedo", "_BaseMap", "_BaseColorMap", "_AlbedoMap", "_DiffuseMap",
|
||||
"_Diffuse", "_ColorMap", "_MainTexture", "_Texture"
|
||||
};
|
||||
|
||||
public static float timer;
|
||||
|
||||
/// <summary>Called from ModEntry.InitMod.</summary>
|
||||
public static void Init()
|
||||
{
|
||||
ModEvents.UnityUpdate.RegisterHandler(OnUnityUpdate);
|
||||
ModEvents.WorldShuttingDown.RegisterHandler(OnWorldShuttingDown);
|
||||
}
|
||||
|
||||
public static void OnWorldShuttingDown(ref ModEvents.SWorldShuttingDownData _data)
|
||||
{
|
||||
Ghosted.Clear();
|
||||
GreyTextures.Clear();
|
||||
timer = 0f;
|
||||
}
|
||||
|
||||
public static void OnUnityUpdate(ref ModEvents.SUnityUpdateData _data)
|
||||
{
|
||||
timer += Time.deltaTime;
|
||||
if (timer < SweepInterval)
|
||||
{
|
||||
return;
|
||||
}
|
||||
timer = 0f;
|
||||
|
||||
World world = GameManager.Instance != null ? GameManager.Instance.World : null;
|
||||
if (world == null || world.EntityAlives == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
for (int i = 0; i < world.EntityAlives.Count; i++)
|
||||
{
|
||||
EntityAlive entity = world.EntityAlives[i];
|
||||
if (!(entity is EntityTrader trader) || trader.IsDead())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if (Ghosted.Contains(trader.entityId))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if (ApplyGreyscale(trader))
|
||||
{
|
||||
Ghosted.Add(trader.entityId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>False when there is nothing to work on yet (model not built), so the caller
|
||||
/// leaves this trader unmarked and tries again on the next sweep.</summary>
|
||||
public static bool ApplyGreyscale(EntityTrader _trader)
|
||||
{
|
||||
Renderer[] renderers = _trader.GetComponentsInChildren<Renderer>(true);
|
||||
if (renderers == null || renderers.Length == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
int converted = 0;
|
||||
foreach (Renderer renderer in renderers)
|
||||
{
|
||||
if (renderer == null || renderer is ParticleSystemRenderer)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
Material[] sources = renderer.sharedMaterials;
|
||||
if (sources == null || sources.Length == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
Material[] greys = new Material[sources.Length];
|
||||
bool anyChanged = false;
|
||||
for (int i = 0; i < sources.Length; i++)
|
||||
{
|
||||
greys[i] = MakeGreyMaterial(sources[i], ref anyChanged);
|
||||
}
|
||||
if (anyChanged)
|
||||
{
|
||||
renderer.materials = greys;
|
||||
converted++;
|
||||
}
|
||||
}
|
||||
|
||||
Debug.Log("[NecromancerTome] GhostTraderPatch: " + _trader.EntityClass.entityClassName +
|
||||
" (entity " + _trader.entityId + ") - " + converted + " of " + renderers.Length + " renderer(s) desaturated");
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Clone of the source material - SAME shader, same everything - with only its
|
||||
/// albedo replaced by a black-and-white copy.</summary>
|
||||
public static Material MakeGreyMaterial(Material _source, ref bool _changed)
|
||||
{
|
||||
if (_source == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
ProbeShaderOnce(_source);
|
||||
|
||||
Material grey = new Material(_source);
|
||||
if (ApplyTransparency(grey))
|
||||
{
|
||||
_changed = true;
|
||||
}
|
||||
string albedoProperty = FindAlbedoProperty(_source);
|
||||
if (albedoProperty == null)
|
||||
{
|
||||
// Nothing to desaturate on this material; hand back the clone unchanged rather
|
||||
// than dropping the renderer's material entirely.
|
||||
return grey;
|
||||
}
|
||||
|
||||
Texture2D desaturated = Desaturate(_source.GetTexture(albedoProperty));
|
||||
if (desaturated == null)
|
||||
{
|
||||
return grey;
|
||||
}
|
||||
|
||||
grey.SetTexture(albedoProperty, desaturated);
|
||||
_changed = true;
|
||||
return grey;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Makes the material blend instead of being drawn solid, then dials its alpha down by the
|
||||
/// requested sliver. Two levers, both conditional, because the traders' own shaders are
|
||||
/// game-specific and nothing about them can be assumed:
|
||||
///
|
||||
/// - A COLOUR with an alpha channel (_Color and friends). This is the only thing that
|
||||
/// actually sets the opacity.
|
||||
/// - THE BLEND MODE (_SrcBlend/_DstBlend). An opaque shader ignores any alpha it is
|
||||
/// handed, so without this the first lever does nothing visible - the same wall the
|
||||
/// mod's first transparency attempt hit back on 2026-08-28 with the summoned pets.
|
||||
/// The recipe is the game's own: MeshDescription.SetupMaterialWithBlendMode writes
|
||||
/// exactly these properties plus _ZWrite and the _ALPHABLEND_ON keyword.
|
||||
///
|
||||
/// _ZWrite IS LEFT ALONE ON PURPOSE. The usual recipe switches depth writing off, which is
|
||||
/// right for glass and wrong for a person: without it every part of the model shows through
|
||||
/// every other part and the trader turns into a soup of overlapping limbs. At 99% opacity
|
||||
/// there is nothing to see through anyway, so keeping depth writing costs nothing visible
|
||||
/// and avoids that entirely.
|
||||
///
|
||||
/// Whatever is missing is reported by the probe rather than silently skipped - if neither
|
||||
/// lever exists on these shaders, the traders stay solid black-and-white and the log says
|
||||
/// why.
|
||||
/// </summary>
|
||||
public static bool ApplyTransparency(Material _material)
|
||||
{
|
||||
bool touched = false;
|
||||
|
||||
foreach (string hint in TintNameHints)
|
||||
{
|
||||
if (!_material.HasProperty(hint))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
Color tint = _material.GetColor(hint);
|
||||
tint.a *= GhostAlpha;
|
||||
_material.SetColor(hint, tint);
|
||||
touched = true;
|
||||
break;
|
||||
}
|
||||
|
||||
if (_material.HasProperty("_SrcBlend") && _material.HasProperty("_DstBlend"))
|
||||
{
|
||||
_material.SetFloat("_SrcBlend", (float)BlendMode.SrcAlpha);
|
||||
_material.SetFloat("_DstBlend", (float)BlendMode.OneMinusSrcAlpha);
|
||||
_material.EnableKeyword("_ALPHABLEND_ON");
|
||||
_material.renderQueue = (int)RenderQueue.Transparent;
|
||||
touched = true;
|
||||
}
|
||||
|
||||
return touched;
|
||||
}
|
||||
|
||||
/// <summary>Name of the texture property holding this material's albedo, or null. Walks
|
||||
/// the shader's declared properties rather than assuming a name - the body and the hair of
|
||||
/// the same trader disagree about it.</summary>
|
||||
public static string FindAlbedoProperty(Material _source)
|
||||
{
|
||||
Shader shader = _source.shader;
|
||||
if (shader == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach (string hint in AlbedoNameHints)
|
||||
{
|
||||
if (_source.HasProperty(hint) && _source.GetTexture(hint) != null)
|
||||
{
|
||||
return hint;
|
||||
}
|
||||
}
|
||||
|
||||
int count = shader.GetPropertyCount();
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
if (shader.GetPropertyType(i) != ShaderPropertyType.Texture)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
string name = shader.GetPropertyName(i);
|
||||
if (_source.GetTexture(name) != null)
|
||||
{
|
||||
return name;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>Black-and-white copy of a texture. See the class comment for why this goes
|
||||
/// through a RenderTexture instead of reading the source directly.</summary>
|
||||
public static Texture2D Desaturate(Texture _source)
|
||||
{
|
||||
if (_source == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
if (GreyTextures.TryGetValue(_source, out Texture2D cached))
|
||||
{
|
||||
return cached;
|
||||
}
|
||||
|
||||
Texture2D grey = null;
|
||||
RenderTexture rt = null;
|
||||
RenderTexture previous = RenderTexture.active;
|
||||
try
|
||||
{
|
||||
rt = RenderTexture.GetTemporary(_source.width, _source.height, 0,
|
||||
RenderTextureFormat.ARGB32, RenderTextureReadWrite.sRGB);
|
||||
Graphics.Blit(_source, rt);
|
||||
RenderTexture.active = rt;
|
||||
|
||||
grey = new Texture2D(_source.width, _source.height, TextureFormat.RGBA32, false);
|
||||
grey.ReadPixels(new Rect(0f, 0f, _source.width, _source.height), 0, 0);
|
||||
|
||||
Color32[] pixels = grey.GetPixels32();
|
||||
for (int i = 0; i < pixels.Length; i++)
|
||||
{
|
||||
Color32 p = pixels[i];
|
||||
byte luma = (byte)((p.r * 299 + p.g * 587 + p.b * 114) / 1000);
|
||||
p.r = luma;
|
||||
p.g = luma;
|
||||
p.b = luma;
|
||||
pixels[i] = p;
|
||||
}
|
||||
grey.SetPixels32(pixels);
|
||||
grey.Apply(false, false);
|
||||
}
|
||||
catch (System.Exception e)
|
||||
{
|
||||
Debug.LogError("[NecromancerTome] GhostTraderPatch: could not desaturate '" + _source.name + "': " + e.Message);
|
||||
grey = null;
|
||||
}
|
||||
finally
|
||||
{
|
||||
RenderTexture.active = previous;
|
||||
if (rt != null)
|
||||
{
|
||||
RenderTexture.ReleaseTemporary(rt);
|
||||
}
|
||||
}
|
||||
|
||||
// Cached even on failure (as null) so an unreadable texture is not retried per trader.
|
||||
GreyTextures[_source] = grey;
|
||||
return grey;
|
||||
}
|
||||
|
||||
/// <summary>Says, once per distinct source shader, which texture properties it has and
|
||||
/// which carry anything. This is what told us the body uses _Albedo and the hair _MainTex;
|
||||
/// it stays in because the next trader built by a different character system will announce
|
||||
/// itself the same way.</summary>
|
||||
public static void ProbeShaderOnce(Material _source)
|
||||
{
|
||||
Shader shader = _source.shader;
|
||||
string shaderName = shader != null ? shader.name : "<null shader>";
|
||||
if (!ProbedShaders.Add(shaderName) || shader == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// EVERY property, not just the textures. The texture-only version answered the
|
||||
// "where is the albedo" question; this one has to answer "is there anything here that
|
||||
// can make it transparent at all", and that lives among the floats and colours.
|
||||
System.Text.StringBuilder sb = new System.Text.StringBuilder();
|
||||
int count = shader.GetPropertyCount();
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
string name = shader.GetPropertyName(i);
|
||||
ShaderPropertyType type = shader.GetPropertyType(i);
|
||||
sb.Append(sb.Length > 0 ? ", " : "").Append(name).Append(':').Append(type);
|
||||
if (type == ShaderPropertyType.Texture)
|
||||
{
|
||||
sb.Append(_source.GetTexture(name) != null ? "=set" : "=empty");
|
||||
}
|
||||
}
|
||||
|
||||
string chosen = FindAlbedoProperty(_source);
|
||||
Texture chosenTexture = chosen != null ? _source.GetTexture(chosen) : null;
|
||||
string tint = "<none>";
|
||||
foreach (string hint in TintNameHints)
|
||||
{
|
||||
if (_source.HasProperty(hint))
|
||||
{
|
||||
tint = hint + " (alpha " + _source.GetColor(hint).a.ToString("0.###") + ")";
|
||||
break;
|
||||
}
|
||||
}
|
||||
bool canBlend = _source.HasProperty("_SrcBlend") && _source.HasProperty("_DstBlend");
|
||||
|
||||
Debug.Log("[NecromancerTome] GhostTraderPatch: shader '" + shaderName + "' properties: " +
|
||||
(sb.Length > 0 ? sb.ToString() : "<none>"));
|
||||
Debug.Log("[NecromancerTome] GhostTraderPatch: shader '" + shaderName + "' - albedo: " + (chosen ?? "<none>") +
|
||||
" -> " + (chosenTexture != null ? chosenTexture.name + " " + chosenTexture.width + "x" + chosenTexture.height : "<none>") +
|
||||
"; tint property: " + tint + "; blend-mode properties present: " + canBlend);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,15 @@ namespace NecromancerTome
|
||||
var harmony = new Harmony("necromancertome.harmony");
|
||||
harmony.PatchAll(Assembly.GetExecutingAssembly());
|
||||
PetFollowPatch.Init();
|
||||
// SpatialVaultPersistence needs NO Init(): it is four Harmony postfixes that PatchAll
|
||||
// above already attached. It used to register a WorldShuttingDown handler to clear its
|
||||
// cache - that handler is exactly what wiped the vault on every clean exit, because
|
||||
// that event fires BEFORE the final player save (GameManager.SaveAndCleanupWorld:
|
||||
// event at IL_0026, SaveLocalPlayerData at IL_00c4). Freshness is decided by what was
|
||||
// read instead; see that file.
|
||||
// Traders rendered as washed-out ghosts (request 2026-09-13). Polls rather than
|
||||
// hooks a spawn event - see that file for why the SDCS-built trader forces it.
|
||||
GhostTraderPatch.Init();
|
||||
// PyramidWardPatch.cs's TEFeaturePyramidWard needs no Init() call - it's discovered
|
||||
// automatically by the engine's own TileEntityCompositeData reflection scan (see that
|
||||
// file's class doc comment), not registered here like PetFollowPatch's UnityUpdate hook.
|
||||
|
||||
@@ -30,19 +30,15 @@ namespace NecromancerTome
|
||||
/// this rounds to 0 - deliberately left as-is, not special-cased away, matching the
|
||||
/// Knife's own "0 at 0 kills is a feature, not a bug" precedent - a tooltip explains it
|
||||
/// instead of silently opening a useless empty window.
|
||||
/// - PERSISTENCE - the one thing NOT fully solved here, flagged rather than silently
|
||||
/// assumed: the Bag backing each player's vault lives in a plain in-memory
|
||||
/// Dictionary<int, Bag> in this file (PlayerVaults below), keyed by entityId. This
|
||||
/// is reliable for as long as the game process keeps running (survives death/respawn/
|
||||
/// relogging within one play session, confirmed by how a static field behaves) but has
|
||||
/// NOT been wired into any save/load system - closing the game entirely and reloading the
|
||||
/// save later will NOT bring the vault's contents back (no persistence file, no hook into
|
||||
/// PersistentPlayerData or a world-save event). Building real cross-session persistence
|
||||
/// (a custom save file + ModEvents.GameSave/Load hooks, or piggybacking on an owned
|
||||
/// world entity the way the summoned pets do - unconfirmed whether THOSE actually survive
|
||||
/// a full restart either) is real, separate follow-up work, not attempted here. Treat
|
||||
/// this like a session-scoped stash until that's built and confirmed - don't rely on it
|
||||
/// across game restarts yet.
|
||||
/// - PERSISTENCE - solved 2026-09-13, see SpatialVaultPersistence.cs. It was NOT solved
|
||||
/// when this item shipped, and that shortfall is exactly what became the mod's first
|
||||
/// Nexus bug report (youkia96581, 11 Sep 2026: "Items stored in the space bracelet will
|
||||
/// disappear after leaving the game and going online again"). PlayerVaults below is still
|
||||
/// the in-memory, entityId-keyed Dictionary it always was, but it is now only the session
|
||||
/// cache: the durable copy is written into the player's own PlayerDataFile, alongside the
|
||||
/// backpack, by four postfixes on FromPlayer/ToPlayer/Write/Read. Read that file's comment
|
||||
/// for why there ("почему не сделать принцип как у ящика?" - because a chest's items live
|
||||
/// in a chunk, and the bracelet's closest equivalent home is its owner's save data).
|
||||
///
|
||||
/// REGULAR ATTACK (index 0) - knock back + slow whatever zombie the crosshair is aimed at:
|
||||
/// - Same raycast mechanism HarmonySrc/ThiefLoopPatch.cs already established for
|
||||
@@ -71,8 +67,9 @@ namespace NecromancerTome
|
||||
public const float MaxRange = 50f;
|
||||
public const float ShoveDistance = 6f;
|
||||
|
||||
/// <summary>See the class-level comment above for exactly what this does and doesn't
|
||||
/// guarantee - session-scoped only, not yet saved/loaded across game restarts.</summary>
|
||||
/// <summary>Session cache only - the durable copy lives on disk, see
|
||||
/// SpatialVaultPersistence.cs. Cleared on WorldShuttingDown so a different save loaded
|
||||
/// afterwards cannot inherit this world's vault through a recycled entityId.</summary>
|
||||
public static readonly Dictionary<int, Bag> PlayerVaults = new Dictionary<int, Bag>();
|
||||
|
||||
public static bool Prefix(ItemActionData _actionData, bool _bReleased)
|
||||
@@ -118,7 +115,17 @@ namespace NecromancerTome
|
||||
|
||||
if (!PlayerVaults.TryGetValue(player.entityId, out Bag bag))
|
||||
{
|
||||
bag = new Bag(slotCount);
|
||||
// Normally a restored vault is already here - the ToPlayer postfix puts it in
|
||||
// when the game applies the save file to the spawning player. LastLoadedVault is
|
||||
// the safety net for when that chain does not complete: opening the bracelet must
|
||||
// never be what silently starts an empty vault over a saved one. Only then is a
|
||||
// genuinely new bag created.
|
||||
bag = SpatialVaultPersistence.LastLoadedVault ?? new Bag(slotCount);
|
||||
if (bag == SpatialVaultPersistence.LastLoadedVault)
|
||||
{
|
||||
Debug.Log("[NecromancerTome] SpatialVaultPatch: session cache was empty, adopted the last loaded vault (" +
|
||||
bag.SlotCount + " slots, " + bag.GetUsedSlotCount() + " used)");
|
||||
}
|
||||
PlayerVaults[player.entityId] = bag;
|
||||
}
|
||||
else if (bag.SlotCount < slotCount)
|
||||
@@ -134,7 +141,21 @@ namespace NecromancerTome
|
||||
|
||||
Debug.Log("[NecromancerTome] SpatialVaultPatch: owner=" + player.entityId + " opened vault, " + slotCount + " slots (Necromancy level " + level + ")");
|
||||
LocalPlayerUI playerUI = LocalPlayerUI.GetUIForPlayer(player);
|
||||
XUiC_BagStorageWindowGroup.Open(playerUI.xui, player, bag, LootContainer.GetLootContainer("roboticDrone"), Localization.Get("braceletSpatialVaultWindowTitle"));
|
||||
// The trailing callbacks are vanilla's own optional parameters (_onModified, _onClose).
|
||||
// _onModified is not needed: the vault lives in PlayerVaults, and PlayerDataFile's
|
||||
// FromPlayer postfix reads it fresh every time the game saves the player, so there is
|
||||
// nothing to flush per item move. _onClose asks for a player-data save right away, so
|
||||
// closing the window is a commit point rather than waiting for the next autosave -
|
||||
// SaveLocalPlayerData is the game's own routine call and no-ops when saving is not
|
||||
// active (which is the correct behaviour on a client, where the server owns the file).
|
||||
XUiC_BagStorageWindowGroup.Open(
|
||||
playerUI.xui,
|
||||
player,
|
||||
bag,
|
||||
LootContainer.GetLootContainer("roboticDrone"),
|
||||
Localization.Get("braceletSpatialVaultWindowTitle"),
|
||||
null,
|
||||
() => GameManager.Instance.SaveLocalPlayerData());
|
||||
}
|
||||
|
||||
public static void ShoveZombieAtCrosshair(EntityPlayerLocal player)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -4,6 +4,6 @@
|
||||
<DisplayName value="Necromancer's Tome" />
|
||||
<Description value="A dark necromancy progression for 7 Days to Die 3.2: a kill-count-driven skill tree with cursed weapons, charm/deviation magic, summonable undead pets, base-defence wards, and a story-ending Black Portal ritual. Fully localized into 13 languages. Single-player; requires EAC off." />
|
||||
<Author value="Alex Cube" />
|
||||
<Version value="2.0.0" />
|
||||
<Version value="1.0.1" />
|
||||
<Website value="https://www.alexcube.ru/7-days-to-die-moi-mody/kniga-nekromanta-necromancer-s-tome/" />
|
||||
</xml>
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,6 +1,6 @@
|
||||
# Книга некроманта / Necromancer's Tome (NecromancerTome)
|
||||
|
||||
**Версия 1.0** — для 7 Days to Die 3.2. Автор: Alex Cube.
|
||||
**Версия 1.0.1** — для 7 Days to Die 3.2. Автор: Alex Cube.
|
||||
|
||||
- Страница мода: https://www.alexcube.ru/7-days-to-die-moi-mody/kniga-nekromanta-necromancer-s-tome/
|
||||
- Nexus Mods: https://www.nexusmods.com/7daystodie/mods/12547
|
||||
@@ -121,7 +121,9 @@
|
||||
|
||||
## Статус
|
||||
|
||||
Версия 1.0 — весь заявленный контент реализован и проходит тесты в игре. Из запланированного не
|
||||
Версия 1.0.1 — исправление по первому баг-репорту с Nexus: содержимое Пространственного
|
||||
браслета больше не пропадает после выхода из игры (хранилище теперь сохраняется в файле
|
||||
игрока, рядом с рюкзаком). Весь заявленный контент реализован и проходит тесты в игре. Из запланированного не
|
||||
сделана только часть фирменных звуков. Текст описания для сайта (RU + EN) — в
|
||||
`SITE_DESCRIPTION.html` (разметка блоков WordPress). Полная техническая история разработки и текст финала лежат рядом с модом
|
||||
в `BACKLOG.md` и `FINAL_TEXT.md` — в репозиторий они не входят (спойлеры и внутренняя кухня).
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user