use crate::vpsdb::models::Game;
use std::path::Path;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum MatchConfidence {
Low,
Medium,
High,
}
impl std::fmt::Display for MatchConfidence {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
MatchConfidence::Low => write!(f, "low"),
MatchConfidence::Medium => write!(f, "medium"),
MatchConfidence::High => write!(f, "high"),
}
}
}
#[derive(Debug, Clone)]
pub struct MatchResult<'a> {
pub game: &'a Game,
pub confidence: MatchConfidence,
pub strategy: &'static str,
}
pub fn match_table<'a>(
games: &'a [Game],
rom_name: Option<&str>,
b2s_game_name: Option<&str>,
table_name: Option<&str>,
folder_name: &str,
) -> Option<MatchResult<'a>> {
let tablename_exact: Option<&Game> = table_name.and_then(|tn| find_by_name_exact(games, tn));
if let Some(rom) = rom_name {
if let Some(g) = find_by_rom_exact(games, rom) {
if let Some(tn_g) = tablename_exact
&& tn_g.id != g.id
{
return Some(MatchResult {
game: tn_g,
confidence: MatchConfidence::High,
strategy: "tablename_overrides_rom",
});
}
return Some(MatchResult {
game: g,
confidence: MatchConfidence::High,
strategy: "rom_exact",
});
}
if let Some(g) = find_by_rom_fuzzy(games, rom) {
if let Some(tn_g) = tablename_exact
&& tn_g.id != g.id
{
return Some(MatchResult {
game: tn_g,
confidence: MatchConfidence::High,
strategy: "tablename_overrides_rom",
});
}
return Some(MatchResult {
game: g,
confidence: MatchConfidence::Medium,
strategy: "rom_fuzzy",
});
}
}
if let Some(b2s) = b2s_game_name
&& let Some(g) = find_by_rom_exact(games, b2s)
{
if let Some(tn_g) = tablename_exact
&& tn_g.id != g.id
{
return Some(MatchResult {
game: tn_g,
confidence: MatchConfidence::High,
strategy: "tablename_overrides_b2s",
});
}
return Some(MatchResult {
game: g,
confidence: MatchConfidence::Medium,
strategy: "b2s_rom_exact",
});
}
if let Some(tn) = table_name {
if let Some(g) = find_by_name_exact(games, tn) {
return Some(MatchResult {
game: g,
confidence: MatchConfidence::High,
strategy: "tablename_exact",
});
}
if let Some(g) = find_by_name_fuzzy(games, tn) {
return Some(MatchResult {
game: g,
confidence: MatchConfidence::Medium,
strategy: "tablename_fuzzy",
});
}
}
if let Some(g) = find_by_name_fuzzy(games, folder_name) {
return Some(MatchResult {
game: g,
confidence: MatchConfidence::Low,
strategy: "name_fuzzy",
});
}
None
}
pub fn match_table_from_paths<'a>(
games: &'a [Game],
vpx_path: &Path,
folder_name: &str,
) -> Option<MatchResult<'a>> {
let (rom_name, table_name) = read_vpx_meta(vpx_path);
let b2s_name = vpx_path
.with_extension("directb2s")
.canonicalize()
.ok()
.and_then(|p| read_b2s_game_name(&p));
match_table(
games,
rom_name.as_deref(),
b2s_name.as_deref(),
table_name.as_deref(),
folder_name,
)
}
pub(crate) fn read_vpx_meta(vpx_path: &Path) -> (Option<String>, Option<String>) {
let Ok(mut vpx) = vpin::vpx::open(vpx_path) else {
return (None, None);
};
let rom_name = vpx
.read_gamedata()
.ok()
.and_then(|g| extract_cgamename(&g.code.string));
let table_name = vpx
.read_tableinfo()
.ok()
.and_then(|info| info.table_name)
.filter(|s| !s.trim().is_empty());
(rom_name, table_name)
}
pub(crate) fn extract_cgamename(vbs: &str) -> Option<String> {
for line in vbs.lines() {
let s = line.trim_start();
if s.starts_with('\'') || s.to_ascii_lowercase().starts_with("rem ") {
continue;
}
let lower = s.to_ascii_lowercase();
if let Some(idx) = lower.find("cgamename") {
let after = &s[idx + "cgamename".len()..];
let eq = after.find('=')?;
let q = after[eq + 1..].find('"')?;
let start = eq + 1 + q + 1;
let rest = &after[start..];
let end = rest.find('"')?;
let name = &rest[..end];
if !name.is_empty() && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
return Some(name.to_string());
}
}
}
None
}
fn read_b2s_game_name(b2s_path: &Path) -> Option<String> {
let f = std::fs::File::open(b2s_path).ok()?;
let r = std::io::BufReader::new(f);
let data = directb2s::read(r).ok()?;
let g = data.game_name.value;
if g.trim().is_empty() { None } else { Some(g) }
}
fn find_by_rom_exact<'a>(games: &'a [Game], rom: &str) -> Option<&'a Game> {
let needle = rom.to_ascii_lowercase();
games.iter().find(|g| {
g.rom_files.iter().any(|rf| {
rf.version
.as_deref()
.map(|v| v.to_ascii_lowercase() == needle)
.unwrap_or(false)
})
})
}
fn find_by_rom_fuzzy<'a>(games: &'a [Game], rom: &str) -> Option<&'a Game> {
let prefix = rom.split_once('_').map(|(p, _)| p).unwrap_or(rom);
if prefix.len() < 2 {
return None;
}
let p_lower = prefix.to_ascii_lowercase();
games.iter().find(|g| {
g.rom_files.iter().any(|rf| {
rf.version
.as_deref()
.map(|v| {
let v_lower = v.to_ascii_lowercase();
v_lower.starts_with(&p_lower)
&& v_lower
.as_bytes()
.get(p_lower.len())
.map(|&b| b == b'_')
.unwrap_or(false)
})
.unwrap_or(false)
})
})
}
fn find_by_name_exact<'a>(games: &'a [Game], name: &str) -> Option<&'a Game> {
let needle = normalize(name);
if needle.len() < 3 {
return None;
}
games.iter().find(|g| normalize(&g.name) == needle)
}
fn find_by_name_fuzzy<'a>(games: &'a [Game], folder: &str) -> Option<&'a Game> {
let needle = normalize(folder);
if needle.len() < 3 {
return None;
}
games.iter().find(|g| {
let n = normalize(&g.name);
n.len() >= 3 && (n.contains(&needle) || needle.contains(&n))
})
}
fn normalize(s: &str) -> String {
s.chars()
.filter(|c| c.is_ascii_alphanumeric())
.flat_map(|c| c.to_lowercase())
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::vpsdb::models::{Game, RomFile};
fn make_game(id: &str, name: &str, rom_versions: &[&str]) -> Game {
Game {
id: id.into(),
name: name.into(),
manufacturer: None,
year: None,
game_type: None,
theme: vec![],
updated_at: None,
table_files: vec![],
rom_files: rom_versions
.iter()
.map(|v| RomFile {
id: format!("rid-{v}"),
version: Some((*v).to_string()),
authors: vec![],
urls: vec![],
features: vec![],
updated_at: None,
comment: None,
})
.collect(),
b2s_files: vec![],
pup_pack_files: vec![],
alt_sound_files: vec![],
alt_color_files: vec![],
wheel_art_files: vec![],
topper_files: vec![],
pov_files: vec![],
media_pack_files: vec![],
rule_files: vec![],
sound_files: vec![],
tutorial_files: vec![],
}
}
#[test]
fn exact_rom_match_wins_first() {
let games = vec![
make_game("g1", "Apollo 13", &["apollo13"]),
make_game("g2", "Attack from Mars", &["afm_113b", "afm_10"]),
];
let m = match_table(&games, Some("apollo13"), None, None, "anything").unwrap();
assert_eq!(m.game.id, "g1");
assert_eq!(m.confidence, MatchConfidence::High);
}
#[test]
fn fuzzy_falls_back_to_prefix_underscore() {
let games = vec![make_game("g1", "Medieval Madness", &["mm_l5", "mm_10"])];
let m = match_table(&games, Some("mm_109c"), None, None, "anything").unwrap();
assert_eq!(m.game.id, "g1");
assert_eq!(m.confidence, MatchConfidence::Medium);
}
#[test]
fn b2s_match_is_medium() {
let games = vec![make_game("g1", "Iron Man", &["im_183ve"])];
let m = match_table(&games, None, Some("im_183ve"), None, "Iron Man").unwrap();
assert_eq!(m.confidence, MatchConfidence::Medium);
}
#[test]
fn folder_fuzzy_is_low_confidence() {
let games = vec![make_game("g1", "Tron Legacy LE", &[])];
let m = match_table(&games, None, None, None, "Tron Legacy").unwrap();
assert_eq!(m.confidence, MatchConfidence::Low);
}
#[test]
fn no_match_when_nothing_lines_up() {
let games = vec![make_game("g1", "Apollo 13", &["apollo13"])];
assert!(match_table(&games, Some("zzz"), None, None, "Mystery").is_none());
}
#[test]
fn extract_cgamename_strips_comments() {
let vbs = r#"' Const cGameName = "ss_01" 'commented out
Const cGameName = "ss_15"
"#;
assert_eq!(extract_cgamename(vbs).as_deref(), Some("ss_15"));
}
#[test]
fn extract_cgamename_handles_no_spaces() {
let vbs = r#"Const cGameName="apollo13",UseSolenoids=2"#;
assert_eq!(extract_cgamename(vbs).as_deref(), Some("apollo13"));
}
}