use chrono::{DateTime, Utc};
use jwalk::WalkDir;
use log::info;
use rayon::prelude::*;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use std::collections::{BTreeMap, HashMap, HashSet};
use std::fmt::Debug;
use std::fs::Metadata;
use std::io::{BufReader, BufWriter, Read};
use std::sync::LazyLock;
use std::time::SystemTime;
use std::{
ffi::OsStr,
fs::{self, File},
io,
path::{Path, PathBuf},
};
use vpin::vpx;
use vpin::vpx::jsonmodel::json_to_info;
use vpin::vpx::tableinfo::TableInfo;
use vpx::gamedata::GameData;
use crate::atomicwrite::atomic_write;
pub const DEFAULT_INDEX_FILE_NAME: &str = "vpxtool_index.json";
static LINE_WITH_CGAMENAME_REGEX: LazyLock<regex::Regex> = LazyLock::new(|| {
regex::Regex::new(r#"(?i)cgamename\s*=\s*\"([^"\\]*(?:\\.[^"\\]*)*)\""#).unwrap()
});
static LINE_WITH_DOT_GAMENAME_REGEX: LazyLock<regex::Regex> = LazyLock::new(|| {
regex::Regex::new(r#"(?i)\.gamename\s*=\s*\"([^"\\]*(?:\\.[^"\\]*)*)\""#).unwrap()
});
static LOADVPM_SUB_REGEX: LazyLock<regex::Regex> =
LazyLock::new(|| regex::Regex::new(r#"sub\s*loadvpm"#).unwrap());
#[derive(Serialize, Deserialize, PartialEq, Debug, Clone)]
pub struct IndexedTableInfo {
pub table_name: Option<String>,
pub author_name: Option<String>,
pub table_blurb: Option<String>,
pub table_rules: Option<String>,
pub author_email: Option<String>,
pub release_date: Option<String>,
pub table_save_rev: Option<String>,
pub table_version: Option<String>,
pub author_website: Option<String>,
pub table_save_date: Option<String>,
pub table_description: Option<String>,
pub properties: BTreeMap<String, String>,
}
impl From<TableInfo> for IndexedTableInfo {
fn from(table_info: TableInfo) -> Self {
IndexedTableInfo {
table_name: table_info.table_name,
author_name: table_info.author_name,
table_blurb: table_info.table_blurb,
table_rules: table_info.table_rules,
author_email: table_info.author_email,
release_date: table_info.release_date,
table_save_rev: table_info.table_save_rev,
table_version: table_info.table_version,
author_website: table_info.author_website,
table_save_date: table_info.table_save_date,
table_description: table_info.table_description,
properties: table_info.properties.into_iter().collect(),
}
}
}
pub struct PathWithMetadata {
pub path: PathBuf,
pub last_modified: SystemTime,
}
#[derive(Clone, Copy, PartialEq, Debug, Eq, Ord, PartialOrd)]
pub struct IsoSystemTime(SystemTime);
impl From<SystemTime> for IsoSystemTime {
fn from(system_time: SystemTime) -> Self {
IsoSystemTime(system_time)
}
}
impl From<IsoSystemTime> for SystemTime {
fn from(iso_system_time: IsoSystemTime) -> Self {
iso_system_time.0
}
}
impl Serialize for IsoSystemTime {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
let now: DateTime<Utc> = self.0.into();
now.to_rfc3339().serialize(serializer)
}
}
impl<'de> Deserialize<'de> for IsoSystemTime {
fn deserialize<D>(deserializer: D) -> Result<IsoSystemTime, D::Error>
where
D: Deserializer<'de>,
{
let s = String::deserialize(deserializer)?;
let dt = DateTime::parse_from_rfc3339(&s).map_err(serde::de::Error::custom)?;
Ok(IsoSystemTime(dt.into()))
}
}
#[derive(Serialize, Deserialize, PartialEq, Debug, Clone)]
pub struct IndexedTable {
pub path: PathBuf,
pub table_info: IndexedTableInfo,
pub game_name: Option<String>,
pub b2s_path: Option<PathBuf>,
rom_path: Option<PathBuf>,
#[serde(skip_serializing_if = "Option::is_none")]
local_rom_path: Option<PathBuf>,
pub wheel_path: Option<PathBuf>,
pub requires_pinmame: bool,
pub last_modified: IsoSystemTime,
}
impl IndexedTable {
pub fn rom_path(&self) -> Option<&PathBuf> {
self.rom_path.as_ref().or(self.local_rom_path.as_ref())
}
}
#[derive(Serialize, Deserialize, PartialEq, Debug)]
pub struct TablesIndex {
tables: HashMap<PathBuf, IndexedTable>,
}
impl TablesIndex {
pub(crate) fn empty() -> TablesIndex {
TablesIndex {
tables: HashMap::new(),
}
}
pub fn len(&self) -> usize {
self.tables.len()
}
pub fn is_empty(&self) -> bool {
self.tables.is_empty()
}
pub(crate) fn insert(&mut self, table: IndexedTable) {
self.tables.insert(table.path.clone(), table);
}
pub fn insert_all(&mut self, new_tables: Vec<IndexedTable>) {
for table in new_tables {
self.insert(table);
}
}
pub fn merge(&mut self, other: TablesIndex) {
self.tables.extend(other.tables);
}
pub fn tables(&self) -> Vec<IndexedTable> {
self.tables.values().cloned().collect()
}
pub(crate) fn should_index(&self, path_with_metadata: &PathWithMetadata) -> bool {
match self.tables.get(&path_with_metadata.path) {
Some(existing) => {
let existing_last_modified: SystemTime = existing.last_modified.into();
existing_last_modified != path_with_metadata.last_modified
}
None => true,
}
}
pub(crate) fn remove_missing(&mut self, paths: &[PathWithMetadata]) -> usize {
let len = self.tables.len();
let paths_set: HashSet<PathBuf> = paths.iter().map(|p| p.path.clone()).collect();
self.tables.retain(|path, _| paths_set.contains(path));
len - self.tables.len()
}
}
#[derive(Serialize, Deserialize, PartialEq, Debug)]
pub struct TablesIndexJson {
tables: Vec<IndexedTable>,
}
impl From<TablesIndex> for TablesIndexJson {
fn from(index: TablesIndex) -> Self {
TablesIndexJson {
tables: sort_tables(index.tables()),
}
}
}
impl From<&TablesIndex> for TablesIndexJson {
fn from(table: &TablesIndex) -> Self {
TablesIndexJson {
tables: sort_tables(table.tables()),
}
}
}
fn sort_tables(mut tables: Vec<IndexedTable>) -> Vec<IndexedTable> {
tables.sort_by(|a, b| {
a.path
.to_string_lossy()
.to_lowercase()
.cmp(&b.path.to_string_lossy().to_lowercase())
});
tables
}
fn to_normalized_path(path: &Path) -> String {
path.to_string_lossy().replace('\\', "/")
}
fn from_normalized_path(normalized: &str) -> PathBuf {
#[cfg(windows)]
{
PathBuf::from(normalized.replace('/', "\\"))
}
#[cfg(not(windows))]
{
PathBuf::from(normalized)
}
}
fn try_make_relative_normalized(path: &Path, base: &Path, canonical_base: Option<&Path>) -> String {
if let Ok(relative) = path.strip_prefix(base) {
return to_normalized_path(relative);
}
let canonical_path = path.canonicalize().ok();
let path_to_check = canonical_path.as_deref().unwrap_or(path);
let base_to_check = canonical_base.unwrap_or(base);
if let Ok(relative) = path_to_check.strip_prefix(base_to_check) {
to_normalized_path(relative)
} else {
to_normalized_path(path_to_check)
}
}
fn resolve_normalized_path(normalized: &str, base: Option<&Path>) -> PathBuf {
let path = from_normalized_path(normalized);
if path.is_absolute() {
path
} else if let Some(base_path) = base {
base_path.join(path)
} else {
path
}
}
pub fn find_roms(rom_path: &Path) -> io::Result<HashMap<String, PathBuf>> {
if !rom_path.exists() {
return Ok(HashMap::new());
}
let mut roms = HashMap::new();
let mut entries = fs::read_dir(rom_path)?;
entries.try_for_each(|entry| {
let dir_entry = entry?;
let path = dir_entry.path();
if path.is_file()
&& let Some("zip") = path.extension().and_then(OsStr::to_str)
{
let rom_name = path
.file_stem()
.unwrap()
.to_str()
.unwrap()
.to_string()
.to_lowercase();
roms.insert(rom_name, path);
}
Ok::<(), io::Error>(())
})?;
Ok(roms)
}
pub fn find_vpx_files(
recursive: bool,
max_depth: Option<usize>,
tables_path: &Path,
) -> io::Result<Vec<PathWithMetadata>> {
let vpx_paths: Vec<PathBuf> = if recursive {
let mut walk = WalkDir::new(tables_path).skip_hidden(false);
if let Some(depth) = max_depth {
walk = walk.max_depth(depth);
}
walk.process_read_dir(|_, _, _, entries| {
entries.retain(|entry| match entry {
Ok(e) if e.file_type().is_dir() => {
!matches!(e.file_name().to_str(), Some(".git" | "__MACOSX"))
}
_ => true,
});
})
.into_iter()
.filter_map(|entry| {
let entry = match entry {
Ok(e) => e,
Err(e) => return Some(Err(io::Error::from(e))),
};
if !entry.file_type().is_file() {
return None;
}
if !matches!(
entry.path().extension().and_then(OsStr::to_str),
Some("vpx")
) {
return None;
}
Some(Ok(entry.path()))
})
.collect::<io::Result<Vec<_>>>()?
} else {
let mut paths = Vec::new();
for entry in fs::read_dir(tables_path)? {
let dir_entry = entry?;
if dir_entry.file_type()?.is_file() {
let path = dir_entry.path();
if matches!(path.extension().and_then(OsStr::to_str), Some("vpx")) {
paths.push(path);
}
}
}
paths
};
vpx_paths
.par_iter()
.map(|path| {
let last_modified = last_modified(path)?;
Ok(PathWithMetadata {
path: path.clone(),
last_modified,
})
})
.collect()
}
pub trait Progress {
fn set_length(&self, len: u64);
fn set_position(&self, i: u64);
fn finish_and_clear(&self);
}
pub struct VoidProgress;
impl Progress for VoidProgress {
fn set_length(&self, _len: u64) {}
fn set_position(&self, _i: u64) {}
fn finish_and_clear(&self) {}
}
pub enum IndexError {
FolderDoesNotExist(PathBuf),
IoError(io::Error),
}
impl Debug for IndexError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
IndexError::FolderDoesNotExist(path) => {
write!(f, "Folder does not exist: {}", path.display())
}
IndexError::IoError(e) => write!(f, "IO error: {e}"),
}
}
}
impl From<IndexError> for io::Error {
fn from(e: IndexError) -> io::Error {
io::Error::other(format!("{e:?}"))
}
}
impl From<io::Error> for IndexError {
fn from(e: io::Error) -> Self {
IndexError::IoError(e)
}
}
#[allow(clippy::too_many_arguments)]
pub fn index_folder(
recursive: bool,
max_depth: Option<usize>,
tables_folder: &Path,
tables_index_path: &Path,
global_pinmame_path: Option<&Path>,
configured_pinmame_path: Option<&Path>,
progress: &impl Progress,
force_reindex: Vec<PathBuf>,
) -> Result<TablesIndex, IndexError> {
info!("Indexing {}", tables_folder.display());
if !tables_folder.exists() {
return Err(IndexError::FolderDoesNotExist(tables_folder.to_path_buf()));
}
let existing_index = read_index_json(tables_index_path, Some(tables_folder))?;
if let Some(index) = &existing_index {
info!(
" Found existing index with {} tables at {}",
index.tables.len(),
tables_index_path.display()
);
}
let mut index = existing_index.unwrap_or(TablesIndex::empty());
let vpx_files = find_vpx_files(recursive, max_depth, tables_folder)?;
info!(" Found {} tables", vpx_files.len());
let removed_len = index.remove_missing(&vpx_files);
info!(" {removed_len} missing tables have been removed");
let tables_with_missing_rom: HashSet<PathBuf> = index
.tables()
.par_iter()
.filter_map(|table| {
table
.rom_path()
.filter(|rom_path| !rom_path.exists())
.map(|_| table.path.clone())
})
.collect();
info!(
" {} tables will be re-indexed because their rom is missing",
tables_with_missing_rom.len()
);
let mut vpx_files_to_index = Vec::new();
for vpx_file in vpx_files {
if tables_with_missing_rom.contains(&vpx_file.path)
|| force_reindex.contains(&vpx_file.path)
|| index.should_index(&vpx_file)
{
vpx_files_to_index.push(vpx_file);
}
}
let index_dirty = removed_len > 0 || !vpx_files_to_index.is_empty();
info!(" {} tables need (re)indexing.", vpx_files_to_index.len());
let vpx_files_with_table_info = index_vpx_files(
vpx_files_to_index,
global_pinmame_path,
configured_pinmame_path,
progress,
)?;
index.merge(vpx_files_with_table_info);
if index_dirty {
write_index_json(&index, tables_index_path, Some(tables_folder))?;
}
Ok(index)
}
pub fn index_vpx_files(
vpx_files: Vec<PathWithMetadata>,
global_pinmame_path: Option<&Path>,
configured_pinmame_path: Option<&Path>,
progress: &impl Progress,
) -> io::Result<TablesIndex> {
let global_roms = global_pinmame_path
.map(|pinmame_path| {
let roms_path = pinmame_path.join("roms");
find_roms(&roms_path)
})
.unwrap_or_else(|| Ok(HashMap::new()))?;
let pinmame_roms_path = configured_pinmame_path.map(|p| p.join("roms").to_path_buf());
let (progress_tx, progress_rx) = std::sync::mpsc::channel();
progress.set_length(vpx_files.len() as u64);
let index_thread = std::thread::spawn(move || {
vpx_files
.par_iter()
.flat_map(|vpx_file| {
let res = match index_vpx_file(vpx_file, pinmame_roms_path.as_deref(), &global_roms)
{
Ok(indexed_table) => Some(indexed_table),
Err(e) => {
let warning =
format!("Not a valid vpx file {}: {}", vpx_file.path.display(), e);
println!("{warning}");
None
}
};
let _ = progress_tx.send(1);
res
})
.collect()
});
let mut finished = 0;
for i in progress_rx {
finished += i;
progress.set_position(finished);
}
let vpx_files_with_table_info = index_thread
.join()
.map_err(|e| io::Error::other(format!("{e:?}")))?;
Ok(TablesIndex {
tables: vpx_files_with_table_info,
})
}
fn index_vpx_file(
vpx_file_path: &PathWithMetadata,
configured_roms_path: Option<&Path>,
global_roms: &HashMap<String, PathBuf>,
) -> io::Result<(PathBuf, IndexedTable)> {
let path = &vpx_file_path.path;
let mut vpx_file = vpx::open(path)?;
let info_file_path = path.with_extension("info.json");
let table_info = if info_file_path.exists() {
read_table_info_json(&info_file_path)
} else {
vpx_file.read_tableinfo()
}?;
let game_data = vpx_file.read_gamedata()?;
let code = consider_sidecar_vbs(path, game_data)?;
let game_name = extract_game_name(&code);
let requires_pinmame = requires_pinmame(&code);
let rom_path = find_local_rom_path(path, &game_name, configured_roms_path)?.or_else(|| {
game_name
.as_ref()
.and_then(|game_name| global_roms.get(&game_name.to_lowercase()).cloned())
});
let b2s_path = find_b2s_path(path);
let wheel_path = find_wheel_path(path);
let last_modified = last_modified(path)?;
let indexed_table_info = IndexedTableInfo::from(table_info);
let indexed = IndexedTable {
path: path.clone(),
table_info: indexed_table_info,
game_name,
b2s_path,
rom_path,
local_rom_path: None,
wheel_path,
requires_pinmame,
last_modified: IsoSystemTime(last_modified),
};
Ok((indexed.path.clone(), indexed))
}
pub fn get_romname_from_vpx(vpx_path: &Path) -> io::Result<Option<String>> {
let mut vpx_file = vpx::open(vpx_path)?;
let game_data = vpx_file.read_gamedata()?;
let code = consider_sidecar_vbs(vpx_path, game_data)?;
let game_name = extract_game_name(&code);
let requires_pinmame = requires_pinmame(&code);
if requires_pinmame {
Ok(game_name)
} else {
Ok(None)
}
}
fn read_table_info_json(info_file_path: &Path) -> io::Result<TableInfo> {
let info_file = File::open(info_file_path)?;
let reader = BufReader::new(info_file);
let json = serde_json::from_reader(reader).map_err(|e| {
io::Error::other(format!(
"Failed to parse/read json {}: {}",
info_file_path.display(),
e
))
})?;
let (table_info, _custom_info_tags) = json_to_info(json, None)?;
Ok(table_info)
}
fn find_local_rom_path(
vpx_file_path: &Path,
game_name: &Option<String>,
configured_roms_path: Option<&Path>,
) -> io::Result<Option<PathBuf>> {
if let Some(game_name) = game_name {
let rom_file_name = format!("{}.zip", game_name.to_lowercase());
let pinmame_roms_path = if let Some(configured_roms_path) = configured_roms_path {
let configured_roms_path = if configured_roms_path.is_relative() {
vpx_file_path.parent().unwrap().join(configured_roms_path)
} else {
configured_roms_path.to_owned()
};
if configured_roms_path.exists() {
configured_roms_path
} else {
vpx_file_path.parent().unwrap().join("pinmame").join("roms")
}
} else {
vpx_file_path.parent().unwrap().join("pinmame").join("roms")
};
let rom_path = pinmame_roms_path.join(rom_file_name);
return if rom_path.exists() {
Ok(Some(rom_path.canonicalize()?))
} else {
Ok(None)
};
};
Ok(None)
}
fn find_b2s_path(vpx_file_path: &Path) -> Option<PathBuf> {
let b2s_file_name = format!(
"{}.directb2s",
vpx_file_path.file_stem().unwrap().to_string_lossy()
);
let b2s_path = vpx_file_path.parent().unwrap().join(b2s_file_name);
if b2s_path.exists() {
Some(b2s_path)
} else {
None
}
}
fn find_wheel_path(vpx_file_path: &Path) -> Option<PathBuf> {
let wheel_file_name = format!(
"wheels/{}.png",
vpx_file_path.file_stem().unwrap().to_string_lossy()
);
let wheel_path = vpx_file_path.parent().unwrap().join(wheel_file_name);
if wheel_path.exists() {
return Some(wheel_path);
}
let wheel_path = vpx_file_path.with_extension("wheel.png");
if wheel_path.exists() {
return Some(wheel_path);
}
None
}
fn consider_sidecar_vbs(path: &Path, game_data: GameData) -> io::Result<String> {
let vbs_path = path.with_extension("vbs");
let code = if vbs_path.exists() {
let vbs_file = File::open(vbs_path)?;
let mut reader = BufReader::new(vbs_file);
let mut code = String::new();
reader.read_to_string(&mut code)?;
code
} else {
game_data.code.string
};
Ok(code)
}
fn normalize_table_for_json(
table: &IndexedTable,
tables_root: &Path,
canonical_root: Option<&Path>,
) -> IndexedTable {
let norm =
|p: &Path| PathBuf::from(try_make_relative_normalized(p, tables_root, canonical_root));
IndexedTable {
path: norm(&table.path),
table_info: table.table_info.clone(),
game_name: table.game_name.clone(),
b2s_path: table.b2s_path.as_ref().map(|p| norm(p)),
rom_path: table.rom_path.as_ref().map(|p| norm(p)),
local_rom_path: table.local_rom_path.as_ref().map(|p| norm(p)),
wheel_path: table.wheel_path.as_ref().map(|p| norm(p)),
requires_pinmame: table.requires_pinmame,
last_modified: table.last_modified,
}
}
fn denormalize_table_from_json(table: IndexedTable, tables_root: Option<&Path>) -> IndexedTable {
let resolve = |p: PathBuf| resolve_normalized_path(&p.to_string_lossy(), tables_root);
IndexedTable {
path: resolve(table.path),
table_info: table.table_info,
game_name: table.game_name,
b2s_path: table.b2s_path.map(resolve),
rom_path: table.rom_path.map(resolve),
local_rom_path: table.local_rom_path.map(resolve),
wheel_path: table.wheel_path.map(resolve),
requires_pinmame: table.requires_pinmame,
last_modified: table.last_modified,
}
}
fn last_modified(path: &Path) -> io::Result<SystemTime> {
let metadata: Metadata = path.metadata()?;
metadata.modified()
}
pub fn write_index_json(
indexed_tables: &TablesIndex,
json_path: &Path,
tables_root: Option<&Path>,
) -> io::Result<()> {
let canonical_root = tables_root.and_then(|r| r.canonicalize().ok());
let canonical_root_ref = canonical_root.as_deref();
let tables: Vec<IndexedTable> = indexed_tables
.tables
.values()
.map(|t| match tables_root {
Some(root) => normalize_table_for_json(t, root, canonical_root_ref),
None => t.clone(),
})
.collect();
let indexed_tables_json = TablesIndexJson {
tables: sort_tables(tables),
};
atomic_write(json_path, |file| {
let mut writer = BufWriter::new(file);
serde_json::to_writer_pretty(&mut writer, &indexed_tables_json)
.map_err(io::Error::other)?;
writer.into_inner().map_err(|e| e.into_error())?;
Ok(())
})
}
pub fn read_index_json(
json_path: &Path,
tables_root: Option<&Path>,
) -> io::Result<Option<TablesIndex>> {
if !json_path.exists() {
return Ok(None);
}
let json_file = File::open(json_path)?;
let reader = BufReader::new(json_file);
match serde_json::from_reader::<_, TablesIndexJson>(reader) {
Ok(json) => {
let tables = json
.tables
.into_iter()
.map(|t| {
let denormalized = denormalize_table_from_json(t, tables_root);
(denormalized.path.clone(), denormalized)
})
.collect();
Ok(Some(TablesIndex { tables }))
}
Err(e) => {
println!("Failed to parse index file, ignoring existing index. ({e})");
Ok(None)
}
}
}
fn extract_game_name<S: AsRef<str>>(code: S) -> Option<String> {
let unified = unify_line_endings(code.as_ref());
unified
.lines()
.filter(|line| !line.trim().starts_with('\''))
.filter(|line| {
let lower: String = line.to_owned().to_lowercase().trim().to_string();
lower.contains("cgamename") || lower.contains(".gamename")
})
.flat_map(|line| {
let caps = LINE_WITH_CGAMENAME_REGEX
.captures(line)
.or(LINE_WITH_DOT_GAMENAME_REGEX.captures(line))?;
let first = caps.get(1)?;
Some(first.as_str().to_string())
})
.next()
}
fn requires_pinmame<S: AsRef<str>>(code: S) -> bool {
let unified = unify_line_endings(code.as_ref());
let lower = unified.to_lowercase();
lower
.lines()
.filter(|line| !line.trim().starts_with('\''))
.any(|line| line.contains("loadvpm") && !LOADVPM_SUB_REGEX.is_match(line))
}
fn unify_line_endings(code: &str) -> String {
code.replace("\r\n", "\n").replace('\r', "\n")
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
use serde_json::json;
use std::io::Write;
use testdir::testdir;
use vpin::vpx;
#[test]
fn test_index_vpx_files() -> io::Result<()> {
let global_pinmame_dir = testdir!().join("global_pinmame");
fs::create_dir(&global_pinmame_dir)?;
let global_roms_dir = global_pinmame_dir.join("roms");
fs::create_dir(&global_roms_dir)?;
let tables_dir = testdir!().join("tables");
fs::create_dir(&tables_dir)?;
let temp_dir = testdir!().join("temp");
fs::create_dir(&temp_dir)?;
let macosx = tables_dir.join("__MACOSX");
fs::create_dir(&macosx)?;
File::create(macosx.join("ignored.vpx"))?;
let git = tables_dir.join(".git");
fs::create_dir(&git)?;
File::create(git.join("ignored2.vpx"))?;
fs::create_dir(tables_dir.join("subdir"))?;
let vpx_1_path = tables_dir.join("test.vpx");
let vpx_2_path = tables_dir.join("test2.vpx");
let vpx_3_path = tables_dir.join("subdir").join("test3.vpx");
vpx::new_minimal_vpx(&vpx_1_path)?;
let script1 = test_script(&temp_dir, "testgamename")?;
vpx::importvbs(&vpx_1_path, Some(script1))?;
let mut rom1_path_local = tables_dir
.join("pinmame")
.join("roms")
.join("testgamename.zip");
fs::create_dir_all(rom1_path_local.parent().unwrap())?;
File::create(&rom1_path_local)?;
rom1_path_local = rom1_path_local.canonicalize()?;
vpx::new_minimal_vpx(&vpx_2_path)?;
let script2 = test_script(&temp_dir, "testgamename2")?;
vpx::importvbs(&vpx_2_path, Some(script2))?;
let rom2_path_global = global_roms_dir.join("testgamename2.zip");
File::create(&rom2_path_global)?;
vpx::new_minimal_vpx(&vpx_3_path)?;
let vpx_files = find_vpx_files(true, None, &tables_dir)?;
assert_eq!(vpx_files.len(), 3);
let global_roms = find_roms(&global_roms_dir)?;
assert_eq!(global_roms.len(), 1);
let configured_roms_path = Some(PathBuf::from("./"));
let indexed_tables = index_vpx_files(
vpx_files,
Some(&global_pinmame_dir),
configured_roms_path.as_deref(),
&VoidProgress,
)?;
assert_eq!(indexed_tables.tables.len(), 3);
let table1 = indexed_tables
.tables
.get(&vpx_1_path)
.expect("table1 not found");
let table2 = indexed_tables
.tables
.get(&vpx_2_path)
.expect("table2 not found");
let table3 = indexed_tables
.tables
.get(&vpx_3_path)
.expect("table3 not found");
assert_eq!(table1.path, vpx_1_path);
assert_eq!(table2.path, vpx_2_path);
assert_eq!(table3.path, vpx_3_path);
assert_eq!(table1.rom_path, Some(rom1_path_local.clone()));
assert_eq!(table2.rom_path, Some(rom2_path_global.clone()));
assert_eq!(table3.rom_path, None);
Ok(())
}
#[test]
#[cfg(unix)]
fn test_index_folder_skips_write_when_unchanged() -> io::Result<()> {
use std::os::unix::fs::MetadataExt;
let tables_dir = testdir!().join("tables");
fs::create_dir(&tables_dir)?;
vpx::new_minimal_vpx(tables_dir.join("test.vpx"))?;
let index_path = testdir!().join("vpxtool_index.json");
index_folder(
true,
None,
&tables_dir,
&index_path,
None,
None,
&VoidProgress,
vec![],
)?;
let inode_before = fs::metadata(&index_path)?.ino();
index_folder(
true,
None,
&tables_dir,
&index_path,
None,
None,
&VoidProgress,
vec![],
)?;
let inode_after = fs::metadata(&index_path)?.ino();
assert_eq!(
inode_before, inode_after,
"index_folder rewrote the index when nothing had changed"
);
Ok(())
}
#[test]
fn test_index_to_json_is_sorted() -> io::Result<()> {
let tables_dir = testdir!().join("tables");
fs::create_dir(&tables_dir)?;
let temp_dir = testdir!().join("temp");
fs::create_dir(&temp_dir)?;
let vpx_z_path = tables_dir.join("z_table.vpx");
let vpx_a_path = tables_dir.join("a_table.vpx");
let vpx_m_path = tables_dir.join("m_table.vpx");
vpx::new_minimal_vpx(&vpx_z_path)?;
vpx::new_minimal_vpx(&vpx_a_path)?;
vpx::new_minimal_vpx(&vpx_m_path)?;
let vpx_files = find_vpx_files(true, None, &tables_dir)?;
let indexed = index_vpx_files(vpx_files, None, None, &VoidProgress)?;
let json: TablesIndexJson = indexed.into();
let filenames: Vec<_> = json
.tables
.iter()
.map(|t| t.path.file_name().unwrap().to_str().unwrap().to_string())
.collect();
assert_eq!(filenames, vec!["a_table.vpx", "m_table.vpx", "z_table.vpx"]);
Ok(())
}
#[test]
fn test_find_vpx_files_max_depth() -> io::Result<()> {
let tables_dir = testdir!().join("tables");
fs::create_dir(&tables_dir)?;
let sub1 = tables_dir.join("sub1");
fs::create_dir(&sub1)?;
let sub2 = sub1.join("sub2");
fs::create_dir(&sub2)?;
let top_vpx = tables_dir.join("top.vpx");
let mid_vpx = sub1.join("mid.vpx");
let deep_vpx = sub2.join("deep.vpx");
vpx::new_minimal_vpx(&top_vpx)?;
vpx::new_minimal_vpx(&mid_vpx)?;
vpx::new_minimal_vpx(&deep_vpx)?;
fn names(files: &[PathWithMetadata]) -> Vec<String> {
let mut v: Vec<_> = files
.iter()
.map(|f| f.path.file_name().unwrap().to_string_lossy().into_owned())
.collect();
v.sort();
v
}
assert_eq!(
names(&find_vpx_files(true, None, &tables_dir)?),
vec!["deep.vpx", "mid.vpx", "top.vpx"]
);
assert_eq!(
names(&find_vpx_files(true, Some(1), &tables_dir)?),
vec!["top.vpx"]
);
assert_eq!(
names(&find_vpx_files(true, Some(2), &tables_dir)?),
vec!["mid.vpx", "top.vpx"]
);
assert_eq!(
names(&find_vpx_files(true, Some(3), &tables_dir)?),
vec!["deep.vpx", "mid.vpx", "top.vpx"]
);
assert!(find_vpx_files(true, Some(0), &tables_dir)?.is_empty());
Ok(())
}
fn test_script(temp_dir: &Path, game_name: &str) -> io::Result<PathBuf> {
let script = format!(
r#"
Const cGameName = "{game_name}"
Sub LoadVPM
"#
);
let script_path = temp_dir.join(game_name).with_extension("vbs");
let mut script_file = File::create(&script_path)?;
script_file.write_all(script.as_bytes())?;
Ok(script_path)
}
#[test]
fn test_write_read_empty_array() -> io::Result<()> {
let index = TablesIndex::empty();
let test_dir = testdir!();
let index_path = test_dir.join("test.json");
let json_file = File::create(&index_path)?;
let json_object = json!({
"tables": []
});
serde_json::to_writer_pretty(json_file, &json_object)?;
let read = read_index_json(&index_path, None)?;
assert_eq!(read, Some(index));
Ok(())
}
#[test]
fn test_write_read_invalid_file() -> io::Result<()> {
let test_dir = testdir!();
let index_path = test_dir.join("test.json");
let json_file = File::create(&index_path)?;
serde_json::to_writer_pretty(json_file, &"garbage")?;
let read = read_index_json(&index_path, None)?;
assert_eq!(read, None);
Ok(())
}
#[test]
fn test_write_read_empty_index() -> io::Result<()> {
let index = TablesIndex::empty();
let test_dir = testdir!();
let index_path = test_dir.join("test.json");
write_index_json(&index, &index_path, None)?;
let read = read_index_json(&index_path, None)?;
assert_eq!(read, Some(index));
Ok(())
}
#[test]
fn test_write_read_single_item_index() -> io::Result<()> {
let mut index = TablesIndex::empty();
index.insert(IndexedTable {
path: PathBuf::from("test.vpx"),
table_info: IndexedTableInfo {
table_name: Some("test".to_string()),
author_name: Some("test".to_string()),
table_blurb: None,
table_rules: None,
author_email: None,
release_date: None,
table_save_rev: None,
table_version: None,
author_website: None,
table_save_date: None,
table_description: None,
properties: BTreeMap::new(),
},
game_name: Some("testrom".to_string()),
b2s_path: Some(PathBuf::from("test.b2s")),
rom_path: Some(PathBuf::from("testrom.zip")),
local_rom_path: None,
wheel_path: Some(PathBuf::from("test.png")),
requires_pinmame: true,
last_modified: IsoSystemTime(SystemTime::UNIX_EPOCH),
});
let test_dir = testdir!();
let index_path = test_dir.join("test.json");
write_index_json(&index, &index_path, None)?;
let read = read_index_json(&index_path, None)?;
assert_eq!(read, Some(index));
Ok(())
}
#[test]
fn test_properties_serialized_in_alphabetical_order() -> io::Result<()> {
let mut properties = BTreeMap::new();
properties.insert("zebra".to_string(), "z".to_string());
properties.insert("mango".to_string(), "m".to_string());
properties.insert("apple".to_string(), "a".to_string());
let mut index = TablesIndex::empty();
index.insert(IndexedTable {
path: PathBuf::from("test.vpx"),
table_info: IndexedTableInfo {
table_name: None,
author_name: None,
table_blurb: None,
table_rules: None,
author_email: None,
release_date: None,
table_save_rev: None,
table_version: None,
author_website: None,
table_save_date: None,
table_description: None,
properties,
},
game_name: None,
b2s_path: None,
rom_path: None,
local_rom_path: None,
wheel_path: None,
requires_pinmame: false,
last_modified: IsoSystemTime::from(SystemTime::UNIX_EPOCH),
});
let test_dir = testdir!();
let index_path = test_dir.join("test.json");
write_index_json(&index, &index_path, None)?;
let json_str = std::fs::read_to_string(&index_path)?;
let apple_pos = json_str.find("\"apple\"").expect("apple not found");
let mango_pos = json_str.find("\"mango\"").expect("mango not found");
let zebra_pos = json_str.find("\"zebra\"").expect("zebra not found");
assert!(apple_pos < mango_pos, "apple should come before mango");
assert!(mango_pos < zebra_pos, "mango should come before zebra");
let read = read_index_json(&index_path, None)?.expect("index missing after write");
let keys: Vec<_> = read
.tables()
.into_iter()
.next()
.unwrap()
.table_info
.properties
.keys()
.cloned()
.collect();
assert_eq!(keys, vec!["apple", "mango", "zebra"]);
Ok(())
}
#[test]
fn test_read_index_missing() -> io::Result<()> {
let index_path = PathBuf::from("missing_index_file.json");
let read = read_index_json(&index_path, None)?;
assert_eq!(read, None);
Ok(())
}
#[test]
fn test_write_read_index_paths_are_relative_and_normalized() -> io::Result<()> {
use std::fs;
let test_dir = testdir!();
let tables_root = test_dir.join("tables");
fs::create_dir(&tables_root)?;
let vpx_path = tables_root.join("subdir").join("game.vpx");
fs::create_dir_all(vpx_path.parent().unwrap())?;
fs::write(&vpx_path, [])?;
let mut index = TablesIndex::empty();
index.insert(IndexedTable {
path: vpx_path.clone(),
table_info: IndexedTableInfo {
table_name: None,
author_name: None,
table_blurb: None,
table_rules: None,
author_email: None,
release_date: None,
table_save_rev: None,
table_version: None,
author_website: None,
table_save_date: None,
table_description: None,
properties: BTreeMap::new(),
},
game_name: None,
b2s_path: None,
rom_path: None,
local_rom_path: None,
wheel_path: None,
requires_pinmame: false,
last_modified: IsoSystemTime(SystemTime::UNIX_EPOCH),
});
let index_path = test_dir.join("index.json");
write_index_json(&index, &index_path, Some(&tables_root))?;
let json_str = std::fs::read_to_string(&index_path)?;
assert!(
json_str.contains("subdir/game.vpx"),
"expected relative forward-slash path in JSON, got: {json_str}"
);
assert!(
!json_str.contains(&tables_root.to_string_lossy().to_string()),
"absolute path should not appear in JSON, got: {json_str}"
);
let restored = read_index_json(&index_path, Some(&tables_root))?.expect("index missing");
let restored_table = restored.tables().into_iter().next().unwrap();
assert_eq!(restored_table.path, vpx_path);
Ok(())
}
#[test]
fn test_extract_game_name() {
let code = r#"
Dim tableheight: tableheight = Table1.height
Const cGameName="godzilla",UseSolenoids=2,UseLamps=1,UseGI=0, SCoin=""
Const UseVPMModSol = True
"#
.to_string();
let game_name = extract_game_name(code);
assert_eq!(game_name, Some("godzilla".to_string()));
}
#[test]
fn test_extract_game_name_commented() {
let code = r#"
'Const cGameName = "commented"
Const cGameName = "actual"
"#
.to_string();
let game_name = extract_game_name(code);
assert_eq!(game_name, Some("actual".to_string()));
}
#[test]
fn test_extract_game_name_spaced() {
let code = r#"
Const cGameName = "gg"
"#
.to_string();
let game_name = extract_game_name(code);
assert_eq!(game_name, Some("gg".to_string()));
}
#[test]
fn test_extract_game_name_casing() {
let code = r#"
const cgamenamE = "othercase"
"#
.to_string();
let game_name = extract_game_name(code);
assert_eq!(game_name, Some("othercase".to_string()));
}
#[test]
fn test_extract_game_name_uppercase_name() {
let code = r#"
Const cGameName = "BOOM"
"#
.to_string();
let game_name = extract_game_name(code);
assert_eq!(game_name, Some("BOOM".to_string()));
}
#[test]
fn test_extract_game_name_with_underscore() {
let code = r#"
Const cGameName="simp_a27",UseSolenoids=1,UseLamps=0,UseGI=0,SSolenoidOn="SolOn",SSolenoidOff="SolOff", SCoin="coin"
LoadVPM "01000200", "DE.VBS", 3.36
"#
.to_string();
let game_name = extract_game_name(code);
assert_eq!(game_name, Some("simp_a27".to_string()));
}
#[test]
fn test_extract_game_name_multidef_end() {
let code = r#"
Const UseSolenoids=2,UseLamps=0,UseSync=1,UseGI=0,SCoin="coin",cGameName="barbwire"
"#
.to_string();
let game_name = extract_game_name(code);
assert_eq!(game_name, Some("barbwire".to_string()));
}
#[test]
fn test_extract_game_name_in_controller() {
let code = r#"
Sub Gorgar_Init
LoadLUT
On Error Resume Next
With Controller
.GameName="grgar_l1"
"#;
let game_name = extract_game_name(code);
assert_eq!(game_name, Some("grgar_l1".to_string()));
}
#[test]
fn test_extract_game_name_dot_gamename_inline() {
let code = r#"
With Controller : .GameName = "inline_l1" : End With
"#;
let game_name = extract_game_name(code);
assert_eq!(game_name, Some("inline_l1".to_string()));
}
#[test]
fn test_extract_game_name_2_line_dim() {
let code = r#"
Dim cGameName
cGameName = "abv106"
"#
.to_string();
let game_name = extract_game_name(code);
assert_eq!(game_name, Some("abv106".to_string()));
}
#[test]
fn test_requires_pinmame() {
let code = r#"#
LoadVPM "01210000", "sys80.VBS", 3.1
"#
.to_string();
assert!(requires_pinmame(code));
}
#[test]
fn test_requires_pinmame_other_casing() {
let code = r#"
loadVpm "01210000", \"sys80.VBS\", 3.1
"#
.to_string();
assert!(requires_pinmame(code));
}
#[test]
fn test_requires_pinmame_not() {
let code = r#"
Const cGameName = "GTB_4Square_1971"
"#
.to_string();
assert!(!requires_pinmame(code));
}
#[test]
fn test_requires_pinmame_with_same_sub() {
let code = r#"
Sub LoadVPM(VPMver, VBSfile, VBSver)
LoadVBSFiles VPMver, VBSfile, VBSver
LoadController("VPM")
End Sub
"#
.to_string();
assert!(!requires_pinmame(code));
}
#[test]
fn test_requires_pinmame_comment() {
let code = r#"
' VRRoom set based on RenderingMode
' Internal DMD in Desktop Mode, using a textbox (must be called before LoadVPM)
Dim UseVPMDMD, VRRoom, DesktopMode
If RenderingMode = 2 Then VRRoom = VRRoomChoice Else VRRoom = 0
"#
.to_string();
assert!(!requires_pinmame(code));
}
#[test]
fn test_requires_pinmame_comment_and_used() {
let code = r#"
Const SCoin="coin3",cCredits=""
LoadVPM "01210000","sys80.vbs",3.10
'Sub LoadVPM(VPMver, VBSfile, VBSver)
' On Error Resume Next
"#
.to_string();
assert!(requires_pinmame(code));
}
#[test]
fn test_requires_pinmame_cr_only_lines_and_commented_sub_loadvpm() {
let code =
"LoadVPM \"01210000\", \"sys80.VBS\", 3.1\r\r'Sub LoadVPM(VPMver, VBSfile, VBSver)\r";
assert!(requires_pinmame(code));
}
#[cfg(target_os = "linux")]
#[test]
fn test_find_local_rom_path_relative_linux() {
let test_table_dir = testdir!();
let vpx_path = test_table_dir.join("test.vpx");
let expected_rom_path = test_table_dir.join("roms").join("testgamename.zip");
fs::create_dir_all(expected_rom_path.parent().unwrap()).unwrap();
File::create(&expected_rom_path).unwrap();
let local_rom = find_local_rom_path(
&vpx_path,
&Some("testgamename".to_string()),
Some(&PathBuf::from("./roms")),
)
.unwrap();
assert_eq!(local_rom, Some(expected_rom_path));
}
#[cfg(target_os = "linux")]
#[test]
fn test_find_local_rom_path_relative_not_found_linux() {
let test_table_dir = testdir!();
let vpx_path = test_table_dir.join("test.vpx");
let expected_rom_path = test_table_dir.join("roms").join("testgamename.zip");
fs::create_dir_all(expected_rom_path.parent().unwrap()).unwrap();
let local_rom = find_local_rom_path(
&vpx_path,
&Some("testgamename".to_string()),
Some(&PathBuf::from("./roms")),
)
.unwrap();
assert_eq!(local_rom, None);
}
fn make_indexed_table(path: &str, table_name: Option<&str>) -> IndexedTable {
IndexedTable {
path: PathBuf::from(path),
table_info: IndexedTableInfo {
table_name: table_name.map(|s| s.to_string()),
author_name: None,
table_blurb: None,
table_rules: None,
author_email: None,
release_date: None,
table_save_rev: None,
table_version: None,
author_website: None,
table_save_date: None,
table_description: None,
properties: BTreeMap::new(),
},
game_name: None,
b2s_path: None,
rom_path: None,
local_rom_path: None,
wheel_path: None,
requires_pinmame: false,
last_modified: IsoSystemTime::from(SystemTime::UNIX_EPOCH),
}
}
#[test]
fn test_sort_tables_by_path_case_insensitive() {
let tables = vec![
make_indexed_table("dir/Zebra.vpx", Some("Alpha")),
make_indexed_table("dir/apple.vpx", Some("Zeta")),
make_indexed_table("dir/Mango.vpx", Some("Beta")),
];
let sorted = sort_tables(tables);
let paths: Vec<_> = sorted.iter().map(|t| t.path.to_str().unwrap()).collect();
assert_eq!(
paths,
vec!["dir/apple.vpx", "dir/Mango.vpx", "dir/Zebra.vpx"]
);
}
#[test]
fn test_sort_tables_by_full_path_not_just_filename() {
let tables = vec![
make_indexed_table("z_dir/apple.vpx", None),
make_indexed_table("a_dir/zebra.vpx", None),
];
let sorted = sort_tables(tables);
let paths: Vec<_> = sorted.iter().map(|t| t.path.to_str().unwrap()).collect();
assert_eq!(paths, vec!["a_dir/zebra.vpx", "z_dir/apple.vpx"]);
}
}