using System; using System.Collections.Generic; using System.IO; using System.Reflection; using HarmonyLib; using UnityEngine; namespace NecromancerTome { /// /// Adds the mod's own paint to the game's opaque block texture atlas, so the Pyramid of /// Spirits can ship with a surface that does not exist in vanilla. /// /// WHY A PATCH IS THE ONLY WAY. A block's Texture property is an INDEX into a prebuilt /// atlas; a paint entry in painting.xml carries TextureId/PaintCost/Group/SortIndex and /// never a path to an image, and the atlas itself is compiled into /// blocktextureatlases_assets_all.bundle. Nothing in the XML layer can introduce new image /// data, so the array has to be extended at runtime. /// /// WHY THIS HOOK. CreateBlockTextures is the coroutine that reads painting.xml. Hooking its /// completion is deliberate and was learned the hard way: an earlier probe ran from /// MeshDescription.ReloadTextureArrays and found BlockTextureData.list still null, because /// the texture arrays load BEFORE painting.xml. By the time this coroutine finishes, both the /// arrays and the paint table exist. /// /// THE NUMBERS THIS RELIES ON were measured in-game rather than assumed (BlockAtlasProbe, /// 2026-09-10): the opaque atlas holds 407 slices of 512x512 with a full 10-level mip chain, /// diffuse as DXT1 and normal/specular as DXT5, and all three arrays are non-readable. Two /// consequences drive the code below - our textures must match those numbers exactly, and /// every copy must go through the GPU, since a non-readable array cannot be read back. /// /// SAFETY. Everything is wrapped: this runs inside the game's own XML loading, and an /// escaping exception aborts that step - which is exactly how a careless earlier version /// produced "XML loader: Executing post load step on 'materials.xml' failed". If anything /// here fails, the mod logs it and leaves the game exactly as it was. /// public static class CustomBlockPaintPatch { /// Bundle we ship the paint textures in, relative to the mod folder. const string BundlePath = "Resources/necroatlas"; const string DiffuseAsset = "Assets/NecroAtlas/atlas_necroPyramid_d.png"; const string NormalAsset = "Assets/NecroAtlas/atlas_necroPyramid_n.png"; const string SpecularAsset = "Assets/NecroAtlas/atlas_necroPyramid_m.png"; /// Name the paint is registered under; blocks.xml refers to the resulting id. public const string PaintName = "txName_NecroAsh"; /// Paint id handed out by the game once registration succeeds, -1 while unset. /// Logged on success so it can be written into blocks.xml. public static int AssignedPaintId = -1; static bool alreadyRan; /// /// Patch the coroutine's MoveNext. A coroutine compiles into a hidden state-machine /// class, so the method that actually runs is MoveNext, not CreateBlockTextures itself - /// AccessTools.EnumeratorMoveNext resolves it for us. /// [HarmonyPatch] static class CreateBlockTexturesHook { static IEnumerable TargetMethods() { MethodBase coroutine = AccessTools.Method( typeof(BlockTexturesFromXML), "CreateBlockTextures"); if (coroutine == null) { Debug.LogWarning("[NecromancerTome] CustomBlockPaint: " + "BlockTexturesFromXML.CreateBlockTextures not found - paint not added"); yield break; } MethodBase moveNext = AccessTools.EnumeratorMoveNext(coroutine); if (moveNext == null) { Debug.LogWarning("[NecromancerTome] CustomBlockPaint: " + "could not resolve the coroutine's MoveNext - paint not added"); yield break; } yield return moveNext; } // __result == false means the enumerator is done: the XML has been read in full. static void Postfix(bool __result) { if (__result || alreadyRan) return; alreadyRan = true; try { AddPaint(); } catch (Exception e) { Debug.LogWarning("[NecromancerTome] CustomBlockPaint: failed, game left " + "untouched: " + e); } } } static void AddPaint() { if (GameManager.IsDedicatedServer) { Debug.Log("[NecromancerTome] CustomBlockPaint: dedicated server, textures skipped"); return; } MeshDescription mesh = MeshDescription.meshes[MeshDescription.MESH_OPAQUE]; var atlas = mesh == null ? null : mesh.textureAtlas as TextureAtlasBlocks; if (atlas == null) { Debug.LogWarning("[NecromancerTome] CustomBlockPaint: opaque atlas unavailable"); return; } AssetBundle bundle = LoadBundle(); if (bundle == null) return; try { var diffuse = bundle.LoadAsset(DiffuseAsset); var normal = bundle.LoadAsset(NormalAsset); var specular = bundle.LoadAsset(SpecularAsset); if (diffuse == null || normal == null || specular == null) { Debug.LogWarning("[NecromancerTome] CustomBlockPaint: bundle is missing one " + "of the three textures - nothing added"); return; } Describe("our diffuse ", diffuse); Describe("our normal ", normal); Describe("our specular", specular); int slice = Append(ref atlas.diffuseTexture, diffuse, "diffuse"); Append(ref atlas.normalTexture, normal, "normal"); Append(ref atlas.specularTexture, specular, "specular"); if (slice < 0) return; mesh.TexDiffuse = atlas.diffuseTexture; mesh.TexNormal = atlas.normalTexture; mesh.TexSpecular = atlas.specularTexture; mesh.ReloadTextureArrays(false); int textureId = RegisterUvMapping(atlas, slice); RegisterPaint(textureId); } finally { // Keep the loaded textures alive: only the bundle wrapper is released. bundle.Unload(false); } } /// /// Give the new slice an entry in uvMapping and return its index - that index is what a /// block's Texture property in blocks.xml actually refers to. /// /// The entry is CLONED from an existing plain opaque paint rather than built field by /// field. UVRectTiling carries more than a slice number (tiling, block size, material /// flags), and copying a known-good neighbour keeps every one of those correct without /// guessing at fields we have never inspected. Only the slice index is changed. /// static int RegisterUvMapping(TextureAtlasBlocks atlas, int slice) { // 356 is txName_Steel_wall - an ordinary full-block opaque paint, which is exactly // the shape of entry we want. const int TemplateTextureId = 356; if (atlas.uvMapping == null || atlas.uvMapping.Length <= TemplateTextureId) { Debug.LogWarning("[NecromancerTome] CustomBlockPaint: uvMapping too small to " + "clone a template from - paint not registered"); return -1; } int textureId = atlas.uvMapping.Length; Array.Resize(ref atlas.uvMapping, textureId + 1); UVRectTiling tile = atlas.uvMapping[TemplateTextureId]; // Только индекс слоя: имени у UVRectTiling нет, оно живёт в BlockTextureData. tile.index = slice; atlas.uvMapping[textureId] = tile; Debug.Log("[NecromancerTome] CustomBlockPaint: uvMapping entry " + textureId + " points at slice " + slice + " (cloned from " + TemplateTextureId + ")"); return textureId; } /// /// Register the paint itself, so it has a name, shows up in the paint brush, and can be /// referred to by name. The block only needs the texture id, but a nameless texture with /// no paint entry would be invisible to the rest of the game. /// static void RegisterPaint(int textureId) { if (textureId < 0) return; if (BlockTextureData.list == null) { Debug.LogWarning("[NecromancerTome] CustomBlockPaint: paint table missing - " + "texture added but not named"); return; } int free = -1; for (int i = 0; i < BlockTextureData.list.Length; i++) { if (BlockTextureData.list[i] == null) { free = i; break; } } if (free < 0) { Debug.LogWarning("[NecromancerTome] CustomBlockPaint: no free paint slot - " + "texture added but not named"); return; } var data = new BlockTextureData { ID = free, Name = PaintName, LocalizedName = Localization.Get(PaintName), TextureID = (ushort)textureId, Group = "txGroupMasonry", PaintCost = 1, SortIndex = 0, Hidden = false, }; data.Init(); AssignedPaintId = free; Debug.Log("[NecromancerTome] CustomBlockPaint: paint registered, slot " + free + ", texture id " + textureId + " -> put Texture=\"" + textureId + "\" on the block in blocks.xml"); } static AssetBundle LoadBundle() { if (ModEntry.Instance == null) { Debug.LogWarning("[NecromancerTome] CustomBlockPaint: mod path unknown"); return null; } string path = Path.Combine(ModEntry.Instance.Path, BundlePath); if (!File.Exists(path)) { Debug.LogWarning("[NecromancerTome] CustomBlockPaint: bundle not found at " + path); return null; } AssetBundle bundle = AssetBundle.LoadFromFile(path); if (bundle == null) { Debug.LogWarning("[NecromancerTome] CustomBlockPaint: bundle failed to load: " + path); } return bundle; } /// /// Rebuild a texture array one slice larger and put our texture in the new last slot. /// Returns the new slice index, or -1 if the arrays disagree on anything that makes a /// copy impossible. /// static int Append(ref Texture target, Texture2D ours, string label) { var src = target as Texture2DArray; if (src == null) { Debug.LogWarning("[NecromancerTome] CustomBlockPaint: " + label + " is not a Texture2DArray - skipped"); return -1; } if (ours.width != src.width || ours.height != src.height) { Debug.LogWarning("[NecromancerTome] CustomBlockPaint: " + label + " size mismatch, " + "atlas is " + src.width + "x" + src.height + " but ours is " + ours.width + "x" + ours.height + " - skipped"); return -1; } if (ours.graphicsFormat != src.graphicsFormat) { Debug.LogWarning("[NecromancerTome] CustomBlockPaint: " + label + " format mismatch, " + "atlas is " + src.graphicsFormat + " but ours is " + ours.graphicsFormat + " - skipped"); return -1; } if (ours.mipmapCount != src.mipmapCount) { Debug.LogWarning("[NecromancerTome] CustomBlockPaint: " + label + " mip mismatch, " + "atlas has " + src.mipmapCount + " but ours has " + ours.mipmapCount + " - skipped"); return -1; } int slice = src.depth; var grown = new Texture2DArray(src.width, src.height, slice + 1, src.graphicsFormat, UnityEngine.Experimental.Rendering.TextureCreationFlags.MipChain, src.mipmapCount); grown.name = src.name + "+necro"; grown.wrapMode = src.wrapMode; grown.filterMode = src.filterMode; grown.anisoLevel = src.anisoLevel; // GPU-side copy: the game's arrays are non-readable, so nothing can be pulled back // to the CPU. CopyTexture moves whole slices with their mip chains. for (int i = 0; i < slice; i++) Graphics.CopyTexture(src, i, grown, i); Graphics.CopyTexture(ours, 0, grown, slice); target = grown; Debug.Log("[NecromancerTome] CustomBlockPaint: " + label + " grown from " + slice + " to " + (slice + 1) + " slices"); return slice; } static void Describe(string label, Texture2D tex) { Debug.Log("[NecromancerTome] CustomBlockPaint: " + label + " " + tex.width + "x" + tex.height + " format=" + tex.format + " graphicsFormat=" + tex.graphicsFormat + " mips=" + tex.mipmapCount); } } }