rlstatsapi 0.1.4

Rocket League Stats API TCP client, parser, and optional Python bindings
Documentation
use std::env;
use std::fs;
use std::path::{Path, PathBuf};

use crate::error::RlStatsError;

pub const DEFAULT_PORT: u16 = 49123;
pub const DEFAULT_PACKET_SEND_RATE: f32 = 60.0;
pub const MAX_PACKET_SEND_RATE: f32 = 120.0;
const DEFAULT_INI_TEMPLATE: &str = "[TAGame.MatchStatsExporter_TA]\n\n; Port the client will listen for connections on\nPort=49123\n\n; How many times per second the game sends the update state (capped at 120, 0 disables this feature)\nPacketSendRate=60\n";

#[derive(Debug, Clone)]
pub struct ClientOptions {
    pub host: String,
    pub port_override: Option<u16>,
    pub stats_api_ini_path: Option<PathBuf>,
    pub auto_enable_packet_rate: bool,
    pub packet_send_rate: f32,
    pub set_packet_rate_only_when_zero: bool,
}

impl Default for ClientOptions {
    fn default() -> Self {
        Self {
            host: "127.0.0.1".to_string(),
            port_override: None,
            stats_api_ini_path: None,
            auto_enable_packet_rate: true,
            packet_send_rate: DEFAULT_PACKET_SEND_RATE,
            set_packet_rate_only_when_zero: true,
        }
    }
}

#[derive(Debug, Clone)]
pub struct ConnectionConfig {
    pub host: String,
    pub port: u16,
    pub packet_send_rate: f32,
    pub ini_path: Option<PathBuf>,
    pub ini_mutated: bool,
}

impl ConnectionConfig {
    pub fn socket_address(&self) -> String {
        format!("{}:{}", self.host, self.port)
    }
}

pub fn prepare_connection_config(
    options: &ClientOptions,
) -> Result<ConnectionConfig, RlStatsError> {
    let ini_path = resolve_ini_path(options)?;
    let mut port = options.port_override.unwrap_or(DEFAULT_PORT);
    let mut packet_send_rate = clamp_rate(options.packet_send_rate);
    let mut ini_mutated = false;

    if let Some(path) = &ini_path {
        let mut ini = read_config_file(path)?;

        if options.port_override.is_none() {
            let parsed_port = find_u16_value(&ini, "Port").filter(|p| *p > 0);

            if let Some(valid_port) = parsed_port {
                port = valid_port;
            } else {
                ini = upsert_key(&ini, "Port", &port.to_string());
                ini_mutated = true;
            }
        }

        if options.auto_enable_packet_rate {
            let existing_rate =
                find_f32_value(&ini, "PacketSendRate").unwrap_or(0.0);
            let should_set = if options.set_packet_rate_only_when_zero {
                existing_rate <= 0.0
            } else {
                true
            };

            if should_set {
                ini = upsert_key(
                    &ini,
                    "PacketSendRate",
                    &format_rate(packet_send_rate),
                );
                ini_mutated = true;
            } else {
                packet_send_rate = clamp_rate(existing_rate);
            }

            if ini_mutated {
                write_config_file(path, &ini)?;
            }
        } else if ini_mutated {
            write_config_file(path, &ini)?;
        }

        if let Some(rate) = find_f32_value(&ini, "PacketSendRate") {
            packet_send_rate = clamp_rate(rate);
        }
    }

    Ok(ConnectionConfig {
        host: options.host.clone(),
        port,
        packet_send_rate,
        ini_path,
        ini_mutated,
    })
}

fn resolve_ini_path(
    options: &ClientOptions,
) -> Result<Option<PathBuf>, RlStatsError> {
    if let Some(path) = &options.stats_api_ini_path {
        ensure_ini_file_exists(path)?;
        return Ok(Some(path.clone()));
    }

    Ok(None)
}

pub fn discover_default_stats_api_ini_path() -> Option<PathBuf> {
    default_stats_api_ini_candidates()
        .into_iter()
        .find(|path| path.exists())
}

