kitgit

tirbofish/dropbear · diff

a591388 · tk

material and model API (such as comparison and swapping) jarvis, run github actions

Unverified

diff --git a/build.gradle.kts b/build.gradle.kts
index 5ed1930..693f382 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -108,7 +108,7 @@ kotlin {
     sourceSets {
         commonMain {
             dependencies {
-                implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.6.0")
+                api("org.jetbrains.kotlinx:kotlinx-datetime:0.6.0")
             }
         }
         nativeMain {
diff --git a/dropbear-engine/src/asset.rs b/dropbear-engine/src/asset.rs
index fddd772..5adadb7 100644
--- a/dropbear-engine/src/asset.rs
+++ b/dropbear-engine/src/asset.rs
@@ -1,4 +1,7 @@
-use std::sync::{Arc, atomic::{AtomicU64, Ordering}, LazyLock};
+use std::sync::{
+    Arc, LazyLock,
+    atomic::{AtomicU64, Ordering},
+};
 
 use dashmap::DashMap;
 
@@ -15,7 +18,9 @@ impl AssetHandle {
     ///
     /// This function does not guarantee if the raw value exists in the registry.
     /// You will have to check yourself.
-    pub fn new(raw: u64) -> Self { Self(raw) }
+    pub fn new(raw: impl Into<u64>) -> Self {
+        Self(raw.into())
+    }
     /// Returns the raw/primitive [`u64`] value.
     pub fn raw(&self) -> u64 {
         self.0
@@ -88,10 +93,12 @@ impl AssetRegistry {
         }
     }
 
+    /// Adds a pointer to the asset registry.
     pub fn add_pointer(&self, pointer_kind: PointerKind, pointer: usize) {
         self.pointers.insert(pointer_kind, pointer);
     }
 
+    /// Attempts to fetch a pointer from the [`AssetRegistry`] by its given [`PointerKind`]
     pub fn get_pointer(&self, pointer_kind: PointerKind) -> Option<usize> {
         self.pointers.get(&pointer_kind).map(|entry| *entry.value())
     }
@@ -128,20 +135,36 @@ impl AssetRegistry {
     /// Iterates through all models, allowing you to iterate through all items in the
     /// model registry.
     pub fn iter_model(&self) -> dashmap::iter::Iter<'_, AssetHandle, Arc<Model>> {
+        self.iter_model_raw()
+    }
+
+    pub fn iter_model_raw(&self) -> dashmap::iter::Iter<'_, AssetHandle, Arc<Model>> {
         self.models.iter()
     }
 
     pub fn iter_material(&self) -> dashmap::iter::Iter<'_, AssetHandle, Arc<Material>> {
+        self.iter_material_raw()
+    }
+
+    pub fn iter_material_raw(&self) -> dashmap::iter::Iter<'_, AssetHandle, Arc<Material>> {
         self.materials.iter()
     }
 
     /// Returns the cached model handle if it exists.
     pub fn model_handle(&self, reference: &ResourceReference) -> Option<AssetHandle> {
+        self.model_handle_raw(reference)
+    }
+
+    pub fn model_handle_raw(&self, reference: &ResourceReference) -> Option<AssetHandle> {
         self.model_handles.get(reference).map(|entry| *entry)
     }
 
     /// Fetches a model by handle.
     pub fn get_model(&self, handle: AssetHandle) -> Option<Arc<Model>> {
+        self.get_model_raw(handle)
+    }
+
+    pub fn get_model_raw(&self, handle: AssetHandle) -> Option<Arc<Model>> {
         self.models
             .get(&handle)
             .map(|entry| Arc::clone(entry.value()))
@@ -149,6 +172,10 @@ impl AssetRegistry {
 
     /// Fetches a material by handle.
     pub fn get_material(&self, handle: AssetHandle) -> Option<Arc<Material>> {
+        self.get_material_raw(handle)
+    }
+
+    pub fn get_material_raw(&self, handle: AssetHandle) -> Option<Arc<Material>> {
         self.materials
             .get(&handle)
             .map(|entry| Arc::clone(entry.value()))
@@ -156,6 +183,10 @@ impl AssetRegistry {
 
     /// Fetches a mesh by handle.
     pub fn get_mesh(&self, handle: AssetHandle) -> Option<Arc<Mesh>> {
+        self.get_mesh_raw(handle)
+    }
+
+    pub fn get_mesh_raw(&self, handle: AssetHandle) -> Option<Arc<Mesh>> {
         self.meshes
             .get(&handle)
             .map(|entry| Arc::clone(entry.value()))
@@ -245,6 +276,10 @@ impl AssetRegistry {
 
     /// Returns the owning model ID for the given material handle.
     pub fn material_owner(&self, handle: AssetHandle) -> Option<ModelId> {
+        self.material_owner_raw(handle)
+    }
+
+    pub fn material_owner_raw(&self, handle: AssetHandle) -> Option<ModelId> {
         self.material_owners.get(&handle).map(|entry| *entry)
     }
 
@@ -255,6 +290,13 @@ impl AssetRegistry {
 
     /// Returns the synthetic `ResourceReference` associated with a material handle.
     pub fn material_reference_for_handle(&self, handle: AssetHandle) -> Option<ResourceReference> {
+        self.material_reference_for_handle_raw(handle)
+    }
+
+    pub fn material_reference_for_handle_raw(
+        &self,
+        handle: AssetHandle,
+    ) -> Option<ResourceReference> {
         self.material_references
             .get(&handle)
             .map(|entry| entry.value().clone())
@@ -262,6 +304,10 @@ impl AssetRegistry {
 
     /// Returns the synthetic `ResourceReference` associated with a mesh handle.
     pub fn mesh_reference_for_handle(&self, handle: AssetHandle) -> Option<ResourceReference> {
+        self.mesh_reference_for_handle_raw(handle)
+    }
+
+    pub fn mesh_reference_for_handle_raw(&self, handle: AssetHandle) -> Option<ResourceReference> {
         self.mesh_references
             .get(&handle)
             .map(|entry| entry.value().clone())
@@ -272,6 +318,13 @@ impl AssetRegistry {
         &self,
         reference: &ResourceReference,
     ) -> Option<AssetHandle> {
+        self.material_handle_from_reference_raw(reference)
+    }
+
+    pub fn material_handle_from_reference_raw(
+        &self,
+        reference: &ResourceReference,
+    ) -> Option<AssetHandle> {
         self.material_reference_lookup
             .get(reference)
             .map(|entry| *entry)
@@ -279,6 +332,13 @@ impl AssetRegistry {
 
     /// Attempts to resolve a mesh handle from its synthetic `ResourceReference`.
     pub fn mesh_handle_from_reference(&self, reference: &ResourceReference) -> Option<AssetHandle> {
+        self.mesh_handle_from_reference_raw(reference)
+    }
+
+    pub fn mesh_handle_from_reference_raw(
+        &self,
+        reference: &ResourceReference,
+    ) -> Option<AssetHandle> {
         self.mesh_reference_lookup
             .get(reference)
             .map(|entry| *entry)
@@ -300,7 +360,10 @@ impl AssetRegistry {
 
             self.material_owners.insert(handle, model_id);
 
-            if let Some(reference) = material_reference(model_id, &name) {
+            let reference = material_reference_from_model(model.as_ref(), &name)
+                .or_else(|| material_reference_fallback(model_id, &name));
+
+            if let Some(reference) = reference {
                 self.material_references.insert(handle, reference.clone());
                 self.material_reference_lookup.insert(reference, handle);
             }
@@ -339,7 +402,21 @@ impl Default for AssetRegistry {
 
 pub static ASSET_REGISTRY: LazyLock<AssetRegistry> = LazyLock::new(AssetRegistry::new);
 
-fn material_reference(model_id: ModelId, name: &str) -> Option<ResourceReference> {
+fn material_reference_from_model(model: &Model, name: &str) -> Option<ResourceReference> {
+    let base_uri = model.path.as_uri()?;
+    let material_component = sanitize_material_component(name);
+
+    if material_component.is_empty() {
+        return None;
+    }
+
+    let base = base_uri.trim_end_matches('/');
+    let combined = format!("{}/{}", base, material_component);
+
+    ResourceReference::from_euca_uri(combined).ok()
+}
+
+fn material_reference_fallback(model_id: ModelId, name: &str) -> Option<ResourceReference> {
     resource_reference_for("materials", model_id, name)
 }
 
@@ -360,6 +437,21 @@ fn resource_reference_for(
     ResourceReference::from_euca_uri(uri).ok()
 }
 
+fn sanitize_material_component(input: &str) -> String {
+    let trimmed = input.trim();
+    if trimmed.is_empty() {
+        return String::new();
+    }
+
+    trimmed
+        .chars()
+        .map(|ch| match ch {
+            'a'..='z' | 'A'..='Z' | '0'..='9' | '-' | '_' | '.' => ch,
+            _ => '_',
+        })
+        .collect()
+}
+
 fn sanitize_component(input: &str) -> String {
     let trimmed = input.trim();
     if trimmed.is_empty() {
diff --git a/dropbear-engine/src/entity.rs b/dropbear-engine/src/entity.rs
index 1901299..9a97442 100644
--- a/dropbear-engine/src/entity.rs
+++ b/dropbear-engine/src/entity.rs
@@ -1,12 +1,16 @@
 use glam::{DMat4, DQuat, DVec3, Mat4};
 use parking_lot::Mutex;
 use serde::{Deserialize, Serialize};
-use std::{collections::HashMap, path::Path, sync::Arc};
+use std::{
+    collections::HashMap,
+    path::Path,
+    sync::{Arc, LazyLock},
+};
 
 use crate::{
-    asset::{AssetRegistry, AssetHandle, AssetKind, ASSET_REGISTRY},
+    asset::{ASSET_REGISTRY, AssetHandle, AssetKind, AssetRegistry},
     graphics::{Instance, SharedGraphicsContext, Texture},
-    model::{LoadedModel, Model, ModelId, MODEL_CACHE},
+    model::{LoadedModel, MODEL_CACHE, Model, ModelId},
     utils::ResourceReference,
 };
 use anyhow::anyhow;
@@ -160,6 +164,10 @@ impl MeshRenderer {
 
     /// Swaps the currently loaded model for that renderer by the provided [`LoadedModel`]
     pub fn set_handle(&mut self, handle: LoadedModel) {
+        self.set_handle_raw(handle);
+    }
+
+    pub fn set_handle_raw(&mut self, handle: LoadedModel) {
         self.handle = handle;
         self.material_overrides.clear();
         self.original_material_snapshots.clear();
@@ -187,18 +195,20 @@ impl MeshRenderer {
             .get_model(handle)
             .ok_or_else(|| anyhow!("Model handle {} not found", handle.raw()))?;
 
-        self.set_handle(LoadedModel::from_registered(handle, model));
-        self.material_overrides.clear();
-        self.original_material_snapshots.clear();
+        self.set_handle_raw(LoadedModel::from_registered(handle, model));
         Ok(())
     }
-    
-    /// Swaps the loaded model to a different one by using an AssetHandle. 
-    /// 
-    /// The main difference between [`MeshRenderer::set_asset_handle`] and 
+
+    /// Swaps the loaded model to a different one by using an AssetHandle.
+    ///
+    /// The main difference between [`MeshRenderer::set_asset_handle`] and
     /// [`MeshRenderer::set_asset_handle_raw`] is that it does not use any static variables
-    /// (like [`ASSET_REGISTRY`]), instead allowing for the registry to be manually provided. 
-    pub fn set_asset_handle_raw(&mut self, registry: &AssetRegistry, handle: AssetHandle) -> anyhow::Result<()> {
+    /// (like [`ASSET_REGISTRY`]), instead allowing for the registry to be manually provided.
+    pub fn set_asset_handle_raw(
+        &mut self,
+        registry: &AssetRegistry,
+        handle: AssetHandle,
+    ) -> anyhow::Result<()> {
         if !registry.contains_handle(handle) {
             return Err(anyhow!(
                 "Asset handle {} is not registered with the asset registry",
@@ -217,9 +227,7 @@ impl MeshRenderer {
             .get_model(handle)
             .ok_or_else(|| anyhow!("Model handle {} not found", handle.raw()))?;
 
-        self.set_handle(LoadedModel::from_registered(handle, model));
-        self.material_overrides.clear();
-        self.original_material_snapshots.clear();
+        self.set_handle_raw(LoadedModel::from_registered(handle, model));
         Ok(())
     }
 
@@ -240,11 +248,27 @@ impl MeshRenderer {
     }
 
     pub fn material_handle(&self, material_name: &str) -> Option<AssetHandle> {
-        ASSET_REGISTRY.material_handle(self.model_id(), material_name)
+        self.material_handle_raw(&ASSET_REGISTRY, material_name)
+    }
+
+    pub fn material_handle_raw(
+        &self,
+        registry: &AssetRegistry,
+        material_name: &str,
+    ) -> Option<AssetHandle> {
+        registry.material_handle(self.model_id(), material_name)
     }
 
     pub fn mesh_handle(&self, mesh_name: &str) -> Option<AssetHandle> {
-        ASSET_REGISTRY.mesh_handle(self.model_id(), mesh_name)
+        self.mesh_handle_raw(&ASSET_REGISTRY, mesh_name)
+    }
+
+    pub fn mesh_handle_raw(
+        &self,
+        registry: &AssetRegistry,
+        mesh_name: &str,
+    ) -> Option<AssetHandle> {
+        registry.mesh_handle(self.model_id(), mesh_name)
     }
 
     pub fn apply_material_override(
@@ -255,7 +279,7 @@ impl MeshRenderer {
     ) -> anyhow::Result<()> {
         self.apply_material_override_raw(
             &ASSET_REGISTRY,
-            &*MODEL_CACHE,
+            LazyLock::force(&MODEL_CACHE),
             target_material,
             source_model,
             source_material,
@@ -357,10 +381,7 @@ impl MeshRenderer {
         // ensure downstream caches observe the newly applied material state
         self.handle.refresh_registry_raw(registry);
 
-        {
-            let mut cache = model_cache.lock();
-            self.refresh_model_cache_raw(&mut cache);
-        }
+        self.refresh_model_cache_with(model_cache);
 
         Ok(())
     }
@@ -375,6 +396,19 @@ impl MeshRenderer {
     }
 
     pub fn restore_original_material(&mut self, target_material: &str) -> anyhow::Result<()> {
+        self.restore_original_material_raw(
+            target_material,
+            &ASSET_REGISTRY,
+            LazyLock::force(&MODEL_CACHE),
+        )
+    }
+
+    pub fn restore_original_material_raw(
+        &mut self,
+        target_material: &str,
+        registry: &AssetRegistry,
+        model_cache: &Mutex<HashMap<String, Arc<Model>>>,
+    ) -> anyhow::Result<()> {
         let snapshot = self
             .original_material_snapshots
             .get(target_material)
@@ -400,21 +434,20 @@ impl MeshRenderer {
                 let _ = model.clear_material_texture_tag(target_material);
             }
 
-            self.original_material_snapshots
-                .remove(target_material);
+            self.original_material_snapshots.remove(target_material);
         }
 
-        self.handle.refresh_registry();
-        self.refresh_model_cache();
+        self.handle.refresh_registry_raw(registry);
+        self.refresh_model_cache_with(model_cache);
 
         Ok(())
     }
 
-    fn refresh_model_cache(&self) {
-        let mut cache = MODEL_CACHE.lock();
-        self.refresh_model_cache_raw(&mut cache);
+    fn refresh_model_cache_with(&self, cache: &Mutex<HashMap<String, Arc<Model>>>) {
+        let mut guard = cache.lock();
+        self.refresh_model_cache_raw(&mut guard);
     }
-    
+
     fn refresh_model_cache_raw(&self, cache: &mut HashMap<String, Arc<Model>>) {
         let current = self.handle.get();
         let keys: Vec<String> = cache
diff --git a/dropbear-engine/src/model.rs b/dropbear-engine/src/model.rs
index 7352b60..40ab27f 100644
--- a/dropbear-engine/src/model.rs
+++ b/dropbear-engine/src/model.rs
@@ -1,3 +1,4 @@
+use crate::asset::AssetRegistry;
 use crate::{
     asset::{ASSET_REGISTRY, AssetHandle},
     graphics::{SharedGraphicsContext, Texture},
@@ -13,12 +14,11 @@ use std::sync::{Arc, LazyLock};
 use std::time::Instant;
 use std::{mem, ops::Range, path::PathBuf};
 use wgpu::{BufferAddress, VertexAttribute, VertexBufferLayout, util::DeviceExt};
-use crate::asset::AssetRegistry;
 
 pub const GREY_TEXTURE_BYTES: &[u8] = include_bytes!("../../resources/textures/grey.png");
 
-
-pub static MODEL_CACHE: LazyLock<Mutex<HashMap<String, Arc<Model>>>> = LazyLock::new(|| Mutex::new(HashMap::new()));
+pub static MODEL_CACHE: LazyLock<Mutex<HashMap<String, Arc<Model>>>> =
+    LazyLock::new(|| Mutex::new(HashMap::new()));
 
 #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
 pub struct ModelId(pub u64);
@@ -46,8 +46,12 @@ pub struct LoadedModel {
 
 impl LoadedModel {
     pub fn new(inner: Arc<Model>) -> Self {
+        Self::new_raw(&ASSET_REGISTRY, inner)
+    }
+
+    pub fn new_raw(registry: &AssetRegistry, inner: Arc<Model>) -> Self {
         let reference = inner.path.clone();
-        let handle = ASSET_REGISTRY.register_model(reference, Arc::clone(&inner));
+        let handle = registry.register_model(reference, Arc::clone(&inner));
         Self { inner, handle }
     }
 
@@ -62,9 +66,7 @@ impl LoadedModel {
     }
 
     pub fn from_asset_handle(handle: AssetHandle) -> Option<Arc<LoadedModel>> {
-        ASSET_REGISTRY
-            .get_model(handle)
-            .map(|model| Arc::new(Self::from_registered(handle, model)))
+        Self::from_asset_handle_raw(&ASSET_REGISTRY, handle).map(|model| Arc::new(model))
     }
 
     /// Returns the unique identifier of the underlying model asset.
@@ -94,9 +96,7 @@ impl LoadedModel {
     /// Re-registers the model with the global asset registry, ensuring cached
     /// sub-assets stay in sync after mutations.
     pub fn refresh_registry(&mut self) {
-        let reference = self.inner.path.clone();
-        let updated_handle = ASSET_REGISTRY.register_model(reference, self.get());
-        self.handle = updated_handle;
+        self.refresh_registry_raw(&ASSET_REGISTRY);
     }
 
     pub fn refresh_registry_raw(&mut self, registry: &AssetRegistry) {
@@ -106,11 +106,28 @@ impl LoadedModel {
     }
 
     pub fn contains_material_handle(&self, handle: AssetHandle) -> bool {
-        self.inner.contains_material_handle(handle)
+        self.contains_material_handle_raw(&ASSET_REGISTRY, handle)
+    }
+
+    pub fn contains_material_handle_raw(
+        &self,
+        registry: &AssetRegistry,
+        handle: AssetHandle,
+    ) -> bool {
+        self.inner.contains_material_handle_raw(registry, handle)
     }
 
     pub fn contains_material_reference(&self, reference: &ResourceReference) -> bool {
-        self.inner.contains_material_reference(reference)
+        self.contains_material_reference_raw(&ASSET_REGISTRY, reference)
+    }
+
+    pub fn contains_material_reference_raw(
+        &self,
+        registry: &AssetRegistry,
+        reference: &ResourceReference,
+    ) -> bool {
+        self.inner
+            .contains_material_reference_raw(registry, reference)
     }
 }
 
@@ -187,7 +204,11 @@ impl Model {
 
     /// Returns the registered asset handle for this model, if available.
     pub fn asset_handle(&self) -> Option<AssetHandle> {
-        ASSET_REGISTRY.model_handle_from_reference(&self.path)
+        self.asset_handle_raw(&ASSET_REGISTRY)
+    }
+
+    pub fn asset_handle_raw(&self, registry: &AssetRegistry) -> Option<AssetHandle> {
+        registry.model_handle_from_reference(&self.path)
     }
 
     /// Returns `true` if this model was loaded from the specified resource reference.
@@ -197,15 +218,32 @@ impl Model {
 
     /// Returns `true` if this model owns the supplied material handle.
     pub fn contains_material_handle(&self, material_handle: AssetHandle) -> bool {
-        ASSET_REGISTRY
-            .material_owner(material_handle) == Some(self.id)
+        self.contains_material_handle_raw(&ASSET_REGISTRY, material_handle)
+    }
+
+    pub fn contains_material_handle_raw(
+        &self,
+        registry: &AssetRegistry,
+        material_handle: AssetHandle,
+    ) -> bool {
+        registry.material_owner(material_handle) == Some(self.id)
     }
 
     /// Returns `true` if this model owns a material registered under the provided resource reference.
     pub fn contains_material_reference(&self, reference: &ResourceReference) -> bool {
-        ASSET_REGISTRY
+        self.contains_material_reference_raw(&ASSET_REGISTRY, reference)
+    }
+
+    pub fn contains_material_reference_raw(
+        &self,
+        registry: &AssetRegistry,
+        reference: &ResourceReference,
+    ) -> bool {
+        registry
             .material_handle_from_reference(reference)
-            .map_or(false, |handle| self.contains_material_handle(handle))
+            .map_or(false, |handle| {
+                self.contains_material_handle_raw(registry, handle)
+            })
     }
 
     /// Returns `true` if any material on this model is tagged with `texture_tag`.
@@ -220,22 +258,49 @@ impl Model {
         self.materials
             .iter()
             .find(|mat| mat.name == material_name)
-            .and_then(|mat| mat.texture_tag.as_deref()) == Some(texture_tag)
+            .and_then(|mat| mat.texture_tag.as_deref())
+            == Some(texture_tag)
     }
 
-    pub async fn load_from_memory(
+    pub async fn load_from_memory<B>(
         graphics: Arc<SharedGraphicsContext>,
-        buffer: impl AsRef<[u8]>,
+        buffer: B,
         label: Option<&str>,
-    ) -> anyhow::Result<LoadedModel> {
+    ) -> anyhow::Result<LoadedModel>
+    where
+        B: AsRef<[u8]>,
+    {
+        Self::load_from_memory_raw(
+            graphics,
+            buffer,
+            label,
+            &ASSET_REGISTRY,
+            LazyLock::force(&MODEL_CACHE),
+        )
+        .await
+    }
+
+    pub async fn load_from_memory_raw<B>(
+        graphics: Arc<SharedGraphicsContext>,
+        buffer: B,
+        label: Option<&str>,
+        registry: &AssetRegistry,
+        cache: &Mutex<HashMap<String, Arc<Model>>>,
+    ) -> anyhow::Result<LoadedModel>
+    where
+        B: AsRef<[u8]>,
+    {
         let start = Instant::now();
         let mut hasher = DefaultHasher::new();
 
         let cache_key = label.unwrap_or("default").to_string();
 
-        if let Some(cached_model) = MODEL_CACHE.lock().get(&cache_key) {
+        if let Some(cached_model) = {
+            let cache_guard = cache.lock();
+            cache_guard.get(&cache_key).cloned()
+        } {
             log::debug!("Model loaded from memory cache: {:?}", cache_key);
-            return Ok(LoadedModel::new(cached_model.clone()));
+            return Ok(LoadedModel::new_raw(registry, cached_model));
         }
 
         log::trace!(
@@ -416,9 +481,12 @@ impl Model {
             id: ModelId(hasher.finish()),
         });
 
-        let loaded = LoadedModel::new(Arc::clone(&model));
+        let loaded = LoadedModel::new_raw(registry, Arc::clone(&model));
 
-        MODEL_CACHE.lock().insert(cache_key.clone(), model);
+        {
+            let mut cache_guard = cache.lock();
+            cache_guard.insert(cache_key.clone(), model);
+        }
         log::trace!("==================== DONE ====================");
         log::debug!("Model cached from memory: {:?}", label);
         log::debug!("Took {:?} to load model: {:?}", start.elapsed(), label);
@@ -431,22 +499,42 @@ impl Model {
         path: &PathBuf,
         label: Option<&str>,
     ) -> anyhow::Result<LoadedModel> {
+        Self::load_raw(
+            graphics,
+            path,
+            label,
+            &ASSET_REGISTRY,
+            LazyLock::force(&MODEL_CACHE),
+        )
+        .await
+    }
+
+    pub async fn load_raw(
+        graphics: Arc<SharedGraphicsContext>,
+        path: &PathBuf,
+        label: Option<&str>,
+        registry: &AssetRegistry,
+        cache: &Mutex<HashMap<String, Arc<Model>>>,
+    ) -> anyhow::Result<LoadedModel> {
         let file_name = path.file_name();
         log::debug!("Loading model [{:?}]", file_name);
 
         let path_str = path.to_string_lossy().to_string();
 
         log::debug!("Checking if model exists in cache");
-        if let Some(cached_model) = MODEL_CACHE.lock().get(&path_str) {
+        if let Some(cached_model) = {
+            let cache_guard = cache.lock();
+            cache_guard.get(&path_str).cloned()
+        } {
             log::debug!("Model loaded from cache: {:?}", path_str);
-            return Ok(LoadedModel::new(cached_model.clone()));
+            return Ok(LoadedModel::new_raw(registry, cached_model));
         }
         log::debug!("Model does not exist in cache, loading memory...");
 
         log::debug!("Path of model: {}", path.display());
 
         let buffer = std::fs::read(path)?;
-        let loaded = Self::load_from_memory(graphics, buffer, label).await?;
+        let loaded = Self::load_from_memory_raw(graphics, buffer, label, registry, cache).await?;
 
         let mut model_clone: Model = (*loaded).clone();
         if let Ok(reference) = ResourceReference::from_path(path) {
@@ -458,15 +546,15 @@ impl Model {
 
         let updated = Arc::new(model_clone);
         {
-            let mut cache = MODEL_CACHE.lock();
-            cache.insert(path_str, Arc::clone(&updated));
+            let mut cache_guard = cache.lock();
+            cache_guard.insert(path_str.clone(), Arc::clone(&updated));
             if let Some(custom_label) = label {
-                cache.insert(custom_label.to_string(), Arc::clone(&updated));
+                cache_guard.insert(custom_label.to_string(), Arc::clone(&updated));
             }
         }
 
         log::debug!("Model cached and loaded: {:?}", file_name);
-        Ok(LoadedModel::new(updated))
+        Ok(LoadedModel::new_raw(registry, updated))
     }
 }
 
diff --git a/dropbear-engine/src/procedural/plane.rs b/dropbear-engine/src/procedural/plane.rs
index baf93b0..8c0418d 100644
--- a/dropbear-engine/src/procedural/plane.rs
+++ b/dropbear-engine/src/procedural/plane.rs
@@ -3,12 +3,15 @@
 //! Inspiration taken from `https://github.com/4tkbytes/RedLight/blob/main/src/RedLight/Entities/Plane.cs`,
 //! my old game engine made in C sharp, where this is the plane "algorithm".
 
+use crate::asset::{ASSET_REGISTRY, AssetRegistry};
 use crate::entity::MeshRenderer;
 use crate::graphics::{SharedGraphicsContext, Texture};
 use crate::model::{LoadedModel, MODEL_CACHE, Material, Mesh, Model, ModelId, ModelVertex};
 use crate::utils::{ResourceReference, ResourceReferenceType};
+use parking_lot::Mutex;
+use std::collections::HashMap;
 use std::hash::{DefaultHasher, Hash, Hasher};
-use std::sync::Arc;
+use std::sync::{Arc, LazyLock};
 use wgpu::{AddressMode, util::DeviceExt};
 
 /// Creates a plane wrapped in a [`MeshRenderer`].
@@ -48,10 +51,28 @@ impl PlaneBuilder {
     }
 
     pub async fn build(
+        self,
+        graphics: Arc<SharedGraphicsContext>,
+        texture_bytes: &[u8],
+        label: Option<&str>,
+    ) -> anyhow::Result<MeshRenderer> {
+        self.build_raw(
+            graphics,
+            texture_bytes,
+            label,
+            &ASSET_REGISTRY,
+            LazyLock::force(&MODEL_CACHE),
+        )
+        .await
+    }
+
+    pub async fn build_raw(
         mut self,
         graphics: Arc<SharedGraphicsContext>,
         texture_bytes: &[u8],
         label: Option<&str>,
+        registry: &AssetRegistry,
+        cache: &Mutex<HashMap<String, Arc<Model>>>,
     ) -> anyhow::Result<MeshRenderer> {
         let label = if let Some(label) = label {
             label.to_string()
@@ -98,9 +119,12 @@ impl PlaneBuilder {
 
         let hash = hasher.finish();
 
-        if let Some(cached_model) = MODEL_CACHE.lock().get(&label) {
+        if let Some(cached_model) = {
+            let cache_guard = cache.lock();
+            cache_guard.get(&label).cloned()
+        } {
             log::debug!("Model loaded from cache: {:?}", label);
-            let handle = LoadedModel::new(Arc::clone(cached_model));
+            let handle = LoadedModel::new_raw(registry, cached_model);
             return Ok(MeshRenderer::from_handle(handle));
         }
 
@@ -145,9 +169,12 @@ impl PlaneBuilder {
             id: ModelId(hash),
         });
 
-        MODEL_CACHE.lock().insert(label, Arc::clone(&model));
+        {
+            let mut cache_guard = cache.lock();
+            cache_guard.insert(label.clone(), Arc::clone(&model));
+        }
 
-        let handle = LoadedModel::new(model);
+        let handle = LoadedModel::new_raw(registry, model);
         Ok(MeshRenderer::from_handle(handle))
     }
 }
diff --git a/dropbear-engine/src/utils.rs b/dropbear-engine/src/utils.rs
index 81593bb..aa91fa7 100644
--- a/dropbear-engine/src/utils.rs
+++ b/dropbear-engine/src/utils.rs
@@ -4,7 +4,7 @@ use serde::{Deserialize, Serialize};
 use std::fmt::{Display, Formatter};
 use std::path::Path;
 
-pub const euca_SCHEME: &str = "euca://";
+pub const EUCA_SCHEME: &str = "euca://";
 
 /// Converts any supported resource reference into the canonical `euca://` form.
 ///
@@ -24,7 +24,7 @@ pub fn canonicalize_euca_uri(uri: &str) -> anyhow::Result<String> {
     }
 
     let normalized = trimmed.replace('\\', "/");
-    let (had_scheme, without_scheme) = if let Some(rest) = normalized.strip_prefix(euca_SCHEME) {
+    let (had_scheme, without_scheme) = if let Some(rest) = normalized.strip_prefix(EUCA_SCHEME) {
         (true, rest)
     } else {
         (false, normalized.as_str())
@@ -49,16 +49,16 @@ pub fn canonicalize_euca_uri(uri: &str) -> anyhow::Result<String> {
         log::debug!(
             "Canonicalized legacy resource reference '{}' to '{}{}'",
             uri,
-            euca_SCHEME,
+            EUCA_SCHEME,
             clean
         );
     }
 
-    Ok(format!("{euca_SCHEME}{clean}"))
+    Ok(format!("{EUCA_SCHEME}{clean}"))
 }
 
 pub fn relative_path_from_euca<'a>(uri: &'a str) -> anyhow::Result<&'a str> {
-    let without_scheme = uri.strip_prefix(euca_SCHEME).unwrap_or(uri);
+    let without_scheme = uri.strip_prefix(EUCA_SCHEME).unwrap_or(uri);
 
     let stripped = without_scheme.trim_start_matches('/');
     if stripped.is_empty() {
@@ -224,7 +224,7 @@ impl ResourceReference {
                     .collect::<Vec<_>>()
                     .join("/");
 
-                let canonical = canonicalize_euca_uri(&format!("{euca_SCHEME}{resource_path}"))?;
+                let canonical = canonicalize_euca_uri(&format!("{EUCA_SCHEME}{resource_path}"))?;
 
                 return Ok(Self {
                     ref_type: ResourceReferenceType::File(canonical),
diff --git a/eucalyptus-core/src/ptr.rs b/eucalyptus-core/src/ptr.rs
index e75a657..2b19cb6 100644
--- a/eucalyptus-core/src/ptr.rs
+++ b/eucalyptus-core/src/ptr.rs
@@ -1,8 +1,8 @@
 use crate::input::InputState;
 use crate::window::GraphicsCommand;
 use crossbeam_channel::Sender;
-use hecs::World;
 use dropbear_engine::asset::AssetRegistry;
+use hecs::World;
 
 pub type WorldPtr = *mut World;
 pub type InputStatePtr = *mut InputState;
diff --git a/eucalyptus-core/src/scripting.rs b/eucalyptus-core/src/scripting.rs
index 29a8597..ac413a6 100644
--- a/eucalyptus-core/src/scripting.rs
+++ b/eucalyptus-core/src/scripting.rs
@@ -8,12 +8,12 @@ use crate::scripting::native::NativeLibrary;
 use crate::states::ScriptComponent;
 use anyhow::Context;
 use crossbeam_channel::Sender;
+use dropbear_engine::asset::ASSET_REGISTRY;
 use hecs::{Entity, World};
 use std::collections::HashMap;
 use std::path::{Path, PathBuf};
 use tokio::io::{AsyncBufReadExt, BufReader};
 use tokio::process::Command;
-use dropbear_engine::asset::{ASSET_REGISTRY};
 
 /// The target of the script. This can be either a JVM or a native library.
 #[derive(Default, Clone)]
diff --git a/eucalyptus-core/src/scripting/jni.rs b/eucalyptus-core/src/scripting/jni.rs
index 65e75c7..23d60ae 100644
--- a/eucalyptus-core/src/scripting/jni.rs
+++ b/eucalyptus-core/src/scripting/jni.rs
@@ -1,6 +1,7 @@
 #![allow(non_snake_case)]
 //! Deals with the Java Native Interface (JNI) with the help of the [`jni`] crate
 
+pub mod error;
 pub mod exception;
 pub mod exports;
 pub mod utils;
diff --git a/eucalyptus-core/src/scripting/jni/error.rs b/eucalyptus-core/src/scripting/jni/error.rs
new file mode 100644
index 0000000..6d94e63
--- /dev/null
+++ b/eucalyptus-core/src/scripting/jni/error.rs
@@ -0,0 +1,51 @@
+use parking_lot::Mutex;
+use std::sync::LazyLock;
+
+/// Shared buffer holding the most recent JNI error message.
+static LAST_ERROR_MESSAGE: LazyLock<Mutex<String>> = LazyLock::new(|| Mutex::new(String::new()));
+
+fn buffer() -> &'static Mutex<String> {
+    LazyLock::force(&LAST_ERROR_MESSAGE)
+}
+
+/// Returns a raw pointer to the shared error buffer so it can be cached on the
+/// Kotlin side. The pointer is stable for the lifetime of the process.
+pub fn get_last_error_message_ptr() -> *const Mutex<String> {
+    buffer() as *const Mutex<String>
+}
+
+/// Provides direct access to the shared error buffer for callers that prefer a
+/// reference over the raw pointer.
+pub fn last_error_message_mutex() -> &'static Mutex<String> {
+    buffer()
+}
+
+/// Replaces the stored error message with `message`.
+pub fn set_last_error_message(message: impl AsRef<str>) {
+    let mut guard = buffer().lock();
+    guard.clear();
+    guard.push_str(message.as_ref());
+}
+
+/// Clears any stored error message.
+pub fn clear_last_error_message() {
+    buffer().lock().clear();
+}
+
+/// Clones and returns the currently stored error message.
+///
+/// The buffer contents remain untouched so callers can fetch the message multiple
+/// times if necessary.
+pub fn get_last_error_message() -> String {
+    buffer().lock().clone()
+}
+
+/// Convenience helper for appending additional context onto the current
+/// error message.
+pub fn append_last_error_message(fragment: impl AsRef<str>) {
+    let mut guard = buffer().lock();
+    if !guard.is_empty() {
+        guard.push('\n');
+    }
+    guard.push_str(fragment.as_ref());
+}
diff --git a/eucalyptus-core/src/scripting/jni/exports.rs b/eucalyptus-core/src/scripting/jni/exports.rs
index fb95db4..05f9824 100644
--- a/eucalyptus-core/src/scripting/jni/exports.rs
+++ b/eucalyptus-core/src/scripting/jni/exports.rs
@@ -1,20 +1,32 @@
 use crate::camera::{CameraComponent, CameraType};
 use crate::ptr::{AssetRegistryPtr, GraphicsPtr, InputStatePtr, WorldPtr};
+use crate::scripting::jni::error::{
+    clear_last_error_message, get_last_error_message, get_last_error_message_ptr,
+    set_last_error_message,
+};
 use crate::scripting::jni::utils::{
     create_vector3, extract_vector3, java_button_to_rust, new_float_array,
 };
 use crate::states::{Label, ModelProperties, Value};
 use crate::utils::keycode_from_ordinal;
 use crate::window::{GraphicsCommand, WindowCommand};
+use crate::{convert_jstring, convert_ptr};
+use dropbear_engine::asset::PointerKind::Const;
+use dropbear_engine::asset::{ASSET_REGISTRY, AssetHandle, AssetRegistry};
 use dropbear_engine::camera::Camera;
 use dropbear_engine::entity::{MeshRenderer, Transform};
+use dropbear_engine::model::Model;
+use dropbear_engine::utils::ResourceReference;
 use glam::{DQuat, DVec3};
 use hecs::World;
 use jni::JNIEnv;
 use jni::objects::{JClass, JObject, JPrimitiveArray, JString, JValue};
-use jni::sys::{JNI_FALSE, jboolean, jclass, jdouble, jfloatArray, jint, jlong, jobject, jstring};
-use dropbear_engine::asset::{AssetHandle};
-use dropbear_engine::utils::ResourceReference;
+use jni::sys::{
+    JNI_FALSE, jboolean, jclass, jdouble, jfloatArray, jint, jlong, jobject, jobjectArray, jstring,
+};
+use parking_lot::Mutex;
+use std::collections::{HashMap, HashSet};
+use std::sync::Arc;
 
 /// `JNIEXPORT jlong JNICALL Java_com_dropbear_ffi_JNINative_getEntity
 ///   (JNIEnv *, jclass, jlong, jstring);`
@@ -1726,11 +1738,15 @@ pub fn Java_com_dropbear_ffi_JNINative_getModel(
     let world = unsafe { &*world };
     let entity = unsafe { world.find_entity_from_id(entity as u32) };
 
-    if let Ok(mut q) = world.query_one::<&MeshRenderer>(entity) && let Some(model) = q.get() {
+    if let Ok(mut q) = world.query_one::<&MeshRenderer>(entity)
+        && let Some(model) = q.get()
+    {
         let handle = model.asset_handle();
         handle.raw() as jlong
     } else {
-        println!("[Java_com_dropbear_ffi_JNINative_getModel] [ERROR] Unable to find entity in world");
+        println!(
+            "[Java_com_dropbear_ffi_JNINative_getModel] [ERROR] Unable to find entity in world"
+        );
         -1
     }
 }
@@ -1754,7 +1770,9 @@ pub fn Java_com_dropbear_ffi_JNINative_setModel(
 
     let asset = asset_handle as AssetRegistryPtr;
     if asset.is_null() {
-        println!("[Java_com_dropbear_ffi_JNINative_setModel] [ERROR] Asset registry pointer is null");
+        println!(
+            "[Java_com_dropbear_ffi_JNINative_setModel] [ERROR] Asset registry pointer is null"
+        );
         return;
     }
 
@@ -1762,8 +1780,8 @@ pub fn Java_com_dropbear_ffi_JNINative_setModel(
     let asset = unsafe { &*asset };
     let entity = unsafe { world.find_entity_from_id(entity as u32) };
 
-    if let Ok(mut q) = world.query_one::<&mut MeshRenderer>(entity) &&
-        let Some(model) = q.get()
+    if let Ok(mut q) = world.query_one::<&mut MeshRenderer>(entity)
+        && let Some(model) = q.get()
     {
         let asset_handle = AssetHandle::new(model_handle as u64);
         if !asset.contains_handle(asset_handle) {
@@ -1771,18 +1789,68 @@ pub fn Java_com_dropbear_ffi_JNINative_setModel(
             return;
         }
         if let Err(e) = model.set_asset_handle_raw(asset, asset_handle) {
-            println!("[Java_com_dropbear_ffi_JNINative_setModel] [ERROR] Unable to set model: {}", e);
+            println!(
+                "[Java_com_dropbear_ffi_JNINative_setModel] [ERROR] Unable to set model: {}",
+                e
+            );
+        }
+    }
+}
+
+/// `JNIEXPORT jboolean JNICALL Java_com_dropbear_ffi_JNINative_isModelHandle
+///   (JNIEnv *, jclass, jlong, jlong);`
+#[unsafe(no_mangle)]
+pub fn Java_com_dropbear_ffi_JNINative_isModelHandle(
+    _env: JNIEnv,
+    _class: JClass,
+    asset_handle: jlong,
+    model_id: jlong,
+) -> jboolean {
+    let asset = convert_ptr!(asset_handle, AssetRegistryPtr => AssetRegistry);
+
+    asset
+        .contains_handle(AssetHandle::new(model_id as u64))
+        .into()
+}
+
+/// `JNIEXPORT jboolean JNICALL Java_com_dropbear_ffi_JNINative_isUsingModel
+///   (JNIEnv *, jclass, jlong, jlong, jlong);`
+#[unsafe(no_mangle)]
+pub fn Java_com_dropbear_ffi_JNINative_isUsingModel(
+    _env: JNIEnv,
+    _class: JClass,
+    world_handle: jlong,
+    entity: jlong,
+    model_handle: jlong,
+) -> jboolean {
+    let world = convert_ptr!(world_handle, WorldPtr => World);
+    let entity = unsafe { world.find_entity_from_id(entity as u32) };
+
+    let handle = AssetHandle::new(model_handle as u64);
+    if let Ok(mut q) = world.query_one::<&MeshRenderer>(entity)
+        && let Some(model) = q.get()
+    {
+        if model.asset_handle() == handle {
+            true.into()
+        } else {
+            false.into()
         }
+    } else {
+        println!(
+            "[Java_com_dropbear_ffi_JNINative_isUsingModel] [ERROR] Unable to find entity in world"
+        );
+        false.into()
     }
 }
 
 /// `JNIEXPORT jlong JNICALL Java_com_dropbear_ffi_JNINative_getTexture
-///   (JNIEnv *, jclass, jlong, jlong, jstring);`
+///   (JNIEnv *, jclass, jlong, jlong, jlong, jstring);`
 #[unsafe(no_mangle)]
 pub fn Java_com_dropbear_ffi_JNINative_getTexture(
     mut env: JNIEnv,
     _class: JClass,
     world_handle: jlong,
+    asset_handle: jlong,
     entity: jlong,
     name: JString,
 ) -> jlong {
@@ -1793,188 +1861,356 @@ pub fn Java_com_dropbear_ffi_JNINative_getTexture(
     }
 
     let world = unsafe { &*world };
+
+    let asset = convert_ptr!(asset_handle, AssetRegistryPtr => AssetRegistry);
     let entity = unsafe { world.find_entity_from_id(entity as u32) };
 
-    if let Ok(mut q) = world.query_one::<&MeshRenderer>(entity) && let Some(mesh) = q.get() {
-        let jni_result = env.get_string(&name);
-        let str = match jni_result {
-            Ok(java_string) => match java_string.to_str() {
-                Ok(rust_str) => rust_str.to_string(),
+    if let Ok(mut q) = world.query_one::<&MeshRenderer>(entity)
+        && let Some(mesh) = q.get()
+    {
+        let str = convert_jstring!(env, name);
+        if let Some(handle) = mesh.material_handle_raw(asset, str.as_str()) {
+            handle.raw() as jlong
+        } else {
+            println!("[Java_com_dropbear_ffi_JNINative_getTexture] [ERROR] Invalid texture handle");
+            0
+        }
+    } else {
+        println!(
+            "[Java_com_dropbear_ffi_JNINative_getTexture] [ERROR] Unable to find entity in world"
+        );
+        0
+    }
+}
+
+/// `JNIEXPORT jobjectArray JNICALL Java_com_dropbear_ffi_JNINative_getAllTextures
+///   (JNIEnv *, jclass, jlong, jlong);`
+#[unsafe(no_mangle)]
+pub fn Java_com_dropbear_ffi_JNINative_getAllTextures(
+    mut env: JNIEnv,
+    _class: JClass,
+    world_handle: jlong,
+    entity: jlong,
+) -> jobjectArray {
+    let world = convert_ptr!(world_handle, WorldPtr => World);
+    let entity = unsafe { world.find_entity_from_id(entity as u32) };
+
+    let mut query = match world.query_one::<&MeshRenderer>(entity) {
+        Ok(query) => query,
+        Err(e) => {
+            let message = format!(
+                "[Java_com_dropbear_ffi_JNINative_getAllTextures] [ERROR] Failed to query entity: {}",
+                e
+            );
+            set_last_error_message(&message);
+            println!("{}", message);
+            return std::ptr::null_mut();
+        }
+    };
+
+    let renderer = match query.get() {
+        Some(renderer) => renderer,
+        None => {
+            let message = "[Java_com_dropbear_ffi_JNINative_getAllTextures] [ERROR] Entity does not have a MeshRenderer component";
+            set_last_error_message(message);
+            println!("{}", message);
+            return std::ptr::null_mut();
+        }
+    };
+
+    let model = renderer.model();
+    let model_id = renderer.model_id();
+
+    let mut seen = HashSet::new();
+    let mut textures = Vec::new();
+
+    for material in &model.materials {
+        let reference = ASSET_REGISTRY
+            .material_handle(model_id, &material.name)
+            .and_then(|handle| ASSET_REGISTRY.material_reference_for_handle(handle))
+            .and_then(|reference| reference.as_uri().map(|uri| uri.to_string()))
+            .or_else(|| material.texture_tag.clone())
+            .unwrap_or_else(|| material.name.clone());
+
+        if seen.insert(reference.clone()) {
+            textures.push(reference);
+        }
+    }
+
+    let string_class = match env.find_class("java/lang/String") {
+        Ok(class) => class,
+        Err(e) => {
+            let message = format!(
+                "[Java_com_dropbear_ffi_JNINative_getAllTextures] [ERROR] Failed to locate java/lang/String: {}",
+                e
+            );
+            set_last_error_message(&message);
+            println!("{}", message);
+            return std::ptr::null_mut();
+        }
+    };
+
+    let array = match env.new_object_array(textures.len() as i32, string_class, JObject::null()) {
+        Ok(array) => array,
+        Err(e) => {
+            let message = format!(
+                "[Java_com_dropbear_ffi_JNINative_getAllTextures] [ERROR] Failed to allocate string array: {}",
+                e
+            );
+            set_last_error_message(&message);
+            println!("{}", message);
+            return std::ptr::null_mut();
+        }
+    };
+
+    for (index, value) in textures.iter().enumerate() {
+        let java_string = match env.new_string(value) {
+            Ok(string) => string,
+            Err(e) => {
+                let message = format!(
+                    "[Java_com_dropbear_ffi_JNINative_getAllTextures] [ERROR] Failed to create Java string: {}",
+                    e
+                );
+                set_last_error_message(&message);
+                println!("{}", message);
+                return std::ptr::null_mut();
+            }
+        };
+
+        if let Err(e) =
+            env.set_object_array_element(&array, index as i32, JObject::from(java_string))
+        {
+            let message = format!(
+                "[Java_com_dropbear_ffi_JNINative_getAllTextures] [ERROR] Failed to set array element: {}",
+                e
+            );
+            set_last_error_message(&message);
+            println!("{}", message);
+            return std::ptr::null_mut();
+        }
+    }
+
+    clear_last_error_message();
+    array.into_raw()
+}
+
+/// `JNIEXPORT void JNICALL Java_com_dropbear_ffi_JNINative_setTexture
+///   (JNIEnv *, jclass, jlong, jlong, jlong, jstring, jlong);`
+#[unsafe(no_mangle)]
+pub fn Java_com_dropbear_ffi_JNINative_setTexture(
+    mut env: JNIEnv,
+    _class: JClass,
+    world_handle: jlong,
+    asset_handle: jlong,
+    entity: jlong,
+    old_material_name: JString,
+    new_texture_handle: jlong,
+) {
+    let world = world_handle as WorldPtr;
+    if world.is_null() {
+        println!("[Java_com_dropbear_ffi_JNINative_setTexture] [ERROR] World pointer is null");
+        return;
+    }
+
+    let asset = asset_handle as AssetRegistryPtr;
+    if asset.is_null() {
+        println!(
+            "[Java_com_dropbear_ffi_JNINative_setTexture] [ERROR] Asset registry pointer is null"
+        );
+        return;
+    }
+
+    let asset = unsafe { &*asset };
+
+    let world = unsafe { &*world };
+    let entity = unsafe { world.find_entity_from_id(entity as u32) };
+
+    match world.query_one::<&mut MeshRenderer>(entity) {
+        Ok(mut query) => {
+            let Some(renderer) = query.get() else {
+                println!(
+                    "[Java_com_dropbear_ffi_JNINative_setTexture] [ERROR] Entity does not have a MeshRenderer component"
+                );
+                return;
+            };
+
+            let Some(cache) = asset.get_pointer(Const("model_cache")) else {
+                println!(
+                    "[Java_com_dropbear_ffi_JNINative_setTexture] [ERROR] Asset registry does not contain model cache"
+                );
+                return;
+            };
+
+            let cache = cache as *const Mutex<HashMap<String, Arc<Model>>>;
+            let cache = unsafe { &*cache };
+
+            let jni_result = env.get_string(&old_material_name);
+            let target_material = match jni_result {
+                Ok(java_string) => match java_string.to_str() {
+                    Ok(rust_str) => rust_str.to_string(),
+                    Err(e) => {
+                        println!(
+                            "[Java_com_dropbear_ffi_JNINative_setTexture] [ERROR] Failed to convert Java string to Rust string: {}",
+                            e
+                        );
+                        return;
+                    }
+                },
                 Err(e) => {
                     println!(
-                        "[Java_com_dropbear_ffi_JNINative_getTexture] [ERROR] Failed to convert Java string to Rust string: {}",
+                        "[Java_com_dropbear_ffi_JNINative_setTexture] [ERROR] Failed to get string from JNI: {}",
                         e
                     );
-                    return -1;
+                    return;
                 }
-            },
-            Err(e) => {
+            };
+
+            let handle = AssetHandle::new(new_texture_handle as u64);
+
+            if !asset.contains_handle(handle) {
                 println!(
-                    "[Java_com_dropbear_ffi_JNINative_getTexture] [ERROR] Failed to get string from JNI: {}",
+                    "[Java_com_dropbear_ffi_JNINative_setTexture] [ERROR] Invalid texture handle: {}",
+                    new_texture_handle
+                );
+                return;
+            }
+
+            if !asset.is_material(handle) {
+                println!(
+                    "[Java_com_dropbear_ffi_JNINative_setTexture] [ERROR] Handle {} does not refer to a material",
+                    new_texture_handle
+                );
+                return;
+            }
+
+            let Some(material) = asset.get_material(handle) else {
+                println!(
+                    "[Java_com_dropbear_ffi_JNINative_setTexture] [ERROR] Invalid texture handle"
+                );
+                return;
+            };
+
+            let Some(owner_model_id) = asset.material_owner(handle) else {
+                println!(
+                    "[Java_com_dropbear_ffi_JNINative_setTexture] [ERROR] Unable to determine owning model for material handle {}",
+                    new_texture_handle
+                );
+                return;
+            };
+
+            let Some(owner_model_handle) = asset.model_handle_from_id(owner_model_id) else {
+                println!(
+                    "[Java_com_dropbear_ffi_JNINative_setTexture] [ERROR] Unable to resolve model handle for owner id {:?}",
+                    owner_model_id
+                );
+                return;
+            };
+
+            let Some(source_reference) = asset.model_reference_for_handle(owner_model_handle)
+            else {
+                println!(
+                    "[Java_com_dropbear_ffi_JNINative_setTexture] [ERROR] Unable to resolve model reference for handle {}",
+                    owner_model_handle.raw()
+                );
+                return;
+            };
+
+            let material_name = material.name.clone();
+
+            if let Err(e) = renderer.apply_material_override_raw(
+                asset,
+                cache,
+                target_material.as_str(),
+                source_reference,
+                material_name.as_str(),
+            ) {
+                println!(
+                    "[Java_com_dropbear_ffi_JNINative_setTexture] [ERROR] Failed to apply material override: {}",
                     e
                 );
-                return -1;
             }
-        };
-        if let Some(handle) = mesh.material_handle(str.as_str()) {
-            handle.raw() as jlong
-        } else {
-            -1
         }
+        Err(err) => {
+            println!(
+                "[Java_com_dropbear_ffi_JNINative_setTexture] [ERROR] Unable to query MeshRenderer: {}",
+                err
+            );
+        }
+    }
+}
+
+/// `JNIEXPORT jstring JNICALL Java_com_dropbear_ffi_JNINative_getTextureName
+///   (JNIEnv *, jclass, jlong, jlong);`
+#[unsafe(no_mangle)]
+pub fn Java_com_dropbear_ffi_JNINative_getTextureName(
+    env: JNIEnv,
+    _class: JClass,
+    asset_handle: jlong,
+    texture_id: jlong,
+) -> jstring {
+    let asset = convert_ptr!(asset_handle, AssetRegistryPtr => AssetRegistry);
+
+    let texture_id = AssetHandle::new(texture_id as u64);
+    asset.get_material(texture_id).map_or_else(
+        || {
+            println!(
+                "[Java_com_dropbear_ffi_JNINative_getTextureName] [ERROR] Invalid texture handle"
+            );
+            return std::ptr::null_mut();
+        },
+        |material| {
+            let Ok(str) = env.new_string(material.name.as_str()) else {
+                return std::ptr::null_mut();
+            };
+
+            str.into_raw()
+        },
+    )
+}
+
+/// `JNIEXPORT jboolean JNICALL Java_com_dropbear_ffi_JNINative_isTextureHandle
+///   (JNIEnv *, jclass, jlong, jlong);`
+#[unsafe(no_mangle)]
+pub fn Java_com_dropbear_ffi_JNINative_isTextureHandle(
+    _env: JNIEnv,
+    _class: JClass,
+    asset_handle: jlong,
+    texture_id: jlong,
+) -> jboolean {
+    let asset = convert_ptr!(asset_handle, AssetRegistryPtr => AssetRegistry);
+    let texture_id = AssetHandle::new(texture_id as u64);
+    if asset.is_material(texture_id) {
+        true.into()
     } else {
-        -1
+        false.into()
     }
 }
 
-// /// `JNIEXPORT void JNICALL Java_com_dropbear_ffi_JNINative_setTexture
-// ///   (JNIEnv *, jclass, jlong, jlong, jlong, jlong);`
-// #[unsafe(no_mangle)]
-// pub fn Java_com_dropbear_ffi_JNINative_setTexture(
-//     _env: JNIEnv,
-//     _class: JClass,
-//     world_handle: jlong,
-//     asset_handle: jlong,
-//     entity: jlong,
-//     texture_handle: jlong,
-// ) {
-//     let world = world_handle as WorldPtr;
-//     if world.is_null() {
-//         println!("[Java_com_dropbear_ffi_JNINative_setTexture] [ERROR] World pointer is null");
-//         return;
-//     }
-//
-//     let asset = asset_handle as AssetRegistryPtr;
-//     if asset.is_null() {
-//         println!("[Java_com_dropbear_ffi_JNINative_setTexture] [ERROR] Asset registry pointer is null");
-//         return;
-//     }
-//
-//     let asset = unsafe { &*asset };
-//
-//     let world = unsafe { &*world };
-//     let entity = unsafe { world.find_entity_from_id(entity as u32) };
-//
-//     match world.query_one::<&mut MeshRenderer>(entity) {
-//         Ok(mut query) => {
-//             let Some(renderer) = query.get() else {
-//                 println!(
-//                     "[Java_com_dropbear_ffi_JNINative_setTexture] [ERROR] Entity does not have a MeshRenderer component"
-//                 );
-//                 return;
-//             };
-//
-//             let material_handle = AssetHandle::new(texture_handle as u64);
-//             if !asset.contains_handle(material_handle) {
-//                 println!(
-//                     "[Java_com_dropbear_ffi_JNINative_setTexture] [ERROR] Material handle {} is not registered",
-//                     material_handle.raw()
-//                 );
-//                 return;
-//             }
-//
-//             if !asset.is_material(material_handle) {
-//                 println!(
-//                     "[Java_com_dropbear_ffi_JNINative_setTexture] [ERROR] Handle {} does not reference a material",
-//                     material_handle.raw()
-//                 );
-//                 return;
-//             }
-//
-//             let Some(material) = asset.get_material(material_handle) else {
-//                 println!(
-//                     "[Java_com_dropbear_ffi_JNINative_setTexture] [ERROR] Unable to fetch material for handle {}",
-//                     material_handle.raw()
-//                 );
-//                 return;
-//             };
-//
-//             let Some(owner_model_id) = asset.material_owner(material_handle) else {
-//                 println!(
-//                     "[Java_com_dropbear_ffi_JNINative_setTexture] [ERROR] Unable to resolve owning model for material {}",
-//                     material_handle.raw()
-//                 );
-//                 return;
-//             };
-//
-//             let Some(owner_model_handle) = asset.model_handle_from_id(owner_model_id) else {
-//                 println!(
-//                     "[Java_com_dropbear_ffi_JNINative_setTexture] [ERROR] Unable to resolve model handle for owner {:?}",
-//                     owner_model_id
-//                 );
-//                 return;
-//             };
-//
-//             let Some(source_model_reference) = asset.model_reference_for_handle(owner_model_handle) else {
-//                 println!(
-//                     "[Java_com_dropbear_ffi_JNINative_setTexture] [ERROR] Unable to resolve resource reference for model handle {}",
-//                     owner_model_handle.raw()
-//                 );
-//                 return;
-//             };
-//
-//             let source_material_name = material.name.clone();
-//
-//             let target_material_name = {
-//                 let current_model = renderer.model();
-//                 if current_model
-//                     .materials
-//                     .iter()
-//                     .any(|existing| existing.name == source_material_name)
-//                 {
-//                     Some(source_material_name.clone())
-//                 } else {
-//                     current_model
-//                         .materials
-//                         .first()
-//                         .map(|existing| existing.name.clone())
-//                 }
-//             };
-//
-//             let Some(target_material_name) = target_material_name else {
-//                 println!(
-//                     "[Java_com_dropbear_ffi_JNINative_setTexture] [ERROR] Entity model has no materials to override"
-//                 );
-//                 return;
-//             };
-//
-//             let Some(cache_raw) = asset.get_pointer(PointerKind::Const("model_cache")) else {
-//                 println!(
-//                     "[Java_com_dropbear_ffi_JNINative_setTexture] [ERROR] Model cache pointer is not registered"
-//                 );
-//                 return;
-//             };
-//
-//             let cache_mutex_ptr = cache_raw as *const Mutex<HashMap<String, Arc<Model>>>;
-//             if cache_mutex_ptr.is_null() {
-//                 println!(
-//                     "[Java_com_dropbear_ffi_JNINative_setTexture] [ERROR] Model cache pointer is null"
-//                 );
-//                 return;
-//             }
-//
-//             let cache_mutex = unsafe { &*cache_mutex_ptr };
-//
-//             if let Err(err) = renderer.apply_material_override_raw(
-//                 asset,
-//                 cache_mutex,
-//                 &target_material_name,
-//                 source_model_reference,
-//                 &source_material_name,
-//             ) {
-//                 println!(
-//                     "[Java_com_dropbear_ffi_JNINative_setTexture] [ERROR] Failed to apply material override: {}",
-//                     err
-//                 );
-//             }
-//         }
-//         Err(err) => {
-//             println!(
-//                 "[Java_com_dropbear_ffi_JNINative_setTexture] [ERROR] Unable to query MeshRenderer: {}",
-//                 err
-//             );
-//         }
-//     }
-// }
+/// `JNIEXPORT jboolean JNICALL Java_com_dropbear_ffi_JNINative_isUsingTexture
+///   (JNIEnv *, jclass, jlong, jlong, jstring);`
+#[unsafe(no_mangle)]
+pub fn Java_com_dropbear_ffi_JNINative_isUsingTexture(
+    _env: JNIEnv,
+    _class: JClass,
+    world_handle: jlong,
+    entity: jlong,
+    texture_handle: jlong,
+) -> jboolean {
+    let world = convert_ptr!(world_handle, WorldPtr => World);
+    let entity = unsafe { world.find_entity_from_id(entity as u32) };
+
+    if let Ok(mut q) = world.query_one::<&MeshRenderer>(entity)
+        && let Some(mesh) = q.get()
+    {
+        mesh.contains_material_handle(AssetHandle::new(texture_handle as u64))
+            .into()
+    } else {
+        println!(
+            "[Java_com_dropbear_ffi_JNINative_isUsingTexture] [ERROR] Unable to find entity in world"
+        );
+        false.into()
+    }
+}
 
 /// `JNIEXPORT jlong JNICALL Java_com_dropbear_ffi_JNINative_getAsset
 ///   (JNIEnv *, jclass, jlong, jstring);`
@@ -1987,7 +2223,9 @@ pub fn Java_com_dropbear_ffi_JNINative_getAsset(
 ) -> jlong {
     let asset = asset_handle as AssetRegistryPtr;
     if asset.is_null() {
-        println!("[Java_com_dropbear_ffi_JNINative_getAsset] [ERROR] Asset registry pointer is null");
+        println!(
+            "[Java_com_dropbear_ffi_JNINative_getAsset] [ERROR] Asset registry pointer is null"
+        );
         return -1;
     }
 
@@ -2013,8 +2251,60 @@ pub fn Java_com_dropbear_ffi_JNINative_getAsset(
             return -1;
         }
     };
-    if let Ok(res) = ResourceReference::from_euca_uri(str) && let Some(asset_handle) = asset.get_handle_from_reference(&res) {
+    if let Ok(res) = ResourceReference::from_euca_uri(str)
+        && let Some(asset_handle) = asset.get_handle_from_reference(&res)
+    {
         return asset_handle.raw() as jlong;
     };
-    -1 as jlong
-}
\ No newline at end of file
+    0 as jlong
+}
+
+/// `JNIEXPORT jstring JNICALL Java_com_dropbear_ffi_JNINative_getLastErrorMsg
+///   (JNIEnv *, jclass, jlong);`
+#[unsafe(no_mangle)]
+pub fn Java_com_dropbear_ffi_JNINative_getLastErrorMsg(
+    env: JNIEnv,
+    _class: JClass,
+    asset_handle: jlong,
+) -> jstring {
+    let asset = convert_ptr!(asset_handle, AssetRegistryPtr => AssetRegistry);
+    if asset.get_pointer(Const("last_error_msg")).is_none() {
+        let ptr = get_last_error_message_ptr() as usize;
+        asset.add_pointer(Const("last_error_msg"), ptr);
+    }
+    let message = get_last_error_message();
+    match env.new_string(&message) {
+        Ok(java_string) => {
+            clear_last_error_message();
+            java_string.into_raw()
+        }
+        Err(e) => {
+            let fallback = format!(
+                "[Java_com_dropbear_ffi_JNINative_getLastErrorMsg] [ERROR] Failed to allocate Java string: {}",
+                e
+            );
+            println!("{}", fallback);
+            set_last_error_message(&fallback);
+            std::ptr::null_mut()
+        }
+    }
+}
+
+/// `JNIEXPORT jlong JNICALL Java_com_dropbear_ffi_JNINative_getLastErrMsgPtr
+///   (JNIEnv *, jclass, jlong);`
+#[unsafe(no_mangle)]
+pub fn Java_com_dropbear_ffi_JNINative_getLastErrMsgPtr(
+    _env: JNIEnv,
+    _class: JClass,
+    asset_handle: jlong,
+) -> jlong {
+    let asset = convert_ptr!(asset_handle, AssetRegistryPtr => AssetRegistry);
+    let pointer = asset
+        .get_pointer(Const("last_error_msg"))
+        .unwrap_or_else(|| {
+            let ptr = get_last_error_message_ptr() as usize;
+            asset.add_pointer(Const("last_error_msg"), ptr);
+            ptr
+        });
+    pointer as jlong
+}
diff --git a/eucalyptus-core/src/scripting/native.rs b/eucalyptus-core/src/scripting/native.rs
index 384dc42..03fe756 100644
--- a/eucalyptus-core/src/scripting/native.rs
+++ b/eucalyptus-core/src/scripting/native.rs
@@ -118,6 +118,11 @@ impl NativeLibrary {
 
 /// Displays the types of errors that can be returned by the native library.
 pub enum DropbearNativeError {
+    /// An error in the case the function returns an unsigned value.
+    ///
+    /// Subtract [`DropbearNativeError::UnsignedGenericError`] with another value
+    /// to get the alternative unsigned error.
+    UnsignedGenericError = 65535,
     Success = 0,
     NullPointer = -1,
     QueryFailed = -2,
diff --git a/eucalyptus-core/src/scripting/native/sig.rs b/eucalyptus-core/src/scripting/native/sig.rs
index 885a609..f807c31 100644
--- a/eucalyptus-core/src/scripting/native/sig.rs
+++ b/eucalyptus-core/src/scripting/native/sig.rs
@@ -3,8 +3,12 @@ use crate::ptr::{AssetRegistryPtr, GraphicsPtr, InputStatePtr, WorldPtr};
 use std::ffi::c_char;
 
 /// CName: `dropbear_init`
-pub type Init =
-    unsafe extern "C" fn(world: WorldPtr, input: InputStatePtr, graphics: GraphicsPtr, asset: AssetRegistryPtr) -> i32;
+pub type Init = unsafe extern "C" fn(
+    world: WorldPtr,
+    input: InputStatePtr,
+    graphics: GraphicsPtr,
+    asset: AssetRegistryPtr,
+) -> i32;
 /// CName: `dropbear_load_tagged`
 pub type LoadTagged = unsafe extern "C" fn(tag: *const c_char) -> i32;
 /// CName: `dropbear_update_all`
diff --git a/eucalyptus-core/src/utils.rs b/eucalyptus-core/src/utils.rs
index 106ece4..e727271 100644
--- a/eucalyptus-core/src/utils.rs
+++ b/eucalyptus-core/src/utils.rs
@@ -1,7 +1,6 @@
-use std::path::PathBuf;
-
 use crate::states::Node;
 use dropbear_engine::utils::{ResourceReference, ResourceReferenceType, relative_path_from_euca};
+use std::path::PathBuf;
 use winit::keyboard::KeyCode;
 
 pub const PROTO_TEXTURE: &[u8] = include_bytes!("../../resources/textures/proto.png");
@@ -287,3 +286,175 @@ impl ResolveReference for ResourceReference {
         }
     }
 }
+
+/// Validates and converts a raw pointer to a reference.
+/// Returns early if the pointer is null.
+///
+/// # Example
+/// ```rust
+/// use eucalyptus_core::{convert_ptr, ptr::AssetRegistryPtr};
+/// use dropbear_engine::asset::AssetRegistry;
+///
+/// let asset_handle = 0x12345678; // pointer
+///
+/// let asset = convert_ptr!(asset_handle, AssetRegistryPtr => AssetRegistry);
+/// ```
+#[macro_export]
+macro_rules! convert_ptr {
+    ($ptr:expr, $ptr_ty:ty => $target_ty:ty) => {{
+        let ptr = $ptr as $ptr_ty;
+        if ptr.is_null() {
+            let message = format!(
+                "[{}] [ERROR] {} pointer is null",
+                std::any::type_name::<$target_ty>(),
+                stringify!($ptr)
+            );
+            crate::scripting::jni::error::set_last_error_message(&message);
+            println!("{}", message);
+            return $crate::ffi_error_return!();
+        }
+        unsafe { &*(ptr as *const $target_ty) }
+    }};
+
+    ($ptr:expr => $target_ty:ty) => {{
+        let ptr = $ptr as *const $target_ty;
+        if ptr.is_null() {
+            let message = format!(
+                "[{}] [ERROR] {} pointer is null",
+                std::any::type_name::<$target_ty>(),
+                stringify!($ptr)
+            );
+            crate::scripting::jni::error::set_last_error_message(&message);
+            println!("{}", message);
+            return $crate::ffi_error_return!();
+        }
+        unsafe { &*ptr }
+    }};
+}
+
+/// Converts a JString to a Rust String with automatic error handling.
+/// Automatically infers the appropriate error return value based on the function's return type.
+///
+/// # Usage
+/// ```rust
+/// convert_jstring!(env, jstring);
+/// ```
+#[macro_export]
+macro_rules! convert_jstring {
+    ($env:expr, $jstring:expr) => {{
+        match $env.get_string(&$jstring) {
+            Ok(java_string) => match java_string.to_str() {
+                Ok(rust_str) => rust_str.to_string(),
+                Err(e) => {
+                    let message = format!(
+                        "[{}] [ERROR] Failed to convert Java string to Rust string: {}",
+                        stringify!($jstring),
+                        e
+                    );
+                    crate::scripting::jni::error::set_last_error_message(&message);
+                    println!("{}", message);
+                    return $crate::ffi_error_return!();
+                }
+            },
+            Err(e) => {
+                let message = format!(
+                    "[{}] [ERROR] Failed to get string from JNI: {}",
+                    stringify!($jstring),
+                    e
+                );
+                crate::scripting::jni::error::set_last_error_message(&message);
+                println!("{}", message);
+                return $crate::ffi_error_return!();
+            }
+        }
+    }};
+}
+
+#[macro_export]
+macro_rules! ffi_error_return {
+    () => {{
+        trait ErrorValue {
+            fn error_value() -> Self;
+        }
+
+        impl ErrorValue for () {
+            fn error_value() -> Self {}
+        }
+
+        impl ErrorValue for i8 {
+            fn error_value() -> Self {
+                -1
+            }
+        }
+
+        impl ErrorValue for i16 {
+            fn error_value() -> Self {
+                -1
+            }
+        }
+
+        impl ErrorValue for i32 {
+            fn error_value() -> Self {
+                -1
+            }
+        }
+
+        impl ErrorValue for i64 {
+            fn error_value() -> Self {
+                -1
+            }
+        }
+
+        impl ErrorValue for isize {
+            fn error_value() -> Self {
+                -1
+            }
+        }
+
+        impl ErrorValue for u8 {
+            fn error_value() -> Self {
+                0
+            } // most likely a char or a jboolean
+        }
+
+        impl ErrorValue for u16 {
+            fn error_value() -> Self {
+                u16::MAX
+            }
+        }
+
+        impl ErrorValue for u32 {
+            fn error_value() -> Self {
+                u32::MAX
+            }
+        }
+
+        impl ErrorValue for u64 {
+            fn error_value() -> Self {
+                u64::MAX
+            }
+        }
+
+        impl ErrorValue for usize {
+            fn error_value() -> Self {
+                usize::MAX
+            }
+        }
+
+        impl<T> ErrorValue for *mut T {
+            fn error_value() -> Self {
+                std::ptr::null_mut()
+            }
+        }
+
+        impl<T> ErrorValue for *const T {
+            fn error_value() -> Self {
+                std::ptr::null()
+            }
+        }
+
+        // todo: implement other types
+
+        ErrorValue::error_value()
+    }};
+}
diff --git a/eucalyptus-editor/src/editor/component.rs b/eucalyptus-editor/src/editor/component.rs
index 0e6e2d7..6bd2da6 100644
--- a/eucalyptus-editor/src/editor/component.rs
+++ b/eucalyptus-editor/src/editor/component.rs
@@ -758,6 +758,19 @@ impl InspectableComponent for MeshRenderer {
             CollapsingHeader::new("Materials")
                 .default_open(false)
                 .show(ui, |ui| {
+                    let material_uri_for = |model_reference: &ResourceReference,
+                                            material_name: &str|
+                     -> Option<String> {
+                        let model_handle =
+                            ASSET_REGISTRY.model_handle_from_reference(model_reference)?;
+                        let model_arc = ASSET_REGISTRY.get_model(model_handle)?;
+                        let material_handle =
+                            ASSET_REGISTRY.material_handle(model_arc.id, material_name)?;
+                        let reference =
+                            ASSET_REGISTRY.material_reference_for_handle(material_handle)?;
+                        reference.as_uri().map(|uri| uri.to_string())
+                    };
+
                     for material in self.model().materials.iter() {
                         let override_snapshot = self
                             .material_overrides()
@@ -766,11 +779,16 @@ impl InspectableComponent for MeshRenderer {
                             .cloned();
 
                         let selected_label = if let Some(override_entry) = &override_snapshot {
-                            let reference = override_entry
-                                .source_model
-                                .as_uri()
-                                .map(|uri| uri.to_string())
-                                .unwrap_or_else(|| "inline".to_string());
+                            let reference = material_uri_for(
+                                &override_entry.source_model,
+                                &override_entry.source_material,
+                            )
+                            .or_else(|| {
+                                override_entry.source_model.as_uri().map(|uri| {
+                                    format!("{}/{}", uri, override_entry.source_material)
+                                })
+                            })
+                            .unwrap_or_else(|| "inline".to_string());
                             format!("{}  [{}]", override_entry.source_material, reference)
                         } else {
                             "Original".to_string()
@@ -840,16 +858,17 @@ impl InspectableComponent for MeshRenderer {
                                         response
                                     };
 
-                                    let original_identifier = format!(
-                                        "bytes://material-original-{}",
-                                        material.name
-                                    );
-                                    let original_path = self
-                                        .model()
-                                        .path
-                                        .as_uri()
-                                        .map(|uri| uri.to_string())
-                                        .unwrap_or_else(|| "embedded".to_string());
+                                    let original_identifier =
+                                        format!("bytes://material-original-{}", material.name);
+                                    let original_path =
+                                        material_uri_for(&self.model().path, &material.name)
+                                            .or_else(|| {
+                                                self.model()
+                                                    .path
+                                                    .as_uri()
+                                                    .map(|uri| format!("{}/{}", uri, material.name))
+                                            })
+                                            .unwrap_or_else(|| "embedded".to_string());
                                     let original_response = render_row(
                                         ui,
                                         &original_identifier,
@@ -900,9 +919,11 @@ impl InspectableComponent for MeshRenderer {
                                             })
                                             .unwrap_or(false);
 
-                                        let resource_path = source_model
-                                            .as_uri()
-                                            .map(|uri| uri.to_string())
+                                        let resource_path = ASSET_REGISTRY
+                                            .material_reference_for_handle(handle)
+                                            .and_then(|reference| {
+                                                reference.as_uri().map(|uri| uri.to_string())
+                                            })
                                             .unwrap_or_else(|| "embedded".to_string());
 
                                         let identifier = format!(
@@ -930,8 +951,7 @@ impl InspectableComponent for MeshRenderer {
                                 if let Err(err) = self.restore_original_material(&material.name) {
                                     fatal!("Failed to restore material: {}", err);
                                 }
-                            } else if let Some((source_model, source_material)) = pending_override
-                            {
+                            } else if let Some((source_model, source_material)) = pending_override {
                                 if let Err(err) = self.apply_material_override(
                                     &material.name,
                                     source_model,
diff --git a/eucalyptus-editor/src/editor/mod.rs b/eucalyptus-editor/src/editor/mod.rs
index 716a7e7..4c1b1c7 100644
--- a/eucalyptus-editor/src/editor/mod.rs
+++ b/eucalyptus-editor/src/editor/mod.rs
@@ -13,6 +13,7 @@ use crate::graphics::OutlineShader;
 use crate::plugin::PluginRegistry;
 use crate::stats::NerdStats;
 use crossbeam_channel::Receiver;
+use dropbear_engine::asset::ASSET_REGISTRY;
 use dropbear_engine::shader::Shader;
 use dropbear_engine::{
     camera::Camera,
@@ -1408,11 +1409,8 @@ impl Editor {
             log::debug!("Pointers before sendoff:");
             log::debug!("World: {:p}", world_ptr);
             log::debug!("Input: {:p}", input_ptr);
-            log::debug!(
-                "script graphics_command: 0x{:p}, 0x{:p}",
-                &GRAPHICS_COMMAND.0,
-                &GRAPHICS_COMMAND.1
-            );
+            log::debug!("Graphics Command Queue: {:p}", &GRAPHICS_COMMAND.0,);
+            log::debug!("Asset Registry: {:p}", &raw const ASSET_REGISTRY);
 
             if let Err(e) = self
                 .script_manager
diff --git a/eucalyptus-editor/src/editor/scene.rs b/eucalyptus-editor/src/editor/scene.rs
index 8c6a825..e5b13fa 100644
--- a/eucalyptus-editor/src/editor/scene.rs
+++ b/eucalyptus-editor/src/editor/scene.rs
@@ -1,6 +1,7 @@
 use super::*;
 use crate::signal::SignalController;
 use crate::spawn::PendingSpawnController;
+use dropbear_engine::asset::{ASSET_REGISTRY, PointerKind};
 use dropbear_engine::graphics::{InstanceRaw, RenderContext};
 use dropbear_engine::model::MODEL_CACHE;
 use dropbear_engine::{
@@ -18,7 +19,6 @@ use tokio::sync::mpsc::unbounded_channel;
 use wgpu::Color;
 use wgpu::util::DeviceExt;
 use winit::{event_loop::ActiveEventLoop, keyboard::KeyCode};
-use dropbear_engine::asset::{PointerKind, ASSET_REGISTRY};
 
 impl Scene for Editor {
     fn load(&mut self, graphics: &mut RenderContext) {
@@ -170,10 +170,15 @@ impl Scene for Editor {
                 log::info!("Plugins loaded");
             }
         }
+
         let cache_mutex_ptr = std::sync::LazyLock::force(&MODEL_CACHE) as *const _;
+        ASSET_REGISTRY.add_pointer(PointerKind::Const("model_cache"), cache_mutex_ptr as usize);
+
+        let last_error_msg_ptr =
+            eucalyptus_core::scripting::jni::error::get_last_error_message_ptr();
         ASSET_REGISTRY.add_pointer(
-            PointerKind::Const("model_cache"),
-            cache_mutex_ptr as usize,
+            PointerKind::Const("last_error_msg"),
+            last_error_msg_ptr as usize,
         );
 
         if let Some((_, tab)) = self.dock_state.find_active_focused() {
diff --git a/magna-carta/src/main.rs b/magna-carta/src/main.rs
index 3a5379d..72b7301 100644
--- a/magna-carta/src/main.rs
+++ b/magna-carta/src/main.rs
@@ -40,12 +40,18 @@ enum Target {
 fn main() -> anyhow::Result<()> {
     let cli = Cli::parse();
 
-    if !cli.raw || (cli.stdout && cli.output.is_some()) {
+    if !(cli.stdout || cli.output.is_some() || cli.raw) {
         return Err(anyhow::anyhow!(
             "No output given. --stdout, --output <target> or --raw must be used."
         ));
     }
 
+    if cli.stdout && cli.output.is_some() {
+        return Err(anyhow::anyhow!(
+            "--stdout and --output cannot be used together. Choose one output destination."
+        ));
+    }
+
     let mut processor = KotlinProcessor::new()?;
     let mut manifest = ScriptManifest::new();
 
@@ -75,10 +81,7 @@ fn main() -> anyhow::Result<()> {
 
     if cli.stdout {
         print!("{}", generated_content);
-    } else if cli.raw && !(cli.stdout || cli.output.is_some()) {
-        return Ok(());
-    } else {
-        let output_dir = cli.output.unwrap();
+    } else if let Some(output_dir) = cli.output {
         fs::create_dir_all(&output_dir)?;
 
         let filename = match cli.target {
diff --git a/src/commonMain/kotlin/com/dropbear/DropbearEngine.kt b/src/commonMain/kotlin/com/dropbear/DropbearEngine.kt
index 10ad7d2..d165d4b 100644
--- a/src/commonMain/kotlin/com/dropbear/DropbearEngine.kt
+++ b/src/commonMain/kotlin/com/dropbear/DropbearEngine.kt
@@ -4,7 +4,6 @@ import com.dropbear.asset.AssetHandle
 import com.dropbear.ffi.NativeEngine
 import com.dropbear.input.InputState
 import com.dropbear.logging.Logger
-import com.dropbear.math.Transform
 
 internal var exceptionOnError: Boolean = false
 
@@ -22,6 +21,9 @@ class DropbearEngine(val native: NativeEngine) {
         }
     }
 
+    /**
+     * Fetches an [EntityRef] with the given label.
+     */
     fun getEntity(label: String): EntityRef? {
         val entityId = native.getEntity(label)
         val entityRef = if (entityId != null) EntityRef(EntityId(entityId)) else null
@@ -29,6 +31,9 @@ class DropbearEngine(val native: NativeEngine) {
         return entityRef
     }
 
+    /**
+     * Fetches the information of the camera with the given label.
+     */
     fun getCamera(label: String): Camera? {
         val result = native.getCamera(label)
         if (result != null) {
@@ -37,6 +42,9 @@ class DropbearEngine(val native: NativeEngine) {
         return result
     }
 
+    /**
+     * Gets the current [InputState] for that frame.
+     */
     fun getInputState(): InputState {
         if (this.inputState == null) {
             Logger.trace("InputState not initialised, creating new one")
@@ -45,17 +53,29 @@ class DropbearEngine(val native: NativeEngine) {
         return this.inputState!!
     }
 
-    internal fun getTransform(entityId: EntityId): Transform? {
-        val result = native.getTransform(entityId)
-        return result
-    }
-
-    internal fun setTransform(entityId: EntityId, transform: Transform) {
-        native.setTransform(entityId, transform)
-    }
-
+    /**
+     * Fetches the asset information from the internal AssetRegistry (located in
+     * `dropbear_engine::asset::AssetRegistry`).
+     *
+     * ## Warning
+     * The eucalyptus asset URI (or `euca://`) is case-sensitive.
+     */
     fun getAsset(eucaURI: String): AssetHandle? {
         val id = native.getAsset(eucaURI)
         return if (id != null) AssetHandle(id) else null
     }
+
+    /**
+     * Globally sets whether exceptions should be thrown when an error occurs.
+     *
+     * This can be run in your update loop without consequences.
+     */
+    fun callExceptionOnError(toggle: Boolean) = DropbearEngine.callExceptionOnError(toggle)
+
+    /**
+     * Fetches the last error message during the native call. 
+     */
+    fun getLastErrorMsg(): String? = native.getLastErrorMsg()
+
+    fun getLastErrorMsgPtr(): Long = native.getLastErrorMsgPtr()
 }
\ No newline at end of file
diff --git a/src/commonMain/kotlin/com/dropbear/EntityRef.kt b/src/commonMain/kotlin/com/dropbear/EntityRef.kt
index 7729870..29f78c1 100644
--- a/src/commonMain/kotlin/com/dropbear/EntityRef.kt
+++ b/src/commonMain/kotlin/com/dropbear/EntityRef.kt
@@ -2,9 +2,19 @@ package com.dropbear
 
 import com.dropbear.asset.ModelHandle
 import com.dropbear.asset.TextureHandle
-import com.dropbear.exception.DropbearNativeException
 import com.dropbear.math.Transform
 
+/**
+ * A reference to an ECS Entity stored inside the dropbear engine.
+ *
+ * The dropbear engine prefers careful mutability, which is why a reference is passed (as a handle) instead
+ * of its full information. Also conserves memory.
+ *
+ * The ECS system the dropbear engine uses is `hecs` ECS, which is a Rust crate that has blazing fast
+ * querying systems. The id passed is just a primitive integer value that points to the entity in the world.
+ *
+ * @property id The unique identifier of the entity as set by `hecs::World`
+ */
 class EntityRef(val id: EntityId = EntityId(0L)) {
     lateinit var engine: DropbearEngine
 
@@ -12,15 +22,24 @@ class EntityRef(val id: EntityId = EntityId(0L)) {
         return "EntityRef(id=$id)"
     }
 
+    /**
+     * Fetches the transform component for the entity.
+     */
     fun getTransform(): Transform? {
-        return engine.getTransform(id)
+        return engine.native.getTransform(id)
     }
 
+    /**
+     * Sets and replaces the transform component for the entity.
+     */
     fun setTransform(transform: Transform?) {
         if (transform == null) return
-        return engine.setTransform(id, transform)
+        return engine.native.setTransform(id, transform)
     }
 
+    /**
+     * Fetches the property of the ModelProperty component on the entity.
+     */
     inline fun <reified T> getProperty(key: String): T? {
         return when (T::class) {
             String::class -> engine.native.getStringProperty(id.id, key) as T?
@@ -35,6 +54,18 @@ class EntityRef(val id: EntityId = EntityId(0L)) {
         }
     }
 
+    /**
+     * Sets a property of the ModelProperty component on the entity.
+     *
+     * # Supported types
+     * - [kotlin.String]
+     * - [kotlin.Long]
+     * - [kotlin.Int]
+     * - [kotlin.Double]
+     * - [kotlin.Float]
+     * - [kotlin.Boolean]
+     * - [com.dropbear.math.Vector3]
+     */
     fun setProperty(key: String, value: Any) {
         when (value) {
             is String -> engine.native.setStringProperty(id.id, key, value)
@@ -51,6 +82,11 @@ class EntityRef(val id: EntityId = EntityId(0L)) {
         }
     }
 
+    /**
+     * Fetches the attached camera for the entity.
+     *
+     * Returns null if no camera is attached as a component according to the editor.
+     */
     fun getAttachedCamera(): Camera? {
         val result = engine.native.getAttachedCamera(id)
         if (result != null) {
@@ -59,30 +95,63 @@ class EntityRef(val id: EntityId = EntityId(0L)) {
         return result
     }
 
+    /**
+     * Fetches the texture for the given material name in the model.
+     */
     fun getTexture(materialName: String): TextureHandle? {
         val result = engine.native.getTexture(id.id, materialName)
-        if (result == -1L) {
-            if (exceptionOnError) {
-                throw DropbearNativeException("Unable to get texture for material $materialName")
-            }
-            return null
+        return if (result == null) {
+            null
         } else {
-            return TextureHandle(result ?: throw Exception("Native returned null texture handle"))
+            TextureHandle(result)
         }
     }
 
-    fun hasTexture(eucaURI: String): Boolean {
-        return engine.native.isUsingTexture(id.id, eucaURI)
+    /**
+     * Returns an array containing the texture identifiers applied to this entity's model.
+     */
+    fun getAllTextures(): Array<String> {
+        return engine.native.getAllTextures(id.id)
+    }
+
+    /**
+     * Checks if the current model being rendered by this entity contains the texture with the given [TextureHandle]
+     */
+    fun hasTexture(textureHandle: TextureHandle): Boolean {
+        return engine.native.isUsingTexture(id.id, textureHandle.raw())
+    }
+
+    /**
+     * Fetches the active model that is currently being used
+     */
+    fun getModel(): ModelHandle? {
+        val result = engine.native.getModel(id.id)
+        return if (result == null) {
+            null
+        } else {
+            ModelHandle(result)
+        }
     }
 
+    /**
+     * Sets the active model for the entity from a ModelHandle
+     */
     fun setModel(modelHandle: ModelHandle) {
         engine.native.setModel(id.id, modelHandle.raw())
     }
 
+    /**
+     * Checks if the entity is currently using the given model handle.
+     *
+     * Returns false if not using, true if is.
+     */
     fun usingModel(modelHandle: ModelHandle): Boolean {
         return engine.native.isUsingModel(id.id, modelHandle.raw())
     }
 
+    /**
+     * Sets a texture override for the given material on the active model.
+     */
     fun setTextureOverride(materialName: String, textureHandle: TextureHandle) {
         engine.native.setTextureOverride(id.id, materialName, textureHandle)
     }
diff --git a/src/commonMain/kotlin/com/dropbear/asset/Handle.kt b/src/commonMain/kotlin/com/dropbear/asset/Handle.kt
index e4a65bc..0528615 100644
--- a/src/commonMain/kotlin/com/dropbear/asset/Handle.kt
+++ b/src/commonMain/kotlin/com/dropbear/asset/Handle.kt
@@ -22,4 +22,19 @@ abstract class Handle(private val id: Long) {
     override fun toString(): String {
         return "Handle(id=$id)"
     }
+
+    override fun equals(other: Any?): Boolean {
+        if (this === other) return true
+
+        val otherAsset = when (other) {
+            is Handle      -> other.asAssetHandle()
+            is AssetHandle -> other
+            else           -> return false
+        }
+
+        val thisAsset = asAssetHandle()
+        return thisAsset.raw() == otherAsset.raw()
+    }
+
+    override fun hashCode(): Int = asAssetHandle().raw().hashCode()
 }
\ No newline at end of file
diff --git a/src/commonMain/kotlin/com/dropbear/ffi/NativeEngine.kt b/src/commonMain/kotlin/com/dropbear/ffi/NativeEngine.kt
index 24a3810..81d3fcb 100644
--- a/src/commonMain/kotlin/com/dropbear/ffi/NativeEngine.kt
+++ b/src/commonMain/kotlin/com/dropbear/ffi/NativeEngine.kt
@@ -21,9 +21,10 @@ expect class NativeEngine {
 
     fun getTexture(entityHandle: Long, name: String): Long?
     fun setTextureOverride(entityHandle: Long, oldMaterialName: String, newTextureHandle: TextureHandle)
-    fun isUsingTexture(entityHandle: Long, name: String): Boolean
+    fun isUsingTexture(entityHandle: Long, textureHandle: Long): Boolean
     fun isTextureHandle(id: Long): Boolean
     fun getTextureName(textureHandle: Long): String?
+    fun getAllTextures(entityHandle: Long): Array<String>
 
     fun getCamera(label: String): Camera?
     fun getAttachedCamera(entityId: EntityId): Camera?
@@ -65,6 +66,8 @@ expect class NativeEngine {
     fun isCursorHidden(): Boolean
     fun setCursorHidden(hidden: Boolean)
     fun getLastMousePos(): Vector2D?
+    fun getLastErrorMsg(): String?
+    fun getLastErrorMsgPtr(): Long
 //    fun getConnectedGamepads(): List<Gamepad>
 
     // -------------------------------------------------------------------
diff --git a/src/jvmMain/java/com/dropbear/ffi/JNINative.java b/src/jvmMain/java/com/dropbear/ffi/JNINative.java
index eb1577e..3f5f4af 100644
--- a/src/jvmMain/java/com/dropbear/ffi/JNINative.java
+++ b/src/jvmMain/java/com/dropbear/ffi/JNINative.java
@@ -8,7 +8,7 @@ public class JNINative {
         System.loadLibrary("eucalyptus_core");
     }
 
-    // entity
+    // getters
     public static native long getEntity(long worldHandle, String label);
     public static native long getAsset(long assetRegistryHandle, String eucaURI);
 
@@ -19,12 +19,12 @@ public class JNINative {
     public static native boolean isUsingModel(long worldHandle, long entityHandle, long modelHandle);
 
     // texture
-    public static native long getTexture(long worldHandle, long entityHandle, String name);
+    public static native long getTexture(long worldHandle, long assetHandle, long entityHandle, String name);
     public static native String getTextureName(long assetHandle, long textureHandle);
     public static native void setTexture(long worldHandle, long assetRegistryHandle, long entityHandle,
                                          String oldMaterialName, long textureHandle);
     public static native boolean isTextureHandle(long assetRegistryHandle, long handle);
-    public static native boolean isUsingTexture(long worldHandle, long entityHandle, String name);
+    public static native boolean isUsingTexture(long worldHandle, long entityHandle, long textureHandle);
 
     // camera
     public static native Camera getCamera(long worldHandle, String label);
@@ -61,4 +61,7 @@ public class JNINative {
     public static native float[] getLastMousePos(long inputHandle);
     public static native boolean isCursorHidden(long inputHandle);
     public static native void setCursorHidden(long inputHandle, long graphicsHandle, boolean hidden);
+    public static native String getLastErrorMsg(long assetHandle);
+    public static native long getLastErrMsgPtr(long assetHandle);
+    public static native String[] getAllTextures(long worldHandle, long entityHandle);
 }
diff --git a/src/jvmMain/kotlin/com/dropbear/ffi/NativeEngine.jvm.kt b/src/jvmMain/kotlin/com/dropbear/ffi/NativeEngine.jvm.kt
index 7db1f02..55e91f6 100644
--- a/src/jvmMain/kotlin/com/dropbear/ffi/NativeEngine.jvm.kt
+++ b/src/jvmMain/kotlin/com/dropbear/ffi/NativeEngine.jvm.kt
@@ -242,6 +242,8 @@ actual class NativeEngine {
             } else {
                 null
             }
+        } else if (result == 0L) {
+            null
         } else {
             result
         }
@@ -252,15 +254,17 @@ actual class NativeEngine {
     }
 
     actual fun getTexture(entityHandle: Long, name: String): Long? {
-        val result = JNINative.getTexture(worldHandle, entityHandle, name)
+        val result = JNINative.getTexture(worldHandle, assetHandle, entityHandle, name)
         return if (result == -1L) {
             if (exceptionOnError) {
                 throw DropbearNativeException("Unable to get texture for entity $entityHandle")
             } else {
                 null
             }
+        } else if (result == 0L) {
+            null
         } else {
-            JNINative.getTexture(worldHandle, entityHandle, name)
+            result
         }
     }
 
@@ -282,8 +286,8 @@ actual class NativeEngine {
         return JNINative.isUsingModel(worldHandle, entityHandle, modelHandle)
     }
 
-    actual fun isUsingTexture(entityHandle: Long, name: String): Boolean {
-        return JNINative.isUsingTexture(worldHandle, entityHandle, name)
+    actual fun isUsingTexture(entityHandle: Long, textureHandle: Long): Boolean {
+        return JNINative.isUsingTexture(worldHandle, entityHandle, textureHandle)
     }
 
     actual fun getAsset(eucaURI: String): Long? {
@@ -294,6 +298,9 @@ actual class NativeEngine {
             } else {
                 null
             }
+        } else if (result == 0L) {
+            // no asset found
+            null
         } else {
             result
         }
@@ -306,4 +313,17 @@ actual class NativeEngine {
     actual fun isTextureHandle(id: Long): Boolean {
         return JNINative.isTextureHandle(assetHandle, id)
     }
+
+    actual fun getLastErrorMsg(): String? {
+        val message = JNINative.getLastErrorMsg(assetHandle)
+        return if (message.isEmpty()) null else message
+    }
+
+    actual fun getLastErrorMsgPtr(): Long {
+        return JNINative.getLastErrMsgPtr(assetHandle)
+    }
+
+    actual fun getAllTextures(entityHandle: Long): Array<String> {
+        return JNINative.getAllTextures(worldHandle, entityHandle) ?: emptyArray()
+    }
 }
\ No newline at end of file
diff --git a/src/nativeMain/kotlin/com/dropbear/ffi/NativeEngine.native.kt b/src/nativeMain/kotlin/com/dropbear/ffi/NativeEngine.native.kt
index a820372..39d0882 100644
--- a/src/nativeMain/kotlin/com/dropbear/ffi/NativeEngine.native.kt
+++ b/src/nativeMain/kotlin/com/dropbear/ffi/NativeEngine.native.kt
@@ -810,7 +810,7 @@ actual class NativeEngine {
         TODO("Not yet implemented")
     }
 
-    actual fun isUsingTexture(entityHandle: Long, name: String): Boolean {
+    actual fun isUsingTexture(entityHandle: Long, textureHandle: Long): Boolean {
         TODO("Not yet implemented")
     }
 
@@ -832,4 +832,16 @@ actual class NativeEngine {
     actual fun getTextureName(textureHandle: Long): String? {
         TODO("Not yet implemented")
     }
+
+    actual fun getLastErrorMsg(): String? {
+        TODO("Not yet implemented")
+    }
+
+    actual fun getLastErrorMsgPtr(): Long {
+        TODO("Not yet implemented")
+    }
+
+    actual fun getAllTextures(entityHandle: Long): Array<String> {
+        TODO("Not yet implemented")
+    }
 }
\ No newline at end of file