redguard-preservation 0.6.0

A CLI tool to preserve Redguard files
Documentation
use crate::cli::convert::{load_palette, resolve_asset_root};
use crate::opts::WldArgs;
use color_eyre::Result;
use log::{info, warn};
use rgpre::gltf::{TextureCache, convert_wld_scene_to_gltf, to_glb};
use rgpre::import::{FileType, palette::Palette, registry, wld};
use std::path::Path;

const ENGINE_TERRAIN_TEXBSI_ID: u16 = 302;

#[allow(clippy::large_stack_frames)]
fn handle_wld_glb_convert(args: &WldArgs, output_path: &Path) -> Result<()> {
    let bytes = std::fs::read(&args.io.file)?;
    let wld_file = wld::parse_wld_file(&bytes).map_err(|e| color_eyre::eyre::eyre!("{e}"))?;

    let declared_texbsi_id = if wld_file.sections[0].header.len() >= 8 {
        Some(u16::from_le_bytes([
            wld_file.sections[0].header[6],
            wld_file.sections[0].header[7],
        ]))
    } else {
        None
    };

    if let Some(declared) = declared_texbsi_id
        && declared != ENGINE_TERRAIN_TEXBSI_ID
    {
        warn!(
            "WLD section header declares TEXBSI.{declared:03}, but original engine terrain path hard-wires TEXBSI.{ENGINE_TERRAIN_TEXBSI_ID:03}; using engine behavior"
        );
    }

    let texbsi_id = ENGINE_TERRAIN_TEXBSI_ID;

    let asset_root = resolve_asset_root(args.assets.as_deref(), &args.io.file);

    let palette = load_palette(
        args.palette.as_deref(),
        &asset_root,
        &args.io.file,
        FileType::Wld,
    )?;

    let mut texture_cache = if args.terrain_textures {
        Some(TextureCache::new(
            asset_root.clone(),
            palette.as_ref().map(|pal| Palette { colors: pal.colors }),
        ))
    } else {
        info!("Terrain textures disabled (--terrain-textures false)");
        None
    };

    let rgm_upper = args.io.file.with_extension("RGM");
    let rgm_lower = args.io.file.with_extension("rgm");
    let rgm_path = if args.terrain_only {
        None
    } else if rgm_upper.is_file() {
        Some(rgm_upper)
    } else if rgm_lower.is_file() {
        Some(rgm_lower)
    } else {
        None
    };

    let positioned_models = if let Some(rgm_file) = rgm_path {
        let registry = registry::scan_dir(asset_root)?;
        let rgm_bytes = std::fs::read(&rgm_file)?;
        let preferred_rob_stem = rgm_file.file_stem().and_then(|s| s.to_str());
        let (rgm_parsed, models, _lights) = rgpre::import::rgm::parse_rgm_with_models_for_stem(
            &rgm_bytes,
            &registry,
            preferred_rob_stem,
        )
        .map_err(|e| color_eyre::eyre::eyre!("{e}"))?;
        info!(
            "Loaded {} positioned models from companion scene '{}'",
            models.len(),
            rgm_file.display()
        );

        let json_path = output_path.with_extension("json");
        let metadata = rgpre::import::rgm::export_rgm_metadata_json(&rgm_parsed, None);
        let json_bytes = serde_json::to_string_pretty(&metadata)?;
        super::ensure_parent_dir(&json_path)?;
        std::fs::write(&json_path, &json_bytes)?;
        info!("Actor metadata exported to: {}", json_path.display());

        models
    } else if args.terrain_only {
        info!("Terrain-only mode enabled (--terrain-only)");
        Vec::new()
    } else {
        warn!("No companion RGM found for WLD; exporting terrain only");
        Vec::new()
    };

    let (root, buffer) = convert_wld_scene_to_gltf(
        &wld_file,
        texbsi_id,
        &positioned_models,
        palette.as_ref(),
        texture_cache.as_mut(),
        args.compress_textures,
    )
    .map_err(|e| color_eyre::eyre::eyre!("{e}"))?;

    let glb = to_glb(&root, &buffer).map_err(|e| color_eyre::eyre::eyre!("{e}"))?;
    super::ensure_parent_dir(output_path)?;
    std::fs::write(output_path, glb)?;
    info!(
        "Successfully exported WLD scene GLB: {}",
        output_path.display()
    );

    Ok(())
}

fn handle_wld_png_convert(args: &WldArgs, output_path: &Path) -> Result<()> {
    let out_ext = output_path
        .extension()
        .and_then(|e| e.to_str())
        .map(str::to_ascii_lowercase);

    if !matches!(out_ext.as_deref(), Some("png")) {
        warn!("WLD map export writes PNG unless output is .glb; overriding extension to .png");
    }

    let png_output = if matches!(out_ext.as_deref(), Some("png")) {
        output_path.to_path_buf()
    } else {
        output_path.with_extension("png")
    };

    let outputs = wld::export_wld_maps_pngs(&args.io.file, &png_output, args.compress_textures)
        .map_err(|e| color_eyre::eyre::eyre!("{e}"))?;
    info!("Successfully exported WLD map PNGs:");
    info!("  map1 (height): {}", outputs.map1_path.display());
    info!("  map2: {}", outputs.map2_path.display());
    info!("  map3: {}", outputs.map3_path.display());
    info!("  map4: {}", outputs.map4_path.display());
    Ok(())
}

pub(crate) fn handle_wld_convert(args: &WldArgs, output_path: &Path) -> Result<()> {
    let out_ext = output_path
        .extension()
        .and_then(|e| e.to_str())
        .map(str::to_ascii_lowercase);

    if matches!(out_ext.as_deref(), Some("glb")) {
        return handle_wld_glb_convert(args, output_path);
    }

    handle_wld_png_convert(args, output_path)
}