Implement native asset pipeline and cache (#64)
Some checks failed
Native code generation / deterministic (push) Failing after 2m18s
Imaging and meshing gate / native (push) Failing after 1m30s
JPEG 2000 feature / linux (push) Successful in 2m40s
Native Rust workspace compile / compile (push) Failing after 57s
Skia feature / linux (push) Successful in 31m8s

This commit is contained in:
2026-08-10 07:51:17 +00:00
parent d5c318d280
commit 52f62d8c39
23 changed files with 4511 additions and 1620 deletions

35
Cargo.lock generated
View File

@@ -26,6 +26,16 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "aotuv_lancer_vorbis_sys"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5bc4fd1a61860d2f1198b60bedd30910eaffa978f1ee6214dfb24ac70d589225"
dependencies = [
"cc",
"ogg_next_sys",
]
[[package]] [[package]]
name = "atomic-waker" name = "atomic-waker"
version = "1.1.2" version = "1.1.2"
@@ -853,7 +863,9 @@ dependencies = [
"os_info", "os_info",
"reqwest", "reqwest",
"roxmltree", "roxmltree",
"serde_json",
"tokio", "tokio",
"vorbis_rs",
] ]
[[package]] [[package]]
@@ -1298,6 +1310,15 @@ dependencies = [
"objc2-foundation", "objc2-foundation",
] ]
[[package]]
name = "ogg_next_sys"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed2d7a48e247c2bb07e633aefb65a38648ea58c7eedd4e4408a5861721ab049b"
dependencies = [
"cc",
]
[[package]] [[package]]
name = "once_cell" name = "once_cell"
version = "1.21.4" version = "1.21.4"
@@ -2239,6 +2260,20 @@ version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "vorbis_rs"
version = "0.5.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "49c5da94d280f7a27e8c937e9b73df2da3e23a2583f48471fd8fb4c72f9c1933"
dependencies = [
"aotuv_lancer_vorbis_sys",
"errno",
"getrandom 0.4.3",
"ogg_next_sys",
"thiserror 2.0.20",
"tinyvec",
]
[[package]] [[package]]
name = "walkdir" name = "walkdir"
version = "2.5.0" version = "2.5.0"

View File

@@ -358,6 +358,13 @@ and uses same-directory temporary replacement with rollback. The compatibility
and persistence contract is documented in and persistence contract is documented in
[`docs/inventory.md`](docs/inventory.md). [`docs/inventory.md`](docs/inventory.md).
Native asset models now enforce bounded format decoding, real JPEG 2000 and Ogg
Vorbis codec boundaries, client-owned capability and LLUDP downloads, complete
small/Xfer and two-stage capability uploads, independent cancellation for
deduplicated HTTP subscribers, and atomic size-pruned disk caching. The ownership,
correlation, and corruption contracts are documented in
[`docs/assets.md`](docs/assets.md).
The client-owned `InventoryManager` now implements the packet-level create, The client-owned `InventoryManager` now implements the packet-level create,
update, move, copy, remove, fetch, search, give, rez/derez, script-state, and update, move, copy, remove, fetch, search, give, rez/derez, script-state, and
task-inventory workflows. Callback IDs and concurrent item/task waiters are task-inventory workflows. Callback IDs and concurrent item/task waiters are

View File

@@ -4,7 +4,7 @@ Generated by `python3 tools/generate_api_shims.py`; do not edit by hand.
| Assembly | Types | Members | Status | | Assembly | Types | Members | Status |
|---|---:|---:|---| |---|---:|---:|---|
| `LibreMetaverse` | 2,711 | 27,281 | native implementation: 166 types / 14,923 members; remaining surface is callable failure-only shims | | `LibreMetaverse` | 2,711 | 27,281 | native implementation: 197 types / 15,177 members; remaining surface is callable failure-only shims |
| `LibreMetaverse.Imaging.Abstractions` | 3 | 20 | native implementation: 3 types / 20 members; no generated shims remain | | `LibreMetaverse.Imaging.Abstractions` | 3 | 20 | native implementation: 3 types / 20 members; no generated shims remain |
| `LibreMetaverse.Imaging.Skia` | 1 | 3 | native implementation: 1 type / 3 members; no generated shims remain | | `LibreMetaverse.Imaging.Skia` | 1 | 3 | native implementation: 1 type / 3 members; no generated shims remain |
| `LibreMetaverse.LslTools` | 164 | 768 | callable failure-only shim | | `LibreMetaverse.LslTools` | 164 | 768 | callable failure-only shim |

View File

@@ -223,11 +223,11 @@ impl J2kCodec {
if options.max_encoded_bytes == 0 { if options.max_encoded_bytes == 0 {
return Err(Error::Argument); return Err(Error::Argument);
} }
if let J2kCompression::Lossy { compression_ratio } = options.compression { if let J2kCompression::Lossy { compression_ratio } = options.compression
if !compression_ratio.is_finite() || compression_ratio < 1.0 { && (!compression_ratio.is_finite() || compression_ratio < 1.0)
{
return Err(Error::Argument); return Err(Error::Argument);
} }
}
encode_with_openjpeg(image, options) encode_with_openjpeg(image, options)
} }
} }

View File

@@ -8,7 +8,7 @@ repository.workspace = true
description = "Rust rewrite shell for the LibreMetaverse client library" description = "Rust rewrite shell for the LibreMetaverse client library"
[features] [features]
default = ["dds-bc67"] default = ["dds-bc67", "jpeg2000"]
dds-bc67 = ["dep:bcdec_rs"] dds-bc67 = ["dep:bcdec_rs"]
jpeg2000 = ["libremetaverse-imaging/jpeg2000"] jpeg2000 = ["libremetaverse-imaging/jpeg2000"]
@@ -24,7 +24,9 @@ mac_address2 = "2.0.2"
os_info = { version = "3.15.0", default-features = false } os_info = { version = "3.15.0", default-features = false }
reqwest = { version = "0.13.4", default-features = false, features = ["rustls", "stream"] } reqwest = { version = "0.13.4", default-features = false, features = ["rustls", "stream"] }
roxmltree = "0.21.1" roxmltree = "0.21.1"
serde_json = "1.0.143"
tokio = { version = "1.47.1", features = ["macros", "net", "rt", "sync", "time"] } tokio = { version = "1.47.1", features = ["macros", "net", "rt", "sync", "time"] }
vorbis_rs = "0.5.5"
[dev-dependencies] [dev-dependencies]
tokio = { version = "1.47.1", features = ["macros", "net", "rt-multi-thread", "sync", "test-util", "time"] } tokio = { version = "1.47.1", features = ["macros", "net", "rt-multi-thread", "sync", "test-util", "time"] }

View File

