fix(dashboard): texture alpha in 3D scenery follows the game's shaders

The viewer treated every blended texture's alpha as opacity, so models
whose shader uses the alpha for something else rendered see-through. The
scenery manifest now carries each model's shader (RenderComponent.
shader_id via mapShaders) and the multishader tag table; meshes carry
their S##__ tag, read the way LWOBaseRenderComponent::AddObjectToRenderPipe
does (S%d, else _S%d, outside 3..108 the LEGO shader). Per res/shaders:
the LEGO lighting shaders lerp the texture over the vertex colors
(decal), LEGO items and terrain meshes ignore its alpha, everything else
keeps opacity. Pure rules unit tested.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Aaron Kimbrell
2026-09-27 03:43:36 -05:00
parent 2961089d6b
commit 330f02de30
7 changed files with 233 additions and 18 deletions

View File

@@ -118,6 +118,7 @@ namespace {
int32_t texturing = -1;
int32_t vertexColor = -1;
int32_t stencil = -1;
int32_t shaderTag = -1; // the nearest multishader tag ("S05__...") on the way down the tree
};
struct NetHeader {
@@ -125,6 +126,7 @@ namespace {
};
struct AvHeader {
std::string name;
uint16_t flags{};
Transform transform;
std::vector<int32_t> properties;
@@ -330,7 +332,7 @@ namespace {
AvHeader ReadAv(Reader& reader) {
AvHeader av;
ReadNet(reader);
av.name = ReadNet(reader).name;
av.flags = reader.U16();
for (auto& value : av.transform.t) value = reader.Float();
// Matrix33 is stored m11, m21, m31, m12, ... (nif.xml): column by column
@@ -385,6 +387,7 @@ namespace {
const auto world = parent.Then(av.transform);
properties = Inherit(properties, av.properties);
if (const auto tag = NifFile::ShaderTag(av.name); tag >= 0) properties.shaderTag = tag;
std::vector<int32_t> drawn = children;
if (type == "NiSwitchNode" || type == "NiLODNode") {
@@ -431,6 +434,7 @@ namespace {
const auto skin = reader.I32();
if (!reader.Ok() || (av.flags & APP_CULLED)) return;
properties = Inherit(properties, av.properties);
if (const auto tag = NifFile::ShaderTag(av.name); tag >= 0) properties.shaderTag = tag;
const auto* dataType = TypeOf(dataRef);
if (!dataType || (*dataType != "NiTriShapeData" && *dataType != "NiTriStripsData")) return;
m_Used.insert(dataRef);
@@ -438,6 +442,7 @@ namespace {
NifFile::Mesh mesh;
if (!ReadGeometryData(dataRef, *dataType == "NiTriStripsData", parent.Then(av.transform), mesh) || mesh.indices.empty()) return;
mesh.material = ReadMaterial(properties);
mesh.material.shaderTag = properties.shaderTag;
if (skin >= 0) {
m_Model.skinned++;
m_Used.insert(skin);
@@ -606,6 +611,48 @@ namespace {
}
namespace NifFile {
int32_t ShaderTag(std::string_view name) {
// The client reads the tag with sscanf: "S%d" at the start of the name, else "_S%d" after the first "_S"
const auto number = [](std::string_view digits) -> int32_t {
size_t i = 0;
while (i < digits.size() && (digits[i] == ' ' || digits[i] == '\t')) i++;
// A signed number names no mapShaders row, like no number at all
if (i >= digits.size() || digits[i] < '0' || digits[i] > '9') return -1;
int32_t value = 0;
for (; i < digits.size() && digits[i] >= '0' && digits[i] <= '9' && value < 100000; i++) value = value * 10 + (digits[i] - '0');
return value;
};
if (name.starts_with('S')) {
const auto tag = number(name.substr(1));
if (tag >= 0) return tag;
}
const auto at = name.find("_S");
if (at == std::string_view::npos || at + 3 >= name.size()) return -1;
return number(name.substr(at + 2));
}
int32_t MultishaderPart(std::optional<int32_t> tagShader) {
return tagShader && *tagShader >= 3 && *tagShader <= 0x6C ? *tagShader : LEGO_SHADER;
}
eTextureAlpha TextureAlphaFor(int32_t shader) {
switch (shader) {
// LEGOPPLighting: textured alone the texture's alpha is forced to 1; with vertex colors it only lays the
// texture over them (lerp by its alpha) and the vertex alpha is what shows through
case 4: case 5: case 12: case 25: case 27: case 28: case 29: case 30: case 50: case 72: case 88:
// Darkling: the same lay-over; alpha from lighting or the fade
case 75: case 76: case 77: case 102: case 103: case 104:
return eTextureAlpha::DECAL;
// LEGOPPLighting_Item: texture alpha forced to 1, multiplied by the vertex colors
case 31: case 48:
// TerrainMeshLighting_Rim: texture times vertex colors, alpha only the fade
case 3:
return eTextureAlpha::IGNORED;
default:
return eTextureAlpha::OPACITY;
}
}
std::optional<Model> Parse(std::string_view data, uint32_t lod, std::string& error) {
return Parser(data, lod).Run(error);
}
@@ -635,7 +682,8 @@ namespace NifFile {
{"normals", !mesh.normals.empty()}, {"uv", !mesh.uvs.empty() && textureIndex >= 0}, {"colors", !mesh.colors.empty()},
{"diffuse", Color(material.diffuse)}, {"emissive", Color(material.emissive)}, {"alpha", std::round(material.alpha * 1000.0f) / 1000.0f},
{"blend", material.alphaBlend}, {"test", material.alphaTest ? material.alphaThreshold : -1}, {"doubleSided", material.doubleSided},
{"vertexColors", material.vertexColorMode}, {"texture", textureIndex}, {"clampU", material.clampU}, {"clampV", material.clampV}
{"vertexColors", material.vertexColorMode}, {"texture", textureIndex}, {"clampU", material.clampU}, {"clampV", material.clampV},
{"shaderTag", material.shaderTag}
};
Append(body, mesh.positions.data(), mesh.positions.size() * sizeof(float));
if (!mesh.normals.empty()) {

View File

@@ -35,6 +35,7 @@ namespace NifFile {
int32_t embeddedTexture{ -1 }; // else the block with the texture's pixels in the file (EmbeddedTexture)
bool clampU{};
bool clampV{};
int32_t shaderTag{ -1 }; // mapShaders id from a multishader tag in the name of the mesh or a node above it
};
struct Mesh {
@@ -46,6 +47,31 @@ namespace NifFile {
std::vector<uint16_t> indices; // triangles
};
/**
* What a texture's alpha channel does, which the client's shader decides (res/shaders/*.fx), not the .nif:
* OPACITY it is see-through where the alpha is (Basic, AlphaAsAlpha, fixed function and most others); DECAL the
* texture is laid over the vertex colors by its alpha and the mesh itself stays as opaque as its vertex colors
* (the LEGO lighting shaders); IGNORED the alpha does nothing (LEGO items, terrain meshes).
*/
enum class eTextureAlpha : uint8_t { OPACITY, DECAL, IGNORED };
// mapShaders.gameValue of the LEGO shader, the client's default
constexpr int32_t LEGO_SHADER = 5;
// RenderComponent's shader for models whose parts name their own shaders (mapShaders "Multishader")
constexpr int32_t MULTISHADER = 9999;
/**
* The mapShaders id in a multishader part's name: "S05__TRUNKS" or "rock_S30" (LWOBaseRenderComponent::
* AddObjectToRenderPipe reads "S%d", else "_S%d"); -1 for none.
*/
int32_t ShaderTag(std::string_view name);
// The shader (gameValue) a multishader part is drawn with, from its tag's gameValue: outside 3..108 the LEGO shader
int32_t MultishaderPart(std::optional<int32_t> tagShader);
// What a texture's alpha does under a shader (mapShaders.gameValue); -1 is fixed function (opacity)
eTextureAlpha TextureAlphaFor(int32_t shader);
struct Model {
uint32_t version{};
std::vector<Mesh> meshes;

View File

@@ -142,16 +142,24 @@ namespace {
struct RenderInfo {
std::string asset; // RenderComponent.render_asset as stored
std::string type; // Objects.type
int32_t shader{ -1 }; // mapShaders.gameValue of RenderComponent.shader_id (-1 fixed function or unknown)
};
// mapShaders: id (what RenderComponent.shader_id and multishader tags name) -> gameValue (the shader drawn)
std::map<int32_t, int32_t> g_ShaderValues;
// Every LOT with a render component, read once (Objects has no index on id, so it is read whole too)
std::unordered_map<uint32_t, RenderInfo> ReadRenderInfos() {
std::unordered_map<uint32_t, RenderInfo> infos;
try {
auto row = CDClientDatabase::ExecuteQuery(
"SELECT cr.id, rc.render_asset FROM ComponentsRegistry cr JOIN RenderComponent rc ON rc.id = cr.component_id "
"WHERE cr.component_type = " + std::to_string(RENDER_COMPONENT) + ";");
for (; !row.eof(); row.nextRow()) infos.try_emplace(static_cast<uint32_t>(row.getIntField(0)), RenderInfo{ row.getStringField(1, ""), "" });
"SELECT cr.id, rc.render_asset, m.gameValue FROM ComponentsRegistry cr JOIN RenderComponent rc ON rc.id = cr.component_id "
"LEFT JOIN mapShaders m ON m.id = rc.shader_id WHERE cr.component_type = " + std::to_string(RENDER_COMPONENT) + ";");
for (; !row.eof(); row.nextRow()) {
infos.try_emplace(static_cast<uint32_t>(row.getIntField(0)), RenderInfo{ row.getStringField(1, ""), "", row.fieldIsNull(2) ? -1 : row.getIntField(2) });
}
auto shaders = CDClientDatabase::ExecuteQuery("SELECT id, gameValue FROM mapShaders;");
for (; !shaders.eof(); shaders.nextRow()) g_ShaderValues.try_emplace(shaders.getIntField(0), shaders.getIntField(1));
auto types = CDClientDatabase::ExecuteQuery("SELECT id, type FROM Objects;");
for (; !types.eof(); types.nextRow()) {
const auto it = infos.find(static_cast<uint32_t>(types.getIntField(0)));
@@ -171,6 +179,7 @@ namespace {
struct Model {
std::string path; // res path of the .nif, empty for none
bool hidden{}; // the client doesn't draw it (WorldScene::ClientDraws)
int32_t shader{ -1 }; // RenderInfo::shader
};
/**
@@ -189,12 +198,34 @@ namespace {
const auto& stored = object.nifName.empty() ? info->second.asset : object.nifName;
{
std::lock_guard lock(mutex);
if (const auto it = resolved.find(stored); it != resolved.end()) return { it->second, draw == WorldScene::eClientDraw::HIDDEN };
if (const auto it = resolved.find(stored); it != resolved.end()) return { it->second, draw == WorldScene::eClientDraw::HIDDEN, info->second.shader };
}
auto path = ResolveModel(stored);
std::lock_guard lock(mutex);
resolved.try_emplace(stored, path);
return { std::move(path), draw == WorldScene::eClientDraw::HIDDEN };
return { std::move(path), draw == WorldScene::eClientDraw::HIDDEN, info->second.shader };
}
/**
* How the client's shaders use each model's texture alpha, for the viewer: "shaders" gives each asset's shader
* (the first object drawing it wins; -1 fixed function or not an object), "shaderTags" a multishader part's tag ->
* shader, and "textureAlpha" the shaders whose texture alpha isn't opacity (NifFile::TextureAlphaFor).
*/
void AddShaders(nlohmann::json& manifest, const std::vector<int32_t>& assetShaders) {
RenderInfos(); // reads g_ShaderValues
manifest["shaders"] = assetShaders;
nlohmann::json tags = nlohmann::json::object(), modes = nlohmann::json::object();
for (const auto& [id, value] : g_ShaderValues) tags[std::to_string(id)] = value;
auto mode = [&modes](int32_t value) {
const auto alpha = NifFile::TextureAlphaFor(value);
if (alpha != NifFile::eTextureAlpha::OPACITY) modes[std::to_string(value)] = alpha == NifFile::eTextureAlpha::DECAL ? "decal" : "ignored";
};
for (const auto& [id, value] : g_ShaderValues) mode(value);
mode(NifFile::LEGO_SHADER);
manifest["shaderTags"] = std::move(tags);
manifest["textureAlpha"] = std::move(modes);
manifest["multishader"] = NifFile::MULTISHADER;
manifest["defaultShader"] = NifFile::LEGO_SHADER;
}
std::optional<std::string> LuzPath(uint32_t zone) {
@@ -233,6 +264,7 @@ namespace {
ZoneScenery scenery;
nlohmann::json assetOf = nlohmann::json::array(), positions = nlohmann::json::array(), rotations = nlohmann::json::array(), scales = nlohmann::json::array();
nlohmann::json hidden = nlohmann::json::array();
std::vector<int32_t> assetShaders;
int64_t sky = -1;
for (const auto& scene : ZonePaths::ReadSceneFiles(*luz)) {
const auto lvl = ClientAssets::ReadResFile("maps/" + folder + scene);
@@ -245,17 +277,23 @@ namespace {
// A spawner is drawn as what it spawns, where the client would show it
const auto model = ModelFor(object);
if (model.path.empty()) continue;
assetOf.push_back(scenery.IndexOf(model.path));
const auto asset = scenery.IndexOf(model.path);
assetOf.push_back(asset);
if (asset >= assetShaders.size()) assetShaders.resize(asset + 1, -1);
if (assetShaders[asset] == -1) assetShaders[asset] = model.shader;
hidden.push_back(model.hidden ? 1 : 0);
for (const auto value : { object.x, object.y, object.z }) positions.push_back(Round(value, 100.0));
for (const auto value : { object.qx, object.qy, object.qz, object.qw }) rotations.push_back(Round(value, 10000.0));
scales.push_back(Round(object.scale, 1000.0));
}
}
scenery.json = nlohmann::json{
nlohmann::json manifest{
{"zone", zoneId}, {"sky", sky}, {"assets", scenery.assets},
{"objects", { {"asset", assetOf}, {"pos", positions}, {"rot", rotations}, {"scale", scales}, {"hidden", hidden} }}
}.dump();
};
assetShaders.resize(scenery.assets.size(), -1);
AddShaders(manifest, assetShaders);
scenery.json = manifest.dump();
return std::make_shared<ZoneScenery>(std::move(scenery));
}

View File

@@ -36,6 +36,23 @@ export function parseModel(buffer) {
return { header, meshes };
}
/**
* What a mesh's texture alpha does in the game, from the manifest's shader data (Scenery.cpp AddShaders): the
* client's shader decides, not the .nif. 'opacity' see-through where the alpha is; 'decal' the texture is laid over
* the vertex colors by its alpha (LEGO shaders); 'ignored' the alpha does nothing. A multishader model's parts name
* their shader in their node names (mesh.shaderTag); a tag the client can't use falls back to the LEGO shader.
*/
export function textureAlphaMode(manifest, asset, mesh) {
if (!manifest || !manifest.shaders || !manifest.textureAlpha) return 'opacity';
let shader = manifest.shaders[asset];
if (shader === undefined || shader === null) return 'opacity';
if (shader === manifest.multishader) {
const tagged = mesh && mesh.shaderTag >= 0 && manifest.shaderTags ? manifest.shaderTags[mesh.shaderTag] : undefined;
shader = tagged !== undefined && tagged >= 3 && tagged <= 108 ? tagged : manifest.defaultShader;
}
return manifest.textureAlpha[shader] || 'opacity';
}
/**
* Meshes of a model that look the same (texture, colors, blending, sides, attributes) joined into one, so a model
* made of many pieces (the zones' "glom" files have over a hundred) costs a few draw calls instead of one per piece.
@@ -46,7 +63,7 @@ export function mergeMeshes(meshes) {
for (const mesh of meshes) {
if (!mesh.vertices || !mesh.indices.length) continue;
const key = JSON.stringify([mesh.texture, mesh.diffuse, mesh.emissive, mesh.alpha, mesh.blend, mesh.test, mesh.doubleSided,
mesh.vertexColors, mesh.clampU, mesh.clampV, !!mesh.normals, !!mesh.uvs, !!mesh.colors]);
mesh.vertexColors, mesh.clampU, mesh.clampV, !!mesh.normals, !!mesh.uvs, !!mesh.colors, mesh.shaderTag]);
if (!groups.has(key)) groups.set(key, []);
groups.get(key).push(mesh);
}

View File

@@ -9,7 +9,7 @@
* follows the camera, far away, behind everything.
*/
import * as THREE from 'three';
import { parseModel, mergeMeshes, parseDds, decodeDxt, completeChain, linearColors, groupObjects, cellsOf } from '/js/scenery-core.js';
import { parseModel, mergeMeshes, parseDds, decodeDxt, completeChain, linearColors, groupObjects, cellsOf, textureAlphaMode } from '/js/scenery-core.js';
// Per detail level (the property view's 0 high, 1 medium, 2 low): the model LOD, how far objects are drawn, the
// largest texture side and a memory budget for geometry and textures
@@ -126,12 +126,50 @@ export function createScenery({ scene, camera, renderer, urls, focus, onProgress
return geometry;
}
function materialOf(mesh, map, forSky) {
// The game's LEGO shaders lay the texture over the vertex colors by its alpha instead of letting it show through
const DECAL_FRAGMENT = `
#if defined( USE_COLOR_ALPHA )
diffuseColor *= vColor;
#elif defined( USE_COLOR )
diffuseColor.rgb *= vColor;
#endif
#ifdef USE_MAP
vec4 sampledDiffuseColor = texture2D( map, vMapUv );
#if defined( USE_COLOR ) || defined( USE_COLOR_ALPHA )
diffuseColor.rgb = mix( diffuseColor.rgb, sampledDiffuseColor.rgb, sampledDiffuseColor.a );
#else
diffuseColor.rgb *= sampledDiffuseColor.rgb;
#endif
#endif
`;
// ... or leave its alpha out altogether (LEGO items, terrain meshes)
const OPAQUE_MAP_FRAGMENT = `
#ifdef USE_MAP
diffuseColor.rgb *= texture2D( map, vMapUv ).rgb;
#endif
`;
function useTextureAlpha(material, mode) {
if (mode === 'opacity' || !material.map) return material;
material.onBeforeCompile = (shader) => {
if (mode === 'decal') {
shader.fragmentShader = shader.fragmentShader.replace('#include <map_fragment>', '').replace('#include <color_fragment>', DECAL_FRAGMENT);
} else {
shader.fragmentShader = shader.fragmentShader.replace('#include <map_fragment>', OPAQUE_MAP_FRAGMENT);
}
};
material.customProgramCacheKey = () => 'textureAlpha:' + mode;
return material;
}
function materialOf(mesh, map, forSky, alphaMode = 'opacity') {
// Nearly everything in the game's files has alpha blending switched on; it only shows where something is see-
// through: the material, a vertex or the texture. Blended meshes still write depth, as Gamebryo's default does.
// through: the material, a vertex or the texture (only when the object's shader uses the texture's alpha as
// opacity). Blended meshes still write depth, as Gamebryo's default does.
let vertexAlpha = false;
if (mesh.colors && mesh.vertexColors !== 0) for (let i = 3; i < mesh.colors.length && !vertexAlpha; i += 4) vertexAlpha = mesh.colors[i] < 250;
const seeThrough = mesh.blend && (mesh.alpha < 0.99 || vertexAlpha || !!(map && map.userData.alpha));
const textureAlpha = alphaMode === 'opacity' && !!(map && map.userData.alpha);
const seeThrough = mesh.blend && (mesh.alpha < 0.99 || vertexAlpha || textureAlpha);
const options = {
color: new THREE.Color().setRGB(mesh.diffuse[0], mesh.diffuse[1], mesh.diffuse[2], THREE.SRGBColorSpace),
vertexColors: !!(mesh.colors && mesh.vertexColors !== 0),
@@ -141,10 +179,10 @@ export function createScenery({ scene, camera, renderer, urls, focus, onProgress
side: mesh.doubleSided ? THREE.DoubleSide : THREE.FrontSide,
map: map || null
};
if (forSky) return new THREE.MeshBasicMaterial({ ...options, depthWrite: false, fog: false });
if (forSky) return useTextureAlpha(new THREE.MeshBasicMaterial({ ...options, depthWrite: false, fog: false }), alphaMode);
const material = new THREE.MeshStandardMaterial({ ...options, roughness: 0.85, metalness: 0 });
material.emissive.setRGB(mesh.emissive[0], mesh.emissive[1], mesh.emissive[2], THREE.SRGBColorSpace);
return material;
return useTextureAlpha(material, alphaMode);
}
async function buildParts(asset, lod, forSky) {
@@ -165,7 +203,7 @@ export function createScenery({ scene, camera, renderer, urls, focus, onProgress
}
}
const geometry = geometryOf(mesh);
parts.push({ geometry, material: materialOf(mesh, map, forSky) });
parts.push({ geometry, material: materialOf(mesh, map, forSky, textureAlphaMode(manifest, asset, mesh)) });
}
const min = model.header.min, max = model.header.max;
const radius = Math.hypot(max[0] - min[0], max[1] - min[1], max[2] - min[2]) / 2;

View File

@@ -964,6 +964,10 @@ first; the detail setting picks the model level of detail, how far objects are d
sharpness and a memory budget. Switch it off with *Scenery*. The 3D world view draws the same as its *Models* layer
(on by default, Medium detail), plus the terrain's flairs. Converted models are cached in memory (64 MB) and in
`dDashboardServer/scenery_cache` next to the server (at most 512 MB); nothing needs ImageMagick.
Whether a texture's alpha makes a model see-through follows the shader the game draws it with (the render component's
shader, or for a multishaded model the `S05__`-style tag in each part's name), not only the .nif: the LEGO shaders
lay the texture over the vertex colors and terrain meshes and LEGO items ignore its alpha, so their textures' alpha
channels (often gloss or leftover masks) don't punch holes in them.
Converting a model the caches don't have yet (a big "glom" file takes a moment) happens on a few worker threads, so
the dashboard keeps answering everything else meanwhile: the route hands the request to a worker (`Web::Defer`) and

View File

@@ -395,3 +395,47 @@ TEST(NifFileTests, ReadsTheClientsMeshes) {
EXPECT_EQ(read, files);
EXPECT_GT(withMeshes, files * 9 / 10);
}
TEST(NifFileTests, ReadsMultishaderTagsLikeTheClient) {
EXPECT_EQ(NifFile::ShaderTag("S05__TRUNKS"), 5);
EXPECT_EQ(NifFile::ShaderTag("S30__Rockwall_0"), 30);
EXPECT_EQ(NifFile::ShaderTag("rock_S14"), 14);
EXPECT_EQ(NifFile::ShaderTag("Shadow_S7_glow"), 7); // "S" not followed by a number: the "_S" tag counts
EXPECT_EQ(NifFile::ShaderTag("rock_S"), -1);
EXPECT_EQ(NifFile::ShaderTag("ROCK"), -1);
EXPECT_EQ(NifFile::ShaderTag(""), -1);
// The client draws a part with the LEGO shader when its tag names no usable shader
EXPECT_EQ(NifFile::MultishaderPart(38), 38);
EXPECT_EQ(NifFile::MultishaderPart(2), NifFile::LEGO_SHADER);
EXPECT_EQ(NifFile::MultishaderPart(9999), NifFile::LEGO_SHADER);
EXPECT_EQ(NifFile::MultishaderPart(std::nullopt), NifFile::LEGO_SHADER);
}
TEST(NifFileTests, KnowsWhichShadersUseTextureAlphaAsOpacity) {
using NifFile::eTextureAlpha;
EXPECT_EQ(NifFile::TextureAlphaFor(NifFile::LEGO_SHADER), eTextureAlpha::DECAL); // LEGOPPLighting: lerp over vertex colors
EXPECT_EQ(NifFile::TextureAlphaFor(31), eTextureAlpha::IGNORED); // LEGO-Item: alpha forced to 1
EXPECT_EQ(NifFile::TextureAlphaFor(3), eTextureAlpha::IGNORED); // Terrain Mesh Rim Light: alpha is the fade only
EXPECT_EQ(NifFile::TextureAlphaFor(7), eTextureAlpha::OPACITY); // VertColor_Alpha (AlphaAsAlpha)
EXPECT_EQ(NifFile::TextureAlphaFor(38), eTextureAlpha::OPACITY); // Basic VC
EXPECT_EQ(NifFile::TextureAlphaFor(14), eTextureAlpha::OPACITY); // LEGO Masked NonDecal: texture alpha is output
EXPECT_EQ(NifFile::TextureAlphaFor(53), eTextureAlpha::OPACITY); // LEGO-Emissive lets texture alpha through
EXPECT_EQ(NifFile::TextureAlphaFor(-1), eTextureAlpha::OPACITY); // fixed function: NiAlphaProperty as Gamebryo does
}
TEST(NifFileTests, PassesMultishaderTagsDownToMeshes) {
NifBuilder nif;
auto rootAv = Av(0, { 0, 0, 0 }, IDENTITY, 1.0f, {});
const auto name = nif.String("S30__Rockwall_0");
std::memcpy(rootAv.data.data(), &name, 4);
const auto file = OneTriangle(nif, rootAv);
std::string error;
const auto model = NifFile::Parse(file, 0, error);
ASSERT_TRUE(model) << error;
ASSERT_EQ(model->meshes.size(), 1u);
EXPECT_EQ(model->meshes[0].material.shaderTag, 30);
const auto encoded = NifFile::Encode(*model, { "" });
uint32_t length{};
std::memcpy(&length, encoded.data(), 4);
EXPECT_EQ(nlohmann::json::parse(encoded.substr(4, length))["meshes"][0]["shaderTag"], 30);
}