use std::{
fs,
io::Write,
path::{Path, PathBuf},
process::Command,
};
use image::DynamicImage;
use serde::Serialize;
use crate::{
bim::{
encode_image_to_bim, load_editable_image, resolve_autoheckin_converter,
supports_builtin_decode,
},
cache::{
capture_file_stamp, hash_serialized, target_cache_paths, CraftCacheState, FileStamp,
TargetCachePaths,
},
context::AppContext,
error::{DoomError, Result},
logos::ensure_default_logo_assets,
manifest::{RequiredEditableManifest, RequiredExport},
placements::{
apply_texture_adjustment, composite_logo, load_config, Placement, TextureAdjustment,
},
sets::{find_set_tokens, 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_adjustments: Vec<TextureAdjustment>,
target_placements: Vec<Placement>,
}
#[derive(Debug, Serialize)]
struct PlacementFingerprint<'a> {
placement: &'a Placement,
logo: FileStamp,
}
#[derive(Debug, Serialize)]
struct TargetJobFingerprint<'a> {
version: u32,
target_name: &'a str,
relative_texture_path: String,
source_set: &'a str,
target_set: &'a str,
editable_extension: &'a str,
decode_command_template: &'a str,
encode_command_template: &'a str,
disable_builtin_decode: bool,
disable_builtin_encode: bool,
decode_from_source: bool,
source_bim: FileStamp,
editable_source: Option<FileStamp>,
adjustments: &'a [TextureAdjustment],
placements: Vec<PlacementFingerprint<'a>>,
}
#[derive(Debug, Clone, Copy)]
struct TargetFingerprintContext<'a> {
prepared_logo_dir: &'a Path,
source_set_name: &'a str,
target_set_name: &'a str,
normalized_extension: &'a str,
decode_command_template: &'a str,
encode_command_template: &'a str,
disable_builtin_decode: bool,
disable_builtin_encode: bool,
}
const TARGET_JOB_FINGERPRINT_VERSION: u32 = 1;
fn should_refresh_editable_from_source(
decode_command_template: &str,
disable_builtin_decode: bool,
source_bim_file: &Path,
) -> bool {
decode_command_template.is_empty()
&& !disable_builtin_decode
&& supports_builtin_decode(source_bim_file)
}
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<_>>();
let target_adjustments = placement_data
.adjustments
.iter()
.filter(|adjustment| adjustment.target == *target_name && adjustment.is_enabled())
.cloned()
.collect::<Vec<_>>();
if target_placements.is_empty() && target_adjustments.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_adjustments,
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 = should_refresh_editable_from_source(
&decode_command_template,
request.disable_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_adjustments,
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,
)?;
}
let cache_root = editable_source_root.join(".doom-eternal-craft-cache");
let cache_state_path = cache_root.join("state.json");
let mut cache_state = if request.dry_run {
CraftCacheState::default()
} else {
CraftCacheState::load(&cache_state_path)
};
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,
)?;
prune_output_warehouse(&output_warehouse, &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"))?;
}
let fingerprint_context = TargetFingerprintContext {
prepared_logo_dir: &prepared_logo_dir,
source_set_name: &source_set_name,
target_set_name: &target_set_name,
normalized_extension: &normalized_extension,
decode_command_template: &decode_command_template,
encode_command_template: &encode_command_template,
disable_builtin_decode: request.disable_builtin_decode,
disable_builtin_encode: request.disable_builtin_encode,
};
let mut rebuilt_targets = 0_u64;
let mut reused_targets = 0_u64;
for job in target_jobs {
if job.target_adjustments.is_empty() && job.target_placements.is_empty() {
println!(
"No enabled adjustments or 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
))
})?;
let edited_texture_file = editable_output_root
.join(&job.relative_texture_path)
.with_extension(normalized_extension.trim_start_matches('.'));
let output_bim_path = output_warehouse.join(&job.relative_texture_path);
let decode_from_source = should_refresh_editable_from_source(
&decode_command_template,
request.disable_builtin_decode,
&job.source_bim_file,
);
let writes_encoded_output =
!encode_command_template.is_empty() || !request.disable_builtin_encode;
let cache_paths = target_cache_paths(
&cache_root,
&job.target_name,
&normalized_extension,
&output_bim_path,
);
let mut job_fingerprint = if request.dry_run {
None
} else {
build_target_job_fingerprint(
&fingerprint_context,
&job,
&editable_source_file,
decode_from_source,
)?
};
if !request.dry_run
&& writes_encoded_output
&& cache_state.fingerprint_for(&job.target_name) == job_fingerprint.as_deref()
&& cache_paths.edited_texture.is_file()
&& cache_paths.encoded_texture.is_file()
{
restore_cached_target_outputs(&cache_paths, &edited_texture_file, &output_bim_path)?;
println!("Reused cached build for {}", job.target_name);
reused_targets += 1;
continue;
}
if decode_from_source {
crate::bim::decode_bim_to_path(
&job.source_bim_file,
&editable_source_file,
request.dry_run,
)?;
} else if !editable_source_file.exists() {
if decode_command_template.is_empty() {
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(),
}],
)?;
} 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 apply {} adjustment(s) and {} logo(s) on {}",
job.target_adjustments.len(),
job.target_placements.len(),
editable_source_file.display()
);
continue;
}
let mut composed = load_editable_image(&editable_source_file)?;
for adjustment in &job.target_adjustments {
apply_texture_adjustment(&mut composed, adjustment);
println!("Applied {} to {}", adjustment.id, job.target_name);
}
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);
}
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());
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()
);
}
rebuilt_targets += 1;
if !request.dry_run && writes_encoded_output && output_bim_path.is_file() {
update_cached_target_outputs(&cache_paths, &edited_texture_file, &output_bim_path)?;
if job_fingerprint.is_none() {
job_fingerprint = build_target_job_fingerprint(
&fingerprint_context,
&job,
&editable_source_file,
decode_from_source,
)?;
}
if let Some(job_fingerprint) = job_fingerprint {
cache_state.update(&job.target_name, job_fingerprint);
}
}
}
if !request.dry_run {
cache_state.save(&cache_state_path)?;
}
if rebuilt_targets > 0 || reused_targets > 0 {
println!(
"Craft summary: rebuilt {rebuilt_targets} target(s), reused {reused_targets} cached target(s)."
);
}
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 build_target_job_fingerprint(
fingerprint_context: &TargetFingerprintContext<'_>,
job: &TargetJob,
editable_source_file: &Path,
decode_from_source: bool,
) -> Result<Option<String>> {
let placements = job
.target_placements
.iter()
.map(|placement| {
let logo_path = fingerprint_context.prepared_logo_dir.join(&placement.logo);
Ok(PlacementFingerprint {
placement,
logo: capture_file_stamp(&logo_path)?,
})
})
.collect::<Result<Vec<_>>>()?;
let editable_source = if decode_from_source {
None
} else if editable_source_file.is_file() {
Some(capture_file_stamp(editable_source_file)?)
} else {
return Ok(None);
};
let fingerprint = TargetJobFingerprint {
version: TARGET_JOB_FINGERPRINT_VERSION,
target_name: &job.target_name,
relative_texture_path: job.relative_texture_path.to_string_lossy().to_string(),
source_set: fingerprint_context.source_set_name,
target_set: fingerprint_context.target_set_name,
editable_extension: fingerprint_context.normalized_extension,
decode_command_template: fingerprint_context.decode_command_template,
encode_command_template: fingerprint_context.encode_command_template,
disable_builtin_decode: fingerprint_context.disable_builtin_decode,
disable_builtin_encode: fingerprint_context.disable_builtin_encode,
decode_from_source,
source_bim: capture_file_stamp(&job.source_bim_file)?,
editable_source,
adjustments: &job.target_adjustments,
placements,
};
Ok(Some(hash_serialized(&fingerprint)?))
}
fn restore_cached_target_outputs(
cache_paths: &TargetCachePaths,
edited_texture_file: &Path,
output_bim_path: &Path,
) -> Result<()> {
if let Some(parent) = edited_texture_file.parent() {
fs::create_dir_all(parent)?;
}
if let Some(parent) = output_bim_path.parent() {
fs::create_dir_all(parent)?;
}
fs::copy(&cache_paths.edited_texture, edited_texture_file)?;
fs::copy(&cache_paths.encoded_texture, output_bim_path)?;
Ok(())
}
fn update_cached_target_outputs(
cache_paths: &TargetCachePaths,
edited_texture_file: &Path,
output_bim_path: &Path,
) -> Result<()> {
fs::create_dir_all(&cache_paths.root)?;
fs::copy(edited_texture_file, &cache_paths.edited_texture)?;
fs::copy(output_bim_path, &cache_paths.encoded_texture)?;
Ok(())
}
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 path_for_shell_command(path: &Path) -> String {
let rendered = path.display().to_string();
#[cfg(windows)]
{
rendered.replace('/', "\\")
}
#[cfg(not(windows))]
{
rendered
}
}
fn run_template_command(
command_template: &str,
source_file: &Path,
destination_file: &Path,
dry_run: bool,
) -> Result<()> {
let input = path_for_shell_command(source_file);
let output = path_for_shell_command(destination_file);
let command = command_template
.replace("{input}", &input)
.replace("{output}", &output)
.replace(
"{input_dir}",
&path_for_shell_command(
source_file
.parent()
.unwrap_or_else(|| Path::new("")),
),
)
.replace(
"{output_dir}",
&path_for_shell_command(
destination_file
.parent()
.unwrap_or_else(|| Path::new("")),
),
)
.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(());
}
if !source_file.is_file() {
return Err(DoomError::message(format!(
"Command source file does not exist: {} (shell path: {input})",
source_file.display()
)));
}
let shell_source = PathBuf::from(&input);
if !shell_source.is_file() {
return Err(DoomError::message(format!(
"Command source path is not visible to the shell: {} (original: {})",
shell_source.display(),
source_file.display()
)));
}
if let Some(parent) = destination_file.parent() {
fs::create_dir_all(parent)?;
}
let output = if cfg!(windows) {
Command::new("cmd").args(["/C", &command]).output()?
} else {
Command::new("sh").args(["-lc", &command]).output()?
};
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
let details = if stderr.is_empty() {
String::new()
} else {
format!("\n{stderr}")
};
return Err(DoomError::message(format!(
"Command failed ({}): {command}{details}",
output.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 prune_output_warehouse(output_warehouse: &Path, target_set: &str, dry_run: bool) -> Result<()> {
let mut removed = 0_u64;
for entry in walkdir::WalkDir::new(output_warehouse)
.contents_first(true)
.into_iter()
.filter_map(|entry| entry.ok())
{
let path = entry.path();
if entry.file_type().is_dir() {
if dry_run || path == output_warehouse {
continue;
}
if fs::read_dir(path)?.next().is_none() {
fs::remove_dir(path)?;
}
continue;
}
let relative_path = path.strip_prefix(output_warehouse).unwrap_or(path);
if should_keep_output_warehouse_file(relative_path, target_set) {
continue;
}
if dry_run {
println!("[dry-run] remove non-target output file {}", path.display());
} else {
fs::remove_file(path)?;
}
removed += 1;
}
if removed > 0 {
println!("Pruned {removed} non-target output file(s) from the staged warehouse");
}
Ok(())
}
fn should_keep_output_warehouse_file(path: &Path, target_set: &str) -> 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| set_name == target_set)
}
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}")
}
}
#[cfg(test)]
mod tests {
use std::{
fs,
path::{Path, PathBuf},
};
use tempfile::TempDir;
use super::{
apply_rtx_logo_pipeline, prune_output_warehouse, should_refresh_editable_from_source,
CraftRequest,
};
use crate::context::AppContext;
#[test]
fn supported_source_bim_prefers_fresh_decode() {
let repo_root = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("..")
.join("..");
let source_bim = repo_root.join(
"rtx-slayer/warehouse/models/customization/characters/doomslayer/set52/doomslayer_game_helmet_set52.tga",
);
assert!(
should_refresh_editable_from_source("", false, &source_bim),
"supported standalone BIMs should refresh from the source pack during craft"
);
}
#[test]
fn explicit_decode_command_disables_fresh_decode_preference() {
let repo_root = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("..")
.join("..");
let source_bim = repo_root.join(
"rtx-slayer/warehouse/models/customization/characters/doomslayer/set52/doomslayer_game_helmet_set52.tga",
);
assert!(
!should_refresh_editable_from_source("samuel --decode", false, &source_bim),
"custom decode commands should keep owning the editable export path"
);
}
#[test]
fn prune_output_warehouse_keeps_only_target_set_and_generic_files() {
let temp_dir = TempDir::new().expect("tempdir");
let warehouse_root = temp_dir
.path()
.join("xylex-blank-black-set17-pack")
.join("warehouse");
let target_decl = warehouse_root
.join("generated/decls/material2/models/customization/characters/doomslayer/set17")
.join("slayer_torso_set17.decl");
let source_decl = warehouse_root
.join("generated/decls/material2/models/customization/characters/doomslayer/set52")
.join("slayer_torso_set52.decl");
let stale_decl = warehouse_root
.join("generated/decls/material2/models/customization/characters/doomslayer/set16")
.join("slayer_torso_set16.decl");
let workspace_file = warehouse_root
.join("generated/decls/material2/models/customization/characters/doomslayer/set17")
.join("doom-eternal-slayer-skin-xylex.code-workspace");
let generic_file = warehouse_root.join("generated/decls/material2/shared/sample.decl");
for file in [
&target_decl,
&source_decl,
&stale_decl,
&workspace_file,
&generic_file,
] {
fs::create_dir_all(file.parent().expect("parent")).expect("create parent");
fs::write(file, "test").expect("write file");
}
prune_output_warehouse(&warehouse_root, "set17", false).expect("prune output");
assert!(target_decl.is_file(), "target-set decl should remain");
assert!(generic_file.is_file(), "generic decl should remain");
assert!(
!source_decl.exists(),
"source-set decl should be removed from the crafted payload"
);
assert!(
!stale_decl.exists(),
"stale non-target decl should be removed from the crafted payload"
);
assert!(
!workspace_file.exists(),
"workspace metadata should never ship in the crafted payload"
);
}
#[test]
fn craft_reuses_cached_target_outputs_when_inputs_do_not_change() {
let temp_dir = TempDir::new().expect("tempdir");
let repo_root = temp_dir.path().join("repo");
let live_repo_root = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("..")
.join("..");
create_repo_scaffold(&repo_root, &live_repo_root);
let output_mod_root = repo_root.join("dist").join("cache-test-pack");
let editable_root = repo_root.join("build").join("cache-editable");
let placement_config = repo_root.join("config").join("cache-test-placements.json");
let marker_path = repo_root.join("build").join("encode-hit.txt");
fs::write(
&placement_config,
serde_json::to_string_pretty(&serde_json::json!({
"targets": {
"third-person-helmet": "models/customization/characters/doomslayer/{set}/doomslayer_game_helmet_{set}.tga"
},
"adjustments": [
{
"id": "helmet-jet-black",
"target": "third-person-helmet",
"kind": "jetBlack",
"strength": 1.0,
"enabled": true
}
],
"placements": []
}))
.expect("serialize placement config"),
)
.expect("write placement config");
let ctx = AppContext::from_anchor(&repo_root);
let request = CraftRequest {
source_path: live_repo_root.join("rtx-slayer"),
output_mod_root: output_mod_root.clone(),
placement_config: placement_config.clone(),
logo_dir: repo_root.join("build").join("logos"),
editable_root: editable_root.clone(),
editable_extension: ".png".to_string(),
target_set: Some("set52".to_string()),
decode_command_template: String::new(),
encode_command_template: cache_test_encode_command(&marker_path),
converter_path: None,
disable_builtin_decode: false,
disable_builtin_encode: false,
zip_output: None,
dry_run: false,
};
apply_rtx_logo_pipeline(&ctx, request.clone()).expect("first craft");
assert_eq!(
cache_test_encode_hits(&marker_path),
1,
"first craft should execute the encode command exactly once"
);
apply_rtx_logo_pipeline(&ctx, request).expect("second craft");
assert_eq!(
cache_test_encode_hits(&marker_path),
1,
"second craft should restore the cached output instead of re-running encode"
);
assert!(editable_root
.join(".doom-eternal-craft-cache")
.join("state.json")
.is_file());
assert!(output_mod_root
.join("warehouse")
.join("models")
.join("customization")
.join("characters")
.join("doomslayer")
.join("set52")
.join("doomslayer_game_helmet_set52.tga")
.is_file());
}
fn create_repo_scaffold(repo_root: &Path, live_repo_root: &Path) {
fs::create_dir_all(repo_root.join("config")).expect("config dir");
fs::create_dir_all(repo_root.join("mod")).expect("mod dir");
fs::create_dir_all(repo_root.join("assets").join("source").join("logos"))
.expect("logos dir");
fs::write(
repo_root
.join("config")
.join("rtx-pack-logo-placements.json"),
"{}\n",
)
.expect("default placement config");
fs::write(repo_root.join("mod").join("EternalMod.json"), "{}\n").expect("mod metadata");
for logo_name in [
"XYLEX Group_xylex-group-white-logo-transparent_10.png",
"XYLEX_GROUP_ICON_TRANSPARENT_WHITE.png",
"XYLEX_Group_xylex_icon_black_zoom.png",
"XYLEX Group_xylex_0.png",
] {
fs::copy(
live_repo_root
.join("assets")
.join("source")
.join("logos")
.join(logo_name),
repo_root
.join("assets")
.join("source")
.join("logos")
.join(logo_name),
)
.expect("copy source logo");
}
}
fn cache_test_encode_hits(marker_path: &Path) -> usize {
fs::read_to_string(marker_path)
.expect("marker contents")
.chars()
.filter(|ch| *ch == 'x')
.count()
}
fn cache_test_encode_command(marker_path: &Path) -> String {
if cfg!(windows) {
let batch_path = marker_path
.parent()
.expect("marker parent")
.join("encode-once.cmd");
fs::create_dir_all(batch_path.parent().expect("batch parent"))
.expect("create batch parent");
let marker = super::path_for_shell_command(marker_path);
let batch = format!("@echo off\r\ncopy /Y \"%1\" \"%2\"\r\necho x>>\"{marker}\"\r\n");
fs::write(&batch_path, batch).expect("write encode batch");
format!(
"{} \"{{input}}\" \"{{output}}\"",
super::path_for_shell_command(&batch_path)
)
} else {
format!(
"cp \"{{input}}\" \"{{output}}\" && printf x >> \"{}\"",
marker_path.display()
)
}
}
}