@@ -0,0 +1,484 @@
//! Cross-platform, bounded on-disk asset cache.
#![allow(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
clippy::collapsible_if,
clippy::missing_errors_doc,
clippy::must_use_candidate,
clippy::needless_pass_by_value,
clippy::single_match_else,
clippy::type_complexity,
clippy::uninlined_format_args,
clippy::unused_async,
clippy::unused_self
)]
use crate::{Error, GridClient, ImageDownload};
use libremetaverse_types::UUID;
use libremetaverse_types::compat::{CancellationToken, Object};
use std::fs::{self, OpenOptions};
use std::io::Write;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
const CACHE_TARGET_PERCENT: i64 = 90;
const ABSOLUTE_CACHE_FILE_LIMIT: u64 = crate::asset_models::MAX_ASSET_BYTES as u64;
type CacheFilenameHandler = dyn Fn(String, UUID) -> Result<String, Error> + Send + Sync;
#[derive(Clone)]
pub struct AssetCacheComputeAssetCacheFilenameDelegate {
handler: Arc<CacheFilenameHandler>,
}
impl std::fmt::Debug for AssetCacheComputeAssetCacheFilenameDelegate {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("AssetCacheComputeAssetCacheFilenameDelegate")
.finish_non_exhaustive()
}
}
impl AssetCacheComputeAssetCacheFilenameDelegate {
pub fn from_handler(
handler: impl Fn(String, UUID) -> Result<String, Error> + Send + Sync + 'static,
) -> Self {
Self {
handler: Arc::new(handler),
}
}
pub fn new(object: Object, method: isize) -> Result<Self, Error> {
if method == 0 {
return Err(Error::Argument);
}
object
.downcast_arc::<Self>()
.map(|value| (*value).clone())
.ok_or(Error::Argument)
}
pub fn invoke(&self, cache_dir: String, asset_id: UUID) -> Result<String, Error> {
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
(self.handler)(cache_dir, asset_id)
}))
.map_err(|_| Error::InvalidOperation)?
}
pub fn begin_invoke(
&self,
cache_dir: String,
asset_id: UUID,
callback: Box<dyn Fn(&dyn std::any::Any) + Send + Sync>,
object: Object,
) -> Result<Box<dyn std::any::Any + Send + Sync>, Error> {
let result = self.invoke(cache_dir, asset_id)?;
callback(&object);
Ok(Box::new(result))
}
pub fn end_invoke(
&self,
result: Box<dyn std::any::Any + Send + Sync>,
) -> Result<String, Error> {
result
.downcast::<String>()
.map(|value| *value)
.map_err(|_| Error::Argument)
}
}
struct AssetCacheInner {
client: crate::client_core::ClientWeakHandle,
pruning: AtomicBool,
auto_prune: AtomicBool,
interval_bits: AtomicU64,
last_prune_ms: AtomicU64,
}
pub struct AssetCache {
inner: Arc<AssetCacheInner>,
pub compute_asset_cache_filename: Option<AssetCacheComputeAssetCacheFilenameDelegate>,
}
impl Clone for AssetCache {
fn clone(&self) -> Self {
Self {
inner: Arc::clone(&self.inner),
compute_asset_cache_filename: self.compute_asset_cache_filename.clone(),
}
}
}
impl std::fmt::Debug for AssetCache {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("AssetCache")
.field("auto_prune_enabled", &self.auto_prune_enabled())
.finish_non_exhaustive()
}
}
impl AssetCache {
fn now_ms() -> u64 {
u64::try_from(
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis(),
)
.unwrap_or(u64::MAX)
}
pub fn new(client: GridClient) -> Result<Self, Error> {
Ok(Self {
inner: Arc::new(AssetCacheInner {
client: client.native_weak_handle(),
pruning: AtomicBool::new(false),
auto_prune: AtomicBool::new(true),
interval_bits: AtomicU64::new((300_000_f64).to_bits()),
last_prune_ms: AtomicU64::new(Self::now_ms()),
}),
compute_asset_cache_filename: None,
})
}
fn config(&self) -> Result<crate::AssetCacheSettings, Error> {
let client = self.inner.client.upgrade().ok_or(Error::InvalidOperation)?;
Ok(client.settings_ref().asset_cache())
}
fn cache_dir(&self) -> Result<PathBuf, Error> {
let config = self.config()?;
let dir = PathBuf::from(config.dir);
if dir.as_os_str().is_empty() {
return Err(Error::InvalidOperation);
}
Ok(dir)
}
fn path(&self, id: UUID) -> Result<PathBuf, Error> {
let dir = self.cache_dir()?;
if let Some(compute) = &self.compute_asset_cache_filename {
let path = PathBuf::from(compute.invoke(dir.to_string_lossy().into_owned(), id)?);
if path.parent() == Some(dir.as_path()) {
return Ok(path);
}
return Err(Error::Argument);
}
Ok(dir.join(id.to_string()))
}
fn operational(&self) -> Result<bool, Error> {
Ok(self.config()?.enabled)
}
fn checked_read(&self, path: &Path) -> Result<Option<Vec<u8>>, Error> {
let metadata = match fs::metadata(path) {
Ok(value) => value,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(_) => return Err(Error::InvalidOperation),
};
if !metadata.is_file() || metadata.len() == 0 || metadata.len() > ABSOLUTE_CACHE_FILE_LIMIT
{
return Err(Error::Argument);
}
let bytes = fs::read(path).map_err(|_| Error::InvalidOperation)?;
if bytes.len() as u64 != metadata.len() {
return Err(Error::InvalidOperation);
}
Ok(Some(bytes))
}
pub fn get_cached_asset_bytes_with_uuid(
&self,
asset_id: UUID,
) -> Result<Option<Vec<u8>>, Error> {
if !self.operational()? {
return Ok(None);
}
self.checked_read(&self.path(asset_id)?)
}
pub async fn get_cached_asset_bytes_with_uuid_cancellation_token(
&self,
asset_id: UUID,
cancellation_token: Option<CancellationToken>,
) -> Result<Option<Vec<u8>>, Error> {
if let Some(token) = &cancellation_token {
token.throw_if_cancellation_requested()?;
}
let result = self.get_cached_asset_bytes_with_uuid(asset_id);
if let Some(token) = &cancellation_token {
token.throw_if_cancellation_requested()?;
}
result
}
pub fn try_get_cached_asset_bytes_with_uuid_bytes(
&self,
asset_id: UUID,
data: &mut Option<Vec<u8>>,
) -> bool {
match self.get_cached_asset_bytes_with_uuid(asset_id) {
Ok(Some(bytes)) => {
*data = Some(bytes);
true
}
_ => {
*data = None;
false
}
}
}
pub async fn try_get_cached_asset_bytes_with_uuid_cancellation_token(
&self,
asset_id: UUID,
cancellation_token: Option<CancellationToken>,
) -> Result<(bool, Option<Vec<u8>>), Error> {
let data = self
.get_cached_asset_bytes_with_uuid_cancellation_token(asset_id, cancellation_token)
.await?;
Ok((data.is_some(), data))
}
pub fn save_asset_to_cache_with_uuid_bytes(
&self,
asset_id: UUID,
asset_data: Vec<u8>,
) -> Result<bool, Error> {
if !self.operational()? {
return Ok(false);
}
if asset_data.is_empty() || asset_data.len() > crate::asset_models::MAX_ASSET_BYTES {
return Err(Error::Argument);
}
let path = self.path(asset_id)?;
if path.is_file() {
return Ok(true);
}
let dir = path.parent().ok_or(Error::Argument)?;
fs::create_dir_all(dir).map_err(|_| Error::InvalidOperation)?;
let nonce = UUID::random()?.to_string();
let temp = dir.join(format!(".{}.{}.tmp", asset_id, nonce));
let write_result = (|| -> Result<(), Error> {
let mut file = OpenOptions::new()
.write(true)
.create_new(true)
.open(&temp)
.map_err(|_| Error::InvalidOperation)?;
file.write_all(&asset_data)
.map_err(|_| Error::InvalidOperation)?;
file.sync_all().map_err(|_| Error::InvalidOperation)?;
match fs::rename(&temp, &path) {
Ok(()) => Ok(()),
Err(_) if path.is_file() => Ok(()),
Err(_) => Err(Error::InvalidOperation),
}
})();
if write_result.is_err() || temp.exists() {
let _ = fs::remove_file(&temp);
}
write_result.map(|()| {
self.maybe_begin_prune();
true
})
}
fn maybe_begin_prune(&self) {
if !self.auto_prune_enabled() {
return;
}
let interval = self.auto_prune_interval();
if !interval.is_finite() || interval <= 0.0 {
return;
}
let now = Self::now_ms();
let last = self.inner.last_prune_ms.load(Ordering::Acquire);
if now.saturating_sub(last) < interval as u64 {
return;
}
if self
.inner
.last_prune_ms
.compare_exchange(last, now, Ordering::AcqRel, Ordering::Acquire)
.is_ok()
{
let _ = self.begin_prune();
}
}
pub async fn save_asset_to_cache_with_uuid_bytes_cancellation_token(
&self,
asset_id: UUID,
asset_data: Vec<u8>,
cancellation_token: Option<CancellationToken>,
) -> Result<bool, Error> {
if let Some(token) = &cancellation_token {
token.throw_if_cancellation_requested()?;
}
let result = self.save_asset_to_cache_with_uuid_bytes(asset_id, asset_data);
if let Some(token) = &cancellation_token {
token.throw_if_cancellation_requested()?;
}
result
}
pub fn asset_file_name(&self, asset_id: UUID) -> Result<Option<String>, Error> {
if !self.operational()? {
return Ok(None);
}
let path = self.path(asset_id)?;
Ok(path.is_file().then(|| path.to_string_lossy().into_owned()))
}
pub fn has_asset(&self, asset_id: UUID) -> Result<bool, Error> {
Ok(self.operational()? && self.path(asset_id)?.is_file())
}
fn cache_entries(&self) -> Result<Vec<(PathBuf, u64, SystemTime)>, Error> {
let dir = self.cache_dir()?;
let mut entries = Vec::new();
let read = match fs::read_dir(dir) {
Ok(value) => value,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(entries),
Err(_) => return Err(Error::InvalidOperation),
};
for entry in read.flatten() {
if let Ok(metadata) = entry.metadata() {
if metadata.is_file() {
entries.push((
entry.path(),
metadata.len(),
metadata
.accessed()
.or_else(|_| metadata.modified())
.unwrap_or(UNIX_EPOCH),
));
}
}
}
Ok(entries)
}
pub fn clear(&self) -> Result<(), Error> {
for (path, _, _) in self.cache_entries()? {
fs::remove_file(path).map_err(|_| Error::InvalidOperation)?;
}
Ok(())
}
pub async fn prune(&self, cancellation_token: Option<CancellationToken>) -> Result<(), Error> {
if let Some(token) = &cancellation_token {
token.throw_if_cancellation_requested()?;
}
let max = self.config()?.max_size;
if max <= 0 {
return Err(Error::InvalidOperation);
}
let mut entries = self.cache_entries()?;
let mut size: u64 = entries.iter().map(|(_, size, _)| *size).sum();
let max = u64::try_from(max).map_err(|_| Error::InvalidOperation)?;
if size <= max {
return Ok(());
}
entries.sort_unstable_by_key(|(_, _, accessed)| *accessed);
let target = max.saturating_mul(CACHE_TARGET_PERCENT as u64) / 100;
for (path, length, _) in entries {
if let Some(token) = &cancellation_token {
token.throw_if_cancellation_requested()?;
}
fs::remove_file(path).map_err(|_| Error::InvalidOperation)?;
size = size.saturating_sub(length);
if size <= target {
break;
}
}
Ok(())
}
pub fn begin_prune(&self) -> Result<(), Error> {
if self.inner.pruning.swap(true, Ordering::AcqRel) {
return Ok(());
}
let cache = self.clone();
std::thread::Builder::new()
.name("asset-cache-prune".into())
.spawn(move || {
let _ = cache.prune_sync(None);
cache.inner.pruning.store(false, Ordering::Release);
})
.map_err(|_| {
self.inner.pruning.store(false, Ordering::Release);
Error::InvalidOperation
})?;
Ok(())
}
fn prune_sync(&self, cancellation_token: Option<CancellationToken>) -> Result<(), Error> {
if let Some(token) = &cancellation_token {
token.throw_if_cancellation_requested()?;
}
let max = self.config()?.max_size;
if max <= 0 {
return Err(Error::InvalidOperation);
}
let mut entries = self.cache_entries()?;
let mut size: u64 = entries.iter().map(|(_, size, _)| *size).sum();
let max = u64::try_from(max).map_err(|_| Error::InvalidOperation)?;
if size <= max {
return Ok(());
}
entries.sort_unstable_by_key(|(_, _, accessed)| *accessed);
let target = max.saturating_mul(CACHE_TARGET_PERCENT as u64) / 100;
for (path, length, _) in entries {
if let Some(token) = &cancellation_token {
token.throw_if_cancellation_requested()?;
}
fs::remove_file(path).map_err(|_| Error::InvalidOperation)?;
size = size.saturating_sub(length);
if size <= target {
break;
}
}
Ok(())
}
pub fn dispose(&self) -> Result<(), Error> {
self.inner.auto_prune.store(false, Ordering::Release);
Ok(())
}
pub fn get_cached_image(&self, image_id: UUID) -> Result<Option<ImageDownload>, Error> {
let Some(bytes) = self.get_cached_asset_bytes_with_uuid(image_id)? else {
return Ok(None);
};
let mut image = ImageDownload::new()?;
image.base.id = image_id;
image.base.asset_type = libremetaverse_types::AssetType::Texture;
image.base.size = i32::try_from(bytes.len()).map_err(|_| Error::Argument)?;
image.base.transferred = image.base.size;
image.base.success = true;
image.base.asset_data = bytes;
image.codec = crate::ImageCodec::J2C;
Ok(Some(image))
}
pub fn auto_prune_enabled(&self) -> bool {
self.inner.auto_prune.load(Ordering::Acquire)
}
pub fn set_auto_prune_enabled(&mut self, value: bool) {
self.inner.auto_prune.store(value, Ordering::Release);
}
pub fn auto_prune_interval(&self) -> f64 {
f64::from_bits(self.inner.interval_bits.load(Ordering::Acquire))
}
pub fn set_auto_prune_interval(&mut self, value: f64) {
if value.is_finite() && value > 0.0 {
self.inner
.interval_bits
.store(value.to_bits(), Ordering::Release);
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,719 @@
//! GLTF material asset and simulator material-override encoding.
#![allow(
clippy::assigning_clones,
clippy::cast_possible_truncation,
clippy::collapsible_if,
clippy::float_cmp,
clippy::missing_errors_doc,
clippy::missing_panics_doc,
clippy::must_use_candidate,
clippy::needless_pass_by_value,
clippy::should_implement_trait
)]
use crate::{Error, assets::GltfAlphaMode};
use libremetaverse_structured_data::{OSD, OSDMap};
use libremetaverse_types::{AssetType, Color4, UUID, Vector2, Vector3};
use serde_json::{Value, json};
use std::collections::HashMap;
const FLOAT_EPSILON: f32 = 1.192_092_9e-7;
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct GltfTextureTransform {
pub offset: Vector2,
pub rotation: f32,
pub scale: Vector2,
}
impl GltfTextureTransform {
pub fn default() -> Self {
Self {
offset: Vector2::zero(),
rotation: 0.0,
scale: Vector2 { x: 1.0, y: 1.0 },
}
}
pub fn equals_with_gltf_texture_transform(&self, other: Self) -> bool {
*self == other
}
pub fn equals_with_object(&self, _obj: Option<libremetaverse_types::compat::Object>) -> bool {
false
}
pub fn get_hash_code(&self) -> i32 {
(self.offset.x.to_bits()
^ self.offset.y.to_bits()
^ self.rotation.to_bits()
^ self.scale.x.to_bits()
^ self.scale.y.to_bits())
.cast_signed()
}
pub fn is_default(&self) -> bool {
self.offset.x.abs() <= FLOAT_EPSILON
&& self.offset.y.abs() <= FLOAT_EPSILON
&& self.rotation.abs() <= FLOAT_EPSILON
&& (self.scale.x - 1.0).abs() <= FLOAT_EPSILON
&& (self.scale.y - 1.0).abs() <= FLOAT_EPSILON
}
}
#[derive(Clone, Debug)]
pub struct AssetMaterial {
asset_id: UUID,
asset_data: Vec<u8>,
name: String,
texture_ids: Vec<UUID>,
texture_transforms: Vec<GltfTextureTransform>,
base_color_factor: Color4,
emissive_factor: Vector3,
metallic_factor: f32,
roughness_factor: f32,
alpha_cutoff: f32,
alpha_mode: GltfAlphaMode,
double_sided: bool,
override_alpha_mode: bool,
override_double_sided: bool,
}
impl AssetMaterial {
pub const TEXTURE_BASE_COLOR: i32 = 0;
pub const TEXTURE_NORMAL: i32 = 1;
pub const TEXTURE_METALLIC_ROUGHNESS: i32 = 2;
pub const TEXTURE_EMISSIVE: i32 = 3;
pub const TEXTURE_COUNT: i32 = 4;
pub fn default() -> Self {
Self {
asset_id: UUID::zero(),
asset_data: Vec::new(),
name: String::new(),
texture_ids: vec![UUID::zero(); Self::TEXTURE_COUNT as usize],
texture_transforms: vec![GltfTextureTransform::default(); Self::TEXTURE_COUNT as usize],
base_color_factor: Color4 {
r: 1.0,
g: 1.0,
b: 1.0,
a: 1.0,
},
emissive_factor: Vector3::zero(),
metallic_factor: 1.0,
roughness_factor: 1.0,
alpha_cutoff: 0.5,
alpha_mode: GltfAlphaMode::Opaque,
double_sided: false,
override_alpha_mode: false,
override_double_sided: false,
}
}
pub fn gltf_override_null_uuid() -> UUID {
UUID::new_with_string("ffffffff-ffff-ffff-ffff-ffffffffffff".into())
.expect("constant UUID is valid")
}
pub fn new_with_constructor() -> Result<Self, Error> {
Ok(Self::default())
}
pub fn new_with_uuid_bytes(asset_id: UUID, asset_data: Vec<u8>) -> Result<Self, Error> {
if asset_data.len() > crate::asset_models::MAX_ASSET_BYTES {
return Err(Error::Argument);
}
let mut material = Self {
asset_id,
asset_data,
..Self::default()
};
if !material.decode()? {
return Err(Error::Argument);
}
Ok(material)
}
fn slot(slot: i32) -> Result<usize, Error> {
usize::try_from(slot)
.ok()
.filter(|slot| *slot < Self::TEXTURE_COUNT as usize)
.ok_or(Error::Argument)
}
pub fn set_texture_id(
&mut self,
slot: i32,
id: UUID,
for_override: Option<bool>,
) -> Result<(), Error> {
let slot = Self::slot(slot)?;
self.texture_ids[slot] = if for_override.unwrap_or(false) && id == UUID::zero() {
Self::gltf_override_null_uuid()
} else {
id
};
Ok(())
}
pub fn set_texture_offset(&mut self, slot: i32, offset: Vector2) -> Result<(), Error> {
self.texture_transforms[Self::slot(slot)?].offset = offset;
Ok(())
}
pub fn set_texture_scale(&mut self, slot: i32, scale: Vector2) -> Result<(), Error> {
self.texture_transforms[Self::slot(slot)?].scale = scale;
Ok(())
}
pub fn set_texture_rotation(&mut self, slot: i32, rotation: f32) -> Result<(), Error> {
self.texture_transforms[Self::slot(slot)?].rotation = rotation;
Ok(())
}
pub fn set_base_color_factor_with_color4_boolean(
&mut self,
mut color: Color4,
for_override: Option<bool>,
) -> Result<(), Error> {
if for_override.unwrap_or(false) && color == Self::default().base_color_factor {
color.a -= FLOAT_EPSILON;
}
self.base_color_factor = color;
Ok(())
}
pub fn set_emissive_factor_with_vector3_boolean(
&mut self,
mut color: Vector3,
for_override: Option<bool>,
) -> Result<(), Error> {
if for_override.unwrap_or(false) && color == Vector3::zero() {
color.x += FLOAT_EPSILON;
}
self.emissive_factor = color;
Ok(())
}
pub fn set_metallic_factor_with_single_boolean(
&mut self,
metallic: f32,
for_override: Option<bool>,
) -> Result<(), Error> {
if !metallic.is_finite() {
return Err(Error::Argument);
}
self.metallic_factor = if for_override.unwrap_or(false) {
metallic.min(1.0 - FLOAT_EPSILON)
} else {
metallic
};
Ok(())
}
pub fn set_roughness_factor_with_single_boolean(
&mut self,
roughness: f32,
for_override: Option<bool>,
) -> Result<(), Error> {
if !roughness.is_finite() {
return Err(Error::Argument);
}
self.roughness_factor = if for_override.unwrap_or(false) {
roughness.min(1.0 - FLOAT_EPSILON)
} else {
roughness
};
Ok(())
}
pub fn set_alpha_cutoff_with_single_boolean(
&mut self,
cutoff: f32,
for_override: Option<bool>,
) -> Result<(), Error> {
if !cutoff.is_finite() {
return Err(Error::Argument);
}
self.alpha_cutoff = if for_override.unwrap_or(false) && cutoff == 0.5 {
cutoff - FLOAT_EPSILON
} else {
cutoff
};
Ok(())
}
pub fn set_alpha_mode_with_gltf_alpha_mode_boolean(
&mut self,
mode: GltfAlphaMode,
for_override: Option<bool>,
) -> Result<(), Error> {
self.alpha_mode = mode;
self.override_alpha_mode = for_override.unwrap_or(false);
Ok(())
}
pub fn set_double_sided_with_boolean_boolean(
&mut self,
double_sided: bool,
for_override: Option<bool>,
) -> Result<(), Error> {
self.double_sided = double_sided;
self.override_double_sided = for_override.unwrap_or(false);
Ok(())
}
pub fn set_base_material(&mut self) -> Result<(), Error> {
let transforms = self.texture_transforms.clone();
let asset_id = self.asset_id;
*self = Self::default();
self.asset_id = asset_id;
self.texture_transforms = transforms;
Ok(())
}
pub fn apply_override(&mut self, value: Self) -> Result<(), Error> {
let sentinel = Self::gltf_override_null_uuid();
for slot in 0..Self::TEXTURE_COUNT as usize {
if value.texture_ids[slot] == sentinel {
self.texture_ids[slot] = UUID::zero();
} else if value.texture_ids[slot] != UUID::zero() {
self.texture_ids[slot] = value.texture_ids[slot];
}
if !value.texture_transforms[slot].is_default() {
self.texture_transforms[slot] = value.texture_transforms[slot];
}
}
let defaults = Self::default();
if value.base_color_factor != defaults.base_color_factor {
self.base_color_factor = value.base_color_factor;
}
if value.emissive_factor != defaults.emissive_factor {
self.emissive_factor = value.emissive_factor;
}
if value.metallic_factor != defaults.metallic_factor {
self.metallic_factor = value.metallic_factor;
}
if value.roughness_factor != defaults.roughness_factor {
self.roughness_factor = value.roughness_factor;
}
if value.alpha_cutoff != defaults.alpha_cutoff {
self.alpha_cutoff = value.alpha_cutoff;
}
if value.override_alpha_mode {
self.alpha_mode = value.alpha_mode;
}
if value.override_double_sided {
self.double_sided = value.double_sided;
}
Ok(())
}
fn alpha_name(mode: GltfAlphaMode) -> &'static str {
match mode {
GltfAlphaMode::Opaque => "OPAQUE",
GltfAlphaMode::Blend => "BLEND",
GltfAlphaMode::Mask => "MASK",
}
}
fn parse_alpha(value: Option<&str>) -> Result<GltfAlphaMode, Error> {
match value.unwrap_or("OPAQUE") {
"OPAQUE" => Ok(GltfAlphaMode::Opaque),
"BLEND" => Ok(GltfAlphaMode::Blend),
"MASK" => Ok(GltfAlphaMode::Mask),
_ => Err(Error::Argument),
}
}
fn texture_json(
&self,
slot: usize,
images: &mut Vec<Value>,
textures: &mut Vec<Value>,
) -> Option<Value> {
let id = self.texture_ids[slot];
if id == UUID::zero() || id == Self::gltf_override_null_uuid() {
return None;
}
let index = images.len();
images.push(json!({"uri": id.to_string()}));
textures.push(json!({"source": index}));
let mut info = json!({"index": index});
let transform = self.texture_transforms[slot];
if !transform.is_default() {
info["extensions"] = json!({"KHR_texture_transform": {"offset": [transform.offset.x, transform.offset.y], "scale": [transform.scale.x, transform.scale.y], "rotation": transform.rotation}});
}
Some(info)
}
pub fn to_json(&self) -> Result<String, Error> {
let mut images = Vec::new();
let mut textures = Vec::new();
let base = self.texture_json(
Self::TEXTURE_BASE_COLOR as usize,
&mut images,
&mut textures,
);
let normal = self.texture_json(Self::TEXTURE_NORMAL as usize, &mut images, &mut textures);
let metallic = self.texture_json(
Self::TEXTURE_METALLIC_ROUGHNESS as usize,
&mut images,
&mut textures,
);
let emissive =
self.texture_json(Self::TEXTURE_EMISSIVE as usize, &mut images, &mut textures);
let mut pbr = json!({"baseColorFactor": [self.base_color_factor.r,self.base_color_factor.g,self.base_color_factor.b,self.base_color_factor.a], "metallicFactor": self.metallic_factor, "roughnessFactor": self.roughness_factor});
if let Some(value) = base {
pbr["baseColorTexture"] = value;
}
if let Some(value) = metallic {
pbr["metallicRoughnessTexture"] = value;
}
let mut material = json!({"name": self.name, "pbrMetallicRoughness": pbr, "emissiveFactor": [self.emissive_factor.x,self.emissive_factor.y,self.emissive_factor.z], "alphaMode": Self::alpha_name(self.alpha_mode), "alphaCutoff": self.alpha_cutoff, "doubleSided": self.double_sided});
if let Some(value) = normal {
material["normalTexture"] = value;
}
if let Some(value) = emissive {
material["emissiveTexture"] = value;
}
let mut root = json!({"asset":{"version":"2.0"},"materials":[material]});
if !images.is_empty() {
root["images"] = Value::Array(images);
root["textures"] = Value::Array(textures);
root["extensionsUsed"] = json!(["KHR_texture_transform"]);
}
serde_json::to_string(&root).map_err(|_| Error::InvalidOperation)
}
pub fn encode(&mut self) -> Result<(), Error> {
self.asset_data = self.to_json()?.into_bytes();
Ok(())
}
pub fn decode(&mut self) -> Result<bool, Error> {
let root: Value = serde_json::from_slice(&self.asset_data).map_err(|_| Error::Argument)?;
let mat = root
.get("materials")
.and_then(Value::as_array)
.and_then(|v| v.first())
.and_then(Value::as_object)
.ok_or(Error::Argument)?;
self.name = mat
.get("name")
.and_then(Value::as_str)
.unwrap_or_default()
.to_owned();
let pbr = mat.get("pbrMetallicRoughness").and_then(Value::as_object);
if let Some(values) = pbr
.and_then(|v| v.get("baseColorFactor"))
.and_then(Value::as_array)
{
if values.len() == 4 {
self.base_color_factor = Color4 {
r: number(&values[0])?,
g: number(&values[1])?,
b: number(&values[2])?,
a: number(&values[3])?,
};
}
}
self.metallic_factor = pbr
.and_then(|v| v.get("metallicFactor"))
.map(number)
.transpose()?
.unwrap_or(1.0);
self.roughness_factor = pbr
.and_then(|v| v.get("roughnessFactor"))
.map(number)
.transpose()?
.unwrap_or(1.0);
if let Some(values) = mat.get("emissiveFactor").and_then(Value::as_array) {
if values.len() == 3 {
self.emissive_factor = Vector3 {
x: number(&values[0])?,
y: number(&values[1])?,
z: number(&values[2])?,
};
}
}
self.alpha_mode = Self::parse_alpha(mat.get("alphaMode").and_then(Value::as_str))?;
self.alpha_cutoff = mat
.get("alphaCutoff")
.map(number)
.transpose()?
.unwrap_or(0.5);
self.double_sided = mat
.get("doubleSided")
.and_then(Value::as_bool)
.unwrap_or(false);
let textures = root.get("textures").and_then(Value::as_array);
let images = root.get("images").and_then(Value::as_array);
for (slot, info) in [
(0, pbr.and_then(|v| v.get("baseColorTexture"))),
(1, mat.get("normalTexture")),
(2, pbr.and_then(|v| v.get("metallicRoughnessTexture"))),
(3, mat.get("emissiveTexture")),
] {
if let Some(info) = info {
self.decode_texture(slot, info, textures, images)?;
}
}
Ok(true)
}
fn decode_texture(
&mut self,
slot: usize,
info: &Value,
textures: Option<&Vec<Value>>,
images: Option<&Vec<Value>>,
) -> Result<(), Error> {
let index = info
.get("index")
.and_then(Value::as_u64)
.and_then(|v| usize::try_from(v).ok())
.ok_or(Error::Argument)?;
let source = textures
.and_then(|v| v.get(index))
.and_then(|v| v.get("source"))
.and_then(Value::as_u64)
.and_then(|v| usize::try_from(v).ok())
.ok_or(Error::Argument)?;
let uri = images
.and_then(|v| v.get(source))
.and_then(|v| v.get("uri"))
.and_then(Value::as_str)
.ok_or(Error::Argument)?;
self.texture_ids[slot] = UUID::new_with_string(uri.to_owned())?;
if let Some(transform) = info.pointer("/extensions/KHR_texture_transform") {
if let Some(v) = transform.get("offset").and_then(Value::as_array) {
if v.len() == 2 {
self.texture_transforms[slot].offset = Vector2 {
x: number(&v[0])?,
y: number(&v[1])?,
};
}
}
if let Some(v) = transform.get("scale").and_then(Value::as_array) {
if v.len() == 2 {
self.texture_transforms[slot].scale = Vector2 {
x: number(&v[0])?,
y: number(&v[1])?,
};
}
}
if let Some(v) = transform.get("rotation") {
self.texture_transforms[slot].rotation = number(v)?;
}
}
Ok(())
}
pub fn to_override_osd(&self) -> Result<OSDMap, Error> {
let defaults = Self::default();
let mut map = HashMap::new();
let mut tex = Vec::new();
let mut last = None;
for (index, id) in self.texture_ids.iter().enumerate() {
if *id != UUID::zero() {
last = Some(index);
}
}
if let Some(last) = last {
for id in &self.texture_ids[..=last] {
tex.push(OSD::UUID(*id));
}
map.insert("tex".into(), OSD::Array(tex));
}
if self.base_color_factor != defaults.base_color_factor {
map.insert("bc".into(), color_osd(self.base_color_factor));
}
if self.emissive_factor != defaults.emissive_factor {
map.insert("ec".into(), vector3_osd(self.emissive_factor));
}
if self.metallic_factor != defaults.metallic_factor {
map.insert("mf".into(), OSD::Real(f64::from(self.metallic_factor)));
}
if self.roughness_factor != defaults.roughness_factor {
map.insert("rf".into(), OSD::Real(f64::from(self.roughness_factor)));
}
if self.override_alpha_mode {
map.insert("am".into(), OSD::Integer(self.alpha_mode as i32));
}
if self.alpha_cutoff != defaults.alpha_cutoff {
map.insert("ac".into(), OSD::Real(f64::from(self.alpha_cutoff)));
}
if self.override_double_sided {
map.insert("ds".into(), OSD::Boolean(self.double_sided));
}
let transforms: Vec<_> = self
.texture_transforms
.iter()
.map(|t| {
OSD::Map(HashMap::from([
("o".into(), vector2_osd(t.offset)),
("s".into(), vector2_osd(t.scale)),
("r".into(), OSD::Real(f64::from(t.rotation))),
]))
})
.collect();
if self.texture_transforms.iter().any(|v| !v.is_default()) {
map.insert("ti".into(), OSD::Array(transforms));
}
OSDMap::new_with_dictionary(map)
}
pub fn from_override_osd(data: OSDMap) -> Result<Self, Error> {
let map = data.snapshot();
let mut value = Self::default();
if let Some(OSD::Array(ids)) = map.get("tex") {
for (slot, id) in ids.iter().take(Self::TEXTURE_COUNT as usize).enumerate() {
value.texture_ids[slot] = id.as_uuid()?;
}
}
if let Some(v) = map.get("bc") {
value.base_color_factor = v.as_color4()?;
if value.base_color_factor == Self::default().base_color_factor {
value.base_color_factor.a -= FLOAT_EPSILON;
}
}
if let Some(v) = map.get("ec") {
value.emissive_factor = v.as_vector3()?;
if value.emissive_factor == Vector3::zero() {
value.emissive_factor.x += FLOAT_EPSILON;
}
}
if let Some(v) = map.get("mf") {
value.metallic_factor = (v.as_real()? as f32).min(1.0 - FLOAT_EPSILON);
}
if let Some(v) = map.get("rf") {
value.roughness_factor = (v.as_real()? as f32).min(1.0 - FLOAT_EPSILON);
}
if let Some(v) = map.get("am") {
value.alpha_mode = match v.as_integer()? {
0 => GltfAlphaMode::Opaque,
1 => GltfAlphaMode::Blend,
2 => GltfAlphaMode::Mask,
_ => return Err(Error::Argument),
};
value.override_alpha_mode = true;
}
if let Some(v) = map.get("ac") {
value.alpha_cutoff = v.as_real()? as f32;
if value.alpha_cutoff == 0.5 {
value.alpha_cutoff -= FLOAT_EPSILON;
}
}
if let Some(v) = map.get("ds") {
value.double_sided = v.as_boolean()?;
value.override_double_sided = true;
}
if let Some(OSD::Array(values)) = map.get("ti") {
for (slot, transform) in values.iter().take(Self::TEXTURE_COUNT as usize).enumerate() {
if let OSD::Map(t) = transform {
if let Some(v) = t.get("o") {
value.texture_transforms[slot].offset = v.as_vector2()?;
}
if let Some(v) = t.get("s") {
value.texture_transforms[slot].scale = v.as_vector2()?;
}
if let Some(v) = t.get("r") {
value.texture_transforms[slot].rotation = v.as_real()? as f32;
}
}
}
}
Ok(value)
}
pub const fn asset_type(&self) -> AssetType {
AssetType::Material
}
pub fn alpha_cutoff(&self) -> f32 {
self.alpha_cutoff
}
pub fn set_alpha_cutoff(&mut self, value: f32) {
self.alpha_cutoff = value;
}
pub fn alpha_mode(&self) -> GltfAlphaMode {
self.alpha_mode
}
pub fn set_alpha_mode(&mut self, value: GltfAlphaMode) {
self.alpha_mode = value;
}
pub fn base_color_factor(&self) -> Color4 {
self.base_color_factor
}
pub fn set_base_color_factor(&mut self, value: Color4) {
self.base_color_factor = value;
}
pub fn double_sided(&self) -> bool {
self.double_sided
}
pub fn set_double_sided(&mut self, value: bool) {
self.double_sided = value;
}
pub fn emissive_factor(&self) -> Vector3 {
self.emissive_factor
}
pub fn set_emissive_factor(&mut self, value: Vector3) {
self.emissive_factor = value;
}
pub fn metallic_factor(&self) -> f32 {
self.metallic_factor
}
pub fn set_metallic_factor(&mut self, value: f32) {
self.metallic_factor = value;
}
pub fn roughness_factor(&self) -> f32 {
self.roughness_factor
}
pub fn set_roughness_factor(&mut self, value: f32) {
self.roughness_factor = value;
}
pub fn name(&self) -> String {
self.name.clone()
}
pub fn set_name(&mut self, value: String) {
self.name = value;
}
pub fn override_alpha_mode(&self) -> bool {
self.override_alpha_mode
}
pub fn set_override_alpha_mode(&mut self, value: bool) {
self.override_alpha_mode = value;
}
pub fn override_double_sided(&self) -> bool {
self.override_double_sided
}
pub fn set_override_double_sided(&mut self, value: bool) {
self.override_double_sided = value;
}
pub fn texture_ids(&self) -> Vec<UUID> {
self.texture_ids.clone()
}
pub fn set_texture_ids(&mut self, value: Vec<UUID>) {
self.texture_ids = value;
}
pub fn texture_transforms(&self) -> Vec<GltfTextureTransform> {
self.texture_transforms.clone()
}
pub fn set_texture_transforms(&mut self, value: Vec<GltfTextureTransform>) {
self.texture_transforms = value;
}
}
fn number(value: &Value) -> Result<f32, Error> {
value
.as_f64()
.map(|v| v as f32)
.filter(|v| v.is_finite())
.ok_or(Error::Argument)
}
fn vector2_osd(value: Vector2) -> OSD {
OSD::Array(vec![
OSD::Real(f64::from(value.x)),
OSD::Real(f64::from(value.y)),
])
}
fn vector3_osd(value: Vector3) -> OSD {
OSD::Array(vec![
OSD::Real(f64::from(value.x)),
OSD::Real(f64::from(value.y)),
OSD::Real(f64::from(value.z)),
])
}
fn color_osd(value: Color4) -> OSD {
OSD::Array(vec![
OSD::Real(f64::from(value.r)),
OSD::Real(f64::from(value.g)),
OSD::Real(f64::from(value.b)),
OSD::Real(f64::from(value.a)),
])
}

