//! Non-interactive command-line interface, intended for scripting and for //! driving the patcher from another application. //! //! Contract: the process exit code is authoritative — `0` success, `1` a //! patch/runtime error, `2` a usage error. The whole command line is //! validated before any patch work starts. With `--json`, the final line of //! stdout is a single JSON object (`{"ok":true,"command":...,"output":...}` //! or `{"ok":false,"error":...}`), usage errors included; human progress is //! printed before it. use crate::patch::{ ace, dispatch::{Target, android, windows}, hoyokprotect, protection, rsa, }; use anyhow::Result; use std::{collections::HashMap, path::PathBuf, process::ExitCode}; const USAGE: &str = "\ Honkai Impact 3rd patcher Usage: Hi3-Patcher Launch the interactive menu Hi3-Patcher [options] Commands: dispatch Redirect dispatch & SDK URLs to a server --platform --input --server [--https] rsa Replace the passport RSA public key --platform --input [--key ] ace Remove ACE from a Windows client (<= 8.9); requires a capture --input [--yes] hoyok Disable HoYoKProtect in a Windows client (9.0+) --input protection Remove Android protection and re-sign the APK --input [--variant ] Options: --json Emit a single JSON result object as the last stdout line -h, --help, -V, --version Windows --input accepts the game folder or its BH3.exe. rsa --key defaults to the PemukulPaku public key when omitted."; /// Options each command accepts: `(value options, switches)`. Every command /// also accepts `--json`. fn accepted(command: &str) -> Option<(&'static [&'static str], &'static [&'static str])> { Some(match command { "dispatch" => (&["platform", "input", "server"], &["https"]), "rsa" => (&["platform", "input", "key"], &[]), "ace" => (&["input"], &["yes"]), "hoyok" => (&["input"], &[]), "protection" => (&["input", "variant"], &[]), _ => return None, }) } struct Parsed { values: HashMap, switches: Vec, } /// Split `--name value`, `--name=value` and `--switch` tokens, rejecting /// anything the command does not accept. Errors are usage errors. fn parse(command: &str, rest: &[String]) -> Result { let (options, flags) = accepted(command).ok_or_else(|| format!("unknown command: {command}"))?; let mut values = HashMap::new(); let mut switches: Vec = Vec::new(); let mut it = rest.iter(); while let Some(token) = it.next() { let Some(name) = token.strip_prefix("--") else { return Err(format!("unexpected argument: {token}")); }; let (name, inline) = match name.split_once('=') { Some((name, value)) => (name, Some(value)), None => (name, None), }; if name == "json" || flags.contains(&name) { if inline.is_some() { return Err(format!("--{name} does not take a value")); } if switches.iter().any(|s| s == name) { return Err(format!("--{name} given more than once")); } switches.push(name.to_owned()); } else if options.contains(&name) { let value = match inline { Some(value) => value.to_owned(), None => it .next() .filter(|value| !value.starts_with("--")) .ok_or_else(|| format!("--{name} needs a value"))? .clone(), }; if values.insert(name.to_owned(), value).is_some() { return Err(format!("--{name} given more than once")); } } else { return Err(format!("{command} does not accept --{name}")); } } Ok(Parsed { values, switches }) } impl Parsed { fn required(&self, name: &str) -> Result<&str, String> { match self.values.get(name).map(String::as_str) { None | Some("") => Err(format!("--{name} is required")), Some(value) => Ok(value), } } fn has(&self, name: &str) -> bool { self.switches.iter().any(|s| s == name) } } enum Platform { Windows, Android, } enum Variant { Auto, Ano, Tss, } /// A fully validated command, ready to run. enum Job { Dispatch { platform: Platform, input: PathBuf, target: Target }, Rsa { platform: Platform, input: PathBuf, key: String }, Ace { input: PathBuf, yes: bool }, Hoyok { input: PathBuf }, Protection { input: PathBuf, variant: Variant }, } fn parse_platform(args: &Parsed) -> Result { match args.required("platform")? { "windows" | "win" => Ok(Platform::Windows), "android" => Ok(Platform::Android), other => Err(format!("unknown platform: {other}")), } } /// Resolve `--input`; a Windows input may be the game folder or its BH3.exe. fn input_path(args: &Parsed, platform: &Platform) -> Result { let path = PathBuf::from(args.required("input")?); Ok(match platform { Platform::Windows if path.is_dir() => path.join("BH3.exe"), _ => path, }) } /// Check a parsed command line per command. Errors are usage errors. fn job(command: &str, args: &Parsed) -> Result { Ok(match command { "dispatch" => { let platform = parse_platform(args)?; let input = input_path(args, &platform)?; let target = Target::new(args.required("server")?, args.has("https")) .map_err(|error| format!("--server: {error:#}"))?; Job::Dispatch { platform, input, target } } "rsa" => { let platform = parse_platform(args)?; let input = input_path(args, &platform)?; let key = args.values.get("key").cloned().unwrap_or_default(); rsa::public_body(&key).map_err(|error| format!("--key: {error:#}"))?; Job::Rsa { platform, input, key } } "ace" => Job::Ace { input: input_path(args, &Platform::Windows)?, yes: args.has("yes"), }, "hoyok" => Job::Hoyok { input: input_path(args, &Platform::Windows)?, }, "protection" => Job::Protection { input: input_path(args, &Platform::Android)?, variant: match args.values.get("variant").map(String::as_str) { None | Some("auto") => Variant::Auto, Some("ano") => Variant::Ano, Some("tss") => Variant::Tss, Some(other) => return Err(format!("unknown variant: {other}")), }, }, other => return Err(format!("unknown command: {other}")), }) } /// Run a validated job. Returns the output path for Android patches. fn execute(job: Job) -> Result> { Ok(match job { Job::Dispatch { platform: Platform::Windows, input, target } => { windows::patch(&input, &target)?; None } Job::Dispatch { platform: Platform::Android, input, target } => { Some(android::patch(&input, &target)?) } Job::Rsa { platform: Platform::Windows, input, key } => { rsa::patch_windows(&input, &key)?; None } Job::Rsa { platform: Platform::Android, input, key } => { Some(rsa::patch_android(&input, &key)?) } Job::Ace { input, yes } => { ace::patch(&input, |_| Ok(yes))?; None } Job::Hoyok { input } => { hoyokprotect::patch(&input)?; None } Job::Protection { input, variant } => Some(match variant { Variant::Auto => protection::auto(&input)?, Variant::Ano => protection::anosdk(&input)?, Variant::Tss => protection::mobile_ace_tss(&input)?, }), }) } fn report(command: &str, result: Result>, json: bool) -> ExitCode { match result { Ok(output) => { let out = output.as_ref().map(|p| p.display().to_string()); if json { println!( "{}", serde_json::json!({"ok": true, "command": command, "output": out}) ); } else if let Some(path) = out { println!("Output: {path}"); } ExitCode::SUCCESS } Err(error) => { let message = format!("{error:#}"); if json { println!("{}", serde_json::json!({"ok": false, "error": message})); } else { eprintln!("error: {message}"); } ExitCode::FAILURE } } } fn usage_error(message: &str, json: bool) -> ExitCode { if json { println!("{}", serde_json::json!({"ok": false, "error": message})); } else { eprintln!("error: {message}\n\n{USAGE}"); } ExitCode::from(2) } /// Entry point for a non-empty argument list (the command and its options). pub fn run(argv: &[String]) -> ExitCode { let command = argv[0].as_str(); let rest = &argv[1..]; if matches!(command, "-h" | "--help" | "help") || rest.iter().any(|a| a == "-h" || a == "--help") { println!("{USAGE}"); return ExitCode::SUCCESS; } if matches!(command, "-V" | "--version" | "version") { println!("Hi3-Patcher {}", env!("CARGO_PKG_VERSION")); return ExitCode::SUCCESS; } let json = rest.iter().any(|a| a == "--json"); match parse(command, rest).and_then(|args| job(command, &args)) { Ok(job) => report(command, execute(job), json), Err(message) => usage_error(&message, json), } } #[cfg(test)] mod tests { use super::*; /// Validate a command line the way `run` does, without executing it. fn check(line: &[&str]) -> Result { let argv: Vec = line.iter().map(|s| s.to_string()).collect(); parse(&argv[0], &argv[1..]).and_then(|args| job(&argv[0], &args)) } #[test] fn accepts_valid_command_lines() { let cases: &[&[&str]] = &[ &["dispatch", "--platform", "windows", "--input", "x", "--server", "1.1.1.1"], &["dispatch", "--platform=android", "--input=a.apk", "--server=h:3000", "--https", "--json"], &["rsa", "--platform", "win", "--input", "x"], &["rsa", "--platform", "windows", "--input", "x", "--key", ""], &["ace", "--input", "x", "--yes"], &["hoyok", "--input", "x"], &["protection", "--input", "a.apk", "--variant", "tss"], ]; for line in cases { assert!(check(line).is_ok(), "rejected: {line:?}"); } } #[test] fn rejects_usage_errors() { let cases: &[&[&str]] = &[ &["bogus"], &["dispatch", "--platform", "windows", "--input", "x"], &["dispatch", "--platform", "plan9", "--input", "x", "--server", "h"], &["dispatch", "--platform", "windows", "--input", "x", "--server", "h/p"], &["dispatch", "stray"], &["dispatch", "--input"], &["dispatch", "--input", "--json"], &["dispatch", "--platform", "windows", "--input", "x", "--server", "h", "--sever", "h"], &["rsa", "--platform", "windows", "--input", "x", "--key", "abc"], &["ace", "--input", "x", "--https"], &["ace", "--input", "a", "--input", "b"], &["ace", "--input", "x", "--yes=1"], &["hoyok", "--input", ""], &["protection", "--input", "a.apk", "--variant", "x"], ]; for line in cases { assert!(check(line).is_err(), "accepted: {line:?}"); } } }