diff --git a/.gitea/workflows/codegen.yml b/.gitea/workflows/codegen.yml index a44f64c..babf79a 100644 --- a/.gitea/workflows/codegen.yml +++ b/.gitea/workflows/codegen.yml @@ -19,6 +19,11 @@ on: - "tools/check_milestone_09_issue_61.py" - "tools/check_milestone_09_issue_62.py" - "tools/check_milestone_09_issue_63.py" + - "tools/check_milestone_09_issue_64.py" + - "tools/check_milestone_09_issue_65.py" + - "tools/check_milestone_09_issue_66.py" + - "tools/check_milestone_09.py" + - "tools/test_milestone_09.py" - "crates/libremetaverse/src/agent_movement.rs" - "crates/libremetaverse/src/inventory.rs" - "crates/libremetaverse/src/inventory_manager.rs" @@ -61,6 +66,11 @@ on: - "tools/check_milestone_09_issue_61.py" - "tools/check_milestone_09_issue_62.py" - "tools/check_milestone_09_issue_63.py" + - "tools/check_milestone_09_issue_64.py" + - "tools/check_milestone_09_issue_65.py" + - "tools/check_milestone_09_issue_66.py" + - "tools/check_milestone_09.py" + - "tools/test_milestone_09.py" - "crates/libremetaverse/src/agent_movement.rs" - "crates/libremetaverse/src/inventory.rs" - "crates/libremetaverse/src/inventory_manager.rs" @@ -119,7 +129,10 @@ jobs: python3 tools/check_milestone_09_issue_61.py python3 tools/check_milestone_09_issue_62.py python3 tools/check_milestone_09_issue_63.py + python3 tools/check_milestone_09_issue_64.py + python3 tools/check_milestone_09_issue_65.py python3 tools/check_milestone_09_issue_66.py + python3 tools/check_milestone_09.py cargo check --manifest-path tests/api-compile/Cargo.toml --locked - name: Test generated wire codecs and translated parity cases run: | diff --git a/.gitea/workflows/rust-workspace.yml b/.gitea/workflows/rust-workspace.yml index 410fc69..151399f 100644 --- a/.gitea/workflows/rust-workspace.yml +++ b/.gitea/workflows/rust-workspace.yml @@ -5,6 +5,8 @@ on: paths: - ".gitea/workflows/rust-workspace.yml" - "tools/install_openjpeg_2_5_4.sh" + - "tools/check_milestone_09.py" + - "tools/test_milestone_09.py" - "**/*.rs" - "**/Cargo.toml" - "Cargo.lock" @@ -12,6 +14,8 @@ on: paths: - ".gitea/workflows/rust-workspace.yml" - "tools/install_openjpeg_2_5_4.sh" + - "tools/check_milestone_09.py" + - "tools/test_milestone_09.py" - "**/*.rs" - "**/Cargo.toml" - "Cargo.lock" @@ -43,5 +47,11 @@ jobs: python3 tools/check_milestone_09_issue_61.py python3 tools/check_milestone_09_issue_62.py python3 tools/check_milestone_09_issue_63.py + python3 tools/check_milestone_09_issue_64.py + python3 tools/check_milestone_09_issue_65.py + python3 tools/check_milestone_09_issue_66.py + python3 tools/check_milestone_09.py + - name: Test the complete native world milestone + run: python3 tools/test_milestone_09.py - name: Compile every workspace target with bounded memory run: cargo check --workspace --all-targets --locked -j 1 diff --git a/README.md b/README.md index cde26e5..d3e219f 100644 --- a/README.md +++ b/README.md @@ -388,6 +388,15 @@ one atomic store transaction; cancellation and malformed responses cannot partially mutate the cache. Focused compatibility and injected-HTTP tests cover link correction, endpoint recording, error isolation, and metadata updates. +The milestone closes with one deterministic cross-manager fake-grid scenario +and a reproducible owned-test runner. The scenario covers authenticated agent +state, movement and handoff, shared inventory/AIS state, assets and appearance, +avatar/animesh and object entry points, land and discovery caches, social, +estate, experience and marketplace construction, ordered events, redacted +diagnostics, clean logout, and root cancellation. The complete audit and test +boundary is documented in +[`docs/world-milestone-gate.md`](docs/world-milestone-gate.md). + Native appearance state now tracks layered wearables and attachment points, sends the inventory-backed wearable and attachment LLUDP messages, and exposes worn queries by slot, item, and point. The client-owned Current Outfit Folder diff --git a/api/SHIM-COVERAGE.md b/api/SHIM-COVERAGE.md index 7c24ffc..f3bb01e 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: 401 types / 17,023 members; remaining surface is callable failure-only shims | +| `LibreMetaverse` | 2,711 | 27,281 | native implementation: 401 types / 17,025 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/src/client_core.rs b/crates/libremetaverse/src/client_core.rs index 8edd2d4..f5dac17 100644 --- a/crates/libremetaverse/src/client_core.rs +++ b/crates/libremetaverse/src/client_core.rs @@ -129,6 +129,7 @@ struct ClientRuntime { interest_list_manager: Mutex>>, friends_manager: Mutex>>, group_manager: Mutex>>, + marketplace_manager: Mutex>, appearance_manager: Mutex>>, avatar_manager: Mutex>>, animesh_manager: Mutex>>, @@ -139,6 +140,12 @@ struct ClientRuntime { shutdown_wait: Mutex<()>, } +fn clear_cached(slot: &Mutex>) { + slot.lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .take(); +} + impl ClientRuntime { fn new( services: Vec>, @@ -174,6 +181,7 @@ impl ClientRuntime { interest_list_manager: Mutex::new(None), friends_manager: Mutex::new(None), group_manager: Mutex::new(None), + marketplace_manager: Mutex::new(None), appearance_manager: Mutex::new(None), avatar_manager: Mutex::new(None), animesh_manager: Mutex::new(None), @@ -248,42 +256,25 @@ impl ClientRuntime { } } services.clear(); - self.agent_throttle_sender - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .take(); - self.inventory_manager - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .take(); - self.inventory_ais_client - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .take(); - self.object_manager - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .take(); - self.grid_manager - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .take(); - self.directory_manager - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .take(); - self.interest_list_manager - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .take(); - self.friends_manager - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .take(); - self.group_manager - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .take(); + clear_cached(&self.agent_throttle_sender); + clear_cached(&self.inventory_manager); + clear_cached(&self.inventory_ais_client); + clear_cached(&self.asset_manager); + clear_cached(&self.appearance_manager); + clear_cached(&self.avatar_manager); + clear_cached(&self.animesh_manager); + clear_cached(&self.object_manager); + clear_cached(&self.environment_manager); + clear_cached(&self.estate_tools); + clear_cached(&self.terrain_manager); + clear_cached(&self.sound_manager); + clear_cached(&self.parcel_manager); + clear_cached(&self.grid_manager); + clear_cached(&self.directory_manager); + clear_cached(&self.interest_list_manager); + clear_cached(&self.friends_manager); + clear_cached(&self.group_manager); + clear_cached(&self.marketplace_manager); self.http_caps_client .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) @@ -1106,6 +1097,32 @@ impl GridClient { .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(value.inner); } + pub(crate) fn native_marketplace(&self) -> crate::marketplace_runtime::MarketplaceManager { + let mut cached = self + .runtime + .marketplace_manager + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if let Some(manager) = cached.as_ref() { + return manager.clone(); + } + let manager = crate::marketplace_runtime::MarketplaceManager::native_new_client_owned(self); + *cached = Some(manager.clone()); + manager + } + + pub(crate) fn native_set_marketplace( + &self, + value: crate::marketplace_runtime::MarketplaceManager, + ) { + value.native_bind_to_client(self); + *self + .runtime + .marketplace_manager + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(value); + } + pub(crate) fn native_set_caps_rate_limiter( &mut self, value: crate::caps_http::CapsRateLimiter, diff --git a/crates/libremetaverse/src/generated.rs b/crates/libremetaverse/src/generated.rs index 97df278..1084e6b 100644 --- a/crates/libremetaverse/src/generated.rs +++ b/crates/libremetaverse/src/generated.rs @@ -6730,11 +6730,13 @@ impl GridClient { } /// C# member: `P:LibreMetaverse.GridClient.Marketplace`. pub fn marketplace(&self) -> libremetaverse::marketplace::MarketplaceManager { - libremetaverse_types::unimplemented_api!("P:LibreMetaverse.GridClient.Marketplace") + /* native client-core implementation */ + self.native_marketplace() } /// Setter for C# member: `P:LibreMetaverse.GridClient.Marketplace`. pub fn set_marketplace(&mut self, value: libremetaverse::marketplace::MarketplaceManager) { - libremetaverse_types::unimplemented_api!("P:LibreMetaverse.GridClient.Marketplace") + /* native client-core implementation */ + self.native_set_marketplace(value) } /// C# member: `P:LibreMetaverse.GridClient.Network`. pub fn network(&self) -> libremetaverse::NetworkManager { @@ -9930,9 +9932,14 @@ impl InventoryManager { >, >, ) -> Result>, crate::Error> { - libremetaverse_types::not_implemented( - "M:LibreMetaverse.InventoryManager.UploadThumbnailAsync(LibreMetaverse.UUID,LibreMetaverse.UUID,System.Byte[],System.Threading.CancellationToken,System.IProgress{LibreMetaverse.HttpCapsClient.ProgressReport})", + self.native_upload_thumbnail( + inventory_id, + task_id, + j2c_image_data, + cancellation_token, + progress, ) + .await } /// C# member: `M:LibreMetaverse.InventoryManager.WaitForNextInventoryOfferAsync(System.Threading.CancellationToken)`. pub async fn wait_for_next_inventory_offer( @@ -17640,6 +17647,7 @@ pub mod assets { pub const TERRAINS_PATH: &'static str = "terrains/"; /// C# member: `M:LibreMetaverse.Assets.ArchiveConstants.#ctor`. pub fn new() -> Result { + /* native zero-sized constants namespace */ Ok(Self) } } diff --git a/crates/libremetaverse/src/inventory_ais.rs b/crates/libremetaverse/src/inventory_ais.rs index 3a720e6..9568107 100644 --- a/crates/libremetaverse/src/inventory_ais.rs +++ b/crates/libremetaverse/src/inventory_ais.rs @@ -11,6 +11,7 @@ use libremetaverse_types::compat::{CancellationToken, HttpResponse, Uri}; use libremetaverse_types::{AssetType, UUID}; use std::collections::{BTreeMap, HashMap, HashSet}; use std::sync::Arc; +use std::time::{Duration, Instant}; const MAX_FOLDER_DEPTH_REQUEST: i32 = 50; const MAX_AIS_OBJECTS: usize = 100_000; @@ -110,6 +111,31 @@ impl InventoryAISClient { caps.capability_uri(name.to_owned()) } + fn wait_for_capability( + &self, + name: &str, + cancellation: &CancellationToken, + ) -> Result, Error> { + let client = self.client()?; + let Some(simulator) = client.native_network()?.current_sim() else { + return Ok(None); + }; + let Some(caps) = simulator.native_caps() else { + return Ok(None); + }; + let deadline = Instant::now() + Duration::from_secs(2); + loop { + cancellation.throw_if_cancellation_requested()?; + if let Some(uri) = caps.capability_uri(name.to_owned())? { + return Ok(Some(uri)); + } + if caps.seed_request_finished() || Instant::now() >= deadline { + return Ok(None); + } + std::thread::sleep(Duration::from_millis(1)); + } + } + pub(crate) fn native_is_available(&self) -> bool { self.capability(Self::INVENTORY_CAP_NAME) .ok() @@ -147,7 +173,7 @@ impl InventoryAISClient { ) -> Result)>, Error> { let token = Self::token(cancellation_token); token.throw_if_cancellation_requested()?; - let Some(capability) = self.capability(cap_name)? else { + let Some(capability) = self.wait_for_capability(cap_name, &token)? else { return Ok(None); }; let bytes = match payload { @@ -359,18 +385,27 @@ impl InventoryAISClient { new_inventory: OSD, cancellation_token: Option, ) -> Result, Error> { - let path = format!("category/{parent_uuid}?tid={}", Self::tid()?); - let (_, _, _, links) = self - .mutation( + let Some((response, bytes)) = self + .request( Self::INVENTORY_CAP_NAME, "POST", - path, + format!("category/{parent_uuid}"), BTreeMap::new(), Some(new_inventory), - false, cancellation_token, ) - .await?; + .await? + else { + return Ok(Vec::new()); + }; + if !Self::status_ok(&response) { + return Err(Error::HttpRequest); + } + let Some(map) = Self::decode_map(bytes)? else { + return Ok(Vec::new()); + }; + let (folders, items, links) = self.parse_all_map(&map)?; + self.reconcile(&map, &folders, &items, &links)?; Ok(links) } diff --git a/crates/libremetaverse/src/inventory_manager.rs b/crates/libremetaverse/src/inventory_manager.rs index c38d038..fbdaf87 100644 --- a/crates/libremetaverse/src/inventory_manager.rs +++ b/crates/libremetaverse/src/inventory_manager.rs @@ -784,9 +784,10 @@ impl InventoryManager { pub(crate) fn native_new(client: Option>) -> Result { let client = client.ok_or(Error::ArgumentNull)?; + let initial_store = Inventory::new_with_grid_client(Arc::clone(&client))?; let inner = Arc::new(InventoryManagerInner { client: client.native_weak_handle(), - store: RwLock::new(None), + store: RwLock::new(Some(initial_store)), disposed: AtomicBool::new(false), callback_position: AtomicU32::new(0), request_position: AtomicU64::new(0), @@ -1914,10 +1915,9 @@ impl InventoryManager { .await? .ok_or(Error::InvalidOperation)?; let uri = Uri(format!( - "{}/category/{}?tid={}", + "{}/category/{}", cap.0.trim_end_matches('/'), - folder_id, - UUID::random()? + folder_id )); let payload = OSD::Map(HashMap::from([( "links".to_owned(), @@ -4397,6 +4397,103 @@ fn parse_task_inventory_values( } impl InventoryManager { + pub(crate) async fn native_upload_thumbnail( + &self, + inventory_id: UUID, + task_id: UUID, + j2c_image_data: Vec, + cancellation_token: Option, + progress: Option< + Box>, + >, + ) -> Result>, Error> { + if inventory_id == UUID::zero() + || j2c_image_data.is_empty() + || j2c_image_data.len() > crate::asset_models::MAX_ASSET_BYTES + { + return Err(Error::Argument); + } + + let token = cancellation_token.unwrap_or_default(); + token.throw_if_cancellation_requested()?; + let client = self.client()?; + let Some(simulator) = client.native_network()?.current_sim() else { + return Ok(Some(None)); + }; + let Some(uri) = self + .wait_for_capability(&simulator, "InventoryThumbnailUpload", &token) + .await? + else { + return Ok(Some(None)); + }; + + let inventory_store = self.native_store(); + let known_local_folder = task_id == UUID::zero() + && inventory_store + .as_ref() + .and_then(|store| { + store + .get_value_or_default_with_uuid_a7c63fbe::(inventory_id) + .ok() + .flatten() + }) + .is_some(); + let metadata = if known_local_folder { + HashMap::from([("category_id".to_owned(), OSD::UUID(inventory_id))]) + } else { + let mut metadata = HashMap::from([("item_id".to_owned(), OSD::UUID(inventory_id))]); + if task_id != UUID::zero() { + metadata.insert("task_id".to_owned(), OSD::UUID(task_id)); + } + metadata + }; + let payload = OSDParser::serialize_llsd_xml_bytes(OSD::Map(metadata))?; + let (metadata_response, metadata_body) = 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(&metadata_response.status_code) { + return Ok(Some(None)); + } + let metadata: serde_json::Value = + serde_json::from_slice(&metadata_body).map_err(|_| Error::Argument)?; + let Some(uploader) = metadata.get("uploader").and_then(serde_json::Value::as_str) else { + return Ok(Some(None)); + }; + + let (upload_response, upload_body) = client + .native_http_caps_client() + .post_with_uri_string_bytes_cancellation_token_i_progress( + Uri(uploader.to_owned()), + "image/x-j2c".into(), + j2c_image_data, + token, + progress, + ) + .await?; + if !(200..300).contains(&upload_response.status_code) { + return Ok(Some(None)); + } + let result: serde_json::Value = + serde_json::from_slice(&upload_body).map_err(|_| Error::Argument)?; + if result.get("state").and_then(serde_json::Value::as_str) != Some("complete") { + return Ok(Some(None)); + } + 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()?; + Ok(Some(asset_id)) + } + pub(crate) async fn native_update_material_inventory( &self, material: crate::assets::AssetMaterial, diff --git a/crates/libremetaverse/src/marketplace.rs b/crates/libremetaverse/src/marketplace.rs index 3047239..92f228a 100644 --- a/crates/libremetaverse/src/marketplace.rs +++ b/crates/libremetaverse/src/marketplace.rs @@ -5,6 +5,7 @@ #![allow(clippy::needless_pass_by_value)] use crate::agent_manager::EventRegistry; +use crate::client_core::ClientWeakHandle; use crate::marketplace::{ MarketplaceFolderRole, MarketplaceListingStatus, MarketplaceValidationFlags, }; @@ -291,8 +292,13 @@ impl MarketplaceListingsSyncedEventArgs { } } +enum MarketplaceClient { + Standalone(GridClient), + ClientOwned(ClientWeakHandle), +} + struct MarketplaceState { - client: GridClient, + client: RwLock, by_id: RwLock>, by_folder: RwLock>, errors: EventRegistry, @@ -306,15 +312,36 @@ pub struct MarketplaceManager(Arc); impl MarketplaceManager { pub fn new(client: GridClient) -> Result { - Ok(Self(Arc::new(MarketplaceState { - client, + Ok(Self::with_client(MarketplaceClient::Standalone(client))) + } + + fn with_client(client: MarketplaceClient) -> Self { + Self(Arc::new(MarketplaceState { + client: RwLock::new(client), by_id: RwLock::new(HashMap::new()), by_folder: RwLock::new(HashMap::new()), errors: EventRegistry::default(), changed: EventRegistry::default(), synced: EventRegistry::default(), live_mutations: AtomicBool::new(false), - }))) + })) + } + + pub(crate) fn native_new_client_owned(client: &GridClient) -> Self { + Self::with_client(MarketplaceClient::ClientOwned(client.native_weak_handle())) + } + + pub(crate) fn native_bind_to_client(&self, client: &GridClient) { + *write(&self.0.client) = MarketplaceClient::ClientOwned(client.native_weak_handle()); + } + + fn client(&self) -> Result { + match &*read(&self.0.client) { + MarketplaceClient::Standalone(client) => Ok(client.clone()), + MarketplaceClient::ClientOwned(client) => { + client.upgrade().ok_or(Error::InvalidOperation) + } + } } /// Explicitly enables mutation requests to non-test capability hosts. @@ -366,8 +393,7 @@ impl MarketplaceManager { async fn base_uri(&self, token: &CancellationToken) -> Result { token.throw_if_cancellation_requested()?; let simulator = self - .0 - .client + .client()? .native_network()? .native_current_sim() .ok_or(Error::InvalidOperation)?; @@ -412,7 +438,7 @@ impl MarketplaceManager { return Err(Error::InvalidOperation); } let uri = Uri(format!("{}/{}", base.0.trim_end_matches('/'), path)); - let client = self.0.client.native_http_caps_client(); + let client = self.client()?.native_http_caps_client(); match method { "GET" => client.get(uri, token, None).await, "POST" => { diff --git a/crates/libremetaverse/tests/network_manager.rs b/crates/libremetaverse/tests/network_manager.rs index f2c7fbf..26b68ff 100644 --- a/crates/libremetaverse/tests/network_manager.rs +++ b/crates/libremetaverse/tests/network_manager.rs @@ -10,15 +10,15 @@ use libremetaverse::packets::{ SimStatsPacket, SimStatsPacketStatBlock, StartPingCheckPacket, UseCircuitCodePacket, }; use libremetaverse::{ - CapsEventDictionary, CapsEventQueueCallback, GridClient, Helpers, HttpCapsClient, LoginState, - LoginStatus, NetworkManager, NetworkManagerDisconnectType, NetworkManagerLoginResponseCallback, - PacketEventDictionary, Simulator, + CapsEventDictionary, CapsEventQueueCallback, GridClient, Helpers, HttpCapsClient, + InventoryItem, LoginState, LoginStatus, NetworkManager, NetworkManagerDisconnectType, + NetworkManagerLoginResponseCallback, PacketEventDictionary, Simulator, }; use libremetaverse_structured_data::{OSD, OSDParser}; use libremetaverse_types::compat::{ CancellationTokenSource, EventHandler, HttpMessageHandler, HttpRequest, HttpResponse, Uri, }; -use libremetaverse_types::{UUID, Utils}; +use libremetaverse_types::{AssetType, Error, UUID, Utils}; use std::collections::{BTreeMap, HashMap}; use std::net::{IpAddr, Ipv4Addr, SocketAddr, UdpSocket}; use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; @@ -1843,12 +1843,17 @@ async fn offline_fake_grid_drives_the_complete_networking_lifecycle_without_task client.settings().agent_settings_mut().multiple_sims = true; client.set_http_caps_client(HttpCapsClient::new(handler).unwrap()); let manager = client.network(); + let manager_event_order = Arc::new(Mutex::new(Vec::new())); let (queue_sender, queue_receiver) = channel(); + let queue_order = Arc::clone(&manager_event_order); let _queue_guard = manager.subscribe_event_queue_running(Arc::new(move |args| { + queue_order.lock().unwrap().push("event_queue_running"); queue_sender.send(args.simulator().handle).unwrap(); })); let (logout_sender, logout_receiver) = channel(); + let logout_order = Arc::clone(&manager_event_order); let _logout_guard = manager.subscribe_logged_out(Arc::new(move |args| { + logout_order.lock().unwrap().push("logged_out"); logout_sender.send(args.inventory_items).unwrap(); })); @@ -1929,6 +1934,77 @@ async fn offline_fake_grid_drives_the_complete_networking_lifecycle_without_task assert_eq!(asset, b"fake-grid-asset"); assert_eq!(downloads.active_download_count(), 0); + // Cross-manager construction is deliberately performed only after the + // authenticated simulator and seed capabilities are available. Every + // manager below shares the same client runtime and must preserve its + // local store when accessed again through GridClient. + { + let agent = client.self_(); + assert_eq!( + agent.agent_id(), + UUID::new_with_string("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa".into()).unwrap() + ); + assert_eq!( + agent.session_id(), + UUID::new_with_string("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb".into()).unwrap() + ); + } + let inventory_id = + UUID::new_with_string("44444444-4444-4444-4444-444444444444".into()).unwrap(); + let inventory = client.inventory(); + let mut store = inventory.store().expect("authenticated inventory store"); + let mut item = InventoryItem::new_with_uuid(inventory_id).unwrap(); + item.base.set_name("fake-grid inventory marker".into()); + store.set_item(inventory_id, item.base); + assert!( + client + .inventory() + .store() + .unwrap() + .contains_with_uuid(inventory_id) + .unwrap() + ); + assert!(!client.ais_client().is_available()); + assert_eq!(client.appearance().last_update_received_cof_version(), -1); + assert!( + client + .assets() + .create_asset_wrapper(AssetType::Texture) + .is_ok() + ); + let _avatars = client.avatars(); + assert!(client.animesh().get_player(UUID::zero()).unwrap().is_none()); + client.objects().request_object(first.clone(), 1).unwrap(); + assert!(client.parcels().current_parcel().is_none()); + assert!(client.grid().regions_read_only().is_empty()); + assert!(client.friends().friend_list().is_empty()); + assert_eq!(client.groups().current_group_count(), -1); + assert!(client.marketplace().listings_by_id().is_empty()); + let _terrain = client.terrain(); + let _environment = client.environment(); + let _sound = client.sound(); + let _estate = client.estate(); + + let cancelled_search = CancellationTokenSource::new(); + cancelled_search.cancel(); + assert!(matches!( + client + .directory() + .search_people("Ada Lovelace".into(), Some(cancelled_search.token()),), + Err(Error::Cancelled) + )); + + let (marketplace_error_sender, marketplace_error_receiver) = channel(); + let marketplace_order = Arc::clone(&manager_event_order); + let _marketplace_error_guard = client.marketplace().subscribe_error(Arc::new(move |_| { + marketplace_order.lock().unwrap().push("marketplace_error"); + marketplace_error_sender.send(()).unwrap(); + })); + client.marketplace().fetch_listings(None).await.unwrap(); + marketplace_error_receiver + .recv_timeout(Duration::from_secs(1)) + .unwrap(); + second_server.commands.send(ServerCommand::Handoff).unwrap(); manager .connect_with_ip_end_point_u_int64_boolean_uri_u_int32_u_int32( @@ -2007,6 +2083,13 @@ async fn offline_fake_grid_drives_the_complete_networking_lifecycle_without_task assert!(downloads.is_disposed()); assert!(!downloads.dispatcher_running()); assert_eq!(downloads.active_download_count(), 0); + assert_eq!( + *manager_event_order.lock().unwrap(), + ["event_queue_running", "marketplace_error", "logged_out"] + ); + let client_cancellation = client.cancellation_token(); + client.dispose_with_method().unwrap(); + assert!(client_cancellation.is_cancellation_requested()); let uris = request_uris.lock().unwrap(); assert!(uris.iter().any(|uri| uri.contains("/login"))); assert!(uris.iter().any(|uri| uri.contains("/seed"))); diff --git a/docs/world-milestone-gate.md b/docs/world-milestone-gate.md new file mode 100644 index 0000000..3683d4d --- /dev/null +++ b/docs/world-milestone-gate.md @@ -0,0 +1,48 @@ +# World milestone integration gate + +Milestone 09 closes on native Rust behavior, not on catalog coverage alone. The +reproducible focused suite is `python3 tools/test_milestone_09.py`; its target +list contains the world-owned library tests, the complete fake-grid lifecycle, +and the translated compatibility targets for agent, inventory, assets, +appearance, avatars, objects, land, discovery, social, estate, experience, and +marketplace behavior. Live tests remain catalogued and reviewed but skip unless +their existing credentials and the explicit `RUN_LIVE_TESTS=1` opt-in are +supplied. The offline milestone runner always clears that opt-in. + +## Cross-manager fake grid + +The loopback scenario logs in through injected HTTP and UDP endpoints, waits +for seed capabilities and the event queue, then performs a simulator handoff. +While authenticated it constructs every client-owned world manager, verifies +agent/session identity, reconciles an inventory marker through the shared +store, checks AIS availability, creates an asset wrapper, observes appearance +and animesh state, requests an object, and inspects parcel, grid, friend, group, +and marketplace caches. A cancelled directory query proves that caller +cancellation wins before wire work. A marketplace capability error emitted +through a second `GridClient.Marketplace` lookup proves that the manager cache +and event registry are shared. + +Manager events are asserted in deterministic order: event-queue readiness, +the marketplace error, then logout. Capability downloads are bounded by the +existing HTTP and download-manager policies. Diagnostic formatting is checked +against login and capability secrets. + +## Cancellation and teardown + +Logout cancels the event queue, disconnects every simulator, leaves no pending +logout task, and stops the download dispatcher. Client disposal then cancels +the root token and releases every cached manager, including assets, +appearance, avatars, animesh, land, discovery, social, estate, and marketplace +state. Inventory has an empty shared store before login so offline fixtures and +programs can stage deterministic data; a successful login atomically replaces +it with the authenticated hierarchy. + +## Audit and program boundary + +`python3 tools/check_milestone_09.py` rejects stubs in all milestone-owned +sources, missing native type/member mappings, unreviewed related parity cases, +missing fake-grid evidence, and drift in the nine catalogued Rust program +targets. The program ports remain a later milestone, but their required world +APIs and target inventory are now auditable. Live actions remain opt-in: +uploads, marketplace changes, estate/parcel mutations, currency spending, and +operations affecting other users are never part of this offline gate. diff --git a/tests/compat/build.rs b/tests/compat/build.rs index 3de1b03..3de3d94 100644 --- a/tests/compat/build.rs +++ b/tests/compat/build.rs @@ -1,6 +1,7 @@ use std::path::Path; const KEYS: [&str; 3] = ["GRID_USER", "GRID_PASSWORD", "GRID_LOGIN_URL"]; +const LIVE_OPT_IN: &str = "RUN_LIVE_TESTS"; fn dotenv_has(path: &Path, key: &str) -> bool { std::fs::read_to_string(path).is_ok_and(|contents| { @@ -22,9 +23,19 @@ fn main() { for key in KEYS { println!("cargo:rerun-if-env-changed={key}"); } - if KEYS.iter().all(|key| { - std::env::var(key).is_ok_and(|value| !value.trim().is_empty()) || dotenv_has(&dotenv, key) - }) { + println!("cargo:rerun-if-env-changed={LIVE_OPT_IN}"); + let opted_in = std::env::var(LIVE_OPT_IN).is_ok_and(|value| { + matches!( + value.trim().to_ascii_lowercase().as_str(), + "1" | "true" | "yes" + ) + }); + if opted_in + && KEYS.iter().all(|key| { + std::env::var(key).is_ok_and(|value| !value.trim().is_empty()) + || dotenv_has(&dotenv, key) + }) + { println!("cargo:rustc-cfg=live_grid_credentials"); } } diff --git a/tests/compat/tests/asset_capability_semantics.rs b/tests/compat/tests/asset_capability_semantics.rs index 729b76c..c423e83 100644 --- a/tests/compat/tests/asset_capability_semantics.rs +++ b/tests/compat/tests/asset_capability_semantics.rs @@ -734,8 +734,10 @@ fn thumbnail_known_local_folder_sends_category_id() { let mut folder = InventoryFolder::new(folder_id).expect("InventoryFolder constructor"); folder.base.set_name("Test Folder".into()); folder.base.set_parent_uuid(UUID::zero()); - let mut store = client.inventory().store().expect("fixture inventory store"); - store.set_item(folder_id, folder.base); + let store = client.inventory().store().expect("fixture inventory store"); + store + .update_node_for(&folder) + .expect("install fixture inventory folder"); let result = block_on(client.inventory().upload_thumbnail( folder_id, UUID::zero(), diff --git a/tests/compat/tests/avatar_manager_shims.rs b/tests/compat/tests/avatar_manager_shims.rs index 3dcf473..d684101 100644 --- a/tests/compat/tests/avatar_manager_shims.rs +++ b/tests/compat/tests/avatar_manager_shims.rs @@ -95,19 +95,22 @@ fn avatar_facing_flows_have_typed_callable_signatures() { } #[test] -fn avatar_facing_constructors_fail_with_catalog_ids() { - assert_eq!( - member_id(AssetDownload::new()), - "M:LibreMetaverse.AssetDownload.#ctor" - ); - assert_eq!( - member_id(AssetUpload::new()), - "M:LibreMetaverse.AssetUpload.#ctor" - ); - assert_eq!( - member_id(AppearanceManagerWearableData::new()), - "M:LibreMetaverse.AppearanceManager.WearableData.#ctor" - ); +fn avatar_facing_constructors_preserve_native_defaults_and_catalog_errors() { + let download = AssetDownload::new().expect("AssetDownload constructor"); + assert_eq!(download.asset_id, UUID::zero()); + assert_eq!(download.next_packet, 0); + assert!(download.out_of_order_packets.read().unwrap().is_empty()); + + let upload = AssetUpload::new().expect("AssetUpload constructor"); + assert_eq!(upload.asset_id, UUID::zero()); + assert_eq!(upload.packet_num, 0); + assert_eq!(upload.xfer_id, 0); + + let wearable = AppearanceManagerWearableData::new().expect("WearableData constructor"); + assert_eq!(wearable.asset_id, UUID::zero()); + assert_eq!(wearable.item_id, UUID::zero()); + assert!(wearable.asset.is_none()); + assert_eq!( member_id(InventoryException::new_with_constructor()), "M:LibreMetaverse.InventoryException.#ctor" diff --git a/tools/check_milestone_09.py b/tools/check_milestone_09.py new file mode 100644 index 0000000..211de17 --- /dev/null +++ b/tools/check_milestone_09.py @@ -0,0 +1,245 @@ +#!/usr/bin/env python3 +"""Audit the complete native world-services milestone boundary.""" + +from __future__ import annotations + +import json +from pathlib import Path +import re + +import generate_api_shims + + +ROOT = Path(__file__).resolve().parents[1] +SOURCE_ROOT = ROOT / "crates" / "libremetaverse" / "src" +OWNED_SOURCES = ( + "agent_manager.rs", + "agent_messages.rs", + "agent_movement.rs", + "animation.rs", + "animesh.rs", + "animesh_skinning.rs", + "appearance_baker.rs", + "appearance_manager.rs", + "asset_archive.rs", + "asset_cache.rs", + "asset_manager.rs", + "asset_material.rs", + "asset_models.rs", + "avatar_manager.rs", + "avatar_rig.rs", + "baking_texture_provider.rs", + "current_outfit.rs", + "directory_manager.rs", + "environment_manager.rs", + "estate_tools.rs", + "friends_manager.rs", + "gesture.rs", + "gltf.rs", + "grid_manager.rs", + "group_manager.rs", + "initial_outfit.rs", + "interest_list.rs", + "inventory.rs", + "inventory_ais.rs", + "inventory_manager.rs", + "marketplace.rs", + "model_workflow.rs", + "object_manager.rs", + "object_material.rs", + "object_model.rs", + "parcel_manager.rs", + "slurl.rs", + "sound_manager.rs", + "terrain_codec.rs", + "terrain_manager.rs", + "transfers.rs", + "user_report.rs", +) +REQUIRED_NATIVE_TYPES = { + "T:LibreMetaverse.AgentManager", + "T:LibreMetaverse.AgentManager.AgentMovement", + "T:LibreMetaverse.Animesh.AnimeshManager", + "T:LibreMetaverse.AppearanceManager", + "T:LibreMetaverse.AssetManager", + "T:LibreMetaverse.AvatarManager", + "T:LibreMetaverse.DirectoryManager", + "T:LibreMetaverse.EnvironmentManager", + "T:LibreMetaverse.EstateTools", + "T:LibreMetaverse.FriendsManager", + "T:LibreMetaverse.GridManager", + "T:LibreMetaverse.GroupManager", + "T:LibreMetaverse.InventoryAISClient", + "T:LibreMetaverse.InventoryManager", + "T:LibreMetaverse.Marketplace.MarketplaceManager", + "T:LibreMetaverse.ObjectManager", + "T:LibreMetaverse.ParcelManager", + "T:LibreMetaverse.SoundManager", + "T:LibreMetaverse.TerrainManager", +} +REQUIRED_NATIVE_MEMBERS = { + "M:LibreMetaverse.InventoryManager.UploadThumbnailAsync(LibreMetaverse.UUID,LibreMetaverse.UUID,System.Byte[],System.Threading.CancellationToken,System.IProgress{LibreMetaverse.HttpCapsClient.ProgressReport})", + "P:LibreMetaverse.GridClient.Marketplace", + "P:LibreMetaverse.GridClient.Marketplace#set", +} +RELATED_UPSTREAM_SOURCES = { + "LibreMetaverse.Tests/AnimationTrackTests.cs", + "LibreMetaverse.Tests/AnimeshPlayerTests.cs", + "LibreMetaverse.Tests/AnimeshSkinningTests.cs", + "LibreMetaverse.Tests/AppearanceLiveTests.cs", + "LibreMetaverse.Tests/AppearanceManagerTests.cs", + "LibreMetaverse.Tests/AppearanceTests.cs", + "LibreMetaverse.Tests/AssetMaterialOverrideTests.cs", + "LibreMetaverse.Tests/AvatarManagerTests.cs", + "LibreMetaverse.Tests/BakerTests.cs", + "LibreMetaverse.Tests/BatchLinkTests.cs", + "LibreMetaverse.Tests/CurrentOutfitFolderTests.cs", + "LibreMetaverse.Tests/EnvironmentManagerTests.cs", + "LibreMetaverse.Tests/EstateExperienceReplyTests.cs", + "LibreMetaverse.Tests/EstateToolsTests.cs", + "LibreMetaverse.Tests/ExperienceListAndRegionMessageTests.cs", + "LibreMetaverse.Tests/ExperiencePreferencesMessageTests.cs", + "LibreMetaverse.Tests/GLTFMaterialOverrideCapTests.cs", + "LibreMetaverse.Tests/GLTFMaterialUploadTests.cs", + "LibreMetaverse.Tests/GltfDocumentTests.cs", + "LibreMetaverse.Tests/InventoryAISClientTests.cs", + "LibreMetaverse.Tests/InventoryFetchTests.cs", + "LibreMetaverse.Tests/InventoryManagerTests.cs", + "LibreMetaverse.Tests/InventoryTests.cs", + "LibreMetaverse.Tests/LibraryInventoryTests.cs", + "LibreMetaverse.Tests/MarketplaceFolderClassifierTests.cs", + "LibreMetaverse.Tests/MarketplaceManagerTests.cs", + "LibreMetaverse.Tests/MisclassifiedLinkRegressionTests.cs", + "LibreMetaverse.Tests/OarFileTerrainTests.cs", + "LibreMetaverse.Tests/OfflineMessagesTests.cs", + "LibreMetaverse.Tests/PrimObjectTests.cs", + "LibreMetaverse.Tests/PrimitiveTests.cs", + "LibreMetaverse.Tests/ProductInfoRequestMessageTests.cs", + "LibreMetaverse.Tests/QueryExperiencesOnParcelTests.cs", + "LibreMetaverse.Tests/RegionScheduleTests.cs", + "LibreMetaverse.Tests/RequestTaskInventoryCapTests.cs", + "LibreMetaverse.Tests/SendPostcardTests.cs", + "LibreMetaverse.Tests/SimConsoleTests.cs", + "LibreMetaverse.Tests/SlurlParserTests.cs", + "LibreMetaverse.Tests/StartAuctionTests.cs", + "LibreMetaverse.Tests/TerrainMaterialOverrideTests.cs", + "LibreMetaverse.Tests/TextureEntryTests.cs", + "LibreMetaverse.Tests/UploadThumbnailTests.cs", +} +STUB_RE = re.compile(r"\b(?:not_implemented|unimplemented_api)\b|\b(?:todo|unimplemented)!\s*\(") + + +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}: audit evidence missing: " + ", ".join(missing)) + + +def check_owned_sources() -> None: + offenders = [ + name for name in OWNED_SOURCES if STUB_RE.search((SOURCE_ROOT / name).read_text()) + ] + if offenders: + raise SystemExit("milestone 09 owned Rust stubs remain: " + ", ".join(offenders)) + + +def check_native_mappings() -> None: + native = set(generate_api_shims.NATIVE_TYPES) | set(generate_api_shims.NATIVE_DECLARATIONS) + missing_types = sorted(REQUIRED_NATIVE_TYPES - native) + if missing_types: + raise SystemExit("milestone 09 native types missing: " + ", ".join(missing_types)) + missing_members = sorted( + REQUIRED_NATIVE_MEMBERS - set(generate_api_shims.NATIVE_MEMBER_BODIES) + ) + if missing_members: + raise SystemExit("milestone 09 native members missing: " + ", ".join(missing_members)) + + +def check_parity_catalog() -> None: + catalog = json.loads((ROOT / "tests" / "upstream-tests.json").read_text()) + related = [test for test in catalog["tests"] if test["source"] in RELATED_UPSTREAM_SOURCES] + present_sources = {test["source"] for test in related} + missing_sources = sorted(RELATED_UPSTREAM_SOURCES - present_sources) + if missing_sources: + raise SystemExit("milestone 09 parity sources missing: " + ", ".join(missing_sources)) + bad = [ + test["id"] + for test in related + if test["status"] not in {"translated", "ignored-live"} + or test.get("semantic_review") != "reviewed" + ] + if bad: + raise SystemExit("milestone 09 parity cases are not reviewed: " + ", ".join(bad)) + missing_rust = sorted( + { + test["rust_file"] + for test in related + if test["status"] == "translated" + and not (ROOT / test["rust_file"]).is_file() + } + ) + if missing_rust: + raise SystemExit("milestone 09 translated files missing: " + ", ".join(missing_rust)) + + +def check_fake_grid_gate() -> None: + require_markers( + ROOT / "crates" / "libremetaverse" / "tests" / "network_manager.rs", + ( + "offline_fake_grid_drives_the_complete_networking_lifecycle_without_task_leaks", + "event_queue_running", + "fake-grid inventory marker", + "create_asset_wrapper", + "get_player", + "request_object", + "current_parcel", + "friend_list", + "current_group_count", + "marketplace_error", + "search_people", + "pending_logout_tasks", + "dispatcher_running", + "is_cancellation_requested", + "seed-secret", + "queue-secret", + "asset-secret", + ), + ) + + +def check_docs_and_program_prerequisites() -> None: + require_markers( + ROOT / "docs" / "world-milestone-gate.md", + ( + "Cross-manager fake grid", + "Cancellation and teardown", + "Live actions remain opt-in", + "tools/test_milestone_09.py", + "RUN_LIVE_TESTS=1", + ), + ) + projects = json.loads((ROOT / "programs" / "upstream-programs.json").read_text())[ + "projects" + ] + if len(projects) != 9: + raise SystemExit("milestone 09 expected all nine program prerequisite inventories") + manifest = (ROOT / "programs" / "Cargo.toml").read_text() + if manifest.count("[[bin]]") != 9: + raise SystemExit("milestone 09 expected all nine Rust program targets") + + +def main() -> None: + check_owned_sources() + check_native_mappings() + check_parity_catalog() + check_fake_grid_gate() + check_docs_and_program_prerequisites() + print( + "milestone 09 audit: owned world sources contain no stubs; native mappings, " + "reviewed parity cases, fake-grid integration, docs, and program prerequisites are present" + ) + + +if __name__ == "__main__": + main() diff --git a/tools/generate_api_shims.py b/tools/generate_api_shims.py index 2758889..41acd79 100644 --- a/tools/generate_api_shims.py +++ b/tools/generate_api_shims.py @@ -510,7 +510,8 @@ NATIVE_MEMBER_BODIES = { "crate::asset_archive::asset_type_to_extension()", "F:LibreMetaverse.Assets.ArchiveConstants.EXTENSION_TO_ASSET_TYPE": "crate::asset_archive::extension_to_asset_type()", - "M:LibreMetaverse.Assets.ArchiveConstants.#ctor": "Ok(Self)", + "M:LibreMetaverse.Assets.ArchiveConstants.#ctor": + "/* native zero-sized constants namespace */ Ok(Self)", "M:LibreMetaverse.Assets.OarFile.ClearAssetFolder(System.String)": "crate::asset_archive::clear_asset_folder(assets_path)", "M:LibreMetaverse.Assets.OarFile.PackageArchive(System.String,System.String)": @@ -525,8 +526,12 @@ NATIVE_MEMBER_BODIES = { "crate::asset_archive::save_terrain(sim, terrain_path)", "M:LibreMetaverse.Assets.OarFile.UnpackageArchive(System.String,LibreMetaverse.Assets.OarFile.AssetLoadedCallback,LibreMetaverse.Assets.OarFile.TerrainLoadedCallback,LibreMetaverse.Assets.OarFile.SceneObjectLoadedCallback,LibreMetaverse.Assets.OarFile.SettingsLoadedCallback)": "crate::asset_archive::unpackage_archive(filename, asset_callback, terrain_callback, object_callback, settings_callback)", + "M:LibreMetaverse.InventoryManager.UploadThumbnailAsync(LibreMetaverse.UUID,LibreMetaverse.UUID,System.Byte[],System.Threading.CancellationToken,System.IProgress{LibreMetaverse.HttpCapsClient.ProgressReport})": + "self.native_upload_thumbnail(inventory_id, task_id, j2c_image_data, cancellation_token, progress).await", "P:LibreMetaverse.GridClient.Estate": "self.native_estate().unwrap_or_else(|_| panic!(\"failed to construct EstateTools\"))", "P:LibreMetaverse.GridClient.Estate#set": "self.native_set_estate(value)", + "P:LibreMetaverse.GridClient.Marketplace": "self.native_marketplace()", + "P:LibreMetaverse.GridClient.Marketplace#set": "self.native_set_marketplace(value)", "P:LibreMetaverse.GridClient.Environment": "self.native_environment().unwrap_or_else(|_| panic!(\"failed to construct EnvironmentManager\"))", "P:LibreMetaverse.GridClient.Environment#set": "self.native_set_environment(value)", diff --git a/tools/generate_rust_mapping.py b/tools/generate_rust_mapping.py index 0c37c19..20fb631 100644 --- a/tools/generate_rust_mapping.py +++ b/tools/generate_rust_mapping.py @@ -974,6 +974,7 @@ def validate_generated_shims() -> None: if ( "native client-core implementation" in body or "native network implementation" in body + or "native zero-sized constants namespace" in body ): continue uses_standardized_or_native_path = any( @@ -1008,6 +1009,7 @@ def validate_generated_shims() -> None: "crate::inventory::", "crate::inventory_ais::", "crate::inventory_manager::", + "crate::asset_archive::", "crate::initial_outfit::", "crate::current_outfit::", "crate::message_codec::", diff --git a/tools/test_milestone_09.py b/tools/test_milestone_09.py new file mode 100644 index 0000000..95fe318 --- /dev/null +++ b/tools/test_milestone_09.py @@ -0,0 +1,71 @@ +#!/usr/bin/env python3 +"""Run only the Rust tests owned by the world-services milestone.""" + +from __future__ import annotations + +import os +from pathlib import Path +import subprocess + + +ROOT = Path(__file__).resolve().parents[1] +COMPAT_TARGETS = ( + "appearance_live_semantics", + "appearance_semantics", + "appearance_visual_semantics", + "archive_model_semantics", + "asset_capability_semantics", + "asset_document_semantics", + "avatar_manager_shims", + "current_outfit_folder_semantics", + "current_outfit_policy_semantics", + "inventory_ais_semantics", + "inventory_live_semantics", + "inventory_manager_semantics", + "inventory_store_semantics", + "marketplace_classifier_semantics", + "marketplace_manager_semantics", + "misclassified_link_semantics", + "slurl_semantics", + "social_constructor_semantics", + "social_message_semantics", + "task_inventory_semantics", + "user_report_semantics", + "wire_shims", + "world_capability_semantics", + "world_constructor_semantics", + "world_live_semantics", + "world_manager_shims", + "world_message_semantics", + "world_object_semantics", + "world_packet_semantics", +) + + +def run(command: list[str]) -> None: + print("+", " ".join(command), flush=True) + subprocess.run(command, cwd=ROOT, env=ENV, check=True) + + +ENV = os.environ.copy() +ENV.setdefault("CARGO_BUILD_JOBS", "1") +ENV.pop("RUN_LIVE_TESTS", None) + +run(["cargo", "test", "-p", "libremetaverse", "--lib", "--locked"]) +run( + [ + "cargo", + "test", + "-p", + "libremetaverse", + "--test", + "network_manager", + "offline_fake_grid_drives_the_complete_networking_lifecycle_without_task_leaks", + "--locked", + ] +) +compat = ["cargo", "test", "-p", "libremetaverse-compat-tests"] +for target in COMPAT_TARGETS: + compat.extend(("--test", target)) +compat.append("--locked") +run(compat)