View File

@@ -0,0 +1,676 @@
//! Native asset value models and their format boundaries.
#![allow(
clippy::format_push_string,
clippy::missing_errors_doc,
clippy::must_use_candidate,
clippy::needless_pass_by_value,
clippy::struct_field_names
)]
use crate::{Error, Permissions};
use libremetaverse_imaging::ManagedImage;
use libremetaverse_structured_data::{OSD, OSDMap, OSDParser};
use libremetaverse_types::{AssetType, SaleType, UUID, Vector3, WearableType};
use std::collections::HashMap;
use std::sync::{Arc, RwLock};
/// Maximum accepted in-memory asset payload. Network limits are enforced before
/// this boundary too, but constructors are public and must defend themselves.
pub const MAX_ASSET_BYTES: usize = 64 * 1024 * 1024;
fn validate_bytes(bytes: &[u8]) -> Result<(), Error> {
if bytes.len() > MAX_ASSET_BYTES {
Err(Error::Argument)
} else {
Ok(())
}
}
#[derive(Clone, Debug)]
pub struct Asset {
pub asset_data: Vec<u8>,
pub temporary: bool,
asset_id: UUID,
asset_type: AssetType,
}
impl Asset {
pub(crate) fn native_new(
asset_type: AssetType,
asset_id: UUID,
data: Vec<u8>,
) -> Result<Self, Error> {
validate_bytes(&data)?;
Ok(Self {
asset_data: data,
temporary: false,
asset_id,
asset_type,
})
}
pub fn decode(&self) -> Result<bool, Error> {
validate_bytes(&self.asset_data)?;
Ok(true)
}
pub fn encode(&self) -> Result<(), Error> {
validate_bytes(&self.asset_data)
}
pub const fn asset_id(&self) -> UUID {
self.asset_id
}
pub fn set_asset_id(&mut self, value: UUID) {
self.asset_id = value;
}
pub const fn asset_type(&self) -> AssetType {
self.asset_type
}
}
#[derive(Clone, Debug)]
struct RawAsset(Arc<RwLock<Asset>>);
impl RawAsset {
fn empty(asset_type: AssetType) -> Result<Self, Error> {
Ok(Self(Arc::new(RwLock::new(Asset::native_new(
asset_type,
UUID::zero(),
Vec::new(),
)?))))
}
fn with_data(asset_type: AssetType, id: UUID, data: Vec<u8>) -> Result<Self, Error> {
Ok(Self(Arc::new(RwLock::new(Asset::native_new(
asset_type, id, data,
)?))))
}
fn decode(&self) -> Result<bool, Error> {
self.0
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.decode()
}
fn encode(&self) -> Result<(), Error> {
self.0
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.encode()
}
fn bytes(&self) -> Vec<u8> {
self.0
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.asset_data
.clone()
}
fn replace(&self, bytes: Vec<u8>) -> Result<(), Error> {
validate_bytes(&bytes)?;
self.0
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.asset_data = bytes;
Ok(())
}
}
macro_rules! raw_asset {
($name:ident, $kind:expr) => {
#[derive(Clone, Debug)]
pub struct $name {
raw: RawAsset,
}
impl $name {
pub fn new_with_constructor() -> Result<Self, Error> {
Ok(Self {
raw: RawAsset::empty($kind)?,
})
}
pub fn new_with_uuid_bytes(asset_id: UUID, asset_data: Vec<u8>) -> Result<Self, Error> {
Ok(Self {
raw: RawAsset::with_data($kind, asset_id, asset_data)?,
})
}
pub fn decode(&self) -> Result<bool, Error> {
self.raw.decode()
}
pub fn encode(&self) -> Result<(), Error> {
self.raw.encode()
}
pub const fn asset_type(&self) -> AssetType {
$kind
}
}
};
}
raw_asset!(AssetAnimation, AssetType::Animation);
raw_asset!(AssetScriptBinary, AssetType::LSLBytecode);
raw_asset!(AssetSound, AssetType::Sound);
impl AssetSound {
pub fn pcm_to_ogg(
pcm_data: Vec<u8>,
sample_rate: i32,
channels: i32,
bits_per_sample: Option<i32>,
) -> Result<Vec<u8>, Error> {
validate_bytes(&pcm_data)?;
if pcm_data.is_empty()
|| sample_rate <= 0
|| !matches!(channels, 1 | 2)
|| !matches!(bits_per_sample.unwrap_or(16), 8 | 16)
{
return Err(Error::Argument);
}
let bits = bits_per_sample.unwrap_or(16);
let bytes_per_sample = usize::try_from(bits / 8).map_err(|_| Error::Argument)?;
let channels = usize::try_from(channels).map_err(|_| Error::Argument)?;
let frame_size = bytes_per_sample
.checked_mul(channels)
.ok_or(Error::Argument)?;
if !pcm_data.len().is_multiple_of(frame_size) {
return Err(Error::Argument);
}
let frequency =
std::num::NonZeroU32::new(u32::try_from(sample_rate).map_err(|_| Error::Argument)?)
.ok_or(Error::Argument)?;
let channel_count =
std::num::NonZeroU8::new(u8::try_from(channels).map_err(|_| Error::Argument)?)
.ok_or(Error::Argument)?;
let mut builder = vorbis_rs::VorbisEncoderBuilder::new_with_serial(
frequency,
channel_count,
Vec::new(),
0x4d43_4154,
);
let mut encoder = builder.build().map_err(|_| Error::InvalidOperation)?;
let frame_count = pcm_data.len() / frame_size;
for first_frame in (0..frame_count).step_by(1024) {
let end_frame = first_frame.saturating_add(1024).min(frame_count);
let mut planar = vec![Vec::with_capacity(end_frame - first_frame); channels];
for frame in first_frame..end_frame {
for (channel, samples) in planar.iter_mut().enumerate() {
let offset = frame * frame_size + channel * bytes_per_sample;
let sample = if bits == 8 {
(f32::from(pcm_data[offset]) - 128.0) / 128.0
} else {
f32::from(i16::from_le_bytes([pcm_data[offset], pcm_data[offset + 1]]))
/ 32768.0
};
samples.push(sample);
}
}
encoder
.encode_audio_block(&planar)
.map_err(|_| Error::InvalidOperation)?;
}
let ogg_data = encoder.finish().map_err(|_| Error::InvalidOperation)?;
validate_bytes(&ogg_data)?;
Ok(ogg_data)
}
}
#[derive(Clone, Debug)]
pub struct AssetMutable {
raw: RawAsset,
pub current_type: AssetType,
}
impl AssetMutable {
pub fn new_with_asset_type(type_: AssetType) -> Result<Self, Error> {
Ok(Self {
raw: RawAsset::empty(type_)?,
current_type: type_,
})
}
pub fn new_with_asset_type_uuid_bytes(
type_: AssetType,
asset_id: UUID,
asset_data: Vec<u8>,
) -> Result<Self, Error> {
Ok(Self {
raw: RawAsset::with_data(type_, asset_id, asset_data)?,
current_type: type_,
})
}
pub fn decode(&self) -> Result<bool, Error> {
self.raw.decode()
}
pub fn encode(&self) -> Result<(), Error> {
self.raw.encode()
}
pub const fn asset_type(&self) -> AssetType {
self.current_type
}
}
#[derive(Clone, Debug)]
pub struct AssetCallingCard {
raw: RawAsset,
pub avatar_id: UUID,
}
impl AssetCallingCard {
pub fn new_with_constructor() -> Result<Self, Error> {
Self::new_with_uuid(UUID::zero())
}
pub fn new_with_uuid(avatar_id: UUID) -> Result<Self, Error> {
let value = Self {
raw: RawAsset::empty(AssetType::CallingCard)?,
avatar_id,
};
value.encode()?;
Ok(value)
}
pub fn new_with_uuid_bytes(asset_id: UUID, asset_data: Vec<u8>) -> Result<Self, Error> {
let raw = RawAsset::with_data(AssetType::CallingCard, asset_id, asset_data)?;
let text = String::from_utf8(raw.bytes()).map_err(|_| Error::Argument)?;
let id = text
.lines()
.find_map(|line| line.strip_prefix("avatar_id "))
.ok_or(Error::Argument)
.and_then(|id| UUID::new_with_string(id.trim().to_owned()))?;
Ok(Self { raw, avatar_id: id })
}
pub fn decode(&self) -> Result<bool, Error> {
let text = String::from_utf8(self.raw.bytes()).map_err(|_| Error::Argument)?;
Ok(text.lines().any(|line| {
line.strip_prefix("avatar_id ")
.and_then(|id| UUID::new_with_string(id.trim().to_owned()).ok())
== Some(self.avatar_id)
}))
}
pub fn encode(&self) -> Result<(), Error> {
self.raw.replace(
format!(
"Linden text version 2\n{{\navatar_id {}\n}}\n",
self.avatar_id
)
.into_bytes(),
)
}
pub const fn asset_type(&self) -> AssetType {
AssetType::CallingCard
}
}
#[derive(Clone, Debug)]
pub struct AssetLandmark {
raw: RawAsset,
pub position: Vector3,
pub region_id: UUID,
}
impl AssetLandmark {
pub fn new_with_constructor() -> Result<Self, Error> {
Ok(Self {
raw: RawAsset::empty(AssetType::Landmark)?,
position: Vector3::zero(),
region_id: UUID::zero(),
})
}
pub fn new_with_uuid_bytes(asset_id: UUID, asset_data: Vec<u8>) -> Result<Self, Error> {
let raw = RawAsset::with_data(AssetType::Landmark, asset_id, asset_data)?;
let text = String::from_utf8(raw.bytes()).map_err(|_| Error::Argument)?;
let region = text
.lines()
.find_map(|line| line.strip_prefix("region_id "))
.ok_or(Error::Argument)
.and_then(|id| UUID::new_with_string(id.trim().to_owned()))?;
let values: Vec<f32> = text
.lines()
.find_map(|line| line.strip_prefix("local_pos "))
.ok_or(Error::Argument)?
.split_whitespace()
.map(str::parse)
.collect::<Result<_, _>>()
.map_err(|_| Error::Argument)?;
if values.len() != 3 {
return Err(Error::Argument);
}
Ok(Self {
raw,
region_id: region,
position: Vector3 {
x: values[0],
y: values[1],
z: values[2],
},
})
}
pub fn decode(&self) -> Result<bool, Error> {
Ok(Self::new_with_uuid_bytes(UUID::zero(), self.raw.bytes()).is_ok())
}
pub fn encode(&self) -> Result<(), Error> {
self.raw.replace(
format!(
"Landmark version 2\nregion_id {}\nlocal_pos {} {} {}\n",
self.region_id, self.position.x, self.position.y, self.position.z
)
.into_bytes(),
)
}
pub const fn asset_type(&self) -> AssetType {
AssetType::Landmark
}
}
#[derive(Clone, Debug)]
pub struct AssetScriptText {
raw: RawAsset,
pub source: Option<String>,
}
impl AssetScriptText {
pub fn new_with_constructor() -> Result<Self, Error> {
Ok(Self {
raw: RawAsset::empty(AssetType::LSLText)?,
source: None,
})
}
pub fn new_with_uuid_bytes(asset_id: UUID, asset_data: Vec<u8>) -> Result<Self, Error> {
let source = String::from_utf8(asset_data.clone()).map_err(|_| Error::Argument)?;
Ok(Self {
raw: RawAsset::with_data(AssetType::LSLText, asset_id, asset_data)?,
source: Some(source),
})
}
pub fn decode(&self) -> Result<bool, Error> {
Ok(String::from_utf8(self.raw.bytes()).is_ok())
}
pub fn encode(&self) -> Result<(), Error> {
self.raw
.replace(self.source.clone().unwrap_or_default().into_bytes())
}
pub const fn asset_type(&self) -> AssetType {
AssetType::LSLText
}
}
#[derive(Clone, Debug)]
pub struct AssetSettings {
raw: RawAsset,
pub settings: Option<OSD>,
}
impl AssetSettings {
pub fn new_with_constructor() -> Result<Self, Error> {
Ok(Self {
raw: RawAsset::empty(AssetType::Settings)?,
settings: None,
})
}
pub fn new_with_uuid_bytes(asset_id: UUID, asset_data: Vec<u8>) -> Result<Self, Error> {
let text = String::from_utf8(asset_data.clone()).map_err(|_| Error::Argument)?;
let settings = OSDParser::deserialize_with_string(text)?;
Ok(Self {
raw: RawAsset::with_data(AssetType::Settings, asset_id, asset_data)?,
settings: Some(settings),
})
}
pub fn decode(&self) -> Result<bool, Error> {
Ok(OSDParser::deserialize_with_bytes(self.raw.bytes()).is_ok())
}
pub fn encode(&self) -> Result<(), Error> {
let value = self.settings.clone().unwrap_or_default();
self.raw.replace(value.as_string()?.into_bytes())
}
pub const fn asset_type(&self) -> AssetType {
AssetType::Settings
}
}
pub struct AssetMesh {
raw: RawAsset,
pub mesh_data: OSDMap,
}
impl AssetMesh {
pub fn new_with_constructor() -> Result<Self, Error> {
Ok(Self {
raw: RawAsset::empty(AssetType::Mesh)?,
mesh_data: OSDMap::new_with_constructor()?,
})
}
pub fn new_with_uuid_bytes(asset_id: UUID, asset_data: Vec<u8>) -> Result<Self, Error> {
let raw = RawAsset::with_data(AssetType::Mesh, asset_id, asset_data.clone())?;
let mesh_data = match OSDParser::deserialize_llsd_binary_with_bytes(asset_data)? {
OSD::Map(map) => OSDMap::new_with_dictionary(map)?,
_ => return Err(Error::Argument),
};
Ok(Self { raw, mesh_data })
}
pub fn decode(&self) -> Result<bool, Error> {
Ok(matches!(
OSDParser::deserialize_llsd_binary_with_bytes(self.raw.bytes()),
Ok(OSD::Map(_))
))
}
pub fn encode(&self) -> Result<(), Error> {
self.raw
.replace(OSDParser::serialize_llsd_binary_with_osd(OSD::Map(
self.mesh_data.snapshot(),
))?)
}
pub const fn asset_type(&self) -> AssetType {
AssetType::Mesh
}
}
pub struct AssetTexture {
raw: RawAsset,
pub components: i32,
pub image: Option<ManagedImage>,
}
impl AssetTexture {
pub fn new_with_constructor() -> Result<Self, Error> {
Ok(Self {
raw: RawAsset::empty(AssetType::Texture)?,
components: 0,
image: None,
})
}
pub fn new_with_managed_image(image: ManagedImage) -> Result<Self, Error> {
Ok(Self {
raw: RawAsset::empty(AssetType::Texture)?,
components: 0,
image: Some(image),
})
}
pub fn new_with_uuid_bytes(asset_id: UUID, asset_data: Vec<u8>) -> Result<Self, Error> {
Ok(Self {
raw: RawAsset::with_data(AssetType::Texture, asset_id, asset_data)?,
components: 0,
image: None,
})
}
pub fn decode(&self) -> Result<bool, Error> {
#[cfg(feature = "jpeg2000")]
{
Ok(libremetaverse_imaging::J2kCodec::decode_bytes(
&self.raw.bytes(),
libremetaverse_imaging::J2kDecodeOptions::default(),
)
.is_ok())
}
#[cfg(not(feature = "jpeg2000"))]
{
validate_bytes(&self.raw.bytes())?;
Err(Error::InvalidOperation)
}
}
pub fn encode(&self) -> Result<(), Error> {
let image = self.image.as_ref().ok_or(Error::InvalidOperation)?;
#[cfg(feature = "jpeg2000")]
{
let encoded = libremetaverse_imaging::J2kCodec::encode(
image,
libremetaverse_imaging::J2kEncodeOptions::default(),
)?;
self.raw.replace(encoded)
}
#[cfg(not(feature = "jpeg2000"))]
{
let _ = image;
Err(Error::InvalidOperation)
}
}
pub const fn asset_type(&self) -> AssetType {
AssetType::Texture
}
}
pub struct AssetWearable {
raw: RawAsset,
pub creator: UUID,
pub description: String,
pub for_sale: SaleType,
pub group: UUID,
pub group_owned: bool,
pub last_owner: UUID,
pub name: String,
pub owner: UUID,
pub params: HashMap<i32, f32>,
pub permissions: Permissions,
pub sale_price: i32,
pub textures: HashMap<crate::AvatarTextureIndex, UUID>,
pub wearable_type: WearableType,
}
impl AssetWearable {
fn empty(kind: AssetType) -> Result<Self, Error> {
Ok(Self {
raw: RawAsset::empty(kind)?,
creator: UUID::zero(),
description: String::new(),
for_sale: SaleType::Not,
group: UUID::zero(),
group_owned: false,
last_owner: UUID::zero(),
name: String::new(),
owner: UUID::zero(),
params: HashMap::new(),
permissions: Permissions::default(),
sale_price: 0,
textures: HashMap::new(),
wearable_type: WearableType::Invalid,
})
}
pub fn decode(&self) -> Result<bool, Error> {
Ok(std::str::from_utf8(&self.raw.bytes()).is_ok())
}
pub fn encode(&self) -> Result<(), Error> {
let mut text = format!(
"LLWearable version 22\n{}\n{}\ntype {}\nparameters {}\n",
self.name,
self.description,
self.wearable_type as i32,
self.params.len()
);
let mut params: Vec<_> = self.params.iter().collect();
params.sort_unstable_by_key(|(id, _)| **id);
for (id, value) in params {
text.push_str(&format!("{id} {value}\n"));
}
text.push_str(&format!("textures {}\n", self.textures.len()));
self.raw.replace(text.into_bytes())
}
}
pub struct AssetBodypart {
wearable: AssetWearable,
}
impl AssetBodypart {
pub fn new_with_constructor() -> Result<Self, Error> {
Ok(Self {
wearable: AssetWearable::empty(AssetType::Bodypart)?,
})
}
pub fn new_with_uuid_bytes(asset_id: UUID, asset_data: Vec<u8>) -> Result<Self, Error> {
let mut wearable = AssetWearable::empty(AssetType::Bodypart)?;
wearable.raw = RawAsset::with_data(AssetType::Bodypart, asset_id, asset_data)?;
Ok(Self { wearable })
}
pub const fn asset_type(&self) -> AssetType {
AssetType::Bodypart
}
pub fn decode(&self) -> Result<bool, Error> {
self.wearable.decode()
}
pub fn encode(&self) -> Result<(), Error> {
self.wearable.encode()
}
}
pub struct AssetClothing {
wearable: AssetWearable,
}
impl AssetClothing {
pub fn new_with_constructor() -> Result<Self, Error> {
Ok(Self {
wearable: AssetWearable::empty(AssetType::Clothing)?,
})
}
pub fn new_with_uuid_bytes(asset_id: UUID, asset_data: Vec<u8>) -> Result<Self, Error> {
let mut wearable = AssetWearable::empty(AssetType::Clothing)?;
wearable.raw = RawAsset::with_data(AssetType::Clothing, asset_id, asset_data)?;
Ok(Self { wearable })
}
pub const fn asset_type(&self) -> AssetType {
AssetType::Clothing
}
pub fn decode(&self) -> Result<bool, Error> {
self.wearable.decode()
}
pub fn encode(&self) -> Result<(), Error> {
self.wearable.encode()
}
}
pub struct AssetNotecard {
raw: RawAsset,
pub body_text: String,
pub embedded_items: Vec<crate::InventoryItem>,
}
impl AssetNotecard {
pub fn new_with_constructor() -> Result<Self, Error> {
Ok(Self {
raw: RawAsset::empty(AssetType::Notecard)?,
body_text: String::new(),
embedded_items: Vec::new(),
})
}
pub fn new_with_uuid_bytes(asset_id: UUID, asset_data: Vec<u8>) -> Result<Self, Error> {
let text = String::from_utf8(asset_data.clone()).map_err(|_| Error::Argument)?;
let marker = "Text length ";
let start = text.find(marker).ok_or(Error::Argument)? + marker.len();
let line_end = text[start..].find('\n').ok_or(Error::Argument)? + start;
let length: usize = text[start..line_end]
.trim()
.parse()
.map_err(|_| Error::Argument)?;
let body_start = line_end + 1;
let body_end = body_start.checked_add(length).ok_or(Error::Argument)?;
let body_text = text
.get(body_start..body_end)
.ok_or(Error::Argument)?
.to_owned();
Ok(Self {
raw: RawAsset::with_data(AssetType::Notecard, asset_id, asset_data)?,
body_text,
embedded_items: Vec::new(),
})
}
pub fn decode(&self) -> Result<bool, Error> {
Ok(Self::new_with_uuid_bytes(UUID::zero(), self.raw.bytes()).is_ok())
}
pub fn encode(&self) -> Result<(), Error> {
if !self.embedded_items.is_empty() {
return Err(Error::InvalidOperation);
}
let bytes = self.body_text.as_bytes();
self.raw.replace(format!("Linden text version 2\n{{\nLLEmbeddedItems version 1\n{{\ncount 0\n}}\nText length {}\n{}\n}}\n", bytes.len(), self.body_text).into_bytes())
}
pub const fn asset_type(&self) -> AssetType {
AssetType::Notecard
}
}

