fix(ugc): moving glitter is updated every frame

The client updates an object's scene graph each frame only when its root
NiNode has the selective update bit (0x02; LWOBaseRenderComponent::Run
0x00d5d770 via NiAVObject::GetSelectiveUpdate 0x00413050). Our roots had
0x110, so a glitter model was updated once when it loaded and its texture
controllers never moved. A model with moving glitter now has 0x102 on its
root and the glitter group's nodes and 0x1A on the glitter shapes, as the
client's own AG ocean; still glitter and everything else keep the flags
they had.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Aaron Kimbrell
2026-09-28 14:09:17 -05:00
parent d6dd573741
commit 51cd1ddfba
3 changed files with 88 additions and 7 deletions

View File

@@ -118,6 +118,12 @@ namespace {
// NiAVObject flags as the game's own brick models (res/BrickModels/ndmade) have them: nodes 0x110, shapes 0x10
constexpr uint16_t NODE_FLAGS = 0x110;
constexpr uint16_t SHAPE_FLAGS = 0x10;
// Nodes and shapes with controllers under them, as the client's own animated files (the AG ocean): the client
// updates an object's scene graph every frame only when its root has the selective update bit (0x02;
// LWOBaseRenderComponent::Run 0x00d5d770, NiAVObject::GetSelectiveUpdate 0x00413050). Without it the model is
// updated once when it loads and its controllers never move.
constexpr uint16_t ANIMATED_NODE_FLAGS = 0x102;
constexpr uint16_t ANIMATED_SHAPE_FLAGS = 0x1A; // selective update, update property controllers, rigid
void WriteNet(Writer& out, int32_t name) {
out.I32(name);
@@ -201,9 +207,9 @@ namespace {
return std::move(out.Data());
}
// An NiNode's data: no properties, `children`, no effects
std::string NodeData(int32_t name, const std::vector<int32_t>& children) {
std::string NodeData(int32_t name, const std::vector<int32_t>& children, uint16_t flags = NODE_FLAGS) {
Writer node;
WriteAv(node, name, {});
WriteAv(node, name, {}, flags);
node.U32(static_cast<uint32_t>(children.size()));
for (const auto child : children) node.I32(child);
node.U32(0); // effects
@@ -389,7 +395,8 @@ namespace {
const auto shapeBlock = m_Nif.Reserve("NiTriShape");
const auto dataBlock = m_Nif.Add("NiTriShapeData", TriShapeData(*mesh, glitter));
Writer tri;
WriteAv(tri, m_Nif.String(name), properties, SHAPE_FLAGS);
const bool animated = glitter && (glitter->PeriodU() > 0.0f || glitter->PeriodV() > 0.0f);
WriteAv(tri, m_Nif.String(name), properties, animated ? ANIMATED_SHAPE_FLAGS : SHAPE_FLAGS);
tri.I32(dataBlock);
tri.I32(-1); // skin instance
tri.U32(0); // materials
@@ -429,8 +436,12 @@ namespace UgcFormats {
const int32_t root = nif.Reserve("NiNode");
SharedProperties properties(nif);
std::vector<int32_t> groupBlocks;
bool anyAnimated = false;
for (const auto& group : groups) {
if (group.lods.empty()) continue;
const bool animated = group.glitter && (group.glitter->PeriodU() > 0.0f || group.glitter->PeriodV() > 0.0f);
anyAnimated = anyAnimated || animated;
const auto nodeFlags = animated ? ANIMATED_NODE_FLAGS : NODE_FLAGS;
const auto lodNode = nif.Reserve("NiLODNode");
std::vector<int32_t> levels;
Writer ranges;
@@ -443,13 +454,13 @@ namespace UgcFormats {
const auto block = properties.Shape(group.name, piece, group.transparent, group.emissive, group.glitter);
if (block >= 0) shapes.push_back(block);
}
nif.Fill(level, NodeData(nif.String(lod.name), shapes));
nif.Fill(level, NodeData(nif.String(lod.name), shapes, nodeFlags));
levels.push_back(level);
ranges.Float(lod.nearDistance);
ranges.Float(lod.farDistance);
}
const auto rangeData = nif.Add("NiRangeLODData", std::move(ranges.Data()));
auto data = NodeData(nif.String(group.name), levels);
auto data = NodeData(nif.String(group.name), levels, nodeFlags);
Writer lod;
lod.Raw(data);
lod.U16(3); // switch flags: update only the active child, and controllers (as the game's own files)
@@ -458,7 +469,7 @@ namespace UgcFormats {
nif.Fill(lodNode, std::move(lod.Data()));
groupBlocks.push_back(lodNode);
}
nif.Fill(root, NodeData(nif.String(rootName), groupBlocks));
nif.Fill(root, NodeData(nif.String(rootName), groupBlocks, anyAnimated ? ANIMATED_NODE_FLAGS : NODE_FLAGS));
return nif.Finish(root);
}

View File

@@ -299,7 +299,12 @@ What a glitter shape has, beside what plastic shapes have (white material, alpha
base map, operation translate U and translate V, each with an `NiFloatInterpolator` and `NiFloatData` of two linear
keys (0, 0) and (period, 1): a tile in `7 / glitter_speed` s in U and `11 / glitter_speed` s in V, looping, and
wrapping makes the loop seamless. The block layouts are the ocean file's (its controllers are 39 bytes, the property
70). With `glitter_speed` 0 the property has no controllers.
70). With `glitter_speed` 0 the property has no controllers. The client updates an object's scene graph every frame only when its root
NiNode has the selective update bit (0x02; `LWOBaseRenderComponent::Run` 0x00d5d770 calls `NiAVObject::Update` when
`NiAVObject::GetSelectiveUpdate` 0x00413050 is set); otherwise only once when it loads, and the controllers never
move. So a model with moving glitter has flags 0x102 on its root, the glitter NiLODNode and its `LOD_n` nodes, and
0x1A on the glitter shapes, as the client's own AG ocean (`mesh/env/env_ag_ocean-maelstrom.nif`); every other
node keeps 0x110 and shape 0x10.
The client finds the animation: `SetupRenderNodeExtraData` (0x00c746c0) sets `RenderNodeExtraData.flags0` bit 2 from
`NifHasAnimatedControllers` (0x00bf4160), which returns true for a shape whose `NiTexturingProperty`'s first

View File

@@ -1347,6 +1347,71 @@ namespace {
// A glitter group: UVs, the fleck texture stored in the file, and the two texture transform controllers the client
// animates it with; read back as NifFile sees it
namespace {
// Each block's type and its NiAVObject flags (the u16 after the name, extra data count and controller), from a
// NIF 20.3.0.9 as UgcFormats writes it (no extra data)
std::vector<std::pair<std::string, uint16_t>> BlockFlags(const std::string& nif) {
size_t at = nif.find('\n') + 1;
const auto u32 = [&]() { uint32_t v = 0; std::memcpy(&v, nif.data() + at, 4); at += 4; return v; };
const auto u16 = [&]() { uint16_t v = 0; std::memcpy(&v, nif.data() + at, 2); at += 2; return v; };
at += 4 + 1 + 4; // version, endian, user version
const auto blocks = u32();
const auto typeCount = u16();
std::vector<std::string> types;
for (uint16_t i = 0; i < typeCount; i++) {
const auto length = u32();
types.emplace_back(nif.substr(at, length));
at += length;
}
std::vector<uint16_t> blockTypes;
for (uint32_t i = 0; i < blocks; i++) blockTypes.push_back(u16() & 0x7fff);
std::vector<uint32_t> sizes;
for (uint32_t i = 0; i < blocks; i++) sizes.push_back(u32());
const auto strings = u32();
u32(); // max length
for (uint32_t i = 0; i < strings; i++) at += u32();
const auto groups = u32();
at += groups * 4;
std::vector<std::pair<std::string, uint16_t>> out;
for (uint32_t i = 0; i < blocks; i++) {
uint16_t flags = 0;
const auto& type = types[blockTypes[i]];
if (type == "NiNode" || type == "NiLODNode" || type == "NiTriShape") std::memcpy(&flags, nif.data() + at + 12, 2);
out.emplace_back(type, flags);
at += sizes[i];
}
return out;
}
}
// The client updates an object's scene graph every frame only when its root has the selective update bit (0x02), so
// a model with moving glitter has it on its root, the glitter group's nodes and shapes (as the client's own animated
// files); still glitter and everything else keep the game's brick model flags
TEST(UgcFormats, MovingGlitterIsUpdatedEveryFrame) {
const auto mesh = Quad({ 0.2f, 0.4f, 0.8f, 0.6f });
const UgcGlitter::Params moving{ 1.6f, 50, 1.0f };
const UgcGlitter::Params still{ 1.6f, 50, 0.0f };
for (const auto* glitter : { &moving, &still }) {
const auto nif = UgcFormats::WriteLodNif("SceneNode_Model", {
{ "S01_Opaque_Model", false, { { 0.0f, 100.0f, "LOD_0", { &mesh } } } },
{ "S21_Glitter_Model", false, { { 0.0f, 100.0f, "LOD_0", { &mesh } } }, 0.0f, glitter } });
const auto blocks = BlockFlags(nif);
const bool animated = glitter == &moving;
ASSERT_EQ(blocks[0].first, "NiNode");
EXPECT_EQ(blocks[0].second, animated ? 0x102 : 0x110) << "root";
std::vector<uint16_t> shapes;
for (const auto& [type, flags] : blocks) if (type == "NiTriShape") shapes.push_back(flags);
ASSERT_EQ(shapes.size(), 2u);
EXPECT_EQ(shapes[0], 0x10); // plastic
EXPECT_EQ(shapes[1], animated ? 0x1A : 0x10);
std::vector<uint16_t> lods;
for (const auto& [type, flags] : blocks) if (type == "NiLODNode") lods.push_back(flags);
ASSERT_EQ(lods.size(), 2u);
EXPECT_EQ(lods[0], 0x110);
EXPECT_EQ(lods[1], animated ? 0x102 : 0x110);
}
}
TEST(UgcFormats, GlitterNifReadsBack) {
const auto mesh = Quad({ 0.2f, 0.4f, 0.8f, 0.6f });
const UgcGlitter::Params glitter{ 1.6f, 50, 2.0f };