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
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:
@@ -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
|
||||
ordering, cancellation, limits, and live-operation safety are documented in
|
||||
[`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).
|
||||
|
||||
@@ -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: 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.Skia` | 1 | 3 | native implementation: 1 type / 3 members; no generated shims remain |
|
||||
| `LibreMetaverse.LslTools` | 164 | 768 | callable failure-only shim |
|
||||
|
||||
@@ -1003,7 +1003,153 @@ pub struct Span<T>(pub PhantomData<fn(T)>);
|
||||
|
||||
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)>);
|
||||
|
||||
|
||||
@@ -375,7 +375,10 @@ async fn run_seed_requests(weak: Weak<CapsInner>, cancellation: CancellationToke
|
||||
.await;
|
||||
match response {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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>,
|
||||
simulator: &Simulator,
|
||||
data: Vec<u8>,
|
||||
cancellation: CancellationToken,
|
||||
) -> Result<(), Error> {
|
||||
let OSD::Map(map) = OSDParser::deserialize_with_bytes(data)? else {
|
||||
return Err(Error::Parse {
|
||||
@@ -439,6 +443,24 @@ fn install_seed_response(
|
||||
queue.start()?;
|
||||
*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());
|
||||
Ok(())
|
||||
}
|
||||
@@ -635,6 +657,10 @@ mod tests {
|
||||
"GetTexture".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(),
|
||||
OSD::Uri(Uri("file:///private/capability".to_owned())),
|
||||
@@ -654,12 +680,22 @@ mod tests {
|
||||
let release = Arc::clone(&release);
|
||||
async move {
|
||||
let is_seed = request.uri.0.contains("/seed");
|
||||
let is_features = request.uri.0.contains("/features");
|
||||
lock(&recorded).push(request);
|
||||
if is_seed {
|
||||
while !release.load(Ordering::Acquire) {
|
||||
tokio::time::sleep(Duration::from_millis(1)).await;
|
||||
}
|
||||
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 {
|
||||
response(404, Vec::new())
|
||||
}
|
||||
@@ -708,6 +744,10 @@ mod tests {
|
||||
None
|
||||
);
|
||||
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);
|
||||
while lock(&requests).len() < 2 && Instant::now() < deadline {
|
||||
|
||||
@@ -123,6 +123,9 @@ struct ClientRuntime {
|
||||
terrain_manager: Mutex<Option<Arc<crate::terrain_manager::TerrainManagerInner>>>,
|
||||
sound_manager: Mutex<Option<Arc<crate::sound_manager::SoundManagerInner>>>,
|
||||
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>>>,
|
||||
avatar_manager: Mutex<Option<Arc<crate::avatar_manager::AvatarManagerInner>>>,
|
||||
animesh_manager: Mutex<Option<Arc<crate::animesh_runtime::AnimeshManagerInner>>>,
|
||||
@@ -162,6 +165,9 @@ impl ClientRuntime {
|
||||
terrain_manager: Mutex::new(None),
|
||||
sound_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),
|
||||
avatar_manager: Mutex::new(None),
|
||||
animesh_manager: Mutex::new(None),
|
||||
@@ -252,6 +258,18 @@ impl ClientRuntime {
|
||||
.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.http_caps_client
|
||||
.lock()
|
||||
.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);
|
||||
}
|
||||
|
||||
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(
|
||||
&mut self,
|
||||
value: crate::caps_http::CapsRateLimiter,
|
||||
|
||||
1445
crates/libremetaverse/src/directory_manager.rs
Normal file
1445
crates/libremetaverse/src/directory_manager.rs
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
1122
crates/libremetaverse/src/grid_manager.rs
Normal file
1122
crates/libremetaverse/src/grid_manager.rs
Normal file
File diff suppressed because it is too large
Load Diff
237
crates/libremetaverse/src/interest_list.rs
Normal file
237
crates/libremetaverse/src/interest_list.rs
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -48,10 +48,15 @@ mod object_material;
|
||||
mod object_model;
|
||||
#[rustfmt::skip]
|
||||
pub mod packet_catalog;
|
||||
mod directory_manager;
|
||||
mod grid_manager;
|
||||
pub use grid_manager::MapItemData;
|
||||
mod interest_list;
|
||||
mod packet_wire;
|
||||
mod parcel_manager;
|
||||
mod sim_stats;
|
||||
mod skeleton;
|
||||
mod slurl;
|
||||
mod sound_manager;
|
||||
#[rustfmt::skip] // Deterministic machine output is formatted by the pinned generator.
|
||||
mod skeleton_catalog;
|
||||
|
||||
@@ -1135,7 +1135,7 @@ impl Simulator {
|
||||
client,
|
||||
colo_location: String::new(),
|
||||
data_pool: None,
|
||||
features: SimulatorFeatures,
|
||||
features: SimulatorFeatures::default(),
|
||||
flags: RegionFlags(0),
|
||||
global_to_local_id: RwLock::new(HashMap::new()),
|
||||
handle,
|
||||
|
||||
430
crates/libremetaverse/src/slurl.rs
Normal file
430
crates/libremetaverse/src/slurl.rs
Normal 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(®ion),
|
||||
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
43
docs/discovery.md
Normal 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.
|
||||
@@ -146,6 +146,42 @@ NATIVE_TYPES = {
|
||||
"T:LibreMetaverse.GroupChatJoinedEventArgs": "crate::agent_messages::GroupChatJoinedEventArgs",
|
||||
"T:LibreMetaverse.RegionExperiencesEventArgs": "crate::agent_messages::RegionExperiencesEventArgs",
|
||||
"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.TerrainCompressor": "crate::terrain_codec::TerrainCompressor",
|
||||
"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.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.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.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()",
|
||||
|
||||
Reference in New Issue
Block a user