Complete first release candidate audit (#107)
Some checks failed
API and SemVer surface / api-surface (push) Failing after 1m34s
Native code generation / deterministic (push) Has been cancelled
Concurrency and resource soak audit / soak (push) Has been cancelled
Documentation / documentation (push) Has been cancelled
performance evidence / audit (push) Has been cancelled
First release candidate / non-fuzz-release-gate (push) Has been cancelled
Release platform and feature matrix / audit (push) Has been cancelled
Release platform and feature matrix / matrix (false, linux-stable-minimal, x86_64-unknown-linux-gnu, stable) (push) Has been cancelled
Release platform and feature matrix / matrix (false, macos-stable-portable, x86_64-apple-darwin, stable) (push) Has been cancelled
Release platform and feature matrix / matrix (false, windows-stable-portable, x86_64-pc-windows-gnu, stable) (push) Has been cancelled
Release platform and feature matrix / matrix (true, linux-msrv-portable, x86_64-unknown-linux-gnu, 1.96.0) (push) Has been cancelled
Release platform and feature matrix / matrix (true, linux-stable-default, x86_64-unknown-linux-gnu, stable) (push) Has been cancelled
Release platform and feature matrix / matrix (true, linux-stable-features, x86_64-unknown-linux-gnu, stable) (push) Has been cancelled
Release platform and feature matrix / matrix (true, linux-stable-release-surface, x86_64-unknown-linux-gnu, stable) (push) Has been cancelled
Dependency and supply-chain audit / audit (push) Has been cancelled
Native release artifact audit / audit (push) Has been cancelled
Imaging and meshing gate / native (push) Failing after 53s
JPEG 2000 feature / linux (push) Successful in 2m49s
Native Rust workspace compile / compile (push) Failing after 59s
Skia feature / linux (push) Has been cancelled
Some checks failed
API and SemVer surface / api-surface (push) Failing after 1m34s
Native code generation / deterministic (push) Has been cancelled
Concurrency and resource soak audit / soak (push) Has been cancelled
Documentation / documentation (push) Has been cancelled
performance evidence / audit (push) Has been cancelled
First release candidate / non-fuzz-release-gate (push) Has been cancelled
Release platform and feature matrix / audit (push) Has been cancelled
Release platform and feature matrix / matrix (false, linux-stable-minimal, x86_64-unknown-linux-gnu, stable) (push) Has been cancelled
Release platform and feature matrix / matrix (false, macos-stable-portable, x86_64-apple-darwin, stable) (push) Has been cancelled
Release platform and feature matrix / matrix (false, windows-stable-portable, x86_64-pc-windows-gnu, stable) (push) Has been cancelled
Release platform and feature matrix / matrix (true, linux-msrv-portable, x86_64-unknown-linux-gnu, 1.96.0) (push) Has been cancelled
Release platform and feature matrix / matrix (true, linux-stable-default, x86_64-unknown-linux-gnu, stable) (push) Has been cancelled
Release platform and feature matrix / matrix (true, linux-stable-features, x86_64-unknown-linux-gnu, stable) (push) Has been cancelled
Release platform and feature matrix / matrix (true, linux-stable-release-surface, x86_64-unknown-linux-gnu, stable) (push) Has been cancelled
Dependency and supply-chain audit / audit (push) Has been cancelled
Native release artifact audit / audit (push) Has been cancelled
Imaging and meshing gate / native (push) Failing after 53s
JPEG 2000 feature / linux (push) Successful in 2m49s
Native Rust workspace compile / compile (push) Failing after 59s
Skia feature / linux (push) Has been cancelled
This commit is contained in:
@@ -8,6 +8,7 @@ repository.workspace = true
|
||||
description = "Vivox voice shims for the MetaCrate LibreMetaverse rewrite"
|
||||
|
||||
[dependencies]
|
||||
base64 = "0.22.1"
|
||||
libremetaverse = { version = "0.0.1", path = "../libremetaverse" }
|
||||
libremetaverse-structured-data = { version = "0.0.1", path = "../libremetaverse-structured-data" }
|
||||
libremetaverse-types = { version = "0.0.1", path = "../libremetaverse-types" }
|
||||
|
||||
206
crates/libremetaverse-voice-vivox/src/callbacks.rs
Normal file
206
crates/libremetaverse-voice-vivox/src/callbacks.rs
Normal file
@@ -0,0 +1,206 @@
|
||||
//! Safe native replacements for the CLR delegate types in the Vivox API.
|
||||
|
||||
use crate::Error;
|
||||
use libremetaverse_types::compat::{Object, SocketException};
|
||||
use std::any::Any;
|
||||
use std::fmt;
|
||||
use std::sync::Arc;
|
||||
|
||||
type AsyncCallback = Box<dyn Fn(&dyn Any) + Send + Sync>;
|
||||
type AsyncResult = Box<dyn Any + Send + Sync>;
|
||||
|
||||
macro_rules! callback_type {
|
||||
($name:ident ( $($argument:ident : $type:ty),* $(,)? )) => {
|
||||
#[derive(Clone)]
|
||||
pub struct $name {
|
||||
handler: Arc<dyn Fn($($type),*) -> Result<(), Error> + Send + Sync>,
|
||||
}
|
||||
|
||||
impl fmt::Debug for $name {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter
|
||||
.debug_struct(stringify!($name))
|
||||
.finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
impl $name {
|
||||
/// Creates a native callback without exposing CLR method pointers.
|
||||
pub fn from_handler<F>(handler: F) -> Self
|
||||
where
|
||||
F: Fn($($type),*) -> Result<(), Error> + Send + Sync + 'static,
|
||||
{
|
||||
Self { handler: Arc::new(handler) }
|
||||
}
|
||||
|
||||
/// Rehydrates a callback previously stored in a mapped `Object`.
|
||||
/// A non-zero CLR method pointer cannot be invoked safely in native
|
||||
/// Rust and is therefore rejected explicitly.
|
||||
pub fn new(object: Object, method: isize) -> Result<Self, Error> {
|
||||
if method != 0 {
|
||||
return Err(Error::Argument);
|
||||
}
|
||||
object.downcast_ref::<Self>().cloned().ok_or(Error::Argument)
|
||||
}
|
||||
|
||||
pub fn invoke(&self, $($argument: $type),*) -> Result<(), Error> {
|
||||
(self.handler)($($argument),*)
|
||||
}
|
||||
|
||||
pub fn begin_invoke(
|
||||
&self,
|
||||
$($argument: $type,)*
|
||||
callback: AsyncCallback,
|
||||
object: Object,
|
||||
) -> Result<AsyncResult, Error> {
|
||||
self.invoke($($argument),*)?;
|
||||
callback(&object);
|
||||
Ok(Box::new(()))
|
||||
}
|
||||
|
||||
pub fn end_invoke(&self, result: AsyncResult) -> Result<(), Error> {
|
||||
result.downcast::<()>().map(|_| ()).map_err(|_| Error::Argument)
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
callback_type!(TCPPipeOnDisconnectedCallback(se: SocketException));
|
||||
callback_type!(TCPPipeOnReceiveLineCallback(line: String));
|
||||
callback_type!(VoiceGatewayDaemonConnectedCallback());
|
||||
callback_type!(VoiceGatewayDaemonCouldntConnectCallback());
|
||||
callback_type!(VoiceGatewayDaemonCouldntRunCallback());
|
||||
callback_type!(VoiceGatewayDaemonDisconnectedCallback());
|
||||
callback_type!(VoiceGatewayDaemonExitedCallback());
|
||||
callback_type!(VoiceGatewayDaemonRunningCallback());
|
||||
callback_type!(VoiceGatewayVoiceConnectionChangeCallback(
|
||||
state: crate::VoiceGatewayConnectionState,
|
||||
));
|
||||
callback_type!(VoiceGatewayVoiceMicTestCallback(level: f32));
|
||||
callback_type!(VoiceManagerAuxAudioPropertiesCallback(cookie: i32, energy: f32));
|
||||
callback_type!(VoiceManagerBasicActionCallback(
|
||||
cookie: i32,
|
||||
status_code: i32,
|
||||
status_string: String,
|
||||
));
|
||||
callback_type!(VoiceManagerConnectorCreatedCallback(
|
||||
cookie: i32,
|
||||
status_code: i32,
|
||||
status_string: String,
|
||||
connector_handle: String,
|
||||
));
|
||||
callback_type!(VoiceManagerDevicesCallback(
|
||||
cookie: i32,
|
||||
status_code: i32,
|
||||
status_string: String,
|
||||
current_device: String,
|
||||
));
|
||||
callback_type!(VoiceManagerLoginCallback(
|
||||
cookie: i32,
|
||||
status_code: i32,
|
||||
status_string: String,
|
||||
account_handle: String,
|
||||
));
|
||||
callback_type!(VoiceManagerLoginStateChangeCallback(
|
||||
cookie: i32,
|
||||
account_handle: String,
|
||||
status_code: i32,
|
||||
status_string: String,
|
||||
state: i32,
|
||||
));
|
||||
callback_type!(VoiceManagerNewSessionCallback(
|
||||
cookie: i32,
|
||||
account_handle: String,
|
||||
event_session_handle: String,
|
||||
state: i32,
|
||||
name_string: String,
|
||||
uri_string: String,
|
||||
));
|
||||
callback_type!(VoiceManagerParcelVoiceInfoCallback(
|
||||
region_name: String,
|
||||
local_id: i32,
|
||||
channel_uri: Option<String>,
|
||||
));
|
||||
callback_type!(VoiceManagerParticipantPropertiesCallback(
|
||||
cookie: i32,
|
||||
uri_string: String,
|
||||
status_code: i32,
|
||||
status_string: String,
|
||||
is_locally_muted: bool,
|
||||
is_moderator_muted: bool,
|
||||
is_speaking: bool,
|
||||
volume: i32,
|
||||
energy: f32,
|
||||
));
|
||||
callback_type!(VoiceManagerParticipantStateChangeCallback(
|
||||
cookie: i32,
|
||||
uri_string: String,
|
||||
status_code: i32,
|
||||
status_string: String,
|
||||
state: i32,
|
||||
name_string: String,
|
||||
display_name_string: String,
|
||||
participant_type: i32,
|
||||
));
|
||||
callback_type!(VoiceManagerProvisionAccountCallback(
|
||||
username: String,
|
||||
password: String,
|
||||
));
|
||||
callback_type!(VoiceManagerSessionCreatedCallback(
|
||||
cookie: i32,
|
||||
status_code: i32,
|
||||
status_string: String,
|
||||
session_handle: String,
|
||||
));
|
||||
callback_type!(VoiceManagerSessionStateChangeCallback(
|
||||
cookie: i32,
|
||||
uri_string: String,
|
||||
status_code: i32,
|
||||
status_string: String,
|
||||
event_session_handle: String,
|
||||
state: i32,
|
||||
is_channel: bool,
|
||||
name_string: String,
|
||||
));
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::atomic::{AtomicI32, Ordering};
|
||||
|
||||
#[test]
|
||||
fn callback_invocation_and_apm_completion_execute_once() {
|
||||
let total = Arc::new(AtomicI32::new(0));
|
||||
let observed = Arc::clone(&total);
|
||||
let callback =
|
||||
VoiceManagerAuxAudioPropertiesCallback::from_handler(move |cookie, energy| {
|
||||
observed.fetch_add(cookie + energy as i32, Ordering::SeqCst);
|
||||
Ok(())
|
||||
});
|
||||
callback.invoke(2, 3.0).unwrap();
|
||||
let completion_count = Arc::new(AtomicI32::new(0));
|
||||
let completed = Arc::clone(&completion_count);
|
||||
let result = callback
|
||||
.begin_invoke(
|
||||
4,
|
||||
5.0,
|
||||
Box::new(move |_| {
|
||||
completed.fetch_add(1, Ordering::SeqCst);
|
||||
}),
|
||||
Object::Undefined,
|
||||
)
|
||||
.unwrap();
|
||||
callback.end_invoke(result).unwrap();
|
||||
assert_eq!(total.load(Ordering::SeqCst), 14);
|
||||
assert_eq!(completion_count.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn callback_round_trips_through_mapped_object_without_raw_pointer() {
|
||||
let callback = TCPPipeOnReceiveLineCallback::from_handler(|_| Ok(()));
|
||||
let restored = TCPPipeOnReceiveLineCallback::new(Object::opaque(callback), 0).unwrap();
|
||||
restored.invoke("line".into()).unwrap();
|
||||
assert!(TCPPipeOnReceiveLineCallback::new(Object::Undefined, 0).is_err());
|
||||
assert!(TCPPipeOnReceiveLineCallback::new(Object::Undefined, 1).is_err());
|
||||
}
|
||||
}
|
||||
166
crates/libremetaverse-voice-vivox/src/event_args.rs
Normal file
166
crates/libremetaverse-voice-vivox/src/event_args.rs
Normal file
@@ -0,0 +1,166 @@
|
||||
//! Stateful Vivox response payloads that preserve the C# inheritance data.
|
||||
|
||||
use crate::{Error, VoiceGatewayResponseType, VoiceGatewayVoiceResponseEventArgs};
|
||||
use std::ops::Deref;
|
||||
|
||||
macro_rules! response_payload {
|
||||
($name:ident { $($field:ident : $type:ty),+ $(,)? }) => {
|
||||
#[derive(Clone)]
|
||||
pub struct $name {
|
||||
pub base: VoiceGatewayVoiceResponseEventArgs,
|
||||
$(pub $field: $type,)+
|
||||
}
|
||||
|
||||
impl Deref for $name {
|
||||
type Target = VoiceGatewayVoiceResponseEventArgs;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.base
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
response_payload!(VoiceGatewayVoiceAccountEventArgs {
|
||||
account_handle_value: String,
|
||||
});
|
||||
|
||||
impl VoiceGatewayVoiceAccountEventArgs {
|
||||
pub fn new(rcode: i32, scode: i32, text: String, ahandle: String) -> Result<Self, Error> {
|
||||
Ok(Self {
|
||||
base: VoiceGatewayVoiceResponseEventArgs::new(
|
||||
VoiceGatewayResponseType::AccountLogin,
|
||||
rcode,
|
||||
scode,
|
||||
text,
|
||||
)?,
|
||||
account_handle_value: ahandle,
|
||||
})
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn account_handle(&self) -> String {
|
||||
self.account_handle_value.clone()
|
||||
}
|
||||
}
|
||||
|
||||
response_payload!(VoiceGatewayVoiceSessionEventArgs {
|
||||
session_handle: String,
|
||||
});
|
||||
|
||||
impl VoiceGatewayVoiceSessionEventArgs {
|
||||
pub fn new(rcode: i32, scode: i32, text: String, shandle: String) -> Result<Self, Error> {
|
||||
Ok(Self {
|
||||
base: VoiceGatewayVoiceResponseEventArgs::new(
|
||||
VoiceGatewayResponseType::SessionCreate,
|
||||
rcode,
|
||||
scode,
|
||||
text,
|
||||
)?,
|
||||
session_handle: shandle,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
response_payload!(VoiceGatewayVoiceConnectorEventArgs {
|
||||
version_value: String,
|
||||
handle_value: String,
|
||||
});
|
||||
|
||||
impl VoiceGatewayVoiceConnectorEventArgs {
|
||||
pub fn new(
|
||||
rcode: i32,
|
||||
scode: i32,
|
||||
text: String,
|
||||
version: String,
|
||||
handle: String,
|
||||
) -> Result<Self, Error> {
|
||||
Ok(Self {
|
||||
base: VoiceGatewayVoiceResponseEventArgs::new(
|
||||
VoiceGatewayResponseType::ConnectorCreate,
|
||||
rcode,
|
||||
scode,
|
||||
text,
|
||||
)?,
|
||||
version_value: version,
|
||||
handle_value: handle,
|
||||
})
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn handle(&self) -> String {
|
||||
self.handle_value.clone()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn version(&self) -> String {
|
||||
self.version_value.clone()
|
||||
}
|
||||
}
|
||||
|
||||
response_payload!(VoiceGatewayVoiceDevicesEventArgs {
|
||||
current_device_value: String,
|
||||
devices_value: Vec<String>,
|
||||
});
|
||||
|
||||
impl VoiceGatewayVoiceDevicesEventArgs {
|
||||
pub fn new(
|
||||
type_: VoiceGatewayResponseType,
|
||||
rcode: i32,
|
||||
scode: i32,
|
||||
text: String,
|
||||
current: String,
|
||||
avail: Vec<String>,
|
||||
) -> Result<Self, Error> {
|
||||
Ok(Self {
|
||||
base: VoiceGatewayVoiceResponseEventArgs::new(type_, rcode, scode, text)?,
|
||||
current_device_value: current,
|
||||
devices_value: avail,
|
||||
})
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn current_device(&self) -> String {
|
||||
self.current_device_value.clone()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn devices(&self) -> Vec<String> {
|
||||
self.devices_value.clone()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn derived_response_payloads_retain_base_and_specific_data() {
|
||||
let connector = VoiceGatewayVoiceConnectorEventArgs::new(
|
||||
3,
|
||||
4,
|
||||
"status".into(),
|
||||
"1.2.3".into(),
|
||||
"connector".into(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(connector.type_, VoiceGatewayResponseType::ConnectorCreate);
|
||||
assert_eq!(connector.return_code, 3);
|
||||
assert_eq!(connector.status_code, 4);
|
||||
assert_eq!(connector.message, "status");
|
||||
assert_eq!(connector.version(), "1.2.3");
|
||||
assert_eq!(connector.handle(), "connector");
|
||||
|
||||
let devices = VoiceGatewayVoiceDevicesEventArgs::new(
|
||||
VoiceGatewayResponseType::GetCaptureDevices,
|
||||
0,
|
||||
0,
|
||||
"OK".into(),
|
||||
"default".into(),
|
||||
vec!["default".into(), "usb".into()],
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(devices.current_device(), "default");
|
||||
assert_eq!(devices.devices(), ["default", "usb"]);
|
||||
}
|
||||
}
|
||||
1496
crates/libremetaverse-voice-vivox/src/gateway.rs
Normal file
1496
crates/libremetaverse-voice-vivox/src/gateway.rs
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -6,8 +6,20 @@
|
||||
|
||||
extern crate self as libremetaverse_voice_vivox;
|
||||
|
||||
#[allow(clippy::all, clippy::pedantic)] // Public shapes mirror the pinned C# API.
|
||||
mod callbacks;
|
||||
#[allow(clippy::all, clippy::pedantic)] // Public shapes mirror the pinned C# API.
|
||||
mod event_args;
|
||||
#[allow(clippy::all, clippy::pedantic)] // Public shapes mirror the pinned C# API.
|
||||
mod gateway;
|
||||
mod generated;
|
||||
mod protocol;
|
||||
#[allow(clippy::all, clippy::pedantic)] // Public shapes mirror the pinned C# API.
|
||||
mod session;
|
||||
#[allow(clippy::all, clippy::pedantic)] // Public shapes mirror the pinned C# API.
|
||||
mod tcp_pipe;
|
||||
#[allow(clippy::all, clippy::pedantic)] // Public shapes mirror the pinned C# API.
|
||||
mod voice_manager;
|
||||
|
||||
pub use generated::*;
|
||||
pub use libremetaverse as core;
|
||||
|
||||
322
crates/libremetaverse-voice-vivox/src/session.rs
Normal file
322
crates/libremetaverse-voice-vivox/src/session.rs
Normal file
@@ -0,0 +1,322 @@
|
||||
//! Vivox session and participant state models.
|
||||
|
||||
use crate::{Error, VoiceGateway, VoicePosition};
|
||||
use base64::Engine as _;
|
||||
use libremetaverse_types::UUID;
|
||||
use libremetaverse_types::compat::{EventHandler, Subscription};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{
|
||||
Arc, Mutex, Weak,
|
||||
atomic::{AtomicU64, Ordering},
|
||||
};
|
||||
|
||||
fn lock<T>(mutex: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
|
||||
mutex
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct UnitEvent {
|
||||
next_id: AtomicU64,
|
||||
handlers: Arc<Mutex<HashMap<u64, EventHandler<()>>>>,
|
||||
}
|
||||
|
||||
impl UnitEvent {
|
||||
fn subscribe(&self, handler: Option<EventHandler<()>>) -> Subscription {
|
||||
let Some(handler) = handler else {
|
||||
return Subscription::detached();
|
||||
};
|
||||
let id = self.next_id.fetch_add(1, Ordering::Relaxed);
|
||||
lock(&self.handlers).insert(id, handler);
|
||||
let handlers: Weak<Mutex<HashMap<u64, EventHandler<()>>>> = Arc::downgrade(&self.handlers);
|
||||
Subscription::new(move || {
|
||||
if let Some(handlers) = handlers.upgrade() {
|
||||
lock(&handlers).remove(&id);
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn emit(&self) {
|
||||
let handlers: Vec<_> = lock(&self.handlers).values().cloned().collect();
|
||||
for handler in handlers {
|
||||
handler(());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct VoiceSessionInner {
|
||||
connector: VoiceGateway,
|
||||
handle: String,
|
||||
is_spatial: bool,
|
||||
participants: Mutex<HashMap<String, VoiceParticipant>>,
|
||||
participant_added: UnitEvent,
|
||||
participant_updated: UnitEvent,
|
||||
participant_removed: UnitEvent,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct VoiceSession {
|
||||
pub region_name: String,
|
||||
inner: Arc<VoiceSessionInner>,
|
||||
}
|
||||
|
||||
impl VoiceSession {
|
||||
pub fn new(conn: VoiceGateway, handle: String) -> Result<Self, Error> {
|
||||
if handle.len() > 16 * 1024 || handle.chars().any(char::is_control) {
|
||||
return Err(Error::Argument);
|
||||
}
|
||||
Ok(Self {
|
||||
region_name: String::new(),
|
||||
inner: Arc::new(VoiceSessionInner {
|
||||
connector: conn,
|
||||
handle,
|
||||
is_spatial: true,
|
||||
participants: Mutex::new(HashMap::new()),
|
||||
participant_added: UnitEvent::default(),
|
||||
participant_updated: UnitEvent::default(),
|
||||
participant_removed: UnitEvent::default(),
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn subscribe_on_participant_added(
|
||||
&self,
|
||||
handler: Option<EventHandler<()>>,
|
||||
) -> Subscription {
|
||||
self.inner.participant_added.subscribe(handler)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn subscribe_on_participant_update(
|
||||
&self,
|
||||
handler: Option<EventHandler<()>>,
|
||||
) -> Subscription {
|
||||
self.inner.participant_updated.subscribe(handler)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn subscribe_on_participant_removed(
|
||||
&self,
|
||||
handler: Option<EventHandler<()>>,
|
||||
) -> Subscription {
|
||||
self.inner.participant_removed.subscribe(handler)
|
||||
}
|
||||
|
||||
pub fn set3_d_position(
|
||||
&self,
|
||||
speaker_position: VoicePosition,
|
||||
listener_position: VoicePosition,
|
||||
) -> Result<(), Error> {
|
||||
self.inner.connector.session_set3_d_position(
|
||||
self.inner.handle.clone(),
|
||||
speaker_position,
|
||||
listener_position,
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn connector(&self) -> VoiceGateway {
|
||||
self.inner.connector.clone()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn handle(&self) -> String {
|
||||
self.inner.handle.clone()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn is_spatial(&self) -> bool {
|
||||
self.inner.is_spatial
|
||||
}
|
||||
|
||||
pub(crate) fn close(&self) {
|
||||
lock(&self.inner.participants).clear();
|
||||
}
|
||||
|
||||
pub(crate) fn add_participant(&self, uri: String) -> Result<(), Error> {
|
||||
let mut participants = lock(&self.inner.participants);
|
||||
if participants.contains_key(&uri) {
|
||||
return Ok(());
|
||||
}
|
||||
participants.insert(uri.clone(), VoiceParticipant::new(uri, self.clone())?);
|
||||
drop(participants);
|
||||
self.inner.participant_added.emit();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn remove_participant(&self, uri: &str) {
|
||||
if lock(&self.inner.participants).remove(uri).is_some() {
|
||||
self.inner.participant_removed.emit();
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn update_participant(
|
||||
&self,
|
||||
uri: &str,
|
||||
muted: bool,
|
||||
speaking: bool,
|
||||
volume: i32,
|
||||
energy: f32,
|
||||
) {
|
||||
let mut participants = lock(&self.inner.participants);
|
||||
let Some(participant) = participants.get_mut(uri) else {
|
||||
return;
|
||||
};
|
||||
participant.is_muted = muted;
|
||||
participant.is_speaking = speaking;
|
||||
participant.volume = volume;
|
||||
participant.energy = energy;
|
||||
drop(participants);
|
||||
self.inner.participant_updated.emit();
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct VoiceParticipant {
|
||||
avatar_name: String,
|
||||
energy: f32,
|
||||
id: UUID,
|
||||
is_muted: bool,
|
||||
is_speaking: bool,
|
||||
uri: String,
|
||||
volume: i32,
|
||||
session: VoiceSession,
|
||||
}
|
||||
|
||||
impl VoiceParticipant {
|
||||
pub fn new(puri: String, s: VoiceSession) -> Result<Self, Error> {
|
||||
if puri.len() > 16 * 1024 || puri.chars().any(char::is_control) {
|
||||
return Err(Error::Argument);
|
||||
}
|
||||
Ok(Self {
|
||||
id: Self::id_from_name(puri.clone())?,
|
||||
avatar_name: String::new(),
|
||||
energy: 0.0,
|
||||
is_muted: false,
|
||||
is_speaking: false,
|
||||
uri: puri,
|
||||
volume: 0,
|
||||
session: s,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn id_from_name(in_name: String) -> Result<UUID, Error> {
|
||||
let bare = in_name
|
||||
.strip_prefix("sip:")
|
||||
.and_then(|value| value.split_once('@').map(|(name, _)| name))
|
||||
.unwrap_or(&in_name);
|
||||
if bare.len() != 25 || !bare.starts_with('x') || !bare.ends_with("==") {
|
||||
return Ok(UUID::zero());
|
||||
}
|
||||
let encoded = bare[1..].replace('-', "+").replace('_', "/");
|
||||
let bytes = base64::engine::general_purpose::STANDARD
|
||||
.decode(encoded)
|
||||
.map_err(|_| Error::Argument)?;
|
||||
if bytes.len() != 16 {
|
||||
return Err(Error::Argument);
|
||||
}
|
||||
UUID::new_with_bytes_int32(bytes, 0)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn energy(&self) -> f32 {
|
||||
self.energy
|
||||
}
|
||||
|
||||
pub fn set_energy(&mut self, value: f32) {
|
||||
self.energy = value;
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn id(&self) -> UUID {
|
||||
self.id
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn is_muted(&self) -> bool {
|
||||
self.is_muted
|
||||
}
|
||||
|
||||
pub fn set_is_muted(&mut self, value: bool) {
|
||||
self.is_muted = value;
|
||||
let _ = self
|
||||
.session
|
||||
.connector()
|
||||
.session_set_participant_mute_for_me(self.session.handle(), self.uri.clone(), value);
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn is_speaking(&self) -> bool {
|
||||
self.is_speaking
|
||||
}
|
||||
|
||||
pub fn set_is_speaking(&mut self, value: bool) {
|
||||
self.is_speaking = value;
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn name(&self) -> String {
|
||||
self.avatar_name.clone()
|
||||
}
|
||||
|
||||
pub fn set_name(&mut self, value: String) {
|
||||
self.avatar_name = value;
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn volume(&self) -> i32 {
|
||||
self.volume
|
||||
}
|
||||
|
||||
pub fn set_volume(&mut self, value: i32) {
|
||||
self.volume = value;
|
||||
let _ = self
|
||||
.session
|
||||
.connector()
|
||||
.session_set_participant_volume_for_me(self.session.handle(), self.uri.clone(), value);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
#[test]
|
||||
fn participant_uuid_round_trips_gateway_sip_name() {
|
||||
let client = libremetaverse::GridClient::new().unwrap();
|
||||
let gateway = VoiceGateway::new(client).unwrap();
|
||||
let id = UUID::new_with_string("1673cfd3-8229-4445-8d92-ec3570e5e587".into()).unwrap();
|
||||
let sip = gateway.sip_from_uuid(id).unwrap();
|
||||
assert_eq!(VoiceParticipant::id_from_name(sip).unwrap(), id);
|
||||
assert_eq!(
|
||||
VoiceParticipant::id_from_name("ordinary-name".into()).unwrap(),
|
||||
UUID::zero()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_participant_lifecycle_emits_only_real_changes() {
|
||||
let client = libremetaverse::GridClient::new().unwrap();
|
||||
let gateway = VoiceGateway::new(client).unwrap();
|
||||
let session = VoiceSession::new(gateway, "session".into()).unwrap();
|
||||
let count = Arc::new(AtomicU64::new(0));
|
||||
let observed = Arc::clone(&count);
|
||||
let _subscription = session.subscribe_on_participant_added(Some(Arc::new(move |()| {
|
||||
observed.fetch_add(1, Ordering::SeqCst);
|
||||
})));
|
||||
session
|
||||
.add_participant("sip:person@example.test".into())
|
||||
.unwrap();
|
||||
session
|
||||
.add_participant("sip:person@example.test".into())
|
||||
.unwrap();
|
||||
assert_eq!(count.load(Ordering::SeqCst), 1);
|
||||
session.update_participant("sip:person@example.test", true, true, 12, 0.5);
|
||||
session.remove_participant("sip:person@example.test");
|
||||
session.close();
|
||||
}
|
||||
}
|
||||
292
crates/libremetaverse-voice-vivox/src/tcp_pipe.rs
Normal file
292
crates/libremetaverse-voice-vivox/src/tcp_pipe.rs
Normal file
@@ -0,0 +1,292 @@
|
||||
//! Cross-platform TCP line transport used by the Vivox gateway adapter.
|
||||
|
||||
use crate::{Error, TCPPipeOnDisconnectedCallback, TCPPipeOnReceiveLineCallback};
|
||||
use libremetaverse_types::compat::{SocketException, Subscription};
|
||||
use std::collections::HashMap;
|
||||
use std::io::{Read, Write};
|
||||
use std::net::{Shutdown, TcpStream};
|
||||
use std::sync::{
|
||||
Arc, Mutex, Weak,
|
||||
atomic::{AtomicBool, AtomicU64, Ordering},
|
||||
};
|
||||
use std::thread::JoinHandle;
|
||||
use std::time::Duration;
|
||||
|
||||
const READ_POLL: Duration = Duration::from_millis(250);
|
||||
const MAX_BUFFER_BYTES: usize = 1024 * 1024;
|
||||
|
||||
fn lock<T>(mutex: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
|
||||
mutex
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
}
|
||||
|
||||
struct Registry<T> {
|
||||
next_id: AtomicU64,
|
||||
handlers: Arc<Mutex<HashMap<u64, T>>>,
|
||||
}
|
||||
|
||||
impl<T> Default for Registry<T> {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
next_id: AtomicU64::new(1),
|
||||
handlers: Arc::new(Mutex::new(HashMap::new())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: Send + 'static> Registry<T> {
|
||||
fn subscribe(&self, handler: Option<T>) -> Subscription {
|
||||
let Some(handler) = handler else {
|
||||
return Subscription::detached();
|
||||
};
|
||||
let id = self.next_id.fetch_add(1, Ordering::Relaxed);
|
||||
lock(&self.handlers).insert(id, handler);
|
||||
let handlers: Weak<Mutex<HashMap<u64, T>>> = Arc::downgrade(&self.handlers);
|
||||
Subscription::new(move || {
|
||||
if let Some(handlers) = handlers.upgrade() {
|
||||
lock(&handlers).remove(&id);
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: Clone> Registry<T> {
|
||||
fn snapshot(&self) -> Vec<T> {
|
||||
lock(&self.handlers).values().cloned().collect()
|
||||
}
|
||||
}
|
||||
|
||||
struct TCPPipeInner {
|
||||
writer: Mutex<Option<TcpStream>>,
|
||||
reader: Mutex<Option<JoinHandle<()>>>,
|
||||
connected: AtomicBool,
|
||||
intentional_disconnect: AtomicBool,
|
||||
received: Registry<TCPPipeOnReceiveLineCallback>,
|
||||
disconnected: Registry<TCPPipeOnDisconnectedCallback>,
|
||||
}
|
||||
|
||||
impl Default for TCPPipeInner {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
writer: Mutex::new(None),
|
||||
reader: Mutex::new(None),
|
||||
connected: AtomicBool::new(false),
|
||||
intentional_disconnect: AtomicBool::new(false),
|
||||
received: Registry::default(),
|
||||
disconnected: Registry::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct TCPPipe {
|
||||
inner: Arc<TCPPipeInner>,
|
||||
}
|
||||
|
||||
impl TCPPipe {
|
||||
pub fn new() -> Result<Self, Error> {
|
||||
Ok(Self {
|
||||
inner: Arc::new(TCPPipeInner::default()),
|
||||
})
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn subscribe_on_disconnected(
|
||||
&self,
|
||||
handler: Option<TCPPipeOnDisconnectedCallback>,
|
||||
) -> Subscription {
|
||||
self.inner.disconnected.subscribe(handler)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn subscribe_on_receive_line(
|
||||
&self,
|
||||
handler: Option<TCPPipeOnReceiveLineCallback>,
|
||||
) -> Subscription {
|
||||
self.inner.received.subscribe(handler)
|
||||
}
|
||||
|
||||
pub fn connect(&self, address: String, port: i32) -> Result<Option<SocketException>, Error> {
|
||||
let Ok(port) = u16::try_from(port) else {
|
||||
return Ok(Some(SocketException));
|
||||
};
|
||||
self.disconnect()?;
|
||||
let stream = match TcpStream::connect((address.as_str(), port)) {
|
||||
Ok(stream) => stream,
|
||||
Err(_) => return Ok(Some(SocketException)),
|
||||
};
|
||||
stream.set_nodelay(true).map_err(|_| Error::Socket)?;
|
||||
let reader = stream.try_clone().map_err(|_| Error::Socket)?;
|
||||
reader
|
||||
.set_read_timeout(Some(READ_POLL))
|
||||
.map_err(|_| Error::Socket)?;
|
||||
self.inner
|
||||
.intentional_disconnect
|
||||
.store(false, Ordering::Release);
|
||||
self.inner.connected.store(true, Ordering::Release);
|
||||
*lock(&self.inner.writer) = Some(stream);
|
||||
let inner = Arc::clone(&self.inner);
|
||||
*lock(&self.inner.reader) = Some(std::thread::spawn(move || read_loop(inner, reader)));
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
pub fn disconnect(&self) -> Result<(), Error> {
|
||||
self.inner
|
||||
.intentional_disconnect
|
||||
.store(true, Ordering::Release);
|
||||
self.inner.connected.store(false, Ordering::Release);
|
||||
if let Some(stream) = lock(&self.inner.writer).take() {
|
||||
let _ = stream.shutdown(Shutdown::Both);
|
||||
}
|
||||
if let Some(reader) = lock(&self.inner.reader).take()
|
||||
&& reader.thread().id() != std::thread::current().id()
|
||||
{
|
||||
reader.join().map_err(|_| Error::Socket)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn send_data(&self, data: Vec<u8>) -> Result<(), Error> {
|
||||
if !self.connected() {
|
||||
return Err(Error::InvalidOperation);
|
||||
}
|
||||
let mut writer = lock(&self.inner.writer);
|
||||
writer
|
||||
.as_mut()
|
||||
.ok_or(Error::InvalidOperation)?
|
||||
.write_all(&data)
|
||||
.map_err(|_| Error::Socket)
|
||||
}
|
||||
|
||||
pub fn send_line(&self, message: String) -> Result<(), Error> {
|
||||
if message.len() > MAX_BUFFER_BYTES {
|
||||
return Err(Error::Argument);
|
||||
}
|
||||
let mut bytes = Vec::with_capacity(message.len().saturating_add(1));
|
||||
bytes.extend(message.chars().map(|character| {
|
||||
if character.is_ascii() {
|
||||
character as u8
|
||||
} else {
|
||||
b'?'
|
||||
}
|
||||
}));
|
||||
bytes.push(b'\n');
|
||||
self.send_data(bytes)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn connected(&self) -> bool {
|
||||
self.inner.connected.load(Ordering::Acquire)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TCPPipe {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
fn read_loop(inner: Arc<TCPPipeInner>, mut reader: TcpStream) {
|
||||
let mut pending = Vec::new();
|
||||
let mut chunk = [0_u8; 4096];
|
||||
loop {
|
||||
match reader.read(&mut chunk) {
|
||||
Ok(0) => break,
|
||||
Ok(count) => {
|
||||
let data = &chunk[..count];
|
||||
let data = data.split(|byte| *byte == 0).next().unwrap_or_default();
|
||||
pending.extend_from_slice(data);
|
||||
if pending.len() > MAX_BUFFER_BYTES {
|
||||
break;
|
||||
}
|
||||
drain_lines(&inner, &mut pending);
|
||||
}
|
||||
Err(error)
|
||||
if matches!(
|
||||
error.kind(),
|
||||
std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut
|
||||
) =>
|
||||
{
|
||||
if !inner.connected.load(Ordering::Acquire) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
let was_connected = inner.connected.swap(false, Ordering::AcqRel);
|
||||
lock(&inner.writer).take();
|
||||
if was_connected && !inner.intentional_disconnect.load(Ordering::Acquire) {
|
||||
for callback in inner.disconnected.snapshot() {
|
||||
let _ = callback.invoke(SocketException);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn drain_lines(inner: &TCPPipeInner, pending: &mut Vec<u8>) {
|
||||
loop {
|
||||
let Some(index) = pending
|
||||
.iter()
|
||||
.position(|byte| matches!(*byte, b'\r' | b'\n'))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let line = String::from_utf8_lossy(&pending[..index]).into_owned();
|
||||
let mut consumed = index + 1;
|
||||
if pending.get(index) == Some(&b'\r') && pending.get(consumed) == Some(&b'\n') {
|
||||
consumed += 1;
|
||||
}
|
||||
pending.drain(..consumed);
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
for callback in inner.received.snapshot() {
|
||||
let _ = callback.invoke(line.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::net::TcpListener;
|
||||
use std::sync::mpsc;
|
||||
|
||||
#[test]
|
||||
fn connects_sends_and_frames_crlf_and_lf_lines() {
|
||||
let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
let server = std::thread::spawn(move || {
|
||||
let (mut stream, _) = listener.accept().unwrap();
|
||||
stream.write_all(b"first\r\nsecond\n").unwrap();
|
||||
let mut sent = [0_u8; 6];
|
||||
stream.read_exact(&mut sent).unwrap();
|
||||
assert_eq!(&sent, b"hello\n");
|
||||
});
|
||||
|
||||
let pipe = TCPPipe::new().unwrap();
|
||||
let (sender, receiver) = mpsc::channel();
|
||||
let _subscription =
|
||||
pipe.subscribe_on_receive_line(Some(TCPPipeOnReceiveLineCallback::from_handler(
|
||||
move |line| sender.send(line).map_err(|_| Error::Socket),
|
||||
)));
|
||||
assert!(
|
||||
pipe.connect("127.0.0.1".into(), i32::from(port))
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
assert!(pipe.connected());
|
||||
assert_eq!(
|
||||
receiver.recv_timeout(Duration::from_secs(2)).unwrap(),
|
||||
"first"
|
||||
);
|
||||
assert_eq!(
|
||||
receiver.recv_timeout(Duration::from_secs(2)).unwrap(),
|
||||
"second"
|
||||
);
|
||||
pipe.send_line("hello".into()).unwrap();
|
||||
server.join().unwrap();
|
||||
pipe.disconnect().unwrap();
|
||||
assert!(!pipe.connected());
|
||||
}
|
||||
}
|
||||
1096
crates/libremetaverse-voice-vivox/src/voice_manager.rs
Normal file
1096
crates/libremetaverse-voice-vivox/src/voice_manager.rs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,77 @@
|
||||
use libremetaverse_voice_vivox::types::Vector3d;
|
||||
use libremetaverse_voice_vivox::{
|
||||
VoiceGatewayParticipantUpdatedEventArgs, VoiceGatewayResponseType, VoiceGatewayVoiceEvent,
|
||||
VoiceGatewayVoiceLoggingSettings, VoiceGatewayVoiceRequest, VoiceGatewayVoiceResponse,
|
||||
VoiceGatewayVoiceResponseEventArgs, VoiceGatewayVoiceResponseResults, VoicePosition,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn vivox_xml_models_start_with_clr_equivalent_defaults() {
|
||||
let event = VoiceGatewayVoiceEvent::new().expect("VoiceEvent constructor");
|
||||
assert!(event.type_.is_none());
|
||||
assert!(event.session_handle.is_none());
|
||||
|
||||
let request = VoiceGatewayVoiceRequest::new().expect("VoiceRequest constructor");
|
||||
assert!(request.request_id.is_none());
|
||||
assert!(request.logging.is_none());
|
||||
assert!(request.listener_position.is_none());
|
||||
|
||||
let response = VoiceGatewayVoiceResponse::new().expect("VoiceResponse constructor");
|
||||
assert_eq!(response.return_code, 0);
|
||||
assert!(response.results.is_none());
|
||||
|
||||
let results =
|
||||
VoiceGatewayVoiceResponseResults::new().expect("VoiceResponseResults constructor");
|
||||
assert_eq!(results.status_code, 0);
|
||||
assert!(results.capture_devices.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[allow(clippy::float_cmp)] // Constructor retains this exactly representable fixture value.
|
||||
fn vivox_event_payload_constructors_retain_every_argument() {
|
||||
let participant = VoiceGatewayParticipantUpdatedEventArgs::new(
|
||||
"session".into(),
|
||||
"sip:participant@example.test".into(),
|
||||
true,
|
||||
false,
|
||||
37,
|
||||
0.625,
|
||||
)
|
||||
.expect("ParticipantUpdatedEventArgs constructor");
|
||||
assert_eq!(participant.session_handle, "session");
|
||||
assert_eq!(participant.uri, "sip:participant@example.test");
|
||||
assert!(participant.is_muted);
|
||||
assert!(!participant.is_speaking);
|
||||
assert_eq!(participant.volume, 37);
|
||||
assert_eq!(participant.energy, 0.625);
|
||||
|
||||
let response = VoiceGatewayVoiceResponseEventArgs::new(
|
||||
VoiceGatewayResponseType::SessionCreate,
|
||||
1,
|
||||
2,
|
||||
"rejected".into(),
|
||||
)
|
||||
.expect("VoiceResponseEventArgs constructor");
|
||||
assert_eq!(response.type_, VoiceGatewayResponseType::SessionCreate);
|
||||
assert_eq!(response.return_code, 1);
|
||||
assert_eq!(response.status_code, 2);
|
||||
assert_eq!(response.message, "rejected");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vivox_explicit_logging_and_position_defaults_match_source() {
|
||||
let logging =
|
||||
VoiceGatewayVoiceLoggingSettings::new().expect("VoiceLoggingSettings constructor");
|
||||
assert!(!logging.enabled);
|
||||
assert!(logging.folder.is_empty());
|
||||
assert_eq!(logging.file_name_prefix, "Connector");
|
||||
assert_eq!(logging.file_name_suffix, ".log");
|
||||
assert_eq!(logging.log_level, 0);
|
||||
|
||||
let position = VoicePosition::new().expect("VoicePosition constructor");
|
||||
assert_eq!(position.position, Vector3d::default());
|
||||
assert_eq!(position.velocity, Vector3d::default());
|
||||
assert_eq!(position.at_orientation, Vector3d::default());
|
||||
assert_eq!(position.up_orientation, Vector3d::default());
|
||||
assert_eq!(position.left_orientation, Vector3d::default());
|
||||
}
|
||||
Reference in New Issue
Block a user