View File

@@ -0,0 +1,133 @@
use crate::assets::{AssetMesh, AssetMutable, AssetSound, AssetTexture};
use crate::{
AssetCache, AssetCacheComputeAssetCacheFilenameDelegate, Error, GridClient, ImageCodec,
};
use libremetaverse_imaging::{ManagedImage, ManagedImageImageChannels};
use libremetaverse_types::{AssetType, UUID};
use std::fs;
use std::path::PathBuf;
fn fixture(max_size: i64) -> (GridClient, AssetCache, PathBuf) {
let path =
std::env::temp_dir().join(format!("metacrate-asset-cache-{}", UUID::random().unwrap()));
let mut client = GridClient::new().unwrap();
let settings = client.settings();
settings.asset_cache_mut().dir = path.to_string_lossy().into_owned();
settings.asset_cache_mut().enabled = true;
settings.asset_cache_mut().max_size = max_size;
let cache = AssetCache::new(client.clone()).unwrap();
(client, cache, path)
}
#[test]
fn asset_models_retain_type_and_reject_malformed_meshes() {
let asset = AssetMutable::new_with_asset_type_uuid_bytes(
AssetType::Sound,
UUID::random().unwrap(),
vec![1, 2, 3],
)
.unwrap();
assert_eq!(asset.asset_type(), AssetType::Sound);
assert!(asset.decode().unwrap());
assert!(AssetMesh::new_with_uuid_bytes(UUID::random().unwrap(), b"not-llsd".to_vec()).is_err());
}
#[test]
fn sound_and_texture_codecs_produce_real_valid_payloads() {
let ogg = AssetSound::pcm_to_ogg(vec![0_u8; 256 * 2], 44_100, 1, Some(16)).unwrap();
assert!(ogg.starts_with(b"OggS"));
let mut image = ManagedImage::new(2, 2, ManagedImageImageChannels::COLOR).unwrap();
image.red.fill(255);
let texture = AssetTexture::new_with_managed_image(image).unwrap();
texture.encode().unwrap();
assert!(texture.decode().unwrap());
}
#[test]
fn cache_writes_are_atomic_and_corruption_is_typed() {
let (_client, cache, path) = fixture(1024);
let id = UUID::random().unwrap();
cache
.save_asset_to_cache_with_uuid_bytes(id, vec![1, 2, 3, 4])
.unwrap();
cache
.save_asset_to_cache_with_uuid_bytes(id, vec![9, 9, 9, 9])
.unwrap();
assert_eq!(
cache.get_cached_asset_bytes_with_uuid(id).unwrap(),
Some(vec![1, 2, 3, 4])
);
let image = cache.get_cached_image(id).unwrap().unwrap();
assert_eq!(image.codec, ImageCodec::J2C);
assert_eq!(image.base.asset_data, vec![1, 2, 3, 4]);
let corrupt = UUID::random().unwrap();
fs::write(path.join(corrupt.to_string()), []).unwrap();
assert_eq!(
cache.get_cached_asset_bytes_with_uuid(corrupt),
Err(Error::Argument)
);
fs::remove_dir_all(path).unwrap();
}
#[test]
fn custom_cache_naming_is_used_but_cannot_escape_the_cache_directory() {
let (_client, mut cache, path) = fixture(1024);
cache.compute_asset_cache_filename = Some(
AssetCacheComputeAssetCacheFilenameDelegate::from_handler(|directory, id| {
Ok(PathBuf::from(directory)
.join(format!("{id}.asset"))
.to_string_lossy()
.into_owned())
}),
);
let id = UUID::random().unwrap();
cache
.save_asset_to_cache_with_uuid_bytes(id, vec![7, 8, 9])
.unwrap();
assert_eq!(
cache.get_cached_asset_bytes_with_uuid(id).unwrap(),
Some(vec![7, 8, 9])
);
assert!(path.join(format!("{id}.asset")).is_file());
cache.clear().unwrap();
assert!(!path.join(format!("{id}.asset")).exists());
cache.compute_asset_cache_filename = Some(
AssetCacheComputeAssetCacheFilenameDelegate::from_handler(|directory, _| {
Ok(PathBuf::from(directory)
.join("..")
.join("escape")
.to_string_lossy()
.into_owned())
}),
);
assert_eq!(
cache.has_asset(UUID::random().unwrap()),
Err(Error::Argument)
);
fs::remove_dir_all(path).unwrap();
}
#[tokio::test]
async fn cache_pruning_reclaims_old_entries_to_below_the_limit() {
let (_client, cache, path) = fixture(12);
cache
.save_asset_to_cache_with_uuid_bytes(UUID::random().unwrap(), vec![1; 8])
.unwrap();
std::thread::sleep(std::time::Duration::from_millis(5));
cache
.save_asset_to_cache_with_uuid_bytes(UUID::random().unwrap(), vec![2; 8])
.unwrap();
cache.prune(None).await.unwrap();
let bytes: u64 = fs::read_dir(&path)
.unwrap()
.filter_map(Result::ok)
.filter_map(|entry| entry.metadata().ok())
.map(|metadata| metadata.len())
.sum();
assert!(bytes <= 10);
fs::remove_dir_all(path).unwrap();
}

