diff --git a/Cargo.lock b/Cargo.lock
index b7ab250..4d37b58 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -852,6 +852,7 @@ dependencies = [
name = "libremetaverse"
version = "0.0.1"
dependencies = [
+ "base64",
"bcdec_rs",
"flate2",
"futures-channel",
@@ -864,6 +865,7 @@ dependencies = [
"reqwest",
"roxmltree",
"serde_json",
+ "tar",
"tokio",
"vorbis_rs",
]
diff --git a/README.md b/README.md
index 9c3d149..cde26e5 100644
--- a/README.md
+++ b/README.md
@@ -365,6 +365,11 @@ deduplicated HTTP subscribers, and atomic size-pruned disk caching. The ownershi
correlation, and corruption contracts are documented in
[`docs/assets.md`](docs/assets.md).
+Deterministic OAR/tar IO, bounded GLTF/GLB and Collada conversion, material
+resolution, and opt-in mesh pricing/upload now use the same native asset and
+capability layers. Their archive safety, offline determinism, and live-upload
+boundaries are also documented in [`docs/assets.md`](docs/assets.md).
+
The client-owned `InventoryManager` now implements the packet-level create,
update, move, copy, remove, fetch, search, give, rez/derez, script-state, and
task-inventory workflows. Callback IDs and concurrent item/task waiters are
diff --git a/api/SHIM-COVERAGE.md b/api/SHIM-COVERAGE.md
index d80e8b8..7c24ffc 100644
--- a/api/SHIM-COVERAGE.md
+++ b/api/SHIM-COVERAGE.md
@@ -4,7 +4,7 @@ Generated by `python3 tools/generate_api_shims.py`; do not edit by hand.
| Assembly | Types | Members | Status |
|---|---:|---:|---|
-| `LibreMetaverse` | 2,711 | 27,281 | native implementation: 369 types / 16,774 members; remaining surface is callable failure-only shims |
+| `LibreMetaverse` | 2,711 | 27,281 | native implementation: 401 types / 17,023 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.Skia` | 1 | 3 | native implementation: 1 type / 3 members; no generated shims remain |
| `LibreMetaverse.LslTools` | 164 | 768 | callable failure-only shim |
diff --git a/crates/libremetaverse/Cargo.toml b/crates/libremetaverse/Cargo.toml
index c7aa540..0ceac71 100644
--- a/crates/libremetaverse/Cargo.toml
+++ b/crates/libremetaverse/Cargo.toml
@@ -13,6 +13,7 @@ dds-bc67 = ["dep:bcdec_rs"]
jpeg2000 = ["libremetaverse-imaging/jpeg2000"]
[dependencies]
+base64 = "0.22.1"
bcdec_rs = { version = "0.2.0", optional = true }
flate2 = "1.1.2"
futures-channel = "0.3.31"
@@ -25,6 +26,7 @@ os_info = { version = "3.15.0", default-features = false }
reqwest = { version = "0.13.4", default-features = false, features = ["rustls", "stream"] }
roxmltree = "0.21.1"
serde_json = "1.0.143"
+tar = "0.4.46"
tokio = { version = "1.47.1", features = ["macros", "net", "rt", "sync", "time"] }
vorbis_rs = "0.5.5"
diff --git a/crates/libremetaverse/src/asset_archive.rs b/crates/libremetaverse/src/asset_archive.rs
new file mode 100644
index 0000000..486e989
--- /dev/null
+++ b/crates/libremetaverse/src/asset_archive.rs
@@ -0,0 +1,1261 @@
+//! Deterministic and bounded tar/OAR archive handling.
+
+// Public signatures mirror the fixed C# compatibility surface. Several own
+// inputs intentionally and RegionSettings intentionally exposes boolean fields.
+#![allow(clippy::pedantic)]
+
+use std::fs::{self, File};
+use std::io::{Read, Seek, SeekFrom, Write};
+use std::path::{Component, Path, PathBuf};
+use std::sync::Mutex;
+
+use flate2::{Compression, GzBuilder, read::GzDecoder};
+use libremetaverse_types::compat::ReadWrite;
+use libremetaverse_types::{AssetType, Error, UUID};
+
+use crate::assets::TarArchiveReaderTarEntryType;
+
+const BLOCK: usize = 512;
+const MAX_ENTRY_BYTES: usize = 64 * 1024 * 1024;
+const MAX_TOTAL_BYTES: usize = 1024 * 1024 * 1024;
+const MAX_ENTRIES: usize = 100_000;
+const MAX_PATH_BYTES: usize = 4096;
+const MAX_PATH_DEPTH: usize = 64;
+const ARCHIVE_XML: &str = "\n";
+const OAR_DIRECTORIES: [&str; 5] = ["assets", "objects", "terrains", "landdata", "settings"];
+
+type AsyncCallback = Box;
+type TerrainCallback = Box>, i64, i64) + Send + Sync>;
+
+fn archive_error(context: &'static str) -> Error {
+ Error::Parse {
+ position: 0,
+ context,
+ }
+}
+
+fn io_error(_: std::io::Error) -> Error {
+ Error::InvalidOperation
+}
+
+fn validate_archive_path(path: &str) -> Result<(), Error> {
+ if path.is_empty()
+ || path.len() > MAX_PATH_BYTES
+ || path.contains('\0')
+ || path.contains('\\')
+ || path.starts_with('/')
+ {
+ return Err(archive_error("unsafe archive path"));
+ }
+ let mut depth = 0usize;
+ for component in Path::new(path).components() {
+ match component {
+ Component::Normal(_) => depth += 1,
+ Component::CurDir => {}
+ Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
+ return Err(archive_error("archive path traversal"));
+ }
+ }
+ }
+ if depth == 0 || depth > MAX_PATH_DEPTH {
+ return Err(archive_error("archive path depth limit"));
+ }
+ Ok(())
+}
+
+fn octal(bytes: &[u8]) -> Result {
+ let text = std::str::from_utf8(bytes)
+ .map_err(|_| archive_error("tar octal UTF-8"))?
+ .trim_matches(['\0', ' ']);
+ if text.is_empty() {
+ return Ok(0);
+ }
+ if !text.bytes().all(|byte| matches!(byte, b'0'..=b'7')) {
+ return Err(archive_error("tar octal field"));
+ }
+ u64::from_str_radix(text, 8).map_err(|_| archive_error("tar octal overflow"))
+}
+
+fn padded_octal(value: u64, width: usize) -> Result, Error> {
+ if width < 2 {
+ return Err(Error::Argument);
+ }
+ let digits = format!("{value:o}");
+ if digits.len() + 1 > width {
+ return Err(Error::Argument);
+ }
+ let mut result = vec![b'0'; width];
+ let start = width - 1 - digits.len();
+ result[start..width - 1].copy_from_slice(digits.as_bytes());
+ result[width - 1] = 0;
+ Ok(result)
+}
+
+fn split_ustar_path(path: &str) -> Result<(&str, &str), Error> {
+ validate_archive_path(path)?;
+ if path.len() <= 100 {
+ return Ok(("", path));
+ }
+ for (index, _) in path.match_indices('/').rev() {
+ let prefix = &path[..index];
+ let name = &path[index + 1..];
+ if prefix.len() <= 155 && name.len() <= 100 && !name.is_empty() {
+ return Ok((prefix, name));
+ }
+ }
+ Err(archive_error("tar path exceeds ustar limits"))
+}
+
+fn write_header(
+ writer: &mut dyn Write,
+ path: &str,
+ size: usize,
+ entry_type: u8,
+) -> Result<(), Error> {
+ if size > MAX_ENTRY_BYTES {
+ return Err(archive_error("tar entry size limit"));
+ }
+ let (prefix, name) = split_ustar_path(path)?;
+ let mut header = [0u8; BLOCK];
+ header[..name.len()].copy_from_slice(name.as_bytes());
+ header[100..108].copy_from_slice(&padded_octal(
+ if entry_type == b'5' { 0o755 } else { 0o644 },
+ 8,
+ )?);
+ header[108..116].copy_from_slice(&padded_octal(0, 8)?);
+ header[116..124].copy_from_slice(&padded_octal(0, 8)?);
+ header[124..136].copy_from_slice(&padded_octal(size as u64, 12)?);
+ header[136..148].copy_from_slice(&padded_octal(0, 12)?);
+ header[148..156].fill(b' ');
+ header[156] = entry_type;
+ header[257..263].copy_from_slice(b"ustar\0");
+ header[263..265].copy_from_slice(b"00");
+ header[345..345 + prefix.len()].copy_from_slice(prefix.as_bytes());
+ let checksum = header.iter().map(|byte| u64::from(*byte)).sum();
+ header[148..156].copy_from_slice(&padded_octal(checksum, 8)?);
+ writer.write_all(&header).map_err(io_error)
+}
+
+fn write_entry(
+ writer: &mut dyn Write,
+ path: &str,
+ data: &[u8],
+ entry_type: u8,
+) -> Result<(), Error> {
+ write_header(writer, path, data.len(), entry_type)?;
+ writer.write_all(data).map_err(io_error)?;
+ let padding = (BLOCK - data.len() % BLOCK) % BLOCK;
+ if padding != 0 {
+ writer.write_all(&vec![0; padding]).map_err(io_error)?;
+ }
+ Ok(())
+}
+
+struct ReaderState {
+ stream: Box,
+ entries: usize,
+ total_bytes: usize,
+ finished: bool,
+}
+
+pub struct TarArchiveReader {
+ state: Mutex,
+}
+
+impl std::fmt::Debug for TarArchiveReader {
+ fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ formatter
+ .debug_struct("TarArchiveReader")
+ .finish_non_exhaustive()
+ }
+}
+
+impl TarArchiveReader {
+ pub fn new(stream: Box) -> Result {
+ Ok(Self {
+ state: Mutex::new(ReaderState {
+ stream,
+ entries: 0,
+ total_bytes: 0,
+ finished: false,
+ }),
+ })
+ }
+
+ pub fn close(&self) -> Result<(), Error> {
+ self.state
+ .lock()
+ .unwrap_or_else(std::sync::PoisonError::into_inner)
+ .finished = true;
+ Ok(())
+ }
+
+ pub fn convert_octal_bytes_to_decimal(
+ bytes: Vec,
+ start_index: i32,
+ count: i32,
+ ) -> Result {
+ let start = usize::try_from(start_index).map_err(|_| Error::IndexOutOfRange)?;
+ let count = usize::try_from(count).map_err(|_| Error::IndexOutOfRange)?;
+ let end = start.checked_add(count).ok_or(Error::IndexOutOfRange)?;
+ let value = octal(bytes.get(start..end).ok_or(Error::IndexOutOfRange)?)?;
+ i32::try_from(value).map_err(|_| Error::IndexOutOfRange)
+ }
+
+ pub fn read_entry(
+ &self,
+ file_path: &mut String,
+ entry_type: &mut TarArchiveReaderTarEntryType,
+ ) -> Result