Initial Commit

This commit is contained in:
Tenshi committed 2026-10-31 23:35:41 +01:00
commit 737780597b
51 files changed
+8675

No files matched your search

+329
View File
@@ -0,0 +1,329 @@
//! 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:?}");
}
}
}
+435
View File
@@ -0,0 +1,435 @@
//! Interactive terminal front end: arrow-key menus and path prompts.
use crate::{
android::toolchain,
patch::{
ace,
dispatch::{Target, android, windows},
hoyokprotect, protection, rsa,
},
};
use anyhow::{Result, bail};
use crossterm::{
cursor::{Hide, MoveTo, Show, position},
event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers},
execute,
style::{Color, Print, ResetColor, SetForegroundColor},
terminal::{self, Clear, ClearType, EnterAlternateScreen, LeaveAlternateScreen},
};
use std::{
io::{self, Write},
path::PathBuf,
};
struct Screen;
impl Drop for Screen {
fn drop(&mut self) {
let _ = terminal::disable_raw_mode();
let _ = execute!(io::stdout(), Show, LeaveAlternateScreen);
}
}
struct RawInput;
impl Drop for RawInput {
fn drop(&mut self) {
let _ = terminal::disable_raw_mode();
let _ = execute!(io::stdout(), Show);
}
}
fn screen(title: &str) -> Result<()> {
execute!(io::stdout(), MoveTo(0, 0), Clear(ClearType::All))?;
print!("Honkai Impact 3 - Patcher\r\n");
print!("{:>53}\r\n", concat!("Ver: ", env!("CARGO_PKG_VERSION")));
print!("=====================================================\r\n{title}\r\n\r\n");
Ok(())
}
// Returns the activated shortcut; arrows only change the highlight.
fn selection(key: KeyEvent, selected: &mut usize, options: &[(char, &str)]) -> Option<char> {
if key.kind == KeyEventKind::Release {
return None;
}
match key.code {
KeyCode::Up => *selected = (*selected + options.len() - 1) % options.len(),
KeyCode::Down => *selected = (*selected + 1) % options.len(),
KeyCode::Enter if key.kind == KeyEventKind::Press => return Some(options[*selected].0),
KeyCode::Esc => return Some('0'),
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => return Some('0'),
KeyCode::Char(c) if key.kind == KeyEventKind::Press && options.iter().any(|o| o.0 == c) => {
return Some(c);
}
_ => {}
}
None
}
fn menu(title: &str, options: &[(char, &str)]) -> Result<char> {
terminal::enable_raw_mode()?;
let _raw = RawInput;
execute!(io::stdout(), Hide)?;
let mut selected = 0;
loop {
screen(title)?;
io::stdout().flush()?;
let (_, first_row) = position()?;
for (index, (key, label)) in options.iter().enumerate() {
print!("{} {key}. {label}\r\n", if index == selected { ">" } else { " " });
}
io::stdout().flush()?;
loop {
match event::read()? {
Event::Key(key) => {
let previous = selected;
if let Some(choice) = selection(key, &mut selected, options) {
return Ok(choice);
}
if previous != selected {
execute!(
io::stdout(),
MoveTo(0, first_row + previous as u16),
Print(" "),
MoveTo(0, first_row + selected as u16),
Print(">")
)?;
}
}
Event::Resize(..) => break,
_ => {}
}
}
}
}
fn prompt(label: &str) -> Result<String> {
print!("{label}");
io::stdout().flush()?;
let mut value = String::new();
if io::stdin().read_line(&mut value)? == 0 {
bail!("input closed");
}
Ok(value.trim().trim_matches('"').trim_matches('\'').to_owned())
}
fn prompt_path(label: &str) -> Result<Option<PathBuf>> {
print!("{label}");
io::stdout().flush()?;
let (column, row) = position()?;
terminal::enable_raw_mode()?;
let _raw = RawInput;
let mut value = String::new();
loop {
match event::read()? {
Event::Key(key) if key.kind != KeyEventKind::Release => match key.code {
KeyCode::Esc => return Ok(None),
KeyCode::Enter => {
print!("\r\n");
return Ok(Some(PathBuf::from(
value.trim().trim_matches('"').trim_matches('\''),
)));
}
KeyCode::Backspace => {
value.pop();
}
KeyCode::Char(c)
if !key.modifiers.contains(KeyModifiers::CONTROL)
|| key.modifiers.contains(KeyModifiers::ALT) =>
{
value.push(c);
}
_ => continue,
},
Event::Paste(text) => value.extend(text.chars().filter(|c| !matches!(c, '\r' | '\n'))),
_ => continue,
}
execute!(
io::stdout(),
MoveTo(column, row),
Clear(ClearType::FromCursorDown),
Print(&value)
)?;
}
}
fn prompt_rsa_key() -> Result<String> {
println!("\nNote: Press Enter for Default used by PemukulPaku");
print!("Public key: ");
io::stdout().flush()?;
let (column, row) = position()?;
terminal::enable_raw_mode()?;
let _raw = RawInput;
let mut value = String::new();
loop {
execute!(io::stdout(), MoveTo(column, row), Clear(ClearType::FromCursorDown))?;
if value.is_empty() {
execute!(
io::stdout(),
SetForegroundColor(Color::DarkGrey),
Print(rsa::DEFAULT_BODY),
ResetColor,
MoveTo(column, row)
)?;
} else {
print!("{value}");
}
io::stdout().flush()?;
match event::read()? {
Event::Key(key) if key.kind != KeyEventKind::Release => match key.code {
KeyCode::Enter => {
execute!(io::stdout(), MoveTo(column, row), Clear(ClearType::FromCursorDown))?;
if value.is_empty() {
execute!(
io::stdout(),
SetForegroundColor(Color::DarkGrey),
Print("(default)"),
ResetColor
)?;
} else {
print!("{value}");
}
print!("\r\n");
return Ok(value);
}
KeyCode::Backspace => {
value.pop();
}
KeyCode::Char(c)
if !key.modifiers.contains(KeyModifiers::CONTROL)
|| key.modifiers.contains(KeyModifiers::ALT) =>
{
value.push(c);
}
_ => {}
},
Event::Paste(text) => value.extend(text.chars().filter(|c| !c.is_whitespace())),
_ => {}
}
}
}
fn prompt_bh3_exe() -> Result<Option<PathBuf>> {
println!("Enter full Path to the Game folder.");
println!("Example: D:\\example\\some folder\\BH3_vX.X.0_becb21cb288f\n");
let Some(path) = prompt_path("Full path to BH3: ")? else {
return Ok(None);
};
Ok(Some(if path.is_dir() {
path.join("BH3.exe")
} else {
path
}))
}
fn dispatch_patch(platform: char) -> Result<()> {
let platform_name = if platform == '1' { "Windows" } else { "Android" };
let section = format!("Platform > {platform_name} > Dispatch & SDK Patch");
let https = match menu(
&format!(
"{section} / Protocol\r\n\r\nPatch Client to redirect to a different IP or Domain.\r\nNote: Normally it's HTTPS, but pemu uses HTTP for simpler setup\r\nso the client does not need a Cert Patch."
),
&[('1', "HTTP (default)"), ('2', "HTTPS"), ('0', "Back")],
)? {
'1' => false,
'2' => true,
_ => return Ok(()),
};
screen(&format!("{section} / Address"))?;
println!("Patch Client to redirect to a different IP or Domain.\n");
println!("Example: 127.0.0.1 or example.com");
let target = Target::new(&prompt("Address: ")?, https)?;
screen(&format!("{section} / Client"))?;
let input = if platform == '1' {
prompt_bh3_exe()?
} else {
prompt_path("Full path to APK: ")?
};
let Some(input) = input else { return Ok(()) };
screen(&format!("{section} / Patching"))?;
if platform == '1' {
windows::patch(&input, &target)?;
} else {
android::patch(&input, &target)?;
}
prompt("\nPress Enter to return...")?;
Ok(())
}
fn setup_android() -> Result<bool> {
if toolchain::build_tools().is_err() {
if menu(
"Platform > Android > Setup\r\n\r\nThis patch requires Android SDK Build-Tools 35.0.0 (~60 MB). Download them?",
&[('1', "Yes"), ('2', "No")],
)? != '1'
{
return Ok(false);
}
screen("Platform > Android > Setup / Downloading Build-Tools")?;
toolchain::install_build_tools(&toolchain::local_build_tools()?)?;
}
if toolchain::check_java().is_err() {
if menu(
"Platform > Android > Setup\r\n\r\nThis patch requires Java 17+. Download portable Java (~190 MB)?",
&[('1', "Yes"), ('2', "No")],
)? != '1'
{
return Ok(false);
}
screen("Platform > Android > Setup / Downloading Java")?;
toolchain::install_java(&toolchain::local_java()?)?;
toolchain::check_java()?;
}
Ok(true)
}
fn patch_options(platform: char) -> &'static [(char, &'static str)] {
if platform == '1' {
&[
('1', "Dispatch & SDK Patch"),
('2', "RSA Patch"),
('3', "ACE Patch"),
('4', "HoYoKProtect Patch"),
('0', "Back"),
]
} else {
&[
('1', "Dispatch & SDK Patch"),
('2', "RSA Patch"),
('3', "Mobile ACE/TSS Patch (CN)"),
('4', "AnoSDK Patch (SEA, GLB, JP, KR, TW)"),
('0', "Back"),
]
}
}
pub fn run() -> Result<()> {
let _screen = Screen;
execute!(io::stdout(), EnterAlternateScreen)?;
loop {
let platform = menu(
"Platform:\r\n\r\nSelect the Client Platform you want to Patch.",
&[
('1', "Windows"),
('2', "Android"),
('3', "iOS (not implemented yet)"),
('0', "Exit"),
],
)?;
if platform == '0' {
return Ok(());
}
if platform == '3' {
continue;
}
let platform_path = if platform == '1' {
"Platform > Windows"
} else {
"Platform > Android"
};
if platform == '2' {
match setup_android() {
Ok(true) => {}
Ok(false) => continue,
Err(error) => {
screen("Platform > Android > Setup / Error")?;
println!("{error:#}");
prompt("\nPress Enter to return...")?;
continue;
}
}
}
loop {
match menu(platform_path, patch_options(platform))? {
'0' => break,
'1' => {
if let Err(error) = dispatch_patch(platform) {
println!("[x] {error:#}");
prompt("\nPress Enter to return...")?;
}
}
'2' => {
screen(&format!("{platform_path} > RSA Patch"))?;
println!("Replaces the RSA Public Key used by Ma-Passport");
println!(
"Note: if you use a different RSA Public key make sure it matches the PemukulPaku's appsettings.json Private Key"
);
if platform == '2' {
println!("Creates a separate signed APK. The original stays unchanged.");
}
let input = if platform == '1' {
prompt_bh3_exe()?
} else {
prompt_path("Full path to APK: ")?
};
let Some(input) = input else { continue };
let key = prompt_rsa_key()?;
let result = if platform == '1' {
rsa::patch_windows(&input, &key)
} else {
rsa::patch_android(&input, &key).map(|_| ())
};
if let Err(error) = result {
println!("[x] {error:#}");
}
prompt("\nPress Enter to return...")?;
}
'3' => {
if platform == '2' {
screen(&format!("{platform_path} > Mobile ACE/TSS Patch"))?;
println!("Removes client Protection and creates a separate signed APK.");
let Some(input) = prompt_path("Full path to APK: ")? else {
continue;
};
if let Err(error) = protection::mobile_ace_tss(&input) {
println!("[x] {error:#}");
}
} else {
screen(&format!("{platform_path} > ACE Patch"))?;
println!("Removes ACE from the Game Client.");
println!("Note: Only for clients 8.9 and below, 9.0+ clients use HoYoKProtect.");
let Some(input) = prompt_bh3_exe()? else {
continue;
};
if let Err(error) = ace::patch(&input, |_| {
let choice = menu(
&format!(
"{platform_path} > ACE Patch\r\n\r\nThis patch will start the client to capture initialization."
),
&[('1', "Yes"), ('2', "No")],
)?;
screen(&format!("{platform_path} > ACE Patch"))?;
Ok(choice == '1')
}) {
println!("[x] {error:#}");
}
}
prompt("\nPress Enter to return...")?;
}
'4' => {
if platform == '2' {
screen(&format!("{platform_path} > AnoSDK Patch"))?;
println!("Removes client Protection and creates a separate signed APK.");
let Some(input) = prompt_path("Full path to APK: ")? else {
continue;
};
if let Err(error) = protection::anosdk(&input) {
println!("[x] {error:#}");
}
} else {
screen(&format!("{platform_path} > HoYoKProtect Patch"))?;
println!("Removes HoYoKProtect from the Game Client.");
let Some(input) = prompt_bh3_exe()? else {
continue;
};
if let Err(error) = hoyokprotect::patch(&input) {
println!("[x] {error:#}");
}
}
prompt("\nPress Enter to return...")?;
}
_ => {
screen("Not implemented yet. No files changed.")?;
prompt("Press Enter to return...")?;
}
}
}
}
}
+20
View File
@@ -0,0 +1,20 @@
//! Front ends: an interactive terminal menu and an argument-driven interface.
//! With no arguments the interactive menu runs; otherwise the first argument
//! selects a command (see [`args`]).
pub mod args;
pub mod interactive;
use std::process::ExitCode;
pub fn run() -> ExitCode {
let argv: Vec<String> = std::env::args().skip(1).collect();
if argv.is_empty() {
if let Err(error) = interactive::run() {
eprintln!("error: {error:#}");
return ExitCode::FAILURE;
}
ExitCode::SUCCESS
} else {
args::run(&argv)
}
}