use std::{
fs,
io::Write,
path::{Path, PathBuf},
process::Command,
};
use image::DynamicImage;
use crate::{
bim::{encode_image_to_bim, load_editable_image, resolve_autoheckin_converter, supports_builtin_decode},
context::AppContext,
error::{DoomError, Result},
logos::ensure_default_logo_assets,
manifest::{RequiredEditableManifest, RequiredExport},
placements::{Placement, load_config, composite_logo},
sets::{normalize_set_name, replace_set_tokens},
source_pack::{detect_source_set, resolve_source_warehouse},
};
#[derive(Debug, Clone)]
pub struct CraftRequest {
pub source_path: PathBuf,
pub output_mod_root: PathBuf,
pub placement_config: PathBuf,
pub logo_dir: PathBuf,
pub editable_root: PathBuf,
pub editable_extension: String,
pub target_set: Option<String>,
pub decode_command_template: String,
pub encode_command_template: String,
pub converter_path: Option<PathBuf>,
pub disable_builtin_decode: bool,
pub disable_builtin_encode: bool,
pub zip_output: Option<PathBuf>,
pub dry_run: bool,
}
#[derive(Debug, Clone)]
struct TargetJob {
target_name: String,
relative_texture_path: PathBuf,
source_bim_file: PathBuf,
editable_source_file: Option<PathBuf>,
target_placements: Vec<Placement>,
}
pub fn apply_rtx_logo_pipeline(ctx: &AppContext, request: CraftRequest) -> Result<()> {
let (source_warehouse, source_parent) = resolve_source_warehouse(ctx.repo(), &request.source_path)?;
let output_root = ctx.repo().repo_path(&request.output_mod_root);
let output_warehouse = output_root.join("warehouse");
let editable_output_root = output_root.join("editable");
let editable_source_root = ctx.repo().repo_path(&request.editable_root);
let placement_path = ctx.repo().repo_path(&request.placement_config);
let prepared_logo_dir = ctx.repo().repo_path(&request.logo_dir);
ensure_default_logo_assets(ctx, &prepared_logo_dir)?;
let placement_data = load_config(ctx, &placement_path)?;
let source_set_name = detect_source_set(&source_warehouse)?;
let target_set_name = request
.target_set
.as_deref()
.and_then(normalize_set_name)
.unwrap_or_else(|| source_set_name.clone());
let decode_command_template =
validate_command_template(&request.decode_command_template, "--decode-command-template")?;
let encode_command_template =
validate_command_template(&request.encode_command_template, "--encode-command-template")?;
let converter = if request.disable_builtin_encode || !encode_command_template.is_empty() {
None
} else {
Some(resolve_autoheckin_converter(
ctx.repo(),
request.converter_path.as_deref(),
)?)
};
println!("Detected Slayer source set: {source_set_name}");
if target_set_name == source_set_name {
println!("Using target set: {target_set_name}");
} else {
println!("Using target set override: {target_set_name}");
}
let normalized_extension = normalize_editable_extension(&request.editable_extension);
let mut target_jobs = Vec::new();
let mut missing_editable_exports = Vec::new();
for (target_name, relative_template) in &placement_data.targets {
let source_relative_texture_path = render_relative_path(relative_template, &source_set_name);
let target_relative_texture_path = render_relative_path(relative_template, &target_set_name);
let source_bim_file = source_warehouse.join(&source_relative_texture_path);
if !source_bim_file.exists() {
println!("Skipping {target_name}: missing {}", source_bim_file.display());
continue;
}
let target_placements = placement_data
.placements
.iter()
.filter(|placement| placement.target == *target_name && placement.is_enabled())
.cloned()
.collect::<Vec<_>>();
if target_placements.is_empty() {
target_jobs.push(TargetJob {
target_name: target_name.clone(),
relative_texture_path: target_relative_texture_path,
source_bim_file,
editable_source_file: None,
target_placements,
});
continue;
}
let editable_source_file =
infer_editable_path(&editable_source_root, &target_relative_texture_path, &normalized_extension);
if !editable_source_file.exists() && decode_command_template.is_empty() {
let builtin_decode_available =
!request.disable_builtin_decode && supports_builtin_decode(&source_bim_file);
if !builtin_decode_available && !request.dry_run {
missing_editable_exports.push(RequiredExport {
target: target_name.clone(),
relative_texture: target_relative_texture_path.to_string_lossy().to_string(),
source_texture: source_bim_file.display().to_string(),
editable_texture: editable_source_file.display().to_string(),
});
}
}
target_jobs.push(TargetJob {
target_name: target_name.clone(),
relative_texture_path: target_relative_texture_path,
source_bim_file,
editable_source_file: Some(editable_source_file),
target_placements,
});
}
if !missing_editable_exports.is_empty() {
raise_missing_editables(
&editable_source_root.join("required-editable-textures.json"),
&source_set_name,
&target_set_name,
&missing_editable_exports,
)?;
}
if !request.dry_run {
assert_under_repo(ctx, &output_root)?;
if output_root.exists() {
fs::remove_dir_all(&output_root)?;
}
fs::create_dir_all(&output_root)?;
}
copy_tree(&source_warehouse, &output_warehouse, request.dry_run)?;
stage_target_set_assets(
&source_warehouse,
&output_warehouse,
&source_set_name,
&target_set_name,
request.dry_run,
)?;
let source_eternal_mod = source_parent.join("EternalMod.json");
let fallback_mod = ctx.repo().root().join("mod").join("EternalMod.json");
let mod_source = if source_eternal_mod.exists() {
source_eternal_mod
} else {
fallback_mod
};
if request.dry_run {
println!(
"[dry-run] copy {} -> {}",
mod_source.display(),
output_root.join("EternalMod.json").display()
);
} else {
fs::copy(mod_source, output_root.join("EternalMod.json"))?;
}
for job in target_jobs {
if job.target_placements.is_empty() {
println!("No enabled placements for {}; leaving as-is.", job.target_name);
continue;
}
let editable_source_file = job
.editable_source_file
.clone()
.ok_or_else(|| DoomError::message(format!("Missing editable source mapping for {}", job.target_name)))?;
if !editable_source_file.exists() {
if decode_command_template.is_empty() {
if request.disable_builtin_decode || !supports_builtin_decode(&job.source_bim_file) {
if request.dry_run {
println!("[dry-run] would require editable export: {}", editable_source_file.display());
continue;
}
raise_missing_editables(
&editable_source_root.join("required-editable-textures.json"),
&source_set_name,
&target_set_name,
&[RequiredExport {
target: job.target_name.clone(),
relative_texture: job.relative_texture_path.to_string_lossy().to_string(),
source_texture: job.source_bim_file.display().to_string(),
editable_texture: editable_source_file.display().to_string(),
}],
)?;
}
crate::bim::decode_bim_to_path(&job.source_bim_file, &editable_source_file, request.dry_run)?;
} else {
if let Some(parent) = editable_source_file.parent() {
fs::create_dir_all(parent)?;
}
run_template_command(
&decode_command_template,
&job.source_bim_file,
&editable_source_file,
request.dry_run,
)?;
if !request.dry_run && !editable_source_file.exists() {
return Err(DoomError::message(format!(
"Decode command completed but editable file was not found: {}",
editable_source_file.display()
)));
}
}
}
if request.dry_run {
println!(
"[dry-run] would composite {} logo(s) on {}",
job.target_placements.len(),
editable_source_file.display()
);
continue;
}
let mut composed = load_editable_image(&editable_source_file)?;
for placement in &job.target_placements {
let logo_path = prepared_logo_dir.join(&placement.logo);
if !logo_path.exists() {
return Err(DoomError::message(format!(
"Missing prepared logo: {}",
logo_path.display()
)));
}
let logo = image::open(&logo_path)?.to_rgba8();
composite_logo(&mut composed, &logo, placement);
println!("Applied {} to {}", placement.id, job.target_name);
}
let edited_texture_file = editable_output_root
.join(&job.relative_texture_path)
.with_extension(normalized_extension.trim_start_matches('.'));
if let Some(parent) = edited_texture_file.parent() {
fs::create_dir_all(parent)?;
}
DynamicImage::ImageRgba8(composed).save(&edited_texture_file)?;
println!("Wrote edited texture: {}", edited_texture_file.display());
let output_bim_path = output_warehouse.join(&job.relative_texture_path);
if !encode_command_template.is_empty() {
run_template_command(
&encode_command_template,
&edited_texture_file,
&output_bim_path,
request.dry_run,
)?;
} else if !request.disable_builtin_encode {
encode_image_to_bim(
ctx.repo(),
&edited_texture_file,
&job.source_bim_file,
&output_bim_path,
converter.as_deref(),
request.dry_run,
)?;
} else {
println!(
"No encode command provided. Keep this edited texture for manual conversion: {}",
edited_texture_file.display()
);
}
}
if let Some(zip_output) = request.zip_output.as_deref() {
let zip_file = ctx.repo().repo_path(zip_output);
if request.dry_run {
println!("[dry-run] zip {} -> {}", output_root.display(), zip_file.display());
} else {
zip_directory(&output_root, &zip_file)?;
println!("Wrote {}", zip_file.display());
}
}
Ok(())
}
fn render_relative_path(template: &str, set_name: &str) -> PathBuf {
PathBuf::from(template.replace("{set}", set_name))
}
fn infer_editable_path(editable_root: &Path, relative_texture_path: &Path, editable_extension: &str) -> PathBuf {
let direct_path = editable_root
.join(relative_texture_path)
.with_extension(editable_extension.trim_start_matches('.'));
if direct_path.exists() {
return direct_path;
}
let search_dir = editable_root.join(relative_texture_path.parent().unwrap_or_else(|| Path::new("")));
if search_dir.is_dir() {
let stem = relative_texture_path
.file_stem()
.map(|value| value.to_string_lossy().to_string())
.unwrap_or_default();
let mut matches = fs::read_dir(search_dir)
.ok()
.into_iter()
.flat_map(|entries| entries.filter_map(|entry| entry.ok()))
.map(|entry| entry.path())
.filter(|path| path.is_file())
.filter(|path| {
path.file_stem()
.map(|value| value.to_string_lossy() == stem)
.unwrap_or(false)
})
.collect::<Vec<_>>();
matches.sort();
if let Some(first) = matches.into_iter().next() {
return first;
}
}
direct_path
}
fn run_template_command(command_template: &str, source_file: &Path, destination_file: &Path, dry_run: bool) -> Result<()> {
let input = source_file.display().to_string();
let output = destination_file.display().to_string();
let command = command_template
.replace("{input}", &input)
.replace("{output}", &output)
.replace("{input_dir}", &source_file.parent().unwrap_or_else(|| Path::new("")).display().to_string())
.replace("{output_dir}", &destination_file.parent().unwrap_or_else(|| Path::new("")).display().to_string())
.replace("{input_stem}", &source_file.file_stem().map(|value| value.to_string_lossy()).unwrap_or_default())
.replace("{output_stem}", &destination_file.file_stem().map(|value| value.to_string_lossy()).unwrap_or_default());
println!("Running command: {command}");
if dry_run {
return Ok(());
}
let status = if cfg!(windows) {
Command::new("cmd").args(["/C", &command]).status()?
} else {
Command::new("sh").args(["-lc", &command]).status()?
};
if !status.success() {
return Err(DoomError::message(format!(
"Command failed ({}): {command}",
status.code().unwrap_or(-1)
)));
}
Ok(())
}
fn validate_command_template(command_template: &str, flag_name: &str) -> Result<String> {
let normalized = command_template.trim();
if normalized.is_empty() {
return Ok(String::new());
}
let uppercase = normalized.to_ascii_uppercase();
if uppercase.contains("YOUR_DECODE_TOOL") || uppercase.contains("YOUR_ENCODE_TOOL") {
let next_step = if flag_name == "--decode-command-template" {
"Omit the flag to generate required-editable-textures.json first."
} else {
"Leave the flag empty to keep manual editable outputs, or replace it with a real encoder command."
};
return Err(DoomError::message(format!(
"Refusing placeholder value for {flag_name}: {normalized}\n{next_step}"
)));
}
Ok(normalized.to_string())
}
fn copy_tree(source_dir: &Path, destination_dir: &Path, dry_run: bool) -> Result<()> {
if dry_run {
println!("[dry-run] copy {} -> {}", source_dir.display(), destination_dir.display());
return Ok(());
}
if destination_dir.exists() {
fs::remove_dir_all(destination_dir)?;
}
for entry in walkdir::WalkDir::new(source_dir).into_iter().filter_map(|entry| entry.ok()) {
let relative = entry.path().strip_prefix(source_dir)?;
let destination = destination_dir.join(relative);
if entry.file_type().is_dir() {
fs::create_dir_all(&destination)?;
} else {
if let Some(parent) = destination.parent() {
fs::create_dir_all(parent)?;
}
fs::copy(entry.path(), &destination)?;
}
}
Ok(())
}
fn stage_target_set_assets(
source_warehouse: &Path,
output_warehouse: &Path,
source_set: &str,
target_set: &str,
dry_run: bool,
) -> Result<()> {
if source_set == target_set {
return Ok(());
}
println!("Staging output assets from {source_set} to target set {target_set}");
let character_root = PathBuf::from("models").join("customization").join("characters");
for character in ["doomslayer", "doomslayer_1p"] {
let source_dir = source_warehouse.join(&character_root).join(character).join(source_set);
if !source_dir.exists() {
println!("Skipping target-set texture staging: missing {}", source_dir.display());
continue;
}
let target_dir = output_warehouse.join(&character_root).join(character).join(target_set);
let copied = copy_retargeted_files(&source_dir, &target_dir, source_set, target_set, dry_run)?;
println!("Staged {copied} texture file(s) for {character}/{target_set}");
}
let decl_root = PathBuf::from("generated")
.join("decls")
.join("material2")
.join("models")
.join("customization")
.join("characters");
for character in ["doomslayer", "doomslayer_1p"] {
let target_decl_dir = output_warehouse.join(&decl_root).join(character).join(target_set);
let source_target_decl_dir = source_warehouse.join(&decl_root).join(character).join(target_set);
let source_decl_dir = source_warehouse.join(&decl_root).join(character).join(source_set);
if source_target_decl_dir.exists() {
let patch_target = if dry_run {
source_target_decl_dir.clone()
} else {
target_decl_dir.clone()
};
let patched = patch_decl_set_references(&patch_target, source_set, target_set, dry_run)?;
println!("Patched {patched} target-set decl file(s) for {character}/{target_set}");
continue;
}
if !source_decl_dir.exists() {
println!("Skipping target-set decl staging: missing {}", source_decl_dir.display());
continue;
}
let copied = copy_retargeted_decl_tree(&source_decl_dir, &target_decl_dir, source_set, target_set, dry_run)?;
println!("Staged {copied} decl file(s) for {character}/{target_set}");
}
Ok(())
}
fn copy_retargeted_files(
source_dir: &Path,
destination_dir: &Path,
source_set: &str,
target_set: &str,
dry_run: bool,
) -> Result<u64> {
let mut copied = 0_u64;
for entry in walkdir::WalkDir::new(source_dir).into_iter().filter_map(|entry| entry.ok()) {
if !entry.file_type().is_file() {
continue;
}
let relative = entry.path().strip_prefix(source_dir)?;
let target_relative = replace_set_path(relative, source_set, target_set);
let destination_file = destination_dir.join(target_relative);
if dry_run {
println!(
"[dry-run] copy target-set file {} -> {}",
entry.path().display(),
destination_file.display()
);
} else {
if let Some(parent) = destination_file.parent() {
fs::create_dir_all(parent)?;
}
fs::copy(entry.path(), &destination_file)?;
}
copied += 1;
}
Ok(copied)
}
fn patch_decl_set_references(decl_dir: &Path, source_set: &str, target_set: &str, dry_run: bool) -> Result<u64> {
let mut patched = 0_u64;
for entry in walkdir::WalkDir::new(decl_dir).into_iter().filter_map(|entry| entry.ok()) {
if !entry.file_type().is_file()
|| entry
.path()
.extension()
.map(|value| value.to_string_lossy().to_ascii_lowercase())
.as_deref()
!= Some("decl")
{
continue;
}
let content = fs::read_to_string(entry.path())?;
let patched_content = content.replace(source_set, target_set);
if patched_content == content {
continue;
}
if dry_run {
println!("[dry-run] patch target-set decl references in {}", entry.path().display());
} else {
fs::write(entry.path(), patched_content)?;
}
patched += 1;
}
Ok(patched)
}
fn copy_retargeted_decl_tree(
source_dir: &Path,
destination_dir: &Path,
source_set: &str,
target_set: &str,
dry_run: bool,
) -> Result<u64> {
let mut copied = 0_u64;
for entry in walkdir::WalkDir::new(source_dir).into_iter().filter_map(|entry| entry.ok()) {
if !entry.file_type().is_file() {
continue;
}
let relative = entry.path().strip_prefix(source_dir)?;
let target_relative = replace_set_path(relative, source_set, target_set);
let destination_file = destination_dir.join(target_relative);
if dry_run {
println!(
"[dry-run] copy target-set decl {} -> {}",
entry.path().display(),
destination_file.display()
);
} else {
if let Some(parent) = destination_file.parent() {
fs::create_dir_all(parent)?;
}
if entry
.path()
.extension()
.map(|value| value.to_string_lossy().to_ascii_lowercase())
.as_deref()
== Some("decl")
{
let content = fs::read_to_string(entry.path())?.replace(source_set, target_set);
fs::write(&destination_file, content)?;
} else {
fs::copy(entry.path(), &destination_file)?;
}
}
copied += 1;
}
Ok(copied)
}
fn replace_set_path(path: &Path, source_set: &str, target_set: &str) -> PathBuf {
path.components()
.map(|component| {
let value = component.as_os_str().to_string_lossy();
replace_set_tokens(value.as_ref(), target_set).replace(source_set, target_set)
})
.collect()
}
fn zip_directory(source_dir: &Path, output_file: &Path) -> Result<()> {
if let Some(parent) = output_file.parent() {
fs::create_dir_all(parent)?;
}
if output_file.exists() {
fs::remove_file(output_file)?;
}
let file = fs::File::create(output_file)?;
let mut archive = zip::ZipWriter::new(file);
let options = zip::write::SimpleFileOptions::default()
.compression_method(zip::CompressionMethod::Deflated);
for entry in walkdir::WalkDir::new(source_dir).into_iter().filter_map(|entry| entry.ok()) {
if !entry.file_type().is_file() {
continue;
}
let relative = entry.path().strip_prefix(source_dir)?;
archive.start_file(relative.to_string_lossy().replace('\\', "/"), options)?;
let contents = fs::read(entry.path())?;
archive.write_all(&contents)?;
}
archive.finish()?;
Ok(())
}
fn raise_missing_editables(
manifest_path: &Path,
source_set: &str,
target_set: &str,
missing_exports: &[RequiredExport],
) -> Result<()> {
write_required_editables_manifest(manifest_path, source_set, target_set, missing_exports)?;
let preview = missing_exports
.iter()
.take(5)
.map(|item| format!("- {}: {}", item.target, item.editable_texture))
.collect::<Vec<_>>()
.join("\n");
let extra = if missing_exports.len() > 5 {
format!("\n- ...and {} more", missing_exports.len() - 5)
} else {
String::new()
};
Err(DoomError::message(format!(
"Missing {} editable texture export(s) and no decode command was provided.\nWrote required export manifest: {}\nExport the listed BIM .tga sources to the matching editable paths, then rerun the command.\nAlternatively pass --decode-command-template, or keep built-in decode enabled for supported standalone BIM inputs.\nFirst missing exports:\n{}{}",
missing_exports.len(),
manifest_path.display(),
preview,
extra,
)))
}
fn write_required_editables_manifest(
manifest_path: &Path,
source_set: &str,
target_set: &str,
missing_exports: &[RequiredExport],
) -> Result<()> {
if let Some(parent) = manifest_path.parent() {
fs::create_dir_all(parent)?;
}
let payload = RequiredEditableManifest {
message: "Export each sourceTexture to editableTexture, or rerun with --decode-command-template if the built-in standalone BIM decode does not apply.".to_string(),
source_set: source_set.to_string(),
target_set: target_set.to_string(),
exports: missing_exports.to_vec(),
};
fs::write(manifest_path, serde_json::to_string_pretty(&payload)?)?;
Ok(())
}
fn assert_under_repo(ctx: &AppContext, path: &Path) -> Result<()> {
let resolved_path = ctx.repo().normalize_path(path)?;
let resolved_root = ctx.repo().normalize_path(ctx.repo().root())?;
if !resolved_path.starts_with(&resolved_root) {
return Err(DoomError::message(format!(
"Refusing to modify path outside repo: {}",
resolved_path.display()
)));
}
Ok(())
}
fn normalize_editable_extension(extension: &str) -> String {
let trimmed = extension.trim();
if trimmed.starts_with('.') {
trimmed.to_string()
} else {
format!(".{trimmed}")
}
}