3662 lines
129 KiB
Rust
3662 lines
129 KiB
Rust
//! Movement, object, parcel, estate, and grid command groups for native `TestClient`.
|
|
|
|
#![allow(
|
|
clippy::elidable_lifetime_names,
|
|
clippy::format_push_string,
|
|
clippy::needless_pass_by_value,
|
|
clippy::too_many_lines,
|
|
private_interfaces
|
|
)]
|
|
|
|
use super::{ClientManager, FakeBackend, InventoryFuture as BackendFuture, LiveBackend, lock};
|
|
use libremetaverse::types::compat::{CancellationToken, Subscription};
|
|
use libremetaverse::types::{AssetType, FolderType, PrimFlags, Quaternion, UUID, Vector2, Vector3};
|
|
use libremetaverse::{
|
|
GridLayer, GridRegion, Parcel, PermissionMask, Primitive, PrimitiveObjectProperties,
|
|
PrimitiveParticleSystemSourcePattern, PrimitiveTextureEntry, Tree,
|
|
};
|
|
use libremetaverse_structured_data::{OSD, OSDParser};
|
|
use std::collections::{BTreeSet, HashMap, HashSet};
|
|
use std::fs;
|
|
use std::path::{Component, Path, PathBuf};
|
|
use std::sync::{Arc, Mutex};
|
|
use std::time::Duration;
|
|
|
|
const MAX_FILE_BYTES: u64 = 64 * 1024 * 1024;
|
|
const MAX_EXPORT_PRIMS: usize = 10_000;
|
|
const MAX_QUERY_RESULTS: usize = 65_535;
|
|
const MAX_MOVEMENT_SECONDS: u64 = 60;
|
|
|
|
#[derive(Clone, Copy)]
|
|
pub(super) enum Command {
|
|
DownloadTerrain,
|
|
EstateCovenant,
|
|
UploadTerrain,
|
|
AgentLocations,
|
|
FindSim,
|
|
GridLayer,
|
|
GridMap,
|
|
ParcelDetails,
|
|
ParcelInfo,
|
|
PrimOwners,
|
|
SelectObjects,
|
|
SyntaxId,
|
|
Wind,
|
|
Back,
|
|
CrossRegion,
|
|
Crouch,
|
|
Fly,
|
|
FlyTo,
|
|
Follow,
|
|
Forward,
|
|
GoHome,
|
|
Goto,
|
|
GotoLandmark,
|
|
Jump,
|
|
Left,
|
|
Location,
|
|
MoveTo,
|
|
Right,
|
|
SetHome,
|
|
Sit,
|
|
SitOn,
|
|
Stand,
|
|
TurnTo,
|
|
ChangePerms,
|
|
DeRez,
|
|
DownloadTexture,
|
|
Export,
|
|
ExportParticles,
|
|
FindObjects,
|
|
FindTexture,
|
|
Import,
|
|
PrimCount,
|
|
PrimInfo,
|
|
PrimRegex,
|
|
Textures,
|
|
Tree,
|
|
}
|
|
|
|
pub(super) fn commands() -> Vec<super::CommandEntry> {
|
|
use super::{CommandCategory as Cat, CommandHandler};
|
|
use Command as C;
|
|
let world = |command| CommandHandler::World(command);
|
|
vec![
|
|
(
|
|
"downloadterrain",
|
|
"Download the current estate RAW terrain. Usage: downloadterrain [timeout-ms] [output] --confirm",
|
|
Cat::Simulator,
|
|
world(C::DownloadTerrain),
|
|
),
|
|
(
|
|
"getestatecovenant",
|
|
"Retrieve estate covenant information. Usage: getestatecovenant [timeout-seconds]",
|
|
Cat::Simulator,
|
|
world(C::EstateCovenant),
|
|
),
|
|
(
|
|
"uploadterrain",
|
|
"Upload a RAW terrain file. Usage: uploadterrain [file] --confirm",
|
|
Cat::Simulator,
|
|
world(C::UploadTerrain),
|
|
),
|
|
(
|
|
"agentlocations",
|
|
"List agent map locations. Usage: agentlocations [region-handle]",
|
|
Cat::Simulator,
|
|
world(C::AgentLocations),
|
|
),
|
|
(
|
|
"findsim",
|
|
"Find a simulator. Usage: findsim [simulator name]",
|
|
Cat::Simulator,
|
|
world(C::FindSim),
|
|
),
|
|
(
|
|
"gridlayer",
|
|
"Download grid object-map layer chunks",
|
|
Cat::Simulator,
|
|
world(C::GridLayer),
|
|
),
|
|
(
|
|
"gridmap",
|
|
"Download visible grid map information",
|
|
Cat::Simulator,
|
|
world(C::GridMap),
|
|
),
|
|
(
|
|
"parceldetails",
|
|
"Display parcel details. Usage: parceldetails [parcel-id]",
|
|
Cat::Parcel,
|
|
world(C::ParcelDetails),
|
|
),
|
|
(
|
|
"parcelinfo",
|
|
"Display all parcels in the current simulator",
|
|
Cat::Parcel,
|
|
world(C::ParcelInfo),
|
|
),
|
|
(
|
|
"primowners",
|
|
"Display parcel prim owners. Usage: primowners [parcel-id]",
|
|
Cat::Parcel,
|
|
world(C::PrimOwners),
|
|
),
|
|
(
|
|
"selectobjects",
|
|
"List parcel objects for an owner. Usage: selectobjects [parcel-id] [owner-uuid]",
|
|
Cat::Parcel,
|
|
world(C::SelectObjects),
|
|
),
|
|
(
|
|
"syntaxid",
|
|
"Display the current native LSL syntax identifiers",
|
|
Cat::Simulator,
|
|
world(C::SyntaxId),
|
|
),
|
|
(
|
|
"wind",
|
|
"Display local wind data",
|
|
Cat::Simulator,
|
|
world(C::Wind),
|
|
),
|
|
(
|
|
"back",
|
|
"Move backward. Usage: back [seconds] --confirm",
|
|
Cat::Movement,
|
|
world(C::Back),
|
|
),
|
|
(
|
|
"crossregion",
|
|
"Cross a region border. Usage: crossregion [direction] [walk|fly] --confirm",
|
|
Cat::Movement,
|
|
world(C::CrossRegion),
|
|
),
|
|
(
|
|
"crouch",
|
|
"Start or stop crouching. Usage: crouch [start|stop] --confirm",
|
|
Cat::Movement,
|
|
world(C::Crouch),
|
|
),
|
|
(
|
|
"fly",
|
|
"Start or stop flying. Usage: fly [start|stop] --confirm",
|
|
Cat::Movement,
|
|
world(C::Fly),
|
|
),
|
|
(
|
|
"flyto",
|
|
"Fly toward a position. Usage: flyto x y z [seconds] --confirm",
|
|
Cat::Movement,
|
|
world(C::FlyTo),
|
|
),
|
|
(
|
|
"follow",
|
|
"Follow another avatar. Usage: follow [First Last|off] --confirm",
|
|
Cat::Movement,
|
|
world(C::Follow),
|
|
),
|
|
(
|
|
"forward",
|
|
"Move forward. Usage: forward [seconds] --confirm",
|
|
Cat::Movement,
|
|
world(C::Forward),
|
|
),
|
|
(
|
|
"gohome",
|
|
"Teleport home. Usage: gohome --confirm",
|
|
Cat::Movement,
|
|
world(C::GoHome),
|
|
),
|
|
(
|
|
"goto",
|
|
"Teleport to a location. Usage: goto sim/x/y/z --confirm",
|
|
Cat::Movement,
|
|
world(C::Goto),
|
|
),
|
|
(
|
|
"goto_landmark",
|
|
"Teleport to a landmark. Usage: goto_landmark [uuid] --confirm",
|
|
Cat::Movement,
|
|
world(C::GotoLandmark),
|
|
),
|
|
(
|
|
"jump",
|
|
"Jump or fly up. Usage: jump --confirm",
|
|
Cat::Movement,
|
|
world(C::Jump),
|
|
),
|
|
(
|
|
"left",
|
|
"Move left. Usage: left [seconds] --confirm",
|
|
Cat::Movement,
|
|
world(C::Left),
|
|
),
|
|
(
|
|
"location",
|
|
"Show the current simulator and position",
|
|
Cat::Movement,
|
|
world(C::Location),
|
|
),
|
|
(
|
|
"moveto",
|
|
"Use simulator autopilot. Usage: moveto x y z --confirm",
|
|
Cat::Movement,
|
|
world(C::MoveTo),
|
|
),
|
|
(
|
|
"right",
|
|
"Move right. Usage: right [seconds] --confirm",
|
|
Cat::Movement,
|
|
world(C::Right),
|
|
),
|
|
(
|
|
"sethome",
|
|
"Set home to the current location. Usage: sethome --confirm",
|
|
Cat::Movement,
|
|
world(C::SetHome),
|
|
),
|
|
(
|
|
"sit",
|
|
"Sit on the closest primitive. Usage: sit --confirm",
|
|
Cat::Movement,
|
|
world(C::Sit),
|
|
),
|
|
(
|
|
"siton",
|
|
"Sit on a primitive. Usage: siton [uuid] --confirm",
|
|
Cat::Movement,
|
|
world(C::SitOn),
|
|
),
|
|
(
|
|
"stand",
|
|
"Stand up. Usage: stand --confirm",
|
|
Cat::Movement,
|
|
world(C::Stand),
|
|
),
|
|
(
|
|
"turnto",
|
|
"Turn toward a point. Usage: turnto x y z --confirm",
|
|
Cat::Movement,
|
|
world(C::TurnTo),
|
|
),
|
|
(
|
|
"changeperms",
|
|
"Change linkset next-owner permissions. Usage: changeperms [uuid] [copy] [mod] [xfer] --confirm",
|
|
Cat::Objects,
|
|
world(C::ChangePerms),
|
|
),
|
|
(
|
|
"derez",
|
|
"Take a primitive into Trash. Usage: derez [uuid] --confirm",
|
|
Cat::Objects,
|
|
world(C::DeRez),
|
|
),
|
|
(
|
|
"downloadtexture",
|
|
"Download a texture. Usage: downloadtexture [uuid] [discard-level] [output]",
|
|
Cat::Inventory,
|
|
world(C::DownloadTexture),
|
|
),
|
|
(
|
|
"export",
|
|
"Export a linkset and its textures. Usage: export [uuid] [output.xml]",
|
|
Cat::Objects,
|
|
world(C::Export),
|
|
),
|
|
(
|
|
"exportparticles",
|
|
"Convert a particle system to LSL. Usage: exportparticles [uuid]",
|
|
Cat::Objects,
|
|
world(C::ExportParticles),
|
|
),
|
|
(
|
|
"findobjects",
|
|
"Find objects by radius and name. Usage: findobjects [radius] [search]",
|
|
Cat::Objects,
|
|
world(C::FindObjects),
|
|
),
|
|
(
|
|
"findtexture",
|
|
"Find a texture on a face. Usage: findtexture [face-index] [uuid]",
|
|
Cat::Objects,
|
|
world(C::FindTexture),
|
|
),
|
|
(
|
|
"import",
|
|
"Import linksets from XML. Usage: import [input.xml] [usegroup] --confirm",
|
|
Cat::Objects,
|
|
world(C::Import),
|
|
),
|
|
(
|
|
"primcount",
|
|
"Show tracked avatar and primitive counts",
|
|
Cat::TestClient,
|
|
world(C::PrimCount),
|
|
),
|
|
(
|
|
"priminfo",
|
|
"Display primitive details. Usage: priminfo [uuid]",
|
|
Cat::Objects,
|
|
world(C::PrimInfo),
|
|
),
|
|
(
|
|
"primregex",
|
|
"Find primitives by regular-expression-like text. Usage: primregex [predicate]",
|
|
Cat::Objects,
|
|
world(C::PrimRegex),
|
|
),
|
|
(
|
|
"textures",
|
|
"Enable or disable automatic texture downloading. Usage: textures [on|off]",
|
|
Cat::Objects,
|
|
world(C::Textures),
|
|
),
|
|
(
|
|
"tree",
|
|
"Rez a tree. Usage: tree [species] --confirm",
|
|
Cat::Objects,
|
|
world(C::Tree),
|
|
),
|
|
]
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
struct AvatarView {
|
|
id: UUID,
|
|
name: String,
|
|
position: Vector3,
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
struct OwnerView {
|
|
owner: UUID,
|
|
count: i32,
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
struct ParcelView {
|
|
parcel: Parcel,
|
|
owners: Vec<OwnerView>,
|
|
selected: HashMap<UUID, Vec<u32>>,
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
struct AgentLocation {
|
|
count: i32,
|
|
x: u32,
|
|
y: u32,
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
struct EstateView {
|
|
name: String,
|
|
owner: UUID,
|
|
covenant: UUID,
|
|
timestamp: u32,
|
|
body: String,
|
|
terrain: Vec<u8>,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Default)]
|
|
struct Snapshot {
|
|
region_name: String,
|
|
region_handle: u64,
|
|
position: Vector3,
|
|
wind: Option<Vec<Vector2>>,
|
|
primitives: Vec<Primitive>,
|
|
avatars: Vec<AvatarView>,
|
|
parcels: Vec<ParcelView>,
|
|
regions: Vec<GridRegion>,
|
|
layers: Vec<GridLayer>,
|
|
locations: HashMap<u64, Vec<AgentLocation>>,
|
|
estate: Option<EstateView>,
|
|
assets: HashMap<UUID, Vec<u8>>,
|
|
syntax: Vec<String>,
|
|
}
|
|
|
|
pub(super) struct State {
|
|
policy: super::inventory::MutationPolicy,
|
|
fake: Snapshot,
|
|
textures_enabled: bool,
|
|
requested_textures: HashSet<UUID>,
|
|
follow_target: Option<UUID>,
|
|
}
|
|
|
|
impl State {
|
|
pub(super) fn new(policy: super::inventory::MutationPolicy, textures_enabled: bool) -> Self {
|
|
Self {
|
|
policy,
|
|
fake: Snapshot::default(),
|
|
textures_enabled,
|
|
requested_textures: HashSet::new(),
|
|
follow_target: None,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug)]
|
|
enum MoveDirection {
|
|
Back,
|
|
Forward,
|
|
Left,
|
|
Right,
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
enum Movement {
|
|
Pulse(MoveDirection, Duration),
|
|
Crouch(bool),
|
|
Fly(bool),
|
|
FlyTo(Vector3, Duration),
|
|
Follow(Option<(UUID, Vector3)>),
|
|
GoHome,
|
|
TeleportRegion(String, Vector3),
|
|
TeleportLandmark(UUID),
|
|
Jump,
|
|
AutoPilot { local: Vector3, global: [f64; 3] },
|
|
SetHome,
|
|
Sit(UUID),
|
|
Stand,
|
|
Turn(Vector3),
|
|
Cross(Vector3, bool),
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
enum Mutation {
|
|
Movement(Movement),
|
|
ChangePermissions {
|
|
root: UUID,
|
|
permissions: PermissionMask,
|
|
},
|
|
DeRez(UUID),
|
|
Import {
|
|
primitives: Vec<Primitive>,
|
|
use_group: bool,
|
|
},
|
|
SetTextures(bool),
|
|
Tree(Tree),
|
|
UploadTerrain {
|
|
name: String,
|
|
data: Vec<u8>,
|
|
},
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
enum Query {
|
|
AgentLocations(u64),
|
|
FindRegion(String),
|
|
GridLayer,
|
|
GridMap,
|
|
Parcels,
|
|
ParcelOwners(i32),
|
|
ParcelObjects(i32, UUID),
|
|
EstateCovenant(Duration),
|
|
DownloadTerrain(Duration),
|
|
}
|
|
|
|
pub(super) trait Backend {
|
|
fn world_state(&self) -> &Mutex<State>;
|
|
fn world_agent_id(&self) -> UUID;
|
|
fn world_snapshot<'a>(
|
|
&'a self,
|
|
cancellation: CancellationToken,
|
|
) -> BackendFuture<'a, Result<Snapshot, String>>;
|
|
fn world_query<'a>(
|
|
&'a self,
|
|
query: Query,
|
|
cancellation: CancellationToken,
|
|
) -> BackendFuture<'a, Result<Snapshot, String>>;
|
|
fn world_mutate<'a>(
|
|
&'a self,
|
|
mutation: Mutation,
|
|
cancellation: CancellationToken,
|
|
) -> BackendFuture<'a, Result<String, String>>;
|
|
fn world_texture<'a>(
|
|
&'a self,
|
|
id: UUID,
|
|
discard: i32,
|
|
cancellation: CancellationToken,
|
|
) -> BackendFuture<'a, Result<Vec<u8>, String>>;
|
|
fn world_resolve_avatar<'a>(
|
|
&'a self,
|
|
name: &'a str,
|
|
cancellation: CancellationToken,
|
|
) -> BackendFuture<'a, Result<Option<UUID>, String>>;
|
|
fn apply_world_fixture(&self, _fields: &[&str]) -> Result<(), &'static str> {
|
|
Err("world fixture requires fake-grid mode")
|
|
}
|
|
}
|
|
|
|
pub(super) async fn execute<B: Backend + ?Sized>(
|
|
backend: &B,
|
|
command: Command,
|
|
args: &[String],
|
|
cancellation: CancellationToken,
|
|
) -> String {
|
|
let result = match command {
|
|
Command::DownloadTerrain => download_terrain(backend, args, cancellation).await,
|
|
Command::EstateCovenant => estate_covenant(backend, args, cancellation).await,
|
|
Command::UploadTerrain => upload_terrain(backend, args, cancellation).await,
|
|
Command::AgentLocations => agent_locations(backend, args, cancellation).await,
|
|
Command::FindSim => find_sim(backend, args, cancellation).await,
|
|
Command::GridLayer => grid_layer(backend, args, cancellation).await,
|
|
Command::GridMap => grid_map(backend, args, cancellation).await,
|
|
Command::ParcelDetails => parcel_details(backend, args, cancellation).await,
|
|
Command::ParcelInfo => parcel_info(backend, args, cancellation).await,
|
|
Command::PrimOwners => prim_owners(backend, args, cancellation).await,
|
|
Command::SelectObjects => select_objects(backend, args, cancellation).await,
|
|
Command::SyntaxId => syntax_id(backend, args, cancellation).await,
|
|
Command::Wind => wind(backend, args, cancellation).await,
|
|
Command::Back => directional(backend, args, MoveDirection::Back, cancellation).await,
|
|
Command::Forward => directional(backend, args, MoveDirection::Forward, cancellation).await,
|
|
Command::Left => directional(backend, args, MoveDirection::Left, cancellation).await,
|
|
Command::Right => directional(backend, args, MoveDirection::Right, cancellation).await,
|
|
Command::CrossRegion => cross_region(backend, args, cancellation).await,
|
|
Command::Crouch => toggle_movement(backend, args, true, cancellation).await,
|
|
Command::Fly => toggle_movement(backend, args, false, cancellation).await,
|
|
Command::FlyTo => fly_to(backend, args, cancellation).await,
|
|
Command::Follow => follow(backend, args, cancellation).await,
|
|
Command::GoHome => {
|
|
simple_movement(
|
|
backend,
|
|
args,
|
|
Movement::GoHome,
|
|
"Teleport Home Succesful",
|
|
cancellation,
|
|
)
|
|
.await
|
|
}
|
|
Command::Goto => goto(backend, args, cancellation).await,
|
|
Command::GotoLandmark => goto_landmark(backend, args, cancellation).await,
|
|
Command::Jump => {
|
|
simple_movement(backend, args, Movement::Jump, "Jumped", cancellation).await
|
|
}
|
|
Command::Location => location(backend, args, cancellation).await,
|
|
Command::MoveTo => move_to(backend, args, cancellation).await,
|
|
Command::SetHome => {
|
|
simple_movement(backend, args, Movement::SetHome, "Home Set", cancellation).await
|
|
}
|
|
Command::Sit => sit(backend, args, None, cancellation).await,
|
|
Command::SitOn => sit(backend, args, Some(()), cancellation).await,
|
|
Command::Stand => {
|
|
simple_movement(backend, args, Movement::Stand, "Standing up.", cancellation).await
|
|
}
|
|
Command::TurnTo => turn_to(backend, args, cancellation).await,
|
|
Command::ChangePerms => change_permissions(backend, args, cancellation).await,
|
|
Command::DeRez => derez(backend, args, cancellation).await,
|
|
Command::DownloadTexture => download_texture(backend, args, cancellation).await,
|
|
Command::Export => export(backend, args, cancellation).await,
|
|
Command::ExportParticles => export_particles(backend, args, cancellation).await,
|
|
Command::FindObjects => find_objects(backend, args, cancellation).await,
|
|
Command::FindTexture => find_texture(backend, args, cancellation).await,
|
|
Command::Import => import(backend, args, cancellation).await,
|
|
Command::PrimCount => prim_count(backend, args, cancellation).await,
|
|
Command::PrimInfo => prim_info(backend, args, cancellation).await,
|
|
Command::PrimRegex => prim_regex(backend, args, cancellation).await,
|
|
Command::Textures => textures(backend, args, cancellation).await,
|
|
Command::Tree => tree(backend, args, cancellation).await,
|
|
};
|
|
result.unwrap_or_else(|error| error)
|
|
}
|
|
|
|
fn authorize<B: Backend + ?Sized>(
|
|
backend: &B,
|
|
args: &[String],
|
|
spending: bool,
|
|
estate: bool,
|
|
) -> Result<Vec<String>, String> {
|
|
let policy = lock(backend.world_state()).policy;
|
|
if !policy.mutations() {
|
|
return Err("Live world mutation blocked; restart with --allow-live-mutations".into());
|
|
}
|
|
if spending && !policy.spending() {
|
|
return Err("Upload blocked; restart with --allow-spending".into());
|
|
}
|
|
if estate && !policy.estate() {
|
|
return Err("Estate action blocked; restart with --allow-estate-actions".into());
|
|
}
|
|
if !args.iter().any(|value| value == "--confirm") {
|
|
return Err("Operation requires an explicit --confirm argument".into());
|
|
}
|
|
Ok(args
|
|
.iter()
|
|
.filter(|value| value.as_str() != "--confirm")
|
|
.cloned()
|
|
.collect())
|
|
}
|
|
|
|
fn parse_uuid(value: &str) -> Result<UUID, String> {
|
|
UUID::new_with_string(value.into()).map_err(|_| format!("{value} is not a valid UUID"))
|
|
}
|
|
|
|
fn parse_vector(args: &[String], usage: &str) -> Result<Vector3, String> {
|
|
if args.len() != 3 {
|
|
return Err(usage.into());
|
|
}
|
|
let values = args
|
|
.iter()
|
|
.map(|value| value.parse::<f32>())
|
|
.collect::<Result<Vec<_>, _>>()
|
|
.map_err(|_| usage.to_owned())?;
|
|
if !values.iter().all(|value| value.is_finite()) {
|
|
return Err(usage.into());
|
|
}
|
|
Vector3::new_with_single_single_single(values[0], values[1], values[2])
|
|
.map_err(|_| usage.into())
|
|
}
|
|
|
|
fn safe_path(value: &str) -> Result<PathBuf, String> {
|
|
let path = PathBuf::from(value);
|
|
if path
|
|
.components()
|
|
.any(|component| matches!(component, Component::ParentDir))
|
|
{
|
|
return Err("Parent path traversal is not allowed".into());
|
|
}
|
|
Ok(path)
|
|
}
|
|
|
|
fn read_bounded(path: &Path) -> Result<Vec<u8>, String> {
|
|
let metadata = fs::metadata(path)
|
|
.map_err(|error| format!("Could not inspect {}: {error}", path.display()))?;
|
|
if metadata.len() > MAX_FILE_BYTES {
|
|
return Err(format!("{} exceeds the 64 MiB limit", path.display()));
|
|
}
|
|
fs::read(path).map_err(|error| format!("Could not read {}: {error}", path.display()))
|
|
}
|
|
|
|
fn write_bounded(path: &Path, data: &[u8]) -> Result<(), String> {
|
|
if u64::try_from(data.len()).unwrap_or(u64::MAX) > MAX_FILE_BYTES {
|
|
return Err("Output exceeds the 64 MiB limit".into());
|
|
}
|
|
if let Some(parent) = path
|
|
.parent()
|
|
.filter(|parent| !parent.as_os_str().is_empty())
|
|
{
|
|
fs::create_dir_all(parent)
|
|
.map_err(|error| format!("Could not create {}: {error}", parent.display()))?;
|
|
}
|
|
fs::write(path, data).map_err(|error| format!("Could not write {}: {error}", path.display()))
|
|
}
|
|
|
|
async fn directional<B: Backend + ?Sized>(
|
|
backend: &B,
|
|
args: &[String],
|
|
direction: MoveDirection,
|
|
cancellation: CancellationToken,
|
|
) -> Result<String, String> {
|
|
let args = authorize(backend, args, false, false)?;
|
|
if args.len() > 1 {
|
|
return Err(format!(
|
|
"Usage: {} [seconds] --confirm",
|
|
direction_name(direction)
|
|
));
|
|
}
|
|
let seconds = args
|
|
.first()
|
|
.map_or(Ok(0), |value| value.parse::<u64>())
|
|
.map_err(|_| format!("Usage: {} [seconds] --confirm", direction_name(direction)))?;
|
|
if seconds > MAX_MOVEMENT_SECONDS {
|
|
return Err("Movement duration must not exceed 60 seconds".into());
|
|
}
|
|
backend
|
|
.world_mutate(
|
|
Mutation::Movement(Movement::Pulse(direction, Duration::from_secs(seconds))),
|
|
cancellation,
|
|
)
|
|
.await?;
|
|
Ok(format!("Moved {}", direction_past(direction)))
|
|
}
|
|
|
|
const fn direction_name(value: MoveDirection) -> &'static str {
|
|
match value {
|
|
MoveDirection::Back => "back",
|
|
MoveDirection::Forward => "forward",
|
|
MoveDirection::Left => "left",
|
|
MoveDirection::Right => "right",
|
|
}
|
|
}
|
|
const fn direction_past(value: MoveDirection) -> &'static str {
|
|
match value {
|
|
MoveDirection::Back => "backward",
|
|
MoveDirection::Forward => "forward",
|
|
MoveDirection::Left => "left",
|
|
MoveDirection::Right => "right",
|
|
}
|
|
}
|
|
|
|
async fn toggle_movement<B: Backend + ?Sized>(
|
|
backend: &B,
|
|
args: &[String],
|
|
crouch: bool,
|
|
cancellation: CancellationToken,
|
|
) -> Result<String, String> {
|
|
let args = authorize(backend, args, false, false)?;
|
|
if args.len() > 1 {
|
|
return Err(if crouch {
|
|
"Usage: crouch [start/stop] --confirm"
|
|
} else {
|
|
"Usage: fly [start/stop] --confirm"
|
|
}
|
|
.into());
|
|
}
|
|
let enabled = !args
|
|
.first()
|
|
.is_some_and(|arg| arg.eq_ignore_ascii_case("stop"));
|
|
let mutation = if crouch {
|
|
Movement::Crouch(enabled)
|
|
} else {
|
|
Movement::Fly(enabled)
|
|
};
|
|
backend
|
|
.world_mutate(Mutation::Movement(mutation), cancellation)
|
|
.await?;
|
|
Ok(format!(
|
|
"{} {}",
|
|
if enabled { "Started" } else { "Stopped" },
|
|
if crouch { "crouching" } else { "flying" }
|
|
))
|
|
}
|
|
|
|
async fn simple_movement<B: Backend + ?Sized>(
|
|
backend: &B,
|
|
args: &[String],
|
|
movement: Movement,
|
|
output: &str,
|
|
cancellation: CancellationToken,
|
|
) -> Result<String, String> {
|
|
let args = authorize(backend, args, false, false)?;
|
|
if !args.is_empty() {
|
|
return Err("This command accepts only --confirm".into());
|
|
}
|
|
backend
|
|
.world_mutate(Mutation::Movement(movement), cancellation)
|
|
.await?;
|
|
Ok(output.into())
|
|
}
|
|
|
|
async fn fly_to<B: Backend + ?Sized>(
|
|
backend: &B,
|
|
args: &[String],
|
|
cancellation: CancellationToken,
|
|
) -> Result<String, String> {
|
|
let args = authorize(backend, args, false, false)?;
|
|
if !(3..=4).contains(&args.len()) {
|
|
return Err("Usage: flyto x y z [seconds] --confirm".into());
|
|
}
|
|
let target = parse_vector(&args[..3], "Usage: flyto x y z [seconds] --confirm")?;
|
|
let seconds = args
|
|
.get(3)
|
|
.map_or(Ok(10), |value| value.parse::<u64>())
|
|
.map_err(|_| "Usage: flyto x y z [seconds] --confirm")?;
|
|
if seconds == 0 || seconds > MAX_MOVEMENT_SECONDS {
|
|
return Err("FlyTo duration must be between 1 and 60 seconds".into());
|
|
}
|
|
backend
|
|
.world_mutate(
|
|
Mutation::Movement(Movement::FlyTo(target, Duration::from_secs(seconds))),
|
|
cancellation,
|
|
)
|
|
.await?;
|
|
Ok(format!("flying to {target:?} in {seconds} seconds"))
|
|
}
|
|
|
|
async fn cross_region<B: Backend + ?Sized>(
|
|
backend: &B,
|
|
args: &[String],
|
|
cancellation: CancellationToken,
|
|
) -> Result<String, String> {
|
|
let args = authorize(backend, args, false, false)?;
|
|
if args.is_empty() || args.len() > 2 {
|
|
return Err("Usage: crossregion [direction] [walk/fly] --confirm".into());
|
|
}
|
|
let (direction, name) = match args[0].to_ascii_lowercase().as_str() {
|
|
"n" | "north" => ((0.0, 1.0), "North"),
|
|
"s" | "south" => ((0.0, -1.0), "South"),
|
|
"e" | "east" => ((1.0, 0.0), "East"),
|
|
"w" | "west" => ((-1.0, 0.0), "West"),
|
|
"ne" | "northeast" => ((1.0, 1.0), "Northeast"),
|
|
"nw" | "northwest" => ((-1.0, 1.0), "Northwest"),
|
|
"se" | "southeast" => ((1.0, -1.0), "Southeast"),
|
|
"sw" | "southwest" => ((-1.0, -1.0), "Southwest"),
|
|
value => {
|
|
return Err(format!(
|
|
"Unknown direction: {value}\nValid directions: north, south, east, west, northeast, northwest, southeast, southwest"
|
|
));
|
|
}
|
|
};
|
|
let fly = match args
|
|
.get(1)
|
|
.map_or("walk", String::as_str)
|
|
.to_ascii_lowercase()
|
|
.as_str()
|
|
{
|
|
"walk" => false,
|
|
"fly" => true,
|
|
value => return Err(format!("Unknown mode: {value}. Use 'walk' or 'fly'")),
|
|
};
|
|
let vector = Vector3::new_with_single_single_single(direction.0, direction.1, 0.0)
|
|
.map_err(|_| "Invalid direction")?;
|
|
let result = backend
|
|
.world_mutate(
|
|
Mutation::Movement(Movement::Cross(vector, fly)),
|
|
cancellation,
|
|
)
|
|
.await?;
|
|
Ok(format!(
|
|
"{result} {name} by {}",
|
|
if fly { "flying" } else { "walking" }
|
|
))
|
|
}
|
|
|
|
async fn follow<B: Backend + ?Sized>(
|
|
backend: &B,
|
|
args: &[String],
|
|
cancellation: CancellationToken,
|
|
) -> Result<String, String> {
|
|
let args = authorize(backend, args, false, false)?;
|
|
if args.len() == 1 && args[0].eq_ignore_ascii_case("off") {
|
|
backend
|
|
.world_mutate(Mutation::Movement(Movement::Follow(None)), cancellation)
|
|
.await?;
|
|
return Ok("Following is off".into());
|
|
}
|
|
if args.len() != 2 {
|
|
return Err("Usage: follow [FirstName LastName]/off --confirm".into());
|
|
}
|
|
let name = args.join(" ");
|
|
let id = backend
|
|
.world_resolve_avatar(&name, cancellation.clone())
|
|
.await?
|
|
.ok_or_else(|| format!("Unable to find {name}"))?;
|
|
let snapshot = backend.world_snapshot(cancellation.clone()).await?;
|
|
let position = snapshot
|
|
.avatars
|
|
.iter()
|
|
.find(|avatar| avatar.id == id)
|
|
.map(|avatar| avatar.position)
|
|
.ok_or_else(|| format!("Unable to locate {name} in the current simulator"))?;
|
|
backend
|
|
.world_mutate(
|
|
Mutation::Movement(Movement::Follow(Some((id, position)))),
|
|
cancellation,
|
|
)
|
|
.await?;
|
|
Ok(format!("Following {name}"))
|
|
}
|
|
|
|
async fn goto<B: Backend + ?Sized>(
|
|
backend: &B,
|
|
args: &[String],
|
|
cancellation: CancellationToken,
|
|
) -> Result<String, String> {
|
|
let args = authorize(backend, args, false, false)?;
|
|
let destination = args.join(" ");
|
|
let parts: Vec<_> = destination.split('/').collect();
|
|
if parts.len() != 4 {
|
|
return Err("Usage: goto sim/x/y/z --confirm".into());
|
|
}
|
|
let position = parse_vector(
|
|
&parts[1..]
|
|
.iter()
|
|
.map(|part| (*part).to_owned())
|
|
.collect::<Vec<_>>(),
|
|
"Usage: goto sim/x/y/z --confirm",
|
|
)?;
|
|
backend
|
|
.world_mutate(
|
|
Mutation::Movement(Movement::TeleportRegion(parts[0].into(), position)),
|
|
cancellation,
|
|
)
|
|
.await?;
|
|
Ok(format!("Teleported to {}", parts[0]))
|
|
}
|
|
|
|
async fn goto_landmark<B: Backend + ?Sized>(
|
|
backend: &B,
|
|
args: &[String],
|
|
cancellation: CancellationToken,
|
|
) -> Result<String, String> {
|
|
let args = authorize(backend, args, false, false)?;
|
|
if args.len() != 1 {
|
|
return Err("Usage: goto_landmark [UUID] --confirm".into());
|
|
}
|
|
let id = parse_uuid(&args[0])?;
|
|
backend
|
|
.world_mutate(
|
|
Mutation::Movement(Movement::TeleportLandmark(id)),
|
|
cancellation,
|
|
)
|
|
.await?;
|
|
Ok("Teleport Successful".into())
|
|
}
|
|
|
|
async fn location<B: Backend + ?Sized>(
|
|
backend: &B,
|
|
args: &[String],
|
|
cancellation: CancellationToken,
|
|
) -> Result<String, String> {
|
|
if !args.is_empty() {
|
|
return Err("Usage: location".into());
|
|
}
|
|
let snapshot = backend.world_snapshot(cancellation).await?;
|
|
Ok(format!(
|
|
"CurrentSim: '{}' Position: {:?}",
|
|
snapshot.region_name, snapshot.position
|
|
))
|
|
}
|
|
|
|
async fn move_to<B: Backend + ?Sized>(
|
|
backend: &B,
|
|
args: &[String],
|
|
cancellation: CancellationToken,
|
|
) -> Result<String, String> {
|
|
let args = authorize(backend, args, false, false)?;
|
|
let local = parse_vector(&args, "Usage: moveto x y z --confirm")?;
|
|
let snapshot = backend.world_snapshot(cancellation.clone()).await?;
|
|
let (x, y) = region_origin(snapshot.region_handle)?;
|
|
let global = [
|
|
f64::from(local.x) + f64::from(x),
|
|
f64::from(local.y) + f64::from(y),
|
|
f64::from(local.z),
|
|
];
|
|
backend
|
|
.world_mutate(
|
|
Mutation::Movement(Movement::AutoPilot { local, global }),
|
|
cancellation,
|
|
)
|
|
.await?;
|
|
Ok(format!(
|
|
"Attempting to move to <{},{},{}>",
|
|
global[0], global[1], global[2]
|
|
))
|
|
}
|
|
|
|
async fn sit<B: Backend + ?Sized>(
|
|
backend: &B,
|
|
args: &[String],
|
|
explicit: Option<()>,
|
|
cancellation: CancellationToken,
|
|
) -> Result<String, String> {
|
|
let args = authorize(backend, args, false, false)?;
|
|
let snapshot = backend.world_snapshot(cancellation.clone()).await?;
|
|
let prim = if explicit.is_some() {
|
|
if args.len() != 1 {
|
|
return Err("Usage: siton UUID --confirm".into());
|
|
}
|
|
let id = parse_uuid(&args[0])?;
|
|
snapshot
|
|
.primitives
|
|
.iter()
|
|
.find(|prim| prim.id == id)
|
|
.ok_or_else(|| format!("Couldn't find a prim to sit on with UUID {id}"))?
|
|
} else {
|
|
if !args.is_empty() {
|
|
return Err("Usage: sit --confirm".into());
|
|
}
|
|
snapshot
|
|
.primitives
|
|
.iter()
|
|
.min_by(|left, right| {
|
|
distance(snapshot.position, left.position)
|
|
.total_cmp(&distance(snapshot.position, right.position))
|
|
})
|
|
.ok_or("Couldn't find a nearby prim to sit on")?
|
|
};
|
|
let distance = distance(snapshot.position, prim.position);
|
|
backend
|
|
.world_mutate(Mutation::Movement(Movement::Sit(prim.id)), cancellation)
|
|
.await?;
|
|
Ok(if explicit.is_some() {
|
|
format!("Requested to sit on prim {} ({})", prim.id, prim.local_id)
|
|
} else {
|
|
format!(
|
|
"Sat on {} ({}). Distance: {distance}",
|
|
prim.id, prim.local_id
|
|
)
|
|
})
|
|
}
|
|
|
|
fn distance(left: Vector3, right: Vector3) -> f32 {
|
|
let x = left.x - right.x;
|
|
let y = left.y - right.y;
|
|
let z = left.z - right.z;
|
|
(x * x + y * y + z * z).sqrt()
|
|
}
|
|
|
|
fn region_origin(handle: u64) -> Result<(u32, u32), String> {
|
|
let x = u32::try_from(handle >> 32).map_err(|_| "Invalid region handle")?;
|
|
let y = u32::try_from(handle & u64::from(u32::MAX)).map_err(|_| "Invalid region handle")?;
|
|
Ok((x, y))
|
|
}
|
|
|
|
async fn turn_to<B: Backend + ?Sized>(
|
|
backend: &B,
|
|
args: &[String],
|
|
cancellation: CancellationToken,
|
|
) -> Result<String, String> {
|
|
let args = authorize(backend, args, false, false)?;
|
|
let target = parse_vector(&args, "Usage: turnto x y z --confirm")?;
|
|
backend
|
|
.world_mutate(Mutation::Movement(Movement::Turn(target)), cancellation)
|
|
.await?;
|
|
Ok(format!("Turned to {target:?}"))
|
|
}
|
|
|
|
async fn wind<B: Backend + ?Sized>(
|
|
backend: &B,
|
|
args: &[String],
|
|
cancellation: CancellationToken,
|
|
) -> Result<String, String> {
|
|
if !args.is_empty() {
|
|
return Err("Usage: wind".into());
|
|
}
|
|
let snapshot = backend.world_snapshot(cancellation).await?;
|
|
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
|
|
let (x, y) = (
|
|
snapshot.position.x.clamp(0.0, 255.0) as usize / 16,
|
|
snapshot.position.y.clamp(0.0, 255.0) as usize / 16,
|
|
);
|
|
let wind = snapshot
|
|
.wind
|
|
.as_ref()
|
|
.and_then(|values| values.get(y * 16 + x))
|
|
.ok_or("Wind data is not available")?;
|
|
Ok(format!("Local wind speed is {wind:?}"))
|
|
}
|
|
|
|
async fn agent_locations<B: Backend + ?Sized>(
|
|
backend: &B,
|
|
args: &[String],
|
|
cancellation: CancellationToken,
|
|
) -> Result<String, String> {
|
|
if args.len() > 1 {
|
|
return Err("Usage: agentlocations [regionhandle]".into());
|
|
}
|
|
let initial = backend.world_snapshot(cancellation.clone()).await?;
|
|
let handle = args
|
|
.first()
|
|
.map_or(Ok(initial.region_handle), |arg| arg.parse::<u64>())
|
|
.map_err(|_| "Usage: agentlocations [regionhandle]")?;
|
|
let snapshot = backend
|
|
.world_query(Query::AgentLocations(handle), cancellation)
|
|
.await?;
|
|
let locations = snapshot
|
|
.locations
|
|
.get(&handle)
|
|
.filter(|values| !values.is_empty())
|
|
.ok_or("Failed to fetch agent locations")?;
|
|
let mut output = String::from("Agent locations:\n");
|
|
for location in locations.iter().take(MAX_QUERY_RESULTS) {
|
|
output.push_str(&format!(
|
|
"{} avatar(s) at {},{}\n",
|
|
location.count, location.x, location.y
|
|
));
|
|
}
|
|
Ok(output)
|
|
}
|
|
|
|
async fn find_sim<B: Backend + ?Sized>(
|
|
backend: &B,
|
|
args: &[String],
|
|
cancellation: CancellationToken,
|
|
) -> Result<String, String> {
|
|
if args.is_empty() {
|
|
return Err("Usage: findsim [Simulator Name]".into());
|
|
}
|
|
let name = args.join(" ").trim().to_ascii_lowercase();
|
|
let snapshot = backend
|
|
.world_query(Query::FindRegion(name.clone()), cancellation)
|
|
.await?;
|
|
snapshot
|
|
.regions
|
|
.iter()
|
|
.find(|region| region.name.eq_ignore_ascii_case(&name))
|
|
.map(|region| {
|
|
format!(
|
|
"{}: handle={} ({},{})",
|
|
region.name, region.region_handle, region.x, region.y
|
|
)
|
|
})
|
|
.ok_or_else(|| format!("Lookup of {name} failed"))
|
|
}
|
|
|
|
async fn grid_layer<B: Backend + ?Sized>(
|
|
backend: &B,
|
|
args: &[String],
|
|
cancellation: CancellationToken,
|
|
) -> Result<String, String> {
|
|
if !args.is_empty() {
|
|
return Err("Usage: gridlayer".into());
|
|
}
|
|
let snapshot = backend.world_query(Query::GridLayer, cancellation).await?;
|
|
let mut output = String::new();
|
|
for layer in snapshot.layers.iter().take(MAX_QUERY_RESULTS) {
|
|
output.push_str(&format!(
|
|
"Layer({}) Bottom: {} Left: {} Top: {} Right: {}\n",
|
|
layer.image_id, layer.bottom, layer.left, layer.top, layer.right
|
|
));
|
|
}
|
|
output.push_str(&format!("Received {} layer chunks", snapshot.layers.len()));
|
|
Ok(output)
|
|
}
|
|
|
|
async fn grid_map<B: Backend + ?Sized>(
|
|
backend: &B,
|
|
args: &[String],
|
|
cancellation: CancellationToken,
|
|
) -> Result<String, String> {
|
|
if !args.is_empty() {
|
|
return Err("Usage: gridmap".into());
|
|
}
|
|
let snapshot = backend.world_query(Query::GridMap, cancellation).await?;
|
|
let mut regions = snapshot.regions;
|
|
regions.sort_by(|a, b| a.name.cmp(&b.name));
|
|
let mut output = String::new();
|
|
for region in regions.iter().take(MAX_QUERY_RESULTS) {
|
|
output.push_str(&format!(
|
|
"{}: handle={} ({},{}) agents={}\n",
|
|
region.name, region.region_handle, region.x, region.y, region.agents
|
|
));
|
|
}
|
|
output.push_str(&format!("Received {} grid regions", regions.len()));
|
|
Ok(output)
|
|
}
|
|
|
|
async fn parcel_info<B: Backend + ?Sized>(
|
|
backend: &B,
|
|
args: &[String],
|
|
cancellation: CancellationToken,
|
|
) -> Result<String, String> {
|
|
if !args.is_empty() {
|
|
return Err("Usage: parcelinfo".into());
|
|
}
|
|
let snapshot = backend.world_query(Query::Parcels, cancellation).await?;
|
|
let mut output = format!(
|
|
"Downloaded {} Parcels in {}\n",
|
|
snapshot.parcels.len(),
|
|
snapshot.region_name
|
|
);
|
|
for view in snapshot.parcels.iter().take(MAX_QUERY_RESULTS) {
|
|
let p = &view.parcel;
|
|
output.push_str(&format!("Parcel[{}]: Name: \"{}\", Description: \"{}\" ACLBlacklist Count: {}, ACLWhiteList Count: {} Traffic: {}\n", p.local_id, p.name, p.desc, p.access_black_list.len(), p.access_white_list.len(), p.dwell));
|
|
}
|
|
Ok(output)
|
|
}
|
|
|
|
async fn parcel_details<B: Backend + ?Sized>(
|
|
backend: &B,
|
|
args: &[String],
|
|
cancellation: CancellationToken,
|
|
) -> Result<String, String> {
|
|
if args.len() != 1 {
|
|
return Err("Usage: parceldetails parcelID (use parcelinfo to get ID)".into());
|
|
}
|
|
let id = args[0]
|
|
.parse::<i32>()
|
|
.map_err(|_| "Usage: parceldetails parcelID (use parcelinfo to get ID)")?;
|
|
let snapshot = backend.world_query(Query::Parcels, cancellation).await?;
|
|
let p=&snapshot.parcels.iter().find(|view| view.parcel.local_id==id).ok_or_else(||format!("Unable to find Parcel {} in Parcels Dictionary, Did you run parcelinfo to populate the dictionary first?",args[0]))?.parcel;
|
|
Ok(format!(
|
|
"LocalID = {}\nName = {}\nDescription = {}\nOwnerID = {}\nGroupID = {}\nArea = {}\nDwell = {}\nTotalPrims = {}\nMaxPrims = {}\nSalePrice = {}\nLanding = {:?}\nFlags = {:?}",
|
|
p.local_id,
|
|
p.name,
|
|
p.desc,
|
|
p.owner_id,
|
|
p.group_id,
|
|
p.area,
|
|
p.dwell,
|
|
p.total_prims,
|
|
p.max_prims,
|
|
p.sale_price,
|
|
p.landing,
|
|
p.flags
|
|
))
|
|
}
|
|
|
|
async fn prim_owners<B: Backend + ?Sized>(
|
|
backend: &B,
|
|
args: &[String],
|
|
cancellation: CancellationToken,
|
|
) -> Result<String, String> {
|
|
if args.len() != 1 {
|
|
return Err("Usage: primowners parcelID (use parcelinfo to get ID)".into());
|
|
}
|
|
let id = args[0]
|
|
.parse::<i32>()
|
|
.map_err(|_| "Usage: primowners parcelID (use parcelinfo to get ID)")?;
|
|
let snapshot = backend
|
|
.world_query(Query::ParcelOwners(id), cancellation)
|
|
.await?;
|
|
let view=snapshot.parcels.iter().find(|view|view.parcel.local_id==id).ok_or_else(||format!("Unable to find Parcel {} in Parcels Dictionary, Did you run parcelinfo to populate the dictionary first?",args[0]))?;
|
|
if view.owners.is_empty() {
|
|
return Ok("No primitive owners returned".into());
|
|
}
|
|
let mut output = String::new();
|
|
for owner in view.owners.iter().take(MAX_QUERY_RESULTS) {
|
|
output.push_str(&format!("Owner: {} Count: {}\n", owner.owner, owner.count));
|
|
}
|
|
Ok(output)
|
|
}
|
|
|
|
async fn select_objects<B: Backend + ?Sized>(
|
|
backend: &B,
|
|
args: &[String],
|
|
cancellation: CancellationToken,
|
|
) -> Result<String, String> {
|
|
if args.len() != 2 {
|
|
return Err("Usage: selectobjects parcelID OwnerUUID (use parcelinfo to get ID, use primowners to get ownerUUID)".into());
|
|
}
|
|
let id=args[0].parse::<i32>().map_err(|_|"Usage: selectobjects parcelID OwnerUUID (use parcelinfo to get ID, use primowners to get ownerUUID)")?;
|
|
let owner = parse_uuid(&args[1])?;
|
|
let snapshot = backend
|
|
.world_query(Query::ParcelObjects(id, owner), cancellation)
|
|
.await?;
|
|
let ids = snapshot
|
|
.parcels
|
|
.iter()
|
|
.find(|view| view.parcel.local_id == id)
|
|
.and_then(|view| view.selected.get(&owner))
|
|
.cloned()
|
|
.unwrap_or_default();
|
|
let mut output = ids
|
|
.iter()
|
|
.take(MAX_QUERY_RESULTS)
|
|
.map(u32::to_string)
|
|
.collect::<Vec<_>>()
|
|
.join(" ");
|
|
if !output.is_empty() {
|
|
output.push(' ');
|
|
}
|
|
output.push_str(&format!("Found a total of {} Objects", ids.len()));
|
|
Ok(output)
|
|
}
|
|
|
|
async fn syntax_id<B: Backend + ?Sized>(
|
|
backend: &B,
|
|
args: &[String],
|
|
cancellation: CancellationToken,
|
|
) -> Result<String, String> {
|
|
if !args.is_empty() {
|
|
return Err("Usage: syntaxid".into());
|
|
}
|
|
let mut syntax = backend.world_snapshot(cancellation).await?.syntax;
|
|
syntax.sort();
|
|
syntax.dedup();
|
|
Ok(format!("LSL Tokens:\n{}", syntax.join("\n")))
|
|
}
|
|
|
|
async fn estate_covenant<B: Backend + ?Sized>(
|
|
backend: &B,
|
|
args: &[String],
|
|
cancellation: CancellationToken,
|
|
) -> Result<String, String> {
|
|
if args.len() > 1 {
|
|
return Err("Usage: getestatecovenant [timeout]".into());
|
|
}
|
|
let seconds = args
|
|
.first()
|
|
.map_or(Ok(20), |v| v.parse::<u64>())
|
|
.map_err(|_| "Usage: getestatecovenant [timeout]")?;
|
|
if !(1..=120).contains(&seconds) {
|
|
return Err("Covenant timeout must be between 1 and 120 seconds".into());
|
|
}
|
|
let snapshot = backend
|
|
.world_query(
|
|
Query::EstateCovenant(Duration::from_secs(seconds)),
|
|
cancellation,
|
|
)
|
|
.await?;
|
|
let estate = snapshot
|
|
.estate
|
|
.ok_or("Timeout waiting for covenant info.")?;
|
|
let mut output = format!(
|
|
"Estate name: {}\nEstate owner: {}\n",
|
|
estate.name, estate.owner
|
|
);
|
|
if estate.covenant != UUID::zero() {
|
|
output.push_str(&format!(
|
|
"Estate Covenant ID: {}\nEstate Covenant Update Time: {}\nCovenant:\n{}",
|
|
estate.covenant, estate.timestamp, estate.body
|
|
));
|
|
}
|
|
Ok(output)
|
|
}
|
|
|
|
async fn download_terrain<B: Backend + ?Sized>(
|
|
backend: &B,
|
|
args: &[String],
|
|
cancellation: CancellationToken,
|
|
) -> Result<String, String> {
|
|
let args = authorize(backend, args, false, true)?;
|
|
if args.len() > 2 {
|
|
return Err("Usage: downloadterrain [timeout-ms] [output] --confirm".into());
|
|
}
|
|
let timeout = args
|
|
.first()
|
|
.map_or(Ok(120_000), |v| v.parse::<u64>())
|
|
.map_err(|_| "Usage: downloadterrain [timeout-ms] [output] --confirm")?;
|
|
if !(1..=300_000).contains(&timeout) {
|
|
return Err("Terrain timeout must be between 1 and 300000 ms".into());
|
|
}
|
|
let snapshot = backend
|
|
.world_query(
|
|
Query::DownloadTerrain(Duration::from_millis(timeout)),
|
|
cancellation,
|
|
)
|
|
.await?;
|
|
let estate = snapshot
|
|
.estate
|
|
.ok_or("Timeout while waiting for terrain data")?;
|
|
let path = args.get(1).map_or_else(
|
|
|| PathBuf::from(format!("{}.raw", snapshot.region_name)),
|
|
PathBuf::from,
|
|
);
|
|
let path = safe_path(path.to_string_lossy().as_ref())?;
|
|
write_bounded(&path, &estate.terrain)?;
|
|
Ok(format!(
|
|
"Terrain file {} ({} bytes) downloaded successfully, written to {}",
|
|
snapshot.region_name,
|
|
estate.terrain.len(),
|
|
path.display()
|
|
))
|
|
}
|
|
|
|
async fn upload_terrain<B: Backend + ?Sized>(
|
|
backend: &B,
|
|
args: &[String],
|
|
cancellation: CancellationToken,
|
|
) -> Result<String, String> {
|
|
let args = authorize(backend, args, true, true)?;
|
|
if args.len() != 1 {
|
|
return Err("Usage: uploadterrain filename --confirm".into());
|
|
}
|
|
let path = safe_path(&args[0])?;
|
|
let data = read_bounded(&path)?;
|
|
backend
|
|
.world_mutate(
|
|
Mutation::UploadTerrain {
|
|
name: path
|
|
.file_name()
|
|
.and_then(|v| v.to_str())
|
|
.unwrap_or("terrain.raw")
|
|
.into(),
|
|
data,
|
|
},
|
|
cancellation,
|
|
)
|
|
.await?;
|
|
Ok("Terrain raw file uploaded and applied".into())
|
|
}
|
|
|
|
async fn change_permissions<B: Backend + ?Sized>(
|
|
backend: &B,
|
|
args: &[String],
|
|
cancellation: CancellationToken,
|
|
) -> Result<String, String> {
|
|
let args = authorize(backend, args, false, false)?;
|
|
if args.is_empty() || args.len() > 4 {
|
|
return Err("Usage: changeperms prim-uuid [copy] [mod] [xfer] --confirm".into());
|
|
}
|
|
let root = parse_uuid(&args[0])?;
|
|
let mut permissions = PermissionMask::NONE;
|
|
for value in &args[1..] {
|
|
match value.to_ascii_lowercase().as_str() {
|
|
"copy" => permissions.0 |= PermissionMask::COPY.0,
|
|
"mod" => permissions.0 |= PermissionMask::MODIFY.0,
|
|
"xfer" => permissions.0 |= PermissionMask::TRANSFER.0,
|
|
_ => return Err("Usage: changeperms prim-uuid [copy] [mod] [xfer] --confirm".into()),
|
|
}
|
|
}
|
|
backend
|
|
.world_mutate(
|
|
Mutation::ChangePermissions { root, permissions },
|
|
cancellation,
|
|
)
|
|
.await
|
|
}
|
|
|
|
async fn derez<B: Backend + ?Sized>(
|
|
backend: &B,
|
|
args: &[String],
|
|
cancellation: CancellationToken,
|
|
) -> Result<String, String> {
|
|
let args = authorize(backend, args, false, false)?;
|
|
if args.len() != 1 {
|
|
return Err("Usage: derez [prim-uuid] --confirm".into());
|
|
}
|
|
let id = parse_uuid(&args[0])?;
|
|
let snapshot = backend.world_snapshot(cancellation.clone()).await?;
|
|
let prim = snapshot
|
|
.primitives
|
|
.iter()
|
|
.find(|prim| prim.id == id)
|
|
.ok_or_else(|| format!("Could not find object {id}"))?;
|
|
let name = prim
|
|
.properties
|
|
.as_ref()
|
|
.map_or("Object", |properties| properties.name.as_str());
|
|
backend
|
|
.world_mutate(Mutation::DeRez(id), cancellation)
|
|
.await?;
|
|
Ok(format!("Removing {name} ({id})"))
|
|
}
|
|
|
|
async fn download_texture<B: Backend + ?Sized>(
|
|
backend: &B,
|
|
args: &[String],
|
|
cancellation: CancellationToken,
|
|
) -> Result<String, String> {
|
|
if args.is_empty() || args.len() > 3 {
|
|
return Err("Usage: downloadtexture [texture-uuid] [discardlevel] [output]".into());
|
|
}
|
|
let id = parse_uuid(&args[0])?;
|
|
let discard = args
|
|
.get(1)
|
|
.map_or(Ok(0), |value| value.parse::<i32>())
|
|
.map_err(|_| "Usage: downloadtexture [texture-uuid] [discardlevel] [output]")?;
|
|
if !(0..=5).contains(&discard) {
|
|
return Err("Discard level must be between 0 and 5".into());
|
|
}
|
|
let data = backend.world_texture(id, discard, cancellation).await?;
|
|
let path = safe_path(
|
|
args.get(2)
|
|
.map_or_else(|| format!("{id}.jp2"), Clone::clone)
|
|
.as_str(),
|
|
)?;
|
|
write_bounded(&path, &data)?;
|
|
let dimensions = libremetaverse_imaging::RustJ2kCodec::decode_bytes(
|
|
&data,
|
|
libremetaverse_imaging::J2kDecodeOptions::default(),
|
|
)
|
|
.map_or_else(
|
|
|_| "undecoded".into(),
|
|
|image| format!("{}x{}", image.width, image.height),
|
|
);
|
|
Ok(format!("Saved {} ({dimensions})", path.display()))
|
|
}
|
|
|
|
fn primitive_name(prim: &Primitive) -> &str {
|
|
prim.properties
|
|
.as_ref()
|
|
.map_or("(unknown)", |properties| properties.name.as_str())
|
|
}
|
|
fn primitive_description(prim: &Primitive) -> &str {
|
|
prim.properties
|
|
.as_ref()
|
|
.map_or("(unknown)", |properties| properties.description.as_str())
|
|
}
|
|
|
|
fn texture_ids(prim: &Primitive) -> BTreeSet<UUID> {
|
|
let mut ids = BTreeSet::new();
|
|
if let Some(textures) = &prim.textures {
|
|
if let Some(face) = &textures.default_texture {
|
|
ids.insert(face.texture_id());
|
|
}
|
|
for face in textures.face_textures.iter().flatten() {
|
|
ids.insert(face.texture_id());
|
|
}
|
|
}
|
|
if let Some(sculpt) = &prim.sculpt
|
|
&& sculpt.sculpt_texture != UUID::zero()
|
|
{
|
|
ids.insert(sculpt.sculpt_texture);
|
|
}
|
|
ids.remove(&PrimitiveTextureEntry::white_texture());
|
|
ids
|
|
}
|
|
|
|
fn linkset(primitives: &[Primitive], id: UUID) -> Result<Vec<&Primitive>, String> {
|
|
let selected = primitives
|
|
.iter()
|
|
.find(|prim| prim.id == id)
|
|
.ok_or_else(|| {
|
|
format!(
|
|
"Couldn't find UUID {id} in the objects currently indexed in the current simulator"
|
|
)
|
|
})?;
|
|
let root = if selected.parent_id == 0 {
|
|
selected.local_id
|
|
} else {
|
|
selected.parent_id
|
|
};
|
|
let mut values: Vec<_> = primitives
|
|
.iter()
|
|
.filter(|prim| prim.local_id == root || prim.parent_id == root)
|
|
.take(MAX_EXPORT_PRIMS + 1)
|
|
.collect();
|
|
if values.len() > MAX_EXPORT_PRIMS {
|
|
return Err("Linkset exceeds the 10,000 primitive export limit".into());
|
|
}
|
|
values.sort_by_key(|prim| prim.local_id);
|
|
Ok(values)
|
|
}
|
|
|
|
async fn export<B: Backend + ?Sized>(
|
|
backend: &B,
|
|
args: &[String],
|
|
cancellation: CancellationToken,
|
|
) -> Result<String, String> {
|
|
if args.len() != 2 {
|
|
return Err("Usage: export uuid outputfile.xml".into());
|
|
}
|
|
let id = parse_uuid(&args[0])?;
|
|
let output_path = safe_path(&args[1])?;
|
|
let snapshot = backend.world_snapshot(cancellation.clone()).await?;
|
|
let prims = linkset(&snapshot.primitives, id)?;
|
|
let owner = prims
|
|
.first()
|
|
.and_then(|prim| prim.properties.as_ref())
|
|
.map_or_else(|| prims[0].owner_id, |properties| properties.owner_id);
|
|
if owner != backend.world_agent_id() {
|
|
return Err(format!(
|
|
"That object is owned by {owner}, we don't have permission to export it"
|
|
));
|
|
}
|
|
let values = prims
|
|
.iter()
|
|
.map(|prim| prim.get_osd())
|
|
.collect::<Result<Vec<_>, _>>()
|
|
.map_err(|_| "Could not serialize primitive linkset")?;
|
|
let xml = OSDParser::serialize_llsd_xml_string(OSD::Array(values))
|
|
.map_err(|_| "Could not serialize primitive linkset")?;
|
|
write_bounded(&output_path, xml.as_bytes())?;
|
|
let texture_directory = output_path.parent().unwrap_or_else(|| Path::new("."));
|
|
let mut textures = BTreeSet::new();
|
|
for prim in &prims {
|
|
textures.extend(texture_ids(prim));
|
|
}
|
|
let mut downloaded = 0usize;
|
|
for texture in textures {
|
|
let data = backend
|
|
.world_texture(texture, 0, cancellation.clone())
|
|
.await?;
|
|
let path = texture_directory.join(format!("{texture}.jp2"));
|
|
write_bounded(&path, &data)?;
|
|
let image = libremetaverse_imaging::RustJ2kCodec::decode_bytes(
|
|
&data,
|
|
libremetaverse_imaging::J2kDecodeOptions::default(),
|
|
)
|
|
.map_err(|_| format!("Failed to decode exported texture {texture}"))?;
|
|
let tga_path = texture_directory.join(format!("{texture}.tga"));
|
|
write_bounded(&tga_path, &super::inventory::encode_tga(&image)?)?;
|
|
downloaded += 1;
|
|
}
|
|
Ok(format!(
|
|
"Exported {} prims to {}; downloaded {downloaded} textures",
|
|
prims.len(),
|
|
output_path.display()
|
|
))
|
|
}
|
|
|
|
fn particle_lsl(prim: &Primitive) -> Result<String, String> {
|
|
let particle = &prim.particle_sys;
|
|
if particle.crc == 0 {
|
|
return Err(format!(
|
|
"Prim {} does not have a particle system",
|
|
prim.local_id
|
|
));
|
|
}
|
|
let pattern = match particle.pattern {
|
|
PrimitiveParticleSystemSourcePattern::DROP => "PSYS_SRC_PATTERN_DROP",
|
|
PrimitiveParticleSystemSourcePattern::EXPLODE => "PSYS_SRC_PATTERN_EXPLODE",
|
|
PrimitiveParticleSystemSourcePattern::ANGLE => "PSYS_SRC_PATTERN_ANGLE",
|
|
PrimitiveParticleSystemSourcePattern::ANGLE_CONE => "PSYS_SRC_PATTERN_ANGLE_CONE",
|
|
PrimitiveParticleSystemSourcePattern::ANGLE_CONE_EMPTY => {
|
|
"PSYS_SRC_PATTERN_ANGLE_CONE_EMPTY"
|
|
}
|
|
_ => "0",
|
|
};
|
|
let acceleration = lsl_vector(particle.part_acceleration);
|
|
let omega = lsl_vector(particle.angular_velocity);
|
|
Ok(format!(
|
|
"default\n{{\n state_entry()\n {{\n llParticleSystem([\n PSYS_PART_FLAGS, {},\n PSYS_SRC_PATTERN, {pattern},\n PSYS_PART_START_ALPHA, {:.5},\n PSYS_PART_END_ALPHA, {:.5},\n PSYS_PART_START_SCALE, <{:.5}, {:.5}, 0>,\n PSYS_PART_END_SCALE, <{:.5}, {:.5}, 0>,\n PSYS_PART_MAX_AGE, {:.5},\n PSYS_SRC_MAX_AGE, {:.5},\n PSYS_SRC_ACCEL, {},\n PSYS_SRC_BURST_PART_COUNT, {},\n PSYS_SRC_BURST_RADIUS, {:.5},\n PSYS_SRC_BURST_RATE, {:.5},\n PSYS_SRC_BURST_SPEED_MIN, {:.5},\n PSYS_SRC_BURST_SPEED_MAX, {:.5},\n PSYS_SRC_INNERANGLE, {:.5},\n PSYS_SRC_OUTERANGLE, {:.5},\n PSYS_SRC_OMEGA, {},\n PSYS_SRC_TEXTURE, (key)\"{}\",\n PSYS_SRC_TARGET_KEY, (key)\"{}\"\n ]);\n }}\n}}\n",
|
|
particle.part_data_flags.0,
|
|
particle.part_start_color.a,
|
|
particle.part_end_color.a,
|
|
particle.part_start_scale_x,
|
|
particle.part_start_scale_y,
|
|
particle.part_end_scale_x,
|
|
particle.part_end_scale_y,
|
|
particle.part_max_age,
|
|
particle.max_age,
|
|
acceleration,
|
|
particle.burst_part_count,
|
|
particle.burst_radius,
|
|
particle.burst_rate,
|
|
particle.burst_speed_min,
|
|
particle.burst_speed_max,
|
|
particle.inner_angle,
|
|
particle.outer_angle,
|
|
omega,
|
|
particle.texture,
|
|
particle.target
|
|
))
|
|
}
|
|
|
|
fn lsl_vector(value: Vector3) -> String {
|
|
format!("<{:.5}, {:.5}, {:.5}>", value.x, value.y, value.z)
|
|
}
|
|
|
|
async fn export_particles<B: Backend + ?Sized>(
|
|
backend: &B,
|
|
args: &[String],
|
|
cancellation: CancellationToken,
|
|
) -> Result<String, String> {
|
|
if args.len() != 1 {
|
|
return Err("Usage: exportparticles [prim-uuid]".into());
|
|
}
|
|
let id = parse_uuid(&args[0])?;
|
|
let snapshot = backend.world_snapshot(cancellation).await?;
|
|
let prim = snapshot
|
|
.primitives
|
|
.iter()
|
|
.find(|prim| prim.id == id)
|
|
.ok_or_else(|| format!("Could not find {id} object"))?;
|
|
particle_lsl(prim)
|
|
}
|
|
|
|
async fn find_objects<B: Backend + ?Sized>(
|
|
backend: &B,
|
|
args: &[String],
|
|
cancellation: CancellationToken,
|
|
) -> Result<String, String> {
|
|
if args.is_empty() || args.len() > 2 {
|
|
return Err("Usage: findobjects [radius] <search-string>".into());
|
|
}
|
|
let radius = args[0]
|
|
.parse::<f32>()
|
|
.map_err(|_| "Usage: findobjects [radius] <search-string>")?;
|
|
if !radius.is_finite() || !(0.0..=4096.0).contains(&radius) {
|
|
return Err("Radius must be between 0 and 4096 metres".into());
|
|
}
|
|
let search = args.get(1).map_or("", String::as_str);
|
|
let snapshot = backend.world_snapshot(cancellation).await?;
|
|
let mut output = String::new();
|
|
let mut count = 0;
|
|
for prim in snapshot
|
|
.primitives
|
|
.iter()
|
|
.filter(|prim| distance(snapshot.position, prim.position) < radius)
|
|
.take(MAX_QUERY_RESULTS)
|
|
{
|
|
let name = primitive_name(prim);
|
|
if search.is_empty() || name.contains(search) {
|
|
output.push_str(&format!("Object '{name}': {}\n", prim.id));
|
|
count += 1;
|
|
}
|
|
}
|
|
output.push_str(&format!("Done searching; found {count} objects"));
|
|
Ok(output)
|
|
}
|
|
|
|
async fn find_texture<B: Backend + ?Sized>(
|
|
backend: &B,
|
|
args: &[String],
|
|
cancellation: CancellationToken,
|
|
) -> Result<String, String> {
|
|
if args.len() != 2 {
|
|
return Err("Usage: findtexture [face-index] [texture-uuid]".into());
|
|
}
|
|
let face = args[0]
|
|
.parse::<usize>()
|
|
.map_err(|_| "Usage: findtexture [face-index] [texture-uuid]")?;
|
|
if face >= PrimitiveTextureEntry::MAX_FACES as usize {
|
|
return Err("Face index is outside the supported range".into());
|
|
}
|
|
let id = parse_uuid(&args[1])?;
|
|
let snapshot = backend.world_snapshot(cancellation).await?;
|
|
let mut output = String::new();
|
|
let mut count = 0;
|
|
for prim in &snapshot.primitives {
|
|
if let Some(texture) = prim
|
|
.textures
|
|
.as_ref()
|
|
.and_then(|textures| textures.face_textures.get(face))
|
|
.and_then(Option::as_ref)
|
|
&& texture.texture_id() == id
|
|
{
|
|
output.push_str(&format!(
|
|
"Primitive {} ({}) has face index {face} set to {id}\n",
|
|
prim.id, prim.local_id
|
|
));
|
|
count += 1;
|
|
}
|
|
}
|
|
output.push_str(&format!("Done searching; found {count} faces"));
|
|
Ok(output)
|
|
}
|
|
|
|
async fn import<B: Backend + ?Sized>(
|
|
backend: &B,
|
|
args: &[String],
|
|
cancellation: CancellationToken,
|
|
) -> Result<String, String> {
|
|
let args = authorize(backend, args, true, false)?;
|
|
if args.is_empty() || args.len() > 2 {
|
|
return Err("Usage: import inputfile.xml [usegroup] --confirm".into());
|
|
}
|
|
if args.len() == 2 && !args[1].eq_ignore_ascii_case("usegroup") {
|
|
return Err("Usage: import inputfile.xml [usegroup] --confirm".into());
|
|
}
|
|
let path = safe_path(&args[0])?;
|
|
let data = read_bounded(&path)?;
|
|
let osd = OSDParser::deserialize_llsd_xml_with_bytes(data)
|
|
.map_err(|_| format!("Failed to deserialize {}", path.display()))?;
|
|
let OSD::Array(values) = osd else {
|
|
return Err("Import document must contain an LLSD array".into());
|
|
};
|
|
if values.is_empty() || values.len() > MAX_EXPORT_PRIMS {
|
|
return Err("Import must contain between 1 and 10,000 primitives".into());
|
|
}
|
|
let primitives = values
|
|
.into_iter()
|
|
.map(Primitive::from_osd)
|
|
.collect::<Result<Vec<_>, _>>()
|
|
.map_err(|_| format!("Failed to deserialize {}", path.display()))?;
|
|
backend
|
|
.world_mutate(
|
|
Mutation::Import {
|
|
primitives,
|
|
use_group: args.len() == 2,
|
|
},
|
|
cancellation,
|
|
)
|
|
.await?;
|
|
Ok("Import complete.".into())
|
|
}
|
|
|
|
async fn prim_count<B: Backend + ?Sized>(
|
|
backend: &B,
|
|
args: &[String],
|
|
cancellation: CancellationToken,
|
|
) -> Result<String, String> {
|
|
if !args.is_empty() {
|
|
return Err("Usage: primcount".into());
|
|
}
|
|
let snapshot = backend.world_snapshot(cancellation).await?;
|
|
Ok(format!(
|
|
"{} (Avatars: {} Primitives: {})\nTracking a total of {} objects",
|
|
snapshot.region_name,
|
|
snapshot.avatars.len(),
|
|
snapshot.primitives.len(),
|
|
snapshot.avatars.len() + snapshot.primitives.len()
|
|
))
|
|
}
|
|
|
|
async fn prim_info<B: Backend + ?Sized>(
|
|
backend: &B,
|
|
args: &[String],
|
|
cancellation: CancellationToken,
|
|
) -> Result<String, String> {
|
|
if args.len() != 1 {
|
|
return Err("Usage: priminfo [prim-uuid]".into());
|
|
}
|
|
let id = parse_uuid(&args[0])?;
|
|
let snapshot = backend.world_snapshot(cancellation).await?;
|
|
let prim = snapshot
|
|
.primitives
|
|
.iter()
|
|
.find(|prim| prim.id == id)
|
|
.ok_or_else(|| format!("Could not find object {id}"))?;
|
|
let mut output = format!(
|
|
"ID: {}\nLocalID: {}\nParentID: {}\nName: {}\nDescription: {}\nPosition: {:?}\nScale: {:?}\nRotation: {:?}\nFlags: {:?}\nText: {}\nParticleCRC: {}\n",
|
|
prim.id,
|
|
prim.local_id,
|
|
prim.parent_id,
|
|
primitive_name(prim),
|
|
primitive_description(prim),
|
|
prim.position,
|
|
prim.scale,
|
|
prim.rotation,
|
|
prim.flags,
|
|
prim.text,
|
|
prim.particle_sys.crc
|
|
);
|
|
for texture in texture_ids(prim) {
|
|
output.push_str(&format!("Texture: {texture}\n"));
|
|
}
|
|
if let Some(properties) = &prim.properties {
|
|
output.push_str(&format!("OwnerID: {}\nCreatorID: {}\nCategory: {:?}\nFolderID: {}\nFromTaskID: {}\nInventorySerial: {}\nItemID: {}\n",properties.owner_id,properties.creator_id,properties.category,properties.folder_id,properties.from_task_id,properties.inventory_serial,properties.item_id));
|
|
}
|
|
output.push_str("Done.");
|
|
Ok(output)
|
|
}
|
|
|
|
async fn prim_regex<B: Backend + ?Sized>(
|
|
backend: &B,
|
|
args: &[String],
|
|
cancellation: CancellationToken,
|
|
) -> Result<String, String> {
|
|
if args.is_empty() {
|
|
return Err("Usage: primregex [text predicate]".into());
|
|
}
|
|
let predicate = args.join(" ");
|
|
if predicate.len() > 1024 {
|
|
return Err("Predicate exceeds 1,024 bytes".into());
|
|
}
|
|
let regex = regex::RegexBuilder::new(&predicate)
|
|
.case_insensitive(true)
|
|
.size_limit(1024 * 1024)
|
|
.build()
|
|
.map_err(|error| format!("Error searching: {error}"))?;
|
|
let snapshot = backend.world_snapshot(cancellation).await?;
|
|
let mut output = format!(
|
|
"Searching prim for [{predicate}] ({} prims loaded in simulator)\n",
|
|
snapshot.primitives.len()
|
|
);
|
|
let mut count = 0;
|
|
for prim in &snapshot.primitives {
|
|
if regex.is_match(&prim.text)
|
|
|| regex.is_match(primitive_name(prim))
|
|
|| regex.is_match(primitive_description(prim))
|
|
{
|
|
output.push_str(&format!(
|
|
"NAME={}\nID = {}\nFLAGS = {:?}\nTEXT = '{}'\nDESC='{}'\n",
|
|
primitive_name(prim),
|
|
prim.id,
|
|
prim.flags,
|
|
prim.text,
|
|
primitive_description(prim)
|
|
));
|
|
count += 1;
|
|
}
|
|
}
|
|
output.push_str(&format!("Done searching; found {count} objects"));
|
|
Ok(output)
|
|
}
|
|
|
|
async fn textures<B: Backend + ?Sized>(
|
|
backend: &B,
|
|
args: &[String],
|
|
cancellation: CancellationToken,
|
|
) -> Result<String, String> {
|
|
if args.len() != 1 {
|
|
return Err("Usage: textures [on/off]".into());
|
|
}
|
|
let enabled = match args[0].to_ascii_lowercase().as_str() {
|
|
"on" => true,
|
|
"off" => false,
|
|
_ => return Err("Usage: textures [on/off]".into()),
|
|
};
|
|
backend
|
|
.world_mutate(Mutation::SetTextures(enabled), cancellation)
|
|
.await?;
|
|
Ok(format!(
|
|
"Texture downloading is {}",
|
|
if enabled { "on" } else { "off" }
|
|
))
|
|
}
|
|
|
|
fn parse_tree(value: &str) -> Option<Tree> {
|
|
match value.to_ascii_lowercase().as_str() {
|
|
"beachgrass1" => Some(Tree::BeachGrass1),
|
|
"cypress1" => Some(Tree::Cypress1),
|
|
"cypress2" => Some(Tree::Cypress2),
|
|
"dogwood" => Some(Tree::Dogwood),
|
|
"eelgrass" => Some(Tree::Eelgrass),
|
|
"eucalyptus" => Some(Tree::Eucalyptus),
|
|
"fern" => Some(Tree::Fern),
|
|
"kelp1" => Some(Tree::Kelp1),
|
|
"kelp2" => Some(Tree::Kelp2),
|
|
"oak" => Some(Tree::Oak),
|
|
"palm1" => Some(Tree::Palm1),
|
|
"palm2" => Some(Tree::Palm2),
|
|
"pine1" => Some(Tree::Pine1),
|
|
"pine2" => Some(Tree::Pine2),
|
|
"plumeria" => Some(Tree::Plumeria),
|
|
"seasword" => Some(Tree::SeaSword),
|
|
"tropicalbush1" => Some(Tree::TropicalBush1),
|
|
"tropicalbush2" => Some(Tree::TropicalBush2),
|
|
"winteraspen" => Some(Tree::WinterAspen),
|
|
"winterpine1" => Some(Tree::WinterPine1),
|
|
"winterpine2" => Some(Tree::WinterPine2),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
async fn tree<B: Backend + ?Sized>(
|
|
backend: &B,
|
|
args: &[String],
|
|
cancellation: CancellationToken,
|
|
) -> Result<String, String> {
|
|
let args = authorize(backend, args, false, false)?;
|
|
if args.len() != 1 {
|
|
return Err("Usage: tree [BeachGrass1,Cypress1,Cypress2,Dogwood,Eelgrass,Eucalyptus,Fern,Kelp1,Kelp2,Oak,Palm1,Palm2,Pine1,Pine2,Plumeria,SeaSword,TropicalBush1,TropicalBush2,WinterAspen,WinterPine1,WinterPine2] --confirm".into());
|
|
}
|
|
let species = parse_tree(&args[0]).ok_or("Type !tree for usage")?;
|
|
backend
|
|
.world_mutate(Mutation::Tree(species), cancellation)
|
|
.await?;
|
|
Ok(format!("Attempted to rez a {} tree", args[0]))
|
|
}
|
|
|
|
impl Backend for FakeBackend {
|
|
fn world_state(&self) -> &Mutex<State> {
|
|
&self.world_state
|
|
}
|
|
fn world_agent_id(&self) -> UUID {
|
|
self.id
|
|
}
|
|
fn world_snapshot<'a>(
|
|
&'a self,
|
|
cancellation: CancellationToken,
|
|
) -> BackendFuture<'a, Result<Snapshot, String>> {
|
|
Box::pin(async move {
|
|
if cancellation.is_cancellation_requested() {
|
|
Err("World query cancelled".into())
|
|
} else {
|
|
Ok(lock(&self.world_state).fake.clone())
|
|
}
|
|
})
|
|
}
|
|
fn world_query<'a>(
|
|
&'a self,
|
|
query: Query,
|
|
cancellation: CancellationToken,
|
|
) -> BackendFuture<'a, Result<Snapshot, String>> {
|
|
Box::pin(async move {
|
|
if cancellation.is_cancellation_requested() {
|
|
return Err("World query cancelled".into());
|
|
}
|
|
self.record(format!("CALL world-query {}", query_name(&query)));
|
|
let snapshot = lock(&self.world_state).fake.clone();
|
|
match query {
|
|
Query::FindRegion(name)
|
|
if !snapshot
|
|
.regions
|
|
.iter()
|
|
.any(|region| region.name.eq_ignore_ascii_case(&name)) =>
|
|
{
|
|
Err(format!("Lookup of {name} failed"))
|
|
}
|
|
Query::EstateCovenant(_) | Query::DownloadTerrain(_)
|
|
if snapshot.estate.is_none() =>
|
|
{
|
|
Err("Estate fixture is missing".into())
|
|
}
|
|
_ => Ok(snapshot),
|
|
}
|
|
})
|
|
}
|
|
fn world_mutate<'a>(
|
|
&'a self,
|
|
mutation: Mutation,
|
|
cancellation: CancellationToken,
|
|
) -> BackendFuture<'a, Result<String, String>> {
|
|
Box::pin(async move {
|
|
if cancellation.is_cancellation_requested() {
|
|
return Err("World mutation cancelled".into());
|
|
}
|
|
let mut state = lock(&self.world_state);
|
|
let result = match mutation {
|
|
Mutation::Movement(action) => apply_fake_movement(self, &mut state, action)?,
|
|
Mutation::ChangePermissions { root, permissions } => {
|
|
let root_local = state
|
|
.fake
|
|
.primitives
|
|
.iter()
|
|
.find(|prim| prim.id == root)
|
|
.map(|prim| {
|
|
if prim.parent_id == 0 {
|
|
prim.local_id
|
|
} else {
|
|
prim.parent_id
|
|
}
|
|
})
|
|
.ok_or_else(|| format!("Cannot find requested object {root}"))?;
|
|
let mut count = 0;
|
|
for prim in &mut state.fake.primitives {
|
|
if prim.local_id == root_local || prim.parent_id == root_local {
|
|
if let Some(properties) = &mut prim.properties {
|
|
properties.permissions.next_owner_mask = permissions;
|
|
}
|
|
count += 1;
|
|
}
|
|
}
|
|
self.record(format!(
|
|
"CALL object-permissions {root} {:08x}",
|
|
permissions.0
|
|
));
|
|
format!(
|
|
"Set permissions to {permissions:?} on {count} objects and 0 inventory items"
|
|
)
|
|
}
|
|
Mutation::DeRez(id) => {
|
|
let before = state.fake.primitives.len();
|
|
state.fake.primitives.retain(|prim| prim.id != id);
|
|
if before == state.fake.primitives.len() {
|
|
return Err(format!("Could not find object {id}"));
|
|
}
|
|
self.record(format!("CALL object-derez {id}"));
|
|
"Object removed".into()
|
|
}
|
|
Mutation::Import {
|
|
mut primitives,
|
|
use_group,
|
|
} => {
|
|
let base = state
|
|
.fake
|
|
.primitives
|
|
.iter()
|
|
.map(|prim| prim.local_id)
|
|
.max()
|
|
.unwrap_or(100);
|
|
let local_map = primitives
|
|
.iter()
|
|
.enumerate()
|
|
.map(|(index, prim)| {
|
|
let local = base
|
|
.saturating_add(u32::try_from(index).unwrap_or(u32::MAX))
|
|
.saturating_add(1);
|
|
(prim.local_id, local)
|
|
})
|
|
.collect::<HashMap<_, _>>();
|
|
for prim in &mut primitives {
|
|
let old_parent = prim.parent_id;
|
|
prim.local_id = *local_map
|
|
.get(&prim.local_id)
|
|
.ok_or("Imported primitive ID mapping is incomplete")?;
|
|
prim.parent_id = if old_parent == 0 {
|
|
0
|
|
} else {
|
|
*local_map
|
|
.get(&old_parent)
|
|
.ok_or("Imported primitive parent is outside the linkset")?
|
|
};
|
|
prim.id = UUID::new_with_u_int64(
|
|
0x9300_0000_0000_0000 | u64::from(prim.local_id),
|
|
)
|
|
.map_err(|_| "Creating imported primitive UUID")?;
|
|
prim.position.x += state.fake.position.x;
|
|
prim.position.y += state.fake.position.y;
|
|
prim.position.z += state.fake.position.z + 3.0;
|
|
}
|
|
let count = primitives.len();
|
|
state.fake.primitives.extend(primitives);
|
|
self.record(format!(
|
|
"CALL object-import count={count} use-group={use_group}"
|
|
));
|
|
format!("Imported {count} primitives")
|
|
}
|
|
Mutation::SetTextures(enabled) => {
|
|
state.textures_enabled = enabled;
|
|
self.record(format!("CALL texture-stream enabled={enabled}"));
|
|
"Texture setting updated".into()
|
|
}
|
|
Mutation::Tree(species) => {
|
|
self.record(format!("CALL object-tree {species:?}"));
|
|
"Tree rez requested".into()
|
|
}
|
|
Mutation::UploadTerrain { name, data } => {
|
|
let estate = state
|
|
.fake
|
|
.estate
|
|
.as_mut()
|
|
.ok_or("Estate fixture is missing")?;
|
|
estate.terrain = data;
|
|
self.record(format!("CALL estate-upload-terrain {name}"));
|
|
"Terrain upload completed".into()
|
|
}
|
|
};
|
|
Ok(result)
|
|
})
|
|
}
|
|
fn world_texture<'a>(
|
|
&'a self,
|
|
id: UUID,
|
|
_discard: i32,
|
|
cancellation: CancellationToken,
|
|
) -> BackendFuture<'a, Result<Vec<u8>, String>> {
|
|
Box::pin(async move {
|
|
if cancellation.is_cancellation_requested() {
|
|
return Err("Texture download cancelled".into());
|
|
}
|
|
self.record(format!("CALL texture-download {id}"));
|
|
lock(&self.world_state)
|
|
.fake
|
|
.assets
|
|
.get(&id)
|
|
.cloned()
|
|
.ok_or_else(|| format!("Download failed or texture not found: {id}"))
|
|
})
|
|
}
|
|
fn world_resolve_avatar<'a>(
|
|
&'a self,
|
|
name: &'a str,
|
|
cancellation: CancellationToken,
|
|
) -> BackendFuture<'a, Result<Option<UUID>, String>> {
|
|
Box::pin(async move {
|
|
if cancellation.is_cancellation_requested() {
|
|
return Err("Avatar lookup cancelled".into());
|
|
}
|
|
Ok(lock(&self.world_state)
|
|
.fake
|
|
.avatars
|
|
.iter()
|
|
.find(|avatar| avatar.name.eq_ignore_ascii_case(name))
|
|
.map(|avatar| avatar.id))
|
|
})
|
|
}
|
|
fn apply_world_fixture(&self, fields: &[&str]) -> Result<(), &'static str> {
|
|
apply_fixture(self, fields)
|
|
}
|
|
}
|
|
|
|
fn query_name(query: &Query) -> &'static str {
|
|
match query {
|
|
Query::AgentLocations(_) => "agent-locations",
|
|
Query::FindRegion(_) => "find-region",
|
|
Query::GridLayer => "grid-layer",
|
|
Query::GridMap => "grid-map",
|
|
Query::Parcels => "parcels",
|
|
Query::ParcelOwners(_) => "parcel-owners",
|
|
Query::ParcelObjects(_, _) => "parcel-objects",
|
|
Query::EstateCovenant(_) => "estate-covenant",
|
|
Query::DownloadTerrain(_) => "download-terrain",
|
|
}
|
|
}
|
|
|
|
fn apply_fake_movement(
|
|
backend: &FakeBackend,
|
|
state: &mut State,
|
|
action: Movement,
|
|
) -> Result<String, String> {
|
|
let value = match action {
|
|
Movement::Pulse(direction, duration) => {
|
|
backend.record(format!(
|
|
"CALL movement-{} duration-ms={}",
|
|
direction_name(direction),
|
|
duration.as_millis()
|
|
));
|
|
format!("movement {} complete", direction_name(direction))
|
|
}
|
|
Movement::Crouch(enabled) => {
|
|
backend.record(format!("CALL movement-crouch {enabled}"));
|
|
"crouch updated".into()
|
|
}
|
|
Movement::Fly(enabled) => {
|
|
backend.record(format!("CALL movement-fly {enabled}"));
|
|
"flight updated".into()
|
|
}
|
|
Movement::FlyTo(position, duration) => {
|
|
state.fake.position = position;
|
|
backend.record(format!(
|
|
"CALL movement-flyto {position:?} duration-ms={}",
|
|
duration.as_millis()
|
|
));
|
|
"FlyTo complete".into()
|
|
}
|
|
Movement::Follow(target) => {
|
|
state.follow_target = target.as_ref().map(|value| value.0);
|
|
backend.record(format!(
|
|
"CALL movement-follow {}",
|
|
target
|
|
.as_ref()
|
|
.map_or_else(|| "off".into(), |(id, _)| id.to_string())
|
|
));
|
|
"Follow updated".into()
|
|
}
|
|
Movement::GoHome => {
|
|
state.fake.position = Vector3::new_with_single_single_single(128.0, 128.0, 25.0)
|
|
.map_err(|_| "Invalid home position")?;
|
|
backend.record("CALL teleport-home".into());
|
|
"Teleport complete".into()
|
|
}
|
|
Movement::TeleportRegion(name, position) => {
|
|
state.fake.region_name.clone_from(&name);
|
|
state.fake.position = position;
|
|
backend.record(format!("CALL teleport-region {name} {position:?}"));
|
|
"Teleport complete".into()
|
|
}
|
|
Movement::TeleportLandmark(id) => {
|
|
backend.record(format!("CALL teleport-landmark {id}"));
|
|
"Teleport complete".into()
|
|
}
|
|
Movement::Jump => {
|
|
backend.record("CALL movement-jump".into());
|
|
"Jump complete".into()
|
|
}
|
|
Movement::AutoPilot { local, global } => {
|
|
state.fake.position = local;
|
|
backend.record(format!("CALL movement-autopilot {global:?}"));
|
|
"Autopilot started".into()
|
|
}
|
|
Movement::SetHome => {
|
|
backend.record("CALL movement-set-home".into());
|
|
"Home set".into()
|
|
}
|
|
Movement::Sit(id) => {
|
|
backend.record(format!("CALL movement-sit {id}"));
|
|
"Sit requested".into()
|
|
}
|
|
Movement::Stand => {
|
|
backend.record("CALL movement-stand".into());
|
|
"Stand requested".into()
|
|
}
|
|
Movement::Turn(position) => {
|
|
backend.record(format!("CALL movement-turn {position:?}"));
|
|
"Turn complete".into()
|
|
}
|
|
Movement::Cross(direction, fly) => {
|
|
state.fake.position.x = if direction.x < 0.0 { 246.0 } else { 10.0 };
|
|
state.fake.position.y = if direction.y < 0.0 { 246.0 } else { 10.0 };
|
|
backend.record(format!("CALL movement-cross {direction:?} fly={fly}"));
|
|
"Successfully crossed region border".into()
|
|
}
|
|
};
|
|
Ok(value)
|
|
}
|
|
|
|
pub(super) fn apply_fake_directive(
|
|
manager: &ClientManager,
|
|
fields: &[&str],
|
|
) -> Option<Result<(), &'static str>> {
|
|
if !fields
|
|
.first()
|
|
.is_some_and(|name| name.starts_with("world-"))
|
|
{
|
|
return None;
|
|
}
|
|
let client = fields
|
|
.get(1)
|
|
.and_then(|value| UUID::new_with_string((*value).into()).ok());
|
|
let Some(client) = client else {
|
|
return Some(Err("world fixture client UUID is invalid"));
|
|
};
|
|
let Some(client) = manager.clients.get(&client) else {
|
|
return Some(Err("world fixture client is not registered"));
|
|
};
|
|
Some(client.backend.apply_world_fixture(fields))
|
|
}
|
|
|
|
fn fixture_uuid(value: &str) -> Result<UUID, &'static str> {
|
|
UUID::new_with_string(value.into()).map_err(|_| "world fixture UUID is invalid")
|
|
}
|
|
fn fixture_number<T: std::str::FromStr>(value: &str) -> Result<T, &'static str> {
|
|
value.parse().map_err(|_| "world fixture number is invalid")
|
|
}
|
|
fn fixture_vector(x: &str, y: &str, z: &str) -> Result<Vector3, &'static str> {
|
|
Vector3::new_with_single_single_single(
|
|
fixture_number(x)?,
|
|
fixture_number(y)?,
|
|
fixture_number(z)?,
|
|
)
|
|
.map_err(|_| "world fixture vector is invalid")
|
|
}
|
|
fn fixture_hex(value: &str) -> Result<Vec<u8>, &'static str> {
|
|
if !value.len().is_multiple_of(2)
|
|
|| u64::try_from(value.len() / 2).unwrap_or(u64::MAX) > MAX_FILE_BYTES
|
|
{
|
|
return Err("world asset fixture is invalid or too large");
|
|
}
|
|
value
|
|
.as_bytes()
|
|
.chunks_exact(2)
|
|
.map(|pair| {
|
|
let high = hex_nibble(pair[0]).ok_or("world asset fixture is invalid")?;
|
|
let low = hex_nibble(pair[1]).ok_or("world asset fixture is invalid")?;
|
|
Ok(high << 4 | low)
|
|
})
|
|
.collect()
|
|
}
|
|
const fn hex_nibble(value: u8) -> Option<u8> {
|
|
match value {
|
|
b'0'..=b'9' => Some(value - b'0'),
|
|
b'a'..=b'f' => Some(value - b'a' + 10),
|
|
b'A'..=b'F' => Some(value - b'A' + 10),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
fn apply_fixture(backend: &FakeBackend, fields: &[&str]) -> Result<(), &'static str> {
|
|
let mut state = lock(&backend.world_state);
|
|
let world = &mut state.fake;
|
|
match fields {
|
|
["world-region", _, handle, name, x, y, z, wind_x, wind_y] => {
|
|
world.region_handle = fixture_number(handle)?;
|
|
world.region_name = (*name).into();
|
|
world.position = fixture_vector(x, y, z)?;
|
|
let wind =
|
|
Vector2::new_with_single_single(fixture_number(wind_x)?, fixture_number(wind_y)?)
|
|
.map_err(|_| "world fixture wind is invalid")?;
|
|
world.wind = Some(vec![wind; 256]);
|
|
}
|
|
["world-avatar", _, id, name, x, y, z] => world.avatars.push(AvatarView {
|
|
id: fixture_uuid(id)?,
|
|
name: (*name).into(),
|
|
position: fixture_vector(x, y, z)?,
|
|
}),
|
|
[
|
|
"world-prim",
|
|
_,
|
|
id,
|
|
local,
|
|
parent,
|
|
owner,
|
|
name,
|
|
description,
|
|
x,
|
|
y,
|
|
z,
|
|
texture,
|
|
particle,
|
|
] => {
|
|
let mut prim = Primitive::new_with_constructor()
|
|
.map_err(|_| "world primitive fixture could not be created")?;
|
|
prim.id = fixture_uuid(id)?;
|
|
prim.local_id = fixture_number(local)?;
|
|
prim.parent_id = fixture_number(parent)?;
|
|
prim.owner_id = fixture_uuid(owner)?;
|
|
prim.position = fixture_vector(x, y, z)?;
|
|
prim.scale = Vector3::new_with_single_single_single(1.0, 1.0, 1.0)
|
|
.map_err(|_| "world primitive fixture scale is invalid")?;
|
|
let mut properties = PrimitiveObjectProperties::new()
|
|
.map_err(|_| "world primitive properties fixture could not be created")?;
|
|
properties.object_id = prim.id;
|
|
properties.owner_id = prim.owner_id;
|
|
properties.creator_id = prim.owner_id;
|
|
properties.name = (*name).into();
|
|
properties.description = (*description).into();
|
|
properties.permissions = libremetaverse::Permissions::full_permissions();
|
|
prim.properties = Some(properties);
|
|
let texture = fixture_uuid(texture)?;
|
|
let mut textures = PrimitiveTextureEntry::new_with_uuid(texture)
|
|
.map_err(|_| "world texture fixture could not be created")?;
|
|
textures
|
|
.create_face(0)
|
|
.map_err(|_| "world texture face fixture could not be created")?
|
|
.set_texture_id(texture);
|
|
prim.textures = Some(textures);
|
|
if *particle == "true" {
|
|
prim.particle_sys.crc = 1;
|
|
prim.particle_sys.pattern = PrimitiveParticleSystemSourcePattern::DROP;
|
|
prim.particle_sys.texture = texture;
|
|
} else if *particle != "false" {
|
|
return Err("world particle fixture must be true or false");
|
|
}
|
|
world.primitives.push(prim);
|
|
}
|
|
[
|
|
"world-parcel",
|
|
_,
|
|
local,
|
|
owner,
|
|
name,
|
|
description,
|
|
dwell,
|
|
area,
|
|
total,
|
|
max,
|
|
] => {
|
|
let mut parcel = Parcel::new(fixture_number(local)?)
|
|
.map_err(|_| "world parcel fixture could not be created")?;
|
|
parcel.owner_id = fixture_uuid(owner)?;
|
|
parcel.name = (*name).into();
|
|
parcel.desc = (*description).into();
|
|
parcel.dwell = fixture_number(dwell)?;
|
|
parcel.area = fixture_number(area)?;
|
|
parcel.total_prims = fixture_number(total)?;
|
|
parcel.max_prims = fixture_number(max)?;
|
|
world.parcels.push(ParcelView {
|
|
parcel,
|
|
owners: Vec::new(),
|
|
selected: HashMap::new(),
|
|
});
|
|
}
|
|
["world-parcel-owner", _, local, owner, count] => {
|
|
let local: i32 = fixture_number(local)?;
|
|
world
|
|
.parcels
|
|
.iter_mut()
|
|
.find(|view| view.parcel.local_id == local)
|
|
.ok_or("world parcel fixture must precede owner")?
|
|
.owners
|
|
.push(OwnerView {
|
|
owner: fixture_uuid(owner)?,
|
|
count: fixture_number(count)?,
|
|
});
|
|
}
|
|
["world-parcel-object", _, local, owner, ids] => {
|
|
let local: i32 = fixture_number(local)?;
|
|
let view = world
|
|
.parcels
|
|
.iter_mut()
|
|
.find(|view| view.parcel.local_id == local)
|
|
.ok_or("world parcel fixture must precede object")?;
|
|
let values = if *ids == "-" {
|
|
Vec::new()
|
|
} else {
|
|
ids.split(',')
|
|
.map(fixture_number)
|
|
.collect::<Result<_, _>>()?
|
|
};
|
|
view.selected.insert(fixture_uuid(owner)?, values);
|
|
}
|
|
["world-grid-region", _, name, handle, x, y, agents] => world.regions.push(GridRegion {
|
|
access: libremetaverse::SimAccess::UNKNOWN,
|
|
agents: fixture_number(agents)?,
|
|
map_image_id: UUID::zero(),
|
|
name: (*name).into(),
|
|
region_flags: libremetaverse::RegionFlags(0),
|
|
region_handle: fixture_number(handle)?,
|
|
water_height: 20,
|
|
x: fixture_number(x)?,
|
|
y: fixture_number(y)?,
|
|
}),
|
|
["world-layer", _, image, bottom, left, top, right] => world.layers.push(GridLayer {
|
|
bottom: fixture_number(bottom)?,
|
|
image_id: fixture_uuid(image)?,
|
|
left: fixture_number(left)?,
|
|
right: fixture_number(right)?,
|
|
top: fixture_number(top)?,
|
|
}),
|
|
["world-agent-location", _, handle, count, x, y] => world
|
|
.locations
|
|
.entry(fixture_number(handle)?)
|
|
.or_default()
|
|
.push(AgentLocation {
|
|
count: fixture_number(count)?,
|
|
x: fixture_number(x)?,
|
|
y: fixture_number(y)?,
|
|
}),
|
|
[
|
|
"world-estate",
|
|
_,
|
|
name,
|
|
owner,
|
|
covenant,
|
|
timestamp,
|
|
body,
|
|
terrain,
|
|
] => {
|
|
world.estate = Some(EstateView {
|
|
name: (*name).into(),
|
|
owner: fixture_uuid(owner)?,
|
|
covenant: fixture_uuid(covenant)?,
|
|
timestamp: fixture_number(timestamp)?,
|
|
body: (*body).into(),
|
|
terrain: fixture_hex(terrain)?,
|
|
});
|
|
}
|
|
["world-asset", _, id, data] => {
|
|
world.assets.insert(fixture_uuid(id)?, fixture_hex(data)?);
|
|
}
|
|
["world-syntax", _, tokens] => world.syntax.extend(tokens.split(',').map(str::to_owned)),
|
|
_ => return Err("invalid world fake directive"),
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
pub(super) fn install_live(backend: &Arc<LiveBackend>, subscriptions: &mut Vec<Subscription>) {
|
|
let weak = Arc::downgrade(backend);
|
|
let grid = lock(&backend.client).grid();
|
|
subscriptions.push(
|
|
grid.subscribe_coarse_location_update(Arc::new(move |event| {
|
|
let Some(backend) = weak.upgrade() else {
|
|
return;
|
|
};
|
|
let positions = event.positions();
|
|
let simulator = event.simulator();
|
|
let mut state = lock(&backend.world_state);
|
|
for (id, position) in positions {
|
|
if let Some(avatar) = state.fake.avatars.iter_mut().find(|avatar| avatar.id == id) {
|
|
avatar.position = position;
|
|
} else {
|
|
state.fake.avatars.push(AvatarView {
|
|
id,
|
|
name: String::new(),
|
|
position,
|
|
});
|
|
}
|
|
}
|
|
let removed = event.removed_entries();
|
|
state
|
|
.fake
|
|
.avatars
|
|
.retain(|avatar| !removed.contains(&avatar.id));
|
|
let follow_position = state.follow_target.and_then(|target| {
|
|
state
|
|
.fake
|
|
.avatars
|
|
.iter()
|
|
.find(|avatar| avatar.id == target)
|
|
.map(|avatar| avatar.position)
|
|
});
|
|
drop(state);
|
|
if let Some(position) = follow_position {
|
|
let Ok((x, y)) = region_origin(simulator.handle) else {
|
|
return;
|
|
};
|
|
let _ = backend.with_agent(|agent| {
|
|
agent.auto_pilot_with_double_double_double(
|
|
f64::from(position.x) + f64::from(x),
|
|
f64::from(position.y) + f64::from(y),
|
|
f64::from(position.z),
|
|
)
|
|
});
|
|
}
|
|
})),
|
|
);
|
|
let weak = Arc::downgrade(backend);
|
|
let (objects, assets) = {
|
|
let client = lock(&backend.client);
|
|
(client.objects(), client.assets())
|
|
};
|
|
subscriptions.push(objects.subscribe_object_update(Arc::new(move |event| {
|
|
let Some(backend) = weak.upgrade() else {
|
|
return;
|
|
};
|
|
let requests = {
|
|
let mut state = lock(&backend.world_state);
|
|
if !state.textures_enabled {
|
|
return;
|
|
}
|
|
texture_ids(&event.prim())
|
|
.into_iter()
|
|
.filter(|id| state.requested_textures.insert(*id))
|
|
.collect::<Vec<_>>()
|
|
};
|
|
for id in requests {
|
|
let assets = assets.clone();
|
|
tokio::spawn(async move {
|
|
let _ = assets
|
|
.request_asset_with_uuid_asset_type_boolean_cancellation_token(
|
|
id,
|
|
AssetType::Texture,
|
|
false,
|
|
None,
|
|
)
|
|
.await;
|
|
});
|
|
}
|
|
})));
|
|
}
|
|
|
|
impl Backend for LiveBackend {
|
|
fn world_state(&self) -> &Mutex<State> {
|
|
&self.world_state
|
|
}
|
|
fn world_agent_id(&self) -> UUID {
|
|
self.id
|
|
}
|
|
fn world_snapshot<'a>(
|
|
&'a self,
|
|
cancellation: CancellationToken,
|
|
) -> BackendFuture<'a, Result<Snapshot, String>> {
|
|
Box::pin(async move {
|
|
if cancellation.is_cancellation_requested() {
|
|
return Err("World query cancelled".into());
|
|
}
|
|
let (position, simulator, grid) = {
|
|
let mut client = lock(&self.client);
|
|
(
|
|
client.self_().sim_position(),
|
|
client.network().current_sim(),
|
|
client.grid(),
|
|
)
|
|
};
|
|
let simulator = simulator.ok_or("No current simulator available")?;
|
|
let primitives = simulator
|
|
.objects_primitives
|
|
.read()
|
|
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
|
.values()
|
|
.take(MAX_QUERY_RESULTS + 1)
|
|
.cloned()
|
|
.collect::<Vec<_>>();
|
|
if primitives.len() > MAX_QUERY_RESULTS {
|
|
return Err("Simulator primitive cache exceeds 65,535 entries".into());
|
|
}
|
|
let parcels = simulator
|
|
.parcels
|
|
.read()
|
|
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
|
.values()
|
|
.take(MAX_QUERY_RESULTS + 1)
|
|
.cloned()
|
|
.map(|parcel| ParcelView {
|
|
parcel,
|
|
owners: Vec::new(),
|
|
selected: HashMap::new(),
|
|
})
|
|
.collect::<Vec<_>>();
|
|
if parcels.len() > MAX_QUERY_RESULTS {
|
|
return Err("Simulator parcel cache exceeds 65,535 entries".into());
|
|
}
|
|
let state = lock(&self.world_state);
|
|
Ok(Snapshot {
|
|
region_name: simulator.name.clone(),
|
|
region_handle: simulator.handle,
|
|
position,
|
|
wind: simulator
|
|
.wind_speeds
|
|
.read()
|
|
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
|
.clone(),
|
|
primitives,
|
|
avatars: state.fake.avatars.clone(),
|
|
parcels,
|
|
regions: grid.regions_read_only().into_values().collect(),
|
|
layers: Vec::new(),
|
|
locations: HashMap::new(),
|
|
estate: None,
|
|
assets: HashMap::new(),
|
|
syntax: native_syntax(),
|
|
})
|
|
})
|
|
}
|
|
fn world_query<'a>(
|
|
&'a self,
|
|
query: Query,
|
|
cancellation: CancellationToken,
|
|
) -> BackendFuture<'a, Result<Snapshot, String>> {
|
|
Box::pin(async move {
|
|
match query {
|
|
Query::AgentLocations(handle) => {
|
|
let grid = lock(&self.client).grid();
|
|
let items = grid
|
|
.map_items(
|
|
handle,
|
|
libremetaverse::GridItemType::AgentLocations,
|
|
libremetaverse::GridLayerType::Objects,
|
|
Some(cancellation.clone()),
|
|
)
|
|
.await
|
|
.map_err(|_| "Failed to fetch agent locations")?;
|
|
let mut snapshot = self.world_snapshot(cancellation).await?;
|
|
let mut values = Vec::new();
|
|
for item in items.into_iter().take(MAX_QUERY_RESULTS) {
|
|
if let libremetaverse::MapItemData::AgentLocation(ref location) = item.data
|
|
{
|
|
values.push(AgentLocation {
|
|
count: location.avatar_count,
|
|
x: item.local_x(),
|
|
y: item.local_y(),
|
|
});
|
|
}
|
|
}
|
|
snapshot.locations.insert(handle, values);
|
|
Ok(snapshot)
|
|
}
|
|
Query::FindRegion(name) => {
|
|
let grid = lock(&self.client).grid();
|
|
let region = grid
|
|
.get_grid_region_with_string_grid_layer_type_cancellation_token(
|
|
name,
|
|
libremetaverse::GridLayerType::Objects,
|
|
Some(cancellation.clone()),
|
|
)
|
|
.await
|
|
.map_err(|_| "Simulator lookup failed")?
|
|
.flatten();
|
|
let mut snapshot = self.world_snapshot(cancellation).await?;
|
|
if let Some(region) = region {
|
|
snapshot.regions.push(region);
|
|
}
|
|
Ok(snapshot)
|
|
}
|
|
Query::GridLayer => {
|
|
let grid = lock(&self.client).grid();
|
|
let layers = Arc::new(Mutex::new(Vec::new()));
|
|
let callback = Arc::clone(&layers);
|
|
let subscription = grid.subscribe_grid_layer(Arc::new(move |event| {
|
|
let mut values = lock(&callback);
|
|
if values.len() < MAX_QUERY_RESULTS {
|
|
values.push(event.layer());
|
|
}
|
|
}));
|
|
grid.request_map_layer(
|
|
libremetaverse::GridLayerType::Objects,
|
|
Some(cancellation.clone()),
|
|
)
|
|
.await
|
|
.map_err(|_| "Grid layer request failed")?;
|
|
tokio::select! {()=tokio::time::sleep(Duration::from_millis(250))=>{},()=cancellation.cancelled()=>return Err("Grid layer request cancelled".into())}
|
|
drop(subscription);
|
|
let mut snapshot = self.world_snapshot(cancellation).await?;
|
|
snapshot.layers.clone_from(&lock(&layers));
|
|
Ok(snapshot)
|
|
}
|
|
Query::GridMap => {
|
|
let grid = lock(&self.client).grid();
|
|
grid.request_mainland_sims(libremetaverse::GridLayerType::Objects)
|
|
.map_err(|_| "Grid map request failed")?;
|
|
tokio::select! {()=tokio::time::sleep(Duration::from_millis(500))=>{},()=cancellation.cancelled()=>return Err("Grid map request cancelled".into())}
|
|
self.world_snapshot(cancellation).await
|
|
}
|
|
Query::Parcels => {
|
|
let (parcels, sim) = {
|
|
let client = lock(&self.client);
|
|
(client.parcels(), client.network().current_sim())
|
|
};
|
|
let sim = sim.ok_or("No current simulator available")?;
|
|
parcels.request_all_sim_parcels_with_simulator_boolean_time_span_cancellation_token(sim,false,Duration::from_millis(25),Some(cancellation.clone())).await.map_err(|_|"Failed to retrieve information on all the simulator parcels")?;
|
|
self.world_snapshot(cancellation).await
|
|
}
|
|
Query::ParcelOwners(local) => live_parcel_owners(self, local, cancellation).await,
|
|
Query::ParcelObjects(local, owner) => {
|
|
live_parcel_objects(self, local, owner, cancellation).await
|
|
}
|
|
Query::EstateCovenant(timeout) => {
|
|
live_estate_covenant(self, timeout, cancellation).await
|
|
}
|
|
Query::DownloadTerrain(timeout) => {
|
|
live_download_terrain(self, timeout, cancellation).await
|
|
}
|
|
}
|
|
})
|
|
}
|
|
fn world_mutate<'a>(
|
|
&'a self,
|
|
mutation: Mutation,
|
|
cancellation: CancellationToken,
|
|
) -> BackendFuture<'a, Result<String, String>> {
|
|
Box::pin(async move {
|
|
if cancellation.is_cancellation_requested() {
|
|
return Err("World mutation cancelled".into());
|
|
}
|
|
match mutation {
|
|
Mutation::Movement(movement) => live_movement(self, movement, cancellation).await,
|
|
Mutation::ChangePermissions { root, permissions } => {
|
|
live_change_permissions(self, root, permissions, cancellation).await
|
|
}
|
|
Mutation::DeRez(id) => {
|
|
let (inventory, sim) = {
|
|
let client = lock(&self.client);
|
|
(client.inventory(), client.network().current_sim())
|
|
};
|
|
let sim = sim.ok_or("No current simulator available")?;
|
|
let prim = sim
|
|
.objects_primitives
|
|
.read()
|
|
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
|
.values()
|
|
.find(|prim| prim.id == id)
|
|
.cloned()
|
|
.ok_or_else(|| format!("Could not find object {id}"))?;
|
|
let trash = inventory
|
|
.find_folder_for_type_with_folder_type(FolderType::Trash)
|
|
.map_err(|_| "Trash folder is unavailable")?;
|
|
inventory
|
|
.request_de_rez_to_inventory_with_u_int32_de_rez_destination_uuid_uuid(
|
|
prim.local_id,
|
|
libremetaverse::DeRezDestination::AgentInventoryTake,
|
|
trash,
|
|
UUID::random().map_err(|_| "Could not create transaction UUID")?,
|
|
)
|
|
.map_err(|_| "DeRez request failed")?;
|
|
Ok("Object removed".into())
|
|
}
|
|
Mutation::Import {
|
|
primitives,
|
|
use_group,
|
|
} => live_import(self, primitives, use_group, cancellation).await,
|
|
Mutation::SetTextures(enabled) => {
|
|
lock(&self.world_state).textures_enabled = enabled;
|
|
if enabled {
|
|
let snapshot = self.world_snapshot(cancellation.clone()).await?;
|
|
let mut requests = BTreeSet::new();
|
|
for prim in &snapshot.primitives {
|
|
requests.extend(texture_ids(prim));
|
|
}
|
|
let assets = lock(&self.client).assets();
|
|
for id in requests {
|
|
cancellation
|
|
.throw_if_cancellation_requested()
|
|
.map_err(|_| "Texture download cancelled")?;
|
|
let should_request =
|
|
lock(&self.world_state).requested_textures.insert(id);
|
|
if should_request {
|
|
assets
|
|
.request_asset_with_uuid_asset_type_boolean_cancellation_token(
|
|
id,
|
|
AssetType::Texture,
|
|
false,
|
|
Some(cancellation.clone()),
|
|
)
|
|
.await
|
|
.map_err(|_| format!("Texture request failed: {id}"))?;
|
|
}
|
|
}
|
|
}
|
|
Ok("Texture setting updated".into())
|
|
}
|
|
Mutation::Tree(species) => {
|
|
let (objects, sim, position, group) = {
|
|
let mut client = lock(&self.client);
|
|
let position = client.self_().sim_position();
|
|
let group = client.self_().active_group();
|
|
(
|
|
client.objects(),
|
|
client.network().current_sim(),
|
|
position,
|
|
group,
|
|
)
|
|
};
|
|
let sim = sim.ok_or("No current simulator available")?;
|
|
let position = Vector3::new_with_single_single_single(
|
|
position.x,
|
|
position.y,
|
|
position.z + 3.0,
|
|
)
|
|
.map_err(|_| "Invalid tree position")?;
|
|
objects
|
|
.add_tree(
|
|
sim,
|
|
Vector3::new_with_single_single_single(0.5, 0.5, 0.5)
|
|
.map_err(|_| "Invalid tree scale")?,
|
|
Quaternion::identity(),
|
|
position,
|
|
species,
|
|
group,
|
|
false,
|
|
)
|
|
.map_err(|_| "Tree rez request failed")?;
|
|
Ok("Tree rez requested".into())
|
|
}
|
|
Mutation::UploadTerrain { name, data } => {
|
|
live_upload_terrain(self, name, data, cancellation).await
|
|
}
|
|
}
|
|
})
|
|
}
|
|
fn world_texture<'a>(
|
|
&'a self,
|
|
id: UUID,
|
|
_discard: i32,
|
|
cancellation: CancellationToken,
|
|
) -> BackendFuture<'a, Result<Vec<u8>, String>> {
|
|
Box::pin(async move {
|
|
let assets = lock(&self.client).assets();
|
|
let texture = assets
|
|
.request_asset_with_uuid_asset_type_boolean_cancellation_token(
|
|
id,
|
|
AssetType::Texture,
|
|
true,
|
|
Some(cancellation),
|
|
)
|
|
.await
|
|
.map_err(|_| format!("Texture request failed: {id}"))?
|
|
.ok_or_else(|| format!("Download failed or texture not found: {id}"))?;
|
|
Ok(texture.asset_data)
|
|
})
|
|
}
|
|
fn world_resolve_avatar<'a>(
|
|
&'a self,
|
|
name: &'a str,
|
|
cancellation: CancellationToken,
|
|
) -> BackendFuture<'a, Result<Option<UUID>, String>> {
|
|
Box::pin(async move {
|
|
super::ClientBackend::resolve_avatar(self, name, cancellation)
|
|
.await
|
|
.map_err(str::to_owned)
|
|
})
|
|
}
|
|
}
|
|
|
|
fn native_syntax() -> Vec<String> {
|
|
[
|
|
"default",
|
|
"state",
|
|
"state_entry",
|
|
"touch_start",
|
|
"timer",
|
|
"integer",
|
|
"float",
|
|
"string",
|
|
"key",
|
|
"vector",
|
|
"rotation",
|
|
"list",
|
|
"if",
|
|
"else",
|
|
"for",
|
|
"while",
|
|
"return",
|
|
"jump",
|
|
"TRUE",
|
|
"FALSE",
|
|
"NULL_KEY",
|
|
"llSay",
|
|
"llOwnerSay",
|
|
"llParticleSystem",
|
|
]
|
|
.into_iter()
|
|
.map(str::to_owned)
|
|
.collect()
|
|
}
|
|
|
|
async fn live_parcel_owners(
|
|
backend: &LiveBackend,
|
|
local: i32,
|
|
cancellation: CancellationToken,
|
|
) -> Result<Snapshot, String> {
|
|
let parcels = lock(&backend.client).parcels();
|
|
let sim = backend
|
|
.network
|
|
.current_sim()
|
|
.ok_or("No current simulator available")?;
|
|
let (sender, receiver) = tokio::sync::oneshot::channel();
|
|
let sender = Arc::new(Mutex::new(Some(sender)));
|
|
let callback = Arc::clone(&sender);
|
|
let subscription = parcels.subscribe_parcel_object_owners_reply(Arc::new(move |event| {
|
|
if let Some(sender) = lock(&callback).take() {
|
|
let _ = sender.send(event.prim_owners());
|
|
}
|
|
}));
|
|
parcels
|
|
.request_object_owners(sim, local)
|
|
.map_err(|_| "Parcel owner request failed")?;
|
|
let owners = tokio::select! {value=receiver=>value.map_err(|_|"Parcel owner reply channel closed")?,()=tokio::time::sleep(Duration::from_secs(10))=>return Err("Timed out waiting for packet.".into()),()=cancellation.cancelled()=>return Err("Parcel owner request cancelled".into())};
|
|
drop(subscription);
|
|
let mut snapshot = backend.world_snapshot(cancellation).await?;
|
|
let view=snapshot.parcels.iter_mut().find(|view|view.parcel.local_id==local).ok_or_else(||format!("Unable to find Parcel {local} in Parcels Dictionary, Did you run parcelinfo to populate the dictionary first?"))?;
|
|
view.owners = owners
|
|
.into_iter()
|
|
.take(MAX_QUERY_RESULTS)
|
|
.map(|owner| OwnerView {
|
|
owner: owner.owner_id,
|
|
count: owner.count,
|
|
})
|
|
.collect();
|
|
Ok(snapshot)
|
|
}
|
|
|
|
async fn live_parcel_objects(
|
|
backend: &LiveBackend,
|
|
local: i32,
|
|
owner: UUID,
|
|
cancellation: CancellationToken,
|
|
) -> Result<Snapshot, String> {
|
|
let parcels = lock(&backend.client).parcels();
|
|
let (sender, receiver) = tokio::sync::oneshot::channel();
|
|
let sender = Arc::new(Mutex::new(Some(sender)));
|
|
let callback = Arc::clone(&sender);
|
|
let subscription = parcels.subscribe_force_select_objects_reply(Arc::new(move |event| {
|
|
let ids = event.object_i_ds();
|
|
if ids.len() < 251
|
|
&& let Some(sender) = lock(&callback).take()
|
|
{
|
|
let _ = sender.send(ids);
|
|
}
|
|
}));
|
|
parcels
|
|
.request_select_objects(local, libremetaverse::ObjectReturnType::List, owner)
|
|
.map_err(|_| "Parcel object selection request failed")?;
|
|
let ids = tokio::select! {value=receiver=>value.map_err(|_|"Parcel object reply channel closed")?,()=tokio::time::sleep(Duration::from_secs(30))=>return Err("Timed out waiting for packet.".into()),()=cancellation.cancelled()=>return Err("Parcel object request cancelled".into())};
|
|
drop(subscription);
|
|
let mut snapshot = backend.world_snapshot(cancellation).await?;
|
|
let view = snapshot
|
|
.parcels
|
|
.iter_mut()
|
|
.find(|view| view.parcel.local_id == local)
|
|
.ok_or_else(|| format!("Unable to find Parcel {local} in Parcels Dictionary"))?;
|
|
view.selected.insert(owner, ids);
|
|
Ok(snapshot)
|
|
}
|
|
|
|
async fn live_estate_covenant(
|
|
backend: &LiveBackend,
|
|
timeout: Duration,
|
|
cancellation: CancellationToken,
|
|
) -> Result<Snapshot, String> {
|
|
let estate = lock(&backend.client).estate();
|
|
let (sender, receiver) = tokio::sync::oneshot::channel();
|
|
let sender = Arc::new(Mutex::new(Some(sender)));
|
|
let callback = Arc::clone(&sender);
|
|
let subscription = estate.subscribe_estate_covenant_reply(Arc::new(move |event| {
|
|
if let Some(sender) = lock(&callback).take() {
|
|
let _ = sender.send(event);
|
|
}
|
|
}));
|
|
estate
|
|
.request_covenant()
|
|
.map_err(|_| "Covenant request failed")?;
|
|
let reply = tokio::select! {value=receiver=>value.map_err(|_|"Covenant reply channel closed")?,()=tokio::time::sleep(timeout)=>return Err("Timeout waiting for covenant info.".into()),()=cancellation.cancelled()=>return Err("Covenant request cancelled".into())};
|
|
drop(subscription);
|
|
let body = if reply.covenant_id() == UUID::zero() {
|
|
String::new()
|
|
} else {
|
|
estate
|
|
.request_covenant_notecard_with_uuid_cancellation_token(
|
|
reply.covenant_id(),
|
|
Some(cancellation.clone()),
|
|
)
|
|
.await
|
|
.map_err(|_| "Could not retrieve covenant notecard")?
|
|
.map_or_else(
|
|
|| "Could not retrieve covenant notecard.".into(),
|
|
|asset| String::from_utf8_lossy(&asset.asset_data).into_owned(),
|
|
)
|
|
};
|
|
let mut snapshot = backend.world_snapshot(cancellation).await?;
|
|
snapshot.estate = Some(EstateView {
|
|
name: reply.estate_name(),
|
|
owner: reply.estate_owner_id(),
|
|
covenant: reply.covenant_id(),
|
|
timestamp: u32::try_from(reply.timestamp()).unwrap_or_default(),
|
|
body,
|
|
terrain: Vec::new(),
|
|
});
|
|
Ok(snapshot)
|
|
}
|
|
|
|
async fn live_download_terrain(
|
|
backend: &LiveBackend,
|
|
timeout: Duration,
|
|
cancellation: CancellationToken,
|
|
) -> Result<Snapshot, String> {
|
|
let (assets, estate, region) = {
|
|
let client = lock(&backend.client);
|
|
(
|
|
client.assets(),
|
|
client.estate(),
|
|
client
|
|
.network()
|
|
.current_sim()
|
|
.map_or_else(|| "terrain".into(), |sim| sim.name.clone()),
|
|
)
|
|
};
|
|
estate.enable_live_mutations();
|
|
let (sender, receiver) = tokio::sync::oneshot::channel();
|
|
let sender = Arc::new(Mutex::new(Some(sender)));
|
|
let callback = Arc::clone(&sender);
|
|
let xfer_assets = assets.clone();
|
|
let initiate = assets.subscribe_initiate_download(Arc::new(move |event| {
|
|
let _ = xfer_assets.request_asset_xfer(
|
|
event.sim_file_name(),
|
|
false,
|
|
false,
|
|
UUID::zero(),
|
|
AssetType::Unknown,
|
|
false,
|
|
);
|
|
}));
|
|
let xfer = assets.subscribe_xfer_received(Arc::new(move |event| {
|
|
let transfer = event.xfer();
|
|
if transfer.base.success
|
|
&& let Some(sender) = lock(&callback).take()
|
|
{
|
|
let _ = sender.send(transfer.base.asset_data);
|
|
}
|
|
}));
|
|
estate
|
|
.estate_owner_message_with_string_list(
|
|
"terrain".into(),
|
|
vec!["download filename".into(), format!("{region}.raw")],
|
|
)
|
|
.map_err(|_| "Terrain download request failed")?;
|
|
let data = tokio::select! {value=receiver=>value.map_err(|_|"Terrain transfer channel closed")?,()=tokio::time::sleep(timeout)=>return Err("Timeout while waiting for terrain data".into()),()=cancellation.cancelled()=>return Err("Terrain download cancelled".into())};
|
|
drop(initiate);
|
|
drop(xfer);
|
|
if data.len() as u64 > MAX_FILE_BYTES {
|
|
return Err("Terrain transfer exceeds the 64 MiB limit".into());
|
|
}
|
|
let mut snapshot = backend.world_snapshot(cancellation).await?;
|
|
snapshot.estate = Some(EstateView {
|
|
name: String::new(),
|
|
owner: UUID::zero(),
|
|
covenant: UUID::zero(),
|
|
timestamp: 0,
|
|
body: String::new(),
|
|
terrain: data,
|
|
});
|
|
Ok(snapshot)
|
|
}
|
|
|
|
async fn live_movement(
|
|
backend: &LiveBackend,
|
|
movement: Movement,
|
|
cancellation: CancellationToken,
|
|
) -> Result<String, String> {
|
|
match movement {
|
|
Movement::Pulse(direction, duration) => {
|
|
let mut agent = live_agent(backend)?;
|
|
let movement = &mut agent.movement;
|
|
set_direction(movement, direction, true);
|
|
movement
|
|
.send_update_with_boolean(Some(duration.is_zero()))
|
|
.map_err(|_| "Movement update failed")?;
|
|
if !duration.is_zero() {
|
|
let deadline = tokio::time::Instant::now() + duration;
|
|
loop {
|
|
tokio::select! {()=tokio::time::sleep(Duration::from_millis(100))=>{if tokio::time::Instant::now()>=deadline{break;}movement.send_update_with_boolean(Some(false)).map_err(|_|"Movement update failed")?;},()=cancellation.cancelled()=>{set_direction(movement,direction,false);let _=movement.send_update_with_boolean(Some(true));return Err("Movement cancelled".into());}}
|
|
}
|
|
}
|
|
set_direction(movement, direction, false);
|
|
movement
|
|
.send_update_with_boolean(Some(true))
|
|
.map_err(|_| "Movement stop failed")?;
|
|
Ok("Movement complete".into())
|
|
}
|
|
Movement::Crouch(enabled) => {
|
|
live_agent(backend)?
|
|
.crouch(enabled)
|
|
.map_err(|_| "Crouch request failed")?;
|
|
Ok("Crouch updated".into())
|
|
}
|
|
Movement::Fly(enabled) => {
|
|
live_agent(backend)?
|
|
.fly(enabled)
|
|
.map_err(|_| "Flight request failed")?;
|
|
Ok("Flight updated".into())
|
|
}
|
|
Movement::FlyTo(target, duration) => {
|
|
live_fly_to(backend, target, duration, cancellation).await
|
|
}
|
|
Movement::Follow(target) => {
|
|
lock(&backend.world_state).follow_target = target.as_ref().map(|value| value.0);
|
|
if let Some((id, position)) = target {
|
|
let snapshot = backend.world_snapshot(cancellation).await?;
|
|
let (x, y) = region_origin(snapshot.region_handle)?;
|
|
live_agent(backend)?
|
|
.auto_pilot_with_double_double_double(
|
|
f64::from(position.x) + f64::from(x),
|
|
f64::from(position.y) + f64::from(y),
|
|
f64::from(position.z),
|
|
)
|
|
.map_err(|_| "Follow autopilot failed")?;
|
|
Ok(format!("Following {id}"))
|
|
} else {
|
|
let _ = live_agent(backend)?.auto_pilot_cancel();
|
|
Ok("Following stopped".into())
|
|
}
|
|
}
|
|
Movement::GoHome => {
|
|
let agent = live_agent(backend)?;
|
|
let success = agent
|
|
.go_home(Some(cancellation))
|
|
.await
|
|
.map_err(|_| "Teleport Home Failed")?;
|
|
if success {
|
|
Ok("Teleport complete".into())
|
|
} else {
|
|
Err("Teleport Home Failed".into())
|
|
}
|
|
}
|
|
Movement::TeleportRegion(name, position) => {
|
|
let agent = live_agent(backend)?;
|
|
let success = agent
|
|
.teleport_with_string_vector3_cancellation_token(name, position, Some(cancellation))
|
|
.await
|
|
.map_err(|_| "Teleport failed")?;
|
|
if success {
|
|
Ok("Teleport complete".into())
|
|
} else {
|
|
Err(format!("Teleport failed: {}", agent.teleport_message()))
|
|
}
|
|
}
|
|
Movement::TeleportLandmark(id) => {
|
|
let agent = live_agent(backend)?;
|
|
if agent
|
|
.teleport_with_uuid_cancellation_token(id, Some(cancellation))
|
|
.await
|
|
.map_err(|_| "Teleport Failed")?
|
|
{
|
|
Ok("Teleport complete".into())
|
|
} else {
|
|
Err("Teleport Failed".into())
|
|
}
|
|
}
|
|
Movement::Jump => {
|
|
live_agent(backend)?
|
|
.jump(true)
|
|
.map_err(|_| "Jump request failed")?;
|
|
Ok("Jump complete".into())
|
|
}
|
|
Movement::AutoPilot { global, .. } => {
|
|
live_agent(backend)?
|
|
.auto_pilot_with_double_double_double(global[0], global[1], global[2])
|
|
.map_err(|_| "Autopilot request failed")?;
|
|
Ok("Autopilot started".into())
|
|
}
|
|
Movement::SetHome => {
|
|
live_agent(backend)?
|
|
.set_home()
|
|
.map_err(|_| "Set home request failed")?;
|
|
Ok("Home set".into())
|
|
}
|
|
Movement::Sit(id) => {
|
|
let sim = backend
|
|
.network
|
|
.current_sim()
|
|
.ok_or("No current simulator available")?;
|
|
let prim = sim
|
|
.objects_primitives
|
|
.read()
|
|
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
|
.values()
|
|
.find(|prim| prim.id == id)
|
|
.cloned()
|
|
.ok_or_else(|| format!("Could not find object {id}"))?;
|
|
let agent = live_agent(backend)?;
|
|
agent
|
|
.request_sit(id, Vector3::zero())
|
|
.map_err(|_| "Sit request failed")?;
|
|
agent.sit().map_err(|_| "Sit request failed")?;
|
|
Ok(format!("Sit requested on {}", prim.local_id))
|
|
}
|
|
Movement::Stand => {
|
|
live_agent(backend)?
|
|
.stand()
|
|
.map_err(|_| "Stand request failed")?;
|
|
Ok("Stand requested".into())
|
|
}
|
|
Movement::Turn(target) => {
|
|
let agent = live_agent(backend)?;
|
|
agent
|
|
.movement
|
|
.turn_toward(target, Some(true))
|
|
.map_err(|_| "Turn request failed")?;
|
|
Ok("Turn complete".into())
|
|
}
|
|
Movement::Cross(direction, fly) => live_cross(backend, direction, fly, cancellation).await,
|
|
}
|
|
}
|
|
|
|
fn live_agent(backend: &LiveBackend) -> Result<libremetaverse::AgentManager, String> {
|
|
let client = lock(&backend.client).clone();
|
|
libremetaverse::AgentManager::new(Some(Arc::new(client)))
|
|
.map_err(|_| "Could not acquire the live agent manager".into())
|
|
}
|
|
|
|
fn set_direction(
|
|
movement: &mut libremetaverse::AgentManagerAgentMovement,
|
|
direction: MoveDirection,
|
|
value: bool,
|
|
) {
|
|
match direction {
|
|
MoveDirection::Back => movement.set_at_neg(value),
|
|
MoveDirection::Forward => movement.set_at_pos(value),
|
|
MoveDirection::Left => movement.set_left_pos(value),
|
|
MoveDirection::Right => movement.set_left_neg(value),
|
|
}
|
|
}
|
|
|
|
async fn live_fly_to(
|
|
backend: &LiveBackend,
|
|
target: Vector3,
|
|
duration: Duration,
|
|
cancellation: CancellationToken,
|
|
) -> Result<String, String> {
|
|
let snapshot = backend.world_snapshot(cancellation.clone()).await?;
|
|
let (x, y) = region_origin(snapshot.region_handle)?;
|
|
let agent = live_agent(backend)?;
|
|
agent
|
|
.fly(true)
|
|
.map_err(|_| "FlyTo could not enable flight")?;
|
|
agent
|
|
.auto_pilot_with_double_double_double(
|
|
f64::from(target.x) + f64::from(x),
|
|
f64::from(target.y) + f64::from(y),
|
|
f64::from(target.z),
|
|
)
|
|
.map_err(|_| "FlyTo autopilot failed")?;
|
|
let deadline = tokio::time::Instant::now() + duration;
|
|
loop {
|
|
if distance(agent.sim_position(), target) <= 2.0 {
|
|
let _ = agent.auto_pilot_cancel();
|
|
return Ok("FlyTo target reached".into());
|
|
}
|
|
tokio::select! {()=tokio::time::sleep(Duration::from_millis(100))=>{if tokio::time::Instant::now()>=deadline{let _=agent.auto_pilot_cancel();return Ok("FlyTo duration elapsed".into());}},()=cancellation.cancelled()=>{let _=agent.auto_pilot_cancel();return Err("FlyTo cancelled".into());}}
|
|
}
|
|
}
|
|
|
|
async fn live_cross(
|
|
backend: &LiveBackend,
|
|
direction: Vector3,
|
|
fly: bool,
|
|
cancellation: CancellationToken,
|
|
) -> Result<String, String> {
|
|
let snapshot = backend.world_snapshot(cancellation.clone()).await?;
|
|
let start = snapshot.region_handle;
|
|
let target_x = if direction.x > 0.0 {
|
|
266.0
|
|
} else if direction.x < 0.0 {
|
|
-10.0
|
|
} else {
|
|
snapshot.position.x
|
|
};
|
|
let target_y = if direction.y > 0.0 {
|
|
266.0
|
|
} else if direction.y < 0.0 {
|
|
-10.0
|
|
} else {
|
|
snapshot.position.y
|
|
};
|
|
let agent = live_agent(backend)?;
|
|
agent
|
|
.fly(fly)
|
|
.map_err(|_| "Could not set crossing movement mode")?;
|
|
let (region_x, region_y) = region_origin(start)?;
|
|
agent
|
|
.auto_pilot_with_double_double_double(
|
|
f64::from(target_x) + f64::from(region_x),
|
|
f64::from(target_y) + f64::from(region_y),
|
|
f64::from(snapshot.position.z),
|
|
)
|
|
.map_err(|_| "Region crossing autopilot failed")?;
|
|
let deadline = tokio::time::Instant::now() + Duration::from_mins(1);
|
|
loop {
|
|
if backend
|
|
.network
|
|
.current_sim()
|
|
.is_some_and(|sim| sim.handle != start)
|
|
{
|
|
let _ = agent.auto_pilot_cancel();
|
|
return Ok("Successfully crossed region border".into());
|
|
}
|
|
tokio::select! {()=tokio::time::sleep(Duration::from_millis(100))=>{if tokio::time::Instant::now()>=deadline{let _=agent.auto_pilot_cancel();return Err("Failed to cross region border: timeout".into());}},()=cancellation.cancelled()=>{let _=agent.auto_pilot_cancel();return Err("Region crossing cancelled".into());}}
|
|
}
|
|
}
|
|
|
|
async fn live_change_permissions(
|
|
backend: &LiveBackend,
|
|
root: UUID,
|
|
permissions: PermissionMask,
|
|
cancellation: CancellationToken,
|
|
) -> Result<String, String> {
|
|
let (objects, inventory, sim) = {
|
|
let client = lock(&backend.client);
|
|
(
|
|
client.objects(),
|
|
client.inventory(),
|
|
client.network().current_sim(),
|
|
)
|
|
};
|
|
let sim = sim.ok_or("No current simulator available")?;
|
|
let linkset = {
|
|
let prims = sim
|
|
.objects_primitives
|
|
.read()
|
|
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
|
let selected = prims
|
|
.values()
|
|
.find(|prim| prim.id == root)
|
|
.cloned()
|
|
.ok_or_else(|| format!("Cannot find requested object {root}"))?;
|
|
let root_prim = if selected.parent_id == 0 {
|
|
selected
|
|
} else {
|
|
prims
|
|
.get(&selected.parent_id)
|
|
.cloned()
|
|
.ok_or("Cannot find root prim for requested object")?
|
|
};
|
|
prims
|
|
.values()
|
|
.filter(|prim| {
|
|
prim.local_id == root_prim.local_id || prim.parent_id == root_prim.local_id
|
|
})
|
|
.take(MAX_EXPORT_PRIMS + 1)
|
|
.cloned()
|
|
.collect::<Vec<_>>()
|
|
};
|
|
if linkset.len() > MAX_EXPORT_PRIMS {
|
|
return Err("Linkset exceeds the 10,000 primitive limit".into());
|
|
}
|
|
let ids = linkset.iter().map(|prim| prim.local_id).collect::<Vec<_>>();
|
|
for mask in [
|
|
PermissionMask::MODIFY,
|
|
PermissionMask::COPY,
|
|
PermissionMask::TRANSFER,
|
|
] {
|
|
cancellation
|
|
.throw_if_cancellation_requested()
|
|
.map_err(|_| "Permission update cancelled")?;
|
|
objects
|
|
.set_permissions(
|
|
sim.clone(),
|
|
ids.clone(),
|
|
libremetaverse::PermissionWho::NEXT_OWNER,
|
|
mask,
|
|
permissions.0 & mask.0 != 0,
|
|
)
|
|
.map_err(|_| "Failed to set linkset permissions")?;
|
|
tokio::select! {()=tokio::time::sleep(Duration::from_millis(250))=>{},()=cancellation.cancelled()=>return Err("Permission update cancelled".into())}
|
|
}
|
|
let mut task_items = 0;
|
|
for prim in &linkset {
|
|
if prim.flags.0 & PrimFlags::INVENTORY_EMPTY.0 != 0 {
|
|
continue;
|
|
}
|
|
let items = inventory
|
|
.get_task_inventory(
|
|
prim.id,
|
|
prim.local_id,
|
|
Some(sim.clone()),
|
|
Some(cancellation.clone()),
|
|
)
|
|
.await
|
|
.map_err(|_| "Task inventory request failed")?;
|
|
for entry in items {
|
|
if let Some(mut item) = entry
|
|
.as_any()
|
|
.downcast_ref::<libremetaverse::InventoryItem>()
|
|
.cloned()
|
|
{
|
|
let mut perms = item.permissions();
|
|
perms.next_owner_mask = permissions;
|
|
item.set_permissions(perms);
|
|
inventory
|
|
.update_task_inventory(prim.local_id, item, Some(sim.clone()), Some(true))
|
|
.map_err(|_| "Task inventory permission update failed")?;
|
|
task_items += 1;
|
|
}
|
|
}
|
|
}
|
|
Ok(format!(
|
|
"Set permissions to {:?} on {} objects and {task_items} inventory items",
|
|
permissions,
|
|
ids.len()
|
|
))
|
|
}
|
|
|
|
async fn next_created(
|
|
receiver: &mut tokio::sync::mpsc::UnboundedReceiver<Primitive>,
|
|
cancellation: &CancellationToken,
|
|
) -> Result<Primitive, String> {
|
|
tokio::select! {value=receiver.recv()=>value.ok_or_else(||"Primitive creation event channel closed".into()),()=tokio::time::sleep(Duration::from_secs(10))=>Err("Rez failed, timed out while creating prim.".into()),()=cancellation.cancelled()=>Err("Import cancelled".into())}
|
|
}
|
|
|
|
fn apply_prim_properties(
|
|
objects: &libremetaverse::ObjectManager,
|
|
sim: &libremetaverse::Simulator,
|
|
created: &Primitive,
|
|
source: &Primitive,
|
|
position: Vector3,
|
|
) -> Result<(), String> {
|
|
objects
|
|
.set_position_with_simulator_u_int32_vector3(sim.clone(), created.local_id, position)
|
|
.map_err(|_| "Setting imported primitive position failed")?;
|
|
if let Some(textures) = &source.textures {
|
|
objects
|
|
.set_textures_with_simulator_u_int32_texture_entry(
|
|
sim.clone(),
|
|
created.local_id,
|
|
textures.clone(),
|
|
)
|
|
.map_err(|_| "Setting imported primitive textures failed")?;
|
|
}
|
|
if let Some(light) = &source.light {
|
|
objects
|
|
.set_light(sim.clone(), created.local_id, light.clone())
|
|
.map_err(|_| "Setting imported primitive light failed")?;
|
|
}
|
|
if let Some(flexible) = &source.flexible {
|
|
objects
|
|
.set_flexible(sim.clone(), created.local_id, flexible.clone())
|
|
.map_err(|_| "Setting imported primitive flexibility failed")?;
|
|
}
|
|
if let Some(sculpt) = &source.sculpt {
|
|
objects
|
|
.set_sculpt(sim.clone(), created.local_id, sculpt.clone())
|
|
.map_err(|_| "Setting imported primitive sculpt failed")?;
|
|
}
|
|
if let Some(properties) = &source.properties {
|
|
if !properties.name.is_empty() {
|
|
objects
|
|
.set_name(sim.clone(), created.local_id, properties.name.clone())
|
|
.map_err(|_| "Setting imported primitive name failed")?;
|
|
}
|
|
if !properties.description.is_empty() {
|
|
objects
|
|
.set_description(
|
|
sim.clone(),
|
|
created.local_id,
|
|
properties.description.clone(),
|
|
)
|
|
.map_err(|_| "Setting imported primitive description failed")?;
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
async fn live_import(
|
|
backend: &LiveBackend,
|
|
primitives: Vec<Primitive>,
|
|
use_group: bool,
|
|
cancellation: CancellationToken,
|
|
) -> Result<String, String> {
|
|
let (objects, sim, agent_position, group) = {
|
|
let mut client = lock(&backend.client);
|
|
let objects = client.objects();
|
|
let sim = client.network().current_sim();
|
|
let agent = client.self_();
|
|
(
|
|
objects,
|
|
sim,
|
|
agent.sim_position(),
|
|
if use_group {
|
|
agent.active_group()
|
|
} else {
|
|
UUID::zero()
|
|
},
|
|
)
|
|
};
|
|
let sim = sim.ok_or("No current simulator available")?;
|
|
let (sender, mut receiver) = tokio::sync::mpsc::unbounded_channel();
|
|
let subscription = objects.subscribe_object_update(Arc::new(move |event| {
|
|
if event.is_new() && event.prim().flags.0 & PrimFlags::CREATE_SELECTED.0 != 0 {
|
|
let _ = sender.send(event.prim());
|
|
}
|
|
}));
|
|
let mut roots: Vec<_> = primitives
|
|
.iter()
|
|
.filter(|prim| prim.parent_id == 0)
|
|
.collect();
|
|
roots.sort_by_key(|prim| prim.local_id);
|
|
if roots.is_empty() {
|
|
return Err("Import contains no root primitives".into());
|
|
}
|
|
let mut created_count = 0;
|
|
for root in roots {
|
|
let base = Vector3::new_with_single_single_single(
|
|
agent_position.x,
|
|
agent_position.y,
|
|
agent_position.z + 3.0,
|
|
)
|
|
.map_err(|_| "Invalid import position")?;
|
|
objects
|
|
.add_prim_with_simulator_construction_data_uuid_vector3_vector3_quaternion(
|
|
sim.clone(),
|
|
root.prim_data.clone(),
|
|
group,
|
|
base,
|
|
root.scale,
|
|
Quaternion::identity(),
|
|
)
|
|
.map_err(|_| "Rez request failed for root primitive")?;
|
|
let created_root = next_created(&mut receiver, &cancellation).await?;
|
|
apply_prim_properties(&objects, &sim, &created_root, root, base)?;
|
|
created_count += 1;
|
|
let mut ids = vec![created_root.local_id];
|
|
let mut children: Vec<_> = primitives
|
|
.iter()
|
|
.filter(|prim| prim.parent_id == root.local_id)
|
|
.collect();
|
|
children.sort_by_key(|prim| prim.local_id);
|
|
for child in children {
|
|
let position = Vector3::new_with_single_single_single(
|
|
base.x + child.position.x,
|
|
base.y + child.position.y,
|
|
base.z + child.position.z,
|
|
)
|
|
.map_err(|_| "Invalid child import position")?;
|
|
objects
|
|
.add_prim_with_simulator_construction_data_uuid_vector3_vector3_quaternion(
|
|
sim.clone(),
|
|
child.prim_data.clone(),
|
|
group,
|
|
position,
|
|
child.scale,
|
|
child.rotation,
|
|
)
|
|
.map_err(|_| "Rez request failed for child primitive")?;
|
|
let created = next_created(&mut receiver, &cancellation).await?;
|
|
apply_prim_properties(&objects, &sim, &created, child, position)?;
|
|
ids.push(created.local_id);
|
|
created_count += 1;
|
|
}
|
|
if ids.len() > 1 {
|
|
objects
|
|
.link_prims(sim.clone(), ids.clone())
|
|
.map_err(|_| "Linking imported primitives failed")?;
|
|
}
|
|
objects
|
|
.set_rotation_with_simulator_u_int32_quaternion(
|
|
sim.clone(),
|
|
created_root.local_id,
|
|
root.rotation,
|
|
)
|
|
.map_err(|_| "Setting imported root rotation failed")?;
|
|
objects
|
|
.set_permissions(
|
|
sim.clone(),
|
|
ids,
|
|
libremetaverse::PermissionWho::ALL,
|
|
PermissionMask::ALL,
|
|
true,
|
|
)
|
|
.map_err(|_| "Setting imported permissions failed")?;
|
|
}
|
|
drop(subscription);
|
|
Ok(format!("Imported {created_count} primitives"))
|
|
}
|
|
|
|
async fn live_upload_terrain(
|
|
backend: &LiveBackend,
|
|
name: String,
|
|
data: Vec<u8>,
|
|
cancellation: CancellationToken,
|
|
) -> Result<String, String> {
|
|
let (estate, assets) = {
|
|
let client = lock(&backend.client);
|
|
(client.estate(), client.assets())
|
|
};
|
|
estate.enable_live_mutations();
|
|
let (sender, receiver) = tokio::sync::oneshot::channel();
|
|
let sender = Arc::new(Mutex::new(Some(sender)));
|
|
let callback = Arc::clone(&sender);
|
|
let subscription = assets.subscribe_upload_progress(Arc::new(move |event| {
|
|
let upload = event.upload();
|
|
if upload.base.transferred == upload.base.size
|
|
&& let Some(sender) = lock(&callback).take()
|
|
{
|
|
let _ = sender.send(upload.base.success);
|
|
}
|
|
}));
|
|
estate
|
|
.upload_terrain(data, name)
|
|
.map_err(|_| "Terrain upload request failed")?;
|
|
let success = tokio::select! {value=receiver=>value.map_err(|_|"Terrain upload channel closed")?,()=tokio::time::sleep(Duration::from_mins(2))=>return Err("Timeout waiting for terrain file upload".into()),()=cancellation.cancelled()=>return Err("Terrain upload cancelled".into())};
|
|
drop(subscription);
|
|
if success {
|
|
Ok("Terrain upload completed".into())
|
|
} else {
|
|
Err("Terrain upload failed".into())
|
|
}
|
|
}
|