Implement remaining TestClient service commands (#94)
Some checks failed
Native code generation / deterministic (push) Successful in 18m19s
Imaging and meshing gate / native (push) Failing after 4m28s
Native Rust workspace compile / compile (push) Failing after 12m58s

This commit is contained in:
2026-08-11 13:10:27 +00:00
parent 374d7958f1
commit 21f85a1b58
13 changed files with 3300 additions and 24 deletions

View File

@@ -501,13 +501,16 @@ link-aware hierarchy traversal, and deterministic export. Their command
surfaces, limits, isolated CLI tests, and the status of every remaining program surfaces, limits, isolated CLI tests, and the status of every remaining program
are documented in the [`native programs guide`](programs/README.md). are documented in the [`native programs guide`](programs/README.md).
The native `TestClient` now exposes its system, communication, inventory, The native `TestClient` now exposes every non-voice system, communication,
appearance, asset, movement, object, parcel, estate, and grid command groups inventory, appearance, asset, movement, object, parcel, estate, grid, social,
through one bounded multi-client shell. World commands use the native manager directory, and statistics command group through one bounded multi-client shell.
World commands use the native manager
APIs for movement and teleportation, object inspection and linkset transfer, APIs for movement and teleportation, object inspection and linkset transfer,
parcel and map queries, estate terrain transfer, texture caching, and primitive parcel and map queries, estate terrain transfer, texture caching, and primitive
creation. Offline fake-world directives exercise the same parsing, filtering, creation. Social and search commands use cancellable friend, group, avatar, and
output, cancellation, and safety paths without connecting to a privileged live paged-directory APIs; complete profile cloning combines capability data with
grid. Command ownership, resource limits, explicit live-operation gates, and the simulator's interest block. Offline fixtures exercise the same parsing,
the focused verification command are documented in the filtering, output, cancellation, and safety paths without connecting to a
privileged live grid. Command ownership, resource limits, explicit
live-operation gates, and the focused verification command are documented in the
[`TestClient` guide](docs/test-client.md). [`TestClient` guide](docs/test-client.md).

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: 401 types / 17,026 members; remaining surface is callable failure-only shims | | `LibreMetaverse` | 2,711 | 27,281 | native implementation: 401 types / 17,027 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 | native implementation: 164 types / 768 members; no generated shims remain | | `LibreMetaverse.LslTools` | 164 | 768 | native implementation: 164 types / 768 members; no generated shims remain |

View File

@@ -1,6 +1,10 @@
//! Bounded decoder for the simulator's binary BVH animation asset format. //! Bounded decoder for the simulator's binary BVH animation asset format.
#![allow(clippy::needless_pass_by_value, clippy::unused_self)] // Mapped C# signatures are fixed. #![allow(
clippy::needless_pass_by_value,
clippy::too_many_lines,
clippy::unused_self
)] // Mapped C# signatures and the source catalog are fixed.
use libremetaverse_types::compat::Object; use libremetaverse_types::compat::Object;
use libremetaverse_types::{Error, UUID, Vector3}; use libremetaverse_types::{Error, UUID, Vector3};
@@ -16,6 +20,163 @@ pub(crate) fn dance1() -> UUID {
.expect("built-in DANCE1 UUID must be valid") .expect("built-in DANCE1 UUID must be valid")
} }
/// Returns the complete built-in viewer animation catalog from the pinned
/// `LibreMetaverse` source snapshot.
pub(crate) fn builtin_animations() -> std::collections::HashMap<UUID, String> {
const ENTRIES: &[(&str, &str)] = &[
("AFRAID", "6b61c8e8-4747-0d75-12d7-e49ff207a4ca"),
("AIM_BAZOOKA_R", "b5b4a67d-0aee-30d2-72cd-77b333e932ef"),
("AIM_BOW_L", "46bb4359-de38-4ed8-6a22-f1f52fe8f506"),
("AIM_HANDGUN_R", "3147d815-6338-b932-f011-16b56d9ac18b"),
("AIM_RIFLE_R", "ea633413-8006-180a-c3ba-96dd1d756720"),
("ANGRY", "5747a48e-073e-c331-f6f3-7c2149613d3e"),
("AWAY", "fd037134-85d4-f241-72c6-4f42164fedee"),
("BACKFLIP", "c4ca6188-9127-4f31-0158-23c4e2f93304"),
("BELLY_LAUGH", "18b3a4b5-b463-bd48-e4b6-71eaac76c515"),
("BLOW_KISS", "db84829b-462c-ee83-1e27-9bbee66bd624"),
("BORED", "b906c4ba-703b-1940-32a3-0c7f7d791510"),
("BOW", "82e99230-c906-1403-4d9c-3889dd98daba"),
("BRUSH", "349a3801-54f9-bf2c-3bd0-1ac89772af01"),
("BUSY", "efcf670c-2d18-8128-973a-034ebc806b67"),
("CLAP", "9b0c1c4e-8ac7-7969-1494-28c874c4f668"),
("COURTBOW", "9ba1c942-08be-e43a-fb29-16ad440efc50"),
("CROUCH", "201f3fdf-cb1f-dbec-201f-7333e328ae7c"),
("CROUCHWALK", "47f5f6fb-22e5-ae44-f871-73aaaf4a6022"),
("CRY", "92624d3e-1068-f1aa-a5ec-8244585193ed"),
("CUSTOMIZE", "038fcec9-5ebd-8a8e-0e2e-6e71a0a1ac53"),
("CUSTOMIZE_DONE", "6883a61a-b27b-5914-a61e-dda118a9ee2c"),
("DANCE1", "b68a3d7c-de9e-fc87-eec8-543d787e5b0d"),
("DANCE2", "928cae18-e31d-76fd-9cc9-2f55160ff818"),
("DANCE3", "30047778-10ea-1af7-6881-4db7a3a5a114"),
("DANCE4", "951469f4-c7b2-c818-9dee-ad7eea8c30b7"),
("DANCE5", "4bd69a1d-1114-a0b4-625f-84e0a5237155"),
("DANCE6", "cd28b69b-9c95-bb78-3f94-8d605ff1bb12"),
("DANCE7", "a54d8ee2-28bb-80a9-7f0c-7afbbe24a5d6"),
("DANCE8", "b0dc417c-1f11-af36-2e80-7e7489fa7cdc"),
("DEAD", "57abaae6-1d17-7b1b-5f98-6d11a6411276"),
("DRINK", "0f86e355-dd31-a61c-fdb0-3a96b9aad05f"),
("EMBARRASSED", "514af488-9051-044a-b3fc-d4dbf76377c6"),
("EXPRESS_AFRAID", "aa2df84d-cf8f-7218-527b-424a52de766e"),
("EXPRESS_ANGER", "1a03b575-9634-b62a-5767-3a679e81f4de"),
("EXPRESS_BORED", "214aa6c1-ba6a-4578-f27c-ce7688f61d0d"),
("EXPRESS_CRY", "d535471b-85bf-3b4d-a542-93bea4f59d33"),
("EXPRESS_DISDAIN", "d4416ff1-09d3-300f-4183-1b68a19b9fc1"),
(
"EXPRESS_EMBARRASSED",
"0b8c8211-d78c-33e8-fa28-c51a9594e424",
),
("EXPRESS_FROWN", "fee3df48-fa3d-1015-1e26-a205810e3001"),
("EXPRESS_KISS", "1e8d90cc-a84e-e135-884c-7c82c8b03a14"),
("EXPRESS_LAUGH", "62570842-0950-96f8-341c-809e65110823"),
("EXPRESS_OPEN_MOUTH", "d63bc1f9-fc81-9625-a0c6-007176d82eb7"),
("EXPRESS_REPULSED", "f76cda94-41d4-a229-2872-e0296e58afe1"),
("EXPRESS_SAD", "eb6ebfb2-a4b3-a19c-d388-4dd5c03823f7"),
("EXPRESS_SHRUG", "a351b1bc-cc94-aac2-7bea-a7e6ebad15ef"),
("EXPRESS_SMILE", "b7c7c833-e3d3-c4e3-9fc0-131237446312"),
("EXPRESS_SURPRISE", "728646d9-cc79-08b2-32d6-937f0a835c24"),
("EXPRESS_TONGUE_OUT", "835965c6-7f2f-bda2-5deb-2478737f91bf"),
("EXPRESS_TOOTHSMILE", "b92ec1a5-e7ce-a76b-2b05-bcdb9311417e"),
("EXPRESS_WINK", "da020525-4d94-59d6-23d7-81fdebf33148"),
("EXPRESS_WORRY", "9c05e5c7-6f07-6ca4-ed5a-b230390c3950"),
("FALLDOWN", "666307d9-a860-572d-6fd4-c3ab8865c094"),
("FEMALE_WALK", "f5fc7433-043d-e819-8298-f519a119b688"),
("FINGER_WAG", "c1bc7f36-3ba0-d844-f93c-93be945d644f"),
("FIST_PUMP", "7db00ccd-f380-f3ee-439d-61968ec69c8a"),
("FLY", "aec4610c-757f-bc4e-c092-c6e9caf18daf"),
("FLYSLOW", "2b5a38b2-5e00-3a97-a495-4c826bc443e6"),
("HELLO", "9b29cd61-c45b-5689-ded2-91756b8d76a9"),
("HOLD_BAZOOKA_R", "ef62d355-c815-4816-2474-b1acc21094a6"),
("HOLD_BOW_L", "8b102617-bcba-037b-86c1-b76219f90c88"),
("HOLD_HANDGUN_R", "efdc1727-8b8a-c800-4077-975fc27ee2f2"),
("HOLD_RIFLE_R", "3d94bad0-c55b-7dcc-8763-033c59405d33"),
("HOLD_THROW_R", "7570c7b5-1f22-56dd-56ef-a9168241bbb6"),
("HOVER", "4ae8016b-31b9-03bb-c401-b1ea941db41d"),
("HOVER_DOWN", "20f063ea-8306-2562-0b07-5c853b37b31e"),
("HOVER_UP", "62c5de58-cb33-5743-3d07-9e4cd4352864"),
("IMPATIENT", "5ea3991f-c293-392e-6860-91dfa01278a3"),
("JUMP", "2305bd75-1ca9-b03b-1faa-b176b8a8c49e"),
("JUMP_FOR_JOY", "709ea28e-1573-c023-8bf8-520c8bc637fa"),
("KISS_MY_BUTT", "19999406-3a3a-d58c-a2ac-d72e555dcf51"),
("LAND", "7a17b059-12b2-41b1-570a-186368b6aa6f"),
("LAUGH_SHORT", "ca5b3f14-3194-7a2b-c894-aa699b718d1f"),
("MEDIUM_LAND", "f4f00d6e-b9fe-9292-f4cb-0ae06ea58d57"),
("MOTORCYCLE_SIT", "08464f78-3a8e-2944-cba5-0c94aff3af29"),
("MUSCLE_BEACH", "315c3a41-a5f3-0ba4-27da-f893f769e69b"),
("NO", "5a977ed9-7f72-44e9-4c4c-6e913df8ae74"),
("NO_UNHAPPY", "d83fa0e5-97ed-7eb2-e798-7bd006215cb4"),
("NYAH_NYAH", "f061723d-0a18-754f-66ee-29a44795a32f"),
("ONETWO_PUNCH", "eefc79be-daae-a239-8c04-890f5d23654a"),
("PEACE", "b312b10e-65ab-a0a4-8b3c-1326ea8e3ed9"),
("POINT_ME", "17c024cc-eef2-f6a0-3527-9869876d7752"),
("POINT_YOU", "ec952cca-61ef-aa3b-2789-4d1344f016de"),
("PRE_JUMP", "7a4e87fe-de39-6fcb-6223-024b00893244"),
("PUNCH_LEFT", "f3300ad9-3462-1d07-2044-0fef80062da0"),
("PUNCH_RIGHT", "c8e42d32-7310-6906-c903-cab5d4a34656"),
("REPULSED", "36f81a92-f076-5893-dc4b-7c3795e487cf"),
("ROUNDHOUSE_KICK", "49aea43b-5ac3-8a44-b595-96100af0beda"),
("RPS_COUNTDOWN", "35db4f7e-28c2-6679-cea9-3ee108f7fc7f"),
("RPS_PAPER", "0836b67f-7f7b-f37b-c00a-460dc1521f5a"),
("RPS_ROCK", "42dd95d5-0bc6-6392-f650-777304946c0f"),
("RPS_SCISSORS", "16803a9f-5140-e042-4d7b-d28ba247c325"),
("RUN", "05ddbff8-aaa9-92a1-2b74-8fe77a29b445"),
("SAD", "0eb702e2-cc5a-9a88-56a5-661a55c0676a"),
("SALUTE", "cd7668a6-7011-d7e2-ead8-fc69eff1a104"),
("SHOOT_BOW_L", "e04d450d-fdb5-0432-fd68-818aaf5935f8"),
("SHOUT", "6bd01860-4ebd-127a-bb3d-d1427e8e0c42"),
("SHRUG", "70ea714f-3a97-d742-1b01-590a8fcd1db5"),
("SIT", "1a5fe8ac-a804-8a5d-7cbd-56bd83184568"),
("SIT_FEMALE", "b1709c8d-ecd3-54a1-4f28-d55ac0840782"),
("SIT_GENERIC", "245f3c54-f1c0-bf2e-811f-46d8eeb386e7"),
("SIT_GROUND", "1c7600d6-661f-b87b-efe2-d7421eb93c86"),
(
"SIT_GROUND_staticRAINED",
"1a2bd58e-87ff-0df8-0b4c-53e047b0bb6e",
),
("SIT_TO_STAND", "a8dee56f-2eae-9e7a-05a2-6fb92b97e21e"),
("SLEEP", "f2bed5f9-9d44-39af-b0cd-257b2a17fe40"),
("SMOKE_IDLE", "d2f2ee58-8ad1-06c9-d8d3-3827ba31567a"),
("SMOKE_INHALE", "6802d553-49da-0778-9f85-1599a2266526"),
("SMOKE_THROW_DOWN", "0a9fb970-8b44-9114-d3a9-bf69cfe804d6"),
("SNAPSHOT", "eae8905b-271a-99e2-4c0e-31106afd100c"),
("STAND", "2408fe9e-df1d-1d7d-f4ff-1384fa7b350f"),
("STANDUP", "3da1d753-028a-5446-24f3-9c9b856d9422"),
("STAND_1", "15468e00-3400-bb66-cecc-646d7c14458e"),
("STAND_2", "370f3a20-6ca6-9971-848c-9a01bc42ae3c"),
("STAND_3", "42b46214-4b44-79ae-deb8-0df61424ff4b"),
("STAND_4", "f22fed8b-a5ed-2c93-64d5-bdd8b93c889f"),
("STRETCH", "80700431-74ec-a008-14f8-77575e73693f"),
("STRIDE", "1cb562b0-ba21-2202-efb3-30f82cdf9595"),
("SURF", "41426836-7437-7e89-025d-0aa4d10f1d69"),
("SURPRISE", "313b9881-4302-73c0-c7d0-0e7a36b6c224"),
("SWORD_STRIKE", "85428680-6bf9-3e64-b489-6f81087c24bd"),
("TALK", "5c682a95-6da4-a463-0bf6-0f5b7be129d1"),
("TANTRUM", "11000694-3f41-adc2-606b-eee1d66f3724"),
("THROW_R", "aa134404-7dac-7aca-2cba-435f9db875ca"),
("TRYON_SHIRT", "83ff59fe-2346-f236-9009-4e3608af64c1"),
("TURNLEFT", "56e0ba0d-4a9f-7f27-6117-32f2ebbf6135"),
("TURNRIGHT", "2d6daa51-3192-6794-8e2e-a15f8338ec30"),
("TYPE", "c541c47f-e0c0-058b-ad1a-d6ae3a4584d9"),
("WALK", "6ed24bd8-91aa-4b12-ccc7-c97c857ab4e0"),
("WHISPER", "7693f268-06c7-ea71-fa21-2b30d6533f8f"),
("WHISTLE", "b1ed7982-c68e-a982-7561-52a88a5298c0"),
("WINK", "869ecdad-a44b-671e-3266-56aef2e3ac2e"),
("WINK_HOLLYWOOD", "c0c4030f-c02b-49de-24ba-2331f43fe41c"),
("WORRY", "9f496bd2-589a-709f-16cc-69bf7df1d36c"),
("YES", "15dd911d-be82-2856-26db-27659b142875"),
("YES_HAPPY", "b8c8b2a3-9008-1771-3bfc-90924955ab2d"),
("YOGA_FLOAT", "42ecd00b-9947-a97c-400a-bbc9174c7aeb"),
];
ENTRIES
.iter()
.map(|(name, id)| {
(
UUID::new_with_string((*id).to_owned()).expect("built-in animation UUID is valid"),
(*name).to_owned(),
)
})
.collect()
}
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct BinBVHJointKey { pub struct BinBVHJointKey {
pub key_element: Vector3, pub key_element: Vector3,
@@ -338,3 +499,18 @@ impl<'a> Cursor<'a> {
String::from_utf8(bytes.to_vec()).map_err(|_| Error::Argument) String::from_utf8(bytes.to_vec()).map_err(|_| Error::Argument)
} }
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn built_in_catalog_contains_every_pinned_animation_once() {
let catalog = builtin_animations();
assert_eq!(catalog.len(), 135);
assert_eq!(catalog.get(&dance1()).map(String::as_str), Some("DANCE1"));
let yoga = UUID::new_with_string("42ecd00b-9947-a97c-400a-bbc9174c7aeb".into())
.expect("YOGA_FLOAT UUID");
assert_eq!(catalog.get(&yoga).map(String::as_str), Some("YOGA_FLOAT"));
}
}