View File

@@ -120,6 +120,7 @@ struct ClientRuntime {
agent_manager: Mutex<std::sync::Weak<crate::agent_manager::AgentManagerInner>>, agent_manager: Mutex<std::sync::Weak<crate::agent_manager::AgentManagerInner>>,
inventory_manager: Mutex<Option<Arc<crate::inventory_manager::InventoryManagerInner>>>, inventory_manager: Mutex<Option<Arc<crate::inventory_manager::InventoryManagerInner>>>,
inventory_ais_client: Mutex<Option<crate::inventory_ais::InventoryAISClient>>, inventory_ais_client: Mutex<Option<crate::inventory_ais::InventoryAISClient>>,
asset_manager: Mutex<Option<Arc<crate::asset_manager::AssetManagerInner>>>,
shutdown_complete: Condvar, shutdown_complete: Condvar,
shutdown_wait: Mutex<()>, shutdown_wait: Mutex<()>,
} }
@@ -150,6 +151,7 @@ impl ClientRuntime {
agent_manager: Mutex::new(std::sync::Weak::new()), agent_manager: Mutex::new(std::sync::Weak::new()),
inventory_manager: Mutex::new(None), inventory_manager: Mutex::new(None),
inventory_ais_client: Mutex::new(None), inventory_ais_client: Mutex::new(None),
asset_manager: Mutex::new(None),
shutdown_complete: Condvar::new(), shutdown_complete: Condvar::new(),
shutdown_wait: Mutex::new(()), shutdown_wait: Mutex::new(()),
} }
@@ -447,6 +449,29 @@ impl GridClient {
Ok(manager) Ok(manager)
} }
pub(crate) fn native_assets(&self) -> Result<crate::AssetManager, crate::Error> {
let mut cached = self
.runtime
.asset_manager
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if let Some(inner) = cached.as_ref() {
return crate::asset_manager::AssetManager::native_from_inner(Arc::clone(inner));
}
let manager = crate::asset_manager::AssetManager::new(Some(self.clone()))?;
*cached = Some(manager.native_inner());
Ok(manager)
}
#[allow(clippy::needless_pass_by_value)]
pub(crate) fn native_set_assets(&mut self, value: crate::AssetManager) {
*self
.runtime
.asset_manager
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner) = Some(value.native_inner());
}
pub(crate) fn native_ais_client(&self) -> Result<crate::InventoryAISClient, crate::Error> { pub(crate) fn native_ais_client(&self) -> Result<crate::InventoryAISClient, crate::Error> {
let mut cached = self let mut cached = self
.runtime .runtime

View File

@@ -5,8 +5,7 @@
use crate::{Error, GridClient, HttpCapsClientProgressReport}; use crate::{Error, GridClient, HttpCapsClientProgressReport};
use libremetaverse_types::compat::{ use libremetaverse_types::compat::{
CancellationToken, CancellationTokenSource, HttpResponse, IProgress, Subscription, CancellationToken, CancellationTokenSource, HttpResponse, IProgress, TaskCompletionSource, Uri,
TaskCompletionSource, Uri,
}; };
use std::collections::HashMap; use std::collections::HashMap;
use std::fmt; use std::fmt;
@@ -82,7 +81,6 @@ impl fmt::Debug for DownloadRequest {
struct ActiveDownload { struct ActiveDownload {
cancellation: CancellationTokenSource, cancellation: CancellationTokenSource,
cancellation_guards: Mutex<Vec<Subscription>>,
progress: Mutex<Vec<Arc<dyn IProgress<HttpCapsClientProgressReport>>>>, progress: Mutex<Vec<Arc<dyn IProgress<HttpCapsClientProgressReport>>>>,
completion_sources: Mutex<Vec<DownloadCompletion>>, completion_sources: Mutex<Vec<DownloadCompletion>>,
waiters: Mutex<Vec<oneshot::Sender<DownloadResult>>>, waiters: Mutex<Vec<oneshot::Sender<DownloadResult>>>,
@@ -93,7 +91,6 @@ impl ActiveDownload {
fn new() -> Self { fn new() -> Self {
Self { Self {
cancellation: CancellationTokenSource::new(), cancellation: CancellationTokenSource::new(),
cancellation_guards: Mutex::new(Vec::new()),
progress: Mutex::new(Vec::new()), progress: Mutex::new(Vec::new()),
completion_sources: Mutex::new(Vec::new()), completion_sources: Mutex::new(Vec::new()),
waiters: Mutex::new(Vec::new()), waiters: Mutex::new(Vec::new()),
@@ -101,12 +98,6 @@ impl ActiveDownload {
} }
} }
fn attach_cancellation(&self, token: &CancellationToken) {
let cancellation = self.cancellation.clone();
let guard = token.register_callback(Arc::new(move || cancellation.cancel()));
mutex(&self.cancellation_guards).push(guard);
}
fn attach_progress(&self, progress: Option<Box<dyn IProgress<HttpCapsClientProgressReport>>>) { fn attach_progress(&self, progress: Option<Box<dyn IProgress<HttpCapsClientProgressReport>>>) {
if let Some(progress) = progress { if let Some(progress) = progress {
mutex(&self.progress).push(Arc::from(progress)); mutex(&self.progress).push(Arc::from(progress));
@@ -146,7 +137,6 @@ impl ActiveDownload {
} }
} }
mutex(&self.progress).clear(); mutex(&self.progress).clear();
mutex(&self.cancellation_guards).clear();
} }
fn report(&self, report: HttpCapsClientProgressReport) { fn report(&self, report: HttpCapsClientProgressReport) {
@@ -371,13 +361,8 @@ impl DownloadManager {
request: DownloadRequest, request: DownloadRequest,
) -> DownloadResult { ) -> DownloadResult {
let cancellation = request.cancellation_token.clone(); let cancellation = request.cancellation_token.clone();
let (active, receiver) = self.enqueue(request, true)?; let (_, receiver) = self.enqueue(request, true)?;
await_download( await_download(receiver.ok_or(Error::InvalidOperation)?, cancellation).await
active,
receiver.ok_or(Error::InvalidOperation)?,
cancellation,
)
.await
} }
pub async fn queue_download_with_uri_string_i_progress_cancellation_token_int32( pub async fn queue_download_with_uri_string_i_progress_cancellation_token_int32(
@@ -392,13 +377,8 @@ impl DownloadManager {
let mut request = DownloadRequest::new(address, content_type, progress_callback)?; let mut request = DownloadRequest::new(address, content_type, progress_callback)?;
request.cancellation_token = cancellation.clone(); request.cancellation_token = cancellation.clone();
request.retries = retries.unwrap_or(5).max(0); request.retries = retries.unwrap_or(5).max(0);
let (active, receiver) = self.enqueue(request, true)?; let (_, receiver) = self.enqueue(request, true)?;
await_download( await_download(receiver.ok_or(Error::InvalidOperation)?, cancellation).await
active,
receiver.ok_or(Error::InvalidOperation)?,
cancellation,
)
.await
} }
pub async fn download_with_uri_string_i_progress_cancellation_token( pub async fn download_with_uri_string_i_progress_cancellation_token(
@@ -466,7 +446,6 @@ impl DownloadManager {
} }
return Err(Error::Cancelled); return Err(Error::Cancelled);
}; };
active.attach_cancellation(&request.cancellation_token);
active.attach_progress(request.download_progress_callback.take()); active.attach_progress(request.download_progress_callback.take());
active.attach_completion_source(request.completion_tcs.take()); active.attach_completion_source(request.completion_tcs.take());
let receiver = with_waiter.then(|| active.add_waiter()); let receiver = with_waiter.then(|| active.add_waiter());
@@ -507,14 +486,12 @@ impl fmt::Debug for DownloadManager {
} }
async fn await_download( async fn await_download(
active: Arc<ActiveDownload>,
receiver: oneshot::Receiver<DownloadResult>, receiver: oneshot::Receiver<DownloadResult>,
cancellation: CancellationToken, cancellation: CancellationToken,
) -> DownloadResult { ) -> DownloadResult {
tokio::select! { tokio::select! {
result = receiver => result.map_err(|_| Error::Cancelled)?, result = receiver => result.map_err(|_| Error::Cancelled)?,
() = cancellation.cancelled() => { () = cancellation.cancelled() => {
active.cancellation.cancel();
Err(Error::Cancelled) Err(Error::Cancelled)
} }
} }

File diff suppressed because it is too large Load Diff

View File

@@ -4396,6 +4396,117 @@ fn parse_task_inventory_values(
Ok(result) Ok(result)
} }
impl InventoryManager {
pub(crate) async fn native_update_material_inventory(
&self,
material: crate::assets::AssetMaterial,
material_item_id: UUID,
task_id: Option<UUID>,
cancellation_token: Option<CancellationToken>,
progress: Option<
Box<dyn libremetaverse_types::compat::IProgress<crate::HttpCapsClientProgressReport>>,
>,
) -> Result<(bool, String, UUID, UUID), Error> {
let client = self.client()?;
let simulator = client
.native_network()?
.current_sim()
.ok_or(Error::InvalidOperation)?;
let caps = simulator.native_caps().ok_or(Error::InvalidOperation)?;
let name = if task_id.is_some() {
"UpdateMaterialTaskInventory"
} else {
"UpdateMaterialAgentInventory"
};
let deadline = Instant::now() + Duration::from_secs(2);
let uri = loop {
if let Some(uri) = caps.capability_uri(name.to_owned())? {
break uri;
}
if caps.seed_request_finished() || Instant::now() >= deadline {
return Err(Error::InvalidOperation);
}
std::thread::sleep(Duration::from_millis(1));
};
let mut query = HashMap::from([("item_id".to_owned(), OSD::UUID(material_item_id))]);
if let Some(task_id) = task_id {
query.insert("task_id".to_owned(), OSD::UUID(task_id));
}
let payload = OSDParser::serialize_llsd_xml_bytes(OSD::Map(query))?;
let token = cancellation_token.unwrap_or_default();
let (response, metadata) = client
.native_http_caps_client()
.post_with_uri_string_bytes_cancellation_token_i_progress(
uri,
"application/llsd+xml".into(),
payload,
token.clone(),
None,
)
.await?;
if !(200..300).contains(&response.status_code) {
return Ok((
false,
format!("HTTP {}", response.status_code),
UUID::zero(),
UUID::zero(),
));
}
let meta: serde_json::Value =
serde_json::from_slice(&metadata).map_err(|_| Error::Argument)?;
let status = meta
.get("state")
.and_then(serde_json::Value::as_str)
.unwrap_or_default()
.to_owned();
let uploader = meta
.get("uploader")
.and_then(serde_json::Value::as_str)
.ok_or(Error::Argument)?
.to_owned();
let data = material.to_json()?.into_bytes();
let (upload_response, response_body) = client
.native_http_caps_client()
.post_with_uri_string_bytes_cancellation_token_i_progress(
Uri(uploader),
"application/octet-stream".into(),
data,
token,
progress,
)
.await?;
if !(200..300).contains(&upload_response.status_code) {
return Ok((
false,
format!("HTTP {}", upload_response.status_code),
material_item_id,
UUID::zero(),
));
}
let result: serde_json::Value =
serde_json::from_slice(&response_body).map_err(|_| Error::Argument)?;
let final_status = result
.get("state")
.and_then(serde_json::Value::as_str)
.unwrap_or(&status)
.to_owned();
let asset_id = result
.get("new_asset")
.or_else(|| result.get("new_asset_id"))
.and_then(serde_json::Value::as_str)
.map(|id| UUID::new_with_string(id.to_owned()))
.transpose()?
.unwrap_or_else(UUID::zero);
let item_id = result
.get("item_id")
.and_then(serde_json::Value::as_str)
.map(|id| UUID::new_with_string(id.to_owned()))
.transpose()?
.unwrap_or(material_item_id);
Ok((final_status == "complete", final_status, asset_id, item_id))
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;

View File

@@ -7,6 +7,10 @@ mod attention_catalog;
mod agent_manager; mod agent_manager;
mod agent_messages; mod agent_messages;
mod agent_movement; mod agent_movement;
mod asset_cache;
mod asset_manager;
mod asset_material;
mod asset_models;
mod bit_pack; mod bit_pack;
mod caps; mod caps;
mod caps_http; mod caps_http;
@@ -27,6 +31,7 @@ mod login;
mod message_codec; mod message_codec;
mod message_decoder; mod message_decoder;
mod network_manager; mod network_manager;
mod object_material;
#[rustfmt::skip] #[rustfmt::skip]
pub mod packet_catalog; pub mod packet_catalog;
mod packet_wire; mod packet_wire;
@@ -35,6 +40,7 @@ mod skeleton;
#[rustfmt::skip] // Deterministic machine output is formatted by the pinned generator. #[rustfmt::skip] // Deterministic machine output is formatted by the pinned generator.
mod skeleton_catalog; mod skeleton_catalog;
mod targa; mod targa;
mod transfers;
mod udp_transport; mod udp_transport;
#[rustfmt::skip] // Deterministic machine output is formatted by the pinned generator. #[rustfmt::skip] // Deterministic machine output is formatted by the pinned generator.
mod visual_catalog; mod visual_catalog;
@@ -44,6 +50,8 @@ mod appearance_baker_semantics;
#[cfg(test)] #[cfg(test)]
mod asset_archive_semantics; mod asset_archive_semantics;
#[cfg(test)] #[cfg(test)]
mod asset_pipeline_semantics;
#[cfg(test)]
mod avatar_animesh_semantics; mod avatar_animesh_semantics;
#[cfg(test)] #[cfg(test)]
mod batch_link_internal_semantics; mod batch_link_internal_semantics;

View File

@@ -0,0 +1,192 @@
//! `ModifyMaterialParams` capability integration.
#![allow(
clippy::missing_errors_doc,
clippy::must_use_candidate,
clippy::option_option
)]
use crate::assets::AssetMaterial;
use crate::{Error, GridClient, Simulator};
use libremetaverse_structured_data::{OSD, OSDParser};
use libremetaverse_types::{UUID, compat::CancellationToken};
use std::collections::HashMap;
use std::sync::Arc;
use std::time::{Duration, Instant};
#[derive(Clone, Debug)]
pub struct GLTFMaterialUpdate {
asset_id: Option<Option<UUID>>,
object_id: UUID,
override_: Option<AssetMaterial>,
side: i32,
}
impl GLTFMaterialUpdate {
pub fn new() -> Result<Self, Error> {
Ok(Self {
asset_id: None,
object_id: UUID::zero(),
override_: None,
side: 0,
})
}
pub fn asset_id(&self) -> Option<Option<UUID>> {
self.asset_id
}
pub fn set_asset_id(&mut self, value: Option<Option<UUID>>) {
self.asset_id = value;
}
pub const fn object_id(&self) -> UUID {
self.object_id
}
pub fn set_object_id(&mut self, value: UUID) {
self.object_id = value;
}
pub fn override_(&self) -> Option<AssetMaterial> {
self.override_.clone()
}
pub fn set_override_(&mut self, value: Option<AssetMaterial>) {
self.override_ = value;
}
pub const fn side(&self) -> i32 {
self.side
}
pub fn set_side(&mut self, value: i32) {
self.side = value;
}
}
pub struct ObjectManager {
client: crate::client_core::ClientWeakHandle,
}
impl ObjectManager {
pub(crate) fn native_new(client: Option<Arc<GridClient>>) -> Result<Self, Error> {
Ok(Self {
client: client.ok_or(Error::ArgumentNull)?.native_weak_handle(),
})
}
fn client(&self) -> Result<GridClient, Error> {
self.client.upgrade().ok_or(Error::InvalidOperation)
}
pub(crate) async fn native_send_material_updates(
&self,
sim: Simulator,
updates: Box<dyn Iterator<Item = GLTFMaterialUpdate>>,
cancellation_token: Option<CancellationToken>,
) -> Result<bool, Error> {
let Some(caps) = sim.native_caps() else {
return Ok(false);
};
let deadline = Instant::now() + Duration::from_secs(2);
let uri = loop {
if let Some(uri) = caps.capability_uri("ModifyMaterialParams".into())? {
break uri;
}
if caps.seed_request_finished() || Instant::now() >= deadline {
return Ok(false);
}
std::thread::sleep(Duration::from_millis(1));
};
let mut body = Vec::new();
for update in updates {
if update.side < 0 {
return Err(Error::Argument);
}
let mut entry = HashMap::from([
("object_id".into(), OSD::UUID(update.object_id)),
("side".into(), OSD::Integer(update.side)),
]);
if let Some(Some(asset_id)) = update.asset_id {
entry.insert("asset_id".into(), OSD::UUID(asset_id));
}
if let Some(material) = update.override_ {
entry.insert("gltf_json".into(), OSD::String(material.to_json()?));
} else if update.asset_id.flatten().is_none() {
entry.insert("gltf_json".into(), OSD::String(String::new()));
}
body.push(OSD::Map(entry));
}
if body.is_empty() {
return Err(Error::Argument);
}
let payload = OSDParser::serialize_llsd_xml_bytes(OSD::Array(body))?;
let client = self.client()?;
let (response, bytes) = client
.native_http_caps_client()
.post_with_uri_string_bytes_cancellation_token_i_progress(
uri,
"application/llsd+xml".into(),
payload,
cancellation_token.unwrap_or_default(),
None,
)
.await?;
if !(200..300).contains(&response.status_code) {
return Ok(false);
}
let response: serde_json::Value =
serde_json::from_slice(&bytes).map_err(|_| Error::Argument)?;
Ok(response
.get("success")
.and_then(serde_json::Value::as_bool)
.unwrap_or(false))
}
pub(crate) async fn native_set_material_override(
&self,
sim: Simulator,
object_id: UUID,
side: i32,
override_material: AssetMaterial,
cancellation_token: Option<CancellationToken>,
) -> Result<bool, Error> {
let mut update = GLTFMaterialUpdate::new()?;
update.object_id = object_id;
update.side = side;
update.override_ = Some(override_material);
self.native_send_material_updates(
sim,
Box::new(std::iter::once(update)),
cancellation_token,
)
.await
}
pub(crate) async fn native_clear_material_override(
&self,
sim: Simulator,
object_id: UUID,
side: i32,
cancellation_token: Option<CancellationToken>,
) -> Result<bool, Error> {
let mut update = GLTFMaterialUpdate::new()?;
update.object_id = object_id;
update.side = side;
self.native_send_material_updates(
sim,
Box::new(std::iter::once(update)),
cancellation_token,
)
.await
}
pub(crate) async fn native_apply_material(
&self,
sim: Simulator,
object_id: UUID,
side: i32,
material_asset_id: UUID,
override_material: Option<AssetMaterial>,
cancellation_token: Option<CancellationToken>,
) -> Result<bool, Error> {
let mut update = GLTFMaterialUpdate::new()?;
update.object_id = object_id;
update.side = side;
update.asset_id = Some(Some(material_asset_id));
update.override_ = override_material;
self.native_send_material_updates(
sim,
Box::new(std::iter::once(update)),
cancellation_token,
)
.await
}
}

View File

@@ -0,0 +1,226 @@
//! Asset transfer state shared by HTTP and LLUDP pipelines.
#![allow(clippy::missing_errors_doc, clippy::must_use_candidate)]
use crate::{
ChannelType, Error, ImageCodec, ImageType, Simulator, SourceType, StatusCode, TargetType,
TransferError,
};
use libremetaverse_types::compat::SortedList;
use libremetaverse_types::{AssetType, UUID};
use std::collections::HashMap;
use std::sync::RwLock;
use std::time::Instant;
#[derive(Clone, Debug)]
pub struct Transfer {
pub asset_data: Vec<u8>,
pub asset_type: AssetType,
pub id: UUID,
pub size: i32,
pub success: bool,
pub transferred: i32,
pub(crate) last_packet: Instant,
}
impl Transfer {
pub fn new() -> Result<Self, Error> {
Ok(Self {
asset_data: Vec::new(),
asset_type: AssetType::Unknown,
id: UUID::zero(),
size: 0,
success: false,
transferred: 0,
last_packet: Instant::now(),
})
}
pub fn time_since_last_packet(&self) -> i32 {
i32::try_from(self.last_packet.elapsed().as_millis()).unwrap_or(i32::MAX)
}
pub fn set_time_since_last_packet(&mut self, value: i32) {
self.last_packet = Instant::now()
.checked_sub(std::time::Duration::from_millis(
u64::try_from(value.max(0)).unwrap_or(0),
))
.unwrap_or_else(Instant::now);
}
}
#[derive(Debug)]
pub struct AssetDownload {
pub asset_id: UUID,
pub channel: ChannelType,
pub priority: f32,
pub simulator: Option<Simulator>,
pub source: SourceType,
pub status: StatusCode,
pub target: TargetType,
pub next_packet: i32,
pub out_of_order_packets: RwLock<HashMap<i32, Vec<u8>>>,
pub base: Transfer,
}
impl AssetDownload {
pub fn new() -> Result<Self, Error> {
Ok(Self {
asset_id: UUID::zero(),
channel: ChannelType::Asset,
priority: 0.0,
simulator: None,
source: SourceType::Asset,
status: StatusCode::Unknown,
target: TargetType::Unknown,
next_packet: 0,
out_of_order_packets: RwLock::new(HashMap::new()),
base: Transfer::new()?,
})
}
}
#[derive(Debug)]
pub struct ImageDownload {
pub codec: ImageCodec,
pub discard_level: i32,
pub image_type: ImageType,
pub packet_count: u16,
pub packets_seen: SortedList<u16, u16>,
pub priority: f32,
pub simulator: Option<Simulator>,
pub base: Transfer,
}
impl ImageDownload {
pub fn new() -> Result<Self, Error> {
Ok(Self {
codec: ImageCodec::Invalid,
discard_level: 0,
image_type: ImageType::Normal,
packet_count: 0,
packets_seen: SortedList(std::collections::BTreeMap::new()),
priority: 0.0,
simulator: None,
base: Transfer::new()?,
})
}
}
#[derive(Clone, Debug)]
pub struct AssetUpload {
pub asset_id: UUID,
pub packet_num: u32,
pub type_: AssetType,
pub xfer_id: u64,
pub base: Transfer,
}
impl AssetUpload {
pub fn new() -> Result<Self, Error> {
Ok(Self {
asset_id: UUID::zero(),
packet_num: 0,
type_: AssetType::Unknown,
xfer_id: 0,
base: Transfer::new()?,
})
}
}
#[derive(Clone, Debug)]
pub struct AssetUploadEventArgs {
upload: AssetUpload,
}
impl AssetUploadEventArgs {
pub fn new(upload: AssetUpload) -> Result<Self, Error> {
Ok(Self { upload })
}
pub fn upload(&self) -> AssetUpload {
self.upload.clone()
}
}
#[derive(Clone, Debug)]
pub struct ImageReceiveProgressEventArgs {
image_id: UUID,
received: i32,
total: i32,
}
impl ImageReceiveProgressEventArgs {
pub fn new(image_id: UUID, received: i32, total: i32) -> Result<Self, Error> {
if received < 0 || total < 0 || received > total {
return Err(Error::Argument);
}
Ok(Self {
image_id,
received,
total,
})
}
pub const fn image_id(&self) -> UUID {
self.image_id
}
pub const fn received(&self) -> i32 {
self.received
}
pub const fn total(&self) -> i32 {
self.total
}
}
#[derive(Clone, Debug)]
pub struct InitiateDownloadEventArgs {
sim_filename: String,
viewer_filename: String,
}
impl InitiateDownloadEventArgs {
pub fn new(sim_filename: String, viewer_filename: String) -> Result<Self, Error> {
if sim_filename.as_bytes().contains(&0) || viewer_filename.as_bytes().contains(&0) {
return Err(Error::Argument);
}
Ok(Self {
sim_filename,
viewer_filename,
})
}
pub fn sim_file_name(&self) -> String {
self.sim_filename.clone()
}
pub fn viewer_file_name(&self) -> String {
self.viewer_filename.clone()
}
}
#[derive(Clone, Debug)]
pub struct XferDownload {
pub error: TransferError,
pub filename: String,
pub packet_num: u32,
pub v_file_id: UUID,
pub xfer_id: u64,
pub base: Transfer,
}
impl XferDownload {
pub fn new() -> Result<Self, Error> {
Ok(Self {
error: TransferError::None,
filename: String::new(),
packet_num: 0,
v_file_id: UUID::zero(),
xfer_id: 0,
base: Transfer::new()?,
})
}
}
#[derive(Clone, Debug)]
pub struct XferReceivedEventArgs {
xfer: XferDownload,
}
impl XferReceivedEventArgs {
pub fn new(xfer: XferDownload) -> Result<Self, Error> {
Ok(Self { xfer })
}
pub fn xfer(&self) -> XferDownload {
self.xfer.clone()
}
}

View File

@@ -600,14 +600,17 @@ async fn download_manager_retries_transient_but_not_permanent_statuses() {
} }
#[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn deduplicated_subscriber_cancellation_cancels_the_shared_download() { async fn deduplicated_subscriber_cancellation_is_independent() {
let attempts = Arc::new(AtomicUsize::new(0)); let attempts = Arc::new(AtomicUsize::new(0));
let handler_attempts = Arc::clone(&attempts); let handler_attempts = Arc::clone(&attempts);
let handler = HttpMessageHandler::new(move |_request, cancellation| { let release = Arc::new(tokio::sync::Notify::new());
let handler_release = Arc::clone(&release);
let handler = HttpMessageHandler::new(move |_request, _cancellation| {
handler_attempts.fetch_add(1, Ordering::AcqRel); handler_attempts.fetch_add(1, Ordering::AcqRel);
let release = Arc::clone(&handler_release);
async move { async move {
cancellation.cancelled().await; release.notified().await;
response(200, b"too late".to_vec()) response(200, b"shared".to_vec())
} }
}); });
let mut client = GridClient::new().expect("client"); let mut client = GridClient::new().expect("client");
@@ -645,8 +648,12 @@ async fn deduplicated_subscriber_cancellation_cancels_the_shared_download() {
tokio::task::yield_now().await; tokio::task::yield_now().await;
} }
cancellation.cancel(); cancellation.cancel();
assert_eq!(first.await.expect("first task"), Err(Error::Cancelled));
assert_eq!(second.await.expect("second task"), Err(Error::Cancelled)); assert_eq!(second.await.expect("second task"), Err(Error::Cancelled));
release.notify_waiters();
assert_eq!(
first.await.expect("first task").expect("first download").1,
b"shared"
);
assert_eq!(attempts.load(Ordering::Acquire), 1); assert_eq!(attempts.load(Ordering::Acquire), 1);
downloads.dispose().expect("dispose"); downloads.dispose().expect("dispose");
} }

49
docs/assets.md Normal file
View File

@@ -0,0 +1,49 @@
# Asset models, transfers, and cache
The native asset layer owns the client-facing asset boundary from typed bytes to
transport and persistence. `Asset` retains its UUID, asset type, temporary flag,
and mutable raw payload. Concrete animation, sound, script, calling-card,
landmark, settings, mesh, texture, wearable, and notecard models enforce a 64 MiB
input ceiling and reject malformed structured formats with typed errors. Mesh and
settings parsing use StructuredData. Texture encode/decode uses the imaging
crate's bounded JPEG 2000 adapter, while PCM conversion streams bounded planar
blocks through the cross-platform Ogg Vorbis encoder; neither API exposes codec
implementation types.
`AssetManager` is client-owned and shares one `DownloadManager` and `AssetCache`.
ViewerAsset, GetMesh, GetTexture, and server-bake requests check the cache before
network I/O. Equal HTTP URIs share one in-flight request and its progress sinks;
each awaiting subscriber retains an independent cancellation token, so cancelling
one waiter cannot cancel the shared request for the others. Capability discovery
waits for the seed request's bounded completion rather than racing it.
When ViewerAsset is unavailable, asset and authenticated inventory requests use
LLUDP `TransferRequest`, `TransferInfo`, and `TransferPacket`. The receiver filters
by transfer UUID, preserves priority and source type, reassembles out-of-order
packets, limits announced data to 64 MiB, and sends `TransferAbort` on cancellation
or timeout. Inventory transfers include the agent, session, owner, task, item,
asset, and type parameters. Legacy Xfer downloads confirm each packet, enforce the
same bound, and publish only completely assembled results.
Small and large uploads use the simulator's `AssetUploadRequest`/Xfer handshake,
including packet confirmation and progress events. Baked texture and inventory or
task material uploads use their two-stage capability contracts and require a
complete response containing the new asset UUID. Upload size, cancellation, and
timeout failures remove their pending correlation state.
The disk cache uses UUID-only filenames by default and confines custom filename
callbacks to the configured cache directory. Reads reject empty, oversized, or
non-regular files. Writes use uniquely named same-directory files, flush them,
and atomically rename them into place, making concurrent writers deterministic.
Pruning removes least-recently-accessed regular cache entries until usage is below 90% of
the configured maximum. Automatic pruning is activity-driven at the configured
millisecond interval, avoiding a permanent timer thread; cancellation is checked
between deletions. Clearing and pruning never inspect or delete files outside the
configured project/application cache directory.
Focused native tests in `asset_pipeline_semantics` cover real sound and texture
codec output, model typing, malformed mesh data, atomic cache replacement, cache
corruption, cached-image reconstruction, and pruning. The `caps_http`
deduplication case proves independent subscriber cancellation. Translated asset,
material, mesh, capability-upload, and cache compatibility cases provide the API
contract checks for this issue.

View File

@@ -17,6 +17,16 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "aotuv_lancer_vorbis_sys"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5bc4fd1a61860d2f1198b60bedd30910eaffa978f1ee6214dfb24ac70d589225"
dependencies = [
"cc",
"ogg_next_sys",
]
[[package]] [[package]]
name = "atomic-waker" name = "atomic-waker"
version = "1.1.2" version = "1.1.2"
@@ -285,6 +295,16 @@ version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
[[package]]
name = "errno"
version = "0.3.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.61.2",
]
[[package]] [[package]]
name = "find-msvc-tools" name = "find-msvc-tools"
version = "0.1.10" version = "0.1.10"
@@ -725,13 +745,16 @@ dependencies = [
"os_info", "os_info",
"reqwest", "reqwest",
"roxmltree", "roxmltree",
"serde_json",
"tokio", "tokio",
"vorbis_rs",
] ]
[[package]] [[package]]
name = "libremetaverse-imaging" name = "libremetaverse-imaging"
version = "0.0.1" version = "0.0.1"
dependencies = [ dependencies = [
"libremetaverse-openjpeg",
"libremetaverse-types", "libremetaverse-types",
] ]
@@ -750,6 +773,14 @@ dependencies = [
"libremetaverse-types", "libremetaverse-types",
] ]
[[package]]
name = "libremetaverse-openjpeg"
version = "0.0.1"
dependencies = [
"pkg-config",
"vcpkg",
]
[[package]] [[package]]
name = "libremetaverse-prim-mesher" name = "libremetaverse-prim-mesher"
version = "0.0.1" version = "0.0.1"
@@ -1113,6 +1144,15 @@ dependencies = [
"objc2-foundation", "objc2-foundation",
] ]
[[package]]
name = "ogg_next_sys"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed2d7a48e247c2bb07e633aefb65a38648ea58c7eedd4e4408a5861721ab049b"
dependencies = [
"cc",
]
[[package]] [[package]]
name = "once_cell" name = "once_cell"
version = "1.21.4" version = "1.21.4"
@@ -1877,12 +1917,32 @@ dependencies = [
"wasm-bindgen", "wasm-bindgen",
] ]
[[package]]
name = "vcpkg"
version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
[[package]] [[package]]
name = "version_check" name = "version_check"
version = "0.9.5" version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "vorbis_rs"
version = "0.5.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "49c5da94d280f7a27e8c937e9b73df2da3e23a2583f48471fd8fb4c72f9c1933"
dependencies = [
"aotuv_lancer_vorbis_sys",
"errno",
"getrandom 0.4.3",
"ogg_next_sys",
"thiserror 2.0.20",
"tinyvec",
]
[[package]] [[package]]
name = "walkdir" name = "walkdir"
version = "2.5.0" version = "2.5.0"

