Версия 2.0: свои модели ножа и крови, своя краска блоков через патч атласа

Нож некроманта и Кровь некроманта получили собственные модели, а Пирамида
духов - собственную поверхность. Три разных способа, каждый выбран по тому,
как устроен сам предмет.

НОЖ И КРОВЬ - свои префабы в бандлах мода.
Форма записи "#@modfolder(...)?prefab" подтверждена живым примером; геометрия
у обоих ванильная, меняются материал и текстура. Текстуры генерируются
скриптами (см. _private/tools), а не рисуются: правка вида сводится к правке
констант и одному запуску.

Нож: состаренная кость, почти чёрная обмотка, пурпур во впадинах, плюс слой
под ручную роспись рун - он подмешивается в альбедо и в эмиссию, поэтому руны
светятся. Слой пользовательский, генератор его никогда не перезаписывает.
Положение росписи посчитано по геометрии: развёртка выгружена из меша, и
подобрано смещение, при котором вся роспись ложится на одну плоскую грань.
Раньше она перегибалась через кромку, обращённую к игроку.

Кровь: банка вместо мешка. Заодно чинится расхождение, жившее с самого начала -
описание предмета говорило "Банка, наполненная кровью", а наследуемый
medicalBloodBag показывал sackPrefab, обычный мешок. Жидкость перерисована по
маскам мешей, а не перекрашена тинтом: тинт предмета на этот меш не действует
вовсе, у шейдера Game_EntityTintMaskSSS выигрывает _Color материала.

ПИРАМИДА - своя краска в атласе блоков через собственный Harmony-патч.
Ваниль своих текстур блоков не умеет: Texture у блока это индекс в готовом
атласе, а запись краски несёт только TextureId/PaintCost/Group/SortIndex и
никогда путь к картинке. Поэтому CustomBlockPaintPatch дописывает наш слой в
массивы атласа на лету.

Путь через свою модель (Shape="ModelEntity") пробовался и отложен: там
остались нерешёнными столкновения и маджента на дальних экземплярах. Краска
лучше тем, что блок остаётся обычным Shape="New" - со всеми работающими
столкновениями, наведением по E и правильной посадкой, - а поверхность у него
своя. Побочно краска доступна кисточкой под именем "Некротический прах".

Параметры атласа не угаданы, а замерены в игре (BlockAtlasProbePatch): 512x512,
DXT1 для альбедо и DXT5 для нормалей со specular, 10 мип-уровней, массивы
нечитаемые - отсюда GPU-копирование и явные настройки импорта текстур.
Зонд оставлен намеренно: номер краски в blocks.xml (608) это длина ванильного
uvMapping, и после обновления игры он может сдвинуться - зонд печатает
фактические числа при каждом запуске.

Патчи обёрнуты целиком: они работают внутри загрузки игры, где вылетевшее
исключение срывает шаг загрузки.

