feat(command-queue): apply global asset changes to all loaded files (#12954) cfd611f559

Co-authored-by: David Skuza <david@rive.app>
diff --git a/.rive_head b/.rive_head
index 493ef2f..625d3cb 100644
--- a/.rive_head
+++ b/.rive_head
@@ -1 +1 @@
-11ef1543e1e70032221a9ff631cf06df2292a2d8
+cfd611f559c885a535c5c322eafc8a4ae0801d13
diff --git a/include/rive/command_queue.hpp b/include/rive/command_queue.hpp
index dd9fc74..f0615e7 100644
--- a/include/rive/command_queue.hpp
+++ b/include/rive/command_queue.hpp
@@ -475,18 +475,93 @@
 
     void deleteFile(FileHandle, uint64_t requestId = 0);
 
+    /**
+     * Registers a global image asset that provides the contents for any file's
+     * out-of-band image asset with the same unique name.
+     *
+     * The change reaches files that are already open as well as files loaded
+     * afterward. Registering a name that is already registered replaces its
+     * resource everywhere.
+     *
+     * Assets with embedded contents and assets claimed by a host-supplied file
+     * asset loader are never overridden. Reports an error on the matching
+     * asset listener if the handle is invalid.
+     *
+     * @param name The unique asset name to register.
+     * @param handle The decoded image resource to provide.
+     * @param requestId The request identifier used for an error callback.
+     */
     void addGlobalImageAsset(std::string name,
-                             RenderImageHandle,
+                             RenderImageHandle handle,
                              uint64_t requestId = 0);
+    /**
+     * Registers a global font asset that provides the contents for any file's
+     * out-of-band font asset with the same unique name.
+     *
+     * The change reaches files that are already open as well as files loaded
+     * afterward. Registering a name that is already registered replaces its
+     * resource everywhere.
+     *
+     * Assets with embedded contents and assets claimed by a host-supplied file
+     * asset loader are never overridden. Reports an error on the matching
+     * asset listener if the handle is invalid.
+     *
+     * @param name The unique asset name to register.
+     * @param handle The decoded font resource to provide.
+     * @param requestId The request identifier used for an error callback.
+     */
     void addGlobalFontAsset(std::string name,
-                            FontHandle,
+                            FontHandle handle,
                             uint64_t requestId = 0);
+    /**
+     * Registers a global audio asset that provides the contents for any file's
+     * out-of-band audio asset with the same unique name.
+     *
+     * The change reaches files that are already open as well as files loaded
+     * afterward. Registering a name that is already registered replaces its
+     * resource everywhere.
+     *
+     * Assets with embedded contents and assets claimed by a host-supplied file
+     * asset loader are never overridden. Reports an error on the matching
+     * asset listener if the handle is invalid.
+     *
+     * @param name The unique asset name to register.
+     * @param handle The decoded audio resource to provide.
+     * @param requestId The request identifier used for an error callback.
+     */
     void addGlobalAudioAsset(std::string name,
-                             AudioSourceHandle,
+                             AudioSourceHandle handle,
                              uint64_t requestId = 0);
 
+    /**
+     * Unregisters the global image asset with the given unique name, clearing
+     * it from every open file it was applied to. Deleting the backing image
+     * resource has the same effect for every name that resource was registered
+     * under.
+     *
+     * @param name The unique asset name to unregister.
+     * @param requestId The request identifier.
+     */
     void removeGlobalImageAsset(std::string name, uint64_t requestId = 0);
+    /**
+     * Unregisters the global font asset with the given unique name, clearing it
+     * from every open file it was applied to. Deleting the backing font
+     * resource has the same effect for every name that resource was registered
+     * under.
+     *
+     * @param name The unique asset name to unregister.
+     * @param requestId The request identifier.
+     */
     void removeGlobalFontAsset(std::string name, uint64_t requestId = 0);
+    /**
+     * Unregisters the global audio asset with the given unique name, clearing
+     * it from every open file it was applied to. Deleting the backing audio
+     * resource has the same effect for every name that resource was registered
+     * under.
+     *
+     * @param name The unique asset name to unregister.
+     * @param requestId The request identifier.
+     */
     void removeGlobalAudioAsset(std::string name, uint64_t requestId = 0);
 
     ArtboardHandle instantiateArtboardNamed(
diff --git a/include/rive/command_server.hpp b/include/rive/command_server.hpp
index 23d2591..6c1fe17 100644
--- a/include/rive/command_server.hpp
+++ b/include/rive/command_server.hpp
@@ -16,13 +16,28 @@
 namespace rive
 {
 class ScriptingContext;
+class GlobalAssetRegistry;
+
 // Server-side worker that executes commands from a CommandQueue.
 class CommandServer
 {
 public:
-    CommandServer(rcp<CommandQueue>,
-                  Factory*,
-                  rcp<rive::FileAssetLoader> = nullptr);
+    /**
+     * Creates the server that executes commands from commandQueue.
+     *
+     * @param commandQueue The queue from which commands are read.
+     * @param factory The factory used to create runtime resources.
+     * @param internalFileAssetLoader An optional FileAssetLoader for loading
+     * out-of-band asset contents without using command-queue global assets.
+     * Provide a loader that recognizes and loads the assets it owns, returning
+     * true from FileAssetLoader::loadContents for those assets. This is
+     * typically used by an embedder that already has its own asset-loading
+     * path. Leave it null when out-of-band images, audio, and fonts should be
+     * handled through the command queue.
+     */
+    CommandServer(rcp<CommandQueue> commandQueue,
+                  Factory* factory,
+                  rcp<rive::FileAssetLoader> internalFileAssetLoader = nullptr);
     virtual ~CommandServer();
 
     Factory* factory() const { return m_factory; }
@@ -249,6 +264,7 @@
     std::unordered_map<DrawKey, CommandServerDrawCallback> m_uniqueDraws;
 
     class CommandFileAssetLoader;
-    rcp<CommandFileAssetLoader> m_fileAssetLoader;
+    rcp<GlobalAssetRegistry> m_globalAssetRegistry;
+    rcp<FileAssetLoader> m_internalFileAssetLoader;
 };
 }; // namespace rive
diff --git a/src/command_server.cpp b/src/command_server.cpp
index 05c52a6..178b561 100644
--- a/src/command_server.cpp
+++ b/src/command_server.cpp
@@ -22,176 +22,422 @@
 namespace rive
 {
 
+/**
+ * Stores registered resource handles and matching assets in loaded files for
+ * one file asset type.
+ *
+ * Entries are keyed by unique asset name. Each entry contains the one resource
+ * handle registered for that name and a map of the loaded files containing a
+ * matching out-of-band file asset. Registering a resource and applying it to
+ * those file assets are intentionally separate operations.
+ */
+template <typename TResourceHandle, typename TFileAsset>
+class TypedGlobalAssetRegistry
+{
+public:
+    /** Adds a matching file asset after its file imports successfully. */
+    void addFileAsset(FileHandle fileHandle, TFileAsset* asset)
+    {
+        m_entries[asset->uniqueName()].assetsByFileHandle[fileHandle] =
+            ref_rcp(asset);
+    }
+
+    /** Removes a file asset when its containing file is deleted. */
+    void removeFileAsset(FileHandle fileHandle, const std::string& name)
+    {
+        auto itr = m_entries.find(name);
+        if (itr == m_entries.end())
+        {
+            return;
+        }
+        itr->second.assetsByFileHandle.erase(fileHandle);
+        eraseIfUnused(itr);
+    }
+
+    /** Returns the resource registered for a name, or the null handle. */
+    TResourceHandle getResourceHandle(const std::string& name) const
+    {
+        auto itr = m_entries.find(name);
+        return itr == m_entries.end() ? RIVE_NULL_HANDLE
+                                      : itr->second.resourceHandle;
+    }
+
+    /** Sets the resource for a name without applying it to file assets. */
+    void setResource(const std::string& name, TResourceHandle handle)
+    {
+        m_entries[name].resourceHandle = handle;
+    }
+
+    /** Unregisters the resource for a name without clearing file assets. */
+    void unregisterResourceByName(const std::string& name)
+    {
+        auto itr = m_entries.find(name);
+        if (itr == m_entries.end())
+        {
+            return;
+        }
+        itr->second.resourceHandle = RIVE_NULL_HANDLE;
+        eraseIfUnused(itr);
+    }
+
+    /**
+     * Unregisters every name backed by a resource handle.
+     *
+     * Returns the names that still have loaded file assets and therefore need
+     * to have the null resource applied separately.
+     */
+    std::vector<std::string> unregisterResourceByHandle(TResourceHandle handle)
+    {
+        std::vector<std::string> names;
+        for (auto itr = m_entries.begin(); itr != m_entries.end();)
+        {
+            if (itr->second.resourceHandle != handle)
+            {
+                ++itr;
+                continue;
+            }
+
+            itr->second.resourceHandle = RIVE_NULL_HANDLE;
+            if (itr->second.empty())
+            {
+                itr = m_entries.erase(itr);
+            }
+            else
+            {
+                names.push_back(itr->first);
+                ++itr;
+            }
+        }
+        return names;
+    }
+
+    /** Applies an operation to every loaded file asset matching a name. */
+    template <typename Apply>
+    void applyToFileAssets(const std::string& name, Apply&& apply) const
+    {
+        auto itr = m_entries.find(name);
+        if (itr == m_entries.end())
+        {
+            return;
+        }
+
+        for (const auto& fileAsset : itr->second.assetsByFileHandle)
+        {
+            apply(*fileAsset.second);
+        }
+    }
+
+private:
+    struct Entry
+    {
+        TResourceHandle resourceHandle = RIVE_NULL_HANDLE;
+        std::unordered_map<FileHandle, rcp<TFileAsset>> assetsByFileHandle;
+
+        bool empty() const
+        {
+            return resourceHandle == RIVE_NULL_HANDLE &&
+                   assetsByFileHandle.empty();
+        }
+    };
+
+    using Entries = std::unordered_map<std::string, Entry>;
+
+    void eraseIfUnused(typename Entries::iterator itr)
+    {
+        if (itr->second.empty())
+        {
+            m_entries.erase(itr);
+        }
+    }
+
+    Entries m_entries;
+};
+
+/**
+ * Maintains global resource registrations and the loaded out-of-band file
+ * assets that consume them.
+ *
+ * One typed registry is maintained for images, audio sources, and fonts. This
+ * class adds and removes file assets, registers and unregisters resource
+ * handles, resolves them from read-only references to CommandServer's resource
+ * maps, and applies the resources to matching loaded file assets.
+ *
+ * It does not own decoded resources. CommandServer owns those resources.
+ *
+ * Resource registration and application remain separate so callers control
+ * when each operation occurs.
+ */
+class GlobalAssetRegistry : public RefCnt<GlobalAssetRegistry>
+{
+public:
+    GlobalAssetRegistry(
+        const std::unordered_map<RenderImageHandle, rcp<RenderImage>>& images,
+        const std::unordered_map<AudioSourceHandle, rcp<AudioSource>>&
+            audioSources,
+        const std::unordered_map<FontHandle, rcp<Font>>& fonts) :
+        m_images(images), m_audioSources(audioSources), m_fonts(fonts)
+    {}
+
+    /** Adds successfully imported out-of-band file assets to the registry. */
+    void addFileAssets(FileHandle fileHandle, Span<const rcp<FileAsset>> assets)
+    {
+        for (const auto& asset : assets)
+        {
+            if (asset->is<ImageAsset>())
+            {
+                m_imageRegistry.addFileAsset(fileHandle,
+                                             asset->as<ImageAsset>());
+            }
+            else if (asset->is<AudioAsset>())
+            {
+                m_audioRegistry.addFileAsset(fileHandle,
+                                             asset->as<AudioAsset>());
+            }
+            else if (asset->is<FontAsset>())
+            {
+                m_fontRegistry.addFileAsset(fileHandle, asset->as<FontAsset>());
+            }
+        }
+    }
+
+    /** Applies registered resources to newly imported file assets. */
+    void applyResourcesToFileAssets(Span<const rcp<FileAsset>> fileAssets) const
+    {
+        for (const auto& asset : fileAssets)
+        {
+            if (asset->is<ImageAsset>())
+            {
+                auto handle = renderImageHandle(asset->uniqueName());
+                asset->as<ImageAsset>()->renderImage(
+                    getResource(m_images, handle));
+            }
+            else if (asset->is<AudioAsset>())
+            {
+                auto handle = audioSourceHandle(asset->uniqueName());
+                asset->as<AudioAsset>()->audioSource(
+                    getResource(m_audioSources, handle));
+            }
+            else if (asset->is<FontAsset>())
+            {
+                auto handle = fontHandle(asset->uniqueName());
+                asset->as<FontAsset>()->font(getResource(m_fonts, handle));
+            }
+        }
+    }
+
+    RenderImageHandle renderImageHandle(const std::string& name) const
+    {
+        return m_imageRegistry.getResourceHandle(name);
+    }
+
+    AudioSourceHandle audioSourceHandle(const std::string& name) const
+    {
+        return m_audioRegistry.getResourceHandle(name);
+    }
+
+    FontHandle fontHandle(const std::string& name) const
+    {
+        return m_fontRegistry.getResourceHandle(name);
+    }
+
+    /** Sets the resource for a name. The caller applies it separately. */
+    void setRenderImage(const std::string& name, RenderImageHandle handle)
+    {
+        m_imageRegistry.setResource(name, handle);
+    }
+
+    void setAudioSource(const std::string& name, AudioSourceHandle handle)
+    {
+        m_audioRegistry.setResource(name, handle);
+    }
+
+    void setFont(const std::string& name, FontHandle handle)
+    {
+        m_fontRegistry.setResource(name, handle);
+    }
+
+    /**
+     * Unregisters the resource for a name. The caller applies the null resource
+     * separately.
+     */
+    void removeRenderImage(const std::string& name)
+    {
+        m_imageRegistry.unregisterResourceByName(name);
+    }
+
+    void removeAudioSource(const std::string& name)
+    {
+        m_audioRegistry.unregisterResourceByName(name);
+    }
+
+    void removeFont(const std::string& name)
+    {
+        m_fontRegistry.unregisterResourceByName(name);
+    }
+
+    void applyRenderImage(const std::string& name,
+                          const rcp<RenderImage>& image) const
+    {
+        m_imageRegistry.applyToFileAssets(name, [&image](ImageAsset& asset) {
+            asset.renderImage(image);
+        });
+    }
+
+    void applyAudioSource(const std::string& name,
+                          const rcp<AudioSource>& audioSource) const
+    {
+        m_audioRegistry.applyToFileAssets(name,
+                                          [&audioSource](AudioAsset& asset) {
+                                              asset.audioSource(audioSource);
+                                          });
+    }
+
+    void applyFont(const std::string& name, const rcp<Font>& font) const
+    {
+        m_fontRegistry.applyToFileAssets(name, [&font](FontAsset& asset) {
+            asset.font(font);
+        });
+    }
+
+    /**
+     * Unregisters every name backed by a deleted resource handle. The caller
+     * applies each returned name separately.
+     */
+    std::vector<std::string> removeRenderImage(RenderImageHandle handle)
+    {
+        return m_imageRegistry.unregisterResourceByHandle(handle);
+    }
+
+    std::vector<std::string> removeAudioSource(AudioSourceHandle handle)
+    {
+        return m_audioRegistry.unregisterResourceByHandle(handle);
+    }
+
+    std::vector<std::string> removeFont(FontHandle handle)
+    {
+        return m_fontRegistry.unregisterResourceByHandle(handle);
+    }
+
+    /**
+     * Removes a deleted file from each registry entry named by an asset in that
+     * loaded Rive file. No file-to-assets reverse registry is needed.
+     */
+    void removeFileAssets(FileHandle fileHandle,
+                          Span<const rcp<FileAsset>> assets)
+    {
+        for (const auto& asset : assets)
+        {
+            if (asset->is<ImageAsset>())
+            {
+                m_imageRegistry.removeFileAsset(fileHandle,
+                                                asset->uniqueName());
+            }
+            else if (asset->is<AudioAsset>())
+            {
+                m_audioRegistry.removeFileAsset(fileHandle,
+                                                asset->uniqueName());
+            }
+            else if (asset->is<FontAsset>())
+            {
+                m_fontRegistry.removeFileAsset(fileHandle, asset->uniqueName());
+            }
+        }
+    }
+
+#ifdef TESTING
+    RenderImageHandle testing_imageNamed(const std::string& name)
+    {
+        return renderImageHandle(name);
+    }
+
+    AudioSourceHandle testing_audioNamed(const std::string& name)
+    {
+        return audioSourceHandle(name);
+    }
+
+    FontHandle testing_fontNamed(const std::string& name)
+    {
+        return fontHandle(name);
+    }
+#endif
+
+private:
+    template <typename TResourceHandle, typename TResource>
+    static rcp<TResource> getResource(
+        const std::unordered_map<TResourceHandle, rcp<TResource>>& resources,
+        TResourceHandle handle)
+    {
+        auto itr = resources.find(handle);
+        return itr == resources.end() ? nullptr : itr->second;
+    }
+
+    const std::unordered_map<RenderImageHandle, rcp<RenderImage>>& m_images;
+    const std::unordered_map<AudioSourceHandle, rcp<AudioSource>>&
+        m_audioSources;
+    const std::unordered_map<FontHandle, rcp<Font>>& m_fonts;
+    TypedGlobalAssetRegistry<RenderImageHandle, ImageAsset> m_imageRegistry;
+    TypedGlobalAssetRegistry<AudioSourceHandle, AudioAsset> m_audioRegistry;
+    TypedGlobalAssetRegistry<FontHandle, FontAsset> m_fontRegistry;
+};
+
+/**
+ * The file asset loader for one command-queue import.
+ *
+ * It first delegates to the optional FileAssetLoader supplied to CommandServer.
+ * When that loader returns true, it is loading or has loaded the asset outside
+ * the command queue, so the asset is not registered for a global resource.
+ * Otherwise, eligible out-of-band assets are retained locally so they can be
+ * registered after a successful import.
+ */
 class CommandServer::CommandFileAssetLoader : public FileAssetLoader
 {
 public:
-    CommandFileAssetLoader(CommandServer* server,
-                           rcp<rive::FileAssetLoader> internalLoader) :
-        m_server(server), m_internalLoader(internalLoader)
+    CommandFileAssetLoader(rcp<FileAssetLoader> internalLoader) :
+        m_internalLoader(std::move(internalLoader))
     {}
 
-    virtual bool loadContents(FileAsset& asset,
-                              Span<const uint8_t> inBandBytes,
-                              Factory* factory) override
+    bool loadContents(FileAsset& asset,
+                      Span<const uint8_t> inBandBytes,
+                      Factory* factory) override
     {
-        if (m_internalLoader)
+        if (m_internalLoader &&
+            m_internalLoader->loadContents(asset, inBandBytes, factory))
         {
-            if (m_internalLoader->loadContents(asset, inBandBytes, factory))
-                return true;
+            return true;
         }
-        if (asset.is<ImageAsset>())
+        if (!inBandBytes.empty())
         {
-            // No need for another if because as just asserts the above
-            // condition anyway
-            auto imageAsset = asset.as<ImageAsset>();
-            auto itr = m_imageAssets.find(asset.uniqueName());
-            if (itr != m_imageAssets.end())
-            {
-                auto image = m_server->getImage(itr->second);
-                if (image)
-                {
-                    imageAsset->renderImage(ref_rcp(image));
-                    return true;
-                }
-                return false;
-            }
+            return false;
         }
-
-        else if (asset.is<AudioAsset>())
+        if (asset.is<ImageAsset>() || asset.is<AudioAsset>() ||
+            asset.is<FontAsset>())
         {
-            auto audioAsset = asset.as<AudioAsset>();
-            auto itr = m_audioAssets.find(asset.uniqueName());
-            if (itr != m_audioAssets.end())
-            {
-                auto audioSource = m_server->getAudioSource(itr->second);
-                if (audioSource)
-                {
-                    audioAsset->audioSource(ref_rcp(audioSource));
-                    return true;
-                }
-                return false;
-            }
+            m_fileAssets.push_back(ref_rcp(&asset));
+            return false;
         }
-        else if (asset.is<FontAsset>())
-        {
-            auto fontAsset = asset.as<FontAsset>();
-            auto itr = m_fontAssets.find(asset.uniqueName());
-            if (itr != m_fontAssets.end())
-            {
-                auto font = m_server->getFont(itr->second);
-                if (font)
-                {
-                    fontAsset->font(ref_rcp(font));
-                    return true;
-                }
-                return false;
-            }
-        }
-        else if (asset.is<TextAsset>() || asset.is<ScriptModuleAsset>() ||
-                 asset.is<BlobAsset>() || asset.is<ManifestAsset>())
+        if (asset.is<TextAsset>() || asset.is<ScriptModuleAsset>() ||
+            asset.is<BlobAsset>() || asset.is<ManifestAsset>())
         {
             // These assets cannot be registered externally with the command
             // server. Returning false lets the importer decode their in-band
             // contents. TextAsset includes ScriptAsset and ShaderAsset.
             return false;
         }
-        else
-        {
-            fprintf(stderr,
-                    "ERROR: CommandFileAssetLoader::loadContents - Unsupported"
-                    " asset type for asset: '%s'\n",
-                    asset.uniqueFilename().c_str());
-            return false;
-        }
-
+        fprintf(stderr,
+                "ERROR: CommandFileAssetLoader::loadContents - Unsupported"
+                " asset type for asset: '%s'\n",
+                asset.uniqueFilename().c_str());
         return false;
     }
 
-    void addRenderImage(std::string name, RenderImageHandle handle)
+    Span<const rcp<FileAsset>> fileAssets() const
     {
-        m_imageAssets[name] = handle;
+        return {m_fileAssets.data(), m_fileAssets.size()};
     }
 
-    void addAudioSource(std::string name, AudioSourceHandle handle)
-    {
-        m_audioAssets[name] = handle;
-    }
-
-    void addFont(std::string name, FontHandle handle)
-    {
-        m_fontAssets[name] = handle;
-    }
-
-    void removeRenderImage(RenderImageHandle handle)
-    {
-        using ItrType = std::pair<std::string, RenderImageHandle>;
-        auto itr = std::find_if(
-            m_imageAssets.begin(),
-            m_imageAssets.end(),
-            [handle](const ItrType& p) { return p.second == handle; });
-        if (itr != m_imageAssets.end())
-            m_imageAssets.erase(itr);
-    }
-
-    void removeAudioSource(AudioSourceHandle handle)
-    {
-        using ItrType = std::pair<std::string, AudioSourceHandle>;
-        auto itr = std::find_if(
-            m_audioAssets.begin(),
-            m_audioAssets.end(),
-            [handle](const ItrType& p) { return p.second == handle; });
-        if (itr != m_audioAssets.end())
-            m_audioAssets.erase(itr);
-    }
-
-    void removeFont(FontHandle handle)
-    {
-        using ItrType = std::pair<std::string, FontHandle>;
-        auto itr = std::find_if(
-            m_fontAssets.begin(),
-            m_fontAssets.end(),
-            [handle](const ItrType& p) { return p.second == handle; });
-        if (itr != m_fontAssets.end())
-            m_fontAssets.erase(itr);
-    }
-
-    void removeRenderImage(std::string name) { m_imageAssets.erase(name); }
-    void removeAudioSource(std::string name) { m_audioAssets.erase(name); }
-    void removeFont(std::string name) { m_fontAssets.erase(name); }
-
-#ifdef TESTING
-    RenderImageHandle testing_imageNamed(std::string name)
-    {
-        auto itr = m_imageAssets.find(name);
-        if (itr != m_imageAssets.end())
-            return itr->second;
-        return nullptr;
-    }
-
-    AudioSourceHandle testing_audioNamed(std::string name)
-    {
-        auto itr = m_audioAssets.find(name);
-        if (itr != m_audioAssets.end())
-            return itr->second;
-        return nullptr;
-    }
-
-    FontHandle testing_fontNamed(std::string name)
-    {
-        auto itr = m_fontAssets.find(name);
-        if (itr != m_fontAssets.end())
-            return itr->second;
-        return nullptr;
-    }
-#endif
-
 private:
-    const CommandServer* m_server;
-
-    std::unordered_map<std::string, RenderImageHandle> m_imageAssets;
-    std::unordered_map<std::string, AudioSourceHandle> m_audioAssets;
-    std::unordered_map<std::string, FontHandle> m_fontAssets;
     rcp<FileAssetLoader> m_internalLoader;
+    std::vector<rcp<FileAsset>> m_fileAssets;
 };
 
 std::ostream& operator<<(std::ostream& os, DataType t)
@@ -248,46 +494,48 @@
 
 RenderImageHandle CommandServer::testing_globalImageNamed(std::string name)
 {
-    return m_fileAssetLoader->testing_imageNamed(name);
+    return m_globalAssetRegistry->testing_imageNamed(name);
 }
 
 AudioSourceHandle CommandServer::testing_globalAudioNamed(std::string name)
 {
-    return m_fileAssetLoader->testing_audioNamed(name);
+    return m_globalAssetRegistry->testing_audioNamed(name);
 }
 
 FontHandle CommandServer::testing_globalFontNamed(std::string name)
 {
-    return m_fileAssetLoader->testing_fontNamed(name);
+    return m_globalAssetRegistry->testing_fontNamed(name);
 }
 
 bool CommandServer::testing_globalImageContains(std::string name)
 {
-    return m_fileAssetLoader->testing_imageNamed(name) != nullptr;
+    return m_globalAssetRegistry->testing_imageNamed(name) != nullptr;
 }
 
 bool CommandServer::testing_globalAudioContains(std::string name)
 {
-    return m_fileAssetLoader->testing_audioNamed(name) != nullptr;
+    return m_globalAssetRegistry->testing_audioNamed(name) != nullptr;
 }
 
 bool CommandServer::testing_globalFontContains(std::string name)
 {
-    return m_fileAssetLoader->testing_fontNamed(name) != nullptr;
+    return m_globalAssetRegistry->testing_fontNamed(name) != nullptr;
 }
 
 #endif
 
-CommandServer::CommandServer(rcp<CommandQueue> commandBuffer,
-                             Factory* factory,
-                             rcp<rive::FileAssetLoader> internalLoader) :
+CommandServer::CommandServer(
+    rcp<CommandQueue> commandBuffer,
+    Factory* factory,
+    rcp<rive::FileAssetLoader> internalFileAssetLoader) :
     m_commandQueue(std::move(commandBuffer)),
     m_factory(factory),
 #ifndef NDEBUG
     m_threadID(std::this_thread::get_id()),
 #endif
-    m_fileAssetLoader(
-        make_rcp<CommandFileAssetLoader>(this, std::move(internalLoader)))
+    m_globalAssetRegistry(
+        make_rcp<GlobalAssetRegistry>(m_images, m_audioSources, m_fonts)),
+    m_internalFileAssetLoader(std::move(internalFileAssetLoader))
 {}
 
 CommandServer::~CommandServer() {}
@@ -701,6 +949,9 @@
                     scriptingContextFactory;
 #endif
                 lock.unlock();
+                auto fileAssetLoader =
+                    make_rcp<CommandFileAssetLoader>(m_internalFileAssetLoader);
+
 #if defined(WITH_RIVE_SCRIPTING) && defined(WITH_RIVE_SCRIPTING_LUAU)
                 std::cout << "Rive: Command Server Scripting Enabled.\n";
                 // Use the host-provided scripting context when supplied (e.g.
@@ -718,7 +969,7 @@
                 rcp<rive::File> file = rive::File::import(rivBytes,
                                                           m_factory,
                                                           nullptr,
-                                                          m_fileAssetLoader,
+                                                          fileAssetLoader,
                                                           vm.get());
 
 #else
@@ -730,11 +981,15 @@
                 rcp<rive::File> file = rive::File::import(rivBytes,
                                                           m_factory,
                                                           nullptr,
-                                                          m_fileAssetLoader);
+                                                          fileAssetLoader);
 #endif
 
                 if (file != nullptr)
                 {
+                    auto fileAssets = fileAssetLoader->fileAssets();
+                    m_globalAssetRegistry->addFileAssets(handle, fileAssets);
+                    m_globalAssetRegistry->applyResourcesToFileAssets(
+                        fileAssets);
                     m_fileDependencies[handle] = {};
                     m_files[handle] = file;
 
@@ -762,7 +1017,14 @@
                 commandStream >> handle;
                 commandStream >> requestId;
                 lock.unlock();
-                m_files.erase(handle);
+                auto fileItr = m_files.find(handle);
+                if (fileItr != m_files.end())
+                {
+                    m_globalAssetRegistry->removeFileAssets(
+                        handle,
+                        fileItr->second->assets());
+                    m_files.erase(fileItr);
+                }
                 auto itr = m_fileDependencies.find(handle);
                 if (itr != m_fileDependencies.end())
                 {
@@ -855,7 +1117,11 @@
                 commandStream >> requestId;
                 lock.unlock();
                 m_images.erase(handle);
-                m_fileAssetLoader->removeRenderImage(handle);
+                for (const auto& name :
+                     m_globalAssetRegistry->removeRenderImage(handle))
+                {
+                    m_globalAssetRegistry->applyRenderImage(name, nullptr);
+                }
                 std::unique_lock<std::mutex> messageLock(
                     m_commandQueue->m_messageMutex);
                 messageStream << CommandQueue::Message::imageDeleted;
@@ -1019,7 +1285,11 @@
                 commandStream >> requestId;
                 lock.unlock();
                 m_audioSources.erase(handle);
-                m_fileAssetLoader->removeAudioSource(handle);
+                for (const auto& name :
+                     m_globalAssetRegistry->removeAudioSource(handle))
+                {
+                    m_globalAssetRegistry->applyAudioSource(name, nullptr);
+                }
                 std::unique_lock<std::mutex> messageLock(
                     m_commandQueue->m_messageMutex);
                 messageStream << CommandQueue::Message::audioDeleted;
@@ -1099,7 +1369,11 @@
                 commandStream >> requestId;
                 lock.unlock();
                 m_fonts.erase(handle);
-                m_fileAssetLoader->removeFont(handle);
+                for (const auto& name :
+                     m_globalAssetRegistry->removeFont(handle))
+                {
+                    m_globalAssetRegistry->applyFont(name, nullptr);
+                }
                 std::unique_lock<std::mutex> messageLock(
                     m_commandQueue->m_messageMutex);
                 messageStream << CommandQueue::Message::fontDeleted;
@@ -4032,9 +4306,22 @@
                 commandStream >> requestId;
                 m_commandQueue->m_names >> name;
                 lock.unlock();
-                if (handle && getImage(handle) != nullptr)
+                auto image = ref_rcp(getImage(handle));
+                if (image != nullptr)
                 {
-                    m_fileAssetLoader->addRenderImage(std::move(name), handle);
+                    m_globalAssetRegistry->setRenderImage(name, handle);
+                    m_globalAssetRegistry->applyRenderImage(name, image);
+                }
+                else
+                {
+                    ErrorReporter<RenderImageHandle>(
+                        this,
+                        handle,
+                        requestId,
+                        CommandQueue::Message::imageError)
+                        << "Invalid image handle when adding global image "
+                           "asset \""
+                        << name << "\"";
                 }
                 break;
             }
@@ -4047,7 +4334,8 @@
                 commandStream >> requestId;
                 m_commandQueue->m_names >> name;
                 lock.unlock();
-                m_fileAssetLoader->removeRenderImage(std::move(name));
+                m_globalAssetRegistry->removeRenderImage(name);
+                m_globalAssetRegistry->applyRenderImage(name, nullptr);
                 break;
             }
 
@@ -4061,9 +4349,22 @@
                 commandStream >> requestId;
                 m_commandQueue->m_names >> name;
                 lock.unlock();
-                if (handle && getAudioSource(handle) != nullptr)
+                auto audioSource = ref_rcp(getAudioSource(handle));
+                if (audioSource != nullptr)
                 {
-                    m_fileAssetLoader->addAudioSource(std::move(name), handle);
+                    m_globalAssetRegistry->setAudioSource(name, handle);
+                    m_globalAssetRegistry->applyAudioSource(name, audioSource);
+                }
+                else
+                {
+                    ErrorReporter<AudioSourceHandle>(
+                        this,
+                        handle,
+                        requestId,
+                        CommandQueue::Message::audioError)
+                        << "Invalid audio handle when adding global audio "
+                           "asset \""
+                        << name << "\"";
                 }
                 break;
             }
@@ -4076,7 +4377,8 @@
                 commandStream >> requestId;
                 m_commandQueue->m_names >> name;
                 lock.unlock();
-                m_fileAssetLoader->removeAudioSource(std::move(name));
+                m_globalAssetRegistry->removeAudioSource(name);
+                m_globalAssetRegistry->applyAudioSource(name, nullptr);
                 break;
             }
 
