use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::Path;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NameDataEntry {
pub internal_type: String,
pub uuid: String,
pub display_name: String,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct NameDataMap {
pub entries: Vec<NameDataEntry>,
#[serde(skip)]
by_type: HashMap<String, Vec<usize>>,
#[serde(skip)]
by_display: HashMap<String, usize>,
}
impl NameDataMap {
pub fn new() -> Self {
Self::default()
}
pub fn build_indices(&mut self) {
self.by_type.clear();
self.by_display.clear();
for (i, entry) in self.entries.iter().enumerate() {
let type_key = entry.internal_type.to_lowercase();
self.by_type.entry(type_key).or_default().push(i);
let display_key = entry.display_name.to_lowercase();
self.by_display.insert(display_key, i);
}
}
pub fn add(&mut self, entry: NameDataEntry) {
let type_key = entry.internal_type.to_lowercase();
let display_key = entry.display_name.to_lowercase();
let idx = self.entries.len();
self.by_type.entry(type_key).or_default().push(idx);
self.by_display.insert(display_key, idx);
self.entries.push(entry);
}
pub fn find_display_name(&self, internal_name: &str) -> Option<&str> {
let name_lower = internal_name.to_lowercase();
if let Some(indices) = self.by_type.get(&name_lower) {
if let Some(entry) = self.find_best_entry(indices) {
return Some(&entry.display_name);
}
}
for part in extract_name_parts(&name_lower) {
if let Some(indices) = self.by_type.get(&part) {
if let Some(entry) = self.find_best_entry(indices) {
return Some(&entry.display_name);
}
}
}
for (type_name, indices) in &self.by_type {
if name_lower.starts_with(type_name) {
let remaining = &name_lower[type_name.len()..];
if remaining.is_empty() || remaining.starts_with('_') {
if let Some(entry) = self.find_best_entry(indices) {
return Some(&entry.display_name);
}
}
}
}
None
}
#[allow(clippy::too_many_lines)]
fn find_best_entry(&self, indices: &[usize]) -> Option<&NameDataEntry> {
let variant_prefixes = [
"big encore",
"badass",
"vile",
"burning",
"acidic",
"atomic",
"galvanic",
"boreal",
"frostbite",
"scorched",
"crackling",
"noxious",
"quasar",
"the ",
"not-so-",
"cold ",
"burnt ",
"spicy ",
"rancid ",
"icy ",
"queen's ",
"launcher ",
"loot ",
"'rager ",
];
let enemy_patterns = [
"meatball",
"icehead",
"hothead",
"fissionhead",
"watthead",
"wastehead",
"'head",
"icebox",
"bandit",
"thresher",
"kratch",
"engine",
"pangolin",
];
let mut best_idx: Option<usize> = None;
let mut best_score: i32 = i32::MIN;
for &idx in indices {
let entry = &self.entries[idx];
let name_lower = entry.display_name.to_lowercase();
let mut score: i32 = 0;
if variant_prefixes.iter().any(|p| name_lower.starts_with(p)) {
score -= 100;
}
if name_lower.contains('&') {
score -= 50;
}
if enemy_patterns.iter().any(|p| name_lower.contains(p)) {
score -= 30;
}
let word_count = entry.display_name.split_whitespace().count();
if word_count == 1 {
score += 20; } else if word_count == 2 {
score += 10; } else {
score -= word_count as i32 * 5; }
if entry
.display_name
.chars()
.next()
.is_some_and(|c| c.is_uppercase())
{
score += 5;
}
if score > best_score {
best_score = score;
best_idx = Some(idx);
}
}
best_idx.map(|idx| &self.entries[idx])
}
pub fn get_by_type(&self, internal_type: &str) -> Vec<&NameDataEntry> {
let type_key = internal_type.to_lowercase();
self.by_type
.get(&type_key)
.map(|indices| indices.iter().map(|&i| &self.entries[i]).collect())
.unwrap_or_default()
}
pub fn len(&self) -> usize {
self.entries.len()
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
pub fn types(&self) -> Vec<&str> {
self.by_type.keys().map(|s| s.as_str()).collect()
}
}
fn extract_name_parts(name: &str) -> Vec<String> {
let mut parts = vec![name.to_string()];
let underscore_parts: Vec<&str> = name.split('_').collect();
if underscore_parts.len() > 1 {
parts.push(underscore_parts[..underscore_parts.len() - 1].join("_"));
parts.push(underscore_parts[0].to_string());
}
let compound_suffixes = [
"rider", ];
let parts_to_check: Vec<String> = parts.clone();
for part in parts_to_check {
for suffix in &compound_suffixes {
if part.ends_with(suffix) {
let base = &part[..part.len() - suffix.len()];
if !base.is_empty() && !parts.contains(&base.to_string()) {
parts.push(base.to_string());
}
}
}
}
parts
}
pub fn extract_from_binary(data: &[u8]) -> Vec<NameDataEntry> {
let mut entries = Vec::new();
let strings = extract_strings(data);
for s in strings {
if let Some(entry) = parse_namedata_line(&s) {
entries.push(entry);
}
}
entries
}
fn extract_strings(data: &[u8]) -> Vec<String> {
let mut strings = Vec::new();
let mut current = Vec::new();
const MIN_LENGTH: usize = 10;
for &byte in data {
if (0x20..0x7f).contains(&byte) {
current.push(byte);
} else if !current.is_empty() {
if current.len() >= MIN_LENGTH {
if let Ok(s) = String::from_utf8(current.clone()) {
strings.push(s);
}
}
current.clear();
}
}
if current.len() >= MIN_LENGTH {
if let Ok(s) = String::from_utf8(current) {
strings.push(s);
}
}
strings
}
fn parse_namedata_line(line: &str) -> Option<NameDataEntry> {
let parts: Vec<&str> = line.splitn(3, ", ").collect();
if parts.len() != 3 {
return None;
}
let uuid = parts[1].to_string();
if uuid.len() != 32 || !uuid.chars().all(|c| c.is_ascii_hexdigit()) {
return None;
}
let display_name = parts[2].trim().to_string();
if display_name.is_empty() {
return None;
}
let internal_type = if let Some(type_name) = parts[0].strip_prefix("NameData_") {
type_name.to_string()
} else if parts[0] == "discovery_ui_data" {
extract_boss_internal_name(&display_name)
} else {
return None;
};
Some(NameDataEntry {
internal_type,
uuid,
display_name,
})
}
fn extract_boss_internal_name(display_name: &str) -> String {
let name = display_name.trim();
let name = name.strip_prefix("The ").unwrap_or(name);
let name = name.split(',').next().unwrap_or(name).trim();
let name = name.strip_prefix("Primordial Guardian ").unwrap_or(name);
let words: Vec<&str> = name.split_whitespace().collect();
if words.len() == 1 {
words[0].to_string()
} else {
let mut result = String::new();
for (i, word) in words.iter().enumerate() {
if i == words.len() - 1 && word.ends_with('s') && word.len() > 3 {
result.push_str(&word[..word.len() - 1]);
} else {
result.push_str(word);
}
}
result
}
}
pub fn extract_from_directory<P: AsRef<Path>>(ncs_dir: P) -> NameDataMap {
let mut map = NameDataMap::new();
for entry in walkdir::WalkDir::new(ncs_dir.as_ref())
.into_iter()
.filter_map(|e| e.ok())
{
let path = entry.path();
if path.extension().is_some_and(|e| e == "bin") {
if let Ok(data) = std::fs::read(path) {
for name_entry in extract_from_binary(&data) {
map.add(name_entry);
}
}
}
}
map
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_namedata_line() {
let line = "NameData_Meathead, D342D6EE47173677CE1C068BADA88F69, Saddleback";
let entry = parse_namedata_line(line).unwrap();
assert_eq!(entry.internal_type, "Meathead");
assert_eq!(entry.uuid, "D342D6EE47173677CE1C068BADA88F69");
assert_eq!(entry.display_name, "Saddleback");
}
#[test]
fn test_parse_namedata_with_spaces() {
let line = "NameData_Meathead, B8EAFB724DAB6362B39A5592718B54B0, The Immortal Boneface";
let entry = parse_namedata_line(line).unwrap();
assert_eq!(entry.display_name, "The Immortal Boneface");
}
#[test]
fn test_parse_invalid_lines() {
assert!(parse_namedata_line("Not a NameData line").is_none());
assert!(parse_namedata_line("NameData_Meathead, INVALID").is_none());
assert!(parse_namedata_line("NameData_Meathead, , ").is_none());
}
#[test]
fn test_extract_name_parts() {
let parts = extract_name_parts("meatheadrider_jockey");
assert!(parts.contains(&"meatheadrider_jockey".to_string()));
assert!(parts.contains(&"meatheadrider".to_string()));
assert!(parts.contains(&"meathead".to_string()));
}
#[test]
fn test_find_display_name() {
let mut map = NameDataMap::new();
map.add(NameDataEntry {
internal_type: "Meathead".to_string(),
uuid: "D342D6EE47173677CE1C068BADA88F69".to_string(),
display_name: "Saddleback".to_string(),
});
map.add(NameDataEntry {
internal_type: "Meathead".to_string(),
uuid: "B8EAFB724DAB6362B39A5592718B54B0".to_string(),
display_name: "The Immortal Boneface".to_string(),
});
assert_eq!(
map.find_display_name("MeatheadRider_Jockey"),
Some("Saddleback")
);
}
#[test]
fn test_best_entry_prefers_base() {
let mut map = NameDataMap::new();
map.add(NameDataEntry {
internal_type: "Thresher".to_string(),
uuid: "A".repeat(32),
display_name: "Badass Thresher".to_string(),
});
map.add(NameDataEntry {
internal_type: "Thresher".to_string(),
uuid: "B".repeat(32),
display_name: "Vile Thresher".to_string(),
});
map.add(NameDataEntry {
internal_type: "Thresher".to_string(),
uuid: "C".repeat(32),
display_name: "Ravenous Thresher".to_string(),
});
assert_eq!(map.find_display_name("Thresher"), Some("Ravenous Thresher"));
}
}