fn default_stats_api_ini_candidates() -> Vec<PathBuf> {
    let mut candidates = Vec::new();

    if let Ok(from_env) = env::var("RL_STATS_API_INI") {
        candidates.push(PathBuf::from(from_env));
    }

    if let Ok(home) = env::var("HOME") {
        let home_path = PathBuf::from(home);

        candidates.push(
            home_path
                .join(".local/share/Steam/steamapps/common/rocketleague/TAGame/Config/DefaultStatsAPI.ini"),
        );
        candidates.push(
            home_path
                .join(".steam/steam/steamapps/common/rocketleague/TAGame/Config/DefaultStatsAPI.ini"),
        );
        candidates.push(
            home_path.join(
                ".steam/steam/steamapps/compatdata/252950/pfx/drive_c/users/steamuser/Documents/My Games/Rocket League/TAGame/Config/DefaultStatsAPI.ini",
            ),
        );
    }

    if let Ok(program_files) = env::var("ProgramFiles") {
        candidates.push(
            PathBuf::from(program_files)
                .join("Rocket League/TAGame/Config/DefaultStatsAPI.ini"),
        );
    }

    if let Ok(program_files_x86) = env::var("ProgramFiles(x86)") {
        candidates.push(
            PathBuf::from(program_files_x86)
                .join("Rocket League/TAGame/Config/DefaultStatsAPI.ini"),
        );
    }

    if let Ok(app_data) = env::var("APPDATA") {
        candidates.push(PathBuf::from(app_data).join(
            "..\\Local\\Rocket League\\TAGame\\Config\\DefaultStatsAPI.ini",
        ));
    }

    candidates
}

fn ensure_ini_file_exists(path: &Path) -> Result<(), RlStatsError> {
    if path.exists() {
        return Ok(());
    }

    if let Some(parent) = path.parent() {
        fs::create_dir_all(parent).map_err(|source| {
            RlStatsError::ConfigIo {
                path: parent.display().to_string(),
                source,
            }
        })?;
    }

    fs::write(path, DEFAULT_INI_TEMPLATE).map_err(|source| {
        RlStatsError::ConfigIo {
            path: path.display().to_string(),
            source,
        }
    })
}

fn read_config_file(path: &Path) -> Result<String, RlStatsError> {
    fs::read_to_string(path).map_err(|source| RlStatsError::ConfigIo {
        path: path.display().to_string(),
        source,
    })
}

fn write_config_file(path: &Path, contents: &str) -> Result<(), RlStatsError> {
    fs::write(path, contents).map_err(|source| RlStatsError::ConfigIo {
        path: path.display().to_string(),
        source,
    })
}

fn clamp_rate(rate: f32) -> f32 {
    rate.clamp(0.0, MAX_PACKET_SEND_RATE)
}

fn format_rate(rate: f32) -> String {
    let mut formatted = format!("{rate:.3}");
    while formatted.ends_with('0') {
        formatted.pop();
    }
    if formatted.ends_with('.') {
        formatted.pop();
    }
    if formatted.is_empty() {
        "0".to_string()
    } else {
        formatted
    }
}

fn find_f32_value(contents: &str, key: &str) -> Option<f32> {
    find_string_value(contents, key).and_then(|raw| raw.parse::<f32>().ok())
}

fn find_u16_value(contents: &str, key: &str) -> Option<u16> {
    find_string_value(contents, key).and_then(|raw| raw.parse::<u16>().ok())
}

fn find_string_value(contents: &str, key: &str) -> Option<String> {
    for line in contents.lines() {
        if let Some((lhs, rhs, _indent)) = split_key_value(line) {
            if lhs.eq_ignore_ascii_case(key) {
                return Some(rhs.to_string());
            }
        }
    }
    None
}

fn upsert_key(contents: &str, key: &str, value: &str) -> String {
    let mut found = false;
    let mut output_lines = Vec::new();

    for line in contents.lines() {
        if let Some((lhs, _rhs, indent)) = split_key_value(line) {
            if lhs.eq_ignore_ascii_case(key) {
                output_lines.push(format!("{indent}{key}={value}"));
                found = true;
                continue;
            }
        }
        output_lines.push(line.to_string());
    }

    if !found {
        if !output_lines.is_empty()
            && !output_lines.last().is_some_and(|line| line.is_empty())
        {
            output_lines.push(String::new());
        }
        output_lines.push(format!("{key}={value}"));
    }

    output_lines.join("\n")
}

fn split_key_value(line: &str) -> Option<(&str, &str, &str)> {
    let indent_len = line.chars().take_while(|ch| ch.is_whitespace()).count();
    let indent = &line[..indent_len];
    let trimmed = line[indent_len..].trim();

    if trimmed.is_empty()
        || trimmed.starts_with(';')
        || trimmed.starts_with('#')
        || trimmed.starts_with('[')
    {
        return None;
    }

    let (lhs, rhs) = trimmed.split_once('=')?;
    Some((lhs.trim(), rhs.trim(), indent))
}