forked from Crockan/MercuryToolbox
1160 lines
39 KiB
Rust
1160 lines
39 KiB
Rust
//! The `unityprobe` command talks to a read-only Unity bridge.
|
|
#![allow(clippy::multiple_crate_versions)]
|
|
|
|
use std::cmp::Ordering;
|
|
use std::ffi::OsString;
|
|
use std::fmt::Write as _;
|
|
use std::path::PathBuf;
|
|
|
|
use common::{
|
|
CliError, CommonArgs, ExitCode, RenderMode, expand_input_patterns, map_result_count,
|
|
parse_color_choice, parse_format_choice, print_json, print_quick_help_error, print_structured,
|
|
print_text, require_exactly_one_input_path,
|
|
};
|
|
use lexopt::prelude::{Long, Short, Value as ArgValue};
|
|
use serde::Serialize;
|
|
use unitysupport::probe::ObjectRecord;
|
|
use unitysupport::{
|
|
BRIDGE_PIPE_NAME, FindData, InspectData, InstallReport, ScenesData, StaticData, StatusReport,
|
|
UninstallReport, find_objects, inspect_object, inspect_static, install_bridge, query_scenes,
|
|
status_bridge, uninstall_bridge,
|
|
};
|
|
|
|
const HELP: &str = "\
|
|
Inspect a running BepInEx Mono Unity game through an explicit read-only bridge.
|
|
|
|
Windows only: install the bridge into BepInEx\\plugins, then query it over a named pipe JSON protocol.
|
|
No implicit injection is performed.
|
|
|
|
Usage:
|
|
unityprobe [OPTIONS] <SUBCOMMAND> [ARGS...]
|
|
|
|
Subcommands:
|
|
install <PATH> Compile the shared bridge source with local csc.exe and install it into BepInEx\\plugins
|
|
uninstall <PATH> Remove the explicit bridge install from BepInEx\\plugins
|
|
status Report install state and named pipe status
|
|
scenes List loaded scenes from the running bridge
|
|
find <QUERY> Find runtime objects by type or name
|
|
inspect <INSTANCE_ID> Inspect one runtime object by Unity instance id
|
|
static <TYPE_NAME> Inspect static fields and properties for a managed type
|
|
|
|
Options:
|
|
--format <FORMAT> Structured output format: text, json, toon
|
|
--json Shortcut for --format json
|
|
--toon Shortcut for --format toon
|
|
--color <WHEN> Control ANSI color output: auto, never
|
|
--quiet Suppress non-essential status output
|
|
-h, --help Show this help text
|
|
-V, --version Show the command version
|
|
|
|
Subcommand options:
|
|
status --game-root <PATH> Inspect explicit install state for one game root
|
|
find --limit <COUNT> Maximum runtime matches to return (default: 25)
|
|
find --type-only Keep only managed type matches and rank type hits ahead of name hits
|
|
|
|
Examples:
|
|
unityprobe install 'C:\\game'
|
|
unityprobe status --game-root 'C:\\game'
|
|
unityprobe --json scenes | ConvertFrom-Json
|
|
unityprobe --json find 'Game\\.UI\\.Windows' | ConvertFrom-Json
|
|
unityprobe find GameManager --type-only
|
|
unityprobe inspect 10432
|
|
unityprobe inspect -- -938
|
|
unityprobe static Manager.GameManager
|
|
";
|
|
|
|
#[derive(Debug, Clone)]
|
|
struct Cli {
|
|
common: CommonArgs,
|
|
command: Command,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
enum Command {
|
|
Install {
|
|
game_root: PathBuf,
|
|
},
|
|
Uninstall {
|
|
game_root: PathBuf,
|
|
},
|
|
Status {
|
|
game_root: Option<PathBuf>,
|
|
},
|
|
Scenes,
|
|
Find {
|
|
query: String,
|
|
limit: usize,
|
|
type_only: bool,
|
|
},
|
|
Inspect {
|
|
instance_id: i32,
|
|
},
|
|
Static {
|
|
type_name: String,
|
|
},
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
enum ParseOutcome {
|
|
Help,
|
|
Version,
|
|
Run,
|
|
}
|
|
|
|
/// Parses CLI arguments and returns a process exit code.
|
|
#[must_use]
|
|
pub fn main_entry() -> i32 {
|
|
match parse_cli_from(std::env::args_os()) {
|
|
Ok((ParseOutcome::Help, _)) => {
|
|
print!("{HELP}");
|
|
ExitCode::Success.as_i32()
|
|
}
|
|
Ok((ParseOutcome::Version, _)) => {
|
|
println!("unityprobe {}", env!("CARGO_PKG_VERSION"));
|
|
ExitCode::Success.as_i32()
|
|
}
|
|
Ok((ParseOutcome::Run, cli)) => match run(&cli) {
|
|
Ok(code) => code.as_i32(),
|
|
Err(error) => {
|
|
print_quick_help_error(&error, HELP);
|
|
error.exit_code().as_i32()
|
|
}
|
|
},
|
|
Err(error) => {
|
|
print_quick_help_error(&error, HELP);
|
|
error.exit_code().as_i32()
|
|
}
|
|
}
|
|
}
|
|
|
|
fn parse_cli_from<I, T>(args: I) -> Result<(ParseOutcome, Cli), CliError>
|
|
where
|
|
I: IntoIterator<Item = T>,
|
|
T: Into<OsString>,
|
|
{
|
|
let mut parser = lexopt::Parser::from_iter(args);
|
|
let mut common = CommonArgs::default();
|
|
|
|
while let Some(argument) = parser
|
|
.next()
|
|
.map_err(|error| CliError::usage(error.to_string()))?
|
|
{
|
|
match argument {
|
|
Long("help") | Short('h') => {
|
|
return Ok((
|
|
ParseOutcome::Help,
|
|
Cli {
|
|
common,
|
|
command: Command::Status { game_root: None },
|
|
},
|
|
));
|
|
}
|
|
Long("version") | Short('V') => {
|
|
return Ok((
|
|
ParseOutcome::Version,
|
|
Cli {
|
|
common,
|
|
command: Command::Status { game_root: None },
|
|
},
|
|
));
|
|
}
|
|
Long("json") => common.set_render_mode(RenderMode::Json),
|
|
Long("toon") => common.set_render_mode(RenderMode::Toon),
|
|
Long("format") => {
|
|
let value = parser_value_string(&mut parser, "--format")?;
|
|
common.set_render_mode(parse_format_choice(&value)?);
|
|
}
|
|
Long("quiet") => common.quiet = true,
|
|
Long("color") => {
|
|
common.color = parse_color_choice(&parser_value_string(&mut parser, "--color")?)?;
|
|
}
|
|
ArgValue(value) => {
|
|
let subcommand = os_string_to_string(value, "subcommand")?;
|
|
let command = parse_subcommand(&subcommand, &mut parser, &mut common)?;
|
|
return Ok((ParseOutcome::Run, Cli { common, command }));
|
|
}
|
|
_ => {
|
|
return Err(CliError::usage(
|
|
"unsupported argument; use --help to see available options",
|
|
));
|
|
}
|
|
}
|
|
}
|
|
|
|
Err(CliError::usage(
|
|
"unityprobe requires a subcommand; use --help to see available options",
|
|
))
|
|
}
|
|
|
|
fn parse_subcommand(
|
|
subcommand: &str,
|
|
parser: &mut lexopt::Parser,
|
|
common: &mut CommonArgs,
|
|
) -> Result<Command, CliError> {
|
|
match subcommand {
|
|
"install" => parse_game_root_command(parser, common, "install")
|
|
.map(|game_root| Command::Install { game_root }),
|
|
"uninstall" => parse_game_root_command(parser, common, "uninstall")
|
|
.map(|game_root| Command::Uninstall { game_root }),
|
|
"status" => parse_status_command(parser, common),
|
|
"scenes" => parse_scenes_command(parser, common),
|
|
"find" => parse_find_command(parser, common),
|
|
"inspect" => parse_inspect_command(parser, common),
|
|
"static" => parse_static_command(parser, common),
|
|
_ => Err(CliError::usage(
|
|
"unsupported subcommand; use --help to see available options",
|
|
)),
|
|
}
|
|
}
|
|
|
|
fn parse_game_root_command(
|
|
parser: &mut lexopt::Parser,
|
|
common: &mut CommonArgs,
|
|
name: &str,
|
|
) -> Result<PathBuf, CliError> {
|
|
let mut game_root = None;
|
|
while let Some(argument) = parser
|
|
.next()
|
|
.map_err(|error| CliError::usage(error.to_string()))?
|
|
{
|
|
match argument {
|
|
Long("json") => common.set_render_mode(RenderMode::Json),
|
|
Long("toon") => common.set_render_mode(RenderMode::Toon),
|
|
Long("format") => {
|
|
let value = parser_value_string(parser, "--format")?;
|
|
common.set_render_mode(parse_format_choice(&value)?);
|
|
}
|
|
Long("quiet") => common.quiet = true,
|
|
Long("color") => {
|
|
common.color = parse_color_choice(&parser_value_string(parser, "--color")?)?;
|
|
}
|
|
Long("game-root") => {
|
|
game_root = Some(parser_value_path(parser, "--game-root")?);
|
|
}
|
|
ArgValue(value) => {
|
|
if game_root.is_some() {
|
|
return Err(CliError::usage(format!(
|
|
"{name} accepts only one game root path"
|
|
)));
|
|
}
|
|
game_root = Some(PathBuf::from(value));
|
|
}
|
|
_ => {
|
|
return Err(CliError::usage(format!(
|
|
"unsupported {name} argument; use --help to see available options"
|
|
)));
|
|
}
|
|
}
|
|
}
|
|
|
|
game_root.ok_or_else(|| CliError::usage(format!("{name} requires a game root path")))
|
|
}
|
|
|
|
fn parse_status_command(
|
|
parser: &mut lexopt::Parser,
|
|
common: &mut CommonArgs,
|
|
) -> Result<Command, CliError> {
|
|
let mut game_root = None;
|
|
while let Some(argument) = parser
|
|
.next()
|
|
.map_err(|error| CliError::usage(error.to_string()))?
|
|
{
|
|
match argument {
|
|
Long("json") => common.set_render_mode(RenderMode::Json),
|
|
Long("toon") => common.set_render_mode(RenderMode::Toon),
|
|
Long("format") => {
|
|
let value = parser_value_string(parser, "--format")?;
|
|
common.set_render_mode(parse_format_choice(&value)?);
|
|
}
|
|
Long("quiet") => common.quiet = true,
|
|
Long("color") => {
|
|
common.color = parse_color_choice(&parser_value_string(parser, "--color")?)?;
|
|
}
|
|
Long("game-root") => game_root = Some(parser_value_path(parser, "--game-root")?),
|
|
_ => {
|
|
return Err(CliError::usage(
|
|
"unsupported status argument; use --help to see available options",
|
|
));
|
|
}
|
|
}
|
|
}
|
|
Ok(Command::Status { game_root })
|
|
}
|
|
|
|
fn parse_scenes_command(
|
|
parser: &mut lexopt::Parser,
|
|
common: &mut CommonArgs,
|
|
) -> Result<Command, CliError> {
|
|
while let Some(argument) = parser
|
|
.next()
|
|
.map_err(|error| CliError::usage(error.to_string()))?
|
|
{
|
|
match argument {
|
|
Long("json") => common.set_render_mode(RenderMode::Json),
|
|
Long("toon") => common.set_render_mode(RenderMode::Toon),
|
|
Long("format") => {
|
|
let value = parser_value_string(parser, "--format")?;
|
|
common.set_render_mode(parse_format_choice(&value)?);
|
|
}
|
|
Long("quiet") => common.quiet = true,
|
|
Long("color") => {
|
|
common.color = parse_color_choice(&parser_value_string(parser, "--color")?)?;
|
|
}
|
|
_ => {
|
|
return Err(CliError::usage(
|
|
"scenes does not accept positional arguments",
|
|
));
|
|
}
|
|
}
|
|
}
|
|
Ok(Command::Scenes)
|
|
}
|
|
|
|
fn parse_find_command(
|
|
parser: &mut lexopt::Parser,
|
|
common: &mut CommonArgs,
|
|
) -> Result<Command, CliError> {
|
|
let mut query = None;
|
|
let mut limit = 25_usize;
|
|
let mut type_only = false;
|
|
while let Some(argument) = parser
|
|
.next()
|
|
.map_err(|error| CliError::usage(error.to_string()))?
|
|
{
|
|
match argument {
|
|
Long("json") => common.set_render_mode(RenderMode::Json),
|
|
Long("toon") => common.set_render_mode(RenderMode::Toon),
|
|
Long("format") => {
|
|
let value = parser_value_string(parser, "--format")?;
|
|
common.set_render_mode(parse_format_choice(&value)?);
|
|
}
|
|
Long("quiet") => common.quiet = true,
|
|
Long("color") => {
|
|
common.color = parse_color_choice(&parser_value_string(parser, "--color")?)?;
|
|
}
|
|
Long("limit") => {
|
|
limit = parse_usize_flag("--limit", &parser_value_string(parser, "--limit")?)?;
|
|
}
|
|
Long("type-only") => type_only = true,
|
|
ArgValue(value) => {
|
|
if query.is_some() {
|
|
return Err(CliError::usage("find accepts only one query string"));
|
|
}
|
|
query = Some(os_string_to_string(value, "find query")?);
|
|
}
|
|
_ => {
|
|
return Err(CliError::usage(
|
|
"unsupported find argument; use --help to see available options",
|
|
));
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(Command::Find {
|
|
query: query.ok_or_else(|| CliError::usage("find requires a query string"))?,
|
|
limit,
|
|
type_only,
|
|
})
|
|
}
|
|
|
|
fn parse_inspect_command(
|
|
parser: &mut lexopt::Parser,
|
|
common: &mut CommonArgs,
|
|
) -> Result<Command, CliError> {
|
|
let mut instance_id = None;
|
|
while let Some(argument) = parser
|
|
.next()
|
|
.map_err(|error| CliError::usage(error.to_string()))?
|
|
{
|
|
match argument {
|
|
Long("json") => common.set_render_mode(RenderMode::Json),
|
|
Long("toon") => common.set_render_mode(RenderMode::Toon),
|
|
Long("format") => {
|
|
let value = parser_value_string(parser, "--format")?;
|
|
common.set_render_mode(parse_format_choice(&value)?);
|
|
}
|
|
Long("quiet") => common.quiet = true,
|
|
Long("color") => {
|
|
common.color = parse_color_choice(&parser_value_string(parser, "--color")?)?;
|
|
}
|
|
ArgValue(value) => {
|
|
if instance_id.is_some() {
|
|
return Err(CliError::usage("inspect accepts only one instance id"));
|
|
}
|
|
instance_id = Some(os_string_to_string(value, "inspect instance id")?);
|
|
}
|
|
_ => {
|
|
return Err(CliError::usage(
|
|
"unsupported inspect argument; use --help to see available options",
|
|
));
|
|
}
|
|
}
|
|
}
|
|
let text = instance_id.ok_or_else(|| CliError::usage("inspect requires an instance id"))?;
|
|
Ok(Command::Inspect {
|
|
instance_id: text.parse::<i32>().map_err(|error| {
|
|
CliError::usage(format!("inspect expects an integer instance id: {error}"))
|
|
})?,
|
|
})
|
|
}
|
|
|
|
fn parse_static_command(
|
|
parser: &mut lexopt::Parser,
|
|
common: &mut CommonArgs,
|
|
) -> Result<Command, CliError> {
|
|
let mut type_name = None;
|
|
while let Some(argument) = parser
|
|
.next()
|
|
.map_err(|error| CliError::usage(error.to_string()))?
|
|
{
|
|
match argument {
|
|
Long("json") => common.set_render_mode(RenderMode::Json),
|
|
Long("toon") => common.set_render_mode(RenderMode::Toon),
|
|
Long("format") => {
|
|
let value = parser_value_string(parser, "--format")?;
|
|
common.set_render_mode(parse_format_choice(&value)?);
|
|
}
|
|
Long("quiet") => common.quiet = true,
|
|
Long("color") => {
|
|
common.color = parse_color_choice(&parser_value_string(parser, "--color")?)?;
|
|
}
|
|
ArgValue(value) => {
|
|
if type_name.is_some() {
|
|
return Err(CliError::usage("static accepts only one type name"));
|
|
}
|
|
type_name = Some(os_string_to_string(value, "static type name")?);
|
|
}
|
|
_ => {
|
|
return Err(CliError::usage(
|
|
"unsupported static argument; use --help to see available options",
|
|
));
|
|
}
|
|
}
|
|
}
|
|
Ok(Command::Static {
|
|
type_name: type_name
|
|
.ok_or_else(|| CliError::usage("static requires a managed type name"))?,
|
|
})
|
|
}
|
|
|
|
fn parser_value_string(parser: &mut lexopt::Parser, flag: &str) -> Result<String, CliError> {
|
|
let value = parser
|
|
.value()
|
|
.map_err(|error| CliError::usage(error.to_string()))?;
|
|
os_string_to_string(value, flag)
|
|
}
|
|
|
|
fn parser_value_path(parser: &mut lexopt::Parser, flag: &str) -> Result<PathBuf, CliError> {
|
|
let value = parser
|
|
.value()
|
|
.map_err(|error| CliError::usage(error.to_string()))?;
|
|
if value.is_empty() {
|
|
Err(CliError::usage(format!("{flag} requires a path value")))
|
|
} else {
|
|
require_exactly_one_input_path(
|
|
&expand_input_patterns(&[PathBuf::from(value)], "unityprobe")?,
|
|
"unityprobe",
|
|
)
|
|
}
|
|
}
|
|
|
|
fn os_string_to_string(value: OsString, label: &str) -> Result<String, CliError> {
|
|
value.into_string().map_err(|invalid| {
|
|
CliError::usage(format!(
|
|
"{label} expects UTF-8 text, got '{}'",
|
|
invalid.to_string_lossy()
|
|
))
|
|
})
|
|
}
|
|
|
|
fn parse_usize_flag(flag: &str, value: &str) -> Result<usize, CliError> {
|
|
let parsed = value.parse::<usize>().map_err(|error| {
|
|
CliError::usage(format!(
|
|
"{flag} expects a positive integer, got '{value}': {error}"
|
|
))
|
|
})?;
|
|
if parsed == 0 {
|
|
return Err(CliError::usage(format!(
|
|
"{flag} expects a positive integer, got '{value}'"
|
|
)));
|
|
}
|
|
Ok(parsed)
|
|
}
|
|
|
|
fn run(cli: &Cli) -> Result<ExitCode, CliError> {
|
|
match &cli.command {
|
|
Command::Install { game_root } => {
|
|
let report = install_bridge(game_root)?;
|
|
emit_report(
|
|
cli.common.render_mode(),
|
|
&report,
|
|
render_install_text(&report),
|
|
)?;
|
|
Ok(ExitCode::Success)
|
|
}
|
|
Command::Uninstall { game_root } => {
|
|
let report = uninstall_bridge(game_root)?;
|
|
emit_report(
|
|
cli.common.render_mode(),
|
|
&report,
|
|
render_uninstall_text(&report),
|
|
)?;
|
|
Ok(ExitCode::Success)
|
|
}
|
|
Command::Status { game_root } => {
|
|
let report = status_bridge(game_root.as_deref())?;
|
|
emit_report(
|
|
cli.common.render_mode(),
|
|
&report,
|
|
render_status_text(&report),
|
|
)?;
|
|
Ok(ExitCode::Success)
|
|
}
|
|
Command::Scenes => {
|
|
let response = query_scenes()?;
|
|
let data = require_data(response, "scenes")?;
|
|
emit_report(cli.common.render_mode(), &data, render_scenes_text(&data))?;
|
|
Ok(map_result_count(data.scenes.len()))
|
|
}
|
|
Command::Find {
|
|
query,
|
|
limit,
|
|
type_only,
|
|
} => {
|
|
let response = find_objects(query, expanded_probe_limit(*limit, *type_only))?;
|
|
let data = rerank_find_data(require_data(response, "find")?, *limit, *type_only);
|
|
emit_report(cli.common.render_mode(), &data, render_find_text(&data))?;
|
|
Ok(map_result_count(data.matches.len()))
|
|
}
|
|
Command::Inspect { instance_id } => {
|
|
let response = inspect_object(*instance_id)?;
|
|
let data = require_data(response, "inspect")?;
|
|
emit_report(cli.common.render_mode(), &data, render_inspect_text(&data))?;
|
|
Ok(ExitCode::Success)
|
|
}
|
|
Command::Static { type_name } => {
|
|
let response = inspect_static(type_name)?;
|
|
let data = require_data(response, "static")?;
|
|
emit_report(cli.common.render_mode(), &data, render_static_text(&data))?;
|
|
Ok(ExitCode::Success)
|
|
}
|
|
}
|
|
}
|
|
|
|
fn require_data<T>(envelope: unitysupport::ProbeEnvelope<T>, kind: &str) -> Result<T, CliError> {
|
|
envelope.data.ok_or_else(|| {
|
|
CliError::runtime(format!(
|
|
"{kind} returned an empty bridge payload on pipe {BRIDGE_PIPE_NAME}"
|
|
))
|
|
})
|
|
}
|
|
|
|
fn expanded_probe_limit(limit: usize, type_only: bool) -> usize {
|
|
let safe_limit = limit.max(1);
|
|
let multiplier = if type_only { 8 } else { 4 };
|
|
safe_limit.saturating_mul(multiplier).clamp(safe_limit, 256)
|
|
}
|
|
|
|
fn rerank_find_data(mut data: FindData, limit: usize, type_only: bool) -> FindData {
|
|
let normalized_query = normalize_find_query(&data.query);
|
|
data.matches
|
|
.sort_by(|left, right| compare_find_matches(left, right, &normalized_query));
|
|
if type_only {
|
|
data.matches
|
|
.retain(|item| find_type_match_score(item, &normalized_query) > 0);
|
|
}
|
|
data.matches.truncate(limit);
|
|
data
|
|
}
|
|
|
|
fn compare_find_matches(
|
|
left: &ObjectRecord,
|
|
right: &ObjectRecord,
|
|
normalized_query: &str,
|
|
) -> Ordering {
|
|
let left_type_score = find_type_match_score(left, normalized_query);
|
|
let right_type_score = find_type_match_score(right, normalized_query);
|
|
let left_name_score = find_name_match_score(left, normalized_query);
|
|
let right_name_score = find_name_match_score(right, normalized_query);
|
|
right_type_score
|
|
.cmp(&left_type_score)
|
|
.then_with(|| right_name_score.cmp(&left_name_score))
|
|
.then_with(|| {
|
|
right_name_score
|
|
.max(right_type_score)
|
|
.cmp(&left_name_score.max(left_type_score))
|
|
})
|
|
.then_with(|| left.type_name.cmp(&right.type_name))
|
|
.then_with(|| left.name.cmp(&right.name))
|
|
.then_with(|| left.hierarchy_path.cmp(&right.hierarchy_path))
|
|
.then_with(|| left.instance_id.cmp(&right.instance_id))
|
|
}
|
|
|
|
fn find_type_match_score(item: &ObjectRecord, normalized_query: &str) -> usize {
|
|
let full_type = item.type_name.to_ascii_lowercase();
|
|
let type_tail = full_type.rsplit('.').next().unwrap_or(full_type.as_str());
|
|
score_text_candidate(&full_type, type_tail, normalized_query, 600, 540, 480)
|
|
}
|
|
|
|
fn find_name_match_score(item: &ObjectRecord, normalized_query: &str) -> usize {
|
|
let name = item.name.to_ascii_lowercase();
|
|
let hierarchy_tail = item
|
|
.hierarchy_path
|
|
.rsplit('/')
|
|
.find(|segment: &&str| !segment.is_empty())
|
|
.map_or_else(String::new, |segment: &str| segment.to_ascii_lowercase());
|
|
score_text_candidate(&name, &hierarchy_tail, normalized_query, 320, 260, 180)
|
|
}
|
|
|
|
fn score_text_candidate(
|
|
full_text: &str,
|
|
tail_text: &str,
|
|
normalized_query: &str,
|
|
exact_full: usize,
|
|
exact_tail: usize,
|
|
contains_score: usize,
|
|
) -> usize {
|
|
if full_text == normalized_query {
|
|
exact_full
|
|
} else if tail_text == normalized_query {
|
|
exact_tail
|
|
} else if full_text.contains(normalized_query) || tail_text.contains(normalized_query) {
|
|
contains_score
|
|
} else {
|
|
0
|
|
}
|
|
}
|
|
|
|
fn normalize_find_query(query: &str) -> String {
|
|
query.trim().to_ascii_lowercase()
|
|
}
|
|
|
|
fn emit_report<T>(mode: RenderMode, value: &T, text: String) -> Result<(), CliError>
|
|
where
|
|
T: Serialize,
|
|
{
|
|
match mode {
|
|
RenderMode::Json => print_json(value)?,
|
|
RenderMode::Toon => print_structured(value, RenderMode::Toon)?,
|
|
RenderMode::Text => print_text(text)?,
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn render_install_text(report: &InstallReport) -> String {
|
|
format!(
|
|
"installed game_root={} plugin_dir={} dll={} pipe={} compiler={} refs={}",
|
|
report.game_root,
|
|
report.plugin_dir,
|
|
report.dll_path,
|
|
report.pipe_name,
|
|
report.compiler_path,
|
|
report.reference_paths.len()
|
|
)
|
|
}
|
|
|
|
fn render_uninstall_text(report: &UninstallReport) -> String {
|
|
format!(
|
|
"uninstall game_root={} plugin_dir={} removed={}",
|
|
report.game_root, report.plugin_dir, report.removed
|
|
)
|
|
}
|
|
|
|
fn render_status_text(report: &StatusReport) -> String {
|
|
let mut text = String::new();
|
|
write!(
|
|
text,
|
|
"status windows_supported={} installed={} pipe={} pipe_reachable={}",
|
|
report.windows_supported, report.installed, report.pipe_name, report.pipe_reachable
|
|
)
|
|
.expect("writing to a String cannot fail");
|
|
if let Some(game_root) = &report.game_root {
|
|
write!(text, " game_root={game_root}").expect("writing to a String cannot fail");
|
|
}
|
|
if let Some(running) = report.game_process_running {
|
|
write!(text, " game_process_running={running}").expect("writing to a String cannot fail");
|
|
}
|
|
if let Some(name) = &report.game_process_name {
|
|
write!(text, " game_process={name}").expect("writing to a String cannot fail");
|
|
}
|
|
if let Some(pid) = report.game_process_id {
|
|
write!(text, " game_pid={pid}").expect("writing to a String cannot fail");
|
|
}
|
|
if let Some(runtime) = &report.runtime_status {
|
|
write!(
|
|
text,
|
|
" unity={} pid={} scenes={}",
|
|
runtime.unity_version, runtime.process_id, runtime.scene_count
|
|
)
|
|
.expect("writing to a String cannot fail");
|
|
}
|
|
if !report.problems.is_empty() {
|
|
write!(text, " problems={}", report.problems.join(" | "))
|
|
.expect("writing to a String cannot fail");
|
|
}
|
|
text
|
|
}
|
|
|
|
fn render_scenes_text(data: &ScenesData) -> String {
|
|
if data.scenes.is_empty() {
|
|
return "scenes count=0".to_string();
|
|
}
|
|
|
|
let mut text = String::new();
|
|
for (index, scene) in data.scenes.iter().enumerate() {
|
|
if index > 0 {
|
|
text.push('\n');
|
|
}
|
|
write!(
|
|
text,
|
|
"scene name={} build_index={} loaded={} roots={} path={}",
|
|
scene.name, scene.build_index, scene.loaded, scene.root_count, scene.path
|
|
)
|
|
.expect("writing to a String cannot fail");
|
|
}
|
|
text
|
|
}
|
|
|
|
fn render_find_text(data: &FindData) -> String {
|
|
if data.matches.is_empty() {
|
|
return format!("find query={} matches=0", data.query);
|
|
}
|
|
|
|
let mut text = format!("find query={} matches={}", data.query, data.matches.len());
|
|
for item in &data.matches {
|
|
write!(
|
|
text,
|
|
"\nobject id={} type={} name={} scene={} path={} active={}",
|
|
item.instance_id,
|
|
item.type_name,
|
|
item.name,
|
|
item.scene_name,
|
|
item.hierarchy_path,
|
|
item.active
|
|
)
|
|
.expect("writing to a String cannot fail");
|
|
}
|
|
text
|
|
}
|
|
|
|
fn render_inspect_text(data: &InspectData) -> String {
|
|
let mut text = format!(
|
|
"inspect id={} type={} name={} scene={} path={} active={} components={} fields={} properties={}",
|
|
data.object.instance_id,
|
|
data.object.type_name,
|
|
data.object.name,
|
|
data.object.scene_name,
|
|
data.object.hierarchy_path,
|
|
data.object.active,
|
|
data.components.len(),
|
|
data.fields.len(),
|
|
data.properties.len()
|
|
);
|
|
for component in &data.components {
|
|
write!(
|
|
text,
|
|
"\ncomponent id={} type={}",
|
|
component.instance_id, component.type_name
|
|
)
|
|
.expect("writing to a String cannot fail");
|
|
}
|
|
for field in &data.fields {
|
|
write!(
|
|
text,
|
|
"\nfield name={} type={} value={}",
|
|
field.name, field.declared_type, field.value
|
|
)
|
|
.expect("writing to a String cannot fail");
|
|
}
|
|
for property in &data.properties {
|
|
write!(
|
|
text,
|
|
"\nproperty name={} type={} value={}",
|
|
property.name, property.declared_type, property.value
|
|
)
|
|
.expect("writing to a String cannot fail");
|
|
}
|
|
text
|
|
}
|
|
|
|
fn render_static_text(data: &StaticData) -> String {
|
|
let mut text = format!(
|
|
"static type={} assembly={} fields={} properties={}",
|
|
data.type_name,
|
|
data.assembly_name,
|
|
data.fields.len(),
|
|
data.properties.len()
|
|
);
|
|
for field in &data.fields {
|
|
write!(
|
|
text,
|
|
"\nfield name={} type={} value={}",
|
|
field.name, field.declared_type, field.value
|
|
)
|
|
.expect("writing to a String cannot fail");
|
|
}
|
|
for property in &data.properties {
|
|
write!(
|
|
text,
|
|
"\nproperty name={} type={} value={}",
|
|
property.name, property.declared_type, property.value
|
|
)
|
|
.expect("writing to a String cannot fail");
|
|
}
|
|
text
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use serde_json::json;
|
|
use std::path::Path;
|
|
|
|
fn sample_find_data() -> FindData {
|
|
serde_json::from_value(json!({
|
|
"query": "GameManager",
|
|
"matches": [
|
|
{
|
|
"instance_id": 42,
|
|
"name": "GameManager",
|
|
"type_name": "Game.Core.GameManager",
|
|
"scene_name": "Main",
|
|
"hierarchy_path": "/Root/GameManager",
|
|
"active": true
|
|
}
|
|
]
|
|
}))
|
|
.expect("find fixture")
|
|
}
|
|
|
|
fn sample_inspect_data() -> InspectData {
|
|
serde_json::from_value(json!({
|
|
"object": {
|
|
"instance_id": 42,
|
|
"name": "GameManager",
|
|
"type_name": "Game.Core.GameManager",
|
|
"scene_name": "Main",
|
|
"hierarchy_path": "/Root/GameManager",
|
|
"active": true
|
|
},
|
|
"components": [
|
|
{
|
|
"instance_id": 77,
|
|
"type_name": "UnityEngine.Transform"
|
|
}
|
|
],
|
|
"fields": [
|
|
{
|
|
"name": "state",
|
|
"declared_type": "System.String",
|
|
"value": "Ready"
|
|
}
|
|
],
|
|
"properties": [
|
|
{
|
|
"name": "Enabled",
|
|
"declared_type": "System.Boolean",
|
|
"value": "True"
|
|
}
|
|
]
|
|
}))
|
|
.expect("inspect fixture")
|
|
}
|
|
|
|
fn sample_static_data() -> StaticData {
|
|
serde_json::from_value(json!({
|
|
"type_name": "Game.Core.Globals",
|
|
"assembly_name": "GameAssembly",
|
|
"fields": [
|
|
{
|
|
"name": "Build",
|
|
"declared_type": "System.Int32",
|
|
"value": "42"
|
|
}
|
|
],
|
|
"properties": [
|
|
{
|
|
"name": "Version",
|
|
"declared_type": "System.String",
|
|
"value": "1.2.3"
|
|
}
|
|
]
|
|
}))
|
|
.expect("static fixture")
|
|
}
|
|
|
|
#[test]
|
|
fn parse_find_supports_limit() {
|
|
let (_, cli) = parse_cli_from(["unityprobe", "--json", "find", "--limit", "8", "Player"])
|
|
.expect("cli");
|
|
assert!(cli.common.json);
|
|
let Command::Find {
|
|
query,
|
|
limit,
|
|
type_only,
|
|
} = cli.command
|
|
else {
|
|
panic!("expected find command");
|
|
};
|
|
assert_eq!(query, "Player");
|
|
assert_eq!(limit, 8);
|
|
assert!(!type_only);
|
|
}
|
|
|
|
#[test]
|
|
fn parse_find_supports_type_only() {
|
|
let (_, cli) =
|
|
parse_cli_from(["unityprobe", "find", "--type-only", "GameManager"]).expect("cli");
|
|
let Command::Find {
|
|
query,
|
|
limit,
|
|
type_only,
|
|
} = cli.command
|
|
else {
|
|
panic!("expected find command");
|
|
};
|
|
assert_eq!(query, "GameManager");
|
|
assert_eq!(limit, 25);
|
|
assert!(type_only);
|
|
}
|
|
|
|
#[test]
|
|
fn parse_status_supports_optional_game_root() {
|
|
let (_, cli) =
|
|
parse_cli_from(["unityprobe", "status", "--game-root", "C:\\Games\\Mercury"])
|
|
.expect("cli");
|
|
let Command::Status { game_root } = cli.command else {
|
|
panic!("expected status command");
|
|
};
|
|
assert_eq!(game_root.as_deref(), Some(Path::new("C:\\Games\\Mercury")));
|
|
}
|
|
|
|
#[test]
|
|
fn parse_common_flags_after_subcommand() {
|
|
let (_, cli) = parse_cli_from([
|
|
"unityprobe",
|
|
"status",
|
|
"--game-root",
|
|
"C:\\Games\\Mercury",
|
|
"--json",
|
|
])
|
|
.expect("cli");
|
|
assert!(cli.common.json);
|
|
}
|
|
|
|
#[test]
|
|
fn help_text_mentions_windows_only_named_pipe_bridge() {
|
|
assert!(HELP.contains("Windows only"));
|
|
assert!(HELP.contains("named pipe JSON"));
|
|
assert!(HELP.contains("BepInEx\\plugins"));
|
|
}
|
|
|
|
#[test]
|
|
fn parse_cli_requires_subcommand() {
|
|
let error = parse_cli_from(["unityprobe"]).expect_err("missing subcommand");
|
|
assert!(error.to_string().contains("requires a subcommand"));
|
|
}
|
|
|
|
#[test]
|
|
fn parse_find_rejects_invalid_limit() {
|
|
let error =
|
|
parse_cli_from(["unityprobe", "find", "--limit", "abc", "Player"]).expect_err("bad");
|
|
assert!(
|
|
error
|
|
.to_string()
|
|
.contains("--limit expects a positive integer")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn parse_inspect_rejects_non_integer_instance_id() {
|
|
let error = parse_cli_from(["unityprobe", "inspect", "abc"]).expect_err("bad inspect");
|
|
assert!(
|
|
error
|
|
.to_string()
|
|
.contains("inspect expects an integer instance id")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn parse_static_rejects_duplicate_type_name() {
|
|
let error = parse_cli_from(["unityprobe", "static", "Type.One", "Type.Two"])
|
|
.expect_err("duplicate static arg");
|
|
assert!(
|
|
error
|
|
.to_string()
|
|
.contains("static accepts only one type name")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn require_data_returns_runtime_error_when_payload_is_missing() {
|
|
let envelope: unitysupport::ProbeEnvelope<unitysupport::ScenesData> =
|
|
unitysupport::ProbeEnvelope {
|
|
ok: true,
|
|
kind: "scenes".to_string(),
|
|
data: None,
|
|
error: None,
|
|
};
|
|
let error = require_data(envelope, "scenes").expect_err("missing payload");
|
|
assert!(
|
|
error
|
|
.to_string()
|
|
.contains("scenes returned an empty bridge payload")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn require_data_returns_payload_when_present() {
|
|
let expected: ScenesData = serde_json::from_value(json!({
|
|
"scenes": [
|
|
{
|
|
"name": "Main",
|
|
"path": "Assets/Scenes/Main.unity",
|
|
"build_index": 0,
|
|
"loaded": true,
|
|
"root_count": 10
|
|
}
|
|
]
|
|
}))
|
|
.expect("scenes fixture");
|
|
let envelope = unitysupport::ProbeEnvelope {
|
|
ok: true,
|
|
kind: "scenes".to_string(),
|
|
data: Some(expected.clone()),
|
|
error: None,
|
|
};
|
|
let data = require_data(envelope, "scenes").expect("payload");
|
|
assert_eq!(data, expected);
|
|
}
|
|
|
|
#[test]
|
|
fn render_status_text_includes_optional_runtime_and_problems() {
|
|
let report = StatusReport {
|
|
windows_supported: true,
|
|
game_root: Some("C:\\Game".to_string()),
|
|
plugin_dir: Some("C:\\Game\\BepInEx\\plugins\\Mercury.UnityProbe".to_string()),
|
|
dll_path: Some(
|
|
"C:\\Game\\BepInEx\\plugins\\Mercury.UnityProbe\\Mercury.UnityProbe.dll"
|
|
.to_string(),
|
|
),
|
|
manifest_path: Some(
|
|
"C:\\Game\\BepInEx\\plugins\\Mercury.UnityProbe\\mercury-unityprobe.install.json"
|
|
.to_string(),
|
|
),
|
|
installed: true,
|
|
game_process_running: Some(true),
|
|
game_process_id: Some(1234),
|
|
game_process_name: Some("Solar Expanse.exe".to_string()),
|
|
pipe_name: BRIDGE_PIPE_NAME.to_string(),
|
|
pipe_reachable: true,
|
|
runtime_status: Some(
|
|
serde_json::from_value(json!({
|
|
"pipe_name": BRIDGE_PIPE_NAME,
|
|
"plugin_version": "1.0.0",
|
|
"unity_version": "2021.3.0f1",
|
|
"process_id": 1234,
|
|
"scene_count": 2,
|
|
"loaded_scene_names": ["Main", "Gameplay"]
|
|
}))
|
|
.expect("runtime fixture"),
|
|
),
|
|
compiler_path: Some(
|
|
"C:\\Windows\\Microsoft.NET\\Framework64\\v4.0.30319\\csc.exe".to_string(),
|
|
),
|
|
managed_dir: Some("C:\\Game\\Example_Data\\Managed".to_string()),
|
|
problems: vec!["warning one".to_string(), "warning two".to_string()],
|
|
};
|
|
|
|
let text = render_status_text(&report);
|
|
assert!(text.contains("status windows_supported=true installed=true"));
|
|
assert!(text.contains("game_process_running=true"));
|
|
assert!(text.contains("game_process=Solar Expanse.exe"));
|
|
assert!(text.contains("unity=2021.3.0f1 pid=1234 scenes=2"));
|
|
assert!(text.contains("problems=warning one | warning two"));
|
|
}
|
|
|
|
#[test]
|
|
fn render_helpers_emit_expected_empty_and_populated_forms() {
|
|
assert_eq!(
|
|
render_scenes_text(&unitysupport::ScenesData { scenes: Vec::new() }),
|
|
"scenes count=0"
|
|
);
|
|
assert_eq!(
|
|
render_find_text(&unitysupport::FindData {
|
|
query: "Manager".to_string(),
|
|
matches: Vec::new(),
|
|
}),
|
|
"find query=Manager matches=0"
|
|
);
|
|
|
|
let scenes: ScenesData = serde_json::from_value(json!({
|
|
"scenes": [
|
|
{
|
|
"name": "Main",
|
|
"path": "Assets/Scenes/Main.unity",
|
|
"build_index": 0,
|
|
"loaded": true,
|
|
"root_count": 12
|
|
}
|
|
]
|
|
}))
|
|
.expect("scenes fixture");
|
|
let find = sample_find_data();
|
|
let inspect = sample_inspect_data();
|
|
let static_data = sample_static_data();
|
|
|
|
let scenes_text = render_scenes_text(&scenes);
|
|
assert!(scenes_text.contains("scene name=Main"));
|
|
|
|
let find_text = render_find_text(&find);
|
|
assert!(find_text.contains("find query=GameManager matches=1"));
|
|
assert!(find_text.contains("object id=42"));
|
|
|
|
let inspect_text = render_inspect_text(&inspect);
|
|
assert!(inspect_text.contains("inspect id=42"));
|
|
assert!(inspect_text.contains("component id=77"));
|
|
assert!(inspect_text.contains("field name=state"));
|
|
assert!(inspect_text.contains("property name=Enabled"));
|
|
|
|
let static_text = render_static_text(&static_data);
|
|
assert!(static_text.contains("static type=Game.Core.Globals assembly=GameAssembly"));
|
|
assert!(static_text.contains("field name=Build"));
|
|
assert!(static_text.contains("property name=Version"));
|
|
}
|
|
|
|
#[test]
|
|
fn rerank_find_data_prefers_type_matches_and_supports_type_only() {
|
|
let mixed: FindData = serde_json::from_value(json!({
|
|
"query": "GameManager",
|
|
"matches": [
|
|
{
|
|
"instance_id": 7,
|
|
"name": "GameManager",
|
|
"type_name": "UnityEngine.TextAsset",
|
|
"scene_name": "Main",
|
|
"hierarchy_path": "/Root/GameManagerAsset",
|
|
"active": true
|
|
},
|
|
{
|
|
"instance_id": 42,
|
|
"name": "Bootstrap",
|
|
"type_name": "Game.Core.GameManager",
|
|
"scene_name": "Main",
|
|
"hierarchy_path": "/Root/Managers/GameManager",
|
|
"active": true
|
|
}
|
|
]
|
|
}))
|
|
.expect("find fixture");
|
|
|
|
let reranked = rerank_find_data(mixed.clone(), 25, false);
|
|
assert_eq!(reranked.matches.len(), 2);
|
|
assert_eq!(reranked.matches[0].type_name, "Game.Core.GameManager");
|
|
|
|
let type_only = rerank_find_data(mixed, 25, true);
|
|
assert_eq!(type_only.matches.len(), 1);
|
|
assert_eq!(type_only.matches[0].type_name, "Game.Core.GameManager");
|
|
}
|
|
}
|