View File

@@ -0,0 +1,62 @@
#!/usr/bin/env python3
"""Audit issue 64's native asset, transfer, cache, and download boundary."""
from __future__ import annotations
from pathlib import Path
import re
import generate_api_shims
ROOT = Path(__file__).resolve().parents[1]
SOURCES = tuple(
ROOT / "crates" / "libremetaverse" / "src" / name
for name in (
"asset_cache.rs", "asset_manager.rs", "asset_material.rs",
"asset_models.rs", "object_material.rs", "transfers.rs",
)
)
DOC = ROOT / "docs" / "assets.md"
NATIVE_TESTS = ROOT / "crates" / "libremetaverse" / "src" / "asset_pipeline_semantics.rs"
HTTP_TESTS = ROOT / "crates" / "libremetaverse" / "tests" / "caps_http.rs"
STUB_RE = re.compile(r"\b(?:not_implemented|unimplemented_api)\b|\b(?:todo|unimplemented)!\s*\(")
REQUIRED_TYPES = {
"T:LibreMetaverse.AssetCache", "T:LibreMetaverse.AssetManager",
"T:LibreMetaverse.AssetDownload", "T:LibreMetaverse.AssetUpload",
"T:LibreMetaverse.AssetUploadEventArgs", "T:LibreMetaverse.ImageDownload",
"T:LibreMetaverse.ImageReceiveProgressEventArgs",
"T:LibreMetaverse.InitiateDownloadEventArgs", "T:LibreMetaverse.Transfer",
"T:LibreMetaverse.XferDownload", "T:LibreMetaverse.XferReceivedEventArgs",
"T:LibreMetaverse.Assets.Asset", "T:LibreMetaverse.Assets.AssetMaterial",
"T:LibreMetaverse.Assets.AssetMesh", "T:LibreMetaverse.Assets.AssetTexture",
}
def require_markers(path: Path, markers: tuple[str, ...]) -> None:
text = path.read_text()
missing = [marker for marker in markers if marker not in text]
if missing:
raise SystemExit(f"{path.name}: audit evidence missing: " + ", ".join(missing))
def main() -> None:
native = set(generate_api_shims.NATIVE_TYPES) | set(generate_api_shims.NATIVE_DECLARATIONS)
missing = sorted(REQUIRED_TYPES - native)
if missing:
raise SystemExit("issue 64 native types missing: " + ", ".join(missing))
for source in SOURCES:
if STUB_RE.search(source.read_text()):
raise SystemExit(f"issue 64 owned Rust stubs remain in {source.name}")
require_markers(SOURCES[0], ("create_new(true)", "fs::rename", "CACHE_TARGET_PERCENT", "maybe_begin_prune"))
require_markers(SOURCES[1], ("ViewerAsset", "TransferRequest", "TransferAbort", "UploadBakedTexture", "AssetUploadRequest"))
require_markers(SOURCES[3], ("MAX_ASSET_BYTES", "VorbisEncoderBuilder", "J2kCodec", "serialize_llsd_binary"))
require_markers(NATIVE_TESTS, ("sound_and_texture_codecs_produce_real_valid_payloads", "cache_writes_are_atomic_and_corruption_is_typed", "cache_pruning_reclaims_old_entries"))
require_markers(HTTP_TESTS, ("deduplicated_subscriber_cancellation_is_independent", "attempts.load(Ordering::Acquire), 1"))
require_markers(DOC, ("64 MiB", "independent cancellation", "out-of-order", "atomically rename", "90%"))
print("issue 64 audit: native models/codecs, bounded LLUDP and capability transfers, independent dedup cancellation, atomic cache, documentation, and focused tests are present")
if __name__ == "__main__":
main()

