#![allow(dead_code)]
use anyhow::{Context, Result};
use regex::Regex;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
use std::path::Path;
use std::process::Command;
use walkdir::WalkDir;
pub fn manufacturer_names() -> HashMap<&'static str, &'static str> {
bl4::reference::MANUFACTURERS
.iter()
.map(|m| (m.code, m.name))
.collect()
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExtractedManufacturer {
pub code: String,
pub name: String,
pub name_source: String,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub paths: Vec<String>,
}
pub fn extract_manufacturer_names_from_pak(
pak_manifest_path: &Path,
) -> Result<HashMap<String, ExtractedManufacturer>> {
let content =
fs::read_to_string(pak_manifest_path).context("Failed to read pak_manifest.json")?;
let manifest: PakManifest =
serde_json::from_str(&content).context("Failed to parse pak_manifest.json")?;
let mut manufacturers: HashMap<String, ExtractedManufacturer> = HashMap::new();
let code_in_filename = Regex::new(r"[/_]([A-Z]{3})_[A-Z]{2}[_.]").unwrap();
let weapon_anim_pattern = Regex::new(r"WeaponAnimation/[^/]+/([A-Za-z]+)/").unwrap();
let manufacturer_dir_pattern = Regex::new(r"_Manufacturer/([A-Z]{3})/").unwrap();
let ui_logo_pattern = Regex::new(
r"ui_art_manu_(?:logomark|logotype|itemcard_logomark|itemcard_logotype)_([a-z]+)",
)
.unwrap();
let potential_codes: std::collections::HashSet<&str> = [
"BOR", "DAD", "DPL", "JAK", "MAL", "ORD", "RIP", "TED", "TOR", "VLA", "COV", "GRV",
]
.iter()
.copied()
.collect();
let mut code_to_name: HashMap<String, (String, String, u8)> = HashMap::new();
for item in &manifest.items {
let path = &item.path;
let path_lower = path.to_lowercase();
if let Some(anim_cap) = weapon_anim_pattern.captures(path) {
let folder_name = anim_cap[1].to_string();
if let Some(code_cap) = code_in_filename.captures(path) {
let code = code_cap[1].to_string();
if potential_codes.contains(code.as_str()) {
let existing_priority =
code_to_name.get(&code).map(|(_, _, p)| *p).unwrap_or(0);
if existing_priority < 10 {
code_to_name.insert(
code.clone(),
(
folder_name.clone(),
format!("WeaponAnimation folder: {}", path),
10,
),
);
}
}
}
}
if let Some(mfr_cap) = manufacturer_dir_pattern.captures(path) {
let code = mfr_cap[1].to_string();
if potential_codes.contains(code.as_str()) {
let filename = path.split('/').next_back().unwrap_or("");
let filename_lower = filename.to_lowercase();
let candidate_names = [
("borg", "Borg"),
("daedalus", "Daedalus"),
("dahl", "Dahl"),
("jakobs", "Jakobs"),
("maliwan", "Maliwan"),
("order", "Order"),
("ripper", "Ripper"),
("tediore", "Tediore"),
("torgue", "Torgue"),
("vladof", "Vladof"),
("gravitar", "Gravitar"),
];
for (name_lower, name_title) in candidate_names {
if filename_lower.contains(name_lower) {
let existing_priority =
code_to_name.get(&code).map(|(_, _, p)| *p).unwrap_or(0);
if existing_priority < 9 {
code_to_name.insert(
code.clone(),
(
name_title.to_string(),
format!("_Manufacturer path: {}", path),
9,
),
);
}
break;
}
}
}
}
if path_lower.contains("ui_art_manu") {
if let Some(cap) = ui_logo_pattern.captures(&path_lower) {
let name = cap[1].to_string();
let name_title = name
.chars()
.enumerate()
.map(|(i, c)| if i == 0 { c.to_ascii_uppercase() } else { c })
.collect::<String>();
code_to_name
.entry(format!("UI_{}", name.to_uppercase()))
.or_insert((name_title, format!("UI logo: {}", path), 5));
}
}
}
let ui_to_code: Vec<(String, String)> = vec![
("UI_TORGUE".to_string(), "TOR".to_string()),
("UI_VLADOF".to_string(), "VLA".to_string()),
("UI_JAKOBS".to_string(), "JAK".to_string()),
("UI_MALIWAN".to_string(), "MAL".to_string()),
("UI_TEDIORE".to_string(), "TED".to_string()),
("UI_DAEDALUS".to_string(), "DAD".to_string()),
("UI_ORDER".to_string(), "ORD".to_string()),
("UI_RIPPER".to_string(), "RIP".to_string()),
("UI_COV".to_string(), "COV".to_string()),
("UI_BORG".to_string(), "BOR".to_string()),
];
for (ui_key, code) in ui_to_code {
if let Some((name, source, priority)) = code_to_name.get(&ui_key) {
let existing_priority = code_to_name.get(&code).map(|(_, _, p)| *p).unwrap_or(0);
if existing_priority < *priority {
code_to_name.insert(code.clone(), (name.clone(), source.clone(), *priority));
}
}
}
let code_pattern = Regex::new(r"/([A-Z]{3})/").unwrap();
for item in &manifest.items {
for cap in code_pattern.captures_iter(&item.path) {
let code = cap[1].to_string();
if !potential_codes.contains(code.as_str()) {
continue;
}
let mfr = manufacturers.entry(code.clone()).or_insert_with(|| {
let (name, source, _) = code_to_name.get(&code).cloned().unwrap_or_else(|| {
(
code.clone(),
"Code only (full name not discovered)".to_string(),
0,
)
});
ExtractedManufacturer {
code: code.clone(),
name,
name_source: source,
paths: Vec::new(),
}
});
if !mfr.paths.contains(&item.path) && mfr.paths.len() < 5 {
mfr.paths.push(item.path.clone());
}
}
}
for code in &manifest.manufacturers {
if !manufacturers.contains_key(code) {
let (name, source, _) = code_to_name.get(code).cloned().unwrap_or_else(|| {
(
code.clone(),
"Code only (full name not discovered)".to_string(),
0,
)
});
manufacturers.insert(
code.clone(),
ExtractedManufacturer {
code: code.clone(),
name,
name_source: source,
paths: Vec::new(),
},
);
}
}
Ok(manufacturers)
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExtractedWeaponType {
pub internal_name: String,
pub code: String,
pub manufacturers: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub example_paths: Vec<String>,
}
pub fn extract_weapon_types_from_pak(
pak_manifest_path: &Path,
) -> Result<HashMap<String, ExtractedWeaponType>> {
let content =
fs::read_to_string(pak_manifest_path).context("Failed to read pak_manifest.json")?;
let manifest: PakManifest =
serde_json::from_str(&content).context("Failed to parse pak_manifest.json")?;
let mut weapon_types: HashMap<String, ExtractedWeaponType> = HashMap::new();
let weapon_path_pattern = Regex::new(r"/Gear/Weapons/([^_/][^/]*)/([A-Z]{3})/").unwrap();
let heavy_weapon_pattern = Regex::new(r"/Gear/Gadgets/HeavyWeapons/([A-Z]{3})/").unwrap();
let type_to_code: HashMap<&str, &str> = [
("AssaultRifles", "AR"),
("Pistols", "PS"),
("Shotguns", "SG"),
("SMG", "SM"),
("Sniper", "SR"),
("HeavyWeapons", "HW"),
]
.iter()
.cloned()
.collect();
for item in &manifest.items {
let path = &item.path;
if let Some(cap) = weapon_path_pattern.captures(path) {
let weapon_type = cap[1].to_string();
let mfr_code = cap[2].to_string();
if weapon_type.starts_with('_')
|| weapon_type == "Materials"
|| weapon_type == "Textures"
|| weapon_type == "Systems"
|| weapon_type == "Uniques"
{
continue;
}
let code = type_to_code
.get(weapon_type.as_str())
.map(|s| s.to_string())
.unwrap_or_else(|| {
weapon_type
.chars()
.take(2)
.collect::<String>()
.to_uppercase()
});
let wt =
weapon_types
.entry(weapon_type.clone())
.or_insert_with(|| ExtractedWeaponType {
internal_name: weapon_type.clone(),
code,
manufacturers: Vec::new(),
example_paths: Vec::new(),
});
if !wt.manufacturers.contains(&mfr_code) {
wt.manufacturers.push(mfr_code);
}
if wt.example_paths.len() < 3 {
wt.example_paths.push(path.clone());
}
}
if let Some(cap) = heavy_weapon_pattern.captures(path) {
let mfr_code = cap[1].to_string();
let wt = weapon_types
.entry("HeavyWeapons".to_string())
.or_insert_with(|| ExtractedWeaponType {
internal_name: "HeavyWeapons".to_string(),
code: "HW".to_string(),
manufacturers: Vec::new(),
example_paths: Vec::new(),
});
if !wt.manufacturers.contains(&mfr_code) {
wt.manufacturers.push(mfr_code);
}
if wt.example_paths.len() < 3 {
wt.example_paths.push(path.clone());
}
}
}
for wt in weapon_types.values_mut() {
wt.manufacturers.sort();
}
Ok(weapon_types)
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExtractedGearType {
pub internal_name: String,
pub manufacturers: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub subcategories: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub example_paths: Vec<String>,
}
pub fn extract_gear_types_from_pak(
pak_manifest_path: &Path,
) -> Result<HashMap<String, ExtractedGearType>> {
let content =
fs::read_to_string(pak_manifest_path).context("Failed to read pak_manifest.json")?;
let manifest: PakManifest =
serde_json::from_str(&content).context("Failed to parse pak_manifest.json")?;
let mut gear_types: HashMap<String, ExtractedGearType> = HashMap::new();
let gear_mfr_pattern = Regex::new(r"/Gear/([^_/][^/]*)/Manufacturer/([A-Z]{3})/").unwrap();
let gadget_pattern = Regex::new(r"/Gear/Gadgets/([^_/][^/]*)/([A-Z]{3})/").unwrap();
let gear_path_pattern = Regex::new(r"/Gear/([^_/][^/]*)/").unwrap();
for item in &manifest.items {
let path = &item.path;
if let Some(cap) = gear_mfr_pattern.captures(path) {
let gear_type = cap[1].to_string();
let mfr_code = cap[2].to_string();
let gear_type_normalized = if gear_type.to_lowercase() == "shields" {
"Shields".to_string()
} else {
gear_type
};
let gt = gear_types
.entry(gear_type_normalized.clone())
.or_insert_with(|| ExtractedGearType {
internal_name: gear_type_normalized.clone(),
manufacturers: Vec::new(),
subcategories: Vec::new(),
example_paths: Vec::new(),
});
if !gt.manufacturers.contains(&mfr_code) {
gt.manufacturers.push(mfr_code);
}
if gt.example_paths.len() < 3 {
gt.example_paths.push(path.clone());
}
}
if let Some(cap) = gadget_pattern.captures(path) {
let subcategory = cap[1].to_string();
let mfr_code = cap[2].to_string();
if subcategory == "HeavyWeapons" {
continue;
}
let gt = gear_types
.entry("Gadgets".to_string())
.or_insert_with(|| ExtractedGearType {
internal_name: "Gadgets".to_string(),
manufacturers: Vec::new(),
subcategories: Vec::new(),
example_paths: Vec::new(),
});
if !gt.subcategories.contains(&subcategory) {
gt.subcategories.push(subcategory);
}
if !gt.manufacturers.contains(&mfr_code) {
gt.manufacturers.push(mfr_code);
}
if gt.example_paths.len() < 3 {
gt.example_paths.push(path.clone());
}
}
if let Some(cap) = gear_path_pattern.captures(path) {
let gear_type = cap[1].to_string();
if gear_type.starts_with('_')
|| gear_type == "Weapons"
|| gear_type.to_lowercase() == "shields"
|| gear_type == "GrenadeGadgets"
|| gear_type == "Gadgets"
|| gear_type == "Effects"
{
continue;
}
let gt = gear_types
.entry(gear_type.clone())
.or_insert_with(|| ExtractedGearType {
internal_name: gear_type.clone(),
manufacturers: Vec::new(),
subcategories: Vec::new(),
example_paths: Vec::new(),
});
if gt.example_paths.len() < 3 && !gt.example_paths.contains(&path.clone()) {
gt.example_paths.push(path.clone());
}
}
}
for gt in gear_types.values_mut() {
gt.manufacturers.sort();
gt.subcategories.sort();
}
Ok(gear_types)
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExtractedElement {
pub internal_name: String,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub example_paths: Vec<String>,
}
pub fn extract_elements_from_pak(
pak_manifest_path: &Path,
) -> Result<HashMap<String, ExtractedElement>> {
let content =
fs::read_to_string(pak_manifest_path).context("Failed to read pak_manifest.json")?;
let manifest: PakManifest =
serde_json::from_str(&content).context("Failed to parse pak_manifest.json")?;
let mut elements: HashMap<String, ExtractedElement> = HashMap::new();
let element_pattern =
Regex::new(r"/(?:Effects|Materials)/(?:Textures|Materials)?/?Elements/([A-Za-z]+)/")
.unwrap();
for item in &manifest.items {
let path = &item.path;
if let Some(cap) = element_pattern.captures(path) {
let element_name = cap[1].to_string();
if element_name.len() < 3 {
continue;
}
let elem = elements
.entry(element_name.clone())
.or_insert_with(|| ExtractedElement {
internal_name: element_name.clone(),
example_paths: Vec::new(),
});
if elem.example_paths.len() < 3 {
elem.example_paths.push(path.clone());
}
}
}
Ok(elements)
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExtractedRarity {
pub tier: u8,
pub code: String,
pub name: String,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub example_paths: Vec<String>,
}
pub fn extract_rarities_from_pak(pak_manifest_path: &Path) -> Result<Vec<ExtractedRarity>> {
let content =
fs::read_to_string(pak_manifest_path).context("Failed to read pak_manifest.json")?;
let manifest: PakManifest =
serde_json::from_str(&content).context("Failed to parse pak_manifest.json")?;
let mut rarities: HashMap<u8, ExtractedRarity> = HashMap::new();
let rarity_pip_pattern = Regex::new(r"rarity_pip_(\d{2})_([a-z]+)").unwrap();
let comp_pattern = Regex::new(r"comp_(\d{2})_([a-z]+)").unwrap();
for item in &manifest.items {
let path = &item.path;
let path_lower = path.to_lowercase();
if let Some(cap) = rarity_pip_pattern.captures(&path_lower) {
let tier: u8 = cap[1].parse().unwrap_or(0);
let name = cap[2].to_string();
if (1..=5).contains(&tier) {
let rarity = rarities.entry(tier).or_insert_with(|| ExtractedRarity {
tier,
code: format!("comp_{:02}", tier),
name: name.clone(),
example_paths: Vec::new(),
});
if rarity.example_paths.len() < 3 {
rarity.example_paths.push(path.clone());
}
}
}
for prop in &item.property_names {
let prop_lower = prop.to_lowercase();
if let Some(cap) = comp_pattern.captures(&prop_lower) {
let tier: u8 = cap[1].parse().unwrap_or(0);
let name = cap[2].to_string();
if (1..=5).contains(&tier) {
let rarity = rarities.entry(tier).or_insert_with(|| ExtractedRarity {
tier,
code: format!("comp_{:02}", tier),
name: name.clone(),
example_paths: Vec::new(),
});
if rarity.name.is_empty() || rarity.name == "unknown" {
rarity.name = name;
}
}
}
}
}
let mut result: Vec<ExtractedRarity> = rarities.into_values().collect();
result.sort_by_key(|r| r.tier);
Ok(result)
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExtractedStat {
pub name: String,
pub modifier_types: Vec<String>,
pub occurrences: usize,
}
pub fn extract_stats_from_pak(pak_manifest_path: &Path) -> Result<Vec<ExtractedStat>> {
let content =
fs::read_to_string(pak_manifest_path).context("Failed to read pak_manifest.json")?;
let manifest: PakManifest =
serde_json::from_str(&content).context("Failed to parse pak_manifest.json")?;
let mut stats: HashMap<String, (std::collections::HashSet<String>, usize)> = HashMap::new();
let stat_pattern =
Regex::new(r"^([A-Z][a-zA-Z]+)_(Scale|Add|Value|Percent)_\d+_[A-F0-9]{32}$").unwrap();
let simple_stat_pattern = Regex::new(r"^([A-Z][a-zA-Z]+)_\d+_[A-F0-9]{32}$").unwrap();
let stat_prefixes = [
"Accuracy",
"Damage",
"CritDamage",
"FireRate",
"ReloadTime",
"ReloadSpeed",
"MagSize",
"Spread",
"Recoil",
"Sway",
"Ammo",
"AmmoCost",
"Capacity",
"Cooldown",
"Duration",
"Healing",
"Health",
"Impulse",
"Projectile",
"Radius",
"Regen",
"Speed",
"StatusChance",
"StatusDamage",
"ElementalPower",
"DamageRadius",
"EquipTime",
"PutDownTime",
"ZoomDuration",
"AccImpulse",
"AccRegen",
"AccDelay",
"ProjectilesPerShot",
];
for item in &manifest.items {
for prop in &item.property_names {
if let Some(cap) = stat_pattern.captures(prop) {
let stat_name = cap[1].to_string();
let modifier_type = cap[2].to_string();
let entry = stats
.entry(stat_name)
.or_insert_with(|| (std::collections::HashSet::new(), 0));
entry.0.insert(modifier_type);
entry.1 += 1;
}
if let Some(cap) = simple_stat_pattern.captures(prop) {
let stat_name = cap[1].to_string();
if stat_prefixes.contains(&stat_name.as_str()) {
let entry = stats
.entry(stat_name)
.or_insert_with(|| (std::collections::HashSet::new(), 0));
entry.1 += 1;
}
}
}
}
let mut result: Vec<ExtractedStat> = stats
.into_iter()
.map(|(name, (modifiers, count))| {
let mut modifier_types: Vec<String> = modifiers.into_iter().collect();
modifier_types.sort();
ExtractedStat {
name,
modifier_types,
occurrences: count,
}
})
.collect();
result.sort_by(|a, b| b.occurrences.cmp(&a.occurrences));
Ok(result)
}
pub fn stat_descriptions() -> HashMap<&'static str, &'static str> {
bl4::reference::all_stat_descriptions()
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Manufacturer {
pub code: String,
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub path: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub balance_data_path: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct WeaponType {
pub name: String,
pub path: String,
pub manufacturers: Vec<ManufacturerRef>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct ManufacturerRef {
pub code: String,
pub name: String,
pub path: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct StatEntry {
pub index: u32,
pub guid: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct StatProperty {
pub stat: String,
#[serde(rename = "type")]
pub modifier_type: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
pub entries: Vec<StatEntry>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct PropertyEntry {
pub index: u32,
pub guid: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct AssetInfo {
pub name: String,
pub file: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub path: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub stats: Option<HashMap<String, StatProperty>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub properties: Option<HashMap<String, Vec<PropertyEntry>>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub raw_strings: Option<Vec<String>>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct BalanceCategory {
pub name: String,
pub path: String,
pub assets: Vec<AssetInfo>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct GearType {
pub name: String,
pub path: String,
pub balance_data: Vec<AssetInfo>,
pub manufacturers: Vec<ManufacturerRef>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct ManifestIndex {
pub version: String,
pub source: String,
pub extract_path: String,
pub files: HashMap<String, String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ItemPool {
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub path: Option<String>,
pub referenced_by: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub contains: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ItemStats {
pub name: String,
pub category: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub manufacturer: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub rarity: Option<String>,
pub stats: HashMap<String, Vec<StatModifier>>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub drop_pools: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StatModifier {
pub modifier_type: String,
pub index: u32,
pub guid: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct ItemsDatabase {
pub version: String,
pub generated: String,
pub item_pools: HashMap<String, ItemPool>,
pub items: Vec<ItemStats>,
pub stats_summary: StatsSummary,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct StatsSummary {
pub total_items: usize,
pub total_pools: usize,
pub stat_types: Vec<String>,
pub categories: Vec<String>,
pub manufacturers: Vec<String>,
}
pub fn extract_strings(uasset_path: &Path) -> Result<String> {
let output = Command::new("strings")
.arg(uasset_path)
.output()
.context("Failed to run strings command")?;
Ok(String::from_utf8_lossy(&output.stdout).to_string())
}
pub fn parse_property_strings(content: &str) -> HashMap<String, Vec<PropertyEntry>> {
let pattern = Regex::new(r"([A-Za-z_]+)_(\d+)_([A-F0-9]{32})").unwrap();
let mut properties: HashMap<String, Vec<PropertyEntry>> = HashMap::new();
for cap in pattern.captures_iter(content) {
let prop_name = cap[1].to_string();
let prop_index: u32 = cap[2].parse().unwrap_or(0);
let prop_guid = cap[3].to_string();
properties
.entry(prop_name)
.or_default()
.push(PropertyEntry {
index: prop_index,
guid: prop_guid,
});
}
properties
}
pub fn parse_stat_properties(content: &str) -> HashMap<String, StatProperty> {
let pattern =
Regex::new(r"([A-Za-z_]+)_(Scale|Add|Value|Percent)_(\d+)_([A-F0-9]{32})").unwrap();
let stat_desc = stat_descriptions();
let mut stats: HashMap<String, StatProperty> = HashMap::new();
for cap in pattern.captures_iter(content) {
let stat_name = cap[1].to_string();
let modifier_type = cap[2].to_string();
let stat_index: u32 = cap[3].parse().unwrap_or(0);
let stat_guid = cap[4].to_string();
let key = format!("{}_{}", stat_name, modifier_type);
let entry = stats.entry(key).or_insert_with(|| StatProperty {
stat: stat_name.clone(),
modifier_type: modifier_type.clone(),
description: stat_desc.get(stat_name.as_str()).map(|s| s.to_string()),
entries: Vec::new(),
});
entry.entries.push(StatEntry {
index: stat_index,
guid: stat_guid,
});
}
stats
}
pub fn extract_manufacturers(extract_dir: &Path) -> HashMap<String, Manufacturer> {
let mfr_names = manufacturer_names();
let mut manufacturers: HashMap<String, Manufacturer> = HashMap::new();
let search_paths = [
"OakGame/Content/Gear/Weapons/_Manufacturer",
"OakGame/Content/Gear/Weapons/_Shared/BalanceData",
"OakGame/Content/Gear/Weapons/_Shared/Materials",
"OakGame/Content/Gear/_Shared/Materials/Materials",
"OakGame/Content/Gear/GrenadeGadgets/Manufacturer",
"OakGame/Content/Gear/shields/Manufacturer",
"OakGame/Content/Gear/Gadgets/Turrets",
];
let weapon_types_dir = extract_dir.join("OakGame/Content/Gear/Weapons");
if weapon_types_dir.exists() {
if let Ok(entries) = fs::read_dir(&weapon_types_dir) {
for entry in entries.flatten() {
if entry.path().is_dir() {
let dir_name = entry.file_name().to_string_lossy().to_string();
if !dir_name.starts_with('_') {
if let Ok(mfr_entries) = fs::read_dir(entry.path()) {
for mfr_entry in mfr_entries.flatten() {
if mfr_entry.path().is_dir() {
let code = mfr_entry.file_name().to_string_lossy().to_string();
if mfr_names.contains_key(code.as_str()) {
manufacturers.entry(code.clone()).or_insert_with(|| {
Manufacturer {
code: code.clone(),
name: mfr_names
.get(code.as_str())
.unwrap_or(&code.as_str())
.to_string(),
path: None,
balance_data_path: None,
}
});
}
}
}
}
}
}
}
}
}
for search_path in &search_paths {
let search_dir = extract_dir.join(search_path);
if !search_dir.exists() {
continue;
}
if let Ok(entries) = fs::read_dir(&search_dir) {
for entry in entries.flatten() {
if entry.path().is_dir() {
let code = entry.file_name().to_string_lossy().to_string();
if mfr_names.contains_key(code.as_str()) {
let rel_path = entry
.path()
.strip_prefix(extract_dir)
.map(|p| p.to_string_lossy().to_string())
.ok();
let is_balance_data = search_path.contains("BalanceData");
manufacturers
.entry(code.clone())
.and_modify(|m| {
if is_balance_data && m.balance_data_path.is_none() {
m.balance_data_path = rel_path.clone();
} else if !is_balance_data && m.path.is_none() {
m.path = rel_path.clone();
}
})
.or_insert(Manufacturer {
code: code.clone(),
name: mfr_names
.get(code.as_str())
.unwrap_or(&code.as_str())
.to_string(),
path: if is_balance_data {
None
} else {
rel_path.clone()
},
balance_data_path: if is_balance_data { rel_path } else { None },
});
}
}
}
}
}
manufacturers
}
pub fn extract_weapon_types(extract_dir: &Path) -> HashMap<String, WeaponType> {
let mfr_names = manufacturer_names();
let mut weapon_types: HashMap<String, WeaponType> = HashMap::new();
let weapons_dir = extract_dir.join("OakGame/Content/Gear/Weapons");
if !weapons_dir.exists() {
return weapon_types;
}
if let Ok(entries) = fs::read_dir(&weapons_dir) {
for entry in entries.flatten() {
if entry.path().is_dir() {
let type_name = entry.file_name().to_string_lossy().to_string();
if type_name.starts_with('_') {
continue;
}
let rel_path = entry
.path()
.strip_prefix(extract_dir)
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_default();
let mut manufacturers = Vec::new();
if let Ok(mfr_entries) = fs::read_dir(entry.path()) {
for mfr_entry in mfr_entries.flatten() {
if mfr_entry.path().is_dir() {
let code = mfr_entry.file_name().to_string_lossy().to_string();
let mfr_rel_path = mfr_entry
.path()
.strip_prefix(extract_dir)
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_default();
manufacturers.push(ManufacturerRef {
code: code.clone(),
name: mfr_names
.get(code.as_str())
.unwrap_or(&code.as_str())
.to_string(),
path: mfr_rel_path,
});
}
}
}
weapon_types.insert(
type_name.clone(),
WeaponType {
name: type_name,
path: rel_path,
manufacturers,
},
);
}
}
}
weapon_types
}
pub fn extract_balance_data(extract_dir: &Path) -> Result<HashMap<String, BalanceCategory>> {
let mut balance_data: HashMap<String, BalanceCategory> = HashMap::new();
let balance_dir = extract_dir.join("OakGame/Content/Gear/Weapons/_Shared/BalanceData");
if !balance_dir.exists() {
return Ok(balance_data);
}
if let Ok(entries) = fs::read_dir(&balance_dir) {
for entry in entries.flatten() {
if entry.path().is_dir() {
let category_name = entry.file_name().to_string_lossy().to_string();
let rel_path = entry
.path()
.strip_prefix(extract_dir)
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_default();
let mut assets = Vec::new();
for asset_entry in WalkDir::new(entry.path())
.into_iter()
.filter_map(|e| e.ok())
.filter(|e| {
e.path()
.extension()
.map(|ext| ext == "uasset")
.unwrap_or(false)
})
{
let asset_path = asset_entry.path();
let mut asset_info = AssetInfo {
name: asset_path
.file_stem()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_default(),
file: asset_path
.file_name()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_default(),
path: asset_path
.strip_prefix(extract_dir)
.map(|p| p.to_string_lossy().to_string())
.ok(),
stats: None,
properties: None,
raw_strings: None,
};
if let Ok(content) = extract_strings(asset_path) {
let stats = parse_stat_properties(&content);
if !stats.is_empty() {
asset_info.stats = Some(stats);
}
let props = parse_property_strings(&content);
if !props.is_empty() {
asset_info.properties = Some(props);
}
}
assets.push(asset_info);
}
balance_data.insert(
category_name.clone(),
BalanceCategory {
name: category_name,
path: rel_path,
assets,
},
);
}
}
}
Ok(balance_data)
}
pub fn extract_naming_data(extract_dir: &Path) -> Result<HashMap<String, AssetInfo>> {
let mut naming_data: HashMap<String, AssetInfo> = HashMap::new();
let naming_dir = extract_dir.join("OakGame/Content/Gear/Weapons/_Shared/NamingStrategies");
if !naming_dir.exists() {
return Ok(naming_data);
}
for entry in WalkDir::new(&naming_dir)
.into_iter()
.filter_map(|e| e.ok())
.filter(|e| {
e.path()
.extension()
.map(|ext| ext == "uasset")
.unwrap_or(false)
})
{
let asset_path = entry.path();
let name = asset_path
.file_stem()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_default();
let mut asset_info = AssetInfo {
name: name.clone(),
file: asset_path
.file_name()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_default(),
path: asset_path
.strip_prefix(extract_dir)
.map(|p| p.to_string_lossy().to_string())
.ok(),
stats: None,
properties: None,
raw_strings: None,
};
if let Ok(content) = extract_strings(asset_path) {
let props = parse_property_strings(&content);
if !props.is_empty() {
asset_info.properties = Some(props);
}
}
naming_data.insert(name, asset_info);
}
Ok(naming_data)
}
pub fn extract_gear_types(extract_dir: &Path) -> HashMap<String, GearType> {
let mfr_names = manufacturer_names();
let mut gear_types: HashMap<String, GearType> = HashMap::new();
let gear_dir = extract_dir.join("OakGame/Content/Gear");
if !gear_dir.exists() {
return gear_types;
}
if let Ok(entries) = fs::read_dir(&gear_dir) {
for entry in entries.flatten() {
if !entry.path().is_dir() {
continue;
}
let type_name = entry.file_name().to_string_lossy().to_string();
if type_name == "Weapons" || type_name.starts_with('_') {
continue;
}
let rel_path = entry
.path()
.strip_prefix(extract_dir)
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_default();
let mut balance_data = Vec::new();
let mut manufacturers = Vec::new();
for bd_entry in WalkDir::new(entry.path())
.into_iter()
.filter_map(|e| e.ok())
.filter(|e| {
e.path().to_string_lossy().contains("BalanceData")
&& e.path()
.extension()
.map(|ext| ext == "uasset")
.unwrap_or(false)
})
{
let asset_path = bd_entry.path();
let mut asset_info = AssetInfo {
name: asset_path
.file_stem()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_default(),
file: asset_path
.file_name()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_default(),
path: asset_path
.strip_prefix(extract_dir)
.map(|p| p.to_string_lossy().to_string())
.ok(),
stats: None,
properties: None,
raw_strings: None,
};
if let Ok(content) = extract_strings(asset_path) {
let stats = parse_stat_properties(&content);
if !stats.is_empty() {
asset_info.stats = Some(stats);
}
}
balance_data.push(asset_info);
}
let mfr_dir = entry.path().join("Manufacturer");
if mfr_dir.exists() {
if let Ok(mfr_entries) = fs::read_dir(&mfr_dir) {
for mfr_entry in mfr_entries.flatten() {
if mfr_entry.path().is_dir() {
let code = mfr_entry.file_name().to_string_lossy().to_string();
manufacturers.push(ManufacturerRef {
code: code.clone(),
name: mfr_names
.get(code.as_str())
.unwrap_or(&code.as_str())
.to_string(),
path: mfr_entry
.path()
.strip_prefix(extract_dir)
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_default(),
});
}
}
}
}
gear_types.insert(
type_name.clone(),
GearType {
name: type_name,
path: rel_path,
balance_data,
manufacturers,
},
);
}
}
gear_types
}
pub fn extract_rarity_data(extract_dir: &Path) -> HashMap<String, AssetInfo> {
let mut rarity_data: HashMap<String, AssetInfo> = HashMap::new();
let rarity_paths = [
extract_dir.join("OakGame/Content/Gear/Weapons/_Shared/BalanceData/Rarity"),
extract_dir.join("OakGame/Content/Gear/_Shared/BalanceData/Rarity"),
];
for rarity_dir in &rarity_paths {
if !rarity_dir.exists() {
continue;
}
for entry in WalkDir::new(rarity_dir)
.into_iter()
.filter_map(|e| e.ok())
.filter(|e| {
e.path()
.extension()
.map(|ext| ext == "uasset")
.unwrap_or(false)
})
{
let asset_path = entry.path();
let name = asset_path
.file_stem()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_default();
let mut raw_strings = None;
if let Ok(content) = extract_strings(asset_path) {
let strings: Vec<String> = content
.lines()
.filter(|s| !s.is_empty() && s.len() < 200)
.take(50)
.map(String::from)
.collect();
if !strings.is_empty() {
raw_strings = Some(strings);
}
}
rarity_data.insert(
name.clone(),
AssetInfo {
name: name.clone(),
file: asset_path
.file_name()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_default(),
path: asset_path
.strip_prefix(extract_dir)
.map(|p| p.to_string_lossy().to_string())
.ok(),
stats: None,
properties: None,
raw_strings,
},
);
}
}
rarity_data
}
pub fn extract_elemental_data(extract_dir: &Path) -> HashMap<String, AssetInfo> {
let mut elemental_data: HashMap<String, AssetInfo> = HashMap::new();
let elemental_dir =
extract_dir.join("OakGame/Content/Gear/Weapons/_Shared/BalanceData/Elemental");
if !elemental_dir.exists() {
return elemental_data;
}
for entry in WalkDir::new(&elemental_dir)
.into_iter()
.filter_map(|e| e.ok())
.filter(|e| {
e.path()
.extension()
.map(|ext| ext == "uasset")
.unwrap_or(false)
})
{
let asset_path = entry.path();
let name = asset_path
.file_stem()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_default();
let mut raw_strings = None;
if let Ok(content) = extract_strings(asset_path) {
let strings: Vec<String> = content
.lines()
.filter(|s| !s.is_empty() && s.len() < 200)
.take(50)
.map(String::from)
.collect();
if !strings.is_empty() {
raw_strings = Some(strings);
}
}
elemental_data.insert(
name.clone(),
AssetInfo {
name: name.clone(),
file: asset_path
.file_name()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_default(),
path: asset_path
.strip_prefix(extract_dir)
.map(|p| p.to_string_lossy().to_string())
.ok(),
stats: None,
properties: None,
raw_strings,
},
);
}
elemental_data
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RarityTier {
pub tier: u8,
pub code: String,
pub name: String,
pub color: String,
}
pub fn rarity_tiers() -> Vec<RarityTier> {
bl4::reference::RARITY_TIERS
.iter()
.map(|r| RarityTier {
tier: r.tier,
code: r.code.to_string(),
name: r.name.to_string(),
color: r.color.to_string(),
})
.collect()
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ElementType {
pub code: String,
pub name: String,
pub description: String,
pub color: String,
}
pub fn element_types() -> Vec<ElementType> {
bl4::reference::ELEMENT_TYPES
.iter()
.map(|e| ElementType {
code: e.code.to_string(),
name: e.name.to_string(),
description: e.description.to_string(),
color: e.color.to_string(),
})
.collect()
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LegendaryItem {
pub internal: String,
pub name: String,
pub weapon_type: String,
pub manufacturer: String,
}
pub fn known_legendaries() -> Vec<LegendaryItem> {
bl4::reference::KNOWN_LEGENDARIES
.iter()
.map(|l| LegendaryItem {
internal: l.internal.to_string(),
name: l.name.to_string(),
weapon_type: l.weapon_type.to_string(),
manufacturer: l.manufacturer.to_string(),
})
.collect()
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WeaponTypeInfo {
pub code: String,
pub name: String,
pub description: String,
}
pub fn weapon_type_info() -> Vec<WeaponTypeInfo> {
bl4::reference::WEAPON_TYPES
.iter()
.map(|w| WeaponTypeInfo {
code: w.code.to_string(),
name: w.name.to_string(),
description: w.description.to_string(),
})
.collect()
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ManufacturerInfo {
pub code: String,
pub name: String,
pub weapon_types: Vec<String>,
pub style: String,
}
pub fn manufacturer_info() -> Vec<ManufacturerInfo> {
bl4::reference::MANUFACTURERS
.iter()
.map(|m| ManufacturerInfo {
code: m.code.to_string(),
name: m.name.to_string(),
weapon_types: m.weapon_types.iter().map(|s| s.to_string()).collect(),
style: m.style.to_string(),
})
.collect()
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GearTypeInfo {
pub code: String,
pub name: String,
pub description: String,
}
pub fn gear_type_info() -> Vec<GearTypeInfo> {
bl4::reference::GEAR_TYPES
.iter()
.map(|g| GearTypeInfo {
code: g.code.to_string(),
name: g.name.to_string(),
description: g.description.to_string(),
})
.collect()
}
#[derive(Debug, Serialize, Deserialize)]
pub struct ConsolidatedManifest {
pub version: String,
pub game: String,
pub description: String,
pub manufacturers: Vec<ManufacturerInfo>,
pub weapon_types: Vec<WeaponTypeInfo>,
pub gear_types: Vec<GearTypeInfo>,
pub rarities: Vec<RarityTier>,
pub elements: Vec<ElementType>,
pub stats: HashMap<String, String>,
pub legendaries: Vec<LegendaryItem>,
}
pub fn generate_reference_manifest(output_dir: &Path) -> Result<()> {
fs::create_dir_all(output_dir).context("Failed to create output directory")?;
println!("Generating consolidated reference manifest (HARDCODED - NOT AUTHORITATIVE)...");
let readme = r#"# Reference Data
WARNING: The files in this directory contain HARDCODED REFERENCE DATA.
They are NOT extracted from game files and should NOT be used in implementation.
These files exist to:
- Document known game data structures
- Provide a starting point for extraction work
- Allow quick prototyping before proper extraction
For authoritative data, use files in the parent directory (share/manifest/)
which are generated by extraction commands that read actual game files.
## Files
- manufacturers.json - Known manufacturer codes and names (HARDCODED)
- weapon_types.json - Weapon type codes (HARDCODED)
- gear_types.json - Gear type codes (HARDCODED)
- rarities.json - Rarity tiers with colors (HARDCODED)
- elements.json - Element types (HARDCODED)
- stats.json - Stat property names (HARDCODED)
- legendaries.json - Known legendary items (HARDCODED)
- reference.json - All above consolidated (HARDCODED)
- parts_database_spreadsheet.json - Community spreadsheet data (EXTERNAL)
"#;
fs::write(output_dir.join("README.md"), readme)?;
println!(" README.md - documentation");
let stats: HashMap<String, String> = stat_descriptions()
.into_iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect();
let manifest = ConsolidatedManifest {
version: env!("CARGO_PKG_VERSION").to_string(),
game: "Borderlands 4".to_string(),
description: "REFERENCE DATA ONLY - Hardcoded, not extracted from game".to_string(),
manufacturers: manufacturer_info(),
weapon_types: weapon_type_info(),
gear_types: gear_type_info(),
rarities: rarity_tiers(),
elements: element_types(),
stats,
legendaries: known_legendaries(),
};
let mfr_path = output_dir.join("manufacturers.json");
fs::write(
&mfr_path,
serde_json::to_string_pretty(&manifest.manufacturers)?,
)?;
println!(
" manufacturers.json - {} entries",
manifest.manufacturers.len()
);
let wt_path = output_dir.join("weapon_types.json");
fs::write(
&wt_path,
serde_json::to_string_pretty(&manifest.weapon_types)?,
)?;
println!(
" weapon_types.json - {} entries",
manifest.weapon_types.len()
);
let gt_path = output_dir.join("gear_types.json");
fs::write(
>_path,
serde_json::to_string_pretty(&manifest.gear_types)?,
)?;
println!(" gear_types.json - {} entries", manifest.gear_types.len());
let rarity_path = output_dir.join("rarities.json");
fs::write(
&rarity_path,
serde_json::to_string_pretty(&manifest.rarities)?,
)?;
println!(" rarities.json - {} entries", manifest.rarities.len());
let elem_path = output_dir.join("elements.json");
fs::write(
&elem_path,
serde_json::to_string_pretty(&manifest.elements)?,
)?;
println!(" elements.json - {} entries", manifest.elements.len());
let stats_path = output_dir.join("stats.json");
fs::write(&stats_path, serde_json::to_string_pretty(&manifest.stats)?)?;
println!(" stats.json - {} entries", manifest.stats.len());
let leg_path = output_dir.join("legendaries.json");
fs::write(
&leg_path,
serde_json::to_string_pretty(&manifest.legendaries)?,
)?;
println!(
" legendaries.json - {} entries",
manifest.legendaries.len()
);
let consolidated_path = output_dir.join("reference.json");
fs::write(&consolidated_path, serde_json::to_string_pretty(&manifest)?)?;
println!(" reference.json - consolidated reference data");
let mut files = HashMap::new();
files.insert(
"manufacturers".to_string(),
"manufacturers.json".to_string(),
);
files.insert("weapon_types".to_string(), "weapon_types.json".to_string());
files.insert("gear_types".to_string(), "gear_types.json".to_string());
files.insert("rarities".to_string(), "rarities.json".to_string());
files.insert("elements".to_string(), "elements.json".to_string());
files.insert("stats".to_string(), "stats.json".to_string());
files.insert("legendaries".to_string(), "legendaries.json".to_string());
files.insert("reference".to_string(), "reference.json".to_string());
let index = ManifestIndex {
version: env!("CARGO_PKG_VERSION").to_string(),
source: "HARDCODED REFERENCE DATA - NOT EXTRACTED FROM GAME".to_string(),
extract_path: output_dir.to_string_lossy().to_string(),
files,
};
let index_path = output_dir.join("index.json");
fs::write(&index_path, serde_json::to_string_pretty(&index)?)?;
println!(" index.json");
println!("\nReference manifest saved to {:?}", output_dir);
println!("WARNING: This is REFERENCE DATA ONLY - do not use in implementation!");
Ok(())
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UextractProperty {
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub value_type: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub float_value: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub int_value: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub string_value: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UextractExport {
pub index: usize,
pub object_name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub class_index: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub super_index: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub template_index: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub outer_index: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub public_export_hash: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cooked_serial_offset: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cooked_serial_size: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub properties: Option<Vec<UextractProperty>>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct UextractAsset {
pub path: String,
pub package_name: String,
pub package_flags: u32,
pub is_unversioned: bool,
pub name_count: usize,
pub import_count: usize,
pub export_count: usize,
pub names: Vec<String>,
pub imports: Vec<serde_json::Value>,
pub exports: Vec<UextractExport>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StatValue {
pub name: String,
pub value: f64,
#[serde(skip_serializing_if = "Option::is_none")]
pub modifier_type: Option<String>, }
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExtractedItem {
pub path: String,
pub asset_name: String,
pub category: String,
pub weapon_type: Option<String>,
pub manufacturer: Option<String>,
pub unique_id: Option<String>,
pub property_names: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub stats: Option<Vec<StatValue>>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct PakManifest {
pub version: String,
pub source: String,
pub description: String,
pub extracted_at: String,
pub total_assets: usize,
pub manufacturers: Vec<String>,
pub weapon_types: HashMap<String, Vec<String>>, pub gear_types: Vec<String>,
pub items: Vec<ExtractedItem>,
pub balance_data: HashMap<String, Vec<String>>, pub naming_strategies: Vec<String>,
pub stats: HashMap<String, Vec<String>>, }
fn parse_uextract_json(json_path: &Path) -> Result<UextractAsset> {
let content = fs::read_to_string(json_path)?;
let asset: UextractAsset = serde_json::from_str(&content)?;
Ok(asset)
}
fn extract_stats_from_names(names: &[String]) -> HashMap<String, String> {
let stat_pattern = Regex::new(r"^([A-Za-z_]+)_(\d+)_([A-F0-9]{32})$").unwrap();
let mut stats = HashMap::new();
for name in names {
if let Some(caps) = stat_pattern.captures(name) {
let stat_name = caps.get(1).unwrap().as_str().to_string();
let guid = caps.get(3).unwrap().as_str().to_string();
stats.insert(stat_name, guid);
}
}
stats
}
pub fn generate_pak_manifest(extracted_dir: &Path, output_dir: &Path) -> Result<()> {
fs::create_dir_all(output_dir).context("Failed to create output directory")?;
println!(
"Building manifest from pak extraction at {:?}",
extracted_dir
);
let mfr_names = manufacturer_names();
let mut manufacturers: std::collections::HashSet<String> = std::collections::HashSet::new();
let mut weapon_types: HashMap<String, Vec<String>> = HashMap::new();
let mut gear_types: std::collections::HashSet<String> = std::collections::HashSet::new();
let mut items: Vec<ExtractedItem> = Vec::new();
let mut balance_data: HashMap<String, Vec<String>> = HashMap::new();
let mut naming_strategies: Vec<String> = Vec::new();
let mut all_stats: HashMap<String, Vec<String>> = HashMap::new();
let mut total_assets = 0;
for entry in WalkDir::new(extracted_dir)
.into_iter()
.filter_map(|e| e.ok())
.filter(|e| {
e.path()
.extension()
.map(|ext| ext == "json")
.unwrap_or(false)
})
{
let json_path = entry.path();
let asset = match parse_uextract_json(json_path) {
Ok(a) => a,
Err(_) => continue,
};
total_assets += 1;
let path_str = asset.path.to_lowercase();
let _package_name = &asset.package_name;
let mut manufacturer: Option<String> = None;
let mut weapon_type: Option<String> = None;
let mut category = "unknown".to_string();
if path_str.contains("gear/weapons") {
category = "weapon".to_string();
if path_str.contains("assaultrifles") {
weapon_type = Some("AssaultRifle".to_string());
} else if path_str.contains("pistols") {
weapon_type = Some("Pistol".to_string());
} else if path_str.contains("shotguns") {
weapon_type = Some("Shotgun".to_string());
} else if path_str.contains("smg") {
weapon_type = Some("SMG".to_string());
} else if path_str.contains("sniper") {
weapon_type = Some("Sniper".to_string());
} else if path_str.contains("heavy") {
weapon_type = Some("Heavy".to_string());
}
for code in mfr_names.keys() {
let code_lower = code.to_lowercase();
if path_str.contains(&format!("/{}/", code_lower))
|| path_str.contains(&format!("/{}_", code_lower))
{
manufacturer = Some(code.to_string());
manufacturers.insert(code.to_string());
if let Some(ref wt) = weapon_type {
weapon_types
.entry(wt.clone())
.or_default()
.push(code.to_string());
}
break;
}
}
} else if path_str.contains("gear/gadgets/heavyweapons") {
category = "weapon".to_string();
weapon_type = Some("Heavy".to_string());
for code in mfr_names.keys() {
let code_lower = code.to_lowercase();
if path_str.contains(&format!("/{}/", code_lower))
|| path_str.contains(&format!("/{}_", code_lower))
{
manufacturer = Some(code.to_string());
manufacturers.insert(code.to_string());
weapon_types
.entry("Heavy".to_string())
.or_default()
.push(code.to_string());
break;
}
}
} else if path_str.contains("gear/classmods") {
category = "classmod".to_string();
gear_types.insert("ClassMod".to_string());
if path_str.contains("gravitar") {
manufacturer = Some("GRV".to_string());
} else if path_str.contains("paladin") {
manufacturer = Some("PLD".to_string());
} else if path_str.contains("darksiren") || path_str.contains("dark_siren") {
manufacturer = Some("SIR".to_string());
} else if path_str.contains("exo") {
manufacturer = Some("EXO".to_string());
}
} else if path_str.contains("gear/enhancements") {
category = "enhancement".to_string();
gear_types.insert("Enhancement".to_string());
for code in mfr_names.keys() {
let code_lower = code.to_lowercase();
if path_str.contains(&format!("_{}_", code_lower))
|| path_str.contains(&format!("/{}/", code_lower))
{
manufacturer = Some(code.to_string());
break;
}
}
} else if path_str.contains("gear/shields") {
category = "shield".to_string();
gear_types.insert("Shield".to_string());
} else if path_str.contains("gear/grenadegadgets") {
category = "grenade".to_string();
gear_types.insert("Grenade".to_string());
} else if path_str.contains("gear/gadgets") {
category = "gadget".to_string();
gear_types.insert("Gadget".to_string());
} else if path_str.contains("gear/firmware") {
category = "firmware".to_string();
gear_types.insert("Firmware".to_string());
} else if path_str.contains("gear/repairkits") {
category = "repair_kit".to_string();
gear_types.insert("RepairKit".to_string());
}
if path_str.contains("balancedata") {
let bd_category = if let Some(ref wt) = weapon_type {
wt.clone()
} else {
category.clone()
};
balance_data
.entry(bd_category)
.or_default()
.push(asset.package_name.clone());
}
if path_str.contains("namingstrateg") {
naming_strategies.push(asset.package_name.clone());
}
let stats = extract_stats_from_names(&asset.names);
for (stat_name, guid) in stats {
all_stats.entry(stat_name).or_default().push(guid);
}
let asset_name = json_path
.file_stem()
.and_then(|s| s.to_str())
.map(|s| s.trim_end_matches(".uasset"))
.unwrap_or("")
.to_string();
let unique_id = asset
.names
.iter()
.find(|n| n.contains("comp_05") || n.contains("Unique") || n.contains("legendary"))
.cloned();
let mut stat_values: Vec<StatValue> = Vec::new();
for export in &asset.exports {
if let Some(ref props) = export.properties {
for prop in props {
if let Some(val) = prop.float_value {
let parts: Vec<&str> = prop.name.split('_').collect();
let modifier_type = if parts.len() >= 2 {
let last = parts[parts.len() - 1];
if ["Scale", "Add", "Value", "Percent"].contains(&last) {
Some(last.to_string())
} else {
None
}
} else {
None
};
stat_values.push(StatValue {
name: prop.name.clone(),
value: val,
modifier_type,
});
}
}
}
}
items.push(ExtractedItem {
path: asset.path.clone(),
asset_name,
category,
weapon_type,
manufacturer,
unique_id,
property_names: asset.names.clone(),
stats: if stat_values.is_empty() {
None
} else {
Some(stat_values)
},
});
}
for manufacturers_list in weapon_types.values_mut() {
manufacturers_list.sort();
manufacturers_list.dedup();
}
for guids in all_stats.values_mut() {
guids.sort();
guids.dedup();
}
let manifest = PakManifest {
version: env!("CARGO_PKG_VERSION").to_string(),
source: "BL4 Pak Files (uextract)".to_string(),
description: "Manifest generated from BL4 pak file extraction".to_string(),
extracted_at: chrono::Utc::now().to_rfc3339(),
total_assets,
manufacturers: manufacturers.into_iter().collect(),
weapon_types,
gear_types: gear_types.into_iter().collect(),
items,
balance_data,
naming_strategies,
stats: all_stats,
};
let manifest_path = output_dir.join("pak_manifest.json");
fs::write(&manifest_path, serde_json::to_string_pretty(&manifest)?)?;
println!(
" pak_manifest.json - {} assets indexed",
manifest.total_assets
);
let summary = serde_json::json!({
"version": manifest.version,
"source": manifest.source,
"total_assets": manifest.total_assets,
"manufacturers": manifest.manufacturers,
"weapon_types": manifest.weapon_types.keys().collect::<Vec<_>>(),
"gear_types": manifest.gear_types,
"balance_data_categories": manifest.balance_data.keys().collect::<Vec<_>>(),
"naming_strategies_count": manifest.naming_strategies.len(),
"stats_count": manifest.stats.len(),
});
let summary_path = output_dir.join("pak_summary.json");
fs::write(&summary_path, serde_json::to_string_pretty(&summary)?)?;
println!(" pak_summary.json");
let weapons_breakdown: HashMap<String, serde_json::Value> = manifest
.weapon_types
.iter()
.map(|(wt, mfrs)| {
(
wt.clone(),
serde_json::json!({
"manufacturers": mfrs,
"count": manifest.items.iter()
.filter(|i| i.weapon_type.as_ref() == Some(wt))
.count()
}),
)
})
.collect();
let weapons_path = output_dir.join("weapons_breakdown.json");
fs::write(
&weapons_path,
serde_json::to_string_pretty(&weapons_breakdown)?,
)?;
println!(" weapons_breakdown.json");
let mut files = HashMap::new();
files.insert("pak_manifest".to_string(), "pak_manifest.json".to_string());
files.insert("pak_summary".to_string(), "pak_summary.json".to_string());
files.insert(
"weapons_breakdown".to_string(),
"weapons_breakdown.json".to_string(),
);
let index = ManifestIndex {
version: env!("CARGO_PKG_VERSION").to_string(),
source: "BL4 Pak Files".to_string(),
extract_path: extracted_dir.to_string_lossy().to_string(),
files,
};
let index_path = output_dir.join("index.json");
fs::write(&index_path, serde_json::to_string_pretty(&index)?)?;
println!(" index.json");
println!(
"\nManifest generated from {} pak assets",
manifest.total_assets
);
println!(" Manufacturers: {:?}", manifest.manufacturers);
println!(
" Weapon types: {:?}",
manifest.weapon_types.keys().collect::<Vec<_>>()
);
println!(" Gear types: {:?}", manifest.gear_types);
Ok(())
}
pub fn extract_manifest(extract_dir: &Path, output_dir: &Path) -> Result<()> {
fs::create_dir_all(output_dir).context("Failed to create output directory")?;
println!("Extracting manifest from {:?}", extract_dir);
println!("Output directory: {:?}", output_dir);
print!("Extracting manufacturers...");
let manufacturers = extract_manufacturers(extract_dir);
let mfr_path = output_dir.join("manufacturers.json");
fs::write(&mfr_path, serde_json::to_string_pretty(&manufacturers)?)?;
println!(" {} entries", manufacturers.len());
print!("Extracting weapon types...");
let weapon_types = extract_weapon_types(extract_dir);
let wt_path = output_dir.join("weapon_types.json");
fs::write(&wt_path, serde_json::to_string_pretty(&weapon_types)?)?;
println!(" {} entries", weapon_types.len());
print!("Extracting balance data...");
let balance_data = extract_balance_data(extract_dir)?;
let bd_path = output_dir.join("balance_data.json");
fs::write(&bd_path, serde_json::to_string_pretty(&balance_data)?)?;
println!(" {} categories", balance_data.len());
print!("Extracting naming data...");
let naming_data = extract_naming_data(extract_dir)?;
let nd_path = output_dir.join("naming.json");
fs::write(&nd_path, serde_json::to_string_pretty(&naming_data)?)?;
println!(" {} entries", naming_data.len());
print!("Extracting gear types...");
let gear_types = extract_gear_types(extract_dir);
let gt_path = output_dir.join("gear_types.json");
fs::write(>_path, serde_json::to_string_pretty(&gear_types)?)?;
println!(" {} types", gear_types.len());
print!("Extracting rarity data...");
let rarity_data = extract_rarity_data(extract_dir);
let rd_path = output_dir.join("rarity.json");
fs::write(&rd_path, serde_json::to_string_pretty(&rarity_data)?)?;
println!(" {} entries", rarity_data.len());
print!("Extracting elemental data...");
let elemental_data = extract_elemental_data(extract_dir);
let ed_path = output_dir.join("elemental.json");
fs::write(&ed_path, serde_json::to_string_pretty(&elemental_data)?)?;
println!(" {} entries", elemental_data.len());
let mut files = HashMap::new();
files.insert(
"manufacturers".to_string(),
"manufacturers.json".to_string(),
);
files.insert("weapon_types".to_string(), "weapon_types.json".to_string());
files.insert("balance_data".to_string(), "balance_data.json".to_string());
files.insert("naming".to_string(), "naming.json".to_string());
files.insert("gear_types".to_string(), "gear_types.json".to_string());
files.insert("rarity".to_string(), "rarity.json".to_string());
files.insert("elemental".to_string(), "elemental.json".to_string());
let index = ManifestIndex {
version: env!("CARGO_PKG_VERSION").to_string(),
source: "BL4 Game Files".to_string(),
extract_path: extract_dir.to_string_lossy().to_string(),
files,
};
let index_path = output_dir.join("index.json");
fs::write(&index_path, serde_json::to_string_pretty(&index)?)?;
println!("\nManifest saved to {:?}", output_dir);
Ok(())
}
pub fn extract_item_pools(manifest_dir: &Path) -> Result<HashMap<String, ItemPool>> {
let pak_manifest_path = manifest_dir.join("pak_manifest.json");
if !pak_manifest_path.exists() {
anyhow::bail!("pak_manifest.json not found in {:?}", manifest_dir);
}
let content = fs::read_to_string(&pak_manifest_path)?;
let manifest: PakManifest = serde_json::from_str(&content)?;
let mut pools: HashMap<String, ItemPool> = HashMap::new();
let pool_pattern = Regex::new(r"(?:CItemPoolDef::)?[Ii]tem[Pp]ool[_A-Za-z0-9]*").unwrap();
for item in &manifest.items {
let asset_path = &item.path;
let asset_name = &item.asset_name;
for prop_str in &item.property_names {
for cap in pool_pattern.find_iter(prop_str) {
let pool_name = cap
.as_str()
.trim_start_matches("CItemPoolDef::")
.to_string();
let pool = pools.entry(pool_name.clone()).or_insert_with(|| ItemPool {
name: pool_name.clone(),
path: None,
referenced_by: Vec::new(),
contains: Vec::new(),
});
if !pool.referenced_by.contains(asset_name) {
pool.referenced_by.push(asset_name.clone());
}
if asset_name
.to_lowercase()
.contains(&pool_name.to_lowercase())
{
pool.path = Some(asset_path.clone());
}
}
}
}
Ok(pools)
}
pub fn extract_item_stats(manifest_dir: &Path) -> Result<Vec<ItemStats>> {
let pak_manifest_path = manifest_dir.join("pak_manifest.json");
if !pak_manifest_path.exists() {
anyhow::bail!("pak_manifest.json not found in {:?}", manifest_dir);
}
let content = fs::read_to_string(&pak_manifest_path)?;
let manifest: PakManifest = serde_json::from_str(&content)?;
let mut items: Vec<ItemStats> = Vec::new();
let stat_pattern =
Regex::new(r"^([A-Za-z]+[A-Za-z0-9]*)_(Scale|Add|Value|Percent)_(\d+)_([A-F0-9]{32})$")
.unwrap();
let rarity_pattern = Regex::new(r"comp_0([1-5])").unwrap();
let rarities = ["Common", "Uncommon", "Rare", "Epic", "Legendary"];
for item in &manifest.items {
if item.category == "unknown" && !item.path.to_lowercase().contains("gear") {
continue;
}
let mut stats: HashMap<String, Vec<StatModifier>> = HashMap::new();
let mut rarity: Option<String> = None;
for prop in &item.property_names {
if let Some(cap) = stat_pattern.captures(prop) {
let stat_name = cap[1].to_string();
let modifier_type = cap[2].to_string();
let index: u32 = cap[3].parse().unwrap_or(0);
let guid = cap[4].to_string();
let key = format!("{}_{}", stat_name, modifier_type);
stats.entry(key).or_default().push(StatModifier {
modifier_type,
index,
guid,
});
}
if rarity.is_none() {
if let Some(cap) = rarity_pattern.captures(prop) {
let tier: usize = cap[1].parse().unwrap_or(1);
if (1..=5).contains(&tier) {
rarity = Some(rarities[tier - 1].to_string());
}
}
}
}
let manufacturer = {
let path_lower = item.path.to_lowercase();
let codes = [
"BOR", "DAD", "DPL", "JAK", "MAL", "ORD", "RIP", "TED", "TOR", "VLA", "COV",
];
let mut found = None;
for code in codes {
let code_lower = code.to_lowercase();
if path_lower.contains(&format!("/{}/", code_lower))
|| path_lower.contains(&format!("/{}_", code_lower))
|| path_lower.contains(&format!("_{}_", code_lower))
{
found = Some(code.to_string());
break;
}
}
found
};
if !stats.is_empty() {
items.push(ItemStats {
name: item.asset_name.clone(),
category: item.category.clone(),
manufacturer,
rarity,
stats,
drop_pools: Vec::new(), });
}
}
Ok(items)
}
pub fn generate_items_database(manifest_dir: &Path) -> Result<ItemsDatabase> {
eprintln!("Extracting item pools...");
let item_pools = extract_item_pools(manifest_dir)?;
eprintln!(" Found {} unique pools", item_pools.len());
eprintln!("Extracting item stats...");
let items = extract_item_stats(manifest_dir)?;
eprintln!(" Found {} items with stats", items.len());
let mut stat_types: std::collections::HashSet<String> = std::collections::HashSet::new();
let mut categories: std::collections::HashSet<String> = std::collections::HashSet::new();
let mut manufacturers: std::collections::HashSet<String> = std::collections::HashSet::new();
for item in &items {
categories.insert(item.category.clone());
if let Some(ref mfr) = item.manufacturer {
manufacturers.insert(mfr.clone());
}
for key in item.stats.keys() {
if let Some(stat_name) = key.split('_').next() {
stat_types.insert(stat_name.to_string());
}
}
}
let stats_summary = StatsSummary {
total_items: items.len(),
total_pools: item_pools.len(),
stat_types: stat_types.into_iter().collect(),
categories: categories.into_iter().collect(),
manufacturers: manufacturers.into_iter().collect(),
};
Ok(ItemsDatabase {
version: env!("CARGO_PKG_VERSION").to_string(),
generated: chrono::Utc::now()
.format("%Y-%m-%d %H:%M:%S UTC")
.to_string(),
item_pools,
items,
stats_summary,
})
}