wowsunpack 0.41.0

Utility for interacting with World of Warships game assets
Documentation
//! Detects available game data builds and emits cfg flags for conditional test compilation.
//!
//! Emits:
//! - `has_game_data` — at least one build is available
//! - `has_build_NNNNN` — specific build number is available
//!
//! Tests can use:
//! ```ignore
//! #[test]
//! #[cfg_attr(not(has_game_data), ignore)]
//! fn test_needs_game_data() { ... }
//! ```

use serde::Deserialize;
use std::collections::BTreeMap;
use std::fmt::Write as _;
use std::path::Path;
use std::path::PathBuf;

#[derive(Deserialize)]
struct ConsumableLayoutsFile {
    #[serde(default)]
    layout: Vec<ConsumableLayoutEntry>,
}

#[derive(Deserialize)]
struct ConsumableLayoutEntry {
    /// Friendly base version, e.g. "0.7.0".
    version: String,
    /// `id -> internal name`. TOML keys are strings, so the ids are parsed below.
    #[serde(default)]
    ids: BTreeMap<String, String>,
}

/// Read `consumable_layouts.toml` and generate the `CONSUMABLE_ID_LAYOUTS` table
/// and `consumable_ids_for_version` lookup into `$OUT_DIR/consumable_versions.rs`,
/// which `src/consumable_versions.rs` includes.
fn generate_consumable_versions() {
    let manifest_dir = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR"));
    let toml_path = manifest_dir.join("consumable_layouts.toml");
    println!("cargo:rerun-if-changed={}", toml_path.display());

    let data = std::fs::read_to_string(&toml_path).expect("read consumable_layouts.toml");
    let parsed: ConsumableLayoutsFile = toml::from_str(&data).expect("parse consumable_layouts.toml");

    // Parse versions and ids, sorting layouts by base version and ids numerically.
    type ParsedLayout = ((u32, u32, u32), Vec<(i32, String)>);
    let mut layouts: Vec<ParsedLayout> = parsed
        .layout
        .into_iter()
        .map(|l| {
            let mut parts = l.version.split('.').map(|p| p.parse::<u32>().expect("version component"));
            let version = (parts.next().expect("major"), parts.next().unwrap_or(0), parts.next().unwrap_or(0));
            let mut ids: Vec<(i32, String)> =
                l.ids.into_iter().map(|(k, name)| (k.parse::<i32>().expect("consumable id key"), name)).collect();
            ids.sort_by_key(|(id, _)| *id);
            (version, ids)
        })
        .collect();
    layouts.sort_by_key(|(version, _)| *version);

    let mut out = String::new();
    out.push_str("// @generated by build.rs from consumable_layouts.toml -- do not edit.\n\n");
    out.push_str("/// A single consumable `id -> internal name` entry.\n");
    out.push_str("type ConsumableEntry = (i32, &'static str);\n");
    out.push_str("/// One layout: the base version it takes effect at, plus its full id -> name table.\n");
    out.push_str("type ConsumableLayout = (Version, &'static [ConsumableEntry]);\n\n");
    out.push_str("/// Consumable id -> name layouts, ascending by base version. Each table is the full\n");
    out.push_str("/// id -> name map effective from that version until the next supersedes it.\n");
    out.push_str("pub static CONSUMABLE_ID_LAYOUTS: &[ConsumableLayout] = &[\n");
    for ((major, minor, patch), ids) in &layouts {
        let entries: Vec<String> = ids.iter().map(|(id, name)| format!("({id}, {name:?})")).collect();
        writeln!(out, "    (Version::base({major}, {minor}, {patch}), &[{}]),", entries.join(", "))
            .expect("write layout");
    }
    out.push_str("];\n\n");
    out.push_str("/// The consumable id -> name table effective for `version`: the latest layout whose\n");
    out.push_str("/// base version is at most `version`. Versions older than every known layout floor\n");
    out.push_str("/// to the earliest one (the closest available approximation).\n");
    out.push_str("pub fn consumable_ids_for_version(version: Version) -> Option<&'static [ConsumableEntry]> {\n");
    out.push_str("    CONSUMABLE_ID_LAYOUTS\n");
    out.push_str("        .iter()\n");
    out.push_str("        .rev()\n");
    out.push_str("        .find(|(start, _)| version.is_at_least(start))\n");
    out.push_str("        .or_else(|| CONSUMABLE_ID_LAYOUTS.first())\n");
    out.push_str("        .map(|(_, table)| *table)\n");
    out.push_str("}\n");

    let out_dir = PathBuf::from(std::env::var("OUT_DIR").expect("OUT_DIR"));
    std::fs::write(out_dir.join("consumable_versions.rs"), out).expect("write generated consumable_versions.rs");
}

