From a883bb20aef1f75933d5c6ace05cbb6f17f1c3cf Mon Sep 17 00:00:00 2001 From: Rye Date: Tue, 9 Jun 2026 22:43:06 -0400 Subject: [PATCH 01/18] Local Assets: fix anim live-reload UAFs and reload bookkeeping doUpdates() freed the globally cached JointMotionList (removeKeyframeData) BEFORE stopping the motions playing it; LLKeyframeMotion::setStopTime and constraint teardown then dereferenced the freed list on every live reload while a preview anim was playing. Stop everything first, then purge. Stopping via mPlaying alone also missed instances the map no longer tracks: replaced/stopped motions still easing out and deprecated duplicates from a double Play. New LLMotionController/LLCharacter::purgeMotionInstances() immediately excises EVERY instance of an id (canonical + deprecated); the delUnit/reload paths sweep all characters with it before freeing the cache. Also: - prune mPlaying entries whose avatar died instead of counting them as a reapply failure -- a dead entry left the mtime unconsumed, re-parsing the file and restarting the anim on every live avatar every 3s, forever - reapplyToAvatar deserializes only on cache miss: each deserialize builds a new JointMotionList and addKeyframeData() overwrites the slot without freeing, leaking one list per extra avatar per reload - wrap last_write_time in fsyspath like the sibling managers; non-ASCII Windows paths silently disabled live reload and the deferred BVH re-decode Co-Authored-By: Claude Fable 5 --- indra/llcharacter/llcharacter.cpp | 8 +++ indra/llcharacter/llcharacter.h | 5 ++ indra/llcharacter/llmotioncontroller.cpp | 21 ++++++ indra/llcharacter/llmotioncontroller.h | 9 +++ indra/newview/lllocalanim.cpp | 90 +++++++++++++++++------- 5 files changed, 107 insertions(+), 26 deletions(-) diff --git a/indra/llcharacter/llcharacter.cpp b/indra/llcharacter/llcharacter.cpp index 24dde1760c..4e8f4eca04 100644 --- a/indra/llcharacter/llcharacter.cpp +++ b/indra/llcharacter/llcharacter.cpp @@ -109,6 +109,14 @@ void LLCharacter::removeMotion( const LLUUID& id ) mMotionController.removeMotion(id); } +//----------------------------------------------------------------------------- +// purgeMotionInstances() +//----------------------------------------------------------------------------- +void LLCharacter::purgeMotionInstances( const LLUUID& id ) +{ + mMotionController.purgeMotionInstances(id); +} + //----------------------------------------------------------------------------- // findMotion() //----------------------------------------------------------------------------- diff --git a/indra/llcharacter/llcharacter.h b/indra/llcharacter/llcharacter.h index b0c721344d..7019802a32 100644 --- a/indra/llcharacter/llcharacter.h +++ b/indra/llcharacter/llcharacter.h @@ -133,6 +133,11 @@ class LLCharacter void removeMotion( const LLUUID& id ); + // removes ALL instances of a motion -- the canonical one AND any deprecated + // duplicates still easing out -- for when the motion's backing keyframe + // data is about to be destroyed (see LLMotionController::purgeMotionInstances) + void purgeMotionInstances( const LLUUID& id ); + // returns an instance of a registered motion, creating one if necessary LLMotion* createMotion( const LLUUID &id ); diff --git a/indra/llcharacter/llmotioncontroller.cpp b/indra/llcharacter/llmotioncontroller.cpp index 03ad676891..c07ff1359f 100644 --- a/indra/llcharacter/llmotioncontroller.cpp +++ b/indra/llcharacter/llmotioncontroller.cpp @@ -335,6 +335,27 @@ void LLMotionController::removeMotionInstance(LLMotion* motionp) } } +//----------------------------------------------------------------------------- +// purgeMotionInstances() +//----------------------------------------------------------------------------- +void LLMotionController::purgeMotionInstances(const LLUUID& id) +{ + removeMotion(id); + for (motion_set_t::iterator iter = mDeprecatedMotions.begin(); + iter != mDeprecatedMotions.end(); ) + { + motion_set_t::iterator cur_iter = iter++; + LLMotion* motionp = *cur_iter; + if (motionp->getID() == id) + { + // The same complete excision deactivateMotionInstance() applies to + // deprecated motions; removeMotionInstance() deactivates if needed. + removeMotionInstance(motionp); // does not touch mDeprecatedMotions + mDeprecatedMotions.erase(cur_iter); + } + } +} + //----------------------------------------------------------------------------- // createMotion() //----------------------------------------------------------------------------- diff --git a/indra/llcharacter/llmotioncontroller.h b/indra/llcharacter/llmotioncontroller.h index c2cb174821..b1862d8ff4 100644 --- a/indra/llcharacter/llmotioncontroller.h +++ b/indra/llcharacter/llmotioncontroller.h @@ -122,6 +122,15 @@ class LLMotionController // returns true if successful bool stopMotionLocally( const LLUUID &id, bool stop_immediate ); + // immediately deactivates and deletes EVERY instance of a motion id -- + // the canonical instance and any deprecated duplicates still easing out. + // For use when the motion's backing data is about to be destroyed (e.g. a + // locally previewed animation whose globally cached keyframe data is purged + // on live reload/removal): removeMotion() only reaches the canonical + // instance, and a deprecated instance left easing out keeps dereferencing + // the freed data every frame. + void purgeMotionInstances( const LLUUID& id ); + // Move motions from loading to loaded void updateLoadingMotions(); diff --git a/indra/newview/lllocalanim.cpp b/indra/newview/lllocalanim.cpp index 95f9074e36..b3aa4bfe6b 100644 --- a/indra/newview/lllocalanim.cpp +++ b/indra/newview/lllocalanim.cpp @@ -26,6 +26,7 @@ #include "lllocalanim.h" +#include "fsyspath.h" // UTF-8-safe std::filesystem paths on Windows #include "llbvhloader.h" #include "llcharacter.h" // LLCharacter::sInstances (resolve avatar by id) #include "lldatapacker.h" @@ -210,7 +211,7 @@ LLUUID LLLocalAnimMgr::loadAnim(const std::string& filename) if (!alias_deferred) { std::error_code ec; - anim.mLastModified = std::filesystem::last_write_time(filename, ec); + anim.mLastModified = std::filesystem::last_write_time(fsyspath(filename), ec); } const size_t bytes = anim.mData.size(); @@ -253,16 +254,15 @@ void LLLocalAnimMgr::delUnit(LLUUID tracking_id) return; } - // Stop it wherever it's playing, purge the cached motion, and drop the play map. + // Drop the play-map entries, then stop and delete the motion EVERYWHERE + // before freeing its cached keyframe data. The sweep covers instances + // mPlaying no longer tracks -- replaced/stopped ones still easing out and + // deprecated duplicates -- which would otherwise keep dereferencing the + // freed JointMotionList every frame. for (auto pit = mPlaying.begin(); pit != mPlaying.end(); ) { if (pit->second == tracking_id) { - if (LLVOAvatar* av = resolve_avatar(pit->first)) - { - av->stopMotion(tracking_id, true); - av->removeMotion(tracking_id); - } pit = mPlaying.erase(pit); } else @@ -270,6 +270,13 @@ void LLLocalAnimMgr::delUnit(LLUUID tracking_id) ++pit; } } + for (LLCharacter* character : LLCharacter::sInstances) + { + if (character) + { + character->purgeMotionInstances(tracking_id); + } + } LLKeyframeDataCache::removeKeyframeData(tracking_id); mAnims.erase(iter); @@ -354,24 +361,27 @@ bool LLLocalAnimMgr::reapplyToAvatar(const LLUUID& av_id, const LLUUID& anim_id) return false; } - // Purge the stale parsed motion instance so createMotion() yields a fresh one - // that deserializes the new bytes. The id is keyed per-avatar, so the old motion - // must go before a new one can take its place. - av->stopMotion(anim_id, true); - av->removeMotion(anim_id); - + // The caller (doUpdates) already purged the stale motion instances and the + // cached keyframe data, so createMotion() yields a fresh motion. The first + // avatar's deserialize() rebuilds and globally re-caches the new data; + // later avatars must NOT deserialize again -- every deserialize builds + // another JointMotionList and addKeyframeData() overwrites the cache slot + // without freeing it, leaking one list per extra avatar per reload. LLKeyframeMotion* motionp = dynamic_cast(av->createMotion(anim_id)); if (!motionp) { return false; } - LLDataPackerBinaryBuffer dp(iter->second.mData.data(), (S32)iter->second.mData.size()); - if (motionp->deserialize(dp, anim_id, false)) + if (!LLKeyframeDataCache::getKeyframeData(anim_id)) { - av->startMotion(anim_id); - return true; + LLDataPackerBinaryBuffer dp(iter->second.mData.data(), (S32)iter->second.mData.size()); + if (!motionp->deserialize(dp, anim_id, false)) + { + return false; + } } - return false; + av->startMotion(anim_id); + return true; } void LLLocalAnimMgr::doUpdates() @@ -385,7 +395,7 @@ void LLLocalAnimMgr::doUpdates() LocalAnim& anim = entry.second; std::error_code ec; - const auto mtime = std::filesystem::last_write_time(anim.mFilename, ec); + const auto mtime = std::filesystem::last_write_time(fsyspath(anim.mFilename), ec); if (ec || mtime == anim.mLastModified) { continue; @@ -398,16 +408,44 @@ void LLLocalAnimMgr::doUpdates() } anim.mData = std::move(fresh); - // Refresh the cached keyframe data so the next play uses the new bytes, - // and re-apply live to any avatar currently playing this id. - LLKeyframeDataCache::removeKeyframeData(id); - bool reapply_ok = true; - for (const auto& play : mPlaying) + // Collect the avatars to re-apply to, pruning entries whose avatar is + // gone -- counting a dead avatar as a reapply failure would leave the + // mtime unconsumed and re-parse + restart the anim on every live avatar + // every heartbeat, forever. + std::vector replaying; + for (auto pit = mPlaying.begin(); pit != mPlaying.end(); ) { - if (play.second == id) + if (pit->second == id) { - reapply_ok = reapplyToAvatar(play.first, id) && reapply_ok; + if (!resolve_avatar(pit->first)) + { + pit = mPlaying.erase(pit); + continue; + } + replaying.push_back(pit->first); } + ++pit; + } + + // Stop and delete every live instance BEFORE freeing the cached keyframe + // data: stopping dereferences the JointMotionList (setStopTime, constraint + // teardown), and instances mPlaying no longer tracks (replaced/stopped + // ones still easing out, deprecated duplicates) would keep reading the + // freed data every frame. + for (LLCharacter* character : LLCharacter::sInstances) + { + if (character) + { + character->purgeMotionInstances(id); + } + } + LLKeyframeDataCache::removeKeyframeData(id); + + // Re-apply live to any avatar currently playing this id. + bool reapply_ok = true; + for (const LLUUID& av_id : replaying) + { + reapply_ok = reapplyToAvatar(av_id, id) && reapply_ok; } // Consume the mtime only once the swap is fully live AND the decode used joint From 14969daa74ea005820c7c8d862423c3c7d01e595 Mon Sep 17 00:00:00 2001 From: Rye Date: Tue, 9 Jun 2026 22:43:20 -0400 Subject: [PATCH 02/18] Local Assets: fix texture picker multi-select Remove use-after-free onBtnRemove snapshotted raw LLScrollListItem pointers, but delUnit() now fires the units-changed signal synchronously: the picker's own onLocalAssetsChanged() -> refreshLocalList() -> clearRows() frees every row item mid-loop, and the next iteration read freed memory. Snapshot the (tracking id, asset type) values instead and mutate the managers after. Co-Authored-By: Claude Fable 5 --- indra/newview/lltexturectrl.cpp | 47 ++++++++++++++++++--------------- 1 file changed, 25 insertions(+), 22 deletions(-) diff --git a/indra/newview/lltexturectrl.cpp b/indra/newview/lltexturectrl.cpp index ca96c56054..2a412cb2e0 100644 --- a/indra/newview/lltexturectrl.cpp +++ b/indra/newview/lltexturectrl.cpp @@ -1200,33 +1200,36 @@ void LLFloaterTexturePicker::onBtnAdd(void* userdata) void LLFloaterTexturePicker::onBtnRemove(void* userdata) { LLFloaterTexturePicker* self = (LLFloaterTexturePicker*) userdata; - std::vector selected_items = self->mLocalScrollCtrl->getAllSelected(); - if (!selected_items.empty()) + // Snapshot the selected units' values BEFORE mutating the managers: delUnit() + // fires the units-changed signal synchronously, which re-enters this picker + // via onLocalAssetsChanged() -> refreshLocalList() -> clearRows() and frees + // every LLScrollListItem a pointer snapshot would still be iterating. + std::vector> to_remove; + for (LLScrollListItem* list_item : self->mLocalScrollCtrl->getAllSelected()) { - - for(std::vector::iterator iter = selected_items.begin(); - iter != selected_items.end(); iter++) + if (list_item) { - LLScrollListItem* list_item = *iter; - if (list_item) + const LLSD data = list_item->getValue(); + if (data["mesh_owned"].asBoolean()) { - LLSD data = list_item->getValue(); - if (data["mesh_owned"].asBoolean()) - { - continue; // model-loaded: read-only, owned by its mesh - } - LLUUID tracking_id = data["id"]; - S32 asset_type = data["type"].asInteger(); + continue; // model-loaded: read-only, owned by its mesh + } + to_remove.emplace_back(data["id"].asUUID(), data["type"].asInteger()); + } + } - if (LLAssetType::AT_MATERIAL == asset_type) - { - LLLocalGLTFMaterialMgr::getInstance()->delUnit(tracking_id); - } - else - { - LLLocalBitmapMgr::getInstance()->delUnit(tracking_id); - } + if (!to_remove.empty()) + { + for (const auto& [tracking_id, asset_type] : to_remove) + { + if (LLAssetType::AT_MATERIAL == asset_type) + { + LLLocalGLTFMaterialMgr::getInstance()->delUnit(tracking_id); + } + else + { + LLLocalBitmapMgr::getInstance()->delUnit(tracking_id); } } From f28cd141cb2a270296579db373db966fb725e6d1 Mon Sep 17 00:00:00 2001 From: Rye Date: Tue, 9 Jun 2026 22:44:14 -0400 Subject: [PATCH 03/18] Local Assets: hold the preview avatar across threaded model loads LoadContext held a raw LLVOAvatar*; the DAE loader resolves (and writes joint-position overrides onto) joints through it from the model loader's worker thread. A teleport away from the spawn region or a logout mid-parse (despawnObjectsInRegion/cleanup) markDead()s the preview avatar and drops the manager's reference, freeing it under the loader. Hold it by LLPointer: joints on a dead-but-referenced avatar stay valid, and the ref releases on the main thread when the load callback frees the context. Co-Authored-By: Claude Fable 5 --- indra/newview/lllocalmesh.cpp | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/indra/newview/lllocalmesh.cpp b/indra/newview/lllocalmesh.cpp index 5a815f6d3b..19d4b75229 100644 --- a/indra/newview/lllocalmesh.cpp +++ b/indra/newview/lllocalmesh.cpp @@ -76,15 +76,19 @@ namespace // owns the by-reference joint maps the loader holds for the duration of the // parse, so they outlive the (possibly deleted) LLLocalMesh. The load // callback frees it. The unit is looked up by tracking ID, so a unit - // deleted mid-load can't be dereferenced. + // deleted mid-load can't be dereferenced. The preview avatar is held by + // LLPointer: the loader thread resolves (and the DAE loader writes to) + // joints on it mid-parse, while a teleport/logout can markDead() and drop + // the manager's reference at any time -- the ref keeps the avatar's memory + // (and its joints) valid until the context is freed on the main thread. struct LoadContext { - LLUUID mTrackingID; - LLModelLoader* mLoader = nullptr; - JointTransformMap mJointTransformMap; - JointNameSet mJointsFromNode; - U32 mLoadState = LLModelLoader::STARTING; - LLVOAvatar* mAvatar = nullptr; // preview skeleton for joint lookup (never the agent) + LLUUID mTrackingID; + LLModelLoader* mLoader = nullptr; + JointTransformMap mJointTransformMap; + JointNameSet mJointsFromNode; + U32 mLoadState = LLModelLoader::STARTING; + LLPointer mAvatar; // preview skeleton for joint lookup (never the agent) }; // Build the joint alias map the loaders use to recognise rig joints, From 2d03f64081285871dfa3a9b3dae730747f8e3828 Mon Sep 17 00:00:00 2001 From: Rye Date: Tue, 9 Jun 2026 22:44:59 -0400 Subject: [PATCH 04/18] Local Assets: make Remove stick; honor saved joint flag on lazy Rez/Attach Remove never stuck: removePath() ran first, then delUnit() fired manager signals while the dying unit was still listed (mesh: despawnUnit's signal + owned-import releases fire before the list erase; materials: a multi- material file's sibling units survive each per-unit delUnit) -- and the add-only LLLocalAssetPaths::onUnitsChanged() re-recorded the path from getFilenames(), resurrecting the row every session. - LLLocalMeshMgr::delUnit() now delists the unit before any teardown that fires signals, so listeners never see a half-deleted unit - the floater Remove paths delete the units first and forget the path last Rez/Attach on an undecoded (dimmed) row decoded via addUnit's default include_joints=false, bypassing the saved flag loadPath applies -- and the next onUnitsChanged then erased the saved flag to match. addAndSpawn/ addAndAttach take include_joints now; the floater passes the persisted one. Co-Authored-By: Claude Fable 5 --- indra/newview/llfloaterlocalassets.cpp | 39 ++++++++---- indra/newview/lllocalmesh.cpp | 88 ++++++++++++++------------ indra/newview/lllocalmesh.h | 6 +- 3 files changed, 76 insertions(+), 57 deletions(-) diff --git a/indra/newview/llfloaterlocalassets.cpp b/indra/newview/llfloaterlocalassets.cpp index 0d28ca6a1b..3b0e5e9dd4 100644 --- a/indra/newview/llfloaterlocalassets.cpp +++ b/indra/newview/llfloaterlocalassets.cpp @@ -402,16 +402,16 @@ void LLPanelLocalAssetBase::onRemoveBtn() for (const auto& entry : selected) { - if (!entry.first.empty()) - { - LLLocalAssetPaths::getInstance()->removePath(assetType(), entry.first); // forget the path - } - // Unload every decoded unit backing this row. For a multi-material glTF file - // that's all of the file's material units, not just the selected row. + // Unload every decoded unit backing this row FIRST and forget the path + // only once they are all gone. delUnit() fires the manager signals + // synchronously, and the add-only LLLocalAssetPaths::onUnitsChanged() + // re-records any path a still-loaded unit reports -- removePath() up + // front would be undone by the very first delUnit (mesh teardown, or a + // multi-material glTF file's surviving sibling units). std::vector ids; if (!entry.first.empty()) { - unitsForPath(entry.first, ids); + unitsForPath(entry.first, ids); // all of the file's units, not just the row } if (ids.empty() && entry.second.notNull()) { @@ -421,6 +421,10 @@ void LLPanelLocalAssetBase::onRemoveBtn() { delUnit(id); // unload the decoded unit (fires the manager signal) } + if (!entry.first.empty()) + { + LLLocalAssetPaths::getInstance()->removePath(assetType(), entry.first); // forget the path + } } refresh(); // removePath() alone (undecoded rows) doesn't fire a manager signal } @@ -723,11 +727,15 @@ void LLPanelLocalMesh::onRez() doSpawn(id); // always rez a new copy return; } - // Undecoded: load it and rez once it finishes (addAndSpawn handles the async load). + // Undecoded: load it and rez once it finishes (addAndSpawn handles the async + // load). Decode with the joint-position flag the artist saved for this file, + // like loadPath -- defaulting it would also make onUnitsChanged erase the + // saved flag. const std::string path = getSelectedPath(); if (!path.empty()) { - LLLocalMeshMgr::getInstance()->addAndSpawn(std::vector(1, path)); + LLLocalMeshMgr::getInstance()->addAndSpawn(std::vector(1, path), + LLLocalAssetPaths::getInstance()->getMeshJoints(path)); } } @@ -740,11 +748,12 @@ void LLPanelLocalMesh::onAttach() return; } // Undecoded row: load it and attach once it finishes loading (mirrors how Rez - // handles an undecoded row via addAndSpawn). + // handles an undecoded row via addAndSpawn), honoring the saved joint flag. const std::string path = getSelectedPath(); if (!path.empty()) { - LLLocalMeshMgr::getInstance()->addAndAttach(path, getComboAttachPoint()); + LLLocalMeshMgr::getInstance()->addAndAttach(path, getComboAttachPoint(), + LLLocalAssetPaths::getInstance()->getMeshJoints(path)); } } @@ -837,9 +846,13 @@ void LLPanelLocalMesh::doRemove(const LLUUID& tracking_id) { if (tracking_id.notNull()) { - LLLocalAssetPaths::getInstance()->removePath(LLLocalAssetPaths::TYPE_MESH, - pathForUnit(tracking_id)); + // Resolve the path before the unit dies, but forget it only AFTER + // delUnit: the units-changed listeners fire during teardown and the + // add-only LLLocalAssetPaths::onUnitsChanged would re-record a path + // removed up front (see onRemoveBtn). + const std::string path = pathForUnit(tracking_id); delUnit(tracking_id); // units-changed signal -> refresh() + LLLocalAssetPaths::getInstance()->removePath(LLLocalAssetPaths::TYPE_MESH, path); } } diff --git a/indra/newview/lllocalmesh.cpp b/indra/newview/lllocalmesh.cpp index 19d4b75229..901a903561 100644 --- a/indra/newview/lllocalmesh.cpp +++ b/indra/newview/lllocalmesh.cpp @@ -937,57 +937,61 @@ LLUUID LLLocalMeshMgr::addUnitInternal(const std::string& filename, bool include void LLLocalMeshMgr::delUnit(LLUUID tracking_id) { - for (local_list_iter iter = mMeshList.begin(); iter != mMeshList.end(); ) + // Pull the unit out of the list BEFORE any teardown that fires signals + // (despawnUnit fires mUnitsChangedSignal; releasing owned imports fires the + // bitmap/material managers' signals). Listeners run synchronously and must + // never see the dying unit still listed -- the add-only + // LLLocalAssetPaths::onUnitsChanged, for one, would re-record its path from + // getFilenames() and undo a Remove. + LLLocalMesh* unit = nullptr; + for (local_list_iter iter = mMeshList.begin(); iter != mMeshList.end(); ++iter) + { + if ((*iter)->getTrackingID() == tracking_id) + { + unit = *iter; + mMeshList.erase(iter); + break; + } + } + if (unit) { - LLLocalMesh* unit = *iter; - if (unit->getTrackingID() == tracking_id) + despawnUnit(tracking_id); + // Release the mesh-owned local bitmaps + materials imported for this + // unit -- but only the ones no OTHER loaded mesh still references. + // Imports are deduplicated by file, so two meshes using the same + // texture/material share a tracking id; releasing it unconditionally + // would strip it from the other mesh too. (The dying unit is already + // delisted, so mMeshList holds only the others.) + auto shared_with_other = [&](const LLUUID& import_id) -> bool { - despawnUnit(tracking_id); - // Release the mesh-owned local bitmaps + materials imported for this - // unit -- but only the ones no OTHER loaded mesh still references. - // Imports are deduplicated by file, so two meshes using the same - // texture/material share a tracking id; releasing it unconditionally - // would strip it from the other mesh too. - auto shared_with_other = [&](const LLUUID& import_id) -> bool + for (const LLLocalMesh* other : mMeshList) { - for (const LLLocalMesh* other : mMeshList) + for (const LLUUID& bid : other->mOwnedBitmaps) { - if (other == unit) - { - continue; - } - for (const LLUUID& bid : other->mOwnedBitmaps) - { - if (bid == import_id) return true; - } - for (const LLUUID& mid : other->mOwnedMaterials) - { - if (mid == import_id) return true; - } + if (bid == import_id) return true; } - return false; - }; - for (const LLUUID& bid : unit->mOwnedBitmaps) - { - if (!shared_with_other(bid)) + for (const LLUUID& mid : other->mOwnedMaterials) { - LLLocalBitmapMgr::getInstance()->delUnit(bid); + if (mid == import_id) return true; } } - for (const LLUUID& mid : unit->mOwnedMaterials) + return false; + }; + for (const LLUUID& bid : unit->mOwnedBitmaps) + { + if (!shared_with_other(bid)) { - if (!shared_with_other(mid)) - { - LLLocalGLTFMaterialMgr::getInstance()->delUnit(mid); - } + LLLocalBitmapMgr::getInstance()->delUnit(bid); } - iter = mMeshList.erase(iter); - delete unit; } - else + for (const LLUUID& mid : unit->mOwnedMaterials) { - ++iter; + if (!shared_with_other(mid)) + { + LLLocalGLTFMaterialMgr::getInstance()->delUnit(mid); + } } + delete unit; } if (mMeshList.empty()) @@ -2020,7 +2024,7 @@ void LLLocalMeshMgr::doUpdates() } } -void LLLocalMeshMgr::addAndSpawn(const std::vector& filenames) +void LLLocalMeshMgr::addAndSpawn(const std::vector& filenames, bool include_joints) { for (const std::string& filename : filenames) { @@ -2028,7 +2032,7 @@ void LLLocalMeshMgr::addAndSpawn(const std::vector& filenames) { continue; } - const LLUUID tracking_id = addUnit(filename); + const LLUUID tracking_id = addUnit(filename, include_joints); if (tracking_id.notNull()) { if (LLLocalMesh* unit = getUnit(tracking_id)) @@ -2039,13 +2043,13 @@ void LLLocalMeshMgr::addAndSpawn(const std::vector& filenames) } } -void LLLocalMeshMgr::addAndAttach(const std::string& filename, S32 attach_point) +void LLLocalMeshMgr::addAndAttach(const std::string& filename, S32 attach_point, bool include_joints) { if (filename.empty()) { return; } - const LLUUID tracking_id = addUnit(filename); // dedups: existing unit if already loaded + const LLUUID tracking_id = addUnit(filename, include_joints); // dedups: existing unit if already loaded LLLocalMesh* unit = tracking_id.notNull() ? getUnit(tracking_id) : nullptr; if (!unit) { diff --git a/indra/newview/lllocalmesh.h b/indra/newview/lllocalmesh.h index 43a3fbff02..2d2b35b357 100644 --- a/indra/newview/lllocalmesh.h +++ b/indra/newview/lllocalmesh.h @@ -266,10 +266,12 @@ class LLLocalMeshMgr : public LLSingleton // its own instance id. Returns the new copy's linkset root. LLViewerObject* spawnInWorld(const LLUUID& tracking_id); // Convenience: load each file and spawn it in-world once it finishes loading. - void addAndSpawn(const std::vector& filenames); + // include_joints applies to any file that needs a fresh decode (pass the + // persisted joint-position flag for saved rows, like loadPath does). + void addAndSpawn(const std::vector& filenames, bool include_joints = false); // Load a file (if needed) and, once decoded, spawn it and wear it at the given // attachment point -- so Attach works on a not-yet-decoded unit. - void addAndAttach(const std::string& filename, S32 attach_point); + void addAndAttach(const std::string& filename, S32 attach_point, bool include_joints = false); // The root of (any) one rezzed copy of a unit, or null if it has none -- handy for // "is this unit in-world?" checks. Use getSpawnedInstances() to act per copy. From 571f2799634a98d8d82b3a8fe97b38c909968d90 Mon Sep 17 00:00:00 2001 From: Rye Date: Tue, 9 Jun 2026 23:36:35 -0400 Subject: [PATCH 05/18] Local Assets: close remaining local-preview server-traffic gaps - sendSelectionMove() (the joystick/spacenav build-move path) was the one transform sender without the all-local gate; it shipped MultipleObjectUpdate blocks with localid 0 for previews - the grab tool sent ObjectGrab/ObjectGrabUpdate/ObjectDeGrab and the ObjectSpin* stream for previews (they spawn with FLAGS_OBJECT_MOVE, so permMove() let the grab activate); gate every send on isLocalOnly - canSelectObject()'s local/real homogeneity check sat below the mForceSelection early-return, so temp/right-click (forced) selects could build a mixed local+real selection -- every whole-selection local gate then fails open (localid-0 blocks go out, and selectDelete stops routing previews to the local delete path). Check homogeneity ahead of force-selection. Co-Authored-By: Claude Fable 5 --- indra/newview/llselectmgr.cpp | 35 ++++++++++++++++++++++------------- indra/newview/lltoolgrab.cpp | 18 +++++++++++------- 2 files changed, 33 insertions(+), 20 deletions(-) diff --git a/indra/newview/llselectmgr.cpp b/indra/newview/llselectmgr.cpp index 402b6c9202..088b1ae9ba 100644 --- a/indra/newview/llselectmgr.cpp +++ b/indra/newview/llselectmgr.cpp @@ -8039,6 +8039,21 @@ bool LLSelectMgr::canSelectObject(LLViewerObject* object, bool ignore_select_own return false; } + // Don't allow mixing client-only local mesh previews with real (sim) objects + // in a single selection -- a mixed selection escapes the all-local gating and + // would send the previews' (fake) local IDs to the sim. Checked ahead of + // mForceSelection so temp/right-click (forced) selects can't sneak a mix in; + // the selection stays homogeneous, so the first selected object is + // representative. + if (mSelectedObjects->getObjectCount() > 0) + { + LLViewerObject* selected = mSelectedObjects->getFirstObject(); + if (selected && selected->isLocalOnly() != object->isLocalOnly()) + { + return false; + } + } + if (mForceSelection) { return true; @@ -8067,19 +8082,6 @@ bool LLSelectMgr::canSelectObject(LLViewerObject* object, bool ignore_select_own ESelectType selection_type = getSelectTypeForObject(object); if (mSelectedObjects->getObjectCount() > 0 && mSelectedObjects->mSelectType != selection_type) return false; - // Don't allow mixing client-only local mesh previews with real (sim) objects - // in a single selection -- a mixed selection escapes the all-local gating and - // would send the previews' (fake) local IDs to the sim. The selection is kept - // homogeneous by this check, so the first selected object is representative. - if (mSelectedObjects->getObjectCount() > 0) - { - LLViewerObject* selected = mSelectedObjects->getFirstObject(); - if (selected && selected->isLocalOnly() != object->isLocalOnly()) - { - return false; - } - } - return true; } @@ -9039,6 +9041,13 @@ void LLSelectMgr::sendSelectionMove() return; } + // Client-only local previews are moved purely locally (this is the + // joystick/spacenav build-move path); there is no sim object to update. + if (selectionAllLocalPreview(mSelectedObjects)) + { + return; + } + //saveSelectedObjectTransform(SELECT_ACTION_TYPE_PICK); U32 update_type = UPD_POSITION | UPD_ROTATION; diff --git a/indra/newview/lltoolgrab.cpp b/indra/newview/lltoolgrab.cpp index a23cf70795..5f80d67f85 100644 --- a/indra/newview/lltoolgrab.cpp +++ b/indra/newview/lltoolgrab.cpp @@ -339,8 +339,9 @@ bool LLToolGrabBase::handleObjectHit(const LLPickInfo& info) void LLToolGrabBase::startSpin() { LLViewerObject* objectp = mGrabPick.getObject(); - if (!objectp) + if (!objectp || objectp->isLocalOnly()) { + // Client-only local previews have no sim object to spin. return; } mSpinGrabbing = true; @@ -365,7 +366,7 @@ void LLToolGrabBase::stopSpin() mSpinGrabbing = false; LLViewerObject* objectp = mGrabPick.getObject(); - if (!objectp) + if (!objectp || objectp->isLocalOnly()) { return; } @@ -715,8 +716,10 @@ void LLToolGrabBase::handleHoverActive(S32 x, S32 y, MASK mask) } } - // Don't move above top of screen or below bottom - if ((grab_center_gl.mY < gViewerWindow->getWorldViewHeightScaled() - 6) + // Don't move above top of screen or below bottom; client-only local + // previews have no sim object to update. + if (!objectp->isLocalOnly() + && (grab_center_gl.mY < gViewerWindow->getWorldViewHeightScaled() - 6) && (grab_center_gl.mY > 24)) { // Transmit update to simulator @@ -889,7 +892,7 @@ void LLToolGrabBase::handleHoverNonPhysical(S32 x, S32 y, MASK mask) changed_since_last_update = true; } - if (changed_since_last_update) + if (changed_since_last_update && !objectp->isLocalOnly()) { LLMessageSystem *msg = gMessageSystem; msg->newMessageFast(_PREHASH_ObjectGrabUpdate); @@ -1173,7 +1176,8 @@ LLVector3d LLToolGrabBase::getGrabPointGlobal() void send_ObjectGrab_message(LLViewerObject* object, const LLPickInfo & pick, const LLVector3 &grab_offset) { - if (!object) return; + // Client-only local previews have no sim object; localid 0 means nothing there. + if (!object || object->isLocalOnly()) return; LLMessageSystem *msg = gMessageSystem; @@ -1210,7 +1214,7 @@ void send_ObjectGrab_message(LLViewerObject* object, const LLPickInfo & pick, co void send_ObjectDeGrab_message(LLViewerObject* object, const LLPickInfo & pick) { - if (!object) return; + if (!object || object->isLocalOnly()) return; LLMessageSystem *msg = gMessageSystem; From e24e7139ab55141eabfe42e1376d81525faa7bfc Mon Sep 17 00:00:00 2001 From: Rye Date: Tue, 9 Jun 2026 22:46:59 -0400 Subject: [PATCH 06/18] Local Assets: don't dedup-kill a worn preview on null-item-id attach The same-inventory-item dedup in LLViewerJointAttachment::addObject was guarded only for the INCOMING object being a local preview. A real attachment with a null item id (a temporary attachment carries no AttachItemID) landing on the same point still matched the worn preview's null item id, markDead()'d the preview, and -- since previews carry FLAGS_OBJECT_YOU_OWNER -- sent an ObjectDetach with localid 0 to the sim. Skip the dedup when the matched object is local-only too. Co-Authored-By: Claude Fable 5 --- indra/newview/llviewerjointattachment.cpp | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/indra/newview/llviewerjointattachment.cpp b/indra/newview/llviewerjointattachment.cpp index 4a625a4e71..4d3b6d614c 100644 --- a/indra/newview/llviewerjointattachment.cpp +++ b/indra/newview/llviewerjointattachment.cpp @@ -184,11 +184,13 @@ bool LLViewerJointAttachment::addObject(LLViewerObject* object) // Two instances of the same inventory item attached -- // Request detach, and kill the object in the meantime. - // (Skip for client-only objects: a local mesh preview has no inventory item, - // so its id is null and the dedup would markDead an unrelated null-id local - // attachment and send it an ObjectDetach the sim can't act on.) + // (Skip when EITHER side is a client-only object: a local mesh preview has + // no inventory item, so its null id would cross-match a real null-item-id + // attachment -- e.g. a temporary attachment -- in both directions, killing + // an unrelated object and sending an ObjectDetach the sim can't act on.) // [SL:KB] - Patch: Appearance-PhantomAttach | Checked: Catznip-5.0 - if (LLViewerObject* pAttachObj = (!object->isLocalOnly()) ? getAttachedObject(object->getAttachmentItemID()) : nullptr) + LLViewerObject* pAttachObj = (!object->isLocalOnly()) ? getAttachedObject(object->getAttachmentItemID()) : nullptr; + if (pAttachObj && !pAttachObj->isLocalOnly()) { LL_INFOS() << "(same object re-attached)" << LL_ENDL; pAttachObj->markDead(); From 32e33fc2ceb2d407b9f1f0261aab7db985df909a Mon Sep 17 00:00:00 2001 From: Rye Date: Tue, 9 Jun 2026 23:44:36 -0400 Subject: [PATCH 07/18] Local Assets: keep user and mesh-owned units apart in file lookups A user-loaded texture/material and a mesh-owned import of the same file are deliberately distinct units, but getUnitID/getTrackingIDs/getWorldIDsByName matched by filename alone. Depending on load order, the Textures/Materials tabs could claim a mesh's hidden import as their row's unit and delete it (stripping the material a loaded local mesh still renders with, while the user's own unit survives and re-records the just-removed path), and a mesh could bind its faces to the user's deletable copies instead of its own imports. The lookups now take the ownership class explicitly: - the floater tabs and picker resolve user units only (also un-hides the dimmed saved row when only a mesh-owned twin of the file is loaded) - mesh texture import reuses its own prior import first, then a user copy - mesh face binding maps mesh-owned materials, with the user set as a fallback only if the mesh-owned import failed to load Also stop clobbering LLLocalGLTFMaterial::mMaterialName on a failed re-decode (file locked mid-save): the blanked name made getWorldIDsByName fall back to "mat" and bind faces wrongly during the retry window. Co-Authored-By: Claude Fable 5 --- indra/newview/llfloaterlocalassets.cpp | 15 +++++++---- indra/newview/lllocalbitmaps.cpp | 4 +-- indra/newview/lllocalbitmaps.h | 6 ++++- indra/newview/lllocalgltfmaterials.cpp | 25 +++++++++++------- indra/newview/lllocalgltfmaterials.h | 16 +++++++----- indra/newview/lllocalmesh.cpp | 36 ++++++++++++++------------ indra/newview/llviewerwindow.cpp | 4 +-- 7 files changed, 64 insertions(+), 42 deletions(-) diff --git a/indra/newview/llfloaterlocalassets.cpp b/indra/newview/llfloaterlocalassets.cpp index 3b0e5e9dd4..a925ff85ba 100644 --- a/indra/newview/llfloaterlocalassets.cpp +++ b/indra/newview/llfloaterlocalassets.cpp @@ -1230,7 +1230,9 @@ class LLPanelLocalTexture final : public LLPanelLocalApplyAsset } LLUUID unitForPath(const std::string& path) const override { - return LLLocalBitmapMgr::getInstance()->getUnitID(path); + // User units only: a mesh-owned import of the same file is a distinct, + // read-only unit this tab must neither claim as loaded nor delete. + return LLLocalBitmapMgr::getInstance()->getUnitID(path, /*mesh_owned=*/false); } std::string pathForUnit(const LLUUID& tracking_id) const override { @@ -1284,8 +1286,9 @@ class LLPanelLocalMaterial final : public LLPanelLocalApplyAsset } LLUUID unitForPath(const std::string& path) const override { - // A file holds >= 1 material; treat it as loaded if its first material is. - return LLLocalGLTFMaterialMgr::getInstance()->getUnitID(path, 0); + // A file holds >= 1 material; treat it as loaded if its first USER material + // is (a mesh-owned import of the same file is a distinct, read-only set). + return LLLocalGLTFMaterialMgr::getInstance()->getUnitID(path, 0, /*mesh_owned=*/false); } std::string pathForUnit(const LLUUID& tracking_id) const override { @@ -1296,8 +1299,10 @@ class LLPanelLocalMaterial final : public LLPanelLocalApplyAsset } void unitsForPath(const std::string& path, std::vector& out) const override { - // One .gltf/.glb can decode to several material units; remove them all. - LLLocalGLTFMaterialMgr::getInstance()->getTrackingIDs(path, out); + // One .gltf/.glb can decode to several material units; act on all of the + // USER's. Mesh-owned imports of the same file belong to a loaded mesh and + // must not be deleted from this tab. + LLLocalGLTFMaterialMgr::getInstance()->getTrackingIDs(path, out, /*mesh_owned=*/false); } std::string iconName() const override { diff --git a/indra/newview/lllocalbitmaps.cpp b/indra/newview/lllocalbitmaps.cpp index 94215b0a53..ae5bb7b237 100644 --- a/indra/newview/lllocalbitmaps.cpp +++ b/indra/newview/lllocalbitmaps.cpp @@ -1163,12 +1163,12 @@ LLUUID LLLocalBitmapMgr::addUnitInternal(const std::string& filename, bool mesh_ return LLUUID::null; } -LLUUID LLLocalBitmapMgr::getUnitID(const std::string& filename) +LLUUID LLLocalBitmapMgr::getUnitID(const std::string& filename, bool mesh_owned) { for (local_list_iter itBitmap = mBitmapList.begin(); mBitmapList.end() != itBitmap; ++itBitmap) { LLLocalBitmap* unit = *itBitmap; - if (filename == unit->getFilename()) + if (filename == unit->getFilename() && unit->isMeshOwned() == mesh_owned) { return unit->getTrackingID(); } diff --git a/indra/newview/lllocalbitmaps.h b/indra/newview/lllocalbitmaps.h index f92bba78c7..9ac0e86413 100644 --- a/indra/newview/lllocalbitmaps.h +++ b/indra/newview/lllocalbitmaps.h @@ -146,7 +146,11 @@ class LLLocalBitmapMgr : public LLSingleton LLUUID addUnit(const std::string& filename); // Add a unit imported by a local mesh: hidden from the Textures tab + persistence. LLUUID addUnit(const std::string& filename, bool mesh_owned); - LLUUID getUnitID(const std::string& filename); + // Resolve a file to its unit of ONE ownership class. A user texture and a + // mesh-owned import of the same file are distinct units (see addUnitInternal); + // an ownership-blind first-match could hand the Textures tab a mesh's hidden + // import to delete (or vice versa), so callers must say which side they want. + LLUUID getUnitID(const std::string& filename, bool mesh_owned); void delUnit(LLUUID tracking_id); bool checkTextureDimensions(std::string filename); diff --git a/indra/newview/lllocalgltfmaterials.cpp b/indra/newview/lllocalgltfmaterials.cpp index 5d378f931c..001d1b6edd 100644 --- a/indra/newview/lllocalgltfmaterials.cpp +++ b/indra/newview/lllocalgltfmaterials.cpp @@ -243,10 +243,16 @@ bool LLLocalGLTFMaterial::loadMaterial() material_name); } - mMaterialName = material_name; // for matching a mesh face's binding name - if (!material_name.empty()) + if (decode_successful) { - mShortName = gDirUtilp->getBaseFileName(filename_lc, true) + " (" + material_name + ")"; + // Only commit the binding name on success: a failed re-decode (e.g. + // the file locked mid-save, retried later) must not blank the name a + // mesh re-bind (getWorldIDsByName) matches faces against. + mMaterialName = material_name; // for matching a mesh face's binding name + if (!material_name.empty()) + { + mShortName = gDirUtilp->getBaseFileName(filename_lc, true) + " (" + material_name + ")"; + } } break; @@ -532,14 +538,15 @@ std::vector LLLocalGLTFMaterialMgr::getFilenames() const return out; } -LLUUID LLLocalGLTFMaterialMgr::getUnitID(const std::string& filename, S32 index) +LLUUID LLLocalGLTFMaterialMgr::getUnitID(const std::string& filename, S32 index, bool mesh_owned) { if (!mMaterialList.empty()) { for (local_list_iter itBitmap = mMaterialList.begin(); mMaterialList.end() != itBitmap; ++itBitmap) { LLLocalGLTFMaterial* unit = *itBitmap; - if (filename == unit->getFilename() && index == unit->getIndexInFile()) + if (filename == unit->getFilename() && index == unit->getIndexInFile() + && unit->isMeshOwned() == mesh_owned) { return unit->getTrackingID(); } @@ -548,11 +555,11 @@ LLUUID LLLocalGLTFMaterialMgr::getUnitID(const std::string& filename, S32 index) return LLUUID::null; } -void LLLocalGLTFMaterialMgr::getTrackingIDs(const std::string& filename, std::vector& out) +void LLLocalGLTFMaterialMgr::getTrackingIDs(const std::string& filename, std::vector& out, bool mesh_owned) { for (const LLPointer& unit : mMaterialList) { - if (unit->getFilename() == filename) + if (unit->getFilename() == filename && unit->isMeshOwned() == mesh_owned) { out.push_back(unit->getTrackingID()); } @@ -616,12 +623,12 @@ void LLLocalGLTFMaterialMgr::getFilenameAndIndex(LLUUID tracking_id, std::string } } -void LLLocalGLTFMaterialMgr::getWorldIDsByName(const std::string& filename, std::map& out) +void LLLocalGLTFMaterialMgr::getWorldIDsByName(const std::string& filename, std::map& out, bool mesh_owned) { for (local_list_iter iter = mMaterialList.begin(); iter != mMaterialList.end(); iter++) { LLLocalGLTFMaterial* unit = *iter; - if (unit->getFilename() != filename) + if (unit->getFilename() != filename || unit->isMeshOwned() != mesh_owned) { continue; } diff --git a/indra/newview/lllocalgltfmaterials.h b/indra/newview/lllocalgltfmaterials.h index 7ec1309e64..9570bc0352 100644 --- a/indra/newview/lllocalgltfmaterials.h +++ b/indra/newview/lllocalgltfmaterials.h @@ -124,11 +124,15 @@ class LLLocalGLTFMaterialMgr : public LLSingleton S32 addUnitInternal(const std::string& filename, LLUUID& outID, bool mesh_owned = false); // file can hold multiple materials public: void delUnit(LLUUID tracking_id); - LLUUID getUnitID(const std::string& filename, S32 index = 0); - // Tracking ids of every unit loaded from a file, in list order. Unlike walking - // getUnitID(file, 0..N), this is safe across the index gaps that import-time - // deduplication leaves in getIndexInFile(). - void getTrackingIDs(const std::string& filename, std::vector& out); + // The lookups below resolve within ONE ownership class: a user-loaded set and a + // mesh-owned import of the same file are distinct units (see addUnitInternal), + // and an ownership-blind match could hand the Materials tab a mesh's hidden + // imports to delete (or bind a mesh's faces to the user's deletable copies). + LLUUID getUnitID(const std::string& filename, S32 index, bool mesh_owned); + // Tracking ids of every unit of the class loaded from a file, in list order. + // Unlike walking getUnitID(file, 0..N), this is safe across the index gaps + // that import-time deduplication leaves in getIndexInFile(). + void getTrackingIDs(const std::string& filename, std::vector& out, bool mesh_owned); LLUUID getWorldID(LLUUID tracking_id); bool isMeshOwned(const LLUUID& tracking_id) const; // imported by a local mesh @@ -136,7 +140,7 @@ class LLLocalGLTFMaterialMgr : public LLSingleton void getFilenameAndIndex(LLUUID tracking_id, std::string &filename, S32 &index); // Map each of a file's materials by name (empty name -> "mat", matching the // model loader's face bindings) to its world id, for texturing a local mesh. - void getWorldIDsByName(const std::string& filename, std::map& out); + void getWorldIDsByName(const std::string& filename, std::map& out, bool mesh_owned); std::vector getFilenames() const; // distinct loaded files (persistence) void feedScrollList(LLScrollListCtrl* ctrl); diff --git a/indra/newview/lllocalmesh.cpp b/indra/newview/lllocalmesh.cpp index 901a903561..e31f5b5166 100644 --- a/indra/newview/lllocalmesh.cpp +++ b/indra/newview/lllocalmesh.cpp @@ -415,9 +415,14 @@ namespace LLUUID LLLocalMesh::registerOwnedBitmap(const std::string& filename, std::vector& owned) { LLLocalBitmapMgr* mgr = LLLocalBitmapMgr::getInstance(); - // Reuse an existing unit for this file (another face of this mesh, a prior - // reload, or one the user already loaded) so we don't duplicate it. - LLUUID tracking_id = mgr->getUnitID(filename); + // Reuse an existing unit for this file -- a prior import (another face of this + // mesh or an earlier reload) first, else a copy the user already loaded -- so + // we don't duplicate it. + LLUUID tracking_id = mgr->getUnitID(filename, /*mesh_owned=*/true); + if (tracking_id.isNull()) + { + tracking_id = mgr->getUnitID(filename, /*mesh_owned=*/false); + } if (tracking_id.isNull()) { tracking_id = mgr->addUnit(filename, /*mesh_owned=*/true); @@ -440,29 +445,26 @@ void LLLocalMesh::importGLTFMaterials(std::map& out_by_name // and doesn't count.) Import-time dedup can leave gaps in per-file material // indices, so enumerate units by tracking id rather than walking indices. std::vector tids; - mgr->getTrackingIDs(mFilename, tids); - bool has_mesh_owned = false; - for (const LLUUID& tid : tids) - { - if (mgr->isMeshOwned(tid)) { has_mesh_owned = true; break; } - } - if (!has_mesh_owned) + mgr->getTrackingIDs(mFilename, tids, /*mesh_owned=*/true); + if (tids.empty()) { mgr->addUnit(mFilename, /*mesh_owned=*/true); - tids.clear(); - mgr->getTrackingIDs(mFilename, tids); + mgr->getTrackingIDs(mFilename, tids, /*mesh_owned=*/true); } // Keep this parse's mesh-owned units referenced (a user-loaded copy is left // untracked -- the user owns it; we must not release it on reload/delete). for (const LLUUID& tid : tids) { - if (mgr->isMeshOwned(tid)) - { - track_owned(owned, tid); - } + track_owned(owned, tid); + } + // Bind faces to the mesh's own imports; the user's deletable copies are only a + // fallback for the corner where the mesh-owned import failed to load. + mgr->getWorldIDsByName(mFilename, out_by_name, /*mesh_owned=*/true); + if (out_by_name.empty()) + { + mgr->getWorldIDsByName(mFilename, out_by_name, /*mesh_owned=*/false); } - mgr->getWorldIDsByName(mFilename, out_by_name); } bool LLLocalMesh::ingestScene(LLModelLoader::scene& scene) diff --git a/indra/newview/llviewerwindow.cpp b/indra/newview/llviewerwindow.cpp index 60968232d2..a1fc41cbc0 100644 --- a/indra/newview/llviewerwindow.cpp +++ b/indra/newview/llviewerwindow.cpp @@ -1584,7 +1584,7 @@ LLWindowCallbacks::DragNDropResult LLViewerWindow::handleDragNDropFile(LLWindow if (LLAssetType::AT_TEXTURE == mDragItems.front().first->getType()) { - LLUUID idLocalBitmap = LLLocalBitmapMgr::instance().getUnitID(strFilename); + LLUUID idLocalBitmap = LLLocalBitmapMgr::instance().getUnitID(strFilename, false); if (idLocalBitmap.isNull()) { idLocalBitmap = LLLocalBitmapMgr::instance().addUnit(strFilename); @@ -1598,7 +1598,7 @@ LLWindowCallbacks::DragNDropResult LLViewerWindow::handleDragNDropFile(LLWindow } else if (LLAssetType::AT_MATERIAL == mDragItems.front().first->getType()) { - LLUUID isLocalMat = LLLocalGLTFMaterialMgr::instance().getUnitID(strFilename, 0); + LLUUID isLocalMat = LLLocalGLTFMaterialMgr::instance().getUnitID(strFilename, 0, false); if (isLocalMat.isNull()) { LLLocalGLTFMaterialMgr::instance().addUnit(strFilename, isLocalMat); From b427c4c5a07ba7151ef823b8ae8475a92b04a7ca Mon Sep 17 00:00:00 2001 From: Rye Date: Tue, 9 Jun 2026 23:09:17 -0400 Subject: [PATCH 08/18] Local Assets: don't start a second loader while the initial parse runs forceReload() only checked mReloading, so toggling Joint Positions on a still-decoding unit (ST_LOADING) launched a second concurrent parse whose completions crossed states with the first -- a late failure would even delUnit a unit whose first parse had succeeded. Skip the reload in that state: ingestScene() reads the build options when the in-flight parse lands, so the new flag is picked up anyway. Co-Authored-By: Claude Fable 5 --- indra/newview/lllocalmesh.cpp | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/indra/newview/lllocalmesh.cpp b/indra/newview/lllocalmesh.cpp index e31f5b5166..bce83102d2 100644 --- a/indra/newview/lllocalmesh.cpp +++ b/indra/newview/lllocalmesh.cpp @@ -760,7 +760,11 @@ bool LLLocalMesh::forceReload() // Re-parse the current file regardless of mtime so a changed build option (e.g. // the joint-position toggle) takes effect. Reuses the reload path: onLoadResult() // rebuilds the geometry and refreshes the in-world linkset if one exists. - if (mReloading || !gDirUtilp->fileExists(mFilename)) + // Skip while the initial parse is still in flight: ingestScene() reads the + // current build options when that parse lands, so the change is picked up + // anyway -- and a second concurrent loader would cross result states with + // the first (a late failure would even delUnit a unit that loaded fine). + if (mReloading || mState == ST_LOADING || !gDirUtilp->fileExists(mFilename)) { return false; } From 6396641d6a18c4528394f5e63ede7a9451887329 Mon Sep 17 00:00:00 2001 From: Rye Date: Tue, 9 Jun 2026 23:10:04 -0400 Subject: [PATCH 09/18] Local Assets: implement Duplicate for local previews Ctrl+D / shift-drag-copy on a preview was a silent no-op: the all-local gate swallowed the ObjectDuplicate send, and the gate comment wrongly claimed LLLocalMeshMgr already handled duplication. selectDuplicate() now routes an all-local selection to LLLocalMeshMgr::duplicatePreview(), which rezzes a fresh instance at the source copy's transform plus the offset (same user-delta rotation capture as respawnInstancesInPlace), honoring select_copy. Worn copies still don't duplicate, matching the sim path; duplicate-on-ray stays intentionally dropped, and the gate comment now says so. Co-Authored-By: Claude Fable 5 --- indra/newview/lllocalmesh.cpp | 27 +++++++++++++++++++++++++ indra/newview/lllocalmesh.h | 5 +++++ indra/newview/llselectmgr.cpp | 37 ++++++++++++++++++++++++++++++++++- 3 files changed, 68 insertions(+), 1 deletion(-) diff --git a/indra/newview/lllocalmesh.cpp b/indra/newview/lllocalmesh.cpp index bce83102d2..310ef76446 100644 --- a/indra/newview/lllocalmesh.cpp +++ b/indra/newview/lllocalmesh.cpp @@ -1416,6 +1416,33 @@ LLViewerObject* LLLocalMeshMgr::spawnInWorld(const LLUUID& tracking_id) return root; } +LLViewerObject* LLLocalMeshMgr::duplicatePreview(LLViewerObject* obj, const LLVector3& offset) +{ + LLViewerObject* root = findRootForObject(obj); + if (!root || root->isDead() || root->isAttachment()) + { + return nullptr; // worn copies don't duplicate, matching the sim path + } + auto it = mSpawnedCopies.find(instanceForObject(root)); + if (it == mSpawnedCopies.end()) + { + return nullptr; + } + const LLUUID tracking_id = it->second.mTrackingID; + const LLLocalMesh* unit = getUnit(tracking_id); + if (!unit || !unit->getValid() || unit->getParts().empty()) + { + return nullptr; + } + // Same transform capture as respawnInstancesInPlace(): spawnLinkset() composes + // the root part's intrinsic rotation itself, so pass the USER delta, not the + // full world rotation. + const LLQuaternion user_delta = ~unit->getParts().front().mRotation * root->getRotation(); + LLUUID instance_id; + instance_id.generate(); + return spawnLinkset(tracking_id, instance_id, root->getPositionAgent() + offset, user_delta, false, 0); +} + LLViewerObject* LLLocalMeshMgr::spawnLinkset(const LLUUID& tracking_id, const LLUUID& instance_id, const LLVector3& base, const LLQuaternion& root_rot, bool attach, S32 attach_point) diff --git a/indra/newview/lllocalmesh.h b/indra/newview/lllocalmesh.h index 2d2b35b357..b63ef700df 100644 --- a/indra/newview/lllocalmesh.h +++ b/indra/newview/lllocalmesh.h @@ -265,6 +265,11 @@ class LLLocalMeshMgr : public LLSingleton // agent. A unit can be rezzed any number of times; each copy is independent with // its own instance id. Returns the new copy's linkset root. LLViewerObject* spawnInWorld(const LLUUID& tracking_id); + // Rez a NEW copy of the preview that owns `obj`, at the source copy's transform + // plus `offset` -- the local-object route for the build tools' Duplicate + // (Ctrl+D / shift-drag-copy). Worn copies don't duplicate, matching the sim + // path. Returns the new copy's linkset root, or null. + LLViewerObject* duplicatePreview(LLViewerObject* obj, const LLVector3& offset); // Convenience: load each file and spawn it in-world once it finishes loading. // include_joints applies to any file that needs a fresh decode (pass the // persisted joint-position flag for saved rows, like loadPath does). diff --git a/indra/newview/llselectmgr.cpp b/indra/newview/llselectmgr.cpp index 088b1ae9ba..044cc7500d 100644 --- a/indra/newview/llselectmgr.cpp +++ b/indra/newview/llselectmgr.cpp @@ -4704,6 +4704,39 @@ void LLSelectMgr::selectDuplicate(const LLVector3& offset, bool select_copy) return; } } + + // Client-only local previews: duplicate locally. The ObjectDuplicate send + // below is swallowed for an all-local selection (sendListToRegions gate), + // which would otherwise make Ctrl+D / shift-drag-copy a silent no-op. + if (selectionAllLocalPreview(mSelectedObjects)) + { + // Snapshot the source roots first: spawning fires the manager's + // units-changed signal, whose listeners must not invalidate this walk. + std::vector> src_roots; + for (LLObjectSelection::root_iterator it = getSelection()->root_begin(); + it != getSelection()->root_end(); ++it) + { + src_roots.emplace_back((*it)->getObject()); + } + std::vector new_roots; + for (const LLPointer& src : src_roots) + { + if (LLViewerObject* new_root = LLLocalMeshMgr::getInstance()->duplicatePreview(src.get(), offset)) + { + new_roots.push_back(new_root); + } + } + if (select_copy && !new_roots.empty()) + { + deselectAll(); + for (LLViewerObject* new_root : new_roots) + { + selectObjectAndFamily(new_root); + } + } + return; + } + LLDuplicateData data; data.offset = offset; @@ -5942,7 +5975,9 @@ void LLSelectMgr::sendListToRegions(LLObjectSelectionHandle selected_handle, // Client-only local mesh previews never talk to the simulator. Short-circuit // every object message marshalled through here (name/description/permissions/ // sale/group/owner/click-action/category/shape/flags/...). Local derez, - // duplicate and attach are handled by LLLocalMeshMgr before reaching this path. + // duplicate and attach are intercepted upstream (selectDelete, selectDuplicate + // and the attach menu route to LLLocalMeshMgr); anything else marshalled here + // for an all-local selection is intentionally dropped. if (selectionAllLocalPreview(selected_handle)) { return; From 6775a1ed2821ae61802430f36069562ec6a4e6b1 Mon Sep 17 00:00:00 2001 From: Rye Date: Tue, 9 Jun 2026 23:10:23 -0400 Subject: [PATCH 10/18] Local Assets: keep local ids out of the decomposition/physics-shape repo getDecomposition()/fetchPhysicsShape() queued repo-thread requests for local preview ids (reachable from physics-shape rendering and the build floater's physics tab); with no header and no HTTP they just retried and parked the id in mLoadingDecompositions/mLoadingPhysicsShapes forever. hasPhysicsShape() triggered the same fetch. Short-circuit all three for local ids like the repo's other entry points. Co-Authored-By: Claude Fable 5 --- indra/newview/llmeshrepository.cpp | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/indra/newview/llmeshrepository.cpp b/indra/newview/llmeshrepository.cpp index 14ca3d947c..e39631f8d8 100644 --- a/indra/newview/llmeshrepository.cpp +++ b/indra/newview/llmeshrepository.cpp @@ -4968,6 +4968,14 @@ void LLMeshRepository::fetchPhysicsShape(const LLUUID& mesh_id) { LL_PROFILE_ZONE_SCOPED_CATEGORY_NETWORK; //LL_RECORD_BLOCK_TIME(FTM_MESH_FETCH); + // Local mesh previews have no server-side decomposition; don't queue a repo + // request that can never resolve (the id would sit in mLoadingPhysicsShapes + // forever, retrying). + if (LLLocalMeshMgr::instanceExists() && LLLocalMeshMgr::getInstance()->isLocal(mesh_id)) + { + return; + } + if (mesh_id.notNull()) { LLModel::Decomposition* decomp = NULL; @@ -4998,6 +5006,14 @@ LLModel::Decomposition* LLMeshRepository::getDecomposition(const LLUUID& mesh_id LLModel::Decomposition* ret = NULL; + // Local mesh previews have no server-side decomposition; don't queue a repo + // request that can never resolve (the id would sit in mLoadingDecompositions + // forever, retrying). + if (LLLocalMeshMgr::instanceExists() && LLLocalMeshMgr::getInstance()->isLocal(mesh_id)) + { + return ret; + } + if (mesh_id.notNull()) { decomposition_map::iterator iter = mDecompositionMap.find(mesh_id); @@ -5046,6 +5062,13 @@ bool LLMeshRepository::hasPhysicsShape(const LLUUID& mesh_id) return false; } + // Local mesh previews have no server physics shape (and must not trigger the + // decomposition fetch below). + if (LLLocalMeshMgr::instanceExists() && LLLocalMeshMgr::getInstance()->isLocal(mesh_id)) + { + return false; + } + if (mThread->hasPhysicsShapeInHeader(mesh_id)) { return true; From 6c9eeda531395fbb0569e711398123ae24f2130f Mon Sep 17 00:00:00 2001 From: Rye Date: Tue, 9 Jun 2026 23:10:45 -0400 Subject: [PATCH 11/18] Local Assets: fix picker selection restore highlighting the first row onLocalAssetsChanged() re-selected via setSelectedByValue() with the row's map-valued LLSD; an LLSD map stringifies to "" under that comparator, so the highlight always jumped to the first enabled row after any local-asset change elsewhere. Match the unit id + type against the row values instead. Co-Authored-By: Claude Fable 5 --- indra/newview/lltexturectrl.cpp | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/indra/newview/lltexturectrl.cpp b/indra/newview/lltexturectrl.cpp index 2a412cb2e0..3874f0b155 100644 --- a/indra/newview/lltexturectrl.cpp +++ b/indra/newview/lltexturectrl.cpp @@ -1591,8 +1591,22 @@ void LLFloaterTexturePicker::onLocalAssetsChanged() { if (still_valid) { - // Keep the user's selection highlighted across the rebuild. - mLocalScrollCtrl->setSelectedByValue(sel_value, true); + // Keep the user's selection highlighted across the rebuild. The rows + // carry map-valued LLSD, which setSelectedByValue() compares via + // asString() -- a map stringifies to "", so it would just highlight + // the first row. Match the unit id + type manually instead. + const LLUUID sel_id = sel_value["id"].asUUID(); + const S32 sel_type = sel_value["type"].asInteger(); + std::vector items = mLocalScrollCtrl->getAllData(); + for (size_t i = 0; i < items.size(); ++i) + { + const LLSD v = items[i]->getValue(); + if (v.has("id") && v["id"].asUUID() == sel_id && v["type"].asInteger() == sel_type) + { + mLocalScrollCtrl->selectNthItem((S32)i); + break; + } + } } else { From db364445d9e09b4e63d3ae6a2d4da7db2afba641 Mon Sep 17 00:00:00 2001 From: Rye Date: Tue, 9 Jun 2026 23:11:07 -0400 Subject: [PATCH 12/18] Local Assets: write local_assets.xml via temp file + rename writeToDisk() truncate-wrote the file in place; a crash mid-write left a truncated file that reloadForAccount() silently normalized to an empty working set, losing the saved paths. Write to a sibling .tmp and rename it over the old file (std::filesystem::rename replaces in one step). Co-Authored-By: Claude Fable 5 --- indra/newview/lllocalassetpaths.cpp | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/indra/newview/lllocalassetpaths.cpp b/indra/newview/lllocalassetpaths.cpp index 53fb163c16..ceba8a1c17 100644 --- a/indra/newview/lllocalassetpaths.cpp +++ b/indra/newview/lllocalassetpaths.cpp @@ -94,16 +94,25 @@ void LLLocalAssetPaths::writeToDisk() const { return; } - llofstream out(path.c_str()); - if (out.is_open()) + // Write to a temp file and rename it over the old one (std::filesystem::rename + // replaces in one step) so a crash mid-write can't leave a truncated file -- + // reloadForAccount() would silently normalize that to an empty working set. + const std::string tmp_path = path + ".tmp"; + llofstream out(tmp_path.c_str()); + if (!out.is_open()) { - LLSDSerialize::toXML(mPaths, out); - out.close(); + LL_WARNS("LocalAssets") << "Can't write local asset list: " << tmp_path << LL_ENDL; + return; } - else + LLSDSerialize::toXML(mPaths, out); + out.close(); + if (out.fail()) { - LL_WARNS("LocalAssets") << "Can't write local asset list: " << path << LL_ENDL; + LL_WARNS("LocalAssets") << "Failed writing local asset list: " << tmp_path << LL_ENDL; + LLFile::remove(tmp_path); + return; } + LLFile::rename(tmp_path, path); } void LLLocalAssetPaths::removePath(EType type, const std::string& path) From 366f52a1fd3dfa7b21b0284873785ca736462b32 Mon Sep 17 00:00:00 2001 From: Rye Date: Tue, 9 Jun 2026 23:11:29 -0400 Subject: [PATCH 13/18] Model loader: hand completion to the main thread via the mainloop queue LLModelLoader::run() posted its completion with doOnIdleOneTime() from the loader's worker thread, but LLCallbackList has no synchronization -- a race the local-assets live reload turned from a rare upload-floater event into a steady-state mechanism (the upstream TODO at the call site even called it out). Post through the thread-safe "mainloop" WorkQueue instead, falling back to the idle list when no queue exists (unit tests / headless). Co-Authored-By: Claude Fable 5 --- indra/llprimitive/llmodelloader.cpp | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/indra/llprimitive/llmodelloader.cpp b/indra/llprimitive/llmodelloader.cpp index 4c0cbda205..8e861a0747 100644 --- a/indra/llprimitive/llmodelloader.cpp +++ b/indra/llprimitive/llmodelloader.cpp @@ -32,6 +32,7 @@ #include "llsdserialize.h" #include "lljoint.h" #include "llcallbacklist.h" +#include "workqueue.h" #include "llmatrix4a.h" #include @@ -192,9 +193,19 @@ void LLModelLoader::run() setLoadState(ERROR_PARSING); } - // todo: we are inside of a thread, push this into main thread worker, - // not into doOnIdleOneTime that laks tread safety - doOnIdleOneTime(boost::bind(&LLModelLoader::loadModelCallback,this)); + // Hand completion back to the main thread. run() executes on the loader's + // worker thread, and the LLCallbackList behind doOnIdleOneTime() is not + // thread-safe -- post through the main loop's WorkQueue instead. + LL::WorkQueue::ptr_t main_queue = LL::WorkQueue::getInstance("mainloop"); + if (main_queue) + { + main_queue->post(boost::bind(&LLModelLoader::loadModelCallback, this)); + } + else + { + // No main-loop queue (unit tests / headless): single-threaded, safe. + doOnIdleOneTime(boost::bind(&LLModelLoader::loadModelCallback, this)); + } } // static From d26e60283f41cc0b4e15715ed06af1248687ec90 Mon Sep 17 00:00:00 2001 From: Rye Date: Tue, 9 Jun 2026 23:26:39 -0400 Subject: [PATCH 14/18] Make LLDrawInfo own its skin info; drop the local-mesh retire list The mesh repo culls cached skins on a pure refcount check (mSkinMap entries with getNumRefs() == 1, every 10 seconds), so the refcount IS the skin's lifetime contract -- but the per-frame matrix-palette uploads read the skin through a raw LLDrawInfo pointer the cull can't see. A volume that dropped its reference (mesh id change, or a local-mesh reload/delete releasing the unit and volume refs mid-frame) left the render path one cull tick away from reading freed memory. LLDrawInfo::mSkinInfo becomes an LLPointer like its mAvatar: every in-world uploadMatrixPalette() call site reads the skin through a draw info, so the draw call now pins what it renders. LLFace::mSkinInfo stays a raw mirror (matching its mAvatar): faces and their group's draw infos are (re)built together in rebuildGeom, so a face's skin is always pinned by its volume or by the co-generated draw-info references -- the one face-based reader outside rebuilds is the octree debug display, covered by the same invariant. Co-Authored-By: Claude Fable 5 --- indra/newview/llspatialpartition.h | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/indra/newview/llspatialpartition.h b/indra/newview/llspatialpartition.h index 1cb35c391c..ff90725981 100644 --- a/indra/newview/llspatialpartition.h +++ b/indra/newview/llspatialpartition.h @@ -102,7 +102,14 @@ class alignas(16) LLDrawInfo final : public LLRefCount const LLMatrix4* mModelMatrix = nullptr; LLPointer mAvatar = nullptr; - LLMeshSkinInfo* mSkinInfo = nullptr; + // Owning, like mAvatar above: the mesh repo culls skins whose only + // remaining reference is its own map (and a local-mesh reload drops the + // unit/volume references mid-frame), so the draw call must hold its skin + // alive itself for the per-frame matrix-palette upload. LLFace::mSkinInfo + // stays a raw mirror (like its mAvatar): faces and their group's draw + // infos are (re)built together, so a face's skin is always pinned by its + // volume or by the co-generated draw-info references here. + LLPointer mSkinInfo; // Material pointer here is likely for debugging only and are immaterial (zing!) LLPointer mMaterial; From 521f88b0d905e52cf13b33e26d5ce1f130f37af7 Mon Sep 17 00:00:00 2001 From: Rye Date: Tue, 9 Jun 2026 23:51:19 -0400 Subject: [PATCH 15/18] Mesh repo: stop wiping the repo thread's skin mirror on every cull tick The skin cull posted mThread->mSkinMap.erase(id) for EVERY skin in the main map -- the post sat outside the getNumRefs() == 1 eviction check. The repo thread's map is a deep-copy mirror kept so volume loads can compute per-joint bounding boxes without taking the main map's path; erasing it unconditionally emptied the mirror within one 10s tick of arrival, quietly defeating that cache for any volume loading later than that, and posted one work item per cached skin every tick. Erase the mirror only for skins actually evicted from the main map, and batch the evicted ids into a single posted work item. Co-Authored-By: Claude Fable 5 --- indra/newview/llmeshrepository.cpp | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/indra/newview/llmeshrepository.cpp b/indra/newview/llmeshrepository.cpp index e39631f8d8..81691b65d4 100644 --- a/indra/newview/llmeshrepository.cpp +++ b/indra/newview/llmeshrepository.cpp @@ -4604,10 +4604,10 @@ void LLMeshRepository::notifyLoadedMeshes() { //// Clean up dead skin info //U64Bytes skinbytes(0); + std::vector culled_ids; for (auto iter = mSkinMap.begin(), ender = mSkinMap.end(); iter != ender;) { auto copy_iter = iter++; - LLUUID id = copy_iter->first; //skinbytes += U64Bytes(sizeof(LLMeshSkinInfo)); //skinbytes += U64Bytes(copy_iter->second->mJointNames.size() * sizeof(std::string)); @@ -4615,16 +4615,28 @@ void LLMeshRepository::notifyLoadedMeshes() //skinbytes += U64Bytes(copy_iter->second->mJointNames.size() * sizeof(LLMatrix4a)); //skinbytes += U64Bytes(copy_iter->second->mJointNames.size() * sizeof(LLMatrix4)); + // The repo thread's mirror is erased only for skins actually evicted + // here: erasing it for every iterated skin emptied the mirror within + // one tick of arrival -- defeating its per-joint bounding-box use for + // any volume loading later than that -- and posted one work item per + // cached skin every tick. if (copy_iter->second->getNumRefs() == 1) { + culled_ids.push_back(copy_iter->first); mSkinMap.erase(copy_iter); } + } + if (!culled_ids.empty()) + { // erase from background thread - mThread->mWorkQueue.post([=, this]() + mThread->mWorkQueue.post([ids = std::move(culled_ids), this]() { LLMutexLock skin_lock(mThread->mSkinMapMutex); - mThread->mSkinMap.erase(id); + for (const LLUUID& id : ids) + { + mThread->mSkinMap.erase(id); + } }); } //LL_INFOS() << "Skin info cache elements:" << mSkinMap.size() << " Memory: " << U64Kilobytes(skinbytes) << LL_ENDL; From 3193165c65edd08731908ea1e9c784fb8aebc3c7 Mon Sep 17 00:00:00 2001 From: Rye Date: Wed, 10 Jun 2026 00:21:39 -0400 Subject: [PATCH 16/18] Local Assets: stop the hot-swap face restore clobbering file material edits restorePreservedFaces() re-applied every captured TE wholesale after applyPartGeometry(), so a reload that changed a face's texture, color, fullbright or material in the SOURCE FILE was immediately overwritten with pre-reload state -- live material edits never showed on spawned copies, and each swap re-captured the stale state so it stuck for good. Worse, the capture ran after ingestScene had already released dropped imports, whose teardown resets live faces to IMG_DEFAULT -- so a rebound face could capture-and-restore the placeholder forever. The restore now diffs instead: the live face state and the OLD parts' imported materials are snapshotted in onLoadResult BEFORE ingest commits, and after the new file state is applied only fields that differ from the old import -- i.e. actual user edits -- are restored (texture compared by local-bitmap tracking id too, so world-id rotation on a texture's own reload doesn't read as an edit). Fields no import can author (glow, transforms, bump/shiny, Blinn-Phong maps, glTF overrides) restore as-is. Co-Authored-By: Claude Fable 5 --- indra/newview/lllocalmesh.cpp | 185 +++++++++++++++++++++++++--------- indra/newview/lllocalmesh.h | 11 +- 2 files changed, 149 insertions(+), 47 deletions(-) diff --git a/indra/newview/lllocalmesh.cpp b/indra/newview/lllocalmesh.cpp index 310ef76446..f8f989c6d4 100644 --- a/indra/newview/lllocalmesh.cpp +++ b/indra/newview/lllocalmesh.cpp @@ -1591,47 +1591,80 @@ void LLLocalMeshMgr::respawnInstancesInPlace(const LLUUID& tracking_id) } } -namespace -{ -// A face's user-visible render state, snapshotted so user edits survive a hot-swap: -// applyPartGeometry re-applies the file's imported materials and resets every face, -// which would otherwise wipe textures/materials/glow/etc. the user applied to the -// in-world preview. -struct PreservedFace +// Hot-swap diff-restore state: each spawned prim's live face state, captured +// BEFORE the reload's ingestScene commits (ingest releases dropped imports, +// whose teardown already swaps live faces to IMG_DEFAULT -- a capture taken +// after that would record the reset as if the user had applied it), plus the +// OLD parts' imported materials to diff user edits against. +struct LLLocalMeshPreSwapSnapshot { - LLTextureEntry te; // diffuse id, color, glow, bump/shiny/fullbright, transforms, Blinn-Phong material, glTF override - LLUUID render_mat_id; // glTF render material id (kept in the param block, not the TE) + struct Face + { + LLTextureEntry mTE; // diffuse id, color, glow, bump/shiny/fullbright, transforms, Blinn-Phong material, glTF override + LLUUID mRenderMatID; // glTF render material id (kept in the param block, not the TE) + }; + typedef std::vector PrimFaces; + + std::map> mCaptured; // per copy (instance id), per prim + std::vector> mOldImported; // per part: the old file's materials }; -std::vector capturePreservedFaces(LLVOVolume* vol) +namespace { - std::vector out; +// Re-apply the user's in-world face edits on top of the freshly applied file +// materials. applyPartGeometry() has just written the NEW file's imported state +// (texture/color/fullbright/render material); for those fields a captured value +// is restored only when it differs from what the OLD file had imported -- i.e. +// only when the user actually changed it. Restoring them wholesale overwrote +// every file-level material edit with pre-reload state, so material changes in +// the source file never showed on spawned copies. Fields no import can author +// (glow, texture transforms, bump/shiny, Blinn-Phong maps, glTF overrides) are +// user state, restored as-is. +void restorePreservedFaces(LLVOVolume* vol, + const LLLocalMeshPreSwapSnapshot::PrimFaces& saved, + const std::vector* old_imported) +{ + LLLocalBitmapMgr* bitmap_mgr = LLLocalBitmapMgr::getInstance(); const U8 n = vol->getNumTEs(); - out.reserve(n); - for (U8 i = 0; i < n; ++i) + bool any_render_mat = false; + for (U8 i = 0; i < n && i < (U8)saved.size(); ++i) { - PreservedFace pf; - if (const LLTextureEntry* tep = vol->getTE(i)) + const LLTextureEntry& te = saved[i].mTE; + + // What the OLD file had applied to this face, with applyPartGeometry()'s + // fallbacks -- captured values equal to these are imports, not user edits. + const LLLocalMeshFaceMaterial* old_fm = + (old_imported && i < (U8)old_imported->size()) ? &(*old_imported)[i] : nullptr; + const LLUUID old_tex = (old_fm && old_fm->mDiffuseID.notNull()) ? old_fm->mDiffuseID : IMG_DEFAULT; + const LLColor4 old_color = old_fm ? old_fm->mDiffuseColor : LLColor4::white; + const bool old_fullbright = old_fm && old_fm->mFullbright; + const LLUUID old_render_mat = old_fm ? old_fm->mRenderMaterialID : LLUUID(); + + // Local-bitmap world ids rotate on the texture's own live reload; compare + // tracking ids too so that churn doesn't read as a user edit. + bool tex_is_user_edit = te.getID() != old_tex; + if (tex_is_user_edit) { - pf.te = *tep; + const LLUUID cap_track = bitmap_mgr->getTrackingID(te.getID()); + tex_is_user_edit = cap_track.isNull() || cap_track != bitmap_mgr->getTrackingID(old_tex); + } + if (tex_is_user_edit) + { + vol->setTETexture(i, te.getID()); + } + if (te.getColor() != old_color) + { + vol->setTEColor(i, te.getColor()); + } + if (((bool)te.getFullbright()) != old_fullbright) + { + vol->setTEFullbright(i, te.getFullbright()); } - pf.render_mat_id = vol->getRenderMaterialID(i); - out.push_back(pf); - } - return out; -} -void restorePreservedFaces(LLVOVolume* vol, const std::vector& saved) -{ - const U8 n = vol->getNumTEs(); - bool any_render_mat = false; - for (U8 i = 0; i < n && i < (U8)saved.size(); ++i) - { - const LLTextureEntry& te = saved[i].te; - vol->setTETexture(i, te.getID()); - vol->setTEColor(i, te.getColor()); + // User-only state: no import path authors these. vol->setTEGlow(i, te.getGlow()); - vol->setTEBumpShinyFullbright(i, te.getBumpShinyFullbright()); + vol->setTEBumpmap(i, te.getBumpmap()); + vol->setTEShiny(i, te.getShiny()); F32 ss = 1.f, st = 1.f, os = 0.f, ot = 0.f; te.getScale(&ss, &st); te.getOffset(&os, &ot); @@ -1642,17 +1675,20 @@ void restorePreservedFaces(LLVOVolume* vol, const std::vector& sa { vol->setTEMaterialParams(i, te.getMaterialParams()); // Blinn-Phong normal/specular } - if (saved[i].render_mat_id.notNull()) + + if (saved[i].mRenderMatID.notNull() && saved[i].mRenderMatID != old_render_mat) { - // glTF PBR material (+ override); client-only, so update_server=false. - vol->setRenderMaterialID((S32)i, saved[i].render_mat_id, false, true); - if (const LLGLTFMaterial* ov = te.getGLTFMaterialOverride()) - { - LLPointer ovp = new LLGLTFMaterial(*ov); - vol->setTEGLTFMaterialOverride(i, ovp); - } + // The user applied this material over the import; keep it showing. + // Client-only, so update_server=false. + vol->setRenderMaterialID((S32)i, saved[i].mRenderMatID, false, true); any_render_mat = true; } + if (const LLGLTFMaterial* ov = te.getGLTFMaterialOverride()) + { + // Overrides are user edits (imports never author them). + LLPointer ovp = new LLGLTFMaterial(*ov); + vol->setTEGLTFMaterialOverride(i, ovp); + } } if (any_render_mat) { @@ -1662,7 +1698,48 @@ void restorePreservedFaces(LLVOVolume* vol, const std::vector& sa } } // namespace -bool LLLocalMeshMgr::hotSwapInWorld(const LLUUID& tracking_id) +void LLLocalMeshMgr::capturePreSwap(const LLUUID& tracking_id, const LLLocalMesh& unit, + LLLocalMeshPreSwapSnapshot& out) const +{ + for (const LLLocalMeshPart& part : unit.getParts()) + { + out.mOldImported.push_back(part.mFaceMaterials); + } + for (const auto& entry : mSpawnedCopies) + { + const SpawnedCopy& copy = entry.second; + if (copy.mTrackingID != tracking_id) + { + continue; + } + std::vector prims; + prims.reserve(copy.mPrims.size()); + for (const LLPointer& p : copy.mPrims) + { + LLLocalMeshPreSwapSnapshot::PrimFaces faces; + LLVOVolume* vol = (p.notNull() && !p->isDead()) ? dynamic_cast(p.get()) : nullptr; + if (vol) + { + const U8 n = vol->getNumTEs(); + faces.reserve(n); + for (U8 i = 0; i < n; ++i) + { + LLLocalMeshPreSwapSnapshot::Face f; + if (const LLTextureEntry* tep = vol->getTE(i)) + { + f.mTE = *tep; + } + f.mRenderMatID = vol->getRenderMaterialID(i); + faces.push_back(f); + } + } + prims.push_back(std::move(faces)); + } + out.mCaptured.emplace(entry.first, std::move(prims)); + } +} + +bool LLLocalMeshMgr::hotSwapInWorld(const LLUUID& tracking_id, const LLLocalMeshPreSwapSnapshot& pre_swap) { LLLocalMesh* unit = getUnit(tracking_id); if (!unit || !unit->getValid()) @@ -1713,12 +1790,17 @@ bool LLLocalMeshMgr::hotSwapInWorld(const LLUUID& tracking_id) return false; // a dead/unexpected prim -> let the caller re-spawn } vol->setScale(parts[i].mScale, false); - // Preserve user-applied face params (diffuse/normal/specular/glow/color/ - // render material/transforms) across the swap -- applyPartGeometry resets - // every face to the file's imported material. - std::vector preserved = capturePreservedFaces(vol); + // Apply the new file's geometry + materials, then diff-restore the + // user's face edits over them (see restorePreservedFaces). The live + // state was captured pre-ingest in onLoadResult. applyPartGeometry(vol, parts[i]); - restorePreservedFaces(vol, preserved); + auto cap_it = pre_swap.mCaptured.find(entry.first); + if (cap_it != pre_swap.mCaptured.end() && i < cap_it->second.size()) + { + const std::vector* old_imported = + (i < pre_swap.mOldImported.size()) ? &pre_swap.mOldImported[i] : nullptr; + restorePreservedFaces(vol, cap_it->second[i], old_imported); + } } } @@ -1959,6 +2041,17 @@ void LLLocalMeshMgr::onLoadResult(const LLUUID& tracking_id, LLModelLoader::scen const bool reloading = unit->isReloading(); const bool parse_ok = (load_state < LLModelLoader::ERROR_PARSING) && !scene.empty(); + + // Snapshot the spawned copies' live face state and the old parts' imported + // materials BEFORE ingestScene commits: the hot-swap diff-restore needs both + // to tell user edits from file changes, and ingest's release of dropped + // imports already resets live faces as the dying units tear down. + LLLocalMeshPreSwapSnapshot pre_swap; + if (reloading && parse_ok) + { + capturePreSwap(tracking_id, *unit, pre_swap); + } + const bool assembled = parse_ok && unit->ingestScene(scene); if (!parse_ok) @@ -1978,7 +2071,7 @@ void LLLocalMeshMgr::onLoadResult(const LLUUID& tracking_id, LLModelLoader::scen // if the preview was worn). Not spawned -> just keep the rebuilt data. if (getSpawnedRoot(tracking_id)) { - if (!hotSwapInWorld(tracking_id)) + if (!hotSwapInWorld(tracking_id, pre_swap)) { respawnInstancesInPlace(tracking_id); // prim count changed -> re-rez each copy } diff --git a/indra/newview/lllocalmesh.h b/indra/newview/lllocalmesh.h index b63ef700df..2d7f84fb28 100644 --- a/indra/newview/lllocalmesh.h +++ b/indra/newview/lllocalmesh.h @@ -60,6 +60,7 @@ class LLViewerRegion; class LLVOAvatar; class LLVOVolume; class LLVolume; +struct LLLocalMeshPreSwapSnapshot; // hot-swap diff-restore state (lllocalmesh.cpp) // One uploadable sub-mesh of a local mesh file: a single LLModel's geometry, // normalized to a unit box (as the upload path does), plus where it sits within @@ -344,8 +345,16 @@ class LLLocalMeshMgr : public LLSingleton // Reload every spawned copy of a unit in place by pointing each existing prim at // the freshly decoded geometry (new sculpt id) instead of despawning -- so // attachment, selection and transform are preserved and there's no flicker. + // pre_swap carries the state captured before ingestScene committed; the + // diff-restore re-applies only the user's face edits over the new file state. // Returns false if the prim count changed (caller falls back to a re-spawn). - bool hotSwapInWorld(const LLUUID& tracking_id); + bool hotSwapInWorld(const LLUUID& tracking_id, const LLLocalMeshPreSwapSnapshot& pre_swap); + // Snapshot, BEFORE a reload's ingestScene commits, everything the hot-swap + // diff-restore needs: each spawned prim's live face state and the old parts' + // imported materials (ingest releases dropped imports, whose teardown already + // resets live faces -- a later capture would mistake that for user state). + void capturePreSwap(const LLUUID& tracking_id, const LLLocalMesh& unit, + LLLocalMeshPreSwapSnapshot& out) const; // Re-rez every existing copy of a unit at its current transform/attachment, used // when a reload changes the prim count so hot-swap can't swap 1:1. void respawnInstancesInPlace(const LLUUID& tracking_id); From d292e5eda71c19b422ef3102f01220019334c34e Mon Sep 17 00:00:00 2001 From: Rye Date: Wed, 10 Jun 2026 00:25:03 -0400 Subject: [PATCH 17/18] Local Assets: regroup a material file's units on live edit; re-bind meshes Import-time dedup collapses content-identical glTF materials onto one unit and records the other binding names as aliases, but a live reload only re-decoded each unit's own material index -- the alias structure was frozen at import. Editing the file so a formerly identical material diverges left its binding name aliased at the stale canonical unit forever (and faces bound to it rendering the wrong material); added materials got no unit, removed ones left orphans retrying. LLLocalGLTFMaterialMgr::doUpdates now runs a structural pass per changed (file, ownership class) group: surviving units keep their tracking/world ids and re-decode as before, diverged or newly added indices get fresh units, removed indices drop theirs, and every alias is rebuilt from the current per-index content signatures. Local meshes bind faces to these materials by name only at ingest, so when a regroup moves the file's name -> world id mapping the mesh manager now re-binds: LLLocalMeshFaceMaterial keeps the glTF binding name, and rebindFaceMaterials() re-resolves the stored face materials and re-points spawned faces -- skipping faces the user re-materialed in-world (the same only-if-still-showing-the-import rule as the hot-swap diff-restore), and updating the stored import so later hot-swap diffs compare correctly. Co-Authored-By: Claude Fable 5 --- indra/newview/lllocalgltfmaterials.cpp | 134 ++++++++++++++++++++++++- indra/newview/lllocalgltfmaterials.h | 9 ++ indra/newview/lllocalmesh.cpp | 78 +++++++++++++- indra/newview/lllocalmesh.h | 16 ++- 4 files changed, 229 insertions(+), 8 deletions(-) diff --git a/indra/newview/lllocalgltfmaterials.cpp b/indra/newview/lllocalgltfmaterials.cpp index 001d1b6edd..682fa2456f 100644 --- a/indra/newview/lllocalgltfmaterials.cpp +++ b/indra/newview/lllocalgltfmaterials.cpp @@ -35,6 +35,7 @@ #include "llgltfmateriallist.h" #include "llimage.h" #include "llinventoryicon.h" +#include "lllocalmesh.h" // rebindFaceMaterials after a live-edit regroup #include "llmaterialmgr.h" #include "llnotificationsutil.h" #include "llscrolllistctrl.h" @@ -702,11 +703,142 @@ void LLLocalGLTFMaterialMgr::doUpdates() // preventing theoretical overlap in cases with huge number of loaded images. mTimer.stopTimer(); + // Per-unit content refresh; collect which (file, ownership class) groups + // actually re-decoded so the structural pass below runs once per group. + std::vector> changed; for (local_list_iter iter = mMaterialList.begin(); iter != mMaterialList.end(); iter++) { - (*iter)->updateSelf(); + if ((*iter)->updateSelf()) + { + std::pair key((*iter)->getFilename(), (*iter)->isMeshOwned()); + if (std::find(changed.begin(), changed.end(), key) == changed.end()) + { + changed.push_back(key); + } + } + } + + // A live edit can change a file's material STRUCTURE, not just content: + // re-derive its units/aliases, and when that moved the name -> id mapping, + // re-point any local mesh bound to the file (faces bind by name only at + // ingest, so a diverged formerly-deduplicated material would otherwise keep + // rendering the stale canonical unit forever). + bool structure_changed = false; + for (const auto& entry : changed) + { + if (regroupFileMaterials(entry.first, entry.second)) + { + structure_changed = true; + LLLocalMeshMgr::getInstance()->rebindFaceMaterials(entry.first); + } + } + if (structure_changed) + { + mUnitsChangedSignal(); // regroup can add units -> refresh the lists } mTimer.startTimer(); } +bool LLLocalGLTFMaterialMgr::regroupFileMaterials(const std::string& filename, bool mesh_owned) +{ + std::map before; + getWorldIDsByName(filename, before, mesh_owned); + + tinygltf::Model model; + if (!LLTinyGLTFHelper::loadModel(filename, model)) + { + return false; // unreadable mid-save; the per-unit retry path covers it + } + const S32 count = (S32)model.materials.size(); + if (count <= 0) + { + return false; + } + + // This file's units of this ownership class, by material index. + std::map by_index; + for (const LLPointer& unit : mMaterialList) + { + if (unit->getFilename() == filename && unit->isMeshOwned() == mesh_owned) + { + by_index[unit->getIndexInFile()] = unit.get(); + } + } + if (by_index.empty()) + { + return false; + } + + // Units whose material index no longer exists: a successful parse is truth + // (a mesh reload replaces geometry wholesale, same idea), so drop them. + // Collect first -- delUnit mutates mMaterialList and fires the signal. + std::vector dead; + for (auto it = by_index.begin(); it != by_index.end();) + { + if (it->first >= count) + { + dead.push_back(it->second->getTrackingID()); + it = by_index.erase(it); + } + else + { + ++it; + } + } + + // Signature per index, and signature -> canonical unit over the unit-backed + // indices (lowest index wins, matching import order). + std::vector sigs; + sigs.reserve(count); + for (S32 i = 0; i < count; ++i) + { + sigs.push_back(gltfMaterialSignature(model, i)); + } + std::map canonical; + for (const auto& entry : by_index) + { + canonical.emplace(sigs[entry.first], entry.second); + } + + // Rebuild every alias from scratch. An index with no unit either still + // matches an existing unit's content (alias it there) or has diverged / + // been newly added (give it its own unit now). + for (const auto& entry : by_index) + { + entry.second->clearAliasNames(); + } + for (S32 i = 0; i < count; ++i) + { + if (by_index.count(i)) + { + continue; // has its own unit + } + const std::string& nm = model.materials[i].name; + const std::string name = nm.empty() ? ("mat" + std::to_string(i)) : nm; + std::map::iterator canon_it = canonical.find(sigs[i]); + if (canon_it != canonical.end()) + { + canon_it->second->addAliasName(name); + continue; + } + LLPointer unit = new LLLocalGLTFMaterial(filename, i); + if (unit->updateSelf()) + { + unit->setMeshOwned(mesh_owned); + mMaterialList.emplace_back(unit); + by_index[i] = unit.get(); + canonical.emplace(sigs[i], unit.get()); + } + } + + for (const LLUUID& id : dead) + { + delUnit(id); + } + + std::map after; + getWorldIDsByName(filename, after, mesh_owned); + return before != after; +} + diff --git a/indra/newview/lllocalgltfmaterials.h b/indra/newview/lllocalgltfmaterials.h index 9570bc0352..ab1f7ab85c 100644 --- a/indra/newview/lllocalgltfmaterials.h +++ b/indra/newview/lllocalgltfmaterials.h @@ -58,6 +58,7 @@ class LLLocalGLTFMaterial : public LLFetchedGLTFMaterial // map (see LLLocalGLTFMaterialMgr::getWorldIDsByName). const std::vector& getAliasNames() const { return mAliasNames; } void addAliasName(const std::string& name) { mAliasNames.push_back(name); } + void clearAliasNames() { mAliasNames.clear(); } // rebuilt by a live-edit regroup // Imported by a local mesh for its own faces: hidden from the Materials tab + // cross-session persistence (it reappears when the mesh re-decodes). bool isMeshOwned() const { return mIsMeshOwned; } @@ -122,6 +123,14 @@ class LLLocalGLTFMaterialMgr : public LLSingleton S32 addUnit(const std::string& filename, bool mesh_owned); protected: S32 addUnitInternal(const std::string& filename, LLUUID& outID, bool mesh_owned = false); // file can hold multiple materials + // Re-derive a file's unit/alias structure after a live edit: import-time dedup + // collapses content-identical materials onto one unit (the other binding names + // become aliases), and an edit can split such a group, merge one, or add/remove + // materials outright. Surviving units keep their tracking/world ids; diverged or + // new indices get fresh units; removed indices drop theirs; every alias is + // rebuilt. Returns true when the file's name -> world id mapping changed (the + // caller re-binds local-mesh faces off that). + bool regroupFileMaterials(const std::string& filename, bool mesh_owned); public: void delUnit(LLUUID tracking_id); // The lookups below resolve within ONE ownership class: a user-loaded set and a diff --git a/indra/newview/lllocalmesh.cpp b/indra/newview/lllocalmesh.cpp index f8f989c6d4..f5c041cb31 100644 --- a/indra/newview/lllocalmesh.cpp +++ b/indra/newview/lllocalmesh.cpp @@ -596,15 +596,21 @@ bool LLLocalMesh::ingestScene(LLModelLoader::scene& scene) } } } - else if (mFormat == FMT_GLTF && !gltf_mat_by_name.empty()) + else if (mFormat == FMT_GLTF) { for (S32 fi = 0; fi < (S32)faces.size() && fi < (S32)mdl->mMaterialList.size(); ++fi) { + LLLocalMeshFaceMaterial& fm = part.mFaceMaterials[fi]; + // Keep the binding name even when it doesn't resolve right now: + // a live edit of the material file regroups its units (see + // LLLocalGLTFMaterialMgr), and rebindFaceMaterials() re-resolves + // faces by this name. + fm.mMaterialName = mdl->mMaterialList[fi]; std::map::const_iterator it = - gltf_mat_by_name.find(mdl->mMaterialList[fi]); + gltf_mat_by_name.find(fm.mMaterialName); if (it != gltf_mat_by_name.end()) { - part.mFaceMaterials[fi].mRenderMaterialID = it->second; + fm.mRenderMaterialID = it->second; } } } @@ -862,6 +868,72 @@ void LLLocalMeshMgr::despawnObjectsInRegion(LLViewerRegion* regionp) } } +void LLLocalMeshMgr::rebindFaceMaterials(const std::string& filename) +{ + const LLUUID tracking_id = getUnitID(filename); + LLLocalMesh* unit = tracking_id.notNull() ? getUnit(tracking_id) : nullptr; + if (!unit || !unit->getValid()) + { + return; // no loaded mesh binds to this file + } + + // The new name -> world id mapping, with the same mesh-owned-first/user- + // fallback preference the ingest-time bind uses. + LLLocalGLTFMaterialMgr* mgr = LLLocalGLTFMaterialMgr::getInstance(); + std::map by_name; + mgr->getWorldIDsByName(filename, by_name, /*mesh_owned=*/true); + if (by_name.empty()) + { + mgr->getWorldIDsByName(filename, by_name, /*mesh_owned=*/false); + } + + for (size_t p = 0; p < unit->mParts.size(); ++p) + { + LLLocalMeshPart& part = unit->mParts[p]; + for (size_t f = 0; f < part.mFaceMaterials.size(); ++f) + { + LLLocalMeshFaceMaterial& fm = part.mFaceMaterials[f]; + if (fm.mMaterialName.empty()) + { + continue; // not a glTF-bound face + } + std::map::const_iterator it = by_name.find(fm.mMaterialName); + const LLUUID new_id = (it != by_name.end()) ? it->second : LLUUID(); + if (new_id == fm.mRenderMaterialID) + { + continue; + } + const LLUUID old_id = fm.mRenderMaterialID; + fm.mRenderMaterialID = new_id; // future hot-swaps diff against the new import + + for (auto& entry : mSpawnedCopies) + { + const SpawnedCopy& copy = entry.second; + if (copy.mTrackingID != tracking_id || p >= copy.mPrims.size()) + { + continue; + } + LLViewerObject* o = copy.mPrims[p].get(); + LLVOVolume* vol = (o && !o->isDead()) ? dynamic_cast(o) : nullptr; + if (!vol || f >= (size_t)vol->getNumTEs()) + { + continue; + } + if (vol->getRenderMaterialID((U8)f) != old_id) + { + continue; // the user re-materialed this face in-world; leave it + } + if (new_id.notNull()) + { + vol->setHasRenderMaterialParams(true); // client-only: no server echo sets it + } + vol->setRenderMaterialID((S32)f, new_id, false, true); + vol->markForUpdate(); + } + } + } +} + LLVOAvatar* LLLocalMeshMgr::getPreviewAvatar(bool run_stand_anim) { if ((mPreviewAvatar.isNull() || mPreviewAvatar->isDead()) && gAgent.getRegion()) diff --git a/indra/newview/lllocalmesh.h b/indra/newview/lllocalmesh.h index 2d7f84fb28..09adb58999 100644 --- a/indra/newview/lllocalmesh.h +++ b/indra/newview/lllocalmesh.h @@ -73,10 +73,11 @@ struct LLLocalMeshPreSwapSnapshot; // hot-swap diff-restore state (lllocalmesh.c // world id applied as the face's render material. struct LLLocalMeshFaceMaterial { - LLUUID mDiffuseID; // local-bitmap world id (Blinn-Phong), or null - LLColor4 mDiffuseColor = LLColor4::white; - bool mFullbright = false; - LLUUID mRenderMaterialID; // local-gltf world id (glTF) -> face render material, or null + LLUUID mDiffuseID; // local-bitmap world id (Blinn-Phong), or null + LLColor4 mDiffuseColor = LLColor4::white; + bool mFullbright = false; + LLUUID mRenderMaterialID; // local-gltf world id (glTF) -> face render material, or null + std::string mMaterialName; // glTF binding name, to re-resolve after a material-file regroup }; struct LLLocalMeshPart @@ -338,6 +339,13 @@ class LLLocalMeshMgr : public LLSingleton // re-creates them in the current region. void despawnObjectsInRegion(LLViewerRegion* regionp); + // A local material file's name -> world id mapping changed (live-edit regroup + // in LLLocalGLTFMaterialMgr): re-resolve this mesh file's stored face + // materials by their binding names and re-point spawned faces at the new ids. + // Faces the user re-materialed in-world are left alone -- only faces still + // showing the previously imported id move (the hot-swap diff-restore rule). + void rebindFaceMaterials(const std::string& filename); + private: LLUUID addUnitInternal(const std::string& filename, bool include_joints = false); void despawnUnit(const LLUUID& tracking_id); From 1b2fe34742f6583f075481e20bb207b7fe386ea5 Mon Sep 17 00:00:00 2001 From: Rye Date: Wed, 10 Jun 2026 01:21:03 -0400 Subject: [PATCH 18/18] Local Assets: address PR #298 review - gate the ObjectSpinUpdate send on isLocalOnly: handleHoverActive re-sets mSpinGrabbing from the mask every hover, so startSpin()'s early-out alone did not keep local previews out of the spin-update block - make repeat-duplicate work for previews: the local selectDuplicate branch now mirrors the sim path's mDuplicated/mDuplicatePos/mDuplicateRot bookkeeping (when not select_copy), and repeatDuplicate() routes the copy-in-place through duplicatePreview for an all-local selection so the shared move-by-delta chain behaves like the sim path - check the final rename in LLLocalAssetPaths::writeToDisk and remove the temp file on failure (the old file stays as the persisted state) - document why the no-mainloop-queue fallback in LLModelLoader keeps using the idle list: the viewer always has the queue (gMainloopWork is a global), and invoking loadModelCallback() inline would deadlock -- it waits for this thread to stop, then deletes the loader Co-Authored-By: Claude Fable 5 --- indra/llprimitive/llmodelloader.cpp | 7 ++++- indra/newview/lllocalassetpaths.cpp | 7 ++++- indra/newview/llselectmgr.cpp | 41 ++++++++++++++++++++++++++--- indra/newview/lltoolgrab.cpp | 26 +++++++++++------- 4 files changed, 65 insertions(+), 16 deletions(-) diff --git a/indra/llprimitive/llmodelloader.cpp b/indra/llprimitive/llmodelloader.cpp index 8e861a0747..638cb765f5 100644 --- a/indra/llprimitive/llmodelloader.cpp +++ b/indra/llprimitive/llmodelloader.cpp @@ -203,7 +203,12 @@ void LLModelLoader::run() } else { - // No main-loop queue (unit tests / headless): single-threaded, safe. + // No main-loop queue registered. The viewer always has one (a global, + // gMainloopWork), so this branch can only run in a binary with no main + // loop concurrently using LLCallbackList -- where the idle list is the + // only delivery left. loadModelCallback() cannot be invoked inline + // here: it blocks until this thread is stopped and then deletes the + // loader, which would deadlock run(). doOnIdleOneTime(boost::bind(&LLModelLoader::loadModelCallback, this)); } } diff --git a/indra/newview/lllocalassetpaths.cpp b/indra/newview/lllocalassetpaths.cpp index ceba8a1c17..bd9c8dacb9 100644 --- a/indra/newview/lllocalassetpaths.cpp +++ b/indra/newview/lllocalassetpaths.cpp @@ -112,7 +112,12 @@ void LLLocalAssetPaths::writeToDisk() const LLFile::remove(tmp_path); return; } - LLFile::rename(tmp_path, path); + if (LLFile::rename(tmp_path, path) != 0) + { + // rename already warned; don't leave the temp file behind, and keep the + // old (still intact) file as the persisted state + LLFile::remove(tmp_path); + } } void LLLocalAssetPaths::removePath(EType type, const std::string& path) diff --git a/indra/newview/llselectmgr.cpp b/indra/newview/llselectmgr.cpp index 044cc7500d..c35130d22a 100644 --- a/indra/newview/llselectmgr.cpp +++ b/indra/newview/llselectmgr.cpp @@ -4734,6 +4734,19 @@ void LLSelectMgr::selectDuplicate(const LLVector3& offset, bool select_copy) selectObjectAndFamily(new_root); } } + else if (!select_copy) + { + // Mirror the sim path's bookkeeping below so repeatDuplicate() + // recognizes these as duplicated and chains the copy/move sequence. + for (LLObjectSelection::root_iterator it = getSelection()->root_begin(); + it != getSelection()->root_end(); ++it) + { + LLSelectNode* node = *it; + node->mDuplicated = true; + node->mDuplicatePos = node->getObject()->getPositionGlobal(); + node->mDuplicateRot = node->getObject()->getRotation(); + } + } return; } @@ -4792,12 +4805,32 @@ void LLSelectMgr::repeatDuplicate() } // duplicate objects in place - LLDuplicateData data; + if (selectionAllLocalPreview(mSelectedObjects)) + { + // Client-only local previews: copy each in place locally -- the gated + // ObjectDuplicate send below is swallowed for an all-local selection + // (see selectDuplicate). The shared move-by-delta loop below then + // advances the selection exactly like the sim path. + std::vector> src_roots; + for (LLObjectSelection::root_iterator iter = getSelection()->root_begin(); + iter != getSelection()->root_end(); ++iter) + { + src_roots.emplace_back((*iter)->getObject()); + } + for (const LLPointer& src : src_roots) + { + LLLocalMeshMgr::getInstance()->duplicatePreview(src.get(), LLVector3::zero); + } + } + else + { + LLDuplicateData data; - data.offset = LLVector3::zero; - data.flags = 0x0; + data.offset = LLVector3::zero; + data.flags = 0x0; - sendListToRegions("ObjectDuplicate", packDuplicateHeader, packDuplicate, logNoOp, &data, SEND_ONLY_ROOTS); + sendListToRegions("ObjectDuplicate", packDuplicateHeader, packDuplicate, logNoOp, &data, SEND_ONLY_ROOTS); + } // move current selection based on delta from duplication position and update duplication position for (LLObjectSelection::root_iterator iter = getSelection()->root_begin(); diff --git a/indra/newview/lltoolgrab.cpp b/indra/newview/lltoolgrab.cpp index 5f80d67f85..44bf95c9ee 100644 --- a/indra/newview/lltoolgrab.cpp +++ b/indra/newview/lltoolgrab.cpp @@ -599,16 +599,22 @@ void LLToolGrabBase::handleHoverActive(S32 x, S32 y, MASK mask) mSpinRotation = mSpinRotation * rotation_around_vertical; mSpinRotation = mSpinRotation * rotation_around_left; - // TODO: Throttle these - LLMessageSystem *msg = gMessageSystem; - msg->newMessageFast(_PREHASH_ObjectSpinUpdate); - msg->nextBlockFast(_PREHASH_AgentData); - msg->addUUIDFast(_PREHASH_AgentID, gAgent.getID() ); - msg->addUUIDFast(_PREHASH_SessionID, gAgent.getSessionID()); - msg->nextBlockFast(_PREHASH_ObjectData); - msg->addUUIDFast(_PREHASH_ObjectID, objectp->getID() ); - msg->addQuatFast(_PREHASH_Rotation, mSpinRotation ); - msg->sendMessage( objectp->getRegion()->getHost() ); + // Client-only local previews have no sim object to spin. The toggle + // above re-sets mSpinGrabbing from the mask every hover, so + // startSpin()'s early-out alone doesn't keep us out of this block. + if (!objectp->isLocalOnly()) + { + // TODO: Throttle these + LLMessageSystem *msg = gMessageSystem; + msg->newMessageFast(_PREHASH_ObjectSpinUpdate); + msg->nextBlockFast(_PREHASH_AgentData); + msg->addUUIDFast(_PREHASH_AgentID, gAgent.getID() ); + msg->addUUIDFast(_PREHASH_SessionID, gAgent.getSessionID()); + msg->nextBlockFast(_PREHASH_ObjectData); + msg->addUUIDFast(_PREHASH_ObjectID, objectp->getID() ); + msg->addQuatFast(_PREHASH_Rotation, mSpinRotation ); + msg->sendMessage( objectp->getRegion()->getHost() ); + } } else {