wows_minimap_renderer 0.7.0

Library/CLI application for rendering World of Warships replay files as a minimap render "
Documentation
use clap::{App, Arg};
use rootcause::prelude::*;
use std::fs::File;
use std::path::Path;
use tracing::{info, warn};
use wowsunpack::data::Version;
use wowsunpack::game_data;
use wowsunpack::game_params::provider::GameMetadataProvider;

use wows_replays::ReplayFile;
use wows_replays::analyzer::Analyzer;
use wows_replays::analyzer::battle_controller::BattleController;
use wows_replays::game_constants::GameConstants;

use wows_minimap_renderer::assets::{
    load_consumable_icons, load_death_cause_icons, load_map_image, load_map_info, load_plane_icons,
    load_powerup_icons, load_ship_icons,
};
use wows_minimap_renderer::config::RendererConfig;
use wows_minimap_renderer::drawing::ImageTarget;
use wows_minimap_renderer::renderer::MinimapRenderer;
use wows_minimap_renderer::video::{DumpMode, VideoEncoder};

fn main() -> Result<(), Report> {
    let matches = App::new("Minimap Renderer")
        .about("Generates a minimap timelapse video from a WoWS replay")
        .arg(
            Arg::with_name("GAME_DIRECTORY")
                .help("Path to the World of Warships game directory")
                .short("g")
                .long("game")
                .takes_value(true)
                .required_unless_one(&["GENERATE_CONFIG", "CHECK_ENCODER"]),
        )
        .arg(
            Arg::with_name("OUTPUT")
                .help("Output MP4 file path")
                .short("o")
                .long("output")
                .takes_value(true)
                .required_unless_one(&["GENERATE_CONFIG", "CHECK_ENCODER"]),
        )
        .arg(
            Arg::with_name("DUMP_FRAME")
                .help("Dump a single frame as PNG instead of rendering video (specify frame number or 'mid' for midpoint)")
                .long("dump-frame")
                .takes_value(true),
        )
        .arg(
            Arg::with_name("NO_PLAYER_NAMES")
                .help("Hide player names above ship icons")
                .long("no-player-names"),
        )
        .arg(
            Arg::with_name("NO_SHIP_NAMES")
                .help("Hide ship names above ship icons")
                .long("no-ship-names"),
        )
        .arg(
            Arg::with_name("NO_CAPTURE_POINTS")
                .help("Hide capture point zones")
                .long("no-capture-points"),
        )
        .arg(
            Arg::with_name("NO_BUILDINGS")
                .help("Hide building markers")
                .long("no-buildings"),
        )
        .arg(
            Arg::with_name("NO_TURRET_DIRECTION")
                .help("Hide turret direction indicators")
                .long("no-turret-direction"),
        )
        .arg(
            Arg::with_name("SHOW_ARMAMENT")
                .help("Show selected armament/ammo type below ship icons")
                .long("show-armament"),
        )
        .arg(
            Arg::with_name("SHOW_TRAILS")
                .help("Show position trail heatmap (rainbow coloring)")
                .long("show-trails"),
        )
        .arg(
            Arg::with_name("NO_DEAD_TRAILS")
                .help("Hide trails for dead ships")
                .long("no-dead-trails"),
        )
        .arg(
            Arg::with_name("SHOW_SPEED_TRAILS")
                .help("Show speed-based trails (blue=slow, red=fast)")
                .long("show-speed-trails"),
        )
        .arg(
            Arg::with_name("SHOW_SHIP_CONFIG")
                .help("Show ship config range circles (detection, battery, etc.)")
                .long("show-ship-config"),
        )
        .arg(
            Arg::with_name("CONFIG")
                .help("Path to TOML config file")
                .long("config")
                .takes_value(true),
        )
        .arg(
            Arg::with_name("GENERATE_CONFIG")
                .help("Print default TOML config to stdout and exit")
                .long("generate-config"),
        )
        .arg(
            Arg::with_name("CHECK_ENCODER")
                .help("Check encoder availability (GPU/CPU) and exit")
                .long("check-encoder"),
        )
        .arg(
            Arg::with_name("CPU")
                .help("Use CPU encoder (openh264) instead of GPU")
                .long("cpu"),
        )
        .arg(
            Arg::with_name("REPLAY")
                .help("The replay file to process")
                .required_unless_one(&["GENERATE_CONFIG", "CHECK_ENCODER"])
                .index(1),
        )
        .get_matches();

    tracing_subscriber::fmt().with_target(false).init();

    // Handle --generate-config before anything else
    if matches.is_present("GENERATE_CONFIG") {
        print!("{}", RendererConfig::generate_default_toml());
        return Ok(());
    }

    // Handle --check-encoder
    if matches.is_present("CHECK_ENCODER") {
        let status = wows_minimap_renderer::check_encoder();
        print!("{status}");
        return Ok(());
    }

    let game_dir = matches.value_of("GAME_DIRECTORY").unwrap();
    let output = matches.value_of("OUTPUT").unwrap();
    let replay_path = matches.value_of("REPLAY").unwrap();

    let dump_mode = match matches.value_of("DUMP_FRAME") {
        Some("mid") => Some(DumpMode::Midpoint),
        Some("last") => Some(DumpMode::Last),
        Some(n) => Some(DumpMode::Frame(
            n.parse::<usize>().expect("invalid frame number"),
        )),
        None => None,
    };

    info!("Parsing replay");
    let replay_file = ReplayFile::from_file(&std::path::PathBuf::from(replay_path))?;
    let replay_version = Version::from_client_exe(&replay_file.meta.clientVersionFromExe);

    info!(build = %replay_version.build, "Loading game data");
    let wows_dir = Path::new(game_dir);
    let resources =
        game_data::load_game_resources(wows_dir, &replay_version).map_err(|e| report!("{e}"))?;
    let file_tree = &resources.file_tree;
    let pkg_loader = &resources.pkg_loader;
    let specs = &resources.specs;

    info!("Loading game params");
    let mut game_params = GameMetadataProvider::from_pkg(file_tree, pkg_loader)
        .map_err(|e| report!("Failed to load GameParams: {e:?}"))?;
    let controller_game_params = GameMetadataProvider::from_pkg(file_tree, pkg_loader)
        .map_err(|e| report!("Failed to load GameParams for controller: {e:?}"))?;

    // Load translations for ship name localization
    let mo_path = game_data::translations_path(wows_dir, replay_version.build);
    if mo_path.exists() {
        let catalog = gettext::Catalog::parse(File::open(&mo_path)?)
            .map_err(|e| report!("Failed to parse global.mo: {e:?}"))?;
        game_params.set_translations(catalog);
    } else {
        warn!(path = ?mo_path, "Translations not found, ship names will be unavailable");
    }

    info!("Loading icons");
    let ship_icons = load_ship_icons(file_tree, pkg_loader);
    let plane_icons = load_plane_icons(file_tree, pkg_loader);
    let consumable_icons = load_consumable_icons(file_tree, pkg_loader);
    let death_cause_icons = load_death_cause_icons(
        file_tree,
        pkg_loader,
        wows_minimap_renderer::assets::ICON_SIZE,
    );
    let powerup_icons = load_powerup_icons(
        file_tree,
        pkg_loader,
        wows_minimap_renderer::assets::ICON_SIZE,
    );

    // Load game constants from game data (falls back to hardcoded defaults per-field)
    let game_constants = GameConstants::from_pkg(file_tree, pkg_loader);

    if let Some(mode_name) = game_constants.game_mode_name(replay_file.meta.gameMode as i32) {
        info!(mode = %mode_name, id = replay_file.meta.gameMode, "Game mode");
    }

    // Load map image and metadata from game files
    let map_name = &replay_file.meta.mapName;
    let map_image = load_map_image(map_name, file_tree, pkg_loader);
    let map_info = load_map_info(map_name, file_tree, pkg_loader);

    let game_duration = replay_file.meta.duration as f32;

    let mut target = ImageTarget::new(
        map_image,
        ship_icons,
        plane_icons,
        consumable_icons,
        death_cause_icons,
        powerup_icons,
    );

    // Load config: --config path > exe-adjacent minimap_renderer.toml > defaults
    let mut config = if let Some(config_path) = matches.value_of("CONFIG") {
        RendererConfig::load(Path::new(config_path))?
    } else {
        // Try exe-adjacent config file
        let exe_config = std::env::current_exe()
            .ok()
            .and_then(|p| p.parent().map(|d| d.join("minimap_renderer.toml")));
        match exe_config {
            Some(path) if path.exists() => {
                info!(path = ?path, "Loading config");
                RendererConfig::load(&path)?
            }
            _ => RendererConfig::default(),
        }
    };
    config.apply_cli_overrides(&matches);
    let options = config.into_render_options();

    let mut renderer = MinimapRenderer::new(
        map_info.clone(),
        &game_params,
        replay_version.clone(),
        options,
    );
    let mut encoder = VideoEncoder::new(output, dump_mode, game_duration);
    if matches.is_present("CPU") {
        encoder.set_prefer_cpu(true);
    }

    let mut controller = BattleController::new(
        &replay_file.meta,
        &controller_game_params,
        Some(&game_constants),
    );

    let mut parser = wows_replays::packet2::Parser::new(specs);
    let mut remaining = &replay_file.packet_data[..];
    let mut prev_clock = wows_replays::types::GameClock(0.0);

    while !remaining.is_empty() {
        let (rest, packet) = parser
            .parse_packet(remaining)
            .map_err(|e| report!("Packet parse error: {e:?}"))?;
        remaining = rest;

        // Render when clock changes (all prev_clock packets have been processed)
        if packet.clock != prev_clock && prev_clock.seconds() > 0.0 {
            renderer.populate_players(&controller);
            renderer.update_squadron_info(&controller);
            renderer.update_ship_abilities(&controller);
            encoder.advance_clock(prev_clock, &controller, &mut renderer, &mut target);
            prev_clock = packet.clock;
        } else if prev_clock.seconds() == 0.0 {
            prev_clock = packet.clock;
        }

        // Process the packet to update state
        controller.process(&packet);
    }

    // Render final tick
    if prev_clock.seconds() > 0.0 {
        renderer.populate_players(&controller);
        renderer.update_squadron_info(&controller);
        renderer.update_ship_abilities(&controller);
        encoder.advance_clock(prev_clock, &controller, &mut renderer, &mut target);
    }

    controller.finish();
    encoder.finish(&controller, &mut renderer, &mut target)?;

    info!("Done");
    Ok(())
}