mod config;
use anyhow::{bail, Context, Result};
use clap::{Parser, Subcommand};
use config::Config;
use std::fs;
use std::io::{self, Read, Write};
use std::path::PathBuf;
use std::process::Command;
#[derive(Parser)]
#[command(name = "bl4")]
#[command(about = "Borderlands 4 Save Editor", long_about = None)]
struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
Decrypt {
#[arg(short, long)]
input: Option<PathBuf>,
#[arg(short, long)]
output: Option<PathBuf>,
#[arg(short, long)]
steam_id: Option<String>,
},
Encrypt {
#[arg(short, long)]
input: Option<PathBuf>,
#[arg(short, long)]
output: Option<PathBuf>,
#[arg(short, long)]
steam_id: Option<String>,
},
Edit {
#[arg(short, long)]
input: PathBuf,
#[arg(short, long)]
steam_id: Option<String>,
#[arg(short, long, default_value_t = true)]
backup: bool,
},
Inspect {
#[arg(short, long)]
input: PathBuf,
#[arg(short, long)]
steam_id: Option<String>,
#[arg(short, long)]
full: bool,
},
Get {
#[arg(short, long)]
input: PathBuf,
#[arg(short, long)]
steam_id: Option<String>,
query: Option<String>,
#[arg(long)]
level: bool,
#[arg(long)]
money: bool,
#[arg(long)]
info: bool,
#[arg(long)]
all: bool,
},
Set {
#[arg(short, long)]
input: PathBuf,
#[arg(short, long)]
steam_id: Option<String>,
path: String,
value: String,
#[arg(short, long)]
raw: bool,
#[arg(short, long, default_value_t = true)]
backup: bool,
},
Configure {
#[arg(long)]
steam_id: Option<String>,
#[arg(long)]
show: bool,
},
}
fn get_steam_id(provided: Option<String>) -> Result<String> {
if let Some(id) = provided {
return Ok(id);
}
let config = Config::load()?;
config.get_steam_id().map(String::from).context(
"Steam ID not provided. Run 'bl4 configure --steam-id YOUR_STEAM_ID' to set a default.",
)
}
fn main() -> Result<()> {
let cli = Cli::parse();
match cli.command {
Commands::Configure { steam_id, show } => {
let mut config = Config::load()?;
if show {
if let Some(id) = config.get_steam_id() {
println!("Steam ID: {}", id);
} else {
println!("No Steam ID configured");
}
if let Ok(path) = Config::config_path() {
println!("Config file: {}", path.display());
}
return Ok(());
}
if let Some(id) = steam_id {
config.set_steam_id(id.clone());
config.save()?;
println!("Steam ID configured: {}", id);
if let Ok(path) = Config::config_path() {
println!("Config saved to: {}", path.display());
}
} else {
println!("Usage: bl4 configure --steam-id YOUR_STEAM_ID");
println!(" or: bl4 configure --show");
println!();
println!("Note: Borderlands 4 uses your Steam ID to encrypt saves.");
println!(" Find it in the top left of your Steam account page.");
}
}
Commands::Decrypt {
input,
output,
steam_id,
} => {
let steam_id = get_steam_id(steam_id)?;
let encrypted = if let Some(path) = input {
eprintln!("Decrypting {} ...", path.display());
fs::read(&path).with_context(|| format!("Failed to read {}", path.display()))?
} else {
let mut buf = Vec::new();
io::stdin()
.read_to_end(&mut buf)
.context("Failed to read from stdin")?;
buf
};
let yaml_data =
bl4::decrypt_sav(&encrypted, &steam_id).context("Failed to decrypt save file")?;
if let Some(path) = output {
fs::write(&path, &yaml_data)
.with_context(|| format!("Failed to write {}", path.display()))?;
eprintln!("Decrypted to {}", path.display());
} else {
io::stdout()
.write_all(&yaml_data)
.context("Failed to write to stdout")?;
}
}
Commands::Encrypt {
input,
output,
steam_id,
} => {
let steam_id = get_steam_id(steam_id)?;
let yaml_data = if let Some(path) = input {
eprintln!("Encrypting {} ...", path.display());
fs::read(&path).with_context(|| format!("Failed to read {}", path.display()))?
} else {
let mut buf = Vec::new();
io::stdin()
.read_to_end(&mut buf)
.context("Failed to read from stdin")?;
buf
};
let encrypted =
bl4::encrypt_sav(&yaml_data, &steam_id).context("Failed to encrypt YAML data")?;
if let Some(path) = output {
fs::write(&path, &encrypted)
.with_context(|| format!("Failed to write {}", path.display()))?;
eprintln!("Encrypted to {}", path.display());
} else {
io::stdout()
.write_all(&encrypted)
.context("Failed to write to stdout")?;
}
}
Commands::Edit {
input,
steam_id,
backup,
} => {
let steam_id = get_steam_id(steam_id)?;
let editor_str = std::env::var("EDITOR").unwrap_or_else(|_| "vim".to_string());
let editor_parts: Vec<&str> = editor_str.split_whitespace().collect();
let (editor, editor_args) = if editor_parts.is_empty() {
("vim", vec![])
} else {
(editor_parts[0], editor_parts[1..].to_vec())
};
if backup {
let backup_created =
bl4::smart_backup(&input).context("Failed to manage backup")?;
if backup_created {
let (backup_path, _) = bl4::backup::backup_paths(&input);
eprintln!("Created backup: {}", backup_path.display());
} else {
eprintln!("Backup exists (preserving original)");
}
}
let encrypted =
fs::read(&input).with_context(|| format!("Failed to read {}", input.display()))?;
let yaml_data =
bl4::decrypt_sav(&encrypted, &steam_id).context("Failed to decrypt save file")?;
let temp_path = input.with_extension("yaml.tmp");
let abs_temp_path = std::fs::canonicalize(temp_path.parent().unwrap())
.unwrap()
.join(temp_path.file_name().unwrap());
fs::write(&abs_temp_path, &yaml_data).with_context(|| {
format!("Failed to write temp file {}", abs_temp_path.display())
})?;
eprintln!("Opening {} in {}...", abs_temp_path.display(), editor_str);
let mut cmd = Command::new(editor);
cmd.args(&editor_args);
cmd.arg(&abs_temp_path);
let status = cmd
.status()
.with_context(|| format!("Failed to launch editor: {}", editor))?;
if !status.success() {
bail!("Editor exited with non-zero status");
}
let modified_yaml =
fs::read(&abs_temp_path).context("Failed to read modified temp file")?;
let encrypted = bl4::encrypt_sav(&modified_yaml, &steam_id)
.context("Failed to encrypt modified YAML")?;
fs::write(&input, &encrypted)
.with_context(|| format!("Failed to write {}", input.display()))?;
fs::remove_file(&abs_temp_path).context("Failed to remove temp file")?;
if backup {
let (_, metadata_path) = bl4::backup::backup_paths(&input);
bl4::update_after_edit(&input, &metadata_path)
.context("Failed to update backup metadata")?;
}
eprintln!("Saved changes to {}", input.display());
}
Commands::Inspect {
input,
steam_id,
full,
} => {
let steam_id = get_steam_id(steam_id)?;
eprintln!("Inspecting {} ...\n", input.display());
let encrypted =
fs::read(&input).with_context(|| format!("Failed to read {}", input.display()))?;
let yaml_data =
bl4::decrypt_sav(&encrypted, &steam_id).context("Failed to decrypt save file")?;
if full {
println!("{}", String::from_utf8_lossy(&yaml_data));
} else {
let save: serde_yaml::Value =
serde_yaml::from_slice(&yaml_data).context("Failed to parse YAML")?;
println!("Save file structure:");
if let Some(obj) = save.as_mapping() {
for key in obj.keys() {
println!(" - {}", key.as_str().unwrap_or("?"));
}
}
println!("\nUse --full to see complete YAML");
}
}
Commands::Get {
input,
steam_id,
query,
level,
money,
info,
all,
} => {
let steam_id = get_steam_id(steam_id)?;
let encrypted =
fs::read(&input).with_context(|| format!("Failed to read {}", input.display()))?;
let yaml_data =
bl4::decrypt_sav(&encrypted, &steam_id).context("Failed to decrypt save file")?;
let save = bl4::SaveFile::from_yaml(&yaml_data).context("Failed to parse save file")?;
if let Some(query_path) = query {
let result = save.get(&query_path).context("Query failed")?;
println!("{}", serde_yaml::to_string(&result)?);
return Ok(());
}
let show_all = all || (!level && !money && !info);
if show_all || info {
if let Some(name) = save.get_character_name() {
println!("Character: {}", name);
}
if let Some(class) = save.get_character_class() {
println!("Class: {}", class);
}
if let Some(diff) = save.get_difficulty() {
println!("Difficulty: {}", diff);
}
if show_all || info {
println!();
}
}
if show_all || level {
if let Some((lvl, pts)) = save.get_character_level() {
println!("Character Level: {} ({} XP)", lvl, pts);
}
if let Some((lvl, pts)) = save.get_specialization_level() {
println!("Specialization Level: {} ({} XP)", lvl, pts);
}
if show_all || level {
println!();
}
}
if show_all || money {
if let Some(cash) = save.get_cash() {
println!("Cash: {}", cash);
}
if let Some(eridium) = save.get_eridium() {
println!("Eridium: {}", eridium);
}
}
}
Commands::Set {
input,
steam_id,
path,
value,
raw,
backup,
} => {
let steam_id = get_steam_id(steam_id)?;
if backup {
let backup_created =
bl4::smart_backup(&input).context("Failed to manage backup")?;
if backup_created {
let (backup_path, _) = bl4::backup::backup_paths(&input);
eprintln!("Created backup: {}", backup_path.display());
} else {
eprintln!("Backup exists (preserving original)");
}
}
let encrypted =
fs::read(&input).with_context(|| format!("Failed to read {}", input.display()))?;
let yaml_data =
bl4::decrypt_sav(&encrypted, &steam_id).context("Failed to decrypt save file")?;
let mut save =
bl4::SaveFile::from_yaml(&yaml_data).context("Failed to parse save file")?;
if raw {
eprintln!("Setting {} = {} (raw YAML)", path, value);
save.set_raw(&path, &value)
.context("Failed to set raw value")?;
} else {
let new_value = bl4::SaveFile::parse_value(&value);
eprintln!("Setting {} = {}", path, value);
save.set(&path, new_value).context("Failed to set value")?;
}
let modified_yaml = save.to_yaml().context("Failed to serialize YAML")?;
let encrypted = bl4::encrypt_sav(&modified_yaml, &steam_id)
.context("Failed to encrypt save file")?;
fs::write(&input, &encrypted)
.with_context(|| format!("Failed to write {}", input.display()))?;
if backup {
let (_, metadata_path) = bl4::backup::backup_paths(&input);
bl4::update_after_edit(&input, &metadata_path)
.context("Failed to update backup metadata")?;
}
eprintln!("Saved changes to {}", input.display());
}
}
Ok(())
}