View File

@@ -10,9 +10,10 @@
use crate::client_core::ClientWeakHandle; use crate::client_core::ClientWeakHandle;
use crate::packet_catalog::PacketType; use crate::packet_catalog::PacketType;
use crate::packets::{ use crate::packets::{
AvatarAnimationPacket, AvatarPickerRequestPacket, AvatarPropertiesRequestPacket, AvatarAnimationPacket, AvatarInterestsReplyPacket, AvatarPickerRequestPacket,
ClassifiedInfoRequestPacket, GenericMessagePacket, GenericMessagePacketParamListBlock, AvatarPropertiesRequestPacket, ClassifiedInfoRequestPacket, GenericMessagePacket,
TrackAgentPacket, UUIDNameRequestPacket, UUIDNameRequestPacketUUIDNameBlockBlock, GenericMessagePacketParamListBlock, TrackAgentPacket, UUIDNameRequestPacket,
UUIDNameRequestPacketUUIDNameBlockBlock,
}; };
use crate::{Error, GridClient}; use crate::{Error, GridClient};
use libremetaverse_structured_data::{OSD, OSDMap, OSDParser}; use libremetaverse_structured_data::{OSD, OSDMap, OSDParser};
@@ -121,6 +122,27 @@ fn handle_avatar_animation(inner: &AvatarManagerInner, data: Vec<u8>) {
}); });
} }
fn decode_avatar_interests(data: Vec<u8>, avatar_id: UUID) -> Option<crate::AvatarInterests> {
let mut offset = 0;
let packet = AvatarInterestsReplyPacket::new_with_bytes_int32(data, &mut offset).ok()?;
if packet.agent_data.avatar_id != avatar_id {
return None;
}
Some(crate::AvatarInterests {
languages_text: String::from_utf8_lossy(&packet.properties_data.languages_text)
.trim_end_matches('\0')
.to_owned(),
skills_mask: packet.properties_data.skills_mask,
skills_text: String::from_utf8_lossy(&packet.properties_data.skills_text)
.trim_end_matches('\0')
.to_owned(),
want_to_mask: packet.properties_data.want_to_mask,
want_to_text: String::from_utf8_lossy(&packet.properties_data.want_to_text)
.trim_end_matches('\0')
.to_owned(),
})
}
impl AvatarAnimationEventArgs { impl AvatarAnimationEventArgs {
pub(crate) fn native_new(avatar_id: UUID, animations: Vec<Animation>) -> Result<Self, Error> { pub(crate) fn native_new(avatar_id: UUID, animations: Vec<Animation>) -> Result<Self, Error> {
if animations.len() > 256 { if animations.len() > 256 {
@@ -476,6 +498,53 @@ impl AvatarManager {
) )
} }
/// Requests the legacy profile-interest block and waits for the matching
/// simulator reply.
///
/// The modern `AgentProfile` capability includes groups and picks but does
/// not include the five interest fields. Consumers that need a complete
/// editable profile can pair this request with [`Self::request_agent_profile`].
///
/// # Errors
///
/// Returns an error if there is no active simulator, the request cannot be
/// encoded or sent, the matching reply channel closes, or cancellation is
/// requested.
pub async fn request_avatar_interests(
&self,
avatar_id: UUID,
cancellation_token: Option<CancellationToken>,
) -> Result<crate::AvatarInterests, Error> {
let network = self.live_network()?;
let token = cancellation_token.unwrap_or_default();
token.throw_if_cancellation_requested()?;
let (sender, receiver) = tokio::sync::oneshot::channel();
let sender = Arc::new(Mutex::new(Some(sender)));
let callback = Arc::clone(&sender);
let subscription = network.subscribe_raw_packet(Arc::new(move |event| {
if event.packet_type != PacketType::AvatarInterestsReply {
return;
}
let Some(interests) = decode_avatar_interests(event.data, avatar_id) else {
return;
};
if let Some(sender) = callback
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.take()
{
let _ = sender.send(interests);
}
}));
self.native_request_avatar_properties(avatar_id)?;
let result = tokio::select! {
value = receiver => value.map_err(|_| Error::InvalidOperation),
() = token.cancelled() => Err(Error::Cancelled),
};
drop(subscription);
result
}
pub(crate) fn native_request_avatar_name_search( pub(crate) fn native_request_avatar_name_search(
&self, &self,
name: String, name: String,
@@ -1018,4 +1087,25 @@ mod tests {
assert_eq!(*received.lock().expect("event lock"), [0]); assert_eq!(*received.lock().expect("event lock"), [0]);
} }
#[test]
fn avatar_interests_reply_is_correlated_and_decoded() {
let avatar_id = UUID::random().expect("avatar id");
let mut packet = AvatarInterestsReplyPacket::new_with_constructor().expect("packet");
packet.agent_data.avatar_id = avatar_id;
packet.properties_data.languages_text = b"Rust, LSL\0".to_vec();
packet.properties_data.skills_mask = 0x12;
packet.properties_data.skills_text = b"Protocol testing\0".to_vec();
packet.properties_data.want_to_mask = 0x34;
packet.properties_data.want_to_text = b"Build things\0".to_vec();
let bytes = packet.to_bytes_with_method().expect("packet bytes");
assert!(decode_avatar_interests(bytes.clone(), UUID::zero()).is_none());
let interests = decode_avatar_interests(bytes, avatar_id).expect("matching reply");
assert_eq!(interests.languages_text, "Rust, LSL");
assert_eq!(interests.skills_mask, 0x12);
assert_eq!(interests.skills_text, "Protocol testing");
assert_eq!(interests.want_to_mask, 0x34);
assert_eq!(interests.want_to_text, "Build things");
}
} }

View File

@@ -2811,7 +2811,9 @@ impl Animations {
libremetaverse_types::compat::FrozenDictionary<libremetaverse_types::UUID, String>, libremetaverse_types::compat::FrozenDictionary<libremetaverse_types::UUID, String>,
crate::Error, crate::Error,
> { > {
libremetaverse_types::not_implemented("M:LibreMetaverse.Animations.ToDictionary") Ok(libremetaverse_types::compat::FrozenDictionary(
crate::animation::builtin_animations(),
))
} }
} }

View File

@@ -28,6 +28,11 @@ fn mutex<T>(value: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
.lock() .lock()
.unwrap_or_else(std::sync::PoisonError::into_inner) .unwrap_or_else(std::sync::PoisonError::into_inner)
} }
fn read<T>(value: &RwLock<T>) -> std::sync::RwLockReadGuard<'_, T> {
value
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
fn write<T>(value: &RwLock<T>) -> std::sync::RwLockWriteGuard<'_, T> { fn write<T>(value: &RwLock<T>) -> std::sync::RwLockWriteGuard<'_, T> {
value value
.write() .write()
@@ -260,6 +265,15 @@ impl GroupManager {
pub fn current_group_count(&self) -> i32 { pub fn current_group_count(&self) -> i32 {
self.inner.group_count.load(Ordering::Acquire) self.inner.group_count.load(Ordering::Acquire)
} }
/// Returns a stable snapshot of the groups currently reported for this agent.
///
/// The cache is replaced atomically whenever an `AgentGroupDataUpdate` arrives,
/// so callers never observe a partially decoded group list.
#[must_use]
pub fn current_groups(&self) -> HashMap<UUID, Group> {
read(&self.inner.groups).clone()
}
pub fn group_membership_limit(&self) -> i32 { pub fn group_membership_limit(&self) -> i32 {
let mut client = (*self.inner.client).clone(); let mut client = (*self.inner.client).clone();
client.self_().benefits().group_membership_limit() client.self_().benefits().group_membership_limit()

View File

@@ -3,8 +3,26 @@
The `test-client` binary is a native Rust multi-client shell. It does not load The `test-client` binary is a native Rust multi-client shell. It does not load
the former C# executable or start a CLR process. The command registry owns the the former C# executable or start a CLR process. The command registry owns the
implemented system, communication, inventory, appearance, asset, movement, implemented system, communication, inventory, appearance, asset, movement,
object, parcel, estate, and grid commands, and reports the remaining upstream object, parcel, estate, grid, agent, friends, groups, directory, and statistics
targets through `pending_test_client_commands()`. commands. `pending_test_client_commands()` now reports only the two explicitly
owned voice-native adapters (`ParcelVoiceInfo` and `VoiceAcountCommand`).
## Social, directory, and statistics behavior
Directory searches use native correlated, paged result streams and retain the
upstream 20-second response bound. Group cache, member, and role requests are
correlated with their native events and preserve the upstream timeouts. Friend
mapping, UUID/name resolution, event details, and group operations propagate
shell cancellation.
`who` maintains a live coarse-location roster and resolves display names;
`bots` also tracks source UUIDs from decoded viewer-effect packets. Profile
cloning combines the AgentProfile capability with the matching legacy interests
reply to replace profile text, images, URL, interests, and picks before joining
and activating the target's public groups. `play list` exposes all 135 built-in
animations from the pinned source catalog and also accepts arbitrary animation
asset UUIDs. Statistics come from each simulator's thread-safe native counters.
Directory and collection results are bounded at 65,535 entries.
## World command behavior ## World command behavior
@@ -39,6 +57,13 @@ terrain download/upload additionally require `--allow-estate-actions`.
Consequently the default invocation cannot move an avatar, alter or create an Consequently the default invocation cannot move an avatar, alter or create an
object, upload an asset, or invoke an estate-owner action. object, upload an asset, or invoke an estate-owner action.
Profile cloning, generic messages, animation starts, touches, group activation,
invitations, joins, and leaves use the same two-part mutation gate. Read-only
friend, group, directory, region, uptime, dilation, and statistics commands do
not require a gate. Profile cloning and direct group joins additionally require
`--allow-spending`, because an open group can charge an enrollment fee and the
client cannot safely infer that fee before every UUID-based join.
Output paths reject parent-directory traversal. Timeouts, cancellation, file Output paths reject parent-directory traversal. Timeouts, cancellation, file
limits, result limits, and linkset limits apply equally to live and fake-grid limits, result limits, and linkset limits apply equally to live and fake-grid
runs. Passwords and login secrets continue to pass through the shell's common runs. Passwords and login secrets continue to pass through the shell's common
@@ -52,13 +77,22 @@ Fake scripts can seed the same command layer with `!world-region`,
`!world-agent-location`, `!world-estate`, `!world-asset`, and `!world-syntax` `!world-agent-location`, `!world-estate`, `!world-asset`, and `!world-syntax`
tab-separated directives. These fixtures never open a network connection. tab-separated directives. These fixtures never open a network connection.
The service layer additionally accepts `!service-avatar`, `!service-friend`,
`!service-group`, `!service-group-result`, `!service-group-member`,
`!service-group-role`, `!service-person`, `!service-classified`,
`!service-event`, `!service-event-info`, `!service-land`, `!service-place`,
`!service-sim`, `!service-animation`, and `!service-utilization[-row]` fixtures.
Run the issue-focused compatibility gate with: Run the issue-focused compatibility gate with:
```text ```text
cargo test -p libremetaverse-programs --test test_client_world_cli cargo test -p libremetaverse-programs --test test_client_world_cli
cargo test -p libremetaverse-programs --test test_client_services_cli
``` ```
The gate drives every command owned by the movement/object/land/grid issue, The world gate drives every command owned by the movement/object/land/grid
checks the exact fake backend calls, verifies destructive-operation guards, issue, checks the exact fake backend calls, round-trips a linkset through
round-trips an exported linkset through import, compares two exports byte for import, and validates generated assets. The services gate drives every one of
byte, and validates generated terrain, texture, and TGA files. the 28 remaining non-voice commands in a single offline session, verifies the
destructive-operation guards, and proves the pending inventory contains only
the two explicitly owned voice adapters.

View File

@@ -13,7 +13,7 @@ here so a source entry is never mistaken for a completed port.
| `prim-inspector` | PrimInspector | Implemented with live and deterministic fake-grid discovery | | `prim-inspector` | PrimInspector | Implemented with live and deterministic fake-grid discovery |
| `inventory-explorer` | InventoryExplorer | Implemented with live inventory and deterministic AIS fixtures | | `inventory-explorer` | InventoryExplorer | Implemented with live inventory and deterministic AIS fixtures |
| `irc-gateway` | IRCGateway | Implemented with live IRC/grid transports and deterministic offline scripts | | `irc-gateway` | IRCGateway | Implemented with live IRC/grid transports and deterministic offline scripts |
| `test-client` | TestClient | Native shell, registry, system, communication, inventory, appearance, asset, movement, object, parcel, estate, and grid groups implemented; remaining command group tracked by #94 | | `test-client` | TestClient | All non-voice command groups implemented with live and deterministic fake-grid backends; voice adapters tracked by #95 and #96 |
| `vivox-test` | VivoxTest | Pending milestone 11 issue #95 | | `vivox-test` | VivoxTest | Pending milestone 11 issue #95 |
| `webrtc-test` | WebRtcTest | Pending milestone 11 issue #96 | | `webrtc-test` | WebRtcTest | Pending milestone 11 issue #96 |
@@ -184,6 +184,13 @@ transfer, and grid map/layer/location/wind commands. `textures on` and
primitive textures. Detailed command behavior and limits are in primitive textures. Detailed command behavior and limits are in
[`docs/test-client.md`](../docs/test-client.md). [`docs/test-client.md`](../docs/test-client.md).
The final non-voice wave adds nearby-agent and bot inspection, complete profile
cloning, friend mapping, group membership and role operations, paged directory
searches, event details, animation control, object touch, and region/network
statistics. Live profile cloning combines the modern AgentProfile capability
with the legacy interests reply so text, images, URL, interests, picks, and
public groups are all synchronized.
The native `load` command reads a portable command-alias manifest instead of a The native `load` command reads a portable command-alias manifest instead of a
CLR assembly. Each non-comment line is tab-separated CLR assembly. Each non-comment line is tab-separated
`name<TAB>description<TAB>template`; `{args}` or `$*` inserts the quoted command `name<TAB>description<TAB>template`; `{args}` or `$*` inserts the quoted command

View File

@@ -1,6 +1,7 @@
//! Native `TestClient` shell, registry, and first-wave command groups. //! Native `TestClient` shell, registry, and first-wave command groups.
mod inventory; mod inventory;
mod services;
mod world; mod world;
use crate::commands::TEST_CLIENT_COMMANDS; use crate::commands::TEST_CLIENT_COMMANDS;
@@ -135,6 +136,34 @@ pub const IMPLEMENTED_TEST_CLIENT_COMMANDS: &[&str] = &[
"ShowEffectsCommand", "ShowEffectsCommand",
"SleepCommand", "SleepCommand",
"WaitForLoginCommand", "WaitForLoginCommand",
"BotsCommand",
"CloneProfileCommand",
"GenericMessageCommand",
"PlayAnimationCommand",
"TouchCommand",
"WhoCommand",
"Key2NameCommand",
"SearchClassifiedsCommand",
"SearchEventsCommand",
"SearchGroupsCommand",
"SearchLandCommand",
"SearchPeopleCommand",
"SearchPlacesCommand",
"ShowEventDetailsCommand",
"FriendsCommand",
"MapFriendCommand",
"ActivateGroupCommand",
"GroupMembersCommand",
"GroupRolesCommand",
"GroupsCommand",
"InviteGroupCommand",
"JoinGroupCommand",
"LeaveGroupCommand",
"DilationCommand",
"NetstatsCommand",
"RegionInfoCommand",
"StatsCommand",
"UptimeCommand",
]; ];
#[must_use] #[must_use]
@@ -153,7 +182,7 @@ pub fn pending_test_client_commands() -> Vec<&'static str> {
version, version,
about = "Run the native LibreMetaverse multi-client command shell", about = "Run the native LibreMetaverse multi-client command shell",
long_about = None, long_about = None,
after_help = "Owned commands include @, debug, echomaster, help, im, imgroup, load, login, logpacket, logout, md5, quit, say, setmaster, setmasterkey, shout, showeffects, sleep, waitforlogin, whisper, plus the inventory, appearance, asset, movement, object, parcel, estate, and grid groups. Run `help` or `help COMMAND` inside the shell for the complete command registry." after_help = "Owned commands include @, debug, echomaster, help, im, imgroup, load, login, logpacket, logout, md5, quit, say, setmaster, setmasterkey, shout, showeffects, sleep, waitforlogin, and whisper. All non-voice inventory, appearance, asset, movement, object, parcel, estate, grid, social, directory, and statistics commands are also native. Run `help` or `help COMMAND` inside the shell for the complete command registry."
)] )]
#[allow(clippy::struct_excessive_bools)] #[allow(clippy::struct_excessive_bools)]
struct Cli { struct Cli {
@@ -296,7 +325,7 @@ enum ClientEvent {
type BackendFuture<'a, T> = Pin<Box<dyn Future<Output = T> + Send + 'a>>; type BackendFuture<'a, T> = Pin<Box<dyn Future<Output = T> + Send + 'a>>;
type InventoryFuture<'a, T> = Pin<Box<dyn Future<Output = T> + 'a>>; type InventoryFuture<'a, T> = Pin<Box<dyn Future<Output = T> + 'a>>;
trait ClientBackend: Send + Sync + inventory::Backend + world::Backend { trait ClientBackend: Send + Sync + inventory::Backend + services::Backend + world::Backend {
fn id(&self) -> UUID; fn id(&self) -> UUID;
fn name(&self) -> String; fn name(&self) -> String;
fn connected(&self) -> bool; fn connected(&self) -> bool;
@@ -332,6 +361,7 @@ struct LiveBackend {
subscriptions: Mutex<Vec<Subscription>>, subscriptions: Mutex<Vec<Subscription>>,
inventory_state: Mutex<inventory::State>, inventory_state: Mutex<inventory::State>,
world_state: Mutex<world::State>, world_state: Mutex<world::State>,
services_state: Mutex<services::State>,
closed: AtomicBool, closed: AtomicBool,
} }
@@ -393,6 +423,7 @@ impl LiveBackend {
subscriptions: Mutex::new(Vec::new()), subscriptions: Mutex::new(Vec::new()),
inventory_state: Mutex::new(inventory::State::new(account_policy)), inventory_state: Mutex::new(inventory::State::new(account_policy)),
world_state: Mutex::new(world::State::new(account_policy, get_textures)), world_state: Mutex::new(world::State::new(account_policy, get_textures)),
services_state: Mutex::new(services::State::new(account_policy)),
closed: AtomicBool::new(false), closed: AtomicBool::new(false),
}); });
backend.install(&events, &dropped); backend.install(&events, &dropped);
@@ -512,6 +543,7 @@ impl LiveBackend {
}, },
))); )));
world::install_live(self, &mut subscriptions); world::install_live(self, &mut subscriptions);
services::install_live(self, &mut subscriptions);
*lock(&self.subscriptions) = subscriptions; *lock(&self.subscriptions) = subscriptions;
} }
} }
@@ -680,6 +712,7 @@ struct FakeBackend {
group_members: Arc<Mutex<HashSet<UUID>>>, group_members: Arc<Mutex<HashSet<UUID>>>,
inventory_state: Mutex<inventory::State>, inventory_state: Mutex<inventory::State>,
world_state: Mutex<world::State>, world_state: Mutex<world::State>,
services_state: Mutex<services::State>,
} }
impl FakeBackend { impl FakeBackend {
@@ -700,6 +733,7 @@ impl FakeBackend {
group_members, group_members,
inventory_state: Mutex::new(inventory::State::new(policy)), inventory_state: Mutex::new(inventory::State::new(policy)),
world_state: Mutex::new(world::State::new(policy, get_textures)), world_state: Mutex::new(world::State::new(policy, get_textures)),
services_state: Mutex::new(services::State::new(policy)),
} }
} }
@@ -820,10 +854,13 @@ impl ProgramOutput {
enum CommandCategory { enum CommandCategory {
Appearance, Appearance,
Communication, Communication,
Friends,
Groups,
Inventory, Inventory,
Movement, Movement,
Objects, Objects,
Parcel, Parcel,
Search,
Simulator, Simulator,
TestClient, TestClient,
Other, Other,
@@ -838,6 +875,7 @@ enum CommandHandler {
Im, Im,
ImGroup, ImGroup,
Inventory(inventory::Command), Inventory(inventory::Command),
Services(services::Command),
World(world::Command), World(world::Command),
Load, Load,
Login, Login,
@@ -999,6 +1037,7 @@ fn built_in_commands() -> HashMap<String, CommandDefinition> {
.into_iter() .into_iter()
.chain(communication_commands()) .chain(communication_commands())
.chain(inventory::commands()) .chain(inventory::commands())
.chain(services::commands())
.chain(world::commands()) .chain(world::commands())
.map(|(name, description, category, handler)| { .map(|(name, description, category, handler)| {
( (
@@ -1780,6 +1819,9 @@ async fn execute_client_command(
CommandHandler::Inventory(command) => { CommandHandler::Inventory(command) => {
inventory::execute(client.backend.as_ref(), *command, args, from, cancellation).await inventory::execute(client.backend.as_ref(), *command, args, from, cancellation).await
} }
CommandHandler::Services(command) => {
services::execute(client.backend.as_ref(), *command, args, cancellation).await
}
CommandHandler::World(command) => { CommandHandler::World(command) => {
world::execute(client.backend.as_ref(), *command, args, cancellation).await world::execute(client.backend.as_ref(), *command, args, cancellation).await
} }
@@ -2367,6 +2409,10 @@ async fn run_fake_event_directive(
result.map_err(|reason| ProgramError::InvalidInput { line, reason })?; result.map_err(|reason| ProgramError::InvalidInput { line, reason })?;
return Ok(()); return Ok(());
} }
if let Some(result) = services::apply_fake_directive(manager, fields) {
result.map_err(|reason| ProgramError::InvalidInput { line, reason })?;
return Ok(());
}
match fields { match fields {
["chat", client, source, name, message] => { ["chat", client, source, name, message] => {
let event = ClientEvent::Chat { let event = ClientEvent::Chat {
@@ -2670,6 +2716,7 @@ mod tests {
assert!(TEST_CLIENT_COMMANDS.contains(command)); assert!(TEST_CLIENT_COMMANDS.contains(command));
assert!(!pending.contains(command)); assert!(!pending.contains(command));
} }
assert_eq!(pending, ["ParcelVoiceInfo", "VoiceAcountCommand"]);
} }
#[test] #[test]

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,215 @@
use std::fs;
use std::path::{Path, PathBuf};
use std::process::{Command, Output, Stdio};
use std::sync::atomic::{AtomicU64, Ordering};
const CLIENT: &str = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee";
const AVATAR: &str = "11111111-2222-3333-4444-555555555555";
const BOT: &str = "22222222-3333-4444-5555-666666666666";
const GROUP: &str = "33333333-4444-5555-6666-777777777777";
const MEMBER: &str = "44444444-5555-6666-7777-888888888888";
const ROLE: &str = "55555555-6666-7777-8888-999999999999";
const CLASSIFIED: &str = "66666666-7777-8888-9999-aaaaaaaaaaaa";
const LAND: &str = "77777777-8888-9999-aaaa-bbbbbbbbbbbb";
const SIM: &str = "88888888-9999-aaaa-bbbb-cccccccccccc";
const OBJECT: &str = "99999999-aaaa-bbbb-cccc-dddddddddddd";
const DANCE1: &str = "b68a3d7c-de9e-fc87-eec8-543d787e5b0d";
static TEMP_ID: AtomicU64 = AtomicU64::new(0);
struct TestDir(PathBuf);
impl TestDir {
fn new() -> Self {
let id = TEMP_ID.fetch_add(1, Ordering::Relaxed);
let path = std::env::temp_dir().join(format!(
"metacrate-test-client-services-{}-{id}",
std::process::id()
));
fs::create_dir(&path).expect("create test directory");
Self(path)
}
fn write(&self, contents: &str) -> PathBuf {
let path = self.0.join("session.tsv");
fs::write(&path, contents).expect("write script");
path
}
}
impl Drop for TestDir {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
fn path_text(path: &Path) -> &str {
path.to_str().expect("UTF-8 test path")
}
fn run(args: &[&str]) -> Output {
Command::new(env!("CARGO_BIN_EXE_test-client"))
.args(args)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.expect("run test-client")
}
#[test]
#[allow(clippy::too_many_lines)]
fn full_offline_session_exercises_every_remaining_non_voice_command() {
let directory = TestDir::new();
let script = directory.write(&format!(
"!client\t{CLIENT}\tAlice\tBot\n\
!service-avatar\t{CLIENT}\t{AVATAR}\tActive Resident\tBuilders\t128\t129\t25\t10\ttrue\n\
!service-avatar\t{CLIENT}\t{BOT}\tQuiet Bot\tNone\t130\t131\t25\t11\tfalse\n\
!service-friend\t{CLIENT}\t{AVATAR}\tActive Resident\ttrue\t4294967298000\t128\t129\t25\n\
!service-group\t{CLIENT}\t{GROUP}\tBuilders Guild\n\
!service-group-result\t{CLIENT}\t{GROUP}\tBuilders Guild\t42\n\
!service-group-member\t{CLIENT}\t{GROUP}\t{MEMBER}\n\
!service-group-role\t{CLIENT}\t{GROUP}\t{ROLE}\tOfficer\tArchitect\n\
!service-person\t{CLIENT}\t{AVATAR}\tActive\tResident\ttrue\n\
!service-classified\t{CLIENT}\t{CLASSIFIED}\tBuilder Services\t25\n\
!service-event\t{CLIENT}\t7\tBuilding Class\tToday\n\
!service-event-info\t{CLIENT}\t7\tBuilding Class\tTest Region\t1000\t2000\t25\n\
!service-land\t{CLIENT}\t{LAND}\tBuilder Parcel\t1024\t500\n\
!service-place\t{CLIENT}\tBuilder Plaza\tTest Region\t128\t128\t25\n\
!service-sim\t{CLIENT}\tTest Region\t{SIM}\t4294967298000\t0.98\n\
!service-utilization\t{CLIENT}\ttrue\n\
!service-utilization-row\t{CLIENT}\tpacket\tAgentUpdate\t2\t3\t2048\t4096\n\
!service-utilization-row\t{CLIENT}\tcapability\tEventQueueGet\t1\t4\t512\t8192\n\
who\n\
bots\n\
friends\n\
mapfriend {AVATAR}\n\
key2name {AVATAR}\n\
key2name {GROUP}\n\
searchclassifieds Builder\n\
searchevents Building\n\
searchgroups Builders\n\
searchland mainland 1000 512\n\
searchpeople Active\n\
searchplaces Builder\n\
showevent 7\n\
groups\n\
groupmembers Builders Guild\n\
grouproles Builders Guild\n\
play list\n\
play show\n\
dilation\n\
netstats\n\
regioninfo\n\
stats\n\
uptime\n\
cloneprofile {AVATAR} --confirm\n\
sendgeneric test-method alpha beta --confirm\n\
play DANCE1 --confirm\n\
touch {OBJECT} --confirm\n\
activategroup Builders Guild --confirm\n\
invitegroup {AVATAR} {GROUP} {ROLE} --confirm\n\
joingroup Builders Guild --confirm\n\
leavegroup Builders Guild --confirm\n\
quit\n"
));
let output = run(&[
"--allow-live-mutations",
"--allow-spending",
"--fake-script",
path_text(&script),
]);
let stdout = String::from_utf8(output.stdout).expect("UTF-8 stdout");
let stderr = String::from_utf8(output.stderr).expect("UTF-8 stderr");
assert!(
output.status.success(),
"stdout:\n{stdout}\nstderr:\n{stderr}"
);
assert!(stderr.is_empty(), "{stderr}");
for expected in [
"Active Resident (Group: Builders",
"Quiet Bot (Group: None",
"Is Probably a bot",
"has 1 friends:",
"Found Friend 11111111-2222-3333-4444-555555555555",
"Avatar: Active Resident",
"Group: Builders Guild",
"returned 1 classified ads",
"matched 1 Events",
"matched 1 Groups",
"1 results",
"matched 1 People",
"returned 1 results",
"<redacted-url> Region/232/208/0",
"got 1 groups",
"MemberCount 1",
"RoleCount 1",
"AFRAID",
DANCE1,
"Dilation is 0.98",
"Capabilities Totals",
"Packet Totals",
"UUID: 88888888-9999-aaaa-bbbb-cccccccccccc",
"Packets in the queue:",
"I am Alice Bot, Up Since:",
"Synchronized our profile",
"Sent generic message with method test-method",
"Touched object",
"Active group is now Builders Guild",
"invited",
"Joined the group Builders Guild",
"has left the group Builders Guild",
"CALL profile-clone",
"CALL generic-message",
"CALL animation-start",
"CALL object-touch",
"CALL group-activate",
"CALL group-invite",
"CALL group-join",
"CALL group-leave",
] {
assert!(stdout.contains(expected), "missing {expected:?}:\n{stdout}");
}
}
#[test]
fn social_mutations_require_startup_and_per_command_confirmation() {
let directory = TestDir::new();
let script = directory.write(&format!(
"!client\t{CLIENT}\tAlice\tBot\nplay {DANCE1} --confirm\nquit\n"
));
let output = run(&["--fake-script", path_text(&script)]);
let stdout = String::from_utf8(output.stdout).expect("UTF-8 stdout");
assert!(output.status.success());
assert!(stdout.contains("Live mutations are disabled"), "{stdout}");
assert!(!stdout.contains("CALL animation-start"), "{stdout}");
let script = directory.write(&format!(
"!client\t{CLIENT}\tAlice\tBot\ntouch {OBJECT}\nquit\n"
));
let output = run(&[
"--allow-live-mutations",
"--fake-script",
path_text(&script),
]);
let stdout = String::from_utf8(output.stdout).expect("UTF-8 stdout");
assert!(output.status.success());
assert!(stdout.contains("append --confirm"), "{stdout}");
assert!(!stdout.contains("CALL object-touch"), "{stdout}");
let script = directory.write(&format!(
"!client\t{CLIENT}\tAlice\tBot\njoingroup uuid {GROUP} --confirm\nquit\n"
));
let output = run(&[
"--allow-live-mutations",
"--fake-script",
path_text(&script),
]);
let stdout = String::from_utf8(output.stdout).expect("UTF-8 stdout");
assert!(output.status.success());
assert!(stdout.contains("restart with --allow-spending"), "{stdout}");
assert!(!stdout.contains("CALL group-join"), "{stdout}");
}
#[test]
fn command_inventory_leaves_only_explicit_voice_adapters() {
assert_eq!(
libremetaverse_programs::test_client::pending_test_client_commands(),
["ParcelVoiceInfo", "VoiceAcountCommand"]
);
}

View File

@@ -1,8 +1,8 @@
use libremetaverse::animesh::AnimeshManager; use libremetaverse::animesh::AnimeshManager;
use libremetaverse::appearance::CurrentOutfitFolder; use libremetaverse::appearance::CurrentOutfitFolder;
use libremetaverse::{ use libremetaverse::{
AgentManager, AppearanceManagerWearableData, AssetDownload, AssetManager, AssetUpload, AgentManager, Animations, AppearanceManagerWearableData, AssetDownload, AssetManager,
ChatEventArgs, ChatType, InventoryException, InventoryItem, InventoryManager, AssetUpload, ChatEventArgs, ChatType, InventoryException, InventoryItem, InventoryManager,
TeleportEventArgs, TeleportEventArgs,
}; };
use libremetaverse_types::compat::{CancellationToken, EventHandler, Subscription}; use libremetaverse_types::compat::{CancellationToken, EventHandler, Subscription};
@@ -116,3 +116,16 @@ fn avatar_facing_constructors_preserve_native_defaults_and_catalog_errors() {
"M:LibreMetaverse.InventoryException.#ctor" "M:LibreMetaverse.InventoryException.#ctor"
); );
} }
#[test]
fn built_in_animation_dictionary_matches_the_pinned_source_catalog() {
let catalog = Animations::to_dictionary().expect("animation dictionary").0;
assert_eq!(catalog.len(), 135);
assert_eq!(
catalog.get(&Animations::dance1()).map(String::as_str),
Some("DANCE1")
);
let yoga = UUID::new_with_string("42ecd00b-9947-a97c-400a-bbc9174c7aeb".into())
.expect("YOGA_FLOAT UUID");
assert_eq!(catalog.get(&yoga).map(String::as_str), Some("YOGA_FLOAT"));
}

View File

@@ -624,6 +624,8 @@ NATIVE_GENERATED_TYPES = {
NATIVE_MEMBER_BODIES = { NATIVE_MEMBER_BODIES = {
"F:LibreMetaverse.Animations.DANCE1": "F:LibreMetaverse.Animations.DANCE1":
"crate::animation::dance1()", "crate::animation::dance1()",
"M:LibreMetaverse.Animations.ToDictionary":
"Ok(libremetaverse_types::compat::FrozenDictionary(crate::animation::builtin_animations()))",
"M:LibreMetaverse.RLV.RlvCommon.TryGetAttachmentPointFromItemName(System.String,System.Nullable{LibreMetaverse.RLV.RlvAttachmentPoint}@)": "M:LibreMetaverse.RLV.RlvCommon.TryGetAttachmentPointFromItemName(System.String,System.Nullable{LibreMetaverse.RLV.RlvAttachmentPoint}@)":
"Self::native_try_get_attachment_point_from_item_name(item_name, attachment_point)", "Self::native_try_get_attachment_point_from_item_name(item_name, attachment_point)",
"M:LibreMetaverse.RLV.RlvRestriction.#ctor(LibreMetaverse.RLV.RlvRestrictionType,System.Guid,System.String,System.Collections.Generic.ICollection{System.Object})": "M:LibreMetaverse.RLV.RlvRestriction.#ctor(LibreMetaverse.RLV.RlvRestrictionType,System.Guid,System.String,System.Collections.Generic.ICollection{System.Object})":