use std::{
fs,
path::{Path, PathBuf},
};
use chrono::Local;
use image::ImageFormat;
use serde::Serialize;
use crate::{
console::Console,
context::AppContext,
error::{DoomError, Result},
repo_state::{build_repo_state, RepoStateOptions},
sets::find_set_tokens,
tools::discover_idstudio_root,
};
#[derive(Debug, Clone)]
pub struct StageIdStudioRequest {
pub output_mod_root: Option<PathBuf>,
pub stage_root: Option<PathBuf>,
pub idstudio_root: Option<PathBuf>,
pub slug: Option<String>,
pub dry_run: bool,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct IdStudioStageManifest {
slug: String,
source_output_root: String,
stage_root: String,
base_root: String,
editable_texture_count: usize,
generated_file_count: usize,
created_at: String,
}
pub fn stage_idstudio_mod(ctx: &AppContext, request: StageIdStudioRequest) -> Result<PathBuf> {
let state = build_repo_state(
ctx,
RepoStateOptions {
output_mod_root: request.output_mod_root.clone(),
..RepoStateOptions::default()
},
);
let output_mod_root = request
.output_mod_root
.clone()
.unwrap_or_else(|| state.output_mod_root.clone());
let resolved_output_root = ctx.repo().repo_path(&output_mod_root);
let stage_root = resolve_stage_root(ctx, &request, &resolved_output_root);
stage_output_tree(
ctx.console(),
&resolved_output_root,
&stage_root,
request.dry_run,
)?;
Ok(stage_root)
}
fn resolve_stage_root(
ctx: &AppContext,
request: &StageIdStudioRequest,
output_root: &Path,
) -> PathBuf {
if let Some(stage_root) = request.stage_root.as_deref() {
return ctx.repo().repo_path(stage_root);
}
let stage_parent = request
.idstudio_root
.as_deref()
.map(|path| ctx.repo().repo_path(path))
.or_else(|| discover_idstudio_root(ctx.repo()))
.unwrap_or_else(|| ctx.repo().root().join("build").join("idstudio"));
let slug = request
.slug
.clone()
.unwrap_or_else(|| infer_slug(output_root));
stage_parent.join(slug)
}
fn infer_slug(output_root: &Path) -> String {
output_root
.file_name()
.map(|value| value.to_string_lossy().to_string())
.filter(|value| !value.trim().is_empty())
.unwrap_or_else(|| "xylex-rtx-slayer-pack".to_string())
}
fn stage_output_tree(
console: &Console,
output_root: &Path,
stage_root: &Path,
dry_run: bool,
) -> Result<()> {
let editable_root = output_root.join("editable");
if !editable_root.is_dir() {
return Err(DoomError::message(format!(
"Missing crafted editable output: {}. Run craft before staging for idStudio.",
console.shorten_path(&editable_root)
)));
}
let warehouse_root = output_root.join("warehouse");
if !warehouse_root.is_dir() {
return Err(DoomError::message(format!(
"Missing crafted warehouse output: {}. Run craft before staging for idStudio.",
console.shorten_path(&warehouse_root)
)));
}
let base_root = stage_root.join("base");
console.log_info(format!(
"Staging {} base folder: {}",
console.format_tool_name("idStudio"),
console.format_path(&base_root)
));
if dry_run {
console.log_dry_run(format!(
"reset staged base folder {}",
console.format_path(&base_root)
));
} else {
if base_root.exists() {
fs::remove_dir_all(&base_root)?;
}
fs::create_dir_all(&base_root)?;
}
let editable_sets = detect_editable_sets(&editable_root);
let generated_source = warehouse_root.join("generated");
let generated_dest = base_root.join("generated");
let generated_file_count = if generated_source.is_dir() {
copy_tree(
console,
&generated_source,
&generated_dest,
&editable_sets,
dry_run,
)?
} else {
0
};
let editable_texture_count =
stage_editable_textures(console, &editable_root, &base_root, dry_run)?;
if editable_texture_count == 0 {
return Err(DoomError::message(format!(
"No staged editable textures were found under {}.",
console.shorten_path(&editable_root)
)));
}
let manifest = IdStudioStageManifest {
slug: infer_slug(stage_root),
source_output_root: output_root.display().to_string(),
stage_root: stage_root.display().to_string(),
base_root: base_root.display().to_string(),
editable_texture_count,
generated_file_count,
created_at: Local::now().to_rfc3339(),
};
let manifest_path = stage_root.join("xylex-idstudio-stage.json");
if dry_run {
console.log_dry_run(format!(
"write stage manifest {}",
console.format_path(&manifest_path)
));
} else {
fs::create_dir_all(stage_root)?;
fs::write(&manifest_path, serde_json::to_string_pretty(&manifest)?)?;
}
console.log_success(format!(
"Staged {} editable texture(s) and {} generated file(s) into {}",
editable_texture_count,
generated_file_count,
console.format_path(&base_root)
));
console.log_info(
"Next step: open idStudio, package from this base folder, then publish through My Creations in the DOOM Eternal launcher.",
);
Ok(())
}
fn copy_tree(
console: &Console,
source_dir: &Path,
destination_dir: &Path,
editable_sets: &[String],
dry_run: bool,
) -> Result<usize> {
let mut copied = 0_usize;
for entry in walkdir::WalkDir::new(source_dir)
.into_iter()
.filter_map(|entry| entry.ok())
{
if entry.file_type().is_dir() || !is_stageable_generated_file(entry.path(), editable_sets) {
continue;
}
let relative = entry.path().strip_prefix(source_dir)?;
let destination = destination_dir.join(relative);
if dry_run {
console.log_dry_run(format!(
"copy {} -> {}",
console.format_path(entry.path()),
console.format_path(&destination)
));
} else {
if let Some(parent) = destination.parent() {
fs::create_dir_all(parent)?;
}
fs::copy(entry.path(), &destination)?;
}
copied += 1;
}
Ok(copied)
}
fn detect_editable_sets(editable_root: &Path) -> Vec<String> {
let mut sets = std::collections::BTreeSet::new();
for entry in walkdir::WalkDir::new(editable_root)
.into_iter()
.filter_map(|entry| entry.ok())
{
if !entry.file_type().is_file() || !is_editable_image(entry.path()) {
continue;
}
let path_text = entry.path().to_string_lossy();
for set_name in find_set_tokens(&path_text) {
sets.insert(set_name);
}
}
sets.into_iter().collect()
}
fn stage_editable_textures(
console: &Console,
editable_root: &Path,
base_root: &Path,
dry_run: bool,
) -> Result<usize> {
let mut staged = 0_usize;
for entry in walkdir::WalkDir::new(editable_root)
.into_iter()
.filter_map(|entry| entry.ok())
{
if !entry.file_type().is_file() || !is_editable_image(entry.path()) {
continue;
}
let relative = entry.path().strip_prefix(editable_root)?;
let destination = base_root.join(relative).with_extension("tga");
if dry_run {
console.log_dry_run(format!(
"convert {} -> {}",
console.format_path(entry.path()),
console.format_path(&destination)
));
} else {
if let Some(parent) = destination.parent() {
fs::create_dir_all(parent)?;
}
let image = image::open(entry.path())?;
image.save_with_format(&destination, ImageFormat::Tga)?;
}
staged += 1;
}
Ok(staged)
}
fn is_editable_image(path: &Path) -> bool {
path.extension()
.map(|value| value.to_string_lossy().to_ascii_lowercase())
.map(|extension| {
matches!(
extension.as_str(),
"png" | "tga" | "dds" | "tif" | "tiff" | "bmp"
)
})
.unwrap_or(false)
}
fn is_stageable_generated_file(path: &Path, editable_sets: &[String]) -> bool {
let extension_allowed = path
.extension()
.map(|value| value.to_string_lossy().to_ascii_lowercase())
.map(|extension| extension != "code-workspace")
.unwrap_or(true);
if !extension_allowed {
return false;
}
let set_tokens = find_set_tokens(path.to_string_lossy());
if set_tokens.is_empty() {
return true;
}
set_tokens
.iter()
.any(|set_name| editable_sets.contains(set_name))
}
#[cfg(test)]
mod tests {
use std::fs;
use image::{ImageBuffer, Rgba};
use tempfile::TempDir;
use crate::{console::Console, error::Result};
use super::stage_output_tree;
#[test]
fn stages_editable_textures_and_generated_files() -> Result<()> {
let temp_dir = TempDir::new().expect("tempdir");
let output_root = temp_dir.path().join("dist").join("xylex-rtx-slayer-pack");
let editable_file = output_root
.join("editable")
.join("models")
.join("customization")
.join("characters")
.join("doomslayer")
.join("set16")
.join("doomslayer_game_torso_set16.png");
let generated_file = output_root
.join("warehouse")
.join("generated")
.join("decls")
.join("material2")
.join("sample.decl");
fs::create_dir_all(
editable_file
.parent()
.expect("editable file should have parent"),
)
.expect("editable parent");
fs::create_dir_all(
generated_file
.parent()
.expect("generated file should have parent"),
)
.expect("generated parent");
let image = ImageBuffer::<Rgba<u8>, Vec<u8>>::from_pixel(2, 2, Rgba([255, 0, 0, 255]));
image.save(&editable_file).expect("png save");
fs::write(&generated_file, "declType( material2 ) {}\n").expect("decl");
let console = Console::new(temp_dir.path().to_path_buf());
let stage_root = temp_dir
.path()
.join("id Software")
.join("idStudio")
.join("xylex");
stage_output_tree(&console, &output_root, &stage_root, false)?;
assert!(stage_root
.join("base")
.join("models")
.join("customization")
.join("characters")
.join("doomslayer")
.join("set16")
.join("doomslayer_game_torso_set16.tga")
.is_file());
assert!(stage_root
.join("base")
.join("generated")
.join("decls")
.join("material2")
.join("sample.decl")
.is_file());
assert!(stage_root.join("xylex-idstudio-stage.json").is_file());
Ok(())
}
}