use anyhow::{Context, Result};
use ini_preserve::Ini;
use std::path::{Path, PathBuf};
pub fn default_ini_path() -> PathBuf {
dirs::data_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join("VPinballX")
.join("10.8")
.join("VPinballX.ini")
}
pub struct VpxConfig {
path: PathBuf,
ini: Ini,
}
impl VpxConfig {
pub fn load(path: Option<&Path>) -> Result<Self> {
let path = path.map(PathBuf::from).unwrap_or_else(default_ini_path);
let ini = if path.exists() {
Ini::load(&path).map_err(|e| anyhow::anyhow!("{e}"))?
} else {
Ini::new()
};
Ok(Self { path, ini })
}
pub fn save(&self) -> Result<()> {
if let Some(parent) = self.path.parent() {
std::fs::create_dir_all(parent).with_context(|| {
format!("Failed to create config directory: {}", parent.display())
})?;
}
let write_path = if self.path.is_symlink() {
let target = std::fs::read_link(&self.path).with_context(|| {
format!("Failed to read symlink target: {}", self.path.display())
})?;
if target.is_absolute() {
target
} else {
self.path
.parent()
.unwrap_or(std::path::Path::new("."))
.join(target)
}
} else {
self.path.clone()
};
let content = self.ini.to_string();
let line_count = content.split('\n').count();
log::info!(
"Saving ini: {} bytes, {} lines to {}{}",
content.len(),
line_count,
self.path.display(),
if self.path.is_symlink() { "@" } else { "" }
);
self.ini
.save(&write_path)
.map_err(|e| anyhow::anyhow!("{e}"))
}
pub fn path(&self) -> &Path {
&self.path
}
pub fn get(&self, section: &str, key: &str) -> Option<String> {
self.ini.get(section, key).map(|s| s.to_string())
}
pub fn set(&mut self, section: &str, key: &str, value: &str) {
self.ini.set(section, key, value);
}
pub fn get_i32(&self, section: &str, key: &str) -> Option<i32> {
self.get(section, key)?.parse().ok()
}
pub fn get_f32(&self, section: &str, key: &str) -> Option<f32> {
self.get(section, key)?.parse().ok()
}
pub fn set_i32(&mut self, section: &str, key: &str, value: i32) {
self.set(section, key, &value.to_string());
}
pub fn set_f32(&mut self, section: &str, key: &str, value: f32) {
self.set(section, key, &format!("{value}"));
}
pub fn set_display(
&mut self,
section: &str,
prefix: &str,
name: &str,
w: i32,
h: i32,
enable_output: bool,
) {
if enable_output {
self.set_i32(section, &format!("{prefix}Output"), 1);
}
self.set(section, &format!("{prefix}Display"), name);
self.set_i32(section, &format!("{prefix}FullScreen"), 1);
self.set(section, &format!("{prefix}WndX"), "");
self.set(section, &format!("{prefix}WndY"), "");
self.set_i32(section, &format!("{prefix}Width"), w);
self.set_i32(section, &format!("{prefix}Height"), h);
}
pub fn set_view_mode(&mut self, mode: i32) {
self.set_i32("Player", "BGSet", mode);
}
fn projection_mode_key(view_mode: i32) -> &'static str {
match view_mode {
0 => "ViewDTMode",
2 => "ViewFSSMode",
_ => "ViewCabMode",
}
}
pub fn set_window_projection(&mut self, view_mode: i32) {
self.set_i32("TableOverride", Self::projection_mode_key(view_mode), 2);
}
pub fn set_input_mapping(&mut self, action_id: &str, mapping: &str) {
let key = format!("Mapping.{action_id}");
self.set("Input", &key, mapping);
}
pub fn get_input_mapping(&self, action_id: &str) -> Option<String> {
let key = format!("Mapping.{action_id}");
self.get("Input", &key)
}
pub fn set_plumb_damping(&mut self, value: f32) {
self.set_f32("Player", "PlumbDamping", value);
}
pub fn set_plumb_threshold_angle(&mut self, value: f32) {
self.set_f32("Player", "PlumbThresholdAngle", value);
}
pub fn set_sound_device_bg(&mut self, device: &str) {
self.set("Player", "SoundDeviceBG", device);
}
pub fn set_sound_device_pf(&mut self, device: &str) {
self.set("Player", "SoundDevice", device);
}
pub fn set_sound_3d_mode(&mut self, mode: i32) {
self.set_i32("Player", "Sound3D", mode);
}
pub fn set_music_volume(&mut self, volume: i32) {
self.set_i32("Player", "MusicVolume", volume);
}
pub fn set_sound_volume(&mut self, volume: i32) {
self.set_i32("Player", "SoundVolume", volume);
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use tempfile::NamedTempFile;
fn config_from_str(content: &str) -> VpxConfig {
let mut tmp = NamedTempFile::new().unwrap();
tmp.write_all(content.as_bytes()).unwrap();
VpxConfig::load(Some(tmp.path())).unwrap()
}
#[test]
fn load_nonexistent_creates_empty() {
let cfg = VpxConfig::load(Some(Path::new("/tmp/pinready_test_nonexistent.ini"))).unwrap();
assert!(cfg.get("Player", "BGSet").is_none());
}
#[test]
fn get_set_roundtrip() {
let mut cfg = config_from_str("");
cfg.set("Player", "BGSet", "2");
assert_eq!(cfg.get("Player", "BGSet"), Some("2".to_string()));
}
#[test]
fn get_i32_valid() {
let cfg = config_from_str("[Player]\nBGSet = 2\n");
assert_eq!(cfg.get_i32("Player", "BGSet"), Some(2));
}
#[test]
fn get_i32_invalid_returns_none() {
let cfg = config_from_str("[Player]\nBGSet = abc\n");
assert_eq!(cfg.get_i32("Player", "BGSet"), None);
}
#[test]
fn get_f32_valid() {
let cfg = config_from_str("[Player]\nAAFactor = 1.5\n");
assert!((cfg.get_f32("Player", "AAFactor").unwrap() - 1.5).abs() < f32::EPSILON);
}
#[test]
fn get_f32_missing_returns_none() {
let cfg = config_from_str("");
assert_eq!(cfg.get_f32("Player", "AAFactor"), None);
}
#[test]
fn set_i32_and_read_back() {
let mut cfg = config_from_str("");
cfg.set_i32("Player", "Sound3D", 4);
assert_eq!(cfg.get_i32("Player", "Sound3D"), Some(4));
}
#[test]
fn set_f32_and_read_back() {
let mut cfg = config_from_str("");
cfg.set_f32("Player", "NudgeStrength", 0.02);
let val = cfg.get_f32("Player", "NudgeStrength").unwrap();
assert!((val - 0.02).abs() < 0.001);
}
#[test]
fn set_display_writes_all_keys() {
let mut cfg = config_from_str("");
cfg.set_display("Player", "Playfield", "Samsung U28E590", 3840, 2160, false);
assert_eq!(
cfg.get("Player", "PlayfieldDisplay"),
Some("Samsung U28E590".to_string())
);
assert_eq!(cfg.get_i32("Player", "PlayfieldWidth"), Some(3840));
assert_eq!(cfg.get_i32("Player", "PlayfieldHeight"), Some(2160));
assert_eq!(cfg.get_i32("Player", "PlayfieldFullScreen"), Some(1));
}
#[test]
fn set_display_with_output_enabled() {
let mut cfg = config_from_str("");
cfg.set_display("Backglass", "Backglass", "LG 27", 2560, 1440, true);
assert_eq!(cfg.get_i32("Backglass", "BackglassOutput"), Some(1));
}
#[test]
fn set_display_without_output() {
let mut cfg = config_from_str("");
cfg.set_display("Player", "Playfield", "Test", 1920, 1080, false);
assert_eq!(cfg.get_i32("Player", "PlayfieldOutput"), None);
}
#[test]
fn set_view_mode() {
let mut cfg = config_from_str("");
cfg.set_view_mode(1);
assert_eq!(cfg.get_i32("Player", "BGSet"), Some(1));
}
#[test]
fn window_projection_follows_the_view_set() {
for (bg_set, key) in [(0, "ViewDTMode"), (1, "ViewCabMode"), (2, "ViewFSSMode")] {
let mut cfg = config_from_str("");
cfg.set_window_projection(bg_set);
assert_eq!(cfg.get_i32("TableOverride", key), Some(2), "BGSet {bg_set}");
}
}
#[test]
fn input_mapping_roundtrip() {
let mut cfg = config_from_str("");
cfg.set_input_mapping("LeftFlipper", "Key;42");
assert_eq!(
cfg.get_input_mapping("LeftFlipper"),
Some("Key;42".to_string())
);
}
#[test]
fn input_mapping_missing() {
let cfg = config_from_str("");
assert_eq!(cfg.get_input_mapping("NonExistent"), None);
}
#[test]
fn audio_config_setters() {
let mut cfg = config_from_str("");
cfg.set_sound_device_bg("HD Audio");
cfg.set_sound_device_pf("USB Audio");
cfg.set_sound_3d_mode(5);
cfg.set_music_volume(80);
cfg.set_sound_volume(60);
assert_eq!(
cfg.get("Player", "SoundDeviceBG"),
Some("HD Audio".to_string())
);
assert_eq!(
cfg.get("Player", "SoundDevice"),
Some("USB Audio".to_string())
);
assert_eq!(cfg.get_i32("Player", "Sound3D"), Some(5));
assert_eq!(cfg.get_i32("Player", "MusicVolume"), Some(80));
assert_eq!(cfg.get_i32("Player", "SoundVolume"), Some(60));
}
#[test]
fn save_and_reload() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("test.ini");
{
let mut cfg = VpxConfig::load(Some(&path)).unwrap();
cfg.set("Player", "BGSet", "2");
cfg.set_i32("Player", "Sound3D", 4);
cfg.save().unwrap();
}
let cfg = VpxConfig::load(Some(&path)).unwrap();
assert_eq!(cfg.get("Player", "BGSet"), Some("2".to_string()));
assert_eq!(cfg.get_i32("Player", "Sound3D"), Some(4));
}
#[test]
fn save_creates_parent_dirs() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("sub/dir/test.ini");
let mut cfg = VpxConfig::load(Some(&path)).unwrap();
cfg.set("Test", "Key", "Value");
cfg.save().unwrap();
assert!(path.exists());
}
#[test]
fn preserves_existing_ini_content() {
let content = "[Player]\nBGSet = 0\nSound3D = 2\n";
let mut cfg = config_from_str(content);
cfg.set("Player", "BGSet", "1");
assert_eq!(cfg.get_i32("Player", "Sound3D"), Some(2));
assert_eq!(cfg.get_i32("Player", "BGSet"), Some(1));
}
}