use serde_json::{json, Value}; use std::{env, fs, io::{self, stdin, Read, Write}, path::{Path, PathBuf}}; const UNLIMITED_FPS_CAP_NUM: i32 = i32::max_value(); #[derive(Debug)] enum Platform { Windows(PathBuf), Linux(PathBuf), } fn main() -> io::Result<()> { let platform = find_platform_path().ok_or_else(|| { println!("Neither Roblox (Windows) nor Sober (Linux) installation found"); io::Error::new(io::ErrorKind::NotFound, "No supported installation found") })?; match &platform { Platform::Windows(path) => println!("Found Roblox installation: {}", path.display()), Platform::Linux(path) => println!("Found Sober installation: {}", path.display()), } println!("What FPS would you like to cap the fps to? (0 for no cap at all)"); io::stdout().flush()?; let fps = read_fps_input()?; let desired_fps = if fps == 0 { UNLIMITED_FPS_CAP_NUM } else { fps }; println!("FPS cap will be set to {desired_fps}"); let required_settings = json!({ // Restore original 'alt-enter' behaviour "FFlagHandleAltEnterFullscreenManually": "False", // Disable Roblox's built-in 240 fps hard-coded cap and replace with custom cap "DFIntTaskSchedulerTargetFps": desired_fps, "FFlagGameBasicSettingsFramerateCap5": "False", "FFlagTaskSchedulerLimitTargetFpsTo2402": "False", // Ensure Direct3D11 is the rendering API for optimal performance "FFlagDebugGraphicsDisableDirect3D11": "False", "FFlagDebugGraphicsPreferD3D11": "True", // Disable telemetry related FFlags "FFlagDebugDisableTelemetryEphemeralCounter": "True", "FFlagDebugDisableTelemetryEphemeralStat": "True", "FFlagDebugDisableTelemetryEventIngest": "True", "FFlagDebugDisableTelemetryPoint": "True", "FFlagDebugDisableTelemetryV2Counter": "True", "FFlagDebugDisableTelemetryV2Event": "True", "FFlagDebugDisableTelemetryV2Stat": "True" }); update_settings(platform, required_settings)?; println!("Press enter to exit"); let _ = io::stdin().read(&mut [0u8])?; Ok(()) } fn find_platform_path() -> Option<Platform> { // Try Windows (Roblox) first if let Some(roblox_path) = find_roblox_path() { return Some(Platform::Windows(roblox_path)); } // Try Linux (Sober) if let Some(sober_path) = find_sober_path() { return Some(Platform::Linux(sober_path)); } None } fn find_roblox_path() -> Option<PathBuf> { if let Some(local_app_data) = env::var("LOCALAPPDATA").ok() { let path = PathBuf::from(local_app_data).join("Roblox"); if path.is_dir() { return Some(path); } } // This is here because Roblox installs into Program Files (x86) if you run the installer as administrator (??? lol) if let Some(program_files) = env::var("ProgramFiles(x86)").ok() { let path = PathBuf::from(program_files).join("Roblox"); if path.is_dir() { return Some(path); } } None } fn find_sober_path() -> Option<PathBuf> { if let Some(home) = env::var("HOME").ok() { let config_path = PathBuf::from(home) .join(".var") .join("app") .join("org.vinegarhq.Sober") .join("config") .join("sober") .join("config.json"); if config_path.exists() { return Some(config_path); } } None } fn read_fps_input() -> io::Result<i32> { let mut input = String::new(); stdin().read_line(&mut input)?; let input = input.trim(); input.parse::<i32>().map_err(|_| { io::Error::new(io::ErrorKind::InvalidInput, "Invalid FPS value") }) } fn update_settings(platform: Platform, required_settings: Value) -> io::Result<()> { match platform { Platform::Windows(roblox_path) => update_roblox_settings(&roblox_path, required_settings), Platform::Linux(sober_config_path) => update_sober_settings(&sober_config_path, required_settings), } } fn update_roblox_settings(roblox_path: &Path, required_settings: Value) -> io::Result<()> { let versions_path = roblox_path.join("Versions"); for entry in fs::read_dir(versions_path)? { let entry = entry?; let path = entry.path(); let path_str = path.to_string_lossy().to_lowercase(); if path_str.contains("version") { let executable = path.join("RobloxPlayerBeta.exe"); if executable.is_file() { let client_settings_dir = path.join("ClientSettings"); if !client_settings_dir.exists() { fs::create_dir(&client_settings_dir)?; println!("Created ClientSettings folder in {}", path.display()); } else { println!("ClientSettings folder already exists in {}", path.display()); } let settings_file = client_settings_dir.join("ClientAppSettings.json"); update_roblox_settings_file(&settings_file, &required_settings)?; println!("Updated ClientAppSettings.json in {}", path.display()); } } } Ok(()) } fn update_sober_settings(config_path: &Path, required_settings: Value) -> io::Result<()> { let mut config = if config_path.exists() { match fs::read_to_string(config_path) { Ok(content) if !content.trim().is_empty() => { match serde_json::from_str::<Value>(&content) { Ok(existing) => existing, Err(e) => { println!("Warning: Could not parse existing Sober config ({}), creating new structure", e); json!({}) } } }, _ => json!({}) } } else { json!({}) }; // Ensure config is an object if !config.is_object() { config = json!({}); } // Get or create the fflags field let mut fflags = config.get("fflags").cloned().unwrap_or_else(|| json!({})); // Ensure fflags is an object if !fflags.is_object() { fflags = json!({}); } // Add our required settings to fflags if let Some(required_obj) = required_settings.as_object() { if let Some(fflags_obj) = fflags.as_object_mut() { for (key, value) in required_obj { println!("Adding FFlag {}: {}", key, value); fflags_obj.insert(key.clone(), value.clone()); } } } // Update the config with the modified fflags if let Some(config_obj) = config.as_object_mut() { config_obj.insert("fflags".to_string(), fflags); } // Write back to file let formatted = serde_json::to_string_pretty(&config)?; fs::write(config_path, formatted)?; println!("Updated Sober config at {}", config_path.display()); Ok(()) } fn update_roblox_settings_file(file_path: &Path, required_settings: &Value) -> io::Result<()> { // Funky code to edit or create the ClientAppSettings.json file (shoutout claude for the help with the object stuff (json sucks)) let mut settings = if file_path.exists() { match fs::read_to_string(file_path) { Ok(content) if !content.trim().is_empty() => { match serde_json::from_str::<Value>(&content) { Ok(existing) => existing, Err(e) => { println!("Warning: Could not parse existing settings ({}), creating new file", e); json!({}) } } }, _ => json!({}) } } else { json!({}) }; if let Some(required_obj) = required_settings.as_object() { if !settings.is_object() { settings = json!({}); } if let Some(settings_obj) = settings.as_object_mut() { for (key, value) in required_obj { println!("Adding {}: {}", key, value); settings_obj.insert(key.clone(), value.clone()); } } } let formatted = serde_json::to_string_pretty(&settings)?; fs::write(file_path, formatted)?; Ok(()) }