//! 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] [ARGS...] Subcommands: install Compile the shared bridge source with local csc.exe and install it into BepInEx\\plugins uninstall 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 Find runtime objects by type or name inspect Inspect one runtime object by Unity instance id static Inspect static fields and properties for a managed type Options: --format Structured output format: text, json, toon --json Shortcut for --format json --toon Shortcut for --format toon --color 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 Inspect explicit install state for one game root find --limit 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, }, 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(args: I) -> Result<(ParseOutcome, Cli), CliError> where I: IntoIterator, T: Into, { 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 { 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 { 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 { 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 { 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 { 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 { 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::().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 { 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 { 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 { 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 { 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 { let parsed = value.parse::().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 { 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(envelope: unitysupport::ProbeEnvelope, kind: &str) -> Result { 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(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::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"); } }