use std::{
fmt::Debug,
marker::PhantomData,
path::{Path, PathBuf},
sync::{mpsc, Mutex},
thread,
};
use bevy::prelude::*;
use directories::ProjectDirs;
use serde::{Deserialize, Serialize};
use thiserror::Error;
#[derive(Error, Debug)]
pub enum SettingsError {
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
#[error("Serialization error: {0}")]
Serialize(String),
#[error("Deserialization error: {0}")]
Deserialize(String),
}
pub type SettingsResult<T> = Result<T, SettingsError>;
pub trait SettingsFormat: Send + Sync + 'static {
fn file_extension() -> &'static str;
fn serialize<T: Serialize>(value: &T) -> SettingsResult<String>;
fn deserialize<T: for<'de> Deserialize<'de>>(data: &str) -> SettingsResult<T>;
}
pub struct TomlFormat;
impl SettingsFormat for TomlFormat {
fn file_extension() -> &'static str {
"toml"
}
fn serialize<T: Serialize>(value: &T) -> SettingsResult<String> {
toml::to_string(value).map_err(|e| SettingsError::Serialize(e.to_string()))
}
fn deserialize<T: for<'de> Deserialize<'de>>(data: &str) -> SettingsResult<T> {
toml::from_str(data).map_err(|e| SettingsError::Deserialize(e.to_string()))
}
}
pub struct JsonFormat;
impl SettingsFormat for JsonFormat {
fn file_extension() -> &'static str {
"json"
}
fn serialize<T: Serialize>(value: &T) -> SettingsResult<String> {
serde_json::to_string_pretty(value).map_err(|e| SettingsError::Serialize(e.to_string()))
}
fn deserialize<T: for<'de> Deserialize<'de>>(data: &str) -> SettingsResult<T> {
serde_json::from_str(data).map_err(|e| SettingsError::Deserialize(e.to_string()))
}
}
fn write_binary<T: Serialize>(path: &Path, value: &T) -> SettingsResult<()> {
let bytes =
postcard::to_allocvec(value).map_err(|e| SettingsError::Serialize(e.to_string()))?;
std::fs::write(path, bytes).map_err(SettingsError::Io)
}
fn read_binary<T: for<'de> Deserialize<'de>>(path: &Path) -> SettingsResult<T> {
let bytes = std::fs::read(path).map_err(SettingsError::Io)?;
postcard::from_bytes(&bytes).map_err(|e| SettingsError::Deserialize(e.to_string()))
}
pub trait Setting:
Resource + Clone + Serialize + Default + for<'de> Deserialize<'de> + Debug + Send + Sync
{
}
impl<T> Setting for T where
T: Resource + Clone + Serialize + Default + for<'de> Deserialize<'de> + Debug + Send + Sync
{
}
pub trait ValidatedSetting {
fn validate(&mut self);
}
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum SettingsStorage {
SystemConfigDir,
GameLocalDir,
}
#[derive(Clone)]
pub struct SettingsPluginConfig {
pub domain: String,
pub company: String,
pub project: String,
pub format: FormatKind,
pub file_name: Option<String>,
pub storage: SettingsStorage,
}
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum FormatKind {
Toml,
Json,
Binary,
}
impl SettingsPluginConfig {
pub fn validate(&self) {
match self.storage {
SettingsStorage::SystemConfigDir => {
if self.company.is_empty() {
panic!(
"SettingsPluginConfig: 'company' cannot be empty when using SystemConfigDir. \
Please set a valid company name (e.g., 'MyCompany')."
);
}
if self.project.is_empty() {
panic!(
"SettingsPluginConfig: 'project' cannot be empty when using SystemConfigDir. \
Please set a valid project name (e.g., 'MyGame')."
);
}
if ProjectDirs::from(&self.domain, &self.company, &self.project).is_none() {
panic!(
"SettingsPluginConfig: unable to determine standard config directory for domain='{}', company='{}', project='{}'. \
Check that the strings do not contain invalid characters (e.g., '/', '\\', ':' on Windows).",
self.domain, self.company, self.project
);
}
}
SettingsStorage::GameLocalDir => {
if self.company.is_empty() || self.project.is_empty() {
bevy::log::warn!(
"SettingsPluginConfig: 'company' or 'project' is empty while using GameLocalDir. \
These fields are not required for local storage but may affect compatibility."
);
}
}
}
}
}
impl Default for SettingsPluginConfig {
fn default() -> Self {
Self {
domain: "com".into(),
company: "".into(),
project: "".into(),
format: FormatKind::Toml,
file_name: None,
storage: SettingsStorage::SystemConfigDir,
}
}
}
#[derive(Event)]
pub struct PersistAllSettings;
#[derive(Event)]
pub struct PersistSetting<S: Setting> {
pub value: Option<S>,
}
#[derive(Event)]
pub struct ReloadSetting<S: Setting> {
pub _phantom: PhantomData<S>,
}
#[derive(Event)]
pub struct SettingsSaveError<S: Setting> {
pub error: SettingsError,
pub _phantom: PhantomData<S>,
}
#[derive(Resource)]
struct SettingsInternal<S: Setting> {
config: SettingsPluginConfig,
path: PathBuf,
temp_path: PathBuf,
directory: PathBuf,
error_sender: mpsc::Sender<SettingsError>,
_marker: PhantomData<S>,
}
#[derive(Resource)]
struct SettingsErrorReceiver<S: Setting> {
receiver: Mutex<mpsc::Receiver<SettingsError>>,
_marker: PhantomData<S>,
}
impl<S: Setting> SettingsInternal<S> {
fn new(
config: SettingsPluginConfig,
dir: PathBuf,
path: PathBuf,
error_sender: mpsc::Sender<SettingsError>,
) -> Self {
let extension = match config.format {
FormatKind::Toml => TomlFormat::file_extension(),
FormatKind::Json => JsonFormat::file_extension(),
FormatKind::Binary => "bin",
};
Self {
temp_path: path.with_extension(format!("tmp.{}", extension)),
directory: dir,
path,
config,
error_sender,
_marker: PhantomData,
}
}
}
pub struct SettingsPlugin<S> {
config: SettingsPluginConfig,
_marker: PhantomData<S>,
}
impl<S: Setting + ValidatedSetting> SettingsPlugin<S> {
pub fn from_config(config: SettingsPluginConfig) -> Self {
config.validate();
Self {
config,
_marker: PhantomData,
}
}
fn file_stem(&self) -> String {
if let Some(ref name) = self.config.file_name {
name.clone()
} else {
let type_name = std::any::type_name::<S>();
let short_name = type_name.split("::").last().unwrap_or(type_name);
let mut snake = String::new();
for (i, ch) in short_name.chars().enumerate() {
if ch.is_uppercase() && i > 0 {
snake.push('_');
snake.push(ch.to_ascii_lowercase());
} else {
snake.push(ch.to_ascii_lowercase());
}
}
snake
}
}
fn base_dir(&self) -> PathBuf {
match self.config.storage {
SettingsStorage::SystemConfigDir => {
let proj_dirs = ProjectDirs::from(
&self.config.domain,
&self.config.company,
&self.config.project,
)
.expect("Already validated in config");
proj_dirs.config_dir().to_path_buf()
}
SettingsStorage::GameLocalDir => {
let exe_path =
std::env::current_exe().expect("Failed to get current executable path");
exe_path
.parent()
.expect("Executable has no parent directory")
.to_path_buf()
}
}
}
fn load(&self) -> SettingsResult<S> {
let path = self.path();
let mut settings = if !path.exists() {
S::default()
} else {
match self.config.format {
FormatKind::Binary => read_binary(&path)?,
_ => {
let data = std::fs::read_to_string(&path).map_err(SettingsError::Io)?;
self.deserialize_text(&data)?
}
}
};
settings.validate();
Ok(settings)
}
fn deserialize_text(&self, data: &str) -> SettingsResult<S> {
match self.config.format {
FormatKind::Toml => TomlFormat::deserialize(data),
FormatKind::Json => JsonFormat::deserialize(data),
FormatKind::Binary => unreachable!(),
}
}
fn serialize_text(&self, value: &S) -> SettingsResult<String> {
match self.config.format {
FormatKind::Toml => TomlFormat::serialize(value),
FormatKind::Json => JsonFormat::serialize(value),
FormatKind::Binary => unreachable!(),
}
}
fn path(&self) -> PathBuf {
let extension = match self.config.format {
FormatKind::Toml => TomlFormat::file_extension(),
FormatKind::Json => JsonFormat::file_extension(),
FormatKind::Binary => "bin",
};
let file_stem = self.file_stem();
self.base_dir().join(format!("{}.{}", file_stem, extension))
}
fn directory(&self) -> PathBuf {
self.base_dir()
}
fn save_to_file(
temp_path: &Path,
path: &Path,
settings: &S,
format_kind: FormatKind,
error_sender: mpsc::Sender<SettingsError>,
) {
let write_result = match format_kind {
FormatKind::Binary => write_binary(temp_path, settings),
_ => {
let content = match format_kind {
FormatKind::Toml => TomlFormat::serialize(settings),
FormatKind::Json => JsonFormat::serialize(settings),
_ => unreachable!(),
};
match content {
Ok(c) => std::fs::write(temp_path, c).map_err(SettingsError::Io),
Err(e) => Err(e),
}
}
};
match write_result {
Ok(_) => {
if let Err(e) = std::fs::rename(temp_path, path) {
bevy::log::error!("Failed to rename settings file: {}", e);
let _ = error_sender.send(SettingsError::Io(e));
let _ = std::fs::remove_file(temp_path);
} else {
bevy::log::debug!("Settings saved to {:?}", path);
}
}
Err(e) => {
bevy::log::error!("Failed to write temp settings file: {}", e);
let _ = error_sender.send(e);
let _ = std::fs::remove_file(temp_path);
}
}
}
fn persist_setting_observer(
event: On<PersistSetting<S>>,
mut settings: ResMut<S>,
internal: Res<SettingsInternal<S>>,
) {
let ev = event.event();
if let Some(new_value) = &ev.value {
*settings = new_value.clone();
settings.validate();
}
settings.validate();
let path = internal.path.clone();
let temp_path = internal.temp_path.clone();
let settings_clone = settings.clone();
let format_kind = internal.config.format;
let error_sender = internal.error_sender.clone();
thread::Builder::new()
.name("bevy-settings-save".into())
.spawn(move || {
Self::save_to_file(
&temp_path,
&path,
&settings_clone,
format_kind,
error_sender,
);
})
.expect("Failed to spawn save thread");
}
fn persist_all_observer(
_event: On<PersistAllSettings>,
mut settings: ResMut<S>,
internal: Res<SettingsInternal<S>>,
) {
settings.validate();
let path = internal.path.clone();
let temp_path = internal.temp_path.clone();
let settings_clone = settings.clone();
let format_kind = internal.config.format;
let error_sender = internal.error_sender.clone();
thread::Builder::new()
.name("bevy-settings-save".into())
.spawn(move || {
Self::save_to_file(
&temp_path,
&path,
&settings_clone,
format_kind,
error_sender,
);
})
.expect("Failed to spawn save thread");
}
fn reload_observer(
_event: On<ReloadSetting<S>>,
mut settings: ResMut<S>,
internal: Res<SettingsInternal<S>>,
) {
let load_result = match internal.config.format {
FormatKind::Binary => read_binary::<S>(&internal.path),
_ => {
let content = match std::fs::read_to_string(&internal.path) {
Ok(c) => c,
Err(e) => {
bevy::log::error!("Failed to read settings file: {}", e);
return;
}
};
match internal.config.format {
FormatKind::Toml => TomlFormat::deserialize(&content),
FormatKind::Json => JsonFormat::deserialize(&content),
_ => unreachable!(),
}
}
};
match load_result {
Ok(mut new_settings) => {
new_settings.validate(); *settings = new_settings;
bevy::log::info!("Settings reloaded from {:?}", internal.path);
}
Err(e) => {
bevy::log::error!("Failed to reload settings: {}", e);
}
}
}
fn process_error_messages(
error_receiver: ResMut<SettingsErrorReceiver<S>>,
mut commands: Commands,
) {
let receiver = error_receiver.receiver.lock().unwrap();
while let Ok(error) = receiver.try_recv() {
commands.trigger(SettingsSaveError::<S> {
error,
_phantom: PhantomData::<S>,
});
}
}
}
impl<S: Setting + ValidatedSetting> Plugin for SettingsPlugin<S> {
fn build(&self, app: &mut App) {
let load_result = self.load();
let mut initial_value = match load_result {
Ok(v) => v,
Err(e) => {
bevy::log::error!(
"Failed to load settings for {}: {}, using default",
std::any::type_name::<S>(),
e
);
S::default()
}
};
initial_value.validate();
let dir = self.directory();
let path = self.path();
let (error_sender, error_receiver) = mpsc::channel();
if let Err(e) = std::fs::create_dir_all(&dir) {
bevy::log::error!("Failed to create settings directory: {}", e);
let _ = error_sender.send(SettingsError::Io(e));
}
let internal = SettingsInternal::<S>::new(self.config.clone(), dir, path, error_sender);
let error_receiver_resource = SettingsErrorReceiver::<S> {
receiver: Mutex::new(error_receiver),
_marker: PhantomData,
};
app.insert_resource(initial_value)
.insert_resource(internal)
.insert_resource(error_receiver_resource)
.add_observer(Self::persist_setting_observer)
.add_observer(Self::persist_all_observer)
.add_observer(Self::reload_observer)
.add_systems(Update, Self::process_error_messages);
}
}
#[cfg(test)]
mod test_utils {
use super::*;
use std::path::PathBuf;
pub fn should_cleanup() -> bool {
std::env::var("KEEP_TEST_FILES").is_err()
}
pub fn cleanup_paths(paths: &[PathBuf]) {
if !should_cleanup() {
println!("Skipping cleanup due to KEEP_TEST_FILES");
return;
}
for path in paths {
if path.exists() {
if path.is_file() {
let _ = std::fs::remove_file(path);
} else if path.is_dir() {
let _ = std::fs::remove_dir_all(path);
}
}
}
}
pub fn config_dir(config: &SettingsPluginConfig) -> PathBuf {
match config.storage {
SettingsStorage::SystemConfigDir => {
ProjectDirs::from(&config.domain, &config.company, &config.project)
.expect("Failed to determine config directory for test - check domain, company, and project values")
.config_dir()
.to_path_buf()
}
SettingsStorage::GameLocalDir => {
std::env::temp_dir()
}
}
}
pub fn settings_path<S: Setting + ValidatedSetting>(config: &SettingsPluginConfig) -> PathBuf {
let plugin = SettingsPlugin::<S>::from_config(config.clone());
plugin.path()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_utils::{cleanup_paths, config_dir, settings_path};
use bevy::app::App;
use serial_test::serial;
use std::collections::HashMap;
use std::time::Duration;
const TEST_DOMAIN: &str = "com";
const TEST_COMPANY: &str = "MyCompany";
const TEST_PROJECT: &str = "mygame";
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Default)]
pub enum GraphicsQuality {
#[default]
Low,
Medium,
High,
Ultra,
}
#[derive(Resource, Serialize, Deserialize, Clone, Debug, PartialEq)]
struct GameConfig {
pub render_scale: f32,
pub max_fps: u16,
pub shadow_map_size: u32,
pub anisotropic_filtering: i32,
pub vsync_enabled: bool,
pub quality: GraphicsQuality,
pub default_language: String,
pub enabled_post_effects: Vec<String>,
pub custom_resolution: Option<(u32, u32)>,
pub texture_quality_overrides: HashMap<String, u8>,
}
impl Default for GameConfig {
fn default() -> Self {
Self {
render_scale: 1.0,
max_fps: 144,
shadow_map_size: 2048,
anisotropic_filtering: 16,
vsync_enabled: true,
quality: GraphicsQuality::High,
default_language: "en-US".to_string(),
enabled_post_effects: vec!["bloom".to_string(), "ssao".to_string()],
custom_resolution: None,
texture_quality_overrides: HashMap::new(),
}
}
}
impl ValidatedSetting for GameConfig {
fn validate(&mut self) {
self.render_scale = self.render_scale.clamp(0.5, 2.0);
self.max_fps = self.max_fps.clamp(30, 360);
self.shadow_map_size = self.shadow_map_size.clamp(512, 4096);
self.anisotropic_filtering = self.anisotropic_filtering.clamp(1, 16);
if self.default_language.is_empty() {
self.default_language = "en-US".to_string();
}
self.enabled_post_effects
.retain(|effect| matches!(effect.as_str(), "bloom" | "ssao" | "motion_blur"));
if let Some((w, h)) = self.custom_resolution {
if w == 0 || h == 0 {
self.custom_resolution = None;
}
}
for (_, quality) in self.texture_quality_overrides.iter_mut() {
*quality = (*quality).clamp(0, 100);
}
}
}
#[derive(Resource, Default, Serialize, Deserialize, Clone, Debug, PartialEq)]
struct TestPreferences {
pub volume: f32,
pub size: f32,
}
impl ValidatedSetting for TestPreferences {
fn validate(&mut self) {}
}
#[test]
#[serial]
fn test_automatic_file_name() {
let mut app = App::new();
let base_config = SettingsPluginConfig {
domain: TEST_DOMAIN.into(),
company: TEST_COMPANY.into(),
project: TEST_PROJECT.into(),
format: FormatKind::Toml,
file_name: None,
storage: SettingsStorage::SystemConfigDir,
};
app.add_plugins(SettingsPlugin::<GameConfig>::from_config(
base_config.clone(),
));
app.add_plugins(SettingsPlugin::<TestPreferences>::from_config(
base_config.clone(),
));
app.update();
let plugin_game = SettingsPlugin::<GameConfig>::from_config(base_config.clone());
let plugin_prefs = SettingsPlugin::<TestPreferences>::from_config(base_config.clone());
assert_eq!(plugin_game.file_stem(), "game_config");
assert_eq!(plugin_prefs.file_stem(), "test_preferences");
{
let mut game = app.world_mut().resource_mut::<GameConfig>();
game.render_scale = 1.2;
game.vsync_enabled = true;
}
{
let mut prefs = app.world_mut().resource_mut::<TestPreferences>();
prefs.volume = 0.75;
prefs.size = 1.5;
}
app.world_mut()
.commands()
.trigger(PersistSetting::<GameConfig> { value: None });
app.world_mut()
.commands()
.trigger(PersistSetting::<TestPreferences> { value: None });
app.update();
std::thread::sleep(Duration::from_millis(100));
let dir = config_dir(&base_config);
let game_path = dir.join("game_config.toml");
let prefs_path = dir.join("test_preferences.toml");
assert!(game_path.exists(), "GameConfig file not found");
assert!(prefs_path.exists(), "TestPreferences file not found");
let game_content = std::fs::read_to_string(&game_path).unwrap();
let loaded_game: GameConfig = toml::from_str(&game_content).unwrap();
assert_eq!(loaded_game.render_scale, 1.2);
assert_eq!(loaded_game.vsync_enabled, true);
let prefs_content = std::fs::read_to_string(&prefs_path).unwrap();
let loaded_prefs: TestPreferences = toml::from_str(&prefs_content).unwrap();
assert_eq!(loaded_prefs.volume, 0.75);
assert_eq!(loaded_prefs.size, 1.5);
cleanup_paths(&[game_path, prefs_path]);
}
#[test]
#[serial]
fn test_explicit_file_name() {
let mut app = App::new();
let explicit_name = "explicit_name";
let config = SettingsPluginConfig {
domain: TEST_DOMAIN.into(),
company: TEST_COMPANY.into(),
project: TEST_PROJECT.into(),
format: FormatKind::Toml,
file_name: Some(explicit_name.into()),
storage: SettingsStorage::SystemConfigDir,
};
app.add_plugins(SettingsPlugin::<GameConfig>::from_config(config.clone()));
app.update();
let plugin = SettingsPlugin::<GameConfig>::from_config(config.clone());
assert_eq!(plugin.file_stem(), explicit_name);
{
let mut game = app.world_mut().resource_mut::<GameConfig>();
game.render_scale = 2.0;
}
app.world_mut()
.commands()
.trigger(PersistSetting::<GameConfig> { value: None });
app.update();
std::thread::sleep(Duration::from_millis(100));
let path = settings_path::<GameConfig>(&config);
assert!(path.exists(), "File does not exist at {:?}", path);
cleanup_paths(&[path]);
}
}
#[cfg(test)]
mod comprehensive_tests {
use super::*;
use crate::test_utils::{cleanup_paths, settings_path};
use bevy::app::App;
use serial_test::serial;
use std::fs;
use std::time::Duration;
const TEST_DOMAIN: &str = "com";
const TEST_COMPANY: &str = "MyCompany";
const TEST_PROJECT: &str = "mygame";
#[derive(Resource, Default, Serialize, Deserialize, Clone, Debug, PartialEq)]
struct GameConfig2 {
pub render_scale: f32,
pub vsync: bool,
}
impl ValidatedSetting for GameConfig2 {
fn validate(&mut self) {
self.render_scale = self.render_scale.clamp(0.5, 2.0);
}
}
#[derive(Resource, Default, Serialize, Deserialize, Clone, Debug, PartialEq)]
struct UserPrefs2 {
pub music_volume: f32,
pub sfx_volume: f32,
pub controls_inverted: bool,
}
impl ValidatedSetting for UserPrefs2 {
fn validate(&mut self) {
self.music_volume = self.music_volume.clamp(0.0, 1.0);
self.sfx_volume = self.sfx_volume.clamp(0.0, 1.0);
}
}
#[test]
#[serial]
fn test_config_and_prefs_together() {
let mut app = App::new();
let config_game = SettingsPluginConfig {
domain: TEST_DOMAIN.into(),
company: TEST_COMPANY.into(),
project: TEST_PROJECT.into(),
format: FormatKind::Toml,
file_name: None,
storage: SettingsStorage::SystemConfigDir,
};
let user_prefs = SettingsPluginConfig {
domain: TEST_DOMAIN.into(),
company: TEST_COMPANY.into(),
project: TEST_PROJECT.into(),
format: FormatKind::Json,
file_name: None,
storage: SettingsStorage::GameLocalDir,
};
app.add_plugins(SettingsPlugin::<GameConfig2>::from_config(
config_game.clone(),
));
app.add_plugins(SettingsPlugin::<UserPrefs2>::from_config(
user_prefs.clone(),
));
{
let mut config = app.world_mut().resource_mut::<GameConfig2>();
config.render_scale = 1.5;
config.vsync = true;
}
{
let mut prefs = app.world_mut().resource_mut::<UserPrefs2>();
prefs.music_volume = 0.8;
prefs.sfx_volume = 0.9;
prefs.controls_inverted = true;
}
app.world_mut()
.commands()
.trigger(PersistSetting::<GameConfig2> { value: None });
app.world_mut()
.commands()
.trigger(PersistSetting::<UserPrefs2> { value: None });
for _ in 0..10 {
app.update();
std::thread::sleep(Duration::from_millis(50));
}
let game_path = settings_path::<GameConfig2>(&config_game);
let prefs_path = settings_path::<UserPrefs2>(&user_prefs);
assert!(game_path.exists(), "Config file not found");
assert!(prefs_path.exists(), "Prefs file not found");
let game_content = fs::read_to_string(&game_path).unwrap();
let loaded_config: GameConfig2 = toml::from_str(&game_content).unwrap();
assert_eq!(loaded_config.render_scale, 1.5);
assert_eq!(loaded_config.vsync, true);
let prefs_content = fs::read_to_string(&prefs_path).unwrap();
let loaded_prefs: UserPrefs2 = serde_json::from_str(&prefs_content).unwrap();
assert_eq!(loaded_prefs.music_volume, 0.8);
assert_eq!(loaded_prefs.sfx_volume, 0.9);
assert_eq!(loaded_prefs.controls_inverted, true);
let new_config_content = r#"
render_scale = 10.0
vsync = false
"#;
fs::write(&game_path, new_config_content).unwrap();
let new_prefs_content =
r#"{ "music_volume": 2.0, "sfx_volume": 1.5, "controls_inverted": false }"#;
fs::write(&prefs_path, new_prefs_content).unwrap();
app.world_mut()
.commands()
.trigger(ReloadSetting::<GameConfig2> {
_phantom: PhantomData,
});
app.world_mut()
.commands()
.trigger(ReloadSetting::<UserPrefs2> {
_phantom: PhantomData,
});
for _ in 0..5 {
app.update();
std::thread::sleep(Duration::from_millis(50));
}
let config = app.world().resource::<GameConfig2>();
assert_eq!(config.render_scale, 2.0); assert_eq!(config.vsync, false);
let prefs = app.world().resource::<UserPrefs2>();
assert_eq!(prefs.music_volume, 1.0); assert_eq!(prefs.sfx_volume, 1.0); assert_eq!(prefs.controls_inverted, false);
cleanup_paths(&[game_path, prefs_path]);
}
}