From 374d7958f141b5ee52811165930ca1ed9899970a Mon Sep 17 00:00:00 2001 From: Chili Palmer Date: Tue, 11 Aug 2026 12:10:19 +0000 Subject: [PATCH] Implement TestClient world commands (#93) --- Cargo.lock | 2 + README.md | 11 + docs/test-client.md | 64 + programs/Cargo.toml | 2 + programs/README.md | 21 +- programs/src/test_client.rs | 92 +- programs/src/test_client/inventory.rs | 15 +- programs/src/test_client/world.rs | 3661 +++++++++++++++++++++++ programs/tests/test_client_world_cli.rs | 327 ++ 9 files changed, 4182 insertions(+), 13 deletions(-) create mode 100644 docs/test-client.md create mode 100644 programs/src/test_client/world.rs create mode 100644 programs/tests/test_client_world_cli.rs diff --git a/Cargo.lock b/Cargo.lock index b102888..297480a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1055,6 +1055,8 @@ dependencies = [ "libremetaverse", "libremetaverse-imaging", "libremetaverse-imaging-skia", + "libremetaverse-structured-data", + "regex", "tokio", ] diff --git a/README.md b/README.md index 1ba8ad7..2412970 100644 --- a/README.md +++ b/README.md @@ -500,3 +500,14 @@ manager/store and AIS parser for bounded browsing, statistics, typed search, link-aware hierarchy traversal, and deterministic export. Their command surfaces, limits, isolated CLI tests, and the status of every remaining program are documented in the [`native programs guide`](programs/README.md). + +The native `TestClient` now exposes its system, communication, inventory, +appearance, asset, movement, object, parcel, estate, and grid command groups +through one bounded multi-client shell. World commands use the native manager +APIs for movement and teleportation, object inspection and linkset transfer, +parcel and map queries, estate terrain transfer, texture caching, and primitive +creation. Offline fake-world directives exercise the same parsing, filtering, +output, cancellation, and safety paths without connecting to a privileged live +grid. Command ownership, resource limits, explicit live-operation gates, and +the focused verification command are documented in the +[`TestClient` guide](docs/test-client.md). diff --git a/docs/test-client.md b/docs/test-client.md new file mode 100644 index 0000000..18c255b --- /dev/null +++ b/docs/test-client.md @@ -0,0 +1,64 @@ +# Native TestClient command shell + +The `test-client` binary is a native Rust multi-client shell. It does not load +the former C# executable or start a CLR process. The command registry owns the +implemented system, communication, inventory, appearance, asset, movement, +object, parcel, estate, and grid commands, and reports the remaining upstream +targets through `pending_test_client_commands()`. + +## World command behavior + +Movement commands use `AgentManager` movement updates, autopilot, teleport, +sit, stand, flight, and home APIs. Timed movement is limited to 60 seconds and +checks cancellation while sending updates. `follow` retains the selected +avatar and refreshes autopilot whenever a coarse-location update moves it; +`follow off` cancels autopilot. Region crossing is bounded to 60 seconds. + +Object queries snapshot the current simulator caches before filtering. +Searches and parcel/map results are capped at 65,535 entries. Linkset export +requires ownership, sorts serialized LLSD deterministically, limits a linkset +to 10,000 primitives, and downloads each unique texture as both JPEG 2000 and +TGA. Import accepts only a bounded LLSD array, waits for each native rez event, +applies primitive properties, links children, and restores root rotation and +permissions. Individual asset and terrain files are limited to 64 MiB. Regular +expressions are limited in source and compiled size. + +`textures on` downloads the textures already visible in the current simulator +and subscribes newly observed primitives for deduplicated native asset-cache +requests. `--gettextures` enables that subscription at login. Parcel, grid, +wind, owner, selection, covenant, and terrain commands use their corresponding +native managers and propagate cancellation through request/reply waits. + +## Live-operation gates + +Read-only queries need no privilege flag. Every movement, teleport, derez, +permission, import, tree, and terrain mutation requires both +`--allow-live-mutations` at process startup and `--confirm` on the command. +Import and terrain upload additionally require `--allow-spending`; estate +terrain download/upload additionally require `--allow-estate-actions`. +Consequently the default invocation cannot move an avatar, alter or create an +object, upload an asset, or invoke an estate-owner action. + +Output paths reject parent-directory traversal. Timeouts, cancellation, file +limits, result limits, and linkset limits apply equally to live and fake-grid +runs. Passwords and login secrets continue to pass through the shell's common +redaction path. + +## Offline verification + +Fake scripts can seed the same command layer with `!world-region`, +`!world-avatar`, `!world-prim`, `!world-parcel`, `!world-parcel-owner`, +`!world-parcel-object`, `!world-grid-region`, `!world-layer`, +`!world-agent-location`, `!world-estate`, `!world-asset`, and `!world-syntax` +tab-separated directives. These fixtures never open a network connection. + +Run the issue-focused compatibility gate with: + +```text +cargo test -p libremetaverse-programs --test test_client_world_cli +``` + +The gate drives every command owned by the movement/object/land/grid issue, +checks the exact fake backend calls, verifies destructive-operation guards, +round-trips an exported linkset through import, compares two exports byte for +byte, and validates generated terrain, texture, and TGA files. diff --git a/programs/Cargo.toml b/programs/Cargo.toml index 2901afd..b99c100 100644 --- a/programs/Cargo.toml +++ b/programs/Cargo.toml @@ -11,6 +11,8 @@ clap = { version = "4.5", features = ["derive"] } libremetaverse = { path = "../crates/libremetaverse", default-features = false } libremetaverse-imaging = { path = "../crates/libremetaverse-imaging", features = ["jpeg2000"] } libremetaverse-imaging-skia = { path = "../crates/libremetaverse-imaging-skia", features = ["skia"] } +libremetaverse-structured-data = { path = "../crates/libremetaverse-structured-data" } +regex = "1.12" tokio = { version = "1.47", features = ["io-std", "io-util", "macros", "net", "rt-multi-thread", "signal", "sync", "time"] } [lints] diff --git a/programs/README.md b/programs/README.md index 2f1442e..69b5a95 100644 --- a/programs/README.md +++ b/programs/README.md @@ -13,7 +13,7 @@ here so a source entry is never mistaken for a completed port. | `prim-inspector` | PrimInspector | Implemented with live and deterministic fake-grid discovery | | `inventory-explorer` | InventoryExplorer | Implemented with live inventory and deterministic AIS fixtures | | `irc-gateway` | IRCGateway | Implemented with live IRC/grid transports and deterministic offline scripts | -| `test-client` | TestClient | Native shell, registry, system, communication, inventory, appearance, and asset groups implemented; remaining command groups tracked by #93–#94 | +| `test-client` | TestClient | Native shell, registry, system, communication, inventory, appearance, asset, movement, object, parcel, estate, and grid groups implemented; remaining command group tracked by #94 | | `vivox-test` | VivoxTest | Pending milestone 11 issue #95 | | `webrtc-test` | WebRtcTest | Pending milestone 11 issue #96 | @@ -157,8 +157,8 @@ cargo test --manifest-path tests/compat/Cargo.toml --test social_message_semanti one account with `--first`, `--last`, and `--pass`, or a bounded account file with `--file`. Account-file records use `First Last Password` followed by an optional `Region/x/y/z` start location. `--loginuri`, `--startpos`, `--master`, -`--masterkey`, `--groupcommands`, `--scriptfile`, and `--nogui` preserve the -upstream shell controls. Each login has a configurable timeout, the registry is +`--masterkey`, `--groupcommands`, `--gettextures`, `--scriptfile`, and `--nogui` +preserve the upstream shell controls. Each login has a configurable timeout, the registry is limited to 64 clients, and `@ First Last` selects one client while `@` restores broadcast command routing. @@ -176,6 +176,14 @@ master teleport lures are accepted, and remote `login` and `md5` command text is redacted from transcripts. Packet logs contain only timestamped packet type, simulator name, and byte count; they are limited to 10,000 records and 16 MiB. +The world wave adds all movement and teleport commands, primitive search, +inspection, permissions, derez, deterministic linkset export/import, texture +download, particle export, tree creation, parcel and estate queries, terrain +transfer, and grid map/layer/location/wind commands. `textures on` and +`--gettextures` perform deduplicated native asset-cache requests for observed +primitive textures. Detailed command behavior and limits are in +[`docs/test-client.md`](../docs/test-client.md). + The native `load` command reads a portable command-alias manifest instead of a CLR assembly. Each non-comment line is tab-separated `namedescriptiontemplate`; `{args}` or `$*` inserts the quoted command @@ -192,6 +200,12 @@ JPEG2000 texture format. Live commands use the public inventory, appearance, asset, agent, directory, and task-inventory managers; Xfer downloads wait for their correlated UDP completion event. +World mutations use the same global switch and confirmation requirement. +Imports and terrain uploads also require `--allow-spending`; terrain owner +messages also require `--allow-estate-actions`. Movement durations, reply +waits, files, query results, regexes, and exported/imported linksets are +bounded, and all waits propagate cancellation. + Use `--fake-script FILE` for deterministic, credential-free terminal and grid validation. Normal lines are dispatched exactly like interactive input. Fake grid records begin with `!` and use tab-separated fields: @@ -228,6 +242,7 @@ framework and compatibility checks with: ```sh cargo test -p libremetaverse-programs --test test_client_cli --locked cargo test -p libremetaverse-programs --test test_client_inventory_cli --locked +cargo test -p libremetaverse-programs --test test_client_world_cli --locked cargo test -p libremetaverse-programs test_client::tests --locked cargo test --manifest-path tests/compat/Cargo.toml --test appearance_semantics --locked cargo test --manifest-path tests/compat/Cargo.toml --test appearance_visual --locked diff --git a/programs/src/test_client.rs b/programs/src/test_client.rs index ce5b4e9..b0c2036 100644 --- a/programs/src/test_client.rs +++ b/programs/src/test_client.rs @@ -1,6 +1,7 @@ //! Native `TestClient` shell, registry, and first-wave command groups. mod inventory; +mod world; use crate::commands::TEST_CLIENT_COMMANDS; use clap::Parser; @@ -68,6 +69,52 @@ pub const IMPLEMENTED_TEST_CLIENT_COMMANDS: &[&str] = &[ "UploadScriptCommand", "ViewNotecardCommand", "XferCommand", + "DownloadTerrainCommand", + "GetEstateCovenantCommand", + "UploadRawTerrainCommand", + "AgentLocationsCommand", + "FindSimCommand", + "GridLayerCommand", + "GridMapCommand", + "ParcelDetailsCommand", + "ParcelInfoCommand", + "ParcelPrimOwnersCommand", + "ParcelSelectObjectsCommand", + "SyntaxIdCommand", + "WindCommand", + "BackCommand", + "CrossRegionCommand", + "CrouchCommand", + "FlyCommand", + "FlyToCommand", + "FollowCommand", + "ForwardCommand", + "GoHome", + "GotoCommand", + "GotoLandmark", + "JumpCommand", + "LeftCommand", + "LocationCommand", + "MoveToCommand", + "RightCommand", + "SetHome", + "SitCommand", + "SitOnCommand", + "StandCommand", + "TurnToCommand", + "ChangePermsCommand", + "DeRezObjectCommand", + "DownloadTextureCommand", + "ExportCommand", + "ExportParticlesCommand", + "FindObjectsCommand", + "FindTextureCommand", + "ImportCommand", + "PrimCountCommand", + "PrimInfoCommand", + "PrimRegexCommand", + "TexturesCommand", + "TreeCommand", "EchoMasterCommand", "IMCommand", "IMGroupCommand", @@ -106,7 +153,7 @@ pub fn pending_test_client_commands() -> Vec<&'static str> { version, about = "Run the native LibreMetaverse multi-client command shell", long_about = None, - after_help = "Owned commands include @, debug, echomaster, help, im, imgroup, load, login, logpacket, logout, md5, quit, say, setmaster, setmasterkey, shout, showeffects, sleep, waitforlogin, whisper, plus the inventory/assets and appearance groups. Run `help` or `help COMMAND` inside the shell for the complete command registry." + after_help = "Owned commands include @, debug, echomaster, help, im, imgroup, load, login, logpacket, logout, md5, quit, say, setmaster, setmasterkey, shout, showeffects, sleep, waitforlogin, whisper, plus the inventory, appearance, asset, movement, object, parcel, estate, and grid groups. Run `help` or `help COMMAND` inside the shell for the complete command registry." )] #[allow(clippy::struct_excessive_bools)] struct Cli { @@ -137,7 +184,7 @@ struct Cli { /// Permit commands from known members of joined group-chat sessions. #[arg(long)] groupcommands: bool, - /// Preserve the upstream flag for later texture-aware command groups. + /// Automatically fetch newly observed object textures into the asset cache. #[arg(long)] gettextures: bool, /// Run bounded terminal commands from a file before the interactive loop. @@ -160,6 +207,10 @@ struct Cli { /// Permit commands that spend or transfer L$; each command also requires confirmation. #[arg(long)] allow_spending: bool, + + /// Permit estate-owner commands; mutations still require their other gates. + #[arg(long)] + allow_estate_actions: bool, } #[derive(Debug)] @@ -245,7 +296,7 @@ enum ClientEvent { type BackendFuture<'a, T> = Pin + Send + 'a>>; type InventoryFuture<'a, T> = Pin + 'a>>; -trait ClientBackend: Send + Sync + inventory::Backend { +trait ClientBackend: Send + Sync + inventory::Backend + world::Backend { fn id(&self) -> UUID; fn name(&self) -> String; fn connected(&self) -> bool; @@ -280,6 +331,7 @@ struct LiveBackend { name: String, subscriptions: Mutex>, inventory_state: Mutex, + world_state: Mutex, closed: AtomicBool, } @@ -290,6 +342,7 @@ impl LiveBackend { events: mpsc::Sender, dropped: Arc, account_policy: inventory::MutationPolicy, + get_textures: bool, cancellation: CancellationToken, ) -> Result, ProgramError> { let mut client = GridClient::new() @@ -339,6 +392,7 @@ impl LiveBackend { name, subscriptions: Mutex::new(Vec::new()), inventory_state: Mutex::new(inventory::State::new(account_policy)), + world_state: Mutex::new(world::State::new(account_policy, get_textures)), closed: AtomicBool::new(false), }); backend.install(&events, &dropped); @@ -457,6 +511,7 @@ impl LiveBackend { ); }, ))); + world::install_live(self, &mut subscriptions); *lock(&self.subscriptions) = subscriptions; } } @@ -624,6 +679,7 @@ struct FakeBackend { people: Arc>>, group_members: Arc>>, inventory_state: Mutex, + world_state: Mutex, } impl FakeBackend { @@ -633,6 +689,7 @@ impl FakeBackend { people: Arc>>, group_members: Arc>>, policy: inventory::MutationPolicy, + get_textures: bool, ) -> Self { Self { id, @@ -642,6 +699,7 @@ impl FakeBackend { people, group_members, inventory_state: Mutex::new(inventory::State::new(policy)), + world_state: Mutex::new(world::State::new(policy, get_textures)), } } @@ -763,6 +821,10 @@ enum CommandCategory { Appearance, Communication, Inventory, + Movement, + Objects, + Parcel, + Simulator, TestClient, Other, } @@ -776,6 +838,7 @@ enum CommandHandler { Im, ImGroup, Inventory(inventory::Command), + World(world::Command), Load, Login, LogPacket, @@ -936,6 +999,7 @@ fn built_in_commands() -> HashMap { .into_iter() .chain(communication_commands()) .chain(inventory::commands()) + .chain(world::commands()) .map(|(name, description, category, handler)| { ( name.into(), @@ -1081,6 +1145,7 @@ struct ClientManager { default_master_key: UUID, default_group_commands: bool, default_mutation_policy: inventory::MutationPolicy, + default_get_textures: bool, cancellation: CancellationTokenSource, output: Arc, events_tx: mpsc::Sender, @@ -1103,6 +1168,7 @@ impl ClientManager { master_key: UUID, group_commands: bool, mutation_policy: inventory::MutationPolicy, + get_textures: bool, output: Arc, ) -> Self { let (events_tx, events_rx) = mpsc::channel(EVENT_QUEUE_CAPACITY); @@ -1119,6 +1185,7 @@ impl ClientManager { default_master_key: master_key, default_group_commands: group_commands, default_mutation_policy: mutation_policy, + default_get_textures: get_textures, cancellation: CancellationTokenSource::new(), output, events_tx, @@ -1179,6 +1246,7 @@ impl ClientManager { Arc::clone(&self.fake_people), Arc::clone(&self.fake_group_members), self.default_mutation_policy, + self.default_get_textures, )); self.add_client(ManagedClient { backend, @@ -1206,6 +1274,7 @@ impl ClientManager { self.events_tx.clone(), Arc::clone(&self.dropped_events), self.default_mutation_policy, + self.default_get_textures, self.cancellation.token(), ) .await @@ -1220,6 +1289,7 @@ impl ClientManager { Arc::clone(&self.fake_people), Arc::clone(&self.fake_group_members), self.default_mutation_policy, + self.default_get_textures, ))), Err(_) => Err(ProgramError::Client( "could not make fake client UUID".into(), @@ -1710,6 +1780,9 @@ async fn execute_client_command( CommandHandler::Inventory(command) => { inventory::execute(client.backend.as_ref(), *command, args, from, cancellation).await } + CommandHandler::World(command) => { + world::execute(client.backend.as_ref(), *command, args, cancellation).await + } CommandHandler::Md5 => { if args.len() != 1 { return "Usage: md5 [password]".into(); @@ -1955,8 +2028,11 @@ pub fn main_entry() -> ExitCode { } async fn run(cli: Cli) -> Result<(), ProgramError> { - let mutation_policy = - inventory::MutationPolicy::new(cli.allow_live_mutations, cli.allow_spending); + let mutation_policy = inventory::MutationPolicy::new( + cli.allow_live_mutations, + cli.allow_spending, + cli.allow_estate_actions, + ); let master_key = match cli.masterkey.as_deref() { Some(value) => UUID::new_with_string(value.into()) .map_err(|_| ProgramError::Usage("--masterkey must be a UUID"))?, @@ -1978,6 +2054,7 @@ async fn run(cli: Cli) -> Result<(), ProgramError> { master_key, cli.groupcommands, mutation_policy, + cli.gettextures, Arc::clone(&output), ); run_fake_script(&mut manager, path).await?; @@ -2011,6 +2088,7 @@ async fn run(cli: Cli) -> Result<(), ProgramError> { master_key, cli.groupcommands, mutation_policy, + cli.gettextures, Arc::clone(&output), ); for account in accounts { @@ -2285,6 +2363,10 @@ async fn run_fake_event_directive( result.map_err(|reason| ProgramError::InvalidInput { line, reason })?; return Ok(()); } + if let Some(result) = world::apply_fake_directive(manager, fields) { + result.map_err(|reason| ProgramError::InvalidInput { line, reason })?; + return Ok(()); + } match fields { ["chat", client, source, name, message] => { let event = ClientEvent::Chat { diff --git a/programs/src/test_client/inventory.rs b/programs/src/test_client/inventory.rs index abf3731..2081769 100644 --- a/programs/src/test_client/inventory.rs +++ b/programs/src/test_client/inventory.rs @@ -222,18 +222,23 @@ pub(super) struct MutationPolicy(u8); impl MutationPolicy { const MUTATIONS: u8 = 1; const SPENDING: u8 = 1 << 1; + const ESTATE: u8 = 1 << 2; - pub(super) const fn new(mutations: bool, spending: bool) -> Self { - Self((mutations as u8) | ((spending as u8) << 1)) + pub(super) const fn new(mutations: bool, spending: bool, estate: bool) -> Self { + Self((mutations as u8) | ((spending as u8) << 1) | ((estate as u8) << 2)) } - const fn mutations(self) -> bool { + pub(super) const fn mutations(self) -> bool { self.0 & Self::MUTATIONS != 0 } - const fn spending(self) -> bool { + pub(super) const fn spending(self) -> bool { self.0 & Self::SPENDING != 0 } + + pub(super) const fn estate(self) -> bool { + self.0 & Self::ESTATE != 0 + } } pub(super) struct State { @@ -1662,7 +1667,7 @@ fn decode_tga(bytes: &[u8]) -> Result { Ok(image) } -fn encode_tga(image: &ManagedImage) -> Result, String> { +pub(super) fn encode_tga(image: &ManagedImage) -> Result, String> { image .validate() .map_err(|_| "Invalid decoded image layout")?; diff --git a/programs/src/test_client/world.rs b/programs/src/test_client/world.rs new file mode 100644 index 0000000..9329d91 --- /dev/null +++ b/programs/src/test_client/world.rs @@ -0,0 +1,3661 @@ +//! 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 { + 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, + selected: HashMap>, +} + +#[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, +} + +#[derive(Clone, Debug, Default)] +struct Snapshot { + region_name: String, + region_handle: u64, + position: Vector3, + wind: Option>, + primitives: Vec, + avatars: Vec, + parcels: Vec, + regions: Vec, + layers: Vec, + locations: HashMap>, + estate: Option, + assets: HashMap>, + syntax: Vec, +} + +pub(super) struct State { + policy: super::inventory::MutationPolicy, + fake: Snapshot, + textures_enabled: bool, + requested_textures: HashSet, + follow_target: Option, +} + +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, + use_group: bool, + }, + SetTextures(bool), + Tree(Tree), + UploadTerrain { + name: String, + data: Vec, + }, +} + +#[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; + fn world_agent_id(&self) -> UUID; + fn world_snapshot<'a>( + &'a self, + cancellation: CancellationToken, + ) -> BackendFuture<'a, Result>; + fn world_query<'a>( + &'a self, + query: Query, + cancellation: CancellationToken, + ) -> BackendFuture<'a, Result>; + fn world_mutate<'a>( + &'a self, + mutation: Mutation, + cancellation: CancellationToken, + ) -> BackendFuture<'a, Result>; + fn world_texture<'a>( + &'a self, + id: UUID, + discard: i32, + cancellation: CancellationToken, + ) -> BackendFuture<'a, Result, String>>; + fn world_resolve_avatar<'a>( + &'a self, + name: &'a str, + cancellation: CancellationToken, + ) -> BackendFuture<'a, Result, String>>; + fn apply_world_fixture(&self, _fields: &[&str]) -> Result<(), &'static str> { + Err("world fixture requires fake-grid mode") + } +} + +pub(super) async fn execute( + 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( + backend: &B, + args: &[String], + spending: bool, + estate: bool, +) -> Result, 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::new_with_string(value.into()).map_err(|_| format!("{value} is not a valid UUID")) +} + +fn parse_vector(args: &[String], usage: &str) -> Result { + if args.len() != 3 { + return Err(usage.into()); + } + let values = args + .iter() + .map(|value| value.parse::()) + .collect::, _>>() + .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 { + 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, 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( + backend: &B, + args: &[String], + direction: MoveDirection, + cancellation: CancellationToken, +) -> Result { + 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::()) + .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( + backend: &B, + args: &[String], + crouch: bool, + cancellation: CancellationToken, +) -> Result { + 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( + backend: &B, + args: &[String], + movement: Movement, + output: &str, + cancellation: CancellationToken, +) -> Result { + 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( + backend: &B, + args: &[String], + cancellation: CancellationToken, +) -> Result { + 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::()) + .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( + backend: &B, + args: &[String], + cancellation: CancellationToken, +) -> Result { + 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( + backend: &B, + args: &[String], + cancellation: CancellationToken, +) -> Result { + 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( + backend: &B, + args: &[String], + cancellation: CancellationToken, +) -> Result { + 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::>(), + "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( + backend: &B, + args: &[String], + cancellation: CancellationToken, +) -> Result { + 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( + backend: &B, + args: &[String], + cancellation: CancellationToken, +) -> Result { + 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( + backend: &B, + args: &[String], + cancellation: CancellationToken, +) -> Result { + 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( + backend: &B, + args: &[String], + explicit: Option<()>, + cancellation: CancellationToken, +) -> Result { + 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( + backend: &B, + args: &[String], + cancellation: CancellationToken, +) -> Result { + 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( + backend: &B, + args: &[String], + cancellation: CancellationToken, +) -> Result { + 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( + backend: &B, + args: &[String], + cancellation: CancellationToken, +) -> Result { + 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::()) + .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( + backend: &B, + args: &[String], + cancellation: CancellationToken, +) -> Result { + 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( + backend: &B, + args: &[String], + cancellation: CancellationToken, +) -> Result { + 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( + backend: &B, + args: &[String], + cancellation: CancellationToken, +) -> Result { + 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( + backend: &B, + args: &[String], + cancellation: CancellationToken, +) -> Result { + 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( + backend: &B, + args: &[String], + cancellation: CancellationToken, +) -> Result { + if args.len() != 1 { + return Err("Usage: parceldetails parcelID (use parcelinfo to get ID)".into()); + } + let id = args[0] + .parse::() + .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( + backend: &B, + args: &[String], + cancellation: CancellationToken, +) -> Result { + if args.len() != 1 { + return Err("Usage: primowners parcelID (use parcelinfo to get ID)".into()); + } + let id = args[0] + .parse::() + .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( + backend: &B, + args: &[String], + cancellation: CancellationToken, +) -> Result { + 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::().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::>() + .join(" "); + if !output.is_empty() { + output.push(' '); + } + output.push_str(&format!("Found a total of {} Objects", ids.len())); + Ok(output) +} + +async fn syntax_id( + backend: &B, + args: &[String], + cancellation: CancellationToken, +) -> Result { + 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( + backend: &B, + args: &[String], + cancellation: CancellationToken, +) -> Result { + if args.len() > 1 { + return Err("Usage: getestatecovenant [timeout]".into()); + } + let seconds = args + .first() + .map_or(Ok(20), |v| v.parse::()) + .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( + backend: &B, + args: &[String], + cancellation: CancellationToken, +) -> Result { + 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::()) + .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( + backend: &B, + args: &[String], + cancellation: CancellationToken, +) -> Result { + 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( + backend: &B, + args: &[String], + cancellation: CancellationToken, +) -> Result { + 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( + backend: &B, + args: &[String], + cancellation: CancellationToken, +) -> Result { + 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( + backend: &B, + args: &[String], + cancellation: CancellationToken, +) -> Result { + 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::()) + .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::J2kCodec::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 { + 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, 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( + backend: &B, + args: &[String], + cancellation: CancellationToken, +) -> Result { + 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::, _>>() + .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::J2kCodec::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 { + 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( + backend: &B, + args: &[String], + cancellation: CancellationToken, +) -> Result { + 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( + backend: &B, + args: &[String], + cancellation: CancellationToken, +) -> Result { + if args.is_empty() || args.len() > 2 { + return Err("Usage: findobjects [radius] ".into()); + } + let radius = args[0] + .parse::() + .map_err(|_| "Usage: findobjects [radius] ")?; + 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( + backend: &B, + args: &[String], + cancellation: CancellationToken, +) -> Result { + if args.len() != 2 { + return Err("Usage: findtexture [face-index] [texture-uuid]".into()); + } + let face = args[0] + .parse::() + .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( + backend: &B, + args: &[String], + cancellation: CancellationToken, +) -> Result { + 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::, _>>() + .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( + backend: &B, + args: &[String], + cancellation: CancellationToken, +) -> Result { + 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( + backend: &B, + args: &[String], + cancellation: CancellationToken, +) -> Result { + 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( + backend: &B, + args: &[String], + cancellation: CancellationToken, +) -> Result { + 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( + backend: &B, + args: &[String], + cancellation: CancellationToken, +) -> Result { + 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 { + 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( + backend: &B, + args: &[String], + cancellation: CancellationToken, +) -> Result { + 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 { + &self.world_state + } + fn world_agent_id(&self) -> UUID { + self.id + } + fn world_snapshot<'a>( + &'a self, + cancellation: CancellationToken, + ) -> BackendFuture<'a, Result> { + 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> { + 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> { + 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::>(); + 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, 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, 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 { + 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> { + 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::new_with_string(value.into()).map_err(|_| "world fixture UUID is invalid") +} +fn fixture_number(value: &str) -> Result { + value.parse().map_err(|_| "world fixture number is invalid") +} +fn fixture_vector(x: &str, y: &str, z: &str) -> Result { + 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, &'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 { + 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::>()? + }; + 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, subscriptions: &mut Vec) { + 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::>() + }; + 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 { + &self.world_state + } + fn world_agent_id(&self) -> UUID { + self.id + } + fn world_snapshot<'a>( + &'a self, + cancellation: CancellationToken, + ) -> BackendFuture<'a, Result> { + 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::>(); + 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::>(); + 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> { + 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> { + 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, 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, String>> { + Box::pin(async move { + super::ClientBackend::resolve_avatar(self, name, cancellation) + .await + .map_err(str::to_owned) + }) + } +} + +fn native_syntax() -> Vec { + [ + "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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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::>() + }; + 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::>(); + 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::() + .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, + cancellation: &CancellationToken, +) -> Result { + 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, + use_group: bool, + cancellation: CancellationToken, +) -> Result { + 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, + cancellation: CancellationToken, +) -> Result { + 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()) + } +} diff --git a/programs/tests/test_client_world_cli.rs b/programs/tests/test_client_world_cli.rs new file mode 100644 index 0000000..1a200cf --- /dev/null +++ b/programs/tests/test_client_world_cli.rs @@ -0,0 +1,327 @@ +use libremetaverse_imaging::{J2kCodec, J2kEncodeOptions, ManagedImage, ManagedImageImageChannels}; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output, Stdio}; +use std::sync::atomic::{AtomicU64, Ordering}; + +const CLIENT: &str = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"; +const TARGET: &str = "11111111-2222-3333-4444-555555555555"; +const ROOT: &str = "90000000-0000-0000-0000-000000000001"; +const CHILD: &str = "90000000-0000-0000-0000-000000000002"; +const REMOVE: &str = "90000000-0000-0000-0000-000000000003"; +const TEXTURE: &str = "80000000-0000-0000-0000-000000000001"; +const COVENANT: &str = "70000000-0000-0000-0000-000000000001"; +const LAYER: &str = "60000000-0000-0000-0000-000000000001"; +const LANDMARK: &str = "50000000-0000-0000-0000-000000000001"; +const HANDLE: &str = "4294967298000"; + +static TEMP_ID: AtomicU64 = AtomicU64::new(0); + +struct TestDir(PathBuf); + +impl TestDir { + fn new(name: &str) -> Self { + let id = TEMP_ID.fetch_add(1, Ordering::Relaxed); + let path = std::env::temp_dir().join(format!( + "metacrate-test-client-world-{name}-{}-{id}", + std::process::id() + )); + fs::create_dir(&path).expect("create test directory"); + Self(path) + } + + fn path(&self, name: &str) -> PathBuf { + self.0.join(name) + } + + fn write(&self, name: &str, contents: impl AsRef<[u8]>) -> PathBuf { + let path = self.path(name); + fs::write(&path, contents).expect("write fixture"); + path + } +} + +impl Drop for TestDir { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } +} + +fn path_text(path: &Path) -> &str { + path.to_str().expect("UTF-8 test path") +} + +fn run(args: &[&str]) -> Output { + Command::new(env!("CARGO_BIN_EXE_test-client")) + .args(args) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .expect("run test-client") +} + +fn stdout(output: &Output) -> &str { + std::str::from_utf8(&output.stdout).expect("UTF-8 stdout") +} + +fn stderr(output: &Output) -> &str { + std::str::from_utf8(&output.stderr).expect("UTF-8 stderr") +} + +fn hex(bytes: &[u8]) -> String { + const DIGITS: &[u8; 16] = b"0123456789abcdef"; + let mut output = String::with_capacity(bytes.len() * 2); + for byte in bytes { + output.push(char::from(DIGITS[usize::from(byte >> 4)])); + output.push(char::from(DIGITS[usize::from(byte & 0x0f)])); + } + output +} + +fn one_pixel_jp2() -> Vec { + let mut image = + ManagedImage::new(1, 1, ManagedImageImageChannels::COLOR).expect("create image"); + image.red[0] = 10; + image.green[0] = 20; + image.blue[0] = 30; + J2kCodec::encode(&image, J2kEncodeOptions::default()).expect("encode JPEG2000") +} + +#[test] +#[allow(clippy::too_many_lines)] +fn fake_grid_exercises_every_owned_world_command() { + let directory = TestDir::new("commands"); + let terrain_source = directory.write("terrain.raw", [9, 8, 7, 6]); + let terrain_download = directory.path("downloaded.raw"); + let texture_download = directory.path("download.jp2"); + let export_one = directory.path("export-one.xml"); + let export_two = directory.path("export-two.xml"); + let texture = one_pixel_jp2(); + let script = directory.write( + "terminal.tsv", + format!( + "!client\t{CLIENT}\tAlice\tBot\n\ + !person\tTarget Resident\t{TARGET}\n\ + !world-region\t{CLIENT}\t{HANDLE}\tTest Region\t128\t128\t25\t1.5\t-2.5\n\ + !world-avatar\t{CLIENT}\t{TARGET}\tTarget Resident\t132\t132\t25\n\ + !world-prim\t{CLIENT}\t{ROOT}\t100\t0\t{CLIENT}\tCube Root\tExport root\t130\t130\t25\t{TEXTURE}\ttrue\n\ + !world-prim\t{CLIENT}\t{CHILD}\t101\t100\t{CLIENT}\tCube Child\tExport child\t1\t0\t0\t{TEXTURE}\tfalse\n\ + !world-prim\t{CLIENT}\t{REMOVE}\t102\t0\t{CLIENT}\tRemove Me\tDerez target\t131\t130\t25\t{TEXTURE}\tfalse\n\ + !world-parcel\t{CLIENT}\t1\t{CLIENT}\tHome Parcel\tFixture parcel\t2.5\t4096\t3\t117\n\ + !world-parcel-owner\t{CLIENT}\t1\t{CLIENT}\t3\n\ + !world-parcel-object\t{CLIENT}\t1\t{CLIENT}\t100,101,102\n\ + !world-grid-region\t{CLIENT}\tTest Region\t{HANDLE}\t1000\t2000\t3\n\ + !world-layer\t{CLIENT}\t{LAYER}\t0\t0\t1024\t1024\n\ + !world-agent-location\t{CLIENT}\t{HANDLE}\t3\t128\t128\n\ + !world-estate\t{CLIENT}\tFixture Estate\t{CLIENT}\t{COVENANT}\t12345\tBe kind.\t0102\n\ + !world-asset\t{CLIENT}\t{TEXTURE}\t{}\n\ + !world-syntax\t{CLIENT}\tdefault,state_entry,llSay\n\ + downloadterrain 1000 \"{}\" --confirm\n\ + getestatecovenant 1\n\ + uploadterrain \"{}\" --confirm\n\ + agentlocations\n\ + findsim Test Region\n\ + gridlayer\n\ + gridmap\n\ + parceldetails 1\n\ + parcelinfo\n\ + primowners 1\n\ + selectobjects 1 {CLIENT}\n\ + syntaxid\n\ + wind\n\ + back 0 --confirm\n\ + crossregion north fly --confirm\n\ + crouch start --confirm\n\ + fly start --confirm\n\ + flyto 130 130 25 1 --confirm\n\ + follow Target Resident --confirm\n\ + follow off --confirm\n\ + forward 0 --confirm\n\ + gohome --confirm\n\ + goto Test Region/128/128/25 --confirm\n\ + goto_landmark {LANDMARK} --confirm\n\ + jump --confirm\n\ + left 0 --confirm\n\ + location\n\ + moveto 10 11 12 --confirm\n\ + right 0 --confirm\n\ + sethome --confirm\n\ + sit --confirm\n\ + siton {ROOT} --confirm\n\ + stand --confirm\n\ + turnto 140 141 25 --confirm\n\ + changeperms {ROOT} copy mod xfer --confirm\n\ + derez {REMOVE} --confirm\n\ + downloadtexture {TEXTURE} 0 \"{}\"\n\ + export {ROOT} \"{}\"\n\ + export {ROOT} \"{}\"\n\ + exportparticles {ROOT}\n\ + findobjects 4096 Cube\n\ + findtexture 0 {TEXTURE}\n\ + import \"{}\" usegroup --confirm\n\ + primcount\n\ + priminfo {ROOT}\n\ + primregex Cube\n\ + textures on\n\ + textures off\n\ + tree Oak --confirm\n\ + quit\n", + hex(&texture), + path_text(&terrain_download), + path_text(&terrain_source), + path_text(&texture_download), + path_text(&export_one), + path_text(&export_two), + path_text(&export_one), + ), + ); + + let output = run(&[ + "--allow-live-mutations", + "--allow-spending", + "--allow-estate-actions", + "--fake-script", + path_text(&script), + ]); + assert!(output.status.success(), "{}", stderr(&output)); + assert!(output.stderr.is_empty(), "{}", stderr(&output)); + let text = stdout(&output); + for expected in [ + "Terrain file Test Region (2 bytes) downloaded successfully", + "Estate name: Fixture Estate", + "Terrain raw file uploaded and applied", + "3 avatar(s) at 128,128", + "Test Region: handle=4294967298000", + "Received 1 layer chunks", + "Received 1 grid regions", + "Name = Home Parcel", + "Downloaded 1 Parcels in Test Region", + "Owner: aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee Count: 3", + "Found a total of 3 Objects", + "llSay", + "Local wind speed is Vector2", + "Following Target Resident", + "CurrentSim: 'Test Region'", + "Set permissions to PermissionMask", + "Removing Remove Me", + "Saved", + "Exported 2 prims", + "llParticleSystem", + "Done searching; found 2 objects", + "Done searching; found 2 faces", + "Import complete.", + "Tracking a total of", + "ID: 90000000-0000-0000-0000-000000000001", + "Texture downloading is on", + "Texture downloading is off", + "CALL estate-upload-terrain terrain.raw", + "CALL world-query download-terrain", + "CALL world-query estate-covenant", + "CALL world-query agent-locations", + "CALL world-query find-region", + "CALL world-query grid-layer", + "CALL world-query grid-map", + "CALL world-query parcel-owners", + "CALL world-query parcel-objects", + "CALL movement-back duration-ms=0", + "CALL movement-cross Vector3", + "CALL movement-crouch true", + "CALL movement-fly true", + "CALL movement-flyto Vector3", + "CALL movement-follow 11111111-2222-3333-4444-555555555555", + "CALL movement-follow off", + "CALL movement-forward duration-ms=0", + "CALL teleport-home", + "CALL teleport-region Test Region", + "CALL teleport-landmark 50000000-0000-0000-0000-000000000001", + "CALL movement-jump", + "CALL movement-left duration-ms=0", + "CALL movement-autopilot [1010.0, 2011.0, 12.0]", + "CALL movement-right duration-ms=0", + "CALL movement-set-home", + "CALL movement-sit 90000000-0000-0000-0000-000000000001", + "CALL movement-stand", + "CALL movement-turn Vector3", + "CALL object-permissions 90000000-0000-0000-0000-000000000001", + "CALL object-derez 90000000-0000-0000-0000-000000000003", + "CALL texture-download 80000000-0000-0000-0000-000000000001", + "CALL object-import count=2 use-group=true", + "CALL texture-stream enabled=true", + "CALL texture-stream enabled=false", + "CALL object-tree Oak", + ] { + assert!(text.contains(expected), "missing {expected:?} in:\n{text}"); + } + assert_eq!( + text.matches("CALL world-query parcels").count(), + 2, + "{text}" + ); + assert_eq!(text.matches("CALL movement-sit ").count(), 2, "{text}"); + assert_eq!( + text.matches("CALL texture-download 80000000-0000-0000-0000-000000000001") + .count(), + 3, + "{text}" + ); + assert_eq!(fs::read(&terrain_download).expect("terrain output"), [1, 2]); + assert_eq!( + fs::read(&texture_download).expect("texture output"), + texture + ); + assert_eq!( + fs::read(&export_one).expect("first export"), + fs::read(&export_two).expect("second export"), + "exports must be deterministic" + ); + assert!(directory.path(&format!("{TEXTURE}.jp2")).is_file()); + assert!(directory.path(&format!("{TEXTURE}.tga")).is_file()); +} + +#[test] +fn privileged_world_mutations_require_every_gate_and_confirmation() { + let directory = TestDir::new("guards"); + let terrain = directory.write("terrain.raw", [1, 2, 3]); + let script = directory.write( + "guards.tsv", + format!( + "!client\t{CLIENT}\tAlice\tBot\n\ + !world-region\t{CLIENT}\t{HANDLE}\tTest Region\t128\t128\t25\t0\t0\n\ + !world-estate\t{CLIENT}\tFixture Estate\t{CLIENT}\t{COVENANT}\t1\tCovenant\t0102\n\ + forward 1 --confirm\n\ + uploadterrain \"{}\" --confirm\n\ + quit\n", + path_text(&terrain), + ), + ); + let output = run(&["--fake-script", path_text(&script)]); + assert!(output.status.success(), "{}", stderr(&output)); + let text = stdout(&output); + assert!(text.contains("Live world mutation blocked"), "{text}"); + assert!(!text.contains("CALL movement-forward"), "{text}"); + assert!(!text.contains("CALL estate-upload-terrain"), "{text}"); + + let script = directory.write( + "confirm.tsv", + format!( + "!client\t{CLIENT}\tAlice\tBot\n\ + !world-region\t{CLIENT}\t{HANDLE}\tTest Region\t128\t128\t25\t0\t0\n\ + !world-estate\t{CLIENT}\tFixture Estate\t{CLIENT}\t{COVENANT}\t1\tCovenant\t0102\n\ + uploadterrain \"{}\"\n\ + quit\n", + path_text(&terrain), + ), + ); + let output = run(&[ + "--allow-live-mutations", + "--allow-spending", + "--allow-estate-actions", + "--fake-script", + path_text(&script), + ]); + assert!(output.status.success(), "{}", stderr(&output)); + let text = stdout(&output); + assert!(text.contains("explicit --confirm"), "{text}"); + assert!(!text.contains("CALL estate-upload-terrain"), "{text}"); +}