@@ -4090,9 +4392,21 @@
                 commandStream >> requestId;
                 m_commandQueue->m_names >> name;
                 lock.unlock();
-                if (handle && getFont(handle) != nullptr)
+                auto font = ref_rcp(getFont(handle));
+                if (font != nullptr)
                 {
-                    m_fileAssetLoader->addFont(std::move(name), handle);
+                    m_globalAssetRegistry->setFont(name, handle);
+                    m_globalAssetRegistry->applyFont(name, font);
+                }
+                else
+                {
+                    ErrorReporter<FontHandle>(this,
+                                              handle,
+                                              requestId,
+                                              CommandQueue::Message::fontError)
+                        << "Invalid font handle when adding global font asset "
+                           "\""
+                        << name << "\"";
                 }
                 break;
             }
@@ -4105,7 +4419,8 @@
                 commandStream >> requestId;
                 m_commandQueue->m_names >> name;
                 lock.unlock();
-                m_fileAssetLoader->removeFont(std::move(name));
+                m_globalAssetRegistry->removeFont(name);
+                m_globalAssetRegistry->applyFont(name, nullptr);
                 break;
             }
 
diff --git a/tests/unit_tests/assets/hosted_audio_file.riv b/tests/unit_tests/assets/hosted_audio_file.riv
new file mode 100644
index 0000000..8afc639
--- /dev/null
+++ b/tests/unit_tests/assets/hosted_audio_file.riv
Binary files differ
diff --git a/tests/unit_tests/runtime/command_queue_test.cpp b/tests/unit_tests/runtime/command_queue_test.cpp
index ac60e27..9ec1277 100644
--- a/tests/unit_tests/runtime/command_queue_test.cpp
+++ b/tests/unit_tests/runtime/command_queue_test.cpp
@@ -518,7 +518,7 @@
 };
 
 static void server_thread_file_loader(rcp<CommandQueue> commandQueue,
-                                      rcp<TestAssetsFileLoader> loader)
+                                      rcp<rive::FileAssetLoader> loader)
 {
     std::unique_ptr<gpu::RenderContext> nullContext =
         RenderContextNULL::MakeContext();
@@ -7647,3 +7647,1062 @@
     commandQueue->setGlobalStateMachineListener(nullptr);
     commandQueue->disconnect();
 }
