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 // 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 NODE_FLAGS = 0x110;
constexpr uint16_t SHAPE_FLAGS = 0x10; 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) { void WriteNet(Writer& out, int32_t name) {
out.I32(name); out.I32(name);
@@ -201,9 +207,9 @@ namespace {
return std::move(out.Data()); return std::move(out.Data());
} }
// An NiNode's data: no properties, `children`, no effects // 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; Writer node;
WriteAv(node, name, {}); WriteAv(node, name, {}, flags);
node.U32(static_cast<uint32_t>(children.size())); node.U32(static_cast<uint32_t>(children.size()));
for (const auto child : children) node.I32(child); for (const auto child : children) node.I32(child);
node.U32(0); // effects node.U32(0); // effects
@@ -389,7 +395,8 @@ namespace {
const auto shapeBlock = m_Nif.Reserve("NiTriShape"); const auto shapeBlock = m_Nif.Reserve("NiTriShape");
const auto dataBlock = m_Nif.Add("NiTriShapeData", TriShapeData(*mesh, glitter)); const auto dataBlock = m_Nif.Add("NiTriShapeData", TriShapeData(*mesh, glitter));
Writer tri; 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(dataBlock);
tri.I32(-1); // skin instance tri.I32(-1); // skin instance
tri.U32(0); // materials tri.U32(0); // materials
@@ -429,8 +436,12 @@ namespace UgcFormats {
const int32_t root = nif.Reserve("NiNode"); const int32_t root = nif.Reserve("NiNode");
SharedProperties properties(nif); SharedProperties properties(nif);
std::vector<int32_t> groupBlocks; std::vector<int32_t> groupBlocks;
bool anyAnimated = false;
for (const auto& group : groups) { for (const auto& group : groups) {
if (group.lods.empty()) continue; 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"); const auto lodNode = nif.Reserve("NiLODNode");
std::vector<int32_t> levels; std::vector<int32_t> levels;
Writer ranges; Writer ranges;
@@ -443,13 +454,13 @@ namespace UgcFormats {
const auto block = properties.Shape(group.name, piece, group.transparent, group.emissive, group.glitter); const auto block = properties.Shape(group.name, piece, group.transparent, group.emissive, group.glitter);
if (block >= 0) shapes.push_back(block); 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); levels.push_back(level);
ranges.Float(lod.nearDistance); ranges.Float(lod.nearDistance);
ranges.Float(lod.farDistance); ranges.Float(lod.farDistance);
} }
const auto rangeData = nif.Add("NiRangeLODData", std::move(ranges.Data())); 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; Writer lod;
lod.Raw(data); lod.Raw(data);
lod.U16(3); // switch flags: update only the active child, and controllers (as the game's own files) 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())); nif.Fill(lodNode, std::move(lod.Data()));
groupBlocks.push_back(lodNode); 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); 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 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 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 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 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 `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 // 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 // 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) { TEST(UgcFormats, GlitterNifReadsBack) {
const auto mesh = Quad({ 0.2f, 0.4f, 0.8f, 0.6f }); const auto mesh = Quad({ 0.2f, 0.4f, 0.8f, 0.6f });
const UgcGlitter::Params glitter{ 1.6f, 50, 2.0f }; const UgcGlitter::Params glitter{ 1.6f, 50, 2.0f };