#[derive(Deserialize, Default)]
struct Registry {
    latest_path: Option<PathBuf>,
    #[serde(default)]
    builds: BTreeMap<String, RegistryEntry>,
}

#[derive(Deserialize)]
struct RegistryEntry {
    #[allow(dead_code)]
    version: String,
}

fn find_workspace_root() -> Option<PathBuf> {
    let manifest_dir = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").ok()?);
    let mut dir = manifest_dir.as_path();
    loop {
        if dir.join("game_versions.toml").exists() {
            return Some(dir.to_path_buf());
        }
        dir = dir.parent()?;
    }
}

fn scan_bin_dir(path: &Path) -> Vec<u32> {
    let bin_dir = path.join("bin");
    let Ok(entries) = std::fs::read_dir(&bin_dir) else {
        return Vec::new();
    };
    entries
        .filter_map(|e| e.ok())
        .filter(|e| e.file_type().map(|t| t.is_dir()).unwrap_or(false))
        .filter_map(|e| e.file_name().to_str().and_then(|s| s.parse::<u32>().ok()))
        .collect()
}

fn discover_builds(workspace_root: &Path) -> Vec<u32> {
    let data_dir = match std::env::var("WOWS_GAME_DATA") {
        Ok(d) => PathBuf::from(d),
        Err(_) => workspace_root.join("game_data"),
    };

    let registry_path = data_dir.join("versions.toml");
    let registry: Registry =
        std::fs::read_to_string(&registry_path).ok().and_then(|s| toml::from_str(&s).ok()).unwrap_or_default();

    let mut builds: Vec<u32> = Vec::new();

    // Builds from registry
    for key in registry.builds.keys() {
        if let Ok(build) = key.parse::<u32>() {
            // For downloaded builds, verify the directory exists
            let build_dir = data_dir.join("builds").join(key);
            if build_dir.exists() {
                builds.push(build);
            }
        }
    }

    // Builds from latest_path
    if let Some(ref latest) = registry.latest_path {
        for build in scan_bin_dir(latest) {
            if !builds.contains(&build) {
                builds.push(build);
            }
        }
    }

    // Also scan game_data/builds/ for any unregistered builds
    let builds_dir = data_dir.join("builds");
    if builds_dir.exists()
        && let Ok(entries) = std::fs::read_dir(&builds_dir)
    {
        for entry in entries.filter_map(|e| e.ok()) {
            if entry.file_type().map(|t| t.is_dir()).unwrap_or(false)
                && let Some(build) = entry.file_name().to_str().and_then(|s| s.parse::<u32>().ok())
                && !builds.contains(&build)
            {
                builds.push(build);
            }
        }
    }

    builds.sort();
    builds
}

/// Build numbers referenced by tests that may not be locally available.
/// Declared here so check-cfg doesn't warn about unknown cfgs.
const KNOWN_TEST_BUILDS: &[u32] = &[
    6965290,  // v12.3.1 (S-189 submarine replay)
    9531281,  // v14.1.0 (Hull DD replay)
    11965230, // v15.1.0 (Vermont, Marceau, Narai replays)
];

fn main() {
    // Generate the consumable id -> name lookup table from the checked-in TOML.
    generate_consumable_versions();

    // Declare all possible cfgs to satisfy check-cfg
    println!("cargo:rustc-check-cfg=cfg(has_game_data)");

    // Pre-declare check-cfg for all known test builds
    for &build in KNOWN_TEST_BUILDS {
        println!("cargo:rustc-check-cfg=cfg(has_build_{build})");
    }

    let Some(workspace_root) = find_workspace_root() else {
        return;
    };

    let builds = discover_builds(&workspace_root);

    for &build in &builds {
        // Declare check-cfg for any discovered build not in the known list
        if !KNOWN_TEST_BUILDS.contains(&build) {
            println!("cargo:rustc-check-cfg=cfg(has_build_{build})");
        }
        println!("cargo:rustc-cfg=has_build_{build}");
    }

    if !builds.is_empty() {
        println!("cargo:rustc-cfg=has_game_data");
    }

    // Re-run if registry changes
    let data_dir = match std::env::var("WOWS_GAME_DATA") {
        Ok(d) => PathBuf::from(d),
        Err(_) => workspace_root.join("game_data"),
    };
    println!("cargo:rerun-if-changed={}", data_dir.join("versions.toml").display());
    println!("cargo:rerun-if-env-changed=WOWS_GAME_DATA");
}