+
+TEST_CASE("registered global image asset applies to files loaded afterward",
+          "[CommandQueue]")
+{
+    auto commandQueue = make_rcp<CommandQueue>();
+    std::thread serverThread(server_thread, commandQueue);
+
+    std::ifstream imageStream("assets/batdude.png", std::ios::binary);
+    auto imageHandle = commandQueue->decodeImage(
+        std::vector<uint8_t>(std::istreambuf_iterator<char>(imageStream), {}));
+    commandQueue->addGlobalImageAsset("one-45008", imageHandle);
+
+    FileAssetsListenerCallback listener;
+    std::ifstream stream("assets/hosted_image_file.riv", std::ios::binary);
+    FileHandle fileHandle = commandQueue->loadFile(
+        std::vector<uint8_t>(std::istreambuf_iterator<char>(stream), {}),
+        &listener);
+
+    commandQueue->runOnce([fileHandle, imageHandle](CommandServer* server) {
+        auto file = server->getFile(fileHandle);
+        REQUIRE(file != nullptr);
+        CHECK(file->assets()[0]->as<ImageAsset>()->renderImage() ==
+              server->getImage(imageHandle));
+    });
+
+    commandQueue->disconnect();
+    serverThread.join();
+}
+
+TEST_CASE("addGlobalImageAsset resolves across all files", "[CommandQueue]")
+{
+    auto commandQueue = make_rcp<CommandQueue>();
+    std::thread serverThread(server_thread, commandQueue);
+
+    FileAssetsListenerCallback listener1;
+    FileAssetsListenerCallback listener2;
+
+    std::ifstream stream1("assets/hosted_image_file.riv", std::ios::binary);
+    FileHandle fileHandle1 = commandQueue->loadFile(
+        std::vector<uint8_t>(std::istreambuf_iterator<char>(stream1), {}),
+        &listener1);
+
+    std::ifstream stream2("assets/hosted_image_file.riv", std::ios::binary);
+    FileHandle fileHandle2 = commandQueue->loadFile(
+        std::vector<uint8_t>(std::istreambuf_iterator<char>(stream2), {}),
+        &listener2);
+
+    std::ifstream imageStream("assets/batdude.png", std::ios::binary);
+    auto imageHandle = commandQueue->decodeImage(
+        std::vector<uint8_t>(std::istreambuf_iterator<char>(imageStream), {}));
+
+    commandQueue->addGlobalImageAsset("one-45008", imageHandle);
+
+    commandQueue->runOnce(
+        [fileHandle1, fileHandle2, imageHandle](CommandServer* server) {
+            auto file1 = server->getFile(fileHandle1);
+            REQUIRE(file1 != nullptr);
+            CHECK(file1->assets()[0]->as<ImageAsset>()->renderImage() ==
+                  server->getImage(imageHandle));
+
+            auto file2 = server->getFile(fileHandle2);
+            REQUIRE(file2 != nullptr);
+            CHECK(file2->assets()[0]->as<ImageAsset>()->renderImage() ==
+                  server->getImage(imageHandle));
+        });
+
+    commandQueue->disconnect();
+    serverThread.join();
+}
+
+TEST_CASE("addGlobalImageAsset replaces across all files", "[CommandQueue]")
+{
+    auto commandQueue = make_rcp<CommandQueue>();
+    std::thread serverThread(server_thread, commandQueue);
+
+    FileAssetsListenerCallback listener1;
+    FileAssetsListenerCallback listener2;
+
+    std::ifstream stream1("assets/hosted_image_file.riv", std::ios::binary);
+    FileHandle fileHandle1 = commandQueue->loadFile(
+        std::vector<uint8_t>(std::istreambuf_iterator<char>(stream1), {}),
+        &listener1);
+
+    std::ifstream stream2("assets/hosted_image_file.riv", std::ios::binary);
+    FileHandle fileHandle2 = commandQueue->loadFile(
+        std::vector<uint8_t>(std::istreambuf_iterator<char>(stream2), {}),
+        &listener2);
+
+    std::ifstream imageStream1("assets/batdude.png", std::ios::binary);
+    auto imageHandle1 = commandQueue->decodeImage(
+        std::vector<uint8_t>(std::istreambuf_iterator<char>(imageStream1), {}));
+
+    commandQueue->addGlobalImageAsset("one-45008", imageHandle1);
+
+    std::ifstream imageStream2("assets/batdude.png", std::ios::binary);
+    auto imageHandle2 = commandQueue->decodeImage(
+        std::vector<uint8_t>(std::istreambuf_iterator<char>(imageStream2), {}));
+
+    commandQueue->addGlobalImageAsset("one-45008", imageHandle2);
+
+    commandQueue->runOnce(
+        [fileHandle1, fileHandle2, imageHandle2](CommandServer* server) {
+            auto file1 = server->getFile(fileHandle1);
+            REQUIRE(file1 != nullptr);
+            CHECK(file1->assets()[0]->as<ImageAsset>()->renderImage() ==
+                  server->getImage(imageHandle2));
+
+            auto file2 = server->getFile(fileHandle2);
+            REQUIRE(file2 != nullptr);
+            CHECK(file2->assets()[0]->as<ImageAsset>()->renderImage() ==
+                  server->getImage(imageHandle2));
+        });
+
+    commandQueue->disconnect();
+    serverThread.join();
+}
+
+TEST_CASE("removeGlobalImageAsset clears across all files", "[CommandQueue]")
+{
+    auto commandQueue = make_rcp<CommandQueue>();
+    std::thread serverThread(server_thread, commandQueue);
+
+    FileAssetsListenerCallback listener1;
+    FileAssetsListenerCallback listener2;
+
+    std::ifstream stream1("assets/hosted_image_file.riv", std::ios::binary);
+    FileHandle fileHandle1 = commandQueue->loadFile(
+        std::vector<uint8_t>(std::istreambuf_iterator<char>(stream1), {}),
+        &listener1);
+
+    std::ifstream stream2("assets/hosted_image_file.riv", std::ios::binary);
+    FileHandle fileHandle2 = commandQueue->loadFile(
+        std::vector<uint8_t>(std::istreambuf_iterator<char>(stream2), {}),
+        &listener2);
+
+    std::ifstream imageStream("assets/batdude.png", std::ios::binary);
+    auto imageHandle = commandQueue->decodeImage(
+        std::vector<uint8_t>(std::istreambuf_iterator<char>(imageStream), {}));
+
+    commandQueue->addGlobalImageAsset("one-45008", imageHandle);
+    commandQueue->removeGlobalImageAsset("one-45008");
+
+    commandQueue->runOnce([fileHandle1, fileHandle2](CommandServer* server) {
+        auto file1 = server->getFile(fileHandle1);
+        REQUIRE(file1 != nullptr);
+        CHECK(file1->assets()[0]->as<ImageAsset>()->renderImage() == nullptr);
+
+        auto file2 = server->getFile(fileHandle2);
+        REQUIRE(file2 != nullptr);
+        CHECK(file2->assets()[0]->as<ImageAsset>()->renderImage() == nullptr);
+    });
+
+    commandQueue->disconnect();
+    serverThread.join();
+}
+
+TEST_CASE(
+    "addGlobalImageAsset with non-matching name does not resolve on files",
+    "[CommandQueue]")
+{
+    auto commandQueue = make_rcp<CommandQueue>();
+    std::thread serverThread(server_thread, commandQueue);
+
+    FileAssetsListenerCallback listener;
+
+    std::ifstream stream("assets/hosted_image_file.riv", std::ios::binary);
+    FileHandle fileHandle = commandQueue->loadFile(
+        std::vector<uint8_t>(std::istreambuf_iterator<char>(stream), {}),
+        &listener);
+
+    std::ifstream imageStream("assets/batdude.png", std::ios::binary);
+    auto imageHandle = commandQueue->decodeImage(
+        std::vector<uint8_t>(std::istreambuf_iterator<char>(imageStream), {}));
+
+    commandQueue->addGlobalImageAsset("wrong-name", imageHandle);
+
+    commandQueue->runOnce([fileHandle](CommandServer* server) {
+        auto file = server->getFile(fileHandle);
+        REQUIRE(file != nullptr);
+        CHECK(file->assets()[0]->as<ImageAsset>()->renderImage() == nullptr);
+    });
+
+    commandQueue->disconnect();
+    serverThread.join();
+}
+
+TEST_CASE("addGlobalImageAsset with invalid handle reports error",
+          "[CommandQueue]")
+{
+    auto commandQueue = make_rcp<CommandQueue>();
+    std::thread serverThread(server_thread, commandQueue);
+
+    TestRenderImageErrorListener errorListener;
+    errorListener.m_handle = RIVE_NULL_HANDLE;
+    commandQueue->setGlobalRenderImageListener(&errorListener);
+
+    FileAssetsListenerCallback listener;
+
+    std::ifstream stream("assets/hosted_image_file.riv", std::ios::binary);
+    FileHandle fileHandle = commandQueue->loadFile(
+        std::vector<uint8_t>(std::istreambuf_iterator<char>(stream), {}),
+        &listener);
+
+    commandQueue->addGlobalImageAsset("one-45008", RIVE_NULL_HANDLE);
+
+    commandQueue->runOnce([fileHandle](CommandServer* server) {
+        auto file = server->getFile(fileHandle);
+        REQUIRE(file != nullptr);
+        CHECK(file->assets()[0]->as<ImageAsset>()->renderImage() == nullptr);
+    });
+
+    wait_for_server(commandQueue.get());
+
+    commandQueue->processMessages();
+    CHECK(errorListener.m_hasCallback);
+
+    commandQueue->setGlobalRenderImageListener(nullptr);
+    commandQueue->disconnect();
+    serverThread.join();
+}
+
+#ifdef WITH_RIVE_TEXT
+TEST_CASE("addGlobalFontAsset resolves across all files", "[CommandQueue]")
+{
+    auto commandQueue = make_rcp<CommandQueue>();
+    std::thread serverThread(server_thread, commandQueue);
+
+    FileAssetsListenerCallback listener1;
+    FileAssetsListenerCallback listener2;
+
+    std::ifstream stream1("assets/hosted_font_file.riv", std::ios::binary);
+    FileHandle fileHandle1 = commandQueue->loadFile(
+        std::vector<uint8_t>(std::istreambuf_iterator<char>(stream1), {}),
+        &listener1);
+
+    std::ifstream stream2("assets/hosted_font_file.riv", std::ios::binary);
+    FileHandle fileHandle2 = commandQueue->loadFile(
+        std::vector<uint8_t>(std::istreambuf_iterator<char>(stream2), {}),
+        &listener2);
+
+    std::ifstream fontStream("assets/fonts/OpenSans-Italic.ttf",
+                             std::ios::binary);
+    auto fontHandle = commandQueue->decodeFont(
+        std::vector<uint8_t>(std::istreambuf_iterator<char>(fontStream), {}));
+
+    commandQueue->addGlobalFontAsset("Inter-43276", fontHandle);
+
+    commandQueue->runOnce(
+        [fileHandle1, fileHandle2, fontHandle](CommandServer* server) {
+            auto file1 = server->getFile(fileHandle1);
+            REQUIRE(file1 != nullptr);
+            CHECK(file1->assets()[0]->as<FontAsset>()->font().get() ==
+                  server->getFont(fontHandle));
+
+            auto file2 = server->getFile(fileHandle2);
+            REQUIRE(file2 != nullptr);
+            CHECK(file2->assets()[0]->as<FontAsset>()->font().get() ==
+                  server->getFont(fontHandle));
+        });
+
+    commandQueue->disconnect();
+    serverThread.join();
+}
+
+TEST_CASE("addGlobalFontAsset replaces across all files", "[CommandQueue]")
+{
+    auto commandQueue = make_rcp<CommandQueue>();
+    std::thread serverThread(server_thread, commandQueue);
+
+    FileAssetsListenerCallback listener1;
+    FileAssetsListenerCallback listener2;
+
+    std::ifstream stream1("assets/hosted_font_file.riv", std::ios::binary);
+    FileHandle fileHandle1 = commandQueue->loadFile(
+        std::vector<uint8_t>(std::istreambuf_iterator<char>(stream1), {}),
+        &listener1);
+
+    std::ifstream stream2("assets/hosted_font_file.riv", std::ios::binary);
+    FileHandle fileHandle2 = commandQueue->loadFile(
+        std::vector<uint8_t>(std::istreambuf_iterator<char>(stream2), {}),
+        &listener2);
+
+    std::ifstream fontStream1("assets/fonts/OpenSans-Italic.ttf",
+                              std::ios::binary);
+    auto fontHandle1 = commandQueue->decodeFont(
+        std::vector<uint8_t>(std::istreambuf_iterator<char>(fontStream1), {}));
+
+    commandQueue->addGlobalFontAsset("Inter-43276", fontHandle1);
+
+    std::ifstream fontStream2("assets/fonts/OpenSans-Italic.ttf",
+                              std::ios::binary);
+    auto fontHandle2 = commandQueue->decodeFont(
+        std::vector<uint8_t>(std::istreambuf_iterator<char>(fontStream2), {}));
+
+    commandQueue->addGlobalFontAsset("Inter-43276", fontHandle2);
+
+    commandQueue->runOnce(
+        [fileHandle1, fileHandle2, fontHandle2](CommandServer* server) {
+            auto file1 = server->getFile(fileHandle1);
+            REQUIRE(file1 != nullptr);
+            CHECK(file1->assets()[0]->as<FontAsset>()->font().get() ==
+                  server->getFont(fontHandle2));
+
+            auto file2 = server->getFile(fileHandle2);
+            REQUIRE(file2 != nullptr);
+            CHECK(file2->assets()[0]->as<FontAsset>()->font().get() ==
+                  server->getFont(fontHandle2));
+        });
+
+    commandQueue->disconnect();
+    serverThread.join();
+}
+
+TEST_CASE("removeGlobalFontAsset clears across all files", "[CommandQueue]")
+{
+    auto commandQueue = make_rcp<CommandQueue>();
+    std::thread serverThread(server_thread, commandQueue);
+
+    FileAssetsListenerCallback listener1;
+    FileAssetsListenerCallback listener2;
+
+    std::ifstream stream1("assets/hosted_font_file.riv", std::ios::binary);
+    FileHandle fileHandle1 = commandQueue->loadFile(
+        std::vector<uint8_t>(std::istreambuf_iterator<char>(stream1), {}),
+        &listener1);
+
+    std::ifstream stream2("assets/hosted_font_file.riv", std::ios::binary);
+    FileHandle fileHandle2 = commandQueue->loadFile(
+        std::vector<uint8_t>(std::istreambuf_iterator<char>(stream2), {}),
+        &listener2);
+
+    std::ifstream fontStream("assets/fonts/OpenSans-Italic.ttf",
+                             std::ios::binary);
+    auto fontHandle = commandQueue->decodeFont(
+        std::vector<uint8_t>(std::istreambuf_iterator<char>(fontStream), {}));
+
+    commandQueue->addGlobalFontAsset("Inter-43276", fontHandle);
+    commandQueue->removeGlobalFontAsset("Inter-43276");
+
+    commandQueue->runOnce([fileHandle1, fileHandle2](CommandServer* server) {
+        auto file1 = server->getFile(fileHandle1);
+        REQUIRE(file1 != nullptr);
+        CHECK(file1->assets()[0]->as<FontAsset>()->font() == nullptr);
+
+        auto file2 = server->getFile(fileHandle2);
+        REQUIRE(file2 != nullptr);
+        CHECK(file2->assets()[0]->as<FontAsset>()->font() == nullptr);
+    });
+
+    commandQueue->disconnect();
+    serverThread.join();
+}
+
+TEST_CASE("addGlobalFontAsset with non-matching name does not resolve on files",
+          "[CommandQueue]")
+{
+    auto commandQueue = make_rcp<CommandQueue>();
+    std::thread serverThread(server_thread, commandQueue);
+
+    FileAssetsListenerCallback listener;
+
+    std::ifstream stream("assets/hosted_font_file.riv", std::ios::binary);
+    FileHandle fileHandle = commandQueue->loadFile(
+        std::vector<uint8_t>(std::istreambuf_iterator<char>(stream), {}),
+        &listener);
+
+    std::ifstream fontStream("assets/fonts/OpenSans-Italic.ttf",
+                             std::ios::binary);
+    auto fontHandle = commandQueue->decodeFont(
+        std::vector<uint8_t>(std::istreambuf_iterator<char>(fontStream), {}));
+
+    commandQueue->addGlobalFontAsset("wrong-name", fontHandle);
+
+    commandQueue->runOnce([fileHandle](CommandServer* server) {
+        auto file = server->getFile(fileHandle);
+        REQUIRE(file != nullptr);
+        CHECK(file->assets()[0]->as<FontAsset>()->font() == nullptr);
+    });
+
+    commandQueue->disconnect();
+    serverThread.join();
+}
+
+TEST_CASE("addGlobalFontAsset with invalid handle reports error",
+          "[CommandQueue]")
+{
+    auto commandQueue = make_rcp<CommandQueue>();
+    std::thread serverThread(server_thread, commandQueue);
+
+    TestFontErrorListener errorListener;
+    errorListener.m_handle = RIVE_NULL_HANDLE;
+    commandQueue->setGlobalFontListener(&errorListener);
+
+    FileAssetsListenerCallback listener;
+
+    std::ifstream stream("assets/hosted_font_file.riv", std::ios::binary);
+    FileHandle fileHandle = commandQueue->loadFile(
+        std::vector<uint8_t>(std::istreambuf_iterator<char>(stream), {}),
+        &listener);
+
+    commandQueue->addGlobalFontAsset("Inter-43276", RIVE_NULL_HANDLE);
+
+    commandQueue->runOnce([fileHandle](CommandServer* server) {
+        auto file = server->getFile(fileHandle);
+        REQUIRE(file != nullptr);
+        CHECK(file->assets()[0]->as<FontAsset>()->font() == nullptr);
+    });
+
+    wait_for_server(commandQueue.get());
+
+    commandQueue->processMessages();
+    CHECK(errorListener.m_hasCallback);
+
+    commandQueue->setGlobalFontListener(nullptr);
+    commandQueue->disconnect();
+    serverThread.join();
+}
+#endif
+
+#ifdef WITH_RIVE_AUDIO
+TEST_CASE("addGlobalAudioAsset resolves across all files", "[CommandQueue]")
+{
+    auto commandQueue = make_rcp<CommandQueue>();
+    std::thread serverThread(server_thread, commandQueue);
+
+    FileAssetsListenerCallback listener1;
+    FileAssetsListenerCallback listener2;
+
+    std::ifstream stream1("assets/hosted_audio_file.riv", std::ios::binary);
+    FileHandle fileHandle1 = commandQueue->loadFile(
+        std::vector<uint8_t>(std::istreambuf_iterator<char>(stream1), {}),
+        &listener1);
+
+    std::ifstream stream2("assets/hosted_audio_file.riv", std::ios::binary);
+    FileHandle fileHandle2 = commandQueue->loadFile(
+        std::vector<uint8_t>(std::istreambuf_iterator<char>(stream2), {}),
+        &listener2);
+
+    std::ifstream audioStream("assets/audio/what.wav", std::ios::binary);
+    auto audioHandle = commandQueue->decodeAudio(
+        std::vector<uint8_t>(std::istreambuf_iterator<char>(audioStream), {}));
+
+    commandQueue->addGlobalAudioAsset("sound-55368", audioHandle);
+
+    commandQueue->runOnce(
+        [fileHandle1, fileHandle2, audioHandle](CommandServer* server) {
+            auto file1 = server->getFile(fileHandle1);
+            REQUIRE(file1 != nullptr);
+            CHECK(file1->assets()[0]->as<AudioAsset>()->audioSource().get() ==
+                  server->getAudioSource(audioHandle));
+
+            auto file2 = server->getFile(fileHandle2);
+            REQUIRE(file2 != nullptr);
+            CHECK(file2->assets()[0]->as<AudioAsset>()->audioSource().get() ==
+                  server->getAudioSource(audioHandle));
+        });
+
+    commandQueue->disconnect();
+    serverThread.join();
+}
+
+TEST_CASE("addGlobalAudioAsset replaces across all files", "[CommandQueue]")
+{
+    auto commandQueue = make_rcp<CommandQueue>();
+    std::thread serverThread(server_thread, commandQueue);
+
+    FileAssetsListenerCallback listener1;
+    FileAssetsListenerCallback listener2;
+
+    std::ifstream stream1("assets/hosted_audio_file.riv", std::ios::binary);
+    FileHandle fileHandle1 = commandQueue->loadFile(
+        std::vector<uint8_t>(std::istreambuf_iterator<char>(stream1), {}),
+        &listener1);
+
+    std::ifstream stream2("assets/hosted_audio_file.riv", std::ios::binary);
+    FileHandle fileHandle2 = commandQueue->loadFile(
+        std::vector<uint8_t>(std::istreambuf_iterator<char>(stream2), {}),
+        &listener2);
+
+    std::ifstream audioStream1("assets/audio/what.wav", std::ios::binary);
+    auto audioHandle1 = commandQueue->decodeAudio(
+        std::vector<uint8_t>(std::istreambuf_iterator<char>(audioStream1), {}));
+
+    commandQueue->addGlobalAudioAsset("sound-55368", audioHandle1);
+
+    std::ifstream audioStream2("assets/audio/what.wav", std::ios::binary);
+    auto audioHandle2 = commandQueue->decodeAudio(
+        std::vector<uint8_t>(std::istreambuf_iterator<char>(audioStream2), {}));
+
+    commandQueue->addGlobalAudioAsset("sound-55368", audioHandle2);
+
+    commandQueue->runOnce(
+        [fileHandle1, fileHandle2, audioHandle2](CommandServer* server) {
+            auto file1 = server->getFile(fileHandle1);
+            REQUIRE(file1 != nullptr);
+            CHECK(file1->assets()[0]->as<AudioAsset>()->audioSource().get() ==
+                  server->getAudioSource(audioHandle2));
+
+            auto file2 = server->getFile(fileHandle2);
+            REQUIRE(file2 != nullptr);
+            CHECK(file2->assets()[0]->as<AudioAsset>()->audioSource().get() ==
+                  server->getAudioSource(audioHandle2));
+        });
+
+    commandQueue->disconnect();
+    serverThread.join();
+}
+
+TEST_CASE("removeGlobalAudioAsset clears across all files", "[CommandQueue]")
+{
+    auto commandQueue = make_rcp<CommandQueue>();
+    std::thread serverThread(server_thread, commandQueue);
+
+    FileAssetsListenerCallback listener1;
+    FileAssetsListenerCallback listener2;
+
+    std::ifstream stream1("assets/hosted_audio_file.riv", std::ios::binary);
+    FileHandle fileHandle1 = commandQueue->loadFile(
+        std::vector<uint8_t>(std::istreambuf_iterator<char>(stream1), {}),
+        &listener1);
+
+    std::ifstream stream2("assets/hosted_audio_file.riv", std::ios::binary);
+    FileHandle fileHandle2 = commandQueue->loadFile(
+        std::vector<uint8_t>(std::istreambuf_iterator<char>(stream2), {}),
+        &listener2);
+
+    std::ifstream audioStream("assets/audio/what.wav", std::ios::binary);
+    auto audioHandle = commandQueue->decodeAudio(
+        std::vector<uint8_t>(std::istreambuf_iterator<char>(audioStream), {}));
+
+    commandQueue->addGlobalAudioAsset("sound-55368", audioHandle);
+    commandQueue->removeGlobalAudioAsset("sound-55368");
+
+    commandQueue->runOnce([fileHandle1, fileHandle2](CommandServer* server) {
+        auto file1 = server->getFile(fileHandle1);
+        REQUIRE(file1 != nullptr);
+        CHECK(file1->assets()[0]->as<AudioAsset>()->audioSource() == nullptr);
+
+        auto file2 = server->getFile(fileHandle2);
+        REQUIRE(file2 != nullptr);
+        CHECK(file2->assets()[0]->as<AudioAsset>()->audioSource() == nullptr);
+    });
+
+    commandQueue->disconnect();
+    serverThread.join();
+}
+
+TEST_CASE(
+    "addGlobalAudioAsset with non-matching name does not resolve on files",
+    "[CommandQueue]")
+{
+    auto commandQueue = make_rcp<CommandQueue>();
+    std::thread serverThread(server_thread, commandQueue);
+
+    FileAssetsListenerCallback listener;
+
+    std::ifstream stream("assets/hosted_audio_file.riv", std::ios::binary);
+    FileHandle fileHandle = commandQueue->loadFile(
+        std::vector<uint8_t>(std::istreambuf_iterator<char>(stream), {}),
+        &listener);
+
+    std::ifstream audioStream("assets/audio/what.wav", std::ios::binary);
+    auto audioHandle = commandQueue->decodeAudio(
+        std::vector<uint8_t>(std::istreambuf_iterator<char>(audioStream), {}));
+
+    commandQueue->addGlobalAudioAsset("wrong-name", audioHandle);
+
+    commandQueue->runOnce([fileHandle](CommandServer* server) {
+        auto file = server->getFile(fileHandle);
+        REQUIRE(file != nullptr);
+        CHECK(file->assets()[0]->as<AudioAsset>()->audioSource() == nullptr);
+    });
+
+    commandQueue->disconnect();
+    serverThread.join();
+}
+
+TEST_CASE("addGlobalAudioAsset with invalid handle reports error",
+          "[CommandQueue]")
+{
+    auto commandQueue = make_rcp<CommandQueue>();
+    std::thread serverThread(server_thread, commandQueue);
+
+    TestAudioSourceErrorListener errorListener;
+    errorListener.m_handle = RIVE_NULL_HANDLE;
+    commandQueue->setGlobalAudioSourceListener(&errorListener);
+
+    FileAssetsListenerCallback listener;
+
+    std::ifstream stream("assets/hosted_audio_file.riv", std::ios::binary);
+    FileHandle fileHandle = commandQueue->loadFile(
+        std::vector<uint8_t>(std::istreambuf_iterator<char>(stream), {}),
+        &listener);
+
+    commandQueue->addGlobalAudioAsset("sound-55368", RIVE_NULL_HANDLE);
+
+    commandQueue->runOnce([fileHandle](CommandServer* server) {
+        auto file = server->getFile(fileHandle);
+        REQUIRE(file != nullptr);
+        CHECK(file->assets()[0]->as<AudioAsset>()->audioSource() == nullptr);
+    });
+
+    wait_for_server(commandQueue.get());
+
+    commandQueue->processMessages();
+    CHECK(errorListener.m_hasCallback);
+
+    commandQueue->setGlobalAudioSourceListener(nullptr);
+    commandQueue->disconnect();
+    serverThread.join();
+}
+#endif
+
+TEST_CASE("deleting a file does not disturb another file's global asset",
+          "[CommandQueue]")
+{
+    auto commandQueue = make_rcp<CommandQueue>();
+    std::thread serverThread(server_thread, commandQueue);
+
+    FileAssetsListenerCallback listener1;
+    FileAssetsListenerCallback listener2;
+
+    std::ifstream stream1("assets/hosted_image_file.riv", std::ios::binary);
+    FileHandle fileHandle1 = commandQueue->loadFile(
+        std::vector<uint8_t>(std::istreambuf_iterator<char>(stream1), {}),
+        &listener1);
+
+    std::ifstream stream2("assets/hosted_image_file.riv", std::ios::binary);
+    FileHandle fileHandle2 = commandQueue->loadFile(
+        std::vector<uint8_t>(std::istreambuf_iterator<char>(stream2), {}),
+        &listener2);
+
+    std::ifstream imageStream("assets/batdude.png", std::ios::binary);
+    auto imageHandle = commandQueue->decodeImage(
+        std::vector<uint8_t>(std::istreambuf_iterator<char>(imageStream), {}));
+
+    commandQueue->addGlobalImageAsset("one-45008", imageHandle);
+    commandQueue->deleteFile(fileHandle1);
+
+    // The surviving file keeps its resolved asset, and the deleted file is
+    // gone.
+    commandQueue->runOnce(
+        [fileHandle1, fileHandle2, imageHandle](CommandServer* server) {
+            CHECK(server->getFile(fileHandle1) == nullptr);
+
+            auto file2 = server->getFile(fileHandle2);
+            REQUIRE(file2 != nullptr);
+            CHECK(file2->assets()[0]->as<ImageAsset>()->renderImage() ==
+                  server->getImage(imageHandle));
+        });
+
+    // A subsequent global change for the same name must reach the surviving
+    // file and must not touch the deleted file's assets.
+    std::ifstream replacementStream("assets/batdude.png", std::ios::binary);
+    auto replacementHandle = commandQueue->decodeImage(
+        std::vector<uint8_t>(std::istreambuf_iterator<char>(replacementStream),
+                             {}));
+
+    commandQueue->addGlobalImageAsset("one-45008", replacementHandle);
+
+    commandQueue->runOnce(
+        [fileHandle2, replacementHandle](CommandServer* server) {
+            auto file2 = server->getFile(fileHandle2);
+            REQUIRE(file2 != nullptr);
+            CHECK(file2->assets()[0]->as<ImageAsset>()->renderImage() ==
+                  server->getImage(replacementHandle));
+        });
+
+    commandQueue->disconnect();
+    serverThread.join();
+}
+
+TEST_CASE("global asset changes after deleting its only file are a safe no-op",
+          "[CommandQueue]")
+{
+    auto commandQueue = make_rcp<CommandQueue>();
+    std::thread serverThread(server_thread, commandQueue);
+
+    FileAssetsListenerCallback listener;
+
+    std::ifstream stream("assets/hosted_image_file.riv", std::ios::binary);
+    FileHandle fileHandle = commandQueue->loadFile(
+        std::vector<uint8_t>(std::istreambuf_iterator<char>(stream), {}),
+        &listener);
+
+    std::ifstream imageStream("assets/batdude.png", std::ios::binary);
+    auto imageHandle = commandQueue->decodeImage(
+        std::vector<uint8_t>(std::istreambuf_iterator<char>(imageStream), {}));
+
+    commandQueue->addGlobalImageAsset("one-45008", imageHandle);
+    commandQueue->deleteFile(fileHandle);
+
+    // With no file referencing the name, applying and clearing it must not
+    // dereference the deleted file's assets.
+    commandQueue->removeGlobalImageAsset("one-45008");
+    commandQueue->addGlobalImageAsset("one-45008", imageHandle);
+
+    commandQueue->runOnce([fileHandle, imageHandle](CommandServer* server) {
+        CHECK(server->getFile(fileHandle) == nullptr);
+        CHECK(server->getImage(imageHandle) != nullptr);
+    });
+
+    commandQueue->disconnect();
+    serverThread.join();
+}
+
+TEST_CASE("failed load does not contaminate a later file's global assets",
+          "[CommandQueue]")
+{
+    auto commandQueue = make_rcp<CommandQueue>();
+    std::thread serverThread(server_thread, commandQueue);
+
+    FileAssetsListenerCallback listener;
+
+    // A file that fails to import must not leave its assets tracked.
+    FileHandle badFile =
+        commandQueue->loadFile(std::vector<uint8_t>(100 * 1024, 0));
+
+    std::ifstream stream("assets/hosted_image_file.riv", std::ios::binary);
+    FileHandle goodFile = commandQueue->loadFile(
+        std::vector<uint8_t>(std::istreambuf_iterator<char>(stream), {}),
+        &listener);
+
+    std::ifstream imageStream("assets/batdude.png", std::ios::binary);
+    auto imageHandle = commandQueue->decodeImage(
+        std::vector<uint8_t>(std::istreambuf_iterator<char>(imageStream), {}));
+
+    commandQueue->addGlobalImageAsset("one-45008", imageHandle);
+
+    commandQueue->runOnce(
+        [badFile, goodFile, imageHandle](CommandServer* server) {
+            CHECK(server->getFile(badFile) == nullptr);
+
+            auto file = server->getFile(goodFile);
+            REQUIRE(file != nullptr);
+            CHECK(file->assets()[0]->as<ImageAsset>()->renderImage() ==
+                  server->getImage(imageHandle));
+        });
+
+    commandQueue->disconnect();
+    serverThread.join();
+}
+
+TEST_CASE("global image asset does not override embedded asset",
+          "[CommandQueue]")
+{
+    auto commandQueue = make_rcp<CommandQueue>();
+    std::thread serverThread(server_thread, commandQueue);
+
+    FileAssetsListenerCallback listener;
+
+    std::ifstream imageStream("assets/batdude.png", std::ios::binary);
+    auto imageHandle = commandQueue->decodeImage(
+        std::vector<uint8_t>(std::istreambuf_iterator<char>(imageStream), {}));
+
+    // Registered before the file loads: the embedded contents must still win.
+    commandQueue->addGlobalImageAsset("1x1-45022", imageHandle);
+
+    std::ifstream stream("assets/in_band_asset.riv", std::ios::binary);
+    FileHandle fileHandle = commandQueue->loadFile(
+        std::vector<uint8_t>(std::istreambuf_iterator<char>(stream), {}),
+        &listener);
+
+    commandQueue->runOnce([fileHandle, imageHandle](CommandServer* server) {
+        auto file = server->getFile(fileHandle);
+        REQUIRE(file != nullptr);
+        auto image = file->assets()[0]->as<ImageAsset>()->renderImage();
+        CHECK(image != nullptr);
+        CHECK(image != server->getImage(imageHandle));
+    });
+
+    // Removing and re-adding the global after load must not clear or replace
+    // the embedded contents either.
+    commandQueue->removeGlobalImageAsset("1x1-45022");
+    commandQueue->addGlobalImageAsset("1x1-45022", imageHandle);
+
+    commandQueue->runOnce([fileHandle, imageHandle](CommandServer* server) {
+        auto file = server->getFile(fileHandle);
+        REQUIRE(file != nullptr);
+        auto image = file->assets()[0]->as<ImageAsset>()->renderImage();
+        CHECK(image != nullptr);
+        CHECK(image != server->getImage(imageHandle));
+    });
+
+    commandQueue->disconnect();
+    serverThread.join();
+}
+
+TEST_CASE("deleting an image clears the applied global asset on loaded files",
+          "[CommandQueue]")
+{
+    auto commandQueue = make_rcp<CommandQueue>();
+    std::thread serverThread(server_thread, commandQueue);
+
+    FileAssetsListenerCallback listener;
+
+    std::ifstream stream("assets/hosted_image_file.riv", std::ios::binary);
+    FileHandle fileHandle = commandQueue->loadFile(
+        std::vector<uint8_t>(std::istreambuf_iterator<char>(stream), {}),
+        &listener);
+
+    std::ifstream imageStream("assets/batdude.png", std::ios::binary);
+    auto imageHandle = commandQueue->decodeImage(
+        std::vector<uint8_t>(std::istreambuf_iterator<char>(imageStream), {}));
+
+    commandQueue->addGlobalImageAsset("one-45008", imageHandle);
+
+    commandQueue->runOnce([fileHandle, imageHandle](CommandServer* server) {
+        auto file = server->getFile(fileHandle);
+        REQUIRE(file != nullptr);
+        CHECK(file->assets()[0]->as<ImageAsset>()->renderImage() ==
+              server->getImage(imageHandle));
+    });
+
+    // Deleting the image deletes the global asset backed by it, so the
+    // change is applied to loaded files just like a removal.
+    commandQueue->deleteImage(imageHandle);
+
+    commandQueue->runOnce([fileHandle](CommandServer* server) {
+        auto file = server->getFile(fileHandle);
+        REQUIRE(file != nullptr);
+        CHECK(file->assets()[0]->as<ImageAsset>()->renderImage() == nullptr);
+    });
+
+    commandQueue->disconnect();
+    serverThread.join();
+}
+
+TEST_CASE("deleting an image registered under multiple names clears all",
+          "[CommandQueue]")
+{
+    auto commandQueue = make_rcp<CommandQueue>();
+    std::thread serverThread(server_thread, commandQueue);
+
+    FileAssetsListenerCallback listener;
+
+    std::ifstream stream("assets/hosted_image_file.riv", std::ios::binary);
+    FileHandle fileHandle = commandQueue->loadFile(
+        std::vector<uint8_t>(std::istreambuf_iterator<char>(stream), {}),
+        &listener);
+
+    std::ifstream imageStream("assets/batdude.png", std::ios::binary);
+    auto imageHandle = commandQueue->decodeImage(
+        std::vector<uint8_t>(std::istreambuf_iterator<char>(imageStream), {}));
+
+    // The same image backs two global asset names.
+    commandQueue->addGlobalImageAsset("one-45008", imageHandle);
+    commandQueue->addGlobalImageAsset("two-45009", imageHandle);
+
+    commandQueue->runOnce([fileHandle, imageHandle](CommandServer* server) {
+        auto file = server->getFile(fileHandle);
+        REQUIRE(file != nullptr);
+        CHECK(file->assets()[0]->as<ImageAsset>()->renderImage() ==
+              server->getImage(imageHandle));
+    });
+
+    // Deleting the image deletes every global asset it backed, applying the
+    // removal to loaded files and leaving no stale registry entries.
+    commandQueue->deleteImage(imageHandle);
+
+    commandQueue->runOnce([fileHandle](CommandServer* server) {
+        auto file = server->getFile(fileHandle);
+        REQUIRE(file != nullptr);
+        CHECK(file->assets()[0]->as<ImageAsset>()->renderImage() == nullptr);
+        CHECK(!server->testing_globalImageContains("one-45008"));
+        CHECK(!server->testing_globalImageContains("two-45009"));
+    });
+
+    commandQueue->disconnect();
+    serverThread.join();
+}
+
+#ifdef WITH_RIVE_TEXT
+TEST_CASE("deleting a font clears the applied global asset on loaded files",
+          "[CommandQueue]")
+{
+    auto commandQueue = make_rcp<CommandQueue>();
+    std::thread serverThread(server_thread, commandQueue);
+
+    FileAssetsListenerCallback listener;
+
+    std::ifstream stream("assets/hosted_font_file.riv", std::ios::binary);
+    FileHandle fileHandle = commandQueue->loadFile(
+        std::vector<uint8_t>(std::istreambuf_iterator<char>(stream), {}),
+        &listener);
+
+    std::ifstream fontStream("assets/fonts/OpenSans-Italic.ttf",
+                             std::ios::binary);
+    auto fontHandle = commandQueue->decodeFont(
+        std::vector<uint8_t>(std::istreambuf_iterator<char>(fontStream), {}));
+
+    commandQueue->addGlobalFontAsset("Inter-43276", fontHandle);
+
+    commandQueue->runOnce([fileHandle, fontHandle](CommandServer* server) {
+        auto file = server->getFile(fileHandle);
+        REQUIRE(file != nullptr);
+        CHECK(file->assets()[0]->as<FontAsset>()->font().get() ==
+              server->getFont(fontHandle));
+    });
+
+    // Deleting the font deletes the global asset backed by it, so the
+    // change is applied to loaded files just like a removal.
+    commandQueue->deleteFont(fontHandle);
+
+    commandQueue->runOnce([fileHandle](CommandServer* server) {
+        auto file = server->getFile(fileHandle);
+        REQUIRE(file != nullptr);
+        CHECK(file->assets()[0]->as<FontAsset>()->font() == nullptr);
+        CHECK(!server->testing_globalFontContains("Inter-43276"));
+    });
+
+    commandQueue->disconnect();
+    serverThread.join();
+}
+#endif
+
+#ifdef WITH_RIVE_AUDIO
+TEST_CASE(
+    "deleting an audio source clears the applied global asset on loaded files",
+    "[CommandQueue]")
+{
+    auto commandQueue = make_rcp<CommandQueue>();
+    std::thread serverThread(server_thread, commandQueue);
+
+    FileAssetsListenerCallback listener;
+
+    std::ifstream stream("assets/hosted_audio_file.riv", std::ios::binary);
+    FileHandle fileHandle = commandQueue->loadFile(
+        std::vector<uint8_t>(std::istreambuf_iterator<char>(stream), {}),
+        &listener);
+
+    std::ifstream audioStream("assets/audio/what.wav", std::ios::binary);
+    auto audioHandle = commandQueue->decodeAudio(
+        std::vector<uint8_t>(std::istreambuf_iterator<char>(audioStream), {}));
+
+    commandQueue->addGlobalAudioAsset("sound-55368", audioHandle);
+
+    commandQueue->runOnce([fileHandle, audioHandle](CommandServer* server) {
+        auto file = server->getFile(fileHandle);
+        REQUIRE(file != nullptr);
+        CHECK(file->assets()[0]->as<AudioAsset>()->audioSource().get() ==
+              server->getAudioSource(audioHandle));
+    });
+
+    // Deleting the audio source deletes the global asset backed by it, so
+    // the change is applied to loaded files just like a removal.
+    commandQueue->deleteAudio(audioHandle);
+
+    commandQueue->runOnce([fileHandle](CommandServer* server) {
+        auto file = server->getFile(fileHandle);
+        REQUIRE(file != nullptr);
+        CHECK(file->assets()[0]->as<AudioAsset>()->audioSource() == nullptr);
+        CHECK(!server->testing_globalAudioContains("sound-55368"));
+    });
+
+    commandQueue->disconnect();
+    serverThread.join();
+}
+#endif
+
+TEST_CASE("deleting a replaced image does not clear its former name",
+          "[CommandQueue]")
+{
+    auto commandQueue = make_rcp<CommandQueue>();
+    std::thread serverThread(server_thread, commandQueue);
+
+    FileAssetsListenerCallback listener;
+
+    std::ifstream stream("assets/hosted_image_file.riv", std::ios::binary);
+    FileHandle fileHandle = commandQueue->loadFile(
+        std::vector<uint8_t>(std::istreambuf_iterator<char>(stream), {}),
+        &listener);
+
+    std::ifstream imageStream1("assets/batdude.png", std::ios::binary);
+    auto firstHandle = commandQueue->decodeImage(
+        std::vector<uint8_t>(std::istreambuf_iterator<char>(imageStream1), {}));
+
+    std::ifstream imageStream2("assets/batdude.png", std::ios::binary);
+    auto secondHandle = commandQueue->decodeImage(
+        std::vector<uint8_t>(std::istreambuf_iterator<char>(imageStream2), {}));
+
+    // The name is re-registered from the first image to the second.
+    commandQueue->addGlobalImageAsset("one-45008", firstHandle);
+    commandQueue->addGlobalImageAsset("one-45008", secondHandle);
+
+    // Deleting the first image must not clear the name, which now belongs
+    // to the second image.
+    commandQueue->deleteImage(firstHandle);
+
+    commandQueue->runOnce([fileHandle, secondHandle](CommandServer* server) {
+        auto file = server->getFile(fileHandle);
+        REQUIRE(file != nullptr);
+        CHECK(file->assets()[0]->as<ImageAsset>()->renderImage() ==
+              server->getImage(secondHandle));
+        CHECK(server->testing_globalImageContains("one-45008"));
+    });
+
+    commandQueue->disconnect();
+    serverThread.join();
+}
+
+namespace
+{
+// Claims image assets without providing contents, standing in for a custom
+// loader that owns asset resolution.
+class ClaimingImageAssetLoader : public rive::FileAssetLoader
+{
+public:
+    bool loadContents(FileAsset& asset,
+                      Span<const uint8_t> inBandBytes,
+                      Factory* factory) override
+    {
+        return asset.is<ImageAsset>();
+    }
+};
+} // namespace
+
+TEST_CASE("internal loader claimed asset is not overridden by global asset",
+          "[CommandQueue]")
+{
+    auto commandQueue = make_rcp<CommandQueue>();
+    auto loader = make_rcp<ClaimingImageAssetLoader>();
+    std::thread serverThread(server_thread_file_loader, commandQueue, loader);
+
+    FileAssetsListenerCallback listener;
+
+    std::ifstream imageStream("assets/batdude.png", std::ios::binary);
+    auto imageHandle = commandQueue->decodeImage(
+        std::vector<uint8_t>(std::istreambuf_iterator<char>(imageStream), {}));
+
+    // Registered before the file loads: the claiming loader still wins.
+    commandQueue->addGlobalImageAsset("one-45008", imageHandle);
+
+    std::ifstream stream("assets/hosted_image_file.riv", std::ios::binary);
+    FileHandle fileHandle = commandQueue->loadFile(
+        std::vector<uint8_t>(std::istreambuf_iterator<char>(stream), {}),
+        &listener);
+
+    // The claiming loader set no contents, and the global asset must not
+    // have been applied over its claim at load.
+    commandQueue->runOnce([fileHandle](CommandServer* server) {
+        auto file = server->getFile(fileHandle);
+        REQUIRE(file != nullptr);
+        CHECK(file->assets()[0]->as<ImageAsset>()->renderImage() == nullptr);
+    });
+
+    // Re-applying the global after load must not reach the claimed asset
+    // either.
+    commandQueue->addGlobalImageAsset("one-45008", imageHandle);
+
+    commandQueue->runOnce([fileHandle](CommandServer* server) {
+        auto file = server->getFile(fileHandle);
+        REQUIRE(file != nullptr);
+        CHECK(file->assets()[0]->as<ImageAsset>()->renderImage() == nullptr);
+    });
+
+    commandQueue->disconnect();
+    serverThread.join();
+}