Implement native PrimInspector
All checks were successful
Native Rust workspace compile / compile (push) Successful in 21m40s
All checks were successful
Native Rust workspace compile / compile (push) Successful in 21m40s
This commit is contained in:
@@ -485,12 +485,16 @@ documented in the
|
|||||||
|
|
||||||
### Milestone 11
|
### Milestone 11
|
||||||
|
|
||||||
The native `osd-inspector`, `simple-bot`, and `packet-dump` programs are complete.
|
The native `osd-inspector`, `simple-bot`, `packet-dump`, and `prim-inspector`
|
||||||
|
programs are complete.
|
||||||
`osd-inspector` validates and converts bounded LLSD and performs deterministic
|
`osd-inspector` validates and converts bounded LLSD and performs deterministic
|
||||||
Primitive-to-OSD round trips. `simple-bot` provides native async login, IM and
|
Primitive-to-OSD round trips. `simple-bot` provides native async login, IM and
|
||||||
local-chat commands, movement and animation calls, bounded fake-grid
|
local-chat commands, movement and animation calls, bounded fake-grid
|
||||||
conversations, secret redaction, and cancellation-safe logout. `packet-dump`
|
conversations, secret redaction, and cancellation-safe logout. `packet-dump`
|
||||||
adds filtered, bounded incoming/outgoing capture with native wire validation,
|
adds filtered, bounded incoming/outgoing capture with native wire validation,
|
||||||
sanitized optional raw bytes, and deterministic fake datagrams. Their command
|
sanitized optional raw bytes, and deterministic fake datagrams. `prim-inspector`
|
||||||
|
discovers real simulator primitives, hydrates missing object properties, orders
|
||||||
|
nearest matches, and formats construction, feature, and ownership details with
|
||||||
|
bounded deterministic fake-grid coverage. Their command
|
||||||
surfaces, limits, isolated CLI tests, and the status of every remaining program
|
surfaces, limits, isolated CLI tests, and the status of every remaining program
|
||||||
are documented in the [`native programs guide`](programs/README.md).
|
are documented in the [`native programs guide`](programs/README.md).
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ here so a source entry is never mistaken for a completed port.
|
|||||||
| `osd-inspector` | OSDInspector | Implemented and tested offline |
|
| `osd-inspector` | OSDInspector | Implemented and tested offline |
|
||||||
| `simple-bot` | SimpleBot | Implemented with live and deterministic fake-grid modes |
|
| `simple-bot` | SimpleBot | Implemented with live and deterministic fake-grid modes |
|
||||||
| `packet-dump` | PacketDump | Implemented with live and deterministic fake-grid capture |
|
| `packet-dump` | PacketDump | Implemented with live and deterministic fake-grid capture |
|
||||||
| `prim-inspector` | PrimInspector | Pending milestone 11 issue #88 |
|
| `prim-inspector` | PrimInspector | Implemented with live and deterministic fake-grid discovery |
|
||||||
| `inventory-explorer` | InventoryExplorer | Pending milestone 11 issue #89 |
|
| `inventory-explorer` | InventoryExplorer | Pending milestone 11 issue #89 |
|
||||||
| `irc-gateway` | IRCGateway | Pending milestone 11 issue #90 |
|
| `irc-gateway` | IRCGateway | Pending milestone 11 issue #90 |
|
||||||
| `test-client` | TestClient | Pending milestone 11 issues #91–#94 |
|
| `test-client` | TestClient | Pending milestone 11 issues #91–#94 |
|
||||||
@@ -148,3 +148,44 @@ cargo test -p libremetaverse-programs --test packet_dump_cli --locked
|
|||||||
cargo test -p libremetaverse --test packet_wire --locked
|
cargo test -p libremetaverse --test packet_wire --locked
|
||||||
cargo test --manifest-path tests/compat/Cargo.toml --test wire_semantics --locked
|
cargo test --manifest-path tests/compat/Cargo.toml --test wire_semantics --locked
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## PrimInspector
|
||||||
|
|
||||||
|
`prim-inspector` preserves the upstream positional login and search arguments:
|
||||||
|
|
||||||
|
```text
|
||||||
|
prim-inspector FIRSTNAME LASTNAME PASSWORD [SEARCH_TERM]
|
||||||
|
GRID_FIRST_NAME=... GRID_LAST_NAME=... GRID_PASSWORD=... prim-inspector
|
||||||
|
```
|
||||||
|
|
||||||
|
The native client disables multiple-simulator connections, allows 30 seconds
|
||||||
|
for login, collects object updates for two seconds, and snapshots the current
|
||||||
|
simulator's real primitive table. It requests missing properties through
|
||||||
|
`ObjectManager::SelectObject`, sharing a three-second deadline across at most
|
||||||
|
1,024 outstanding requests. A disconnect, simulator change, or Ctrl-C cancels
|
||||||
|
the operation and still removes handlers, logs out, and disposes the client.
|
||||||
|
|
||||||
|
Names are filtered case-insensitively after available properties are hydrated.
|
||||||
|
Matches are ordered by distance from the agent, with local ID and UUID tie
|
||||||
|
breakers, and the nearest ten are shown by default. `--limit` accepts one
|
||||||
|
through 100 objects. Output covers transforms, construction data, flags,
|
||||||
|
parentage, sculpt, light, flexible, ownership, creator, description, sale, and
|
||||||
|
hover-text details. Status text, descriptions, and names suppress URLs and
|
||||||
|
credential-like assignments, and `--max-output-bytes` bounds the complete
|
||||||
|
buffer before anything is written.
|
||||||
|
|
||||||
|
For offline validation, `--fake-grid FILE [--search SEARCH_TERM]` reads an LLSD
|
||||||
|
JSON map containing `region`, `agent_position`, and an `objects` array. Object
|
||||||
|
maps use the public `Primitive::from_osd` fields and may add `owner`, `creator`,
|
||||||
|
`sale_type`, `sale_price`, `hover_text`, and `property_state`. The latter is
|
||||||
|
`ready`, `delayed`, or `missing`; delayed and missing fixtures record the same
|
||||||
|
selection/property-timeout decisions as a live session. Input is limited to 8
|
||||||
|
MiB, 100,000 objects, LLSD depth/allocation limits, nonzero UUID/local IDs, and
|
||||||
|
validated property/sale states. No live grid is contacted by the tests.
|
||||||
|
|
||||||
|
Run the isolated CLI golden tests and the related translated primitive cases:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo test -p libremetaverse-programs --test prim_inspector_cli --locked
|
||||||
|
cargo test --manifest-path tests/compat/Cargo.toml --test world_object_semantics --locked
|
||||||
|
```
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
fn main() -> std::process::ExitCode {
|
fn main() -> std::process::ExitCode {
|
||||||
libremetaverse_programs::pending_program("PrimInspector")
|
libremetaverse_programs::prim_inspector::main_entry()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
pub mod commands;
|
pub mod commands;
|
||||||
pub mod osd_inspector;
|
pub mod osd_inspector;
|
||||||
pub mod packet_dump;
|
pub mod packet_dump;
|
||||||
|
pub mod prim_inspector;
|
||||||
pub mod simple_bot;
|
pub mod simple_bot;
|
||||||
|
|
||||||
pub use libremetaverse::shim::pending_program;
|
pub use libremetaverse::shim::pending_program;
|
||||||
|
|||||||
893
programs/src/prim_inspector.rs
Normal file
893
programs/src/prim_inspector.rs
Normal file
@@ -0,0 +1,893 @@
|
|||||||
|
//! Native primitive discovery and inspection for live and deterministic fake grids.
|
||||||
|
|
||||||
|
use clap::Parser;
|
||||||
|
use clap::error::ErrorKind;
|
||||||
|
use libremetaverse::structured_data::{OSD, OSDParser};
|
||||||
|
use libremetaverse::types::compat::{CancellationTokenSource, Subscription};
|
||||||
|
use libremetaverse::types::{SaleType, UUID, Vector3};
|
||||||
|
use libremetaverse::{
|
||||||
|
DisconnectedEventArgs, GridClient, LoginProgressEventArgs, NetworkManager,
|
||||||
|
ObjectPropertiesEventArgs, Primitive, PrimitiveObjectProperties, SimChangedEventArgs,
|
||||||
|
Simulator,
|
||||||
|
};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::fmt;
|
||||||
|
use std::fs;
|
||||||
|
use std::io::{self, Write};
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::process::ExitCode;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
use tokio::sync::mpsc;
|
||||||
|
|
||||||
|
pub const EXIT_SUCCESS: u8 = 0;
|
||||||
|
pub const EXIT_USAGE: u8 = 2;
|
||||||
|
pub const EXIT_INPUT: u8 = 3;
|
||||||
|
pub const EXIT_CLIENT: u8 = 4;
|
||||||
|
pub const EXIT_OUTPUT: u8 = 5;
|
||||||
|
|
||||||
|
const DEFAULT_LOGIN_TIMEOUT_SECONDS: u64 = 30;
|
||||||
|
const DEFAULT_DISCOVERY_DELAY_MS: u64 = 2_000;
|
||||||
|
const DEFAULT_PROPERTY_TIMEOUT_MS: u64 = 3_000;
|
||||||
|
const DEFAULT_MAX_OUTPUT_BYTES: usize = 4 * 1024 * 1024;
|
||||||
|
const MAX_FIXTURE_BYTES: u64 = 8 * 1024 * 1024;
|
||||||
|
const MAX_FIXTURE_ALLOC_BYTES: usize = 8 * 1024 * 1024;
|
||||||
|
const MAX_OBJECTS: usize = 100_000;
|
||||||
|
const MAX_PROPERTY_REQUESTS: usize = 1_024;
|
||||||
|
const EVENT_QUEUE_CAPACITY: usize = 2_048;
|
||||||
|
|
||||||
|
#[derive(Parser)]
|
||||||
|
#[command(
|
||||||
|
name = "prim-inspector",
|
||||||
|
version,
|
||||||
|
about = "Discover and inspect the nearest primitives on a native LibreMetaverse grid",
|
||||||
|
long_about = None,
|
||||||
|
arg_required_else_help = true
|
||||||
|
)]
|
||||||
|
struct Cli {
|
||||||
|
/// Avatar first name. May also be supplied as `GRID_FIRST_NAME`.
|
||||||
|
#[arg(value_name = "FIRSTNAME")]
|
||||||
|
first_name: Option<String>,
|
||||||
|
|
||||||
|
/// Avatar last name. May also be supplied as `GRID_LAST_NAME`.
|
||||||
|
#[arg(value_name = "LASTNAME")]
|
||||||
|
last_name: Option<String>,
|
||||||
|
|
||||||
|
/// Avatar password. May also be supplied as `GRID_PASSWORD`.
|
||||||
|
#[arg(value_name = "PASSWORD")]
|
||||||
|
password: Option<String>,
|
||||||
|
|
||||||
|
/// Case-insensitive object-name substring.
|
||||||
|
#[arg(value_name = "SEARCH_TERM")]
|
||||||
|
search_term: Option<String>,
|
||||||
|
|
||||||
|
/// Case-insensitive object-name substring for `--fake-grid` mode.
|
||||||
|
#[arg(long = "search", value_name = "SEARCH_TERM")]
|
||||||
|
fake_search: Option<String>,
|
||||||
|
|
||||||
|
/// Inspect a bounded LLSD JSON fixture without accessing a live grid.
|
||||||
|
#[arg(long, value_name = "FILE")]
|
||||||
|
fake_grid: Option<PathBuf>,
|
||||||
|
|
||||||
|
/// Maximum number of nearest matching objects to print.
|
||||||
|
#[arg(long, default_value_t = 10, value_name = "COUNT")]
|
||||||
|
limit: usize,
|
||||||
|
|
||||||
|
/// Override the login endpoint. `GRID_LOGIN_URL` is used when absent.
|
||||||
|
#[arg(long, value_name = "URL")]
|
||||||
|
login_uri: Option<String>,
|
||||||
|
|
||||||
|
/// Maximum time allowed for login.
|
||||||
|
#[arg(long, default_value_t = DEFAULT_LOGIN_TIMEOUT_SECONDS, value_name = "SECONDS", value_parser = clap::value_parser!(u64).range(1..=300))]
|
||||||
|
login_timeout_seconds: u64,
|
||||||
|
|
||||||
|
/// Time to collect object updates after login.
|
||||||
|
#[arg(long, default_value_t = DEFAULT_DISCOVERY_DELAY_MS, value_name = "MILLISECONDS", value_parser = clap::value_parser!(u64).range(0..=300_000))]
|
||||||
|
discovery_delay_ms: u64,
|
||||||
|
|
||||||
|
/// Shared deadline for outstanding object-property requests.
|
||||||
|
#[arg(long, default_value_t = DEFAULT_PROPERTY_TIMEOUT_MS, value_name = "MILLISECONDS", value_parser = clap::value_parser!(u64).range(1..=300_000))]
|
||||||
|
property_timeout_ms: u64,
|
||||||
|
|
||||||
|
/// Refuse to emit more than this many bytes.
|
||||||
|
#[arg(long, default_value_t = DEFAULT_MAX_OUTPUT_BYTES, value_name = "BYTES")]
|
||||||
|
max_output_bytes: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct LiveArguments {
|
||||||
|
first_name: String,
|
||||||
|
last_name: String,
|
||||||
|
password: String,
|
||||||
|
search_term: Option<String>,
|
||||||
|
login_uri: Option<String>,
|
||||||
|
login_timeout: Duration,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
enum ProgramError {
|
||||||
|
Usage(String),
|
||||||
|
Input { action: String, source: io::Error },
|
||||||
|
InvalidFixture(&'static str),
|
||||||
|
Client(&'static str),
|
||||||
|
LoginFailed,
|
||||||
|
LoginTimedOut,
|
||||||
|
Cancelled,
|
||||||
|
SimulatorChanged,
|
||||||
|
Disconnected,
|
||||||
|
Output(io::Error),
|
||||||
|
OutputLimit,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ProgramError {
|
||||||
|
const fn exit_code(&self) -> u8 {
|
||||||
|
match self {
|
||||||
|
Self::Usage(_) => EXIT_USAGE,
|
||||||
|
Self::Input { .. } | Self::InvalidFixture(_) => EXIT_INPUT,
|
||||||
|
Self::Client(_)
|
||||||
|
| Self::LoginFailed
|
||||||
|
| Self::LoginTimedOut
|
||||||
|
| Self::Cancelled
|
||||||
|
| Self::SimulatorChanged
|
||||||
|
| Self::Disconnected => EXIT_CLIENT,
|
||||||
|
Self::Output(_) | Self::OutputLimit => EXIT_OUTPUT,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for ProgramError {
|
||||||
|
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
match self {
|
||||||
|
Self::Usage(message) => formatter.write_str(message),
|
||||||
|
Self::Input { action, source } => write!(formatter, "{action}: {source}"),
|
||||||
|
Self::InvalidFixture(reason) => {
|
||||||
|
write!(formatter, "invalid fake-grid fixture: {reason}")
|
||||||
|
}
|
||||||
|
Self::Client(operation) => {
|
||||||
|
write!(formatter, "native client operation failed: {operation}")
|
||||||
|
}
|
||||||
|
Self::LoginFailed => formatter.write_str("login failed"),
|
||||||
|
Self::LoginTimedOut => formatter.write_str("login timed out"),
|
||||||
|
Self::Cancelled => formatter.write_str("inspection cancelled"),
|
||||||
|
Self::SimulatorChanged => formatter.write_str("simulator changed during inspection"),
|
||||||
|
Self::Disconnected => formatter.write_str("grid disconnected during inspection"),
|
||||||
|
Self::Output(source) => write!(formatter, "writing standard output: {source}"),
|
||||||
|
Self::OutputLimit => formatter.write_str("inspection reached the output byte limit"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct BoundedOutput {
|
||||||
|
bytes: Vec<u8>,
|
||||||
|
maximum: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BoundedOutput {
|
||||||
|
fn new(maximum: usize) -> Self {
|
||||||
|
Self {
|
||||||
|
bytes: Vec::new(),
|
||||||
|
maximum,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn line(&mut self, value: impl AsRef<str>) -> Result<(), ProgramError> {
|
||||||
|
let value = sanitize(value.as_ref());
|
||||||
|
let required = value
|
||||||
|
.len()
|
||||||
|
.checked_add(1)
|
||||||
|
.ok_or(ProgramError::OutputLimit)?;
|
||||||
|
if self
|
||||||
|
.bytes
|
||||||
|
.len()
|
||||||
|
.checked_add(required)
|
||||||
|
.is_none_or(|size| size > self.maximum)
|
||||||
|
{
|
||||||
|
return Err(ProgramError::OutputLimit);
|
||||||
|
}
|
||||||
|
self.bytes.extend_from_slice(value.as_bytes());
|
||||||
|
self.bytes.push(b'\n');
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn flush(self) -> Result<(), ProgramError> {
|
||||||
|
let mut output = io::stdout().lock();
|
||||||
|
output
|
||||||
|
.write_all(&self.bytes)
|
||||||
|
.map_err(ProgramError::Output)?;
|
||||||
|
output.flush().map_err(ProgramError::Output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum LiveEvent {
|
||||||
|
Status(String),
|
||||||
|
Properties(Box<PrimitiveObjectProperties>),
|
||||||
|
Disconnected,
|
||||||
|
SimulatorChanged { had_previous: bool },
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
enum PropertyState {
|
||||||
|
Ready,
|
||||||
|
Delayed,
|
||||||
|
Missing,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct FixtureObject {
|
||||||
|
primitive: Primitive,
|
||||||
|
pending_properties: Option<PrimitiveObjectProperties>,
|
||||||
|
state: PropertyState,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Fixture {
|
||||||
|
region: String,
|
||||||
|
agent_position: Vector3,
|
||||||
|
objects: Vec<FixtureObject>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn main_entry() -> ExitCode {
|
||||||
|
match run() {
|
||||||
|
Ok(()) => ExitCode::SUCCESS,
|
||||||
|
Err(error) => {
|
||||||
|
eprintln!("prim-inspector: {error}");
|
||||||
|
ExitCode::from(error.exit_code())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run() -> Result<(), ProgramError> {
|
||||||
|
let cli = match Cli::try_parse() {
|
||||||
|
Ok(cli) => cli,
|
||||||
|
Err(error)
|
||||||
|
if matches!(
|
||||||
|
error.kind(),
|
||||||
|
ErrorKind::DisplayHelp | ErrorKind::DisplayVersion
|
||||||
|
) =>
|
||||||
|
{
|
||||||
|
error.print().map_err(ProgramError::Output)?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
Err(error) => return Err(ProgramError::Usage(error.to_string())),
|
||||||
|
};
|
||||||
|
if !(1..=100).contains(&cli.limit) {
|
||||||
|
return Err(ProgramError::Usage(
|
||||||
|
"--limit must be between 1 and 100".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if cli.max_output_bytes == 0 {
|
||||||
|
return Err(ProgramError::Usage(
|
||||||
|
"--max-output-bytes must be greater than zero".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
validate_search(cli.search_term.as_deref())?;
|
||||||
|
validate_search(cli.fake_search.as_deref())?;
|
||||||
|
let mut output = BoundedOutput::new(cli.max_output_bytes);
|
||||||
|
let runtime =
|
||||||
|
tokio::runtime::Runtime::new().map_err(|_| ProgramError::Client("start runtime"))?;
|
||||||
|
if let Some(path) = &cli.fake_grid {
|
||||||
|
if cli.first_name.is_some()
|
||||||
|
|| cli.last_name.is_some()
|
||||||
|
|| cli.password.is_some()
|
||||||
|
|| cli.login_uri.is_some()
|
||||||
|
{
|
||||||
|
return Err(ProgramError::Usage(
|
||||||
|
"live credentials and --login-uri cannot be combined with --fake-grid".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if cli.search_term.is_some() && cli.fake_search.is_some() {
|
||||||
|
return Err(ProgramError::Usage(
|
||||||
|
"positional SEARCH_TERM and --search cannot be combined".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
run_fixture(
|
||||||
|
path,
|
||||||
|
cli.fake_search.as_deref().or(cli.search_term.as_deref()),
|
||||||
|
cli.limit,
|
||||||
|
cli.property_timeout_ms,
|
||||||
|
&mut output,
|
||||||
|
)?;
|
||||||
|
} else {
|
||||||
|
let live = resolve_live_arguments(&cli)?;
|
||||||
|
runtime.block_on(run_live(
|
||||||
|
live,
|
||||||
|
cli.limit,
|
||||||
|
Duration::from_millis(cli.discovery_delay_ms),
|
||||||
|
Duration::from_millis(cli.property_timeout_ms),
|
||||||
|
&mut output,
|
||||||
|
))?;
|
||||||
|
}
|
||||||
|
output.flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_search(search: Option<&str>) -> Result<(), ProgramError> {
|
||||||
|
if search.is_some_and(|value| value.chars().count() > 256) {
|
||||||
|
return Err(ProgramError::Usage(
|
||||||
|
"SEARCH_TERM must contain at most 256 characters".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_live_arguments(cli: &Cli) -> Result<LiveArguments, ProgramError> {
|
||||||
|
fn required(
|
||||||
|
value: Option<&String>,
|
||||||
|
variable: &str,
|
||||||
|
label: &str,
|
||||||
|
) -> Result<String, ProgramError> {
|
||||||
|
value
|
||||||
|
.cloned()
|
||||||
|
.or_else(|| std::env::var(variable).ok())
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.ok_or_else(|| ProgramError::Usage(format!("{label} is required (or set {variable})")))
|
||||||
|
}
|
||||||
|
Ok(LiveArguments {
|
||||||
|
first_name: required(cli.first_name.as_ref(), "GRID_FIRST_NAME", "FIRSTNAME")?,
|
||||||
|
last_name: required(cli.last_name.as_ref(), "GRID_LAST_NAME", "LASTNAME")?,
|
||||||
|
password: required(cli.password.as_ref(), "GRID_PASSWORD", "PASSWORD")?,
|
||||||
|
search_term: cli.search_term.clone(),
|
||||||
|
login_uri: cli
|
||||||
|
.login_uri
|
||||||
|
.clone()
|
||||||
|
.or_else(|| std::env::var("GRID_LOGIN_URL").ok())
|
||||||
|
.filter(|value| !value.is_empty()),
|
||||||
|
login_timeout: Duration::from_secs(cli.login_timeout_seconds),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run_live(
|
||||||
|
mut arguments: LiveArguments,
|
||||||
|
limit: usize,
|
||||||
|
discovery_delay: Duration,
|
||||||
|
property_timeout: Duration,
|
||||||
|
output: &mut BoundedOutput,
|
||||||
|
) -> Result<(), ProgramError> {
|
||||||
|
let mut client = GridClient::new().map_err(|_| ProgramError::Client("construct GridClient"))?;
|
||||||
|
client.settings().agent_settings_mut().multiple_sims = false;
|
||||||
|
let network = client.network();
|
||||||
|
let objects = client.objects();
|
||||||
|
let mut login = network
|
||||||
|
.default_login_params(
|
||||||
|
std::mem::take(&mut arguments.first_name),
|
||||||
|
std::mem::take(&mut arguments.last_name),
|
||||||
|
std::mem::take(&mut arguments.password),
|
||||||
|
"PrimInspector".into(),
|
||||||
|
env!("CARGO_PKG_VERSION").into(),
|
||||||
|
)
|
||||||
|
.map_err(|_| ProgramError::Client("build login parameters"))?;
|
||||||
|
if let Some(uri) = arguments.login_uri.take() {
|
||||||
|
login.uri = uri;
|
||||||
|
}
|
||||||
|
let (sender, mut receiver) = mpsc::channel(EVENT_QUEUE_CAPACITY);
|
||||||
|
let subscriptions = install_subscriptions(&network, &objects, &sender);
|
||||||
|
if let Err(error) = output.line("Logging in...") {
|
||||||
|
shutdown(&client, &network, subscriptions);
|
||||||
|
return Err(error);
|
||||||
|
}
|
||||||
|
let cancellation = CancellationTokenSource::new();
|
||||||
|
let login_result = tokio::select! {
|
||||||
|
result = network.login_with_login_params_cancellation_token(login, Some(cancellation.token())) => {
|
||||||
|
result.map_err(|_| ProgramError::LoginFailed)
|
||||||
|
}
|
||||||
|
() = tokio::time::sleep(arguments.login_timeout) => {
|
||||||
|
cancellation.cancel();
|
||||||
|
let _ = network.abort_login();
|
||||||
|
Err(ProgramError::LoginTimedOut)
|
||||||
|
}
|
||||||
|
signal = tokio::signal::ctrl_c() => {
|
||||||
|
cancellation.cancel();
|
||||||
|
let _ = network.abort_login();
|
||||||
|
signal.map_err(|_| ProgramError::Cancelled)?;
|
||||||
|
Err(ProgramError::Cancelled)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let result = async {
|
||||||
|
if !login_result? {
|
||||||
|
return Err(ProgramError::LoginFailed);
|
||||||
|
}
|
||||||
|
let simulator = network
|
||||||
|
.current_sim()
|
||||||
|
.ok_or(ProgramError::Client("resolve current simulator"))?;
|
||||||
|
drain_login_events(&mut receiver, output)?;
|
||||||
|
let position = client.self_().sim_position();
|
||||||
|
output.line(format!("Region: {}", simulator.name))?;
|
||||||
|
output.line(format!("Agent position: {}", vector3(position)))?;
|
||||||
|
output.line(format!(
|
||||||
|
"Collecting object updates for {} ms...",
|
||||||
|
discovery_delay.as_millis()
|
||||||
|
))?;
|
||||||
|
wait_duration(discovery_delay, &mut receiver, output).await?;
|
||||||
|
let mut primitives = snapshot(&simulator)?;
|
||||||
|
output.line(format!("Found {} primitives", primitives.len()))?;
|
||||||
|
hydrate_live_properties(
|
||||||
|
&objects,
|
||||||
|
&simulator,
|
||||||
|
&mut primitives,
|
||||||
|
property_timeout,
|
||||||
|
&mut receiver,
|
||||||
|
output,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
inspect(
|
||||||
|
primitives,
|
||||||
|
position,
|
||||||
|
arguments.search_term.as_deref(),
|
||||||
|
limit,
|
||||||
|
output,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.await;
|
||||||
|
shutdown(&client, &network, subscriptions);
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
fn drain_login_events(
|
||||||
|
receiver: &mut mpsc::Receiver<LiveEvent>,
|
||||||
|
output: &mut BoundedOutput,
|
||||||
|
) -> Result<(), ProgramError> {
|
||||||
|
while let Ok(event) = receiver.try_recv() {
|
||||||
|
match event {
|
||||||
|
LiveEvent::Status(status) => output.line(status)?,
|
||||||
|
LiveEvent::Properties(_)
|
||||||
|
| LiveEvent::SimulatorChanged {
|
||||||
|
had_previous: false,
|
||||||
|
} => {}
|
||||||
|
LiveEvent::SimulatorChanged { had_previous: true } => {
|
||||||
|
return Err(ProgramError::SimulatorChanged);
|
||||||
|
}
|
||||||
|
LiveEvent::Disconnected => return Err(ProgramError::Disconnected),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn install_subscriptions(
|
||||||
|
network: &NetworkManager,
|
||||||
|
objects: &libremetaverse::ObjectManager,
|
||||||
|
sender: &mpsc::Sender<LiveEvent>,
|
||||||
|
) -> Vec<Subscription> {
|
||||||
|
let status = sender.clone();
|
||||||
|
let properties = sender.clone();
|
||||||
|
let disconnected = sender.clone();
|
||||||
|
let changed = sender.clone();
|
||||||
|
vec![
|
||||||
|
network.subscribe_login_progress(Arc::new(move |event: LoginProgressEventArgs| {
|
||||||
|
let _ = status.try_send(LiveEvent::Status(format!(
|
||||||
|
"Login {:?}: {}",
|
||||||
|
event.status(),
|
||||||
|
event.message()
|
||||||
|
)));
|
||||||
|
})),
|
||||||
|
objects.subscribe_object_properties(Arc::new(move |event: ObjectPropertiesEventArgs| {
|
||||||
|
let _ = properties.try_send(LiveEvent::Properties(Box::new(event.properties())));
|
||||||
|
})),
|
||||||
|
network.subscribe_disconnected(Arc::new(move |_event: DisconnectedEventArgs| {
|
||||||
|
let _ = disconnected.try_send(LiveEvent::Disconnected);
|
||||||
|
})),
|
||||||
|
network.subscribe_sim_changed(Arc::new(move |event: SimChangedEventArgs| {
|
||||||
|
let _ = changed.try_send(LiveEvent::SimulatorChanged {
|
||||||
|
had_previous: event.previous_simulator().is_some(),
|
||||||
|
});
|
||||||
|
})),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn shutdown(client: &GridClient, network: &NetworkManager, subscriptions: Vec<Subscription>) {
|
||||||
|
drop(subscriptions);
|
||||||
|
let _ = network.logout_with_method();
|
||||||
|
let _ = client.dispose_with_method();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn snapshot(simulator: &Simulator) -> Result<Vec<Primitive>, ProgramError> {
|
||||||
|
let values = simulator
|
||||||
|
.objects_primitives
|
||||||
|
.read()
|
||||||
|
.map_err(|_| ProgramError::Client("lock simulator primitives"))?;
|
||||||
|
if values.len() > MAX_OBJECTS {
|
||||||
|
return Err(ProgramError::Client(
|
||||||
|
"primitive snapshot exceeds safety limit",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(values.values().cloned().collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn wait_duration(
|
||||||
|
duration: Duration,
|
||||||
|
receiver: &mut mpsc::Receiver<LiveEvent>,
|
||||||
|
output: &mut BoundedOutput,
|
||||||
|
) -> Result<(), ProgramError> {
|
||||||
|
let deadline = tokio::time::Instant::now() + duration;
|
||||||
|
loop {
|
||||||
|
tokio::select! {
|
||||||
|
() = tokio::time::sleep_until(deadline) => return Ok(()),
|
||||||
|
signal = tokio::signal::ctrl_c() => {
|
||||||
|
signal.map_err(|_| ProgramError::Cancelled)?;
|
||||||
|
return Err(ProgramError::Cancelled);
|
||||||
|
}
|
||||||
|
event = receiver.recv() => match event {
|
||||||
|
Some(LiveEvent::Status(status)) => output.line(status)?,
|
||||||
|
Some(LiveEvent::Disconnected) => return Err(ProgramError::Disconnected),
|
||||||
|
Some(LiveEvent::SimulatorChanged { had_previous: true }) => return Err(ProgramError::SimulatorChanged),
|
||||||
|
Some(LiveEvent::SimulatorChanged { had_previous: false } | LiveEvent::Properties(_)) => {},
|
||||||
|
None => return Err(ProgramError::Client("event queue closed")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn hydrate_live_properties(
|
||||||
|
objects: &libremetaverse::ObjectManager,
|
||||||
|
simulator: &Simulator,
|
||||||
|
primitives: &mut [Primitive],
|
||||||
|
timeout: Duration,
|
||||||
|
receiver: &mut mpsc::Receiver<LiveEvent>,
|
||||||
|
output: &mut BoundedOutput,
|
||||||
|
) -> Result<(), ProgramError> {
|
||||||
|
let mut pending = HashMap::new();
|
||||||
|
for primitive in primitives
|
||||||
|
.iter()
|
||||||
|
.filter(|primitive| primitive.properties.is_none())
|
||||||
|
.take(MAX_PROPERTY_REQUESTS)
|
||||||
|
{
|
||||||
|
objects
|
||||||
|
.select_object_with_simulator_u_int32(simulator.clone(), primitive.local_id)
|
||||||
|
.map_err(|_| ProgramError::Client("request object properties"))?;
|
||||||
|
pending.insert(primitive.id, primitive.local_id);
|
||||||
|
}
|
||||||
|
if pending.is_empty() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
output.line(format!(
|
||||||
|
"Requested properties for {} primitives",
|
||||||
|
pending.len()
|
||||||
|
))?;
|
||||||
|
let deadline = tokio::time::Instant::now() + timeout;
|
||||||
|
while !pending.is_empty() {
|
||||||
|
tokio::select! {
|
||||||
|
() = tokio::time::sleep_until(deadline) => break,
|
||||||
|
signal = tokio::signal::ctrl_c() => {
|
||||||
|
signal.map_err(|_| ProgramError::Cancelled)?;
|
||||||
|
return Err(ProgramError::Cancelled);
|
||||||
|
}
|
||||||
|
event = receiver.recv() => match event {
|
||||||
|
Some(LiveEvent::Properties(properties)) => {
|
||||||
|
if pending.remove(&properties.object_id).is_some()
|
||||||
|
&& let Some(primitive) = primitives.iter_mut().find(|primitive| primitive.id == properties.object_id)
|
||||||
|
{ primitive.properties = Some(*properties); }
|
||||||
|
}
|
||||||
|
Some(LiveEvent::Status(status)) => output.line(status)?,
|
||||||
|
Some(LiveEvent::Disconnected) => return Err(ProgramError::Disconnected),
|
||||||
|
Some(LiveEvent::SimulatorChanged { had_previous: true }) => return Err(ProgramError::SimulatorChanged),
|
||||||
|
Some(LiveEvent::SimulatorChanged { had_previous: false }) => {},
|
||||||
|
None => return Err(ProgramError::Client("event queue closed")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !pending.is_empty() {
|
||||||
|
output.line(format!(
|
||||||
|
"Property timeout: {} primitives remain unknown",
|
||||||
|
pending.len()
|
||||||
|
))?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_fixture(
|
||||||
|
path: &Path,
|
||||||
|
search: Option<&str>,
|
||||||
|
limit: usize,
|
||||||
|
property_timeout_ms: u64,
|
||||||
|
output: &mut BoundedOutput,
|
||||||
|
) -> Result<(), ProgramError> {
|
||||||
|
let fixture = read_fixture(path)?;
|
||||||
|
output.line("Fake grid connected")?;
|
||||||
|
output.line(format!("Region: {}", fixture.region))?;
|
||||||
|
output.line(format!(
|
||||||
|
"Agent position: {}",
|
||||||
|
vector3(fixture.agent_position)
|
||||||
|
))?;
|
||||||
|
output.line(format!("Found {} primitives", fixture.objects.len()))?;
|
||||||
|
let mut primitives = Vec::with_capacity(fixture.objects.len());
|
||||||
|
let mut missing = 0;
|
||||||
|
for mut object in fixture.objects {
|
||||||
|
match object.state {
|
||||||
|
PropertyState::Ready => {}
|
||||||
|
PropertyState::Delayed => {
|
||||||
|
output.line(format!(
|
||||||
|
"CALL select-object local_id={}",
|
||||||
|
object.primitive.local_id
|
||||||
|
))?;
|
||||||
|
object.primitive.properties = object.pending_properties.take();
|
||||||
|
output.line(format!(
|
||||||
|
"PROPERTY received local_id={}",
|
||||||
|
object.primitive.local_id
|
||||||
|
))?;
|
||||||
|
}
|
||||||
|
PropertyState::Missing => {
|
||||||
|
output.line(format!(
|
||||||
|
"CALL select-object local_id={}",
|
||||||
|
object.primitive.local_id
|
||||||
|
))?;
|
||||||
|
missing += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
primitives.push(object.primitive);
|
||||||
|
}
|
||||||
|
if missing != 0 {
|
||||||
|
output.line(format!(
|
||||||
|
"Property timeout after {property_timeout_ms} ms: {missing} primitives remain unknown"
|
||||||
|
))?;
|
||||||
|
}
|
||||||
|
inspect(primitives, fixture.agent_position, search, limit, output)?;
|
||||||
|
output.line("Fake grid logout complete; active_tasks=0 open_sockets=0")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_fixture(path: &Path) -> Result<Fixture, ProgramError> {
|
||||||
|
let metadata = fs::metadata(path).map_err(|source| ProgramError::Input {
|
||||||
|
action: format!("reading fixture metadata {}", path.display()),
|
||||||
|
source,
|
||||||
|
})?;
|
||||||
|
if metadata.len() > MAX_FIXTURE_BYTES {
|
||||||
|
return Err(ProgramError::InvalidFixture("file exceeds 8 MiB limit"));
|
||||||
|
}
|
||||||
|
let bytes = fs::read(path).map_err(|source| ProgramError::Input {
|
||||||
|
action: format!("reading fixture {}", path.display()),
|
||||||
|
source,
|
||||||
|
})?;
|
||||||
|
if bytes.len() > MAX_FIXTURE_ALLOC_BYTES {
|
||||||
|
return Err(ProgramError::InvalidFixture("file exceeds 8 MiB limit"));
|
||||||
|
}
|
||||||
|
let text =
|
||||||
|
String::from_utf8(bytes).map_err(|_| ProgramError::InvalidFixture("JSON must be UTF-8"))?;
|
||||||
|
let root = OSDParser::deserialize_json_with_string(text)
|
||||||
|
.map_err(|_| ProgramError::InvalidFixture("could not parse LLSD JSON"))?;
|
||||||
|
root.validate_limits(32, 1_000_000, MAX_FIXTURE_ALLOC_BYTES)
|
||||||
|
.map_err(|_| ProgramError::InvalidFixture("LLSD limits exceeded"))?;
|
||||||
|
let OSD::Map(root) = root else {
|
||||||
|
return Err(ProgramError::InvalidFixture("root must be a map"));
|
||||||
|
};
|
||||||
|
let region = value_string(&root, "region").unwrap_or_else(|| "Fake Region".into());
|
||||||
|
let agent_position = root
|
||||||
|
.get("agent_position")
|
||||||
|
.and_then(|value| value.as_vector3().ok())
|
||||||
|
.ok_or(ProgramError::InvalidFixture(
|
||||||
|
"agent_position must be a three-number array",
|
||||||
|
))?;
|
||||||
|
let Some(OSD::Array(values)) = root.get("objects") else {
|
||||||
|
return Err(ProgramError::InvalidFixture("objects must be an array"));
|
||||||
|
};
|
||||||
|
if values.len() > MAX_OBJECTS {
|
||||||
|
return Err(ProgramError::InvalidFixture("too many objects"));
|
||||||
|
}
|
||||||
|
let mut objects = Vec::with_capacity(values.len());
|
||||||
|
for value in values {
|
||||||
|
let OSD::Map(map) = value else {
|
||||||
|
return Err(ProgramError::InvalidFixture("each object must be a map"));
|
||||||
|
};
|
||||||
|
let mut primitive = Primitive::from_osd(value.clone())
|
||||||
|
.map_err(|_| ProgramError::InvalidFixture("invalid primitive data"))?;
|
||||||
|
if primitive.id == UUID::zero() || primitive.local_id == 0 {
|
||||||
|
return Err(ProgramError::InvalidFixture(
|
||||||
|
"each object needs non-zero id and localid",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
primitive.text = value_string(map, "hover_text").unwrap_or_default();
|
||||||
|
let mut properties = primitive.properties.take().unwrap_or_default();
|
||||||
|
properties.object_id = primitive.id;
|
||||||
|
properties.owner_id = value_uuid(map, "owner").unwrap_or_default();
|
||||||
|
properties.creator_id = value_uuid(map, "creator").unwrap_or_default();
|
||||||
|
properties.sale_type = parse_sale_type(value_string(map, "sale_type").as_deref())?;
|
||||||
|
properties.sale_price = map
|
||||||
|
.get("sale_price")
|
||||||
|
.and_then(|value| value.as_integer().ok())
|
||||||
|
.unwrap_or_default();
|
||||||
|
let state = match value_string(map, "property_state")
|
||||||
|
.as_deref()
|
||||||
|
.unwrap_or("ready")
|
||||||
|
{
|
||||||
|
"ready" => PropertyState::Ready,
|
||||||
|
"delayed" => PropertyState::Delayed,
|
||||||
|
"missing" => PropertyState::Missing,
|
||||||
|
_ => {
|
||||||
|
return Err(ProgramError::InvalidFixture(
|
||||||
|
"property_state must be ready, delayed, or missing",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let (current, pending) = match state {
|
||||||
|
PropertyState::Ready => (Some(properties), None),
|
||||||
|
PropertyState::Delayed => (None, Some(properties)),
|
||||||
|
PropertyState::Missing => (None, None),
|
||||||
|
};
|
||||||
|
primitive.properties = current;
|
||||||
|
objects.push(FixtureObject {
|
||||||
|
primitive,
|
||||||
|
pending_properties: pending,
|
||||||
|
state,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(Fixture {
|
||||||
|
region,
|
||||||
|
agent_position,
|
||||||
|
objects,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn value_string(map: &HashMap<String, OSD>, key: &str) -> Option<String> {
|
||||||
|
map.get(key).and_then(|value| value.as_string().ok())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn value_uuid(map: &HashMap<String, OSD>, key: &str) -> Option<UUID> {
|
||||||
|
map.get(key)
|
||||||
|
.and_then(|value| value.as_uuid().ok())
|
||||||
|
.filter(|value| *value != UUID::zero())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_sale_type(value: Option<&str>) -> Result<SaleType, ProgramError> {
|
||||||
|
match value.unwrap_or("not").to_ascii_lowercase().as_str() {
|
||||||
|
"not" | "none" => Ok(SaleType::Not),
|
||||||
|
"original" => Ok(SaleType::Original),
|
||||||
|
"copy" => Ok(SaleType::Copy),
|
||||||
|
"contents" => Ok(SaleType::Contents),
|
||||||
|
_ => Err(ProgramError::InvalidFixture("unknown sale_type")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn inspect(
|
||||||
|
primitives: Vec<Primitive>,
|
||||||
|
agent_position: Vector3,
|
||||||
|
search: Option<&str>,
|
||||||
|
limit: usize,
|
||||||
|
output: &mut BoundedOutput,
|
||||||
|
) -> Result<(), ProgramError> {
|
||||||
|
let needle = search.map(str::to_lowercase);
|
||||||
|
let mut matches = primitives
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|primitive| {
|
||||||
|
let name = primitive
|
||||||
|
.properties
|
||||||
|
.as_ref()
|
||||||
|
.map_or("Unknown", |properties| properties.name.as_str());
|
||||||
|
if needle
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|needle| !name.to_lowercase().contains(needle))
|
||||||
|
{
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let distance = Vector3::distance(primitive.position, agent_position).ok()?;
|
||||||
|
Some((distance, primitive))
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
matches.sort_by(|(left_distance, left), (right_distance, right)| {
|
||||||
|
left_distance
|
||||||
|
.total_cmp(right_distance)
|
||||||
|
.then_with(|| left.local_id.cmp(&right.local_id))
|
||||||
|
.then_with(|| left.id.to_string().cmp(&right.id.to_string()))
|
||||||
|
});
|
||||||
|
if matches.is_empty() {
|
||||||
|
if let Some(search) = search {
|
||||||
|
output.line(format!("No objects matching '{search}'"))?;
|
||||||
|
} else {
|
||||||
|
output.line("No primitives found")?;
|
||||||
|
}
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
output.line(format!(
|
||||||
|
"Inspecting {} nearest matching primitives",
|
||||||
|
matches.len().min(limit)
|
||||||
|
))?;
|
||||||
|
for (distance, primitive) in matches.into_iter().take(limit) {
|
||||||
|
format_primitive(&primitive, distance, output)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format_primitive(
|
||||||
|
primitive: &Primitive,
|
||||||
|
distance: f32,
|
||||||
|
output: &mut BoundedOutput,
|
||||||
|
) -> Result<(), ProgramError> {
|
||||||
|
let name = primitive
|
||||||
|
.properties
|
||||||
|
.as_ref()
|
||||||
|
.map(|properties| properties.name.as_str())
|
||||||
|
.filter(|name| !name.is_empty())
|
||||||
|
.unwrap_or("Unknown");
|
||||||
|
output.line("")?;
|
||||||
|
output.line(format!("Object: {name} (ID: {})", primitive.id))?;
|
||||||
|
output.line(format!(" Local ID: {}", primitive.local_id))?;
|
||||||
|
output.line(format!(" Type: {:?}", primitive.type_()))?;
|
||||||
|
output.line(format!(" Distance: {distance:.2} m"))?;
|
||||||
|
output.line(format!(" Position: {}", vector3(primitive.position)))?;
|
||||||
|
output.line(format!(
|
||||||
|
" Rotation: <{:.3}, {:.3}, {:.3}, {:.3}>",
|
||||||
|
primitive.rotation.x, primitive.rotation.y, primitive.rotation.z, primitive.rotation.w
|
||||||
|
))?;
|
||||||
|
output.line(format!(" Scale: {}", vector3(primitive.scale)))?;
|
||||||
|
if primitive.parent_id != 0 {
|
||||||
|
output.line(format!(" Parent ID: {}", primitive.parent_id))?;
|
||||||
|
}
|
||||||
|
output.line(format!(" Flags: {:?}", primitive.flags))?;
|
||||||
|
output.line(format!(" Material: {:?}", primitive.prim_data.material))?;
|
||||||
|
output.line(format!(" PCode: {:?}", primitive.prim_data.p_code))?;
|
||||||
|
output.line(format!(
|
||||||
|
" Profile: {:?}",
|
||||||
|
primitive.prim_data.profile_curve_with_property()
|
||||||
|
))?;
|
||||||
|
output.line(format!(" Path: {:?}", primitive.prim_data.path_curve))?;
|
||||||
|
if let Some(sculpt) = &primitive.sculpt {
|
||||||
|
output.line(format!(" Sculpt Type: {:?}", sculpt.type_()))?;
|
||||||
|
output.line(format!(" Sculpt Texture: {}", sculpt.sculpt_texture))?;
|
||||||
|
}
|
||||||
|
if let Some(light) = &primitive.light {
|
||||||
|
output.line(format!(
|
||||||
|
" Light Color: <{:.3}, {:.3}, {:.3}, {:.3}>",
|
||||||
|
light.color.r, light.color.g, light.color.b, light.color.a
|
||||||
|
))?;
|
||||||
|
output.line(format!(" Light Intensity: {:.3}", light.intensity))?;
|
||||||
|
output.line(format!(" Light Radius: {:.3}", light.radius))?;
|
||||||
|
}
|
||||||
|
if let Some(flexible) = &primitive.flexible {
|
||||||
|
output.line(format!(" Flexible Softness: {}", flexible.softness))?;
|
||||||
|
output.line(format!(" Flexible Gravity: {:.3}", flexible.gravity))?;
|
||||||
|
}
|
||||||
|
if let Some(properties) = &primitive.properties {
|
||||||
|
output.line(format!(" Owner: {}", properties.owner_id))?;
|
||||||
|
output.line(format!(" Creator: {}", properties.creator_id))?;
|
||||||
|
output.line(format!(" Description: {}", properties.description))?;
|
||||||
|
if properties.sale_type != SaleType::Not {
|
||||||
|
output.line(format!(
|
||||||
|
" For Sale: {:?} for L${}",
|
||||||
|
properties.sale_type, properties.sale_price
|
||||||
|
))?;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
output.line(" Properties: unavailable")?;
|
||||||
|
}
|
||||||
|
if !primitive.text.is_empty() {
|
||||||
|
output.line(format!(" Hover Text: {}", primitive.text))?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sanitize(value: &str) -> String {
|
||||||
|
let value = value.replace(['\r', '\n', '\t'], " ");
|
||||||
|
value
|
||||||
|
.split_whitespace()
|
||||||
|
.map(|word| {
|
||||||
|
let lower = word.to_ascii_lowercase();
|
||||||
|
if lower.starts_with("http://")
|
||||||
|
|| lower.starts_with("https://")
|
||||||
|
|| lower.contains("password=")
|
||||||
|
|| lower.contains("token=")
|
||||||
|
{
|
||||||
|
"[redacted]"
|
||||||
|
} else {
|
||||||
|
word
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(" ")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn vector3(value: Vector3) -> String {
|
||||||
|
format!("<{:.3}, {:.3}, {:.3}>", value.x, value.y, value.z)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sanitize_removes_urls_and_secret_parameters() {
|
||||||
|
assert_eq!(
|
||||||
|
sanitize("see https://grid.invalid/?token=secret now"),
|
||||||
|
"see [redacted] now"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sale_type_parser_accepts_documented_values() {
|
||||||
|
assert_eq!(parse_sale_type(Some("COPY")).unwrap(), SaleType::Copy);
|
||||||
|
assert!(parse_sale_type(Some("auction")).is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
260
programs/tests/prim_inspector_cli.rs
Normal file
260
programs/tests/prim_inspector_cli.rs
Normal file
@@ -0,0 +1,260 @@
|
|||||||
|
use std::fs;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::process::{Command, Output, Stdio};
|
||||||
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
|
||||||
|
const EXIT_USAGE: i32 = 2;
|
||||||
|
const EXIT_INPUT: i32 = 3;
|
||||||
|
const EXIT_OUTPUT: i32 = 5;
|
||||||
|
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-prim-inspector-{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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for TestDir {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
let _ = fs::remove_dir_all(&self.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run(args: &[&str]) -> Output {
|
||||||
|
Command::new(env!("CARGO_BIN_EXE_prim-inspector"))
|
||||||
|
.args(args)
|
||||||
|
.stdin(Stdio::null())
|
||||||
|
.stdout(Stdio::piped())
|
||||||
|
.stderr(Stdio::piped())
|
||||||
|
.output()
|
||||||
|
.expect("run prim-inspector")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn text(bytes: &[u8]) -> &str {
|
||||||
|
std::str::from_utf8(bytes).expect("UTF-8 output")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn path_text(path: &Path) -> &str {
|
||||||
|
path.to_str().expect("UTF-8 path")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn assert_exit(output: &Output, expected: i32) {
|
||||||
|
assert_eq!(
|
||||||
|
output.status.code(),
|
||||||
|
Some(expected),
|
||||||
|
"stdout:\n{}\nstderr:\n{}",
|
||||||
|
String::from_utf8_lossy(&output.stdout),
|
||||||
|
String::from_utf8_lossy(&output.stderr)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fixture(directory: &TestDir) -> PathBuf {
|
||||||
|
let path = directory.path("objects.json");
|
||||||
|
fs::write(
|
||||||
|
&path,
|
||||||
|
r#"{
|
||||||
|
"region":"Fixture Region",
|
||||||
|
"agent_position":[0,0,0],
|
||||||
|
"objects":[
|
||||||
|
{
|
||||||
|
"id":"11111111-1111-1111-1111-111111111111",
|
||||||
|
"localid":30,
|
||||||
|
"name":"Far Lamp",
|
||||||
|
"description":"A bright fixture",
|
||||||
|
"position":[6,8,0],
|
||||||
|
"rotation":[0,0,0,1],
|
||||||
|
"scale":[1,2,3],
|
||||||
|
"parentid":7,
|
||||||
|
"material":3,
|
||||||
|
"pcode":9,
|
||||||
|
"physical":true,
|
||||||
|
"light":{"color":[0.2,0.4,0.6,1],"intensity":0.75,"radius":12,"cutoff":0.5,"falloff":0.25},
|
||||||
|
"flex":{"simulate_lod":2,"gravity":-0.5,"air_friction":1,"wind_sensitivity":0.2,"tension":0.8,"user_force":[0,0,0]},
|
||||||
|
"owner":"aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
|
||||||
|
"creator":"bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
|
||||||
|
"sale_type":"copy",
|
||||||
|
"sale_price":25,
|
||||||
|
"hover_text":"Welcome visitor",
|
||||||
|
"property_state":"ready"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id":"22222222-2222-2222-2222-222222222222",
|
||||||
|
"localid":20,
|
||||||
|
"name":"Near Lamp",
|
||||||
|
"description":"Hydrated later",
|
||||||
|
"position":[3,4,0],
|
||||||
|
"rotation":[0,0,0,1],
|
||||||
|
"scale":[1,1,1],
|
||||||
|
"sculpt":{"texture":"cccccccc-cccc-cccc-cccc-cccccccccccc","type":5},
|
||||||
|
"owner":"dddddddd-dddd-dddd-dddd-dddddddddddd",
|
||||||
|
"creator":"eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee",
|
||||||
|
"property_state":"delayed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id":"33333333-3333-3333-3333-333333333333",
|
||||||
|
"localid":10,
|
||||||
|
"name":"Lost Lamp",
|
||||||
|
"position":[1,0,0],
|
||||||
|
"rotation":[0,0,0,1],
|
||||||
|
"scale":[1,1,1],
|
||||||
|
"property_state":"missing"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id":"44444444-4444-4444-4444-444444444444",
|
||||||
|
"localid":40,
|
||||||
|
"name":"Chair",
|
||||||
|
"position":[-1,0,0],
|
||||||
|
"rotation":[0,0,0,1],
|
||||||
|
"scale":[1,1,1]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}"#,
|
||||||
|
)
|
||||||
|
.expect("write fixture");
|
||||||
|
path
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn help_preserves_upstream_arguments_and_documents_bounded_controls() {
|
||||||
|
let output = run(&["--help"]);
|
||||||
|
assert!(output.status.success());
|
||||||
|
let help = text(&output.stdout);
|
||||||
|
for marker in [
|
||||||
|
"[FIRSTNAME]",
|
||||||
|
"[LASTNAME]",
|
||||||
|
"[PASSWORD]",
|
||||||
|
"[SEARCH_TERM]",
|
||||||
|
"--fake-grid",
|
||||||
|
"--search",
|
||||||
|
"--limit",
|
||||||
|
"--discovery-delay-ms",
|
||||||
|
"--property-timeout-ms",
|
||||||
|
"--max-output-bytes",
|
||||||
|
] {
|
||||||
|
assert!(help.contains(marker), "help omitted {marker}:\n{help}");
|
||||||
|
}
|
||||||
|
|
||||||
|
let output = run(&[]);
|
||||||
|
assert_exit(&output, EXIT_USAGE);
|
||||||
|
assert!(text(&output.stderr).contains("Usage: prim-inspector"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fake_grid_golden_output_hydrates_orders_filters_and_formats_details() {
|
||||||
|
let directory = TestDir::new("golden");
|
||||||
|
let fixture = fixture(&directory);
|
||||||
|
let output = run(&[
|
||||||
|
"--fake-grid",
|
||||||
|
path_text(&fixture),
|
||||||
|
"--search",
|
||||||
|
"Lamp",
|
||||||
|
"--limit",
|
||||||
|
"2",
|
||||||
|
"--property-timeout-ms",
|
||||||
|
"17",
|
||||||
|
]);
|
||||||
|
assert!(output.status.success(), "{}", text(&output.stderr));
|
||||||
|
assert!(output.stderr.is_empty());
|
||||||
|
let output = text(&output.stdout);
|
||||||
|
|
||||||
|
for expected in [
|
||||||
|
"Fake grid connected",
|
||||||
|
"Region: Fixture Region",
|
||||||
|
"Agent position: <0.000, 0.000, 0.000>",
|
||||||
|
"Found 4 primitives",
|
||||||
|
"CALL select-object local_id=20",
|
||||||
|
"PROPERTY received local_id=20",
|
||||||
|
"CALL select-object local_id=10",
|
||||||
|
"Property timeout after 17 ms: 1 primitives remain unknown",
|
||||||
|
"Inspecting 2 nearest matching primitives",
|
||||||
|
"Object: Near Lamp (ID: 22222222-2222-2222-2222-222222222222)",
|
||||||
|
"Distance: 5.00 m",
|
||||||
|
"Sculpt Type: Mesh",
|
||||||
|
"Sculpt Texture: cccccccc-cccc-cccc-cccc-cccccccccccc",
|
||||||
|
"Owner: dddddddd-dddd-dddd-dddd-dddddddddddd",
|
||||||
|
"Description: Hydrated later",
|
||||||
|
"Object: Far Lamp (ID: 11111111-1111-1111-1111-111111111111)",
|
||||||
|
"Distance: 10.00 m",
|
||||||
|
"Parent ID: 7",
|
||||||
|
"Material:",
|
||||||
|
"PCode: Prim",
|
||||||
|
"Light Intensity: 0.750",
|
||||||
|
"Light Radius: 12.000",
|
||||||
|
"Flexible Softness: 2",
|
||||||
|
"Flexible Gravity: -0.500",
|
||||||
|
"For Sale: Copy for L$25",
|
||||||
|
"Hover Text: Welcome visitor",
|
||||||
|
"Fake grid logout complete; active_tasks=0 open_sockets=0",
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
output.contains(expected),
|
||||||
|
"output omitted {expected}:\n{output}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert!(output.find("Object: Near Lamp").unwrap() < output.find("Object: Far Lamp").unwrap());
|
||||||
|
assert!(!output.contains("Object: Chair"));
|
||||||
|
assert!(!output.contains("Object: Lost Lamp"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn nearest_limit_has_deterministic_distance_and_local_id_tie_breaking() {
|
||||||
|
let directory = TestDir::new("ordering");
|
||||||
|
let fixture = fixture(&directory);
|
||||||
|
let output = run(&["--fake-grid", path_text(&fixture), "--limit", "3"]);
|
||||||
|
assert!(output.status.success(), "{}", text(&output.stderr));
|
||||||
|
let output = text(&output.stdout);
|
||||||
|
let lost = output.find("Object: Unknown").unwrap();
|
||||||
|
let chair = output.find("Object: Chair").unwrap();
|
||||||
|
let near = output.find("Object: Near Lamp").unwrap();
|
||||||
|
assert!(lost < chair && chair < near, "unexpected order:\n{output}");
|
||||||
|
assert!(!output.contains("Object: Far Lamp"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn malformed_limits_and_live_credentials_fail_without_partial_or_secret_output() {
|
||||||
|
let directory = TestDir::new("invalid");
|
||||||
|
let malformed = directory.path("malformed.json");
|
||||||
|
fs::write(&malformed, "{not-json}").expect("write malformed fixture");
|
||||||
|
let output = run(&["--fake-grid", path_text(&malformed)]);
|
||||||
|
assert_exit(&output, EXIT_INPUT);
|
||||||
|
assert!(output.stdout.is_empty());
|
||||||
|
assert!(text(&output.stderr).contains("could not parse LLSD JSON"));
|
||||||
|
|
||||||
|
let fixture = fixture(&directory);
|
||||||
|
let output = run(&[
|
||||||
|
"--fake-grid",
|
||||||
|
path_text(&fixture),
|
||||||
|
"--max-output-bytes",
|
||||||
|
"10",
|
||||||
|
]);
|
||||||
|
assert_exit(&output, EXIT_OUTPUT);
|
||||||
|
assert!(output.stdout.is_empty());
|
||||||
|
assert!(text(&output.stderr).contains("output byte limit"));
|
||||||
|
|
||||||
|
let password = "do-not-print-this-password";
|
||||||
|
let output = run(&[
|
||||||
|
"First",
|
||||||
|
"Last",
|
||||||
|
password,
|
||||||
|
"--fake-grid",
|
||||||
|
path_text(&fixture),
|
||||||
|
]);
|
||||||
|
assert_exit(&output, EXIT_USAGE);
|
||||||
|
assert!(!text(&output.stdout).contains(password));
|
||||||
|
assert!(!text(&output.stderr).contains(password));
|
||||||
|
|
||||||
|
let output = run(&["--fake-grid", path_text(&fixture), "--limit", "0"]);
|
||||||
|
assert_exit(&output, EXIT_USAGE);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user