View File

@@ -105,6 +105,36 @@ NATIVE_TYPES = {
"T:LibreMetaverse.ChatSessionMemberLeftEventArgs": "crate::agent_messages::ChatSessionMemberLeftEventArgs", "T:LibreMetaverse.ChatSessionMemberLeftEventArgs": "crate::agent_messages::ChatSessionMemberLeftEventArgs",
"T:LibreMetaverse.CameraConstraintEventArgs": "crate::agent_movement::CameraConstraintEventArgs", "T:LibreMetaverse.CameraConstraintEventArgs": "crate::agent_movement::CameraConstraintEventArgs",
"T:LibreMetaverse.Assets.AssetGesture": "crate::gesture::AssetGesture", "T:LibreMetaverse.Assets.AssetGesture": "crate::gesture::AssetGesture",
"T:LibreMetaverse.AssetCache": "crate::asset_cache::AssetCache",
"T:LibreMetaverse.AssetCache.ComputeAssetCacheFilenameDelegate": "crate::asset_cache::AssetCacheComputeAssetCacheFilenameDelegate",
"T:LibreMetaverse.AssetManager": "crate::asset_manager::AssetManager",
"T:LibreMetaverse.AssetDownload": "crate::transfers::AssetDownload",
"T:LibreMetaverse.ImageDownload": "crate::transfers::ImageDownload",
"T:LibreMetaverse.AssetUpload": "crate::transfers::AssetUpload",
"T:LibreMetaverse.AssetUploadEventArgs": "crate::transfers::AssetUploadEventArgs",
"T:LibreMetaverse.Transfer": "crate::transfers::Transfer",
"T:LibreMetaverse.ImageReceiveProgressEventArgs": "crate::transfers::ImageReceiveProgressEventArgs",
"T:LibreMetaverse.InitiateDownloadEventArgs": "crate::transfers::InitiateDownloadEventArgs",
"T:LibreMetaverse.XferDownload": "crate::transfers::XferDownload",
"T:LibreMetaverse.XferReceivedEventArgs": "crate::transfers::XferReceivedEventArgs",
"T:LibreMetaverse.Assets.Asset": "crate::asset_models::Asset",
"T:LibreMetaverse.Assets.AssetAnimation": "crate::asset_models::AssetAnimation",
"T:LibreMetaverse.Assets.AssetBodypart": "crate::asset_models::AssetBodypart",
"T:LibreMetaverse.Assets.AssetCallingCard": "crate::asset_models::AssetCallingCard",
"T:LibreMetaverse.Assets.AssetClothing": "crate::asset_models::AssetClothing",
"T:LibreMetaverse.Assets.AssetLandmark": "crate::asset_models::AssetLandmark",
"T:LibreMetaverse.Assets.AssetMaterial": "crate::asset_material::AssetMaterial",
"T:LibreMetaverse.Assets.AssetMesh": "crate::asset_models::AssetMesh",
"T:LibreMetaverse.Assets.AssetMutable": "crate::asset_models::AssetMutable",
"T:LibreMetaverse.Assets.AssetNotecard": "crate::asset_models::AssetNotecard",
"T:LibreMetaverse.Assets.AssetScriptBinary": "crate::asset_models::AssetScriptBinary",
"T:LibreMetaverse.Assets.AssetScriptText": "crate::asset_models::AssetScriptText",
"T:LibreMetaverse.Assets.AssetSettings": "crate::asset_models::AssetSettings",
"T:LibreMetaverse.Assets.AssetSound": "crate::asset_models::AssetSound",
"T:LibreMetaverse.Assets.AssetTexture": "crate::asset_models::AssetTexture",
"T:LibreMetaverse.Assets.AssetWearable": "crate::asset_models::AssetWearable",
"T:LibreMetaverse.Assets.GltfTextureTransform": "crate::asset_material::GltfTextureTransform",
"T:LibreMetaverse.GLTFMaterialUpdate": "crate::object_material::GLTFMaterialUpdate",
"T:LibreMetaverse.Assets.GestureStep": "crate::gesture::GestureStep", "T:LibreMetaverse.Assets.GestureStep": "crate::gesture::GestureStep",
"T:LibreMetaverse.Assets.GestureStepAnimation": "crate::gesture::GestureStepAnimation", "T:LibreMetaverse.Assets.GestureStepAnimation": "crate::gesture::GestureStepAnimation",
"T:LibreMetaverse.Assets.GestureStepChat": "crate::gesture::GestureStepChat", "T:LibreMetaverse.Assets.GestureStepChat": "crate::gesture::GestureStepChat",
@@ -265,6 +295,7 @@ NATIVE_DECLARATIONS = {
"T:LibreMetaverse.InventoryManager": "crate::inventory_manager::InventoryManager", "T:LibreMetaverse.InventoryManager": "crate::inventory_manager::InventoryManager",
"T:LibreMetaverse.InventoryAISClient": "crate::inventory_ais::InventoryAISClient", "T:LibreMetaverse.InventoryAISClient": "crate::inventory_ais::InventoryAISClient",
"T:LibreMetaverse.NetworkManager": "crate::network_manager::NetworkManager", "T:LibreMetaverse.NetworkManager": "crate::network_manager::NetworkManager",
"T:LibreMetaverse.ObjectManager": "crate::object_material::ObjectManager",
"T:LibreMetaverse.Settings": "crate::client_core::Settings", "T:LibreMetaverse.Settings": "crate::client_core::Settings",
"T:LibreMetaverse.Simulator": "crate::network_manager::Simulator", "T:LibreMetaverse.Simulator": "crate::network_manager::Simulator",
"T:LibreMetaverse.StructuredData.OSDParser": "crate::model::OSDParser", "T:LibreMetaverse.StructuredData.OSDParser": "crate::model::OSDParser",
@@ -335,6 +366,14 @@ NATIVE_MEMBER_BODIES = {
"self.native_http_caps_client()", "self.native_http_caps_client()",
"P:LibreMetaverse.GridClient.HttpCapsClient#set": "P:LibreMetaverse.GridClient.HttpCapsClient#set":
"self.native_set_http_caps_client(value)", "self.native_set_http_caps_client(value)",
"P:LibreMetaverse.GridClient.Assets":
"self.native_assets().unwrap_or_else(|_| panic!(\"failed to construct AssetManager\"))",
"P:LibreMetaverse.GridClient.Assets#set":
"self.native_set_assets(value)",
"P:LibreMetaverse.GridClient.Objects":
"crate::object_material::ObjectManager::native_new(Some(std::sync::Arc::new(self.clone()))).unwrap_or_else(|_| panic!(\"failed to construct ObjectManager\"))",
"P:LibreMetaverse.GridClient.Objects#set":
"{ let _ = value; }",
"P:LibreMetaverse.GridClient.Inventory": "P:LibreMetaverse.GridClient.Inventory":
"self.native_inventory().unwrap_or_else(|_| panic!(\"failed to construct InventoryManager\"))", "self.native_inventory().unwrap_or_else(|_| panic!(\"failed to construct InventoryManager\"))",
"P:LibreMetaverse.GridClient.Inventory#set": "P:LibreMetaverse.GridClient.Inventory#set":
@@ -375,6 +414,20 @@ NATIVE_MEMBER_BODIES = {
"crate::inventory_ais::InventoryAISClient::LIBRARY_CAP_NAME", "crate::inventory_ais::InventoryAISClient::LIBRARY_CAP_NAME",
"M:LibreMetaverse.InventoryAISClient.#ctor(LibreMetaverse.GridClient)": "M:LibreMetaverse.InventoryAISClient.#ctor(LibreMetaverse.GridClient)":
"crate::inventory_ais::InventoryAISClient::native_new(client)", "crate::inventory_ais::InventoryAISClient::native_new(client)",
"M:LibreMetaverse.ObjectManager.#ctor(LibreMetaverse.GridClient)":
"crate::object_material::ObjectManager::native_new(client)",
"M:LibreMetaverse.ObjectManager.SendMaterialUpdatesAsync(LibreMetaverse.Simulator,System.Collections.Generic.IEnumerable{LibreMetaverse.GLTFMaterialUpdate},System.Threading.CancellationToken)":
"self.native_send_material_updates(sim, updates, cancellation_token).await",
"M:LibreMetaverse.ObjectManager.SetMaterialOverrideAsync(LibreMetaverse.Simulator,LibreMetaverse.UUID,System.Int32,LibreMetaverse.Assets.AssetMaterial,System.Threading.CancellationToken)":
"self.native_set_material_override(sim, object_id, side, override_material, cancellation_token).await",
"M:LibreMetaverse.ObjectManager.ClearMaterialOverrideAsync(LibreMetaverse.Simulator,LibreMetaverse.UUID,System.Int32,System.Threading.CancellationToken)":
"self.native_clear_material_override(sim, object_id, side, cancellation_token).await",
"M:LibreMetaverse.ObjectManager.ApplyMaterialAsync(LibreMetaverse.Simulator,LibreMetaverse.UUID,System.Int32,LibreMetaverse.UUID,LibreMetaverse.Assets.AssetMaterial,System.Threading.CancellationToken)":
"self.native_apply_material(sim, object_id, side, material_asset_id, override_material, cancellation_token).await",
"M:LibreMetaverse.InventoryManager.RequestUpdateMaterialAgentInventoryAsync(LibreMetaverse.Assets.AssetMaterial,LibreMetaverse.UUID,System.Threading.CancellationToken,System.IProgress{LibreMetaverse.HttpCapsClient.ProgressReport})":
"self.native_update_material_inventory(material, material_item_id, None, cancellation_token, progress).await",
"M:LibreMetaverse.InventoryManager.RequestUpdateMaterialTaskInventoryAsync(LibreMetaverse.Assets.AssetMaterial,LibreMetaverse.UUID,LibreMetaverse.UUID,System.Threading.CancellationToken,System.IProgress{LibreMetaverse.HttpCapsClient.ProgressReport})":
"self.native_update_material_inventory(material, material_item_id, Some(task_id), cancellation_token, progress).await",
"P:LibreMetaverse.InventoryAISClient.IsAvailable": "self.native_is_available()", "P:LibreMetaverse.InventoryAISClient.IsAvailable": "self.native_is_available()",
"M:LibreMetaverse.InventoryAISClient.CreateInventoryAsync(LibreMetaverse.UUID,LibreMetaverse.StructuredData.OSD,System.Boolean,System.Threading.CancellationToken)": "M:LibreMetaverse.InventoryAISClient.CreateInventoryAsync(LibreMetaverse.UUID,LibreMetaverse.StructuredData.OSD,System.Boolean,System.Threading.CancellationToken)":
"self.native_create_inventory(parent_uuid, new_inventory, create_link, cancellation_token).await", "self.native_create_inventory(parent_uuid, new_inventory, create_link, cancellation_token).await",

View File

@@ -985,6 +985,10 @@ def validate_generated_shims() -> None:
"crate::agent_manager::", "crate::agent_manager::",
"crate::agent_messages::", "crate::agent_messages::",
"crate::agent_movement::", "crate::agent_movement::",
"crate::asset_cache::",
"crate::asset_manager::",
"crate::asset_material::",
"crate::asset_models::",
"crate::byte_order::", "crate::byte_order::",
"crate::attention_catalog::", "crate::attention_catalog::",
"crate::binary::", "crate::binary::",
@@ -998,6 +1002,7 @@ def validate_generated_shims() -> None:
"crate::inventory_manager::", "crate::inventory_manager::",
"crate::message_codec::", "crate::message_codec::",
"crate::message_decoder::", "crate::message_decoder::",
"crate::object_material::",
"crate::notation::", "crate::notation::",
"crate::packet_catalog::", "crate::packet_catalog::",
"crate::packet_wire::", "crate::packet_wire::",
@@ -1006,6 +1011,7 @@ def validate_generated_shims() -> None:
"crate::protobuf::", "crate::protobuf::",
"crate::skeleton::", "crate::skeleton::",
"crate::visual_catalog::", "crate::visual_catalog::",
"crate::transfers::",
"crate::xml_codec::", "crate::xml_codec::",
"stored_visual_params", "stored_visual_params",
"self.native_", "self.native_",