Implement grid and directory discovery (#71)
All checks were successful
Native code generation / deterministic (push) Successful in 15m16s
Imaging and meshing gate / native (push) Successful in 5m9s
JPEG 2000 feature / linux (push) Successful in 2m47s
Native Rust workspace compile / compile (push) Successful in 5m14s
Skia feature / linux (push) Successful in 31m28s

This commit is contained in:
2026-08-10 17:45:08 +00:00
parent d537a37172
commit 66fbb87b3f
14 changed files with 3949 additions and 1541 deletions

View File

@@ -413,3 +413,11 @@ parcel-versus-region URI rules, and sound packets retain identity, gain,
position, and queue flags. Packet/capability selection, state-before-event position, and queue flags. Packet/capability selection, state-before-event
ordering, cancellation, limits, and live-operation safety are documented in ordering, cancellation, limits, and live-operation safety are documented in
[`docs/land.md`](docs/land.md). [`docs/land.md`](docs/land.md).
Grid and directory discovery now use native LLUDP map and search requests,
correlated bounded reply events, expiring region indexes, coarse-location
deltas, and exact region-handle math. SLURL parsing covers location and
application forms with stable escaping, while interest-list modes and unknown
simulator features are preserved through LLSD capabilities. The cache,
pagination, cancellation, and compatibility contracts are documented in
[`docs/discovery.md`](docs/discovery.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: 290 types / 16,219 members; remaining surface is callable failure-only shims | | `LibreMetaverse` | 2,711 | 27,281 | native implementation: 326 types / 16,464 members; remaining surface is callable failure-only shims |
| `LibreMetaverse.Imaging.Abstractions` | 3 | 20 | native implementation: 3 types / 20 members; no generated shims remain | | `LibreMetaverse.Imaging.Abstractions` | 3 | 20 | native implementation: 3 types / 20 members; no generated shims remain |
| `LibreMetaverse.Imaging.Skia` | 1 | 3 | native implementation: 1 type / 3 members; no generated shims remain | | `LibreMetaverse.Imaging.Skia` | 1 | 3 | native implementation: 1 type / 3 members; no generated shims remain |
| `LibreMetaverse.LslTools` | 164 | 768 | callable failure-only shim | | `LibreMetaverse.LslTools` | 164 | 768 | callable failure-only shim |

View File

@@ -1003,7 +1003,153 @@ pub struct Span<T>(pub PhantomData<fn(T)>);
pub struct RandomSource; pub struct RandomSource;
pub struct IAsyncEnumerable<T>(pub PhantomData<fn() -> T>); struct AsyncEnumerableState<T> {
values: std::collections::VecDeque<T>,
closed: bool,
waiters: Vec<Waker>,
cancellation: Option<CancellationToken>,
}
/// A cloneable, producer-driven equivalent of `IAsyncEnumerable<T>`.
///
/// Native managers keep a producer clone and publish correlated protocol
/// results. Consumers call [`IAsyncEnumerable::next`] until it returns `None`.
pub struct IAsyncEnumerable<T>(Arc<Mutex<AsyncEnumerableState<T>>>);
impl<T> Clone for IAsyncEnumerable<T> {
fn clone(&self) -> Self {
Self(Arc::clone(&self.0))
}
}
impl<T> Default for IAsyncEnumerable<T> {
fn default() -> Self {
Self::new()
}
}
impl<T> IAsyncEnumerable<T> {
#[must_use]
pub fn new() -> Self {
Self(Arc::new(Mutex::new(AsyncEnumerableState {
values: std::collections::VecDeque::new(),
closed: false,
waiters: Vec::new(),
cancellation: None,
})))
}
#[must_use]
pub fn with_cancellation(cancellation: CancellationToken) -> Self {
let value = Self::new();
value
.0
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.cancellation = Some(cancellation);
value
}
/// Publishes one item and wakes pending consumers.
pub fn push(&self, value: T) {
let waiters = {
let mut state = self
.0
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if state
.cancellation
.as_ref()
.is_some_and(CancellationToken::is_cancellation_requested)
{
state.closed = true;
}
if state.closed {
return;
}
state.values.push_back(value);
std::mem::take(&mut state.waiters)
};
for waiter in waiters {
waiter.wake();
}
}
/// Completes the sequence after already queued items are consumed.
pub fn close(&self) {
let waiters = {
let mut state = self
.0
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
state.closed = true;
std::mem::take(&mut state.waiters)
};
for waiter in waiters {
waiter.wake();
}
}
#[must_use]
pub fn next(&self) -> AsyncEnumerableNext<T> {
let cancellation = self
.0
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.cancellation
.clone()
.map(|token| token.cancelled());
AsyncEnumerableNext {
state: Arc::clone(&self.0),
registered: false,
cancellation,
}
}
}
pub struct AsyncEnumerableNext<T> {
state: Arc<Mutex<AsyncEnumerableState<T>>>,
registered: bool,
cancellation: Option<CancellationFuture>,
}
impl<T> Future for AsyncEnumerableNext<T> {
type Output = Option<T>;
fn poll(mut self: Pin<&mut Self>, context: &mut Context<'_>) -> Poll<Self::Output> {
let should_register = !self.registered;
if let Some(cancelled) = self.cancellation.as_mut()
&& Pin::new(cancelled).poll(context).is_ready()
{
self.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.closed = true;
}
let outcome = {
let mut state = self
.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if let Some(value) = state.values.pop_front() {
Some(Poll::Ready(Some(value)))
} else if state.closed {
Some(Poll::Ready(None))
} else {
if should_register {
state.waiters.push(context.waker().clone());
}
None
}
};
if let Some(outcome) = outcome {
outcome
} else {
self.registered = true;
Poll::Pending
}
}
}
pub struct ImmutableDictionary<TKey, TValue>(pub PhantomData<fn(TKey, TValue)>); pub struct ImmutableDictionary<TKey, TValue>(pub PhantomData<fn(TKey, TValue)>);

View File

@@ -375,7 +375,10 @@ async fn run_seed_requests(weak: Weak<CapsInner>, cancellation: CancellationToke
.await; .await;
match response { match response {
Ok((response, data)) if response.is_success_status_code() => { Ok((response, data)) if response.is_success_status_code() => {
if install_seed_response(&inner, &simulator, data).is_ok() { if install_seed_response(&inner, &simulator, data, cancellation.clone())
.await
.is_ok()
{
return; return;
} }
} }
@@ -392,10 +395,11 @@ async fn run_seed_requests(weak: Weak<CapsInner>, cancellation: CancellationToke
} }
} }
fn install_seed_response( async fn install_seed_response(
inner: &Arc<CapsInner>, inner: &Arc<CapsInner>,
simulator: &Simulator, simulator: &Simulator,
data: Vec<u8>, data: Vec<u8>,
cancellation: CancellationToken,
) -> Result<(), Error> { ) -> Result<(), Error> {
let OSD::Map(map) = OSDParser::deserialize_with_bytes(data)? else { let OSD::Map(map) = OSDParser::deserialize_with_bytes(data)? else {
return Err(Error::Parse { return Err(Error::Parse {
@@ -439,6 +443,24 @@ fn install_seed_response(
queue.start()?; queue.start()?;
*mutex(&inner.event_queue) = Some(queue); *mutex(&inner.event_queue) = Some(queue);
} }
// SimulatorFeatures is a GET capability, not merely an event-queue
// message. Load its initial snapshot before announcing that capability
// discovery is complete, while still allowing grids that omit or reject
// the optional endpoint to finish seed discovery normally.
let simulator_features_uri = read(&inner.caps).get("SimulatorFeatures").cloned();
if let Some(uri) = simulator_features_uri
&& let Ok((response, bytes)) = simulator
.client
.native_http_caps_client()
.get(uri, cancellation.clone(), None)
.await
&& response.is_success_status_code()
&& bytes.len() <= 8 * 1024 * 1024
{
let _ = simulator.features.set_features(None, Some(bytes), None);
}
cancellation.throw_if_cancellation_requested()?;
inner.emit_capabilities_received(simulator.clone()); inner.emit_capabilities_received(simulator.clone());
Ok(()) Ok(())
} }
@@ -635,6 +657,10 @@ mod tests {
"GetTexture".to_owned(), "GetTexture".to_owned(),
OSD::Uri(Uri("https://asset.example.test/texture".to_owned())), OSD::Uri(Uri("https://asset.example.test/texture".to_owned())),
), ),
(
"SimulatorFeatures".to_owned(),
OSD::Uri(Uri("https://caps.example.test/features".to_owned())),
),
( (
"InvalidScheme".to_owned(), "InvalidScheme".to_owned(),
OSD::Uri(Uri("file:///private/capability".to_owned())), OSD::Uri(Uri("file:///private/capability".to_owned())),
@@ -654,12 +680,22 @@ mod tests {
let release = Arc::clone(&release); let release = Arc::clone(&release);
async move { async move {
let is_seed = request.uri.0.contains("/seed"); let is_seed = request.uri.0.contains("/seed");
let is_features = request.uri.0.contains("/features");
lock(&recorded).push(request); lock(&recorded).push(request);
if is_seed { if is_seed {
while !release.load(Ordering::Acquire) { while !release.load(Ordering::Acquire) {
tokio::time::sleep(Duration::from_millis(1)).await; tokio::time::sleep(Duration::from_millis(1)).await;
} }
response(200, seed_body()) response(200, seed_body())
} else if is_features {
response(
200,
OSDParser::serialize_llsd_xml_bytes(OSD::Map(HashMap::from([(
"MeshRezEnabled".to_owned(),
OSD::Boolean(true),
)])))
.unwrap(),
)
} else { } else {
response(404, Vec::new()) response(404, Vec::new())
} }
@@ -708,6 +744,10 @@ mod tests {
None None
); );
assert!(caps.event_queue().is_some()); assert!(caps.event_queue().is_some());
assert_eq!(
simulator.features.get("MeshRezEnabled".to_owned()).unwrap(),
Some(OSD::Boolean(true))
);
let deadline = Instant::now() + Duration::from_secs(2); let deadline = Instant::now() + Duration::from_secs(2);
while lock(&requests).len() < 2 && Instant::now() < deadline { while lock(&requests).len() < 2 && Instant::now() < deadline {

View File

@@ -123,6 +123,9 @@ struct ClientRuntime {
terrain_manager: Mutex<Option<Arc<crate::terrain_manager::TerrainManagerInner>>>, terrain_manager: Mutex<Option<Arc<crate::terrain_manager::TerrainManagerInner>>>,
sound_manager: Mutex<Option<Arc<crate::sound_manager::SoundManagerInner>>>, sound_manager: Mutex<Option<Arc<crate::sound_manager::SoundManagerInner>>>,
parcel_manager: Mutex<Option<Arc<crate::parcel_manager::ParcelManagerInner>>>, parcel_manager: Mutex<Option<Arc<crate::parcel_manager::ParcelManagerInner>>>,
grid_manager: Mutex<Option<Arc<crate::grid_manager::GridManagerInner>>>,
directory_manager: Mutex<Option<Arc<crate::directory_manager::DirectoryManagerInner>>>,
interest_list_manager: Mutex<Option<Arc<crate::interest_list::InterestListManagerInner>>>,
appearance_manager: Mutex<Option<Arc<crate::appearance_manager::AppearanceManagerInner>>>, appearance_manager: Mutex<Option<Arc<crate::appearance_manager::AppearanceManagerInner>>>,
avatar_manager: Mutex<Option<Arc<crate::avatar_manager::AvatarManagerInner>>>, avatar_manager: Mutex<Option<Arc<crate::avatar_manager::AvatarManagerInner>>>,
animesh_manager: Mutex<Option<Arc<crate::animesh_runtime::AnimeshManagerInner>>>, animesh_manager: Mutex<Option<Arc<crate::animesh_runtime::AnimeshManagerInner>>>,
@@ -162,6 +165,9 @@ impl ClientRuntime {
terrain_manager: Mutex::new(None), terrain_manager: Mutex::new(None),
sound_manager: Mutex::new(None), sound_manager: Mutex::new(None),
parcel_manager: Mutex::new(None), parcel_manager: Mutex::new(None),
grid_manager: Mutex::new(None),
directory_manager: Mutex::new(None),
interest_list_manager: Mutex::new(None),
appearance_manager: Mutex::new(None), appearance_manager: Mutex::new(None),
avatar_manager: Mutex::new(None), avatar_manager: Mutex::new(None),
animesh_manager: Mutex::new(None), animesh_manager: Mutex::new(None),
@@ -252,6 +258,18 @@ impl ClientRuntime {
.lock() .lock()
.unwrap_or_else(std::sync::PoisonError::into_inner) .unwrap_or_else(std::sync::PoisonError::into_inner)
.take(); .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.http_caps_client self.http_caps_client
.lock() .lock()
.unwrap_or_else(std::sync::PoisonError::into_inner) .unwrap_or_else(std::sync::PoisonError::into_inner)
@@ -845,6 +863,121 @@ impl GridClient {
.unwrap_or_else(std::sync::PoisonError::into_inner) = Some(value.inner); .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(value.inner);
} }
pub(crate) fn cached_grid_manager_inner(
&self,
) -> Option<Arc<crate::grid_manager::GridManagerInner>> {
self.runtime
.grid_manager
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clone()
}
pub(crate) fn install_grid_manager_inner(
&self,
candidate: Arc<crate::grid_manager::GridManagerInner>,
) -> Arc<crate::grid_manager::GridManagerInner> {
let mut cached = self
.runtime
.grid_manager
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if let Some(inner) = cached.as_ref() {
return Arc::clone(inner);
}
*cached = Some(Arc::clone(&candidate));
candidate
}
pub(crate) fn native_grid(&self) -> Result<crate::GridManager, crate::Error> {
if let Some(inner) = self.cached_grid_manager_inner() {
Ok(crate::GridManager { inner })
} else {
crate::grid_manager::GridManager::native_new(Arc::new(self.clone()))
}
}
pub(crate) fn native_set_grid(&self, value: crate::GridManager) {
*self
.runtime
.grid_manager
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner) = Some(value.inner);
}
pub(crate) fn cached_directory_manager_inner(
&self,
) -> Option<Arc<crate::directory_manager::DirectoryManagerInner>> {
self.runtime
.directory_manager
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clone()
}
pub(crate) fn install_directory_manager_inner(
&self,
candidate: Arc<crate::directory_manager::DirectoryManagerInner>,
) -> Arc<crate::directory_manager::DirectoryManagerInner> {
let mut cached = self
.runtime
.directory_manager
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if let Some(inner) = cached.as_ref() {
return Arc::clone(inner);
}
*cached = Some(Arc::clone(&candidate));
candidate
}
pub(crate) fn native_directory(&self) -> Result<crate::DirectoryManager, crate::Error> {
if let Some(inner) = self.cached_directory_manager_inner() {
Ok(crate::DirectoryManager { inner })
} else {
crate::directory_manager::DirectoryManager::native_new(Arc::new(self.clone()))
}
}
pub(crate) fn native_set_directory(&self, value: crate::DirectoryManager) {
*self
.runtime
.directory_manager
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner) = Some(value.inner);
}
pub(crate) fn cached_interest_list_manager_inner(
&self,
) -> Option<Arc<crate::interest_list::InterestListManagerInner>> {
self.runtime
.interest_list_manager
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clone()
}
pub(crate) fn install_interest_list_manager_inner(
&self,
candidate: Arc<crate::interest_list::InterestListManagerInner>,
) -> Arc<crate::interest_list::InterestListManagerInner> {
let mut cached = self
.runtime
.interest_list_manager
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if let Some(inner) = cached.as_ref() {
return Arc::clone(inner);
}
*cached = Some(Arc::clone(&candidate));
candidate
}
pub(crate) fn native_interest_list(&self) -> Result<crate::InterestListManager, crate::Error> {
if let Some(inner) = self.cached_interest_list_manager_inner() {
Ok(crate::InterestListManager { inner })
} else {
crate::interest_list::InterestListManager::native_new(Arc::new(self.clone()))
}
}
pub(crate) fn native_set_interest_list(&self, value: crate::InterestListManager) {
*self
.runtime
.interest_list_manager
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner) = Some(value.inner);
}
pub(crate) fn native_set_caps_rate_limiter( pub(crate) fn native_set_caps_rate_limiter(
&mut self, &mut self,
value: crate::caps_http::CapsRateLimiter, value: crate::caps_http::CapsRateLimiter,

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,237 @@
//! Capability-backed interest-list mode control.
#![allow(clippy::missing_errors_doc)]
#![allow(clippy::must_use_candidate)]
#![allow(clippy::needless_pass_by_value)]
#![allow(clippy::unnecessary_wraps)] // Fixed mapped constructor is fallible.
use crate::{Error, GridClient, Simulator};
use libremetaverse_structured_data::{OSD, OSDMap, OSDParser};
use libremetaverse_types::compat::CancellationToken;
use std::sync::{Arc, RwLock};
#[derive(Clone, Debug)]
pub struct InterestListMessage {
mode: String,
interest_list_mode: crate::messages::linden::InterestListMode,
}
impl InterestListMessage {
pub fn new() -> Result<Self, Error> {
Ok(Self {
mode: "default".into(),
interest_list_mode: crate::messages::linden::InterestListMode::Default,
})
}
pub fn deserialize(&mut self, map: OSDMap) -> Result<(), Error> {
let mode = map
.get("mode")
.map(|value| value.as_string())
.transpose()?
.unwrap_or_else(|| "default".into());
self.set_mode(mode);
Ok(())
}
pub fn serialize(&self) -> Result<OSDMap, Error> {
OSDMap::new_with_dictionary(std::collections::HashMap::from([(
"mode".into(),
OSD::String(self.mode.clone()),
)]))
}
pub fn interest_list_mode(&self) -> crate::messages::linden::InterestListMode {
self.interest_list_mode
}
pub fn set_interest_list_mode(&mut self, value: crate::messages::linden::InterestListMode) {
self.interest_list_mode = value;
self.mode = mode_name(value).into();
}
pub fn mode(&self) -> String {
self.mode.clone()
}
pub fn set_mode(&mut self, value: String) {
self.interest_list_mode = if matches!(
value.to_ascii_lowercase().as_str(),
"360" | "panoramic360" | "panoramic_360"
) {
crate::messages::linden::InterestListMode::Panoramic360
} else {
crate::messages::linden::InterestListMode::Default
};
self.mode = mode_name(self.interest_list_mode).into();
}
}
impl crate::interfaces::IMessage for InterestListMessage {
fn deserialize(&mut self, map: OSDMap) -> Result<(), Error> {
InterestListMessage::deserialize(self, map)
}
fn serialize(&self) -> Result<OSDMap, Error> {
InterestListMessage::serialize(self)
}
}
fn mode_name(mode: crate::messages::linden::InterestListMode) -> &'static str {
match mode {
crate::messages::linden::InterestListMode::Default => "default",
crate::messages::linden::InterestListMode::Panoramic360 => "360",
}
}
pub(crate) struct InterestListManagerInner {
client: Arc<GridClient>,
current_mode: RwLock<crate::messages::linden::InterestListMode>,
}
#[derive(Clone)]
pub struct InterestListManager {
pub(crate) inner: Arc<InterestListManagerInner>,
}
impl InterestListManager {
pub fn new(client: GridClient) -> Result<Self, Error> {
Self::native_new(Arc::new(client))
}
pub(crate) fn native_new(client: Arc<GridClient>) -> Result<Self, Error> {
if let Some(inner) = client.cached_interest_list_manager_inner() {
return Ok(Self { inner });
}
let inner = Arc::new(InterestListManagerInner {
client: Arc::clone(&client),
current_mode: RwLock::new(crate::messages::linden::InterestListMode::Default),
});
Ok(Self {
inner: client.install_interest_list_manager_inner(inner),
})
}
pub fn dispose(&self) -> Result<(), Error> {
Ok(())
}
pub fn current_mode(&self) -> crate::messages::linden::InterestListMode {
*self
.inner
.current_mode
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
pub async fn reset(&self, cancellation_token: Option<CancellationToken>) -> Result<(), Error> {
self.set_mode(
crate::messages::linden::InterestListMode::Default,
cancellation_token,
)
.await
}
pub async fn set_mode(
&self,
mode: crate::messages::linden::InterestListMode,
cancellation_token: Option<CancellationToken>,
) -> Result<(), Error> {
let token = cancellation_token.unwrap_or_else(|| self.inner.client.cancellation_token());
token.throw_if_cancellation_requested()?;
let simulations = self.inner.client.network().simulators.snapshot();
let mut accepted = false;
for simulator in simulations {
if self
.set_mode_on_sim(simulator, mode, Some(token.clone()))
.await?
{
accepted = true;
}
}
if accepted || self.inner.client.network().simulators.is_empty() {
*self
.inner
.current_mode
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner) = mode;
}
Ok(())
}
pub async fn reset_on_sim(
&self,
simulator: Simulator,
cancellation_token: Option<CancellationToken>,
) -> Result<bool, Error> {
self.set_mode_on_sim(
simulator,
crate::messages::linden::InterestListMode::Default,
cancellation_token,
)
.await
}
pub async fn set_mode_on_sim(
&self,
simulator: Simulator,
mode: crate::messages::linden::InterestListMode,
cancellation_token: Option<CancellationToken>,
) -> Result<bool, Error> {
let token = cancellation_token.unwrap_or_else(|| self.inner.client.cancellation_token());
token.throw_if_cancellation_requested()?;
let Some(caps) = simulator.native_caps() else {
return Ok(false);
};
let Some(uri) = caps.capability_uri("InterestList".into())? else {
return Ok(false);
};
let mut message = InterestListMessage::new()?;
message.set_interest_list_mode(mode);
let body = OSDParser::serialize_llsd_xml_bytes(OSD::Map(message.serialize()?.snapshot()))?;
let (response, bytes) = self
.inner
.client
.native_http_caps_client()
.post_with_uri_string_bytes_cancellation_token_i_progress(
uri,
"application/llsd+xml".into(),
body,
token.clone(),
None,
)
.await?;
token.throw_if_cancellation_requested()?;
if bytes.len() > 1024 * 1024 {
return Err(Error::Argument);
}
let ok = (200..300).contains(&response.status_code);
if ok {
*self
.inner
.current_mode
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner) = mode;
}
Ok(ok)
}
}
#[cfg(test)]
mod tests {
use super::*;
use libremetaverse_types::compat::CancellationTokenSource;
#[test]
fn message_round_trip() {
let mut value = InterestListMessage::new().unwrap();
value.set_interest_list_mode(crate::messages::linden::InterestListMode::Panoramic360);
let mut copy = InterestListMessage::new().unwrap();
copy.deserialize(value.serialize().unwrap()).unwrap();
assert_eq!(copy.mode(), "360");
assert_eq!(
copy.interest_list_mode(),
crate::messages::linden::InterestListMode::Panoramic360
);
}
#[tokio::test]
async fn cancelled_mode_change_does_not_commit() {
let client = GridClient::new().unwrap();
let manager = InterestListManager::new(client).unwrap();
let source = CancellationTokenSource::new();
source.cancel();
assert_eq!(
manager
.set_mode(
crate::messages::linden::InterestListMode::Panoramic360,
Some(source.token())
)
.await,
Err(Error::Cancelled)
);
assert_eq!(
manager.current_mode(),
crate::messages::linden::InterestListMode::Default
);
}
}

View File

@@ -48,10 +48,15 @@ mod object_material;
mod object_model; mod object_model;
#[rustfmt::skip] #[rustfmt::skip]
pub mod packet_catalog; pub mod packet_catalog;
mod directory_manager;
mod grid_manager;
pub use grid_manager::MapItemData;
mod interest_list;
mod packet_wire; mod packet_wire;
mod parcel_manager; mod parcel_manager;
mod sim_stats; mod sim_stats;
mod skeleton; mod skeleton;
mod slurl;
mod sound_manager; mod sound_manager;
#[rustfmt::skip] // Deterministic machine output is formatted by the pinned generator. #[rustfmt::skip] // Deterministic machine output is formatted by the pinned generator.
mod skeleton_catalog; mod skeleton_catalog;

View File

@@ -1135,7 +1135,7 @@ impl Simulator {
client, client,
colo_location: String::new(), colo_location: String::new(),
data_pool: None, data_pool: None,
features: SimulatorFeatures, features: SimulatorFeatures::default(),
flags: RegionFlags(0), flags: RegionFlags(0),
global_to_local_id: RwLock::new(HashMap::new()), global_to_local_id: RwLock::new(HashMap::new()),
handle, handle,

View File

@@ -0,0 +1,430 @@
//! Second Life location and application URI parsing and formatting.
#![allow(clippy::missing_errors_doc)]
#![allow(clippy::must_use_candidate)]
#![allow(clippy::needless_pass_by_value)]
use crate::{Error, SlappCommand, ViewerUriType};
use libremetaverse_types::UUID;
use std::collections::{BTreeMap, HashMap};
use std::fmt::Write;
const DEFAULT_X: i32 = 128;
const DEFAULT_Y: i32 = 128;
const DEFAULT_Z: i32 = 0;
const MAX_URI_BYTES: usize = 1024 * 1024;
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct SlurlParser {
command: SlappCommand,
command_path: String,
query_parameters: HashMap<String, String>,
sim: String,
uri_type: ViewerUriType,
x: i32,
y: i32,
z: i32,
}
fn command_from_str(value: &str) -> SlappCommand {
match value.to_ascii_lowercase().as_str() {
"agent" => SlappCommand::Agent,
"appearance" => SlappCommand::Appearance,
"balance" => SlappCommand::Balance,
"chat" => SlappCommand::Chat,
"classified" => SlappCommand::Classified,
"event" => SlappCommand::Event,
"experience" => SlappCommand::Experience,
"group" => SlappCommand::Group,
"help" => SlappCommand::Help,
"inventory" => SlappCommand::Inventory,
"keybinding" => SlappCommand::Keybinding,
"login" => SlappCommand::Login,
"maptrackavatar" => SlappCommand::MapTrackAvatar,
"objectim" => SlappCommand::ObjectIm,
"openfloater" => SlappCommand::OpenFloater,
"parcel" => SlappCommand::Parcel,
"region" => SlappCommand::Region,
"search" => SlappCommand::Search,
"sharewithavatar" => SlappCommand::ShareWithAvatar,
"teleport" => SlappCommand::Teleport,
"voicecallavatar" => SlappCommand::VoiceCallAvatar,
"wear_folder" => SlappCommand::WearFolder,
"worldmap" => SlappCommand::WorldMap,
_ => SlappCommand::Unknown,
}
}
fn command_name(command: SlappCommand) -> &'static str {
match command {
SlappCommand::Agent => "agent",
SlappCommand::Appearance => "appearance",
SlappCommand::Balance => "balance",
SlappCommand::Chat => "chat",
SlappCommand::Classified => "classified",
SlappCommand::Event => "event",
SlappCommand::Experience => "experience",
SlappCommand::Group => "group",
SlappCommand::Help => "help",
SlappCommand::Inventory => "inventory",
SlappCommand::Keybinding => "keybinding",
SlappCommand::Login => "login",
SlappCommand::MapTrackAvatar => "maptrackavatar",
SlappCommand::ObjectIm => "objectim",
SlappCommand::OpenFloater => "openfloater",
SlappCommand::Parcel => "parcel",
SlappCommand::Region => "region",
SlappCommand::Search => "search",
SlappCommand::ShareWithAvatar => "sharewithavatar",
SlappCommand::Teleport => "teleport",
SlappCommand::VoiceCallAvatar => "voicecallavatar",
SlappCommand::WearFolder => "wear_folder",
SlappCommand::WorldMap => "worldmap",
SlappCommand::Unknown => "unknown",
}
}
fn encode(value: &str) -> String {
let mut result = String::with_capacity(value.len());
for byte in value.bytes() {
if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~') {
result.push(char::from(byte));
} else {
result.push('%');
let _ = write!(result, "{byte:02X}");
}
}
result
}
fn decode(value: &str) -> Result<String, Error> {
let bytes = value.as_bytes();
let mut output = Vec::with_capacity(bytes.len());
let mut index = 0;
while index < bytes.len() {
match bytes[index] {
b'%' if index + 2 < bytes.len() => {
let text = std::str::from_utf8(&bytes[index + 1..index + 3])
.map_err(|_| Error::Argument)?;
output.push(u8::from_str_radix(text, 16).map_err(|_| Error::Argument)?);
index += 3;
}
b'+' => {
output.push(b' ');
index += 1;
}
byte => {
output.push(byte);
index += 1;
}
}
}
String::from_utf8(output).map_err(|_| Error::Argument)
}
fn parse_query(value: Option<&str>) -> Result<HashMap<String, String>, Error> {
let mut result = HashMap::new();
let Some(value) = value else {
return Ok(result);
};
for pair in value.split('&').filter(|pair| !pair.is_empty()).take(4096) {
let (key, value) = pair.split_once('=').unwrap_or((pair, ""));
result.insert(decode(key)?, decode(value)?);
}
Ok(result)
}
fn parse_coordinate(value: Option<&&str>, default: i32) -> Result<i32, Error> {
match value.filter(|value| !value.is_empty()) {
Some(value) => value.parse().map_err(|_| Error::Argument),
None => Ok(default),
}
}
impl SlurlParser {
pub fn new(location: Option<String>) -> Result<Self, Error> {
let location = location.ok_or(Error::ArgumentNull)?;
if location.is_empty() || location.len() > MAX_URI_BYTES {
return Err(Error::Argument);
}
if let Some(application) = location.strip_prefix("secondlife:///app/") {
return Self::parse_application(application);
}
let raw = if let Some(value) = location.strip_prefix("secondlife://") {
value
} else if let Some(value) = location
.strip_prefix("http://maps.secondlife.com/secondlife/")
.or_else(|| location.strip_prefix("https://maps.secondlife.com/secondlife/"))
{
value
} else if let Some(value) = location.strip_prefix("uri:") {
let values: Vec<_> = value.split('&').collect();
return Self::from_location_parts(&values);
} else {
location.as_str()
};
let values: Vec<_> = raw.trim_matches('/').split('/').collect();
Self::from_location_parts(&values)
}
fn from_location_parts(values: &[&str]) -> Result<Self, Error> {
let Some(region) = values.first().filter(|value| !value.is_empty()) else {
return Err(Error::Argument);
};
Ok(Self {
command: SlappCommand::Unknown,
command_path: String::new(),
query_parameters: HashMap::new(),
sim: decode(region)?,
uri_type: ViewerUriType::Location,
x: parse_coordinate(values.get(1), DEFAULT_X)?,
y: parse_coordinate(values.get(2), DEFAULT_Y)?,
z: parse_coordinate(values.get(3), DEFAULT_Z)?,
})
}
fn parse_application(application: &str) -> Result<Self, Error> {
let (path, query) = application
.split_once('?')
.map_or((application, None), |(path, query)| (path, Some(query)));
let command_path = path.trim_start_matches('/').to_owned();
let parts: Vec<_> = command_path.split('/').collect();
let command = command_from_str(parts.first().copied().unwrap_or_default());
let application_location =
if matches!(command, SlappCommand::Teleport | SlappCommand::WorldMap) {
Some(Self::from_location_parts(
parts.get(1..).unwrap_or_default(),
)?)
} else {
None
};
let mut result = Self {
command,
command_path,
query_parameters: parse_query(query)?,
sim: String::new(),
uri_type: ViewerUriType::Application,
x: DEFAULT_X,
y: DEFAULT_Y,
z: DEFAULT_Z,
};
if let Some(location) = application_location {
result.sim = location.sim;
result.x = location.x;
result.y = location.y;
result.z = location.z;
}
Ok(result)
}
pub fn get_agent_url(agent_id: UUID, action: Option<String>) -> Result<String, Error> {
Self::get_slapp_url(
SlappCommand::Agent,
Some(format!(
"{agent_id}/{}",
encode(action.as_deref().unwrap_or("about"))
)),
None,
)
}
pub fn get_group_url(group_id: UUID, action: Option<String>) -> Result<String, Error> {
Self::get_slapp_url(
SlappCommand::Group,
Some(format!(
"{group_id}/{}",
encode(action.as_deref().unwrap_or("about"))
)),
None,
)
}
pub fn get_login_url(
last_name: Option<String>,
session_id: Option<String>,
location: Option<String>,
) -> Result<String, Error> {
let mut query = HashMap::new();
if let Some(value) = last_name {
query.insert("last".into(), value);
}
if let Some(value) = session_id {
query.insert("session".into(), value);
}
if let Some(value) = location {
query.insert("location".into(), value);
}
Self::get_slapp_url(
SlappCommand::Login,
None,
(!query.is_empty()).then_some(query),
)
}
pub fn get_object_im_url(
object_id: UUID,
object_name: String,
owner_id: UUID,
group_owned: bool,
slurl: String,
) -> Result<String, Error> {
let mut query = HashMap::from([
("name".into(), object_name),
("owner".into(), owner_id.to_string()),
("slurl".into(), slurl),
]);
if group_owned {
query.insert("groupowned".into(), "true".into());
}
Self::get_slapp_url(
SlappCommand::ObjectIm,
Some(object_id.to_string()),
Some(query),
)
}
pub fn get_raw_location(&self) -> Result<String, Error> {
if !self.is_location() {
return Err(Error::InvalidOperation);
}
Ok(format!("{}/{}/{}/{}", self.sim, self.x, self.y, self.z))
}
pub fn get_search_url(category: String, search_term: String) -> Result<String, Error> {
Self::get_slapp_url(
SlappCommand::Search,
Some(format!("{}/{}", encode(&category), encode(&search_term))),
None,
)
}
pub fn get_slapp_url(
command: SlappCommand,
path: Option<String>,
query_params: Option<HashMap<String, String>>,
) -> Result<String, Error> {
if command == SlappCommand::Unknown {
return Err(Error::Argument);
}
let mut result = format!("secondlife:///app/{}", command_name(command));
if let Some(path) = path.filter(|path| !path.is_empty()) {
result.push('/');
result.push_str(path.trim_start_matches('/'));
}
if let Some(query) = query_params.filter(|query| !query.is_empty()) {
let ordered: BTreeMap<_, _> = query.into_iter().collect();
result.push('?');
for (index, (key, value)) in ordered.iter().enumerate() {
if index != 0 {
result.push('&');
}
result.push_str(&encode(key));
result.push('=');
result.push_str(&encode(value));
}
}
if result.len() > MAX_URI_BYTES {
return Err(Error::Argument);
}
Ok(result)
}
pub fn get_slurl(&self) -> Result<String, Error> {
Ok(format!(
"secondlife://{}/{}/{}/{}/",
encode(&self.sim),
self.x,
self.y,
self.z
))
}
pub fn get_start_location_uri(&self) -> Result<String, Error> {
Ok(format!("uri:{}&{}&{}&{}", self.sim, self.x, self.y, self.z))
}
pub fn get_teleport_url(
region: String,
x: Option<i32>,
y: Option<i32>,
z: Option<i32>,
) -> Result<String, Error> {
Self::location_application(SlappCommand::Teleport, region, x, y, z)
}
pub fn get_world_map_url(
region: String,
x: Option<i32>,
y: Option<i32>,
z: Option<i32>,
) -> Result<String, Error> {
Self::location_application(SlappCommand::WorldMap, region, x, y, z)
}
fn location_application(
command: SlappCommand,
region: String,
x: Option<i32>,
y: Option<i32>,
z: Option<i32>,
) -> Result<String, Error> {
if region.is_empty() {
return Err(Error::Argument);
}
Self::get_slapp_url(
command,
Some(format!(
"{}/{}/{}/{}",
encode(&region),
x.unwrap_or(DEFAULT_X),
y.unwrap_or(DEFAULT_Y),
z.unwrap_or(DEFAULT_Z)
)),
None,
)
}
pub fn command(&self) -> SlappCommand {
self.command
}
pub fn set_command(&mut self, value: SlappCommand) {
self.command = value;
}
pub fn command_path(&self) -> String {
self.command_path.clone()
}
pub fn set_command_path(&mut self, value: String) {
self.command_path = value;
}
pub fn is_application(&self) -> bool {
self.uri_type == ViewerUriType::Application
}
pub fn is_location(&self) -> bool {
self.uri_type == ViewerUriType::Location
}
pub fn query_parameters(&self) -> HashMap<String, String> {
self.query_parameters.clone()
}
pub fn set_query_parameters(&mut self, value: HashMap<String, String>) {
self.query_parameters = value;
}
pub fn sim(&self) -> String {
self.sim.clone()
}
pub fn set_sim(&mut self, value: String) {
self.sim = value;
}
pub fn uri_type(&self) -> ViewerUriType {
self.uri_type
}
pub fn set_uri_type(&mut self, value: ViewerUriType) {
self.uri_type = value;
}
pub fn x(&self) -> i32 {
self.x
}
pub fn set_x(&mut self, value: i32) {
self.x = value;
}
pub fn y(&self) -> i32 {
self.y
}
pub fn set_y(&mut self, value: i32) {
self.y = value;
}
pub fn z(&self) -> i32 {
self.z
}
pub fn set_z(&mut self, value: i32) {
self.z = value;
}
}

43
docs/discovery.md Normal file
View File

@@ -0,0 +1,43 @@
# Grid and directory discovery
`GridClient.Grid`, `GridClient.Directory`, and `GridClient.InterestList` are
client-owned managers. Repeated property access returns handles to the same
state, and disposing the client releases their packet and capability
subscriptions.
`GridManager` uses the advertised `MapLayer` capability with an LLUDP fallback,
and sends standard map-name, map-block, map-item, and region-handle LLUDP
requests. Replies update the name, handle, and UUID indexes
before observers run. Region names are indexed case-insensitively, repeated
blocks replace older values, and entries expire after fifteen minutes using
the client's injectable clock. Async lookups use `MapRequestTimeout`, observe
the caller or client cancellation token, and return the cache result available
at timeout. Map coordinates use 256-metre region origins; `MapItem` exposes
the corresponding local coordinates and region handle, retains every raw
reply field, and provides typed payloads for telehubs, agent clusters, land
sales, and events. Coarse-location updates report complete current positions
plus added and removed agent IDs.
`DirectoryManager` sends correlated people, group, event, classified, parcel,
land, place, and event-info requests. Query IDs are random and returned to the
caller, reply pages are capped at 1,024 records, strings are bounded to the
LLUDP variable-field limit, and negative page/price/area inputs are rejected.
Reply events retain the server query or transaction ID so callers can combine
pages without mixing concurrent searches. Maturity, category, sort, price,
area, and ownership flags are sent unchanged. High-level search entry points
start the same real server queries and return live `IAsyncEnumerable` streams
that yield decoded records in reply order until cancellation, timeout, or a
terminal page. Typed reply events remain available for callback-oriented
consumers.
`SlurlParser` accepts raw locations, `secondlife://` locations, maps URLs,
legacy start locations, and `secondlife:///app` commands. It applies the
reference coordinate defaults, decodes query values, and generates stable,
percent-escaped application and location URLs. Inputs and query counts are
bounded.
`InterestListManager` posts LLSD to each simulator's `InterestList`
capability. A mode is committed only after a successful response; missing
capabilities return `false` for per-simulator calls. `SimulatorFeatures`
atomically replaces its map after bounded LLSD validation and deliberately
retains unknown feature keys for forward compatibility.

View File

@@ -146,6 +146,42 @@ NATIVE_TYPES = {
"T:LibreMetaverse.GroupChatJoinedEventArgs": "crate::agent_messages::GroupChatJoinedEventArgs", "T:LibreMetaverse.GroupChatJoinedEventArgs": "crate::agent_messages::GroupChatJoinedEventArgs",
"T:LibreMetaverse.RegionExperiencesEventArgs": "crate::agent_messages::RegionExperiencesEventArgs", "T:LibreMetaverse.RegionExperiencesEventArgs": "crate::agent_messages::RegionExperiencesEventArgs",
"T:LibreMetaverse.SetDisplayNameReplyEventArgs": "crate::agent_messages::SetDisplayNameReplyEventArgs", "T:LibreMetaverse.SetDisplayNameReplyEventArgs": "crate::agent_messages::SetDisplayNameReplyEventArgs",
"T:LibreMetaverse.SlurlParser": "crate::slurl::SlurlParser",
"T:LibreMetaverse.GridManager": "crate::grid_manager::GridManager",
"T:LibreMetaverse.GridRegion": "crate::grid_manager::GridRegion",
"T:LibreMetaverse.GridLayer": "crate::grid_manager::GridLayer",
"T:LibreMetaverse.MapItem": "crate::grid_manager::MapItem",
"T:LibreMetaverse.MapAgentLocation": "crate::grid_manager::MapAgentLocation",
"T:LibreMetaverse.MapLandForSale": "crate::grid_manager::MapLandForSale",
"T:LibreMetaverse.MapAdultLandForSale": "crate::grid_manager::MapAdultLandForSale",
"T:LibreMetaverse.MapPGEvent": "crate::grid_manager::MapPGEvent",
"T:LibreMetaverse.MapMatureEvent": "crate::grid_manager::MapMatureEvent",
"T:LibreMetaverse.MapAdultEvent": "crate::grid_manager::MapAdultEvent",
"T:LibreMetaverse.MapTelehub": "crate::grid_manager::MapTelehub",
"T:LibreMetaverse.CoarseLocationUpdateEventArgs": "crate::grid_manager::CoarseLocationUpdateEventArgs",
"T:LibreMetaverse.GridItemsEventArgs": "crate::grid_manager::GridItemsEventArgs",
"T:LibreMetaverse.GridLayerEventArgs": "crate::grid_manager::GridLayerEventArgs",
"T:LibreMetaverse.GridRegionEventArgs": "crate::grid_manager::GridRegionEventArgs",
"T:LibreMetaverse.RegionHandleReplyEventArgs": "crate::grid_manager::RegionHandleReplyEventArgs",
"T:LibreMetaverse.SimulatorFeatures": "crate::grid_manager::SimulatorFeatures",
"T:LibreMetaverse.DirectoryManager": "crate::directory_manager::DirectoryManager",
"T:LibreMetaverse.DirectoryManager.AgentSearchData": "crate::directory_manager::DirectoryManagerAgentSearchData",
"T:LibreMetaverse.DirectoryManager.Classified": "crate::directory_manager::DirectoryManagerClassified",
"T:LibreMetaverse.DirectoryManager.DirectoryParcel": "crate::directory_manager::DirectoryManagerDirectoryParcel",
"T:LibreMetaverse.DirectoryManager.EventInfo": "crate::directory_manager::DirectoryManagerEventInfo",
"T:LibreMetaverse.DirectoryManager.EventsSearchData": "crate::directory_manager::DirectoryManagerEventsSearchData",
"T:LibreMetaverse.DirectoryManager.GroupSearchData": "crate::directory_manager::DirectoryManagerGroupSearchData",
"T:LibreMetaverse.DirectoryManager.PlacesSearchData": "crate::directory_manager::DirectoryManagerPlacesSearchData",
"T:LibreMetaverse.DirClassifiedsReplyEventArgs": "crate::directory_manager::DirClassifiedsReplyEventArgs",
"T:LibreMetaverse.DirEventsReplyEventArgs": "crate::directory_manager::DirEventsReplyEventArgs",
"T:LibreMetaverse.DirGroupsReplyEventArgs": "crate::directory_manager::DirGroupsReplyEventArgs",
"T:LibreMetaverse.DirLandReplyEventArgs": "crate::directory_manager::DirLandReplyEventArgs",
"T:LibreMetaverse.DirPeopleReplyEventArgs": "crate::directory_manager::DirPeopleReplyEventArgs",
"T:LibreMetaverse.DirPlacesReplyEventArgs": "crate::directory_manager::DirPlacesReplyEventArgs",
"T:LibreMetaverse.EventInfoReplyEventArgs": "crate::directory_manager::EventInfoReplyEventArgs",
"T:LibreMetaverse.PlacesReplyEventArgs": "crate::directory_manager::PlacesReplyEventArgs",
"T:LibreMetaverse.InterestListManager": "crate::interest_list::InterestListManager",
"T:LibreMetaverse.Messages.Linden.InterestListMessage": "crate::interest_list::InterestListMessage",
"T:LibreMetaverse.TeleportEventArgs": "crate::agent_movement::TeleportEventArgs", "T:LibreMetaverse.TeleportEventArgs": "crate::agent_movement::TeleportEventArgs",
"T:LibreMetaverse.TerrainCompressor": "crate::terrain_codec::TerrainCompressor", "T:LibreMetaverse.TerrainCompressor": "crate::terrain_codec::TerrainCompressor",
"T:LibreMetaverse.TerrainPatch": "crate::terrain_codec::TerrainPatch", "T:LibreMetaverse.TerrainPatch": "crate::terrain_codec::TerrainPatch",
@@ -404,6 +440,12 @@ NATIVE_MEMBER_BODIES = {
"P:LibreMetaverse.GridClient.Sound#set": "self.native_set_sound(value)", "P:LibreMetaverse.GridClient.Sound#set": "self.native_set_sound(value)",
"P:LibreMetaverse.GridClient.Parcels": "self.native_parcels().unwrap_or_else(|_| panic!(\"failed to construct ParcelManager\"))", "P:LibreMetaverse.GridClient.Parcels": "self.native_parcels().unwrap_or_else(|_| panic!(\"failed to construct ParcelManager\"))",
"P:LibreMetaverse.GridClient.Parcels#set": "self.native_set_parcels(value)", "P:LibreMetaverse.GridClient.Parcels#set": "self.native_set_parcels(value)",
"P:LibreMetaverse.GridClient.Grid": "self.native_grid().unwrap_or_else(|_| panic!(\"failed to construct GridManager\"))",
"P:LibreMetaverse.GridClient.Grid#set": "self.native_set_grid(value)",
"P:LibreMetaverse.GridClient.Directory": "self.native_directory().unwrap_or_else(|_| panic!(\"failed to construct DirectoryManager\"))",
"P:LibreMetaverse.GridClient.Directory#set": "self.native_set_directory(value)",
"P:LibreMetaverse.GridClient.InterestList": "self.native_interest_list().unwrap_or_else(|_| panic!(\"failed to construct InterestListManager\"))",
"P:LibreMetaverse.GridClient.InterestList#set": "self.native_set_interest_list(value)",
"M:LibreMetaverse.Simulator.IsParcelMapFull": "Ok(self.native_is_parcel_map_full())", "M:LibreMetaverse.Simulator.IsParcelMapFull": "Ok(self.native_is_parcel_map_full())",
"M:LibreMetaverse.Simulator.TerrainHeightAtPoint(System.Int32,System.Int32,System.Single@)": "self.native_terrain_height_at_point(x, y, height)", "M:LibreMetaverse.Simulator.TerrainHeightAtPoint(System.Int32,System.Int32,System.Single@)": "self.native_terrain_height_at_point(x, y, height)",
"P:LibreMetaverse.Simulator.ParcelMap": "self.native_parcel_map_snapshot()", "P:LibreMetaverse.Simulator.ParcelMap": "self.native_parcel_map_snapshot()",