Resources/necropyramid в коммит не идёт - на него никто не ссылается.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W5F3AVwsusZHMqPBcSQcVJ
This commit is contained in:
AlexCube
2026-09-10 16:28:59 +03:00
co-authored by Claude Opus 5
parent 026e006c9c
commit ab1b3eadaf
13 changed files with 654 additions and 20 deletions
+149
View File
@@ -0,0 +1,149 @@
using System;
using System.Text;
using HarmonyLib;
using UnityEngine;
namespace NecromancerTome
{
/// <summary>
/// DIAGNOSTIC - measures the game's opaque block texture atlas and logs what it finds. Adds
/// nothing and changes nothing.
///
/// KEPT ON PURPOSE, though it started as throwaway reconnaissance for CustomBlockPaintPatch.
/// That patch appends our paint to the end of the atlas, so the block's Texture number in
/// blocks.xml (608 today) is simply "however many entries vanilla had". A game update that
/// grows the atlas moves it. This probe prints the real numbers on every load, which is what
/// turns that drift from a silent wrong texture into one line in the log.
///
/// WHY THIS EXISTS. The Pyramid of Spirits should ship with its own surface, but vanilla has
/// no way to add one: a block's Texture property is an INDEX into a prebuilt atlas, and the
/// atlas itself lives in blocktextureatlases_assets_all.bundle. Confirmed by reading the
/// game's own strings - a paint entry carries only TextureId/PaintCost/Group/SortIndex, never
/// a path to an image. So the only way in is to extend the atlas at runtime from a Harmony
/// patch.
///
/// Extending it means building a bigger Texture2DArray, copying every existing slice across
/// and appending ours. That REQUIRES knowing the array's exact width, height, format and
/// mipmap count - a slice that disagrees on any of those cannot be copied in. None of it can
/// be known statically, hence this probe: measure first, write the real patch second.
///
/// TWO LESSONS FROM THE FIRST ATTEMPT, both paid for in a broken load:
///
/// 1. A THROWING POSTFIX BREAKS THE GAME'S LOADING. The first version dereferenced
/// BlockTextureData.list without checking it, threw, and the log answered with
/// "XML loader: Executing post load step on 'materials.xml' failed". A probe must be
/// incapable of harm, so everything here is wrapped and nothing is allowed to escape.
///
/// 2. THIS RUNS BEFORE THE PAINT TABLE EXISTS. ReloadTextureArrays fires during
/// MeshDescription.Init, and the log shows painting.xml loading well after it - so
/// BlockTextureData.list is still null at that point. Hence the probe reports several
/// times instead of once: the early call shows the atlas as loaded, later calls show it
/// once the rest of the game has caught up.
///
/// </summary>
[HarmonyPatch(typeof(MeshDescription), "ReloadTextureArrays")]
public static class BlockAtlasProbePatch
{
const int MaxReports = 4;
static int reports;
static void Postfix()
{
if (reports >= MaxReports) return;
reports++;
// Never let a measurement break a load: the game calls this from inside its own
// XML post-load step, and an escaping exception aborts that step.
try
{
LogOpaqueAtlas(reports);
}
catch (Exception e)
{
Debug.LogWarning("[NecromancerTome] BlockAtlasProbe: measurement #" + reports +
" failed harmlessly: " + e.Message);
}
}
public static void LogOpaqueAtlas(int report)
{
var sb = new StringBuilder();
sb.AppendLine("[NecromancerTome] BlockAtlasProbe #" + report + ": opaque block atlas");
if (MeshDescription.meshes == null)
{
sb.AppendLine(" MeshDescription.meshes is null - too early");
Debug.Log(sb.ToString());
return;
}
MeshDescription mesh = MeshDescription.meshes[MeshDescription.MESH_OPAQUE];
if (mesh == null)
{
sb.AppendLine(" MESH_OPAQUE is null - too early");
Debug.Log(sb.ToString());
return;
}
var atlas = mesh.textureAtlas as TextureAtlasBlocks;
if (atlas == null)
{
sb.AppendLine(" textureAtlas is " + (mesh.textureAtlas == null
? "null" : mesh.textureAtlas.GetType().Name) + ", expected TextureAtlasBlocks");
Debug.Log(sb.ToString());
return;
}
sb.AppendLine(" uvMapping entries: " +
(atlas.uvMapping == null ? "null" : atlas.uvMapping.Length.ToString()));
Describe(sb, "diffuse ", atlas.diffuseTexture);
Describe(sb, "normal ", atlas.normalTexture);
Describe(sb, "specular", atlas.specularTexture);
// A new paint needs an unused index in BlockTextureData.list. The table is filled
// from painting.xml, which loads AFTER the textures - so on the early call this is
// still null, and that is expected rather than a fault.
if (BlockTextureData.list == null)
{
sb.AppendLine(" paint table: not built yet (painting.xml loads later)");
}
else
{
int used = 0, free = 0;
for (int i = 0; i < BlockTextureData.list.Length; i++)
{
if (BlockTextureData.list[i] == null) free++; else used++;
}
sb.AppendLine(" paint slots: " + used + " used, " + free + " free, " +
BlockTextureData.list.Length + " total");
}
Debug.Log(sb.ToString());
}
static void Describe(StringBuilder sb, string label, Texture texture)
{
if (texture == null)
{
sb.AppendLine(" " + label + ": null");
return;
}
var arr = texture as Texture2DArray;
if (arr == null)
{
sb.AppendLine(" " + label + ": " + texture.GetType().Name +
" (expected Texture2DArray) " + texture.width + "x" + texture.height);
return;
}
// depth = how many slices are already in the array; ours would become index `depth`,
// and every number below has to be matched exactly by our own texture.
sb.AppendLine(" " + label + ": " + arr.width + "x" + arr.height +
" slices=" + arr.depth +
" format=" + arr.format +
" graphicsFormat=" + arr.graphicsFormat +
" mips=" + arr.mipmapCount +
" readable=" + arr.isReadable);
}
}
}