Files
Hi3-Patcher/src/cli/args.rs
T
2026-10-31 23:35:41 +01:00

330 lines
12 KiB
Rust

//! 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 <command> [options]
Commands:
dispatch Redirect dispatch & SDK URLs to a server
--platform <windows|android> --input <path>
--server <host[:port]> [--https]
rsa Replace the passport RSA public key
--platform <windows|android> --input <path> [--key <base64>]
ace Remove ACE from a Windows client (<= 8.9); requires a capture
--input <BH3.exe|folder> [--yes]
hoyok Disable HoYoKProtect in a Windows client (9.0+)
--input <BH3.exe|folder>
protection Remove Android protection and re-sign the APK
--input <app.apk> [--variant <ano|tss|auto>]
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<String, String>,
switches: Vec<String>,
}
/// 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<Parsed, String> {
let (options, flags) =
accepted(command).ok_or_else(|| format!("unknown command: {command}"))?;
let mut values = HashMap::new();
let mut switches: Vec<String> = 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<Platform, String> {
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<PathBuf, String> {
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<Job, String> {
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<Option<PathBuf>> {
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<Option<PathBuf>>, 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<Job, String> {
let argv: Vec<String> = 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:?}");
}
}
}